@craftercms/studio-ui
Version:
Services, components, models & utils to build CrafterCMS authoring extensions.
627 lines (625 loc) • 22.6 kB
JavaScript
/*
* 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;