diff --git a/webapp/babel.config.js b/webapp/babel.config.js
index e4aa2d1..2283743 100644
--- a/webapp/babel.config.js
+++ b/webapp/babel.config.js
@@ -5,10 +5,10 @@ const config = {
presets: [
['@babel/preset-env', {
targets: {
- chrome: 66,
- firefox: 60,
- edge: 42,
- safari: 12,
+ chrome: 110,
+ firefox: 102,
+ edge: 110,
+ safari: '16.4',
},
modules: false,
corejs: 3,
diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json
index c9d4f22..bb996f1 100644
--- a/webapp/i18n/en.json
+++ b/webapp/i18n/en.json
@@ -1,4 +1,13 @@
{
+ "docs.archiveSpace.confirm": "Yes, archive space",
+ "docs.archiveSpace.error.generic": "Something went wrong. Please try again.",
+ "docs.archiveSpace.error.title": "Unable to archive {name}",
+ "docs.archiveSpace.message": "Are you sure you want to archive the {name} space? Members will lose access until it is restored.",
+ "docs.archiveSpace.title": "Archive {name}",
+ "docs.basicInputModal.cancel": "Cancel",
+ "docs.basicInputModal.confirm": "Save",
+ "docs.comments.placeholder": "Comments on this page will appear here.",
+ "docs.comments.title": "Comments",
"docs.confirmModal.cancel": "Cancel",
"docs.confirmModal.confirm": "Confirm",
"docs.createSpace.cancel": "Cancel",
@@ -8,10 +17,6 @@
"docs.createSpace.error.description.tooLong": "Description must be {max} characters or fewer",
"docs.createSpace.error.name.required": "Please enter a name for the space",
"docs.createSpace.error.name.tooLong": "Name must be {max} characters or fewer",
- "docs.createSpace.error.url.invalid": "Use lowercase letters, numbers, and dashes, with no spaces",
- "docs.createSpace.error.url.required": "Please enter a URL for the space",
- "docs.createSpace.error.url.taken": "That URL is already taken",
- "docs.createSpace.error.url.tooLong": "URL must be {max} characters or fewer",
"docs.createSpace.nameLabel": "Space name",
"docs.createSpace.permissionsNote": "Specific edit and sharing permissions can be defined once the space is created.",
"docs.createSpace.private.description": "Only invited members",
@@ -20,8 +25,19 @@
"docs.createSpace.public.description": "Any team member can view",
"docs.createSpace.public.title": "Public Space",
"docs.createSpace.title": "Create a new space",
- "docs.createSpace.urlAriaLabel": "Space URL",
"docs.createSpace.visibilityLabel": "Space visibility",
+ "docs.deletePage.confirm": "Delete",
+ "docs.deletePage.error": "Could not delete “{title}”.",
+ "docs.deletePage.message": "Are you sure you want to delete {title}? Its subpages will be deleted too.",
+ "docs.deletePage.title": "Delete page",
+ "docs.discardDraft.confirm": "Discard",
+ "docs.discardDraft.error": "Could not discard “{title}”.",
+ "docs.discardDraft.message": "Are you sure you want to discard {title}? It has never been published, so this cannot be undone.",
+ "docs.discardDraft.title": "Discard draft",
+ "docs.editor.header.draft": "Draft · {spaceId} / {pageId}",
+ "docs.editor.header.published": "Published · {spaceId} / {pageId}",
+ "docs.editor.hostMissing": "This Mattermost build does not publish the Docs editor. Update the server to edit pages here.",
+ "docs.editor.stub.body": "Editor is available and will mount here. Suggestion providers exposed: {providerCount}.",
"docs.form.url.edit": "Edit",
"docs.form.url.label": "URL:",
"docs.genericModal.close": "Close",
@@ -51,9 +67,77 @@
"docs.home.welcome.body": "Long-form knowledge for your team — documentation, specs, handbooks, decisions, postmortems. Every page lives in a Space which can be linked with Channels for convenience.",
"docs.home.welcome.title": "Welcome to Docs.",
"docs.leaveSpace.confirm": "Yes, leave space",
+ "docs.leaveSpace.error.generic": "Something went wrong. Please try again.",
+ "docs.leaveSpace.error.lastMember": "A space must keep at least one member with access. Add another member before you leave.",
+ "docs.leaveSpace.error.title": "Unable to leave {name}",
"docs.leaveSpace.message": "Are you sure you want to leave the {name} space? You can rejoin later if it is public.",
"docs.leaveSpace.title": "Leave {name}",
- "docs.main.spaceOverview": "Space overview",
+ "docs.page.addEmoji": "Add emoji",
+ "docs.page.byAuthor": "By {name}",
+ "docs.page.headerImage": "Header image",
+ "docs.page.titleLabel": "Page title",
+ "docs.page.titleSaveFailed": "Could not rename the page. Please try again.",
+ "docs.page.untitled": "Untitled",
+ "docs.pageMenu.comingSoon": "Coming soon",
+ "docs.pageMenu.copyLink": "Copy link",
+ "docs.pageMenu.delete": "Delete page",
+ "docs.pageMenu.discard": "Discard draft",
+ "docs.pageMenu.draftLabel": "Draft options for {title}",
+ "docs.pageMenu.duplicate": "Duplicate page",
+ "docs.pageMenu.export": "Export",
+ "docs.pageMenu.export.markdown": "Markdown",
+ "docs.pageMenu.export.pdf": "PDF",
+ "docs.pageMenu.favorite": "Favorite",
+ "docs.pageMenu.label": "Page options for {title}",
+ "docs.pageMenu.moveTo": "Move to",
+ "docs.pageMenu.notifications": "Notifications",
+ "docs.pageMenu.notifications.all": "All activity",
+ "docs.pageMenu.notifications.mentions": "Mentions",
+ "docs.pageMenu.notifications.none": "Nothing",
+ "docs.pageMenu.openInNewWindow": "Open in new window",
+ "docs.pageMenu.openNewWindow": "Open in new window",
+ "docs.pageMenu.rename": "Rename",
+ "docs.pageMenu.sharing": "Sharing and Permissions",
+ "docs.pageMenu.unfavorite": "Remove from favorites",
+ "docs.pageMenu.versionHistory": "Version history",
+ "docs.pageTree.add": "Add page",
+ "docs.pageTree.addFailed": "Could not create the page. Please try again.",
+ "docs.pageTree.collapse": "Collapse {title}",
+ "docs.pageTree.draft": "Draft",
+ "docs.pageTree.expand": "Expand {title}",
+ "docs.pageTree.favorited": "Favorited",
+ "docs.pageTree.keyboardHelp": "Use the arrow keys to move through pages. Hold Alt with an arrow key to move the selected page up, down, in, or out.",
+ "docs.pageTree.menu": "Page options for {title}",
+ "docs.pageTree.moveBlocked": "Cannot move {title} any further in that direction.",
+ "docs.pageTree.moveFailed": "Could not move the page. Please try again.",
+ "docs.pageTree.moved": "Moved {title} to position {position} under {parent}.",
+ "docs.pageTree.nestBlocked": "Cannot nest {title} deeper than {levels} levels.",
+ "docs.pageTree.overview": "Overview",
+ "docs.pageTree.topLevel": "top level",
+ "docs.publish.failed": "Could not publish the page. Please try again.",
+ "docs.publish.parentUnpublished": "Publish the parent page first, then publish this one.",
+ "docs.renamePage.confirm": "Save",
+ "docs.renamePage.label": "Page name",
+ "docs.renamePage.title": "Rename page",
+ "docs.rhs.back": "Back to {name}",
+ "docs.rhs.close": "Close",
+ "docs.rhs.resize": "Resize {name}",
+ "docs.saveChanges.error": "There was an error saving your changes",
+ "docs.saveChanges.message": "You have unsaved changes",
+ "docs.saveChanges.reset": "Reset",
+ "docs.saveChanges.save": "Save",
+ "docs.share.access.canView": "Can View",
+ "docs.share.copyLink": "Copy link",
+ "docs.share.handle": "@{username}",
+ "docs.share.noResults": "No people found",
+ "docs.share.remove": "Remove {name}",
+ "docs.share.role.admin": "Admin",
+ "docs.share.search": "Add people or groups",
+ "docs.share.searching": "Searching…",
+ "docs.share.title": "Share space",
+ "docs.share.visibility.public": "Public",
+ "docs.share.visibility.publicHint": "Anyone in Mattermost",
+ "docs.share.you": "(You)",
"docs.sidebar.add.browse": "Browse spaces",
"docs.sidebar.add.create": "Create a space",
"docs.sidebar.add.menu": "Add or browse spaces",
@@ -63,11 +147,15 @@
"docs.sidebar.favorites.empty": "Drag favorite items here or click the star icon on any space",
"docs.sidebar.label": "Spaces",
"docs.sidebar.nav.home": "Home",
+ "docs.sidebar.resize": "Resize pages sidebar",
+ "docs.sidebar.resizeSpaces": "Resize spaces sidebar",
"docs.sidebar.search.placeholder": "Find docs",
"docs.sidebar.space.copyLink": "Copy link",
"docs.sidebar.space.favorite": "Add to favorites",
+ "docs.sidebar.space.favoritePartial": "Some pages are favorited",
"docs.sidebar.space.leave": "Leave space",
"docs.sidebar.space.menu": "Space options for {name}",
+ "docs.sidebar.space.menuShort": "Space options",
"docs.sidebar.space.unfavorite": "Remove from favorites",
"docs.sidebar.team.createTeam": "Create a team",
"docs.sidebar.team.invite": "Invite people",
@@ -77,11 +165,97 @@
"docs.sidebar.team.members": "Manage members",
"docs.sidebar.team.menu": "Manage {teamName}",
"docs.sidebar.team.settings": "Team settings",
- "docs.switcher.group.recent": "Recent docs",
+ "docs.space.allSpaces": "All spaces",
+ "docs.space.close": "Close",
+ "docs.space.comments": "Comments",
+ "docs.space.descriptionPlaceholder": "Add a space description here — just a brief summary of the purpose for this space.",
+ "docs.space.details": "Space details",
+ "docs.space.edit": "Edit",
+ "docs.space.exitFullscreen": "Exit fullscreen",
+ "docs.space.expand": "Expand",
+ "docs.space.favorite": "Favorite this space",
+ "docs.space.favoritePartial": "Favorite this space (some pages are favorited)",
+ "docs.space.memberHandle": "@{username}",
+ "docs.space.membersButton": "Members",
+ "docs.space.membersOverflow": "+{count}",
+ "docs.space.menu": "Space options",
+ "docs.space.menu.archive": "Archive space",
+ "docs.space.menu.copyLink": "Copy link",
+ "docs.space.menu.info": "Space info",
+ "docs.space.menu.leave": "Leave space",
+ "docs.space.menu.members": "Members",
+ "docs.space.menu.settings": "Space settings",
+ "docs.space.more": "More actions",
+ "docs.space.noUnpublishedChanges": "No unpublished changes",
+ "docs.space.pages": "Pages",
+ "docs.space.publish": "Publish",
+ "docs.space.share": "Share",
+ "docs.space.stat.members": "Members",
+ "docs.space.stat.pages": "Pages",
+ "docs.space.stat.views": "Views",
+ "docs.space.unfavorite": "Remove from favorites",
+ "docs.space.update": "Update",
+ "docs.space.updated": "Updated {relative}",
+ "docs.spaceInfo.created": "Created",
+ "docs.spaceInfo.description": "Description",
+ "docs.spaceInfo.descriptionLabel": "Space description",
+ "docs.spaceInfo.descriptionPlaceholder": "Add a space description",
+ "docs.spaceInfo.editDescription": "Edit description",
+ "docs.spaceInfo.handle": "@{username}",
+ "docs.spaceInfo.members": "Members",
+ "docs.spaceInfo.menu.copyLink": "Copy link",
+ "docs.spaceInfo.menu.members": "Members",
+ "docs.spaceInfo.menu.settings": "Space settings",
+ "docs.spaceInfo.menu.title": "Space info actions",
+ "docs.spaceInfo.noDescription": "No description",
+ "docs.spaceInfo.pages": "Pages",
+ "docs.spaceInfo.title": "Space info",
+ "docs.spaceSettings.archive.button": "Archive space",
+ "docs.spaceSettings.archive.confirmBody": "“{title}” and its pages will be archived and members will lose access. This can be undone by an admin.",
+ "docs.spaceSettings.archive.confirmButton": "Archive",
+ "docs.spaceSettings.archive.confirmTitle": "Archive this space?",
+ "docs.spaceSettings.archive.copy": "Archiving removes this space and its pages from the team. Members will lose access. You can ask an admin to restore it later.",
+ "docs.spaceSettings.archive.heading": "Archive space",
+ "docs.spaceSettings.configuration.comingSoon": "Space configuration options are coming soon.",
+ "docs.spaceSettings.configuration.heading": "Configuration",
+ "docs.spaceSettings.discard.body": "Your unsaved changes to this space will be lost.",
+ "docs.spaceSettings.discard.confirm": "Discard changes",
+ "docs.spaceSettings.discard.title": "Discard unsaved changes?",
+ "docs.spaceSettings.info.descriptionHelper": "Describe how this space should be used. This will show when browsing spaces.",
+ "docs.spaceSettings.info.descriptionLabel": "Description",
+ "docs.spaceSettings.info.descriptionPlaceholder": "Enter a description for this space…",
+ "docs.spaceSettings.info.editUrl": "Edit",
+ "docs.spaceSettings.info.heading": "Space info",
+ "docs.spaceSettings.info.landingLabel": "Default landing page",
+ "docs.spaceSettings.info.landingSpaceHome": "Space home",
+ "docs.spaceSettings.info.nameLabel": "Space name",
+ "docs.spaceSettings.info.urlLabel": "Space URL",
+ "docs.spaceSettings.navLabel": "Space settings sections",
+ "docs.spaceSettings.permissions.accessHeading": "Space access",
+ "docs.spaceSettings.permissions.accessLabel": "Space access",
+ "docs.spaceSettings.permissions.externalSharing.comingSoon": "Coming soon",
+ "docs.spaceSettings.permissions.externalSharing.description": "Let people outside the team access this space with a link.",
+ "docs.spaceSettings.permissions.externalSharing.title": "External sharing",
+ "docs.spaceSettings.permissions.handle": "@{username}",
+ "docs.spaceSettings.permissions.peopleHeading": "People and groups with access",
+ "docs.spaceSettings.permissions.private.comingSoon": "Coming soon",
+ "docs.spaceSettings.permissions.private.description": "Only invited members can view this space.",
+ "docs.spaceSettings.permissions.private.title": "Private",
+ "docs.spaceSettings.permissions.public.description": "Anyone in the team can find and view this space.",
+ "docs.spaceSettings.permissions.public.title": "Public",
+ "docs.spaceSettings.permissions.role.admin": "Admin",
+ "docs.spaceSettings.permissions.searchPlaceholder": "Add people, groups or channels",
+ "docs.spaceSettings.tab.archive": "Archive space",
+ "docs.spaceSettings.tab.configuration": "Configuration",
+ "docs.spaceSettings.tab.info": "Info",
+ "docs.spaceSettings.tab.permissions": "Permissions",
+ "docs.spaceSettings.title": "Space Settings",
+ "docs.switcher.group.recent": "Recent",
"docs.switcher.group.results": "Spaces and pages",
"docs.switcher.group.spaces": "Your spaces",
"docs.switcher.noResults": "No spaces or pages found",
"docs.switcher.placeholder": "Search all spaces and pages",
"docs.switcher.title": "Find docs",
- "docs.switcher.title.query": "Find spaces or pages"
+ "docs.switcher.title.query": "Find spaces or pages",
+ "docs.toast.close": "Close"
}
diff --git a/webapp/package-lock.json b/webapp/package-lock.json
index ff57f99..f9412fa 100644
--- a/webapp/package-lock.json
+++ b/webapp/package-lock.json
@@ -74,6 +74,7 @@
"sass-loader": "13.0.0",
"style-loader": "3.3.1",
"webpack": "5.95.0",
+ "webpack-bundle-analyzer": "4.10.2",
"webpack-cli": "5.1.4"
}
},
@@ -3629,6 +3630,13 @@
"node": ">= 8"
}
},
+ "node_modules/@polka/url": {
+ "version": "1.0.0-next.29",
+ "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
+ "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@redux-devtools/extension": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@redux-devtools/extension/-/extension-3.3.0.tgz",
@@ -6340,6 +6348,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/debounce": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz",
+ "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -6568,6 +6583,13 @@
"node": ">= 0.4"
}
},
+ "node_modules/duplexer": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz",
+ "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/electron-to-chromium": {
"version": "1.5.331",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz",
@@ -8334,6 +8356,22 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/gzip-size": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz",
+ "integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "duplexer": "^0.1.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/harmony-reflect": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/harmony-reflect/-/harmony-reflect-1.6.2.tgz",
@@ -11015,6 +11053,16 @@
"color-name": "^1.1.4"
}
},
+ "node_modules/mrmime": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
+ "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -11346,6 +11394,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/opener": {
+ "version": "1.5.2",
+ "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz",
+ "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==",
+ "dev": true,
+ "license": "(WTFPL OR MIT)",
+ "bin": {
+ "opener": "bin/opener-bin.js"
+ }
+ },
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -12794,6 +12852,21 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/sirv": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz",
+ "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@polka/url": "^1.0.0-next.24",
+ "mrmime": "^2.0.0",
+ "totalist": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 10"
+ }
+ },
"node_modules/sisteransi": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
@@ -13364,6 +13437,16 @@
"node": ">=8.0"
}
},
+ "node_modules/totalist": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
+ "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/tough-cookie": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
@@ -13896,6 +13979,56 @@
}
}
},
+ "node_modules/webpack-bundle-analyzer": {
+ "version": "4.10.2",
+ "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.2.tgz",
+ "integrity": "sha512-vJptkMm9pk5si4Bv922ZbKLV8UTT4zib4FPgXMhgzUny0bfDDkLXAVQs3ly3fS4/TN9ROFtb0NFrm04UXFE/Vw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@discoveryjs/json-ext": "0.5.7",
+ "acorn": "^8.0.4",
+ "acorn-walk": "^8.0.0",
+ "commander": "^7.2.0",
+ "debounce": "^1.2.1",
+ "escape-string-regexp": "^4.0.0",
+ "gzip-size": "^6.0.0",
+ "html-escaper": "^2.0.2",
+ "opener": "^1.5.2",
+ "picocolors": "^1.0.0",
+ "sirv": "^2.0.3",
+ "ws": "^7.3.1"
+ },
+ "bin": {
+ "webpack-bundle-analyzer": "lib/bin/analyzer.js"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ }
+ },
+ "node_modules/webpack-bundle-analyzer/node_modules/acorn-walk": {
+ "version": "8.3.5",
+ "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
+ "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "acorn": "^8.11.0"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/webpack-bundle-analyzer/node_modules/commander": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
+ "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10"
+ }
+ },
"node_modules/webpack-cli": {
"version": "5.1.4",
"resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz",
diff --git a/webapp/package.json b/webapp/package.json
index fe2350f..c6b39a2 100644
--- a/webapp/package.json
+++ b/webapp/package.json
@@ -3,6 +3,7 @@
"scripts": {
"build": "webpack --mode=production",
"build:watch": "webpack --mode=production --watch",
+ "build:analyze": "webpack --mode=production",
"debug": "webpack --mode=development",
"debug:watch": "webpack --mode=development --watch",
"lint": "eslint --ignore-pattern node_modules --ignore-pattern dist --ext=.js,.jsx,tsx,ts . --quiet --cache",
@@ -60,6 +61,7 @@
"sass-loader": "13.0.0",
"style-loader": "3.3.1",
"webpack": "5.95.0",
+ "webpack-bundle-analyzer": "4.10.2",
"webpack-cli": "5.1.4"
},
"dependencies": {
diff --git a/webapp/src/client/rest.ts b/webapp/src/client/rest.ts
new file mode 100644
index 0000000..a35fa55
--- /dev/null
+++ b/webapp/src/client/rest.ts
@@ -0,0 +1,134 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import manifest from 'manifest';
+
+import {ClientError} from '@mattermost/client';
+
+import {Client4} from 'mattermost-redux/client';
+
+// Base URL for the Docs plugin REST API. Client4.url is the host-configured
+// site URL (including any subpath), so this resolves correctly on subpath-hosted
+// instances without extra wiring. Deferred to call time because the host sets
+// Client4.url after our bundle loads.
+const apiUrl = (): string => `${Client4.url}/plugins/${manifest.id}/api/v1`;
+
+// The host site URL, for the platform's own endpoints (avatars, files) that are
+// not under our plugin's API root.
+export const siteRoot = (): string => Client4.url;
+
+type FetchOptions = {
+ method: string;
+ body?: string;
+ headers?: Record;
+ signal?: AbortSignal;
+};
+
+/**
+ * A failed Docs API call. Extends ClientError so existing callers keep matching
+ * on `instanceof ClientError` and `status_code`, and adds the two things an
+ * AppError body carries that ClientError drops: the raw parsed payload, and a
+ * plain `status`. The payload matters for endpoints that answer an error with
+ * data — draft publish returns the current page alongside its 409.
+ */
+export class RestError extends ClientError {
+ status: number;
+ body: unknown;
+
+ constructor(url: string, status: number, message: string, body: unknown, serverErrorId?: string) {
+ super(Client4.url, {message, status_code: status, url, server_error_id: serverErrorId});
+ this.name = 'RestError';
+ this.status = status;
+ this.body = body;
+ }
+}
+
+// Single fetch idiom shared by every Docs API call. Client4.getOptions injects
+// the session credentials and CSRF header the server expects (it reads the
+// platform-supplied Mattermost-User-Id header), so this never hand-rolls auth.
+// Server errors are JSON `AppError`s ({message, status_code}); non-OK responses
+// are normalized into RestError. An aborted request
+// rejects with fetch's own `AbortError` DOMException instead, so callers can tell
+// "I cancelled this" apart from "the server said no".
+async function request(url: string, options: FetchOptions): Promise {
+ const response = await fetch(url, Client4.getOptions(options));
+
+ if (response.ok) {
+ // Actions like DELETE return {"status":"OK"}; callers that expect no
+ // payload type this as void and ignore it.
+ const text = await response.text();
+ return (text ? JSON.parse(text) : {}) as T;
+ }
+
+ let message = `Received status code ${response.status}`;
+ let body: unknown;
+ let serverErrorId: string | undefined;
+ try {
+ body = await response.json();
+ const data = body as {message?: string; id?: string};
+ message = data.message || message;
+ serverErrorId = data.id;
+ } catch {
+ // Non-JSON error body — keep the status-based message.
+ }
+ throw new RestError(url, response.status, message, body, serverErrorId);
+}
+
+export const restGet = (url: string, signal?: AbortSignal): Promise =>
+ request(url, {method: 'GET', signal});
+
+export const restPost = (url: string, body: unknown, signal?: AbortSignal): Promise =>
+ request(url, {method: 'POST', body: JSON.stringify(body), headers: {'Content-Type': 'application/json'}, signal});
+
+export const restPatch = (url: string, body: unknown, signal?: AbortSignal): Promise =>
+ request(url, {method: 'PATCH', body: JSON.stringify(body), headers: {'Content-Type': 'application/json'}, signal});
+
+export const restDelete = (url: string, signal?: AbortSignal): Promise =>
+ request(url, {method: 'DELETE', signal});
+
+type DoFetchOptions = {
+ method?: string;
+ body?: unknown;
+ signal?: AbortSignal;
+};
+
+/**
+ * Path-relative form of the request helpers: `path` is resolved against the
+ * plugin's API root and `body` is serialized here. Client modules that describe a
+ * whole endpoint family (see `client/drafts.ts`) read better this way than
+ * building an absolute URL per call.
+ */
+export const doFetch = (path: string, {method = 'GET', body, signal}: DoFetchOptions = {}): Promise =>
+ request(`${apiUrl()}${path}`, {
+ method,
+ signal,
+ ...(body === undefined ? {} : {body: JSON.stringify(body), headers: {'Content-Type': 'application/json'}}),
+ });
+
+type Paginated = {
+ items: T[];
+ page: number;
+ per_page: number;
+ has_more: boolean;
+};
+
+const PER_PAGE = 100;
+const MAX_PAGES = 1000;
+
+// Follows the server's {items, page, per_page, has_more} envelope across pages
+// and returns the flattened list. The page cap is a runaway-loop backstop, not
+// an expected limit.
+export async function listAll(path: (query: string) => string, signal?: AbortSignal): Promise {
+ const out: T[] = [];
+ for (let page = 0; page < MAX_PAGES; page++) {
+ // eslint-disable-next-line no-await-in-loop
+ const res = await restGet>(path(`page=${page}&per_page=${PER_PAGE}`), signal);
+ out.push(...res.items);
+ if (!res.has_more) {
+ break;
+ }
+ }
+ return out;
+}
+
+export {apiUrl};
diff --git a/webapp/src/components/basic_input_modal/basic_input_modal.module.scss b/webapp/src/components/basic_input_modal/basic_input_modal.module.scss
new file mode 100644
index 0000000..16827e4
--- /dev/null
+++ b/webapp/src/components/basic_input_modal/basic_input_modal.module.scss
@@ -0,0 +1,20 @@
+.modal {
+ width: 512px;
+ max-width: calc(100vw - 48px);
+}
+
+.body {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+
+ // Vertical padding so the field's floated label (which overhangs the field's
+ // top border) isn't clipped.
+ padding: 12px 32px 20px;
+}
+
+.error {
+ color: var(--error-text);
+ font-size: 12px;
+ line-height: 16px;
+}
diff --git a/webapp/src/components/basic_input_modal/basic_input_modal.test.tsx b/webapp/src/components/basic_input_modal/basic_input_modal.test.tsx
new file mode 100644
index 0000000..71650d5
--- /dev/null
+++ b/webapp/src/components/basic_input_modal/basic_input_modal.test.tsx
@@ -0,0 +1,65 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {fireEvent, screen, waitFor} from '@testing-library/react';
+import React from 'react';
+
+import BasicInputModal from './basic_input_modal';
+
+import {renderWithContext} from '../../../tests/react_testing_utils';
+
+const renderModal = (props: Partial> = {}) => renderWithContext(
+ ,
+);
+
+describe('BasicInputModal', () => {
+ it('seeds the field with initialValue and disables Save while it is blank', () => {
+ renderModal({initialValue: ' '});
+
+ expect(screen.getByRole('button', {name: 'Save'})).toBeDisabled();
+
+ fireEvent.change(screen.getByLabelText('Page name'), {target: {value: 'Notes'}});
+ expect(screen.getByRole('button', {name: 'Save'})).toBeEnabled();
+ });
+
+ it('confirms with the trimmed value and closes', async () => {
+ const onConfirm = jest.fn();
+ const onClose = jest.fn();
+ renderModal({initialValue: 'Old', onConfirm, onClose});
+
+ fireEvent.change(screen.getByLabelText('Page name'), {target: {value: ' New '}});
+ fireEvent.click(screen.getByRole('button', {name: 'Save'}));
+
+ await waitFor(() => expect(onConfirm).toHaveBeenCalledWith('New'));
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('submits on Enter in the field', async () => {
+ const onConfirm = jest.fn();
+ renderModal({initialValue: 'Old', onConfirm});
+
+ fireEvent.keyDown(screen.getByLabelText('Page name'), {key: 'Enter'});
+
+ await waitFor(() => expect(onConfirm).toHaveBeenCalledWith('Old'));
+ });
+
+ it('keeps the modal open and shows the failure inline when onConfirm rejects', async () => {
+ const onClose = jest.fn();
+ renderModal({
+ initialValue: 'Old',
+ onConfirm: jest.fn().mockRejectedValue(new Error('Someone else edited this page')),
+ onClose,
+ });
+
+ fireEvent.click(screen.getByRole('button', {name: 'Save'}));
+
+ await waitFor(() => expect(screen.getByText('Someone else edited this page')).toBeInTheDocument());
+ expect(onClose).not.toHaveBeenCalled();
+ });
+});
diff --git a/webapp/src/components/basic_input_modal/basic_input_modal.tsx b/webapp/src/components/basic_input_modal/basic_input_modal.tsx
new file mode 100644
index 0000000..310e83c
--- /dev/null
+++ b/webapp/src/components/basic_input_modal/basic_input_modal.tsx
@@ -0,0 +1,133 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React, {useId, useState} from 'react';
+import {FormattedMessage} from 'react-intl';
+
+import {PrimaryButton, TertiaryButton} from 'components/form_controls/button';
+import TextArea from 'components/form_controls/text_area';
+import TextInput from 'components/form_controls/text_input';
+import GenericModal from 'components/generic_modal/generic_modal';
+
+import styles from './basic_input_modal.module.scss';
+
+// Rows for the multiline field; matches the description box in Space Settings.
+const MULTILINE_ROWS = 4;
+
+type Props = {
+ title: React.ReactNode;
+ label: string;
+ initialValue?: string;
+ confirmButtonText?: React.ReactNode;
+ maxLength?: number;
+
+ /** Renders a textarea instead of a single-line input. */
+ multiline?: boolean;
+
+ /**
+ * Allows confirming with an empty value, for fields that can be cleared
+ * (e.g. removing a description). Off by default so renames stay required.
+ */
+ allowEmpty?: boolean;
+
+ /** Rejecting keeps the modal open and surfaces the reason inline. */
+ onConfirm: (value: string) => void | Promise;
+ onClose: () => void;
+};
+
+/**
+ * A one-field modal for short renames and single prompts, single- or multiline.
+ * The caller supplies the copy and the confirm handler; this owns the value, the
+ * saving state and the inline error.
+ */
+const BasicInputModal = ({title, label, initialValue = '', confirmButtonText, maxLength, multiline = false, allowEmpty = false, onConfirm, onClose}: Props) => {
+ const inputId = useId();
+
+ const [value, setValue] = useState(initialValue);
+ const [error, setError] = useState();
+ const [saving, setSaving] = useState(false);
+
+ const trimmed = value.trim();
+ const canConfirm = (allowEmpty || Boolean(trimmed)) && !saving;
+
+ const confirm = async () => {
+ if (!canConfirm) {
+ return;
+ }
+ setSaving(true);
+ setError(undefined);
+ try {
+ await onConfirm(trimmed);
+ onClose();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err));
+ setSaving(false);
+ }
+ };
+
+ const footer = (
+ <>
+
+
+
+
+ {confirmButtonText ?? (
+
+ )}
+
+ >
+ );
+
+ return (
+
+
+ {multiline ? (
+
+ // No Enter-to-submit: newlines are part of the value.
+
+ ) : (
+
+ )}
+ {error &&
{error}
}
+
+
+ );
+};
+
+export default BasicInputModal;
diff --git a/webapp/src/components/comments/comments_panel.module.scss b/webapp/src/components/comments/comments_panel.module.scss
new file mode 100644
index 0000000..f4242da
--- /dev/null
+++ b/webapp/src/components/comments/comments_panel.module.scss
@@ -0,0 +1,6 @@
+.empty {
+ margin: 0;
+ color: rgba(var(--center-channel-color-rgb), 0.56);
+ font-size: 14px;
+ line-height: 20px;
+}
diff --git a/webapp/src/components/comments/comments_panel.tsx b/webapp/src/components/comments/comments_panel.tsx
new file mode 100644
index 0000000..0929d84
--- /dev/null
+++ b/webapp/src/components/comments/comments_panel.tsx
@@ -0,0 +1,33 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react';
+import {FormattedMessage, useIntl} from 'react-intl';
+
+import RhsPanel from 'components/rhs/rhs_panel';
+
+import styles from './comments_panel.module.scss';
+
+// Placeholder: the panel opens, closes and resizes like the rest of the RHS, and
+// carries its state in the URL, but has no comments in it yet. Here so the header's
+// Comments control leads somewhere while threads are built out.
+const CommentsPanel = ({onClose}: {onClose: () => void}) => {
+ const {formatMessage} = useIntl();
+
+ return (
+
+
+
+
+
+ );
+};
+
+export default CommentsPanel;
diff --git a/webapp/src/components/confirm_modal/confirm_modal.test.tsx b/webapp/src/components/confirm_modal/confirm_modal.test.tsx
index d839bee..ac481e9 100644
--- a/webapp/src/components/confirm_modal/confirm_modal.test.tsx
+++ b/webapp/src/components/confirm_modal/confirm_modal.test.tsx
@@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
-import {fireEvent, screen} from '@testing-library/react';
+import {fireEvent, screen, waitFor} from '@testing-library/react';
import React from 'react';
import ConfirmModal from './confirm_modal';
@@ -28,24 +28,39 @@ describe('ConfirmModal', () => {
expect(screen.getByRole('button', {name: 'Cancel'})).toBeInTheDocument();
});
- it('fires onConfirm and onCancel from the respective buttons', () => {
+ // Both handlers run after the modal has animated out, not on the click: the
+ // button dismisses the modal through its own close so the exit transition can
+ // play, and the handler — which typically unmounts the modal — runs once it has.
+ it('fires onConfirm from the confirm button, after the modal closes', async () => {
const onConfirm = jest.fn();
- const onCancel = jest.fn();
renderWithContext(
{'Body'}
,
);
fireEvent.click(screen.getByRole('button', {name: 'Confirm'}));
- expect(onConfirm).toHaveBeenCalledTimes(1);
+
+ await waitFor(() => expect(onConfirm).toHaveBeenCalledTimes(1));
+ });
+
+ it('fires onCancel from the cancel button, after the modal closes', async () => {
+ const onCancel = jest.fn();
+ renderWithContext(
+
+
{'Body'}
+ ,
+ );
fireEvent.click(screen.getByRole('button', {name: 'Cancel'}));
- expect(onCancel).toHaveBeenCalledTimes(1);
+
+ await waitFor(() => expect(onCancel).toHaveBeenCalledTimes(1));
});
it('renders custom button labels', () => {
diff --git a/webapp/src/components/confirm_modal/confirm_modal.tsx b/webapp/src/components/confirm_modal/confirm_modal.tsx
index 7fb12c6..f5ace25 100644
--- a/webapp/src/components/confirm_modal/confirm_modal.tsx
+++ b/webapp/src/components/confirm_modal/confirm_modal.tsx
@@ -4,8 +4,8 @@
import React, {useRef} from 'react';
import {FormattedMessage} from 'react-intl';
-import {DestructiveButton, PrimaryButton, TertiaryButton} from 'components/form-controls/button';
-import GenericModal from 'components/generic_modal/generic_modal';
+import {DestructiveButton, PrimaryButton, TertiaryButton} from 'components/form_controls/button';
+import GenericModal, {useModalClose} from 'components/generic_modal/generic_modal';
import styles from './confirm_modal.module.scss';
@@ -40,23 +40,41 @@ const ConfirmModal = ({title, children, confirmButtonText, cancelButtonText, isC
/>
);
- const footer = (
- <>
-
- {cancelLabel}
-
-
- {confirmLabel}
-
- >
- );
+ // Rendered inside the modal rather than built here, so it can reach
+ // `useModalClose`: both buttons dismiss the modal as well as acting, and going
+ // through the modal's own close lets it animate out before the handler runs and
+ // unmounts it.
+ const Footer = () => {
+ const close = useModalClose();
+
+ // Not `close?.(action) ?? action()` — close returns void, so that would run
+ // the action a second time, immediately.
+ const dismiss = (action: () => void) => () => {
+ if (close) {
+ close(action);
+ } else {
+ action();
+ }
+ };
+
+ return (
+ <>
+
+ {cancelLabel}
+
+
+ {confirmLabel}
+
+ >
+ );
+ };
return (
}
>
{children}
diff --git a/webapp/src/components/create_space_modal/create_space_modal.test.tsx b/webapp/src/components/create_space_modal/create_space_modal.test.tsx
index b2b7231..ba23ef3 100644
--- a/webapp/src/components/create_space_modal/create_space_modal.test.tsx
+++ b/webapp/src/components/create_space_modal/create_space_modal.test.tsx
@@ -13,25 +13,17 @@ import {renderWithContext} from '../../../tests/react_testing_utils';
const team = makeTeam('team1', 'myteam');
-const takenSpaceState = {
- docs: {
- spaces: {taken: makeSpace('taken', 'Taken', 'team1')},
- spacesInTeam: {team1: new Set(['taken'])},
- pages: {},
- pagesInSpace: {},
- },
- currentTeam: team,
-};
-
function typeName(value: string) {
fireEvent.change(screen.getByLabelText('Space name'), {target: {value}});
}
describe('CreateSpaceModal', () => {
- // Isolate the create path from the mock data source's module-level fixture
- // store so the "valid submit" test doesn't mutate shared state.
+ // Stub the API create so the form path doesn't hit the network; the server
+ // assigns the opaque id.
beforeEach(() => {
- jest.spyOn(docsDataSource, 'createSpace').mockImplementation((input) => makeSpace(input.slug, input.title.trim()));
+ jest.spyOn(docsDataSource, 'createSpace').mockImplementation(
+ async (_teamId, input) => makeSpace('new-space-id', input.title.trim(), 'team1'),
+ );
});
afterEach(() => {
@@ -49,20 +41,6 @@ describe('CreateSpaceModal', () => {
expect(screen.getByRole('button', {name: 'Create'})).toBeEnabled();
});
- it('focuses the URL field and shows the error when the slug is already taken', async () => {
- renderWithContext(, {state: takenSpaceState});
-
- // Typing the name auto-derives the slug ("Taken" -> "taken"), which
- // collides with the existing space.
- typeName('Taken');
- fireEvent.click(screen.getByRole('button', {name: 'Create'}));
-
- const urlInput = await screen.findByLabelText('Space URL');
-
- await waitFor(() => expect(urlInput).toHaveFocus());
- expect(screen.getByText('That URL is already taken')).toBeInTheDocument();
- });
-
it('creates the space and closes on a valid submit', async () => {
const onClose = jest.fn();
const onCreated = jest.fn();
diff --git a/webapp/src/components/create_space_modal/create_space_modal.tsx b/webapp/src/components/create_space_modal/create_space_modal.tsx
index 5bdafd9..c7b15dd 100644
--- a/webapp/src/components/create_space_modal/create_space_modal.tsx
+++ b/webapp/src/components/create_space_modal/create_space_modal.tsx
@@ -4,17 +4,17 @@
import {useCreateSpace} from 'hooks/spaces';
import React from 'react';
import {useIntl} from 'react-intl';
+import {SpaceIcon} from 'utils/space_icon';
import {SPACE_DESCRIPTION_MAX_LENGTH, SPACE_NAME_MAX_LENGTH} from 'validation/space_schema';
import GlobeIcon from '@mattermost/compass-icons/components/globe';
import LockOutlineIcon from '@mattermost/compass-icons/components/lock-outline';
-import {PrimaryButton, TertiaryButton} from 'components/form-controls/button';
-import type {SelectorOption} from 'components/form-controls/public_private_selector';
-import PublicPrivateSelector from 'components/form-controls/public_private_selector';
-import TextArea from 'components/form-controls/text_area';
-import TextInput from 'components/form-controls/text_input';
-import UrlInput from 'components/form-controls/url_input';
+import {PrimaryButton, TertiaryButton} from 'components/form_controls/button';
+import type {SelectorOption} from 'components/form_controls/public_private_selector';
+import PublicPrivateSelector from 'components/form_controls/public_private_selector';
+import TextArea from 'components/form_controls/text_area';
+import TextInput from 'components/form_controls/text_input';
import GenericModal from 'components/generic_modal/generic_modal';
import type {Space, SpaceVisibility} from 'types/docs';
@@ -27,12 +27,10 @@ type Props = {
onCreated?: (space: Space) => void;
};
-const DEFAULT_SPACE_EMOJI = '📄';
-
const CreateSpaceModal = ({onClose, onCreated}: Props) => {
const {formatMessage} = useIntl();
- const {form, slugSchema, baseUrl, changeName, changeSlug, submit, urlInputRef} = useCreateSpace({
+ const {form, changeName, submit} = useCreateSpace({
onCreated: (space) => {
onCreated?.(space);
onClose();
@@ -98,7 +96,7 @@ const CreateSpaceModal = ({onClose, onCreated}: Props) => {
label={formatMessage({id: 'docs.createSpace.nameLabel', defaultMessage: 'Space name'})}
value={field.state.value}
onChange={changeName}
- leading={{DEFAULT_SPACE_EMOJI}}
+ leading={}
error={firstSpaceValidationError(field.state.meta.errors, formatMessage)}
maxLength={SPACE_NAME_MAX_LENGTH}
autoFocus={true}
@@ -106,23 +104,6 @@ const CreateSpaceModal = ({onClose, onCreated}: Props) => {
/>
)}
-
- {(field) => (
-
- )}
-
diff --git a/webapp/src/components/create_space_modal/validation_messages.ts b/webapp/src/components/create_space_modal/validation_messages.ts
index 2338938..cda84c8 100644
--- a/webapp/src/components/create_space_modal/validation_messages.ts
+++ b/webapp/src/components/create_space_modal/validation_messages.ts
@@ -3,17 +3,13 @@
import {defineMessages} from 'react-intl';
import type {IntlShape} from 'react-intl';
-import {SPACE_DESCRIPTION_MAX_LENGTH, SPACE_NAME_MAX_LENGTH, SPACE_SLUG_MAX_LENGTH, SpaceValidationError} from 'validation/space_schema';
+import {SPACE_DESCRIPTION_MAX_LENGTH, SPACE_NAME_MAX_LENGTH, SpaceValidationError} from 'validation/space_schema';
type FormatMessage = IntlShape['formatMessage'];
const messages = defineMessages({
nameRequired: {id: 'docs.createSpace.error.name.required', defaultMessage: 'Please enter a name for the space'},
nameTooLong: {id: 'docs.createSpace.error.name.tooLong', defaultMessage: 'Name must be {max} characters or fewer'},
- urlRequired: {id: 'docs.createSpace.error.url.required', defaultMessage: 'Please enter a URL for the space'},
- urlTooLong: {id: 'docs.createSpace.error.url.tooLong', defaultMessage: 'URL must be {max} characters or fewer'},
- urlInvalid: {id: 'docs.createSpace.error.url.invalid', defaultMessage: 'Use lowercase letters, numbers, and dashes, with no spaces'},
- urlTaken: {id: 'docs.createSpace.error.url.taken', defaultMessage: 'That URL is already taken'},
descriptionTooLong: {id: 'docs.createSpace.error.description.tooLong', defaultMessage: 'Description must be {max} characters or fewer'},
});
@@ -26,14 +22,6 @@ export function resolveSpaceValidationError(id: string, formatMessage: FormatMes
return formatMessage(messages.nameRequired);
case SpaceValidationError.NameTooLong:
return formatMessage(messages.nameTooLong, {max: SPACE_NAME_MAX_LENGTH});
- case SpaceValidationError.UrlRequired:
- return formatMessage(messages.urlRequired);
- case SpaceValidationError.UrlTooLong:
- return formatMessage(messages.urlTooLong, {max: SPACE_SLUG_MAX_LENGTH});
- case SpaceValidationError.UrlInvalid:
- return formatMessage(messages.urlInvalid);
- case SpaceValidationError.UrlTaken:
- return formatMessage(messages.urlTaken);
case SpaceValidationError.DescriptionTooLong:
return formatMessage(messages.descriptionTooLong, {max: SPACE_DESCRIPTION_MAX_LENGTH});
default:
diff --git a/webapp/src/components/delete_page_modal/delete_page_modal.test.tsx b/webapp/src/components/delete_page_modal/delete_page_modal.test.tsx
new file mode 100644
index 0000000..3ea2ad5
--- /dev/null
+++ b/webapp/src/components/delete_page_modal/delete_page_modal.test.tsx
@@ -0,0 +1,130 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {fireEvent, screen, waitFor} from '@testing-library/react';
+import {createMemoryHistory} from 'history';
+import React from 'react';
+
+import {makePage, makeSpace, makeTeam} from 'store/test_fixtures';
+
+import {toast} from 'components/toast';
+
+import type {Page} from 'types/docs';
+
+import DeletePageModal from './delete_page_modal';
+
+import {renderWithContext} from '../../../tests/react_testing_utils';
+
+const mockDeletePage = jest.fn();
+let mockDeleteResult: Promise = Promise.resolve();
+
+// deletePage is a thunk the modal awaits, so the mock has to be a thunk too.
+jest.mock('store/actions', () => ({
+ ...jest.requireActual('store/actions'),
+ deletePage: (...args: unknown[]) => {
+ mockDeletePage(...args as []);
+ return async () => mockDeleteResult;
+ },
+}));
+
+jest.mock('components/toast', () => ({toast: {error: jest.fn()}}));
+
+const TEAM = makeTeam('team1id', 'team1');
+const SPACE = makeSpace('space1', 'Engineering', TEAM.id);
+
+const child = (id: string, parentId: string): Page => ({
+ ...makePage(id, SPACE.id, id),
+ parent_id: parentId,
+});
+
+// parent
+// childOfParent
+// unrelated
+const PAGES: Page[] = [makePage('parent', SPACE.id, 'Parent'), child('childOfParent', 'parent'), makePage('unrelated', SPACE.id, 'Unrelated')];
+
+const PAGE_PATH = `/team1/spaces/${SPACE.id}/childOfParent`;
+const SPACE_PATH = `/team1/spaces/${SPACE.id}`;
+
+const renderModal = (deletingPageId: string) => {
+ const history = createMemoryHistory({initialEntries: [PAGE_PATH]});
+ const onClose = jest.fn();
+
+ const rendered = renderWithContext(
+ ,
+ {
+ history,
+ state: {
+ currentTeam: TEAM,
+ docs: {
+ spaces: {[SPACE.id]: SPACE},
+ pages: Object.fromEntries(PAGES.map((page) => [page.id, page])),
+ pagesInSpace: {[SPACE.id]: new Set(PAGES.map((page) => page.id))},
+ },
+ },
+ },
+ );
+
+ return {...rendered, history, onClose};
+};
+
+const confirmDelete = () => fireEvent.click(screen.getByRole('button', {name: 'Delete'}));
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ mockDeleteResult = Promise.resolve();
+});
+
+describe('DeletePageModal', () => {
+ it('sends the viewer to the space, not Docs home, when the routed page is deleted', async () => {
+ const {history} = renderModal('childOfParent');
+
+ confirmDelete();
+
+ await waitFor(() => expect(history.location.pathname).toBe(SPACE_PATH));
+ expect(mockDeletePage).toHaveBeenCalledWith(SPACE.id, 'childOfParent');
+ });
+
+ it('replaces the deleted page in history rather than pushing, so Back cannot return to it', async () => {
+ const {history} = renderModal('childOfParent');
+
+ confirmDelete();
+
+ await waitFor(() => expect(history.location.pathname).toBe(SPACE_PATH));
+ expect(history.length).toBe(1);
+ });
+
+ // Subpages are deleted with their parent, so viewing a descendant of the
+ // deleted page loses the page too — the exact-match check missed this.
+ it('sends the viewer to the space when an ancestor of the routed page is deleted', async () => {
+ const {history} = renderModal('parent');
+
+ confirmDelete();
+
+ await waitFor(() => expect(history.location.pathname).toBe(SPACE_PATH));
+ });
+
+ it('stays put when the deleted page is not the one being viewed', async () => {
+ const {history, onClose} = renderModal('unrelated');
+
+ confirmDelete();
+
+ await waitFor(() => expect(onClose).toHaveBeenCalled());
+ expect(history.location.pathname).toBe(PAGE_PATH);
+ });
+
+ it('surfaces a failure and leaves the viewer on the page', async () => {
+ mockDeleteResult = Promise.reject(new Error('nope'));
+
+ const {history} = renderModal('childOfParent');
+
+ confirmDelete();
+
+ await waitFor(() => expect(toast.error).toHaveBeenCalled());
+ expect(history.location.pathname).toBe(PAGE_PATH);
+ });
+});
diff --git a/webapp/src/components/delete_page_modal/delete_page_modal.tsx b/webapp/src/components/delete_page_modal/delete_page_modal.tsx
new file mode 100644
index 0000000..127f8c0
--- /dev/null
+++ b/webapp/src/components/delete_page_modal/delete_page_modal.tsx
@@ -0,0 +1,85 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {useDocsNavigation} from 'hooks/navigation';
+import {useAppDispatch, useAppSelector} from 'hooks/redux';
+import React from 'react';
+import {FormattedMessage} from 'react-intl';
+
+import {deletePage} from 'store/actions';
+import {isPageInSubtree} from 'store/selectors';
+
+import ConfirmModal from 'components/confirm_modal/confirm_modal';
+import {toast} from 'components/toast';
+
+type Props = {
+ spaceId: string;
+ pageId: string;
+ pageTitle: string;
+ onClose: () => void;
+};
+
+/**
+ * Confirms deleting a page and its subpages. Deleting the page being viewed — or
+ * any of its ancestors, since subpages go with it — leaves nothing to show, so
+ * the viewer lands on the space's home URL. That keeps them in the space and lets
+ * the default-landing-page redirect apply, rather than ejecting them to Docs home.
+ */
+const DeletePageModal = ({spaceId, pageId, pageTitle, onClose}: Props) => {
+ const dispatch = useAppDispatch();
+ const {pageId: routedPageId, goToSpace} = useDocsNavigation();
+
+ // Resolved before the delete, while the subtree is still in the store.
+ const viewingDeletedSubtree = useAppSelector((state) =>
+ (routedPageId ? isPageInSubtree(state, pageId, routedPageId) : false));
+
+ const confirm = async () => {
+ try {
+ await dispatch(deletePage(spaceId, pageId));
+ if (viewingDeletedSubtree) {
+ goToSpace(spaceId, {replace: true});
+ }
+ } catch (error) {
+ toast.error(
+ ,
+ {description: error instanceof Error ? error.message : String(error)},
+ );
+ }
+ onClose();
+ };
+
+ return (
+
+ )}
+ confirmButtonText={(
+
+ )}
+ isConfirmDestructive={true}
+ onConfirm={confirm}
+ onCancel={onClose}
+ >
+ {chunks},
+ }}
+ />
+
+ );
+};
+
+export default DeletePageModal;
diff --git a/webapp/src/components/discard_draft_modal/discard_draft_modal.tsx b/webapp/src/components/discard_draft_modal/discard_draft_modal.tsx
new file mode 100644
index 0000000..fdd9bb4
--- /dev/null
+++ b/webapp/src/components/discard_draft_modal/discard_draft_modal.tsx
@@ -0,0 +1,85 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {useDocsNavigation} from 'hooks/navigation';
+import {useAppDispatch} from 'hooks/redux';
+import React from 'react';
+import {FormattedMessage} from 'react-intl';
+
+import {discardDraft} from 'store/actions';
+
+import ConfirmModal from 'components/confirm_modal/confirm_modal';
+import {toast} from 'components/toast';
+
+type Props = {
+ spaceId: string;
+ pageId: string;
+ pageTitle: string;
+ onClose: () => void;
+};
+
+/**
+ * Confirms discarding an unpublished page.
+ *
+ * Worth a confirm even though nothing published is at risk: the draft *is* the
+ * page, so discarding it destroys the only copy. Viewing it at the time leaves
+ * nothing to show, so the viewer lands back in the space.
+ */
+const DiscardDraftModal = ({spaceId, pageId, pageTitle, onClose}: Props) => {
+ const dispatch = useAppDispatch();
+ const {pageId: routedPageId, isDraft, goToSpace} = useDocsNavigation();
+
+ const viewingThisDraft = isDraft && routedPageId === pageId;
+
+ const confirm = async () => {
+ try {
+ await dispatch(discardDraft(spaceId, pageId));
+ if (viewingThisDraft) {
+ // The draft URL names something that no longer exists, so it is not
+ // somewhere Back should return to.
+ goToSpace(spaceId, {replace: true});
+ }
+ } catch (error) {
+ toast.error(
+ ,
+ {description: error instanceof Error ? error.message : String(error)},
+ );
+ }
+ onClose();
+ };
+
+ return (
+
+ )}
+ confirmButtonText={(
+
+ )}
+ isConfirmDestructive={true}
+ onConfirm={confirm}
+ onCancel={onClose}
+ >
+ {chunks},
+ }}
+ />
+
+ );
+};
+
+export default DiscardDraftModal;
diff --git a/webapp/src/components/docs_home/docs_home.module.scss b/webapp/src/components/docs_home/docs_home.module.scss
index e859b54..0bacc5d 100644
--- a/webapp/src/components/docs_home/docs_home.module.scss
+++ b/webapp/src/components/docs_home/docs_home.module.scss
@@ -7,17 +7,7 @@
background: var(--center-channel-bg);
}
-.header {
- display: flex;
- flex: 0 0 auto;
- gap: 16px;
- align-items: center;
- padding: 12px 20px;
- border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.12);
-}
-
.headerTitle {
- flex: 1 1 auto;
margin: 0;
color: var(--center-channel-color);
font-family: 'Metropolis', sans-serif;
diff --git a/webapp/src/components/docs_home/docs_home.test.tsx b/webapp/src/components/docs_home/docs_home.test.tsx
index 397a80f..b516636 100644
--- a/webapp/src/components/docs_home/docs_home.test.tsx
+++ b/webapp/src/components/docs_home/docs_home.test.tsx
@@ -19,14 +19,28 @@ jest.mock('hooks/user', () => ({useCurrentUser: () => ({name: 'Sam'})}));
jest.mock('hooks/navigation', () => ({useDocsNavigation: () => ({goToSpace: mockGoToSpace})}));
jest.mock('hooks/spaces', () => ({useRecentSpaceSummaries: () => mockSummaries}));
+const TEAM = {id: 'team1', name: 'team-1'};
+
+// An entry for the team means "this team's spaces are loaded" (see
+// areSpacesLoadedForCurrentTeam); without it Home can't tell an empty team from
+// one whose spaces are still in flight.
+const spacesLoaded = {currentTeam: TEAM, docs: {spacesInTeam: {[TEAM.id]: new Set()}}};
+
describe('DocsHome', () => {
beforeEach(() => {
mockSummaries = [];
});
+ it('renders only the header until the team spaces have loaded', () => {
+ renderWithContext(, {state: {currentTeam: TEAM}});
+
+ expect(screen.getByRole('button', {name: 'New Space'})).toBeInTheDocument();
+ expect(screen.queryByText('Welcome to Docs.')).not.toBeInTheDocument();
+ });
+
it('shows the welcome hero and wires the create CTAs when there are no spaces', () => {
const onCreateSpace = jest.fn();
- renderWithContext();
+ renderWithContext(, {state: spacesLoaded});
expect(screen.getByText('Welcome to Docs.')).toBeInTheDocument();
@@ -42,6 +56,7 @@ describe('DocsHome', () => {
onCreateSpace={jest.fn()}
onBrowseSpaces={onBrowseSpaces}
/>,
+ {state: spacesLoaded},
);
fireEvent.click(screen.getByRole('button', {name: 'Browse spaces'}));
@@ -51,7 +66,7 @@ describe('DocsHome', () => {
it('greets the user and lists recent spaces, navigating on click', () => {
mockSummaries = [{space: {...makeSpace('eng', 'Engineering'), icon: '📘'}, pageCount: 3, lastViewedAt: Date.now() - (12 * 60 * 1000)}];
- renderWithContext();
+ renderWithContext(, {state: spacesLoaded});
expect(screen.getByText(/Good (morning|afternoon|evening), Sam\./)).toBeInTheDocument();
diff --git a/webapp/src/components/docs_home/docs_home.tsx b/webapp/src/components/docs_home/docs_home.tsx
index 1226e62..4bb0961 100644
--- a/webapp/src/components/docs_home/docs_home.tsx
+++ b/webapp/src/components/docs_home/docs_home.tsx
@@ -2,10 +2,12 @@
// See LICENSE.txt for license information.
import {useDocsNavigation} from 'hooks/navigation';
+import {useAppSelector} from 'hooks/redux';
import {useRecentSpaceSummaries} from 'hooks/spaces';
import {useCurrentUser} from 'hooks/user';
import React from 'react';
import {FormattedMessage, defineMessages, useIntl} from 'react-intl';
+import {SpaceIcon} from 'utils/space_icon';
import {Timestamp} from 'webapp_globals';
import type {TimestampUnit} from 'webapp_globals';
@@ -14,7 +16,10 @@ import NotebookOutlineIcon from '@mattermost/compass-icons/components/notebook-o
import PlusIcon from '@mattermost/compass-icons/components/plus';
import SearchListIcon from '@mattermost/compass-icons/components/search-list';
-import {PrimaryButton, TertiaryButton} from 'components/form-controls/button';
+import {areSpacesLoadedForCurrentTeam} from 'store/selectors';
+
+import {PrimaryButton, TertiaryButton} from 'components/form_controls/button';
+import Header from 'components/header/header';
import type {SpaceSummary} from 'types/docs';
@@ -34,22 +39,38 @@ const DocsHome = ({onCreateSpace, onBrowseSpaces}: Props) => {
const {name} = useCurrentUser();
const {goToSpace} = useDocsNavigation();
const summaries = useRecentSpaceSummaries();
+ const spacesLoaded = useAppSelector(areSpacesLoadedForCurrentTeam);
const header = (
-
-
-
-
- {formatMessage({id: 'docs.home.newSpace', defaultMessage: 'New Space'})}
-
-
+
+ {formatMessage({id: 'docs.home.title', defaultMessage: 'Home'})}
+
+ }
+ right={
+
+
+ {formatMessage({id: 'docs.home.newSpace', defaultMessage: 'New Space'})}
+
+ }
+ />
);
+ // Only an empty list that's actually settled means "no spaces". Until the
+ // team's spaces arrive the list is empty for a different reason, and showing
+ // the welcome hero would flash it at every returning user.
+ if (!spacesLoaded) {
+ return (
+
+ {header}
+
+ );
+ }
+
if (summaries.length === 0) {
return (
@@ -156,21 +177,21 @@ const SpaceCard = ({summary, onOpen}: {summary: SpaceSummary; onOpen: (id: strin
const {formatMessage} = useIntl();
const {space, pageCount, lastViewedAt} = summary;
- const pages = formatMessage(
+ const pages = pageCount === undefined ? null : formatMessage(
{id: 'docs.home.space.pageCount', defaultMessage: '{count, plural, one {# page} other {# pages}}'},
{count: pageCount},
);
// Timestamp's `style` is a narrow/short/long format variant, not a DOM style object.
/* eslint-disable react/style-prop-object */
- const relative = lastViewedAt !== undefined && Timestamp ? (
+ const relative = lastViewedAt === undefined ? null : (
- ) : null;
+ );
/* eslint-enable react/style-prop-object */
return (
@@ -183,7 +204,10 @@ const SpaceCard = ({summary, onOpen}: {summary: SpaceSummary; onOpen: (id: strin
className={styles.spaceCardEmoji}
aria-hidden='true'
>
- {space.icon}
+ {space.title}
@@ -192,8 +216,10 @@ const SpaceCard = ({summary, onOpen}: {summary: SpaceSummary; onOpen: (id: strin
<>
{pages}
- {/* eslint-disable-next-line formatjs/no-literal-string-in-jsx -- decorative separator between metadata segments */}
- {' · '}
+ {pages ? (
+ // eslint-disable-next-line formatjs/no-literal-string-in-jsx -- decorative separator between metadata segments
+ <>{' · '}>
+ ) : null}
void;
onBrowseSpaces: () => void;
};
-// The space view is built later; for now a routed space renders a placeholder
-// that reflects the routed space/page. When a page is routed we hand off to
-// PageEditor
-const DocsMainContent = ({spaceId, pageId, isDraft, onCreateSpace, onBrowseSpaces}: Props) => {
+// Routes the product's main column: a routed space renders its view, the product
+// root (anything else) renders Home.
+const DocsMainContent = ({onCreateSpace, onBrowseSpaces}: Props) => {
+ const home = (
+
+ );
+
+ return (
+
+ {/* The draft pattern is listed first: the generic space route would
+ otherwise capture 'drafts' as the page id. Both render the space
+ view, which reads the parsed selection from the URL. */}
+
+
+
+ {home}
+
+ );
+};
+
+// Resolves the routed space id against the store. Once the team's spaces are
+// loaded, an id that still isn't there names a space the user can't see (or one
+// that's gone), so the URL is corrected to the product home; until then the id
+// may simply not have arrived, so nothing renders rather than flashing Home.
+const RoutedSpaceView = () => {
+ const {spaceId, paths} = useDocsNavigation();
const space = useSpace(spaceId);
+ const spacesLoaded = useAppSelector(areSpacesLoadedForCurrentTeam);
- if (!space) {
- return (
-
- );
+ if (space) {
+ return ;
}
-
- if (pageId) {
- return (
-
- );
+ if (spacesLoaded) {
+ return ;
}
-
- return (
-
);
};
diff --git a/webapp/src/components/docs_root/docs_root_lazy.tsx b/webapp/src/components/docs_root/docs_root_lazy.tsx
new file mode 100644
index 0000000..5439bb5
--- /dev/null
+++ b/webapp/src/components/docs_root/docs_root_lazy.tsx
@@ -0,0 +1,19 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React, {Suspense} from 'react';
+
+// The Docs product UI ("backstage") and its whole import tree are code-split
+// into their own async chunk, so the plugin's initial bundle stays small and
+// the UI loads only when the product mounts. webpack fetches the chunk at
+// runtime; output.publicPath: 'auto' resolves it relative to the served plugin
+// bundle (no hardcoded plugin static path).
+const DocsRoot = React.lazy(() => import(/* webpackChunkName: "docs-backstage" */ './docs_root'));
+
+const DocsRootLazy = () => (
+
+
+
+);
+
+export default DocsRootLazy;
diff --git a/webapp/src/components/docs_switcher/docs_switcher.tsx b/webapp/src/components/docs_switcher/docs_switcher.tsx
index db02793..2050a62 100644
--- a/webapp/src/components/docs_switcher/docs_switcher.tsx
+++ b/webapp/src/components/docs_switcher/docs_switcher.tsx
@@ -4,15 +4,20 @@
import classNames from 'classnames';
import {useDocsSearch, useRecentDocs} from 'hooks/docs';
import {useDocsNavigation} from 'hooks/navigation';
+import {useAppDispatch} from 'hooks/redux';
import {useAllSpaces} from 'hooks/spaces';
-import {useTeamNamesById} from 'hooks/team';
+import {useTeamContext, useTeamDisplayNamesById, useTeamNamesById} from 'hooks/team';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
+import {SpaceIcon} from 'utils/space_icon';
import MagnifyIcon from '@mattermost/compass-icons/components/magnify';
import TextBoxOutlineIcon from '@mattermost/compass-icons/components/text-box-outline';
+import {fetchAllSpaces} from 'store/actions';
+
import GenericModal from 'components/generic_modal/generic_modal';
+import Spacer from 'components/spacer/spacer';
import type {Page, Space} from 'types/docs';
@@ -33,8 +38,15 @@ const optionId = (index: number) => `docs-switcher-option-${index}`;
const DocsSwitcher = ({onClose}: Props) => {
const {formatMessage} = useIntl();
+ const dispatch = useAppDispatch();
const {navigate, navigateInTeam} = useDocsNavigation();
const [query, setQuery] = useState('');
+
+ // The sidebar only loads the current team; the switcher is cross-team, so
+ // pull every team's spaces when it opens.
+ useEffect(() => {
+ dispatch(fetchAllSpaces());
+ }, [dispatch]);
const [activeIndex, setActiveIndex] = useState(0);
const inputRef = useRef(null);
const trimmed = query.trim().toLowerCase();
@@ -44,12 +56,21 @@ const DocsSwitcher = ({onClose}: Props) => {
const recent = useRecentDocs();
const results = useDocsSearch(query);
const teamNamesById = useTeamNamesById();
+ const teamDisplayNamesById = useTeamDisplayNamesById();
+ const {id: currentTeamId} = useTeamContext();
// Cross-team: each space's title and owning team, so a result can render its
// space label and route to that space's own team.
const spaceMetaById = useMemo(
- () => new Map(allSpaces.map((space) => [space.id, {title: space.title, teamName: teamNamesById.get(space.team_id)}])),
- [allSpaces, teamNamesById],
+ () => new Map(allSpaces.map((space) => [space.id, {
+ title: space.title,
+ teamName: teamNamesById.get(space.team_id),
+
+ // Only set for a space outside the current team, so the result can be
+ // labelled with the team it actually lives in.
+ otherTeam: space.team_id === currentTeamId ? undefined : teamDisplayNamesById.get(space.team_id),
+ }])),
+ [allSpaces, teamNamesById, teamDisplayNamesById, currentTeamId],
);
const groups: Group[] = useMemo(() => {
@@ -65,16 +86,20 @@ const DocsSwitcher = ({onClose}: Props) => {
}];
}
+ const recentSpaceIds = new Set(recent.spaces.map((space) => space.id));
+
return [
{
id: 'recent',
- title: formatMessage({id: 'docs.switcher.group.recent', defaultMessage: 'Recent docs'}),
+ title: formatMessage({id: 'docs.switcher.group.recent', defaultMessage: 'Recent'}),
entries: [...recent.spaces.map(spaceEntry), ...recent.pages.map(pageEntry)],
},
{
id: 'spaces',
title: formatMessage({id: 'docs.switcher.group.spaces', defaultMessage: 'Your spaces'}),
- entries: allSpaces.map(spaceEntry),
+
+ // Don't repeat a space already surfaced under Recent above.
+ entries: allSpaces.filter((space) => !recentSpaceIds.has(space.id)).map(spaceEntry),
},
];
}, [hasQuery, results, recent, allSpaces, formatMessage]);
@@ -140,31 +165,43 @@ const DocsSwitcher = ({onClose}: Props) => {
const title = hasQuery ? formatMessage({id: 'docs.switcher.title.query', defaultMessage: 'Find spaces or pages'}) : formatMessage({id: 'docs.switcher.title', defaultMessage: 'Find docs'});
const placeholder = formatMessage({id: 'docs.switcher.placeholder', defaultMessage: 'Search all spaces and pages'});
- const renderEntry = (entry: Entry) => (
-
- );
+ const renderEntry = (entry: Entry) => {
+ // The trailing slot names where a result lives: a space in another team is
+ // labelled with that team, a page with its space.
+ const meta = entry.kind === 'space' ? spaceMetaById.get(entry.space.id)?.otherTeam : spaceMetaById.get(entry.page.space_id)?.title;
+
+ return (
+
+ );
+ };
const searchField = (
diff --git a/webapp/src/components/form-controls/button.tsx b/webapp/src/components/form-controls/button.tsx
deleted file mode 100644
index 2fa878d..0000000
--- a/webapp/src/components/form-controls/button.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
-// See LICENSE.txt for license information.
-
-import React from 'react';
-
-import {Button} from '@mattermost/shared/components/button';
-import type {ButtonProps} from '@mattermost/shared/components/button';
-
-// The Docs plugin avoids styled-components (its browser ESM references `process`
-// and throws in the plugin runtime), so these are plain functional wrappers that
-// bind the emphasis/variant preset.
-
-// The preset is applied first so an explicit prop can still override it.
-function createButton(displayName: string, preset: Pick) {
- const Wrapped = React.forwardRef((props, ref) => (
-
- ));
- Wrapped.displayName = displayName;
- return Wrapped;
-}
-
-export const PrimaryButton = createButton('PrimaryButton', {emphasis: 'primary'});
-export const SecondaryButton = createButton('SecondaryButton', {emphasis: 'secondary'});
-export const TertiaryButton = createButton('TertiaryButton', {emphasis: 'tertiary'});
-export const DestructiveButton = createButton('DestructiveButton', {emphasis: 'primary', variant: 'destructive'});
-
-export {Button};
-export type {ButtonProps};
diff --git a/webapp/src/components/form-controls/button.test.tsx b/webapp/src/components/form_controls/button.test.tsx
similarity index 100%
rename from webapp/src/components/form-controls/button.test.tsx
rename to webapp/src/components/form_controls/button.test.tsx
diff --git a/webapp/src/components/form_controls/button.tsx b/webapp/src/components/form_controls/button.tsx
new file mode 100644
index 0000000..3d62041
--- /dev/null
+++ b/webapp/src/components/form_controls/button.tsx
@@ -0,0 +1,97 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import classNames from 'classnames';
+import React from 'react';
+
+import {Button as SharedButton} from '@mattermost/shared/components/button';
+import type {ButtonProps as SharedButtonProps} from '@mattermost/shared/components/button';
+import {WithTooltip} from '@mattermost/shared/components/tooltip';
+
+// Neutral-grey treatment for `btn-icon` and `docs-btn-neutral` ghost buttons
+// (the shared Button is accent-only), plus the badge overlay; the :global rules
+// apply wherever these buttons render.
+import styles from './button_neutral.module.scss';
+
+// The Docs plugin avoids styled-components (its browser ESM references `process`
+// and throws in the plugin runtime), so these are plain functional wrappers that
+// bind the emphasis/variant preset.
+
+type ContentProps = Omit & {
+
+ /** Icon rendered before `children`. */
+ leadingIcon?: React.ReactNode;
+
+ /** Icon rendered after `children`. */
+ trailingIcon?: React.ReactNode;
+
+ /**
+ * Overlay indicator. `true` renders an unread dot; a number or string
+ * renders a count pill.
+ */
+ badge?: number | string | true;
+
+ /**
+ * Wraps the button in a hover tooltip. A string tooltip also supplies the
+ * accessible name when `aria-label` is omitted.
+ */
+ tooltip?: React.ReactNode;
+};
+
+// Icon-only buttons (no children) carry no text, so they must name themselves.
+type LabelledProps = {children: React.ReactNode};
+type IconOnlyProps = {children?: undefined} & ({'aria-label': string} | {tooltip: string});
+
+export type ButtonProps = ContentProps & (LabelledProps | IconOnlyProps);
+
+// The preset is applied first so an explicit prop can still override it.
+function createButton(displayName: string, preset: Pick) {
+ const Wrapped = React.forwardRef((props, ref) => {
+ const {
+ 'aria-label': ariaLabel,
+ badge,
+ children,
+ className,
+ leadingIcon,
+ tooltip,
+ trailingIcon,
+ ...rest
+ } = props;
+
+ const button = (
+
+ {leadingIcon}
+ {children}
+ {trailingIcon}
+ {badge === true && (
+
+ )}
+ {badge !== undefined && badge !== true && (
+ {badge}
+ )}
+
+ );
+
+ return tooltip ? {button} : button;
+ });
+ Wrapped.displayName = displayName;
+ return Wrapped;
+}
+
+export const Button = createButton('Button', {});
+export const PrimaryButton = createButton('PrimaryButton', {emphasis: 'primary'});
+export const SecondaryButton = createButton('SecondaryButton', {emphasis: 'secondary'});
+export const TertiaryButton = createButton('TertiaryButton', {emphasis: 'tertiary'});
+export const DestructiveButton = createButton('DestructiveButton', {emphasis: 'primary', variant: 'destructive'});
+
+export type {SharedButtonProps};
diff --git a/webapp/src/components/form_controls/button_neutral.module.scss b/webapp/src/components/form_controls/button_neutral.module.scss
new file mode 100644
index 0000000..5dee77f
--- /dev/null
+++ b/webapp/src/components/form_controls/button_neutral.module.scss
@@ -0,0 +1,82 @@
+/*
+ * PORT-TO-CORE: the shared Button is accent-only (every emphasis colors with
+ * --button-bg). These :global rules restore core's neutral header-icon-button
+ * behaviour for ghost buttons that should read as toolbar controls:
+ * - `.btn-icon` quaternary icon buttons (core's .btn-icon grey is otherwise
+ * overridden by the later emphasis color, so raise its specificity)
+ * - quaternary buttons marked `docs-btn-neutral` that carry a label
+ * States mirror core's .btn-icon / channel-header icon: grey base, light-grey
+ * hover, light-blue press (:active), persistent blue when toggled on (.active).
+ * Fold the icon rules into core's .btn-icon; the label rules are a stopgap
+ * until a neutral emphasis exists upstream.
+ */
+
+// Badge overlay (see the `badge` prop on the Button wrappers). Local classes —
+// the wrapper imports this stylesheet for both the badge and the rules below.
+.badged {
+ position: relative;
+}
+
+.badgeDot {
+ position: absolute;
+ top: 6px;
+ right: 6px;
+ width: 8px;
+ height: 8px;
+ border: 2px solid var(--center-channel-bg);
+ border-radius: 50%;
+ background: var(--button-bg);
+}
+
+.badgeCount {
+ position: absolute;
+ top: 2px;
+ right: 2px;
+ display: flex;
+ min-width: 16px;
+ height: 16px;
+ align-items: center;
+ justify-content: center;
+ padding: 0 4px;
+ border: 2px solid var(--center-channel-bg);
+ border-radius: 8px;
+ background: var(--button-bg);
+ color: var(--button-color);
+ font-size: 10px;
+ font-weight: 600;
+ line-height: 12px;
+}
+
+// Base — neutral grey.
+:global(.btn.btn-icon.btn-quaternary),
+:global(.btn.btn-quaternary.docs-btn-neutral) {
+ color: rgba(var(--center-channel-color-rgb), 0.64);
+}
+
+// Hover — light-grey wash (replaces btn-quaternary's accent-blue hover).
+:global(.btn.btn-icon.btn-quaternary:hover),
+:global(.btn.btn-icon.btn-quaternary.btn-force-hover),
+:global(.btn.btn-quaternary.docs-btn-neutral:hover),
+:global(.btn.btn-quaternary.docs-btn-neutral.btn-force-hover) {
+ background: rgba(var(--center-channel-color-rgb), 0.08);
+ color: var(--center-channel-color);
+}
+
+// Pressed — light-blue wash + accent color. Declared after :hover so a hovered
+// press reads blue, not grey.
+:global(.btn.btn-icon.btn-quaternary:active),
+:global(.btn.btn-icon.btn-quaternary.btn-force-active),
+:global(.btn.btn-quaternary.docs-btn-neutral:active),
+:global(.btn.btn-quaternary.docs-btn-neutral.btn-force-active) {
+ background: rgba(var(--button-bg-rgb), 0.08);
+ color: rgb(var(--button-bg-rgb));
+}
+
+// Toggled on (favorited / panel open) — persistent blue, overriding hover.
+:global(.btn.btn-icon.btn-quaternary.active),
+:global(.btn.btn-icon.btn-quaternary.active:hover),
+:global(.btn.btn-quaternary.docs-btn-neutral.active),
+:global(.btn.btn-quaternary.docs-btn-neutral.active:hover) {
+ background: rgba(var(--button-bg-rgb), 0.08);
+ color: rgb(var(--button-bg-rgb));
+}
diff --git a/webapp/src/components/form-controls/public_private_selector.module.scss b/webapp/src/components/form_controls/public_private_selector.module.scss
similarity index 100%
rename from webapp/src/components/form-controls/public_private_selector.module.scss
rename to webapp/src/components/form_controls/public_private_selector.module.scss
diff --git a/webapp/src/components/form-controls/public_private_selector.test.tsx b/webapp/src/components/form_controls/public_private_selector.test.tsx
similarity index 100%
rename from webapp/src/components/form-controls/public_private_selector.test.tsx
rename to webapp/src/components/form_controls/public_private_selector.test.tsx
diff --git a/webapp/src/components/form-controls/public_private_selector.tsx b/webapp/src/components/form_controls/public_private_selector.tsx
similarity index 100%
rename from webapp/src/components/form-controls/public_private_selector.tsx
rename to webapp/src/components/form_controls/public_private_selector.tsx
diff --git a/webapp/src/components/form_controls/select.module.scss b/webapp/src/components/form_controls/select.module.scss
new file mode 100644
index 0000000..5d88212
--- /dev/null
+++ b/webapp/src/components/form_controls/select.module.scss
@@ -0,0 +1,112 @@
+// Same chrome as TextInput: bordered field with the label floated over its top
+// border, a leading icon, the value, and a trailing chevron.
+.trigger {
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+ padding: 10px 16px;
+ border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
+ border-radius: 4px;
+ background: var(--center-channel-bg);
+ cursor: pointer;
+ text-align: left;
+
+ &:hover {
+ border-color: rgba(var(--center-channel-color-rgb), 0.24);
+ }
+
+ &[data-popup-open],
+ &:focus-visible {
+ border-color: var(--button-bg);
+ box-shadow: inset 0 0 0 1px var(--button-bg);
+ outline: none;
+ }
+
+ &[data-disabled] {
+ background: rgba(var(--center-channel-color-rgb), 0.04);
+ cursor: not-allowed;
+ }
+}
+
+.label {
+ position: absolute;
+ top: 0;
+ left: 12px;
+ padding: 0 4px;
+ background: var(--center-channel-bg);
+ color: rgba(var(--center-channel-color-rgb), 0.64);
+ font-size: 10px;
+ line-height: 12px;
+ transform: translateY(-50%);
+}
+
+.leadingIcon {
+ display: flex;
+ flex-shrink: 0;
+ align-items: center;
+ color: rgba(var(--center-channel-color-rgb), 0.64);
+}
+
+.value {
+ flex: 1 1 auto;
+ min-width: 0;
+ overflow: hidden;
+ color: var(--center-channel-color);
+ font-size: 14px;
+ line-height: 20px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.chevron {
+ display: flex;
+ flex-shrink: 0;
+ align-items: center;
+ color: rgba(var(--center-channel-color-rgb), 0.64);
+}
+
+.positioner {
+ z-index: var(--z-index-menu);
+}
+
+.popup {
+ min-width: var(--anchor-width);
+ max-height: 320px;
+ padding: 8px 0;
+ border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
+ border-radius: 4px;
+ background: var(--center-channel-bg);
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
+ overflow-y: auto;
+
+ &:focus {
+ outline: none;
+ }
+}
+
+.item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 16px;
+ cursor: pointer;
+
+ // Base UI highlights the focused item (keyboard or pointer) via this attr.
+ &[data-highlighted] {
+ background: rgba(var(--center-channel-color-rgb), 0.08);
+ outline: none;
+ }
+}
+
+.itemText {
+ flex: 1 1 auto;
+}
+
+.indicator {
+ display: flex;
+ flex-shrink: 0;
+ align-items: center;
+ color: var(--button-bg);
+}
diff --git a/webapp/src/components/form_controls/select.tsx b/webapp/src/components/form_controls/select.tsx
new file mode 100644
index 0000000..b0bc384
--- /dev/null
+++ b/webapp/src/components/form_controls/select.tsx
@@ -0,0 +1,123 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {Select as BaseSelect} from '@base-ui-components/react/select';
+import classNames from 'classnames';
+import React from 'react';
+
+import CheckIcon from '@mattermost/compass-icons/components/check';
+import ChevronDownIcon from '@mattermost/compass-icons/components/chevron-down';
+
+import styles from './select.module.scss';
+
+export type SelectOption = {
+ value: string;
+ label: string;
+ leadingIcon?: React.ReactNode;
+};
+
+type Props = {
+ id: string;
+
+ /** Floated label above the field, matching TextInput. */
+ label: string;
+ value: string;
+ options: SelectOption[];
+ onChange: (value: string) => void;
+ disabled?: boolean;
+};
+
+/**
+ * A single-select field with the same floated-label chrome as TextInput. Built
+ * on Base UI's Select, so keyboard navigation, typeahead and the listbox
+ * semantics come from the primitive.
+ */
+const Select = ({id, label, value, options, onChange, disabled}: Props) => {
+ const selected = options.find((option) => option.value === value);
+
+ return (
+ onChange(next == null ? '' : String(next))}
+ >
+
+ {label}
+ {selected?.leadingIcon && (
+
+ {selected.leadingIcon}
+
+ )}
+
+ {selected?.label ?? ''}
+
+
+
+
+
+
+ {/* alignItemWithTrigger is Base UI's default and is why this popup
+ would not stay with its trigger. In that mode the popup overlaps
+ the trigger so the selected item's text lines up with the trigger's
+ value — macOS-style — and to do it Base UI gives the positioner a
+ static style, sets `disableAnchorTracking`, reports side 'none' and
+ locks scrolling (SelectPositioner lines 96, 110, 114-115). floating-
+ ui's computed position is not used at all, side/align/sideOffset are
+ ignored, and nothing follows the trigger — so inside a scrollable
+ modal pane the popup sits where it first landed while the field
+ scrolls away from it.
+
+ Off, it is an ordinary anchored popup: tracking on, our side and
+ offsets honoured, and positionMethod below actually applied.
+
+ Viewport coordinates rather than document: the trigger can sit in a
+ modal, which is `position: fixed`, and matching that space keeps the
+ two from drifting apart on scroll. Equally correct outside a modal. */}
+
+
+
+ {options.map((option) => (
+
+ {option.leadingIcon && (
+
+ {option.leadingIcon}
+
+ )}
+
+ {option.label}
+
+
+
+
+
+ ))}
+
+
+
+
+
+ );
+};
+
+export default Select;
diff --git a/webapp/src/components/form-controls/text_area.module.scss b/webapp/src/components/form_controls/text_area.module.scss
similarity index 100%
rename from webapp/src/components/form-controls/text_area.module.scss
rename to webapp/src/components/form_controls/text_area.module.scss
diff --git a/webapp/src/components/form-controls/text_area.test.tsx b/webapp/src/components/form_controls/text_area.test.tsx
similarity index 100%
rename from webapp/src/components/form-controls/text_area.test.tsx
rename to webapp/src/components/form_controls/text_area.test.tsx
diff --git a/webapp/src/components/form-controls/text_area.tsx b/webapp/src/components/form_controls/text_area.tsx
similarity index 93%
rename from webapp/src/components/form-controls/text_area.tsx
rename to webapp/src/components/form_controls/text_area.tsx
index faddf58..f21275e 100644
--- a/webapp/src/components/form-controls/text_area.tsx
+++ b/webapp/src/components/form_controls/text_area.tsx
@@ -16,9 +16,10 @@ type Props = {
error?: string;
maxLength?: number;
rows?: number;
+ autoFocus?: boolean;
};
-const TextArea = ({id, label, value, onChange, error, maxLength, rows = 3}: Props) => {
+const TextArea = ({id, label, value, onChange, error, maxLength, rows = 3, autoFocus}: Props) => {
const errorId = error ? `${id}-error` : undefined;
return (
@@ -33,6 +34,7 @@ const TextArea = ({id, label, value, onChange, error, maxLength, rows = 3}: Prop
value={value}
rows={rows}
maxLength={maxLength}
+ autoFocus={autoFocus}
onChange={(e) => onChange(e.target.value)}
/>
{error && (
diff --git a/webapp/src/components/form-controls/text_input.module.scss b/webapp/src/components/form_controls/text_input.module.scss
similarity index 100%
rename from webapp/src/components/form-controls/text_input.module.scss
rename to webapp/src/components/form_controls/text_input.module.scss
diff --git a/webapp/src/components/form-controls/text_input.test.tsx b/webapp/src/components/form_controls/text_input.test.tsx
similarity index 100%
rename from webapp/src/components/form-controls/text_input.test.tsx
rename to webapp/src/components/form_controls/text_input.test.tsx
diff --git a/webapp/src/components/form-controls/text_input.tsx b/webapp/src/components/form_controls/text_input.tsx
similarity index 100%
rename from webapp/src/components/form-controls/text_input.tsx
rename to webapp/src/components/form_controls/text_input.tsx
diff --git a/webapp/src/components/form-controls/url_input.module.scss b/webapp/src/components/form_controls/url_input.module.scss
similarity index 100%
rename from webapp/src/components/form-controls/url_input.module.scss
rename to webapp/src/components/form_controls/url_input.module.scss
diff --git a/webapp/src/components/form-controls/url_input.test.tsx b/webapp/src/components/form_controls/url_input.test.tsx
similarity index 100%
rename from webapp/src/components/form-controls/url_input.test.tsx
rename to webapp/src/components/form_controls/url_input.test.tsx
diff --git a/webapp/src/components/form-controls/url_input.tsx b/webapp/src/components/form_controls/url_input.tsx
similarity index 100%
rename from webapp/src/components/form-controls/url_input.tsx
rename to webapp/src/components/form_controls/url_input.tsx
diff --git a/webapp/src/components/generic_modal/generic_modal.module.scss b/webapp/src/components/generic_modal/generic_modal.module.scss
index c161c34..8b5e14f 100644
--- a/webapp/src/components/generic_modal/generic_modal.module.scss
+++ b/webapp/src/components/generic_modal/generic_modal.module.scss
@@ -1,13 +1,20 @@
+// Each stacked modal gets a two-slot band of its own — backdrop, then popup —
+// starting from the app's modal layer. Depth comes from `--docs-modal-level`,
+// set by the modal stack (see components/modals). Without it every modal shared
+// one z-index and the order fell to whichever portal mounted first, which is why
+// a modal opened from another could end up under it.
+@function layer($slot) {
+ @return calc(var(--z-index-modal) + (var(--docs-modal-level, 0) * 2) + $slot);
+}
+
// Base UI's Dialog has no positioner, so the consumer positions the popup. This
// full-viewport flex container centers it and is click-through
// (pointer-events: none) so clicks on the dimmed area reach the backdrop to
-// dismiss. This and the backdrop sit in the app's modal layer so they cover
-// fixed chrome like the global header; within that layer the popup still paints
-// above the backdrop by DOM order.
+// dismiss. It sits above its own backdrop and below the next level's.
.viewport {
position: fixed;
inset: 0;
- z-index: var(--z-index-modal);
+ z-index: layer(1);
display: flex;
flex-direction: column;
align-items: center;
@@ -26,13 +33,73 @@
background: var(--center-channel-bg);
box-shadow: 0 20px 32px rgba(0, 0, 0, 0.12);
pointer-events: auto;
+ transition: transform 150ms ease, opacity 150ms ease;
+}
+
+// Arriving and leaving, via the attributes Base UI puts on the popup while it
+// transitions. Same duration and easing as the receding step below, and the same
+// downward axis, so the two read as one motion — but with more travel: this one has
+// to carry a surface in from nothing, where receding only has to hint at depth.
+//
+// The fade here is about a surface that isn't there yet; a modal that has arrived
+// stays fully opaque even once something covers it.
+.modal[data-starting-style],
+.modal[data-ending-style] {
+ transform: scale(0.96) translateY(8px);
+ opacity: 0;
+}
+
+// Something opened on top of this one: sink it back a step so the stack reads as
+// layers rather than as one modal replacing another, and stop it taking clicks —
+// the covering backdrop already intercepts them, but a lower popup should never
+// be the thing under the pointer.
+//
+// Two selectors because there are two ways to stack. `.modalCovered` is the modal
+// stack's own signal. `[data-nested-dialog-open]` is Base UI's, set on a popup
+// that has a dialog open inside its React tree — which is how a modal rendered in
+// another's JSX stacks, and which nothing was reading before.
+// The surface stays fully opaque — it's a solid sheet of paper that moved back, not
+// a translucent one. The dimming comes from the covering modal's backdrop, which
+// paints above this popup's band; fading the popup too would double the effect and
+// let the app show through a modal.
+// Kept deliberately slight so it reads as a step back rather than as the modal
+// shrinking. The dim from the covering backdrop is doing most of the work; this only
+// has to be enough to notice. Sinks down rather than lifting, so the covering modal
+// appears to sit in front of it rather than the two sliding apart.
+.modalCovered,
+.modal[data-nested-dialog-open] {
+ transform: scale(0.99) translateY(2px);
+ pointer-events: none;
}
.backdrop {
position: fixed;
inset: 0;
- z-index: var(--z-index-modal);
+ z-index: layer(0);
background: rgba(0, 0, 0, 0.5);
+ transition: opacity 150ms ease;
+}
+
+// The dim arrives with the surface rather than snapping in behind it.
+.backdrop[data-starting-style],
+.backdrop[data-ending-style] {
+ opacity: 0;
+}
+
+// A nested modal's backdrop is additive: the modal below it is already dimming the
+// app, so this one's job is to dim that modal — it is the whole layering cue now
+// that the covered surface stays opaque. Kept well below full strength so levels
+// don't compound toward black.
+.backdropNested {
+ background: rgba(0, 0, 0, 0.32);
+}
+
+// Only the topmost backdrop dims. Stacked blacks would compound toward black, so
+// lower ones stay mounted — Base UI dismisses through them — but paint nothing.
+// Declared after `.backdropNested` deliberately: a backdrop can be both nested and
+// covered, they have equal specificity, and "covered" has to win.
+.backdropCovered {
+ background: transparent;
}
.header {
@@ -41,17 +108,31 @@
flex-direction: column;
gap: 24px;
padding: 24px 32px;
+}
+
+// Opt-in divider under the header (on by default via the component).
+.headerDivider {
border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.12);
}
+// Title grows; the actions slot and close button sit at the right edge.
.titleRow {
display: flex;
align-items: center;
- justify-content: space-between;
gap: 10px;
}
+.titleActions {
+ display: flex;
+ flex-shrink: 0;
+ align-items: center;
+ gap: 8px;
+}
+
+// Grows so the actions slot and close button are pushed to the right edge.
.title {
+ flex: 1 1 auto;
+ min-width: 0;
margin: 0;
color: var(--center-channel-color);
font-family: 'Metropolis', sans-serif;
@@ -69,6 +150,11 @@
padding: 24px 32px;
}
+// Opt-in divider between the body and footer actions.
+.footerDivider {
+ border-top: 1px solid rgba(var(--center-channel-color-rgb), 0.12);
+}
+
.close {
display: flex;
flex-shrink: 0;
diff --git a/webapp/src/components/generic_modal/generic_modal.test.tsx b/webapp/src/components/generic_modal/generic_modal.test.tsx
index 80e6dc6..26633b5 100644
--- a/webapp/src/components/generic_modal/generic_modal.test.tsx
+++ b/webapp/src/components/generic_modal/generic_modal.test.tsx
@@ -56,4 +56,58 @@ describe('GenericModal', () => {
expect(screen.queryByRole('button', {name: 'Close'})).not.toBeInTheDocument();
});
+
+ // A modal rendered inside another's JSX stacks through Base UI's nesting
+ // rather than through the modal stack. Base UI renders no backdrop for a
+ // nested dialog unless forced, and marks the outer popup instead — so both
+ // halves are pinned here.
+ describe('a modal opened from inside another', () => {
+ const renderNested = () => renderWithContext(
+
+
+
{'Inner body'}
+
+ ,
+ );
+
+ // Base UI hides a covered dialog from the accessibility tree, so the outer
+ // title is present in the DOM but has no `heading` role to query.
+ it('renders both dialogs', () => {
+ renderNested();
+
+ expect(screen.getByText('Outer')).toBeInTheDocument();
+ expect(screen.getByText('Inner')).toBeInTheDocument();
+ expect(screen.getByRole('heading', {name: 'Inner'})).toBeInTheDocument();
+ });
+
+ it('marks the outer popup as covered, so it can recede', () => {
+ renderNested();
+
+ const covered = document.querySelectorAll('[data-nested-dialog-open]');
+
+ // Only the outer popup is marked, and it holds only its own content —
+ // the inner dialog is portaled to the body rather than nested in the
+ // outer popup's DOM, even though it nests in the React tree.
+ expect(covered).toHaveLength(1);
+ expect(covered[0].textContent).toContain('Outer');
+ expect(covered[0].textContent).not.toContain('Inner body');
+ });
+
+ it('paints each dialog in its own band, innermost highest', () => {
+ renderNested();
+
+ // Both the backdrop and the viewport of each dialog carry the level, in
+ // portal order: outer pair first, then inner.
+ const levels = [...document.querySelectorAll('[style*="--docs-modal-level"]')].
+ map((el) => (el as HTMLElement).style.getPropertyValue('--docs-modal-level'));
+
+ expect(levels).toEqual(['0', '0', '1', '1']);
+ });
+ });
});
diff --git a/webapp/src/components/generic_modal/generic_modal.tsx b/webapp/src/components/generic_modal/generic_modal.tsx
index d14cf98..8d22fa7 100644
--- a/webapp/src/components/generic_modal/generic_modal.tsx
+++ b/webapp/src/components/generic_modal/generic_modal.tsx
@@ -3,14 +3,36 @@
import {Dialog} from '@base-ui-components/react/dialog';
import classNames from 'classnames';
-import React from 'react';
+import React, {createContext, useCallback, useContext, useEffect, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import CloseIcon from '@mattermost/compass-icons/components/close';
import {WithTooltip} from '@mattermost/shared/components/tooltip';
+import {useDocsModalLayer} from 'components/modals';
+
import styles from './generic_modal.module.scss';
+// How many modals this one is rendered inside. A modal opened by rendering it in
+// another's JSX (rather than through `openDocsModal`) stacks through Base UI's
+// nesting instead of through the stack, so it needs its own count to land in the
+// right paint band — Base UI's own nesting context isn't exported.
+const ModalNestingContext = createContext(0);
+
+type CloseWith = (after?: () => void) => void;
+
+const ModalCloseContext = createContext(undefined);
+
+/**
+ * Dismisses the modal this component is inside, playing the exit transition and
+ * only then running `after`. Buttons that both close the modal and do something —
+ * a confirm, a cancel — should go through this instead of calling their handler
+ * directly, or the modal is unmounted before it can animate out.
+ *
+ * Undefined outside a modal, so a shared control can be used in both places.
+ */
+export const useModalClose = () => useContext(ModalCloseContext);
+
type Props = {
onClose: () => void;
title: React.ReactNode;
@@ -19,41 +41,114 @@ type Props = {
headerClassName?: string;
initialFocus?: React.RefObject;
showCloseButton?: boolean;
+
+ /**
+ * Content aligned to the right of the title, before the close button — for
+ * actions that belong to the header rather than the body or footer.
+ */
+ titleActions?: React.ReactNode;
headerContent?: React.ReactNode;
footer?: React.ReactNode;
+
+ /** Divider under the header. On by default; opt out for minimal modals. */
+ headerDivider?: boolean;
+
+ /** Renders a divider between the body and the footer actions. */
+ footerDivider?: boolean;
children: React.ReactNode;
};
-const GenericModal = ({onClose, title, ariaLabel, className, headerClassName, initialFocus, showCloseButton = true, headerContent, footer, children}: Props) => {
+const GenericModal = ({onClose, title, ariaLabel, className, headerClassName, initialFocus, showCloseButton = true, titleActions, headerContent, footer, headerDivider = true, footerDivider = false, children}: Props) => {
const {formatMessage} = useIntl();
const closeLabel = formatMessage({id: 'docs.genericModal.close', defaultMessage: 'Close'});
+ // Each level paints in its own band so the order never depends on which portal
+ // happened to mount first: two slots per level, the backdrop then the popup
+ // above it. A modal can be stacked two ways — pushed onto the modal stack, or
+ // rendered inside another modal's JSX — and depth is the sum, since either
+ // route puts one dialog above another.
+ const {level: stackLevel, covered} = useDocsModalLayer();
+ const nesting = useContext(ModalNestingContext);
+ const layerStyle = {'--docs-modal-level': stackLevel + nesting} as React.CSSProperties;
+ const isCovered = covered > 0;
+
+ // Closing is driven from here rather than by the owner unmounting us, so the
+ // exit transition has somewhere to run: flip `open`, let Base UI animate, and
+ // report the close only once it has finished. Whatever unmounts this modal —
+ // the modal stack, or a parent's state — then does so after the animation
+ // instead of cutting it off.
+ const [open, setOpen] = useState(false);
+ const afterCloseRef = useRef<(() => void) | undefined>(undefined);
+ const openedRef = useRef(false);
+
+ // Opened after the first paint rather than mounted open, so Base UI sees a
+ // false -> true change. A dialog that is already open on its first render never
+ // gets `data-starting-style` — useTransitionStatus initialises its `mounted`
+ // from `open`, so the starting state is skipped — and appears with no entrance
+ // animation. Every modal here is created already-open, so that was all of them.
+ useEffect(() => {
+ openedRef.current = true;
+ setOpen(true);
+ }, []);
+
+ const closeWith = useCallback((after) => {
+ afterCloseRef.current = after;
+ setOpen(false);
+ }, []);
+
return (
{
if (!nextOpen) {
- onClose();
+ closeWith();
+ }
+ }}
+ onOpenChangeComplete={(nextOpen) => {
+ // `openedRef` guards the closed state this mounts in: without it a
+ // completion reported before the modal has opened would be read as a
+ // dismissal and close it on arrival.
+ if (nextOpen || !openedRef.current) {
+ return;
}
+ const after = afterCloseRef.current;
+ afterCloseRef.current = undefined;
+ (after ?? onClose)();
}}
>
-
+ {/* forceRender because Base UI renders no backdrop for a nested
+ dialog by default (DialogBackdrop: `enabled: forceRender ||
+ !nested`), which left a modal opened from inside another with
+ nothing to dim or click away on. Its alpha is reduced instead,
+ since the modal below already dims the app. */}
+ 0,
+ [styles.backdropCovered]: isCovered,
+ })}
+ style={layerStyle}
+ />
{/* Flex centering container rendered after the backdrop, so the
- popup paints above it by DOM order — no z-index needed, and
- no centering transform on the popup itself. */}
-
+ popup paints above it within this level's band — and no
+ centering transform on the popup itself. */}
+
-
+
}>
{title}
+ {titleActions != null &&
{titleActions}
}
{showCloseButton && (
{headerContent}
- {children}
- {footer &&
{footer}
}
+
+
+ {children}
+ {footer &&
{footer}
}
+
+
diff --git a/webapp/src/components/header/header.module.scss b/webapp/src/components/header/header.module.scss
new file mode 100644
index 0000000..b2b7fb1
--- /dev/null
+++ b/webapp/src/components/header/header.module.scss
@@ -0,0 +1,25 @@
+.header {
+ display: flex;
+ flex: 0 0 auto;
+ align-items: center;
+ gap: 8px;
+ height: 56px;
+ padding: 0 16px;
+ border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.12);
+ background: var(--center-channel-bg);
+}
+
+.left {
+ display: flex;
+ flex: 1 1 auto;
+ min-width: 0;
+ align-items: center;
+ gap: 8px;
+}
+
+.right {
+ display: flex;
+ flex-shrink: 0;
+ align-items: center;
+ gap: 8px;
+}
diff --git a/webapp/src/components/header/header.tsx b/webapp/src/components/header/header.tsx
new file mode 100644
index 0000000..ea9290d
--- /dev/null
+++ b/webapp/src/components/header/header.tsx
@@ -0,0 +1,25 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import classNames from 'classnames';
+import React from 'react';
+
+import styles from './header.module.scss';
+
+type Props = {
+ left: React.ReactNode;
+ right?: React.ReactNode;
+ className?: string;
+};
+
+// Shared 56px product header bar: a full-width row with a flexible left region
+// and a shrink-to-fit right region for actions. Docs Home and the Space view
+// share the chrome (height, border, padding); each supplies its own content.
+const Header = ({left, right, className}: Props) => (
+
+
{left}
+ {right != null &&
{right}
}
+
+);
+
+export default Header;
diff --git a/webapp/src/components/menu/menu.module.scss b/webapp/src/components/menu/menu.module.scss
index 3403a23..b17365e 100644
--- a/webapp/src/components/menu/menu.module.scss
+++ b/webapp/src/components/menu/menu.module.scss
@@ -49,10 +49,21 @@
}
}
+// Base UI marks the submenu trigger while its popup is open, so it keeps the
+// highlight after the pointer moves into the nested menu.
+.submenuTrigger[data-popup-open] {
+ background: rgba(var(--center-channel-color-rgb), 0.08);
+}
+
.destructive {
color: var(--error-text);
}
+.disabled {
+ opacity: 0.4;
+ pointer-events: none;
+}
+
.link {
color: var(--link-color);
}
@@ -89,3 +100,14 @@
font-size: 12px;
line-height: 16px;
}
+
+.itemTrailing {
+ display: flex;
+ flex-shrink: 0;
+ align-items: center;
+ color: rgba(var(--center-channel-color-rgb), 0.64);
+}
+
+.destructive .itemTrailing {
+ color: var(--error-text);
+}
diff --git a/webapp/src/components/menu/menu.test.tsx b/webapp/src/components/menu/menu.test.tsx
index 8b0d3ad..a4307c8 100644
--- a/webapp/src/components/menu/menu.test.tsx
+++ b/webapp/src/components/menu/menu.test.tsx
@@ -5,23 +5,30 @@ import {fireEvent, screen, waitFor} from '@testing-library/react';
import React from 'react';
import Menu from './menu';
-import type {MenuItemSpec} from './menu_types';
import {renderWithContext} from '../../../tests/react_testing_utils';
-function renderMenu(items: MenuItemSpec[]) {
+function renderMenu(onRename: () => void) {
return renderWithContext(
,
);
}
describe('Menu', () => {
it('renders the trigger and keeps items closed initially', () => {
- renderMenu([{id: 'a', label: 'Rename', onClick: jest.fn()}]);
+ renderMenu(jest.fn());
expect(screen.getByRole('button', {name: 'Open menu'})).toBeInTheDocument();
expect(screen.queryByText('Rename')).not.toBeInTheDocument();
@@ -29,10 +36,7 @@ describe('Menu', () => {
it('opens on trigger click and fires the item handler', async () => {
const onClick = jest.fn();
- renderMenu([
- {id: 'rename', label: 'Rename', onClick},
- {id: 'delete', label: 'Delete', onClick: jest.fn(), isDestructive: true, hasDivider: true},
- ]);
+ renderMenu(onClick);
fireEvent.click(screen.getByRole('button', {name: 'Open menu'}));
diff --git a/webapp/src/components/menu/menu.tsx b/webapp/src/components/menu/menu.tsx
index c082e17..77d0dff 100644
--- a/webapp/src/components/menu/menu.tsx
+++ b/webapp/src/components/menu/menu.tsx
@@ -5,24 +5,172 @@ import {Menu as BaseMenu} from '@base-ui-components/react/menu';
import classNames from 'classnames';
import React from 'react';
+import ChevronRightIcon from '@mattermost/compass-icons/components/chevron-right';
import {WithTooltip} from '@mattermost/shared/components/tooltip';
import styles from './menu.module.scss';
-import type {MenuItemSpec} from './menu_types';
-type Props = {
+type MenuProps = {
ariaLabel: string;
- items: MenuItemSpec[];
align?: 'left' | 'right';
tooltip?: string;
// Base UI merges its own open/aria/ref props onto the trigger element.
trigger: React.ReactElement;
+
+ // Controlled open state. Only needed when something other than the trigger
+ // opens the menu (e.g. a keyboard shortcut on the surrounding row).
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+ children: React.ReactNode;
+};
+
+type ItemProps = {
+ leadingIcon?: React.ReactNode;
+ trailingIcon?: React.ReactNode;
+ secondaryLabel?: React.ReactNode;
+ destructive?: boolean;
+ disabled?: boolean;
+ closeOnClick?: boolean;
+ onClick?: () => void;
+ children: React.ReactNode;
+};
+
+type LinkItemProps = ItemProps & {
+ href: string;
+ external?: boolean;
};
-// Portals to the body so it is never clipped by the sidebar's overflow.
-const Menu = ({ariaLabel, items, align = 'left', tooltip, trigger}: Props) => (
-
+type SubmenuProps = {
+ label: React.ReactNode;
+ leadingIcon?: React.ReactNode;
+ ariaLabel?: string;
+ disabled?: boolean;
+ children: React.ReactNode;
+};
+
+const ItemBody = ({leadingIcon, trailingIcon, secondaryLabel, children}: Pick) => (
+ <>
+ {leadingIcon && (
+
+ {leadingIcon}
+
+ )}
+
+ {children}
+ {secondaryLabel && {secondaryLabel}}
+
+ {trailingIcon && (
+
+ {trailingIcon}
+
+ )}
+ >
+);
+
+/**
+ * An interactive menu item. Renders as a button-like row.
+ */
+const MenuItem = ({leadingIcon, trailingIcon, secondaryLabel, destructive, disabled, closeOnClick, onClick, children}: ItemProps) => (
+
+
+ {children}
+
+
+);
+
+/**
+ * A menu item that navigates. Renders a real anchor for proper semantics,
+ * keyboard and middle-click support.
+ */
+const MenuLinkItem = ({href, external, leadingIcon, trailingIcon, secondaryLabel, destructive, disabled, onClick, children}: LinkItemProps) => (
+
+ )}
+ >
+
+ {children}
+
+
+);
+
+/** A horizontal rule between groups of items. */
+const MenuSeparator = () => ;
+
+/** A nested menu opened from an item row. */
+const MenuSubmenu = ({label, leadingIcon, ariaLabel, disabled, children}: SubmenuProps) => (
+
+
+ }
+ >
+ {label}
+
+
+
+
+
+ {children}
+
+
+
+
+);
+
+/**
+ * A dropdown menu built from `Menu.Item`, `Menu.LinkItem`, `Menu.Separator` and
+ * `Menu.Submenu` children. Portals to the body so it is never clipped by an
+ * ancestor's overflow.
+ */
+const Menu = ({ariaLabel, align = 'left', tooltip, trigger, open, onOpenChange, children}: MenuProps) => (
+
{tooltip ? (
@@ -35,41 +183,22 @@ const Menu = ({ariaLabel, items, align = 'left', tooltip, trigger}: Props) => (
className={styles.positioner}
side='bottom'
align={align === 'right' ? 'end' : 'start'}
- sideOffset={4}
collisionPadding={8}
>
- {items.map((item) => (
-
- {item.hasDivider && }
-
- ) : undefined}
- >
- {item.leadingIcon && {item.leadingIcon}}
-
- {item.label}
- {item.secondaryLabel && {item.secondaryLabel}}
-
-
-
- ))}
+ {children}
);
+Menu.Item = MenuItem;
+Menu.LinkItem = MenuLinkItem;
+Menu.Separator = MenuSeparator;
+Menu.Submenu = MenuSubmenu;
+
export default Menu;
diff --git a/webapp/src/components/menu/menu_types.ts b/webapp/src/components/menu/menu_types.ts
deleted file mode 100644
index 1d4f574..0000000
--- a/webapp/src/components/menu/menu_types.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
-// See LICENSE.txt for license information.
-
-import type React from 'react';
-
-export type MenuItemSpec = {
- id: string;
- label: React.ReactNode;
- secondaryLabel?: React.ReactNode;
- leadingIcon?: React.ReactNode;
- onClick?: () => void;
- isDestructive?: boolean;
- isLink?: boolean;
- hasDivider?: boolean;
-
- // When set, the item renders as a real anchor (proper semantics, keyboard
- // and middle-click support) instead of a button. `external` opens in a new
- // tab with a safe rel.
- href?: string;
- external?: boolean;
-};
diff --git a/webapp/src/components/modals/docs_modal_controller.test.tsx b/webapp/src/components/modals/docs_modal_controller.test.tsx
new file mode 100644
index 0000000..cb3684b
--- /dev/null
+++ b/webapp/src/components/modals/docs_modal_controller.test.tsx
@@ -0,0 +1,133 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {act, screen} from '@testing-library/react';
+import React from 'react';
+
+import DocsModalController, {useDocsModal, useDocsModalLayer} from './docs_modal_controller';
+import {closeAllDocsModals, openDocsModal} from './modal_store';
+
+import {renderWithContext} from '../../../tests/react_testing_utils';
+
+describe('DocsModalController', () => {
+ afterEach(() => {
+ act(() => {
+ closeAllDocsModals();
+ });
+ });
+
+ it('renders a modal opened imperatively and closes it via the handle', () => {
+ renderWithContext();
+
+ let handle = {id: '', close: () => {}};
+ act(() => {
+ handle = openDocsModal(
;
+ };
+
+ it('gives each stacked modal its own level, deepest first', () => {
+ renderWithContext();
+
+ act(() => {
+ openDocsModal();
+ });
+ act(() => {
+ openDocsModal();
+ });
+
+ expect(screen.getByText('Settings: level 0, covered 1')).toBeInTheDocument();
+ expect(screen.getByText('Archive: level 1, covered 0')).toBeInTheDocument();
+ });
+
+ it('uncovers the modal below when the one above it closes', () => {
+ renderWithContext();
+
+ act(() => {
+ openDocsModal();
+ });
+
+ let archive = {id: '', close: () => {}};
+ act(() => {
+ archive = openDocsModal();
+ });
+
+ expect(screen.getByText('Settings: level 0, covered 1')).toBeInTheDocument();
+
+ act(() => {
+ archive.close();
+ });
+
+ expect(screen.getByText('Settings: level 0, covered 0')).toBeInTheDocument();
+ });
+
+ it('reports a lone modal as uncovered, so a dialog outside the stack is unaffected', () => {
+ renderWithContext();
+
+ expect(screen.getByText('Standalone: level 0, covered 0')).toBeInTheDocument();
+ });
+ });
+
+ it('passes the handle to a render function and exposes it through context', () => {
+ const Content = () => {
+ const handle = useDocsModal();
+
+ return (
+
+ );
+ };
+
+ renderWithContext();
+
+ act(() => {
+ openDocsModal(() => );
+ });
+
+ act(() => {
+ screen.getByRole('button', {name: 'Close from context'}).click();
+ });
+
+ expect(screen.queryByRole('button', {name: 'Close from context'})).not.toBeInTheDocument();
+ });
+});
diff --git a/webapp/src/components/modals/docs_modal_controller.tsx b/webapp/src/components/modals/docs_modal_controller.tsx
new file mode 100644
index 0000000..405e0c9
--- /dev/null
+++ b/webapp/src/components/modals/docs_modal_controller.tsx
@@ -0,0 +1,72 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React, {createContext, useContext, useSyncExternalStore} from 'react';
+
+import {getDocsModalStack, subscribeToDocsModals} from './modal_store';
+import type {DocsModalEntry, DocsModalHandle} from './modal_store';
+
+const DocsModalContext = createContext(undefined);
+
+/** The handle of the modal the calling component is rendered inside, if any. */
+export const useDocsModal = () => useContext(DocsModalContext);
+
+export type DocsModalLayer = {
+
+ /** 0-based position in the stack, so each level can own a paint order. */
+ level: number;
+
+ /** How many modals are stacked on top of this one. */
+ covered: number;
+};
+
+const NOT_STACKED: DocsModalLayer = {level: 0, covered: 0};
+
+// Base UI decides whether a dialog is nested from React context — a `Dialog.Root`
+// is nested only when it renders inside another one's subtree (see
+// DialogRoot: `nested = Boolean(useDialogRootContext(true))`). This controller
+// renders the stack as siblings, so Base UI sees unrelated dialogs and never
+// applies its own nesting treatment (`data-nested-dialog-open`,
+// `--nested-dialogs`). The stack is what knows the depth, so it supplies it.
+const DocsModalLayerContext = createContext(NOT_STACKED);
+
+/**
+ * This modal's place in the stack. Defaults to a lone top-level modal, so a
+ * dialog rendered outside the stack behaves as it always did.
+ */
+export const useDocsModalLayer = () => useContext(DocsModalLayerContext);
+
+type EntryProps = {
+ entry: DocsModalEntry;
+ layer: DocsModalLayer;
+};
+
+const DocsModalEntryMount = ({entry, layer}: EntryProps) => (
+
+
+ {entry.render(entry.handle)}
+
+
+);
+
+/**
+ * Renders the stack of modals opened through `openDocsModal`. Mount exactly
+ * once, at the Docs root.
+ */
+const DocsModalController = () => {
+ const stack = useSyncExternalStore(subscribeToDocsModals, getDocsModalStack);
+
+ return (
+ <>
+ {stack.map((entry, index) => (
+
+ ))}
+ >
+ );
+};
+
+export default DocsModalController;
diff --git a/webapp/src/components/modals/index.ts b/webapp/src/components/modals/index.ts
new file mode 100644
index 0000000..4ed357f
--- /dev/null
+++ b/webapp/src/components/modals/index.ts
@@ -0,0 +1,9 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+export {default as DocsModalController, useDocsModal, useDocsModalLayer} from './docs_modal_controller';
+export type {DocsModalLayer} from './docs_modal_controller';
+export {closeAllDocsModals, closeDocsModal, openDocsModal} from './modal_store';
+export type {DocsModalHandle, DocsModalRender} from './modal_store';
+export {useDocsModals} from './use_docs_modals';
+export type {DocsModalsApi} from './use_docs_modals';
diff --git a/webapp/src/components/modals/modal_store.test.ts b/webapp/src/components/modals/modal_store.test.ts
new file mode 100644
index 0000000..5b20caa
--- /dev/null
+++ b/webapp/src/components/modals/modal_store.test.ts
@@ -0,0 +1,86 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {
+ closeAllDocsModals,
+ closeDocsModal,
+ getDocsModalStack,
+ openDocsModal,
+ subscribeToDocsModals,
+} from './modal_store';
+
+describe('modal store', () => {
+ afterEach(() => {
+ closeAllDocsModals();
+ });
+
+ it('stacks modals in the order they were opened', () => {
+ const first = openDocsModal('one');
+ const second = openDocsModal('two');
+
+ expect(getDocsModalStack().map((entry) => entry.handle.id)).toEqual([first.id, second.id]);
+ });
+
+ it('pops only the modal a handle refers to, whatever its position', () => {
+ const first = openDocsModal('one');
+ const second = openDocsModal('two');
+
+ first.close();
+
+ expect(getDocsModalStack().map((entry) => entry.handle.id)).toEqual([second.id]);
+ });
+
+ it('wraps a plain node so callers can always render through the entry', () => {
+ const handle = openDocsModal('content');
+
+ expect(getDocsModalStack()[0].render(handle)).toBe('content');
+ });
+
+ it('passes the handle to a render function', () => {
+ const render = jest.fn(() => 'rendered');
+ const handle = openDocsModal(render);
+
+ getDocsModalStack()[0].render(handle);
+
+ expect(render).toHaveBeenCalledWith(handle);
+ });
+
+ it('notifies subscribers on open and close, and stops after unsubscribe', () => {
+ const listener = jest.fn();
+ const unsubscribe = subscribeToDocsModals(listener);
+
+ const handle = openDocsModal('one');
+ expect(listener).toHaveBeenCalledTimes(1);
+
+ handle.close();
+ expect(listener).toHaveBeenCalledTimes(2);
+
+ unsubscribe();
+ openDocsModal('two');
+ expect(listener).toHaveBeenCalledTimes(2);
+ });
+
+ // Guards against a stale handle (a modal already closed another way) waking
+ // every subscriber for a no-op.
+ it('does not notify when closing an id that is not on the stack', () => {
+ const listener = jest.fn();
+ const unsubscribe = subscribeToDocsModals(listener);
+
+ closeDocsModal('docs-modal-nope');
+ expect(listener).not.toHaveBeenCalled();
+
+ closeAllDocsModals();
+ expect(listener).not.toHaveBeenCalled();
+
+ unsubscribe();
+ });
+
+ it('clears the whole stack at once', () => {
+ openDocsModal('one');
+ openDocsModal('two');
+
+ closeAllDocsModals();
+
+ expect(getDocsModalStack()).toEqual([]);
+ });
+});
diff --git a/webapp/src/components/modals/modal_store.ts b/webapp/src/components/modals/modal_store.ts
new file mode 100644
index 0000000..b78c8d9
--- /dev/null
+++ b/webapp/src/components/modals/modal_store.ts
@@ -0,0 +1,74 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import type {ReactNode} from 'react';
+
+export type DocsModalHandle = {
+ id: string;
+ close: () => void;
+};
+
+export type DocsModalRender = (handle: DocsModalHandle) => ReactNode;
+
+export type DocsModalEntry = {
+ handle: DocsModalHandle;
+ render: DocsModalRender;
+};
+
+let stack: DocsModalEntry[] = [];
+let nextId = 0;
+
+const listeners = new Set<() => void>();
+
+const setStack = (next: DocsModalEntry[]) => {
+ stack = next;
+ listeners.forEach((listener) => listener());
+};
+
+export const subscribeToDocsModals = (listener: () => void) => {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+};
+
+export const getDocsModalStack = () => stack;
+
+export const closeDocsModal = (id: string) => {
+ if (stack.some((entry) => entry.handle.id === id)) {
+ setStack(stack.filter((entry) => entry.handle.id !== id));
+ }
+};
+
+/**
+ * Opens a Docs modal from anywhere — no hook, no context, and no mount point at
+ * the callsite: `` at the Docs root renders it. Opening from
+ * within an open modal stacks on top of it, to any depth.
+ *
+ * The content owns its own dialog surface (e.g. `GenericModal`/`ConfirmModal`,
+ * both Base UI `Dialog`-based), which handles focus management and dismissal.
+ * Layering is this stack's job, not Base UI's: Base UI treats a dialog as nested
+ * only when its `Dialog.Root` renders inside another one's React subtree, and
+ * these are siblings, so it sees unrelated dialogs. The depth each level paints
+ * at comes from `useDocsModalLayer` (see `docs_modal_controller`).
+ *
+ * Pass a render function to receive the handle (wire its `close` to the content's
+ * `onClose`), or a ready-made element when the content closes itself.
+ *
+ * @returns a handle whose `close()` pops this modal off the stack.
+ */
+export const openDocsModal = (content: ReactNode | DocsModalRender): DocsModalHandle => {
+ const id = `docs-modal-${nextId++}`;
+ const handle: DocsModalHandle = {id, close: () => closeDocsModal(id)};
+ const render: DocsModalRender = typeof content === 'function' ? content : () => content;
+
+ setStack([...stack, {handle, render}]);
+
+ return handle;
+};
+
+export const closeAllDocsModals = () => {
+ if (stack.length > 0) {
+ setStack([]);
+ }
+};
diff --git a/webapp/src/components/modals/use_docs_modals.ts b/webapp/src/components/modals/use_docs_modals.ts
new file mode 100644
index 0000000..3b852fd
--- /dev/null
+++ b/webapp/src/components/modals/use_docs_modals.ts
@@ -0,0 +1,15 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {closeAllDocsModals, closeDocsModal, openDocsModal} from './modal_store';
+
+const api = {
+ open: openDocsModal,
+ close: closeDocsModal,
+ closeAll: closeAllDocsModals,
+};
+
+export type DocsModalsApi = typeof api;
+
+/** Hook flavour of {@link openDocsModal} for components that prefer hooks. */
+export const useDocsModals = (): DocsModalsApi => api;
diff --git a/webapp/src/components/page_menu/page_menu.tsx b/webapp/src/components/page_menu/page_menu.tsx
new file mode 100644
index 0000000..ebfa7f7
--- /dev/null
+++ b/webapp/src/components/page_menu/page_menu.tsx
@@ -0,0 +1,300 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {useIsFavorite, useToggleFavorite} from 'hooks/favorites';
+import {useDocsNavigation} from 'hooks/navigation';
+import {useAppDispatch} from 'hooks/redux';
+import React, {useCallback} from 'react';
+import {FormattedMessage, useIntl} from 'react-intl';
+import {copyToClipboard} from 'utils/clipboard';
+
+import AccountMultipleOutlineIcon from '@mattermost/compass-icons/components/account-multiple-outline';
+import BellOutlineIcon from '@mattermost/compass-icons/components/bell-outline';
+import ClockOutlineIcon from '@mattermost/compass-icons/components/clock-outline';
+import ContentCopyIcon from '@mattermost/compass-icons/components/content-copy';
+import DockWindowIcon from '@mattermost/compass-icons/components/dock-window';
+import ExportVariantIcon from '@mattermost/compass-icons/components/export-variant';
+import FolderMoveOutlineIcon from '@mattermost/compass-icons/components/folder-move-outline';
+import LinkVariantIcon from '@mattermost/compass-icons/components/link-variant';
+import PencilOutlineIcon from '@mattermost/compass-icons/components/pencil-outline';
+import StarIcon from '@mattermost/compass-icons/components/star';
+import StarOutlineIcon from '@mattermost/compass-icons/components/star-outline';
+import TrashCanOutlineIcon from '@mattermost/compass-icons/components/trash-can-outline';
+
+import {updatePage} from 'store/actions';
+
+import BasicInputModal from 'components/basic_input_modal/basic_input_modal';
+import DeletePageModal from 'components/delete_page_modal/delete_page_modal';
+import DiscardDraftModal from 'components/discard_draft_modal/discard_draft_modal';
+import Menu from 'components/menu/menu';
+import {openDocsModal} from 'components/modals';
+
+// Mirrors the server's PageTitleMaxRunes (server/model/page.go).
+const PAGE_TITLE_MAX_LENGTH = 255;
+
+type Props = {
+ spaceId: string;
+ pageId: string;
+ pageTitle: string;
+
+ // An unpublished page. Most page actions have no meaning for one — it cannot be
+ // favorited, shared, exported or version-tracked — so it gets a reduced set
+ // ending in Discard rather than Delete.
+ isDraft?: boolean;
+
+ // Base UI merges its own open/aria/ref props onto the trigger element.
+ trigger: React.ReactElement;
+ align?: 'left' | 'right';
+ tooltip?: string;
+
+ // Controlled open state, for callers that open the menu from a keyboard
+ // shortcut rather than the trigger (see the page tree's rows).
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+};
+
+/**
+ * The page actions menu, shared by the page tree rows and the page header.
+ * Items without a handler are scaffolding for features that have no API yet.
+ */
+const PageMenu = ({spaceId, pageId, pageTitle, isDraft = false, trigger, align = 'left', tooltip, open, onOpenChange}: Props) => {
+ const {formatMessage} = useIntl();
+ const {paths} = useDocsNavigation();
+ const dispatch = useAppDispatch();
+ const favorited = useIsFavorite('page', pageId);
+ const toggleFavorite = useToggleFavorite();
+
+ const pageUrl = `${window.location.origin}${isDraft ? paths.draft(spaceId, pageId) : paths.page(spaceId, pageId)}`;
+
+ const copyLink = useCallback(() => copyToClipboard(pageUrl), [pageUrl]);
+ const openInNewWindow = useCallback(() => window.open(pageUrl, '_blank', 'noopener,noreferrer'), [pageUrl]);
+
+ const openRename = useCallback(() => {
+ openDocsModal((modal) => (
+ {
+ await dispatch(updatePage(spaceId, pageId, {title}));
+ }}
+ onClose={modal.close}
+ />
+ ));
+ }, [dispatch, formatMessage, pageId, pageTitle, spaceId]);
+
+ const openDeleteConfirm = useCallback(() => {
+ openDocsModal((modal) => (
+
+ ));
+ }, [pageId, pageTitle, spaceId]);
+
+ const openDiscardConfirm = useCallback(() => {
+ openDocsModal((modal) => (
+
+ ));
+ }, [pageId, pageTitle, spaceId]);
+
+ if (isDraft) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+};
+
+export default PageMenu;
diff --git a/webapp/src/components/readout/index.ts b/webapp/src/components/readout/index.ts
new file mode 100644
index 0000000..0e1abde
--- /dev/null
+++ b/webapp/src/components/readout/index.ts
@@ -0,0 +1,5 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+export {default as Readout} from './readout';
+export {announce, clearReadout, getReadoutMessage, getReadoutState, subscribeToReadout} from './readout_store';
diff --git a/webapp/src/components/readout/readout.module.scss b/webapp/src/components/readout/readout.module.scss
new file mode 100644
index 0000000..07258de
--- /dev/null
+++ b/webapp/src/components/readout/readout.module.scss
@@ -0,0 +1,13 @@
+// Core uses its global `.sr-only`; scoped here so the plugin doesn't depend on a
+// host class staying put.
+.readout {
+ position: absolute;
+ overflow: hidden;
+ width: 1px;
+ height: 1px;
+ margin: -1px;
+ padding: 0;
+ border: 0;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+}
diff --git a/webapp/src/components/readout/readout.tsx b/webapp/src/components/readout/readout.tsx
new file mode 100644
index 0000000..3e15e96
--- /dev/null
+++ b/webapp/src/components/readout/readout.tsx
@@ -0,0 +1,42 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React, {useEffect, useSyncExternalStore} from 'react';
+
+import styles from './readout.module.scss';
+import {clearReadout, getReadoutState, subscribeToReadout} from './readout_store';
+
+// Core clears after 2s so a stale message isn't re-read when focus returns to the
+// region (webapp/channels/src/components/readout).
+const CLEAR_DELAY = 2000;
+
+/**
+ * The Docs live region: one per product, mounted at the Docs root. Announces
+ * whatever `announce()` last passed, then empties itself.
+ */
+const Readout = () => {
+ const {message, nonce} = useSyncExternalStore(subscribeToReadout, getReadoutState);
+
+ // Keyed on the nonce so each announcement resets its own timer: a newer message
+ // can't be wiped early by the previous one's pending clear.
+ useEffect(() => {
+ if (!message) {
+ return undefined;
+ }
+ const timeout = setTimeout(clearReadout, CLEAR_DELAY);
+ return () => clearTimeout(timeout);
+ }, [message, nonce]);
+
+ return (
+
+ {message}
+
+ );
+};
+
+export default Readout;
diff --git a/webapp/src/components/readout/readout_store.ts b/webapp/src/components/readout/readout_store.ts
new file mode 100644
index 0000000..b4a1402
--- /dev/null
+++ b/webapp/src/components/readout/readout_store.ts
@@ -0,0 +1,58 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+export type ReadoutState = {
+ message: string;
+
+ // Bumped on every announcement. `` keys its text node on this, so an
+ // identical message still replaces the node — assistive tech announces DOM
+ // mutations inside a live region, and re-rendering the same string is not one.
+ // Clearing state and re-setting it in the same tick would not work: React
+ // batches both updates into one commit, so the blank never reaches the DOM.
+ nonce: number;
+};
+
+// Ported from core's readout (webapp/channels/src/components/readout + the
+// SET_READOUT/CLEAR_READOUT reducer): one live region for the whole product, fed
+// imperatively, cleared shortly after. Core carries the message in Redux; this
+// uses the same module-store transport as the Docs toast and modal controllers so
+// non-component code can announce without a dispatch.
+let state: ReadoutState = {message: '', nonce: 0};
+
+const listeners = new Set<() => void>();
+
+// The snapshot identity only changes when the state does, which is what
+// useSyncExternalStore requires.
+const setState = (next: ReadoutState) => {
+ state = next;
+ listeners.forEach((listener) => listener());
+};
+
+export const subscribeToReadout = (listener: () => void) => {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+};
+
+export const getReadoutState = (): ReadoutState => state;
+
+/** The text currently in the live region. Exported for tests. */
+export const getReadoutMessage = (): string => state.message;
+
+export const clearReadout = () => {
+ if (state.message !== '') {
+ setState({message: '', nonce: state.nonce + 1});
+ }
+};
+
+/**
+ * Announces `text` to screen readers via the Docs live region, without moving
+ * focus. Use for outcomes that are only conveyed visually — a page moved, a list
+ * reordered — where a toast would be too heavy.
+ *
+ * Announcing the same text twice in a row announces twice.
+ */
+export const announce = (text: string) => {
+ setState({message: text, nonce: state.nonce + 1});
+};
diff --git a/webapp/src/components/resizable_divider/resizable_divider.module.scss b/webapp/src/components/resizable_divider/resizable_divider.module.scss
new file mode 100644
index 0000000..a6ed711
--- /dev/null
+++ b/webapp/src/components/resizable_divider/resizable_divider.module.scss
@@ -0,0 +1,73 @@
+// A 12px grab area centered on the container's edge, so the pointer target is
+// forgiving while the visible line stays 4px (matching core).
+.divider {
+ position: absolute;
+ z-index: 5;
+ top: 0;
+ width: 12px;
+ height: 100%;
+ cursor: col-resize;
+
+ &::after {
+ position: absolute;
+ top: 0;
+ width: 4px;
+ height: 100%;
+ background: transparent;
+ content: '';
+ }
+
+ &:hover::after,
+ &:focus-visible::after {
+ background: var(--sidebar-text-active-border);
+ }
+
+ &:focus-visible {
+ outline: none;
+ }
+}
+
+.active::after {
+ background: var(--sidebar-text-active-border);
+}
+
+// Emphasized while the drag is snapped to the default width, so the snap is
+// visible rather than just felt.
+.snapped::after {
+ width: 6px;
+ background: var(--button-bg);
+}
+
+// `left`/`right` name the edge the handle sits on, so the line hugs the border.
+//
+// `--docs-divider-clearance` slides the whole handle outward, off the panel it
+// resizes. A panel that scrolls puts its scrollbar on exactly the edge the handle
+// straddles, and the handle wins the pointer — so the scrollbar becomes hard to
+// grab. At 6px or more the 12px hitbox clears the panel entirely, whatever width
+// the platform draws its scrollbar.
+//
+// The line moves with it rather than staying behind: an indicator you can see but
+// not grab is worse than one that sits a few pixels off the border. `max()` pins it
+// to the handle's inner edge once shifted, so it still reads as hugging the border
+// instead of floating in the content.
+.left {
+ right: calc(-6px - var(--docs-divider-clearance, 0px));
+
+ &::after {
+ left: max(0px, calc(4px - var(--docs-divider-clearance, 0px)));
+ }
+}
+
+.right {
+ left: calc(-6px - var(--docs-divider-clearance, 0px));
+
+ &::after {
+ right: max(0px, calc(4px - var(--docs-divider-clearance, 0px)));
+ }
+}
+
+// Set on for the duration of a drag.
+.resizing {
+ cursor: col-resize;
+ user-select: none;
+}
diff --git a/webapp/src/components/resizable_divider/resizable_divider.tsx b/webapp/src/components/resizable_divider/resizable_divider.tsx
new file mode 100644
index 0000000..676ff18
--- /dev/null
+++ b/webapp/src/components/resizable_divider/resizable_divider.tsx
@@ -0,0 +1,157 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import classNames from 'classnames';
+import React, {useEffect, useRef, useState} from 'react';
+
+import styles from './resizable_divider.module.scss';
+
+// Matches core's resizable sidebars (components/resizable_sidebar): a wide
+// invisible grab area straddling the edge, with a thin accent line on hover and
+// while dragging. Core builds this with styled-components, which the Docs plugin
+// can't use, so the same behaviour is reimplemented over CSS Modules.
+
+export type ResizeSide = 'left' | 'right';
+
+type Props = {
+ ariaLabel: string;
+
+ /** Which edge of the resized container the handle sits on. */
+ side: ResizeSide;
+ width: number;
+ minWidth: number;
+ maxWidth: number;
+ defaultWidth: number;
+
+ /** Fires continuously while dragging; keep this cheap (state only). */
+ onResize: (width: number) => void;
+
+ /** Fires once when the drag ends — the place to persist the result. */
+ onResizeEnd: (width: number) => void;
+
+ /**
+ * Pixels to slide the handle outward, off the panel it resizes. Pass this when
+ * the panel scrolls: its scrollbar sits on the same edge the handle straddles,
+ * and the handle would otherwise take the clicks meant for it. 6 is enough to
+ * clear the panel entirely, whatever width the platform's scrollbar is.
+ */
+ scrollbarClearance?: number;
+};
+
+// Keyboard resizing steps by this many pixels per arrow press.
+const KEYBOARD_STEP = 16;
+
+// Dragging within this distance of the default width snaps to it, so the default
+// is easy to land on by feel (core's sidebars do the same).
+const SNAP_DISTANCE = 10;
+
+const ResizableDivider = ({ariaLabel, side, width, minWidth, maxWidth, defaultWidth, onResize, onResizeEnd, scrollbarClearance = 0}: Props) => {
+ const [dragging, setDragging] = useState(false);
+ const [snapped, setSnapped] = useState(false);
+ const startX = useRef(0);
+ const startWidth = useRef(0);
+
+ const clamp = (value: number) => Math.min(maxWidth, Math.max(minWidth, Math.round(value)));
+
+ // Snapping is applied to the pointer's raw width so it can't compound: the
+ // snap zone stays anchored to the default rather than following the result.
+ const snap = (value: number) => (Math.abs(value - defaultWidth) <= SNAP_DISTANCE ? defaultWidth : value);
+
+ // Dragging over the document needs a body-level cursor and no text
+ // selection, otherwise the pointer flickers over the content it crosses.
+ useEffect(() => {
+ if (!dragging) {
+ return undefined;
+ }
+ document.body.classList.add(styles.resizing);
+ return () => document.body.classList.remove(styles.resizing);
+ }, [dragging]);
+
+ const widthFor = (clientX: number) => {
+ const delta = side === 'left' ? clientX - startX.current : startX.current - clientX;
+ return clamp(snap(startWidth.current + delta));
+ };
+
+ const onPointerDown = (event: React.PointerEvent) => {
+ if (event.button !== 0) {
+ return;
+ }
+ event.preventDefault();
+ startX.current = event.clientX;
+ startWidth.current = width;
+ setDragging(true);
+ event.currentTarget.setPointerCapture(event.pointerId);
+ };
+
+ const onPointerMove = (event: React.PointerEvent) => {
+ if (!dragging) {
+ return;
+ }
+ const next = widthFor(event.clientX);
+ setSnapped(next === defaultWidth);
+ onResize(next);
+ };
+
+ const endDrag = (event: React.PointerEvent) => {
+ if (!dragging) {
+ return;
+ }
+ setDragging(false);
+ setSnapped(false);
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
+ event.currentTarget.releasePointerCapture(event.pointerId);
+ }
+ onResizeEnd(widthFor(event.clientX));
+ };
+
+ // Double-click restores the default width, as core's divider does.
+ const onDoubleClick = () => {
+ onResize(defaultWidth);
+ onResizeEnd(defaultWidth);
+ };
+
+ const onKeyDown = (event: React.KeyboardEvent) => {
+ const grow = side === 'left' ? 'ArrowRight' : 'ArrowLeft';
+ const shrink = side === 'left' ? 'ArrowLeft' : 'ArrowRight';
+
+ let next: number | undefined;
+ if (event.key === grow) {
+ next = clamp(width + KEYBOARD_STEP);
+ } else if (event.key === shrink) {
+ next = clamp(width - KEYBOARD_STEP);
+ } else if (event.key === 'Home') {
+ next = defaultWidth;
+ }
+
+ if (next !== undefined) {
+ event.preventDefault();
+ onResize(next);
+ onResizeEnd(next);
+ }
+ };
+
+ return (
+
+ );
+};
+
+export default ResizableDivider;
diff --git a/webapp/src/components/rhs/rhs_panel.module.scss b/webapp/src/components/rhs/rhs_panel.module.scss
new file mode 100644
index 0000000..031b1ca
--- /dev/null
+++ b/webapp/src/components/rhs/rhs_panel.module.scss
@@ -0,0 +1,36 @@
+// Full-height right column; the width comes from the resize hook, so the panel
+// only holds its flex/`position` context here. `position: relative` anchors the
+// resize handle on the left edge.
+.panel {
+ position: relative;
+ display: flex;
+ flex-shrink: 0;
+ flex-direction: column;
+ height: 100%;
+ min-height: 0;
+ border-left: 1px solid rgba(var(--center-channel-color-rgb), 0.12);
+ background: var(--center-channel-bg);
+}
+
+// Dragging sets the width every frame; no transition to fight the pointer.
+.resizing {
+ transition: none;
+}
+
+.headerTitle {
+ margin: 0;
+ color: var(--center-channel-color);
+ font-family: 'Metropolis', sans-serif;
+ font-size: 16px;
+ font-weight: 600;
+ line-height: 20px;
+}
+
+.body {
+ display: flex;
+ flex: 1 1 0;
+ flex-direction: column;
+ gap: 20px;
+ overflow-y: auto;
+ padding: 20px 16px;
+}
diff --git a/webapp/src/components/rhs/rhs_panel.test.tsx b/webapp/src/components/rhs/rhs_panel.test.tsx
new file mode 100644
index 0000000..b2f4b98
--- /dev/null
+++ b/webapp/src/components/rhs/rhs_panel.test.tsx
@@ -0,0 +1,65 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {fireEvent, screen} from '@testing-library/react';
+import React from 'react';
+
+import RhsPanel from './rhs_panel';
+
+import {renderWithContext} from '../../../tests/react_testing_utils';
+
+const renderPanel = (props: Partial> = {}) =>
+ renderWithContext(
+
+
{'body'}
+ ,
+ );
+
+describe('RhsPanel', () => {
+ // The region's name is what a screen reader reads on the way in, and it stays
+ // the panel's rather than the current screen's.
+ it('names the region and heads it with the panel name', () => {
+ renderPanel();
+
+ expect(screen.getByRole('complementary', {name: 'Space info'})).toBeInTheDocument();
+ expect(screen.getByRole('heading', {name: 'Space info'})).toBeInTheDocument();
+ expect(screen.getByText('body')).toBeInTheDocument();
+ });
+
+ it('heads a drilled-in screen with its own title, keeping the region name', () => {
+ renderPanel({title: 'Members', onBack: jest.fn()});
+
+ expect(screen.getByRole('complementary', {name: 'Space info'})).toBeInTheDocument();
+ expect(screen.getByRole('heading', {name: 'Members'})).toBeInTheDocument();
+ });
+
+ it('closes on request', () => {
+ const onClose = jest.fn();
+ renderPanel({onClose});
+
+ fireEvent.click(screen.getByRole('button', {name: 'Close'}));
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ // Back is what a drilled-in screen supplies; the root screen has nowhere to go.
+ it('offers no back control at the root', () => {
+ renderPanel();
+
+ expect(screen.queryByRole('button', {name: 'Back to Space info'})).not.toBeInTheDocument();
+ });
+
+ it('goes back from a drilled-in screen', () => {
+ const onBack = jest.fn();
+ renderPanel({title: 'Members', onBack});
+
+ fireEvent.click(screen.getByRole('button', {name: 'Back to Space info'}));
+
+ expect(onBack).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/webapp/src/components/rhs/rhs_panel.tsx b/webapp/src/components/rhs/rhs_panel.tsx
new file mode 100644
index 0000000..8b18534
--- /dev/null
+++ b/webapp/src/components/rhs/rhs_panel.tsx
@@ -0,0 +1,112 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import classNames from 'classnames';
+import {useSidebarWidth} from 'hooks/sidebar_width';
+import React, {useState} from 'react';
+import {useIntl} from 'react-intl';
+
+import ChevronLeftIcon from '@mattermost/compass-icons/components/chevron-left';
+import CloseIcon from '@mattermost/compass-icons/components/close';
+
+import {Button} from 'components/form_controls/button';
+import Header from 'components/header/header';
+import ResizableDivider from 'components/resizable_divider/resizable_divider';
+
+import styles from './rhs_panel.module.scss';
+
+const DEFAULT_WIDTH = 400;
+const MIN_WIDTH = 304;
+const MAX_WIDTH = 776;
+
+type Props = {
+
+ /**
+ * The panel's name, stable across its screens. Names the region for a screen
+ * reader and stands in for it in the resize and back controls.
+ */
+ name: string;
+
+ /** The visible heading. Defaults to `name`; a drilled-in screen overrides it. */
+ title?: React.ReactNode;
+
+ /** Distinguishes this panel's stored width from other panels'. */
+ widthKey: string;
+
+ /** Given only on a drilled-in screen — its presence is what renders Back. */
+ onBack?: () => void;
+ onClose: () => void;
+ children: React.ReactNode;
+};
+
+/**
+ * The right-hand panel shell, mirroring core's RHS: a resizable full-height column
+ * whose header shares the product header chrome, so it lines up with the space
+ * header rather than starting below it.
+ *
+ * Holds the frame only — the width, the resize handle, the heading and the close
+ * (and optional back) controls. What the panel is *for* is its children. Open/close
+ * state belongs to the caller, which should route it through `useRhs`.
+ */
+const RhsPanel = ({name, title, widthKey, onBack, onClose, children}: Props) => {
+ const {formatMessage} = useIntl();
+ const {width, setWidth, commitWidth} = useSidebarWidth(widthKey, DEFAULT_WIDTH);
+ const [resizing, setResizing] = useState(false);
+
+ return (
+
+ );
+};
+
+export default RhsPanel;
diff --git a/webapp/src/components/save_changes_bar/save_changes_bar.module.scss b/webapp/src/components/save_changes_bar/save_changes_bar.module.scss
new file mode 100644
index 0000000..aeddebd
--- /dev/null
+++ b/webapp/src/components/save_changes_bar/save_changes_bar.module.scss
@@ -0,0 +1,46 @@
+.bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 12px 12px 12px 16px;
+ border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
+ border-radius: 4px;
+ background: var(--center-channel-bg);
+ box-shadow: 0 6px 14px rgba(0, 0, 0, 0.12);
+}
+
+.error {
+ border-color: rgba(var(--error-text-color-rgb), 0.5);
+}
+
+.message {
+ display: flex;
+ min-width: 0;
+ align-items: center;
+ gap: 8px;
+ color: var(--center-channel-color);
+ font-family: 'Open Sans', sans-serif;
+ font-size: 14px;
+ font-weight: 600;
+ line-height: 20px;
+}
+
+.icon {
+ display: flex;
+ flex-shrink: 0;
+ align-items: center;
+ color: rgba(var(--center-channel-color-rgb), 0.75);
+}
+
+.error .message,
+.error .icon {
+ color: var(--error-text);
+}
+
+.actions {
+ display: flex;
+ flex-shrink: 0;
+ align-items: center;
+ gap: 8px;
+}
diff --git a/webapp/src/components/save_changes_bar/save_changes_bar.tsx b/webapp/src/components/save_changes_bar/save_changes_bar.tsx
new file mode 100644
index 0000000..143767b
--- /dev/null
+++ b/webapp/src/components/save_changes_bar/save_changes_bar.tsx
@@ -0,0 +1,94 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import classNames from 'classnames';
+import React from 'react';
+import {FormattedMessage} from 'react-intl';
+
+import AlertCircleOutlineIcon from '@mattermost/compass-icons/components/alert-circle-outline';
+import InformationOutlineIcon from '@mattermost/compass-icons/components/information-outline';
+
+import {PrimaryButton, TertiaryButton} from 'components/form_controls/button';
+
+import styles from './save_changes_bar.module.scss';
+
+export type SaveChangesBarState = 'editing' | 'error';
+
+type Props = {
+ state?: SaveChangesBarState;
+
+ /** Overrides the default "You have unsaved changes" prompt. */
+ message?: React.ReactNode;
+
+ /** Shown in place of the prompt when `state` is `error`. */
+ errorMessage?: React.ReactNode;
+ saving?: boolean;
+ saveText?: React.ReactNode;
+ resetText?: React.ReactNode;
+ onSave: () => void;
+ onReset: () => void;
+};
+
+/**
+ * Floating "unsaved changes" bar with Save/Reset actions, modeled on the host's
+ * channel-settings SaveChangesPanel. The consumer positions it (e.g. absolutely
+ * at the bottom of a modal) and renders it only while there are changes.
+ */
+const SaveChangesBar = ({state = 'editing', message, errorMessage, saving = false, saveText, resetText, onSave, onReset}: Props) => {
+ const isError = state === 'error';
+
+ return (
+
+
+ );
+};
+
+export default ShareSpaceModal;
diff --git a/webapp/src/components/space_info/space_info_members.tsx b/webapp/src/components/space_info/space_info_members.tsx
new file mode 100644
index 0000000..0c6c5c1
--- /dev/null
+++ b/webapp/src/components/space_info/space_info_members.tsx
@@ -0,0 +1,49 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import type {MemberProfile} from 'hooks/members';
+import React from 'react';
+import {FormattedMessage} from 'react-intl';
+import {Avatar} from 'webapp_globals';
+
+import styles from './space_info_panel.module.scss';
+
+type Props = {
+ members: MemberProfile[];
+};
+
+/**
+ * The panel's members view, reached from the info menu. Mirrors core's channel
+ * members RHS: the roster on its own screen rather than inline on the root.
+ */
+const SpaceInfoMembers = ({members}: Props) => (
+