mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 21:33:41 +02:00
feat(core): team surfaces data layer — scope plan, my-teams order, routes
Implements the team scope query plan (mirrors the project shape;
team_id is a real server filter) so team issue surfaces work end to
end. myTeamListOptions derives the sidebar's joined-teams view from the
shared list cache; useUpdateTeamMembership (fractional reorder) and
useReplaceTeamMembers (wholesale member set) drive the drag sort and
the member config panel. creationDefaultTeamId encodes the personal
default chain (my first team → workspace default → any). Adds
/team/:key/{issues,projects,autopilots,settings} paths, the /issue/:id
identifier-first detail path, the persisted sidebar collapse store, and
the create-team modal registration; quick-create drops its lastTeamId
memory in favor of the predictable order-first default.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -74,6 +74,8 @@ import type {
|
||||
SendChatMessageResponse,
|
||||
CancelTaskResponse,
|
||||
Team,
|
||||
TeamMembership,
|
||||
ListTeamMembersResponse,
|
||||
CreateTeamRequest,
|
||||
UpdateTeamRequest,
|
||||
ListTeamsResponse,
|
||||
@@ -187,7 +189,10 @@ import {
|
||||
EMPTY_LIST_AUTOPILOTS_RESPONSE,
|
||||
ListIssuesResponseSchema,
|
||||
ListTeamsResponseSchema,
|
||||
ListTeamMembersResponseSchema,
|
||||
EMPTY_LIST_TEAM_MEMBERS_RESPONSE,
|
||||
TeamSchema,
|
||||
TeamMembershipSchema,
|
||||
ListWebhookDeliveriesResponseSchema,
|
||||
RuntimeHourlyActivityListSchema,
|
||||
RuntimeUsageByAgentListSchema,
|
||||
@@ -1603,6 +1608,31 @@ export class ApiClient {
|
||||
return parseOrWarn(raw, TeamSchema, { endpoint: "PATCH /api/teams/:id" });
|
||||
}
|
||||
|
||||
async updateTeamMembership(id: string, data: { sort_order: number }): Promise<TeamMembership> {
|
||||
const raw = await this.fetch<unknown>(`/api/teams/${id}/membership`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return parseOrWarn(raw, TeamMembershipSchema, { endpoint: "PATCH /api/teams/:id/membership" });
|
||||
}
|
||||
|
||||
async replaceTeamMembers(id: string, memberIds: string[]): Promise<ListTeamMembersResponse> {
|
||||
const raw = await this.fetch<unknown>(`/api/teams/${id}/members`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ member_ids: memberIds }),
|
||||
});
|
||||
return parseWithFallback(raw, ListTeamMembersResponseSchema, EMPTY_LIST_TEAM_MEMBERS_RESPONSE, {
|
||||
endpoint: "PUT /api/teams/:id/members",
|
||||
});
|
||||
}
|
||||
|
||||
async listTeamMembers(id: string): Promise<ListTeamMembersResponse> {
|
||||
const raw = await this.fetch<unknown>(`/api/teams/${id}/members`);
|
||||
return parseWithFallback(raw, ListTeamMembersResponseSchema, EMPTY_LIST_TEAM_MEMBERS_RESPONSE, {
|
||||
endpoint: "GET /api/teams/:id/members",
|
||||
});
|
||||
}
|
||||
|
||||
async archiveTeam(id: string): Promise<Team> {
|
||||
const raw = await this.fetch<unknown>(`/api/teams/${id}`, { method: "DELETE" });
|
||||
return parseOrWarn(raw, TeamSchema, { endpoint: "DELETE /api/teams/:id" });
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
InboxWorkspaceUnread,
|
||||
ListIssuesResponse,
|
||||
ListTeamsResponse,
|
||||
ListTeamMembersResponse,
|
||||
ListWebhookDeliveriesResponse,
|
||||
SearchIssuesResponse,
|
||||
SearchProjectsResponse,
|
||||
@@ -324,8 +325,35 @@ export const TeamSchema = z.object({
|
||||
created_by: z.string().nullable().default(null),
|
||||
created_at: z.string().default(""),
|
||||
updated_at: z.string().default(""),
|
||||
is_member: z.boolean().default(false),
|
||||
sort_order: z.number().default(0),
|
||||
}).loose();
|
||||
|
||||
// PATCH /api/teams/{id}/membership — the caller's own sort position.
|
||||
export const TeamMembershipSchema = z.object({
|
||||
team_id: z.string().default(""),
|
||||
sort_order: z.number().default(0),
|
||||
}).loose();
|
||||
|
||||
export const TeamMemberSchema = z.object({
|
||||
user_id: z.string(),
|
||||
name: z.string().default(""),
|
||||
email: z.string().default(""),
|
||||
avatar_url: z.string().nullable().default(null),
|
||||
role: z.string().default("member"),
|
||||
created_at: z.string().default(""),
|
||||
}).loose();
|
||||
|
||||
export const ListTeamMembersResponseSchema = z.object({
|
||||
members: z.array(TeamMemberSchema).default([]),
|
||||
total: z.number().default(0),
|
||||
});
|
||||
|
||||
export const EMPTY_LIST_TEAM_MEMBERS_RESPONSE: ListTeamMembersResponse = {
|
||||
members: [],
|
||||
total: 0,
|
||||
};
|
||||
|
||||
export const ListTeamsResponseSchema = z.object({
|
||||
teams: z.array(TeamSchema).default([]),
|
||||
total: z.number().default(0),
|
||||
@@ -344,6 +372,8 @@ export const EMPTY_TEAM: Team = {
|
||||
created_by: null,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
is_member: false,
|
||||
sort_order: 0,
|
||||
};
|
||||
|
||||
export const EMPTY_LIST_TEAMS_RESPONSE: ListTeamsResponse = {
|
||||
|
||||
@@ -27,8 +27,6 @@ interface QuickCreateState {
|
||||
setLastActor: (type: QuickCreateActorType | null, id: string | null) => void;
|
||||
lastProjectId: string | null;
|
||||
setLastProjectId: (id: string | null) => void;
|
||||
lastTeamId: string | null;
|
||||
setLastTeamId: (id: string | null) => void;
|
||||
prompt: string;
|
||||
setPrompt: (prompt: string) => void;
|
||||
clearPrompt: () => void;
|
||||
@@ -44,8 +42,6 @@ export const useQuickCreateStore = create<QuickCreateState>()(
|
||||
setLastActor: (type, id) => set({ lastActorType: type, lastActorId: id }),
|
||||
lastProjectId: null,
|
||||
setLastProjectId: (id) => set({ lastProjectId: id }),
|
||||
lastTeamId: null,
|
||||
setLastTeamId: (id) => set({ lastTeamId: id }),
|
||||
prompt: "",
|
||||
setPrompt: (prompt) => set({ prompt }),
|
||||
clearPrompt: () => set({ prompt: "" }),
|
||||
|
||||
@@ -3,11 +3,7 @@ import type {
|
||||
AssigneeGroupedIssuesFilter,
|
||||
MyIssuesFilter,
|
||||
} from "../queries";
|
||||
import {
|
||||
issueScopeKey,
|
||||
UnsupportedIssueScopeError,
|
||||
type IssueScope,
|
||||
} from "./scope";
|
||||
import { issueScopeKey, type IssueScope } from "./scope";
|
||||
|
||||
export type IssueSurfaceQueryPlan =
|
||||
| {
|
||||
@@ -163,7 +159,22 @@ export function buildIssueSurfaceQueryPlan(
|
||||
: {},
|
||||
};
|
||||
}
|
||||
case "team":
|
||||
throw new UnsupportedIssueScopeError(scope, "issue surface query plan");
|
||||
case "team": {
|
||||
// Mirrors the project plan: team_id is a real server filter on both
|
||||
// the list and grouped endpoints, so the team surface gets its own
|
||||
// cache entry, and issues created from it default into the team.
|
||||
const queryFilter = { team_id: scope.teamId };
|
||||
return {
|
||||
kind: "scoped",
|
||||
scopeKey,
|
||||
queryScope: scopeKey,
|
||||
queryFilter,
|
||||
groupedScopeFilter: queryFilter,
|
||||
loadMoreScope: scopeKey,
|
||||
loadMoreFilter: queryFilter,
|
||||
userId: undefined,
|
||||
createDefaults: { team_id: scope.teamId },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
UnsupportedIssueScopeError,
|
||||
issueScopeKey,
|
||||
} from "./scope";
|
||||
import { issueScopeKey } from "./scope";
|
||||
import { buildIssueSurfaceQueryPlan } from "./query-plan";
|
||||
|
||||
describe("issue surface scope", () => {
|
||||
@@ -145,10 +142,16 @@ describe("issue surface scope", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("throws for team until the issue API has a team filter", () => {
|
||||
const scope = { type: "team" as const, teamId: "t1" };
|
||||
expect(() => buildIssueSurfaceQueryPlan(scope)).toThrow(
|
||||
UnsupportedIssueScopeError,
|
||||
);
|
||||
it("builds the team query plan mirroring the project shape", () => {
|
||||
expect(
|
||||
buildIssueSurfaceQueryPlan({ type: "team", teamId: "t1" }),
|
||||
).toMatchObject({
|
||||
kind: "scoped",
|
||||
scopeKey: "team:t1",
|
||||
queryScope: "team:t1",
|
||||
queryFilter: { team_id: "t1" },
|
||||
groupedScopeFilter: { team_id: "t1" },
|
||||
createDefaults: { team_id: "t1" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
32
packages/core/layout/sidebar-store.ts
Normal file
32
packages/core/layout/sidebar-store.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../platform/workspace-storage";
|
||||
import { defaultStorage } from "../platform/storage";
|
||||
|
||||
// Sidebar group collapse state, persisted per workspace (same
|
||||
// workspace-aware storage the quick-create prefs use). Keys are group ids:
|
||||
// "pinned", "workspace", "more", "teams", and `team:{teamId}` for each team
|
||||
// group. Absent key = expanded — groups default open so a fresh workspace
|
||||
// shows its whole structure.
|
||||
interface SidebarState {
|
||||
collapsed: Record<string, boolean>;
|
||||
setGroupCollapsed: (key: string, collapsed: boolean) => void;
|
||||
}
|
||||
|
||||
export const useSidebarStore = create<SidebarState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
collapsed: {},
|
||||
setGroupCollapsed: (key, collapsed) =>
|
||||
set((s) => ({ collapsed: { ...s.collapsed, [key]: collapsed } })),
|
||||
}),
|
||||
{
|
||||
name: "multica_sidebar",
|
||||
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
registerForWorkspaceRehydration(() => useSidebarStore.persist.rehydrate());
|
||||
@@ -8,6 +8,7 @@ type ModalType =
|
||||
| "quick-create-issue"
|
||||
| "create-project"
|
||||
| "create-squad"
|
||||
| "create-team"
|
||||
| "feedback"
|
||||
| "issue-set-parent"
|
||||
| "issue-add-child"
|
||||
|
||||
@@ -73,6 +73,8 @@
|
||||
"./teams": "./teams/index.ts",
|
||||
"./teams/queries": "./teams/queries.ts",
|
||||
"./teams/mutations": "./teams/mutations.ts",
|
||||
"./teams/default-team": "./teams/default-team.ts",
|
||||
"./layout/sidebar-store": "./layout/sidebar-store.ts",
|
||||
"./labels": "./labels/index.ts",
|
||||
"./labels/queries": "./labels/queries.ts",
|
||||
"./labels/mutations": "./labels/mutations.ts",
|
||||
|
||||
@@ -20,8 +20,19 @@ function workspaceScoped(slug: string) {
|
||||
root: () => `${ws}/issues`,
|
||||
usage: () => `${ws}/usage`,
|
||||
issues: () => `${ws}/issues`,
|
||||
issueDetail: (id: string) => `${ws}/issues/${encode(id)}`,
|
||||
// Issue detail is identifier-first (Linear-style /issue/NAI-3): the team
|
||||
// rides in the identifier, never in a nested path segment, so moving an
|
||||
// issue between teams can't orphan the URL (old identifiers keep
|
||||
// resolving via the server-side alias). The same route accepts a UUID —
|
||||
// internal navigation passes ids, shared links pass identifiers.
|
||||
issueDetail: (idOrIdentifier: string) => `${ws}/issue/${encode(idOrIdentifier)}`,
|
||||
teams: () => `${ws}/teams`,
|
||||
// Team-scoped surfaces, addressed by team key (readable, stable: keys
|
||||
// freeze once a team has issues) — /team/ENG/issues, Linear-style.
|
||||
teamIssues: (key: string) => `${ws}/team/${encode(key)}/issues`,
|
||||
teamProjects: (key: string) => `${ws}/team/${encode(key)}/projects`,
|
||||
teamAutopilots: (key: string) => `${ws}/team/${encode(key)}/autopilots`,
|
||||
teamSettings: (key: string) => `${ws}/team/${encode(key)}/settings`,
|
||||
projects: () => `${ws}/projects`,
|
||||
projectDetail: (id: string) => `${ws}/projects/${encode(id)}`,
|
||||
autopilots: () => `${ws}/autopilots`,
|
||||
|
||||
@@ -90,6 +90,7 @@ export const RESERVED_SLUGS: ReadonlySet<string> = new Set([
|
||||
"settings",
|
||||
"workspaces",
|
||||
"teams",
|
||||
"team",
|
||||
|
||||
// API / integration prefixes
|
||||
// `api` above already covers `/api/*`; these guard against future top-level
|
||||
|
||||
15
packages/core/teams/default-team.ts
Normal file
15
packages/core/teams/default-team.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { Team } from "../types";
|
||||
|
||||
/**
|
||||
* The team a new issue defaults into when no other context (route, parent,
|
||||
* single-team project) applies: the first team in the user's personal order.
|
||||
* Falls back to the workspace default team for users whose membership rows
|
||||
* predate the membership rollout, then to any active team.
|
||||
*/
|
||||
export function creationDefaultTeamId(teams: Team[]): string | undefined {
|
||||
const active = teams.filter((team) => !team.archived_at);
|
||||
const mine = active
|
||||
.filter((team) => team.is_member)
|
||||
.sort((a, b) => a.sort_order - b.sort_order);
|
||||
return (mine[0] ?? active.find((team) => team.is_default) ?? active[0])?.id;
|
||||
}
|
||||
@@ -1,2 +1,15 @@
|
||||
export { teamKeys, teamListOptions, activeTeamListOptions } from "./queries";
|
||||
export { useCreateTeam, useUpdateTeam, useArchiveTeam } from "./mutations";
|
||||
export {
|
||||
teamKeys,
|
||||
teamListOptions,
|
||||
activeTeamListOptions,
|
||||
myTeamListOptions,
|
||||
teamMembersOptions,
|
||||
} from "./queries";
|
||||
export {
|
||||
useCreateTeam,
|
||||
useUpdateTeam,
|
||||
useArchiveTeam,
|
||||
useUpdateTeamMembership,
|
||||
useReplaceTeamMembers,
|
||||
} from "./mutations";
|
||||
export { creationDefaultTeamId } from "./default-team";
|
||||
|
||||
@@ -54,6 +54,53 @@ export function useUpdateTeam() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateTeamMembership() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, sort_order }: { id: string; sort_order: number }) =>
|
||||
api.updateTeamMembership(id, { sort_order }),
|
||||
// Optimistic: sidebar ordering must not snap back while the PATCH is in
|
||||
// flight. Fractional sort keys mean only the dragged team's row changes.
|
||||
onMutate: async ({ id, sort_order }) => {
|
||||
await qc.cancelQueries({ queryKey: teamKeys.list(wsId) });
|
||||
const prevList = qc.getQueryData<ListTeamsResponse>(teamKeys.list(wsId));
|
||||
qc.setQueryData<ListTeamsResponse>(teamKeys.list(wsId), (old) =>
|
||||
old
|
||||
? {
|
||||
...old,
|
||||
teams: old.teams.map((t) => (t.id === id ? { ...t, sort_order } : t)),
|
||||
}
|
||||
: old,
|
||||
);
|
||||
return { prevList };
|
||||
},
|
||||
onError: (_err, _vars, ctx) => {
|
||||
if (ctx?.prevList) qc.setQueryData(teamKeys.list(wsId), ctx.prevList);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: teamKeys.all(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useReplaceTeamMembers() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, member_ids }: { id: string; member_ids: string[] }) =>
|
||||
api.replaceTeamMembers(id, member_ids),
|
||||
// No optimistic patch: the config panel closes on save, and the
|
||||
// caller's own is_member may flip (they can add/remove themselves), so
|
||||
// one settled invalidation of the list + members caches is the simplest
|
||||
// correct reconcile.
|
||||
onSettled: (_data, _err, { id }) => {
|
||||
qc.invalidateQueries({ queryKey: teamKeys.all(wsId) });
|
||||
qc.invalidateQueries({ queryKey: teamKeys.members(wsId, id) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useArchiveTeam() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
|
||||
@@ -4,6 +4,8 @@ import { api } from "../api";
|
||||
export const teamKeys = {
|
||||
all: (wsId: string) => ["teams", wsId] as const,
|
||||
list: (wsId: string) => [...teamKeys.all(wsId), "list"] as const,
|
||||
members: (wsId: string, teamId: string) =>
|
||||
[...teamKeys.all(wsId), "members", teamId] as const,
|
||||
};
|
||||
|
||||
export function teamListOptions(wsId: string) {
|
||||
@@ -24,3 +26,25 @@ export function activeTeamListOptions(wsId: string) {
|
||||
select: (data) => data.teams.filter((team) => !team.archived_at),
|
||||
});
|
||||
}
|
||||
|
||||
export function teamMembersOptions(wsId: string, teamId: string) {
|
||||
return queryOptions({
|
||||
queryKey: teamKeys.members(wsId, teamId),
|
||||
queryFn: () => api.listTeamMembers(teamId),
|
||||
select: (data) => data.members,
|
||||
});
|
||||
}
|
||||
|
||||
export function myTeamListOptions(wsId: string) {
|
||||
// The sidebar's Teams section: only teams the user joined, in their
|
||||
// personal order. Same cache entry as teamListOptions (per-observer
|
||||
// select), so reorder patches on the base key reflect here instantly.
|
||||
return queryOptions({
|
||||
queryKey: teamKeys.list(wsId),
|
||||
queryFn: () => api.listTeams(),
|
||||
select: (data) =>
|
||||
data.teams
|
||||
.filter((team) => team.is_member && !team.archived_at)
|
||||
.sort((a, b) => a.sort_order - b.sort_order),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ export type {
|
||||
} from "./agent";
|
||||
export { RUNTIME_PROFILE_PROTOCOL_FAMILIES } from "./agent";
|
||||
export type { Workspace, WorkspaceRepo, Member, MemberRole, User, MemberWithUser, Invitation } from "./workspace";
|
||||
export type { Team, CreateTeamRequest, UpdateTeamRequest, ListTeamsResponse } from "./team";
|
||||
export type { Team, CreateTeamRequest, UpdateTeamRequest, ListTeamsResponse, TeamMembership, TeamMember, ListTeamMembersResponse } from "./team";
|
||||
export type { InboxItem, InboxSeverity, InboxItemType, InboxWorkspaceUnread } from "./inbox";
|
||||
export type { NotificationGroupKey, NotificationGroupValue, NotificationPreferences, NotificationPreferenceResponse } from "./notification-preference";
|
||||
export type { Comment, CommentType, CommentAuthorType, CommentTriggerPreview, CommentTriggerPreviewAgent, CommentTriggerSource, Reaction } from "./comment";
|
||||
|
||||
@@ -11,6 +11,10 @@ export interface Team {
|
||||
created_by: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
/** Requesting user's membership view — the sidebar shows only joined
|
||||
* teams, ordered by sort_order (per-user fractional position). */
|
||||
is_member: boolean;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export interface CreateTeamRequest {
|
||||
@@ -18,6 +22,8 @@ export interface CreateTeamRequest {
|
||||
key: string;
|
||||
description?: string;
|
||||
icon?: string | null;
|
||||
/** Workspace members invited alongside the creator (who joins as lead). */
|
||||
member_ids?: string[];
|
||||
}
|
||||
|
||||
export interface UpdateTeamRequest {
|
||||
@@ -31,3 +37,25 @@ export interface ListTeamsResponse {
|
||||
teams: Team[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** Caller's own membership row, as returned by PATCH /api/teams/{id}/membership. */
|
||||
export interface TeamMembership {
|
||||
team_id: string;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
/** A team member with user display data (GET /api/teams/{id}/members). */
|
||||
export interface TeamMember {
|
||||
user_id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
avatar_url: string | null;
|
||||
/** "lead" | "member" — informational in v1, no privileges attached. */
|
||||
role: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ListTeamMembersResponse {
|
||||
members: TeamMember[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user