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:
parent
040f9ae717
commit
9d2b77ced8
37 changed files with 912 additions and 146 deletions
16
CONTEXT.md
16
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
|
||||
|
|
|
|||
65
docs/adr/0003-space-uid-allocation-and-format.md
Normal file
65
docs/adr/0003-space-uid-allocation-and-format.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
30
web/src/lib/space-display.ts
Normal file
30
web/src/lib/space-display.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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…",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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", () => ({
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
21
web/tests/space-display.test.ts
Normal file
21
web/tests/space-display.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue