diff --git a/.prettierignore b/.prettierignore index ca2895400a..f87b36dc84 100644 --- a/.prettierignore +++ b/.prettierignore @@ -41,6 +41,7 @@ node_modules /out-tsc /coverage /docs/compodoc +/docker-compose /junit.xml /eslint-rules/dist /schematics/src/**/files/** diff --git a/.vscode/intershop.txt b/.vscode/intershop.txt index 84fb6a4a86..29d3a9823d 100644 --- a/.vscode/intershop.txt +++ b/.vscode/intershop.txt @@ -175,6 +175,7 @@ signup sitekey sku skus +sparque spgid srcache sslmode diff --git a/docker-compose/compose_icm.yml b/docker-compose/compose_icm.yml new file mode 100644 index 0000000000..92ea8d762d --- /dev/null +++ b/docker-compose/compose_icm.yml @@ -0,0 +1,174 @@ +# docker-compose for an full deployment with following components +# - ICM with necessary DB, WA and WAA_IMAGE +# - utils like Mailserver; Dozzle + +name: ICM + +services: + # ICM container settings + icmDB: + image: ${MSSQLDOCKERIMAGE} + environment: + ACCEPT_EULA: Y + SA_PASSWORD: 1nstershop5A + MSSQL_PID: Developer + RECREATE_DB: 'TRUE' + RECREATE_USER: 'TRUE' + ICM_DB_NAME: ${JDBCDBNAME} + ICM_DB_USER: ${JDBCUSER} + ICM_DB_PASSWORD: ${JDBCPASSWORD} + ports: + - '1433:1433' + healthcheck: + test: + [ + 'CMD', + '/opt/mssql-tools/bin/sqlcmd', + '-b', + '-S', + 'localhost', + '-U', + 'intershop', + '-P', + 'intershop', + '-q', + 'quit', + ] + interval: 30s + timeout: 5s + retries: 8 + start_period: 120s + volumes: + - dbdata:/var/opt/mssql/data + + headless: + image: ${HEADLESS_IMAGE} + container_name: headless + volumes: + - customizations:/customizations + + demodata: + image: ${DEMODATA_IMAGE} + container_name: demodata + volumes: + - customizations:/customizations + + customize-with-solr: + image: ${CUSTOMIZATION_IMAGE_SOLR} + volumes: + - customizations:/customizations + + icm-as: + image: ${APPSERVER_IMAGE} + container_name: icm-as + cpuset: ${CPU_SET} + depends_on: + icmDB: + condition: service_healthy + environment: + SERVER_NAME: '${SERVER_NAME}' + CARTRIDGE_LIST: '${CARTRIDGE_LIST}' + INTERSHOP_DATABASETYPE: '${DBTYPE}' + INTERSHOP_JDBC_URL: '${JDBCURL}' + INTERSHOP_JDBC_USER: '${JDBCUSER}' + INTERSHOP_JDBC_PASSWORD: '${JDBCPASSWORD}' + INTERSHOP_SERVLETENGINE_CONNECTOR_PORT: 7743 + DEBUG_ICM: '${DEBUG_ICM}' + INTERSHOP_WEBSERVERURL: 'http://icmwebserver:${WA_HTTP_PORT}' + INTERSHOP_WEBSERVERSECUREURL: 'https://icmwebserver:${WA_HTTPS_PORT}' + INTERSHOP_SMTPSERVER: mail + MAIL_SMTP_HOST: mail + MAIL_SMTP_PORT: '1025' + MAIL_CLIENT_API_PORT: '8026' + MAIL_SMTP_MAILHOG_ENABLED: 'true' + INTERSHOP_ENCRYPTION_STRICTMODE_ENABLED: 'false' + SOLR_ZOOKEEPERHOSTLIST: 'solr:9983' + SOLR_CLUSTERINDEXPREFIX: 'my-resp-demo-test' + + volumes: + - ${SITES_DIR}:/intershop/sites + - customizations:/intershop/customizations + - system-conf:/intershop/system-conf + ports: + - '7743:7743' + #debug port + - '7746:7746' + #jmx port + - '7747:7747' + + icmwebserver: + image: ${WA_IMAGE} + container_name: icmwebserver + ports: + - '${WA_HTTP_PORT}:8080' + - '${WA_HTTPS_PORT}:8443' + depends_on: + - 'icm-as' + volumes: + - waproperties:/intershop/webadapter-conf + - pagecache:/intershop/pagecache + - walogs:/intershop/logs + environment: + # a configuration servlet must be available before the webadapter starts + ICM_ICMSERVLETURLS: 'cs.url.0=http://icm-as:7743/servlet/ConfigurationServlet' + + icmwebadapteragent: + image: ${WAA_IMAGE} + container_name: icmwebadapteragent + depends_on: + - 'icmwebserver' + volumes: + - waproperties:/intershop/webadapter-conf + - pagecache:/intershop/pagecache + - walogs:/intershop/logs + environment: + # a configuration servlet must be available before the webadapteragent starts + ICM_AS_SERVICE: 'http://icm-as:7743/servlet/ConfigurationServlet' + + debugging: + image: ${DEBUGGING_IMAGE} + container_name: debugging + command: ['sh', '-c', 'apt-get --yes update && apt-get --yes install ${DEBUGGING_PACKAGES} && sleep infinity'] + volumes: + - sites:/intershop/sites + - system-conf:/intershop/system-conf + - waproperties:/intershop/webadapter-conf + - pagecache:/intershop/pagecache + - walogs:/intershop/logs + + #util containers + mail: + image: mailhog/mailhog + ports: + - '8026:8025' + #- "1025:1025" + + solr: + image: ${SOLRDOCKERIMAGE} + container_name: solr + environment: + SOLR_OPTS: '-Dsolr.disableConfigSetsCreateAuthChecks=true' + ports: + - '8983:8983' + command: 'solr -cloud -f' + + dozzle: + container_name: dozzle + image: amir20/dozzle:latest + volumes: + - /var/run/docker.sock:/var/run/docker.sock + ports: + - 9999:8080 + +volumes: + customizations: + system-conf: + waproperties: + pagecache: + walogs: + dbdata: + sites: + +networks: + default: + name: ${NETWORK_NAME} diff --git a/docker-compose/compose_sparque.yml b/docker-compose/compose_sparque.yml new file mode 100644 index 0000000000..740ec009db --- /dev/null +++ b/docker-compose/compose_sparque.yml @@ -0,0 +1,253 @@ +# docker-compose for an full deployment with following components +# - ICM with necessary DB, WA and WAA_IMAGE +# - Sparque Wrapper and Sparque Policy enforcer +# - utils like Mailserver; Dozzle + +name: SPARQUE(icm,wrapper,policy-enforcer) + +services: + # policy enforcer container settings + policy_enforcer: + container_name: policy_enforcer + hostname: search-sparque-policy-enforcer + image: ${POLICY_ENFORCER_IMAGE} + ports: + - '27090:7090' + - '28090:8090' + - '29090:9090' + environment: + - SPRING_CONFIG_LOCATION=/app/application.yml + volumes: + - ${APP_YML}:/app/application.yml + + # sparque wrapper container settings + redis: + image: 'bitnami/redis:latest' + environment: + - ALLOW_EMPTY_PASSWORD=yes + ports: + - '6379:6379' + volumes: + - redis-data:/bitnami/redis/data + db: + image: postgres:latest + container_name: postgres_wrapper_db + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: 'Zb641308809576!' + POSTGRES_DB: sparque_wrapper_acc + ports: + - '5433:5432' + volumes: + - postgres_data:/var/lib/postgresql/data + wrapper_legacy: + image: ${SPARQUE_WRAPPER_IMAGE} + environment: + - ASPNETCORE_ENVIRONMENT=Development + - SPARQUE_CONFIG=default + - SPARQUE_MAGIC_CLIENT_ID=8OQKJtMlmADPDzOVvLrqQzXjaflgducc + - SPARQUE_MAGIC_CLIENT_SECRET=3hhGrhVgjt22SqTLdgN-vOf-GfYzWvsKHBSXGHrozt-mfT0wDgAPGO2yUUWCzth8 + - SPARQUE_API_NO_ENDPOINT_CACHE_EXPIRATION_MINUTES=720 + - REDIS_TIMEOUT_MS=2500 + - REDIS_URL=redis:6379 + - REDIS_DATABASE=0 + - REDIS_CIRCUIT_BRK_TRIGGER_EXCEPTION_COUNT=3 + - REDIS_CIRCUIT_BRK_SEC=10 + - SPARQUE_API_ACCESS_TOKEN_ENCRYPTION_KEY=c6d3089cacaa4abc990d55b294e93320 + - SPARQUE_API_BASE_URL=https://rest.sparque.ai/1 + - CACHE_SHOULD_CACHE=${SPARQUE_CACHE_SHOULD_CACHE} + - CACHE_FILTERED_REQUESTS=false + - CACHE_DURATION_MINUTES_SEARCH=30 + - CACHE_DURATION_MINUTES_SUGGESTIONS=30 + - CACHE_DURATION_MINUTES_PRODUCTS=30 + - CACHE_DURATION_MINUTES_RECOMMENDATIONS=30 + - CACHE_DURATION_MINUTES_FACETS=30 + - SEARCH_MAX_CONCURRENT_SEARCHES=100 + - SEARCH_MAX_RESULTS=100 + - SEARCH_DEFAULT_RESULTS=20 + - JWT_KEY=f7960c6887a943e499c6118c5fc5ace8 + - JWT_ISSUER=https://login.sparque.ai + - JWT_AUDIENCE=https://rest.sparque.ai + - JWT_TOKEN_URL=https://login.sparque.ai/oauth/token + - WORKSPACES_WITHOUT_PERSONALIZATION=${SPARQUE_WORKSPACES_WITHOUT_PERSONALIZATION} + - WORKSPACE_ENDPOINTSETS_WITHOUT_AUTH=${SPARQUE_WORKSPACE_ENDPOINTSETS_WITHOUT_AUTH} + - LOGLEVEL=Information + restart: always + ports: + - '${SPARQUE_WRAPPER_PORT}:5755' + depends_on: + - redis + + # ICM container settings + icmDB: + image: ${MSSQLDOCKERIMAGE} + environment: + ACCEPT_EULA: Y + SA_PASSWORD: 1nstershop5A + MSSQL_PID: Developer + RECREATE_DB: 'TRUE' + RECREATE_USER: 'TRUE' + ICM_DB_NAME: ${JDBCDBNAME} + ICM_DB_USER: ${JDBCUSER} + ICM_DB_PASSWORD: ${JDBCPASSWORD} + ports: + - '1433:1433' + healthcheck: + test: + [ + 'CMD', + '/opt/mssql-tools/bin/sqlcmd', + '-b', + '-S', + 'localhost', + '-U', + 'intershop', + '-P', + 'intershop', + '-q', + 'quit', + ] + interval: 30s + timeout: 5s + retries: 8 + start_period: 120s + volumes: + - dbdata:/var/opt/mssql/data + + headless: + image: ${HEADLESS_IMAGE} + container_name: headless + volumes: + - customizations:/customizations + + demodata: + image: ${DEMODATA_IMAGE} + container_name: demodata + volumes: + - customizations:/customizations + + customize-with-solr: + image: ${CUSTOMIZATION_IMAGE_SOLR} + volumes: + - customizations:/customizations + + icm-as: + image: ${APPSERVER_IMAGE} + container_name: icm-as + cpuset: ${CPU_SET} + depends_on: + icmDB: + condition: service_healthy + environment: + SERVER_NAME: '${SERVER_NAME}' + CARTRIDGE_LIST: '${CARTRIDGE_LIST}' + INTERSHOP_DATABASETYPE: '${DBTYPE}' + INTERSHOP_JDBC_URL: '${JDBCURL}' + INTERSHOP_JDBC_USER: '${JDBCUSER}' + INTERSHOP_JDBC_PASSWORD: '${JDBCPASSWORD}' + INTERSHOP_SERVLETENGINE_CONNECTOR_PORT: 7743 + DEBUG_ICM: '${DEBUG_ICM}' + INTERSHOP_WEBSERVERURL: 'http://icmwebserver:${WA_HTTP_PORT}' + INTERSHOP_WEBSERVERSECUREURL: 'https://icmwebserver:${WA_HTTPS_PORT}' + INTERSHOP_CARTRIDGES_REST_TOKENKIND: 'JWT' + INTERSHOP_SEARCH_SPARQUE: 'true' + INTERSHOP_SMTPSERVER: mail + MAIL_SMTP_HOST: mail + MAIL_SMTP_PORT: '1025' + MAIL_CLIENT_API_PORT: '8026' + MAIL_SMTP_MAILHOG_ENABLED: 'true' + INTERSHOP_ENCRYPTION_STRICTMODE_ENABLED: 'false' + SOLR_ZOOKEEPERHOSTLIST: 'solr:9983' + SOLR_CLUSTERINDEXPREFIX: 'my-resp-demo-test' + + volumes: + - ${SITES_DIR}:/intershop/sites + - customizations:/intershop/customizations + - system-conf:/intershop/system-conf + ports: + - '7743:7743' + #debug port + - '7746:7746' + #jmx port + - '7747:7747' + + icmwebserver: + image: ${WA_IMAGE} + container_name: icmwebserver + ports: + - '${WA_HTTP_PORT}:8080' + - '${WA_HTTPS_PORT}:8443' + depends_on: + - 'icm-as' + volumes: + - waproperties:/intershop/webadapter-conf + - pagecache:/intershop/pagecache + - walogs:/intershop/logs + environment: + # a configuration servlet must be available before the webadapter starts + ICM_ICMSERVLETURLS: 'cs.url.0=http://icm-as:7743/servlet/ConfigurationServlet' + + icmwebadapteragent: + image: ${WAA_IMAGE} + container_name: icmwebadapteragent + depends_on: + - 'icmwebserver' + volumes: + - waproperties:/intershop/webadapter-conf + - pagecache:/intershop/pagecache + - walogs:/intershop/logs + environment: + # a configuration servlet must be available before the webadapteragent starts + ICM_AS_SERVICE: 'http://icm-as:7743/servlet/ConfigurationServlet' + + debugging: + image: ${DEBUGGING_IMAGE} + container_name: debugging + command: ['sh', '-c', 'apt-get --yes update && apt-get --yes install ${DEBUGGING_PACKAGES} && sleep infinity'] + volumes: + - sites:/intershop/sites + - system-conf:/intershop/system-conf + - waproperties:/intershop/webadapter-conf + - pagecache:/intershop/pagecache + - walogs:/intershop/logs + + #util containers + mail: + image: mailhog/mailhog + ports: + - '8026:8025' + #- "1025:1025" + + solr: + image: ${SOLRDOCKERIMAGE} + container_name: solr + environment: + SOLR_OPTS: '-Dsolr.disableConfigSetsCreateAuthChecks=true' + ports: + - '8983:8983' + command: 'solr -cloud -f' + + dozzle: + container_name: dozzle + image: amir20/dozzle:latest + volumes: + - /var/run/docker.sock:/var/run/docker.sock + ports: + - 9999:8080 + +volumes: + customizations: + system-conf: + waproperties: + pagecache: + walogs: + testmails: + dbdata: + redis-data: + postgres_data: + sites: + solrdata: + +networks: + default: + name: ${NETWORK_NAME} diff --git a/docker-compose/compose_sparque_full.yml b/docker-compose/compose_sparque_full.yml new file mode 100644 index 0000000000..3230a21338 --- /dev/null +++ b/docker-compose/compose_sparque_full.yml @@ -0,0 +1,295 @@ +# docker-compose for an full deployment with following components +# - ICM with necessary DB, WA and WAA_IMAGE +# - Sparque Wrapper and Sparque Policy enforcer +# - PWA +# - utils like Mailserver; Dozzle + +name: SPARQUE(icm,wrapper,policy-enforcer) + +services: + # policy enforcer container settings + policy_enforcer: + container_name: policy_enforcer + hostname: search-sparque-policy-enforcer + image: ${POLICY_ENFORCER_IMAGE} + ports: + - '27090:7090' + - '28090:8090' + - '29090:9090' + environment: + - SPRING_CONFIG_LOCATION=/app/application.yml + volumes: + - ${APP_YML}:/app/application.yml + + # sparque wrapper container settings + redis: + image: 'bitnami/redis:latest' + environment: + - ALLOW_EMPTY_PASSWORD=yes + ports: + - '6379:6379' + volumes: + - redis-data:/bitnami/redis/data + db: + image: postgres:latest + container_name: postgres_wrapper_db + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: 'Zb641308809576!' + POSTGRES_DB: sparque_wrapper_acc + ports: + - '5433:5432' + volumes: + - postgres_data:/var/lib/postgresql/data + wrapper_legacy: + image: ${SPARQUE_WRAPPER_IMAGE} + environment: + - ASPNETCORE_ENVIRONMENT=Development + - SPARQUE_CONFIG=default + - SPARQUE_MAGIC_CLIENT_ID=8OQKJtMlmADPDzOVvLrqQzXjaflgducc + - SPARQUE_MAGIC_CLIENT_SECRET=3hhGrhVgjt22SqTLdgN-vOf-GfYzWvsKHBSXGHrozt-mfT0wDgAPGO2yUUWCzth8 + - SPARQUE_API_NO_ENDPOINT_CACHE_EXPIRATION_MINUTES=720 + - REDIS_TIMEOUT_MS=2500 + - REDIS_URL=redis:6379 + - REDIS_DATABASE=0 + - REDIS_CIRCUIT_BRK_TRIGGER_EXCEPTION_COUNT=3 + - REDIS_CIRCUIT_BRK_SEC=10 + - SPARQUE_API_ACCESS_TOKEN_ENCRYPTION_KEY=c6d3089cacaa4abc990d55b294e93320 + - SPARQUE_API_BASE_URL=https://rest.sparque.ai/1 + - CACHE_SHOULD_CACHE=${SPARQUE_CACHE_SHOULD_CACHE} + - CACHE_FILTERED_REQUESTS=false + - CACHE_DURATION_MINUTES_SEARCH=30 + - CACHE_DURATION_MINUTES_SUGGESTIONS=30 + - CACHE_DURATION_MINUTES_PRODUCTS=30 + - CACHE_DURATION_MINUTES_RECOMMENDATIONS=30 + - CACHE_DURATION_MINUTES_FACETS=30 + - SEARCH_MAX_CONCURRENT_SEARCHES=100 + - SEARCH_MAX_RESULTS=100 + - SEARCH_DEFAULT_RESULTS=20 + - JWT_KEY=f7960c6887a943e499c6118c5fc5ace8 + - JWT_ISSUER=https://login.sparque.ai + - JWT_AUDIENCE=https://rest.sparque.ai + - JWT_TOKEN_URL=https://login.sparque.ai/oauth/token + - WORKSPACES_WITHOUT_PERSONALIZATION=${SPARQUE_WORKSPACES_WITHOUT_PERSONALIZATION} + - WORKSPACE_ENDPOINTSETS_WITHOUT_AUTH=${SPARQUE_WORKSPACE_ENDPOINTSETS_WITHOUT_AUTH} + - LOGLEVEL=Information + restart: always + ports: + - '${SPARQUE_WRAPPER_PORT}:5755' + depends_on: + - redis + + # PWA container settings + pwa: + container_name: pwa + image: ${PWA_IMAGE} + environment: + LOGGING: 'true' + SOURCE_MAPS: 'true' + ICM_BASE_URL: ${ICM_BASE_URL} + TRUST_ICM: 'true' + SPARQUE: '{"server_url": "${SPARQUE_URL}", "wrapperAPI": "v2", "workspaceName": "intershop-project-base-v2-team2", "apiName": "PWA", "channelId": "ish"}' + + nginx: + build: nginx + container_name: nginx + image: ${PWA_NGINX_IMAGE} + depends_on: + - pwa + ports: + - '4200:80' + environment: + UPSTREAM_PWA: 'http://pwa:4200' + ICM_BASE_URL: '${ICM_BASE_URL}' + DEBUG: 0 + NGINX_ENTRYPOINT_QUIET_LOGS: ANYVALUE + CACHE: 0 + SSR: 1 + SSL: 0 + MULTI_CHANNEL: | + .+: + - baseHref: /en + channel: default + lang: en_US + - baseHref: /de + channel: default + lang: de_DE + - baseHref: /fr + channel: default + lang: fr_FR + - baseHref: /b2c + channel: default + theme: b2c + # ICM container settings + icmDB: + image: ${MSSQLDOCKERIMAGE} + environment: + ACCEPT_EULA: Y + SA_PASSWORD: 1nstershop5A + MSSQL_PID: Developer + RECREATE_DB: 'TRUE' + RECREATE_USER: 'TRUE' + ICM_DB_NAME: ${JDBCDBNAME} + ICM_DB_USER: ${JDBCUSER} + ICM_DB_PASSWORD: ${JDBCPASSWORD} + ports: + - '1433:1433' + healthcheck: + test: + [ + 'CMD', + '/opt/mssql-tools/bin/sqlcmd', + '-b', + '-S', + 'localhost', + '-U', + 'intershop', + '-P', + 'intershop', + '-q', + 'quit', + ] + interval: 30s + timeout: 5s + retries: 8 + start_period: 120s + volumes: + - dbdata:/var/opt/mssql/data + + headless: + image: ${HEADLESS_IMAGE} + container_name: headless + volumes: + - customizations:/customizations + + demodata: + image: ${DEMODATA_IMAGE} + container_name: demodata + volumes: + - customizations:/customizations + + customize-with-solr: + image: ${CUSTOMIZATION_IMAGE_SOLR} + volumes: + - customizations:/customizations + + icm-as: + image: ${APPSERVER_IMAGE} + container_name: icm-as + cpuset: ${CPU_SET} + depends_on: + icmDB: + condition: service_healthy + environment: + SERVER_NAME: '${SERVER_NAME}' + CARTRIDGE_LIST: '${CARTRIDGE_LIST}' + INTERSHOP_DATABASETYPE: '${DBTYPE}' + INTERSHOP_JDBC_URL: '${JDBCURL}' + INTERSHOP_JDBC_USER: '${JDBCUSER}' + INTERSHOP_JDBC_PASSWORD: '${JDBCPASSWORD}' + INTERSHOP_SERVLETENGINE_CONNECTOR_PORT: 7743 + DEBUG_ICM: '${DEBUG_ICM}' + INTERSHOP_WEBSERVERURL: 'http://icmwebserver:${WA_HTTP_PORT}' + INTERSHOP_WEBSERVERSECUREURL: 'https://icmwebserver:${WA_HTTPS_PORT}' + INTERSHOP_CARTRIDGES_REST_TOKENKIND: 'JWT' + INTERSHOP_SEARCH_SPARQUE: 'true' + INTERSHOP_SMTPSERVER: mail + MAIL_SMTP_HOST: mail + MAIL_SMTP_PORT: '1025' + MAIL_CLIENT_API_PORT: '8026' + MAIL_SMTP_MAILHOG_ENABLED: 'true' + INTERSHOP_ENCRYPTION_STRICTMODE_ENABLED: 'false' + SOLR_ZOOKEEPERHOSTLIST: 'solr:9983' + SOLR_CLUSTERINDEXPREFIX: 'my-resp-demo-test' + + volumes: + - ${SITES_DIR}:/intershop/sites + - customizations:/intershop/customizations + - system-conf:/intershop/system-conf + ports: + - '7743:7743' + #debug port + - '7746:7746' + #jmx port + - '7747:7747' + + icmwebserver: + image: ${WA_IMAGE} + container_name: icmwebserver + ports: + - '${WA_HTTP_PORT}:8080' + - '${WA_HTTPS_PORT}:8443' + depends_on: + - 'icm-as' + volumes: + - waproperties:/intershop/webadapter-conf + - pagecache:/intershop/pagecache + - walogs:/intershop/logs + environment: + # a configuration servlet must be available before the webadapter starts + ICM_ICMSERVLETURLS: 'cs.url.0=http://icm-as:7743/servlet/ConfigurationServlet' + + icmwebadapteragent: + image: ${WAA_IMAGE} + container_name: icmwebadapteragent + depends_on: + - 'icmwebserver' + volumes: + - waproperties:/intershop/webadapter-conf + - pagecache:/intershop/pagecache + - walogs:/intershop/logs + environment: + # a configuration servlet must be available before the webadapteragent starts + ICM_AS_SERVICE: 'http://icm-as:7743/servlet/ConfigurationServlet' + + debugging: + image: ${DEBUGGING_IMAGE} + container_name: debugging + command: ['sh', '-c', 'apt-get --yes update && apt-get --yes install ${DEBUGGING_PACKAGES} && sleep infinity'] + volumes: + - sites:/intershop/sites + - system-conf:/intershop/system-conf + - waproperties:/intershop/webadapter-conf + - pagecache:/intershop/pagecache + - walogs:/intershop/logs + + #util containers + mail: + image: mailhog/mailhog + ports: + - '8026:8025' + #- "1025:1025" + + solr: + image: ${SOLRDOCKERIMAGE} + container_name: solr + environment: + SOLR_OPTS: '-Dsolr.disableConfigSetsCreateAuthChecks=true' + ports: + - '8983:8983' + command: 'solr -cloud -f' + + dozzle: + container_name: dozzle + image: amir20/dozzle:latest + volumes: + - /var/run/docker.sock:/var/run/docker.sock + ports: + - 9999:8080 + +volumes: + customizations: + system-conf: + waproperties: + pagecache: + walogs: + testmails: + dbdata: + redis-data: + postgres_data: + sites: + solrdata: + +networks: + default: + name: ${NETWORK_NAME} diff --git a/docker-compose/env.full.example b/docker-compose/env.full.example new file mode 100644 index 0000000000..d50e90ec86 --- /dev/null +++ b/docker-compose/env.full.example @@ -0,0 +1,88 @@ +COMPOSE_PROJECT_NAME = sparque_pwa_demo +# name the docker network to be used +NETWORK_NAME=${COMPOSE_PROJECT_NAME}_network + +CPU_SET=0-3 + +DOCKER_HOST=host.docker.internal + +### images + +# pwa image +PWA_IMAGE=intershophub/intershop-pwa-ssr:latest # pwa image +# as long as the sparque integration is not part of an public release use an loacl image: +# - git clone https://github.com/intershop/intershop-pwa.git +# - npm install +# - build local image 'docker build -t intershop-pwa:local .' +#PWA_IMAGE=intershop-pwa:local # pwa image +PWA_NGINX_IMAGE=intershophub/intershop-pwa-nginx:latest # pwa nginx image + +# icm image +APPSERVER_IMAGE=intershophub/icm-as:12.2.3 +#APPSERVER_IMAGE=intershophub/icm-as:LOCAL + +# solr image +CUSTOMIZATION_IMAGE_SOLR=icmbuild.azurecr.io/intershop/icm-as-customization-f_solrcloud:dev-SNAPSHOT + +# headless image +#HEADLESS_IMAGE=intershophub/icm-as-customization-headless:LOCAL +HEADLESS_IMAGE=icmbuild.azurecr.io/intershop/icm-as-customization-headless:dev-SNAPSHOT + +# demo image +#DEMODATA_IMAGE=intershophub/icm-as-customization-demo-data:LOCAL +DEMODATA_IMAGE=icmbuild.azurecr.io/intershop/icm-as-customization-demo-data:dev-SNAPSHOT + +# policy enforcer image +POLICY_ENFORCER_IMAGE=ishsearchacr.azurecr.io/intershop/search-sparque-policy-enforcer:latest + +# sparque wrapper image +SPARQUE_WRAPPER_IMAGE=eusparqueops/sparque-api-wrapper +# in case that pulling of image will denied(unauthorized): +# - git clone https://intershop-com@dev.azure.com/intershop-com/Products/_git/search-sparque-api-wrapper +# - build local image 'docker build -t sparque-wrapper:local .' +#SPARQUE_WRAPPER_IMAGE=sparque-wrapper:local + +# webadapter images +WA_IMAGE=intershophub/icm-webadapter:2.6.0 +WAA_IMAGE=intershophub/icm-webadapteragent:5.1.0 + +#configure solr +SOLRDOCKERIMAGE=solr:8.11 + +# icm settings +SERVER_NAME=appserver +CARTRIDGE_LIST="ft_demo_search" + +#configure your icm db properties here +DBTYPE=mssql +JDBCDBNAME=icmDB_develop +JDBCURL=jdbc:sqlserver://icmDB:1433;databaseName=${JDBCDBNAME} +JDBCUSER=intershop +JDBCPASSWORD=intershop +DATABACKUPFOLDER_ICM=[CONFIGURE_ME: local data folder e.g. d:/Development/temp/data/icmDB] +SITES_DIR=[CONFIGURE_ME: local data folder e.g. d:/Development/temp/data/icmSites] +MSSQLDOCKERIMAGE=intershophub/mssql-intershop:2022-2.0 + +WA_HTTP_PORT=8080 +WA_HTTPS_PORT=8443 + +ENABLE_DEBUG=true +#true|suspend +#DEBUG_ICM=suspend +DEBUG_ICM=true +# debugging container +DEBUGGING_IMAGE=ubuntu:20.04 +# may something like "curl bind9-host wget nano dnsutils iproute2 man-db inetutils-ping" +DEBUGGING_PACKAGES="iputils-ping" + +# sparque settings +SPARQUE_WRAPPER_PORT=5755 +SPARQUE_CACHE_SHOULD_CACHE=false +SPARQUE_WORKSPACES_WITHOUT_PERSONALIZATION=intershop-project-base-v2-team2 +SPARQUE_WORKSPACE_ENDPOINTSETS_WITHOUT_AUTH=intershop-project-base-v2-team2|PWA + +APP_YML=[path to]policy_enforcer_application.yml + +# pwa settings +ICM_BASE_URL = https://${DOCKER_HOST}:${WA_HTTPS_PORT} +SPARQUE_URL = http://${DOCKER_HOST}:${SPARQUE_WRAPPER_PORT} diff --git a/docker-compose/policy_enforcer_application.yml b/docker-compose/policy_enforcer_application.yml new file mode 100644 index 0000000000..51b4ebb1bf --- /dev/null +++ b/docker-compose/policy_enforcer_application.yml @@ -0,0 +1,68 @@ +intershop: + security: + cors: + allowedOrigins: 'http://localhost:4200 http://pwa:4200' + enforcer: +logging: + config: classpath:logback_json.xml + level: + com.intershop: INFO +management: + endpoint: + health: + enabled: true + group: + readiness: + include: readinessState + show-details: always + info: + enabled: true + metrics: + enabled: true + prometheus: + enabled: true + endpoints: + web: + cors: + allowed-methods: GET + allowed-origins: '*' + exposure: + include: info,health,metrics,prometheus,openapi,swaggerui + health: + diskSpace: + enabled: false + probes: + enabled: true + metrics: + export: + prometheus: + enabled: true + server: + port: 9090 +server: + port: 8090 +spring: + autoconfigure: + exclude: org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration,org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration, org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration + main: + allow-bean-definition-overriding: true + banner-mode: 'off' +springdoc: + packagesToScan: com.intershop + show-actuator: true + use-management-port: true +sparque: + path: / + defaultTargetUriWrapper: http://host.docker.internal:5755 + defaultTargetUriSparque: https://rest.sparque.ai + connectTimeout: PT1S + requestTimeout: PT3S + clients: + - description: Team2 Sparque Test environment + workspaceToChannelSites: + intershop-project-base-v2-team2: inSPIRED-inTRONICS_Business-Site + issuer: https://icm-as:8443 + name: Team2 UAT Test + targetURIWrapper: http://host.docker.internal:5755 + targetURISparque: https://rest.sparque.ai + login: https://login.sparque.ai diff --git a/docs/README.md b/docs/README.md index 980a012a98..61dee3c8b7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -90,3 +90,4 @@ kb_sync_latest_only - [Guide - Store Locator with Google Maps](./guides/store-locator.md) - [Guide - Address Check with Address Doctor](./guides/address-doctor.md) - [Guide - E-Mail Marketing/Newsletter Subscription](./guides/newsletter-subscription.md) +- [Guide - Sparque Integration](./guides/sparque-integration.md) diff --git a/docs/guides/sparque-integration.md b/docs/guides/sparque-integration.md new file mode 100644 index 0000000000..5f9236468a --- /dev/null +++ b/docs/guides/sparque-integration.md @@ -0,0 +1,26 @@ + + +# Sparque AI Integration + +Sparque AI works as a search engine and delivers various information, like keyword suggestions, search results, filter options and category navigation. + +## Configuration + +To use the Sparque search engine, the following steps must be taken into account during deployment: + +- store the correct sparque configuration as an environment variable [Sparque Config Model](../../src/app/core/models/sparque/sparque-config.model.ts) +- bind the SparqueSuggestionService as SuggestionService in the core module [Core Module](../../src/app/core/core.module.ts) + +_core.module.ts_: + +``` +exchange: +{ provide: SuggestionService, useClass: ICMSuggestionService }, +with: +{ provide: SuggestionService, useClass: SparqueSuggestionService }, +``` diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 33f70302c4..543d3f2c59 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -15,6 +15,8 @@ import { PaymentPayoneInterceptor } from './interceptors/payment-payone.intercep import { PGIDChangeInterceptor } from './interceptors/pgid-change.interceptor'; import { PreviewInterceptor } from './interceptors/preview.interceptor'; import { InternationalizationModule } from './internationalization.module'; +import { ICMSuggestionService } from './services/icm-suggestion/icm-suggestion.service'; +import { SuggestionService } from './services/suggestion/suggestion.service'; import { StateManagementModule } from './state-management.module'; import { DefaultErrorHandler } from './utils/default-error-handler'; @@ -43,6 +45,8 @@ import { DefaultErrorHandler } from './utils/default-error-handler'; { provide: HTTP_INTERCEPTORS, useClass: MockInterceptor, multi: true }, { provide: HTTP_INTERCEPTORS, useClass: PreviewInterceptor, multi: true }, { provide: ErrorHandler, useClass: DefaultErrorHandler }, + // to use Sparque for suggestion exchange ICMSuggestionService with SparqueSuggestionService + { provide: SuggestionService, useClass: ICMSuggestionService }, { provide: APP_BASE_HREF, useFactory: (s: PlatformLocation, baseHref: string) => baseHref || s.getBaseHrefFromDOM(), diff --git a/src/app/core/facades/app.facade.ts b/src/app/core/facades/app.facade.ts index 0c353cbc36..891110c4d2 100644 --- a/src/app/core/facades/app.facade.ts +++ b/src/app/core/facades/app.facade.ts @@ -1,10 +1,12 @@ import { getCurrencySymbol } from '@angular/common'; -import { Injectable } from '@angular/core'; +import { Injectable, inject } from '@angular/core'; import { NavigationCancel, NavigationEnd, NavigationStart, Router } from '@angular/router'; import { Store, select } from '@ngrx/store'; import { combineLatest, merge, noop } from 'rxjs'; import { filter, map, sample, shareReplay, startWith, withLatestFrom } from 'rxjs/operators'; +import { SparqueSuggestionService } from 'ish-core/services/sparque-suggestion/sparque-suggestion.service'; +import { SuggestionService } from 'ish-core/services/suggestion/suggestion.service'; import { getAvailableLocales, getCurrentCurrency, @@ -146,4 +148,8 @@ export class AppFacade { this.store.dispatch(loadRegions({ countryCode })); return this.store.pipe(select(getRegionsByCountryCode(countryCode))); } + + isSparqueSuggestActive(): boolean { + return inject(SuggestionService) instanceof SparqueSuggestionService; + } } diff --git a/src/app/core/models/sparque-suggestion/sparque-suggestion.interface.ts b/src/app/core/models/sparque-suggestion/sparque-suggestion.interface.ts new file mode 100644 index 0000000000..479e8fea3c --- /dev/null +++ b/src/app/core/models/sparque-suggestion/sparque-suggestion.interface.ts @@ -0,0 +1,97 @@ +/** + * Interface for Sparque Suggestions API response object + */ +export interface SparqueSuggestions { + products?: SparqueProduct[]; + categories?: SparqueCategory[]; + brands?: SparqueBrand[]; + keywordSuggestions?: SparqueKeywordSuggestions[]; + contentSuggestions?: SparqueContentSuggestions[]; +} + +export interface SparqueProduct { + sku: string; + name: string; + defaultBrandName?: string; + defaultcategoryId: string; + gtin?: string; + shortDescription?: string; + longDescription?: string; + manufacturer?: string; + type?: string; + rank?: number; + offers?: SparqueOffer[]; + productVariants?: string[]; + productMaster?: string; + attributes?: SparqueAttribute[]; + images: SparqueImage[]; + attachments?: SparqueAttachment[]; + variantAttributes?: SparqueVariantAttribute[]; +} + +export interface SparqueCategory { + CategoryID: string; + CategoryName: string; + CategoryURL?: string; + TotalCount: number; + Position?: number; + ParentCategoryId: string; + SubCategories?: string[]; + attributes?: SparqueAttribute[]; +} + +export interface SparqueBrand { + BrandName: string; + TotalCount: number; + ImageUrl: string; +} + +export interface SparqueKeywordSuggestions { + keyword: string; +} + +export interface SparqueContentSuggestions { + newsType: string; + paragraph: string; + slug: string; + summary: string; + title: string; + type: string; + articleDate: Date; +} + +interface SparqueOffer { + priceExclVat: number; + priceIncVat: number; + vatAmount: number; + vatPercentage: number; + currency: string; + type: string; +} + +export interface SparqueAttribute { + name: string; + value: string; +} + +export interface SparqueImage { + id: string; + extension?: string; + url: string; + isPrimaryImage?: boolean; + attributes?: SparqueAttribute[]; +} + +interface SparqueAttachment { + id: string; + extension: string; + relativeUrl: string; + attributes: SparqueAttribute[]; +} + +interface SparqueVariantAttribute { + id: string; + value: string; + name: string; + sku: string; +} diff --git a/src/app/core/models/sparque-suggestion/sparque-suggestion.mapper.spec.ts b/src/app/core/models/sparque-suggestion/sparque-suggestion.mapper.spec.ts new file mode 100644 index 0000000000..8fd5fced0e --- /dev/null +++ b/src/app/core/models/sparque-suggestion/sparque-suggestion.mapper.spec.ts @@ -0,0 +1,151 @@ +import { Suggestion } from 'ish-core/models/suggestion/suggestion.model'; + +import { SparqueSuggestions } from './sparque-suggestion.interface'; +import { SparqueSuggestionMapper } from './sparque-suggestion.mapper'; + +describe('Sparque Suggestion Mapper', () => { + describe('fromData', () => { + it('should map products correctly', () => { + const sparqueSuggestions: SparqueSuggestions = { + products: [ + { + name: 'Product 1', + shortDescription: 'Short description', + longDescription: 'Long description', + manufacturer: 'Manufacturer', + images: [{ id: '1', url: 'http://image.url', isPrimaryImage: true }], + attributes: [{ name: 'Color', value: 'Red' }], + sku: 'SKU1', + defaultcategoryId: 'cat1', + }, + ], + categories: [], + brands: [], + keywordSuggestions: [], + contentSuggestions: [], + }; + + const result: Suggestion = SparqueSuggestionMapper.fromData(sparqueSuggestions); + + expect(result.products).toHaveLength(1); + expect(result.products[0].name).toBe('Product 1'); + expect(result.products[0].shortDescription).toBe('Short description'); + expect(result.products[0].longDescription).toBe('Long description'); + expect(result.products[0].manufacturer).toBe('Manufacturer'); + expect(result.products[0].images).toHaveLength(1); + expect(result.products[0].images[0].effectiveUrl).toBe('http://image.url'); + expect(result.products[0].attributes).toHaveLength(1); + expect(result.products[0].attributes[0].name).toBe('Color'); + expect(result.products[0].attributes[0].value).toBe('Red'); + expect(result.products[0].sku).toBe('SKU1'); + expect(result.products[0].defaultCategoryId).toBe('cat1'); + }); + + it('should map categories correctly', () => { + const sparqueSuggestions: SparqueSuggestions = { + products: [], + categories: [ + { + CategoryName: 'Category 1', + CategoryID: 'cat1', + CategoryURL: 'http://category.url', + ParentCategoryId: 'parentCat', + TotalCount: 10, + attributes: [{ name: 'Type', value: 'Electronics' }], + }, + ], + brands: [], + keywordSuggestions: [], + contentSuggestions: [], + }; + + const result: Suggestion = SparqueSuggestionMapper.fromData(sparqueSuggestions); + + expect(result.categories).toHaveLength(1); + expect(result.categories[0].name).toBe('Category 1'); + expect(result.categories[0].uniqueId).toBe('cat1'); + expect(result.categories[0].categoryRef).toBe('http://category.url'); + expect(result.categories[0].categoryPath).toEqual(['parentCat', 'cat1']); + expect(result.categories[0].hasOnlineProducts).toBeTrue(); + expect(result.categories[0].attributes).toHaveLength(1); + expect(result.categories[0].attributes[0].name).toBe('Type'); + expect(result.categories[0].attributes[0].value).toBe('Electronics'); + }); + + it('should map brands correctly', () => { + const sparqueSuggestions: SparqueSuggestions = { + products: [], + categories: [], + brands: [ + { + BrandName: 'Brand 1', + ImageUrl: 'http://brand.image.url', + TotalCount: 5, + }, + ], + keywordSuggestions: [], + contentSuggestions: [], + }; + + const result: Suggestion = SparqueSuggestionMapper.fromData(sparqueSuggestions); + + expect(result.brands).toHaveLength(1); + expect(result.brands[0].name).toBe('Brand 1'); + expect(result.brands[0].imageUrl).toBe('http://brand.image.url'); + expect(result.brands[0].productCount).toBe(5); + }); + + it('should map keyword suggestions correctly', () => { + const sparqueSuggestions: SparqueSuggestions = { + products: [], + categories: [], + brands: [], + keywordSuggestions: [{ keyword: 'keyword1' }, { keyword: 'keyword2' }], + contentSuggestions: [], + }; + + const result: Suggestion = SparqueSuggestionMapper.fromData(sparqueSuggestions); + + expect(result.keywordSuggestions).toHaveLength(2); + expect(result.keywordSuggestions).toContain('keyword1'); + expect(result.keywordSuggestions).toContain('keyword2'); + }); + + it('should map content suggestions correctly', () => { + const date = new Date('2023-10-01'); + const sparqueSuggestions: SparqueSuggestions = { + products: [], + categories: [], + brands: [], + keywordSuggestions: [], + contentSuggestions: [ + { + newsType: 'News', + paragraph: 'Paragraph', + slug: 'slug', + summary: 'Summary', + title: 'Title', + type: 'Type', + articleDate: date, + }, + ], + }; + + const result: Suggestion = SparqueSuggestionMapper.fromData(sparqueSuggestions); + + expect(result.contentSuggestions).toHaveLength(1); + expect(result.contentSuggestions[0].newsType).toBe('News'); + expect(result.contentSuggestions[0].paragraph).toBe('Paragraph'); + expect(result.contentSuggestions[0].slug).toBe('slug'); + expect(result.contentSuggestions[0].summary).toBe('Summary'); + expect(result.contentSuggestions[0].title).toBe('Title'); + expect(result.contentSuggestions[0].type).toBe('Type'); + expect(result.contentSuggestions[0].articleDate).toBe(date); + }); + + it('should return undefined for undefined input', () => { + const result: Suggestion = SparqueSuggestionMapper.fromData(undefined); + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/src/app/core/models/sparque-suggestion/sparque-suggestion.mapper.ts b/src/app/core/models/sparque-suggestion/sparque-suggestion.mapper.ts new file mode 100644 index 0000000000..c1bada00f4 --- /dev/null +++ b/src/app/core/models/sparque-suggestion/sparque-suggestion.mapper.ts @@ -0,0 +1,125 @@ +import { Attribute } from 'ish-core/models/attribute/attribute.model'; +import { Category } from 'ish-core/models/category/category.model'; +import { Image } from 'ish-core/models/image/image.model'; +import { Product } from 'ish-core/models/product/product.model'; +import { Brand, ContentSuggestion, Suggestion } from 'ish-core/models/suggestion/suggestion.model'; + +import { + SparqueAttribute, + SparqueBrand, + SparqueCategory, + SparqueContentSuggestions, + SparqueImage, + SparqueKeywordSuggestions, + SparqueProduct, + SparqueSuggestions, +} from './sparque-suggestion.interface'; + +function mapProducts(products: SparqueProduct[]): Product[] { + return products + ? products.map(product => ({ + name: product.name ? product.name : undefined, + shortDescription: product.shortDescription ? product.shortDescription : undefined, + longDescription: product.longDescription ? product.longDescription : undefined, + available: true, + manufacturer: product.manufacturer ? product.manufacturer : undefined, + images: mapImages(product.images), + attributes: mapAttributes(product.attributes), + sku: product.sku ? product.sku : undefined, + defaultCategoryId: product.defaultcategoryId ? product.defaultcategoryId : undefined, + completenessLevel: 0, + maxOrderQuantity: undefined, + minOrderQuantity: undefined, + stepQuantity: undefined, + roundedAverageRating: undefined, + numberOfReviews: undefined, + readyForShipmentMin: undefined, + readyForShipmentMax: undefined, + packingUnit: undefined, + type: undefined, + promotionIds: undefined, + failed: false, + })) + : undefined; +} + +function mapCategories(categories: SparqueCategory[]): Category[] { + return categories + ? categories.map(category => ({ + name: category.CategoryName ? category.CategoryName : undefined, + uniqueId: category.CategoryID ? category.CategoryID : undefined, + categoryRef: category.CategoryURL ? category.CategoryURL : undefined, + categoryPath: category.ParentCategoryId + ? [category.ParentCategoryId, category.CategoryID ? category.CategoryID : undefined] + : category.CategoryID + ? [category.CategoryID] + : [], + hasOnlineProducts: category.TotalCount && category.TotalCount > 0, + description: undefined, + images: mapImages([]), + attributes: mapAttributes(category.attributes), + completenessLevel: 0, + })) + : []; +} + +function mapBrands(brands: SparqueBrand[]): Brand[] { + return brands + ? brands.map(brand => ({ + name: brand.BrandName ? brand.BrandName : undefined, + imageUrl: brand.ImageUrl ? brand.ImageUrl : undefined, + productCount: brand.TotalCount ? brand.TotalCount : undefined, + })) + : undefined; +} + +function mapKeywords(keyWords: SparqueKeywordSuggestions[]): string[] { + return keyWords ? keyWords.map(entry => entry.keyword) : []; +} + +function mapContent(contentSuggestions: SparqueContentSuggestions[]): ContentSuggestion[] { + return contentSuggestions + ? contentSuggestions.map(content => ({ + newsType: content.newsType ? content.newsType : undefined, + paragraph: content.paragraph ? content.paragraph : undefined, + slug: content.slug ? content.slug : undefined, + summary: content.summary ? content.summary : undefined, + title: content.title ? content.title : undefined, + type: content.type ? content.type : undefined, + articleDate: content.articleDate ? content.articleDate : undefined, + })) + : undefined; +} + +function mapAttributes(attributes: SparqueAttribute[]): Attribute[] { + return attributes ? attributes.map(attribute => ({ name: attribute.name, value: attribute.value })) : []; +} + +function mapImages(images: SparqueImage[]): Image[] { + return images + ? images.map(image => ({ + name: image.id ? image.id : undefined, + type: undefined, + effectiveUrl: image.url ? image.url : undefined, + viewID: image.id ? image.id : undefined, + typeID: undefined, + primaryImage: image.isPrimaryImage ? image.isPrimaryImage : false, + imageActualHeight: undefined, + imageActualWidth: undefined, + })) + : []; +} + +export class SparqueSuggestionMapper { + static fromData(suggestion: SparqueSuggestions): Suggestion { + return suggestion + ? { + products: mapProducts(suggestion.products), + categories: mapCategories(suggestion.categories), + brands: mapBrands(suggestion.brands), + keywordSuggestions: mapKeywords(suggestion.keywordSuggestions), + contentSuggestions: mapContent(suggestion.contentSuggestions), + } + : undefined; + } +} diff --git a/src/app/core/models/sparque/sparque-config.model.ts b/src/app/core/models/sparque/sparque-config.model.ts new file mode 100644 index 0000000000..74bb946b5b --- /dev/null +++ b/src/app/core/models/sparque/sparque-config.model.ts @@ -0,0 +1,19 @@ +/** + * Configuration settings for the Sparque connections. + */ +export interface SparqueConfig { + // base url of the sparque wrapper server + server_url: string; + // version of the sparque wrapper REST API + wrapperAPI: string; + // sparque workspace name + WorkspaceName: string; + // sparque API name + ApiName: string; + // sparque deployment configuration e.g. production + config?: string; + // id of channel where sparque product data are assigned to + ChannelId?: string; + + [key: string]: unknown; +} diff --git a/src/app/core/models/suggest-term/suggest-term.model.ts b/src/app/core/models/suggest-term/suggest-term.model.ts deleted file mode 100644 index 7e9874b0f6..0000000000 --- a/src/app/core/models/suggest-term/suggest-term.model.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface SuggestTerm { - term: string; -} diff --git a/src/app/core/models/suggestion/suggestion.model.ts b/src/app/core/models/suggestion/suggestion.model.ts new file mode 100644 index 0000000000..f920e7dbd0 --- /dev/null +++ b/src/app/core/models/suggestion/suggestion.model.ts @@ -0,0 +1,26 @@ +import { Category } from 'ish-core/models/category/category.model'; +import { Product } from 'ish-core/models/product/product.model'; + +export interface Suggestion { + products?: Product[]; + categories?: Category[]; + brands?: Brand[]; + keywordSuggestions?: string[]; + contentSuggestions?: ContentSuggestion[]; +} + +export interface Brand { + name: string; + productCount: number; + imageUrl: string; +} + +export interface ContentSuggestion { + newsType: string; + paragraph: string; + slug: string; + summary: string; + title: string; + type: string; + articleDate: Date; +} diff --git a/src/app/core/services/api/api.service.ts b/src/app/core/services/api/api.service.ts index 120dcb52e4..0720c250ed 100644 --- a/src/app/core/services/api/api.service.ts +++ b/src/app/core/services/api/api.service.ts @@ -70,9 +70,9 @@ export class ApiService { static AUTHORIZATION_HEADER_KEY = 'Authorization'; constructor( - private httpClient: HttpClient, - private store: Store, - private featureToggleService: FeatureToggleService + protected httpClient: HttpClient, + protected store: Store, + protected featureToggleService: FeatureToggleService ) {} /** @@ -95,7 +95,7 @@ export class ApiService { /** * merges supplied and default headers */ - private constructHeaders(options?: AvailableOptions): Observable { + protected constructHeaders(options?: AvailableOptions): Observable { const defaultHeaders = new HttpHeaders().set('content-type', 'application/json').set('Accept', 'application/json'); return of( @@ -187,7 +187,7 @@ export class ApiService { : of(''); } - private constructHttpClientParams( + protected constructHttpClientParams( path: string, options?: AvailableOptions ): Observable<[string, { headers: HttpHeaders; params: HttpParams }]> { diff --git a/src/app/core/services/suggest/suggest.service.spec.ts b/src/app/core/services/icm-suggestion/icm-suggestion.service.spec.ts similarity index 74% rename from src/app/core/services/suggest/suggest.service.spec.ts rename to src/app/core/services/icm-suggestion/icm-suggestion.service.spec.ts index ce91d92c6a..0e1d09847d 100644 --- a/src/app/core/services/suggest/suggest.service.spec.ts +++ b/src/app/core/services/icm-suggestion/icm-suggestion.service.spec.ts @@ -2,22 +2,22 @@ import { TestBed } from '@angular/core/testing'; import { of } from 'rxjs'; import { anything, instance, mock, verify, when } from 'ts-mockito'; -import { SuggestTerm } from 'ish-core/models/suggest-term/suggest-term.model'; +import { Suggestion } from 'ish-core/models/suggestion/suggestion.model'; import { ApiService } from 'ish-core/services/api/api.service'; -import { SuggestService } from './suggest.service'; +import { ICMSuggestionService } from './icm-suggestion.service'; -describe('Suggest Service', () => { +describe('Icm Suggestion Service', () => { let apiService: ApiService; - let suggestService: SuggestService; + let suggestService: ICMSuggestionService; beforeEach(() => { apiService = mock(ApiService); - when(apiService.get(anything(), anything())).thenReturn(of([])); + when(apiService.get(anything(), anything())).thenReturn(of({})); TestBed.configureTestingModule({ providers: [{ provide: ApiService, useFactory: () => instance(apiService) }], }); - suggestService = TestBed.inject(SuggestService); + suggestService = TestBed.inject(ICMSuggestionService); }); it('should always delegate to api service when called', () => { @@ -32,12 +32,11 @@ describe('Suggest Service', () => { suggestService.search('g').subscribe(res => { expect(res).toMatchInlineSnapshot(` - [ - { - "term": "Goods", - "type": undefined, - }, - ] + { + "keywordSuggestions": [ + "Goods", + ], + } `); verify(apiService.get(anything(), anything())).once(); done(); diff --git a/src/app/core/services/icm-suggestion/icm-suggestion.service.ts b/src/app/core/services/icm-suggestion/icm-suggestion.service.ts new file mode 100644 index 0000000000..8c0e7bd3b0 --- /dev/null +++ b/src/app/core/services/icm-suggestion/icm-suggestion.service.ts @@ -0,0 +1,34 @@ +import { HttpParams } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable, map } from 'rxjs'; + +import { Suggestion } from 'ish-core/models/suggestion/suggestion.model'; +import { ApiService, unpackEnvelope } from 'ish-core/services/api/api.service'; +import { SuggestionService } from 'ish-core/services/suggestion/suggestion.service'; + +// not-dead-code +/** + * The Suggest Service handles the interaction with the 'suggest' REST API. + */ +@Injectable({ providedIn: 'root' }) +export class ICMSuggestionService extends SuggestionService { + constructor(private apiService: ApiService) { + super(); + } + + /** + * Returns a list of suggested search terms matching the given search term. + * + * @param searchTerm The search term to get suggestions for. + * @returns List of suggested search terms. + */ + search(searchTerm: string): Observable { + const params = new HttpParams().set('SearchTerm', searchTerm); + return this.apiService.get('suggest', { params }).pipe( + unpackEnvelope<{ term: string }>(), + map(suggestTerms => ({ + keywordSuggestions: suggestTerms.map(term => term.term), + })) + ); + } +} diff --git a/src/app/core/services/sparque-api/sparque-api.service.spec.ts b/src/app/core/services/sparque-api/sparque-api.service.spec.ts new file mode 100644 index 0000000000..291a7589ef --- /dev/null +++ b/src/app/core/services/sparque-api/sparque-api.service.spec.ts @@ -0,0 +1,469 @@ +import { HttpErrorResponse, HttpHeaders } from '@angular/common/http'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { Action, Store } from '@ngrx/store'; +import { MockStore, provideMockStore } from '@ngrx/store/testing'; +import { BehaviorSubject, noop } from 'rxjs'; +import { anything, capture, instance, mock, spy, verify, when } from 'ts-mockito'; + +import { FeatureToggleService } from 'ish-core/feature-toggle.module'; +import { HttpError } from 'ish-core/models/http-error/http-error.model'; +import { SparqueConfig } from 'ish-core/models/sparque/sparque-config.model'; +import { TokenService } from 'ish-core/services/token/token.service'; +import { + getCurrentCurrency, + getCurrentLocale, + getICMServerURL, + getRestEndpoint, + getSparqueConfig, +} from 'ish-core/store/core/configuration'; +import { serverError } from 'ish-core/store/core/error'; +import { isServerConfigurationLoaded } from 'ish-core/store/core/server-config'; +import { getPGID } from 'ish-core/store/customer/user'; +import { ApiTokenService } from 'ish-core/utils/api-token/api-token.service'; + +import { SparqueApiService } from './sparque-api.service'; + +const sparqueConfig = { + server_url: 'http://fancy:0815', + ApiName: 'dopeShit', + WorkspaceName: 'OttoKartoffel', + wrapperAPI: 'infinity', + ChannelId: 'aura', +} as SparqueConfig; + +function getRestURL(endpoint: string): string { + return sparqueConfig.server_url + .concat('/api/', sparqueConfig.wrapperAPI) + .concat(endpoint) + .concat('?ApiName=') + .concat(sparqueConfig.ApiName) + .concat('&WorkspaceName=') + .concat(sparqueConfig.WorkspaceName) + .concat('&ChannelId=') + .concat(sparqueConfig.ChannelId) + .concat('&Locale=en-US'); +} + +// testing here is handled by http testing controller +/* eslint-disable ish-custom-rules/use-async-synchronization-in-tests */ + +describe('Sparque Api Service', () => { + describe('Sparque API Service Methods', () => { + let sparqueApiService: SparqueApiService; + let store: Store; + let httpTestingController: HttpTestingController; + const featureToggleServiceMock = mock(FeatureToggleService); + const apiTokenServiceMock = mock(ApiTokenService); + const tokenServiceMock = mock(TokenService); + + beforeEach(() => { + TestBed.configureTestingModule({ + // https://angular.io/guide/http#testing-http-requests + imports: [HttpClientTestingModule], + providers: [ + { provide: ApiTokenService, useFactory: () => instance(apiTokenServiceMock) }, + { provide: FeatureToggleService, useFactory: () => instance(featureToggleServiceMock) }, + { provide: TokenService, useFactory: () => instance(tokenServiceMock) }, + provideMockStore({ + selectors: [ + { selector: isServerConfigurationLoaded, value: true }, + { selector: getRestEndpoint, value: 'http://www.example.org/WFS/site/-' }, + { selector: getICMServerURL, value: undefined }, + { selector: getCurrentCurrency, value: 'USD' }, + { selector: getCurrentLocale, value: 'en_US' }, + { selector: getPGID, value: undefined }, + { + selector: getSparqueConfig, + value: sparqueConfig, + }, + ], + }), + ], + }); + + sparqueApiService = TestBed.inject(SparqueApiService); + httpTestingController = TestBed.inject(HttpTestingController); + store = spy(TestBed.inject(Store)); + + when(apiTokenServiceMock.apiToken$).thenReturn(new BehaviorSubject('apiToken')); + }); + + afterEach(() => { + // After every test, assert that there are no more pending requests. + httpTestingController.verify(); + }); + + it('should call the httpClient.options method when sparqueApiService.options method is called.', done => { + sparqueApiService.options('data').subscribe({ + next: data => { + expect(data).toBeTruthy(); + }, + complete: done, + }); + + const req = httpTestingController.expectOne(getRestURL('/data')); + req.flush({}); + expect(req.request.method).toEqual('OPTIONS'); + }); + + it('should create Error Action if httpClient.options throws Error.', () => { + const statusText = 'ERROR'; + + sparqueApiService.options('data').subscribe({ next: fail, error: fail }); + const req = httpTestingController.expectOne(getRestURL('/data')); + + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(noop); + req.flush('err', { status: 500, statusText }); + consoleSpy.mockRestore(); + + verify(store.dispatch(anything())).once(); + const [action] = capture(store.dispatch).last(); + expect(action.type).toEqual(serverError.type); + expect(action.payload.error).toHaveProperty('statusText', statusText); + }); + + it('should call the httpClient.get method when sparqueApiService.get method is called.', done => { + sparqueApiService.get('data').subscribe({ + next: data => { + expect(data).toBeTruthy(); + }, + complete: done, + }); + + const req = httpTestingController.expectOne(getRestURL('/data')); + req.flush({}); + expect(req.request.method).toEqual('GET'); + }); + + it('should create Error Action if httpClient.get throws Error.', () => { + const statusText = 'ERROR'; + + sparqueApiService.get('data').subscribe({ next: fail, error: fail }); + const req = httpTestingController.expectOne(getRestURL('/data')); + + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(noop); + req.flush('err', { status: 500, statusText }); + consoleSpy.mockRestore(); + + verify(store.dispatch(anything())).once(); + const [action] = capture(store.dispatch).last(); + expect(action.type).toEqual(serverError.type); + expect(action.payload.error).toHaveProperty('statusText', statusText); + }); + + it('should call the httpClient.put method when sparqueApiService.put method is called.', done => { + sparqueApiService.put('data').subscribe({ + next: data => { + expect(data).toBeTruthy(); + }, + complete: done, + }); + + const req = httpTestingController.expectOne(getRestURL('/data')); + req.flush({}); + expect(req.request.method).toEqual('PUT'); + }); + + it('should call the httpClient.patch method when sparqueApiService.patch method is called.', done => { + sparqueApiService.patch('data').subscribe({ + next: data => { + expect(data).toBeTruthy(); + }, + complete: done, + }); + + const req = httpTestingController.expectOne(getRestURL('/data')); + req.flush({}); + expect(req.request.method).toEqual('PATCH'); + }); + + it('should call the httpClient.post method when sparqueApiService.post method is called.', done => { + sparqueApiService.post('data').subscribe({ + next: data => { + expect(data).toBeTruthy(); + }, + complete: done, + }); + + const req = httpTestingController.expectOne(getRestURL('/data')); + req.flush({}); + expect(req.request.method).toEqual('POST'); + }); + + it('should call the httpClient.delete method when sparqueApiService.delete method is called.', done => { + sparqueApiService.delete('data').subscribe({ + next: data => { + expect(data).toBeTruthy(); + }, + complete: done, + }); + + const req = httpTestingController.expectOne(getRestURL('/data')); + req.flush({}); + expect(req.request.method).toEqual('DELETE'); + }); + + describe('Encode Resource ID', () => { + it('should return a double encoded string if legacyEncoding is on', () => { + when(featureToggleServiceMock.enabled('legacyEncoding')).thenReturn(true); + + expect(sparqueApiService.encodeResourceId('123456abc')).toEqual(`123456abc`); + expect(sparqueApiService.encodeResourceId('d.ori+6@test.intershop.de')).toEqual( + `d.ori%252B6%2540test.intershop.de` + ); + expect(sparqueApiService.encodeResourceId('pmiller@test.intershop.de')).toEqual( + `pmiller%2540test.intershop.de` + ); + }); + + it('should return a single encoded string if legacyEncoding is off', () => { + when(featureToggleServiceMock.enabled('legacyEncoding')).thenReturn(false); + + expect(sparqueApiService.encodeResourceId('123456abc')).toEqual(`123456abc`); + expect(sparqueApiService.encodeResourceId('d.ori+6@test.intershop.de')).toEqual(`d.ori+6%40test.intershop.de`); + expect(sparqueApiService.encodeResourceId('pmiller@test.intershop.de')).toEqual(`pmiller%40test.intershop.de`); + }); + }); + }); + + describe('Sparque API Service URL construction', () => { + let sparqueApiService: SparqueApiService; + let httpTestingController: HttpTestingController; + const apiTokenServiceMock = mock(ApiTokenService); + const tokenServiceMock = mock(TokenService); + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + { provide: ApiTokenService, useFactory: () => instance(apiTokenServiceMock) }, + { provide: TokenService, useFactory: () => instance(tokenServiceMock) }, + provideMockStore({ + selectors: [ + { selector: isServerConfigurationLoaded, value: true }, + { selector: getRestEndpoint, value: 'http://www.example.org/WFS/site/-' }, + { selector: getICMServerURL, value: undefined }, + { selector: getCurrentLocale, value: undefined }, + { selector: getCurrentCurrency, value: undefined }, + { selector: getPGID, value: undefined }, + { + selector: getSparqueConfig, + value: sparqueConfig, + }, + ], + }), + ], + }); + + sparqueApiService = TestBed.inject(SparqueApiService); + httpTestingController = TestBed.inject(HttpTestingController); + TestBed.inject(MockStore); + + when(apiTokenServiceMock.apiToken$).thenReturn(new BehaviorSubject('apiToken')); + }); + + afterEach(() => { + // After every test, assert that there are no more pending requests. + httpTestingController.verify(); + }); + + it('should bypass URL construction when path is an external link', () => { + sparqueApiService.get('http://google.de').subscribe({ next: fail, error: fail, complete: fail }); + + httpTestingController.expectOne('http://google.de'); + }); + + it('should bypass URL construction when path is an external secure link', () => { + sparqueApiService.get('https://google.de').subscribe({ next: fail, error: fail, complete: fail }); + + httpTestingController.expectOne('https://google.de'); + }); + }); + + describe('SPARQUE API Service Headers', () => { + let sparqueApiService: SparqueApiService; + let httpTestingController: HttpTestingController; + const apiTokenServiceMock = mock(ApiTokenService); + + beforeEach(() => { + TestBed.configureTestingModule({ + // https://angular.io/guide/http#testing-http-requests + imports: [HttpClientTestingModule], + providers: [ + { provide: ApiTokenService, useFactory: () => instance(apiTokenServiceMock) }, + { provide: TokenService, useFactory: () => instance(mock(TokenService)) }, + provideMockStore({ + selectors: [ + { selector: isServerConfigurationLoaded, value: true }, + { selector: getCurrentCurrency, value: 'USD' }, + { selector: getCurrentLocale, value: 'en_US' }, + { + selector: getSparqueConfig, + value: sparqueConfig, + }, + ], + }), + ], + }); + + sparqueApiService = TestBed.inject(SparqueApiService); + httpTestingController = TestBed.inject(HttpTestingController); + TestBed.inject(Store); + + when(apiTokenServiceMock.apiToken$).thenReturn(new BehaviorSubject('apiToken')); + }); + + afterEach(() => { + // After every test, assert that there are no more pending requests. + httpTestingController.verify(); + }); + + it('should always have default headers', () => { + sparqueApiService.get('dummy').subscribe({ next: fail, error: fail, complete: fail }); + + const req = httpTestingController.expectOne(getRestURL('/dummy')); + expect(req.request.headers.keys()).not.toBeEmpty(); + expect(req.request.headers.get('content-type')).toEqual('application/json'); + expect(req.request.headers.get('Accept')).toEqual('application/json'); + }); + + it('should always append additional headers', () => { + sparqueApiService + .get('dummy', { + headers: new HttpHeaders({ + dummy: 'test', + }), + }) + .subscribe({ next: fail, error: fail, complete: fail }); + + const req = httpTestingController.expectOne(getRestURL('/dummy')); + expect(req.request.headers.keys()).not.toBeEmpty(); + expect(req.request.headers.has('dummy')).toBeTrue(); + expect(req.request.headers.get('content-type')).toEqual('application/json'); + expect(req.request.headers.get('Accept')).toEqual('application/json'); + }); + + it('should always have overridable default headers', () => { + sparqueApiService + .get('dummy', { + headers: new HttpHeaders({ + Accept: 'application/xml', + 'content-type': 'application/xml', + }), + }) + .subscribe({ next: fail, error: fail, complete: fail }); + + const req = httpTestingController.expectOne(getRestURL('/dummy')); + expect(req.request.headers.keys()).not.toBeEmpty(); + expect(req.request.headers.get('content-type')).toEqual('application/xml'); + expect(req.request.headers.get('Accept')).toEqual('application/xml'); + }); + + it('should have default response type of "json" if no other is provided', () => { + sparqueApiService.get('dummy').subscribe({ next: fail, error: fail, complete: fail }); + + const req = httpTestingController.expectOne(getRestURL('/dummy')); + expect(req.request.responseType).toEqual('json'); + }); + + it('should append specific response type of "text" if provided', () => { + sparqueApiService.get('dummy', { responseType: 'text' }).subscribe({ next: fail, error: fail, complete: fail }); + + const req = httpTestingController.expectOne(getRestURL('/dummy')); + expect(req.request.responseType).toEqual('text'); + }); + }); + + describe('Sparque API Service general error handling', () => { + let sparqueApiService: SparqueApiService; + let store: Store; + let httpTestingController: HttpTestingController; + const apiTokenServiceMock = mock(ApiTokenService); + + beforeEach(() => { + TestBed.configureTestingModule({ + // https://angular.io/guide/http#testing-http-requests + imports: [HttpClientTestingModule], + providers: [ + { provide: ApiTokenService, useFactory: () => instance(apiTokenServiceMock) }, + { provide: TokenService, useFactory: () => instance(mock(TokenService)) }, + provideMockStore({ + selectors: [ + { selector: getCurrentLocale, value: 'en_US' }, + { + selector: getSparqueConfig, + value: sparqueConfig, + }, + ], + }), + ], + }); + + sparqueApiService = TestBed.inject(SparqueApiService); + httpTestingController = TestBed.inject(HttpTestingController); + store = spy(TestBed.inject(Store)); + + when(apiTokenServiceMock.apiToken$).thenReturn(new BehaviorSubject('apiToken')); + }); + + afterEach(() => { + // After every test, assert that there are no more pending requests. + httpTestingController.verify(); + }); + + it('should dispatch communication timeout errors when getting status 0', done => { + sparqueApiService.get('route').subscribe({ next: fail, error: fail, complete: done }); + + httpTestingController + .expectOne(() => true) + .flush('', { + status: 0, + statusText: 'Error', + }); + + verify(store.dispatch(anything())).once(); + expect(capture(store.dispatch).last()?.[0]).toMatchInlineSnapshot(` + [Error Internal] Communication Timeout Error: + error: {"headers":{"normalizedNames":{},"lazyUpdate":null,"headers"... + `); + }); + + it('should dispatch general errors when getting status 500', done => { + sparqueApiService.get('route').subscribe({ next: fail, error: fail, complete: done }); + + httpTestingController + .expectOne(() => true) + .flush('', { + status: 500, + statusText: 'Error', + }); + + verify(store.dispatch(anything())).once(); + expect(capture(store.dispatch).last()?.[0]).toMatchInlineSnapshot(` + [Error Internal] Server Error (5xx): + error: {"headers":{"normalizedNames":{},"lazyUpdate":null,"headers"... + `); + }); + + it('should not dispatch errors when getting status 404', done => { + sparqueApiService.get('route').subscribe({ + next: fail, + error: err => { + expect(err).toBeInstanceOf(HttpErrorResponse); + done(); + }, + complete: fail, + }); + + httpTestingController + .expectOne(() => true) + .flush('', { + status: 404, + statusText: 'Error', + }); + + verify(store.dispatch(anything())).never(); + }); + }); +}); diff --git a/src/app/core/services/sparque-api/sparque-api.service.ts b/src/app/core/services/sparque-api/sparque-api.service.ts new file mode 100644 index 0000000000..d9c26bcc9d --- /dev/null +++ b/src/app/core/services/sparque-api/sparque-api.service.ts @@ -0,0 +1,143 @@ +import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { concatLatestFrom } from '@ngrx/effects'; +import { Store, select } from '@ngrx/store'; +import { Observable, combineLatest, defer, first, forkJoin, iif, map, of, switchMap, take } from 'rxjs'; + +import { FeatureToggleService } from 'ish-core/feature-toggle.module'; +import { ApiService, AvailableOptions } from 'ish-core/services/api/api.service'; +import { TokenService } from 'ish-core/services/token/token.service'; +import { getCurrentLocale, getSparqueConfig } from 'ish-core/store/core/configuration'; +import { ApiTokenService } from 'ish-core/utils/api-token/api-token.service'; +import { whenTruthy } from 'ish-core/utils/operators'; + +// sparque config keys that should not be appended to the query params +const SPARQUE_CONFIG_EXCLUDE_PARAMS = ['server_url', 'wrapperAPI']; + +/** + * Service to interact with the Sparque API. + * + * This service extends the `ApiService` and provides methods to construct HTTP client parameters, + * headers, and URLs for making API requests to the Sparque backend. + * + * @extends ApiService + * + * @constructor + * @param httpClient - The HTTP client used to make requests. + * @param store - The store used to select state. + * @param featureToggleService - The service used to manage feature toggles. + * @param apiTokenService - The service used to manage API tokens. + * @param tokenService - The service used to fetch tokens. + */ +@Injectable({ providedIn: 'root' }) +export class SparqueApiService extends ApiService { + constructor( + protected httpClient: HttpClient, + protected store: Store, + protected featureToggleService: FeatureToggleService, + private apiTokenService: ApiTokenService, + private tokenService: TokenService + ) { + super(httpClient, store, featureToggleService); + } + + protected constructHttpClientParams( + path: string, + options?: AvailableOptions + ): Observable<[string, { headers: HttpHeaders; params: HttpParams }]> { + return forkJoin([ + this.constructUrlForPath(path), + defer(() => + this.constructHeaders(options).pipe( + map(headers => ({ + headers, + params: options?.params + ? // append incoming params to default ones + options.params + .keys() + .reduce((acc, key) => acc.set(key, options.params.get(key)), this.sparqueQueryToHttpParams(path)) + : // just use default headers + this.sparqueQueryToHttpParams(path), + responseType: options?.responseType, + })) + ) + ), + ]); + } + + /** + * Converts a given path to HTTP parameters based on the Sparque configuration and current locale. + * If the path starts with 'http://' or 'https://', it returns an empty set of HTTP parameters. + * Otherwise, it retrieves the Sparque configuration and current locale from the store, + * and appends them as HTTP parameters, excluding specific keys defined in SPARQUE_CONFIG_EXCLUDE_PARAMS. + * + * @param path - The path to be converted to HTTP parameters. + * @returns An instance of HttpParams containing the Sparque configuration and locale. + */ + private sparqueQueryToHttpParams(path: string): HttpParams { + if (path.startsWith('http://') || path.startsWith('https://')) { + return new HttpParams(); + } + let sparqueParams = new HttpParams(); + + this.store + .pipe( + select(getSparqueConfig), + take(1), + concatLatestFrom(() => this.store.pipe(select(getCurrentLocale))) + ) + .subscribe(([config, locale]) => { + Object.keys(config).forEach(key => { + if (!SPARQUE_CONFIG_EXCLUDE_PARAMS.includes(key)) { + sparqueParams = sparqueParams.append(key, String(config[key])); + } + }); + sparqueParams = sparqueParams.append('Locale', locale.replace('_', '-')); + }); + return sparqueParams; + } + + /** + * merges supplied and default headers + */ + protected constructHeaders(options?: AvailableOptions): Observable { + let defaultHeaders = new HttpHeaders().set('content-type', 'application/json').set('Accept', 'application/json'); + + return this.apiTokenService.apiToken$.pipe( + first(), + switchMap(apiToken => + iif(() => !!apiToken, of(apiToken), this.tokenService.fetchToken('anonymous')).pipe( + whenTruthy(), + first(), + switchMap(apiToken => { + defaultHeaders = defaultHeaders.append('Authorization', `bearer ${apiToken}`); + return of( + options?.headers + ? // append incoming headers to default ones + options.headers.keys().reduce((acc, key) => acc.set(key, options.headers.get(key)), defaultHeaders) + : // just use default headers + defaultHeaders + ); + }) + ) + ) + ); + } + + constructUrlForPath(path: string): Observable { + if (path.startsWith('http://') || path.startsWith('https://')) { + return of(path); + } + return combineLatest([ + this.store.pipe( + select(getSparqueConfig), + whenTruthy(), + map(config => config.server_url.concat('/api/', config.wrapperAPI)) + ), + of(`/${path}`), + ]).pipe( + first(), + map(arr => arr.join('')) + ); + } +} diff --git a/src/app/core/services/sparque-suggestion/sparque-suggestion.service.spec.ts b/src/app/core/services/sparque-suggestion/sparque-suggestion.service.spec.ts new file mode 100644 index 0000000000..adb9bc5478 --- /dev/null +++ b/src/app/core/services/sparque-suggestion/sparque-suggestion.service.spec.ts @@ -0,0 +1,39 @@ +import { TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { anything, instance, mock, verify, when } from 'ts-mockito'; + +import { SparqueSuggestions } from 'ish-core/models/sparque-suggestion/sparque-suggestion.interface'; +import { SparqueApiService } from 'ish-core/services/sparque-api/sparque-api.service'; + +import { SparqueSuggestionService } from './sparque-suggestion.service'; + +describe('Sparque Suggestion Service', () => { + let sparqueApiService: SparqueApiService; + let suggestService: SparqueSuggestionService; + + beforeEach(() => { + sparqueApiService = mock(SparqueApiService); + when(sparqueApiService.get(anything(), anything())).thenReturn(of({ keywordSuggestions: [] })); + TestBed.configureTestingModule({ + providers: [{ provide: SparqueApiService, useFactory: () => instance(sparqueApiService) }], + }); + suggestService = TestBed.inject(SparqueSuggestionService); + }); + + it('should always delegate to api service when called', () => { + verify(sparqueApiService.get(anything(), anything())).never(); + + suggestService.search('some'); + verify(sparqueApiService.get(anything(), anything())).once(); + }); + + it('should return the matched terms when search term is executed', done => { + when(sparqueApiService.get(anything(), anything())).thenReturn(of({ keywordSuggestions: [{ keyword: 'Goods' }] })); + + suggestService.search('g').subscribe(res => { + expect(res.keywordSuggestions).toContainEqual('Goods'); + verify(sparqueApiService.get(anything(), anything())).once(); + done(); + }); + }); +}); diff --git a/src/app/core/services/sparque-suggestion/sparque-suggestion.service.ts b/src/app/core/services/sparque-suggestion/sparque-suggestion.service.ts new file mode 100644 index 0000000000..e329bfd1d6 --- /dev/null +++ b/src/app/core/services/sparque-suggestion/sparque-suggestion.service.ts @@ -0,0 +1,34 @@ +import { HttpParams } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable, map } from 'rxjs'; + +import { SparqueSuggestions } from 'ish-core/models/sparque-suggestion/sparque-suggestion.interface'; +import { SparqueSuggestionMapper } from 'ish-core/models/sparque-suggestion/sparque-suggestion.mapper'; +import { Suggestion } from 'ish-core/models/suggestion/suggestion.model'; +import { SparqueApiService } from 'ish-core/services/sparque-api/sparque-api.service'; +import { SuggestionService } from 'ish-core/services/suggestion/suggestion.service'; + +// not-dead-code +/** + * Service to fetch suggested search terms from the Sparque Wrapper API. + * Implements the SuggestService interface. + */ +@Injectable({ providedIn: 'root' }) +export class SparqueSuggestionService extends SuggestionService { + constructor(private sparqueApiService: SparqueApiService) { + super(); + } + + /** + * Searches for suggestions based on the provided search term. + * + * @param searchTerm - The term to search for suggestions. + * @returns An Observable emitting the search suggestions. + */ + search(searchTerm: string): Observable { + const params = new HttpParams().set('Keyword', searchTerm); + return this.sparqueApiService + .get(`suggestions`, { params }) + .pipe(map(SparqueSuggestionMapper.fromData)); + } +} diff --git a/src/app/core/services/suggest/suggest.service.ts b/src/app/core/services/suggest/suggest.service.ts deleted file mode 100644 index eb46b1fdfe..0000000000 --- a/src/app/core/services/suggest/suggest.service.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { HttpParams } from '@angular/common/http'; -import { Injectable } from '@angular/core'; -import { Observable } from 'rxjs'; - -import { SuggestTerm } from 'ish-core/models/suggest-term/suggest-term.model'; -import { ApiService, unpackEnvelope } from 'ish-core/services/api/api.service'; - -/** - * The Suggest Service handles the interaction with the 'suggest' REST API. - */ -@Injectable({ providedIn: 'root' }) -export class SuggestService { - constructor(private apiService: ApiService) {} - - /** - * Returns a list of suggested search terms matching the given search term. - * - * @param searchTerm The search term to get suggestions for. - * @returns List of suggested search terms. - */ - search(searchTerm: string): Observable { - const params = new HttpParams().set('SearchTerm', searchTerm); - return this.apiService.get('suggest', { params }).pipe(unpackEnvelope()); - } -} diff --git a/src/app/core/services/suggestion/suggestion.service.ts b/src/app/core/services/suggestion/suggestion.service.ts new file mode 100644 index 0000000000..2fecce7079 --- /dev/null +++ b/src/app/core/services/suggestion/suggestion.service.ts @@ -0,0 +1,22 @@ +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; + +import { Suggestion } from 'ish-core/models/suggestion/suggestion.model'; + +/** + * Abstract service for providing search term suggestions. + * + * This service defines a contract for implementing search term suggestion functionality. + * Implementations of this service should provide a method to return a list of suggested + * search terms based on a given search term. + */ +@Injectable({ providedIn: 'root' }) +export abstract class SuggestionService { + /** + * Searches for suggestions based on the provided search term. + * + * @param searchTerm - The term to search for suggestions. + * @returns An observable that emits the search results. + */ + abstract search(searchTerm: string): Observable; +} diff --git a/src/app/shared/components/search/search-box/search-box.component.html b/src/app/core/standalone/component/suggest/advanced-search-box/advanced-search-box.component.html similarity index 93% rename from src/app/shared/components/search/search-box/search-box.component.html rename to src/app/core/standalone/component/suggest/advanced-search-box/advanced-search-box.component.html index 49dcf5e42c..04a38af6f4 100644 --- a/src/app/shared/components/search/search-box/search-box.component.html +++ b/src/app/core/standalone/component/suggest/advanced-search-box/advanced-search-box.component.html @@ -51,13 +51,13 @@
  • diff --git a/src/app/core/standalone/component/suggest/advanced-search-box/advanced-search-box.component.spec.ts b/src/app/core/standalone/component/suggest/advanced-search-box/advanced-search-box.component.spec.ts new file mode 100644 index 0000000000..0ac848e7ff --- /dev/null +++ b/src/app/core/standalone/component/suggest/advanced-search-box/advanced-search-box.component.spec.ts @@ -0,0 +1,120 @@ +/* eslint-disable ish-custom-rules/ban-imports-file-pattern */ +import { CommonModule } from '@angular/common'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { ReplaySubject, Subject } from 'rxjs'; + +import { ShoppingFacade } from 'ish-core/facades/shopping.facade'; +import { IconModule } from 'ish-core/icon.module'; +import { PipesModule } from 'ish-core/pipes.module'; + +import { AdvancedSearchBoxComponent } from './advanced-search-box.component'; + +describe('Advanced Search Box Component', () => { + let component: AdvancedSearchBoxComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + let searchResults$: Subject; + let searchTerm$: Subject; + + beforeEach(async () => { + searchResults$ = new ReplaySubject(1); + searchTerm$ = new ReplaySubject(1); + searchResults$.next(undefined); + searchTerm$.next(undefined); + + await TestBed.configureTestingModule({ + imports: [CommonModule, IconModule, PipesModule, RouterTestingModule, TranslateModule.forRoot()], + providers: [ + { + provide: ShoppingFacade, + useFactory: () => ({ searchResults$: () => searchResults$, searchTerm$ } as Partial), + }, + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(AdvancedSearchBoxComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + + // activate + component.inputFocused = true; + component.configuration = { maxAutoSuggests: 4 }; + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); + + describe('with no results', () => { + beforeEach(() => { + searchResults$.next(undefined); + }); + + it('should show no results when no suggestions are found', () => { + fixture.detectChanges(); + + const ul = element.querySelector('.search-suggest-results'); + expect(ul).toBeFalsy(); + }); + }); + + describe('with results', () => { + beforeEach(() => { + searchResults$.next(['Cameras', 'Camcorders']); + }); + + it('should show results when suggestions are available', () => { + fixture.detectChanges(); + + const ul = element.querySelector('.search-suggest-results'); + expect(ul.querySelectorAll('li')).toHaveLength(2); + }); + + it('should show no results when suggestions are available but maxAutoSuggests is 0', () => { + component.configuration.maxAutoSuggests = 0; + fixture.detectChanges(); + + const ul = element.querySelector('.search-suggest-results'); + expect(ul.querySelectorAll('li')).toHaveLength(0); + }); + + it('should show no results when suggestions are available but input has no focus', () => { + component.inputFocused = false; + fixture.detectChanges(); + + expect(element.querySelector('.search-suggest-results')).toBeFalsy(); + }); + }); + + describe('with inputs', () => { + it('should show searchTerm when on search page', () => { + searchTerm$.next('search'); + + fixture.detectChanges(); + const input = element.querySelector('input'); + expect(input.value).toContain('search'); + }); + + it('should show button text when buttonText is set', () => { + component.configuration = { buttonText: 'buttonTextInput' }; + + fixture.detectChanges(); + const button = element.querySelector('.btn-search'); + expect(button.textContent).toContain('buttonTextInput'); + }); + + it('should show placeholder text when placeholder is set', () => { + component.configuration = { placeholder: 'placeholderInput' }; + + fixture.detectChanges(); + const inputElement = element.querySelector('.searchTerm'); + expect(inputElement.getAttribute('placeholder')).toBe('placeholderInput'); + }); + }); +}); diff --git a/src/app/core/standalone/component/suggest/advanced-search-box/advanced-search-box.component.ts b/src/app/core/standalone/component/suggest/advanced-search-box/advanced-search-box.component.ts new file mode 100644 index 0000000000..075228047c --- /dev/null +++ b/src/app/core/standalone/component/suggest/advanced-search-box/advanced-search-box.component.ts @@ -0,0 +1,128 @@ +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component, DestroyRef, Input, OnInit, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Router } from '@angular/router'; +import { IconName } from '@fortawesome/fontawesome-svg-core'; +import { TranslateModule } from '@ngx-translate/core'; +import { Observable, ReplaySubject, map, take } from 'rxjs'; + +import { ShoppingFacade } from 'ish-core/facades/shopping.facade'; +import { IconModule } from 'ish-core/icon.module'; +import { SearchBoxConfiguration } from 'ish-core/models/search-box-configuration/search-box-configuration.model'; +import { PipesModule } from 'ish-core/pipes.module'; + +/** + * @description + * The `AdvancedSearchBoxComponent` is a standalone component that provides a search box with auto-suggest functionality. + * It interacts with the `ShoppingFacade` to fetch search suggestions and handles user input to perform searches. + * + * @example + * + * + * @property {SearchBoxConfiguration} configuration - The search box configuration for this component. + * @property {Observable} searchResults$ - An observable stream of search suggestions. + * @property {ReplaySubject} inputSearchTerms$ - A subject to emit search terms entered by the user. + * @property {number} activeIndex - The index of the currently active suggestion. + * @property {boolean} inputFocused - Indicates whether the search input is focused. + * + * @method blur - Handles the blur event of the search input. + * @method focus - Handles the focus event of the search input. + * @method searchSuggest - Emits a new search term to trigger suggestions. + * @method submitSearch - Submits the selected or entered search term. + * @method selectSuggestedTerm - Selects a suggested term based on the provided index. + * + * @constructor + * @param {ShoppingFacade} shoppingFacade - The facade to interact with the shopping state. + * @param {Router} router - The Angular router to navigate to the search results page. + * + * @getter usedIcon - Returns the icon to be used in the search box. + * + * @lifecycle ngOnInit - Initializes the component and sets up the necessary streams. + */ +@Component({ + selector: 'ish-advanced-search-box', + templateUrl: './advanced-search-box.component.html', + standalone: true, + imports: [CommonModule, IconModule, PipesModule, TranslateModule], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AdvancedSearchBoxComponent implements OnInit { + /** + * the search box configuration for this component + */ + @Input() configuration: SearchBoxConfiguration; + + searchResults$: Observable; + inputSearchTerms$ = new ReplaySubject(1); + + activeIndex = -1; + inputFocused: boolean; + + private destroyRef = inject(DestroyRef); + + constructor(private shoppingFacade: ShoppingFacade, private router: Router) {} + + get usedIcon(): IconName { + return this.configuration?.icon || 'search'; + } + + ngOnInit() { + // initialize with searchTerm when on search route + this.shoppingFacade.searchTerm$ + .pipe( + map(x => (x ? x : '')), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(term => this.inputSearchTerms$.next(term)); + + // suggests are triggered solely via stream + this.searchResults$ = this.shoppingFacade.searchResults$(this.inputSearchTerms$); + } + blur() { + this.inputFocused = false; + this.activeIndex = -1; + } + + focus() { + this.inputFocused = true; + } + + searchSuggest(source: string | EventTarget) { + this.inputSearchTerms$.next(typeof source === 'string' ? source : (source as HTMLDataElement).value); + } + + submitSearch(suggestedTerm: string) { + if (!suggestedTerm) { + return false; + } + + // remove focus when switching to search page + this.inputFocused = false; + + if (this.activeIndex !== -1) { + // something was selected via keyboard + this.searchResults$.pipe(take(1), takeUntilDestroyed(this.destroyRef)).subscribe(results => { + this.router.navigate(['/search', results[this.activeIndex]]); + this.activeIndex = -1; + }); + } else { + this.router.navigate(['/search', suggestedTerm]); + } + + // prevent form submission + return false; + } + + selectSuggestedTerm(index: number) { + this.searchResults$.pipe(take(1), takeUntilDestroyed(this.destroyRef)).subscribe(results => { + if ( + (this.configuration?.maxAutoSuggests && index > this.configuration.maxAutoSuggests - 1) || + index < -1 || + index > results.length - 1 + ) { + return; + } + this.activeIndex = index; + }); + } +} diff --git a/src/app/core/standalone/component/suggest/simple-search-box/simple-search-box.component.html b/src/app/core/standalone/component/suggest/simple-search-box/simple-search-box.component.html new file mode 100644 index 0000000000..04a38af6f4 --- /dev/null +++ b/src/app/core/standalone/component/suggest/simple-search-box/simple-search-box.component.html @@ -0,0 +1,65 @@ + diff --git a/src/app/shared/components/search/search-box/search-box.component.spec.ts b/src/app/core/standalone/component/suggest/simple-search-box/simple-search-box.component.spec.ts similarity index 80% rename from src/app/shared/components/search/search-box/search-box.component.spec.ts rename to src/app/core/standalone/component/suggest/simple-search-box/simple-search-box.component.spec.ts index 93a2b879b6..b5ac33357e 100644 --- a/src/app/shared/components/search/search-box/search-box.component.spec.ts +++ b/src/app/core/standalone/component/suggest/simple-search-box/simple-search-box.component.spec.ts @@ -1,20 +1,21 @@ +/* eslint-disable ish-custom-rules/ban-imports-file-pattern */ +import { CommonModule } from '@angular/common'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { RouterTestingModule } from '@angular/router/testing'; import { TranslateModule } from '@ngx-translate/core'; -import { MockComponent, MockPipe } from 'ng-mocks'; import { ReplaySubject, Subject } from 'rxjs'; import { ShoppingFacade } from 'ish-core/facades/shopping.facade'; -import { SuggestTerm } from 'ish-core/models/suggest-term/suggest-term.model'; -import { HighlightPipe } from 'ish-core/pipes/highlight.pipe'; +import { IconModule } from 'ish-core/icon.module'; +import { PipesModule } from 'ish-core/pipes.module'; -import { SearchBoxComponent } from './search-box.component'; +import { SimpleSearchBoxComponent } from './simple-search-box.component'; -describe('Search Box Component', () => { - let component: SearchBoxComponent; - let fixture: ComponentFixture; +describe('Simple Search Box Component', () => { + let component: SimpleSearchBoxComponent; + let fixture: ComponentFixture; let element: HTMLElement; - let searchResults$: Subject; + let searchResults$: Subject; let searchTerm$: Subject; beforeEach(async () => { @@ -24,8 +25,7 @@ describe('Search Box Component', () => { searchTerm$.next(undefined); await TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot()], - declarations: [MockComponent(FaIconComponent), MockPipe(HighlightPipe), SearchBoxComponent], + imports: [CommonModule, IconModule, PipesModule, RouterTestingModule, TranslateModule.forRoot()], providers: [ { provide: ShoppingFacade, @@ -36,7 +36,7 @@ describe('Search Box Component', () => { }); beforeEach(() => { - fixture = TestBed.createComponent(SearchBoxComponent); + fixture = TestBed.createComponent(SimpleSearchBoxComponent); component = fixture.componentInstance; element = fixture.nativeElement; @@ -66,7 +66,7 @@ describe('Search Box Component', () => { describe('with results', () => { beforeEach(() => { - searchResults$.next([{ term: 'Cameras' }, { term: 'Camcorders' }]); + searchResults$.next(['Cameras', 'Camcorders']); }); it('should show results when suggestions are available', () => { diff --git a/src/app/shared/components/search/search-box/search-box.component.ts b/src/app/core/standalone/component/suggest/simple-search-box/simple-search-box.component.ts similarity index 80% rename from src/app/shared/components/search/search-box/search-box.component.ts rename to src/app/core/standalone/component/suggest/simple-search-box/simple-search-box.component.ts index 52c574f8bb..511262cc6e 100644 --- a/src/app/shared/components/search/search-box/search-box.component.ts +++ b/src/app/core/standalone/component/suggest/simple-search-box/simple-search-box.component.ts @@ -1,14 +1,16 @@ +import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, DestroyRef, Input, OnInit, inject } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { Router } from '@angular/router'; import { IconName } from '@fortawesome/fontawesome-svg-core'; +import { TranslateModule } from '@ngx-translate/core'; import { Observable, ReplaySubject } from 'rxjs'; import { map, take } from 'rxjs/operators'; import { ShoppingFacade } from 'ish-core/facades/shopping.facade'; +import { IconModule } from 'ish-core/icon.module'; import { SearchBoxConfiguration } from 'ish-core/models/search-box-configuration/search-box-configuration.model'; -import { SuggestTerm } from 'ish-core/models/suggest-term/suggest-term.model'; -import { GenerateLazyComponent } from 'ish-core/utils/module-loader/generate-lazy-component.decorator'; +import { PipesModule } from 'ish-core/pipes.module'; /** * The search box container component @@ -17,21 +19,22 @@ import { GenerateLazyComponent } from 'ish-core/utils/module-loader/generate-laz * uses input to display the search box * * @example - * + * */ @Component({ - selector: 'ish-search-box', - templateUrl: './search-box.component.html', + selector: 'ish-simple-search-box', + templateUrl: './simple-search-box.component.html', + standalone: true, + imports: [CommonModule, IconModule, PipesModule, TranslateModule], changeDetection: ChangeDetectionStrategy.OnPush, }) -@GenerateLazyComponent() -export class SearchBoxComponent implements OnInit { +export class SimpleSearchBoxComponent implements OnInit { /** * the search box configuration for this component */ @Input() configuration: SearchBoxConfiguration; - searchResults$: Observable; + searchResults$: Observable; inputSearchTerms$ = new ReplaySubject(1); activeIndex = -1; @@ -55,7 +58,7 @@ export class SearchBoxComponent implements OnInit { .subscribe(term => this.inputSearchTerms$.next(term)); // suggests are triggered solely via stream - this.searchResults$ = this.shoppingFacade.searchResults$(this.inputSearchTerms$); + this.searchResults$ = this.shoppingFacade.searchResults$(this.inputSearchTerms$) as Observable; } blur() { this.inputFocused = false; @@ -81,7 +84,7 @@ export class SearchBoxComponent implements OnInit { if (this.activeIndex !== -1) { // something was selected via keyboard this.searchResults$.pipe(take(1), takeUntilDestroyed(this.destroyRef)).subscribe(results => { - this.router.navigate(['/search', results[this.activeIndex].term]); + this.router.navigate(['/search', results[this.activeIndex]]); this.activeIndex = -1; }); } else { diff --git a/src/app/core/store/core/configuration/configuration.effects.ts b/src/app/core/store/core/configuration/configuration.effects.ts index 2f4b00b76d..7ce77d73f1 100644 --- a/src/app/core/store/core/configuration/configuration.effects.ts +++ b/src/app/core/store/core/configuration/configuration.effects.ts @@ -9,6 +9,7 @@ import { LARGE_BREAKPOINT_WIDTH, MEDIUM_BREAKPOINT_WIDTH } from 'ish-core/config import { NGRX_STATE_SK } from 'ish-core/configurations/ngrx-state-transfer'; import { SSR_LOCALE } from 'ish-core/configurations/state-keys'; import { FeatureToggleType } from 'ish-core/feature-toggle.module'; +import { SparqueConfig } from 'ish-core/models/sparque/sparque-config.model'; import { DeviceType } from 'ish-core/models/viewtype/viewtype.types'; import { LocalizationsService } from 'ish-core/services/localizations/localizations.service'; import { DomService } from 'ish-core/utils/dom/dom.service'; @@ -84,6 +85,7 @@ export class ConfigurationEffects { 'MULTI_SITE_LOCALE_MAP', 'multiSiteLocaleMap' ), + this.stateProperties.getStateOrEnvOrDefault('SPARQUE', 'sparque'), ]), map( ([ @@ -97,6 +99,7 @@ export class ConfigurationEffects { identityProvider, identityProviders, multiSiteLocaleMap, + sparque, ]) => applyConfiguration({ baseURL, @@ -108,6 +111,7 @@ export class ConfigurationEffects { identityProvider, identityProviders, multiSiteLocaleMap, + sparque, }) ) ), diff --git a/src/app/core/store/core/configuration/configuration.reducer.ts b/src/app/core/store/core/configuration/configuration.reducer.ts index 2429cb5d97..babea8674c 100644 --- a/src/app/core/store/core/configuration/configuration.reducer.ts +++ b/src/app/core/store/core/configuration/configuration.reducer.ts @@ -1,6 +1,7 @@ import { createReducer, on } from '@ngrx/store'; import { FeatureToggleType } from 'ish-core/feature-toggle.module'; +import { SparqueConfig } from 'ish-core/models/sparque/sparque-config.model'; import { DeviceType } from 'ish-core/models/viewtype/viewtype.types'; import { Translations } from 'ish-core/utils/translate/translations.type'; @@ -28,6 +29,7 @@ export interface ConfigurationState { multiSiteLocaleMap: Record; // not synced via state transfer _deviceType?: DeviceType; + sparque?: SparqueConfig; } const initialState: ConfigurationState = { @@ -47,6 +49,7 @@ const initialState: ConfigurationState = { serverTranslations: {}, multiSiteLocaleMap: undefined, _deviceType: environment.defaultDeviceType, + sparque: environment.sparque, }; function addSingleTranslation( diff --git a/src/app/core/store/core/configuration/configuration.selectors.ts b/src/app/core/store/core/configuration/configuration.selectors.ts index 142b5ee8f5..9977e81706 100644 --- a/src/app/core/store/core/configuration/configuration.selectors.ts +++ b/src/app/core/store/core/configuration/configuration.selectors.ts @@ -136,3 +136,5 @@ export const getMultiSiteLocaleMap = createSelector( getConfigurationState, (state: ConfigurationState) => state.multiSiteLocaleMap ); + +export const getSparqueConfig = createSelector(getConfigurationState, state => state.sparque); diff --git a/src/app/core/store/customer/customer-store.spec.ts b/src/app/core/store/customer/customer-store.spec.ts index faa98e54fd..3a2806a30f 100644 --- a/src/app/core/store/customer/customer-store.spec.ts +++ b/src/app/core/store/customer/customer-store.spec.ts @@ -27,7 +27,7 @@ import { PaymentService } from 'ish-core/services/payment/payment.service'; import { PricesService } from 'ish-core/services/prices/prices.service'; import { ProductsService } from 'ish-core/services/products/products.service'; import { PromotionsService } from 'ish-core/services/promotions/promotions.service'; -import { SuggestService } from 'ish-core/services/suggest/suggest.service'; +import { SuggestionService } from 'ish-core/services/suggestion/suggestion.service'; import { TokenService } from 'ish-core/services/token/token.service'; import { UserService } from 'ish-core/services/user/user.service'; import { WarrantyService } from 'ish-core/services/warranty/warranty.service'; @@ -186,7 +186,7 @@ describe('Customer Store', () => { { provide: PricesService, useFactory: () => instance(productPriceServiceMock) }, { provide: ProductsService, useFactory: () => instance(productsServiceMock) }, { provide: PromotionsService, useFactory: () => instance(promotionsServiceMock) }, - { provide: SuggestService, useFactory: () => instance(mock(SuggestService)) }, + { provide: SuggestionService, useFactory: () => instance(mock(SuggestionService)) }, { provide: TokenService, useFactory: () => instance(mock(TokenService)) }, { provide: UserService, useFactory: () => instance(userServiceMock) }, { provide: WarrantyService, useFactory: () => instance(mock(WarrantyService)) }, diff --git a/src/app/core/store/shopping/search/search.actions.ts b/src/app/core/store/shopping/search/search.actions.ts index 3828333822..f5ba68462c 100644 --- a/src/app/core/store/shopping/search/search.actions.ts +++ b/src/app/core/store/shopping/search/search.actions.ts @@ -1,6 +1,6 @@ import { createAction } from '@ngrx/store'; -import { SuggestTerm } from 'ish-core/models/suggest-term/suggest-term.model'; +import { Suggestion } from 'ish-core/models/suggestion/suggestion.model'; import { httpError, payload } from 'ish-core/utils/ngrx-creators'; export const searchProducts = createAction( @@ -17,5 +17,5 @@ export const suggestSearch = createAction( export const suggestSearchSuccess = createAction( '[Suggest Search API] Return Search Suggestions', - payload<{ searchTerm: string; suggests: SuggestTerm[] }>() + payload<{ searchTerm: string; suggests: Suggestion }>() ); diff --git a/src/app/core/store/shopping/search/search.effects.spec.ts b/src/app/core/store/shopping/search/search.effects.spec.ts index 10ca74a5b7..29f064d846 100644 --- a/src/app/core/store/shopping/search/search.effects.spec.ts +++ b/src/app/core/store/shopping/search/search.effects.spec.ts @@ -5,9 +5,10 @@ import { TranslateModule } from '@ngx-translate/core'; import { of, throwError } from 'rxjs'; import { anyNumber, anyString, anything, capture, instance, mock, spy, verify, when } from 'ts-mockito'; -import { SuggestTerm } from 'ish-core/models/suggest-term/suggest-term.model'; +import { Suggestion } from 'ish-core/models/suggestion/suggestion.model'; +import { ICMSuggestionService } from 'ish-core/services/icm-suggestion/icm-suggestion.service'; import { ProductsService } from 'ish-core/services/products/products.service'; -import { SuggestService } from 'ish-core/services/suggest/suggest.service'; +import { SuggestionService } from 'ish-core/services/suggestion/suggestion.service'; import { CoreStoreModule } from 'ish-core/store/core/core-store.module'; import { personalizationStatusDetermined } from 'ish-core/store/customer/user'; import { loadMoreProducts, setProductListingPageSize } from 'ish-core/store/shopping/product-listing'; @@ -25,14 +26,14 @@ describe('Search Effects', () => { let effects: SearchEffects; let router: Router; let productsServiceMock: ProductsService; - let suggestServiceMock: SuggestService; + let suggestionServiceMock: ICMSuggestionService; let httpStatusCodeService: HttpStatusCodeService; - const suggests = [{ term: 'Goods' }] as SuggestTerm[]; + const suggests = [{ keywordSuggestions: ['Goods'] }] as Suggestion; beforeEach(() => { - suggestServiceMock = mock(SuggestService); - when(suggestServiceMock.search(anyString())).thenReturn(of(suggests)); + suggestionServiceMock = mock(ICMSuggestionService); + when(suggestionServiceMock.search(anyString())).thenReturn(of(suggests)); productsServiceMock = mock(ProductsService); const skus = ['A', 'B', 'C', 'D', 'E', 'F', 'G']; when(productsServiceMock.searchProducts(anyString(), anyNumber(), anything(), anyNumber())).thenCall( @@ -62,7 +63,7 @@ describe('Search Effects', () => { ], providers: [ { provide: ProductsService, useFactory: () => instance(productsServiceMock) }, - { provide: SuggestService, useFactory: () => instance(suggestServiceMock) }, + { provide: SuggestionService, useFactory: () => instance(suggestionServiceMock) }, provideStoreSnapshots(), ], }); @@ -101,7 +102,7 @@ describe('Search Effects', () => { tick(5000); - verify(suggestServiceMock.search(anyString())).never(); + verify(suggestionServiceMock.search(anyString())).never(); })); it('should not fire when search term is empty', fakeAsync(() => { @@ -110,7 +111,7 @@ describe('Search Effects', () => { tick(5000); - verify(suggestServiceMock.search(anyString())).never(); + verify(suggestionServiceMock.search(anyString())).never(); })); it('should return search terms when available', fakeAsync(() => { @@ -119,7 +120,7 @@ describe('Search Effects', () => { tick(5000); - verify(suggestServiceMock.search('g')).once(); + verify(suggestionServiceMock.search('g')).once(); })); it('should debounce correctly when search term is entered stepwise', fakeAsync(() => { @@ -130,31 +131,31 @@ describe('Search Effects', () => { store$.dispatch(suggestSearch({ searchTerm: 'good' })); tick(200); - verify(suggestServiceMock.search(anyString())).never(); + verify(suggestionServiceMock.search(anyString())).never(); tick(400); - verify(suggestServiceMock.search('good')).once(); + verify(suggestionServiceMock.search('good')).once(); })); it('should send only once if search term is entered multiple times', fakeAsync(() => { store$.dispatch(suggestSearch({ searchTerm: 'good' })); tick(2000); - verify(suggestServiceMock.search('good')).once(); + verify(suggestionServiceMock.search('good')).once(); store$.dispatch(suggestSearch({ searchTerm: 'good' })); tick(2000); - verify(suggestServiceMock.search('good')).once(); + verify(suggestionServiceMock.search('good')).once(); })); it('should not fire action when error is encountered at service level', fakeAsync(() => { - when(suggestServiceMock.search(anyString())).thenReturn(throwError(() => makeHttpError({ message: 'ERROR' }))); + when(suggestionServiceMock.search(anyString())).thenReturn(throwError(() => makeHttpError({ message: 'ERROR' }))); store$.dispatch(suggestSearch({ searchTerm: 'good' })); tick(4000); effects.suggestSearch$.subscribe({ next: fail, error: fail }); - verify(suggestServiceMock.search('good')).once(); + verify(suggestionServiceMock.search('good')).once(); })); it('should fire all necessary actions for suggest-search', fakeAsync(() => { @@ -165,7 +166,7 @@ describe('Search Effects', () => { searchTerm: "good" [Suggest Search API] Return Search Suggestions: searchTerm: "good" - suggests: [{"term":"Goods"}] + suggests: [{"keywordSuggestions":["Goods"]}] `); // 2nd term to because distinctUntilChanged @@ -176,12 +177,12 @@ describe('Search Effects', () => { searchTerm: "good" [Suggest Search API] Return Search Suggestions: searchTerm: "good" - suggests: [{"term":"Goods"}] + suggests: [{"keywordSuggestions":["Goods"]}] [Suggest Search] Load Search Suggestions: searchTerm: "goo" [Suggest Search API] Return Search Suggestions: searchTerm: "goo" - suggests: [{"term":"Goods"}] + suggests: [{"keywordSuggestions":["Goods"]}] `); // test cache: search->api->success & search->success->api->success @@ -192,17 +193,17 @@ describe('Search Effects', () => { searchTerm: "good" [Suggest Search API] Return Search Suggestions: searchTerm: "good" - suggests: [{"term":"Goods"}] + suggests: [{"keywordSuggestions":["Goods"]}] [Suggest Search] Load Search Suggestions: searchTerm: "goo" [Suggest Search API] Return Search Suggestions: searchTerm: "goo" - suggests: [{"term":"Goods"}] + suggests: [{"keywordSuggestions":["Goods"]}] [Suggest Search] Load Search Suggestions: searchTerm: "good" [Suggest Search API] Return Search Suggestions: searchTerm: "good" - suggests: [{"term":"Goods"}] + suggests: [{"keywordSuggestions":["Goods"]}] `); })); }); diff --git a/src/app/core/store/shopping/search/search.effects.ts b/src/app/core/store/shopping/search/search.effects.ts index 5a9a2d6d30..9fa15e6c63 100644 --- a/src/app/core/store/shopping/search/search.effects.ts +++ b/src/app/core/store/shopping/search/search.effects.ts @@ -19,7 +19,7 @@ import { import { ProductListingMapper } from 'ish-core/models/product-listing/product-listing.mapper'; import { generateProductUrl } from 'ish-core/routing/product/product.route'; import { ProductsService } from 'ish-core/services/products/products.service'; -import { SuggestService } from 'ish-core/services/suggest/suggest.service'; +import { SuggestionService } from 'ish-core/services/suggestion/suggestion.service'; import { ofUrl, selectRouteParam } from 'ish-core/store/core/router'; import { setBreadcrumbData } from 'ish-core/store/core/viewconf'; import { personalizationStatusDetermined } from 'ish-core/store/customer/user'; @@ -46,7 +46,7 @@ export class SearchEffects { private actions$: Actions, private store: Store, private productsService: ProductsService, - private suggestService: SuggestService, + private suggestService: SuggestionService, private httpStatusCodeService: HttpStatusCodeService, private productListingMapper: ProductListingMapper, private translateService: TranslateService, diff --git a/src/app/core/store/shopping/search/search.reducer.ts b/src/app/core/store/shopping/search/search.reducer.ts index a8842b8ca0..95b4c5ac94 100644 --- a/src/app/core/store/shopping/search/search.reducer.ts +++ b/src/app/core/store/shopping/search/search.reducer.ts @@ -1,13 +1,13 @@ import { EntityState, createEntityAdapter } from '@ngrx/entity'; import { createReducer, on } from '@ngrx/store'; -import { SuggestTerm } from 'ish-core/models/suggest-term/suggest-term.model'; +import { Suggestion } from 'ish-core/models/suggestion/suggestion.model'; import { suggestSearchSuccess } from './search.actions'; interface SuggestSearch { searchTerm: string; - suggests: SuggestTerm[]; + suggests: Suggestion; } export const searchAdapter = createEntityAdapter({ diff --git a/src/app/core/store/shopping/search/search.selector.spec.ts b/src/app/core/store/shopping/search/search.selector.spec.ts index 11267380cd..c9592752cb 100644 --- a/src/app/core/store/shopping/search/search.selector.spec.ts +++ b/src/app/core/store/shopping/search/search.selector.spec.ts @@ -1,6 +1,6 @@ import { TestBed } from '@angular/core/testing'; -import { SuggestTerm } from 'ish-core/models/suggest-term/suggest-term.model'; +import { Suggestion } from 'ish-core/models/suggestion/suggestion.model'; import { CoreStoreModule } from 'ish-core/store/core/core-store.module'; import { ShoppingStoreModule } from 'ish-core/store/shopping/shopping-store.module'; import { StoreWithSnapshots, provideStoreSnapshots } from 'ish-core/utils/dev/ngrx-testing'; @@ -22,15 +22,15 @@ describe('Search Selector', () => { describe('getSuggestSearchResults', () => { beforeEach(() => { - store$.dispatch(suggestSearchSuccess({ searchTerm: 'searchTerm', suggests: [{ term: 'term' } as SuggestTerm] })); + store$.dispatch( + suggestSearchSuccess({ searchTerm: 'searchTerm', suggests: { keywordSuggestions: ['term'] } as Suggestion }) + ); }); it('should get search results when searchTerm exists', () => { expect(getSuggestSearchResults('searchTerm')(store$.state)).toMatchInlineSnapshot(` [ - { - "term": "term", - }, + "term", ] `); }); diff --git a/src/app/core/store/shopping/search/search.selectors.ts b/src/app/core/store/shopping/search/search.selectors.ts index 037edf3cb6..f15b7f4eea 100644 --- a/src/app/core/store/shopping/search/search.selectors.ts +++ b/src/app/core/store/shopping/search/search.selectors.ts @@ -12,4 +12,4 @@ const { selectEntities: getSuggestSearchEntities } = searchAdapter.getSelectors( export const getSearchTerm = selectRouteParam('searchTerm'); export const getSuggestSearchResults = (searchTerm: string) => - createSelector(getSuggestSearchEntities, entities => entities[searchTerm]?.suggests || []); + createSelector(getSuggestSearchEntities, entities => entities[searchTerm]?.suggests.keywordSuggestions || []); diff --git a/src/app/core/store/shopping/shopping-store.spec.ts b/src/app/core/store/shopping/shopping-store.spec.ts index 608f214c90..d6bd18c544 100644 --- a/src/app/core/store/shopping/shopping-store.spec.ts +++ b/src/app/core/store/shopping/shopping-store.spec.ts @@ -11,14 +11,16 @@ import { Category, CategoryCompletenessLevel } from 'ish-core/models/category/ca import { FilterNavigation } from 'ish-core/models/filter-navigation/filter-navigation.model'; import { Product } from 'ish-core/models/product/product.model'; import { Promotion } from 'ish-core/models/promotion/promotion.model'; +import { Suggestion } from 'ish-core/models/suggestion/suggestion.model'; import { CategoriesService } from 'ish-core/services/categories/categories.service'; import { ConfigurationService } from 'ish-core/services/configuration/configuration.service'; import { CountryService } from 'ish-core/services/country/country.service'; import { FilterService } from 'ish-core/services/filter/filter.service'; +import { ICMSuggestionService } from 'ish-core/services/icm-suggestion/icm-suggestion.service'; import { PricesService } from 'ish-core/services/prices/prices.service'; import { ProductsService } from 'ish-core/services/products/products.service'; import { PromotionsService } from 'ish-core/services/promotions/promotions.service'; -import { SuggestService } from 'ish-core/services/suggest/suggest.service'; +import { SuggestionService } from 'ish-core/services/suggestion/suggestion.service'; import { WarrantyService } from 'ish-core/services/warranty/warranty.service'; import { CoreStoreModule } from 'ish-core/store/core/core-store.module'; import { personalizationStatusDetermined } from 'ish-core/store/customer/user'; @@ -42,7 +44,7 @@ describe('Shopping Store', () => { let categoriesServiceMock: CategoriesService; let productsServiceMock: ProductsService; let promotionsServiceMock: PromotionsService; - let suggestServiceMock: SuggestService; + let suggestionServiceMock: ICMSuggestionService; let filterServiceMock: FilterService; let priceServiceMock: PricesService; let warrantyServiceMock: WarrantyService; @@ -133,8 +135,8 @@ describe('Shopping Store', () => { promotionsServiceMock = mock(PromotionsService); when(promotionsServiceMock.getPromotion(anything())).thenReturn(of(promotion)); - suggestServiceMock = mock(SuggestService); - when(suggestServiceMock.search('some')).thenReturn(of([{ term: 'something' }])); + suggestionServiceMock = mock(ICMSuggestionService); + when(suggestionServiceMock.search('some')).thenReturn(of({ keywordSuggestions: ['something'] })); filterServiceMock = mock(FilterService); when(filterServiceMock.getFilterForSearch(anything())).thenReturn(of({} as FilterNavigation)); @@ -189,7 +191,7 @@ describe('Shopping Store', () => { { provide: PricesService, useFactory: () => instance(priceServiceMock) }, { provide: ProductsService, useFactory: () => instance(productsServiceMock) }, { provide: PromotionsService, useFactory: () => instance(promotionsServiceMock) }, - { provide: SuggestService, useFactory: () => instance(suggestServiceMock) }, + { provide: SuggestionService, useFactory: () => instance(suggestionServiceMock) }, { provide: WarrantyService, useFactory: () => instance(warrantyServiceMock) }, provideStoreSnapshots(), SelectedProductContextFacade, @@ -275,7 +277,7 @@ describe('Shopping Store', () => { searchTerm: "some" [Suggest Search API] Return Search Suggestions: searchTerm: "some" - suggests: [{"term":"something"}] + suggests: {"keywordSuggestions":[1]} `); }); }); diff --git a/src/app/core/utils/api-token/api-token.service.ts b/src/app/core/utils/api-token/api-token.service.ts index 115b918b0f..2659226fa7 100644 --- a/src/app/core/utils/api-token/api-token.service.ts +++ b/src/app/core/utils/api-token/api-token.service.ts @@ -66,7 +66,7 @@ export class ApiTokenService { /** * stores current apiToken information, will be used to add authentication header for each request */ - private apiToken$: BehaviorSubject; + apiToken$: BehaviorSubject; /** * informs subscriber that cookie is unexpectedly removed (e.g. logout from another tab) diff --git a/src/app/pages/error/error-page.module.ts b/src/app/pages/error/error-page.module.ts index 2b71b52636..447e316607 100644 --- a/src/app/pages/error/error-page.module.ts +++ b/src/app/pages/error/error-page.module.ts @@ -1,6 +1,7 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; +import { SimpleSearchBoxComponent } from 'ish-core/standalone/component/suggest/simple-search-box/simple-search-box.component'; import { SharedModule } from 'ish-shared/shared.module'; import { ErrorPageComponent } from './error-page.component'; @@ -12,7 +13,7 @@ const errorPageRoutes: Routes = [ ]; @NgModule({ - imports: [RouterModule.forChild(errorPageRoutes), SharedModule], + imports: [RouterModule.forChild(errorPageRoutes), SharedModule, SimpleSearchBoxComponent], declarations: [ErrorComponent, ErrorPageComponent, ServerErrorComponent], }) export class ErrorPageModule {} diff --git a/src/app/pages/error/error/error.component.html b/src/app/pages/error/error/error.component.html index 3b8b8a6e35..63352b884a 100644 --- a/src/app/pages/error/error/error.component.html +++ b/src/app/pages/error/error/error.component.html @@ -2,7 +2,7 @@

    {{ 'error.page.title' | translate }}

    - {{ 'search.noResult.heading' | translate }} >

    -
    - +
    @@ -81,7 +81,7 @@ exports[`Header Default Component should render normal header adequately for mob
    @@ -183,7 +183,7 @@ exports[`Header Default Component should render normal header adequately for tab
    - +
    @@ -212,7 +212,7 @@ exports[`Header Default Component should render sticky header adequately for des
    -
    -
    -
    - + + +
    diff --git a/src/app/shell/header/header-default/header-default.component.spec.ts b/src/app/shell/header/header-default/header-default.component.spec.ts index 071593c30b..3b9f19da6c 100644 --- a/src/app/shell/header/header-default/header-default.component.spec.ts +++ b/src/app/shell/header/header-default/header-default.component.spec.ts @@ -3,15 +3,17 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { NgbCollapse } from '@ng-bootstrap/ng-bootstrap'; import { TranslateModule } from '@ngx-translate/core'; import { MockComponent, MockDirective } from 'ng-mocks'; +import { instance, mock } from 'ts-mockito'; +import { AppFacade } from 'ish-core/facades/app.facade'; import { FeatureToggleModule } from 'ish-core/feature-toggle.module'; +import { SimpleSearchBoxComponent } from 'ish-core/standalone/component/suggest/simple-search-box/simple-search-box.component'; import { findAllCustomElements } from 'ish-core/utils/dev/html-query-utils'; import { HeaderNavigationComponent } from 'ish-shell/header/header-navigation/header-navigation.component'; import { LanguageSwitchComponent } from 'ish-shell/header/language-switch/language-switch.component'; import { LoginStatusComponent } from 'ish-shell/header/login-status/login-status.component'; import { MiniBasketComponent } from 'ish-shell/header/mini-basket/mini-basket.component'; import { UserInformationMobileComponent } from 'ish-shell/header/user-information-mobile/user-information-mobile.component'; -import { LazySearchBoxComponent } from 'ish-shell/shared/lazy-search-box/lazy-search-box.component'; import { LazyProductCompareStatusComponent } from '../../../extensions/compare/exports/lazy-product-compare-status/lazy-product-compare-status.component'; import { LazyQuickorderLinkComponent } from '../../../extensions/quickorder/exports/lazy-quickorder-link/lazy-quickorder-link.component'; @@ -25,6 +27,7 @@ describe('Header Default Component', () => { beforeEach(async () => { await TestBed.configureTestingModule({ + providers: [{ provide: AppFacade, useFactory: () => instance(mock(AppFacade)) }], imports: [FeatureToggleModule.forTesting('compare'), TranslateModule.forRoot()], declarations: [ HeaderDefaultComponent, @@ -33,9 +36,9 @@ describe('Header Default Component', () => { MockComponent(LanguageSwitchComponent), MockComponent(LazyProductCompareStatusComponent), MockComponent(LazyQuickorderLinkComponent), - MockComponent(LazySearchBoxComponent), MockComponent(LoginStatusComponent), MockComponent(MiniBasketComponent), + MockComponent(SimpleSearchBoxComponent), MockComponent(UserInformationMobileComponent), MockDirective(NgbCollapse), ], @@ -66,7 +69,7 @@ describe('Header Default Component', () => { it('should render Search Box on template', () => { fixture.detectChanges(); - expect(findAllCustomElements(element)).toContain('ish-lazy-search-box'); + expect(findAllCustomElements(element)).toContain('ish-simple-search-box'); }); it('should render Header Navigation on template', () => { diff --git a/src/app/shell/header/header-default/header-default.component.ts b/src/app/shell/header/header-default/header-default.component.ts index f408a68d3c..946913ec3d 100644 --- a/src/app/shell/header/header-default/header-default.component.ts +++ b/src/app/shell/header/header-default/header-default.component.ts @@ -1,5 +1,6 @@ import { ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges } from '@angular/core'; +import { AppFacade } from 'ish-core/facades/app.facade'; import { DeviceType } from 'ish-core/models/viewtype/viewtype.types'; type CollapsibleComponent = 'search' | 'navbar' | 'minibasket'; @@ -30,6 +31,11 @@ export class HeaderDefaultComponent implements OnChanges { @Input() reset: unknown; private activeComponent: CollapsibleComponent = 'search'; + isSparqueActive = false; + + constructor(appFacade: AppFacade) { + this.isSparqueActive = appFacade.isSparqueSuggestActive(); + } ngOnChanges(changes: SimpleChanges) { if (changes.reset) { diff --git a/src/app/shell/shell.module.ts b/src/app/shell/shell.module.ts index d7e0944ac8..a9336d2320 100644 --- a/src/app/shell/shell.module.ts +++ b/src/app/shell/shell.module.ts @@ -10,6 +10,8 @@ import { FeatureToggleModule } from 'ish-core/feature-toggle.module'; import { IconModule } from 'ish-core/icon.module'; import { PipesModule } from 'ish-core/pipes.module'; import { RoleToggleModule } from 'ish-core/role-toggle.module'; +import { AdvancedSearchBoxComponent } from 'ish-core/standalone/component/suggest/advanced-search-box/advanced-search-box.component'; +import { SimpleSearchBoxComponent } from 'ish-core/standalone/component/suggest/simple-search-box/simple-search-box.component'; import { FeatureEventService } from 'ish-core/utils/feature-event/feature-event.service'; import { ModuleLoaderService } from 'ish-core/utils/module-loader/module-loader.service'; @@ -37,12 +39,14 @@ import { SubCategoryNavigationComponent } from './header/sub-category-navigation import { UserInformationMobileComponent } from './header/user-information-mobile/user-information-mobile.component'; import { LazyContentIncludeComponent } from './shared/lazy-content-include/lazy-content-include.component'; import { LazyMiniBasketContentComponent } from './shared/lazy-mini-basket-content/lazy-mini-basket-content.component'; -import { LazySearchBoxComponent } from './shared/lazy-search-box/lazy-search-box.component'; const exportedComponents = [CookiesBannerComponent, FooterComponent, HeaderComponent]; +const importStandaloneComponents = [AdvancedSearchBoxComponent, SimpleSearchBoxComponent]; + @NgModule({ imports: [ + ...importStandaloneComponents, AuthorizationToggleModule, CommonModule, CompareExportsModule, @@ -75,7 +79,6 @@ const exportedComponents = [CookiesBannerComponent, FooterComponent, HeaderCompo LanguageSwitchComponent, LazyContentIncludeComponent, LazyMiniBasketContentComponent, - LazySearchBoxComponent, LoginStatusComponent, MiniBasketComponent, SubCategoryNavigationComponent, diff --git a/src/environments/environment.b2b.ts b/src/environments/environment.b2b.ts index a6fc503c0f..6dd6e6c60d 100644 --- a/src/environments/environment.b2b.ts +++ b/src/environments/environment.b2b.ts @@ -8,6 +8,24 @@ export const environment: Environment = { themeColor: '#688dc3', + /** + * To use the sparque api wrapper follow following steps: + * 1. download the sparque wrapper repository => git clone https://intershop-com@dev.azure.com/intershop-com/Products/_git/search-sparque-api-wrapper + * 2. build the docker image => docker build -t sparque_wrapper:local . + * 3. adapt the docker-compose.yml file: + * - change the image in the dotnet section form eusparqueops/sparque-api-wrapper to sparque_wrapper:local + * - add the team2 workspace and API(intershop-project-base-v2-team2|PWA) to the list for WORKSPACE_ENDPOINTSETS_WITHOUT_AUTH + * - set CACHE_SHOULD_CACHE to false + * 4. start the docker-compose => docker-compose up -d + * 5. to check if the sparque wrapper is running correctly open the url http://localhost:5755/swagger/index.html**/ + sparque: { + server_url: 'http://host.docker.internal:28090', + wrapperAPI: 'v2', + WorkspaceName: 'intershop-project-base-v2-team2', + ApiName: 'PWA', + ChannelId: 'ish', + }, + features: [ ...ENVIRONMENT_DEFAULTS.features, 'businessCustomerRegistration', diff --git a/src/environments/environment.model.ts b/src/environments/environment.model.ts index 9bf3f0a723..244286d690 100644 --- a/src/environments/environment.model.ts +++ b/src/environments/environment.model.ts @@ -1,5 +1,6 @@ import { Auth0Config } from 'ish-core/identity-provider/auth0.identity-provider'; import { CookieConsentOptions } from 'ish-core/models/cookies/cookies.model'; +import { SparqueConfig } from 'ish-core/models/sparque/sparque-config.model'; import { DeviceType, ViewType } from 'ish-core/models/viewtype/viewtype.types'; import { DataRetentionPolicy } from 'ish-core/utils/meta-reducers'; import { MultiSiteLocaleMap } from 'ish-core/utils/multi-site/multi-site.service'; @@ -141,6 +142,9 @@ export interface Environment { * - 'stable': only fetch prices once per application lifetime */ priceUpdate: 'stable' | 'always'; + + // sparque integration + sparque?: SparqueConfig; } export const ENVIRONMENT_DEFAULTS: Omit = {