mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-28 10:02:36 +02:00
Compare commits
2 Commits
agent/lamb
...
agent/j/d6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30aa949ac6 | ||
|
|
6d64141e2c |
@@ -70,7 +70,7 @@ If logic appears in both apps, it MUST be extracted to a shared package. There a
|
||||
|
||||
### Issue keys
|
||||
|
||||
Every issue has a human-readable key like `MUL-123`: workspace `issue_prefix` (3 letters, uppercase) + sequence number. The prefix is set at workspace creation and is never changed afterward.
|
||||
Every issue has a human-readable key like `MUL-123`: workspace `issue_prefix` (uppercase letters and digits, typically 3 chars, max 10) + sequence number. Workspace admins can change the prefix in Settings → General; changing it renumbers every existing issue, so external references that embed the old prefix (PR titles, branch names, links in docs and chat) stop resolving.
|
||||
|
||||
### Comments in code
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ monorepo 的包边界是硬约束:
|
||||
|
||||
### Issue 编号
|
||||
|
||||
每个 issue 有人类可读的编号,比如 `MUL-123`:工作区 `issue_prefix`(3 个大写字母)+ 流水号。前缀在工作区创建时定,之后不可改。
|
||||
每个 issue 有人类可读的编号,比如 `MUL-123`:工作区 `issue_prefix`(大写字母和数字,通常 3 个字符,最长 10 个)+ 流水号。工作区管理员可以在 Settings → General 中修改前缀;修改会让所有现有 issue 重新编号,外部引用——PR 标题、分支名、文档与聊天里的链接——里的旧前缀会失效。
|
||||
|
||||
### 代码注释
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Three things get decided when you create a workspace:
|
||||
|
||||
- **Workspace name** — the display name members see. Spaces and non-ASCII characters are allowed. You can change it later.
|
||||
- **Slug** — the string used in the workspace URL. Lowercase letters and digits only (joined with `-`). **It cannot be changed after creation**, so pick carefully. If the slug is taken or hits a system-reserved word, the create screen will ask you to choose another.
|
||||
- **Issue prefix** — the prefix for every issue number in the workspace (the `MUL` in `MUL-123`). Use uppercase letters.
|
||||
- **Issue prefix** — the prefix for every issue number in the workspace (the `MUL` in `MUL-123`). Uppercase letters and digits, up to 10 characters.
|
||||
|
||||
<Callout type="warning">
|
||||
**Avoid changing the issue prefix.** Issue numbers are rendered with the current prefix — change it and `MUL-5` instantly becomes `NEW-5`. Every external link, Slack mention, and historical reference in comments breaks against the old number. Treat the issue prefix as "set at creation, never touched."
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
- **工作区名字** — 给成员看的显示名称,可以包含空格和中文。后续随时能改。
|
||||
- **Slug(短链标识符)** — 工作区 URL 中使用的字符串,只能是小写字母和数字(用 `-` 连接)。**创建后不能改**,提前想好。如果 slug 已被占用或命中系统保留词,创建界面会让你换一个。
|
||||
- **Issue 前缀** — 工作区里所有 issue 编号的前缀(比如 `MUL-123` 里的 `MUL`)。使用大写字母。
|
||||
- **Issue 前缀** — 工作区里所有 issue 编号的前缀(比如 `MUL-123` 里的 `MUL`)。只能是大写字母和数字,最长 10 个字符。
|
||||
|
||||
<Callout type="warning">
|
||||
**尽量不要修改 issue 前缀。** 系统在展示 issue 编号时会用当前的前缀——改了之后,`MUL-5` 会立刻变成 `NEW-5`。所有外部链接、Slack 提及、评论里的历史引用都会对不上旧编号。把 issue 前缀当成"创建后不改"的设计来对待。
|
||||
|
||||
@@ -1102,7 +1102,7 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async updateWorkspace(id: string, data: { name?: string; description?: string; context?: string; settings?: Record<string, unknown>; repos?: WorkspaceRepo[] }): Promise<Workspace> {
|
||||
async updateWorkspace(id: string, data: { name?: string; description?: string; context?: string; settings?: Record<string, unknown>; repos?: WorkspaceRepo[]; issue_prefix?: string }): Promise<Workspace> {
|
||||
return this.fetch(`/api/workspaces/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(data),
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { chatKeys } from "../chat/queries";
|
||||
import type { ChatDonePayload, ChatMessage, ChatPendingTask } from "../types";
|
||||
import { applyChatDoneToCache } from "./use-realtime-sync";
|
||||
import { issueKeys } from "../issues/queries";
|
||||
import { workspaceKeys } from "../workspace/queries";
|
||||
import type {
|
||||
ChatDonePayload,
|
||||
ChatMessage,
|
||||
ChatPendingTask,
|
||||
Workspace,
|
||||
} from "../types";
|
||||
import {
|
||||
applyChatDoneToCache,
|
||||
applyWorkspaceUpdatedToCache,
|
||||
} from "./use-realtime-sync";
|
||||
|
||||
const sessionId = "session-1";
|
||||
const taskId = "task-1";
|
||||
@@ -115,3 +125,78 @@ describe("applyChatDoneToCache", () => {
|
||||
expect(qc.getQueryData<ChatPendingTask>(pendingKey)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyWorkspaceUpdatedToCache", () => {
|
||||
const wsId = "ws-1";
|
||||
|
||||
function workspace(overrides: Partial<Workspace> = {}): Workspace {
|
||||
return {
|
||||
id: wsId,
|
||||
name: "Test",
|
||||
slug: "test",
|
||||
description: null,
|
||||
context: null,
|
||||
settings: {},
|
||||
repos: [],
|
||||
issue_prefix: "TES",
|
||||
created_at: "2026-05-18T00:00:00Z",
|
||||
updated_at: "2026-05-18T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it("invalidates issue cache when issue_prefix changes", () => {
|
||||
const qc = createQueryClient();
|
||||
qc.setQueryData<Workspace[]>(workspaceKeys.list(), [
|
||||
workspace({ issue_prefix: "TES" }),
|
||||
]);
|
||||
const invalidate = vi.spyOn(qc, "invalidateQueries");
|
||||
|
||||
applyWorkspaceUpdatedToCache(qc, {
|
||||
workspace: workspace({ issue_prefix: "NEW" }),
|
||||
});
|
||||
|
||||
expect(invalidate).toHaveBeenCalledWith({
|
||||
queryKey: issueKeys.all(wsId),
|
||||
});
|
||||
expect(invalidate).toHaveBeenCalledWith({
|
||||
queryKey: workspaceKeys.list(),
|
||||
});
|
||||
});
|
||||
|
||||
it("does not invalidate issue cache when only non-prefix fields change", () => {
|
||||
const qc = createQueryClient();
|
||||
qc.setQueryData<Workspace[]>(workspaceKeys.list(), [
|
||||
workspace({ issue_prefix: "TES", name: "Old name" }),
|
||||
]);
|
||||
const invalidate = vi.spyOn(qc, "invalidateQueries");
|
||||
|
||||
applyWorkspaceUpdatedToCache(qc, {
|
||||
workspace: workspace({ issue_prefix: "TES", name: "New name" }),
|
||||
});
|
||||
|
||||
expect(invalidate).not.toHaveBeenCalledWith({
|
||||
queryKey: issueKeys.all(wsId),
|
||||
});
|
||||
expect(invalidate).toHaveBeenCalledWith({
|
||||
queryKey: workspaceKeys.list(),
|
||||
});
|
||||
});
|
||||
|
||||
it("invalidates issue cache when the workspace isn't in the cached list yet", () => {
|
||||
// Conservative: a workspace appearing for the first time may correspond
|
||||
// to issue queries that were primed without ever seeing the (possibly
|
||||
// changing) prefix. Erring on the side of refresh keeps identifiers
|
||||
// accurate at minimal cost.
|
||||
const qc = createQueryClient();
|
||||
const invalidate = vi.spyOn(qc, "invalidateQueries");
|
||||
|
||||
applyWorkspaceUpdatedToCache(qc, {
|
||||
workspace: workspace({ issue_prefix: "NEW" }),
|
||||
});
|
||||
|
||||
expect(invalidate).toHaveBeenCalledWith({
|
||||
queryKey: issueKeys.all(wsId),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,12 +31,14 @@ import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged, onInboxIssueD
|
||||
import { inboxKeys } from "../inbox/queries";
|
||||
import { notificationPreferenceOptions } from "../notification-preferences/queries";
|
||||
import { workspaceKeys, workspaceListOptions } from "../workspace/queries";
|
||||
import type { Workspace } from "../types/workspace";
|
||||
import { chatKeys } from "../chat/queries";
|
||||
import { useChatStore } from "../chat";
|
||||
import { resolvePostAuthDestination, useHasOnboarded } from "../paths";
|
||||
import type {
|
||||
MemberAddedPayload,
|
||||
WorkspaceDeletedPayload,
|
||||
WorkspaceUpdatedPayload,
|
||||
MemberRemovedPayload,
|
||||
IssueUpdatedPayload,
|
||||
IssueCreatedPayload,
|
||||
@@ -107,6 +109,36 @@ export function applyChatDoneToCache(
|
||||
qc.invalidateQueries({ queryKey: chatKeys.pendingTask(sessionId) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a workspace:updated event to the cache. Always refreshes the
|
||||
* workspace list. If the incoming `issue_prefix` differs from what's
|
||||
* currently cached, also invalidates issueKeys.all for that workspace,
|
||||
* since every issue's rendered identifier (`MUL-123`) is recomputed from
|
||||
* the workspace prefix at read time. Without this, the UI keeps showing
|
||||
* the old `OLD-N` keys until the next hard refresh.
|
||||
*
|
||||
* If the workspace isn't in the cached list (first observation), we
|
||||
* conservatively invalidate — the prefix is effectively "new" relative to
|
||||
* what's cached, so any issues already loaded under the old prefix would
|
||||
* be stale anyway.
|
||||
*/
|
||||
export function applyWorkspaceUpdatedToCache(
|
||||
qc: QueryClient,
|
||||
payload: WorkspaceUpdatedPayload,
|
||||
): void {
|
||||
const next = payload.workspace;
|
||||
if (next?.id) {
|
||||
const cached =
|
||||
qc
|
||||
.getQueryData<Workspace[]>(workspaceKeys.list())
|
||||
?.find((w) => w.id === next.id) ?? null;
|
||||
if (!cached || cached.issue_prefix !== next.issue_prefix) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.all(next.id) });
|
||||
}
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidates all workspace-scoped queries. Used after reconnect and when a
|
||||
* new WSClient instance is detected (workspace switch) to recover events
|
||||
@@ -191,6 +223,11 @@ export function useRealtimeSync(
|
||||
const wsId = getCurrentWsId();
|
||||
if (wsId) qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
|
||||
},
|
||||
// workspace:updated is handled by the specific handler below
|
||||
// (compares prefixes to decide whether to also invalidate issues).
|
||||
// This generic fallback still fires for workspace:deleted (paired
|
||||
// with the specific navigation handler) and any future workspace:*
|
||||
// events without dedicated handlers.
|
||||
workspace: () => {
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
|
||||
},
|
||||
@@ -303,6 +340,7 @@ export function useRealtimeSync(
|
||||
|
||||
// Event types handled by specific handlers below -- skip generic refresh
|
||||
const specificEvents = new Set([
|
||||
"workspace:updated",
|
||||
"issue:updated", "issue:created", "issue:deleted", "issue_labels:changed", "inbox:new",
|
||||
"comment:created", "comment:updated", "comment:deleted",
|
||||
"comment:resolved", "comment:unresolved",
|
||||
@@ -540,6 +578,10 @@ export function useRealtimeSync(
|
||||
}
|
||||
};
|
||||
|
||||
const unsubWsUpdated = ws.on("workspace:updated", (p) => {
|
||||
applyWorkspaceUpdatedToCache(qc, p as WorkspaceUpdatedPayload);
|
||||
});
|
||||
|
||||
const unsubWsDeleted = ws.on("workspace:deleted", (p) => {
|
||||
const { workspace_id } = p as WorkspaceDeletedPayload;
|
||||
// Event payload has UUID; look up slug from cached workspace list
|
||||
@@ -849,6 +891,7 @@ export function useRealtimeSync(
|
||||
unsubIssueReactionRemoved();
|
||||
unsubSubscriberAdded();
|
||||
unsubSubscriberRemoved();
|
||||
unsubWsUpdated();
|
||||
unsubWsDeleted();
|
||||
unsubMemberRemoved();
|
||||
unsubMemberAdded();
|
||||
|
||||
@@ -116,6 +116,10 @@
|
||||
"context_label": "Context",
|
||||
"context_placeholder": "Background information and context for AI agents working in this workspace",
|
||||
"slug_label": "Slug",
|
||||
"issue_prefix_label": "Issue prefix",
|
||||
"issue_prefix_hint": "Used in issue numbers like {{example}}.",
|
||||
"prefix_confirm_title": "Change issue prefix?",
|
||||
"prefix_confirm_description": "All issues will be renumbered from {{oldPrefix}}-N to {{newPrefix}}-N. External references — pull request titles, branch names, links in docs and chat — that use {{oldPrefix}}-N will stop resolving.",
|
||||
"save": "Save",
|
||||
"saving": "Saving...",
|
||||
"manage_hint": "Only admins and owners can update workspace settings.",
|
||||
|
||||
@@ -116,6 +116,10 @@
|
||||
"context_label": "上下文",
|
||||
"context_placeholder": "供工作区中的智能体参考的背景信息和上下文",
|
||||
"slug_label": "Slug",
|
||||
"issue_prefix_label": "Issue 前缀",
|
||||
"issue_prefix_hint": "用于 issue 编号,例如 {{example}}。",
|
||||
"prefix_confirm_title": "确认修改 issue 前缀?",
|
||||
"prefix_confirm_description": "所有 issue 将从 {{oldPrefix}}-N 重命名为 {{newPrefix}}-N。任何使用旧编号的外部引用——PR 标题、分支名、文档与聊天中的链接——都会失效。",
|
||||
"save": "保存",
|
||||
"saving": "保存中...",
|
||||
"manage_hint": "只有管理员和所有者可以修改工作区设置。",
|
||||
|
||||
237
packages/views/settings/components/workspace-tab.test.tsx
Normal file
237
packages/views/settings/components/workspace-tab.test.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { I18nProvider } from "@multica/core/i18n/react";
|
||||
import enCommon from "../../locales/en/common.json";
|
||||
import enSettings from "../../locales/en/settings.json";
|
||||
|
||||
const mockUpdateWorkspace = vi.hoisted(() => vi.fn());
|
||||
const mockInvalidateQueries = vi.hoisted(() => vi.fn());
|
||||
const workspaceRef = vi.hoisted(() => ({
|
||||
current: {
|
||||
id: "workspace-1",
|
||||
name: "Test Workspace",
|
||||
slug: "test-workspace",
|
||||
description: "",
|
||||
context: "",
|
||||
issue_prefix: "TES",
|
||||
repos: [] as { url: string }[],
|
||||
},
|
||||
}));
|
||||
const membersRef = vi.hoisted(() => ({
|
||||
current: [{ user_id: "user-1", role: "owner" as "owner" | "admin" | "member" }],
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data: membersRef.current, isFetched: true }),
|
||||
useQueryClient: () => ({
|
||||
setQueryData: vi.fn(),
|
||||
getQueryData: vi.fn(() => []),
|
||||
invalidateQueries: mockInvalidateQueries,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/hooks", () => ({
|
||||
useWorkspaceId: () => "workspace-1",
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/paths", () => ({
|
||||
useCurrentWorkspace: () => workspaceRef.current,
|
||||
useHasOnboarded: () => true,
|
||||
resolvePostAuthDestination: () => "/",
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/platform", () => ({
|
||||
setCurrentWorkspace: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/workspace/queries", () => ({
|
||||
memberListOptions: () => ({ queryKey: ["members"], queryFn: vi.fn() }),
|
||||
workspaceListOptions: () => ({ queryKey: ["workspaces"], queryFn: vi.fn() }),
|
||||
workspaceKeys: { list: () => ["workspaces"] },
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/issues/queries", () => ({
|
||||
issueKeys: { all: (wsId: string) => ["issues", wsId] },
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/workspace/mutations", () => ({
|
||||
useLeaveWorkspace: () => ({ mutateAsync: vi.fn() }),
|
||||
useDeleteWorkspace: () => ({ mutateAsync: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
api: { updateWorkspace: mockUpdateWorkspace },
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/auth", () => {
|
||||
const useAuthStore = Object.assign(
|
||||
(sel?: (s: { user: { id: string } }) => unknown) =>
|
||||
sel ? sel({ user: { id: "user-1" } }) : { user: { id: "user-1" } },
|
||||
{ getState: () => ({ user: { id: "user-1" } }) },
|
||||
);
|
||||
return { useAuthStore };
|
||||
});
|
||||
|
||||
vi.mock("../../navigation", () => ({
|
||||
useNavigation: () => ({ push: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("./delete-workspace-dialog", () => ({
|
||||
DeleteWorkspaceDialog: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
import { WorkspaceTab } from "./workspace-tab";
|
||||
|
||||
const TEST_RESOURCES = {
|
||||
en: { common: enCommon, settings: enSettings },
|
||||
};
|
||||
|
||||
function I18nWrapper({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<I18nProvider locale="en" resources={TEST_RESOURCES}>
|
||||
{children}
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("WorkspaceTab — issue prefix editing", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
workspaceRef.current = {
|
||||
id: "workspace-1",
|
||||
name: "Test Workspace",
|
||||
slug: "test-workspace",
|
||||
description: "",
|
||||
context: "",
|
||||
issue_prefix: "TES",
|
||||
repos: [],
|
||||
};
|
||||
membersRef.current = [{ user_id: "user-1", role: "owner" }];
|
||||
mockUpdateWorkspace.mockImplementation(
|
||||
async (
|
||||
_id: string,
|
||||
payload: { issue_prefix?: string; name?: string },
|
||||
) => ({
|
||||
...workspaceRef.current,
|
||||
...payload,
|
||||
issue_prefix: payload.issue_prefix ?? workspaceRef.current.issue_prefix,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the current prefix in the input", () => {
|
||||
render(<WorkspaceTab />, { wrapper: I18nWrapper });
|
||||
const input = screen.getByPlaceholderText("TES") as HTMLInputElement;
|
||||
expect(input.value).toBe("TES");
|
||||
});
|
||||
|
||||
it("uppercases and strips non-alphanumeric input as the user types", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<WorkspaceTab />, { wrapper: I18nWrapper });
|
||||
const input = screen.getByPlaceholderText("TES") as HTMLInputElement;
|
||||
|
||||
await user.clear(input);
|
||||
await user.type(input, "ab-12!cd");
|
||||
|
||||
expect(input.value).toBe("AB12CD");
|
||||
});
|
||||
|
||||
it("saves directly without confirm when the prefix is unchanged", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<WorkspaceTab />, { wrapper: I18nWrapper });
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /^Save$/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateWorkspace).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
// No issue_prefix in the payload when unchanged — avoids no-op churn
|
||||
// and keeps the request shape identical to pre-feature behavior.
|
||||
expect(mockUpdateWorkspace).toHaveBeenCalledWith(
|
||||
"workspace-1",
|
||||
expect.not.objectContaining({ issue_prefix: expect.anything() }),
|
||||
);
|
||||
expect(screen.queryByText(/Change issue prefix/i)).toBeNull();
|
||||
// Non-prefix saves must NOT invalidate the issue cache — would
|
||||
// trigger an unnecessary workspace-wide refetch on every name edit.
|
||||
expect(mockInvalidateQueries).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ queryKey: ["issues", "workspace-1"] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows a confirm dialog before saving when the prefix changes, and only saves on confirm", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<WorkspaceTab />, { wrapper: I18nWrapper });
|
||||
|
||||
const input = screen.getByPlaceholderText("TES") as HTMLInputElement;
|
||||
await user.clear(input);
|
||||
await user.type(input, "NEW");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /^Save$/ }));
|
||||
|
||||
// Save is gated behind the dialog — no API call yet.
|
||||
expect(mockUpdateWorkspace).not.toHaveBeenCalled();
|
||||
|
||||
// Dialog body mentions both the old and new prefix in the warning.
|
||||
await screen.findByText(/Change issue prefix/i);
|
||||
expect(screen.getByText(/TES-N/)).toBeTruthy();
|
||||
expect(screen.getByText(/NEW-N/)).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Confirm" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateWorkspace).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(mockUpdateWorkspace).toHaveBeenCalledWith(
|
||||
"workspace-1",
|
||||
expect.objectContaining({ issue_prefix: "NEW" }),
|
||||
);
|
||||
// Issue identifiers (`MUL-123`) are recomputed from the workspace
|
||||
// prefix at read time, so cached issues display the stale OLD-N key
|
||||
// until invalidated. Without this the confirm dialog's promise that
|
||||
// "all issues will be renumbered to NEW-N" is a lie.
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ queryKey: ["issues", "workspace-1"] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("cancelling the confirm dialog does not save", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<WorkspaceTab />, { wrapper: I18nWrapper });
|
||||
|
||||
const input = screen.getByPlaceholderText("TES") as HTMLInputElement;
|
||||
await user.clear(input);
|
||||
await user.type(input, "NEW");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /^Save$/ }));
|
||||
|
||||
await screen.findByText(/Change issue prefix/i);
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
expect(mockUpdateWorkspace).not.toHaveBeenCalled();
|
||||
// The user's edited value is preserved so they can resume.
|
||||
expect(input.value).toBe("NEW");
|
||||
});
|
||||
|
||||
it("disables Save when the prefix is empty", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<WorkspaceTab />, { wrapper: I18nWrapper });
|
||||
|
||||
const input = screen.getByPlaceholderText("TES") as HTMLInputElement;
|
||||
await user.clear(input);
|
||||
|
||||
expect(screen.getByRole("button", { name: /^Save$/ })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("disables the prefix input for non-admins", () => {
|
||||
membersRef.current = [{ user_id: "user-1", role: "member" }];
|
||||
render(<WorkspaceTab />, { wrapper: I18nWrapper });
|
||||
expect(screen.getByPlaceholderText("TES")).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
workspaceKeys,
|
||||
workspaceListOptions,
|
||||
} from "@multica/core/workspace/queries";
|
||||
import { issueKeys } from "@multica/core/issues/queries";
|
||||
import { api } from "@multica/core/api";
|
||||
import {
|
||||
resolvePostAuthDestination,
|
||||
@@ -98,6 +99,7 @@ export function WorkspaceTab() {
|
||||
const [name, setName] = useState(workspace?.name ?? "");
|
||||
const [description, setDescription] = useState(workspace?.description ?? "");
|
||||
const [context, setContext] = useState(workspace?.context ?? "");
|
||||
const [issuePrefix, setIssuePrefix] = useState(workspace?.issue_prefix ?? "");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [actionId, setActionId] = useState<string | null>(null);
|
||||
const [confirmAction, setConfirmAction] = useState<{
|
||||
@@ -123,9 +125,21 @@ export function WorkspaceTab() {
|
||||
setName(workspace?.name ?? "");
|
||||
setDescription(workspace?.description ?? "");
|
||||
setContext(workspace?.context ?? "");
|
||||
setIssuePrefix(workspace?.issue_prefix ?? "");
|
||||
}, [workspace]);
|
||||
|
||||
const handleSave = async () => {
|
||||
// Letters + digits only, uppercase, capped at 10 chars. The backend
|
||||
// uppercases and trims on its side too — this is purely a UX guardrail
|
||||
// so the value the user sees in the input matches what gets persisted.
|
||||
const normalizePrefix = (raw: string) =>
|
||||
raw.toUpperCase().replace(/[^A-Z0-9]/g, "").slice(0, 10);
|
||||
|
||||
const normalizedPrefix = normalizePrefix(issuePrefix);
|
||||
const prefixChanged =
|
||||
!!workspace && normalizedPrefix !== workspace.issue_prefix;
|
||||
const prefixInvalid = normalizedPrefix.length === 0;
|
||||
|
||||
const performSave = async (includePrefix: boolean) => {
|
||||
if (!workspace) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
@@ -133,10 +147,19 @@ export function WorkspaceTab() {
|
||||
name,
|
||||
description,
|
||||
context,
|
||||
...(includePrefix ? { issue_prefix: normalizedPrefix } : {}),
|
||||
});
|
||||
qc.setQueryData(workspaceKeys.list(), (old: Workspace[] | undefined) =>
|
||||
old?.map((ws) => (ws.id === updated.id ? updated : ws)),
|
||||
);
|
||||
// Issue identifiers (`MUL-123`) are computed from `issue_prefix` at
|
||||
// read time, not stored on each issue row. When the prefix changes,
|
||||
// every cached issue's rendered identifier is stale until refetched.
|
||||
// Limit invalidation to the prefix-changed branch so unrelated saves
|
||||
// (name / description / context) stay cheap.
|
||||
if (includePrefix) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.all(updated.id) });
|
||||
}
|
||||
toast.success(t(($) => $.workspace.toast_saved));
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : t(($) => $.workspace.toast_save_failed));
|
||||
@@ -145,6 +168,23 @@ export function WorkspaceTab() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!workspace || prefixInvalid) return;
|
||||
if (prefixChanged) {
|
||||
setConfirmAction({
|
||||
title: t(($) => $.workspace.prefix_confirm_title),
|
||||
description: t(($) => $.workspace.prefix_confirm_description, {
|
||||
oldPrefix: workspace.issue_prefix,
|
||||
newPrefix: normalizedPrefix,
|
||||
}),
|
||||
variant: "destructive",
|
||||
onConfirm: () => performSave(true),
|
||||
});
|
||||
return;
|
||||
}
|
||||
void performSave(false);
|
||||
};
|
||||
|
||||
const handleLeaveWorkspace = () => {
|
||||
if (!workspace) return;
|
||||
setConfirmAction({
|
||||
@@ -231,11 +271,28 @@ export function WorkspaceTab() {
|
||||
{workspace.slug}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">{t(($) => $.workspace.issue_prefix_label)}</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={issuePrefix}
|
||||
onChange={(e) => setIssuePrefix(normalizePrefix(e.target.value))}
|
||||
disabled={!canManageWorkspace}
|
||||
maxLength={10}
|
||||
className="mt-1 font-mono uppercase"
|
||||
placeholder={workspace.issue_prefix}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t(($) => $.workspace.issue_prefix_hint, {
|
||||
example: `${normalizedPrefix || workspace.issue_prefix}-123`,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 pt-1">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !name.trim() || !canManageWorkspace}
|
||||
disabled={saving || !name.trim() || prefixInvalid || !canManageWorkspace}
|
||||
>
|
||||
<Save className="h-3 w-3" />
|
||||
{saving ? t(($) => $.workspace.saving) : t(($) => $.workspace.save)}
|
||||
|
||||
Reference in New Issue
Block a user