feat(web): rename, duplicate, move-to-team and delete from the project switcher (#8183)

* feat(web): rename, duplicate, move-to-team and delete from the project switcher

The docked project switcher (the chat header's tab dropdown) gains a ⋮ menu
per project row with the four actions the rail's 最近项目 rows already offer:
重命名 (in place), 复制项目, 转入团队空间 and 删除 (OPEND-2686 / OPEND-3128).

All four run through the shared project-actions flows so the switcher, the
rail rows and the project cards cannot drift: `useProjectDeleteFlow` +
`ProjectDeleteConfirmDialog`, `useProjectDuplicateFlow` (which now reports
its outcome so a menu can stay open on failure), and `useWorkspaceProjectMove`
+ `MoveToTeamConfirmDialog`. 转入团队空间 is a real move into the team space,
shown under the rail row menu's conditions: only in a team workspace with
`canShareProjects`, inert once shared, and disabled with the ownership hint on
a project someone else shared. The move response doubles as the row's
ownership witness until the team catalog lists it, through the same optimistic
ownership helpers EntryShell uses for the cards.

Opening a row's menu suppresses that row's hover preview; the menu takes the
preview's slot beside the dropdown. New locale key
`recentProjects.duplicateInProgress` across all 19 locales.

* fix(web): tolerate a workspace context without permissions in the switcher menu gate

A context carrying no permissions block must read as "cannot share", not
throw during render; some App-level fixtures build contexts that way.
This commit is contained in:
Ray Xi
2026-09-16 13:39:59 +08:00
committed by GitHub
parent 084641478e
commit e14622a644
26 changed files with 837 additions and 14 deletions
+3
View File
@@ -5684,6 +5684,9 @@ function AppInner() {
onboardingCompleted={config.onboardingCompleted === true}
identityScopeKey={workspaceTabsIdentityScopeKey}
workspaceContext={workspaceContext}
onRenameProject={handleRenameProject}
onDuplicateProject={handleDuplicateProject}
onDeleteProject={handleDeleteProject}
/>
{/* Avatar + credits keep their home-view spot (the top-right actions
host inside the tabs chrome) while a project tab is open, even
@@ -0,0 +1,110 @@
/* The ⋮ on a docked project-switcher row and the menu it opens (OPEND-2686 /
OPEND-3128): 重命名 / 复制项目 / 转入团队空间 / 删除, the rail's 最近项目 row
menu brought to the chat header's switcher. Material per the reference:
`--bg` panel, `--border-soft` hairline, `--shadow-md`. */
.row {
position: relative;
}
/* Trailing ⋮. Hidden until the row is hovered or focused so the list reads as
names first; stays up while its menu is open so the affordance and the menu
cannot disagree about which row owns it. */
.more {
flex: 0 0 24px;
width: 24px;
height: 24px;
margin-right: 4px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--text-muted);
opacity: 0;
cursor: pointer;
}
.row:hover .more,
.row:focus-within .more,
.more[aria-expanded='true'] {
opacity: 1;
}
.more:hover,
.more[aria-expanded='true'] {
background: var(--vibrancy-fill-tertiary);
color: var(--text);
}
/* 重命名 edits the name in place, in the row's own slot — the rail row does the
same — so the switcher never has to leave the list to rename an entry. */
.rename {
flex: 1 1 auto;
min-width: 0;
height: 28px;
margin: 2px 4px 2px 0;
padding: 0 8px;
border: 1px solid var(--accent);
border-radius: 6px;
background: var(--bg);
color: var(--text);
font: inherit;
font-size: 13px;
outline: none;
}
/* Portalled beside the dropdown (see `dockActionsAnchorFor` in
WorkspaceTabsBar.tsx), in the slot the hover preview would take — which is
why opening it suppresses that preview. */
.menu {
position: fixed;
z-index: 1200;
width: 160px;
padding: 6px;
display: flex;
flex-direction: column;
gap: 2px;
border: 1px solid var(--border-soft);
border-radius: 10px;
background: var(--bg);
box-shadow: var(--shadow-md);
}
.menu button {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
width: 100%;
height: auto;
padding: 8px 10px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--text);
font: inherit;
font-size: 13px;
text-align: left;
cursor: pointer;
}
.menu button:hover:not(:disabled) {
background: var(--bg-subtle);
}
.menu button:disabled {
opacity: 0.45;
cursor: default;
}
.menu button.danger {
color: var(--red, #f04142);
}
.menu button.danger:hover:not(:disabled) {
background: color-mix(in srgb, var(--red, #f04142) 12%, transparent);
}
/* A failed request is reported under the item that asked for it, the way the
rail row menu and the project cards report theirs. */
.error {
padding: 2px 10px 6px;
font-size: 12px;
line-height: 1.4;
color: var(--red, #f04142);
}
+478 -6
View File
@@ -16,7 +16,12 @@ import {
import { useT } from '../i18n';
import { buildPath, navigate, type EntryHomeView, type Route } from '../router';
import type { Project } from '../types';
import type { ProjectDisplayStatus, WorkspaceCollabContext } from '@open-design/contracts';
import {
type ProjectDisplayStatus,
type TeamProject,
type WorkspaceCollabContext,
workspaceContextHasTeamIdentity,
} from '@open-design/contracts';
import { Icon, type IconName } from './Icon';
import {
hasCompletionNotice,
@@ -29,6 +34,30 @@ import {
ProjectHoverPreviewCard,
useProjectHoverCover,
} from './entry-nav-rail/ProjectHoverPreview';
import { DeleteMark, MoreDotsMark, RenameMark } from './entry-nav-rail/RailRecentRow';
import { createSharedProjectPredicate } from '../collab/all-projects-list';
import { fetchTeamProjectsCatalog } from '../collab/team-projects-catalog';
import { projectOwnedBySelf } from './project-actions/ownership';
import {
forgetOptimisticProjectOwnership,
type OptimisticProjectOwnershipWitnesses,
optimisticProjectOwnershipScopeKey,
projectOwnerMemberIdsWithOptimisticWitnesses,
recordOptimisticProjectOwnership,
} from '../collab/optimistic-project-ownership';
import { currentWorkspaceAccountGeneration } from '../collab/workspace-identity';
import { ProjectDeleteConfirmDialog } from './project-actions/ProjectDeleteConfirmDialog';
import {
type ProjectDeleteHandler,
useProjectDeleteFlow,
} from './project-actions/useProjectDeleteFlow';
import {
type ProjectDuplicateHandler,
useProjectDuplicateFlow,
} from './project-actions/useProjectDuplicateFlow';
import { useWorkspaceProjectMove } from './project-actions/useWorkspaceProjectMove';
import { MoveToTeamConfirmDialog, moveConfirmSkipped } from './MoveToTeamConfirmDialog';
import actionStyles from './WorkspaceProjectActions.module.css';
import { acknowledgeProjectCompletion, useProjectRunStatuses } from '../hooks/useProjectRunStatuses';
import { STATUS_LABEL_KEYS } from '../state/projectRunStatus';
import {
@@ -142,6 +171,17 @@ interface Props {
* simply returns nothing readable in a workspace one.
*/
workspaceContext?: WorkspaceCollabContext | null;
/**
* The docked dropdown's per-row ⋮ menu (OPEND-2686 / OPEND-3128): 重命名 /
* 复制项目 / 转入团队空间 / 删除, through the flows the rail's 最近项目 rows and
* the project cards already share. Each item renders only when its handler
* is supplied; 转入团队空间 needs no handler — it runs `moveWorkspaceProject`
* itself — and shows under the rail row menu's conditions (a team workspace
* with `canShareProjects`).
*/
onRenameProject?: (id: string, name: string) => Promise<unknown> | void;
onDuplicateProject?: ProjectDuplicateHandler;
onDeleteProject?: ProjectDeleteHandler;
}
/* Dwell before the dock dropdown's hover preview commits to a row. Long
@@ -157,6 +197,32 @@ const PREVIEW_HOVER_DELAY_MS = 180;
const PREVIEW_WIDTH_PX = 216;
const PREVIEW_GAP_PX = 8;
/* The row ⋮ menu takes the preview's slot beside the dropdown (see
`.menu` in WorkspaceProjectActions.module.css for the width). */
const DOCK_ACTIONS_WIDTH_PX = 160;
/**
* Where a row's ⋮ menu sits: top-aligned with the row, just past the
* dropdown's right edge — or its left edge when the window has no room there,
* the same fallback the hover preview makes.
*/
function dockActionsAnchorFor(
row: HTMLElement,
menu: HTMLElement | null,
viewportWidth: number,
): { top: number; left: number } {
const menuRect = menu?.getBoundingClientRect() ?? row.getBoundingClientRect();
const rowRect = row.getBoundingClientRect();
const right = menuRect.right + PREVIEW_GAP_PX;
const fits = right + DOCK_ACTIONS_WIDTH_PX + PREVIEW_GAP_PX <= viewportWidth;
return {
top: rowRect.top,
left: fits
? right
: Math.max(PREVIEW_GAP_PX, menuRect.left - PREVIEW_GAP_PX - DOCK_ACTIONS_WIDTH_PX),
};
}
const STORAGE_KEY = 'open-design:workspace-tabs:v1';
const OPEN_WORKSPACE_TAB_EVENT = 'open-design:workspace-tabs:open';
const REMOVE_WORKSPACE_PROJECT_TABS_EVENT = 'open-design:workspace-tabs:remove-project';
@@ -793,6 +859,9 @@ export function WorkspaceTabsBar({
onboardingCompleted = false,
identityScopeKey,
workspaceContext = null,
onRenameProject,
onDuplicateProject,
onDeleteProject,
}: Props) {
const t = useT();
const analytics = useAnalytics();
@@ -1060,6 +1129,194 @@ export function WorkspaceTabsBar({
}, [dockMenuOpen, clearPreview]);
useEffect(() => cancelPreviewTimer, [cancelPreviewTimer]);
// ---- Row ⋮ menu (OPEND-2686 / OPEND-3128) ------------------------------
// The project whose ⋮ menu is open, and where that menu sits. Keyed by
// project rather than tab: the move flow re-opens it by project id once the
// request is on its way (see `onMoveStart`), and a project has one row here.
const [dockActionsProjectId, setDockActionsProjectId] = useState<string | null>(null);
const [dockActionsAnchor, setDockActionsAnchor] = useState<{ top: number; left: number } | null>(null);
const dockActionsRef = useRef<HTMLDivElement | null>(null);
// 重命名 edits in place, in the row (the rail row does the same).
const [dockRenamingProjectId, setDockRenamingProjectId] = useState<string | null>(null);
const [dockRenameDraft, setDockRenameDraft] = useState('');
// 复制项目: one at a time, and a failure stays in the menu until it closes.
const [dockDuplicatingId, setDockDuplicatingId] = useState<string | null>(null);
const [dockDuplicateFailedId, setDockDuplicateFailedId] = useState<string | null>(null);
// Closing the dropdown takes everything it hosts with it. The dialogs below
// are portalled and outlive it on purpose.
useEffect(() => {
if (dockMenuOpen) return;
setDockActionsProjectId(null);
setDockActionsAnchor(null);
setDockRenamingProjectId(null);
setDockDuplicateFailedId(null);
}, [dockMenuOpen]);
// Outside pointer / Escape / anything that moves the dropdown closes the
// menu — the same dismissals the rail row menu answers to.
useEffect(() => {
if (!dockActionsProjectId) return undefined;
const close = () => setDockActionsProjectId(null);
const onPointerDown = (event: PointerEvent) => {
const target = event.target as Node;
if (dockActionsRef.current?.contains(target)) return;
if ((target as Element).closest?.('[data-dock-actions-trigger]')) return;
close();
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') close();
};
document.addEventListener('pointerdown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
document.addEventListener('scroll', close, true);
window.addEventListener('resize', close);
return () => {
document.removeEventListener('pointerdown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
document.removeEventListener('scroll', close, true);
window.removeEventListener('resize', close);
};
}, [dockActionsProjectId]);
// 转入团队空间 shows under the rail row menu's conditions: a team plane to
// move into AND the right to share. A personal workspace hides the item.
const moveToTeamAvailable =
workspaceContextHasTeamIdentity(workspaceContext)
&& workspaceContext?.permissions?.canShareProjects === true;
// Shared / foreign rows are decided from the same evidence the rail uses —
// the team hub's catalog — read once per dropdown open (coalesced and cached
// by the catalog module, so this is not a second poll) rather than through
// `useTeamProjects`, which would keep polling on the project route.
const [teamCatalog, setTeamCatalog] = useState<readonly TeamProject[]>([]);
useEffect(() => {
if (!dockMenuOpen || !workspaceContextHasTeamIdentity(workspaceContext) || !workspaceContext) {
return undefined;
}
let cancelled = false;
fetchTeamProjectsCatalog({ context: workspaceContext })
.then((catalog) => {
if (!cancelled) setTeamCatalog(catalog);
})
.catch((error: unknown) => {
// Off-team / offline / no hub: the rows read as unshared and owned,
// which is what the rail shows with no catalog either.
console.warn('[WorkspaceTabsBar] team project catalog read failed:', error);
});
return () => {
cancelled = true;
};
}, [dockMenuOpen, workspaceContext]);
// Moves made from this menu flip the row's shared state at once; the
// catalog read on the next open confirms it. The move response is also the
// row's ownership witness until then — without it a just-shared row would
// read as someone else's (shared, no attribution) and lose its actions,
// which is the same lag EntryShell's optimistic layer covers for the cards.
const [sharedThisSession, setSharedThisSession] = useState<ReadonlySet<string>>(
() => new Set<string>(),
);
const [ownershipWitnesses, setOwnershipWitnesses] = useState<OptimisticProjectOwnershipWitnesses>(
() => new Map(),
);
const ownershipScopeKey = optimisticProjectOwnershipScopeKey(
workspaceContext,
currentWorkspaceAccountGeneration(),
);
const isSharedProject = useMemo(
() => createSharedProjectPredicate({
teamProjects: teamCatalog,
localProjects: projects,
workspaceContext,
sharedThisSession,
}),
[projects, sharedThisSession, teamCatalog, workspaceContext],
);
const teamProjectOwnerMemberIds = useMemo(
() => projectOwnerMemberIdsWithOptimisticWitnesses({
scopeKey: ownershipScopeKey,
teamProjects: teamCatalog,
witnesses: ownershipWitnesses,
}),
[ownershipScopeKey, ownershipWitnesses, teamCatalog],
);
const dockRowOwnedBySelf = useCallback(
(projectId: string) => projectOwnedBySelf({
projectId,
ownerMemberIds: teamProjectOwnerMemberIds,
selfMemberId: workspaceContext?.workspaceMemberId,
isShared: isSharedProject,
}),
[isSharedProject, teamProjectOwnerMemberIds, workspaceContext?.workspaceMemberId],
);
// The dropdown only exists on the project route, where no project-collection
// page is on screen; its actions file under Home, as the rail's do off the
// collection views.
const deleteFlow = useProjectDeleteFlow({
onDelete: onDeleteProject,
analyticsPage: 'home',
workspaceContext,
});
const duplicateFlow = useProjectDuplicateFlow({
onDuplicate: onDuplicateProject,
analyticsPage: 'home',
workspaceContext,
});
const moveFlow = useWorkspaceProjectMove({
workspaceContext,
analyticsPage: 'home',
onProjectShared: (moved) => {
setSharedThisSession((prev) => new Set(prev).add(moved.id));
setOwnershipWitnesses((prev) => recordOptimisticProjectOwnership(prev, {
scopeKey: ownershipScopeKey,
context: workspaceContext,
project: moved,
}));
},
onProjectShareFailed: (projectId) => {
setSharedThisSession((prev) => {
if (!prev.has(projectId)) return prev;
const next = new Set(prev);
next.delete(projectId);
return next;
});
setOwnershipWitnesses((prev) => forgetOptimisticProjectOwnership(prev, projectId));
},
// Progress and failure show in the row's ⋮ menu, which the confirm dialog
// dismissed: re-open it once the request is on its way, close it again on
// success (the rail section does the same with its row menu).
onMoveStart: (project) => setDockActionsProjectId(project.id),
onMoveSettled: (project, _action, ok) => {
if (ok) setDockActionsProjectId((current) => (current === project.id ? null : current));
},
});
const [moveTarget, setMoveTarget] = useState<Project | null>(null);
const requestMoveToTeam = useCallback((project: Project) => {
setDockActionsProjectId(null);
if (moveConfirmSkipped()) {
void moveFlow.shareToTeam(project);
return;
}
setMoveTarget(project);
}, [moveFlow]);
const duplicateFromDock = useCallback(async (project: Project) => {
if (dockDuplicatingId) return;
setDockDuplicateFailedId(null);
setDockDuplicatingId(project.id);
const ok = await duplicateFlow.duplicate(project);
setDockDuplicatingId(null);
if (ok) {
// The shell opens the copy; the list this dropdown showed is behind it.
setDockActionsProjectId(null);
setDockMenuOpen(false);
return;
}
setDockDuplicateFailedId(project.id);
}, [dockDuplicatingId, duplicateFlow]);
const commitDockRename = useCallback((project: Project) => {
const next = dockRenameDraft.trim();
setDockRenamingProjectId(null);
if (!next || next === project.name) return;
void onRenameProject?.(project.id, next);
}, [dockRenameDraft, onRenameProject]);
// Full-page settings borrows CHAT's chrome row: a lone Home logo at the left
// edge, no search / rail toggle. App swaps EntryShell out for the settings
// surface on this route, so the rail those two controls drive isn't mounted —
@@ -1847,6 +2104,23 @@ export function WorkspaceTabsBar({
previewTab && previewTab.kind === 'project'
? projectById.get(previewTab.projectId) ?? null
: null;
// The row whose ⋮ menu is open, and what that menu has to say about it.
const dockActionsProject = dockActionsProjectId
? projectById.get(dockActionsProjectId) ?? null
: null;
const dockActionsOwnedBySelf = dockActionsProject
? dockRowOwnedBySelf(dockActionsProject.id)
: true;
const dockActionsForeignTitle = dockActionsOwnedBySelf
? undefined
: t('recentProjects.ownOnlyMutation');
const dockActionsShared = dockActionsProject ? isSharedProject(dockActionsProject.id) : false;
const dockActionsSharing =
dockActionsProject !== null && moveFlow.sharingId === dockActionsProject.id;
const dockActionsShareError =
dockActionsProject && moveFlow.error?.projectId === dockActionsProject.id
? moveFlow.error.kind
: null;
return (
<div className="workspace-tabs-dropdown" data-testid="workspace-tabs-dropdown">
<button
@@ -1880,11 +2154,40 @@ export function WorkspaceTabsBar({
displayTabById.get(tab.id)
?? displayTabFor(tab, projectById, t, knownProjectNamesRef.current);
const active = tab.id === state.activeTabId;
// The row's project, when the ambient list carries it — the ⋮
// menu acts on a Project, so a tab naming one this list has
// not loaded (deep link, other workspace) offers no menu.
const rowProject =
tab.kind === 'project' ? projectById.get(tab.projectId) ?? null : null;
const actionsOpen = rowProject !== null && dockActionsProjectId === rowProject.id;
return (
<div
key={tab.id}
className={`workspace-tabs-dropdown__row${active ? ' is-active' : ''}`}
className={`workspace-tabs-dropdown__row${active ? ' is-active' : ''}${
rowProject ? ` ${actionStyles.row}` : ''
}`}
>
{rowProject && dockRenamingProjectId === rowProject.id ? (
<input
className={actionStyles.rename}
value={dockRenameDraft}
aria-label={t('designs.menuRename')}
autoFocus
onChange={(event) => setDockRenameDraft(event.target.value)}
onBlur={() => commitDockRename(rowProject)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
commitDockRename(rowProject);
} else if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
setDockRenameDraft(rowProject.name);
setDockRenamingProjectId(null);
}
}}
/>
) : (
<button
type="button"
className="workspace-tabs-dropdown__row-main"
@@ -1895,9 +2198,17 @@ export function WorkspaceTabsBar({
openTab(tab);
}}
/* Focus previews too, so the card is not mouse-only:
arrowing/tabbing the list shows the same picture. */
onMouseEnter={(event) => queuePreview(tab.id, event.currentTarget)}
onFocus={(event) => showPreviewNow(tab.id, event.currentTarget)}
arrowing/tabbing the list shows the same picture. An
open ⋮ menu outranks the hover (it took the preview's
slot, and the user asked for it by clicking). */
onMouseEnter={(event) => {
if (dockActionsProjectId) return;
queuePreview(tab.id, event.currentTarget);
}}
onFocus={(event) => {
if (dockActionsProjectId) return;
showPreviewNow(tab.id, event.currentTarget);
}}
>
{/* Always up: every row fills the slot now — a live run
status when there is one, the folder otherwise — so
@@ -1912,16 +2223,153 @@ export function WorkspaceTabsBar({
<Icon name="check" size={14} className="workspace-tabs-dropdown__row-check" />
) : null}
</button>
)}
{rowProject && (onRenameProject || onDuplicateProject || onDeleteProject || moveToTeamAvailable) ? (
<button
type="button"
className={actionStyles.more}
aria-label={t('designs.menuMore')}
aria-haspopup="menu"
aria-expanded={actionsOpen}
data-dock-actions-trigger
data-testid="workspace-tabs-dropdown-row-more"
onClick={(event) => {
event.stopPropagation();
if (actionsOpen) {
setDockActionsProjectId(null);
return;
}
const row = event.currentTarget.parentElement;
if (row) {
setDockActionsAnchor(
dockActionsAnchorFor(row, dockMenuRef.current, window.innerWidth),
);
}
clearPreview();
setDockDuplicateFailedId(null);
moveFlow.clearError();
setDockActionsProjectId(rowProject.id);
}}
>
<MoreDotsMark />
</button>
) : null}
</div>
);
})}
</div>
{/* The open row's ⋮ menu, in the preview's slot beside the
dropdown and portalled for the same reason the preview is.
Items, in order: 重命名 / 复制项目 / 转入团队空间 (team
workspaces only) / 删除 — the rail row menu's list, through
the flows it shares with the project cards. */}
{dockActionsProject && dockActionsAnchor && typeof document !== 'undefined' ? createPortal(
<div
ref={dockActionsRef}
className={actionStyles.menu}
role="menu"
data-testid="workspace-tabs-dropdown-row-menu"
style={{ top: dockActionsAnchor.top, left: dockActionsAnchor.left }}
>
{onRenameProject ? (
<button
type="button"
role="menuitem"
disabled={!dockActionsOwnedBySelf}
title={dockActionsForeignTitle}
onClick={() => {
setDockActionsProjectId(null);
setDockRenameDraft(dockActionsProject.name);
setDockRenamingProjectId(dockActionsProject.id);
}}
>
<RenameMark />
<span>{t('designs.menuRename')}</span>
</button>
) : null}
{onDuplicateProject ? (
<button
type="button"
role="menuitem"
disabled={!dockActionsOwnedBySelf || dockDuplicatingId !== null}
title={dockActionsForeignTitle}
onClick={() => { void duplicateFromDock(dockActionsProject); }}
>
<Icon name="copy" size={14} />
<span>
{dockDuplicatingId === dockActionsProject.id
? t('recentProjects.duplicateInProgress')
: t('designs.menuDuplicate')}
</span>
</button>
) : null}
{dockDuplicateFailedId === dockActionsProject.id ? (
<div className={actionStyles.error} role="alert">
{t('ds.actionFailed')}
</div>
) : null}
{/* Hidden, not disabled, without a team plane (see
`moveToTeamAvailable`); a shared row and a foreign row
keep the item and explain themselves instead. */}
{moveToTeamAvailable ? (
<button
type="button"
role="menuitem"
disabled={dockActionsSharing || dockActionsShared || !dockActionsOwnedBySelf}
title={dockActionsForeignTitle}
data-testid="workspace-tabs-dropdown-move-to-team"
onClick={() => requestMoveToTeam(dockActionsProject)}
>
<Icon name="share" size={14} />
<span>
{dockActionsSharing
? t('recentProjects.shareInProgress')
: dockActionsShared
? t('recentProjects.sharedInTeam')
: t('recentProjects.moveToTeam')}
</span>
</button>
) : null}
{dockActionsShareError ? (
<div className={actionStyles.error} role="alert">
{t(
dockActionsShareError === 'unshare'
? 'recentProjects.unshareFailed'
: dockActionsShareError === 'owner-conflict'
? 'recentProjects.shareOwnerConflict'
: 'recentProjects.shareFailed',
)}
</div>
) : null}
{onDeleteProject ? (
<button
type="button"
role="menuitem"
className={actionStyles.danger}
disabled={!dockActionsOwnedBySelf}
title={dockActionsForeignTitle}
onClick={() => {
// The confirmation (the shared project delete dialog,
// OPEND-2797) is modal; the dropdown has nothing left
// to show under it.
setDockActionsProjectId(null);
setDockMenuOpen(false);
deleteFlow.request(dockActionsProject);
}}
>
<DeleteMark />
<span>{t('designs.menuDelete')}</span>
</button>
) : null}
</div>,
document.body,
) : null}
{/* Preview of the hovered row, parked beside the menu — the rail's
own card, so the switcher and the 最近项目 list show one and the
same picture for a project. Purely informational (aria-hidden,
no pointer events), so it can't sit between the pointer and a
row. */}
{previewProject && previewAnchor && typeof document !== 'undefined' ? (
{!dockActionsProjectId && previewProject && previewAnchor && typeof document !== 'undefined' ? (
<DockRowPreview
/* Keyed by project so switching rows remounts the card instead
of pointing a live cover at a new project. */
@@ -2211,6 +2659,30 @@ export function WorkspaceTabsBar({
className="workspace-chrome-account-actions"
data-testid="workspace-chrome-account-actions"
/>
{/* Portalled: the chrome header is a backdrop-filtered box, which would
contain a fixed dialog rendered inside it. `MoveToTeamConfirmDialog`
portals itself. */}
{deleteFlow.target && typeof document !== 'undefined' ? createPortal(
<ProjectDeleteConfirmDialog
projectName={deleteFlow.target.name}
pending={deleteFlow.pending}
failed={deleteFlow.failed}
onCancel={deleteFlow.cancel}
onConfirm={() => void deleteFlow.commit()}
/>,
document.body,
) : null}
{moveTarget ? (
<MoveToTeamConfirmDialog
action="to-team"
onCancel={() => setMoveTarget(null)}
onConfirm={() => {
const project = moveTarget;
setMoveTarget(null);
void moveFlow.shareToTeam(project);
}}
/>
) : null}
{radialMenu ? createPortal(
<div className="workspace-radial-layer" onMouseDown={() => setRadialMenu(null)}>
<div
@@ -74,10 +74,11 @@ const POPUP_GAP_PX = 24;
/**
* The ⋮ mark (supplied artwork; Remix's `more-2-line`). Inlined rather than
* added to the shared icon set: no `IconName` maps to that glyph today, and
* this is the only place it appears.
* added to the shared icon set: no `IconName` maps to that glyph today. Shared
* with the project switcher's row menu (WorkspaceTabsBar), which is the same
* menu on another surface.
*/
function MoreDotsMark() {
export function MoreDotsMark() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -96,9 +97,10 @@ function MoreDotsMark() {
/**
* Menu marks (supplied artwork). Inlined for the same reason as the ⋮ above:
* neither glyph exists in the shared icon set — `pencil`/`trash` are close but
* not these drawings, and product asked for these.
* not these drawings, and product asked for these. Shared with the switcher's
* row menu too.
*/
function RenameMark() {
export function RenameMark() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -114,7 +116,7 @@ function RenameMark() {
);
}
function DeleteMark() {
export function DeleteMark() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -14,6 +14,11 @@ export type ProjectDuplicateHandler = (id: string) => Promise<void> | void;
* handler (which POSTs `/api/projects/:id/duplicate`, the same call
* `od project duplicate` makes) and reports the outcome once, so both
* surfaces tell one analytics story.
*
* Resolves `true` once the copy exists and `false` when the request failed —
* the failure is already logged and tracked here, so a caller only needs the
* boolean to decide whether its menu stays open to say so (the switcher's row
* menu does; the cards let the failure pass).
*/
export function useProjectDuplicateFlow(input: {
onDuplicate?: ProjectDuplicateHandler;
@@ -22,8 +27,8 @@ export function useProjectDuplicateFlow(input: {
}) {
const { onDuplicate, analyticsPage, workspaceContext } = input;
const analytics = useAnalytics();
const duplicate = useCallback(async (project: Project): Promise<void> => {
if (!onDuplicate) return;
const duplicate = useCallback(async (project: Project): Promise<boolean> => {
if (!onDuplicate) return false;
const startedAt = performance.now();
const workspaceDimensions = workspaceAnalyticsDimensions(workspaceContext);
try {
@@ -39,6 +44,7 @@ export function useProjectDuplicateFlow(input: {
duration_ms: Math.round(performance.now() - startedAt),
...workspaceDimensions,
});
return true;
} catch (err) {
console.warn('[useProjectDuplicateFlow] duplicate project failed:', err);
trackWorkspaceProjectActionResult(analytics.track, {
@@ -53,6 +59,7 @@ export function useProjectDuplicateFlow(input: {
error_code: 'request_failed',
...workspaceDimensions,
});
return false;
}
}, [analytics.track, analyticsPage, onDuplicate, workspaceContext]);
return { duplicate };
+1
View File
@@ -1133,6 +1133,7 @@ export const ar: Dict = {
'recentProjects.teamMemberCreator': 'Team member',
'recentProjects.ownOnlyMutation': 'Only projects you created can be changed or deleted.',
'recentProjects.shareInProgress': 'Sharing…',
'recentProjects.duplicateInProgress': 'جارٍ النسخ…',
'recentProjects.sharedInTeam': 'In team space',
'recentProjects.moveToTeam': 'Move to team space',
'recentProjects.moveToTeamDescPre': 'Once moved to the team space, ',
+1
View File
@@ -1133,6 +1133,7 @@ export const de: Dict = {
'recentProjects.teamMemberCreator': 'Team member',
'recentProjects.ownOnlyMutation': 'Only projects you created can be changed or deleted.',
'recentProjects.shareInProgress': 'Sharing…',
'recentProjects.duplicateInProgress': 'Wird dupliziert…',
'recentProjects.sharedInTeam': 'In team space',
'recentProjects.moveToTeam': 'Move to team space',
'recentProjects.moveToTeamDescPre': 'Once moved to the team space, ',
+1
View File
@@ -1133,6 +1133,7 @@ export const en: Dict = {
'recentProjects.teamMemberCreator': 'Team member',
'recentProjects.ownOnlyMutation': 'Only projects you created can be changed or deleted.',
'recentProjects.shareInProgress': 'Sharing…',
'recentProjects.duplicateInProgress': 'Duplicating…',
'recentProjects.sharedInTeam': 'In team space',
'recentProjects.moveToTeam': 'Move to team space',
'recentProjects.moveToTeamDescPre': 'Once moved to the team space, ',
+1
View File
@@ -1133,6 +1133,7 @@ export const esES: Dict = {
'recentProjects.teamMemberCreator': 'Team member',
'recentProjects.ownOnlyMutation': 'Only projects you created can be changed or deleted.',
'recentProjects.shareInProgress': 'Sharing…',
'recentProjects.duplicateInProgress': 'Duplicando…',
'recentProjects.sharedInTeam': 'In team space',
'recentProjects.moveToTeam': 'Move to team space',
'recentProjects.moveToTeamDescPre': 'Once moved to the team space, ',
+1
View File
@@ -1133,6 +1133,7 @@ export const fa: Dict = {
'recentProjects.teamMemberCreator': 'Team member',
'recentProjects.ownOnlyMutation': 'Only projects you created can be changed or deleted.',
'recentProjects.shareInProgress': 'Sharing…',
'recentProjects.duplicateInProgress': 'در حال کپی…',
'recentProjects.sharedInTeam': 'In team space',
'recentProjects.moveToTeam': 'Move to team space',
'recentProjects.moveToTeamDescPre': 'Once moved to the team space, ',
+1
View File
@@ -1133,6 +1133,7 @@ export const fr: Dict = {
'recentProjects.teamMemberCreator': "Membre de l'équipe",
'recentProjects.ownOnlyMutation': 'Seuls les projets que vous avez créés peuvent être modifiés ou supprimés.',
'recentProjects.shareInProgress': 'Partage…',
'recentProjects.duplicateInProgress': 'Duplication…',
'recentProjects.sharedInTeam': "Dans l'espace d'équipe",
'recentProjects.moveToTeam': "Déplacer vers l'espace d'équipe",
'recentProjects.moveToTeamDescPre': "Une fois déplacé dans l'espace d'équipe, ",
+1
View File
@@ -1133,6 +1133,7 @@ export const hu: Dict = {
'recentProjects.teamMemberCreator': 'Team member',
'recentProjects.ownOnlyMutation': 'Only projects you created can be changed or deleted.',
'recentProjects.shareInProgress': 'Sharing…',
'recentProjects.duplicateInProgress': 'Másolás…',
'recentProjects.sharedInTeam': 'In team space',
'recentProjects.moveToTeam': 'Move to team space',
'recentProjects.moveToTeamDescPre': 'Once moved to the team space, ',
+1
View File
@@ -1133,6 +1133,7 @@ export const id: Dict = {
'recentProjects.teamMemberCreator': 'Team member',
'recentProjects.ownOnlyMutation': 'Only projects you created can be changed or deleted.',
'recentProjects.shareInProgress': 'Sharing…',
'recentProjects.duplicateInProgress': 'Menduplikasi…',
'recentProjects.sharedInTeam': 'In team space',
'recentProjects.moveToTeam': 'Move to team space',
'recentProjects.moveToTeamDescPre': 'Once moved to the team space, ',
+1
View File
@@ -1133,6 +1133,7 @@ export const it: Dict = {
'recentProjects.teamMemberCreator': 'Team member',
'recentProjects.ownOnlyMutation': 'Only projects you created can be changed or deleted.',
'recentProjects.shareInProgress': 'Sharing…',
'recentProjects.duplicateInProgress': 'Duplicazione…',
'recentProjects.sharedInTeam': 'In team space',
'recentProjects.moveToTeam': 'Move to team space',
'recentProjects.moveToTeamDescPre': 'Once moved to the team space, ',
+1
View File
@@ -1133,6 +1133,7 @@ export const ja: Dict = {
'recentProjects.teamMemberCreator': 'Team member',
'recentProjects.ownOnlyMutation': 'Only projects you created can be changed or deleted.',
'recentProjects.shareInProgress': 'Sharing…',
'recentProjects.duplicateInProgress': '複製中…',
'recentProjects.sharedInTeam': 'In team space',
'recentProjects.moveToTeam': 'Move to team space',
'recentProjects.moveToTeamDescPre': 'Once moved to the team space, ',
+1
View File
@@ -1133,6 +1133,7 @@ export const ko: Dict = {
'recentProjects.teamMemberCreator': 'Team member',
'recentProjects.ownOnlyMutation': 'Only projects you created can be changed or deleted.',
'recentProjects.shareInProgress': 'Sharing…',
'recentProjects.duplicateInProgress': '복제 중…',
'recentProjects.sharedInTeam': 'In team space',
'recentProjects.moveToTeam': 'Move to team space',
'recentProjects.moveToTeamDescPre': 'Once moved to the team space, ',
+1
View File
@@ -1133,6 +1133,7 @@ export const pl: Dict = {
'recentProjects.teamMemberCreator': 'Team member',
'recentProjects.ownOnlyMutation': 'Only projects you created can be changed or deleted.',
'recentProjects.shareInProgress': 'Sharing…',
'recentProjects.duplicateInProgress': 'Duplikowanie…',
'recentProjects.sharedInTeam': 'In team space',
'recentProjects.moveToTeam': 'Move to team space',
'recentProjects.moveToTeamDescPre': 'Once moved to the team space, ',
+1
View File
@@ -1133,6 +1133,7 @@ export const ptBR: Dict = {
'recentProjects.teamMemberCreator': 'Team member',
'recentProjects.ownOnlyMutation': 'Only projects you created can be changed or deleted.',
'recentProjects.shareInProgress': 'Sharing…',
'recentProjects.duplicateInProgress': 'Duplicando…',
'recentProjects.sharedInTeam': 'In team space',
'recentProjects.moveToTeam': 'Move to team space',
'recentProjects.moveToTeamDescPre': 'Once moved to the team space, ',
+1
View File
@@ -1133,6 +1133,7 @@ export const ru: Dict = {
'recentProjects.teamMemberCreator': 'Team member',
'recentProjects.ownOnlyMutation': 'Only projects you created can be changed or deleted.',
'recentProjects.shareInProgress': 'Sharing…',
'recentProjects.duplicateInProgress': 'Дублирование…',
'recentProjects.sharedInTeam': 'In team space',
'recentProjects.moveToTeam': 'Move to team space',
'recentProjects.moveToTeamDescPre': 'Once moved to the team space, ',
+1
View File
@@ -1133,6 +1133,7 @@ export const th: Dict = {
'recentProjects.teamMemberCreator': 'Team member',
'recentProjects.ownOnlyMutation': 'Only projects you created can be changed or deleted.',
'recentProjects.shareInProgress': 'Sharing…',
'recentProjects.duplicateInProgress': 'กำลังทำสำเนา…',
'recentProjects.sharedInTeam': 'In team space',
'recentProjects.moveToTeam': 'Move to team space',
'recentProjects.moveToTeamDescPre': 'Once moved to the team space, ',
+1
View File
@@ -1133,6 +1133,7 @@ export const tr: Dict = {
'recentProjects.teamMemberCreator': 'Team member',
'recentProjects.ownOnlyMutation': 'Only projects you created can be changed or deleted.',
'recentProjects.shareInProgress': 'Sharing…',
'recentProjects.duplicateInProgress': 'Çoğaltılıyor…',
'recentProjects.sharedInTeam': 'In team space',
'recentProjects.moveToTeam': 'Move to team space',
'recentProjects.moveToTeamDescPre': 'Once moved to the team space, ',
+1
View File
@@ -1133,6 +1133,7 @@ export const uk: Dict = {
'recentProjects.teamMemberCreator': 'Team member',
'recentProjects.ownOnlyMutation': 'Only projects you created can be changed or deleted.',
'recentProjects.shareInProgress': 'Sharing…',
'recentProjects.duplicateInProgress': 'Дублювання…',
'recentProjects.sharedInTeam': 'In team space',
'recentProjects.moveToTeam': 'Move to team space',
'recentProjects.moveToTeamDescPre': 'Once moved to the team space, ',
+1
View File
@@ -1148,6 +1148,7 @@ export const zhCN: Dict = {
"recentProjects.teamMemberCreator": "团队成员",
"recentProjects.ownOnlyMutation": "只能修改或删除自己创建的项目。",
"recentProjects.shareInProgress": "分享中…",
"recentProjects.duplicateInProgress": "复制中…",
"recentProjects.sharedInTeam": "已在团队空间",
"recentProjects.moveToTeam": "转入团队空间",
"recentProjects.moveToTeamDescPre": "转入团队空间后,",
+1
View File
@@ -1150,6 +1150,7 @@ export const zhTW: Dict = {
"recentProjects.teamMemberCreator": "團隊成員",
"recentProjects.ownOnlyMutation": "只能修改或刪除自己建立的專案。",
"recentProjects.shareInProgress": "分享中…",
"recentProjects.duplicateInProgress": "複製中…",
"recentProjects.sharedInTeam": "已在團隊空間",
"recentProjects.moveToTeam": "轉入團隊空間",
"recentProjects.moveToTeamDescPre": "轉入團隊空間後,",
+1
View File
@@ -1512,6 +1512,7 @@ export interface Dict {
'recentProjects.teamMemberCreator': string;
'recentProjects.ownOnlyMutation': string;
'recentProjects.shareInProgress': string;
'recentProjects.duplicateInProgress': string;
'recentProjects.sharedInTeam': string;
'recentProjects.moveToTeam': string;
'recentProjects.moveToTeamDescPre': string;
@@ -3,6 +3,7 @@
import { StrictMode } from 'react';
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import type { WorkspaceCollabContext } from '@open-design/contracts';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
@@ -1975,3 +1976,211 @@ describe('WorkspaceTabsBar dock dropdown run status', () => {
});
});
});
// The ⋮ menu on each row of the docked project switcher (OPEND-2686 / 3128):
// 重命名 / 复制项目 / 转入团队空间 / 删除 — the same four actions, through the
// same shared flows, as the rail's 最近项目 rows. 转入团队空间 is a REAL move
// into the team space (MoveToTeamConfirmDialog + POST …/move), shown under the
// rail row menu's conditions: a team workspace with `canShareProjects` only.
describe('WorkspaceTabsBar dock dropdown project actions', () => {
const originalFetch = globalThis.fetch;
const dock = document.createElement('div');
const personalContext = {
workspaceId: 'ws-personal',
workspaceType: 'personal',
workspaceMemberId: 'wm-1',
role: 'owner',
memberStatus: 'active',
lifecycleState: 'active',
permissions: { canInviteMembers: false, canViewWorkspaceSettings: false, canShareProjects: true },
} as unknown as WorkspaceCollabContext;
const teamContext = {
...personalContext,
workspaceId: 'ws-team',
workspaceType: 'team',
} as unknown as WorkspaceCollabContext;
function moveRequests(): string[] {
return vi.mocked(fetch).mock.calls
.filter(([, init]) => init?.method === 'POST')
.map(([url]) => String(url))
.filter((url) => /\/projects\/[^/]+\/move$/.test(url));
}
beforeEach(() => {
window.localStorage.clear();
vi.clearAllMocks();
document.body.append(dock);
setWorkspaceTabsDock(dock);
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (init?.method === 'POST' && /\/projects\/[^/]+\/move$/.test(url)) {
return new Response(JSON.stringify({
project: {
id: project.id,
name: project.name,
workspaceId: 'ws-team',
visibility: 'team',
resourceState: 'active',
createdByWorkspaceMemberId: 'wm-1',
currentUserAccess: {
canOpen: true,
canRename: true,
canDelete: true,
canDuplicate: true,
canMoveToTeam: false,
canMoveToPersonal: true,
canExport: true,
canSendTo: true,
canRestoreVersion: true,
},
createdAt: 1,
updatedAt: 2,
},
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (url.startsWith('/api/runs?')) {
return new Response(JSON.stringify({ runs: [], awaitingInputProjectIds: [] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}) as unknown as typeof fetch;
});
afterEach(() => {
cleanup();
globalThis.fetch = originalFetch;
setWorkspaceTabsDock(null);
dock.remove();
});
async function openRowMenu() {
fireEvent.click(await screen.findByTestId('workspace-tabs-dropdown-trigger'));
const listbox = screen.getByRole('listbox');
fireEvent.click(within(listbox).getByRole('button', { name: 'designs.menuMore' }));
return screen.getByRole('menu');
}
it('offers rename, duplicate, move-to-team and delete on a team workspace row', async () => {
render(
<WorkspaceTabsBar
route={projectRoute}
projects={[project]}
workspaceContext={teamContext}
onRenameProject={vi.fn()}
onDuplicateProject={vi.fn()}
onDeleteProject={vi.fn()}
/>,
);
const menu = await openRowMenu();
const items = within(menu).getAllByRole('menuitem').map((item) => item.textContent);
expect(items).toEqual([
'designs.menuRename',
'designs.menuDuplicate',
'recentProjects.moveToTeam',
'designs.menuDelete',
]);
});
it('hides move-to-team in a personal workspace', async () => {
render(
<WorkspaceTabsBar
route={projectRoute}
projects={[project]}
workspaceContext={personalContext}
onRenameProject={vi.fn()}
onDuplicateProject={vi.fn()}
onDeleteProject={vi.fn()}
/>,
);
const menu = await openRowMenu();
expect(within(menu).queryByRole('menuitem', { name: 'recentProjects.moveToTeam' })).toBeNull();
expect(within(menu).getByRole('menuitem', { name: 'designs.menuDelete' })).toBeTruthy();
});
it('deletes only through the shared confirmation dialog', async () => {
const remove = vi.fn().mockResolvedValue(true);
render(
<WorkspaceTabsBar
route={projectRoute}
projects={[project]}
workspaceContext={teamContext}
onDeleteProject={remove}
/>,
);
const menu = await openRowMenu();
fireEvent.click(within(menu).getByRole('menuitem', { name: 'designs.menuDelete' }));
const dialog = await screen.findByTestId('project-delete-confirm-dialog');
expect(remove).not.toHaveBeenCalled();
fireEvent.click(within(dialog).getByTestId('project-delete-confirm-cancel'));
expect(remove).not.toHaveBeenCalled();
expect(screen.queryByTestId('project-delete-confirm-dialog')).toBeNull();
fireEvent.click(within(await openRowMenu()).getByRole('menuitem', { name: 'designs.menuDelete' }));
fireEvent.click(within(await screen.findByTestId('project-delete-confirm-dialog')).getByTestId('project-delete-confirm-accept'));
await waitFor(() => expect(remove).toHaveBeenCalledWith(project.id));
await waitFor(() => expect(screen.queryByTestId('project-delete-confirm-dialog')).toBeNull());
});
it('duplicates through the shared duplicate flow and hands the id to the shell', async () => {
const duplicate = vi.fn().mockResolvedValue(undefined);
render(
<WorkspaceTabsBar
route={projectRoute}
projects={[project]}
workspaceContext={teamContext}
onDuplicateProject={duplicate}
/>,
);
const menu = await openRowMenu();
fireEvent.click(within(menu).getByRole('menuitem', { name: 'designs.menuDuplicate' }));
await waitFor(() => expect(duplicate).toHaveBeenCalledWith(project.id));
});
it('moves the project into the team space after confirmation', async () => {
render(
<WorkspaceTabsBar
route={projectRoute}
projects={[project]}
workspaceContext={teamContext}
onDeleteProject={vi.fn()}
/>,
);
const menu = await openRowMenu();
fireEvent.click(within(menu).getByRole('menuitem', { name: 'recentProjects.moveToTeam' }));
const confirm = await screen.findByRole('alertdialog');
expect(moveRequests()).toEqual([]);
fireEvent.click(within(confirm).getByRole('button', { name: 'recentProjects.confirmMoveToTeam' }));
await waitFor(() => expect(moveRequests()).toEqual(['/api/workspaces/ws-team/projects/project-alpha/move']));
// Progress shows in the row menu the flow re-opens; success closes it
// again (as the rail's row menu does), and the next open reads 已在团队空间.
await waitFor(() => expect(screen.queryByRole('menu')).toBeNull());
fireEvent.click(within(screen.getByRole('listbox')).getByRole('button', { name: 'designs.menuMore' }));
const item = screen.getByRole('menuitem', { name: 'recentProjects.sharedInTeam' });
expect((item as HTMLButtonElement).disabled).toBe(true);
// Still the member's own project: the move response is its ownership
// witness until the team catalog lists it, so the other actions stay.
expect((screen.getByRole('menuitem', { name: 'designs.menuDelete' }) as HTMLButtonElement).disabled).toBe(false);
});
it('renames in place and commits on Enter', async () => {
const rename = vi.fn().mockResolvedValue(undefined);
render(
<WorkspaceTabsBar
route={projectRoute}
projects={[project]}
workspaceContext={teamContext}
onRenameProject={rename}
/>,
);
const menu = await openRowMenu();
fireEvent.click(within(menu).getByRole('menuitem', { name: 'designs.menuRename' }));
const input = await screen.findByRole('textbox', { name: 'designs.menuRename' });
fireEvent.change(input, { target: { value: 'Renamed project' } });
expect(rename).not.toHaveBeenCalled();
fireEvent.keyDown(input, { key: 'Enter' });
await waitFor(() => expect(rename).toHaveBeenCalledWith(project.id, 'Renamed project'));
});
});