From 13b2db391a739e3a501d866a3bafbaa8419b5096 Mon Sep 17 00:00:00 2001 From: Caleb Roseland Date: Mon, 27 Jul 2026 16:12:21 -0500 Subject: [PATCH 01/68] chore(docs-editor): integrate stub page editor from PR #7 Soft-merge of MM-69892 (PR #7, nang2049): the editor slice in webapp_globals and a stub PageEditor mounted at the page/draft routes, so the spaces-API wiring can build around the editor and avoid later conflicts. Applied as a net diff onto master since PR #7 was cut from the pre-merge scaffolding branch. Two divergences resolved: - Dropped PR #7's navigation.ts change: master's merged draft-route fix already exposes the isDraft the editor consumes (single [DOCS_DRAFT_ROUTE, DOCS_ROUTE] match instead of two useRouteMatch calls). - webapp_globals: took PR #7's superset React type imports; kept master's useBootstrapDocs in docs_root (3-way merged cleanly with the isDraft add). --- .../docs_root/docs_main_content.tsx | 33 ++-- webapp/src/components/docs_root/docs_root.tsx | 3 +- .../page_editor/page_editor.module.scss | 42 ++++ .../components/page_editor/page_editor.tsx | 63 ++++++ webapp/src/webapp_globals.ts | 182 +++++++++++++++++- 5 files changed, 300 insertions(+), 23 deletions(-) create mode 100644 webapp/src/components/page_editor/page_editor.module.scss create mode 100644 webapp/src/components/page_editor/page_editor.tsx diff --git a/webapp/src/components/docs_root/docs_main_content.tsx b/webapp/src/components/docs_root/docs_main_content.tsx index 067e933..497074b 100644 --- a/webapp/src/components/docs_root/docs_main_content.tsx +++ b/webapp/src/components/docs_root/docs_main_content.tsx @@ -6,19 +6,22 @@ import React from 'react'; import {FormattedMessage} from 'react-intl'; import DocsHome from 'components/docs_home/docs_home'; +import PageEditor from 'components/page_editor/page_editor'; import styles from './docs_main_content.module.scss'; type Props = { spaceId?: string; pageId?: string; + isDraft?: boolean; onCreateSpace: () => void; onBrowseSpaces: () => void; }; // The space view is built later; for now a routed space renders a placeholder -// that reflects the routed space/page to keep the URL observable. -const DocsMainContent = ({spaceId, pageId, onCreateSpace, onBrowseSpaces}: Props) => { +// that reflects the routed space/page. When a page is routed we hand off to +// PageEditor +const DocsMainContent = ({spaceId, pageId, isDraft, onCreateSpace, onBrowseSpaces}: Props) => { const space = useSpace(spaceId); if (!space) { @@ -30,6 +33,16 @@ const DocsMainContent = ({spaceId, pageId, onCreateSpace, onBrowseSpaces}: Props ); } + if (pageId) { + return ( + + ); + } + return (
@@ -39,18 +52,10 @@ const DocsMainContent = ({spaceId, pageId, onCreateSpace, onBrowseSpaces}: Props {space.title}

- {pageId ? ( - - ) : ( - - )} +

diff --git a/webapp/src/components/docs_root/docs_root.tsx b/webapp/src/components/docs_root/docs_root.tsx index 5b4069d..0d1c9be 100644 --- a/webapp/src/components/docs_root/docs_root.tsx +++ b/webapp/src/components/docs_root/docs_root.tsx @@ -16,7 +16,7 @@ import styles from './docs_root.module.scss'; const DocsRoot = () => { useBootstrapDocs(); - const {spaceId, pageId} = useDocsNavigation(); + const {spaceId, pageId, isDraft} = useDocsNavigation(); const [switcherOpen, setSwitcherOpen] = useState(false); const openSwitcher = useCallback(() => setSwitcherOpen(true), []); @@ -44,6 +44,7 @@ const DocsRoot = () => { diff --git a/webapp/src/components/page_editor/page_editor.module.scss b/webapp/src/components/page_editor/page_editor.module.scss new file mode 100644 index 0000000..7818986 --- /dev/null +++ b/webapp/src/components/page_editor/page_editor.module.scss @@ -0,0 +1,42 @@ +.root { + display: flex; + flex-direction: column; + height: 100%; + padding: 24px 32px; + gap: 16px; + overflow: auto; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + color: var(--center-channel-color); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.stub { + display: flex; + flex: 1; + align-items: center; + justify-content: center; + padding: 32px; + border: 1px dashed rgba(var(--center-channel-color-rgb), 0.24); + border-radius: 8px; + color: var(--center-channel-color); + font-size: 14px; + text-align: center; +} + +.empty { + display: flex; + flex: 1; + align-items: center; + justify-content: center; + padding: 48px 24px; + color: var(--center-channel-color); + font-size: 14px; + text-align: center; +} diff --git a/webapp/src/components/page_editor/page_editor.tsx b/webapp/src/components/page_editor/page_editor.tsx new file mode 100644 index 0000000..eeb24e7 --- /dev/null +++ b/webapp/src/components/page_editor/page_editor.tsx @@ -0,0 +1,63 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {FormattedMessage} from 'react-intl'; +import {hostCanUseEditor, hostGetEditor} from 'webapp_globals'; + +import styles from './page_editor.module.scss'; + +type Props = { + spaceId: string; + pageId: string; + isDraft: boolean; +}; + +// Placeholder mount for the WYSIWYG editor. This ticket only wires the +// component to the page route and proves the host slice resolves +const PageEditor = ({spaceId, pageId, isDraft}: Props) => { + if (!hostCanUseEditor()) { + return ( +
+ +
+ ); + } + + const editor = hostGetEditor(); + const providerCount = editor?.providers ? Object.keys(editor.providers).length : 0; + + return ( +
+
+ + {isDraft ? ( + + ) : ( + + )} + +
+
+ +
+
+ ); +}; + +export default PageEditor; diff --git a/webapp/src/webapp_globals.ts b/webapp/src/webapp_globals.ts index c4970fb..af00657 100644 --- a/webapp/src/webapp_globals.ts +++ b/webapp/src/webapp_globals.ts @@ -2,20 +2,28 @@ // See LICENSE.txt for license information. import type {History} from 'history'; -import type {ComponentType, ReactNode} from 'react'; +import type {ComponentType, ElementType, ForwardRefExoticComponent, KeyboardEvent, KeyboardEventHandler, ReactNode, ReactNodeArray, RefAttributes, RefObject} from 'react'; +import type {MessageDescriptor} from 'react-intl'; import type {Action} from 'redux'; +import type {Agent} from '@mattermost/types/agents'; +import type {Channel} from '@mattermost/types/channels'; +import type {Group} from '@mattermost/types/groups'; +import type {UserProfile} from '@mattermost/types/users'; + // Hand-typed view of the API the host web app attaches to `window` for plugins // (core's plugins/export.ts). The host guarantees these at runtime; anything // missing degrades to a no-op. // -// TRANSITION-MIGRATION: the modal contract below now lives in core as -// @mattermost/shared/types/global (WindowShared, PublishedModalUtils, -// PublishedModalId, PublishedModalProps). Our pinned @mattermost/shared release -// doesn't export it yet, so it's mirrored here. Replace this slice with those -// imports once the dependency is bumped to a version that ships types/global — -// which also lets openModalById/dialogProps be typed per-modal instead of loose. -// browserHistory has not migrated into WindowShared yet, so it stays here too. +// TRANSITION-MIGRATION: the modal and editor contracts below now live in core +// as @mattermost/shared/types/global (WindowShared, PublishedModalUtils, +// PublishedEditorUtils, PublishedSuggestionProviderConstructors, etc.). Our +// pinned @mattermost/shared release doesn't export them yet, so they're +// mirrored here. Replace this slice with those imports once the dependency is +// bumped to a version that ships types/global — which also lets openModalById +// be typed per-modal, and the editor components import from their canonical +// source instead of this local mirror. browserHistory has not migrated into +// WindowShared yet, so it stays here too. type PublishedModalId = 'user_settings' | 'invitation' | 'team_settings' | 'team_members' | 'leave_team'; @@ -30,9 +38,153 @@ type PublishedModalUtils = { canOpenModalId: (modalId: string) => boolean; }; +export type ActionResult = { + data?: Data; + error?: Error; +}; + +type Loading = {loading: boolean}; + +type ComponentOrComponents = { + component: ElementType; +} | { + components: ElementType[]; +}; + +export type ProviderResultsGroup = { + key: string; + label?: MessageDescriptor; + terms: string[]; + items: Array; +} & ComponentOrComponents; + +export type ProviderResults = + | {matchedPretext: string; groups: Array>} + | ({matchedPretext: string; terms: string[]; items: Array} & ComponentOrComponents); + +type SuggestionResultsGroup = { + key: string; + label?: MessageDescriptor; + terms: string[]; + items: Array; + components: ElementType[]; +}; + +export type SuggestionResults = + | {matchedPretext: string; groups: Array>} + | {matchedPretext: string; terms: string[]; items: Array; components: ElementType[]}; + +export type WysiwygEditorProps = { + value: string; + onChange: (markdown: string) => void; + onSubmit: () => void; + onFocus?: () => void; + onBlur?: () => void; + placeholder?: string; + channelId: string; + rootId?: string; + disabled?: boolean; + id?: string; + useCtrlSend?: boolean; + sendCodeBlockOnCtrlEnter?: boolean; + onKeyDown?: (e: KeyboardEvent) => void; +}; + +export type SuggestionListProps = { + inputRef?: RefObject; + open: boolean; + position?: 'top' | 'bottom'; + renderNoResults?: boolean; + onCompleteWord: (term: string, matchedPretext: string, e?: KeyboardEventHandler) => boolean; + preventClose?: () => void; + onItemHover: (term: string) => void; + pretext: string; + cleared: boolean; + results: SuggestionResults; + selection: string; + suggestionBoxAlgn?: { + lineHeight?: number; + pixelsToMoveX?: number; + pixelsToMoveY?: number; + }; +}; + +export type PublishedMarkdownMode = 'bold' | 'italic' | 'link' | 'strike' | 'code' | 'heading' | 'quote' | 'ul' | 'ol'; + +export type FormattingBarProps = { + applyFormatting: (mode: PublishedMarkdownMode) => void; + disableControls: boolean; + location: string; + additionalControls?: ReactNodeArray; + aiActionsMenu?: ReactNode; + + // Returns a Tiptap Editor. Left as `unknown` so consumers don't have to + // depend on `@tiptap/react` transitively; cast at the call site. + getEditor?: () => unknown; +}; + +export type PublishedWysiwygEditorHandle = { + insertText: (text: string) => void; + focus: () => void; + blur: () => void; + getInputBox: () => HTMLElement | null; +}; + +export type PublishedFormattingBarHandle = { + openLinkPopover: () => void; +}; + +export type SuggestionProviderInstance = { + triggerCharacter?: string; + handlePretextChanged: (pretext: string, resultsCallback: (results: ProviderResults) => void) => boolean | void; +}; + +export type AtMentionProviderOptions = { + currentUserId: string; + channelId: string; + autocompleteUsersInChannel: (prefix: string) => Promise; + useChannelMentions: boolean; + autocompleteGroups: Group[] | null; + searchAssociatedGroupsForReference: (prefix: string) => Promise>; + priorityProfiles: UserProfile[] | undefined; + defaultAgent?: Agent; +}; + +export type CommandProviderOptions = { + teamId: string; + channelId: string; + rootId?: string; +}; + +export type ChannelMentionProviderArgs = [ + channelSearchFunc: ( + term: string, + success: (channels: Channel[]) => void, + error: () => void, + ) => Promise, + delayChannelAutocomplete: boolean, +]; + +export type PublishedSuggestionProviderConstructors = { + AtMention: new (options: AtMentionProviderOptions) => SuggestionProviderInstance; + ChannelMention: new (...args: ChannelMentionProviderArgs) => SuggestionProviderInstance; + Command: new (options: CommandProviderOptions) => SuggestionProviderInstance; + Emoticon: new () => SuggestionProviderInstance; +}; + +export type PublishedSuggestionProviderId = keyof PublishedSuggestionProviderConstructors; + +export type PublishedEditorUtils = { + WysiwygEditor: ForwardRefExoticComponent>; + SuggestionList: ComponentType; + FormattingBar: ForwardRefExoticComponent>; + providers: PublishedSuggestionProviderConstructors; +}; + type WebappUtils = { browserHistory?: History; modals?: Partial; + editor?: Partial; }; const webappUtils = (): WebappUtils => (window as unknown as {WebappUtils?: WebappUtils}).WebappUtils ?? {}; @@ -78,3 +230,17 @@ export function hostCanOpenModal(modalId: string): boolean { export function hostOpenModalAction(modalId: PublishedModalId, dialogProps?: Record): Action | undefined { return webappUtils().modals?.openModalById?.(modalId, dialogProps); } + +// Whether the running host publishes the WYSIWYG editor surface. Older hosts +// (predating MM-69774) don't attach `editor` — callers should fall back to a +// read-only render or an "update your server" empty state. +export function hostCanUseEditor(): boolean { + return Boolean(webappUtils().editor?.WysiwygEditor); +} + +// The published editor components + suggestion provider constructors, or +// undefined when the host doesn't expose them. Fields are individually optional +// so a newer host can add pieces without breaking older plugin bundles. +export function hostGetEditor(): Partial | undefined { + return webappUtils().editor; +} From 07d9a50e06bfbdff41401f951f4b9c49fdcb6bba Mon Sep 17 00:00:00 2001 From: Caleb Roseland Date: Mon, 27 Jul 2026 17:09:21 -0500 Subject: [PATCH 02/68] feat(docs): wire spaces and membership to the plugin REST API Replace the mock data source with an API-backed DocsDataSource over the plugin's /api/v1 routes (server/api.go), following the Playbooks fetch(url, Client4.getOptions()) idiom. Spaces are created, listed (team-scoped by backing-channel membership), and left against the real server; the switcher fans out per team for cross-team results. - client/rest: doGet/doPost/doDelete + paginated listAll ({items,has_more}) - data: async DocsDataSource + apiDataSource; remove mock fixtures - store: fetchSpaces/fetchAllSpaces/createSpace/leaveSpace async thunks; drop the team_id hashing stand-in (the server scopes lists by team) - opaque server ids: drop slug-as-id and isSlugAvailable; the slug stays a client-only vanity field, format-validated only - recent: client-side view history (data/recent_spaces) as the seam for a later server last_viewed_at proxy; page count omitted for MVP - leave space: wired to member removal, navigates home when viewing it - visibility stays client-only, maps to server view_access later (PR #10) Addresses PR #2 feedback (team_id hashing, slug assumption, leaveSpace wiring). Targets current master; forward-compatible with PR #10. --- webapp/src/client/rest.ts | 80 ++++++++++++++++++ .../create_space_modal.test.tsx | 32 ++----- .../create_space_modal/validation_messages.ts | 3 - webapp/src/components/docs_home/docs_home.tsx | 8 +- webapp/src/components/docs_root/docs_root.tsx | 3 + .../docs_switcher/docs_switcher.tsx | 10 +++ .../spaces_sidebar/space_item_menu.tsx | 26 +++++- webapp/src/data/api_data_source.ts | 26 ++++++ webapp/src/data/docs_data_source.ts | 40 +++++---- webapp/src/data/fixtures.ts | 74 ----------------- webapp/src/data/index.ts | 9 +- webapp/src/data/mock_data_source.ts | 50 ----------- webapp/src/data/recent_spaces.ts | 61 ++++++++++++++ webapp/src/hooks/bootstrap.ts | 7 +- webapp/src/hooks/docs.ts | 21 ++++- webapp/src/hooks/spaces.ts | 55 ++++++++---- webapp/src/store/actions.ts | 83 +++++++++++-------- webapp/src/store/selectors.test.ts | 16 +--- webapp/src/store/selectors.ts | 36 +------- webapp/src/types/docs.ts | 12 ++- webapp/src/validation/space_schema.ts | 19 ++--- 21 files changed, 364 insertions(+), 307 deletions(-) create mode 100644 webapp/src/client/rest.ts create mode 100644 webapp/src/data/api_data_source.ts delete mode 100644 webapp/src/data/fixtures.ts delete mode 100644 webapp/src/data/mock_data_source.ts create mode 100644 webapp/src/data/recent_spaces.ts diff --git a/webapp/src/client/rest.ts b/webapp/src/client/rest.ts new file mode 100644 index 0000000..3d45024 --- /dev/null +++ b/webapp/src/client/rest.ts @@ -0,0 +1,80 @@ +// 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`; + +type FetchOptions = { + method: string; + body?: string; + headers?: Record; +}; + +// 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 ClientError from @mattermost/client. +async function doFetch(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}`; + try { + const data = await response.json(); + message = data.message || message; + } catch { + // Non-JSON error body — keep the status-based message. + } + throw new ClientError(Client4.url, {message, status_code: response.status, url}); +} + +export const restGet = (url: string): Promise => doFetch(url, {method: 'GET'}); + +export const restPost = (url: string, body: unknown): Promise => + doFetch(url, {method: 'POST', body: JSON.stringify(body), headers: {'Content-Type': 'application/json'}}); + +export const restDelete = (url: string): Promise => doFetch(url, {method: 'DELETE'}); + +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): 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}`)); + out.push(...res.items); + if (!res.has_more) { + break; + } + } + return out; +} + +export {apiUrl}; 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..c1d0359 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, so the returned space's id is unrelated to the slug. 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/validation_messages.ts b/webapp/src/components/create_space_modal/validation_messages.ts index 2338938..888fa92 100644 --- a/webapp/src/components/create_space_modal/validation_messages.ts +++ b/webapp/src/components/create_space_modal/validation_messages.ts @@ -13,7 +13,6 @@ const messages = defineMessages({ 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'}, }); @@ -32,8 +31,6 @@ export function resolveSpaceValidationError(id: string, formatMessage: FormatMes 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/docs_home/docs_home.tsx b/webapp/src/components/docs_home/docs_home.tsx index 1226e62..88ac443 100644 --- a/webapp/src/components/docs_home/docs_home.tsx +++ b/webapp/src/components/docs_home/docs_home.tsx @@ -156,7 +156,7 @@ 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}, ); @@ -192,8 +192,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} { const {spaceId, pageId, isDraft} = useDocsNavigation(); + useRecordSpaceView(spaceId); + const [switcherOpen, setSwitcherOpen] = useState(false); const openSwitcher = useCallback(() => setSwitcherOpen(true), []); const closeSwitcher = useCallback(() => setSwitcherOpen(false), []); diff --git a/webapp/src/components/docs_switcher/docs_switcher.tsx b/webapp/src/components/docs_switcher/docs_switcher.tsx index db02793..adc17d7 100644 --- a/webapp/src/components/docs_switcher/docs_switcher.tsx +++ b/webapp/src/components/docs_switcher/docs_switcher.tsx @@ -4,6 +4,7 @@ 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 React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; @@ -12,6 +13,8 @@ import {useIntl} from 'react-intl'; 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 type {Page, Space} from 'types/docs'; @@ -33,8 +36,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(); diff --git a/webapp/src/components/spaces_sidebar/space_item_menu.tsx b/webapp/src/components/spaces_sidebar/space_item_menu.tsx index 4d5c9a5..c419046 100644 --- a/webapp/src/components/spaces_sidebar/space_item_menu.tsx +++ b/webapp/src/components/spaces_sidebar/space_item_menu.tsx @@ -2,7 +2,8 @@ // See LICENSE.txt for license information. import {useDocsNavigation} from 'hooks/navigation'; -import React, {useState} from 'react'; +import {useAppDispatch} from 'hooks/redux'; +import React, {useCallback, useState} from 'react'; import {FormattedMessage, useIntl} from 'react-intl'; import {copyToClipboard} from 'utils/clipboard'; @@ -10,6 +11,8 @@ import DotsVerticalIcon from '@mattermost/compass-icons/components/dots-vertical import ExitToAppIcon from '@mattermost/compass-icons/components/exit-to-app'; import LinkVariantIcon from '@mattermost/compass-icons/components/link-variant'; +import {leaveSpace} from 'store/actions'; + import ConfirmModal from 'components/confirm_modal/confirm_modal'; import Menu from 'components/menu/menu'; import type {MenuItemSpec} from 'components/menu/menu_types'; @@ -24,12 +27,29 @@ type Props = { const SpaceItemMenu = ({space}: Props) => { const {formatMessage} = useIntl(); - const {paths} = useDocsNavigation(); + const dispatch = useAppDispatch(); + const {paths, spaceId, goHome} = useDocsNavigation(); const [confirmLeaveOpen, setConfirmLeaveOpen] = useState(false); const copyLink = () => copyToClipboard(`${window.location.origin}${paths.space(space.id)}`); + // Leaving removes the current user's membership. Navigate home only if we + // just left the space being viewed, and only after the server confirms + // (a last-authorized-member removal is rejected with 409). + const confirmLeave = useCallback(async () => { + try { + await dispatch(leaveSpace(space.id)); + if (spaceId === space.id) { + goHome(); + } + } catch (error) { + // eslint-disable-next-line no-console + console.error('Docs: failed to leave space', error); + } + setConfirmLeaveOpen(false); + }, [dispatch, space.id, spaceId, goHome]); + const items: MenuItemSpec[] = [ // Favorite space is deferred until ordering/favorites persist to user @@ -116,7 +136,7 @@ const SpaceItemMenu = ({space}: Props) => { /> )} isConfirmDestructive={true} - onConfirm={() => setConfirmLeaveOpen(false)} + onConfirm={confirmLeave} onCancel={() => setConfirmLeaveOpen(false)} > listAll((query) => `${apiUrl()}/teams/${teamId}/spaces?${query}`), + + getSpace: (spaceId) => restGet(`${apiUrl()}/spaces/${spaceId}`), + + createSpace: (teamId, input: CreateSpaceInput) => restPost(`${apiUrl()}/teams/${teamId}/spaces`, { + title: input.title.trim(), + description: input.description?.trim() || undefined, + icon: input.icon || undefined, + }), + + removeSpaceMember: (spaceId, userId) => restDelete(`${apiUrl()}/spaces/${spaceId}/members/${userId}`), + + listPages: (spaceId) => listAll((query) => `${apiUrl()}/spaces/${spaceId}/pages?${query}`), +}; diff --git a/webapp/src/data/docs_data_source.ts b/webapp/src/data/docs_data_source.ts index 57e1d3d..a1e9329 100644 --- a/webapp/src/data/docs_data_source.ts +++ b/webapp/src/data/docs_data_source.ts @@ -3,24 +3,32 @@ import type {CreateSpaceInput, Page, Space} from 'types/docs'; -// The seam between the store's thunks and where Docs data actually comes from. -// The mock source implements this today; an API-backed source (over the -// Mattermost client + plugin REST) replaces it once the server contract -// exists. Methods are synchronous for the mock source and will become -// Promise-based with the real source. +// The seam between the store's thunks and the Docs server REST API. The +// API-backed source implements this over the plugin's /api/v1 routes; the +// interface stays transport-agnostic so tests can substitute a fake. +// +// All methods are async: they map to network calls. Ids are the platform's +// opaque 26-char ids (no slugs) and space reads/lists are team-scoped, matching +// the server contract. export interface DocsDataSource { - listSpaces(): Space[]; - getSpace(id: string): Space | undefined; - // Pages belong to a space, so listing them is always scoped to one. - listPages(spaceId: string): Page[]; + // Spaces the caller is a member of in the given team (the server filters by + // backing-channel membership). + listSpaces(teamId: string): Promise; - // Creates a space and returns it. Synchronous for the mock source; becomes - // Promise-based (and may reject) once a real backend exists, which is why - // the createSpace thunk treats submission as async. - createSpace(input: CreateSpaceInput): Space; + getSpace(spaceId: string): Promise; - // Whether a custom slug is free to use. The mock source checks its - // in-memory spaces; the real source checks the server. - isSlugAvailable(slug: string): boolean; + // Creates a space in the team and returns it (with its server-assigned id + // and team_id). Only server-supported fields are sent; client-only fields + // (slug, visibility) are dropped by the API source until the server models + // them (see PR #10's view_access). + createSpace(teamId: string, input: CreateSpaceInput): Promise; + + // Removes a member from a space. Leaving a space is removing yourself; the + // server rejects removing the last authorized member (409). + removeSpaceMember(spaceId: string, userId: string): Promise; + + // Pages belong to a space. No page-consuming UI exists yet, so this is + // wired for later; the server returns page summaries (no body). + listPages(spaceId: string): Promise; } diff --git a/webapp/src/data/fixtures.ts b/webapp/src/data/fixtures.ts deleted file mode 100644 index dd6dc1c..0000000 --- a/webapp/src/data/fixtures.ts +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import type {Page, Space} from 'types/docs'; - -// Design-time fixtures behind the mock data source. The real Docs API replaces -// these once the server contract exists; only the mock source and the store's -// recent-docs selectors read them directly. - -function space(id: string, title: string, icon: string): Space { - return { - id, - team_id: '', - creator_id: '', - title, - icon, - props: {}, - create_at: 0, - update_at: 0, - delete_at: 0, - sort_order: 0, - }; -} - -function page(id: string, title: string, spaceId: string): Page { - return { - id, - space_id: spaceId, - parent_id: '', - type: 'page', - title, - body: '', - sort_order: 0, - create_at: 0, - update_at: 0, - edit_at: 0, - delete_at: 0, - }; -} - -export const spaces: Space[] = [ - space('project-avalanche', 'Project Avalanche', '✈️'), - space('contributor-wiki', 'Contributor Wiki', '👨🏻‍💻'), - space('developers', 'Developers', '⌨️'), - space('release-discussion', 'Release Discussion', '🚀'), - space('incident-handbook', 'Incident Handbook', '📕'), - space('security-incident-handbook', 'Security Incident Handbook', '🛡️'), - space('product-support', 'Product Support', '🖐️'), -]; - -export const pages: Page[] = [ - page('operational-procedures', 'Operational Procedures', 'project-avalanche'), - page('overview-flight-comms', 'Overview of Flight Communication Protocols', 'project-avalanche'), - page('communication-protocols', 'Communication Protocols', 'project-avalanche'), -]; - -export const recentSpaceIds = ['project-avalanche']; -export const recentPageIds = ['operational-procedures', 'overview-flight-comms', 'communication-protocols']; - -// Recently-viewed-spaces summaries for the Home listing (page counts and -// last-viewed timestamps are mocked until the server provides them; the client -// formats the timestamp into a relative label at render). -const MINUTE = 60 * 1000; -const DAY = 24 * 60 * MINUTE; -const now = Date.now(); - -export const recentSpaceSummaries: Array<{spaceId: string; pageCount: number; lastViewedAt: number}> = [ - {spaceId: 'project-avalanche', pageCount: 12, lastViewedAt: now - (12 * MINUTE)}, - {spaceId: 'incident-handbook', pageCount: 25, lastViewedAt: now - (18 * MINUTE)}, - {spaceId: 'contributor-wiki', pageCount: 48, lastViewedAt: now - (18 * MINUTE)}, - {spaceId: 'product-support', pageCount: 4, lastViewedAt: now - (2 * DAY)}, - {spaceId: 'release-discussion', pageCount: 5, lastViewedAt: now - (2 * DAY)}, - {spaceId: 'security-incident-handbook', pageCount: 5, lastViewedAt: now - (2 * DAY)}, -]; diff --git a/webapp/src/data/index.ts b/webapp/src/data/index.ts index 49ffae9..c2bfa4c 100644 --- a/webapp/src/data/index.ts +++ b/webapp/src/data/index.ts @@ -1,12 +1,11 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {apiDataSource} from './api_data_source'; import type {DocsDataSource} from './docs_data_source'; -import {mockDataSource} from './mock_data_source'; -// The single active data source. Swapped for the API-backed source once the -// Docs server contract exists; hooks depend only on this, never on the source -// implementation, so the swap touches no UI. -export const docsDataSource: DocsDataSource = mockDataSource; +// The single active data source: the Docs plugin REST API. Thunks depend only +// on this, never on the transport, so a fake can be substituted in tests. +export const docsDataSource: DocsDataSource = apiDataSource; export type {DocsDataSource} from './docs_data_source'; diff --git a/webapp/src/data/mock_data_source.ts b/webapp/src/data/mock_data_source.ts deleted file mode 100644 index 8c38035..0000000 --- a/webapp/src/data/mock_data_source.ts +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import type {Space} from 'types/docs'; - -import type {DocsDataSource} from './docs_data_source'; -import {pages, spaces} from './fixtures'; - -const spacesById = new Map(spaces.map((s) => [s.id, s])); - -// Default icon for a newly created space until an icon/emoji picker (imported -// from the web app) exists. -const DEFAULT_SPACE_ICON = '📄'; - -export const mockDataSource: DocsDataSource = { - listSpaces: () => spaces, - getSpace: (id) => spacesById.get(id), - listPages: (spaceId) => pages.filter((page) => page.space_id === spaceId), - createSpace: (input): Space => { - const now = Date.now(); - - // The slug is the space's URL identifier, so it doubles as the id in the - // mock (fixture ids are slugs too). isSlugAvailable guarantees it's free. - const space: Space = { - id: input.slug, - team_id: '', - creator_id: '', - title: input.title.trim(), - icon: input.icon || DEFAULT_SPACE_ICON, - description: input.description?.trim() || undefined, - visibility: input.visibility, - props: {}, - create_at: now, - update_at: now, - delete_at: 0, - sort_order: 0, - }; - - // Prepend so the new space shows at the top of the Spaces list, the way - // the real source would reflect a freshly created entity. - spaces.unshift(space); - spacesById.set(space.id, space); - return space; - }, - - // A slug is free when no existing space claims it. Mock spaces are keyed by - // their slug (see createSpace + fixtures), so the id map is the source of - // truth the real backend check stands in for. - isSlugAvailable: (slug) => !spacesById.has(slug), -}; diff --git a/webapp/src/data/recent_spaces.ts b/webapp/src/data/recent_spaces.ts new file mode 100644 index 0000000..bf77a9d --- /dev/null +++ b/webapp/src/data/recent_spaces.ts @@ -0,0 +1,61 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// Client-side "recently viewed spaces" store. There is no server view-history +// API yet; space visibility is already driven by backing-channel membership, so +// the eventual source is that channel's ChannelMember.LastViewedAt surfaced by +// the server. Callers use recordSpaceView / getSpaceViews as the seam, so that +// swap won't touch them. + +export type SpaceView = { + spaceId: string; + lastViewedAt: number; +}; + +const KEY_PREFIX = 'docs_recent_spaces_'; +const MAX_TRACKED = 50; + +// Per-user so multiple accounts on one browser don't share a history. +const storageKey = (userId: string): string => `${KEY_PREFIX}${userId}`; + +const read = (userId: string): Record => { + try { + const raw = window.localStorage.getItem(storageKey(userId)); + return raw ? JSON.parse(raw) as Record : {}; + } catch { + // Storage unavailable (private mode / quota) or corrupt — treat as empty. + return {}; + } +}; + +const write = (userId: string, map: Record): void => { + try { + window.localStorage.setItem(storageKey(userId), JSON.stringify(map)); + } catch { + // Best-effort; recency is non-critical, so a write failure is ignored. + } +}; + +export function recordSpaceView(userId: string, spaceId: string, at: number): void { + if (!userId || !spaceId) { + return; + } + const map = read(userId); + map[spaceId] = at; + + // Cap the history: drop the oldest entries beyond MAX_TRACKED. + const entries = Object.entries(map); + if (entries.length > MAX_TRACKED) { + entries.sort((a, b) => b[1] - a[1]); + write(userId, Object.fromEntries(entries.slice(0, MAX_TRACKED))); + return; + } + write(userId, map); +} + +// Most-recently-viewed first. +export function getSpaceViews(userId: string): SpaceView[] { + return Object.entries(read(userId)). + map(([spaceId, lastViewedAt]) => ({spaceId, lastViewedAt})). + sort((a, b) => b.lastViewedAt - a.lastViewedAt); +} diff --git a/webapp/src/hooks/bootstrap.ts b/webapp/src/hooks/bootstrap.ts index e2fff60..d5698c7 100644 --- a/webapp/src/hooks/bootstrap.ts +++ b/webapp/src/hooks/bootstrap.ts @@ -4,16 +4,15 @@ import {useAppDispatch} from 'hooks/redux'; import {useEffect} from 'react'; -import {fetchPages, fetchSpaces} from 'store/actions'; +import {fetchSpaces} from 'store/actions'; // Loads the Docs data layer once the product mounts (i.e. an authenticated user -// has navigated into Docs), rather than at plugin init. Later this becomes a -// team-scoped fetch against the real API. +// has navigated into Docs), rather than at plugin init. Team-scoped: the server +// returns the current team's spaces the caller belongs to. export function useBootstrapDocs(): void { const dispatch = useAppDispatch(); useEffect(() => { dispatch(fetchSpaces()); - dispatch(fetchPages()); }, [dispatch]); } diff --git a/webapp/src/hooks/docs.ts b/webapp/src/hooks/docs.ts index 8829edd..c107eb8 100644 --- a/webapp/src/hooks/docs.ts +++ b/webapp/src/hooks/docs.ts @@ -1,18 +1,31 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {getSpaceViews} from 'data/recent_spaces'; import {useAppSelector} from 'hooks/redux'; import {useMemo} from 'react'; -import {getRecentPages, getRecentSpaces, searchDocs} from 'store/selectors'; +import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; + +import {getSpacesById, searchDocs} from 'store/selectors'; import type {DocsSearchResults} from 'store/selectors'; import type {Page, Space} from 'types/docs'; +const EMPTY_PAGES: Page[] = []; + +// Recently-viewed docs for the switcher. Cross-team recent spaces resolved from +// the client-side recency store; recent pages await the page tree (Pages later). export function useRecentDocs(): {spaces: Space[]; pages: Page[]} { - const spaces = useAppSelector(getRecentSpaces); - const pages = useAppSelector(getRecentPages); - return useMemo(() => ({spaces, pages}), [spaces, pages]); + const userId = useAppSelector(getCurrentUserId); + const spacesById = useAppSelector(getSpacesById); + return useMemo(() => { + const spaces = getSpaceViews(userId).flatMap(({spaceId}) => { + const space = spacesById[spaceId]; + return space ? [space] : []; + }); + return {spaces, pages: EMPTY_PAGES}; + }, [userId, spacesById]); } // Filtering lives in the store selector, not the UI; later this can debounce diff --git a/webapp/src/hooks/spaces.ts b/webapp/src/hooks/spaces.ts index 24f91ec..d123ea3 100644 --- a/webapp/src/hooks/spaces.ts +++ b/webapp/src/hooks/spaces.ts @@ -2,14 +2,17 @@ // See LICENSE.txt for license information. import {useForm} from '@tanstack/react-form'; -import {useAppDispatch, useAppSelector, useAppStore} from 'hooks/redux'; +import {getSpaceViews, recordSpaceView} from 'data/recent_spaces'; +import {useAppDispatch, useAppSelector} from 'hooks/redux'; import {useTeamContext} from 'hooks/team'; -import {useCallback, useMemo, useRef} from 'react'; +import {useCallback, useEffect, useMemo, useRef} from 'react'; import {DOCS_KEYWORD} from 'routing/paths'; import {createSpaceFormSchema, slugify} from 'validation/space_schema'; +import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; + import {createSpace} from 'store/actions'; -import {getAllSpaces, getRecentSpaceSummaries, getSpace, getSpacesForCurrentTeam, isSlugAvailable} from 'store/selectors'; +import {getAllSpaces, getSpace, getSpacesForCurrentTeam} from 'store/selectors'; import type {UrlInputHandle} from 'components/form-controls/url_input'; @@ -28,8 +31,31 @@ export function useSpace(id?: string): Space | undefined { return useAppSelector((state) => (id ? getSpace(state, id) : undefined)); } +// Recently-viewed spaces in the current team (Home). Recency is client-side +// today (see data/recent_spaces); resolved against the loaded team spaces so a +// left/deleted space drops out. pageCount is omitted until the server provides +// one. export function useRecentSpaceSummaries(): SpaceSummary[] { - return useAppSelector(getRecentSpaceSummaries); + const userId = useAppSelector(getCurrentUserId); + const teamSpaces = useAppSelector(getSpacesForCurrentTeam); + return useMemo(() => { + const byId = new Map(teamSpaces.map((space) => [space.id, space])); + return getSpaceViews(userId).flatMap(({spaceId, lastViewedAt}) => { + const space = byId.get(spaceId); + return space ? [{space, lastViewedAt}] : []; + }); + }, [userId, teamSpaces]); +} + +// Records that the current user viewed a space, feeding the recently-viewed +// list. No-op until both ids are known. +export function useRecordSpaceView(spaceId?: string): void { + const userId = useAppSelector(getCurrentUserId); + useEffect(() => { + if (userId && spaceId) { + recordSpaceView(userId, spaceId, Date.now()); + } + }, [userId, spaceId]); } type CreateSpaceValues = { @@ -50,19 +76,16 @@ type CreateSpaceOptions = { onCreated?: (space: Space) => void; }; -// Owns the create-space form via TanStack Form. The existing Zod schemas drive -// validation through TanStack's validators — the whole-form schema on submit -// (its issues distribute to fields by path) and the slug's async format + -// uniqueness schema on blur. The uniqueness check reads current store state -// (a Zod refine isn't a React hook, so it reads the store snapshot rather than -// subscribing) so a space created earlier in the same session is accounted for. +// Owns the create-space form via TanStack Form. The Zod schema drives validation +// through TanStack's validators (its issues distribute to fields by path). The +// slug is a client-only vanity field for now — the server assigns an opaque id +// and has no slug concept, so there's no uniqueness check; only format is +// validated (the slug's on-blur schema). export function useCreateSpace({onCreated}: CreateSpaceOptions = {}) { const {name: teamName} = useTeamContext(); const dispatch = useAppDispatch(); - const store = useAppStore(); - const checkSlugAvailable = useCallback((slug: string) => isSlugAvailable(store.getState(), slug), [store]); - const formSchema = useMemo(() => createSpaceFormSchema(checkSlugAvailable), [checkSlugAvailable]); + const formSchema = useMemo(() => createSpaceFormSchema(), []); const slugSchema = useMemo(() => formSchema.shape.slug, [formSchema]); // Stop deriving the slug from the name once the user edits the slug directly. @@ -74,12 +97,12 @@ export function useCreateSpace({onCreated}: CreateSpaceOptions = {}) { defaultValues: INITIAL_VALUES, validators: {onSubmitAsync: formSchema}, onSubmit: async ({value}) => { - const space = await Promise.resolve(dispatch(createSpace({ + const space = await dispatch(createSpace({ title: value.name.trim(), slug: value.slug.trim(), visibility: value.visibility, description: value.description.trim() || undefined, - }))); + })); onCreated?.(space); }, }); @@ -96,7 +119,7 @@ export function useCreateSpace({onCreated}: CreateSpaceOptions = {}) { form.setFieldValue('slug', slug); }, [form]); - // Submits, then surfaces a rejected slug (e.g. a taken URL) by focusing the + // Submits, then surfaces a rejected slug (e.g. a bad format) by focusing the // URL input — otherwise the error lands on the field's read-only preview. const submit = useCallback(async () => { await form.handleSubmit(); diff --git a/webapp/src/store/actions.ts b/webapp/src/store/actions.ts index 2964f16..bc8b0a3 100644 --- a/webapp/src/store/actions.ts +++ b/webapp/src/store/actions.ts @@ -4,60 +4,73 @@ import {docsDataSource} from 'data'; import {getCurrentTeamId, getMyTeams} from 'mattermost-redux/selectors/entities/teams'; +import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; import type {CreateSpaceInput, Space} from 'types/docs'; import type {DocsThunkAction} from 'types/store'; import {PageTypes, SpaceTypes} from './action_types'; -// Stable per-id hash so a space always lands in the same team across refetches -// (a plain random would make spaces hop teams on every fetch). -const hashString = (value: string): number => { - let hash = 0; - for (let i = 0; i < value.length; i++) { - hash = ((hash * 31) + value.charCodeAt(i)) | 0; - } - return Math.abs(hash); -}; - -// The mock fixtures aren't team-aware. Spread them across the user's teams -// (deterministically by space id) so team-scoped reads visibly differ per team; -// falls back to the current team when membership isn't loaded. The real API will -// return spaces already scoped to their team, at which point this goes away. -export function fetchSpaces(): DocsThunkAction { - return (dispatch, getState) => { - const state = getState(); - const teamIds = getMyTeams(state).map((team) => team.id); - const fallbackTeamId = getCurrentTeamId(state); - const spaces = docsDataSource.listSpaces().map((space) => ({ - ...space, - team_id: teamIds.length ? teamIds[hashString(space.id) % teamIds.length] : fallbackTeamId, - })); - dispatch({type: SpaceTypes.RECEIVED_SPACES, spaces}); +// Spaces the caller belongs to in the current team (the server scopes the list +// by backing-channel membership). A failed load leaves the store empty rather +// than crashing the product on mount. +export function fetchSpaces(): DocsThunkAction> { + return async (dispatch, getState) => { + const teamId = getCurrentTeamId(getState()); + if (!teamId) { + return; + } + try { + const spaces = await docsDataSource.listSpaces(teamId); + dispatch({type: SpaceTypes.RECEIVED_SPACES, spaces}); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Docs: failed to load spaces', error); + } + }; +} + +// Cross-team load for the switcher: fan out over the user's teams. The server +// has no all-teams endpoint, so this is N team-scoped calls run in parallel. +export function fetchAllSpaces(): DocsThunkAction> { + return async (dispatch, getState) => { + const teams = getMyTeams(getState()); + try { + const perTeam = await Promise.all(teams.map((team) => docsDataSource.listSpaces(team.id))); + dispatch({type: SpaceTypes.RECEIVED_SPACES, spaces: perTeam.flat()}); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Docs: failed to load spaces across teams', error); + } }; } -// Bootstraps pages for one space, or for every known space when called with no -// argument — there's no bulk "list all pages" on the data source yet. -export function fetchPages(spaceId?: string): DocsThunkAction { - return (dispatch) => { - const spaceIds = spaceId ? [spaceId] : docsDataSource.listSpaces().map((space) => space.id); - const pages = spaceIds.flatMap((id) => docsDataSource.listPages(id)); +// Loads a space's pages. Wired for the page tree that lands later; no UI reads +// store pages yet, so this isn't called on bootstrap. +export function fetchPages(spaceId: string): DocsThunkAction> { + return async (dispatch) => { + const pages = await docsDataSource.listPages(spaceId); dispatch({type: PageTypes.RECEIVED_PAGES, pages}); }; } -export function createSpace(input: CreateSpaceInput): DocsThunkAction { - return (dispatch, getState) => { +// Creates a space in the current team and returns the server-assigned entity +// (rejects on failure so the form can surface it). +export function createSpace(input: CreateSpaceInput): DocsThunkAction> { + return async (dispatch, getState) => { const teamId = getCurrentTeamId(getState()); - const space = {...docsDataSource.createSpace(input), team_id: teamId}; + const space = await docsDataSource.createSpace(teamId, input); dispatch({type: SpaceTypes.CREATED_SPACE, space}); return space; }; } -export function leaveSpace(spaceId: string): DocsThunkAction { - return (dispatch) => { +// Leaving a space is removing yourself from its membership. The server rejects +// removing the last authorized member (409); the caller surfaces that. +export function leaveSpace(spaceId: string): DocsThunkAction> { + return async (dispatch, getState) => { + const userId = getCurrentUserId(getState()); + await docsDataSource.removeSpaceMember(spaceId, userId); dispatch({type: SpaceTypes.DELETED_SPACE, spaceId}); }; } diff --git a/webapp/src/store/selectors.test.ts b/webapp/src/store/selectors.test.ts index 76e6c8a..eb104ed 100644 --- a/webapp/src/store/selectors.test.ts +++ b/webapp/src/store/selectors.test.ts @@ -5,7 +5,7 @@ import manifest from 'manifest'; import type {GlobalState} from '@mattermost/types/store'; -import {getPagesForSpace, getSpacesInTeam, isSlugAvailable} from './selectors'; +import {getPagesForSpace, getSpacesInTeam} from './selectors'; import {makePage, makeSpace} from './test_fixtures'; import type {DocsPluginState} from './types'; @@ -29,20 +29,6 @@ describe('getSpacesInTeam', () => { }); }); -describe('isSlugAvailable', () => { - it('is false when a space already claims the slug', () => { - const state = makeState({ - spaces: {taken: makeSpace('taken', 'Taken')}, - spacesInTeam: {}, - pages: {}, - pagesInSpace: {}, - }); - - expect(isSlugAvailable(state, 'taken')).toBe(false); - expect(isSlugAvailable(state, 'free')).toBe(true); - }); -}); - describe('getPagesForSpace', () => { it('resolves page ids for a space, ignoring other spaces', () => { const page1 = makePage('p1', 'space-a', 'Page 1'); diff --git a/webapp/src/store/selectors.ts b/webapp/src/store/selectors.ts index 51326bd..699f3da 100644 --- a/webapp/src/store/selectors.ts +++ b/webapp/src/store/selectors.ts @@ -1,7 +1,6 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {recentPageIds, recentSpaceIds, recentSpaceSummaries} from 'data/fixtures'; import manifest from 'manifest'; import {createSelector} from 'reselect'; @@ -9,7 +8,7 @@ import type {GlobalState} from '@mattermost/types/store'; import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; -import type {Page, Space, SpaceSummary} from 'types/docs'; +import type {Page, Space} from 'types/docs'; import type {DocsPluginState} from './types'; @@ -85,34 +84,6 @@ const getAllPages = createSelector( export const getSpace = (state: GlobalState, id: string): Space | undefined => getSpacesById(state)[id]; -// A slug doubles as a space id in the mock (see data/mock_data_source.ts), so -// checking the store's id map is equivalent to checking slug uniqueness. -export const isSlugAvailable = (state: GlobalState, slug: string): boolean => !getSpacesById(state)[slug]; - -const EMPTY_SET: Set = new Set(); - -const getCurrentTeamSpaceIds = createSelector( - [getSpacesInTeamIndex, getCurrentTeamId], - (index, teamId) => index[teamId] ?? EMPTY_SET, -); - -// "Recent" bookkeeping is design-time fixture data until a real recently-viewed -// concept (and API) exists; the ids resolve against the reactive store so the -// result still reflects any local creates/deletes. Cross-team (backs the -// switcher); the team-scoped Home listing is getRecentSpaceSummaries below. -export const getRecentSpaces = createSelector( - [getSpacesById], - (byId) => compact(recentSpaceIds.map((id) => byId[id])), -); - -export const getRecentSpaceSummaries = createSelector( - [getSpacesById, getCurrentTeamSpaceIds], - (byId, teamSpaceIds): SpaceSummary[] => recentSpaceSummaries.flatMap(({spaceId, pageCount, lastViewedAt}) => { - const space = byId[spaceId]; - return space && teamSpaceIds.has(space.id) ? [{space, pageCount, lastViewedAt}] : []; - }), -); - export const getPagesForSpace = createSelector( [getPagesById, getPagesInSpaceIndex, (_state: GlobalState, spaceId: string) => spaceId], (byId, index, spaceId) => resolvePages(index[spaceId], byId), @@ -120,11 +91,6 @@ export const getPagesForSpace = createSelector( export const getPage = (state: GlobalState, id: string): Page | undefined => getPagesById(state)[id]; -export const getRecentPages = createSelector( - [getPagesById], - (byId) => compact(recentPageIds.map((id) => byId[id])), -); - export type DocsSearchResults = { spaces: Space[]; pages: Page[]; diff --git a/webapp/src/types/docs.ts b/webapp/src/types/docs.ts index 357614e..a2e0ac8 100644 --- a/webapp/src/types/docs.ts +++ b/webapp/src/types/docs.ts @@ -8,8 +8,8 @@ export type SpaceVisibility = 'public' | 'private'; // Field names and shapes mirror the server model (server/model/space.go) — // snake_case JSON per @mattermost/types convention. The server model has no -// `visibility`; it stays a client-only field until channel privacy / props -// represent it. +// `visibility` yet; it stays a client-only field and maps to the server's +// `view_access` (open/private) once that lands (PR #10 / MM-69269). export type Space = { id: string; team_id: string; @@ -56,10 +56,14 @@ export type Page = { export type SpaceSummary = { space: Space; - pageCount: number; + + // Omitted until the server provides a count (listing pages per space just to + // count them isn't worth it for MVP). + pageCount?: number; // Epoch ms of the viewer's last visit; the client formats it into a - // "Viewed 12m ago"-style label at render (server-provided later). + // "Viewed 12m ago"-style label at render. Client-tracked today (see + // data/recent_spaces), server-provided later. lastViewedAt?: number; }; diff --git a/webapp/src/validation/space_schema.ts b/webapp/src/validation/space_schema.ts index fe1c2bd..4972577 100644 --- a/webapp/src/validation/space_schema.ts +++ b/webapp/src/validation/space_schema.ts @@ -24,22 +24,16 @@ export const SpaceValidationError = { UrlRequired: 'url.required', UrlTooLong: 'url.tooLong', UrlInvalid: 'url.invalid', - UrlTaken: 'url.taken', DescriptionTooLong: 'description.tooLong', } as const; export type SpaceValidationError = typeof SpaceValidationError[keyof typeof SpaceValidationError]; -// Reports whether a slug is free. Injected so the schema stays decoupled from -// where availability is checked (the store, today); may be async, since the -// real check asks the server. -export type CheckSlugAvailable = (slug: string) => boolean | Promise; - -// One schema for the whole form. The slug field carries the async uniqueness -// refine; its format checks abort on failure so a malformed slug never reaches -// the server. Consumers that validate the slug on its own (the URL field, on -// blur) derive it with `createSpaceFormSchema(...).shape.slug`. -export function createSpaceFormSchema(checkSlugAvailable: CheckSlugAvailable) { +// One schema for the whole form. The slug is a client-only vanity field (the +// server assigns an opaque id and has no slug concept), so it's only format- +// validated — no uniqueness check. Consumers that validate the slug on its own +// (the URL field, on blur) derive it with `createSpaceFormSchema().shape.slug`. +export function createSpaceFormSchema() { return z.object({ name: z. string(). @@ -51,8 +45,7 @@ export function createSpaceFormSchema(checkSlugAvailable: CheckSlugAvailable) { trim(). min(1, {error: SpaceValidationError.UrlRequired, abort: true}). max(SPACE_SLUG_MAX_LENGTH, {error: SpaceValidationError.UrlTooLong, abort: true}). - regex(SLUG_PATTERN, {error: SpaceValidationError.UrlInvalid, abort: true}). - refine(async (slug) => checkSlugAvailable(slug), {error: SpaceValidationError.UrlTaken}), + regex(SLUG_PATTERN, {error: SpaceValidationError.UrlInvalid, abort: true}), visibility: z.enum(['public', 'private']), // Required string (may be empty) to match the always-present form field; From 02323f4f7388f9365ad7c4bcdcb682e2786674e6 Mon Sep 17 00:00:00 2001 From: Caleb Roseland Date: Mon, 27 Jul 2026 17:09:22 -0500 Subject: [PATCH 03/68] chore(i18n): re-extract en.json after spaces-API wiring Adds the docs.editor.* ids from the stub editor and drops the removed url.taken and page-placeholder ids plus the stale favorites ids. --- webapp/i18n/en.json | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json index 1ab23b7..9c97e02 100644 --- a/webapp/i18n/en.json +++ b/webapp/i18n/en.json @@ -10,7 +10,6 @@ "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.", @@ -22,6 +21,10 @@ "docs.createSpace.title": "Create a new space", "docs.createSpace.urlAriaLabel": "Space URL", "docs.createSpace.visibilityLabel": "Space visibility", + "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", @@ -53,12 +56,10 @@ "docs.leaveSpace.confirm": "Yes, leave space", "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.page": "Page {pageId}", "docs.main.spaceOverview": "Space overview", "docs.sidebar.add.browse": "Browse spaces", "docs.sidebar.add.create": "Create a space", "docs.sidebar.add.menu": "Add or browse spaces", - "docs.sidebar.category.favorites": "Favorites", "docs.sidebar.category.spaces": "Spaces", "docs.sidebar.createSpace": "Create a space", "docs.sidebar.favorites.empty": "Drag favorite items here or click the star icon on any space", @@ -66,10 +67,8 @@ "docs.sidebar.nav.home": "Home", "docs.sidebar.search.placeholder": "Find docs", "docs.sidebar.space.copyLink": "Copy link", - "docs.sidebar.space.favorite": "Add to favorites", "docs.sidebar.space.leave": "Leave space", "docs.sidebar.space.menu": "Space options for {name}", - "docs.sidebar.space.unfavorite": "Remove from favorites", "docs.sidebar.team.createTeam": "Create a team", "docs.sidebar.team.invite": "Invite people", "docs.sidebar.team.invite.secondary": "Add or invite people to the team", From ba0848c5b889c7fa199927c328cd52f22d414815 Mon Sep 17 00:00:00 2001 From: Caleb Roseland Date: Mon, 27 Jul 2026 17:32:03 -0500 Subject: [PATCH 04/68] refactor(docs): drop the vestigial create-space URL field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The slug/URL field became dead weight once spaces route by opaque server id: it was collected and format-validated but never sent or used. Remove it from the create-space modal and the form/schema — slug is gone from CreateSpaceInput, the form values, and the Zod schema (with its Url* errors and slugify helper). The UrlInput form-control component and its test are kept for reuse. Re-extract en.json (drops the url.* messages). --- webapp/i18n/en.json | 4 -- .../create_space_modal.test.tsx | 2 +- .../create_space_modal/create_space_modal.tsx | 20 +-------- .../create_space_modal/validation_messages.ts | 11 +---- webapp/src/data/docs_data_source.ts | 6 +-- webapp/src/hooks/spaces.ts | 44 +++---------------- webapp/src/types/docs.ts | 6 +-- webapp/src/validation/space_schema.ts | 28 +----------- 8 files changed, 15 insertions(+), 106 deletions(-) diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json index 9c97e02..159e5e8 100644 --- a/webapp/i18n/en.json +++ b/webapp/i18n/en.json @@ -8,9 +8,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.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", @@ -19,7 +16,6 @@ "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.editor.header.draft": "Draft · {spaceId} / {pageId}", "docs.editor.header.published": "Published · {spaceId} / {pageId}", 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 c1d0359..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 @@ -19,7 +19,7 @@ function typeName(value: string) { describe('CreateSpaceModal', () => { // Stub the API create so the form path doesn't hit the network; the server - // assigns the opaque id, so the returned space's id is unrelated to the slug. + // assigns the opaque id. beforeEach(() => { jest.spyOn(docsDataSource, 'createSpace').mockImplementation( async (_teamId, input) => makeSpace('new-space-id', input.title.trim(), 'team1'), 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..422a6d0 100644 --- a/webapp/src/components/create_space_modal/create_space_modal.tsx +++ b/webapp/src/components/create_space_modal/create_space_modal.tsx @@ -14,7 +14,6 @@ import type {SelectorOption} from 'components/form-controls/public_private_selec 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 GenericModal from 'components/generic_modal/generic_modal'; import type {Space, SpaceVisibility} from 'types/docs'; @@ -32,7 +31,7 @@ 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(); @@ -106,23 +105,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 888fa92..cda84c8 100644 --- a/webapp/src/components/create_space_modal/validation_messages.ts +++ b/webapp/src/components/create_space_modal/validation_messages.ts @@ -3,16 +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'}, descriptionTooLong: {id: 'docs.createSpace.error.description.tooLong', defaultMessage: 'Description must be {max} characters or fewer'}, }); @@ -25,12 +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.DescriptionTooLong: return formatMessage(messages.descriptionTooLong, {max: SPACE_DESCRIPTION_MAX_LENGTH}); default: diff --git a/webapp/src/data/docs_data_source.ts b/webapp/src/data/docs_data_source.ts index a1e9329..c83c7d1 100644 --- a/webapp/src/data/docs_data_source.ts +++ b/webapp/src/data/docs_data_source.ts @@ -19,9 +19,9 @@ export interface DocsDataSource { getSpace(spaceId: string): Promise; // Creates a space in the team and returns it (with its server-assigned id - // and team_id). Only server-supported fields are sent; client-only fields - // (slug, visibility) are dropped by the API source until the server models - // them (see PR #10's view_access). + // and team_id). Only server-supported fields are sent; the client-only + // visibility is dropped by the API source until the server models it (see + // PR #10's view_access). createSpace(teamId: string, input: CreateSpaceInput): Promise; // Removes a member from a space. Leaving a space is removing yourself; the diff --git a/webapp/src/hooks/spaces.ts b/webapp/src/hooks/spaces.ts index d123ea3..1b7c2f8 100644 --- a/webapp/src/hooks/spaces.ts +++ b/webapp/src/hooks/spaces.ts @@ -4,18 +4,14 @@ import {useForm} from '@tanstack/react-form'; import {getSpaceViews, recordSpaceView} from 'data/recent_spaces'; import {useAppDispatch, useAppSelector} from 'hooks/redux'; -import {useTeamContext} from 'hooks/team'; -import {useCallback, useEffect, useMemo, useRef} from 'react'; -import {DOCS_KEYWORD} from 'routing/paths'; -import {createSpaceFormSchema, slugify} from 'validation/space_schema'; +import {useCallback, useEffect, useMemo} from 'react'; +import {createSpaceFormSchema} from 'validation/space_schema'; import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; import {createSpace} from 'store/actions'; import {getAllSpaces, getSpace, getSpacesForCurrentTeam} from 'store/selectors'; -import type {UrlInputHandle} from 'components/form-controls/url_input'; - import type {Space, SpaceSummary, SpaceVisibility} from 'types/docs'; export function useSpaces(): Space[] { @@ -60,14 +56,12 @@ export function useRecordSpaceView(spaceId?: string): void { type CreateSpaceValues = { name: string; - slug: string; visibility: SpaceVisibility; description: string; }; const INITIAL_VALUES: CreateSpaceValues = { name: '', - slug: '', visibility: 'public', description: '', }; @@ -77,21 +71,11 @@ type CreateSpaceOptions = { }; // Owns the create-space form via TanStack Form. The Zod schema drives validation -// through TanStack's validators (its issues distribute to fields by path). The -// slug is a client-only vanity field for now — the server assigns an opaque id -// and has no slug concept, so there's no uniqueness check; only format is -// validated (the slug's on-blur schema). +// through TanStack's validators (its issues distribute to fields by path). export function useCreateSpace({onCreated}: CreateSpaceOptions = {}) { - const {name: teamName} = useTeamContext(); const dispatch = useAppDispatch(); const formSchema = useMemo(() => createSpaceFormSchema(), []); - const slugSchema = useMemo(() => formSchema.shape.slug, [formSchema]); - - // Stop deriving the slug from the name once the user edits the slug directly. - const slugEdited = useRef(false); - - const urlInputRef = useRef(null); const form = useForm({ defaultValues: INITIAL_VALUES, @@ -99,7 +83,6 @@ export function useCreateSpace({onCreated}: CreateSpaceOptions = {}) { onSubmit: async ({value}) => { const space = await dispatch(createSpace({ title: value.name.trim(), - slug: value.slug.trim(), visibility: value.visibility, description: value.description.trim() || undefined, })); @@ -109,26 +92,9 @@ export function useCreateSpace({onCreated}: CreateSpaceOptions = {}) { const changeName = useCallback((name: string) => { form.setFieldValue('name', name); - if (!slugEdited.current) { - form.setFieldValue('slug', slugify(name)); - } - }, [form]); - - const changeSlug = useCallback((slug: string) => { - slugEdited.current = true; - form.setFieldValue('slug', slug); - }, [form]); - - // Submits, then surfaces a rejected slug (e.g. a bad format) by focusing the - // URL input — otherwise the error lands on the field's read-only preview. - const submit = useCallback(async () => { - await form.handleSubmit(); - if ((form.getFieldMeta('slug')?.errors.length ?? 0) > 0) { - urlInputRef.current?.focus(); - } }, [form]); - const baseUrl = useMemo(() => `${window.location.origin}/${teamName}/${DOCS_KEYWORD}`, [teamName]); + const submit = useCallback(() => form.handleSubmit(), [form]); - return {form, slugSchema, baseUrl, changeName, changeSlug, submit, urlInputRef}; + return {form, changeName, submit}; } diff --git a/webapp/src/types/docs.ts b/webapp/src/types/docs.ts index a2e0ac8..8ca45e4 100644 --- a/webapp/src/types/docs.ts +++ b/webapp/src/types/docs.ts @@ -26,12 +26,10 @@ export type Space = { }; // The fields the create-space form collects. The data source turns this into a -// Space (assigning the opaque id, etc.). +// Space (assigning the opaque id, etc.). visibility is client-only for now (maps +// to the server's view_access later). export type CreateSpaceInput = { title: string; - - // Vanity URL segment, derived from the title but independently editable. - slug: string; visibility: SpaceVisibility; description?: string; icon?: string; diff --git a/webapp/src/validation/space_schema.ts b/webapp/src/validation/space_schema.ts index 4972577..da03ae2 100644 --- a/webapp/src/validation/space_schema.ts +++ b/webapp/src/validation/space_schema.ts @@ -10,29 +10,19 @@ import {z} from 'zod'; // state so TanStack distributes each issue to its field by path. export const SPACE_NAME_MAX_LENGTH = 64; -export const SPACE_SLUG_MAX_LENGTH = 64; export const SPACE_DESCRIPTION_MAX_LENGTH = 1024; -// Lowercase alphanumerics and single dashes, not leading/trailing — the same -// shape Mattermost uses for channel URL names. -const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; - // Stable ids for each validation failure; the UI maps these to messages. export const SpaceValidationError = { NameRequired: 'name.required', NameTooLong: 'name.tooLong', - UrlRequired: 'url.required', - UrlTooLong: 'url.tooLong', - UrlInvalid: 'url.invalid', DescriptionTooLong: 'description.tooLong', } as const; export type SpaceValidationError = typeof SpaceValidationError[keyof typeof SpaceValidationError]; -// One schema for the whole form. The slug is a client-only vanity field (the -// server assigns an opaque id and has no slug concept), so it's only format- -// validated — no uniqueness check. Consumers that validate the slug on its own -// (the URL field, on blur) derive it with `createSpaceFormSchema().shape.slug`. +// One schema for the whole form. Field keys match the form state so TanStack +// distributes each issue to its field by path. export function createSpaceFormSchema() { return z.object({ name: z. @@ -40,12 +30,6 @@ export function createSpaceFormSchema() { trim(). min(1, {error: SpaceValidationError.NameRequired}). max(SPACE_NAME_MAX_LENGTH, {error: SpaceValidationError.NameTooLong}), - slug: z. - string(). - trim(). - min(1, {error: SpaceValidationError.UrlRequired, abort: true}). - max(SPACE_SLUG_MAX_LENGTH, {error: SpaceValidationError.UrlTooLong, abort: true}). - regex(SLUG_PATTERN, {error: SpaceValidationError.UrlInvalid, abort: true}), visibility: z.enum(['public', 'private']), // Required string (may be empty) to match the always-present form field; @@ -58,11 +42,3 @@ export function createSpaceFormSchema() { } export type SpaceFormValues = z.infer>; - -export function slugify(value: string): string { - return value. - toLowerCase(). - trim(). - replace(/[^a-z0-9]+/g, '-'). - replace(/^-+|-+$/g, ''); -} From 0c349ae61920e7024ecc4bc3a7558ae7527be4ce Mon Sep 17 00:00:00 2001 From: Caleb Roseland Date: Mon, 27 Jul 2026 17:49:52 -0500 Subject: [PATCH 05/68] feat(docs): generic space-icon fallback and switcher recent polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Custom emoji/icon picking is deferred, so a space without a custom icon renders a generic compass glyph (utils/space_icon: , using the product's file-text-outline) everywhere a space icon shows — sidebar, home cards, main content, switcher, and the create-space name field — so a freshly created space never appears blank. A space with a custom emoji renders that character. Switcher: rename the "Recent docs" group to "Recent", and dedupe — a space already listed under Recent is no longer repeated under "Your spaces". --- webapp/i18n/en.json | 2 +- .../create_space_modal/create_space_modal.tsx | 5 ++-- webapp/src/components/docs_home/docs_home.tsx | 6 ++++- .../docs_root/docs_main_content.tsx | 11 ++++++++- .../docs_switcher/docs_switcher.tsx | 16 ++++++++++--- .../components/spaces_sidebar/space_item.tsx | 6 ++++- webapp/src/utils/space_icon.tsx | 24 +++++++++++++++++++ 7 files changed, 60 insertions(+), 10 deletions(-) create mode 100644 webapp/src/utils/space_icon.tsx diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json index 159e5e8..2fa3f36 100644 --- a/webapp/i18n/en.json +++ b/webapp/i18n/en.json @@ -73,7 +73,7 @@ "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.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", 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 422a6d0..bf9d174 100644 --- a/webapp/src/components/create_space_modal/create_space_modal.tsx +++ b/webapp/src/components/create_space_modal/create_space_modal.tsx @@ -4,6 +4,7 @@ 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'; @@ -26,8 +27,6 @@ type Props = { onCreated?: (space: Space) => void; }; -const DEFAULT_SPACE_EMOJI = '📄'; - const CreateSpaceModal = ({onClose, onCreated}: Props) => { const {formatMessage} = useIntl(); @@ -97,7 +96,7 @@ const CreateSpaceModal = ({onClose, onCreated}: Props) => { label={formatMessage({id: 'docs.createSpace.nameLabel', defaultMessage: 'Space name'})} value={field.state.value} onChange={changeName} - leading={} + leading={} error={firstSpaceValidationError(field.state.meta.errors, formatMessage)} maxLength={SPACE_NAME_MAX_LENGTH} autoFocus={true} diff --git a/webapp/src/components/docs_home/docs_home.tsx b/webapp/src/components/docs_home/docs_home.tsx index 88ac443..8473921 100644 --- a/webapp/src/components/docs_home/docs_home.tsx +++ b/webapp/src/components/docs_home/docs_home.tsx @@ -6,6 +6,7 @@ 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'; @@ -183,7 +184,10 @@ const SpaceCard = ({summary, onOpen}: {summary: SpaceSummary; onOpen: (id: strin className={styles.spaceCardEmoji} aria-hidden='true' > - {space.icon} + {space.title} diff --git a/webapp/src/components/docs_root/docs_main_content.tsx b/webapp/src/components/docs_root/docs_main_content.tsx index 497074b..0d76828 100644 --- a/webapp/src/components/docs_root/docs_main_content.tsx +++ b/webapp/src/components/docs_root/docs_main_content.tsx @@ -4,6 +4,7 @@ import {useSpace} from 'hooks/spaces'; import React from 'react'; import {FormattedMessage} from 'react-intl'; +import {SpaceIcon} from 'utils/space_icon'; import DocsHome from 'components/docs_home/docs_home'; import PageEditor from 'components/page_editor/page_editor'; @@ -48,7 +49,15 @@ const DocsMainContent = ({spaceId, pageId, isDraft, onCreateSpace, onBrowseSpace

{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx -- decorative emoji, not translatable */} - {space.icon ? `${space.icon} ` : null} + + + {space.title}

diff --git a/webapp/src/components/docs_switcher/docs_switcher.tsx b/webapp/src/components/docs_switcher/docs_switcher.tsx index adc17d7..65b6263 100644 --- a/webapp/src/components/docs_switcher/docs_switcher.tsx +++ b/webapp/src/components/docs_switcher/docs_switcher.tsx @@ -9,6 +9,7 @@ import {useAllSpaces} from 'hooks/spaces'; import {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'; @@ -75,16 +76,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]); @@ -163,7 +168,12 @@ const DocsSwitcher = ({onClose}: Props) => { > {entry.kind === 'space' ? ( <> - {entry.space.icon} + + + {entry.space.title} ) : ( diff --git a/webapp/src/components/spaces_sidebar/space_item.tsx b/webapp/src/components/spaces_sidebar/space_item.tsx index f46c67a..8cb522e 100644 --- a/webapp/src/components/spaces_sidebar/space_item.tsx +++ b/webapp/src/components/spaces_sidebar/space_item.tsx @@ -4,6 +4,7 @@ import {DropIndicator} from '@atlaskit/pragmatic-drag-and-drop-react-drop-indicator/box'; import classNames from 'classnames'; import React, {useState} from 'react'; +import {SpaceIcon} from 'utils/space_icon'; import type {Space} from 'types/docs'; @@ -32,7 +33,10 @@ const SpaceItem = ({space, category, active, dndEnabled, onSelect}: Props) => { className={styles.emoji} aria-hidden={true} > - {space.icon} + ); diff --git a/webapp/src/utils/space_icon.tsx b/webapp/src/utils/space_icon.tsx new file mode 100644 index 0000000..de0552e --- /dev/null +++ b/webapp/src/utils/space_icon.tsx @@ -0,0 +1,24 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import FileTextOutlineIcon from '@mattermost/compass-icons/components/file-text-outline'; + +import type {Space} from 'types/docs'; + +// A space's icon: its custom emoji when set, otherwise a generic compass glyph +// (the product's file-text-outline). Custom emoji/icon picking is deferred, so +// most spaces render the fallback today. `space` is optional so the create +// form can render the standard icon before a space exists. +type Props = { + space?: Pick; + size: number; +}; + +export function SpaceIcon({space, size}: Props): JSX.Element { + if (space?.icon) { + return <>{space.icon}; + } + return ; +} From 2dd9e2e30a42b2c0933ce6c5cea605aa851d4307 Mon Sep 17 00:00:00 2001 From: Caleb Roseland Date: Mon, 27 Jul 2026 18:07:14 -0500 Subject: [PATCH 06/68] revert(docs-editor): remove non-working stub page editor Reverts 13b2db3 (soft-merge of PR #7's stub editor): the mounted editor wasn't working. Drops the page_editor component, the editor slice from webapp_globals, and the pageId->PageEditor routing in docs_main_content (back to the space/page placeholder). Later work on the touched files (SpaceIcon, useRecordSpaceView) is preserved. Re-extract en.json. --- webapp/i18n/en.json | 5 +- .../docs_root/docs_main_content.tsx | 33 ++-- webapp/src/components/docs_root/docs_root.tsx | 3 +- .../page_editor/page_editor.module.scss | 42 ---- .../components/page_editor/page_editor.tsx | 63 ------ webapp/src/webapp_globals.ts | 182 +----------------- 6 files changed, 24 insertions(+), 304 deletions(-) delete mode 100644 webapp/src/components/page_editor/page_editor.module.scss delete mode 100644 webapp/src/components/page_editor/page_editor.tsx diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json index 2fa3f36..0d95eb9 100644 --- a/webapp/i18n/en.json +++ b/webapp/i18n/en.json @@ -17,10 +17,6 @@ "docs.createSpace.public.title": "Public Space", "docs.createSpace.title": "Create a new space", "docs.createSpace.visibilityLabel": "Space visibility", - "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", @@ -52,6 +48,7 @@ "docs.leaveSpace.confirm": "Yes, leave space", "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.page": "Page {pageId}", "docs.main.spaceOverview": "Space overview", "docs.sidebar.add.browse": "Browse spaces", "docs.sidebar.add.create": "Create a space", diff --git a/webapp/src/components/docs_root/docs_main_content.tsx b/webapp/src/components/docs_root/docs_main_content.tsx index 0d76828..2778025 100644 --- a/webapp/src/components/docs_root/docs_main_content.tsx +++ b/webapp/src/components/docs_root/docs_main_content.tsx @@ -7,22 +7,19 @@ import {FormattedMessage} from 'react-intl'; import {SpaceIcon} from 'utils/space_icon'; import DocsHome from 'components/docs_home/docs_home'; -import PageEditor from 'components/page_editor/page_editor'; import styles from './docs_main_content.module.scss'; type Props = { spaceId?: string; pageId?: string; - isDraft?: boolean; onCreateSpace: () => 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) => { +// that reflects the routed space/page to keep the URL observable. +const DocsMainContent = ({spaceId, pageId, onCreateSpace, onBrowseSpaces}: Props) => { const space = useSpace(spaceId); if (!space) { @@ -34,16 +31,6 @@ const DocsMainContent = ({spaceId, pageId, isDraft, onCreateSpace, onBrowseSpace ); } - if (pageId) { - return ( - - ); - } - return (

@@ -61,10 +48,18 @@ const DocsMainContent = ({spaceId, pageId, isDraft, onCreateSpace, onBrowseSpace {space.title}

- + {pageId ? ( + + ) : ( + + )}

diff --git a/webapp/src/components/docs_root/docs_root.tsx b/webapp/src/components/docs_root/docs_root.tsx index d1922ff..3e1f074 100644 --- a/webapp/src/components/docs_root/docs_root.tsx +++ b/webapp/src/components/docs_root/docs_root.tsx @@ -17,7 +17,7 @@ import styles from './docs_root.module.scss'; const DocsRoot = () => { useBootstrapDocs(); - const {spaceId, pageId, isDraft} = useDocsNavigation(); + const {spaceId, pageId} = useDocsNavigation(); useRecordSpaceView(spaceId); @@ -47,7 +47,6 @@ const DocsRoot = () => { diff --git a/webapp/src/components/page_editor/page_editor.module.scss b/webapp/src/components/page_editor/page_editor.module.scss deleted file mode 100644 index 7818986..0000000 --- a/webapp/src/components/page_editor/page_editor.module.scss +++ /dev/null @@ -1,42 +0,0 @@ -.root { - display: flex; - flex-direction: column; - height: 100%; - padding: 24px 32px; - gap: 16px; - overflow: auto; -} - -.header { - display: flex; - align-items: center; - justify-content: space-between; - color: var(--center-channel-color); - font-size: 12px; - text-transform: uppercase; - letter-spacing: 0.04em; -} - -.stub { - display: flex; - flex: 1; - align-items: center; - justify-content: center; - padding: 32px; - border: 1px dashed rgba(var(--center-channel-color-rgb), 0.24); - border-radius: 8px; - color: var(--center-channel-color); - font-size: 14px; - text-align: center; -} - -.empty { - display: flex; - flex: 1; - align-items: center; - justify-content: center; - padding: 48px 24px; - color: var(--center-channel-color); - font-size: 14px; - text-align: center; -} diff --git a/webapp/src/components/page_editor/page_editor.tsx b/webapp/src/components/page_editor/page_editor.tsx deleted file mode 100644 index eeb24e7..0000000 --- a/webapp/src/components/page_editor/page_editor.tsx +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; -import {FormattedMessage} from 'react-intl'; -import {hostCanUseEditor, hostGetEditor} from 'webapp_globals'; - -import styles from './page_editor.module.scss'; - -type Props = { - spaceId: string; - pageId: string; - isDraft: boolean; -}; - -// Placeholder mount for the WYSIWYG editor. This ticket only wires the -// component to the page route and proves the host slice resolves -const PageEditor = ({spaceId, pageId, isDraft}: Props) => { - if (!hostCanUseEditor()) { - return ( -
- -
- ); - } - - const editor = hostGetEditor(); - const providerCount = editor?.providers ? Object.keys(editor.providers).length : 0; - - return ( -
-
- - {isDraft ? ( - - ) : ( - - )} - -
-
- -
-
- ); -}; - -export default PageEditor; diff --git a/webapp/src/webapp_globals.ts b/webapp/src/webapp_globals.ts index af00657..c4970fb 100644 --- a/webapp/src/webapp_globals.ts +++ b/webapp/src/webapp_globals.ts @@ -2,28 +2,20 @@ // See LICENSE.txt for license information. import type {History} from 'history'; -import type {ComponentType, ElementType, ForwardRefExoticComponent, KeyboardEvent, KeyboardEventHandler, ReactNode, ReactNodeArray, RefAttributes, RefObject} from 'react'; -import type {MessageDescriptor} from 'react-intl'; +import type {ComponentType, ReactNode} from 'react'; import type {Action} from 'redux'; -import type {Agent} from '@mattermost/types/agents'; -import type {Channel} from '@mattermost/types/channels'; -import type {Group} from '@mattermost/types/groups'; -import type {UserProfile} from '@mattermost/types/users'; - // Hand-typed view of the API the host web app attaches to `window` for plugins // (core's plugins/export.ts). The host guarantees these at runtime; anything // missing degrades to a no-op. // -// TRANSITION-MIGRATION: the modal and editor contracts below now live in core -// as @mattermost/shared/types/global (WindowShared, PublishedModalUtils, -// PublishedEditorUtils, PublishedSuggestionProviderConstructors, etc.). Our -// pinned @mattermost/shared release doesn't export them yet, so they're -// mirrored here. Replace this slice with those imports once the dependency is -// bumped to a version that ships types/global — which also lets openModalById -// be typed per-modal, and the editor components import from their canonical -// source instead of this local mirror. browserHistory has not migrated into -// WindowShared yet, so it stays here too. +// TRANSITION-MIGRATION: the modal contract below now lives in core as +// @mattermost/shared/types/global (WindowShared, PublishedModalUtils, +// PublishedModalId, PublishedModalProps). Our pinned @mattermost/shared release +// doesn't export it yet, so it's mirrored here. Replace this slice with those +// imports once the dependency is bumped to a version that ships types/global — +// which also lets openModalById/dialogProps be typed per-modal instead of loose. +// browserHistory has not migrated into WindowShared yet, so it stays here too. type PublishedModalId = 'user_settings' | 'invitation' | 'team_settings' | 'team_members' | 'leave_team'; @@ -38,153 +30,9 @@ type PublishedModalUtils = { canOpenModalId: (modalId: string) => boolean; }; -export type ActionResult = { - data?: Data; - error?: Error; -}; - -type Loading = {loading: boolean}; - -type ComponentOrComponents = { - component: ElementType; -} | { - components: ElementType[]; -}; - -export type ProviderResultsGroup = { - key: string; - label?: MessageDescriptor; - terms: string[]; - items: Array; -} & ComponentOrComponents; - -export type ProviderResults = - | {matchedPretext: string; groups: Array>} - | ({matchedPretext: string; terms: string[]; items: Array} & ComponentOrComponents); - -type SuggestionResultsGroup = { - key: string; - label?: MessageDescriptor; - terms: string[]; - items: Array; - components: ElementType[]; -}; - -export type SuggestionResults = - | {matchedPretext: string; groups: Array>} - | {matchedPretext: string; terms: string[]; items: Array; components: ElementType[]}; - -export type WysiwygEditorProps = { - value: string; - onChange: (markdown: string) => void; - onSubmit: () => void; - onFocus?: () => void; - onBlur?: () => void; - placeholder?: string; - channelId: string; - rootId?: string; - disabled?: boolean; - id?: string; - useCtrlSend?: boolean; - sendCodeBlockOnCtrlEnter?: boolean; - onKeyDown?: (e: KeyboardEvent) => void; -}; - -export type SuggestionListProps = { - inputRef?: RefObject; - open: boolean; - position?: 'top' | 'bottom'; - renderNoResults?: boolean; - onCompleteWord: (term: string, matchedPretext: string, e?: KeyboardEventHandler) => boolean; - preventClose?: () => void; - onItemHover: (term: string) => void; - pretext: string; - cleared: boolean; - results: SuggestionResults; - selection: string; - suggestionBoxAlgn?: { - lineHeight?: number; - pixelsToMoveX?: number; - pixelsToMoveY?: number; - }; -}; - -export type PublishedMarkdownMode = 'bold' | 'italic' | 'link' | 'strike' | 'code' | 'heading' | 'quote' | 'ul' | 'ol'; - -export type FormattingBarProps = { - applyFormatting: (mode: PublishedMarkdownMode) => void; - disableControls: boolean; - location: string; - additionalControls?: ReactNodeArray; - aiActionsMenu?: ReactNode; - - // Returns a Tiptap Editor. Left as `unknown` so consumers don't have to - // depend on `@tiptap/react` transitively; cast at the call site. - getEditor?: () => unknown; -}; - -export type PublishedWysiwygEditorHandle = { - insertText: (text: string) => void; - focus: () => void; - blur: () => void; - getInputBox: () => HTMLElement | null; -}; - -export type PublishedFormattingBarHandle = { - openLinkPopover: () => void; -}; - -export type SuggestionProviderInstance = { - triggerCharacter?: string; - handlePretextChanged: (pretext: string, resultsCallback: (results: ProviderResults) => void) => boolean | void; -}; - -export type AtMentionProviderOptions = { - currentUserId: string; - channelId: string; - autocompleteUsersInChannel: (prefix: string) => Promise; - useChannelMentions: boolean; - autocompleteGroups: Group[] | null; - searchAssociatedGroupsForReference: (prefix: string) => Promise>; - priorityProfiles: UserProfile[] | undefined; - defaultAgent?: Agent; -}; - -export type CommandProviderOptions = { - teamId: string; - channelId: string; - rootId?: string; -}; - -export type ChannelMentionProviderArgs = [ - channelSearchFunc: ( - term: string, - success: (channels: Channel[]) => void, - error: () => void, - ) => Promise, - delayChannelAutocomplete: boolean, -]; - -export type PublishedSuggestionProviderConstructors = { - AtMention: new (options: AtMentionProviderOptions) => SuggestionProviderInstance; - ChannelMention: new (...args: ChannelMentionProviderArgs) => SuggestionProviderInstance; - Command: new (options: CommandProviderOptions) => SuggestionProviderInstance; - Emoticon: new () => SuggestionProviderInstance; -}; - -export type PublishedSuggestionProviderId = keyof PublishedSuggestionProviderConstructors; - -export type PublishedEditorUtils = { - WysiwygEditor: ForwardRefExoticComponent>; - SuggestionList: ComponentType; - FormattingBar: ForwardRefExoticComponent>; - providers: PublishedSuggestionProviderConstructors; -}; - type WebappUtils = { browserHistory?: History; modals?: Partial; - editor?: Partial; }; const webappUtils = (): WebappUtils => (window as unknown as {WebappUtils?: WebappUtils}).WebappUtils ?? {}; @@ -230,17 +78,3 @@ export function hostCanOpenModal(modalId: string): boolean { export function hostOpenModalAction(modalId: PublishedModalId, dialogProps?: Record): Action | undefined { return webappUtils().modals?.openModalById?.(modalId, dialogProps); } - -// Whether the running host publishes the WYSIWYG editor surface. Older hosts -// (predating MM-69774) don't attach `editor` — callers should fall back to a -// read-only render or an "update your server" empty state. -export function hostCanUseEditor(): boolean { - return Boolean(webappUtils().editor?.WysiwygEditor); -} - -// The published editor components + suggestion provider constructors, or -// undefined when the host doesn't expose them. Fields are individually optional -// so a newer host can add pieces without breaking older plugin bundles. -export function hostGetEditor(): Partial | undefined { - return webappUtils().editor; -} From 69edb2df1f5680b755c3c7d4c6342e584622993b Mon Sep 17 00:00:00 2001 From: Caleb Roseland Date: Mon, 27 Jul 2026 18:21:17 -0500 Subject: [PATCH 07/68] feat(docs): build the Space Home main content UI Implements the space main-content view (Figma Space Home) as a new space_view component set, replacing the routed-space placeholder: - SpaceTitleBar: favorite, space icon + title + menu, member count, space details, Share - PageBar: page-tree toggle + "Pages", "Updated " (host Timestamp), comments, Edit, overflow, expand - PageHero: rounded banner with the space icon/title, description (with a placeholder when empty), and a pages/members/views stats row - Page body: a placeholder until the editor is mounted Uses Mattermost theme tokens and compass icons. Data-less bits are honest placeholders for now: the stat counts and title-bar member count render an em dash (await the pages/members/views APIs), member avatars are omitted, and the controls are visual scaffolding wired in later passes. --- webapp/i18n/en.json | 18 ++- .../docs_root/docs_main_content.module.scss | 34 ------ .../docs_root/docs_main_content.tsx | 45 +------ .../space_view/page_bar.module.scss | 103 ++++++++++++++++ webapp/src/components/space_view/page_bar.tsx | 115 ++++++++++++++++++ .../space_view/page_hero.module.scss | 81 ++++++++++++ .../src/components/space_view/page_hero.tsx | 70 +++++++++++ .../space_view/space_title_bar.module.scss | 98 +++++++++++++++ .../components/space_view/space_title_bar.tsx | 89 ++++++++++++++ .../space_view/space_view.module.scss | 27 ++++ .../src/components/space_view/space_view.tsx | 35 ++++++ 11 files changed, 639 insertions(+), 76 deletions(-) delete mode 100644 webapp/src/components/docs_root/docs_main_content.module.scss create mode 100644 webapp/src/components/space_view/page_bar.module.scss create mode 100644 webapp/src/components/space_view/page_bar.tsx create mode 100644 webapp/src/components/space_view/page_hero.module.scss create mode 100644 webapp/src/components/space_view/page_hero.tsx create mode 100644 webapp/src/components/space_view/space_title_bar.module.scss create mode 100644 webapp/src/components/space_view/space_title_bar.tsx create mode 100644 webapp/src/components/space_view/space_view.module.scss create mode 100644 webapp/src/components/space_view/space_view.tsx diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json index 0d95eb9..417a6a4 100644 --- a/webapp/i18n/en.json +++ b/webapp/i18n/en.json @@ -48,8 +48,6 @@ "docs.leaveSpace.confirm": "Yes, leave space", "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.page": "Page {pageId}", - "docs.main.spaceOverview": "Space overview", "docs.sidebar.add.browse": "Browse spaces", "docs.sidebar.add.create": "Create a space", "docs.sidebar.add.menu": "Add or browse spaces", @@ -70,6 +68,22 @@ "docs.sidebar.team.members": "Manage members", "docs.sidebar.team.menu": "Manage {teamName}", "docs.sidebar.team.settings": "Team settings", + "docs.space.bodyPlaceholder": "Page content will appear here once the editor is available.", + "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.expand": "Expand", + "docs.space.favorite": "Favorite this space", + "docs.space.menu": "Space options", + "docs.space.more": "More actions", + "docs.space.pages": "Pages", + "docs.space.pages.toggle": "Toggle page tree", + "docs.space.share": "Share", + "docs.space.stat.members": "Members", + "docs.space.stat.pages": "Pages", + "docs.space.stat.views": "Views", + "docs.space.updated": "Updated {relative}", "docs.switcher.group.recent": "Recent", "docs.switcher.group.results": "Spaces and pages", "docs.switcher.group.spaces": "Your spaces", diff --git a/webapp/src/components/docs_root/docs_main_content.module.scss b/webapp/src/components/docs_root/docs_main_content.module.scss deleted file mode 100644 index d75b27e..0000000 --- a/webapp/src/components/docs_root/docs_main_content.module.scss +++ /dev/null @@ -1,34 +0,0 @@ -.root { - display: flex; - flex: 1 1 0; - flex-direction: column; - min-width: 0; - height: 100%; - background: var(--center-channel-bg); -} - -.empty { - display: flex; - flex: 1 1 0; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 4px; - text-align: center; -} - -.title { - margin: 0; - color: var(--center-channel-color); - font-family: 'Metropolis', sans-serif; - font-size: 20px; - font-weight: 600; - line-height: 28px; -} - -.subtitle { - margin: 0; - color: rgba(var(--center-channel-color-rgb), 0.72); - font-size: 14px; - line-height: 20px; -} diff --git a/webapp/src/components/docs_root/docs_main_content.tsx b/webapp/src/components/docs_root/docs_main_content.tsx index 2778025..0b633f3 100644 --- a/webapp/src/components/docs_root/docs_main_content.tsx +++ b/webapp/src/components/docs_root/docs_main_content.tsx @@ -3,12 +3,9 @@ import {useSpace} from 'hooks/spaces'; import React from 'react'; -import {FormattedMessage} from 'react-intl'; -import {SpaceIcon} from 'utils/space_icon'; import DocsHome from 'components/docs_home/docs_home'; - -import styles from './docs_main_content.module.scss'; +import SpaceView from 'components/space_view/space_view'; type Props = { spaceId?: string; @@ -17,9 +14,9 @@ type Props = { onBrowseSpaces: () => void; }; -// The space view is built later; for now a routed space renders a placeholder -// that reflects the routed space/page to keep the URL observable. -const DocsMainContent = ({spaceId, pageId, onCreateSpace, onBrowseSpaces}: Props) => { +// No routed space → the product Home; a routed space → its main content view +// (the page editor within it is mounted later). +const DocsMainContent = ({spaceId, onCreateSpace, onBrowseSpaces}: Props) => { const space = useSpace(spaceId); if (!space) { @@ -31,39 +28,7 @@ const DocsMainContent = ({spaceId, pageId, onCreateSpace, onBrowseSpaces}: Props ); } - return ( -
-
-

- {/* eslint-disable-next-line formatjs/no-literal-string-in-jsx -- decorative emoji, not translatable */} - - - - {space.title} -

-

- {pageId ? ( - - ) : ( - - )} -

-
-
- ); + return ; }; export default DocsMainContent; diff --git a/webapp/src/components/space_view/page_bar.module.scss b/webapp/src/components/space_view/page_bar.module.scss new file mode 100644 index 0000000..bff1bec --- /dev/null +++ b/webapp/src/components/space_view/page_bar.module.scss @@ -0,0 +1,103 @@ +.bar { + display: flex; + align-items: center; + gap: 8px; + height: 40px; + padding: 0 12px 0 8px; + 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; +} + +.right { + display: flex; + flex-shrink: 0; + align-items: center; + gap: 2px; +} + +.pagesToggle { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 8px; + border: none; + border-radius: 4px; + background: transparent; + color: rgba(var(--center-channel-color-rgb), 0.72); + cursor: pointer; + + &:hover { + background: rgba(var(--center-channel-color-rgb), 0.08); + } +} + +.pagesLabel { + font-size: 14px; + font-weight: 600; + line-height: 20px; +} + +.updated { + margin-right: 8px; + color: rgba(var(--center-channel-color-rgb), 0.56); + font-size: 12px; + white-space: nowrap; +} + +.iconButton { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + border: none; + border-radius: 4px; + background: transparent; + color: rgba(var(--center-channel-color-rgb), 0.64); + cursor: pointer; + + &:hover { + background: rgba(var(--center-channel-color-rgb), 0.08); + color: rgba(var(--center-channel-color-rgb), 0.72); + } +} + +.badge { + position: absolute; + top: 6px; + right: 6px; + width: 8px; + height: 8px; + border: 2px solid var(--center-channel-bg); + border-radius: 50%; + background: var(--button-bg); +} + +.edit { + display: flex; + align-items: center; + gap: 6px; + height: 28px; + margin: 0 4px; + padding: 0 12px; + border: 1px solid rgba(var(--center-channel-color-rgb), 0.16); + border-radius: 4px; + background: transparent; + color: var(--center-channel-color); + font-size: 12px; + font-weight: 600; + cursor: pointer; + + &:hover { + background: rgba(var(--center-channel-color-rgb), 0.08); + } +} diff --git a/webapp/src/components/space_view/page_bar.tsx b/webapp/src/components/space_view/page_bar.tsx new file mode 100644 index 0000000..f3080a5 --- /dev/null +++ b/webapp/src/components/space_view/page_bar.tsx @@ -0,0 +1,115 @@ +// 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 {Timestamp} from 'webapp_globals'; +import type {TimestampUnit} from 'webapp_globals'; + +import ArrowExpandIcon from '@mattermost/compass-icons/components/arrow-expand'; +import DotsHorizontalIcon from '@mattermost/compass-icons/components/dots-horizontal'; +import FormatListBulletedIcon from '@mattermost/compass-icons/components/format-list-bulleted'; +import MessageTextOutlineIcon from '@mattermost/compass-icons/components/message-text-outline'; +import PencilOutlineIcon from '@mattermost/compass-icons/components/pencil-outline'; + +import type {Space} from 'types/docs'; + +import styles from './page_bar.module.scss'; + +// Relative "Updated …" buckets for the host Timestamp. +const UPDATED_TIME_SPEC: TimestampUnit[] = [ + ['minute', -59], + ['hour', -48], + ['day', -30], + ['month', -12], + 'year', +]; + +// Controls (pages toggle, comments, edit, overflow, expand) are visual +// scaffolding wired in later passes. +const PageBar = ({space}: {space: Space}) => { + const {formatMessage} = useIntl(); + + const pagesLabel = formatMessage({id: 'docs.space.pages.toggle', defaultMessage: 'Toggle page tree'}); + const commentsLabel = formatMessage({id: 'docs.space.comments', defaultMessage: 'Comments'}); + const moreLabel = formatMessage({id: 'docs.space.more', defaultMessage: 'More actions'}); + const expandLabel = formatMessage({id: 'docs.space.expand', defaultMessage: 'Expand'}); + + // Timestamp's `style` is a narrow/short/long format variant, not a DOM style object. + /* eslint-disable react/style-prop-object */ + const updatedRelative = Timestamp ? ( + + ) : null; + /* eslint-enable react/style-prop-object */ + + return ( +
+
+ +
+ +
+ {updatedRelative && ( + + + + )} + + + + +
+
+ ); +}; + +export default PageBar; diff --git a/webapp/src/components/space_view/page_hero.module.scss b/webapp/src/components/space_view/page_hero.module.scss new file mode 100644 index 0000000..2209304 --- /dev/null +++ b/webapp/src/components/space_view/page_hero.module.scss @@ -0,0 +1,81 @@ +.hero { + display: flex; + flex-direction: column; + gap: 16px; + padding: 24px 28px; + border-radius: 12px; + background: rgba(var(--button-bg-rgb), 0.08); +} + +.heading { + display: flex; + align-items: center; + gap: 16px; +} + +.iconTile { + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + border-radius: 8px; + background: var(--center-channel-bg); + font-size: 28px; + line-height: 1; + box-shadow: 0 2px 3px rgba(0, 0, 0, 0.08); +} + +.title { + margin: 0; + color: var(--center-channel-color); + font-family: 'Metropolis', sans-serif; + font-size: 32px; + font-weight: 600; + line-height: 40px; +} + +.description { + margin: 0; + color: rgba(var(--center-channel-color-rgb), 0.72); + font-size: 16px; + line-height: 24px; +} + +.descriptionMuted { + color: rgba(var(--center-channel-color-rgb), 0.56); +} + +.stats { + display: flex; + align-items: center; + gap: 24px; +} + +.stat { + display: flex; + flex-direction: column; + gap: 2px; + padding-right: 24px; + border-right: 1px solid rgba(var(--center-channel-color-rgb), 0.12); +} + +.stat:last-of-type { + border-right: none; +} + +.statValue { + color: var(--center-channel-color); + font-size: 20px; + font-weight: 600; + line-height: 24px; +} + +.statLabel { + color: rgba(var(--center-channel-color-rgb), 0.64); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; +} diff --git a/webapp/src/components/space_view/page_hero.tsx b/webapp/src/components/space_view/page_hero.tsx new file mode 100644 index 0000000..1890993 --- /dev/null +++ b/webapp/src/components/space_view/page_hero.tsx @@ -0,0 +1,70 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import classNames from 'classnames'; +import React from 'react'; +import {useIntl} from 'react-intl'; +import {SpaceIcon} from 'utils/space_icon'; + +import type {Space} from 'types/docs'; + +import styles from './page_hero.module.scss'; + +type Stat = { + key: string; + value: number | undefined; + label: string; +}; + +// Space front-door banner: icon + title, description, and a stats row. Counts +// (pages/members/views) aren't wired to the server yet, so they render an +// em dash until the member/page/view APIs feed them. +const PageHero = ({space}: {space: Space}) => { + const {formatMessage} = useIntl(); + + const stats: Stat[] = [ + {key: 'pages', value: undefined, label: formatMessage({id: 'docs.space.stat.pages', defaultMessage: 'Pages'})}, + {key: 'members', value: undefined, label: formatMessage({id: 'docs.space.stat.members', defaultMessage: 'Members'})}, + {key: 'views', value: undefined, label: formatMessage({id: 'docs.space.stat.views', defaultMessage: 'Views'})}, + ]; + + return ( +
+
+ + + +

