UNPKG

@craftercms/studio-ui

Version:

Services, components, models & utils to build CrafterCMS authoring extensions.

627 lines (625 loc) 22.6 kB
/* * Copyright (C) 2007-2022 Crafter Software Corporation. All Rights Reserved. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Lesser General Public License version 3 as published by * the Free Software Foundation. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ /* * Copyright (C) 2007-2022 Crafter Software Corporation. All Rights Reserved. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License version 3 as published by * the Free Software Foundation. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import RefreshRounded from '@mui/icons-material/RefreshRounded'; import MoreVertRounded from '@mui/icons-material/MoreVertRounded'; import { PREVIEW_URL_PATH, UNDEFINED } from '../../utils/constants'; import useActiveSiteId from '../../hooks/useActiveSiteId'; import useEnv from '../../hooks/useEnv'; import useLocale from '../../hooks/useLocale'; import useSpreadState from '../../hooks/useSpreadState'; import { fetchActivity } from '../../services/dashboard'; import IconButton from '@mui/material/IconButton'; import { FormattedMessage, useIntl } from 'react-intl'; import MuiTimeline from '@mui/lab/Timeline'; import TimelineItem from '@mui/lab/TimelineItem'; import TimelineSeparator from '@mui/lab/TimelineSeparator'; import TimelineDot from '@mui/lab/TimelineDot'; import TimelineContent from '@mui/lab/TimelineContent'; import TimelineConnector from '@mui/lab/TimelineConnector'; import Typography from '@mui/material/Typography'; import DropDownMenu from '../DropDownMenuButton/DropDownMenuButton'; import { activityNameLookup, renderActivity, renderActivityTimestamp, useSelectionLookupState } from './utils'; import Skeleton from '@mui/material/Skeleton'; import { styled } from '@mui/material/styles'; import { RangePickerModal } from './RangePickerModal'; import Tooltip from '@mui/material/Tooltip'; import DashletCard from '../DashletCard/DashletCard'; import { asLocalizedDateTime } from '../../utils/datetime'; import { DashletAvatar, DashletEmptyMessage, PersonAvatar, PersonFullName } from '../DashletCard/dashletCommons'; import { getSystemLink } from '../../utils/system'; import { useDispatch } from 'react-redux'; import { changeCurrentUrl } from '../../state/actions/preview'; import { useWidgetDialogContext } from '../WidgetDialog'; import PackageDetailsDialog from '../PackageDetailsDialog/PackageDetailsDialog'; import InfiniteScroll from 'react-infinite-scroller'; import Box from '@mui/material/Box'; import { contentEvent, deleteContentEvent, publishEvent, workflowEvent } from '../../state/actions/system'; import { getHostToHostBus } from '../../utils/subjects'; import { filter } from 'rxjs/operators'; import LoadingIconButton from '../LoadingIconButton'; import { ApiResponseErrorState } from '../ApiResponseErrorState'; import Button from '@mui/material/Button'; import KeyboardArrowDownRounded from '@mui/icons-material/KeyboardArrowDownRounded'; import Popover from '@mui/material/Popover'; import TextField from '@mui/material/TextField'; import InputAdornment from '@mui/material/InputAdornment'; import ReplyRounded from '@mui/icons-material/ReplyRounded'; import ClearRounded from '@mui/icons-material/ClearRounded'; const Timeline = styled(MuiTimeline)({ margin: 0, padding: 0 }); const CustomTimelineItem = styled(TimelineItem)({ minHeight: 0, '&.MuiTimelineItem-missingOppositeContent::before': { display: 'none', content: 'none' } }); const TimelineDotWithAvatar = styled(TimelineDot)({ padding: 0, border: 'none', alignSelf: 'auto' }); const TimelineDotWithoutAvatar = styled(TimelineDot)({ alignSelf: 'auto' }); const SizedTimelineSeparator = styled(TimelineSeparator)({ width: 40, minWidth: 40, textAlign: 'center', alignItems: 'center', alignContent: 'center', justifyContent: 'center' }); const emptyTimelineContentSx = { pt: 0, pb: 0 }; const getSkeletonTimelineItems = ({ items = 1 }) => new Array(items) .fill(null) .map((nothing, index) => React.createElement( CustomTimelineItem, { key: index }, React.createElement( SizedTimelineSeparator, null, React.createElement(TimelineConnector, null), React.createElement(TimelineDotWithAvatar, null, React.createElement(DashletAvatar, null)), React.createElement(TimelineConnector, null) ), React.createElement( TimelineContent, { sx: { py: '12px', px: 2 } }, React.createElement(Typography, { variant: 'h6' }, React.createElement(Skeleton, { variant: 'text' })), React.createElement(Typography, { variant: 'body1' }, React.createElement(Skeleton, { variant: 'text' })), React.createElement(Typography, { variant: 'body1' }, React.createElement(Skeleton, { variant: 'text' })) ) ) ); export function ActivityDashlet(props) { const { borderLeftColor = 'success.main' } = props; const locale = useLocale(); const site = useActiveSiteId(); const { authoringBase } = useEnv(); const { formatMessage } = useIntl(); const dispatch = useDispatch(); const widgetDialogContext = useWidgetDialogContext(); // region const { ... } = state const [ { feed, feedType, usernames, dateFrom, dateTo, limit, offset, total, openRangePicker, loadingFeed, loadingChunk, selectedPackageId, openPackageDetailsDialog, error }, // endregion setState ] = useSpreadState({ loadingChunk: false, loadingFeed: false, openRangePicker: false, openPackageDetailsDialog: false, selectedPackageId: null, feed: null, usernames: null, feedType: 'timeline', dateFrom: null, dateTo: null, limit: 50, offset: 0, total: null, error: null }); const [selectedActivities, setSelectedActivities, activities] = useSelectionLookupState({ ALL: true }); // region activityFilterOptions = ... const activityFilterOptions = useMemo( () => Object.keys(activityNameLookup).map((key) => ({ id: key, primaryText: activityNameLookup[key], selected: selectedActivities[key] })), [selectedActivities] ); // endregion // region feedTypeOptions const feedTypeOptions = useMemo( () => [ { id: 'timeline', primaryText: React.createElement(FormattedMessage, { id: 'words.timeline', defaultMessage: 'Timeline' }), selected: feedType === 'timeline', secondaryText: React.createElement(FormattedMessage, { id: 'activityDashlet.timelineOptionTip', defaultMessage: 'All activity, most recent first' }) }, { id: 'range', primaryText: React.createElement(FormattedMessage, { id: 'words.range', defaultMessage: 'Range' }), selected: feedType === 'range', secondaryText: React.createElement(FormattedMessage, { id: 'activityDashlet.rangeOptionTip', defaultMessage: 'Specify a range of dates' }) } ], [feedType] ); // endregion // region onRefresh const fetchFeed = useCallback(() => { setState({ feed: null, total: null, offset: 0, loadingFeed: true, error: null }); return fetchActivity(site, { actions: activities.filter((key) => key !== 'ALL'), usernames, dateTo, dateFrom, limit }).subscribe({ next: (feed) => { setState({ feed, total: feed.total, loadingFeed: false, loadingChunk: false, error: null }); }, error(error) { setState({ loadingFeed: false, loadingChunk: false, error: error }); } }); }, [activities, dateFrom, dateTo, limit, setState, site, usernames]); // endregion const listRef = useRef(); const loadNextPage = () => { let newOffset = offset + limit; setState({ loadingChunk: true }); fetchActivity(site, { actions: activities.filter((key) => key !== 'ALL'), usernames, dateTo, dateFrom, limit, offset: newOffset }).subscribe({ next: (nextFeedChunk) => { setState({ feed: feed.concat(nextFeedChunk), total: nextFeedChunk.total, offset: newOffset, loadingChunk: false }); }, error(error) { setState({ loadingFeed: false, loadingChunk: false, error: error }); } }); }; const onRefresh = useCallback(() => { setState({ loadingChunk: true }); fetchActivity(site, { actions: activities.filter((key) => key !== 'ALL'), usernames, dateTo, dateFrom, offset: 0, limit: offset + limit }).subscribe({ next: (feed) => { setState({ feed, total: feed.total, loadingChunk: false }); }, error(error) { setState({ loadingFeed: false, loadingChunk: false, error: error }); } }); }, [activities, dateFrom, dateTo, limit, offset, setState, site, usernames]); const onItemClick = (previewUrl, e) => { const pathname = window.location.pathname; if (pathname.includes(PREVIEW_URL_PATH)) { dispatch(changeCurrentUrl(previewUrl)); widgetDialogContext?.onClose(e, null); } else { window.location.href = getSystemLink({ page: previewUrl, systemLinkId: 'preview', site, authoringBase }); } }; const onPackageClick = (pkg) => { setState({ openPackageDetailsDialog: true, selectedPackageId: pkg.id }); }; const hasMoreItemsToLoad = total > 0 && limit + offset < total; const isFetching = loadingChunk || loadingFeed; useEffect(() => { fetchFeed(); }, [fetchFeed, setState]); // region Item Updates Propagation useEffect(() => { const events = [deleteContentEvent.type, workflowEvent.type, publishEvent.type, contentEvent.type]; const hostToHost$ = getHostToHostBus(); const subscription = hostToHost$.pipe(filter((e) => events.includes(e.type))).subscribe(({ type, payload }) => { onRefresh(); }); return () => { subscription.unsubscribe(); }; }, [onRefresh]); // endregion // region author filter const [authorFilterOpen, setAuthorFilterOpen] = useState(false); const [authorFilterValue, setAuthorFilterValue] = useState(''); const authorFilterButtonRef = useRef(); const authorFilterInputRef = useRef(); const onAuthorFilterChange = (users) => { if (users.length === 0 && (usernames === null || usernames.length === 0)) return; setState({ usernames: users.map(({ username }) => username) }); }; const submitAuthorFilterChanges = () => { const usernames = authorFilterValue .split(',') .filter(Boolean) .map((username) => ({ username: username.trim() })); onAuthorFilterChange(usernames); }; const clearAuthorFilterValue = () => { setAuthorFilterOpen(false); setAuthorFilterValue(''); onAuthorFilterChange([]); }; const handleAuthorFilterInputChange = (e) => { setAuthorFilterValue(e.target.value); }; const handleAuthorFilterKeyUp = (e) => { if (e.key === 'Enter') { submitAuthorFilterChanges(); } }; useEffect(() => { if (authorFilterOpen && !isFetching && authorFilterInputRef.current) { setTimeout(() => { authorFilterInputRef.current.focus(); }); } }, [authorFilterOpen, isFetching]); // endregion return React.createElement( DashletCard, { ...props, borderLeftColor: borderLeftColor, title: React.createElement(FormattedMessage, { id: 'words.activity', defaultMessage: 'Activity' }), headerAction: React.createElement( LoadingIconButton, { onClick: () => onRefresh(), loading: isFetching }, React.createElement(RefreshRounded, null) ), actionsBar: React.createElement( React.Fragment, null, React.createElement( DropDownMenu, { size: 'small', variant: 'text', onMenuItemClick: (e, id) => setSelectedActivities(id), options: activityFilterOptions, closeOnSelection: false, menuProps: { sx: { minWidth: 180 } } }, selectedActivities.ALL ? React.createElement(FormattedMessage, { id: 'activityDashlet.showActivityByEveryone', defaultMessage: 'All activities' }) : React.createElement(FormattedMessage, { id: 'activityDashlet.showSelectedActivities', defaultMessage: 'Selected activities ({count})', values: { count: activities.length } }) ), React.createElement( Button, { ref: authorFilterButtonRef, variant: 'text', size: 'small', endIcon: React.createElement(KeyboardArrowDownRounded, null), onClick: (e) => { setAuthorFilterOpen(true); } }, React.createElement(FormattedMessage, { id: 'words.author', defaultMessage: 'Author' }) ), React.createElement( Popover, { open: authorFilterOpen, anchorEl: authorFilterButtonRef.current, onClose: () => setAuthorFilterOpen(false), slotProps: { paper: { sx: { width: 350, p: 1 } } }, anchorOrigin: { vertical: 'bottom', horizontal: 'left' } }, React.createElement(TextField, { fullWidth: true, autoFocus: true, value: authorFilterValue, disabled: isFetching, onChange: handleAuthorFilterInputChange, placeholder: 'e.g. "jon.doe, jdoe, jane@example.com"', onKeyUp: handleAuthorFilterKeyUp, InputProps: { inputRef: authorFilterInputRef, endAdornment: React.createElement( InputAdornment, { position: 'end' }, React.createElement( IconButton, { disabled: isFetching, title: formatMessage({ defaultMessage: 'Submit' }), edge: 'end', onClick: submitAuthorFilterChanges, size: 'small' }, React.createElement(ReplyRounded, { sx: { transform: 'scaleX(-1)' } }) ), React.createElement( IconButton, { disabled: isFetching, title: formatMessage({ defaultMessage: 'Clear & close' }), edge: 'end', onClick: clearAuthorFilterValue, size: 'small' }, React.createElement(ClearRounded, null) ) ) } }) ), React.createElement( DropDownMenu, { size: 'small', variant: 'text', onMenuItemClick: (e, feedType) => { const state = { feedType, dateTo, dateFrom, openRangePicker: feedType === 'range' }; if (feedType === 'timeline') { state.dateTo = null; state.dateFrom = null; } setState(state); }, options: feedTypeOptions, menuProps: { sx: { minWidth: 200 } }, onClick: () => { if (feedType === 'range') { setState({ openRangePicker: true }); } } }, React.createElement(FormattedMessage, { id: 'words.timeline', defaultMessage: 'Timeline' }) ) ), cardContentProps: { ref: listRef } }, error && React.createElement(ApiResponseErrorState, { error: error.response?.response, validationErrors: error.response?.validationErrors?.map((error) => { // `error.field` looks like getActivitiesForUsers.usernames[1].<list element> const match = error.field.match(/usernames\[(\d)]/); if (match) { error.message = formatMessage( { defaultMessage: '"{value}" is not a valid username.' }, { value: usernames[match[1]] } ); } return error; }) }), loadingFeed && React.createElement( Timeline, { position: 'right' }, React.createElement( CustomTimelineItem, null, React.createElement(SizedTimelineSeparator, null, React.createElement(TimelineDotWithoutAvatar, null)), React.createElement(TimelineContent, { sx: emptyTimelineContentSx }) ), getSkeletonTimelineItems({ items: 3 }), React.createElement( CustomTimelineItem, null, React.createElement( SizedTimelineSeparator, null, hasMoreItemsToLoad ? React.createElement( TimelineDotWithAvatar, { sx: { bgcolor: 'unset' } }, React.createElement( Tooltip, { title: React.createElement(FormattedMessage, { id: 'activityDashlet.loadMore', defaultMessage: 'Load {limit} more', values: { limit } }) }, React.createElement( IconButton, { color: 'primary', size: 'small', onClick: loadNextPage }, React.createElement(MoreVertRounded, null) ) ) ) : React.createElement(TimelineDotWithoutAvatar, null) ), React.createElement(TimelineContent, { sx: emptyTimelineContentSx }) ) ), Boolean(feed?.length) && React.createElement( Timeline, { position: 'right' }, React.createElement( CustomTimelineItem, null, React.createElement(SizedTimelineSeparator, null, React.createElement(TimelineDotWithoutAvatar, null)), React.createElement(TimelineContent, { sx: emptyTimelineContentSx }) ), React.createElement( InfiniteScroll, { initialLoad: false, pageStart: 0, loadMore: () => { loadNextPage(); }, hasMore: hasMoreItemsToLoad, loader: React.createElement( Box, { key: 'infiniteScrollLoaderSkeleton' }, getSkeletonTimelineItems({ items: 3 }) ), useWindow: false, getScrollParent: () => listRef.current }, feed.map((activity) => React.createElement( CustomTimelineItem, { key: activity.id }, React.createElement( SizedTimelineSeparator, null, React.createElement(TimelineConnector, null), React.createElement( TimelineDotWithAvatar, null, React.createElement(PersonAvatar, { person: activity.person }) ), React.createElement(TimelineConnector, null) ), React.createElement( TimelineContent, { sx: { py: '12px', px: 2 } }, React.createElement(PersonFullName, { person: activity.person }), React.createElement( Typography, null, renderActivity(activity, { formatMessage, onPackageClick, onItemClick }) ), React.createElement( Typography, { variant: 'caption', title: asLocalizedDateTime( activity.actionTimestamp, locale.localeCode, locale.dateTimeFormatOptions ) }, renderActivityTimestamp(activity.actionTimestamp, locale) ) ) ) ) ), !hasMoreItemsToLoad && React.createElement( CustomTimelineItem, null, React.createElement(SizedTimelineSeparator, null, React.createElement(TimelineDotWithoutAvatar, null)) ) ), total === 0 && React.createElement( DashletEmptyMessage, null, React.createElement(FormattedMessage, { id: 'activityDashlet.noEntriesFound', defaultMessage: 'No activity was found.' }) ), React.createElement(RangePickerModal, { open: openRangePicker, onClose: () => setState({ openRangePicker: false }), onAccept: (dateFrom, dateTo) => setState({ dateFrom: dateFrom.toISOString(), dateTo: dateTo.toISOString(), openRangePicker: false }), onSwitchToTimelineClick: feedType === 'range' ? () => { setState({ feedType: 'timeline', dateTo: null, dateFrom: null, openRangePicker: false }); } : UNDEFINED }), React.createElement(PackageDetailsDialog, { open: openPackageDetailsDialog, onClose: () => setState({ openPackageDetailsDialog: false }), onClosed: () => setState({ selectedPackageId: null }), packageId: selectedPackageId }) ); } export default ActivityDashlet;