feat(space): add client-defined UIDs and identity cues

- Generate UUID v4 values in the client with validated custom UID
  support.\n- Show immutable UIDs where Space titles need
  disambiguation.\n- Standardize Space surfaces on the Lucide Astroid
  icon.
This commit is contained in:
amblued 2026-08-28 00:33:09 +08:00
parent 040f9ae717
commit 9d2b77ced8
37 changed files with 912 additions and 146 deletions

View file

@ -24,6 +24,22 @@ _Avoid_: Content reaction, generic reaction, reaction target
An instance-scoped collaboration boundary for accepted members and memo placement. It is not a tenant, folder, or application-wide authorization role.
_Avoid_: Tenant, workspace, group
**Space ID**:
The stable internal identity of a Space. It is not exposed as the Space's public identifier.
_Avoid_: Space UID, Space title
**Space UID**:
The immutable, instance-wide public identifier assigned when a Space is created. It may be user-defined or generated automatically.
_Avoid_: Space ID, Space title, slug
**Space resource name**:
The API identity of a Space in the form `spaces/{space UID}`.
_Avoid_: Space ID, Space title
**Space title**:
The mutable, non-unique display label of a Space.
_Avoid_: Space UID, Space resource name
**Space invitation**:
A pending offer for an existing active Memos user to join a Space with a specified Space role. It grants no membership or Space access until that user accepts it.
_Avoid_: Pending membership, direct add

View file

@ -0,0 +1,65 @@
# ADR 0003: Space UID Allocation and Format
Status: Accepted
Date: 2026-08-27
Domain glossary: [Memos domain glossary](../glossary.md)
## Context
A Space title is mutable and non-unique. Using it alone in switchers, settings, badges, and destructive confirmations makes distinct Spaces with the same
title difficult to tell apart. The immutable public identity is instead the instance-wide UID embedded in the resource name `spaces/{space UID}`.
Previously, first-party clients omitted the UID and the server generated a short UUID. That prevented clients from choosing a stable identifier before
creation and made the default differ from other newer UUID-backed identities. Existing clients may still omit the request field, and existing short UIDs
must remain valid without a data migration.
## Decision drivers
- Give clients a stable identifier before they issue a create request.
- Keep old clients and existing Space resource names compatible.
- Reuse the established public-resource UID grammar instead of introducing a Space-only slug format.
- Keep the complete immutable UID discoverable in Settings without adding identity metadata to every compact Space label.
## Decision
The first-party client generates a canonical lowercase UUID v4 for every new Space and sends it in `CreateSpaceRequest.space_id`. It may expose that value
before creation and let the user replace it with a custom UID. A retry of the same create interaction reuses the same generated value.
The API field remains optional for compatibility. When it is empty, the server generates a canonical lowercase UUID v4. A supplied value uses the shared
public-resource grammar:
```text
SpaceUID := Alphanumeric
| Alphanumeric UIDCharacter{0,34} Alphanumeric
UIDCharacter := Alphanumeric | "-"
Alphanumeric := ASCII letter | ASCII digit
```
The UID is therefore 1 through 36 characters. Consecutive interior hyphens, uppercase letters, and digits-only values are valid. The one-character
minimum matches the other public-resource UIDs; increasing it would not materially prevent collisions or name claiming. The spelling is preserved.
The UI uses the title as the primary label. Settings surfaces and their management subflows always show the complete UID with an explicit `Space UID`
label. Other surfaces show the UID only when two known Spaces have exactly matching, case-sensitive titles, or when the title is unavailable and the UID
is the only usable identity. When compact identity metadata is needed, canonical UUIDs use an eight-character prefix; short custom UIDs are shown in
full, while long custom UIDs show both ends so late differences remain visible. Accessible labels and tooltips retain the complete UID in those cases.
Existing Space UIDs remain readable and are not rewritten.
## Consequences
- New first-party creates use UUID v4 by default, while custom identifiers remain concise and human-readable when desired.
- Old clients continue to work through the server fallback.
- Duplicate titles remain allowed and require no rename or uniqueness migration.
- Compact Space labels stay title-only until a matching title or missing-title fallback makes the UID necessary.
- UID collisions are rejected by the existing instance-wide uniqueness constraint.
- Case-preserving UID input is accepted. Aligning exact-case uniqueness and lookup across database collations is a separate schema decision.
## Alternatives considered
- **Require unique titles.** Rejected because titles are display labels and users may legitimately reuse them.
- **Always show UIDs across the UI.** Rejected because it makes a technical identifier compete with the human-readable title even when no ambiguity exists.
- **Only show UIDs in Settings.** Rejected because matching titles would remain ambiguous in switchers, badges, and scoped search.
- **Keep server-only allocation.** Rejected because the first-party client cannot retain one identity across retries or offer customization before creation.
- **Require a longer custom UID.** Rejected because length does not meaningfully solve collision or namespace-claiming concerns.

View file

@ -9,6 +9,7 @@ implementation work is complete.
| --- | --- | --- | --- |
| [0001](0001-tag-syntax-and-recognition.md) | Tag Syntax and Recognition | Accepted | 2026-08-01 |
| [0002](0002-username-format-and-references.md) | Username Format and References | Accepted | 2026-08-02 |
| [0003](0003-space-uid-allocation-and-format.md) | Space UID Allocation and Format | Accepted | 2026-08-27 |
## Conventions

View file

@ -44,6 +44,29 @@ receive no special boundary handling.
A mention candidate whose exact username resolves to a user under the consuming operation's existing account-status and visibility policy. User-targeted
effects apply to the resolved user ID, not to unresolved source text.
## Spaces
### Space
An instance-scoped collaboration boundary for accepted members and memo placement. A Space is not a tenant, folder, or application-wide authorization
role.
### Space ID
The stable internal identity of a Space. It is distinct from the public Space UID and the mutable Space title.
### Space UID
The immutable, instance-wide public identifier assigned when a Space is created. It may be user-defined or generated automatically.
### Space resource name
The API identity of a Space in the form `spaces/{space UID}`.
### Space title
The mutable, non-unique display label of a Space.
## Tags
### Tag

View file

@ -219,7 +219,9 @@ message CreateSpaceRequest {
// Required. The space to create.
Space space = 1 [(google.api.field_behavior) = REQUIRED];
// Optional. The resource ID to use. If empty, one is generated.
// Optional. The space UID to use for this space.
// If empty, a canonical UUID v4 will be generated.
// Format: ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,34}[a-zA-Z0-9])?$
string space_id = 2 [(google.api.field_behavior) = OPTIONAL];
}

View file