{space.title}

+
+ + {space.description ? ( +

{space.description}

+ ) : ( +

+ {formatMessage({id: 'docs.space.descriptionPlaceholder', defaultMessage: 'Add a space description here — just a brief summary of the purpose for this space.'})} +

+ )} + +
+ {stats.map((stat) => ( +
+ {/* eslint-disable-next-line formatjs/no-literal-string-in-jsx -- em dash placeholder until counts are wired */} + {stat.value ?? '—'} + {stat.label} +
+ ))} +
+
+ ); +}; + +export default PageHero; diff --git a/webapp/src/components/space_view/space_title_bar.module.scss b/webapp/src/components/space_view/space_title_bar.module.scss new file mode 100644 index 0000000..be6f50f --- /dev/null +++ b/webapp/src/components/space_view/space_title_bar.module.scss @@ -0,0 +1,98 @@ +.bar { + display: flex; + align-items: center; + gap: 8px; + height: 48px; + padding: 0 12px 0 8px; + 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; +} + +.titleTrigger { + display: flex; + min-width: 0; + align-items: center; + gap: 6px; + padding: 4px 6px; + border: none; + border-radius: 4px; + background: transparent; + cursor: pointer; + + &:hover { + background: rgba(var(--center-channel-color-rgb), 0.08); + } +} + +.emoji { + display: flex; + flex-shrink: 0; + align-items: center; + font-size: 18px; + line-height: 1; +} + +.title { + overflow: hidden; + color: var(--center-channel-color); + font-family: 'Metropolis', sans-serif; + font-size: 16px; + font-weight: 600; + line-height: 20px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chevron { + flex-shrink: 0; + color: rgba(var(--center-channel-color-rgb), 0.64); +} + +.members { + display: flex; + align-items: center; + gap: 4px; + color: rgba(var(--center-channel-color-rgb), 0.64); + font-size: 12px; + font-weight: 600; +} + +.iconButton { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + border: none; + border-radius: 4px; + background: transparent; + color: rgba(var(--center-channel-color-rgb), 0.64); + cursor: pointer; + + &:hover { + background: rgba(var(--center-channel-color-rgb), 0.08); + color: rgba(var(--center-channel-color-rgb), 0.72); + } +} + +.share { + display: flex; + align-items: center; + gap: 6px; +} diff --git a/webapp/src/components/space_view/space_title_bar.tsx b/webapp/src/components/space_view/space_title_bar.tsx new file mode 100644 index 0000000..f1a4825 --- /dev/null +++ b/webapp/src/components/space_view/space_title_bar.tsx @@ -0,0 +1,89 @@ +// 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 {SpaceIcon} from 'utils/space_icon'; + +import AccountMultipleOutlineIcon from '@mattermost/compass-icons/components/account-multiple-outline'; +import ChevronDownIcon from '@mattermost/compass-icons/components/chevron-down'; +import InformationOutlineIcon from '@mattermost/compass-icons/components/information-outline'; +import ShareVariantOutlineIcon from '@mattermost/compass-icons/components/share-variant-outline'; +import StarOutlineIcon from '@mattermost/compass-icons/components/star-outline'; + +import {PrimaryButton} from 'components/form-controls/button'; + +import type {Space} from 'types/docs'; + +import styles from './space_title_bar.module.scss'; + +// The controls here (favorite, space menu, details, share) are visual scaffolding +// — wired in later passes. Member count awaits the space-members API. +const SpaceTitleBar = ({space}: {space: Space}) => { + const {formatMessage} = useIntl(); + + const favoriteLabel = formatMessage({id: 'docs.space.favorite', defaultMessage: 'Favorite this space'}); + const menuLabel = formatMessage({id: 'docs.space.menu', defaultMessage: 'Space options'}); + const detailsLabel = formatMessage({id: 'docs.space.details', defaultMessage: 'Space details'}); + + return ( +
+
+ + + + + + {/* eslint-disable-next-line formatjs/no-literal-string-in-jsx -- em dash placeholder until the members API is wired */} + {'—'} + +
+ +
+ + + + + + + +
+
+ ); +}; + +export default SpaceTitleBar; diff --git a/webapp/src/components/space_view/space_view.module.scss b/webapp/src/components/space_view/space_view.module.scss new file mode 100644 index 0000000..014d15e --- /dev/null +++ b/webapp/src/components/space_view/space_view.module.scss @@ -0,0 +1,27 @@ +.root { + display: flex; + flex: 1 1 0; + flex-direction: column; + min-width: 0; + height: 100%; + background: var(--center-channel-bg); +} + +.scroll { + flex: 1 1 0; + overflow-y: auto; +} + +.content { + display: flex; + flex-direction: column; + gap: 24px; + padding: 20px 24px; +} + +.body { + padding: 8px 4px 40px; + color: rgba(var(--center-channel-color-rgb), 0.56); + font-size: 14px; + line-height: 20px; +} diff --git a/webapp/src/components/space_view/space_view.tsx b/webapp/src/components/space_view/space_view.tsx new file mode 100644 index 0000000..c607578 --- /dev/null +++ b/webapp/src/components/space_view/space_view.tsx @@ -0,0 +1,35 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {FormattedMessage} from 'react-intl'; + +import type {Space} from 'types/docs'; + +import PageBar from './page_bar'; +import PageHero from './page_hero'; +import SpaceTitleBar from './space_title_bar'; +import styles from './space_view.module.scss'; + +// Main content for a routed space: the space title bar and page bar over the +// front-door page (hero). The page body is a placeholder until the editor is +// mounted (a later pass). +const SpaceView = ({space}: {space: Space}) => ( +
+ + +
+
+ +
+ +
+
+
+
+); + +export default SpaceView; From 90c36515508e29b70765a473506618e048a50938 Mon Sep 17 00:00:00 2001 From: Caleb Roseland Date: Mon, 27 Jul 2026 18:31:23 -0500 Subject: [PATCH 08/68] feat(docs): wire space page and member count pipelines Feeds the Space Home stats and title-bar member count with real data. - data: add listSpaceMembers over GET /spaces/{id}/members; normalize the page-list summaries (no body) to Page for the store - store: new spaceMembers slice (user ids per space) + fetchSpaceMembers; fetchPages now stores pages for the count (reused by the page tree later) - selectors: getSpaceMemberIds; page count derives from getPagesForSpace - hooks: useSpaceStats loads both on space mount and returns the counts - space view: real PAGES and MEMBERS counts in the hero and the title-bar member count Views has no server source yet, so that stat stays an em dash. --- .../src/components/space_view/page_hero.tsx | 18 ++++++--- .../components/space_view/space_title_bar.tsx | 10 ++--- .../src/components/space_view/space_view.tsx | 38 ++++++++++++------- webapp/src/data/api_data_source.ts | 13 ++++++- webapp/src/data/docs_data_source.ts | 10 +++-- webapp/src/hooks/spaces.ts | 26 ++++++++++++- webapp/src/store/action_types.ts | 1 + webapp/src/store/actions.ts | 26 +++++++++++-- webapp/src/store/reducer.ts | 25 +++++++++++- webapp/src/store/selectors.test.ts | 2 + webapp/src/store/selectors.ts | 6 +++ webapp/src/types/docs.ts | 6 +++ webapp/tests/react_testing_utils.tsx | 1 + 13 files changed, 145 insertions(+), 37 deletions(-) diff --git a/webapp/src/components/space_view/page_hero.tsx b/webapp/src/components/space_view/page_hero.tsx index 1890993..5db3d7a 100644 --- a/webapp/src/components/space_view/page_hero.tsx +++ b/webapp/src/components/space_view/page_hero.tsx @@ -16,15 +16,21 @@ type Stat = { label: string; }; -// Space front-door banner: icon + title, description, and a stats row. Counts -// (pages/members/views) aren't wired to the server yet, so they render an -// em dash until the member/page/view APIs feed them. -const PageHero = ({space}: {space: Space}) => { +type Props = { + space: Space; + pageCount: number; + memberCount: number; +}; + +// Space front-door banner: icon + title, description, and a stats row. Page and +// member counts are wired to the server; views has no source yet, so it renders +// an em dash. +const PageHero = ({space, pageCount, memberCount}: Props) => { const {formatMessage} = useIntl(); const stats: Stat[] = [ - {key: 'pages', value: undefined, label: formatMessage({id: 'docs.space.stat.pages', defaultMessage: 'Pages'})}, - {key: 'members', value: undefined, label: formatMessage({id: 'docs.space.stat.members', defaultMessage: 'Members'})}, + {key: 'pages', value: pageCount, label: formatMessage({id: 'docs.space.stat.pages', defaultMessage: 'Pages'})}, + {key: 'members', value: memberCount, label: formatMessage({id: 'docs.space.stat.members', defaultMessage: 'Members'})}, {key: 'views', value: undefined, label: formatMessage({id: 'docs.space.stat.views', defaultMessage: 'Views'})}, ]; diff --git a/webapp/src/components/space_view/space_title_bar.tsx b/webapp/src/components/space_view/space_title_bar.tsx index f1a4825..4d056e3 100644 --- a/webapp/src/components/space_view/space_title_bar.tsx +++ b/webapp/src/components/space_view/space_title_bar.tsx @@ -17,9 +17,9 @@ import type {Space} from 'types/docs'; import styles from './space_title_bar.module.scss'; -// The controls here (favorite, space menu, details, share) are visual scaffolding -// — wired in later passes. Member count awaits the space-members API. -const SpaceTitleBar = ({space}: {space: Space}) => { +// The controls here (favorite, space menu, details, share) are visual +// scaffolding — wired in later passes. +const SpaceTitleBar = ({space, memberCount}: {space: Space; memberCount: number}) => { const {formatMessage} = useIntl(); const favoriteLabel = formatMessage({id: 'docs.space.favorite', defaultMessage: 'Favorite this space'}); @@ -58,9 +58,7 @@ const SpaceTitleBar = ({space}: {space: Space}) => { - - {/* eslint-disable-next-line formatjs/no-literal-string-in-jsx -- em dash placeholder until the members API is wired */} - {'—'} + {memberCount}
diff --git a/webapp/src/components/space_view/space_view.tsx b/webapp/src/components/space_view/space_view.tsx index c607578..6d72666 100644 --- a/webapp/src/components/space_view/space_view.tsx +++ b/webapp/src/components/space_view/space_view.tsx @@ -1,6 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {useSpaceStats} from 'hooks/spaces'; import React from 'react'; import {FormattedMessage} from 'react-intl'; @@ -14,22 +15,33 @@ import styles from './space_view.module.scss'; // Main content for a routed space: the space title bar and page bar over the // front-door page (hero). The page body is a placeholder until the editor is // mounted (a later pass). -const SpaceView = ({space}: {space: Space}) => ( -
- - -
-
- -
- { + const {pageCount, memberCount} = useSpaceStats(space.id); + + return ( +
+ + +
+
+ +
+ +
-
-); + ); +}; export default SpaceView; diff --git a/webapp/src/data/api_data_source.ts b/webapp/src/data/api_data_source.ts index 8eda041..50d0e82 100644 --- a/webapp/src/data/api_data_source.ts +++ b/webapp/src/data/api_data_source.ts @@ -3,10 +3,14 @@ import {apiUrl, listAll, restDelete, restGet, restPost} from 'client/rest'; -import type {CreateSpaceInput, Page, Space} from 'types/docs'; +import type {CreateSpaceInput, Page, Space, SpaceMember} from 'types/docs'; import type {DocsDataSource} from './docs_data_source'; +// The server's page list returns summaries (no body/delete_at); fill the fields +// the store's Page type needs so a summary is a valid, body-less Page. +const toPage = (summary: Page): Page => ({...summary, body: summary.body ?? '', delete_at: summary.delete_at ?? 0}); + // Docs data over the plugin REST API (server/api.go). Ids are opaque; lists are // paginated ({items, has_more}) and followed to completion by listAll. export const apiDataSource: DocsDataSource = { @@ -22,5 +26,10 @@ export const apiDataSource: DocsDataSource = { removeSpaceMember: (spaceId, userId) => restDelete(`${apiUrl()}/spaces/${spaceId}/members/${userId}`), - listPages: (spaceId) => listAll((query) => `${apiUrl()}/spaces/${spaceId}/pages?${query}`), + listSpaceMembers: (spaceId) => listAll((query) => `${apiUrl()}/spaces/${spaceId}/members?${query}`), + + listPages: async (spaceId) => { + const summaries = await listAll((query) => `${apiUrl()}/spaces/${spaceId}/pages?${query}`); + return summaries.map(toPage); + }, }; diff --git a/webapp/src/data/docs_data_source.ts b/webapp/src/data/docs_data_source.ts index c83c7d1..8e1632e 100644 --- a/webapp/src/data/docs_data_source.ts +++ b/webapp/src/data/docs_data_source.ts @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import type {CreateSpaceInput, Page, Space} from 'types/docs'; +import type {CreateSpaceInput, Page, Space, SpaceMember} from 'types/docs'; // The seam between the store's thunks and the Docs server REST API. The // API-backed source implements this over the plugin's /api/v1 routes; the @@ -28,7 +28,11 @@ export interface DocsDataSource { // server rejects removing the last authorized member (409). removeSpaceMember(spaceId: string, userId: string): Promise; - // Pages belong to a space. No page-consuming UI exists yet, so this is - // wired for later; the server returns page summaries (no body). + // Members of a space (user ids only). Backs the member count and, later, + // member avatars. + listSpaceMembers(spaceId: string): Promise; + + // Pages in a space. The server returns page summaries (no body); the source + // normalizes them to Page with an empty body for the store. listPages(spaceId: string): Promise; } diff --git a/webapp/src/hooks/spaces.ts b/webapp/src/hooks/spaces.ts index 1b7c2f8..6374a9b 100644 --- a/webapp/src/hooks/spaces.ts +++ b/webapp/src/hooks/spaces.ts @@ -9,8 +9,8 @@ import {createSpaceFormSchema} from 'validation/space_schema'; import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; -import {createSpace} from 'store/actions'; -import {getAllSpaces, getSpace, getSpacesForCurrentTeam} from 'store/selectors'; +import {createSpace, fetchPages, fetchSpaceMembers} from 'store/actions'; +import {getAllSpaces, getPagesForSpace, getSpace, getSpaceMemberIds, getSpacesForCurrentTeam} from 'store/selectors'; import type {Space, SpaceSummary, SpaceVisibility} from 'types/docs'; @@ -43,6 +43,28 @@ export function useRecentSpaceSummaries(): SpaceSummary[] { }, [userId, teamSpaces]); } +export type SpaceStats = { + pageCount: number; + memberCount: number; +}; + +// Loads and returns a space's page and member counts. Fetches on mount into the +// store, so the page tree and member avatars can reuse the same data later. The +// view count has no server source yet, so it isn't included. +export function useSpaceStats(spaceId: string): SpaceStats { + const dispatch = useAppDispatch(); + + useEffect(() => { + dispatch(fetchPages(spaceId)); + dispatch(fetchSpaceMembers(spaceId)); + }, [dispatch, spaceId]); + + const pages = useAppSelector((state) => getPagesForSpace(state, spaceId)); + const memberIds = useAppSelector((state) => getSpaceMemberIds(state, spaceId)); + + return {pageCount: pages.length, memberCount: memberIds.length}; +} + // Records that the current user viewed a space, feeding the recently-viewed // list. No-op until both ids are known. export function useRecordSpaceView(spaceId?: string): void { diff --git a/webapp/src/store/action_types.ts b/webapp/src/store/action_types.ts index 4ae6df9..6c885e6 100644 --- a/webapp/src/store/action_types.ts +++ b/webapp/src/store/action_types.ts @@ -7,6 +7,7 @@ export const SpaceTypes = { RECEIVED_SPACES: manifest.id + '_received_spaces', CREATED_SPACE: manifest.id + '_created_space', DELETED_SPACE: manifest.id + '_deleted_space', + RECEIVED_SPACE_MEMBERS: manifest.id + '_received_space_members', } as const; export const PageTypes = { diff --git a/webapp/src/store/actions.ts b/webapp/src/store/actions.ts index bc8b0a3..e2f92df 100644 --- a/webapp/src/store/actions.ts +++ b/webapp/src/store/actions.ts @@ -45,12 +45,30 @@ export function fetchAllSpaces(): DocsThunkAction> { }; } -// Loads a space's pages. Wired for the page tree that lands later; no UI reads -// store pages yet, so this isn't called on bootstrap. +// Loads a space's pages into the store (backs the page count today, the page +// tree later). Best-effort: a failure leaves the count at its current value. export function fetchPages(spaceId: string): DocsThunkAction> { return async (dispatch) => { - const pages = await docsDataSource.listPages(spaceId); - dispatch({type: PageTypes.RECEIVED_PAGES, pages}); + try { + const pages = await docsDataSource.listPages(spaceId); + dispatch({type: PageTypes.RECEIVED_PAGES, pages}); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Docs: failed to load pages', error); + } + }; +} + +// Loads a space's members (user ids) into the store, backing the member count. +export function fetchSpaceMembers(spaceId: string): DocsThunkAction> { + return async (dispatch) => { + try { + const members = await docsDataSource.listSpaceMembers(spaceId); + dispatch({type: SpaceTypes.RECEIVED_SPACE_MEMBERS, spaceId, userIds: members.map((m) => m.user_id)}); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Docs: failed to load space members', error); + } }; } diff --git a/webapp/src/store/reducer.ts b/webapp/src/store/reducer.ts index 10d2ab0..f38329c 100644 --- a/webapp/src/store/reducer.ts +++ b/webapp/src/store/reducer.ts @@ -12,6 +12,7 @@ type ReceivedSpacesAction = {spaces: Space[]}; type CreatedSpaceAction = {space: Space}; type DeletedSpaceAction = {spaceId: string}; type ReceivedPagesAction = {pages: Page[]}; +type ReceivedSpaceMembersAction = {spaceId: string; userIds: string[]}; // SpaceTypes'/PageTypes' values aren't string-literal types (manifest.id is // loaded via JSON.parse), so `action.type` can't discriminate a union by @@ -145,6 +146,28 @@ function pagesInSpace(state: Record> = {}, action: UnknownAc } } -const reducer = combineReducers({spaces, spacesInTeam, pages, pagesInSpace}); +// Space member user ids, keyed by space id. Roles/capabilities are hidden by +// the server, so this is just membership (count today, avatars later). +function spaceMembers(state: Record = {}, action: UnknownAction): Record { + switch (action.type) { + case SpaceTypes.RECEIVED_SPACE_MEMBERS: { + const {spaceId, userIds} = action as unknown as ReceivedSpaceMembersAction; + return {...state, [spaceId]: userIds}; + } + case SpaceTypes.DELETED_SPACE: { + const {spaceId} = action as unknown as DeletedSpaceAction; + if (!(spaceId in state)) { + return state; + } + const next = {...state}; + delete next[spaceId]; + return next; + } + default: + return state; + } +} + +const reducer = combineReducers({spaces, spacesInTeam, pages, pagesInSpace, spaceMembers}); export default reducer; diff --git a/webapp/src/store/selectors.test.ts b/webapp/src/store/selectors.test.ts index eb104ed..57c8c23 100644 --- a/webapp/src/store/selectors.test.ts +++ b/webapp/src/store/selectors.test.ts @@ -22,6 +22,7 @@ describe('getSpacesInTeam', () => { spacesInTeam: {t1: new Set(['a', 'b', 'missing'])}, pages: {}, pagesInSpace: {}, + spaceMembers: {}, }); expect(getSpacesInTeam(state, 't1')).toEqual([spaceB, spaceA]); @@ -39,6 +40,7 @@ describe('getPagesForSpace', () => { spacesInTeam: {}, pages: {p1: page1, p2: page2}, pagesInSpace: {'space-a': new Set(['p1']), 'space-b': new Set(['p2'])}, + spaceMembers: {}, }); expect(getPagesForSpace(state, 'space-a')).toEqual([page1]); diff --git a/webapp/src/store/selectors.ts b/webapp/src/store/selectors.ts index 699f3da..31fad49 100644 --- a/webapp/src/store/selectors.ts +++ b/webapp/src/store/selectors.ts @@ -17,6 +17,7 @@ const EMPTY_PLUGIN_STATE: DocsPluginState = { spacesInTeam: {}, pages: {}, pagesInSpace: {}, + spaceMembers: {}, }; const EMPTY_SPACES: Space[] = []; @@ -58,6 +59,11 @@ export const getPagesById = (state: GlobalState): Record => plugin export const getPagesInSpaceIndex = (state: GlobalState): Record> => pluginState(state).pagesInSpace; +const EMPTY_MEMBER_IDS: string[] = []; + +export const getSpaceMemberIds = (state: GlobalState, spaceId: string): string[] => + pluginState(state).spaceMembers[spaceId] ?? EMPTY_MEMBER_IDS; + // Spaces for an explicit team, resolved through the byId map and sorted. // Mirrors core's getChannelsInTeam-style read: index Set → entities → order. export const getSpacesInTeam = createSelector( diff --git a/webapp/src/types/docs.ts b/webapp/src/types/docs.ts index 8ca45e4..ad932f2 100644 --- a/webapp/src/types/docs.ts +++ b/webapp/src/types/docs.ts @@ -25,6 +25,12 @@ export type Space = { visibility?: SpaceVisibility; }; +// A space member. The server exposes only the user id (roles/capabilities are +// hidden); the user profile is resolved from the host store when needed. +export type SpaceMember = { + user_id: string; +}; + // The fields the create-space form collects. The data source turns this into a // Space (assigning the opaque id, etc.). visibility is client-only for now (maps // to the server's view_access later). diff --git a/webapp/tests/react_testing_utils.tsx b/webapp/tests/react_testing_utils.tsx index 06ddcad..2ceb45c 100644 --- a/webapp/tests/react_testing_utils.tsx +++ b/webapp/tests/react_testing_utils.tsx @@ -22,6 +22,7 @@ const EMPTY_DOCS_STATE: DocsPluginState = { spacesInTeam: {}, pages: {}, pagesInSpace: {}, + spaceMembers: {}, }; type TestTeam = {id: string; name: string; display_name?: string}; From 446989d4ec64fff0554c49d576c7ae3a6eab9fc6 Mon Sep 17 00:00:00 2001 From: Caleb Roseland Date: Mon, 27 Jul 2026 19:16:17 -0500 Subject: [PATCH 09/68] feat(docs): member avatars, Share modal, design-system buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Space view build-out plus a raw- + + + +
+ +
+ ); + + return ( + +
+ } + /> +
+ {members.map((member) => ( +
+ + + {member.displayName} + {member.username && ( + + + + )} + {member.id === currentUserId && ( + + + + )} + + +
+ ))} +
+
+
+ ); +}; + +export default ShareSpaceModal; diff --git a/webapp/src/components/space_view/member_avatars.module.scss b/webapp/src/components/space_view/member_avatars.module.scss new file mode 100644 index 0000000..07195ac --- /dev/null +++ b/webapp/src/components/space_view/member_avatars.module.scss @@ -0,0 +1,30 @@ +.stack { + display: flex; + align-items: center; +} + +.avatar { + display: inline-flex; + border-radius: 50%; + box-shadow: 0 0 0 2px var(--center-channel-bg); + + &:not(:first-child) { + margin-left: -8px; + } +} + +.overflow { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 24px; + height: 24px; + margin-left: -8px; + padding: 0 6px; + border-radius: 12px; + background: rgba(var(--center-channel-color-rgb), 0.08); + box-shadow: 0 0 0 2px var(--center-channel-bg); + color: rgba(var(--center-channel-color-rgb), 0.72); + font-size: 11px; + font-weight: 600; +} diff --git a/webapp/src/components/space_view/member_avatars.tsx b/webapp/src/components/space_view/member_avatars.tsx new file mode 100644 index 0000000..5130162 --- /dev/null +++ b/webapp/src/components/space_view/member_avatars.tsx @@ -0,0 +1,54 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useSpaceMemberProfiles} from 'hooks/members'; +import React from 'react'; +import {FormattedMessage} from 'react-intl'; +import {Avatar} from 'webapp_globals'; + +import styles from './member_avatars.module.scss'; + +const MAX_SHOWN = 3; + +// Overlapping avatar stack for a space's members, with a "+N" overflow. Renders +// nothing when there are no members (Avatar itself no-ops on hosts without it). +const MemberAvatars = ({spaceId}: {spaceId: string}) => { + const members = useSpaceMemberProfiles(spaceId); + + if (members.length === 0) { + return null; + } + + const shown = members.slice(0, MAX_SHOWN); + const overflow = members.length - shown.length; + + return ( +
+ {shown.map((member) => ( + + + + ))} + {overflow > 0 && ( + + + + )} +
+ ); +}; + +export default MemberAvatars; diff --git a/webapp/src/components/space_view/page_bar.module.scss b/webapp/src/components/space_view/page_bar.module.scss index bff1bec..135647c 100644 --- a/webapp/src/components/space_view/page_bar.module.scss +++ b/webapp/src/components/space_view/page_bar.module.scss @@ -19,23 +19,11 @@ display: flex; flex-shrink: 0; align-items: center; - gap: 2px; + gap: 4px; } .pagesToggle { - display: flex; - align-items: center; gap: 8px; - padding: 4px 8px; - border: none; - border-radius: 4px; - background: transparent; - color: rgba(var(--center-channel-color-rgb), 0.72); - cursor: pointer; - - &:hover { - background: rgba(var(--center-channel-color-rgb), 0.08); - } } .pagesLabel { @@ -45,30 +33,14 @@ } .updated { - margin-right: 8px; + margin-right: 4px; color: rgba(var(--center-channel-color-rgb), 0.56); font-size: 12px; white-space: nowrap; } -.iconButton { +.commentButton { position: relative; - display: flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - padding: 0; - border: none; - border-radius: 4px; - background: transparent; - color: rgba(var(--center-channel-color-rgb), 0.64); - cursor: pointer; - - &:hover { - background: rgba(var(--center-channel-color-rgb), 0.08); - color: rgba(var(--center-channel-color-rgb), 0.72); - } } .badge { @@ -83,21 +55,5 @@ } .edit { - display: flex; - align-items: center; gap: 6px; - height: 28px; - margin: 0 4px; - padding: 0 12px; - border: 1px solid rgba(var(--center-channel-color-rgb), 0.16); - border-radius: 4px; - background: transparent; - color: var(--center-channel-color); - font-size: 12px; - font-weight: 600; - cursor: pointer; - - &:hover { - background: rgba(var(--center-channel-color-rgb), 0.08); - } } diff --git a/webapp/src/components/space_view/page_bar.tsx b/webapp/src/components/space_view/page_bar.tsx index f3080a5..16e423a 100644 --- a/webapp/src/components/space_view/page_bar.tsx +++ b/webapp/src/components/space_view/page_bar.tsx @@ -1,6 +1,7 @@ // 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, useIntl} from 'react-intl'; import {Timestamp} from 'webapp_globals'; @@ -12,6 +13,8 @@ import FormatListBulletedIcon from '@mattermost/compass-icons/components/format- import MessageTextOutlineIcon from '@mattermost/compass-icons/components/message-text-outline'; import PencilOutlineIcon from '@mattermost/compass-icons/components/pencil-outline'; +import {Button, SecondaryButton} from 'components/form-controls/button'; + import type {Space} from 'types/docs'; import styles from './page_bar.module.scss'; @@ -50,8 +53,10 @@ const PageBar = ({space}: {space: Space}) => { return (
- +
@@ -75,16 +80,19 @@ const PageBar = ({space}: {space: Space}) => { /> )} - - - - +
); diff --git a/webapp/src/components/space_view/page_hero.module.scss b/webapp/src/components/space_view/page_hero.module.scss index 2209304..5454999 100644 --- a/webapp/src/components/space_view/page_hero.module.scss +++ b/webapp/src/components/space_view/page_hero.module.scss @@ -65,6 +65,10 @@ border-right: none; } +.avatars { + margin-left: auto; +} + .statValue { color: var(--center-channel-color); font-size: 20px; diff --git a/webapp/src/components/space_view/page_hero.tsx b/webapp/src/components/space_view/page_hero.tsx index 5db3d7a..8699b4c 100644 --- a/webapp/src/components/space_view/page_hero.tsx +++ b/webapp/src/components/space_view/page_hero.tsx @@ -8,6 +8,7 @@ import {SpaceIcon} from 'utils/space_icon'; import type {Space} from 'types/docs'; +import MemberAvatars from './member_avatars'; import styles from './page_hero.module.scss'; type Stat = { @@ -63,11 +64,14 @@ const PageHero = ({space, pageCount, memberCount}: Props) => { key={stat.key} className={styles.stat} > - {/* eslint-disable-next-line formatjs/no-literal-string-in-jsx -- em dash placeholder until counts are wired */} + {/* eslint-disable-next-line formatjs/no-literal-string-in-jsx -- em dash placeholder for the view count, which has no server source yet */} {stat.value ?? '—'} {stat.label}
))} +
+ +
); diff --git a/webapp/src/components/space_view/space_title_bar.module.scss b/webapp/src/components/space_view/space_title_bar.module.scss index be6f50f..fd0ecd7 100644 --- a/webapp/src/components/space_view/space_title_bar.module.scss +++ b/webapp/src/components/space_view/space_title_bar.module.scss @@ -13,7 +13,7 @@ flex: 1 1 auto; min-width: 0; align-items: center; - gap: 8px; + gap: 4px; } .right { @@ -24,19 +24,11 @@ } .titleTrigger { - display: flex; + display: inline-flex; min-width: 0; + max-width: 280px; align-items: center; gap: 6px; - padding: 4px 6px; - border: none; - border-radius: 4px; - background: transparent; - cursor: pointer; - - &:hover { - background: rgba(var(--center-channel-color-rgb), 0.08); - } } .emoji { @@ -67,32 +59,14 @@ display: flex; align-items: center; gap: 4px; + margin-left: 4px; color: rgba(var(--center-channel-color-rgb), 0.64); font-size: 12px; font-weight: 600; } -.iconButton { - display: flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - padding: 0; - border: none; - border-radius: 4px; - background: transparent; - color: rgba(var(--center-channel-color-rgb), 0.64); - cursor: pointer; - - &:hover { - background: rgba(var(--center-channel-color-rgb), 0.08); - color: rgba(var(--center-channel-color-rgb), 0.72); - } -} - .share { - display: flex; + display: inline-flex; align-items: center; gap: 6px; } diff --git a/webapp/src/components/space_view/space_title_bar.tsx b/webapp/src/components/space_view/space_title_bar.tsx index 4d056e3..8fb4d85 100644 --- a/webapp/src/components/space_view/space_title_bar.tsx +++ b/webapp/src/components/space_view/space_title_bar.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React from 'react'; +import React, {useState} from 'react'; import {FormattedMessage, useIntl} from 'react-intl'; import {SpaceIcon} from 'utils/space_icon'; @@ -11,16 +11,19 @@ import InformationOutlineIcon from '@mattermost/compass-icons/components/informa import ShareVariantOutlineIcon from '@mattermost/compass-icons/components/share-variant-outline'; import StarOutlineIcon from '@mattermost/compass-icons/components/star-outline'; -import {PrimaryButton} from 'components/form-controls/button'; +import {Button, PrimaryButton} from 'components/form-controls/button'; +import ShareSpaceModal from 'components/share_space_modal/share_space_modal'; import type {Space} from 'types/docs'; import styles from './space_title_bar.module.scss'; // The controls here (favorite, space menu, details, share) are visual -// scaffolding — wired in later passes. +// scaffolding — wired in later passes. Icon buttons use the shared Button with +// the compass `btn-icon` treatment (quaternary + square), the same as core. const SpaceTitleBar = ({space, memberCount}: {space: Space; memberCount: number}) => { const {formatMessage} = useIntl(); + const [shareOpen, setShareOpen] = useState(false); const favoriteLabel = formatMessage({id: 'docs.space.favorite', defaultMessage: 'Favorite this space'}); const menuLabel = formatMessage({id: 'docs.space.menu', defaultMessage: 'Space options'}); @@ -29,15 +32,19 @@ const SpaceTitleBar = ({space, memberCount}: {space: Space; memberCount: number} return (
- - + {memberCount} @@ -63,23 +70,34 @@ const SpaceTitleBar = ({space, memberCount}: {space: Space; memberCount: number}
- - - - - - + + setShareOpen(true)} + > + +
+ {shareOpen && ( + setShareOpen(false)} + /> + )}
); }; diff --git a/webapp/src/hooks/members.ts b/webapp/src/hooks/members.ts new file mode 100644 index 0000000..0d0d96f --- /dev/null +++ b/webapp/src/hooks/members.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useAppDispatch, useAppSelector} from 'hooks/redux'; +import {useEffect, useMemo} from 'react'; + +import {getMissingProfilesByIds} from 'mattermost-redux/actions/users'; +import {Client4} from 'mattermost-redux/client'; +import {getTeammateNameDisplaySetting} from 'mattermost-redux/selectors/entities/preferences'; +import {getUsers} from 'mattermost-redux/selectors/entities/users'; +import {displayUsername} from 'mattermost-redux/utils/user_utils'; + +import {getSpaceMemberIds} from 'store/selectors'; + +export type MemberProfile = { + id: string; + displayName: string; + username: string; + avatarUrl: string; +}; + +// Resolves a space's member ids (from the Docs store) to display profiles, +// fetching any not yet in the host store. Member ids are loaded by +// fetchSpaceMembers (see useSpaceStats). +export function useSpaceMemberProfiles(spaceId: string): MemberProfile[] { + const dispatch = useAppDispatch(); + const memberIds = useAppSelector((state) => getSpaceMemberIds(state, spaceId)); + const usersById = useAppSelector(getUsers); + const nameDisplay = useAppSelector(getTeammateNameDisplaySetting) || ''; + + useEffect(() => { + if (memberIds.length) { + dispatch(getMissingProfilesByIds(memberIds)); + } + }, [dispatch, memberIds]); + + return useMemo(() => memberIds.map((id) => { + const user = usersById[id]; + return { + id, + displayName: displayUsername(user, nameDisplay), + username: user?.username ?? '', + avatarUrl: Client4.getProfilePictureUrl(id, user?.last_picture_update), + }; + }), [memberIds, usersById, nameDisplay]); +} diff --git a/webapp/src/webapp_globals.ts b/webapp/src/webapp_globals.ts index c4970fb..8ec2aa5 100644 --- a/webapp/src/webapp_globals.ts +++ b/webapp/src/webapp_globals.ts @@ -2,7 +2,8 @@ // See LICENSE.txt for license information. import type {History} from 'history'; -import type {ComponentType, ReactNode} from 'react'; +import React from 'react'; +import type {ComponentType, ReactElement, ReactNode} from 'react'; import type {Action} from 'redux'; // Hand-typed view of the API the host web app attaches to `window` for plugins @@ -52,17 +53,36 @@ type TimestampProps = { children?: ReactNode; }; +export type AvatarSize = 'xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl'; + +// Mirrors core's Avatar props (widgets/users/avatar). `name` is set to '' when a +// visible label already accompanies the avatar, so screen readers don't repeat it. +type AvatarProps = { + url?: string; + username?: string; + size?: AvatarSize; + name?: string; +}; + // Core exposes shared React components to plugins on `window.Components` -// (core's plugins/export.ts). Timestamp renders localized, timezone-aware -// relative/absolute times so plugins don't hand-roll date formatting. +// (core's plugins/export.ts). Timestamp renders localized times; Avatar renders +// a user's profile picture with the host's sizing/fallback. type HostComponents = { Timestamp?: ComponentType; + Avatar?: ComponentType; }; const hostComponents = (): HostComponents => (window as unknown as {Components?: HostComponents}).Components ?? {}; export const Timestamp = hostComponents().Timestamp; +// Renders the host Avatar, or nothing on a host that doesn't publish it. The +// fallback lives here so callers just render without a null check. +export const Avatar = (props: AvatarProps): ReactElement | null => { + const HostAvatar = hostComponents().Avatar; + return HostAvatar ? React.createElement(HostAvatar, props) : null; +}; + export function getBrowserHistory(): History | undefined { return webappUtils().browserHistory; } From 0bed4731db868ef6d031af264798e8ea8ed2b2f7 Mon Sep 17 00:00:00 2001 From: Caleb Roseland Date: Mon, 27 Jul 2026 19:43:15 -0500 Subject: [PATCH 10/68] feat(docs): nested, collapsible, drag-and-drop page tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the space page-tree panel (Figma) as a middle column in the space view, backed by the real page + move APIs. - store: buildPageTree (parent_id/sort_order, orphans as roots) + buildDescendantMap; reindexAfterMove (0-based sibling renumber mirroring the server) + MOVED_PAGE; movePage thunk (optimistic move, reconcile on success, re-fetch on failure) and createPage thunk - data: DocsDataSource.movePage (PATCH .../move) and createPage (POST); restPatch - collapse: per-user localStorage tracker (data/collapsed_pages) + useCollapsedPages, mirroring the recent-spaces seam - ui: PageTreePanel (264px column: Pages header, add-page, tree) and the recursive PageTreeNode (disclosure chevron, page/folder glyph, indent, active highlight, navigate/collapse) - dnd: pragmatic-drag-and-drop reused from spaces_sidebar — a 3-zone hitbox (reorder above/below, reparent onto center), descendant-drop guard, and a monitor resolving the drop into (parent_id, sibling_index) - layout: space view is now the title bar over [tree panel][content column]; the page-bar Pages button toggles the panel - tests: buildPageTree/descendants and reindexAfterMove/MOVED_PAGE Design's role/visibility remain PR #10 scaffolding; the page body is still a placeholder until the editor is mounted. --- webapp/i18n/en.json | 5 + webapp/src/client/rest.ts | 3 + webapp/src/components/space_view/page_bar.tsx | 14 +- .../space_view/page_tree/dnd/types.ts | 17 +++ .../page_tree/dnd/use_page_drag_drop.ts | 69 ++++++++++ .../space_view/page_tree/dnd/use_pages_dnd.ts | 79 ++++++++++++ .../page_tree/page_tree_node.module.scss | 77 +++++++++++ .../space_view/page_tree/page_tree_node.tsx | 122 ++++++++++++++++++ .../page_tree/page_tree_panel.module.scss | 43 ++++++ .../space_view/page_tree/page_tree_panel.tsx | 106 +++++++++++++++ .../space_view/space_view.module.scss | 15 ++- .../src/components/space_view/space_view.tsx | 43 +++--- webapp/src/data/api_data_source.ts | 21 ++- webapp/src/data/collapsed_pages.ts | 49 +++++++ webapp/src/data/docs_data_source.ts | 12 +- webapp/src/hooks/page_tree.ts | 26 ++++ webapp/src/store/action_types.ts | 1 + webapp/src/store/actions.ts | 38 +++++- webapp/src/store/page_tree.test.ts | 53 ++++++++ webapp/src/store/page_tree.ts | 62 +++++++++ webapp/src/store/reducer.test.ts | 64 ++++++++- webapp/src/store/reducer.ts | 49 +++++++ webapp/src/types/docs.ts | 7 + 23 files changed, 951 insertions(+), 24 deletions(-) create mode 100644 webapp/src/components/space_view/page_tree/dnd/types.ts create mode 100644 webapp/src/components/space_view/page_tree/dnd/use_page_drag_drop.ts create mode 100644 webapp/src/components/space_view/page_tree/dnd/use_pages_dnd.ts create mode 100644 webapp/src/components/space_view/page_tree/page_tree_node.module.scss create mode 100644 webapp/src/components/space_view/page_tree/page_tree_node.tsx create mode 100644 webapp/src/components/space_view/page_tree/page_tree_panel.module.scss create mode 100644 webapp/src/components/space_view/page_tree/page_tree_panel.tsx create mode 100644 webapp/src/data/collapsed_pages.ts create mode 100644 webapp/src/hooks/page_tree.ts create mode 100644 webapp/src/store/page_tree.test.ts create mode 100644 webapp/src/store/page_tree.ts diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json index 85cc6dc..68e1a67 100644 --- a/webapp/i18n/en.json +++ b/webapp/i18n/en.json @@ -48,6 +48,11 @@ "docs.leaveSpace.confirm": "Yes, leave space", "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.pageTree.add": "Add page", + "docs.pageTree.collapse": "Collapse {title}", + "docs.pageTree.expand": "Expand {title}", + "docs.pageTree.heading": "Pages", + "docs.pageTree.untitled": "Untitled", "docs.share.access.canView": "Can View", "docs.share.copyLink": "Copy link", "docs.share.handle": "@{username}", diff --git a/webapp/src/client/rest.ts b/webapp/src/client/rest.ts index 3d45024..cc68f65 100644 --- a/webapp/src/client/rest.ts +++ b/webapp/src/client/rest.ts @@ -49,6 +49,9 @@ export const restGet = (url: string): Promise => doFetch(url, {method: export const restPost = (url: string, body: unknown): Promise => doFetch(url, {method: 'POST', body: JSON.stringify(body), headers: {'Content-Type': 'application/json'}}); +export const restPatch = (url: string, body: unknown): Promise => + doFetch(url, {method: 'PATCH', body: JSON.stringify(body), headers: {'Content-Type': 'application/json'}}); + export const restDelete = (url: string): Promise => doFetch(url, {method: 'DELETE'}); type Paginated = { diff --git a/webapp/src/components/space_view/page_bar.tsx b/webapp/src/components/space_view/page_bar.tsx index 16e423a..60ec856 100644 --- a/webapp/src/components/space_view/page_bar.tsx +++ b/webapp/src/components/space_view/page_bar.tsx @@ -28,9 +28,15 @@ const UPDATED_TIME_SPEC: TimestampUnit[] = [ 'year', ]; -// Controls (pages toggle, comments, edit, overflow, expand) are visual -// scaffolding wired in later passes. -const PageBar = ({space}: {space: Space}) => { +type Props = { + space: Space; + treeOpen: boolean; + onTogglePages: () => void; +}; + +// Controls (comments, edit, overflow, expand) are visual scaffolding wired in +// later passes; the pages toggle drives the page tree panel. +const PageBar = ({space, treeOpen, onTogglePages}: Props) => { const {formatMessage} = useIntl(); const pagesLabel = formatMessage({id: 'docs.space.pages.toggle', defaultMessage: 'Toggle page tree'}); @@ -59,6 +65,8 @@ const PageBar = ({space}: {space: Space}) => { size='sm' className={styles.pagesToggle} aria-label={pagesLabel} + aria-pressed={treeOpen} + onClick={onTogglePages} > diff --git a/webapp/src/components/space_view/page_tree/dnd/types.ts b/webapp/src/components/space_view/page_tree/dnd/types.ts new file mode 100644 index 0000000..eae77b5 --- /dev/null +++ b/webapp/src/components/space_view/page_tree/dnd/types.ts @@ -0,0 +1,17 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {Edge} from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; + +export const PAGE_DRAG_TYPE = 'docs-page'; + +// Where a drop lands relative to the target row: 'reorder' (above/below a +// sibling, via `edge`) or 'reparent' (onto the row's center → become its child). +export type PageDropTarget = + | {mode: 'reorder'; edge: Edge} + | {mode: 'reparent'}; + +export type PageDragData = { + type: typeof PAGE_DRAG_TYPE; + pageId: string; +}; diff --git a/webapp/src/components/space_view/page_tree/dnd/use_page_drag_drop.ts b/webapp/src/components/space_view/page_tree/dnd/use_page_drag_drop.ts new file mode 100644 index 0000000..5e934de --- /dev/null +++ b/webapp/src/components/space_view/page_tree/dnd/use_page_drag_drop.ts @@ -0,0 +1,69 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {combine} from '@atlaskit/pragmatic-drag-and-drop/combine'; +import {draggable, dropTargetForElements} from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import {useLatest} from 'hooks/utils'; +import {useEffect, useState} from 'react'; + +import {PAGE_DRAG_TYPE, type PageDropTarget} from './types'; + +type Args = { + pageId: string; + element: HTMLElement | null; + + // True when `sourcePageId` is allowed to drop on this row (guards against + // dropping a page into its own subtree). Read at drop time via a ref, so a + // changing predicate doesn't re-register the drag listeners. + canDrop: (sourcePageId: string) => boolean; + enabled: boolean; +}; + +// A row's vertical hitbox: the top and bottom quarters reorder above/below the +// row; the middle half reparents (drop onto center → become a child). +const REORDER_BAND = 0.25; + +function computeDropTarget(element: Element, clientY: number): PageDropTarget { + const rect = element.getBoundingClientRect(); + const ratio = (clientY - rect.top) / rect.height; + if (ratio <= REORDER_BAND) { + return {mode: 'reorder', edge: 'top'}; + } + if (ratio >= 1 - REORDER_BAND) { + return {mode: 'reorder', edge: 'bottom'}; + } + return {mode: 'reparent'}; +} + +export function usePageDragDrop({pageId, element, canDrop, enabled}: Args) { + const [dragging, setDragging] = useState(false); + const [dropTarget, setDropTarget] = useState(null); + const canDropRef = useLatest(canDrop); + + useEffect(() => { + if (!element || !enabled) { + return undefined; + } + + return combine( + draggable({ + element, + getInitialData: () => ({type: PAGE_DRAG_TYPE, pageId}), + onDragStart: () => setDragging(true), + onDrop: () => setDragging(false), + }), + dropTargetForElements({ + element, + getData: ({input, element: el}) => ({type: PAGE_DRAG_TYPE, pageId, ...computeDropTarget(el, input.clientY)}), + canDrop: ({source}) => source.data.type === PAGE_DRAG_TYPE && + source.data.pageId !== pageId && + canDropRef.current(source.data.pageId as string), + onDrag: ({self}) => setDropTarget({mode: self.data.mode, edge: self.data.edge} as PageDropTarget), + onDragLeave: () => setDropTarget(null), + onDrop: () => setDropTarget(null), + }), + ); + }, [pageId, element, enabled, canDropRef]); + + return {dragging, dropTarget}; +} diff --git a/webapp/src/components/space_view/page_tree/dnd/use_pages_dnd.ts b/webapp/src/components/space_view/page_tree/dnd/use_pages_dnd.ts new file mode 100644 index 0000000..d125b6c --- /dev/null +++ b/webapp/src/components/space_view/page_tree/dnd/use_pages_dnd.ts @@ -0,0 +1,79 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {monitorForElements} from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import {useLatest} from 'hooks/utils'; +import {useEffect} from 'react'; + +import type {Page} from 'types/docs'; + +import {PAGE_DRAG_TYPE, type PageDropTarget} from './types'; + +type MoveArgs = { + pageId: string; + parentId: string; + siblingIndex: number; +}; + +type Args = { + pages: Page[]; + onMove: (args: MoveArgs) => void; + enabled: boolean; +}; + +const bySortOrder = (a: Page, b: Page): number => + a.sort_order - b.sort_order || a.title.localeCompare(b.title); + +// Resolves the drop of `sourceId` onto `targetId` into a (parentId, siblingIndex) +// move. Siblings are computed with the source removed, so the returned index is +// the final 0-based position the server expects. +function resolveMove(pages: Page[], sourceId: string, targetId: string, drop: PageDropTarget): MoveArgs | null { + const target = pages.find((page) => page.id === targetId); + if (!target) { + return null; + } + + if (drop.mode === 'reparent') { + const childCount = pages.filter((page) => page.parent_id === targetId && page.id !== sourceId).length; + return {pageId: sourceId, parentId: targetId, siblingIndex: childCount}; + } + + const parentId = target.parent_id; + const siblings = pages. + filter((page) => page.parent_id === parentId && page.id !== sourceId). + sort(bySortOrder); + const targetIndex = siblings.findIndex((page) => page.id === targetId); + if (targetIndex === -1) { + return null; + } + const siblingIndex = drop.edge === 'bottom' ? targetIndex + 1 : targetIndex; + return {pageId: sourceId, parentId, siblingIndex}; +} + +// One monitor for the whole tree: on drop it resolves the source/target pair +// into a move and hands it to `onMove`. Registered once; live state is read +// through refs so the listener never re-registers mid-drag. +export function usePagesDnd({pages, onMove, enabled}: Args) { + const pagesRef = useLatest(pages); + const onMoveRef = useLatest(onMove); + const enabledRef = useLatest(enabled); + + useEffect(() => monitorForElements({ + canMonitor: ({source}) => enabledRef.current && source.data.type === PAGE_DRAG_TYPE, + onDrop: ({source, location}) => { + const target = location.current.dropTargets[0]; + if (!target || target.data.type !== PAGE_DRAG_TYPE) { + return; + } + + const sourceId = source.data.pageId as string; + const targetId = target.data.pageId as string; + const drop = {mode: target.data.mode, edge: target.data.edge} as PageDropTarget; + + const move = resolveMove(pagesRef.current, sourceId, targetId, drop); + if (move) { + onMoveRef.current(move); + } + }, + }), [pagesRef, onMoveRef, enabledRef]); +} diff --git a/webapp/src/components/space_view/page_tree/page_tree_node.module.scss b/webapp/src/components/space_view/page_tree/page_tree_node.module.scss new file mode 100644 index 0000000..d6ee120 --- /dev/null +++ b/webapp/src/components/space_view/page_tree/page_tree_node.module.scss @@ -0,0 +1,77 @@ +.node { + display: flex; + flex-direction: column; +} + +.row { + position: relative; + display: flex; + align-items: center; + gap: 2px; + min-width: 0; + padding-right: 4px; + border-radius: 4px; + + &:hover { + background: rgba(var(--center-channel-color-rgb), 0.08); + } + + &.active, + &.active:hover { + background: rgba(var(--button-bg-rgb), 0.08); + } + + &.dragging { + opacity: 0.4; + } + + &.reparent { + box-shadow: inset 0 0 0 2px var(--button-bg); + } +} + +.chevron { + flex-shrink: 0; +} + +.chevronSpacer { + flex-shrink: 0; + width: 24px; + height: 24px; +} + +.label { + display: flex; + flex: 1 1 0; + gap: 6px; + align-items: center; + min-width: 0; + justify-content: flex-start; + padding: 4px 6px; + font-weight: 400; +} + +.icon { + display: flex; + flex-shrink: 0; + align-items: center; + color: rgba(var(--center-channel-color-rgb), 0.64); +} + +.active .icon { + color: var(--button-bg); +} + +.title { + overflow: hidden; + color: var(--center-channel-color); + font-size: 14px; + line-height: 20px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.children { + display: flex; + flex-direction: column; +} diff --git a/webapp/src/components/space_view/page_tree/page_tree_node.tsx b/webapp/src/components/space_view/page_tree/page_tree_node.tsx new file mode 100644 index 0000000..2af49ff --- /dev/null +++ b/webapp/src/components/space_view/page_tree/page_tree_node.tsx @@ -0,0 +1,122 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {DropIndicator} from '@atlaskit/pragmatic-drag-and-drop-react-drop-indicator/box'; +import classNames from 'classnames'; +import React, {useState} from 'react'; +import {useIntl} from 'react-intl'; + +import ChevronDownIcon from '@mattermost/compass-icons/components/chevron-down'; +import ChevronRightIcon from '@mattermost/compass-icons/components/chevron-right'; +import FileTextOutlineIcon from '@mattermost/compass-icons/components/file-text-outline'; +import FolderOutlineIcon from '@mattermost/compass-icons/components/folder-outline'; + +import type {PageNode} from 'store/page_tree'; + +import {Button} from 'components/form-controls/button'; + +import {usePageDragDrop} from './dnd/use_page_drag_drop'; +import styles from './page_tree_node.module.scss'; + +const INDENT_STEP = 16; + +type Props = { + node: PageNode; + activePageId?: string; + collapsed: Set; + descendants: Map>; + dndEnabled: boolean; + onSelect: (pageId: string) => void; + onToggle: (pageId: string) => void; +}; + +const PageTreeNode = ({node, activePageId, collapsed, descendants, dndEnabled, onSelect, onToggle}: Props) => { + const {formatMessage} = useIntl(); + const [element, setElement] = useState(null); + const {page, children, depth} = node; + const hasChildren = children.length > 0; + const isCollapsed = collapsed.has(page.id); + + const toggleLabel = isCollapsed ? + formatMessage({id: 'docs.pageTree.expand', defaultMessage: 'Expand {title}'}, {title: page.title}) : + formatMessage({id: 'docs.pageTree.collapse', defaultMessage: 'Collapse {title}'}, {title: page.title}); + + const {dragging, dropTarget} = usePageDragDrop({ + pageId: page.id, + element, + enabled: dndEnabled, + canDrop: (sourceId) => !descendants.get(sourceId)?.has(page.id), + }); + + const PageGlyph = page.type === 'page_folder' ? FolderOutlineIcon : FileTextOutlineIcon; + + return ( +
+
+ {hasChildren ? ( + + ) : ( + + )} + + {dropTarget?.mode === 'reorder' && ( + + )} +
+ {hasChildren && !isCollapsed && ( +
+ {children.map((child) => ( + + ))} +
+ )} +
+ ); +}; + +export default PageTreeNode; diff --git a/webapp/src/components/space_view/page_tree/page_tree_panel.module.scss b/webapp/src/components/space_view/page_tree/page_tree_panel.module.scss new file mode 100644 index 0000000..2e740c9 --- /dev/null +++ b/webapp/src/components/space_view/page_tree/page_tree_panel.module.scss @@ -0,0 +1,43 @@ +.panel { + display: flex; + flex-direction: column; + flex-shrink: 0; + width: 264px; + height: 100%; + padding: 8px; + border-right: 1px solid rgba(var(--center-channel-color-rgb), 0.12); + background: var(--center-channel-bg); +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + height: 32px; + padding: 0 4px 0 8px; +} + +.heading { + color: rgba(var(--center-channel-color-rgb), 0.75); + font-size: 12px; + font-weight: 600; + line-height: 16px; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.tree { + display: flex; + flex: 1 1 0; + flex-direction: column; + margin-top: 4px; + overflow-y: auto; +} + +.addPage { + gap: 6px; + justify-content: flex-start; + margin-top: 4px; + color: rgba(var(--center-channel-color-rgb), 0.64); + font-weight: 400; +} diff --git a/webapp/src/components/space_view/page_tree/page_tree_panel.tsx b/webapp/src/components/space_view/page_tree/page_tree_panel.tsx new file mode 100644 index 0000000..40391f7 --- /dev/null +++ b/webapp/src/components/space_view/page_tree/page_tree_panel.tsx @@ -0,0 +1,106 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useDocsNavigation} from 'hooks/navigation'; +import {useCollapsedPages} from 'hooks/page_tree'; +import {useAppDispatch, useAppSelector} from 'hooks/redux'; +import React, {useCallback, useMemo} from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; + +import PlusIcon from '@mattermost/compass-icons/components/plus'; + +import {createPage, movePage} from 'store/actions'; +import {buildDescendantMap, buildPageTree} from 'store/page_tree'; +import {getPagesForSpace} from 'store/selectors'; + +import {Button} from 'components/form-controls/button'; + +import type {Space} from 'types/docs'; + +import {usePagesDnd} from './dnd/use_pages_dnd'; +import PageTreeNode from './page_tree_node'; +import styles from './page_tree_panel.module.scss'; + +const PageTreePanel = ({space}: {space: Space}) => { + const {formatMessage} = useIntl(); + const dispatch = useAppDispatch(); + const {goToPage, pageId} = useDocsNavigation(); + const {collapsed, toggle} = useCollapsedPages(); + + const pages = useAppSelector((state) => getPagesForSpace(state, space.id)); + const roots = useMemo(() => buildPageTree(pages), [pages]); + const descendants = useMemo(() => buildDescendantMap(roots), [roots]); + + const untitled = formatMessage({id: 'docs.pageTree.untitled', defaultMessage: 'Untitled'}); + const addLabel = formatMessage({id: 'docs.pageTree.add', defaultMessage: 'Add page'}); + + const createRootPage = useCallback(async () => { + try { + const page = await dispatch(createPage(space.id, {title: untitled})); + goToPage(space.id, page.id); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Docs: failed to create page', error); + } + }, [dispatch, space.id, untitled, goToPage]); + + const onMove = useCallback(({pageId: movedId, parentId, siblingIndex}: {pageId: string; parentId: string; siblingIndex: number}) => { + dispatch(movePage(space.id, movedId, parentId, siblingIndex)); + }, [dispatch, space.id]); + + usePagesDnd({pages, onMove, enabled: pages.length > 0}); + + return ( +
+
+ + + + +
+ +
+ {roots.map((node) => ( + goToPage(space.id, id)} + onToggle={toggle} + /> + ))} +
+ + +
+ ); +}; + +export default PageTreePanel; diff --git a/webapp/src/components/space_view/space_view.module.scss b/webapp/src/components/space_view/space_view.module.scss index 014d15e..1c57053 100644 --- a/webapp/src/components/space_view/space_view.module.scss +++ b/webapp/src/components/space_view/space_view.module.scss @@ -7,6 +7,19 @@ background: var(--center-channel-bg); } +.body { + display: flex; + flex: 1 1 0; + min-height: 0; +} + +.main { + display: flex; + flex: 1 1 0; + flex-direction: column; + min-width: 0; +} + .scroll { flex: 1 1 0; overflow-y: auto; @@ -19,7 +32,7 @@ padding: 20px 24px; } -.body { +.placeholder { padding: 8px 4px 40px; color: rgba(var(--center-channel-color-rgb), 0.56); font-size: 14px; diff --git a/webapp/src/components/space_view/space_view.tsx b/webapp/src/components/space_view/space_view.tsx index 6d72666..635e22e 100644 --- a/webapp/src/components/space_view/space_view.tsx +++ b/webapp/src/components/space_view/space_view.tsx @@ -2,21 +2,25 @@ // See LICENSE.txt for license information. import {useSpaceStats} from 'hooks/spaces'; -import React from 'react'; +import React, {useCallback, useState} from 'react'; import {FormattedMessage} from 'react-intl'; import type {Space} from 'types/docs'; import PageBar from './page_bar'; import PageHero from './page_hero'; +import PageTreePanel from './page_tree/page_tree_panel'; import SpaceTitleBar from './space_title_bar'; import styles from './space_view.module.scss'; -// Main content for a routed space: the space title bar and page bar over the -// front-door page (hero). The page body is a placeholder until the editor is -// mounted (a later pass). +// Main content for a routed space: the space title bar spans the full width; a +// flex row holds the page tree panel and the content column (page bar over the +// front-door hero). The page body is a placeholder until the editor is mounted. const SpaceView = ({space}: {space: Space}) => { const {pageCount, memberCount} = useSpaceStats(space.id); + const [treeOpen, setTreeOpen] = useState(true); + + const togglePages = useCallback(() => setTreeOpen((open) => !open), []); return (
@@ -24,19 +28,28 @@ const SpaceView = ({space}: {space: Space}) => { space={space} memberCount={memberCount} /> - -
-
- + {treeOpen && } +
+ -
- +
+
+ +
+ +
+
diff --git a/webapp/src/data/api_data_source.ts b/webapp/src/data/api_data_source.ts index 50d0e82..e71b08c 100644 --- a/webapp/src/data/api_data_source.ts +++ b/webapp/src/data/api_data_source.ts @@ -1,9 +1,9 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {apiUrl, listAll, restDelete, restGet, restPost} from 'client/rest'; +import {apiUrl, listAll, restDelete, restGet, restPatch, restPost} from 'client/rest'; -import type {CreateSpaceInput, Page, Space, SpaceMember} from 'types/docs'; +import type {CreatePageInput, CreateSpaceInput, Page, Space, SpaceMember} from 'types/docs'; import type {DocsDataSource} from './docs_data_source'; @@ -32,4 +32,21 @@ export const apiDataSource: DocsDataSource = { const summaries = await listAll((query) => `${apiUrl()}/spaces/${spaceId}/pages?${query}`); return summaries.map(toPage); }, + + movePage: async (spaceId, pageId, parentId, siblingIndex, expectedUpdateAt) => { + const moved = await restPatch(`${apiUrl()}/spaces/${spaceId}/pages/${pageId}/move`, { + parent_id: parentId, + sibling_index: siblingIndex, + expected_update_at: expectedUpdateAt, + }); + return toPage(moved); + }, + + createPage: async (spaceId, input: CreatePageInput) => { + const created = await restPost(`${apiUrl()}/spaces/${spaceId}/pages`, { + title: input.title.trim(), + parent_id: input.parentId || undefined, + }); + return toPage(created); + }, }; diff --git a/webapp/src/data/collapsed_pages.ts b/webapp/src/data/collapsed_pages.ts new file mode 100644 index 0000000..e6a6788 --- /dev/null +++ b/webapp/src/data/collapsed_pages.ts @@ -0,0 +1,49 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// Client-side "collapsed pages" store for the page tree. Which nodes a user has +// collapsed is a per-user UI preference with no server model, so it lives in +// localStorage (mirroring data/recent_spaces). Stored as a flat list of the +// collapsed page ids. + +const KEY_PREFIX = 'docs_collapsed_pages_'; + +// Per-user so multiple accounts on one browser don't share collapse state. +const storageKey = (userId: string): string => `${KEY_PREFIX}${userId}`; + +const read = (userId: string): string[] => { + try { + const raw = window.localStorage.getItem(storageKey(userId)); + return raw ? JSON.parse(raw) as string[] : []; + } catch { + // Storage unavailable (private mode / quota) or corrupt — treat as empty. + return []; + } +}; + +const write = (userId: string, ids: string[]): void => { + try { + window.localStorage.setItem(storageKey(userId), JSON.stringify(ids)); + } catch { + // Best-effort; collapse state is non-critical, so a write failure is ignored. + } +}; + +export function getCollapsed(userId: string): Set { + return new Set(read(userId)); +} + +export function isCollapsed(userId: string, pageId: string): boolean { + return read(userId).includes(pageId); +} + +export function toggleCollapsed(userId: string, pageId: string): Set { + const next = getCollapsed(userId); + if (next.has(pageId)) { + next.delete(pageId); + } else { + next.add(pageId); + } + write(userId, [...next]); + return next; +} diff --git a/webapp/src/data/docs_data_source.ts b/webapp/src/data/docs_data_source.ts index 8e1632e..474dca5 100644 --- a/webapp/src/data/docs_data_source.ts +++ b/webapp/src/data/docs_data_source.ts @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import type {CreateSpaceInput, Page, Space, SpaceMember} from 'types/docs'; +import type {CreatePageInput, CreateSpaceInput, Page, Space, SpaceMember} from 'types/docs'; // The seam between the store's thunks and the Docs server REST API. The // API-backed source implements this over the plugin's /api/v1 routes; the @@ -35,4 +35,14 @@ export interface DocsDataSource { // Pages in a space. The server returns page summaries (no body); the source // normalizes them to Page with an empty body for the store. listPages(spaceId: string): Promise; + + // Reparents and/or reorders a page. `parentId` is the new parent id ('' = + // space root); `siblingIndex` is the 0-based position within the new parent. + // `expectedUpdateAt` is the moved page's current update_at for optimistic + // concurrency. Returns the moved page. + movePage(spaceId: string, pageId: string, parentId: string, siblingIndex: number, expectedUpdateAt: number): Promise; + + // Creates a page in a space (optionally under a parent) and returns it (with + // its server-assigned id and sort_order). + createPage(spaceId: string, input: CreatePageInput): Promise; } diff --git a/webapp/src/hooks/page_tree.ts b/webapp/src/hooks/page_tree.ts new file mode 100644 index 0000000..b5690b0 --- /dev/null +++ b/webapp/src/hooks/page_tree.ts @@ -0,0 +1,26 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {getCollapsed, toggleCollapsed} from 'data/collapsed_pages'; +import {useAppSelector} from 'hooks/redux'; +import {useCallback, useState} from 'react'; + +import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; + +type CollapsedPages = { + collapsed: Set; + toggle: (pageId: string) => void; +}; + +// Owns the current user's collapsed-node set for the page tree, backed by +// localStorage (see data/collapsed_pages). Toggling persists and re-renders. +export function useCollapsedPages(): CollapsedPages { + const userId = useAppSelector(getCurrentUserId); + const [collapsed, setCollapsed] = useState>(() => getCollapsed(userId)); + + const toggle = useCallback((pageId: string) => { + setCollapsed(toggleCollapsed(userId, pageId)); + }, [userId]); + + return {collapsed, toggle}; +} diff --git a/webapp/src/store/action_types.ts b/webapp/src/store/action_types.ts index 6c885e6..cbe8812 100644 --- a/webapp/src/store/action_types.ts +++ b/webapp/src/store/action_types.ts @@ -12,4 +12,5 @@ export const SpaceTypes = { export const PageTypes = { RECEIVED_PAGES: manifest.id + '_received_pages', + MOVED_PAGE: manifest.id + '_moved_page', } as const; diff --git a/webapp/src/store/actions.ts b/webapp/src/store/actions.ts index e2f92df..520501a 100644 --- a/webapp/src/store/actions.ts +++ b/webapp/src/store/actions.ts @@ -6,10 +6,11 @@ import {docsDataSource} from 'data'; import {getCurrentTeamId, getMyTeams} from 'mattermost-redux/selectors/entities/teams'; import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; -import type {CreateSpaceInput, Space} from 'types/docs'; +import type {CreatePageInput, CreateSpaceInput, Page, Space} from 'types/docs'; import type {DocsThunkAction} from 'types/store'; import {PageTypes, SpaceTypes} from './action_types'; +import {getPage} from './selectors'; // Spaces the caller belongs to in the current team (the server scopes the list // by backing-channel membership). A failed load leaves the store empty rather @@ -59,6 +60,41 @@ export function fetchPages(spaceId: string): DocsThunkAction> { }; } +// Reparents/reorders a page. Optimistically reindexes the store, then reconciles +// with the server-returned page. On failure it re-fetches the space's pages to +// restore server truth. siblingIndex is 0-based within the new parent; +// parentId '' is the space root. +export function movePage(spaceId: string, pageId: string, parentId: string, siblingIndex: number): DocsThunkAction> { + return async (dispatch, getState) => { + const page = getPage(getState(), pageId); + if (!page) { + return; + } + const expectedUpdateAt = page.update_at; + + dispatch({type: PageTypes.MOVED_PAGE, pageId, spaceId, parentId, siblingIndex}); + + try { + const moved = await docsDataSource.movePage(spaceId, pageId, parentId, siblingIndex, expectedUpdateAt); + dispatch({type: PageTypes.RECEIVED_PAGES, pages: [moved]}); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Docs: failed to move page', error); + dispatch(fetchPages(spaceId)); + } + }; +} + +// Creates a page in a space (optionally under a parent) and returns the +// server-assigned entity (rejects on failure so the caller can surface it). +export function createPage(spaceId: string, input: CreatePageInput): DocsThunkAction> { + return async (dispatch) => { + const page = await docsDataSource.createPage(spaceId, input); + dispatch({type: PageTypes.RECEIVED_PAGES, pages: [page]}); + return page; + }; +} + // Loads a space's members (user ids) into the store, backing the member count. export function fetchSpaceMembers(spaceId: string): DocsThunkAction> { return async (dispatch) => { diff --git a/webapp/src/store/page_tree.test.ts b/webapp/src/store/page_tree.test.ts new file mode 100644 index 0000000..f9121e1 --- /dev/null +++ b/webapp/src/store/page_tree.test.ts @@ -0,0 +1,53 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {buildDescendantMap, buildPageTree} from './page_tree'; +import {makePage} from './test_fixtures'; + +const child = (id: string, parentId: string, title: string, sortOrder = 0) => ({ + ...makePage(id, 'space-a', title, sortOrder), + parent_id: parentId, +}); + +describe('buildPageTree', () => { + it('nests children under parents and orders each group by sort_order', () => { + const root1 = makePage('r1', 'space-a', 'Root 1', 1); + const root2 = makePage('r2', 'space-a', 'Root 2', 0); + const c1 = child('c1', 'r1', 'Child 1', 1); + const c2 = child('c2', 'r1', 'Child 2', 0); + const g1 = child('g1', 'c2', 'Grandchild', 0); + + const tree = buildPageTree([root1, root2, c1, c2, g1]); + + expect(tree.map((node) => node.page.id)).toEqual(['r2', 'r1']); + + const r1Node = tree.find((node) => node.page.id === 'r1')!; + expect(r1Node.depth).toBe(0); + expect(r1Node.children.map((node) => node.page.id)).toEqual(['c2', 'c1']); + + const c2Node = r1Node.children.find((node) => node.page.id === 'c2')!; + expect(c2Node.depth).toBe(1); + expect(c2Node.children.map((node) => node.page.id)).toEqual(['g1']); + expect(c2Node.children[0].depth).toBe(2); + }); + + it('treats pages with a missing parent as roots', () => { + const orphan = child('o1', 'gone', 'Orphan', 0); + const tree = buildPageTree([orphan]); + expect(tree.map((node) => node.page.id)).toEqual(['o1']); + }); +}); + +describe('buildDescendantMap', () => { + it('collects the full subtree id set for each node', () => { + const root = makePage('r1', 'space-a', 'Root', 0); + const c1 = child('c1', 'r1', 'Child', 0); + const g1 = child('g1', 'c1', 'Grandchild', 0); + + const map = buildDescendantMap(buildPageTree([root, c1, g1])); + + expect(map.get('r1')).toEqual(new Set(['c1', 'g1'])); + expect(map.get('c1')).toEqual(new Set(['g1'])); + expect(map.get('g1')).toEqual(new Set()); + }); +}); diff --git a/webapp/src/store/page_tree.ts b/webapp/src/store/page_tree.ts new file mode 100644 index 0000000..ff16e3d --- /dev/null +++ b/webapp/src/store/page_tree.ts @@ -0,0 +1,62 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {Page} from 'types/docs'; + +// A node in the page tree: the page, its ordered children, and its depth from +// the roots (roots are depth 0). +export type PageNode = { + page: Page; + children: PageNode[]; + depth: number; +}; + +const bySortOrder = (a: Page, b: Page): number => + a.sort_order - b.sort_order || a.title.localeCompare(b.title); + +// Builds the page tree from a flat page list. Roots have parent_id === ''. +// Children are ordered by sort_order (title as a stable tiebreak). Pages whose +// parent isn't present are treated as roots so nothing is silently dropped. +export function buildPageTree(pages: Page[]): PageNode[] { + const byParent = new Map(); + const ids = new Set(pages.map((page) => page.id)); + + for (const page of pages) { + const parentId = ids.has(page.parent_id) ? page.parent_id : ''; + const group = byParent.get(parentId); + if (group) { + group.push(page); + } else { + byParent.set(parentId, [page]); + } + } + + const build = (parentId: string, depth: number): PageNode[] => + (byParent.get(parentId) ?? []). + slice(). + sort(bySortOrder). + map((page) => ({page, children: build(page.id, depth + 1), depth})); + + return build('', 0); +} + +// Maps each page id to the set of its descendant ids (excluding itself). Backs +// the drag guard that forbids dropping a page into its own subtree. +export function buildDescendantMap(roots: PageNode[]): Map> { + const map = new Map>(); + + const collect = (node: PageNode): Set => { + const descendants = new Set(); + for (const child of node.children) { + descendants.add(child.page.id); + for (const id of collect(child)) { + descendants.add(id); + } + } + map.set(node.page.id, descendants); + return descendants; + }; + + roots.forEach(collect); + return map; +} diff --git a/webapp/src/store/reducer.test.ts b/webapp/src/store/reducer.test.ts index 3fae465..eed937f 100644 --- a/webapp/src/store/reducer.test.ts +++ b/webapp/src/store/reducer.test.ts @@ -2,9 +2,14 @@ // See LICENSE.txt for license information. import {PageTypes, SpaceTypes} from './action_types'; -import reducer from './reducer'; +import reducer, {reindexAfterMove} from './reducer'; import {makePage, makeSpace} from './test_fixtures'; +const withParent = (id: string, parentId: string, title: string, sortOrder: number) => ({ + ...makePage(id, 'space-a', title, sortOrder), + parent_id: parentId, +}); + describe('spaces', () => { const initialState = reducer(undefined, {type: '@@INIT'}); @@ -74,4 +79,61 @@ describe('pages', () => { expect(afterDelete.pagesInSpace['space-a']).toBeUndefined(); expect(afterDelete.pagesInSpace['space-b']).toEqual(new Set(['p2'])); }); + + it('MOVED_PAGE reindexes the moved page within the store', () => { + const a = withParent('a', '', 'A', 0); + const b = withParent('b', '', 'B', 1); + const c = withParent('c', '', 'C', 2); + + const afterReceive = reducer(initialState, {type: PageTypes.RECEIVED_PAGES, pages: [a, b, c]}); + const afterMove = reducer(afterReceive, { + type: PageTypes.MOVED_PAGE, + pageId: 'c', + spaceId: 'space-a', + parentId: '', + siblingIndex: 0, + }); + + expect(afterMove.pages.c.sort_order).toBe(0); + expect(afterMove.pages.a.sort_order).toBe(1); + expect(afterMove.pages.b.sort_order).toBe(2); + }); +}); + +describe('reindexAfterMove', () => { + it('reorders siblings within the same parent (0-based)', () => { + const byId = { + a: withParent('a', '', 'A', 0), + b: withParent('b', '', 'B', 1), + c: withParent('c', '', 'C', 2), + }; + + // Move A to the end. + const next = reindexAfterMove(byId, 'a', 'space-a', '', 2); + + expect(next.b.sort_order).toBe(0); + expect(next.c.sort_order).toBe(1); + expect(next.a.sort_order).toBe(2); + expect(next.a.parent_id).toBe(''); + }); + + it('reparents a page and reindexes both the old and new sibling groups', () => { + const byId = { + p: withParent('p', '', 'Parent', 0), + a: withParent('a', '', 'A', 1), + b: withParent('b', '', 'B', 2), + x: withParent('x', 'p', 'X', 0), + }; + + // Move B under P as its first child. + const next = reindexAfterMove(byId, 'b', 'space-a', 'p', 0); + + expect(next.b.parent_id).toBe('p'); + expect(next.b.sort_order).toBe(0); + expect(next.x.sort_order).toBe(1); + + // Old root group renumbers to fill the gap. + expect(next.p.sort_order).toBe(0); + expect(next.a.sort_order).toBe(1); + }); }); diff --git a/webapp/src/store/reducer.ts b/webapp/src/store/reducer.ts index f38329c..0e11316 100644 --- a/webapp/src/store/reducer.ts +++ b/webapp/src/store/reducer.ts @@ -12,8 +12,50 @@ type ReceivedSpacesAction = {spaces: Space[]}; type CreatedSpaceAction = {space: Space}; type DeletedSpaceAction = {spaceId: string}; type ReceivedPagesAction = {pages: Page[]}; +type MovedPageAction = {pageId: string; spaceId: string; parentId: string; siblingIndex: number}; type ReceivedSpaceMembersAction = {spaceId: string; userIds: string[]}; +const bySortOrder = (a: Page, b: Page): number => + a.sort_order - b.sort_order || a.title.localeCompare(b.title); + +// Moves a page under `newParentId` at `siblingIndex` and renumbers the affected +// sibling groups' sort_order 0-based, mirroring the server's reindex. Returns a +// new byId map (untouched pages are shared by reference). Only pages in +// `spaceId` are considered; a pure reorder (same parent) skips the old group. +export function reindexAfterMove( + byId: Record, + pageId: string, + spaceId: string, + newParentId: string, + siblingIndex: number, +): Record { + const moved = byId[pageId]; + if (!moved) { + return byId; + } + const oldParentId = moved.parent_id; + const next = {...byId}; + + const groupOf = (parentId: string): Page[] => Object.values(byId). + filter((page) => page.space_id === spaceId && page.parent_id === parentId && page.id !== pageId). + sort(bySortOrder); + + const newGroup = groupOf(newParentId); + const index = Math.max(0, Math.min(siblingIndex, newGroup.length)); + newGroup.splice(index, 0, moved); + newGroup.forEach((page, i) => { + next[page.id] = {...page, parent_id: newParentId, sort_order: i}; + }); + + if (oldParentId !== newParentId) { + groupOf(oldParentId).forEach((page, i) => { + next[page.id] = {...page, sort_order: i}; + }); + } + + return next; +} + // SpaceTypes'/PageTypes' values aren't string-literal types (manifest.id is // loaded via JSON.parse), so `action.type` can't discriminate a union by // itself — each case casts to its own shape, mirroring the core channels @@ -107,6 +149,13 @@ function pages(state: Record = {}, action: UnknownAction): Record< } return next; } + case PageTypes.MOVED_PAGE: { + const {pageId, spaceId, parentId, siblingIndex} = action as unknown as MovedPageAction; + if (!(pageId in state)) { + return state; + } + return reindexAfterMove(state, pageId, spaceId, parentId, siblingIndex); + } case SpaceTypes.DELETED_SPACE: { const {spaceId} = action as unknown as DeletedSpaceAction; const remaining = Object.entries(state).filter(([, page]) => page.space_id !== spaceId); diff --git a/webapp/src/types/docs.ts b/webapp/src/types/docs.ts index ad932f2..a6470e7 100644 --- a/webapp/src/types/docs.ts +++ b/webapp/src/types/docs.ts @@ -58,6 +58,13 @@ export type Page = { delete_at: number; }; +// The fields needed to create a page. parentId is omitted for a root page (the +// data source maps it to the server's parent_id). +export type CreatePageInput = { + title: string; + parentId?: string; +}; + export type SpaceSummary = { space: Space; From 08c30e5a9e43c6eb9316022771b213e0fb3e8373 Mon Sep 17 00:00:00 2001 From: Caleb Roseland Date: Mon, 27 Jul 2026 20:19:07 -0500 Subject: [PATCH 11/68] feat(docs): people-search combobox for Share, gated on canManageMembers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a real debounced people-search pipeline and a Base UI Autocomplete combobox for the Share modal, gated on a named permission so nothing misleading ships. There is no server add-member API yet (roles/view-access + capabilities land with PR #10), so a live "add people" control could only hold selections client-side. The picker renders only when canManageMembers. - store/permissions: getSpacePermissions(state, spaceId) selector + useSpacePermissions(spaceId) hook — the get/use split from core's channel_bookmarks/utils — returning named SpacePermissions {canManageMembers}; the seam PR #10's capability set drops into - hooks/user_search: useUserSearch — 300ms-debounced searchProfiles scoped to the current team, mapped to MemberProfile, excludes already-shown ids - share_space_modal/people_picker: Base UI Autocomplete (server-driven list, host Avatar rows, empty/searching states) - share_space_modal: render the picker only when canManageMembers --- webapp/i18n/en.json | 2 + .../people_picker.module.scss | 110 ++++++++++++++++++ .../share_space_modal/people_picker.tsx | 108 +++++++++++++++++ .../share_space_modal/share_space_modal.tsx | 46 +++++--- webapp/src/hooks/user_search.ts | 82 +++++++++++++ webapp/src/store/permissions.ts | 35 ++++++ 6 files changed, 367 insertions(+), 16 deletions(-) create mode 100644 webapp/src/components/share_space_modal/people_picker.module.scss create mode 100644 webapp/src/components/share_space_modal/people_picker.tsx create mode 100644 webapp/src/hooks/user_search.ts create mode 100644 webapp/src/store/permissions.ts diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json index 68e1a67..8b1f548 100644 --- a/webapp/i18n/en.json +++ b/webapp/i18n/en.json @@ -56,8 +56,10 @@ "docs.share.access.canView": "Can View", "docs.share.copyLink": "Copy link", "docs.share.handle": "@{username}", + "docs.share.noResults": "No people found", "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", diff --git a/webapp/src/components/share_space_modal/people_picker.module.scss b/webapp/src/components/share_space_modal/people_picker.module.scss new file mode 100644 index 0000000..0a6ce64 --- /dev/null +++ b/webapp/src/components/share_space_modal/people_picker.module.scss @@ -0,0 +1,110 @@ +.control { + position: relative; + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + border: 1px solid rgba(var(--center-channel-color-rgb), 0.16); + border-radius: 4px; + background: var(--center-channel-bg); + + &:focus-within { + padding: 11px 15px; + border: 2px solid var(--button-bg); + } +} + +.searchIcon { + flex-shrink: 0; + color: rgba(var(--center-channel-color-rgb), 0.64); +} + +.input { + flex: 1 1 auto; + min-width: 0; + padding: 0; + border: none; + background: transparent; + color: var(--center-channel-color); + font-family: 'Open Sans', sans-serif; + font-size: 14px; + line-height: 20px; + + &:focus { + outline: none; + } + + &::placeholder { + color: rgba(var(--center-channel-color-rgb), 0.64); + } +} + +.positioner { + z-index: var(--z-index-menu); + width: var(--anchor-width); +} + +.popup { + max-height: min(320px, var(--available-height)); + padding: 8px 0; + overflow-y: auto; + 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); + + &:focus { + outline: none; + } +} + +.empty { + padding: 8px 16px; + color: rgba(var(--center-channel-color-rgb), 0.64); + font-size: 14px; + line-height: 20px; + + &:empty { + display: none; + } +} + +.item { + display: flex; + align-items: center; + gap: 12px; + padding: 6px 16px; + color: var(--center-channel-color); + font-family: 'Open Sans', sans-serif; + font-size: 14px; + line-height: 20px; + cursor: pointer; + user-select: none; + + &:hover, + &[data-highlighted] { + background: rgba(var(--center-channel-color-rgb), 0.08); + outline: none; + } +} + +.itemInfo { + display: flex; + flex: 1 1 0; + min-width: 0; + align-items: baseline; + gap: 6px; +} + +.itemName { + color: var(--center-channel-color); + font-weight: 600; + white-space: nowrap; +} + +.itemUsername { + overflow: hidden; + color: rgba(var(--center-channel-color-rgb), 0.64); + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/webapp/src/components/share_space_modal/people_picker.tsx b/webapp/src/components/share_space_modal/people_picker.tsx new file mode 100644 index 0000000..421a94c --- /dev/null +++ b/webapp/src/components/share_space_modal/people_picker.tsx @@ -0,0 +1,108 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Autocomplete} from '@base-ui-components/react/autocomplete'; +import type {MemberProfile} from 'hooks/members'; +import {useUserSearch} from 'hooks/user_search'; +import React, {useState} from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; +import {Avatar} from 'webapp_globals'; + +import MagnifyIcon from '@mattermost/compass-icons/components/magnify'; + +import styles from './people_picker.module.scss'; + +type Props = { + excludeIds: string[]; + onSelect: (user: MemberProfile) => void; +}; + +// Searchable people combobox for the share modal. Built on Base UI's Autocomplete +// (mode='none' so the list is driven by the server search, not client filtering). +// Picking a result fires onSelect and clears the query. +const PeoplePicker = ({excludeIds, onSelect}: Props) => { + const {formatMessage} = useIntl(); + const [query, setQuery] = useState(''); + const {results, loading} = useUserSearch(query, excludeIds); + + const placeholder = formatMessage({id: 'docs.share.search', defaultMessage: 'Add people or groups'}); + + const pick = (user: MemberProfile) => { + onSelect(user); + setQuery(''); + }; + + return ( + +
+ + +
+ + + + + {loading ? ( + + ) : ( + + )} + + + {(user: MemberProfile) => ( + pick(user)} + > + + + {user.displayName} + {user.username && ( + + + + )} + + + )} + + + + +
+ ); +}; + +export default PeoplePicker; diff --git a/webapp/src/components/share_space_modal/share_space_modal.tsx b/webapp/src/components/share_space_modal/share_space_modal.tsx index 1626006..775eab3 100644 --- a/webapp/src/components/share_space_modal/share_space_modal.tsx +++ b/webapp/src/components/share_space_modal/share_space_modal.tsx @@ -1,10 +1,11 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import type {MemberProfile} from 'hooks/members'; import {useSpaceMemberProfiles} from 'hooks/members'; import {useDocsNavigation} from 'hooks/navigation'; import {useAppSelector} from 'hooks/redux'; -import React, {useState} from 'react'; +import React, {useMemo, useState} from 'react'; import {FormattedMessage, useIntl} from 'react-intl'; import {copyToClipboard} from 'utils/clipboard'; import {Avatar} from 'webapp_globals'; @@ -12,16 +13,17 @@ import {Avatar} from 'webapp_globals'; import ChevronDownIcon from '@mattermost/compass-icons/components/chevron-down'; import ContentCopyIcon from '@mattermost/compass-icons/components/content-copy'; import GlobeIcon from '@mattermost/compass-icons/components/globe'; -import MagnifyIcon from '@mattermost/compass-icons/components/magnify'; import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; +import {useSpacePermissions} from 'store/permissions'; + import {Button, SecondaryButton} from 'components/form-controls/button'; -import TextInput from 'components/form-controls/text_input'; import GenericModal from 'components/generic_modal/generic_modal'; import type {Space} from 'types/docs'; +import PeoplePicker from './people_picker'; import styles from './share_space_modal.module.scss'; type Props = { @@ -29,17 +31,30 @@ type Props = { onClose: () => void; }; -// Members come from the real member API; roles and space visibility (Admin / -// Public / Can View) are capability/view-access features from PR #10, so those -// dropdowns and the add-people search are visual scaffolding for now. Copy link -// is functional. +// Members come from the real member API and Copy link is functional. Roles and +// space visibility (Admin / Public / Can View) are capability/view-access +// features from PR #10, so those dropdowns are visual scaffolding. +// +// Adding people needs a server add-member API (also PR #10). The people-search +// combobox and its live search pipeline are built, but gated on the +// canManageMembers permission so we don't ship an "add people" control whose +// selections can't persist (they'd only live client-side). const ShareSpaceModal = ({space, onClose}: Props) => { const {formatMessage} = useIntl(); const {paths} = useDocsNavigation(); const members = useSpaceMemberProfiles(space.id); const currentUserId = useAppSelector(getCurrentUserId); + const {canManageMembers} = useSpacePermissions(space.id); + + // People chosen from the search picker. There's no add-member API yet + // (roles/view-access land with PR #10), so these are held client-side and + // shown alongside the real members until the server can persist them. + const [added, setAdded] = useState([]); + + const displayedMembers = useMemo(() => [...members, ...added], [members, added]); + const excludeIds = useMemo(() => displayedMembers.map((member) => member.id), [displayedMembers]); - const [query, setQuery] = useState(''); + const addPerson = (user: MemberProfile) => setAdded((prev) => [...prev, user]); const copyLink = () => copyToClipboard(`${window.location.origin}${paths.space(space.id)}`); @@ -111,15 +126,14 @@ const ShareSpaceModal = ({space, onClose}: Props) => { footer={footer} >
- } - /> + {canManageMembers && ( + + )}
- {members.map((member) => ( + {displayedMembers.map((member) => (
([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + const trimmed = term.trim(); + if (!trimmed) { + setProfiles([]); + setLoading(false); + return undefined; + } + + setLoading(true); + let cancelled = false; + + const handle = window.setTimeout(async () => { + try { + const {data} = await dispatch(searchProfiles(trimmed, {team_id: teamId, limit: SEARCH_LIMIT})); + if (!cancelled) { + setProfiles(data ?? []); + } + } catch { + if (!cancelled) { + setProfiles([]); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + }, SEARCH_DEBOUNCE_MS); + + return () => { + cancelled = true; + window.clearTimeout(handle); + }; + }, [dispatch, term, teamId]); + + const exclude = useMemo(() => new Set(excludeIds), [excludeIds]); + + const results = useMemo(() => profiles. + filter((profile) => !exclude.has(profile.id)). + map((profile) => ({ + id: profile.id, + displayName: displayUsername(profile, nameDisplay), + username: profile.username, + avatarUrl: Client4.getProfilePictureUrl(profile.id, profile.last_picture_update), + })), [profiles, exclude, nameDisplay]); + + return {results, loading}; +} diff --git a/webapp/src/store/permissions.ts b/webapp/src/store/permissions.ts new file mode 100644 index 0000000..f8ebd95 --- /dev/null +++ b/webapp/src/store/permissions.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useAppSelector} from 'hooks/redux'; + +import type {GlobalState} from '@mattermost/types/store'; + +// Named space permissions for the current user. Capabilities/RBAC land with +// PR #10 (a per-member capability set on the space's backing-channel +// membership); until then everything is a conservative default, so the UI never +// offers an action the server can't yet honor. +export type SpacePermissions = { + + // Add or remove space members. Needs the add-member API + capabilities from + // PR #10. + canManageMembers: boolean; +}; + +const NO_PERMISSIONS: SpacePermissions = { + canManageMembers: false, +}; + +// Pure selector so a check works in thunks/other selectors too, mirroring +// core's getHaveIChannelBookmarkPermission (channel_bookmarks/utils). PR #10's +// capability set will feed this, keyed by spaceId; today it returns the safe +// defaults. Returns a stable reference so useSelector doesn't re-render. +// eslint-disable-next-line @typescript-eslint/no-unused-vars -- state/spaceId are the API the real capability lookup (PR #10) will use +export function getSpacePermissions(state: GlobalState, spaceId: string): SpacePermissions { + return NO_PERMISSIONS; +} + +// Thin hook wrapper, matching core's useChannelBookmarkPermission. +export function useSpacePermissions(spaceId: string): SpacePermissions { + return useAppSelector((state) => getSpacePermissions(state, spaceId)); +} From 631754c86b704e39f42293fa17cae93d2878b268 Mon Sep 17 00:00:00 2001 From: Caleb Roseland Date: Mon, 27 Jul 2026 21:18:20 -0500 Subject: [PATCH 12/68] chore(docs): dev-ungate add-members; member count opens Share - permissions: a DEV override forces canManageMembers on so the add-people picker is exercisable before PR #10's capability set exists. The get/use permission hooks are unchanged; revert the default to false (and wire the real capability source) before shipping. - space title bar: the member count is now a button that opens the Share modal, the same as the Share button. --- webapp/i18n/en.json | 1 + .../src/components/space_view/space_title_bar.tsx | 12 ++++++++++-- webapp/src/store/permissions.ts | 13 ++++++++----- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json index 8b1f548..9a7de24 100644 --- a/webapp/i18n/en.json +++ b/webapp/i18n/en.json @@ -91,6 +91,7 @@ "docs.space.edit": "Edit", "docs.space.expand": "Expand", "docs.space.favorite": "Favorite this space", + "docs.space.membersButton": "Members", "docs.space.membersOverflow": "+{count}", "docs.space.menu": "Space options", "docs.space.more": "More actions", diff --git a/webapp/src/components/space_view/space_title_bar.tsx b/webapp/src/components/space_view/space_title_bar.tsx index 8fb4d85..c7f8f07 100644 --- a/webapp/src/components/space_view/space_title_bar.tsx +++ b/webapp/src/components/space_view/space_title_bar.tsx @@ -28,6 +28,7 @@ const SpaceTitleBar = ({space, memberCount}: {space: Space; memberCount: number} const favoriteLabel = formatMessage({id: 'docs.space.favorite', defaultMessage: 'Favorite this space'}); const menuLabel = formatMessage({id: 'docs.space.menu', defaultMessage: 'Space options'}); const detailsLabel = formatMessage({id: 'docs.space.details', defaultMessage: 'Space details'}); + const membersLabel = formatMessage({id: 'docs.space.membersButton', defaultMessage: 'Members'}); return (
@@ -63,10 +64,17 @@ const SpaceTitleBar = ({space, memberCount}: {space: Space; memberCount: number} size={16} /> - +
diff --git a/webapp/src/store/permissions.ts b/webapp/src/store/permissions.ts index f8ebd95..a2cb96a 100644 --- a/webapp/src/store/permissions.ts +++ b/webapp/src/store/permissions.ts @@ -16,17 +16,20 @@ export type SpacePermissions = { canManageMembers: boolean; }; -const NO_PERMISSIONS: SpacePermissions = { - canManageMembers: false, +// DEV OVERRIDE: canManageMembers is forced on so the add-people flow is +// exercisable before PR #10's capability set exists. Revert to false (and wire +// the real capability source below) before shipping. +const DEFAULT_PERMISSIONS: SpacePermissions = { + canManageMembers: true, }; // Pure selector so a check works in thunks/other selectors too, mirroring // core's getHaveIChannelBookmarkPermission (channel_bookmarks/utils). PR #10's -// capability set will feed this, keyed by spaceId; today it returns the safe -// defaults. Returns a stable reference so useSelector doesn't re-render. +// capability set will feed this, keyed by spaceId. Returns a stable reference so +// useSelector doesn't re-render. // eslint-disable-next-line @typescript-eslint/no-unused-vars -- state/spaceId are the API the real capability lookup (PR #10) will use export function getSpacePermissions(state: GlobalState, spaceId: string): SpacePermissions { - return NO_PERMISSIONS; + return DEFAULT_PERMISSIONS; } // Thin hook wrapper, matching core's useChannelBookmarkPermission. From 2c11e33a38a3c82ef01467d837aaa29d5f51f3ba Mon Sep 17 00:00:00 2001 From: Caleb Roseland Date: Mon, 27 Jul 2026 21:31:22 -0500 Subject: [PATCH 13/68] fix(docs): neutral grey for btn-icon buttons (port-to-core) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared Button is accent-only: every emphasis (quaternary etc.) colors with --button-bg, and its rule follows .btn-icon in core's _buttons.scss, so ` - - + +
+ +
+
+ + + + {space.title} +
+ +
+

+ +

+ {space.description ? ( +

{space.description}

+ ) : ( +

+ +

+ )} +
+ +
+

+ + {memberCount} +

+
+ {members.map((member) => ( +
+ + + {member.displayName} + {member.username && ( + + + + )} + +
+ ))} +
+
+ +
+
+
+
+ +
+
{pageCount}
+
+ {createdRelative && ( +
+
+ +
+
{createdRelative}
+
+ )} +
+
+
+ + ); +}; + +export default SpaceInfoPanel; diff --git a/webapp/src/components/space_settings_modal/space_settings_modal.module.scss b/webapp/src/components/space_settings_modal/space_settings_modal.module.scss new file mode 100644 index 0000000..8f52eba --- /dev/null +++ b/webapp/src/components/space_settings_modal/space_settings_modal.module.scss @@ -0,0 +1,245 @@ +.modal { + width: 720px; + max-width: calc(100vw - 48px); +} + +.subtitle { + display: flex; + align-items: center; + gap: 8px; + color: rgba(var(--center-channel-color-rgb), 0.64); + font-size: 13px; + font-weight: 600; +} + +.subtitleIcon { + display: flex; + align-items: center; + justify-content: center; +} + +.body { + display: flex; + min-height: 440px; + overflow: hidden; +} + +.nav { + display: flex; + flex: 0 0 208px; + flex-direction: column; + gap: 2px; + padding: 16px 12px; + border-right: 1px solid rgba(var(--center-channel-color-rgb), 0.12); +} + +.navItem { + justify-content: flex-start; + width: 100%; + gap: 10px; + color: rgba(var(--center-channel-color-rgb), 0.75); + font-weight: 600; + text-align: left; + + &:hover { + background: rgba(var(--center-channel-color-rgb), 0.08); + } +} + +.navItemActive { + background: rgba(var(--button-bg-rgb), 0.08); + color: var(--button-bg); + + &:hover { + background: rgba(var(--button-bg-rgb), 0.12); + } +} + +.navItemDestructive { + color: var(--error-text); + + &:hover { + background: rgba(var(--error-text-color-rgb), 0.08); + } +} + +.navLabel { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pane { + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: 16px; + padding: 24px 32px; + overflow-y: auto; +} + +.heading { + margin: 0; + color: var(--center-channel-color); + font-family: 'Metropolis', sans-serif; + font-size: 18px; + font-weight: 600; + line-height: 24px; +} + +.fieldLabel { + margin-bottom: -8px; + color: rgba(var(--center-channel-color-rgb), 0.75); + font-size: 13px; + font-weight: 600; +} + +.helper { + margin-top: -8px; + color: rgba(var(--center-channel-color-rgb), 0.64); + font-size: 12px; + line-height: 16px; +} + +.urlRow { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 12px; + border: 1px solid rgba(var(--center-channel-color-rgb), 0.16); + border-radius: 4px; + background: rgba(var(--center-channel-color-rgb), 0.04); +} + +.urlText { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + color: rgba(var(--center-channel-color-rgb), 0.75); + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.selectStub { + justify-content: space-between; + width: 100%; + padding: 8px 12px; + border: 1px solid rgba(var(--center-channel-color-rgb), 0.16); + border-radius: 4px; + color: rgba(var(--center-channel-color-rgb), 0.75); + font-weight: 600; +} + +.selectStubLabel { + display: flex; + flex: 1 1 auto; + min-width: 0; + align-items: center; + gap: 8px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.error { + color: var(--error-text); + font-size: 13px; +} + +.searchStub { + width: 100%; + padding: 10px 12px; + border: 1px solid rgba(var(--center-channel-color-rgb), 0.16); + border-radius: 4px; + background: rgba(var(--center-channel-color-rgb), 0.04); + color: rgba(var(--center-channel-color-rgb), 0.56); + font-size: 14px; +} + +.memberList { + display: flex; + flex-direction: column; +} + +.memberRow { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 0; +} + +.memberInfo { + display: flex; + flex: 1 1 auto; + min-width: 0; + align-items: baseline; + gap: 6px; +} + +.memberName { + color: var(--center-channel-color); + font-size: 14px; + font-weight: 600; + white-space: nowrap; +} + +.memberUsername { + overflow: hidden; + color: rgba(var(--center-channel-color-rgb), 0.64); + font-size: 14px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.roleTrigger { + gap: 4px; + color: var(--link-color); + font-weight: 600; +} + +.toggleRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px 0; + border-top: 1px solid rgba(var(--center-channel-color-rgb), 0.12); +} + +.toggleText { + display: flex; + flex-direction: column; + gap: 2px; +} + +.toggleTitle { + color: var(--center-channel-color); + font-size: 14px; + font-weight: 600; +} + +.comingSoon { + color: rgba(var(--center-channel-color-rgb), 0.64); + font-size: 14px; + line-height: 20px; +} + +.archiveCard { + display: flex; + flex-direction: column; + gap: 12px; + align-items: flex-start; + padding: 16px; + border: 1px solid rgba(var(--error-text-color-rgb), 0.24); + border-radius: 4px; + background: rgba(var(--error-text-color-rgb), 0.04); +} + +.archiveCopy { + color: rgba(var(--center-channel-color-rgb), 0.75); + font-size: 14px; + line-height: 20px; +} diff --git a/webapp/src/components/space_settings_modal/space_settings_modal.test.tsx b/webapp/src/components/space_settings_modal/space_settings_modal.test.tsx new file mode 100644 index 0000000..2aeb26e --- /dev/null +++ b/webapp/src/components/space_settings_modal/space_settings_modal.test.tsx @@ -0,0 +1,92 @@ +// 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 {deleteSpace, updateSpace} from 'store/actions'; +import {makeSpace} from 'store/test_fixtures'; + +import SpaceSettingsModal from './space_settings_modal'; + +import {renderWithContext} from '../../../tests/react_testing_utils'; + +const mockGoHome = jest.fn(); + +jest.mock('store/actions', () => ({ + updateSpace: jest.fn(() => () => Promise.resolve()), + deleteSpace: jest.fn(() => () => Promise.resolve()), +})); + +const mockUpdateSpace = updateSpace as jest.Mock; +const mockDeleteSpace = deleteSpace as jest.Mock; + +jest.mock('hooks/navigation', () => ({ + useDocsNavigation: () => ({ + goHome: mockGoHome, + paths: {space: (id: string) => `/team/spaces/${id}`}, + }), +})); + +jest.mock('hooks/members', () => ({ + useSpaceMemberProfiles: () => [], +})); + +const space = makeSpace('space-1', 'Project Avalanche'); + +describe('SpaceSettingsModal', () => { + it('renders the title, space subtitle, and section tabs', () => { + renderWithContext( + , + ); + + expect(screen.getByRole('heading', {name: 'Space Settings'})).toBeInTheDocument(); + expect(screen.getByRole('button', {name: /Info/})).toBeInTheDocument(); + expect(screen.getByRole('button', {name: /Permissions/})).toBeInTheDocument(); + expect(screen.getByRole('button', {name: /Configuration/})).toBeInTheDocument(); + expect(screen.getByRole('button', {name: /Archive space/})).toBeInTheDocument(); + }); + + it('keeps Save disabled until a field changes, then dispatches updateSpace', () => { + const onClose = jest.fn(); + renderWithContext( + , + ); + + const save = screen.getByRole('button', {name: 'Save'}); + expect(save).toBeDisabled(); + + fireEvent.change(screen.getByLabelText('Space name'), {target: {value: 'Renamed'}}); + expect(save).toBeEnabled(); + + fireEvent.click(save); + expect(mockUpdateSpace).toHaveBeenCalledWith('space-1', {title: 'Renamed', description: ''}); + }); + + it('archives the space through a confirm dialog', async () => { + const onClose = jest.fn(); + renderWithContext( + , + ); + + // The left-nav tab and the in-card action both read "Archive space"; + // the card button is the later one in DOM order. + const archiveButtons = screen.getAllByRole('button', {name: 'Archive space'}); + fireEvent.click(archiveButtons[archiveButtons.length - 1]); + fireEvent.click(screen.getByRole('button', {name: 'Archive'})); + + expect(mockDeleteSpace).toHaveBeenCalledWith('space-1'); + await Promise.resolve(); + expect(mockGoHome).toHaveBeenCalled(); + }); +}); diff --git a/webapp/src/components/space_settings_modal/space_settings_modal.tsx b/webapp/src/components/space_settings_modal/space_settings_modal.tsx new file mode 100644 index 0000000..63f7e38 --- /dev/null +++ b/webapp/src/components/space_settings_modal/space_settings_modal.tsx @@ -0,0 +1,514 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import classNames from 'classnames'; +import {useSpaceMemberProfiles} from 'hooks/members'; +import {useDocsNavigation} from 'hooks/navigation'; +import {useAppDispatch} from 'hooks/redux'; +import React, {useMemo, useState} from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; +import {SpaceIcon} from 'utils/space_icon'; +import {Avatar} from 'webapp_globals'; + +import ArchiveOutlineIcon from '@mattermost/compass-icons/components/archive-outline'; +import ChevronDownIcon from '@mattermost/compass-icons/components/chevron-down'; +import CogOutlineIcon from '@mattermost/compass-icons/components/cog-outline'; +import GlobeIcon from '@mattermost/compass-icons/components/globe'; +import InformationOutlineIcon from '@mattermost/compass-icons/components/information-outline'; +import LockOutlineIcon from '@mattermost/compass-icons/components/lock-outline'; +import type IconProps from '@mattermost/compass-icons/components/props'; +import ShieldOutlineIcon from '@mattermost/compass-icons/components/shield-outline'; + +import {deleteSpace, updateSpace} from 'store/actions'; + +import ConfirmModal from 'components/confirm_modal/confirm_modal'; +import {Button, DestructiveButton, PrimaryButton, TertiaryButton} from 'components/form_controls/button'; +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} from 'types/docs'; + +import styles from './space_settings_modal.module.scss'; + +export type SpaceSettingsTab = 'info' | 'permissions' | 'configuration' | 'archive'; + +type Props = { + space: Space; + onClose: () => void; + initialTab?: SpaceSettingsTab; +}; + +type TabDef = { + id: SpaceSettingsTab; + label: string; + icon: React.ComponentType; + destructive?: boolean; +}; + +const SpaceSettingsModal = ({space, onClose, initialTab = 'info'}: Props) => { + const {formatMessage} = useIntl(); + const {paths} = useDocsNavigation(); + const [activeTab, setActiveTab] = useState(initialTab); + + const tabs: TabDef[] = [ + {id: 'info', label: formatMessage({id: 'docs.spaceSettings.tab.info', defaultMessage: 'Info'}), icon: InformationOutlineIcon}, + {id: 'permissions', label: formatMessage({id: 'docs.spaceSettings.tab.permissions', defaultMessage: 'Permissions'}), icon: ShieldOutlineIcon}, + {id: 'configuration', label: formatMessage({id: 'docs.spaceSettings.tab.configuration', defaultMessage: 'Configuration'}), icon: CogOutlineIcon}, + {id: 'archive', label: formatMessage({id: 'docs.spaceSettings.tab.archive', defaultMessage: 'Archive space'}), icon: ArchiveOutlineIcon, destructive: true}, + ]; + + const info = useInfoTab(space, onClose); + + const footer = activeTab === 'info' ? ( + <> + + + + + + + + ) : undefined; + + const subtitle = ( + + + + + {space.title} + + ); + + return ( + +
+ + +
+ {activeTab === 'info' && ( + + )} + {activeTab === 'permissions' && } + {activeTab === 'configuration' && } + {activeTab === 'archive' && ( + + )} +
+
+
+ ); +}; + +type InfoTabState = { + name: string; + setName: (value: string) => void; + description: string; + setDescription: (value: string) => void; + error?: string; + canSave: boolean; + save: () => void; +}; + +// Owns the editable Info-tab fields and the save flow so the footer (rendered by +// the modal shell) and the pane share one source of truth. +function useInfoTab(space: Space, onClose: () => void): InfoTabState { + const dispatch = useAppDispatch(); + const [name, setName] = useState(space.title); + const [description, setDescription] = useState(space.description ?? ''); + const [error, setError] = useState(); + const [saving, setSaving] = useState(false); + + const dirty = name.trim() !== space.title || description.trim() !== (space.description ?? ''); + const canSave = dirty && Boolean(name.trim()) && !saving; + + const save = async () => { + if (!canSave) { + return; + } + setSaving(true); + setError(undefined); + try { + await dispatch(updateSpace(space.id, {title: name.trim(), description: description.trim()})); + onClose(); + } catch (err) { + setSaving(false); + setError(err instanceof Error ? err.message : String(err)); + } + }; + + return {name, setName, description, setDescription, error, canSave, save}; +} + +const InfoTab = ({space, info, url}: {space: Space; info: InfoTabState; url: string}) => { + const {formatMessage} = useIntl(); + + return ( + <> +

+ +

+ + + )} + /> + +
+ +
+
+ {url} + +
+ +
+ +
+