mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 05:19:30 +02:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -42,7 +42,6 @@
|
||||
},
|
||||
"picker": {
|
||||
"placeholder": "Team",
|
||||
"clear": "Clear team",
|
||||
"empty": "No active teams",
|
||||
"selected_count_one": "{{count}} team",
|
||||
"selected_count_other": "{{count}} teams"
|
||||
|
||||
@@ -42,7 +42,6 @@
|
||||
},
|
||||
"picker": {
|
||||
"placeholder": "チーム",
|
||||
"clear": "チームをクリア",
|
||||
"empty": "アクティブなチームはありません",
|
||||
"selected_count_other": "{{count}} チーム"
|
||||
},
|
||||
|
||||
@@ -42,7 +42,6 @@
|
||||
},
|
||||
"picker": {
|
||||
"placeholder": "팀",
|
||||
"clear": "팀 지우기",
|
||||
"empty": "활성 팀이 없습니다",
|
||||
"selected_count_other": "{{count}}개 팀"
|
||||
},
|
||||
|
||||
@@ -42,7 +42,6 @@
|
||||
},
|
||||
"picker": {
|
||||
"placeholder": "Team",
|
||||
"clear": "清除 Team",
|
||||
"empty": "没有启用的 Team",
|
||||
"selected_count_other": "已选 {{count}} 个 Team"
|
||||
},
|
||||
|
||||
@@ -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 */}
|
||||
<div className="flex items-center justify-between px-5 pt-3 pb-2 shrink-0">
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="text-muted-foreground">{workspaceName}</span>
|
||||
{/* The issue's team namespace — leads the breadcrumb like the
|
||||
workspace name used to, but as a required single-select. */}
|
||||
<TeamPicker
|
||||
teamId={effectiveTeamId ?? null}
|
||||
onChange={setTeamId}
|
||||
allowedTeamIds={projectTeamIds}
|
||||
disabled={!!parentTeamId}
|
||||
triggerRender={<PillButton />}
|
||||
align="start"
|
||||
/>
|
||||
<ChevronRight className="size-3 text-muted-foreground/50" />
|
||||
<span className="font-medium">{t(($) => $.create_issue.manual_breadcrumb)}</span>
|
||||
</div>
|
||||
@@ -668,15 +691,6 @@ export function ManualCreatePanel({
|
||||
align="start"
|
||||
/>
|
||||
|
||||
{/* Team */}
|
||||
<TeamPicker
|
||||
teamId={teamId ?? null}
|
||||
onChange={(next) => setTeamId(next ?? undefined)}
|
||||
triggerRender={<PillButton />}
|
||||
align="start"
|
||||
allowedTeamIds={projectTeamIds}
|
||||
/>
|
||||
|
||||
{/* Project */}
|
||||
<ProjectPicker
|
||||
projectId={projectId ?? null}
|
||||
|
||||
@@ -9,9 +9,10 @@ import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Switch } from "@multica/ui/components/ui/switch";
|
||||
import { api, ApiError } from "@multica/core/api";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { useCurrentWorkspace } from "@multica/core/paths";
|
||||
import { agentListOptions, squadListOptions } from "@multica/core/workspace/queries";
|
||||
import { projectListOptions } from "@multica/core/projects/queries";
|
||||
import { activeTeamListOptions } from "@multica/core/teams/queries";
|
||||
import { issueDetailOptions } from "@multica/core/issues/queries";
|
||||
import {
|
||||
useQuickCreateStore,
|
||||
type QuickCreateActorType,
|
||||
@@ -83,7 +84,6 @@ export function AgentCreatePanel({
|
||||
setIsExpanded: (v: boolean) => 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<string, unknown> = {};
|
||||
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 */}
|
||||
<div className="flex items-center justify-between px-5 pt-3 pb-2 shrink-0">
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="text-muted-foreground">{workspaceName}</span>
|
||||
{/* The issue's team namespace — leads the breadcrumb like the
|
||||
workspace name used to, but as a required single-select. */}
|
||||
<TeamPicker
|
||||
teamId={effectiveTeamId}
|
||||
onChange={setTeamId}
|
||||
allowedTeamIds={projectTeamIds}
|
||||
disabled={!!parentTeamId}
|
||||
triggerRender={<PillButton />}
|
||||
align="start"
|
||||
/>
|
||||
<ChevronRight className="size-3 text-muted-foreground/50" />
|
||||
<span className="font-medium">{t(($) => $.create_issue.agent_breadcrumb)}</span>
|
||||
</div>
|
||||
@@ -529,12 +573,6 @@ export function AgentCreatePanel({
|
||||
triggerRender={<PillButton />}
|
||||
align="start"
|
||||
/>
|
||||
<TeamPicker
|
||||
teamId={teamId}
|
||||
onChange={setTeamId}
|
||||
triggerRender={<PillButton />}
|
||||
align="start"
|
||||
/>
|
||||
{parentIssueId && (
|
||||
<span
|
||||
data-testid="agent-sub-issue-chip"
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { TeamsPage } from "./teams-page";
|
||||
export { TeamPicker, TeamMultiPicker } from "./team-picker";
|
||||
export { TeamIcon } from "./team-icon";
|
||||
|
||||
24
packages/views/teams/components/team-icon.tsx
Normal file
24
packages/views/teams/components/team-icon.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Users } from "lucide-react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import type { Team } from "@multica/core/types";
|
||||
|
||||
/**
|
||||
* Team avatar: renders the team's custom icon (emoji, set in the team
|
||||
* dialog) when present, otherwise a default glyph on the team color.
|
||||
* Custom icon upload / per-team colors are planned; until then every team
|
||||
* falls back to the same blue — keep the color here so the future
|
||||
* `team.color` field has a single place to land.
|
||||
*/
|
||||
export function TeamIcon({ team, className }: { team: Pick<Team, "icon">; className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-4 shrink-0 items-center justify-center rounded-sm text-white",
|
||||
team.icon ? "bg-transparent text-sm leading-none" : "bg-blue-500",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{team.icon ? team.icon : <Users className="size-3" />}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<span className="inline-flex h-5 min-w-7 items-center justify-center rounded bg-muted px-1.5 text-[10px] font-medium text-muted-foreground">
|
||||
{team.key}
|
||||
<span className="flex items-center gap-1.5 overflow-hidden">
|
||||
<TeamIcon team={team} />
|
||||
<span className="shrink-0 text-[10px] font-medium text-muted-foreground">
|
||||
{team.key}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
disabled={disabled}
|
||||
className={
|
||||
triggerRender
|
||||
? undefined
|
||||
: "flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors overflow-hidden"
|
||||
: "flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors overflow-hidden disabled:cursor-default disabled:hover:bg-transparent"
|
||||
}
|
||||
render={triggerRender}
|
||||
>
|
||||
{/* Trigger shows icon + key only — the key IS the team's compact
|
||||
identity; the full name lives in the menu items. */}
|
||||
{current ? (
|
||||
<TeamKey team={current} />
|
||||
<TeamBadge team={current} />
|
||||
) : (
|
||||
<Users className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<>
|
||||
<Users className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{t(($) => $.picker.placeholder)}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="truncate">
|
||||
{current ? current.name : t(($) => $.picker.placeholder)}
|
||||
</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={align} className="w-56">
|
||||
{teams.map((team) => (
|
||||
<DropdownMenuItem key={team.id} onClick={() => onChange(team.id)}>
|
||||
<TeamKey team={team} />
|
||||
<TeamBadge team={team} />
|
||||
<span className="truncate">{team.name}</span>
|
||||
{team.id === teamId && (
|
||||
<Check className="ml-auto h-3.5 w-3.5 shrink-0" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{allowClear && teamId && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onChange(null)}>
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{t(($) => $.picker.clear)}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{teams.length === 0 && (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
{t(($) => $.picker.empty)}
|
||||
@@ -147,7 +152,7 @@ export function TeamMultiPicker({
|
||||
checked={checked}
|
||||
onCheckedChange={() => toggle(team.id)}
|
||||
>
|
||||
<TeamKey team={team} />
|
||||
<TeamBadge team={team} />
|
||||
<span className="truncate">{team.name}</span>
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { TeamsPage, TeamPicker, TeamMultiPicker } from "./components";
|
||||
export { TeamsPage, TeamPicker, TeamMultiPicker, TeamIcon } from "./components";
|
||||
|
||||
Reference in New Issue
Block a user