From 28731e2241949815c0f53667f44d441066d8f0b9 Mon Sep 17 00:00:00 2001 From: MarkLark86 Date: Fri, 20 Oct 2023 15:08:49 +1100 Subject: [PATCH] [TGA-62] Support multiple author sign offs (#16) * Update server to handle multiple author sign offs * Update front-end to handle multiple author sign offs * fix: Reload users when authors change * fix(api): Issues with associations and publish_sign_off fields * Remove duplicate code, use httpRequestVoidLocal from API * fix failing tests --- .../src/components/SignOffListItem.tsx | 107 ++++++++ .../src/components/SignOffRequestDetails.tsx | 24 ++ .../src/components/UserDetail.tsx | 26 ++ .../tga-sign-off/src/components/details.tsx | 66 ----- .../src/components/fields/editor.tsx | 230 +++++++++++++----- .../src/components/fields/preview.tsx | 145 ++++++++--- .../src/components/fields/template.tsx | 4 +- .../extensions/tga-sign-off/src/interfaces.ts | 27 +- client/extensions/tga-sign-off/src/utils.ts | 104 +++++++- server/tga/author_profiles.py | 33 ++- server/tga/sign_off/__init__.py | 20 ++ server/tga/sign_off/sign_off_requests.py | 45 +++- server/tga/sign_off/template_globals.py | 2 +- server/tga/sign_off/utils.py | 176 ++++++++++++-- 14 files changed, 806 insertions(+), 203 deletions(-) create mode 100644 client/extensions/tga-sign-off/src/components/SignOffListItem.tsx create mode 100644 client/extensions/tga-sign-off/src/components/SignOffRequestDetails.tsx create mode 100644 client/extensions/tga-sign-off/src/components/UserDetail.tsx delete mode 100644 client/extensions/tga-sign-off/src/components/details.tsx diff --git a/client/extensions/tga-sign-off/src/components/SignOffListItem.tsx b/client/extensions/tga-sign-off/src/components/SignOffListItem.tsx new file mode 100644 index 0000000..0dba802 --- /dev/null +++ b/client/extensions/tga-sign-off/src/components/SignOffListItem.tsx @@ -0,0 +1,107 @@ +import * as React from 'react'; + +import {IUser} from 'superdesk-api'; +import {superdesk} from '../superdesk'; + +import {ButtonGroup, Button, ContentDivider} from 'superdesk-ui-framework/react'; +import {UserDetail} from './UserDetail'; + +interface IPropsBase { + state: 'approved' | 'pending' | 'not_sent'; + user: IUser; + readOnly?: boolean; + appendContentDivider?: boolean; + buttonProps?: { + text: string; + icon: string; + onClick(): void; + }; +} + +interface IPropsApproved extends IPropsBase { + state: 'approved'; + fundingSource: string; + affiliation: string; + date: string; +} + +interface IPropsPendingOrExpired extends IPropsBase { + state: 'pending'; + date: string; +} + +interface IPropsNotSend extends IPropsBase { + state: 'not_sent'; +} + +type IProps = IPropsApproved | IPropsPendingOrExpired | IPropsNotSend; + +export function SignOffListItem(props: IProps) { + const {formatDateTime, gettext} = superdesk.localization; + + return ( + +
+ + {props.state === 'not_sent' ? null : ( +
+ + {formatDateTime(new Date(props.date))} +
+ )} + + {props.buttonProps == null ? null : ( + +
+ {props.state !== 'approved' ? null : ( + +
+ + {props.fundingSource.trim()} +
+
+ + {props.affiliation.trim()} +
+
+ )} + + {props.state !== 'pending' ? null : ( +
+ + {new Date(props.date) <= new Date() ? ( + {gettext('Expired')} + ): ( + {gettext('Pending')} + )} +
+ )} + {props.state !== 'not_sent' ? null : ( +
+ + {gettext('Not Sent')} +
+ )} + + {props.appendContentDivider !== true ? null : ( + + )} +
+ ); +} diff --git a/client/extensions/tga-sign-off/src/components/SignOffRequestDetails.tsx b/client/extensions/tga-sign-off/src/components/SignOffRequestDetails.tsx new file mode 100644 index 0000000..d541aa6 --- /dev/null +++ b/client/extensions/tga-sign-off/src/components/SignOffRequestDetails.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; + +import {IUser} from 'superdesk-api'; +import {IPublishSignOff} from '../interfaces'; +import {superdesk} from '../superdesk'; + +interface IProps { + publishSignOff: IPublishSignOff; + user: IUser; +} + +export function SignOffRequestDetails(props: IProps) { + const {gettext, formatDateTime, longFormatDateTime} = superdesk.localization; + + return ( +
+ {gettext('Request last sent')}  + +  {gettext('by')} {props.user.display_name} +
+ ); +} diff --git a/client/extensions/tga-sign-off/src/components/UserDetail.tsx b/client/extensions/tga-sign-off/src/components/UserDetail.tsx new file mode 100644 index 0000000..1cfbc5f --- /dev/null +++ b/client/extensions/tga-sign-off/src/components/UserDetail.tsx @@ -0,0 +1,26 @@ +import * as React from 'react'; + +import {IUser} from 'superdesk-api'; +import {superdesk} from "../superdesk"; + + +interface IProps { + user: IUser; + label: string; +} + +export function UserDetail(props: IProps) { + const {UserAvatar} = superdesk.components; + + return ( + +
+ +
+
+ + {props.user.display_name} +
+
+ ); +} diff --git a/client/extensions/tga-sign-off/src/components/details.tsx b/client/extensions/tga-sign-off/src/components/details.tsx deleted file mode 100644 index b71fae0..0000000 --- a/client/extensions/tga-sign-off/src/components/details.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import * as React from 'react'; - -import {IUser} from 'superdesk-api'; -import {IUserSignOff} from '../interfaces'; -import {superdesk} from '../superdesk'; - -interface IProps { - signOff?: IUserSignOff | null; -} - -interface IState { - user?: IUser; -} - -export class SignOffDetails extends React.Component { - constructor(props: IProps) { - super(props); - - this.state = {user: undefined}; - } - - componentDidMount() { - this.loadUser(); - } - - componentDidUpdate(prevProps: Readonly) { - if (prevProps.signOff?.user_id !== this.props.signOff?.user_id) { - this.loadUser(); - } - } - - loadUser() { - if (this.props.signOff?.user_id == null) { - this.setState({user: undefined}); - } else { - const {getUsersByIds} = superdesk.entities.users; - - getUsersByIds([this.props.signOff.user_id]).then((users) => { - this.setState({user: users[0]}); - }) - } - } - - render() { - const {UserAvatar} = superdesk.components; - const {formatDateTime} = superdesk.localization; - - return this.state.user == null ? null : ( - -
- -
-
- - {this.state.user.display_name} -
- {this.props.signOff?.sign_date == null ? null : ( -
- - {formatDateTime(new Date(this.props.signOff.sign_date))} -
- )} -
- ); - } -} diff --git a/client/extensions/tga-sign-off/src/components/fields/editor.tsx b/client/extensions/tga-sign-off/src/components/fields/editor.tsx index dc83728..4184112 100644 --- a/client/extensions/tga-sign-off/src/components/fields/editor.tsx +++ b/client/extensions/tga-sign-off/src/components/fields/editor.tsx @@ -1,110 +1,214 @@ import * as React from 'react'; -import {IEditorProps} from '../../interfaces'; +import {IUser} from 'superdesk-api'; +import {IEditorProps, IUserSignOff, IPublishSignOff} from '../../interfaces'; import {superdesk} from '../../superdesk'; -import {hasUserSignedOff} from '../../utils'; +import {hasUserSignedOff, getListAuthorIds, loadUsersFromPublishSignOff, getSignOffDetails} from '../../utils'; -import {Button, ButtonGroup} from 'superdesk-ui-framework/react'; -import {SignOffDetails} from '../details'; +import {Button, ToggleBox} from 'superdesk-ui-framework/react'; +import {SignOffListItem} from '../SignOffListItem'; +import {SignOffRequestDetails} from '../SignOffRequestDetails'; -export class UserSignOffField extends React.Component { +interface IState { + users: {[userId: string]: IUser}; +} + +export class UserSignOffField extends React.Component { constructor(props: IEditorProps) { super(props); + this.state = {users: {}}; + this.removeSignOff = this.removeSignOff.bind(this); - this.sendSignOff = this.sendSignOff.bind(this); } - sendSignOff() { - const authorIds = (this.props.item.authors ?? []).map((author) => author.parent); + componentDidMount() { + this.reloadUsers(); + } + + componentDidUpdate() { + const {signOffIds, unsentAuthorIds} = getSignOffDetails(this.props.item, this.state.users); + const userIds = signOffIds.concat(unsentAuthorIds); + let reloadUsers = false; + + for (let i = 0; i < userIds.length; i++) { + if (this.state.users[userIds[i]] == null) { + reloadUsers = true; + break; + } + } + + if (reloadUsers) { + this.reloadUsers(); + } + } + + reloadUsers() { + loadUsersFromPublishSignOff(this.props.item).then((users) => { + this.setState({users: users}); + }); + } + + sendSignOff(authorIds?: Array) { + const {notify} = superdesk.ui; + const {gettext} = superdesk.localization; + const {signOffIds} = getSignOffDetails(this.props.item, this.state.users); + + if (authorIds == null) { + authorIds = getListAuthorIds(this.props.item) + .filter((authorId) => !signOffIds.includes(authorId)); + } if (authorIds.length === 0) { - superdesk.ui.notify.error( - superdesk.localization.gettext('Unable to send email(s), list of authors is empty!') + notify.error( + signOffIds.length === 0 ? + gettext('Unable to send email(s), list of authors is empty!') : + gettext('All authors have already signed off.') ); return; } superdesk.httpRequestJsonLocal({ - method: "POST", - path: "/sign_off_request", + method: 'POST', + path: '/sign_off_request', payload: { item_id: this.props.item._id, authors: authorIds, } + }).then(() => { + notify.success(gettext('Sign off request email(s) sent')); + }, (error) => { + notify.error(gettext('Failed to send sign off request email(s). {{ error }}', error)); }); } - removeSignOff() { - const {confirm} = superdesk.ui; + removeSignOff(sign_off_data: IUserSignOff) { + const {confirm, notify} = superdesk.ui; const {gettext} = superdesk.localization; + const publishSignOff: IPublishSignOff | undefined = this.props.item.extra?.publish_sign_off; - confirm(gettext('Are you sure you want to remove this publishing sign off?'), gettext('Remove publishing sign off')) - .then((response) => { - if (response) { - superdesk.entities.article.patch(this.props.item, { - extra: { - ...(this.props.item.extra ?? {}), - publish_sign_off: {}, - } - }); - } - }) + if (publishSignOff == null) { + notify.error(gettext('Unable to remove sign off, no sign offs found')); + return; + } + + confirm( + gettext('Are you sure you want to remove this publishing sign off?'), + gettext('Remove publishing sign off') + ).then((response) => { + if (response) { + superdesk.httpRequestVoidLocal({ + method: 'DELETE', + path: `/sign_off_request/${this.props.item._id}/${sign_off_data.user_id}`, + }).then(() => { + notify.success(gettext('Publishing sign off removed')); + }).catch((error: string) => { + notify.error(gettext('Failed to remove sign off. {{ error }}', {error})) + }); + } + }); } render() { - const {gettext} = superdesk.localization; - const {getCurrentUserId} = superdesk.session; - const sign_off_data = this.props.item.extra?.publish_sign_off ?? {}; - const isSameUser = getCurrentUserId() === sign_off_data?.user_id; + const {gettext,} = superdesk.localization; + const { + publishSignOff, + unsentAuthorIds, + pendingReviews, + requestUser, + } = getSignOffDetails(this.props.item, this.state.users); return (
-
- - {!hasUserSignedOff(sign_off_data) ? ( + {hasUserSignedOff(this.props.item) === true || this.props.readOnly ? null : ( +
- {(sign_off_data.funding_source?.length ?? 0) > 0 && ( -
- - {sign_off_data.funding_source}
)} - {(sign_off_data.affiliation?.length ?? 0) > 0 && ( -
- - {sign_off_data.affiliation} -
+ {requestUser == null || publishSignOff?.request_sent == null ? null : ( + + )} + + {publishSignOff?.sign_offs == null || publishSignOff.sign_offs.length === 0 ? null : ( + + {publishSignOff.sign_offs.map((sign_off_data, index) => ( + this.state.users[sign_off_data.user_id] == null ? null : ( + + ) + ))} + + )} + + {(pendingReviews.length + unsentAuthorIds.length) === 0 ? null : ( + + {pendingReviews.map((pendingReview) => ( + this.state.users[pendingReview.user_id] == null ? null : ( + + ) + ))} + + {unsentAuthorIds.map((authorId) => ( + this.state.users[authorId] == null ? null : ( + + ) + ))} + )}
); diff --git a/client/extensions/tga-sign-off/src/components/fields/preview.tsx b/client/extensions/tga-sign-off/src/components/fields/preview.tsx index ba6f1da..9207327 100644 --- a/client/extensions/tga-sign-off/src/components/fields/preview.tsx +++ b/client/extensions/tga-sign-off/src/components/fields/preview.tsx @@ -1,51 +1,132 @@ import * as React from 'react'; -import {IPreviewComponentProps} from 'superdesk-api'; +import {IPreviewComponentProps, IUser} from 'superdesk-api'; import {IUserSignOff} from '../../interfaces'; import {superdesk} from '../../superdesk'; -import {hasUserSignedOff} from '../../utils'; +import {loadUsersFromPublishSignOff, getSignOffDetails} from '../../utils'; import {IconLabel, ToggleBox} from 'superdesk-ui-framework/react'; -import {SignOffDetails} from '../details'; +import {SignOffListItem} from '../SignOffListItem'; +import {SignOffRequestDetails} from '../SignOffRequestDetails'; type IProps = IPreviewComponentProps; +type ISignOffState = 'completed' | 'partially' | 'none'; + +function getSignOffStateLabel(state: ISignOffState): string { + const {gettext} = superdesk.localization; + + switch (state) { + case 'completed': + return gettext('Signed Off'); + case 'partially': + return gettext('Partially Signed Off'); + case 'none': + return gettext('Not Signed Off'); + } +} + +interface IState { + users: {[userId: string]: IUser}; +} + +export class UserSignOffPreview extends React.PureComponent { + constructor(props: IProps) { + super(props); + + this.state = {users: {}}; + } + + componentDidMount() { + loadUsersFromPublishSignOff(this.props.item).then((users) => { + this.setState({users: users}); + }); + } -export class UserSignOffPreview extends React.PureComponent { render() { const {gettext} = superdesk.localization; - const sign_off_data = this.props.item.extra?.publish_sign_off; - const isSignedOff = hasUserSignedOff(sign_off_data); + const { + publishSignOff, + unsentAuthorIds, + pendingReviews, + requestUser, + } = getSignOffDetails(this.props.item, this.state.users); + + let signOffState: ISignOffState = 'none'; + + if (publishSignOff != null) { + signOffState = (unsentAuthorIds.length + pendingReviews.length) === 0 ? 'completed' : 'partially'; + } return (
- - + + {requestUser == null || publishSignOff?.request_sent == null ? null : ( + -
- -
- {!sign_off_data?.funding_source?.length ? null : ( -
- - {sign_off_data.funding_source} -
- )} - {!sign_off_data?.affiliation?.length ? null : ( -
- - {sign_off_data.affiliation} -
- )} -
+ )} + + {publishSignOff?.sign_offs == null || publishSignOff.sign_offs.length === 0 ? null : ( + + {publishSignOff.sign_offs.map((signOffData, index) => ( + this.state.users[signOffData.user_id] == null ? null : ( + + ) + ))} + + )} + + {(pendingReviews.length + unsentAuthorIds.length) === 0 ? null : ( + + {pendingReviews.map((pendingReview) => ( + this.state.users[pendingReview.user_id] == null ? null : ( + + ) + ))} + {unsentAuthorIds.map((authorId) => ( + this.state.users[authorId] == null ? null : ( + + ) + ))} + + )}
); } diff --git a/client/extensions/tga-sign-off/src/components/fields/template.tsx b/client/extensions/tga-sign-off/src/components/fields/template.tsx index 83b519d..e1b7a90 100644 --- a/client/extensions/tga-sign-off/src/components/fields/template.tsx +++ b/client/extensions/tga-sign-off/src/components/fields/template.tsx @@ -4,7 +4,6 @@ import {IEditorProps} from '../../interfaces'; import {superdesk} from '../../superdesk'; import {Button} from 'superdesk-ui-framework/react'; -import {SignOffDetails} from '../details'; export class UserSignOffTemplate extends React.PureComponent { render() { @@ -13,11 +12,10 @@ export class UserSignOffTemplate extends React.PureComponent { return (
-