diff --git a/src/components/Communicator/CommunicatorDialog/BoardDetails/BoardDetails.js b/src/components/Communicator/CommunicatorDialog/BoardDetails/BoardDetails.js new file mode 100644 index 000000000..82da2b590 --- /dev/null +++ b/src/components/Communicator/CommunicatorDialog/BoardDetails/BoardDetails.js @@ -0,0 +1,196 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { intlShape } from 'react-intl'; +import moment from 'moment'; +import { makeStyles } from '@material-ui/core/styles'; +import Typography from '@material-ui/core/Typography'; +import Button from '@material-ui/core/Button'; +import Divider from '@material-ui/core/Divider'; + +import BoardThumb from '../shared/BoardThumb'; +import BoardStatusChips from '../shared/BoardStatusChips'; +import { formatBoardLocale } from '../utils/boardLocale'; +import PremiumFeature from '../../../PremiumFeature'; +import messages from '../CommunicatorDialog.messages'; + +const useStyles = makeStyles((theme) => ({ + root: { + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(1.5), + padding: theme.spacing(1, 2) + }, + thumb: { + width: '100%', + height: 120, + borderRadius: theme.shape.borderRadius, + border: `1px solid ${theme.palette.divider}` + }, + title: { + fontWeight: 600 + }, + header: { + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(1) + }, + meta: { + display: 'flex', + flexWrap: 'wrap', + alignItems: 'center', + color: theme.palette.text.secondary + }, + separator: { + margin: theme.spacing(0, 0.75), + opacity: 0.5 + }, + actions: { + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(0.5) + }, + action: { + // Full-width, left-aligned, labelled. Comfortably above the 44px target + // minimum in both themes. + justifyContent: 'flex-start', + minHeight: 44, + textTransform: 'none' + }, + destructive: { + color: theme.palette.error.main + }, + empty: { + padding: theme.spacing(4, 2), + color: theme.palette.text.secondary, + textAlign: 'center' + } +})); + +const ActionButton = ({ action, busy, classes }) => { + const button = ( + + ); + + return action.premium ? {button} : button; +}; + +ActionButton.propTypes = { + action: PropTypes.object.isRequired, + busy: PropTypes.bool, + classes: PropTypes.object.isRequired +}; + +const BoardDetails = ({ + intl, + board, + actions, + busy, + communicator, + activeBoardId, + headingRef, + hideTitle +}) => { + const classes = useStyles(); + + if (!board) { + return ( +
+ + {intl.formatMessage(messages.selectBoardPrompt)} + +
+ ); + } + + const title = board.name || board.id; + const locale = formatBoardLocale(intl, board.locale); + const metaParts = [ + intl.formatMessage(messages.tilesQty, { qty: board.tiles.length }), + locale, + moment(board.lastEdited).format('DD/MM/YYYY') + ].filter(Boolean); + + return ( +
+ + +
+ {!hideTitle && ( + + {title} + + )} + + {metaParts.map((part, index) => ( + + {index > 0 && ( + + )} + {part} + + ))} + + {communicator && ( + + )} +
+ + + +
+ {actions.map((action) => ( + + ))} +
+
+ ); +}; + +BoardDetails.defaultProps = { + actions: [], + busy: false, + hideTitle: false +}; + +BoardDetails.propTypes = { + intl: intlShape.isRequired, + board: PropTypes.object, + actions: PropTypes.array, + busy: PropTypes.bool, + communicator: PropTypes.object, + activeBoardId: PropTypes.string, + headingRef: PropTypes.object, + hideTitle: PropTypes.bool +}; + +export default BoardDetails; diff --git a/src/components/Communicator/CommunicatorDialog/BoardDetails/BoardDetails.test.js b/src/components/Communicator/CommunicatorDialog/BoardDetails/BoardDetails.test.js new file mode 100644 index 000000000..f81e85bb8 --- /dev/null +++ b/src/components/Communicator/CommunicatorDialog/BoardDetails/BoardDetails.test.js @@ -0,0 +1,109 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import Button from '@material-ui/core/Button'; +import BoardDetails from './BoardDetails'; + +jest.mock('../CommunicatorDialog.messages', () => ({ + __esModule: true, + default: new Proxy( + {}, + { + get: (target, prop) => ({ + id: String(prop), + defaultMessage: String(prop) + }) + } + ) +})); + +jest.mock('../shared/BoardThumb', () => ({ + __esModule: true, + default: () => null +})); + +const intl = { formatMessage: ({ defaultMessage }) => defaultMessage }; + +const board = { + id: 'b1', + name: 'Comidas', + tiles: [{ id: 't1' }, { id: 't2' }], + lastEdited: '2026-01-01T00:00:00.000Z' +}; + +const actions = [ + { key: 'show', label: 'Show', icon: null, onClick: jest.fn() }, + { + key: 'clone', + label: 'Clone', + icon: null, + disabled: true, + onClick: jest.fn() + }, + { + key: 'delete', + label: 'Delete', + icon: null, + destructive: true, + onClick: jest.fn() + } +]; + +const render = (props = {}) => + mount( + + ); + +describe('BoardDetails', () => { + it('renders the board name', () => { + expect(render().text()).toContain('Comidas'); + }); + + it('renders one button per action with a visible label', () => { + const buttons = render() + .find(Button) + .filterWhere((node) => !!node.prop('data-action-key')); + expect(buttons).toHaveLength(3); + expect(buttons.map((node) => node.text())).toEqual( + expect.arrayContaining(['Show', 'Clone', 'Delete']) + ); + }); + + it('disables an action marked disabled', () => { + const clone = render() + .find(Button) + .filterWhere((node) => node.prop('data-action-key') === 'clone'); + expect(clone.prop('disabled')).toBe(true); + }); + + it('disables every action while busy', () => { + const show = render({ busy: true }) + .find(Button) + .filterWhere((node) => node.prop('data-action-key') === 'show'); + expect(show.prop('disabled')).toBe(true); + }); + + it('calls the action handler on click', () => { + const onClick = jest.fn(); + const wrapper = render({ + actions: [{ key: 'show', label: 'Show', icon: null, onClick }] + }); + + wrapper + .find(Button) + .filterWhere((node) => node.prop('data-action-key') === 'show') + .simulate('click'); + + expect(onClick).toHaveBeenCalled(); + }); + + it('renders a prompt when nothing is selected', () => { + expect(render({ board: null }).text()).toContain('selectBoardPrompt'); + }); +}); diff --git a/src/components/Communicator/CommunicatorDialog/BoardDetails/BoardDetailsSurface.js b/src/components/Communicator/CommunicatorDialog/BoardDetails/BoardDetailsSurface.js new file mode 100644 index 000000000..b1350f267 --- /dev/null +++ b/src/components/Communicator/CommunicatorDialog/BoardDetails/BoardDetailsSurface.js @@ -0,0 +1,225 @@ +import React, { useRef, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { intlShape } from 'react-intl'; +import { alpha, makeStyles, useTheme } from '@material-ui/core/styles'; +import useMediaQuery from '@material-ui/core/useMediaQuery'; +import Drawer from '@material-ui/core/Drawer'; +import Paper from '@material-ui/core/Paper'; +import Typography from '@material-ui/core/Typography'; +import IconButton from '@material-ui/core/IconButton'; +import CloseIcon from '@material-ui/icons/Close'; + +import BoardDetails from './BoardDetails'; +import { softRadius } from '../CommunicatorDialog.styles'; +import messages from '../CommunicatorDialog.messages'; + +const PANEL_WIDTH = 380; + +const useStyles = makeStyles((theme) => ({ + // Flush full-height rail rather than a floating card: only the inner edge is + // drawn, so the panel reads as part of the dashboard chrome, and just the + // body scrolls. + panel: { + alignSelf: 'stretch', + display: 'flex', + flexDirection: 'column', + width: PANEL_WIDTH, + flexShrink: 0, + height: '100%', + borderWidth: 0, + borderLeftWidth: 1, + borderRadius: 0, + backgroundColor: theme.palette.background.default, + overflow: 'hidden' + }, + panelHeader: { + flexShrink: 0, + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + minHeight: 48, + padding: theme.spacing(0, 0.5, 0, 2), + borderBottom: `1px solid ${theme.palette.divider}` + }, + panelTitle: { + flex: 1, + minWidth: 0, + fontWeight: 550, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + outline: 'none' + }, + panelBody: { + flex: 1, + paddingBottom: theme.spacing(1), + minHeight: 0, + overflowY: 'auto', + overscrollBehavior: 'contain' + }, + sheet: { + display: 'flex', + flexDirection: 'column', + borderTopLeftRadius: softRadius(theme), + borderTopRightRadius: softRadius(theme), + maxHeight: '70vh', + overflow: 'hidden', + '@media (prefers-reduced-motion: reduce)': { + transition: 'none !important' + } + }, + sheetHeader: { + position: 'relative', + flexShrink: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + minHeight: 48, + padding: theme.spacing(0, 1) + }, + handle: { + width: 40, + height: 4, + borderRadius: 2, + backgroundColor: alpha(theme.palette.text.primary, 0.2) + }, + closeButton: { + position: 'absolute', + top: '50%', + right: theme.spacing(0.5), + transform: 'translateY(-50%)', + color: theme.palette.text.secondary + }, + sheetBody: { + flex: 1, + minHeight: 0, + overflowY: 'auto', + overscrollBehavior: 'contain', + WebkitOverflowScrolling: 'touch' + } +})); + +/** + * Chooses how the details for the selected board are presented: + * a self-scrolling column beside the list on wide viewports, a bottom sheet on + * phones so the board list never gets pushed off screen. + */ +const BoardDetailsSurface = ({ + intl, + board, + actions, + busy, + communicator, + activeBoardId, + onClose +}) => { + const classes = useStyles(); + const theme = useTheme(); + const isNarrow = useMediaQuery(theme.breakpoints.down('sm')); + const headingRef = useRef(null); + + // Explicit activation: once a board is chosen, focus moves into the details + // so keyboard and screen reader users land on the actions, not back at the + // top of the list. Keyed on the id so a re-fetched board object does not + // steal focus back from the control the user just used. + const selectedBoardId = board && board.id; + useEffect(() => { + if (selectedBoardId && headingRef.current) { + headingRef.current.focus(); + } + }, [selectedBoardId]); + + const handleKeyDown = (event) => { + if (event.key === 'Escape') { + onClose(); + } + }; + + const details = (hideTitle) => ( + + ); + + const closeButton = (className) => ( + + + + ); + + if (isNarrow) { + return ( + +
+ + {closeButton(classes.closeButton)} +
+
{details(false)}
+
+ ); + } + + return ( + +
+ + {board + ? board.name || board.id + : intl.formatMessage(messages.boardDetails)} + + {board && closeButton()} +
+
{details(true)}
+
+ ); +}; + +BoardDetailsSurface.defaultProps = { + actions: [], + busy: false +}; + +BoardDetailsSurface.propTypes = { + intl: intlShape.isRequired, + board: PropTypes.object, + actions: PropTypes.array, + busy: PropTypes.bool, + communicator: PropTypes.object, + activeBoardId: PropTypes.string, + onClose: PropTypes.func.isRequired +}; + +export default BoardDetailsSurface; diff --git a/src/components/Communicator/CommunicatorDialog/BoardDetails/BoardDetailsSurface.test.js b/src/components/Communicator/CommunicatorDialog/BoardDetails/BoardDetailsSurface.test.js new file mode 100644 index 000000000..1371fddca --- /dev/null +++ b/src/components/Communicator/CommunicatorDialog/BoardDetails/BoardDetailsSurface.test.js @@ -0,0 +1,180 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import Drawer from '@material-ui/core/Drawer'; +import BoardDetailsSurface from './BoardDetailsSurface'; +import BoardDetails from './BoardDetails'; + +jest.mock('../CommunicatorDialog.messages', () => ({ + __esModule: true, + default: new Proxy( + {}, + { + get: (target, prop) => ({ + id: String(prop), + defaultMessage: String(prop) + }) + } + ) +})); + +jest.mock('../shared/BoardThumb', () => ({ + __esModule: true, + default: () => null +})); + +const intl = { formatMessage: ({ defaultMessage }) => defaultMessage }; + +const setViewport = (matches) => { + window.matchMedia = jest.fn().mockImplementation((query) => ({ + matches, + media: query, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn() + })); +}; + +const board = { + id: 'b1', + name: 'Comidas', + tiles: [], + lastEdited: '2026-01-01T00:00:00.000Z' +}; + +let plainWrappers = []; +let attachedWrappers = []; +let attachedNodes = []; + +const render = (props = {}) => { + const wrapper = mount( + + ); + plainWrappers.push(wrapper); + return wrapper; +}; + +const renderAttached = (props = {}) => { + const host = document.createElement('div'); + document.body.appendChild(host); + attachedNodes.push(host); + const wrapper = mount( + , + { attachTo: host } + ); + attachedWrappers.push(wrapper); + return wrapper; +}; + +const appendOutsideButton = () => { + const outside = document.createElement('button'); + document.body.appendChild(outside); + attachedNodes.push(outside); + return outside; +}; + +describe('BoardDetailsSurface', () => { + afterEach(() => { + plainWrappers.forEach((wrapper) => { + if (wrapper.length) { + wrapper.unmount(); + } + }); + plainWrappers = []; + attachedWrappers.forEach((wrapper) => { + if (wrapper.length) { + wrapper.detach(); + } + }); + attachedWrappers = []; + attachedNodes.forEach((node) => { + if (node.parentNode) { + node.parentNode.removeChild(node); + } + }); + attachedNodes = []; + delete window.matchMedia; + }); + + it('renders an inline panel on wide viewports', () => { + setViewport(false); + const wrapper = render(); + expect(wrapper.find(Drawer)).toHaveLength(0); + expect(wrapper.find(BoardDetails)).toHaveLength(1); + }); + + it('renders a bottom drawer on narrow viewports', () => { + setViewport(true); + const wrapper = render(); + const drawer = wrapper.find(Drawer); + expect(drawer).toHaveLength(1); + expect(drawer.prop('anchor')).toBe('bottom'); + }); + + it('opens the drawer only when a board is selected', () => { + setViewport(true); + expect(render({ board: null }).find(Drawer).prop('open')).toBe(false); + expect(render().find(Drawer).prop('open')).toBe(true); + }); + + it('closes the drawer from the close button', () => { + setViewport(true); + const onClose = jest.fn(); + const wrapper = render({ onClose }); + + wrapper.find('button[data-testid="close-board-details"]').simulate('click'); + + expect(onClose).toHaveBeenCalled(); + }); + + it('closes on Escape', () => { + setViewport(false); + const onClose = jest.fn(); + const wrapper = render({ onClose }); + + wrapper + .find('[data-testid="board-details-panel"]') + .first() + .simulate('keydown', { + key: 'Escape' + }); + + expect(onClose).toHaveBeenCalled(); + }); + + it('does not re-take focus when the selected board object is replaced', () => { + setViewport(false); + const outside = appendOutsideButton(); + const wrapper = renderAttached(); + + outside.focus(); + wrapper.setProps({ board: { ...board, isPublic: true } }); + + expect(document.activeElement).toBe(outside); + }); + + it('moves focus into the details when a different board is selected', () => { + setViewport(false); + const outside = appendOutsideButton(); + const wrapper = renderAttached(); + + outside.focus(); + wrapper.setProps({ board: { ...board, id: 'b2', name: 'Escuela' } }); + + expect(document.activeElement.tagName).toBe('H2'); + }); +}); diff --git a/src/components/Communicator/CommunicatorDialog/BoardDetails/index.js b/src/components/Communicator/CommunicatorDialog/BoardDetails/index.js new file mode 100644 index 000000000..128d621cb --- /dev/null +++ b/src/components/Communicator/CommunicatorDialog/BoardDetails/index.js @@ -0,0 +1,3 @@ +import BoardDetailsSurface from './BoardDetailsSurface'; + +export default BoardDetailsSurface; diff --git a/src/components/Communicator/CommunicatorDialog/BoardsView/BoardCard.js b/src/components/Communicator/CommunicatorDialog/BoardsView/BoardCard.js new file mode 100644 index 000000000..7c8f6ef1b --- /dev/null +++ b/src/components/Communicator/CommunicatorDialog/BoardsView/BoardCard.js @@ -0,0 +1,236 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { intlShape } from 'react-intl'; +import moment from 'moment'; +import { makeStyles } from '@material-ui/core/styles'; +import orange from '@material-ui/core/colors/orange'; +import classNames from 'classnames'; +import Card from '@material-ui/core/Card'; +import CardActionArea from '@material-ui/core/CardActionArea'; +import CardContent from '@material-ui/core/CardContent'; +import Typography from '@material-ui/core/Typography'; +import IconButton from '@material-ui/core/IconButton'; +import Tooltip from '@material-ui/core/Tooltip'; +import CircularProgress from '@material-ui/core/CircularProgress'; +import StarIcon from '@material-ui/icons/Star'; +import StarBorderIcon from '@material-ui/icons/StarBorder'; + +import BoardThumb from '../shared/BoardThumb'; +import BoardStatusIcons from '../shared/BoardStatusIcons'; +import { formatBoardLocale } from '../utils/boardLocale'; +import { + softRadius, + surfaceInteractive, + surfaceAccent, + busyOverlay +} from '../CommunicatorDialog.styles'; +import messages from '../CommunicatorDialog.messages'; + +const useStyles = makeStyles((theme) => ({ + card: { + position: 'relative', + display: 'flex', + flexDirection: 'column', + height: '100%', + borderRadius: softRadius(theme), + overflow: 'hidden', + ...surfaceInteractive(theme, { lift: true }) + }, + accent: surfaceAccent(theme), + selected: { + borderColor: theme.palette.primary.main, + boxShadow: `0 0 0 2px ${theme.palette.primary.main}` + }, + actionArea: { + flex: 1, + display: 'flex', + flexDirection: 'column', + alignItems: 'stretch', + // The action area is a + } + > + + {intl.formatMessage(messages.errorGettingBoards)} + + + + ); + } + + if (!boards.length) { + return ( +
+
+ {hasSearch ? ( + + ) : ( + + )} +
+ + {intl.formatMessage( + hasSearch ? messages.noBoardsFound : messages.emptyBoardsList + )} + +
+ ); + } + + return ( +
+ {isGrid ? ( + + {boards.map((board) => ( + + + + ))} + + ) : ( +
+ {boards.map((board) => ( + + ))} +
+ )} + + {showPagination && ( +
+ onPageChange(value)} + /> +
+ )} +
+ ); +}; + +BoardsView.propTypes = { + intl: intlShape.isRequired, + boards: PropTypes.array.isRequired, + viewMode: PropTypes.string.isRequired, + loading: PropTypes.bool, + error: PropTypes.object, + hasSearch: PropTypes.bool, + page: PropTypes.number.isRequired, + totalPages: PropTypes.number.isRequired, + onPageChange: PropTypes.func.isRequired, + onRetry: PropTypes.func.isRequired, + busyBoardId: PropTypes.string, + communicator: PropTypes.object.isRequired, + activeBoardId: PropTypes.string, + selectedId: PropTypes.string, + onSelect: PropTypes.func.isRequired, + onToggleQuickAccess: PropTypes.func.isRequired, + showQuickAccess: PropTypes.bool, + registerTrigger: PropTypes.func.isRequired +}; + +export default BoardsView; diff --git a/src/components/Communicator/CommunicatorDialog/BoardsView/index.js b/src/components/Communicator/CommunicatorDialog/BoardsView/index.js new file mode 100644 index 000000000..cbd7a911e --- /dev/null +++ b/src/components/Communicator/CommunicatorDialog/BoardsView/index.js @@ -0,0 +1,3 @@ +import BoardsView from './BoardsView'; + +export default BoardsView; diff --git a/src/components/Communicator/CommunicatorDialog/CommunicatorDialog.component.js b/src/components/Communicator/CommunicatorDialog/CommunicatorDialog.component.js index 78f91de61..760c65890 100644 --- a/src/components/Communicator/CommunicatorDialog/CommunicatorDialog.component.js +++ b/src/components/Communicator/CommunicatorDialog/CommunicatorDialog.component.js @@ -1,241 +1,545 @@ -import React from 'react'; +import React, { useState, useMemo, useEffect, useRef } from 'react'; import PropTypes from 'prop-types'; -import FullScreenDialog, { - FullScreenDialogContent -} from '../../UI/FullScreenDialog'; -import Paper from '@material-ui/core/Paper'; -import Tabs from '@material-ui/core/Tabs'; -import Tab from '@material-ui/core/Tab'; -import CircularProgress from '@material-ui/core/CircularProgress'; -import { intlShape, FormattedMessage } from 'react-intl'; - -import { TAB_INDEXES } from './CommunicatorDialog.constants'; -import CommunicatorDialogBoardItem from './CommunicatorDialogBoardItem.component'; -import messages from './CommunicatorDialog.messages'; +import { intlShape } from 'react-intl'; +import { makeStyles, useTheme } from '@material-ui/core/styles'; +import useMediaQuery from '@material-ui/core/useMediaQuery'; +import { debounce } from 'lodash'; -import './CommunicatorDialog.css'; -import CommunicatorDialogButtons from './CommunicatorDialogButtons.component'; -import { Button } from '@material-ui/core'; +import FullScreenDialog from '../../UI/FullScreenDialog'; +import { + SECTIONS, + VIEW_MODES, + VIEW_MODE_STORAGE_KEY, + SECTION_TOUR_FLAGS +} from './CommunicatorDialog.constants'; +import messages from './CommunicatorDialog.messages'; +import useBoardsFetcher from './hooks/useBoardsFetcher'; +import useBoardActions from './hooks/useBoardActions'; +import { + DashboardNav, + SectionHeader, + ContentToolbar, + LiveRegion +} from './Dashboard'; +import BoardsView from './BoardsView'; import CommunicatorDialogTour from './CommunicatorDialogTour.component'; +import useBoardSelection from './hooks/useBoardSelection'; +import BoardDetailsSurface from './BoardDetails'; +import QuickAccessTray from './QuickAccess'; +import { getBoardActions } from './utils/boardActions'; + +import BoardInfoDialog from './dialogs/BoardInfoDialog'; +import ConfirmDialog from './dialogs/ConfirmDialog'; +import EditBoardDialog from './dialogs/EditBoardDialog'; +import PublishBoardDialog from './dialogs/PublishBoardDialog'; +import ReportBoardDialog from './dialogs/ReportBoardDialog'; + +const useStyles = makeStyles((theme) => ({ + dashboard: { + display: 'flex', + height: '100%', + overflow: 'hidden' + }, + content: { + flex: 1, + minWidth: 0, + display: 'flex', + flexDirection: 'column', + overflow: 'hidden' + }, + header: { + flexShrink: 0, + display: 'grid', + // Title block / chip / toolbar each own a track, so when the toolbar + // track is empty (Quick Access has no ContentToolbar) the title and + // chip stay put instead of the row re-centering around fewer children. + gridTemplateColumns: 'minmax(0, 1fr) auto minmax(0, 1fr)', + alignItems: 'center', + gap: theme.spacing(1, 2), + + padding: theme.spacing(2, 2), + backgroundColor: theme.palette.background.default, + borderBottom: `1px solid ${theme.palette.divider}`, + + [theme.breakpoints.only('md')]: { + gridTemplateColumns: 'none', + gridAutoFlow: 'column', + justifyContent: 'space-between' + }, + + [theme.breakpoints.down('xs')]: { + gridTemplateColumns: 'minmax(0, 1fr) auto', + '& $toolbarSlot': { + gridColumn: '1 / -1' + } + } + }, + toolbarSlot: { + display: 'flex', + justifyContent: 'flex-end', + minWidth: 0 + }, + scrollArea: { + flex: 1, + minHeight: 0, + overflowY: 'auto', + padding: theme.spacing(2, 2) + }, + scrollAreaWithPanel: { + flex: 1, + minHeight: 0, + overflow: 'hidden', + display: 'flex', + alignItems: 'stretch' + // padding: theme.spacing(0, 2, 2, 0) + }, + boards: { + flex: 1, + minWidth: 0, + minHeight: 0, + display: 'flex', + flexDirection: 'column', + overflowY: 'auto', + padding: theme.spacing(2, 2, 0) + } +})); + +const readStoredViewMode = () => { + try { + const stored = window.localStorage.getItem(VIEW_MODE_STORAGE_KEY); + return stored === VIEW_MODES.LIST ? VIEW_MODES.LIST : VIEW_MODES.GRID; + } catch (e) { + return VIEW_MODES.GRID; + } +}; const CommunicatorDialog = ({ open, intl, - selectedTab, - loading, - nextPageLoading, - boards, - total, - limit, - page, - totalPages, + onClose, + // redux state userData, - communicatorBoardsIds, - communicator, + language, + communicators, + currentCommunicator, + communicatorBoards, + availableBoards, activeBoardId, - search, - isSearchOpen, - loadNextPage, - onClose, - onTabChange, - onSearch, - openSearchBar, - addOrRemoveBoard, - copyBoard, - deleteMyBoard, - updateMyBoard, - setRootBoard, - publishBoard, - boardReport, - showNotification, - dark, communicatorTour, isSymbolSearchTourEnabled, - disableTour -}) => ( - + // dispatchers + createBoard, + updateBoard, + replaceBoard, + addBoards, + deleteBoard, + deleteApiBoard, + updateApiBoard, + updateApiObjectsNoChild, + addBoardCommunicator, + verifyAndUpsertCommunicator, + upsertApiCommunicator, + showNotification, + disableTour, + switchBoard +}) => { + const classes = useStyles(); + const theme = useTheme(); + // Matches the breakpoint BoardDetailsSurface switches on. + const isNarrow = useMediaQuery(theme.breakpoints.down('sm')); + + const [section, setSection] = useState(SECTIONS.MY_BOARDS); + const [viewMode, setViewMode] = useState(readStoredViewMode); + const [searchInput, setSearchInput] = useState(''); + const [search, setSearch] = useState(''); + const [mobileNavOpen, setMobileNavOpen] = useState(false); + const [busyBoardId, setBusyBoardId] = useState(null); + const [dialog, setDialog] = useState({ type: null, board: null }); + const [announcement, setAnnouncement] = useState(''); + const announceTimer = useRef(null); + const scrollAreaRef = useRef(null); + + const fetcher = useBoardsFetcher({ + section, + search, + communicatorBoards, + availableBoards, + userData + }); + + const actions = useBoardActions({ + section, + intl, + userData, + language, + communicators, + currentCommunicator, + availableBoards, + createBoard, + updateBoard, + replaceBoard, + addBoards, + deleteBoard, + deleteApiBoard, + updateApiBoard, + updateApiObjectsNoChild, + addBoardCommunicator, + verifyAndUpsertCommunicator, + upsertApiCommunicator, + showNotification, + refetch: fetcher.refetch, + removeBoardFromList: fetcher.removeBoardFromList, + replaceBoardInList: fetcher.replaceBoardInList, + switchBoard, + onClose + }); + + const selection = useBoardSelection({ + boards: fetcher.boards, + section, + search, + page: fetcher.page + }); + + const tourFlag = SECTION_TOUR_FLAGS[section]; + const isTourEnabled = !!communicatorTour[tourFlag]; + const isTourReady = !fetcher.loading; + + // The details step describes the actions in the panel, which only appear + // once a board is chosen — so the tour opens on the first board rather than + // pointing at the empty placeholder. Not on a phone: there the details are a + // bottom sheet that would cover the rest of the tour, and its step is + // dropped anyway because the docked panel is not rendered. + useEffect(() => { + const hasDetailsPanel = section !== SECTIONS.MY_COMMUNICATOR && !isNarrow; + if ( + hasDetailsPanel && + isTourEnabled && + isTourReady && + !selection.selectedId + ) { + const [first] = fetcher.boards; + if (first) { + selection.select(first.id); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isTourEnabled, isTourReady, section, isNarrow]); + + const debouncedSetSearch = useMemo( + () => debounce((value) => setSearch(value), 400), + [] + ); + + useEffect(() => () => debouncedSetSearch.cancel(), [debouncedSetSearch]); + + useEffect(() => () => window.clearTimeout(announceTimer.current), []); + + const handleSearchChange = (value) => { + setSearchInput(value); + debouncedSetSearch(value); + }; + + const handleSectionChange = (nextSection) => { + debouncedSetSearch.cancel(); + setSection(nextSection); + setSearchInput(''); + setSearch(''); + }; + + const handlePageChange = (nextPage) => { + fetcher.goToPage(nextPage); + const scrollArea = scrollAreaRef.current; + if (!scrollArea) { + return; + } + if (scrollArea.scrollTo) { + scrollArea.scrollTo({ top: 0, behavior: 'smooth' }); + } else { + scrollArea.scrollTop = 0; + } + }; + + const handleViewModeChange = (mode) => { + setViewMode(mode); + try { + window.localStorage.setItem(VIEW_MODE_STORAGE_KEY, mode); + } catch (e) {} + }; + + const closeDialog = () => setDialog({ type: null, board: null }); + + const announce = (message) => { + setAnnouncement(''); + // Re-setting the same string would not re-announce; clearing first makes + // repeated identical results (two exports in a row) audible. + window.clearTimeout(announceTimer.current); + announceTimer.current = window.setTimeout( + () => setAnnouncement(message), + 50 + ); + }; + + const runBusy = async (board, fn, pendingMessage) => { + setBusyBoardId(board.id); + if (pendingMessage) { + announce(pendingMessage); + } + try { + await fn(board); + } finally { + // A slower action finishing later must not clear the spinner of a + // different board the user has since started acting on. + setBusyBoardId((current) => (current === board.id ? null : current)); } - > - - - - - - - - -
- {!loading && ( - - {selectedTab === TAB_INDEXES.COMMUNICATOR_BOARDS && ( -
- -
- {intl.formatMessage(messages.title)} -
-
- {intl.formatMessage(messages.boardsQty, { - qty: total - })} -
-
-
- )} - -
- {!boards.length && ( -
- -
- )} - - {boards.slice(0, limit).map((board, i) => ( - - ))} - - actions.showBoard(board), + onSetRoot: (board) => runBusy(board, actions.setRootBoard), + onAddRemove: (board) => runBusy(board, actions.addOrRemoveBoard), + onShowInfo: (board) => setDialog({ type: 'info', board }), + onReport: (board) => setDialog({ type: 'report', board }), + onEdit: (board) => setDialog({ type: 'edit', board }), + onDelete: (board) => setDialog({ type: 'delete', board }), + onCopy: (board) => setDialog({ type: 'copy', board }), + onExport: (board) => + runBusy( + board, + actions.exportBoard, + intl.formatMessage(messages.exportingBoard, { name: board.name }) + ), + onExportPdf: (board) => + runBusy( + board, + actions.exportBoardToPdf, + intl.formatMessage(messages.exportingBoard, { name: board.name }) + ), + onPublishToggle: (board) => { + if (!board.isPublic && !board.description) { + setDialog({ type: 'publish', board }); + } else { + runBusy(board, actions.publishBoard); + } + } + }; + + const selectedActions = selection.selectedBoard + ? getBoardActions({ + section, + board: selection.selectedBoard, + communicator: currentCommunicator, + userData, + activeBoardId, + handlers, + intl + }) + : []; + + const handleMove = async (boardId, delta, visibleIds) => { + await actions.reorderCommunicatorBoards(boardId, delta, visibleIds); + const board = communicatorBoards.find((item) => item.id === boardId); + const nextPosition = visibleIds.indexOf(boardId) + 1 + delta; + announce( + intl.formatMessage(messages.boardMoved, { + name: board ? board.name : boardId, + position: nextPosition, + total: visibleIds.length + }) + ); + }; + + return ( + +
+ setMobileNavOpen(false)} + /> + +
+
+ setMobileNavOpen(true)} + /> + {section !== SECTIONS.MY_COMMUNICATOR && ( +
+ +
+ )} +
- {page < totalPages && ( - - )} - - {nextPageLoading && ( - - )} + {section === SECTIONS.MY_COMMUNICATOR ? ( +
+ runBusy(board, actions.setRootBoard)} + onRemove={(board) => runBusy(board, actions.addOrRemoveBoard)} + onMove={handleMove} + onGoToMyBoards={() => handleSectionChange(SECTIONS.MY_BOARDS)} + /> +
+ ) : ( +
+
+ + runBusy(board, actions.addOrRemoveBoard) + } + showQuickAccess={section !== SECTIONS.COMMUNITY} + registerTrigger={selection.registerTrigger} + />
- + +
)} - {loading && ( - - )} -
- - -
-); + + +
+ + {/* Outside the dashboard's overflow-hidden columns so Joyride's inline + tooltip is not clipped by them. */} + + + + actions.deleteMyBoard(dialog.board)} + onClose={closeDialog} + /> + actions.copyBoard(dialog.board)} + onClose={closeDialog} + /> + + + + + ); +}; CommunicatorDialog.defaultProps = { open: false, - loading: false, - nextPageLoading: false, - userData: null, - limit: 10, - page: 1, - totalPages: 1, - selectedTab: 0, - boards: [], - total: 0, - communicatorBoardsIds: [], - loadNextPage: () => {}, - onClose: () => {}, - onTabChange: () => {}, - onSearch: () => {} + onClose: () => {} }; CommunicatorDialog.propTypes = { - boards: PropTypes.array, - userData: PropTypes.object, - limit: PropTypes.number, - page: PropTypes.number, - totalPages: PropTypes.number, - total: PropTypes.number, open: PropTypes.bool, - loading: PropTypes.bool, - selectedTab: PropTypes.number, - communicator: PropTypes.object, - activeBoardId: PropTypes.string, - communicatorBoardsIds: PropTypes.arrayOf(PropTypes.string), - intl: intlShape, - loadNextPage: PropTypes.func, + intl: intlShape.isRequired, onClose: PropTypes.func, - onTabChange: PropTypes.func, - onSearch: PropTypes.func, - addOrRemoveBoard: PropTypes.func.isRequired, - deleteMyBoard: PropTypes.func.isRequired, - updateMyBoard: PropTypes.func.isRequired, - copyBoard: PropTypes.func.isRequired, - setRootBoard: PropTypes.func.isRequired, - publishBoard: PropTypes.func.isRequired, - boardReport: PropTypes.func.isRequired, - showNotification: PropTypes.func.isRequired, - dark: PropTypes.bool, + userData: PropTypes.object, + language: PropTypes.object, + communicators: PropTypes.array, + currentCommunicator: PropTypes.object, + communicatorBoards: PropTypes.array, + availableBoards: PropTypes.array, + activeBoardId: PropTypes.string, communicatorTour: PropTypes.object.isRequired, isSymbolSearchTourEnabled: PropTypes.bool, - disableTour: PropTypes.func.isRequired + createBoard: PropTypes.func.isRequired, + updateBoard: PropTypes.func.isRequired, + replaceBoard: PropTypes.func.isRequired, + addBoards: PropTypes.func.isRequired, + deleteBoard: PropTypes.func.isRequired, + deleteApiBoard: PropTypes.func.isRequired, + updateApiBoard: PropTypes.func.isRequired, + updateApiObjectsNoChild: PropTypes.func.isRequired, + addBoardCommunicator: PropTypes.func.isRequired, + verifyAndUpsertCommunicator: PropTypes.func.isRequired, + upsertApiCommunicator: PropTypes.func.isRequired, + showNotification: PropTypes.func.isRequired, + disableTour: PropTypes.func.isRequired, + switchBoard: PropTypes.func.isRequired }; export default CommunicatorDialog; diff --git a/src/components/Communicator/CommunicatorDialog/CommunicatorDialog.constants.js b/src/components/Communicator/CommunicatorDialog/CommunicatorDialog.constants.js index 97f5be756..0e6531197 100644 --- a/src/components/Communicator/CommunicatorDialog/CommunicatorDialog.constants.js +++ b/src/components/Communicator/CommunicatorDialog/CommunicatorDialog.constants.js @@ -1,5 +1,32 @@ -export const TAB_INDEXES = { - COMMUNICATOR_BOARDS: 0, - PUBLIC_BOARDS: 1, - MY_BOARDS: 2 +// Dashboard sections (replaces the tabs). Each section maps the data source it +// reads from and the set of board actions it exposes. +export const SECTIONS = { + MY_BOARDS: 'myBoards', + MY_COMMUNICATOR: 'myCommunicator', + COMMUNITY: 'community' }; + +export const SECTION_ORDER = [ + SECTIONS.MY_BOARDS, + SECTIONS.MY_COMMUNICATOR, + SECTIONS.COMMUNITY +]; + +// Each section has its own walkthrough. The flags are persisted per user, so +// they keep the names they had when the sections were tabs. +export const SECTION_TOUR_FLAGS = { + [SECTIONS.MY_BOARDS]: 'isAllMyBoardsEnabled', + [SECTIONS.MY_COMMUNICATOR]: 'isCommBoardsEnabled', + [SECTIONS.COMMUNITY]: 'isPublicBoardsEnabled' +}; + +export const VIEW_MODES = { + GRID: 'grid', + LIST: 'list' +}; + +export const VIEW_MODE_STORAGE_KEY = 'cboard-communicator-view-mode'; + +export const NAV_COLLAPSED_STORAGE_KEY = 'cboard-communicator-nav-collapsed'; + +export const BOARDS_PAGE_LIMIT = 12; diff --git a/src/components/Communicator/CommunicatorDialog/CommunicatorDialog.container.js b/src/components/Communicator/CommunicatorDialog/CommunicatorDialog.container.js index fd26d70d9..a280416c8 100644 --- a/src/components/Communicator/CommunicatorDialog/CommunicatorDialog.container.js +++ b/src/components/Communicator/CommunicatorDialog/CommunicatorDialog.container.js @@ -1,589 +1,38 @@ -import React from 'react'; import { connect } from 'react-redux'; -import CommunicatorDialog from './CommunicatorDialog.component'; -import { TAB_INDEXES } from './CommunicatorDialog.constants'; import { injectIntl } from 'react-intl'; -import shortid from 'shortid'; -import API from '../../../api'; + +import CommunicatorDialog from './CommunicatorDialog.component'; import { - deleteBoardCommunicator, addBoardCommunicator, verifyAndUpsertCommunicator, - upsertApiCommunicator, - pushCommunicator + upsertApiCommunicator } from '../Communicator.actions'; -import { deleteBoard, deleteApiBoard } from '../../Board/Board.actions'; -import { showNotification } from '../../Notifications/Notifications.actions'; -import { getVisibleBoards } from '../../Board/Board.selectors'; import { + deleteBoard, + deleteApiBoard, addBoards, replaceBoard, createBoard, updateBoard, updateApiObjectsNoChild, - updateApiBoard + updateApiBoard, + switchBoard } from '../../Board/Board.actions'; +import { showNotification } from '../../Notifications/Notifications.actions'; +import { getVisibleBoards } from '../../Board/Board.selectors'; import { disableTour } from '../../App/App.actions'; -import messages from './CommunicatorDialog.messages'; - -const BOARDS_PAGE_LIMIT = 10; -const INITIAL_STATE = { - page: 0, - total: 0, - search: '', - data: [] -}; - -const findBoards = (boards, criteria, page, search = '') => { - let result = boards; - for (let [key, value] of Object.entries(criteria)) { - result = result.filter( - (board) => - (board.hasOwnProperty(key) && board[key] === value) || - !board.hasOwnProperty(key) - ); - } - if (search) { - let re = new RegExp(search); - result = result.filter( - (board) => re.test(board.name) || re.test(board.author) - ); - } - return { - limit: BOARDS_PAGE_LIMIT, - offset: 0, - search: search, - page: page, - total: result.length, - data: result.slice((page - 1) * BOARDS_PAGE_LIMIT, page * BOARDS_PAGE_LIMIT) - }; -}; - -class CommunicatorDialogContainer extends React.Component { - constructor(props) { - super(props); - - this.state = { - loading: false, - boards: props.communicatorBoards, // First time => Communicator Boards Tab - total: props.communicatorBoards.length, - selectedTab: TAB_INDEXES.COMMUNICATOR_BOARDS, - totalPages: Math.ceil( - props.communicatorBoards.length / BOARDS_PAGE_LIMIT - ), - page: 1, - search: '', - isSearchOpen: false, - communicatorBoards: findBoards(props.communicatorBoards, {}, 1) - }; - } - - async onTabChange(event, selectedTab = TAB_INDEXES.COMMUNICATOR_BOARDS) { - this.setState({ selectedTab, loading: true }); - const tabData = await this.doSearch('', 1, selectedTab); - this.setState({ - ...tabData, - selectedTab, - page: 1, - search: '', - isSearchOpen: false, - loading: false - }); - } - - async loadNextPage() { - this.setState({ nextPageLoading: true }); - const page = this.state.page + 1; - const { search, selectedTab, boards } = this.state; - const tabData = await this.doSearch(search, page, selectedTab); - this.setState({ - ...tabData, - boards: boards.concat(tabData.boards), - page: page, - loading: false, - nextPageLoading: false - }); - } - - async doSearch( - search = this.state.search, - page = this.state.page, - selectedTab = this.state.selectedTab - ) { - let boards = []; - let totalPages = 1; - let total = 0; - - switch (selectedTab) { - case TAB_INDEXES.COMMUNICATOR_BOARDS: - const commState = findBoards( - this.props.communicatorBoards, - {}, - page, - search - ); - boards = boards.concat(commState.data); - total = commState.total; - totalPages = Math.ceil(commState.total / BOARDS_PAGE_LIMIT); - break; - case TAB_INDEXES.PUBLIC_BOARDS: - let externalState = INITIAL_STATE; - try { - externalState = await API.getPublicBoards({ - limit: BOARDS_PAGE_LIMIT, - page, - search - }); - } catch (err) { - externalState = findBoards( - this.props.availableBoards, - { - isPublic: true, - hidden: false - }, - page, - search - ); - } - boards = boards.concat(externalState.data); - total = externalState.total; - totalPages = Math.ceil(externalState.total / BOARDS_PAGE_LIMIT); - break; - case TAB_INDEXES.MY_BOARDS: - let myBoardsResponse = INITIAL_STATE; - try { - myBoardsResponse = await API.getMyBoards({ - limit: BOARDS_PAGE_LIMIT, - page, - search - }); - } catch (err) { - myBoardsResponse = findBoards( - this.props.availableBoards, - { - email: this.props.userData.email, - hidden: false - }, - page, - search - ); - } - boards = boards.concat(myBoardsResponse.data); - total = myBoardsResponse.total; - totalPages = Math.ceil(myBoardsResponse.total / BOARDS_PAGE_LIMIT); - break; - default: - break; - } - return { - boards, - totalPages, - total - }; - } - - async onSearch(search = this.state.search) { - this.setState({ - search, - boards: [], - loading: true, - page: 1, - totalPages: 1 - }); - if (this.searchTimeout) { - clearTimeout(this.searchTimeout); - } - this.searchTimeout = setTimeout(async () => { - const { boards, totalPages, total } = await this.doSearch(search); - this.setState({ - boards, - page: 1, - total, - totalPages, - loading: false - }); - }, 500); - } - - async addOrRemoveBoard(board) { - const BOARD_ACTIONS_MAP = { - [TAB_INDEXES.COMMUNICATOR_BOARDS]: 'communicatorBoardsAction', - [TAB_INDEXES.PUBLIC_BOARDS]: 'addOrRemoveAction', - [TAB_INDEXES.MY_BOARDS]: 'addOrRemoveAction' - }; - const action = BOARD_ACTIONS_MAP[this.state.selectedTab]; - await this[action](board); - } - - async communicatorBoardsAction(board) { - // If Communicator Tab is selected, the board should be removed from the Communicator - const communicatorBoards = this.props.communicatorBoards.filter( - (cb) => cb.id !== board.id - ); - await this.updateCommunicatorBoards(communicatorBoards); - this.setState({ boards: communicatorBoards }); - } - - async copyBoard(board) { - const { intl, showNotification } = this.props; - try { - await this.createBoardsRecursively(board); - showNotification(intl.formatMessage(messages.boardAddedToCommunicator)); - } catch (err) { - console.log(err.message); - showNotification(intl.formatMessage(messages.boardCopyError)); - } - } - - async createBoardsRecursively(board, records) { - const { - createBoard, - addBoardCommunicator, - verifyAndUpsertCommunicator, - currentCommunicator, - userData, - updateApiObjectsNoChild, - availableBoards, - intl - } = this.props; - //prevent shit - if (!board) { - return; - } - if (records) { - //get the list of next boards in records - let nextBoardsRecords = records.map((entry) => entry.next); - if (nextBoardsRecords.includes(board.id)) { - return; - } - } - - let newBoard = { - ...board, - isPublic: false, - id: shortid.generate(), - hidden: false, - author: '', - email: '' - }; - if (!newBoard.name) { - newBoard.name = newBoard.nameKey - ? intl.formatMessage({ id: newBoard.nameKey }) - : intl.formatMessage(messages.noTitle); - } - if ('name' in userData && 'email' in userData) { - newBoard = { - ...newBoard, - author: userData.name, - email: userData.email - }; - } - createBoard(newBoard); - if (!records) { - verifyAndUpsertCommunicator({ ...currentCommunicator }); - addBoardCommunicator(newBoard.id); - } - - if (!records) { - records = [{ prev: board.id, next: newBoard.id }]; - } else { - records.push({ prev: board.id, next: newBoard.id }); - } - this.updateBoardReferences(board, newBoard, records); - - // Loggedin user? - if ('name' in userData && 'email' in userData) { - try { - const boardId = await updateApiObjectsNoChild(newBoard, true); - newBoard = { - ...newBoard, - id: boardId - }; - } catch (err) { - console.log(err.message); - } - } - - if (board.tiles.length < 1) { - return; - } - - //return condition - for (const tile of board.tiles) { - if (tile.loadBoard && !tile.linkedBoard) { - try { - const nextBoard = await API.getBoard(tile.loadBoard); - await this.createBoardsRecursively(nextBoard, records); - } catch (err) { - if (!err.respose || err.response?.status === 404) { - //look for this board in available boards - const localBoard = availableBoards.find( - (b) => b.id === tile.loadBoard - ); - if (localBoard) { - await this.createBoardsRecursively(localBoard, records); - } - } - } - } - } - } - - updateBoardReferences(board, newBoard, records) { - const { availableBoards, updateBoard } = this.props; - //get the list of prev boards in records, but remove the current board - let prevBoardsRecords = records.map((entry) => entry.prev); - prevBoardsRecords = prevBoardsRecords.filter((id) => id !== newBoard.id); - //look for reference to the original board id - availableBoards.forEach((b) => { - b.tiles.forEach((tile, index) => { - if ( - //general case: tile can contains reference to the board - tile && - tile.loadBoard && - tile.loadBoard === board.id - ) { - b.tiles.splice(index, 1, { - ...tile, - loadBoard: newBoard.id - }); - try { - updateBoard(b); - } catch (err) { - console.log(err.message); - } - } - if ( - //special case: tile can contains reference to a prev board in records! - tile && - tile.loadBoard && - prevBoardsRecords.includes(tile.loadBoard) - ) { - const el = records.find((e) => e.prev === tile.loadBoard); - b.tiles.splice(index, 1, { - ...tile, - loadBoard: el.next - }); - try { - updateBoard(b); - } catch (err) { - console.log(err.message); - } - } - }); - }); - } - - async addOrRemoveAction(board) { - // If All My Boards Tab is selected, the board should be added/removed to/from the Communicator - let communicatorBoards = [...this.props.communicatorBoards]; - const boardIndex = communicatorBoards.findIndex((b) => b.id === board.id); - if (boardIndex >= 0) { - communicatorBoards.splice(boardIndex, 1); - this.props.showNotification( - this.props.intl.formatMessage(messages.boardRemovedFromCommunicator) - ); - } else { - communicatorBoards.push(board); - this.props.showNotification( - this.props.intl.formatMessage(messages.boardAddedToCommunicator) - ); - } - - await this.updateCommunicatorBoards(communicatorBoards); - - // Need to fetch board if its not locally available - if ( - boardIndex < 0 && - this.props.availableBoards.findIndex((b) => b.id === board.id) < 0 - ) { - let boards = []; - try { - const boardData = await API.getBoard(board.id); - boards.push(boardData); - } catch (e) {} - this.props.addBoards(boards); - } - } - - async updateCommunicatorBoards(boards) { - const { currentCommunicator, pushCommunicator } = this.props; - - const updatedCommunicatorData = { - ...currentCommunicator, - boards: boards.map((cb) => cb.id) - }; - - try { - await pushCommunicator(updatedCommunicatorData); - } catch (err) { - console.error('Error upserting communicator', err); - } - } - - async publishBoard(board) { - const { userData, replaceBoard, showNotification, intl } = this.props; - const boardData = { - ...board, - isPublic: !board.isPublic - }; - const sBoards = this.state.boards; - const index = sBoards.findIndex((b) => board.id === b.id); - sBoards.splice(index, 1, boardData); - replaceBoard(board, boardData); - this.setState({ - boards: sBoards - }); - boardData.isPublic - ? showNotification(intl.formatMessage(messages.boardPublished)) - : showNotification(intl.formatMessage(messages.boardUnpublished)); - - // Loggedin user? - if ('name' in userData && 'email' in userData) { - try { - const { board: sanitized } = await API.uploadBoardLocalMedia(boardData); - replaceBoard(boardData, sanitized); - const boardResponse = await API.updateBoard(sanitized); - replaceBoard(sanitized, boardResponse); - } catch (err) {} - } - } - - async boardReport(reportedBoardData) { - const { - language: { lang } - } = this.props; - reportedBoardData.whistleblower.language = lang; - await API.boardReport(reportedBoardData); - return; - } - - async setRootBoard(board) { - const { currentCommunicator, pushCommunicator } = this.props; - - const updatedCommunicatorData = { - ...currentCommunicator, - rootBoard: board.id - }; - try { - await pushCommunicator(updatedCommunicatorData); - } catch (err) { - console.error('Error upserting communicator', err); - } - } - - openSearchBar() { - this.setState({ isSearchOpen: true }); - } - - async deleteMyBoard(board) { - const { - showNotification, - deleteBoard, - communicators, - deleteApiBoard, - userData, - intl, - pushCommunicator - } = this.props; - deleteBoard(board.id); - - // Loggedin user? - if ('name' in userData && 'email' in userData) { - try { - await deleteApiBoard(board.id); - } catch (err) {} - } - for await (const comm of communicators) { - if (comm.boards.includes(board.id)) { - const filteredCommunicator = { - ...comm, - boards: comm.boards.filter((b) => b !== board.id) - }; - - try { - await pushCommunicator(filteredCommunicator); - } catch (err) { - console.error('Error upserting communicator', err); - } - } - } - - const sBoards = this.state.boards; - const index = sBoards.findIndex((b) => board.id === b.id); - sBoards.splice(index, 1); - this.setState({ - boards: sBoards - }); - showNotification(intl.formatMessage(messages.boardDeleted)); - } - - async updateMyBoard(board) { - const { updateBoard, updateApiBoard, userData } = this.props; - updateBoard(board); - const sBoards = this.state.boards; - const index = sBoards.findIndex((b) => board.id === b.id); - sBoards.splice(index, 1, board); - this.setState({ - boards: sBoards - }); - - // Loggedin user? - if ('name' in userData && 'email' in userData) { - try { - await updateApiBoard(board); - } catch (err) {} - } - } - - render() { - const limit = this.state.page * BOARDS_PAGE_LIMIT; - const communicatorBoardsIds = this.props.communicatorBoards.map( - (b) => b.id - ); - const dialogProps = { - ...this.props, - ...this.state, - limit, - communicator: this.props.currentCommunicator, - communicatorBoardsIds, - addOrRemoveBoard: this.addOrRemoveBoard.bind(this), - deleteMyBoard: this.deleteMyBoard.bind(this), - updateMyBoard: this.updateMyBoard.bind(this), - publishBoard: this.publishBoard.bind(this), - setRootBoard: this.setRootBoard.bind(this), - copyBoard: this.copyBoard.bind(this), - boardReport: this.boardReport.bind(this), - loadNextPage: this.loadNextPage.bind(this), - onTabChange: this.onTabChange.bind(this), - onSearch: this.onSearch.bind(this), - openSearchBar: this.openSearchBar.bind(this), - disableTour: this.props.disableTour - }; - - return ; - } -} - -export const mapStateToProps = ( - { board, communicator, language, app }, - ownProps -) => { +export const mapStateToProps = ({ board, communicator, language, app }) => { const activeCommunicatorId = communicator.activeCommunicatorId; const currentCommunicator = communicator.communicators.find( - (communicator) => communicator.id === activeCommunicatorId + (c) => c.id === activeCommunicatorId ); const visibleBoards = getVisibleBoards({ board }); const communicatorBoards = visibleBoards.filter( - (board) => currentCommunicator.boards.indexOf(board.id) >= 0 + (b) => currentCommunicator.boards.indexOf(b.id) >= 0 ); - const { userData, displaySettings } = app; - const cboardBoards = visibleBoards.filter( - (board) => board.email === 'support@cboard.io' - ); const communicatorTour = app.liveHelp.communicatorTour || { isCommBoardsEnabled: true, isPublicBoardsEnabled: true, @@ -591,16 +40,13 @@ export const mapStateToProps = ( }; return { - ...ownProps, communicators: communicator.communicators, currentCommunicator, communicatorBoards, - cboardBoards, availableBoards: visibleBoards, - userData, + userData: app.userData, language, activeBoardId: board.activeBoardId, - dark: displaySettings.darkThemeActive, communicatorTour, isSymbolSearchTourEnabled: app.liveHelp.isSymbolSearchTourEnabled }; @@ -612,7 +58,6 @@ const mapDispatchToProps = { showNotification, deleteBoard, deleteApiBoard, - deleteBoardCommunicator, createBoard, updateBoard, addBoardCommunicator, @@ -621,10 +66,10 @@ const mapDispatchToProps = { disableTour, verifyAndUpsertCommunicator, upsertApiCommunicator, - pushCommunicator + switchBoard }; export default connect( mapStateToProps, mapDispatchToProps -)(injectIntl(CommunicatorDialogContainer)); +)(injectIntl(CommunicatorDialog)); diff --git a/src/components/Communicator/CommunicatorDialog/CommunicatorDialog.css b/src/components/Communicator/CommunicatorDialog/CommunicatorDialog.css index a7dd690f0..fccc85b61 100644 --- a/src/components/Communicator/CommunicatorDialog/CommunicatorDialog.css +++ b/src/components/Communicator/CommunicatorDialog/CommunicatorDialog.css @@ -1,260 +1,5 @@ -.CommunicatorDialog__container { - width: 100%; - height: calc(100vh - 70px); - min-height: 300px; - overflow: hidden; - padding: 0; -} - -.CommunicatorDialog__tabs { - background-color: #3f51b5; - color: #fff; -} - -.is-dark .CommunicatorDialog__tabs { - background-color: #78909c; - color: #fff; -} - -.CommunicatorDialog__tabs [class^='TabIndicator-'] { - background-color: #fff; - height: 3px; -} - -.CommunicatorDialog__content { - height: calc(100% - 48px); - overflow: auto; - padding: 0; -} - -.is-dark .CommunicatorDialog__content { - background-color: #5e5e5e; -} - -.CommunicatorDialog__content .CommunicatorDialog__spinner { - margin: 40px auto; - display: block; -} - -.CommunicatorDialog__communicatorData { - background: #eee; - padding: 16px 16px 28px; -} -.is-dark .CommunicatorDialog__communicatorData { - background: rgba(36, 36, 36, 0.933); -} - -.CommunicatorDialog__communicatorData__title { - font-size: 1.14rem; -} - -.CommunicatorDialog__communicatorData__boardsQty { - font-size: 0.85rem; - margin-top: 5px; - color: #888; -} - -.CommunicatorDialog__boards { - background: #fff; - padding: 20px 16px; -} - -.is-dark .CommunicatorDialog__boards { - background-color: #424242; -} - -.CommunicatorDialog__boards__item { - display: flex; - min-height: 150px; - margin-bottom: 10px; - text-transform: uppercase; -} - -.CommunicatorDialog__boards__item__data__title__secondary { - display: flex; - align-items: center; - align-content: space-between; -} -.is-dark .CommunicatorDialog__boards__item { - background-color: #424242; -} - -.CommunicatorDialog__boards__item__data { - position: relative; - width: 50%; - padding-left: 20px; -} - -[dir='rtl'] .CommunicatorDialog__boards__item__data { - padding-left: 0; - padding-right: 20px; -} - -.CommunicatorDialog__boards__item__image { - position: relative; - width: 40%; -} - -.CommunicatorDialog__boards__item__image_container { - position: relative; - width: 100%; -} - -.CommunicatorDialog__boards__item__image_container button { - position: absolute; - bottom: 8px; - right: 8px; - padding: 5px; - min-width: 20px; - background-color: #838383; -} - -.CommunicatorDialog__boards__item__image_container svg { - color: #fff; - font-size: 1.2rem; -} - -.CommunicatorDialog__boards__item__actions { - position: relative; - width: 10%; - color: #838383; -} - -.CommunicatorDialog__boards__item__image img { - height: 100%; - width: 100%; - object-fit: contain; -} - -.CommunicatorDialog__boards__item__image__empty { - height: 100%; - background: #eee; - position: relative; -} - -.CommunicatorDialog__boards__item__image__empty button { - position: absolute; - bottom: 8px; - right: 8px; - padding: 5px; - min-width: 20px; - background-color: #838383; -} - -.CommunicatorDialog__boards__item__image__empty button svg { - color: #fff; - font-size: 1.2rem; -} - -.CommunicatorDialog__boards__item__image__empty .ViewModuleIcon { - position: absolute; - font-size: 3.42rem; - color: #aaa; - top: calc(50% - 24px); - left: calc(50% - 24px); -} -.CommunicatorDialog__boards__item__image__button { - position: absolute; - bottom: 0; - right: 0; - z-index: 10; -} - -.CommunicatorDialog__boards__item__image__button svg { - font-size: 1.3rem; - color: #777; - opacity: 0.7; -} - -.CommunicatorDialog__boards__item__image__button svg:hover { - opacity: 1; -} - -.CommunicatorDialog__boards__item__data__button { - position: absolute; - top: 0; - right: 0; - z-index: 10; -} - -.CommunicatorDialog__boards__item__data__title, -.CommunicatorDialog__boards__item__data__author, -.CommunicatorDialog__boards__item__data__locale, -.CommunicatorDialog__boards__item__data__extra { - margin-top: 2px; - display: flex; -} - -.CommunicatorDialog__boards__item__data__title svg { - color: #777; -} - -.CommunicatorDialog__boards__item__data__date { - margin-top: 5px; - font-size: smaller; - color: grey; -} - -.CommunicatorDialog__boards__item__data__extra { - color: #369b00; - margin-top: 10px; -} - -.CommunicatorDialogButtons__container { - margin: 0 20px 0 auto; - display: flex; -} - -.CommunicatorDialogButtons__container > div { - margin-left: 20px; -} - -.CommunicatorDialogButtons__searchInput input { - min-width: 250px; - background-color: rgb(99, 112, 212); - color: #fff; - border-radius: 4px; -} - -.is-dark .CommunicatorDialogButtons__searchInput input { - background-color: #424242; -} - -.CommunicatorDialogButtons__searchInput [class*='MuiInput-underline-']:before, -.CommunicatorDialogButtons__searchInput [class*='MuiInput-underline-']:after { - border-bottom-color: #fff; -} - -.CommunicatorDialogButtons__searchInput - [class*='MuiInput-underline-']:hover:not([class^='MuiInput-disabled']):not( - [class^='MuiInput-focused'] - ):not([class^='MuiInput-error']):before, -.CommunicatorDialogButtons__searchInput - [class*='MuiInput-underline-']:hover:not([class^='MuiInput-disabled']):not( - [class^='MuiInput-focused'] - ):not([class^='MuiInput-error']):after { - border-bottom-color: #fff; -} - -#communicator-dialog-buttons-menu a { - color: inherit; - text-decoration: none; -} - -.CommunicatorDialog__imageDialog__content { - display: flex; - flex-direction: column; - align-items: center; -} - -.CommunicatorDialog__imageDialog__content img { - max-width: 300px; -} - -.CommunicatorDialog__boardInfoDialog { - user-select: text; -} - +/* Styles consumed by CommunicatorDialogTour (Joyride step content). + The dashboard UI itself is styled with makeStyles in each component. */ .CommunicatorDialog__Tour svg { color: #838383; } @@ -266,36 +11,3 @@ .CommunicatorDialog__Tour li { padding: 0px; } - -.CommunicatorDialog__boards__item__edit-title { - display: inline; - margin: 0; - padding: 0; -} - -.CommunicatorDialog__board-report-dialog { - width: minmax(auto, 50ch); - min-width: auto; - min-height: auto; -} - -.CommunicatorDialog__board-report-dialog-title { - display: grid; - grid-template-columns: minmax(auto, 90%) 2em; - align-items: center; -} - -.CommunicatorDialog__board-report-success { - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; -} - -.CommunicatorDialog__board-report-success - > .CommunicatorDialog__board-report-success-icon { - width: 3em; - height: auto; - margin-bottom: 30px; - color: rgba(7, 148, 77, 0.918); -} diff --git a/src/components/Communicator/CommunicatorDialog/CommunicatorDialog.messages.js b/src/components/Communicator/CommunicatorDialog/CommunicatorDialog.messages.js index 9aa18d1e3..822d5875b 100644 --- a/src/components/Communicator/CommunicatorDialog/CommunicatorDialog.messages.js +++ b/src/components/Communicator/CommunicatorDialog/CommunicatorDialog.messages.js @@ -5,10 +5,6 @@ export default defineMessages({ id: 'cboard.components.CommunicatorDialog.title', defaultMessage: 'My Communicator' }, - menu: { - id: 'cboard.components.CommunicatorDialog.menu', - defaultMessage: 'Menu' - }, menuRootBoardOption: { id: 'cboard.components.CommunicatorDialog.menuRootBoardOption', defaultMessage: 'Set as Root Board' @@ -25,10 +21,6 @@ export default defineMessages({ id: 'cboard.components.CommunicatorDialog.emptyBoardsList', defaultMessage: 'No boards :(' }, - loadNextPage: { - id: 'cboard.components.CommunicatorDialog.loadNextPage', - defaultMessage: 'Load More' - }, search: { id: 'cboard.components.CommunicatorDialog.search', defaultMessage: 'Search' @@ -37,18 +29,6 @@ export default defineMessages({ id: 'cboard.components.CommunicatorDialog.author', defaultMessage: 'By {author}' }, - addBoard: { - id: 'cboard.components.CommunicatorDialog.addBoard', - defaultMessage: 'Add Board' - }, - removeBoard: { - id: 'cboard.components.CommunicatorDialog.removeBoard', - defaultMessage: 'Remove Board' - }, - communicatorBoards: { - id: 'cboard.components.CommunicatorDialog.communicatorBoards', - defaultMessage: 'Boards' - }, allBoards: { id: 'cboard.components.CommunicatorDialog.allBoards', defaultMessage: 'Public Boards' @@ -61,14 +41,6 @@ export default defineMessages({ id: 'cboard.components.CommunicatorDialog.boardsQty', defaultMessage: '{qty} boards' }, - helpAndSupport: { - id: 'cboard.components.CommunicatorDialog.helpAndSupport', - defaultMessage: 'Help And Support' - }, - termsOfService: { - id: 'cboard.components.CommunicatorDialog.termsOfService', - defaultMessage: 'Terms Of Service' - }, tilesQty: { id: 'cboard.components.CommunicatorDialog.tilesQty', defaultMessage: '{qty} Tiles' @@ -105,10 +77,6 @@ export default defineMessages({ id: 'cboard.components.CommunicatorDialog.boardInfoId', defaultMessage: 'Board ID' }, - boardInfoUrl: { - id: 'cboard.components.CommunicatorDialog.boardInfoUrl', - defaultMessage: 'Board URL' - }, boardInfoDate: { id: 'cboard.components.CommunicatorDialog.boardInfoDate', defaultMessage: 'Last edited' @@ -146,6 +114,22 @@ export default defineMessages({ id: 'cboard.components.CommunicatorDialog.activeBoard', defaultMessage: 'Active Board' }, + statusPublic: { + id: 'cboard.components.CommunicatorDialog.statusPublic', + defaultMessage: 'Public' + }, + statusPrivate: { + id: 'cboard.components.CommunicatorDialog.statusPrivate', + defaultMessage: 'Private' + }, + statusRoot: { + id: 'cboard.components.CommunicatorDialog.statusRoot', + defaultMessage: 'Root' + }, + statusActive: { + id: 'cboard.components.CommunicatorDialog.statusActive', + defaultMessage: 'Active' + }, copyBoard: { id: 'cboard.components.CommunicatorDialog.copyBoard', defaultMessage: 'Copy Board into your communicator' @@ -177,14 +161,6 @@ export default defineMessages({ id: 'cboard.components.CommunicatorDialog.accept', defaultMessage: 'Accept' }, - imageBoard: { - id: 'cboard.components.CommunicatorDialog.imageBoard', - defaultMessage: 'Edit Board Image' - }, - imageBoardDescription: { - id: 'cboard.components.CommunicatorDialog.imageBoardDescription', - defaultMessage: 'You can either upload or look for symbols from libraries.' - }, imageSearch: { id: 'cboard.components.CommunicatorDialog.imageSearch', defaultMessage: 'Search for symbols' @@ -281,14 +257,6 @@ export default defineMessages({ defaultMessage: 'Remove board from the communicator, but it will not delete the board from the database and it will be still available under All My Boards tab.' }, - walkthroughAllMyBoardsEditBoardImage: { - id: 'cboard.components.CommunicatorDialog.walkthroughAllMyBoardsEditBoardImage', - defaultMessage: 'You can edit the image that represents the board.' - }, - walkthroughAllMyBoardsEditBoardName: { - id: 'cboard.components.CommunicatorDialog.walkthroughAllMyBoardsEditBoardName', - defaultMessage: 'You can edit the board title and description.' - }, walkthroughAllMyBoardsAddBoard: { id: 'cboard.components.CommunicatorDialog.walkthroughAllMyBoardsAddBoard', defaultMessage: 'Add board into the communicator.' @@ -345,5 +313,155 @@ export default defineMessages({ boardInfoLocale: { id: 'cboard.components.CommunicatorDialog.boardInfoLanguage', defaultMessage: 'Language' + }, + // --- Dashboard layout --- + sectionMyBoards: { + id: 'cboard.components.CommunicatorDialog.sectionMyBoards', + defaultMessage: 'My Boards' + }, + sectionMyBoardsHint: { + id: 'cboard.components.CommunicatorDialog.sectionMyBoardsHint', + defaultMessage: 'All the boards you have created' + }, + sectionCommunity: { + id: 'cboard.components.CommunicatorDialog.sectionCommunity', + defaultMessage: 'Community' + }, + sectionCommunityHint: { + id: 'cboard.components.CommunicatorDialog.sectionCommunityHint', + defaultMessage: 'Discover and copy public boards' + }, + navigation: { + id: 'cboard.components.CommunicatorDialog.navigation', + defaultMessage: 'Navigation' + }, + expandNavigation: { + id: 'cboard.components.CommunicatorDialog.expandNavigation', + defaultMessage: 'Expand navigation' + }, + collapseNavigation: { + id: 'cboard.components.CommunicatorDialog.collapseNavigation', + defaultMessage: 'Collapse navigation' + }, + searchBoardsPlaceholder: { + id: 'cboard.components.CommunicatorDialog.searchBoardsPlaceholder', + defaultMessage: 'Search by name or author' + }, + clearSearch: { + id: 'cboard.components.CommunicatorDialog.clearSearch', + defaultMessage: 'Clear search' + }, + gridView: { + id: 'cboard.components.CommunicatorDialog.gridView', + defaultMessage: 'Grid view' + }, + listView: { + id: 'cboard.components.CommunicatorDialog.listView', + defaultMessage: 'List view' + }, + openBoardInNewTab: { + id: 'cboard.components.CommunicatorDialog.openBoardInNewTab', + defaultMessage: 'Open in a new tab' + }, + errorGettingBoards: { + id: 'cboard.components.CommunicatorDialog.errorGettingBoards', + defaultMessage: 'There was an error loading the boards.' + }, + tryAgain: { + id: 'cboard.components.CommunicatorDialog.tryAgain', + defaultMessage: 'Try again' + }, + noBoardsFound: { + id: 'cboard.components.CommunicatorDialog.noBoardsFound', + defaultMessage: 'No boards match your search.' + }, + sectionQuickAccess: { + id: 'cboard.components.CommunicatorDialog.sectionQuickAccess', + defaultMessage: 'Quick access' + }, + sectionQuickAccessHint: { + id: 'cboard.components.CommunicatorDialog.sectionQuickAccessHint', + defaultMessage: 'Boards you can reach from the communicator bar' + }, + quickAccessExplainer: { + id: 'cboard.components.CommunicatorDialog.quickAccessExplainer', + defaultMessage: + 'These {qty} boards appear in your communicator bar. They all live in My Boards.' + }, + quickAccessEmpty: { + id: 'cboard.components.CommunicatorDialog.quickAccessEmpty', + defaultMessage: 'Your communicator bar is empty.' + }, + quickAccessAddFromMyBoards: { + id: 'cboard.components.CommunicatorDialog.quickAccessAddFromMyBoards', + defaultMessage: 'Add a board from My Boards' + }, + addToQuickAccess: { + id: 'cboard.components.CommunicatorDialog.addToQuickAccess', + defaultMessage: 'Add to quick access' + }, + removeFromQuickAccess: { + id: 'cboard.components.CommunicatorDialog.removeFromQuickAccess', + defaultMessage: 'Remove from quick access' + }, + boardOptions: { + id: 'cboard.components.CommunicatorDialog.boardOptions', + defaultMessage: 'Board options' + }, + moveUp: { + id: 'cboard.components.CommunicatorDialog.moveUp', + defaultMessage: 'Move up' + }, + moveDown: { + id: 'cboard.components.CommunicatorDialog.moveDown', + defaultMessage: 'Move down' + }, + boardMoved: { + id: 'cboard.components.CommunicatorDialog.boardMoved', + defaultMessage: '{name} moved to position {position} of {total}' + }, + boardDetails: { + id: 'cboard.components.CommunicatorDialog.boardDetails', + defaultMessage: 'Board details' + }, + selectBoardPrompt: { + id: 'cboard.components.CommunicatorDialog.selectBoardPrompt', + defaultMessage: 'Select a board to see its details and actions' + }, + closeDetails: { + id: 'cboard.components.CommunicatorDialog.closeDetails', + defaultMessage: 'Close details' + }, + showBoard: { + id: 'cboard.components.CommunicatorDialog.showBoard', + defaultMessage: 'Show' + }, + cloneBoard: { + id: 'cboard.components.CommunicatorDialog.cloneBoard', + defaultMessage: 'Clone' + }, + cloneBoardUnavailable: { + id: 'cboard.components.CommunicatorDialog.cloneBoardUnavailable', + defaultMessage: 'Cloning is not available yet' + }, + exportBoard: { + id: 'cboard.components.CommunicatorDialog.exportBoard', + defaultMessage: 'Export' + }, + exportBoardToPdf: { + id: 'cboard.components.CommunicatorDialog.exportBoardToPdf', + defaultMessage: 'Save as PDF' + }, + exportingBoard: { + id: 'cboard.components.CommunicatorDialog.exportingBoard', + defaultMessage: 'Exporting {name}…' + }, + boardExported: { + id: 'cboard.components.CommunicatorDialog.boardExported', + defaultMessage: '{name} exported' + }, + boardExportError: { + id: 'cboard.components.CommunicatorDialog.boardExportError', + defaultMessage: 'Could not export {name}' } }); diff --git a/src/components/Communicator/CommunicatorDialog/CommunicatorDialog.styles.js b/src/components/Communicator/CommunicatorDialog/CommunicatorDialog.styles.js new file mode 100644 index 000000000..760060565 --- /dev/null +++ b/src/components/Communicator/CommunicatorDialog/CommunicatorDialog.styles.js @@ -0,0 +1,57 @@ +import { alpha } from '@material-ui/core/styles'; + +/** + * Shared presentational tokens for the dashboard surfaces (cards / rows). + * Everything is derived from the active theme so light/dark and custom + * primary colors keep working; values fall back gracefully if a theme token + * is missing. + */ + +// Softer, more modern corner than the default 4px. +export const softRadius = (theme) => (theme.shape.borderRadius || 4) * 2; + +const surfaceTransition = (theme) => + theme.transitions.create(['box-shadow', 'border-color', 'transform'], { + duration: theme.transitions.duration.shorter + }); + +/** + * Hover / focus treatment shared by BoardCard and BoardRow so both surfaces + * react identically. `lift` adds a subtle upward translate (cards only). + */ +export const surfaceInteractive = (theme, { lift = false } = {}) => ({ + transition: surfaceTransition(theme), + '&:hover': { + borderColor: alpha(theme.palette.primary.main, 0.5), + boxShadow: theme.shadows[3], + ...(lift ? { transform: 'translateY(-2px)' } : {}) + }, + '&:focus-within': { + borderColor: theme.palette.primary.main, + boxShadow: `0 0 0 2px ${alpha(theme.palette.primary.main, 0.4)}` + } +}); + +/** + * Accent border used when a board is the communicator root or the active + * board, so its status reads at a glance without extra chrome. + */ +export const surfaceAccent = (theme) => ({ + borderColor: alpha(theme.palette.primary.main, 0.6), + boxShadow: `inset 3px 0 0 0 ${theme.palette.primary.main}` +}); + +/** + * Theme-aware busy overlay. The previous hard-coded white wash was invisible + * (and wrong) in dark mode. + */ +export const busyOverlay = (theme) => ({ + position: 'absolute', + inset: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: alpha(theme.palette.background.paper, 0.72), + backdropFilter: 'blur(2px)', + zIndex: 2 +}); diff --git a/src/components/Communicator/CommunicatorDialog/CommunicatorDialog.test.js b/src/components/Communicator/CommunicatorDialog/CommunicatorDialog.test.js index e71e5d348..11fbf3b02 100644 --- a/src/components/Communicator/CommunicatorDialog/CommunicatorDialog.test.js +++ b/src/components/Communicator/CommunicatorDialog/CommunicatorDialog.test.js @@ -1,168 +1,147 @@ import React from 'react'; -import { shallowMatchSnapshot } from '../../../common/test_utils'; import { shallow } from 'enzyme'; import toJson from 'enzyme-to-json'; import CommunicatorDialog from './CommunicatorDialog.component'; -import { TAB_INDEXES } from './CommunicatorDialog.constants'; - -jest.mock('./CommunicatorDialog.messages', () => { - return { - title: { - id: 'cboard.components.CommunicatorDialog.title', - defaultMessage: 'My Communicator' - }, - menu: { - id: 'cboard.components.CommunicatorDialog.menu', - defaultMessage: 'Menu' - }, - search: { - id: 'cboard.components.CommunicatorDialog.search', - defaultMessage: 'Search' - }, - author: { - id: 'cboard.components.CommunicatorDialog.author', - defaultMessage: 'By {author}' - }, - addBoard: { - id: 'cboard.components.CommunicatorDialog.addBoard', - defaultMessage: 'Add Board' - }, - removeBoard: { - id: 'cboard.components.CommunicatorDialog.removeBoard', - defaultMessage: 'Remove Board' - }, - communicatorBoards: { - id: 'cboard.components.CommunicatorDialog.communicatorBoards', - defaultMessage: 'Included Boards' - }, - allBoards: { - id: 'cboard.components.CommunicatorDialog.allBoards', - defaultMessage: 'All Boards' - }, - myBoards: { - id: 'cboard.components.CommunicatorDialog.myBoards', - defaultMessage: 'My Boards' - }, - boardsQty: { - id: 'cboard.components.CommunicatorDialog.boardsQty', - defaultMessage: '{qty} boards' +import { SECTIONS } from './CommunicatorDialog.constants'; + +// react-intl is auto-mocked, so the real messages module resolves to undefined. +// Provide a proxy that returns a descriptor for any key the component reads. +jest.mock('./CommunicatorDialog.messages', () => ({ + __esModule: true, + default: new Proxy( + {}, + { + get: (target, prop) => ({ + id: String(prop), + defaultMessage: String(prop) + }) } - }; -}); + ) +})); -let selectedTab = TAB_INDEXES.COMMUNICATOR_BOARDS; -let loading = false; - -const mockBoard = { - name: 'tewt', - id: '12345678901234567', - tiles: [{ id: '1234567890123456', loadBoard: '456456456456456456456' }], - isPublic: true, - caption: 'test', - email: 'asd@qwe.com', - markToUpdate: true -}; -const mockComm = { - id: 'cboard_default', - name: "Cboard's Communicator", - description: "Cboard's default communicator", - author: 'Cboard Team', - email: 'support@cboard.io', - rootBoard: '12345678901234567', - boards: ['root', '12345678901234567'] +const intlMock = { + formatMessage: ({ defaultMessage }) => defaultMessage }; -const intlMock = { - formatMessage: ({ id }) => id +const mockCommunicator = { + id: 'comm-1', + name: 'My Communicator', + rootBoard: 'board-1', + boards: ['board-1', 'board-2'] }; -const COMPONENT_PROPS = { +const buildProps = (overrides = {}) => ({ + open: true, intl: intlMock, - selectedTab, - loading, - userData: { - authToken: 'something' - }, - communicator: mockComm, - board: mockBoard, - onTabChange: (event, value = TAB_INDEXES.COMMUNICATOR_BOARDS) => { - loading = true; - selectedTab = value; - }, - deleteMyBoard: jest.fn(), + onClose: jest.fn(), + userData: { authToken: 'token', name: 'Tester', email: 'tester@cboard.io' }, + language: { lang: 'en-US' }, + communicators: [mockCommunicator], + currentCommunicator: mockCommunicator, + communicatorBoards: [], + availableBoards: [], + activeBoardId: 'board-1', + communicatorTour: {}, + isSymbolSearchTourEnabled: false, + createBoard: jest.fn(), + updateBoard: jest.fn(), + replaceBoard: jest.fn(), + addBoards: jest.fn(), + deleteBoard: jest.fn(), + deleteApiBoard: jest.fn(), + updateApiBoard: jest.fn(), + updateApiObjectsNoChild: jest.fn(), + addBoardCommunicator: jest.fn(), + verifyAndUpsertCommunicator: jest.fn(), + upsertApiCommunicator: jest.fn(), showNotification: jest.fn(), - copyBoard: jest.fn(), - publishBoard: jest.fn(), - addOrRemoveBoard: jest.fn(), - setRootBoard: jest.fn(), - publishBoardAction: jest.fn(), - updateMyBoard: jest.fn(), - boardReport: jest.fn(), disableTour: jest.fn(), - communicatorTour: {} -}; + switchBoard: jest.fn(), + ...overrides +}); -describe('CommunicatorDialog tests', () => { - beforeEach(() => { - selectedTab = TAB_INDEXES.COMMUNICATOR_BOARDS; - loading = false; +describe('CommunicatorDialog (dashboard)', () => { + test('renders the dashboard shell', () => { + const wrapper = shallow(); + expect(toJson(wrapper)).toMatchSnapshot(); }); - test('default renderer', () => { - shallowMatchSnapshot(); + test('renders the navigation and section header', () => { + const wrapper = shallow(); + expect(wrapper.find('DashboardNav').length).toBe(1); + expect(wrapper.find('SectionHeader').length).toBe(1); }); - test('tabs behavior', () => { - let wrapper = shallow( - + test('starts on the My Boards section', () => { + const wrapper = shallow(); + expect(wrapper.find('DashboardNav').prop('section')).toBe( + SECTIONS.MY_BOARDS ); - let tree = toJson(wrapper); - expect(tree).toMatchSnapshot(); - let tabsBar = wrapper.find('.CommunicatorDialog__tabs'); - - // First tab is active (tabs value = 0) - expect(selectedTab).toBe(0); - expect(tabsBar.children().get(selectedTab).props.className).toBe('active'); - - tabsBar.simulate('change', {}, TAB_INDEXES.PUBLIC_BOARDS); - expect(selectedTab).toBe(TAB_INDEXES.PUBLIC_BOARDS); - // Mount with new tab selected - wrapper = shallow( - - ); - tree = toJson(wrapper); - expect(tree).toMatchSnapshot(); - tabsBar = wrapper.find('.CommunicatorDialog__tabs'); - expect(tabsBar.children().get(selectedTab).props.className).toBe('active'); }); - test('loading behavior', () => { - let wrapper = shallow( - + test('renders the quick access tray on My Communicator', () => { + const wrapper = shallow(); + wrapper.find('DashboardNav').prop('onChange')(SECTIONS.MY_COMMUNICATOR); + expect(wrapper.find('QuickAccessTray').length).toBe(1); + expect(wrapper.find('BoardsView').length).toBe(0); + }); + + test('renders the boards view and details surface on the default section', () => { + const wrapper = shallow(); + expect(wrapper.find('BoardsView').length).toBe(1); + expect(wrapper.find('BoardDetailsSurface').length).toBe(1); + }); + + test('renders a live region', () => { + const wrapper = shallow(); + expect(wrapper.find('LiveRegion').length).toBe(1); + }); + + test('hides search and the view toggle on quick access', () => { + const wrapper = shallow(); + expect(wrapper.find('ContentToolbar').length).toBe(1); + + wrapper.find('DashboardNav').prop('onChange')(SECTIONS.MY_COMMUNICATOR); + expect(wrapper.find('ContentToolbar').length).toBe(0); + }); + + test('switching section updates the active section', () => { + const wrapper = shallow(); + wrapper.find('DashboardNav').prop('onChange')(SECTIONS.COMMUNITY); + expect(wrapper.find('DashboardNav').prop('section')).toBe( + SECTIONS.COMMUNITY ); - let tree = toJson(wrapper); - expect(tree).toMatchSnapshot(); - let tabsBar = wrapper.find('.CommunicatorDialog__tabs'); - - tabsBar.simulate('change', {}, TAB_INDEXES.PUBLIC_BOARDS); - expect(selectedTab).toBe(TAB_INDEXES.PUBLIC_BOARDS); - - expect(wrapper.find('.CommunicatorDialog__spinner').length).toBe(0); - - // Mount with new tab selected - wrapper = shallow( - + expect(wrapper.find('SectionHeader').prop('section')).toBe( + SECTIONS.COMMUNITY ); - tree = toJson(wrapper); - expect(tree).toMatchSnapshot(); - expect(wrapper.find('.CommunicatorDialog__spinner').length).toBe(1); + }); + + test('drops a pending search when the section changes', () => { + jest.useFakeTimers(); + const wrapper = shallow(); + + wrapper.find('ContentToolbar').prop('onSearchChange')('abc'); + wrapper.find('DashboardNav').prop('onChange')(SECTIONS.COMMUNITY); + + jest.advanceTimersByTime(1000); + wrapper.update(); + + expect(wrapper.find('ContentToolbar').prop('search')).toBe(''); + expect(wrapper.find('BoardsView').prop('hasSearch')).toBe(false); + jest.useRealTimers(); + }); + + test('opens with the default view mode when storage is unavailable', () => { + const getItem = jest + .spyOn(window.localStorage.__proto__, 'getItem') + .mockImplementation(() => { + throw new Error('blocked'); + }); + + expect(() => + shallow() + ).not.toThrow(); + + getItem.mockRestore(); }); }); diff --git a/src/components/Communicator/CommunicatorDialog/CommunicatorDialogBoardItem.component.js b/src/components/Communicator/CommunicatorDialog/CommunicatorDialogBoardItem.component.js deleted file mode 100644 index 940fd61dd..000000000 --- a/src/components/Communicator/CommunicatorDialog/CommunicatorDialogBoardItem.component.js +++ /dev/null @@ -1,1071 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { FormattedMessage, intlShape } from 'react-intl'; -import moment from 'moment'; -import PublicIcon from '@material-ui/icons/Public'; -import KeyIcon from '@material-ui/icons/VpnKey'; -import ViewModuleIcon from '@material-ui/icons/ViewModule'; -import DeleteIcon from '@material-ui/icons/Delete'; -import InputIcon from '@material-ui/icons/Input'; -import ClearIcon from '@material-ui/icons/Clear'; -import HomeIcon from '@material-ui/icons/Home'; -import InfoIcon from '@material-ui/icons/Info'; -import SearchIcon from '@material-ui/icons/Search'; -import EditIcon from '@material-ui/icons/Edit'; -import RemoveRedEyeIcon from '@material-ui/icons/RemoveRedEye'; -import QueueIcon from '@material-ui/icons/Queue'; -import FlagIcon from '@material-ui/icons/Flag'; -import LinearProgress from '@material-ui/core/LinearProgress'; -import CheckCircleIcon from '@material-ui/icons/CheckCircle'; -import ListItem from '@material-ui/core/ListItem'; -import ListItemText from '@material-ui/core/ListItemText'; -import Dialog from '@material-ui/core/Dialog'; -import DialogTitle from '@material-ui/core/DialogTitle'; -import DialogContent from '@material-ui/core/DialogContent'; -import DialogActions from '@material-ui/core/DialogActions'; -import Button from '@material-ui/core/Button'; -import Typography from '@material-ui/core/Typography'; -import Tooltip from '@material-ui/core/Tooltip'; -import TextField from '@material-ui/core/TextField'; -import CircularProgress from '@material-ui/core/CircularProgress'; -import Slide from '@material-ui/core/Slide'; -import DialogContentText from '@material-ui/core/DialogContentText'; -import LoadingIcon from '../../UI/LoadingIcon'; -import LanguageIcon from '@material-ui/icons/Language'; - -import IconButton from '../../UI/IconButton'; -import { TAB_INDEXES } from './CommunicatorDialog.constants'; -import messages from './CommunicatorDialog.messages'; -import { isCordova, isPackagedApp } from '../../../cordova-util'; -import InputImage from '../../UI/InputImage'; -import SymbolSearch from '../../Board/SymbolSearch'; -import PremiumFeature from '../../PremiumFeature'; -import Language from '../../Settings/Language/Language.messages'; - -const Transition = React.forwardRef(function Transition(props, ref) { - return ; -}); - -const defaultReportDialogState = { - openBoardReport: false, - reportReason: '', - loading: false, - error: false, - success: false -}; - -const getFormattedName = (lang) => { - lang = lang || navigator.language; - const processLanguage = (lang) => { - const locale = lang.slice(0, 2).toLowerCase(); - const formattedLocale = locale === 'sr-ME' ? 'srme' : locale; - return ; - }; - return processLanguage(lang); -}; - -class CommunicatorDialogBoardItem extends React.Component { - constructor(props) { - super(props); - - this.state = { - loading: false, - - reportDialogState: defaultReportDialogState, - - openBoardInfo: false, - openDeleteBoard: false, - openPublishBoard: false, - openCopyBoard: false, - openImageBoard: false, - openEditBoardTitle: false, - imageBoard: null, - isSymbolSearchOpen: false, - editBoardTitleDialogValue: props.board.name, - editBoardDescriptionDialogValue: props.board.description - ? props.board.description - : '' - }; - - this.handleDialogClose = this.handleDialogClose.bind(this); - } - - openMenu(e) { - this.setState({ menu: e.currentTarget }); - } - - closeMenu() { - this.setState({ menu: null }); - } - - handleBoardInfoOpen() { - this.setState({ - openBoardInfo: true - }); - } - - handleBoardDeleteOpen() { - this.setState({ - openDeleteBoard: true - }); - } - - handleBoardReportOpen() { - this.setState({ - reportDialogState: { - ...this.state.reportDialogState, - openBoardReport: true - } - }); - } - - handleBoardDescriptionChange = (event) => { - const { value: editBoardDescriptionDialogValue } = event.target; - this.setState({ - editBoardDescriptionDialogValue: editBoardDescriptionDialogValue - }); - }; - - handleBoardTitleChange = (event) => { - const { value: editBoardTitleDialogValue } = event.target; - this.setState({ editBoardTitleDialogValue: editBoardTitleDialogValue }); - }; - - handleBoardImageChange(image) { - this.setState({ imageBoard: image }); - } - - async handleBoardPublishOpen(board) { - if (!board.isPublic && !board.description) { - this.setState({ - openPublishBoard: true - }); - } else { - this.setState({ loading: true }); - try { - if (this.state.editBoardDescriptionDialogValue) { - board.description = this.state.editBoardDescriptionDialogValue; - } - await this.props.publishBoard(board); - } catch (err) { - } finally { - this.setState({ - loading: false - }); - } - } - } - - handleBoardCopyOpen() { - this.setState({ - openCopyBoard: true - }); - } - - async handleBoardCopy(board) { - this.setState({ - loading: true - }); - try { - await this.props.copyBoard(board); - } catch (err) { - } finally { - this.setState({ - openCopyBoard: false, - loading: false - }); - } - } - - async handleBoardDelete(board) { - this.setState({ - openDeleteBoard: false, - loading: true - }); - try { - await this.props.deleteMyBoard(board); - } catch (err) { - } finally { - this.setState({ - loading: false - }); - } - } - - handleBoardReport = async (boardUrl) => { - const { - boardReport, - board: { id, name, author, description }, - userData: { name: whistleblowerName, email: whistleblowerEmail } - } = this.props; - const { reportDialogState } = this.state; - - const reportedBoardData = { - id, - name, - author, - description, - url: boardUrl, - reason: reportDialogState.reportReason - }; - - const whistleblower = { - name: whistleblowerName, - email: whistleblowerEmail - }; - - const reportData = { - ...reportedBoardData, - whistleblower - }; - - this.setState({ - reportDialogState: { ...reportDialogState, loading: true } - }); - try { - await boardReport(reportData); - this.setState({ - reportDialogState: { - ...reportDialogState, - error: false, - success: true, - loading: false - } - }); - } catch (error) { - this.setState({ - reportDialogState: { - ...reportDialogState, - error: true, - loading: false - } - }); - } - }; - - handleSymbolSearchClick = (event) => { - this.setState({ isSymbolSearchOpen: true }); - }; - - handleSymbolSearchChange = ({ image }) => { - return new Promise((resolve) => { - this.setState({ imageBoard: image }); - resolve(); - }); - }; - - handleSymbolSearchClose = (event) => { - this.setState({ isSymbolSearchOpen: false }); - }; - - async handleBoardImage(board) { - this.setState({ - openImageBoard: false, - loading: true - }); - - try { - if (this.state.imageBoard) { - const newBoard = { - ...board, - caption: this.state.imageBoard - }; - await this.props.updateMyBoard(newBoard); - } - } catch (err) { - } finally { - this.setState({ - imageBoard: null, - loading: false - }); - } - } - - async handleBoardTitleDesc(board) { - this.setState({ - openEditBoardTitle: false, - loading: true - }); - try { - const newBoard = { - ...board, - description: this.state.editBoardDescriptionDialogValue - ? this.state.editBoardDescriptionDialogValue - : board.description, - name: this.state.editBoardTitleDialogValue - ? this.state.editBoardTitleDialogValue - : board.name - }; - await this.props.updateMyBoard(newBoard); - } catch (err) { - console.log(err.message); - } finally { - this.setState({ - editBoardDescriptionDialogValue: board.description, - editBoardTitleDialogValue: board.name, - loading: false - }); - } - } - - async handleBoardPublish(board) { - this.setState({ - openPublishBoard: false, - loading: true - }); - - try { - if (this.state.editBoardDescriptionDialogValue) { - const newBoard = { - ...board, - description: this.state.editBoardDescriptionDialogValue - }; - await this.props.updateMyBoard(newBoard); - await this.props.publishBoard(newBoard); - } else { - await this.props.publishBoard(board); - } - } catch (err) { - } finally { - this.setState({ - editBoardDescriptionDialogValue: '', - loading: false - }); - } - } - - handleDialogClose() { - this.setState({ - reportDialogState: defaultReportDialogState, - openBoardInfo: false, - openCopyBoard: false, - openDeleteBoard: false, - openPublishBoard: false, - openEditBoardTitle: false, - openImageBoard: false - }); - } - - async setRootBoard(board) { - await this.props.setRootBoard(board); - } - - render() { - const { - board, - selectedTab, - intl, - userData, - communicator, - activeBoardId, - addOrRemoveBoard - } = this.props; - const { reportDialogState } = this.state; - const title = board.name || board.id; - const boardUrl = - window.location.origin + - '/' + - window.location.pathname.split('/')[1] + - '/' + - board.id; - const displayActions = - selectedTab === TAB_INDEXES.MY_BOARDS || - selectedTab === TAB_INDEXES.PUBLIC_BOARDS || - (selectedTab === TAB_INDEXES.COMMUNICATOR_BOARDS && !!userData.authToken); - // Cordova path cannot be absolute - const boardCaption = - isPackagedApp() && board.caption && board.caption.search('/') === 0 - ? `.${board.caption}` - : board.caption; - const imageBoard = - isPackagedApp() && - this.state.imageBoard && - this.state.imageBoard.search('/') === 0 - ? `.${this.state.imageBoard}` - : this.state.imageBoard; - - const PublishBoardButton = ( - { - this.handleBoardPublishOpen(board); - }} - > - {board.isPublic ? : } - - ); - - const PublicBoardInfo = () => { - const boardInfoLocale = ( - - ); - return ( - - - {board.name} - - - - - {intl.formatMessage(messages.boardInfoName)}:{' '} - {board.name} - - - {intl.formatMessage(messages.boardInfoAuthor)}:{' '} - {board.author} - - - {boardInfoLocale}: {getFormattedName(board.locale)} - - - {intl.formatMessage(messages.boardDescription)}:{' '} - {board.description} - - - {intl.formatMessage(messages.boardInfoDate)}:{' '} - {moment(board.lastEdited).format('DD/MM/YYYY')} - - - {intl.formatMessage(messages.boardInfoTiles)}:{' '} - {board.tiles.length} - - - {intl.formatMessage(messages.boardInfoId)}: {board.id} - - {!isCordova() && ( - - {intl.formatMessage(messages.boardInfoUrl)}:{' '} - - {boardUrl} - - - )} - - - - - - - ); - }; - - const ReportBoardDialog = () => { - const ReportSuccesContent = ( - <> - -
- - - {intl.formatMessage(messages.boardReportSuccesSubtitle)} -
- {intl.formatMessage(messages.boardReportSuccesGratitude)} -
-
-
- - - - - ); - - const ReportingContent = ( - <> - - - {intl.formatMessage(messages.boardReportContentSubtitle)} -
-
- {intl.formatMessage(messages.boardInfoName)} -
- {board.name} -
- - this.setState({ - reportDialogState: { - ...reportDialogState, - reportReason: event.target.value - } - }) - } - /> - {reportDialogState.error && ( -
- - {intl.formatMessage(messages.boardReportError)} - -
- )} - - {reportDialogState.loading && ( - - )} -
- - {!reportDialogState.loading && ( - - - - - - )} - - ); - return ( - <> - - {reportDialogState.openBoardReport && ( - <> - -
- {intl.formatMessage(messages.boardReport)} - -
-
-
- {reportDialogState.success - ? ReportSuccesContent - : ReportingContent} -
- - )} -
- - ); - }; - - return ( -
-
- {!!boardCaption && ( -
- {title} - {selectedTab === TAB_INDEXES.MY_BOARDS && ( - - )} -
- )} - {!boardCaption && ( -
- - {selectedTab === TAB_INDEXES.MY_BOARDS && ( - - )} -
- )} - - - {intl.formatMessage(messages.imageBoard)} - - - - {intl.formatMessage(messages.imageBoardDescription)} - - - - {!!this.state.imageBoard && ( - {board.name} - )} - - - - - - -
-
-
- - - {title} - {selectedTab === TAB_INDEXES.MY_BOARDS && ( -
- { - this.setState({ openEditBoardTitle: true }); - }} - label={intl.formatMessage(messages.editBoardTitle)} - > - - -
- )} -
- } - secondary={ -
- {intl.formatMessage(messages.tilesQty, { - qty: board.tiles.length - })} - - - - {getFormattedName(board.locale)} -
- } - /> - -
- - - {intl.formatMessage(messages.editBoardTitle)} - - - - {intl.formatMessage(messages.editBoardTitleDescription)} - - - - - - - - - -
- {intl.formatMessage(messages.author, { author: board.author })} -
-
- {moment(board.lastEdited).format('DD/MM/YYYY')} -
- -
- {board.isPublic && ( - - - - )} - {!board.isPublic && ( - - - - )} - {communicator.rootBoard === board.id && ( - - - - )} - {activeBoardId === board.id && ( - - - - )} -
-
-
- {this.state.loading && } -
-
- {displayActions && ( -
- {selectedTab === TAB_INDEXES.COMMUNICATOR_BOARDS && ( -
- { - addOrRemoveBoard(board); - }} - > - - - { - this.setRootBoard(board); - }} - label={intl.formatMessage(messages.menuRootBoardOption)} - > - - -
- )} - {selectedTab === TAB_INDEXES.PUBLIC_BOARDS && ( -
- - - - - - - - - - {ReportBoardDialog()} - - - { - if (!this.state.loading) this.handleDialogClose(); - }} - aria-labelledby="board-copy-dialog" - open={this.state.openCopyBoard} - > - - {intl.formatMessage(messages.copyBoard)} - - - - {intl.formatMessage(messages.copyBoardDescription)} - - - - - - - - - -
- )} - {selectedTab === TAB_INDEXES.MY_BOARDS && ( -
- { - addOrRemoveBoard(board); - }} - > - {communicator.boards.includes(board.id) ? ( - - ) : ( - - )} - - {board.description || board.isPublic ? ( - {PublishBoardButton} - ) : ( - PublishBoardButton - )} - - - {intl.formatMessage(messages.publishBoard)} - - - - {intl.formatMessage(messages.publishBoardDescription)} - - - - - - - - - - - { - this.handleBoardDeleteOpen(board); - }} - > - - - - - {intl.formatMessage(messages.deleteBoard)} - - - - {intl.formatMessage(messages.deleteBoardDescription)} - - - - - - - -
- )} -
- )} -
- - -
- ); - } -} - -CommunicatorDialogBoardItem.propTypes = { - intl: intlShape, - communicator: PropTypes.object, - activeBoardId: PropTypes.string, - selectedTab: PropTypes.number, - board: PropTypes.object, - userData: PropTypes.object, - copyBoard: PropTypes.func.isRequired, - addOrRemoveBoard: PropTypes.func.isRequired, - deleteMyBoard: PropTypes.func.isRequired, - updateMyBoard: PropTypes.func.isRequired, - publishBoard: PropTypes.func.isRequired, - boardReport: PropTypes.func.isRequired, - setRootBoard: PropTypes.func.isRequired, - showNotification: PropTypes.func.isRequired, - selectedIds: PropTypes.arrayOf(PropTypes.string), - isSymbolSearchTourEnabled: PropTypes.bool, - disableTour: PropTypes.func -}; - -export default CommunicatorDialogBoardItem; diff --git a/src/components/Communicator/CommunicatorDialog/CommunicatorDialogButtons.component.js b/src/components/Communicator/CommunicatorDialog/CommunicatorDialogButtons.component.js deleted file mode 100644 index ba186ebdc..000000000 --- a/src/components/Communicator/CommunicatorDialog/CommunicatorDialogButtons.component.js +++ /dev/null @@ -1,120 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { Link } from 'react-router-dom'; -import classNames from 'classnames'; -import TextField from '@material-ui/core/TextField'; -import SearchIcon from '@material-ui/icons/Search'; -import IconButton from '../../UI/IconButton'; -import MenuIcon from '@material-ui/icons/MoreVert'; -import Menu from '@material-ui/core/Menu'; -import MenuItem from '@material-ui/core/MenuItem'; -import { intlShape, FormattedMessage } from 'react-intl'; - -import messages from './CommunicatorDialog.messages'; - -class CommunicatorDialogButtons extends React.Component { - constructor(props) { - super(props); - - this.state = { - menu: null - }; - } - - componentDidUpdate(prevProps) { - if ( - prevProps.isSearchOpen !== this.props.isSearchOpen && - this.props.isSearchOpen - ) { - document.getElementById('communicator-dialog-buttons-search').focus(); - } - } - - openMenu(e) { - this.setState({ menu: e.currentTarget }); - } - - closeMenu() { - this.setState({ menu: null }); - } - - onSearch(event) { - const searchValue = event.target.value; - this.props.onSearch(searchValue); - } - - render() { - const { intl, searchValue, isSearchOpen, openSearchBar, dark } = this.props; - - return ( -
- {isSearchOpen && ( -
- -
- )} - {!isSearchOpen && ( -
- - - -
- )} - -
- - - - - - - - - - - - - - - -
-
- ); - } -} - -CommunicatorDialogButtons.propTypes = { - onSearch: PropTypes.func.isRequired, - intl: intlShape.isRequired, - dark: PropTypes.bool -}; - -export default CommunicatorDialogButtons; diff --git a/src/components/Communicator/CommunicatorDialog/CommunicatorDialogButtons.test.js b/src/components/Communicator/CommunicatorDialog/CommunicatorDialogButtons.test.js deleted file mode 100644 index 04227a8f5..000000000 --- a/src/components/Communicator/CommunicatorDialog/CommunicatorDialogButtons.test.js +++ /dev/null @@ -1,83 +0,0 @@ -import React from 'react'; -import { shallowMatchSnapshot } from '../../../common/test_utils'; -import { shallow } from 'enzyme'; -import toJson from 'enzyme-to-json'; -import CommunicatorDialogButtons from './CommunicatorDialogButtons.component'; - -jest.mock('./CommunicatorDialog.messages', () => { - return { - title: { - id: 'cboard.components.CommunicatorDialog.title', - defaultMessage: 'My Communicator' - }, - menu: { - id: 'cboard.components.CommunicatorDialog.menu', - defaultMessage: 'Menu' - }, - search: { - id: 'cboard.components.CommunicatorDialog.search', - defaultMessage: 'Search' - }, - author: { - id: 'cboard.components.CommunicatorDialog.author', - defaultMessage: 'By {author}' - }, - addBoard: { - id: 'cboard.components.CommunicatorDialog.addBoard', - defaultMessage: 'Add Board' - }, - removeBoard: { - id: 'cboard.components.CommunicatorDialog.removeBoard', - defaultMessage: 'Remove Board' - }, - communicatorBoards: { - id: 'cboard.components.CommunicatorDialog.communicatorBoards', - defaultMessage: 'Included Boards' - }, - allBoards: { - id: 'cboard.components.CommunicatorDialog.allBoards', - defaultMessage: 'All Boards' - }, - myBoards: { - id: 'cboard.components.CommunicatorDialog.myBoards', - defaultMessage: 'My Boards' - }, - boardsQty: { - id: 'cboard.components.CommunicatorDialog.boardsQty', - defaultMessage: '{qty} boards' - } - }; -}); - -const intlMock = { - formatMessage: ({ id }) => id -}; - -const COMPONENT_PROPS = { - intl: intlMock, - onSearch: () => {} -}; - -describe('CommunicatorDialogButtons tests', () => { - test('default renderer', () => { - shallowMatchSnapshot(); - }); - - test('menu behavior', () => { - const wrapper = shallow(); - let tree = toJson(wrapper); - expect(tree).toMatchSnapshot(); - - let menu = wrapper.find('#communicator-dialog-buttons-menu').get(0); - expect(menu.props.open).toBe(false); - - const menuButton = wrapper.find('#communicator-dialog-buttons-menu-button'); - menuButton.simulate('click', { currentTarget: 'someElement' }); - - menu = wrapper.find('#communicator-dialog-buttons-menu').get(0); - expect(menu.props.open).toBe(true); - - tree = toJson(wrapper); - expect(tree).toMatchSnapshot(); - }); -}); diff --git a/src/components/Communicator/CommunicatorDialog/CommunicatorDialogTour.component.js b/src/components/Communicator/CommunicatorDialog/CommunicatorDialogTour.component.js index 6bc4010ba..1620a53a4 100644 --- a/src/components/Communicator/CommunicatorDialog/CommunicatorDialogTour.component.js +++ b/src/components/Communicator/CommunicatorDialog/CommunicatorDialogTour.component.js @@ -1,9 +1,9 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import { FormattedMessage, intlShape } from 'react-intl'; import Joyride, { STATUS } from 'react-joyride'; -import { TAB_INDEXES } from './CommunicatorDialog.constants'; +import { SECTIONS, SECTION_TOUR_FLAGS } from './CommunicatorDialog.constants'; import Typography from '@material-ui/core/Typography'; import Grid from '@material-ui/core/Grid'; import List from '@material-ui/core/List'; @@ -13,19 +13,21 @@ import ListItemText from '@material-ui/core/ListItemText'; import QueueIcon from '@material-ui/icons/Queue'; import InfoIcon from '@material-ui/icons/Info'; import HomeIcon from '@material-ui/icons/Home'; -import ClearIcon from '@material-ui/icons/Clear'; +import StarIcon from '@material-ui/icons/Star'; import PublicIcon from '@material-ui/icons/Public'; import KeyIcon from '@material-ui/icons/VpnKey'; import RemoveRedEyeIcon from '@material-ui/icons/RemoveRedEye'; import DeleteIcon from '@material-ui/icons/Delete'; -import InputIcon from '@material-ui/icons/Input'; +import StarBorderIcon from '@material-ui/icons/StarBorder'; +import RemoveCircleOutlineIcon from '@material-ui/icons/RemoveCircleOutline'; import './CommunicatorDialog.css'; import messages from './CommunicatorDialog.messages'; const propTypes = { communicatorTour: PropTypes.object.isRequired, - selectedTab: PropTypes.number, + section: PropTypes.string.isRequired, + ready: PropTypes.bool, disableTour: PropTypes.func.isRequired, intl: intlShape.isRequired }; @@ -41,427 +43,237 @@ const joyrideStyles = { } }; +const intro = (title, body) => ({ + target: 'body', + placement: 'center', + hideCloseButton: true, + content: ( +
+

+ +

+
+ +
+
+ ) +}); + +const legend = (intl, title, items, className) => ( +
+ + + + + + + {items.map(({ key, icon, label }) => ( + + {icon} + + + ))} + + + +
+); + +// Steps are built lazily: `messages` is not resolvable at module-eval time +// under the react-intl mock the tests install. +const searchStep = () => ({ + hideCloseButton: true, + // The header never scrolls out of view, so there is nothing to scroll to. + disableScrolling: true, + target: '#CommunicatorDialog__search', + content: +}); + +const statusIconsStep = (intl) => ({ + hideCloseButton: true, + disableScrolling: true, + target: '[name="CommunicatorDialog__PropertyOption"]', + content: legend( + intl, + messages.walkthroughBoardProperties, + [ + { key: 'public', icon: , label: messages.publicBoard }, + { key: 'private', icon: , label: messages.privateBoard }, + { key: 'root', icon: , label: messages.rootBoard }, + { key: 'active', icon: , label: messages.activeBoard } + ], + 'CommunicatorDialog__Tour__boardProperty' + ) +}); + +const detailsStep = (intl, items) => ({ + hideCloseButton: true, + target: '[data-testid="board-details-panel"]', + placement: 'left', + content: legend(intl, messages.walkthroughBoardActionButton, items) +}); + +// One tour per dashboard section, each targeting only what its own section +// renders: search and status icons in the board sections, the tray in quick +// access. Nothing points at the sidebar, so no tour spans sections. +const SECTION_STEPS = { + [SECTIONS.MY_BOARDS]: (intl) => [ + intro( + messages.walkthroughCommunicatorTitle, + messages.walkthroughCommunicator + ), + searchStep(), + statusIconsStep(intl), + detailsStep(intl, [ + { + key: 'remove', + icon: , + label: messages.walkthroughAllMyBoardsRemoveBoard + }, + { + key: 'add', + icon: , + label: messages.walkthroughAllMyBoardsAddBoard + }, + { + key: 'unpublish', + icon: , + label: messages.walkthroughAllMyBoardsUnpublishBoard + }, + { + key: 'publish', + icon: , + label: messages.walkthroughAllMyBoardsPublishBoard + }, + { + key: 'delete', + icon: , + label: messages.walkthroughAllMyBoardsDeleteBoard + } + ]) + ], + [SECTIONS.MY_COMMUNICATOR]: (intl) => [ + intro(messages.sectionQuickAccess, messages.sectionQuickAccessHint), + { + hideCloseButton: true, + disableScrolling: true, + target: '[data-testid="quick-access-row"]', + placement: 'bottom', + content: legend(intl, messages.walkthroughBoardActionButton, [ + { + key: 'remove', + icon: , + label: messages.walkthroughBoardActionsRemove + }, + { + key: 'root', + icon: , + label: messages.walkthroughBoardActionsSetBoardAsRoot + } + ]) + } + ], + [SECTIONS.COMMUNITY]: (intl) => [ + intro(messages.allBoards, messages.walkthroughPublicBoards), + searchStep(), + detailsStep(intl, [ + { + key: 'copy', + icon: , + label: messages.walkthroughPublicBoardsCopy + }, + { + key: 'detail', + icon: , + label: messages.walkthroughPublicBoardsDetail + } + ]) + ] +}; + +// A step whose target is missing or hidden — a status icon a board happens not +// to have, the details panel on a phone, the sidebar behind the mobile drawer — +// leaves Joyride on an unplaceable tooltip, so drop those before starting. +const isTargetVisible = (target) => { + if (target === 'body') { + return true; + } + const element = document.querySelector(target); + return !!element && element.offsetParent !== null; +}; + function CommunicatorDialogTour({ communicatorTour, - selectedTab, + section, + ready, disableTour, intl }) { - const commBoardsHelpSteps = [ - { - target: 'body', - placement: 'center', - hideCloseButton: true, - content: ( -
-

