From fc39431008dbbb6c0338f10a659ca5eb0776dd12 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Fri, 3 Jul 2026 10:08:21 +0800 Subject: [PATCH] feat(issues): require a team on issue creation via header team picker Replace the workspace-name breadcrumb in both create modals with a required single-select TeamPicker (icon + key pill). The picker always resolves to a value (parent team for sub-issues, else explicit pick, else default team), converges instead of clearing on project change, and locks for sub-issues since the server enforces inheritance. Teams render as icon + key everywhere in pickers, with a blue default icon until custom icons/colors land. Co-Authored-By: Claude Fable 5 --- packages/views/locales/en/teams.json | 1 - packages/views/locales/ja/teams.json | 1 - packages/views/locales/ko/teams.json | 1 - packages/views/locales/zh-Hans/teams.json | 1 - packages/views/modals/create-issue.tsx | 54 ++++++++++------ packages/views/modals/quick-create-issue.tsx | 62 +++++++++++++++---- packages/views/teams/components/index.ts | 1 + packages/views/teams/components/team-icon.tsx | 24 +++++++ .../views/teams/components/team-picker.tsx | 57 +++++++++-------- packages/views/teams/index.ts | 2 +- 10 files changed, 141 insertions(+), 63 deletions(-) create mode 100644 packages/views/teams/components/team-icon.tsx diff --git a/packages/views/locales/en/teams.json b/packages/views/locales/en/teams.json index 992aad44e..837edf8bb 100644 --- a/packages/views/locales/en/teams.json +++ b/packages/views/locales/en/teams.json @@ -42,7 +42,6 @@ }, "picker": { "placeholder": "Team", - "clear": "Clear team", "empty": "No active teams", "selected_count_one": "{{count}} team", "selected_count_other": "{{count}} teams" diff --git a/packages/views/locales/ja/teams.json b/packages/views/locales/ja/teams.json index 879999ab0..88b7e092f 100644 --- a/packages/views/locales/ja/teams.json +++ b/packages/views/locales/ja/teams.json @@ -42,7 +42,6 @@ }, "picker": { "placeholder": "チーム", - "clear": "チームをクリア", "empty": "アクティブなチームはありません", "selected_count_other": "{{count}} チーム" }, diff --git a/packages/views/locales/ko/teams.json b/packages/views/locales/ko/teams.json index 857af9779..24efc5b42 100644 --- a/packages/views/locales/ko/teams.json +++ b/packages/views/locales/ko/teams.json @@ -42,7 +42,6 @@ }, "picker": { "placeholder": "팀", - "clear": "팀 지우기", "empty": "활성 팀이 없습니다", "selected_count_other": "{{count}}개 팀" }, diff --git a/packages/views/locales/zh-Hans/teams.json b/packages/views/locales/zh-Hans/teams.json index 719c4a0df..836843c22 100644 --- a/packages/views/locales/zh-Hans/teams.json +++ b/packages/views/locales/zh-Hans/teams.json @@ -42,7 +42,6 @@ }, "picker": { "placeholder": "Team", - "clear": "清除 Team", "empty": "没有启用的 Team", "selected_count_other": "已选 {{count}} 个 Team" }, diff --git a/packages/views/modals/create-issue.tsx b/packages/views/modals/create-issue.tsx index 2418feb30..fa1f754ee 100644 --- a/packages/views/modals/create-issue.tsx +++ b/packages/views/modals/create-issue.tsx @@ -41,13 +41,14 @@ import { ProjectPicker } from "../projects/components/project-picker"; import { TeamPicker } from "../teams/components/team-picker"; import { useIssueTriggerPreview } from "../issues/hooks/use-issue-trigger-preview"; import { useActorName } from "@multica/core/workspace/hooks"; -import { useCurrentWorkspace, useWorkspacePaths } from "@multica/core/paths"; +import { useWorkspacePaths } from "@multica/core/paths"; import { useWorkspaceId } from "@multica/core/hooks"; import { useIssueDraftStore } from "@multica/core/issues/stores/draft-store"; import { useCreateModeStore } from "@multica/core/issues/stores/create-mode-store"; import { useQuickCreateStore } from "@multica/core/issues/stores/quick-create-store"; import { issueDetailOptions, childIssuesOptions } from "@multica/core/issues/queries"; import { projectListOptions } from "@multica/core/projects/queries"; +import { activeTeamListOptions } from "@multica/core/teams/queries"; import { useCreateIssue, useUpdateIssue } from "@multica/core/issues/mutations"; import { useFileUpload } from "@multica/core/hooks/use-file-upload"; import { @@ -192,7 +193,6 @@ export function ManualCreatePanel({ const { t } = useT("modals"); const router = useNavigation(); const p = useWorkspacePaths(); - const workspaceName = useCurrentWorkspace()?.name; const draft = useIssueDraftStore((s) => s.draft); const setDraft = useIssueDraftStore((s) => s.setDraft); @@ -272,17 +272,31 @@ export function ManualCreatePanel({ // them. When a project is selected, constrain the team picker to its teams; // no project selected leaves the picker unconstrained. const projectTeamIds = projectId ? selectedProject?.team_ids : undefined; + + // Every issue belongs to exactly one team, so the picker always resolves to + // a value: explicit pick → workspace default team. Sub-issues are pinned to + // the parent's team (the server enforces inheritance), so the picker locks. + const { data: teams = [] } = useQuery(activeTeamListOptions(wsId)); + const defaultTeamId = useMemo( + () => (teams.find((team) => team.is_default) ?? teams[0])?.id, + [teams], + ); + const parentTeamId = parentIssueId ? parentIssue?.team_id ?? undefined : undefined; + const effectiveTeamId = parentTeamId ?? teamId ?? defaultTeamId; + useEffect(() => { - if (!projectId) return; + if (!projectId || parentTeamId) return; const allowed = selectedProject?.team_ids ?? []; - // Exactly one team: auto-select it. Otherwise drop a stale pick that the - // newly-chosen project doesn't include. + if (allowed.length === 0) return; + // Converge instead of clearing: the issue must keep a team, so a pick the + // newly-chosen project doesn't include snaps to that project's own team. if (allowed.length === 1) { - setTeamId(allowed[0]); + if (teamId !== allowed[0]) setTeamId(allowed[0]); return; } - if (teamId && !allowed.includes(teamId)) setTeamId(undefined); - }, [projectId, selectedProject, teamId]); + const current = teamId ?? defaultTeamId; + if (current && !allowed.includes(current)) setTeamId(allowed[0]); + }, [projectId, selectedProject, teamId, defaultTeamId, parentTeamId]); const draftAttachments = draft.attachments ?? []; @@ -374,7 +388,7 @@ export function ManualCreatePanel({ due_date: dueDate || undefined, attachment_ids: activeAttachmentIds.length > 0 ? activeAttachmentIds : undefined, parent_issue_id: parentIssueId, - team_id: teamId, + team_id: effectiveTeamId, // Stage is only meaningful for a sub-issue (relative to its siblings). stage: parentIssueId && stage != null ? stage : undefined, project_id: projectId, @@ -546,7 +560,7 @@ export function ManualCreatePanel({ ? { squad_id: assigneeId } : {}), ...(projectId ? { project_id: projectId } : {}), - ...(teamId ? { team_id: teamId } : {}), + ...(effectiveTeamId ? { team_id: effectiveTeamId } : {}), ...(parentIssueId ? { parent_issue_id: parentIssueId } : {}), ...(carryParentIdentifier ? { parent_issue_identifier: carryParentIdentifier } : {}), }); @@ -559,7 +573,16 @@ export function ManualCreatePanel({ {/* Header */}
- {workspaceName} + {/* The issue's team namespace — leads the breadcrumb like the + workspace name used to, but as a required single-select. */} + } + align="start" + /> {t(($) => $.create_issue.manual_breadcrumb)}
@@ -668,15 +691,6 @@ export function ManualCreatePanel({ align="start" /> - {/* Team */} - setTeamId(next ?? undefined)} - triggerRender={} - align="start" - allowedTeamIds={projectTeamIds} - /> - {/* Project */} void; }) { const { t } = useT("modals"); - const workspaceName = useCurrentWorkspace()?.name; const wsId = useWorkspaceId(); const userId = useAuthStore((s) => s.user?.id); const { data: members = [] } = useQuery(memberListOptions(wsId)); @@ -218,6 +218,41 @@ export function AgentCreatePanel({ const parentIssueIdentifier = (data?.parent_issue_identifier as string | undefined) ?? undefined; + // Every issue belongs to exactly one team, so the header picker always + // resolves to a value: explicit pick → remembered pick → workspace default + // team. Sub-issues are pinned to the parent's team (the server enforces + // inheritance), so the picker locks. + const { data: teams = [] } = useQuery(activeTeamListOptions(wsId)); + const defaultTeamId = useMemo( + () => (teams.find((team) => team.is_default) ?? teams[0])?.id, + [teams], + ); + const { data: parentIssue } = useQuery({ + ...issueDetailOptions(wsId, parentIssueId ?? ""), + enabled: !!parentIssueId, + }); + const parentTeamId = parentIssueId ? parentIssue?.team_id ?? undefined : undefined; + const effectiveTeamId = parentTeamId ?? teamId ?? defaultTeamId ?? null; + + const selectedProject = useMemo( + () => projects.find((p) => p.id === projectId) ?? null, + [projects, projectId], + ); + const projectTeamIds = projectId ? selectedProject?.team_ids : undefined; + useEffect(() => { + if (!projectId || parentTeamId) return; + const allowed = selectedProject?.team_ids ?? []; + if (allowed.length === 0) return; + // Converge instead of clearing: the issue must keep a team, so a pick the + // newly-chosen project doesn't include snaps to that project's own team. + if (allowed.length === 1) { + if (teamId !== allowed[0]) setTeamId(allowed[0]); + return; + } + const current = teamId ?? defaultTeamId; + if (current && !allowed.includes(current)) setTeamId(allowed[0]); + }, [projectId, selectedProject, teamId, defaultTeamId, parentTeamId]); + // Stale-id sweep. Once the project list query has actually resolved // (`isSuccess` — distinct from "data is the empty default during loading"), // a `projectId` that isn't in the list means the project was deleted in @@ -313,13 +348,13 @@ export function AgentCreatePanel({ ? { agent_id: actor.id } : { squad_id: actor.id }), prompt: md, - team_id: teamId ?? undefined, + team_id: effectiveTeamId ?? undefined, project_id: projectId ?? undefined, parent_issue_id: parentIssueId, ...(activeAttachmentIds.length > 0 ? { attachment_ids: activeAttachmentIds } : {}), }); setLastActor(actor.type, actor.id); - setLastTeamId(teamId); + setLastTeamId(effectiveTeamId); setLastProjectId(projectId); clearPrompt(); setLastMode("agent"); @@ -405,7 +440,7 @@ export function AgentCreatePanel({ // through. const carry: Record = {}; if (projectId) carry.project_id = projectId; - if (teamId) carry.team_id = teamId; + if (effectiveTeamId) carry.team_id = effectiveTeamId; if (parentIssueId) carry.parent_issue_id = parentIssueId; if (parentIssueIdentifier) carry.parent_issue_identifier = parentIssueIdentifier; onSwitchMode?.(Object.keys(carry).length > 0 ? carry : null); @@ -418,7 +453,16 @@ export function AgentCreatePanel({ {/* Header */}
- {workspaceName} + {/* The issue's team namespace — leads the breadcrumb like the + workspace name used to, but as a required single-select. */} + } + align="start" + /> {t(($) => $.create_issue.agent_breadcrumb)}
@@ -529,12 +573,6 @@ export function AgentCreatePanel({ triggerRender={} align="start" /> - } - align="start" - /> {parentIssueId && ( ; className?: string }) { + return ( + + {team.icon ? team.icon : } + + ); +} diff --git a/packages/views/teams/components/team-picker.tsx b/packages/views/teams/components/team-picker.tsx index 5583675ee..83346e1f6 100644 --- a/packages/views/teams/components/team-picker.tsx +++ b/packages/views/teams/components/team-picker.tsx @@ -1,7 +1,7 @@ "use client"; import type { ReactElement } from "react"; -import { Check, Users, X } from "lucide-react"; +import { Check, Users } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; import { activeTeamListOptions } from "@multica/core/teams/queries"; import { useWorkspaceId } from "@multica/core/hooks"; @@ -10,36 +10,44 @@ import { DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, - DropdownMenuSeparator, DropdownMenuTrigger, } from "@multica/ui/components/ui/dropdown-menu"; import type { Team } from "@multica/core/types"; +import { TeamIcon } from "./team-icon"; import { useT } from "../../i18n"; -function TeamKey({ team }: { team: Team }) { +// icon + key: how a team is identified everywhere (picker rows, triggers). +function TeamBadge({ team }: { team: Team }) { return ( - - {team.key} + + + + {team.key} + ); } +// Single-select and non-clearable by design: every issue belongs to exactly +// one team, so the picker never offers an empty state — `teamId` is only +// null while the team list is still loading. export function TeamPicker({ teamId, onChange, triggerRender, align = "start", - allowClear = false, allowedTeamIds, + disabled = false, }: { teamId: string | null; - onChange: (teamId: string | null) => void; + onChange: (teamId: string) => void; triggerRender?: ReactElement; align?: "start" | "center" | "end"; - allowClear?: boolean; // When set, restricts the offered teams to this id set (e.g. the selected // project's teams). Undefined means no constraint. allowedTeamIds?: string[]; + // Locked display (e.g. sub-issues inherit the parent's team server-side). + disabled?: boolean; }) { const { t } = useT("teams"); const wsId = useWorkspaceId(); @@ -47,46 +55,43 @@ export function TeamPicker({ const teams = allowedTeamIds ? allTeams.filter((team) => allowedTeamIds.includes(team.id)) : allTeams; - const current = teams.find((team) => team.id === teamId); + // Resolve the display label against the unfiltered list so a selection + // outside `allowedTeamIds` (e.g. before the caller converges it) still + // renders instead of flashing the placeholder. + const current = allTeams.find((team) => team.id === teamId); return ( + {/* Trigger shows icon + key only — the key IS the team's compact + identity; the full name lives in the menu items. */} {current ? ( - + ) : ( - + <> + + {t(($) => $.picker.placeholder)} + )} - - {current ? current.name : t(($) => $.picker.placeholder)} - {teams.map((team) => ( onChange(team.id)}> - + {team.name} {team.id === teamId && ( )} ))} - {allowClear && teamId && ( - <> - - onChange(null)}> - - {t(($) => $.picker.clear)} - - - )} {teams.length === 0 && (
{t(($) => $.picker.empty)} @@ -147,7 +152,7 @@ export function TeamMultiPicker({ checked={checked} onCheckedChange={() => toggle(team.id)} > - + {team.name} ); diff --git a/packages/views/teams/index.ts b/packages/views/teams/index.ts index 4f58279ea..4b1c9f445 100644 --- a/packages/views/teams/index.ts +++ b/packages/views/teams/index.ts @@ -1 +1 @@ -export { TeamsPage, TeamPicker, TeamMultiPicker } from "./components"; +export { TeamsPage, TeamPicker, TeamMultiPicker, TeamIcon } from "./components";