diff --git a/README.md b/README.md index 744127719a..c156845f27 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Intallation guide # Dependencies - **NodeJs 8.11.x** For ease of update, use nvm: https://github.com/creationix/nvm -- **ElasticSearch 5.5.x** https://www.elastic.co/guide/en/elasticsearch/reference/5.5/install-elasticsearch.html (Make sure to have 5.5, some sections of the instructions use 5.x which would install a different version). Please note that ElasticSearch requires java. +- **ElasticSearch 5.6.x** https://www.elastic.co/guide/en/elasticsearch/reference/5.5/install-elasticsearch.html (Make sure to have 5.5, some sections of the instructions use 5.x which would install a different version). Please note that ElasticSearch requires java. - **MongoDB 3.4.x** https://docs.mongodb.com/v3.4/installation/ (there are known issues with 3.6, please ensure 3.4) - **Yarn** https://yarnpkg.com/en/docs/install - **pdftotext (Poppler)** tested to work on version 0.26 but its recommended to use the latest available for your platform https://poppler.freedesktop.org/ diff --git a/app/api/auth/routes.js b/app/api/auth/routes.js index 6e1c4bf7a8..1ca0de9095 100644 --- a/app/api/auth/routes.js +++ b/app/api/auth/routes.js @@ -16,7 +16,7 @@ export default (app) => { app.use(cookieParser()); app.use(session({ - secret: uniqueID(), + secret: app.get('env') === 'production' ? uniqueID() : 'harvey&lola', store: new MongoStore({ mongooseConnection: mongoose.connection }), diff --git a/app/api/entities/entities.js b/app/api/entities/entities.js index 83906d636d..6dcc42343d 100644 --- a/app/api/entities/entities.js +++ b/app/api/entities/entities.js @@ -448,5 +448,44 @@ export default { }); }, + async addLanguage(language) { + const [lanuageTranslationAlreadyExists] = await this.get({ locale: language }, null, { limit: 1 }); + if (lanuageTranslationAlreadyExists) { + return Promise.resolve(); + } + + const { languages } = await settings.get(); + + const defaultLanguage = languages.find(l => l.default).key; + const duplicate = (offset, totalRows) => { + const limit = 200; + if (offset >= totalRows) { + return Promise.resolve(); + } + + return this.get({ language: defaultLanguage }, null, { skip: offset, limit }) + .then((entities) => { + const newLanguageEntities = entities.map((_entity) => { + const entity = Object.assign({}, _entity); + delete entity._id; + delete entity.__v; + entity.language = language; + return entity; + }); + + return this.saveMultiple(newLanguageEntities); + }) + .then(() => duplicate(offset + limit, totalRows)); + }; + + return this.count({ language: defaultLanguage }) + .then(totalRows => duplicate(0, totalRows)); + }, + + async removeLanguage(locale) { + return model.delete({ locale }) + .then(() => search.deleteLanguage(locale)); + }, + count: model.count }; diff --git a/app/api/entities/entitiesModel.js b/app/api/entities/entitiesModel.js index 8ff53b1797..1a9774bfa8 100644 --- a/app/api/entities/entitiesModel.js +++ b/app/api/entities/entitiesModel.js @@ -49,10 +49,10 @@ const entitySchema = new mongoose.Schema({ entitySchema.index({ title: 'text' }, { language_override: 'mongoLanguage' }); const schema = mongoose.model('entities', entitySchema); +schema.collection.dropIndex('title_text', () => { schema.ensureIndexes(); }); const Model = instanceModel(schema); - const { save } = Model; -const unsuportedLanguages = ['ar', 'sr', 'ka']; +const suportedLanguages = ['da', 'nl', 'en', 'fi', 'fr', 'de', 'hu', 'it', 'nb', 'pt', 'ro', 'ru', 'es', 'sv', 'tr']; const setMongoLanguage = (doc) => { if (!doc.language) { @@ -60,7 +60,7 @@ const setMongoLanguage = (doc) => { } let mongoLanguage = doc.language; - if (unsuportedLanguages.includes(doc.language)) { + if (!suportedLanguages.includes(mongoLanguage)) { mongoLanguage = 'none'; } diff --git a/app/api/entities/specs/entities.spec.js b/app/api/entities/specs/entities.spec.js index 37cec31d81..63fa6d0058 100644 --- a/app/api/entities/specs/entities.spec.js +++ b/app/api/entities/specs/entities.spec.js @@ -808,4 +808,16 @@ describe('entities', () => { .catch(catchErrors(done)); }); }); + + describe('addLanguage()', () => { + it('should duplicate all the entities from the default language to the new one', (done) => { + entities.addLanguage('ab') + .then(() => entities.get({ language: 'ab' })) + .then((newEntities) => { + expect(newEntities.length).toBe(7); + done(); + }) + .catch(catchErrors(done)); + }); + }); }); diff --git a/app/api/entities/specs/fixtures.js b/app/api/entities/specs/fixtures.js index 3d1057f271..1be51170d4 100644 --- a/app/api/entities/specs/fixtures.js +++ b/app/api/entities/specs/fixtures.js @@ -61,7 +61,7 @@ export default { { _id: db.id(), template: templateWithOnlyMultiselect, sharedId: 'otherTemplateWithMultiselect', type: 'entity', language: 'es', metadata: { multiselect: ['value1', 'multiselect'] }, file: { filename: '123.pdf' } } ], settings: [ - { _id: db.id(), languages: [{ key: 'es' }, { key: 'pt' }, { key: 'en' }] } + { _id: db.id(), languages: [{ key: 'es', default: true }, { key: 'pt' }, { key: 'en' }] } ], templates: [ { _id: templateId, diff --git a/app/api/i18n/routes.js b/app/api/i18n/routes.js index dc51166ffe..eb51c77602 100644 --- a/app/api/i18n/routes.js +++ b/app/api/i18n/routes.js @@ -1,6 +1,8 @@ import Joi from 'joi'; import { validateRequest } from 'api/utils'; +import settings from 'api/settings'; +import entities from 'api/entities'; import needsAuthorization from '../auth/authMiddleware'; import translations from './translations'; @@ -17,7 +19,8 @@ export default (app) => { needsAuthorization(), - validateRequest(Joi.object().keys({ + validateRequest(Joi.object() + .keys({ _id: Joi.string(), __v: Joi.number(), locale: Joi.string().required(), @@ -40,23 +43,68 @@ export default (app) => { res.json(response); }) .catch(next); - }); + } + ); - // app.post( - // '/api/translations/addentry', + app.post( + '/api/translations/setasdeafult', + needsAuthorization(), + validateRequest(Joi.object().keys({ + key: Joi.string(), + }).required()), - // needsAuthorization(), + (req, res, next) => { + settings.setDefaultLanguage(req.body.key) + .then((response) => { + req.io.sockets.emit('updateSettings', response); + res.json(response); + }) + .catch(next); + } + ); - // validateRequest(Joi.object().keys({ - // context: Joi.string().required(), - // key: Joi.string().required(), - // value: Joi.string().required(), - // }).required()), + app.post( + '/api/translations/languages', + needsAuthorization(), + validateRequest(Joi.object().keys({ + key: Joi.string(), + label: Joi.string(), + }).required()), + + (req, res, next) => { + Promise.all([ + settings.addLanguage(req.body), + translations.addLanguage(req.body.key), + entities.addLanguage(req.body.key), + ]) + .then(([newSettings, newTranslations]) => { + req.io.sockets.emit('updateSettings', newSettings); + req.io.sockets.emit('translationsChange', newTranslations); + res.json(newSettings); + }) + .catch(next); + } + ); + + app.delete( + '/api/translations/languages', + needsAuthorization(), + validateRequest(Joi.object().keys({ + key: Joi.string(), + }).required()), - // (req, res, next) => { - // translations.addEntry(req.body.context, req.body.key, req.body.value) - // .then(response => res.json(response)) - // .catch(next); - // } - // ); + (req, res, next) => { + Promise.all([ + settings.deleteLanguage(req.query.key), + translations.removeLanguage(req.query.key), + entities.removeLanguage(req.query.key), + ]) + .then(([newSettings, newTranslations]) => { + req.io.sockets.emit('updateSettings', newSettings); + req.io.sockets.emit('translationsChange', newTranslations); + res.json(newSettings); + }) + .catch(next); + } + ); }; diff --git a/app/api/i18n/specs/__snapshots__/routes.spec.js.snap b/app/api/i18n/specs/__snapshots__/routes.spec.js.snap index 666c5f20d6..c68ea1c020 100644 --- a/app/api/i18n/specs/__snapshots__/routes.spec.js.snap +++ b/app/api/i18n/specs/__snapshots__/routes.spec.js.snap @@ -1,5 +1,22 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`i18n translations routes POST /api/translations/setasdeafult should have a validation schema 1`] = ` +Object { + "children": Object { + "key": Object { + "invalids": Array [ + "", + ], + "type": "string", + }, + }, + "flags": Object { + "presence": "required", + }, + "type": "object", +} +`; + exports[`i18n translations routes POST should have a validation schema 1`] = ` Object { "children": Object { diff --git a/app/api/i18n/specs/fixtures.js b/app/api/i18n/specs/fixtures.js index 16c2fc8184..45fc18c6c8 100644 --- a/app/api/i18n/specs/fixtures.js +++ b/app/api/i18n/specs/fixtures.js @@ -1,4 +1,5 @@ import db from 'api/utils/testing_db'; + const entityTemplateId = db.id(); const documentTemplateId = db.id(); const englishTranslation = db.id(); @@ -9,30 +10,35 @@ export default { locale: 'en', contexts: [ { + _id: db.id(), id: 'System', label: 'System', values: [ - {key: 'Password', value: 'Password'}, - {key: 'Account', value: 'Account'}, - {key: 'Email', value: 'E-Mail'}, - {key: 'Age', value: 'Age'} + { key: 'Password', value: 'Password' }, + { key: 'Account', value: 'Account' }, + { key: 'Email', value: 'E-Mail' }, + { key: 'Age', value: 'Age' } ] }, { + _id: db.id(), id: 'Filters', label: 'Filters' }, { + _id: db.id(), id: 'Menu', label: 'Menu' }, { + _id: db.id(), id: entityTemplateId.toString(), label: 'Judge', values: [], type: 'Entity' }, { + _id: db.id(), id: documentTemplateId.toString(), label: 'Court order', values: [], @@ -49,10 +55,10 @@ export default { id: 'System', label: 'System', values: [ - {key: 'Password', value: 'Contraseña'}, - {key: 'Account', value: 'Cuenta'}, - {key: 'Email', value: 'Correo electronico'}, - {key: 'Age', value: 'Edad'} + { key: 'Password', value: 'Contraseña' }, + { key: 'Account', value: 'Cuenta' }, + { key: 'Email', value: 'Correo electronico' }, + { key: 'Age', value: 'Edad' } ] } ] diff --git a/app/api/i18n/specs/routes.spec.js b/app/api/i18n/specs/routes.spec.js index 770d1a10f6..f48f154bf6 100644 --- a/app/api/i18n/specs/routes.spec.js +++ b/app/api/i18n/specs/routes.spec.js @@ -1,4 +1,5 @@ import { catchErrors } from 'api/utils/jasmineHelpers'; +import settings from 'api/settings'; import i18nRoutes from 'api/i18n/routes.js'; import instrumentRoutes from 'api/utils/instrumentRoutes'; import translations from 'api/i18n/translations'; @@ -43,16 +44,22 @@ describe('i18n translations routes', () => { }); }); - // describe('POST addentry', () => { - // it('should add entry to a translation context', (done) => { - // spyOn(translations, 'addEntry').and.returnValue(mockRequest); - // routes.post('/api/translations/addentry', { body: { context: 'System', key: 'Search', value: 'Buscar' } }) - // .then((response) => { - // expect(translations.addEntry).toHaveBeenCalledWith('System', 'Search', 'Buscar'); - // expect(response).toEqual({ translations: 'response' }); - // done(); - // }) - // .catch(catchErrors(done)); - // }); - // }); + describe('POST /api/translations/setasdeafult', () => { + it('should have a validation schema', () => { + expect(routes.post.validation('/api/translations/setasdeafult')).toMatchSnapshot(); + }); + + it('should update the setting', (done) => { + spyOn(settings, 'setDefaultLanguage').and.returnValue(Promise.resolve({ site_name: 'Uwazi' })); + const emit = jasmine.createSpy('emit'); + routes.post('/api/translations/setasdeafult', { body: { key: 'fr' }, io: { sockets: { emit } } }) + .then((response) => { + expect(settings.setDefaultLanguage).toHaveBeenCalledWith('fr'); + expect(response).toEqual({ site_name: 'Uwazi' }); + expect(emit).toHaveBeenCalledWith('updateSettings', { site_name: 'Uwazi' }); + done(); + }) + .catch(catchErrors(done)); + }); + }); }); diff --git a/app/api/i18n/specs/translations.spec.js b/app/api/i18n/specs/translations.spec.js index 51e2f991c2..0b22c14389 100644 --- a/app/api/i18n/specs/translations.spec.js +++ b/app/api/i18n/specs/translations.spec.js @@ -250,4 +250,46 @@ describe('translations', () => { .catch(catchErrors(done)); }); }); + + describe('addLanguage', () => { + it('should clone translations of default language and change language to the one added', async () => { + await translations.addLanguage('fr'); + const allTranslations = await translations.get(); + + const frTranslation = allTranslations.find(t => t.locale === 'fr'); + const defaultTranslation = allTranslations.find(t => t.locale === 'en'); + + expect(frTranslation.contexts[0]._id.toString()).not.toBe(defaultTranslation.contexts[0]._id.toString()); + expect(frTranslation.contexts[1]._id.toString()).not.toBe(defaultTranslation.contexts[1]._id.toString()); + expect(frTranslation.contexts[2]._id.toString()).not.toBe(defaultTranslation.contexts[2]._id.toString()); + expect(frTranslation.contexts[3]._id.toString()).not.toBe(defaultTranslation.contexts[3]._id.toString()); + expect(frTranslation.contexts[4]._id.toString()).not.toBe(defaultTranslation.contexts[4]._id.toString()); + + expect(frTranslation.contexts[0].values).toEqual(defaultTranslation.contexts[0].values); + expect(frTranslation.contexts[1].values).toEqual(defaultTranslation.contexts[1].values); + }); + + describe('when translation already exists', () => { + it('should not clone it again', async () => { + await translations.addLanguage('fr'); + await translations.addLanguage('fr'); + const allTranslations = await translations.get(); + + const frTranslations = allTranslations.filter(t => t.locale === 'fr'); + + expect(frTranslations.length).toBe(1); + }); + }); + }); + + describe('removeLanguage', () => { + it('should remove translation for the language passed', async () => { + await translations.removeLanguage('es'); + await translations.removeLanguage('other'); + const allTranslations = await translations.get(); + + expect(allTranslations.length).toBe(1); + expect(allTranslations[0].locale).toBe('en'); + }); + }); }); diff --git a/app/api/i18n/systemKeys.js b/app/api/i18n/systemKeys.js index 9d97d6d7be..84904b3bcf 100644 --- a/app/api/i18n/systemKeys.js +++ b/app/api/i18n/systemKeys.js @@ -7,6 +7,7 @@ export default [ { key: 'Add entity type', label: 'Add entity type' }, { key: 'Add file', label: 'Add file' }, { key: 'Add link', label: 'Add link' }, +{ key: 'Add language', label: 'Add language' }, { key: 'Add page', label: 'Add page' }, { key: 'Add thesaurus', label: 'Add thesaurus' }, { key: 'Add to all languages', label: 'Add to all languages' }, @@ -51,6 +52,7 @@ export default [ { key: 'Order', label: 'Order' }, { key: 'Delete documents and entities', label: 'Delete documents and entities' }, { key: 'Delete', label: 'Delete' }, +{ key: 'Delete language', label: 'Delete language' }, { key: 'Dictionaries', label: 'Dictionaries' }, { key: 'Dictionaries', label: 'Dictionaries' }, { key: 'Discard changes', label: 'Discard changes' }, diff --git a/app/api/i18n/translations.js b/app/api/i18n/translations.js index aeee77b9f2..2d6b188679 100644 --- a/app/api/i18n/translations.js +++ b/app/api/i18n/translations.js @@ -1,51 +1,41 @@ +import instanceModel from 'api/odm'; import settings from 'api/settings/settings'; -import instanceModel from 'api/odm'; import translationsModel from './translationsModel.js'; const model = instanceModel(translationsModel); function prepareContexts(contexts) { - return contexts.map((context) => { - if (context.id === 'System' || context.id === 'Filters' || context.id === 'Menu') { - context.type = 'Uwazi UI'; - } - - let values = {}; - context.values = context.values || []; - context.values.forEach((value) => { - values[value.key] = value.value; - }); - - context.values = values; - return context; - }); + return contexts.map(context => ({ + ...context, + type: context.id === 'System' || context.id === 'Filters' || context.id === 'Menu' ? 'Uwazi UI' : context.type, + values: context.values ? context.values.reduce((values, value) => ({ ...values, [value.key]: value.value }), {}) : {} + })); } function processContextValues(context) { + let values; if (context.values && !Array.isArray(context.values)) { - let values = []; + values = []; Object.keys(context.values).forEach((key) => { - values.push({key, value: context.values[key]}); + values.push({ key, value: context.values[key] }); }); - context.values = values; } - return context; + return { ...context, values: values || context.values }; } function update(translation) { return model.getById(translation._id) .then((currentTranslationData) => { currentTranslationData.contexts.forEach((context) => { - let isPresentInTheComingData = translation.contexts.find((_context) => _context.id === context.id); + const isPresentInTheComingData = translation.contexts.find(_context => _context.id === context.id); if (!isPresentInTheComingData) { translation.contexts.push(context); } }); - translation.contexts = translation.contexts.map(processContextValues); - return model.save(translation); + return model.save({ ...translation, contexts: translation.contexts.map(processContextValues) }); }); } @@ -53,70 +43,48 @@ export default { prepareContexts, get() { return model.get() - .then((response) => { - return response.map((translation) => { - translation.contexts = prepareContexts(translation.contexts); - return translation; - }); - }); + .then(response => response.map(translation => ({ ...translation, contexts: prepareContexts(translation.contexts) }))); }, save(translation) { - translation.contexts = translation.contexts || []; if (translation._id) { return update(translation); } - translation.contexts = translation.contexts.map(processContextValues); - return model.save(translation); + return model.save({ ...translation, contexts: translation.contexts && translation.contexts.map(processContextValues) }); }, addEntry(contextId, key, defaultValue) { return model.get() - .then((result) => { - return Promise.all(result.map((translation) => { - let context = translation.contexts.find((ctx) => ctx.id === contextId); - if (!context) { - return Promise.resolve(); - } - context.values = context.values || []; - context.values.push({key, value: defaultValue}); - return this.save(translation); - })); - }) - .then(() => { - return 'ok'; - }); + .then(result => Promise.all(result.map((translation) => { + const context = translation.contexts.find(ctx => ctx.id === contextId); + if (!context) { + return Promise.resolve(); + } + context.values = context.values || []; + context.values.push({ key, value: defaultValue }); + return this.save(translation); + }))) + .then(() => 'ok'); }, addContext(id, contextName, values, type) { - let translatedValues = []; + const translatedValues = []; Object.keys(values).forEach((key) => { - translatedValues.push({key, value: values[key]}); + translatedValues.push({ key, value: values[key] }); }); return model.get() - .then((result) => { - return Promise.all(result.map((translation) => { - translation.contexts.push({id, label: contextName, values: translatedValues, type}); - return this.save(translation); - })); - }) - .then(() => { - return 'ok'; - }); + .then(result => Promise.all(result.map((translation) => { + translation.contexts.push({ id, label: contextName, values: translatedValues, type }); + return this.save(translation); + }))) + .then(() => 'ok'); }, deleteContext(id) { return model.get() - .then((result) => { - return Promise.all(result.map((translation) => { - translation.contexts = translation.contexts.filter((tr) => tr.id !== id); - return model.save(translation); - })); - }) - .then(() => { - return 'ok'; - }); + .then(result => Promise.all(result.map(translation => model.save({ ...translation, contexts: translation.contexts.filter(tr => tr.id !== id) })))) + .then(() => 'ok'); }, processSystemKeys(keys) { @@ -124,11 +92,10 @@ export default { .then((languages) => { let existingKeys = languages[0].contexts.find(c => c.label === 'System').values; const newKeys = keys.map(k => k.key); - let keysToAdd = []; + const keysToAdd = []; keys.forEach((key) => { - key.label = key.label || key.key; if (!existingKeys.find(k => key.key === k.key)) { - keysToAdd.push({key: key.key, value: key.label}); + keysToAdd.push({ key: key.key, value: key.label || key.key }); } }); @@ -138,12 +105,12 @@ export default { system = { id: 'System', label: 'System', - values: keys.map((k) => ({key: k.key, value: k.label})) + values: keys.map(k => ({ key: k.key, value: k.label || k.key })) }; language.contexts.unshift(system); } existingKeys = system.values; - let valuesWithRemovedValues = existingKeys.filter(i => newKeys.includes(i.key)); + const valuesWithRemovedValues = existingKeys.filter(i => newKeys.includes(i.key)); system.values = valuesWithRemovedValues.concat(keysToAdd); }); @@ -152,18 +119,18 @@ export default { }, updateContext(id, newContextName, keyNamesChanges, deletedProperties, values) { - let translatedValues = []; + const translatedValues = []; Object.keys(values).forEach((key) => { - translatedValues.push({key, value: values[key]}); + translatedValues.push({ key, value: values[key] }); }); return Promise.all([model.get(), settings.get()]) .then(([translations, siteSettings]) => { - let defaultLanguage = siteSettings.languages.find((lang) => lang.default).key; + const defaultLanguage = siteSettings.languages.find(lang => lang.default).key; return Promise.all(translations.map((translation) => { - let context = translation.contexts.find((tr) => tr.id.toString() === id.toString()); + const context = translation.contexts.find(tr => tr.id.toString() === id.toString()); if (!context) { - translation.contexts.push({id, label: newContextName, values: translatedValues}); + translation.contexts.push({ id, label: newContextName, values: translatedValues }); return this.save(translation); } @@ -171,8 +138,8 @@ export default { context.values = context.values.filter(v => !deletedProperties.includes(v.key)); Object.keys(keyNamesChanges).forEach((originalKey) => { - let newKey = keyNamesChanges[originalKey]; - let value = context.values.find(v => v.key === originalKey); + const newKey = keyNamesChanges[originalKey]; + const value = context.values.find(v => v.key === originalKey); if (value) { value.key = newKey; @@ -181,13 +148,13 @@ export default { } } if (!value) { - context.values.push({key: newKey, value: values[newKey]}); + context.values.push({ key: newKey, value: values[newKey] }); } }); Object.keys(values).forEach((key) => { if (!context.values.find(v => v.key === key)) { - context.values.push({key, value: values[key]}); + context.values.push({ key, value: values[key] }); } }); @@ -196,8 +163,28 @@ export default { return this.save(translation); })); }) - .then(() => { - return 'ok'; + .then(() => 'ok'); + }, + + async addLanguage(language) { + const [lanuageTranslationAlreadyExists] = await model.get({ locale: language }); + if (lanuageTranslationAlreadyExists) { + return Promise.resolve(); + } + + const { languages } = await settings.get(); + + const [defaultTranslation] = await model.get({ locale: languages.find(l => l.default).key }); + + return model.save({ + ...defaultTranslation, + _id: null, + locale: language, + contexts: defaultTranslation.contexts.map(({ _id, ...context }) => context) }); + }, + + async removeLanguage(language) { + return model.delete({ locale: language }); } }; diff --git a/app/api/migrations/migrations/7-relationships_remove_languages/index.js b/app/api/migrations/migrations/7-relationships_remove_languages/index.js new file mode 100644 index 0000000000..e842a9586b --- /dev/null +++ b/app/api/migrations/migrations/7-relationships_remove_languages/index.js @@ -0,0 +1,39 @@ +/* eslint-disable no-await-in-loop */ +export default { + delta: 7, + + name: 'relationships_remove_languages', + + description: 'remove duplication of relationships for each language', + + async up(db) { + process.stdout.write(`${this.name}...\r\n`); + let index = 1; + const [{ languages }] = await db.collection('settings').find().toArray(); + const languagesToRemove = languages.filter(l => !l.default).map(l => l.key); + + const cursor = db.collection('connections').find(); + while (await cursor.hasNext()) { + const relation = await cursor.next(); + const isTextReference = relation.range; + if (!isTextReference) { + await db.collection('connections').deleteMany({ sharedId: relation.sharedId, language: { $in: languagesToRemove } }); + await db.collection('connections').update({ sharedId: relation.sharedId }, { $unset: { language: 1, sharedId: 1 } }); + } + + if (isTextReference) { + const [entityRelated] = await db.collection('entities').find({ sharedId: relation.entity, language: relation.language }).toArray(); + + await db.collection('connections').deleteMany({ sharedId: relation.sharedId, _id: { $ne: relation._id } }); + + await db.collection('connections').update(relation, { + $set: { filename: entityRelated.file.filename }, + $unset: { language: 1, sharedId: 1 }, + }); + } + process.stdout.write(`processed -> ${index}\r`); + index += 1; + } + process.stdout.write('\r\n'); + } +}; diff --git a/app/api/migrations/migrations/7-relationships_remove_languages/specs/7-relationships_remove_languages.spec.js b/app/api/migrations/migrations/7-relationships_remove_languages/specs/7-relationships_remove_languages.spec.js new file mode 100644 index 0000000000..cc547bacac --- /dev/null +++ b/app/api/migrations/migrations/7-relationships_remove_languages/specs/7-relationships_remove_languages.spec.js @@ -0,0 +1,77 @@ +import { catchErrors } from 'api/utils/jasmineHelpers'; +import testingDB from 'api/utils/testing_db'; +import migration from '../index.js'; +import fixtures from './fixtures.js'; + +describe('migration relationships_remove_languages', () => { + beforeEach((done) => { + spyOn(process.stdout, 'write'); + testingDB.clearAllAndLoad(fixtures).then(done).catch(catchErrors(done)); + }); + + afterAll((done) => { + testingDB.disconnect().then(done); + }); + + it('should have a delta number', () => { + expect(migration.delta).toBe(7); + }); + + it('should remove duplicated relationships, sharedIds and languages', async () => { + await migration.up(testingDB.mongodb); + const relationships = await testingDB.mongodb.collection('connections').find({ range: { $exists: false } }).toArray(); + + expect(relationships.length).toBe(2); + + expect(relationships[0]).not.toHaveProperty('sharedId'); + expect(relationships[0]).not.toHaveProperty('langauge'); + expect(relationships[0].entity).toBe('entity1'); + + expect(relationships[1]).not.toHaveProperty('sharedId'); + expect(relationships[1]).not.toHaveProperty('langauge'); + expect(relationships[1].entity).toBe('entity2'); + }); + + describe('text references', () => { + describe('when diferent languages have diferent filenames', () => { + it('should maintain duplication based on filename instead of language', async () => { + await migration.up(testingDB.mongodb); + const relationships = await testingDB.mongodb.collection('connections').find({ entity: 'entity3' }).toArray(); + + const enRelation = relationships.find(r => r.filename === 'enFile'); + const esRelation = relationships.find(r => r.filename === 'esFile'); + const ptRelation = relationships.find(r => r.filename === 'ptFile'); + + expect(enRelation).not.toHaveProperty('sharedId'); + expect(enRelation).not.toHaveProperty('langauge'); + expect(enRelation.entity).toBe('entity3'); + + expect(esRelation).not.toHaveProperty('sharedId'); + expect(esRelation).not.toHaveProperty('langauge'); + expect(esRelation.entity).toBe('entity3'); + + expect(ptRelation).not.toHaveProperty('sharedId'); + expect(ptRelation).not.toHaveProperty('langauge'); + expect(ptRelation.entity).toBe('entity3'); + }); + }); + + describe('when diferent languages have the same filenames', () => { + it('should remove duplication', async () => { + await migration.up(testingDB.mongodb); + let relationships = await testingDB.mongodb.collection('connections').find({ entity: 'entity4' }).toArray(); + expect(relationships.length).toBe(1); + + expect(relationships[0]).not.toHaveProperty('sharedId'); + expect(relationships[0]).not.toHaveProperty('langauge'); + expect(relationships[0].filename).toBe('sameFile'); + + relationships = await testingDB.mongodb.collection('connections').find({ entity: 'entity5' }).toArray(); + expect(relationships.length).toBe(3); + expect(relationships.find(r => r.range.text === 'text_a').filename).toBe('anotherFile'); + expect(relationships.find(r => r.range.text === 'text_b').filename).toBe('anotherFile'); + expect(relationships.find(r => r.filename === 'sameFileOn2')).toBeDefined(); + }); + }); + }); +}); diff --git a/app/api/migrations/migrations/7-relationships_remove_languages/specs/fixtures.js b/app/api/migrations/migrations/7-relationships_remove_languages/specs/fixtures.js new file mode 100644 index 0000000000..1827e4b42a --- /dev/null +++ b/app/api/migrations/migrations/7-relationships_remove_languages/specs/fixtures.js @@ -0,0 +1,47 @@ +export default { + settings: [ + { + languages: [ + { key: 'en', default: true }, + { key: 'es' }, + { key: 'pt' }, + ] + } + ], + connections: [ + { entity: 'entity1', language: 'es', sharedId: 'shared' }, + { entity: 'entity1', language: 'en', sharedId: 'shared' }, + { entity: 'entity1', language: 'pt', sharedId: 'shared' }, + + { entity: 'entity2', language: 'es', sharedId: 'shared2' }, + { entity: 'entity2', language: 'en', sharedId: 'shared2' }, + { entity: 'entity2', language: 'pt', sharedId: 'shared2' }, + + { entity: 'entity3', language: 'es', sharedId: 'shared3', range: {} }, + { entity: 'entity3', language: 'en', sharedId: 'shared4', range: {} }, + { entity: 'entity3', language: 'pt', sharedId: 'shared5', range: {} }, + + { entity: 'entity4', sharedId: 'sameFileDiferentLanguages', language: 'es', range: {} }, + { entity: 'entity4', sharedId: 'sameFileDiferentLanguages', language: 'en', range: {} }, + { entity: 'entity4', sharedId: 'sameFileDiferentLanguages', language: 'pt', range: {} }, + + { entity: 'entity5', sharedId: 'sameFileOn2Languages', language: 'es', range: {} }, + { entity: 'entity5', sharedId: 'sameFileOn2Languages', language: 'en', range: {} }, + { entity: 'entity5', sharedId: 'anotherFile', language: 'pt', range: { text: 'text_a' } }, + + { entity: 'entity5', sharedId: 'anotherTextConnection', language: 'pt', range: { text: 'text_b' } }, + ], + entities: [ + { sharedId: 'entity3', language: 'es', file: { filename: 'esFile' } }, + { sharedId: 'entity3', language: 'en', file: { filename: 'enFile' } }, + { sharedId: 'entity3', language: 'pt', file: { filename: 'ptFile' } }, + + { sharedId: 'entity4', language: 'es', file: { filename: 'sameFile' } }, + { sharedId: 'entity4', language: 'en', file: { filename: 'sameFile' } }, + { sharedId: 'entity4', language: 'pt', file: { filename: 'sameFile' } }, + + { sharedId: 'entity5', language: 'es', file: { filename: 'sameFileOn2' } }, + { sharedId: 'entity5', language: 'en', file: { filename: 'sameFileOn2' } }, + { sharedId: 'entity5', language: 'pt', file: { filename: 'anotherFile' } }, + ] +}; diff --git a/app/api/relationships/model.js b/app/api/relationships/model.js index 4896884bfd..39508fd5e4 100644 --- a/app/api/relationships/model.js +++ b/app/api/relationships/model.js @@ -7,7 +7,7 @@ const relationshipsSchema = new mongoose.Schema({ sharedId: { type: mongoose.Schema.Types.ObjectId, index: true }, template: { type: mongoose.Schema.Types.ObjectId, ref: 'relationTypes', index: true }, metadata: mongoose.Schema.Types.Mixed, - language: String, + filename: String, range: { start: Number, end: Number, diff --git a/app/api/relationships/relationships.js b/app/api/relationships/relationships.js index 4e8fda8040..fe5ea4c311 100644 --- a/app/api/relationships/relationships.js +++ b/app/api/relationships/relationships.js @@ -2,15 +2,16 @@ import { fromJS } from 'immutable'; import templatesAPI from 'api/templates'; import settings from 'api/settings'; import relationtypes from 'api/relationtypes'; -import { generateNamesAndIds } from '../templates/utils'; import entities from 'api/entities/entities'; +import { generateID } from 'api/odm'; +import { createError } from 'api/utils'; import model from './model'; import search from '../search/search'; -import { generateID } from 'api/odm'; -import { createError } from 'api/utils'; +import { generateNamesAndIds } from '../templates/utils'; import { filterRelevantRelationships, groupRelationships } from './groupByRelationships'; +import { RelationshipCollection, groupByHubs } from './relationshipsHelpers'; const normalizeConnectedDocumentData = (relationship, connectedDocument) => { relationship.entityData = connectedDocument; @@ -26,16 +27,21 @@ function getPropertiesToBeConnections(template) { return template.properties.filter(prop => prop.type === 'relationship'); } -function groupByHubs(references) { - const hubs = references.reduce((_hubs, reference) => { - if (!_hubs[reference.hub]) { - _hubs[reference.hub] = []; - } - _hubs[reference.hub].push(reference); - return _hubs; - }, []); - return Object.keys(hubs).map(key => hubs[key]); -} +const createRelationship = async (relationship, language) => { + const isATextReference = relationship.range; + let filename; + if (isATextReference) { + const [entity] = await entities.get({ sharedId: relationship.entity, language }); + ({ filename } = entity.file); + } + + return model.save({ ...relationship, filename }); +}; + +const updateRelationship = async relationship => model.save({ + ...relationship, + template: relationship.template && relationship.template._id !== null ? relationship.template : null +}); function findPropertyHub(propertyRelationType, hubs, entitySharedId) { return hubs.reduce((result, hub) => { @@ -51,15 +57,6 @@ function findPropertyHub(propertyRelationType, hubs, entitySharedId) { }, null); } -function determineDeleteAction(hubId, relation, relationQuery) { - let deleteQuery = relationQuery; - if (relationQuery._id) { - deleteQuery = { sharedId: relation.sharedId.toString() }; - } - - return model.delete(deleteQuery); -} - // Code mostly copied from react/Relationships/reducer/hubsReducer.js, abstract this QUICKLY! const conformRelationships = (rows, parentEntitySharedId) => { let order = -1; @@ -82,7 +79,7 @@ const conformRelationships = (rows, parentEntitySharedId) => { } const newConnection = connection.set('entity', row.delete('connections')); hubsImmutable = hubsImmutable.setIn([hubId, 'rightRelationships', templateId], - hubsImmutable.getIn([hubId, 'rightRelationships', templateId]).push(newConnection)); + hubsImmutable.getIn([hubId, 'rightRelationships', templateId]).push(newConnection)); } }); @@ -135,41 +132,28 @@ export default { return model.getById(id); }, - getDocumentHubs(id, language) { - return model.get({ entity: id, language }) - .then((ownRelations) => { - const hubsIds = ownRelations.map(relationship => relationship.hub); - return model.db.aggregate([ - { $match: { hub: { $in: hubsIds }, language } }, - { $group: { - _id: '$hub', - relationships: { $push: '$$ROOT' }, - count: { $sum: 1 } - } } - ]); - }) - .then(hubs => hubs.filter(hub => hub.count > 1)); + async getDocumentHubs(entity) { + const ownRelations = await model.get({ entity }); + const hubsIds = ownRelations.map(relationship => relationship.hub); + return model.get({ hub: { $in: hubsIds } }); }, - getByDocument(id, language, withEntityData = true) { - return this.getDocumentHubs(id, language) - .then((hubs) => { - const relationships = Array.prototype.concat(...hubs.map(hub => hub.relationships)); - const connectedEntityiesSharedId = relationships.map(relationship => relationship.entity); + getByDocument(sharedId, language) { + return this.getDocumentHubs(sharedId) + .then((_relationships) => { + const connectedEntityiesSharedId = _relationships.map(relationship => relationship.entity); return entities.get({ sharedId: { $in: connectedEntityiesSharedId }, language }) .then((_connectedDocuments) => { const connectedDocuments = _connectedDocuments.reduce((res, doc) => { res[doc.sharedId] = doc; return res; }, {}); - return relationships.map((_relationship) => { - const relationship = Object.assign({}, { template: null }, _relationship); - if (withEntityData) { - return normalizeConnectedDocumentData(relationship, connectedDocuments[relationship.entity]); - } - return relationship; - }); + return new RelationshipCollection(..._relationships) + .removeOtherLanguageTextReferences(connectedDocuments) + .removeSingleHubs() + .removeOrphanHubsOf(sharedId) + .withConnectedData(connectedDocuments); }); }); }, @@ -193,8 +177,8 @@ export default { }); }, - getHub(hub, language) { - return model.get({ hub, language }); + getHub(hub) { + return model.get({ hub }); }, countByRelationType(typeId) { @@ -205,122 +189,40 @@ export default { return model.get({ sharedId }); }, - updateRelationship(relationship) { - const getTemplate = relationship.template && relationship.template._id === null ? null : relationtypes.getById(relationship.template); - return Promise.all([getTemplate, model.get({ sharedId: relationship.sharedId })]) - .then(([template, relationshipsVersions]) => { - let toSyncProperties = []; - if (template && template.properties) { - toSyncProperties = template.properties - .filter(p => p.type.match('select|multiselect|date|multidate|multidaterange|nested')) - .map(p => p.name); - } - - relationship.metadata = relationship.metadata || {}; - const updateRelationships = relationshipsVersions.map((relation) => { - if (relationship._id.toString() === relation._id.toString()) { - if (relationship.template && relationship.template._id === null) { - relationship.template = null; - } - return model.save(relationship); - } - toSyncProperties.map((propertyName) => { - relation.metadata = relation.metadata || {}; - relation.metadata[propertyName] = relationship.metadata[propertyName]; - }); - return model.save(relation); - }); - return Promise.all(updateRelationships).then(relations => relations.find(r => r.language === relationship.language)); - }); - }, - - createRelationship(relationship) { - relationship.sharedId = generateID(); - return entities.get({ sharedId: relationship.entity }) - .then((entitiesVersions) => { - const currentLanguageEntity = entitiesVersions.find(entity => entity.language === relationship.language); - currentLanguageEntity.file = currentLanguageEntity.file || {}; - const relationshipsCreation = entitiesVersions.map((entity) => { - const isATextReference = relationship.range; - entity.file = entity.file || {}; - const entityFileDoesNotMatch = currentLanguageEntity.file.filename !== entity.file.filename; - if (isATextReference && entityFileDoesNotMatch) { - return Promise.resolve(); - } - const _relationship = Object.assign({}, relationship); - _relationship.language = entity.language; - return model.save(_relationship); - }); - return Promise.all(relationshipsCreation).then(relations => relations.filter(r => r).find(r => r.language === relationship.language)); - }); + async bulk(bulkData, language) { + const saves = await Promise.all(bulkData.save.map(reference => this.save(reference, language))); + const deletions = await Promise.all(bulkData.delete.map(reference => this.delete(reference, language))); + return saves.concat(deletions); }, - bulk(bulkData, language) { - const saveActions = bulkData.save.map(reference => this.save(reference, language), false); - const deleteActions = bulkData.delete.map(reference => this.delete(reference, language), false); - const unique = (elem, pos, arr) => arr.indexOf(elem) === pos; - - const hubsAffectedBySave = bulkData.save.map((item) => { - if (Array.isArray(item)) { - return item[0].hub; - } - return item.hub; - }).filter(unique); - - const hubsAffectedByDelete = bulkData.delete.map(item => item.hub).filter(unique); - const hubsAffected = hubsAffectedBySave.concat(hubsAffectedByDelete).filter(unique); - const entitiesAffectedByDelete = bulkData.delete.map(item => item.entity).filter(unique); - - return Promise.all(saveActions.concat(deleteActions)) - .then(bulkResponse => Promise.all(hubsAffected.map(hubid => this.getHub(hubid, language))) - .then((hubs) => { - const entitiesAffected = hubs.reduce((result, hub) => result.concat(hub.map(relationship => relationship.entity)), []) - .concat(entitiesAffectedByDelete).filter(unique); - return entities.updateMetdataFromRelationships(entitiesAffected, language) - .then(() => bulkResponse); - })); - }, - - save(_relationships, language, updateMetdata = true) { + async save(_relationships, language) { if (!language) { - return Promise.reject(createError('Language cant be undefined')); - } - let relationships = _relationships; - if (!Array.isArray(relationships)) { - relationships = [relationships]; + throw createError('Language cant be undefined'); } + const relationships = !Array.isArray(_relationships) ? [_relationships] : _relationships; + if (relationships.length === 1 && !relationships[0].hub) { - return Promise.reject(createError('Single relationships must have a hub')); + throw createError('Single relationships must have a hub'); } + const hub = relationships[0].hub || generateID(); - return Promise.all( - relationships.map((relationship) => { - let action; - relationship.hub = hub; - relationship.language = language; - if (relationship.sharedId) { - action = this.updateRelationship(relationship); - } else { - action = this.createRelationship(relationship); - } - return action - .then(savedRelationship => Promise.all([savedRelationship, entities.getById(savedRelationship.entity, language)])) - .then(([result, connectedEntity]) => { - if (updateMetdata) { - return this.updateEntitiesMetadataByHub(hub, language) - .then(() => normalizeConnectedDocumentData(result, connectedEntity)); - } - return normalizeConnectedDocumentData(result, connectedEntity); - }); - }) - ); + const result = await Promise.all(relationships.map((relationship) => { + const action = relationship._id ? updateRelationship : createRelationship; + + return action({ ...relationship, hub }, language) + .then(savedRelationship => Promise.all([savedRelationship, entities.getById(savedRelationship.entity, language)])) + .then(([savedRelationship, connectedEntity]) => normalizeConnectedDocumentData(savedRelationship, connectedEntity)); + })); + + await this.updateEntitiesMetadataByHub(hub, language); + return result; }, updateEntitiesMetadataByHub(hubId, language) { - return this.getHub(hubId, language) + return this.getHub(hubId) .then(hub => entities.updateMetdataFromRelationships(hub.map(r => r.entity), language)); }, @@ -354,7 +256,7 @@ export default { const referencesOfThisType = references.filter(reference => reference.template && - reference.template.toString() === propertyRelationType.toString() + reference.template.toString() === propertyRelationType.toString() ); propertyValues.forEach((entitySharedId) => { @@ -364,9 +266,9 @@ export default { } }); const referencesToBeDeleted = references.filter(reference => !(reference.entity === entity.sharedId) && - reference.template && reference.template.toString() === propertyRelationType && - (!entityType || reference.entityData.template.toString() === entityType) && - !propertyValues.includes(reference.entity)); + reference.template && reference.template.toString() === propertyRelationType && + (!entityType || reference.entityData.template.toString() === entityType) && + !propertyValues.includes(reference.entity)); let save = Promise.resolve(); if (hub.length > 1) { @@ -428,45 +330,45 @@ export default { }); }, - delete(relationQuery, language, updateMetdata = true) { + async delete(relationQuery, language, updateMetdata = true) { if (!relationQuery) { return Promise.reject(createError('Cant delete without a condition')); } - let languages; - let relation; - - return Promise.all([settings.get(), model.get(relationQuery)]) - .then(([_settings, relationships]) => { - ({ languages } = _settings); - [relation] = relationships; - return relationships; - }) - .then(relationships => Promise.all(relationships.map(_relation => model.get({ hub: _relation.hub })))) - .then(hubsRelationships => Promise.all(hubsRelationships.map((hub) => { - let deleteAction = determineDeleteAction(hub[0].hub, relation, relationQuery); - - if (updateMetdata) { - deleteAction = deleteAction.then(() => Promise.all(languages.map(l => this.updateEntitiesMetadata(hub.map(r => r.entity), l.key)))); - } + const unique = (elem, pos, arr) => arr.indexOf(elem) === pos; + const relationsToDelete = await model.get(relationQuery, 'hub'); + const hubsAffected = relationsToDelete.map(r => r.hub).filter(unique); - return deleteAction - .then(response => Promise.all([response, model.get({ hub: hub[0].hub })])) - .then(([response, hubRelationships]) => { - const shouldDeleteHub = languages.reduce((shouldDelete, currentLanguage) => - hubRelationships.filter(r => r.language === currentLanguage.key).length < 2 && shouldDelete, true - ); - if (shouldDeleteHub) { - return model.delete({ hub: hub[0].hub }); - } + const { languages } = await settings.get(); + const entitiesAffected = await model.db.aggregate([ + { $match: { hub: { $in: hubsAffected } } }, + { $group: { _id: '$entity' } }, + ]); - return Promise.resolve(response); - }); - }))); + const response = await model.delete(relationQuery); + + const hubsToDelete = await model.db.aggregate([ + { $match: { hub: { $in: hubsAffected } } }, + { $group: { _id: '$hub', length: { $sum: 1 } } }, + { $match: { length: { $lt: 2 } } } + ]); + + await model.delete({ hub: { $in: hubsToDelete.map(h => h._id) } }); + + if (updateMetdata) { + await Promise.all(languages.map(l => this.updateEntitiesMetadata(entitiesAffected.map(e => e._id), l.key))); + } + + return response; }, - deleteTextReferences(sharedId, language) { - return model.delete({ entity: sharedId, language, range: { $exists: true } }); + async deleteTextReferences(sharedId, language) { + const [{ _id, file = {} }] = await entities.get({ sharedId, language }, 'file'); + const languagesWithSameFile = await entities.count({ 'file.filename': file.filename, sharedId, _id: { $ne: _id } }); + if (!languagesWithSameFile && file.filename) { + return this.delete({ filename: file.filename }); + } + return Promise.resolve(); }, updateMetadataProperties(template, currentTemplate) { diff --git a/app/api/relationships/relationshipsHelpers.js b/app/api/relationships/relationshipsHelpers.js new file mode 100644 index 0000000000..9bb23d0be4 --- /dev/null +++ b/app/api/relationships/relationshipsHelpers.js @@ -0,0 +1,51 @@ + +function groupByHubs(references) { + const hubs = references.reduce((_hubs, reference) => { + if (!_hubs[reference.hub]) { + _hubs[reference.hub] = []; //eslint-disable-line no-param-reassign + } + _hubs[reference.hub].push(reference); + return _hubs; + }, []); + return Object.keys(hubs).map(key => hubs[key]); +} + +class RelationshipCollection extends Array { + removeOtherLanguageTextReferences(connectedDocuments) { + return this.filter((r) => { + if (r.filename) { + const filename = connectedDocuments[r.entity].file ? connectedDocuments[r.entity].file.filename : ''; + return r.filename === filename; + } + return true; + }); + } + + removeOrphanHubsOf(sharedId) { + const hubs = groupByHubs(this).filter(h => h.map(r => r.entity).includes(sharedId)); + return new RelationshipCollection(...Array.prototype.concat(...hubs)); + } + + removeSingleHubs() { + const hubRelationshipsCount = this.reduce((data, r) => { + data[r.hub.toString()] = data[r.hub.toString()] ? data[r.hub.toString()] + 1 : 1; //eslint-disable-line no-param-reassign + return data; + }, {}); + + return this.filter(r => hubRelationshipsCount[r.hub.toString()] > 1); + } + + withConnectedData(connectedDocuments) { + return this.map(relationship => ({ + template: null, + entityData: connectedDocuments[relationship.entity], + ...relationship, + })); + } +} + +export { + RelationshipCollection, + groupByHubs, +}; + diff --git a/app/api/relationships/specs/__snapshots__/relationships.spec.js.snap b/app/api/relationships/specs/__snapshots__/relationships.spec.js.snap new file mode 100644 index 0000000000..629ea50a18 --- /dev/null +++ b/app/api/relationships/specs/__snapshots__/relationships.spec.js.snap @@ -0,0 +1,39 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`relationships bulk() should save or delete the relationships 1`] = ` +Array [ + Array [ + Object { + "_id": "connectionID5", + "entity": "entity3", + "entityData": Object { + "_id": "entity3", + "creationDate": 456, + "icon": "icon3", + "language": "en", + "metadata": Object { + "data": "data2", + }, + "published": true, + "sharedId": "entity3", + "template": "template", + "title": "entity3 title", + "type": "entity", + }, + "hub": "hub2", + "range": Object { + "text": "changed text", + }, + "template": "relation2", + }, + ], + Object { + "n": 1, + "ok": 1, + }, + Object { + "n": 1, + "ok": 1, + }, +] +`; diff --git a/app/api/relationships/specs/fixtures.js b/app/api/relationships/specs/fixtures.js index 44a91f115a..ee20d8d965 100644 --- a/app/api/relationships/specs/fixtures.js +++ b/app/api/relationships/specs/fixtures.js @@ -4,6 +4,14 @@ const connectionID1 = db.id(); const connectionID2 = db.id(); const connectionID3 = db.id(); const connectionID4 = db.id(); +const connectionID5 = db.id(); +const connectionID6 = db.id(); +const connectionID7 = db.id(); +const connectionID8 = db.id(); +const connectionID9 = db.id(); + +const entity3 = db.id(); + const inbound = db.id(); const template = db.id(); const thesauri = db.id(); @@ -34,54 +42,52 @@ const sharedId1 = db.id(); const sharedId2 = db.id(); const sharedId3 = db.id(); const sharedId4 = db.id(); -const sharedId5 = db.id(); -const sharedId6 = db.id(); const sharedId7 = db.id(); export default { connections: [ - {entity: 'entity1', hub: hub1, language: 'en', template: relation1, sharedId: db.id()}, - {entity: 'entity2', hub: hub1, language: 'en', template: relation1, sharedId: db.id()}, - - {entity: 'entity2', hub: hub2, template: relation2, sharedId: db.id(), language: 'en'}, - {entity: 'entity3', hub: hub2, template: relation2, range: {text: 'english'}, language: 'en', sharedId: sharedId1}, - {entity: 'entity3', hub: hub2, language: 'en', sharedId: sharedId4}, - - {entity: 'entity2', hub: hub11, template: relation2, sharedId: db.id(), language: 'ru'}, - {entity: 'entity3', hub: hub11, template: relation2, range: {text: 'rusian'}, language: 'ru', sharedId: sharedId1}, - {entity: 'entity3', hub: hub11, language: 'en', sharedId: db.id()}, - - {entity: 'entity2', hub: hub3, template: relation2, sharedId: db.id(), language: 'en'}, - {entity: 'doc4', hub: hub3, template: relation2, sharedId: db.id(), language: 'en'}, - - {entity: 'doc5', hub: hub4, template: relation1, sharedId: db.id(), language: 'en'}, - {entity: 'entity2', hub: hub4, template: relation1, sharedId: db.id(), language: 'en'}, - {entity: 'entity3', hub: hub4, template: relation1, sharedId: db.id(), language: 'en'}, - - {entity: 'target', hub: hub5, sharedId: db.id(), language: 'en'}, - {entity: 'target', hub: hub5, sharedId: db.id(), language: 'en'}, - - {entity: 'target1', hub: hub6, sharedId: db.id(), language: 'en'}, - - {_id: connectionID1, entity: 'entity_id', hub: hub7, template: relation1, sharedId: sharedId2, language: 'en'}, - {entity: 'entity_id', hub: hub7, template: relation1, sharedId: sharedId2, language: 'es'}, - {entity: value2ID, hub: hub7, range: 'range1', template: relation1, sharedId: sharedId3, language: 'en'}, - {entity: value2ID, hub: hub7, range: 'range1', template: relation1, sharedId: sharedId3, language: 'es'}, - {entity: 'another_id', hub: hub7, template: relation1, sharedId: sharedId5, language: 'en'}, - {_id: connectionID2, entity: 'another_id', hub: hub7, template: relation1, sharedId: sharedId5, language: 'es'}, - {_id: connectionID3, entity: 'document_id', range: { end: 1 }, hub: hub7, template: relation1, sharedId: sharedId6, language: 'en'}, - - {_id: inbound, entity: value2ID, hub: hub8, sharedId: db.id()}, - {entity: 'entity_id', hub: hub8, sharedId: db.id()}, - {entity: 'entity_id', hub: hub8, sharedId: db.id()}, - {entity: 'bruceWayne', hub: hub9, sharedId: db.id(), language: 'en'}, - {entity: 'thomasWayne', hub: hub9, template: family, sharedId: db.id(), language: 'en'}, - {entity: 'IHaveNoTemplate', hub: hub9, template: null, sharedId: db.id(), language: 'en'}, - - {hub: hub12, entity: 'entity1', sharedId: sharedId7, language: 'es' }, - {hub: hub12, entity: 'entity1', sharedId: sharedId7, language: 'en' }, - {hub: hub12, _id: connectionID4, entity: 'doc1', sharedId: db.id(), range: { end: 5, text: 'not empty' }, language: 'es' }, - {hub: hub12, entity: 'doc2', sharedId: db.id(), range: { end: 9, text: 'another text' }, language: 'en' }, + {entity: 'entity1', hub: hub1}, + {entity: 'entity2', hub: hub1, template: relation1}, + + {_id: connectionID5, entity: 'entity3', hub: hub2, template: relation2}, + {_id: connectionID8, entity: 'entity2', hub: hub2, template: relation2}, + {_id: connectionID9, entity: 'entity3', hub: hub2}, + + {entity: 'entity2', hub: hub3, template: relation2}, + {entity: 'doc4', hub: hub3, template: relation2}, + + {entity: 'entity2', hub: hub4, template: relation1}, + {entity: 'doc5', hub: hub4, template: relation1}, + {entity: 'entity3', hub: hub4, template: relation1}, + + {entity: 'target', hub: hub5}, + {entity: 'doc5', hub: hub5, range: {}, filename: 'doc5enFile'}, + {entity: 'target', hub: hub5}, + + {entity: 'target1', hub: hub6}, + + {_id: connectionID1, entity: 'entity_id', hub: hub7, template: relation1}, + {_id: connectionID2, entity: 'another_id', hub: hub7, template: relation1}, + {_id: connectionID3, entity: 'document_id', range: { end: 1 }, hub: hub7}, + {entity: value2ID, hub: hub7, range: 'range1', template: relation1}, + + {_id: inbound, entity: value2ID, hub: hub8}, + {entity: 'entity_id', hub: hub8}, + {entity: 'entity_id', hub: hub8}, + {hub: hub8, entity: 'doc2', range: { end: 9, text: 'another text' }, filename: 'doc2enFile'}, + {hub: hub8, entity: 'doc2', range: { end: 9, text: 'another text' }, filename: 'doc2ptFile'}, + + {entity: 'bruceWayne', hub: hub9}, + {entity: 'thomasWayne', hub: hub9, template: family}, + {entity: 'IHaveNoTemplate', hub: hub9, template: null}, + {hub: hub9, entity: 'doc2'}, + + {_id: connectionID6, entity: 'entity2', hub: hub11, template: relation2}, + {_id: connectionID7, entity: 'entity3', hub: hub11}, + + {hub: hub12, _id: connectionID4, entity: 'doc1', range: { end: 5, text: 'not empty' }, filename: 'doc1enFile'}, + {hub: hub12, entity: 'entity1'}, + {hub: hub12, entity: 'doc2', range: { end: 9, text: 'another text' }, filename: 'doc2enFile'}, ], templates: [ {_id: templateWithoutProperties}, @@ -120,18 +126,25 @@ export default { ]} ], entities: [ - {sharedId: 'entity1', language: 'en', title: 'entity1 title', type: 'document', template: template, icon: 'icon1', metadata: {data: 'data1'}, creationDate: 123}, + {sharedId: 'doc1', language: 'en', title: 'doc1 en title', type: 'document', template: template, file: {filename: 'doc1enFile'}, metadata: {data: 'data3'}, creationDate: 789}, + {sharedId: 'doc1', language: 'pt', title: 'doc1 pt title', type: 'document', template: template, file: {filename: 'doc1ptFile'}, metadata: {data: 'data3'}, creationDate: 789}, + {sharedId: 'doc1', language: 'es', title: 'doc1 es title', type: 'document', template: template, file: {filename: 'doc1enFile'}, metadata: {data: 'data3'}, creationDate: 789}, + {sharedId: 'doc2', language: 'en', title: 'doc2 title', type: 'document', template: template, published: true, file: {filename: 'doc2enFile'}}, + {sharedId: 'doc2', language: 'pt', title: 'doc2 title', type: 'document', template: template, published: true, file: {filename: 'doc2ptFile'}}, + {sharedId: 'doc2', language: 'es', title: 'doc2 title', type: 'document', template: template, published: true, file: {filename: 'doc2esFile'}}, + + {sharedId: 'entity1', language: 'en', title: 'entity1 title', file: {}, type: 'document', template: template, icon: 'icon1', metadata: {data: 'data1'}, creationDate: 123}, {sharedId: 'entity2', language: 'en', title: 'entity2 title', type: 'document', template: template, icon: 'icon1', metadata: {data: 'data2'}, creationDate: 123}, - {sharedId: 'entity3', language: 'en', title: 'entity3 title', type: 'entity', template: template, published: true, icon: 'icon3', metadata: {data: 'data2'}, creationDate: 456}, + {_id: entity3, sharedId: 'entity3', language: 'en', title: 'entity3 title', type: 'entity', template: template, published: true, icon: 'icon3', metadata: {data: 'data2'}, creationDate: 456}, {sharedId: 'entity3', language: 'ru', title: 'entity3 title', type: 'entity', template: template, published: true, icon: 'icon3', metadata: {data: 'data2'}, creationDate: 456}, {sharedId: 'entity4', language: 'en', title: 'entity4 title', type: 'entity', template: template, published: true, icon: 'icon3', metadata: {data: 'data2'}, creationDate: 456}, {sharedId: 'entity4', language: 'ru', title: 'entity4 title', type: 'entity', template: template, published: true, icon: 'icon3', metadata: {data: 'data2'}, creationDate: 456}, - {sharedId: 'doc4', language: 'en', title: 'doc4 en title', type: 'document', template: template, file: {filename: 'en'}, metadata: {data: 'data3'}, creationDate: 789}, - {sharedId: 'doc4', language: 'pt', title: 'doc4 pt title', type: 'document', template: template, file: {filename: 'pt'}, metadata: {data: 'data3'}, creationDate: 789}, - {sharedId: 'doc4', language: 'es', title: 'doc4 es title', type: 'document', template: template, file: {filename: 'en'}, metadata: {data: 'data3'}, creationDate: 789}, - {sharedId: 'doc5', language: 'en', title: 'doc5 title', type: 'document', template: template, published: true, file: {filename: 'en'}}, - {sharedId: 'doc5', language: 'es', title: 'doc5 title', type: 'document', template: template, published: true, file: {filename: 'en'}}, - {sharedId: 'doc5', language: 'pt', title: 'doc5 title', type: 'document', template: template, published: true, file: {filename: 'en'}}, + {sharedId: 'doc4', language: 'en', title: 'doc4 en title', type: 'document', template: template, file: {filename: 'doc4enFile'}, metadata: {data: 'data3'}, creationDate: 789}, + {sharedId: 'doc4', language: 'pt', title: 'doc4 pt title', type: 'document', template: template, file: {filename: 'doc4ptFile'}, metadata: {data: 'data3'}, creationDate: 789}, + {sharedId: 'doc4', language: 'es', title: 'doc4 es title', type: 'document', template: template, file: {filename: 'doc4enFile'}, metadata: {data: 'data3'}, creationDate: 789}, + {sharedId: 'doc5', language: 'en', title: 'doc5 title', type: 'document', template: template, published: true, file: {filename: 'doc5enFile'}}, + {sharedId: 'doc5', language: 'es', title: 'doc5 title', type: 'document', template: template, published: true, file: {filename: 'doc5enFile'}}, + {sharedId: 'doc5', language: 'pt', title: 'doc5 title', type: 'document', template: template, published: true, file: {filename: 'doc5enFile'}}, {sharedId: selectValueID, language: 'en', title: 'selectValue', type: 'entity'}, {sharedId: value1ID, language: 'en', title: 'value1', type: 'entity'}, {sharedId: value2ID, language: 'en', title: 'value2', type: 'entity', template}, @@ -167,6 +180,12 @@ export { connectionID2, connectionID3, connectionID4, + connectionID5, + connectionID6, + connectionID7, + connectionID8, + connectionID9, + entity3, selectValueID, value1ID, value2ID, @@ -184,12 +203,12 @@ export { hub8, hub9, hub10, + hub11, hub12, family, friend, sharedId2, sharedId3, sharedId4, - sharedId5, sharedId7, }; diff --git a/app/api/relationships/specs/relationships.spec.js b/app/api/relationships/specs/relationships.spec.js index 0d6e6275ff..254ab66e55 100644 --- a/app/api/relationships/specs/relationships.spec.js +++ b/app/api/relationships/specs/relationships.spec.js @@ -1,11 +1,33 @@ /* eslint-disable max-nested-callbacks */ +import { catchErrors } from 'api/utils/jasmineHelpers'; import db from 'api/utils/testing_db'; import entities from 'api/entities/entities'; -import { catchErrors } from 'api/utils/jasmineHelpers'; +import fixtures, { + connectionID1, + connectionID2, + connectionID3, + connectionID4, + connectionID5, + connectionID6, + connectionID8, + connectionID9, + entity3, + hub1, + hub2, + hub5, + hub7, + hub8, + hub9, + hub11, + hub12, + friend, + family, + relation1, + relation2, + template +} from './fixtures'; import relationships from '../relationships'; -import fixtures, { connectionID1, connectionID2, connectionID3, connectionID4, hub1, hub2, hub7, hub12, relation1, relation2, - template, sharedId1, sharedId3, sharedId4, sharedId5, sharedId7 } from './fixtures'; import search from '../../search/search'; describe('relationships', () => { @@ -19,10 +41,10 @@ describe('relationships', () => { }); describe('getByDocument()', () => { - it('should return all the relationships of a document in the current language', (done) => { + it('should return all the relationships of a document', (done) => { relationships.getByDocument('entity2', 'en') .then((result) => { - expect(result.length).toBe(10); + expect(result.length).toBe(12); const entity1Connection = result.find(connection => connection.entity === 'entity1'); expect(entity1Connection.entityData.title).toBe('entity1 title'); expect(entity1Connection.entityData.icon).toBe('icon1'); @@ -46,279 +68,240 @@ describe('relationships', () => { .catch(catchErrors(done)); }); - it('should set template to null if no template found', (done) => { - relationships.getByDocument('entity2', 'en') - .then((results) => { - const noTemplateConnection = results.find(connection => connection.sharedId.toString() === sharedId4.toString()); - expect(noTemplateConnection.template).toBe(null); - done(); - }); - }); - }); + it('should return text references only for the relations that match the filename of the entity', async () => { + const entity1EnRelationships = await relationships.getByDocument('entity1', 'en'); + const entity1EsRelationships = await relationships.getByDocument('entity1', 'es'); + const entity1PtRelationships = await relationships.getByDocument('entity1', 'pt'); - describe('getGroupsByConnection()', () => { - it('should return groups of connection types and templates of all the relationships of a document', (done) => { - relationships.getGroupsByConnection('entity2', 'en') - .then((results) => { - const group1 = results.find(r => r.key === relation1.toString()); - expect(group1.key).toBe(relation1.toString()); - expect(group1.connectionLabel).toBe('relation 1'); - expect(group1.context).toBe(relation1.toString()); - expect(group1.templates.length).toBe(1); - expect(group1.templates[0].count).toBe(2); - - const group2 = results.find(r => r.key === relation2.toString()); - expect(group2.key).toBe(relation2.toString()); - expect(group2.connectionLabel).toBe('relation 2'); - expect(group2.context).toBe(relation2.toString()); - expect(group2.templates.length).toBe(1); - - expect(group2.templates[0]._id.toString()).toBe(template.toString()); - expect(group2.templates[0].label).toBe('template'); + expect(entity1EnRelationships.length).toBe(5); + expect(entity1EnRelationships.filter(r => r.hub.toString() === hub1.toString()).length).toBe(2); + expect(entity1EnRelationships.filter(r => r.hub.toString() === hub12.toString()).length).toBe(3); - done(); - }) - .catch(catchErrors(done)); + expect(entity1EsRelationships.length).toBe(4); + expect(entity1EsRelationships.filter(r => r.hub.toString() === hub1.toString()).length).toBe(2); + expect(entity1EsRelationships.filter(r => r.hub.toString() === hub12.toString()).length).toBe(2); + + expect(entity1PtRelationships.length).toBe(2); + expect(entity1PtRelationships.filter(r => r.hub.toString() === hub1.toString()).length).toBe(2); }); - it('should return groups of connection including unpublished docs if user is found', (done) => { - relationships.getGroupsByConnection('entity2', 'en', { user: 'found' }) - .then((results) => { - expect(results.length).toBe(3); - const group1 = results.find(r => r.key === relation1.toString()); - expect(group1.key).toBe(relation1.toString()); - expect(group1.templates[0]._id.toString()).toBe(template.toString()); + it('should set template to null if no template found', async () => { + const relations = await relationships.getByDocument('entity2', 'en'); + const relationshipWithoutTemplate = relations.find(r => r._id.equals(connectionID9)); + const relationshipWithTemplate = relations.find(r => r._id.equals(connectionID8)); - const group2 = results.find(r => r.key === relation2.toString()); - expect(group2.key).toBe(relation2.toString()); - expect(group2.templates[0].count).toBe(2); + expect(relationshipWithoutTemplate.template).toBe(null); + expect(relationshipWithTemplate.template).not.toBe(null); + }); - const group3 = results.find(r => !r.key); - expect(group3.key).toBe(null); - expect(group3.templates[0].count).toBe(1); + it('should not return hubs that are connected only to other languages', async () => { + const relations = await relationships.getByDocument('doc2', 'es'); + expect(relations.filter(r => r.hub.equals(hub8)).length).toBe(0); + }); + }); - done(); - }) - .catch(catchErrors(done)); + describe('getGroupsByConnection()', () => { + it('should return groups of connection types and templates of all the relationships of a document', async () => { + const groups = await relationships.getGroupsByConnection('entity2', 'en'); + const group1 = groups.find(r => r.key === relation1.toString()); + expect(group1.key).toBe(relation1.toString()); + expect(group1.connectionLabel).toBe('relation 1'); + expect(group1.context).toBe(relation1.toString()); + expect(group1.templates.length).toBe(1); + expect(group1.templates[0].count).toBe(2); + + const group2 = groups.find(r => r.key === relation2.toString()); + expect(group2.key).toBe(relation2.toString()); + expect(group2.connectionLabel).toBe('relation 2'); + expect(group2.context).toBe(relation2.toString()); + expect(group2.templates.length).toBe(1); + + expect(group2.templates[0]._id.toString()).toBe(template.toString()); + expect(group2.templates[0].label).toBe('template'); }); - it('should return groups of connection wihtout refs if excluded', (done) => { - relationships.getGroupsByConnection('entity2', 'en', { excludeRefs: true }) - .then((results) => { - expect(results.length).toBe(3); - expect(results[0].templates[0].refs).toBeUndefined(); - expect(results[1].templates[0].refs).toBeUndefined(); - expect(results[2].templates[0].refs).toBeUndefined(); + it('should return groups of connection including unpublished docs if user is found', async () => { + const groups = await relationships.getGroupsByConnection('entity2', 'en', { user: 'found' }); + expect(groups.length).toBe(3); + const group1 = groups.find(r => r.key === relation1.toString()); + expect(group1.key).toBe(relation1.toString()); + expect(group1.templates[0]._id.toString()).toBe(template.toString()); - done(); - }) - .catch(catchErrors(done)); + const group2 = groups.find(r => r.key === relation2.toString()); + expect(group2.key).toBe(relation2.toString()); + expect(group2.templates[0].count).toBe(2); + + const group3 = groups.find(r => !r.key); + expect(group3.key).toBe(null); + expect(group3.templates[0].count).toBe(3); + }); + + it('should return groups of connection wihtout refs if excluded', async () => { + const groups = await relationships.getGroupsByConnection('entity2', 'en', { excludeRefs: true }); + expect(groups.length).toBe(3); + expect(groups[0].templates[0].refs).toBeUndefined(); + expect(groups[1].templates[0].refs).toBeUndefined(); + expect(groups[2].templates[0].refs).toBeUndefined(); }); }); describe('getHub()', () => { - it('should return all the connections of the smae hub', (done) => { - relationships.getHub(hub1, 'en') - .then((result) => { - expect(result.length).toBe(2); - expect(result[0].entity).toBe('entity1'); - expect(result[1].entity).toBe('entity2'); - done(); - }).catch(catchErrors(done)); + it('should return all the connections of the same hub', async () => { + const relations = await relationships.getHub(hub1, 'en'); + expect(relations.length).toBe(2); + expect(relations[0].entity).toBe('entity1'); + expect(relations[1].entity).toBe('entity2'); }); }); describe('countByRelationType()', () => { - it('should return number of relationships using a relationType', (done) => { - relationships.countByRelationType(relation2.toString()) - .then((result) => { - expect(result).toBe(6); - done(); - }).catch(catchErrors(done)); + it('should return number of relationships using a relationType', async () => { + const relationsCount = await relationships.countByRelationType(relation2.toString()); + expect(relationsCount).toBe(5); }); - it('should return zero when none is using it', (done) => { + it('should return zero when none is using it', async () => { const notUsedRelation = db.id().toString(); - relationships.countByRelationType(notUsedRelation) - .then((result) => { - expect(result).toBe(0); - done(); - }).catch(catchErrors(done)); + const relationsCount = await relationships.countByRelationType(notUsedRelation); + expect(relationsCount).toBe(0); }); }); describe('bulk()', () => { - beforeEach(() => { - spyOn(relationships, 'save').and.returnValue(Promise.resolve()); - spyOn(relationships, 'delete').and.returnValue(Promise.resolve()); + const cleanSnapshot = (_value) => { + const [[_savedItem], ...deletes] = _value; + const savedItem = { + ..._savedItem, + _id: _savedItem._id.equals(connectionID5) ? 'connectionID5' : _savedItem._id, + template: _savedItem.template.equals(relation2) ? 'relation2' : _savedItem.relation2, + hub: _savedItem.hub.equals(hub2) ? 'hub2' : _savedItem.hub2, + }; + + savedItem.entityData = { + ...savedItem.entityData, + _id: savedItem.entityData._id.equals(entity3) ? 'entity3' : savedItem.entityData._id, + template: savedItem.entityData.template.equals(template) ? 'template' : savedItem.entityData.template, + }; + + return [[savedItem], ...deletes]; + }; + + it('should save or delete the relationships', async () => { + const data = { + save: [{ _id: connectionID5, entity: 'entity3', hub: hub2, template: relation2, range: { text: 'changed text' } }], + delete: [{ _id: connectionID2 }, { _id: connectionID3 }] + }; + + const response = await relationships.bulk(data, 'en'); + expect(cleanSnapshot(response)).toMatchSnapshot(); + + const savedReference = await relationships.getById(connectionID5); + expect(savedReference.range.text).toBe('changed text'); + + const deletedReference2 = await relationships.getById(connectionID2); + expect(deletedReference2).toBe(null); + const deletedReference3 = await relationships.getById(connectionID3); + expect(deletedReference3).toBe(null); }); - it('should call save and delete and then ask entities to update the ones affected by the changes', (done) => { + it('should first save and then delete to prevent sidefects of hub sanitizing', async () => { const data = { - save: [{ entity: 'entity3', hub: hub2, template: relation2, range: { text: 'english' }, language: 'en', sharedId: sharedId1 }], - delete: [{ hub: hub1, entity: '123' }, { hub: hub1, entity: '456' }] + save: [{ entity: 'new relationship entity', hub: hub11 }], + delete: [{ _id: connectionID6 }] }; - relationships.bulk(data, 'en') - .then(() => { - expect(entities.updateMetdataFromRelationships).toHaveBeenCalledWith(['entity2', 'entity3', 'entity1', '123', '456'], 'en'); - done(); - }); + + await relationships.bulk(data, 'en'); + const hubRelationships = await relationships.getHub(hub11); + expect(hubRelationships.length).toBe(2); }); }); describe('save()', () => { describe('When creating a new reference to a hub', () => { - it('should save it and return it with the entity data', (done) => { - relationships.save({ entity: 'entity3', hub: hub1 }, 'en') - .then(([result]) => { - expect(result.entity).toBe('entity3'); - expect(result.language).toBe('en'); - expect(result.entityData.template).toEqual(template); - expect(result.entityData.type).toBe('entity'); - expect(result.entityData.title).toBe('entity3 title'); - expect(result.entityData.published).toBe(true); - - expect(result._id).toBeDefined(); - done(); - }) - .catch(catchErrors(done)); + it('should save it and return it with the entity data', async () => { + const [result] = await relationships.save({ entity: 'entity3', hub: hub1 }, 'en'); + + expect(result.entity).toBe('entity3'); + expect(result.entityData.template).toEqual(template); + expect(result.entityData.type).toBe('entity'); + expect(result.entityData.title).toBe('entity3 title'); + expect(result.entityData.published).toBe(true); + expect(result._id).toBeDefined(); }); - it('should call entities to update the metadata', (done) => { - relationships.save({ entity: 'entity3', hub: hub1 }, 'en') - .then(() => { - expect(entities.updateMetdataFromRelationships).toHaveBeenCalledWith(['entity1', 'entity2', 'entity3'], 'en'); - done(); - }); + it('should call entities to update the metadata', async () => { + await relationships.save({ entity: 'entity3', hub: hub1 }, 'en'); + expect(entities.updateMetdataFromRelationships).toHaveBeenCalledWith(['entity1', 'entity2', 'entity3'], 'en'); }); }); describe('When creating new relationships', () => { - it('should assign them a hub and return them with the entity data', (done) => { - relationships.save([{ entity: 'entity3' }, { entity: 'doc4' }], 'en') - .then(([entity3Connection, doc4Connection]) => { - expect(entity3Connection.entity).toBe('entity3'); - expect(entity3Connection.entityData.template).toEqual(template); - expect(entity3Connection.entityData.type).toBe('entity'); - expect(entity3Connection.entityData.title).toBe('entity3 title'); - expect(entity3Connection.entityData.published).toBe(true); - - expect(entity3Connection._id).toBeDefined(); - expect(entity3Connection.hub).toBeDefined(); - - expect(doc4Connection.entity).toBe('doc4'); - expect(doc4Connection.entityData.template).toEqual(template); - expect(doc4Connection.entityData.type).toBe('document'); - expect(doc4Connection.entityData.title).toBe('doc4 en title'); - expect(doc4Connection.entityData.published).not.toBeDefined(); - - expect(doc4Connection._id).toBeDefined(); - expect(doc4Connection.hub).toBeDefined(); - expect(doc4Connection.hub.toString()).toBe(entity3Connection.hub.toString()); - done(); - }) - .catch(catchErrors(done)); - }); + it('should assign them a hub and return them with the entity data', async () => { + const [entity3Connection, doc4Connection] = await relationships.save([{ entity: 'entity3' }, { entity: 'doc4' }], 'en'); - it('should create fallback relationships for all the languages versions of the entity', (done) => { - relationships.save([{ entity: 'entity4' }, { entity: 'entity1' }], 'en') - .then(() => relationships.get({ entity: 'entity4' })) - .then((relations) => { - expect(relations.length).toBe(2); - done(); - }) - .catch(catchErrors(done)); - }); + expect(entity3Connection.entity).toBe('entity3'); + expect(entity3Connection.entityData.template).toEqual(template); + expect(entity3Connection.entityData.type).toBe('entity'); + expect(entity3Connection.entityData.title).toBe('entity3 title'); + expect(entity3Connection.entityData.published).toBe(true); - it('should sync the metadata', (done) => { - relationships.save([{ entity: 'entity4', template: relation1.toString() }, { entity: 'entity1' }], 'en') - .then(() => relationships.get({ entity: 'entity4' })) - .then((relations) => { - const englishRelation = relations.find(r => r.language === 'en'); - englishRelation.metadata = { name: 'English name', options: ['a', 'b'], date: 123453 }; - return relationships.save(englishRelation, 'en') - .then(() => relationships.get({ entity: 'entity4' })); - }) - .then((relations) => { - const englishRelation = relations.find(r => r.language === 'en'); - const rusianRelation = relations.find(r => r.language === 'ru'); - expect(englishRelation.metadata).toEqual({ name: 'English name', options: ['a', 'b'], date: 123453 }); - expect(rusianRelation.metadata).toEqual({ options: ['a', 'b'], date: 123453 }); - done(); - }) - .catch(catchErrors(done)); + expect(entity3Connection._id).toBeDefined(); + expect(entity3Connection.hub).toBeDefined(); + + expect(doc4Connection.entity).toBe('doc4'); + expect(doc4Connection.entityData.template).toEqual(template); + expect(doc4Connection.entityData.type).toBe('document'); + expect(doc4Connection.entityData.title).toBe('doc4 en title'); + expect(doc4Connection.entityData.published).not.toBeDefined(); + + expect(doc4Connection._id).toBeDefined(); + expect(doc4Connection.hub).toBeDefined(); + expect(doc4Connection.hub.toString()).toBe(entity3Connection.hub.toString()); }); describe('when creating text references', () => { - it('should assign them language and fallback for the same document in other languages', (done) => { - relationships.save([{ entity: 'doc5', range: { text: 'one thing' } }, { entity: 'doc4', range: { text: 'something' } }], 'es') - .then((saveResult) => { - expect(saveResult.length).toBe(2); - expect(saveResult[0].language).toBe('es'); - expect(saveResult[1].language).toBe('es'); - return Promise.all([relationships.get({ entity: 'doc4' }), relationships.get({ entity: 'doc5' })]); - }) - .then(([doc4Realtions, doc5Relations]) => { - expect(doc4Realtions.length).toBe(3); - expect(doc5Relations.length).toBe(4); - - expect(doc4Realtions.find(r => r.language === 'es')).toBeDefined(); - expect(doc4Realtions.find(r => r.language === 'en')).toBeDefined(); - expect(doc4Realtions.find(r => r.language === 'pt')).not.toBeDefined(); - return relationships.save(doc4Realtions.find(r => r.language === 'en'), 'en') - .then(() => relationships.get({ entity: 'doc4' })); - }) - .then((relations) => { - expect(relations.length).toBe(3); - done(); - }) - .catch(catchErrors(done)); + it('should assign them the file they belong to', async () => { + const saveResult = await relationships.save([ + { entity: 'doc5', range: { text: 'one thing' } }, + { entity: 'doc4', range: { text: 'something' } }, + ], 'es'); + + expect(saveResult.length).toBe(2); + expect(saveResult[0].filename).toBe('doc5enFile'); + expect(saveResult[1].filename).toBe('doc4enFile'); }); }); }); describe('when the reference exists', () => { - it('should update it', (done) => { - relationships.getById(connectionID1) - .then((reference) => { - reference.entity = 'entity1'; - return relationships.save(reference, 'en'); - }) - .then(([result]) => { - expect(result.entity).toBe('entity1'); - expect(result._id.equals(connectionID1)).toBe(true); - done(); - }) - .catch(catchErrors(done)); + it('should update it', async () => { + const reference = await relationships.getById(connectionID1); + reference.entity = 'entity1'; + await relationships.save(reference, 'en'); + + const changedReference = await relationships.getById(connectionID1); + + expect(changedReference.entity).toBe('entity1'); + expect(changedReference._id.equals(connectionID1)).toBe(true); }); - it('should update correctly if ID is not a mongo ObjectId', (done) => { - relationships.getById(connectionID1) - .then((reference) => { - reference.entity = 'entity1'; - reference._id = reference._id.toString(); - return relationships.save(reference, 'en'); - }) - .then(([result]) => { - expect(result.entity).toBe('entity1'); - expect(result._id.equals(connectionID1)).toBe(true); - done(); - }) - .catch(catchErrors(done)); + it('should update correctly if ID is not a mongo ObjectId', async () => { + const reference = await relationships.getById(connectionID1); + reference._id = reference._id.toString(); + reference.entity = 'entity1'; + + const [changedReference] = await relationships.save(reference, 'en'); + + expect(changedReference.entity).toBe('entity1'); + expect(changedReference._id.equals(connectionID1)).toBe(true); }); - it('should update correctly if template is null', (done) => { - relationships.getById(connectionID1) - .then((reference) => { - reference.template = { _id: null }; - return relationships.save(reference, 'en'); - }) - .then(([result]) => { - expect(result.entity).toBe('entity_id'); - expect(result.template).toBe(null); - done(); - }) - .catch(catchErrors(done)); + it('should update correctly if template is null', async () => { + const reference = await relationships.getById(connectionID1); + reference.template = { _id: null }; + const [savedReference] = await relationships.save(reference, 'en'); + expect(savedReference.entity).toBe('entity_id'); + expect(savedReference.template).toBe(null); }); }); @@ -337,7 +320,7 @@ describe('relationships', () => { }); describe('saveEntityBasedReferences()', () => { - it('should create connections based on properties', (done) => { + it('should create connections based on properties', async () => { const entity = { template: template.toString(), sharedId: 'bruceWayne', @@ -346,39 +329,39 @@ describe('relationships', () => { } }; - relationships.saveEntityBasedReferences(entity, 'en') - .then(() => relationships.getByDocument('bruceWayne', 'en')) - .then((connections) => { - expect(connections.length).toBe(4); - expect(connections.find(connection => connection.entity === 'bruceWayne')).toBeDefined(); - expect(connections.find(connection => connection.entity === 'robin')).toBeDefined(); - expect(connections[0].hub).toEqual(connections[1].hub); - done(); - }) - .catch(catchErrors(done)); + await relationships.saveEntityBasedReferences(entity, 'en'); + const connections = await relationships.getByDocument('bruceWayne', 'en'); + expect(connections.find(connection => connection.entity === 'bruceWayne')).toBeDefined(); + expect(connections.find(connection => connection.entity === 'robin')).toBeDefined(); + expect(connections[0].hub).toEqual(connections[1].hub); }); - it('should not create existing connections based on properties', (done) => { + it('should not create existing connections based on properties', async () => { const entity = { template: template.toString(), sharedId: 'bruceWayne', metadata: { family: ['thomasWayne'], - friend: ['robin'] + friend: ['robin', 'alfred'] } }; - relationships.saveEntityBasedReferences(entity, 'en') - .then(() => relationships.saveEntityBasedReferences(entity, 'en')) - .then(() => relationships.getByDocument('bruceWayne', 'en')) - .then((connections) => { - expect(connections.length).toBe(5); - done(); - }) - .catch(catchErrors(done)); + await relationships.saveEntityBasedReferences(entity, 'en'); + await relationships.saveEntityBasedReferences(entity, 'en'); + const connections = await relationships.getByDocument('bruceWayne', 'en'); + + const existingHubConnections = connections.filter(c => c.hub.equals(hub9)); + const newHubCreated = connections.filter(c => !c.hub.equals(hub9)); + + expect(existingHubConnections.length).toBe(4); + + expect(newHubCreated.length).toBe(3); + expect(newHubCreated.find(c => c.entity === 'robin').template.toString()).toBe(friend.toString()); + expect(newHubCreated.find(c => c.entity === 'alfred').template.toString()).toBe(friend.toString()); + expect(newHubCreated.find(c => c.entity === 'bruceWayne').template).toBe(null); }); - it('should delete connections based on properties', (done) => { + it('should delete connections based on properties', async () => { const entity = { template: template.toString(), sharedId: 'bruceWayne', @@ -388,74 +371,64 @@ describe('relationships', () => { } }; - relationships.saveEntityBasedReferences(entity, 'en') - .then(() => relationships.getByDocument('bruceWayne', 'en')) - .then((connections) => { - expect(connections.length).toBe(6); - entity.metadata = { - family: ['thomasWayne'], - friend: ['alfred'] - }; - return relationships.saveEntityBasedReferences(entity, 'en'); - }) - .then(() => relationships.getByDocument('bruceWayne', 'en')) - .then((connections) => { - expect(connections.length).toBe(5); - entity.metadata = { - family: ['alfred'], - friend: ['robin'] - }; - return relationships.saveEntityBasedReferences(entity, 'en'); - }) - .then(() => relationships.getByDocument('bruceWayne', 'en')) - .then((connections) => { - expect(connections.length).toBe(6); - done(); - }) - .catch(catchErrors(done)); + await relationships.saveEntityBasedReferences(entity, 'en'); + + entity.metadata = { + family: ['thomasWayne'], + friend: ['alfred'] + }; + await relationships.saveEntityBasedReferences(entity, 'en'); + let connections = await relationships.getByDocument('bruceWayne', 'en'); + expect(connections.length).toBe(6); + expect(connections.find(c => c.entity === 'robin')).not.toBeDefined(); + + entity.metadata = { + family: ['alfred'], + friend: ['robin'] + }; + await relationships.saveEntityBasedReferences(entity, 'en'); + connections = await relationships.getByDocument('bruceWayne', 'en'); + + expect(connections.find(c => c.entity === 'thomasWayne')).not.toBeDefined(); + expect(connections.find(c => c.entity === 'alfred').template.toString()).toBe(family.toString()); + expect(connections.length).toBe(7); }); }); describe('search()', () => { - it('should prepare a query with ids based on an entity id and a searchTerm', (done) => { + it('should prepare a query with ids based on an entity id and a searchTerm', async () => { const searchResponse = Promise.resolve({ rows: [] }); spyOn(search, 'search').and.returnValue(searchResponse); - relationships.search('entity2', { filter: {}, searchTerm: 'something' }, 'en') - .then(() => { - const actualQuery = search.search.calls.mostRecent().args[0]; - expect(actualQuery.searchTerm).toEqual('something'); - expect(actualQuery.ids).containItems(['doc5', 'doc4', 'entity3', 'entity1']); - expect(actualQuery.includeUnpublished).toBe(true); - expect(actualQuery.limit).toBe(9999); - done(); - }) - .catch(catchErrors(done)); + await relationships.search('entity2', { filter: {}, searchTerm: 'something' }, 'en'); + const actualQuery = search.search.calls.mostRecent().args[0]; + expect(actualQuery.searchTerm).toEqual('something'); + expect(actualQuery.ids).containItems(['doc5', 'doc4', 'entity3', 'entity1']); + expect(actualQuery.includeUnpublished).toBe(true); + expect(actualQuery.limit).toBe(9999); }); - it('should filter out ids based on filtered relation types and templates, and pass the user to search', (done) => { + it('should filter out ids based on filtered relation types and templates, and pass the user to search', async () => { const searchResponse = Promise.resolve({ rows: [] }); spyOn(search, 'search').and.returnValue(searchResponse); const query = { filter: {}, searchTerm: 'something' }; query.filter[relation2] = [relation2 + template]; - relationships.search('entity2', query, 'en', 'user') - .then(() => { - const actualQuery = search.search.calls.mostRecent().args[0]; - const language = search.search.calls.mostRecent().args[1]; - const user = search.search.calls.mostRecent().args[2]; - - expect(actualQuery.searchTerm).toEqual('something'); - expect(actualQuery.ids).containItems(['doc4', 'entity3']); - expect(actualQuery.includeUnpublished).toBe(true); - expect(actualQuery.limit).toBe(9999); - - expect(language).toBe('en'); - expect(user).toBe('user'); - done(); - }) - .catch(catchErrors(done)); + + await relationships.search('entity2', query, 'en', 'user'); + + const actualQuery = search.search.calls.mostRecent().args[0]; + const language = search.search.calls.mostRecent().args[1]; + const user = search.search.calls.mostRecent().args[2]; + + expect(actualQuery.searchTerm).toEqual('something'); + expect(actualQuery.ids).containItems(['doc4', 'entity3']); + expect(actualQuery.includeUnpublished).toBe(true); + expect(actualQuery.limit).toBe(9999); + + expect(language).toBe('en'); + expect(user).toBe('user'); }); - it('should return the matching entities with their relationships and the current entity with the respective relationships', (done) => { + it('should return the matching entities with their relationships and the current entity with the respective relationships', async () => { const searchResponse = Promise.resolve( { rows: [ { sharedId: 'entity1' }, @@ -465,20 +438,16 @@ describe('relationships', () => { ] } ); spyOn(search, 'search').and.returnValue(searchResponse); - relationships.search('entity2', { filter: {}, searchTerm: 'something' }, 'en') - .then((result) => { - expect(result.rows.length).toBe(5); - expect(result.rows[0].connections.length).toBe(1); - expect(result.rows[1].connections.length).toBe(3); - expect(result.rows[2].connections.length).toBe(1); - expect(result.rows[3].connections.length).toBe(1); - expect(result.rows[4].connections.length).toBe(4); - done(); - }) - .catch(catchErrors(done)); + const result = await relationships.search('entity2', { filter: {}, searchTerm: 'something' }, 'en'); + expect(result.rows.length).toBe(5); + expect(result.rows[0].connections.length).toBe(1); + expect(result.rows[1].connections.length).toBe(4); + expect(result.rows[2].connections.length).toBe(1); + expect(result.rows[3].connections.length).toBe(1); + expect(result.rows[4].connections.length).toBe(5); }); - it('should retrun number of hubs (total and requested) and allow limiting the number of HUBs returned', (done) => { + it('should return number of hubs (total and requested) and allow limiting the number of HUBs returned', async () => { const searchResponse = Promise.resolve( { rows: [ { sharedId: 'entity1' }, @@ -488,103 +457,73 @@ describe('relationships', () => { ] }); spyOn(search, 'search').and.returnValue(searchResponse); - relationships.search('entity2', { filter: {}, searchTerm: 'something', limit: 2 }, 'en') - .then((result) => { - expect(result.totalHubs).toBe(4); - expect(result.requestedHubs).toBe(2); - const expectedHubIds = result.rows[result.rows.length - 1].connections.map(c => c.hub.toString()); + const result = await relationships.search('entity2', { filter: {}, searchTerm: 'something', limit: 2 }, 'en'); + expect(result.totalHubs).toBe(5); + expect(result.requestedHubs).toBe(2); - expect(result.rows[0].sharedId).toBe('entity1'); - expect(result.rows[0].connections.length).toBe(1); - expect(expectedHubIds).toContain(result.rows[0].connections[0].hub.toString()); + const expectedHubIds = result.rows[result.rows.length - 1].connections.map(c => c.hub.toString()); + expect(expectedHubIds.length).toBe(2); + expect(expectedHubIds).toContain(result.rows[0].connections[0].hub.toString()); + expect(expectedHubIds).toContain(result.rows[1].connections[0].hub.toString()); + expect(expectedHubIds).toContain(result.rows[1].connections[1].hub.toString()); - expect(result.rows[1].sharedId).toBe('entity3'); - expect(result.rows[1].connections.length).toBe(2); - expect(expectedHubIds).toContain(result.rows[1].connections[0].hub.toString()); - expect(expectedHubIds).toContain(result.rows[1].connections[1].hub.toString()); + expect(result.rows[0].sharedId).toBe('entity1'); + expect(result.rows[0].connections.length).toBe(1); - expect(result.rows[2].sharedId).toBe('entity2'); - expect(result.rows[2].connections.length).toBe(2); + expect(result.rows[1].sharedId).toBe('entity3'); + expect(result.rows[1].connections.length).toBe(2); - done(); - }) - .catch(catchErrors(done)); + expect(result.rows[2].sharedId).toBe('entity2'); + expect(result.rows[2].connections.length).toBe(2); }); }); describe('delete()', () => { - beforeEach(async () => { - await relationships.delete({ _id: connectionID1 }, 'en'); + it('should delete the relationship', async () => { + const response = await relationships.delete({ _id: connectionID1 }, 'en'); + const hub7Connections = await relationships.get({ hub: hub7 }); + expect(hub7Connections.filter(c => c._id.toString() === connectionID1.toString()).length).toBe(0); + expect(JSON.parse(response).ok).toBe(1); }); - function expectLength(result, source, target, length) { - expect(result.filter(i => i[source].toString() === target.toString()).length).toBe(length); - } + it('should not leave a lone connection in the hub', async () => { + await relationships.delete({ _id: connectionID1 }, 'en'); + await relationships.delete({ _id: connectionID3 }, 'en'); + await relationships.delete({ _id: connectionID2 }, 'en'); - it('should delete the relationship in all languages', (done) => { - relationships.get({ hub: hub7 }) - .then((result) => { - expect(result.length).toBe(5); - expectLength(result, 'sharedId', sharedId3, 2); - expectLength(result, 'sharedId', sharedId5, 2); - expectLength(result, '_id', connectionID3, 1); - done(); - }) - .catch(catchErrors(done)); - }); + const hubRelationships = await relationships.get({ hub: hub7 }); - it('should not leave a lone connection in the hub', (done) => { - relationships.delete({ _id: connectionID3 }, 'en') - .then(() => relationships.delete({ _id: connectionID2 }, 'es')) - .then(() => relationships.get({ hub: hub7 })) - .then((result) => { - expect(result).toEqual([]); - done(); - }) - .catch(catchErrors(done)); + expect(hubRelationships).toEqual([]); }); - it('should not delete the hub when other languages have more connections (because of text references)', (done) => { - relationships.delete({ _id: connectionID2 }, 'es') - .then(() => relationships.get({ hub: hub7 })) - .then((result) => { - expect(result.length).toBe(3); - expectLength(result, 'sharedId', sharedId3, 2); - expectLength(result, '_id', connectionID3, 1); - done(); - }) - .catch(catchErrors(done)); - }); + describe('when deleting relations for an entity', () => { + it('should not leave single relationship hubs', async () => { + await relationships.delete({ entity: 'entity3' }, 'en'); - it('should not delete the hub when specific combos yield a hub with less than 2 connections in every language', (done) => { - relationships.delete({ _id: connectionID4 }, 'es') - .then(() => relationships.get({ hub: hub12 })) - .then((result) => { - expect(result.length).toBe(3); - expectLength(result, 'sharedId', sharedId7, 2); - expectLength(result, 'entity', 'doc2', 1); - done(); - }) - .catch(catchErrors(done)); - }); + const hub2Relationships = await relationships.get({ hub: hub2 }); + const hub11Relationships = await relationships.get({ hub: hub11 }); - it('should call entities to update the metadata', (done) => { - relationships.delete({ entity: 'bruceWayne' }, 'en') - .then(() => { - expect(entities.updateMetdataFromRelationships).toHaveBeenCalledWith(['bruceWayne', 'thomasWayne', 'IHaveNoTemplate'], 'en'); - expect(entities.updateMetdataFromRelationships).toHaveBeenCalledWith(['bruceWayne', 'thomasWayne', 'IHaveNoTemplate'], 'es'); - done(); + expect(hub2Relationships).toEqual([]); + expect(hub11Relationships).toEqual([]); }); }); - it('should delete all the relationships for a given entity', done => - relationships.delete({ entity: 'entity2' }) - .then(() => relationships.get({ entity: 'entity2' })) - .then((result) => { - expect(result).toEqual([]); - done(); - }) - .catch(catchErrors(done))); + it('should not delete the hub when specific combos yield a hub with less than 2 connections', async () => { + await relationships.delete({ _id: connectionID4 }, 'es'); + + const hubRelationships = await relationships.get({ hub: hub12 }); + + expect(hubRelationships.length).toBe(2); + expect(hubRelationships.filter(c => c.entity === 'entity1').length).toBe(1); + expect(hubRelationships.filter(c => c.entity === 'doc2').length).toBe(1); + }); + + it('should call entities to update the metadata', async () => { + await relationships.delete({ entity: 'bruceWayne' }, 'en'); + + expect(entities.updateMetdataFromRelationships).toHaveBeenCalledWith(['doc2', 'IHaveNoTemplate', 'thomasWayne', 'bruceWayne'], 'en'); + expect(entities.updateMetdataFromRelationships).toHaveBeenCalledWith(['doc2', 'IHaveNoTemplate', 'thomasWayne', 'bruceWayne'], 'es'); + }); describe('when there is no condition', () => { it('should throw an error', (done) => { @@ -601,14 +540,38 @@ describe('relationships', () => { }); describe('deleteTextReferences()', () => { - it('should delete the entity text relationships (that match language)', (done) => { - relationships.deleteTextReferences('entity3', 'en') - .then(() => Promise.all([relationships.getByDocument('entity3', 'en'), relationships.getByDocument('entity3', 'ru')])) - .then(([relationshipsInEnglish, relationshipsInRusian]) => { - expect(relationshipsInEnglish.length).toBe(5); - expect(relationshipsInRusian.length).toBe(2); - done(); - }); + it('should delete the entity text relationships (that match language)', async () => { + await relationships.deleteTextReferences('doc2', 'en'); + + const [relationshipsInEnglish, relationshipsInPT] = await Promise.all([ + relationships.getByDocument('doc2', 'en'), + relationships.getByDocument('doc2', 'pt'), + ]); + + expect(relationshipsInEnglish.length).toBe(4); + expect(relationshipsInPT.length).toBe(8); + }); + + it('should not delete text relationships if filename also used in other languages', async () => { + await relationships.deleteTextReferences('doc5', 'en'); + const doc5Relationships = await relationships.get({ entity: 'doc5', hub: hub5 }); + expect(doc5Relationships.length).toBe(1); + }); + + it('should not leave a lone connection in the hub', async () => { + await relationships.delete({ entity: 'entity_id' }, 'en'); + await relationships.deleteTextReferences('doc2', 'en'); + await relationships.deleteTextReferences('doc2', 'pt'); + + const hubRelationships = await relationships.get({ hub: hub8 }); + + expect(hubRelationships).toEqual([]); + }); + + it('should not delete any relationships if entity.file.filename if undefined', async () => { + await relationships.deleteTextReferences('entity1', 'en'); + const hubRelationships = await relationships.getByDocument('entity1', 'en'); + expect(hubRelationships.length).toEqual(5); }); }); }); diff --git a/app/api/search/documentQueryBuilder.js b/app/api/search/documentQueryBuilder.js index 81e1a5fca2..a7ad17ec3f 100644 --- a/app/api/search/documentQueryBuilder.js +++ b/app/api/search/documentQueryBuilder.js @@ -174,6 +174,20 @@ export default function () { return this; }, + sortByForeignKey(property, keys, order = 'desc') { + const sort = {}; + sort._script = { + order, + type: 'string', + script: { + params: { keys }, + source: `try {params.keys[doc['${property}.sort'].value] != null ? params.keys[doc['${property}.sort'].value] : '|'}catch(Exception e){'|'}` + }, + }; + baseQuery.sort.push(sort); + return this; + }, + hasMetadataProperties(fieldNames) { const match = { bool: { should: [] } }; match.bool.should = fieldNames.map(field => ({ exists: { field: `metadata.${field}` } })); diff --git a/app/api/search/search.js b/app/api/search/search.js index 5c538e8fa8..962c033c94 100644 --- a/app/api/search/search.js +++ b/app/api/search/search.js @@ -1,5 +1,7 @@ import { comonFilters, defaultFilters, allUniqueProperties, textFields } from 'shared/comonProperties'; import { detect as detectLanguage } from 'shared/languages'; +import translate, { getLocaleTranslation, getContext } from 'shared/translate'; +import translations from 'api/i18n/translations'; import { index as elasticIndex } from 'api/config/elasticIndexes'; import languages from 'shared/languagesList'; import dictionariesModel from 'api/thesauris/dictionariesModel'; @@ -116,6 +118,35 @@ function searchGeolocation(documentsQuery, filteringTypes, templates) { documentsQuery.select(selectProps); } +const processResponse = (response) => { + const rows = response.hits.hits.map((hit) => { + const result = hit._source; + result._explanation = hit._explanation; + result.snippets = snippetsFromSearchHit(hit); + result._id = hit._id; + return result; + }); + Object.keys(response.aggregations.all).forEach((aggregationKey) => { + const aggregation = response.aggregations.all[aggregationKey]; + if (aggregation.buckets && !Array.isArray(aggregation.buckets)) { + aggregation.buckets = Object.keys(aggregation.buckets).map(key => Object.assign({ key }, aggregation.buckets[key])); + } + if (aggregation.buckets) { + response.aggregations.all[aggregationKey] = aggregation; + } + if (!aggregation.buckets) { + Object.keys(aggregation).forEach((key) => { + if (aggregation[key].buckets) { + const buckets = aggregation[key].buckets.map(option => Object.assign({ key: option.key }, option.filtered.total)); + response.aggregations.all[key] = { doc_count: aggregation[key].doc_count, buckets }; + } + }); + } + }); + + return { rows, totalRows: response.hits.total, aggregations: response.aggregations }; +}; + const search = { search(query, language, user) { let searchEntitiesbyTitle = Promise.resolve([]); @@ -130,8 +161,15 @@ const search = { ]); } - return Promise.all([templatesModel.get(), searchEntitiesbyTitle, searchDictionariesByTitle, dictionariesModel.get(), relationtypes.get()]) - .then(([templates, entitiesMatchedByTitle, dictionariesMatchByLabel, dictionaries, relationTypes]) => { + return Promise.all([ + templatesModel.get(), + searchEntitiesbyTitle, + searchDictionariesByTitle, + dictionariesModel.get(), + relationtypes.get(), + translations.get() + ]) + .then(([templates, entitiesMatchedByTitle, dictionariesMatchByLabel, dictionaries, relationTypes, _translations]) => { const textFieldsToSearch = query.fields || textFields(templates).map(prop => `metadata.${prop.name}`).concat(['title', 'fullText']); const documentsQuery = documentQueryBuilder() .fullTextSearch(query.searchTerm, textFieldsToSearch, 2) @@ -139,10 +177,6 @@ const search = { .filterById(query.ids) .language(language); - if (query.sort) { - documentsQuery.sort(query.sort, query.order); - } - if (query.from) { documentsQuery.from(query.from); } @@ -160,17 +194,34 @@ const search = { } const allTemplates = templates.map(t => t._id); + const allUniqueProps = allUniqueProperties(templates); const filteringTypes = query.types && query.types.length ? query.types : allTemplates; let properties = comonFilters(templates, relationTypes, filteringTypes); properties = !query.types || !query.types.length ? defaultFilters(templates) : properties; + if (query.sort) { + const sortingProp = allUniqueProps.find(p => `metadata.${p.name}` === query.sort); + if (sortingProp && sortingProp.type === 'select') { + const dictionary = dictionaries.find(d => d._id.toString() === sortingProp.content); + const translation = getLocaleTranslation(_translations, language); + const context = getContext(translation, dictionary._id.toString()); + const keys = dictionary.values.reduce((result, value) => { + result[value.id] = translate(context, value.label, value.label); + return result; + }, {}); + documentsQuery.sortByForeignKey(query.sort, keys, query.order); + } else { + documentsQuery.sort(query.sort, query.order); + } + } + if (query.allAggregations) { - properties = allUniqueProperties(templates); + properties = allUniqueProps; } const aggregations = agregationProperties(properties); const filters = processFiltes(query.filters, properties); - const textSearchFilters = filtersBasedOnSearchTerm(allUniqueProperties(templates), entitiesMatchedByTitle, dictionariesMatchByLabel); + const textSearchFilters = filtersBasedOnSearchTerm(allUniqueProps, entitiesMatchedByTitle, dictionariesMatchByLabel); documentsQuery.filterMetadataByFullText(textSearchFilters); @@ -181,36 +232,8 @@ const search = { searchGeolocation(documentsQuery, filteringTypes, templates); } return elastic.search({ index: elasticIndex, body: documentsQuery.query() }) - .then((response) => { - const rows = response.hits.hits.map((hit) => { - const result = hit._source; - result._explanation = hit._explanation; - result.snippets = snippetsFromSearchHit(hit); - result._id = hit._id; - return result; - }); - Object.keys(response.aggregations.all).forEach((aggregationKey) => { - const aggregation = response.aggregations.all[aggregationKey]; - if (aggregation.buckets && !Array.isArray(aggregation.buckets)) { - aggregation.buckets = Object.keys(aggregation.buckets).map(key => Object.assign({ key }, aggregation.buckets[key])); - } - if (aggregation.buckets) { - response.aggregations.all[aggregationKey] = aggregation; - } - if (!aggregation.buckets) { - Object.keys(aggregation).forEach((key) => { - if (aggregation[key].buckets) { - const buckets = aggregation[key].buckets.map(option => Object.assign({ key: option.key }, option.filtered.total)); - response.aggregations.all[key] = { doc_count: aggregation[key].doc_count, buckets }; - } - }); - } - }); - - return { rows, totalRows: response.hits.total, aggregations: response.aggregations }; - }) - .catch((error) => { - console.log(error); + .then(processResponse) + .catch(() => { throw createError('Query error', 400); }); }); @@ -302,6 +325,11 @@ const search = { delete(entity) { const id = entity._id.toString(); return elastic.delete({ index: elasticIndex, type: 'entity', id }); + }, + + deleteLanguage(language) { + const query = { query: { match: { language } } }; + return elastic.deleteByQuery({ index: elasticIndex, body: query }); } }; diff --git a/app/api/search/specs/index.spec.js b/app/api/search/specs/index.spec.js index c05a7f3b73..afcd2288ac 100644 --- a/app/api/search/specs/index.spec.js +++ b/app/api/search/specs/index.spec.js @@ -114,4 +114,16 @@ describe('search', () => { .catch(catchErrors(done)); }); }); + + describe('deleteLanguage', () => { + it('should delete the index', (done) => { + spyOn(elastic, 'deleteByQuery').and.returnValue(Promise.resolve()); + search.deleteLanguage('en') + .then(() => { + expect(elastic.deleteByQuery) + .toHaveBeenCalledWith({ index: elasticIndex, body: { query: { match: { language: 'en' } } } }); + done(); + }); + }); + }); }); diff --git a/app/api/settings/settings.js b/app/api/settings/settings.js index 35d3837fc7..53573bc5ea 100644 --- a/app/api/settings/settings.js +++ b/app/api/settings/settings.js @@ -77,6 +77,35 @@ export default { })); }, + setDefaultLanguage(key) { + return this.get() + .then((currentSettings) => { + const languages = currentSettings.languages.map((_language) => { + const language = Object.assign({}, _language); + language.default = language.key === key; + return language; + }); + + return model.save(Object.assign(currentSettings, { languages })); + }); + }, + + addLanguage(language) { + return this.get() + .then((currentSettings) => { + currentSettings.languages.push(language); + return model.save(currentSettings); + }); + }, + + deleteLanguage(key) { + return this.get() + .then((currentSettings) => { + const languages = currentSettings.languages.filter(language => language.key !== key); + return model.save(Object.assign(currentSettings, { languages })); + }); + }, + removeTemplateFromFilters(templateId) { return this.get() .then((settings) => { diff --git a/app/api/settings/specs/fixtures.js b/app/api/settings/specs/fixtures.js index cb6f49dfdc..a1e5d2fa34 100644 --- a/app/api/settings/specs/fixtures.js +++ b/app/api/settings/specs/fixtures.js @@ -4,7 +4,11 @@ export default { settings: [ { _id: db.id(), - site_name: 'Uwazi' + site_name: 'Uwazi', + languages: [ + { key: 'es', label: 'Español', default: true }, + { key: 'en', label: 'English' } + ] } ] }; diff --git a/app/api/settings/specs/settings.spec.js b/app/api/settings/specs/settings.spec.js index 896c68f4b6..a08abbe390 100644 --- a/app/api/settings/specs/settings.spec.js +++ b/app/api/settings/specs/settings.spec.js @@ -1,9 +1,8 @@ import { catchErrors } from 'api/utils/jasmineHelpers'; import db from 'api/utils/testing_db'; import translations from 'api/i18n/translations'; - -import fixtures from './fixtures.js'; import settings from '../settings.js'; +import fixtures from './fixtures.js'; describe('settings', () => { beforeEach((done) => { @@ -73,17 +72,8 @@ describe('settings', () => { it('should create translations for them', (done) => { const config = { site_name: 'My collection', - filters: [{ - id: 1, - name: 'Judge' - }, { - id: 2, - name: 'Documents', - items: [{ - id: 3, - name: 'Cause' - }] - }] + filters: [{ id: 1, name: 'Judge' }, + { id: 2, name: 'Documents', items: [{ id: 3, name: 'Cause' }] }] }; settings.save(config) .then(() => { @@ -135,6 +125,43 @@ describe('settings', () => { }); }); + describe('setDefaultLanguage()', () => { + it('should save the settings with the new default language', (done) => { + settings.setDefaultLanguage('en') + .then(() => settings.get()) + .then((result) => { + expect(result.languages[1].key).toBe('en'); + expect(result.languages[1].default).toBe(true); + done(); + }) + .catch(catchErrors(done)); + }); + }); + + describe('addLanguage()', () => { + it('should add a to settings list language', (done) => { + settings.addLanguage({ key: 'fr', label: 'Frances' }) + .then(() => settings.get()) + .then((result) => { + expect(result.languages.length).toBe(3); + done(); + }) + .catch(catchErrors(done)); + }); + }); + + describe('deleteLanguage()', () => { + it('should add a to settings list language', (done) => { + settings.deleteLanguage('en') + .then(() => settings.get()) + .then((result) => { + expect(result.languages.length).toBe(1); + done(); + }) + .catch(catchErrors(done)); + }); + }); + describe('removeTemplateFromFilters', () => { it('should remove the template from the filters', (done) => { const _settings = { diff --git a/app/react/App/Confirm.js b/app/react/App/Confirm.js index d12af5cd96..cb766f6d40 100644 --- a/app/react/App/Confirm.js +++ b/app/react/App/Confirm.js @@ -1,61 +1,118 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; +import { t } from 'app/I18N'; import Modal from 'app/Layout/Modal'; +import Loader from 'app/components/Elements/Loader'; export class Confirm extends Component { constructor(props) { super(props); this.state = { - isOpen: false + isOpen: props.isOpen, + isLoading: props.isLoading, + confirmInputValue: '' }; + + this.accept = this.accept.bind(this); + this.cancel = this.cancel.bind(this); + this.close = this.close.bind(this); + this.handleInput = this.handleInput.bind(this); + } + + componentWillReceiveProps(newProps) { + if (newProps.accept !== this.props.accept) { + this.setState({ isOpen: true }); + } + } + + close() { + this.setState({ isOpen: false, confirmInputValue: '', isLoading: false }); } accept() { - this.setState({ isOpen: false }); if (this.props.accept) { - this.props.accept(); + const actionResponse = this.props.accept(); + if (actionResponse && actionResponse instanceof Promise) { + this.setState({ isLoading: true }); + actionResponse.then(this.close); + actionResponse.catch(this.close); + return; + } } + this.close(); } cancel() { - this.setState({ isOpen: false }); if (this.props.cancel) { this.props.cancel(); } + this.close(); } - componentWillReceiveProps(newProps) { - if (newProps.accept !== this.props.accept) { - this.setState({ isOpen: true }); - } + handleInput(e) { + this.setState({ confirmInputValue: e.target.value }); + } + + renderExtraConfirm() { + return ( + +

