diff --git a/package-lock.json b/package-lock.json index e722ed2eaa..d6168ad806 100644 --- a/package-lock.json +++ b/package-lock.json @@ -818,7 +818,7 @@ }, "@typescript-eslint/parser": { "version": "5.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.57.0.tgz", + "resolved": "https://verdaccio.sourcefabric.org/@typescript-eslint/parser/-/parser-5.57.0.tgz", "integrity": "sha512-orrduvpWYkgLCyAdNtR1QIWovcNZlEm6yL8nwH/eTxWLd8gsP+25pdLHYzL2QdkqrieaDwLpytHqycncv0woUQ==", "requires": { "@typescript-eslint/scope-manager": "5.57.0", @@ -829,7 +829,7 @@ "dependencies": { "debug": { "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "resolved": "https://verdaccio.sourcefabric.org/debug/-/debug-4.3.4.tgz", "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", "requires": { "ms": "2.1.2" @@ -1315,7 +1315,7 @@ "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" }, "prop-types": { "version": "15.8.1", @@ -8473,7 +8473,7 @@ "jsesc": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", - "integrity": "sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==" + "integrity": "sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0=" }, "json-loader": { "version": "0.5.7", @@ -12028,7 +12028,7 @@ "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" } } }, @@ -12523,7 +12523,7 @@ "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" }, "prop-types": { "version": "15.7.2", @@ -12810,7 +12810,7 @@ "regexpu-core": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-1.0.0.tgz", - "integrity": "sha512-Ci+lDRlvAElKjFp5keqmVUaJLqZiHywekXhshT6wVUyDObGPdymNPhxBmf38ZVsaUGOnZ3Fot9YzxvoI31ymYw==", + "integrity": "sha1-hqdj9Y7k18L2sQLkdkBQ3n7ZDGs=", "requires": { "regenerate": "^1.2.1", "regjsgen": "^0.2.0", @@ -12820,12 +12820,12 @@ "regjsgen": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.2.0.tgz", - "integrity": "sha512-x+Y3yA24uF68m5GA+tBjbGYo64xXVJpbToBaWCoSNSc1hdk6dfctaRWrNFTVJZIIhL5GxW8zwjoixbnifnK59g==" + "integrity": "sha1-bAFq3qxVT3WCP+N6wFuS1aTtsfc=" }, "regjsparser": { "version": "0.1.5", "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.1.5.tgz", - "integrity": "sha512-jlQ9gYLfk2p3V5Ag5fYhA7fv7OHzd1KUH0PRP46xc3TgwjwgROIW572AfYg/X9kaNq/LJnu6oJcFRXlIrGoTRw==", + "integrity": "sha1-fuj4Tcb6eS0/0K4ijSS9lJ6tIFw=", "requires": { "jsesc": "~0.5.0" } diff --git a/scripts/api/article-patch.ts b/scripts/api/article-patch.ts index d09960adb7..c10c481fc0 100644 --- a/scripts/api/article-patch.ts +++ b/scripts/api/article-patch.ts @@ -34,7 +34,7 @@ export const patchArticle = ( if (dangerousOptions?.patchDirectlyAndOverwriteAuthoringValues === true) { dispatchInternalEvent( 'dangerouslyOverwriteAuthoringData', - {...patch, _etag: res._etag, _id: res._id}, + {item: {...patch, _etag: res._etag, _id: res._id}}, ); } }); diff --git a/scripts/apps/authoring-react/authoring-integration-wrapper.tsx b/scripts/apps/authoring-react/authoring-integration-wrapper.tsx index b54f719236..05b1bb19fd 100644 --- a/scripts/apps/authoring-react/authoring-integration-wrapper.tsx +++ b/scripts/apps/authoring-react/authoring-integration-wrapper.tsx @@ -260,9 +260,11 @@ export class AuthoringIntegrationWrapper extends React.PureComponent extends React.PureCo addInternalEventListener( 'dangerouslyOverwriteAuthoringData', (event) => { - if (event.detail._id === this.props.itemId) { - const patch = event.detail; + if (event.detail.item._id === this.props.itemId) { + const patch = event.detail.item; const {state} = this; @@ -709,6 +709,53 @@ export class AuthoringReact extends React.PureCo }), ); + this.cleanupFunctionsToRunBeforeUnmounting.push( + addInternalEventListener( + 'dangerouslyOverwriteAuthoringField', + (event) => { + if (event.detail.itemId === this.props.itemId) { + const patch = {[event.detail.field.key]: event.detail.field.value}; + + 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, + }, + }); + } + } + } + }, + ), + ); + /** * Reload item if updated while locked in another session. * Unless there are unsaved changes. diff --git a/scripts/apps/authoring-react/toolbar/translate-modal.tsx b/scripts/apps/authoring-react/toolbar/translate-modal.tsx index 541c994f84..6c00d80dd0 100644 --- a/scripts/apps/authoring-react/toolbar/translate-modal.tsx +++ b/scripts/apps/authoring-react/toolbar/translate-modal.tsx @@ -3,8 +3,12 @@ import {sdApi} from 'api'; import {Spacer} from 'core/ui/components/Spacer'; import {gettext} from 'core/utils'; import {httpRequestJsonLocal} from 'core/helpers/network'; -import {IArticle, IRestApiResponse, ITranslation} from 'superdesk-api'; +import {IArticle, IExtensionActivationResult, IRestApiResponse, ITranslation} from 'superdesk-api'; import {Button, Modal, Option, Select} from 'superdesk-ui-framework/react'; +import {notify} from 'core/notify/notify'; +import {extensions} from 'appConfig'; +import {flatMap} from 'lodash'; +import ng from 'core/services/ng'; interface IProps { article: IArticle; @@ -51,7 +55,7 @@ export class TranslateModal extends React.PureComponent { translate() { if (this.state.initialized) { - return httpRequestJsonLocal({ + return httpRequestJsonLocal({ method: 'POST', path: '/archive/translate', payload: { @@ -59,7 +63,25 @@ export class TranslateModal extends React.PureComponent { language: this.state.selectedLanguage, guid: this.props.article._id, }, - }).then(() => this.props.closeModal()); + }).then((item) => { + const onTranslateAfterMiddlewares + : Array + = flatMap( + Object.values(extensions).map(({activationResult}) => activationResult), + (activationResult) => activationResult?.contributions?.entities?.article?.onTranslateAfter ?? [], + ); + + if (onTranslateAfterMiddlewares.length > 0) { + onTranslateAfterMiddlewares.forEach((fn) => { + fn(this.props.article, item); + }); + } else { + ng.get('authoringWorkspace').open(item); + notify.success(gettext('Item Translated')); + } + + this.props.closeModal(); + }); } } diff --git a/scripts/apps/authoring/authoring/directives/AuthoringDirective.ts b/scripts/apps/authoring/authoring/directives/AuthoringDirective.ts index 6ce77ef643..350c5cb09f 100644 --- a/scripts/apps/authoring/authoring/directives/AuthoringDirective.ts +++ b/scripts/apps/authoring/authoring/directives/AuthoringDirective.ts @@ -102,6 +102,7 @@ export function AuthoringDirective( const MEDIA_TYPES = ['video', 'picture', 'audio']; const isPersonalSpace = $location.path() === '/workspace/personal'; + $scope.eventListenersToRemoveOnUnmount = []; $scope.toDeskEnabled = false; // Send an Item to a desk $scope.closeAndContinueEnabled = false; // Create an update of an item and Close the item. $scope.publishEnabled = false; // publish an item @@ -1033,21 +1034,42 @@ export function AuthoringDirective( } }); - const removeListener = addInternalEventListener( - 'dangerouslyOverwriteAuthoringData', - (event) => { - if (event.detail._id === $scope.item._id) { - angular.extend($scope.item, event.detail); - angular.extend($scope.origItem, event.detail); - $scope.$apply(); - $scope.refresh(); - } - }, + $scope.eventListenersToRemoveOnUnmount.push( + addInternalEventListener( + 'dangerouslyOverwriteAuthoringData', + (event) => { + if (event.detail.item._id === $scope.item._id) { + angular.extend($scope.item, event.detail.item); + angular.extend($scope.origItem, event.detail.item); + + $scope.$applyAsync(); + $scope.refresh(); + } + }, + ), + ); + + $scope.eventListenersToRemoveOnUnmount.push( + addInternalEventListener( + 'dangerouslyOverwriteAuthoringField', + (event) => { + if (event.detail.itemId === $scope.item._id) { + angular.extend($scope.item, {[event.detail.field.key]: event.detail.field.value}); + + $scope.dirty = true; + $scope.$applyAsync(); + $scope.refresh(); + } + }, + ), ); $scope.$on('$destroy', () => { deregisterTansa(); - removeListener(); + + for (const fn of $scope.eventListenersToRemoveOnUnmount) { + fn(); + } }); var initEmbedFieldsValidation = () => { diff --git a/scripts/apps/authoring/widgets/widgets.ts b/scripts/apps/authoring/widgets/widgets.ts index 426016dbdd..7c46afdab0 100644 --- a/scripts/apps/authoring/widgets/widgets.ts +++ b/scripts/apps/authoring/widgets/widgets.ts @@ -13,6 +13,9 @@ const USER_PREFERENCE_SETTINGS = 'editor:pinned_widget'; let PINNED_WIDGET_RESIZED = false; +export let IS_WIDGET_PINNED = false; +export const SIDE_WIDGET_WIDTH = 330; + interface IWidget { label?: string; icon?: string; @@ -157,7 +160,13 @@ function WidgetsManagerCtrl( preferencesService, $rootScope, ) { - $scope.active = null; + const localStorageWidget = localStorage.getItem('SIDE_WIDGET'); + const localStorageWidgetState = localStorageWidget != null ? JSON.parse(localStorageWidget) : null; + const widgetValue = localStorageWidget == null + ? null + : authoringWidgets.find((widget) => widget._id === localStorageWidgetState?.id); + + $scope.active = widgetValue; preferencesService.get(USER_PREFERENCE_SETTINGS).then((preferences) => this.widgetFromPreferences = preferences, @@ -306,13 +315,13 @@ function WidgetsManagerCtrl( } if (!PINNED_WIDGET_RESIZED && widget && !$scope.pinnedWidget) { - $rootScope.$broadcast('resize:monitoring', -330); + $rootScope.$broadcast('resize:monitoring', -SIDE_WIDGET_WIDTH); PINNED_WIDGET_RESIZED = true; } if (!widget || $scope.pinnedWidget === widget) { - $rootScope.$broadcast('resize:monitoring', 330); + $rootScope.$broadcast('resize:monitoring', SIDE_WIDGET_WIDTH); angular.element('body').removeClass('main-section--pinned-tabs'); @@ -333,6 +342,8 @@ function WidgetsManagerCtrl( this.updateUserPreferences(widget); } + + IS_WIDGET_PINNED = $scope.pinnedWidget?.pinned ?? false; }; widgetReactIntegration.pinWidget = $scope.pinWidget; @@ -392,6 +403,10 @@ function WidgetsManagerCtrl( }); }; + if (widgetValue?.component != null && localStorageWidgetState?.pinned === true) { + $scope.pinWidget(widgetValue); + } + $scope.$on('$destroy', () => { unbindAllShortcuts(); }); diff --git a/scripts/apps/dashboard/widget-react.tsx b/scripts/apps/dashboard/widget-react.tsx index 41cd66b539..a8c7f5ede2 100644 --- a/scripts/apps/dashboard/widget-react.tsx +++ b/scripts/apps/dashboard/widget-react.tsx @@ -20,6 +20,19 @@ export class WidgetReact extends React.PureComponent { { + const localStorageWidgetState = JSON.parse(localStorage.getItem('SIDE_WIDGET') ?? 'null'); + + if (localStorageWidgetState?.id === this.props.widget._id) { + const initialState = localStorageWidgetState?.initialState; + + localStorage.removeItem('SIDE_WIDGET'); + + return initialState; + } else { + return undefined; + } + })()} // below props are only relevant for authoring-react readOnly={undefined} diff --git a/scripts/apps/translations/services/TranslationService.ts b/scripts/apps/translations/services/TranslationService.ts index e6c08706d9..443de6119c 100644 --- a/scripts/apps/translations/services/TranslationService.ts +++ b/scripts/apps/translations/services/TranslationService.ts @@ -1,6 +1,9 @@ -import _ from 'lodash'; -import {gettext} from 'core/utils'; +import _, {flatMap} from 'lodash'; import {AuthoringWorkspaceService} from 'apps/authoring/authoring/services/AuthoringWorkspaceService'; +import {gettext} from 'core/utils'; +import {extensions} from 'appConfig'; +import {IExtensionActivationResult} from 'superdesk-api'; +import ng from 'core/services/ng'; /** * @ngdoc service @@ -64,9 +67,23 @@ export function TranslationService( }; api.save('translate', params).then((_item) => { - authoringWorkspace.open(_item); + const onTranslateAfterMiddlewares + : Array + = flatMap( + Object.values(extensions).map(({activationResult}) => activationResult), + (activationResult) => activationResult?.contributions?.entities?.article?.onTranslateAfter ?? [], + ); + + if (onTranslateAfterMiddlewares.length > 0) { + onTranslateAfterMiddlewares.forEach((fn) => { + fn(item, _item); + }); + } else { + ng.get('authoringWorkspace').open(item); + notify.success(gettext('Item Translated')); + } + $rootScope.$broadcast('item:translate'); - notify.success(gettext('Item Translated')); }); }; diff --git a/scripts/core/get-superdesk-api-implementation.tsx b/scripts/core/get-superdesk-api-implementation.tsx index 67c4cba8e3..94991d104f 100644 --- a/scripts/core/get-superdesk-api-implementation.tsx +++ b/scripts/core/get-superdesk-api-implementation.tsx @@ -9,6 +9,7 @@ import { IUser, IBaseRestApiResponse, IPatchResponseExtraFields, + IOpenSideWidget, } from 'superdesk-api'; import { gettext, @@ -123,9 +124,17 @@ function getContentType(id): Promise { return dataApi.findOne('content_types', id); } -export function openArticle(id: IArticle['_id'], mode: 'view' | 'edit' | 'edit-new-window'): Promise { +export function openArticle( + id: IArticle['_id'], + mode: 'view' | 'edit' | 'edit-new-window', + openSideWidget?: IOpenSideWidget, +): Promise { const authoringWorkspace = ng.get('authoringWorkspace'); + if (openSideWidget?.id != null) { + localStorage.setItem('SIDE_WIDGET', JSON.stringify(openSideWidget)); + } + if (mode === 'edit-new-window') { authoringWorkspace.popupFromId(id, 'view'); } else { @@ -366,9 +375,24 @@ export function getSuperdeskApiImplementation( view: (id: IArticle['_id']) => { openArticle(id, 'view'); }, + edit: ( + id: IArticle['_id'], + openSideWidget?: IOpenSideWidget, + ) => { + openArticle(id, 'edit', openSideWidget); + }, addImage: (field: string, image: IArticle) => { dispatchInternalEvent('addImage', {field, image}); }, + applyFieldChangesToEditor: ( + itemId: IArticle['_id'], + field: {key: string, value: valueof}, + ) => { + dispatchInternalEvent('dangerouslyOverwriteAuthoringField', { + field, + itemId, + }); + }, save: () => { dispatchInternalEvent('saveArticleInEditMode', null); }, diff --git a/scripts/core/internal-events.ts b/scripts/core/internal-events.ts index e94215f789..0ada6b89d5 100644 --- a/scripts/core/internal-events.ts +++ b/scripts/core/internal-events.ts @@ -7,7 +7,11 @@ interface IInternalEvents { image: IArticle; }; saveArticleInEditMode: void; - dangerouslyOverwriteAuthoringData: Partial; + dangerouslyOverwriteAuthoringData: {item: Partial;}; + dangerouslyOverwriteAuthoringField: { + itemId: IArticle['_id']; + field: {key: string, value: valueof}; + }; replaceAuthoringDataWithChanges: Partial; /** diff --git a/scripts/core/superdesk-api.d.ts b/scripts/core/superdesk-api.d.ts index 0a8b622df1..52adfecacb 100644 --- a/scripts/core/superdesk-api.d.ts +++ b/scripts/core/superdesk-api.d.ts @@ -606,6 +606,8 @@ declare module 'superdesk-api' { getLatestArticle: IExposedFromAuthoring['getLatestItem']; + initialState?: any; + // other props below are specific to authoring-react implementation readOnly: boolean; @@ -753,6 +755,7 @@ declare module 'superdesk-api' { onPublish?(item: IArticle): Promise; onRewriteAfter?(item: IArticle): Promise; onSendBefore?(items: Array, desk: IDesk): Promise; + onTranslateAfter?(original: IArticle, translation: IArticle): void; }; ingest?: { ruleHandlers?: {[key: string]: IIngestRuleHandlerExtension}; @@ -2767,6 +2770,12 @@ declare module 'superdesk-api' { undefinedEqNull: boolean; } + export interface IOpenSideWidget { + id: string; + pinned?: boolean; + initialState?: any; + } + export type ISuperdesk = DeepReadonly<{ dataApi: IDataApi, dataApiByEntity: { @@ -2793,9 +2802,17 @@ declare module 'superdesk-api' { article: { view(id: IArticle['_id']): void; + edit( + id: IArticle['_id'], + openSideWidget?: IOpenSideWidget, + ): void; // This isn't implemented for all fields accepting images. addImage(field: string, image: IArticle): void; + // itemId is passed for safety, changes would only apply if + // the function is called when the given article is open in authoring. + // TODO: Drop this function when authoring angular is removed; tag: authoringReactViewEnabled + applyFieldChangesToEditor(itemId: IArticle['_id'], field: {key: string, value: valueof}): void; /** * Programmatically triggers saving of an article in edit mode. * Runs the same code as if "save" button was clicked manually. diff --git a/scripts/core/ui/ui.ts b/scripts/core/ui/ui.ts index 645816c9ec..48b694ec50 100644 --- a/scripts/core/ui/ui.ts +++ b/scripts/core/ui/ui.ts @@ -13,6 +13,7 @@ import {TextAreaInput} from './components/Form'; import {PlainTextEditor} from './components/PlainTextEditor/PlainTextEditor'; import {getTimezoneLabel} from 'apps/dashboard/world-clock/timezones-all-labels'; import {FormattingOptionsTreeSelect} from 'apps/workspace/content/views/FormattingOptionsMultiSelect'; +import {IS_WIDGET_PINNED, SIDE_WIDGET_WIDTH} from 'apps/authoring/widgets/widgets'; /** * Gives top shadow for scroll elements @@ -959,7 +960,12 @@ function splitterWidget(superdesk, $timeout, $rootScope) { handles: 'e', minWidth: MONITORING_MIN_WIDTH, start: function(e, ui) { - workspace.resizable({maxWidth: container.width() - AUTHORING_MIN_WIDTH}); + const WIDGET_SIDEBAR_MENU_WIDTH = 48; + const totalSize = IS_WIDGET_PINNED + ? (AUTHORING_MIN_WIDTH + SIDE_WIDGET_WIDTH + WIDGET_SIDEBAR_MENU_WIDTH) + : AUTHORING_MIN_WIDTH; + + workspace.resizable({maxWidth: container.width() - totalSize}); }, resize: resize, stop: afterResize, diff --git a/scripts/extensions/ai-widget/src/ai-assistant.tsx b/scripts/extensions/ai-widget/src/ai-assistant.tsx index 6616ac8080..0056afc828 100644 --- a/scripts/extensions/ai-widget/src/ai-assistant.tsx +++ b/scripts/extensions/ai-widget/src/ai-assistant.tsx @@ -1,150 +1,201 @@ +/* eslint-disable react/no-multi-comp */ + import React from 'react'; -import {IArticleSideWidgetComponentType} from 'superdesk-api'; +import {IArticleSideWidgetComponentType, ITranslation} from 'superdesk-api'; import {Spacer} from 'superdesk-ui-framework/react'; import {superdesk} from './superdesk'; -import {configuration} from './configuration'; -import getHeadlinesWidget from './headlines/headlines-widget'; -import getSummaryWidget from './summary/summary-widget'; import DefaultAiAssistantPanel from './main-panel'; +import SummaryWidget from './summary/summary-widget'; +import {HeadlinesWidget} from './headlines/headlines-widget'; +import TranslationsWidget from './translations/translations-widget'; -export type IAiAssistantSection = 'headlines' | 'summary' | null; +const {assertNever} = superdesk.helpers; -interface IState { - activeSection: IAiAssistantSection; +export type IAiAssistantSection = 'headlines' | 'summary' | 'translations' | null; +export type ITranslationLanguage = ITranslation['_id']; - /** - * Handle loading of each request separately, - */ - loadingHeadlines: boolean; - loadingSummary: boolean; +export interface ICommonProps extends IArticleSideWidgetComponentType { + state: T; + setSection: (section: IAiAssistantSection) => void; + setTabState: (state: IState['currentTab'], callbackFn?: () => void) => void; + children: (components: {header?: JSX.Element, body: JSX.Element, footer?: JSX.Element}) => JSX.Element; +} - headlines: Array; +export interface IStateTranslationsTab { + activeSection: 'translations'; + mode: 'other' | 'current'; + translation: string; + loading: boolean; error: boolean; + activeLanguageId: ITranslationLanguage; +} + +export interface IStateSummaryTab { + activeSection: 'summary'; summary: string; + loading: boolean; + error: boolean; +} + +export interface IStateHeadlinesTab { + activeSection: 'headlines'; + headlines: Array | null; + loading: boolean; + error: boolean; +} + +interface IDefaultState { + activeSection: null; +} + +type IState = { + currentTab: IDefaultState | IStateTranslationsTab | IStateSummaryTab | IStateHeadlinesTab +}; + +const {AuthoringWidgetLayout, AuthoringWidgetHeading} = superdesk.components; +const {gettext} = superdesk.localization; + +function renderResult({header, body, footer}: {header?: JSX.Element, body: JSX.Element, footer?: JSX.Element}) { + return ( + + + {header} + + )} + body={body} + footer={footer} + /> + ); } export class AiAssistantWidget extends React.PureComponent { + private inactiveTabState: { + [KEY in NonNullable]?: IState['currentTab']; + }; + constructor(props: IArticleSideWidgetComponentType) { super(props); - this.state = { - activeSection: null, - headlines: [], - error: false, - loadingSummary: true, - loadingHeadlines: true, - summary: '', - }; - - this.setError = this.setError.bind(this); - this.generateHeadlines = this.generateHeadlines.bind(this); - this.generateSummary = this.generateSummary.bind(this); + this.inactiveTabState = {}; + this.getDefaultState = this.getDefaultState.bind(this); + this.setSection = this.setSection.bind(this); + this.state = this.props.initialState != null + ? {currentTab: this.props.initialState} + : {currentTab: {activeSection: null}}; } - setError() { - this.setState({ - error: true, - }); + private getDefaultState(section: IAiAssistantSection): IState['currentTab'] { + switch (section) { + case null: + return { + activeSection: null, + }; + case 'translations': + return { + activeSection: 'translations', + mode: 'current', + translation: '', + loading: false, + error: false, + activeLanguageId: this.props.article.language, + }; + case 'headlines': + return { + activeSection: 'headlines', + headlines: [], + error: false, + loading: true, + }; + case 'summary': + return { + activeSection: 'summary', + summary: '', + loading: false, + error: true, + }; + default: + return assertNever(section); + } } - generateHeadlines() { - configuration.generateHeadlines?.(this.props.article, superdesk) - .then((res) => { - this.setState({ - loadingHeadlines: false, - headlines: res, - }); - }).catch(() => { - this.setError(); - }); - } + private setSection(section: IAiAssistantSection) { + if (this.state.currentTab.activeSection != null) { + this.inactiveTabState[this.state.currentTab.activeSection] = this.state.currentTab; + } - generateSummary() { - configuration.generateSummary?.(this.props.article, superdesk) - .then((res) => { - this.setState({ - loadingSummary: false, - summary: res, - }); - }).catch(() => { - this.setError(); - }); + if (section == null) { + this.setState({currentTab: {activeSection: null}}); + } else { + const nextSectionState = this.inactiveTabState[section] ?? this.getDefaultState(section); + + this.setState({currentTab: nextSectionState}); + } } render() { - const {gettext} = superdesk.localization; - const {AuthoringWidgetLayout, AuthoringWidgetHeading} = superdesk.components; - const closeActiveSection = () => { - this.setState({activeSection: null}); - }; - const headlinesWidget = getHeadlinesWidget({ - closeActiveSection, - article: this.props.article, - error: this.state.error, - generateHeadlines: this.generateHeadlines, - headlines: this.state.headlines, - loading: this.state.loadingHeadlines, - reGenerateHeadlines: () => { - this.setState({ - loadingHeadlines: true, - }, () => this.generateHeadlines()); - }, - fieldsData: this.props.fieldsData, - onFieldsDataChange: this.props.onFieldsDataChange, - }); - const summaryWidget = getSummaryWidget({ - closeActiveSection, - article: this.props.article, - error: this.state.error, - generateSummary: this.generateSummary, - summary: this.state.summary, - loading: this.state.loadingSummary, - regenerateSummary: () => { - this.setState({ - loadingSummary: true, - }, () => this.generateSummary()); + const state = this.state; + + const tabManagementProps: Pick, 'setSection' | 'setTabState'> = { + setSection: this.setSection, + setTabState: (state, callbackFn) => { + this.setState({currentTab: state}, callbackFn); }, - }); - const currentComponent: { - header?: JSX.Element; - body: JSX.Element; - footer?: JSX.Element; - } = (() => { - if (this.state.activeSection === 'headlines') { - return headlinesWidget; - } else if (this.state.activeSection === 'summary') { - return summaryWidget; - } else { - return { - header: undefined, - body: ( - { - this.setState({ - activeSection: id, - }); - }} - /> - ), - footer: undefined, - }; - } - })(); - - return ( - + }; + + switch (state.currentTab.activeSection) { + case null: + return ( + - {currentComponent.header} - - )} - body={currentComponent.body} - footer={currentComponent.footer} - /> - ); + )} + body={( + + )} + /> + ); + case 'headlines': + return ( + + {renderResult} + + ); + case 'summary': + return ( + + {renderResult} + + ); + case 'translations': + return ( + + {renderResult} + + ); + default: + return assertNever(state.currentTab); + } } } diff --git a/scripts/extensions/ai-widget/src/configuration.ts b/scripts/extensions/ai-widget/src/configuration.ts index bdce4237fa..e5a937f171 100644 --- a/scripts/extensions/ai-widget/src/configuration.ts +++ b/scripts/extensions/ai-widget/src/configuration.ts @@ -1,12 +1,20 @@ import {IArticle, ISuperdesk} from 'superdesk-api'; +import {superdesk} from './superdesk'; export interface IConfigurationOptions { - generateHeadlines?: (article: IArticle, superdesk: ISuperdesk) => Promise>; - generateSummary?: (article: IArticle, superdesk: ISuperdesk) => Promise; + generateHeadlines?: (article: IArticle, abortSignal: AbortSignal) => Promise>; + generateSummary?: (article: IArticle, abortSignal: AbortSignal) => Promise; + translations?: { + generateTranslations: (article: IArticle, language: string, abortSignal: AbortSignal) => Promise; + translateActionIntegration?: boolean; + }; } export const configuration: IConfigurationOptions = {}; -export function configure(_configuration: IConfigurationOptions) { +export function configure(fn: (superdesk: ISuperdesk) => IConfigurationOptions) { + const _configuration = fn(superdesk); + Object.assign(configuration, _configuration); } + diff --git a/scripts/extensions/ai-widget/src/extension.ts b/scripts/extensions/ai-widget/src/extension.ts index aa0a0f94b0..881c80c54e 100644 --- a/scripts/extensions/ai-widget/src/extension.ts +++ b/scripts/extensions/ai-widget/src/extension.ts @@ -1,5 +1,5 @@ -import {IExtension, IExtensionActivationResult} from 'superdesk-api'; -import {AiAssistantWidget} from './ai-assistant'; +import {IArticle, IExtension, IExtensionActivationResult} from 'superdesk-api'; +import {AiAssistantWidget, IStateTranslationsTab} from './ai-assistant'; import {superdesk} from './superdesk'; import {configuration} from './configuration'; @@ -12,19 +12,42 @@ const extension: IExtension = { return Promise.resolve({}); } + const onTranslateAfterIntegration = (_original: IArticle, translation: IArticle) => { + const initialState: IStateTranslationsTab = { + activeLanguageId: translation.language, + activeSection: 'translations', + error: false, + loading: true, + mode: 'other', + translation: '', + }; + + superdesk.ui.article.edit(translation._id, { + id: 'ai-widget', + pinned: true, + initialState: initialState, + }); + + superdesk.ui.notify.success(superdesk.localization.gettext('Item Translated')); + }; + const result: IExtensionActivationResult = { contributions: { authoringSideWidgets: [{ - _id: 'ai-assistant', + _id: 'ai-widget', component: AiAssistantWidget, icon: 'open-ai', label: superdesk.localization.gettext('Ai Assistant'), order: 2, }], + entities: { + article: configuration.translations?.translateActionIntegration === true ? { + onTranslateAfter: onTranslateAfterIntegration, + } : {}, + }, }, }; - return Promise.resolve(result); }, }; diff --git a/scripts/extensions/ai-widget/src/headlines/headlines-widget.tsx b/scripts/extensions/ai-widget/src/headlines/headlines-widget.tsx index bff9d71698..10a771a3df 100644 --- a/scripts/extensions/ai-widget/src/headlines/headlines-widget.tsx +++ b/scripts/extensions/ai-widget/src/headlines/headlines-widget.tsx @@ -1,76 +1,106 @@ import React from 'react'; import {Button, ContentDivider, Heading, IconButton, Spacer} from 'superdesk-ui-framework/react'; import {superdesk} from '../superdesk'; -import {IArticle, OrderedMap} from 'superdesk-api'; import HeadlinesBody from './headlines'; +import {configuration} from '../configuration'; +import {ICommonProps, IStateHeadlinesTab} from '../ai-assistant'; -interface IProps { - closeActiveSection: () => void; - article: IArticle; - error: boolean; - loading: boolean; - headlines: Array; - generateHeadlines: () => void; - reGenerateHeadlines: () => void; - fieldsData?: OrderedMap; - onFieldsDataChange?(fieldsData?: OrderedMap): void; -} +export class HeadlinesWidget extends React.Component> { + abortController: AbortController; + + constructor(props: ICommonProps) { + super(props); + + this.abortController = new AbortController(); + this.generateHeadlines = this.generateHeadlines.bind(this); + } + + generateHeadlines() { + configuration.generateHeadlines?.( + this.props.article, + this.abortController.signal, + ) + .then((res) => { + this.props.setTabState({ + activeSection: 'headlines', + error: false, + loading: false, + headlines: res, + }); + }).catch(() => { + this.props.setTabState({ + activeSection: 'headlines', + error: true, + loading: false, + headlines: [], + }); + }); + } + + componentWillUnmount(): void { + this.abortController.abort(); + } -export default function getHeadlinesWidget({ - closeActiveSection, - article, - error, - loading, - headlines, - generateHeadlines, - reGenerateHeadlines, - fieldsData, - onFieldsDataChange, -}: IProps) { - const {gettext} = superdesk.localization; + render() { + const {gettext} = superdesk.localization; + const { + article, + children, + state: {error, loading, headlines}, + fieldsData, + onFieldsDataChange, + } = this.props; - return { - header: ( - <> -
- - - - {gettext('Headlines')} - - -
- - - ), - body: ( - - ), - footer: ( -