@craftercms/studio-ui
Version:
Services, components, models & utils to build CrafterCMS authoring extensions.
470 lines (468 loc) • 17.5 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, { useEffect, useMemo } from 'react';
import { MoreVertRounded, RefreshRounded } from '@mui/icons-material';
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 { AuthorFilter } from './AuthorFilter';
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';
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();
const [
{
feed,
feedType,
usernames,
dateFrom,
dateTo,
limit,
offset,
total,
openRangePicker,
loadingChunk,
loadingFeed,
selectedPackageId,
openPackageDetailsDialog
},
setState
] = useSpreadState({
loadingChunk: false,
loadingFeed: false,
openRangePicker: false,
openPackageDetailsDialog: false,
selectedPackageId: null,
feed: null,
usernames: null,
feedType: 'timeline',
dateFrom: null,
dateTo: null,
limit: 10,
offset: 0,
total: 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 onRefresh = useMemo(
() => () => {
setState({ feed: null, total: null, offset: 0, loadingFeed: true });
fetchActivity(site, {
actions: activities.filter((key) => key !== 'ALL'),
usernames,
dateTo,
dateFrom,
limit
}).subscribe((feed) => {
setState({ feed, total: feed.total, loadingFeed: false });
});
},
[site, activities, dateFrom, dateTo, limit, setState, usernames]
);
// endregion
const loadNextPage = () => {
let newOffset = offset + limit;
setState({ loadingChunk: true });
fetchActivity(site, {
actions: activities.filter((key) => key !== 'ALL'),
usernames,
dateTo,
dateFrom,
limit,
offset: newOffset
}).subscribe((nextFeedChunk) => {
setState({
feed: feed.concat(nextFeedChunk),
total: nextFeedChunk.total,
offset: newOffset,
loadingChunk: false
});
});
};
const onItemClick = (previewUrl, e) => {
const pathname = window.location.pathname;
if (pathname.includes(PREVIEW_URL_PATH)) {
dispatch(changeCurrentUrl(previewUrl));
widgetDialogContext === null || widgetDialogContext === void 0 ? void 0 : 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;
useEffect(() => {
onRefresh();
}, [onRefresh, setState]);
return React.createElement(
DashletCard,
Object.assign({}, props, {
borderLeftColor: borderLeftColor,
title: React.createElement(FormattedMessage, { id: 'words.activity', defaultMessage: 'Activity' }),
headerAction: React.createElement(IconButton, { onClick: onRefresh }, 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 } },
listItemProps: { dense: true }
},
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(AuthorFilter, {
onChange: (users) => setState({ usernames: users.map(({ username }) => username) })
}),
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 } },
listItemProps: { dense: true },
onClick: () => {
if (feedType === 'range') {
setState({ openRangePicker: true });
return false;
}
}
},
React.createElement(FormattedMessage, { id: 'words.timeline', defaultMessage: 'Timeline' })
)
)
}),
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 === null || feed === void 0 ? void 0 : feed.length) &&
React.createElement(
Timeline,
{ position: 'right' },
React.createElement(
CustomTimelineItem,
null,
React.createElement(SizedTimelineSeparator, null, React.createElement(TimelineDotWithoutAvatar, null)),
React.createElement(TimelineContent, { sx: emptyTimelineContentSx })
),
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)
)
)
)
),
loadingChunk
? 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: hasMoreItemsToLoad
? { py: '12px', px: 2, display: 'flex', alignItems: 'center' }
: emptyTimelineContentSx
},
hasMoreItemsToLoad &&
React.createElement(
Typography,
{ variant: 'body2', color: 'text.secondary' },
React.createElement(FormattedMessage, {
id: 'activityDashlet.hasMoreItemsToLoadMessage',
defaultMessage: '{count} more {count, plural, one {activity} other {activities}} available',
values: { count: total - (limit + offset) }
})
)
)
)
),
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;