If you want to continue, please type '{this.props.extraConfirmWord}'

+ +
+ ); } render() { - const type = this.props.type || 'danger'; + const { type } = this.props; return ( - + -

{this.props.title || 'Confirm action'}

-

{this.props.message || 'Are you sure you want to continue?'}

+

{this.props.title}

+

{this.props.message}

+ {this.props.extraConfirm && !this.state.isLoading && this.renderExtraConfirm()} + {this.state.isLoading && }
- - {(() => { - if (!this.props.noCancel) { - return ; + { + !this.state.isLoading && + + { + !this.props.noCancel && + } - })()} - - + + + }
); } } +Confirm.defaultProps = { + isLoading: false, + extraConfirm: false, + isOpen: false, + noCancel: false, + type: 'danger', + title: 'Confirm action', + message: 'Are you sure you want to continue?', + extraConfirmWord: 'CONFIRM' +}; + Confirm.propTypes = { + isLoading: PropTypes.bool, + extraConfirm: PropTypes.bool, + extraConfirmWord: PropTypes.string, isOpen: PropTypes.bool, noCancel: PropTypes.bool, accept: PropTypes.func, diff --git a/app/react/App/scss/elements/_alerts.scss b/app/react/App/scss/elements/_alerts.scss index 9ffdeb16f6..4e42b5fe2e 100644 --- a/app/react/App/scss/elements/_alerts.scss +++ b/app/react/App/scss/elements/_alerts.scss @@ -1,6 +1,6 @@ .alert-wrapper { z-index: 9; - + .alert { display: block; width: 50%; @@ -86,6 +86,10 @@ border-radius: 0; border: 0; box-shadow: $box-shadow; + + .cs-loader { + padding: 50px; + } } .modal-success { diff --git a/app/react/App/specs/Confirm.spec.js b/app/react/App/specs/Confirm.spec.js index 4eeef10946..39050c5c53 100644 --- a/app/react/App/specs/Confirm.spec.js +++ b/app/react/App/specs/Confirm.spec.js @@ -1,12 +1,14 @@ import React from 'react'; -import {shallow} from 'enzyme'; +import { shallow } from 'enzyme'; import Confirm from '../Confirm'; import Modal from 'app/Layout/Modal'; +import Loader from 'app/components/Elements/Loader'; describe('CantDeleteTemplateAlert', () => { let component; let props; + let instance; beforeEach(() => { props = { @@ -16,8 +18,9 @@ describe('CantDeleteTemplateAlert', () => { }; }); - let render = () => { + const render = () => { component = shallow(); + instance = component.instance(); }; it('should render a default closed modal', () => { @@ -28,14 +31,59 @@ describe('CantDeleteTemplateAlert', () => { it('noCancel option should hide the cancel button', () => { props.noCancel = true; render(); - expect(component.find('cancel-button').length).toBe(0); + expect(component).toMatchSnapshot(); + }); + + it('extraConfirm option should render a confirm input', () => { + props.extraConfirm = true; + render(); + expect(component).toMatchSnapshot(); }); describe('when clicking ok button', () => { it('should call accept function', () => { + props.isOpen = true; render(); component.find('.btn-danger').simulate('click'); expect(props.accept).toHaveBeenCalled(); + expect(instance.state.isOpen).toBe(false); + expect(instance.state.isLoading).toBe(false); + }); + + describe('when the action is async', () => { + let resolve; + let reject; + let promise; + beforeEach(() => { + promise = new Promise((_resolve, _reject) => { resolve = _resolve; reject = _reject; }); + props.accept.and.returnValue(promise); + props.isOpen = true; + render(); + }); + + it('should show a loading state until the promise resolves and render a Loader', (done) => { + component.find('.btn-danger').simulate('click'); + expect(props.accept).toHaveBeenCalled(); + expect(instance.state.isOpen).toBe(true); + expect(instance.state.isLoading).toBe(true); + expect(component.find(Loader).length).toBe(1); + resolve(); + promise.then(() => { + expect(instance.state.isOpen).toBe(false); + expect(instance.state.isLoading).toBe(false); + done(); + }); + }); + + it('should show a loading state until the promise rejects', (done) => { + component.find('.btn-danger').simulate('click'); + reject(); + promise.catch(() => { + expect(instance.state.isOpen).toBe(false); + expect(instance.state.isLoading).toBe(false); + done(); + }); + }); }); }); diff --git a/app/react/App/specs/__snapshots__/Confirm.spec.js.snap b/app/react/App/specs/__snapshots__/Confirm.spec.js.snap new file mode 100644 index 0000000000..f43f93d754 --- /dev/null +++ b/app/react/App/specs/__snapshots__/Confirm.spec.js.snap @@ -0,0 +1,72 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CantDeleteTemplateAlert extraConfirm option should render a confirm input 1`] = ` + + +

+ Confirm action +

+

+ Are you sure you want to continue? +

+ +

+ If you want to continue, please type ' + CONFIRM + ' +

+ +
+ +
+ + +
+
+`; + +exports[`CantDeleteTemplateAlert noCancel option should hide the cancel button 1`] = ` + + +

+ Confirm action +

+

+ Are you sure you want to continue? +

+ +
+ +
+
+`; diff --git a/app/react/Connections/actions/actions.js b/app/react/Connections/actions/actions.js index 43c33b9e4f..f9c9c563d6 100644 --- a/app/react/Connections/actions/actions.js +++ b/app/react/Connections/actions/actions.js @@ -1,5 +1,5 @@ -import {actions} from 'app/BasicReducer'; -import {notify} from 'app/Notifications'; +import { actions } from 'app/BasicReducer'; +import { notify } from 'app/Notifications'; import api from 'app/utils/api'; import debounce from 'app/utils/debounce'; @@ -9,7 +9,7 @@ import * as uiActions from './uiActions'; export function immidiateSearch(dispatch, searchTerm, connectionType) { dispatch(uiActions.searching()); - let query = {searchTerm, fields: ['title']}; + const query = { searchTerm, fields: ['title'] }; return api.get('search', query) .then((response) => { @@ -24,20 +24,18 @@ export function immidiateSearch(dispatch, searchTerm, connectionType) { const debouncedSearch = debounce(immidiateSearch, 400); export function search(searchTerm, connectionType) { - return function (dispatch) { + return (dispatch) => { dispatch(actions.set('connections/searchTerm', searchTerm)); return debouncedSearch(dispatch, searchTerm, connectionType); }; } export function startNewConnection(connectionType, sourceDocument) { - return function (dispatch) { - return immidiateSearch(dispatch, '', connectionType) - .then(() => { - dispatch(actions.set('connections/searchTerm', '')); - dispatch(uiActions.openPanel(connectionType, sourceDocument)); - }); - }; + return dispatch => immidiateSearch(dispatch, '', connectionType) + .then(() => { + dispatch(actions.set('connections/searchTerm', '')); + dispatch(uiActions.openPanel(connectionType, sourceDocument)); + }); } export function setRelationType(template) { @@ -55,17 +53,17 @@ export function setTargetDocument(id) { } export function saveConnection(connection, callback = () => {}) { - return function (dispatch, getState) { - dispatch({type: types.CREATING_CONNECTION}); + return (dispatch, getState) => { + dispatch({ type: types.CREATING_CONNECTION }); if (connection.type !== 'basic') { connection.language = getState().locale; } delete connection.type; - const sourceRelationship = {entity: connection.sourceDocument, template: null, range: connection.sourceRange}; + const sourceRelationship = { entity: connection.sourceDocument, template: null, range: connection.sourceRange }; - let targetRelationship = {entity: connection.targetDocument, template: connection.template}; + const targetRelationship = { entity: connection.targetDocument, template: connection.template }; if (connection.targetRange && typeof connection.targetRange.start !== 'undefined') { targetRelationship.range = connection.targetRange; } @@ -76,8 +74,8 @@ export function saveConnection(connection, callback = () => {}) { }; return api.post('relationships/bulk', apiCall) - .then(response => { - dispatch({type: types.CONNECTION_CREATED}); + .then((response) => { + dispatch({ type: types.CONNECTION_CREATED }); callback(response.json); dispatch(notify('saved successfully !', 'success')); }); @@ -85,8 +83,8 @@ export function saveConnection(connection, callback = () => {}) { } export function selectRangedTarget(connection, onRangedConnect) { - return function (dispatch) { - dispatch({type: types.CREATING_RANGED_CONNECTION}); + return (dispatch) => { + dispatch({ type: types.CREATING_RANGED_CONNECTION }); onRangedConnect(connection.targetDocument); }; } diff --git a/app/react/I18N/I18NApi.js b/app/react/I18N/I18NApi.js index 78e0cdf9e0..c8aa372651 100644 --- a/app/react/I18N/I18NApi.js +++ b/app/react/I18N/I18NApi.js @@ -14,5 +14,20 @@ export default { addEntry(context, key, value) { return api.post('translations/addentry', { context, key, value }) .then(response => response.json); + }, + + addLanguage(language) { + return api.post('translations/languages', language) + .then(response => response.json); + }, + + deleteLanguage(key) { + return api.delete('translations/languages', { key }) + .then(response => response.json); + }, + + setDefaultLanguage(key) { + return api.post('translations/setasdeafult', { key }) + .then(response => response.json); } }; diff --git a/app/react/I18N/actions/I18NActions.js b/app/react/I18N/actions/I18NActions.js index 8866426d25..7e9292646e 100644 --- a/app/react/I18N/actions/I18NActions.js +++ b/app/react/I18N/actions/I18NActions.js @@ -52,3 +52,24 @@ export function resetForm() { dispatch(formActions.reset('translationsForm')); }; } + +export function addLanguage(language) { + return dispatch => I18NApi.addLanguage(language) + .then(() => { + notifications.notify(t('System', 'New language added', null, false), 'success')(dispatch); + }); +} + +export function deleteLanguage(key) { + return dispatch => I18NApi.deleteLanguage(key) + .then(() => { + notifications.notify(t('System', 'Language deleted', null, false), 'success')(dispatch); + }); +} + +export function setDefaultLanguage(key) { + return dispatch => I18NApi.setDefaultLanguage(key) + .then(() => { + notifications.notify(t('System', 'Default language change success', null, false), 'success')(dispatch); + }); +} diff --git a/app/react/I18N/actions/specs/I18NActions.spec.js b/app/react/I18N/actions/specs/I18NActions.spec.js index 035bb50029..48b82206f1 100644 --- a/app/react/I18N/actions/specs/I18NActions.spec.js +++ b/app/react/I18N/actions/specs/I18NActions.spec.js @@ -1,7 +1,9 @@ import { actions as formActions } from 'react-redux-form'; import { store } from 'app/store'; import Immutable from 'immutable'; +import SettingsAPI from 'app/Settings/SettingsAPI'; import I18NApi from '../../I18NApi'; +import { actions as basicActions } from 'app/BasicReducer'; import * as actions from '../I18NActions'; describe('I18NActions', () => { @@ -65,4 +67,38 @@ describe('I18NActions', () => { done(); }); }); + + describe('addLanguage', () => { + it('should request the I18NApi to add a language', (done) => { + spyOn(I18NApi, 'addLanguage').and.returnValue(Promise.resolve()); + spyOn(SettingsAPI, 'get').and.returnValue(Promise.resolve({ collection: 'updated settings' })); + spyOn(basicActions, 'set'); + actions.addLanguage({ label: 'Español', key: 'es' })(dispatch).then(() => { + expect(I18NApi.addLanguage).toHaveBeenCalledWith({ label: 'Español', key: 'es' }); + done(); + }); + }); + }); + + describe('deleteLanguage', () => { + it('should request the I18NApi to add a language', (done) => { + spyOn(I18NApi, 'deleteLanguage').and.returnValue(Promise.resolve()); + spyOn(SettingsAPI, 'get').and.returnValue(Promise.resolve({ collection: 'updated settings' })); + spyOn(basicActions, 'set'); + actions.deleteLanguage('es')(dispatch).then(() => { + expect(I18NApi.deleteLanguage).toHaveBeenCalledWith('es'); + done(); + }); + }); + }); + + describe('setDefaultLanguage', () => { + it('should request the I18NApi to add a language', (done) => { + spyOn(I18NApi, 'setDefaultLanguage').and.returnValue(Promise.resolve()); + actions.setDefaultLanguage('es')(dispatch).then(() => { + expect(I18NApi.setDefaultLanguage).toHaveBeenCalledWith('es'); + done(); + }); + }); + }); }); diff --git a/app/react/I18N/specs/I18NApi.spec.js b/app/react/I18N/specs/I18NApi.spec.js index 037c3568a0..0016182020 100644 --- a/app/react/I18N/specs/I18NApi.spec.js +++ b/app/react/I18N/specs/I18NApi.spec.js @@ -1,18 +1,21 @@ import I18NApi from '../I18NApi'; -import {APIURL} from 'app/config.js'; +import { APIURL } from 'app/config.js'; import backend from 'fetch-mock'; describe('I18NApi', () => { - let translations = [{locale: 'es'}, {locale: 'en'}]; - let translation = {locale: 'es'}; - let addentryresponse = 'ok'; + const translations = [{ locale: 'es' }, { locale: 'en' }]; + const translation = { locale: 'es' }; + const okResponse = 'ok'; beforeEach(() => { backend.restore(); backend - .get(APIURL + 'translations', {body: JSON.stringify({rows: translations})}) - .post(APIURL + 'translations', {body: JSON.stringify(translation)}) - .post(APIURL + 'translations/addentry', {body: JSON.stringify(addentryresponse)}); + .get(`${APIURL}translations`, { body: JSON.stringify({ rows: translations }) }) + .post(`${APIURL}translations`, { body: JSON.stringify(translation) }) + .post(`${APIURL}translations/addentry`, { body: JSON.stringify(okResponse) }) + .post(`${APIURL}translations/languages`, { body: JSON.stringify(okResponse) }) + .delete(`${APIURL}translations/languages?key=kl`, { body: JSON.stringify(okResponse) }) + .post(`${APIURL}translations/setasdeafult`, { body: JSON.stringify(okResponse) }); }); afterEach(() => backend.restore()); @@ -30,10 +33,10 @@ describe('I18NApi', () => { describe('save()', () => { it('should post the document data to translations', (done) => { - let data = {locale: 'fr'}; + const data = { locale: 'fr' }; I18NApi.save(data) .then((response) => { - expect(JSON.parse(backend.lastOptions(APIURL + 'translations').body)).toEqual(data); + expect(JSON.parse(backend.lastOptions(`${APIURL}translations`).body)).toEqual(data); expect(response).toEqual(translation); done(); }) @@ -45,8 +48,47 @@ describe('I18NApi', () => { it('should post the new entry translations', (done) => { I18NApi.addEntry('System', 'search', 'Buscar') .then((response) => { - expect(JSON.parse(backend.lastOptions(APIURL + 'translations/addentry').body)) - .toEqual({context: 'System', key: 'search', value: 'Buscar'}); + expect(JSON.parse(backend.lastOptions(`${APIURL}translations/addentry`).body)) + .toEqual({ context: 'System', key: 'search', value: 'Buscar' }); + + expect(response).toEqual('ok'); + done(); + }) + .catch(done.fail); + }); + }); + + describe('addLanguage()', () => { + it('should post the new language', (done) => { + I18NApi.addLanguage({ label: 'Klingon', key: 'kl' }) + .then((response) => { + expect(JSON.parse(backend.lastOptions(`${APIURL}translations/languages`).body)) + .toEqual({ label: 'Klingon', key: 'kl' }); + + expect(response).toEqual('ok'); + done(); + }) + .catch(done.fail); + }); + }); + + describe('deleteLanguage()', () => { + it('should delete languages', (done) => { + I18NApi.deleteLanguage('kl') + .then((response) => { + expect(response).toEqual('ok'); + done(); + }) + .catch(done.fail); + }); + }); + + describe('setDefaultLanguage()', () => { + it('should post the default language', (done) => { + I18NApi.setDefaultLanguage('kl') + .then((response) => { + expect(JSON.parse(backend.lastOptions(`${APIURL}translations/setasdeafult`).body)) + .toEqual({ key: 'kl' }); expect(response).toEqual('ok'); done(); diff --git a/app/react/I18N/t.js b/app/react/I18N/t.js index e7924c4f9f..6f9897d365 100644 --- a/app/react/I18N/t.js +++ b/app/react/I18N/t.js @@ -1,5 +1,6 @@ import { store } from 'app/store'; import React from 'react'; +import translate, { getLocaleTranslation, getContext } from '../../shared/translate'; import { Translate } from './'; const testingEnvironment = process.env.NODE_ENV === 'test'; @@ -12,12 +13,12 @@ const t = (contextId, key, _text, returnComponent = true) => { if (!t.translation) { const state = store.getState(); const translations = state.translations.toJS(); - t.translation = translations.find(d => d.locale === state.locale) || { contexts: [] }; + t.translation = getLocaleTranslation(translations, state.locale); } - const context = t.translation.contexts.find(ctx => ctx.id === contextId) || { values: {} }; + const context = getContext(t.translation, contextId); - return context.values[key] || text; + return translate(context, key, text); }; t.resetCachedTranslation = () => { diff --git a/app/react/I18N/utils.js b/app/react/I18N/utils.js index 8553575a00..e3bc34543f 100644 --- a/app/react/I18N/utils.js +++ b/app/react/I18N/utils.js @@ -1,5 +1,4 @@ import * as Cookie from 'tiny-cookie'; - import { isClient } from 'app/utils'; const I18NUtils = { diff --git a/app/react/Layout/Warning.js b/app/react/Layout/Warning.js new file mode 100644 index 0000000000..4491ced82e --- /dev/null +++ b/app/react/Layout/Warning.js @@ -0,0 +1,18 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { Icon } from 'UI'; + +const Tip = ({ children }) => ( + + +
+ {children} +
+
+); + +Tip.propTypes = { + children: PropTypes.string.isRequired +}; + +export default Tip; diff --git a/app/react/Library/components/SortButtons.js b/app/react/Library/components/SortButtons.js index 37593ae6eb..6c35cf44a3 100644 --- a/app/react/Library/components/SortButtons.js +++ b/app/react/Library/components/SortButtons.js @@ -1,130 +1,156 @@ import PropTypes from 'prop-types'; -import React, {Component} from 'react'; -import {connect} from 'react-redux'; -import {bindActionCreators} from 'redux'; -import {wrapDispatch} from 'app/Multireducer'; -import {actions} from 'react-redux-form'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; +import { wrapDispatch } from 'app/Multireducer'; +import { actions } from 'react-redux-form'; import ShowIf from 'app/App/ShowIf'; -import {t} from 'app/I18N'; +import { t } from 'app/I18N'; import { Icon } from 'UI'; export class SortButtons extends Component { + static orderDirectionLabel(propertyType, order = 'asc') { + let label = order === 'asc' ? 'A-Z' : 'Z-A'; + if (propertyType === 'date') { + label = order === 'asc' ? t('System', 'Least recently') : t('System', 'Recently'); + } - constructor(props) { - super(props); - this.state = {active: false}; - } - - handleClick(property, defaultOrder, treatAs) { - if (!this.state.active) { - return; + if (propertyType === 'numeric') { + label = order === 'asc' ? '0-9' : '9-0'; } - this.sort(property, defaultOrder, treatAs); + return label; } - sort(property, defaultOrder, defaultTreatAs) { - let {search} = this.props; - let order = defaultOrder || 'asc'; - let treatAs = defaultTreatAs; - - if (search.sort === property) { - treatAs = search.treatAs; - } + constructor(props) { + super(props); + this.state = { active: false }; + } - let sort = {sort: property, order: order, treatAs}; + getAdditionalSorts(templates, search) { + const additionalSorts = templates.toJS().reduce((sorts, template) => { + template.properties.forEach((property) => { + const sortable = property.filter && ( + property.type === 'text' || + property.type === 'date' || + property.type === 'numeric' || + property.type === 'select' + ); - this.props.merge(this.props.stateProperty, sort); + if (sortable && !sorts.find(s => s.property === property.name)) { + const sortString = `metadata.${property.name}`; + const sortOptions = { isActive: search.sort === sortString, search, type: property.type }; - // TEST!!! - let filters = Object.assign({}, this.props.search, sort, {userSelectedSorting: true}); - // ------- - delete filters.treatAs; + sorts.push({ + property: property.name, + html: this.createSortItem(sorts.length + 3, sortString, template._id, property.label, sortOptions) + }); + } + }); + return sorts; + }, []); - if (this.props.sortCallback) { - this.props.sortCallback({search: filters}, this.props.storeKey); - } + return additionalSorts.map(s => s.html); } createSortItem(key, sortString, context, label, options) { - const {isActive, search, treatAs} = options; - + const { isActive, search, type } = options; + const treatAs = (type === 'text' || type === 'select') ? 'string' : 'number'; const firstOrder = treatAs !== 'number' ? 'asc' : 'desc'; const secondOrder = treatAs !== 'number' ? 'desc' : 'asc'; return ( -
  • - this.handleClick(sortString, firstOrder, treatAs)}> - {t(context, label)} ({treatAs !== 'number' ? 'A-Z' : t('System', 'Recently')}) - - - - - - +
  • + this.handleClick(sortString, firstOrder, treatAs)} + > + {t(context, label)} ({SortButtons.orderDirectionLabel(type, firstOrder)}) + + + + + + - this.handleClick(sortString, secondOrder, treatAs)}> - {t(context, label)} ({treatAs !== 'number' ? 'Z-A' : t('System', 'Least recently')}) - - - - - - + this.handleClick(sortString, secondOrder, treatAs)} + > + {t(context, label)} ({SortButtons.orderDirectionLabel(type, secondOrder)}) + + + + + +
  • ); } changeOrder() { - const {sort, order} = this.props.search; + const { sort, order } = this.props.search; this.sort(sort, order === 'desc' ? 'asc' : 'desc'); } - getAdditionalSorts(templates, search) { - const additionalSorts = templates.toJS().reduce((sorts, template) => { - template.properties.forEach(property => { - const sortable = property.filter && (property.type === 'text' || property.type === 'date'); + sort(property, defaultOrder, defaultTreatAs) { + const { search } = this.props; + const order = defaultOrder || 'asc'; + let treatAs = defaultTreatAs; - if (sortable && !sorts.find(s => s.property === property.name)) { - const sortString = 'metadata.' + property.name; - const treatAs = property.type === 'date' ? 'number' : 'string'; - const sortOptions = {isActive: search.sort === sortString, search, treatAs}; + if (search.sort === property) { + treatAs = search.treatAs; + } - sorts.push({ - property: property.name, - html: this.createSortItem(sorts.length + 3, sortString, template._id, property.label, sortOptions) - }); - } - }); - return sorts; - }, []); + const sort = { sort: property, order, treatAs }; - return additionalSorts.map(s => s.html); + this.props.merge(this.props.stateProperty, sort); + + // TEST!!! + const filters = Object.assign({}, this.props.search, sort, { userSelectedSorting: true }); + // ------- + delete filters.treatAs; + + if (this.props.sortCallback) { + this.props.sortCallback({ search: filters }, this.props.storeKey); + } + } + + handleClick(property, defaultOrder, treatAs) { + if (!this.state.active) { + return; + } + + this.sort(property, defaultOrder, treatAs); } toggle() { - this.setState({active: !this.state.active}); + this.setState({ active: !this.state.active }); } render() { - const {search, templates} = this.props; + const { search, templates } = this.props; const order = search.order === 'asc' ? 'up' : 'down'; const additionalSorts = this.getAdditionalSorts(templates, search, order); return (
    -
    +
      - {this.createSortItem(0, 'title', 'System', 'Title', {isActive: search.sort === 'title', search, treatAs: 'string'})} - {this.createSortItem(1, 'creationDate', 'System', 'Date added', {isActive: search.sort === 'creationDate', search, treatAs: 'number'})} -
    • - this.handleClick('_score')}> - {t('System', 'Relevance')} + {this.createSortItem(0, 'title', 'System', 'Title', { isActive: search.sort === 'title', search, type: 'string' })} + {this.createSortItem(1, 'creationDate', 'System', 'Date added', { isActive: search.sort === 'creationDate', search, type: 'date' })} +
    • + this.handleClick('_score')} + > + {t('System', 'Relevance')}
    • {additionalSorts} @@ -146,22 +172,20 @@ SortButtons.propTypes = { }; export function mapStateToProps(state, ownProps) { - let {templates} = state; - const stateProperty = ownProps.stateProperty ? ownProps.stateProperty : ownProps.storeKey + '.search'; + let { templates } = state; + const stateProperty = ownProps.stateProperty ? ownProps.stateProperty : `${ownProps.storeKey}.search`; if (ownProps.selectedTemplates && ownProps.selectedTemplates.count()) { templates = templates.filter(i => ownProps.selectedTemplates.includes(i.get('_id'))); } - const search = stateProperty.split(/[.,\/]/).reduce((memo, property) => { - return Object.keys(memo).indexOf(property) !== -1 ? memo[property] : null; - }, state); + const search = stateProperty.split(/[.,\/]/).reduce((memo, property) => Object.keys(memo).indexOf(property) !== -1 ? memo[property] : null, state); - return {stateProperty, search, templates}; + return { stateProperty, search, templates }; } function mapDispatchToProps(dispatch, props) { - return bindActionCreators({merge: actions.merge}, wrapDispatch(dispatch, props.storeKey)); + return bindActionCreators({ merge: actions.merge }, wrapDispatch(dispatch, props.storeKey)); } export default connect(mapStateToProps, mapDispatchToProps)(SortButtons); diff --git a/app/react/Library/components/specs/SortButtons.spec.js b/app/react/Library/components/specs/SortButtons.spec.js index 422d183ce8..ae8872b04c 100644 --- a/app/react/Library/components/specs/SortButtons.spec.js +++ b/app/react/Library/components/specs/SortButtons.spec.js @@ -1,15 +1,15 @@ import React from 'react'; -import {shallow} from 'enzyme'; -import {fromJS as immutable} from 'immutable'; +import { shallow } from 'enzyme'; +import { fromJS as immutable } from 'immutable'; -import {SortButtons, mapStateToProps} from 'app/Library/components/SortButtons'; +import { SortButtons, mapStateToProps } from 'app/Library/components/SortButtons'; describe('SortButtons', () => { let component; let instance; let props; - let render = () => { + const render = () => { component = shallow(); instance = component.instance(); }; @@ -18,9 +18,16 @@ describe('SortButtons', () => { props = { sortCallback: jasmine.createSpy('sortCallback'), merge: jasmine.createSpy('merge'), - search: {order: 'desc', sort: 'title'}, + search: { order: 'desc', sort: 'title' }, templates: immutable([ - {properties: [{}, {filter: true, name: 'sortable_name', label: 'sortableProperty', type: 'text'}]} + { properties: [ + {}, + { filter: true, name: 'date', label: 'date', type: 'date' }, + { filter: true, name: 'number', label: 'number', type: 'numeric' }, + { filter: true, name: 'my_select', label: 'my select', type: 'select' }, + { filter: true, name: 'sortable_name', label: 'sortableProperty', type: 'text' } + ] + } ]), stateProperty: 'search', storeKey: 'library' @@ -30,28 +37,14 @@ describe('SortButtons', () => { describe('Sort options', () => { it('should use templates sortable properties as options (with asc and desc for each)', () => { render(); - expect(component.find('li').length).toBe(4); - - expect(component.find('li').last().children().at(0).find('span').last().text()).toBe('sortableProperty (A-Z)'); - expect(component.find('li').last().children().at(1).find('span').last().text()).toBe('sortableProperty (Z-A)'); - }); - - it('should use use "recent" label for date type properties', () => { - props.templates = immutable([ - {properties: [{}, {filter: true, name: 'sortable_name', label: 'sortableProperty', type: 'date'}]} - ]); - render(); - expect(component.find('li').length).toBe(4); - - expect(component.find('li').last().children().at(0).find('span').last().text()).toBe('sortableProperty (Recently)'); - expect(component.find('li').last().children().at(1).find('span').last().text()).toBe('sortableProperty (Least recently)'); + expect(component).toMatchSnapshot(); }); describe('when multiple options have the same name', () => { it('should not duplicate the entry', () => { props.templates = immutable([ - {properties: [{}, {filter: true, name: 'sortable_name', label: 'sortableProperty', type: 'text'}]}, - {properties: [{filter: true, name: 'sortable_name', label: 'anotherLabel', type: 'text'}]} + { properties: [{}, { filter: true, name: 'sortable_name', label: 'sortableProperty', type: 'text' }] }, + { properties: [{ filter: true, name: 'sortable_name', label: 'anotherLabel', type: 'text' }] } ]); render(); @@ -73,23 +66,24 @@ describe('SortButtons', () => { describe('clicking an option', () => { it('should sort by that property with default order (asc for text and desc for date)', () => { render(); - component.setState({active: true}); + component.setState({ active: true }); component.find('li').last().children().at(0).simulate('click'); expect(props.sortCallback).toHaveBeenCalledWith( - {search: {sort: 'metadata.sortable_name', order: 'asc', userSelectedSorting: true}}, 'library' + { search: { sort: 'metadata.sortable_name', order: 'asc', userSelectedSorting: true } }, 'library' ); const templates = props.templates.toJS(); - templates[0].properties[1].name = 'different_name'; - templates[0].properties[1].type = 'date'; + const lastPropindex = templates[0].properties.length - 1; + templates[0].properties[lastPropindex].name = 'different_name'; + templates[0].properties[lastPropindex].type = 'date'; props.templates = immutable(templates); render(); - component.setState({active: true}); + component.setState({ active: true }); component.find('li').last().children().at(0).simulate('click'); expect(props.sortCallback).toHaveBeenCalledWith( - {search: {sort: 'metadata.different_name', order: 'desc', userSelectedSorting: true}}, 'library' + { search: { sort: 'metadata.different_name', order: 'desc', userSelectedSorting: true } }, 'library' ); }); }); @@ -99,14 +93,14 @@ describe('SortButtons', () => { it('should merge with searchTerm and filtersForm and NOT toggle between asc/desc', () => { render(); instance.sort('title', 'asc', 'number'); - expect(props.sortCallback).toHaveBeenCalledWith({search: {sort: 'title', order: 'asc', userSelectedSorting: true}}, 'library'); + expect(props.sortCallback).toHaveBeenCalledWith({ search: { sort: 'title', order: 'asc', userSelectedSorting: true } }, 'library'); props.search.order = 'asc'; props.search.treatAs = 'number'; render(); instance.sort('title', 'asc', 'string'); - expect(props.merge).toHaveBeenCalledWith('search', {sort: 'title', order: 'asc', treatAs: 'number'}); - expect(props.sortCallback).toHaveBeenCalledWith({search: {sort: 'title', order: 'asc', userSelectedSorting: true}}, 'library'); + expect(props.merge).toHaveBeenCalledWith('search', { sort: 'title', order: 'asc', treatAs: 'number' }); + expect(props.sortCallback).toHaveBeenCalledWith({ search: { sort: 'title', order: 'asc', userSelectedSorting: true } }, 'library'); }); it('should not fail if no sortCallback', () => { @@ -123,51 +117,51 @@ describe('SortButtons', () => { describe('when changing property being sorted', () => { it('should use default order', () => { - props.search = {order: 'desc', sort: 'title'}; + props.search = { order: 'desc', sort: 'title' }; render(); instance.sort('title'); - expect(props.sortCallback).toHaveBeenCalledWith({search: {sort: 'title', order: 'asc', userSelectedSorting: true}}, 'library'); + expect(props.sortCallback).toHaveBeenCalledWith({ search: { sort: 'title', order: 'asc', userSelectedSorting: true } }, 'library'); props.sortCallback.calls.reset(); - props.search = {order: 'desc', sort: 'title'}; + props.search = { order: 'desc', sort: 'title' }; render(); instance.sort('creationDate', 'desc'); - expect(props.sortCallback).toHaveBeenCalledWith({search: {sort: 'creationDate', order: 'desc', userSelectedSorting: true}}, 'library'); + expect(props.sortCallback).toHaveBeenCalledWith({ search: { sort: 'creationDate', order: 'desc', userSelectedSorting: true } }, 'library'); props.sortCallback.calls.reset(); - props.search = {order: 'desc', sort: 'title'}; + props.search = { order: 'desc', sort: 'title' }; render(); instance.sort('creationDate', 'asc'); - expect(props.sortCallback).toHaveBeenCalledWith({search: {sort: 'creationDate', order: 'asc', userSelectedSorting: true}}, 'library'); + expect(props.sortCallback).toHaveBeenCalledWith({ search: { sort: 'creationDate', order: 'asc', userSelectedSorting: true } }, 'library'); }); }); describe('when changing order', () => { it('should keep the treatAs property', () => { - props.search = {order: 'desc', sort: 'title', treatAs: 'number'}; + props.search = { order: 'desc', sort: 'title', treatAs: 'number' }; render(); instance.sort('title'); instance.changeOrder(); - expect(props.merge).toHaveBeenCalledWith('search', {sort: 'title', order: 'asc', treatAs: 'number'}); + expect(props.merge).toHaveBeenCalledWith('search', { sort: 'title', order: 'asc', treatAs: 'number' }); }); }); }); describe('when filtering title property', () => { it('should set active title', () => { - props.search = {order: 'asc', sort: 'title'}; + props.search = { order: 'asc', sort: 'title' }; render(); - let title = component.find('li').at(0); + const title = component.find('li').at(0); expect(title.hasClass('is-active')).toBe(true); }); }); describe('when filtering creationDate property asc', () => { it('should set active recent', () => { - props.search = {order: 'asc', sort: 'creationDate'}; + props.search = { order: 'asc', sort: 'creationDate' }; render(); - let title = component.find('li').at(0); - let recent = component.find('li').at(1); + const title = component.find('li').at(0); + const recent = component.find('li').at(1); expect(title.hasClass('is-active')).toBe(false); expect(recent.hasClass('is-active')).toBe(true); }); @@ -177,36 +171,36 @@ describe('SortButtons', () => { let templates; it('should send all templates from state', () => { - const state = {templates: immutable(['item']), library: {search: {}}}; - const _props = {storeKey: 'library'}; + const state = { templates: immutable(['item']), library: { search: {} } }; + const _props = { storeKey: 'library' }; expect(mapStateToProps(state, _props).templates.get(0)).toBe('item'); }); it('should only send selectedTemplates if array passed in ownProps', () => { - templates = immutable([{_id: 'a'}, {_id: 'b'}]); - const state = {templates, library: {search: {}}}; - const _props = {selectedTemplates: immutable(['b']), storeKey: 'library'}; + templates = immutable([{ _id: 'a' }, { _id: 'b' }]); + const state = { templates, library: { search: {} } }; + const _props = { selectedTemplates: immutable(['b']), storeKey: 'library' }; expect(mapStateToProps(state, _props).templates.getIn([0, '_id'])).toBe('b'); }); describe('search', () => { beforeEach(() => { - templates = immutable([{_id: 'a'}, {_id: 'b'}]); + templates = immutable([{ _id: 'a' }, { _id: 'b' }]); }); it('should be selected from the state according to the store key', () => { - const state = {templates, library: {search: 'correct search'}}; - const _props = {storeKey: 'library'}; + const state = { templates, library: { search: 'correct search' } }; + const _props = { storeKey: 'library' }; expect(mapStateToProps(state, _props).search).toBe('correct search'); }); it('should be selected from the state according to the stateProperty (if passed)', () => { - let state = {templates, library: {search: 'incorrect search', sort: 'correct search'}}; - let _props = {storeKey: 'library', stateProperty: 'library.sort'}; + let state = { templates, library: { search: 'incorrect search', sort: 'correct search' } }; + let _props = { storeKey: 'library', stateProperty: 'library.sort' }; expect(mapStateToProps(state, _props).search).toBe('correct search'); - state = {templates, library: {search: 'incorrect search', sort: 'incorrect search', nested: {dashed: 'correct search'}}}; - _props = {storeKey: 'library', stateProperty: 'library/nested.dashed'}; + state = { templates, library: { search: 'incorrect search', sort: 'incorrect search', nested: { dashed: 'correct search' } } }; + _props = { storeKey: 'library', stateProperty: 'library/nested.dashed' }; expect(mapStateToProps(state, _props).search).toBe('correct search'); }); }); diff --git a/app/react/Library/components/specs/__snapshots__/SortButtons.spec.js.snap b/app/react/Library/components/specs/__snapshots__/SortButtons.spec.js.snap new file mode 100644 index 0000000000..5a237e0bc5 --- /dev/null +++ b/app/react/Library/components/specs/__snapshots__/SortButtons.spec.js.snap @@ -0,0 +1,360 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SortButtons Sort options should use templates sortable properties as options (with asc and desc for each) 1`] = ` + +`; diff --git a/app/react/Metadata/components/MetadataFormFields.js b/app/react/Metadata/components/MetadataFormFields.js index d4f328d80c..aac9d21a3a 100644 --- a/app/react/Metadata/components/MetadataFormFields.js +++ b/app/react/Metadata/components/MetadataFormFields.js @@ -70,7 +70,7 @@ export class MetadataFormFields extends Component { case 'image': return (
      - +