diff --git a/CONTEXT.md b/CONTEXT.md index c58b7aa8..32b4341a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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 diff --git a/docs/adr/0003-space-uid-allocation-and-format.md b/docs/adr/0003-space-uid-allocation-and-format.md new file mode 100644 index 00000000..c3fe69c5 --- /dev/null +++ b/docs/adr/0003-space-uid-allocation-and-format.md @@ -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. diff --git a/docs/adr/README.md b/docs/adr/README.md index 8f52d520..1cab5e9a 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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 diff --git a/docs/glossary.md b/docs/glossary.md index f1c71360..6de8b29c 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -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 diff --git a/proto/api/v1/space_service.proto b/proto/api/v1/space_service.proto index 22b4f339..aea232dc 100644 --- a/proto/api/v1/space_service.proto +++ b/proto/api/v1/space_service.proto @@ -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]; } diff --git a/proto/gen/api/v1/space_service.pb.go b/proto/gen/api/v1/space_service.pb.go index aa5ddbbc..802a116c 100644 --- a/proto/gen/api/v1/space_service.pb.go +++ b/proto/gen/api/v1/space_service.pb.go @@ -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 diff --git a/proto/gen/openapi.yaml b/proto/gen/openapi.yaml index 65dfa8d5..ec5f5b2e 100644 --- a/proto/gen/openapi.yaml +++ b/proto/gen/openapi.yaml @@ -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: diff --git a/server/router/api/v1/resource_name.go b/server/router/api/v1/resource_name.go index 165e3d65..9eb7f870 100644 --- a/server/router/api/v1/resource_name.go +++ b/server/router/api/v1/resource_name.go @@ -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) +} diff --git a/server/router/api/v1/resource_name_test.go b/server/router/api/v1/resource_name_test.go index 968ba349..8bc33690 100644 --- a/server/router/api/v1/resource_name_test.go +++ b/server/router/api/v1/resource_name_test.go @@ -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 { diff --git a/server/router/api/v1/space_service.go b/server/router/api/v1/space_service.go index ea80868a..16b6ce17 100644 --- a/server/router/api/v1/space_service.go +++ b/server/router/api/v1/space_service.go @@ -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 } diff --git a/server/router/api/v1/space_service_test.go b/server/router/api/v1/space_service_test.go index 34d7dad5..de59c634 100644 --- a/server/router/api/v1/space_service_test.go +++ b/server/router/api/v1/space_service_test.go @@ -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) diff --git a/web/package.json b/web/package.json index 6e9141af..aa5615fa 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 2d41ee9d..601faa87 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -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 diff --git a/web/src/components/AppSidebar/QuickFindDialog.tsx b/web/src/components/AppSidebar/QuickFindDialog.tsx index 3b8bc936..fee93cb3 100644 --- a/web/src/components/AppSidebar/QuickFindDialog.tsx +++ b/web/src/components/AppSidebar/QuickFindDialog.tsx @@ -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}`} /> + {showCustomId && ( +
+ + { + 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" + /> +

+ {t(!isSpaceUidValid ? "space.custom-id-invalid" : spaceUidConflict ? "space.custom-id-conflict" : "space.custom-id-help")} +

+
+ )} +

{note ?? t("space.creator-admin-note")}

- diff --git a/web/src/components/MemoView/components/MemoSpaceBadge.tsx b/web/src/components/MemoView/components/MemoSpaceBadge.tsx index 59b6cf93..ddd59f3b 100644 --- a/web/src/components/MemoView/components/MemoSpaceBadge.tsx +++ b/web/src/components/MemoView/components/MemoSpaceBadge.tsx @@ -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 ( - ); }; diff --git a/web/src/components/Settings/InviteSpaceMemberDialog.tsx b/web/src/components/Settings/InviteSpaceMemberDialog.tsx index f4f5132b..60ff8d5d 100644 --- a/web/src/components/Settings/InviteSpaceMemberDialog.tsx +++ b/web/src/components/Settings/InviteSpaceMemberDialog.tsx @@ -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
{t("setting.spaces.invite-to", { space: space.title })} - {t("setting.spaces.invite-description")} + + {t("setting.spaces.invite-description")} + + {t("space.custom-id-label")}: {spaceUid} + +
diff --git a/web/src/components/Settings/SpacesSection.tsx b/web/src/components/Settings/SpacesSection.tsx index 6ec7eb84..1b21ab65 100644 --- a/web/src/components/Settings/SpacesSection.tsx +++ b/web/src/components/Settings/SpacesSection.tsx @@ -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,39 +191,54 @@ const SpacesSection = () => {
- {receivedInvitations.map((invitation) => ( -
-
- -
-
-

{invitation.space?.title || t("setting.spaces.untitled")}

- + {receivedInvitations.map((invitation) => { + const uid = invitation.space ? extractSpaceUidFromName(invitation.space.name) : ""; + const title = invitation.space?.title || t("setting.spaces.untitled"); + + return ( +
+
+ +
+
+

{title}

+ +
+ {uid ? ( +

+ {t("space.custom-id-label")}: + + {uid} + +

+ ) : null} +

+ {invitation.space?.description || t("setting.spaces.invited-to-join")} +

-

- {invitation.space?.description || t("setting.spaces.invited-to-join")} -

+
+
+ +
-
- - -
-
- ))} + ); + })}
) : null} @@ -257,32 +276,43 @@ const SpacesSection = () => {
) : (
- {spaces.map((space) => ( -
- - {space.memberCount === 1 - ? t("setting.spaces.member-count", { count: space.memberCount }) - : t("setting.spaces.members-count", { count: space.memberCount })} - - - - ))} + + {space.memberCount === 1 + ? t("setting.spaces.member-count", { count: space.memberCount }) + : t("setting.spaces.members-count", { count: space.memberCount })} + + + + ); + })}
)} @@ -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) => {
- +

{space.title}

+

+ {t("space.custom-id-label")}: + + {spaceUid} + +

{space.description || t("setting.spaces.no-description")}

@@ -466,6 +504,12 @@ const SpaceDetail = ({ space, viewerName, onBack }: SpaceDetailProps) => {

{t("setting.spaces.space-details-description")}

+
+ {t("space.custom-id-label")} + + {spaceUid} + +
{ {t("setting.spaces.leave-confirm-title", { space: disambiguatedSpaceTitle })} + } 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) => { {t("setting.spaces.delete-confirm-title", { space: disambiguatedSpaceTitle })} + } description={t("setting.spaces.delete-confirm-description")} confirmLabel={t("common.delete")} cancelLabel={t("common.cancel")} diff --git a/web/src/components/Settings/settingSections.ts b/web/src/components/Settings/settingSections.ts index a8c14948..5d639d2f 100644 --- a/web/src/components/Settings/settingSections.ts +++ b/web/src/components/Settings/settingSections.ts @@ -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, }, { diff --git a/web/src/components/SpaceMark.tsx b/web/src/components/SpaceMark.tsx index 07be5df4..5168ad58 100644 --- a/web/src/components/SpaceMark.tsx +++ b/web/src/components/SpaceMark.tsx @@ -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; 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 ( - {initial} + ); }; diff --git a/web/src/contexts/SpaceContext.tsx b/web/src/contexts/SpaceContext.tsx index 615731a6..6fd28122 100644 --- a/web/src/contexts/SpaceContext.tsx +++ b/web/src/contexts/SpaceContext.tsx @@ -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; + duplicateSpaceTitles: ReadonlySet; selectedSpace?: Space; selectedSpaceName?: string; collectionScope: CollectionScope; @@ -52,6 +55,8 @@ const SpaceContext = createContext(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 = new Map(); +const NO_DUPLICATE_SPACE_TITLES: ReadonlySet = 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(); 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( () => (selectedSpaceName ? { kind: "space", name: selectedSpaceName } : ALL_COLLECTION_SCOPE), [selectedSpaceName], @@ -121,6 +131,8 @@ function UserSpaceSession({ userName, children }: { userName: string; children: const value = useMemo( () => ({ 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, diff --git a/web/src/hooks/useSpaceQueries.ts b/web/src/hooks/useSpaceQueries.ts index 53975675..b6ab5f01 100644 --- a/web/src/hooks/useSpaceQueries.ts +++ b/web/src/hooks/useSpaceQueries.ts @@ -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(spaceKeys.list(viewerName), (spaces = []) => upsertByName(spaces, space)); diff --git a/web/src/lib/space-display.ts b/web/src/lib/space-display.ts new file mode 100644 index 00000000..c2a15a5a --- /dev/null +++ b/web/src/lib/space-display.ts @@ -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 => { + const seen = new Set(); + const duplicates = new Set(); + + 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; +}; diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 74a9683f..54702f3d 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -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…", diff --git a/web/src/types/proto/api/v1/space_service_pb.ts b/web/src/types/proto/api/v1/space_service_pb.ts index 18f034f4..81e159b4 100644 --- a/web/src/types/proto/api/v1/space_service_pb.ts +++ b/web/src/types/proto/api/v1/space_service_pb.ts @@ -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; */ diff --git a/web/tests/app-sidebar-logo.test.tsx b/web/tests/app-sidebar-logo.test.tsx index 6bfbecf2..7383b050 100644 --- a/web/tests/app-sidebar-logo.test.tsx +++ b/web/tests/app-sidebar-logo.test.tsx @@ -96,11 +96,19 @@ vi.mock("@/contexts/MemoFilterContext", () => ({ })); vi.mock("@/contexts/SpaceContext", () => ({ - useSpaceContext: () => ({ - ...spaceState, - isLoadingSpaces: false, - isSpacesError: false, - }), + 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", () => ({ diff --git a/web/tests/create-space-dialog.test.tsx b/web/tests/create-space-dialog.test.tsx index 9f888198..f49d6b09 100644 --- a/web/tests/create-space-dialog.test.tsx +++ b/web/tests/create-space-dialog.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + view.rerender(); + 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 () => { diff --git a/web/tests/invite-space-member-dialog.test.tsx b/web/tests/invite-space-member-dialog.test.tsx index 258505b9..192c89a1 100644 --- a/web/tests/invite-space-member-dialog.test.tsx +++ b/web/tests/invite-space-member-dialog.test.tsx @@ -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( + , + ); + + expect(screen.getByText(uid)).toHaveClass("break-all"); + }); }); diff --git a/web/tests/memo-space-badge.test.tsx b/web/tests/memo-space-badge.test.tsx index a0746527..bf872dbc 100644 --- a/web/tests/memo-space-badge.test.tsx +++ b/web/tests/memo-space-badge.test.tsx @@ -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(); - 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(); + + 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(); + + 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(); + + 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(); - expect(screen.getByTitle("Space")).toHaveTextContent("Space"); + expect(screen.getByTitle("Space (private)")).toHaveTextContent("Space · private"); expect(screen.queryByText("spaces/private")).not.toBeInTheDocument(); }); diff --git a/web/tests/quick-find-navigation.test.tsx b/web/tests/quick-find-navigation.test.tsx index 0e3c42f7..beebb533 100644 --- a/web/tests/quick-find-navigation.test.tsx +++ b/web/tests/quick-find-navigation.test.tsx @@ -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: ( + + + + + + ), + }, + ], + { initialEntries: ["/"] }, + ); + render(); + + 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: ( + + + + + + ), + }, + ], + { initialEntries: ["/"] }, + ); + render(); + + 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; diff --git a/web/tests/space-context.test.tsx b/web/tests/space-context.test.tsx index 20fb5d3b..f1844af6 100644 --- a/web/tests/space-context.test.tsx +++ b/web/tests/space-context.test.tsx @@ -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 (
{selectedSpaceName ?? "Memos"} @@ -36,6 +45,7 @@ const Probe = () => { {collectionScope.kind === "space" ? `${collectionScope.kind}:${collectionScope.name}` : collectionScope.kind} {memoFilter ?? "all"} + {[...duplicateSpaceTitles].join(",")} @@ -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]; diff --git a/web/tests/space-display.test.ts b/web/tests/space-display.test.ts new file mode 100644 index 00000000..ad559f54 --- /dev/null +++ b/web/tests/space-display.test.ts @@ -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"]); + }); +}); diff --git a/web/tests/space-queries.test.tsx b/web/tests/space-queries.test.tsx index 90427b65..05bfc3bd 100644 --- a/web/tests/space-queries.test.tsx +++ b/web/tests/space-queries.test.tsx @@ -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]); diff --git a/web/tests/space-settings-shell.test.ts b/web/tests/space-settings-shell.test.ts index 6e4cdad6..a886395d 100644 --- a/web/tests/space-settings-shell.test.ts +++ b/web/tests/space-settings-shell.test.ts @@ -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, }); }); diff --git a/web/tests/space-switcher.test.tsx b/web/tests/space-switcher.test.tsx index b820effe..fa239805 100644 --- a/web/tests/space-switcher.test.tsx +++ b/web/tests/space-switcher.test.tsx @@ -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: () => ({ - ...spaceState, - isLoadingSpaces: false, - isSpacesError: false, - }), + 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(); + + 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 () => { diff --git a/web/tests/spaces-section.test.tsx b/web/tests/spaces-section.test.tsx index b099d157..eb7444d0 100644 --- a/web/tests/spaces-section.test.tsx +++ b/web/tests/spaces-section.test.tsx @@ -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 } }) => {space?.title?.slice(0, 1) ?? "S"}, + default: () => space-mark, })); 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 }) => + default: ({ + open, + title, + confirmLabel, + onConfirm, + }: { + open: boolean; + title: ReactNode; + confirmLabel: string; + onConfirm: () => void | Promise; + }) => open ? (
+
{title}
@@ -101,7 +113,8 @@ vi.mock("@/components/ConfirmDialog", () => ({ })); vi.mock("@/utils/i18n", () => ({ - useTranslate: () => (key: string) => key, + useTranslate: () => (key: string, params?: Record) => + 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;