scripts/apps/search/directives/SearchResults.ts
import _, {debounce, noop} from 'lodash';
import {gettext} from 'core/utils';
import {appConfig} from 'appConfig';
import {ISearchOptions, showRefresh} from '../services/SearchService';
import {IPackagesService} from 'types/Services/Packages';
SearchResults.$inject = [
'$location',
'preferencesService',
'packages',
'asset',
'$timeout',
'api',
'search',
'session',
'$rootScope',
'superdeskFlags',
'notify',
'$q',
];
const HEX_REG_EXP = /[a-f0-9]{24}/;
function isObjectId(value) {
return value != null && value.length === 24 && HEX_REG_EXP.test(value);
}
/**
* @ngdoc directive
* @module superdesk.apps.search
* @name sdSearchResults
*
* @requires $location
* @requires preferencesService
* @requires packages
* @requires asset
* @requires $timeout
* @requires api
* @requires search
* @requires session
* @requires $rootScope
*
* @description Item list with sidebar preview
*/
export function SearchResults(
$location,
preferencesService,
packages: IPackagesService,
asset,
$timeout,
api,
search,
session,
$rootScope,
superdeskFlags,
notify,
$q,
) { // uff - should it use injector instead?
var preferencesUpdate = {
'archive:view': {
allowed: [
'mgrid',
'compact',
],
category: 'archive',
view: 'mgrid',
default: 'mgrid',
label: 'Users archive view format',
type: 'string',
},
};
return {
require: '^sdSearchContainer',
templateUrl: asset.templateUrl('apps/search/views/search-results.html'),
link: function(scope, elem, attr, controller) {
var containerElem = elem.find('.shadow-list-holder');
var GRID_VIEW = 'mgrid',
LIST_VIEW = 'compact';
var projections = search.getProjectedFields();
var multiSelectable = attr.multiSelectable !== undefined;
scope.previewingBroadcast = false;
superdeskFlags.flags.previewing = false;
// allow controller overwrite getSearch
const getSearch = typeof scope.search.getSearch === 'function'
? scope.search.getSearch
: () => $location.search();
const queryOptions: ISearchOptions = {
hidePreviousVersions: scope.search.hideNested === true,
};
var criteria = search.query(getSearch(), queryOptions).getCriteria(true),
oldQuery = _.omit(getSearch(), '_id');
let fetchInProgress = false;
/**
* When a new request is initialized, all existing requests that are still in progress need to be cancelled.
*/
let cancelAllBeforeTime: ReturnType<DateConstructor['now']> = 0;
/**
* Schedule an update
*/
const queryItems = debounce(
(event, data) => {
if (isObjectId(scope.search.repo.search) && event != null) {
// external provider, don't refresh on events
return;
}
if (scope.search.repo.search !== 'local' && !getSearch().q && !(data && data.force)) {
return; // ignore updates with external content
}
if (fetchInProgress) {
// schedule querying again after current query finishes
queryItems(event, data);
return;
}
scope.loading = true;
fetchInProgress = true;
_queryItems(data, Date.now())
.catch(() => noop)
.finally(() => {
scope.$applyAsync(() => {
fetchInProgress = false;
});
});
},
1000,
{
leading: true,
trailing: true,
maxWait: 3000,
},
);
scope.flags = controller.flags;
scope.selected = scope.selected || {};
scope.showHistoryTab = true;
scope.context = 'search';
scope.$on('item:deleted:archived', itemDelete);
scope.$on('item:fetch', queryItems);
scope.$on('item:update', updateItem);
scope.$on('item:deleted', scheduleIfShouldUpdate);
scope.$on('item:unlink', queryItems);
scope.$on('item:spike', scheduleIfShouldUpdate);
scope.$on('item:unspike', scheduleIfShouldUpdate);
scope.$on('item:duplicate', queryItems);
scope.$on('item:translate', queryItems);
scope.$on('item:marked_desks', queryItems);
scope.autorefreshContent = appConfig.features.autorefreshContent === true;
// used by superdesk-fi
scope.showtags = attr.showtags !== 'false';
// Custom components for item fields
scope.customRender = scope.search.customRender || {};
scope.$on('ingest:update', (event, args) => {
if (!scope.showRefresh) {
queryItems(event, args);
}
});
scope.$on('content:update', queryItems);
scope.$on('item:move', scheduleIfShouldUpdate);
scope.$on('aggregations:changed', queryItems);
scope.$on('broadcast:preview', (event, args) => {
scope.previewingBroadcast = true;
scope.preview(args.item);
});
scope.$on('broadcast:created', (event, args) => {
scope.previewingBroadcast = true;
queryItems(undefined, undefined);
scope.preview(args.item);
});
scope.$watch('selected', (newVal, oldVal) => {
if (!newVal && scope.previewingBroadcast) {
scope.previewingBroadcast = false;
}
});
scope.$watch(function getSearchParams() {
return _.omit(getSearch(), ['_id', 'item', 'action']);
}, (newValue, oldValue) => {
if (newValue !== oldValue) {
scope.refreshList();
}
}, true);
// public api - called by list when needed
scope.fetchNext = function() {
render(null, true);
};
function updateItem(e, data) {
var item = _.find(scope.items._items, {_id: data.item._id});
if (item) {
angular.extend(item, data.item);
}
}
/**
* Function for fetching total items and filling scope.
*/
function _queryItems(data: any | undefined, initiatedAt: ReturnType<DateConstructor['now']>) {
const pageSize: number = 50;
criteria = search.query(getSearch(), queryOptions).getCriteria(true);
criteria.source.size = pageSize;
var originalQuery;
// when forced refresh or query then keep query size default as set 50 above.
if (!(data && data.force)) {
// To compare current scope of items, consider fetching same number of items.
if (scope.items && scope.items._items.length > 50) {
criteria.source.size = scope.items._items.length;
}
}
if (data && (data.item || data.items || data.item_id) && scope.showRefresh && !data.force) {
// if we know the ids of the items then try to fetch those only
originalQuery = angular.extend({}, criteria.source.query);
let items = data.items || {};
if (data.item || data.item_id) {
items[data.item || data.item_id] = 1;
}
criteria.source.query = search.getItemQuery(data.items);
} else {
criteria.aggregations = $rootScope.aggregations;
}
criteria.source.from = 0;
scope.total = null;
criteria.es_highlight = search.getElasticHighlight();
criteria.projections = JSON.stringify(projections);
if (getSearch().params) {
criteria.params = JSON.parse(getSearch().params);
}
return api.query(getProvider(criteria), criteria).then((items) => {
if (initiatedAt < cancelAllBeforeTime) {
return $q.reject('cancelling in favor of a newer request');
}
if (appConfig.features.autorefreshContent && data != null) {
data.force = true;
}
if (!scope.showRefresh && data && !data.force && data.user !== session.identity._id) {
scope.showRefresh =
items._items.length === pageSize
? showRefresh(
(scope.items?._items ?? []),
items._items,
)
: false;
}
if (!scope.showRefresh || data && data.force) {
scope.total = items._meta.total;
scope.$applyAsync(() => {
render(items, null, true);
});
} else {
// update scope items only with the matching fetched items
scope.items = search.updateItems(items, scope.items);
}
if (originalQuery) {
criteria.source.query = originalQuery;
}
}, (error) => {
notify.error(gettext('Failed to run the query!'));
console.error(error, getProvider(criteria));
})
.finally(() => {
scope.loading = false;
});
}
function scheduleIfShouldUpdate(event, data) {
if (data && data.item && _.includes(['item:spike', 'item:unspike', 'item:deleted'], event.name)) {
// item was spiked/unspikes from the list
extendItem(data.item, {
gone: true,
_etag: data.item,
});
queryItems(event, data);
} else if (data && data.from_stage) {
// item was moved from current stage
extendItem(data.item, {
gone: true,
_etag: data.from_stage, // this must change to make it re-render
});
queryItems(event, data);
}
}
function extendItem(itemId, updates) {
scope.$apply(() => {
scope.items._items = scope.items._items.map((item) => {
if (item._id === itemId) {
return angular.extend(item, updates);
}
return item;
});
scope.items = angular.extend({}, scope.items); // trigger a watch
});
}
scope.$on('refresh:list', (event, group, options) => {
/**
* When manual refreshing is enabled, scrolling should not automatically refresh the list.
*/
if (scope.showRefresh === true && options?.event_origin === 'scroll') {
return;
}
scope.refreshList();
});
scope.refreshList = function() {
scope.showRefresh = false;
scope.manualRefreshInProgress = true;
fetchInProgress = true;
cancelAllBeforeTime = Date.now();
_queryItems({force: true}, cancelAllBeforeTime)
.catch(noop)
.finally(() => {
fetchInProgress = false;
scope.manualRefreshInProgress = false;
});
if (containerElem[0].scrollTop > 0) {
containerElem[0].scrollTop = 0;
}
};
/*
* Function to get the search endpoint name based on the criteria
*
* @param {Object} _criteria
* @returns {string}
*/
function getProvider(_criteria) {
var provider = 'search';
if (_criteria.repo && _criteria.repo.indexOf(',') === -1) {
provider = _criteria.repo;
}
if (scope.search.repo.search && scope.search.repo.search !== 'local') {
provider = scope.search.repo.search;
}
if (isObjectId(provider)) {
_criteria.repo = provider;
provider = 'search_providers_proxy';
}
if (provider === 'local') {
provider = 'search';
}
return provider;
}
/*
* Function for fetching the elements from the database
*
* @param {items}
*/
function render(items, next, force?) {
scope.loading = true;
if (items) {
setScopeItems(items, force);
} else if (next) {
scope.loading = true;
criteria.source.from = (criteria.source.from || 0) + criteria.source.size;
criteria.source.size = 50;
criteria.projections = JSON.stringify(projections);
api
.query(getProvider(criteria), criteria)
.then(setScopeItems)
.finally(() => {
scope.loading = false;
});
} else {
var query = _.omit(getSearch(), '_id');
if (!_.isEqual(_.omit(query, 'page'), _.omit(oldQuery, 'page'))) {
$location.search('page', null);
}
criteria = search.query(getSearch(), queryOptions).getCriteria(true);
criteria.source.from = 0;
criteria.source.size = 50;
criteria.aggregations = $rootScope.aggregations;
criteria.es_highlight = search.getElasticHighlight();
scope.loading = true;
criteria.projections = JSON.stringify(projections);
api
.query(getProvider(criteria), criteria)
.then(setScopeItems)
.finally(() => {
scope.loading = false;
});
oldQuery = query;
}
function setScopeItems(_items, _force) {
scope.items = search.mergeItems(_items, scope.items, next, _force);
scope.total_records = _items._meta.total;
scope.loading = false;
}
}
/*
* Function for updating list
* after item has been deleted
*/
function itemDelete(e, data) {
if (session.identity._id === data.user) {
queryItems(undefined, undefined);
}
}
scope.preview = function preview(item) {
if (multiSelectable) {
if (_.findIndex(scope.selectedList, {_id: item._id}) === -1) {
scope.selectedList.push(item);
} else {
_.remove(scope.selectedList, {_id: item._id});
}
}
if (item) {
if (item._type === 'externalsource') {
var preview_id = item.extra?.previewid;
if (preview_id) {
criteria.params = {'preview_id': preview_id};
return api.query(getProvider(criteria), criteria).then((item_) => {
if ('_items' in item_ && item_._items[0] !== undefined) {
processPreview(search.mergeHighlightFields(item_._items[0]));
} else {
processPreview(item);
}
}, (error) => {
notify.error('Detailed informations is not found for the item : ' + item.guid);
processPreview(item);
});
} else {
processPreview(item);
return;
}
}
scope.loading = true;
let previewCriteria = search.getSingleItemCriteria(item);
api.query(getProvider(previewCriteria), previewCriteria).then((completeItems) => {
let completeItem = search.mergeHighlightFields(completeItems._items[0]);
processPreview(completeItem);
})
.finally(() => {
scope.loading = false;
});
} else {
delete scope.selected.preview;
superdeskFlags.flags.previewing = false;
sendRowViewEvents();
}
};
/**
* @ngdoc method
* @name sdSearchResults#sendRowViewEvents
* @private
* @param {Object} item
* @description If singLine:view preference is set, an item is being previewed, config has narrowView list
* then, sends rowview event
*/
function sendRowViewEvents(item?) {
let sendEvent = scope.singleLine
&& superdeskFlags.flags.authoring
&& appConfig.list != null
&& appConfig.list.narrowView;
let evnt = item ? 'rowview:narrow' : 'rowview:default';
if (sendEvent) {
$rootScope.$broadcast(evnt);
}
}
/**
* @ngdoc method
* @name sdSearchResults#processPreview
* @private
* @param {Object} item
* @description Sets the preview item
*/
function processPreview(item) {
scope.selected.preview = item;
if (!_.isNil(scope.selected.preview)) {
scope.showHistoryTab = scope.selected.preview.state !== 'ingested' &&
!_.includes(['archived', 'externalsource'], scope.selected.preview._type);
superdeskFlags.flags.previewing = true;
sendRowViewEvents(item);
}
$location.search('_id', item ? item._id : null);
}
scope.openSingleItem = function(packageItem) {
packages.fetchItem(packageItem).then((item) => {
scope.selected.view = item;
});
};
scope.setview = setView;
var savedView;
preferencesService.get('archive:view').then((result) => {
savedView = result.view;
scope.view = !!savedView && savedView !== 'undefined' ? savedView : 'mgrid';
});
scope.$on('key:v', toggleView);
scope.$on('open:archived_kill', (evt, item, action) => {
scope.selected.archived_kill = item;
scope.selected.archived_kill_action = action;
});
scope.$on('open:resend', (evt, item) => {
scope.selected.resend = item;
});
function setView(view) {
scope.view = view || 'mgrid';
preferencesUpdate['archive:view'].view = view || 'mgrid';
preferencesService.update(preferencesUpdate, 'archive:view');
}
function toggleView() {
var nextView = scope.view === LIST_VIEW ? GRID_VIEW : LIST_VIEW;
return setView(nextView);
}
/**
* Generates Identifier to be used by track by expression.
*/
scope.uuid = function(item) {
return search.generateTrackByIdentifier(item);
};
// init
$rootScope.aggregations = 0;
_queryItems(undefined, Date.now())
.catch(noop);
},
};
}