diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..ffa1c6e --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,14 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "start", + "runtimeExecutable": "npm", + "runtimeArgs": [ + "run", + "start" + ], + "port": 3000 + } + ] +} diff --git a/README.md b/README.md index d6ddb76..5994ddf 100644 --- a/README.md +++ b/README.md @@ -30,10 +30,16 @@ Open it in [Expo Go](https://expo.dev/go). At the login screen enter your machin ``` src/app/ expo-router routes — thin wrappers, no logic - (auth)/login signed out + (auth)/ signed out: login, register (app)/ signed in: Home, Board, Search, Inbox, You + invite/[token] an invitation — readable either way, so guarded by neither + [teamKey] /ENG — sets the active team, then hands to Board src/api/ HTTP client, instance URL, generated client -src/auth/ session store and auth context +src/auth/ session store, auth context, deep-link capture +src/team/ team context, switcher, creation, invitations +src/board/ board and list views, filters, drag-to-move +src/issues/ priority and status metadata +src/offline/ connectivity, cache persistence, the mutation queue src/ui/ design tokens, theme, primitives, navigation chrome openapi/ the vendored API contract codegen reads ``` @@ -61,12 +67,25 @@ Tokens in `src/ui/tokens.ts` are transcribed from the web's `frontend/src/index. npm run lint && npm run typecheck && npm test ``` -The API integration suite is opt-in because it needs a live server: +Two integration suites are opt-in because they need a live server. They drive +the real client modules against it, and the onboarding one writes real rows +(fresh accounts and team keys per run): ```bash SOFTTRACK_LIVE_URL=http://localhost:8000 npm test ``` +The live suites between them register a handful of real accounts per run, and +registration is throttled per IP and charged even on success — so a few full runs +in a row will eventually 429. The counter lives in the API process, so +`docker compose restart backend && docker compose up -d` clears it without +touching the database. + +Note `experiments.typedRoutes` is a dev-time aid: the route union is written by +`expo start`, not by `expo export`, so outside the dev server every path +typechecks permissively. Paths built at runtime go through `href()` in +`src/ui/href.ts` rather than being cast at each call site. + ## Planning - Work is tracked on the [Mobile App project](https://github.com/orgs/soft-track/projects/3). @@ -79,10 +98,38 @@ The mockups reuse the web app's design tokens (brand `#6342db`, tinted neutrals, ## Status -The foundation is in: navigation shell, theming, the authenticated API client, and a working sign-in against any instance. Every other screen is a placeholder pointing at its issue. +Every tracked issue is implemented except push notifications, which have no +backend to talk to. Sign in to any instance, register, accept invitations, +manage teams, work a board, create and edit issues with sub-issues and links, +comment in GitHub-flavored markdown, attach photos and files, search, run cycles, +read the four reports, save views, administer a team or the whole instance, and +keep working with no connection. Known gaps, tracked rather than hidden: -- **Only the phone layout has been seen on a device.** The medium and expanded layouts are covered by tests against the mockup geometry, but verifying them for real needs a tablet or an Android emulator. -- **No aurora or glass blur yet.** The web's translucent panels are approximated with opaque surfaces; `backdrop-filter` has no React Native equivalent and per-surface blur is expensive on Android. -- **Team rows show no member counts.** `TeamRead` does not carry them, and fetching them today would mean one request per row. +- **Nothing has been seen on a device yet.** The suites cover the logic, the + mockup geometry and that the trees render, but no one has run this on + hardware. The drag gesture in particular is unverified by touch, which is why + every move is also reachable by tapping a card. The medium and expanded + layouts would need a tablet or an Android emulator to check for real. +- **Invitation links cannot be true universal links.** A `https://your-instance/invite/…` + link can only open the app if that exact domain is declared in the build, which + is impossible for arbitrary self-hosted hosts. `softtrack://invite/` + works, and falls back to asking you to sign in first, since a custom-scheme + link carries no instance. +- **Push notifications are not built, because the API has no way to register a + device.** Issue #10 anticipated this ("requires a backend addition for device + token registration + push delivery"). There is no `/devices` endpoint and + nothing APNs- or FCM-shaped anywhere in the schema, so the in-app inbox and + the unread badge are what ship; the badge polls once a minute while the app is + in front rather than pretending to be pushed. Real push also needs a dev build + and signing credentials, neither of which exists yet. +- **Member counts cost one request per team.** `TeamRead` carries no count, so + the teams list asks each team for its members. A `member_count` field upstream + would remove the fan-out and help the web too. +- **Offline is verified as logic, not as behaviour.** The queue, the conflict + rule and the cache are covered by tests, but nobody has watched a phone lose + signal, move a card and come back. That needs a device. +- **No aurora or glass blur yet.** The web's translucent panels are approximated + with opaque surfaces; `backdrop-filter` has no React Native equivalent and + per-surface blur is expensive on Android. diff --git a/app.json b/app.json index 200a10a..ba0b969 100644 --- a/app.json +++ b/app.json @@ -44,6 +44,14 @@ "backgroundColor": "#0c0b15" } } + ], + "expo-image", + [ + "expo-image-picker", + { + "photosPermission": "SoftTrack uses your photo library to attach screenshots to issues.", + "cameraPermission": "SoftTrack uses the camera to attach a photo to an issue." + } ] ], "experiments": { diff --git a/jest-setup.ts b/jest-setup.ts index b40e2f9..758549e 100644 --- a/jest-setup.ts +++ b/jest-setup.ts @@ -1,3 +1,7 @@ +// react-native-gesture-handler ships its own jest setup; without it any tree +// containing a GestureDetector throws on render. +import 'react-native-gesture-handler/jestSetup'; + jest.mock('expo-secure-store', () => { const store = new Map(); return { diff --git a/jest.config.js b/jest.config.js index 6337f20..9c3fdb0 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,6 +1,10 @@ module.exports = { preset: 'jest-expo', setupFilesAfterEnv: ['/jest-setup.ts'], + // react-native-worklets resolves to a .native implementation that needs the + // real native module. Its own resolver strips those extensions so reanimated + // is importable under jest. + resolver: 'react-native-worklets/jest/resolver.js', // The generated client is orval's output, not ours -- it is covered by the // api-contract check instead. testPathIgnorePatterns: ['/node_modules/', '/src/api/generated/'], diff --git a/package-lock.json b/package-lock.json index a8d55a6..4860f5d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,11 +9,19 @@ "version": "0.1.0", "dependencies": { "@react-native-async-storage/async-storage": "2.2.0", + "@react-native-community/netinfo": "12.0.1", + "@tanstack/query-async-storage-persister": "^5.102.8", "@tanstack/react-query": "^5.102.8", + "@tanstack/react-query-persist-client": "^5.102.8", "axios": "^1.20.0", "expo": "~57.0.22", "expo-blur": "~57.0.3", + "expo-clipboard": "~57.0.2", "expo-constants": "~57.0.18", + "expo-document-picker": "~57.0.2", + "expo-file-system": "~57.0.7", + "expo-image": "~57.0.5", + "expo-image-picker": "~57.0.17", "expo-linear-gradient": "~57.0.2", "expo-linking": "~57.0.10", "expo-router": "~57.0.21", @@ -25,6 +33,7 @@ "react-dom": "19.2.3", "react-native": "0.86.3", "react-native-gesture-handler": "~2.32.0", + "react-native-marked": "^8.3.0", "react-native-reanimated": "4.5.1", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "~4.26.0", @@ -3179,6 +3188,23 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@jsamr/counter-style": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@jsamr/counter-style/-/counter-style-2.0.2.tgz", + "integrity": "sha512-2mXudGVtSzVxWEA7B9jZLKjoXUeUFYDDtFrQoC0IFX9/Dszz4t1vZOmafi3JSw/FxD+udMQ+4TAFR8Qs0J3URQ==", + "license": "MIT" + }, + "node_modules/@jsamr/react-native-li": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@jsamr/react-native-li/-/react-native-li-2.3.1.tgz", + "integrity": "sha512-Qbo4NEj48SQ4k8FZJHFE2fgZDKTWaUGmVxcIQh3msg5JezLdTMMHuRRDYctfdHI6L0FZGObmEv3haWbIvmol8w==", + "license": "MIT", + "peerDependencies": { + "@jsamr/counter-style": "^1.0.0 || ^2.0.0", + "react": "*", + "react-native": "*" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz", @@ -3634,6 +3660,16 @@ "react-native": "^0.0.0-0 || >=0.65 <1.0" } }, + "node_modules/@react-native-community/netinfo": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@react-native-community/netinfo/-/netinfo-12.0.1.tgz", + "integrity": "sha512-P/3caXIvfYSJG8AWJVefukg+ZGRPs+M4Lp3pNJtgcTYoJxCjWrKQGNnCkj/Cz//zWa/avGed0i/wzm0T8vV2IQ==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": ">=0.59" + } + }, "node_modules/@react-native-masked-view/masked-view": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/@react-native-masked-view/masked-view/-/masked-view-0.3.2.tgz", @@ -4195,6 +4231,20 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@tanstack/query-async-storage-persister": { + "version": "5.102.8", + "resolved": "https://registry.npmjs.org/@tanstack/query-async-storage-persister/-/query-async-storage-persister-5.102.8.tgz", + "integrity": "sha512-9smqFJWyhRo+0l3NqaGFo6CQVr+3jpKOVZBQXI/LL+GoTIGhhxq1wTZegeI63dy0oResZx/EyazGzDjW8harbg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.102.8", + "@tanstack/query-persist-client-core": "5.102.8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/query-core": { "version": "5.102.8", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.102.8.tgz", @@ -4205,6 +4255,19 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tanstack/query-persist-client-core": { + "version": "5.102.8", + "resolved": "https://registry.npmjs.org/@tanstack/query-persist-client-core/-/query-persist-client-core-5.102.8.tgz", + "integrity": "sha512-t3v4/D6ejo/BrPzM5gm/AT3c4CWPCxFa+0cIjAeHAeanGPG6rCjFS1hZnPZH/rf55qe3qYYgx5U6iBZMY1PQjA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.102.8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/react-query": { "version": "5.102.8", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.102.8.tgz", @@ -4221,6 +4284,23 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-query-persist-client": { + "version": "5.102.8", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-persist-client/-/react-query-persist-client-5.102.8.tgz", + "integrity": "sha512-WOZReC+m9klekPtG7eeZ3txJeMA9lg2R0RytSG3yXcZgJItQAVIyoobJdbdIQtlpgcU2tSzsOEBtJacs2vlCOA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-persist-client-core": "5.102.8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.102.8", + "react": "^18 || ^19" + } + }, "node_modules/@testing-library/react-native": { "version": "14.0.1", "resolved": "https://registry.npmjs.org/@testing-library/react-native/-/react-native-14.0.1.tgz", @@ -8072,6 +8152,17 @@ "react-native": "*" } }, + "node_modules/expo-clipboard": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-57.0.2.tgz", + "integrity": "sha512-VB4Au8X/RbvJKUtJk+87vdtveWSNEa5RzY3ooeO6VNF7Rd49RbQXZOu3/TfEW9p7mU8TCfyTvjyCiAUwSoSJ4w==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-constants": { "version": "57.0.18", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.18.tgz", @@ -8085,6 +8176,25 @@ "react-native": "*" } }, + "node_modules/expo-document-picker": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-57.0.2.tgz", + "integrity": "sha512-vVBbBNg0piMJUIxjB+DR6JaZZpcSl90ZUoywLA/EgNHTfRkZW+9axP97UxM2a0Zm0W6WfH3cLzzGAEfP9OKGwg==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-file-system": { + "version": "57.0.7", + "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-57.0.7.tgz", + "integrity": "sha512-1vkZRadOsIputFDBcclqBa1pO3iXOO6iIsdqJipQWw4V5TSnWgmRTuOF7ro8IzrVue+PHbum++KCG3Tawem4cg==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo-font": { "version": "57.0.4", "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-57.0.4.tgz", @@ -8110,6 +8220,47 @@ "react-native": "*" } }, + "node_modules/expo-image": { + "version": "57.0.5", + "resolved": "https://registry.npmjs.org/expo-image/-/expo-image-57.0.5.tgz", + "integrity": "sha512-jOm5lofaIKXhQ7i0Hz3I5JuNULw/aghmpIPZXddwQcEtw8+wUhlozq3M7LbsOrcsBEapcG4jFs85DsfuFoz1tQ==", + "license": "MIT", + "dependencies": { + "sf-symbols-typescript": "^2.2.0" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*", + "react-native-web": "*" + }, + "peerDependenciesMeta": { + "react-native-web": { + "optional": true + } + } + }, + "node_modules/expo-image-loader": { + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-57.0.1.tgz", + "integrity": "sha512-uhrZKLT/cTl2mXyR28kPpVkS5O+PK9N1QA/07IFM4f5T4g0lTW1JHT3NEWwEEsGFldPmVX4j7LwUVVZxE+woug==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-image-picker": { + "version": "57.0.17", + "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-57.0.17.tgz", + "integrity": "sha512-qPcCkGSoqDLpmdXCLiLe09Je01gzBrwEgLd4SBI03Od/5t531vDTkzUmGemjm7vcsCTG2iqZMh2VZXBscAPO3g==", + "license": "MIT", + "dependencies": { + "expo-image-loader": "~57.0.1" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-linear-gradient": { "version": "57.0.2", "resolved": "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-57.0.2.tgz", @@ -8795,16 +8946,6 @@ "react-native": "*" } }, - "node_modules/expo/node_modules/expo-file-system": { - "version": "57.0.7", - "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-57.0.7.tgz", - "integrity": "sha512-1vkZRadOsIputFDBcclqBa1pO3iXOO6iIsdqJipQWw4V5TSnWgmRTuOF7ro8IzrVue+PHbum++KCG3Tawem4cg==", - "license": "MIT", - "peerDependencies": { - "expo": "*", - "react-native": "*" - } - }, "node_modules/expo/node_modules/expo-keep-awake": { "version": "57.0.2", "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-57.0.2.tgz", @@ -9388,6 +9529,12 @@ "node": ">=6" } }, + "node_modules/github-slugger": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", + "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", + "license": "ISC" + }, "node_modules/glob": { "version": "13.0.6", "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", @@ -9623,6 +9770,22 @@ "node": ">=12" } }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -12429,6 +12592,18 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/marked": { + "version": "18.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.12.tgz", + "integrity": "sha512-LEm4ga2YeI2T3GVHj9b0BaDPPk93LLTHMFeMyQbNIzPxc8vCI0y/scy0ZA6z6lXKyT9j9Nhl/OC6ZYKYGuFScA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/marky": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz", @@ -14678,6 +14853,29 @@ "react-native": "*" } }, + "node_modules/react-native-marked": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/react-native-marked/-/react-native-marked-8.3.0.tgz", + "integrity": "sha512-OepvLJ3IctdZMpGOZEGEAi+x2hDtigsufZQv9Jcs84DJl3wnR3ABmEWVTiABxcBYriKCI9YdxsIN7onwYuxmZg==", + "license": "MIT", + "dependencies": { + "@jsamr/counter-style": "2.0.2", + "@jsamr/react-native-li": "2.3.1", + "github-slugger": "2.0.0", + "html-entities": "2.6.0", + "marked": "18.0.12", + "react-native-reanimated-table": "0.0.2", + "svg-parser": "2.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": ">=16.8.6", + "react-native": ">=0.76.0", + "react-native-svg": ">=12.3.0" + } + }, "node_modules/react-native-reanimated": { "version": "4.5.1", "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.5.1.tgz", @@ -14693,6 +14891,16 @@ "react-native-worklets": "0.10.x" } }, + "node_modules/react-native-reanimated-table": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/react-native-reanimated-table/-/react-native-reanimated-table-0.0.2.tgz", + "integrity": "sha512-OeuqfU1AFEmHNTJlEOLWrV78JgAXnM0/ZrCm0Ab+9e5nwYJ+xab/UFXkNKz3Gyf08ZfLSNzwMQRjt3eZWPWoGA==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0", + "react-native": ">=0.6.0" + } + }, "node_modules/react-native-reanimated/node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -16062,6 +16270,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svg-parser": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.1.0.tgz", + "integrity": "sha512-bwLf38YmY+TDYHJw1Ex0Co8c4yeXuJAo8YnXGZrscxrvYoVVIvLeniEkV1Ks/54VteMnL4FtyN1+P+TZJdUPmQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", diff --git a/package.json b/package.json index 874e8ef..df5300e 100644 --- a/package.json +++ b/package.json @@ -15,11 +15,19 @@ }, "dependencies": { "@react-native-async-storage/async-storage": "2.2.0", + "@react-native-community/netinfo": "12.0.1", + "@tanstack/query-async-storage-persister": "^5.102.8", "@tanstack/react-query": "^5.102.8", + "@tanstack/react-query-persist-client": "^5.102.8", "axios": "^1.20.0", "expo": "~57.0.22", "expo-blur": "~57.0.3", + "expo-clipboard": "~57.0.2", "expo-constants": "~57.0.18", + "expo-document-picker": "~57.0.2", + "expo-file-system": "~57.0.7", + "expo-image": "~57.0.5", + "expo-image-picker": "~57.0.17", "expo-linear-gradient": "~57.0.2", "expo-linking": "~57.0.10", "expo-router": "~57.0.21", @@ -31,6 +39,7 @@ "react-dom": "19.2.3", "react-native": "0.86.3", "react-native-gesture-handler": "~2.32.0", + "react-native-marked": "^8.3.0", "react-native-reanimated": "4.5.1", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "~4.26.0", diff --git a/src/admin/site-admin-sheet.tsx b/src/admin/site-admin-sheet.tsx new file mode 100644 index 0000000..e9e8096 --- /dev/null +++ b/src/admin/site-admin-sheet.tsx @@ -0,0 +1,256 @@ +import { useState } from 'react'; +import { Alert as RNAlert, Pressable, View } from 'react-native'; +import { useQueryClient } from '@tanstack/react-query'; + +import { + resetPasswordAdminUsersUserIdResetPasswordPost, + updateUserAdminUsersUserIdPatch, + useListUsersAdminUsersGet, +} from '@/api/generated/endpoints/admin/admin'; +import type { AdminUserRead, UserMe } from '@/api/generated/models'; +import { errorDetail } from '@/api/errors'; +import { AppText, Alert, Avatar, Button, Card, Field } from '@/ui/primitives'; +import { Sheet } from '@/ui/sheet'; +import { useDebouncedValue } from '@/ui/use-debounced-value'; +import { useTokens } from '@/ui/theme'; + +/** + * The user directory, per `docs/design/mobile/154-site-admin.svg`. + * + * Site admins only; the server refuses everyone else, and the entry point is + * hidden for them so there is nothing to discover that would be denied. + * + * Every guard is the server's -- you cannot deactivate or demote yourself, and + * the last active site admin is protected. Those two are checked here as well, + * not to enforce them but so the action is explained rather than merely refused. + */ +const PAGE = 25; + +export function SiteAdminSheet({ + visible, + onClose, + user, +}: { + visible: boolean; + onClose: () => void; + user: UserMe | null; +}) { + const t = useTokens(); + const queryClient = useQueryClient(); + + const [query, setQuery] = useState(''); + const [limit, setLimit] = useState(PAGE); + const [error, setError] = useState(null); + const [resetting, setResetting] = useState(null); + + const settled = useDebouncedValue(query.trim(), 300); + const users = useListUsersAdminUsersGet( + { q: settled || undefined, limit, offset: 0 }, + { query: { enabled: visible } }, + ); + + const items = users.data?.items ?? []; + const total = users.data?.total ?? 0; + const activeAdmins = items.filter((row) => row.is_site_admin && row.is_active).length; + + async function run(action: () => Promise, fallback: string) { + setError(null); + try { + await action(); + await queryClient.invalidateQueries({ queryKey: ['/admin/users'] }); + } catch (err) { + setError(errorDetail(err, fallback)); + } + } + + function manage(row: AdminUserRead) { + const isSelf = row.id === user?.id; + // The page shows one page at a time, so this count is only a hint -- the + // server has the real answer and will refuse regardless. + const lastAdmin = row.is_site_admin && row.is_active && activeAdmins <= 1; + + const options: Parameters[2] = [ + { text: 'Cancel', style: 'cancel' }, + ]; + + if (!isSelf && !lastAdmin) { + options.push({ + text: row.is_site_admin ? 'Revoke site admin' : 'Grant site admin', + onPress: () => + void run( + () => + updateUserAdminUsersUserIdPatch(row.id, { + is_site_admin: !row.is_site_admin, + }).then(() => undefined), + 'Could not change that role.', + ), + }); + } + + options.push({ + text: 'Set a password', + onPress: () => setResetting(row), + }); + + if (!isSelf && !lastAdmin) { + options.push({ + text: row.is_active ? 'Deactivate' : 'Reactivate', + style: row.is_active ? 'destructive' : 'default', + onPress: () => + void run( + () => + updateUserAdminUsersUserIdPatch(row.id, { + is_active: !row.is_active, + }).then(() => undefined), + 'Could not change that account.', + ), + }); + } + + RNAlert.alert( + row.full_name, + isSelf + ? 'You cannot deactivate or demote your own account.' + : lastAdmin + ? 'This is the last active site admin — promote somebody else first.' + : row.email, + options, + ); + } + + if (!visible) return null; + + return ( + + + {error ? {error} : null} + + + + {items.map((row) => ( + manage(row)} + > + + + + + {row.full_name} + {row.id === user?.id ? ' (you)' : ''} + + + {row.email} · {row.team_count} team{row.team_count === 1 ? '' : 's'} + + + + + {row.is_site_admin ? ( + + Site admin + + ) : null} + {!row.is_active ? ( + + Deactivated + + ) : null} + + + + ))} + + {items.length === 0 && !users.isPending ? ( + No account matches that. + ) : null} + + {total > items.length ? ( + + ) : null} + + + {resetting ? ( + setResetting(null)} + onRun={run} + /> + ) : null} + + ); +} + +/** + * Setting a password for somebody locked out. + * + * The admin types it and passes it on out of band -- there is no email here to + * send a reset link through, which is exactly why this exists. + */ +function SetPasswordSheet({ + row, + onClose, + onRun, +}: { + row: AdminUserRead; + onClose: () => void; + onRun: (action: () => Promise, fallback: string) => Promise; +}) { + const [password, setPassword] = useState(''); + const [invalid, setInvalid] = useState(null); + + return ( + + + {invalid ? {invalid} : null} + + + + + + + ); +} diff --git a/src/api/__tests__/live-attachments.test.ts b/src/api/__tests__/live-attachments.test.ts new file mode 100644 index 0000000..54ef82b --- /dev/null +++ b/src/api/__tests__/live-attachments.test.ts @@ -0,0 +1,139 @@ +/** + * @jest-environment node + */ +import Axios from 'axios'; + +import { AXIOS_INSTANCE } from '@/api/client'; +import { loginAuthLoginPost } from '@/api/generated/endpoints/auth/auth'; +import { + deleteAttachmentAttachmentsAttachmentIdDelete, + listIssueAttachmentsIssuesIssueIdAttachmentsGet, +} from '@/api/generated/endpoints/attachments/attachments'; +import { + createIssueTeamsTeamIdIssuesPost, + deleteIssueIssuesIssueIdDelete, +} from '@/api/generated/endpoints/issues/issues'; +import { listMyTeamsTeamsGet } from '@/api/generated/endpoints/teams/teams'; +import type { AttachmentRead, TeamRead } from '@/api/generated/models'; +import { setInstanceUrl } from '@/api/instance'; +import { persistToken, getAccessToken } from '@/auth/session'; + +/** + * Attachments against a real instance. + * + * The acceptance criterion for #9 is that a photo taken on the phone lands on + * the issue and renders. A jest run has no camera, so a real PNG is built in + * memory and posted the same way the app posts one -- what is being checked is + * that multipart upload, the server's type rules, and authenticated reads of + * the bytes all line up. + */ +const LIVE_URL = process.env.SOFTTRACK_LIVE_URL; +const describeLive = LIVE_URL ? describe : describe.skip; + +const RUN = Date.now().toString(36).slice(-5); + +/** The smallest valid PNG: 1x1, and it really does start with the signature. */ +const PNG_1X1 = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + 'base64', +); + +async function postFile(issueId: number, filename: string, bytes: Buffer, type: string) { + const form = new FormData(); + form.append('file', new Blob([new Uint8Array(bytes)], { type }), filename); + const { data } = await AXIOS_INSTANCE.post( + `/issues/${issueId}/attachments`, + form, + ); + return data; +} + +describeLive('attachments against a live instance', () => { + let team: TeamRead; + let issueId: number; + + beforeAll(async () => { + await setInstanceUrl(LIVE_URL!); + const token = await loginAuthLoginPost({ + username: process.env.SOFTTRACK_LIVE_EMAIL ?? 'demo@softtrack.dev', + password: process.env.SOFTTRACK_LIVE_PASSWORD ?? 'password123', + }); + await persistToken(token.access_token); + team = (await listMyTeamsTeamsGet())[0]; + const issue = await createIssueTeamsTeamIdIssuesPost(team.id, { + title: `Attachments ${RUN}`, + }); + issueId = issue.id; + }); + + afterAll(async () => { + await deleteIssueIssuesIssueIdDelete(issueId).catch(() => undefined); + }); + + it('accepts an image and marks it as one', async () => { + const attachment = await postFile(issueId, `shot-${RUN}.png`, PNG_1X1, 'image/png'); + + expect(attachment.is_image).toBe(true); + expect(attachment.content_type).toBe('image/png'); + expect(attachment.size_bytes).toBe(PNG_1X1.length); + // is_image is what decides between rendering inline and offering to open. + expect(attachment.url).toContain(String(attachment.id)); + + const listed = await listIssueAttachmentsIssuesIssueIdAttachmentsGet(issueId); + expect(listed.map((a) => a.id)).toContain(attachment.id); + + await deleteAttachmentAttachmentsAttachmentIdDelete(attachment.id); + }); + + it('serves the bytes only with the bearer token', async () => { + const attachment = await postFile(issueId, `auth-${RUN}.png`, PNG_1X1, 'image/png'); + const base = LIVE_URL!; + const path = `/attachments/${attachment.id}/content`; + + // With the header the app sends, which is why an image source carries it + // rather than relying on a plain URL. + const ok = await AXIOS_INSTANCE.get(path, { responseType: 'arraybuffer' }); + expect(ok.status).toBe(200); + expect(Buffer.from(ok.data).subarray(0, 8)).toEqual(PNG_1X1.subarray(0, 8)); + + // And without it: a bare client, the way a plain would ask. + expect(getAccessToken()).not.toBeNull(); + const anonymous = Axios.create({ baseURL: base }); + await expect(anonymous.get(path)).rejects.toMatchObject({ + response: { status: 401 }, + }); + + await deleteAttachmentAttachmentsAttachmentIdDelete(attachment.id); + }); + + it('refuses an SVG, which is why the client refuses it first', async () => { + // 415 for a type that is not on the list at all. + await expect( + postFile(issueId, `diagram-${RUN}.svg`, Buffer.from(''), 'image/svg+xml'), + ).rejects.toMatchObject({ response: { status: 415 } }); + }); + + it('refuses a file whose bytes are not the image its name claims', async () => { + // "Screenshot of the bug" is the whole point, so a mislabelled file fails + // now with a clear error rather than later as a broken image. + // 422 rather than 415: the extension is allowed, the bytes are not what it + // claims, and the server says exactly that. + await expect( + postFile(issueId, `fake-${RUN}.png`, Buffer.from('not a png at all'), 'image/png'), + ).rejects.toMatchObject({ + response: { status: 422, data: { detail: expect.stringContaining('not a valid PNG') } }, + }); + }); + + it('takes a non-image and does not mark it as one', async () => { + const attachment = await postFile( + issueId, + `notes-${RUN}.txt`, + Buffer.from('a log line'), + 'text/plain', + ); + expect(attachment.is_image).toBe(false); + expect(attachment.content_type).toBe('text/plain'); + await deleteAttachmentAttachmentsAttachmentIdDelete(attachment.id); + }); +}); diff --git a/src/api/__tests__/live-board.test.ts b/src/api/__tests__/live-board.test.ts new file mode 100644 index 0000000..bfcca18 --- /dev/null +++ b/src/api/__tests__/live-board.test.ts @@ -0,0 +1,146 @@ +/** + * @jest-environment node + */ +import { loginAuthLoginPost } from '@/api/generated/endpoints/auth/auth'; +import { + getEstimateSummaryTeamsTeamIdEstimatesGet, + listIssuesTeamsTeamIdIssuesGet, + updateIssueIssuesIssueIdPatch, +} from '@/api/generated/endpoints/issues/issues'; +import { listStatusesTeamsTeamIdStatusesGet } from '@/api/generated/endpoints/statuses/statuses'; +import { listMyTeamsTeamsGet } from '@/api/generated/endpoints/teams/teams'; +import type { StatusRead, TeamRead } from '@/api/generated/models'; +import { toQueryParams } from '@/board/filters'; +import { setInstanceUrl } from '@/api/instance'; +import { persistToken } from '@/auth/session'; + +/** + * The board's data path against a real instance: that the six filters narrow + * server-side, and that moving a card actually moves it. + * + * cd ../soft-track && docker compose up -d + * SOFTTRACK_LIVE_URL=http://localhost:8000 npm test + */ +const LIVE_URL = process.env.SOFTTRACK_LIVE_URL; +const describeLive = LIVE_URL ? describe : describe.skip; + +describeLive('the board against a live instance', () => { + let team: TeamRead; + let statuses: StatusRead[]; + + beforeAll(async () => { + await setInstanceUrl(LIVE_URL!); + const token = await loginAuthLoginPost({ + username: process.env.SOFTTRACK_LIVE_EMAIL ?? 'demo@softtrack.dev', + password: process.env.SOFTTRACK_LIVE_PASSWORD ?? 'password123', + }); + await persistToken(token.access_token); + + team = (await listMyTeamsTeamsGet())[0]; + statuses = await listStatusesTeamsTeamIdStatusesGet(team.id); + }); + + it('gets the team its own columns, not a fixed list', () => { + expect(statuses.length).toBeGreaterThan(0); + for (const status of statuses) { + expect(status.color).toMatch(/^#[0-9a-f]{6}$/i); + expect(['backlog', 'unstarted', 'started', 'done', 'cancelled']).toContain( + status.category, + ); + } + // Rendered left to right in this order. + const positions = statuses.map((s) => s.position); + expect([...positions].sort((a, b) => a - b)).toEqual(positions); + }); + + it('returns a page whose items carry what a card renders', async () => { + const page = await listIssuesTeamsTeamIdIssuesGet(team.id, { limit: 200 }); + expect(page.items.length).toBeGreaterThan(0); + + for (const issue of page.items) { + expect(issue.identifier).toContain(team.key); + expect(issue.status).toHaveProperty('id'); + // The blocked marker is read straight off the issue rather than fetched + // per card. + expect(typeof issue.blocked_by_count).toBe('number'); + expect(typeof issue.child_count).toBe('number'); + } + + // Every issue belongs to a column the board actually draws. + const statusIds = new Set(statuses.map((s) => s.id)); + for (const issue of page.items) expect(statusIds.has(issue.status.id)).toBe(true); + }); + + it('keys the point rollup by status id, as the column headers read it', async () => { + const estimates = await getEstimateSummaryTeamsTeamIdEstimatesGet(team.id); + for (const [key, load] of Object.entries(estimates.by_status)) { + // String keys: `by_status[String(status.id)]` is not incidental. + expect(Number.isInteger(Number(key))).toBe(true); + expect(typeof load.points).toBe('number'); + expect(typeof load.unestimated_count).toBe('number'); + } + }); + + it('narrows by status on the server', async () => { + const status = statuses[0]; + const page = await listIssuesTeamsTeamIdIssuesGet(team.id, { + ...toQueryParams({ + statusId: status.id, + priority: null, + assignee: null, + labelId: null, + projectId: null, + cycleId: null, + }), + limit: 200, + }); + for (const issue of page.items) expect(issue.status.id).toBe(status.id); + }); + + it('narrows by priority on the server', async () => { + const all = await listIssuesTeamsTeamIdIssuesGet(team.id, { limit: 200 }); + const priority = all.items.find((i) => i.priority !== 'no_priority')?.priority; + if (!priority) return; // Nothing prioritised in this dataset. + + const page = await listIssuesTeamsTeamIdIssuesGet(team.id, { + priority, + limit: 200, + }); + expect(page.items.length).toBeGreaterThan(0); + for (const issue of page.items) expect(issue.priority).toBe(priority); + }); + + it('treats unassigned as its own answer', async () => { + // The flag overrides assignee_id server-side, which is why the client maps + // "unassigned" onto it rather than onto a sentinel id. + const page = await listIssuesTeamsTeamIdIssuesGet(team.id, { + unassigned: true, + limit: 200, + }); + for (const issue of page.items) expect(issue.assignee ?? null).toBeNull(); + }); + + it('moves a card between columns and keeps it there', async () => { + const page = await listIssuesTeamsTeamIdIssuesGet(team.id, { limit: 200 }); + const issue = page.items[0]; + const target = statuses.find((s) => s.id !== issue.status.id)!; + const original = issue.status.id; + + const moved = await updateIssueIssuesIssueIdPatch(issue.id, { status_id: target.id }); + expect(moved.status.id).toBe(target.id); + + // The board re-reads the list after a move, so that is what has to agree. + const after = await listIssuesTeamsTeamIdIssuesGet(team.id, { limit: 200 }); + expect(after.items.find((i) => i.id === issue.id)?.status.id).toBe(target.id); + + // And the filtered view has to agree too -- the acceptance criterion is + // that board and list stay consistent with the filters. + const inTarget = await listIssuesTeamsTeamIdIssuesGet(team.id, { + status_id: target.id, + limit: 200, + }); + expect(inTarget.items.map((i) => i.id)).toContain(issue.id); + + await updateIssueIssuesIssueIdPatch(issue.id, { status_id: original }); + }); +}); diff --git a/src/api/__tests__/live-comments.test.ts b/src/api/__tests__/live-comments.test.ts new file mode 100644 index 0000000..1589376 --- /dev/null +++ b/src/api/__tests__/live-comments.test.ts @@ -0,0 +1,147 @@ +/** + * @jest-environment node + */ +import { loginAuthLoginPost } from '@/api/generated/endpoints/auth/auth'; +import { + createCommentIssuesIssueIdCommentsPost, + listCommentsIssuesIssueIdCommentsGet, +} from '@/api/generated/endpoints/comments/comments'; +import { + createIssueTeamsTeamIdIssuesPost, + deleteIssueIssuesIssueIdDelete, + getIssueIssuesIssueIdGet, + updateIssueIssuesIssueIdPatch, +} from '@/api/generated/endpoints/issues/issues'; +import { + getWatchStateIssuesIssueIdWatchGet, + setWatchStateIssuesIssueIdWatchPut, +} from '@/api/generated/endpoints/notifications/notifications'; +import { + listMyTeamsTeamsGet, + listTeamMembersTeamsTeamIdMembersGet, +} from '@/api/generated/endpoints/teams/teams'; +import type { TeamRead } from '@/api/generated/models'; +import { setInstanceUrl } from '@/api/instance'; +import { persistToken } from '@/auth/session'; +import { toggleTaskAtIndex } from '@/markdown/tasks'; + +/** + * Comments and markdown against a real instance. + * + * The acceptance criterion for #8 is that a comment written on mobile renders + * identically on web, which means the *source* has to survive the round trip + * untouched -- so these assert the bytes, not a rendering. + */ +const LIVE_URL = process.env.SOFTTRACK_LIVE_URL; +const describeLive = LIVE_URL ? describe : describe.skip; + +const RUN = Date.now().toString(36).slice(-5); + +describeLive('comments against a live instance', () => { + let team: TeamRead; + const created: number[] = []; + + beforeAll(async () => { + await setInstanceUrl(LIVE_URL!); + const token = await loginAuthLoginPost({ + username: process.env.SOFTTRACK_LIVE_EMAIL ?? 'demo@softtrack.dev', + password: process.env.SOFTTRACK_LIVE_PASSWORD ?? 'password123', + }); + await persistToken(token.access_token); + team = (await listMyTeamsTeamsGet())[0]; + }); + + afterAll(async () => { + for (const id of created) { + await deleteIssueIssuesIssueIdDelete(id).catch(() => undefined); + } + }); + + async function newIssue(title: string, description?: string) { + const issue = await createIssueTeamsTeamIdIssuesPost(team.id, { title, description }); + created.push(issue.id); + return issue; + } + + it('stores comment markdown byte for byte', async () => { + const issue = await newIssue(`Comment ${RUN}`); + // Everything GFM that the issue names, including the shapes a parser would + // normalise if the client round-tripped through one. + const body = [ + '## Heading', + '', + 'Some **bold**, ~~struck~~ and `code`.', + '', + '| a | b |', + '| - | - |', + '| 1 | 2 |', + '', + '```sh', + 'echo "@demo is not a mention in here"', + '```', + '', + '- [ ] not done', + '- [x] done', + ].join('\n'); + + const comment = await createCommentIssuesIssueIdCommentsPost(issue.id, { body }); + expect(comment.body).toBe(body); + + const page = await listCommentsIssuesIssueIdCommentsGet(issue.id, { limit: 20 }); + expect(page.items.map((c) => c.body)).toContain(body); + }); + + it('paginates, which is what the "show more" control pages through', async () => { + const issue = await newIssue(`Paging ${RUN}`); + for (let n = 0; n < 3; n += 1) { + await createCommentIssuesIssueIdCommentsPost(issue.id, { body: `comment ${n}` }); + } + + const firstPage = await listCommentsIssuesIssueIdCommentsGet(issue.id, { + limit: 2, + offset: 0, + }); + expect(firstPage.items).toHaveLength(2); + expect(firstPage.total).toBe(3); + + const wider = await listCommentsIssuesIssueIdCommentsGet(issue.id, { limit: 20 }); + expect(wider.items).toHaveLength(3); + }); + + it('watches an issue you opened, and does not resubscribe you after you opt out', async () => { + // auto_watch only ever adds a row when none exists + // (`lib_softtrack/notifications.py:72`): someone who unwatched an issue and + // then answered a question on it meant to answer the question, not to + // resubscribe. This is what "the same auto-watch semantics as web" means, + // and it is why the toggle reads the server's state instead of predicting + // it after a comment. + const issue = await newIssue(`Autowatch ${RUN}`); + expect((await getWatchStateIssuesIssueIdWatchGet(issue.id)).watching).toBe(true); + + await setWatchStateIssuesIssueIdWatchPut(issue.id, { watching: false }); + await createCommentIssuesIssueIdCommentsPost(issue.id, { body: 'answering a question' }); + + expect((await getWatchStateIssuesIssueIdWatchGet(issue.id)).watching).toBe(false); + }); + + it('persists a ticked checkbox as a one-character edit', async () => { + const description = ['- [ ] first', '- [ ] second'].join('\n'); + const issue = await newIssue(`Tasks ${RUN}`, description); + + const toggled = toggleTaskAtIndex(issue.description ?? '', 1)!; + await updateIssueIssuesIssueIdPatch(issue.id, { description: toggled }); + + const refreshed = await getIssueIssuesIssueIdGet(issue.id); + expect(refreshed.description).toBe('- [ ] first\n- [x] second'); + }); + + it('restricts mentionable people to the issue team', async () => { + // The `@` menu offers exactly these, because they are who the API will + // notify. + const members = await listTeamMembersTeamsTeamIdMembersGet(team.id); + expect(members.length).toBeGreaterThan(0); + for (const member of members) { + expect(member.user.username).toEqual(expect.any(String)); + } + }); +}); diff --git a/src/api/__tests__/live-cycles.test.ts b/src/api/__tests__/live-cycles.test.ts new file mode 100644 index 0000000..bb34535 --- /dev/null +++ b/src/api/__tests__/live-cycles.test.ts @@ -0,0 +1,173 @@ +/** + * @jest-environment node + */ +import { loginAuthLoginPost } from '@/api/generated/endpoints/auth/auth'; +import { + completeCycleCyclesCycleIdCompletePost, + createCycleTeamsTeamIdCyclesPost, + deleteCycleCyclesCycleIdDelete, + getCycleCyclesCycleIdGet, + listCyclesTeamsTeamIdCyclesGet, + startCycleCyclesCycleIdStartPost, +} from '@/api/generated/endpoints/cycles/cycles'; +import { + createIssueTeamsTeamIdIssuesPost, + deleteIssueIssuesIssueIdDelete, + getIssueIssuesIssueIdGet, + listIssuesTeamsTeamIdIssuesGet, +} from '@/api/generated/endpoints/issues/issues'; +import { listStatusesTeamsTeamIdStatusesGet } from '@/api/generated/endpoints/statuses/statuses'; +import { listMyTeamsTeamsGet } from '@/api/generated/endpoints/teams/teams'; +import type { TeamRead } from '@/api/generated/models'; +import { setInstanceUrl } from '@/api/instance'; +import { persistToken } from '@/auth/session'; + +/** + * Cycles against a real instance. + * + * The acceptance criterion for #12 is that a cycle can be run start to finish + * from mobile, so this does exactly that: create, start, complete, and check + * where the unfinished work went. + */ +const LIVE_URL = process.env.SOFTTRACK_LIVE_URL; +const describeLive = LIVE_URL ? describe : describe.skip; + +const RUN = Date.now().toString(36).slice(-5); + +/** Fixed dates: a test that depends on today is a test that breaks on a Monday. */ +const FROM = '2031-01-06'; +const TO = '2031-01-20'; +const NEXT_FROM = '2031-01-21'; +const NEXT_TO = '2031-02-03'; + +describeLive('cycles against a live instance', () => { + let team: TeamRead; + const cycles: number[] = []; + const issues: number[] = []; + + beforeAll(async () => { + await setInstanceUrl(LIVE_URL!); + const token = await loginAuthLoginPost({ + username: process.env.SOFTTRACK_LIVE_EMAIL ?? 'demo@softtrack.dev', + password: process.env.SOFTTRACK_LIVE_PASSWORD ?? 'password123', + }); + await persistToken(token.access_token); + team = (await listMyTeamsTeamsGet())[0]; + }); + + afterAll(async () => { + for (const id of issues) await deleteIssueIssuesIssueIdDelete(id).catch(() => undefined); + for (const id of cycles) await deleteCycleCyclesCycleIdDelete(id).catch(() => undefined); + }); + + it('numbers a cycle when it is given no name', async () => { + const cycle = await createCycleTeamsTeamIdCyclesPost(team.id, { + starts_at: FROM, + ends_at: TO, + }); + cycles.push(cycle.id); + + expect(cycle.name ?? null).toBeNull(); + // display_name is always present, which is why every label reads that. + expect(cycle.display_name).toContain(String(cycle.number)); + expect(cycle.state).toBe('upcoming'); + }); + + it('reports progress including work that was never sized', async () => { + const cycle = await createCycleTeamsTeamIdCyclesPost(team.id, { + name: `Progress ${RUN}`, + starts_at: FROM, + ends_at: TO, + }); + cycles.push(cycle.id); + + const sized = await createIssueTeamsTeamIdIssuesPost(team.id, { + title: `Sized ${RUN}`, + cycle_id: cycle.id, + estimate: 3, + }); + const unsized = await createIssueTeamsTeamIdIssuesPost(team.id, { + title: `Unsized ${RUN}`, + cycle_id: cycle.id, + }); + issues.push(sized.id, unsized.id); + + const refreshed = await getCycleCyclesCycleIdGet(cycle.id); + expect(refreshed.progress.issues_total).toBe(2); + expect(refreshed.progress.points_total).toBe(3); + // The count that stops a points total reading as the whole story. + expect(refreshed.progress.issues_unestimated).toBe(1); + }); + + it('runs a cycle start to finish and says where unfinished work went', async () => { + const statuses = await listStatusesTeamsTeamIdStatusesGet(team.id); + const done = statuses.find((s) => s.category === 'done'); + + const current = await createCycleTeamsTeamIdCyclesPost(team.id, { + name: `Run ${RUN}`, + starts_at: FROM, + ends_at: TO, + }); + const next = await createCycleTeamsTeamIdCyclesPost(team.id, { + name: `Next ${RUN}`, + starts_at: NEXT_FROM, + ends_at: NEXT_TO, + }); + cycles.push(current.id, next.id); + + const finished = await createIssueTeamsTeamIdIssuesPost(team.id, { + title: `Finished ${RUN}`, + cycle_id: current.id, + status_id: done?.id, + }); + const unfinished = await createIssueTeamsTeamIdIssuesPost(team.id, { + title: `Unfinished ${RUN}`, + cycle_id: current.id, + }); + issues.push(finished.id, unfinished.id); + + const started = await startCycleCyclesCycleIdStartPost(current.id); + expect(started.state).toBe('active'); + + const completion = await completeCycleCyclesCycleIdCompletePost(current.id); + expect(completion.cycle.state).toBe('completed'); + // One issue was not done, and the app reports this count because + // "completed" otherwise reads as though it had been finished. + expect(completion.carried_over).toBe(1); + + const moved = await getIssueIssuesIssueIdGet(unfinished.id); + if (completion.carried_into_cycle_id) { + expect(moved.cycle_id).toBe(completion.carried_into_cycle_id); + } else { + expect(moved.cycle_id ?? null).toBeNull(); + } + + // The finished one stays where it was. + expect((await getIssueIssuesIssueIdGet(finished.id)).cycle_id).toBe(current.id); + }); + + it('filters the board by cycle, which is the link between the two', async () => { + const cycle = await createCycleTeamsTeamIdCyclesPost(team.id, { + name: `Filter ${RUN}`, + starts_at: FROM, + ends_at: TO, + }); + cycles.push(cycle.id); + const issue = await createIssueTeamsTeamIdIssuesPost(team.id, { + title: `In cycle ${RUN}`, + cycle_id: cycle.id, + }); + issues.push(issue.id); + + const page = await listIssuesTeamsTeamIdIssuesGet(team.id, { + cycle_id: cycle.id, + limit: 200, + }); + expect(page.items.map((i) => i.id)).toEqual([issue.id]); + }); + + it('lists cycles for the team', async () => { + const listed = await listCyclesTeamsTeamIdCyclesGet(team.id); + for (const id of cycles) expect(listed.map((c) => c.id)).toContain(id); + }); +}); diff --git a/src/api/__tests__/live-issue-detail.test.ts b/src/api/__tests__/live-issue-detail.test.ts new file mode 100644 index 0000000..b5a3a83 --- /dev/null +++ b/src/api/__tests__/live-issue-detail.test.ts @@ -0,0 +1,165 @@ +/** + * @jest-environment node + */ +import { loginAuthLoginPost } from '@/api/generated/endpoints/auth/auth'; +import { + createIssueLinkIssuesIssueIdLinksPost, + createIssueTeamsTeamIdIssuesPost, + deleteIssueIssuesIssueIdDelete, + deleteIssueLinkIssuesIssueIdLinksLinkIdDelete, + getIssueIssuesIssueIdGet, + listIssueLinksIssuesIssueIdLinksGet, + listIssuesTeamsTeamIdIssuesGet, + updateIssueIssuesIssueIdPatch, +} from '@/api/generated/endpoints/issues/issues'; +import { listCodeLinksIssuesIssueIdCodeLinksGet } from '@/api/generated/endpoints/integrations/integrations'; +import { + getWatchStateIssuesIssueIdWatchGet, + setWatchStateIssuesIssueIdWatchPut, +} from '@/api/generated/endpoints/notifications/notifications'; +import { listStatusesTeamsTeamIdStatusesGet } from '@/api/generated/endpoints/statuses/statuses'; +import { listMyTeamsTeamsGet } from '@/api/generated/endpoints/teams/teams'; +import type { StatusRead, TeamRead } from '@/api/generated/models'; +import { setInstanceUrl } from '@/api/instance'; +import { persistToken } from '@/auth/session'; + +/** + * The issue detail sections against a real instance: sub-issues, links and + * their derived inverses, watch, and code links. + */ +const LIVE_URL = process.env.SOFTTRACK_LIVE_URL; +const describeLive = LIVE_URL ? describe : describe.skip; + +const RUN = Date.now().toString(36).slice(-5); + +describeLive('issue detail against a live instance', () => { + let team: TeamRead; + let statuses: StatusRead[]; + const created: number[] = []; + + async function newIssue(title: string, parentId?: number) { + const issue = await createIssueTeamsTeamIdIssuesPost(team.id, { + title, + parent_id: parentId, + }); + created.push(issue.id); + return issue; + } + + beforeAll(async () => { + await setInstanceUrl(LIVE_URL!); + const token = await loginAuthLoginPost({ + username: process.env.SOFTTRACK_LIVE_EMAIL ?? 'demo@softtrack.dev', + password: process.env.SOFTTRACK_LIVE_PASSWORD ?? 'password123', + }); + await persistToken(token.access_token); + team = (await listMyTeamsTeamsGet())[0]; + statuses = await listStatusesTeamsTeamIdStatusesGet(team.id); + }); + + afterAll(async () => { + // Children first: deleting a parent with children may be refused. + for (const id of [...created].reverse()) { + await deleteIssueIssuesIssueIdDelete(id).catch(() => undefined); + } + }); + + it('lists children by parent and counts progress server-side', async () => { + const parent = await newIssue(`Parent ${RUN}`); + const childA = await newIssue(`Child A ${RUN}`, parent.id); + await newIssue(`Child B ${RUN}`, parent.id); + + // This is the query the sub-issues section runs. + const children = await listIssuesTeamsTeamIdIssuesGet(team.id, { + parent_id: parent.id, + limit: 200, + }); + expect(children.items.map((i) => i.id).sort()).toEqual( + [childA.id, created[created.length - 1]].sort(), + ); + + let refreshed = await getIssueIssuesIssueIdGet(parent.id); + expect(refreshed.child_count).toBe(2); + expect(refreshed.completed_child_count).toBe(0); + + const done = statuses.find((s) => s.category === 'done'); + if (done) { + await updateIssueIssuesIssueIdPatch(childA.id, { status_id: done.id }); + refreshed = await getIssueIssuesIssueIdGet(parent.id); + // Counted by the server, which is why the section reads these fields + // rather than tallying the child list itself. + expect(refreshed.completed_child_count).toBe(1); + } + + const cancelled = statuses.find((s) => s.category === 'cancelled'); + if (cancelled) { + await updateIssueIssuesIssueIdPatch(childA.id, { status_id: cancelled.id }); + refreshed = await getIssueIssuesIssueIdGet(parent.id); + // A cancelled child leaves the denominator, rather than counting as + // outstanding work forever. + expect(refreshed.child_count).toBe(1); + } + }); + + it('derives the inverse of a link on the other issue', async () => { + const blocker = await newIssue(`Blocker ${RUN}`); + const blocked = await newIssue(`Blocked ${RUN}`); + + await createIssueLinkIssuesIssueIdLinksPost(blocker.id, { + target_id: blocked.id, + type: 'blocks', + }); + + const fromBlocker = await listIssueLinksIssuesIssueIdLinksGet(blocker.id); + expect((fromBlocker.blocks ?? []).map((l) => l.issue.id)).toContain(blocked.id); + + // The inverse is not stored by the client -- it comes back on the other + // side, which is why only three types are offered when adding one. + const fromBlocked = await listIssueLinksIssuesIssueIdLinksGet(blocked.id); + expect((fromBlocked.blocked_by ?? []).map((l) => l.issue.id)).toContain(blocker.id); + + // And it is what drives the card's blocked marker. + const refreshed = await getIssueIssuesIssueIdGet(blocked.id); + expect(refreshed.blocked_by_count).toBe(1); + }); + + it('removes a link from both sides', async () => { + const a = await newIssue(`Relates A ${RUN}`); + const b = await newIssue(`Relates B ${RUN}`); + + await createIssueLinkIssuesIssueIdLinksPost(a.id, { + target_id: b.id, + type: 'relates_to', + }); + const links = await listIssueLinksIssuesIssueIdLinksGet(a.id); + const link = (links.relates_to ?? [])[0]; + expect(link).toBeDefined(); + + await deleteIssueLinkIssuesIssueIdLinksLinkIdDelete(a.id, link.id); + + expect((await listIssueLinksIssuesIssueIdLinksGet(a.id)).relates_to ?? []).toEqual([]); + expect((await listIssueLinksIssuesIssueIdLinksGet(b.id)).relates_to ?? []).toEqual([]); + }); + + it('toggles watch and reports the current state', async () => { + const issue = await newIssue(`Watch ${RUN}`); + + const before = await getWatchStateIssuesIssueIdWatchGet(issue.id); + expect(typeof before.watching).toBe('boolean'); + + await setWatchStateIssuesIssueIdWatchPut(issue.id, { watching: !before.watching }); + const after = await getWatchStateIssuesIssueIdWatchGet(issue.id); + expect(after.watching).toBe(!before.watching); + }); + + it('returns the development buckets the section renders', async () => { + const issue = await newIssue(`Code ${RUN}`); + const codeLinks = await listCodeLinksIssuesIssueIdCodeLinksGet(issue.id); + + // Nothing is connected in the demo stack, so these are empty -- the point + // is the shape the section groups by. + for (const key of ['branches', 'commits', 'pull_requests'] as const) { + expect(Array.isArray(codeLinks[key] ?? [])).toBe(true); + } + }); +}); diff --git a/src/api/__tests__/live-issues.test.ts b/src/api/__tests__/live-issues.test.ts new file mode 100644 index 0000000..52834ed --- /dev/null +++ b/src/api/__tests__/live-issues.test.ts @@ -0,0 +1,155 @@ +/** + * @jest-environment node + */ +import { loginAuthLoginPost } from '@/api/generated/endpoints/auth/auth'; +import { + createIssueTeamsTeamIdIssuesPost, + deleteIssueIssuesIssueIdDelete, + getIssueIssuesIssueIdGet, + listIssuesTeamsTeamIdIssuesGet, + updateIssueIssuesIssueIdPatch, +} from '@/api/generated/endpoints/issues/issues'; +import { createLabelTeamsTeamIdLabelsPost } from '@/api/generated/endpoints/labels/labels'; +import { createProjectTeamsTeamIdProjectsPost } from '@/api/generated/endpoints/projects/projects'; +import { listStatusesTeamsTeamIdStatusesGet } from '@/api/generated/endpoints/statuses/statuses'; +import { listMyTeamsTeamsGet } from '@/api/generated/endpoints/teams/teams'; +import type { StatusRead, TeamRead } from '@/api/generated/models'; +import { setInstanceUrl } from '@/api/instance'; +import { persistToken } from '@/auth/session'; +import { ESTIMATE_SCALE } from '@/issues/use-issue-properties'; + +/** + * Creating and editing issues against a real instance. + * + * The acceptance criterion for #6 is that an issue created on mobile is + * indistinguishable from a web-created one, so these assert the identifier + * shape and that every property round-trips -- including the two that mean + * something by being absent. + * + * SOFTTRACK_LIVE_URL=http://localhost:8000 npm test + */ +const LIVE_URL = process.env.SOFTTRACK_LIVE_URL; +const describeLive = LIVE_URL ? describe : describe.skip; + +const RUN = Date.now().toString(36).slice(-5); + +describeLive('issues against a live instance', () => { + let team: TeamRead; + let statuses: StatusRead[]; + const created: number[] = []; + + beforeAll(async () => { + await setInstanceUrl(LIVE_URL!); + const token = await loginAuthLoginPost({ + username: process.env.SOFTTRACK_LIVE_EMAIL ?? 'demo@softtrack.dev', + password: process.env.SOFTTRACK_LIVE_PASSWORD ?? 'password123', + }); + await persistToken(token.access_token); + team = (await listMyTeamsTeamsGet())[0]; + statuses = await listStatusesTeamsTeamIdStatusesGet(team.id); + }); + + afterAll(async () => { + // These are real rows; do not leave them behind. + for (const id of created) { + await deleteIssueIssuesIssueIdDelete(id).catch(() => undefined); + } + }); + + it('creates an issue that looks exactly like a web-created one', async () => { + const issue = await createIssueTeamsTeamIdIssuesPost(team.id, { + title: `Mobile create ${RUN}`, + }); + created.push(issue.id); + + // TEAMKEY-n, allocated by the server -- the client never invents one. + expect(issue.identifier).toBe(`${team.key}-${issue.number}`); + expect(issue.team_id).toBe(team.id); + expect(issue.creator).toHaveProperty('id'); + // The defaults a title-only issue gets. + expect(issue.priority).toBe('no_priority'); + expect(issue.estimate ?? null).toBeNull(); + expect(issue.assignee ?? null).toBeNull(); + // A status is assigned even though none was asked for. + expect(statuses.map((s) => s.id)).toContain(issue.status.id); + }); + + it('round-trips every property the form offers', async () => { + const label = await createLabelTeamsTeamIdLabelsPost(team.id, { + name: `mob-${RUN}`, + }); + const project = await createProjectTeamsTeamIdProjectsPost(team.id, { + name: `Mobile ${RUN}`, + }); + const status = statuses[1] ?? statuses[0]; + + const issue = await createIssueTeamsTeamIdIssuesPost(team.id, { + title: `Mobile full ${RUN}`, + description: '## Heading\n\n- [ ] a task', + status_id: status.id, + priority: 'high', + estimate: ESTIMATE_SCALE[3], + label_ids: [label.id], + project_id: project.id, + }); + created.push(issue.id); + + expect(issue.title).toBe(`Mobile full ${RUN}`); + expect(issue.description).toContain('## Heading'); + expect(issue.status.id).toBe(status.id); + expect(issue.priority).toBe('high'); + expect(issue.estimate).toBe(ESTIMATE_SCALE[3]); + expect(issue.project_id).toBe(project.id); + expect((issue.labels ?? []).map((l) => l.id)).toEqual([label.id]); + + // And it reads back the same, which is what the detail screen renders. + const fetched = await getIssueIssuesIssueIdGet(issue.id); + expect(fetched.identifier).toBe(issue.identifier); + expect(fetched.estimate).toBe(ESTIMATE_SCALE[3]); + }); + + it('accepts the estimate scale the picker offers, and nothing else', async () => { + const issue = await createIssueTeamsTeamIdIssuesPost(team.id, { + title: `Mobile estimate ${RUN}`, + }); + created.push(issue.id); + + for (const points of ESTIMATE_SCALE) { + const updated = await updateIssueIssuesIssueIdPatch(issue.id, { estimate: points }); + expect(updated.estimate).toBe(points); + } + + // Off-scale values are refused, which is why the picker is a fixed list. + await expect( + updateIssueIssuesIssueIdPatch(issue.id, { estimate: 4 as never }), + ).rejects.toMatchObject({ response: { status: 422 } }); + }); + + it('clears a property back to nothing', async () => { + // "Not sized" and "unassigned" are states, not the absence of an edit -- + // the pickers offer them as rows and null is what expresses them. + const issue = await createIssueTeamsTeamIdIssuesPost(team.id, { + title: `Mobile clear ${RUN}`, + estimate: 5, + }); + created.push(issue.id); + expect(issue.estimate).toBe(5); + + const cleared = await updateIssueIssuesIssueIdPatch(issue.id, { estimate: null }); + expect(cleared.estimate ?? null).toBeNull(); + }); + + it('deletes an issue and stops listing it', async () => { + const issue = await createIssueTeamsTeamIdIssuesPost(team.id, { + title: `Mobile delete ${RUN}`, + }); + + await deleteIssueIssuesIssueIdDelete(issue.id); + + await expect(getIssueIssuesIssueIdGet(issue.id)).rejects.toMatchObject({ + response: { status: 404 }, + }); + const page = await listIssuesTeamsTeamIdIssuesGet(team.id, { limit: 200 }); + expect(page.items.map((i) => i.id)).not.toContain(issue.id); + }); +}); diff --git a/src/api/__tests__/live-notifications.test.ts b/src/api/__tests__/live-notifications.test.ts new file mode 100644 index 0000000..f3d1ef2 --- /dev/null +++ b/src/api/__tests__/live-notifications.test.ts @@ -0,0 +1,166 @@ +/** + * @jest-environment node + */ +import { + loginAuthLoginPost, + registerAuthRegisterPost, +} from '@/api/generated/endpoints/auth/auth'; +import { createCommentIssuesIssueIdCommentsPost } from '@/api/generated/endpoints/comments/comments'; +import { + createIssueTeamsTeamIdIssuesPost, + deleteIssueIssuesIssueIdDelete, + updateIssueIssuesIssueIdPatch, +} from '@/api/generated/endpoints/issues/issues'; +import { + listNotificationsNotificationsGet, + markAllReadNotificationsReadAllPost, + unreadCountNotificationsUnreadCountGet, + updateNotificationNotificationsNotificationIdPatch, +} from '@/api/generated/endpoints/notifications/notifications'; +import { + acceptInviteInvitesTokenAcceptPost, + createInviteTeamsTeamIdInvitesPost, +} from '@/api/generated/endpoints/invites/invites'; +import { listMyTeamsTeamsGet } from '@/api/generated/endpoints/teams/teams'; +import type { TeamRead } from '@/api/generated/models'; +import { setInstanceUrl } from '@/api/instance'; +import { persistToken } from '@/auth/session'; + +/** + * The inbox against a real instance. + * + * Needs two accounts: the acceptance criterion says no notifications for your + * own actions, which cannot be shown with one. + */ +const LIVE_URL = process.env.SOFTTRACK_LIVE_URL; +const describeLive = LIVE_URL ? describe : describe.skip; + +const RUN = Date.now().toString(36).slice(-5); +const DEMO_EMAIL = process.env.SOFTTRACK_LIVE_EMAIL ?? 'demo@softtrack.dev'; +const DEMO_PASSWORD = process.env.SOFTTRACK_LIVE_PASSWORD ?? 'password123'; +const OTHER_EMAIL = `mob-notify-${RUN}@example.com`; + +describeLive('notifications against a live instance', () => { + let team: TeamRead; + let demoToken: string; + let otherToken: string; + let otherUsername: string; + let otherId: number; + const issues: number[] = []; + + beforeAll(async () => { + await setInstanceUrl(LIVE_URL!); + const demo = await loginAuthLoginPost({ + username: DEMO_EMAIL, + password: DEMO_PASSWORD, + }); + demoToken = demo.access_token; + await persistToken(demoToken); + team = (await listMyTeamsTeamsGet())[0]; + + const other = await registerAuthRegisterPost({ + email: OTHER_EMAIL, + password: 'password123', + full_name: 'Notify Tester', + }); + otherToken = other.access_token; + otherUsername = other.user.username; + otherId = other.user.id; + + // The second account has to be on the team to be assignable or mentionable. + await persistToken(demoToken); + const invite = await createInviteTeamsTeamIdInvitesPost(team.id, { + email: OTHER_EMAIL, + role: 'member', + }); + await persistToken(otherToken); + await acceptInviteInvitesTokenAcceptPost(invite.token); + }); + + afterAll(async () => { + await persistToken(demoToken); + for (const id of issues) await deleteIssueIssuesIssueIdDelete(id).catch(() => undefined); + }); + + it('says nothing about your own actions', async () => { + await persistToken(otherToken); + await markAllReadNotificationsReadAllPost(); + const before = await unreadCountNotificationsUnreadCountGet(); + + // The other account acts on its own issue, entirely by itself. + const own = await createIssueTeamsTeamIdIssuesPost(team.id, { + title: `Own action ${RUN}`, + assignee_id: otherId, + }); + issues.push(own.id); + await createCommentIssuesIssueIdCommentsPost(own.id, { body: 'talking to myself' }); + + const after = await unreadCountNotificationsUnreadCountGet(); + expect(after.unread).toBe(before.unread); + }); + + it('raises one when somebody assigns you', async () => { + await persistToken(demoToken); + const issue = await createIssueTeamsTeamIdIssuesPost(team.id, { + title: `Assign ${RUN}`, + }); + issues.push(issue.id); + await updateIssueIssuesIssueIdPatch(issue.id, { assignee_id: otherId }); + + await persistToken(otherToken); + const page = await listNotificationsNotificationsGet({ unread_only: true, limit: 25 }); + const hit = page.items.find((n) => n.issue.id === issue.id); + expect(hit).toBeDefined(); + expect(hit?.kind).toBe('assigned'); + // Both are what the row renders. + expect(hit?.actor?.full_name).toEqual(expect.any(String)); + expect(hit?.issue.identifier).toContain(team.key); + }); + + it('raises one when somebody mentions you', async () => { + await persistToken(demoToken); + const issue = await createIssueTeamsTeamIdIssuesPost(team.id, { + title: `Mention ${RUN}`, + }); + issues.push(issue.id); + await createCommentIssuesIssueIdCommentsPost(issue.id, { + body: `hey @${otherUsername} take a look`, + }); + + await persistToken(otherToken); + const page = await listNotificationsNotificationsGet({ unread_only: true, limit: 25 }); + const hit = page.items.find((n) => n.issue.id === issue.id); + expect(hit?.kind).toBe('mentioned'); + }); + + it('marks one read, and then all of them', async () => { + await persistToken(otherToken); + const unreadBefore = await unreadCountNotificationsUnreadCountGet(); + expect(unreadBefore.unread).toBeGreaterThan(0); + + const page = await listNotificationsNotificationsGet({ unread_only: true, limit: 1 }); + const one = page.items[0]; + await updateNotificationNotificationsNotificationIdPatch(one.id, { read: true }); + + const afterOne = await unreadCountNotificationsUnreadCountGet(); + expect(afterOne.unread).toBe(unreadBefore.unread - 1); + + await markAllReadNotificationsReadAllPost(); + expect((await unreadCountNotificationsUnreadCountGet()).unread).toBe(0); + // And the unread filter is genuinely a filter, not a styling choice. + const unreadOnly = await listNotificationsNotificationsGet({ + unread_only: true, + limit: 25, + }); + expect(unreadOnly.items).toEqual([]); + }); + + it('pages the full list', async () => { + await persistToken(otherToken); + const all = await listNotificationsNotificationsGet({ limit: 25 }); + expect(all.total).toBeGreaterThan(0); + const firstOnly = await listNotificationsNotificationsGet({ limit: 1, offset: 0 }); + expect(firstOnly.items).toHaveLength(1); + expect(firstOnly.total).toBe(all.total); + }); +}); diff --git a/src/api/__tests__/live-onboarding.test.ts b/src/api/__tests__/live-onboarding.test.ts new file mode 100644 index 0000000..ac09445 --- /dev/null +++ b/src/api/__tests__/live-onboarding.test.ts @@ -0,0 +1,189 @@ +/** + * @jest-environment node + */ +import { + loginAuthLoginPost, + myInvitesAuthMeInvitesGet, + registerAuthRegisterPost, +} from '@/api/generated/endpoints/auth/auth'; +import { + acceptInviteInvitesTokenAcceptPost, + createInviteTeamsTeamIdInvitesPost, + previewInviteInvitesTokenGet, +} from '@/api/generated/endpoints/invites/invites'; +import { + createTeamTeamsPost, + listMyTeamsTeamsGet, +} from '@/api/generated/endpoints/teams/teams'; +import { formatDuration, retryAfterSeconds } from '@/api/errors'; +import { setInstanceUrl } from '@/api/instance'; +import { persistToken } from '@/auth/session'; + +/** + * The onboarding path -- register, create a team, invite, accept -- against a + * real instance. + * + * Opt-in, same as `live.test.ts`: + * + * cd ../soft-track && docker compose up -d + * SOFTTRACK_LIVE_URL=http://localhost:8000 npm test + * + * Writes real rows, so every run uses a fresh address and team key derived from + * the index passed in, never a fixed one. + */ +const LIVE_URL = process.env.SOFTTRACK_LIVE_URL; +const describeLive = LIVE_URL ? describe : describe.skip; + +/** Unique per run without Math.random, which jest-expo's env makes awkward. */ +const RUN = `${process.pid}${Date.now().toString(36).slice(-4)}`.slice(-8).toLowerCase(); +// example.com, not example.test: email-validator rejects special-use TLDs +// outright, which is a 422 before the account logic is ever reached. +const newEmail = (tag: string) => `mob-${tag}-${RUN}@example.com`; + +/** Keys are 2-6 letters and permanent, so each run needs its own. */ +function teamKeyFor(tag: string): string { + const letters = `${tag}${RUN}`.replace(/[^a-z]/g, '').toUpperCase(); + return (letters + 'XXXXXX').slice(0, 6); +} + +/** + * Two registrations per run, deliberately: registration is throttled per IP -- 10 free, then exponential backoff up to an + * hour (`backend/lib_utils/rate_limit.py`), charged even on success. This suite + * creates three or four accounts per run, so running it repeatedly will hit it. + * Turn that into something actionable rather than a bare AxiosError. + */ +async function register(...args: Parameters) { + try { + return await registerAuthRegisterPost(...args); + } catch (err) { + const wait = retryAfterSeconds(err); + if (wait !== null) { + throw new Error( + `The instance is throttling registrations for ${formatDuration(wait)}. ` + + 'This suite registers real accounts, so re-running it in quick succession ' + + 'exhausts the per-IP budget. The counter is in-memory in the API ' + + 'process, so `docker compose restart backend` clears it without ' + + 'touching the database.', + ); + } + throw err; + } +} + +describeLive('onboarding against a live instance', () => { + beforeAll(async () => { + await setInstanceUrl(LIVE_URL!); + await persistToken(null); + }); + + it('registers a new account and signs it in', async () => { + const email = newEmail('reg'); + const token = await register({ + email, + password: 'password123', + full_name: 'Mobile Test', + }); + + expect(token.access_token).toEqual(expect.any(String)); + expect(token.user.email).toBe(email); + // Derived from the address when not supplied (lib_identity/usernames.py). + expect(token.user.username).toEqual(expect.any(String)); + + await persistToken(token.access_token); + // A brand new account belongs to nothing yet -- the empty state the teams + // home and the board both have to handle. + expect(await listMyTeamsTeamsGet()).toEqual([]); + }); + + it('rejects a password the backend considers too short', async () => { + await expect( + register({ + email: newEmail('short'), + password: 'short', + full_name: 'Too Short', + }), + // 422, whose `detail` is an ARRAY -- the case the web's errorDetail + // drops and this one joins. + ).rejects.toMatchObject({ response: { status: 422 } }); + }); + + it('creates a team and uppercases its key', async () => { + const key = teamKeyFor('a'); + const team = await createTeamTeamsPost({ name: 'Mobile QA', key: key.toLowerCase() }); + + // lib_softtrack/teams.py:69 uppercases on the way in, which is what keeps + // lowercase tab routes from ever colliding with a team key. + expect(team.key).toBe(key); + expect((await listMyTeamsTeamsGet()).map((t) => t.key)).toContain(key); + }); + + it('carries an invitation through preview and acceptance', async () => { + // Signed in as the account from the first test, which owns a team now. + const teams = await listMyTeamsTeamsGet(); + const team = teams[0]; + expect(team).toBeDefined(); + + const guestEmail = newEmail('guest'); + const invite = await createInviteTeamsTeamIdInvitesPost(team.id, { + email: guestEmail, + role: 'member', + }); + + // The preview is public -- it is what the invite screen shows before the + // recipient has any account at all. + await persistToken(null); + const preview = await previewInviteInvitesTokenGet(invite.token); + expect(preview.team_key).toBe(team.key); + expect(preview.email).toBe(guestEmail); + expect(preview.role).toBe('member'); + + // Registering with the token accepts the invitation best-effort, so the + // team list is the thing to check rather than the register response. + const guest = await register({ + email: guestEmail, + password: 'password123', + full_name: 'Invited Guest', + invite_token: invite.token, + }); + await persistToken(guest.access_token); + expect((await listMyTeamsTeamsGet()).map((t) => t.key)).toContain(team.key); + }); + + it('surfaces a pending invitation to an existing account', async () => { + // The banner case: someone who already has an account is invited to another + // team, and with no email delivery this is the only way they find out. + // + // Reuses the guest registered by the previous test rather than creating a + // third account. Registration is throttled at 10 per IP and charged even on + // success, so a suite that registered once per scenario would throttle + // itself after two runs. + const owner = await loginAuthLoginPost({ + username: newEmail('reg'), + password: 'password123', + }); + await persistToken(owner.access_token); + + const second = await createTeamTeamsPost({ + name: 'Mobile Ops', + key: teamKeyFor('b'), + }); + const invite = await createInviteTeamsTeamIdInvitesPost(second.id, { + email: newEmail('guest'), + role: 'admin', + }); + + const guest = await loginAuthLoginPost({ + username: newEmail('guest'), + password: 'password123', + }); + await persistToken(guest.access_token); + + const pending = await myInvitesAuthMeInvitesGet(); + expect(pending.map((i) => i.token)).toContain(invite.token); + expect(pending.find((i) => i.token === invite.token)?.role).toBe('admin'); + + const joined = await acceptInviteInvitesTokenAcceptPost(invite.token); + expect(joined.key).toBe(second.key); + expect(await myInvitesAuthMeInvitesGet()).toEqual([]); + }); +}); diff --git a/src/api/__tests__/live-reports.test.ts b/src/api/__tests__/live-reports.test.ts new file mode 100644 index 0000000..bf2932f --- /dev/null +++ b/src/api/__tests__/live-reports.test.ts @@ -0,0 +1,104 @@ +/** + * @jest-environment node + */ +import { loginAuthLoginPost } from '@/api/generated/endpoints/auth/auth'; +import { listCyclesTeamsTeamIdCyclesGet } from '@/api/generated/endpoints/cycles/cycles'; +import { + cycleBurndownCyclesCycleIdBurndownGet, + teamCreatedVsResolvedTeamsTeamIdCreatedVsResolvedGet, + teamCumulativeFlowTeamsTeamIdCumulativeFlowGet, + teamVelocityTeamsTeamIdVelocityGet, +} from '@/api/generated/endpoints/reports/reports'; +import { listMyTeamsTeamsGet } from '@/api/generated/endpoints/teams/teams'; +import type { TeamRead } from '@/api/generated/models'; +import { setInstanceUrl } from '@/api/instance'; +import { persistToken } from '@/auth/session'; +import { makeScale } from '@/reports/chart'; + +/** + * The four reports against a real instance. + * + * The acceptance criterion for #13 is that the numbers match web exactly, which + * they do by construction -- every figure is read from these endpoints and none + * is recomputed on the client. What is worth checking is that the shapes the + * charts index into are what arrives, and that a chart can be scaled from them + * without dividing by zero on an empty instance. + */ +const LIVE_URL = process.env.SOFTTRACK_LIVE_URL; +const describeLive = LIVE_URL ? describe : describe.skip; + +describeLive('reports against a live instance', () => { + let team: TeamRead; + + beforeAll(async () => { + await setInstanceUrl(LIVE_URL!); + const token = await loginAuthLoginPost({ + username: process.env.SOFTTRACK_LIVE_EMAIL ?? 'demo@softtrack.dev', + password: process.env.SOFTTRACK_LIVE_PASSWORD ?? 'password123', + }); + await persistToken(token.access_token); + team = (await listMyTeamsTeamsGet())[0]; + }); + + it('returns a burndown with an ideal line to draw against', async () => { + const cycles = await listCyclesTeamsTeamIdCyclesGet(team.id); + if (cycles.length === 0) return; + + const burndown = await cycleBurndownCyclesCycleIdBurndownGet(cycles[0].id); + expect(burndown.cycle_id).toBe(cycles[0].id); + + for (const point of burndown.points) { + expect(point.day).toMatch(/^\d{4}-\d{2}-\d{2}/); + expect(typeof point.points_remaining).toBe('number'); + // Both lines come from the server; the client draws, it does not model. + expect(typeof point.ideal_remaining).toBe('number'); + expect(point.points_remaining).toBeLessThanOrEqual(point.points_total); + } + }); + + it('returns velocity as committed against delivered', async () => { + const velocity = await teamVelocityTeamsTeamIdVelocityGet(team.id); + expect(Array.isArray(velocity.cycles)).toBe(true); + for (const cycle of velocity.cycles) { + expect(typeof cycle.points_committed).toBe('number'); + expect(typeof cycle.points_completed).toBe('number'); + expect(cycle.cycle_name).toEqual(expect.any(String)); + } + }); + + it('returns cumulative flow keyed by the categories the chart stacks', async () => { + const flow = await teamCumulativeFlowTeamsTeamIdCumulativeFlowGet(team.id); + const known = ['backlog', 'unstarted', 'started', 'done', 'cancelled']; + + for (const day of flow.days) { + expect(day.day).toMatch(/^\d{4}-\d{2}-\d{2}/); + // Unknown keys would silently vanish from a stacked area, so the chart's + // fixed order has to cover everything that can arrive. + for (const key of Object.keys(day.counts)) expect(known).toContain(key); + } + }); + + it('returns created against resolved with a running open count', async () => { + const report = await teamCreatedVsResolvedTeamsTeamIdCreatedVsResolvedGet(team.id); + expect(typeof report.total_created).toBe('number'); + expect(typeof report.total_resolved).toBe('number'); + + for (const day of report.days) { + expect(typeof day.created).toBe('number'); + expect(typeof day.resolved).toBe('number'); + expect(typeof day.open_at_end_of_day).toBe('number'); + } + }); + + it('scales whatever the instance actually returns', async () => { + // An instance with no history is the common case for a fresh install, and + // the charts have to draw an axis rather than divide by zero. + const flow = await teamCumulativeFlowTeamsTeamIdCumulativeFlowGet(team.id); + const totals = flow.days.map((day) => + Object.values(day.counts).reduce((sum, n) => sum + (n ?? 0), 0), + ); + const scale = makeScale(300, flow.days.length, Math.max(...totals, 1)); + expect(Number.isFinite(scale.y(0))).toBe(true); + expect(Number.isFinite(scale.x(0))).toBe(true); + }); +}); diff --git a/src/api/__tests__/live-search.test.ts b/src/api/__tests__/live-search.test.ts new file mode 100644 index 0000000..de7613b --- /dev/null +++ b/src/api/__tests__/live-search.test.ts @@ -0,0 +1,103 @@ +/** + * @jest-environment node + */ +import { loginAuthLoginPost } from '@/api/generated/endpoints/auth/auth'; +import { createCommentIssuesIssueIdCommentsPost } from '@/api/generated/endpoints/comments/comments'; +import { + createIssueTeamsTeamIdIssuesPost, + deleteIssueIssuesIssueIdDelete, +} from '@/api/generated/endpoints/issues/issues'; +import { searchSearchGet } from '@/api/generated/endpoints/search/search'; +import { listMyTeamsTeamsGet } from '@/api/generated/endpoints/teams/teams'; +import type { TeamRead } from '@/api/generated/models'; +import { setInstanceUrl } from '@/api/instance'; +import { persistToken } from '@/auth/session'; + +/** + * Search against a real instance. + * + * The acceptance criterion for #11 is the same result set as web for the same + * query and scope, so what matters is that matching, ranking and scoping all + * stay on the server -- the client only debounces and pages. + */ +const LIVE_URL = process.env.SOFTTRACK_LIVE_URL; +const describeLive = LIVE_URL ? describe : describe.skip; + +const RUN = Date.now().toString(36).slice(-5); +const NEEDLE = `zqx${RUN}`; + +describeLive('search against a live instance', () => { + let team: TeamRead; + const created: number[] = []; + + beforeAll(async () => { + await setInstanceUrl(LIVE_URL!); + const token = await loginAuthLoginPost({ + username: process.env.SOFTTRACK_LIVE_EMAIL ?? 'demo@softtrack.dev', + password: process.env.SOFTTRACK_LIVE_PASSWORD ?? 'password123', + }); + await persistToken(token.access_token); + team = (await listMyTeamsTeamsGet())[0]; + + const inTitle = await createIssueTeamsTeamIdIssuesPost(team.id, { + title: `Title match ${NEEDLE}`, + }); + const inDescription = await createIssueTeamsTeamIdIssuesPost(team.id, { + title: `Description holder ${RUN}`, + description: `body mentions ${NEEDLE} here`, + }); + const inComment = await createIssueTeamsTeamIdIssuesPost(team.id, { + title: `Comment holder ${RUN}`, + }); + await createCommentIssuesIssueIdCommentsPost(inComment.id, { + body: `a comment saying ${NEEDLE}`, + }); + created.push(inTitle.id, inDescription.id, inComment.id); + }); + + afterAll(async () => { + for (const id of created) { + await deleteIssueIssuesIssueIdDelete(id).catch(() => undefined); + } + }); + + it('searches titles, descriptions and comments alike', async () => { + const page = await searchSearchGet({ q: NEEDLE, limit: 25 }); + const ids = page.items.map((hit) => hit.id); + + for (const id of created) expect(ids).toContain(id); + + // `matched_in` is what the result row labels itself with. + const kinds = new Set(page.items.map((hit) => hit.matched_in)); + expect(kinds.size).toBeGreaterThan(1); + }); + + it('carries a snippet, which is why the result is worth a line', async () => { + const page = await searchSearchGet({ q: NEEDLE, limit: 25 }); + for (const hit of page.items) { + expect(typeof hit.snippet).toBe('string'); + expect(hit.identifier).toContain(hit.team_key); + } + }); + + it('scopes to one team when asked', async () => { + const scoped = await searchSearchGet({ q: NEEDLE, team_id: team.id, limit: 25 }); + for (const hit of scoped.items) expect(hit.team_id).toBe(team.id); + expect(scoped.items.length).toBeGreaterThan(0); + }); + + it('pages, which is what the "show more" control walks through', async () => { + const first = await searchSearchGet({ q: NEEDLE, limit: 1, offset: 0 }); + expect(first.items).toHaveLength(1); + expect(first.total).toBeGreaterThanOrEqual(3); + + const wider = await searchSearchGet({ q: NEEDLE, limit: 25 }); + expect(wider.items.length).toBe(wider.total); + }); + + it('finds nothing for a query that matches nothing', async () => { + const page = await searchSearchGet({ q: `${NEEDLE}-absent`, limit: 25 }); + expect(page.items).toEqual([]); + expect(page.total).toBe(0); + }); +}); diff --git a/src/api/__tests__/live-settings.test.ts b/src/api/__tests__/live-settings.test.ts new file mode 100644 index 0000000..6de427d --- /dev/null +++ b/src/api/__tests__/live-settings.test.ts @@ -0,0 +1,116 @@ +/** + * @jest-environment node + */ +import { + changeMyPasswordAuthMePasswordPost, + loginAuthLoginPost, + meAuthMeGet, + registerAuthRegisterPost, + signOutEverywhereRouteAuthMeSignOutEverywherePost, + updateMeAuthMePatch, +} from '@/api/generated/endpoints/auth/auth'; +import { + getNotificationSettingsNotificationsSettingsGet, + updateNotificationSettingsNotificationsSettingsPatch, +} from '@/api/generated/endpoints/notifications/notifications'; +import { setInstanceUrl } from '@/api/instance'; +import { getAccessToken, persistToken } from '@/auth/session'; + +/** + * Account settings against a real instance. + * + * Uses a throwaway account rather than the demo one, because these change a + * password and invalidate sessions -- doing that to the account every other + * suite signs in as would be antisocial. + */ +const LIVE_URL = process.env.SOFTTRACK_LIVE_URL; +const describeLive = LIVE_URL ? describe : describe.skip; + +const RUN = Date.now().toString(36).slice(-5); +const EMAIL = `mob-settings-${RUN}@example.com`; +const PASSWORD = 'password123'; + +describeLive('account settings against a live instance', () => { + beforeAll(async () => { + await setInstanceUrl(LIVE_URL!); + const token = await registerAuthRegisterPost({ + email: EMAIL, + password: PASSWORD, + full_name: 'Settings Tester', + }); + await persistToken(token.access_token); + }); + + it('edits the profile fields the form offers', async () => { + const updated = await updateMeAuthMePatch({ + full_name: 'Renamed Tester', + username: `mob${RUN}`, + avatar_color: '#14b8a6', + }); + + expect(updated.full_name).toBe('Renamed Tester'); + expect(updated.username).toBe(`mob${RUN}`); + expect(updated.avatar_color).toBe('#14b8a6'); + }); + + it('refuses an email change without the current password', async () => { + // Which is exactly why the field only appears once the address is edited. + await expect( + updateMeAuthMePatch({ email: `changed-${RUN}@example.com` }), + ).rejects.toMatchObject({ response: { status: 400 } }); + + const withPassword = await updateMeAuthMePatch({ + email: `changed-${RUN}@example.com`, + current_password: PASSWORD, + }); + expect(withPassword.email).toBe(`changed-${RUN}@example.com`); + }); + + it('reports whether the instance can actually send email', async () => { + const settings = await getNotificationSettingsNotificationsSettingsGet(); + expect(typeof settings.email_notifications).toBe('boolean'); + // The toggle is disabled and explained when this is false, rather than + // silently doing nothing. + expect(typeof settings.email_delivery_configured).toBe('boolean'); + + const toggled = await updateNotificationSettingsNotificationsSettingsPatch({ + email_notifications: !settings.email_notifications, + }); + expect(toggled.email_notifications).toBe(!settings.email_notifications); + }); + + it('hands back a working token when the password changes', async () => { + const before = getAccessToken(); + + const reissued = await changeMyPasswordAuthMePasswordPost({ + current_password: PASSWORD, + new_password: 'password456', + }); + expect(reissued.access_token).not.toBe(before); + + // The old token is dead -- which is why the app adopts the new one rather + // than leaving the user signed out of the account they just secured. + await persistToken(before); + await expect(meAuthMeGet()).rejects.toMatchObject({ response: { status: 401 } }); + + await persistToken(reissued.access_token); + expect((await meAuthMeGet()).email).toBe(`changed-${RUN}@example.com`); + }); + + it('signs other devices out while keeping this one', async () => { + // A second session, as if from another device. + const other = await loginAuthLoginPost({ + username: `changed-${RUN}@example.com`, + password: 'password456', + }); + + const reissued = await signOutEverywhereRouteAuthMeSignOutEverywherePost(); + await persistToken(reissued.access_token); + expect((await meAuthMeGet()).email).toBe(`changed-${RUN}@example.com`); + + await persistToken(other.access_token); + await expect(meAuthMeGet()).rejects.toMatchObject({ response: { status: 401 } }); + + await persistToken(reissued.access_token); + }); +}); diff --git a/src/api/__tests__/live-site-admin.test.ts b/src/api/__tests__/live-site-admin.test.ts new file mode 100644 index 0000000..e91e4a1 --- /dev/null +++ b/src/api/__tests__/live-site-admin.test.ts @@ -0,0 +1,118 @@ +/** + * @jest-environment node + */ +import { + listUsersAdminUsersGet, + resetPasswordAdminUsersUserIdResetPasswordPost, + updateUserAdminUsersUserIdPatch, +} from '@/api/generated/endpoints/admin/admin'; +import { + loginAuthLoginPost, + meAuthMeGet, + registerAuthRegisterPost, +} from '@/api/generated/endpoints/auth/auth'; +import { setInstanceUrl } from '@/api/instance'; +import { persistToken } from '@/auth/session'; + +/** + * The user directory against a real instance. + * + * The demo account is a site admin, which is what makes this testable at all. + */ +const LIVE_URL = process.env.SOFTTRACK_LIVE_URL; +const describeLive = LIVE_URL ? describe : describe.skip; + +const RUN = Date.now().toString(36).slice(-5); +const SUBJECT_EMAIL = `mob-subject-${RUN}@example.com`; + +describeLive('site administration against a live instance', () => { + let adminToken: string; + let adminId: number; + let subjectId: number; + + beforeAll(async () => { + await setInstanceUrl(LIVE_URL!); + const admin = await loginAuthLoginPost({ + username: process.env.SOFTTRACK_LIVE_EMAIL ?? 'demo@softtrack.dev', + password: process.env.SOFTTRACK_LIVE_PASSWORD ?? 'password123', + }); + adminToken = admin.access_token; + adminId = admin.user.id; + expect(admin.user.is_site_admin).toBe(true); + + const subject = await registerAuthRegisterPost({ + email: SUBJECT_EMAIL, + password: 'password123', + full_name: 'Directory Subject', + }); + subjectId = subject.user.id; + await persistToken(adminToken); + }); + + it('searches the directory', async () => { + const page = await listUsersAdminUsersGet({ q: `mob-subject-${RUN}`, limit: 25 }); + const found = page.items.find((row) => row.id === subjectId); + expect(found).toBeDefined(); + // Every field the row renders. + expect(found?.email).toBe(SUBJECT_EMAIL); + expect(found?.is_active).toBe(true); + expect(found?.is_site_admin).toBe(false); + expect(typeof found?.team_count).toBe('number'); + }); + + it('grants and revokes site admin', async () => { + const granted = await updateUserAdminUsersUserIdPatch(subjectId, { + is_site_admin: true, + }); + expect(granted.is_site_admin).toBe(true); + + const revoked = await updateUserAdminUsersUserIdPatch(subjectId, { + is_site_admin: false, + }); + expect(revoked.is_site_admin).toBe(false); + }); + + it('deactivates and reactivates, and a deactivated account cannot sign in', async () => { + await updateUserAdminUsersUserIdPatch(subjectId, { is_active: false }); + + // 403 with its own message, which is what login surfaces rather than + // "incorrect password". + await expect( + loginAuthLoginPost({ username: SUBJECT_EMAIL, password: 'password123' }), + ).rejects.toMatchObject({ response: { status: 403 } }); + + const reactivated = await updateUserAdminUsersUserIdPatch(subjectId, { + is_active: true, + }); + expect(reactivated.is_active).toBe(true); + }); + + it('sets a password for somebody locked out', async () => { + // There is no email here to send a reset link through, which is exactly why + // this exists. + await resetPasswordAdminUsersUserIdResetPasswordPost(subjectId, { + new_password: 'brand-new-password', + }); + + const signedIn = await loginAuthLoginPost({ + username: SUBJECT_EMAIL, + password: 'brand-new-password', + }); + expect(signedIn.user.id).toBe(subjectId); + await persistToken(adminToken); + }); + + it('refuses to let an admin deactivate or demote themselves', async () => { + // Both guards the sheet explains up front rather than discovering. + await expect( + updateUserAdminUsersUserIdPatch(adminId, { is_active: false }), + ).rejects.toMatchObject({ response: { status: 400 } }); + + await expect( + updateUserAdminUsersUserIdPatch(adminId, { is_site_admin: false }), + ).rejects.toMatchObject({ response: { status: 400 } }); + + // Still a working admin afterwards. + expect((await meAuthMeGet()).is_site_admin).toBe(true); + }); +}); diff --git a/src/api/__tests__/live-team-admin.test.ts b/src/api/__tests__/live-team-admin.test.ts new file mode 100644 index 0000000..79a08c1 --- /dev/null +++ b/src/api/__tests__/live-team-admin.test.ts @@ -0,0 +1,169 @@ +/** + * @jest-environment node + */ +import { + loginAuthLoginPost, + registerAuthRegisterPost, +} from '@/api/generated/endpoints/auth/auth'; +import { + acceptInviteInvitesTokenAcceptPost, + createInviteTeamsTeamIdInvitesPost, + listInvitesTeamsTeamIdInvitesGet, + revokeInviteTeamsTeamIdInvitesInviteIdDelete, +} from '@/api/generated/endpoints/invites/invites'; +import { + createIssueTeamsTeamIdIssuesPost, + deleteIssueIssuesIssueIdDelete, + getIssueIssuesIssueIdGet, +} from '@/api/generated/endpoints/issues/issues'; +import { + createStatusTeamsTeamIdStatusesPost, + deleteStatusStatusesStatusIdDelete, + listStatusesTeamsTeamIdStatusesGet, + reorderStatusesTeamsTeamIdStatusesOrderPut, +} from '@/api/generated/endpoints/statuses/statuses'; +import { + createTeamTeamsPost, + listTeamMembersTeamsTeamIdMembersGet, + removeTeamMemberTeamsTeamIdMembersUserIdDelete, + updateTeamMemberRoleTeamsTeamIdMembersUserIdPatch, + updateTeamTeamsTeamIdPatch, +} from '@/api/generated/endpoints/teams/teams'; +import type { TeamRead } from '@/api/generated/models'; +import { setInstanceUrl } from '@/api/instance'; +import { persistToken } from '@/auth/session'; + +/** + * Team administration against a real instance. + * + * Works on a team created for the run rather than the demo one, because these + * delete statuses and change roles. + */ +const LIVE_URL = process.env.SOFTTRACK_LIVE_URL; +const describeLive = LIVE_URL ? describe : describe.skip; + +const RUN = Date.now().toString(36).slice(-5); +const KEY = `T${RUN}`.replace(/[^A-Za-z]/g, 'X').toUpperCase().slice(0, 6); + +describeLive('team administration against a live instance', () => { + let team: TeamRead; + let adminToken: string; + let otherToken: string; + let otherId: number; + + beforeAll(async () => { + await setInstanceUrl(LIVE_URL!); + const admin = await loginAuthLoginPost({ + username: process.env.SOFTTRACK_LIVE_EMAIL ?? 'demo@softtrack.dev', + password: process.env.SOFTTRACK_LIVE_PASSWORD ?? 'password123', + }); + adminToken = admin.access_token; + await persistToken(adminToken); + + team = await createTeamTeamsPost({ name: `Admin ${RUN}`, key: KEY }); + + const other = await registerAuthRegisterPost({ + email: `mob-admin-${RUN}@example.com`, + password: 'password123', + full_name: 'Second Admin', + }); + otherToken = other.access_token; + otherId = other.user.id; + }); + + it('renames the team but never its key', async () => { + const updated = await updateTeamTeamsTeamIdPatch(team.id, { + name: `Renamed ${RUN}`, + description: 'Set from mobile', + }); + expect(updated.name).toBe(`Renamed ${RUN}`); + expect(updated.description).toBe('Set from mobile'); + // The key is permanent, which is why the field is read-only. + expect(updated.key).toBe(KEY); + }); + + it('invites, lists, and revokes', async () => { + const invite = await createInviteTeamsTeamIdInvitesPost(team.id, { + email: `revoke-${RUN}@example.com`, + role: 'member', + }); + expect((await listInvitesTeamsTeamIdInvitesGet(team.id)).map((i) => i.id)).toContain( + invite.id, + ); + + await revokeInviteTeamsTeamIdInvitesInviteIdDelete(team.id, invite.id); + expect((await listInvitesTeamsTeamIdInvitesGet(team.id)).map((i) => i.id)).not.toContain( + invite.id, + ); + }); + + it('refuses to leave the team without another admin', async () => { + // The guard the Manage sheet explains rather than discovers. + await expect( + removeTeamMemberTeamsTeamIdMembersUserIdDelete(team.id, otherId), + ).rejects.toMatchObject({ response: { status: 404 } }); + + const members = await listTeamMembersTeamsTeamIdMembersGet(team.id); + const me = members[0]; + await expect( + updateTeamMemberRoleTeamsTeamIdMembersUserIdPatch(team.id, me.user.id, { + role: 'member', + }), + // 409: demoting the last admin conflicts with the team still needing one, + // which is the guard the Manage sheet explains up front. + ).rejects.toMatchObject({ response: { status: 409 } }); + }); + + it('promotes a second member to admin', async () => { + const invite = await createInviteTeamsTeamIdInvitesPost(team.id, { + email: `mob-admin-${RUN}@example.com`, + role: 'member', + }); + await persistToken(otherToken); + await acceptInviteInvitesTokenAcceptPost(invite.token); + + await persistToken(adminToken); + const promoted = await updateTeamMemberRoleTeamsTeamIdMembersUserIdPatch( + team.id, + otherId, + { role: 'admin' }, + ); + expect(promoted.role).toBe('admin'); + }); + + it('creates, reorders and deletes a status, moving its issues', async () => { + const before = await listStatusesTeamsTeamIdStatusesGet(team.id); + const created = await createStatusTeamsTeamIdStatusesPost(team.id, { + name: `Review ${RUN}`, + category: 'started', + }); + + // Reordering sends every id at once, so two people cannot interleave into + // an order neither chose. + const reversed = [created.id, ...before.map((s) => s.id)]; + const reordered = await reorderStatusesTeamsTeamIdStatusesOrderPut(team.id, { + status_ids: reversed, + }); + expect(reordered.map((s) => s.id)).toEqual(reversed); + + const issue = await createIssueTeamsTeamIdIssuesPost(team.id, { + title: `In deleted status ${RUN}`, + status_id: created.id, + }); + + const destination = before[0]; + await deleteStatusStatusesStatusIdDelete(created.id, { move_to_id: destination.id }); + + // Where the issues go is required rather than guessed -- so they are + // exactly where they were sent. + expect((await getIssueIssuesIssueIdGet(issue.id)).status.id).toBe(destination.id); + await deleteIssueIssuesIssueIdDelete(issue.id).catch(() => undefined); + }); + + it('will not delete a status without saying where its issues go', async () => { + const statuses = await listStatusesTeamsTeamIdStatusesGet(team.id); + await expect( + deleteStatusStatusesStatusIdDelete(statuses[0].id, {} as never), + ).rejects.toMatchObject({ response: { status: 422 } }); + }); +}); diff --git a/src/api/__tests__/live-views.test.ts b/src/api/__tests__/live-views.test.ts new file mode 100644 index 0000000..ddb21f6 --- /dev/null +++ b/src/api/__tests__/live-views.test.ts @@ -0,0 +1,128 @@ +/** + * @jest-environment node + */ +import { loginAuthLoginPost } from '@/api/generated/endpoints/auth/auth'; +import { listMyTeamsTeamsGet } from '@/api/generated/endpoints/teams/teams'; +import { + createViewTeamsTeamIdViewsPost, + deleteViewViewsViewIdDelete, + listViewsTeamsTeamIdViewsGet, + setMyDefaultViewTeamsTeamIdDefaultViewMePut, + setTeamDefaultViewTeamsTeamIdDefaultViewPut, + updateViewViewsViewIdPatch, +} from '@/api/generated/endpoints/views/views'; +import type { TeamRead } from '@/api/generated/models'; +import { setInstanceUrl } from '@/api/instance'; +import { persistToken } from '@/auth/session'; +import { NO_FILTERS, sameFilters } from '@/board/filters'; +import { fromViewFilters, toViewFilters } from '@/views/saved-views'; + +/** + * Saved views against a real instance. + * + * The acceptance criterion for #14 is that views created on mobile appear on web + * with identical filters and that defaults resolve the same way -- so these + * assert the stored shape and the server's own default resolution. + */ +const LIVE_URL = process.env.SOFTTRACK_LIVE_URL; +const describeLive = LIVE_URL ? describe : describe.skip; + +const RUN = Date.now().toString(36).slice(-5); + +describeLive('saved views against a live instance', () => { + let team: TeamRead; + const created: number[] = []; + + beforeAll(async () => { + await setInstanceUrl(LIVE_URL!); + const token = await loginAuthLoginPost({ + username: process.env.SOFTTRACK_LIVE_EMAIL ?? 'demo@softtrack.dev', + password: process.env.SOFTTRACK_LIVE_PASSWORD ?? 'password123', + }); + await persistToken(token.access_token); + team = (await listMyTeamsTeamsGet())[0]; + }); + + afterAll(async () => { + await setMyDefaultViewTeamsTeamIdDefaultViewMePut(team.id, { view_id: null }).catch( + () => undefined, + ); + await setTeamDefaultViewTeamsTeamIdDefaultViewPut(team.id, { view_id: null }).catch( + () => undefined, + ); + for (const id of created) { + await deleteViewViewsViewIdDelete(id).catch(() => undefined); + } + }); + + it('stores exactly the filters the board was showing', async () => { + const filters = { ...NO_FILTERS, priority: 'urgent' as const, assignee: 'unassigned' as const }; + const view = await createViewTeamsTeamIdViewsPost(team.id, { + name: `Urgent unassigned ${RUN}`, + filters: toViewFilters(filters), + }); + created.push(view.id); + + expect(view.is_shared).toBe(false); // private by default + expect(view.filters.priority).toBe('urgent'); + expect(view.filters.unassigned).toBe(true); + // And reading it back reconstructs the same question. + expect(sameFilters(fromViewFilters(view.filters), filters)).toBe(true); + }); + + it('renames and re-shares', async () => { + const view = await createViewTeamsTeamIdViewsPost(team.id, { + name: `Rename me ${RUN}`, + filters: toViewFilters(NO_FILTERS), + }); + created.push(view.id); + + const renamed = await updateViewViewsViewIdPatch(view.id, { + name: `Renamed ${RUN}`, + is_shared: true, + }); + expect(renamed.name).toBe(`Renamed ${RUN}`); + expect(renamed.is_shared).toBe(true); + }); + + it('resolves the effective default itself: yours over the team’s', async () => { + const teamView = await createViewTeamsTeamIdViewsPost(team.id, { + name: `Team default ${RUN}`, + is_shared: true, + filters: toViewFilters({ ...NO_FILTERS, priority: 'low' }), + }); + const myView = await createViewTeamsTeamIdViewsPost(team.id, { + name: `My default ${RUN}`, + filters: toViewFilters({ ...NO_FILTERS, priority: 'high' }), + }); + created.push(teamView.id, myView.id); + + await setTeamDefaultViewTeamsTeamIdDefaultViewPut(team.id, { view_id: teamView.id }); + let listed = await listViewsTeamsTeamIdViewsGet(team.id); + expect(listed.team_default_id).toBe(teamView.id); + // With no personal default, the team's is what a board lands on. + expect(listed.effective_default_id).toBe(teamView.id); + + await setMyDefaultViewTeamsTeamIdDefaultViewMePut(team.id, { view_id: myView.id }); + listed = await listViewsTeamsTeamIdViewsGet(team.id); + // Yours wins -- the client reads this rather than re-deriving the order. + expect(listed.effective_default_id).toBe(myView.id); + + // Null clears rather than sets. + await setMyDefaultViewTeamsTeamIdDefaultViewMePut(team.id, { view_id: null }); + listed = await listViewsTeamsTeamIdViewsGet(team.id); + expect(listed.my_default_id ?? null).toBeNull(); + expect(listed.effective_default_id).toBe(teamView.id); + }); + + it('stops listing a deleted view', async () => { + const view = await createViewTeamsTeamIdViewsPost(team.id, { + name: `Delete me ${RUN}`, + filters: toViewFilters(NO_FILTERS), + }); + await deleteViewViewsViewIdDelete(view.id); + + const listed = await listViewsTeamsTeamIdViewsGet(team.id); + expect(listed.items.map((v) => v.id)).not.toContain(view.id); + }); +}); diff --git a/src/api/query-client.ts b/src/api/query-client.ts index 5b40782..297d4e0 100644 --- a/src/api/query-client.ts +++ b/src/api/query-client.ts @@ -13,6 +13,10 @@ export const queryClient = new QueryClient({ queries: { retry: 1, refetchOnWindowFocus: false, + // Long enough that a persisted cache is still served after a restart -- + // without it everything would be discarded as stale on the way in, which + // is exactly the case offline reading exists for. + gcTime: 7 * 24 * 60 * 60 * 1000, }, }, }); diff --git a/src/app/(app)/board.tsx b/src/app/(app)/board.tsx index e30adab..8379b9a 100644 --- a/src/app/(app)/board.tsx +++ b/src/app/(app)/board.tsx @@ -1,5 +1 @@ -import { Placeholder } from '@/ui/placeholder'; - -export default function BoardRoute() { - return ; -} +export { BoardScreen as default } from '@/board/board-screen'; diff --git a/src/app/(app)/inbox.tsx b/src/app/(app)/inbox.tsx index 59408ec..650da8f 100644 --- a/src/app/(app)/inbox.tsx +++ b/src/app/(app)/inbox.tsx @@ -1,5 +1 @@ -import { Placeholder } from '@/ui/placeholder'; - -export default function InboxRoute() { - return ; -} +export { InboxScreen as default } from '@/notifications/inbox-screen'; diff --git a/src/app/(app)/search.tsx b/src/app/(app)/search.tsx index a7d84ea..4c1673c 100644 --- a/src/app/(app)/search.tsx +++ b/src/app/(app)/search.tsx @@ -1,5 +1 @@ -import { Placeholder } from '@/ui/placeholder'; - -export default function SearchRoute() { - return ; -} +export { SearchScreen as default } from '@/search/search-screen'; diff --git a/src/app/(auth)/register.tsx b/src/app/(auth)/register.tsx new file mode 100644 index 0000000..4e410a7 --- /dev/null +++ b/src/app/(auth)/register.tsx @@ -0,0 +1 @@ +export { RegisterScreen as default } from '@/auth/register-screen'; diff --git a/src/app/[teamKey].tsx b/src/app/[teamKey].tsx new file mode 100644 index 0000000..5d709ba --- /dev/null +++ b/src/app/[teamKey].tsx @@ -0,0 +1 @@ +export { TeamRoute as default } from '@/team/team-route'; diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index bd518df..28018fd 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -1,17 +1,22 @@ import { useEffect, useRef, useState } from 'react'; import { View } from 'react-native'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import { Stack } from 'expo-router'; import * as SplashScreen from 'expo-splash-screen'; import * as SystemUI from 'expo-system-ui'; import { StatusBar } from 'expo-status-bar'; -import { QueryClientProvider } from '@tanstack/react-query'; +import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'; import { hydrateInstanceUrl } from '@/api/instance'; import { queryClient } from '@/api/query-client'; +import { startConnectivityWatch } from '@/offline/connectivity'; +import { MAX_AGE_MS, persister } from '@/offline/persistence'; +import { registerQueuedMutations } from '@/offline/queue'; import { AuthProvider, useAuth } from '@/auth/auth-context'; import { hydrateSession } from '@/auth/session'; import { useDeepLinkCapture } from '@/auth/use-deep-link-capture'; +import { hydrateActiveTeam, TeamProvider } from '@/team/team-context'; import { AppText, Button, Loading } from '@/ui/primitives'; import { hydrateTheme, @@ -32,7 +37,7 @@ import { */ void SplashScreen.preventAutoHideAsync(); -type Hydrated = { theme: ThemePreference }; +type Hydrated = { theme: ThemePreference; teamKey: string | null }; function RootNavigator() { const { status } = useAuth(); @@ -57,10 +62,20 @@ function RootNavigator() { structurally impossible. */} + {/* Pushed over the tabs rather than being tab destinations: both are + things you enter from somewhere and come back from. */} + + + + {/* Guarded by neither: an invitation has to be readable before you have + an account, and a team link has to resolve once you do. Both decide + for themselves what to do with the session they find. */} + + ); } @@ -137,17 +152,25 @@ function Gate() { export default function RootLayout() { const [hydrated, setHydrated] = useState(null); + useEffect(() => { + // Mutation functions have to be registered by key before a persisted queue + // is restored, or a paused mutation comes back as data with nothing to run. + registerQueuedMutations(queryClient); + return startConnectivityWatch(); + }, []); + useEffect(() => { // One pass over persisted state before anything renders: the stored theme, // the instance URL, and the token -- each into its module-level mirror so // the axios interceptor can read them synchronously from the first request. void (async () => { - const [theme] = await Promise.all([ + const [theme, , , teamKey] = await Promise.all([ hydrateTheme(), hydrateInstanceUrl(), hydrateSession(), + hydrateActiveTeam(), ]); - setHydrated({ theme }); + setHydrated({ theme, teamKey }); })(); }, []); @@ -155,15 +178,27 @@ export default function RootLayout() { return ( - - - - - - - - - + {/* Required by react-native-gesture-handler, which the board's + long-press-to-lift drag is built on. */} + + + + void queryClient.resumePausedMutations()} + > + + + + + + + + + ); } diff --git a/src/app/invite/[token].tsx b/src/app/invite/[token].tsx new file mode 100644 index 0000000..a9d20c3 --- /dev/null +++ b/src/app/invite/[token].tsx @@ -0,0 +1 @@ +export { InviteScreen as default } from '@/auth/invite-screen'; diff --git a/src/app/issue/[id].tsx b/src/app/issue/[id].tsx new file mode 100644 index 0000000..9128aa0 --- /dev/null +++ b/src/app/issue/[id].tsx @@ -0,0 +1 @@ +export { IssueDetailScreen as default } from '@/issues/issue-detail-screen'; diff --git a/src/app/new-issue.tsx b/src/app/new-issue.tsx new file mode 100644 index 0000000..f9c83b3 --- /dev/null +++ b/src/app/new-issue.tsx @@ -0,0 +1 @@ +export { NewIssueScreen as default } from '@/issues/new-issue-screen'; diff --git a/src/attachments/__tests__/rules.test.ts b/src/attachments/__tests__/rules.test.ts new file mode 100644 index 0000000..39a561e --- /dev/null +++ b/src/attachments/__tests__/rules.test.ts @@ -0,0 +1,58 @@ +import { contentTypeFor, formatBytes, MAX_BYTES, rejectionFor } from '@/attachments/rules'; +import { filenameFrom } from '@/attachments/upload'; + +describe('contentTypeFor', () => { + it('accepts what the server accepts', () => { + expect(contentTypeFor('shot.PNG')).toBe('image/png'); + expect(contentTypeFor('notes.md')).toBe('text/markdown'); + expect(contentTypeFor('fix.patch')).toBe('text/plain'); + expect(contentTypeFor('clip.mov')).toBe('video/quicktime'); + }); + + it('refuses what it does not', () => { + expect(contentTypeFor('page.html')).toBeNull(); + expect(contentTypeFor('noextension')).toBeNull(); + }); +}); + +describe('rejectionFor', () => { + it('says why an SVG is refused rather than just refusing it', () => { + // The server leaves SVG out deliberately: it is a document that can carry + // script. A generic "not allowed" would read as an oversight. + const rejection = rejectionFor('diagram.svg', 1000); + expect(rejection?.reason).toBe('type'); + expect(rejection?.message).toContain('script'); + }); + + it('names the file and the limit when it is too big', () => { + const rejection = rejectionFor('huge.zip', MAX_BYTES + 1); + expect(rejection?.reason).toBe('size'); + expect(rejection?.message).toContain('huge.zip'); + expect(rejection?.message).toContain('25.0 MB'); + }); + + it('passes a file the server would take', () => { + expect(rejectionFor('shot.png', 500_000)).toBeNull(); + // Unknown size is not a reason to refuse; the server has the final say. + expect(rejectionFor('shot.png', undefined)).toBeNull(); + }); +}); + +describe('filenameFrom', () => { + it('keeps the extension the server validates on', () => { + expect(filenameFrom('file:///var/tmp/ABC123.jpg')).toBe('ABC123.jpg'); + expect(filenameFrom('file:///tmp/my%20shot.png')).toBe('my shot.png'); + }); + + it('invents a name with an extension when the camera gives none', () => { + expect(filenameFrom('file:///var/tmp/ABC123')).toMatch(/^upload-\d+\.jpg$/); + }); +}); + +describe('formatBytes', () => { + it('reads the way a person would say it', () => { + expect(formatBytes(512)).toBe('512 B'); + expect(formatBytes(2048)).toBe('2 KB'); + expect(formatBytes(5 * 1024 * 1024)).toBe('5.0 MB'); + }); +}); diff --git a/src/attachments/attachments-section.tsx b/src/attachments/attachments-section.tsx new file mode 100644 index 0000000..06391f7 --- /dev/null +++ b/src/attachments/attachments-section.tsx @@ -0,0 +1,304 @@ +import { useState } from 'react'; +import { Linking, Modal, Pressable, View } from 'react-native'; +import { Image } from 'expo-image'; +import * as DocumentPicker from 'expo-document-picker'; +import * as ImagePicker from 'expo-image-picker'; +import { useQueryClient } from '@tanstack/react-query'; + +import { getInstanceUrl } from '@/api/instance'; +import { + deleteAttachmentAttachmentsAttachmentIdDelete, + useListIssueAttachmentsIssuesIssueIdAttachmentsGet, +} from '@/api/generated/endpoints/attachments/attachments'; +import type { AttachmentRead, IssueRead } from '@/api/generated/models'; +import { errorDetail } from '@/api/errors'; +import { getAccessToken } from '@/auth/session'; +import { formatBytes, rejectionFor } from '@/attachments/rules'; +import { filenameFrom, uploadAttachment } from '@/attachments/upload'; +import { Section } from '@/issues/detail/sections'; +import { Icon } from '@/ui/icon'; +import { Alert, AppText, Card, Loading } from '@/ui/primitives'; +import { Sheet } from '@/ui/sheet'; +import { useTokens } from '@/ui/theme'; + +/** + * The URL an attachment's bytes come from, with the bearer token attached. + * + * Attachment content is authenticated, so a plain image source would 401. The + * token goes in a header rather than the query string -- it would otherwise end + * up in any log the instance keeps. + */ +function imageSource(attachment: AttachmentRead) { + const base = getInstanceUrl() ?? ''; + const token = getAccessToken(); + return { + uri: `${base}/attachments/${attachment.id}/content`, + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + }; +} + +export function AttachmentsSection({ issue }: { issue: IssueRead }) { + const t = useTokens(); + const queryClient = useQueryClient(); + const attachments = useListIssueAttachmentsIssuesIssueIdAttachmentsGet(issue.id); + + const [picking, setPicking] = useState(false); + const [error, setError] = useState(null); + const [progress, setProgress] = useState(null); + const [retry, setRetry] = useState<{ uri: string; filename: string } | null>(null); + const [viewing, setViewing] = useState(null); + + const items = attachments.data ?? []; + + async function upload(uri: string, filename: string) { + setError(null); + setRetry(null); + + const rejection = rejectionFor(filename, undefined); + if (rejection) { + // Refused before spending a minute uploading something that was never + // going to land. + setError(rejection.message); + return; + } + + setProgress(0); + try { + await uploadAttachment({ + issueId: issue.id, + uri, + filename, + onProgress: setProgress, + }); + await queryClient.invalidateQueries({ + queryKey: [`/issues/${issue.id}/attachments`], + }); + } catch (err) { + setError(errorDetail(err, `Could not upload ${filename}.`)); + setRetry({ uri, filename }); + } finally { + setProgress(null); + } + } + + async function fromCamera() { + setPicking(false); + const permission = await ImagePicker.requestCameraPermissionsAsync(); + if (!permission.granted) { + setError('SoftTrack needs camera access to attach a photo.'); + return; + } + const result = await ImagePicker.launchCameraAsync({ quality: 0.8 }); + const asset = result.assets?.[0]; + if (!asset) return; + await upload(asset.uri, asset.fileName ?? filenameFrom(asset.uri)); + } + + async function fromLibrary() { + setPicking(false); + const result = await ImagePicker.launchImageLibraryAsync({ + quality: 0.8, + mediaTypes: ['images', 'videos'], + }); + const asset = result.assets?.[0]; + if (!asset) return; + await upload(asset.uri, asset.fileName ?? filenameFrom(asset.uri)); + } + + async function fromFiles() { + setPicking(false); + const result = await DocumentPicker.getDocumentAsync({ copyToCacheDirectory: true }); + const asset = result.assets?.[0]; + if (!asset) return; + + // The file picker knows the size, so an oversized file is caught here + // rather than by the server after the whole upload. + const rejection = rejectionFor(asset.name, asset.size); + if (rejection) { + setError(rejection.message); + return; + } + await upload(asset.uri, asset.name); + } + + return ( +
setPicking(true)} + > + + + } + > + {error ? {error} : null} + + {retry ? ( + void upload(retry.uri, retry.filename)} + style={{ + paddingVertical: 10, + alignItems: 'center', + borderRadius: t.radius.control, + backgroundColor: t.line.well, + }} + > + + Retry {retry.filename} + + + ) : null} + + {progress !== null ? ( + + Uploading… {Math.round(progress * 100)}% + + + + + ) : null} + + {attachments.isPending ? : null} + + {items.length > 0 ? ( + + {items.map((attachment) => ( + + {attachment.is_image ? ( + setViewing(attachment)} + > + + + ) : null} + + + + + {attachment.filename} + + + {formatBytes(attachment.size_bytes)} · {attachment.uploaded_by.full_name} + + + + {!attachment.is_image ? ( + { + const base = getInstanceUrl() ?? ''; + void Linking.openURL(`${base}/attachments/${attachment.id}/content`); + }} + > + + Open + + + ) : null} + + { + setError(null); + try { + await deleteAttachmentAttachmentsAttachmentIdDelete(attachment.id); + await queryClient.invalidateQueries({ + queryKey: [`/issues/${issue.id}/attachments`], + }); + } catch (err) { + setError(errorDetail(err, 'Could not delete that attachment.')); + } + }} + > + + + + + ))} + + ) : !attachments.isPending ? ( + + Nothing attached yet. + + ) : null} + + setPicking(false)} title="Attach"> + + {[ + { label: 'Take a photo', run: fromCamera }, + { label: 'Photo library', run: fromLibrary }, + { label: 'Files', run: fromFiles }, + ].map((option) => ( + void option.run()} + style={{ + paddingVertical: 14, + paddingHorizontal: 10, + borderRadius: t.radius.control, + }} + > + {option.label} + + ))} + + + + setViewing(null)} + supportedOrientations={['portrait', 'landscape']} + > + setViewing(null)} + style={{ + flex: 1, + backgroundColor: 'rgba(0,0,0,0.92)', + alignItems: 'center', + justifyContent: 'center', + }} + > + {viewing ? ( + + ) : null} + + +
+ ); +} diff --git a/src/attachments/rules.ts b/src/attachments/rules.ts new file mode 100644 index 0000000..f0c53b8 --- /dev/null +++ b/src/attachments/rules.ts @@ -0,0 +1,81 @@ +/** + * What the server will accept, mirrored so a phone can say no before spending + * a minute uploading something that was never going to land. + * + * Kept in sync by hand with `backend/lib_softtrack/attachments.py` -- the + * allowed list is not in the OpenAPI schema, so there is nothing to generate + * it from. + */ + +/** `.svg` is deliberately absent: it is a document that can carry script. */ +export const ALLOWED_TYPES: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.pdf': 'application/pdf', + '.txt': 'text/plain', + '.log': 'text/plain', + '.md': 'text/markdown', + '.csv': 'text/csv', + '.json': 'application/json', + '.patch': 'text/plain', + '.diff': 'text/plain', + '.zip': 'application/zip', + '.mp4': 'video/mp4', + '.webm': 'video/webm', + '.mov': 'video/quicktime', +}; + +/** The default cap; an instance can lower it, and the server has final say. */ +export const MAX_BYTES = 25 * 1024 * 1024; + +export function extensionOf(filename: string): string { + const dot = filename.lastIndexOf('.'); + return dot === -1 ? '' : filename.slice(dot).toLowerCase(); +} + +export function contentTypeFor(filename: string): string | null { + return ALLOWED_TYPES[extensionOf(filename)] ?? null; +} + +export type Rejection = { reason: 'type' | 'size'; message: string }; + +/** + * Why this file cannot be uploaded, or `null` if it can. + * + * Names the file rather than stating a rule in the abstract, because the person + * picking it is looking at a gallery of a hundred things. + */ +export function rejectionFor( + filename: string, + sizeBytes: number | undefined, + maxBytes = MAX_BYTES, +): Rejection | null { + if (contentTypeFor(filename) === null) { + const extension = extensionOf(filename); + return { + reason: 'type', + message: + extension === '.svg' + ? 'SVG files are not accepted, because they can carry script.' + : `${extension || 'That file type'} is not accepted. Try an image, PDF, text, CSV, JSON, zip or video file.`, + }; + } + + if (sizeBytes !== undefined && sizeBytes > maxBytes) { + return { + reason: 'size', + message: `${filename} is ${formatBytes(sizeBytes)}, over the ${formatBytes(maxBytes)} limit.`, + }; + } + + return null; +} + +export function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} diff --git a/src/attachments/upload.ts b/src/attachments/upload.ts new file mode 100644 index 0000000..7520fa2 --- /dev/null +++ b/src/attachments/upload.ts @@ -0,0 +1,67 @@ +import { AXIOS_INSTANCE } from '@/api/client'; +import type { AttachmentRead } from '@/api/generated/models'; +import { contentTypeFor } from '@/attachments/rules'; + +/** + * Uploading a file the API expects as multipart. + * + * Not routed through the generated client: Orval emits a `Blob` body for the + * upload, and React Native has no real Blob to give it -- a file is a `file://` + * URI that the native networking layer streams itself. Handing axios a + * `FormData` with `{uri, name, type}` is how that is expressed, and it also + * gives a progress callback, which reading the whole file into memory first + * would not. + */ +export type UploadProgress = (fraction: number) => void; + +export async function uploadAttachment({ + issueId, + uri, + filename, + onProgress, + signal, +}: { + issueId: number; + uri: string; + filename: string; + onProgress?: UploadProgress; + signal?: AbortSignal; +}): Promise { + const form = new FormData(); + form.append('file', { + uri, + name: filename, + // The server derives the type from the extension anyway, but sending the + // right one avoids a generic octet-stream round trip. + type: contentTypeFor(filename) ?? 'application/octet-stream', + } as unknown as Blob); + + const { data } = await AXIOS_INSTANCE.post( + `/issues/${issueId}/attachments`, + form, + { + signal, + headers: { 'Content-Type': 'multipart/form-data' }, + onUploadProgress: (event) => { + if (!onProgress) return; + // `total` is absent on some platforms; an indeterminate bar is better + // than a bar that jumps to 100% and sits there. + if (event.total) onProgress(Math.min(1, event.loaded / event.total)); + }, + }, + ); + return data; +} + +/** + * A filename for something the picker gave no name to. + * + * The camera hands back a `file://.../ABC123.jpg` with no display name, and the + * extension is what the server validates on, so it has to survive. + */ +export function filenameFrom(uri: string, fallbackExtension = '.jpg'): string { + const withoutQuery = uri.split('?')[0]; + const last = withoutQuery.split('/').pop() ?? ''; + if (last.includes('.')) return decodeURIComponent(last); + return `upload-${Date.now()}${fallbackExtension}`; +} diff --git a/src/auth/__tests__/login-screen.test.tsx b/src/auth/__tests__/login-screen.test.tsx index 2878e87..ae5375a 100644 --- a/src/auth/__tests__/login-screen.test.tsx +++ b/src/auth/__tests__/login-screen.test.tsx @@ -129,4 +129,13 @@ describe('LoginScreen, once the instance has answered', () => { expect(view.queryByDisplayValue('demo@softtrack.dev')).toBeNull(); }); + it('offers sign-up only where registration is open', async () => { + const open = await renderConnected({ open_registration: true }); + expect(await open.findByLabelText('Create an account')).toBeTruthy(); + + // An invite-only instance would make the register screen a dead end. + const closed = await renderConnected({ open_registration: false }); + await waitFor(() => expect(probeInstance).toHaveBeenCalled()); + expect(closed.queryByLabelText('Create an account')).toBeNull(); + }); }); diff --git a/src/auth/invite-screen.tsx b/src/auth/invite-screen.tsx new file mode 100644 index 0000000..f36d180 --- /dev/null +++ b/src/auth/invite-screen.tsx @@ -0,0 +1,210 @@ +import { useState } from 'react'; +import { ScrollView, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { router, useLocalSearchParams } from 'expo-router'; +import { useQueryClient } from '@tanstack/react-query'; + +import { + acceptInviteInvitesTokenAcceptPost, + declineInviteInvitesTokenDeclinePost, + usePreviewInviteInvitesTokenGet, +} from '@/api/generated/endpoints/invites/invites'; +import { errorDetail } from '@/api/errors'; +import { getInstanceUrl, instanceLabel } from '@/api/instance'; +import { useAuth } from '@/auth/auth-context'; +import { rememberDestination } from '@/auth/pending-destination'; +import { useTeams } from '@/team/team-context'; +import { href } from '@/ui/href'; +import { Alert, AppText, Button, Card, Loading, Logo, RoleChip } from '@/ui/primitives'; +import { useTokens } from '@/ui/theme'; + +/** + * The landing screen for an invitation link, per + * `docs/design/mobile/140-register-invite.svg`. + * + * Deliberately outside both the `(auth)` and `(app)` groups, so neither guard + * covers it: most people opening one of these have no account yet, and bouncing + * them to a login screen without saying what they were invited to is how an + * invitation gets ignored. Same reasoning as the web keeping `/invite/:token` + * outside `RequireAuth` (`frontend/src/app/App.tsx:31-34`). + * + * One thing the web gets for free that this cannot: on the web the page's own + * origin IS the instance. A `softtrack://` deep link carries no host, so the + * instance has to already be known. Arriving with none sends the user to sign in + * first, with this screen recorded as where to come back to. + */ +export function InviteScreen() { + const t = useTokens(); + const { token = '' } = useLocalSearchParams<{ token: string }>(); + const { isAuthenticated, user, signOut } = useAuth(); + const { setTeamKey } = useTeams(); + const queryClient = useQueryClient(); + + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const instance = getInstanceUrl(); + const preview = usePreviewInviteInvitesTokenGet(token, { + query: { enabled: Boolean(token) && Boolean(instance), retry: false }, + }); + + const shell = (children: React.ReactNode) => ( + + + + + + + + {children} + + + + + ); + + if (!instance) { + return shell( + <> + Which SoftTrack is this? + + An invitation link does not say which instance it belongs to. Sign in to + your instance and the invitation will open straight after. + + + , + ); + } + + if (preview.isPending) { + return shell(); + } + + if (preview.isError || !preview.data) { + return shell( + <> + This invitation is no longer valid + + It may have been used, revoked, or simply run out. Ask whoever invited + you to send a fresh link. + + + , + ); + } + + const invite = preview.data; + const wrongAccount = + isAuthenticated && + user !== null && + user.email.toLowerCase() !== invite.email.toLowerCase(); + + async function act(run: () => Promise, fallback: string) { + setError(null); + setBusy(true); + try { + await run(); + } catch (err) { + setError(errorDetail(err, fallback)); + } finally { + setBusy(false); + } + } + + const onAccept = () => + act(async () => { + const team = await acceptInviteInvitesTokenAcceptPost(token); + // Awaited, with refetchType 'all': the team list drives which team the + // board shows, so switching to a team the list has not caught up with + // would land on nothing. + await queryClient.invalidateQueries({ queryKey: ['/teams'], refetchType: 'all' }); + await queryClient.invalidateQueries({ queryKey: ['/auth/me/invites'] }); + setTeamKey(team.key); + router.replace('/'); + }, 'Could not accept this invitation.'); + + const onDecline = () => + act(async () => { + await declineInviteInvitesTokenDeclinePost(token); + await queryClient.invalidateQueries({ queryKey: ['/auth/me/invites'] }); + router.replace('/'); + }, 'Could not decline this invitation.'); + + return shell( + <> + INVITATION + + {invite.invited_by_name} invited you to join {invite.team_name} + + + + + {invite.team_key} + + as + + + + + Sent to {invite.email} on {instanceLabel(instance)}. + + + {error ? {error} : null} + + {!isAuthenticated ? ( + + + + + ) : wrongAccount ? ( + + + You are signed in as {user?.email}, and this invitation was sent to{' '} + {invite.email}. An invitation only admits the address it was + addressed to. + + + + ) : ( + + + + + )} + , + ); +} diff --git a/src/auth/login-screen.tsx b/src/auth/login-screen.tsx index 853ae16..fbbd98c 100644 --- a/src/auth/login-screen.tsx +++ b/src/auth/login-screen.tsx @@ -217,6 +217,21 @@ export function LoginScreen() { + {/* Only offered once the instance says sign-ups are open -- on an + invite-only instance the register screen is a dead end. */} + {config?.open_registration ? ( + router.push('/register')} + style={{ alignSelf: 'center' }} + > + + Create an account + + + ) : null} + Self-hosted? Point the server link at your instance. diff --git a/src/auth/register-screen.tsx b/src/auth/register-screen.tsx new file mode 100644 index 0000000..a162a9c --- /dev/null +++ b/src/auth/register-screen.tsx @@ -0,0 +1,204 @@ +import { useEffect, useState } from 'react'; +import { KeyboardAvoidingView, Platform, Pressable, ScrollView, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { router, useLocalSearchParams } from 'expo-router'; + +import { registerAuthRegisterPost } from '@/api/generated/endpoints/auth/auth'; +import { usePreviewInviteInvitesTokenGet } from '@/api/generated/endpoints/invites/invites'; +import { errorDetail, formatDuration, retryAfterSeconds } from '@/api/errors'; +import { getInstanceUrl, instanceLabel, probeInstance, type InstanceConfig } from '@/api/instance'; +import { useAuth } from '@/auth/auth-context'; +import { Alert, AppText, Button, Card, Field, Logo } from '@/ui/primitives'; +import { useTokens } from '@/ui/theme'; + +/** + * Create an account, per `docs/design/mobile/140-register-invite.svg`. + * + * Reached from the login screen, which is where the instance link is entered -- + * so by the time anyone is here there is already an instance to register + * against. Arriving without one (a stale deep link, say) sends them back rather + * than posting into the void. + */ +export function RegisterScreen() { + const t = useTokens(); + const { setSession } = useAuth(); + const params = useLocalSearchParams<{ invite?: string }>(); + const inviteToken = params.invite ?? ''; + const instance = getInstanceUrl(); + + const [config, setConfig] = useState(null); + const [configPending, setConfigPending] = useState(true); + + const invite = usePreviewInviteInvitesTokenGet(inviteToken, { + query: { enabled: Boolean(inviteToken) && Boolean(instance), retry: false }, + }); + + const [fullName, setFullName] = useState(''); + const [typedEmail, setTypedEmail] = useState(''); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + if (!instance) { + router.replace('/login'); + return; + } + let cancelled = false; + void (async () => { + const result = await probeInstance(instance); + if (cancelled) return; + setConfigPending(false); + if (result.ok) setConfig(result.config); + })(); + return () => { + cancelled = true; + }; + }, [instance]); + + const openRegistration = config?.open_registration ?? true; + const invitedIn = Boolean(invite.data); + // The invitation names the address it admits, so that is the address the form + // uses -- typing a different one would only earn a 403 on submit. Derived + // rather than copied into state: the invite simply *is* the answer when there + // is one. + const email = invite.data ? invite.data.email : typedEmail; + const locked = !openRegistration && !invitedIn && !configPending; + + async function submit() { + setError(null); + if (password.length < 8) { + setError('Passwords must be at least 8 characters.'); + return; + } + + setSubmitting(true); + try { + const token = await registerAuthRegisterPost({ + email: email.trim(), + password, + full_name: fullName.trim(), + username: username.trim() || undefined, + invite_token: inviteToken || undefined, + }); + setSession(token.access_token, token.user); + // The backend accepts an invite best-effort after creating the account + // and swallows failures (`lib_identity/identity.py:144-150`), so the team + // list is re-read rather than assumed -- landing on Home shows whatever + // actually happened. + router.replace('/'); + } catch (err) { + const wait = retryAfterSeconds(err); + setError( + wait !== null + ? `Too many sign-up attempts. Try again in ${formatDuration(wait)}.` + : errorDetail(err, 'Could not create your account.'), + ); + } finally { + setSubmitting(false); + } + } + + const body = locked ? ( + + This SoftTrack is invite-only + + New accounts can only be created from an invitation link. Ask an + administrator to send you one. + + + ) : ( + + {error ? {error} : null} + + + + setUsername(value.toLowerCase())} + placeholder="Leave blank for the part before the @" + autoCapitalize="none" + autoCorrect={false} + returnKeyType="next" + /> + + + + + ); + + return ( + + + + + + + Create your account + + {invite.data + ? `${invite.data.invited_by_name} invited you to ${invite.data.team_name}.` + : locked + ? 'This instance is closed to open sign-ups.' + : instance + ? `On ${instanceLabel(instance)}.` + : ''} + + + + {body} + + router.replace('/login')} + style={{ alignSelf: 'center' }} + > + + Already have an account? Sign in + + + + + + + ); +} diff --git a/src/board/__tests__/column-layout.test.ts b/src/board/__tests__/column-layout.test.ts new file mode 100644 index 0000000..ec14610 --- /dev/null +++ b/src/board/__tests__/column-layout.test.ts @@ -0,0 +1,91 @@ +import { + columnAt, + columnWidth, + edgeScrollStep, + EDGE_SPEED, + type ColumnBounds, +} from '@/board/column-layout'; + +/** Three 200-wide columns with a 12 gutter, as the strip would lay them out. */ +const COLUMNS: ColumnBounds[] = [ + { statusId: 1, x: 0, width: 200 }, + { statusId: 2, x: 212, width: 200 }, + { statusId: 3, x: 424, width: 200 }, +]; + +describe('columnAt', () => { + it('finds the column under the finger when nothing is scrolled', () => { + expect(columnAt(COLUMNS, 100, 0)).toBe(1); + expect(columnAt(COLUMNS, 300, 0)).toBe(2); + expect(columnAt(COLUMNS, 500, 0)).toBe(3); + }); + + it('accounts for the strip being scrolled', () => { + // The whole point: a drop at the same screen x means a different column + // once the strip has moved under it. + expect(columnAt(COLUMNS, 100, 0)).toBe(1); + expect(columnAt(COLUMNS, 100, 212)).toBe(2); + expect(columnAt(COLUMNS, 100, 424)).toBe(3); + }); + + it('accounts for the board not starting at the screen edge', () => { + // On a tablet the nav rail sits to the left, so screen x is offset. + expect(columnAt(COLUMNS, 176, 0, 76)).toBe(1); + expect(columnAt(COLUMNS, 376, 0, 76)).toBe(2); + }); + + it('snaps a drop in the gutter to the nearest column', () => { + // A finger released a few pixels into the gap clearly meant the column + // beside it; doing nothing would read as the app ignoring the gesture. + expect(columnAt(COLUMNS, 205, 0)).toBe(1); + expect(columnAt(COLUMNS, 210, 0)).toBe(2); + }); + + it('snaps a drop past either end to the end column', () => { + expect(columnAt(COLUMNS, -80, 0)).toBe(1); + expect(columnAt(COLUMNS, 5000, 0)).toBe(3); + }); + + it('has no answer when there are no columns', () => { + expect(columnAt([], 100, 0)).toBeNull(); + }); +}); + +describe('columnWidth', () => { + it('leaves the neighbours peeking on a phone', () => { + // That peek is the only thing saying the strip scrolls sideways. + const width = columnWidth('compact', 390); + expect(width).toBeLessThan(390); + expect(width).toBe(320); + }); + + it('fits exactly two on a foldable, so nothing straddles the hinge', () => { + const width = columnWidth('medium', 700); + expect(width * 2 + 12).toBe(700); + }); + + it('fits exactly four on a tablet', () => { + const width = columnWidth('expanded', 1200); + expect(width * 4 + 12 * 3).toBeCloseTo(1200, 0); + }); +}); + +describe('edgeScrollStep', () => { + it('scrolls back when a card is held against the left edge', () => { + expect(edgeScrollStep(10, 0, 400)).toBe(-EDGE_SPEED); + }); + + it('scrolls on when a card is held against the right edge', () => { + expect(edgeScrollStep(390, 0, 400)).toBe(EDGE_SPEED); + }); + + it('stays still in the middle, so a drag between two visible columns does not drift', () => { + expect(edgeScrollStep(200, 0, 400)).toBe(0); + }); + + it('measures the edges from the board, not the screen', () => { + // With a nav rail at 76, the board's own left edge is where scrolling starts. + expect(edgeScrollStep(86, 76, 400)).toBe(-EDGE_SPEED); + expect(edgeScrollStep(200, 76, 400)).toBe(0); + }); +}); diff --git a/src/board/__tests__/filter-chips.test.ts b/src/board/__tests__/filter-chips.test.ts new file mode 100644 index 0000000..583cc33 --- /dev/null +++ b/src/board/__tests__/filter-chips.test.ts @@ -0,0 +1,59 @@ +import { buildChips } from '@/board/filter-chips'; +import { NO_FILTERS } from '@/board/filters'; + +const DATA = { + statuses: [{ id: 1, team_id: 1, name: 'Todo', category: 'unstarted' as const, position: 1, color: '#000' }], + labels: [{ id: 2, team_id: 1, name: 'bug', color: '#f00' }], + projects: [{ id: 3, team_id: 1, name: 'Platform' }], + cycles: [{ id: 4, display_name: 'Cycle 14' }], + members: [{ user: { id: 5, username: 'ada', full_name: 'Ada Lovelace', avatar_color: '#000' } }], +} as never as Parameters[1]; + +describe('buildChips', () => { + it('says nothing when nothing narrows the board', () => { + expect(buildChips(NO_FILTERS, DATA, jest.fn())).toEqual([]); + }); + + it('names the dimension as well as the value', () => { + // With six possible filters the value alone is ambiguous: "bug" could be a + // label or a project. + const chips = buildChips( + { statusId: 1, priority: 'urgent', assignee: 5, labelId: 2, projectId: 3, cycleId: 4 }, + DATA, + jest.fn(), + ); + expect(chips.map((c) => c.label)).toEqual([ + 'Status: Todo', + 'Priority: Urgent', + 'Assignee: Ada Lovelace', + 'Label: bug', + 'Project: Platform', + 'Cycle: Cycle 14', + ]); + }); + + it('labels unassigned as an answer rather than an id', () => { + const chips = buildChips({ ...NO_FILTERS, assignee: 'unassigned' }, DATA, jest.fn()); + expect(chips[0].label).toBe('Assignee: Unassigned'); + }); + + it('stays removable when the row it names is gone', () => { + // A link naming a label another team deleted still has to be clearable, + // so the chip falls back to the id rather than vanishing. + const onChange = jest.fn(); + const chips = buildChips({ ...NO_FILTERS, labelId: 999 }, DATA, onChange); + expect(chips[0].label).toBe('Label: 999'); + + chips[0].clear(); + expect(onChange).toHaveBeenCalledWith({ ...NO_FILTERS, labelId: null }); + }); + + it('clears only the filter it belongs to', () => { + const onChange = jest.fn(); + const filters = { ...NO_FILTERS, priority: 'high' as const, labelId: 2 }; + const chips = buildChips(filters, DATA, onChange); + + chips[0].clear(); + expect(onChange).toHaveBeenCalledWith({ ...filters, priority: null }); + }); +}); diff --git a/src/board/__tests__/filters.test.ts b/src/board/__tests__/filters.test.ts new file mode 100644 index 0000000..96713d0 --- /dev/null +++ b/src/board/__tests__/filters.test.ts @@ -0,0 +1,127 @@ +import { + activeCount, + fromParams, + isEmpty, + NO_FILTERS, + sameFilters, + toParams, + toQueryParams, +} from '@/board/filters'; + +describe('fromParams', () => { + it('reads every filter a link can carry', () => { + expect( + fromParams({ + status: '3', + priority: 'urgent', + assignee: '7', + label: '2', + project: '9', + cycle: '4', + }), + ).toEqual({ + statusId: 3, + priority: 'urgent', + assignee: 7, + labelId: 2, + projectId: 9, + cycleId: 4, + }); + }); + + it('treats unassigned as an answer, not an absent filter', () => { + // "assigned to nobody" and "assigned to somebody" answer the same question, + // which is why it is a value of assignee rather than a flag beside it. + expect(fromParams({ assignee: 'unassigned' }).assignee).toBe('unassigned'); + }); + + it('is unfiltered when the link says nothing', () => { + expect(fromParams({})).toEqual(NO_FILTERS); + }); + + it('ignores values a hand-edited link could carry', () => { + // Falling back to "no filter" rather than sending NaN and earning a 422. + expect( + fromParams({ + status: 'abc', + priority: 'catastrophic', + assignee: '-1', + label: '0', + project: '1.5', + cycle: '', + }), + ).toEqual(NO_FILTERS); + }); + + it('takes the first value when a key is repeated', () => { + expect(fromParams({ status: ['3', '5'] }).statusId).toBe(3); + }); +}); + +describe('toParams', () => { + it('clears a dropped filter rather than leaving it in the URL', () => { + // expo-router MERGES setParams into the existing params, so a cleared key + // has to be present as undefined or its old value survives. + const params = toParams({ ...NO_FILTERS, priority: 'high' }); + expect(params.priority).toBe('high'); + expect(params).toHaveProperty('status'); + expect(params.status).toBeUndefined(); + }); + + it('round-trips through fromParams', () => { + const filters = { + statusId: 3, + priority: 'low' as const, + assignee: 'unassigned' as const, + labelId: 2, + projectId: 9, + cycleId: 4, + }; + const params = toParams(filters); + const defined = Object.fromEntries( + Object.entries(params).filter(([, v]) => v !== undefined), + ); + expect(fromParams(defined)).toEqual(filters); + }); +}); + +describe('toQueryParams', () => { + it('sends only the filters that are set', () => { + expect(toQueryParams({ ...NO_FILTERS, labelId: 4 })).toEqual({ + status_id: undefined, + priority: undefined, + assignee_id: undefined, + unassigned: undefined, + label_id: 4, + project_id: undefined, + cycle_id: undefined, + }); + }); + + it('maps unassigned onto the flag the API actually has', () => { + // The backend takes `unassigned=true`, which overrides assignee_id, rather + // than a sentinel assignee id. + const params = toQueryParams({ ...NO_FILTERS, assignee: 'unassigned' }); + expect(params.unassigned).toBe(true); + expect(params.assignee_id).toBeUndefined(); + }); + + it('sends a real assignee as an id', () => { + const params = toQueryParams({ ...NO_FILTERS, assignee: 12 }); + expect(params.assignee_id).toBe(12); + expect(params.unassigned).toBeUndefined(); + }); +}); + +describe('activeCount / isEmpty / sameFilters', () => { + it('counts only what narrows the board', () => { + expect(activeCount(NO_FILTERS)).toBe(0); + expect(isEmpty(NO_FILTERS)).toBe(true); + expect(activeCount({ ...NO_FILTERS, priority: 'urgent', labelId: 1 })).toBe(2); + }); + + it('compares the question, not the object', () => { + expect(sameFilters(NO_FILTERS, { ...NO_FILTERS })).toBe(true); + expect(sameFilters(NO_FILTERS, { ...NO_FILTERS, cycleId: 1 })).toBe(false); + }); +}); diff --git a/src/board/__tests__/kanban-board.test.tsx b/src/board/__tests__/kanban-board.test.tsx new file mode 100644 index 0000000..c7dc438 --- /dev/null +++ b/src/board/__tests__/kanban-board.test.tsx @@ -0,0 +1,108 @@ +import { render } from '@testing-library/react-native'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; + +import type { EstimateSummary, IssueRead, StatusRead } from '@/api/generated/models'; +import { KanbanBoard } from '@/board/kanban-board'; +import { ThemeProvider } from '@/ui/theme'; + +const METRICS = { + frame: { x: 0, y: 0, width: 390, height: 844 }, + insets: { top: 47, left: 0, right: 0, bottom: 34 }, +}; + +function status(id: number, name: string, category: StatusRead['category']): StatusRead { + return { id, team_id: 1, name, category, position: id, color: '#6342db' }; +} + +const STATUSES = [ + status(1, 'Todo', 'unstarted'), + status(2, 'In Progress', 'started'), + status(3, 'Cancelled', 'cancelled'), +]; + +function issue(id: number, statusId: number, overrides: Partial = {}): IssueRead { + const user = { + id: 1, + username: 'demo', + full_name: 'Demo User', + avatar_color: '#6342db', + }; + return { + id, + team_id: 1, + team_key: 'ENG', + number: id, + identifier: `ENG-${id}`, + title: `Issue ${id}`, + status: STATUSES.find((s) => s.id === statusId)!, + priority: 'medium', + blocked_by_count: 0, + child_count: 0, + completed_child_count: 0, + creator: user, + labels: [], + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + ...overrides, + } as IssueRead; +} + +const ESTIMATES = { + total_points: 5, + total_issues: 2, + unestimated_issues: 1, + by_status: { + // Keyed by status id as a string, which is what the server sends. + '1': { points: 5, issue_count: 1, unestimated_count: 1 }, + }, + by_assignee: [], +} as unknown as EstimateSummary; + +function renderBoard(issues: IssueRead[]) { + return render( + + + + + , + ); +} + +describe('KanbanBoard', () => { + it('draws a column per team status', async () => { + const view = await renderBoard([issue(1, 1), issue(2, 2)]); + for (const name of ['Todo', 'In Progress', 'Cancelled']) { + expect(view.getByLabelText(name)).toBeTruthy(); + } + }); + + it('puts each issue in its own column', async () => { + const view = await renderBoard([issue(1, 1), issue(2, 2)]); + expect(view.getByLabelText('ENG-1 Issue 1')).toBeTruthy(); + expect(view.getByLabelText('ENG-2 Issue 2')).toBeTruthy(); + }); + + it('starts a cancelled column collapsed', async () => { + // By category, not by name -- a team may well call it "Won't do". + const view = await renderBoard([issue(1, 1)]); + expect(view.getByLabelText('Expand Cancelled')).toBeTruthy(); + expect(view.queryByLabelText('Expand Todo')).toBeNull(); + }); + + it('shows the point total the server rolled up', async () => { + const view = await renderBoard([issue(1, 1)]); + // "+?" because an unsized issue is not worth zero. + expect(view.getByText(/5 pts/)).toBeTruthy(); + }); + + it('marks a blocked card', async () => { + const view = await renderBoard([issue(1, 1, { blocked_by_count: 2 })]); + expect(view.getByLabelText('Blocked by 2 issues')).toBeTruthy(); + }); +}); diff --git a/src/board/board-screen.tsx b/src/board/board-screen.tsx new file mode 100644 index 0000000..08fbe15 --- /dev/null +++ b/src/board/board-screen.tsx @@ -0,0 +1,406 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Pressable, StyleSheet, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { router, useLocalSearchParams } from 'expo-router'; + +import { useListIssuesTeamsTeamIdIssuesGet } from '@/api/generated/endpoints/issues/issues'; +import type { IssueRead, StatusRead } from '@/api/generated/models'; +import { buildChips, FilterChips } from '@/board/filter-chips'; +import { FilterSheet } from '@/board/filter-sheet'; +import { fromParams, isEmpty, toParams, toQueryParams, type BoardFilters } from '@/board/filters'; +import { IssueList } from '@/board/issue-list'; +import { KanbanBoard } from '@/board/kanban-board'; +import { useStatusChange } from '@/board/use-status-change'; +import { CycleBanner, CyclesSheet } from '@/cycles/cycles-screen'; +import { ReportsSheet } from '@/reports/reports-sheet'; +import { OfflineBanner, SyncQueueSheet } from '@/offline/sync-status'; +import { useListViewsTeamsTeamIdViewsGet } from '@/api/generated/endpoints/views/views'; +import { useAuth } from '@/auth/auth-context'; +import { fromViewFilters } from '@/views/saved-views'; +import { ViewsSheet } from '@/views/views-sheet'; +import { useTeamData } from '@/board/use-team-data'; +import { NewTeamSheet } from '@/team/new-team-sheet'; +import { useTeams } from '@/team/team-context'; +import { TeamSwitcher } from '@/team/team-switcher'; +import { href } from '@/ui/href'; +import { Icon } from '@/ui/icon'; +import { useIsMultiPane } from '@/ui/layout'; +import { AppText, Button, Loading, TeamBadge } from '@/ui/primitives'; +import { Sheet } from '@/ui/sheet'; +import { useTokens } from '@/ui/theme'; + +/** + * Board and list, per `docs/design/mobile/142-board-list.svg`. + * + * Filters live in the route's query params rather than in component state, the + * same choice the web makes -- that is what makes the board someone is looking + * at a link they can send, and it uses the same keys, so a URL copied out of + * the web app narrows this board the same way. + * + * The issue list is one page (the API caps at 200), so columns are grouped from + * it client-side while per-column point totals come from the server's own + * rollup -- summing the loaded page would quietly under-report a long column. + */ +type BoardView = 'board' | 'list'; + +export function BoardScreen() { + const t = useTokens(); + const multiPane = useIsMultiPane(); + const { team, teams, isPending: teamsPending } = useTeams(); + + const params = useLocalSearchParams(); + const filters = useMemo(() => fromParams(params), [params]); + const setFilters = useCallback((next: BoardFilters) => { + router.setParams(toParams(next)); + }, []); + + const [view, setView] = useState('board'); + const [filterOpen, setFilterOpen] = useState(false); + const [viewsOpen, setViewsOpen] = useState(false); + const [cyclesOpen, setCyclesOpen] = useState(false); + const [reportsOpen, setReportsOpen] = useState(false); + const [syncOpen, setSyncOpen] = useState(false); + const [switching, setSwitching] = useState(false); + const [creating, setCreating] = useState(false); + const [moving, setMoving] = useState(null); + + const { user } = useAuth(); + const views = useListViewsTeamsTeamIdViewsGet(team?.id ?? 0, { + query: { enabled: Boolean(team) }, + }); + + /** + * Land on the default view, at most once per team. + * + * `effective_default_id` is the server's own resolution of yours, then the + * team's, then nothing -- so the client does not re-derive that order. A ref + * rather than state because nothing renders differently for having landed; it + * only stops the redirect happening again after someone clears the filters on + * purpose. + */ + const landed = useRef(null); + useEffect(() => { + if (!team || views.isPending) return; + if (landed.current === team.id) return; + landed.current = team.id; + + const defaultId = views.data?.effective_default_id; + if (!defaultId || !isEmpty(filters)) return; + const view = views.data?.items.find((candidate) => candidate.id === defaultId); + if (view) router.setParams(toParams(fromViewFilters(view.filters))); + }, [team, views.isPending, views.data, filters]); + + const data = useTeamData(team); + const queryParams = useMemo( + // The cap is the API's own MAX_LIMIT; a board wants every column at once, + // not a page of one of them. + () => ({ ...toQueryParams(filters), limit: 200 }), + [filters], + ); + + const issuesQuery = useListIssuesTeamsTeamIdIssuesGet(team?.id ?? 0, queryParams, { + query: { enabled: Boolean(team) }, + }); + const issues = issuesQuery.data?.items ?? []; + + const move = useStatusChange(team, queryParams); + const chips = useMemo( + () => buildChips(filters, data, setFilters), + [filters, data, setFilters], + ); + + const onMove = useCallback( + (issueId: number, status: StatusRead) => { + void move(issueId, status); + }, + [move], + ); + + if (teamsPending) return ; + + if (!team) { + return ( + + + No teams yet + + A board belongs to a team. Create one to get started. + + + + setCreating(false)} /> + + ); + } + + return ( + + + setSwitching(true)} + style={{ flexDirection: 'row', alignItems: 'center', gap: 8, flex: 1 }} + > + + + {multiPane ? `${team.key} · ${team.name}` : `${team.key} · Board`} + + + + + + + setReportsOpen(true)} + style={{ + width: 36, + height: 36, + borderRadius: t.radius.control, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: t.line.ghost, + }} + > + + + + setViewsOpen(true)} + style={{ + width: 36, + height: 36, + borderRadius: t.radius.control, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: t.line.ghost, + }} + > + + + + setFilterOpen(true)} + style={{ + width: 36, + height: 36, + borderRadius: t.radius.control, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: chips.length > 0 ? t.line.navActive : t.line.ghost, + }} + > + 0 ? t.brand[600] : t.neutral[600]} + /> + + + + setSyncOpen(true)} /> + + setCyclesOpen(true)} /> + + setFilterOpen(true)} /> + + {issuesQuery.isPending ? ( + + ) : view === 'board' ? ( + router.push(href(`/issue/${issue.id}`))} + /> + ) : ( + router.push(href(`/issue/${issue.id}`))} + onMovePress={setMoving} + /> + )} + + setSyncOpen(false)} /> + + setReportsOpen(false)} + team={team} + /> + + setCyclesOpen(false)} + team={team} + /> + + setViewsOpen(false)} + team={team} + user={user} + filters={filters} + onApply={setFilters} + /> + + setFilterOpen(false)} + filters={filters} + onChange={setFilters} + statuses={data.statuses} + labels={data.labels} + projects={data.projects} + cycles={data.cycles} + members={data.members} + /> + + {/* The fallback the issue asks for beside dragging: a long press on a + list row, and the only way to move a card without a drag at all. */} + setMoving(null)} + title={moving ? `Move ${moving.identifier}` : 'Move'} + > + + {data.statuses.map((status) => ( + { + if (moving && moving.status.id !== status.id) onMove(moving.id, status); + setMoving(null); + }} + style={{ + flexDirection: 'row', + alignItems: 'center', + gap: 10, + paddingVertical: 12, + paddingHorizontal: 10, + borderRadius: t.radius.control, + backgroundColor: + moving?.status.id === status.id ? t.line.navActive : 'transparent', + }} + > + + + {status.name} + + {moving?.status.id === status.id ? ( + + ) : null} + + ))} + + + + router.push('/new-issue')} + style={{ + position: 'absolute', + right: 20, + bottom: 24, + width: 56, + height: 56, + borderRadius: 28, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: t.brand[600], + shadowColor: '#000', + shadowOpacity: 0.25, + shadowRadius: 12, + shadowOffset: { width: 0, height: 6 }, + elevation: 6, + }} + > + + + + setSwitching(false)} + onCreateTeam={() => setCreating(true)} + /> + setCreating(false)} /> + {teams.length === 0 ? null : null} + + ); +} + +function Segmented({ + value, + onChange, +}: { + value: BoardView; + onChange: (next: BoardView) => void; +}) { + const t = useTokens(); + return ( + + {(['board', 'list'] as const).map((option) => { + const active = value === option; + return ( + onChange(option)} + style={{ + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: t.radius.control - 2, + backgroundColor: active ? t.surface.card : 'transparent', + borderWidth: active ? StyleSheet.hairlineWidth : 0, + borderColor: t.surface.border, + }} + > + + + ); + })} + + ); +} diff --git a/src/board/column-layout.ts b/src/board/column-layout.ts new file mode 100644 index 0000000..1d18ce3 --- /dev/null +++ b/src/board/column-layout.ts @@ -0,0 +1,90 @@ +/** + * Where the columns are, and which one a dropped card landed on. + * + * Split out from the gesture code and kept pure on purpose: hit-testing across + * a horizontally scrolled strip is the part most likely to be subtly wrong, and + * it is the only part that can be tested without a device. + */ + +/** A column's position in the scroll content, not on screen. */ +export type ColumnBounds = { + statusId: number; + /** Left edge, in content coordinates. */ + x: number; + width: number; +}; + +/** + * The column under a point. + * + * `pointerX` is a screen coordinate; `scrollX` converts it into the content + * coordinate space the bounds were measured in, which is what makes this work + * while the strip is scrolled or mid-auto-scroll. + * + * Falls back to the nearest column rather than nothing: a finger released a few + * pixels into the gutter clearly meant the column beside it, and dropping the + * card back where it started would read as the app ignoring the gesture. + */ +export function columnAt( + columns: ColumnBounds[], + pointerX: number, + scrollX: number, + boardOriginX = 0, +): number | null { + if (columns.length === 0) return null; + + const contentX = pointerX - boardOriginX + scrollX; + + for (const column of columns) { + if (contentX >= column.x && contentX < column.x + column.width) { + return column.statusId; + } + } + + let nearest = columns[0]; + let bestDistance = Infinity; + for (const column of columns) { + const centre = column.x + column.width / 2; + const distance = Math.abs(contentX - centre); + if (distance < bestDistance) { + bestDistance = distance; + nearest = column; + } + } + return nearest.statusId; +} + +/** How wide each column is at a given size class, per the mockups. */ +export const COLUMN_GAP = 12; + +export function columnWidth(sizeClass: 'compact' | 'medium' | 'expanded', boardWidth: number) { + // Phone shows one column with its neighbours peeking, which is what says + // "this strip scrolls sideways" without a scrollbar to point at. + if (sizeClass === 'compact') return Math.round(boardWidth * 0.82); + // The foldable's hinge is the column gutter: exactly two, never a card + // straddling the seam. + if (sizeClass === 'medium') return Math.round((boardWidth - COLUMN_GAP) / 2); + // Tablet fits four; more than that and cards get too narrow to read. + return Math.round((boardWidth - COLUMN_GAP * 3) / 4); +} + +/** + * How far to scroll when a lifted card is held near an edge. + * + * Returns pixels per frame, signed. Zero in the middle, so a card being dragged + * between two visible columns does not drift the strip under it. + */ +export const EDGE_ZONE = 64; +export const EDGE_SPEED = 12; + +export function edgeScrollStep(pointerX: number, boardOriginX: number, boardWidth: number): number { + // Runs on the UI thread from the drag's frame callback, so that a card held + // still against an edge keeps scrolling. Harmless on the JS thread, which is + // where the tests call it. + 'worklet'; + const left = pointerX - boardOriginX; + const right = boardOriginX + boardWidth - pointerX; + if (left < EDGE_ZONE) return -EDGE_SPEED; + if (right < EDGE_ZONE) return EDGE_SPEED; + return 0; +} diff --git a/src/board/filter-chips.tsx b/src/board/filter-chips.tsx new file mode 100644 index 0000000..3b5cae2 --- /dev/null +++ b/src/board/filter-chips.tsx @@ -0,0 +1,157 @@ +import { Pressable, ScrollView, View } from 'react-native'; + +import type { + CycleRead, + LabelRead, + ProjectRead, + StatusRead, + TeamMemberRead, +} from '@/api/generated/models'; +import type { BoardFilters } from '@/board/filters'; +import { PRIORITY_LABEL } from '@/issues/issue-meta'; +import { AppText } from '@/ui/primitives'; +import { useTokens } from '@/ui/theme'; + +/** + * Active filters, each removable, per `docs/design/mobile/142-board-list.svg`. + * + * Every chip names both the dimension and the value ("Priority: Urgent"), + * because with six possible filters the value alone is ambiguous -- "bug" could + * be a label or a project. + */ +type Chip = { key: string; label: string; clear: () => void }; + +export function buildChips( + filters: BoardFilters, + data: { + statuses: StatusRead[]; + labels: LabelRead[]; + projects: ProjectRead[]; + cycles: CycleRead[]; + members: TeamMemberRead[]; + }, + onChange: (next: BoardFilters) => void, +): Chip[] { + const chips: Chip[] = []; + const clear = (key: K) => () => + onChange({ ...filters, [key]: null }); + + if (filters.statusId !== null) { + const status = data.statuses.find((s) => s.id === filters.statusId); + // A filter naming a row this team no longer has still has to be removable, + // so the chip falls back to the id rather than disappearing. + chips.push({ + key: 'status', + label: `Status: ${status?.name ?? filters.statusId}`, + clear: clear('statusId'), + }); + } + + if (filters.priority) { + chips.push({ + key: 'priority', + label: `Priority: ${PRIORITY_LABEL[filters.priority]}`, + clear: clear('priority'), + }); + } + + if (filters.assignee !== null) { + const name = + filters.assignee === 'unassigned' + ? 'Unassigned' + : (data.members.find((m) => m.user.id === filters.assignee)?.user.full_name ?? + String(filters.assignee)); + chips.push({ key: 'assignee', label: `Assignee: ${name}`, clear: clear('assignee') }); + } + + if (filters.labelId !== null) { + const label = data.labels.find((l) => l.id === filters.labelId); + chips.push({ + key: 'label', + label: `Label: ${label?.name ?? filters.labelId}`, + clear: clear('labelId'), + }); + } + + if (filters.projectId !== null) { + const project = data.projects.find((p) => p.id === filters.projectId); + chips.push({ + key: 'project', + label: `Project: ${project?.name ?? filters.projectId}`, + clear: clear('projectId'), + }); + } + + if (filters.cycleId !== null) { + const cycle = data.cycles.find((c) => c.id === filters.cycleId); + chips.push({ + key: 'cycle', + label: `Cycle: ${cycle?.display_name ?? filters.cycleId}`, + clear: clear('cycleId'), + }); + } + + return chips; +} + +export function FilterChips({ + chips, + onAdd, +}: { + chips: Chip[]; + onAdd: () => void; +}) { + const t = useTokens(); + + return ( + + {chips.map((chip) => ( + + + {chip.label} + + + × + + + ))} + + + + Filter + + + + ); +} diff --git a/src/board/filter-sheet.tsx b/src/board/filter-sheet.tsx new file mode 100644 index 0000000..aa8f5e2 --- /dev/null +++ b/src/board/filter-sheet.tsx @@ -0,0 +1,179 @@ +import { Pressable, View } from 'react-native'; + +import type { + CycleRead, + LabelRead, + ProjectRead, + StatusRead, + TeamMemberRead, +} from '@/api/generated/models'; +import type { BoardFilters } from '@/board/filters'; +import { NO_FILTERS } from '@/board/filters'; +import { PRIORITY_LABEL, PRIORITY_ORDER, priorityColor } from '@/issues/issue-meta'; +import { Icon } from '@/ui/icon'; +import { AppText, Button } from '@/ui/primitives'; +import { Sheet } from '@/ui/sheet'; +import { useTokens } from '@/ui/theme'; + +/** + * The six composable filters, applied server-side. + * + * One sheet rather than the web's inline bar: a phone has no room for six + * dropdowns beside a board, and every one of them is a single choice, so a list + * of rows with a tick reads better than a row of collapsed menus. + */ +type Option = { key: string; label: string; color?: string; selected: boolean; onPress: () => void }; + +function Group({ title, options }: { title: string; options: Option[] }) { + const t = useTokens(); + if (options.length === 0) return null; + + return ( + + {title} + {options.map((option) => ( + + {option.color ? ( + + ) : null} + + {option.label} + + {option.selected ? : null} + + ))} + + ); +} + +export function FilterSheet({ + visible, + onClose, + filters, + onChange, + statuses, + labels, + projects, + cycles, + members, +}: { + visible: boolean; + onClose: () => void; + filters: BoardFilters; + onChange: (next: BoardFilters) => void; + statuses: StatusRead[]; + labels: LabelRead[]; + projects: ProjectRead[]; + cycles: CycleRead[]; + members: TeamMemberRead[]; +}) { + const t = useTokens(); + + /** Tapping the selected option clears it, so every row is its own toggle. */ + function toggle(key: K, value: BoardFilters[K]) { + onChange({ ...filters, [key]: filters[key] === value ? null : value }); + } + + return ( + + ({ + key: `status-${status.id}`, + label: status.name, + color: status.color, + selected: filters.statusId === status.id, + onPress: () => toggle('statusId', status.id), + }))} + /> + + ({ + key: `priority-${priority}`, + label: PRIORITY_LABEL[priority], + color: priorityColor(priority, t), + selected: filters.priority === priority, + onPress: () => toggle('priority', priority), + }))} + /> + + toggle('assignee', 'unassigned'), + }, + ...members.map((member) => ({ + key: `assignee-${member.user.id}`, + label: member.user.full_name, + selected: filters.assignee === member.user.id, + onPress: () => toggle('assignee', member.user.id), + })), + ]} + /> + + ({ + key: `label-${label.id}`, + label: label.name, + color: label.color, + selected: filters.labelId === label.id, + onPress: () => toggle('labelId', label.id), + }))} + /> + + ({ + key: `project-${project.id}`, + label: project.name, + selected: filters.projectId === project.id, + onPress: () => toggle('projectId', project.id), + }))} + /> + + ({ + key: `cycle-${cycle.id}`, + label: cycle.display_name, + selected: filters.cycleId === cycle.id, + onPress: () => toggle('cycleId', cycle.id), + }))} + /> + + + + + + ); +} diff --git a/src/board/filters.ts b/src/board/filters.ts new file mode 100644 index 0000000..ab734aa --- /dev/null +++ b/src/board/filters.ts @@ -0,0 +1,135 @@ +import type { + IssuePriority, + ListIssuesTeamsTeamIdIssuesGetParams, +} from '@/api/generated/models'; + +/** + * What the board is narrowed to. + * + * A port of `frontend/src/board/filters.ts`, deliberately keeping the same + * query-string keys. Those end up in links people paste to each other, so a + * board URL copied out of the web app narrows the same way here. + * + * Null everywhere means "all issues" rather than a filter matching nothing. + * `unassigned` is a value of `assignee` rather than a flag beside it, because + * "assigned to nobody" and "assigned to somebody" answer the same question. + */ +export type AssigneeFilter = number | 'unassigned' | null; + +export type BoardFilters = { + statusId: number | null; + priority: IssuePriority | null; + assignee: AssigneeFilter; + labelId: number | null; + projectId: number | null; + cycleId: number | null; +}; + +export const NO_FILTERS: BoardFilters = { + statusId: null, + priority: null, + assignee: null, + labelId: null, + projectId: null, + cycleId: null, +}; + +/** + * The query-string key for each filter. Short and stable, and identical to the + * web's -- renaming one breaks every link already sent. + */ +const KEYS = { + statusId: 'status', + priority: 'priority', + assignee: 'assignee', + labelId: 'label', + projectId: 'project', + cycleId: 'cycle', +} as const; + +const PRIORITIES = ['urgent', 'high', 'medium', 'low', 'no_priority']; + +/** expo-router hands params back as strings, or arrays when repeated. */ +export type RouteParams = Record; + +function one(value: string | string[] | undefined): string | null { + if (Array.isArray(value)) return value[0] ?? null; + return value ?? null; +} + +function readNumber(raw: string | null): number | null { + if (raw === null) return null; + const value = Number(raw); + // A hand-edited or truncated link should fall back to "no filter" rather + // than sending NaN to the API and earning a 422. + return Number.isInteger(value) && value > 0 ? value : null; +} + +/** The filters a route is asking for. Anything unparseable is simply not a filter. */ +export function fromParams(params: RouteParams): BoardFilters { + const assignee = one(params[KEYS.assignee]); + const priority = one(params[KEYS.priority]); + + return { + // A status is a row id, so the same "unparseable means no filter" rule + // covers a link naming a status another team deleted. + statusId: readNumber(one(params[KEYS.statusId])), + priority: priority && PRIORITIES.includes(priority) ? (priority as IssuePriority) : null, + assignee: assignee === 'unassigned' ? 'unassigned' : readNumber(assignee), + labelId: readNumber(one(params[KEYS.labelId])), + projectId: readNumber(one(params[KEYS.projectId])), + cycleId: readNumber(one(params[KEYS.cycleId])), + }; +} + +/** + * Filters as route params. + * + * Every key is present, with cleared ones as `undefined`: expo-router merges + * `setParams` into the existing params rather than replacing them, so omitting + * a cleared key would leave the old value in the URL. + */ +export function toParams(filters: BoardFilters): Record { + return { + [KEYS.statusId]: filters.statusId === null ? undefined : String(filters.statusId), + [KEYS.priority]: filters.priority ?? undefined, + [KEYS.assignee]: filters.assignee === null ? undefined : String(filters.assignee), + [KEYS.labelId]: filters.labelId === null ? undefined : String(filters.labelId), + [KEYS.projectId]: filters.projectId === null ? undefined : String(filters.projectId), + [KEYS.cycleId]: filters.cycleId === null ? undefined : String(filters.cycleId), + }; +} + +/** The same filters as the issue list endpoint wants them. */ +export function toQueryParams( + filters: BoardFilters, +): ListIssuesTeamsTeamIdIssuesGetParams { + return { + status_id: filters.statusId ?? undefined, + priority: filters.priority ?? undefined, + assignee_id: typeof filters.assignee === 'number' ? filters.assignee : undefined, + unassigned: filters.assignee === 'unassigned' ? true : undefined, + label_id: filters.labelId ?? undefined, + project_id: filters.projectId ?? undefined, + cycle_id: filters.cycleId ?? undefined, + }; +} + +export function activeCount(filters: BoardFilters): number { + return Object.values(filters).filter((value) => value !== null).length; +} + +export function isEmpty(filters: BoardFilters): boolean { + return activeCount(filters) === 0; +} + +export function sameFilters(a: BoardFilters, b: BoardFilters): boolean { + return ( + a.statusId === b.statusId && + a.priority === b.priority && + a.assignee === b.assignee && + a.labelId === b.labelId && + a.projectId === b.projectId && + a.cycleId === b.cycleId + ); +} diff --git a/src/board/issue-card.tsx b/src/board/issue-card.tsx new file mode 100644 index 0000000..27071f2 --- /dev/null +++ b/src/board/issue-card.tsx @@ -0,0 +1,131 @@ +import { StyleSheet, Text, View } from 'react-native'; + +import type { IssueRead } from '@/api/generated/models'; +import { priorityColor } from '@/issues/issue-meta'; +import { Avatar, AppText } from '@/ui/primitives'; +import { useTokens } from '@/ui/theme'; + +/** + * A card on the board, per `docs/design/mobile/142-board-list.svg`: + * identifier and markers on top, title, then labels and assignee. + * + * Pure presentation -- the gesture that lifts it lives in the column, so the + * same card renders in the list view and inside the drag overlay unchanged. + */ +export function IssueCard({ issue }: { issue: IssueRead }) { + const t = useTokens(); + + return ( + + + {issue.identifier} + + + {/* Derived server-side as blocked_by_count, so the board does not have + to fetch links per card to know a card is stuck. */} + {issue.blocked_by_count > 0 ? ( + + + + {issue.blocked_by_count} + + + ) : null} + + {issue.child_count > 0 ? ( + + {issue.completed_child_count}/{issue.child_count} + + ) : null} + + {issue.estimate != null ? ( + + + {issue.estimate} + + + ) : null} + + + + + + {issue.title} + + + + + {issue.labels?.slice(0, 2).map((label) => ( + + {label.name} + + ))} + + + {issue.assignee ? ( + + ) : null} + + + ); +} diff --git a/src/board/issue-list.tsx b/src/board/issue-list.tsx new file mode 100644 index 0000000..66bda84 --- /dev/null +++ b/src/board/issue-list.tsx @@ -0,0 +1,111 @@ +import { FlatList, Pressable, StyleSheet, View } from 'react-native'; + +import type { IssueRead, StatusRead } from '@/api/generated/models'; +import { priorityColor } from '@/issues/issue-meta'; +import { Avatar, AppText } from '@/ui/primitives'; +import { useTokens } from '@/ui/theme'; + +/** + * The same filtered issue set as the board, as one vertical list. + * + * Rows rather than cards: a list exists to be scanned, so the status moves from + * "which column am I in" to an explicit dot, and the layout stays one line of + * metadata per issue. + */ +export function IssueList({ + issues, + onPress, + onMovePress, +}: { + issues: IssueRead[]; + onPress: (issue: IssueRead) => void; + onMovePress: (issue: IssueRead) => void; +}) { + const t = useTokens(); + + return ( + String(issue.id)} + contentContainerStyle={{ padding: 12, gap: 8 }} + ListEmptyComponent={ + + No issues match these filters. + + } + renderItem={({ item }) => ( + onPress(item)} + onLongPress={() => onMovePress(item)} + style={{ + flexDirection: 'row', + alignItems: 'center', + gap: 10, + backgroundColor: t.surface.card, + borderRadius: t.radius.card, + borderWidth: StyleSheet.hairlineWidth, + borderColor: t.surface.border, + padding: 12, + }} + > + + + + + {item.identifier} + {item.blocked_by_count > 0 ? ( + + blocked + + ) : null} + + + {item.title} + + + + + + {item.assignee ? ( + + ) : ( + + )} + + )} + /> + ); +} + +function StatusDot({ status }: { status: StatusRead }) { + return ( + + ); +} diff --git a/src/board/kanban-board.tsx b/src/board/kanban-board.tsx new file mode 100644 index 0000000..5ad5f6f --- /dev/null +++ b/src/board/kanban-board.tsx @@ -0,0 +1,395 @@ +import { useCallback, useRef, useState } from 'react'; +import { Pressable, ScrollView, StyleSheet, View } from 'react-native'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import Animated, { + runOnJS, + scrollTo, + useAnimatedRef, + useAnimatedScrollHandler, + useAnimatedStyle, + useFrameCallback, + useSharedValue, +} from 'react-native-reanimated'; + +import type { EstimateSummary, IssueRead, StatusRead } from '@/api/generated/models'; +import { IssueCard } from '@/board/issue-card'; +import { + COLUMN_GAP, + columnAt, + columnWidth, + edgeScrollStep, + type ColumnBounds, +} from '@/board/column-layout'; +import { COLLAPSED_CATEGORIES } from '@/issues/issue-meta'; +import { Icon } from '@/ui/icon'; +import { useSizeClass } from '@/ui/layout'; +import { AppText } from '@/ui/primitives'; +import { useTokens } from '@/ui/theme'; + +/** How long a press has to be held before the card lifts. */ +const LIFT_MS = 250; + +type Load = EstimateSummary['by_status'][string]; + +function ColumnHeader({ + status, + count, + load, + collapsed, + onToggle, +}: { + status: StatusRead; + count: number; + load: Load | undefined; + collapsed: boolean; + onToggle: () => void; +}) { + const t = useTokens(); + return ( + + + + {status.name} + + + {count} + + + + + {load && load.points > 0 ? ( + + {load.points} pts + {/* An unsized issue is not worth zero, so say so rather than let the + total read as complete. */} + {load.unestimated_count > 0 ? ' +?' : ''} + + ) : null} + + + + ); +} + +export function KanbanBoard({ + statuses, + issues, + estimates, + onMove, + onCardPress, +}: { + statuses: StatusRead[]; + issues: IssueRead[]; + estimates: EstimateSummary | undefined; + onMove: (issueId: number, status: StatusRead) => void; + onCardPress: (issue: IssueRead) => void; +}) { + const t = useTokens(); + const sizeClass = useSizeClass(); + + const scrollRef = useAnimatedRef(); + const scrollX = useSharedValue(0); + const pointerX = useSharedValue(0); + const pointerY = useSharedValue(0); + const dragging = useSharedValue(false); + + const [boardWidth, setBoardWidth] = useState(0); + const [boardOriginX, setBoardOriginX] = useState(0); + const [lifted, setLifted] = useState(null); + const [collapsed, setCollapsed] = useState>(() => + Object.fromEntries( + statuses + .filter((status) => COLLAPSED_CATEGORIES.includes(status.category)) + .map((status) => [status.id, true]), + ), + ); + + // Measured in content coordinates, which is what makes hit-testing survive + // the strip being scrolled mid-drag. + const bounds = useRef([]); + const boardRef = useRef(null); + + const width = boardWidth > 0 ? columnWidth(sizeClass, boardWidth) : 0; + + const scrollHandler = useAnimatedScrollHandler((event) => { + scrollX.set(event.contentOffset.x); + }); + + // A card held still against an edge has to keep scrolling, which a + // movement-driven handler would never do. + const frame = useFrameCallback(() => { + 'worklet'; + if (!dragging.get()) return; + const step = edgeScrollStep(pointerX.get(), boardOriginX, boardWidth); + if (step !== 0) scrollTo(scrollRef, scrollX.get() + step, 0, false); + }, false); + + /** + * Pointer tracking lives here rather than in the card. + * + * The React Compiler treats every prop as immutable, so a card handed these + * shared values could not write to them -- and routing each move through + * runOnJS would put a bridge hop between the finger and the card. Passing + * worklets down instead keeps the writes on the UI thread and in the scope + * that owns the values. + */ + const trackPointer = useCallback( + (x: number, y: number) => { + 'worklet'; + pointerX.set(x); + pointerY.set(y); + }, + [pointerX, pointerY], + ); + + const setDragging = useCallback( + (active: boolean) => { + 'worklet'; + dragging.set(active); + }, + [dragging], + ); + + const beginDrag = useCallback( + (issue: IssueRead) => { + setLifted(issue); + frame.setActive(true); + }, + [frame], + ); + + const endDrag = useCallback( + (issue: IssueRead, x: number) => { + frame.setActive(false); + setLifted(null); + + const targetId = columnAt(bounds.current, x, scrollX.get(), boardOriginX); + const target = statuses.find((status) => status.id === targetId); + // Dropping a card back where it started is not a move. + if (target && target.id !== issue.status.id) onMove(issue.id, target); + }, + [frame, scrollX, boardOriginX, statuses, onMove], + ); + + const overlayStyle = useAnimatedStyle(() => ({ + transform: [ + { translateX: pointerX.get() - boardOriginX - width / 2 }, + { translateY: pointerY.get() - 40 }, + { rotate: '2deg' }, + ], + })); + + return ( + { + // Screen-space origin, so the gesture's absoluteX can be converted into + // the content coordinates the columns were measured in. + boardRef.current?.measureInWindow((x, _y, w) => { + setBoardOriginX(x); + setBoardWidth(w); + }); + }} + > + + {statuses.map((status, index) => { + const columnIssues = issues.filter((issue) => issue.status.id === status.id); + const load = estimates?.by_status?.[String(status.id)]; + const isCollapsed = collapsed[status.id] === true; + + return ( + { + const { x, width: w } = event.nativeEvent.layout; + bounds.current = [ + ...bounds.current.filter((b) => b.statusId !== status.id), + { statusId: status.id, x, width: w }, + ]; + }} + style={{ + width: isCollapsed ? 52 : width, + backgroundColor: t.surface.subtle, + borderRadius: t.radius.panel, + borderWidth: StyleSheet.hairlineWidth, + borderColor: t.line.hairline, + overflow: 'hidden', + }} + > + {isCollapsed ? ( + + setCollapsed((prev) => ({ ...prev, [status.id]: false })) + } + style={{ flex: 1, alignItems: 'center', paddingVertical: 12, gap: 8 }} + > + + {columnIssues.length} + + ) : ( + <> + + setCollapsed((prev) => ({ ...prev, [status.id]: true })) + } + /> + + {columnIssues.map((issue) => ( + + ))} + {columnIssues.length === 0 ? ( + + Nothing here + + ) : null} + + + )} + {index === statuses.length - 1 ? null : null} + + ); + })} + + + {/* The lifted card rides above every column, so it is not clipped by the + one it came from. */} + {lifted ? ( + + + + ) : null} + + ); +} + +function DraggableCard({ + issue, + lifted, + trackPointer, + setDragging, + onBegin, + onEnd, + onPress, +}: { + issue: IssueRead; + lifted: boolean; + trackPointer: (x: number, y: number) => void; + setDragging: (active: boolean) => void; + onBegin: (issue: IssueRead) => void; + onEnd: (issue: IssueRead, x: number) => void; + onPress: (issue: IssueRead) => void; +}) { + // One gesture rather than a long-press composed with a pan: activateAfterLongPress + // is what lets the column keep scrolling vertically until the card actually + // lifts, instead of the card swallowing every touch that starts on it. + const pan = Gesture.Pan() + .activateAfterLongPress(LIFT_MS) + .onStart((event) => { + setDragging(true); + trackPointer(event.absoluteX, event.absoluteY); + runOnJS(onBegin)(issue); + }) + .onUpdate((event) => { + trackPointer(event.absoluteX, event.absoluteY); + }) + .onFinalize((event) => { + setDragging(false); + runOnJS(onEnd)(issue, event.absoluteX); + }); + + return ( + + {/* A tap is the non-drag route to the same move action, which is what + makes the board usable without the gesture at all. */} + onPress(issue)} + style={{ opacity: lifted ? 0.35 : 1 }} + > + + + + ); +} diff --git a/src/board/use-status-change.ts b/src/board/use-status-change.ts new file mode 100644 index 0000000..ca78b4f --- /dev/null +++ b/src/board/use-status-change.ts @@ -0,0 +1,82 @@ +import { useCallback } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { getListIssuesTeamsTeamIdIssuesGetQueryKey } from '@/api/generated/endpoints/issues/issues'; +import { QUEUED, type MoveIssueVariables } from '@/offline/queue'; +import type { + ListIssuesTeamsTeamIdIssuesGetParams, + PageIssueRead, + StatusRead, + TeamRead, +} from '@/api/generated/models'; + +/** + * Move an issue between columns, optimistically. + * + * A port of `frontend/src/board/useStatusChange.ts`. The card moves before the + * server answers and moves back if the server refuses -- on a phone, where the + * round trip may be a cellular one, waiting for it would make every drag feel + * broken. + * + * `params` has to match what the board is showing, because it is part of the + * query key being patched. + * + * The write itself goes through a keyed mutation rather than a bare call, so + * React Query pauses it when there is no network and replays it on reconnect -- + * a card moved on a train stays moved, and reaches the server when the train + * comes out of the tunnel. + */ +export function useStatusChange( + team: TeamRead | undefined, + params: ListIssuesTeamsTeamIdIssuesGetParams, +) { + const queryClient = useQueryClient(); + const move = useMutation({ + mutationKey: QUEUED.moveIssue, + }); + + return useCallback( + async (issueId: number, status: StatusRead): Promise => { + if (!team) return false; + + const queryKey = getListIssuesTeamsTeamIdIssuesGetQueryKey(team.id, params); + const previous = queryClient.getQueryData(queryKey); + + queryClient.setQueryData(queryKey, (old) => + old + ? { + ...old, + items: old.items.map((issue) => + issue.id === issueId ? { ...issue, status } : issue, + ), + } + : old, + ); + + const before = previous?.items.find((item) => item.id === issueId); + + try { + await move.mutateAsync({ + issueId, + status, + // Captured now so a replay can tell "nothing moved" from "somebody + // else moved it while we were away". + seenAt: before?.updated_at ?? '', + identifier: before?.identifier ?? `#${issueId}`, + }); + // Moving a card moves its points between columns, and may close out a + // cycle's remaining work. + void queryClient.invalidateQueries({ queryKey: [`/teams/${team.id}/estimates`] }); + void queryClient.invalidateQueries({ queryKey: [`/teams/${team.id}/cycles`] }); + void queryClient.invalidateQueries({ queryKey: [`/issues/${issueId}`] }); + return true; + } catch { + // A paused mutation does not reject, so reaching here means it was + // genuinely refused -- put the card back where it was. + queryClient.setQueryData(queryKey, previous); + return false; + } + }, + [queryClient, team, params, move], + ); +} diff --git a/src/board/use-team-data.ts b/src/board/use-team-data.ts new file mode 100644 index 0000000..c9c239a --- /dev/null +++ b/src/board/use-team-data.ts @@ -0,0 +1,38 @@ +import { useListCyclesTeamsTeamIdCyclesGet } from '@/api/generated/endpoints/cycles/cycles'; +import { useGetEstimateSummaryTeamsTeamIdEstimatesGet } from '@/api/generated/endpoints/issues/issues'; +import { useListLabelsTeamsTeamIdLabelsGet } from '@/api/generated/endpoints/labels/labels'; +import { useListProjectsTeamsTeamIdProjectsGet } from '@/api/generated/endpoints/projects/projects'; +import { useListStatusesTeamsTeamIdStatusesGet } from '@/api/generated/endpoints/statuses/statuses'; +import { useListTeamMembersTeamsTeamIdMembersGet } from '@/api/generated/endpoints/teams/teams'; +import type { TeamRead } from '@/api/generated/models'; + +/** + * Everything the board needs about a team besides its issues. + * + * A port of `frontend/src/team/useTeamData.ts`. All six are keyed on the team, + * enabled together, and consumed as one object, so they belong together. + */ +export function useTeamData(team: TeamRead | undefined) { + const id = team?.id ?? 0; + const options = { query: { enabled: Boolean(team) } }; + + const projects = useListProjectsTeamsTeamIdProjectsGet(id, options); + const labels = useListLabelsTeamsTeamIdLabelsGet(id, options); + const members = useListTeamMembersTeamsTeamIdMembersGet(id, options); + const cycles = useListCyclesTeamsTeamIdCyclesGet(id, options); + // The team's own board columns -- never a hardcoded list. + const statuses = useListStatusesTeamsTeamIdStatusesGet(id, options); + // Rolled up on the server rather than summed from the issue list: that list + // is one page, so a client-side total would only total what happened to load. + const estimates = useGetEstimateSummaryTeamsTeamIdEstimatesGet(id, options); + + return { + projects: projects.data ?? [], + labels: labels.data ?? [], + members: members.data ?? [], + cycles: cycles.data ?? [], + statuses: statuses.data ?? [], + estimates: estimates.data, + isPending: statuses.isPending, + }; +} diff --git a/src/comments/comments-section.tsx b/src/comments/comments-section.tsx new file mode 100644 index 0000000..7305cbf --- /dev/null +++ b/src/comments/comments-section.tsx @@ -0,0 +1,205 @@ +import { useState } from 'react'; +import { Pressable, StyleSheet, View } from 'react-native'; +import { router } from 'expo-router'; +import { useQueryClient } from '@tanstack/react-query'; + +import { + createCommentIssuesIssueIdCommentsPost, + useListCommentsIssuesIssueIdCommentsGet, +} from '@/api/generated/endpoints/comments/comments'; +import { listIssuesTeamsTeamIdIssuesGet } from '@/api/generated/endpoints/issues/issues'; +import type { CommentRead, IssueRead, TeamMemberRead } from '@/api/generated/models'; +import { errorDetail } from '@/api/errors'; +import { Section } from '@/issues/detail/sections'; +import { MarkdownBody } from '@/markdown/markdown'; +import { MarkdownEditor } from '@/markdown/markdown-editor'; +import type { Mentionable } from '@/markdown/mentions'; +import { href } from '@/ui/href'; +import { Alert, AppText, Avatar, Button, Card } from '@/ui/primitives'; +import { useTokens } from '@/ui/theme'; + +const PAGE = 20; + +/** Members are what `@` can name, and the API restricts mentions to them. */ +export function mentionablesFrom(members: TeamMemberRead[]): Mentionable[] { + return members.map((member) => ({ + id: member.user.id, + full_name: member.user.full_name, + username: member.user.username, + })); +} + +/** + * Resolve `TEAMKEY-123` to an issue and open it. + * + * There is no lookup-by-identifier endpoint, so this searches the teams the + * viewer is actually on -- which is also the access rule: a link to an issue on + * a team you cannot see simply does not resolve, rather than erroring. + */ +export async function openIssueByIdentifier( + identifier: string, + teamIds: number[], +): Promise { + for (const teamId of teamIds) { + const page = await listIssuesTeamsTeamIdIssuesGet(teamId, { limit: 200 }).catch( + () => null, + ); + const match = page?.items.find( + (issue) => issue.identifier.toLowerCase() === identifier.toLowerCase(), + ); + if (match) { + router.push(href(`/issue/${match.id}`)); + return true; + } + } + return false; +} + +function CommentCard({ + comment, + people, + onOpenIssue, +}: { + comment: CommentRead; + people: Mentionable[]; + onOpenIssue: (identifier: string) => void; +}) { + const t = useTokens(); + // A comment with no author came from an automation rule, not a person. + const automation = comment.author === null; + + return ( + + + {automation ? ( + + ⚙ + + ) : ( + + )} + + {automation ? 'Automation' : comment.author?.full_name} + + + + + + ); +} + +export function CommentsSection({ + issue, + members, + teamIds, +}: { + issue: IssueRead; + members: TeamMemberRead[]; + teamIds: number[]; +}) { + const t = useTokens(); + const queryClient = useQueryClient(); + const people = mentionablesFrom(members); + + const [limit, setLimit] = useState(PAGE); + const [body, setBody] = useState(''); + const [error, setError] = useState(null); + const [posting, setPosting] = useState(false); + + const comments = useListCommentsIssuesIssueIdCommentsGet(issue.id, { limit, offset: 0 }); + const items = comments.data?.items ?? []; + const total = comments.data?.total ?? 0; + + const onOpenIssue = (identifier: string) => { + void openIssueByIdentifier(identifier, teamIds); + }; + + async function post() { + setError(null); + setPosting(true); + try { + await createCommentIssuesIssueIdCommentsPost(issue.id, { body: body.trim() }); + setBody(''); + await queryClient.invalidateQueries({ + queryKey: [`/issues/${issue.id}/comments`], + }); + // Commenting auto-watches server-side, so the toggle has to re-read. + void queryClient.invalidateQueries({ queryKey: [`/issues/${issue.id}/watch`] }); + } catch (err) { + setError(errorDetail(err, 'Could not post that comment.')); + } finally { + setPosting(false); + } + } + + return ( +
0 ? `COMMENTS · ${total}` : 'COMMENTS'}> + {error ? {error} : null} + + + {items.map((comment) => ( + + ))} + + {items.length === 0 && !comments.isPending ? ( + + No comments yet. + + ) : null} + + {total > items.length ? ( + setLimit((current) => current + PAGE)} + style={{ + paddingVertical: 12, + alignItems: 'center', + borderRadius: t.radius.control, + borderWidth: StyleSheet.hairlineWidth, + borderColor: t.line.hairline, + }} + > + + Show {Math.min(PAGE, total - items.length)} more + + + ) : null} + + + + + + +
+ ); +} diff --git a/src/cycles/cycles-screen.tsx b/src/cycles/cycles-screen.tsx new file mode 100644 index 0000000..03376b3 --- /dev/null +++ b/src/cycles/cycles-screen.tsx @@ -0,0 +1,418 @@ +import { useState } from 'react'; +import { Alert as RNAlert, Pressable, View } from 'react-native'; +import { useQueryClient } from '@tanstack/react-query'; + +import { + completeCycleCyclesCycleIdCompletePost, + createCycleTeamsTeamIdCyclesPost, + deleteCycleCyclesCycleIdDelete, + startCycleCyclesCycleIdStartPost, + updateCycleCyclesCycleIdPatch, + useListCyclesTeamsTeamIdCyclesGet, +} from '@/api/generated/endpoints/cycles/cycles'; +import type { CycleRead, TeamRead } from '@/api/generated/models'; +import { errorDetail } from '@/api/errors'; +import { Icon } from '@/ui/icon'; +import { Alert, AppText, Button, Card, Field, Loading } from '@/ui/primitives'; +import { Sheet } from '@/ui/sheet'; +import { useTokens } from '@/ui/theme'; + +/** + * Cycles, per `docs/design/mobile/149-cycles.svg`. + * + * Progress is reported by the server, unsized issues included as their own + * count: a cycle whose points total looks complete while three issues were never + * sized is not finished, and saying so is the whole reason that field exists. + */ + +/** `YYYY-MM-DD` is what the API takes, and what a date field can produce. */ +function isoDate(value: string): string | null { + const trimmed = value.trim(); + if (!/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) return null; + const parsed = new Date(`${trimmed}T00:00:00Z`); + return Number.isNaN(parsed.getTime()) ? null : trimmed; +} + +function stateLabel(state: CycleRead['state']): string { + return state === 'active' ? 'Active' : state === 'upcoming' ? 'Upcoming' : 'Completed'; +} + +function CycleCard({ + cycle, + onManage, +}: { + cycle: CycleRead; + onManage: (cycle: CycleRead) => void; +}) { + const t = useTokens(); + const { progress } = cycle; + const done = progress.issues_total > 0 ? progress.issues_completed / progress.issues_total : 0; + + const color = + cycle.state === 'active' + ? t.status.progress + : cycle.state === 'upcoming' + ? t.status.todo + : t.status.done; + + return ( + onManage(cycle)} + > + + + + + {cycle.display_name} + + {stateLabel(cycle.state)} + + + + {cycle.starts_at.slice(0, 10)} → {cycle.ends_at.slice(0, 10)} + + + + + + + + {progress.issues_completed}/{progress.issues_total} issues ·{' '} + {progress.points_completed}/{progress.points_total} pts + {/* An unsized issue is not worth zero points, so the total is not the + whole story until this is nothing. */} + {progress.issues_unestimated > 0 + ? ` · ${progress.issues_unestimated} unsized` + : ''} + + + + ); +} + +export function CyclesSheet({ + visible, + onClose, + team, +}: { + visible: boolean; + onClose: () => void; + team: TeamRead; +}) { + const queryClient = useQueryClient(); + const cycles = useListCyclesTeamsTeamIdCyclesGet(team.id, { query: { enabled: visible } }); + + const [managing, setManaging] = useState(null); + const [creating, setCreating] = useState(false); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const items = cycles.data ?? []; + + async function run(action: () => Promise, fallback: string) { + setError(null); + setBusy(true); + try { + await action(); + await queryClient.invalidateQueries({ queryKey: [`/teams/${team.id}/cycles`] }); + // A cycle boundary moves issues, so the board has to re-read too. + void queryClient.invalidateQueries({ queryKey: [`/teams/${team.id}/issues`] }); + } catch (err) { + setError(errorDetail(err, fallback)); + } finally { + setBusy(false); + } + } + + return ( + + + {error ? {error} : null} + + {cycles.isPending ? : null} + + {items.map((cycle) => ( + + ))} + + {items.length === 0 && !cycles.isPending ? ( + + No cycles yet. + + ) : null} + + + + + setCreating(false)} + onRun={run} + /> + + setManaging(null)} + onRun={run} + /> + + ); +} + +function NewCycleSheet({ + visible, + teamId, + busy, + onClose, + onRun, +}: { + visible: boolean; + teamId: number; + busy: boolean; + onClose: () => void; + onRun: (action: () => Promise, fallback: string) => Promise; +}) { + const [name, setName] = useState(''); + const [starts, setStarts] = useState(''); + const [ends, setEnds] = useState(''); + const [invalid, setInvalid] = useState(null); + + if (!visible) return null; + + return ( + + + {invalid ? {invalid} : null} + + + + + + + + + ); +} + +function ManageCycleSheet({ + cycle, + busy, + onClose, + onRun, +}: { + cycle: CycleRead | null; + busy: boolean; + onClose: () => void; + onRun: (action: () => Promise, fallback: string) => Promise; +}) { + const [name, setName] = useState(''); + if (!cycle) return null; + + return ( + + + + + + {cycle.state === 'upcoming' ? ( + + ) : null} + + {cycle.state === 'active' ? ( + + ) : null} + + + + + ); +} + +/** The current cycle, shown above the board while one is running. */ +export function CycleBanner({ team, onOpen }: { team: TeamRead; onOpen: () => void }) { + const t = useTokens(); + const cycles = useListCyclesTeamsTeamIdCyclesGet(team.id); + const active = (cycles.data ?? []).find((cycle) => cycle.state === 'active'); + if (!active) return null; + + const { progress } = active; + const done = + progress.issues_total > 0 ? progress.issues_completed / progress.issues_total : 0; + + return ( + + + + {active.display_name} + + + {progress.issues_completed}/{progress.issues_total} + {progress.issues_unestimated > 0 ? ` · ${progress.issues_unestimated} unsized` : ''} + + + + + + + + ); +} diff --git a/src/issues/__tests__/use-issue-properties.test.ts b/src/issues/__tests__/use-issue-properties.test.ts new file mode 100644 index 0000000..4d2d7d6 --- /dev/null +++ b/src/issues/__tests__/use-issue-properties.test.ts @@ -0,0 +1,31 @@ +import { labelFor } from '@/issues/use-issue-properties'; + +const OPTIONS = [ + { value: 1, label: 'Todo', color: '#abc' }, + { value: 2, label: 'Done', color: '#def' }, +]; + +describe('labelFor', () => { + it('names the chosen value and carries its colour', () => { + expect(labelFor(OPTIONS, 1, 'None')).toEqual({ + text: 'Todo', + color: '#abc', + muted: false, + }); + }); + + it('shows the empty label when nothing is chosen', () => { + // "Unassigned" and "Not sized" are real states the API distinguishes, so + // they read as values rather than as a blank row. + expect(labelFor(OPTIONS, null, 'Unassigned')).toEqual({ + text: 'Unassigned', + muted: true, + }); + }); + + it('still renders a value whose row the team has deleted', () => { + // An issue can outlive the label or project it points at; the row has to + // draw something rather than crash or vanish. + expect(labelFor(OPTIONS, 99, 'None')).toEqual({ text: '99', muted: true }); + }); +}); diff --git a/src/issues/detail/issue-picker.tsx b/src/issues/detail/issue-picker.tsx new file mode 100644 index 0000000..124b71b --- /dev/null +++ b/src/issues/detail/issue-picker.tsx @@ -0,0 +1,112 @@ +import { useMemo, useState } from 'react'; +import { Pressable, View } from 'react-native'; + +import { useListIssuesTeamsTeamIdIssuesGet } from '@/api/generated/endpoints/issues/issues'; +import type { IssueRead } from '@/api/generated/models'; +import { AppText, Field, Loading } from '@/ui/primitives'; +import { Sheet } from '@/ui/sheet'; +import { useTokens } from '@/ui/theme'; + +/** + * Choose another issue on the same team -- the target of a link, or a parent. + * + * Filtered client-side from the team's issue page rather than through /search: + * the list is already cached for the board, the set is bounded by the same + * 200-row cap, and matching on identifier as well as title is what people + * actually type ("ENG-142"). + */ +export function IssuePicker({ + visible, + title, + teamId, + excludeIds, + onSelect, + onClose, +}: { + visible: boolean; + title: string; + teamId: number; + excludeIds: number[]; + onSelect: (issue: IssueRead) => void; + onClose: () => void; +}) { + const t = useTokens(); + const [search, setSearch] = useState(''); + + const query = useListIssuesTeamsTeamIdIssuesGet( + teamId, + { limit: 200 }, + { query: { enabled: visible && teamId > 0 } }, + ); + + const matches = useMemo(() => { + const needle = search.trim().toLowerCase(); + const all = (query.data?.items ?? []).filter((issue) => !excludeIds.includes(issue.id)); + if (!needle) return all.slice(0, 40); + return all + .filter( + (issue) => + issue.title.toLowerCase().includes(needle) || + issue.identifier.toLowerCase().includes(needle), + ) + .slice(0, 40); + }, [query.data, search, excludeIds]); + + return ( + + + + + {query.isPending ? : null} + + {matches.map((issue) => ( + { + onSelect(issue); + onClose(); + }} + style={{ + flexDirection: 'row', + alignItems: 'center', + gap: 10, + paddingVertical: 10, + paddingHorizontal: 8, + borderRadius: t.radius.control, + }} + > + + + {issue.identifier} + + {issue.title} + + + + ))} + + {!query.isPending && matches.length === 0 ? ( + + No issue matches that. + + ) : null} + + + ); +} diff --git a/src/issues/detail/sections.tsx b/src/issues/detail/sections.tsx new file mode 100644 index 0000000..1bc6e30 --- /dev/null +++ b/src/issues/detail/sections.tsx @@ -0,0 +1,500 @@ +import { useState, type ReactNode } from 'react'; +import { Linking, Pressable, StyleSheet, View } from 'react-native'; +import { router } from 'expo-router'; +import { useQueryClient } from '@tanstack/react-query'; + +import { + createIssueLinkIssuesIssueIdLinksPost, + createIssueTeamsTeamIdIssuesPost, + deleteIssueLinkIssuesIssueIdLinksLinkIdDelete, + useListIssueLinksIssuesIssueIdLinksGet, + useListIssuesTeamsTeamIdIssuesGet, +} from '@/api/generated/endpoints/issues/issues'; +import { useListCodeLinksIssuesIssueIdCodeLinksGet } from '@/api/generated/endpoints/integrations/integrations'; +import { + useGetWatchStateIssuesIssueIdWatchGet, + setWatchStateIssuesIssueIdWatchPut, +} from '@/api/generated/endpoints/notifications/notifications'; +import { IssueLinkType, type IssueRead, type LinkedIssue } from '@/api/generated/models'; +import { errorDetail } from '@/api/errors'; +import { IssuePicker } from '@/issues/detail/issue-picker'; +import { Icon } from '@/ui/icon'; +import { href } from '@/ui/href'; +import { Alert, AppText, Button, Card, Field } from '@/ui/primitives'; +import { Sheet } from '@/ui/sheet'; +import { useTokens } from '@/ui/theme'; + +/** A titled block on the detail screen, with an optional action in the header. */ +export function Section({ + title, + action, + children, +}: { + title: string; + action?: ReactNode; + children: ReactNode; +}) { + return ( + + + + {title} + + {action} + + {children} + + ); +} + +function LinkedRow({ + issue, + trailing, +}: { + issue: LinkedIssue; + trailing?: ReactNode; +}) { + return ( + router.push(href(`/issue/${issue.id}`))} + style={{ + flexDirection: 'row', + alignItems: 'center', + gap: 10, + paddingVertical: 10, + paddingHorizontal: 12, + }} + > + + + {issue.identifier} + + {issue.title} + + + {trailing} + + ); +} + +/** + * Children of this issue, one level deep. + * + * Progress comes from the issue's own child_count / completed_child_count + * rather than being counted here: the server excludes cancelled children from + * both, and re-deriving it from the child list would quietly disagree. + */ +export function SubIssuesSection({ issue }: { issue: IssueRead }) { + const t = useTokens(); + const queryClient = useQueryClient(); + const [adding, setAdding] = useState(false); + const [error, setError] = useState(null); + + const children = useListIssuesTeamsTeamIdIssuesGet(issue.team_id, { + parent_id: issue.id, + limit: 200, + }); + const items = children.data?.items ?? []; + + return ( +
0 + ? `SUB-ISSUES · ${issue.completed_child_count} OF ${issue.child_count} DONE` + : 'SUB-ISSUES' + } + action={ + setAdding(true)} + > + + + } + > + {error ? {error} : null} + + + {items.length === 0 ? ( + + No sub-issues yet. + + ) : ( + items.map((child, index) => ( + + + + )) + )} + + + setAdding(false)} + onCreated={async () => { + await queryClient.invalidateQueries({ queryKey: [`/issues/${issue.id}`] }); + await queryClient.invalidateQueries({ + queryKey: [`/teams/${issue.team_id}/issues`], + }); + }} + onError={setError} + /> +
+ ); +} + +/** Creating a child is creating an issue with a parent, so it is the same call. */ +function NewSubIssueSheet({ + visible, + parent, + onClose, + onCreated, + onError, +}: { + visible: boolean; + parent: IssueRead; + onClose: () => void; + onCreated: () => Promise; + onError: (message: string) => void; +}) { + const [title, setTitle] = useState(''); + const [busy, setBusy] = useState(false); + + return ( + + + + + + + ); +} + +/** The relation buckets the API returns, in the order the web lists them. */ +const RELATIONS = [ + { key: 'blocked_by', label: 'Blocked by' }, + { key: 'blocks', label: 'Blocks' }, + { key: 'duplicates', label: 'Duplicates' }, + { key: 'duplicated_by', label: 'Duplicated by' }, + { key: 'relates_to', label: 'Relates to' }, +] as const; + +const ADDABLE = [ + { type: IssueLinkType.blocks, label: 'Blocks' }, + { type: IssueLinkType.duplicates, label: 'Duplicates' }, + { type: IssueLinkType.relates_to, label: 'Relates to' }, +] as const; + +/** + * Links to other issues. + * + * Only three types can be created; the inverses (blocked_by, duplicated_by) are + * derived server-side, which is why they are listed but not offered. + */ +export function LinksSection({ issue }: { issue: IssueRead }) { + const t = useTokens(); + const queryClient = useQueryClient(); + const links = useListIssueLinksIssuesIssueIdLinksGet(issue.id); + const [picking, setPicking] = useState(null); + const [choosingType, setChoosingType] = useState(false); + const [error, setError] = useState(null); + + const buckets = links.data ?? {}; + const linkedIds = RELATIONS.flatMap( + (relation) => (buckets[relation.key] ?? []).map((link) => link.issue.id), + ); + + async function refresh() { + await queryClient.invalidateQueries({ queryKey: [`/issues/${issue.id}/links`] }); + // A blocking link changes the blocked marker on the board. + void queryClient.invalidateQueries({ queryKey: [`/issues/${issue.id}`] }); + void queryClient.invalidateQueries({ queryKey: [`/teams/${issue.team_id}/issues`] }); + } + + const anyLinks = RELATIONS.some((relation) => (buckets[relation.key] ?? []).length > 0); + + return ( +
setChoosingType(true)} + > + + + } + > + {error ? {error} : null} + + + {!anyLinks ? ( + + No linked issues. + + ) : ( + RELATIONS.map((relation) => { + const bucket = buckets[relation.key] ?? []; + if (bucket.length === 0) return null; + return ( + + + {relation.label} + + {bucket.map((link) => ( + { + setError(null); + try { + await deleteIssueLinkIssuesIssueIdLinksLinkIdDelete( + issue.id, + link.id, + ); + await refresh(); + } catch (err) { + setError(errorDetail(err, 'Could not remove that link.')); + } + }} + > + + × + + + } + /> + ))} + + ); + }) + )} + + + setChoosingType(false)} + title="Link type" + > + + {ADDABLE.map((option) => ( + { + setChoosingType(false); + setPicking(option.type); + }} + style={{ + paddingVertical: 12, + paddingHorizontal: 10, + borderRadius: t.radius.control, + }} + > + {option.label} + + ))} + + “Blocked by” and “Duplicated by” are the other side of these, and + appear on the issue you pick. + + + + + setPicking(null)} + onSelect={async (target) => { + if (!picking) return; + setError(null); + try { + await createIssueLinkIssuesIssueIdLinksPost(issue.id, { + target_id: target.id, + type: picking, + }); + await refresh(); + } catch (err) { + setError(errorDetail(err, 'Could not add that link.')); + } + }} + /> +
+ ); +} + +/** + * Branches, commits and pull requests from connected repositories. + * + * Read-only: these arrive by webhook from the repository side, so there is + * nothing to create here. Tapping opens the host in a browser. + */ +export function DevelopmentSection({ issue }: { issue: IssueRead }) { + const t = useTokens(); + const codeLinks = useListCodeLinksIssuesIssueIdCodeLinksGet(issue.id); + + const groups = [ + { key: 'pull_requests' as const, label: 'Pull requests' }, + { key: 'branches' as const, label: 'Branches' }, + { key: 'commits' as const, label: 'Commits' }, + ]; + + const data = codeLinks.data ?? {}; + const any = groups.some((group) => (data[group.key] ?? []).length > 0); + + // Nothing connected and nothing linked is the common case; a permanently + // empty section would just be noise on a phone. + if (!any) return null; + + return ( +
+ + {groups.map((group) => { + const items = data[group.key] ?? []; + if (items.length === 0) return null; + return ( + + + {group.label} + + {items.map((item) => ( + void Linking.openURL(item.url)} + style={{ + flexDirection: 'row', + alignItems: 'center', + gap: 10, + paddingVertical: 10, + paddingHorizontal: 12, + }} + > + + + {item.title ?? item.external_id} + + + {item.repository.full_name} + {item.state ? ` · ${item.state}` : ''} + + + + + ))} + + ); + })} + +
+ ); +} + +/** + * Watch toggle. + * + * The server auto-watches on assignment and commenting, so this reflects a + * state that changes without being touched here -- it reads the current value + * rather than assuming. + */ +export function WatchToggle({ issue }: { issue: IssueRead }) { + const t = useTokens(); + const queryClient = useQueryClient(); + const watch = useGetWatchStateIssuesIssueIdWatchGet(issue.id); + const [busy, setBusy] = useState(false); + + const watching = watch.data?.watching === true; + + return ( + { + setBusy(true); + try { + await setWatchStateIssuesIssueIdWatchPut(issue.id, { watching: !watching }); + await queryClient.invalidateQueries({ queryKey: [`/issues/${issue.id}/watch`] }); + } finally { + setBusy(false); + } + }} + style={{ + flexDirection: 'row', + alignItems: 'center', + gap: 6, + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: t.radius.pill, + backgroundColor: watching ? t.line.navActive : t.line.ghost, + }} + > + + + {watching ? 'Watching' : 'Watch'} + + + ); +} diff --git a/src/issues/issue-detail-screen.tsx b/src/issues/issue-detail-screen.tsx new file mode 100644 index 0000000..df5e508 --- /dev/null +++ b/src/issues/issue-detail-screen.tsx @@ -0,0 +1,437 @@ +import { useState } from 'react'; +import { Alert as RNAlert, Pressable, ScrollView, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { router, useLocalSearchParams } from 'expo-router'; +import { useQueryClient } from '@tanstack/react-query'; + +import { + deleteIssueIssuesIssueIdDelete, + updateIssueIssuesIssueIdPatch, + useGetIssueIssuesIssueIdGet, +} from '@/api/generated/endpoints/issues/issues'; +import type { IssuePriority, IssueUpdate } from '@/api/generated/models'; +import { errorDetail } from '@/api/errors'; +import { + InlineCreate, + MultiPicker, + PropertyRow, + SinglePicker, +} from '@/issues/property-picker'; +import { + DevelopmentSection, + LinksSection, + SubIssuesSection, + WatchToggle, +} from '@/issues/detail/sections'; +import { labelFor, useIssueProperties } from '@/issues/use-issue-properties'; +import { AttachmentsSection } from '@/attachments/attachments-section'; +import { CommentsSection, mentionablesFrom, openIssueByIdentifier } from '@/comments/comments-section'; +import { MarkdownBody } from '@/markdown/markdown'; +import { MarkdownEditor } from '@/markdown/markdown-editor'; +import { toggleTaskAtIndex } from '@/markdown/tasks'; +import { useTeams } from '@/team/team-context'; +import { Icon } from '@/ui/icon'; +import { Alert, AppText, Button, Card, Field, Loading } from '@/ui/primitives'; +import { useTokens } from '@/ui/theme'; + +/** + * One issue, per `docs/design/mobile/144-issue-detail.svg`. + * + * Properties are written through as they are picked rather than collected + * behind a Save button: each is a single-field PATCH, and a form that has to be + * submitted invites half-made edits to be lost when the screen is dismissed. + * Title and description are the exception -- they are typed, so they save on + * blur. + */ +type Picker = + | 'status' + | 'priority' + | 'estimate' + | 'assignee' + | 'labels' + | 'project' + | 'cycle' + | null; + +export function IssueDetailScreen() { + const t = useTokens(); + const queryClient = useQueryClient(); + const { id } = useLocalSearchParams<{ id: string }>(); + const issueId = Number(id); + const { teams } = useTeams(); + + const issueQuery = useGetIssueIssuesIssueIdGet(issueId, { + query: { enabled: Number.isInteger(issueId) }, + }); + const issue = issueQuery.data; + + // The issue names its own team, which is not necessarily the active one -- + // a link can arrive from anywhere. + const team = teams.find((candidate) => candidate.id === issue?.team_id); + const props = useIssueProperties(team); + + const [picker, setPicker] = useState(null); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + const [title, setTitle] = useState(null); + const [description, setDescription] = useState(null); + + async function patch(update: IssueUpdate) { + if (!issue) return; + setError(null); + setSaving(true); + try { + await updateIssueIssuesIssueIdPatch(issue.id, update); + await queryClient.invalidateQueries({ queryKey: [`/issues/${issue.id}`] }); + // The board groups by status and totals points, so both have to re-read. + void queryClient.invalidateQueries({ queryKey: [`/teams/${issue.team_id}/issues`] }); + void queryClient.invalidateQueries({ queryKey: [`/teams/${issue.team_id}/estimates`] }); + } catch (err) { + setError(errorDetail(err, 'Could not save that change.')); + } finally { + setSaving(false); + } + } + + function confirmDelete() { + if (!issue) return; + RNAlert.alert( + `Delete ${issue.identifier}?`, + 'This also deletes its attachments. It cannot be undone.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete', + style: 'destructive', + onPress: async () => { + try { + await deleteIssueIssuesIssueIdDelete(issue.id); + await queryClient.invalidateQueries({ + queryKey: [`/teams/${issue.team_id}/issues`], + }); + router.back(); + } catch (err) { + setError(errorDetail(err, 'Could not delete the issue.')); + } + }, + }, + ], + ); + } + + if (issueQuery.isPending) return ; + + if (!issue) { + return ( + + + Issue not found + + It may have been deleted, or belong to a team you are not on. + + + + + ); + } + + const status = labelFor(props.statusOptions, issue.status.id, 'None'); + const priorityMeta = labelFor(props.priorityOptions, issue.priority, 'No priority'); + const points = labelFor(props.estimateOptions, issue.estimate ?? null, 'Not sized'); + const assignee = labelFor(props.assigneeOptions, issue.assignee?.id ?? null, 'Unassigned'); + const project = labelFor(props.projectOptions, issue.project_id ?? null, 'None'); + const cycle = labelFor(props.cycleOptions, issue.cycle_id ?? null, 'None'); + const labelIds = (issue.labels ?? []).map((label) => label.id); + + return ( + + + router.back()} + > + Back + + + {issue.identifier} + + {saving ? Saving… : } + + + + {error ? {error} : null} + + {issue.blocked_by_count > 0 ? ( + + + + Blocked by {issue.blocked_by_count} issue + {issue.blocked_by_count === 1 ? '' : 's'} + + + ) : null} + + + { + if (title !== null && title.trim() && title.trim() !== issue.title) { + void patch({ title: title.trim() }); + } + setTitle(null); + }} + /> + Description + {description === null ? ( + setDescription(issue.description ?? '')} + > + {issue.description?.trim() ? ( + + void openIssueByIdentifier(identifier, teams.map((team) => team.id)) + } + // Ticking a box edits the source at that one character + // rather than re-serialising the parsed markdown, so every + // byte the author typed stays where it was. + onToggleTask={(index) => { + const next = toggleTaskAtIndex(issue.description ?? '', index); + if (next !== null) void patch({ description: next }); + }} + /> + ) : ( + No description. Tap to add one. + )} + + ) : ( + + + + + + + + )} + + + + setPicker('status')} + /> + setPicker('priority')} + /> + setPicker('estimate')} + /> + setPicker('assignee')} + /> + label.name).join(', ') + } + muted={labelIds.length === 0} + onPress={() => setPicker('labels')} + /> + setPicker('project')} + /> + setPicker('cycle')} + /> + + + + + + + + + candidate.id)} + /> + + + Opened by {issue.creator.full_name} + + + + + + Delete issue + + + + + value !== null && void patch({ status_id: value })} + onClose={() => setPicker(null)} + /> + void patch({ priority: (value ?? 'no_priority') as IssuePriority })} + onClose={() => setPicker(null)} + /> + void patch({ estimate: value as never })} + onClose={() => setPicker(null)} + /> + void patch({ assignee_id: value })} + onClose={() => setPicker(null)} + /> + + void patch({ + label_ids: labelIds.includes(labelId) + ? labelIds.filter((x) => x !== labelId) + : [...labelIds, labelId], + }) + } + onClose={() => setPicker(null)} + footer={ + { + const newId = await props.createLabel(name); + if (newId !== null) void patch({ label_ids: [...labelIds, newId] }); + }} + /> + } + /> + void patch({ project_id: value })} + onClose={() => setPicker(null)} + footer={ + { + const newId = await props.createProject(name); + if (newId !== null) void patch({ project_id: newId }); + }} + /> + } + /> + void patch({ cycle_id: value })} + onClose={() => setPicker(null)} + /> + + ); +} diff --git a/src/issues/issue-meta.ts b/src/issues/issue-meta.ts new file mode 100644 index 0000000..294a7b9 --- /dev/null +++ b/src/issues/issue-meta.ts @@ -0,0 +1,56 @@ +import { IssuePriority, StatusCategory } from '@/api/generated/models'; +import type { StatusRead } from '@/api/generated/models'; +import type { Tokens } from '@/ui/tokens'; + +/** + * Priority and category metadata, ported from `frontend/src/issues/issueMeta.ts`. + * + * The web reaches for CSS variables; here the colour has to come from the + * resolved palette, so each lookup takes the tokens rather than naming one. + */ +export const PRIORITY_ORDER: IssuePriority[] = [ + IssuePriority.urgent, + IssuePriority.high, + IssuePriority.medium, + IssuePriority.low, + IssuePriority.no_priority, +]; + +export const PRIORITY_LABEL: Record = { + urgent: 'Urgent', + high: 'High', + medium: 'Medium', + low: 'Low', + no_priority: 'No priority', +}; + +export function priorityColor(priority: IssuePriority, t: Tokens): string { + switch (priority) { + case 'urgent': + return t.priority.urgent; + case 'high': + return t.priority.high; + case 'medium': + return t.priority.medium; + case 'low': + return t.priority.low; + default: + return t.priority.none; + } +} + +/** Work that is finished, one way or the other. Mirrors RESOLVED in lib_softtrack/statuses.py. */ +const RESOLVED: StatusCategory[] = [StatusCategory.done, StatusCategory.cancelled]; + +export function isResolved(status: StatusRead): boolean { + return RESOLVED.includes(status.category); +} + +/** + * Columns in these categories start collapsed. Cancelled work is rarely what a + * board is for, and on a phone it would cost a whole swipe to get past. + * + * By category rather than by name, because the columns are the team's own -- + * a team may well call it "Won't do". + */ +export const COLLAPSED_CATEGORIES: StatusCategory[] = [StatusCategory.cancelled]; diff --git a/src/issues/new-issue-screen.tsx b/src/issues/new-issue-screen.tsx new file mode 100644 index 0000000..579efa7 --- /dev/null +++ b/src/issues/new-issue-screen.tsx @@ -0,0 +1,301 @@ +import { useState } from 'react'; +import { KeyboardAvoidingView, Platform, Pressable, ScrollView, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { router } from 'expo-router'; +import { useQueryClient } from '@tanstack/react-query'; + +import { createIssueTeamsTeamIdIssuesPost } from '@/api/generated/endpoints/issues/issues'; +import type { IssuePriority } from '@/api/generated/models'; +import { errorDetail } from '@/api/errors'; +import { + InlineCreate, + MultiPicker, + PropertyRow, + SinglePicker, +} from '@/issues/property-picker'; +import { labelFor, useIssueProperties } from '@/issues/use-issue-properties'; +import { useTeams } from '@/team/team-context'; +import { href } from '@/ui/href'; +import { Alert, AppText, Button, Card, Field } from '@/ui/primitives'; +import { useTokens } from '@/ui/theme'; + +/** + * Create an issue, per `docs/design/mobile/143-new-issue.svg`. + * + * Every property the web's new-issue modal offers, as tappable rows rather than + * a column of `` elements, which a phone has no equivalent of worth + * having. A sheet of rows is the mobile idiom, and it is the same component for + * creating an issue and for editing one -- the difference is only whether the + * change is held locally or written straight through. + */ +export type PickerOption = { + value: T; + label: string; + /** A dot before the label: status colours, label colours, priority. */ + color?: string; + hint?: string; +}; + +/** A tappable row showing a property's current value, opening the sheet. */ +export function PropertyRow({ + label, + value, + color, + onPress, + muted = false, +}: { + label: string; + value: string; + color?: string; + onPress: () => void; + muted?: boolean; +}) { + const t = useTokens(); + return ( + + + {label} + + {color ? ( + + ) : null} + + {value} + + + + ); +} + +function OptionRow({ + label, + color, + hint, + selected, + onPress, +}: { + label: string; + color?: string; + hint?: string; + selected: boolean; + onPress: () => void; +}) { + const t = useTokens(); + return ( + + {color ? ( + + ) : null} + + + {label} + + {hint ? {hint} : null} + + {selected ? : null} + + ); +} + +/** + * Pick one value, or none. + * + * `null` is a real choice here rather than an absent one -- "no assignee" and + * "not sized yet" are meaningful states the API distinguishes, so they get their + * own row instead of being expressed by closing the sheet. + */ +export function SinglePicker({ + visible, + title, + options, + selected, + emptyLabel, + onSelect, + onClose, + footer, +}: { + visible: boolean; + title: string; + options: PickerOption[]; + selected: T | null; + /** Row for "none"; omitted when the property cannot be empty. */ + emptyLabel?: string; + onSelect: (value: T | null) => void; + onClose: () => void; + footer?: ReactNode; +}) { + return ( + + + {emptyLabel ? ( + { + onSelect(null); + onClose(); + }} + /> + ) : null} + {options.map((option) => ( + { + onSelect(option.value); + onClose(); + }} + /> + ))} + {footer} + + + ); +} + +/** + * Pick any number of values. + * + * Stays open as things are ticked, because labels are chosen in handfuls -- + * closing after each one would mean reopening the sheet for every label. + */ +export function MultiPicker({ + visible, + title, + options, + selected, + onToggle, + onClose, + footer, +}: { + visible: boolean; + title: string; + options: PickerOption[]; + selected: T[]; + onToggle: (value: T) => void; + onClose: () => void; + footer?: ReactNode; +}) { + return ( + + + {options.map((option) => ( + onToggle(option.value)} + /> + ))} + {options.length === 0 ? ( + + Nothing to choose from yet. + + ) : null} + {footer} + + + ); +} + +/** + * The inline "create one while you are here" row. + * + * The web lets any member add a label or project from the issue form, and + * needing to leave for team settings to add a label mid-triage would be worse + * on a phone than it is on a desktop. + */ +export function InlineCreate({ + placeholder, + busy, + onCreate, +}: { + placeholder: string; + busy: boolean; + onCreate: (name: string) => void; +}) { + const t = useTokens(); + const [name, setName] = useState(''); + + return ( + + { + if (name.trim()) { + onCreate(name.trim()); + setName(''); + } + }} + /> + + + ); +} diff --git a/src/issues/use-issue-properties.ts b/src/issues/use-issue-properties.ts new file mode 100644 index 0000000..c97874e --- /dev/null +++ b/src/issues/use-issue-properties.ts @@ -0,0 +1,125 @@ +import { useCallback, useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; + +import { createLabelTeamsTeamIdLabelsPost } from '@/api/generated/endpoints/labels/labels'; +import { createProjectTeamsTeamIdProjectsPost } from '@/api/generated/endpoints/projects/projects'; +import type { IssuePriority, TeamRead } from '@/api/generated/models'; +import { useTeamData } from '@/board/use-team-data'; +import { PRIORITY_LABEL, PRIORITY_ORDER, priorityColor } from '@/issues/issue-meta'; +import type { PickerOption } from '@/issues/property-picker'; +import { useTokens } from '@/ui/theme'; + +/** The story-point scale, mirroring ESTIMATE_SCALE in the backend's issue model. */ +export const ESTIMATE_SCALE = [1, 2, 3, 5, 8] as const; + +/** + * The option lists every issue picker needs, plus inline creation for the two + * the web lets any member add from the issue form. + */ +export function useIssueProperties(team: TeamRead | undefined) { + const t = useTokens(); + const queryClient = useQueryClient(); + const data = useTeamData(team); + const [creating, setCreating] = useState(false); + + const statusOptions: PickerOption[] = data.statuses.map((status) => ({ + value: status.id, + label: status.name, + color: status.color, + })); + + const priorityOptions: PickerOption[] = PRIORITY_ORDER.map((priority) => ({ + value: priority, + label: PRIORITY_LABEL[priority], + color: priorityColor(priority, t), + })); + + const estimateOptions: PickerOption[] = ESTIMATE_SCALE.map((points) => ({ + value: points, + label: `${points} point${points === 1 ? '' : 's'}`, + })); + + const assigneeOptions: PickerOption[] = data.members.map((member) => ({ + value: member.user.id, + label: member.user.full_name, + color: member.user.avatar_color, + })); + + const labelOptions: PickerOption[] = data.labels.map((label) => ({ + value: label.id, + label: label.name, + color: label.color, + })); + + const projectOptions: PickerOption[] = data.projects.map((project) => ({ + value: project.id, + label: project.name, + })); + + const cycleOptions: PickerOption[] = data.cycles.map((cycle) => ({ + value: cycle.id, + label: cycle.display_name, + hint: cycle.state, + })); + + const createLabel = useCallback( + async (name: string): Promise => { + if (!team) return null; + setCreating(true); + try { + const label = await createLabelTeamsTeamIdLabelsPost(team.id, { name }); + await queryClient.invalidateQueries({ queryKey: [`/teams/${team.id}/labels`] }); + return label.id; + } catch { + return null; + } finally { + setCreating(false); + } + }, + [team, queryClient], + ); + + const createProject = useCallback( + async (name: string): Promise => { + if (!team) return null; + setCreating(true); + try { + const project = await createProjectTeamsTeamIdProjectsPost(team.id, { name }); + await queryClient.invalidateQueries({ queryKey: [`/teams/${team.id}/projects`] }); + return project.id; + } catch { + return null; + } finally { + setCreating(false); + } + }, + [team, queryClient], + ); + + return { + ...data, + statusOptions, + priorityOptions, + estimateOptions, + assigneeOptions, + labelOptions, + projectOptions, + cycleOptions, + createLabel, + createProject, + creating, + }; +} + +/** Look a value's label up for a property row, falling back to a placeholder. */ +export function labelFor( + options: PickerOption[], + value: T | null, + empty: string, +): { text: string; color?: string; muted: boolean } { + if (value === null) return { text: empty, muted: true }; + const match = options.find((option) => option.value === value); + // A property naming a row the team has since deleted still has to render. + if (!match) return { text: String(value), muted: true }; + return { text: match.label, color: match.color, muted: false }; +} diff --git a/src/markdown/__tests__/inline.test.ts b/src/markdown/__tests__/inline.test.ts new file mode 100644 index 0000000..be3e5d8 --- /dev/null +++ b/src/markdown/__tests__/inline.test.ts @@ -0,0 +1,77 @@ +import { segments } from '@/markdown/inline'; +import { matchMentions, mentionQueryAt, applyMention } from '@/markdown/mentions'; + +const PEOPLE = [ + { id: 1, full_name: 'Ada Lovelace', username: 'ada' }, + { id: 2, full_name: 'Grace Hopper', username: 'grace' }, +]; + +describe('segments', () => { + it('finds a mention of someone on the team', () => { + expect(segments('hey @ada look', PEOPLE)).toEqual([ + { kind: 'text', text: 'hey ' }, + { kind: 'mention', text: '@ada', person: PEOPLE[0] }, + { kind: 'text', text: ' look' }, + ]); + }); + + it('leaves an unknown handle as plain text', () => { + // Far more likely an email fragment or a shell variable than a person. + expect(segments('hi @nobody', PEOPLE)).toEqual([{ kind: 'text', text: 'hi @nobody' }]); + }); + + it('does not read an email address as a mention', () => { + expect(segments('write to ada@example.com', PEOPLE)).toEqual([ + { kind: 'text', text: 'write to ada@example.com' }, + ]); + }); + + it('links issue identifiers', () => { + expect(segments('see ENG-142 for detail', PEOPLE)).toEqual([ + { kind: 'text', text: 'see ' }, + { kind: 'issue', text: 'ENG-142', identifier: 'ENG-142' }, + { kind: 'text', text: ' for detail' }, + ]); + }); + + it('ignores identifier-like text inside a longer token', () => { + // A hyphenated word or a trailer should not become a link. + expect(segments('build-ENG-1 and lowercase eng-1', PEOPLE)).toEqual([ + { kind: 'text', text: 'build-ENG-1 and lowercase eng-1' }, + ]); + }); + + it('handles both kinds in one run', () => { + const parts = segments('@grace please see ENG-7', PEOPLE); + expect(parts.map((p) => p.kind)).toEqual(['mention', 'text', 'issue']); + }); +}); + +describe('mentionQueryAt', () => { + it('opens on a handle being typed', () => { + expect(mentionQueryAt('hi @ad', 6)).toEqual({ query: 'ad', start: 3 }); + }); + + it('stays closed inside an email address', () => { + expect(mentionQueryAt('mail ada@example', 16)).toBeNull(); + }); + + it('is driven by the caret, not the end of the text', () => { + expect(mentionQueryAt('hi @ad and more', 6)).toEqual({ query: 'ad', start: 3 }); + }); +}); + +describe('matchMentions', () => { + it('matches on handle or full name', () => { + expect(matchMentions(PEOPLE, 'lov').map((p) => p.username)).toEqual(['ada']); + expect(matchMentions(PEOPLE, 'gra').map((p) => p.username)).toEqual(['grace']); + }); +}); + +describe('applyMention', () => { + it('replaces the typed query and leaves the caret after a space', () => { + const result = applyMention('hi @ad', 3, 2, 'ada'); + expect(result.text).toBe('hi @ada '); + expect(result.caret).toBe(8); + }); +}); diff --git a/src/markdown/__tests__/tasks.test.ts b/src/markdown/__tests__/tasks.test.ts new file mode 100644 index 0000000..3c5476f --- /dev/null +++ b/src/markdown/__tests__/tasks.test.ts @@ -0,0 +1,52 @@ +import { hasTaskList, taskProgress, toggleTaskAtIndex } from '@/markdown/tasks'; + +const DOC = `# Plan + +- [ ] first +- [x] second +- [ ] third + +Some prose with a literal [x] that is not a task. + +1. [ ] numbered task +`; + +describe('toggleTaskAtIndex', () => { + it('ticks the nth box and leaves every other byte alone', () => { + const next = toggleTaskAtIndex(DOC, 0)!; + expect(next).toContain('- [x] first'); + // The surgical-edit property: nothing else moves, including the prose the + // author chose and the literal [x] that is not a task. + expect(next.replace('- [x] first', '- [ ] first')).toBe(DOC); + }); + + it('unticks a ticked box', () => { + expect(toggleTaskAtIndex(DOC, 1)).toContain('- [ ] second'); + }); + + it('counts ordered-list tasks in the same sequence', () => { + const next = toggleTaskAtIndex(DOC, 3)!; + expect(next).toContain('1. [x] numbered task'); + }); + + it('never mistakes a literal marker in prose for a task', () => { + // Four markers, not five -- the "[x]" in the sentence is not one. + expect(taskProgress(DOC)).toEqual({ done: 1, total: 4 }); + expect(toggleTaskAtIndex(DOC, 4)).toBeNull(); + }); + + it('does nothing when there is no such checkbox', () => { + // Writing a guess back to the server would be worse than ignoring the tap. + expect(toggleTaskAtIndex(DOC, 99)).toBeNull(); + expect(toggleTaskAtIndex(DOC, -1)).toBeNull(); + expect(toggleTaskAtIndex('no tasks here', 0)).toBeNull(); + }); +}); + +describe('hasTaskList', () => { + it('is true only for real task items', () => { + expect(hasTaskList(DOC)).toBe(true); + expect(hasTaskList('- a plain bullet')).toBe(false); + expect(hasTaskList('a line with [x] in it')).toBe(false); + }); +}); diff --git a/src/markdown/inline.ts b/src/markdown/inline.ts new file mode 100644 index 0000000..f95ec0f --- /dev/null +++ b/src/markdown/inline.ts @@ -0,0 +1,78 @@ +import { MENTION_PATTERN, type Mentionable, peopleByHandle } from '@/markdown/mentions'; + +/** + * Splitting a run of plain text into the things that should become tappable. + * + * Done on `text` tokens only, never on the raw source: `` `@demo` `` and fenced + * blocks parse to code tokens and never reach here, so a comment full of shell + * snippets keeps them literal. That is the same property the web gets from + * running this as a remark plugin rather than a string replacement. + */ + +/** + * `TEAMKEY-123`. Team keys are 2-6 uppercase letters (the backend uppercases + * them and caps the length), and the boundary stops it matching inside a longer + * token like a commit trailer or a hyphenated word. + */ +export const ISSUE_PATTERN = /(^|[^\w-])([A-Z][A-Z0-9]{1,5})-(\d+)\b/g; + +export type Segment = + | { kind: 'text'; text: string } + | { kind: 'mention'; text: string; person: Mentionable } + | { kind: 'issue'; text: string; identifier: string }; + +/** + * Break `text` into plain runs, mentions of known people, and issue references. + * + * An `@handle` matching nobody on the team stays plain text, exactly as on the + * web -- an unknown handle is far more likely to be an email fragment or a + * shell variable than a person. + */ +export function segments(text: string, people: Mentionable[]): Segment[] { + const byHandle = peopleByHandle(people); + const found: { start: number; end: number; segment: Segment }[] = []; + + MENTION_PATTERN.lastIndex = 0; + for (const match of text.matchAll(MENTION_PATTERN)) { + const [whole, boundary, handle] = match; + const person = byHandle.get(handle.toLowerCase()); + if (!person) continue; + const start = (match.index ?? 0) + boundary.length; + found.push({ + start, + end: start + whole.length - boundary.length, + segment: { kind: 'mention', text: `@${handle}`, person }, + }); + } + + ISSUE_PATTERN.lastIndex = 0; + for (const match of text.matchAll(ISSUE_PATTERN)) { + const [whole, boundary, key, number] = match; + const start = (match.index ?? 0) + boundary.length; + const end = start + whole.length - boundary.length; + // A mention and an identifier cannot overlap in practice, but if they did + // the mention was found first and wins. + if (found.some((f) => start < f.end && end > f.start)) continue; + found.push({ + start, + end, + segment: { kind: 'issue', text: `${key}-${number}`, identifier: `${key}-${number}` }, + }); + } + + if (found.length === 0) return [{ kind: 'text', text }]; + + found.sort((a, b) => a.start - b.start); + + const out: Segment[] = []; + let cursor = 0; + for (const item of found) { + if (item.start > cursor) { + out.push({ kind: 'text', text: text.slice(cursor, item.start) }); + } + out.push(item.segment); + cursor = item.end; + } + if (cursor < text.length) out.push({ kind: 'text', text: text.slice(cursor) }); + return out; +} diff --git a/src/markdown/markdown-editor.tsx b/src/markdown/markdown-editor.tsx new file mode 100644 index 0000000..9ff196c --- /dev/null +++ b/src/markdown/markdown-editor.tsx @@ -0,0 +1,162 @@ +import { useRef, useState } from 'react'; +import { Pressable, TextInput, View, type TextInputSelectionChangeEventData } from 'react-native'; + +import { MarkdownBody } from '@/markdown/markdown'; +import { + applyMention, + matchMentions, + mentionQueryAt, + type Mentionable, +} from '@/markdown/mentions'; +import { AppText } from '@/ui/primitives'; +import { useTokens } from '@/ui/theme'; + +/** + * A markdown field with a preview toggle and `@` autocomplete. + * + * The suggestion list is driven by the caret rather than by the last character + * typed, so it also opens when someone moves back into a half-written handle -- + * and closes on the second `@` of an email address, which is what stops it + * appearing every time an address is pasted into a comment. + */ +export function MarkdownEditor({ + value, + onChangeText, + people, + placeholder, + minHeight = 110, + autoFocus = false, +}: { + value: string; + onChangeText: (next: string) => void; + people: Mentionable[]; + placeholder?: string; + minHeight?: number; + autoFocus?: boolean; +}) { + const t = useTokens(); + const inputRef = useRef(null); + const [preview, setPreview] = useState(false); + const [caret, setCaret] = useState(0); + + const mention = mentionQueryAt(value, caret); + const suggestions = mention ? matchMentions(people, mention.query) : []; + + function onSelectionChange(event: { nativeEvent: TextInputSelectionChangeEventData }) { + setCaret(event.nativeEvent.selection.end); + } + + return ( + + + + {(['Write', 'Preview'] as const).map((mode, index) => { + const active = preview === (index === 1); + return ( + setPreview(index === 1)} + style={{ + paddingHorizontal: 10, + paddingVertical: 5, + borderRadius: t.radius.pill, + backgroundColor: active ? t.line.navActive : 'transparent', + }} + > + + {mode} + + + ); + })} + + + {preview ? ( + + {value.trim() ? ( + // Read-only: ticking a box in a draft would have nowhere to write. + + ) : ( + Nothing to preview yet. + )} + + ) : ( + + )} + + {!preview && mention && suggestions.length > 0 ? ( + + {suggestions.map((person) => ( + { + const next = applyMention( + value, + mention.start, + mention.query.length, + person.username, + ); + onChangeText(next.text); + setCaret(next.caret); + }} + style={{ paddingVertical: 10, paddingHorizontal: 12 }} + > + + {person.full_name}{' '} + @{person.username} + + + ))} + + ) : null} + + ); +} diff --git a/src/markdown/markdown.tsx b/src/markdown/markdown.tsx new file mode 100644 index 0000000..4a22576 --- /dev/null +++ b/src/markdown/markdown.tsx @@ -0,0 +1,261 @@ +import { useMemo, type ReactNode } from 'react'; +import { Linking, Pressable, Text, View } from 'react-native'; +import Markdown, { MarkedLexer, Renderer, type RendererInterface } from 'react-native-marked'; +import type { Token, Tokens } from 'marked'; + +import { segments } from '@/markdown/inline'; +import type { Mentionable } from '@/markdown/mentions'; +import { Icon } from '@/ui/icon'; +import { AppText } from '@/ui/primitives'; +import { useTokens } from '@/ui/theme'; +import type { Tokens as ThemeTokens } from '@/ui/tokens'; + +/** + * Markdown for descriptions and comments. + * + * GFM comes from `react-native-marked`, except for task lists, which it cannot + * render: its parser drops marked's `task` and `checked` flags before the + * renderer sees them, so a checklist would come out as bare bullets with no box. + * So the source is lexed at block level here, task lists are drawn directly -- + * which is also what makes them tappable -- and every other block is handed + * back to the library by its own `raw` text. + */ +export function MarkdownBody({ + source, + people = [], + onToggleTask, + onOpenIssue, +}: { + source: string; + people?: Mentionable[]; + /** Omit to render checkboxes read-only, where the viewer cannot edit. */ + onToggleTask?: (index: number) => void; + onOpenIssue?: (identifier: string) => void; +}) { + const t = useTokens(); + + /** + * Blocks, each already carrying where its task numbering starts. + * + * Counted here rather than while mapping, because a counter mutated during + * render is exactly what the compiler refuses -- and precomputing is clearer + * anyway: the nth checkbox on screen is the nth marker in the source. + */ + const blocks = useMemo(() => { + let tokens: Token[]; + try { + tokens = MarkedLexer(source ?? '', { gfm: true }) as Token[]; + } catch { + // A body that will not lex still has to render as something. + return []; + } + + let seen = 0; + return tokens.map((token) => { + const taskStart = seen; + if (token.type === 'list') { + seen += (token as Tokens.List).items.filter((item) => item.task).length; + } + return { token, taskStart }; + }); + }, [source]); + + const renderer = useMemo( + () => new SoftTrackRenderer(t, people, onOpenIssue), + [t, people, onOpenIssue], + ); + + if (blocks.length === 0) { + return source ? {source} : null; + } + + return ( + + {blocks.map(({ token, taskStart }, index) => { + if (token.type === 'space') return null; + + const list = token as Tokens.List; + if (token.type === 'list' && list.items.some((item) => item.task)) { + return ( + + ); + } + + return ( + + ); + })} + + ); +} + +function TaskList({ + items, + startIndex, + people, + onToggleTask, + onOpenIssue, +}: { + items: Tokens.ListItem[]; + startIndex: number; + people: Mentionable[]; + onToggleTask?: (index: number) => void; + onOpenIssue?: (identifier: string) => void; +}) { + const t = useTokens(); + // Only task items are numbered, so a plain item between two tasks must not + // consume an index. Precomputed for the same reason as above. + const indices: number[] = []; + let offset = startIndex; + for (const item of items) indices.push(item.task === true ? offset++ : -1); + + return ( + + {items.map((item, index) => { + const isTask = item.task === true; + const taskIndex = indices[index]; + + return ( + onToggleTask?.(taskIndex)} + style={{ flexDirection: 'row', alignItems: 'flex-start', gap: 8 }} + > + + {item.checked ? : null} + + + + + + ); + })} + + ); +} + +/** Plain text with mentions and issue identifiers made tappable. */ +function InlineText({ + text, + people, + onOpenIssue, + style, +}: { + text: string; + people: Mentionable[]; + onOpenIssue?: (identifier: string) => void; + style?: object; +}) { + const t = useTokens(); + const parts = segments(text, people); + + return ( + <> + {parts.map((part, index) => { + if (part.kind === 'text') { + return ( + + {part.text} + + ); + } + if (part.kind === 'mention') { + return ( + + {part.text} + + ); + } + return ( + onOpenIssue?.(part.identifier)} + style={[style, { color: t.brand[600], fontWeight: '600' }]} + > + {part.text} + + ); + })} + + ); +} + +/** + * The library's renderer, themed and with inline text routed through the + * mention/identifier pass. + */ +class SoftTrackRenderer extends Renderer implements RendererInterface { + constructor( + private readonly tokens: ThemeTokens, + private readonly people: Mentionable[], + private readonly onOpenIssue?: (identifier: string) => void, + ) { + super(); + } + + text(text: string | ReactNode[], styles?: object): ReactNode { + if (typeof text !== 'string') return super.text(text, styles); + return ( + + + + ); + } + + link(children: string | ReactNode[], href: string, styles?: object): ReactNode { + return ( + void Linking.openURL(href)} + style={[styles, { color: this.tokens.brand[600] }]} + > + {children} + + ); + } + +} diff --git a/src/markdown/mentions.ts b/src/markdown/mentions.ts new file mode 100644 index 0000000..92ff153 --- /dev/null +++ b/src/markdown/mentions.ts @@ -0,0 +1,70 @@ +/** + * `@` mentions of team members. + * + * A port of `frontend/src/markdown/mentions.ts`. The handle is the user's own + * `username` column, so a mention keeps pointing at the same person after they + * change their email address. Usernames are unique instance-wide and validated + * against the same character class as MENTION_PATTERN. + */ +export type Mentionable = { + id: number; + full_name: string; + username: string; +}; + +/** Reverse lookup for the renderer. */ +export function peopleByHandle(people: Mentionable[]): Map { + return new Map(people.map((person) => [person.username.toLowerCase(), person])); +} + +/** + * Matches `@handle` where a mention can plausibly start. + * + * The leading boundary stops `user@example.com` in prose from being read as a + * mention of `@example.com`. + */ +export const MENTION_PATTERN = /(^|[^\w@/])@([a-z0-9][a-z0-9._-]*)/gi; + +/** People whose handle or name matches what has been typed after the `@`. */ +export function matchMentions( + people: Mentionable[], + query: string, + limit = 6, +): Mentionable[] { + const needle = query.toLowerCase(); + return people + .filter( + (person) => + !needle || + person.username.toLowerCase().includes(needle) || + person.full_name.toLowerCase().includes(needle), + ) + .slice(0, limit); +} + +/** The `@query` being typed immediately before the caret, if any. */ +export function mentionQueryAt( + value: string, + caret: number, +): { query: string; start: number } | null { + const upToCaret = value.slice(0, caret); + // An `@` starting a word, followed by no whitespace. Bailing out on the + // second `@` of an email address keeps the menu closed while someone is + // simply typing an address into a comment. + const match = /(^|[^\w@/])@([a-z0-9._@-]*)$/i.exec(upToCaret); + if (!match) return null; + return { query: match[2], start: caret - match[2].length - 1 }; +} + +/** Replace the `@query` at `start` with a handle, leaving a trailing space. */ +export function applyMention( + value: string, + start: number, + queryLength: number, + handle: string, +): { text: string; caret: number } { + const before = value.slice(0, start); + const after = value.slice(start + queryLength + 1); + const inserted = `@${handle} `; + return { text: `${before}${inserted}${after}`, caret: before.length + inserted.length }; +} diff --git a/src/markdown/tasks.ts b/src/markdown/tasks.ts new file mode 100644 index 0000000..a97ac7d --- /dev/null +++ b/src/markdown/tasks.ts @@ -0,0 +1,57 @@ +/** + * Toggling a task-list checkbox writes back to the markdown source. + * + * A port of `frontend/src/markdown/tasks.ts`, keyed by the checkbox's index in + * the document rather than by a source offset: the web's renderer carries hast + * positions, and React Native's does not. Order is the same either way, because + * markdown renders in source order. + * + * The edit stays surgical for the same reason it does on the web -- round + * tripping markdown through a parser normalises things the author chose on + * purpose (bullet characters, indentation, hard breaks, trailing spaces). + * Editing the source in place leaves every byte where it was except the one + * character being toggled. + */ + +/** Anchored on the list bullet, so a literal "[x]" in prose is never a task. */ +const TASK_MARKER = /^([ \t]*(?:[-*+]|\d+[.)])[ \t]+)\[([ xX])\]/gm; + +/** Whether `source` contains at least one task-list item. */ +export function hasTaskList(source: string): boolean { + TASK_MARKER.lastIndex = 0; + return TASK_MARKER.test(source); +} + +/** `{done, total}` for the task lists in `source`, for a progress summary. */ +export function taskProgress(source: string): { done: number; total: number } { + let done = 0; + let total = 0; + TASK_MARKER.lastIndex = 0; + for (const match of source.matchAll(TASK_MARKER)) { + total += 1; + if (match[2] !== ' ') done += 1; + } + return { done, total }; +} + +/** + * `source` with the nth task checkbox flipped, or `null` when there is no such + * checkbox -- in which case the caller should do nothing rather than write a + * guess back to the server. + */ +export function toggleTaskAtIndex(source: string, index: number): string | null { + if (index < 0) return null; + + let seen = 0; + TASK_MARKER.lastIndex = 0; + for (const match of source.matchAll(TASK_MARKER)) { + if (seen === index) { + const markerStart = (match.index ?? 0) + match[1].length; + const next = match[2] === ' ' ? 'x' : ' '; + // markerStart points at "[", so the state is the character after it. + return source.slice(0, markerStart + 1) + next + source.slice(markerStart + 2); + } + seen += 1; + } + return null; +} diff --git a/src/notifications/inbox-screen.tsx b/src/notifications/inbox-screen.tsx new file mode 100644 index 0000000..dcd74e3 --- /dev/null +++ b/src/notifications/inbox-screen.tsx @@ -0,0 +1,252 @@ +import { useState } from 'react'; +import { FlatList, Pressable, StyleSheet, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { router } from 'expo-router'; +import { useQueryClient } from '@tanstack/react-query'; + +import { + markAllReadNotificationsReadAllPost, + updateNotificationNotificationsNotificationIdPatch, + useListNotificationsNotificationsGet, +} from '@/api/generated/endpoints/notifications/notifications'; +import type { NotificationKind, NotificationRead } from '@/api/generated/models'; +import { errorDetail } from '@/api/errors'; +import { href } from '@/ui/href'; +import { Icon } from '@/ui/icon'; +import { useIsMultiPane } from '@/ui/layout'; +import { Alert, AppText, Avatar, Card, Loading } from '@/ui/primitives'; +import { useTokens } from '@/ui/theme'; + +/** + * The inbox, per `docs/design/mobile/147-notifications.svg`. + * + * Four kinds, and each one needs a different sentence -- "commented on" and + * "mentioned you in" are not the same event even though both are a comment. + */ +const PAGE = 25; + +function sentenceFor(notification: NotificationRead): string { + const who = notification.actor?.full_name ?? 'Someone'; + const kind: NotificationKind = notification.kind; + switch (kind) { + case 'assigned': + return `${who} assigned you`; + case 'mentioned': + return `${who} mentioned you`; + case 'commented': + return `${who} commented`; + case 'status_changed': + return `${who} changed the status`; + default: + return who; + } +} + +export function InboxScreen() { + const t = useTokens(); + const multiPane = useIsMultiPane(); + const queryClient = useQueryClient(); + + const [unreadOnly, setUnreadOnly] = useState(false); + const [limit, setLimit] = useState(PAGE); + const [error, setError] = useState(null); + + const notifications = useListNotificationsNotificationsGet({ + unread_only: unreadOnly || undefined, + limit, + offset: 0, + }); + + const items = notifications.data?.items ?? []; + const total = notifications.data?.total ?? 0; + + async function refresh() { + await queryClient.invalidateQueries({ queryKey: ['/notifications'] }); + // The tab badge reads its own endpoint, so it has to be told too. + void queryClient.invalidateQueries({ queryKey: ['/notifications/unread-count'] }); + } + + async function open(notification: NotificationRead) { + // Marked read on the way out rather than on arrival: opening it is what + // says it has been seen. + if (!notification.read) { + try { + await updateNotificationNotificationsNotificationIdPatch(notification.id, { + read: true, + }); + await refresh(); + } catch { + // Navigating still matters more than the read flag. + } + } + router.push(href(`/issue/${notification.issue.id}`)); + } + + return ( + + + + Inbox + + + setUnreadOnly((current) => !current)} + style={{ + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: t.radius.pill, + backgroundColor: unreadOnly ? t.line.navActive : t.line.ghost, + }} + > + + Unread + + + + { + setError(null); + try { + await markAllReadNotificationsReadAllPost(); + await refresh(); + } catch (err) { + setError(errorDetail(err, 'Could not mark everything read.')); + } + }} + > + + + + + {notifications.isPending ? ( + + ) : ( + String(item.id)} + contentContainerStyle={{ padding: 16, gap: 8 }} + ListHeaderComponent={error ? {error} : null} + ListEmptyComponent={ + + + {unreadOnly ? 'Nothing unread' : 'Nothing yet'} + + + You are told when you are assigned, mentioned, or when work you + watch moves. + + + } + ListFooterComponent={ + total > items.length ? ( + setLimit((current) => current + PAGE)} + style={{ + paddingVertical: 12, + alignItems: 'center', + borderRadius: t.radius.control, + borderWidth: StyleSheet.hairlineWidth, + borderColor: t.line.hairline, + }} + > + + Show more + + + ) : null + } + renderItem={({ item }) => ( + void open(item)} + > + + {item.actor ? ( + + ) : ( + + )} + + + + {sentenceFor(item)} + + + {item.issue.identifier} · {item.issue.title} + + {item.excerpt ? ( + + {item.excerpt} + + ) : null} + + + {!item.read ? ( + + ) : null} + + + )} + refreshing={notifications.isFetching && !notifications.isPending} + onRefresh={refresh} + /> + )} + + ); +} diff --git a/src/offline/__tests__/queue.test.ts b/src/offline/__tests__/queue.test.ts new file mode 100644 index 0000000..f84d80f --- /dev/null +++ b/src/offline/__tests__/queue.test.ts @@ -0,0 +1,90 @@ +import { QueryClient } from '@tanstack/react-query'; + +import { describeQueued, MoveConflict, QUEUED, registerQueuedMutations } from '@/offline/queue'; + +jest.mock('@/api/generated/endpoints/issues/issues', () => ({ + getIssueIssuesIssueIdGet: jest.fn(), + updateIssueIssuesIssueIdPatch: jest.fn(), + createIssueTeamsTeamIdIssuesPost: jest.fn(), +})); +jest.mock('@/api/generated/endpoints/comments/comments', () => ({ + createCommentIssuesIssueIdCommentsPost: jest.fn(), +})); + +const issues = jest.requireMock('@/api/generated/endpoints/issues/issues'); + +const TODO = { id: 1, name: 'Todo', category: 'unstarted', color: '#000', team_id: 1, position: 1 }; +const DONE = { id: 2, name: 'Done', category: 'done', color: '#0f0', team_id: 1, position: 2 }; + +/** Run the registered move mutation the way a resumed queue would. */ +async function runMove(variables: Record) { + const client = new QueryClient(); + registerQueuedMutations(client); + const defaults = client.getMutationDefaults(QUEUED.moveIssue); + return (defaults.mutationFn as (v: unknown) => Promise)(variables); +} + +beforeEach(() => jest.clearAllMocks()); + +describe('the queued move', () => { + it('applies when nothing changed while offline', async () => { + issues.getIssueIssuesIssueIdGet.mockResolvedValue({ + updated_at: 'T1', + status: TODO, + }); + issues.updateIssueIssuesIssueIdPatch.mockResolvedValue({ status: DONE }); + + await runMove({ issueId: 7, status: DONE, seenAt: 'T1', identifier: 'ENG-7' }); + + expect(issues.updateIssueIssuesIssueIdPatch).toHaveBeenCalledWith(7, { status_id: 2 }); + }); + + it('refuses when somebody else moved it in the meantime', async () => { + // Replaying blindly here would undo their work, which is the whole reason + // the timestamp is captured when the move is queued. + issues.getIssueIssuesIssueIdGet.mockResolvedValue({ + updated_at: 'T2', + status: { ...TODO, id: 3, name: 'In Review' }, + }); + + await expect( + runMove({ issueId: 7, status: DONE, seenAt: 'T1', identifier: 'ENG-7' }), + ).rejects.toBeInstanceOf(MoveConflict); + expect(issues.updateIssueIssuesIssueIdPatch).not.toHaveBeenCalled(); + }); + + it('is not a conflict when somebody made the same move', async () => { + // The issue changed, but it is already where this move wanted it -- there + // is nothing to argue about. + issues.getIssueIssuesIssueIdGet.mockResolvedValue({ updated_at: 'T2', status: DONE }); + issues.updateIssueIssuesIssueIdPatch.mockResolvedValue({ status: DONE }); + + await runMove({ issueId: 7, status: DONE, seenAt: 'T1', identifier: 'ENG-7' }); + expect(issues.updateIssueIssuesIssueIdPatch).toHaveBeenCalled(); + }); + + it('names both sides in the conflict message', async () => { + const conflict = new MoveConflict('ENG-7', 'Done', 'In Review'); + expect(conflict.message).toContain('ENG-7'); + expect(conflict.message).toContain('In Review'); + expect(conflict.message).toContain('Done'); + }); +}); + +describe('describeQueued', () => { + it('says what each waiting change will do', () => { + expect( + describeQueued(QUEUED.moveIssue, { identifier: 'ENG-7', status: DONE }), + ).toBe('Move ENG-7 to Done'); + expect(describeQueued(QUEUED.addComment, { identifier: 'ENG-7' })).toBe( + 'Comment on ENG-7', + ); + expect(describeQueued(QUEUED.createIssue, { title: 'Fix it' })).toBe( + 'New issue “Fix it”', + ); + }); + + it('falls back rather than rendering a key', () => { + expect(describeQueued(['something', 'else'], {})).toBe('Pending change'); + }); +}); diff --git a/src/offline/connectivity.ts b/src/offline/connectivity.ts new file mode 100644 index 0000000..8dc8cb9 --- /dev/null +++ b/src/offline/connectivity.ts @@ -0,0 +1,32 @@ +import NetInfo from '@react-native-community/netinfo'; +import { onlineManager, focusManager } from '@tanstack/react-query'; +import { AppState, type AppStateStatus } from 'react-native'; + +/** + * Telling React Query whether there is a network. + * + * Its browser default listens for `window.online`, which does not exist here -- + * without this, a phone in a tunnel keeps firing requests that time out one by + * one instead of pausing them, and nothing is ever queued. + * + * `isInternetReachable` rather than `isConnected`: a captive portal or a Wi-Fi + * network with no route out is still "connected", and is exactly the case where + * a request will hang rather than fail fast. + */ +export function startConnectivityWatch(): () => void { + const unsubscribeNet = NetInfo.addEventListener((state) => { + const reachable = state.isInternetReachable ?? state.isConnected ?? false; + onlineManager.setOnline(Boolean(reachable)); + }); + + // React Query's focus default is also browser-shaped; on a phone, coming back + // to the app is the equivalent event. + const subscription = AppState.addEventListener('change', (status: AppStateStatus) => { + focusManager.setFocused(status === 'active'); + }); + + return () => { + unsubscribeNet(); + subscription.remove(); + }; +} diff --git a/src/offline/persistence.ts b/src/offline/persistence.ts new file mode 100644 index 0000000..fd1569e --- /dev/null +++ b/src/offline/persistence.ts @@ -0,0 +1,19 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister'; + +/** + * The cache that survives the app being closed. + * + * What this buys is offline *reading*: boards, issues and comments that were + * loaded recently are still there with no network. A week is long enough that a + * phone opened on Monday still shows Friday's board, and short enough that + * nothing ancient is presented as current -- the banner says what it is. + */ +export const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; + +export const persister = createAsyncStoragePersister({ + storage: AsyncStorage, + key: 'softtrack.cache', + // Writes are debounced so a fast-scrolling board does not thrash storage. + throttleTime: 1000, +}); diff --git a/src/offline/queue.ts b/src/offline/queue.ts new file mode 100644 index 0000000..d3be5c9 --- /dev/null +++ b/src/offline/queue.ts @@ -0,0 +1,126 @@ +import type { QueryClient } from '@tanstack/react-query'; + +import { createCommentIssuesIssueIdCommentsPost } from '@/api/generated/endpoints/comments/comments'; +import { + createIssueTeamsTeamIdIssuesPost, + getIssueIssuesIssueIdGet, + updateIssueIssuesIssueIdPatch, +} from '@/api/generated/endpoints/issues/issues'; +import type { IssueRead, StatusRead } from '@/api/generated/models'; + +/** + * The mutations that survive going offline. + * + * React Query pauses a mutation while `onlineManager` says there is no network + * and replays it on reconnect. For that to survive the app being killed, the + * mutation function has to be registered by key rather than passed inline -- + * a persisted mutation is only data, and `setMutationDefaults` is what turns it + * back into something runnable. + * + * Only these three are queued. They are the ones the issue names, and the ones + * where doing the work offline is genuinely useful: moving a card, saying + * something, and capturing a thought. Everything else asks for a connection. + */ +export const QUEUED = { + moveIssue: ['offline', 'moveIssue'] as const, + addComment: ['offline', 'addComment'] as const, + createIssue: ['offline', 'createIssue'] as const, +}; + +export type MoveIssueVariables = { + issueId: number; + status: StatusRead; + /** The issue's updated_at when the move was queued, for conflict detection. */ + seenAt: string; + identifier: string; +}; + +export type AddCommentVariables = { + issueId: number; + body: string; + identifier: string; +}; + +export type CreateIssueVariables = { + teamId: number; + title: string; + description?: string; +}; + +/** + * A queued move whose issue changed while we were away. + * + * Thrown rather than silently applied: somebody else moving the card to Done + * while this phone was in a tunnel is exactly the case where replaying blindly + * would undo their work. + */ +export class MoveConflict extends Error { + constructor( + readonly identifier: string, + readonly intended: string, + readonly actual: string, + ) { + super( + `${identifier} moved to ${actual} while you were offline, so it was not moved to ${intended}.`, + ); + this.name = 'MoveConflict'; + } +} + +export function registerQueuedMutations(queryClient: QueryClient): void { + queryClient.setMutationDefaults(QUEUED.moveIssue, { + mutationFn: async (variables: MoveIssueVariables) => { + const current = await getIssueIssuesIssueIdGet(variables.issueId); + + // Unchanged since it was queued: the move is still the one intended. + if (current.updated_at !== variables.seenAt && current.status.id !== variables.status.id) { + throw new MoveConflict( + variables.identifier, + variables.status.name, + current.status.name, + ); + } + + return updateIssueIssuesIssueIdPatch(variables.issueId, { + status_id: variables.status.id, + }); + }, + }); + + queryClient.setMutationDefaults(QUEUED.addComment, { + mutationFn: (variables: AddCommentVariables) => + createCommentIssuesIssueIdCommentsPost(variables.issueId, { body: variables.body }), + }); + + queryClient.setMutationDefaults(QUEUED.createIssue, { + mutationFn: (variables: CreateIssueVariables) => + createIssueTeamsTeamIdIssuesPost(variables.teamId, { + title: variables.title, + description: variables.description, + }), + }); +} + +/** What a queued mutation is called in the sync panel. */ +export function describeQueued(mutationKey: unknown, variables: unknown): string { + const key = Array.isArray(mutationKey) ? mutationKey.join('/') : ''; + + if (key === QUEUED.moveIssue.join('/')) { + const move = variables as MoveIssueVariables; + return `Move ${move.identifier} to ${move.status.name}`; + } + if (key === QUEUED.addComment.join('/')) { + const comment = variables as AddCommentVariables; + return `Comment on ${comment.identifier}`; + } + if (key === QUEUED.createIssue.join('/')) { + const issue = variables as CreateIssueVariables; + return `New issue “${issue.title}”`; + } + return 'Pending change'; +} + +/** The issue as it should look while a queued move waits. */ +export function withQueuedStatus(issue: IssueRead, status: StatusRead): IssueRead { + return { ...issue, status }; +} diff --git a/src/offline/sync-status.tsx b/src/offline/sync-status.tsx new file mode 100644 index 0000000..43822df --- /dev/null +++ b/src/offline/sync-status.tsx @@ -0,0 +1,203 @@ +import { useEffect, useState } from 'react'; +import { Pressable, View } from 'react-native'; +import { + onlineManager, + useIsMutating, + useMutationState, + useQueryClient, +} from '@tanstack/react-query'; + +import { describeQueued, MoveConflict } from '@/offline/queue'; +import { AppText, Button, Card } from '@/ui/primitives'; +import { Sheet } from '@/ui/sheet'; +import { useTokens } from '@/ui/theme'; + +/** Whether there is a network, as React Query currently understands it. */ +export function useIsOnline(): boolean { + const [online, setOnline] = useState(() => onlineManager.isOnline()); + useEffect(() => onlineManager.subscribe(setOnline), []); + return online; +} + +/** + * The offline banner, per `docs/design/mobile/155-offline.svg`. + * + * Shown while there is no network, and also while queued work is still going + * out after one returns -- "back online" with three unsent changes is not the + * same as being up to date, and saying so is the difference between trusting the + * app and refreshing it nervously. + */ +export function OfflineBanner({ onOpenQueue }: { onOpenQueue: () => void }) { + const t = useTokens(); + const online = useIsOnline(); + const pending = useMutationState({ + filters: { status: 'pending' }, + }).length; + const paused = useMutationState({ + filters: { predicate: (mutation) => mutation.state.isPaused }, + }).length; + + const queued = paused + pending; + if (online && queued === 0) return null; + + const tone = online ? t.status.progress : t.priority.medium; + + return ( + + + + {online + ? `Syncing ${queued} change${queued === 1 ? '' : 's'}…` + : queued > 0 + ? `Offline · ${queued} change${queued === 1 ? '' : 's'} waiting` + : 'Offline · showing what was last loaded'} + + {queued > 0 ? ( + + View + + ) : null} + + ); +} + +/** + * The queue, and anything that could not be applied. + * + * A conflict is shown rather than resolved: the server's version and the one + * that was queued are both somebody's intent, and picking between them is not a + * decision to make on their behalf. + */ +export function SyncQueueSheet({ + visible, + onClose, +}: { + visible: boolean; + onClose: () => void; +}) { + const t = useTokens(); + const queryClient = useQueryClient(); + const online = useIsOnline(); + const inFlight = useIsMutating(); + + const all = useMutationState({ + select: (mutation) => ({ + key: mutation.options.mutationKey, + variables: mutation.state.variables, + isPaused: mutation.state.isPaused, + status: mutation.state.status, + error: mutation.state.error, + }), + }); + + const waiting = all.filter((entry) => entry.isPaused || entry.status === 'pending'); + const failed = all.filter((entry) => entry.status === 'error'); + + if (!visible) return null; + + return ( + + + + {online + ? inFlight > 0 + ? 'Sending queued changes…' + : 'Up to date.' + : 'No connection. Changes are kept and sent when one returns.'} + + + {waiting.length > 0 ? ( + + WAITING + {waiting.map((entry, index) => ( + + + + {describeQueued(entry.key, entry.variables)} + + + ))} + + ) : null} + + {failed.length > 0 ? ( + + NEEDS ATTENTION + {failed.map((entry, index) => ( + + + {describeQueued(entry.key, entry.variables)} + + + {entry.error instanceof MoveConflict + ? entry.error.message + : 'Could not be applied.'} + + {entry.error instanceof MoveConflict ? ( + + Open the issue to decide what it should be. + + ) : null} + + ))} + + ) : null} + + {waiting.length === 0 && failed.length === 0 ? ( + Nothing waiting. + ) : null} + + {online && waiting.length > 0 ? ( + + ) : null} + + {failed.length > 0 ? ( + + ) : null} + + + ); +} diff --git a/src/reports/__tests__/chart.test.ts b/src/reports/__tests__/chart.test.ts new file mode 100644 index 0000000..215e2da --- /dev/null +++ b/src/reports/__tests__/chart.test.ts @@ -0,0 +1,41 @@ +import { linePath, makeScale } from '@/reports/chart'; + +describe('makeScale', () => { + const scale = makeScale(200, 5, 10); + + it('puts the first point on the axis and the last at the right edge', () => { + expect(scale.x(0)).toBeCloseTo(34, 5); + expect(scale.x(4)).toBeCloseTo(34 + scale.innerWidth, 5); + }); + + it('draws zero on the baseline and the max at the top', () => { + expect(scale.y(0)).toBeCloseTo(12 + scale.innerHeight, 5); + expect(scale.y(10)).toBeCloseTo(12, 5); + }); + + it('survives a chart whose values are all zero', () => { + // A cycle with nothing in it still has to draw an axis rather than divide + // by zero. + const flat = makeScale(200, 3, 0); + expect(Number.isFinite(flat.y(0))).toBe(true); + expect(flat.y(0)).toBeCloseTo(12 + flat.innerHeight, 5); + }); + + it('survives a single-point series', () => { + const single = makeScale(200, 1, 5); + expect(Number.isFinite(single.x(0))).toBe(true); + }); +}); + +describe('linePath', () => { + it('moves to the first point and lines to the rest', () => { + const scale = makeScale(200, 3, 10); + const path = linePath([10, 5, 0], scale); + expect(path.startsWith('M')).toBe(true); + expect(path.match(/L/g)).toHaveLength(2); + }); + + it('is empty for no data, rather than a malformed path', () => { + expect(linePath([], makeScale(200, 0, 1))).toBe(''); + }); +}); diff --git a/src/reports/chart.tsx b/src/reports/chart.tsx new file mode 100644 index 0000000..af871e4 --- /dev/null +++ b/src/reports/chart.tsx @@ -0,0 +1,270 @@ +import { useState, type ReactNode } from 'react'; +import { Pressable, View } from 'react-native'; +import Svg, { Circle, Line, Path, Rect } from 'react-native-svg'; + +import { AppText } from '@/ui/primitives'; +import { useTokens } from '@/ui/theme'; + +/** + * The chart primitives the four reports are drawn with. + * + * Hand-drawn in react-native-svg, which is already here for the icon set, rather + * than pulling in a charting library: four fixed chart shapes need a fraction of + * what one provides, and tapping for a value is easier to get right on a touch + * screen when the hit areas are ours. + */ +export const CHART_HEIGHT = 180; +const PADDING = { top: 12, right: 12, bottom: 22, left: 34 }; + +export type Scale = { + x: (index: number) => number; + y: (value: number) => number; + width: number; + height: number; + innerWidth: number; + innerHeight: number; +}; + +export function makeScale(width: number, count: number, max: number): Scale { + const innerWidth = Math.max(1, width - PADDING.left - PADDING.right); + const innerHeight = CHART_HEIGHT - PADDING.top - PADDING.bottom; + const steps = Math.max(1, count - 1); + const top = max <= 0 ? 1 : max; + + return { + x: (index) => PADDING.left + (index / steps) * innerWidth, + y: (value) => PADDING.top + innerHeight - (value / top) * innerHeight, + width, + height: CHART_HEIGHT, + innerWidth, + innerHeight, + }; +} + +export function linePath(values: number[], scale: Scale): string { + if (values.length === 0) return ''; + return values + .map((value, index) => `${index === 0 ? 'M' : 'L'}${scale.x(index)},${scale.y(value)}`) + .join(' '); +} + +/** + * A chart with a title, an optional note, and a tap-anywhere readout. + * + * `onProbe` receives the index nearest the touch, because a fingertip covers + * several days at this width and asking someone to hit an exact point would + * make the values unreachable. + */ +export function Chart({ + title, + subtitle, + count, + width, + onProbe, + readout, + children, +}: { + title: string; + subtitle?: string; + count: number; + width: number; + onProbe: (index: number | null) => void; + readout?: ReactNode; + children: ReactNode; +}) { + const t = useTokens(); + + return ( + + {title} + {subtitle ? {subtitle} : null} + + onProbe(null)} + onPress={(event) => { + const x = event.nativeEvent.locationX; + const steps = Math.max(1, count - 1); + const innerWidth = Math.max(1, width - PADDING.left - PADDING.right); + const ratio = (x - PADDING.left) / innerWidth; + onProbe(Math.max(0, Math.min(count - 1, Math.round(ratio * steps)))); + }} + style={{ + height: CHART_HEIGHT, + backgroundColor: t.surface.card, + borderRadius: t.radius.card, + }} + > + + {children} + + + + {/* Reserved rather than conditional, so the layout does not jump as a + finger moves across the chart. */} + {readout} + + ); +} + +export function Axes({ scale }: { scale: Scale }) { + const t = useTokens(); + return ( + <> + + + + ); +} + +export function Series({ + values, + scale, + color, + dashed = false, +}: { + values: number[]; + scale: Scale; + color: string; + dashed?: boolean; +}) { + return ( + + ); +} + +export function Marker({ + index, + value, + scale, + color, +}: { + index: number; + value: number; + scale: Scale; + color: string; +}) { + return ; +} + +export function Bars({ + values, + scale, + color, + offset = 0, + slots = 1, +}: { + values: number[]; + scale: Scale; + color: string; + offset?: number; + slots?: number; +}) { + const groupWidth = scale.innerWidth / Math.max(1, values.length); + const barWidth = Math.max(2, (groupWidth * 0.7) / slots); + const base = scale.y(0); + + return ( + <> + {values.map((value, index) => { + const top = scale.y(value); + const left = + PADDING.left + index * groupWidth + groupWidth * 0.15 + offset * barWidth; + return ( + + ); + })} + + ); +} + +/** Stacked areas, for the cumulative flow diagram. */ +export function StackedAreas({ + series, + scale, +}: { + series: { values: number[]; color: string }[]; + scale: Scale; +}) { + const running = series.length > 0 ? series[0].values.map(() => 0) : []; + + return ( + <> + {series.map((layer, layerIndex) => { + const lower = [...running]; + layer.values.forEach((value, index) => { + running[index] += value; + }); + const upper = [...running]; + + const forward = upper + .map((value, index) => `${index === 0 ? 'M' : 'L'}${scale.x(index)},${scale.y(value)}`) + .join(' '); + const back = lower + .map((value, index) => `L${scale.x(lower.length - 1 - index)},${scale.y(lower[lower.length - 1 - index])}`) + .join(' '); + + return ( + + ); + })} + + ); +} + +export function Legend({ items }: { items: { label: string; color: string }[] }) { + return ( + + {items.map((item) => ( + + + {item.label} + + ))} + + ); +} + +/** Hook for the shared "which point is being probed" state. */ +export function useProbe() { + const [index, setIndex] = useState(null); + return { index, setIndex }; +} diff --git a/src/reports/reports-sheet.tsx b/src/reports/reports-sheet.tsx new file mode 100644 index 0000000..a0f8f09 --- /dev/null +++ b/src/reports/reports-sheet.tsx @@ -0,0 +1,345 @@ +import { useState } from 'react'; +import { Pressable, useWindowDimensions, View } from 'react-native'; + +import { useListCyclesTeamsTeamIdCyclesGet } from '@/api/generated/endpoints/cycles/cycles'; +import { + useCycleBurndownCyclesCycleIdBurndownGet, + useTeamCreatedVsResolvedTeamsTeamIdCreatedVsResolvedGet, + useTeamCumulativeFlowTeamsTeamIdCumulativeFlowGet, + useTeamVelocityTeamsTeamIdVelocityGet, +} from '@/api/generated/endpoints/reports/reports'; +import type { StatusCategory, TeamRead } from '@/api/generated/models'; +import { + Axes, + Bars, + Chart, + Legend, + Marker, + makeScale, + Series, + StackedAreas, + useProbe, +} from '@/reports/chart'; +import { AppText, Card, Loading } from '@/ui/primitives'; +import { Sheet } from '@/ui/sheet'; +import { useTokens } from '@/ui/theme'; + +/** + * The four reports, per `docs/design/mobile/150-reports.svg`. + * + * Every number comes from the same event-history endpoints the web charts, so + * the figures match rather than being recomputed here. Portrait-first: one chart + * per row, full width, with a tap-anywhere readout instead of a hover tooltip. + */ +const CATEGORY_ORDER: StatusCategory[] = [ + 'backlog', + 'unstarted', + 'started', + 'done', + 'cancelled', +]; + +/** `2026-01-06` -> `6 Jan`, which is all the axis has room for. */ +function shortDay(day: string): string { + const parsed = new Date(`${day}T00:00:00Z`); + if (Number.isNaN(parsed.getTime())) return day; + return `${parsed.getUTCDate()} ${parsed.toLocaleString('en', { month: 'short', timeZone: 'UTC' })}`; +} + +export function ReportsSheet({ + visible, + onClose, + team, +}: { + visible: boolean; + onClose: () => void; + team: TeamRead; +}) { + const t = useTokens(); + const { width: screenWidth } = useWindowDimensions(); + const width = Math.min(screenWidth, 520) - 72; + + const cycles = useListCyclesTeamsTeamIdCyclesGet(team.id, { query: { enabled: visible } }); + const [cycleId, setCycleId] = useState(null); + + // Whichever cycle is running, else the most recent one -- a burndown of + // nothing is not worth the screen. + const chosenCycle = + cycleId ?? + (cycles.data ?? []).find((cycle) => cycle.state === 'active')?.id ?? + (cycles.data ?? [])[0]?.id ?? + null; + + return ( + + + {/* The same notice the web shows: nothing can be drawn for the period + before the instance started recording history. */} + + Charts are drawn from recorded history. Anything that happened before + this instance began tracking cannot be reconstructed. + + + {cycles.isPending ? : null} + + + + {(cycles.data ?? []).length > 1 ? ( + + {(cycles.data ?? []).map((cycle) => { + const active = chosenCycle === cycle.id; + return ( + setCycleId(cycle.id)} + style={{ + paddingHorizontal: 10, + paddingVertical: 5, + borderRadius: t.radius.pill, + backgroundColor: active ? t.line.navActive : t.line.well, + }} + > + + {cycle.display_name} + + + ); + })} + + ) : null} + + + + + + + ); +} + +function BurndownChart({ cycleId, width }: { cycleId: number | null; width: number }) { + const t = useTokens(); + const probe = useProbe(); + const burndown = useCycleBurndownCyclesCycleIdBurndownGet(cycleId ?? 0, { + query: { enabled: cycleId !== null }, + }); + + const points = burndown.data?.points ?? []; + if (cycleId === null || points.length === 0) { + return ; + } + + const remaining = points.map((point) => point.points_remaining); + const ideal = points.map((point) => point.ideal_remaining); + const max = Math.max(...remaining, ...ideal, 1); + const scale = makeScale(width, points.length, max); + const at = probe.index !== null ? points[probe.index] : null; + + return ( + + {shortDay(at.day)} · {at.points_remaining} remaining of {at.points_total} + {' · ideal '} + {Math.round(at.ideal_remaining)} + + ) : ( + + ) + } + > + + + + {at && probe.index !== null ? ( + + ) : null} + + ); +} + +function VelocityChart({ teamId, width }: { teamId: number; width: number }) { + const t = useTokens(); + const probe = useProbe(); + const velocity = useTeamVelocityTeamsTeamIdVelocityGet(teamId); + + const cycles = velocity.data?.cycles ?? []; + if (cycles.length === 0) { + return ; + } + + const committed = cycles.map((cycle) => cycle.points_committed); + const completed = cycles.map((cycle) => cycle.points_completed); + const scale = makeScale(width, cycles.length, Math.max(...committed, ...completed, 1)); + const at = probe.index !== null ? cycles[probe.index] : null; + + return ( + + {at.cycle_name} · {at.points_completed} delivered of {at.points_committed}{' '} + committed + + ) : ( + + ) + } + > + + + + + ); +} + +function FlowChart({ teamId, width }: { teamId: number; width: number }) { + const t = useTokens(); + const probe = useProbe(); + const flow = useTeamCumulativeFlowTeamsTeamIdCumulativeFlowGet(teamId); + + const days = flow.data?.days ?? []; + if (days.length === 0) { + return ; + } + + const colorFor: Record = { + backlog: t.status.backlog, + unstarted: t.status.todo, + started: t.status.progress, + done: t.status.done, + cancelled: t.status.cancelled, + }; + + const series = CATEGORY_ORDER.map((category) => ({ + color: colorFor[category], + values: days.map((day) => day.counts[category] ?? 0), + })); + + const totals = days.map((day) => + CATEGORY_ORDER.reduce((sum, category) => sum + (day.counts[category] ?? 0), 0), + ); + const scale = makeScale(width, days.length, Math.max(...totals, 1)); + const at = probe.index !== null ? days[probe.index] : null; + + return ( + + {shortDay(at.day)} ·{' '} + {CATEGORY_ORDER.filter((category) => (at.counts[category] ?? 0) > 0) + .map((category) => `${category} ${at.counts[category]}`) + .join(', ')} + + ) : ( + ({ + label: category, + color: colorFor[category], + }))} + /> + ) + } + > + + + + ); +} + +function CreatedResolvedChart({ teamId, width }: { teamId: number; width: number }) { + const t = useTokens(); + const probe = useProbe(); + const report = useTeamCreatedVsResolvedTeamsTeamIdCreatedVsResolvedGet(teamId); + + const days = report.data?.days ?? []; + if (days.length === 0) { + return ; + } + + const created = days.map((day) => day.created); + const resolved = days.map((day) => day.resolved); + const scale = makeScale(width, days.length, Math.max(...created, ...resolved, 1)); + const at = probe.index !== null ? days[probe.index] : null; + + return ( + + {shortDay(at.day)} · {at.created} created, {at.resolved} resolved ·{' '} + {at.open_at_end_of_day} open + + ) : ( + + ) + } + > + + + + + ); +} + +function Empty({ title, reason }: { title: string; reason: string }) { + return ( + + {title} + + {reason} + + + ); +} diff --git a/src/search/search-screen.tsx b/src/search/search-screen.tsx new file mode 100644 index 0000000..5cc8aa2 --- /dev/null +++ b/src/search/search-screen.tsx @@ -0,0 +1,219 @@ +import { useState } from 'react'; +import { FlatList, Pressable, StyleSheet, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { router } from 'expo-router'; + +import { useSearchSearchGet } from '@/api/generated/endpoints/search/search'; +import type { SearchHit } from '@/api/generated/models'; +import { useTeams } from '@/team/team-context'; +import { href } from '@/ui/href'; +import { useIsMultiPane } from '@/ui/layout'; +import { AppText, Card, Field, Loading, TeamBadge } from '@/ui/primitives'; +import { Sheet } from '@/ui/sheet'; +import { useDebouncedValue } from '@/ui/use-debounced-value'; +import { useTokens } from '@/ui/theme'; + +/** + * Search, per `docs/design/mobile/148-search.svg`. + * + * The query settles before it is sent, so typing a word does not cost a request + * per keystroke. Scope is all your teams or one of them, which is exactly the + * `team_id` parameter the endpoint takes -- ranking and matching stay on the + * server, so the result set is the web's. + */ +const DEBOUNCE_MS = 300; +const PAGE = 25; + +/** Where the match was found, as something to read rather than a column name. */ +function matchedInLabel(hit: SearchHit): string { + switch (hit.matched_in) { + case 'title': + return 'Title'; + case 'description': + return 'Description'; + case 'comment': + return 'Comment'; + default: + return hit.matched_in; + } +} + +export function SearchScreen() { + const t = useTokens(); + const multiPane = useIsMultiPane(); + const { teams } = useTeams(); + + const [query, setQuery] = useState(''); + const [scopeTeamId, setScopeTeamId] = useState(null); + const [scoping, setScoping] = useState(false); + const [limit, setLimit] = useState(PAGE); + + const settled = useDebouncedValue(query.trim(), DEBOUNCE_MS); + + const results = useSearchSearchGet( + { q: settled, team_id: scopeTeamId ?? undefined, limit, offset: 0 }, + // A single character matches most of an instance and is never what anyone + // meant, so the request waits for a second one. + { query: { enabled: settled.length >= 2 } }, + ); + + const hits = results.data?.items ?? []; + const total = results.data?.total ?? 0; + const scopeName = scopeTeamId + ? (teams.find((team) => team.id === scopeTeamId)?.name ?? 'One team') + : 'All teams'; + + return ( + + + Search + + setScoping(true)} + style={{ + alignSelf: 'flex-start', + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: t.radius.pill, + backgroundColor: scopeTeamId ? t.line.navActive : t.line.ghost, + }} + > + + {scopeName} + + + + + {settled.length < 2 ? ( + + + Type at least two characters to search. + + + ) : results.isPending ? ( + + ) : ( + String(hit.id)} + contentContainerStyle={{ padding: 16, gap: 10 }} + keyboardShouldPersistTaps="handled" + ListHeaderComponent={ + + {total} RESULT{total === 1 ? '' : 'S'} + + } + ListEmptyComponent={ + + Nothing matches “{settled}”. + + } + ListFooterComponent={ + total > hits.length ? ( + setLimit((current) => current + PAGE)} + style={{ + paddingVertical: 12, + alignItems: 'center', + borderRadius: t.radius.control, + borderWidth: StyleSheet.hairlineWidth, + borderColor: t.line.hairline, + }} + > + + Show more + + + ) : null + } + renderItem={({ item }) => ( + router.push(href(`/issue/${item.id}`))} + > + + + + {item.identifier} + + + {matchedInLabel(item)} + + + + {item.title} + + + {/* The snippet is why this result is here, so it is worth the + line even when it repeats part of the title. */} + {item.snippet ? ( + + {item.snippet} + + ) : null} + + + )} + /> + )} + + setScoping(false)} title="Search in"> + + {[{ id: null, name: 'All teams' }, ...teams].map((option) => { + const id = 'id' in option ? (option.id as number | null) : null; + const active = scopeTeamId === id; + return ( + { + setScopeTeamId(id); + setScoping(false); + }} + style={{ + paddingVertical: 12, + paddingHorizontal: 10, + borderRadius: t.radius.control, + backgroundColor: active ? t.line.navActive : 'transparent', + }} + > + {option.name} + + ); + })} + + + + ); +} diff --git a/src/settings/__tests__/username.test.ts b/src/settings/__tests__/username.test.ts new file mode 100644 index 0000000..00566e5 --- /dev/null +++ b/src/settings/__tests__/username.test.ts @@ -0,0 +1,24 @@ +import { isValidUsername } from '@/settings/username'; + +describe('isValidUsername', () => { + it('accepts what the backend accepts', () => { + expect(isValidUsername('ada')).toBe(true); + expect(isValidUsername('ada.lovelace')).toBe(true); + expect(isValidUsername('ada_2')).toBe(true); + expect(isValidUsername('a1')).toBe(true); + }); + + it('refuses what it would reject', () => { + // Must start with a letter or digit. + expect(isValidUsername('.ada')).toBe(false); + expect(isValidUsername('-ada')).toBe(false); + // Lowercase only, and no spaces or @. + expect(isValidUsername('Ada')).toBe(false); + expect(isValidUsername('ada lovelace')).toBe(false); + expect(isValidUsername('ada@example.com')).toBe(false); + // Two to thirty-nine characters. + expect(isValidUsername('a')).toBe(false); + expect(isValidUsername('a'.repeat(40))).toBe(false); + expect(isValidUsername('a'.repeat(39))).toBe(true); + }); +}); diff --git a/src/settings/username.ts b/src/settings/username.ts new file mode 100644 index 0000000..fe4597c --- /dev/null +++ b/src/settings/username.ts @@ -0,0 +1,12 @@ +/** + * The username rule, mirrored from `backend/lib_identity/usernames.py`. + * + * Checked before submitting so the form answers rather than the server -- a + * round trip to be told a dash is fine but a space is not reads as a fault in + * the app. + */ +const USERNAME_PATTERN = /^[a-z0-9][a-z0-9._-]{1,38}$/; + +export function isValidUsername(username: string): boolean { + return USERNAME_PATTERN.test(username); +} diff --git a/src/settings/you-screen.tsx b/src/settings/you-screen.tsx index edad7fe..11cd09d 100644 --- a/src/settings/you-screen.tsx +++ b/src/settings/you-screen.tsx @@ -1,24 +1,52 @@ -import { ScrollView, View } from 'react-native'; +import { useState } from 'react'; +import { Alert as RNAlert, Pressable, ScrollView, Switch, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; +import { useQueryClient } from '@tanstack/react-query'; -import { instanceLabel } from '@/api/instance'; +import { + changeMyPasswordAuthMePasswordPost, + signOutEverywhereRouteAuthMeSignOutEverywherePost, + updateMeAuthMePatch, +} from '@/api/generated/endpoints/auth/auth'; +import { + updateNotificationSettingsNotificationsSettingsPatch, + useGetNotificationSettingsNotificationsSettingsGet, +} from '@/api/generated/endpoints/notifications/notifications'; +import { errorDetail } from '@/api/errors'; +import { instanceLabel, setInstanceUrl } from '@/api/instance'; import { useAuth } from '@/auth/auth-context'; +import { SiteAdminSheet } from '@/admin/site-admin-sheet'; +import { isValidUsername } from '@/settings/username'; import { useIsMultiPane } from '@/ui/layout'; -import { AppText, Avatar, Button, Card } from '@/ui/primitives'; +import { AppText, Alert, Avatar, Button, Card, Field } from '@/ui/primitives'; +import { Sheet } from '@/ui/sheet'; import { ThemePreferenceControl } from '@/ui/theme-control'; import { useTokens } from '@/ui/theme'; /** - * The You destination, per `docs/design/mobile/152-settings.svg`. + * Account settings, per `docs/design/mobile/152-settings.svg`. * - * Deliberately minimal -- issue #15 owns real account settings. What is here is - * what makes issue #1's acceptance criteria exercisable from the running app: - * the theme control, the connected instance, and sign out. + * The seven the backend picks from; see AVATAR_COLORS in lib_identity. Offered + * as swatches rather than a free colour because the server will only keep one + * of these. */ +const AVATAR_COLORS = [ + '#6366f1', + '#ec4899', + '#14b8a6', + '#f59e0b', + '#8b5cf6', + '#ef4444', + '#22c55e', +]; + +type Panel = 'profile' | 'security' | 'notifications' | 'instance' | 'admin' | null; + export function YouScreen() { const t = useTokens(); const multiPane = useIsMultiPane(); - const { user, instanceUrl, signOut } = useAuth(); + const { user, instanceUrl, signOut, setSession } = useAuth(); + const [panel, setPanel] = useState(null); return ( ) : null} + + ACCOUNT + + setPanel('profile')} /> + setPanel('security')} /> + setPanel('notifications')} /> + + + + {/* Only for an account that actually has it; there is nothing to + discover here that the server would then refuse. */} + {user?.is_site_admin ? ( + + ADMINISTRATION + + setPanel('admin')} /> + + + ) : null} + APPEARANCE @@ -49,13 +97,12 @@ export function YouScreen() { INSTANCE - - - {instanceUrl ? instanceLabel(instanceUrl) : 'Not connected'} - - - Switching instances arrives with the settings work. - + + setPanel('instance')} + /> @@ -63,6 +110,384 @@ export function YouScreen() { Sign out + + setPanel(null)} + onSaved={setSession} + /> + setPanel(null)} + onReissued={setSession} + /> + setPanel(null)} + /> + setPanel(null)} + user={user} + /> + setPanel(null)} + onSwitch={signOut} + /> ); } + +function SettingsRow({ + label, + detail, + onPress, +}: { + label: string; + detail?: string; + onPress: () => void; +}) { + const t = useTokens(); + return ( + + + {label} + + {detail ? ( + + {detail} + + ) : null} + + ); +} + +function ProfilePanel({ + visible, + onClose, + onSaved, +}: { + visible: boolean; + onClose: () => void; + onSaved: (token: string, user: import('@/api/generated/models').UserMe) => void; +}) { + const t = useTokens(); + const queryClient = useQueryClient(); + const { user } = useAuth(); + + const [fullName, setFullName] = useState(''); + const [username, setUsername] = useState(''); + const [email, setEmail] = useState(''); + const [currentPassword, setCurrentPassword] = useState(''); + const [color, setColor] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + if (!visible || !user) return null; + + const chosenColor = color ?? user.avatar_color; + const emailChanged = email.trim() !== '' && email.trim() !== user.email; + + return ( + + + {error ? {error} : null} + + + {AVATAR_COLORS.map((swatch) => ( + setColor(swatch)} + style={{ + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: swatch, + borderWidth: chosenColor === swatch ? 3 : 0, + borderColor: t.brand[600], + }} + /> + ))} + + + + setUsername(value.toLowerCase())} + autoCapitalize="none" + autoCorrect={false} + hint="Lowercase letters, digits, dot, dash or underscore. 2 to 39 characters." + /> + + + {/* Only asked for when it is actually needed, which is the one case the + server demands it. */} + {emailChanged ? ( + + ) : null} + + + + + ); +} + +function SecurityPanel({ + visible, + onClose, + onReissued, +}: { + visible: boolean; + onClose: () => void; + onReissued: (token: string, user: import('@/api/generated/models').UserMe) => void; +}) { + const [current, setCurrent] = useState(''); + const [next, setNext] = useState(''); + const [confirm, setConfirm] = useState(''); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + if (!visible) return null; + + return ( + + + {error ? {error} : null} + + + + + + + + + + + ); +} + +function NotificationsPanel({ + visible, + onClose, +}: { + visible: boolean; + onClose: () => void; +}) { + const queryClient = useQueryClient(); + const settings = useGetNotificationSettingsNotificationsSettingsGet({ + query: { enabled: visible }, + }); + const [error, setError] = useState(null); + + if (!visible) return null; + const data = settings.data; + + return ( + + + {error ? {error} : null} + + + { + setError(null); + try { + await updateNotificationSettingsNotificationsSettingsPatch({ + email_notifications: value, + }); + await queryClient.invalidateQueries({ + queryKey: ['/notifications/settings'], + }); + } catch (err) { + setError(errorDetail(err, 'Could not change that setting.')); + } + }} + /> + + Email me about my issues + + + + {/* The toggle is meaningless where the instance cannot send email, and + silently doing nothing would be worse than saying so. */} + {data && !data.email_delivery_configured ? ( + + This instance has no email configured, so nothing would be sent. Ask + an administrator to set up SMTP. + + ) : null} + + + ); +} + +function InstancePanel({ + visible, + onClose, + onSwitch, +}: { + visible: boolean; + onClose: () => void; + onSwitch: () => void; +}) { + const { instanceUrl } = useAuth(); + if (!visible) return null; + + return ( + + + + Connected to {instanceUrl ? instanceLabel(instanceUrl) : 'nothing'}. + + + Switching signs you out of this instance. Your account here is not + affected. + + + + + ); +} diff --git a/src/storage/keys.ts b/src/storage/keys.ts index b9f3a4f..be3e630 100644 --- a/src/storage/keys.ts +++ b/src/storage/keys.ts @@ -11,3 +11,6 @@ export const THEME_KEY = 'softtrack.theme'; /** Base URL of the instance the user signed in to. */ export const INSTANCE_KEY = 'softtrack.instance'; + +/** The team key whose board the Board tab shows. */ +export const ACTIVE_TEAM_KEY = 'softtrack.team'; diff --git a/src/team/__tests__/new-team-sheet.test.ts b/src/team/__tests__/new-team-sheet.test.ts new file mode 100644 index 0000000..20f243a --- /dev/null +++ b/src/team/__tests__/new-team-sheet.test.ts @@ -0,0 +1,25 @@ +import { suggestKey } from '@/team/new-team-sheet'; + +describe('suggestKey', () => { + it('takes initials from a multi-word name', () => { + expect(suggestKey('Mobile Platform')).toBe('MP'); + expect(suggestKey('Site Reliability Engineering')).toBe('SRE'); + }); + + it('truncates a single word', () => { + expect(suggestKey('Engineering')).toBe('ENG'); + expect(suggestKey('Ops')).toBe('OPS'); + }); + + it('stops at three initials, since the key caps at six characters', () => { + expect(suggestKey('One Two Three Four Five')).toBe('OTT'); + }); + + it('ignores anything that is not a letter', () => { + // The backend uppercases the key and the field strips non-letters, so a + // suggestion containing either would never survive submission. + expect(suggestKey('design-systems')).toBe('DS'); + expect(suggestKey(' ')).toBe(''); + expect(suggestKey('123')).toBe(''); + }); +}); diff --git a/src/team/admin/__tests__/automation.test.ts b/src/team/admin/__tests__/automation.test.ts new file mode 100644 index 0000000..3a50428 --- /dev/null +++ b/src/team/admin/__tests__/automation.test.ts @@ -0,0 +1,69 @@ +import { describeRule } from '@/team/admin/automation-panel'; + +const LOOK = { + status: (id: number) => (id === 1 ? 'In Progress' : 'a deleted status'), + label: (id: number) => (id === 2 ? 'bug' : 'a deleted label'), + project: (id: number) => (id === 3 ? 'Platform' : 'a deleted project'), + member: (id: number) => (id === 4 ? 'Ada Lovelace' : 'a former member'), + cycle: (id: number) => (id === 5 ? 'Cycle 14' : 'a deleted cycle'), +}; + +function rule(over: Record = {}) { + return { + id: 1, + team_id: 1, + name: 'Rule', + trigger: 'issue_created', + is_enabled: true, + conditions: {}, + actions: {}, + created_by: { id: 1, username: 'a', full_name: 'A', avatar_color: '#000' }, + created_at: '', + updated_at: '', + ...over, + } as never as Parameters[0]; +} + +describe('describeRule', () => { + it('reads as a sentence rather than a grid of fields', () => { + expect( + describeRule(rule({ actions: { set_priority: 'high' } }), LOOK), + ).toBe('When an issue is created, set its priority to High.'); + }); + + it('joins conditions with "and"', () => { + expect( + describeRule( + rule({ + trigger: 'status_changed', + conditions: { if_status_id: 1, if_unassigned: true }, + actions: { set_assignee_id: 4 }, + }), + LOOK, + ), + ).toBe( + 'When an issue changes status and it is in In Progress and it is unassigned, assign it to Ada Lovelace.', + ); + }); + + it('joins multiple actions', () => { + expect( + describeRule( + rule({ actions: { add_label_id: 2, move_to_active_cycle: true } }), + LOOK, + ), + ).toBe('When an issue is created, add the bug label, and move it into the active cycle.'); + }); + + it('names a row the team has since deleted rather than printing undefined', () => { + // A rule outlives the label it points at, and a sentence with "undefined" + // in it is worse than one that says the label is gone. + expect(describeRule(rule({ actions: { add_label_id: 99 } }), LOOK)).toContain( + 'a deleted label', + ); + }); + + it('says so when a rule does nothing', () => { + expect(describeRule(rule(), LOOK)).toBe('When an issue is created, do nothing.'); + }); +}); diff --git a/src/team/admin/automation-panel.tsx b/src/team/admin/automation-panel.tsx new file mode 100644 index 0000000..587f538 --- /dev/null +++ b/src/team/admin/automation-panel.tsx @@ -0,0 +1,235 @@ +import { useState } from 'react'; +import { Alert as RNAlert, Pressable, Switch, View } from 'react-native'; +import { useQueryClient } from '@tanstack/react-query'; + +import { + deleteRuleAutomationRulesRuleIdDelete, + updateRuleAutomationRulesRuleIdPatch, + useListRulesTeamsTeamIdAutomationRulesGet, + useListRunsTeamsTeamIdAutomationRunsGet, +} from '@/api/generated/endpoints/automations/automations'; +import type { + AutomationRuleRead, + AutomationTrigger, + RuleActions, + RuleConditions, + TeamRead, +} from '@/api/generated/models'; +import { errorDetail } from '@/api/errors'; +import { useTeamData } from '@/board/use-team-data'; +import { PRIORITY_LABEL } from '@/issues/issue-meta'; +import { Alert, AppText, Card } from '@/ui/primitives'; +import { Sheet } from '@/ui/sheet'; +import { useTokens } from '@/ui/theme'; + +/** + * Automation rules and their run log. + * + * Rules render as English rather than as a form of dropdowns. A rule is read + * far more often than it is written -- usually to work out why an issue moved -- + * and "When an issue is created, set its priority to High" answers that in a + * glance where a grid of selects does not. + */ +const TRIGGER_TEXT: Record = { + issue_created: 'an issue is created', + status_changed: 'an issue changes status', + issue_assigned: 'an issue is assigned', + comment_added: 'a comment is added', + cycle_completed: 'a cycle is completed', + branch_created: 'a branch is created', + pull_request_opened: 'a pull request is opened', + pull_request_merged: 'a pull request is merged', +}; + +type Lookup = { + status: (id: number) => string; + label: (id: number) => string; + project: (id: number) => string; + member: (id: number) => string; + cycle: (id: number) => string; +}; + +function conditionText(conditions: RuleConditions, look: Lookup): string[] { + const parts: string[] = []; + if (conditions.if_status_id) parts.push(`it is in ${look.status(conditions.if_status_id)}`); + if (conditions.if_priority) parts.push(`its priority is ${PRIORITY_LABEL[conditions.if_priority]}`); + if (conditions.if_label_id) parts.push(`it has the ${look.label(conditions.if_label_id)} label`); + if (conditions.if_project_id) parts.push(`it is in ${look.project(conditions.if_project_id)}`); + if (conditions.if_assignee_id) parts.push(`it is assigned to ${look.member(conditions.if_assignee_id)}`); + if (conditions.if_unassigned) parts.push('it is unassigned'); + return parts; +} + +function actionText(actions: RuleActions, look: Lookup): string[] { + const parts: string[] = []; + if (actions.set_status_id) parts.push(`move it to ${look.status(actions.set_status_id)}`); + if (actions.set_priority) parts.push(`set its priority to ${PRIORITY_LABEL[actions.set_priority]}`); + if (actions.set_assignee_id) parts.push(`assign it to ${look.member(actions.set_assignee_id)}`); + if (actions.add_label_id) parts.push(`add the ${look.label(actions.add_label_id)} label`); + if (actions.set_cycle_id) parts.push(`put it in ${look.cycle(actions.set_cycle_id)}`); + if (actions.move_to_active_cycle) parts.push('move it into the active cycle'); + if (actions.comment_body) parts.push('leave a comment'); + return parts; +} + +/** A rule as a sentence. Exported because the phrasing is worth testing. */ +export function describeRule(rule: AutomationRuleRead, look: Lookup): string { + const when = TRIGGER_TEXT[rule.trigger] ?? rule.trigger; + const ifs = conditionText(rule.conditions, look); + const dos = actionText(rule.actions, look); + + const condition = ifs.length > 0 ? ` and ${ifs.join(' and ')}` : ''; + const action = dos.length > 0 ? dos.join(', and ') : 'do nothing'; + return `When ${when}${condition}, ${action}.`; +} + +export function AutomationPanel({ + visible, + onClose, + team, + isAdmin, +}: { + visible: boolean; + onClose: () => void; + team: TeamRead; + isAdmin: boolean; +}) { + const t = useTokens(); + const queryClient = useQueryClient(); + const data = useTeamData(team); + + const rules = useListRulesTeamsTeamIdAutomationRulesGet(team.id, { + query: { enabled: visible }, + }); + const runs = useListRunsTeamsTeamIdAutomationRunsGet( + team.id, + { limit: 20 }, + { query: { enabled: visible } }, + ); + + const [error, setError] = useState(null); + + const look: Lookup = { + // A rule can outlive the row it names, so every lookup has a fallback + // rather than rendering "undefined" into a sentence. + status: (id) => data.statuses.find((s) => s.id === id)?.name ?? 'a deleted status', + label: (id) => data.labels.find((l) => l.id === id)?.name ?? 'a deleted label', + project: (id) => data.projects.find((p) => p.id === id)?.name ?? 'a deleted project', + member: (id) => + data.members.find((m) => m.user.id === id)?.user.full_name ?? 'a former member', + cycle: (id) => data.cycles.find((c) => c.id === id)?.display_name ?? 'a deleted cycle', + }; + + async function run(action: () => Promise, fallback: string) { + setError(null); + try { + await action(); + await queryClient.invalidateQueries({ + queryKey: [`/teams/${team.id}/automation-rules`], + }); + } catch (err) { + setError(errorDetail(err, fallback)); + } + } + + if (!visible) return null; + + return ( + + + {error ? {error} : null} + + {(rules.data ?? []).map((rule) => ( + + + + {rule.name} + + + void run( + () => + updateRuleAutomationRulesRuleIdPatch(rule.id, { + is_enabled: value, + }).then(() => undefined), + 'Could not change that rule.', + ) + } + /> + + + + {describeRule(rule, look)} + + + {isAdmin ? ( + + RNAlert.alert(`Delete ${rule.name}?`, 'This cannot be undone.', [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete', + style: 'destructive', + onPress: () => + void run( + () => + deleteRuleAutomationRulesRuleIdDelete(rule.id).then( + () => undefined, + ), + 'Could not delete that rule.', + ), + }, + ]) + } + > + + Delete + + + ) : null} + + ))} + + {(rules.data ?? []).length === 0 && !rules.isPending ? ( + + No automation rules yet. + + ) : null} + + {/* Rules can be read, enabled, disabled and deleted here. Authoring one + is a trigger plus six optional conditions plus seven optional + actions, which is a form that belongs on a wider screen -- the web + settings page has it. */} + {isAdmin ? ( + + New rules are written on the web settings page; everything else about + them is here. + + ) : null} + + + RECENT RUNS + {(runs.data?.items ?? []).map((entry) => ( + + + {entry.rule_name} + + + {entry.issue_identifier ? `${entry.issue_identifier} · ` : ''} + {entry.summary} + + + ))} + {(runs.data?.items ?? []).length === 0 && !runs.isPending ? ( + Nothing has run yet. + ) : null} + + + + ); +} diff --git a/src/team/admin/members-panel.tsx b/src/team/admin/members-panel.tsx new file mode 100644 index 0000000..a1d630a --- /dev/null +++ b/src/team/admin/members-panel.tsx @@ -0,0 +1,249 @@ +import { useState } from 'react'; +import { Alert as RNAlert, Pressable, View } from 'react-native'; +import { useQueryClient } from '@tanstack/react-query'; + +import { + createInviteTeamsTeamIdInvitesPost, + revokeInviteTeamsTeamIdInvitesInviteIdDelete, + useListInvitesTeamsTeamIdInvitesGet, +} from '@/api/generated/endpoints/invites/invites'; +import { + removeTeamMemberTeamsTeamIdMembersUserIdDelete, + updateTeamMemberRoleTeamsTeamIdMembersUserIdPatch, + useListTeamMembersTeamsTeamIdMembersGet, +} from '@/api/generated/endpoints/teams/teams'; +import type { TeamRead, UserMe } from '@/api/generated/models'; +import { errorDetail } from '@/api/errors'; +import { getInstanceUrl } from '@/api/instance'; +import { shareInvite } from '@/team/admin/share'; +import { Alert, AppText, Avatar, Button, Card, Field, RoleChip } from '@/ui/primitives'; +import { Sheet } from '@/ui/sheet'; +import { useTokens } from '@/ui/theme'; + +/** + * Members and invitations. + * + * Every guard here is the server's -- last-active-admin, self-demotion, admin + * only. The client mirrors the messages rather than the rules, so an action that + * would be refused says why instead of failing opaquely. + */ +export function MembersPanel({ + visible, + onClose, + team, + user, + isAdmin, +}: { + visible: boolean; + onClose: () => void; + team: TeamRead; + user: UserMe | null; + isAdmin: boolean; +}) { + const t = useTokens(); + const queryClient = useQueryClient(); + const members = useListTeamMembersTeamsTeamIdMembersGet(team.id, { + query: { enabled: visible }, + }); + const invites = useListInvitesTeamsTeamIdInvitesGet(team.id, { + query: { enabled: visible && isAdmin }, + }); + + const [email, setEmail] = useState(''); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const list = members.data ?? []; + const admins = list.filter((member) => member.role === 'admin'); + + async function run(action: () => Promise, fallback: string) { + setError(null); + setBusy(true); + try { + await action(); + await queryClient.invalidateQueries({ queryKey: [`/teams/${team.id}/members`] }); + await queryClient.invalidateQueries({ queryKey: [`/teams/${team.id}/invites`] }); + } catch (err) { + setError(errorDetail(err, fallback)); + } finally { + setBusy(false); + } + } + + if (!visible) return null; + + return ( + + + {error ? {error} : null} + + {list.map((member) => { + const isSelf = member.user.id === user?.id; + const isLastAdmin = member.role === 'admin' && admins.length === 1; + + return ( + + + + + {member.user.full_name} + {isSelf ? ' (you)' : ''} + + + @{member.user.username} + + + + + + {isAdmin ? ( + { + const options: Parameters[2] = [ + { text: 'Cancel', style: 'cancel' }, + ]; + + if (!isLastAdmin) { + options.push({ + text: member.role === 'admin' ? 'Make member' : 'Make admin', + onPress: () => + void run( + () => + updateTeamMemberRoleTeamsTeamIdMembersUserIdPatch( + team.id, + member.user.id, + { role: member.role === 'admin' ? 'member' : 'admin' }, + ).then(() => undefined), + 'Could not change that role.', + ), + }); + } + + options.push({ + text: isSelf ? 'Leave team' : 'Remove from team', + style: 'destructive', + onPress: () => + void run( + () => + removeTeamMemberTeamsTeamIdMembersUserIdDelete( + team.id, + member.user.id, + ).then(() => undefined), + isSelf ? 'Could not leave the team.' : 'Could not remove them.', + ), + }); + + RNAlert.alert( + member.user.full_name, + isLastAdmin + ? 'The last admin cannot be demoted — promote somebody else first.' + : undefined, + options, + ); + }} + > + + Manage + + + ) : null} + + ); + })} + + {isAdmin ? ( + + INVITE + + {(invites.data ?? []).map((invite) => ( + + + + {invite.email} + + Invited as {invite.role} + + + void shareInvite(invite.token, team.name, getInstanceUrl()) + } + > + + Share + + + + void run( + () => + revokeInviteTeamsTeamIdInvitesInviteIdDelete( + team.id, + invite.id, + ).then(() => undefined), + 'Could not revoke that invitation.', + ) + } + > + + Revoke + + + + ))} + + + + + ) : null} + + + ); +} diff --git a/src/team/admin/share.ts b/src/team/admin/share.ts new file mode 100644 index 0000000..3f6cea4 --- /dev/null +++ b/src/team/admin/share.ts @@ -0,0 +1,28 @@ +import { Share } from 'react-native'; + +/** + * Hand an invitation link to the system share sheet. + * + * This instance sends no email, so the link only reaches anybody if the person + * creating it can pass it on. The web URL is shared rather than the custom + * scheme: it opens for someone with no app installed, and the app can pick it + * up from there. + */ +export async function shareInvite( + token: string, + teamName: string, + instanceUrl: string | null, +): Promise { + const link = instanceUrl + ? `${instanceUrl}/invite/${token}` + : `softtrack://invite/${token}`; + + try { + await Share.share({ + message: `Join ${teamName} on SoftTrack: ${link}`, + url: link, + }); + } catch { + // Dismissing the share sheet is not a failure worth reporting. + } +} diff --git a/src/team/admin/statuses-panel.tsx b/src/team/admin/statuses-panel.tsx new file mode 100644 index 0000000..8297ab6 --- /dev/null +++ b/src/team/admin/statuses-panel.tsx @@ -0,0 +1,331 @@ +import { useState } from 'react'; +import { Alert as RNAlert, Pressable, View } from 'react-native'; +import { useQueryClient } from '@tanstack/react-query'; + +import { + createStatusTeamsTeamIdStatusesPost, + deleteStatusStatusesStatusIdDelete, + reorderStatusesTeamsTeamIdStatusesOrderPut, + updateStatusStatusesStatusIdPatch, + useListStatusesTeamsTeamIdStatusesGet, +} from '@/api/generated/endpoints/statuses/statuses'; +import { StatusCategory, type StatusRead, type TeamRead } from '@/api/generated/models'; +import { errorDetail } from '@/api/errors'; +import { Icon } from '@/ui/icon'; +import { Alert, AppText, Button, Card, Field } from '@/ui/primitives'; +import { Sheet } from '@/ui/sheet'; +import { useTokens } from '@/ui/theme'; + +/** + * The team's board columns. + * + * Reordering moves one row at a time with arrows rather than a drag. The board + * already spends its drag gesture on cards, and a second drag surface inside a + * sheet is both harder to hit and harder to verify -- arrows do the same job + * with a target a thumb can actually land on. + */ +const CATEGORY_ORDER: StatusCategory[] = [ + StatusCategory.backlog, + StatusCategory.unstarted, + StatusCategory.started, + StatusCategory.done, + StatusCategory.cancelled, +]; + +const CATEGORY_HINT: Record = { + backlog: 'Not committed to yet', + unstarted: 'Accepted, not begun', + started: 'Work in flight', + done: 'Finished', + cancelled: 'Closed without being delivered', +}; + +export function StatusesPanel({ + visible, + onClose, + team, + isAdmin, +}: { + visible: boolean; + onClose: () => void; + team: TeamRead; + isAdmin: boolean; +}) { + const t = useTokens(); + const queryClient = useQueryClient(); + const statuses = useListStatusesTeamsTeamIdStatusesGet(team.id, { + query: { enabled: visible }, + }); + + const [name, setName] = useState(''); + const [category, setCategory] = useState(StatusCategory.unstarted); + const [editing, setEditing] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const items = statuses.data ?? []; + + async function run(action: () => Promise, fallback: string) { + setError(null); + setBusy(true); + try { + await action(); + await queryClient.invalidateQueries({ queryKey: [`/teams/${team.id}/statuses`] }); + // Columns are what the board draws, so it has to re-read them. + void queryClient.invalidateQueries({ queryKey: [`/teams/${team.id}/issues`] }); + } catch (err) { + setError(errorDetail(err, fallback)); + } finally { + setBusy(false); + } + } + + /** Every id at once: two people reordering would otherwise interleave. */ + function move(index: number, delta: number) { + const next = [...items]; + const target = index + delta; + if (target < 0 || target >= next.length) return; + [next[index], next[target]] = [next[target], next[index]]; + void run( + () => + reorderStatusesTeamsTeamIdStatusesOrderPut(team.id, { + status_ids: next.map((status) => status.id), + }).then(() => undefined), + 'Could not reorder the columns.', + ); + } + + function confirmDelete(status: StatusRead) { + const others = items.filter((candidate) => candidate.id !== status.id); + if (others.length === 0) { + setError('A team needs at least one status.'); + return; + } + + // Where the issues go is required rather than defaulted: guessing which + // column somebody's work lands in is not a decision to make for them. + RNAlert.alert( + `Delete ${status.name}?`, + 'Its issues have to go somewhere. Choose a column.', + [ + { text: 'Cancel', style: 'cancel' }, + ...others.slice(0, 4).map((destination) => ({ + text: `Move to ${destination.name}`, + onPress: () => + void run( + () => + deleteStatusStatusesStatusIdDelete(status.id, { + move_to_id: destination.id, + }).then(() => undefined), + 'Could not delete that status.', + ), + })), + ], + ); + } + + if (!visible) return null; + + return ( + + + {error ? {error} : null} + + {items.map((status, index) => ( + + + + + {status.name} + + {CATEGORY_HINT[status.category]} + + + {isAdmin ? ( + <> + move(index, -1)} + style={{ padding: 6, opacity: index === 0 ? 0.3 : 1 }} + > + ↑ + + move(index, 1)} + style={{ padding: 6, opacity: index === items.length - 1 ? 0.3 : 1 }} + > + ↓ + + setEditing(status)} + style={{ padding: 6 }} + > + + + + ) : null} + + ))} + + {isAdmin ? ( + + NEW STATUS + + + + {CATEGORY_ORDER.map((option) => { + const active = category === option; + return ( + setCategory(option)} + style={{ + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: t.radius.pill, + backgroundColor: active ? t.line.navActive : t.line.well, + }} + > + + {option} + + + ); + })} + + {CATEGORY_HINT[category]} + + + + ) : null} + + + {editing ? ( + setEditing(null)} + onRun={run} + onDelete={() => { + const target = editing; + setEditing(null); + confirmDelete(target); + }} + /> + ) : null} + + ); +} + +function EditStatusSheet({ + status, + onClose, + onRun, + onDelete, +}: { + status: StatusRead; + onClose: () => void; + onRun: (action: () => Promise, fallback: string) => Promise; + onDelete: () => void; +}) { + const t = useTokens(); + const [name, setName] = useState(''); + const [category, setCategory] = useState(status.category); + + return ( + + + + + + {CATEGORY_ORDER.map((option) => { + const active = category === option; + return ( + setCategory(option)} + style={{ + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: t.radius.pill, + backgroundColor: active ? t.line.navActive : t.line.well, + }} + > + + {option} + + + ); + })} + + + + + + + + ); +} diff --git a/src/team/admin/team-admin-sheet.tsx b/src/team/admin/team-admin-sheet.tsx new file mode 100644 index 0000000..59c0c94 --- /dev/null +++ b/src/team/admin/team-admin-sheet.tsx @@ -0,0 +1,381 @@ +import { useState } from 'react'; +import { Alert as RNAlert, Linking, Pressable, Share, View } from 'react-native'; +import * as Clipboard from 'expo-clipboard'; +import { useQueryClient } from '@tanstack/react-query'; + +import { + deleteRepositoryRepositoriesRepositoryIdDelete, + rotateSecretRepositoriesRepositoryIdRotatePost, + useListRepositoriesTeamsTeamIdRepositoriesGet, +} from '@/api/generated/endpoints/integrations/integrations'; +import { + updateTeamTeamsTeamIdPatch, + useListTeamMembersTeamsTeamIdMembersGet, +} from '@/api/generated/endpoints/teams/teams'; +import type { TeamRead } from '@/api/generated/models'; +import { errorDetail } from '@/api/errors'; +import { getInstanceUrl } from '@/api/instance'; +import { useAuth } from '@/auth/auth-context'; +import { AutomationPanel } from '@/team/admin/automation-panel'; +import { MembersPanel } from '@/team/admin/members-panel'; +import { StatusesPanel } from '@/team/admin/statuses-panel'; +import { Alert, AppText, Button, Card, Field } from '@/ui/primitives'; +import { Sheet } from '@/ui/sheet'; +import { useTokens } from '@/ui/theme'; + +/** + * Team administration, per `docs/design/mobile/153-team-admin.svg`. + * + * A menu of panels rather than one long form: five unrelated areas on one phone + * screen would be a scroll with no landmarks. + */ +type Panel = 'general' | 'members' | 'statuses' | 'automation' | 'repositories' | null; + +export function TeamAdminSheet({ + visible, + onClose, + team, +}: { + visible: boolean; + onClose: () => void; + team: TeamRead; +}) { + const t = useTokens(); + const { user } = useAuth(); + const [panel, setPanel] = useState(null); + + const members = useListTeamMembersTeamsTeamIdMembersGet(team.id, { + query: { enabled: visible }, + }); + // The server enforces every admin rule; this only decides what to offer, so + // nothing is presented that would be refused. + const isAdmin = + (members.data ?? []).find((member) => member.user.id === user?.id)?.role === 'admin'; + + if (!visible) return null; + + return ( + + + {( + [ + ['general', 'General'], + ['members', 'Members and invitations'], + ['statuses', 'Statuses'], + ['automation', 'Automation'], + ['repositories', 'Repositories'], + ] as const + ).map(([key, label]) => ( + setPanel(key)} + style={{ + paddingVertical: 14, + paddingHorizontal: 10, + borderRadius: t.radius.control, + }} + > + {label} + + ))} + + {!isAdmin ? ( + + You are a member of this team, so some settings are read-only. + + ) : null} + + {/* Importing from Jira is a long, file-driven flow that stays on the + web settings page, as the issue allows. */} + { + const base = getInstanceUrl(); + if (base) void Linking.openURL(`${base}/settings/teams/${team.key}/general`); + }} + style={{ paddingVertical: 14, paddingHorizontal: 10 }} + > + + Import from Jira (opens the web app) + + + + + setPanel(null)} + team={team} + isAdmin={isAdmin} + /> + setPanel(null)} + team={team} + user={user} + isAdmin={isAdmin} + /> + setPanel(null)} + team={team} + isAdmin={isAdmin} + /> + setPanel(null)} + team={team} + isAdmin={isAdmin} + /> + setPanel(null)} + team={team} + isAdmin={isAdmin} + /> + + ); +} + +function GeneralPanel({ + visible, + onClose, + team, + isAdmin, +}: { + visible: boolean; + onClose: () => void; + team: TeamRead; + isAdmin: boolean; +}) { + const queryClient = useQueryClient(); + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + if (!visible) return null; + + return ( + + + {error ? {error} : null} + + + + + + {isAdmin ? ( + + ) : null} + + + ); +} + +function RepositoriesPanel({ + visible, + onClose, + team, + isAdmin, +}: { + visible: boolean; + onClose: () => void; + team: TeamRead; + isAdmin: boolean; +}) { + const t = useTokens(); + const queryClient = useQueryClient(); + const repositories = useListRepositoriesTeamsTeamIdRepositoriesGet(team.id, { + query: { enabled: visible }, + }); + const [error, setError] = useState(null); + const [revealed, setRevealed] = useState(null); + + async function run(action: () => Promise, fallback: string) { + setError(null); + try { + await action(); + await queryClient.invalidateQueries({ + queryKey: [`/teams/${team.id}/repositories`], + }); + } catch (err) { + setError(errorDetail(err, fallback)); + } + } + + if (!visible) return null; + const items = repositories.data ?? []; + + return ( + + + {error ? {error} : null} + + {items.map((repository) => ( + + {repository.full_name} + + {repository.provider} ·{' '} + {repository.last_delivery_at + ? `last delivery ${repository.last_delivery_at.slice(0, 10)}` + : 'nothing delivered yet'} + + + + void Clipboard.setStringAsync(repository.webhook_url)} + > + + Copy payload URL + + + + {/* A secret is not put on screen until it is asked for -- these + screens get shown to other people. */} + { + if (revealed === repository.id) { + void Clipboard.setStringAsync(repository.secret); + } else { + setRevealed(repository.id); + } + }} + > + + {revealed === repository.id ? 'Copy secret' : 'Reveal secret'} + + + + {isAdmin ? ( + + RNAlert.alert( + 'Rotate the secret?', + 'Deliveries stop until the new secret is set on the repository.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Rotate', + style: 'destructive', + onPress: () => + void run( + () => + rotateSecretRepositoriesRepositoryIdRotatePost( + repository.id, + ).then(() => undefined), + 'Could not rotate the secret.', + ), + }, + ], + ) + } + > + + Rotate secret + + + ) : null} + + {isAdmin ? ( + + RNAlert.alert( + `Disconnect ${repository.full_name}?`, + 'Branches and pull requests stop being linked to issues.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Disconnect', + style: 'destructive', + onPress: () => + void run( + () => + deleteRepositoryRepositoriesRepositoryIdDelete( + repository.id, + ).then(() => undefined), + 'Could not disconnect that repository.', + ), + }, + ], + ) + } + > + + Disconnect + + + ) : null} + + void Share.share({ message: repository.webhook_url })} + > + + Share URL + + + + + {revealed === repository.id ? ( + + {repository.secret} + + ) : null} + + ))} + + {items.length === 0 && !repositories.isPending ? ( + + No repositories connected. + + ) : null} + + + ); +} diff --git a/src/team/invites-banner.tsx b/src/team/invites-banner.tsx new file mode 100644 index 0000000..ce48ea2 --- /dev/null +++ b/src/team/invites-banner.tsx @@ -0,0 +1,101 @@ +import { useState } from 'react'; +import { View } from 'react-native'; +import { useQueryClient } from '@tanstack/react-query'; + +import { useMyInvitesAuthMeInvitesGet } from '@/api/generated/endpoints/auth/auth'; +import { + acceptInviteInvitesTokenAcceptPost, + declineInviteInvitesTokenDeclinePost, +} from '@/api/generated/endpoints/invites/invites'; +import { errorDetail } from '@/api/errors'; +import { useTeams } from '@/team/team-context'; +import { Alert, AppText, Button, Card } from '@/ui/primitives'; +import { useTokens } from '@/ui/theme'; + +/** + * Invitations waiting for the signed-in user. + * + * There is no email delivery in the picture, so this is the only thing that + * tells someone an invitation exists once they already have an account -- + * without it, a person invited to a second team would never find out. + */ +export function InvitesBanner() { + const t = useTokens(); + const invites = useMyInvitesAuthMeInvitesGet({ query: { staleTime: 30_000 } }); + const queryClient = useQueryClient(); + const { setTeamKey } = useTeams(); + + const [busyToken, setBusyToken] = useState(null); + const [error, setError] = useState(null); + + const pending = invites.data ?? []; + if (pending.length === 0) return null; + + const refresh = () => + Promise.all([ + queryClient.invalidateQueries({ queryKey: ['/auth/me/invites'] }), + // refetchType 'all' so the team list updates even where nothing is + // currently observing it -- the switcher reads it straight after. + queryClient.invalidateQueries({ queryKey: ['/teams'], refetchType: 'all' }), + ]); + + async function act(token: string, run: () => Promise, fallback: string) { + setError(null); + setBusyToken(token); + try { + await run(); + await refresh(); + } catch (err) { + setError(errorDetail(err, fallback)); + } finally { + setBusyToken(null); + } + } + + return ( + + INVITATIONS + {error ? {error} : null} + + {pending.map((invite) => ( + + + {invite.invited_by.full_name} invited you to {invite.team_name} ( + {invite.team_key}) as {invite.role}. + + + + + + + ))} + + ); +} diff --git a/src/team/new-team-sheet.tsx b/src/team/new-team-sheet.tsx new file mode 100644 index 0000000..5d9ab2d --- /dev/null +++ b/src/team/new-team-sheet.tsx @@ -0,0 +1,119 @@ +import { useState } from 'react'; +import { View } from 'react-native'; +import { useQueryClient } from '@tanstack/react-query'; + +import { createTeamTeamsPost } from '@/api/generated/endpoints/teams/teams'; +import { errorDetail } from '@/api/errors'; +import { useTeams } from '@/team/team-context'; +import { Alert, AppText, Button, Field } from '@/ui/primitives'; +import { Sheet } from '@/ui/sheet'; + +/** + * Suggest a key from the team name: "Mobile Platform" -> "MOB". + * + * A suggestion only. The key is permanent once created + * (`backend/lib_softtrack/models/teams.py:27-36` -- PATCH accepts name and + * description, never key), so it is offered rather than imposed, and typing in + * the field stops the suggestion from overwriting the choice. + */ +export function suggestKey(name: string): string { + const words = name + .toUpperCase() + .split(/[^A-Z]+/) + .filter(Boolean); + if (words.length === 0) return ''; + if (words.length === 1) return words[0].slice(0, 3); + return words + .slice(0, 3) + .map((word) => word[0]) + .join(''); +} + +export function NewTeamSheet({ + visible, + onClose, +}: { + visible: boolean; + onClose: () => void; +}) { + const queryClient = useQueryClient(); + const { setTeamKey } = useTeams(); + + const [name, setName] = useState(''); + const [typedKey, setTypedKey] = useState(null); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + // Derived, not synced: the suggestion applies until someone types their own. + const key = typedKey ?? suggestKey(name); + + function reset() { + setName(''); + setTypedKey(null); + setError(null); + } + + async function submit() { + setError(null); + if (key.length < 2) { + setError('A key needs at least 2 letters.'); + return; + } + + setSubmitting(true); + try { + const team = await createTeamTeamsPost({ name: name.trim(), key }); + await queryClient.invalidateQueries({ queryKey: ['/teams'], refetchType: 'all' }); + setTeamKey(team.key); + reset(); + onClose(); + } catch (err) { + setError(errorDetail(err, 'Could not create the team.')); + } finally { + setSubmitting(false); + } + } + + return ( + { + reset(); + onClose(); + }} + > + + Teams group issues, cycles and projects. + + {error ? {error} : null} + + + setTypedKey(value.toUpperCase().replace(/[^A-Z]/g, ''))} + placeholder="MOB" + autoCapitalize="characters" + autoCorrect={false} + maxLength={6} + returnKeyType="go" + onSubmitEditing={submit} + hint="Permanent — it appears in issue IDs like ENG-42." + /> + + + + + ); +} diff --git a/src/team/team-context.tsx b/src/team/team-context.tsx new file mode 100644 index 0000000..f8d5062 --- /dev/null +++ b/src/team/team-context.tsx @@ -0,0 +1,82 @@ +import { + createContext, + useCallback, + useContext, + useMemo, + useState, + type ReactNode, +} from 'react'; + +import { useListMyTeamsTeamsGet } from '@/api/generated/endpoints/teams/teams'; +import type { TeamRead } from '@/api/generated/models'; +import { ACTIVE_TEAM_KEY } from '@/storage/keys'; +import { readPref, writePref } from '@/storage/prefs'; + +/** + * Which team the team-scoped destinations are showing. + * + * The web keys this off the URL (`/:teamKey`, `frontend/src/app/App.tsx:72`) + * because every board view is its own address. A tab bar has no room for that: + * Board is one destination whose subject changes, so the key lives here and is + * persisted, and the `/[teamKey]` route sets it rather than replacing the shell. + */ +type TeamContextValue = { + teams: TeamRead[]; + team: TeamRead | undefined; + teamKey: string | null; + setTeamKey: (key: string) => void; + isPending: boolean; + isError: boolean; +}; + +const TeamContext = createContext(null); + +/** Load the remembered team key. Called once, behind the splash. */ +export async function hydrateActiveTeam(): Promise { + return readPref(ACTIVE_TEAM_KEY); +} + +export function TeamProvider({ + initialTeamKey, + children, +}: { + initialTeamKey: string | null; + children: ReactNode; +}) { + const [storedKey, setStoredKey] = useState(initialTeamKey); + const teamsQuery = useListMyTeamsTeamsGet({ query: { staleTime: 30_000 } }); + const teams = useMemo(() => teamsQuery.data ?? [], [teamsQuery.data]); + + const setTeamKey = useCallback((key: string) => { + setStoredKey(key); + void writePref(ACTIVE_TEAM_KEY, key); + }, []); + + // Fall back to the first team when nothing is remembered, or when the + // remembered one is gone -- left the team, or it was deleted. Without the + // fallback the board would sit empty pointing at a team that no longer exists. + const team = useMemo(() => { + const match = teams.find((t) => t.key.toLowerCase() === storedKey?.toLowerCase()); + return match ?? teams[0]; + }, [teams, storedKey]); + + const value = useMemo( + () => ({ + teams, + team, + teamKey: team?.key ?? null, + setTeamKey, + isPending: teamsQuery.isPending, + isError: teamsQuery.isError, + }), + [teams, team, setTeamKey, teamsQuery.isPending, teamsQuery.isError], + ); + + return {children}; +} + +export function useTeams(): TeamContextValue { + const value = useContext(TeamContext); + if (!value) throw new Error('useTeams must be used inside a TeamProvider'); + return value; +} diff --git a/src/team/team-route.tsx b/src/team/team-route.tsx new file mode 100644 index 0000000..2523795 --- /dev/null +++ b/src/team/team-route.tsx @@ -0,0 +1,44 @@ +import { useEffect } from 'react'; +import { router, useLocalSearchParams } from 'expo-router'; + +import { useAuth } from '@/auth/auth-context'; +import { rememberDestination } from '@/auth/pending-destination'; +import { useTeams } from '@/team/team-context'; +import { Loading } from '@/ui/primitives'; + +/** + * `/ENG` — web URL parity for a team. + * + * The web makes every board its own address (`/:teamKey`, + * `frontend/src/app/App.tsx:72`). A tab bar cannot: Board is one destination + * whose subject changes. So this route is a redirect rather than a screen — it + * sets the active team and hands over to the Board tab, which keeps links from + * the web working without the shell growing a second shape. + * + * Lowercase tab names (`/board`, `/search`) can never be shadowed by this, + * because team keys are uppercased server-side + * (`backend/lib_softtrack/teams.py:69`) and expo-router matches static segments + * case-sensitively. + */ +export function TeamRoute() { + const { teamKey = '' } = useLocalSearchParams<{ teamKey: string }>(); + const { isAuthenticated, status } = useAuth(); + const { teams, setTeamKey, isPending } = useTeams(); + + useEffect(() => { + if (!isAuthenticated) { + // Come back here once there is a session to resolve the key against. + if (status === 'signedOut') rememberDestination(`/${teamKey}`); + return; + } + if (isPending) return; + + const match = teams.find((team) => team.key.toLowerCase() === teamKey.toLowerCase()); + if (match) setTeamKey(match.key); + // An unknown key falls through to Home rather than a dead end -- you may + // have left the team, or the link may be for an instance you are not on. + router.replace(match ? '/board' : '/'); + }, [isAuthenticated, status, isPending, teams, teamKey, setTeamKey]); + + return ; +} diff --git a/src/team/team-switcher.tsx b/src/team/team-switcher.tsx new file mode 100644 index 0000000..e493755 --- /dev/null +++ b/src/team/team-switcher.tsx @@ -0,0 +1,101 @@ +import { Pressable, View } from 'react-native'; + +import { useTeams } from '@/team/team-context'; +import { Icon } from '@/ui/icon'; +import { AppText, TeamBadge } from '@/ui/primitives'; +import { Sheet } from '@/ui/sheet'; +import { useTokens } from '@/ui/theme'; + +/** + * Switch which team the team-scoped destinations are showing. + * + * A bottom sheet on a phone and a side sheet on anything wider, per + * `docs/design/mobile/141-teams.svg`, with a checkmark on the active team. + * The web does this with a `