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. +