diff --git a/assets/interfaces/common.ts b/assets/interfaces/common.ts index 85e6858ff..c9c2140a2 100644 --- a/assets/interfaces/common.ts +++ b/assets/interfaces/common.ts @@ -1,3 +1,7 @@ +import {IUser} from './user'; +import {IArticle} from './content'; +import {IAgendaItem} from './agenda'; + export type TDatetime = string; // ISO8601 format export interface IFilterGroup { @@ -25,3 +29,27 @@ export interface ICountry { } export type IListConfig = {[key: string]: string | number | boolean}; + +interface IBaseAction { + _id: string; + id: string; + name: string; + shortcut: boolean; + icon: string; + tooltip: string; + multi: boolean; + visited?(user: IUser['_id'], item: IArticle | IAgendaItem): void; + when?(props: any, item: IArticle | IAgendaItem): boolean; +} + +interface ISingleItemAction extends IBaseAction { + multi: false; + action(item: IArticle | IAgendaItem, group: string, plan: IAgendaItem): void; +} + +interface IMultiItemAction extends IBaseAction { + multi: true; + action(items: Array): void; +} + +export type IItemAction = ISingleItemAction | IMultiItemAction; diff --git a/assets/interfaces/content.ts b/assets/interfaces/content.ts index 698bccab7..be7ebdd94 100644 --- a/assets/interfaces/content.ts +++ b/assets/interfaces/content.ts @@ -13,7 +13,9 @@ export interface IArticle { _id: string; guid: string; type: IContentType; - associations: {[key: string]: IArticle}; + ancestors?: Array; + nextversion?: IArticle['_id']; + associations?: {[key: string]: IArticle}; renditions?: {[key: string]: IRendition}; slugline: string; headline: string; @@ -21,4 +23,6 @@ export interface IArticle { source: string; versioncreated: string; extra?: {[key: string]: any}; + es_highlight?: {[field: string]: Array} + deleted?: boolean; // Used only in the front-end, populated by wire/reducer } diff --git a/assets/interfaces/index.ts b/assets/interfaces/index.ts index ecb07c9f8..257f3960e 100644 --- a/assets/interfaces/index.ts +++ b/assets/interfaces/index.ts @@ -1,4 +1,4 @@ -export {TDatetime, IFilterGroup, ISection, ICountry, IListConfig} from './common'; +export {TDatetime, IFilterGroup, ISection, ICountry, IListConfig, IItemAction} from './common'; export {ICompany, ICompanyType, IAuthProvider, IService} from './company'; export {IClientConfig} from './config'; export {INavigation} from './navigation'; diff --git a/assets/wire/actions.ts b/assets/wire/actions.ts index cffc1ce62..6d03a1c29 100644 --- a/assets/wire/actions.ts +++ b/assets/wire/actions.ts @@ -1,5 +1,6 @@ import {get, isEmpty} from 'lodash'; +import {IArticle} from 'interfaces'; import server from 'server'; import analytics from 'analytics'; @@ -387,11 +388,9 @@ export function removeBookmarks(items: any) { * @param {Object} item * @return {Promise} */ -export function fetchVersions(item: any) { - return () => server.get(`/wire/${item._id}/versions`) - .then((data: any) => { - return data._items; - }); +export function fetchVersions(item: IArticle): Promise> { + return server.get(`/wire/${item._id}/versions`) + .then((data: {_items: Array}) => (data._items)); } /** @@ -524,14 +523,12 @@ export function fetchNewItems() { .then((response: any) => dispatch(setNewItems(response))); } -export function fetchNext(item: any) { - return () => { - if (!item.nextversion) { - return Promise.reject(); - } +export function fetchNext(item: IArticle): Promise { + if (!item.nextversion) { + return Promise.reject(); + } - return server.get(`/wire/${item.nextversion}?format=json`); - }; + return server.get(`/wire/${item.nextversion}?format=json`); } export const TOGGLE_FILTER = 'TOGGLE_FILTER'; diff --git a/assets/wire/components/ListItemNextVersion.tsx b/assets/wire/components/ListItemNextVersion.tsx index 1bb742966..6a7ef16bb 100644 --- a/assets/wire/components/ListItemNextVersion.tsx +++ b/assets/wire/components/ListItemNextVersion.tsx @@ -1,36 +1,59 @@ import React from 'react'; -import PropTypes from 'prop-types'; import {connect} from 'react-redux'; +import {IArticle} from 'interfaces'; import {gettext} from 'utils'; import {getVersionsLabelText} from 'wire/utils'; import ItemVersion from './ItemVersion'; import {fetchNext, openItem} from '../actions'; -class ListItemNextVersion extends React.Component { - static propTypes: any; - constructor(props: any) { +interface IOwnProps { + item: IArticle; + displayConfig: {[field: string]: boolean}; +} + +interface IState { + next: IArticle | null; +} + +interface IDispatchProps { + openItem(item: IArticle): void; +} + +type IProps = IDispatchProps & IOwnProps; + +const mapDispatchToProps = (dispatch: any) => ({ + openItem: (item: IArticle) => dispatch(openItem(item)), +}); + + +class ListItemNextVersion extends React.Component { + constructor(props: IProps) { super(props); this.state = {next: null}; this.open = this.open.bind(this); - this.fetch(props); + this.fetch(); } - componentWillReceiveProps(nextProps: any) { - if (nextProps.item.nextversion !== this.props.item.nextversion) { - this.fetch(nextProps); + componentDidUpdate(prevProps: Readonly) { + if (prevProps.item.nextversion !== this.props.item.nextversion) { + this.fetch(); } } - fetch(props: any) { - props.dispatch(fetchNext(props.item)) - .then((next: any) => this.setState({next})) + fetch() { + fetchNext(this.props.item) + .then((next) => this.setState({next})) .catch(() => this.setState({next: null})); } - open(version: any, event: any) { + open(version: IArticle, event: React.MouseEvent) { + if (this.state.next == null) { + return; + } + event.stopPropagation(); - this.props.dispatch(openItem(this.state.next)); + this.props.openItem(this.state.next); } render() { @@ -58,12 +81,6 @@ class ListItemNextVersion extends React.Component { } } -ListItemNextVersion.propTypes = { - item: PropTypes.object.isRequired, - dispatch: PropTypes.func.isRequired, - displayConfig: PropTypes.object, -}; - -const component: React.ComponentType = connect()(ListItemNextVersion); +const component = connect(null, mapDispatchToProps)(ListItemNextVersion); export default component; diff --git a/assets/wire/components/ListItemPreviousVersions.tsx b/assets/wire/components/ListItemPreviousVersions.tsx index a6d46192a..fd63eb5bc 100644 --- a/assets/wire/components/ListItemPreviousVersions.tsx +++ b/assets/wire/components/ListItemPreviousVersions.tsx @@ -1,41 +1,64 @@ import React from 'react'; -import PropTypes from 'prop-types'; import {connect} from 'react-redux'; +import {IArticle, IListConfig} from 'interfaces'; import {gettext} from 'utils'; import {getVersionsLabelText} from 'wire/utils'; import {fetchVersions, openItem} from '../actions'; import ItemVersion from './ItemVersion'; +interface IOwnProps { + item: IArticle; + isPreview: boolean; + inputId?: string; + displayConfig?: IListConfig; + matchedIds?: Array +} + +interface IState { + versions: Array; + loading: boolean; + error: boolean; +} + +interface IDispatchProps { + openItem(item: IArticle): void; +} + +type IProps = IDispatchProps & IOwnProps; + +const mapDispatchToProps = (dispatch: any) => ({ + openItem: (item: IArticle) => dispatch(openItem(item)), +}); + -class ListItemPreviousVersions extends React.Component { +class ListItemPreviousVersions extends React.Component { baseClass: string; - static propTypes: any; - static defaultProps: any; + static defaultProps = {matchedIds: []}; - constructor(props: any) { + constructor(props: IProps) { super(props); this.state = {versions: [], loading: true, error: false}; this.baseClass = this.props.isPreview ? 'wire-column__preview' : 'wire-articles'; this.open = this.open.bind(this); - this.fetch(props); + this.fetch(); } - componentWillReceiveProps(nextProps: any) { - if (nextProps.item._id !== this.props.item._id) { - this.fetch(nextProps); + componentDidUpdate(prevProps: Readonly) { + if (prevProps.item._id !== this.props.item._id) { + this.fetch(); } } - open(version: any, event: any) { + open(version: IArticle, event: React.MouseEvent) { event.stopPropagation(); - this.props.dispatch(openItem(version)); + this.props.openItem(version); } - fetch(props: any) { - props.dispatch(fetchVersions(props.item)) - .then((versions: any) => this.setState({versions, loading: false})) + fetch() { + fetchVersions(this.props.item) + .then((versions) => this.setState({versions, loading: false})) .catch(() => this.setState({error: true})); } @@ -75,22 +98,6 @@ class ListItemPreviousVersions extends React.Component { } } -ListItemPreviousVersions.propTypes = { - item: PropTypes.shape({ - _id: PropTypes.string, - ancestors: PropTypes.array, - }).isRequired, - isPreview: PropTypes.bool.isRequired, - dispatch: PropTypes.func, - inputId: PropTypes.string, - displayConfig: PropTypes.object, - matchedIds: PropTypes.array, -}; - -ListItemPreviousVersions.defaultProps = { - matchedIds: [], -}; - -const component: React.ComponentType = connect()(ListItemPreviousVersions); +const component = connect(null, mapDispatchToProps)(ListItemPreviousVersions); export default component; diff --git a/assets/wire/components/WireListItem.tsx b/assets/wire/components/WireListItem.tsx index 33942b174..60623e3b4 100644 --- a/assets/wire/components/WireListItem.tsx +++ b/assets/wire/components/WireListItem.tsx @@ -1,19 +1,15 @@ import React from 'react'; -import PropTypes from 'prop-types'; import classNames from 'classnames'; -import {get} from 'lodash'; -import {IArticle} from 'interfaces'; +import {IArticle, IItemAction, IUser, IListConfig, IAgendaItem} from 'interfaces'; import { gettext, wordCount, characterCount, LIST_ANIMATIONS, getSlugline, - getConfig, } from 'utils'; import { - getFeatureMedia, getImageForList, showItemVersions, shortText, @@ -33,13 +29,16 @@ import {Embargo} from './fields/Embargo'; import {UrgencyItemBorder, UrgencyLabel} from './fields/UrgencyLabel'; import {FieldComponents} from './fields'; -export const DISPLAY_WORD_COUNT = getConfig('display_word_count'); -export const DISPLAY_CHAR_COUNT = getConfig('display_char_count'); - const DEFAULT_META_FIELDS = ['source', 'charcount', 'versioncreated']; const DEFAULT_COMPACT_META_FIELDS = ['versioncreated']; -function getShowVersionText(item: IArticle, isExpanded: any, itemCount: any, matchCount: any, isExtended: any) { +function getShowVersionText( + item: IArticle, + isExpanded: boolean, + itemCount: number, + matchCount: number, + isExtended: boolean +): string { const versionLabelText = getVersionsLabelText(item, itemCount > 1); if (isExpanded) { @@ -79,13 +78,39 @@ function getShowVersionText(item: IArticle, isExpanded: any, itemCount: any, mat } } -class WireListItem extends React.Component { - static propTypes: any; - static defaultProps: any; - wordCount: any; - characterCount: any; - dom: any; - constructor(props: any) { +interface IProps { + item: IArticle; + isActive: boolean; + isSelected: boolean; + isRead: boolean; + showActions: boolean; + isExtended: boolean; + matchedIds: Array; + isSearchFiltered: boolean; + showShortcutActionIcons: boolean; + user: IUser['_id']; + context: string; + contextName: string; + listConfig: IListConfig; + filterGroupLabels: {[field: string]: string}; + actions: Array; + onClick(item: IArticle): void; + onDoubleClick(item: IArticle): void; + onActionList(event: React.MouseEvent, item: IArticle, group?: string, plan?: IAgendaItem): void; + toggleSelected(): void; +} + +interface IState { + previousVersions: boolean; +} + +class WireListItem extends React.Component { + static defaultProps = {matchedIds: []}; + wordCount: number; + characterCount: number; + dom: {article: HTMLElement | null}; + + constructor(props: IProps) { super(props); this.wordCount = wordCount(props.item); this.characterCount = characterCount(props.item); @@ -96,7 +121,7 @@ class WireListItem extends React.Component { this.dom = {article: null}; } - onKeyDown(event: any) { + onKeyDown(event: React.KeyboardEvent) { switch (event.key) { case ' ': // on space toggle selected item this.props.toggleSelected(); @@ -109,7 +134,7 @@ class WireListItem extends React.Component { event.preventDefault(); } - togglePreviousVersions(event: any) { + togglePreviousVersions(event: React.MouseEvent) { event.stopPropagation(); this.setState({previousVersions: !this.state.previousVersions}); } @@ -120,7 +145,7 @@ class WireListItem extends React.Component { } } - stopPropagation(event: any) { + stopPropagation(event: React.MouseEvent) { event.stopPropagation(); } @@ -133,7 +158,7 @@ class WireListItem extends React.Component { listConfig, } = this.props; - if (get(this.props, 'item.deleted')) { + if (this.props.item.deleted === true) { return ( { {getShowVersionText( this.props.item, this.state.previousVersions, - item.ancestors.length, + item.ancestors?.length ?? 0, matchedAncestors.length, isExtended )} @@ -362,35 +387,4 @@ class WireListItem extends React.Component { } } -WireListItem.propTypes = { - item: PropTypes.object.isRequired, - isActive: PropTypes.bool.isRequired, - isSelected: PropTypes.bool.isRequired, - isRead: PropTypes.bool.isRequired, - onClick: PropTypes.func.isRequired, - onDoubleClick: PropTypes.func.isRequired, - onActionList: PropTypes.func.isRequired, - showActions: PropTypes.bool.isRequired, - toggleSelected: PropTypes.func.isRequired, - actions: PropTypes.arrayOf( - PropTypes.shape({ - name: PropTypes.string, - action: PropTypes.func, - }) - ), - isExtended: PropTypes.bool.isRequired, - user: PropTypes.string, - context: PropTypes.string, - contextName: PropTypes.string, - listConfig: PropTypes.object, - matchedIds: PropTypes.array, - isSearchFiltered: PropTypes.bool, - showShortcutActionIcons: PropTypes.bool, - filterGroupLabels: PropTypes.object, -}; - -WireListItem.defaultProps = { - matchedIds: [], -}; - export default WireListItem; diff --git a/assets/wire/components/fields/PreviousVersions.tsx b/assets/wire/components/fields/PreviousVersions.tsx index feecd425c..e7064dca0 100644 --- a/assets/wire/components/fields/PreviousVersions.tsx +++ b/assets/wire/components/fields/PreviousVersions.tsx @@ -1,20 +1,26 @@ import React from 'react'; -import PropTypes from 'prop-types'; +import {IArticle} from 'interfaces'; import {gettext} from 'utils'; import {getVersionsLabelText} from 'wire/utils'; -export function PreviousVersions ({item, isItemDetail, inputRef}: any) { +interface IProps { + item: IArticle; + isItemDetail: boolean; + inputRef: string; +} + +export function PreviousVersions({item, isItemDetail, inputRef}: IProps) { if (isItemDetail) { return null; } - const numVersions = (item.ancestors ?? []).length; - const versionLabelText = getVersionsLabelText(item, numVersions === 0 || numVersions > 1); const onClick = () => { const previousVersions = document.getElementById(inputRef); previousVersions && previousVersions.scrollIntoView(); }; + const numVersions = item.ancestors?.length ?? 0; + const versionLabelText = getVersionsLabelText(item, numVersions === 0 || numVersions > 1); return ( @@ -27,10 +33,3 @@ export function PreviousVersions ({item, isItemDetail, inputRef}: any) { ); } - - -PreviousVersions.propTypes = { - isItemDetail: PropTypes.bool, - item: PropTypes.object, - inputRef: PropTypes.string, -}; diff --git a/assets/wire/tests/actions.spec.ts b/assets/wire/tests/actions.spec.ts index 19c400acc..4ffde49de 100644 --- a/assets/wire/tests/actions.spec.ts +++ b/assets/wire/tests/actions.spec.ts @@ -2,6 +2,7 @@ import thunk from 'redux-thunk'; import fetchMock from 'fetch-mock'; import {createStore, applyMiddleware} from 'redux'; +import {IArticle} from 'interfaces'; import server from 'server'; import wireApp from '../reducers'; @@ -16,6 +17,17 @@ import { } from 'search/actions'; import {initData} from '../actions'; +const testArticle: IArticle = { + _id: 'foo', + guid: 'foo', + type: 'text', + associations: {}, + slugline: 'test-article', + headline: 'My test article', + source: 'sofab', + versioncreated: '2023-06-27T11:07:17+0000', +}; + describe('wire actions', () => { let store: any; const response: any = { @@ -114,7 +126,7 @@ describe('wire actions', () => { it('can open item', () => { fetchMock.post('/history/new', {}); - store.dispatch(actions.openItem({_id: 'foo'})); + store.dispatch(actions.openItem(testArticle)); expect(store.getState().openItem._id).toBe('foo'); expect(fetchMock.called('/history/new')).toBeTruthy(); fetchMock.reset(); @@ -127,7 +139,7 @@ describe('wire actions', () => { expect(action).toEqual('open'); expect(section).toEqual('wire'); }); - store.dispatch(actions.openItem({_id: 'foo'})); + store.dispatch(actions.openItem(testArticle)); expect(store.getState().openItem._id).toBe('foo'); fetchMock.reset(); }); @@ -147,7 +159,7 @@ describe('wire actions', () => { const item: any = {_id: 'foo'}; fetchMock.get(`/wire/${item._id}/versions`, {_items: [{_id: 'bar'}, {_id: 'baz'}]}); - return store.dispatch(actions.fetchVersions(item)) + return actions.fetchVersions(item) .then((versions: any) => { expect(versions.length).toBe(2); expect(versions[0]._id).toBe('bar'); @@ -155,18 +167,18 @@ describe('wire actions', () => { }); it('can fetch next item version', () => { - const item: any = {nextversion: 'bar'}; const next: any = {}; fetchMock.get('/wire/bar?format=json', next); - return store.dispatch(actions.fetchNext(item)) - .then((_next: any) => { + return actions.fetchNext({...testArticle, nextversion: 'bar'}) + .then((_next) => { + console.log(_next); expect(_next).toEqual(next); }); }); it('can reject if item has no next version', () => { - return store.dispatch(actions.fetchNext({})).then(() => { + return actions.fetchNext(testArticle).then(() => { expect(true).toBe(false); // this should not be called }, () => { expect(fetchMock.called()).toBeFalsy(); diff --git a/assets/wire/utils.ts b/assets/wire/utils.ts index ec42adeb8..83e87e18e 100644 --- a/assets/wire/utils.ts +++ b/assets/wire/utils.ts @@ -70,14 +70,14 @@ export function getOtherMedia(item: IArticle): Array | null { return null; } - return Object.keys(item.associations || {}) - .filter((key) => ( + return Object.entries(item.associations ?? {}) + .filter(([key, mediaItem]) => ( !key.startsWith('editor_') && key !== 'featuremedia' && - item.associations[key] != null && - ['video', 'audio'].includes(item.associations[key].type) + mediaItem != null && + ['video', 'audio'].includes(mediaItem.type) )) - .map((key) => item.associations[key]); + .map((args) => args[1]); } /**