diff --git a/scripts/apps/archive/views/single-item-preview.html b/scripts/apps/archive/views/single-item-preview.html
deleted file mode 100644
index 77d246aec9..0000000000
--- a/scripts/apps/archive/views/single-item-preview.html
+++ /dev/null
@@ -1,30 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
{{ item.headline }}
-
-
{{ item.slugline || item.headline }}
-
-
-
-
-
-
-
-
-
{{ item.headline }}
-
diff --git a/scripts/apps/authoring-bridge/authoring-api-common.ts b/scripts/apps/authoring-bridge/authoring-api-common.ts
new file mode 100644
index 0000000000..b55bf2a1bb
--- /dev/null
+++ b/scripts/apps/authoring-bridge/authoring-api-common.ts
@@ -0,0 +1,110 @@
+import {ITEM_STATE} from 'apps/archive/constants';
+import {runAfterUpdateEvent, runBeforeUpdateMiddlware} from 'apps/authoring/authoring/services/authoring-helpers';
+import {isArticleLockedInCurrentSession} from 'core/get-superdesk-api-implementation';
+import {assertNever} from 'core/helpers/typescript-helpers';
+import ng from 'core/services/ng';
+import {IUnsavedChangesActionWithSaving, showUnsavedChangesPrompt} from 'core/ui/components/prompt-for-unsaved-changes';
+import {IArticle} from 'superdesk-api';
+import {appConfig} from 'appConfig';
+
+export interface IAuthoringApiCommon {
+ saveBefore(current: IArticle, original: IArticle): Promise
;
+ saveAfter(current: IArticle, original: IArticle): void;
+ closeAuthoring(
+ original: IArticle,
+ hasUnsavedChanges: boolean,
+ save: () => Promise,
+ unlock: () => Promise,
+ cancelAutoSave: () => Promise,
+ doClose: () => void,
+ ): Promise;
+
+ /**
+ * Is only meant to be used when there are no unsaved changes
+ * and item is not locked.
+ */
+ closeAuthoringForce(): void;
+
+ /**
+ * We need to keep the steps separate because it needs to be called
+ * separately in Angular. When we remove Angular the closeAuthoring
+ * and closeAuthoringStep2 will be merged together.
+ */
+ closeAuthoringStep2(scope: any, rootScope: any): Promise;
+ checkShortcutButtonAvailability: (item: IArticle, dirty?: boolean, personal?: boolean) => boolean;
+}
+
+/**
+ * Immutable API that is used in both - angularjs and reactjs based authoring code.
+ */
+export const authoringApiCommon: IAuthoringApiCommon = {
+ checkShortcutButtonAvailability: (item: IArticle, dirty?: boolean, personal?: boolean): boolean => {
+ if (personal) {
+ return appConfig?.features?.publishFromPersonal && item.state !== 'draft';
+ }
+
+ return item.task && item.task.desk && item.state !== 'draft' || dirty;
+ },
+ saveBefore: (current, original) => {
+ return runBeforeUpdateMiddlware(current, original);
+ },
+ saveAfter: (current, original) => {
+ runAfterUpdateEvent(original, current);
+ },
+ closeAuthoringStep2: (scope: any, rootScope: any): Promise => {
+ return ng.get('authoring').close(
+ scope.item,
+ scope.origItem,
+ scope.save_enabled(),
+ () => {
+ ng.get('authoringWorkspace').close(true);
+ const itemId = scope.origItem._id;
+ const storedItemId = localStorage.getItem(`open-item-after-related-closed--${itemId}`);
+
+ rootScope.$broadcast('item:close', itemId);
+
+ /**
+ * If related item was just created and saved, open the original item
+ * that triggered the creation of this related item.
+ */
+ if (storedItemId != null) {
+ return ng.get('autosave').get({_id: storedItemId}).then((resulted) => {
+ ng.get('authoringWorkspace').open(resulted);
+ localStorage.removeItem(`open-item-after-related-closed--${itemId}`);
+ });
+ }
+ },
+ );
+ },
+ closeAuthoring: (original: IArticle, hasUnsavedChanges, save, unlock, cancelAutoSave, doClose) => {
+ if (!isArticleLockedInCurrentSession(original)) {
+ return Promise.resolve().then(() => doClose());
+ }
+
+ if (hasUnsavedChanges && (original.state !== ITEM_STATE.PUBLISHED && original.state !== ITEM_STATE.CORRECTED)) {
+ return showUnsavedChangesPrompt(hasUnsavedChanges).then(({action, closePromptFn}) => {
+ const unlockAndClose = () => unlock().then(() => {
+ closePromptFn();
+ doClose();
+ });
+
+ if (action === IUnsavedChangesActionWithSaving.cancelAction) {
+ return closePromptFn();
+ } else if (action === IUnsavedChangesActionWithSaving.discardChanges) {
+ return cancelAutoSave().then(() => unlockAndClose());
+ } else if (action === IUnsavedChangesActionWithSaving.save) {
+ return save().then(() => unlockAndClose());
+ } else {
+ assertNever(action);
+ }
+ });
+ } else {
+ return unlock().then(() => doClose());
+ }
+ },
+ closeAuthoringForce: () => {
+ ng.get('superdeskFlags').flags.hideMonitoring = false;
+
+ ng.get('authoringWorkspace').close();
+ },
+};
diff --git a/scripts/apps/authoring-bridge/receive-patches.ts b/scripts/apps/authoring-bridge/receive-patches.ts
new file mode 100644
index 0000000000..73e6c3f066
--- /dev/null
+++ b/scripts/apps/authoring-bridge/receive-patches.ts
@@ -0,0 +1,33 @@
+import {registerInternalExtension, unregisterInternalExtension} from 'core/helpers/register-internal-extension';
+import {IArticle} from 'superdesk-api';
+
+const receivingPatchesInternalExtension = 'receiving-patches-internal-extension';
+
+export function registerToReceivePatches(articleId: IArticle['_id'], applyPatch: (patch: Partial) => void) {
+ registerInternalExtension(receivingPatchesInternalExtension, {
+ contributions: {
+ entities: {
+ article: {
+ onPatchBefore: (id, patch, dangerousOptions) => {
+ if (
+ articleId === id
+ && dangerousOptions?.patchDirectlyAndOverwriteAuthoringValues !== true
+ ) {
+ applyPatch(patch);
+ console.info('Article is locked and can\'t be updated via HTTP directly.'
+ + 'The updates will be added to existing diff in article-edit view instead.');
+
+ return Promise.reject();
+ } else {
+ return Promise.resolve(patch);
+ }
+ },
+ },
+ },
+ },
+ });
+}
+
+export function unregisterFromReceivingPatches() {
+ unregisterInternalExtension(receivingPatchesInternalExtension);
+}
diff --git a/scripts/apps/authoring-react/README.md b/scripts/apps/authoring-react/README.md
new file mode 100644
index 0000000000..ba1a65e142
--- /dev/null
+++ b/scripts/apps/authoring-react/README.md
@@ -0,0 +1,35 @@
+# Authoring react
+
+## Field type
+
+Field type is an object that implements `ICustomFieldType`. There can be many text fields in an article, like headline, abstract, custom-text-1 etc. but they would all use the same code that a field type provides.
+
+## Important principles
+
+There should be no special behavior depending on field ID. It should always be possible to create one more field of the same type and get the same behavior.
+
+### Field data formats - **storage** and **operational**
+
+Storage format refers to field data that is stored in `IArticle` when it's received from the API. Operational format is the one being used in runtime. In most cases it will be the same.
+
+For example if we had a plain text input, we would use a string for storage, and also a string as operational format, since that's what ` ` uses.
+
+A different operational format is required, when working with an editor that uses a more complex format that requires custom code to serialize it for storage. For example, draft-js uses `EditorState` as an operational format and requires running additional code in order to serialize it to storage format - `RawDraftContentState`.
+
+## Storage location of field data
+
+The data of all fields is stored in `IArticle['extra']` by default. Custom storage location may be specified in field type(`ICustomFieldType`) or field adapter. If a function in field adapter is defined, the one in field type will be ignored.
+
+## Field adapters
+
+Field adapters are needed for two purposes.
+
+1. To allow setting up built-in fields, that are not present in the database as custom fields.
+2. To allow storing field data in other location that `IArticle['extra']`.
+
+The code is simplified by using adapters, since there is only one place where storage details are defined, and the rest of the authoring code doesn't know about it.
+
+
+## Article adapter
+
+React based authoring isn't using "extra>" prefix for custom fields in `IArticle['fields_meta']`, because there can't be multiple fields with the same ID. I didn't know this when originally implementing `IArticle['fields_meta']`. An upgrade script may be written and prefix dropped completely when angular based authoring code is removed. In the meanwhile, the adapter makes it so the prefix isn't needed in authoring-react code, but is outputted when saving, to make the output compatible with angular based authoring.
diff --git a/scripts/apps/authoring-react/article-adapter.ts b/scripts/apps/authoring-react/article-adapter.ts
new file mode 100644
index 0000000000..ef77e716cc
--- /dev/null
+++ b/scripts/apps/authoring-react/article-adapter.ts
@@ -0,0 +1,77 @@
+import {IArticle} from 'superdesk-api';
+import {getCustomFieldVocabularies} from 'core/helpers/business-logic';
+import {IOldCustomFieldId} from './interfaces';
+
+interface IAuthoringReactArticleAdapter {
+ /**
+ * Remove changes done for authoring-react
+ */
+ fromAuthoringReact>(article: T): T;
+
+ /**
+ * Apply changes required for for authoring-react
+ */
+ toAuthoringReact>(article: T): T;
+}
+
+/**
+ * There are slight changes in data structure that AuthoringV2 uses.
+ *
+ * 1. Fields are generic in AuthoringV2. Field IDs are not used in business logic.
+ * Adapter moves some custom fields to {@link IArticle} root.
+ *
+ * 2. Angular based authoring adds prefixes fields in {@link IArticle.fields_meta} (only {@link IOldCustomFieldId})
+ * to prevent possible conflicts of field IDs. It seems though, that validation is in place
+ * to prevent duplicate IDs, thus prefixing was never necessary. Adapter removes the prefixes.
+ */
+export function getArticleAdapter(): IAuthoringReactArticleAdapter {
+ const customFieldVocabularies = getCustomFieldVocabularies();
+
+ const oldFormatCustomFieldIds: Set = new Set(
+ customFieldVocabularies
+ .filter((vocabulary) => vocabulary.field_type === 'text')
+ .map((vocabulary) => vocabulary._id),
+ );
+
+ const adapter: IAuthoringReactArticleAdapter = {
+ fromAuthoringReact: (_article) => {
+ // making a copy in order to do immutable updates
+ let article = {..._article};
+
+ // Add prefixes
+ for (const fieldId of Array.from(oldFormatCustomFieldIds)) {
+ const withPrefix = `extra>${fieldId}`;
+
+ if (article.fields_meta?.hasOwnProperty(fieldId)) {
+ article.fields_meta[withPrefix] = article.fields_meta[fieldId];
+
+ delete article.fields_meta[fieldId];
+ }
+ }
+
+ return article;
+ },
+ toAuthoringReact: (_article) => {
+ let article = {..._article}; // ensure immutability
+
+ if (_article.fields_meta != null) { // ensure immutability
+ article.fields_meta = {..._article.fields_meta};
+ }
+
+ // remove prefixes
+ for (const fieldId of Array.from(oldFormatCustomFieldIds)) {
+ const withPrefix = `extra>${fieldId}`;
+
+ if (article.fields_meta?.hasOwnProperty(withPrefix)) {
+ article.fields_meta[fieldId] = article.fields_meta[withPrefix];
+
+ delete article.fields_meta[withPrefix];
+ }
+ }
+
+ return article;
+ },
+ };
+
+ return adapter;
+}
diff --git a/scripts/apps/authoring-react/article-widgets/README.MD b/scripts/apps/authoring-react/article-widgets/README.MD
new file mode 100644
index 0000000000..88b21495e5
--- /dev/null
+++ b/scripts/apps/authoring-react/article-widgets/README.MD
@@ -0,0 +1,17 @@
+# About
+
+These widgets are react-only widgets that will appear in both - current and new versions of authoring when enabled.
+The goal is to re-implement existing angular widgets in react. We can run react code inside angularjs, but not vice-versa.
+When a widget is re-implemented in react, we can delete the angular code
+and enable the widget in `../manage-widget-registration.ts`.
+
+# Adding a widget
+
+* Add a file or folder per widget.
+* Export an object or a function returning the object implementing `IAuthoringSideWidget` interface.
+* Import it in `../manage-widget-registration.ts`
+* Use `AuthoringWidgetLayout` as a root widget component.
+
+To update the article that is being edited, call `sdApi.article.patch`.
+
+An example is available in ./demo-widget.tsx
diff --git a/scripts/apps/authoring-react/article-widgets/comments/index.tsx b/scripts/apps/authoring-react/article-widgets/comments/index.tsx
new file mode 100644
index 0000000000..396be6b2af
--- /dev/null
+++ b/scripts/apps/authoring-react/article-widgets/comments/index.tsx
@@ -0,0 +1,73 @@
+/* eslint-disable react/no-multi-comp */
+import React from 'react';
+import {IArticleSideWidget, IComment, IExtensionActivationResult, IRestApiResponse} from 'superdesk-api';
+import {gettext} from 'core/utils';
+import CommentsWidget from '../../generic-widgets/comments/CommentsWidget';
+import {httpRequestJsonLocal} from 'core/helpers/network';
+// Can't call `gettext` in the top level
+const getLabel = () => gettext('Comments');
+
+type IProps = React.ComponentProps<
+ IExtensionActivationResult['contributions']['authoringSideWidgets'][0]['component']
+>;
+
+class Component extends React.PureComponent {
+ render() {
+ return (
+ {
+ const itemId = this.props.article?._id;
+
+ if (itemId == null) {
+ return Promise.resolve([]);
+ }
+
+ const criteria = {
+ where: {
+ item: itemId,
+ },
+ embedded: {user: 1},
+ };
+
+ return httpRequestJsonLocal>({
+ method: 'GET',
+ path: '/item_comments',
+ urlParams: criteria,
+ }).then(({_items}) => _items);
+ }}
+ addComment={(text) => {
+ return httpRequestJsonLocal({
+ method: 'POST',
+ path: '/item_comments',
+ payload: {
+ item: this.props.article._id,
+ text: text,
+ },
+ });
+ }}
+ />
+ );
+ }
+}
+
+export function getCommentsWidget() {
+ const widget: IArticleSideWidget = {
+ _id: 'comments-widget',
+ label: getLabel(),
+ order: 3,
+ icon: 'chat',
+ component: Component,
+ isAllowed: (item) => item._type !== 'legal_archive',
+ };
+
+ return widget;
+}
diff --git a/scripts/apps/authoring-react/article-widgets/demo-widget.tsx b/scripts/apps/authoring-react/article-widgets/demo-widget.tsx
new file mode 100644
index 0000000000..6ed1ce9ed3
--- /dev/null
+++ b/scripts/apps/authoring-react/article-widgets/demo-widget.tsx
@@ -0,0 +1,58 @@
+import React from 'react';
+import {IArticleSideWidget, IArticle, IExtensionActivationResult} from 'superdesk-api';
+import {Button} from 'superdesk-ui-framework';
+import {sdApi} from 'api';
+import {gettext} from 'core/utils';
+import {AuthoringWidgetHeading} from 'apps/dashboard/widget-heading';
+import {AuthoringWidgetLayout} from 'apps/dashboard/widget-layout';
+
+// Can't call `gettext` in the top level
+const getLabel = () => gettext('Demo widget');
+
+type IProps = React.ComponentProps<
+ IExtensionActivationResult['contributions']['authoringSideWidgets'][0]['component']
+>;
+
+class DemoWidget extends React.PureComponent {
+ render() {
+ return (
+
+ )}
+ body={(
+
+ {
+ sdApi.article.patch(
+ this.props.article,
+ {slugline: (this.props.article.slugline ?? '') + '@'},
+ );
+ }}
+ size="small"
+ />
+
+ )}
+ footer={(
+ test footer
+ )}
+ />
+ );
+ }
+}
+
+export function getDemoWidget() {
+ const metadataWidget: IArticleSideWidget = {
+ _id: 'demo-widget',
+ label: getLabel(),
+ order: 2,
+ icon: 'info',
+ component: DemoWidget,
+ };
+
+ return metadataWidget;
+}
diff --git a/scripts/apps/authoring-react/article-widgets/find-and-replace.tsx b/scripts/apps/authoring-react/article-widgets/find-and-replace.tsx
new file mode 100644
index 0000000000..f744c6e3dc
--- /dev/null
+++ b/scripts/apps/authoring-react/article-widgets/find-and-replace.tsx
@@ -0,0 +1,176 @@
+import React from 'react';
+import {IArticleSideWidget, IArticle, IExtensionActivationResult} from 'superdesk-api';
+import {gettext} from 'core/utils';
+import {AuthoringWidgetHeading} from 'apps/dashboard/widget-heading';
+import {AuthoringWidgetLayout} from 'apps/dashboard/widget-layout';
+import {Input, Button, IconButton, Switch} from 'superdesk-ui-framework/react';
+import {dispatchEditorEvent} from '../authoring-react-editor-events';
+import {Spacer} from 'core/ui/components/Spacer';
+import {throttle} from 'lodash';
+
+// Can't call `gettext` in the top level
+const getLabel = () => gettext('Find and Replace');
+
+type IProps = React.ComponentProps<
+ IExtensionActivationResult['contributions']['authoringSideWidgets'][0]['component']
+>;
+
+interface IState {
+ findValue: string;
+ replaceValue: string;
+ caseSensitive: boolean;
+}
+
+/**
+ * Current implementation of find-replace only supports one field.
+ */
+export const editorId = 'body_html';
+
+class FindAndReplaceWidget extends React.PureComponent {
+ private scheduleHighlightingOfMatches: () => void;
+
+ constructor(props: IProps) {
+ super(props);
+
+ this.state = {
+ findValue: '',
+ replaceValue: '',
+ caseSensitive: false,
+ };
+
+ this.highlightMatches.bind(this);
+
+ this.scheduleHighlightingOfMatches = throttle(
+ this.highlightMatches,
+ 500,
+ {leading: false},
+ );
+ }
+
+ private highlightMatches() {
+ dispatchEditorEvent('find_and_replace__find', {
+ editorId,
+ text: this.state.findValue,
+ caseSensitive: this.state.caseSensitive,
+ });
+ }
+
+ componentWillUnmount() {
+ // remove highlights from editor
+ dispatchEditorEvent('find_and_replace__find', {
+ editorId,
+ text: '',
+ caseSensitive: false,
+ });
+ }
+
+ render() {
+ return (
+
+ )}
+ body={(
+
+ {
+ this.setState({findValue});
+ this.scheduleHighlightingOfMatches();
+ }}
+ />
+
+ {
+ this.setState({replaceValue});
+ }}
+ />
+
+ {
+ this.setState({caseSensitive});
+ this.scheduleHighlightingOfMatches();
+ }}
+ />
+
+
+
+ {
+ dispatchEditorEvent('find_and_replace__find_prev', {editorId});
+ }}
+ icon="chevron-left-thin"
+ />
+
+ {
+ dispatchEditorEvent('find_and_replace__find_next', {editorId});
+ }}
+ icon="chevron-right-thin"
+ />
+
+
+
+ {
+ dispatchEditorEvent('find_and_replace__replace', {
+ editorId,
+ replaceWith: this.state.replaceValue,
+ replaceAllMatches: false,
+ });
+
+ setTimeout(() => {
+ this.highlightMatches();
+ });
+ }}
+ disabled={this.state.replaceValue.trim().length < 1}
+ />
+
+ {
+ dispatchEditorEvent('find_and_replace__replace', {
+ editorId,
+ replaceWith: this.state.replaceValue,
+ replaceAllMatches: true,
+ });
+
+ setTimeout(() => {
+ this.highlightMatches();
+ });
+ }}
+ disabled={this.state.replaceValue.trim().length < 1}
+ />
+
+
+
+ )}
+ />
+ );
+ }
+}
+
+export function getFindAndReplaceWidget() {
+ const metadataWidget: IArticleSideWidget = {
+ _id: 'find-and-replace-widget',
+ label: getLabel(),
+ order: 1,
+ icon: 'find-replace',
+ component: FindAndReplaceWidget,
+ };
+
+ return metadataWidget;
+}
diff --git a/scripts/apps/authoring-react/article-widgets/inline-comments.tsx b/scripts/apps/authoring-react/article-widgets/inline-comments.tsx
new file mode 100644
index 0000000000..3f5ffbea6c
--- /dev/null
+++ b/scripts/apps/authoring-react/article-widgets/inline-comments.tsx
@@ -0,0 +1,44 @@
+/* eslint-disable react/no-multi-comp */
+
+import React from 'react';
+import {IArticleSideWidget, IExtensionActivationResult, IArticle} from 'superdesk-api';
+import {gettext} from 'core/utils';
+import {InlineCommentsWidget} from '../generic-widgets/inline-comments';
+
+// Can't call `gettext` in the top level
+const getLabel = () => gettext('Inline comments');
+
+type IProps = React.ComponentProps<
+ IExtensionActivationResult['contributions']['authoringSideWidgets'][0]['component']
+>;
+
+class InlineCommentsWidgetWrapper extends React.PureComponent {
+ render() {
+ return (
+
+ entityId={this.props.article._id}
+ readOnly={this.props.readOnly}
+ contentProfile={this.props.contentProfile}
+ fieldsData={this.props.fieldsData}
+ authoringStorage={this.props.authoringStorage}
+ fieldsAdapter={this.props.fieldsAdapter}
+ storageAdapter={this.props.storageAdapter}
+ onFieldsDataChange={this.props.onFieldsDataChange}
+ handleUnsavedChanges={this.props.handleUnsavedChanges}
+ />
+ );
+ }
+}
+
+export function getInlineCommentsWidget() {
+ const metadataWidget: IArticleSideWidget = {
+ _id: 'inline-comments-widget',
+ label: getLabel(),
+ order: 2,
+ icon: 'comments',
+ component: InlineCommentsWidgetWrapper,
+ isAllowed: (item) => item._type !== 'legal_archive',
+ };
+
+ return metadataWidget;
+}
diff --git a/scripts/apps/authoring-react/article-widgets/metadata/AnnotationsPreview.tsx b/scripts/apps/authoring-react/article-widgets/metadata/AnnotationsPreview.tsx
new file mode 100644
index 0000000000..12de91baed
--- /dev/null
+++ b/scripts/apps/authoring-react/article-widgets/metadata/AnnotationsPreview.tsx
@@ -0,0 +1,43 @@
+import {getAllAnnotations} from 'apps/archive/directives/HtmlPreview';
+import {Spacer} from 'core/ui/components/Spacer';
+import {gettext} from 'core/utils';
+import React from 'react';
+import {IArticle} from 'superdesk-api';
+import {Label, ToggleBox} from 'superdesk-ui-framework/react';
+import './annotations-preview.scss';
+
+interface IProps {
+ article: IArticle;
+}
+
+export class AnnotationsPreview extends React.Component {
+ render(): React.ReactNode {
+ const {article} = this.props;
+
+ return (
+
+
+
+ {
+ (article.annotations?.length ?? 0) > 0 && (
+ getAllAnnotations(article).map((annotation) => (
+
+
+
+
+
+ {annotation.id}
+
+
+
+ ))
+ )
+ }
+
+
+ );
+ }
+}
diff --git a/scripts/apps/authoring-react/article-widgets/metadata/annotations-preview.scss b/scripts/apps/authoring-react/article-widgets/metadata/annotations-preview.scss
new file mode 100644
index 0000000000..04b7c31947
--- /dev/null
+++ b/scripts/apps/authoring-react/article-widgets/metadata/annotations-preview.scss
@@ -0,0 +1,6 @@
+.annotation-body-react {
+ border-bottom: 1px dotted;
+ p {
+ display: inline;
+ }
+}
diff --git a/scripts/apps/authoring-react/article-widgets/metadata/metadata-item.tsx b/scripts/apps/authoring-react/article-widgets/metadata/metadata-item.tsx
new file mode 100644
index 0000000000..1dc1612c09
--- /dev/null
+++ b/scripts/apps/authoring-react/article-widgets/metadata/metadata-item.tsx
@@ -0,0 +1,26 @@
+import {Spacer} from 'core/ui/components/Spacer';
+import React from 'react';
+import {ContentDivider, Heading} from 'superdesk-ui-framework/react';
+
+interface IProps {
+ label: string;
+ value: string | number | JSX.Element;
+}
+
+export class MetadataItem extends React.Component {
+ render(): React.ReactNode {
+ const {label, value} = this.props;
+
+ return (
+ <>
+
+
+ {label.toUpperCase()}
+
+ {value}
+
+
+ >
+ );
+ }
+}
diff --git a/scripts/apps/authoring-react/article-widgets/metadata/metadata.tsx b/scripts/apps/authoring-react/article-widgets/metadata/metadata.tsx
new file mode 100644
index 0000000000..0ab69a3f86
--- /dev/null
+++ b/scripts/apps/authoring-react/article-widgets/metadata/metadata.tsx
@@ -0,0 +1,476 @@
+import React, {Fragment} from 'react';
+import {IArticleSideWidget, IExtensionActivationResult, IVocabularyItem} from 'superdesk-api';
+import {gettext} from 'core/utils';
+import {AuthoringWidgetHeading} from 'apps/dashboard/widget-heading';
+import {AuthoringWidgetLayout} from 'apps/dashboard/widget-layout';
+import {Spacer} from 'core/ui/components/Spacer';
+import {Input, Select, Switch, Option, Heading, ContentDivider, Label} from 'superdesk-ui-framework/react';
+import {MetadataItem} from './metadata-item';
+import {dataApi} from 'core/helpers/CrudManager';
+import {ILanguage} from 'superdesk-interfaces/Language';
+import {DateTime} from 'core/ui/components/DateTime';
+import {vocabularies} from 'api/vocabularies';
+import Datetime from 'core/datetime/datetime';
+import {sdApi} from 'api';
+import {StateComponent} from 'apps/search/components/fields/state';
+import {AnnotationsPreview} from './AnnotationsPreview';
+
+// Can't call `gettext` in the top level
+const getLabel = () => gettext('Metadata');
+
+type IProps = React.ComponentProps<
+ IExtensionActivationResult['contributions']['authoringSideWidgets'][0]['component']
+>;
+
+interface IState {
+ languages: Array;
+}
+
+class MetadataWidget extends React.PureComponent {
+ constructor(props: IProps) {
+ super(props);
+
+ this.state = {
+ languages: [],
+ };
+ }
+
+ componentDidMount(): void {
+ dataApi.query(
+ 'languages',
+ 1,
+ {field: 'language', direction: 'ascending'},
+ {},
+ ).then(({_items}) => {
+ this.setState({
+ languages: _items,
+ });
+ });
+ }
+
+ render() {
+ const {article} = this.props;
+
+ const {
+ flags,
+ usageterms,
+ pubstatus,
+ expiry,
+ urgency,
+ priority,
+ word_count,
+ source,
+ anpa_take_key,
+ genre,
+ dateline,
+ slugline,
+ byline,
+ sign_off,
+ guid,
+ unique_name,
+ type,
+ language,
+ copyrightholder,
+ copyrightnotice,
+ creditline,
+ original_source,
+ ingest_provider_sequence,
+ ingest_provider,
+ keywords,
+ signal,
+ anpa_category,
+ place,
+ ednote,
+ _current_version,
+ firstcreated,
+ versioncreated,
+ renditions,
+ original_id,
+ originalCreator,
+ versioncreator,
+ rewritten_by,
+ } = article;
+
+ const {onArticleChange} = this.props;
+
+ const allVocabularies = sdApi.vocabularies.getAll();
+
+ return (
+
+ )}
+ body={(
+
+
+
+ {gettext('Not For Publication')}
+
+ {
+ onArticleChange({
+ ...article,
+ flags: {
+ ...flags,
+ marked_for_not_publication: !flags.marked_for_not_publication,
+ },
+ });
+ }}
+ value={flags.marked_for_not_publication}
+ />
+
+
+
+
+
+
+ {gettext('Legal')}
+
+ {
+ onArticleChange({
+ ...article,
+ flags: {...flags, marked_for_legal: !flags.marked_for_legal},
+ });
+ }}
+ value={flags.marked_for_legal}
+ />
+
+
+
+
+ {
+ onArticleChange({
+ ...article,
+ usageterms: value,
+ });
+ }}
+ />
+
+
+
+ {
+ onArticleChange({
+ ...article,
+ language: val,
+ });
+ }}
+ >
+ {
+ this.state.languages.map((lang) =>
+ {lang.label} ,
+ )
+ }
+
+
+
+
+ {(pubstatus?.length ?? 0) > 0 && (
+
+ )}
+
+ {(original_source?.length ?? 0) > 0 && (
+
+ )}
+
+ {(copyrightholder?.length ?? 0) > 0 && (
+
+ )}
+
+ {(copyrightnotice?.length ?? 0) > 0 && (
+
+ )}
+
+ {(creditline?.length ?? 0) > 0 && (
+
+ )}
+
+ {
+ <>
+
+
+ {gettext('State').toUpperCase()}
+
+
+
+ {article.embargo && (
+
+ )}
+ {flags.marked_for_not_publication && (
+
+ )}
+ {flags.marked_for_legal && (
+
+ )}
+ {flags.marked_for_sms && (
+
+ )}
+ {(rewritten_by?.length ?? 0) > 0 && (
+
+ )}
+
+
+
+ >
+ }
+
+ {ingest_provider != null && (
+
+ )}
+
+ {
+ (ingest_provider_sequence?.length ?? 0) > 0 && (
+
+ )
+ }
+
+ {expiry && (
+ }
+ />
+ )}
+
+ {(slugline?.length ?? 0) > 0 && }
+
+ {(urgency?.length ?? 0) > 0 && }
+
+ {priority && }
+
+ {word_count > 0 && }
+
+ {keywords && (
+
+ )}
+
+ {(source?.length ?? 0) > 0 && }
+
+
+
+ {
+ signal && (
+
+ {(signal.map(({name, qcode}) => (
+ {name ?? qcode}
+ )))}
+
+ )}
+ />
+ )
+ }
+
+ {
+ anpa_category?.name != null && (
+
+ )
+ }
+
+ {
+ allVocabularies
+ .filter((cv) => article[cv.schema_field] != null)
+ .toArray()
+ .map((vocabulary) => (
+
+ ))
+ }
+
+ {
+ (genre.length ?? 0) > 0
+ && allVocabularies.map((v) => v.schema_field).includes('genre') === false
+ && (
+
+ )
+ }
+
+ {
+ (place.length ?? 0) > 0
+ && allVocabularies.map((v) => v.schema_field).includes('place') === false
+ && (
+
+ )
+ }
+
+ {(ednote?.length ?? 0) > 0 &&
}
+
+
+
+ {gettext('Dateline').toUpperCase()}
+
+
+ /
+ {dateline?.located.city}
+
+
+
+
+
+
+
+
+
+ {_current_version &&
}
+
+ {firstcreated && (
+
+ )}
+ />
+ )}
+
+ {versioncreated && (
+
}
+ />
+ )}
+
+
+
+ {(originalCreator?.length ?? 0) > 0 && (
+
+ )}
+
+ {(versioncreator?.length ?? 0) > 0 && (
+
+ )}
+
+
+
+
{
+ onArticleChange({
+ ...article,
+ unique_name: value,
+ });
+ }}
+ />
+
+
+
+
+
+ {
+ renditions?.original != null && (
+
+ )
+ }
+
+ {
+ article.type === 'picture'
+ && article.archive_description !== article.description_text
+ && (
+
+ )
+ }
+
+ )}
+ />
+ );
+ }
+}
+
+export function getMetadataWidget() {
+ const metadataWidget: IArticleSideWidget = {
+ _id: 'metadata-widget',
+ label: getLabel(),
+ order: 1,
+ icon: 'info',
+ component: MetadataWidget,
+ };
+
+ return metadataWidget;
+}
diff --git a/scripts/apps/authoring-react/article-widgets/suggestions.tsx b/scripts/apps/authoring-react/article-widgets/suggestions.tsx
new file mode 100644
index 0000000000..355e20e955
--- /dev/null
+++ b/scripts/apps/authoring-react/article-widgets/suggestions.tsx
@@ -0,0 +1,242 @@
+/* eslint-disable react/no-multi-comp */
+
+import React from 'react';
+import {IArticleSideWidget, IExtensionActivationResult, IUser, IEditor3ValueOperational} from 'superdesk-api';
+import {gettext} from 'core/utils';
+import {AuthoringWidgetHeading} from 'apps/dashboard/widget-heading';
+import {AuthoringWidgetLayout} from 'apps/dashboard/widget-layout';
+import {EmptyState, Label} from 'superdesk-ui-framework/react';
+import {getCustomEditor3Data} from 'core/editor3/helpers/editor3CustomData';
+import {store} from 'core/data';
+import {Card} from 'core/ui/components/Card';
+import {UserAvatar} from 'apps/users/components/UserAvatar';
+import {TimeElem} from 'apps/search/components';
+import {Spacer, SpacerBlock} from 'core/ui/components/Spacer';
+import {getLocalizedTypeText} from 'apps/authoring/track-changes/suggestions';
+
+// Can't call `gettext` in the top level
+const getLabel = () => gettext('Resolved suggestions');
+
+type IProps = React.ComponentProps<
+ IExtensionActivationResult['contributions']['authoringSideWidgets'][0]['component']
+>;
+
+interface ISuggestion {
+ resolutionInfo: {
+ accepted: boolean;
+ date: string;
+ resolverUserId: IUser['_id'];
+ };
+
+ suggestionInfo: {
+ author: IUser['_id'];
+ date: string;
+ selection: {}; // serialized SelectionState
+ styleName: string;
+ suggestionText: string;
+ type: string;
+ blockType?: string;
+ link?: { // only for link suggestions
+ href: string;
+ }
+ };
+
+ suggestionText: string;
+ oldText?: string; // used with replace suggestion
+}
+
+class Suggestion extends React.PureComponent<{suggestion: ISuggestion}> {
+ render() {
+ const {suggestionInfo, suggestionText, oldText, resolutionInfo} = this.props.suggestion;
+ const suggestionAuthor =
+ store.getState().entities.users[suggestionInfo.author];
+ const suggestionResolver =
+ store.getState().entities.users[resolutionInfo.resolverUserId];
+
+ return (
+
+
+
+
+
+
+
+
{suggestionAuthor.display_name} :
+
+
+
+
+
+
+
+
+
+ {
+ resolutionInfo.accepted
+ ? ( )
+ : ( )
+ }
+
+
+
+
+
+
+ {
+ suggestionInfo.type === 'REPLACE_SUGGESTION'
+ ? (
+ "${oldText}"`,
+ y: `"${suggestionText}" `,
+ },
+ ),
+ }}
+ />
+ )
+ : (
+
+ {
+ getLocalizedTypeText(
+ suggestionInfo.type,
+ suggestionInfo.blockType,
+ )
+ }
+ :
+
+ "{suggestionText}"
+
+ {
+ suggestionInfo.type === 'ADD_LINK_SUGGESTION' && (
+
+ {suggestionInfo.link.href}
+
+ )
+ }
+
+ )
+ }
+
+
+
+
+
+ {
+ resolutionInfo.accepted
+ ? gettext('Accepted by {{user}}', {user: suggestionResolver.display_name})
+ : gettext('Rejected by {{user}}', {user: suggestionResolver.display_name})
+ }
+
+
+
+
+
+
+ );
+ }
+}
+
+class SuggestionsWidget extends React.PureComponent
{
+ constructor(props: IProps) {
+ super(props);
+
+ this.getEditor3Fields = this.getEditor3Fields.bind(this);
+ this.getResolvedSuggestions = this.getResolvedSuggestions.bind(this);
+ }
+
+ getEditor3Fields() {
+ const {contentProfile} = this.props;
+ const allFields = contentProfile.header.merge(contentProfile.content);
+
+ return allFields.filter((field) => field.fieldType === 'editor3').toArray();
+ }
+
+ getResolvedSuggestions() {
+ const {fieldsData} = this.props;
+
+ return this.getEditor3Fields().map((field) => {
+ const value = fieldsData.get(field.id) as IEditor3ValueOperational;
+
+ return {
+ fieldId: field.id,
+ suggestions: (getCustomEditor3Data(
+ value.contentState,
+ 'RESOLVED_SUGGESTIONS_HISTORY',
+ ) ?? []) as Array,
+ };
+ }).filter(({suggestions}) => suggestions.length > 0);
+ }
+
+ render() {
+ const {contentProfile} = this.props;
+ const allFields = contentProfile.header.merge(contentProfile.content);
+ const resolvedSuggestions = this.getResolvedSuggestions();
+
+ const widgetBody: JSX.Element = resolvedSuggestions.length > 0
+ ? (
+
+
+ {
+ resolvedSuggestions.map(({fieldId, suggestions}, i) => {
+ return (
+
+
+ {allFields.get(fieldId).name}
+
+
+
+
+
+ {
+ suggestions.map((suggestion, j) => (
+
+ ))
+ }
+
+
+ );
+ })
+ }
+
+
+ )
+ : (
+
+ );
+
+ return (
+
+ )}
+ body={widgetBody}
+ background="grey"
+ />
+ );
+ }
+}
+
+export function getSuggestionsWidget() {
+ const metadataWidget: IArticleSideWidget = {
+ _id: 'editor3-suggestions-widget',
+ label: getLabel(),
+ order: 3,
+ icon: 'suggestion',
+ component: SuggestionsWidget,
+ isAllowed: (item) => item._type !== 'legal_archive' && item._type !== 'archived',
+ };
+
+ return metadataWidget;
+}
diff --git a/scripts/apps/authoring-react/article-widgets/translations/TranslationsBody.tsx b/scripts/apps/authoring-react/article-widgets/translations/TranslationsBody.tsx
new file mode 100644
index 0000000000..d5dc6c8954
--- /dev/null
+++ b/scripts/apps/authoring-react/article-widgets/translations/TranslationsBody.tsx
@@ -0,0 +1,62 @@
+import React from 'react';
+import ng from 'core/services/ng';
+import {IArticle} from 'superdesk-api';
+
+interface IProps {
+ item: IArticle;
+ wrapperTemplate: React.ComponentType<{children: Array}>;
+ translationTemplate: React.ComponentType<{translation: IArticle, getTranslatedFromLanguage: () => string}>;
+}
+
+interface IState {
+ translations: Array | null;
+ translationsLookup: Dictionary;
+}
+
+export class TranslationsBody extends React.PureComponent {
+ componentDidMount() {
+ const {item} = this.props;
+
+ ng.get('TranslationService').getTranslations(item)
+ .then((response) => {
+ const translations: Array = response._items;
+
+ this.setState({
+ translations: translations,
+ translationsLookup: translations.reduce((result, reference) => {
+ result[reference._id] = reference;
+ return result;
+ }, {}),
+ });
+ });
+ }
+
+ render(): React.ReactNode {
+ if (this.state?.translations == null || this.state?.translationsLookup == null) {
+ return null;
+ }
+
+ const sortOldestFirst = (a: IArticle, b: IArticle) =>
+ new Date(b.firstcreated) > new Date(a.firstcreated) ? -1 : 1;
+ const WrapperTemplate = this.props.wrapperTemplate;
+ const TranslationTemplate = this.props.translationTemplate;
+
+ return (
+
+ {
+ this.state.translations.sort(sortOldestFirst).map((translation: IArticle, i) => {
+ return (
+ this.state.translationsLookup[translation.translated_from]?.language
+ }
+ />
+ );
+ })
+ }
+
+ );
+ }
+}
diff --git a/scripts/apps/authoring-react/article-widgets/translations/translations.tsx b/scripts/apps/authoring-react/article-widgets/translations/translations.tsx
new file mode 100644
index 0000000000..0bab637a02
--- /dev/null
+++ b/scripts/apps/authoring-react/article-widgets/translations/translations.tsx
@@ -0,0 +1,101 @@
+import React from 'react';
+import {RelativeDate} from 'core/datetime/relativeDate';
+import {state as State} from 'apps/search/components/fields/state';
+import {IArticle, IArticleSideWidget, IExtensionActivationResult} from 'superdesk-api';
+import {gettext} from 'core/utils';
+import {openArticle} from 'core/get-superdesk-api-implementation';
+import {AuthoringWidgetLayout} from 'apps/dashboard/widget-layout';
+import {AuthoringWidgetHeading} from 'apps/dashboard/widget-heading';
+import {Card} from 'core/ui/components/Card';
+import {Spacer, SpacerBlock} from 'core/ui/components/Spacer';
+import {Label} from 'superdesk-ui-framework';
+import {TranslationsBody} from './TranslationsBody';
+
+const getLabel = () => gettext('Translations');
+
+type IProps = React.ComponentProps<
+ IExtensionActivationResult['contributions']['authoringSideWidgets'][0]['component']
+>;
+
+class Translations extends React.Component {
+ render() {
+ return (
+
+ )}
+ body={(
+
+ {children}
+ }
+ translationTemplate={({translation, getTranslatedFromLanguage}) => (
+
+ openArticle(translation._id, 'edit')}>
+
+
+ {translation.language}
+
+ {translation.headline}
+
+
+
+
+
+
+
+
+ {
+ translation.translated_from == null
+ ? (
+
+ )
+ : (
+
+ {gettext('Translated from')}
+
+
+ )
+ }
+
+
+
+
+
+
+
+
+ )}
+ />
+ )}
+ />
+ );
+ }
+}
+
+export function getTranslationsWidget() {
+ const metadataWidget: IArticleSideWidget = {
+ _id: 'translation-widget',
+ label: getLabel(),
+ order: 2,
+ icon: 'web',
+ component: Translations,
+ };
+
+ return metadataWidget;
+}
diff --git a/scripts/apps/authoring-react/article-widgets/versions-and-item-history/history-tab.tsx b/scripts/apps/authoring-react/article-widgets/versions-and-item-history/history-tab.tsx
new file mode 100644
index 0000000000..34202c608f
--- /dev/null
+++ b/scripts/apps/authoring-react/article-widgets/versions-and-item-history/history-tab.tsx
@@ -0,0 +1,554 @@
+import React from 'react';
+import {Map} from 'immutable';
+import {IExtensionActivationResult, IRestApiResponse} from 'superdesk-api';
+import {getHistoryItems, IHistoryItem, getOperationLabel} from 'apps/authoring/versioning/history/HistoryController';
+import {TimeElem} from 'apps/search/components';
+import {gettext} from 'core/utils';
+import {Spacer} from 'core/ui/components/Spacer';
+import {Card} from 'core/ui/components/Card';
+import {openArticle} from 'core/get-superdesk-api-implementation';
+import {IHighlight} from 'apps/highlights/services/HighlightsService';
+import {httpRequestJsonLocal} from 'core/helpers/network';
+import {Button, ToggleBox} from 'superdesk-ui-framework/react';
+import {TransmissionDetails} from './transmission-details';
+
+type IProps = React.ComponentProps<
+ IExtensionActivationResult['contributions']['authoringSideWidgets'][0]['component']
+>;
+
+interface IState {
+ historyItems: Array | null;
+ highlights: Map | null;
+}
+
+export class HistoryTab extends React.PureComponent {
+ constructor(props: IProps) {
+ super(props);
+
+ this.state = {
+ historyItems: null,
+ highlights: null,
+ };
+
+ this.getHighlightsLabel = this.getHighlightsLabel.bind(this);
+ }
+
+ getHighlightsLabel(id: IHighlight['_id'], fallbackLabel: string): string {
+ return this.state.highlights.get(id)?.name ?? fallbackLabel;
+ }
+
+ componentDidMount() {
+ Promise.all([
+ getHistoryItems(this.props.article),
+ httpRequestJsonLocal>({method: 'GET', path: '/highlights'}),
+ ]).then(([historyItems, highlightsResponse]) => {
+ this.setState({
+ historyItems,
+ highlights: Map(highlightsResponse._items.map((item) => [item._id, item])),
+ });
+ });
+ }
+
+ render() {
+ const {historyItems} = this.state;
+
+ if (historyItems == null) {
+ return null;
+ }
+
+ const BaseHistoryItem: React.ComponentType<{items: Array, current: number}> = (props) => {
+ const {items, current, children} = props;
+ const item = items[current];
+ const itemPrevious = items[current - 1];
+ const showVersion = item.version > 0 && item.version !== itemPrevious?.version;
+
+ return (
+
+
+
+
+
+
+
+ {
+ showVersion && (
+
+ {gettext('Version: {{n}}', {n: item.version})}
+
+ )
+ }
+
+
+ {children}
+
+
+ );
+ };
+
+ return (
+
+ {
+ historyItems.map((item, i) => {
+ if (item.operation === 'create') {
+ return (
+
+
+ {gettext('Created by')}
+
+ {item.displayName}
+
+
+ {
+ item.desk != null && (
+ {item.desk} / {item.stage}
+ )
+ }
+
+ );
+ } else if (item.operation === 'update') {
+ if (item.update.operation === 'deschedule') {
+ return (
+
+
+ {gettext('Descheduled by')}
+
+ {item.displayName}
+
+
+
+ {gettext('Updated fields:')}
+
+ {item.fieldsUpdated}
+
+
+ );
+ } else {
+ return (
+
+
+ {gettext('Updated by')}
+
+ {item.displayName}
+
+
+
+ {gettext('Updated fields:')}
+
+ {item.fieldsUpdated}
+
+
+ );
+ }
+ } else if (item.operation === 'duplicated_from') {
+ return (
+
+
+ {gettext('Duplicated by')}
+
+ {item.displayName}
+
+
+ {
+ item.update.duplicate_id != null && (
+
+ {
+ openArticle(item.update.duplicate_id, 'view');
+ }}
+ size="small"
+ style="hollow"
+ />
+
+ )
+ }
+
+ );
+ } else if (item.operation === 'duplicate') {
+ return (
+
+
+ {gettext('Duplicate created by')}
+
+ {item.displayName}
+
+
+ {
+ item.update.duplicate_id != null && (
+
+ {
+ openArticle(item.update.duplicate_id, 'view');
+ }}
+ size="small"
+ style="hollow"
+ />
+
+ )
+ }
+
+ );
+ } else if (item.operation === 'translate') {
+ return (
+
+
+ {gettext('Translated by')}
+
+ {item.displayName}
+
+
+ {
+ item.update.duplicate_id != null && (
+
+ {
+ openArticle(item.update.duplicate_id, 'view');
+ }}
+ size="small"
+ style="hollow"
+ />
+
+ )
+ }
+
+ );
+ } else if (item.operation === 'spike') {
+ return (
+
+
+ {gettext('Spiked by')}
+
+ {item.displayName}
+
+
+ {
+ item.desk != null && (
+ {gettext('from:')} {item.desk} / {item.stage}
+ )
+ }
+
+ );
+ } else if (item.operation === 'unspike') {
+ return (
+
+
+ {gettext('Unspiked by')}
+
+ {item.displayName}
+
+
+ {
+ item.desk != null && (
+ {gettext('from:')} {item.desk} / {item.stage}
+ )
+ }
+
+ );
+ } else if (item.operation === 'move') {
+ return (
+
+
+ {gettext('Moved by')}
+
+ {item.displayName}
+
+
+ {
+ item.desk != null && (
+ {gettext('from:')} {item.desk} / {item.stage}
+ )
+ }
+
+ );
+ } else if (item.operation === 'fetch') {
+ return (
+
+
+ {gettext('Fetched by')}
+
+ {item.displayName}
+
+
+ {
+ item.desk != null && (
+ {gettext('from:')} {item.desk} / {item.stage}
+ )
+ }
+
+ );
+ } else if (item.operation === 'mark' && item.update.highlight_id !== null) {
+ return (
+
+
+ {gettext(
+ 'Marked for highlight {{x}} by {{user}}',
+ {
+ x: this.getHighlightsLabel(
+ item.update.highlight_id,
+ item.update.highlight_name,
+ ),
+ user: item.displayName,
+ },
+ )}
+
+
+ );
+ } else if (item.operation === 'unmark' && item.update.highlight_id !== null) {
+ return (
+
+
+ {gettext(
+ 'Unmarked from highlight({{x}}) by {{user}}',
+ {
+ x: this.getHighlightsLabel(
+ item.update.highlight_id,
+ item.update.highlight_name,
+ ),
+ user: item.displayName,
+ },
+ )}
+
+
+ );
+ } else if (item.operation === 'mark' && item.update.desk_id !== null) {
+ return (
+
+
+ {gettext(
+ 'Marked for desk {{x}} by {{user}}',
+ {
+ x: this.getHighlightsLabel(
+ item.update.highlight_id,
+ item.update.highlight_name,
+ ),
+ user: item.displayName,
+ },
+ )}
+
+
+ );
+ } else if (item.operation === 'unmark' && item.update.desk_id !== null) {
+ return (
+
+
+ {gettext(
+ 'Unmarked from desk({{x}}) by {{user}}',
+ {
+ x: this.getHighlightsLabel(
+ item.update.highlight_id,
+ item.update.highlight_name,
+ ),
+ user: item.displayName,
+ },
+ )}
+
+
+ );
+ } else if (item.operation === 'export_highlight') {
+ return (
+
+
+ {gettext('Exported by')}
+
+ {item.displayName}
+
+
+ {
+ item.update.highlight_id != null && (
+
+ {gettext(
+ 'from highlight: {{x}}',
+ {
+ x: this.getHighlightsLabel(
+ item.update.highlight_id,
+ item.update.highlight_name,
+ ),
+ },
+ )}
+
+ )
+ }
+
+
+ );
+ } else if (item.operation === 'create_highlight' && item.update.highlight_id != null) {
+ return (
+
+
+ {gettext(
+ 'Created from highlight({{x}}) by {{user}}',
+ {
+ x: this.getHighlightsLabel(
+ item.update.highlight_id,
+ item.update.highlight_name,
+ ),
+ user: item.displayName,
+ },
+ )}
+
+
+
+ );
+ } else if (item.operation === 'link') {
+ return (
+
+
+ {gettext('Linked by {{user}}', {user: item.displayName})}
+
+
+
+ {
+ openArticle(item.update.linked_to, 'view');
+ }}
+ size="small"
+ style="hollow"
+ />
+
+
+
+ );
+ } else if (item.operation === 'take') {
+ return (
+
+
+ {gettext(
+ 'Take created by {{user}}',
+ {
+ user: item.displayName,
+ },
+ )}
+
+
+
+ {gettext('Taken as a rewrite of')}
+ {
+ openArticle(item.update.rewrite_of, 'view');
+ }}
+ size="small"
+ style="hollow"
+ />
+
+
+ );
+ } else if (item.operation === 'reopen') {
+ return (
+
+
+ {gettext(
+ 'Reopened by {{user}}',
+ {
+ user: item.displayName,
+ },
+ )}
+
+
+ );
+ } else if (item.operation === 'unlink') {
+ return (
+
+
+ {gettext(
+ 'Unlinked by {{user}}',
+ {
+ user: item.displayName,
+ },
+ )}
+
+
+ {
+ (() => {
+ if (item.update.rewrite_of != null) {
+ return gettext('Rewrite link is removed');
+ } else if (item.update.rewritten_by != null) {
+ return gettext('Rewritten link is removed');
+ } else if (item.update == null) {
+ return gettext('Take link is removed');
+ }
+ })()
+ }
+
+
+ );
+ } else if (
+ item.operation === 'cancel_correction'
+ || item.update?.operation === 'cancel_correction'
+ ) {
+ return (
+
+
+ {gettext(
+ 'Correction cancelled by {{user}}',
+ {
+ user: item.displayName,
+ },
+ )}
+
+
+ {
+ item.update != null && (
+ {gettext('Correction link is removed')}
+ )
+ }
+
+ );
+ } else if (item.operation === 'rewrite') {
+ return (
+
+
+ {gettext(
+ 'Rewritten by {{user}}',
+ {
+ user: item.displayName,
+ },
+ )}
+
+
+ {
+ item.update?.rewritten_by != null && (
+
+ {
+ openArticle(item.update.rewritten_by, 'view');
+ }}
+ size="small"
+ style="hollow"
+ />
+
+ )
+ }
+
+ );
+ } else if (
+ item.operation === 'publish'
+ || item.operation === 'correct'
+ || item.operation === 'kill'
+ || item.operation === 'takedown'
+ || item.operation === 'unpublish'
+ ) {
+ return (
+
+
+ {getOperationLabel(item.operation, item.update.state)} {item.displayName}
+
+
+
+
+
+
+ );
+ } else {
+ return null;
+ }
+ })
+ }
+
+ );
+ }
+}
diff --git a/scripts/apps/authoring-react/article-widgets/versions-and-item-history/index.tsx b/scripts/apps/authoring-react/article-widgets/versions-and-item-history/index.tsx
new file mode 100644
index 0000000000..b6f1398a8d
--- /dev/null
+++ b/scripts/apps/authoring-react/article-widgets/versions-and-item-history/index.tsx
@@ -0,0 +1,84 @@
+import React from 'react';
+import {
+ IArticleSideWidget,
+ IExtensionActivationResult,
+} from 'superdesk-api';
+import {gettext} from 'core/utils';
+import {AuthoringWidgetHeading} from 'apps/dashboard/widget-heading';
+import {AuthoringWidgetLayout} from 'apps/dashboard/widget-layout';
+import {assertNever} from 'core/helpers/typescript-helpers';
+import {TabList} from 'core/ui/components/tabs';
+import {HistoryTab} from './history-tab';
+import {VersionsTab} from './versions-tab';
+
+// Can't call `gettext` in the top level
+const getLabel = () => gettext('Versions and item history');
+
+type IProps = React.ComponentProps<
+ IExtensionActivationResult['contributions']['authoringSideWidgets'][0]['component']
+>;
+
+interface IState {
+ selectedTab: 'versions' | 'history';
+}
+
+class VersionsAndItemHistoryWidget extends React.PureComponent {
+ constructor(props: IProps) {
+ super(props);
+
+ this.state = {
+ selectedTab: 'versions',
+ };
+ }
+
+ render() {
+ return (
+ {
+ this.setState({selectedTab});
+ }}
+ selectedTabId={this.state.selectedTab}
+ />
+ )}
+ />
+ )}
+ body={(() => {
+ if (this.state.selectedTab === 'history') {
+ return (
+
+ );
+ } else if (this.state.selectedTab === 'versions') {
+ return (
+
+ );
+ } else {
+ assertNever(this.state.selectedTab);
+ }
+ })()}
+ background="grey"
+ />
+ );
+ }
+}
+
+export function getVersionsAndItemHistoryWidget() {
+ const widget: IArticleSideWidget = {
+ _id: 'versions-and-item-history',
+ label: getLabel(),
+ order: 4,
+ icon: 'history',
+ component: VersionsAndItemHistoryWidget,
+ };
+
+ return widget;
+}
diff --git a/scripts/apps/authoring-react/article-widgets/versions-and-item-history/transmission-details.tsx b/scripts/apps/authoring-react/article-widgets/versions-and-item-history/transmission-details.tsx
new file mode 100644
index 0000000000..95b2f7e341
--- /dev/null
+++ b/scripts/apps/authoring-react/article-widgets/versions-and-item-history/transmission-details.tsx
@@ -0,0 +1,139 @@
+import React from 'react';
+import {IHistoryItem} from 'apps/authoring/versioning/history/HistoryController';
+import {IArticle, IRestApiResponse} from 'superdesk-api';
+import {httpRequestJsonLocal} from 'core/helpers/network';
+import {IPublishQueueItem} from 'superdesk-interfaces/PublishQueueItem';
+import {gettext} from 'core/utils';
+import {IconButton} from 'superdesk-ui-framework/react';
+import {showModal} from '@superdesk/common';
+import {Modal} from 'core/ui/components/Modal/Modal';
+import {ModalHeader} from 'core/ui/components/Modal/ModalHeader';
+import {ModalBody} from 'core/ui/components/Modal/ModalBody';
+import {TimeElem} from 'apps/search/components/TimeElem';
+import {Spacer} from 'core/ui/components/Spacer';
+
+interface IProps {
+ article: IArticle;
+ historyItem: IHistoryItem;
+}
+
+interface IState {
+ queueItems: Array | null;
+}
+
+function tryShowingFormattedJson(maybeJson: string) {
+ try {
+ const parsed = JSON.parse(maybeJson);
+
+ return (
+ {JSON.stringify(parsed, null, 2)}
+ );
+ } catch {
+ return {maybeJson} ;
+ }
+}
+
+export class TransmissionDetails extends React.PureComponent {
+ constructor(props: IProps) {
+ super(props);
+
+ this.state = {
+ queueItems: null,
+ };
+ }
+
+ componentDidMount() {
+ httpRequestJsonLocal>({
+ method: 'GET',
+ path: this.props.article._type === 'legal_archive' ? '/legal_publish_queue' : '/publish_queue',
+ urlParams: {
+ max_results: 20,
+ where: {
+ $and: [
+ {item_id: this.props.historyItem.item_id},
+ {item_version: this.props.historyItem.version},
+ ],
+ },
+ },
+ }).then((res) => {
+ this.setState({queueItems: res._items});
+ });
+ }
+
+ render() {
+ const {queueItems} = this.state;
+
+ if (queueItems == null) {
+ return null;
+ }
+
+ if (queueItems.length < 1) {
+ return (
+ {gettext('Item has not been transmitted to any subscriber')}
+ );
+ }
+
+ return (
+ {/** if show_transmission_details && hasItems */}
+ {
+ queueItems.map((queueItem, i) => (
+
+ {(() => {
+ if (queueItem.state === 'error') {
+ return (
+
+ {
+ gettext(
+ 'Error sending as {{name}} to {{destination}} at {{date}}',
+ {
+ name: () => {queueItem.unique_name} ,
+ destination: queueItem.destination.name,
+ date: () => ,
+ },
+ )
+ }
+
+ );
+ } else {
+ return (
+
+ {
+ gettext(
+ 'Sent/Queued as {{name}} to {{destination}} at {{date}}',
+ {
+ name: () => {queueItem.unique_name} ,
+ destination: queueItem.destination.name,
+ date: () => ,
+ },
+ )
+ }
+
+ );
+ }
+ })()}
+
+ {
+ showModal(({closeModal}) => (
+
+
+ {gettext('Item sent to Subscriber')}
+
+
+
+ {tryShowingFormattedJson(queueItem.formatted_item)}
+
+
+ ));
+ }}
+ size="small"
+ />
+
+ ))
+ }
+
+ );
+ }
+}
diff --git a/scripts/apps/authoring-react/article-widgets/versions-and-item-history/versions-tab.tsx b/scripts/apps/authoring-react/article-widgets/versions-and-item-history/versions-tab.tsx
new file mode 100644
index 0000000000..962be36afe
--- /dev/null
+++ b/scripts/apps/authoring-react/article-widgets/versions-and-item-history/versions-tab.tsx
@@ -0,0 +1,291 @@
+import React from 'react';
+import {uniq} from 'lodash';
+import {
+ IArticle,
+ IExtensionActivationResult,
+ IRestApiResponse,
+ IDesk,
+ IStage,
+} from 'superdesk-api';
+import {gettext, getItemLabel} from 'core/utils';
+import {httpRequestJsonLocal} from 'core/helpers/network';
+import {Card} from 'core/ui/components/Card';
+import {TimeElem} from 'apps/search/components';
+import {Spacer, SpacerBlock} from 'core/ui/components/Spacer';
+import {store} from 'core/data';
+import {StateComponent} from 'apps/search/components/fields/state';
+import {Button, ToggleBox} from 'superdesk-ui-framework/react';
+import {notNullOrUndefined} from 'core/helpers/typescript-helpers';
+import {Map} from 'immutable';
+import {sdApi} from 'api';
+import {dispatchInternalEvent} from 'core/internal-events';
+import {omitFields} from '../../data-layer';
+import {compareAuthoringEntities} from '../../compare-articles/compare-articles';
+import {previewAuthoringEntity} from '../../preview-article-modal';
+import {getArticleAdapter} from '../../article-adapter';
+import {SelectFilterable} from 'core/ui/components/select-filterable';
+
+const loadingState: IState = {
+ versions: 'loading',
+ desks: Map(),
+ stages: Map(),
+ selectedForComparison: {from: null, to: null},
+};
+
+type IProps = React.ComponentProps<
+ IExtensionActivationResult['contributions']['authoringSideWidgets'][0]['component']
+>;
+
+interface IState {
+ versions: Array | 'loading';
+ desks: Map;
+ stages: Map;
+ selectedForComparison?: {from: IArticle; to: IArticle};
+}
+
+export class VersionsTab extends React.PureComponent {
+ constructor(props: IProps) {
+ super(props);
+
+ this.state = loadingState;
+
+ this.initialize = this.initialize.bind(this);
+ this.revert = this.revert.bind(this);
+ this.compareVersions = this.compareVersions.bind(this);
+ }
+
+ initialize() {
+ Promise.all([
+ httpRequestJsonLocal>({
+ method: 'GET',
+ path: `/archive/${this.props.article._id}?version=all`,
+ }),
+ getArticleAdapter(),
+ ]).then(([res, adapter]) => {
+ const items = res._items.map((item) => adapter.toAuthoringReact(item));
+ const itemsReversed = items.reverse();
+
+ const deskIds = uniq(items.map((item) => item.task?.desk).filter(notNullOrUndefined));
+ const stageIds = uniq(items.map((item) => item.task?.stage).filter(notNullOrUndefined));
+
+ return Promise.all([
+ httpRequestJsonLocal>({
+ method: 'GET',
+ path: '/desks',
+ urlParams: {$in: deskIds}},
+ ),
+ httpRequestJsonLocal>({
+ method: 'GET',
+ path: '/stages',
+ urlParams: {$in: stageIds}},
+ ),
+ ]).then(([resDesks, resStages]) => {
+ this.setState({
+ versions: itemsReversed,
+ desks: Map(resDesks._items.map((item) => [item._id, item])),
+ stages: Map(resStages._items.map((item) => [item._id, item])),
+ selectedForComparison: {
+ from: itemsReversed[1],
+ to: itemsReversed[0],
+ },
+ });
+ });
+ });
+ }
+
+ revert(version: IArticle) {
+ this.props.handleUnsavedChanges().then(({_id, _etag}) => {
+ httpRequestJsonLocal({
+ method: 'PATCH',
+ path: `/archive/${_id}`,
+ payload: omitFields(version, true),
+ headers: {
+ 'If-Match': _etag,
+ },
+ }).then(() => {
+ dispatchInternalEvent('dangerouslyForceReloadAuthoring', undefined);
+
+ this.setState(loadingState);
+
+ this.initialize();
+ });
+ });
+ }
+
+ compareVersions() {
+ const {from, to} = this.state.selectedForComparison;
+
+ compareAuthoringEntities({
+ item1: {
+ label: gettext('version {{n}}', {n: from._current_version}),
+ entity: from,
+ },
+ item2: {
+ label: gettext('version {{n}}', {n: to._current_version}),
+ entity: to,
+ },
+ getLanguage: () => '',
+ authoringStorage: this.props.authoringStorage,
+ fieldsAdapter: this.props.fieldsAdapter,
+ storageAdapter: this.props.storageAdapter,
+ });
+ }
+
+ componentDidMount() {
+ this.initialize();
+ }
+
+ render() {
+ if (this.state.versions === 'loading') {
+ return null;
+ }
+
+ const {versions, desks, stages, selectedForComparison} = this.state;
+ const {readOnly, contentProfile, fieldsData} = this.props;
+
+ const userEntities =
+ store.getState().entities.users;
+
+ return (
+
+
+
+ {
+ this.setState({
+ selectedForComparison: {
+ ...this.state.selectedForComparison,
+ from: val,
+ },
+ });
+ }}
+ getLabel={(item) => gettext('version: {{n}}', {n: item._current_version})}
+ required
+ />
+
+ {
+ this.setState({
+ selectedForComparison: {
+ ...this.state.selectedForComparison,
+ to: val,
+ },
+ });
+ }}
+ getLabel={(item) => gettext('version: {{n}}', {n: item._current_version})}
+ required
+ />
+
+
+
+
+
+ {
+ this.compareVersions();
+ }}
+ type="primary"
+ size="small"
+ />
+
+
+
+
+
+ {
+ versions.map((item, i) => {
+ const canRevert = i !== 0 && !readOnly && !sdApi.article.isPublished(item);
+
+ return (
+
+
+
+
+ {
+ gettext('by {{user}}', {
+ user: userEntities[item.version_creator].display_name,
+ })
+ }
+
+
+
+
+
+
+ {getItemLabel(item)}
+
+
+
+
+ {
+ item.task.desk != null && (
+
+ {desks.get(item.task.desk).name}
+ {stages.get(item.task.stage).name}
+
+ )
+ }
+
+
+
+
+
+ {gettext('version: {{n}}', {n: item._current_version})}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ previewAuthoringEntity(
+ contentProfile,
+ fieldsData,
+ gettext('version {{n}}', {n: item._current_version}),
+ );
+ }}
+ style="hollow"
+ size="small"
+ />
+
+
+ {
+ canRevert && (
+
+ {
+ this.revert(item);
+ }}
+ style="hollow"
+ size="small"
+ />
+
+ )
+ }
+
+
+
+ );
+ })
+ }
+
+ );
+ }
+}
diff --git a/scripts/apps/authoring-react/authoring-angular-integration.tsx b/scripts/apps/authoring-react/authoring-angular-integration.tsx
new file mode 100644
index 0000000000..8ea008d02e
--- /dev/null
+++ b/scripts/apps/authoring-react/authoring-angular-integration.tsx
@@ -0,0 +1,449 @@
+/* eslint-disable react/display-name */
+/* eslint-disable react/no-multi-comp */
+import {assertNever} from 'core/helpers/typescript-helpers';
+import {DeskAndStage} from './subcomponents/desk-and-stage';
+import {LockInfo} from './subcomponents/lock-info';
+import {Button, ButtonGroup, IconButton, NavButton, Popover} from 'superdesk-ui-framework/react';
+import {
+ IArticle,
+ ITopBarWidget,
+ IExposedFromAuthoring,
+ IAuthoringOptions,
+} from 'superdesk-api';
+import {appConfig, extensions} from 'appConfig';
+import {ITEM_STATE} from 'apps/archive/constants';
+import React from 'react';
+import {gettext} from 'core/utils';
+import {sdApi} from 'api';
+import ng from 'core/services/ng';
+import {AuthoringIntegrationWrapper} from './authoring-integration-wrapper';
+import {MarkedDesks} from './toolbar/mark-for-desks/mark-for-desks-popover';
+import {WithPopover} from 'core/helpers/with-popover';
+import {HighlightsCardContent} from './toolbar/highlights-management';
+import {authoringStorageIArticle} from './data-layer';
+import {
+ IStateInteractiveActionsPanelHOC,
+ IActionsInteractiveActionsPanelHOC,
+} from 'core/interactive-article-actions-panel/index-hoc';
+import {IArticleActionInteractive} from 'core/interactive-article-actions-panel/interfaces';
+import {dispatchInternalEvent} from 'core/internal-events';
+import {notify} from 'core/notify/notify';
+
+export interface IProps {
+ itemId: IArticle['_id'];
+}
+
+function onClose() {
+ ng.get('authoringWorkspace').close();
+ ng.get('$rootScope').$applyAsync();
+}
+
+function getInlineToolbarActions(options: IExposedFromAuthoring): IAuthoringOptions {
+ const {
+ item,
+ hasUnsavedChanges,
+ handleUnsavedChanges,
+ save,
+ initiateClosing,
+ keepChangesAndClose,
+ stealLock,
+ } = options;
+ const itemState: ITEM_STATE = item.state;
+
+ const saveButton: ITopBarWidget = {
+ group: 'end',
+ priority: 0.2,
+ component: () => (
+ {
+ save();
+ }}
+ />
+ ),
+ availableOffline: true,
+ keyBindings: {
+ 'ctrl+shift+s': () => {
+ if (hasUnsavedChanges()) {
+ save();
+ }
+ },
+ },
+ };
+
+ const closeButton: ITopBarWidget = {
+ group: 'end',
+ priority: 0.1,
+ component: () => (
+ {
+ initiateClosing();
+ }}
+ />
+ ),
+ availableOffline: true,
+ keyBindings: {
+ 'ctrl+shift+e': () => {
+ initiateClosing();
+ },
+ },
+ };
+
+ const minimizeButton: ITopBarWidget = {
+ group: 'end',
+ priority: 0.3,
+ component: () => (
+ {
+ keepChangesAndClose();
+ }}
+ icon="minimize"
+ iconSize="big"
+ />
+ ),
+ availableOffline: true,
+ };
+
+ const getManageHighlights = (): ITopBarWidget => ({
+ group: 'start',
+ priority: 0.3,
+ component: () => (
+ (
+
+ )}
+ placement="right-end"
+ zIndex={1050}
+ >
+ {
+ (togglePopup) => (
+
+ togglePopup(event.target as HTMLElement)
+ }
+ icon={
+ item.highlights.length > 1
+ ? 'multi-star'
+ : 'star'
+ }
+ ariaValue={gettext('Highlights')}
+ />
+ )
+ }
+
+ ),
+ availableOffline: true,
+ });
+
+ switch (itemState) {
+ case ITEM_STATE.DRAFT:
+ return {
+ readOnly: false,
+ actions: [saveButton, minimizeButton],
+ };
+
+ case ITEM_STATE.SUBMITTED:
+ case ITEM_STATE.IN_PROGRESS:
+ case ITEM_STATE.ROUTED:
+ case ITEM_STATE.FETCHED:
+ case ITEM_STATE.UNPUBLISHED:
+ // eslint-disable-next-line no-case-declarations
+ const actions: Array> = [
+ minimizeButton,
+ closeButton,
+ ];
+
+ if (item.highlights != null) {
+ actions.push(getManageHighlights());
+ }
+
+ // eslint-disable-next-line no-case-declarations
+ const manageDesksButton: ITopBarWidget = ({
+ group: 'start',
+ priority: 0.3,
+ // eslint-disable-next-line react/display-name
+ component: () => (
+ <>
+
+
+
+ null}
+ id="marked-for-desks"
+ icon="bell"
+ iconSize="small"
+ />
+ >
+ ),
+ availableOffline: true,
+ });
+
+ if (item.marked_desks?.length > 0) {
+ actions.push(manageDesksButton);
+ }
+
+ actions.push({
+ group: 'start',
+ priority: 0.2,
+ component: ({entity}) => ,
+ availableOffline: false,
+ });
+
+ if (sdApi.article.showPublishAndContinue(item, hasUnsavedChanges())) {
+ actions.push({
+ group: 'middle',
+ priority: 0.3,
+ component: ({entity}) => (
+ {
+ const getLatestItem = hasUnsavedChanges()
+ ? handleUnsavedChanges()
+ : Promise.resolve(entity);
+
+ getLatestItem.then((article) => {
+ sdApi.article.publishItem(article, article).then((result) => {
+ typeof result !== 'boolean'
+ ? ng.get('authoring').rewrite(result)
+ : notify.error(gettext('Failed to publish and continue.'));
+ });
+ });
+ }}
+ text={gettext('P & C')}
+ style="filled"
+ />
+ ),
+ availableOffline: false,
+ });
+ }
+
+ if (sdApi.article.showCloseAndContinue(item, hasUnsavedChanges())) {
+ actions.push({
+ group: 'middle',
+ priority: 0.4,
+ component: ({entity}) => (
+ {
+ const getLatestItem = hasUnsavedChanges()
+ ? handleUnsavedChanges()
+ : Promise.resolve(entity);
+
+ getLatestItem.then((article) => {
+ ng.get('authoring').close().then(() => {
+ sdApi.article.rewrite(article);
+ });
+ });
+ }}
+ text={gettext('C & C')}
+ style="filled"
+ />
+ ),
+ availableOffline: false,
+ });
+ }
+
+ // FINISH: ensure locking is available in generic version of authoring
+ actions.push({
+ group: 'start',
+ priority: 0.1,
+ component: ({entity}) => (
+ {
+ stealLock();
+ }}
+ isLockedInOtherSession={(article) => sdApi.article.isLockedInOtherSession(article)}
+ />
+ ),
+ keyBindings: {
+ 'ctrl+shift+u': () => {
+ if (sdApi.article.isLockedInOtherSession(item)) {
+ stealLock();
+ }
+ },
+ },
+ availableOffline: false,
+ });
+
+ if (sdApi.article.isLockedInCurrentSession(item)) {
+ actions.push(saveButton);
+ }
+
+ if (
+ sdApi.article.isLockedInCurrentSession(item)
+ && appConfig.features.customAuthoringTopbar.toDesk === true
+ && sdApi.article.isPersonal(item) !== true
+ ) {
+ actions.push({
+ group: 'middle',
+ priority: 0.2,
+ component: () => (
+ {
+ handleUnsavedChanges()
+ .then(() => sdApi.article.sendItemToNextStage(item))
+ .then(() => initiateClosing());
+ }}
+ />
+ ),
+ availableOffline: false,
+ });
+ }
+
+ return {
+ readOnly: sdApi.article.isLockedInCurrentSession(item) !== true,
+ actions: actions,
+ };
+
+ case ITEM_STATE.INGESTED:
+ return {
+ readOnly: true,
+ actions: [], // fetch
+ };
+
+ case ITEM_STATE.SPIKED:
+ return {
+ readOnly: true,
+ actions: [], // un-spike
+ };
+
+ case ITEM_STATE.SCHEDULED:
+ return {
+ readOnly: true,
+ actions: [], // un-schedule
+ };
+
+ case ITEM_STATE.PUBLISHED:
+ case ITEM_STATE.CORRECTED:
+ return {
+ readOnly: true,
+ actions: [], // correct update kill takedown
+ };
+
+ case ITEM_STATE.BEING_CORRECTED:
+ return {
+ readOnly: true,
+ actions: [], // cancel correction
+ };
+
+ case ITEM_STATE.CORRECTION:
+ return {
+ readOnly: false,
+ actions: [], // cancel correction, save, publish
+ };
+
+ case ITEM_STATE.KILLED:
+ case ITEM_STATE.RECALLED:
+ return {
+ readOnly: true,
+ actions: [], // NONE
+ };
+ default:
+ assertNever(itemState);
+ }
+}
+
+function getPublishToolbarWidget(
+ panelState: IStateInteractiveActionsPanelHOC,
+ panelActions: IActionsInteractiveActionsPanelHOC,
+): ITopBarWidget {
+ const publishWidgetButton: ITopBarWidget = {
+ priority: 99,
+ availableOffline: false,
+ group: 'end',
+ // eslint-disable-next-line react/display-name
+ component: (props: {entity: IArticle}) => (
+
+
+ {
+ if (panelState.active) {
+ panelActions.closePanel();
+ } else {
+ const availableTabs: Array = [
+ 'send_to',
+ ];
+
+ const canPublish =
+ sdApi.article.canPublish(props.entity);
+
+ if (canPublish) {
+ availableTabs.push('publish');
+ }
+
+ dispatchInternalEvent('interactiveArticleActionStart', {
+ items: [props.entity],
+ tabs: availableTabs,
+ activeTab: canPublish ? 'publish' : availableTabs[0],
+ });
+ }
+ }}
+ />
+
+
+ ),
+ };
+
+ return publishWidgetButton;
+}
+
+export function getAuthoringPrimaryToolbarWidgets(
+ panelState: IStateInteractiveActionsPanelHOC,
+ panelActions: IActionsInteractiveActionsPanelHOC,
+) {
+ return Object.values(extensions)
+ .flatMap(({activationResult}) =>
+ activationResult?.contributions?.authoringTopbarWidgets ?? [],
+ )
+ .map((item): ITopBarWidget => {
+ const Component = item.component;
+
+ return {
+ ...item,
+ component: (props: {entity: IArticle}) => (
+
+ ),
+ };
+ })
+ .concat([getPublishToolbarWidget(panelState, panelActions)]);
+}
+
+export class AuthoringAngularIntegration extends React.PureComponent {
+ render(): React.ReactNode {
+ return (
+
+ );
+ }
+}
diff --git a/scripts/apps/authoring-react/authoring-angular-template-integration.tsx b/scripts/apps/authoring-react/authoring-angular-template-integration.tsx
new file mode 100644
index 0000000000..70839178f3
--- /dev/null
+++ b/scripts/apps/authoring-react/authoring-angular-template-integration.tsx
@@ -0,0 +1,71 @@
+import ng from 'core/services/ng';
+import React from 'react';
+import {ITemplate, IArticle, IAuthoringStorage, IAuthoringAutoSave} from 'superdesk-api';
+import {AuthoringIntegrationWrapper} from './authoring-integration-wrapper';
+import {getArticleContentProfile} from './data-layer';
+
+interface IProps {
+ template: ITemplate;
+ scopeApply(): void;
+}
+
+export class AuthoringAngularTemplateIntegration extends React.PureComponent {
+ render(): React.ReactNode {
+ return (
+
+
{
+ this.props.template.data = computeLatestEntity();
+ this.props.scopeApply();
+
+ return fieldsData;
+ }}
+ />
+
+ );
+ }
+}
+
+function getTemplateEditViewAuthoringStorage(article: IArticle): IAuthoringStorage {
+ class AutoSaveTemplate implements IAuthoringAutoSave {
+ get() {
+ return Promise.resolve(article);
+ }
+
+ delete() {
+ return Promise.resolve();
+ }
+
+ schedule(
+ getItem: () => IArticle,
+ callback: (autosaved: IArticle) => void,
+ ) {
+ callback(getItem());
+ }
+
+ cancel() {
+ // noop
+ }
+
+ flush(): Promise {
+ return Promise.resolve();
+ }
+ }
+
+ const authoringStorageTemplateEditView: IAuthoringStorage = {
+ autosave: new AutoSaveTemplate(),
+ getEntity: () => Promise.resolve({saved: article, autosaved: null}),
+ isLockedInCurrentSession: () => true,
+ forceLock: (entity) => Promise.resolve(entity),
+ saveEntity: (current) => Promise.resolve(current),
+ getContentProfile: (item, fieldsAdapter) => getArticleContentProfile(item, fieldsAdapter),
+ closeAuthoring: () => null, // no UI button; not possible to close since it's embedded in another view
+ getUserPreferences: () => ng.get('preferencesService').get(),
+ };
+
+ return authoringStorageTemplateEditView;
+}
diff --git a/scripts/apps/authoring-react/authoring-integration-wrapper.tsx b/scripts/apps/authoring-react/authoring-integration-wrapper.tsx
new file mode 100644
index 0000000000..d5e7db026c
--- /dev/null
+++ b/scripts/apps/authoring-react/authoring-integration-wrapper.tsx
@@ -0,0 +1,491 @@
+/* eslint-disable react/no-multi-comp */
+/* eslint-disable no-case-declarations */
+import React from 'react';
+import {Map} from 'immutable';
+import {Button, ButtonGroup, NavButton} from 'superdesk-ui-framework/react';
+import * as Nav from 'superdesk-ui-framework/react/components/Navigation';
+import {
+ IArticle,
+ IAuthoringAction,
+ IArticleSideWidget,
+ IContentProfileV2,
+ IExtensionActivationResult,
+ ITopBarWidget,
+ IExposedFromAuthoring,
+ IAuthoringStorage,
+ IFieldsAdapter,
+ IStorageAdapter,
+ IRestApiResponse,
+ IFieldsData,
+} from 'superdesk-api';
+import {AuthoringReact} from './authoring-react';
+import {getFieldsAdapter} from './field-adapters';
+import {dispatchCustomEvent} from 'core/get-superdesk-api-implementation';
+import {extensions} from 'appConfig';
+import {getArticleActionsFromExtensions} from 'core/superdesk-api-helpers';
+import {flatMap} from 'lodash';
+import {gettext} from 'core/utils';
+import {sdApi} from 'api';
+import {
+ IActionsInteractiveActionsPanelHOC,
+ IStateInteractiveActionsPanelHOC,
+ WithInteractiveArticleActionsPanel,
+} from 'core/interactive-article-actions-panel/index-hoc';
+import {InteractiveArticleActionsPanel} from 'core/interactive-article-actions-panel/index-ui';
+import {ISideBarTab} from 'superdesk-ui-framework/react/components/Navigation/SideBarTabs';
+import {CreatedModifiedInfo} from './subcomponents/created-modified-info';
+import {dispatchInternalEvent} from 'core/internal-events';
+import {IArticleActionInteractive} from 'core/interactive-article-actions-panel/interfaces';
+import {ARTICLE_RELATED_RESOURCE_NAMES} from 'core/constants';
+import {showModal} from '@superdesk/common';
+import {ExportModal} from './toolbar/export-modal';
+import {TranslateModal} from './toolbar/translate-modal';
+import {HighlightsModal} from './toolbar/highlights-modal';
+import {CompareArticleVersionsModal} from './toolbar/compare-article-versions';
+import {httpRequestJsonLocal} from 'core/helpers/network';
+import {getArticleAdapter} from './article-adapter';
+import {ui} from 'core/ui-utils';
+import {MultiEditToolbarAction} from './toolbar/multi-edit-toolbar-action';
+import {MarkForDesksModal} from './toolbar/mark-for-desks/mark-for-desks-modal';
+import {TemplateModal} from './toolbar/template-modal';
+
+function getAuthoringActionsFromExtensions(
+ item: IArticle,
+ contentProfile: IContentProfileV2,
+ fieldsData: Map,
+): Array {
+ const actionGetters
+ : Array
+ = flatMap(
+ Object.values(extensions),
+ (extension) => extension.activationResult.contributions?.getAuthoringActions ?? [],
+ );
+
+ return flatMap(
+ actionGetters.map((getPromise) => getPromise(item, contentProfile, fieldsData)),
+ );
+}
+
+const defaultToolbarItems: Array> = [CreatedModifiedInfo];
+
+interface IProps {
+ itemId: IArticle['_id'];
+}
+
+const getCompareVersionsModal = (
+ getLatestItem: () => IArticle,
+ authoringStorage: IAuthoringStorage,
+ fieldsAdapter: IFieldsAdapter,
+ storageAdapter: IStorageAdapter,
+): IAuthoringAction => ({
+ label: gettext('Compare versions'),
+ onTrigger: () => {
+ const article = getLatestItem();
+
+ Promise.all([
+ httpRequestJsonLocal>({
+ method: 'GET',
+ path: `/archive/${article._id}?version=all`,
+ }),
+ getArticleAdapter(),
+ ]).then(([res, adapter]) => {
+ const versions = res._items.map((item) => adapter.toAuthoringReact(item)).reverse();
+
+ if (versions.length <= 1) {
+ ui.alert(gettext('At least two versions are needed for comparison. This article has only one.'));
+ } else {
+ showModal(({closeModal}) => {
+ return (
+ article.language}
+ />
+ );
+ });
+ }
+ });
+ },
+});
+
+const getMultiEditModal = (getItem: () => IArticle): IAuthoringAction => ({
+ label: gettext('Multi-edit'),
+ onTrigger: () => {
+ showModal(({closeModal}) => (
+
+ ));
+ },
+});
+
+const getExportModal = (
+ getLatestItem: () => IArticle,
+ handleUnsavedChanges: () => Promise,
+ hasUnsavedChanges: () => boolean,
+): IAuthoringAction => ({
+ label: gettext('Export'),
+ onTrigger: () => {
+ const openModal = (article: IArticle) => showModal(({closeModal}) => {
+ return (
+
+ );
+ });
+
+ if (hasUnsavedChanges()) {
+ handleUnsavedChanges().then((article) => openModal(article));
+ } else {
+ openModal(getLatestItem());
+ }
+ },
+});
+
+const getHighlightsAction = (getItem: () => IArticle): IAuthoringAction => {
+ const showHighlightsModal = () => {
+ sdApi.highlights.fetchHighlights().then((res) => {
+ if (res._items.length === 0) {
+ ui.alert(gettext('No highlights have been created yet.'));
+ } else {
+ showModal(({closeModal}) => (
+
+ ));
+ }
+ });
+ };
+
+ return {
+ label: gettext('Highlights'),
+ onTrigger: () => showHighlightsModal(),
+ keyBindings: {
+ 'ctrl+shift+h': () => {
+ showHighlightsModal();
+ },
+ },
+ };
+};
+
+const getSaveAsTemplate = (getItem: () => IArticle): IAuthoringAction => ({
+ label: gettext('Save as template'),
+ onTrigger: () => (
+ showModal(({closeModal}) => {
+ return (
+
+ );
+ })
+ ),
+});
+
+const getTranslateModal = (getItem: () => IArticle): IAuthoringAction => ({
+ label: gettext('Translate'),
+ onTrigger: () => {
+ showModal(({closeModal}) => (
+
+ ));
+ },
+});
+
+const getMarkedForDesksModal = (getItem: () => IArticle): IAuthoringAction => ({
+ label: gettext('Marked for desks'),
+ onTrigger: () => (
+ showModal(({closeModal}) => {
+ return (
+
+ );
+ })
+ ),
+});
+
+interface IPropsWrapper extends IProps {
+ onClose?(): void;
+ getAuthoringPrimaryToolbarWidgets?: (
+ panelState: IStateInteractiveActionsPanelHOC,
+ panelActions: IActionsInteractiveActionsPanelHOC,
+ ) => Array>;
+ getInlineToolbarActions?(options: IExposedFromAuthoring): {
+ readOnly: boolean;
+ actions: Array>;
+ };
+
+ // Hides the toolbar which includes the "Print Preview" button.
+ hideSecondaryToolbar?: boolean;
+
+ // If it's not passed then the sidebar is shown expanded and can't be collapsed.
+ // If hidden is passed then it can't be expanded.
+ // If it's set to true or false then it can be collapsed/expanded back.
+ sidebarMode?: boolean | 'hidden';
+ authoringStorage: IAuthoringStorage;
+ onFieldChange?(fieldId: string, fieldsData: IFieldsData, computeLatestEntity: () => IArticle): IFieldsData;
+}
+
+/**
+ * The purpose of the wrapper is to handle integration with the angular part of the application.
+ * The main component will not know about angular.
+ */
+
+interface IState {
+ sidebarMode: boolean | 'hidden';
+ sideWidget: null | {
+ name: string;
+ pinned: boolean;
+ };
+}
+
+export class AuthoringIntegrationWrapper extends React.PureComponent {
+ private authoringReactRef: AuthoringReact | null;
+
+ constructor(props: IPropsWrapper) {
+ super(props);
+
+ this.state = {
+ sidebarMode: this.props.sidebarMode === 'hidden' ? 'hidden' : (this.props.sidebarMode ?? false),
+ sideWidget: null,
+ };
+
+ this.prepareForUnmounting = this.prepareForUnmounting.bind(this);
+ this.handleUnsavedChanges = this.handleUnsavedChanges.bind(this);
+ this.toggleSidebar = this.toggleSidebar.bind(this);
+ }
+
+ public toggleSidebar() {
+ if (typeof this.state.sidebarMode === 'boolean') {
+ this.setState({sidebarMode: !this.state.sidebarMode});
+ }
+ }
+
+ public isSidebarCollapsed() {
+ return this.state.sidebarMode != null;
+ }
+
+ public prepareForUnmounting() {
+ if (this.authoringReactRef == null) {
+ return Promise.resolve();
+ } else {
+ return this.authoringReactRef.initiateUnmounting();
+ }
+ }
+
+ public handleUnsavedChanges(): Promise {
+ if (this.authoringReactRef == null) {
+ return Promise.resolve();
+ } else if (this.authoringReactRef.state.initialized) {
+ return this.authoringReactRef.handleUnsavedChanges(this.authoringReactRef.state);
+ } else {
+ return Promise.reject();
+ }
+ }
+
+ render() {
+ function getWidgetsFromExtensions(article: IArticle): Array {
+ return Object.values(extensions)
+ .flatMap((extension) => extension.activationResult?.contributions?.authoringSideWidgets ?? [])
+ .filter((widget) => widget.isAllowed?.(article) ?? true)
+ .sort((a, b) => a.order - b.order);
+ }
+
+ const getSidebar = (options: IExposedFromAuthoring) => {
+ const sidebarTabs: Array = getWidgetsFromExtensions(options.item)
+ .map((widget) => {
+ const tab: ISideBarTab = {
+ icon: widget.icon,
+ size: 'big',
+ tooltip: widget.label,
+ id: widget.label,
+ };
+
+ return tab;
+ });
+
+ return (
+ {
+ this.setState({
+ sideWidget: {
+ name: val,
+ pinned: this.state.sideWidget?.pinned ?? false,
+ },
+ });
+ }}
+ items={sidebarTabs}
+ />
+ );
+ };
+
+ const secondaryToolbarWidgetsFromExtensions = Object.values(extensions)
+ .flatMap(({activationResult}) => activationResult?.contributions?.authoringTopbar2Widgets ?? []);
+
+ const secondaryToolbarWidgetsReady: Array> =
+ defaultToolbarItems.concat(secondaryToolbarWidgetsFromExtensions).map(
+ (Component) => (props: {item: IArticle}) => ,
+ );
+
+ return (
+
+ {(panelState, panelActions) => {
+ return (
+ {
+ this.authoringReactRef = component;
+ }}
+ itemId={this.props.itemId}
+ resourceNames={ARTICLE_RELATED_RESOURCE_NAMES}
+ onClose={() => this.props.onClose()}
+ authoringStorage={this.props.authoringStorage}
+ fieldsAdapter={getFieldsAdapter(this.props.authoringStorage)}
+ storageAdapter={{
+ storeValue: (value, fieldId, article) => {
+ return {
+ ...article,
+ extra: {
+ ...(article.extra ?? {}),
+ [fieldId]: value,
+ },
+ };
+ },
+ retrieveStoredValue: (item: IArticle, fieldId) => item.extra?.[fieldId] ?? null,
+ }}
+ getLanguage={(article) => article.language ?? 'en'}
+ onEditingStart={(article) => {
+ dispatchCustomEvent('articleEditStart', article);
+ }}
+ onEditingEnd={(article) => {
+ dispatchCustomEvent('articleEditEnd', article);
+ }}
+ getActions={({
+ item,
+ contentProfile,
+ fieldsData,
+ getLatestItem,
+ handleUnsavedChanges,
+ hasUnsavedChanges,
+ authoringStorage,
+ fieldsAdapter,
+ storageAdapter,
+ }) => {
+ const authoringActionsFromExtensions = getAuthoringActionsFromExtensions(
+ item,
+ contentProfile,
+ fieldsData,
+ );
+ const articleActionsFromExtensions = getArticleActionsFromExtensions(item);
+
+ return [
+ getSaveAsTemplate(getLatestItem),
+ getCompareVersionsModal(
+ getLatestItem,
+ authoringStorage,
+ fieldsAdapter,
+ storageAdapter,
+ ),
+ getMultiEditModal(getLatestItem),
+ getHighlightsAction(getLatestItem),
+ getMarkedForDesksModal(getLatestItem),
+ getExportModal(getLatestItem, handleUnsavedChanges, hasUnsavedChanges),
+ getTranslateModal(getLatestItem),
+ ...authoringActionsFromExtensions,
+ ...articleActionsFromExtensions,
+ ];
+ }}
+ getSidebarWidgetsCount={({item}) => getWidgetsFromExtensions(item).length}
+ sideWidget={this.state.sideWidget}
+ onSideWidgetChange={(sideWidget) => {
+ this.setState({sideWidget});
+ }}
+ getInlineToolbarActions={this.props.getInlineToolbarActions}
+ getAuthoringPrimaryToolbarWidgets={
+ this.props.getAuthoringPrimaryToolbarWidgets != null
+ ? () => this.props.getAuthoringPrimaryToolbarWidgets(panelState, panelActions)
+ : undefined
+ }
+ getSidePanel={({
+ item,
+ getLatestItem,
+ contentProfile,
+ fieldsData,
+ handleFieldsDataChange,
+ fieldsAdapter,
+ storageAdapter,
+ authoringStorage,
+ handleUnsavedChanges,
+ sideWidget,
+ onArticleChange,
+ }, readOnly) => {
+ const OpenWidgetComponent = (() => {
+ if (panelState.active === true) {
+ return () => (
+ handleUnsavedChanges().then((res) => [res])
+ }
+ onClose={panelActions.closePanel}
+ markupV2
+ />
+ );
+ } else if (sideWidget != null) {
+ return getWidgetsFromExtensions(item).find(
+ ({label}) => sideWidget === label,
+ ).component;
+ } else {
+ return null;
+ }
+ })();
+
+ if (OpenWidgetComponent == null) {
+ return null;
+ } else {
+ return (
+ handleUnsavedChanges()}
+ onArticleChange={onArticleChange}
+ />
+ );
+ }
+ }}
+ getSidebar={this.state.sidebarMode !== true ? null : getSidebar}
+ secondaryToolbarWidgets={secondaryToolbarWidgetsReady}
+ validateBeforeSaving={false}
+ getSideWidgetNameAtIndex={(article, index) => {
+ return getWidgetsFromExtensions(article)[index].label;
+ }}
+ />
+ );
+ }}
+
+ );
+ }
+}
diff --git a/scripts/apps/authoring-react/authoring-react-editor-events.ts b/scripts/apps/authoring-react/authoring-react-editor-events.ts
new file mode 100644
index 0000000000..a51e71e6ff
--- /dev/null
+++ b/scripts/apps/authoring-react/authoring-react-editor-events.ts
@@ -0,0 +1,75 @@
+import {EditorState} from 'draft-js';
+import {IArticle} from 'superdesk-api';
+
+interface IAuthoringReactEditorEvents {
+ find_and_replace__find: {
+ editorId: string;
+ text: string;
+ caseSensitive: boolean;
+ };
+
+ find_and_replace__request_for_current_selection_index: null;
+
+ find_and_replace__receive_current_selection_index: {
+ selectionIndex: number;
+ editorId: string;
+ };
+
+ find_and_replace__find_distinct: {
+ editorId: string;
+
+ // strings that we want to highlight in the editor
+ matches: Array;
+ caseSensitive: boolean;
+ };
+
+ find_and_replace__find_prev: {
+ editorId: string;
+ };
+ find_and_replace__find_next: {
+ editorId: string;
+ };
+
+ find_and_replace__replace: {
+ editorId: string;
+ replaceWith: string;
+ replaceAllMatches: boolean;
+ };
+
+ find_and_replace__multi_replace: {
+ editorId: string;
+ replaceWith: {[key: string]: string};
+ };
+
+ authoring__patch_html: {
+ editorId: string;
+ editorState: EditorState;
+ html: string;
+ };
+
+ authoring__update_editor_state: {
+ editorId: string;
+ article: IArticle;
+ };
+
+ spellchecker__request_status: null;
+ spellchecker__set_status: boolean;
+}
+
+export function addEditorEventListener(
+ eventName: T,
+ handler: (event: CustomEvent) => void,
+) {
+ window.addEventListener(eventName, handler);
+
+ return () => {
+ window.removeEventListener(eventName, handler);
+ };
+}
+
+export function dispatchEditorEvent(
+ eventName: T,
+ payload: IAuthoringReactEditorEvents[T],
+) {
+ window.dispatchEvent(new CustomEvent(eventName, {detail: payload}));
+}
diff --git a/scripts/apps/authoring-react/authoring-react.scss b/scripts/apps/authoring-react/authoring-react.scss
new file mode 100644
index 0000000000..0769249366
--- /dev/null
+++ b/scripts/apps/authoring-react/authoring-react.scss
@@ -0,0 +1,60 @@
+.sd-authoring-react {
+ position: absolute;
+ top: $nav-height;
+ left: 0;
+ width: 100%;
+ height: calc(100% - #{$nav-height} - #{$authoring-opened-articles});
+ overflow-y: auto;
+ z-index: 100;
+ background: $white;
+}
+
+.sd-authoring-react-template-edit-view {
+ padding: 1rem;
+}
+
+.authoring-toolbar-1 {
+ display: flex;
+ width: 100%;
+ overflow: auto;
+ justify-content: space-between;
+ gap: 16px;
+ align-items: center;
+ padding-left: 16px;
+
+ > * {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+
+ // allow desk/stage name to get truncated
+ &:first-child {
+ overflow: hidden;
+ }
+ }
+}
+
+.desk-and-stage {
+ display: flex;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+
+ font-size: 10px;
+ @include text-light();
+ letter-spacing: 0.035em;
+ line-height: 34px;
+ color: $gray;
+ text-transform: uppercase;
+}
+
+.desk-and-stage--desk,
+.desk-and-stage--stage {
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.desk-and-stage--desk {
+ color: $grayDark;
+ @include text-semibold();
+}
diff --git a/scripts/apps/authoring-react/authoring-react.tsx b/scripts/apps/authoring-react/authoring-react.tsx
new file mode 100644
index 0000000000..7e6c8d8228
--- /dev/null
+++ b/scripts/apps/authoring-react/authoring-react.tsx
@@ -0,0 +1,1421 @@
+import React from 'react';
+import {
+ IArticle,
+ IAuthoringFieldV2,
+ IContentProfileV2,
+ IAuthoringAction,
+ IVocabularyItem,
+ IAuthoringStorage,
+ IFieldsAdapter,
+ IBaseRestApiResponse,
+ IStorageAdapter,
+ IPropsAuthoring,
+ ITopBarWidget,
+ IExposedFromAuthoring,
+ IKeyBindings,
+ IAuthoringOptions,
+} from 'superdesk-api';
+import {
+ ButtonGroup,
+ Loader,
+ SubNav,
+ IconButton,
+} from 'superdesk-ui-framework/react';
+import * as Layout from 'superdesk-ui-framework/react/components/Layouts';
+import {gettext} from 'core/utils';
+import {AuthoringSection, IAuthoringSectionTheme} from './authoring-section/authoring-section';
+import {EditorTest} from './ui-framework-authoring-test';
+import {uiFrameworkAuthoringPanelTest, appConfig} from 'appConfig';
+import {widgetReactIntegration} from 'apps/authoring/widgets/widgets';
+import {AuthoringWidgetLayoutComponent} from './widget-layout-component';
+import {WidgetHeaderComponent} from './widget-header-component';
+import {registerToReceivePatches, unregisterFromReceivingPatches} from 'apps/authoring-bridge/receive-patches';
+import {addInternalEventListener} from 'core/internal-events';
+import {
+ showUnsavedChangesPrompt,
+ IUnsavedChangesActionWithSaving,
+} from 'core/ui/components/prompt-for-unsaved-changes';
+import {assertNever} from 'core/helpers/typescript-helpers';
+import {WithInteractiveArticleActionsPanel} from 'core/interactive-article-actions-panel/index-hoc';
+import {sdApi} from 'api';
+import {AuthoringToolbar} from './subcomponents/authoring-toolbar';
+import {addInternalWebsocketEventListener, addWebsocketEventListener} from 'core/notification/notification';
+import {AUTHORING_FIELD_PREFERENCES} from 'core/constants';
+import {AuthoringActionsMenu} from './subcomponents/authoring-actions-menu';
+import {Map} from 'immutable';
+import {getField} from 'apps/fields';
+import {preferences} from 'api/preferences';
+import {dispatchEditorEvent, addEditorEventListener} from './authoring-react-editor-events';
+import {previewAuthoringEntity} from './preview-article-modal';
+import {WithKeyBindings} from './with-keybindings';
+import {IFontSizeOption, ITheme, ProofreadingThemeModal} from './toolbar/proofreading-theme-modal';
+import {showModal} from '@superdesk/common';
+import ng from 'core/services/ng';
+
+export function getFieldsData(
+ item: T,
+ fields: Map,
+ fieldsAdapter: IFieldsAdapter,
+ authoringStorage: IAuthoringStorage,
+ storageAdapter: IStorageAdapter,
+ language: string,
+) {
+ return fields.map((field) => {
+ const fieldEditor = getField(field.fieldType);
+
+ const storageValue = (() => {
+ if (fieldsAdapter[field.id]?.retrieveStoredValue != null) {
+ return fieldsAdapter[field.id].retrieveStoredValue(item, authoringStorage);
+ } else {
+ return storageAdapter.retrieveStoredValue(item, field.id, field.fieldType);
+ }
+ })();
+
+ const operationalValue = (() => {
+ if (fieldEditor.toOperationalFormat != null) {
+ return fieldEditor.toOperationalFormat(
+ storageValue,
+ field.fieldConfig,
+ language,
+ );
+ } else {
+ return storageValue;
+ }
+ })();
+
+ return operationalValue;
+ }).toMap();
+}
+
+function serializeFieldsDataAndApplyOnEntity(
+ item: T,
+ fieldsProfile: Map,
+ fieldsData: Map,
+ userPreferencesForFields: {[key: string]: unknown},
+ fieldsAdapter: IFieldsAdapter,
+ storageAdapter: IStorageAdapter,
+): T {
+ let result: T = item;
+
+ fieldsProfile.forEach((field) => {
+ const fieldEditor = getField(field.fieldType);
+ const valueOperational = fieldsData.get(field.id);
+
+ const storageValue = (() => {
+ if (fieldEditor.toStorageFormat != null) {
+ return fieldEditor.toStorageFormat(
+ valueOperational,
+ field.fieldConfig,
+ );
+ } else {
+ return valueOperational;
+ }
+ })();
+
+ if (fieldsAdapter[field.id]?.storeValue != null) {
+ result = fieldsAdapter[field.id].storeValue(storageValue, result, field.fieldConfig);
+ } else {
+ result = storageAdapter.storeValue(storageValue, field.id, result, field.fieldConfig, field.fieldType);
+ }
+ });
+
+ return result;
+}
+
+const SPELLCHECKER_PREFERENCE = 'spellchecker:status';
+
+const ANPA_CATEGORY = {
+ vocabularyId: 'categories',
+ fieldId: 'anpa_category',
+};
+
+function getInitialState(
+ item: {saved: T; autosaved: T},
+ profile: IContentProfileV2,
+ userPreferencesForFields: IStateLoaded['userPreferencesForFields'],
+ spellcheckerEnabled: boolean,
+ fieldsAdapter: IFieldsAdapter,
+ authoringStorage: IAuthoringStorage,
+ storageAdapter: IStorageAdapter,
+ language: string,
+ validationErrors: IAuthoringValidationErrors,
+ defaultTheme: ITheme,
+ proofReadingTheme: ITheme,
+): IStateLoaded {
+ const allFields = profile.header.merge(profile.content);
+
+ const itemOriginal = item.saved;
+ const itemWithChanges = item.autosaved ?? itemOriginal;
+
+ const fieldsOriginal = getFieldsData(
+ itemOriginal,
+ allFields,
+ fieldsAdapter,
+ authoringStorage,
+ storageAdapter,
+ language,
+ );
+
+ const fieldsDataWithChanges: Map = itemOriginal === itemWithChanges
+ ? fieldsOriginal
+ : getFieldsData(
+ itemWithChanges,
+ allFields,
+ fieldsAdapter,
+ authoringStorage,
+ storageAdapter,
+ language,
+ );
+
+ const toggledFields = {};
+
+ allFields
+ .filter((field) => field.fieldConfig.allow_toggling === true)
+ .forEach((field) => {
+ const val = fieldsDataWithChanges.get(field.id);
+
+ const FieldEditorConfig = getField(field.fieldType);
+
+ toggledFields[field.id] = FieldEditorConfig.hasValue(val);
+ });
+
+ const initialState: IStateLoaded = {
+ initialized: true,
+ loading: false,
+ itemOriginal: itemOriginal,
+ itemWithChanges: itemWithChanges,
+ autosaveEtag: item.autosaved?._etag ?? null,
+ fieldsDataOriginal: fieldsOriginal,
+ fieldsDataWithChanges: fieldsDataWithChanges,
+ profile: profile,
+ toggledFields: toggledFields,
+ userPreferencesForFields,
+ spellcheckerEnabled,
+ validationErrors: validationErrors,
+ allThemes: {
+ default: defaultTheme,
+ proofreading: proofReadingTheme,
+ },
+ proofreadingEnabled: false,
+ };
+
+ return initialState;
+}
+
+function getKeyBindingsFromActions(actions: Array>): IKeyBindings {
+ return actions
+ .filter((action) => action.keyBindings != null)
+ .reduce((acc, action) => {
+ return {
+ ...acc,
+ ...action.keyBindings,
+ };
+ }, {});
+}
+
+export const getUiThemeFontSize = (value: IFontSizeOption) => {
+ if (value === 'small') {
+ return '1.4rem';
+ } else if (value === 'medium') {
+ return '1.6rem';
+ } else if (value === 'large') {
+ return '1.8rem';
+ } else {
+ assertNever(value);
+ }
+};
+
+export const getUiThemeFontSizeHeading = (value: IFontSizeOption) => {
+ if (value === 'small') {
+ return '2.3rem';
+ } else if (value === 'medium') {
+ return '2.8rem';
+ } else if (value === 'large') {
+ return '3.2rem';
+ } else {
+ assertNever(value);
+ }
+};
+
+/**
+ * Toggling a field "off" hides it and removes its values.
+ * Toggling to "on", displays field's input and allows setting a value.
+ *
+ * Only fields that have toggling enabled in content profile will be present in this object.
+ * `true` means field is available - `false` - hidden.
+ */
+export type IToggledFields = {[fieldId: string]: boolean};
+export type IAuthoringValidationErrors = {[fieldId: string]: string};
+
+interface IStateLoaded {
+ initialized: true;
+ itemOriginal: T;
+ itemWithChanges: T;
+ autosaveEtag: string | null;
+ fieldsDataOriginal: Map;
+ fieldsDataWithChanges: Map;
+ profile: IContentProfileV2;
+ userPreferencesForFields: {[key: string]: unknown};
+ toggledFields: IToggledFields;
+ spellcheckerEnabled: boolean;
+ validationErrors: IAuthoringValidationErrors;
+
+ /**
+ * Prevents changes to state while async operation is in progress(e.g. saving).
+ */
+ loading: boolean;
+ allThemes: {default: ITheme, proofreading: ITheme};
+ proofreadingEnabled: boolean;
+}
+
+type IState = {initialized: false} | IStateLoaded;
+
+export class AuthoringReact extends React.PureComponent, IState> {
+ private eventListenersToRemoveBeforeUnmounting: Array<() => void>;
+ private _mounted: boolean;
+
+ constructor(props: IPropsAuthoring) {
+ super(props);
+
+ this.state = {
+ initialized: false,
+ };
+
+ this.save = this.save.bind(this);
+ this.forceLock = this.forceLock.bind(this);
+ this.discardUnsavedChanges = this.discardUnsavedChanges.bind(this);
+ this.initiateClosing = this.initiateClosing.bind(this);
+ this.handleFieldChange = this.handleFieldChange.bind(this);
+ this.handleFieldsDataChange = this.handleFieldsDataChange.bind(this);
+ this.handleUnsavedChanges = this.handleUnsavedChanges.bind(this);
+ this.computeLatestEntity = this.computeLatestEntity.bind(this);
+ this.setUserPreferences = this.setUserPreferences.bind(this);
+ this.cancelAutosave = this.cancelAutosave.bind(this);
+ this.getVocabularyItems = this.getVocabularyItems.bind(this);
+ this.toggleField = this.toggleField.bind(this);
+ this.updateItemWithChanges = this.updateItemWithChanges.bind(this);
+ this.showThemeConfigModal = this.showThemeConfigModal.bind(this);
+ this.onArticleChange = this.onArticleChange.bind(this);
+
+ const setStateOriginal = this.setState.bind(this);
+
+ this.setState = (...args) => {
+ const {state} = this;
+
+ // disallow changing state while loading (for example when saving is in progress)
+ const allow: boolean = (() => {
+ if (state.initialized !== true) {
+ return true;
+ } else if (args[0]['loading'] === false) {
+ // it is allowed to change state while loading
+ // only if it sets loading to false
+ return true;
+ } else {
+ return state.loading === false;
+ }
+ })();
+
+ if (allow) {
+ setStateOriginal(...args);
+ }
+ };
+
+ widgetReactIntegration.pinWidget = () => {
+ this.props.onSideWidgetChange({
+ ...this.props.sideWidget,
+ pinned: !(this.props.sideWidget?.pinned ?? false),
+ });
+ };
+
+ widgetReactIntegration.getActiveWidget = () => {
+ return this.props.sideWidget?.name ?? null;
+ };
+
+ widgetReactIntegration.getPinnedWidget = () => {
+ const pinned = this.props.sideWidget?.pinned === true;
+
+ if (pinned) {
+ return this.props.sideWidget.name;
+ } else {
+ return null;
+ }
+ };
+
+ widgetReactIntegration.closeActiveWidget = () => {
+ this.props.onSideWidgetChange(null);
+ };
+
+ widgetReactIntegration.WidgetHeaderComponent = WidgetHeaderComponent;
+ widgetReactIntegration.WidgetLayoutComponent = AuthoringWidgetLayoutComponent;
+
+ widgetReactIntegration.disableWidgetPinning = props.disableWidgetPinning ?? false;
+
+ this.eventListenersToRemoveBeforeUnmounting = [];
+ }
+
+ initiateUnmounting(): Promise {
+ if (!this.state.initialized) {
+ return Promise.resolve();
+ } else {
+ return this.props.authoringStorage.autosave.flush();
+ }
+ }
+
+ cancelAutosave(): Promise {
+ const {authoringStorage} = this.props;
+
+ authoringStorage.autosave.cancel();
+
+ if (this.state.initialized && this.state.autosaveEtag != null) {
+ return authoringStorage.autosave.delete(this.state.itemOriginal['_id'], this.state.autosaveEtag);
+ } else {
+ return Promise.resolve();
+ }
+ }
+
+ private showThemeConfigModal(state: IStateLoaded) {
+ showModal(({closeModal}) => {
+ return (
+ {
+ this.setState({
+ ...state,
+ allThemes: {
+ default: res.default,
+ proofreading: res.proofreading,
+ },
+ });
+ }}
+ />
+ );
+ });
+ }
+
+ /**
+ * This is a relatively computationally expensive operation that serializes all fields.
+ * It is meant to be called when an article is to be saved/autosaved.
+ */
+ computeLatestEntity(): T {
+ const state = this.state;
+
+ if (state.initialized !== true) {
+ throw new Error('Authoring not initialized');
+ }
+
+ const allFields = state.profile.header.merge(state.profile.content);
+
+ const itemWithFieldsApplied = serializeFieldsDataAndApplyOnEntity(
+ state.itemWithChanges,
+ allFields,
+ state.fieldsDataWithChanges,
+ state.userPreferencesForFields,
+ this.props.fieldsAdapter,
+ this.props.storageAdapter,
+ );
+
+ return itemWithFieldsApplied;
+ }
+
+ handleFieldChange(fieldId: string, data: unknown) {
+ const {state} = this;
+
+ if (state.initialized !== true) {
+ throw new Error('can not change field value when authoring is not initialized');
+ }
+
+ const {onFieldChange} = this.props;
+ const fieldsDataUpdated = state.fieldsDataWithChanges.set(fieldId, data);
+
+ this.setState({
+ ...state,
+ fieldsDataWithChanges: onFieldChange == null
+ ? fieldsDataUpdated
+ : onFieldChange(fieldId, fieldsDataUpdated, this.computeLatestEntity),
+ });
+ }
+
+ handleFieldsDataChange(fieldsData: Map): void {
+ const {state} = this;
+
+ if (state.initialized) {
+ this.setState({
+ ...state,
+ fieldsDataWithChanges: fieldsData,
+ });
+ }
+ }
+
+ hasUnsavedChanges() {
+ if (this.state.initialized) {
+ return (this.state.itemOriginal !== this.state.itemWithChanges)
+ || (this.state.fieldsDataOriginal !== this.state.fieldsDataWithChanges);
+ } else {
+ return false;
+ }
+ }
+
+ getVocabularyItems(vocabularyId): Array {
+ const vocabulary = sdApi.vocabularies.getAll().get(vocabularyId);
+
+ if (vocabularyId === ANPA_CATEGORY.vocabularyId) {
+ return vocabulary.items;
+ }
+
+ const anpaCategoryQcodes: Array = this.state.initialized ?
+ (this.state.fieldsDataWithChanges.get(ANPA_CATEGORY.fieldId) as Array ?? [])
+ : [];
+
+ if (vocabulary.service == null || vocabulary.service?.all != null) {
+ return vocabulary.items.filter(
+ (vocabularyItem) => {
+ if (vocabularyItem.service == null) {
+ return true;
+ } else {
+ return anpaCategoryQcodes.some((qcode) => vocabularyItem.service[qcode] != null);
+ }
+ },
+ );
+ } else if (anpaCategoryQcodes.some((qcode) => vocabulary.service?.[qcode] != null)) {
+ return vocabulary.items;
+ } else {
+ return [];
+ }
+ }
+
+ componentDidMount() {
+ const authThemes = ng.get('authThemes');
+
+ this._mounted = true;
+
+ const {authoringStorage} = this.props;
+
+ Promise.all(
+ [
+ authoringStorage.getEntity(this.props.itemId).then((item) => {
+ const itemCurrent = item.autosaved ?? item.saved;
+
+ return authoringStorage.getContentProfile(itemCurrent, this.props.fieldsAdapter).then((profile) => {
+ return {item, profile};
+ });
+ }),
+ authoringStorage.getUserPreferences(),
+ authThemes.get('theme'),
+ authThemes.get('proofreadTheme'),
+ ],
+ ).then((res) => {
+ const [{item, profile}, userPreferences, defaultTheme, proofReadingTheme] = res;
+
+ const spellcheckerEnabled =
+ userPreferences[SPELLCHECKER_PREFERENCE].enabled
+ ?? userPreferences[SPELLCHECKER_PREFERENCE].default
+ ?? true;
+
+ const initialState = getInitialState(
+ item,
+ profile,
+ userPreferences[AUTHORING_FIELD_PREFERENCES] ?? {},
+ spellcheckerEnabled,
+ this.props.fieldsAdapter,
+ this.props.authoringStorage,
+ this.props.storageAdapter,
+ this.props.getLanguage(item.autosaved ?? item.saved),
+ {},
+ defaultTheme,
+ proofReadingTheme,
+ );
+
+ this.props.onEditingStart?.(initialState.itemWithChanges);
+
+ this.setState(initialState);
+ });
+
+ registerToReceivePatches(this.props.itemId, (patch) => {
+ const {state} = this;
+
+ if (state.initialized) {
+ this.setState({
+ ...state,
+ itemWithChanges: {
+ ...state.itemWithChanges,
+ ...patch,
+ },
+ });
+ }
+ });
+
+ this.eventListenersToRemoveBeforeUnmounting.push(addEditorEventListener('spellchecker__request_status', () => {
+ if (this.state.initialized) {
+ dispatchEditorEvent('spellchecker__set_status', this.state.spellcheckerEnabled);
+ }
+ }));
+
+ this.eventListenersToRemoveBeforeUnmounting.push(
+ addInternalEventListener(
+ 'replaceAuthoringDataWithChanges',
+ (event) => {
+ const {state} = this;
+ const article = event.detail;
+
+ if (state.initialized) {
+ this.setState(this.updateItemWithChanges(state, article));
+ }
+ },
+ ),
+ );
+
+ this.eventListenersToRemoveBeforeUnmounting.push(
+ addInternalEventListener(
+ 'dangerouslyOverwriteAuthoringData',
+ (event) => {
+ if (event.detail._id === this.props.itemId) {
+ const patch = event.detail;
+
+ const {state} = this;
+
+ if (state.initialized) {
+ if (state.itemOriginal === state.itemWithChanges) {
+ /**
+ * if object references are the same before patching
+ * they should be the same after patching too
+ * in order for checking for changes to work correctly
+ * (reference equality is used for change detection)
+ */
+
+ const patched = {
+ ...state.itemOriginal,
+ ...patch,
+ };
+
+ this.setState({
+ ...state,
+ itemOriginal: patched,
+ itemWithChanges: patched,
+ });
+ } else {
+ this.setState({
+ ...state,
+ itemWithChanges: {
+ ...state.itemWithChanges,
+ ...patch,
+ },
+ itemOriginal: {
+ ...state.itemOriginal,
+ ...patch,
+ },
+ });
+ }
+ }
+ }
+ },
+ ),
+ );
+
+ /**
+ * Update UI when locked in another session,
+ * regardless whether by same or different user.
+ */
+ this.eventListenersToRemoveBeforeUnmounting.push(
+ addInternalWebsocketEventListener('item:lock', (data) => {
+ const {user, lock_session, lock_time, _etag} = data.extra;
+
+ const state = this.state;
+
+ if (state.initialized && (state.itemOriginal._id === data.extra.item)) {
+ /**
+ * Only patch these fields to preserve
+ * unsaved changes.
+ * FINISH: remove IArticle usage
+ */
+ const patch: Partial = {
+ _etag,
+ lock_session,
+ lock_time,
+ lock_user: user,
+ lock_action: 'edit',
+ };
+
+ if (!this.hasUnsavedChanges()) {
+ /**
+ * if object references are the same before patching
+ * they should be the same after patching too
+ * in order for checking for changes to work correctly
+ * (reference equality is used for change detection)
+ */
+
+ const patched = {
+ ...state.itemOriginal,
+ ...patch,
+ };
+
+ this.setState({
+ ...state,
+ itemOriginal: patched,
+ itemWithChanges: patched,
+ });
+ } else {
+ this.setState({
+ ...state,
+ itemOriginal: {
+ ...state.itemOriginal,
+ ...patch,
+ },
+ itemWithChanges: {
+ ...state.itemWithChanges,
+ ...patch,
+ },
+ });
+ }
+ }
+ }),
+ );
+
+ /**
+ * Reload item if updated while locked in another session.
+ * Unless there are unsaved changes.
+ */
+ this.eventListenersToRemoveBeforeUnmounting.push(
+ addWebsocketEventListener('resource:updated', (event) => {
+ const {_id, resource} = event.extra;
+ const state = this.state;
+
+ if (state.initialized !== true) {
+ return;
+ }
+
+ if (
+ this.props.resourceNames.includes(resource) !== true
+ || state.itemOriginal._id !== _id
+ ) {
+ return;
+ }
+
+ if (authoringStorage.isLockedInCurrentSession(state.itemOriginal)) {
+ return;
+ }
+
+ if (this.hasUnsavedChanges()) {
+ return;
+ }
+
+ authoringStorage.getEntity(state.itemOriginal._id).then((item) => {
+ this.setState(getInitialState(
+ item,
+ state.profile,
+ state.userPreferencesForFields,
+ state.spellcheckerEnabled,
+ this.props.fieldsAdapter,
+ this.props.authoringStorage,
+ this.props.storageAdapter,
+ this.props.getLanguage(item.autosaved ?? item.saved),
+ state.validationErrors,
+ state.allThemes.default,
+ state.allThemes.proofreading,
+ ));
+ });
+ }),
+ );
+
+ this.eventListenersToRemoveBeforeUnmounting.push(
+ addInternalEventListener('dangerouslyForceReloadAuthoring', () => {
+ const state = this.state;
+
+ if (state.initialized !== true) {
+ return;
+ }
+
+ authoringStorage.getEntity(state.itemOriginal._id).then((item) => {
+ this.setState(getInitialState(
+ item,
+ state.profile,
+ state.userPreferencesForFields,
+ state.spellcheckerEnabled,
+ this.props.fieldsAdapter,
+ this.props.authoringStorage,
+ this.props.storageAdapter,
+ this.props.getLanguage(item.autosaved ?? item.saved),
+ state.validationErrors,
+ state.allThemes.default,
+ state.allThemes.proofreading,
+ ));
+ });
+ }),
+ );
+ }
+
+ componentWillUnmount() {
+ this._mounted = false;
+
+ const state = this.state;
+
+ if (state.initialized) {
+ this.props.onEditingEnd?.(state.itemWithChanges);
+ }
+
+ unregisterFromReceivingPatches();
+
+ for (const fn of this.eventListenersToRemoveBeforeUnmounting) {
+ fn();
+ }
+ }
+
+ componentDidUpdate(_prevProps, prevState: IState) {
+ const {authoringStorage} = this.props;
+ const state = this.state;
+
+ if (
+ state.initialized
+ && prevState.initialized
+ && authoringStorage.isLockedInCurrentSession(state.itemOriginal)
+ ) {
+ const articleChanged = (state.itemWithChanges !== prevState.itemWithChanges)
+ || (state.fieldsDataWithChanges !== prevState.fieldsDataWithChanges);
+
+ if (articleChanged) {
+ if (this.hasUnsavedChanges()) {
+ authoringStorage.autosave.schedule(
+ () => {
+ return this.computeLatestEntity();
+ },
+ (autosaved) => {
+ this.setState({
+ ...state,
+ autosaveEtag: autosaved._etag,
+ });
+ },
+ );
+ }
+ }
+ }
+ }
+
+ handleUnsavedChanges(state: IStateLoaded): Promise {
+ return new Promise((resolve, reject) => {
+ if (!this.hasUnsavedChanges()) {
+ resolve(state.itemOriginal);
+ return;
+ }
+
+ return showUnsavedChangesPrompt(true).then(({action, closePromptFn}) => {
+ if (action === IUnsavedChangesActionWithSaving.cancelAction) {
+ closePromptFn();
+ reject();
+ } else if (action === IUnsavedChangesActionWithSaving.discardChanges) {
+ this.discardUnsavedChanges(state).then(() => {
+ closePromptFn();
+
+ if (this.state.initialized) {
+ resolve(this.state.itemOriginal);
+ }
+ });
+ } else if (action === IUnsavedChangesActionWithSaving.save) {
+ this.save(state).then(() => {
+ closePromptFn();
+
+ if (this.state.initialized) {
+ resolve(this.state.itemOriginal);
+ }
+ });
+ } else {
+ assertNever(action);
+ }
+ });
+ });
+ }
+
+ save(state: IStateLoaded