Phone app: a new work item offers its own workspace's repositories
New work item listed the signed-in workspace's repositories, cached without the workspace, so a link into another workspace offered the wrong ones. It reads that workspace's projects, and ticks the repository it is filed from as a target, as the site does. Work's New, on a list filtered to a repository, files the item from it.
- G1T-Session:
- ses_01m56vafhge7ttrgs5288dpxvb
5 files+91−130/5 viewed
| 229 | 229 | <Pressable | |
| 230 | 230 | accessibilityRole="button" | |
| 231 | 231 | accessibilityLabel="New work item" | |
| 232 | − | onPress={() => router.push({ pathname: "/work/new", params: { workspace: slug } })} | |
| 232 | + | // Filtered to a repository, a new item is filed from it. | |
| 233 | + | onPress={() => router.push({ pathname: "/work/new", params: { workspace: slug, ...(filter.repo ? { repo: filter.repo } : {}) } })} | |
| 233 | 234 | style={({ pressed }) => [styles.fab, { backgroundColor: colors.accent, opacity: pressed ? 0.85 : 1 }]} | |
| 234 | 235 | > | |
| 235 | 236 | <Plus size={20} color={colors.onAccent} strokeWidth={2.5} /> |
| 6 | 6 | ||
| 7 | 7 | import { Button, Text } from "@/components/ui"; | |
| 8 | 8 | import { Chip } from "@/components/work"; | |
| 9 | − | import { useRepos } from "@/lib/code"; | |
| 9 | + | import { useWorkspaceRepos } from "@/lib/code"; | |
| 10 | + | import { originOf } from "@/lib/new-item"; | |
| 10 | 11 | import { useSignedIn } from "@/lib/session"; | |
| 11 | 12 | import { useLabels, useOpenWorkItem } from "@/lib/work"; | |
| 12 | 13 | import { radius, space, useColors } from "@/theme"; | |
| ⋯ | |||
| 14 | 15 | /** | |
| 15 | 16 | * A new work item: a title, what it is about, its labels, and the | |
| 16 | 17 | * repositories where it is meant to land (none is fine: it can be linked | |
| 17 | − | * later). Opened from a repository's Work, that repository is its origin. | |
| 18 | + | * later): its own workspace's, whichever the app is in. Opened from a | |
| 19 | + | * repository (`repo`), that repository is its origin, and a target to | |
| 20 | + | * start with. | |
| 18 | 21 | * Made for the on-screen keyboard: the title's return goes on to the | |
| 19 | 22 | * description, and Create stays in the header above the keyboard. | |
| 20 | 23 | */ | |
| ⋯ | |||
| 22 | 25 | const params = useLocalSearchParams<{ workspace?: string; repo?: string }>(); | |
| 23 | 26 | const { workspace: current } = useSignedIn(); | |
| 24 | 27 | const workspace = params.workspace ?? current?.slug ?? ""; | |
| 25 | − | const origin = params.repo ? `${workspace}/${params.repo}` : undefined; | |
| 28 | + | const origin = originOf(workspace, params.repo) ?? undefined; | |
| 26 | 29 | const colors = useColors(); | |
| 27 | 30 | const headerHeight = useHeaderHeight(); | |
| 28 | 31 | const [title, setTitle] = useState(""); | |
| 29 | 32 | const [body, setBody] = useState(""); | |
| 30 | 33 | const [labels, setLabels] = useState<string[]>([]); | |
| 31 | − | const [targets, setTargets] = useState<string[]>([]); | |
| 34 | + | // Filed from a repository, it starts as a target too, as on the site; untick it to leave it the origin only. | |
| 35 | + | const [targets, setTargets] = useState<string[]>(origin ? [origin] : []); | |
| 32 | 36 | const bodyField = useRef<TextInput>(null); | |
| 33 | 37 | const known = useLabels(workspace); | |
| 34 | − | const repos = useRepos(""); | |
| 38 | + | const repos = useWorkspaceRepos(workspace); | |
| 35 | 39 | const open = useOpenWorkItem(workspace); | |
| 36 | 40 | const ready = title.trim().length > 0 && !open.isPending; | |
| 37 | 41 | ||
| ⋯ | |||
| 60 | 64 | <ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled" keyboardDismissMode="interactive"> | |
| 61 | 65 | {origin ? ( | |
| 62 | 66 | <Text size="small" tone="muted"> | |
| 63 | − | Filed from {params.repo}, in {workspace} | |
| 67 | + | Filed from {origin} | |
| 64 | 68 | </Text> | |
| 65 | 69 | ) : ( | |
| 66 | 70 | <Text size="small" tone="muted"> | |
| ⋯ | |||
| 106 | 110 | hint="The repositories the work is meant to land in. It closes once it has landed in each. Leave them out to pick later." | |
| 107 | 111 | empty={repos.isPending ? "…" : "No repositories you can see."} | |
| 108 | 112 | > | |
| 109 | − | {(repos.data ?? []).map((repo) => { | |
| 110 | − | const path = `${repo.namespace}/${repo.name}`; | |
| 111 | − | const on = targets.includes(path); | |
| 113 | + | {(repos.data ?? []).map((path) => { | |
| 114 | + | const on = targets.some((one) => one.toLowerCase() === path.toLowerCase()); | |
| 112 | 115 | return ( | |
| 113 | 116 | <Chip | |
| 114 | − | key={repo.id} | |
| 115 | − | label={repo.name} | |
| 117 | + | key={path} | |
| 118 | + | label={path.split("/")[1] ?? path} | |
| 116 | 119 | tone={on ? "accent" : "neutral"} | |
| 117 | 120 | leading={on ? <Check size={12} color={colors.accent} /> : null} | |
| 118 | − | onPress={() => setTargets(toggle(targets, path))} | |
| 121 | + | onPress={() => setTargets(on ? targets.filter((one) => one.toLowerCase() !== path.toLowerCase()) : [...targets, path])} | |
| 119 | 122 | /> | |
| 120 | 123 | ); | |
| 121 | 124 | })} | |
| 1 | 1 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 2 | 2 | ||
| 3 | + | import { workspaceRepos } from "./new-item"; | |
| 3 | 4 | import { useSignedIn } from "./session"; | |
| 4 | 5 | import type { ApiWorkItem } from "./work"; | |
| 5 | 6 | ||
| ⋯ | |||
| 90 | 91 | type ApiProject = { repository: string | null; primary: boolean; description: string | null }; | |
| 91 | 92 | ||
| 92 | 93 | /** | |
| 94 | + | * The repositories of `workspace`'s own projects, `owner/name`: where a | |
| 95 | + | * new work item of it may land, whichever workspace the app is in. | |
| 96 | + | */ | |
| 97 | + | export function useWorkspaceRepos(workspace: string | null | undefined) { | |
| 98 | + | const { client } = useSignedIn(); | |
| 99 | + | return useQuery({ | |
| 100 | + | queryKey: ["code", "workspace-repos", workspace?.toLowerCase()], | |
| 101 | + | enabled: Boolean(workspace), | |
| 102 | + | queryFn: () => client.api<ApiProject[]>(`/workspaces/${encodeURIComponent(workspace!)}/projects`), | |
| 103 | + | select: (projects) => workspaceRepos(workspace!, projects), | |
| 104 | + | }); | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | /** | |
| 93 | 108 | * The descriptions of a workspace's repositories, by lowercase | |
| 94 | 109 | * `owner/name`. A repository has none of its own: its project's is shown. | |
| 95 | 110 | */ | |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { originOf, workspaceRepos } from "./new-item.ts"; | |
| 5 | + | ||
| 6 | + | test("a new work item may land in its own workspace's repositories, each once", () => { | |
| 7 | + | const projects = [ | |
| 8 | + | { repository: "acme/web" }, | |
| 9 | + | { repository: null }, | |
| 10 | + | { repository: "Acme/Web" }, | |
| 11 | + | { repository: "acme/api" }, | |
| 12 | + | // Shared with someone from another workspace: not this one's to land in. | |
| 13 | + | { repository: "other/tool" }, | |
| 14 | + | ]; | |
| 15 | + | assert.deepEqual(workspaceRepos("acme", projects), ["acme/web", "acme/api"]); | |
| 16 | + | assert.deepEqual(workspaceRepos("beta", projects), []); | |
| 17 | + | }); | |
| 18 | + | ||
| 19 | + | test("a new work item is filed from a repository of its workspace, by name or path", () => { | |
| 20 | + | assert.equal(originOf("acme", "web"), "acme/web"); | |
| 21 | + | assert.equal(originOf("acme", "Acme/web"), "acme/web"); | |
| 22 | + | assert.equal(originOf("acme", "other/web"), null); | |
| 23 | + | assert.equal(originOf("acme", ""), null); | |
| 24 | + | assert.equal(originOf("acme", undefined), null); | |
| 25 | + | }); |
| 1 | + | /** | |
| 2 | + | * A new work item's choices, as the site's New work item has them | |
| 3 | + | * (apps/web/app/routes/workspace/work/new.tsx): the repositories of its | |
| 4 | + | * own workspace it may land in, and the one it is filed from, ticked as | |
| 5 | + | * a target to start with. | |
| 6 | + | * | |
| 7 | + | * Kept free of React Native imports so `npm test` runs it under Node. | |
| 8 | + | */ | |
| 9 | + | ||
| 10 | + | /** A workspace's project as the API answers: only its repository, `owner/name`, when it has one. */ | |
| 11 | + | export type ProjectRepo = { repository: string | null }; | |
| 12 | + | ||
| 13 | + | /** The repositories of a workspace's projects, `owner/name`, each once, in the order given. Another workspace's are left out. */ | |
| 14 | + | export function workspaceRepos(workspace: string, projects: readonly ProjectRepo[]): string[] { | |
| 15 | + | const own = workspace.toLowerCase(); | |
| 16 | + | const seen = new Map<string, string>(); | |
| 17 | + | for (const project of projects) { | |
| 18 | + | const path = project.repository; | |
| 19 | + | if (!path || path.split("/")[0]?.toLowerCase() !== own) continue; | |
| 20 | + | if (!seen.has(path.toLowerCase())) seen.set(path.toLowerCase(), path); | |
| 21 | + | } | |
| 22 | + | return [...seen.values()]; | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | /** | |
| 26 | + | * The repository a new item is filed from, `owner/name`, from a link's | |
| 27 | + | * `repo` (`web` or `acme/web`): only one of this workspace, else none. | |
| 28 | + | */ | |
| 29 | + | export function originOf(workspace: string, repo: string | null | undefined): string | null { | |
| 30 | + | const parts = (repo ?? "").split("/").filter(Boolean); | |
| 31 | + | if (parts.length === 1) return `${workspace}/${parts[0]}`; | |
| 32 | + | if (parts.length === 2 && parts[0]!.toLowerCase() === workspace.toLowerCase()) return `${workspace}/${parts[1]}`; | |
| 33 | + | return null; | |
| 34 | + | } |
Comments
Sign in to comment.