scripts/apps/authoring/widgets/widgets.ts
import React from 'react';
import {flatMap, noop} from 'lodash';
import {isWidgetVisibleForContentProfile} from 'apps/workspace/content/components/WidgetsConfig';
import {gettext} from 'core/utils';
import {isKilled} from 'apps/archive/utils';
import {AuthoringWorkspaceService} from '../authoring/services/AuthoringWorkspaceService';
import {IArticle, IAuthoringWidgetLayoutProps, IContentProfile} from 'superdesk-api';
import {appConfig, extensions} from 'appConfig';
import {WidgetHeaderComponent} from './WidgetHeaderComponent';
import {WidgetLayoutComponent} from './WidgetLayoutComponent';
const USER_PREFERENCE_SETTINGS = 'editor:pinned_widget';
let PINNED_WIDGET_RESIZED = false;
interface IWidget {
label?: string;
icon?: string;
side?: 'left' | 'right';
order?: number; // Integer. Lower is higher.
template?: string;
display?: {
archived: boolean;
authoring: boolean;
killedItem: boolean;
legalArchive: boolean;
packages: boolean;
personal: boolean;
picture: boolean;
};
needEditable?: boolean; // true if item must be editable.
needUnlock?: boolean; // true will make widget locked if item is locked.
configurable?: boolean;
configurationTemplate?: string;
isWidgetVisible?: any; // injectable function, gets single param `item`.
badge?: any; // injectable function to badge number for item.
badgeAsync: any; // injectable function to badge number for item. Returns a promise.
removeHeader?: boolean;
pinned?: boolean;
_id?: string;
feature?: string;
afterClose(): void;
configuration?: {
modificationDateAfter: 'today' | string;
sluglineMatch: 'EXACT' | string;
};
// extension-specific fields
component: React.ComponentType<{article: IArticle}>;
isAllowed?(article: IArticle): boolean;
}
interface IScope extends ng.IScope {
item: IArticle;
active: any;
widgets: any;
pinnedWidget: IWidget;
activate(widget: IWidget): void;
pinWidget(widget: IWidget): void;
closeWidget(): void;
isWidgetLocked(widget: IWidget): boolean;
isAssigned(item: IArticle): boolean;
autosave(): void;
updateItem(updates: Partial<IArticle>): void;
}
function AuthoringWidgetsProvider() {
var widgets = [];
/**
* Register new widget
*
* @param {String} id
* @param {Object} config Widget configuration
*
* Object properties:
* - `badge` - `{Function}` - Injectable function to get badge number for item,
* gets `item` injected.
* - `badgeAsync` - `{Function}` - Injectable function to get badge number
* returning a promise, gets `item` injected.
*/
this.widget = function(id, widget: IWidget) {
widgets = widgets.filter((_widget) => _widget._id !== id);
widgets.push(angular.extend({}, widget, {_id: id})); // make a new instance for every widget
};
this.$get = function() {
const widgetsFromExtensions = flatMap(
Object.values(extensions),
(extension) => extension.activationResult?.contributions?.authoringSideWidgets ?? [],
);
return widgets.concat(widgetsFromExtensions);
};
}
export interface IWidgetIntegrationComponentProps {
widgetName: string;
pinned: boolean;
widget: any;
editMode: boolean;
pinWidget(widget: any): void;
closeWidget(): void;
/**
* Only available in authoring-react.
* If used, widgetName will not be shown.
* Required for displaying multiple sections for the same widget.
*/
customContent?: JSX.Element;
}
/**
* This was initially written for {@link AuthoringWidgetHeading} to work.
* Wrapper components for header/layout were later added in order to be able to use
* react-based layout components from ui-framework while maintaining existing markup
* and styles in the angular based authoring.
*/
interface IWidgetIntegration {
pinWidget(widget: any): void;
getActiveWidget(): any;
closeActiveWidget(): any;
getPinnedWidget(): any;
WidgetHeaderComponent: React.ComponentType<IWidgetIntegrationComponentProps>;
WidgetLayoutComponent: React.ComponentType<IAuthoringWidgetLayoutProps>;
disableWidgetPinning: boolean;
}
export const widgetReactIntegration: IWidgetIntegration = {
pinWidget: noop as any,
getActiveWidget: noop as any,
getPinnedWidget: noop as any,
closeActiveWidget: noop,
WidgetHeaderComponent: () => null,
WidgetLayoutComponent: () => null,
disableWidgetPinning: false,
};
WidgetsManagerCtrl.$inject = ['$scope', '$routeParams', 'authoringWidgets', 'archiveService', 'authoringWorkspace',
'keyboardManager', '$location', 'desks', 'lock', 'content', 'lodash', 'privileges',
'$injector', 'preferencesService', '$rootScope'];
function WidgetsManagerCtrl(
$scope: IScope,
$routeParams,
authoringWidgets: Array<IWidget>,
archiveService,
authoringWorkspace: AuthoringWorkspaceService,
keyboardManager,
$location,
desks,
lock,
content,
_,
privileges,
$injector,
preferencesService,
$rootScope,
) {
$scope.active = null;
preferencesService.get(USER_PREFERENCE_SETTINGS).then((preferences) =>
this.widgetFromPreferences = preferences,
);
$scope.$watch('item', (item: IArticle) => {
if (!item) {
$scope.widgets = null;
unbindAllShortcuts();
return;
}
var display;
if (archiveService.isLegal(item)) {
display = 'legalArchive';
} else if (archiveService.isArchived(item)) {
display = 'archived';
} else if (archiveService.isPersonal(item)) {
display = 'personal';
} else {
display = isKilled(item) ? 'killedItem' : 'authoring';
if (item.type === 'composite') {
display = 'packages';
}
if (item.type === 'picture') {
display = 'picture';
}
}
const widgets = authoringWidgets.filter((widget) => {
if (widget.component != null) { // widgets from extensions are themselves in control of widget visibility
return widget.isAllowed?.(item) ?? true;
} else {
return !!widget.display[display] &&
// If the widget requires a feature configured, then test this
// feature name against the config (defaulting to true)
(!widget.feature || !!_.get(appConfig.features, widget.feature, true));
}
});
content.getType(item.profile).then((contentProfile: IContentProfile) => {
const promises = widgets.map(
(widget) => new Promise((resolve) => {
Promise.all([
// checking static superdesk config
typeof widget.isWidgetVisible === 'function'
? $injector.invoke(widget.isWidgetVisible(item))
: Promise.resolve(true),
// checking result from plugins
authoringWorkspace.isWidgetVisible(widget),
Promise.resolve(isWidgetVisibleForContentProfile(contentProfile.widgets_config, widget._id)),
])
.then((res) => {
resolve(res.every((i) => i === true));
})
.catch(() => {
resolve(false);
});
}),
);
Promise.all(promises).then((result) => {
$scope.widgets = widgets.filter((__, i) => result[i] === true);
$scope.widgets.forEach((widget) => {
if (widget.badgeAsync != null) {
widget.badgeAsyncValue = null;
$injector.invoke(widget.badgeAsync, null, {item})
.then((value) => widget.badgeAsyncValue = value);
}
});
if (this.widgetFromPreferences) {
let widgetFromPreferences = $scope.widgets.find((widget) =>
widget._id === this.widgetFromPreferences._id);
if (widgetFromPreferences) {
$scope.pinWidget(widgetFromPreferences);
}
}
$scope.$apply(); // tell angular to re-render
});
});
bindAllShortcuts();
});
var shortcuts = [];
function unbindAllShortcuts() {
shortcuts.forEach((sc) => {
keyboardManager.unbind(sc);
});
shortcuts = [];
}
function bindKeyShortcutToWidget(shortcut, widget) {
shortcuts.push(shortcut);
keyboardManager.bind(shortcut, () => {
$scope.activate(widget);
});
}
function bindAllShortcuts() {
/*
* Navigate through right tab widgets, include custom keys from `keyboardShortcut` property
*/
angular.forEach(_.sortBy($scope.widgets, 'order'), (widget, index) => {
// binding keys from `widget.keyboardShortcut` property
if (angular.isDefined(widget.keyboardShortcut)) {
bindKeyShortcutToWidget(widget.keyboardShortcut, widget);
}
if ($location.search()[widget._id]) {
$scope.activate(widget);
}
});
}
$scope.isWidgetLocked = function(widget: IWidget) {
if (widget) {
var locked = lock.isLocked($scope.item) && !lock.can_unlock($scope.item);
var isReadOnlyStage = desks.isReadOnlyStage($scope.item.task.stage);
return widget.needUnlock && (locked || isReadOnlyStage) ||
widget.needEditable && (!$scope.item._editable || isReadOnlyStage);
}
};
$scope.activate = function(widget) {
if (!$scope.isWidgetLocked(widget)) {
if ($scope.active === widget) {
$scope.closeWidget();
} else {
$scope.active = widget;
}
}
};
$scope.pinWidget = (widget: IWidget) => {
if ($scope.pinnedWidget) {
$scope.pinnedWidget.pinned = false;
}
if (!PINNED_WIDGET_RESIZED && widget && !$scope.pinnedWidget) {
$rootScope.$broadcast('resize:monitoring', -330);
PINNED_WIDGET_RESIZED = true;
}
if (!widget || $scope.pinnedWidget === widget) {
$rootScope.$broadcast('resize:monitoring', 330);
angular.element('body').removeClass('main-section--pinned-tabs');
$scope.pinnedWidget = null;
PINNED_WIDGET_RESIZED = false;
this.widgetFromPreferences = null;
if (widget) {
widget.pinned = false;
}
this.updateUserPreferences();
} else {
angular.element('body').addClass('main-section--pinned-tabs');
$scope.pinnedWidget = widget;
widget.pinned = true;
this.updateUserPreferences(widget);
}
};
widgetReactIntegration.pinWidget = $scope.pinWidget;
widgetReactIntegration.getActiveWidget = () => $scope.active ?? $scope.pinnedWidget;
widgetReactIntegration.getPinnedWidget =
() => $scope.widgets.find(({pinned}) => pinned === true)?.name ?? null;
widgetReactIntegration.WidgetHeaderComponent = WidgetHeaderComponent;
widgetReactIntegration.WidgetLayoutComponent = WidgetLayoutComponent;
this.updateUserPreferences = (widget?: IWidget) => {
let update = [];
update[USER_PREFERENCE_SETTINGS] = {
type: 'string',
_id: widget ? widget._id : null,
};
preferencesService.update(update);
};
// item is associated to an assignment
$scope.isAssigned = (item) => _.get(item, 'assignment_id') != null
&& _.get(privileges, 'privileges.planning') === 1;
this.activate = function(widget) {
$scope.activate(widget);
};
$scope.closeWidget = function() {
if ($scope.active && typeof $scope.active.afterClose === 'function') {
$scope.active.afterClose($scope);
}
$scope.active = null;
};
// activate widget based on query string
angular.forEach($scope.widgets, (widget) => {
if ($routeParams[widget._id]) {
$scope.activate(widget);
}
});
$scope.$watch('item._locked', () => {
if ($scope.active) {
var widget = $scope.active;
$scope.closeWidget();
$scope.activate(widget);
}
});
$scope.updateItem = (updates: Partial<IArticle>) => {
$scope.$applyAsync(() => {
angular.extend($scope.item, updates);
$scope.autosave();
});
};
$scope.$on('$destroy', () => {
unbindAllShortcuts();
});
}
AuthoringWidgetsDir.$inject = ['desks', 'commentsService', '$injector'];
function AuthoringWidgetsDir(desks, commentsService, $injector) {
return {
controller: WidgetsManagerCtrl,
templateUrl: 'scripts/apps/authoring/widgets/views/authoring-widgets.html',
transclude: true,
link: function(scope) {
scope.widget = null;
scope.pinnedWidget = null;
scope.userLookup = desks.userLookup;
function reload() {
if (scope.item) {
commentsService.fetch(scope.item._id).then(() => {
scope.comments = commentsService.comments;
});
}
}
scope.$on('item:comment', (e, data) => {
if (data.item === scope.item.guid) {
reload();
}
});
scope.badge = (widget) => {
if (widget.badgeAsyncValue !== undefined) {
return widget.badgeAsyncValue;
}
if (widget.badge) {
return $injector.invoke(widget.badge, null, {item: scope.item});
}
};
scope.generateHotkey = (order, tooltip?) => {
const shiftNums = {1: '!', 2: '@', 3: '#', 4: '$', 5: '%', 6: '^', 7: '&', 8: '*', 9: '('};
if (order < 10) {
return `ctrl+alt+${order}`;
} else if (order === 10) {
return 'ctrl+alt+0';
} else if (order > 10) {
return tooltip ? `ctrl+shift+${order - 10}` : `ctrl+shift+${shiftNums[order - 10]}`;
}
};
scope.$on('$destroy', () => {
angular.element('body').removeClass('main-section--pinned-tabs');
if (scope.pinnedWidget) {
scope.pinnedWidget.pinned = false;
}
});
reload();
},
};
}
angular.module('superdesk.apps.authoring.widgets', ['superdesk.core.keyboard'])
.provider('authoringWidgets', AuthoringWidgetsProvider)
.directive('sdAuthoringWidgets', AuthoringWidgetsDir)
.run(['keyboardManager', function(keyboardManager) {
keyboardManager.register('Authoring', 'ctrl + alt + {N}',
gettext('Toggle Nth widget, where \'N\' is order of widget it appears'));
}]);