diff --git a/.gitignore b/.gitignore index 1c13b20..4785fef 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,8 @@ gradle/wrapper out # IDEs and editors -/.idea +.idea +*/.idea *.iml /lib .project diff --git a/.travis.yml b/.travis.yml index 1e97978..0cabaa4 100644 --- a/.travis.yml +++ b/.travis.yml @@ -17,6 +17,8 @@ cache: - $HOME/.m2 before_install: + - sudo apt-get -qq update + - sudo apt-get install -y html2text - pushd data-showcase - gradle wrapper - GRADLE_SCRIPT="$(pwd)/gradlew" @@ -33,6 +35,11 @@ script: - ${GRADLE_SCRIPT} test - popd +after_failure: + - echo "Writing reports..." + - html2text data-showcase/build/reports/tests/test/index.html + - for f in data-showcase/build/reports/tests/test/classes/*.html; do echo "$f"; html2text "$f"; done + notifications: webhooks: on_success: change diff --git a/README.md b/README.md index 02bb780..276dfca 100644 --- a/README.md +++ b/README.md @@ -36,11 +36,11 @@ gradle wrapper cd src/main/user-interface npm start -# Package, creates build/lib/data-showcase-0.0.1-SNAPSHOT.war +# Package, creates build/lib/data-showcase-1.0.0.war ./gradlew assemble # Run the war -java -jar build/libs/data-showcase-0.0.1-SNAPSHOT.war +java -jar build/libs/data-showcase-1.0.0.war ``` ## Publish @@ -55,7 +55,7 @@ Publish to Nexus: # Deploy to the Nexus repository https://repo.thehyve.nl ./gradlew publish ``` -This publishes the artifact `nl.thehyve:data-showcase:0.0.1-SNAPSHOT:war`. +This publishes the artifact `nl.thehyve:data-showcase:1.0.0:war`. ## Deploy For deployment, fetch the application war file from the Nexus repository, @@ -64,7 +64,7 @@ and run the application with an external config file: MEMORY_OPTIONS="-Xms2g -Xmx2g -XX:MaxPermSize=512m" JAVA_OPTIONS="-server -Djava.awt.headless=true -Djava.security.egd=file:/dev/./urandom" APP_OPTIONS="-Dgrails.env=prod -Dspring.config.location=/home/user/data-showcase-internal.yml" -java -jar ${JAVA_OPTIONS} ${MEMORY_OPTIONS} ${APP_OPTIONS} data-showcase-0.0.1-SNAPHOT.war +java -jar ${JAVA_OPTIONS} ${MEMORY_OPTIONS} ${APP_OPTIONS} data-showcase-1.0.0.war ``` Example configuration file `data-showcase-internal.yml`: diff --git a/data-showcase-api-e2e/build.gradle b/data-showcase-api-e2e/build.gradle index 0d46b53..7653b0b 100644 --- a/data-showcase-api-e2e/build.gradle +++ b/data-showcase-api-e2e/build.gradle @@ -23,6 +23,7 @@ dependencies { testCompile 'org.spockframework:spock-core:1.0-groovy-2.4' testCompile 'io.github.http-builder-ng:http-builder-ng-apache:0.17.0' + testCompile 'io.github.http-builder-ng:http-builder-ng-okhttp:0.17.0' } tasks.withType(Test) { systemProperties = System.getProperties() } diff --git a/data-showcase-api-e2e/src/test/groovy/base/RestHelper.groovy b/data-showcase-api-e2e/src/test/groovy/base/RestHelper.groovy index 8995064..524b1ec 100644 --- a/data-showcase-api-e2e/src/test/groovy/base/RestHelper.groovy +++ b/data-showcase-api-e2e/src/test/groovy/base/RestHelper.groovy @@ -8,6 +8,7 @@ package base import groovyx.net.http.FromServer import groovyx.net.http.HttpBuilder +import groovyx.net.http.OkHttpEncoders import static config.Config.AUTH_NEEDED import static config.Config.DEFAULT_USER @@ -81,6 +82,10 @@ class RestHelper { request.contentType = requestMap.contentType ?: ContentTypeFor.JSON request.body = requestMap.body + if(requestMap.contentType == 'multipart/form-data') { + request.encoder 'multipart/form-data', OkHttpEncoders.&multipart + } + if (!requestMap.skipOauth && AUTH_NEEDED) { testContext.getAuthAdapter().authenticate(getRequest(), (requestMap.user ?: DEFAULT_USER)) } diff --git a/data-showcase-api-e2e/src/test/groovy/config/Config.groovy b/data-showcase-api-e2e/src/test/groovy/config/Config.groovy index a979c15..bef7892 100644 --- a/data-showcase-api-e2e/src/test/groovy/config/Config.groovy +++ b/data-showcase-api-e2e/src/test/groovy/config/Config.groovy @@ -39,4 +39,6 @@ class Config { //settings public static final boolean AUTH_NEEDED = false public static final String DEFAULT_USER = 'default' + public static final String TOKEN = 'TestToken123!' + } diff --git a/data-showcase-api-e2e/src/test/groovy/tests/rest/DataImportSpec.groovy b/data-showcase-api-e2e/src/test/groovy/tests/rest/DataImportSpec.groovy index 55207bd..ecfd8ed 100644 --- a/data-showcase-api-e2e/src/test/groovy/tests/rest/DataImportSpec.groovy +++ b/data-showcase-api-e2e/src/test/groovy/tests/rest/DataImportSpec.groovy @@ -7,9 +7,36 @@ package tests.rest import base.RESTSpec +import static config.Config.TOKEN +import static groovyx.net.http.MultipartContent.multipart class DataImportSpec extends RESTSpec { + def "upload valid data"() { + given: + def env = get([path: '/api/environment']) + assert env['grailsEnvironment'] == 'test' + assert env['environment'] == 'Public' + get([path: '/api/test/clearDatabase']) + + when: + def file = new File(getClass().getResource("/test.json").toURI()) + def requestToken = TOKEN + def request = [ + path : '/api/data_import/upload', + contentType: 'multipart/form-data', + body : multipart { + field 'requestToken', requestToken + part 'file', 'test.json', 'text/plain', file + }, + statusCode : 200 + ] + def response = post(request) + + then: + assert response.message == "Data successfully uploaded" + } + def "upload data with invalid requestToken"() { given: def env = get([path: '/api/environment']) @@ -19,19 +46,91 @@ class DataImportSpec extends RESTSpec { get([path: '/api/test/clearDatabase']) when: - def file = null - //file = new GrailsMockMultipartFile('testFile', 'test file contents'.bytes) + def requestToken = "" + def file = new File(getClass().getResource("/test.json").toURI()) def request = [ - path: '/api/data_import/upload', - body: toJSON([ - file : file, - requestToken: null - ]), - statusCode: 401 + path : '/api/data_import/upload', + contentType: 'multipart/form-data', + body : multipart { + field 'requestToken', requestToken + part 'file', 'test.json', 'text/plain', file + }, + statusCode : 401 ] def response = post(request) then: assert response.error == 'requestToken is required to upload the data' + + when: + requestToken = "invalid" + request = [ + path : '/api/data_import/upload', + contentType: 'multipart/form-data', + body : multipart { + field 'requestToken', requestToken + part 'file', 'test.json', 'text/plain', file + }, + statusCode : 401 + ] + response = post(request) + + then: + assert response.error == "requestToken: $requestToken is invalid" + } + + def "upload invalid data"() { + given: + def env = get([path: '/api/environment']) + assert env['grailsEnvironment'] == 'test' + assert env['environment'] == 'Public' + get([path: '/api/test/clearDatabase']) + + when: + // File with invalid data: + // project names not unique, concept code of the first item is missing + def file = new File(getClass().getResource("/test_invalid.json").toURI()) + def requestToken = TOKEN + def request = [ + path : '/api/data_import/upload', + contentType: 'multipart/form-data', + body : multipart { + field 'requestToken', requestToken + part 'file', 'test.json', 'text/plain', file + }, + statusCode : 400 + ] + def response = post(request) + + then: + assert response.status == 400 + assert response.error == "Bad Request" + } + + def "upload empty file"() { + given: + def env = get([path: '/api/environment']) + assert env['grailsEnvironment'] == 'test' + assert env['environment'] == 'Public' + get([path: '/api/test/clearDatabase']) + + when: + def file = new File(getClass().getResource("/test_invalid.json").toURI()) + def requestToken = TOKEN + def request = [ + path : '/api/data_import/upload', + contentType: 'multipart/form-data', + body : multipart { + field 'requestToken', requestToken + part 'file', 'test.json', 'text/plain', file + }, + statusCode : 400 + ] + def response = post(request) + + then: + assert response.status == 400 + assert response.error == "Bad Request" } + } diff --git a/data-showcase-api-e2e/src/test/resources/test.json b/data-showcase-api-e2e/src/test/resources/test.json new file mode 100644 index 0000000..80e11ba --- /dev/null +++ b/data-showcase-api-e2e/src/test/resources/test.json @@ -0,0 +1,190 @@ +{ + "items": [ + { + "conceptCode": "age", + "publicItem": true, + "summary": { + "observationCount": 102, + "dataStability": "Committed", + "maxValue": null, + "patientCount": 100, + "minValue": null, + "avgValue": null, + "stdDevValue": null, + "values": [ + { + "value": "<= 65", + "frequency": 35, + "label": "Young" + }, + { + "value": "> 65", + "frequency": 65, + "label": "Old" + } + ] + }, + "itemPath": "/Personal information/Basic information/Age", + "name": "ageA", + "projectName": "Project A" + }, + { + "conceptCode": "height", + "publicItem": true, + "summary": { + "observationCount": 402, + "dataStability": "Committed", + "maxValue": null, + "patientCount": 200, + "minValue": null, + "avgValue": null, + "stdDevValue": null, + "values": [ + { + "value": "<= 175", + "frequency": 36, + "label": "Short" + }, + { + "value": "> 175", + "frequency": 64, + "label": "Tall" + } + ] + }, + "itemPath": "/Personal information/Extended information/Height", + "name": "heightB", + "projectName": "Project B" + } + ], + "concepts": [ + { + "labelNlLong": "Leeftijd van het onderwerp", + "labelNl": "Leeftijd", + "conceptCode": "age", + "variableType": "Categorical", + "labelLong": "Age at time of survey", + "label": "Age", + "keywords": [ + "Personal information" + ] + }, + { + "labelNlLong": "Hoogte van het onderwerp", + "labelNl": "Hoogte", + "conceptCode": "height", + "variableType": "Categorical", + "labelLong": "Height at time of survey", + "label": "Height", + "keywords": [ + "Personal information", + "Family related", + "Body characteristics" + ] + }, + { + "labelNlLong": "Gewicht (kg)", + "labelNl": "Gewicht", + "conceptCode": "weight", + "variableType": "Numerical", + "labelLong": "Weight (kg)", + "label": "Weight", + "keywords": [ + "Personal information", + "Family related", + "Body characteristics" + ] + } + ], + "projects": [ + { + "name": "Project A", + "description": "First test project", + "lineOfResearch": "Research line 1" + }, + { + "name": "Project B", + "description": "Second test project", + "lineOfResearch": "Research line 2" + } + ], + "tree_nodes": [ + { + "conceptCode": null, + "path": "/Personal information", + "nodeType": "Domain", + "label": "Personal information", + "children": [ + { + "conceptCode": null, + "path": "/Personal information/Basic information", + "nodeType": "Domain", + "label": "Basic information", + "children": [ + { + "conceptCode": "age", + "path": "/Personal information/Basic information/Age", + "nodeType": "Concept", + "label": "Age", + "children": [ + + ] + }, + { + "conceptCode": "weight", + "path": "/Personal information/Basic information/Weight", + "nodeType": "Concept", + "label": "Weight", + "children": [ + + ] + } + ] + }, + { + "conceptCode": null, + "path": "/Personal information/Extended information", + "nodeType": "Domain", + "label": "Extended information", + "children": [ + { + "conceptCode": "height", + "path": "/Personal information/Extended information/Height", + "nodeType": "Concept", + "label": "Height", + "children": [ + + ] + }, + { + "conceptCode": null, + "path": "/Personal information/Extended information/Some details", + "nodeType": "Domain", + "label": "Some details", + "children": [ + + ] + } + ] + } + ] + }, + { + "conceptCode": null, + "path": "/Other information", + "nodeType": "Domain", + "label": "Other information", + "children": [ + { + "conceptCode": null, + "path": "/Other information/Some information", + "nodeType": "Domain", + "label": "Some information", + "children": [ + + ] + } + ] + } + ] +} diff --git a/data-showcase-api-e2e/src/test/resources/test_empty.json b/data-showcase-api-e2e/src/test/resources/test_empty.json new file mode 100644 index 0000000..e69de29 diff --git a/data-showcase-api-e2e/src/test/resources/test_invalid.json b/data-showcase-api-e2e/src/test/resources/test_invalid.json new file mode 100644 index 0000000..e35b702 --- /dev/null +++ b/data-showcase-api-e2e/src/test/resources/test_invalid.json @@ -0,0 +1,189 @@ +{ + "items": [ + { + "publicItem": true, + "summary": { + "observationCount": 102, + "dataStability": "Committed", + "maxValue": null, + "patientCount": 100, + "minValue": null, + "avgValue": null, + "stdDevValue": null, + "values": [ + { + "value": "<= 65", + "frequency": 35, + "label": "Young" + }, + { + "value": "> 65", + "frequency": 65, + "label": "Old" + } + ] + }, + "itemPath": "/Personal information/Basic information/Age", + "name": "ageA", + "projectName": "Project A" + }, + { + "conceptCode": "height", + "publicItem": true, + "summary": { + "observationCount": 402, + "dataStability": "Committed", + "maxValue": null, + "patientCount": 200, + "minValue": null, + "avgValue": null, + "stdDevValue": null, + "values": [ + { + "value": "<= 175", + "frequency": 36, + "label": "Short" + }, + { + "value": "> 175", + "frequency": 64, + "label": "Tall" + } + ] + }, + "itemPath": "/Personal information/Extended information/Height", + "name": "heightB", + "projectName": "Project B" + } + ], + "concepts": [ + { + "labelNlLong": "Leeftijd van het onderwerp", + "labelNl": "Leeftijd", + "conceptCode": "age", + "variableType": "Categorical", + "labelLong": "Age at time of survey", + "label": "Age", + "keywords": [ + "Personal information" + ] + }, + { + "labelNlLong": "Hoogte van het onderwerp", + "labelNl": "Hoogte", + "conceptCode": "height", + "variableType": "Categorical", + "labelLong": "Height at time of survey", + "label": "Height", + "keywords": [ + "Personal information", + "Family related", + "Body characteristics" + ] + }, + { + "labelNlLong": "Gewicht (kg)", + "labelNl": "Gewicht", + "conceptCode": "weight", + "variableType": "Numerical", + "labelLong": "Weight (kg)", + "label": "Weight", + "keywords": [ + "Personal information", + "Family related", + "Body characteristics" + ] + } + ], + "projects": [ + { + "name": "Project A", + "description": "First test project", + "lineOfResearch": "Research line 1" + }, + { + "name": "Project A", + "description": "Second test project", + "lineOfResearch": "Research line 2" + } + ], + "tree_nodes": [ + { + "conceptCode": null, + "path": "/Personal information", + "nodeType": "Domain", + "label": "Personal information", + "children": [ + { + "conceptCode": null, + "path": "/Personal information/Basic information", + "nodeType": "Domain", + "label": "Basic information", + "children": [ + { + "conceptCode": "age", + "path": "/Personal information/Basic information/Age", + "nodeType": "Concept", + "label": "Age", + "children": [ + + ] + }, + { + "conceptCode": "weight", + "path": "/Personal information/Basic information/Weight", + "nodeType": "Concept", + "label": "Weight", + "children": [ + + ] + } + ] + }, + { + "conceptCode": null, + "path": "/Personal information/Extended information", + "nodeType": "Domain", + "label": "Extended information", + "children": [ + { + "conceptCode": "height", + "path": "/Personal information/Extended information/Height", + "nodeType": "Concept", + "label": "Height", + "children": [ + + ] + }, + { + "conceptCode": null, + "path": "/Personal information/Extended information/Some details", + "nodeType": "Domain", + "label": "Some details", + "children": [ + + ] + } + ] + } + ] + }, + { + "conceptCode": null, + "path": "/Other information", + "nodeType": "Domain", + "label": "Other information", + "children": [ + { + "conceptCode": null, + "path": "/Other information/Some information", + "nodeType": "Domain", + "label": "Some information", + "children": [ + + ] + } + ] + } + ] +} diff --git a/data-showcase/build.gradle b/data-showcase/build.gradle index a190c84..b18ebb0 100644 --- a/data-showcase/build.gradle +++ b/data-showcase/build.gradle @@ -13,7 +13,7 @@ buildscript { } ext { - dataShowcaseVersion = '0.0.1-SNAPSHOT' + dataShowcaseVersion = '1.0.0' nodeVersion = '7.10.1' modelMapperVersion = '1.1.0' } @@ -84,13 +84,18 @@ node { nodeModulesDir = file('src/main/user-interface') } -task buildUserInterfaceOnce(type: NpmTask, dependsOn: 'npmInstall') { +task buildUserInterfaceProd(type: NpmTask, dependsOn: 'npmInstall') { group = 'application' description = 'Builds the user interface assets' - args = ['run', 'buildOnce'] + args = ['run', 'buildProd'] } -war.dependsOn(buildUserInterfaceOnce) -bootRun.dependsOn(buildUserInterfaceOnce) +war.dependsOn(buildUserInterfaceProd) +task buildUserInterfaceDev(type: NpmTask, dependsOn: 'npmInstall') { + group = 'application' + description = 'Builds the user interface assets' + args = ['run', 'buildDev'] +} +bootRun.dependsOn(buildUserInterfaceDev) task buildUserInterface(type: NpmTask, dependsOn: 'npmInstall') { group = 'application' @@ -147,3 +152,6 @@ publishing { publishToMavenLocal.dependsOn 'executableWar' publish.dependsOn 'executableWar' +bootRun { + jvmArgs = ['-Xmx4096m'] +} diff --git a/data-showcase/grails-app/conf/application.yml b/data-showcase/grails-app/conf/application.yml index f9a6a71..248b6f0 100644 --- a/data-showcase/grails-app/conf/application.yml +++ b/data-showcase/grails-app/conf/application.yml @@ -27,6 +27,8 @@ grails: mapping: version: false autoTimestamp: false + databinding: + convertEmptyStringsToNull: false info: app: @@ -103,6 +105,14 @@ environments: grails: cors: enabled: true + test: + grails: + cors: + enabled: true + testInternal: + grails: + cors: + enabled: true --- grails: @@ -164,6 +174,8 @@ environments: username: sa password: '' production: + hibernate: + show_sql: false dataSource: dbCreate: update url: jdbc:postgresql://localhost:5432/data_showcase diff --git a/data-showcase/grails-app/conf/spring/resources.groovy b/data-showcase/grails-app/conf/spring/resources.groovy index fdc9f4c..595eb8c 100644 --- a/data-showcase/grails-app/conf/spring/resources.groovy +++ b/data-showcase/grails-app/conf/spring/resources.groovy @@ -6,7 +6,7 @@ import nl.thehyve.datashowcase.Environment import nl.thehyve.datashowcase.StartupMessage -import nl.thehyve.datashowcase.mapping.ItemMapper +import nl.thehyve.datashowcase.search.SearchCriteriaBuilder import org.modelmapper.ModelMapper import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder @@ -14,7 +14,7 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder beans = { dataShowcaseEnvironment(Environment) {} modelMapper(ModelMapper) {} - itemMapper(ItemMapper) {} startupMessage(StartupMessage) {} bcryptEncoder(BCryptPasswordEncoder) {} + searchCriteriaBuilder(SearchCriteriaBuilder) {} } diff --git a/data-showcase/grails-app/controllers/nl/thehyve/datashowcase/ConceptController.groovy b/data-showcase/grails-app/controllers/nl/thehyve/datashowcase/ConceptController.groovy new file mode 100644 index 0000000..db57f31 --- /dev/null +++ b/data-showcase/grails-app/controllers/nl/thehyve/datashowcase/ConceptController.groovy @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2017 The Hyve B.V. + * This file is distributed under the GNU Affero General Public License + * (see accompanying file LICENSE). + */ + +package nl.thehyve.datashowcase + +import org.springframework.beans.factory.annotation.Autowired + +class ConceptController { + + static responseFormats = ['json'] + + @Autowired + ConceptService conceptService + + def index() { + respond concepts: conceptService.concepts + } + +} diff --git a/data-showcase/grails-app/controllers/nl/thehyve/datashowcase/ItemController.groovy b/data-showcase/grails-app/controllers/nl/thehyve/datashowcase/ItemController.groovy index 8b14894..28fed6d 100644 --- a/data-showcase/grails-app/controllers/nl/thehyve/datashowcase/ItemController.groovy +++ b/data-showcase/grails-app/controllers/nl/thehyve/datashowcase/ItemController.groovy @@ -6,6 +6,8 @@ package nl.thehyve.datashowcase +import com.fasterxml.jackson.databind.ObjectMapper +import nl.thehyve.datashowcase.representation.SearchQueryRepresentation import org.springframework.beans.factory.annotation.Autowired class ItemController { @@ -17,11 +19,69 @@ class ItemController { /** * Fetches all items. - * * @return the list of items as JSON. */ def index() { - respond items: itemService.items + try { + def args = request.JSON as Map + int firstResult = args.firstResult ?: 0 + def maxResults = args.maxResults as Integer + String order = args.order + String propertyName = args.propertyName + + response.status = 200 + response.contentType = 'application/json' + response.characterEncoding = 'utf-8' + def items = itemService.getItems(firstResult, maxResults, order, propertyName) + def count = itemService.getItemsCount() + int page = maxResults ? (firstResult/maxResults + 1) : 1 + def data = [totalCount: count, page: page, items: items] + new ObjectMapper().writeValue(response.outputStream, data) + } catch (Exception e) { + response.status = 400 + log.error 'An error occurred when fetching items.', e + respond error: "An error occurred when fetching items. Error: $e.message" + } + } + + /** + * Fetches all items with filter criteria. + * Supported criteria: conceptCodes, projects, free text search query. + * @return the list of items, total count and page number as JSON . + */ + def search() { + try { + + def args = request.JSON as Map + Set concepts = args.conceptCodes as Set + Set projects = args.projects as Set + int firstResult = args.firstResult ?: 0 + def maxResults = args.maxResults as Integer + String order = args.order + String propertyName = args.propertyName + log.info "Query input: ${args.searchQuery}" + def searchQuery = null + if (args.searchQuery) { + searchQuery = new SearchQueryRepresentation() + bindData(searchQuery, args.searchQuery) + } + + response.status = 200 + response.contentType = 'application/json' + response.characterEncoding = 'utf-8' + + def items = itemService.getItems( + firstResult, maxResults, order, propertyName, concepts, projects, searchQuery) + def count = itemService.getItemsCount(concepts, projects, searchQuery) + int page = maxResults ? (firstResult/maxResults + 1) : 1 + def data = [totalCount: count, page: page, items: items] + new ObjectMapper().writeValue(response.outputStream, data) + + } catch (Exception e) { + response.status = 400 + log.error 'An error occurred when fetching items.', e + respond error: "An error occurred when fetching items. Error: $e.message" + } } /** diff --git a/data-showcase/grails-app/controllers/nl/thehyve/datashowcase/KeywordController.groovy b/data-showcase/grails-app/controllers/nl/thehyve/datashowcase/KeywordController.groovy index 98853a7..7422f5b 100644 --- a/data-showcase/grails-app/controllers/nl/thehyve/datashowcase/KeywordController.groovy +++ b/data-showcase/grails-app/controllers/nl/thehyve/datashowcase/KeywordController.groovy @@ -19,4 +19,8 @@ class KeywordController { respond keywords: keywordService.keywords } + def show(String conceptCode) { + respond keywords: keywordService.getKeywordsForConcept(conceptCode) + } + } diff --git a/data-showcase/grails-app/controllers/nl/thehyve/datashowcase/ProjectController.groovy b/data-showcase/grails-app/controllers/nl/thehyve/datashowcase/ProjectController.groovy index 98b003a..658af22 100644 --- a/data-showcase/grails-app/controllers/nl/thehyve/datashowcase/ProjectController.groovy +++ b/data-showcase/grails-app/controllers/nl/thehyve/datashowcase/ProjectController.groovy @@ -6,6 +6,7 @@ package nl.thehyve.datashowcase +import nl.thehyve.datashowcase.representation.SearchQueryRepresentation import org.springframework.beans.factory.annotation.Autowired class ProjectController { @@ -15,8 +16,35 @@ class ProjectController { @Autowired ProjectService projectService + /** + * Fetches all projects + */ def index() { respond projects: projectService.projects } + /** + * Fetches all projects for items with filter criteria. + * Supported criteria: conceptCodes, free text search query. + * @return the list of projects as JSON. + */ + def search() { + def args = request.JSON as Map + Set concepts = args.conceptCodes as Set + def searchQuery = null + if (args.searchQuery) { + log.info "Query input: ${args.searchQuery}" + searchQuery = new SearchQueryRepresentation() + bindData(searchQuery, args.searchQuery) + } + + try { + respond projects: projectService.getProjects(concepts, searchQuery) + } catch (Exception e) { + response.status = 400 + log.error 'An error occurred when fetching projects.', e + respond error: "An error occurred when fetching projects. Error: $e.message" + } + } + } diff --git a/data-showcase/grails-app/controllers/nl/thehyve/datashowcase/TestController.groovy b/data-showcase/grails-app/controllers/nl/thehyve/datashowcase/TestController.groovy index 2937e29..66d7c71 100644 --- a/data-showcase/grails-app/controllers/nl/thehyve/datashowcase/TestController.groovy +++ b/data-showcase/grails-app/controllers/nl/thehyve/datashowcase/TestController.groovy @@ -20,14 +20,20 @@ class TestController { def clearDatabase() { dataService.clearDatabase() + dataService.clearCaches() + response.status = 200 } def createInternalData() { - testService.createInternalTestData() + testService.createRandomInternalTestData() + dataService.clearCaches() + response.status = 200 } def createPublicData() { testService.createPublicTestData() + dataService.clearCaches() + response.status = 200 } } diff --git a/data-showcase/grails-app/controllers/nl/thehyve/datashowcase/TreeController.groovy b/data-showcase/grails-app/controllers/nl/thehyve/datashowcase/TreeController.groovy index 14b0133..5da664d 100644 --- a/data-showcase/grails-app/controllers/nl/thehyve/datashowcase/TreeController.groovy +++ b/data-showcase/grails-app/controllers/nl/thehyve/datashowcase/TreeController.groovy @@ -6,6 +6,7 @@ package nl.thehyve.datashowcase +import com.fasterxml.jackson.databind.ObjectMapper import org.springframework.beans.factory.annotation.Autowired class TreeController { @@ -15,6 +16,9 @@ class TreeController { TreeService treeService def index() { - respond tree_nodes: treeService.nodes + response.status = 200 + response.contentType = 'application/json' + response.characterEncoding = 'utf-8' + new ObjectMapper().writeValue(response.outputStream, [tree_nodes: treeService.nodes]) } } diff --git a/data-showcase/grails-app/controllers/nl/thehyve/datashowcase/UrlMappings.groovy b/data-showcase/grails-app/controllers/nl/thehyve/datashowcase/UrlMappings.groovy index 3ac28f2..dc2582f 100644 --- a/data-showcase/grails-app/controllers/nl/thehyve/datashowcase/UrlMappings.groovy +++ b/data-showcase/grails-app/controllers/nl/thehyve/datashowcase/UrlMappings.groovy @@ -15,9 +15,16 @@ class UrlMappings { "/"(uri: '/index.html') "/api/environment"(controller: 'environment', includes: ['index']) - "/api/items"(resources: 'item', includes: ['index', 'show']) - "/api/keywords"(controller: 'keyword', includes: ['index']) - "/api/projects"(controller: 'project', includes: ['index']) + "/api/items/$id"(method: 'GET', controller: 'item', action: 'show') + "/api/items"(controller: 'item') { + action = [GET: 'index', POST: 'search'] + } + "/api/projects"(controller: 'project') { + action = [GET: 'index', POST: 'search'] + } + "/api/keywords/by_concept/$conceptCode"(controller: 'keyword', action: 'show') + "/api/keywords"(controller: 'keyword', action: 'index') + "/api/concepts"(controller: 'concept', includes: ['index']) "/api/lines_of_research"(controller: 'researchLine', includes: ['index']) "/api/tree_nodes"(controller: 'tree', includes: ['index']) "/api/file/logo/${type}"(controller: 'file', includes: ['getLogo']) diff --git a/data-showcase/grails-app/domain/nl/thehyve/datashowcase/Concept.groovy b/data-showcase/grails-app/domain/nl/thehyve/datashowcase/Concept.groovy index b78f4a5..3797ede 100644 --- a/data-showcase/grails-app/domain/nl/thehyve/datashowcase/Concept.groovy +++ b/data-showcase/grails-app/domain/nl/thehyve/datashowcase/Concept.groovy @@ -6,7 +6,6 @@ package nl.thehyve.datashowcase -import grails.databinding.BindUsing import nl.thehyve.datashowcase.enumeration.VariableType /** @@ -45,19 +44,12 @@ class Concept { */ VariableType variableType - /** - * Associated key words. - */ - @BindUsing({ obj, source -> - def keywords = source['keywords'].collect { - if (it) { - Keyword existingKeyword = Keyword.findByKeyword(it) - existingKeyword ?: new Keyword(keyword: it).save(flush: true) - } - } - keywords - }) - List keywords + static hasMany = [ + /** + * Associated key words. + */ + keywords: Keyword + ] @Override String toString() { @@ -66,6 +58,8 @@ class Concept { static mapping = { version false + + keywords batchSize: 1000 } static constraints = { diff --git a/data-showcase/grails-app/domain/nl/thehyve/datashowcase/Item.groovy b/data-showcase/grails-app/domain/nl/thehyve/datashowcase/Item.groovy index a21a0d3..2c68322 100644 --- a/data-showcase/grails-app/domain/nl/thehyve/datashowcase/Item.groovy +++ b/data-showcase/grails-app/domain/nl/thehyve/datashowcase/Item.groovy @@ -6,7 +6,7 @@ package nl.thehyve.datashowcase -import grails.databinding.BindUsing +import nl.thehyve.datashowcase.enumeration.VariableType /** * An item represents a variable in a study or survey. @@ -35,10 +35,6 @@ class Item { /** * The concept that the item represents in a project. */ - @BindUsing({ obj, source -> - String conceptCode = source['concept'] - Concept.findByConceptCode(conceptCode) - }) Concept concept /** @@ -46,20 +42,12 @@ class Item { */ static belongsTo = [project: Project] - /** - * Summary data for the variable: aggregate values and value frequencies. - */ - @BindUsing({ obj, source -> - def s = source['summary'] - def summary = s instanceof Summary ? s : new Summary(s) - if (s.values) { - s.values.each { - summary.addToValues(it) - } - } - summary - }) - Summary summary + static hasOne = [ + /** + * Summary data for the variable: aggregate values and value frequencies. + */ + summary: Summary + ] String getLabel() { concept.label @@ -77,21 +65,22 @@ class Item { concept.labelNlLong } - List getKeywords() { - concept.keywords - } - - String getType() { + VariableType getType() { concept.variableType } static mapping = { version false + + summary lazy: true + concept fetch: 'join' + project fetch: 'join' } static constraints = { name unique: true itemPath maxSize: 700 + summary nullable: true } } diff --git a/data-showcase/grails-app/domain/nl/thehyve/datashowcase/Project.groovy b/data-showcase/grails-app/domain/nl/thehyve/datashowcase/Project.groovy index 53ca3b5..f5fd8ae 100644 --- a/data-showcase/grails-app/domain/nl/thehyve/datashowcase/Project.groovy +++ b/data-showcase/grails-app/domain/nl/thehyve/datashowcase/Project.groovy @@ -6,8 +6,6 @@ package nl.thehyve.datashowcase -import grails.databinding.BindUsing - /** * Entity class to store projects (surveys). */ @@ -26,10 +24,6 @@ class Project { /** * The line of research the project belongs to. */ - @BindUsing({ obj, source -> - LineOfResearch existingResearchLine = LineOfResearch.findByName(source['lineOfResearch']) - existingResearchLine ?: new LineOfResearch(name: source['lineOfResearch']).save(flush: true) - }) LineOfResearch lineOfResearch @Override @@ -44,6 +38,8 @@ class Project { static mapping = { version false + + lineOfResearch fetch: 'join' } static constraints = { diff --git a/data-showcase/grails-app/domain/nl/thehyve/datashowcase/Summary.groovy b/data-showcase/grails-app/domain/nl/thehyve/datashowcase/Summary.groovy index 216da22..f567f53 100644 --- a/data-showcase/grails-app/domain/nl/thehyve/datashowcase/Summary.groovy +++ b/data-showcase/grails-app/domain/nl/thehyve/datashowcase/Summary.groovy @@ -18,7 +18,7 @@ class Summary { Long observationCount /** - * The number of patients for whom there are observations associated with the variable. + * The number of patients (subjects) for whom there are observations associated with the variable. */ Long patientCount @@ -48,7 +48,12 @@ class Summary { */ Double stdDevValue - static belongsTo = [item: Item] + static belongsTo = [ + /** + * The item this summary belongs to. + */ + item: Item + ] static hasMany = [ /** @@ -59,12 +64,14 @@ class Summary { static mapping = { version false + + values batchSize: 1000, sort: 'frequency', order:'desc' } static constraints = { observationCount nullable: false patientCount nullable: false - dataStability nullable: false + dataStability nullable: true minValue nullable: true maxValue nullable: true avgValue nullable: true diff --git a/data-showcase/grails-app/domain/nl/thehyve/datashowcase/TreeNode.groovy b/data-showcase/grails-app/domain/nl/thehyve/datashowcase/TreeNode.groovy index 9e650a4..49cafa6 100644 --- a/data-showcase/grails-app/domain/nl/thehyve/datashowcase/TreeNode.groovy +++ b/data-showcase/grails-app/domain/nl/thehyve/datashowcase/TreeNode.groovy @@ -6,7 +6,6 @@ package nl.thehyve.datashowcase -import grails.databinding.BindUsing import nl.thehyve.datashowcase.enumeration.NodeType import nl.thehyve.datashowcase.exception.InvalidDataException @@ -38,10 +37,6 @@ class TreeNode { * The concept the node is associated with, if it is a concept node. * A concept node always is a leaf node. */ - @BindUsing({ obj, source -> - String conceptCode = source['concept'] - Concept.findByConceptCode(conceptCode) - }) Concept concept /** @@ -84,6 +79,10 @@ class TreeNode { static mapping = { version false + + concept fetch: 'join' + parent fetch: 'join' + children batchSize: 1000 } static constraints = { diff --git a/data-showcase/grails-app/services/nl/thehyve/datashowcase/ConceptService.groovy b/data-showcase/grails-app/services/nl/thehyve/datashowcase/ConceptService.groovy new file mode 100644 index 0000000..ee6f8e7 --- /dev/null +++ b/data-showcase/grails-app/services/nl/thehyve/datashowcase/ConceptService.groovy @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2017 The Hyve B.V. + * This file is distributed under the GNU Affero General Public License + * (see accompanying file LICENSE). + */ + +package nl.thehyve.datashowcase + +import grails.gorm.transactions.Transactional +import grails.plugin.cache.CacheEvict +import grails.plugin.cache.Cacheable +import nl.thehyve.datashowcase.representation.ConceptRepresentation +import org.modelmapper.ModelMapper +import org.springframework.beans.factory.annotation.Autowired + +@Transactional(readOnly = true) +class ConceptService { + + @Autowired + ModelMapper modelMapper + + @Cacheable('concepts') + List getConcepts() { + Concept.findAll().collect({ Concept concept -> + modelMapper.map(concept, ConceptRepresentation) + }) + } + + @CacheEvict(value = 'concepts', allEntries = true) + void clearConceptsCache() { + log.info "Clear concepts cache." + } + +} diff --git a/data-showcase/grails-app/services/nl/thehyve/datashowcase/DataImportService.groovy b/data-showcase/grails-app/services/nl/thehyve/datashowcase/DataImportService.groovy index 80cf2af..2a81721 100644 --- a/data-showcase/grails-app/services/nl/thehyve/datashowcase/DataImportService.groovy +++ b/data-showcase/grails-app/services/nl/thehyve/datashowcase/DataImportService.groovy @@ -9,10 +9,14 @@ package nl.thehyve.datashowcase import grails.gorm.transactions.Transactional import grails.validation.ValidationException import nl.thehyve.datashowcase.exception.InvalidDataException +import org.grails.core.util.StopWatch import org.grails.datastore.gorm.GormEntity import org.grails.web.json.JSONArray import org.grails.web.json.JSONObject import org.springframework.beans.factory.annotation.Autowired +import org.hibernate.Transaction +import org.hibernate.SessionFactory +import org.hibernate.StatelessSession @Transactional class DataImportService { @@ -23,31 +27,90 @@ class DataImportService { @Autowired DataService dataService + SessionFactory sessionFactory + def upload(JSONObject json) { + def stopWatch = new StopWatch('Upload data') + // clear database + log.info('Clearing database...') + stopWatch.start('Clear database') + dataService.clearDatabase() + stopWatch.stop() + StatelessSession statelessSession = null + Transaction tx = null try { - // clear database - dataService.clearDatabase() + statelessSession = sessionFactory.openStatelessSession() + tx = statelessSession.beginTransaction() + + // save keywords + def keywords = json.concepts?.keywords?.flatten().unique().collect { + if (it?.trim()) new Keyword(keyword: it) + } as List + keywords.removeAll([null]) + validate(keywords) + log.info('Saving keywords...') + stopWatch.start('Save keywords') + keywords*.save() + stopWatch.stop() + def keywordMap = keywords.collectEntries { [(it.keyword): it] } as Map - // save concepts and related keywords - def concepts = json.concepts?.collect { new Concept(it) } + // save concepts + def concepts = json.concepts?.collect { + def conceptKeywords = it.remove('keywords') + def concept = new Concept(it as JSONObject) + conceptKeywords.each { + def keyword = keywordMap[it?.trim() as String] + if (keyword) { + concept.addToKeywords(keyword) + } + } + concept + } as List validate(concepts) log.info('Saving concepts...') - concepts*.save(flush: true, failOnError: true) + stopWatch.start('Save concepts') + concepts*.save() + stopWatch.stop() + def conceptMap = concepts.collectEntries { [(it.conceptCode): it] } as Map + statelessSession.managedFlush() // save tree_nodes - def tree_nodes = json.tree_nodes?.collect { JSONObject it -> - copyConceptCode(it) - new TreeNode(it) - } + def tree_nodes = flatten(buildTree(json.tree_nodes as JSONArray, conceptMap)) validate(tree_nodes) log.info('Saving tree nodes...') - tree_nodes*.save(flush: true, failOnError: true) + stopWatch.start('Save tree nodes') + tree_nodes*.save() + stopWatch.stop() + statelessSession.managedFlush() + + // save research_lines + def linesOfResearch = json.projects?.lineOfResearch.unique().findAll {it != null}.collect { + if (it) new LineOfResearch(name: it) + } as List + validate(linesOfResearch) + log.info("Saving lines of research...") + stopWatch.start('Save lines of research') + linesOfResearch*.save() + stopWatch.stop() + def lineOfResearchMap = linesOfResearch.collectEntries { [(it.name): it]} as Map - // save projects and related research_lines - def projects = json.projects?.collect { new Project(it) } + // save projects + def projects = json.projects?.collect { JSONObject it -> + def lineOfResearch = it.remove('lineOfResearch') as String + def project = new Project(it) + project.lineOfResearch = lineOfResearchMap[lineOfResearch] + if (!project.lineOfResearch) { + throw new InvalidDataException("No valid line of research set for project ${project.name}.") + } + project + } as List validate(projects) - log.info('Saving projects and research lines...') - projects*.save(flush: true, failOnError: true) + log.info("Saving projects...") + stopWatch.start('Save projects') + projects*.save() + stopWatch.stop() + def projectMap = projects.collectEntries { [(it.name): it] } as Map + statelessSession.managedFlush() // save items, related summaries and values if (!dataShowcaseEnvironment.internalInstance && !allItemsArePublic((JSONArray) json.items)) { @@ -55,22 +118,58 @@ class DataImportService { throw new InvalidDataException("Data validation exception. " + "Non public item cannot be loaded to a public environment") } + def summaryMap = [:] as Map def items = json.items?.collect { JSONObject it -> - it.concept = it.conceptCode + def summaryData = it.remove('summary') as JSONObject def item = new Item(it) - item.project = Project.findByName(it.projectName as String) + summaryMap[item.name] = new Summary(summaryData) + item.concept = conceptMap[it.conceptCode as String] + if (!item.concept) { + throw new InvalidDataException("No valid concept code set for item ${item.name}.") + } + item.project = projectMap[it.projectName as String] + if (!item.project) { + throw new InvalidDataException("No valid project name set for item ${item.name}.") + } item - } + } as List validate(items) - log.info('Saving items, summaries, values...') - items*.save(flush: true, failOnError: true) + log.info"Saving ${items.size()} items ..." + stopWatch.start('Save items') + def count = 0 + def countWidth = items.size().toString().size() + items.collate(500).each{ sublist -> + sublist*.save() + count += sublist.size() + log.info " [${count.toString().padLeft(countWidth)} / ${items.size()}] items saved" + sublist.each { + it.setSummary(summaryMap[it.name]) + it.summary?.item = it + } + log.info " [${count.toString().padLeft(countWidth)} / ${items.size()}] summaries saved" + sublist*.summary*.save() + statelessSession.managedFlush() + } + stopWatch.stop() + log.info "All items saved." + + stopWatch.start('Commit transaction') + tx.commit() + statelessSession.close() + stopWatch.stop() + + log.info "Upload completed.\n${stopWatch.prettyPrint()}" + + dataService.clearCaches() } catch (ValidationException e) { - log.error e.message - throw new InvalidDataException(e.message) + log.error "Invalid data uploaded", e + tx?.rollback() + throw new InvalidDataException("An error occured when uploading the data: ${e.message}") } catch (Exception e) { - log.error e.message - throw new InvalidDataException("An error occured when uploading the data: $e.message") + log.error "Error while saving data", e + tx?.rollback() + throw new InvalidDataException("An error occured when uploading the data: ${e.message}") } } @@ -87,12 +186,33 @@ class DataImportService { return items.every { it.publicItem == true } } - private static void copyConceptCode(JSONObject obj) { - obj.concept = obj.conceptCode - if (obj.children) { - (obj.children as JSONArray).each { - copyConceptCode(it as JSONObject) + private static List flatten(Collection nodes) { + if (nodes == null) { + return [] + } + nodes.collectMany { node -> + [node] + flatten(node.children) + } + } + + private static List buildTree(JSONArray nodes, final Map conceptMap) { + if (nodes == null) { + return null + } + nodes.collect { JSONObject nodeData -> + def childrenData = nodeData.remove('children') as JSONArray + def node = new TreeNode(nodeData) + if (nodeData.conceptCode) { + node.concept = conceptMap[nodeData.conceptCode as String] + if (!node.concept) { + throw new InvalidDataException("Invalid concept code set for tree node ${node.path}.") + } + } + node.children = buildTree(childrenData, conceptMap) + node.children?.each { + it.parent = node } + node } } } diff --git a/data-showcase/grails-app/services/nl/thehyve/datashowcase/DataService.groovy b/data-showcase/grails-app/services/nl/thehyve/datashowcase/DataService.groovy index 9fcc495..28af44d 100644 --- a/data-showcase/grails-app/services/nl/thehyve/datashowcase/DataService.groovy +++ b/data-showcase/grails-app/services/nl/thehyve/datashowcase/DataService.groovy @@ -7,7 +7,6 @@ package nl.thehyve.datashowcase import grails.gorm.transactions.Transactional -import nl.thehyve.datashowcase.representation.ItemRepresentation import org.springframework.beans.factory.annotation.Autowired @Transactional @@ -16,10 +15,26 @@ class DataService { @Autowired Environment dataShowcaseEnvironment + @Autowired + ItemService itemService + + @Autowired + ConceptService conceptService + + @Autowired + TreeService treeService + + def clearCaches() { + itemService.clearItemsCache() + itemService.clearItemCountsCache() + conceptService.clearConceptsCache() + treeService.clearTreeNodesCache() + } + def clearDatabase() { Value.executeUpdate('delete from Value') - Item.executeUpdate('delete from Item') Summary.executeUpdate('delete from Summary') + Item.executeUpdate('delete from Item') TreeNode.executeUpdate('delete from TreeNode') Concept.executeUpdate('delete from Concept') Keyword.executeUpdate('delete from Keyword') @@ -27,22 +42,4 @@ class DataService { LineOfResearch.executeUpdate('delete from LineOfResearch') } - /** - * Upload concepts, tree nodes and items. - * - * @param concepts The concepts to save. - * @param nodes The nodes to save. - * @param items The items to save. - */ - def uploadData(List concepts, List nodes, List items) { - if (!dataShowcaseEnvironment.internalInstance) { - // TODO - } - // FIXME - // - check if no public data is being uploaded. - // - truncate tables - // - upload concepts - // - upload items and associated data - // - upload tree nodes - } } diff --git a/data-showcase/grails-app/services/nl/thehyve/datashowcase/ItemService.groovy b/data-showcase/grails-app/services/nl/thehyve/datashowcase/ItemService.groovy index 84b329a..cab96e6 100644 --- a/data-showcase/grails-app/services/nl/thehyve/datashowcase/ItemService.groovy +++ b/data-showcase/grails-app/services/nl/thehyve/datashowcase/ItemService.groovy @@ -7,12 +7,27 @@ package nl.thehyve.datashowcase import grails.gorm.transactions.Transactional +import grails.plugin.cache.CacheEvict import grails.plugin.cache.Cacheable -import nl.thehyve.datashowcase.enumeration.NodeType +import groovy.transform.CompileStatic +import nl.thehyve.datashowcase.enumeration.VariableType import nl.thehyve.datashowcase.exception.ResourceNotFoundException -import nl.thehyve.datashowcase.mapping.ItemMapper +import nl.thehyve.datashowcase.representation.InternalItemRepresentation import nl.thehyve.datashowcase.representation.ItemRepresentation -import nl.thehyve.datashowcase.representation.TreeNodeRepresentation +import nl.thehyve.datashowcase.representation.PublicItemRepresentation +import nl.thehyve.datashowcase.representation.SearchQueryRepresentation +import nl.thehyve.datashowcase.search.SearchCriteriaBuilder +import org.grails.core.util.StopWatch +import org.hibernate.Criteria +import org.hibernate.Session +import org.hibernate.SessionFactory +import org.hibernate.criterion.Criterion +import org.hibernate.criterion.Order +import org.hibernate.criterion.Projections +import org.hibernate.criterion.Restrictions +import org.hibernate.sql.JoinType +import org.hibernate.transform.Transformers +import org.modelmapper.ModelMapper import org.springframework.beans.factory.annotation.Autowired @Transactional @@ -22,71 +37,247 @@ class ItemService { Environment dataShowcaseEnvironment @Autowired - ItemMapper itemMapper + ModelMapper modelMapper + @Autowired + SearchCriteriaBuilder searchCriteriaBuilder + + SessionFactory sessionFactory + + @CompileStatic + static ItemRepresentation map(Map itemData) { + new ItemRepresentation( + id: itemData.id as Long, + name: itemData.name as String, + label: itemData.label as String, + labelLong: itemData.labelLong as String, + project: itemData.projectName as String, + concept: itemData.conceptCode as String, + itemPath: itemData.itemPath as String, + type: itemData.type as VariableType, + publicItem: itemData.publicItem as boolean + ) + } + + static String propertyNameFromRepresentationName(String name) { + switch (name){ + case "project": return "projectName" + case "concept": return "conceptCode" + case "label": return "label" + case "lineofresearch": return "p.lineOfResearch" + default: return "i.name" + } + } + + @Cacheable('items') @Transactional(readOnly = true) - List getItems() { - if (dataShowcaseEnvironment.internalInstance) { - Item.findAll().collect({ - itemMapper.map(it) - }) + List getItems(int firstResult, Integer maxResults, String order, String propertyName) { + def property = propertyNameFromRepresentationName(propertyName) + + def stopWatch = new StopWatch('Fetch items') + stopWatch.start('Retrieve from database') + def session = sessionFactory.openStatelessSession() + def query = session.createQuery( + """ + select + i.id as id, + i.name as name, + i.publicItem as publicItem, + i.itemPath as itemPath, + c.conceptCode as conceptCode, + c.label as label, + c.labelLong as labelLong, + c.variableType as type, + p.name as projectName + from Item as i + join i.concept c + join i.project p + ${dataShowcaseEnvironment.internalInstance ? + '' : 'where i.publicItem = true' + } + order by $property ${order == 'desc' ? + 'desc' : 'asc' + } + """ + ) + query.setResultTransformer(Transformers.ALIAS_TO_ENTITY_MAP) + if (firstResult > 0) { + query.setFirstResult(firstResult) + } + if (maxResults) { + query.setMaxResults(maxResults) + } + def items = query.list() as List + stopWatch.stop() + stopWatch.start('Map to representations') + def result = items.collect { Map itemData -> + map(itemData) + } + stopWatch.stop() + log.info "${result.size()} items fetched.\n${stopWatch.prettyPrint()}" + result + } + + @Transactional(readOnly = true) + Long getItemsCount() { + def session = sessionFactory.currentSession + session.createCriteria(Item, "i") + .setProjection(Projections.rowCount()) + .uniqueResult() as Long + } + + @Transactional(readOnly = true) + List getItems(int firstResult, Integer maxResults, String order, String propertyName, + Set concepts, Set projects, SearchQueryRepresentation searchQuery) { + + def property = propertyNameFromRepresentationName(propertyName) + Criterion searchQueryCriterion = searchQuery ? searchCriteriaBuilder.buildCriteria(searchQuery) : null + def stopWatch = new StopWatch('Fetch filtered items') + stopWatch.start('Retrieve from database') + def session = sessionFactory.openStatelessSession() + + Criteria criteria = session.createCriteria(Item, "i") + .createAlias("i.concept", "c") + .createAlias("i.project", "p") + .createAlias("c.keywords", "k", JoinType.LEFT_OUTER_JOIN) + .setProjection(Projections.projectionList() + .add(Projections.distinct(Projections.property("i.id").as("id"))) + .add(Projections.property("i.name").as("name")) + .add(Projections.property("i.publicItem").as("publicItem")) + .add(Projections.property("i.itemPath").as("itemPath")) + .add(Projections.property("c.conceptCode").as("conceptCode")) + .add(Projections.property("c.label").as("label")) + .add(Projections.property("c.labelLong").as("labelLong")) + .add(Projections.property("c.variableType").as("variableType")) + .add(Projections.property("p.name").as("projectName"))) + if (concepts) { + criteria.add(Restrictions.in('c.conceptCode', concepts)) + } + if (projects) { + criteria.add(Restrictions.in('p.name', projects)) + } + if (!dataShowcaseEnvironment.internalInstance) { + criteria.add(Restrictions.eq('i.publicItem', true)) + } + if (searchQueryCriterion) { + criteria.add(searchQueryCriterion) + } + criteria.setResultTransformer(Criteria.ALIAS_TO_ENTITY_MAP) + if (firstResult > 0) { + criteria.setFirstResult(firstResult) + } + if (maxResults) { + criteria.setMaxResults(maxResults) + } + + if (order == "desc") { + criteria.addOrder(Order.desc(property)) } else { - Item.findAllByPublicItem(true).collect({ - itemMapper.map(it) - }) + criteria.addOrder(Order.asc(property)) } + def items = criteria.list() as List + + stopWatch.stop() + + stopWatch.start('Map to representations') + def result = items.collect { Map itemData -> + map(itemData) + } + stopWatch.stop() + log.info "${result.size()} filtered items fetched.\n${stopWatch.prettyPrint()}" + result } @Transactional(readOnly = true) - @Cacheable('itemcounts') - Long countItemsForNode(String path) { + Long getItemsCount(Set concepts, Set projects, SearchQueryRepresentation searchQuery) { + + Criterion searchQueryCriterion = searchQuery ? searchCriteriaBuilder.buildCriteria(searchQuery) : null + def stopWatch = new StopWatch('Items count') + stopWatch.start('Retrieve from database') + def session = sessionFactory.openStatelessSession() + + Criteria criteria = session.createCriteria(Item, "i") + .createAlias("i.concept", "c") + .createAlias("i.project", "p") + .createAlias("c.keywords", "k", JoinType.LEFT_OUTER_JOIN) + if(concepts) { + criteria.add( Restrictions.in('c.conceptCode', concepts)) + } + if(projects) { + criteria.add( Restrictions.in('p.name', projects)) + } + if(!dataShowcaseEnvironment.internalInstance) { + criteria.add( Restrictions.eq('i.publicItem',true)) + } + if(searchQueryCriterion) { + criteria.add(searchQueryCriterion) + } + criteria.setResultTransformer(Criteria.ALIAS_TO_ENTITY_MAP) + .setProjection(Projections.countDistinct('i.id')) + Long totalItemsCount = (Long)criteria.uniqueResult() + + stopWatch.stop() + log.info "Total item count: ${totalItemsCount}" + totalItemsCount + } + + @CacheEvict(value = 'items', allEntries = true) + void clearItemsCache() { + log.info "Clear items cache." + } + + @Transactional(readOnly = true) + @Cacheable('itemCountPerNode') + Map getItemCountPerNode() { + Session session = sessionFactory.currentSession if (dataShowcaseEnvironment.internalInstance) { - Item.executeQuery( - """ select count(distinct i) from Item i, TreeNode n + def itemCountMap = session.createQuery( + """ select n.path as path, count(distinct i) as itemCount + from Item i, TreeNode n join i.concept c where n.concept = c - and n.path = :path - """, - [path: path] - )[0] + group by n.path + """ + ).setResultTransformer(Transformers.ALIAS_TO_ENTITY_MAP) + .list() as List + itemCountMap.collectEntries { + [(it.path): it.itemCount as Long] + } } else { - Item.executeQuery( - """ select count(distinct i) from Item i, TreeNode n + def itemCountMap = session.createQuery( + """ select n.path as path, count(distinct i) as itemCount + from Item i, TreeNode n join i.concept c where n.concept = c - and n.path = :path - and i.publicItem = true - """, - [path: path] - )[0] + and i.publicItem = true + group by n.path + """ + ).setResultTransformer(Transformers.ALIAS_TO_ENTITY_MAP) + .list() as List + itemCountMap.collectEntries { + [(it.path): it.itemCount as Long] + } } } - @Transactional(readOnly = true) - Long countItemsForNode(TreeNodeRepresentation treeNode) { - if (treeNode.nodeType == NodeType.Domain) { - 0 - } else { - countItemsForNode(treeNode.path) - } + @CacheEvict(value = 'itemCountPerNode', allEntries = true) + void clearItemCountsCache() { + log.info "Clear items counts cache." } @Transactional(readOnly = true) ItemRepresentation getItem(long id) { - def item if (dataShowcaseEnvironment.internalInstance) { - item = Item.findById(id) + def item = Item.findById(id) + if (item) { + return modelMapper.map(item, InternalItemRepresentation.class) + } } else { - item = Item.findByPublicItemAndId(true, id) - } - if (item == null) { - throw new ResourceNotFoundException('Item not found') + def item = Item.findByPublicItemAndId(true, id) + if (item) { + return modelMapper.map(item, PublicItemRepresentation.class) + } } - itemMapper.map(item) - } - - def saveItems(List items) { - + throw new ResourceNotFoundException('Item not found') } - } diff --git a/data-showcase/grails-app/services/nl/thehyve/datashowcase/KeywordService.groovy b/data-showcase/grails-app/services/nl/thehyve/datashowcase/KeywordService.groovy index ae60d1a..4b01b0a 100644 --- a/data-showcase/grails-app/services/nl/thehyve/datashowcase/KeywordService.groovy +++ b/data-showcase/grails-app/services/nl/thehyve/datashowcase/KeywordService.groovy @@ -7,6 +7,13 @@ package nl.thehyve.datashowcase import grails.gorm.transactions.Transactional +import org.grails.core.util.StopWatch +import org.hibernate.Criteria +import org.hibernate.SessionFactory +import org.hibernate.criterion.Order +import org.hibernate.criterion.Projections +import org.hibernate.criterion.Restrictions +import org.hibernate.sql.JoinType import org.modelmapper.ModelMapper import org.springframework.beans.factory.annotation.Autowired @@ -16,10 +23,31 @@ class KeywordService { @Autowired ModelMapper modelMapper + SessionFactory sessionFactory + List getKeywords() { Keyword.findAll().collect({ it.keyword }) } + List getKeywordsForConcept(String conceptCode) { + def stopWatch = new StopWatch("Fetch keywords for concept: $conceptCode.") + stopWatch.start('Retrieve from database') + def session = sessionFactory.openSession() + + Criteria criteria = session.createCriteria(Concept, "c") + .createAlias("c.keywords", "k", JoinType.LEFT_OUTER_JOIN) + .setProjection(Projections.property("k.keyword").as("keyword")) + .add( Restrictions.eq('c.conceptCode', conceptCode)) + criteria.setResultTransformer(Criteria.ALIAS_TO_ENTITY_MAP) + criteria.addOrder(Order.asc("keyword")) + def keywords = criteria.list() as List + + stopWatch.stop() + + log.info "${keywords.size()} keywords fetched for concept: $conceptCode.\n${stopWatch.prettyPrint()}" + keywords.collect{it.keyword} + } + } diff --git a/data-showcase/grails-app/services/nl/thehyve/datashowcase/ProjectService.groovy b/data-showcase/grails-app/services/nl/thehyve/datashowcase/ProjectService.groovy index 1716555..040f370 100644 --- a/data-showcase/grails-app/services/nl/thehyve/datashowcase/ProjectService.groovy +++ b/data-showcase/grails-app/services/nl/thehyve/datashowcase/ProjectService.groovy @@ -7,20 +7,85 @@ package nl.thehyve.datashowcase import grails.gorm.transactions.Transactional +import groovy.transform.CompileStatic import nl.thehyve.datashowcase.representation.ProjectRepresentation +import nl.thehyve.datashowcase.representation.SearchQueryRepresentation +import nl.thehyve.datashowcase.search.SearchCriteriaBuilder +import org.grails.core.util.StopWatch +import org.hibernate.Criteria +import org.hibernate.HibernateException +import org.hibernate.SessionFactory +import org.hibernate.Transaction +import org.hibernate.criterion.Criterion +import org.hibernate.criterion.Order +import org.hibernate.criterion.Projections +import org.hibernate.criterion.Restrictions +import org.hibernate.sql.JoinType import org.modelmapper.ModelMapper import org.springframework.beans.factory.annotation.Autowired @Transactional(readOnly = true) class ProjectService { + @Autowired + Environment dataShowcaseEnvironment + + @Autowired + SearchCriteriaBuilder searchCriteriaBuilder + + SessionFactory sessionFactory + @Autowired ModelMapper modelMapper + @CompileStatic + static ProjectRepresentation map(Map projectData) { + new ProjectRepresentation( + name: projectData.name as String, + lineOfResearch: projectData.lineOfResearch as String + ) + } + List getProjects() { - Project.findAll().collect({ + Project.findAll([sort: 'name', order: 'asc']).collect({ modelMapper.map(it, ProjectRepresentation) }) } + List getProjects(Set concepts, SearchQueryRepresentation searchQuery) { + + Criterion searchQueryCriterion = searchQuery ? searchCriteriaBuilder.buildCriteria(searchQuery) : null + def stopWatch = new StopWatch('Fetch projects filtered items') + stopWatch.start('Retrieve from database') + + Criteria criteria = sessionFactory.currentSession.createCriteria(Project, "p") + .createAlias("p.items", "i") + .createAlias("i.concept", "c") + .createAlias("c.keywords", "k", JoinType.LEFT_OUTER_JOIN) + .setProjection(Projections.projectionList() + .add(Projections.distinct(Projections.property("p.name").as("name"))) + .add(Projections.property("p.lineOfResearch").as("lineOfResearch"))) + if (concepts) { + criteria.add(Restrictions.in('c.conceptCode', concepts)) + } + if (dataShowcaseEnvironment.internalInstance) { + criteria.add(Restrictions.eq('i.publicItem', true)) + } + if (searchQueryCriterion) { + criteria.add(searchQueryCriterion) + } + criteria.addOrder(Order.asc('p.name')) + criteria.setResultTransformer(Criteria.ALIAS_TO_ENTITY_MAP) + def projects = criteria.list() as List + stopWatch.stop() + + stopWatch.start('Map to representations') + def result = projects.collect { Map projectData -> + map(projectData) + } + stopWatch.stop() + log.info "Projects fetched.\n${stopWatch.prettyPrint()}" + result + } + } diff --git a/data-showcase/grails-app/services/nl/thehyve/datashowcase/TestService.groovy b/data-showcase/grails-app/services/nl/thehyve/datashowcase/TestService.groovy index 261c8db..698a36d 100644 --- a/data-showcase/grails-app/services/nl/thehyve/datashowcase/TestService.groovy +++ b/data-showcase/grails-app/services/nl/thehyve/datashowcase/TestService.groovy @@ -143,20 +143,27 @@ class TestService { Summary[] weightSummaries = createSummary(9, 55, 230) Item[] weightItems = createItems(9, "weight", projectC, nodes[3], weightSummaries, - true) + false) - createRandomData() concepts*.save(flush: true) nodes*.save(flush: true) - nodes.each { node -> - log.info "Created node ${node.label}, parent: ${node.parent?.path}, path: ${node.path}" - } ageItems*.save(flush: true) heightItems*.save(flush: true) weightItems*.save(flush: true) } + /** + * Creates and stores test objects for the internal environment (including + * non-public items and extensive summary data). + */ + def createRandomInternalTestData() { + checkGrailsEnvironment(Constants.INTERNAL_ENVIRONMENTS) + + createInternalTestData() + createRandomData() + } + /** * Creates and stores test objects for the public environment (with * only public items and limited summary data). @@ -219,10 +226,10 @@ class TestService { testLineOfResearch = new LineOfResearch(name: 'Test research line') testLineOfResearch.save(flush: true) testProject = new Project(name: 'Project', description: 'Test project', lineOfResearch: testLineOfResearch) - [testProject, testKeyword]*.save() + [testProject, testKeyword]*.save(flush: true) int[] domainPerLevel = [10, 2, 3, 2] - TreeNode parent = null; + TreeNode parent = null createRandomDomain(parent, domainPerLevel) } @@ -242,16 +249,19 @@ class TestService { def createRandomConceptNodeWithItems(TreeNode domain) { + def keyword = Keyword.findByKeyword('Test keyword') def concept = new Concept( conceptCode: "ConceptFor$domain.label", label: "ConceptFor$domain.label", labelLong: "ConceptFor$domain.label long description", labelNl: "NederlandsConceptVoor$domain.label", labelNlLong: "NederlandsConceptVoor$domain.label lange beschrijving", variableType: VariableType.Categorical, - keywords: ["keyword: 'Test keyword'"]) + keywords: [keyword]) concepts.add(concept) def conceptNode = new TreeNode(domain, concept) nodes.add(conceptNode) + [concept, conceptNode]*.save(flush: true) + Summary[] summaries = createSummary(8, 12, 99) Item[] items = createItems(3, concept.label + "Item", testProject, conceptNode, summaries, true) diff --git a/data-showcase/grails-app/services/nl/thehyve/datashowcase/TreeService.groovy b/data-showcase/grails-app/services/nl/thehyve/datashowcase/TreeService.groovy index 284e2cc..95b345c 100644 --- a/data-showcase/grails-app/services/nl/thehyve/datashowcase/TreeService.groovy +++ b/data-showcase/grails-app/services/nl/thehyve/datashowcase/TreeService.groovy @@ -7,8 +7,12 @@ package nl.thehyve.datashowcase import grails.gorm.transactions.Transactional +import grails.plugin.cache.CacheEvict +import grails.plugin.cache.Cacheable import groovy.transform.CompileStatic +import nl.thehyve.datashowcase.representation.ConceptRepresentation import nl.thehyve.datashowcase.representation.TreeNodeRepresentation +import org.grails.core.util.StopWatch import org.modelmapper.ModelMapper import org.springframework.beans.factory.annotation.Autowired @@ -28,26 +32,64 @@ class TreeService { * @param node the node (tree) to enrich. */ @CompileStatic - private void enrichWithItemCounts(TreeNodeRepresentation node) { - long itemCount = itemService.countItemsForNode(node) + private static void enrichWithItemCounts(TreeNodeRepresentation node, final Map itemCountMap) { + if (node == null) { + return + } + long itemCount = itemCountMap[node.path] ?: 0 node.itemCount = itemCount node.accumulativeItemCount = itemCount + (long)(node.children?.sum { TreeNodeRepresentation child -> - enrichWithItemCounts(child) + enrichWithItemCounts(child, itemCountMap) child.accumulativeItemCount } ?: 0) } + @CompileStatic + static TreeNodeRepresentation map(TreeNode node) { + def result = new TreeNodeRepresentation( + nodeType: node.nodeType, + label: node.label, + path: node.path, + concept: node.concept?.conceptCode, + variableType: node.concept?.variableType + ) + if (node.children) { + result.children = node.children.collect { + map(it) + } + } + result + } + /** * Fetches all top nodes of the tree. * @return the list of top nodes with child nodes embedded. */ + @Cacheable('tree_nodes') List getNodes() { - TreeNode.findAllByParentIsNull().collect { domain -> - def node = modelMapper.map(domain, TreeNodeRepresentation.class) - enrichWithItemCounts(node) - node + def stopWatch = new StopWatch('Fetch tree nodes') + stopWatch.start('Retrieve from database') + def nodes = TreeNode.findAllByParentIsNull() + stopWatch.stop() + stopWatch.start('Map to representations') + def result = nodes.collect { domain -> + map(domain) + } + stopWatch.stop() + stopWatch.start('Enrich with counts') + def itemCountMap = itemService.itemCountPerNode + result.each { node -> + enrichWithItemCounts(node, itemCountMap) } + stopWatch.stop() + log.info "Fetched tree nodes.\n${stopWatch.prettyPrint()}" + result + } + + @CacheEvict(value = 'tree_nodes', allEntries = true) + void clearTreeNodesCache() { + log.info "Clear tree nodes cache." } } diff --git a/data-showcase/src/main/groovy/nl/thehyve/datashowcase/enumeration/Operator.groovy b/data-showcase/src/main/groovy/nl/thehyve/datashowcase/enumeration/Operator.groovy new file mode 100644 index 0000000..4c338b0 --- /dev/null +++ b/data-showcase/src/main/groovy/nl/thehyve/datashowcase/enumeration/Operator.groovy @@ -0,0 +1,49 @@ +package nl.thehyve.datashowcase.enumeration + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +/** + * Operator types supported by the criteria builder. + */ +@CompileStatic +@Slf4j +enum Operator { + + AND('and'), + OR('or'), + CONTAINS('contains'), + EQUALS('='), + NOT_EQUALS("!="), + LIKE('like'), + IN('in'), + NOT('not'), + NONE('none') + + String symbol + + Operator(String symbol) { + this.symbol = symbol + } + + private static final Map mapping = new HashMap<>() + + static { + for (Operator op : Operator.values()) { + mapping.put(op.symbol, op) + } + } + + static Operator forSymbol(String symbol) { + if (symbol == null) { + return null + } + symbol = symbol.toLowerCase() + if (mapping.containsKey(symbol)) { + return mapping[symbol] + } else { + log.debug "Unknown operator: ${symbol}" + return NONE + } + } +} diff --git a/data-showcase/src/main/groovy/nl/thehyve/datashowcase/enumeration/SearchField.groovy b/data-showcase/src/main/groovy/nl/thehyve/datashowcase/enumeration/SearchField.groovy new file mode 100644 index 0000000..97dd7f0 --- /dev/null +++ b/data-showcase/src/main/groovy/nl/thehyve/datashowcase/enumeration/SearchField.groovy @@ -0,0 +1,68 @@ +package nl.thehyve.datashowcase.enumeration + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +/** + * The fields on which it is possible to search (a free text filter). + */ +@CompileStatic +@Slf4j +enum SearchField { + /** + * Name of the item. + */ + NAME('name'), + /** + * Key words associated with concept. + */ + KEYWORDS('keywords'), + /** + * Key word associated with concept. + */ + KEYWORD('keyword'), + /** + * The short name of the variable in English associated with concept. + */ + LABEL('label'), + /** + * A textual description of the variable in English associated with concept. + */ + LABEL_LONG('labelLong'), + /** + * The short name of the variable in Dutch associated with concept. + */ + LABEL_NL('labelNl'), + /** + * A textual description of the variable in Dutch associated with concept. + */ + LABEL_NL_LONG('labelNlLong'), + /** + * Unknown field. + */ + NONE('none') + + String value + + SearchField(String value) { + this.value = value + } + + private static final Map mapping = new HashMap<>() + + static { + for (SearchField type : values()) { + mapping.put(type.value, type) + } + } + + static SearchField forName(String name) { + if (mapping.containsKey(name)) { + return mapping[name] + } else { + log.debug "Unknown search field: ${name}" + return NONE + } + } + +} diff --git a/data-showcase/src/main/groovy/nl/thehyve/datashowcase/enumeration/VariableType.groovy b/data-showcase/src/main/groovy/nl/thehyve/datashowcase/enumeration/VariableType.groovy index 1038975..e7edd32 100644 --- a/data-showcase/src/main/groovy/nl/thehyve/datashowcase/enumeration/VariableType.groovy +++ b/data-showcase/src/main/groovy/nl/thehyve/datashowcase/enumeration/VariableType.groovy @@ -13,5 +13,6 @@ enum VariableType { Numerical, Categorical, Text, + Date, None } diff --git a/data-showcase/src/main/groovy/nl/thehyve/datashowcase/mapping/ItemMapper.groovy b/data-showcase/src/main/groovy/nl/thehyve/datashowcase/mapping/ItemMapper.groovy deleted file mode 100644 index c37f0bc..0000000 --- a/data-showcase/src/main/groovy/nl/thehyve/datashowcase/mapping/ItemMapper.groovy +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright (c) 2017 The Hyve B.V. - * This file is distributed under the GNU Affero General Public License - * (see accompanying file LICENSE). - */ - -package nl.thehyve.datashowcase.mapping - -import groovy.transform.CompileStatic -import nl.thehyve.datashowcase.Environment -import nl.thehyve.datashowcase.Item -import nl.thehyve.datashowcase.representation.InternalItemRepresentation -import nl.thehyve.datashowcase.representation.ItemRepresentation -import nl.thehyve.datashowcase.representation.PublicItemRepresentation -import org.modelmapper.ModelMapper -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.stereotype.Component - -@Component -@CompileStatic -class ItemMapper { - - @Autowired - ModelMapper modelMapper - - @Autowired - Environment dataShowcaseEnvironment - - ItemRepresentation map(Item item) { - if (dataShowcaseEnvironment.internalInstance) { - modelMapper.map(item, InternalItemRepresentation.class) - } else { - modelMapper.map(item, PublicItemRepresentation.class) - } - } - -} diff --git a/data-showcase/src/main/groovy/nl/thehyve/datashowcase/representation/ConceptRepresentation.groovy b/data-showcase/src/main/groovy/nl/thehyve/datashowcase/representation/ConceptRepresentation.groovy index db92ce1..7bc07c1 100644 --- a/data-showcase/src/main/groovy/nl/thehyve/datashowcase/representation/ConceptRepresentation.groovy +++ b/data-showcase/src/main/groovy/nl/thehyve/datashowcase/representation/ConceptRepresentation.groovy @@ -12,6 +12,11 @@ import nl.thehyve.datashowcase.enumeration.VariableType @CompileStatic class ConceptRepresentation { + /** + * The unique code of the concept in Transmart. + */ + String conceptCode + /** * The short name of the variable. */ diff --git a/data-showcase/src/main/groovy/nl/thehyve/datashowcase/representation/InternalItemRepresentation.groovy b/data-showcase/src/main/groovy/nl/thehyve/datashowcase/representation/InternalItemRepresentation.groovy index 917a405..7ed2d7d 100644 --- a/data-showcase/src/main/groovy/nl/thehyve/datashowcase/representation/InternalItemRepresentation.groovy +++ b/data-showcase/src/main/groovy/nl/thehyve/datashowcase/representation/InternalItemRepresentation.groovy @@ -11,6 +11,9 @@ import groovy.transform.CompileStatic @CompileStatic class InternalItemRepresentation extends ItemRepresentation { + /** + * Summary data for the variable: aggregate values and value frequencies. + */ InternalSummaryRepresentation summary } diff --git a/data-showcase/src/main/groovy/nl/thehyve/datashowcase/representation/ItemRepresentation.groovy b/data-showcase/src/main/groovy/nl/thehyve/datashowcase/representation/ItemRepresentation.groovy index 61219df..ad2c6ed 100644 --- a/data-showcase/src/main/groovy/nl/thehyve/datashowcase/representation/ItemRepresentation.groovy +++ b/data-showcase/src/main/groovy/nl/thehyve/datashowcase/representation/ItemRepresentation.groovy @@ -10,7 +10,7 @@ import groovy.transform.CompileStatic import nl.thehyve.datashowcase.enumeration.VariableType @CompileStatic -abstract class ItemRepresentation { +class ItemRepresentation { /** * An id of the variable @@ -42,11 +42,6 @@ abstract class ItemRepresentation { */ String labelNlLong - /** - * Associated key words. - */ - List keywords - /** * The full path of the item that can be used in tranSMART */ @@ -63,11 +58,6 @@ abstract class ItemRepresentation { */ boolean publicItem - /** - * Summary data for the variable: aggregate values and value frequencies. - */ - abstract SummaryRepresentation getSummary() - /** * The concept code of the associated concept. */ diff --git a/data-showcase/src/main/groovy/nl/thehyve/datashowcase/representation/PublicItemRepresentation.groovy b/data-showcase/src/main/groovy/nl/thehyve/datashowcase/representation/PublicItemRepresentation.groovy index 25bdf64..ff5a375 100644 --- a/data-showcase/src/main/groovy/nl/thehyve/datashowcase/representation/PublicItemRepresentation.groovy +++ b/data-showcase/src/main/groovy/nl/thehyve/datashowcase/representation/PublicItemRepresentation.groovy @@ -11,6 +11,9 @@ import groovy.transform.CompileStatic @CompileStatic class PublicItemRepresentation extends ItemRepresentation { + /** + * Summary data for the variable: aggregate values and value frequencies. + */ PublicSummaryRepresentation summary } diff --git a/data-showcase/src/main/groovy/nl/thehyve/datashowcase/representation/SearchQueryRepresentation.groovy b/data-showcase/src/main/groovy/nl/thehyve/datashowcase/representation/SearchQueryRepresentation.groovy new file mode 100644 index 0000000..c62d437 --- /dev/null +++ b/data-showcase/src/main/groovy/nl/thehyve/datashowcase/representation/SearchQueryRepresentation.groovy @@ -0,0 +1,10 @@ +package nl.thehyve.datashowcase.representation + +import groovy.transform.CompileStatic + +@CompileStatic +class SearchQueryRepresentation { + String type + String value + List values +} diff --git a/data-showcase/src/main/groovy/nl/thehyve/datashowcase/representation/SummaryRepresentation.groovy b/data-showcase/src/main/groovy/nl/thehyve/datashowcase/representation/SummaryRepresentation.groovy index fe5eb73..faa56d0 100644 --- a/data-showcase/src/main/groovy/nl/thehyve/datashowcase/representation/SummaryRepresentation.groovy +++ b/data-showcase/src/main/groovy/nl/thehyve/datashowcase/representation/SummaryRepresentation.groovy @@ -17,7 +17,7 @@ abstract class SummaryRepresentation { Long observationCount /** - * The number of patients for whom there are observations associated with the variable. + * The number of patients (subjects) for whom there are observations associated with the variable. */ Long patientCount diff --git a/data-showcase/src/main/groovy/nl/thehyve/datashowcase/representation/TreeNodeRepresentation.groovy b/data-showcase/src/main/groovy/nl/thehyve/datashowcase/representation/TreeNodeRepresentation.groovy index a1b895a..ca94deb 100644 --- a/data-showcase/src/main/groovy/nl/thehyve/datashowcase/representation/TreeNodeRepresentation.groovy +++ b/data-showcase/src/main/groovy/nl/thehyve/datashowcase/representation/TreeNodeRepresentation.groovy @@ -6,8 +6,10 @@ package nl.thehyve.datashowcase.representation +import com.fasterxml.jackson.annotation.JsonIgnore import groovy.transform.CompileStatic import nl.thehyve.datashowcase.enumeration.NodeType +import nl.thehyve.datashowcase.enumeration.VariableType @CompileStatic class TreeNodeRepresentation { @@ -22,14 +24,17 @@ class TreeNodeRepresentation { */ String label + VariableType variableType + /** * The concept code of the concept the node refers to, if it is of type Concept. */ - ConceptRepresentation concept + String concept /** * The complete path of the node, including the name. */ + @JsonIgnore String path /** diff --git a/data-showcase/src/main/groovy/nl/thehyve/datashowcase/search/SearchCriteriaBuilder.groovy b/data-showcase/src/main/groovy/nl/thehyve/datashowcase/search/SearchCriteriaBuilder.groovy new file mode 100644 index 0000000..9d59be5 --- /dev/null +++ b/data-showcase/src/main/groovy/nl/thehyve/datashowcase/search/SearchCriteriaBuilder.groovy @@ -0,0 +1,214 @@ +package nl.thehyve.datashowcase.search + +import groovy.json.JsonOutput +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j +import nl.thehyve.datashowcase.enumeration.Operator +import nl.thehyve.datashowcase.enumeration.SearchField +import nl.thehyve.datashowcase.representation.SearchQueryRepresentation +import org.hibernate.criterion.Criterion +import org.hibernate.criterion.MatchMode +import org.hibernate.criterion.Restrictions + +/** + * The class for parsing search criteria from free text filter, serialized as JSON + * into Hibernate Criteria + */ +@CompileStatic +@Slf4j +class SearchCriteriaBuilder { + + private final static String ITEM_ALIAS = "i" + private final static String CONCEPT_ALIAS = "c" + private final static String KEYWORDS_ALIAS = "k" + + private final Operator defaultOperator = Operator.CONTAINS + + private static final EnumSet booleanOperators = EnumSet.of(Operator.NOT, Operator.AND, Operator.OR) + private static final EnumSet valueOperators = EnumSet.of( + Operator.EQUALS, + Operator.NOT_EQUALS, + Operator.CONTAINS, + Operator.LIKE, + Operator.IN + ) + + /** + * Returns true if operator equals AND or OR + * @param operator + * @return + */ + private static boolean isBooleanOperator(Operator operator) { + booleanOperators.contains(operator) + } + + /** + * Returns true if the operator is a value operator + * @param operator + * @return + */ + private static boolean isValueOperator(Operator operator) { + valueOperators.contains(operator) + } + + private static Criterion applyOperator(Operator operator, String propertyName, String value) { + switch (operator) { + case Operator.CONTAINS: + return Restrictions.ilike(propertyName, value as String, MatchMode.ANYWHERE) + case Operator.EQUALS: + return Restrictions.eq(propertyName, value as String).ignoreCase() + case Operator.NOT_EQUALS: + return Restrictions.not(Restrictions.eq(propertyName, value as String).ignoreCase()) + case Operator.LIKE: + return Restrictions.ilike(propertyName, value as String) + default: + throw new IllegalArgumentException("Unsupported operator: ${operator}.") + } + } + + /** + * Create single Restriction criterion for a specified operator + * @param operator + * @param propertyName + * @param value + * @return + */ + private static Criterion buildSingleCriteria(Operator operator, SearchField field, List values) { + String propertyName = searchFieldToPropertyName(field) + switch (operator) { + case Operator.CONTAINS: + case Operator.EQUALS: + case Operator.LIKE: + def criteria = values.collect { applyOperator(operator, propertyName, it) } + if (criteria.size() == 1) { + return criteria[0] + } else { + return Restrictions.or(criteria.toArray() as Criterion[]) + } + case Operator.NOT_EQUALS: + def criteria = values.collect { applyOperator(operator, propertyName, it) } + def arg + if (criteria.size() == 1) { + arg = criteria[0] + } else { + arg = Restrictions.or(criteria.toArray() as Criterion[]) + } + return Restrictions.not(arg) + case Operator.IN: + return Restrictions.in(propertyName, values) + default: + throw new IllegalArgumentException("Unsupported operator: ${operator}.") + } + } + + static Criterion expressionToCriteria(Operator operator, List criteria) { + log.info "Applying ${operator} to ${criteria.size()} arguments." + switch (operator) { + case Operator.NOT: + if (criteria.size() != 1) { + throw new IllegalArgumentException("Not can only be applied to a single argument.") + } + log.info "Applying NOT to ${criteria.size()} argument." + return Restrictions.not(criteria[0]) + case Operator.AND: + log.info "Applying AND to ${criteria.size()} arguments." + return Restrictions.and(criteria.toArray() as Criterion[]) + case Operator.OR: + log.info "Applying OR to ${criteria.size()} arguments." + return Restrictions.or(criteria.toArray() as Criterion[]) + default: + throw new IllegalArgumentException("Unsupported operator: ${operator}.") + } + } + + /** + * If searchField is not specified, search query is applied to all supported properties + * @param operator + * @param value + * @return + */ + private static Criterion applyToAllSearchFields(Operator operator, List values) { + List criteria = [] + SearchField.values().each { SearchField field -> + if (field != SearchField.NONE) { + def singleCriteria = buildSingleCriteria(operator, field, values) + criteria.add(singleCriteria) + } + } + return expressionToCriteria(Operator.OR, criteria) + } + + /** + * Parse specified field to supported property name + * @param field + * @return + */ + private static String searchFieldToPropertyName(SearchField field) { + switch (field) { + case SearchField.NAME: + return ITEM_ALIAS + "." + SearchField.NAME.value + case SearchField.KEYWORD: + case SearchField.KEYWORDS: + return KEYWORDS_ALIAS + "." + "keyword" + case SearchField.LABEL: + return CONCEPT_ALIAS + "." + SearchField.LABEL.value + case SearchField.LABEL_LONG: + return CONCEPT_ALIAS + "." + SearchField.LABEL_LONG.value + case SearchField.LABEL_NL: + return CONCEPT_ALIAS + "." + SearchField.LABEL_NL.value + case SearchField.LABEL_NL_LONG: + return CONCEPT_ALIAS + "." + SearchField.LABEL_NL_LONG.value + default: + return SearchField.NONE.value + } + } + + /** + * Construct criteria from JSON query + * @param query + * @return + */ + Criterion buildCriteria(SearchQueryRepresentation query) { + log.info "Build criteria: ${query}" + if (query == null) { + return null + } + def type = query.type + if (type == null) { + log.error "Unexpected null type in object: ${JsonOutput.toJson(query)}" + return null + } + else if (type == 'string') { + def values = [query.value as String] + log.info "Applying default operator ${defaultOperator} on args: ${values}" + return applyToAllSearchFields(defaultOperator, values) + } else { + def operator = Operator.forSymbol(type) + if (isBooleanOperator(operator)) { + return expressionToCriteria(operator, query.values.collect { buildCriteria(it) }) + } else if (isValueOperator(operator)) { + def propertyName = query.value + def property = SearchField.NONE + def args = query.values.collect { it.value } + if (propertyName && propertyName != '*') { + property = SearchField.forName(propertyName) + if (property == SearchField.NONE) { + throw new IllegalArgumentException("Unsupported property: ${propertyName}.") + } + } + if (property != SearchField.NONE) { + log.info "Applying ${operator} on field ${property} with args: ${args}" + // applying an operator to a field with a list of values + return buildSingleCriteria(operator, property, args) + } else { + log.info "Applying ${operator} on args: ${args}" + // applying an operator to all fields with a list of values + return applyToAllSearchFields(operator, args) + } + } else { + throw new IllegalArgumentException("Unsupported type: ${type}.") + } + } + } + +} diff --git a/data-showcase/src/main/user-interface/e2e/features/search.feature b/data-showcase/src/main/user-interface/e2e/features/search.feature index dc8b3c6..8975a94 100644 --- a/data-showcase/src/main/user-interface/e2e/features/search.feature +++ b/data-showcase/src/main/user-interface/e2e/features/search.feature @@ -13,20 +13,20 @@ Feature: Data Showcase provides search functionality. (NTRREQ-41) [["heightB", "Height at time of survey", "Project B", "Research line 2", "height", "ui-btn"]] """ - Scenario: Search and filter based on keyword - Given I select keywords 'Family related' - Then the data table contains - """ - [["heightB", "Height at time of survey", "Project B", "Research line 2", "height", "ui-btn"]] - """ - - Scenario: Search and filter based on multiple keywords - Given I select keywords 'Family related, Administration' - Then the data table contains - """ - [["ageA", "Age at time of survey", "Project A", "Research line 1", "age", "ui-btn"], - ["heightB", "Height at time of survey", "Project B", "Research line 2", "height", "ui-btn"]] - """ +# Scenario: Search and filter based on keyword +# Given I select keywords 'Family related' +# Then the data table contains +# """ +# [["heightB", "Height at time of survey", "Project B", "Research line 2", "height", "ui-btn"]] +# """ + +# Scenario: Search and filter based on multiple keywords +# Given I select keywords 'Family related, Administration' +# Then the data table contains +# """ +# [["ageA", "Age at time of survey", "Project A", "Research line 1", "age", "ui-btn"], +# ["heightB", "Height at time of survey", "Project B", "Research line 2", "height", "ui-btn"]] +# """ Scenario: Search and filter based on Research line Given I select Research lines 'Research line 2' diff --git a/data-showcase/src/main/user-interface/e2e/features/treeview.feature b/data-showcase/src/main/user-interface/e2e/features/treeview.feature index b4acbbd..e631c8e 100644 --- a/data-showcase/src/main/user-interface/e2e/features/treeview.feature +++ b/data-showcase/src/main/user-interface/e2e/features/treeview.feature @@ -22,4 +22,4 @@ Feature: As a researcher I want to view the data showcase catalogue from a tree. Given I open all tree nodes When I select all data in the data table When I select 'Age' - Then I see the counters Items selected '2' and total '1' + Then I see the counters Items selected '0', total '1' and number of pages '1' diff --git a/data-showcase/src/main/user-interface/e2e/stepdefinitions/cart.ts b/data-showcase/src/main/user-interface/e2e/stepdefinitions/cart.ts index a842191..a211b7d 100644 --- a/data-showcase/src/main/user-interface/e2e/stepdefinitions/cart.ts +++ b/data-showcase/src/main/user-interface/e2e/stepdefinitions/cart.ts @@ -26,13 +26,15 @@ defineSupportCode(({ Given, When, Then }) => { Then('the cart contains', function (string) { let tableRows: [string] = JSON.parse(string); - return countIs($('.ds-shopping-cart').$$(".ui-datatable-data > tr"), tableRows.length).then(() => { - return $('.ds-shopping-cart').$$(".ui-datatable-data > tr").map((row, rowIndex) => { // get all data rows - return row.$$('.ui-cell-data').map((cell, cellIndex) => { - return checkTextElement(cell, tableRows[rowIndex][cellIndex]); + return browser.driver.wait(function () { + return countIs($('.ds-shopping-cart').$$(".ui-datatable-data > tr"), tableRows.length).then(() => { + return $('.ds-shopping-cart').$$(".ui-datatable-data > tr").map((row, rowIndex) => { // get all data rows + return row.$$('.ui-cell-data').map((cell, cellIndex) => { + return checkTextElement(cell, tableRows[rowIndex][cellIndex]); + }) }) }) - }) + }, 1000000) }); Then('the cart is empty', function () { diff --git a/data-showcase/src/main/user-interface/e2e/stepdefinitions/search.ts b/data-showcase/src/main/user-interface/e2e/stepdefinitions/search.ts index 91a9da5..351eabd 100644 --- a/data-showcase/src/main/user-interface/e2e/stepdefinitions/search.ts +++ b/data-showcase/src/main/user-interface/e2e/stepdefinitions/search.ts @@ -12,7 +12,9 @@ let { defineSupportCode } = require('cucumber'); defineSupportCode(({ Given, When, Then }) => { Given(/^In the main search bar I type '(.*)'$/, function (searchText): Promise { - return $('.text-filter-container >p-autocomplete > span > input').sendKeys(searchText); + return $('.text-filter-container >p-autocomplete > span > input').sendKeys(searchText).then(()=> + $('.text-filter-container >button.export-button').click() + ); }); Given(/^I select keywords '(.*)'$/, function (keywordSting) { diff --git a/data-showcase/src/main/user-interface/e2e/stepdefinitions/treeview.ts b/data-showcase/src/main/user-interface/e2e/stepdefinitions/treeview.ts index 51aadab..6f4c5e6 100644 --- a/data-showcase/src/main/user-interface/e2e/stepdefinitions/treeview.ts +++ b/data-showcase/src/main/user-interface/e2e/stepdefinitions/treeview.ts @@ -5,7 +5,7 @@ */ import { Promise } from 'es6-promise'; -import { $, $$, by } from 'protractor'; +import { $, $$, by, browser, promise } from 'protractor'; import { checkTextElement, countIs, promiseTrue } from './support/util'; let { defineSupportCode } = require('cucumber'); @@ -20,7 +20,9 @@ defineSupportCode(({ Given, When, Then }) => { }); Given('I open all tree nodes', function (): Promise { - return toggleNode() + return browser.driver.wait(function () { + return openNodes() + }, 5000000) }); Then(/^I see the following nodes in the tree: '(.*)'$/, function (nodeText: string): Promise { @@ -46,21 +48,23 @@ defineSupportCode(({ Given, When, Then }) => { Then('the data table contains', function (string): Promise { let tableRows: [string] = JSON.parse(string); - return countIs($$(".ui-datatable-data > tr"), tableRows.length).then(() => { - return $$(".ui-datatable-data > tr").map((row, rowIndex) => { // get all data rows - return row.$$('.ui-cell-data').map((cell, cellIndex) => { - return checkTextElement(cell, tableRows[rowIndex][cellIndex]) + return browser.driver.wait(function () { + return countIs($$(".ui-datatable-data > tr"), tableRows.length).then(() => { + return $$(".ui-datatable-data > tr").map((row, rowIndex) => { // get all data rows + return row.$$('.ui-cell-data').map((cell, cellIndex) => { + return checkTextElement(cell, tableRows[rowIndex][cellIndex]) + }) + }) }) - }) - }) + }, 30000) }); When('I select all data in the data table', function (): Promise { return $('.ui-datatable-thead').$$('.ui-chkbox').click(); }); - Then('I see the counters Items selected \'{int}\' and total \'{int}\'', function (int, int2) { - let counts = [int, int2]; + Then('I see the counters Items selected \'{int}\', total \'{int}\' and number of pages \'{int}\'', function (int, int2, int3) { + let counts = [int, int2, int3]; return $$('.item-count-container > b').map((counter, index) => { return checkTextElement(counter, counts[index]); }) @@ -68,8 +72,22 @@ defineSupportCode(({ Given, When, Then }) => { }); -export function toggleNode() { - return $$(".fa-caret-right").click().then(() => { - return $$(".fa-caret-right").click() - }) +export function openNodes(): Promise { + return new Promise(function(resolve, reject) { + let elements = $$('.fa-caret-right').filter((element) => element.isDisplayed()); + elements.count().then((n) => { + if (n > 0) { + return elements.reduce((res, el) => { + el.click(); + return ++res; + }, 0).then((res) => { + openNodes() + .then(() => resolve(true)) + .catch((err) => reject(err)) + }) + } else { + resolve(false); + } + }); + }); } diff --git a/data-showcase/src/main/user-interface/package.json b/data-showcase/src/main/user-interface/package.json index 5571c2d..2996173 100644 --- a/data-showcase/src/main/user-interface/package.json +++ b/data-showcase/src/main/user-interface/package.json @@ -3,10 +3,14 @@ "version": "0.0.1", "license": "MIT", "scripts": { + "grammar": "./node_modules/nearley/bin/nearleyc.js ./src/app/search-text-parser/grammar.ne -o ./src/app/search-text-parser/grammar.ts", + "pretest": "npm run grammar", + "prestart": "npm run grammar", "ng": "ng", "start": "ng serve", - "buildOnce": "ng build --watch=false", - "build": "ng build --watch=true", + "buildProd": "npm run grammar && ng build --prod --watch=false", + "buildDev": "npm run grammar && ng build --dev --watch=false", + "build": "npm run grammar && ng build --watch=true", "test": "ng test --single-run", "lint": "ng lint", "e2e": "ng e2e" @@ -23,24 +27,27 @@ "@angular/platform-browser-dynamic": "^4.0.0", "@angular/router": "^4.0.0", "@types/file-saver": "0.0.1", + "@types/nearley": "^2.11.0", "angular-tree-component": "^3.8.0", "core-js": "^2.4.1", "file-saver": "^1.3.3", "font-awesome": "^4.7.0", - "primeng": "^4.1.3", + "primeng": "^4.3.0", + "moo": "^0.4.3", + "nearley": "^2.11.0", "roboto-fontface": "^0.8.0", - "rxjs": "^5.4.3", + "rxjs": "^5.5.2", "zone.js": "^0.8.4" }, "devDependencies": { - "@angular/cli": "1.2.1", + "@angular/cli": "1.3.2", "@angular/compiler-cli": "^4.0.0", "@angular/language-service": "^4.0.0", "@types/jasmine": "~2.5.53", "@types/jasminewd2": "~2.0.2", - "@types/node": "~6.0.60", + "@types/node": "^6.0.92", "codelyzer": "~3.0.1", - "cucumber": "^3.0.5", + "cucumber": "^3.1.0", "es6-promise": "^4.1.1", "jasmine-core": "~2.6.2", "jasmine-spec-reporter": "~4.1.0", @@ -51,7 +58,7 @@ "karma-jasmine": "~1.1.0", "karma-jasmine-html-reporter": "^0.2.2", "protractor": "~5.1.2", - "protractor-cucumber-framework": "^4.0.9", + "protractor-cucumber-framework": "^4.1.1", "request": "^2.81.0", "request-promise-native": "^1.0.4", "ts-node": "~3.0.4", diff --git a/data-showcase/src/main/user-interface/protractor.conf.js b/data-showcase/src/main/user-interface/protractor.conf.js index 7b3d093..8ef4032 100644 --- a/data-showcase/src/main/user-interface/protractor.conf.js +++ b/data-showcase/src/main/user-interface/protractor.conf.js @@ -13,7 +13,7 @@ exports.config = { 'browserName': 'chrome', chromeOptions: { // Get rid of --ignore-certificate yellow warning - args: ['--no-sandbox', '--test-type=browser'], + args: ['--no-sandbox', '--test-type=browser', '--window-size=1400,1024'], // Set download path and avoid prompting for download even though // this is already the default on Chrome but for completeness prefs: { diff --git a/data-showcase/src/main/user-interface/src/app/app.component.css b/data-showcase/src/main/user-interface/src/app/app.component.css index 81487bc..9109ee4 100644 --- a/data-showcase/src/main/user-interface/src/app/app.component.css +++ b/data-showcase/src/main/user-interface/src/app/app.component.css @@ -66,24 +66,24 @@ color: #00889C; } -.ds-text-filter { - padding-right: 10px; - padding-left: 10px; +.ds-shopping-cart { + padding-right: 20px; + padding-left: 20px; + padding-top: 5px; border-left: 2px solid darkgrey; float: right; } -.ds-shopping-cart { +.ds-info { padding-right: 20px; padding-left: 20px; - padding-top: 5px; - border-left: 2px solid darkgrey; + padding-top: 25px; float: right; } +.ds-info, .ds-logos, -.ds-shopping-cart, -.ds-text-filter{ +.ds-shopping-cart{ height: 100px; display:inline-block; } diff --git a/data-showcase/src/main/user-interface/src/app/app.component.html b/data-showcase/src/main/user-interface/src/app/app.component.html index 50d3375..868238f 100644 --- a/data-showcase/src/main/user-interface/src/app/app.component.html +++ b/data-showcase/src/main/user-interface/src/app/app.component.html @@ -18,12 +18,13 @@
- +
+ - +
diff --git a/data-showcase/src/main/user-interface/src/app/app.component.spec.ts b/data-showcase/src/main/user-interface/src/app/app.component.spec.ts index 4bcd43b..8693c99 100644 --- a/data-showcase/src/main/user-interface/src/app/app.component.spec.ts +++ b/data-showcase/src/main/user-interface/src/app/app.component.spec.ts @@ -8,13 +8,12 @@ import { TestBed, async } from '@angular/core/testing'; import { AppComponent } from './app.component'; import {TreeNodesComponent} from "./tree-nodes/tree-nodes.component"; -import {TextFilterComponent} from "./text-filter/text-filter.component"; import { - AutoCompleteModule, DataTableModule, DialogModule, FieldsetModule, PanelModule, + AutoCompleteModule, CheckboxModule, DataTableModule, DialogModule, FieldsetModule, GrowlModule, PaginatorModule, + PanelModule, TreeModule } from "primeng/primeng"; import {FormsModule} from "@angular/forms"; -import {CheckboxFilterComponent} from "./checkbox-filter/checkbox-filter.component"; import {ListboxModule} from "primeng/components/listbox/listbox"; import {ItemFilter, ItemTableComponent} from "./item-table/item-table.component"; import {DataService} from "./services/data.service"; @@ -28,6 +27,11 @@ import {AppConfigMock} from "./config/app.config.mock"; import {ItemSummaryComponent} from "./item-summary/item-summary.component"; import {LogosComponent} from "./logos/logos.component"; import {PageRibbonComponent} from "./page-ribbon/page-ribbon.component"; +import {FiltersModule} from "./filters/filters.module"; +import {InfoModule} from "./info/info.module"; +import {SearchParserService} from "./services/search-parser.service"; +import {DSMessageService} from "./services/ds-message.service"; +import {MessageService} from "primeng/components/common/messageservice"; describe('AppComponent', () => { beforeEach(async(() => { @@ -35,8 +39,6 @@ describe('AppComponent', () => { declarations: [ PageRibbonComponent, TreeNodesComponent, - TextFilterComponent, - CheckboxFilterComponent, ItemTableComponent, ItemFilter, ShoppingCartComponent, @@ -50,12 +52,17 @@ describe('AppComponent', () => { PanelModule, ListboxModule, TreeModule, + FiltersModule, FieldsetModule, DataTableModule, BrowserModule, BrowserAnimationsModule, DialogModule, - HttpModule + HttpModule, + InfoModule, + PaginatorModule, + CheckboxModule, + GrowlModule ], providers: [ DataService, @@ -63,7 +70,10 @@ describe('AppComponent', () => { provide: AppConfig, useClass: AppConfigMock }, - ResourceService + ResourceService, + SearchParserService, + DSMessageService, + MessageService ] }).compileComponents(); })); diff --git a/data-showcase/src/main/user-interface/src/app/app.component.ts b/data-showcase/src/main/user-interface/src/app/app.component.ts index 5da6cdb..e4f99d1 100644 --- a/data-showcase/src/main/user-interface/src/app/app.component.ts +++ b/data-showcase/src/main/user-interface/src/app/app.component.ts @@ -5,11 +5,15 @@ */ import {Component, OnInit, ViewChild} from '@angular/core'; +import { Message } from 'primeng/primeng'; +import { MessageService } from 'primeng/components/common/messageservice'; +import { DSMessageService } from './services/ds-message.service'; @Component({ selector: 'app-root', templateUrl: './app.component.html', - styleUrls: ['./app.component.css'] + styleUrls: ['./app.component.css'], + providers: [MessageService] }) export class AppComponent implements OnInit { @@ -22,7 +26,9 @@ export class AppComponent implements OnInit { private x_pos: number; // Stores x coordinate of the mouse pointer private x_gap: number; // Stores x gap (edge) between mouse and gutter - constructor() { + msgs: Message[] = []; + + constructor(private messageService: MessageService, private dsMessageService: DSMessageService) { } ngOnInit() { @@ -64,5 +70,9 @@ export class AppComponent implements OnInit { gutterElm.addEventListener('mousedown', onMouseDown.bind(this)); parentContainerElm.addEventListener('mousemove', onMouseMove.bind(this)); parentContainerElm.addEventListener('mouseup', onMouseUp.bind(this)); + + this.dsMessageService.messages.subscribe(message => { + this.messageService.add(message); + }); } } diff --git a/data-showcase/src/main/user-interface/src/app/app.module.ts b/data-showcase/src/main/user-interface/src/app/app.module.ts index 891e822..cfab087 100644 --- a/data-showcase/src/main/user-interface/src/app/app.module.ts +++ b/data-showcase/src/main/user-interface/src/app/app.module.ts @@ -14,14 +14,18 @@ import {DataService} from "./services/data.service"; import {ResourceService} from "./services/resource.service"; import {BrowserAnimationsModule} from "@angular/platform-browser/animations"; import {ItemTableModule} from "./item-table/item-table.module"; -import {CheckboxFilterModule} from "./checkbox-filter/checkbox-filter.module"; -import {TextFilterModule} from "./text-filter/text-filter.module"; import {HttpModule} from "@angular/http"; import {AppConfig} from "./config/app.config"; import {ShoppingCartModule} from "./shopping-cart/shopping-cart.module"; import {ItemSummaryModule} from "./item-summary/item-summary.module"; -import { LogosComponent } from './logos/logos.component'; -import { PageRibbonComponent } from './page-ribbon/page-ribbon.component'; +import {LogosComponent} from './logos/logos.component'; +import {PageRibbonComponent} from './page-ribbon/page-ribbon.component'; +import {FiltersModule} from "./filters/filters.module"; +import {InfoModule} from "./info/info.module"; +import {SearchParserService} from "./services/search-parser.service"; +import {GrowlModule} from "primeng/primeng"; +import {DSMessageService} from "./services/ds-message.service"; +import {MessageService} from "primeng/components/common/messageservice"; export function initConfig(config: AppConfig) { return () => config.load() @@ -38,16 +42,20 @@ export function initConfig(config: AppConfig) { HttpModule, BrowserAnimationsModule, TreeNodesModule, - CheckboxFilterModule, + FiltersModule, FormsModule, - TextFilterModule, ItemTableModule, ShoppingCartModule, - ItemSummaryModule + ItemSummaryModule, + InfoModule, + GrowlModule ], providers: [ ResourceService, DataService, + SearchParserService, + DSMessageService, + MessageService, AppConfig, { provide: APP_INITIALIZER, @@ -58,4 +66,5 @@ export function initConfig(config: AppConfig) { ], bootstrap: [AppComponent] }) -export class AppModule { } +export class AppModule { +} diff --git a/data-showcase/src/main/user-interface/src/app/checkbox-filter/checkbox-filter.component.html b/data-showcase/src/main/user-interface/src/app/checkbox-filter/checkbox-filter.component.html deleted file mode 100644 index 1e6a305..0000000 --- a/data-showcase/src/main/user-interface/src/app/checkbox-filter/checkbox-filter.component.html +++ /dev/null @@ -1,32 +0,0 @@ - - -
- - -
-
- - - -
-
- - - -
-
- - - -
-
-
- -
diff --git a/data-showcase/src/main/user-interface/src/app/checkbox-filter/checkbox-filter.component.ts b/data-showcase/src/main/user-interface/src/app/checkbox-filter/checkbox-filter.component.ts deleted file mode 100644 index 7a33399..0000000 --- a/data-showcase/src/main/user-interface/src/app/checkbox-filter/checkbox-filter.component.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright (c) 2017 The Hyve B.V. - * This file is distributed under the GNU Affero General Public License - * (see accompanying file LICENSE). - */ - -import {Component, OnInit} from '@angular/core'; -import {DataService} from "../services/data.service"; -import {Item} from "../models/item"; - -@Component({ - selector: 'app-checkbox-filter', - templateUrl: './checkbox-filter.component.html', - styleUrls: ['./checkbox-filter.component.css'] -}) -export class CheckboxFilterComponent implements OnInit { - - items: Item[]; - keywords: string[] = []; - projects: string[] = []; - researchLines: string[] = []; - selectedKeywords: string[] = []; - selectedProjects: string[] = []; - selectedResearchLines: string[] = []; - - constructor(public dataService: DataService) { - this.items = this.dataService.getItems(); - this.keywords = this.dataService.getKeywords(); - this.projects = this.dataService.getProjects(); - this.researchLines = this.dataService.getReasearchLines(); - } - - ngOnInit() { - } - - updateFilters() { - this.dataService.updateFilterValues( - this.selectedKeywords, - this.selectedProjects, - this.selectedResearchLines - ); - } - - updateProjects() { - this.dataService.updateProjectsForResearchLines(); - } -} diff --git a/data-showcase/src/main/user-interface/src/app/checkbox-filter/checkbox-filter.module.ts b/data-showcase/src/main/user-interface/src/app/checkbox-filter/checkbox-filter.module.ts deleted file mode 100644 index 2241c19..0000000 --- a/data-showcase/src/main/user-interface/src/app/checkbox-filter/checkbox-filter.module.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright (c) 2017 The Hyve B.V. - * This file is distributed under the GNU Affero General Public License - * (see accompanying file LICENSE). - */ - -import { NgModule } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import {FormsModule} from "@angular/forms"; -import { - PanelModule, FieldsetModule, AutoCompleteModule, CheckboxModule, DataListModule, - ListboxModule -} from "primeng/primeng"; -import {CheckboxFilterComponent} from "./checkbox-filter.component"; - -@NgModule({ - imports: [ - CommonModule, - FormsModule, - PanelModule, - FieldsetModule, - AutoCompleteModule, - CheckboxModule, - DataListModule, - ListboxModule - ], - declarations: [CheckboxFilterComponent], - exports: [CheckboxFilterComponent] -}) -export class CheckboxFilterModule { } diff --git a/data-showcase/src/main/user-interface/src/app/config/app.config.mock.ts b/data-showcase/src/main/user-interface/src/app/config/app.config.mock.ts index bdd76a2..88cd181 100644 --- a/data-showcase/src/main/user-interface/src/app/config/app.config.mock.ts +++ b/data-showcase/src/main/user-interface/src/app/config/app.config.mock.ts @@ -9,7 +9,7 @@ export class AppConfigMock { private env: Object = null; constructor() { this.config = { - 'api-url': '', + 'api-url': 'http://localhost:8080', 'api-version': '', 'app-url': '' }; diff --git a/data-showcase/src/main/user-interface/src/app/constants/endpoints.constants.ts b/data-showcase/src/main/user-interface/src/app/constants/endpoints.constants.ts index c90b3ed..be03cc3 100644 --- a/data-showcase/src/main/user-interface/src/app/constants/endpoints.constants.ts +++ b/data-showcase/src/main/user-interface/src/app/constants/endpoints.constants.ts @@ -7,5 +7,7 @@ export const PATH_TREE_NODES = "/api/tree_nodes"; export const PATH_ITEMS = "/api/items"; export const PATH_PROJECTS = "/api/projects"; +export const PATH_CONCEPTS = "/api/concepts"; +export const PATH_KEYWORDS_BY_CONCEPT = "/api/keywords/by_concept"; export const PATH_LOGOS = "/api/file/logo"; export const PATH_ENVIRONMENT = "/api/environment"; diff --git a/data-showcase/src/main/user-interface/src/app/checkbox-filter/checkbox-filter.component.css b/data-showcase/src/main/user-interface/src/app/filters/checkbox-filter/checkbox-filter.component.css similarity index 64% rename from data-showcase/src/main/user-interface/src/app/checkbox-filter/checkbox-filter.component.css rename to data-showcase/src/main/user-interface/src/app/filters/checkbox-filter/checkbox-filter.component.css index 07694d4..f513fd4 100644 --- a/data-showcase/src/main/user-interface/src/app/checkbox-filter/checkbox-filter.component.css +++ b/data-showcase/src/main/user-interface/src/app/filters/checkbox-filter/checkbox-filter.component.css @@ -4,17 +4,10 @@ * (see accompanying file LICENSE). */ -.checkbox-filter-container { - margin-left: 10px; - margin-right: 10px; - padding: 10px; - min-width: 800px; -} - .ds-data-format-item { background-color: transparent; border: none; /* padding: top right bottom left */ - padding: 2px 6px 2px 25px; + padding: 0 6px 0 25px; } diff --git a/data-showcase/src/main/user-interface/src/app/filters/checkbox-filter/checkbox-filter.component.html b/data-showcase/src/main/user-interface/src/app/filters/checkbox-filter/checkbox-filter.component.html new file mode 100644 index 0000000..8caeda6 --- /dev/null +++ b/data-showcase/src/main/user-interface/src/app/filters/checkbox-filter/checkbox-filter.component.html @@ -0,0 +1,25 @@ + +
+ +
+
+
+ + + +
+
+ + + +
+
diff --git a/data-showcase/src/main/user-interface/src/app/checkbox-filter/checkbox-filter.component.spec.ts b/data-showcase/src/main/user-interface/src/app/filters/checkbox-filter/checkbox-filter.component.spec.ts similarity index 78% rename from data-showcase/src/main/user-interface/src/app/checkbox-filter/checkbox-filter.component.spec.ts rename to data-showcase/src/main/user-interface/src/app/filters/checkbox-filter/checkbox-filter.component.spec.ts index 3551a35..8fda325 100644 --- a/data-showcase/src/main/user-interface/src/app/checkbox-filter/checkbox-filter.component.spec.ts +++ b/data-showcase/src/main/user-interface/src/app/filters/checkbox-filter/checkbox-filter.component.spec.ts @@ -9,12 +9,14 @@ import { CheckboxFilterComponent } from './checkbox-filter.component'; import {AutoCompleteModule, FieldsetModule, ListboxModule, PanelModule} from "primeng/primeng"; import {FormsModule} from "@angular/forms"; import {DataListModule} from "primeng/components/datalist/datalist"; -import {DataService} from "../services/data.service"; -import {ResourceService} from "../services/resource.service"; +import {DataService} from "../../services/data.service"; +import {ResourceService} from "../../services/resource.service"; import {BrowserAnimationsModule} from "@angular/platform-browser/animations"; import {HttpModule} from "@angular/http"; -import {AppConfig} from "../config/app.config"; -import {AppConfigMock} from "../config/app.config.mock"; +import {AppConfig} from "../../config/app.config"; +import {AppConfigMock} from "../../config/app.config.mock"; +import {DSMessageService} from "../../services/ds-message.service"; +import {MessageService} from "primeng/components/common/messageservice"; describe('CheckboxFilterComponent', () => { let component: CheckboxFilterComponent; @@ -36,6 +38,8 @@ describe('CheckboxFilterComponent', () => { providers: [ DataService, ResourceService, + DSMessageService, + MessageService, { provide: AppConfig, useClass: AppConfigMock diff --git a/data-showcase/src/main/user-interface/src/app/filters/checkbox-filter/checkbox-filter.component.ts b/data-showcase/src/main/user-interface/src/app/filters/checkbox-filter/checkbox-filter.component.ts new file mode 100644 index 0000000..e3bef62 --- /dev/null +++ b/data-showcase/src/main/user-interface/src/app/filters/checkbox-filter/checkbox-filter.component.ts @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2017 The Hyve B.V. + * This file is distributed under the GNU Affero General Public License + * (see accompanying file LICENSE). + */ + +import {Component, OnInit} from '@angular/core'; +import {DataService} from "../../services/data.service"; +import { SelectItem } from 'primeng/primeng'; + +@Component({ + selector: 'app-checkbox-filter', + templateUrl: './checkbox-filter.component.html', + styleUrls: ['./checkbox-filter.component.css'] +}) +export class CheckboxFilterComponent implements OnInit { + + rerender: boolean = false; + spinner: boolean = false; + + projects: SelectItem[] = []; + researchLines: SelectItem[] = []; + selectedProjects: string[] = []; + selectedResearchLines: string[] = []; + + constructor(public dataService: DataService) { + this.projects = this.dataService.projects; + this.researchLines = this.dataService.linesOfResearch; + + /* workaround for primeng listbox not giving possibility to clear filters: reload checkboxFilters component */ + this.dataService.rerenderCheckboxFilters$.subscribe( + rerender => { + this.spinner = true; + this.rerender = rerender; + window.setTimeout((function () { + this.rerender = false; + this.spinner = false; + }).bind(this), 10); + } + ); + } + + ngOnInit() { + } + + onResearchLineSelect() { + this.dataService.filterOnResearchLines(this.selectedResearchLines) + } + + onProjectSelect(){ + this.dataService.filterOnProjects(this.selectedProjects) + } + +} diff --git a/data-showcase/src/main/user-interface/src/app/filters/filters.component.css b/data-showcase/src/main/user-interface/src/app/filters/filters.component.css new file mode 100644 index 0000000..85452fc --- /dev/null +++ b/data-showcase/src/main/user-interface/src/app/filters/filters.component.css @@ -0,0 +1,26 @@ + +.filter-container { + margin-left: 10px; + margin-right: 10px; + /* padding: top right bottom left */ + padding: 0 10px 0 10px; + min-width: 800px; +} + +.filter-container-header { + color: white; + margin-top: -0.8em; +} + +.btn-primary-outline { + background-color: transparent; + border: none; + color: white; + height: 40px; + padding: 0; + box-shadow: none; + cursor: pointer; +} +.btn-primary-outline:active { + color: black; +} diff --git a/data-showcase/src/main/user-interface/src/app/filters/filters.component.html b/data-showcase/src/main/user-interface/src/app/filters/filters.component.html new file mode 100644 index 0000000..c08ac2a --- /dev/null +++ b/data-showcase/src/main/user-interface/src/app/filters/filters.component.html @@ -0,0 +1,16 @@ +
+ + + + + + + + +
diff --git a/data-showcase/src/main/user-interface/src/app/filters/filters.component.spec.ts b/data-showcase/src/main/user-interface/src/app/filters/filters.component.spec.ts new file mode 100644 index 0000000..50a821c --- /dev/null +++ b/data-showcase/src/main/user-interface/src/app/filters/filters.component.spec.ts @@ -0,0 +1,59 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { FiltersComponent } from './filters.component'; +import {HttpModule} from "@angular/http"; +import {BrowserAnimationsModule} from "@angular/platform-browser/animations"; +import {AutoCompleteModule, DataListModule, FieldsetModule, ListboxModule, PanelModule} from "primeng/primeng"; +import {FormsModule} from "@angular/forms"; +import {TextFilterComponent} from "./text-filter/text-filter.component"; +import {CheckboxFilterComponent} from "./checkbox-filter/checkbox-filter.component"; +import {DataService} from "../services/data.service"; +import {ResourceService} from "../services/resource.service"; +import {AppConfig} from "../config/app.config"; +import {AppConfigMock} from "../config/app.config.mock"; +import {SearchParserService} from "../services/search-parser.service"; +import {DSMessageService} from "../services/ds-message.service"; +import {MessageService} from "primeng/components/common/messageservice"; + +describe('FiltersComponent', () => { + let component: FiltersComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ FiltersComponent, TextFilterComponent, CheckboxFilterComponent ], + imports: [ + FormsModule, + PanelModule, + FieldsetModule, + AutoCompleteModule, + DataListModule, + ListboxModule, + BrowserAnimationsModule, + HttpModule + ], + providers: [ + DataService, + ResourceService, + DSMessageService, + MessageService, + { + provide: AppConfig, + useClass: AppConfigMock + }, + SearchParserService + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(FiltersComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/data-showcase/src/main/user-interface/src/app/filters/filters.component.ts b/data-showcase/src/main/user-interface/src/app/filters/filters.component.ts new file mode 100644 index 0000000..db4b0a0 --- /dev/null +++ b/data-showcase/src/main/user-interface/src/app/filters/filters.component.ts @@ -0,0 +1,20 @@ +import { Component, OnInit } from '@angular/core'; +import {DataService} from "../services/data.service"; + +@Component({ + selector: 'app-filters', + templateUrl: './filters.component.html', + styleUrls: ['./filters.component.css'] +}) +export class FiltersComponent implements OnInit { + + constructor(public dataService: DataService) { + } + + ngOnInit() { + } + + clearFilters() { + this.dataService.clearAllFilters(); + } +} diff --git a/data-showcase/src/main/user-interface/src/app/filters/filters.module.ts b/data-showcase/src/main/user-interface/src/app/filters/filters.module.ts new file mode 100644 index 0000000..a9004a7 --- /dev/null +++ b/data-showcase/src/main/user-interface/src/app/filters/filters.module.ts @@ -0,0 +1,25 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import {FormsModule} from "@angular/forms"; +import {AutoCompleteModule, ButtonModule, FieldsetModule, ListboxModule, PanelModule, TooltipModule} from "primeng/primeng"; +import {FiltersComponent} from "./filters.component"; +import {CheckboxFilterComponent} from "./checkbox-filter/checkbox-filter.component"; +import {TextFilterComponent} from "./text-filter/text-filter.component"; +import {SearchParserService} from "../services/search-parser.service"; + +@NgModule({ + imports: [ + AutoCompleteModule, + ButtonModule, + CommonModule, + FieldsetModule, + FormsModule, + ListboxModule, + PanelModule, + TooltipModule + ], + declarations: [FiltersComponent, TextFilterComponent, CheckboxFilterComponent], + exports: [FiltersComponent], + providers: [SearchParserService] +}) +export class FiltersModule { } diff --git a/data-showcase/src/main/user-interface/src/app/filters/text-filter/text-filter.component.css b/data-showcase/src/main/user-interface/src/app/filters/text-filter/text-filter.component.css new file mode 100644 index 0000000..3151011 --- /dev/null +++ b/data-showcase/src/main/user-interface/src/app/filters/text-filter/text-filter.component.css @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2017 The Hyve B.V. + * This file is distributed under the GNU Affero General Public License + * (see accompanying file LICENSE). + */ + +.text-filter-container { + text-align: center; + height: 65px; + line-height: 40px; + white-space: nowrap; + display: block; + padding-bottom: 5px; + border-bottom: 1px solid lightgray; +} +.text-filter-container:focus {outline:none;} + +.search-label{ + line-height: 20px; + color: #00889C; +} + +.search-error-message { + color: red; + text-align: center; +} + +.active-search-filter { + opacity: .8; + background-color: #eee; + padding: 1px 5px; +} diff --git a/data-showcase/src/main/user-interface/src/app/filters/text-filter/text-filter.component.html b/data-showcase/src/main/user-interface/src/app/filters/text-filter/text-filter.component.html new file mode 100644 index 0000000..59d649a --- /dev/null +++ b/data-showcase/src/main/user-interface/src/app/filters/text-filter/text-filter.component.html @@ -0,0 +1,30 @@ + + +
+
+ Search by keywords, item name, item labels: +
+ + + + +
+ + diff --git a/data-showcase/src/main/user-interface/src/app/text-filter/text-filter.component.spec.ts b/data-showcase/src/main/user-interface/src/app/filters/text-filter/text-filter.component.spec.ts similarity index 69% rename from data-showcase/src/main/user-interface/src/app/text-filter/text-filter.component.spec.ts rename to data-showcase/src/main/user-interface/src/app/filters/text-filter/text-filter.component.spec.ts index 56cb701..1aec4e9 100644 --- a/data-showcase/src/main/user-interface/src/app/text-filter/text-filter.component.spec.ts +++ b/data-showcase/src/main/user-interface/src/app/filters/text-filter/text-filter.component.spec.ts @@ -9,11 +9,14 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { TextFilterComponent } from './text-filter.component'; import {AutoCompleteModule} from "primeng/primeng"; import {FormsModule} from "@angular/forms"; -import {ResourceService} from "../services/resource.service"; -import {DataService} from "../services/data.service"; +import {ResourceService} from "../../services/resource.service"; +import {DataService} from "../../services/data.service"; import {HttpModule} from "@angular/http"; -import {AppConfig} from "../config/app.config"; -import {AppConfigMock} from "../config/app.config.mock"; +import {AppConfig} from "../../config/app.config"; +import {AppConfigMock} from "../../config/app.config.mock"; +import {SearchParserService} from "../../services/search-parser.service"; +import {DSMessageService} from "../../services/ds-message.service"; +import {MessageService} from "primeng/components/common/messageservice"; describe('TextFilterComponent', () => { let component: TextFilterComponent; @@ -30,10 +33,13 @@ describe('TextFilterComponent', () => { providers: [ DataService, ResourceService, + DSMessageService, + MessageService, { provide: AppConfig, useClass: AppConfigMock - } + }, + SearchParserService ] }) .compileComponents(); diff --git a/data-showcase/src/main/user-interface/src/app/filters/text-filter/text-filter.component.ts b/data-showcase/src/main/user-interface/src/app/filters/text-filter/text-filter.component.ts new file mode 100644 index 0000000..5171245 --- /dev/null +++ b/data-showcase/src/main/user-interface/src/app/filters/text-filter/text-filter.component.ts @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2017 The Hyve B.V. + * This file is distributed under the GNU Affero General Public License + * (see accompanying file LICENSE). + */ + +import {Component, ElementRef, OnInit} from '@angular/core'; +import {DataService} from "../../services/data.service"; +import {SearchParserService} from "../../services/search-parser.service"; + +@Component({ + selector: 'app-text-filter', + templateUrl: './text-filter.component.html', + styleUrls: ['./text-filter.component.css'] +}) +export class TextFilterComponent implements OnInit { + + // value of the main text filter + textFilter: string; + // search error message + searchErrorMessage: string = ''; + // search query as html + searchQueryHtml: string = ''; + // the delay before triggering updating methods + delay: number; + + constructor(public dataService: DataService, + private element: ElementRef) { + this.dataService.searchErrorMessage$.subscribe( + message => { + this.searchErrorMessage = message; + }); + this.dataService.textFilterInput$.subscribe( + filter => { + this.textFilter = filter; + if(this.textFilter == ''){ + this.onFiltering(null); + } + }); + this.delay = 0; + } + + ngOnInit() { + } + + onKeyUp(event) { + // "enter" key code = 13 + if (event.keyCode == 13) { + this.onFiltering(event) + } + } + + onFiltering(event) { + this.dataService.clearErrorSearchMessage(); + try { + this.searchQueryHtml = ''; + let query = SearchParserService.parse(this.textFilter); + this.searchQueryHtml = SearchParserService.toHtml(query); + this.dataService.setSearchQuery(query); + } catch(e) { + console.error(`${e}`, e); + this.searchErrorMessage = `${e}`; + } + } + + /* + PrimeNG library is attaching a spinner (.ui-autocomplete-loader) which is not automatically + removed after search is finished. + */ + removePrimeNgAutocompleteLoader() { + window.setTimeout((function () { + let loaderIcon = this.element.nativeElement.querySelector('.ui-autocomplete-loader'); + if (loaderIcon) { + loaderIcon.remove(); + } + }).bind(this), this.delay); + } +} diff --git a/data-showcase/src/main/user-interface/src/app/info/info.component.css b/data-showcase/src/main/user-interface/src/app/info/info.component.css new file mode 100644 index 0000000..58c26cf --- /dev/null +++ b/data-showcase/src/main/user-interface/src/app/info/info.component.css @@ -0,0 +1,40 @@ +.info-button { + font-size:20px; + padding: 0; + cursor:pointer; + box-shadow: none; +} + +.info-header { + font-weight:normal; + margin-top: .7em; +} + +.info-title { + color: #00889C; +} + +.info-bar { + height: 200px; +} + +dl.explanation { + display: table; +} +.explanation dt, .explanation dd { + float: left; + display: inline-block; +} + +.explanation dt { + clear: left; + padding-right: 1em; +} + +.short-title dt { + min-width: 5em; +} + +.long-title dt { + min-width: 8em; +} diff --git a/data-showcase/src/main/user-interface/src/app/info/info.component.html b/data-showcase/src/main/user-interface/src/app/info/info.component.html new file mode 100644 index 0000000..5493b5a --- /dev/null +++ b/data-showcase/src/main/user-interface/src/app/info/info.component.html @@ -0,0 +1,95 @@ + + + +

Help information

+

General

+

+ General information about the application. +

+ +

Search

+ + + General structure of the query is: +
field⟩ ⟨operator⟩ ⟨value⟩, +

+ where field is a name of the property to which the condition is applied (see: Supported fields). +
+ If field is not specified, condition will be applied to all the supported fields. + If condition is satisfied for at least one of the fields of an item, this item will be added to returned item list. +

+ Combination of multiple query criterion can be composed using and, or, and not operators + (see: Supported operators). +
+ +
+
name
+
Name of the item.
+
keywords
+
Key words associated with concept.
+
label
+
The short name of the variable in English associated with concept.
+
labelLong
+
A textual description of the variable in English associated with concept.
+
labelNl
+
The short name of the variable in Dutch associated with concept.
+
labelNlLong
+
A textual description of the variable in Dutch associated with concept.
+
*
+
Any of these fields.
+
+
+ +
+
=
+
equals, e.g., name = "value1" +
+
!=
+
not equals, e.g., labelEn != "value1" +
+
like
+
like, search for a specified pattern. There are two wildcards used: +
    +
  • % The percent sign represents zero, one, or multiple characters;
  • +
  • _ The underscore represents a single character; + e.g., labelNl like "va_ue%"
+
+
AND
+
logical conjunction, + e.g., (name = "value1") AND (labelLong != "value2") +
+
OR
+
logical disjunction, + e.g., (name = "value1") OR (name = "value2") +
+
NOT
+
logical negation, + e.g., NOT (name = "value1") +
+
+
All the combinations of junction operators are possible, when using round brackets. +
e.g., ((name = "value1") OR (name = "value2")) AND (NOT labelLong = "value3") +
+
+ +

About

+

+ This application is open source software. + The source code is available + under the terms of the GNU Affero General Public License. +

+
+ + + + + + + diff --git a/data-showcase/src/main/user-interface/src/app/info/info.component.spec.ts b/data-showcase/src/main/user-interface/src/app/info/info.component.spec.ts new file mode 100644 index 0000000..33ef1e5 --- /dev/null +++ b/data-showcase/src/main/user-interface/src/app/info/info.component.spec.ts @@ -0,0 +1,38 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { InfoComponent } from './info.component'; +import {FormsModule} from "@angular/forms"; +import {AccordionModule, PanelModule} from "primeng/primeng"; +import {SidebarModule} from "primeng/components/sidebar/sidebar"; +import {BrowserModule} from "@angular/platform-browser"; +import {BrowserAnimationsModule} from "@angular/platform-browser/animations"; + +describe('InfoComponent', () => { + let component: InfoComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ InfoComponent ], + imports: [ + FormsModule, + PanelModule, + SidebarModule, + BrowserModule, + BrowserAnimationsModule, + AccordionModule + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(InfoComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/data-showcase/src/main/user-interface/src/app/info/info.component.ts b/data-showcase/src/main/user-interface/src/app/info/info.component.ts new file mode 100644 index 0000000..32980a4 --- /dev/null +++ b/data-showcase/src/main/user-interface/src/app/info/info.component.ts @@ -0,0 +1,21 @@ +import { Component, OnInit } from '@angular/core'; + +@Component({ + selector: 'app-info', + templateUrl: './info.component.html', + styleUrls: ['./info.component.css'] +}) +export class InfoComponent implements OnInit { + + display: boolean = false; + + constructor() { } + + ngOnInit() { + } + + showInfoDialog() { + this.display = true; + } + +} diff --git a/data-showcase/src/main/user-interface/src/app/info/info.module.ts b/data-showcase/src/main/user-interface/src/app/info/info.module.ts new file mode 100644 index 0000000..e035591 --- /dev/null +++ b/data-showcase/src/main/user-interface/src/app/info/info.module.ts @@ -0,0 +1,20 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import {InfoComponent} from "./info.component"; +import {FormsModule} from "@angular/forms"; +import {AccordionModule, DialogModule, PanelModule} from "primeng/primeng"; +import {SidebarModule} from "primeng/components/sidebar/sidebar"; + +@NgModule({ + imports: [ + CommonModule, + FormsModule, + SidebarModule, + PanelModule, + DialogModule, + AccordionModule, + ], + declarations: [InfoComponent], + exports: [InfoComponent] +}) +export class InfoModule { } diff --git a/data-showcase/src/main/user-interface/src/app/item-summary/item-summary.component.css b/data-showcase/src/main/user-interface/src/app/item-summary/item-summary.component.css index 7697733..ce35e3c 100644 --- a/data-showcase/src/main/user-interface/src/app/item-summary/item-summary.component.css +++ b/data-showcase/src/main/user-interface/src/app/item-summary/item-summary.component.css @@ -28,3 +28,7 @@ th { font-weight: bold; background-color: #e7f3f1; } + +.export-button { + width: auto; +} diff --git a/data-showcase/src/main/user-interface/src/app/item-summary/item-summary.component.html b/data-showcase/src/main/user-interface/src/app/item-summary/item-summary.component.html index 5111cfc..199c79f 100644 --- a/data-showcase/src/main/user-interface/src/app/item-summary/item-summary.component.html +++ b/data-showcase/src/main/user-interface/src/app/item-summary/item-summary.component.html @@ -4,9 +4,9 @@ ~ (see accompanying file LICENSE). --> - - + +
@@ -39,12 +39,16 @@ + + + +
Name: {{item?.name}}Type of variable: {{item?.type}}
Keywords:{{keywordsForConcept.length > 0 ? keywordsForConcept.join(', ') : '-'}}

Summary statistics: - + @@ -53,41 +57,48 @@
Patient counts:Subject counts: {{item?.summary.patientCount}}

-
- - - - - - - - - - - - - - - - - - -
Average:{{item?.summary.avgValue}}
Min:{{item?.summary.minValue}}
Max:{{item?.summary.maxValue}}
Standard deviation:{{item?.summary.stdDevValue}}
-
- - - - - - - - - - - - -
Value:Frequency:Label:
{{value.value}}{{value.frequency}}{{value.label}}
-
- -
+
+
+ + + + + + + + + + + + + + + + + + +
Average:{{item?.summary.avgValue}}
Min:{{item?.summary.minValue}}
Max:{{item?.summary.maxValue}}
Standard deviation:{{item?.summary.stdDevValue}}
+
+ + + + + + + + + + + + +
Value:Frequency:Label:
{{value.value}}{{value.frequency}}{{value.label}}
+
+
+
+ + + +
diff --git a/data-showcase/src/main/user-interface/src/app/item-summary/item-summary.component.spec.ts b/data-showcase/src/main/user-interface/src/app/item-summary/item-summary.component.spec.ts index b585140..f3c5a84 100644 --- a/data-showcase/src/main/user-interface/src/app/item-summary/item-summary.component.spec.ts +++ b/data-showcase/src/main/user-interface/src/app/item-summary/item-summary.component.spec.ts @@ -15,6 +15,8 @@ import {ResourceService} from "../services/resource.service"; import {AppConfig} from "../config/app.config"; import {AppConfigMock} from "../config/app.config.mock"; import {BrowserAnimationsModule} from "@angular/platform-browser/animations"; +import {DSMessageService} from "../services/ds-message.service"; +import {MessageService} from "primeng/components/common/messageservice"; describe('ItemSummaryComponent', () => { let component: ItemSummaryComponent; @@ -33,6 +35,8 @@ describe('ItemSummaryComponent', () => { providers: [ DataService, ResourceService, + DSMessageService, + MessageService, { provide: AppConfig, useClass: AppConfigMock diff --git a/data-showcase/src/main/user-interface/src/app/item-summary/item-summary.component.ts b/data-showcase/src/main/user-interface/src/app/item-summary/item-summary.component.ts index d735ab0..31c120c 100644 --- a/data-showcase/src/main/user-interface/src/app/item-summary/item-summary.component.ts +++ b/data-showcase/src/main/user-interface/src/app/item-summary/item-summary.component.ts @@ -7,6 +7,8 @@ import { Component, OnInit } from '@angular/core'; import {DataService} from "../services/data.service"; import {Item} from "../models/item"; +import {ResourceService} from "../services/resource.service"; +import {Environment} from "../models/environment"; @Component({ selector: 'app-item-summary', @@ -17,15 +19,48 @@ export class ItemSummaryComponent implements OnInit { display: boolean = false; item: Item = null; + keywordsForConcept: string[] = []; + environment: Environment; - constructor(private dataService: DataService) { + constructor(private dataService: DataService, + private resourceService: ResourceService) { + dataService.environment$.subscribe( + environment => { + this.environment = environment; + }); dataService.itemSummaryVisible$.subscribe( visibleItem => { this.display = true; this.item = visibleItem; + if(visibleItem.concept) { + this.fetchKeywords(visibleItem.concept); + } }); } ngOnInit() { } + + isInternal(): boolean { + return this.environment && this.environment.environment == "Internal"; + } + + fetchKeywords(conceptCode: string) { + this.keywordsForConcept = []; + this.resourceService.getKeywords(conceptCode) + .subscribe( + (keywords: string[]) => { + this.keywordsForConcept = keywords; + }, + err => console.error(err) + ); + } + + addToCart(){ + this.dataService.addToShoppingCart([this.item]); + } + + close() { + this.display = false; + } } diff --git a/data-showcase/src/main/user-interface/src/app/item-table/item-table.component.html b/data-showcase/src/main/user-interface/src/app/item-table/item-table.component.html index 9322697..32a9669 100644 --- a/data-showcase/src/main/user-interface/src/app/item-table/item-table.component.html +++ b/data-showcase/src/main/user-interface/src/app/item-table/item-table.component.html @@ -4,16 +4,22 @@ ~ (see accompanying file LICENSE). --> -
+
- + [totalRecords] = "totalItemsCount()" + [(selection)]="itemsSelectionPerPage" [showHeaderCheckbox]=false + (onRowSelect)="handleRowSelect($event)" (onRowUnselect)="handleRowSelect($event)" + (onSort)="changeSort($event)" + [loading]="dataService.loadingItems == 'loading'" loadingIcon="fa fa-spin fa-refresh fa-fw"> + + + + + @@ -27,7 +33,7 @@ [sortable]="true"> - @@ -35,17 +41,24 @@ [sortable]="true"> - + + + +
- +
- Items selected: {{itemsSelection? itemsSelection.length : 0}}. Total: {{items? items.length : 0}} + Items selected: {{itemsSelection ? itemsSelection.length : 0}}. + Total results in table: {{totalItemsCount()}}. + Number of pages: {{pagesCount()}}.
diff --git a/data-showcase/src/main/user-interface/src/app/item-table/item-table.component.spec.ts b/data-showcase/src/main/user-interface/src/app/item-table/item-table.component.spec.ts index 2141f35..fff1eec 100644 --- a/data-showcase/src/main/user-interface/src/app/item-table/item-table.component.spec.ts +++ b/data-showcase/src/main/user-interface/src/app/item-table/item-table.component.spec.ts @@ -9,7 +9,7 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import {ItemFilter, ItemTableComponent} from './item-table.component'; import {FormsModule} from "@angular/forms"; import { - AutoCompleteModule, DataListModule, DataTableModule, FieldsetModule, ListboxModule, + AutoCompleteModule, CheckboxModule, DataListModule, DataTableModule, FieldsetModule, ListboxModule, PaginatorModule, PanelModule } from "primeng/primeng"; import {BrowserAnimationsModule} from "@angular/platform-browser/animations"; @@ -18,6 +18,8 @@ import {ResourceService} from "../services/resource.service"; import {HttpModule} from "@angular/http"; import {AppConfig} from "../config/app.config"; import {AppConfigMock} from "../config/app.config.mock"; +import {DSMessageService} from "../services/ds-message.service"; +import {MessageService} from "primeng/components/common/messageservice"; describe('ItemTableComponent', () => { let component: ItemTableComponent; @@ -35,11 +37,15 @@ describe('ItemTableComponent', () => { ListboxModule, BrowserAnimationsModule, DataTableModule, - HttpModule + HttpModule, + PaginatorModule, + CheckboxModule ], providers: [ DataService, ResourceService, + DSMessageService, + MessageService, { provide: AppConfig, useClass: AppConfigMock diff --git a/data-showcase/src/main/user-interface/src/app/item-table/item-table.component.ts b/data-showcase/src/main/user-interface/src/app/item-table/item-table.component.ts index 55ffc32..da8775e 100644 --- a/data-showcase/src/main/user-interface/src/app/item-table/item-table.component.ts +++ b/data-showcase/src/main/user-interface/src/app/item-table/item-table.component.ts @@ -7,20 +7,20 @@ import {Component, OnInit, Pipe, PipeTransform} from '@angular/core'; import {DataService} from "../services/data.service"; import {Item} from "../models/item"; +import { + trigger, style, animate, transition +} from '@angular/animations'; @Pipe({ name: 'itemFilter' }) export class ItemFilter implements PipeTransform { transform(value: Item[], filter: string): Item[] { - filter = filter ? filter.toLocaleLowerCase(): null; + filter = filter ? filter.toLocaleLowerCase() : null; return filter && value ? value.filter(item => (item.name.toLocaleLowerCase().indexOf(filter) !== -1) || - (item.label.toLocaleLowerCase().indexOf(filter) !== -1) || - (item.labelLong.toLocaleLowerCase().indexOf(filter) !== -1) || - (item.labelNl && item.labelNl.toLocaleLowerCase().indexOf(filter) !== -1) || - (item.labelNlLong && item.labelNlLong.toLocaleLowerCase().indexOf(filter) !== -1) + (item.labelLong.toLocaleLowerCase().indexOf(filter) !== -1) ) : value; } } @@ -28,17 +28,46 @@ export class ItemFilter implements PipeTransform { @Component({ selector: 'app-item-table', templateUrl: './item-table.component.html', - styleUrls: ['./item-table.component.css'] + styleUrls: ['./item-table.component.css'], + animations: [ + trigger('notifyState', [ + transition('loading => complete', [ + style({ + background: 'rgba(51, 156, 144, 0.5)' + }), + animate('1000ms ease-out', style({ + background: 'rgba(255, 255, 255, 0.0)' + })) + ]) + ]) + ] }) export class ItemTableComponent implements OnInit { filterValue: string; items: Item[]; itemsSelection: Item[]; + itemsSelectionPerPage: Item[]; + rowsPerPage: number; constructor(public dataService: DataService) { - this.items = this.dataService.filteredItems; - this.dataService.globalFilter$.subscribe( + this.dataService.itemsSelection$.subscribe( + selection => { + this.itemsSelection = selection; + if(selection.length === 0){ + this.itemsSelectionPerPage = []; + } + } + ); + this.dataService.filteredItems$.subscribe( + items => { + this.items = items; + if(items.length > 0) { + this.updateCurrentPageItemsSelection(items); + } + } + ); + this.dataService.textFilterInput$.subscribe( filter => { this.filterValue = filter; } @@ -46,13 +75,78 @@ export class ItemTableComponent implements OnInit { } ngOnInit() { + this.rowsPerPage = 8; + this.itemsSelection = []; + this.itemsSelectionPerPage = []; } - addToCart(){ + addToCart() { this.dataService.addToShoppingCart(this.itemsSelection); } - showSummary(item: Item){ + showSummary(item: Item) { this.dataService.displayPopup(item); } + + pagesCount(): number { + return Math.ceil(this.totalItemsCount() / this.rowsPerPage); + } + + totalItemsCount(): number { + return this.dataService.totalItemsCount; + } + + changeSort(event) { + console.log("Sort: " + event.field + ", " + event.order); + this.dataService.itemsOrder = event.order; + this.dataService.itemsPropertyName = event.field; + this.dataService.fetchItems(); + } + + paginate(event) { + console.log("On page: " + (event.page + 1)); + this.dataService.itemsFirstResult = event.page * event.rows; + this.dataService.itemsMaxResults = event.rows; + this.dataService.fetchItems(); + } + + handleHeaderCheckboxToggle(event) { + if (event) { + this.dataService.selectAllItems(true); + this.itemsSelectionPerPage = this.items; + this.dataService.allItemsSelected = true; + console.log("All items selected"); + } else { + this.dataService.selectAllItems(false); + this.itemsSelectionPerPage = []; + this.dataService.allItemsSelected = false; + console.log("All items deselected"); + } + } + + handleRowSelect($event){ + let item = this.itemsSelection ? this.itemsSelection.filter(items => (items.id === $event.data.id)) : []; + if($event.originalEvent.checked && item.length == 0){ + this.itemsSelection.push($event.data); + console.log("Item '" + $event.data.name +"' added to selection."); + if(this.itemsSelection.length == this.totalItemsCount()){ + this.dataService.allItemsSelected = true; + } + } else if(!$event.originalEvent.checked && item.length > 0) { + this.itemsSelection.splice(this.itemsSelection.indexOf(item[0]),1); + console.log("\"Item '" + $event.data.name + "' removed from selection."); + if(this.dataService.allItemsSelected) { + this.dataService.allItemsSelected = false; + } + } + } + + updateCurrentPageItemsSelection(items: Item[]){ + if(this.dataService.allItemsSelected) { + this.itemsSelectionPerPage = items; + } else if (this.itemsSelection) { + this.itemsSelectionPerPage = items.filter(i => this.itemsSelection.some(is => is.id == i.id)); + } + } + } diff --git a/data-showcase/src/main/user-interface/src/app/item-table/item-table.module.ts b/data-showcase/src/main/user-interface/src/app/item-table/item-table.module.ts index 2f0a54d..d1df642 100644 --- a/data-showcase/src/main/user-interface/src/app/item-table/item-table.module.ts +++ b/data-showcase/src/main/user-interface/src/app/item-table/item-table.module.ts @@ -6,7 +6,10 @@ import {NgModule} from '@angular/core'; import {CommonModule} from '@angular/common'; -import {ButtonModule, DataTableModule, ListboxModule, PanelModule} from "primeng/primeng"; +import { + ButtonModule, CheckboxModule, DataTableModule, ListboxModule, PaginatorModule, + PanelModule +} from "primeng/primeng"; import {FormsModule} from "@angular/forms"; import {ItemFilter, ItemTableComponent} from "./item-table.component"; @@ -17,7 +20,9 @@ import {ItemFilter, ItemTableComponent} from "./item-table.component"; PanelModule, DataTableModule, ListboxModule, - ButtonModule + ButtonModule, + PaginatorModule, + CheckboxModule ], declarations: [ItemTableComponent, ItemFilter], exports: [ItemTableComponent, ItemFilter] diff --git a/data-showcase/src/main/user-interface/src/app/logos/logos.component.spec.ts b/data-showcase/src/main/user-interface/src/app/logos/logos.component.spec.ts index b59ca36..5756926 100644 --- a/data-showcase/src/main/user-interface/src/app/logos/logos.component.spec.ts +++ b/data-showcase/src/main/user-interface/src/app/logos/logos.component.spec.ts @@ -12,6 +12,8 @@ import {ResourceService} from "../services/resource.service"; import {AppConfig} from "../config/app.config"; import {AppConfigMock} from "../config/app.config.mock"; import {HttpModule} from "@angular/http"; +import {DSMessageService} from "../services/ds-message.service"; +import {MessageService} from "primeng/components/common/messageservice"; describe('LogosComponent', () => { let component: LogosComponent; @@ -24,6 +26,8 @@ describe('LogosComponent', () => { providers: [ DataService, ResourceService, + DSMessageService, + MessageService, { provide: AppConfig, useClass: AppConfigMock diff --git a/data-showcase/src/main/user-interface/src/app/models/concept.ts b/data-showcase/src/main/user-interface/src/app/models/concept.ts index 1677352..e0df30a 100644 --- a/data-showcase/src/main/user-interface/src/app/models/concept.ts +++ b/data-showcase/src/main/user-interface/src/app/models/concept.ts @@ -4,8 +4,14 @@ * (see accompanying file LICENSE). */ -type VariableType = "Numerical"| "Categorical" | "Text" | "None"; +export type VariableType = "Numerical"| "Categorical" | "Text" | "None"; export class Concept { + conceptCode: string; + label: string; + labelLong: string; + labelNl: string; + labelNlLong: string; variableType: VariableType; + keywords: string[]; } diff --git a/data-showcase/src/main/user-interface/src/app/models/item.ts b/data-showcase/src/main/user-interface/src/app/models/item.ts index aa027ee..e85ec1c 100644 --- a/data-showcase/src/main/user-interface/src/app/models/item.ts +++ b/data-showcase/src/main/user-interface/src/app/models/item.ts @@ -8,12 +8,12 @@ import {ItemSummary} from "./item-summary"; import {Concept} from "./concept"; export class Item { + id: number; name: string; itemPath: string; type: string; project: string; - keywords: string[]; - researchLine: string; + lineOfResearch: string; concept: string; summary: ItemSummary; label: string; diff --git a/data-showcase/src/main/user-interface/src/app/text-filter/text-filter.component.css b/data-showcase/src/main/user-interface/src/app/models/itemResponse.ts similarity index 57% rename from data-showcase/src/main/user-interface/src/app/text-filter/text-filter.component.css rename to data-showcase/src/main/user-interface/src/app/models/itemResponse.ts index c549c2d..656bef0 100644 --- a/data-showcase/src/main/user-interface/src/app/text-filter/text-filter.component.css +++ b/data-showcase/src/main/user-interface/src/app/models/itemResponse.ts @@ -4,9 +4,10 @@ * (see accompanying file LICENSE). */ -.text-filter-container { - margin-left: 10px; - height: 100px; - line-height: 100px; - white-space: nowrap; +import {Item} from "./item"; + +export class ItemResponse { + items: Item[]; + totalCount: number; + page: number; } diff --git a/data-showcase/src/main/user-interface/src/app/models/tree-node.ts b/data-showcase/src/main/user-interface/src/app/models/tree-node.ts index a47d994..1aff759 100644 --- a/data-showcase/src/main/user-interface/src/app/models/tree-node.ts +++ b/data-showcase/src/main/user-interface/src/app/models/tree-node.ts @@ -4,16 +4,16 @@ * (see accompanying file LICENSE). */ -import {Concept} from "./concept"; +import { VariableType } from './concept'; -type NodeType = 'Domain' | 'Concept'; +export type NodeType = 'Domain' | 'Concept'; export class TreeNode { label: string; accumulativeItemCount: number; itemCount: number; - path: string; - concept: Concept; + concept: string; + variableType: VariableType; nodeType: NodeType; children: TreeNode[]; } diff --git a/data-showcase/src/main/user-interface/src/app/page-ribbon/page-ribbon.component.spec.ts b/data-showcase/src/main/user-interface/src/app/page-ribbon/page-ribbon.component.spec.ts index b7b0d10..1c34505 100644 --- a/data-showcase/src/main/user-interface/src/app/page-ribbon/page-ribbon.component.spec.ts +++ b/data-showcase/src/main/user-interface/src/app/page-ribbon/page-ribbon.component.spec.ts @@ -12,6 +12,8 @@ import {AppConfig} from "../config/app.config"; import {AppConfigMock} from "../config/app.config.mock"; import {DataService} from "../services/data.service"; import {HttpModule} from "@angular/http"; +import {MessageService} from "primeng/components/common/messageservice"; +import {DSMessageService} from "../services/ds-message.service"; describe('PageRibbonComponent', () => { let component: PageRibbonComponent; @@ -24,6 +26,8 @@ describe('PageRibbonComponent', () => { providers: [ ResourceService, DataService, + DSMessageService, + MessageService, { provide: AppConfig, useClass: AppConfigMock diff --git a/data-showcase/src/main/user-interface/src/app/search-text-parser/grammar.ne b/data-showcase/src/main/user-interface/src/app/search-text-parser/grammar.ne new file mode 100644 index 0000000..973937a --- /dev/null +++ b/data-showcase/src/main/user-interface/src/app/search-text-parser/grammar.ne @@ -0,0 +1,86 @@ +@preprocessor typescript +@{% +/* + * Copyright (c) 2017 The Hyve B.V. + * This file is distributed under the GNU Affero General Public License + * (see accompanying file LICENSE). + */ + +import { syntax } from './syntax'; + +const defaultOperator = 'or'; + +function stripQuotes(word) { + return word.value.replace(/^"|"$/g, ''); +} +function buildValue(value) { + return { type: 'string', value: value }; +} + +function buildSequence(d) { + if (d[2] == null) { + return d[0]; + } else { + return { type: defaultOperator, left: d[0], right: d[2] }; + } +} + +function buildToken(d) { + return d[0].value; +} + +function buildLowercaseToken(d) { + return d[0].value.toLowerCase(); +} + +%} + +# Pass your lexer object using the @lexer option: +@lexer syntax + +search -> _ expression _ {% function(d) { return d[1]; } %} + +word -> %word {% function(d) { return buildValue(stripQuotes(d[0])); } %} + +field -> + "name" {% buildToken %} + | "label" {% buildToken %} + | "labelLong" {% buildToken %} + | "labelNl" {% buildToken %} + | "labelNlLong" {% buildToken %} + | "keywords" {% buildToken %} + | "keyword" {% buildToken %} + | "*" {% buildToken %} + +comparator -> + "=" {% buildLowercaseToken %} + | "!=" {% buildLowercaseToken %} + | "contains" {% buildLowercaseToken %} + | "CONTAINS" {% buildLowercaseToken %} + | "like" {% buildLowercaseToken %} + | "LIKE" {% buildLowercaseToken %} + +comparison -> + field _ comparator _ word {% + function(d) { return { type: 'comparison', field: d[0], comparator: d[2], arg: d[4] }; } + %} + | comparator _ word {% + function(d) { return { type: 'comparison', field: '*', comparator: d[0], arg: d[2] }; } + %} + +sequence -> + null {% function(d) { return null; } %} + | word _ sequence {% buildSequence %} + | comparison _ sequence {% buildSequence %} + +inner -> + "(" _ expression _ ")" {% function(d) { return d[2]; } %} + | ("not"|"NOT") _ inner {% function(d) { return { type: 'not', arg: d[2] }; } %} + | sequence {% function(d) { return d[0]; } %} + +expression -> + inner _ ("and"|"AND") _ expression {% function(d) { return { type: 'and', left: d[0], right: d[4] }; } %} + | inner _ ("or"|"OR") _ expression {% function(d) { return { type: 'or', left: d[0], right: d[4] }; } %} + | inner {% function(d) { return d[0]; } %} + +_ -> null | %WS {% function(d) { return null; } %} diff --git a/data-showcase/src/main/user-interface/src/app/search-text-parser/grammar.ts b/data-showcase/src/main/user-interface/src/app/search-text-parser/grammar.ts new file mode 100644 index 0000000..0ffaf1f --- /dev/null +++ b/data-showcase/src/main/user-interface/src/app/search-text-parser/grammar.ts @@ -0,0 +1,86 @@ +// Generated automatically by nearley +// http://github.com/Hardmath123/nearley +function id(d:any[]):any {return d[0];} +declare var word:any; +declare var WS:any; + +/* + * Copyright (c) 2017 The Hyve B.V. + * This file is distributed under the GNU Affero General Public License + * (see accompanying file LICENSE). + */ + +import { syntax } from './syntax'; + +const defaultOperator = 'or'; + +function stripQuotes(word) { + return word.value.replace(/^"|"$/g, ''); +} +function buildValue(value) { + return { type: 'string', value: value }; +} + +function buildSequence(d) { + if (d[2] == null) { + return d[0]; + } else { + return { type: defaultOperator, left: d[0], right: d[2] }; + } +} + +function buildToken(d) { + return d[0].value; +} + +function buildLowercaseToken(d) { + return d[0].value.toLowerCase(); +} + +export interface Token {value:any; [key: string]:any}; +export interface Lexer {reset:(chunk:string, info:any) => void; next:() => Token | undefined; save:() => any; formatError:(token:Token) => string; has:(tokenType:string) => boolean}; +export interface NearleyRule {name:string; symbols:NearleySymbol[]; postprocess?:(d:any[],loc?:number,reject?:{})=>any}; +export type NearleySymbol = string | {literal:any} | {test:(token:any) => boolean}; +export var Lexer:Lexer|undefined = syntax; +export var ParserRules:NearleyRule[] = [ + {"name": "search", "symbols": ["_", "expression", "_"], "postprocess": function(d) { return d[1]; }}, + {"name": "word", "symbols": [(syntax.has("word") ? {type: "word"} : word)], "postprocess": function(d) { return buildValue(stripQuotes(d[0])); }}, + {"name": "field", "symbols": [{"literal":"name"}], "postprocess": buildToken}, + {"name": "field", "symbols": [{"literal":"label"}], "postprocess": buildToken}, + {"name": "field", "symbols": [{"literal":"labelLong"}], "postprocess": buildToken}, + {"name": "field", "symbols": [{"literal":"labelNl"}], "postprocess": buildToken}, + {"name": "field", "symbols": [{"literal":"labelNlLong"}], "postprocess": buildToken}, + {"name": "field", "symbols": [{"literal":"keywords"}], "postprocess": buildToken}, + {"name": "field", "symbols": [{"literal":"keyword"}], "postprocess": buildToken}, + {"name": "field", "symbols": [{"literal":"*"}], "postprocess": buildToken}, + {"name": "comparator", "symbols": [{"literal":"="}], "postprocess": buildLowercaseToken}, + {"name": "comparator", "symbols": [{"literal":"!="}], "postprocess": buildLowercaseToken}, + {"name": "comparator", "symbols": [{"literal":"contains"}], "postprocess": buildLowercaseToken}, + {"name": "comparator", "symbols": [{"literal":"CONTAINS"}], "postprocess": buildLowercaseToken}, + {"name": "comparator", "symbols": [{"literal":"like"}], "postprocess": buildLowercaseToken}, + {"name": "comparator", "symbols": [{"literal":"LIKE"}], "postprocess": buildLowercaseToken}, + {"name": "comparison", "symbols": ["field", "_", "comparator", "_", "word"], "postprocess": + function(d) { return { type: 'comparison', field: d[0], comparator: d[2], arg: d[4] }; } + }, + {"name": "comparison", "symbols": ["comparator", "_", "word"], "postprocess": + function(d) { return { type: 'comparison', field: '*', comparator: d[0], arg: d[2] }; } + }, + {"name": "sequence", "symbols": [], "postprocess": function(d) { return null; }}, + {"name": "sequence", "symbols": ["word", "_", "sequence"], "postprocess": buildSequence}, + {"name": "sequence", "symbols": ["comparison", "_", "sequence"], "postprocess": buildSequence}, + {"name": "inner", "symbols": [{"literal":"("}, "_", "expression", "_", {"literal":")"}], "postprocess": function(d) { return d[2]; }}, + {"name": "inner$subexpression$1", "symbols": [{"literal":"not"}]}, + {"name": "inner$subexpression$1", "symbols": [{"literal":"NOT"}]}, + {"name": "inner", "symbols": ["inner$subexpression$1", "_", "inner"], "postprocess": function(d) { return { type: 'not', arg: d[2] }; }}, + {"name": "inner", "symbols": ["sequence"], "postprocess": function(d) { return d[0]; }}, + {"name": "expression$subexpression$1", "symbols": [{"literal":"and"}]}, + {"name": "expression$subexpression$1", "symbols": [{"literal":"AND"}]}, + {"name": "expression", "symbols": ["inner", "_", "expression$subexpression$1", "_", "expression"], "postprocess": function(d) { return { type: 'and', left: d[0], right: d[4] }; }}, + {"name": "expression$subexpression$2", "symbols": [{"literal":"or"}]}, + {"name": "expression$subexpression$2", "symbols": [{"literal":"OR"}]}, + {"name": "expression", "symbols": ["inner", "_", "expression$subexpression$2", "_", "expression"], "postprocess": function(d) { return { type: 'or', left: d[0], right: d[4] }; }}, + {"name": "expression", "symbols": ["inner"], "postprocess": function(d) { return d[0]; }}, + {"name": "_", "symbols": []}, + {"name": "_", "symbols": [(syntax.has("WS") ? {type: "WS"} : WS)], "postprocess": function(d) { return null; }} +]; +export var ParserStart:string = "search"; diff --git a/data-showcase/src/main/user-interface/src/app/search-text-parser/index.spec.ts b/data-showcase/src/main/user-interface/src/app/search-text-parser/index.spec.ts new file mode 100644 index 0000000..d1333ca --- /dev/null +++ b/data-showcase/src/main/user-interface/src/app/search-text-parser/index.spec.ts @@ -0,0 +1,251 @@ +/* + * Copyright (c) 2017 The Hyve B.V. + * This file is distributed under the GNU Affero General Public License + * (see accompanying file LICENSE). + */ + +import { SearchTextParser } from '.' +import { SearchQuery } from './search-query'; + +describe('Search text parser', () => { + + it('nested with comparison', () => { + let line = '"cow chicken" and not ("moo" or (label = grass))'; + let parser = new SearchTextParser(); + let tree = parser.parse(line); + + expect(tree).not.toBeNull(); + + let result = parser.flatten(tree); + let expected = {type: 'and', values: [ + {type: 'string', value :'cow chicken'}, + {type: 'not', values: [ + {type: 'or', values: [ + {type: 'string', value: 'moo'}, + {type: '=', value: 'label', values: [ + {type: 'string', value: 'grass'} + ]} + ]} + ]} + ]} as SearchQuery; + + expect(JSON.stringify(result)).toEqual(JSON.stringify(expected)); + }); + + it('nested', () => { + let line = '"moo" or (label = grass)'; + let parser = new SearchTextParser(); + let tree = parser.parse(line); + + expect(tree).not.toBeNull(); + + let result = parser.flatten(tree); + let expected = {type: 'or', values: [ + {type: 'string', value :'moo'}, + {type: '=', value: 'label', values: [ + {type: 'string', value: 'grass'} + ]} + ]} as SearchQuery; + + expect(JSON.stringify(result)).toEqual(JSON.stringify(expected)); + }); + + it('sequences', () => { + let line = 'moo cow and (bla or this)'; + let parser = new SearchTextParser(); + let tree = parser.parse(line); + + expect(tree).not.toBeNull(); + + let result = parser.flatten(tree); + let expected = {type: 'and', values: [ + {type: 'or', values: [ + {type: 'string', value: 'moo'}, + {type: 'string', value: 'cow'} + ]}, + {type: 'or', values: [ + {type: 'string', value: 'bla'}, + {type: 'string', value: 'this'} + ]} + ]} as SearchQuery; + + expect(JSON.stringify(result)).toEqual(JSON.stringify(expected)); + }); + + it('infix comparison', () => { + let line = 'contains corn or = rice'; + let parser = new SearchTextParser(); + let tree = parser.parse(line); + + expect(tree).not.toBeNull(); + + let result = parser.flatten(tree); + let expected = {type: 'or', values: [ + {type: 'contains', value: '*', values: [ + {type: 'string', value: 'corn'} + ]}, + {type: '=', value: '*', values: [ + {type: 'string', value: 'rice'} + ]} + ]} as SearchQuery; + + expect(JSON.stringify(result)).toEqual(JSON.stringify(expected)); + }); + + it('wildcard comparison', () => { + let line = '* contains corn or keyword = rice'; + let parser = new SearchTextParser(); + let tree = parser.parse(line); + + expect(tree).not.toBeNull(); + + let result = parser.flatten(tree); + let expected = {type: 'or', values: [ + {type: 'contains', value: '*', values: [ + {type: 'string', value: 'corn'} + ]}, + {type: '=', value: 'keyword', values: [ + {type: 'string', value: 'rice'} + ]} + ]} as SearchQuery; + + expect(JSON.stringify(result)).toEqual(JSON.stringify(expected)); + }); + + it('empty string', () => { + let line = ''; + let parser = new SearchTextParser(); + let tree = parser.parse(line); + + expect(tree).toBeNull(); + + let result = parser.flatten(tree); + + expect(result).toBeNull(); + }); + + it('loose use of comma\'s and operators', () => { + let line = 'and cow, goose, sheep'; + let parser = new SearchTextParser(); + let tree = parser.parse(line); + + expect(tree).not.toBeNull(); + + let result = parser.flatten(tree); + let expected = {type: 'and', values: [ + {type: 'or', values :[ + {type: 'string', value: 'cow'}, + {type: 'string', value: 'goose'}, + {type: 'string', value: 'sheep'} + ]}]} as SearchQuery; + + expect(JSON.stringify(result)).toEqual(JSON.stringify(expected)); + }); + + it('freely mix words and comparisons', () => { + let line = 'cow label != goose'; + let parser = new SearchTextParser(); + let tree = parser.parse(line); + + expect(tree).not.toBeNull(); + + let result = parser.flatten(tree); + let expected = {type: 'or', values: [ + {type: 'string', value: 'cow'}, + {type: '!=', value: 'label', values: [ + {type: 'string', value: 'goose'} + ]} + ]} as SearchQuery; + + expect(JSON.stringify(result)).toEqual(JSON.stringify(expected)); + }); + + it('comparison with like', () => { + let line = 'label like goose'; + let parser = new SearchTextParser(); + let tree = parser.parse(line); + + expect(tree).not.toBeNull(); + + let result = parser.flatten(tree); + let expected = {type: 'like', value: 'label', values: [ + {type: 'string', value: 'goose'} + ]} as SearchQuery; + + expect(JSON.stringify(result)).toEqual(JSON.stringify(expected)); + }); + + it('comparison with uppercase like', () => { + let line = 'label LIKE goose'; + let parser = new SearchTextParser(); + let tree = parser.parse(line); + + expect(tree).not.toBeNull(); + + let result = parser.flatten(tree); + let expected = {type: 'like', value: 'label', values: [ + {type: 'string', value: 'goose'} + ]} as SearchQuery; + + expect(JSON.stringify(result)).toEqual(JSON.stringify(expected)); + }); + + it('empty and or', () => { + let line = 'and or and'; + let parser = new SearchTextParser(); + let tree = parser.parse(line); + + expect(tree).not.toBeNull(); + }); + + it('test precedence of not over or', () => { + let line = 'not cow or goose'; + let parser = new SearchTextParser(); + let tree = parser.parse(line); + + expect(tree).not.toBeNull(); + + let result = parser.flatten(tree); + let expected = {type: 'or', values: [ + {type: 'not', values: [ + {type: 'string', value: 'cow'} + ]}, + {type: 'string', value: 'goose'}, + ]} as SearchQuery; + + expect(JSON.stringify(result)).toEqual(JSON.stringify(expected)); + }); + + it('test elimination of double negation', () => { + let line = 'not not goose'; + let parser = new SearchTextParser(); + let tree = parser.parse(line); + + expect(tree).not.toBeNull(); + + let result = parser.flatten(tree); + let expected = {type: 'string', value: 'goose'} as SearchQuery; + + expect(JSON.stringify(result)).toEqual(JSON.stringify(expected)); + }); + + it('test upper case and lower case operators', () => { + let line = 'not goose AND NOT cow'; + let parser = new SearchTextParser(); + let tree = parser.parse(line); + + expect(tree).not.toBeNull(); + + let result = parser.flatten(tree); + let expected = {type: 'and', values: [ + {type: 'not', values: [ + {type: 'string', value: 'goose'} + ]}, + {type: 'not', values: [ + {type: 'string', value: 'cow'} + ]} + ]} as SearchQuery; + expect(JSON.stringify(result)).toEqual(JSON.stringify(expected)); + }); + +}); diff --git a/data-showcase/src/main/user-interface/src/app/search-text-parser/index.ts b/data-showcase/src/main/user-interface/src/app/search-text-parser/index.ts new file mode 100644 index 0000000..951d065 --- /dev/null +++ b/data-showcase/src/main/user-interface/src/app/search-text-parser/index.ts @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2017 The Hyve B.V. + * This file is distributed under the GNU Affero General Public License + * (see accompanying file LICENSE). + */ + +import { CompiledRules, Grammar, Parser } from 'nearley'; +import { Lexer, ParserRules, ParserStart } from './grammar'; +import { ParseTree, QueryType, SearchQuery } from './search-query'; + +export class SearchTextParser { + + parser: Parser; + + constructor() { + let rules: CompiledRules = { + Lexer: Lexer, + ParserStart: ParserStart, + ParserRules: ParserRules + }; + this.parser = new Parser(Grammar.fromCompiled(rules)); + } + + parse(line: string): ParseTree { + this.parser.feed(line); + if (this.parser.results.length == 0) { + return null; + } else { + return this.parser.results[0]; + } + } + + private flattenBinaryNode(node: ParseTree): SearchQuery { + let values = []; + let left = this.flattenNode(node.left); + if (left == null) { + // skip + } else if (left.type == node.type) { + values = values.concat(left.values); + } else { + values.push(left); + } + let right = this.flattenNode(node.right); + if (right == null) { + // skip + } else if (right.type == node.type) { + values = values.concat(right.values); + } else { + values.push(right); + } + return SearchQuery.forValues(node.type, values); + } + + private flattenNode(node: ParseTree): SearchQuery { + if (node === null || node === undefined) { + return null; + } + switch(node.type) { + case 'not': + if (node.arg == null) { + return null; + } else if (node.arg.type == 'not') { + return this.flatten(node.arg.arg); + } else { + return SearchQuery.forValues('not',[this.flatten(node.arg)]); + } + case 'or': + case 'and': + return this.flattenBinaryNode(node); + case 'string': + return SearchQuery.forValue(node.value); + case 'comparison': + let query = new SearchQuery(); + query.type = node.comparator as QueryType; + query.value = node.field; + query.values = [this.flattenNode(node.arg)]; + return query; + default: + throw Error(`Unexpected node type ${node.type}`); + } + } + + flatten(tree: ParseTree): SearchQuery { + return this.flattenNode( tree); + } + +} \ No newline at end of file diff --git a/data-showcase/src/main/user-interface/src/app/search-text-parser/search-query.ts b/data-showcase/src/main/user-interface/src/app/search-text-parser/search-query.ts new file mode 100644 index 0000000..b9e2d4c --- /dev/null +++ b/data-showcase/src/main/user-interface/src/app/search-text-parser/search-query.ts @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2017 The Hyve B.V. + * This file is distributed under the GNU Affero General Public License + * (see accompanying file LICENSE). + */ + +export type Field = 'name' | 'label' | 'labelLong' | 'labelNl' | 'labelNlLong'; +export type Comparator = '=' | '!=' | 'contains' | 'like'; +export type Operator = 'and' | 'or' | 'not'; +export type NodeType = 'string' | 'comparison' | Operator; +export type QueryType = NodeType | Comparator; + +export class ParseTree { + type: NodeType; + // for comparison: + field: Field; + comparator: Comparator; + // for comparison, string: + value: string; + // for negation: + arg: ParseTree; + // for and, or: + left: ParseTree; + right: ParseTree; +} + +export class SearchQuery { + type: QueryType; + value: string; + values: SearchQuery[]; + + static forValues(type: QueryType, values: SearchQuery[]) : SearchQuery { + let result = new SearchQuery(); + result.type = type; + result.values = values; + return result; + } + + static forValue(value: string) : SearchQuery { + let result = new SearchQuery(); + result.type = 'string'; + result.value = value; + return result; + } + +} diff --git a/data-showcase/src/main/user-interface/src/app/search-text-parser/syntax.ts b/data-showcase/src/main/user-interface/src/app/search-text-parser/syntax.ts new file mode 100644 index 0000000..15d2184 --- /dev/null +++ b/data-showcase/src/main/user-interface/src/app/search-text-parser/syntax.ts @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2017 The Hyve B.V. + * This file is distributed under the GNU Affero General Public License + * (see accompanying file LICENSE). + */ + +import { compile } from 'moo'; + +export const syntax = compile({ + word: { match: /[^ \t\n(),;"]+|"(?:\\["\\]|[^\n"\\])*"/, + keywords: { + 'not': 'not', + 'NOT': 'not', + 'and': 'and', + 'AND': 'AND', + 'or': 'or', + 'OR': 'OR', + '=': '=', + '!=': '!=', + 'contains': 'contains', + 'CONTAINS': 'CONTAINS', + 'like': 'like', + 'LIKE': 'LIKE', + 'name': 'name', + 'label': 'label', + 'labelNl': 'labelNl', + 'labelLong': 'labelLong', + 'labelNlLong': 'labelNlLong', + 'keywords': 'keywords', + 'keyword': 'keyword', + '*': '*' + } + }, + '(': '(', + ')': ')', + WS: /[ \t,;]+/, + NL: { match: /\n/, lineBreaks: true } +}); diff --git a/data-showcase/src/main/user-interface/src/app/services/data.service.spec.ts b/data-showcase/src/main/user-interface/src/app/services/data.service.spec.ts index 64aeb58..bcb7510 100644 --- a/data-showcase/src/main/user-interface/src/app/services/data.service.spec.ts +++ b/data-showcase/src/main/user-interface/src/app/services/data.service.spec.ts @@ -11,6 +11,8 @@ import {ResourceService} from "./resource.service"; import {HttpModule} from "@angular/http"; import {AppConfig} from "../config/app.config"; import {AppConfigMock} from "../config/app.config.mock"; +import {DSMessageService} from "./ds-message.service"; +import {MessageService} from "primeng/components/common/messageservice"; describe('DataService', () => { beforeEach(() => { @@ -19,6 +21,8 @@ describe('DataService', () => { providers: [ DataService, ResourceService, + DSMessageService, + MessageService, { provide: AppConfig, useClass: AppConfigMock diff --git a/data-showcase/src/main/user-interface/src/app/services/data.service.ts b/data-showcase/src/main/user-interface/src/app/services/data.service.ts index cac3670..af46694 100644 --- a/data-showcase/src/main/user-interface/src/app/services/data.service.ts +++ b/data-showcase/src/main/user-interface/src/app/services/data.service.ts @@ -5,7 +5,7 @@ */ import {Injectable} from '@angular/core'; -import {TreeNode as TreeNodeLib} from 'primeng/primeng'; +import {SelectItem, TreeNode as TreeNodeLib} from 'primeng/primeng'; import {ResourceService} from './resource.service'; import {TreeNode} from "../models/tree-node"; import {Item} from "../models/item"; @@ -13,8 +13,11 @@ import {Project} from "../models/project"; import {Subject} from "rxjs/Subject"; import {BehaviorSubject} from "rxjs/BehaviorSubject"; import {Environment} from "../models/environment"; +import {ItemResponse} from "../models/itemResponse"; +import {DSMessageService} from "./ds-message.service"; type LoadingState = 'loading' | 'complete'; +type Order = 'asc' | 'desc'; @Injectable() export class DataService { @@ -24,41 +27,53 @@ export class DataService { // the status indicating the when the tree is being loaded or finished loading public loadingTreeNodes: LoadingState = 'complete'; - // list of all items - private items: Item[] = []; // the flag indicating if Items are still being loaded - public loadingItems: boolean = false; + public loadingItems: LoadingState = 'complete'; // filtered list of items based on selected node and selected checkbox filters - public filteredItems: Item[] = []; - // items available for currently selected node - private itemsPerNode: Item[] = []; + private filteredItemsSource = new Subject(); + public filteredItems$ = this.filteredItemsSource.asObservable(); + // items selected in the itemTable + private itemsSelectionSource = new Subject(); + public itemsSelection$ = this.itemsSelectionSource.asObservable(); + //a number of items in the table + public totalItemsCount: number = 0; + // if the select-all-items checkbox is selected + public allItemsSelected: boolean = false; // items added to the shopping cart public shoppingCartItems = new BehaviorSubject([]); - // global text filter - private globalFilterSource = new Subject(); - public globalFilter$ = this.globalFilterSource.asObservable(); - - // selected checkboxes for keywords filter - private selectedKeywords: string[] = []; + // text filter input + private textFilterInputSource = new Subject(); + public textFilterInput$ = this.textFilterInputSource.asObservable(); + + // Search query + private searchQuery: Object = null; + + // item table pagination settings + // the first result to retrieve, numbered from '0' + public itemsFirstResult: number = 0; + // the maximum number of results + public itemsMaxResults: number = 8; + // ascending/descending order + public itemsOrder: number = 1; + // the property to order on + public itemsPropertyName: string = ""; + + // trigger checkboxFilters reload + private rerenderCheckboxFiltersSource = new Subject(); + public rerenderCheckboxFilters$ = this.rerenderCheckboxFiltersSource.asObservable(); + // currently selected tree node + private selectedTreeNode: TreeNode = null; // selected checkboxes for projects filter private selectedProjects: string[] = []; // selected checkboxes for research lines filter private selectedResearchLines: string[] = []; - - // list of keywords available for current item list - private keywords: string[] = []; - // list of keywords available for current item list - private projects: string[] = []; - // list of keywords available for current item list - private researchLines: string[] = []; - + // list of project names available for current item list + public projects: SelectItem[] = []; + // list of research lines available for current item list + public linesOfResearch: SelectItem[] = []; // list of all projects - private availableProjects: Project[] = []; - - // item summary popup visibility - private itemSummaryVisibleSource = new Subject(); - public itemSummaryVisible$ = this.itemSummaryVisibleSource.asObservable(); + private allProjects: Project[] = []; // NTR logo private ntrLogoUrlSummary = new Subject(); @@ -69,36 +84,35 @@ export class DataService { public vuLogoUrl$ = this.vuLogoUrlSummary.asObservable(); // item summary popup visibility + private itemSummaryVisibleSource = new Subject(); + public itemSummaryVisible$ = this.itemSummaryVisibleSource.asObservable(); + + // search error message + private searchErrorMessageSource = new Subject(); + public searchErrorMessage$ = this.searchErrorMessageSource.asObservable(); + + // environment label visibility private environmentSource = new Subject(); public environment$ = this.environmentSource.asObservable(); - constructor(private resourceService: ResourceService) { - this.updateAvailableProjects(); - this.updateNodes(); - this.updateItems(); - this.setFilteredItems(); + constructor(private resourceService: ResourceService, + private dsMessageService: DSMessageService) { + this.fetchFilters(); + this.fetchItems(); + this.fetchAllTreeNodes() this.setEnvironment(); } - loadLogo(type: string) { - this.resourceService.getLogo(type) - .subscribe( - (blobContent) => { - let urlCreator = window.URL; - if (type == "NTR") { - this.ntrLogoUrlSummary.next(urlCreator.createObjectURL(blobContent)); - } else { - this.vuLogoUrlSummary.next(urlCreator.createObjectURL(blobContent)); - } - }, - err => console.error(err) - ); - } + // ------------------------- tree nodes ------------------------- private processTreeNodes(nodes: TreeNode[]): TreeNodeLib[] { + if (nodes == null) { + return []; + } let treeNodes: TreeNodeLib[] = []; for (let node of nodes) { - if (!(node.accumulativeItemCount == 0 && node.nodeType == "Domain" )) { + // filter out empty domains + if (!(node.accumulativeItemCount == 0 && node.nodeType == 'Domain')) { let newNode = this.processTreeNode(node); treeNodes.push(newNode); } @@ -107,32 +121,36 @@ export class DataService { } private processTreeNode(node: TreeNode): TreeNodeLib { + if (node == null) { + return null; + } // Add PrimeNG visual properties for tree nodes let newNode: TreeNodeLib = node; - // filter out empty domains - newNode.children = node.children.filter(value => !(value.accumulativeItemCount == 0 && value.nodeType == "Domain" )); let count = node.accumulativeItemCount ? node.accumulativeItemCount : 0; - let countStr = ' (' + count + ')'; - newNode.label = node.label + countStr; - - // If this newNode has children, drill down - if (node.children && node.children.length > 0) { - // Recurse - newNode.expandedIcon = 'fa-folder-open'; - newNode.collapsedIcon = 'fa-folder'; - newNode.icon = ''; - this.processTreeNodes(node.children); + newNode.label = `${node.label} (${count})`; + + // If this node has children, drill down + if (node.children) { + let children = this.processTreeNodes(node.children); + if (children.length > 0) { + newNode.children = children; + newNode.expandedIcon = 'fa-folder-open'; + newNode.collapsedIcon = 'fa-folder'; + newNode.icon = ''; + } else { + newNode.icon = 'fa-folder-o'; + } } else { - switch (node.concept.variableType) { - case "Text": - newNode['icon'] = 'icon-abc'; + switch (node.variableType) { + case 'Text': + newNode.icon = 'icon-abc'; break; - case "Numerical": - newNode['icon'] = 'icon-123'; + case 'Numerical': + newNode.icon = 'icon-123'; break; default: { - newNode['icon'] = 'fa-file-text'; + newNode.icon = 'fa-file-text'; break; } } @@ -140,17 +158,7 @@ export class DataService { return newNode; } - updateAvailableProjects() { - this.resourceService.getProjects() - .subscribe( - (projects: Project[]) => { - this.availableProjects = projects; - }, - err => console.error(err) - ); - } - - updateNodes() { + fetchAllTreeNodes() { this.loadingTreeNodes = 'loading'; // Retrieve all tree nodes this.resourceService.getTreeNodes() @@ -166,131 +174,304 @@ export class DataService { ); } - updateItems() { - this.loadingItems = true; - this.itemsPerNode.length = 0; - this.items.length = 0; - this.resourceService.getItems() - .subscribe( - (items: Item[]) => { - console.log('item loading'); - for (let item of items) { - if (this.availableProjects) { - item['researchLine'] = this.availableProjects.find(p => p.name == item['project']).lineOfResearch; - } + selectTreeNode(treeNode: TreeNode) { + this.selectedTreeNode = treeNode; + this.updateItemTable(); + } + + // ------------------------- filters and item table ------------------------- + + projectToResearchLine(projectName: string): string { + if (this.allProjects) { + return this.allProjects.find(p => p.name == projectName).lineOfResearch; + } else { + return null; + } + } + + fetchFilters() { + this.projects.length = 0; + this.linesOfResearch.length = 0; + + let selectedConceptCodes = DataService.treeConceptCodes(this.selectedTreeNode); + let codes = Array.from(selectedConceptCodes); + + this.resourceService.getProjects(codes, this.searchQuery).subscribe( + (projects: Project[]) => { + for (let project of projects) { + this.allProjects.push(project); + this.projects.push({label: project.name, value: project.name}); + DataService.collectUnique(project.lineOfResearch, this.linesOfResearch); + } + this.sortLinesOfResearch(); + }, + err => { + console.error(err); + } + ); + } + + fetchItems() { + let t1 = new Date(); + console.debug(`Fetching items ...`); + this.loadingItems = 'loading'; + this.filteredItemsSource.next([]); + this.clearErrorSearchMessage(); + + let selectedConceptCodes = DataService.treeConceptCodes(this.selectedTreeNode); + let codes = Array.from(selectedConceptCodes); + let projects = this.getProjectsForSelectedResearchLines(); + + let order: Order = this.orderFlagToOrderName(this.itemsOrder); + + this.resourceService.getItems(this.itemsFirstResult, this.itemsMaxResults, order, this.itemsPropertyName, + codes, projects, this.searchQuery).subscribe( + (response: ItemResponse) => { + this.totalItemsCount = response.totalCount; + for (let item of response.items) { + if (this.allProjects && this.allProjects.length > 0) { + item.lineOfResearch = this.projectToResearchLine(item.project); + } + } + this.filteredItemsSource.next(response.items); + this.loadingItems = "complete"; + let t2 = new Date(); + console.info(`Found ${response.totalCount} items. (Took ${t2.getTime() - t1.getTime()} ms.)`); + }, + err => { + if (err != String(undefined)) { + this.searchErrorMessageSource.next(err); + } + console.error(err); + this.clearCheckboxFilters(); + } + ); + } - this.itemsPerNode.push(item); - this.items.push(item); + orderFlagToOrderName(order: number){ + return order == 1 ? "asc" : "desc"; + } + + clearErrorSearchMessage(){ + this.searchErrorMessageSource.next(''); + } + + selectAllItems(selectAll: boolean){ + if(selectAll){ + let firstResult = 0; + let selectedConceptCodes = DataService.treeConceptCodes(this.selectedTreeNode); + let codes = Array.from(selectedConceptCodes); + let projects = this.getProjectsForSelectedResearchLines(); + + this.resourceService.getItems(firstResult, null, null, null, + codes, projects, this.searchQuery).subscribe( + (response: ItemResponse) => { + for (let item of response.items) { + if (this.allProjects && this.allProjects.length > 0) { + item.lineOfResearch = this.projectToResearchLine(item.project); + } } - this.setFilteredItems(); - this.getUniqueFilterValues(); - this.loadingItems = false; + this.itemsSelectionSource.next(response.items); }, - err => console.error(err) + err => { + if (err != String(undefined)) { + this.searchErrorMessageSource.next(err); + } + console.error(err); + } ); + } else { + this.clearItemsSelection(); + } } - updateItemTable(treeNode: TreeNode) { - this.items.length = 0; - let nodeItems = treeNode ? this.itemsPerNode.filter(item => item.itemPath.startsWith(treeNode.path)) - : this.itemsPerNode; - for (let node of nodeItems) { - this.items.push(node); + static treeConceptCodes(treeNode: TreeNode): Set { + if (treeNode == null) { + return new Set(); + } + let conceptCodes = new Set(); + if (treeNode.concept != null) { + conceptCodes.add(treeNode.concept); } - this.setFilteredItems(); - this.getUniqueFilterValues(); + if (treeNode.children != null) { + treeNode.children.forEach((node: TreeNode) => + DataService.treeConceptCodes(node).forEach((conceptCode: string) => + conceptCodes.add(conceptCode) + ) + ) + } + return conceptCodes; } - updateProjectsForResearchLines() { - this.selectedProjects.length = 0; - this.setFilteredItems(); + updateItemTable() { + this.clearAllFilters(); + } + + filterOnResearchLines(selectedResearchLines) { this.projects.length = 0; - for (let item of this.filteredItems) { - DataService.collectUnique(item['project'], this.projects); - } + this.selectedResearchLines = selectedResearchLines; + this.clearItemsSelection(); + this.resetTableToTheFirstPage(); + this.fetchItems(); + this.getUniqueProjects(); } - updateFilterValues(selectedKeywords: string[], selectedProjects: string[], selectedResearchLines: string[]) { - this.selectedKeywords = selectedKeywords; + filterOnProjects(selectedProjects) { + this.linesOfResearch.length = 0; this.selectedProjects = selectedProjects; - this.selectedResearchLines = selectedResearchLines; - this.setFilteredItems(); + this.clearItemsSelection(); + this.resetTableToTheFirstPage(); + this.fetchItems(); + this.getUniqueLinesOfResearch(); } - getItems() { - return this.items; + getProjectsForSelectedResearchLines(): string[] { + if(this.selectedResearchLines.length && !this.selectedProjects.length) { + let projects: string[] = []; + this.allProjects.forEach( p => { + if(this.selectedResearchLines.includes(p.lineOfResearch)) { + projects.push(p.name); + } + }); + return projects; + } else { + return this.selectedProjects; + } } - getKeywords() { - return this.keywords; + clearAllFilters() { + this.clearCheckboxFilterSelection(); + this.resetTableToTheFirstPage(); + this.setTextFilterInput(''); + this.clearItemsSelection(); + this.rerenderCheckboxFiltersSource.next(true); } - getProjects() { - return this.projects; + clearCheckboxFilters() { + this.linesOfResearch.length = 0; + this.projects.length = 0; + this.selectedResearchLines.length = 0; + this.selectedProjects.length = 0; } - getReasearchLines() { - return this.researchLines; + clearCheckboxFilterSelection() { + this.selectedResearchLines.length = 0; + this.selectedProjects.length = 0; } - setFilteredItems() { - this.filteredItems.length = 0; - for (let item of this.items) { - if ((this.selectedKeywords.length == 0 || item['keywords'].some(k => this.selectedKeywords.includes(k))) - && (this.selectedProjects.length == 0 || this.selectedProjects.includes(item['project'])) - && (this.selectedResearchLines.length == 0 || this.selectedResearchLines.includes(item['researchLine'])) - ) { - this.filteredItems.push(item); - } - } + clearItemsSelection() { + this.allItemsSelected = false; + this.itemsSelectionSource.next([]); } - setGlobalFilter(globalFilter: string) { - this.globalFilterSource.next(globalFilter); + resetTableToTheFirstPage() { + this.itemsFirstResult = 0; } - addToShoppingCart(newItemSelection: Item[]) { - let items: Item[] = this.shoppingCartItems.getValue(); - let newItems: Item[] = items; - for (let item of newItemSelection) { - if (!newItems.includes(item)) { - newItems.push(item); + setTextFilterInput(text: string) { + this.textFilterInputSource.next(text); + } + + setSearchQuery(query: Object) { + this.searchQuery = query; + this.clearItemsSelection(); + this.resetTableToTheFirstPage(); + this.fetchItems(); + this.fetchFilters(); + } + + private getUniqueProjects() { + this.allProjects.forEach(ap => { + if (!this.projects.find(p => p.value == ap.name)){ + if (!this.selectedResearchLines.length) { + this.projects.push({label: ap.name, value: ap.name}); + } else { + if (this.selectedResearchLines.includes(ap.lineOfResearch)) { + this.projects.push({label: ap.name, value: ap.name}); + } + } } - } - this.shoppingCartItems.next(newItems); + }); } - setShoppingCartItems(items: Item[]) { - this.shoppingCartItems.next(items); + private static compareSelectItems(a: SelectItem, b: SelectItem) { + if (a.value < b.value) { + return -1; + } else if (a.value > b.value) { + return 1; + } else { + return 0; + } } - private getUniqueFilterValues() { - this.keywords.length = 0; - this.projects.length = 0; - this.researchLines.length = 0; + private sortLinesOfResearch() { + this.linesOfResearch.sort(DataService.compareSelectItems); + } - for (let item of this.items) { - for (let keyword of item['keywords']) { - DataService.collectUnique(keyword, this.keywords); - } - DataService.collectUnique(item['project'], this.projects); - DataService.collectUnique(item['researchLine'], this.researchLines); + private getUniqueLinesOfResearch() { + if (!this.selectedProjects.length) { + this.allProjects.forEach(p => { + if(!this.linesOfResearch.find(l=> l.value == p.lineOfResearch)) { + this.linesOfResearch.push({label: p.lineOfResearch, value: p.lineOfResearch}); + } + }); + } else { + this.selectedProjects.forEach(p => { + let researchLine = this.allProjects.find(ap => ap.name == p).lineOfResearch; + if(!this.linesOfResearch.find(l=> l.value == researchLine)) { + this.linesOfResearch.push({label: researchLine, value: researchLine}); + } + }); } + this.sortLinesOfResearch(); } - private static collectUnique(element, list) { + private static collectUnique(element, list: SelectItem[]) { let values = list.map(function (a) { return a.value; }); if (element && !values.includes(element)) { - list.push({label: element, value: element}); + list.push({label: element, value: element} as SelectItem); } } + // ------------------------- shopping cart ------------------------- + + + addToShoppingCart(newItemSelection: Item[]) { + let count: number = 0; + let newItems: Item[] = this.shoppingCartItems.getValue(); + let itemNames = newItems.map((item) => item.name); + for (let item of newItemSelection) { + if (!itemNames.includes(item.name)) { + newItems.push(item); + count++; + } + } + if(count > 0) { + this.dsMessageService.addInfoMessage("success", "Shopping cart updated!", count + " item(s) added to the cart.") + } else if(newItemSelection.length > 0) { + this.dsMessageService.addInfoMessage("info", "No item added", "Item(s) already in the shopping cart.") + } else { + this.dsMessageService.addInfoMessage("info", "No item selected", "Select item(s) you want to add to the cart.") + } + this.shoppingCartItems.next(newItems); + } + + setShoppingCartItems(items: Item[]) { + this.shoppingCartItems.next(items); + } + + + // ------------------------- item summary ------------------------- + displayPopup(item: Item) { - this.itemSummaryVisibleSource.next(item); + this.resourceService.getItem(item.id).subscribe(extendedItem => + this.itemSummaryVisibleSource.next(extendedItem) + ) } + // ------------------------- environment label ------------------------- setEnvironment() { this.resourceService.getEnvironment().subscribe( (env: Environment) => { @@ -298,4 +479,20 @@ export class DataService { }); } + // ------------------------- header logos ------------------------- + loadLogo(type: string) { + this.resourceService.getLogo(type) + .subscribe( + (blobContent) => { + let urlCreator = window.URL; + if (type == 'NTR') { + this.ntrLogoUrlSummary.next(urlCreator.createObjectURL(blobContent)); + } else { + this.vuLogoUrlSummary.next(urlCreator.createObjectURL(blobContent)); + } + }, + err => console.error(err) + ); + } + } diff --git a/data-showcase/src/main/user-interface/src/app/services/ds-message.service.spec.ts b/data-showcase/src/main/user-interface/src/app/services/ds-message.service.spec.ts new file mode 100644 index 0000000..2b200f1 --- /dev/null +++ b/data-showcase/src/main/user-interface/src/app/services/ds-message.service.spec.ts @@ -0,0 +1,25 @@ +import {TestBed, inject} from '@angular/core/testing'; + +import {DSMessageService} from './ds-message.service'; +import {HttpModule} from "@angular/http"; +import {AppConfig} from "../config/app.config"; +import {AppConfigMock} from "../config/app.config.mock"; + +describe('DSMessageService', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpModule], + providers: [ + DSMessageService, + { + provide: AppConfig, + useClass: AppConfigMock + } + ] + }); + }); + + it('should be created', inject([DSMessageService], (service: DSMessageService) => { + expect(service).toBeTruthy(); + })); +}); diff --git a/data-showcase/src/main/user-interface/src/app/services/ds-message.service.ts b/data-showcase/src/main/user-interface/src/app/services/ds-message.service.ts new file mode 100644 index 0000000..1c4567e --- /dev/null +++ b/data-showcase/src/main/user-interface/src/app/services/ds-message.service.ts @@ -0,0 +1,18 @@ +import {Injectable} from '@angular/core'; +import { Message } from 'primeng/primeng'; +import { Subject } from 'rxjs/Subject'; + +export type MessageType = 'success' | 'info' | 'warn' | 'error'; + +@Injectable() +export class DSMessageService { + + messages = new Subject(); + + constructor() {} + + addInfoMessage(type: MessageType, infoMsg: string, detail: string) { + this.messages.next({severity: type, summary: infoMsg, detail:detail}); + } + +} diff --git a/data-showcase/src/main/user-interface/src/app/services/resource.service.ts b/data-showcase/src/main/user-interface/src/app/services/resource.service.ts index 48a7a50..b0e3bce 100644 --- a/data-showcase/src/main/user-interface/src/app/services/resource.service.ts +++ b/data-showcase/src/main/user-interface/src/app/services/resource.service.ts @@ -9,16 +9,19 @@ import {Observable} from 'rxjs/Rx'; import 'rxjs/add/operator/map'; import 'rxjs/add/operator/catch'; import {TreeNode} from "../models/tree-node"; -import {Http, Response, Headers, ResponseContentType} from '@angular/http'; +import {Http, Response, Headers, ResponseContentType, RequestOptions} from '@angular/http'; import {Endpoint} from "../models/endpoint"; import {AppConfig} from "../config/app.config"; import { + PATH_CONCEPTS, PATH_ENVIRONMENT, PATH_ITEMS, PATH_LOGOS, PATH_PROJECTS, - PATH_TREE_NODES + PATH_TREE_NODES, PATH_KEYWORDS_BY_CONCEPT } from "../constants/endpoints.constants"; import {Item} from "../models/item"; import {Project} from "../models/project"; import {Environment} from "../models/environment"; +import { Concept } from '../models/concept'; +import {ItemResponse} from "../models/itemResponse"; @Injectable() @@ -36,9 +39,17 @@ export class ResourceService { private handleError(error: Response | any) { let errMsg: string; if (error instanceof Response) { - const body = error.json() || ''; - const err = body.error || JSON.stringify(body); - errMsg = `${error.status} - ${error.statusText || ''} ${err}`; + let contentType = error.headers.get('Content-Type') || ''; + if (contentType == 'application/json') { + const body = error.json() || ''; + errMsg = body.error || JSON.stringify(body); + } else { + errMsg = `Error: ${error.statusText}`; + } + if (error.status in [0, 404]) { + console.error('Server not available.'); + return Observable.never(); + } } else { errMsg = error.message ? error.message : error.toString(); } @@ -57,18 +68,44 @@ export class ResourceService { .catch(this.handleError.bind(this)); } - getItems(): Observable { + getItem(id: number): Observable { let headers = new Headers(); - let url = this.endpoint.apiUrl + PATH_ITEMS; + let url = `${this.endpoint.apiUrl}${PATH_ITEMS}/${id}`; return this.http.get(url, { headers: headers }) - .map((response: Response) => response.json().items as Item[]) + .map((response: Response) => response.json() as Item) + .catch(this.handleError.bind(this)); + } + + + getItems(firstResult: number, maxResults: number, order?: string,propertyName?: string, + conceptCodes?: string[], projects?: string[], jsonSearchQuery?: Object): Observable { + + let headers = new Headers(); + headers.append('Content-Type', 'application/json'); + + const options = new RequestOptions({headers: headers}); + const url = this.endpoint.apiUrl + PATH_ITEMS; + + let body = { + conceptCodes: conceptCodes, + projects: projects, + searchQuery: jsonSearchQuery, + firstResult: firstResult, + maxResults: maxResults, + order: order, + propertyName: propertyName + }; + + // use POST because of url length limits in some of the browsers (limit of characters) + return this.http.post(url, body, options) + .map((res: Response) => res.json() as ItemResponse) .catch(this.handleError.bind(this)); } - getProjects(): Observable { + getAllProjects(): Observable { let headers = new Headers(); let url = this.endpoint.apiUrl + PATH_PROJECTS; @@ -79,6 +116,49 @@ export class ResourceService { .catch(this.handleError.bind(this)); } + getProjects(conceptCodes?: string[], jsonSearchQuery?: Object): Observable { + let headers = new Headers(); + headers.append('Content-Type', 'application/json'); + + const options = new RequestOptions({headers: headers}); + const url = this.endpoint.apiUrl + PATH_PROJECTS; + let body = null; + + if(conceptCodes || jsonSearchQuery) { + body = { + conceptCodes: conceptCodes, + searchQuery: jsonSearchQuery + } + } + + // use POST because of url length limits in some of the browsers (limit of characters) + return this.http.post(url, body, options) + .map((res: Response) => res.json().projects as Project[]) + .catch(this.handleError.bind(this)); + } + + getKeywords(conceptCode: string): Observable { + let headers = new Headers(); + let url = this.endpoint.apiUrl + PATH_KEYWORDS_BY_CONCEPT + "/" + conceptCode; + + return this.http.get(url, { + headers: headers + }) + .map((response: Response) => response.json().keywords as string[]) + .catch(this.handleError.bind(this)); + } + + getConcepts(): Observable { + let headers = new Headers(); + let url = this.endpoint.apiUrl + PATH_CONCEPTS; + + return this.http.get(url, { + headers: headers + }) + .map((response: Response) => response.json().concepts as Concept[]) + .catch(this.handleError.bind(this)); + } + getLogo(type: string): Observable { let headers = new Headers(); let url = this.endpoint.apiUrl + PATH_LOGOS +"/" + type; diff --git a/data-showcase/src/main/user-interface/src/app/services/search-parser.service.spec.ts b/data-showcase/src/main/user-interface/src/app/services/search-parser.service.spec.ts new file mode 100644 index 0000000..4421a69 --- /dev/null +++ b/data-showcase/src/main/user-interface/src/app/services/search-parser.service.spec.ts @@ -0,0 +1,15 @@ +import { TestBed, inject } from '@angular/core/testing'; + +import { SearchParserService } from './search-parser.service'; + +describe('SearchParserService', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [SearchParserService] + }); + }); + + it('should be created', inject([SearchParserService], (service: SearchParserService) => { + expect(service).toBeTruthy(); + })); +}); diff --git a/data-showcase/src/main/user-interface/src/app/services/search-parser.service.ts b/data-showcase/src/main/user-interface/src/app/services/search-parser.service.ts new file mode 100644 index 0000000..fb2ce3c --- /dev/null +++ b/data-showcase/src/main/user-interface/src/app/services/search-parser.service.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@angular/core'; +import { SearchTextParser } from '../search-text-parser'; +import { SearchQuery } from "../search-text-parser/search-query"; + +const valueOperators: Set = new Set(['=', '!=', 'like', 'contains']); + +@Injectable() +export class SearchParserService { + + constructor() { + } + + static toHtml(query: SearchQuery): string { + if (query == null) { + return ''; + } else if (query.type == 'string') { + return query.value; + } else if (query.type == 'not') { + let args = query.values.map(SearchParserService.toHtml).join(' '); + return `not (${args})`; + } if (query.type == 'and' || query.type == 'or') { + return '(' + query.values.map(SearchParserService.toHtml).join(`) ${query.type} (`) + ')'; + } else if (valueOperators.has(query.type)) { + let property = query.value ? `${query.value} `: ''; + let args = query.values.map(SearchParserService.toHtml).join(' '); + return `${property}${query.type} ${args}`; + } else { + return query.values.map(SearchParserService.toHtml).join(' '); + } + } + + /* Generate binary tree with a logic query string as input + * and parse it to JSON object, + * using logic-query-parser library*/ + static parse(text: string) : SearchQuery { + if (text == null || text.length == 0) { + return null; + } + let parser = new SearchTextParser(); + let binaryTree = parser.parse(text); + let query = parser.flatten(binaryTree) as SearchQuery; + console.debug(`Query`, query); + return query; + } + +} diff --git a/data-showcase/src/main/user-interface/src/app/shopping-cart/shopping-cart.component.css b/data-showcase/src/main/user-interface/src/app/shopping-cart/shopping-cart.component.css index da6509e..dc51a15 100644 --- a/data-showcase/src/main/user-interface/src/app/shopping-cart/shopping-cart.component.css +++ b/data-showcase/src/main/user-interface/src/app/shopping-cart/shopping-cart.component.css @@ -8,9 +8,10 @@ font-size:20px; padding: 0; cursor:pointer; + box-shadow: none; } -.table_content { +.table-content { text-align: left !important; } .ui-dialog-footer { diff --git a/data-showcase/src/main/user-interface/src/app/shopping-cart/shopping-cart.component.html b/data-showcase/src/main/user-interface/src/app/shopping-cart/shopping-cart.component.html index b80bac5..e62ec7c 100644 --- a/data-showcase/src/main/user-interface/src/app/shopping-cart/shopping-cart.component.html +++ b/data-showcase/src/main/user-interface/src/app/shopping-cart/shopping-cart.component.html @@ -4,7 +4,7 @@ ~ (see accompanying file LICENSE). --> -
Shopping Cart is empty. @@ -13,8 +13,10 @@ + [style]="table-content" + [rows]="rowsPerPage" [paginator]="true" + (onSort)="changeSort($event)" + [(first)]="firstOnPage"> @@ -35,7 +37,7 @@ [sortable]="true"> - diff --git a/data-showcase/src/main/user-interface/src/app/shopping-cart/shopping-cart.component.spec.ts b/data-showcase/src/main/user-interface/src/app/shopping-cart/shopping-cart.component.spec.ts index 7aa761a..380470b 100644 --- a/data-showcase/src/main/user-interface/src/app/shopping-cart/shopping-cart.component.spec.ts +++ b/data-showcase/src/main/user-interface/src/app/shopping-cart/shopping-cart.component.spec.ts @@ -8,7 +8,7 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { ShoppingCartComponent } from './shopping-cart.component'; import {FormsModule} from "@angular/forms"; -import {DataTableModule, DialogModule, PanelModule} from "primeng/primeng"; +import {DataTableModule, DialogModule, GrowlModule, PanelModule} from "primeng/primeng"; import {DataService} from "../services/data.service"; import {ResourceService} from "../services/resource.service"; import {AppConfig} from "../config/app.config"; @@ -17,6 +17,8 @@ import {initConfig} from "../app.module"; import {HttpModule} from "@angular/http"; import {AppConfigMock} from "../config/app.config.mock"; import {BrowserAnimationsModule} from "@angular/platform-browser/animations"; +import {DSMessageService} from "../services/ds-message.service"; +import {MessageService} from "primeng/components/common/messageservice"; describe('ShoppingCartComponent', () => { let component: ShoppingCartComponent; @@ -36,6 +38,8 @@ describe('ShoppingCartComponent', () => { providers: [ DataService, ResourceService, + DSMessageService, + MessageService, { provide: AppConfig, useClass: AppConfigMock diff --git a/data-showcase/src/main/user-interface/src/app/shopping-cart/shopping-cart.component.ts b/data-showcase/src/main/user-interface/src/app/shopping-cart/shopping-cart.component.ts index 3f123a1..4252da8 100644 --- a/data-showcase/src/main/user-interface/src/app/shopping-cart/shopping-cart.component.ts +++ b/data-showcase/src/main/user-interface/src/app/shopping-cart/shopping-cart.component.ts @@ -4,10 +4,11 @@ * (see accompanying file LICENSE). */ -import { Component, OnInit } from '@angular/core'; +import {Component, OnInit, ViewChild} from '@angular/core'; import {DataService} from "../services/data.service"; import {Item} from "../models/item"; import {saveAs} from "file-saver"; +import {DataTable} from "primeng/primeng"; @Component({ selector: 'app-shopping-cart', @@ -16,11 +17,14 @@ import {saveAs} from "file-saver"; }) export class ShoppingCartComponent implements OnInit { + @ViewChild('itemTable') dataTable: DataTable; display: boolean = false; items: Item[] = []; pathSelection: string[] = []; fileName: string; disabled: boolean = true; + firstOnPage: number = 0; + rowsPerPage: number = 10; constructor(private dataService: DataService) { dataService.shoppingCartItems.subscribe( @@ -40,6 +44,10 @@ export class ShoppingCartComponent implements OnInit { this.display = true; } + changeSort(event) { + this.dataTable['first'] = this.firstOnPage ; + } + deleteItem(itemToDelete: Item) { this.items = this.items.filter(item => item !== itemToDelete); this.dataService.setShoppingCartItems(this.items); diff --git a/data-showcase/src/main/user-interface/src/app/text-filter/text-filter.component.html b/data-showcase/src/main/user-interface/src/app/text-filter/text-filter.component.html deleted file mode 100644 index b13180d..0000000 --- a/data-showcase/src/main/user-interface/src/app/text-filter/text-filter.component.html +++ /dev/null @@ -1,14 +0,0 @@ - - -
- - -
diff --git a/data-showcase/src/main/user-interface/src/app/text-filter/text-filter.component.ts b/data-showcase/src/main/user-interface/src/app/text-filter/text-filter.component.ts deleted file mode 100644 index 5d9069f..0000000 --- a/data-showcase/src/main/user-interface/src/app/text-filter/text-filter.component.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright (c) 2017 The Hyve B.V. - * This file is distributed under the GNU Affero General Public License - * (see accompanying file LICENSE). - */ - -import { Component, OnInit } from '@angular/core'; -import {DataService} from "../services/data.service"; - -@Component({ - selector: 'app-text-filter', - templateUrl: './text-filter.component.html', - styleUrls: ['./text-filter.component.css'] -}) -export class TextFilterComponent implements OnInit { - - globalFilter: string; - - constructor(public dataService: DataService) { - } - - ngOnInit() { - } - - onFiltering(event) { - this.dataService.setGlobalFilter(this.globalFilter); - console.log('global filter: ', this.globalFilter); - } -} diff --git a/data-showcase/src/main/user-interface/src/app/text-filter/text-filter.module.ts b/data-showcase/src/main/user-interface/src/app/text-filter/text-filter.module.ts deleted file mode 100644 index 731a0ad..0000000 --- a/data-showcase/src/main/user-interface/src/app/text-filter/text-filter.module.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright (c) 2017 The Hyve B.V. - * This file is distributed under the GNU Affero General Public License - * (see accompanying file LICENSE). - */ - -import { NgModule } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import {AutoCompleteModule} from "primeng/primeng"; -import {TextFilterComponent} from "./text-filter.component"; -import {FormsModule} from "@angular/forms"; - -@NgModule({ - imports: [ - CommonModule, - FormsModule, - AutoCompleteModule - ], - declarations: [TextFilterComponent], - exports: [TextFilterComponent] -}) -export class TextFilterModule { } diff --git a/data-showcase/src/main/user-interface/src/app/tree-nodes/tree-nodes.component.css b/data-showcase/src/main/user-interface/src/app/tree-nodes/tree-nodes.component.css index 104f54e..f3e51ea 100644 --- a/data-showcase/src/main/user-interface/src/app/tree-nodes/tree-nodes.component.css +++ b/data-showcase/src/main/user-interface/src/app/tree-nodes/tree-nodes.component.css @@ -4,3 +4,8 @@ * (see accompanying file LICENSE). */ +.tree-header { + color: #00889C; + font-weight: bold; + padding-top: 15px; +} diff --git a/data-showcase/src/main/user-interface/src/app/tree-nodes/tree-nodes.component.html b/data-showcase/src/main/user-interface/src/app/tree-nodes/tree-nodes.component.html index 4b5896b..7596f2a 100644 --- a/data-showcase/src/main/user-interface/src/app/tree-nodes/tree-nodes.component.html +++ b/data-showcase/src/main/user-interface/src/app/tree-nodes/tree-nodes.component.html @@ -5,7 +5,7 @@ -->
- +

Select domain

diff --git a/data-showcase/src/main/user-interface/src/app/tree-nodes/tree-nodes.component.spec.ts b/data-showcase/src/main/user-interface/src/app/tree-nodes/tree-nodes.component.spec.ts index 7daa770..ae68de6 100644 --- a/data-showcase/src/main/user-interface/src/app/tree-nodes/tree-nodes.component.spec.ts +++ b/data-showcase/src/main/user-interface/src/app/tree-nodes/tree-nodes.component.spec.ts @@ -16,6 +16,8 @@ import {BrowserAnimationsModule} from "@angular/platform-browser/animations"; import {HttpModule} from "@angular/http"; import {AppConfig} from "../config/app.config"; import {AppConfigMock} from "../config/app.config.mock"; +import {DSMessageService} from "../services/ds-message.service"; +import {MessageService} from "primeng/components/common/messageservice"; describe('TreeNodesComponent', () => { let component: TreeNodesComponent; @@ -36,6 +38,8 @@ describe('TreeNodesComponent', () => { providers: [ DataService, ResourceService, + DSMessageService, + MessageService, { provide: AppConfig, useClass: AppConfigMock diff --git a/data-showcase/src/main/user-interface/src/app/tree-nodes/tree-nodes.component.ts b/data-showcase/src/main/user-interface/src/app/tree-nodes/tree-nodes.component.ts index 3c38f92..fe946a6 100644 --- a/data-showcase/src/main/user-interface/src/app/tree-nodes/tree-nodes.component.ts +++ b/data-showcase/src/main/user-interface/src/app/tree-nodes/tree-nodes.component.ts @@ -39,8 +39,10 @@ export class TreeNodesComponent implements OnInit, AfterViewInit { expansionStatus: any; // the search term in the text input box to filter the tree searchTerm: string; - + // currently selected node selectedNode: Object; + // the delay before triggering updating methods + delay: number; constructor(private element: ElementRef, public dataService: DataService) { @@ -50,6 +52,7 @@ export class TreeNodesComponent implements OnInit, AfterViewInit { treeNode: null }; this.treeNodes = dataService.treeNodes; + this.delay = 500; } ngOnInit() { @@ -77,7 +80,7 @@ export class TreeNodesComponent implements OnInit, AfterViewInit { selectNode(event) { if (event.node) { - this.dataService.updateItemTable(event.node) + this.dataService.selectTreeNode(event.node) } } @@ -87,7 +90,7 @@ export class TreeNodesComponent implements OnInit, AfterViewInit { // https://github.com/primefaces/primeng/issues/2882 this.autocompleteCharge.inputEL.nativeElement.value = ''; this.searchTerm = ''; - this.dataService.updateItemTable(null); + this.dataService.selectTreeNode(null); this.onFiltering(event); } @@ -137,6 +140,7 @@ export class TreeNodesComponent implements OnInit, AfterViewInit { this.treeNodes = this.filterTreeNodes(this.dataService.treeNodes, 'label', filterWord).matchingTreeNodes; console.log('found tree nodes: ', this.treeNodes); } + this.removePrimeNgAutocompleteLoader(); } update() { @@ -170,4 +174,16 @@ export class TreeNodesComponent implements OnInit, AfterViewInit { } } + /* + PrimeNG library is attaching a spinner (.ui-autocomplete-loader) which is not automatically + removed after search is finished. + */ + removePrimeNgAutocompleteLoader() { + window.setTimeout((function () { + let loaderIcon = this.element.nativeElement.querySelector('.ui-autocomplete-loader'); + if (loaderIcon) { + loaderIcon.remove(); + } + }).bind(this), this.delay); + } } diff --git a/data-showcase/src/main/user-interface/src/styles.css b/data-showcase/src/main/user-interface/src/styles.css index e67047d..c0f8a59 100644 --- a/data-showcase/src/main/user-interface/src/styles.css +++ b/data-showcase/src/main/user-interface/src/styles.css @@ -45,8 +45,7 @@ body .ds-tree-container { */ .ds-tree-filter-input { border-bottom: 1px solid darkgray; - padding-top: 10px; - height: 50px; + height: 40px; font-size: small !important; } .ds-tree-filter-input > input{ @@ -73,7 +72,7 @@ body .ds-spinner.loading { * Change the font size of the legend - checkbox-filter */ legend { - font-size: larger; + font-size: medium; } /* @@ -159,6 +158,9 @@ body .ui-panel .ui-panel-titlebar .ui-panel-titlebar-icon { */ body .ui-panel .ui-panel-titlebar { background-color: #00889C; + height: 40px; + line-height: 40px; + padding-top: 0px } body .ui-panel, body .ui-fieldset, @@ -174,7 +176,7 @@ body .ui-datatable { box-shadow: 0 2px 2px 0 rgba(0,0,0,0.14),0 3px 1px -2px rgba(0,0,0,0.12),0 1px 5px 0 rgba(0,0,0,0.2); } -body .input:focus { +body .ui-inputtext:focus { border-color: #00889C !important; } @@ -274,10 +276,24 @@ body .ui-inputgroup .ui-inputtext.ui-widget.ui-state-default.ui-corner-all { height: 36px; } -.text-filter-container .ui-inputtext.ui-widget.ui-state-default.ui-corner-all { - width: 500px; +/* Global text filter */ +.text-filter-container .ui-inputtext { + width: 900px; height: 36px; + padding: 0; +} + +.filter-container .ui-panel .ui-panel-content { + padding-top: 5px; + padding-bottom: 5px; +} + +/* Table column wrapping */ +.ui-datatable, +.ui-datatable-resizable thead th, +.ui-datatable-resizable tbody td, +.ui-datatable-resizable tfoot td { + word-wrap: break-word; + word-break: break-all; + white-space: normal; } -/*.text-filter-container .ui-inputtext.ui-widget.ui-state-default.ui-corner-all:focus {*/ - /*width: 500px;*/ -/*}*/ diff --git a/data-showcase/src/main/user-interface/src/tsconfig.app.json b/data-showcase/src/main/user-interface/src/tsconfig.app.json index 39ba8db..2ac34b0 100644 --- a/data-showcase/src/main/user-interface/src/tsconfig.app.json +++ b/data-showcase/src/main/user-interface/src/tsconfig.app.json @@ -4,7 +4,7 @@ "outDir": "../out-tsc/app", "baseUrl": "./", "module": "es2015", - "types": [] + "types": ["node"] }, "exclude": [ "test.ts", diff --git a/data-showcase/src/test/groovy/nl/thehyve/datashowcase/DataImportServiceSpec.groovy b/data-showcase/src/test/groovy/nl/thehyve/datashowcase/DataImportServiceSpec.groovy index f582c0c..e5cb72a 100644 --- a/data-showcase/src/test/groovy/nl/thehyve/datashowcase/DataImportServiceSpec.groovy +++ b/data-showcase/src/test/groovy/nl/thehyve/datashowcase/DataImportServiceSpec.groovy @@ -8,19 +8,20 @@ package nl.thehyve.datashowcase import grails.converters.JSON import grails.testing.mixin.integration.Integration +import grails.transaction.Rollback import groovy.util.logging.Slf4j import nl.thehyve.datashowcase.enumeration.NodeType import nl.thehyve.datashowcase.enumeration.VariableType import nl.thehyve.datashowcase.exception.InvalidDataException import org.grails.web.json.JSONObject +import org.hibernate.SessionFactory import org.springframework.beans.factory.annotation.Autowired -import org.springframework.transaction.annotation.Transactional import spock.lang.Requires import spock.lang.Specification @Slf4j @Integration -@Transactional +@Rollback class DataImportServiceSpec extends Specification { @Autowired @@ -29,18 +30,22 @@ class DataImportServiceSpec extends Specification { @Autowired DataService dataService + SessionFactory sessionFactory + URL jsonUrl = getClass().getClassLoader().getResource('test.json') File file = new File(jsonUrl.path) def setupData() { log.info "Clear database ..." dataService.clearDatabase() + sessionFactory.currentSession.flush() } def loadValidData() { log.info "Upload test data set ..." JSONObject json = (JSONObject)JSON.parse(file.text) dataImportService.upload(json) + sessionFactory.currentSession.flush() } def loadInvalidData() { @@ -49,6 +54,7 @@ class DataImportServiceSpec extends Specification { JSONObject json = (JSONObject)JSON.parse(file.text) log.info "Upload test data set ..." dataImportService.upload(json) + sessionFactory.currentSession.flush() } @Requires({ -> Environment.grailsEnvironmentIn(Constants.PUBLIC_ENVIRONMENTS) }) @@ -76,7 +82,7 @@ class DataImportServiceSpec extends Specification { '/Personal information/Extended information/Height'] items.every { it.publicItem == true } items*.summary == summaries - items*.summary*.values?.flatten() == values + items*.summary*.values?.flatten() as Set == values as Set items*.concept.every { concepts.indexOf(it) != -1 } items*.project.every { projects.indexOf(it) != -1 } @@ -104,7 +110,7 @@ class DataImportServiceSpec extends Specification { summaries*.patientCount == [100, 200] summaries*.observationCount == [102, 402] summaries*.dataStability == ['Committed', 'Committed'] - summaries*.values.flatten() == values + summaries*.values.flatten() as Set == values as Set treeNodes.size() == 9 treeNodes*.label.sort() == ['Personal information', 'Basic information', 'Age', 'Weight', 'Extended information', @@ -125,10 +131,10 @@ class DataImportServiceSpec extends Specification { '/Other information', '/Other information/Some information'].sort() - values.size() == 8 - values*.value.every { v -> v in ['<= 65', '> 65', '<= 175', '> 175'] } - values*.label.every { l -> l in ['Young', 'Old', 'Short', 'Tall'] } - values*.frequency.every { f -> (int)f in [35, 65, 36, 64] } + values.size() == 4 + values*.value as Set == ['<= 65', '> 65', '<= 175', '> 175'] as Set + values*.label as Set == ['Young', 'Old', 'Short', 'Tall'] as Set + values*.frequency as Set == [35, 65, 36, 64] as Set } @Requires({ -> Environment.grailsEnvironmentIn(Constants.PUBLIC_ENVIRONMENTS) }) @@ -157,7 +163,7 @@ class DataImportServiceSpec extends Specification { '/Personal information/Extended information/Height'] items.every { it.publicItem == true } items*.summary == summaries - items*.summary*.values?.flatten() == values + items*.summary*.values?.flatten() as Set == values as Set items*.concept.every { concepts.indexOf(it) != -1 } items*.project.every { projects.indexOf(it) != -1 } @@ -185,7 +191,7 @@ class DataImportServiceSpec extends Specification { summaries*.patientCount == [100, 200] summaries*.observationCount == [102, 402] summaries*.dataStability == ['Committed', 'Committed'] - summaries*.values.flatten() == values + summaries*.values.flatten() as Set == values as Set treeNodes.size() == 9 treeNodes*.label.sort() == ['Personal information', 'Basic information', 'Age', 'Weight', 'Extended information', @@ -206,7 +212,7 @@ class DataImportServiceSpec extends Specification { '/Other information', '/Other information/Some information'].sort() - values.size() == 8 + values.size() == 4 values*.value.every { v -> v in ['<= 65', '> 65', '<= 175', '> 175'] } values*.label.every { l -> l in ['Young', 'Old', 'Short', 'Tall'] } values*.frequency.every { f -> (int)f in [35, 65, 36, 64] } diff --git a/data-showcase/src/test/groovy/nl/thehyve/datashowcase/InternalItemServiceSpec.groovy b/data-showcase/src/test/groovy/nl/thehyve/datashowcase/InternalItemServiceSpec.groovy index 96ababa..e7a9c9f 100644 --- a/data-showcase/src/test/groovy/nl/thehyve/datashowcase/InternalItemServiceSpec.groovy +++ b/data-showcase/src/test/groovy/nl/thehyve/datashowcase/InternalItemServiceSpec.groovy @@ -45,13 +45,19 @@ class InternalItemServiceSpec extends Specification { setupData() when: log.info "Running test ..." - List items = itemService.items.collect { it as InternalItemRepresentation } + List items = itemService.items.collect { + itemService.getItem(it.id) as InternalItemRepresentation + } def maxValues = items*.summary.maxValue as List then: "2 items being returned, age and height, both public and internal, including aggregate values" - items.size() == 2 - items*.label == ['Age', 'Height'] - items*.publicItem == [true, false] - items*.itemPath == ['/Personal information/Basic information/Age', '/Personal information/Extended information/Height'] + items.size() == 8 + 20 + 9 + items*.labelLong as Set == ['Age at time of survey', 'Height at time of survey', 'Weight (kg)'] as Set + items*.publicItem as Set == [true, false] as Set + items*.itemPath as Set == [ + '/Personal information/Basic information/Age', + '/Personal information/Extended information/Height', + '/Personal information/Basic information/Weight' + ] as Set that(maxValues, hasItem( closeTo(99, 0.1) )) diff --git a/data-showcase/src/test/groovy/nl/thehyve/datashowcase/PublicItemServiceSpec.groovy b/data-showcase/src/test/groovy/nl/thehyve/datashowcase/PublicItemServiceSpec.groovy index 5e41d22..309b1e7 100644 --- a/data-showcase/src/test/groovy/nl/thehyve/datashowcase/PublicItemServiceSpec.groovy +++ b/data-showcase/src/test/groovy/nl/thehyve/datashowcase/PublicItemServiceSpec.groovy @@ -7,7 +7,9 @@ package nl.thehyve.datashowcase import grails.testing.mixin.integration.Integration +import grails.web.databinding.DataBinder import groovy.util.logging.Slf4j +import nl.thehyve.datashowcase.representation.SearchQueryRepresentation import org.springframework.beans.factory.annotation.Autowired import org.springframework.transaction.annotation.Transactional import spock.lang.Requires @@ -30,6 +32,16 @@ class PublicItemServiceSpec extends Specification { @Autowired TestService testService + static class SearchQueryBinder implements DataBinder {} + + static final searchQueryBinder = new SearchQueryBinder() + + static SearchQueryRepresentation parseQuery(final Map values) { + def searchQuery = new SearchQueryRepresentation() + searchQueryBinder.bindData(searchQuery, values) + return searchQuery + } + def setupData() { log.info "Clear database ..." dataService.clearDatabase() @@ -43,14 +55,132 @@ class PublicItemServiceSpec extends Specification { setupData() when: log.info "Running test ..." - def items = itemService.items + def items = itemService.getItems(0, 9999, 'asc', 'name') then: "2 items being returned" items.size() == 2 items*.name == ['ageA', 'heightB'] - items*.label == ['Age', 'Height'] + items*.labelLong == ['Age at time of survey', 'Height at time of survey'] items*.project == ['Project A', 'Project B'] items*.itemPath == ['/Personal information/Basic information/Age', '/Personal information/Extended information/Height'] that(items*.concept, hasItem('age')) } + @Requires({ -> Environment.grailsEnvironmentIn(Constants.PUBLIC_ENVIRONMENTS) }) + void "test free text filter"() { + given: + int firstResult = 0 + int maxResults = 9999 + String order = 'asc' + String propertyName = 'name' + setupData() + SearchQueryRepresentation searchQuery + + when: "Filter on single word without field and operator specified" + searchQuery = parseQuery(["type": "string", "value": "ageA"]) + def items = itemService.getItems(firstResult, maxResults, order, propertyName, [] as Set, [] as Set, searchQuery) + then: + items.size() == 1 + items*.name == ['ageA'] + + when: "Filter on words conjunction (OR) without field and operator specified" + searchQuery = parseQuery(["type": "or", "values": [ + ["type": "string", "value": "ageA"], + ["type": "string", "value": "heightB"] + ]]) + items = itemService.getItems(firstResult, maxResults, order, propertyName, [] as Set, [] as Set, searchQuery) + then: + items.size() == 2 + items*.name as Set == ['ageA', 'heightB'] as Set + + when: "Filter on single word without field, operator (LIKE) is specified" + searchQuery = parseQuery(["type" : "like", + "values": [ + ["type": "string", "value": "a_e%"] + ]]) + items = itemService.getItems(firstResult, maxResults, order, propertyName, [] as Set, [] as Set, searchQuery) + then: + items.size() == 1 + items*.name == ['ageA'] + + when: "Filter on single word with specified list of fields and operator ('keyword' IN '[]')" + searchQuery = parseQuery(["type": "in", "value": "keywords", "values": [ + ["type": "string", "value": "Personal information"], + ["type": "string", "value": "Family related"]] + ]) + items = itemService.getItems(firstResult, maxResults, order, propertyName, [] as Set, [] as Set, searchQuery) + then: + items.size() == 2 + items*.name as Set == ['ageA', 'heightB'] as Set + + when: "Filter on words disjunction (AND) and '=' operator ('field1=val1 OR field2=val2')" + searchQuery = parseQuery(["type": "and", "values": [ + ["type": "=", "value": "name", "values": [ + ["type": "string", "value": "ageA"] + ]], + ["type": "=", "value": "label", "values": [ + ["type": "string", "value": "ageB"] + ]] + ]]) + items = itemService.getItems(firstResult, maxResults, order, propertyName, [] as Set, [] as Set, searchQuery) + then: + items.size() == 0 + + when: "Invalid field name specified" + def invalidProperty = "test_field" + searchQuery = parseQuery(["type": "=", "value": invalidProperty, "values": [ + ["type": "string", "value": "value"] + ]]) + itemService.getItems(firstResult, maxResults, order, propertyName, [] as Set, [] as Set, searchQuery) + then: "Exception is thrown" + IllegalArgumentException ex = thrown() + ex.message == "Unsupported property: ${invalidProperty}." + + when: "Invalid operator specified" + def invalidOperator = "~" + searchQuery = parseQuery(["type": invalidOperator, "value": "label", "values": [ + ["type": "string", "value": "value"] + ]]) + itemService.getItems(firstResult, maxResults, order, propertyName, [] as Set, [] as Set, searchQuery) + then: "Exception is thrown" + IllegalArgumentException ex2 = thrown() + ex2.message == "Unsupported type: ${invalidOperator}." + + when: "Brackets are used in junction query" + // 'name = "ageA" OR (name = "heightA" AND label = "height")' + def searchQuery1 = parseQuery(["type": "or", "values": [ + ["type": "=", "value": "name", "values": [ + ["type": "string", "value": "ageA"] + ]], + ["type": "and", "values": [ + ["type": "=", "value": "name", "values": [ + ["type": "string", "value": "heightB"] + ]], + ["type": "=", "value": "labelNl", "values": [ + ["type": "string", "value": "hoogte"] + ]] + ]] + ]]) + + // '(name = "ageA" OR name = "heightA") AND label = "height"' + def searchQuery2 = parseQuery(["type": "and", "values": [ + ["type": "or", "values": [ + ["type": "=", "value": "name", "values": [ + ["type": "string", "value": "ageA"] + ]], + ["type": "=", "value": "name", "values": [ + ["type": "string", "value": "heightB"] + ]] + ]], + ["type": "=", "value": "labelNl", "values": [ + ["type": "string", "value": "hoogte"] + ]] + ]]) + def itemsForQuery1 = itemService.getItems(firstResult, maxResults, order, propertyName, [] as Set, [] as Set, searchQuery1) + def itemsForQuery2 = itemService.getItems(firstResult, maxResults, order, propertyName, [] as Set, [] as Set, searchQuery2) + then: "Results are different, depending on the distribution of brackets" + itemsForQuery1 != itemsForQuery2 + itemsForQuery1.size() == 2 + itemsForQuery2.size() == 1 + } + }