diff --git a/app/api/files/files.ts b/app/api/files/files.ts index 9e4108e558..2667669584 100644 --- a/app/api/files/files.ts +++ b/app/api/files/files.ts @@ -32,12 +32,11 @@ export const files = { return toDeleteFiles; }, - async tocReviewed(_id: string) { + async tocReviewed(_id: string, language: string) { const savedFile = await files.save({ _id, generatedToc: false }); const sameEntityFiles = await files.get({ entity: savedFile.entity }, { generatedToc: 1 }); const [entity] = await entities.get({ sharedId: savedFile.entity, - language: savedFile.language, }); await entities.save( @@ -50,7 +49,7 @@ export const files = { false ), }, - { user: {}, language: savedFile.language } + { user: {}, language } ); return savedFile; diff --git a/app/api/files/routes.ts b/app/api/files/routes.ts index 4c5de41b3c..9e3170aea2 100644 --- a/app/api/files/routes.ts +++ b/app/api/files/routes.ts @@ -73,7 +73,7 @@ export default (app: Application) => { }), async (req, res, next) => { try { - res.json(await files.tocReviewed(req.body.fileId)); + res.json(await files.tocReviewed(req.body.fileId, req.language)); } catch (e) { next(e); } diff --git a/app/api/files/specs/fixtures.ts b/app/api/files/specs/fixtures.ts index 1bab9ad9da..94b88d6f77 100644 --- a/app/api/files/specs/fixtures.ts +++ b/app/api/files/specs/fixtures.ts @@ -13,7 +13,6 @@ const fixtures: DBFixture = { { _id: uploadId, entity: 'sharedId1', - language: 'es', generatedToc: true, originalname: 'upload1', filename: fileName1, @@ -22,7 +21,6 @@ const fixtures: DBFixture = { { _id: uploadId2, generatedToc: true, - language: 'es', entity: 'sharedId1', filename: 'fileNotInDisk', }, diff --git a/app/api/search/deprecatedRoutes.js b/app/api/search/deprecatedRoutes.js index 9526181f88..e5d369dbeb 100644 --- a/app/api/search/deprecatedRoutes.js +++ b/app/api/search/deprecatedRoutes.js @@ -1,6 +1,6 @@ import Joi from 'joi'; import entities from 'api/entities'; -import { searchSchema } from 'api/search/searchSchema'; +import { searchParamsSchema } from 'shared/types/searchParams'; import { search } from './search'; import { validation, parseQuery } from '../utils'; @@ -25,7 +25,7 @@ export default app => { app.get( '/api/search', parseQuery, - validation.validateRequest(searchSchema), + validation.validateRequest(searchParamsSchema), (req, res, next) => { const action = req.query.geolocation ? 'searchGeolocations' : 'search'; diff --git a/app/api/search/elasticTypes.ts b/app/api/search/elasticTypes.ts index efee29aab9..b5351ca1fa 100644 --- a/app/api/search/elasticTypes.ts +++ b/app/api/search/elasticTypes.ts @@ -1,5 +1,6 @@ import { RequestParams } from '@elastic/elasticsearch'; import { RequestBody } from '@elastic/elasticsearch/lib/Transport'; +import { Aggregations } from 'shared/types/Aggregations.d.ts'; interface ShardsResponse { total: number; @@ -42,7 +43,7 @@ export interface SearchResponse { sort?: string[]; }>; }; - aggregations?: any; + aggregations?: Aggregations; } export type IndicesDelete = Omit; diff --git a/app/api/search/specs/searchSchema.spec.ts b/app/api/search/specs/searchSchema.spec.ts index 33722cd602..8a23458c1b 100644 --- a/app/api/search/specs/searchSchema.spec.ts +++ b/app/api/search/specs/searchSchema.spec.ts @@ -1,6 +1,6 @@ import { ValidationError } from 'ajv'; import { validation } from 'api/utils'; -import { searchSchema } from '../searchSchema'; +import { searchParamsSchema } from 'shared/types/searchParams'; describe('search schema', () => { const validQuery = { @@ -27,11 +27,12 @@ describe('search schema', () => { it('should not have validation errors for valid search', async () => { const validSearch = { validQuery }; - await validation.validateRequest(searchSchema)(validSearch, null, expectValidSchema); + await validation.validateRequest(searchParamsSchema)(validSearch, null, expectValidSchema); }); + it('should support a number as a search term', async () => { const validSearch = { query: { ...validQuery, searchTerm: 3 } }; - await validation.validateRequest(searchSchema)(validSearch, null, expectValidSchema); + await validation.validateRequest(searchParamsSchema)(validSearch, null, expectValidSchema); }); }); @@ -42,7 +43,7 @@ describe('search schema', () => { async function testInvalidProperty(invalidProperty: any) { const invalidSearch = { query: { ...validQuery, ...invalidProperty } }; - await validation.validateRequest(searchSchema)(invalidSearch, null, expectInvalidSchema); + await validation.validateRequest(searchParamsSchema)(invalidSearch, null, expectInvalidSchema); } it('should be invalid if allAgregations is not a boolean value', async () => { diff --git a/app/react/App/scss/modules/_toc.scss b/app/react/App/scss/modules/_toc.scss index 3f195ab5ed..9f06fca32e 100644 --- a/app/react/App/scss/modules/_toc.scss +++ b/app/react/App/scss/modules/_toc.scss @@ -1,5 +1,10 @@ .toc { - padding: 15px; + padding: 0px 15px 15px 15px; +} + +div.tocHeader { + padding-left: 15px; + border-bottom: 1px solid #f4f4f4; } .toc-view{ diff --git a/app/react/Attachments/components/File.tsx b/app/react/Attachments/components/File.tsx index 21fd3c5cb1..e18dd4bc0a 100644 --- a/app/react/Attachments/components/File.tsx +++ b/app/react/Attachments/components/File.tsx @@ -10,6 +10,7 @@ import { APIURL } from 'app/config.js'; import { LocalForm, Control } from 'react-redux-form'; import { updateFile, deleteFile } from 'app/Attachments/actions/actions'; import { wrapDispatch } from 'app/Multireducer'; +import { TocGeneratedLabel } from 'app/ToggledFeatures/tocGeneration'; import { NeedAuthorization } from 'app/Auth'; import { EntitySchema } from 'shared/types/entityType'; import { ViewDocumentLink } from './ViewDocumentLink'; @@ -102,7 +103,10 @@ export class File extends Component {
{language ? transformLanguage(language) || '' : ''} -
{' '} +
+ + ML TOC + { const mapDispatchToProps = (dispatch: Dispatch<{}>, props: FileProps) => bindActionCreators({ updateFile, deleteFile }, wrapDispatch(dispatch, props.storeKey)); -export const ConnectedFile = connect(null, mapDispatchToProps)(File); +export const ConnectedFile = connect( + null, + mapDispatchToProps +)(File); diff --git a/app/react/Attachments/components/specs/File.spec.tsx b/app/react/Attachments/components/specs/File.spec.tsx index 997ef2a635..95ca643fa4 100644 --- a/app/react/Attachments/components/specs/File.spec.tsx +++ b/app/react/Attachments/components/specs/File.spec.tsx @@ -2,8 +2,8 @@ import React from 'react'; import { shallow, ShallowWrapper } from 'enzyme'; import { LocalForm } from 'react-redux-form'; import { FileType } from 'shared/types/fileType'; -import { File, FileProps } from '../File'; import { Translate } from 'app/I18N'; +import { File, FileProps } from '../File'; describe('file', () => { let component: ShallowWrapper; @@ -32,6 +32,7 @@ describe('file', () => { }); const render = () => { + //eslint-disable-next-line react/jsx-props-no-spreading component = shallow(, { context }); }; diff --git a/app/react/Documents/components/DocumentSidePanel.js b/app/react/Documents/components/DocumentSidePanel.js index d476d6d047..9d9cf7d75e 100644 --- a/app/react/Documents/components/DocumentSidePanel.js +++ b/app/react/Documents/components/DocumentSidePanel.js @@ -16,6 +16,8 @@ import ShowIf from 'app/App/ShowIf'; import SidePanel from 'app/Layout/SidePanel'; import DocumentSemanticSearchResults from 'app/SemanticSearch/components/DocumentResults'; import { CopyFromEntity } from 'app/Metadata/components/CopyFromEntity'; +import { FeatureToggle } from 'app/components/Elements/FeatureToggle'; +import { TocGeneratedLabel, ReviewTocButton } from 'app/ToggledFeatures/tocGeneration'; import { Icon } from 'UI'; import * as viewerModule from 'app/Viewer'; @@ -321,11 +323,14 @@ export class DocumentSidePanel extends Component {
+ + Mark as Reviewed +
@@ -340,6 +345,14 @@ export class DocumentSidePanel extends Component { /> +
+

+ Table of contents +

+ + auto-created ⓘ + +
{ let filtersState; beforeEach(() => { - libraryFilters = [ - { name: 'author', filter: true }, - { name: 'country', filter: true }, - ]; + libraryFilters = [{ name: 'author', filter: true }, { name: 'country', filter: true }]; search = { searchTerm: '', filters: { author: 'RR Martin', country: '' } }; filtersState = { documentTypes, @@ -36,6 +33,9 @@ describe('filterActions', () => { }; store = { + settings: { + collection: Immutable.Map({}), + }, library: { filters: Immutable.fromJS(filtersState), search, diff --git a/app/react/Library/actions/specs/libraryActions.spec.js b/app/react/Library/actions/specs/libraryActions.spec.js index 71d771da5a..d1d94de2cf 100644 --- a/app/react/Library/actions/specs/libraryActions.spec.js +++ b/app/react/Library/actions/specs/libraryActions.spec.js @@ -48,9 +48,12 @@ describe('libraryActions', () => { beforeEach(() => { dispatch = jasmine.createSpy('dispatch'); - getState = jasmine - .createSpy('getState') - .and.returnValue({ library: { filters: Immutable.fromJS(filters), search: {} } }); + getState = jasmine.createSpy('getState').and.returnValue({ + settings: { + collection: Immutable.Map({}), + }, + library: { filters: Immutable.fromJS(filters), search: {} }, + }); }); it('should dispatch a SET_LIBRARY_TEMPLATES action ', () => { @@ -178,15 +181,24 @@ describe('libraryActions', () => { { name: 'relationshipfilter', type: 'relationshipfilter', - filters: [ - { name: 'status', type: 'select' }, - { name: 'empty', type: 'date' }, - ], + filters: [{ name: 'status', type: 'select' }, { name: 'empty', type: 'date' }], }, ], documentTypes: ['decision'], }; - store = { library: { filters: Immutable.fromJS(state), search: { searchTerm: 'batman' } } }; + store = { + settings: { + collection: Immutable.Map({}), + }, + library: { + filters: Immutable.fromJS(state), + search: { + searchTerm: 'batman', + customFilters: { property: { values: ['value'] } }, + filters: {}, + }, + }, + }; spyOn(browserHistory, 'getCurrentLocation').and.returnValue({ pathname: '/library', query: { view: 'chart' }, @@ -261,6 +273,16 @@ describe('libraryActions', () => { ); }); + it('should use customFilters from the current search on the store', () => { + const limit = 60; + spyOn(browserHistory, 'push'); + actions.searchDocuments({}, storeKey, limit)(dispatch, getState); + + expect(browserHistory.push).toHaveBeenCalledWith( + "/library/?view=chart&q=(customFilters:(property:(values:!(value))),filters:(),from:0,limit:60,searchTerm:'batman',sort:_score,types:!(decision))" //eslint-disable-line + ); + }); + it('should set the storeKey selectedSorting if user has selected a custom sorting', () => { const expectedDispatch = { type: 'library.selectedSorting/SET', @@ -365,10 +387,7 @@ describe('libraryActions', () => { }, { type: types.UPDATE_DOCUMENTS, - docs: [ - { sharedId: '1', metadataResponse }, - { sharedId: '2', metadataResponse }, - ], + docs: [{ sharedId: '1', metadataResponse }, { sharedId: '2', metadataResponse }], }, ]; const store = mockStore({}); diff --git a/app/react/Library/components/FiltersForm.js b/app/react/Library/components/FiltersForm.js index 6f773961c4..a06985851a 100644 --- a/app/react/Library/components/FiltersForm.js +++ b/app/react/Library/components/FiltersForm.js @@ -11,6 +11,7 @@ import { t } from 'app/I18N'; import { wrapDispatch } from 'app/Multireducer'; import debounce from 'app/utils/debounce'; import libraryHelper from 'app/Library/helpers/libraryFilters'; +import { FilterTocGeneration } from 'app/ToggledFeatures/tocGeneration'; import Filters from './FiltersFromProperties'; @@ -97,6 +98,7 @@ export class FiltersForm extends Component { translationContext={translationContext} storeKey={this.props.storeKey} /> + ); @@ -126,4 +128,7 @@ function mapDispatchToProps(dispatch, props) { return bindActionCreators({ searchDocuments }, wrapDispatch(dispatch, props.storeKey)); } -export default connect(mapStateToProps, mapDispatchToProps)(FiltersForm); +export default connect( + mapStateToProps, + mapDispatchToProps +)(FiltersForm); diff --git a/app/react/Library/components/ViewMetadataPanel.js b/app/react/Library/components/ViewMetadataPanel.js index 6f9ec1138f..f3487a6278 100644 --- a/app/react/Library/components/ViewMetadataPanel.js +++ b/app/react/Library/components/ViewMetadataPanel.js @@ -9,6 +9,7 @@ import { actions } from 'app/Metadata'; import { deleteDocument, searchSnippets } from 'app/Library/actions/libraryActions'; import { deleteEntity } from 'app/Entities/actions/actions'; import { wrapDispatch } from 'app/Multireducer'; +import { entityDefaultDocument } from 'shared/entityDefaultDocument'; import modals from 'app/Modals'; import { @@ -22,9 +23,18 @@ const getTemplates = state => state.templates; const mapStateToProps = (state, props) => { const library = state[props.storeKey]; + const doc = library.ui.get('selectedDocuments').first() || Immutable.fromJS({ documents: [] }); + const defaultLanguage = state.settings.collection.get('languages').find(l => l.get('defautl')); + const file = entityDefaultDocument( + doc.get('documents').toJS(), + doc.get('language'), + defaultLanguage + ); + return { open: library.ui.get('selectedDocuments').size === 1, - doc: library.ui.get('selectedDocuments').first() || Immutable.fromJS({}), + doc, + file, references: library.sidepanel.references, tab: library.sidepanel.tab, docBeingEdited: !!Object.keys(library.sidepanel.metadata).length, diff --git a/app/react/Library/helpers/libraryFilters.js b/app/react/Library/helpers/libraryFilters.js index 79695577b3..9f39daa6fb 100644 --- a/app/react/Library/helpers/libraryFilters.js +++ b/app/react/Library/helpers/libraryFilters.js @@ -56,6 +56,7 @@ function URLQueryToState(query, templates, _thesauris, _relationTypes, forcedPro search: { searchTerm, filters, + customFilters: query.customFilters, sort, order, userSelectedSorting, diff --git a/app/react/Library/helpers/requestState.js b/app/react/Library/helpers/requestState.js index dc66ccd084..144f01f5b9 100644 --- a/app/react/Library/helpers/requestState.js +++ b/app/react/Library/helpers/requestState.js @@ -5,6 +5,7 @@ import prioritySortingCriteria from 'app/utils/prioritySortingCriteria'; import rison from 'rison-node'; import { getThesaurusPropertyNames } from 'shared/commonTopicClassification'; import { setTableViewColumns } from 'app/Library/actions/libraryActions'; +import { tocGenerationUtils } from 'app/ToggledFeatures/tocGeneration'; import { wrapDispatch } from 'app/Multireducer'; import { getTableColumns } from './tableColumns'; import setReduxState from './setReduxState.js'; @@ -36,12 +37,17 @@ export function processQuery(params, globalResources, key) { } const { userSelectedSorting, ...sanitizedQuery } = query; - return sanitizedQuery; + return tocGenerationUtils.aggregations( + sanitizedQuery, + globalResources.settings.collection.toJS() + ); } export default function requestState(request, globalResources, calculateTableColumns = false) { const docsQuery = processQuery(request.data, globalResources, 'library'); - const documentsRequest = request.set(docsQuery); + const documentsRequest = request.set( + tocGenerationUtils.aggregations(docsQuery, globalResources.settings.collection.toJS()) + ); const markersRequest = request.set({ ...docsQuery, geolocation: true }); return Promise.all([api.search(documentsRequest), api.search(markersRequest)]).then( diff --git a/app/react/Library/helpers/specs/libraryFilters.spec.js b/app/react/Library/helpers/specs/libraryFilters.spec.js index fa91a8bc15..72225d6450 100644 --- a/app/react/Library/helpers/specs/libraryFilters.spec.js +++ b/app/react/Library/helpers/specs/libraryFilters.spec.js @@ -33,26 +33,17 @@ describe('library helper', () => { const thesauris = [ { _id: 'abc1', - values: [ - { id: 1, value: 'value1' }, - { id: 2, value: 'value2' }, - ], + values: [{ id: 1, value: 'value1' }, { id: 2, value: 'value2' }], }, { _id: 'thesauri2', type: 'template', - values: [ - { id: 3, value: 'value3' }, - { id: 4, value: 'value4' }, - ], + values: [{ id: 3, value: 'value3' }, { id: 4, value: 'value4' }], }, { _id: 'thesauri3', type: 'template', - values: [ - { id: 5, value: 'value5' }, - { id: 6, value: 'value6' }, - ], + values: [{ id: 5, value: 'value5' }, { id: 6, value: 'value6' }], }, ]; @@ -83,10 +74,14 @@ describe('library helper', () => { sort: 'sort', types: ['3'], filters: { country: 'countryValue', rich: 'search' }, + customFilters: { + property: { values: ['value'] }, + }, }; const state = libraryHelper.URLQueryToState(query, templates); expect(state.properties.length).toBe(1); + expect(state.search.customFilters).toEqual(query.customFilters); expect(state.search.filters.country).toBe('countryValue'); expect(state.search.filters.rich).toBe('search'); expect(state.search.searchTerm).toBe('searchTerm'); @@ -146,10 +141,7 @@ describe('library helper', () => { filter: true, type: 'select', content: 'abc1', - options: [ - { id: 1, value: 'value1' }, - { id: 2, value: 'value2' }, - ], + options: [{ id: 1, value: 'value1' }, { id: 2, value: 'value2' }], }, { name: 'date', filter: true, type: 'text' }, ]; diff --git a/app/react/Library/helpers/specs/resquestState.spec.js b/app/react/Library/helpers/specs/resquestState.spec.js index 7df7599f3c..6377d1f40c 100644 --- a/app/react/Library/helpers/specs/resquestState.spec.js +++ b/app/react/Library/helpers/specs/resquestState.spec.js @@ -30,6 +30,7 @@ describe('static requestState()', () => { }; const globalResources = { templates: Immutable.fromJS(templates), + settings: { collection: Immutable.fromJS({ features: {} }) }, thesauris: Immutable.fromJS(thesauris), relationTypes: Immutable.fromJS(relationTypes), }; diff --git a/app/react/ToggledFeatures/tocGeneration/FilterTocGeneration.tsx b/app/react/ToggledFeatures/tocGeneration/FilterTocGeneration.tsx new file mode 100644 index 0000000000..2afe82a2ff --- /dev/null +++ b/app/react/ToggledFeatures/tocGeneration/FilterTocGeneration.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { Aggregations } from 'shared/types/Aggregations.d.ts'; +import { FeatureToggle } from 'app/components/Elements/FeatureToggle'; +import SelectFilter from 'app/Library/components/SelectFilter'; +import FormGroup from 'app/DocumentForm/components/FormGroup'; +import { t } from 'app/I18N'; +import { NeedAuthorization } from 'app/Auth'; + +export interface FilterTocGenerationProps { + onChange: () => void; + aggregations: Aggregations; +} + +const filteredAggregation = (aggregations: Aggregations, key: string) => { + const bucket = (aggregations?.all?.generatedToc?.buckets || []).find(a => a.key === key) || { + filtered: { doc_count: 0 }, + }; + return bucket.filtered.doc_count; +}; + +const options = (aggregations: Aggregations = { all: {} }) => [ + { + label: 'Automatically generated', + value: true, + results: filteredAggregation(aggregations, 'true'), + }, + { + label: 'Reviewed', + value: false, + results: filteredAggregation(aggregations, 'false'), + }, +]; + +export const FilterTocGeneration = ({ onChange, aggregations }: FilterTocGenerationProps) => ( + + + + + + + +); diff --git a/app/react/ToggledFeatures/tocGeneration/ReviewTocButton.tsx b/app/react/ToggledFeatures/tocGeneration/ReviewTocButton.tsx new file mode 100644 index 0000000000..8c5878b62c --- /dev/null +++ b/app/react/ToggledFeatures/tocGeneration/ReviewTocButton.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { Icon } from 'UI'; +import { FeatureToggle } from 'app/components/Elements/FeatureToggle'; +import { connect, ConnectedProps } from 'react-redux'; +import { bindActionCreators, Dispatch } from 'redux'; +import { ClientFile } from 'app/istore'; +import { tocGenerationActions } from './actions'; + +export interface ReviewTocButtonProps { + file: ClientFile; + children: JSX.Element | string; +} + +const mapDispatchToProps = (dispatch: Dispatch<{}>) => + bindActionCreators({ onClick: tocGenerationActions.reviewToc }, dispatch); + +const connector = connect(null, mapDispatchToProps); + +type MappedProps = ConnectedProps; +type ComponentProps = ReviewTocButtonProps & MappedProps; + +const ReviewTocButton = ({ file, onClick, children }: ComponentProps) => ( + + {file.generatedToc && ( + + )} + +); + +const container = connector(ReviewTocButton); +export { container as ReviewTocButton }; diff --git a/app/react/ToggledFeatures/tocGeneration/TocGeneratedLabel.tsx b/app/react/ToggledFeatures/tocGeneration/TocGeneratedLabel.tsx new file mode 100644 index 0000000000..5ef7e63b17 --- /dev/null +++ b/app/react/ToggledFeatures/tocGeneration/TocGeneratedLabel.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { FeatureToggle } from 'app/components/Elements/FeatureToggle'; +import { FileType } from 'shared/types/fileType'; + +export interface TocGeneratedLabelProps { + file: FileType; + children: JSX.Element | string; +} + +export const TocGeneratedLabel = ({ file, children }: TocGeneratedLabelProps) => ( + + {file.generatedToc && {children}} + +); diff --git a/app/react/ToggledFeatures/tocGeneration/actions.ts b/app/react/ToggledFeatures/tocGeneration/actions.ts new file mode 100644 index 0000000000..8ab8f5b5fc --- /dev/null +++ b/app/react/ToggledFeatures/tocGeneration/actions.ts @@ -0,0 +1,36 @@ +import { actions } from 'app/BasicReducer/reducer'; +import { actions as formActions } from 'react-redux-form'; +import { RequestParams } from 'app/utils/RequestParams'; +import api from 'app/utils/api'; +import { notificationActions } from 'app/Notifications'; +import { IStore } from 'app/istore'; +import { Dispatch } from 'redux'; +import { ensure } from 'shared/tsUtils'; +import { FileType } from 'shared/types/fileType'; + +const tocGenerationActions = { + reviewToc(fileId: string) { + return async (dispatch: Dispatch, getState: () => IStore) => { + const currentDoc = getState().documentViewer.doc.toJS(); + dispatch(formActions.reset('documentViewer.sidepanel.metadata')); + + const updatedFile = (await api.post('files/tocReviewed', new RequestParams({ fileId }))).json; + const doc = { + ...currentDoc, + defaultDoc: updatedFile, + documents: ensure(currentDoc.documents).map(d => { + if (d._id === updatedFile._id) { + return updatedFile; + } + return d; + }), + }; + + dispatch(notificationActions.notify('Document updated', 'success')); + dispatch(formActions.reset('documentViewer.sidepanel.metadata')); + dispatch(actions.set('viewer/doc', doc)); + }; + }, +}; + +export { tocGenerationActions }; diff --git a/app/react/ToggledFeatures/tocGeneration/index.ts b/app/react/ToggledFeatures/tocGeneration/index.ts new file mode 100644 index 0000000000..7785be5636 --- /dev/null +++ b/app/react/ToggledFeatures/tocGeneration/index.ts @@ -0,0 +1,4 @@ +export { TocGeneratedLabel } from './TocGeneratedLabel'; +export { ReviewTocButton } from './ReviewTocButton'; +export { FilterTocGeneration } from './FilterTocGeneration'; +export { tocGenerationUtils } from './utils'; diff --git a/app/react/ToggledFeatures/tocGeneration/specs/FilterTocGeneration.spec.tsx b/app/react/ToggledFeatures/tocGeneration/specs/FilterTocGeneration.spec.tsx new file mode 100644 index 0000000000..a2532c54ba --- /dev/null +++ b/app/react/ToggledFeatures/tocGeneration/specs/FilterTocGeneration.spec.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { shallow, ShallowWrapper } from 'enzyme'; +import SelectFilter from 'app/Library/components/SelectFilter'; +import { FilterTocGeneration } from '../FilterTocGeneration'; +import { Aggregations } from 'shared/types/Aggregations'; + +describe('ReviewTocButton', () => { + let component: ShallowWrapper; + const aggregations = { + all: { + generatedToc: { + buckets: [ + { key: 'false', filtered: { doc_count: 2 } }, + { key: 'true', filtered: { doc_count: 5 } }, + ], + }, + }, + }; + + const render = (aggs: Aggregations = aggregations) => { + component = shallow( {}} aggregations={aggs} />); + }; + + it('should render nothing if file generatedToc is false', () => { + render(); + const options = component.find(SelectFilter).prop('options'); + expect(options).toEqual([ + expect.objectContaining({ value: true, results: 5 }), + expect.objectContaining({ value: false, results: 2 }), + ]); + }); + + describe('when there is bucket missing', () => { + it('should not fail (render blank state)', () => { + render({ + all: { + generatedToc: { + buckets: [{ key: 'false', filtered: { doc_count: 2 } }], + }, + }, + }); + const options = component.find(SelectFilter).prop('options'); + expect(options).toEqual([ + expect.objectContaining({ value: true, results: 0 }), + expect.objectContaining({ value: false, results: 2 }), + ]); + }); + }); + + describe('when aggregations are not defined/complete', () => { + it('should not fail (render blank state)', () => { + //@ts-ignore + render({}); + let options = component.find(SelectFilter).prop('options'); + expect(options).toEqual([ + expect.objectContaining({ value: true, results: 0 }), + expect.objectContaining({ value: false, results: 0 }), + ]); + + render({ all: {} }); + options = component.find(SelectFilter).prop('options'); + expect(options).toEqual([ + expect.objectContaining({ value: true, results: 0 }), + expect.objectContaining({ value: false, results: 0 }), + ]); + }); + }); +}); diff --git a/app/react/ToggledFeatures/tocGeneration/specs/ReviewTocButton.spec.tsx b/app/react/ToggledFeatures/tocGeneration/specs/ReviewTocButton.spec.tsx new file mode 100644 index 0000000000..43b032cc8d --- /dev/null +++ b/app/react/ToggledFeatures/tocGeneration/specs/ReviewTocButton.spec.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { shallow, ShallowWrapper } from 'enzyme'; +import configureStore, { MockStore, MockStoreCreator } from 'redux-mock-store'; +import { Provider } from 'react-redux'; +import { ClientFile } from 'app/istore'; +import { ReviewTocButton } from '../ReviewTocButton'; + +describe('ReviewTocButton', () => { + let component: ShallowWrapper; + + const mockStoreCreator: MockStoreCreator = configureStore([]); + const render = (file: Partial) => { + const store: MockStore = mockStoreCreator({}); + component = shallow( + + + test + + + ) + .dive() + .dive(); + }; + + it('should render nothing if file generatedToc is false', () => { + render({ generatedToc: false }); + expect(component.find('button').length).toEqual(0); + + render({}); + expect(component.find('button').length).toEqual(0); + }); + + it('should render when generatedToc is true', () => { + render({ generatedToc: true }); + expect(component.find('button').length).toEqual(1); + }); +}); diff --git a/app/react/ToggledFeatures/tocGeneration/specs/actions.spec.ts b/app/react/ToggledFeatures/tocGeneration/specs/actions.spec.ts new file mode 100644 index 0000000000..8950ca6b73 --- /dev/null +++ b/app/react/ToggledFeatures/tocGeneration/specs/actions.spec.ts @@ -0,0 +1,99 @@ +import api from 'app/utils/api'; +import backend from 'fetch-mock'; +import * as notificationsTypes from 'app/Notifications/actions/actionTypes'; +import { actions as relationshipActions } from 'app/Relationships'; +import { APIURL } from 'app/config'; +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; +import Immutable from 'immutable'; +import { mockID } from 'shared/uniqueID.js'; +import { ClientEntitySchema, IStore } from 'app/istore'; +import { tocGenerationActions } from '../actions'; + +const middlewares = [thunk]; +const mockStore = configureMockStore(middlewares); + +describe('reviewToc', () => { + it('should store the document with the response of reviewToc', done => { + mockID(); + const fileId = 'fileId'; + + backend.post(`${APIURL}files/tocReviewed`, { + body: JSON.stringify({ _id: fileId, generatedToc: false }), + }); + + spyOn(relationshipActions, 'reloadRelationships').and.returnValue({ + type: 'reloadRelationships', + }); + + const doc: ClientEntitySchema = { + name: 'doc', + _id: 'id', + sharedId: 'sharedId', + defaultDoc: { + _id: fileId, + generatedToc: true, + }, + documents: [ + { + _id: fileId, + generatedToc: true, + }, + ], + }; + + const updatedEntity = { + name: 'doc', + _id: 'id', + sharedId: 'sharedId', + defaultDoc: { + _id: fileId, + generatedToc: false, + }, + documents: [ + { + _id: fileId, + generatedToc: false, + }, + ], + }; + + const expectedActions = [ + { type: 'rrf/reset', model: 'documentViewer.sidepanel.metadata' }, + { + type: notificationsTypes.NOTIFY, + notification: { message: 'Document updated', type: 'success', id: 'unique_id' }, + }, + { type: 'rrf/reset', model: 'documentViewer.sidepanel.metadata' }, + { type: 'viewer/doc/SET', value: updatedEntity }, + ]; + + const store = mockStore({ + documentViewer: { + doc: Immutable.fromJS(doc), + references: Immutable.fromJS([]), + targetDocReferences: Immutable.fromJS([]), + targetDoc: Immutable.fromJS(doc), + uiState: Immutable.fromJS({}), + }, + }); + + spyOn(api, 'post').and.callThrough(); + store + //fot this to be properly typed, redux, redux-thunk need to be updated (and probably others), + //producing hundreds of type errors + //@ts-ignore + .dispatch(tocGenerationActions.reviewToc(fileId)) + .then(() => { + expect(api.post).toHaveBeenCalledWith('files/tocReviewed', { + data: { + fileId: 'fileId', + }, + headers: {}, + }); + expect(store.getActions()).toEqual(expectedActions); + }) + .then(done) + .catch(done.fail); + }); +}); diff --git a/app/react/ToggledFeatures/tocGeneration/specs/utils.spec.ts b/app/react/ToggledFeatures/tocGeneration/specs/utils.spec.ts new file mode 100644 index 0000000000..bfdcd09eb5 --- /dev/null +++ b/app/react/ToggledFeatures/tocGeneration/specs/utils.spec.ts @@ -0,0 +1,19 @@ +import Immutable from 'immutable'; +import { processQuery } from 'app/Library/helpers/requestState'; + +describe('Library/Uploads processQuery()', () => { + it('should add aggregateGeneratedToc if feature activated', () => { + const params = { q: '(order:desc,sort:creationDate)' }; + const globalResources = { + settings: { + collection: Immutable.fromJS({ features: { tocGeneration: {} } }), + }, + }; + const query = processQuery(params, globalResources, 'library'); + expect(query).toEqual({ + order: 'desc', + sort: 'creationDate', + aggregateGeneratedToc: true, + }); + }); +}); diff --git a/app/react/ToggledFeatures/tocGeneration/utils.ts b/app/react/ToggledFeatures/tocGeneration/utils.ts new file mode 100644 index 0000000000..42cef93ce9 --- /dev/null +++ b/app/react/ToggledFeatures/tocGeneration/utils.ts @@ -0,0 +1,11 @@ +import { Settings } from 'shared/types/settingsType'; +import { SearchParams } from 'shared/types/searchParams.d.ts'; + +export const tocGenerationUtils = { + aggregations(params: SearchParams, settings: Settings) { + return { + ...params, + ...(settings?.features?.tocGeneration ? { aggregateGeneratedToc: true } : {}), + }; + }, +}; diff --git a/app/react/Uploads/specs/UploadsRoute.spec.js b/app/react/Uploads/specs/UploadsRoute.spec.js index 18889e06cb..644c98994d 100644 --- a/app/react/Uploads/specs/UploadsRoute.spec.js +++ b/app/react/Uploads/specs/UploadsRoute.spec.js @@ -26,6 +26,7 @@ describe('UploadsRoute', () => { ]; const globalResources = { templates: Immutable(templates), + settings: { collection: Immutable({ features: {} }) }, thesauris: Immutable([]), relationTypes: Immutable([]), }; diff --git a/app/react/istore.d.ts b/app/react/istore.d.ts index 39366d8aba..955b58df2b 100644 --- a/app/react/istore.d.ts +++ b/app/react/istore.d.ts @@ -8,6 +8,7 @@ import { TemplateSchema } from 'shared/types/templateType'; import { EntitySchema } from 'shared/types/entityType'; import { UserGroupSchema } from 'shared/types/userGroupType'; import { ConnectionSchema } from 'shared/types/connectionType'; +import { FileType } from 'shared/types/fileType'; export interface TasksState { SyncState?: TaskStatus; @@ -83,8 +84,12 @@ interface ClientTemplateSchema extends TemplateSchema { _id: string; } +export interface ClientFile extends FileType { + _id: string; +} + export interface ClientEntitySchema extends EntitySchema { - documents?: []; + documents?: ClientFile[]; } export interface IStore { @@ -110,8 +115,8 @@ export interface IStore { documentViewer: { references: IImmutable; targetDocReferences: IImmutable; - doc: IImmutable; - targetDoc: IImmutable; + doc: IImmutable; + targetDoc: IImmutable; uiState: IImmutable<{ activeReference: string; }>; diff --git a/app/server.js b/app/server.js index 1cc383b1bb..1befa11243 100644 --- a/app/server.js +++ b/app/server.js @@ -119,7 +119,7 @@ DB.connect(config.DBHOST, dbAuth).then(async () => { const { evidencesVault, features } = await settings.get(); if (evidencesVault && evidencesVault.token && evidencesVault.template) { - console.info('==> 📥 evidences vault config detected, started sync ....'); + console.info('==> 📥 evidences vault config detected, started sync ....'); repeater.start( () => vaultSync.sync(evidencesVault.token, evidencesVault.template), 10000 diff --git a/app/shared/types/Aggregations.d.ts b/app/shared/types/Aggregations.d.ts new file mode 100644 index 0000000000..e1c059a581 --- /dev/null +++ b/app/shared/types/Aggregations.d.ts @@ -0,0 +1,13 @@ +export interface Aggregations { + all: { + [key: string]: { + buckets: Array<{ + key: string; + filtered: { + // eslint-disable-next-line camelcase + doc_count: number; + }; + }>; + }; + }; +} diff --git a/app/shared/types/connectionType.d.ts b/app/shared/types/connectionType.d.ts index 0bcd15b543..1a026f5731 100644 --- a/app/shared/types/connectionType.d.ts +++ b/app/shared/types/connectionType.d.ts @@ -19,6 +19,7 @@ export interface ConnectionSchema { title?: string; template?: ObjectIdSchema; published?: boolean; + generatedToc?: boolean; icon?: { _id?: string | null; label?: string; @@ -42,6 +43,7 @@ export interface ConnectionSchema { | null | string | number + | boolean | { label?: string | null; url?: string | null; @@ -75,6 +77,7 @@ export interface ConnectionSchema { | null | string | number + | boolean | { label?: string | null; url?: string | null; diff --git a/app/shared/types/searchParams.d.ts b/app/shared/types/searchParams.d.ts new file mode 100644 index 0000000000..53c1826b95 --- /dev/null +++ b/app/shared/types/searchParams.d.ts @@ -0,0 +1,33 @@ +/* eslint-disable */ +/**AUTO-GENERATED. RUN yarn emit-types to update.*/ + +export interface SearchParams { + query?: { + aggregateGeneratedToc?: boolean; + filters?: { + [k: string]: unknown | undefined; + }; + customFilters?: { + generatedToc?: { + values?: [] | [string]; + }; + }; + types?: [] | [string]; + _types?: [] | [string]; + fields?: [] | [string]; + allAggregations?: boolean; + aggregations?: string; + order?: 'asc' | 'desc'; + sort?: string; + limit?: number; + from?: number; + searchTerm?: string | number; + includeUnpublished?: boolean; + userSelectedSorting?: boolean; + treatAs?: string; + unpublished?: boolean; + select?: [] | [string]; + geolocation?: boolean; + }; + [k: string]: unknown | undefined; +} diff --git a/app/api/search/searchSchema.ts b/app/shared/types/searchParams.ts similarity index 85% rename from app/api/search/searchSchema.ts rename to app/shared/types/searchParams.ts index abae216f28..38be2b30da 100644 --- a/app/api/search/searchSchema.ts +++ b/app/shared/types/searchParams.ts @@ -1,14 +1,19 @@ -export const searchSchema = { +export const emitSchemaTypes = true; +export const searchParamsSchema = { + title: 'searchParams', properties: { query: { + additionalProperties: false, properties: { aggregateGeneratedToc: { type: 'boolean' }, filters: { type: 'object' }, customFilters: { + additionalProperties: false, type: 'object', properties: { generatedToc: { type: 'object', + additionalProperties: false, properties: { values: { type: 'array', items: [{ type: 'boolean' }] }, }, diff --git a/emitSchemaTypes.js b/emitSchemaTypes.js index 9f0c2df6fe..4b626ea540 100644 --- a/emitSchemaTypes.js +++ b/emitSchemaTypes.js @@ -63,28 +63,30 @@ const writeTypeFile = (file, commonImport, snippets) => { const emitSchemaTypes = async file => { try { - if (!file.match(/Schema/) || file.match(/spec/)) { + if (file.match(/spec/)) { return; } - const schemas = require(`./${file}`); + if (file.match(/shared\/types/) || file.match(/Schema/)) { + const schemas = require(`./${file}`); - if (!schemas.emitSchemaTypes) { - return; - } + if (!schemas.emitSchemaTypes) { + return; + } - const snippets = await Promise.all( - Object.entries(schemas).map(([name, schema]) => { - if (!name.match(/Schema$/)) { - return ''; - } - return compile(schema, schema.title || firstUp(name), opts); - }) - ); + const snippets = await Promise.all( + Object.entries(schemas).map(([name, schema]) => { + if (!name.match(/Schema$/)) { + return ''; + } + return compile(schema, schema.title || firstUp(name), opts); + }) + ); - const contents = fs.readFileSync(file).toString(); + const contents = fs.readFileSync(file).toString(); - writeTypeFile(file, typeImports(contents.match(typeImportFindRegex)), snippets); + writeTypeFile(file, typeImports(contents.match(typeImportFindRegex)), snippets); + } } catch (err) { console.error(`Failed emitting types from ${file}: ${err}.`); }