@ -299,7 +299,9 @@ type CreateSpaceRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Required. The space to create.
Space *Space `protobuf:"bytes,1,opt,name=space,proto3" json:"space,omitempty"`
// Optional. The resource ID to use. If empty, one is generated.
// Optional. The space UID to use for this space.
// If empty, a canonical UUID v4 will be generated.
// Format: ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,34}[a-zA-Z0-9])?$
SpaceId string `protobuf:"bytes,2,opt,name=space_id,json=spaceId,proto3" json:"space_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache

View file

@ -1346,7 +1346,10 @@ paths:
parameters:
- name: spaceId
in: query
description: Optional. The resource ID to use. If empty, one is generated.
description: |-
Optional. The space UID to use for this space.
If empty, a canonical UUID v4 will be generated.
Format: ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,34}[a-zA-Z0-9])?$
schema:
type: string
requestBody:

View file

@ -183,3 +183,12 @@ func ValidateAndGenerateUID(provided string) (string, error) {
}
return uid, nil
}
// ValidateAndGenerateSpaceUID validates a user-provided Space UID or generates a UUID v4.
// Custom UIDs use the same format as other public-resource UIDs.
func ValidateAndGenerateSpaceUID(provided string) (string, error) {
if strings.TrimSpace(provided) == "" {
return util.GenUUID(), nil
}
return ValidateAndGenerateUID(provided)
}

View file

@ -3,6 +3,7 @@ package v1
import (
"strings"
"testing"
"uuid"
)
func TestValidateAndGenerateUIDValidatesUserProvidedResourceIDs(t *testing.T) {
@ -38,6 +39,37 @@ func TestValidateAndGenerateUIDValidatesUserProvidedResourceIDs(t *testing.T) {
}
}
func TestValidateAndGenerateSpaceUID(t *testing.T) {
for _, provided := range []string{"", " \t\n"} {
generated, err := ValidateAndGenerateSpaceUID(provided)
if err != nil {
t.Fatalf("ValidateAndGenerateSpaceUID(%q) returned error: %v", provided, err)
}
parsed, err := uuid.Parse(generated)
if err != nil {
t.Fatalf("ValidateAndGenerateSpaceUID(%q) = %q, want UUID: %v", provided, generated, err)
}
if parsed.String() != generated {
t.Fatalf("ValidateAndGenerateSpaceUID(%q) = %q, want canonical lowercase UUID", provided, generated)
}
if parsed[6]>>4 != 4 {
t.Fatalf("ValidateAndGenerateSpaceUID(%q) = %q, want UUID v4", provided, generated)
}
}
custom, err := ValidateAndGenerateSpaceUID(" Team-Notes ")
if err != nil {
t.Fatalf("ValidateAndGenerateSpaceUID() returned error for valid custom UID: %v", err)
}
if custom != "Team-Notes" {
t.Fatalf("ValidateAndGenerateSpaceUID() = %q, want %q", custom, "Team-Notes")
}
if _, err := ValidateAndGenerateSpaceUID("team_notes"); err == nil {
t.Fatal("ValidateAndGenerateSpaceUID() succeeded for invalid custom UID, want error")
}
}
func TestExtractSpaceUIDFromName(t *testing.T) {
uid, err := ExtractSpaceUIDFromName("spaces/team-notes")
if err != nil {

View file

@ -94,7 +94,7 @@ func (s *APIV1Service) CreateSpace(ctx context.Context, request *v1pb.CreateSpac
if title == "" {
return nil, status.Error(codes.InvalidArgument, "space title is required")
}
uid, err := ValidateAndGenerateUID(request.SpaceId)
uid, err := ValidateAndGenerateSpaceUID(request.SpaceId)
if err != nil {
return nil, err
}

View file

@ -8,6 +8,7 @@ import (
"path/filepath"
"strings"
"testing"
"uuid"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"github.com/stretchr/testify/require"
@ -52,6 +53,27 @@ func inviteAndAcceptSpaceTestUser(ctx context.Context, t *testing.T, service *AP
return membership
}
func TestCreateSpaceGeneratesUUIDV4WhenSpaceUIDIsEmpty(t *testing.T) {
ctx := context.Background()
service := newIntegrationService(t)
owner := createSpaceTestUser(ctx, t, service, "space-id-owner", store.RoleUser)
for _, spaceUID := range []string{"", " \t\n"} {
space, err := service.CreateSpace(userCtx(ctx, owner.ID), &v1pb.CreateSpaceRequest{
SpaceId: spaceUID,
Space: &v1pb.Space{Title: "Same title"},
})
require.NoError(t, err)
uid, err := ExtractSpaceUIDFromName(space.Name)
require.NoError(t, err)
parsed, err := uuid.Parse(uid)
require.NoError(t, err)
require.Equal(t, parsed.String(), uid, "generated Space UID must be a canonical lowercase UUID")
require.Equal(t, byte(4), parsed[6]>>4, "generated Space UID must be UUID v4")
}
}
func TestSpaceServiceMembershipVisibilityAndGovernance(t *testing.T) {
ctx := context.Background()
service := newIntegrationService(t)

View file

@ -44,7 +44,7 @@
"leaflet": "^1.9.4",
"leaflet.markercluster": "^1.5.3",
"lodash-es": "^4.18.1",
"lucide-react": "^1.14.0",
"lucide-react": "^1.34.0",
"mdast-util-from-markdown": "^2.0.3",
"mdast-util-gfm": "^3.1.0",
"mermaid": "^11.14.0",

View file

@ -93,8 +93,8 @@ importers:
specifier: ^4.18.1
version: 4.18.1
lucide-react:
specifier: ^1.14.0
version: 1.14.0(react@19.2.6)
specifier: ^1.34.0
version: 1.34.0(react@19.2.6)
mdast-util-from-markdown:
specifier: ^2.0.3
version: 2.0.3
@ -1859,8 +1859,8 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
lucide-react@1.14.0:
resolution: {integrity: sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==}
lucide-react@1.34.0:
resolution: {integrity: sha512-vnjGJNI7Htk5+oWW8gXGuaLgwgAb0T6/iZbBrp9JCfRFwdNWZ0YTm3eyxjOLgwN6r8iyAf3UA70zNmBRBNv7yg==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
@ -4126,7 +4126,7 @@ snapshots:
dependencies:
yallist: 3.1.1
lucide-react@1.14.0(react@19.2.6):
lucide-react@1.34.0(react@19.2.6):
dependencies:
react: 19.2.6

View file

@ -10,6 +10,7 @@ import { useSpaceContext } from "@/contexts/SpaceContext";
import useCurrentUser from "@/hooks/useCurrentUser";
import { useMemoViews } from "@/hooks/useUserQueries";
import { BUILTIN_TASKS_VIEW_ID, getMemoViewId, isMemoScopeRoute } from "@/lib/memo-views";
import { extractSpaceUidFromName, formatSpaceUidForDisplay } from "@/lib/space-display";
import { useTranslate } from "@/utils/i18n";
import { getRouteActionPolicy, getSidebarRouteKind } from "./routes";
@ -55,7 +56,7 @@ const QuickFindDialog = () => {
const currentUser = useCurrentUser();
const { data: memoViews = [] } = useMemoViews(currentUser?.name);
const { filters, setFilters, setMemoView, memoView } = useMemoFilterContext();
const { clearSelectedSpace, selectedSpace, selectedSpaceName } = useSpaceContext();
const { clearSelectedSpace, duplicateSpaceTitles, selectedSpace, selectedSpaceName } = useSpaceContext();
const { quickFindOpen, setQuickFindOpen } = useAppSidebar();
const [query, setQuery] = useState("");
const viewApplies = isMemoScopeRoute(location.pathname);
@ -63,10 +64,19 @@ const QuickFindDialog = () => {
const lensLabel =
viewApplies && memoView === BUILTIN_TASKS_VIEW_ID ? t("common.tasks") : selectedMemoView?.title || getScopeLabel(location.pathname, t);
const routePolicy = getRouteActionPolicy(location.pathname);
const selectedSpaceUid = selectedSpaceName ? extractSpaceUidFromName(selectedSpaceName) : "";
const selectedSpaceUidDisplay = selectedSpaceName ? formatSpaceUidForDisplay(selectedSpaceName) : "";
const showSelectedSpaceUid = selectedSpace ? duplicateSpaceTitles.has(selectedSpace.title) : Boolean(selectedSpaceName);
const selectedSpaceLabel = `${selectedSpace?.title || t("space.current")}${
showSelectedSpaceUid && selectedSpaceUid ? ` (${selectedSpaceUid})` : ""
}`;
const compactSelectedSpaceLabel = `${selectedSpace?.title || t("space.current")}${
showSelectedSpaceUid && selectedSpaceUidDisplay ? ` (${selectedSpaceUidDisplay})` : ""
}`;
const scopeLabel =
routePolicy.searchScope === "remembered-collection" && selectedSpaceName
? `${selectedSpace?.title || t("space.current")} · ${lensLabel}`
: lensLabel;
routePolicy.searchScope === "remembered-collection" && selectedSpaceName ? `${selectedSpaceLabel} · ${lensLabel}` : lensLabel;
const compactScopeLabel =
routePolicy.searchScope === "remembered-collection" && selectedSpaceName ? `${compactSelectedSpaceLabel} · ${lensLabel}` : lensLabel;
useEffect(() => {
if (!quickFindOpen) return;
@ -125,7 +135,7 @@ const QuickFindDialog = () => {
submitQuery();
}}
className="h-10 border-0 bg-transparent px-0 !text-[14px] shadow-none focus-visible:ring-0"
placeholder={`${t("common.search")} ${scopeLabel}`}
placeholder={`${t("common.search")} ${compactScopeLabel}`}
aria-label={`${t("common.search")} ${scopeLabel}`}
/>
<Button

View file

@ -13,6 +13,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useSpaceContext } from "@/contexts/SpaceContext";
import { extractSpaceUidFromName, formatSpaceUidForDisplay } from "@/lib/space-display";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
@ -24,8 +25,25 @@ const RowIcon = ({ icon: Icon, className }: { icon: LucideIcon; className?: stri
</span>
);
const ContextItem = ({ selected, onSelect, children }: { selected: boolean; onSelect: () => void; children: ReactNode }) => (
<DropdownMenuItem role="menuitemradio" aria-checked={selected} closeOnClick onClick={onSelect} className={cn(selected && "bg-accent/60")}>
const ContextItem = ({
selected,
onSelect,
children,
ariaLabel,
}: {
selected: boolean;
onSelect: () => void;
children: ReactNode;
ariaLabel?: string;
}) => (
<DropdownMenuItem
role="menuitemradio"
aria-checked={selected}
aria-label={ariaLabel}
closeOnClick
onClick={onSelect}
className={cn(selected && "bg-accent/60")}
>
{children}
{selected && <CheckIcon className="ms-auto size-3.5 shrink-0 text-primary" />}
</DropdownMenuItem>
@ -33,9 +51,15 @@ const ContextItem = ({ selected, onSelect, children }: { selected: boolean; onSe
function SpaceSwitcher({ className }: { className?: string }) {
const t = useTranslate();
const { spaces, selectedSpace, selectedSpaceName, isLoadingSpaces, isSpacesError, selectMemos, selectSpace } = useSpaceContext();
const { spaces, duplicateSpaceTitles, selectedSpace, selectedSpaceName, isLoadingSpaces, isSpacesError, selectMemos, selectSpace } =
useSpaceContext();
const [createOpen, setCreateOpen] = useState(false);
const currentContextLabel = selectedSpaceName ? selectedSpace?.title || t("space.current") : t("common.memos");
const selectedSpaceIdentity = selectedSpace?.name || selectedSpaceName || "";
const selectedSpaceUid = selectedSpaceIdentity ? extractSpaceUidFromName(selectedSpaceIdentity) : "";
const showSelectedSpaceUid = selectedSpace ? duplicateSpaceTitles.has(selectedSpace.title) : Boolean(selectedSpaceName);
const currentContextLabel = selectedSpaceName
? `${selectedSpace?.title || t("space.current")}${showSelectedSpaceUid && selectedSpaceUid ? ` (${selectedSpaceUid})` : ""}`
: t("common.memos");
return (
<>
@ -55,10 +79,21 @@ function SpaceSwitcher({ className }: { className?: string }) {
<span className="flex min-w-0 flex-1 items-center">
{selectedSpaceName ? (
<>
<SpaceMark space={selectedSpace} />
<span className="ms-1.5 min-w-0 truncate text-[14px] font-medium tracking-[-0.01em] text-foreground">
<SpaceMark />
<span className="ms-1.5 flex min-w-0 flex-1 flex-col justify-center">
<span className="block truncate text-[14px] font-medium leading-4 tracking-[-0.01em] text-foreground">
{selectedSpace?.title || t("space.current")}
</span>
{showSelectedSpaceUid && selectedSpaceUid ? (
<span
aria-hidden="true"
title={selectedSpaceUid}
className="block truncate font-mono text-[10px] leading-3 text-muted-foreground"
>
{formatSpaceUidForDisplay(selectedSpaceIdentity)}
</span>
) : null}
</span>
</>
) : (
<MemosLogo compact />
@ -76,12 +111,33 @@ function SpaceSwitcher({ className }: { className?: string }) {
{spaces.length > 0 && (
<>
<DropdownMenuLabel className="pb-0.5 pt-1.5 font-normal text-muted-foreground">{t("space.spaces")}</DropdownMenuLabel>
{spaces.map((space) => (
<ContextItem key={space.name} selected={space.name === selectedSpaceName} onSelect={() => selectSpace(space)}>
<SpaceMark space={space} size="sm" />
<span className="min-w-0 flex-1 truncate font-medium">{space.title}</span>
{spaces.map((space) => {
const uid = extractSpaceUidFromName(space.name);
const showUid = duplicateSpaceTitles.has(space.title);
return (
<ContextItem
key={space.name}
selected={space.name === selectedSpaceName}
onSelect={() => selectSpace(space)}
ariaLabel={showUid && uid ? `${space.title} (${uid})` : space.title}
>
<SpaceMark size="sm" />
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">{space.title}</span>
{showUid && uid ? (
<span
aria-hidden="true"
title={uid}
className="block truncate font-mono text-[10px] leading-3.5 text-muted-foreground"
>
{formatSpaceUidForDisplay(space.name)}
</span>
) : null}
</span>
</ContextItem>
))}
);
})}
</>
)}
</DropdownMenuGroup>

View file

@ -1,5 +1,8 @@
import { Code, ConnectError } from "@connectrpc/connect";
import { ChevronDownIcon } from "lucide-react";
import { type FormEvent, useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { v4 as uuidv4 } from "uuid";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
@ -8,9 +11,12 @@ import { Textarea } from "@/components/ui/textarea";
import useCurrentUser from "@/hooks/useCurrentUser";
import { useCreateSpace } from "@/hooks/useSpaceQueries";
import { handleError } from "@/lib/error";
import { cn } from "@/lib/utils";
import type { Space } from "@/types/proto/api/v1/space_service_pb";
import { useTranslate } from "@/utils/i18n";
const SPACE_UID_PATTERN = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,34}[a-zA-Z0-9])?$/;
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
@ -24,28 +30,42 @@ function CreateSpaceDialog({ open, onOpenChange, onCreated, note }: Props) {
const createSpace = useCreateSpace(currentUserName);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [spaceUid, setSpaceUid] = useState(() => uuidv4());
const [showCustomId, setShowCustomId] = useState(false);
const [spaceUidConflict, setSpaceUidConflict] = useState(false);
const isSpaceUidValid = SPACE_UID_PATTERN.test(spaceUid);
const hasSpaceUidError = !isSpaceUidValid || spaceUidConflict;
useEffect(() => {
if (!open) {
setTitle("");
setDescription("");
setSpaceUid(uuidv4());
setShowCustomId(false);
setSpaceUidConflict(false);
}
}, [open]);
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const trimmedTitle = title.trim();
if (!trimmedTitle || createSpace.isPending) {
if (!trimmedTitle || hasSpaceUidError || createSpace.isPending) {
return;
}
let space: Space;
try {
setSpaceUidConflict(false);
space = await createSpace.mutateAsync({
title: trimmedTitle,
description: description.trim() || undefined,
spaceId: spaceUid,
});
} catch (error) {
if (error instanceof ConnectError && error.code === Code.AlreadyExists) {
setSpaceUidConflict(true);
setShowCustomId(true);
}
handleError(error, toast.error, { context: "Create space" });
return;
}
@ -91,12 +111,52 @@ function CreateSpaceDialog({ open, onOpenChange, onCreated, note }: Props) {
className="min-h-20 resize-none"
/>
</div>
<div>
<Button
type="button"
variant="ghost"
size="sm"
className="-mx-2 px-2 text-muted-foreground"
aria-expanded={showCustomId}
aria-controls="space-custom-uid"
onClick={() => setShowCustomId((visible) => (hasSpaceUidError ? true : !visible))}
>
<ChevronDownIcon className={cn("transition-transform", showCustomId && "rotate-180")} />
{t("space.custom-id-toggle")}
</Button>
{showCustomId && (
<div id="space-custom-uid" className="mt-2 grid gap-2 rounded-md border bg-muted/30 p-3">
<Label htmlFor="space-uid">{t("space.custom-id-label")}</Label>
<Input
id="space-uid"
value={spaceUid}
onChange={(event) => {
setSpaceUid(event.target.value);
setSpaceUidConflict(false);
}}
maxLength={36}
autoComplete="off"
spellCheck={false}
aria-invalid={hasSpaceUidError}
aria-describedby="space-uid-help"
className="font-mono text-xs aria-invalid:border-destructive"
/>
<p
id="space-uid-help"
role={hasSpaceUidError ? "alert" : undefined}
className={cn("text-xs leading-5", hasSpaceUidError ? "text-destructive" : "text-muted-foreground")}
>
{t(!isSpaceUidValid ? "space.custom-id-invalid" : spaceUidConflict ? "space.custom-id-conflict" : "space.custom-id-help")}
</p>
</div>
)}
</div>
<p className="text-xs text-muted-foreground">{note ?? t("space.creator-admin-note")}</p>
<DialogFooter>
<Button type="button" variant="ghost" disabled={createSpace.isPending} onClick={() => onOpenChange(false)}>
{t("common.cancel")}
</Button>
<Button type="submit" disabled={!title.trim() || createSpace.isPending}>
<Button type="submit" disabled={!title.trim() || hasSpaceUidError || createSpace.isPending}>
{t("common.create")}
</Button>
</DialogFooter>

View file

@ -1,6 +1,8 @@
import { UserLockIcon } from "lucide-react";
import { AstroidIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { useSpaceContext } from "@/contexts/SpaceContext";
import { extractSpaceUidFromName, formatSpaceUidForDisplay } from "@/lib/space-display";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
interface MemoSpaceBadgeProps {
@ -9,27 +11,42 @@ interface MemoSpaceBadgeProps {
const MemoSpaceBadge = ({ spaceName }: MemoSpaceBadgeProps) => {
const t = useTranslate();
const { spaces } = useSpaceContext();
const { duplicateSpaceTitles, spaceByName } = useSpaceContext();
if (!spaceName) return null;
const spaceLabel = t("space.current");
const knownTitle = spaces.find((space) => space.name === spaceName)?.title.trim();
const knownSpace = spaceByName.get(spaceName);
const knownTitle = knownSpace?.title.trim();
const identityName = knownSpace?.name ?? spaceName;
const uid = extractSpaceUidFromName(identityName);
const title = knownTitle || spaceLabel;
const accessibleLabel = knownTitle ? `${spaceLabel}: ${knownTitle}` : spaceLabel;
const showUid = !knownSpace || !knownTitle || duplicateSpaceTitles.has(knownSpace.title);
const accessibleLabel = `${knownTitle ? `${spaceLabel}: ${knownTitle}` : spaceLabel}${showUid && uid ? ` (${uid})` : ""}`;
return (
<Badge
variant="outline"
shape="pill"
title={accessibleLabel}
className="min-w-0 max-w-24 shrink gap-1 border-border/60 bg-muted/30 px-1.5 py-0 text-[11px] font-normal text-muted-foreground sm:max-w-32"
className={cn(
"min-w-0 shrink gap-1 border-border/60 bg-muted/30 px-1.5 py-0 text-[11px] font-normal text-muted-foreground",
showUid && uid ? "max-w-44 sm:max-w-52" : "max-w-24 sm:max-w-32",
)}
>
<UserLockIcon aria-hidden="true" className="size-3 shrink-0" strokeWidth={1.8} />
<span className="truncate">
<AstroidIcon aria-hidden="true" className="size-3 shrink-0" strokeWidth={1.8} />
<span className="min-w-0 truncate">
{knownTitle && <span className="sr-only">{spaceLabel}: </span>}
{title}
</span>
{showUid && uid ? (
<>
<span aria-hidden="true" className="max-w-32 shrink-0 truncate font-mono">
{` · ${formatSpaceUidForDisplay(identityName)}`}
</span>
<span className="sr-only"> ({uid})</span>
</>
) : null}
</Badge>
);
};

View file

@ -11,6 +11,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { useCreateSpaceInvitation } from "@/hooks/useSpaceQueries";
import { useUsersByUsernames } from "@/hooks/useUserQueries";
import { handleError } from "@/lib/error";
import { extractSpaceUidFromName } from "@/lib/space-display";
import { type Space, SpaceInvitationSchema, SpaceMember_Role } from "@/types/proto/api/v1/space_service_pb";
import { useTranslate } from "@/utils/i18n";
@ -27,6 +28,7 @@ interface Props {
const InviteSpaceMemberDialog = ({ open, onOpenChange, space, viewerName, memberUserNames, pendingInviteeNames }: Props) => {
const t = useTranslate();
const spaceUid = extractSpaceUidFromName(space.name);
const createInvitation = useCreateSpaceInvitation(viewerName);
const [query, setQuery] = useState("");
const [lookupUsername, setLookupUsername] = useState("");
@ -109,7 +111,12 @@ const InviteSpaceMemberDialog = ({ open, onOpenChange, space, viewerName, member
<form className="grid gap-4" onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>{t("setting.spaces.invite-to", { space: space.title })}</DialogTitle>
<DialogDescription>{t("setting.spaces.invite-description")}</DialogDescription>
<DialogDescription>
{t("setting.spaces.invite-description")}
<span className="mt-1 block text-xs">
{t("space.custom-id-label")}: <span className="break-all font-mono">{spaceUid}</span>
</span>
</DialogDescription>
</DialogHeader>
<div className="grid gap-2">

View file

@ -42,6 +42,7 @@ import {
import { useUsersByUsernames } from "@/hooks/useUserQueries";
import { handleError } from "@/lib/error";
import { extractUsernameFromName } from "@/lib/resource-names";
import { extractSpaceUidFromName } from "@/lib/space-display";
import { ROUTES } from "@/router/routes";
import { type Space, type SpaceInvitation, type SpaceMember, SpaceMember_Role } from "@/types/proto/api/v1/space_service_pb";
import type { User } from "@/types/proto/api/v1/user_service_pb";
@ -129,7 +130,10 @@ const SpacesSection = () => {
const handleAcceptInvitation = async (invitation: SpaceInvitation) => {
try {
await acceptInvitation.mutateAsync({ name: invitation.name });
toast.success(t("setting.spaces.accept-success", { space: invitation.space?.title ?? t("setting.spaces.label") }));
const spaceLabel = invitation.space
? `${invitation.space.title} (${extractSpaceUidFromName(invitation.space.name)})`
: t("setting.spaces.label");
toast.success(t("setting.spaces.accept-success", { space: spaceLabel }));
} catch (error) {
handleError(error, toast.error, { context: "Accept space invitation" });
}
@ -187,15 +191,27 @@ const SpacesSection = () => {
</span>
</div>
<div className="divide-y divide-border overflow-hidden rounded-lg border border-border bg-background">
{receivedInvitations.map((invitation) => (
{receivedInvitations.map((invitation) => {
const uid = invitation.space ? extractSpaceUidFromName(invitation.space.name) : "";
const title = invitation.space?.title || t("setting.spaces.untitled");
return (
<div key={invitation.name} className="flex min-w-0 flex-col gap-3 px-3 py-3 sm:flex-row sm:items-center">
<div className="flex min-w-0 flex-1 items-center gap-3">
<SpaceMark space={invitation.space} size="lg" />
<SpaceMark size="lg" />
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="truncate text-sm font-medium">{invitation.space?.title || t("setting.spaces.untitled")}</p>
<p className="truncate text-sm font-medium">{title}</p>
<SpaceRoleBadge role={invitation.role} />
</div>
{uid ? (
<p className="mt-0.5 flex min-w-0 items-baseline gap-1 text-[11px] text-muted-foreground">
<span className="shrink-0">{t("space.custom-id-label")}:</span>
<span title={uid} className="min-w-0 break-all font-mono">
{uid}
</span>
</p>
) : null}
<p className="mt-1 truncate text-xs leading-5 text-muted-foreground">
{invitation.space?.description || t("setting.spaces.invited-to-join")}
</p>
@ -205,6 +221,7 @@ const SpacesSection = () => {
<Button
variant="ghost"
size="sm"
aria-label={uid ? `${t("setting.spaces.decline")} ${title} (${uid})` : undefined}
disabled={acceptInvitation.isPending || declineInvitation.isPending}
onClick={() => void handleDeclineInvitation(invitation)}
>
@ -212,6 +229,7 @@ const SpacesSection = () => {
</Button>
<Button
size="sm"
aria-label={uid ? `${t("setting.spaces.accept")} ${title} (${uid})` : undefined}
disabled={acceptInvitation.isPending || declineInvitation.isPending}
onClick={() => void handleAcceptInvitation(invitation)}
>
@ -219,7 +237,8 @@ const SpacesSection = () => {
</Button>
</div>
</div>
))}
);
})}
</div>
</section>
) : null}
@ -257,20 +276,30 @@ const SpacesSection = () => {
</div>
) : (
<div className="divide-y divide-border overflow-hidden rounded-lg border border-border bg-background">
{spaces.map((space) => (
{spaces.map((space) => {
const uid = extractSpaceUidFromName(space.name);
const manageLabel = t("setting.spaces.manage-space", { space: space.title });
return (
<button
key={space.name}
type="button"
aria-label={t("setting.spaces.manage-space", { space: space.title })}
aria-label={uid ? `${manageLabel} (${uid})` : manageLabel}
onClick={() => handleOpenSpace(space.name)}
className="group flex w-full min-w-0 items-center gap-3 px-3 py-3 text-left transition-colors hover:bg-muted/35 focus-visible:bg-muted/35 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/45"
>
<SpaceMark space={space} size="lg" />
<SpaceMark size="lg" />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<p className="truncate text-sm font-medium">{space.title}</p>
<SpaceRoleBadge role={space.currentUserRole} />
</div>
<p className="mt-0.5 flex min-w-0 items-baseline gap-1 text-[11px] text-muted-foreground">
<span className="shrink-0">{t("space.custom-id-label")}:</span>
<span title={uid} className="min-w-0 break-all font-mono">
{uid}
</span>
</p>
<p className="mt-1 truncate text-xs text-muted-foreground">
{space.description || t("setting.spaces.no-description")}
</p>
@ -282,7 +311,8 @@ const SpacesSection = () => {
</span>
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground/65 transition-transform group-hover:translate-x-0.5" />
</button>
))}
);
})}
</div>
)}
</section>
@ -347,6 +377,8 @@ const SpaceDetail = ({ space, viewerName, onBack }: SpaceDetailProps) => {
{ value: String(SpaceMember_Role.ADMIN), label: t("setting.spaces.space-admin") },
];
const detailsChanged = title.trim() !== space.title || description.trim() !== space.description;
const spaceUid = extractSpaceUidFromName(space.name);
const disambiguatedSpaceTitle = `${space.title} (${spaceUid})`;
useEffect(() => {
setTitle(space.title);
@ -405,7 +437,7 @@ const SpaceDetail = ({ space, viewerName, onBack }: SpaceDetailProps) => {
if (!currentMember) return;
try {
await deleteMember.mutateAsync({ name: currentMember.name });
toast.success(t("setting.spaces.leave-success", { space: space.title }));
toast.success(t("setting.spaces.leave-success", { space: disambiguatedSpaceTitle }));
onBack();
} catch (error) {
handleError(error, toast.error, { context: "Leave space" });
@ -416,7 +448,7 @@ const SpaceDetail = ({ space, viewerName, onBack }: SpaceDetailProps) => {
const handleDeleteSpace = async () => {
try {
await deleteSpace.mutateAsync({ name: space.name });
toast.success(t("setting.spaces.delete-success", { space: space.title }));
toast.success(t("setting.spaces.delete-success", { space: disambiguatedSpaceTitle }));
onBack();
} catch (error) {
handleError(error, toast.error, { context: "Delete space" });
@ -436,12 +468,18 @@ const SpaceDetail = ({ space, viewerName, onBack }: SpaceDetailProps) => {
</button>
<header className="flex min-w-0 flex-col gap-3 border-b border-border/70 pb-4 sm:flex-row sm:items-center">
<SpaceMark space={space} size="xl" />
<SpaceMark size="xl" />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="truncate text-lg font-semibold tracking-tight">{space.title}</h3>
<SpaceRoleBadge role={currentRole} />
</div>
<p className="mt-1 flex min-w-0 items-baseline gap-1 text-xs text-muted-foreground">
<span className="shrink-0">{t("space.custom-id-label")}:</span>
<span title={spaceUid} className="min-w-0 break-all font-mono">
{spaceUid}
</span>
</p>
<p className="mt-1 truncate text-sm text-muted-foreground">{space.description || t("setting.spaces.no-description")}</p>
</div>
</header>
@ -466,6 +504,12 @@ const SpaceDetail = ({ space, viewerName, onBack }: SpaceDetailProps) => {
<p className="mt-1 text-xs leading-5 text-muted-foreground">{t("setting.spaces.space-details-description")}</p>
</div>
<div className="overflow-hidden rounded-lg border border-border bg-background">
<div className="grid gap-2 border-b border-border px-3 py-3 sm:grid-cols-[170px_1fr] sm:items-center">
<span className="text-sm font-medium">{t("space.custom-id-label")}</span>
<code className="min-w-0 break-all rounded-md bg-muted/35 px-3 py-2 font-mono text-xs leading-5 text-foreground/85">
{spaceUid}
</code>
</div>
<div className="grid gap-2 border-b border-border px-3 py-3 sm:grid-cols-[170px_1fr] sm:items-center">
<Label htmlFor="space-settings-title">{t("common.name")}</Label>
<Input
@ -685,7 +729,9 @@ const SpaceDetail = ({ space, viewerName, onBack }: SpaceDetailProps) => {
<ConfirmDialog
open={leaveOpen}
onOpenChange={setLeaveOpen}
title={t("setting.spaces.leave-confirm-title", { space: space.title })}
title={
<span className="[overflow-wrap:anywhere]">{t("setting.spaces.leave-confirm-title", { space: disambiguatedSpaceTitle })}</span>
}
description={t("setting.spaces.leave-confirm-description")}
confirmLabel={t("setting.spaces.leave")}
cancelLabel={t("common.cancel")}
@ -695,7 +741,9 @@ const SpaceDetail = ({ space, viewerName, onBack }: SpaceDetailProps) => {
<ConfirmDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
title={t("setting.spaces.delete-confirm-title", { space: space.title })}
title={
<span className="[overflow-wrap:anywhere]">{t("setting.spaces.delete-confirm-title", { space: disambiguatedSpaceTitle })}</span>
}
description={t("setting.spaces.delete-confirm-description")}
confirmLabel={t("common.delete")}
cancelLabel={t("common.cancel")}

View file

@ -1,6 +1,6 @@
import {
AstroidIcon,
BarChart3Icon,
BoxesIcon,
CogIcon,
DatabaseIcon,
HeartHandshakeIcon,
@ -71,7 +71,7 @@ export const SETTINGS_SECTIONS: SettingSectionDefinition[] = [
key: "spaces",
scope: "basic",
labelKey: "setting.spaces.label",
icon: BoxesIcon,
icon: AstroidIcon,
component: SpacesSection,
},
{

View file

@ -1,33 +1,27 @@
import { AstroidIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import type { Space } from "@/types/proto/api/v1/space_service_pb";
const MARK_SCALE = {
xl: "size-11 rounded-lg text-base",
lg: "size-9 rounded-[7px] text-sm",
md: "size-7 rounded-[7px] text-[13px]",
sm: "size-5 rounded-[5px] text-[11px]",
xl: { mark: "size-11 rounded-lg", icon: "size-6" },
lg: { mark: "size-9 rounded-[7px]", icon: "size-5" },
md: { mark: "size-7 rounded-[7px]", icon: "size-4" },
sm: { mark: "size-5 rounded-[5px]", icon: "size-3" },
} as const;
interface Props {
space?: Pick<Space, "title">;
size?: keyof typeof MARK_SCALE;
className?: string;
}
const SpaceMark = ({ space, size = "md", className }: Props) => {
const label = space?.title.trim() || "S";
const initial = Array.from(label)[0]?.toLocaleUpperCase() ?? "S";
const SpaceMark = ({ size = "md", className }: Props) => {
const scale = MARK_SCALE[size];
return (
<span
aria-hidden
className={cn(
"flex shrink-0 items-center justify-center bg-sidebar-accent font-semibold text-sidebar-accent-foreground",
MARK_SCALE[size],
className,
)}
className={cn("flex shrink-0 items-center justify-center bg-sidebar-accent text-sidebar-accent-foreground", scale.mark, className)}
>
{initial}
<AstroidIcon className={scale.icon} strokeWidth={1.8} />
</span>
);
};

View file

@ -4,6 +4,7 @@ import { routeSupportsCollectionScope } from "@/components/AppSidebar/routes";
import useCurrentUser from "@/hooks/useCurrentUser";
import { useSpaces } from "@/hooks/useSpaceQueries";
import { buildCollectionScopeFilter, type CollectionScope } from "@/lib/cel-filter";
import { getDuplicateSpaceTitles } from "@/lib/space-display";
import { ROUTES } from "@/router/routes";
import type { Space } from "@/types/proto/api/v1/space_service_pb";
@ -35,6 +36,8 @@ const writeSelectedSpaceName = (userName: string, spaceName: string | undefined)
interface SpaceContextValue {
spaces: Space[];
spaceByName: ReadonlyMap<string, Space>;
duplicateSpaceTitles: ReadonlySet<string>;
selectedSpace?: Space;
selectedSpaceName?: string;
collectionScope: CollectionScope;
@ -52,6 +55,8 @@ const SpaceContext = createContext<SpaceContextValue | null>(null);
// Stable identity for the pre-load and error states, so the memoized context value
// below does not rebuild — and re-render every consumer — on each provider render.
const NO_SPACES: Space[] = [];
const NO_SPACE_BY_NAME: ReadonlyMap<string, Space> = new Map();
const NO_DUPLICATE_SPACE_TITLES: ReadonlySet<string> = new Set();
function UserSpaceSession({ userName, children }: { userName: string; children: ReactNode }) {
// Keep router values in refs so switching scope does not make the context callbacks
@ -67,8 +72,13 @@ function UserSpaceSession({ userName, children }: { userName: string; children:
const [optimisticSpace, setOptimisticSpace] = useState<Space>();
const spacesQuery = useSpaces(userName);
const spaces = spacesQuery.data ?? NO_SPACES;
const spaceByName = useMemo(() => new Map(spaces.map((space) => [space.name, space])), [spaces]);
const listedSelectedSpace = spaces.find((space) => space.name === selectedSpaceName);
const selectedSpace = listedSelectedSpace ?? (optimisticSpace?.name === selectedSpaceName ? optimisticSpace : undefined);
const duplicateSpaceTitles = useMemo(
() => getDuplicateSpaceTitles(selectedSpace && !spaceByName.has(selectedSpace.name) ? [...spaces, selectedSpace] : spaces),
[selectedSpace, spaceByName, spaces],
);
const collectionScope = useMemo<CollectionScope>(
() => (selectedSpaceName ? { kind: "space", name: selectedSpaceName } : ALL_COLLECTION_SCOPE),
[selectedSpaceName],
@ -121,6 +131,8 @@ function UserSpaceSession({ userName, children }: { userName: string; children:
const value = useMemo<SpaceContextValue>(
() => ({
spaces,
spaceByName,
duplicateSpaceTitles,
selectedSpace,
selectedSpaceName,
collectionScope,
@ -134,10 +146,12 @@ function UserSpaceSession({ userName, children }: { userName: string; children:
[
clearSelectedSpace,
collectionScope,
duplicateSpaceTitles,
selectMemos,
selectSpace,
selectedSpace,
selectedSpaceName,
spaceByName,
spaces,
spacesQuery.isError,
spacesQuery.isPending,
@ -149,6 +163,8 @@ function UserSpaceSession({ userName, children }: { userName: string; children:
const anonymousValue: SpaceContextValue = {
spaces: [],
spaceByName: NO_SPACE_BY_NAME,
duplicateSpaceTitles: NO_DUPLICATE_SPACE_TITLES,
collectionScope: ALL_COLLECTION_SCOPE,
isLoadingSpaces: false,
isSpacesError: false,

View file

@ -198,9 +198,10 @@ export function useCreateSpace(viewerName: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ title, description }: { title: string; description?: string }) =>
mutationFn: ({ title, description, spaceId }: { title: string; description?: string; spaceId: string }) =>
spaceServiceClient.createSpace({
space: create(SpaceSchema, { title, description }),
spaceId,
}),
onSuccess: (space) => {
queryClient.setQueryData<Space[]>(spaceKeys.list(viewerName), (spaces = []) => upsertByName(spaces, space));

View file

@ -0,0 +1,30 @@
const UUID_PATTERN = /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i;
const LONG_CUSTOM_UID_LENGTH = 18;
export const extractSpaceUidFromName = (name: string): string => name.split("/").at(-1) ?? "";
export const getDuplicateSpaceTitles = (spaces: readonly { title: string }[]): ReadonlySet<string> => {
const seen = new Set<string>();
const duplicates = new Set<string>();
for (const space of spaces) {
if (seen.has(space.title)) {
duplicates.add(space.title);
} else {
seen.add(space.title);
}
}
return duplicates;
};
export const formatSpaceUidForDisplay = (name: string): string => {
const uid = extractSpaceUidFromName(name);
if (UUID_PATTERN.test(uid)) {
return `${uid.slice(0, 8)}…`;
}
if (uid.length > LONG_CUSTOM_UID_LENGTH) {
return `${uid.slice(0, 8)}…${uid.slice(-6)}`;
}
return uid;
};

View file

@ -1038,6 +1038,11 @@
"create-success": "Space created",
"creator-admin-note": "You'll become the first admin.",
"current": "Space",
"custom-id-conflict": "This Space UID is already in use.",
"custom-id-help": "Used in links and API requests. It can't be changed later. Use 1–36 ASCII letters, numbers, or hyphens; don't start or end with a hyphen.",
"custom-id-invalid": "Use 1–36 ASCII letters, numbers, or hyphens, without a hyphen at the beginning or end.",
"custom-id-label": "Space UID",
"custom-id-toggle": "Customize space UID",
"description-placeholder": "What is this space for?",
"load-error": "Couldn't load spaces",
"loading": "Loading spaces…",

View file

@ -185,7 +185,9 @@ export type CreateSpaceRequest = Message<"memos.api.v1.CreateSpaceRequest"> & {
space?: Space | undefined;
/**
* Optional. The resource ID to use. If empty, one is generated.
* Optional. The space UID to use for this space.
* If empty, a canonical UUID v4 will be generated.
* Format: ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,34}[a-zA-Z0-9])?$
*
* @generated from field: string space_id = 2;
*/

View file

@ -96,11 +96,19 @@ vi.mock("@/contexts/MemoFilterContext", () => ({
}));
vi.mock("@/contexts/SpaceContext", () => ({
useSpaceContext: () => ({
useSpaceContext: () => {
const duplicateSpaceTitles = new Set(
spaceState.spaces
.filter((space, index) => spaceState.spaces.findIndex((candidate) => candidate.title === space.title) !== index)
.map((space) => space.title),
);
return {
...spaceState,
duplicateSpaceTitles,
isLoadingSpaces: false,
isSpacesError: false,
}),
};
},
}));
vi.mock("@/hooks/useCurrentUser", () => ({

View file

@ -1,3 +1,4 @@
import { Code, ConnectError } from "@connectrpc/connect";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import CreateSpaceDialog from "@/components/CreateSpaceDialog";
@ -7,6 +8,7 @@ const mocks = vi.hoisted(() => ({
onOpenChange: vi.fn(),
onCreated: vi.fn(),
toastSuccess: vi.fn(),
uuidv4: vi.fn(),
isPending: false,
}));
@ -22,16 +24,24 @@ vi.mock("@/hooks/useSpaceQueries", () => ({
useCreateSpace: () => ({ mutateAsync: mocks.mutateAsync, isPending: mocks.isPending }),
}));
vi.mock("uuid", () => ({
v4: mocks.uuidv4,
}));
vi.mock("@/utils/i18n", () => ({
useTranslate: () => (key: string) => key,
}));
const FIRST_SPACE_UID = "5c094171-6f55-4cd0-b79a-2b777ec3596d";
const SECOND_SPACE_UID = "53a9441d-7536-411a-b234-a1d655422108";
describe("CreateSpaceDialog", () => {
beforeEach(() => {
mocks.mutateAsync.mockReset().mockResolvedValue({ name: "spaces/product", title: "Product", description: "Plans" });
mocks.onOpenChange.mockClear();
mocks.onCreated.mockClear();
mocks.toastSuccess.mockClear();
mocks.uuidv4.mockReset().mockReturnValue(FIRST_SPACE_UID);
mocks.isPending = false;
});
@ -46,7 +56,72 @@ describe("CreateSpaceDialog", () => {
expect(mocks.onCreated).toHaveBeenCalledWith({ name: "spaces/product", title: "Product", description: "Plans" });
expect(mocks.onOpenChange).toHaveBeenCalledWith(false);
});
expect(mocks.mutateAsync).toHaveBeenCalledWith({ title: "Product", description: "Plans" });
expect(mocks.mutateAsync).toHaveBeenCalledWith({ title: "Product", description: "Plans", spaceId: FIRST_SPACE_UID });
});
it("allows a valid custom Space UID and explains invalid values", async () => {
render(<CreateSpaceDialog open onOpenChange={mocks.onOpenChange} />);
fireEvent.change(screen.getByLabelText("common.name"), { target: { value: "Product" } });
fireEvent.click(screen.getByRole("button", { name: "space.custom-id-toggle" }));
const spaceIdInput = screen.getByLabelText("space.custom-id-label");
expect(spaceIdInput).toHaveValue(FIRST_SPACE_UID);
fireEvent.change(spaceIdInput, { target: { value: "-product" } });
expect(screen.getByRole("alert")).toHaveTextContent("space.custom-id-invalid");
expect(screen.getByRole("button", { name: "common.create" })).toBeDisabled();
fireEvent.change(spaceIdInput, { target: { value: "P" } });
expect(screen.getByText("space.custom-id-help")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "common.create" })).toBeEnabled();
fireEvent.change(spaceIdInput, { target: { value: "Product-2026" } });
fireEvent.click(screen.getByRole("button", { name: "common.create" }));
await waitFor(() =>
expect(mocks.mutateAsync).toHaveBeenCalledWith({ title: "Product", description: undefined, spaceId: "Product-2026" }),
);
});
it("reveals and associates a conflicting Space UID returned by the server", async () => {
mocks.mutateAsync.mockRejectedValue(new ConnectError("space already exists", Code.AlreadyExists));
render(<CreateSpaceDialog open onOpenChange={mocks.onOpenChange} />);
fireEvent.change(screen.getByLabelText("common.name"), { target: { value: "Product" } });
fireEvent.click(screen.getByRole("button", { name: "common.create" }));
const spaceUidInput = await screen.findByLabelText("space.custom-id-label");
expect(spaceUidInput).toHaveAttribute("aria-invalid", "true");
expect(screen.getByRole("alert")).toHaveTextContent("space.custom-id-conflict");
expect(screen.getByRole("button", { name: "common.create" })).toBeDisabled();
fireEvent.change(spaceUidInput, { target: { value: "product-2" } });
expect(spaceUidInput).toHaveAttribute("aria-invalid", "false");
expect(screen.getByText("space.custom-id-help")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "common.create" })).toBeEnabled();
});
it("reuses the Space UID for retries and generates a new one for the next open", async () => {
mocks.uuidv4.mockReset().mockReturnValueOnce(FIRST_SPACE_UID).mockReturnValueOnce(SECOND_SPACE_UID);
mocks.mutateAsync.mockRejectedValueOnce(new Error("Unavailable")).mockResolvedValueOnce({
name: `spaces/${FIRST_SPACE_UID}`,
title: "Product",
description: "",
});
const view = render(<CreateSpaceDialog open onOpenChange={mocks.onOpenChange} />);
fireEvent.change(screen.getByLabelText("common.name"), { target: { value: "Product" } });
fireEvent.click(screen.getByRole("button", { name: "common.create" }));
await waitFor(() => expect(mocks.mutateAsync).toHaveBeenCalledTimes(1));
fireEvent.click(screen.getByRole("button", { name: "common.create" }));
await waitFor(() => expect(mocks.mutateAsync).toHaveBeenCalledTimes(2));
expect(mocks.mutateAsync.mock.calls[0][0].spaceId).toBe(FIRST_SPACE_UID);
expect(mocks.mutateAsync.mock.calls[1][0].spaceId).toBe(FIRST_SPACE_UID);
view.rerender(<CreateSpaceDialog open={false} onOpenChange={mocks.onOpenChange} />);
view.rerender(<CreateSpaceDialog open onOpenChange={mocks.onOpenChange} />);
fireEvent.click(screen.getByRole("button", { name: "space.custom-id-toggle" }));
expect(screen.getByLabelText("space.custom-id-label")).toHaveValue(SECOND_SPACE_UID);
});
it("can create without activating the new Space", async () => {

View file

@ -120,6 +120,7 @@ describe("InviteSpaceMemberDialog", () => {
);
expect(screen.getByText("Invite an existing Memos user. They must accept before joining the space.")).toBeInTheDocument();
expect(screen.getByText("product")).toBeInTheDocument();
fireEvent.change(screen.getByLabelText("Memos user"), { target: { value: " @alice " } });
act(() => vi.advanceTimersByTime(300));
@ -141,4 +142,21 @@ describe("InviteSpaceMemberDialog", () => {
});
expect(state.onOpenChange).toHaveBeenCalledWith(false);
});
it("wraps a maximum-length Space UID in the dialog context", () => {
const uid = "a".repeat(36);
render(
<InviteSpaceMemberDialog
open
onOpenChange={state.onOpenChange}
space={{ ...productSpace, name: `spaces/${uid}` }}
viewerName="users/steven"
memberUserNames={new Set(["users/steven"])}
pendingInviteeNames={new Set()}
/>,
);
expect(screen.getByText(uid)).toHaveClass("break-all");
});
});

View file

@ -7,7 +7,17 @@ const state = vi.hoisted(() => ({
}));
vi.mock("@/contexts/SpaceContext", () => ({
useSpaceContext: () => ({ spaces: state.spaces }),
useSpaceContext: () => {
const duplicateSpaceTitles = new Set(
state.spaces
.filter((space, index) => state.spaces.findIndex((candidate) => candidate.title === space.title) !== index)
.map((space) => space.title),
);
return {
duplicateSpaceTitles,
spaceByName: new Map(state.spaces.map((space) => [space.name, space])),
};
},
}));
vi.mock("@/utils/i18n", () => ({
@ -23,7 +33,8 @@ describe("MemoSpaceBadge", () => {
state.spaces = [{ name: "spaces/product", title: "Product" }];
render(<MemoSpaceBadge spaceName="spaces/product" />);
expect(screen.getByTitle("Space: Product")).toHaveTextContent("Space: Product");
expect(screen.getByTitle("Space: Product")).toHaveTextContent("Product");
expect(screen.getByTitle("Space: Product")).not.toHaveTextContent("product");
});
it("uses the shared pill presentation", () => {
@ -32,13 +43,44 @@ describe("MemoSpaceBadge", () => {
const badge = screen.getByTitle("Space: Product");
expect(badge).toHaveAttribute("data-slot", "badge");
expect(badge.querySelector(".lucide-user-lock")).not.toBeNull();
expect(badge.querySelector(".lucide-astroid")).not.toBeNull();
});
it("uses a neutral label rather than fetching or exposing an unknown Space", () => {
it("adds the full custom UID when known Space titles match", () => {
state.spaces = [
{ name: "spaces/product-notes", title: "Product" },
{ name: "spaces/product-roadmap", title: "Product" },
];
render(<MemoSpaceBadge spaceName="spaces/product-notes" />);
expect(screen.getByTitle("Space: Product (product-notes)")).toHaveTextContent("Product · product-notes");
});
it("shows eight UUID characters while preserving the full ID in the accessible label", () => {
const uuid = "123e4567-e89b-12d3-a456-426614174000";
state.spaces = [
{ name: `spaces/${uuid}`, title: "Product" },
{ name: "spaces/product-roadmap", title: "Product" },
];
render(<MemoSpaceBadge spaceName={`spaces/${uuid}`} />);
expect(screen.getByTitle(`Space: Product (${uuid})`)).toHaveTextContent("Product · 123e4567…");
});
it("keeps both ends of long custom UIDs visible", () => {
state.spaces = [
{ name: "spaces/customer-support-production", title: "Product" },
{ name: "spaces/customer-support-development", title: "Product" },
];
render(<MemoSpaceBadge spaceName="spaces/customer-support-production" />);
expect(screen.getByTitle("Space: Product (customer-support-production)")).toHaveTextContent("Product · customer…uction");
});
it("uses a neutral title while still exposing an unknown Space UID", () => {
render(<MemoSpaceBadge spaceName="spaces/private" />);
expect(screen.getByTitle("Space")).toHaveTextContent("Space");
expect(screen.getByTitle("Space (private)")).toHaveTextContent("Space · private");
expect(screen.queryByText("spaces/private")).not.toBeInTheDocument();
});

View file

@ -63,6 +63,7 @@ describe("Quick Find navigation", () => {
beforeEach(() => {
sessionStorage.clear();
state.currentUser = { name: "users/alice" };
state.spaces = [{ name: "spaces/product", title: "Product", description: "" }];
state.filters = [];
state.setFilters.mockClear();
state.setMemoView.mockClear();
@ -107,6 +108,63 @@ describe("Quick Find navigation", () => {
expect(screen.getByTestId("path")).toHaveTextContent("/inbox");
});
it("keeps a unique selected Space title compact in remembered-collection search", async () => {
sessionStorage.setItem(getSelectedSpaceStorageKey("users/alice"), "spaces/product");
const router = createMemoryRouter(
[
{
path: "*",
element: (
<SpaceProvider>
<AppSidebarProvider>
<Harness />
</AppSidebarProvider>
</SpaceProvider>
),
},
],
{ initialEntries: ["/"] },
);
render(<RouterProvider router={router} />);
fireEvent.click(screen.getByRole("button", { name: "Open Quick Find" }));
const input = await screen.findByRole("textbox");
expect(input).toHaveAttribute("placeholder", "common.search Product · common.memos");
expect(input).toHaveAttribute("aria-label", "common.search Product · common.memos");
});
it("shows a UID for matching titles and compacts its UUID only in the placeholder", async () => {
const uuid = "123e4567-e89b-12d3-a456-426614174000";
state.spaces = [
{ name: `spaces/${uuid}`, title: "Product", description: "" },
{ name: "spaces/product-roadmap", title: "Product", description: "" },
];
sessionStorage.setItem(getSelectedSpaceStorageKey("users/alice"), `spaces/${uuid}`);
const router = createMemoryRouter(
[
{
path: "*",
element: (
<SpaceProvider>
<AppSidebarProvider>
<Harness />
</AppSidebarProvider>
</SpaceProvider>
),
},
],
{ initialEntries: ["/"] },
);
render(<RouterProvider router={router} />);
fireEvent.click(screen.getByRole("button", { name: "Open Quick Find" }));
const input = await screen.findByRole("textbox");
expect(input).toHaveAttribute("placeholder", "common.search Product (123e4567…) · common.memos");
expect(input).toHaveAttribute("aria-label", `common.search Product (${uuid}) · common.memos`);
});
it("preserves an anonymous global page in history", async () => {
state.currentUser = undefined;

View file

@ -26,8 +26,17 @@ vi.mock("@/hooks/useSpaceQueries", () => ({
}));
const Probe = () => {
const { clearSelectedSpace, collectionScope, memoFilter, spaces, selectedSpace, selectedSpaceName, selectMemos, selectSpace } =
useSpaceContext();
const {
clearSelectedSpace,
collectionScope,
duplicateSpaceTitles,
memoFilter,
spaces,
selectedSpace,
selectedSpaceName,
selectMemos,
selectSpace,
} = useSpaceContext();
return (
<div>
<output data-testid="selected-name">{selectedSpaceName ?? "Memos"}</output>
@ -36,6 +45,7 @@ const Probe = () => {
{collectionScope.kind === "space" ? `${collectionScope.kind}:${collectionScope.name}` : collectionScope.kind}
</output>
<output data-testid="memo-filter">{memoFilter ?? "all"}</output>
<output data-testid="duplicate-titles">{[...duplicateSpaceTitles].join(",")}</output>
<button type="button" onClick={selectMemos}>
Select Memos
</button>
@ -130,6 +140,15 @@ describe("SpaceProvider", () => {
expect(sessionStorage.getItem(getSelectedSpaceStorageKey("users/alice"))).toBe(newlyCreatedSpace.name);
});
it("includes an optimistic selected Space when deriving matching titles", () => {
state.query.data = [{ name: "spaces/existing-new", title: "New", description: "" }];
renderProvider();
fireEvent.click(screen.getByRole("button", { name: "Select new Space" }));
expect(screen.getByTestId("duplicate-titles")).toHaveTextContent("New");
});
it.each(["/", "/explore", "/attachments"])("preserves the current collection route when switching to a Space from %s", (initialPath) => {
const product = { name: "spaces/product", title: "Product", description: "" };
state.query.data = [product];

View file

@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { extractSpaceUidFromName, formatSpaceUidForDisplay, getDuplicateSpaceTitles } from "@/lib/space-display";
describe("Space display identity", () => {
it("keeps short custom UIDs intact and compacts UUIDs to eight visible hex characters", () => {
expect(extractSpaceUidFromName("spaces/research-notes")).toBe("research-notes");
expect(formatSpaceUidForDisplay("spaces/research-notes")).toBe("research-notes");
expect(formatSpaceUidForDisplay("spaces/123e4567-e89b-12d3-a456-426614174000")).toBe("123e4567…");
});
it("shows both ends of long custom UIDs", () => {
expect(formatSpaceUidForDisplay("spaces/customer-support-production")).toBe("customer…uction");
expect(formatSpaceUidForDisplay("spaces/customer-support-development")).toBe("customer…opment");
});
it("marks only titles that match exactly", () => {
const duplicates = getDuplicateSpaceTitles([{ title: "Product" }, { title: "Research" }, { title: "Product" }, { title: "product" }]);
expect([...duplicates]).toEqual(["Product"]);
});
});

View file

@ -199,10 +199,11 @@ describe("Space queries", () => {
);
await act(async () => {
await result.current.create.mutateAsync({ title: "Research", description: "Notes" });
await result.current.create.mutateAsync({ title: "Research", description: "Notes", spaceId: "research" });
});
expect(clients.createSpace).toHaveBeenCalledWith({
space: expect.objectContaining({ title: "Research", description: "Notes" }),
spaceId: "research",
});
expect(queryClient.getQueryData(spaceKeys.list(VIEWER))).toEqual([originalSpace, createdSpace]);

View file

@ -1,3 +1,4 @@
import { AstroidIcon } from "lucide-react";
import { describe, expect, it } from "vitest";
import { isSettingSectionKey, SETTINGS_SECTIONS } from "@/components/Settings/settingSections";
import enTranslation from "@/locales/en.json";
@ -8,6 +9,7 @@ describe("Spaces settings shell", () => {
expect(SETTINGS_SECTIONS.find((section) => section.key === "spaces")).toMatchObject({
scope: "basic",
labelKey: "setting.spaces.label",
icon: AstroidIcon,
});
});

View file

@ -1,4 +1,4 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import SpaceSwitcher from "@/components/AppSidebar/SpaceSwitcher";
@ -30,11 +30,19 @@ vi.mock("@/components/CreateSpaceDialog", () => ({
}));
vi.mock("@/contexts/SpaceContext", () => ({
useSpaceContext: () => ({
useSpaceContext: () => {
const duplicateSpaceTitles = new Set(
spaceState.spaces
.filter((space, index) => spaceState.spaces.findIndex((candidate) => candidate.title === space.title) !== index)
.map((space) => space.title),
);
return {
...spaceState,
duplicateSpaceTitles,
isLoadingSpaces: false,
isSpacesError: false,
}),
};
},
}));
vi.mock("@/utils/i18n", () => ({
@ -43,6 +51,10 @@ vi.mock("@/utils/i18n", () => ({
describe("SpaceSwitcher", () => {
beforeEach(() => {
spaceState.spaces = [
{ name: "spaces/product", title: "Product", description: "" },
{ name: "spaces/research", title: "Research", description: "" },
];
spaceState.selectedSpace = undefined;
spaceState.selectedSpaceName = undefined;
spaceState.selectMemos.mockClear();
@ -55,7 +67,9 @@ describe("SpaceSwitcher", () => {
fireEvent.click(screen.getByRole("button", { name: "space.switch: common.memos" }));
expect(await screen.findByRole("menuitemradio", { name: "Memos" })).toHaveAttribute("aria-checked", "true");
expect(screen.getByRole("menuitemradio", { name: "Product" })).toHaveAttribute("aria-checked", "false");
const productRow = screen.getByRole("menuitemradio", { name: "Product" });
expect(productRow).toHaveAttribute("aria-checked", "false");
expect(productRow.querySelector(".lucide-astroid")).not.toBeNull();
expect(screen.getByRole("menuitemradio", { name: "Research" })).toBeInTheDocument();
expect(screen.getByRole("menuitem", { name: "space.create" })).toBeInTheDocument();
});
@ -73,8 +87,32 @@ describe("SpaceSwitcher", () => {
// The active row carries the fill; the check is its only indicator.
const active = screen.getByRole("menuitemradio", { name: "Product" });
expect(active.className).toContain("bg-accent/60");
expect(active.querySelector("svg")).not.toBeNull();
expect(screen.getByRole("menuitemradio", { name: "Research" }).querySelector("svg")).toBeNull();
expect(active.querySelector(".lucide-check")).not.toBeNull();
expect(screen.getByRole("menuitemradio", { name: "Research" }).querySelector(".lucide-check")).toBeNull();
});
it("shows UIDs only for Spaces whose titles match", async () => {
const uuid = "123e4567-e89b-12d3-a456-426614174000";
spaceState.spaces = [
{ name: "spaces/product-notes", title: "Product", description: "" },
{ name: `spaces/${uuid}`, title: "Product", description: "" },
{ name: "spaces/research-space", title: "Research", description: "" },
];
spaceState.selectedSpaceName = spaceState.spaces[0].name;
spaceState.selectedSpace = spaceState.spaces[0];
render(<SpaceSwitcher />);
const trigger = screen.getByRole("button", { name: "space.switch: Product (product-notes)" });
expect(within(trigger).getByTitle("product-notes")).toHaveTextContent("product-notes");
fireEvent.click(trigger);
const customIdRow = await screen.findByRole("menuitemradio", { name: "Product (product-notes)" });
const uuidRow = screen.getByRole("menuitemradio", { name: `Product (${uuid})` });
expect(within(customIdRow).getByTitle("product-notes")).toHaveTextContent("product-notes");
expect(within(uuidRow).getByTitle(uuid)).toHaveTextContent("123e4567…");
const researchRow = screen.getByRole("menuitemradio", { name: "Research" });
expect(within(researchRow).queryByTitle("research-space")).not.toBeInTheDocument();
expect(researchRow).not.toHaveTextContent("research-space");
});
it("switches context without navigation and opens Space creation", async () => {

View file

@ -1,4 +1,5 @@
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import type { ReactNode } from "react";
import { MemoryRouter, useLocation } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import SpacesSection from "@/components/Settings/SpacesSection";
@ -53,7 +54,7 @@ vi.mock("@/contexts/SpaceContext", () => ({
}));
vi.mock("@/components/SpaceMark", () => ({
default: ({ space }: { space?: { title?: string } }) => <span aria-hidden>{space?.title?.slice(0, 1) ?? "S"}</span>,
default: () => <span aria-hidden>space-mark</span>,
}));
vi.mock("@/components/UserAvatar", () => ({
@ -90,9 +91,20 @@ vi.mock("@/components/Settings/InviteSpaceMemberDialog", () => ({
}));
vi.mock("@/components/ConfirmDialog", () => ({
default: ({ open, confirmLabel, onConfirm }: { open: boolean; confirmLabel: string; onConfirm: () => void | Promise<void> }) =>
default: ({
open,
title,
confirmLabel,
onConfirm,
}: {
open: boolean;
title: ReactNode;
confirmLabel: string;
onConfirm: () => void | Promise<void>;
}) =>
open ? (
<div role="dialog">
<div>{title}</div>
<button type="button" onClick={() => void onConfirm()}>
{confirmLabel}
</button>
@ -101,7 +113,8 @@ vi.mock("@/components/ConfirmDialog", () => ({
}));
vi.mock("@/utils/i18n", () => ({
useTranslate: () => (key: string) => key,
useTranslate: () => (key: string, params?: Record<string, string>) =>
key === "setting.spaces.delete-confirm-title" && params?.space ? `${key} ${params.space}` : key,
}));
const adminSpace: Space = {
@ -201,13 +214,47 @@ describe("SpacesSection", () => {
expect(within(joinedSection!).getByText("Product")).toBeInTheDocument();
expect(within(joinedSection!).queryByText("Research")).not.toBeInTheDocument();
fireEvent.click(within(invitationsSection!).getByRole("button", { name: "setting.spaces.accept" }));
fireEvent.click(within(invitationsSection!).getByRole("button", { name: "setting.spaces.accept Research (research)" }));
await waitFor(() => expect(state.acceptInvitation).toHaveBeenCalledWith({ name: "spaces/research/invitations/alice" }));
fireEvent.click(within(invitationsSection!).getByRole("button", { name: "setting.spaces.decline" }));
fireEvent.click(within(invitationsSection!).getByRole("button", { name: "setting.spaces.decline Research (research)" }));
await waitFor(() => expect(state.declineInvitation).toHaveBeenCalledWith({ name: "spaces/research/invitations/alice" }));
});
it("shows the full Space UID for every joined Space", () => {
const uuid = "123e4567-e89b-12d3-a456-426614174000";
state.spaces = [
{ ...adminSpace, name: "spaces/product-notes" },
{ ...adminSpace, name: `spaces/${uuid}` },
{ ...adminSpace, name: "spaces/research-space", title: "Research" },
];
renderSection();
const customIdRow = screen.getByRole("button", { name: "setting.spaces.manage-space (product-notes)" });
const uuidRow = screen.getByRole("button", { name: `setting.spaces.manage-space (${uuid})` });
expect(within(customIdRow).getByTitle("product-notes")).toHaveTextContent("product-notes");
expect(within(uuidRow).getByTitle(uuid)).toHaveTextContent(uuid);
expect(screen.getByTitle("research-space")).toHaveTextContent("research-space");
});
it("disambiguates matching titles across joined Spaces and invitations", () => {
state.spaces = [{ ...adminSpace, name: "spaces/joined-product" }];
state.receivedInvitations = [
{
...receivedInvitation,
space: { ...receivedInvitation.space!, name: "spaces/invited-product", title: "Product" },
},
];
renderSection();
const invitationsSection = screen.getByRole("heading", { name: "setting.spaces.invitations" }).closest("section");
const joinedSection = screen.getByRole("heading", { name: "setting.spaces.your-spaces" }).closest("section");
expect(within(invitationsSection!).getByTitle("invited-product")).toHaveTextContent("invited-product");
expect(within(joinedSection!).getByTitle("joined-product")).toHaveTextContent("joined-product");
});
it("shows governance controls to Space admins and cancels the exact pending invitation", async () => {
state.spaces = [adminSpace];
state.members = [adminMember, ordinaryMember];
@ -215,6 +262,7 @@ describe("SpacesSection", () => {
renderSection("/setting?space=spaces%2Fproduct#spaces");
expect(screen.getByText("product", { selector: "code" })).toBeInTheDocument();
expect(screen.getByLabelText("common.name")).not.toHaveAttribute("readonly");
expect(screen.getByRole("button", { name: "setting.spaces.save-changes" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "setting.spaces.delete-space" })).toBeInTheDocument();
@ -228,6 +276,21 @@ describe("SpacesSection", () => {
await waitFor(() => expect(state.deleteInvitation).toHaveBeenCalledWith({ name: "spaces/product/invitations/carol" }));
});
it("wraps a maximum-length Space UID in the destructive confirmation", () => {
const uid = "a".repeat(36);
const space = { ...adminSpace, name: `spaces/${uid}` };
state.spaces = [space];
state.members = [{ ...adminMember, name: `spaces/${uid}/members/alice` }];
renderSection(`/setting?space=${encodeURIComponent(space.name)}#spaces`);
expect(screen.getByTitle(uid)).toHaveClass("break-all");
expect(screen.getByTitle(uid)).toHaveTextContent(uid);
fireEvent.click(screen.getByRole("button", { name: "setting.spaces.delete-space" }));
const title = within(screen.getByRole("dialog")).getByText(`setting.spaces.delete-confirm-title Product (${uid})`);
expect(title).toHaveClass("[overflow-wrap:anywhere]");
});
it("keeps ordinary Space members read-only and hides governance controls", () => {
// Instance administrators do not implicitly receive Space governance rights.
state.viewerInstanceRole = 2;