- -

-
- -
-
- ) - }, - { - hideCloseButton: true, - target: '#CommunicatorDialog__BoardBtn', - content: ( -
- -
- ) - }, - { - hideCloseButton: true, - target: '#CommunicatorDialog__PublicBoardsBtn', - content: ( -
- -
- ) - }, - { - hideCloseButton: true, - target: '#CommunicatorDialog__AllMyBoardsBtn', - content: ( -
- -
- ) - }, - { - hideCloseButton: true, - target: '.CommunicatorDialogButtons__searchButton', - content: - }, - { - hideCloseButton: true, - target: '[name="CommunicatorDialog__PropertyOption"]', - content: ( -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- ) - }, - { - hideCloseButton: true, - target: '.CommunicatorDialog__boards__item__actions', - placement: 'left', - content: ( -
- - - - - - - - - - - - - - - - - - - - - -
- ) - } - ]; + const flag = SECTION_TOUR_FLAGS[section]; + const enabled = !!flag && !!communicatorTour[flag] && ready; + const [steps, setSteps] = useState([]); - const publicBoardsHelpSteps = [ - { - target: 'body', - placement: 'center', - hideCloseButton: true, - content: ( -
-

- -

-
- -
-
- ) - }, - { - hideCloseButton: true, - target: '.CommunicatorDialog__boards__item__actions', - placement: 'left', - content: ( -
- - - - - - - - - - - - - - - - - - - - - -
- ) + // Targets are resolved once the section has painted its boards: measuring + // while the list is still loading would drop every step but the intro. + useEffect(() => { + if (!enabled) { + setSteps([]); + return; } - ]; - - const allMyBoardsHelpSteps = [ - { - target: 'body', - placement: 'center', - hideCloseButton: true, - content: ( -
-

- -

-
- -
-
+ setSteps( + SECTION_STEPS[section](intl).filter((step) => + isTargetVisible(step.target) ) - }, - { - target: '#CommunicatorDialog__boards__item__image__Btn', - hideCloseButton: true, - content: ( - - ) - }, - { - target: '.CommunicatorDialog__boards__item__edit-title', - hideCloseButton: true, - content: ( - - ) - }, - { - hideCloseButton: true, - target: '.CommunicatorDialog__boards__item__actions', - placement: 'left', - content: ( -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- ) - } - ]; + ); + }, [enabled, section, intl]); + + if (!enabled || !steps.length) { + return null; + } + return ( -
- {selectedTab === TAB_INDEXES.PUBLIC_BOARDS && - communicatorTour.isPublicBoardsEnabled && ( - { - const { status } = data; - if ([STATUS.FINISHED, STATUS.SKIPPED].includes(status)) { - disableTour({ - communicatorTour: { - ...communicatorTour, - isPublicBoardsEnabled: false - } - }); - } - }} - steps={publicBoardsHelpSteps} - continuous={true} - showSkipButton={true} - disableScrollParentFix={true} - disableScrolling={true} - showProgress={false} - disableOverlayClose={true} - run={communicatorTour.isPublicBoardsEnabled} - styles={joyrideStyles} - locale={{ - last: intl.formatMessage(messages.walkthroughEndTour), - skip: intl.formatMessage(messages.walkthroughCloseTour), - next: intl.formatMessage(messages.walkthroughNext), - back: intl.formatMessage(messages.walkthroughBack) - }} - /> - )} - {selectedTab === TAB_INDEXES.MY_BOARDS && - communicatorTour.isAllMyBoardsEnabled && ( - { - const { status } = data; - if ([STATUS.FINISHED, STATUS.SKIPPED].includes(status)) { - disableTour({ - communicatorTour: { - ...communicatorTour, - isAllMyBoardsEnabled: false - } - }); - } - }} - steps={allMyBoardsHelpSteps} - continuous={true} - showSkipButton={true} - showProgress={false} - disableScrollParentFix={true} - disableOverlayClose={true} - scrollOffset={250} - run={communicatorTour.isAllMyBoardsEnabled} - styles={joyrideStyles} - locale={{ - last: intl.formatMessage(messages.walkthroughEndTour), - skip: intl.formatMessage(messages.walkthroughCloseTour), - next: intl.formatMessage(messages.walkthroughNext), - back: intl.formatMessage(messages.walkthroughBack) - }} - /> - )} - {selectedTab === TAB_INDEXES.COMMUNICATOR_BOARDS && - communicatorTour.isCommBoardsEnabled && ( - { - const { status } = data; - if ([STATUS.FINISHED, STATUS.SKIPPED].includes(status)) { - disableTour({ - communicatorTour: { - ...communicatorTour, - isCommBoardsEnabled: false - } - }); - } - }} - steps={commBoardsHelpSteps} - continuous={true} - showSkipButton={true} - showProgress={false} - disableOverlayClose={true} - disableScrolling={true} - disableScrollParentFix={true} - run={communicatorTour.isCommBoardsEnabled} - styles={joyrideStyles} - locale={{ - last: intl.formatMessage(messages.walkthroughEndTour), - skip: intl.formatMessage(messages.walkthroughCloseTour), - next: intl.formatMessage(messages.walkthroughNext), - back: intl.formatMessage(messages.walkthroughBack) - }} - /> - )} -
+ { + if ([STATUS.FINISHED, STATUS.SKIPPED].includes(status)) { + disableTour({ + communicatorTour: { ...communicatorTour, [flag]: false } + }); + } + }} + steps={steps} + continuous={true} + /* Joyride's scroll-parent "fix" rewrites a non-scrolling parent's + overflow to `initial`, which un-clips the dialog container and gives + the page a scrollbar it should not have. Every remaining target + measures correctly without it: their offset parent is the fixed + full-screen dialog, so offsetTop is already a viewport coordinate. */ + disableScrollParentFix={true} + showSkipButton={true} + showProgress={false} + disableOverlayClose={true} + run={enabled} + styles={joyrideStyles} + locale={{ + last: intl.formatMessage(messages.walkthroughEndTour), + skip: intl.formatMessage(messages.walkthroughCloseTour), + next: intl.formatMessage(messages.walkthroughNext), + back: intl.formatMessage(messages.walkthroughBack) + }} + /> ); } +CommunicatorDialogTour.defaultProps = { + ready: true +}; + CommunicatorDialogTour.propTypes = propTypes; export default CommunicatorDialogTour; diff --git a/src/components/Communicator/CommunicatorDialog/Dashboard/ContentToolbar.js b/src/components/Communicator/CommunicatorDialog/Dashboard/ContentToolbar.js new file mode 100644 index 000000000..43661b9e8 --- /dev/null +++ b/src/components/Communicator/CommunicatorDialog/Dashboard/ContentToolbar.js @@ -0,0 +1,165 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { intlShape } from 'react-intl'; +import { alpha, makeStyles } from '@material-ui/core/styles'; +import InputBase from '@material-ui/core/InputBase'; +import IconButton from '@material-ui/core/IconButton'; +import Tooltip from '@material-ui/core/Tooltip'; +import SearchIcon from '@material-ui/icons/Search'; +import ClearIcon from '@material-ui/icons/Clear'; +import ViewModuleIcon from '@material-ui/icons/ViewModule'; +import ViewListIcon from '@material-ui/icons/ViewList'; +import ToggleButton from '@material-ui/lab/ToggleButton'; +import ToggleButtonGroup from '@material-ui/lab/ToggleButtonGroup'; + +import { VIEW_MODES } from '../CommunicatorDialog.constants'; +import messages from '../CommunicatorDialog.messages'; + +const FIELD_HEIGHT = 40; + +const useStyles = makeStyles((theme) => ({ + root: { + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-end', + gap: theme.spacing(1.5), + flex: '1 1 300px', + minWidth: 0 + }, + search: { + position: 'relative', + height: FIELD_HEIGHT, + boxSizing: 'border-box', + display: 'flex', + alignItems: 'center', + flex: 1, + minWidth: 0, + maxWidth: 380, + color: theme.palette.text.primary, + borderRadius: theme.shape.borderRadius * 2, + border: `1px solid ${theme.palette.divider}`, + backgroundColor: theme.palette.background.paper, + transition: theme.transitions.create(['border-color', 'box-shadow']), + '&:hover': { + borderColor: alpha(theme.palette.text.primary, 0.4) + }, + '&:focus-within': { + borderColor: theme.palette.primary.main, + boxShadow: `0 0 0 2px ${alpha(theme.palette.primary.main, 0.25)}` + } + }, + searchIcon: { + padding: theme.spacing(0, 1.25), + pointerEvents: 'none', + display: 'flex', + alignItems: 'center', + color: theme.palette.text.secondary + }, + inputRoot: { + color: 'inherit', + flex: 1, + minWidth: 0 + }, + inputInput: { + padding: theme.spacing(1, 1, 1, 0), + width: '100%' + }, + clearButton: { + color: theme.palette.text.secondary + }, + toggleGroup: { + height: FIELD_HEIGHT, + flexShrink: 0 + }, + toggleButton: { + padding: theme.spacing(0, 1.25), + color: theme.palette.text.secondary, + borderRadius: theme.shape.borderRadius * 2, + transition: theme.transitions.create(['background-color', 'color']), + '&.Mui-selected': { + color: theme.palette.primary.main, + backgroundColor: alpha(theme.palette.primary.main, 0.12), + '&:hover': { + backgroundColor: alpha(theme.palette.primary.main, 0.18) + } + } + } +})); + +const ContentToolbar = ({ + intl, + search, + onSearchChange, + viewMode, + onViewModeChange +}) => { + const classes = useStyles(); + + const handleViewMode = (event, value) => { + if (value) onViewModeChange(value); + }; + + return ( +
+ + + + + + + + + + + + + + +
+ ); +}; + +ContentToolbar.propTypes = { + intl: intlShape.isRequired, + search: PropTypes.string.isRequired, + onSearchChange: PropTypes.func.isRequired, + viewMode: PropTypes.string.isRequired, + onViewModeChange: PropTypes.func.isRequired +}; + +export default ContentToolbar; diff --git a/src/components/Communicator/CommunicatorDialog/Dashboard/DashboardNav.js b/src/components/Communicator/CommunicatorDialog/Dashboard/DashboardNav.js new file mode 100644 index 000000000..1aa25a4d8 --- /dev/null +++ b/src/components/Communicator/CommunicatorDialog/Dashboard/DashboardNav.js @@ -0,0 +1,318 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { intlShape } from 'react-intl'; +import { alpha, makeStyles } from '@material-ui/core/styles'; +import List from '@material-ui/core/List'; +import ListItem from '@material-ui/core/ListItem'; +import ListItemIcon from '@material-ui/core/ListItemIcon'; +import ListItemText from '@material-ui/core/ListItemText'; +import Drawer from '@material-ui/core/Drawer'; +import Hidden from '@material-ui/core/Hidden'; +import IconButton from '@material-ui/core/IconButton'; +import Tooltip from '@material-ui/core/Tooltip'; +import StarIcon from '@material-ui/icons/Star'; +import DashboardIcon from '@material-ui/icons/Dashboard'; +import PublicIcon from '@material-ui/icons/Public'; +import ChevronLeftIcon from '@material-ui/icons/ChevronLeft'; +import ChevronRightIcon from '@material-ui/icons/ChevronRight'; + +import { + SECTIONS, + SECTION_ORDER, + NAV_COLLAPSED_STORAGE_KEY +} from '../CommunicatorDialog.constants'; +import messages from '../CommunicatorDialog.messages'; + +export const NAV_WIDTH = 260; +export const NAV_WIDTH_COLLAPSED = 64; + +// Icons are static; message lookups are done at render time so importing this +// module has no side effects (react-intl is auto-mocked in tests). +const SECTION_ICONS = { + [SECTIONS.MY_BOARDS]: DashboardIcon, + [SECTIONS.MY_COMMUNICATOR]: StarIcon, + [SECTIONS.COMMUNITY]: PublicIcon +}; + +const SECTION_LABELS = { + [SECTIONS.MY_BOARDS]: { + label: 'sectionMyBoards', + hint: 'sectionMyBoardsHint' + }, + [SECTIONS.MY_COMMUNICATOR]: { + label: 'sectionQuickAccess', + hint: 'sectionQuickAccessHint' + }, + [SECTIONS.COMMUNITY]: { + label: 'sectionCommunity', + hint: 'sectionCommunityHint' + } +}; + +const readStoredCollapsed = () => { + try { + return window.localStorage.getItem(NAV_COLLAPSED_STORAGE_KEY) === 'true'; + } catch (e) { + return false; + } +}; + +const useStyles = makeStyles((theme) => { + const dark = theme.palette.type === 'dark'; + const widthTransition = theme.transitions.create('width', { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.enteringScreen + }); + return { + docked: { + height: '100%' + }, + drawerPaper: { + width: NAV_WIDTH, + position: 'relative', + border: 'none', + borderRight: `1px solid ${theme.palette.divider}`, + overflowX: 'hidden', + transition: widthTransition, + // Subtly recede the sidebar from the content surface in both modes. + backgroundColor: dark + ? alpha(theme.palette.common.white, 0.03) + : theme.palette.grey[50] + }, + drawerPaperCollapsed: { + width: NAV_WIDTH_COLLAPSED + }, + temporaryPaper: { + width: NAV_WIDTH, + backgroundColor: dark + ? theme.palette.background.paper + : theme.palette.grey[50] + }, + toggleRow: { + display: 'flex', + justifyContent: 'flex-start', + padding: theme.spacing(1.5, 0, 1.5, 0.75) + }, + toggleButton: { + '& svg': { + fontSize: '1.75rem' + } + }, + list: { + paddingTop: theme.spacing(0.5), + paddingBottom: theme.spacing(1.5) + }, + item: { + position: 'relative', + borderRadius: theme.shape.borderRadius * 2, + margin: theme.spacing(0.25, 1), + padding: theme.spacing(1, 1.5), + width: 'auto', + transition: theme.transitions.create(['background-color']), + // Left accent bar that grows in when the section is selected. + '&::before': { + content: '""', + position: 'absolute', + left: 0, + top: '50%', + transform: 'translateY(-50%)', + width: 3, + height: 0, + borderRadius: 3, + backgroundColor: theme.palette.primary.main, + transition: theme.transitions.create('height') + }, + '&.Mui-selected': { + backgroundColor: alpha(theme.palette.primary.main, 0.12), + '&:hover': { + backgroundColor: alpha(theme.palette.primary.main, 0.18) + }, + '&::before': { + height: '60%' + } + } + }, + // Padding and icon slot stay identical in both states: margin (8) + + // padding (12) + half the icon (12) puts the glyph on the centre line of + // the collapsed rail, so it never travels during the width animation. + itemIcon: { + minWidth: 40 + }, + // The label keeps a fixed width and never wraps, so the shrinking paper + // clips it instead of reflowing it; opacity does the visible work. + itemText: { + flex: '0 0 auto', + width: NAV_WIDTH - 88, + whiteSpace: 'nowrap', + overflow: 'hidden', + opacity: 1, + transition: theme.transitions.create('opacity', { + duration: theme.transitions.duration.enteringScreen, + delay: theme.transitions.duration.shortest + }) + }, + itemTextCollapsed: { + opacity: 0, + transition: theme.transitions.create('opacity', { + duration: theme.transitions.duration.shortest + }) + }, + selectedIcon: { + color: theme.palette.primary.main + }, + selectedText: { + color: theme.palette.primary.main, + fontWeight: 600 + } + }; +}); + +const NavList = ({ intl, section, onChange, collapsed }) => { + const classes = useStyles(); + return ( + + {SECTION_ORDER.map((key) => { + const meta = SECTION_LABELS[key]; + const Icon = SECTION_ICONS[key]; + const selected = section === key; + const label = intl.formatMessage(messages[meta.label]); + const item = ( + onChange(key)} + aria-current={selected ? 'page' : undefined} + > + + + + + + ); + + // Collapsed items show only the icon, so the label also goes in a + // tooltip (which MUI exposes on keyboard focus too). + return collapsed ? ( + + {item} + + ) : ( + item + ); + })} + + ); +}; + +const DashboardNav = ({ + intl, + section, + onChange, + mobileOpen, + onMobileClose +}) => { + const classes = useStyles(); + const [collapsed, setCollapsed] = useState(readStoredCollapsed); + + const handleChange = (key) => { + onChange(key); + onMobileClose(); + }; + + const handleToggle = () => { + const next = !collapsed; + setCollapsed(next); + try { + window.localStorage.setItem(NAV_COLLAPSED_STORAGE_KEY, String(next)); + } catch (e) { + // storage unavailable (private mode) — collapse state stays session-only + } + }; + + const toggleLabel = intl.formatMessage( + collapsed ? messages.expandNavigation : messages.collapseNavigation + ); + + return ( + <> + + +
+ + + {collapsed ? : } + + +
+
+ +
+
+
+ + + + + + + ); +}; + +DashboardNav.propTypes = { + intl: intlShape.isRequired, + section: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + mobileOpen: PropTypes.bool, + onMobileClose: PropTypes.func +}; + +DashboardNav.defaultProps = { + mobileOpen: false, + onMobileClose: () => {} +}; + +export default DashboardNav; diff --git a/src/components/Communicator/CommunicatorDialog/Dashboard/LiveRegion.js b/src/components/Communicator/CommunicatorDialog/Dashboard/LiveRegion.js new file mode 100644 index 000000000..e7fb2c6b7 --- /dev/null +++ b/src/components/Communicator/CommunicatorDialog/Dashboard/LiveRegion.js @@ -0,0 +1,40 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +/** + * Polite screen-reader announcer for asynchronous results (export finished, + * board moved). Visually hidden but never `display: none`, which would stop + * assistive tech from reading it. + */ +const visuallyHidden = { + position: 'absolute', + width: 1, + height: 1, + padding: 0, + margin: -1, + overflow: 'hidden', + clip: 'rect(0 0 0 0)', + whiteSpace: 'nowrap', + border: 0 +}; + +const LiveRegion = ({ message }) => ( +
+ {message} +
+); + +LiveRegion.propTypes = { + message: PropTypes.string +}; + +LiveRegion.defaultProps = { + message: '' +}; + +export default LiveRegion; diff --git a/src/components/Communicator/CommunicatorDialog/Dashboard/SectionHeader.js b/src/components/Communicator/CommunicatorDialog/Dashboard/SectionHeader.js new file mode 100644 index 000000000..8c1ef8a0c --- /dev/null +++ b/src/components/Communicator/CommunicatorDialog/Dashboard/SectionHeader.js @@ -0,0 +1,110 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { intlShape } from 'react-intl'; +import { makeStyles } from '@material-ui/core/styles'; +import Typography from '@material-ui/core/Typography'; +import IconButton from '@material-ui/core/IconButton'; +import MenuIcon from '@material-ui/icons/Menu'; + +import TonalChip from '../../../UI/TonalChip'; +import { SECTIONS } from '../CommunicatorDialog.constants'; +import messages from '../CommunicatorDialog.messages'; + +// Message keys resolved at render time (react-intl is auto-mocked in tests, so +// the imported `messages` object is undefined there — avoid top-level access). +const SECTION_LABELS = { + [SECTIONS.MY_COMMUNICATOR]: { + title: 'sectionQuickAccess', + hint: 'sectionQuickAccessHint' + }, + [SECTIONS.MY_BOARDS]: { + title: 'sectionMyBoards', + hint: 'sectionMyBoardsHint' + }, + [SECTIONS.COMMUNITY]: { + title: 'sectionCommunity', + hint: 'sectionCommunityHint' + } +}; + +const useStyles = makeStyles((theme) => ({ + titleGroup: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + minWidth: 0 + }, + navButton: { + marginLeft: theme.spacing(-1), + // Only relevant while the sidebar is hidden (xs). + [theme.breakpoints.up('sm')]: { + display: 'none' + } + }, + titleBlock: { + flex: 1, + minWidth: 0 + }, + title: { + fontWeight: 700, + letterSpacing: '-0.01em', + lineHeight: 1.2 + }, + hint: { + color: theme.palette.text.secondary, + marginTop: theme.spacing(0.25), + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap' + }, + chip: { + flexShrink: 0 + } +})); + +// Renders the title/description as one grid item and the chip as a second, +// separate grid item so the parent header grid can give the chip its own +// fixed-width column — independent of whether a third (toolbar) column +// is present. +const SectionHeader = ({ intl, section, total, onOpenNav }) => { + const classes = useStyles(); + const meta = SECTION_LABELS[section]; + + return ( + <> +
+ {onOpenNav && ( + + + + )} +
+ + {intl.formatMessage(messages[meta.title])} + + + {intl.formatMessage(messages[meta.hint])} + +
+
+ + + ); +}; + +SectionHeader.propTypes = { + intl: intlShape.isRequired, + section: PropTypes.string.isRequired, + total: PropTypes.number.isRequired, + onOpenNav: PropTypes.func +}; + +export default SectionHeader; diff --git a/src/components/Communicator/CommunicatorDialog/Dashboard/index.js b/src/components/Communicator/CommunicatorDialog/Dashboard/index.js new file mode 100644 index 000000000..d0c94b215 --- /dev/null +++ b/src/components/Communicator/CommunicatorDialog/Dashboard/index.js @@ -0,0 +1,6 @@ +import DashboardNav from './DashboardNav'; +import SectionHeader from './SectionHeader'; +import ContentToolbar from './ContentToolbar'; +import LiveRegion from './LiveRegion'; + +export { DashboardNav, SectionHeader, ContentToolbar, LiveRegion }; diff --git a/src/components/Communicator/CommunicatorDialog/QuickAccess/QuickAccessRow.js b/src/components/Communicator/CommunicatorDialog/QuickAccess/QuickAccessRow.js new file mode 100644 index 000000000..9511fe806 --- /dev/null +++ b/src/components/Communicator/CommunicatorDialog/QuickAccess/QuickAccessRow.js @@ -0,0 +1,264 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { intlShape } from 'react-intl'; +import { makeStyles, useTheme } from '@material-ui/core/styles'; +import useMediaQuery from '@material-ui/core/useMediaQuery'; +import classNames from 'classnames'; +import Paper from '@material-ui/core/Paper'; +import Typography from '@material-ui/core/Typography'; +import IconButton from '@material-ui/core/IconButton'; +import Tooltip from '@material-ui/core/Tooltip'; +import Menu from '@material-ui/core/Menu'; +import MenuItem from '@material-ui/core/MenuItem'; +import ListItemIcon from '@material-ui/core/ListItemIcon'; +import ListItemText from '@material-ui/core/ListItemText'; +import Divider from '@material-ui/core/Divider'; +import CircularProgress from '@material-ui/core/CircularProgress'; +import ArrowUpwardIcon from '@material-ui/icons/ArrowUpward'; +import ArrowDownwardIcon from '@material-ui/icons/ArrowDownward'; +import HomeIcon from '@material-ui/icons/Home'; +import MoreVertIcon from '@material-ui/icons/MoreVert'; +import RemoveCircleOutlineIcon from '@material-ui/icons/RemoveCircleOutline'; + +import BoardThumb from '../shared/BoardThumb'; +import BoardStatusChips from '../shared/BoardStatusChips'; +import { + softRadius, + surfaceInteractive, + surfaceAccent, + busyOverlay +} from '../CommunicatorDialog.styles'; +import messages from '../CommunicatorDialog.messages'; + +const useStyles = makeStyles((theme) => ({ + row: { + position: 'relative', + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1.5), + padding: theme.spacing(1), + borderRadius: softRadius(theme), + ...surfaceInteractive(theme), + [theme.breakpoints.up('sm')]: { + padding: theme.spacing(1, 1.5) + } + }, + accent: surfaceAccent(theme), + position: { + flexShrink: 0, + width: 16, + textAlign: 'center', + fontWeight: 600, + color: theme.palette.text.disabled + }, + thumb: { + width: 48, + height: 48, + flexShrink: 0, + borderRadius: theme.shape.borderRadius, + border: `1px solid ${theme.palette.divider}`, + [theme.breakpoints.up('sm')]: { + width: 56, + height: 56 + } + }, + main: { + flex: 1, + minWidth: 0, + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(0.5) + }, + title: { + fontWeight: 600, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap' + }, + controls: { + flexShrink: 0, + display: 'flex', + alignItems: 'center', + gap: theme.spacing(0.5) + }, + control: { + minWidth: 44, + minHeight: 44 + }, + separator: { + height: 24, + margin: theme.spacing(0, 0.5) + }, + busy: busyOverlay(theme) +})); + +const QuickAccessRow = ({ + intl, + board, + position, + isRoot, + isActive, + isFirst, + isLast, + hasAuth, + busy, + onSetRoot, + onRemove, + onMoveUp, + onMoveDown +}) => { + const classes = useStyles(); + const theme = useTheme(); + const isNarrow = useMediaQuery(theme.breakpoints.down('xs')); + const [menuAnchor, setMenuAnchor] = useState(null); + const title = board.name || board.id; + const t = (msg) => intl.formatMessage(msg); + + const actions = [ + { + key: 'move-up', + label: t(messages.moveUp), + icon: , + disabled: isFirst, + onClick: onMoveUp + }, + { + key: 'move-down', + label: t(messages.moveDown), + icon: , + disabled: isLast, + onClick: onMoveDown + }, + { + key: 'set-root', + label: t(messages.menuRootBoardOption), + icon: , + disabled: isRoot || !hasAuth, + onClick: onSetRoot, + startsGroup: true + }, + { + key: 'remove', + label: t(messages.removeFromQuickAccess), + icon: , + disabled: isRoot, + onClick: onRemove + } + ]; + + const closeMenu = () => setMenuAnchor(null); + + const runFromMenu = (action) => () => { + closeMenu(); + action.onClick(); + }; + + return ( + + {busy && ( +
+ +
+ )} + + + {position} + + + + +
+ + {title} + + +
+ + {isNarrow ? ( +
+ setMenuAnchor(event.currentTarget)} + > + + + + {actions.reduce((items, action) => { + if (action.startsGroup) { + items.push(); + } + items.push( + + {action.icon} + + + ); + return items; + }, [])} + +
+ ) : ( +
+ {actions.map((action) => ( + + {action.startsGroup && ( + + )} + + + + {action.icon} + + + + + ))} +
+ )} +
+ ); +}; + +QuickAccessRow.propTypes = { + intl: intlShape.isRequired, + board: PropTypes.object.isRequired, + position: PropTypes.number.isRequired, + isRoot: PropTypes.bool, + isActive: PropTypes.bool, + isFirst: PropTypes.bool, + isLast: PropTypes.bool, + hasAuth: PropTypes.bool, + busy: PropTypes.bool, + onSetRoot: PropTypes.func.isRequired, + onRemove: PropTypes.func.isRequired, + onMoveUp: PropTypes.func.isRequired, + onMoveDown: PropTypes.func.isRequired +}; + +export default QuickAccessRow; diff --git a/src/components/Communicator/CommunicatorDialog/QuickAccess/QuickAccessTray.js b/src/components/Communicator/CommunicatorDialog/QuickAccess/QuickAccessTray.js new file mode 100644 index 000000000..49066d023 --- /dev/null +++ b/src/components/Communicator/CommunicatorDialog/QuickAccess/QuickAccessTray.js @@ -0,0 +1,160 @@ +import React, { useMemo } from 'react'; +import PropTypes from 'prop-types'; +import { intlShape } from 'react-intl'; +import { makeStyles } from '@material-ui/core/styles'; +import Typography from '@material-ui/core/Typography'; +import Button from '@material-ui/core/Button'; +import StarBorderIcon from '@material-ui/icons/StarBorder'; + +import QuickAccessRow from './QuickAccessRow'; +import { softRadius } from '../CommunicatorDialog.styles'; +import messages from '../CommunicatorDialog.messages'; + +const EXPLAINER_ID = 'CommunicatorDialog__quickAccessExplainer'; + +const useStyles = makeStyles((theme) => ({ + root: { + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(1), + width: '100%', + maxWidth: 820, + marginLeft: 'auto', + marginRight: 'auto' + }, + explainer: { + color: theme.palette.text.secondary, + marginBottom: theme.spacing(1) + }, + list: { + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(1), + listStyle: 'none', + margin: 0, + padding: 0 + }, + addButton: { + borderStyle: 'dashed', + borderRadius: softRadius(theme), + minHeight: 56, + textTransform: 'none' + }, + empty: { + padding: theme.spacing(4, 2), + color: theme.palette.text.secondary, + textAlign: 'center' + } +})); + +/** + * The communicator, presented as a short ordered tray rather than a second + * board grid — the different shape is what tells the user this is a + * quick-access subset of My Boards, not a separate collection. + */ +const QuickAccessTray = ({ + intl, + boards, + communicator, + activeBoardId, + busyBoardId, + hasAuth, + onSetRoot, + onRemove, + onMove, + onGoToMyBoards +}) => { + const classes = useStyles(); + + // Communicator order wins; ids with no visible board are skipped rather + // than rendered as holes. The toolbar derives its list the same way. + const orderedBoards = useMemo( + () => + communicator.boards + .map((boardId) => boards.find((board) => board.id === boardId)) + .filter(Boolean), + [communicator.boards, boards] + ); + + const visibleIds = orderedBoards.map((board) => board.id); + + const addButton = ( + + ); + + if (!orderedBoards.length) { + return ( +
+
+ + {intl.formatMessage(messages.quickAccessEmpty)} + +
+ {addButton} +
+ ); + } + + return ( +
+ + {intl.formatMessage(messages.quickAccessExplainer, { + qty: orderedBoards.length + })} + + +
    + {orderedBoards.map((board, index) => ( +
  • + onSetRoot(board)} + onRemove={() => onRemove(board)} + onMoveUp={() => onMove(board.id, -1, visibleIds)} + onMoveDown={() => onMove(board.id, 1, visibleIds)} + /> +
  • + ))} +
+ + {addButton} +
+ ); +}; + +QuickAccessTray.propTypes = { + intl: intlShape.isRequired, + boards: PropTypes.array.isRequired, + communicator: PropTypes.object.isRequired, + activeBoardId: PropTypes.string, + busyBoardId: PropTypes.string, + hasAuth: PropTypes.bool, + onSetRoot: PropTypes.func.isRequired, + onRemove: PropTypes.func.isRequired, + onMove: PropTypes.func.isRequired, + onGoToMyBoards: PropTypes.func.isRequired +}; + +export default QuickAccessTray; diff --git a/src/components/Communicator/CommunicatorDialog/QuickAccess/QuickAccessTray.test.js b/src/components/Communicator/CommunicatorDialog/QuickAccess/QuickAccessTray.test.js new file mode 100644 index 000000000..087fbe17f --- /dev/null +++ b/src/components/Communicator/CommunicatorDialog/QuickAccess/QuickAccessTray.test.js @@ -0,0 +1,128 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import IconButton from '@material-ui/core/IconButton'; +import QuickAccessTray from './QuickAccessTray'; + +jest.mock('../CommunicatorDialog.messages', () => ({ + __esModule: true, + default: new Proxy( + {}, + { + get: (target, prop) => ({ + id: String(prop), + defaultMessage: String(prop) + }) + } + ) +})); + +jest.mock('../shared/BoardThumb', () => ({ + __esModule: true, + default: () => null +})); + +const intl = { + formatMessage: ({ defaultMessage }) => defaultMessage +}; + +const boards = [ + { id: 'b1', name: 'General', tiles: [] }, + { id: 'b2', name: 'Comidas', tiles: [] }, + { id: 'b3', name: 'Escuela', tiles: [] } +]; + +const render = (props = {}) => + mount( + + ); + +const namesOf = (wrapper) => + wrapper.find('QuickAccessRow').map((row) => row.prop('board').name); + +const moveButtons = (wrapper, index) => + wrapper.find('QuickAccessRow').at(index).find(IconButton); + +describe('QuickAccessTray', () => { + it('renders rows in communicator order', () => { + expect(namesOf(render())).toEqual(['Escuela', 'General', 'Comidas']); + }); + + it('skips communicator ids with no matching board', () => { + const wrapper = render({ + communicator: { rootBoard: 'b1', boards: ['b3', 'missing', 'b1'] } + }); + expect(namesOf(wrapper)).toEqual(['Escuela', 'General']); + }); + + it('renders the empty state when nothing is in quick access', () => { + const wrapper = render({ + communicator: { rootBoard: null, boards: [] } + }); + expect(wrapper.text()).toContain('quickAccessEmpty'); + expect(wrapper.find('QuickAccessRow')).toHaveLength(0); + }); + + it('disables Move up on the first row', () => { + const up = moveButtons(render(), 0).filterWhere( + (node) => node.prop('data-testid') === 'move-up' + ); + expect(up.first().prop('disabled')).toBe(true); + }); + + it('disables Move down on the last row', () => { + const down = moveButtons(render(), 2).filterWhere( + (node) => node.prop('data-testid') === 'move-down' + ); + expect(down.first().prop('disabled')).toBe(true); + }); + + it('reports a move with the id, the direction and the visible ids', () => { + const onMove = jest.fn(); + moveButtons(render({ onMove }), 1) + .filterWhere((node) => node.prop('data-testid') === 'move-up') + .first() + .simulate('click'); + + expect(onMove).toHaveBeenCalledWith('b1', -1, ['b3', 'b1', 'b2']); + }); + + it('offers a jump to My Boards', () => { + const onGoToMyBoards = jest.fn(); + const wrapper = render({ onGoToMyBoards }); + wrapper + .find('button') + .filterWhere((node) => node.prop('data-testid') === 'go-to-my-boards') + .first() + .simulate('click'); + expect(onGoToMyBoards).toHaveBeenCalled(); + }); + + it('disables set as home board when the user is not logged in', () => { + const setRoot = render({ hasAuth: false }) + .find(IconButton) + .filterWhere((node) => node.prop('data-testid') === 'set-root'); + + expect(setRoot.length).toBe(3); + setRoot.forEach((node) => expect(node.prop('disabled')).toBe(true)); + }); + + it('enables set as home board for non-root rows when logged in', () => { + const setRoot = render() + .find(IconButton) + .filterWhere((node) => node.prop('data-testid') === 'set-root'); + + expect(setRoot.at(0).prop('disabled')).toBe(false); + }); +}); diff --git a/src/components/Communicator/CommunicatorDialog/QuickAccess/index.js b/src/components/Communicator/CommunicatorDialog/QuickAccess/index.js new file mode 100644 index 000000000..4b46abeb4 --- /dev/null +++ b/src/components/Communicator/CommunicatorDialog/QuickAccess/index.js @@ -0,0 +1,3 @@ +import QuickAccessTray from './QuickAccessTray'; + +export default QuickAccessTray; diff --git a/src/components/Communicator/CommunicatorDialog/__snapshots__/CommunicatorDialog.test.js.snap b/src/components/Communicator/CommunicatorDialog/__snapshots__/CommunicatorDialog.test.js.snap index d0bb73cd9..b787976d6 100644 --- a/src/components/Communicator/CommunicatorDialog/__snapshots__/CommunicatorDialog.test.js.snap +++ b/src/components/Communicator/CommunicatorDialog/__snapshots__/CommunicatorDialog.test.js.snap @@ -1,421 +1,218 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`CommunicatorDialog tests default renderer 1`] = ` +exports[`CommunicatorDialog (dashboard) renders the dashboard shell 1`] = ` - } - disableSubmit={true} - onClose={[Function]} - open={false} - title="cboard.components.CommunicatorDialog.title" + disableAppBarElevation={true} + fullHeight={true} + fullWidth={true} + onClose={[MockFunction]} + open={true} + title="title" > - - - - - - - -
- -
- -
- cboard.components.CommunicatorDialog.title -
-
- cboard.components.CommunicatorDialog.boardsQty -
-
-
-
-
- -
- -
-
-
-
-
-
-`; - -exports[`CommunicatorDialog tests loading behavior 1`] = ` - - } - disableSubmit={true} - onClose={[Function]} - open={false} - title="cboard.components.CommunicatorDialog.title" -> - - - - - - -
+
-
- cboard.components.CommunicatorDialog.title -
-
- cboard.components.CommunicatorDialog.boardsQty -
-
-
-
- -
-
-
-
-
-`; - -exports[`CommunicatorDialog tests loading behavior 2`] = ` - - } - disableSubmit={true} - onClose={[Function]} - open={false} - title="cboard.components.CommunicatorDialog.title" -> - - - - - - - -
- -
-
-
-
-`; - -exports[`CommunicatorDialog tests tabs behavior 1`] = ` - - } - disableSubmit={true} - onClose={[Function]} - open={false} - title="cboard.components.CommunicatorDialog.title" -> - - - - - - -
-
- cboard.components.CommunicatorDialog.title -
-
- cboard.components.CommunicatorDialog.boardsQty -
-
-
-
- -
- -
-
-
-
-
-`; - -exports[`CommunicatorDialog tests tabs behavior 2`] = ` - - } - disableSubmit={true} - onClose={[Function]} - open={false} - title="cboard.components.CommunicatorDialog.title" -> - - - - - - - -
-
-
- -
-
+
-
-
+ + + + + + + + + +
`; diff --git a/src/components/Communicator/CommunicatorDialog/__snapshots__/CommunicatorDialogButtons.test.js.snap b/src/components/Communicator/CommunicatorDialog/__snapshots__/CommunicatorDialogButtons.test.js.snap deleted file mode 100644 index 5427c3068..000000000 --- a/src/components/Communicator/CommunicatorDialog/__snapshots__/CommunicatorDialogButtons.test.js.snap +++ /dev/null @@ -1,166 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`CommunicatorDialogButtons tests default renderer 1`] = ` -
-
- - - -
-
- - - - - - - - - - - - - - - -
-
-`; - -exports[`CommunicatorDialogButtons tests menu behavior 1`] = ` -
-
- - - -
-
- - - - - - - - - - - - - - - -
-
-`; - -exports[`CommunicatorDialogButtons tests menu behavior 2`] = ` -
-
- - - -
-
- - - - - - - - - - - - - - - -
-
-`; diff --git a/src/components/Communicator/CommunicatorDialog/dialogs/BoardInfoDialog.js b/src/components/Communicator/CommunicatorDialog/dialogs/BoardInfoDialog.js new file mode 100644 index 000000000..be28b90d3 --- /dev/null +++ b/src/components/Communicator/CommunicatorDialog/dialogs/BoardInfoDialog.js @@ -0,0 +1,152 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { intlShape } from 'react-intl'; +import moment from 'moment'; +import { makeStyles } from '@material-ui/core/styles'; +import Dialog from '@material-ui/core/Dialog'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import DialogContent from '@material-ui/core/DialogContent'; +import DialogActions from '@material-ui/core/DialogActions'; +import Button from '@material-ui/core/Button'; +import Typography from '@material-ui/core/Typography'; +import VisibilityIcon from '@material-ui/icons/Visibility'; + +import { isCordova } from '../../../../cordova-util'; +import { formatBoardLocale } from '../utils/boardLocale'; +import messages from '../CommunicatorDialog.messages'; + +const getBoardUrl = (board) => + `${window.location.origin}/${window.location.pathname.split('/')[1]}/${ + board.id + }`; + +const useStyles = makeStyles((theme) => ({ + list: { + display: 'grid', + gridTemplateColumns: 'auto 1fr', + columnGap: theme.spacing(2), + rowGap: theme.spacing(1) + }, + label: { + color: theme.palette.text.secondary, + fontWeight: 600 + }, + value: { + wordBreak: 'break-word' + }, + openButton: { + marginTop: theme.spacing(2) + } +})); + +const Row = ({ classes, label, children }) => ( + <> + + {label} + + + {children} + + +); + +Row.propTypes = { + classes: PropTypes.object.isRequired, + label: PropTypes.string, + children: PropTypes.node +}; + +const BoardInfoDialog = ({ intl, open, board, onClose }) => { + const classes = useStyles(); + if (!board) return null; + const boardUrl = getBoardUrl(board); + + return ( + + {board.name} + +
+ + {board.name} + + {board.author && ( + + {board.author} + + )} + + {formatBoardLocale(intl, board.locale)} + + {board.description && ( + + {board.description} + + )} + + {moment(board.lastEdited).format('DD/MM/YYYY')} + + + {board.tiles.length} + + + {board.id} + +
+ {!isCordova() && ( + + )} +
+ + + +
+ ); +}; + +BoardInfoDialog.propTypes = { + intl: intlShape.isRequired, + open: PropTypes.bool, + board: PropTypes.object, + onClose: PropTypes.func.isRequired +}; + +export default BoardInfoDialog; diff --git a/src/components/Communicator/CommunicatorDialog/dialogs/ConfirmDialog.js b/src/components/Communicator/CommunicatorDialog/dialogs/ConfirmDialog.js new file mode 100644 index 000000000..71f32c9af --- /dev/null +++ b/src/components/Communicator/CommunicatorDialog/dialogs/ConfirmDialog.js @@ -0,0 +1,87 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { intlShape } from 'react-intl'; +import Dialog from '@material-ui/core/Dialog'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import DialogContent from '@material-ui/core/DialogContent'; +import DialogContentText from '@material-ui/core/DialogContentText'; +import DialogActions from '@material-ui/core/DialogActions'; +import Button from '@material-ui/core/Button'; + +import LoadingIcon from '../../../UI/LoadingIcon'; +import PremiumFeature from '../../../PremiumFeature'; +import messages from '../CommunicatorDialog.messages'; + +/** + * Generic confirmation dialog used for delete / copy flows. Handles its own + * loading state while the async `onConfirm` resolves. + */ +const ConfirmDialog = ({ + intl, + open, + title, + description, + premium, + onConfirm, + onClose +}) => { + const [loading, setLoading] = useState(false); + + const handleConfirm = async () => { + setLoading(true); + try { + await onConfirm(); + onClose(); + } finally { + setLoading(false); + } + }; + + const confirmButton = ( + + ); + + return ( + + {title} + + {description} + + + + {premium ? ( + {confirmButton} + ) : ( + confirmButton + )} + + + ); +}; + +ConfirmDialog.propTypes = { + intl: intlShape.isRequired, + open: PropTypes.bool, + title: PropTypes.string, + description: PropTypes.string, + premium: PropTypes.bool, + onConfirm: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired +}; + +export default ConfirmDialog; diff --git a/src/components/Communicator/CommunicatorDialog/dialogs/EditBoardDialog.js b/src/components/Communicator/CommunicatorDialog/dialogs/EditBoardDialog.js new file mode 100644 index 000000000..23bc71f28 --- /dev/null +++ b/src/components/Communicator/CommunicatorDialog/dialogs/EditBoardDialog.js @@ -0,0 +1,179 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { intlShape } from 'react-intl'; +import { makeStyles } from '@material-ui/core/styles'; +import Dialog from '@material-ui/core/Dialog'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import DialogContent from '@material-ui/core/DialogContent'; +import DialogContentText from '@material-ui/core/DialogContentText'; +import DialogActions from '@material-ui/core/DialogActions'; +import Button from '@material-ui/core/Button'; +import TextField from '@material-ui/core/TextField'; +import SearchIcon from '@material-ui/icons/Search'; + +import InputImage from '../../../UI/InputImage'; +import SymbolSearch from '../../../Board/SymbolSearch'; +import LoadingIcon from '../../../UI/LoadingIcon'; +import { isCordova } from '../../../../cordova-util'; +import messages from '../CommunicatorDialog.messages'; + +const useStyles = makeStyles((theme) => ({ + imageBlock: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: theme.spacing(1), + marginTop: theme.spacing(2) + }, + preview: { + maxWidth: 200, + maxHeight: 200, + objectFit: 'contain' + } +})); + +const resolveCaption = (caption) => + isCordova() && caption && caption.search('/') === 0 ? `.${caption}` : caption; + +const EditBoardDialog = ({ + intl, + open, + board, + onUpdateBoard, + onClose, + disableTour, + isSymbolSearchTourEnabled +}) => { + const classes = useStyles(); + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [image, setImage] = useState(null); + const [loading, setLoading] = useState(false); + const [symbolSearchOpen, setSymbolSearchOpen] = useState(false); + + useEffect(() => { + if (open && board) { + setName(board.name || ''); + setDescription(board.description || ''); + setImage(null); + } + }, [open, board]); + + if (!board) return null; + + const handleSymbolSearchChange = ({ image: searchImage }) => + new Promise((resolve) => { + setImage(searchImage); + resolve(); + }); + + const handleSave = async () => { + setLoading(true); + try { + const newBoard = { + ...board, + name: name.trim() ? name : board.name, + description: description ? description : board.description + }; + if (image) newBoard.caption = image; + await onUpdateBoard(newBoard); + onClose(); + } finally { + setLoading(false); + } + }; + + const previewImage = resolveCaption(image || board.caption); + + return ( + <> + + + {intl.formatMessage(messages.editBoardTitle)} + + + + {intl.formatMessage(messages.editBoardTitleDescription)} + + setName(event.target.value)} + /> + setDescription(event.target.value)} + /> + +
+ {previewImage && ( + {board.name + )} +
+ + setImage(img)} /> +
+
+
+ + + + +
+ + setSymbolSearchOpen(false)} + disableTour={disableTour} + isSymbolSearchTourEnabled={isSymbolSearchTourEnabled} + /> + + ); +}; + +EditBoardDialog.propTypes = { + intl: intlShape.isRequired, + open: PropTypes.bool, + board: PropTypes.object, + onUpdateBoard: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, + disableTour: PropTypes.func, + isSymbolSearchTourEnabled: PropTypes.bool +}; + +export default EditBoardDialog; diff --git a/src/components/Communicator/CommunicatorDialog/dialogs/PublishBoardDialog.js b/src/components/Communicator/CommunicatorDialog/dialogs/PublishBoardDialog.js new file mode 100644 index 000000000..598570170 --- /dev/null +++ b/src/components/Communicator/CommunicatorDialog/dialogs/PublishBoardDialog.js @@ -0,0 +1,108 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { intlShape } from 'react-intl'; +import Dialog from '@material-ui/core/Dialog'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import DialogContent from '@material-ui/core/DialogContent'; +import DialogContentText from '@material-ui/core/DialogContentText'; +import DialogActions from '@material-ui/core/DialogActions'; +import Button from '@material-ui/core/Button'; +import TextField from '@material-ui/core/TextField'; + +import LoadingIcon from '../../../UI/LoadingIcon'; +import PremiumFeature from '../../../PremiumFeature'; +import messages from '../CommunicatorDialog.messages'; + +/** + * Captures a description before publishing a board (shown only when a board is + * being made public and has no description yet). On accept it updates the board + * description (if provided) and then publishes it. + */ +const PublishBoardDialog = ({ + intl, + open, + board, + onUpdateBoard, + onPublish, + onClose +}) => { + const [description, setDescription] = useState(''); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (open) setDescription(board ? board.description || '' : ''); + }, [open, board]); + + if (!board) return null; + + const handlePublish = async () => { + setLoading(true); + try { + if (description) { + const updated = { ...board, description }; + await onUpdateBoard(updated); + await onPublish(updated); + } else { + await onPublish(board); + } + onClose(); + } finally { + setLoading(false); + } + }; + + return ( + + + {intl.formatMessage(messages.publishBoard)} + + + + {intl.formatMessage(messages.publishBoardDescription)} + + setDescription(event.target.value)} + /> + + + + + + + + + ); +}; + +PublishBoardDialog.propTypes = { + intl: intlShape.isRequired, + open: PropTypes.bool, + board: PropTypes.object, + onUpdateBoard: PropTypes.func.isRequired, + onPublish: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired +}; + +export default PublishBoardDialog; diff --git a/src/components/Communicator/CommunicatorDialog/dialogs/ReportBoardDialog.js b/src/components/Communicator/CommunicatorDialog/dialogs/ReportBoardDialog.js new file mode 100644 index 000000000..cd097e763 --- /dev/null +++ b/src/components/Communicator/CommunicatorDialog/dialogs/ReportBoardDialog.js @@ -0,0 +1,174 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { intlShape } from 'react-intl'; +import Dialog from '@material-ui/core/Dialog'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import DialogContent from '@material-ui/core/DialogContent'; +import DialogContentText from '@material-ui/core/DialogContentText'; +import DialogActions from '@material-ui/core/DialogActions'; +import Button from '@material-ui/core/Button'; +import TextField from '@material-ui/core/TextField'; +import Typography from '@material-ui/core/Typography'; +import LinearProgress from '@material-ui/core/LinearProgress'; +import FlagIcon from '@material-ui/icons/Flag'; +import CheckCircleIcon from '@material-ui/icons/CheckCircle'; + +import messages from '../CommunicatorDialog.messages'; + +const getBoardUrl = (board) => + `${window.location.origin}/${window.location.pathname.split('/')[1]}/${ + board.id + }`; + +const ReportBoardDialog = ({ + intl, + open, + board, + userData, + onReport, + onClose +}) => { + const [reason, setReason] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(false); + const [success, setSuccess] = useState(false); + + useEffect(() => { + if (open) { + setReason(''); + setLoading(false); + setError(false); + setSuccess(false); + } + }, [open]); + + if (!board) return null; + + const handleSend = async () => { + const reportData = { + id: board.id, + name: board.name, + author: board.author, + description: board.description, + url: getBoardUrl(board), + reason, + whistleblower: { + name: userData && userData.name, + email: userData && userData.email + } + }; + setLoading(true); + setError(false); + try { + await onReport(reportData); + setSuccess(true); + } catch (err) { + setError(true); + } finally { + setLoading(false); + } + }; + + return ( + + +
+ {intl.formatMessage(messages.boardReport)} + +
+
+ + {success ? ( + <> + +
+ + + {intl.formatMessage(messages.boardReportSuccesSubtitle)} +
+ {intl.formatMessage(messages.boardReportSuccesGratitude)} +
+
+
+ + + + + ) : ( + <> + + + {intl.formatMessage(messages.boardReportContentSubtitle)} +
+
+ {intl.formatMessage(messages.boardInfoName)} +
+ {board.name} +
+ setReason(event.target.value)} + /> + {error && ( + + {intl.formatMessage(messages.boardReportError)} + + )} + {loading && } +
+ {!loading && ( + + + + + )} + + )} +
+ ); +}; + +ReportBoardDialog.propTypes = { + intl: intlShape.isRequired, + open: PropTypes.bool, + board: PropTypes.object, + userData: PropTypes.object, + onReport: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired +}; + +export default ReportBoardDialog; diff --git a/src/components/Communicator/CommunicatorDialog/hooks/useBoardActions.js b/src/components/Communicator/CommunicatorDialog/hooks/useBoardActions.js new file mode 100644 index 000000000..5ca4a8fc2 --- /dev/null +++ b/src/components/Communicator/CommunicatorDialog/hooks/useBoardActions.js @@ -0,0 +1,98 @@ +import { useCallback } from 'react'; +import history from '../../../../history'; +import useCommunicatorBoards from './useCommunicatorBoards'; +import useBoardCrud from './useBoardCrud'; +import useBoardExport from './useBoardExport'; + +/** + * All the board/communicator business logic, extracted from the old + * CommunicatorDialog container. Receives the redux state/dispatchers as deps + * (the container stays the redux boundary) plus the active section and the + * fetcher mutators so the visible list stays in sync after each operation. + * + * Composes three hooks along the seams of what they touch: communicator + * membership, board lifecycle and export. + */ +const useBoardActions = ({ + section, + intl, + userData, + language, + communicators, + currentCommunicator, + availableBoards, + // dispatchers + createBoard, + updateBoard, + replaceBoard, + addBoards, + deleteBoard, + deleteApiBoard, + updateApiBoard, + updateApiObjectsNoChild, + addBoardCommunicator, + verifyAndUpsertCommunicator, + upsertApiCommunicator, + showNotification, + // fetcher mutators + refetch, + removeBoardFromList, + replaceBoardInList, + switchBoard, + onClose +}) => { + const communicatorActions = useCommunicatorBoards({ + section, + intl, + userData, + currentCommunicator, + availableBoards, + addBoards, + verifyAndUpsertCommunicator, + upsertApiCommunicator, + showNotification, + refetch + }); + + const crudActions = useBoardCrud({ + intl, + userData, + language, + communicators, + currentCommunicator, + availableBoards, + createBoard, + updateBoard, + replaceBoard, + deleteBoard, + deleteApiBoard, + updateApiBoard, + updateApiObjectsNoChild, + addBoardCommunicator, + verifyAndUpsertCommunicator, + upsertApiCommunicator, + showNotification, + removeBoardFromList, + replaceBoardInList + }); + + const exportActions = useBoardExport({ intl, showNotification }); + + const showBoard = useCallback( + async (board) => { + switchBoard(board.id); + history.replace(`/board/${board.id}`); + onClose(); + }, + [switchBoard, onClose] + ); + + return { + ...communicatorActions, + ...crudActions, + ...exportActions, + showBoard + }; +}; + +export default useBoardActions; diff --git a/src/components/Communicator/CommunicatorDialog/hooks/useBoardActions.test.js b/src/components/Communicator/CommunicatorDialog/hooks/useBoardActions.test.js new file mode 100644 index 000000000..b54b89ba9 --- /dev/null +++ b/src/components/Communicator/CommunicatorDialog/hooks/useBoardActions.test.js @@ -0,0 +1,360 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import { act } from 'react-dom/test-utils'; +import useBoardActions from './useBoardActions'; +import { SECTIONS } from '../CommunicatorDialog.constants'; + +jest.mock('../../../../api', () => ({ + __esModule: true, + default: { + getBoard: jest.fn(), + boardReport: jest.fn(), + updateBoard: jest.fn(), + uploadBoardLocalMedia: jest.fn() + } +})); + +jest.mock('../CommunicatorDialog.messages', () => ({ + __esModule: true, + default: new Proxy( + {}, + { get: (t, prop) => ({ id: String(prop), defaultMessage: String(prop) }) } + ) +})); + +jest.mock('../../../Settings/Export/Export.helpers', () => ({ + __esModule: true, + openboardExportOneAdapter: jest.fn(() => Promise.resolve()), + pdfExportAdapter: jest.fn(() => Promise.resolve()) +})); + +jest.mock('../../../../history', () => ({ + __esModule: true, + default: { replace: jest.fn() } +})); + +const intl = { formatMessage: ({ defaultMessage }) => defaultMessage }; + +const Harness = ({ hookArgs, onResult }) => { + onResult(useBoardActions(hookArgs)); + return null; +}; + +const renderActions = (hookArgs) => { + let latest; + act(() => { + mount( + (latest = result)} /> + ); + }); + return () => latest; +}; + +const baseArgs = (overrides) => ({ + section: SECTIONS.MY_COMMUNICATOR, + intl, + userData: {}, // not logged in -> skips API calls + language: { lang: 'en-US' }, + communicators: [], + currentCommunicator: { id: 'c1', boards: ['b1', 'b2'], rootBoard: 'b1' }, + availableBoards: [{ id: 'b1' }, { id: 'b2' }], + createBoard: jest.fn(), + updateBoard: jest.fn(), + replaceBoard: jest.fn(), + addBoards: jest.fn(), + deleteBoard: jest.fn(), + deleteApiBoard: jest.fn(), + updateApiBoard: jest.fn(), + updateApiObjectsNoChild: jest.fn(), + addBoardCommunicator: jest.fn(), + verifyAndUpsertCommunicator: jest.fn((x) => x), + upsertApiCommunicator: jest.fn(), + showNotification: jest.fn(), + refetch: jest.fn(), + removeBoardFromList: jest.fn(), + replaceBoardInList: jest.fn(), + switchBoard: jest.fn(), + onClose: jest.fn(), + ...overrides +}); + +describe('useBoardActions', () => { + it('removes a board from the communicator and refetches', async () => { + const args = baseArgs(); + const get = renderActions(args); + + await act(async () => { + await get().addOrRemoveBoard({ id: 'b2' }); + }); + + expect(args.verifyAndUpsertCommunicator).toHaveBeenCalledWith( + expect.objectContaining({ boards: ['b1'] }) + ); + expect(args.refetch).toHaveBeenCalled(); + }); + + it('toggles membership from the My Boards section', async () => { + const args = baseArgs({ section: SECTIONS.MY_BOARDS }); + const get = renderActions(args); + + // b3 is not in the communicator and is locally available -> just adds it + await act(async () => { + await get().addOrRemoveBoard({ id: 'b1' }); + }); + + // b1 was in the communicator -> removed + expect(args.verifyAndUpsertCommunicator).toHaveBeenCalledWith( + expect.objectContaining({ boards: ['b2'] }) + ); + expect(args.showNotification).toHaveBeenCalled(); + }); + + it('deletes a board and removes it from the visible list', async () => { + const args = baseArgs({ section: SECTIONS.MY_BOARDS }); + const get = renderActions(args); + + await act(async () => { + await get().deleteMyBoard({ id: 'b2' }); + }); + + expect(args.deleteBoard).toHaveBeenCalledWith('b2'); + expect(args.removeBoardFromList).toHaveBeenCalledWith('b2'); + expect(args.showNotification).toHaveBeenCalled(); + }); + + it('reorders the communicator ids without dropping hidden ones', async () => { + const args = baseArgs({ + section: SECTIONS.MY_COMMUNICATOR, + currentCommunicator: { + id: 'c1', + boards: ['b1', 'hidden', 'b2'], + rootBoard: 'b1' + } + }); + const get = renderActions(args); + + await act(async () => { + await get().reorderCommunicatorBoards('b1', 1, ['b1', 'b2']); + }); + + expect(args.verifyAndUpsertCommunicator).toHaveBeenCalledWith( + expect.objectContaining({ boards: ['b2', 'hidden', 'b1'] }) + ); + }); + + it('adds a non-member board to the communicator when setting it as root', async () => { + const args = baseArgs({ section: SECTIONS.MY_BOARDS }); + const get = renderActions(args); + + await act(async () => { + await get().setRootBoard({ id: 'b3' }); + }); + + expect(args.verifyAndUpsertCommunicator).toHaveBeenCalledWith( + expect.objectContaining({ rootBoard: 'b3', boards: ['b1', 'b2', 'b3'] }) + ); + }); + + it('fetches the new root into the local store when it is missing', async () => { + const API = require('../../../../api').default; + API.getBoard.mockResolvedValueOnce({ id: 'b3' }); + const args = baseArgs({ section: SECTIONS.MY_BOARDS }); + const get = renderActions(args); + + await act(async () => { + await get().setRootBoard({ id: 'b3' }); + }); + + expect(API.getBoard).toHaveBeenCalledWith('b3'); + expect(args.addBoards).toHaveBeenCalledWith([{ id: 'b3' }]); + }); + + it('keeps the board order when setting a member board as root', async () => { + const args = baseArgs({ section: SECTIONS.MY_COMMUNICATOR }); + const get = renderActions(args); + + await act(async () => { + await get().setRootBoard({ id: 'b2' }); + }); + + expect(args.verifyAndUpsertCommunicator).toHaveBeenCalledWith( + expect.objectContaining({ rootBoard: 'b2', boards: ['b1', 'b2'] }) + ); + }); + + it('does not persist a move that is already at the end', async () => { + const args = baseArgs({ section: SECTIONS.MY_COMMUNICATOR }); + const get = renderActions(args); + + await act(async () => { + await get().reorderCommunicatorBoards('b2', 1, ['b1', 'b2']); + }); + + expect(args.verifyAndUpsertCommunicator).not.toHaveBeenCalled(); + }); + + it('exports a single board and notifies on success', async () => { + const { + openboardExportOneAdapter + } = require('../../../Settings/Export/Export.helpers'); + const args = baseArgs({ section: SECTIONS.MY_BOARDS }); + const get = renderActions(args); + const board = { id: 'b2', name: 'Comidas' }; + + await act(async () => { + await get().exportBoard(board); + }); + + expect(openboardExportOneAdapter).toHaveBeenCalledWith(board, args.intl); + expect(args.showNotification).toHaveBeenCalled(); + }); + + it('notifies when an export fails', async () => { + const { + openboardExportOneAdapter + } = require('../../../Settings/Export/Export.helpers'); + openboardExportOneAdapter.mockRejectedValueOnce(new Error('boom')); + const args = baseArgs({ section: SECTIONS.MY_BOARDS }); + const get = renderActions(args); + + await act(async () => { + await get().exportBoard({ id: 'b2', name: 'Comidas' }); + }); + + expect(args.showNotification).toHaveBeenCalled(); + }); + + it('exports a board as PDF wrapped in an array', async () => { + const { + pdfExportAdapter + } = require('../../../Settings/Export/Export.helpers'); + const args = baseArgs({ section: SECTIONS.MY_BOARDS }); + const get = renderActions(args); + const board = { id: 'b2', name: 'Comidas' }; + + await act(async () => { + await get().exportBoardToPdf(board); + }); + + expect(pdfExportAdapter).toHaveBeenCalledWith( + [board], + expect.anything(), + args.intl + ); + }); + + it('switches to a board, navigates and closes the dialog', async () => { + const history = require('../../../../history').default; + const args = baseArgs({ section: SECTIONS.MY_BOARDS }); + const get = renderActions(args); + + await act(async () => { + await get().showBoard({ id: 'b2' }); + }); + + expect(args.switchBoard).toHaveBeenCalledWith('b2'); + expect(history.replace).toHaveBeenCalledWith('/board/b2'); + expect(args.onClose).toHaveBeenCalled(); + }); + + it('uploads local media before sending the published board', async () => { + const API = require('../../../../api').default; + const board = { + id: 'b2', + name: 'Comidas', + isPublic: false, + tiles: [{ id: 't1', image: 'data:image/png;base64,AAA' }] + }; + const sanitized = { + ...board, + isPublic: true, + tiles: [{ id: 't1', image: 'https://cdn.cboard.io/t1.png' }] + }; + const persisted = { ...sanitized, _id: 'srv1' }; + API.uploadBoardLocalMedia.mockResolvedValueOnce({ + board: sanitized, + hadFailure: false + }); + API.updateBoard.mockResolvedValueOnce(persisted); + + const args = baseArgs({ + section: SECTIONS.MY_BOARDS, + userData: { name: 'Tester', email: 'tester@cboard.io' } + }); + const get = renderActions(args); + + await act(async () => { + await get().publishBoard(board); + }); + + expect(API.uploadBoardLocalMedia).toHaveBeenCalledWith( + expect.objectContaining({ id: 'b2', isPublic: true }) + ); + expect(API.updateBoard).toHaveBeenCalledWith(sanitized); + expect(args.replaceBoardInList).toHaveBeenLastCalledWith(persisted); + }); + + it('keeps the communicator order when a board is starred off', async () => { + const args = baseArgs({ + section: SECTIONS.MY_BOARDS, + currentCommunicator: { + id: 'c1', + boards: ['b2', 'b1', 'b3'], + rootBoard: 'b2' + }, + availableBoards: [{ id: 'b1' }, { id: 'b2' }, { id: 'b3' }] + }); + const get = renderActions(args); + + await act(async () => { + await get().addOrRemoveBoard({ id: 'b3' }); + }); + + expect(args.verifyAndUpsertCommunicator).toHaveBeenCalledWith( + expect.objectContaining({ boards: ['b2', 'b1'] }) + ); + }); + + it('keeps ids with no local board when a board is starred on', async () => { + const args = baseArgs({ + section: SECTIONS.MY_BOARDS, + currentCommunicator: { + id: 'c1', + boards: ['b1', 'not-local'], + rootBoard: 'b1' + }, + availableBoards: [{ id: 'b1' }, { id: 'b2' }] + }); + const get = renderActions(args); + + await act(async () => { + await get().addOrRemoveBoard({ id: 'b2' }); + }); + + expect(args.verifyAndUpsertCommunicator).toHaveBeenCalledWith( + expect.objectContaining({ boards: ['b1', 'not-local', 'b2'] }) + ); + }); + + it('keeps ids with no local board when a board is removed from the tray', async () => { + const args = baseArgs({ + section: SECTIONS.MY_COMMUNICATOR, + currentCommunicator: { + id: 'c1', + boards: ['b2', 'not-local', 'b1'], + rootBoard: 'b2' + }, + availableBoards: [{ id: 'b1' }, { id: 'b2' }] + }); + const get = renderActions(args); + + await act(async () => { + await get().addOrRemoveBoard({ id: 'b1' }); + }); + + expect(args.verifyAndUpsertCommunicator).toHaveBeenCalledWith( + expect.objectContaining({ boards: ['b2', 'not-local'] }) + ); + expect(args.refetch).toHaveBeenCalled(); + }); +}); diff --git a/src/components/Communicator/CommunicatorDialog/hooks/useBoardCrud.js b/src/components/Communicator/CommunicatorDialog/hooks/useBoardCrud.js new file mode 100644 index 000000000..ae730996f --- /dev/null +++ b/src/components/Communicator/CommunicatorDialog/hooks/useBoardCrud.js @@ -0,0 +1,273 @@ +import { useCallback } from 'react'; +import shortid from 'shortid'; +import API from '../../../../api'; +import messages from '../CommunicatorDialog.messages'; +import { isLoggedIn } from '../utils/userData'; + +/** + * Lifecycle of the boards themselves: copying (which walks the whole linked + * board tree), publishing, deleting, updating and reporting. Communicator + * membership lives in useCommunicatorBoards; the only overlap is that copying a + * board also drops the copy into the active communicator. + */ +const useBoardCrud = ({ + intl, + userData, + language, + communicators, + currentCommunicator, + availableBoards, + createBoard, + updateBoard, + replaceBoard, + deleteBoard, + deleteApiBoard, + updateApiBoard, + updateApiObjectsNoChild, + addBoardCommunicator, + verifyAndUpsertCommunicator, + upsertApiCommunicator, + showNotification, + removeBoardFromList, + replaceBoardInList +}) => { + const updateBoardReferences = useCallback( + (board, newBoard, records) => { + let prevBoardsRecords = records.map((entry) => entry.prev); + prevBoardsRecords = prevBoardsRecords.filter((id) => id !== newBoard.id); + availableBoards.forEach((b) => { + b.tiles.forEach((tile, index) => { + if (tile && tile.loadBoard && tile.loadBoard === board.id) { + b.tiles.splice(index, 1, { ...tile, loadBoard: newBoard.id }); + try { + updateBoard(b); + } catch (err) { + console.log(err.message); + } + } + if ( + tile && + tile.loadBoard && + prevBoardsRecords.includes(tile.loadBoard) + ) { + const el = records.find((e) => e.prev === tile.loadBoard); + b.tiles.splice(index, 1, { ...tile, loadBoard: el.next }); + try { + updateBoard(b); + } catch (err) { + console.log(err.message); + } + } + }); + }); + }, + [availableBoards, updateBoard] + ); + + const createBoardsRecursively = useCallback( + async (board, records) => { + if (!board) { + return; + } + if (records) { + const nextBoardsRecords = records.map((entry) => entry.next); + if (nextBoardsRecords.includes(board.id)) { + return; + } + } + + let newBoard = { + ...board, + isPublic: false, + id: shortid.generate(), + hidden: false, + author: '', + email: '' + }; + if (!newBoard.name) { + newBoard.name = newBoard.nameKey + ? intl.formatMessage({ id: newBoard.nameKey }) + : intl.formatMessage(messages.noTitle); + } + if (isLoggedIn(userData)) { + newBoard = { + ...newBoard, + author: userData.name, + email: userData.email + }; + } + createBoard(newBoard); + if (!records) { + verifyAndUpsertCommunicator({ ...currentCommunicator }); + addBoardCommunicator(newBoard.id); + } + + if (!records) { + records = [{ prev: board.id, next: newBoard.id }]; + } else { + records.push({ prev: board.id, next: newBoard.id }); + } + updateBoardReferences(board, newBoard, records); + + if (isLoggedIn(userData)) { + try { + const boardId = await updateApiObjectsNoChild(newBoard, true); + newBoard = { ...newBoard, id: boardId }; + } catch (err) { + console.log(err.message); + } + } + + if (board.tiles.length < 1) { + return; + } + + for (const tile of board.tiles) { + if (tile.loadBoard && !tile.linkedBoard) { + try { + const nextBoard = await API.getBoard(tile.loadBoard); + await createBoardsRecursively(nextBoard, records); + } catch (err) { + if (!err.respose || err.response?.status === 404) { + const localBoard = availableBoards.find( + (b) => b.id === tile.loadBoard + ); + if (localBoard) { + await createBoardsRecursively(localBoard, records); + } + } + } + } + } + }, + [ + intl, + userData, + currentCommunicator, + availableBoards, + createBoard, + verifyAndUpsertCommunicator, + addBoardCommunicator, + updateApiObjectsNoChild, + updateBoardReferences + ] + ); + + const copyBoard = useCallback( + async (board) => { + try { + await createBoardsRecursively(board); + showNotification(intl.formatMessage(messages.boardAddedToCommunicator)); + } catch (err) { + console.log(err.message); + showNotification(intl.formatMessage(messages.boardCopyError)); + } + }, + [createBoardsRecursively, showNotification, intl] + ); + + const publishBoard = useCallback( + async (board) => { + const boardData = { ...board, isPublic: !board.isPublic }; + replaceBoard(board, boardData); + replaceBoardInList(boardData); + showNotification( + intl.formatMessage( + boardData.isPublic + ? messages.boardPublished + : messages.boardUnpublished + ) + ); + + if (isLoggedIn(userData)) { + try { + const { board: sanitized } = + await API.uploadBoardLocalMedia(boardData); + if (sanitized !== boardData) { + replaceBoard(boardData, sanitized); + replaceBoardInList(sanitized); + } + const boardResponse = await API.updateBoard(sanitized); + replaceBoard(sanitized, boardResponse); + replaceBoardInList(boardResponse); + } catch (err) {} + } + }, + [userData, replaceBoard, replaceBoardInList, showNotification, intl] + ); + + const deleteMyBoard = useCallback( + async (board) => { + deleteBoard(board.id); + + if (isLoggedIn(userData)) { + try { + await deleteApiBoard(board.id); + } catch (err) {} + } + + for await (const comm of communicators) { + if (comm.boards.includes(board.id)) { + const filteredCommunicator = { + ...comm, + boards: comm.boards.filter((b) => b !== board.id) + }; + const upsertedCommunicator = + verifyAndUpsertCommunicator(filteredCommunicator); + if (isLoggedIn(userData)) { + try { + await upsertApiCommunicator(upsertedCommunicator); + } catch (err) { + console.error('Error upserting communicator', err); + } + } + } + } + + removeBoardFromList(board.id); + showNotification(intl.formatMessage(messages.boardDeleted)); + }, + [ + userData, + communicators, + deleteBoard, + deleteApiBoard, + verifyAndUpsertCommunicator, + upsertApiCommunicator, + removeBoardFromList, + showNotification, + intl + ] + ); + + const updateMyBoard = useCallback( + async (board) => { + updateBoard(board); + replaceBoardInList(board); + if (isLoggedIn(userData)) { + try { + await updateApiBoard(board); + } catch (err) {} + } + }, + [userData, updateBoard, updateApiBoard, replaceBoardInList] + ); + + const boardReport = useCallback( + async (reportedBoardData) => { + reportedBoardData.whistleblower.language = language.lang; + await API.boardReport(reportedBoardData); + }, + [language] + ); + + return { + copyBoard, + publishBoard, + deleteMyBoard, + updateMyBoard, + boardReport + }; +}; + +export default useBoardCrud; diff --git a/src/components/Communicator/CommunicatorDialog/hooks/useBoardExport.js b/src/components/Communicator/CommunicatorDialog/hooks/useBoardExport.js new file mode 100644 index 000000000..4f74595a4 --- /dev/null +++ b/src/components/Communicator/CommunicatorDialog/hooks/useBoardExport.js @@ -0,0 +1,48 @@ +import { useCallback } from 'react'; +import messages from '../CommunicatorDialog.messages'; +import { MEDIUM_FONT_SIZE } from '../../../Settings/Export/Export.constants'; + +const useBoardExport = ({ intl, showNotification }) => { + const exportBoard = useCallback( + async (board) => { + try { + // Loaded lazily: Export.helpers pulls in pdfmake + vfs_fonts (~1.7MB) + // at module scope, which we only want paid for on an actual export. + const { openboardExportOneAdapter } = + await import('../../../Settings/Export/Export.helpers'); + await openboardExportOneAdapter(board, intl); + showNotification( + intl.formatMessage(messages.boardExported, { name: board.name }) + ); + } catch (err) { + showNotification( + intl.formatMessage(messages.boardExportError, { name: board.name }) + ); + } + }, + [intl, showNotification] + ); + + const exportBoardToPdf = useCallback( + async (board) => { + try { + // Same lazy-load rationale as exportBoard above. + const { pdfExportAdapter } = + await import('../../../Settings/Export/Export.helpers'); + await pdfExportAdapter([board], MEDIUM_FONT_SIZE, intl); + showNotification( + intl.formatMessage(messages.boardExported, { name: board.name }) + ); + } catch (err) { + showNotification( + intl.formatMessage(messages.boardExportError, { name: board.name }) + ); + } + }, + [intl, showNotification] + ); + + return { exportBoard, exportBoardToPdf }; +}; + +export default useBoardExport; diff --git a/src/components/Communicator/CommunicatorDialog/hooks/useBoardSelection.js b/src/components/Communicator/CommunicatorDialog/hooks/useBoardSelection.js new file mode 100644 index 000000000..7001b3f77 --- /dev/null +++ b/src/components/Communicator/CommunicatorDialog/hooks/useBoardSelection.js @@ -0,0 +1,54 @@ +import { useState, useRef, useEffect, useMemo, useCallback } from 'react'; + +/** + * Selection state for the board list. + * + * The details panel / bottom sheet renders whatever is selected here. Focus + * management lives with the selection because Escape has to return focus to + * the exact card the user came from, which means something has to remember + * that card's DOM node. + */ +const useBoardSelection = ({ boards, section, search, page }) => { + const [selectedId, setSelectedId] = useState(null); + const triggers = useRef({}); + + // Any change of context invalidates the selection: the panel would + // otherwise keep describing a board that is no longer on screen. + useEffect(() => { + setSelectedId(null); + }, [section, search, page]); + + const selectedBoard = useMemo( + () => boards.find((board) => board.id === selectedId) || null, + [boards, selectedId] + ); + + // The board was deleted, filtered out, or paged away. + useEffect(() => { + if (selectedId && !selectedBoard) { + setSelectedId(null); + } + }, [selectedId, selectedBoard]); + + const select = useCallback((boardId) => setSelectedId(boardId), []); + + const registerTrigger = useCallback((boardId, node) => { + if (node) { + triggers.current[boardId] = node; + } else { + delete triggers.current[boardId]; + } + }, []); + + const clear = useCallback(() => { + const trigger = triggers.current[selectedId]; + setSelectedId(null); + if (trigger && typeof trigger.focus === 'function') { + trigger.focus(); + } + }, [selectedId]); + + return { selectedId, selectedBoard, select, clear, registerTrigger }; +}; + +export default useBoardSelection; diff --git a/src/components/Communicator/CommunicatorDialog/hooks/useBoardSelection.test.js b/src/components/Communicator/CommunicatorDialog/hooks/useBoardSelection.test.js new file mode 100644 index 000000000..deadb34b2 --- /dev/null +++ b/src/components/Communicator/CommunicatorDialog/hooks/useBoardSelection.test.js @@ -0,0 +1,113 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import { act } from 'react-dom/test-utils'; +import useBoardSelection from './useBoardSelection'; + +const Harness = ({ hookArgs, onResult }) => { + onResult(useBoardSelection(hookArgs)); + return null; +}; + +const render = (initialArgs) => { + let latest; + let wrapper; + act(() => { + wrapper = mount( + (latest = result)} + /> + ); + }); + return { + get: () => latest, + setArgs: (nextArgs) => { + act(() => { + wrapper.setProps({ hookArgs: nextArgs }); + }); + } + }; +}; + +const boards = [{ id: 'b1' }, { id: 'b2' }]; + +describe('useBoardSelection', () => { + it('starts with nothing selected', () => { + const { get } = render({ boards, section: 's1', search: '', page: 1 }); + expect(get().selectedBoard).toBe(null); + }); + + it('resolves the selected board from the list', () => { + const { get } = render({ boards, section: 's1', search: '', page: 1 }); + + act(() => { + get().select('b2'); + }); + + expect(get().selectedBoard).toEqual({ id: 'b2' }); + }); + + it('clears the selection when the section changes', () => { + const args = { boards, section: 's1', search: '', page: 1 }; + const { get, setArgs } = render(args); + + act(() => { + get().select('b2'); + }); + setArgs({ ...args, section: 's2' }); + + expect(get().selectedBoard).toBe(null); + }); + + it('clears the selection when the search changes', () => { + const args = { boards, section: 's1', search: '', page: 1 }; + const { get, setArgs } = render(args); + + act(() => { + get().select('b2'); + }); + setArgs({ ...args, search: 'com' }); + + expect(get().selectedBoard).toBe(null); + }); + + it('clears the selection when the page changes', () => { + const args = { boards, section: 's1', search: '', page: 1 }; + const { get, setArgs } = render(args); + + act(() => { + get().select('b2'); + }); + setArgs({ ...args, page: 2 }); + + expect(get().selectedBoard).toBe(null); + }); + + it('drops a selection whose board left the list', () => { + const args = { boards, section: 's1', search: '', page: 1 }; + const { get, setArgs } = render(args); + + act(() => { + get().select('b2'); + }); + setArgs({ ...args, boards: [{ id: 'b1' }] }); + + expect(get().selectedBoard).toBe(null); + }); + + it('returns focus to the registered trigger on clear', () => { + const { get } = render({ boards, section: 's1', search: '', page: 1 }); + const focus = jest.fn(); + + act(() => { + get().registerTrigger('b2', { focus }); + get().select('b2'); + }); + act(() => { + get().clear(); + }); + + expect(focus).toHaveBeenCalled(); + expect(get().selectedBoard).toBe(null); + }); +}); diff --git a/src/components/Communicator/CommunicatorDialog/hooks/useBoardsFetcher.js b/src/components/Communicator/CommunicatorDialog/hooks/useBoardsFetcher.js new file mode 100644 index 000000000..7e9da57de --- /dev/null +++ b/src/components/Communicator/CommunicatorDialog/hooks/useBoardsFetcher.js @@ -0,0 +1,183 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import API from '../../../../api'; +import { SECTIONS, BOARDS_PAGE_LIMIT } from '../CommunicatorDialog.constants'; + +/** + * Client-side filtering + pagination, used for the communicator section (which + * reads from redux) and as a fallback for the API-backed sections when the + * request fails. Mirrors the legacy `findBoards` helper. + */ +const filterLocalBoards = (boards, criteria, page, search = '') => { + let result = boards; + for (const [key, value] of Object.entries(criteria)) { + result = result.filter( + (board) => + (board.hasOwnProperty(key) && board[key] === value) || + !board.hasOwnProperty(key) + ); + } + if (search) { + const term = search.toLowerCase(); + const matches = (value) => + typeof value === 'string' && value.toLowerCase().includes(term); + result = result.filter( + (board) => matches(board.name) || matches(board.author) + ); + } + return { + total: result.length, + data: result.slice((page - 1) * BOARDS_PAGE_LIMIT, page * BOARDS_PAGE_LIMIT) + }; +}; + +/** + * Fetches the boards for the active dashboard section with page-based + * pagination and search. Keeps redux-derived inputs in refs so the fetch + * callback stays stable and never loops on array identity changes. + */ +const useBoardsFetcher = ({ + section, + search, + communicatorBoards, + availableBoards, + userData +}) => { + const [boards, setBoards] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const communicatorBoardsRef = useRef(communicatorBoards); + const availableBoardsRef = useRef(availableBoards); + const userDataRef = useRef(userData); + communicatorBoardsRef.current = communicatorBoards; + availableBoardsRef.current = availableBoards; + userDataRef.current = userData; + + const requestIdRef = useRef(0); + + const doFetch = useCallback(async (pageArg, searchArg, sectionArg) => { + const requestId = requestIdRef.current + 1; + requestIdRef.current = requestId; + setLoading(true); + setError(null); + try { + let result = { data: [], total: 0 }; + switch (sectionArg) { + case SECTIONS.MY_COMMUNICATOR: + result = filterLocalBoards( + communicatorBoardsRef.current, + {}, + pageArg, + searchArg + ); + break; + case SECTIONS.COMMUNITY: + try { + result = await API.getPublicBoards({ + limit: BOARDS_PAGE_LIMIT, + page: pageArg, + search: searchArg + }); + } catch (err) { + result = filterLocalBoards( + availableBoardsRef.current, + { isPublic: true, hidden: false }, + pageArg, + searchArg + ); + } + break; + case SECTIONS.MY_BOARDS: + try { + result = await API.getMyBoards({ + limit: BOARDS_PAGE_LIMIT, + page: pageArg, + search: searchArg + }); + } catch (err) { + result = filterLocalBoards( + availableBoardsRef.current, + { email: userDataRef.current.email, hidden: false }, + pageArg, + searchArg + ); + } + break; + default: + break; + } + if (requestIdRef.current !== requestId) { + return; + } + setBoards(result.data || []); + setTotal(result.total || 0); + } catch (err) { + if (requestIdRef.current !== requestId) { + return; + } + setError(err); + setBoards([]); + setTotal(0); + } finally { + if (requestIdRef.current === requestId) { + setLoading(false); + } + } + }, []); + + // Section or search change always resets to page 1 and refetches. + useEffect(() => { + setPage(1); + doFetch(1, search, section); + }, [doFetch, search, section]); + + const goToPage = useCallback( + (newPage) => { + setPage(newPage); + doFetch(newPage, search, section); + }, + [doFetch, search, section] + ); + + const refetch = useCallback( + () => doFetch(page, search, section), + [doFetch, page, search, section] + ); + + useEffect(() => { + if (!loading && page > 1 && boards.length === 0 && total > 0) { + goToPage(page - 1); + } + }, [loading, page, boards.length, total, goToPage]); + + const removeBoardFromList = useCallback((id) => { + setBoards((prev) => prev.filter((board) => board.id !== id)); + setTotal((prev) => Math.max(prev - 1, 0)); + }, []); + + const replaceBoardInList = useCallback((updated) => { + setBoards((prev) => + prev.map((board) => (board.id === updated.id ? updated : board)) + ); + }, []); + + const totalPages = Math.ceil(total / BOARDS_PAGE_LIMIT); + + return { + boards, + total, + totalPages, + page, + goToPage, + loading, + error, + refetch, + setBoards, + removeBoardFromList, + replaceBoardInList + }; +}; + +export default useBoardsFetcher; diff --git a/src/components/Communicator/CommunicatorDialog/hooks/useBoardsFetcher.test.js b/src/components/Communicator/CommunicatorDialog/hooks/useBoardsFetcher.test.js new file mode 100644 index 000000000..70a4244fc --- /dev/null +++ b/src/components/Communicator/CommunicatorDialog/hooks/useBoardsFetcher.test.js @@ -0,0 +1,212 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import { act } from 'react-dom/test-utils'; +import useBoardsFetcher from './useBoardsFetcher'; +import { SECTIONS } from '../CommunicatorDialog.constants'; + +jest.mock('../../../../api', () => ({ + __esModule: true, + default: { + getMyBoards: jest.fn(), + getPublicBoards: jest.fn(), + getBoard: jest.fn() + } +})); + +const makeBoards = (n) => + Array.from({ length: n }, (_, i) => ({ + id: `board-${i}`, + name: `Board ${i}`, + author: 'Author' + })); + +// Captures the hook's return value on every render so tests can assert on it. +const Harness = ({ hookArgs, onResult }) => { + const result = useBoardsFetcher(hookArgs); + onResult(result); + return null; +}; + +const renderHook = (hookArgs) => { + let latest; + const onResult = (result) => { + latest = result; + }; + let wrapper; + act(() => { + wrapper = mount(); + }); + return { + get: () => latest, + rerender: (nextArgs) => + act(() => { + wrapper.setProps({ hookArgs: nextArgs }); + }) + }; +}; + +describe('useBoardsFetcher (communicator local source)', () => { + it('paginates the communicator boards (12 per page)', () => { + const communicatorBoards = makeBoards(14); + const { get } = renderHook({ + section: SECTIONS.MY_COMMUNICATOR, + search: '', + communicatorBoards, + availableBoards: [], + userData: {} + }); + + expect(get().total).toBe(14); + expect(get().totalPages).toBe(2); + expect(get().boards).toHaveLength(12); + + act(() => get().goToPage(2)); + expect(get().boards).toHaveLength(2); + expect(get().page).toBe(2); + }); + + it('filters by name/author with the search term', () => { + const communicatorBoards = [ + { id: 'a', name: 'Animals', author: 'X' }, + { id: 'b', name: 'Food', author: 'X' }, + { id: 'c', name: 'Numbers', author: 'Animals fan' } + ]; + const { get, rerender } = renderHook({ + section: SECTIONS.MY_COMMUNICATOR, + search: '', + communicatorBoards, + availableBoards: [], + userData: {} + }); + + expect(get().total).toBe(3); + + rerender({ + section: SECTIONS.MY_COMMUNICATOR, + search: 'animals', + communicatorBoards, + availableBoards: [], + userData: {} + }); + + // matches "Animals" (name) and "Animals fan" (author), case-insensitive + expect(get().total).toBe(2); + }); + + it('treats regex special characters in the search as plain text', () => { + const communicatorBoards = [ + { id: 'a', name: 'Food (snacks)', author: 'X' }, + { id: 'b', name: 'Food', author: 'X' } + ]; + const { get } = renderHook({ + section: SECTIONS.MY_COMMUNICATOR, + search: '(', + communicatorBoards, + availableBoards: [], + userData: {} + }); + + expect(get().error).toBeNull(); + expect(get().boards.map((board) => board.id)).toEqual(['a']); + }); + + it('falls back to local boards with a special-character search when offline', async () => { + const API = require('../../../../api').default; + API.getMyBoards.mockRejectedValueOnce(new Error('offline')); + + const { get } = renderHook({ + section: SECTIONS.MY_BOARDS, + search: '[', + communicatorBoards: [], + availableBoards: [ + { id: 'mine', name: 'List [1]', email: 'tester@cboard.io' }, + { id: 'other', name: 'List', email: 'tester@cboard.io' } + ], + userData: { email: 'tester@cboard.io' } + }); + + await act(async () => { + await Promise.resolve(); + }); + + expect(get().error).toBeNull(); + expect(get().boards.map((board) => board.id)).toEqual(['mine']); + }); + + it('removes a board from the visible list optimistically', () => { + const communicatorBoards = makeBoards(3); + const { get } = renderHook({ + section: SECTIONS.MY_COMMUNICATOR, + search: '', + communicatorBoards, + availableBoards: [], + userData: {} + }); + + expect(get().boards).toHaveLength(3); + act(() => get().removeBoardFromList('board-1')); + expect(get().boards).toHaveLength(2); + expect(get().boards.find((b) => b.id === 'board-1')).toBeUndefined(); + }); + + it('ignores a slow response that a newer request has superseded', async () => { + const API = require('../../../../api').default; + let resolveSlow; + API.getMyBoards + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSlow = resolve; + }) + ) + .mockResolvedValueOnce({ + data: [{ id: 'fast', name: 'Fast' }], + total: 1 + }); + + const { get, rerender } = renderHook({ + section: SECTIONS.MY_BOARDS, + search: 'slow', + communicatorBoards: [], + availableBoards: [], + userData: { email: 'tester@cboard.io' } + }); + + await act(async () => { + rerender({ + section: SECTIONS.MY_BOARDS, + search: 'fast', + communicatorBoards: [], + availableBoards: [], + userData: { email: 'tester@cboard.io' } + }); + }); + + await act(async () => { + resolveSlow({ data: [{ id: 'slow', name: 'Slow' }], total: 1 }); + await Promise.resolve(); + }); + + expect(get().boards.map((board) => board.id)).toEqual(['fast']); + expect(get().loading).toBe(false); + }); + + it('steps back a page when the last board on it is removed', () => { + const communicatorBoards = makeBoards(13); + const { get } = renderHook({ + section: SECTIONS.MY_COMMUNICATOR, + search: '', + communicatorBoards, + availableBoards: [], + userData: {} + }); + + act(() => get().goToPage(2)); + expect(get().boards).toHaveLength(1); + + act(() => get().removeBoardFromList('board-12')); + + expect(get().page).toBe(1); + expect(get().boards).toHaveLength(12); + }); +}); diff --git a/src/components/Communicator/CommunicatorDialog/hooks/useCommunicatorBoards.js b/src/components/Communicator/CommunicatorDialog/hooks/useCommunicatorBoards.js new file mode 100644 index 000000000..436714bd2 --- /dev/null +++ b/src/components/Communicator/CommunicatorDialog/hooks/useCommunicatorBoards.js @@ -0,0 +1,183 @@ +import { useCallback } from 'react'; +import API from '../../../../api'; +import { SECTIONS } from '../CommunicatorDialog.constants'; +import messages from '../CommunicatorDialog.messages'; +import { moveVisibleBoard } from '../utils/quickAccessOrder'; +import { isLoggedIn } from '../utils/userData'; + +/** + * Membership of boards in the active communicator: adding, removing, choosing + * the root board and reordering. Every write goes through + * persistCommunicatorBoardIds so the local upsert and the API upsert stay in + * one place. + */ +const useCommunicatorBoards = ({ + section, + intl, + userData, + currentCommunicator, + availableBoards, + addBoards, + verifyAndUpsertCommunicator, + upsertApiCommunicator, + showNotification, + refetch +}) => { + const persistCommunicatorBoardIds = useCallback( + async (boardIds) => { + const updatedCommunicatorData = { + ...currentCommunicator, + boards: boardIds + }; + const upsertedCommunicator = verifyAndUpsertCommunicator( + updatedCommunicatorData + ); + if (isLoggedIn(userData)) { + try { + await upsertApiCommunicator(upsertedCommunicator); + } catch (err) { + console.error('Error upserting communicator', err); + } + } + }, + [ + currentCommunicator, + userData, + verifyAndUpsertCommunicator, + upsertApiCommunicator + ] + ); + + /** + * My Boards and Community list boards straight from the API, but the tray + * and the board view only resolve boards that are in the local store. Pull + * in any board that just joined the communicator so it can be shown. + */ + const ensureBoardIsLocal = useCallback( + async (board) => { + if (availableBoards.findIndex((b) => b.id === board.id) >= 0) { + return; + } + const boards = []; + try { + const boardData = await API.getBoard(board.id); + boards.push(boardData); + } catch (e) {} + addBoards(boards); + }, + [availableBoards, addBoards] + ); + + const addOrRemoveFromCommunicator = useCallback( + async (board) => { + const currentIds = currentCommunicator.boards; + const isMember = currentIds.includes(board.id); + const nextIds = isMember + ? currentIds.filter((id) => id !== board.id) + : [...currentIds, board.id]; + + showNotification( + intl.formatMessage( + isMember + ? messages.boardRemovedFromCommunicator + : messages.boardAddedToCommunicator + ) + ); + + await persistCommunicatorBoardIds(nextIds); + + if (!isMember) { + await ensureBoardIsLocal(board); + } + }, + [ + currentCommunicator, + persistCommunicatorBoardIds, + ensureBoardIsLocal, + showNotification, + intl + ] + ); + + const addOrRemoveBoard = useCallback( + async (board) => { + if (section === SECTIONS.MY_COMMUNICATOR) { + await persistCommunicatorBoardIds( + currentCommunicator.boards.filter((id) => id !== board.id) + ); + refetch(); + return; + } + await addOrRemoveFromCommunicator(board); + }, + [ + section, + currentCommunicator, + persistCommunicatorBoardIds, + addOrRemoveFromCommunicator, + refetch + ] + ); + + /** + * Home can be set from My Boards on a board that is not in Quick access yet. + * The root must always be a member of the communicator: the tray only lists + * member boards, and the add-to-Quick-access action is disabled for the + * root, so a non-member root could never be added afterwards. + */ + const setRootBoard = useCallback( + async (board) => { + const currentIds = currentCommunicator.boards; + const updatedCommunicatorData = { + ...currentCommunicator, + rootBoard: board.id, + boards: currentIds.includes(board.id) + ? currentIds + : [...currentIds, board.id] + }; + const upsertedCommunicator = verifyAndUpsertCommunicator( + updatedCommunicatorData + ); + try { + if (isLoggedIn(userData)) { + await upsertApiCommunicator(upsertedCommunicator); + } + } catch (err) { + console.error('Error upserting communicator', err); + } + await ensureBoardIsLocal(board); + }, + [ + currentCommunicator, + userData, + verifyAndUpsertCommunicator, + upsertApiCommunicator, + ensureBoardIsLocal + ] + ); + + /** + * Moves a board one slot up (delta -1) or down (delta 1) among the ids the + * tray is actually showing, leaving ids with no visible board untouched at + * their current index. Skips the write entirely when the move is a no-op. + */ + const reorderCommunicatorBoards = useCallback( + async (boardId, delta, visibleIds) => { + const currentIds = currentCommunicator.boards; + const nextIds = moveVisibleBoard(currentIds, visibleIds, boardId, delta); + if (nextIds === currentIds) { + return; + } + await persistCommunicatorBoardIds(nextIds); + }, + [currentCommunicator, persistCommunicatorBoardIds] + ); + + return { + addOrRemoveBoard, + setRootBoard, + reorderCommunicatorBoards + }; +}; + +export default useCommunicatorBoards; diff --git a/src/components/Communicator/CommunicatorDialog/shared/BoardStatusChips.js b/src/components/Communicator/CommunicatorDialog/shared/BoardStatusChips.js new file mode 100644 index 000000000..47e47f835 --- /dev/null +++ b/src/components/Communicator/CommunicatorDialog/shared/BoardStatusChips.js @@ -0,0 +1,79 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { intlShape } from 'react-intl'; +import { makeStyles } from '@material-ui/core/styles'; + +import TonalChip from '../../../UI/TonalChip'; +import { getBoardStatusItems } from './boardStatusItems'; + +const TONES = { + public: 'success', + private: 'neutral', + root: 'primary', + active: 'secondary' +}; + +const useStyles = makeStyles((theme) => ({ + root: { + display: 'flex', + flexWrap: 'wrap', + gap: theme.spacing(0.5) + } +})); + +/** + * Labelled counterpart to BoardStatusIcons, for surfaces with room for words. + * A bare glyph only carries meaning through a tooltip, which a keyboard user + * never reaches on a non-focusable . + */ +const BoardStatusChips = ({ + intl, + board, + isRoot, + isActive, + showVisibility, + className +}) => { + const classes = useStyles(); + const items = getBoardStatusItems(intl, { + isPublic: board.isPublic, + isRoot, + isActive, + showVisibility + }); + + if (!items.length) { + return null; + } + + return ( +
+ {items.map((item) => ( + + ))} +
+ ); +}; + +BoardStatusChips.defaultProps = { + isRoot: false, + isActive: false, + showVisibility: true +}; + +BoardStatusChips.propTypes = { + intl: intlShape.isRequired, + board: PropTypes.object.isRequired, + isRoot: PropTypes.bool, + isActive: PropTypes.bool, + showVisibility: PropTypes.bool, + className: PropTypes.string +}; + +export default BoardStatusChips; diff --git a/src/components/Communicator/CommunicatorDialog/shared/BoardStatusIcons.js b/src/components/Communicator/CommunicatorDialog/shared/BoardStatusIcons.js new file mode 100644 index 000000000..188c44384 --- /dev/null +++ b/src/components/Communicator/CommunicatorDialog/shared/BoardStatusIcons.js @@ -0,0 +1,75 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { intlShape } from 'react-intl'; +import { makeStyles } from '@material-ui/core/styles'; +import Tooltip from '@material-ui/core/Tooltip'; + +import { getBoardStatusItems } from './boardStatusItems'; + +const useStyles = makeStyles((theme) => ({ + root: { + display: 'inline-flex', + alignItems: 'center', + gap: theme.spacing(0.5) + }, + icon: { + fontSize: '1.3rem' + }, + // Semantic colors so each status reads on its own instead of a uniform green. + public: { + color: theme.palette.success?.main || '#369b00' + }, + private: { + color: theme.palette.text.secondary + }, + root_: { + color: theme.palette.primary.main + }, + active: { + color: theme.palette.secondary?.main || theme.palette.primary.main + } +})); + +const BoardStatusIcons = ({ intl, board, communicator, activeBoardId }) => { + const classes = useStyles(); + const isRoot = communicator.rootBoard === board.id; + const isActive = activeBoardId === board.id; + + const items = getBoardStatusItems(intl, { + isPublic: board.isPublic, + isRoot, + isActive + }); + const tones = { + public: classes.public, + private: classes.private, + root: classes.root_, + active: classes.active + }; + + return ( + + {items.map((item) => ( + + {React.cloneElement(item.icon, { + className: `${classes.icon} ${tones[item.tone]}`, + 'aria-label': item.title + })} + + ))} + + ); +}; + +BoardStatusIcons.propTypes = { + intl: intlShape.isRequired, + board: PropTypes.object.isRequired, + communicator: PropTypes.object.isRequired, + activeBoardId: PropTypes.string +}; + +export default BoardStatusIcons; diff --git a/src/components/Communicator/CommunicatorDialog/shared/BoardThumb.js b/src/components/Communicator/CommunicatorDialog/shared/BoardThumb.js new file mode 100644 index 000000000..e962db5fb --- /dev/null +++ b/src/components/Communicator/CommunicatorDialog/shared/BoardThumb.js @@ -0,0 +1,66 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { alpha, makeStyles } from '@material-ui/core/styles'; +import ViewModuleIcon from '@material-ui/icons/ViewModule'; + +import { isCordova } from '../../../../cordova-util'; + +const useStyles = makeStyles((theme) => { + const dark = theme.palette.type === 'dark'; + return { + root: { + position: 'relative', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + // Subtle diagonal wash so empty/short thumbnails still feel intentional. + background: dark + ? `linear-gradient(135deg, ${alpha( + theme.palette.common.white, + 0.06 + )} 0%, ${alpha(theme.palette.common.white, 0.02)} 100%)` + : `linear-gradient(135deg, ${theme.palette.grey[100]} 0%, ${ + theme.palette.grey[200] + } 100%)`, + overflow: 'hidden' + }, + image: { + width: '100%', + height: '100%', + objectFit: 'contain' + }, + placeholder: { + fontSize: '2.75rem', + color: alpha(theme.palette.text.primary, dark ? 0.26 : 0.22) + } + }; +}); + +const resolveCaption = (caption) => { + if (isCordova() && caption && caption.search('/') === 0) { + return `.${caption}`; + } + return caption; +}; + +const BoardThumb = ({ board, className }) => { + const classes = useStyles(); + const caption = resolveCaption(board.caption); + + return ( +
+ {caption ? ( + {board.name + ) : ( + + )} +
+ ); +}; + +BoardThumb.propTypes = { + board: PropTypes.object.isRequired, + className: PropTypes.string +}; + +export default BoardThumb; diff --git a/src/components/Communicator/CommunicatorDialog/shared/boardStatusItems.js b/src/components/Communicator/CommunicatorDialog/shared/boardStatusItems.js new file mode 100644 index 000000000..414dd90fb --- /dev/null +++ b/src/components/Communicator/CommunicatorDialog/shared/boardStatusItems.js @@ -0,0 +1,62 @@ +import React from 'react'; +import PublicIcon from '@material-ui/icons/Public'; +import KeyIcon from '@material-ui/icons/VpnKey'; +import HomeIcon from '@material-ui/icons/Home'; +import RemoveRedEyeIcon from '@material-ui/icons/RemoveRedEye'; + +import messages from '../CommunicatorDialog.messages'; + +/** + * The statuses a board can carry, in a fixed order, shared by every surface + * that shows them (cards, rows, details panel, quick access) so a board never + * reads differently depending on where it is displayed. `tone` names the + * semantic color; each surface maps it to its own class. `title` is the full + * phrase (tooltips, labels for assistive tech); `label` is the bare status for + * badges, where the surrounding context already says "board". + * + * Visibility is opt-out: in the quick access tray every row is one of the + * user's own boards, so a "Private Board" badge on each of them is noise that + * competes with the two statuses that do vary there. + */ +export const getBoardStatusItems = ( + intl, + { isPublic, isRoot, isActive, showVisibility = true } +) => { + const items = []; + + if (showVisibility) { + items.push({ + key: 'visibility', + title: intl.formatMessage( + isPublic ? messages.publicBoard : messages.privateBoard + ), + label: intl.formatMessage( + isPublic ? messages.statusPublic : messages.statusPrivate + ), + icon: isPublic ? : , + tone: isPublic ? 'public' : 'private' + }); + } + + if (isRoot) { + items.push({ + key: 'root', + title: intl.formatMessage(messages.rootBoard), + label: intl.formatMessage(messages.statusRoot), + icon: , + tone: 'root' + }); + } + + if (isActive) { + items.push({ + key: 'active', + title: intl.formatMessage(messages.activeBoard), + label: intl.formatMessage(messages.statusActive), + icon: , + tone: 'active' + }); + } + + return items; +}; diff --git a/src/components/Communicator/CommunicatorDialog/utils/boardActions.js b/src/components/Communicator/CommunicatorDialog/utils/boardActions.js new file mode 100644 index 000000000..a0d5697af --- /dev/null +++ b/src/components/Communicator/CommunicatorDialog/utils/boardActions.js @@ -0,0 +1,159 @@ +import React from 'react'; +import HomeIcon from '@material-ui/icons/Home'; +import StarIcon from '@material-ui/icons/Star'; +import StarBorderIcon from '@material-ui/icons/StarBorder'; +import QueueIcon from '@material-ui/icons/Queue'; +import InfoIcon from '@material-ui/icons/Info'; +import FlagIcon from '@material-ui/icons/Flag'; +import EditIcon from '@material-ui/icons/Edit'; +import DeleteIcon from '@material-ui/icons/Delete'; +import PublicIcon from '@material-ui/icons/Public'; +import KeyIcon from '@material-ui/icons/VpnKey'; +import VisibilityIcon from '@material-ui/icons/Visibility'; +import FileCopyIcon from '@material-ui/icons/FileCopy'; +import GetAppIcon from '@material-ui/icons/GetApp'; +import PictureAsPdfIcon from '@material-ui/icons/PictureAsPdf'; + +import { SECTIONS } from '../CommunicatorDialog.constants'; +import messages from '../CommunicatorDialog.messages'; + +/** + * The single source of truth for what a board can do in a given section. + * + * Returns ONE flat array of descriptors. Both the desktop details panel and + * the phone bottom sheet render straight from it, so an action cannot exist + * without being visible and labelled. + * + * Descriptor shape: + * { key, label, icon, onClick, disabled, premium, destructive } + */ +export const getBoardActions = ({ + section, + board, + communicator, + userData, + activeBoardId, + handlers, + intl +}) => { + const isRoot = communicator.rootBoard === board.id; + const isActive = activeBoardId === board.id; + const inCommunicator = communicator.boards.includes(board.id); + const hasAuth = !!(userData && userData.authToken); + const isOwnBoard = !!(userData && userData.email === board.email); + const t = (msg) => intl.formatMessage(msg); + + const infoAction = { + key: 'info', + label: t(messages.boardInfo), + icon: , + onClick: () => handlers.onShowInfo(board) + }; + + const setRootAction = { + key: 'setRoot', + label: t(messages.menuRootBoardOption), + icon: , + disabled: isRoot || !hasAuth, + onClick: () => handlers.onSetRoot(board) + }; + + const quickAccessAction = { + key: 'addRemove', + label: inCommunicator + ? t(messages.removeFromQuickAccess) + : t(messages.addToQuickAccess), + icon: inCommunicator ? : , + disabled: isRoot && inCommunicator, + onClick: () => handlers.onAddRemove(board) + }; + + switch (section) { + case SECTIONS.MY_COMMUNICATOR: + return [setRootAction, quickAccessAction, infoAction]; + + case SECTIONS.COMMUNITY: + return [ + { + key: 'copy', + label: t(messages.copyBoard), + icon: , + disabled: inCommunicator || isOwnBoard, + premium: true, + onClick: () => handlers.onCopy(board) + }, + infoAction, + { + key: 'report', + label: t(messages.boardReport), + icon: , + disabled: !hasAuth, + onClick: () => handlers.onReport(board) + } + ]; + + case SECTIONS.MY_BOARDS: + return [ + { + key: 'show', + label: t(messages.showBoard), + icon: , + onClick: () => handlers.onShow(board) + }, + { + key: 'edit', + label: t(messages.editBoardTitle), + icon: , + onClick: () => handlers.onEdit(board) + }, + { + // Ships disabled on purpose: the handler is not implemented yet. + // A labelled button that appears to work and silently does nothing + // is worse than one that is visibly unavailable. + key: 'clone', + label: t(messages.cloneBoard), + icon: , + disabled: true, + hint: t(messages.cloneBoardUnavailable), + onClick: () => {} + }, + { + key: 'export', + label: t(messages.exportBoard), + icon: , + onClick: () => handlers.onExport(board) + }, + { + key: 'exportPdf', + label: t(messages.exportBoardToPdf), + icon: , + onClick: () => handlers.onExportPdf(board) + }, + { + key: 'publish', + label: board.isPublic + ? t(messages.menuUnpublishOption) + : t(messages.menuPublishOption), + icon: board.isPublic ? : , + premium: !!(board.description || board.isPublic), + onClick: () => handlers.onPublishToggle(board) + }, + setRootAction, + quickAccessAction, + infoAction, + { + key: 'delete', + label: t(messages.deleteBoard), + icon: , + disabled: isRoot || isActive, + destructive: true, + onClick: () => handlers.onDelete(board) + } + ]; + + default: + return []; + } +}; + +export default getBoardActions; diff --git a/src/components/Communicator/CommunicatorDialog/utils/boardActions.test.js b/src/components/Communicator/CommunicatorDialog/utils/boardActions.test.js new file mode 100644 index 000000000..a9e3ae47b --- /dev/null +++ b/src/components/Communicator/CommunicatorDialog/utils/boardActions.test.js @@ -0,0 +1,138 @@ +import { getBoardActions } from './boardActions'; +import { SECTIONS } from '../CommunicatorDialog.constants'; + +jest.mock('../CommunicatorDialog.messages', () => ({ + __esModule: true, + default: new Proxy( + {}, + { + get: (target, prop) => ({ + id: String(prop), + defaultMessage: String(prop) + }) + } + ) +})); + +const intl = { formatMessage: ({ defaultMessage }) => defaultMessage }; + +const handlers = { + onShow: jest.fn(), + onSetRoot: jest.fn(), + onAddRemove: jest.fn(), + onShowInfo: jest.fn(), + onReport: jest.fn(), + onEdit: jest.fn(), + onDelete: jest.fn(), + onCopy: jest.fn(), + onClone: jest.fn(), + onExport: jest.fn(), + onExportPdf: jest.fn(), + onPublishToggle: jest.fn() +}; + +const build = (overrides = {}) => + getBoardActions({ + section: SECTIONS.MY_BOARDS, + board: { id: 'b2', name: 'Comidas', email: 'me@cboard.io' }, + communicator: { rootBoard: 'b1', boards: ['b1'] }, + userData: { authToken: 't', email: 'me@cboard.io' }, + activeBoardId: 'b1', + handlers, + intl, + ...overrides + }); + +const keysOf = (actions) => actions.map((action) => action.key); + +describe('getBoardActions', () => { + it('returns a flat array', () => { + expect(Array.isArray(build())).toBe(true); + }); + + it('lists My Boards actions with show first and delete last', () => { + const keys = keysOf(build()); + expect(keys[0]).toBe('show'); + expect(keys[keys.length - 1]).toBe('delete'); + expect(keys).toEqual( + expect.arrayContaining([ + 'show', + 'edit', + 'clone', + 'export', + 'exportPdf', + 'publish', + 'setRoot', + 'addRemove', + 'info', + 'delete' + ]) + ); + }); + + it('ships clone disabled because the handler is not implemented yet', () => { + const clone = build().find((action) => action.key === 'clone'); + expect(clone.disabled).toBe(true); + }); + + it('gives every action a label', () => { + build().forEach((action) => { + expect(typeof action.label).toBe('string'); + expect(action.label.length).toBeGreaterThan(0); + }); + }); + + it('disables delete for the root board', () => { + const actions = build({ board: { id: 'b1', tiles: [] } }); + expect(actions.find((a) => a.key === 'delete').disabled).toBe(true); + }); + + it('disables delete for the active board', () => { + const actions = build({ + board: { id: 'b9', tiles: [] }, + activeBoardId: 'b9' + }); + expect(actions.find((a) => a.key === 'delete').disabled).toBe(true); + }); + + it('marks delete as destructive', () => { + expect(build().find((a) => a.key === 'delete').destructive).toBe(true); + }); + + it('disables setRoot when the user is not authenticated', () => { + const actions = build({ userData: {} }); + expect(actions.find((a) => a.key === 'setRoot').disabled).toBe(true); + }); + + it('lists the quick access toggle as Remove when the board is a member', () => { + const actions = build({ + board: { id: 'b1', tiles: [] }, + communicator: { rootBoard: 'zzz', boards: ['b1'] } + }); + expect(actions.find((a) => a.key === 'addRemove').label).toBe( + 'removeFromQuickAccess' + ); + }); + + it('exposes copy, info and report in the Community section', () => { + const actions = build({ section: SECTIONS.COMMUNITY }); + expect(keysOf(actions)).toEqual(['copy', 'info', 'report']); + }); + + it('disables copying your own community board', () => { + const actions = build({ + section: SECTIONS.COMMUNITY, + board: { id: 'b2', email: 'me@cboard.io' } + }); + expect(actions.find((a) => a.key === 'copy').disabled).toBe(true); + }); + + it('exposes setRoot, remove and info in the Quick access section', () => { + const actions = build({ section: SECTIONS.MY_COMMUNICATOR }); + expect(keysOf(actions)).toEqual(['setRoot', 'addRemove', 'info']); + }); + + it('returns an empty array for an unknown section', () => { + expect(build({ section: 'nope' })).toEqual([]); + }); +}); diff --git a/src/components/Communicator/CommunicatorDialog/utils/boardLocale.js b/src/components/Communicator/CommunicatorDialog/utils/boardLocale.js new file mode 100644 index 000000000..a975421c6 --- /dev/null +++ b/src/components/Communicator/CommunicatorDialog/utils/boardLocale.js @@ -0,0 +1,17 @@ +import Language from '../../../Settings/Language/Language.messages'; + +/** + * Resolves the localized language name for a board's locale. + * Returns a react-intl message descriptor or null when unknown. + */ +export const getBoardLocaleMessage = (lang) => { + if (!Language) return null; + const source = lang || navigator.language || ''; + const locale = source.slice(0, 2).toLowerCase(); + return Language[locale] || null; +}; + +export const formatBoardLocale = (intl, lang) => { + const message = getBoardLocaleMessage(lang); + return message ? intl.formatMessage(message) : ''; +}; diff --git a/src/components/Communicator/CommunicatorDialog/utils/quickAccessOrder.js b/src/components/Communicator/CommunicatorDialog/utils/quickAccessOrder.js new file mode 100644 index 000000000..eb71cf5c6 --- /dev/null +++ b/src/components/Communicator/CommunicatorDialog/utils/quickAccessOrder.js @@ -0,0 +1,35 @@ +/** + * Ordering helpers for the Quick access tray. + * + * The tray renders only the communicator ids that resolve to a visible board, + * but it persists the FULL id array. Reordering therefore swaps two ids in + * place rather than rebuilding the array from the rendered rows — otherwise an + * id the user cannot currently see would be silently dropped from the + * communicator. + */ + +export const swapBoardIds = (allIds, idA, idB) => { + const a = allIds.indexOf(idA); + const b = allIds.indexOf(idB); + if (a < 0 || b < 0) { + return allIds; + } + const next = [...allIds]; + next[a] = idB; + next[b] = idA; + return next; +}; + +/** + * Moves `boardId` one position up (`delta === -1`) or down (`delta === 1`) + * relative to the other VISIBLE boards. Returns the original array unchanged + * when the move is not possible, so callers can skip a pointless write. + */ +export const moveVisibleBoard = (allIds, visibleIds, boardId, delta) => { + const from = visibleIds.indexOf(boardId); + const to = from + delta; + if (from < 0 || to < 0 || to >= visibleIds.length) { + return allIds; + } + return swapBoardIds(allIds, boardId, visibleIds[to]); +}; diff --git a/src/components/Communicator/CommunicatorDialog/utils/quickAccessOrder.test.js b/src/components/Communicator/CommunicatorDialog/utils/quickAccessOrder.test.js new file mode 100644 index 000000000..ee4088332 --- /dev/null +++ b/src/components/Communicator/CommunicatorDialog/utils/quickAccessOrder.test.js @@ -0,0 +1,56 @@ +import { swapBoardIds, moveVisibleBoard } from './quickAccessOrder'; + +describe('swapBoardIds', () => { + it('swaps two ids in place', () => { + expect(swapBoardIds(['a', 'b', 'c'], 'a', 'c')).toEqual(['c', 'b', 'a']); + }); + + it('returns the original array when an id is absent', () => { + const ids = ['a', 'b']; + expect(swapBoardIds(ids, 'a', 'zzz')).toBe(ids); + }); +}); + +describe('moveVisibleBoard', () => { + it('moves a board up among the visible ids', () => { + expect(moveVisibleBoard(['a', 'b', 'c'], ['a', 'b', 'c'], 'b', -1)).toEqual( + ['b', 'a', 'c'] + ); + }); + + it('moves a board down among the visible ids', () => { + expect(moveVisibleBoard(['a', 'b', 'c'], ['a', 'b', 'c'], 'b', 1)).toEqual([ + 'a', + 'c', + 'b' + ]); + }); + + it('leaves hidden ids at their original index', () => { + // 'hidden' is stored on the communicator but not rendered in the tray. + const all = ['a', 'hidden', 'b', 'c']; + const visible = ['a', 'b', 'c']; + + expect(moveVisibleBoard(all, visible, 'a', 1)).toEqual([ + 'b', + 'hidden', + 'a', + 'c' + ]); + }); + + it('is a no-op at the start of the visible list', () => { + const all = ['a', 'b']; + expect(moveVisibleBoard(all, ['a', 'b'], 'a', -1)).toBe(all); + }); + + it('is a no-op at the end of the visible list', () => { + const all = ['a', 'b']; + expect(moveVisibleBoard(all, ['a', 'b'], 'b', 1)).toBe(all); + }); + + it('is a no-op for an unknown id', () => { + const all = ['a', 'b']; + expect(moveVisibleBoard(all, ['a', 'b'], 'zzz', 1)).toBe(all); + }); +}); diff --git a/src/components/Communicator/CommunicatorDialog/utils/userData.js b/src/components/Communicator/CommunicatorDialog/utils/userData.js new file mode 100644 index 000000000..87320ed61 --- /dev/null +++ b/src/components/Communicator/CommunicatorDialog/utils/userData.js @@ -0,0 +1,2 @@ +export const isLoggedIn = (userData) => + !!userData && 'name' in userData && 'email' in userData; diff --git a/src/components/Communicator/CommunicatorToolbar/CommunicatorToolbar.container.js b/src/components/Communicator/CommunicatorToolbar/CommunicatorToolbar.container.js index 96317207a..6e84c9eb9 100644 --- a/src/components/Communicator/CommunicatorToolbar/CommunicatorToolbar.container.js +++ b/src/components/Communicator/CommunicatorToolbar/CommunicatorToolbar.container.js @@ -80,12 +80,15 @@ export const mapStateToProps = ( (communicator) => communicator.id === activeCommunicatorId ); const activeBoardId = board.activeBoardId; - const boards = getVisibleBoards({ board }).filter( - (board) => - board !== null && - board.id !== null && - currentCommunicator.boards.includes(board.id) + const visibleBoards = getVisibleBoards({ board }).filter( + (board) => board !== null && board.id !== null ); + // Render in the order stored on the communicator so the toolbar matches the + // Quick access tray. Ids with no visible board (deleted, not yet synced) are + // skipped rather than rendered as holes. + const boards = currentCommunicator.boards + .map((boardId) => visibleBoards.find((board) => board.id === boardId)) + .filter(Boolean); const currentBoard = boards.find((board) => board.id === activeBoardId); return { diff --git a/src/components/Communicator/CommunicatorToolbar/CommunicatorToolbar.container.test.js b/src/components/Communicator/CommunicatorToolbar/CommunicatorToolbar.container.test.js new file mode 100644 index 000000000..516959f73 --- /dev/null +++ b/src/components/Communicator/CommunicatorToolbar/CommunicatorToolbar.container.test.js @@ -0,0 +1,43 @@ +import { mapStateToProps } from './CommunicatorToolbar.container'; + +const createState = ({ boards, communicatorBoards }) => ({ + board: { + boards, + syncMeta: {}, + activeBoardId: 'b1' + }, + communicator: { + activeCommunicatorId: 'comm-1', + communicators: [ + { id: 'comm-1', boards: communicatorBoards, rootBoard: 'b1' } + ] + }, + app: { + userData: null, + displaySettings: { darkThemeActive: false } + } +}); + +describe('CommunicatorToolbar.container mapStateToProps', () => { + it('renders boards in communicator order, not in availableBoards order', () => { + const state = createState({ + boards: [{ id: 'b1' }, { id: 'b2' }, { id: 'b3' }], + communicatorBoards: ['b3', 'b1', 'b2'] + }); + + const props = mapStateToProps(state, {}); + + expect(props.boards.map((b) => b.id)).toEqual(['b3', 'b1', 'b2']); + }); + + it('skips communicator ids that have no visible board', () => { + const state = createState({ + boards: [{ id: 'b1' }, { id: 'b3' }], + communicatorBoards: ['b3', 'missing', 'b1'] + }); + + const props = mapStateToProps(state, {}); + + expect(props.boards.map((b) => b.id)).toEqual(['b3', 'b1']); + }); +}); diff --git a/src/components/UI/FullScreenDialog/FullScreenDialog.js b/src/components/UI/FullScreenDialog/FullScreenDialog.js index ff4052de8..44c642bf1 100644 --- a/src/components/UI/FullScreenDialog/FullScreenDialog.js +++ b/src/components/UI/FullScreenDialog/FullScreenDialog.js @@ -16,8 +16,10 @@ import './FullScreenDialog.css'; FullScreenDialog.propTypes = { disableSubmit: PropTypes.bool, + disableAppBarElevation: PropTypes.bool, open: PropTypes.bool, fullWidth: PropTypes.bool, + fullHeight: PropTypes.bool, title: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), onClose: PropTypes.func, onSubmit: PropTypes.func, @@ -59,6 +61,10 @@ const styles = { }, contentFullWidth: { margin: '0 auto' + }, + contentFullHeight: { + margin: '0 auto', + height: '100%' } }; @@ -93,10 +99,12 @@ function FullScreenDialog(props) { title, buttons, disableSubmit, + disableAppBarElevation, onClose, onSubmit, transition = transitions.UP, - fullWidth + fullWidth, + fullHeight } = props; const theme = useTheme(); @@ -109,7 +117,10 @@ function FullScreenDialog(props) { TransitionComponent={getTransition(transition)} onClose={onClose} > - + @@ -144,7 +155,15 @@ function FullScreenDialog(props) {
-
+
{children}
diff --git a/src/components/UI/FullScreenDialog/__snapshots__/FullScreenDialog.test.js.snap b/src/components/UI/FullScreenDialog/__snapshots__/FullScreenDialog.test.js.snap index ddda25d1d..736e65312 100644 --- a/src/components/UI/FullScreenDialog/__snapshots__/FullScreenDialog.test.js.snap +++ b/src/components/UI/FullScreenDialog/__snapshots__/FullScreenDialog.test.js.snap @@ -8,6 +8,7 @@ exports[`FullScreenDialog tests default renderer 1`] = ` "container": "FullScreenDialog-container-3", "containerDark": "FullScreenDialog-containerDark-4", "content": "FullScreenDialog-content-5", + "contentFullHeight": "FullScreenDialog-contentFullHeight-7", "contentFullWidth": "FullScreenDialog-contentFullWidth-6", "title": "FullScreenDialog-title-2", } diff --git a/src/components/UI/TonalChip/TonalChip.js b/src/components/UI/TonalChip/TonalChip.js new file mode 100644 index 000000000..5e316b3f1 --- /dev/null +++ b/src/components/UI/TonalChip/TonalChip.js @@ -0,0 +1,102 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { alpha, makeStyles } from '@material-ui/core/styles'; +import Chip from '@material-ui/core/Chip'; + +// Softer corner than the default 4px, matching the app's card surfaces. +const chipRadius = (theme) => (theme.shape.borderRadius || 4) * 2; + +/** + * A tinted ground with a same-hue label. The label takes the dark or light + * shade of the hue (whichever the theme needs) so it keeps contrast on the + * tint; a plain color string is accepted for neutral tones. + */ +const tone = (theme, color) => { + const isDark = theme.palette.type === 'dark'; + const main = color.main || color; + const label = (isDark ? color.light : color.dark) || main; + + return { + color: label, + backgroundColor: alpha(main, isDark ? 0.22 : 0.11), + borderColor: alpha(main, isDark ? 0.42 : 0.26) + }; +}; + +const useStyles = makeStyles((theme) => ({ + // MUI injects its own .MuiChip-outlined rules after component styles, so a + // single-class override loses the cascade and the chip falls back to the + // default grey outline. Doubling the selector settles it by specificity. + chip: { + '&&': { + height: 24, + borderRadius: chipRadius(theme), + borderStyle: 'solid', + borderWidth: 1, + fontSize: '0.75rem', + fontWeight: 600, + letterSpacing: '0.02em' + }, + '& .MuiChip-label': { + paddingLeft: theme.spacing(0.75), + paddingRight: theme.spacing(1) + }, + '& .MuiChip-icon': { + color: 'inherit', + fontSize: '0.9375rem', + marginLeft: theme.spacing(0.75), + marginRight: 0 + } + }, + withoutIcon: { + '& .MuiChip-label': { + paddingLeft: theme.spacing(1) + } + }, + primary: { '&&': tone(theme, theme.palette.primary) }, + secondary: { '&&': tone(theme, theme.palette.secondary) }, + success: { '&&': tone(theme, theme.palette.success || { main: '#369b00' }) }, + neutral: { '&&': tone(theme, theme.palette.text.secondary) } +})); + +/** + * The app's badge: a tinted ground with a same-hue label, sized to sit inside a + * row of text. Chips go through here so a count and a status never read as two + * different components. + */ +const TonalChip = ({ tone, icon, label, title, className }) => { + const classes = useStyles(); + + return ( + + ); +}; + +TonalChip.defaultProps = { + tone: 'primary' +}; + +TonalChip.propTypes = { + tone: PropTypes.oneOf(['primary', 'secondary', 'success', 'neutral']), + icon: PropTypes.element, + label: PropTypes.node.isRequired, + title: PropTypes.string, + className: PropTypes.string +}; + +export default TonalChip; diff --git a/src/components/UI/TonalChip/TonalChip.test.js b/src/components/UI/TonalChip/TonalChip.test.js new file mode 100644 index 000000000..fd652c05a --- /dev/null +++ b/src/components/UI/TonalChip/TonalChip.test.js @@ -0,0 +1,77 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import { MuiThemeProvider, createTheme } from '@material-ui/core/styles'; +import HomeIcon from '@material-ui/icons/Home'; + +import TonalChip from './TonalChip'; + +const render = (props = {}, theme = createTheme()) => + mount( + + + + ); + +const cssFor = (element) => { + const names = element.className.split(' '); + return Array.from(document.styleSheets) + .reduce( + (rules, sheet) => rules.concat(Array.from(sheet.cssRules || [])), + [] + ) + .filter( + (rule) => + rule.selectorText && + names.some((name) => rule.selectorText === `.${name}.${name}`) + ) + .map((rule) => ({ + // cssstyle only exposes a subset of properties as getters; go through + // getPropertyValue so shorthands like background-color come back. + background: rule.style.getPropertyValue('background-color'), + border: rule.style.getPropertyValue('border-color'), + color: rule.style.getPropertyValue('color') + })); +}; + +describe('TonalChip', () => { + it('renders its label and icon', () => { + const wrapper = render({ icon: }); + expect(wrapper.text()).toContain('Root'); + expect(wrapper.find(HomeIcon)).toHaveLength(1); + }); + + it('names itself after the full phrase when the label is abbreviated', () => { + const chip = render({ label: 'Root', title: 'Root Board' }) + .find('.MuiChip-root') + .first(); + expect(chip.prop('aria-label')).toBe('Root Board'); + }); + + it('tints the chip with the tone, at a specificity MUI cannot override', () => { + const chip = render({ tone: 'success' }).find('.MuiChip-root').first(); + const declarations = cssFor(chip.getDOMNode()); + const tinted = declarations.filter((style) => style.background); + expect(tinted).not.toHaveLength(0); + tinted.forEach((style) => { + expect(style.background).not.toBe('transparent'); + expect(style.border).toBeTruthy(); + }); + }); + + it('adapts the label shade to a dark theme', () => { + const shadeFor = (theme) => + cssFor( + render({ tone: 'success' }, theme) + .find('.MuiChip-root') + .first() + .getDOMNode() + ) + .map((style) => style.color) + .filter(Boolean); + + const light = shadeFor(createTheme()); + const dark = shadeFor(createTheme({ palette: { type: 'dark' } })); + expect(light).not.toEqual([]); + expect(dark).not.toEqual(light); + }); +}); diff --git a/src/components/UI/TonalChip/index.js b/src/components/UI/TonalChip/index.js new file mode 100644 index 000000000..c76d4f39f --- /dev/null +++ b/src/components/UI/TonalChip/index.js @@ -0,0 +1 @@ +export { default } from './TonalChip'; diff --git a/src/translations/src/cboard.json b/src/translations/src/cboard.json index b849c389d..7ea67cd61 100644 --- a/src/translations/src/cboard.json +++ b/src/translations/src/cboard.json @@ -288,22 +288,15 @@ "cboard.components.CommunicatorToolbar.SyncButton.saving": "Saving", "cboard.components.CommunicatorToolbar.SyncButton.synced": "Synced", "cboard.components.CommunicatorDialog.title": "My Communicator", - "cboard.components.CommunicatorDialog.menu": "Menu", "cboard.components.CommunicatorDialog.menuRootBoardOption": "Set as Root Board", "cboard.components.CommunicatorDialog.menuPublishOption": "Publish Board", "cboard.components.CommunicatorDialog.menuUnpublishOption": "Unpublish Board", "cboard.components.CommunicatorDialog.emptyBoardsList": "No boards", - "cboard.components.CommunicatorDialog.loadNextPage": "Load More", "cboard.components.CommunicatorDialog.search": "Search", "cboard.components.CommunicatorDialog.author": "By {author}", - "cboard.components.CommunicatorDialog.addBoard": "Add Board", - "cboard.components.CommunicatorDialog.removeBoard": "Remove Board", - "cboard.components.CommunicatorDialog.communicatorBoards": "Boards", "cboard.components.CommunicatorDialog.allBoards": "Public Boards", "cboard.components.CommunicatorDialog.myBoards": "All My Boards", "cboard.components.CommunicatorDialog.boardsQty": "{qty} boards", - "cboard.components.CommunicatorDialog.helpAndSupport": "Help And Support", - "cboard.components.CommunicatorDialog.termsOfService": "Terms Of Service", "cboard.components.CommunicatorDialog.tilesQty": "{qty} Tiles", "cboard.components.CommunicatorDialog.boardInfo": "Board Information", "cboard.components.CommunicatorDialog.boardAddedToCommunicator": "Board successfully added to your Communicator", @@ -313,7 +306,6 @@ "cboard.components.CommunicatorDialog.boardInfoAuthor": "Board Author", "cboard.components.CommunicatorDialog.boardInfoTiles": "Number of tiles", "cboard.components.CommunicatorDialog.boardInfoId": "Board ID", - "cboard.components.CommunicatorDialog.boardInfoUrl": "Board URL", "cboard.components.CommunicatorDialog.boardInfoDate": "Last edited", "cboard.components.CommunicatorDialog.boardDescription": "Description", "cboard.components.CommunicatorDialog.boardPublished": "Board successfully published! Everybody can see your board now.", @@ -330,8 +322,6 @@ "cboard.components.CommunicatorDialog.publishBoard": "Board Description", "cboard.components.CommunicatorDialog.publishBoardDescription": "Please provide a short description about the content of the board and what is the board intended for.", "cboard.components.CommunicatorDialog.accept": "Accept", - "cboard.components.CommunicatorDialog.imageBoard": "Edit Board Image", - "cboard.components.CommunicatorDialog.imageBoardDescription": "You can either upload or look for symbols from libraries.", "cboard.components.CommunicatorDialog.imageSearch": "Search for symbols", "cboard.components.CommunicatorDialog.editBoardTitle": "Edit board name and description", "cboard.components.CommunicatorDialog.editBoardTitleDescription": "You can edit board name and description from here. Please consider to write a meaningful description for your board.", @@ -353,8 +343,6 @@ "cboard.components.CommunicatorDialog.walkthroughPublicBoardsCopy": "Copy the public board and add it into your communicator. This option will create a copy of the public board, and it will not modify the original board.", "cboard.components.CommunicatorDialog.walkthroughPublicBoardsDetail": "This buttton will display detailed board information.", "cboard.components.CommunicatorDialog.walkthroughAllMyBoardsRemoveBoard": "Remove board from the communicator, but it will not delete the board from the database and it will be still available under All My Boards tab.", - "cboard.components.CommunicatorDialog.walkthroughAllMyBoardsEditBoardImage": "You can edit the image that represents the board.", - "cboard.components.CommunicatorDialog.walkthroughAllMyBoardsEditBoardName": "You can edit the board title and description.", "cboard.components.CommunicatorDialog.walkthroughAllMyBoardsAddBoard": "Add board into the communicator.", "cboard.components.CommunicatorDialog.walkthroughAllMyBoardsPublishBoard": "Publish board: Make your board public. The board will be displayed under the Public Boards tab.", "cboard.components.CommunicatorDialog.walkthroughAllMyBoardsUnpublishBoard": "Unpublish board: Make your board private. The board won't be displayed under the Public Boards tab.", @@ -368,6 +356,48 @@ "cboard.components.CommunicatorDialog.boardReportSuccesGratitude": "Thanks for helping Cboard to be out of inappropriate content.", "cboard.components.CommunicatorDialog.boardReportClose": "Close", "cboard.components.CommunicatorDialog.boardInfoLocale": "Language", + "cboard.components.CommunicatorDialog.boardCopyError": "ERROR: There was an error trying to get the board.", + "cboard.components.CommunicatorDialog.sectionMyBoards": "My Boards", + "cboard.components.CommunicatorDialog.sectionMyBoardsHint": "All the boards you have created", + "cboard.components.CommunicatorDialog.sectionCommunity": "Community", + "cboard.components.CommunicatorDialog.sectionCommunityHint": "Discover and copy public boards", + "cboard.components.CommunicatorDialog.navigation": "Navigation", + "cboard.components.CommunicatorDialog.expandNavigation": "Expand navigation", + "cboard.components.CommunicatorDialog.collapseNavigation": "Collapse navigation", + "cboard.components.CommunicatorDialog.searchBoardsPlaceholder": "Search by name or author", + "cboard.components.CommunicatorDialog.clearSearch": "Clear search", + "cboard.components.CommunicatorDialog.gridView": "Grid view", + "cboard.components.CommunicatorDialog.listView": "List view", + "cboard.components.CommunicatorDialog.openBoardInNewTab": "Open in a new tab", + "cboard.components.CommunicatorDialog.errorGettingBoards": "There was an error loading the boards.", + "cboard.components.CommunicatorDialog.tryAgain": "Try again", + "cboard.components.CommunicatorDialog.noBoardsFound": "No boards match your search.", + "cboard.components.CommunicatorDialog.sectionQuickAccess": "Quick access", + "cboard.components.CommunicatorDialog.sectionQuickAccessHint": "Boards you can reach from the communicator bar", + "cboard.components.CommunicatorDialog.quickAccessExplainer": "These {qty} boards appear in your communicator bar. They all live in My Boards.", + "cboard.components.CommunicatorDialog.quickAccessEmpty": "Your communicator bar is empty.", + "cboard.components.CommunicatorDialog.quickAccessAddFromMyBoards": "Add a board from My Boards", + "cboard.components.CommunicatorDialog.addToQuickAccess": "Add to quick access", + "cboard.components.CommunicatorDialog.removeFromQuickAccess": "Remove from quick access", + "cboard.components.CommunicatorDialog.boardOptions": "Board options", + "cboard.components.CommunicatorDialog.moveUp": "Move up", + "cboard.components.CommunicatorDialog.moveDown": "Move down", + "cboard.components.CommunicatorDialog.boardMoved": "{name} moved to position {position} of {total}", + "cboard.components.CommunicatorDialog.boardDetails": "Board details", + "cboard.components.CommunicatorDialog.selectBoardPrompt": "Select a board to see its details and actions", + "cboard.components.CommunicatorDialog.closeDetails": "Close details", + "cboard.components.CommunicatorDialog.showBoard": "Show", + "cboard.components.CommunicatorDialog.cloneBoard": "Clone", + "cboard.components.CommunicatorDialog.cloneBoardUnavailable": "Cloning is not available yet", + "cboard.components.CommunicatorDialog.exportBoard": "Export", + "cboard.components.CommunicatorDialog.exportBoardToPdf": "Save as PDF", + "cboard.components.CommunicatorDialog.exportingBoard": "Exporting {name}…", + "cboard.components.CommunicatorDialog.boardExported": "{name} exported", + "cboard.components.CommunicatorDialog.boardExportError": "Could not export {name}", + "cboard.components.CommunicatorDialog.statusPublic": "Public", + "cboard.components.CommunicatorDialog.statusPrivate": "Private", + "cboard.components.CommunicatorDialog.statusRoot": "Root", + "cboard.components.CommunicatorDialog.statusActive": "Active", "cboard.components.Board.EditToolbar.deleteTileTitle": "Delete Tiles", "cboard.components.Board.EditToolbar.deleteTileDescription": "Are you sure you want to delete the tile(s)?", "cboard.components.Board.EditToolbar.deleteTileCancel": "Cancel", @@ -3979,7 +4009,6 @@ "cboard.components.BoardShare.cannotShare": "Cannot share the board on facebook. Please try again later.", "cboard.components.SymbolSearch.cboardSymbols": "Cboard Symbols", "cboard.components.SymbolSearch.inclusivityOptions": "Inclusivity (ARASAAC & Cboard)", - "cboard.components.CommunicatorDialog.boardCopyError": "ERROR: There was an error trying to get the board.", "cboard.components.CommunicatorDialog.boardInfoLanguage": "Language", "cboard.components.CommunicatorToolbar.copyMessage": "Copied to clipboard!", "cboard.components.OfflineNotification.offlineTitle": "You're Offline",