From 6aaad18149f5aa21fa2239f6c6a1ac5c7173a757 Mon Sep 17 00:00:00 2001 From: Simen Fivelstad Smaaberg Date: Tue, 9 Dec 2025 11:45:41 -0800 Subject: [PATCH 01/25] MDS-67432 Initial search rewamp --- docker-compose.M1.yaml | 72 +- docker-compose.yaml | 62 +- .../mines/permits/permit_conditions/tasks.py | 11 +- .../elasticsearch/elastic_search_service.py | 27 + .../app/api/search/elasticsearch/mappings.py | 43 + .../app/api/search/response_models.py | 11 + .../app/api/search/search/resources/search.py | 179 ++-- .../search/search/resources/simple_search.py | 113 ++- services/core-api/app/config.py | 10 +- services/core-api/requirements.txt | 3 +- .../core-api/tests/cli_commands/__init__.py | 0 services/core-web/bunfig.toml | 0 services/core-web/rsbuild.config.ts | 2 +- .../src/components/homepage/HomeBanner.tsx | 6 +- .../Permit/Search/PermitConditionSearch.tsx | 13 +- .../Search/components/AiResponseViewer.tsx | 99 +++ .../Search/components/MarkdownViewer.tsx | 88 +- .../components/PermitDocumentsModal.tsx | 72 ++ .../Permit/Search/components/ResultItem.tsx | 32 +- .../src/components/navigation/NavBar.tsx | 4 +- .../src/components/search/GlobalSearch.tsx | 219 +++++ .../src/components/search/SearchBar.tsx | 146 ---- .../components/search/SearchBarDropdown.tsx | 128 --- .../src/components/search/SearchResults.tsx | 797 +++++++++++++----- .../components/search/GlobalSearch.spec.tsx | 29 + .../components/search/SearchBar.spec.tsx | 35 - .../search/SearchBarDropdown.spec.tsx | 22 - 27 files changed, 1537 insertions(+), 686 deletions(-) create mode 100644 services/core-api/app/api/search/elasticsearch/elastic_search_service.py create mode 100644 services/core-api/app/api/search/elasticsearch/mappings.py create mode 100644 services/core-api/tests/cli_commands/__init__.py create mode 100644 services/core-web/bunfig.toml create mode 100644 services/core-web/src/components/mine/Permit/Search/components/AiResponseViewer.tsx create mode 100644 services/core-web/src/components/mine/Permit/Search/components/PermitDocumentsModal.tsx create mode 100644 services/core-web/src/components/search/GlobalSearch.tsx delete mode 100644 services/core-web/src/components/search/SearchBar.tsx delete mode 100644 services/core-web/src/components/search/SearchBarDropdown.tsx create mode 100644 services/core-web/src/tests/components/search/GlobalSearch.spec.tsx delete mode 100644 services/core-web/src/tests/components/search/SearchBar.spec.tsx delete mode 100644 services/core-web/src/tests/components/search/SearchBarDropdown.spec.tsx diff --git a/docker-compose.M1.yaml b/docker-compose.M1.yaml index 954d775d72..27f3355909 100644 --- a/docker-compose.M1.yaml +++ b/docker-compose.M1.yaml @@ -2,7 +2,6 @@ version: "3" include: - ./services/permits/docker-compose.yaml services: - ####################### Keycloak for Cypress ####################### keycloak: build: @@ -24,7 +23,7 @@ services: ####################### Open Telemetry ####################### otelcollector: image: otel/opentelemetry-collector - command: [ --config=/etc/otel-collector-config.yaml ] + command: [--config=/etc/otel-collector-config.yaml] volumes: - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml ports: @@ -67,10 +66,9 @@ services: - ./services/postgres/postgresql.conf:/config/postgresql.conf - ./services/postgres/pg_hba.conf:/config/pg_hba.conf - postgres_data:/var/lib/postgresql/data - command: - postgres -c 'config_file=/config/postgresql.conf' -c 'hba_file=/config/pg_hba.conf' + command: postgres -c 'config_file=/config/postgresql.conf' -c 'hba_file=/config/pg_hba.conf' healthcheck: - test: [ "CMD", "pg_isready" ] + test: ["CMD", "pg_isready"] interval: 5s timeout: 5s retries: 5 @@ -84,12 +82,12 @@ services: context: services/postgres dockerfile: Dockerfile_update environment: - - PGUSER=mds - - POSTGRES_PASSWORD=test - - POSTGRES_INITDB_ARGS=-U mds + - PGUSER=mds + - POSTGRES_PASSWORD=test + - POSTGRES_INITDB_ARGS=-U mds volumes: - - postgres_data_bkp:/var/lib/postgresql/13/data - - postgres_data:/var/lib/postgresql/16/data + - postgres_data_bkp:/var/lib/postgresql/13/data + - postgres_data:/var/lib/postgresql/16/data ####################### Flyway Migration Definition ####################### flyway: @@ -118,6 +116,40 @@ services: depends_on: - postgres ####################### Backend Definition ####################### + + pgsync: + build: + context: services/pgsync + container_name: mds_pgsync + platform: linux/amd64 + environment: + - PG_HOST=postgres + - PG_PORT=5432 + - PG_USER=mds + - PG_PASSWORD=test + - PG_DATABASE=mds + - ELASTICSEARCH_HOST=elasticsearch + - ELASTICSEARCH_PORT=9200 + - ELASTICSEARCH_SCHEME=https + - ELASTICSEARCH_USER=elastic + - ELASTICSEARCH_PASSWORD=changeme + - ELASTICSEARCH_VERIFY_CERTS=false + - ELASTICSEARCH_CA_CERTS=/certs/ca/ca.crt + - PGSYNC_CHECKPOINT_PATH=/tmp + - REDIS_HOST=redis + - REDIS_PORT=6379 + - REDIS_DB=0 + - REDIS_AUTH=redis-password + volumes: + - ./services/pgsync:/config + - certs:/certs + depends_on: + postgres: + condition: service_healthy + elasticsearch: + condition: service_healthy + redis: + condition: service_healthy backend: restart: always user: 1000:1000 @@ -131,6 +163,7 @@ services: - ./migrations:/migrations - ./services/core-api:/app - core_api_logs:/var/log/core-api + - certs:/certs depends_on: - flyway - nris_backend @@ -139,12 +172,13 @@ services: - filesystem_provider - postgres - redis + - pgsync - jaeger - otelcollector - keycloak - core_api_celery healthcheck: - test: [ "CMD", "curl", "localhost:5000/health" ] + test: ["CMD", "curl", "localhost:5000/health"] interval: 5s timeout: 5s retries: 5 @@ -159,6 +193,7 @@ services: - 5556:5555 volumes: - ./services/core-api:/app + - certs:/certs depends_on: - postgres - redis @@ -173,7 +208,7 @@ services: ports: - "6379:6379" healthcheck: - test: [ "CMD", "redis-cli", "ping" ] + test: ["CMD", "redis-cli", "ping"] interval: 5s timeout: 5s retries: 5 @@ -186,7 +221,7 @@ services: platform: linux/amd64 build: context: services/core-web - command: [ "npm", "run", "serve" ] + command: ["npm", "run", "serve"] volumes: - ./services/core-web/src:/app/src ports: @@ -195,7 +230,7 @@ services: - backend env_file: ./services/core-web/.env healthcheck: - test: [ "CMD", "curl", "localhost:3000/health" ] + test: ["CMD", "curl", "localhost:3000/health"] interval: 15s timeout: 5s retries: 5 @@ -209,7 +244,7 @@ services: platform: linux/amd64 build: context: services/minespace-web - command: [ "npm", "run", "serve" ] + command: ["npm", "run", "serve"] volumes: - ./services/minespace-web/src:/app/src ports: @@ -218,7 +253,7 @@ services: - backend env_file: ./services/minespace-web/.env healthcheck: - test: [ "CMD", "curl", "localhost:3020/health" ] + test: ["CMD", "curl", "localhost:3020/health"] interval: 5s timeout: 5s retries: 5 @@ -239,7 +274,7 @@ services: - nris_migrate env_file: ./services/nris-api/backend/.env healthcheck: - test: [ "CMD", "curl", "localhost:5500/health" ] + test: ["CMD", "curl", "localhost:5500/health"] interval: 5s timeout: 5s retries: 5 @@ -269,6 +304,7 @@ services: ####################### Syncfusion Filesystem Provider Definition ####################### filesystem_provider: container_name: filesystem_provider + platform: linux/amd64 build: context: services/filesystem-provider ports: @@ -329,7 +365,7 @@ services: restart: always container_name: docgen_api image: bcgovimages/common-document-generation-service:2.4.1 - command: [ "npm", "run", "start" ] + command: ["npm", "run", "start"] environment: - SERVER_PORT=3030 - APP_PORT=3030 diff --git a/docker-compose.yaml b/docker-compose.yaml index 1fb9897f17..8bc813b2a9 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -2,7 +2,6 @@ version: "3" include: - ./services/permits/docker-compose.yaml services: - ####################### Keycloak for Cypress ####################### keycloak: build: @@ -23,7 +22,7 @@ services: ####################### Open Telemetry ####################### otelcollector: image: otel/opentelemetry-collector - command: [ --config=/etc/otel-collector-config.yaml ] + command: [--config=/etc/otel-collector-config.yaml] volumes: - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml ports: @@ -66,14 +65,42 @@ services: - ./services/postgres/postgresql.conf:/config/postgresql.conf - ./services/postgres/pg_hba.conf:/config/pg_hba.conf - postgres_data:/var/lib/postgresql/data - command: - postgres -c 'config_file=/config/postgresql.conf' -c 'hba_file=/config/pg_hba.conf' + command: postgres -c 'config_file=/config/postgresql.conf' -c 'hba_file=/config/pg_hba.conf' healthcheck: - test: [ "CMD", "pg_isready" ] + test: ["CMD", "pg_isready"] interval: 5s timeout: 5s retries: 5 - + pgsync: + image: query/pgsync + container_name: mds_pgsync + environment: + - PG_HOST=postgres + - PG_PORT=5432 + - PG_USER=mds + - PG_PASSWORD=test + - PG_DATABASE=mds + - ELASTICSEARCH_HOST=elasticsearch + - ELASTICSEARCH_PORT=9200 + - ELASTICSEARCH_SCHEME=https + - ELASTICSEARCH_USER=elastic + - ELASTICSEARCH_PASSWORD=changeme + - ELASTICSEARCH_VERIFY_CERTS=false + - REDIS_HOST=redis + - REDIS_PORT=6379 + - REDIS_DB=0 + - REDIS_AUTH= + volumes: + - ./services/pgsync:/config + command: > + bash -c "pgsync --config /config/schema.json --daemon" + depends_on: + postgres: + condition: service_healthy + elasticsearch: + condition: service_healthy + redis: + condition: service_healthy postgres_update: container_name: mds_postgres_update user: postgres @@ -81,12 +108,12 @@ services: context: services/postgres dockerfile: Dockerfile_update environment: - - PGUSER=mds - - POSTGRES_PASSWORD=test - - POSTGRES_INITDB_ARGS=-U mds + - PGUSER=mds + - POSTGRES_PASSWORD=test + - POSTGRES_INITDB_ARGS=-U mds volumes: - - postgres_data_bkp:/var/lib/postgresql/13/data - - postgres_data:/var/lib/postgresql/16/data + - postgres_data_bkp:/var/lib/postgresql/13/data + - postgres_data:/var/lib/postgresql/16/data ####################### Flyway Migration Definition ####################### flyway: @@ -143,7 +170,7 @@ services: - keycloak - core_api_celery healthcheck: - test: [ "CMD", "curl", "localhost:5000/health" ] + test: ["CMD", "curl", "localhost:5000/health"] interval: 5s timeout: 5s retries: 5 @@ -171,7 +198,7 @@ services: ports: - "6379:6379" healthcheck: - test: [ "CMD", "redis-cli", "ping" ] + test: ["CMD", "redis-cli", "ping"] interval: 5s timeout: 5s retries: 5 @@ -194,7 +221,7 @@ services: env_file: ./services/core-web/.env network_mode: "host" healthcheck: - test: [ "CMD", "curl", "localhost:3000/health" ] + test: ["CMD", "curl", "localhost:3000/health"] interval: 15s timeout: 5s retries: 5 @@ -218,7 +245,7 @@ services: - 3020:3020 env_file: ./services/minespace-web/.env healthcheck: - test: [ "CMD", "curl", "localhost:3020/health" ] + test: ["CMD", "curl", "localhost:3020/health"] interval: 5s timeout: 5s retries: 5 @@ -240,7 +267,7 @@ services: - nris_migrate env_file: ./services/nris-api/backend/.env healthcheck: - test: [ "CMD", "curl", "localhost:5500/health" ] + test: ["CMD", "curl", "localhost:5500/health"] interval: 5s timeout: 5s retries: 5 @@ -270,6 +297,7 @@ services: ####################### Syncfusion Filesystem Provider Definition ####################### filesystem_provider: container_name: filesystem_provider + platform: linux/amd64 build: context: services/filesystem-provider ports: @@ -330,7 +358,7 @@ services: restart: always container_name: docgen_api image: bcgovimages/common-document-generation-service:2.5.0 - command: [ "npm", "run", "start" ] + command: ["npm", "run", "start"] environment: - SERVER_PORT=3030 - APP_PORT=3030 diff --git a/services/core-api/app/api/mines/permits/permit_conditions/tasks.py b/services/core-api/app/api/mines/permits/permit_conditions/tasks.py index 62834354e8..1363e50b55 100644 --- a/services/core-api/app/api/mines/permits/permit_conditions/tasks.py +++ b/services/core-api/app/api/mines/permits/permit_conditions/tasks.py @@ -2,11 +2,12 @@ import datetime import io -from app.api.tasks.celery_task_base import TaskBase -from app.cli_commands.export_permit_conditions import headers, export_permit_conditions from app.api.search.search.permit_search_service import PermitSearchService +from app.api.tasks.celery_task_base import TaskBase +from app.cli_commands.export_permit_conditions import export_permit_conditions, headers from app.tasks.celery import celery + @celery.task(base=TaskBase) def export_and_index_permit_amendments(permit_amendment_guids, is_manual=False): """ @@ -19,7 +20,11 @@ def export_and_index_permit_amendments(permit_amendment_guids, is_manual=False): writer.writeheader() for permit_amendment_guid in permit_amendment_guids: - export_permit_conditions(permit_amendment_guid, csv_writer=writer) + try: + print(f"Exporting permit amendment conditions for GUID: {permit_amendment_guid}") + export_permit_conditions(permit_amendment_guid, csv_writer=writer) + except Exception as e: + print(f"Failed to export permit amendment {permit_amendment_guid}: {e}") csv_data.seek(0) timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") diff --git a/services/core-api/app/api/search/elasticsearch/elastic_search_service.py b/services/core-api/app/api/search/elasticsearch/elastic_search_service.py new file mode 100644 index 0000000000..d52b9bf991 --- /dev/null +++ b/services/core-api/app/api/search/elasticsearch/elastic_search_service.py @@ -0,0 +1,27 @@ +import logging + +from elasticsearch import Elasticsearch +from flask import current_app + + +class ElasticSearchService: + _client = None + + @classmethod + def get_client(cls): + if cls._client is None: + config = current_app.config + current_app.logger.info(f"Connecting to Elasticsearch at {config['ELASTICSEARCH_URL']}") + current_app.logger.info(f"Using CA certs at {config['ELASTICSEARCH_CA_CERTS']}") + cls._client = Elasticsearch( + config['ELASTICSEARCH_URL'], + basic_auth=(config['ELASTICSEARCH_USERNAME'], config['ELASTICSEARCH_PASSWORD']), + ca_certs=config['ELASTICSEARCH_CA_CERTS'], + verify_certs=False + ) + return cls._client + + @classmethod + def search(cls, index_name, query, size=10): + client = cls.get_client() + return client.search(index=index_name, body=query, size=size) diff --git a/services/core-api/app/api/search/elasticsearch/mappings.py b/services/core-api/app/api/search/elasticsearch/mappings.py new file mode 100644 index 0000000000..cde8298cf1 --- /dev/null +++ b/services/core-api/app/api/search/elasticsearch/mappings.py @@ -0,0 +1,43 @@ +mine_mapping = { + "mappings": { + "properties": { + "mine_guid": { "type": "keyword" }, + "mine_name": { "type": "text", "analyzer": "standard" }, + "mine_no": { "type": "keyword" }, + "mms_alias": { "type": "text" } + } + } +} + +party_mapping = { + "mappings": { + "properties": { + "party_guid": { "type": "keyword" }, + "first_name": { "type": "text" }, + "party_name": { "type": "text" }, + "email": { "type": "keyword" }, + "phone_no": { "type": "keyword" }, + "name": { "type": "text" } # Concatenated name + } + } +} + +permit_mapping = { + "mappings": { + "properties": { + "permit_guid": { "type": "keyword" }, + "permit_no": { "type": "keyword" } + } + } +} + +document_mapping = { + "mappings": { + "properties": { + "document_guid": { "type": "keyword" }, + "document_name": { "type": "text" }, + "mine_guid": { "type": "keyword" }, + "upload_date": { "type": "date" } + } + } +} diff --git a/services/core-api/app/api/search/response_models.py b/services/core-api/app/api/search/response_models.py index ae36f53341..6e5a4d65e9 100644 --- a/services/core-api/app/api/search/response_models.py +++ b/services/core-api/app/api/search/response_models.py @@ -1,3 +1,9 @@ +from app.api.mines.response_models import ( + MINE_TSF_MODEL, + MINE_TYPE_MODEL, + MINE_VERIFIED_MODEL, + MINE_WORK_INFORMATION_MODEL, +) from app.api.parties.response_models import ( PARTY_BUSINESS_ROLE_APPT, PARTY_ORGBOOK_ENTITY, @@ -50,6 +56,11 @@ 'mine_permit': fields.List(fields.Nested(PERMIT_SEARCH_MODEL)), 'mine_status': fields.Nested(MINE_STATUS_MODEL), 'mms_alias': fields.String, + 'major_mine_ind': fields.Boolean, + 'mine_type': fields.List(fields.Nested(MINE_TYPE_MODEL)), + 'mine_tailings_storage_facilities': fields.List(fields.Nested(MINE_TSF_MODEL)), + 'mine_work_information': fields.Nested(MINE_WORK_INFORMATION_MODEL), + 'verified_status': fields.Nested(MINE_VERIFIED_MODEL), }) PARTY_ADDRESS = api.model( diff --git a/services/core-api/app/api/search/search/resources/search.py b/services/core-api/app/api/search/search/resources/search.py index dfb5969bcb..2664b26f77 100644 --- a/services/core-api/app/api/search/search/resources/search.py +++ b/services/core-api/app/api/search/search/resources/search.py @@ -1,13 +1,12 @@ import regex -from concurrent.futures import ThreadPoolExecutor, as_completed -from flask_restx import Resource -from flask import request, current_app - -from app.extensions import db, api -from app.api.utils.access_decorators import requires_role_view_all, requires_role_mine_edit -from app.api.utils.resources_mixins import UserMixin -from app.api.utils.search import search_targets, append_result, execute_search, SearchResult +from app.api.search.elasticsearch.elastic_search_service import ElasticSearchService from app.api.search.response_models import SEARCH_RESULT_RETURN_MODEL +from app.api.utils.access_decorators import requires_role_view_all +from app.api.utils.resources_mixins import UserMixin +from app.api.utils.search import search_targets +from app.extensions import api, db +from flask import current_app, request +from flask_restx import Resource class SearchOptionsResource(Resource, UserMixin): @@ -24,72 +23,122 @@ class SearchResource(Resource, UserMixin): @requires_role_view_all @api.marshal_with(SEARCH_RESULT_RETURN_MODEL, 200) def get(self): - search_results = [] - app = current_app._get_current_object() - search_term = request.args.get('search_term', None, type=str) search_types = request.args.get('search_types', None, type=str) - search_types = search_types.split(',') if search_types else search_targets.keys() + search_types = search_types.split(',') if search_types else list(search_targets.keys()) # Split incoming search query by space to search by individual words reg_exp = regex.compile(r'\'.*?\' | ".*?" | \S+ ', regex.VERBOSE) search_terms = reg_exp.findall(search_term) search_terms = [term.replace('"', '') for term in search_terms] - with ThreadPoolExecutor(max_workers=50) as executor: - task_list = [] - for type, type_config in search_targets.items(): - if type in search_types: - task_list.append( - executor.submit(execute_search, app, search_results, search_term, - search_terms, type, type_config)) - for task in as_completed(task_list): - try: - data = task.result() - except Exception as exc: - current_app.logger.error( - f'generated an exception: {exc} with search term - {search_term}') - - grouped_results = {} - for result in search_results: - if (result.result['id'] in grouped_results): - grouped_results[result.result['id']].score += result.score - else: - grouped_results[result.result['id']] = result - - top_search_results = list(grouped_results.values()) - top_search_results.sort(key=lambda x: x.score, reverse=True) - all_search_results = {} - + + type_to_index = { + 'mine': 'mines', + 'party': 'parties', + 'permit': 'permits', + 'mine_documents': 'documents' + # 'permit_documents': 'documents' # TODO: Add permit documents to ES index + } + + index_to_type = {v: k for k, v in type_to_index.items()} + + indices = [] for type in search_types: - top_search_results_by_type = {} - - max_results = 5 - if len(search_types) == 1: - max_results = 50 - - for result in top_search_results: - if len(top_search_results_by_type) > max_results: - break - if result.type == type: - top_search_results_by_type[result.result['id']] = result - if search_targets[type].get('primary_column'): - # Look up result data from the DB if the search type has a primary column - # specified. Otherwise, just return the JSON representation of the result (in the case of the permit search service). - full_results = db.session.query(search_targets[type]['model'])\ - .filter( - search_targets[type]['primary_column'].in_( - top_search_results_by_type.keys()) - )\ - .all() - - for full_result in full_results: - top_search_results_by_type[getattr( - full_result, search_targets[type]['id_field'])].result = full_result - - all_search_results[type] = list(top_search_results_by_type.values()) - else: - all_search_results[type] = [res.json() for res in search_results if res.type == type] + if type in type_to_index: + indices.append(type_to_index[type]) + + if indices: + indices_string = ",".join(list(set(indices))) + + # Construct query + query = { + "query": { + "bool": { + "must": [ + { + "multi_match": { + "query": search_term, + "fields": ["*"], + "fuzziness": "AUTO" + } + } + ], + "filter": [ + {"term": {"deleted_ind": False}} + ] + } + } + } + + try: + es_results = ElasticSearchService.search(indices_string, query, size=200) + hits = es_results['hits']['hits'] + + grouped_hits = {} + for hit in hits: + index = hit['_index'] + type = index_to_type.get(index) + if not type: + continue + + if type not in grouped_hits: + grouped_hits[type] = [] + grouped_hits[type].append(hit) + + for type, hits in grouped_hits.items(): + results = [] + for hit in hits: + source = hit['_source'] + score = hit['_score'] + + id_field = search_targets[type]['id_field'] + id = source.get(id_field) + + if id: + results.append({ + 'score': score, + 'type': type, + 'id': id + }) + + if not results: + all_search_results[type] = [] + continue + + ids = [r['id'] for r in results] + + if type in search_targets and search_targets[type].get('primary_column'): + model = search_targets[type]['model'] + primary_column = search_targets[type]['primary_column'] + + db_results = db.session.query(model).filter(primary_column.in_(ids)).all() + db_results_map = {str(getattr(r, search_targets[type]['id_field'])): r for r in db_results} + + final_results = [] + for r in results: + if r['id'] in db_results_map: + final_results.append({ + 'score': r['score'], + 'type': r['type'], + 'result': db_results_map[r['id']] + }) + + all_search_results[type] = final_results + else: + all_search_results[type] = [] + + except Exception as e: + current_app.logger.error(f"Elasticsearch error: {e}") + # If the single query fails, we might want to return empty results for all requested types + for type in search_types: + if type not in all_search_results: + all_search_results[type] = [] + + # Ensure all requested types are in the result, even if empty + for type in search_types: + if type not in all_search_results: + all_search_results[type] = [] return {'search_terms': search_terms, 'search_results': all_search_results} diff --git a/services/core-api/app/api/search/search/resources/simple_search.py b/services/core-api/app/api/search/search/resources/simple_search.py index 8ab5d17245..510406144a 100644 --- a/services/core-api/app/api/search/search/resources/simple_search.py +++ b/services/core-api/app/api/search/search/resources/simple_search.py @@ -1,13 +1,12 @@ import regex -from concurrent.futures import ThreadPoolExecutor, as_completed -from flask_restx import Resource -from flask import request, current_app - -from app.extensions import db, api -from app.api.utils.access_decorators import requires_role_view_all, requires_role_mine_edit -from app.api.utils.resources_mixins import UserMixin -from app.api.utils.search import simple_search_targets, append_result, execute_search, SearchResult +from app.api.search.elasticsearch.elastic_search_service import ElasticSearchService from app.api.search.response_models import SIMPLE_SEARCH_RESULT_RETURN_MODEL +from app.api.utils.access_decorators import requires_role_view_all +from app.api.utils.resources_mixins import UserMixin +from app.api.utils.search import SearchResult, simple_search_targets +from app.extensions import api +from flask import current_app, request +from flask_restx import Resource class SimpleSearchResource(Resource, UserMixin): @@ -15,8 +14,6 @@ class SimpleSearchResource(Resource, UserMixin): @api.marshal_with(SIMPLE_SEARCH_RESULT_RETURN_MODEL, 200) def get(self): search_results = [] - app = current_app._get_current_object() - search_term = request.args.get('search_term', None, type=str) # Split incoming search query by space to search by individual words @@ -24,18 +21,90 @@ def get(self): search_terms = reg_exp.findall(search_term) search_terms = [term.replace('"', '') for term in search_terms] - with ThreadPoolExecutor(max_workers=50) as executor: - task_list = [] - for type, type_config in simple_search_targets.items(): - task_list.append( - executor.submit(execute_search, app, search_results, search_term, search_terms, - type, type_config, 200)) - for task in as_completed(task_list): - try: - data = task.result() - except Exception as exc: - current_app.logger.error( - f'generated an exception: {exc} with search term - {search_term}') + type_to_index = { + 'mine': 'mines', + 'party': 'parties', + 'permit': 'permits' + } + + index_to_type = {v: k for k, v in type_to_index.items()} + + indices = [] + for type in simple_search_targets.keys(): + if type in type_to_index: + indices.append(type_to_index[type]) + + if indices: + indices_string = ",".join(list(set(indices))) + + # Construct query + query = { + "query": { + "bool": { + "must": [ + { + "multi_match": { + "query": search_term, + "fields": ["*"], + "fuzziness": "AUTO" + } + } + ], + "filter": [ + {"term": {"deleted_ind": False}} + ] + } + } + } + + try: + es_results = ElasticSearchService.search(indices_string, query, size=30) + hits = es_results['hits']['hits'] + + for hit in hits: + index = hit['_index'] + type = index_to_type.get(index) + if not type: + continue + + type_config = simple_search_targets.get(type) + if not type_config: + continue + + source = hit['_source'] + score = hit['_score'] + + # Apply multiplier from config + score_multiplier = type_config.get('score_multiplier', 1) + + # Construct value based on type + value = "" + if type == 'mine': + value = source.get('mine_name', '') + elif type == 'party': + first_name = source.get('first_name', '') + party_name = source.get('party_name', '') + value = f"{first_name} {party_name}".strip() + elif type == 'permit': + value = source.get('permit_no', '') + + # Boost if starts with or exact match + if value.lower().startswith(search_term.lower()): + score_multiplier *= 3 + if value.lower() == search_term.lower(): + score_multiplier *= 10 + + search_results.append(SearchResult( + score * score_multiplier, + type, + { + 'id': source.get(type_config['id_field']), + 'value': value + } + )) + + except Exception as e: + current_app.logger.error(f"Elasticsearch error: {e}") grouped_results = {} for result in search_results: diff --git a/services/core-api/app/config.py b/services/core-api/app/config.py index 9245e52ae0..f1f6bb7bd1 100644 --- a/services/core-api/app/config.py +++ b/services/core-api/app/config.py @@ -2,11 +2,11 @@ import os import traceback -from dotenv import load_dotenv, find_dotenv +import requests from celery.schedules import crontab +from dotenv import find_dotenv, load_dotenv from flask import current_app, has_app_context, has_request_context from opentelemetry import trace -import requests ENV_FILE = find_dotenv() if ENV_FILE: @@ -73,6 +73,11 @@ class Config(object): 'CRITICAL') # ['DEBUG','INFO','WARN','ERROR','CRITICAL'] DISPLAY_WERKZEUG_LOG = os.environ.get('DISPLAY_WERKZEUG_LOG', True) + ELASTICSEARCH_URL = os.environ.get('ELASTICSEARCH_URL', 'https://elasticsearch:9200') + ELASTICSEARCH_USERNAME = os.environ.get('ELASTICSEARCH_USERNAME', 'elastic') + ELASTICSEARCH_PASSWORD = os.environ.get('ELASTICSEARCH_PASSWORD', 'changeme') + ELASTICSEARCH_CA_CERTS = os.environ.get('ELASTICSEARCH_CA_CERTS', '/usr/share/elasticsearch/config/certs/ca/ca.crt') + LOGGING_DICT_CONFIG = { 'version': 1, 'formatters': { @@ -142,6 +147,7 @@ class Config(object): SQLALCHEMY_DATABASE_URI = DB_URL SQLALCHEMY_TRACK_MODIFICATIONS = False SQLALCHEMY_ENGINE_OPTIONS = {"pool_pre_ping": True} + SQLALCHEMY_WARN_20 = True JWT_OIDC_WELL_KNOWN_CONFIG = os.environ.get( 'JWT_OIDC_WELL_KNOWN_CONFIG', diff --git a/services/core-api/requirements.txt b/services/core-api/requirements.txt index 384de10582..295ae6f0ae 100644 --- a/services/core-api/requirements.txt +++ b/services/core-api/requirements.txt @@ -58,4 +58,5 @@ Pillow==10.3.0 setuptools==65.5.1 requests_toolbelt==1.0.0 untp_models==0.1.1 -urllib3==2.5.0 \ No newline at end of file +urllib3==2.5.0 +elasticsearch==8.12.0 \ No newline at end of file diff --git a/services/core-api/tests/cli_commands/__init__.py b/services/core-api/tests/cli_commands/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/services/core-web/bunfig.toml b/services/core-web/bunfig.toml new file mode 100644 index 0000000000..e69de29bb2 diff --git a/services/core-web/rsbuild.config.ts b/services/core-web/rsbuild.config.ts index e802f9462a..3f33e29f08 100644 --- a/services/core-web/rsbuild.config.ts +++ b/services/core-web/rsbuild.config.ts @@ -79,7 +79,7 @@ export default defineConfig({ pluginTypeCheck(), ], source: { - include: [/\.(?:ts|tsx|jsx|mts|cts|js)$/], + include: [PATHS.commonPackage, PATHS.sharedPackage], assetsInclude: /\.(?:png|jpe?g|gif|svg|mp3|pdf|docx?|xlsx?|woff2?|ttf|eot)$/, define: { "process.env": JSON.stringify(envFile), diff --git a/services/core-web/src/components/homepage/HomeBanner.tsx b/services/core-web/src/components/homepage/HomeBanner.tsx index 140c858dfa..9a2b3f4611 100644 --- a/services/core-web/src/components/homepage/HomeBanner.tsx +++ b/services/core-web/src/components/homepage/HomeBanner.tsx @@ -1,7 +1,7 @@ import React from "react"; import { Typography, Col, Row } from "antd"; -import SearchBar from "@/components/search/SearchBar"; +import GlobalSearch from "@/components/search/GlobalSearch"; import { BACKGROUND } from "@/constants/assets"; const HomeBanner = () => { @@ -24,8 +24,8 @@ const HomeBanner = () => { - diff --git a/services/core-web/src/components/mine/Permit/Search/PermitConditionSearch.tsx b/services/core-web/src/components/mine/Permit/Search/PermitConditionSearch.tsx index e8cb6303db..3dbf219190 100644 --- a/services/core-web/src/components/mine/Permit/Search/PermitConditionSearch.tsx +++ b/services/core-web/src/components/mine/Permit/Search/PermitConditionSearch.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react'; import { Layout, Typography, Row, Col, Card, Skeleton } from 'antd'; import SearchBox from './components/SearchBox'; import SearchResults, { SelectedFilter } from './components/SearchResults'; -import MarkdownViewer from './components/MarkdownViewer'; +import AiResponseViewer from './components/AiResponseViewer'; import { useAppDispatch, useAppSelector } from '@mds/common/redux/rootState'; import { selectSearchResults, selectSearchLoading, selectSearchQuery, selectSearchFilters, selectAiLoading, searchPermitConditions, } from '@mds/common/redux/slices/permitSearchSlice'; import PermitConditionSearchSplashScreen from './components/PermitConditionSearchSplashScreen'; @@ -69,6 +69,7 @@ const PermitConditionSearch: React.FC = () => { title="AI-Generated Response" loading={false} // Don't use Card's loading state className={`permit-search__ai-response ${isAIResponseExpanded ? 'permit-search__ai-response--expanded' : ''}`} + bodyStyle={{ padding: 0 }} > { className="expand-button" titleId="expand-button" title={isAIResponseExpanded ? "Compress" : "Expand"} + style={{ zIndex: 10 }} /> {aiLoading ? ( - +
+ +
) : ( results?.prompt?.answers?.map((result) => ( - )) )} diff --git a/services/core-web/src/components/mine/Permit/Search/components/AiResponseViewer.tsx b/services/core-web/src/components/mine/Permit/Search/components/AiResponseViewer.tsx new file mode 100644 index 0000000000..dfe0147d5c --- /dev/null +++ b/services/core-web/src/components/mine/Permit/Search/components/AiResponseViewer.tsx @@ -0,0 +1,99 @@ +import React, { useState, useMemo } from 'react'; +import { Tabs } from 'antd'; +import { HaystackDocumentSearchResult } from '@mds/common/interfaces/search/facet-search.interface'; +import MarkdownViewer from './MarkdownViewer'; +import ResultItem from './ResultItem'; + +interface AiResponseViewerProps { + answer: string; + documents: HaystackDocumentSearchResult[]; +} + +const AiResponseViewer: React.FC = ({ answer, documents }) => { + const [activeTab, setActiveTab] = useState('response'); + + // Extract references and assign numbers + const { references, referenceMap } = useMemo(() => { + const referenceRegex = /\[(?:doc:([a-f0-9-]+)(?:\s*,\s*doc:([a-f0-9-]+))*)\]|\[\[doc:([a-f0-9-]+)\]\]/g; + const foundIds = new Set(); + const refs: { id: string; number: number; doc?: HaystackDocumentSearchResult }[] = []; + const map = new Map(); + + let nextNum = 1; + + // We want to find all matches in order + let m; + // Reset regex lastIndex if it was global, but here we create new regex or use matchAll + // matchAll is ES2020. Let's use exec loop. + const regex = new RegExp(referenceRegex); + + let tempAnswer = answer; + while ((m = regex.exec(tempAnswer)) !== null) { + const args = m.slice(1).filter(Boolean); + args.forEach(id => { + if (!foundIds.has(id)) { + foundIds.add(id); + const doc = documents.find(d => d.id === id); + map.set(id, nextNum); + refs.push({ id, number: nextNum, doc }); + nextNum++; + } + }); + // Advance past the match to avoid infinite loop if regex doesn't consume + // But exec on global regex advances lastIndex. + // Wait, referenceRegex in MarkdownViewer is global (/g). + // Here I created new RegExp(referenceRegex) which copies flags? + // No, new RegExp(/.../g) copies flags in modern JS. + // Let's be safe. + if (!regex.global) { + break; // Should be global + } + } + + return { references: refs, referenceMap: map }; + }, [answer, documents]); + + const handleReferenceClick = (id: string) => { + setActiveTab(`source-${id}`); + }; + + const items = [ + { + label: 'Response', + key: 'response', + children: ( +
+ +
+ ), + }, + ...references.map(ref => ({ + label: `Source ${ref.number}`, + key: `source-${ref.id}`, + children: ref.doc ? ( +
+ +
+ ) : ( +
Source document not found.
+ ) + })) + ]; + + return ( + + ); +}; + +export default AiResponseViewer; diff --git a/services/core-web/src/components/mine/Permit/Search/components/MarkdownViewer.tsx b/services/core-web/src/components/mine/Permit/Search/components/MarkdownViewer.tsx index 324cbc9916..33f31adf20 100644 --- a/services/core-web/src/components/mine/Permit/Search/components/MarkdownViewer.tsx +++ b/services/core-web/src/components/mine/Permit/Search/components/MarkdownViewer.tsx @@ -1,9 +1,14 @@ import React, { useMemo } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm' +import { Tooltip, Tag } from 'antd'; +import { HaystackDocumentSearchResult } from '@mds/common/interfaces/search/facet-search.interface'; interface MarkdownViewerProps { markdown: string; + documents?: HaystackDocumentSearchResult[]; + referenceMap?: Map; + onReferenceClick?: (id: string) => void; } // Matches references in the format [doc:hash1, doc:hash2, ...] or [[doc:hash, doc:hash, ...]] @@ -11,41 +16,90 @@ const referenceRegex = /\[(?:doc:([a-f0-9-]+)(?:\s*,\s*doc:([a-f0-9-]+))*)\]|\[\ // Replaces references with actual links to the corresponding condition. // Example: [doc:abc123], [doc:def567] -> [1](#abc123) [2](#def567) -const processReferences = (markdown: string) => { +const processReferences = (markdown: string, referenceMap?: Map) => { let refCount = 0; return markdown.replace(referenceRegex, (match, ...args) => { const hashes = args.slice(0, -2).filter(Boolean); return hashes.map(hash => { - refCount++; - return `[[${refCount}]](#condition-${hash})`; + let num; + if (referenceMap) { + num = referenceMap.get(hash) || '?'; + } else { + refCount++; + num = refCount; + } + return `[${num}](#condition-${hash})`; }).join(' '); }); }; -const MarkdownViewer: React.FC = ({ markdown }) => { +const MarkdownViewer: React.FC = ({ markdown, documents = [], referenceMap, onReferenceClick }) => { const processedMarkdown = useMemo(() => { - return processReferences(markdown); - }, [markdown]); + return processReferences(markdown, referenceMap); + }, [markdown, referenceMap]); // Smoothly scroll to the condition when a reference is clicked. - const handleClick = (event: React.MouseEvent | React.KeyboardEvent) => { - const target = event.target as HTMLAnchorElement; - if (target.tagName === 'A' && target.href.includes('#condition-')) { - event.preventDefault(); - const hash = target.href.split('#')[1]; - const element = document.getElementById(hash); - if (element) { - element.scrollIntoView({ behavior: 'smooth' }); - window.location.hash = hash; + const handleLinkClick = (event: React.MouseEvent, hash: string) => { + event.preventDefault(); + if (onReferenceClick) { + onReferenceClick(hash); + return; + } + + const element = document.getElementById(`condition-${hash}`); + if (element) { + element.scrollIntoView({ behavior: 'smooth' }); + window.location.hash = `condition-${hash}`; + } + }; + + const components: any = { + a: ({ node, href, children, ...props }: any) => { + if (href && href.startsWith('#condition-')) { + const id = href.replace('#condition-', ''); + const doc = documents.find(d => d.id === id); + + const handleClick = (e: React.MouseEvent) => { + handleLinkClick(e, id); + }; + + if (doc) { + const tooltipContent = ( +
+
Mine: {doc.meta.mine_name}
+
Permit: {doc.meta.permit}
+
Score: {Math.round(((doc.score - 1) / 3) * 100)}%
+
+ ); + + return ( + + + + {children} + + + + ); + } + + return ( + + + {children} + + + ); } + return {children}; } }; return ( -
+
- + {processedMarkdown}
diff --git a/services/core-web/src/components/mine/Permit/Search/components/PermitDocumentsModal.tsx b/services/core-web/src/components/mine/Permit/Search/components/PermitDocumentsModal.tsx new file mode 100644 index 0000000000..6e884fdcc2 --- /dev/null +++ b/services/core-web/src/components/mine/Permit/Search/components/PermitDocumentsModal.tsx @@ -0,0 +1,72 @@ +import React, { useEffect } from "react"; +import { Button, Table } from "antd"; +import { fetchPermits } from "@mds/common/redux/actionCreators/permitActionCreator"; +import { getAmendment } from "@mds/common/redux/selectors/permitSelectors"; +import DocumentLink from "@mds/common/components/documents/DocumentLink"; +import { IPermitAmendment } from "@mds/common/interfaces/permits/permitAmendment.interface"; +import { useAppDispatch, useAppSelector } from "@mds/common/redux/rootState"; +import { formatDate } from "@common/utils/helpers"; + +interface Props { + onCancel: () => void; + permitAmendmentGuid: string; + mineGuid: string; + permitGuid: string; +} + +const PermitDocumentsModal: React.FC = ({ + onCancel, + permitAmendmentGuid, + mineGuid, + permitGuid, +}) => { + const dispatch = useAppDispatch(); + const currentAmendment: IPermitAmendment = useAppSelector((state) => + getAmendment(permitGuid, permitAmendmentGuid)(state) + ); + + useEffect(() => { + dispatch(fetchPermits(mineGuid)); + }, [mineGuid, dispatch]); + + const columns = [ + { + title: "Document Name", + dataIndex: "document_name", + key: "document_name", + render: (text, record) => ( + + ), + }, + { + title: "Category", + dataIndex: "category", + key: "category", + }, + { + title: "Upload Date", + dataIndex: "upload_date", + key: "upload_date", + render: (text) => formatDate(text), + }, + ]; + + const allDocuments = [ + ...(currentAmendment?.related_documents || []).map(doc => ({ ...doc, category: 'Permit Document' })), + ...(currentAmendment?.now_application_documents || []).filter(d => d.is_final_package).map(doc => ({ ...doc, document_name: doc.mine_document.document_name, document_manager_guid: doc.mine_document.document_manager_guid, upload_date: doc.mine_document.upload_date, category: 'Final Application Package' })) + ]; + + return ( +
+ +
+ +
+ + ); +}; + +export default PermitDocumentsModal; diff --git a/services/core-web/src/components/mine/Permit/Search/components/ResultItem.tsx b/services/core-web/src/components/mine/Permit/Search/components/ResultItem.tsx index f00a33488d..fcfb423457 100644 --- a/services/core-web/src/components/mine/Permit/Search/components/ResultItem.tsx +++ b/services/core-web/src/components/mine/Permit/Search/components/ResultItem.tsx @@ -16,6 +16,7 @@ import { faChevronUp } from '@fortawesome/pro-solid-svg-icons'; import PermitAmendmentPreviewModal from './PermitAmendmentPreviewModal'; +import PermitDocumentsModal from './PermitDocumentsModal'; import DocumentLink from '@mds/common/components/documents/DocumentLink'; import { ActionMenuButton } from '@mds/common/components/common/ActionMenu'; import { WarningOutlined } from '@ant-design/icons'; @@ -59,6 +60,19 @@ const ResultItem: React.FC = ({ result, onFilterClick }) => { })); }; + const handleViewPermitDocuments = () => { + dispatch(openModal({ + props: { + title: 'Permit Documents', + permitAmendmentGuid: meta.permit_amendment_guid, + mineGuid: meta.mine_guid, + permitGuid: meta.permit_guid, + }, + width: '50%', + content: PermitDocumentsModal, + })); + }; + useEffect(() => { // Highlight conditions when url changes. // E.g. /conditions/#condition-123 will highlight condition with id 123 for 5 seconds @@ -315,12 +329,18 @@ const ResultItem: React.FC = ({ result, onFilterClick }) => { - + {meta.permit_type === 'Notice of Work' ? ( + + ) : ( + + )} diff --git a/services/core-web/src/components/navigation/NavBar.tsx b/services/core-web/src/components/navigation/NavBar.tsx index 537130132c..48ea1eab47 100644 --- a/services/core-web/src/components/navigation/NavBar.tsx +++ b/services/core-web/src/components/navigation/NavBar.tsx @@ -15,7 +15,7 @@ import * as Strings from "@mds/common/constants/strings"; import * as router from "@/constants/routes"; import * as Permission from "@/constants/permissions"; import AuthorizationWrapper from "@/components/common/wrappers/AuthorizationWrapper"; -import SearchBar from "@/components/search/SearchBar"; +import GlobalSearch from "@/components/search/GlobalSearch"; import { LOGO, HAMBURGER, CLOSE, SUCCESS_CHECKMARK, YELLOW_HAZARD } from "@/constants/assets"; import NotificationDrawer from "@/components/navigation/NotificationDrawer"; import HelpGuide from "@mds/common/components/help/HelpGuide"; @@ -408,7 +408,7 @@ export const NavBar: FC = ({ activeButton, isMenuOpen, toggleHambur Home
- +
-

No Results Found.

- {searchTooShort && ( -

At least one word in your search needs to be a minimum of three characters.

- )} -

Please try another search.

- - - ); +const StatusTag = ({ status }: { status: string | string[] }) => { + if (!status) return null; + + const statusText = Array.isArray(status) ? status.join(", ") : status; + let color = "default"; + + const lowerStatus = statusText.toLowerCase(); + if (lowerStatus.includes("operating") || lowerStatus.includes("open") || lowerStatus.includes("active")) { + color = "success"; + } else if (lowerStatus.includes("closed")) { + color = "error"; + } else if (lowerStatus.includes("care") || lowerStatus.includes("maintenance")) { + color = "warning"; + } + + return {statusText}; }; -const CantFindIt = () => ( - - -

Can't find it?

-

- Try clicking to see more results, or select the advanced lookup if available. Also, double - check your spelling to ensure it is correct. If you feel there is a problem, contact the - Core administrator to ask for assistance. -

- - +const SearchSkeleton = () => ( + + + ); -export const SearchResults: React.FC = (props) => { +const SearchResultCard = ({ title, icon, children, link, highlightRegex, actions = [] }) => ( + + + +
{icon}
+ + + + + {highlightRegex ? ( + <Highlight search={highlightRegex}>{title ?? ""}</Highlight> + ) : ( + title ?? "" + )} + + + {children} + + + + + , + + + + ]} + > + + + + + Mine No:{" "} + {highlightRegex ? ( + {item.result.mine_no ?? ""} + ) : ( + item.result.mine_no ?? "" + )} + + + + + + + {item.result.mms_alias && ( + + Alias:{" "} + {highlightRegex ? ( + {item.result.mms_alias ?? ""} + ) : ( + item.result.mms_alias ?? "" + )} + + )} + Region: {item.result.mine_region} + + + ); + }; - return results; - }, [props.searchResults]); + const renderPartyResult = (item: any) => ( + } + link={router.PARTY_PROFILE.dynamicRoute(item.result.party_guid)} + highlightRegex={highlightRegex} + actions={[ + + + + ]} + > + + + Email:{" "} + {highlightRegex ? ( + {item.result.email ?? ""} + ) : ( + item.result.email ?? "" + )} + + + Phone:{" "} + {highlightRegex ? ( + {item.result.phone_no ?? ""} + ) : ( + item.result.phone_no ?? "" + )} + + {item.result.mine_party_appt && item.result.mine_party_appt.length > 0 && ( + Role: {item.result.mine_party_appt[0].mine_party_appt_type_code_description} + )} + + + ); + + const renderPermitResult = (item: any) => ( + } + link={router.VIEW_MINE_PERMIT.dynamicRoute(item.result.mine[0].mine_guid, item.result.permit_guid)} + highlightRegex={highlightRegex} + actions={[ + + + + ]} + > + + + + Mine: {item.result.mine[0].mine_name} + + + + + + + + ); - const results = useMemo(() => props.searchTerms.map((t) => `"${t}"`).join(", "), [ - props.searchTerms, - ]); + const renderDocumentResult = (item: any) => ( + + + +
+ + + + <DocumentLink + documentManagerGuid={item.result.document_manager_guid} + documentName={item.result.document_name} + truncateDocumentName={false} + linkTitleOverride={highlightRegex ? <Highlight search={highlightRegex}>{item.result.document_name ?? ""}</Highlight> : undefined} + /> + + + Mine: {item.result.mine_name} + Uploaded: {formatDate(item.result.upload_date)} + + + + + ); - const type_filter = params.t; + const renderContent = () => { + if (isSearching && !props.hideLoadingIndicator) return ; - if (isSearching && !props.hideLoadingIndicator) return ; + const allResults = [ + ...(props.searchResults.mine || []), + ...(props.searchResults.party || []), + ...(props.searchResults.permit || []), + ...(props.searchResults.mine_documents || []), + ...(props.searchResults.permit_documents || []) + ].sort((a, b) => b.score - a.score); - return hasSearchTerm ? ( -
-
-
-

- {`${type_filter ? props.searchOptionsHash[type_filter] : "Search results" - } for ${results}`} -

-
- {type_filter ? ( - - - {`Back to all search results for ${results}`} - - ) : ( -

- Just show me: - {props.searchOptions.map((o) => ( - - - {o.description} - - - ))} -

- )} -
-
-
-
- {groupedSearchResults.length === 0 && NoResults(props.searchTerms)} - {groupedSearchResults.map((group) => ( -
- {TableForGroup( - group, - RegExp(`${props.searchTerms.join("|")}`, "i"), - props.partyRelationshipTypeHash, - params, - !!type_filter - )} - {!type_filter && ( - ; + } + + const facets = getFacets(allResults); + + const filteredResults = allResults.filter(item => { + const result = item.result as any; + if (item.type === 'mine') { + if (selectedFilters.mine_region && selectedFilters.mine_region.length > 0) { + if (!selectedFilters.mine_region.includes(result.mine_region)) return false; + } + if (selectedFilters.mine_status && selectedFilters.mine_status.length > 0) { + const status = result.mine_status && result.mine_status.length > 0 ? result.mine_status[0].status_labels : null; + const statusStr = Array.isArray(status) ? status.join(", ") : status; + if (!selectedFilters.mine_status.includes(statusStr)) return false; + } + if (selectedFilters.mine_tenure && selectedFilters.mine_tenure.length > 0) { + const tenures = result.mine_type ? result.mine_type.map((mt: any) => mt.mine_tenure_type_code) : []; + if (!selectedFilters.mine_tenure.some(t => tenures.includes(t))) return false; + } + if (selectedFilters.mine_commodity && selectedFilters.mine_commodity.length > 0) { + const commodities = result.mine_type ? result.mine_type.flatMap((mt: any) => mt.mine_type_detail ? mt.mine_type_detail.map((mtd: any) => mtd.mine_commodity_code) : []) : []; + if (!selectedFilters.mine_commodity.some(c => commodities.includes(c))) return false; + } + if (selectedFilters.mine_classification && selectedFilters.mine_classification.length > 0) { + const classification = result.major_mine_ind ? "Major Mine" : "Regional Mine"; + if (!selectedFilters.mine_classification.includes(classification)) return false; + } + if (selectedFilters.mine_tsf && selectedFilters.mine_tsf.length > 0) { + const tsf = result.mine_tailings_storage_facilities && result.mine_tailings_storage_facilities.length > 0 ? "Has TSF" : "No TSF"; + if (!selectedFilters.mine_tsf.includes(tsf)) return false; + } + if (selectedFilters.mine_work_status && selectedFilters.mine_work_status.length > 0) { + const ws = result.mine_work_information ? result.mine_work_information.work_status : null; + if (!ws || !selectedFilters.mine_work_status.includes(ws)) return false; + } + if (selectedFilters.mine_verified_status && selectedFilters.mine_verified_status.length > 0) { + const verified = result.verified_status ? (result.verified_status.healthy_ind ? "Verified" : "Unverified") : null; + if (!verified || !selectedFilters.mine_verified_status.includes(verified)) return false; + } + } + + if (item.type === 'permit') { + if (selectedFilters.permit_status && selectedFilters.permit_status.length > 0) { + if (!selectedFilters.permit_status.includes(result.permit_status_code)) return false; + } + } + return true; + }); + + const activeTab = params.t || "all"; + + const renderFacets = () => ( +
+ <FilterOutlined /> Filters + + {Object.keys(facets.mine_region).length > 0 && ( + + {Object.entries(facets.mine_region).map(([region, count]) => ( +
+ handleFilterChange('mine_region', region, e.target.checked)} > - See more search results for {props.searchOptionsHash[group.type]} - - )} -
- ))} - -
-
+ {region} ({count}) + +
+ ))} + + )} + {Object.keys(facets.mine_status).length > 0 && ( + + {Object.entries(facets.mine_status).map(([status, count]) => ( +
+ handleFilterChange('mine_status', status, e.target.checked)} + > + {status} ({count}) + +
+ ))} +
+ )} + {Object.keys(facets.mine_tenure).length > 0 && ( + + {Object.entries(facets.mine_tenure).map(([tenure, count]) => ( +
+ handleFilterChange('mine_tenure', tenure, e.target.checked)} + > + {tenure} ({count}) + +
+ ))} +
+ )} + {Object.keys(facets.mine_commodity).length > 0 && ( + + {Object.entries(facets.mine_commodity).map(([commodity, count]) => ( +
+ handleFilterChange('mine_commodity', commodity, e.target.checked)} + > + {commodity} ({count}) + +
+ ))} +
+ )} + {Object.keys(facets.mine_classification).length > 0 && ( + + {Object.entries(facets.mine_classification).map(([classification, count]) => ( +
+ handleFilterChange('mine_classification', classification, e.target.checked)} + > + {classification} ({count}) + +
+ ))} +
+ )} + {Object.keys(facets.mine_tsf).length > 0 && ( + + {Object.entries(facets.mine_tsf).map(([tsf, count]) => ( +
+ handleFilterChange('mine_tsf', tsf, e.target.checked)} + > + {tsf} ({count}) + +
+ ))} +
+ )} + {Object.keys(facets.mine_work_status).length > 0 && ( + + {Object.entries(facets.mine_work_status).map(([status, count]) => ( +
+ handleFilterChange('mine_work_status', status, e.target.checked)} + > + {status} ({count}) + +
+ ))} +
+ )} + {Object.keys(facets.mine_verified_status).length > 0 && ( + + {Object.entries(facets.mine_verified_status).map(([status, count]) => ( +
+ handleFilterChange('mine_verified_status', status, e.target.checked)} + > + {status} ({count}) + +
+ ))} +
+ )} + {Object.keys(facets.permit_status).length > 0 && ( + + {Object.entries(facets.permit_status).map(([status, count]) => ( +
+ handleFilterChange('permit_status', status, e.target.checked)} + > + {status} ({count}) + +
+ ))} +
+ )} + +
+ ); + + const getFilteredByType = (type: string) => filteredResults.filter(item => item.type === type); + const getFilteredDocuments = () => filteredResults.filter(item => item.type === 'mine_documents' || item.type === 'permit_documents'); + + return ( + +
+ {renderFacets()} + + + + + { + if (item.type === "mine") return renderMineResult(item); + if (item.type === "party") return renderPartyResult(item); + if (item.type === "permit") return renderPermitResult(item); + if (item.type === "mine_documents" || item.type === "permit_documents") return renderDocumentResult(item); + return null; + }} + /> + + + + + + + + + + + + + + + + + ); + }; + + return ( +
+
+ +
+ Search & Exploration + + + + +
+ +
+ {renderContent()} + + - ) : ( - <> ); }; -const mapStateToProps = (state: any) => ({ +const mapStateToProps = (state) => ({ searchOptions: getSearchOptions(state), - searchOptionsHash: mapValues(keyBy(getSearchOptions(state), "model_id"), "description"), searchResults: getSearchResults(state), searchTerms: getSearchTerms(state), - partyRelationshipTypeHash: getPartyRelationshipTypeHash(state), }); const mapDispatchToProps = (dispatch) => @@ -252,8 +665,4 @@ const mapDispatchToProps = (dispatch) => dispatch ); - -const connector = connect(mapStateToProps, mapDispatchToProps); -type PropsFromRedux = ConnectedProps; - -export default connector(SearchResults); +export default connect(mapStateToProps, mapDispatchToProps)(SearchResults); diff --git a/services/core-web/src/tests/components/search/GlobalSearch.spec.tsx b/services/core-web/src/tests/components/search/GlobalSearch.spec.tsx new file mode 100644 index 0000000000..ea0d600790 --- /dev/null +++ b/services/core-web/src/tests/components/search/GlobalSearch.spec.tsx @@ -0,0 +1,29 @@ +import React from "react"; +import { shallow } from "enzyme"; +import { Provider } from "react-redux"; +import configureStore from "redux-mock-store"; +import GlobalSearch from "@/components/search/GlobalSearch"; + +const mockStore = configureStore([]); + +describe("GlobalSearch", () => { + let store; + let component; + + beforeEach(() => { + store = mockStore({ + search: { + searchBarResults: [], + }, + }); + component = shallow( + + + + ); + }); + + it("renders properly", () => { + expect(component).toMatchSnapshot(); + }); +}); diff --git a/services/core-web/src/tests/components/search/SearchBar.spec.tsx b/services/core-web/src/tests/components/search/SearchBar.spec.tsx deleted file mode 100644 index d634d246de..0000000000 --- a/services/core-web/src/tests/components/search/SearchBar.spec.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import React from "react"; -import { render } from "@testing-library/react"; -import { Provider } from "react-redux"; -import SearchBar from "@/components/search/SearchBar"; -import * as MOCK from "@mds/common/tests/mocks/dataMocks"; -import { store } from "@/App"; -import { BrowserRouter } from "react-router-dom"; - -const dispatchProps = { - fetchSearchBarResults: jest.fn(), - clearSearchBarResults: jest.fn(), -}; -const reducerProps = { - searchBarResults: MOCK.SIMPLE_SEARCH_RESULTS, - history: { - push: jest.fn(), - location: {}, - }, -}; -const props = { - iconPlacement: "prefix", - -} - -describe("SearchBar", () => { - it("renders properly", () => { - const { container: component } = render( - - - - - ); - expect(component).toMatchSnapshot(); - }); -}); diff --git a/services/core-web/src/tests/components/search/SearchBarDropdown.spec.tsx b/services/core-web/src/tests/components/search/SearchBarDropdown.spec.tsx deleted file mode 100644 index 22a322d317..0000000000 --- a/services/core-web/src/tests/components/search/SearchBarDropdown.spec.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import React from "react"; -import { shallow } from "enzyme"; -import { SearchBarDropdown } from "@/components/search/SearchBarDropdown"; -import * as MOCK from "@mds/common/tests/mocks/dataMocks"; - -const dispatchProps = {}; -const reducerProps = { - searchBarResults: MOCK.SIMPLE_SEARCH_RESULTS.search_results, - searchTerm: MOCK.SIMPLE_SEARCH_RESULTS.search_terms[0], - searchTermHistory: [""], - history: { - push: jest.fn(), - location: {}, - }, -}; - -describe("SearchBarDropdown", () => { - it("renders properly", () => { - const component = shallow(Child} {...dispatchProps} {...reducerProps} />); - expect(component).toMatchSnapshot(); - }); -}); From eaa31be2b4519197daeb8f7df422b350b0ecd804 Mon Sep 17 00:00:00 2001 From: Simen Fivelstad Smaaberg Date: Mon, 22 Dec 2025 14:45:18 -0800 Subject: [PATCH 02/25] Updated search interface --- .../search/searchResult.interface.ts | 2 + .../actionCreators/searchActionCreator.js | 16 +- .../src/redux/reducers/searchReducer.ts | 7 + .../elasticsearch/elastic_search_service.py | 2 +- .../app/api/search/response_models.py | 33 + .../app/api/search/search/resources/search.py | 151 ++- .../search/search/resources/simple_search.py | 445 ++++++- services/core-api/app/api/utils/search.py | 71 ++ .../src/components/search/GlobalSearch.tsx | 932 +++++++++++--- .../src/components/search/SearchResults.tsx | 916 +++++++------- .../src/styles/components/GlobalSearch.scss | 1066 +++++++++++++++++ services/core-web/src/styles/index.scss | 1 + .../app/celery/elasticsearch_backend.py | 12 +- services/pgsync/Dockerfile | 17 + services/pgsync/schema.json | 318 +++++ services/pgsync/start.sh | 14 + 16 files changed, 3351 insertions(+), 652 deletions(-) create mode 100644 services/core-web/src/styles/components/GlobalSearch.scss create mode 100644 services/pgsync/Dockerfile create mode 100644 services/pgsync/schema.json create mode 100755 services/pgsync/start.sh diff --git a/services/common/src/interfaces/search/searchResult.interface.ts b/services/common/src/interfaces/search/searchResult.interface.ts index dfcd5d9e0f..06d800ec48 100644 --- a/services/common/src/interfaces/search/searchResult.interface.ts +++ b/services/common/src/interfaces/search/searchResult.interface.ts @@ -13,6 +13,8 @@ export interface ISearchResult { export interface ISimpleSearchResult { id: string; value: string; + description?: string; + highlight?: string; } export interface ISearchResultList { diff --git a/services/common/src/redux/actionCreators/searchActionCreator.js b/services/common/src/redux/actionCreators/searchActionCreator.js index 0dc945a3ad..7caa7fcc3d 100644 --- a/services/common/src/redux/actionCreators/searchActionCreator.js +++ b/services/common/src/redux/actionCreators/searchActionCreator.js @@ -27,14 +27,20 @@ export const fetchSearchResults = (searchTerm, searchTypes) => (dispatch) => { .finally(() => dispatch(hideLoading())); }; -export const fetchSearchBarResults = (searchTerm) => (dispatch) => { +export const fetchSearchBarResults = (searchTerm, searchTypes = null, mineGuid = null) => (dispatch) => { dispatch(request(NetworkReducerTypes.GET_SEARCH_BAR_RESULTS)); dispatch(showLoading()); + + let url = `${ENVIRONMENT.apiUrl + API.SIMPLE_SEARCH}?search_term=${encodeURIComponent(searchTerm)}`; + if (searchTypes && searchTypes.length > 0) { + url += `&search_types=${encodeURIComponent(searchTypes.join(','))}`; + } + if (mineGuid) { + url += `&mine_guid=${encodeURIComponent(mineGuid)}`; + } + return CustomAxios() - .get( - `${ENVIRONMENT.apiUrl + API.SIMPLE_SEARCH}?search_term=${searchTerm}`, - createRequestHeader() - ) + .get(url, createRequestHeader()) .then((response) => { dispatch(success(NetworkReducerTypes.GET_SEARCH_BAR_RESULTS)); dispatch(searchActions.storeSearchBarResults(response.data)); diff --git a/services/common/src/redux/reducers/searchReducer.ts b/services/common/src/redux/reducers/searchReducer.ts index 94f215e149..0c6c414568 100644 --- a/services/common/src/redux/reducers/searchReducer.ts +++ b/services/common/src/redux/reducers/searchReducer.ts @@ -10,7 +10,9 @@ import { ISearchResult, ISearchResultList, ISimpleSearchResult } from "@mds/comm const initialState = { searchOptions: [], searchResults: [], + searchFacets: { mine_region: [], mine_classification: [], permit_status: [], type: [] }, searchBarResults: [], + searchBarFacets: { mine: 0, person: 0, organization: 0, permit: 0, nod: 0, explosives_permit: 0 }, searchTerms: [], searchSubsetResults: [], }; @@ -26,6 +28,7 @@ export const searchReducer = (state = initialState, action) => { return { ...state, searchResults: action.payload.search_results, + searchFacets: action.payload.facets || initialState.searchFacets, searchTerms: action.payload.search_terms, }; case actionTypes.STORE_SUBSET_SEARCH_RESULTS: @@ -37,11 +40,13 @@ export const searchReducer = (state = initialState, action) => { return { ...state, searchBarResults: action.payload.search_results, + searchBarFacets: action.payload.facets || initialState.searchBarFacets, }; case actionTypes.CLEAR_SEARCH_BAR_RESULTS: return { ...state, searchBarResults: [], + searchBarFacets: initialState.searchBarFacets, }; case actionTypes.CLEAR_ALL_SEARCH_RESULTS: return initialState; @@ -56,7 +61,9 @@ const searchReducerObject = { export const getSearchOptions = (state) => state[SEARCH].searchOptions; export const getSearchResults = (state): ISearchResultList => state[SEARCH].searchResults; +export const getSearchFacets = (state) => state[SEARCH].searchFacets; export const getSearchBarResults = (state): ISearchResult[] => state[SEARCH].searchBarResults; +export const getSearchBarFacets = (state): { mine: number; person: number; organization: number; permit: number; nod: number; explosives_permit: number } => state[SEARCH].searchBarFacets; export const getSearchTerms = (state) => state[SEARCH].searchTerms; export const getSearchSubsetResults = (state) => state[SEARCH].searchSubsetResults; diff --git a/services/core-api/app/api/search/elasticsearch/elastic_search_service.py b/services/core-api/app/api/search/elasticsearch/elastic_search_service.py index d52b9bf991..2a297f0c38 100644 --- a/services/core-api/app/api/search/elasticsearch/elastic_search_service.py +++ b/services/core-api/app/api/search/elasticsearch/elastic_search_service.py @@ -24,4 +24,4 @@ def get_client(cls): @classmethod def search(cls, index_name, query, size=10): client = cls.get_client() - return client.search(index=index_name, body=query, size=size) + return client.search(index=index_name, body=query, size=size, ignore_unavailable=True) diff --git a/services/core-api/app/api/search/response_models.py b/services/core-api/app/api/search/response_models.py index 6e5a4d65e9..f987b0cde0 100644 --- a/services/core-api/app/api/search/response_models.py +++ b/services/core-api/app/api/search/response_models.py @@ -19,6 +19,8 @@ SIMPLE_SEARCH_MODEL = api.model('SimpleSearchResult', { 'id': fields.String, 'value': fields.String, + 'description': fields.String, + 'highlight': fields.String, }) MINE_MODEL = api.model('Mine_simple ', { @@ -143,14 +145,45 @@ 'permit_documents': fields.List(fields.Nested(PERMIT_DOCUMENT_SEARCH_RESULT_MODEL)), }) +SEARCH_FACET_BUCKET_MODEL = api.model( + 'SearchFacetBucket', { + 'key': fields.String, + 'count': fields.Integer, + }) + +SEARCH_FACETS_MODEL = api.model( + 'SearchFacets', { + 'mine_region': fields.List(fields.Nested(SEARCH_FACET_BUCKET_MODEL)), + 'mine_classification': fields.List(fields.Nested(SEARCH_FACET_BUCKET_MODEL)), + 'mine_operation_status': fields.List(fields.Nested(SEARCH_FACET_BUCKET_MODEL)), + 'mine_tenure': fields.List(fields.Nested(SEARCH_FACET_BUCKET_MODEL)), + 'mine_commodity': fields.List(fields.Nested(SEARCH_FACET_BUCKET_MODEL)), + 'has_tsf': fields.List(fields.Nested(SEARCH_FACET_BUCKET_MODEL)), + 'verified_status': fields.List(fields.Nested(SEARCH_FACET_BUCKET_MODEL)), + 'permit_status': fields.List(fields.Nested(SEARCH_FACET_BUCKET_MODEL)), + 'type': fields.List(fields.Nested(SEARCH_FACET_BUCKET_MODEL)), + }) + SEARCH_RESULT_RETURN_MODEL = api.model( 'SearchResultReturn', { 'search_terms': fields.List(fields.String), 'search_results': fields.Nested(SEARCH_RESULTS_LIST_MODEL), + 'facets': fields.Nested(SEARCH_FACETS_MODEL), + }) + +SIMPLE_SEARCH_FACETS_MODEL = api.model( + 'SimpleSearchFacets', { + 'mine': fields.Integer, + 'person': fields.Integer, + 'organization': fields.Integer, + 'permit': fields.Integer, + 'nod': fields.Integer, + 'explosives_permit': fields.Integer, }) SIMPLE_SEARCH_RESULT_RETURN_MODEL = api.model( 'SimpleSearchResultReturn', { 'search_terms': fields.List(fields.String), 'search_results': fields.List(fields.Nested(SIMPLE_SEARCH_RESULT_MODEL)), + 'facets': fields.Nested(SIMPLE_SEARCH_FACETS_MODEL), }) diff --git a/services/core-api/app/api/search/search/resources/search.py b/services/core-api/app/api/search/search/resources/search.py index 2664b26f77..88e06a1956 100644 --- a/services/core-api/app/api/search/search/resources/search.py +++ b/services/core-api/app/api/search/search/resources/search.py @@ -1,4 +1,5 @@ import regex +from uuid import UUID from app.api.search.elasticsearch.elastic_search_service import ElasticSearchService from app.api.search.response_models import SEARCH_RESULT_RETURN_MODEL from app.api.utils.access_decorators import requires_role_view_all @@ -38,12 +39,27 @@ def get(self): 'mine': 'mines', 'party': 'parties', 'permit': 'permits', - 'mine_documents': 'documents' + 'mine_documents': 'documents', + 'notice_of_departure': 'notices_of_departure', + 'explosives_permit': 'explosives_permits' # 'permit_documents': 'documents' # TODO: Add permit documents to ES index } index_to_type = {v: k for k, v in type_to_index.items()} + # Initialize facets + facets = { + 'mine_region': [], + 'mine_classification': [], + 'mine_operation_status': [], + 'mine_tenure': [], + 'mine_commodity': [], + 'has_tsf': [], + 'verified_status': [], + 'permit_status': [], + 'type': [] + } + indices = [] for type in search_types: if type in type_to_index: @@ -52,7 +68,7 @@ def get(self): if indices: indices_string = ",".join(list(set(indices))) - # Construct query + # Construct query with aggregations for facets query = { "query": { "bool": { @@ -69,12 +85,131 @@ def get(self): {"term": {"deleted_ind": False}} ] } + }, + "aggs": { + "by_index": { + "terms": {"field": "_index", "size": 10} + }, + "mine_region": { + "terms": {"field": "mine_region.keyword", "size": 20, "missing": "Unknown"} + }, + "major_mine_ind": { + "terms": {"field": "major_mine_ind", "size": 10} + }, + "mine_operation_status": { + "nested": {"path": "mine_status"}, + "aggs": { + "status_codes": { + "nested": {"path": "mine_status.status_xref"}, + "aggs": { + "codes": { + "terms": {"field": "mine_status.status_xref.mine_operation_status_code.keyword", "size": 20} + } + } + } + } + }, + "mine_tenure": { + "nested": {"path": "mine_types"}, + "aggs": { + "tenure_codes": { + "terms": {"field": "mine_types.mine_tenure_type_code.keyword", "size": 20} + } + } + }, + "mine_commodity": { + "nested": {"path": "mine_types"}, + "aggs": { + "details": { + "nested": {"path": "mine_types.mine_type_details"}, + "aggs": { + "commodity_codes": { + "terms": {"field": "mine_types.mine_type_details.mine_commodity_code.keyword", "size": 30} + } + } + } + } + }, + "has_tsf": { + "nested": {"path": "tailings_storage_facilities"}, + "aggs": { + "count": { + "value_count": {"field": "tailings_storage_facilities.mine_tailings_storage_facility_guid"} + } + } + }, + "verified_status": { + "nested": {"path": "verified_status"}, + "aggs": { + "healthy": { + "terms": {"field": "verified_status.healthy_ind", "size": 10} + } + } + }, + "permit_status": { + "terms": {"field": "permit_status_code.keyword", "size": 20} + } } } try: + current_app.logger.info(f"Searching ES indices: {indices_string} for term: {search_term}") es_results = ElasticSearchService.search(indices_string, query, size=200) hits = es_results['hits']['hits'] + current_app.logger.info(f"ES returned {len(hits)} hits") + + # Process aggregations for facets + aggs = es_results.get('aggregations', {}) + + # Type facets (by index) + for bucket in aggs.get('by_index', {}).get('buckets', []): + index_name = bucket['key'] + type_name = index_to_type.get(index_name, index_name) + facets['type'].append({'key': type_name, 'count': bucket['doc_count']}) + + # Mine region facets + for bucket in aggs.get('mine_region', {}).get('buckets', []): + if bucket['key'] != 'Unknown': + facets['mine_region'].append({'key': bucket['key'], 'count': bucket['doc_count']}) + + # Mine classification facets (major vs regional) + for bucket in aggs.get('major_mine_ind', {}).get('buckets', []): + label = 'Major Mine' if bucket.get('key_as_string') == 'true' or bucket['key'] == True else 'Regional Mine' + facets['mine_classification'].append({'key': label, 'count': bucket['doc_count']}) + + # Mine operation status facets (nested) + status_agg = aggs.get('mine_operation_status', {}).get('status_codes', {}).get('codes', {}) + for bucket in status_agg.get('buckets', []): + facets['mine_operation_status'].append({'key': bucket['key'], 'count': bucket['doc_count']}) + + # Mine tenure facets (nested) + tenure_agg = aggs.get('mine_tenure', {}).get('tenure_codes', {}) + for bucket in tenure_agg.get('buckets', []): + facets['mine_tenure'].append({'key': bucket['key'], 'count': bucket['doc_count']}) + + # Mine commodity facets (nested) + commodity_agg = aggs.get('mine_commodity', {}).get('details', {}).get('commodity_codes', {}) + for bucket in commodity_agg.get('buckets', []): + if bucket['key']: # Skip empty commodity codes + facets['mine_commodity'].append({'key': bucket['key'], 'count': bucket['doc_count']}) + + # TSF facets - count mines with TSF + tsf_count = aggs.get('has_tsf', {}).get('count', {}).get('value', 0) + total_mines = sum(b['doc_count'] for b in aggs.get('by_index', {}).get('buckets', []) if b['key'] == 'mines') + if tsf_count > 0: + facets['has_tsf'].append({'key': 'Has TSF', 'count': tsf_count}) + if total_mines > tsf_count: + facets['has_tsf'].append({'key': 'No TSF', 'count': total_mines - tsf_count}) + + # Verified status facets (nested) + verified_agg = aggs.get('verified_status', {}).get('healthy', {}) + for bucket in verified_agg.get('buckets', []): + label = 'Verified' if bucket.get('key_as_string') == 'true' or bucket['key'] == True else 'Unverified' + facets['verified_status'].append({'key': label, 'count': bucket['doc_count']}) + + # Permit status facets + for bucket in aggs.get('permit_status', {}).get('buckets', []): + facets['permit_status'].append({'key': bucket['key'], 'count': bucket['doc_count']}) grouped_hits = {} for hit in hits: @@ -113,7 +248,15 @@ def get(self): model = search_targets[type]['model'] primary_column = search_targets[type]['primary_column'] - db_results = db.session.query(model).filter(primary_column.in_(ids)).all() + # Convert string IDs to UUIDs for database query + try: + uuid_ids = [UUID(id) for id in ids if id] + except (ValueError, TypeError) as e: + current_app.logger.error(f"UUID conversion error for {type}: {e}") + uuid_ids = ids # Fall back to string IDs + + db_results = db.session.query(model).filter(primary_column.in_(uuid_ids)).all() + current_app.logger.info(f"DB query for {type} returned {len(db_results)} results from {len(uuid_ids)} IDs") db_results_map = {str(getattr(r, search_targets[type]['id_field'])): r for r in db_results} final_results = [] @@ -141,4 +284,4 @@ def get(self): if type not in all_search_results: all_search_results[type] = [] - return {'search_terms': search_terms, 'search_results': all_search_results} + return {'search_terms': search_terms, 'search_results': all_search_results, 'facets': facets} diff --git a/services/core-api/app/api/search/search/resources/simple_search.py b/services/core-api/app/api/search/search/resources/simple_search.py index 510406144a..278e4384a0 100644 --- a/services/core-api/app/api/search/search/resources/simple_search.py +++ b/services/core-api/app/api/search/search/resources/simple_search.py @@ -15,51 +15,226 @@ class SimpleSearchResource(Resource, UserMixin): def get(self): search_results = [] search_term = request.args.get('search_term', None, type=str) + search_types = request.args.get('search_types', None, type=str) + mine_guid = request.args.get('mine_guid', None, type=str) # Split incoming search query by space to search by individual words - reg_exp = regex.compile(r'\'.*?\' | ".*?" | \S+ ', regex.VERBOSE) - search_terms = reg_exp.findall(search_term) - search_terms = [term.replace('"', '') for term in search_terms] + search_terms = [] + if search_term and search_term != "*": + reg_exp = regex.compile(r'\'.*?\' | ".*?" | \S+ ', regex.VERBOSE) + search_terms = reg_exp.findall(search_term) + search_terms = [term.replace('"', '') for term in search_terms] type_to_index = { 'mine': 'mines', 'party': 'parties', - 'permit': 'permits' + 'permit': 'permits', + 'notice_of_departure': 'notices_of_departure', + 'explosives_permit': 'explosives_permits', + 'now_application': 'now_applications' } index_to_type = {v: k for k, v in type_to_index.items()} + # Parse search_types filter (e.g., "mine,person,organization,permit,nod,explosives_permit") + allowed_types = None + if search_types: + allowed_types = [t.strip() for t in search_types.split(',')] + + # Map result types to index types + result_type_to_index = { + 'mine': 'mine', + 'person': 'party', + 'organization': 'party', + 'permit': 'permit', + 'nod': 'notice_of_departure', + 'explosives_permit': 'explosives_permit', + 'now_application': 'now_application' + } + indices = [] for type in simple_search_targets.keys(): if type in type_to_index: - indices.append(type_to_index[type]) + # Only include if no filter, or any result type maps to this index + if allowed_types is None: + indices.append(type_to_index[type]) + else: + for result_type in allowed_types: + if result_type_to_index.get(result_type) == type: + indices.append(type_to_index[type]) + break if indices: indices_string = ",".join(list(set(indices))) - # Construct query - query = { - "query": { + # Build base filter clauses + # Use bool with should to handle missing deleted_ind field + base_filters = [{ + "bool": { + "should": [ + {"term": {"deleted_ind": False}}, + {"bool": {"must_not": {"exists": {"field": "deleted_ind"}}}} + ], + "minimum_should_match": 1 + } + }] + + # Add mine_guid filter if provided (scoped search) + if mine_guid: + current_app.logger.info(f"Scoped search for mine_guid: {mine_guid}, indices: {indices_string}") + # Search for mine_guid in multiple locations across different indices: + # - mines index: mine_guid direct field + # - permits: mine.mine_guid nested field + # - nod/explosives/documents: mine_guid direct field + # - parties: mine_appointments.mine_guid or mine_appointments.mine.mine_guid + # Use both raw and .keyword variants for compatibility + base_filters.append({ "bool": { - "must": [ - { - "multi_match": { - "query": search_term, - "fields": ["*"], - "fuzziness": "AUTO" - } - } + "should": [ + {"term": {"mine_guid": mine_guid}}, + {"term": {"mine_guid.keyword": mine_guid}}, + {"term": {"mine.mine_guid": mine_guid}}, + {"term": {"mine.mine_guid.keyword": mine_guid}}, + {"term": {"mine_appointments.mine_guid": mine_guid}}, + {"term": {"mine_appointments.mine_guid.keyword": mine_guid}}, + {"term": {"mine_appointments.mine.mine_guid": mine_guid}}, + {"term": {"mine_appointments.mine.mine_guid.keyword": mine_guid}}, ], - "filter": [ - {"term": {"deleted_ind": False}} - ] + "minimum_should_match": 1 } - } + }) + + # Define searchable fields with boosting + search_fields = [ + # Mine fields + "mine_name^3", + "mine_no^3", + "mms_alias^2", + "mine.mine_name^2", + "mine.mine_no^2", + # Party/contact fields + "party_name^3", + "first_name^2", + "email^2", + "phone_no", + # Permit fields + "permit_no^3", + "permit_number^3", + "application_number^2", + # NOD fields + "nod_no^3", + "nod_title^3", + "nod_description", + # NOW fields + "now_number^3", + "application.property_name^2", + # Document fields + "document_name^2", + # Description fields + "description", + # Catch-all + "*" + ] + + # Highlight configuration + highlight_config = { + "fields": { + "mine_name": {}, + "mine_no": {}, + "mms_alias": {}, + "mine.mine_name": {}, + "mine.mine_no": {}, + "party_name": {}, + "first_name": {}, + "email": {}, + "permit_no": {}, + "permit_number": {}, + "nod_no": {}, + "nod_title": {}, + "nod_description": {}, + "now_number": {}, + "application.property_name": {}, + "document_name": {}, + "description": {}, + "application_number": {}, + }, + "pre_tags": [""], + "post_tags": [""], + "fragment_size": 150, + "number_of_fragments": 1 } + + # Construct query - use match_all for wildcard, prefix for short terms, fuzzy for longer + is_wildcard = search_term == "*" or (mine_guid and not search_term) + + if is_wildcard: + # Match all documents (filtered by mine_guid if provided) + query = { + "query": { + "bool": { + "must": [{"match_all": {}}], + "filter": base_filters + } + }, + "sort": [{"_score": "desc"}] + } + elif len(search_term) < 3: + query = { + "query": { + "bool": { + "should": [ + { + "multi_match": { + "query": search_term, + "fields": search_fields, + "type": "phrase_prefix" + } + }, + { + "multi_match": { + "query": search_term, + "fields": search_fields + } + } + ], + "minimum_should_match": 1, + "filter": base_filters + } + }, + "highlight": highlight_config + } + else: + query = { + "query": { + "bool": { + "should": [ + { + "multi_match": { + "query": search_term, + "fields": search_fields, + "type": "phrase_prefix" + } + }, + { + "multi_match": { + "query": search_term, + "fields": search_fields, + "fuzziness": "AUTO" + } + } + ], + "minimum_should_match": 1, + "filter": base_filters + } + }, + "highlight": highlight_config + } try: + current_app.logger.info(f"ES Query: {query}") es_results = ElasticSearchService.search(indices_string, query, size=30) hits = es_results['hits']['hits'] + current_app.logger.info(f"ES returned {len(hits)} hits") for hit in hits: index = hit['_index'] @@ -77,29 +252,133 @@ def get(self): # Apply multiplier from config score_multiplier = type_config.get('score_multiplier', 1) - # Construct value based on type + # Construct value and description based on type value = "" + description = "" + result_type = type if type == 'mine': value = source.get('mine_name', '') + mine_no = source.get('mine_no', '') + mms_alias = source.get('mms_alias', '') + + # Extract commodity codes from mine_types -> mine_type_details + commodities = set() + mine_types = source.get('mine_types', []) + if mine_types: + for mt in mine_types: + details = mt.get('mine_type_details', []) + if details: + for detail in details: + commodity = detail.get('mine_commodity_code') + if commodity: + commodities.add(commodity) + + desc_parts = [] + if mine_no: + desc_parts.append(f"Mine #: {mine_no}") + if commodities: + desc_parts.append(", ".join(sorted(commodities))) + if mms_alias: + desc_parts.append(f"Alias: {mms_alias}") + description = " | ".join(desc_parts) elif type == 'party': first_name = source.get('first_name', '') party_name = source.get('party_name', '') - value = f"{first_name} {party_name}".strip() + party_type_code = source.get('party_type_code', 'PER') + email = source.get('email', '') + phone_no = source.get('phone_no', '') + value = f"{first_name} {party_name}".strip() if party_type_code == 'PER' else party_name + result_type = 'person' if party_type_code == 'PER' else 'organization' + desc_parts = [] + if email: + desc_parts.append(email) + if phone_no: + desc_parts.append(phone_no) + description = " | ".join(desc_parts) elif type == 'permit': value = source.get('permit_no', '') + permit_status = source.get('permit_status_code', '') + if permit_status: + description = f"Status: {permit_status}" + elif type == 'notice_of_departure': + result_type = 'nod' + value = source.get('nod_title', '') or source.get('nod_no', '') + nod_no = source.get('nod_no', '') + nod_status = source.get('nod_status', '') + mine_info = source.get('mine', {}) + mine_name = mine_info.get('mine_name', '') if mine_info else '' + desc_parts = [] + if nod_no: + desc_parts.append(nod_no) + if mine_name: + desc_parts.append(mine_name) + if nod_status: + desc_parts.append(nod_status.replace('_', ' ').title()) + description = " | ".join(desc_parts) + elif type == 'explosives_permit': + result_type = 'explosives_permit' + value = source.get('permit_number', '') or source.get('application_number', '') + app_status = source.get('application_status', '') + is_closed = source.get('is_closed', False) + mine_info = source.get('mine', {}) + mine_name = mine_info.get('mine_name', '') if mine_info else '' + desc_parts = [] + if mine_name: + desc_parts.append(mine_name) + if is_closed: + desc_parts.append('Closed') + elif app_status: + status_map = {'REC': 'Received', 'APP': 'Approved', 'REJ': 'Rejected'} + desc_parts.append(status_map.get(app_status, app_status)) + description = " | ".join(desc_parts) + elif type == 'now_application': + result_type = 'now_application' + value = source.get('now_number', '') + application = source.get('application', {}) + property_name = application.get('property_name', '') if application else '' + status_code = application.get('now_application_status_code', '') if application else '' + mine_info = source.get('mine', {}) + mine_name = mine_info.get('mine_name', '') if mine_info else '' + desc_parts = [] + if property_name: + desc_parts.append(property_name) + if mine_name: + desc_parts.append(mine_name) + if status_code: + status_map = {'REC': 'Received', 'REF': 'Referred', 'CDI': 'Client Delay', 'GVD': 'Govt Delay', + 'CON': 'Consultation', 'AIA': 'Approved', 'REJ': 'Rejected', 'WDN': 'Withdrawn', 'NPR': 'No Permit Required'} + desc_parts.append(status_map.get(status_code, status_code)) + description = " | ".join(desc_parts) - # Boost if starts with or exact match - if value.lower().startswith(search_term.lower()): - score_multiplier *= 3 - if value.lower() == search_term.lower(): - score_multiplier *= 10 + # Filter by result type if search_types specified + if allowed_types and result_type not in allowed_types: + continue + + # Boost if starts with or exact match (skip for wildcard searches) + if value and search_term and search_term != "*": + if value.lower().startswith(search_term.lower()): + score_multiplier *= 3 + if value.lower() == search_term.lower(): + score_multiplier *= 10 + + # Extract highlights from ES response + highlights = hit.get('highlight', {}) + highlight_text = None + if highlights: + # Get the first highlighted field + for field, fragments in highlights.items(): + if fragments: + highlight_text = fragments[0] + break search_results.append(SearchResult( score * score_multiplier, - type, + result_type, { 'id': source.get(type_config['id_field']), - 'value': value + 'value': value, + 'description': description, + 'highlight': highlight_text } )) @@ -117,4 +396,110 @@ def get(self): search_results.sort(key=lambda x: x.score, reverse=True) search_results = search_results[0:4] - return {'search_terms': search_terms, 'search_results': search_results} \ No newline at end of file + # Get facet counts (unfiltered) using aggregations + facets = {'mine': 0, 'person': 0, 'organization': 0, 'permit': 0, 'nod': 0, 'explosives_permit': 0, 'now_application': 0} + all_indices = ",".join([type_to_index[t] for t in simple_search_targets.keys() if t in type_to_index]) + + if all_indices and search_term: + # Build facet query with aggregations + if len(search_term) < 3: + facet_query = { + "query": { + "bool": { + "should": [ + {"multi_match": {"query": search_term, "fields": ["*"], "type": "phrase_prefix"}}, + {"multi_match": {"query": search_term, "fields": ["*"]}} + ], + "minimum_should_match": 1, + "filter": [{"term": {"deleted_ind": False}}] + } + }, + "aggs": { + "by_index": { + "terms": {"field": "_index"}, + "aggs": { + "by_party_type": { + "terms": {"field": "party_type_code.keyword", "missing": "N/A"} + } + } + } + } + } + else: + facet_query = { + "query": { + "bool": { + "must": [{"multi_match": {"query": search_term, "fields": ["*"], "fuzziness": "AUTO"}}], + "filter": [{"term": {"deleted_ind": False}}] + } + }, + "aggs": { + "by_index": { + "terms": {"field": "_index"}, + "aggs": { + "by_party_type": { + "terms": {"field": "party_type_code.keyword", "missing": "N/A"} + } + } + } + } + } + + try: + facet_results = ElasticSearchService.search(all_indices, facet_query, size=0) + buckets = facet_results.get('aggregations', {}).get('by_index', {}).get('buckets', []) + + for bucket in buckets: + index_name = bucket['key'] + doc_count = bucket['doc_count'] + + if index_name == 'mines': + facets['mine'] = doc_count + elif index_name == 'permits': + facets['permit'] = doc_count + elif index_name == 'notices_of_departure': + facets['nod'] = doc_count + elif index_name == 'explosives_permits': + facets['explosives_permit'] = doc_count + elif index_name == 'now_applications': + facets['now_application'] = doc_count + elif index_name == 'parties': + # Split by party_type_code + party_buckets = bucket.get('by_party_type', {}).get('buckets', []) + if party_buckets: + for party_bucket in party_buckets: + party_type = party_bucket['key'] + party_count = party_bucket['doc_count'] + if party_type == 'PER': + facets['person'] = party_count + elif party_type == 'ORG': + facets['organization'] = party_count + + # Fallback: if no party_type breakdown worked, count from grouped results + if facets['person'] == 0 and facets['organization'] == 0 and doc_count > 0: + for result in grouped_results.values(): + if result.type == 'person': + facets['person'] += 1 + elif result.type == 'organization': + facets['organization'] += 1 + + except Exception as e: + current_app.logger.error(f"Elasticsearch facet error: {e}") + # Fallback: count from all grouped results (before truncation) + for result in grouped_results.values(): + if result.type == 'mine': + facets['mine'] += 1 + elif result.type == 'person': + facets['person'] += 1 + elif result.type == 'organization': + facets['organization'] += 1 + elif result.type == 'permit': + facets['permit'] += 1 + elif result.type == 'nod': + facets['nod'] += 1 + elif result.type == 'explosives_permit': + facets['explosives_permit'] += 1 + elif result.type == 'now_application': + facets['now_application'] += 1 + + return {'search_terms': search_terms, 'search_results': search_results, 'facets': facets} \ No newline at end of file diff --git a/services/core-api/app/api/utils/search.py b/services/core-api/app/api/utils/search.py index cdc0101460..ee103bed75 100644 --- a/services/core-api/app/api/utils/search.py +++ b/services/core-api/app/api/utils/search.py @@ -6,7 +6,10 @@ from app.api.mines.permits.permit_amendment.models.permit_amendment_document import ( PermitAmendmentDocument, ) +from app.api.mines.explosives_permit.models.explosives_permit import ExplosivesPermit from app.api.parties.party.models.party import Party +from app.api.notice_of_departure.models.notice_of_departure import NoticeOfDeparture +from app.api.now_applications.models.now_application_identity import NOWApplicationIdentity from app.api.search.search.permit_search_service import PermitSearchService from app.api.utils.feature_flag import Feature, is_feature_enabled from app.extensions import db @@ -58,6 +61,39 @@ 'id_field': 'permit_guid', 'value_field': 'permit_no', 'score_multiplier': 1000 + }, + 'notice_of_departure': { + 'model': NoticeOfDeparture, + 'primary_column': NoticeOfDeparture.nod_guid, + 'description': 'Notices of Departure', + 'entities_to_return': [NoticeOfDeparture.nod_guid, NoticeOfDeparture.nod_no, NoticeOfDeparture.nod_title], + 'columns_to_search': [NoticeOfDeparture.nod_no, NoticeOfDeparture.nod_title], + 'has_deleted_ind': True, + 'id_field': 'nod_guid', + 'value_field': 'nod_title', + 'score_multiplier': 500 + }, + 'explosives_permit': { + 'model': ExplosivesPermit, + 'primary_column': ExplosivesPermit.explosives_permit_guid, + 'description': 'Explosives Permits', + 'entities_to_return': [ExplosivesPermit.explosives_permit_guid, ExplosivesPermit.permit_number], + 'columns_to_search': [ExplosivesPermit.permit_number, ExplosivesPermit.application_number], + 'has_deleted_ind': True, + 'id_field': 'explosives_permit_guid', + 'value_field': 'permit_number', + 'score_multiplier': 500 + }, + 'now_application': { + 'model': NOWApplicationIdentity, + 'primary_column': NOWApplicationIdentity.now_application_guid, + 'description': 'Notice of Work Applications', + 'entities_to_return': [NOWApplicationIdentity.now_application_guid, NOWApplicationIdentity.now_number], + 'columns_to_search': [NOWApplicationIdentity.now_number], + 'has_deleted_ind': False, + 'id_field': 'now_application_guid', + 'value_field': 'now_number', + 'score_multiplier': 500 } } @@ -104,6 +140,41 @@ 'document_name', 'score_multiplier': 250 + }, + 'notice_of_departure': { + 'model': NoticeOfDeparture, + 'primary_column': NoticeOfDeparture.nod_guid, + 'description': 'Notices of Departure', + 'entities_to_return': [ + NoticeOfDeparture.nod_guid, + NoticeOfDeparture.nod_no, + NoticeOfDeparture.nod_title, + NoticeOfDeparture.nod_status, + NoticeOfDeparture.nod_type + ], + 'columns_to_search': [NoticeOfDeparture.nod_no, NoticeOfDeparture.nod_title, NoticeOfDeparture.nod_description], + 'has_deleted_ind': True, + 'id_field': 'nod_guid', + 'value_field': 'nod_title', + 'score_multiplier': 500 + }, + 'explosives_permit': { + 'model': ExplosivesPermit, + 'primary_column': ExplosivesPermit.explosives_permit_guid, + 'description': 'Explosives Permits', + 'entities_to_return': [ + ExplosivesPermit.explosives_permit_guid, + ExplosivesPermit.permit_number, + ExplosivesPermit.application_number, + ExplosivesPermit.description, + ExplosivesPermit.application_status, + ExplosivesPermit.is_closed + ], + 'columns_to_search': [ExplosivesPermit.permit_number, ExplosivesPermit.application_number, ExplosivesPermit.description], + 'has_deleted_ind': True, + 'id_field': 'explosives_permit_guid', + 'value_field': 'permit_number', + 'score_multiplier': 500 } } diff --git a/services/core-web/src/components/search/GlobalSearch.tsx b/services/core-web/src/components/search/GlobalSearch.tsx index 04bfb11ed6..4881dda107 100644 --- a/services/core-web/src/components/search/GlobalSearch.tsx +++ b/services/core-web/src/components/search/GlobalSearch.tsx @@ -1,219 +1,775 @@ -import React, { useState, useEffect, useRef, useCallback } from "react"; +import React, { useState, useEffect, useRef, useMemo, useCallback } from "react"; import { useDispatch, useSelector } from "react-redux"; -import { useHistory } from "react-router-dom"; -import { Modal, Input, List, Typography, Button, Empty } from "antd"; -import { SearchOutlined, FileSearchOutlined, EnterOutlined } from "@ant-design/icons"; -import { throttle } from "lodash"; +import { useHistory, useLocation } from "react-router-dom"; +import { Modal, Input, Typography, Button, List, Space, Row, Col, Avatar, Divider, Tag, Switch } from "antd"; +import { AimOutlined } from "@ant-design/icons"; +import { + SearchOutlined, + FileSearchOutlined, + EnterOutlined, + EnvironmentOutlined, + TeamOutlined, + FileProtectOutlined, + ClockCircleOutlined, + DeleteOutlined, + HistoryOutlined, + UserOutlined, + BankOutlined, + ExceptionOutlined, + AlertOutlined, +} from "@ant-design/icons"; import { fetchSearchBarResults } from "@mds/common/redux/actionCreators/searchActionCreator"; -import { getSearchBarResults } from "@mds/common/redux/reducers/searchReducer"; +import { getSearchBarResults, getSearchBarFacets } from "@mds/common/redux/reducers/searchReducer"; import * as router from "@/constants/routes"; -import { MINE, PROFILE_NOCIRCLE, DOC } from "@/constants/assets"; import { ISearchResult, ISimpleSearchResult } from "@mds/common/interfaces"; -const { Text } = Typography; +const { Text, Title } = Typography; + +const RECENT_SEARCHES_KEY = "mds_recent_searches"; +const MAX_RECENT_SEARCHES = 5; interface GlobalSearchProps { - placeholder?: string; - containerStyle?: React.CSSProperties; - size?: "small" | "middle" | "large"; + placeholder?: string; + size?: "small" | "middle" | "large"; } -const GlobalSearch: React.FC = ({ - placeholder = "Search Core...", - containerStyle = {}, - size = "middle" -}) => { - const [isModalVisible, setIsModalVisible] = useState(false); - const [searchTerm, setSearchTerm] = useState(""); - const [selectedIndex, setSelectedIndex] = useState(0); - const dispatch = useDispatch(); - const searchResults = useSelector(getSearchBarResults); - const history = useHistory(); - const inputRef = useRef(null); - - const handleOpen = () => { - setIsModalVisible(true); - setTimeout(() => inputRef.current?.focus(), 100); - }; +const TYPE_CONFIG: Record = { + mine: { icon: , label: "Mines", color: "#2e7d32", types: ["mine"] }, + contact: { icon: , label: "People", color: "#1565c0", types: ["person", "party"] }, + organization: { icon: , label: "Organizations", color: "#f57c00", types: ["organization"] }, + permit: { icon: , label: "Permits", color: "#e65100", types: ["permit"] }, + explosives_permit: { icon: , label: "Explosives", color: "#d32f2f", types: ["explosives_permit"] }, + nod: { icon: , label: "NODs", color: "#7b1fa2", types: ["nod"] }, +}; + +const RESULT_TYPE_CONFIG: Record = { + mine: { icon: , label: "Mine", color: "#2e7d32" }, + person: { icon: , label: "Person", color: "#1565c0" }, + organization: { icon: , label: "Organization", color: "#f57c00" }, + party: { icon: , label: "Contact", color: "#1565c0" }, + permit: { icon: , label: "Permit", color: "#e65100" }, + explosives_permit: { icon: , label: "Explosives Permit", color: "#d32f2f" }, + nod: { icon: , label: "NOD", color: "#7b1fa2" }, +}; + +const COMMANDS: Record = { + mine: { action: "filter:mine", description: "Toggle Mines filter", aliases: ["mines", "m"] }, + contact: { action: "filter:contact", description: "Toggle People filter", aliases: ["contacts", "people", "person", "p"] }, + organization: { action: "filter:organization", description: "Toggle Organizations filter", aliases: ["organizations", "orgs", "org", "o"] }, + permit: { action: "filter:permit", description: "Toggle Permits filter", aliases: ["permits"] }, + explosives: { action: "filter:explosives_permit", description: "Toggle Explosives filter", aliases: ["explosives_permit", "exp", "e"] }, + nod: { action: "filter:nod", description: "Toggle NODs filter", aliases: ["nods", "n"] }, + here: { action: "scope:mine", description: "Toggle scope to current mine", aliases: ["this", "scope"] }, + clear: { action: "clear:filters", description: "Clear all filters", aliases: ["reset", "c"] }, +}; + +const GlobalSearch: React.FC = ({ placeholder = "Search Core..." }) => { + const [isModalVisible, setIsModalVisible] = useState(false); + const [searchTerm, setSearchTerm] = useState(""); + const [selectedIndex, setSelectedIndex] = useState(0); + const [recentSearches, setRecentSearches] = useState([]); + const [activeFilters, setActiveFilters] = useState([]); + const [scopeToMine, setScopeToMine] = useState(false); + const [commandMode, setCommandMode] = useState(false); + const [commandInput, setCommandInput] = useState(""); + + const dispatch = useDispatch(); + const searchResults = useSelector(getSearchBarResults); + const facets = useSelector(getSearchBarFacets); + const history = useHistory(); + const location = useLocation(); + const inputRef = useRef(null); + + // Extract mine_guid from URL if on a mine page + const currentMineGuid = useMemo(() => { + const match = location.pathname.match(/\/mine-dashboard\/([a-f0-9-]+)/i); + return match ? match[1] : null; + }, [location.pathname]); + + const isOnMinePage = !!currentMineGuid; + + useEffect(() => { + const stored = localStorage.getItem(RECENT_SEARCHES_KEY); + if (stored) { + try { + setRecentSearches(JSON.parse(stored)); + } catch { + setRecentSearches([]); + } + } + }, []); + + const saveRecentSearch = (term: string) => { + if (!term.trim()) return; + const updated = [term, ...recentSearches.filter((s) => s !== term)].slice(0, MAX_RECENT_SEARCHES); + setRecentSearches(updated); + localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated)); + }; + + const removeRecentSearch = (term: string, e: React.MouseEvent) => { + e.stopPropagation(); + const updated = recentSearches.filter((s) => s !== term); + setRecentSearches(updated); + localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated)); + }; + + const handleOpen = () => { + setIsModalVisible(true); + setTimeout(() => inputRef.current?.focus(), 50); + }; - const handleClose = () => { - setIsModalVisible(false); - setSearchTerm(""); - setSelectedIndex(0); + const handleClose = useCallback(() => { + setIsModalVisible(false); + setSearchTerm(""); + setSelectedIndex(0); + setActiveFilters([]); + setScopeToMine(false); + setCommandMode(false); + setCommandInput(""); + }, []); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "k") { + e.preventDefault(); + handleOpen(); + } }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, []); + + const getSearchTypes = (filters: string[]) => { + if (filters.length === 0) return null; + return filters.flatMap((f) => TYPE_CONFIG[f]?.types || []); + }; + + const getMineGuidForSearch = () => scopeToMine && currentMineGuid ? currentMineGuid : null; + + const findCommand = (input: string): { key: string; command: typeof COMMANDS[string] } | null => { + const cmd = input.toLowerCase().trim(); + for (const [key, command] of Object.entries(COMMANDS)) { + if (key === cmd || command.aliases.includes(cmd)) { + return { key, command }; + } + } + return null; + }; - // Keyboard shortcut to open search - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === "k") { - e.preventDefault(); - handleOpen(); - } - }; - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, []); - - const fetchResults = useCallback( - throttle((term: string) => { - if (term.length >= 2) { - dispatch(fetchSearchBarResults(term)); - } - }, 1000), - [dispatch] + const getMatchingCommands = (input: string) => { + const cmd = input.toLowerCase().trim(); + if (!cmd) return Object.entries(COMMANDS); + return Object.entries(COMMANDS).filter(([key, command]) => + key.startsWith(cmd) || command.aliases.some(a => a.startsWith(cmd)) ); + }; - const handleSearchChange = (e: React.ChangeEvent) => { - const value = e.target.value; + const executeCommand = (action: string, followUpSearch?: string) => { + const [type, target] = action.split(":"); + let newFilters = activeFilters; + let newScopeToMine = scopeToMine; + + if (type === "filter") { + newFilters = activeFilters.includes(target) + ? activeFilters.filter((f) => f !== target) + : [...activeFilters, target]; + setActiveFilters(newFilters); + } else if (type === "scope" && isOnMinePage) { + newScopeToMine = !scopeToMine; + setScopeToMine(newScopeToMine); + } else if (type === "clear") { + newFilters = []; + newScopeToMine = false; + setActiveFilters([]); + setScopeToMine(false); + } + + setCommandMode(false); + setCommandInput(""); + setSelectedIndex(0); + + // If there's a follow-up search term, set it and trigger search + if (followUpSearch && followUpSearch.trim()) { + const term = followUpSearch.trim(); + setSearchTerm(term); + const mineGuid = newScopeToMine && currentMineGuid ? currentMineGuid : null; + dispatch(fetchSearchBarResults(term, getSearchTypes(newFilters), mineGuid)); + } else if (searchTerm) { + // Re-run existing search with new filters + const mineGuid = newScopeToMine && currentMineGuid ? currentMineGuid : null; + dispatch(fetchSearchBarResults(searchTerm, getSearchTypes(newFilters), mineGuid)); + } + }; + + const handleSearchChange = (e: React.ChangeEvent) => { + const value = e.target.value; + + // If already in command mode, update command input + if (commandMode) { + // If they cleared the input or typed something without /, exit command mode + if (value === "" || (!value.startsWith("/") && commandInput === "")) { + setCommandMode(false); + setCommandInput(""); setSearchTerm(value); - setSelectedIndex(0); - fetchResults(value); - }; + return; + } + // Update command input (value is the command text without /) + setCommandInput(value); + setSelectedIndex(0); + return; + } + + // Detect command mode entry + if (value.startsWith("/")) { + setCommandMode(true); + setCommandInput(value.slice(1)); + setSelectedIndex(0); + return; + } + + setSearchTerm(value); + setSelectedIndex(0); + if (value.length > 0) { + dispatch(fetchSearchBarResults(value, getSearchTypes(activeFilters), getMineGuidForSearch())); + } + }; - const navigateToResult = (item: ISearchResult) => { - let routeUrl = ""; - switch (item.type) { - case "mine": - routeUrl = router.MINE_GENERAL.dynamicRoute(item.result.id); - break; - case "party": - routeUrl = router.PARTY_PROFILE.dynamicRoute(item.result.id); - break; - case "permit": - routeUrl = router.SEARCH_RESULTS.dynamicRoute({ q: item.result.value }); - break; - default: - break; - } - if (routeUrl) { - history.push(routeUrl); - handleClose(); - } - }; + const toggleFilter = (filterKey: string) => { + const newFilters = activeFilters.includes(filterKey) + ? activeFilters.filter((f) => f !== filterKey) + : [...activeFilters, filterKey]; + setActiveFilters(newFilters); + setSelectedIndex(0); + if (searchTerm.length > 0) { + dispatch(fetchSearchBarResults(searchTerm, getSearchTypes(newFilters), getMineGuidForSearch())); + } + }; - const handleEnter = () => { - if (searchResults && searchResults.length > 0) { - navigateToResult(searchResults[selectedIndex]); - } else if (searchTerm.length > 0) { - history.push(router.SEARCH_RESULTS.dynamicRoute({ q: searchTerm })); - handleClose(); - } + const toggleScopeToMine = (checked: boolean) => { + setScopeToMine(checked); + const mineGuid = checked && currentMineGuid ? currentMineGuid : null; + // Trigger search immediately - use "*" as wildcard if no search term + const term = searchTerm || "*"; + dispatch(fetchSearchBarResults(term, getSearchTypes(activeFilters), mineGuid)); + }; + + const navigateToResult = (item: ISearchResult) => { + saveRecentSearch(item.result.value); + let routeUrl = ""; + switch (item.type) { + case "mine": + routeUrl = router.MINE_GENERAL.dynamicRoute(item.result.id); + break; + case "person": + case "organization": + case "party": + routeUrl = router.PARTY_PROFILE.dynamicRoute(item.result.id); + break; + case "permit": + case "explosives_permit": + case "nod": + routeUrl = router.SEARCH_RESULTS.dynamicRoute({ q: item.result.value }); + break; + } + if (routeUrl) { + handleClose(); + history.push(routeUrl); + } + }; + + const handleEnter = () => { + if (searchResults?.length > 0) { + navigateToResult(searchResults[selectedIndex]); + } else if (searchTerm.length > 0) { + saveRecentSearch(searchTerm); + handleClose(); + history.push(router.SEARCH_RESULTS.dynamicRoute({ q: searchTerm })); + } + }; + + const handleRecentSearchClick = (term: string) => { + setSearchTerm(term); + dispatch(fetchSearchBarResults(term, getSearchTypes(activeFilters), getMineGuidForSearch())); + }; + + const parseCommandInput = (input: string): { commandPart: string; searchPart: string } => { + const spaceIndex = input.indexOf(" "); + if (spaceIndex === -1) { + return { commandPart: input, searchPart: "" }; + } + return { + commandPart: input.slice(0, spaceIndex), + searchPart: input.slice(spaceIndex + 1) }; + }; - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "ArrowDown") { + const handleKeyDown = (e: React.KeyboardEvent) => { + if (commandMode) { + const { commandPart, searchPart } = parseCommandInput(commandInput); + const matchingCommands = getMatchingCommands(commandPart); + const totalCommands = matchingCommands.length; + + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + setSelectedIndex((prev) => (prev + 1) % (totalCommands || 1)); + break; + case "ArrowUp": + e.preventDefault(); + setSelectedIndex((prev) => (prev - 1 + (totalCommands || 1)) % (totalCommands || 1)); + break; + case "Enter": + e.preventDefault(); + if (matchingCommands.length > 0) { + executeCommand(matchingCommands[selectedIndex][1].action, searchPart); + } + break; + case "Tab": + e.preventDefault(); + // Tab autocompletes the command but keeps the search part + if (matchingCommands.length > 0) { + const selectedCmd = matchingCommands[selectedIndex][0]; + setCommandInput(selectedCmd + (searchPart ? " " + searchPart : " ")); + } + break; + case "Escape": + e.preventDefault(); + setCommandMode(false); + setCommandInput(""); + setSearchTerm(""); + break; + case "Backspace": + if (commandInput === "") { e.preventDefault(); - setSelectedIndex((prev) => (prev + 1) % (searchResults.length || 1)); - } else if (e.key === "ArrowUp") { - e.preventDefault(); - setSelectedIndex((prev) => (prev - 1 + (searchResults.length || 1)) % (searchResults.length || 1)); - } else if (e.key === "Enter") { - e.preventDefault(); - handleEnter(); - } else if (e.key === "Escape") { - handleClose(); - } - }; + setCommandMode(false); + setSearchTerm(""); + } + break; + } + return; + } - const getIcon = (type: string) => { - switch (type) { - case "mine": - return Mine; - case "party": - return Contact; - case "permit": - return Permit; - default: - return ; + const totalItems = searchResults?.length || recentSearches.length || 0; + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + setSelectedIndex((prev) => (prev + 1) % (totalItems || 1)); + break; + case "ArrowUp": + e.preventDefault(); + setSelectedIndex((prev) => (prev - 1 + (totalItems || 1)) % (totalItems || 1)); + break; + case "Enter": + e.preventDefault(); + if (!searchTerm && recentSearches.length > 0) { + handleRecentSearchClick(recentSearches[selectedIndex]); + } else { + handleEnter(); } - }; + break; + case "Escape": + handleClose(); + break; + } + }; + + const highlightMatch = (text: string, search: string) => { + if (!search || !text) return text; + const regex = new RegExp(`(${search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "gi"); + const parts = text.split(regex); + return parts.map((part, i) => (regex.test(part) ? {part} : part)); + }; + + const groupedResults = useMemo(() => { + if (!searchResults?.length) return null; + const groups: Record[]> = {}; + searchResults.forEach((result) => { + if (!groups[result.type]) groups[result.type] = []; + groups[result.type].push(result); + }); + return groups; + }, [searchResults]); + + const renderResultItem = (item: ISearchResult, index: number) => { + const config = RESULT_TYPE_CONFIG[item.type] || { icon: , label: item.type, color: "#8c8c8c" }; + const isSelected = index === selectedIndex; return ( - <> - + navigateToResult(item)} + onMouseEnter={() => setSelectedIndex(index)} + > + + } + title={{highlightMatch(item.result.value, searchTerm)}} + description={ + + {config.label} + {item.result.description && • {item.result.description}} + {item.result.highlight && ( + + )} + + } + /> + {isSelected && } + + ); + }; - { + if (filterKey === "mine") return facets.mine; + if (filterKey === "contact") return facets.person; + if (filterKey === "organization") return facets.organization; + if (filterKey === "permit") return facets.permit; + if (filterKey === "explosives_permit") return facets.explosives_permit; + if (filterKey === "nod") return facets.nod; + return 0; + }; + + const renderFilters = () => ( +
+ + {isOnMinePage && ( + toggleScopeToMine(!scopeToMine)} + style={{ + cursor: "pointer", + backgroundColor: scopeToMine ? "#5e46a115" : "transparent", + borderColor: scopeToMine ? "#5e46a1" : "#d9d9d9", + color: scopeToMine ? "#5e46a1" : "#595959", + margin: 0, + fontWeight: scopeToMine ? 600 : 400, + }} + > + + + This Mine + + + )} + {isOnMinePage && } + {Object.entries(TYPE_CONFIG).map(([key, config]) => { + const isActive = activeFilters.includes(key); + const count = getFacetCount(key); + + return ( + toggleFilter(key)} + style={{ + cursor: "pointer", + backgroundColor: isActive ? `${config.color}15` : "transparent", + borderColor: isActive ? config.color : "#d9d9d9", + color: isActive ? config.color : "#595959", + margin: 0, + }} > -
- } - placeholder="Search for mines, contacts, permits..." - value={searchTerm} - onChange={handleSearchChange} - onKeyDown={handleKeyDown} - bordered={false} - style={{ fontSize: "16px" }} - allowClear + + {config.icon} + {config.label} + {searchTerm && ({count})} + + + ); + })} + +
+ ); + + const handleViewAll = () => { + saveRecentSearch(searchTerm); + handleClose(); + history.push(router.SEARCH_RESULTS.dynamicRoute({ q: searchTerm })); + }; + + const getCommandIcon = (action: string) => { + if (action.startsWith("filter:")) { + const filterKey = action.split(":")[1]; + return TYPE_CONFIG[filterKey]?.icon || ; + } + if (action === "scope:mine") return ; + if (action === "clear:filters") return ; + return ; + }; + + const isCommandActive = (action: string): boolean => { + if (action.startsWith("filter:")) { + return activeFilters.includes(action.split(":")[1]); + } + if (action === "scope:mine") return scopeToMine; + return false; + }; + + const renderCommands = () => { + const { commandPart, searchPart } = parseCommandInput(commandInput); + const matchingCommands = getMatchingCommands(commandPart); + + return ( +
+ + + + Commands + {searchPart && ( + + → will search "{searchPart}" + + )} + + + { + const isSelected = index === selectedIndex; + const isActive = isCommandActive(command.action); + const isDisabled = command.action === "scope:mine" && !isOnMinePage; + + return ( + !isDisabled && executeCommand(command.action, searchPart)} + onMouseEnter={() => setSelectedIndex(index)} + style={{ opacity: isDisabled ? 0.5 : 1, cursor: isDisabled ? "not-allowed" : "pointer" }} + > + -
- -
- {searchTerm && searchResults && searchResults.length > 0 ? ( - ( - navigateToResult(item)} - style={{ - padding: "12px 24px", - cursor: "pointer", - backgroundColor: index === selectedIndex ? "#e6f7ff" : "transparent" - }} - onMouseEnter={() => setSelectedIndex(index)} - > - {item.result.value}} - description={{item.type.toUpperCase()}} - /> - {index === selectedIndex && } - - )} - /> - ) : searchTerm ? ( -
- - -
- ) : ( -
- Type to start searching... -
- )} -
-
- to select - ↑↓ to navigate - esc to close -
- - + } + title={ + + /{key} + {isActive && ON} + {isDisabled && (not on mine page)} + + } + description={{command.description}} + /> + {isSelected && } + + ); + }} + split={false} + locale={{ emptyText: No matching commands }} + /> +
+ + Type /command search term to filter and search. Tab autocompletes, Enter executes. + +
+
); + }; + + const renderResults = () => { + if (commandMode) { + return renderCommands(); + } + // Show results if we have a search term OR if scoped to mine (wildcard search) + const hasActiveSearch = searchTerm || scopeToMine; + + if (hasActiveSearch && groupedResults) { + let globalIndex = 0; + return ( +
+ {Object.entries(groupedResults).map(([type, results]) => { + const config = RESULT_TYPE_CONFIG[type] || { label: type }; + return ( +
+ + {config.label}s + + renderResultItem(item, globalIndex++)} + split={false} + /> +
+ ); + })} +
+ +
+
+ ); + } + + if (hasActiveSearch && searchResults?.length === 0) { + return ( +
+ + + No results found + + {scopeToMine && !searchTerm + ? "No items found for this mine" + : activeFilters.length > 0 + ? "Try removing some filters or adjusting your search" + : "Try adjusting your search or browse all results"} + + {searchTerm && ( + + )} + +
+ ); + } + + if (!hasActiveSearch && recentSearches.length > 0) { + return ( +
+ + + + Recent Searches + + + ( + handleRecentSearchClick(term)} + onMouseEnter={() => setSelectedIndex(index)} + extra={ + removeRecentSearch(term, e)} + style={{ color: "#bfbfbf", cursor: "pointer", padding: 4 }} + /> + } + > + } + title={term} + /> + + )} + split={false} + /> +
+ ); + } + + return ( + + + Quick Actions + + + {[ + { icon: , label: "Browse Mines", color: "#2e7d32", route: router.MINE_HOME_PAGE.dynamicRoute({ page: "1", per_page: "25" }) }, + { icon: , label: "Browse Contacts", color: "#1565c0", route: router.CONTACT_HOME_PAGE.dynamicRoute({ page: "1", per_page: "25" }) }, + { icon: , label: "Reports", color: "#7b1fa2", route: router.REPORTING_DASHBOARD.route }, + ].map((action) => ( +
+ + + ))} + + + ); + }; + + return ( + <> + + + + + select + ↑↓ navigate + / commands + esc close + + + } + closable={false} + maskClosable + keyboard + className="global-search-modal" + width={580} + style={{ top: 80 }} + destroyOnClose + > + / + ) : ( + + ) + } + placeholder={commandMode ? "Type command name..." : "Search for mines, contacts, permits... (type / for commands)"} + value={commandMode ? commandInput : searchTerm} + onChange={handleSearchChange} + onKeyDown={handleKeyDown} + bordered={false} + allowClear + size="large" + style={{ borderBottom: "1px solid #f0f0f0", borderRadius: 0 }} + /> + {renderFilters()} + {renderResults()} + + + ); }; export default GlobalSearch; diff --git a/services/core-web/src/components/search/SearchResults.tsx b/services/core-web/src/components/search/SearchResults.tsx index 6408e39804..3ee63df9f7 100644 --- a/services/core-web/src/components/search/SearchResults.tsx +++ b/services/core-web/src/components/search/SearchResults.tsx @@ -3,22 +3,65 @@ import { connect } from "react-redux"; import { bindActionCreators } from "redux"; import { useLocation, useHistory, Link } from "react-router-dom"; import queryString from "query-string"; -import { Input, Tabs, List, Card, Typography, Tag, Empty, Row, Col, Button, Space, Skeleton, Checkbox, Collapse } from "antd"; -import { SearchOutlined, FileTextOutlined, TeamOutlined, EnvironmentOutlined, FileProtectOutlined, ArrowRightOutlined, EyeOutlined, FilterOutlined } from "@ant-design/icons"; -import { getSearchResults, getSearchTerms } from "@mds/common/redux/selectors/searchSelectors"; -import { fetchSearchOptions, fetchSearchResults } from "@mds/common/redux/actionCreators/searchActionCreator"; +import { + Input, + Tabs, + Card, + Typography, + Tag, + Empty, + Row, + Col, + Button, + Space, + Checkbox, + Collapse, + Tooltip, +} from "antd"; +import { + SearchOutlined, + FileTextOutlined, + TeamOutlined, + EnvironmentOutlined, + FileProtectOutlined, + ArrowRightOutlined, + EyeOutlined, + FilterOutlined, + CloseOutlined, + ClearOutlined, +} from "@ant-design/icons"; +import { getSearchResults, getSearchFacets, getSearchTerms } from "@mds/common/redux/selectors/searchSelectors"; +import { + fetchSearchOptions, + fetchSearchResults, +} from "@mds/common/redux/actionCreators/searchActionCreator"; import { getSearchOptions } from "@mds/common/redux/reducers/searchReducer"; import * as router from "@/constants/routes"; -import Loading from "@/components/common/Loading"; import Highlight from "react-highlighter"; import DocumentLink from "@mds/common/components/documents/DocumentLink"; import { ISearchResultList } from "@mds/common/interfaces"; import { formatDate } from "@common/utils/helpers"; const { Title, Text } = Typography; -const { TabPane } = Tabs; const { Panel } = Collapse; +interface FacetBucket { + key: string; + count: number; +} + +interface SearchFacets { + mine_region: FacetBucket[]; + mine_classification: FacetBucket[]; + mine_operation_status: FacetBucket[]; + mine_tenure: FacetBucket[]; + mine_commodity: FacetBucket[]; + has_tsf: FacetBucket[]; + verified_status: FacetBucket[]; + permit_status: FacetBucket[]; + type: FacetBucket[]; +} + interface SearchResultsProps { location: { search: string }; history: { push: (path: string) => void }; @@ -27,6 +70,7 @@ interface SearchResultsProps { searchOptions: any[]; searchTerms: string[]; searchResults: ISearchResultList; + searchFacets: SearchFacets; hideLoadingIndicator?: boolean; } @@ -34,141 +78,113 @@ type PropsFromRedux = SearchResultsProps; const StatusTag = ({ status }: { status: string | string[] }) => { if (!status) return null; - const statusText = Array.isArray(status) ? status.join(", ") : status; let color = "default"; - const lowerStatus = statusText.toLowerCase(); - if (lowerStatus.includes("operating") || lowerStatus.includes("open") || lowerStatus.includes("active")) { + if ( + lowerStatus.includes("operating") || + lowerStatus.includes("open") || + lowerStatus.includes("active") + ) { color = "success"; } else if (lowerStatus.includes("closed")) { color = "error"; } else if (lowerStatus.includes("care") || lowerStatus.includes("maintenance")) { color = "warning"; } - return {statusText}; }; const SearchSkeleton = () => ( - - - +
+ {[1, 2, 3, 4].map((i) => ( + +
+
+
+
+
+
+
+ + ))} +
); -const SearchResultCard = ({ title, icon, children, link, highlightRegex, actions = [] }) => ( - - -
-
{icon}
+const ResultCard = ({ + icon, + iconClass, + title, + link, + children, + actions, + highlightRegex, +}: { + icon: React.ReactNode; + iconClass: string; + title: string; + link: string; + children: React.ReactNode; + actions?: React.ReactNode[]; + highlightRegex?: RegExp | null; +}) => ( + + + +
{icon}
- - - - {highlightRegex ? ( - <Highlight search={highlightRegex}>{title ?? ""}</Highlight> - ) : ( - title ?? "" - )} - + + + {highlightRegex ? {title ?? ""} : title} - {children} +
{children}
- - - + + + + + , + + , - - - ]} > - - - - - Mine No:{" "} - {highlightRegex ? ( - {item.result.mine_no ?? ""} - ) : ( - item.result.mine_no ?? "" - )} - - - - - - - {item.result.mms_alias && ( - - Alias:{" "} - {highlightRegex ? ( - {item.result.mms_alias ?? ""} - ) : ( - item.result.mms_alias ?? "" - )} - - )} - Region: {item.result.mine_region} + •}> + + {highlightRegex ? ( + {item.result.mine_no ?? ""} + ) : ( + item.result.mine_no + )} + + {item.result.mine_region} + {mineStatus && } - + ); }; const renderPartyResult = (item: any) => ( - } + iconClass="card-icon--party" + title={item.result.name} link={router.PARTY_PROFILE.dynamicRoute(item.result.party_guid)} highlightRegex={highlightRegex} actions={[ - - + + , ]} > - - - Email:{" "} - {highlightRegex ? ( - {item.result.email ?? ""} - ) : ( - item.result.email ?? "" - )} - - - Phone:{" "} - {highlightRegex ? ( - {item.result.phone_no ?? ""} - ) : ( - item.result.phone_no ?? "" - )} - + •}> + {item.result.email && {item.result.email}} + {item.result.phone_no && {item.result.phone_no}} {item.result.mine_party_appt && item.result.mine_party_appt.length > 0 && ( - Role: {item.result.mine_party_appt[0].mine_party_appt_type_code_description} + {item.result.mine_party_appt[0].mine_party_appt_type_code_description} )} - + ); const renderPermitResult = (item: any) => ( - } - link={router.VIEW_MINE_PERMIT.dynamicRoute(item.result.mine[0].mine_guid, item.result.permit_guid)} + iconClass="card-icon--permit" + title={`Permit: ${item.result.permit_no}`} + link={router.VIEW_MINE_PERMIT.dynamicRoute( + item.result.mine[0].mine_guid, + item.result.permit_guid + )} highlightRegex={highlightRegex} actions={[ - - - + + + , ]} > - - - - Mine: {item.result.mine[0].mine_name} - - - - - + •}> + {item.result.mine[0].mine_name} + - + ); const renderDocumentResult = (item: any) => ( - - - -
+ + + +
+ +
- - - <DocumentLink - documentManagerGuid={item.result.document_manager_guid} - documentName={item.result.document_name} - truncateDocumentName={false} - linkTitleOverride={highlightRegex ? <Highlight search={highlightRegex}>{item.result.document_name ?? ""}</Highlight> : undefined} - /> - - - Mine: {item.result.mine_name} - Uploaded: {formatDate(item.result.upload_date)} - + + {item.result.document_name ?? ""} + ) : undefined + } + /> +
+ •}> + {item.result.mine_name} + Uploaded {formatDate(item.result.upload_date)} + +
@@ -373,53 +394,84 @@ export const SearchResults: React.FC = (props) => { ...(props.searchResults.party || []), ...(props.searchResults.permit || []), ...(props.searchResults.mine_documents || []), - ...(props.searchResults.permit_documents || []) + ...(props.searchResults.permit_documents || []), ].sort((a, b) => b.score - a.score); if (allResults.length === 0 && !isSearching) { - return ; + return ( + + No results found for "{params.q}" + + } + style={{ padding: 48 }} + > + + + ); } - const facets = getFacets(allResults); - - const filteredResults = allResults.filter(item => { + const filteredResults = allResults.filter((item) => { const result = item.result as any; - if (item.type === 'mine') { + if (item.type === "mine") { + // Mine Region filter if (selectedFilters.mine_region && selectedFilters.mine_region.length > 0) { if (!selectedFilters.mine_region.includes(result.mine_region)) return false; } - if (selectedFilters.mine_status && selectedFilters.mine_status.length > 0) { - const status = result.mine_status && result.mine_status.length > 0 ? result.mine_status[0].status_labels : null; - const statusStr = Array.isArray(status) ? status.join(", ") : status; - if (!selectedFilters.mine_status.includes(statusStr)) return false; - } - if (selectedFilters.mine_tenure && selectedFilters.mine_tenure.length > 0) { - const tenures = result.mine_type ? result.mine_type.map((mt: any) => mt.mine_tenure_type_code) : []; - if (!selectedFilters.mine_tenure.some(t => tenures.includes(t))) return false; - } - if (selectedFilters.mine_commodity && selectedFilters.mine_commodity.length > 0) { - const commodities = result.mine_type ? result.mine_type.flatMap((mt: any) => mt.mine_type_detail ? mt.mine_type_detail.map((mtd: any) => mtd.mine_commodity_code) : []) : []; - if (!selectedFilters.mine_commodity.some(c => commodities.includes(c))) return false; - } + // Mine Classification filter (Major/Regional) if (selectedFilters.mine_classification && selectedFilters.mine_classification.length > 0) { const classification = result.major_mine_ind ? "Major Mine" : "Regional Mine"; if (!selectedFilters.mine_classification.includes(classification)) return false; } - if (selectedFilters.mine_tsf && selectedFilters.mine_tsf.length > 0) { - const tsf = result.mine_tailings_storage_facilities && result.mine_tailings_storage_facilities.length > 0 ? "Has TSF" : "No TSF"; - if (!selectedFilters.mine_tsf.includes(tsf)) return false; + // Mine Operation Status filter + if (selectedFilters.mine_operation_status && selectedFilters.mine_operation_status.length > 0) { + const statusCode = + result.mine_status && result.mine_status.length > 0 + ? result.mine_status[0].status_values?.[0] + : null; + if (!statusCode || !selectedFilters.mine_operation_status.includes(statusCode)) return false; + } + // Mine Tenure filter + if (selectedFilters.mine_tenure && selectedFilters.mine_tenure.length > 0) { + const tenures = result.mine_type + ? result.mine_type.map((mt: any) => mt.mine_tenure_type_code) + : []; + if (!selectedFilters.mine_tenure.some((t) => tenures.includes(t))) return false; + } + // Mine Commodity filter + if (selectedFilters.mine_commodity && selectedFilters.mine_commodity.length > 0) { + const commodities = result.mine_type + ? result.mine_type.flatMap((mt: any) => + mt.mine_type_detail + ? mt.mine_type_detail.map((mtd: any) => mtd.mine_commodity_code) + : [] + ) + : []; + if (!selectedFilters.mine_commodity.some((c) => commodities.includes(c))) return false; } - if (selectedFilters.mine_work_status && selectedFilters.mine_work_status.length > 0) { - const ws = result.mine_work_information ? result.mine_work_information.work_status : null; - if (!ws || !selectedFilters.mine_work_status.includes(ws)) return false; + // TSF filter + if (selectedFilters.has_tsf && selectedFilters.has_tsf.length > 0) { + const tsf = + result.mine_tailings_storage_facilities && + result.mine_tailings_storage_facilities.length > 0 + ? "Has TSF" + : "No TSF"; + if (!selectedFilters.has_tsf.includes(tsf)) return false; } - if (selectedFilters.mine_verified_status && selectedFilters.mine_verified_status.length > 0) { - const verified = result.verified_status ? (result.verified_status.healthy_ind ? "Verified" : "Unverified") : null; - if (!verified || !selectedFilters.mine_verified_status.includes(verified)) return false; + // Verified Status filter + if (selectedFilters.verified_status && selectedFilters.verified_status.length > 0) { + const verified = result.verified_status + ? result.verified_status.healthy_ind + ? "Verified" + : "Unverified" + : null; + if (!verified || !selectedFilters.verified_status.includes(verified)) return false; } } - - if (item.type === 'permit') { + if (item.type === "permit") { if (selectedFilters.permit_status && selectedFilters.permit_status.length > 0) { if (!selectedFilters.permit_status.includes(result.permit_status_code)) return false; } @@ -428,220 +480,237 @@ export const SearchResults: React.FC = (props) => { }); const activeTab = params.t || "all"; + const hasActiveFilters = Object.keys(selectedFilters).length > 0; + + // Convert API facets (array format) to Record format for consistency + const apiFacetToRecord = (apiFacet: FacetBucket[] | undefined): Record => { + if (!apiFacet) return {}; + return apiFacet.reduce((acc, bucket) => { + acc[bucket.key] = bucket.count; + return acc; + }, {} as Record); + }; + + // Use ES-powered API facets for all filters + const facetConfig = [ + { key: "mine_region", label: "Mine Region", data: apiFacetToRecord(props.searchFacets?.mine_region) }, + { key: "mine_classification", label: "Classification", data: apiFacetToRecord(props.searchFacets?.mine_classification) }, + { key: "mine_operation_status", label: "Operation Status", data: apiFacetToRecord(props.searchFacets?.mine_operation_status) }, + { key: "mine_tenure", label: "Mine Tenure", data: apiFacetToRecord(props.searchFacets?.mine_tenure) }, + { key: "mine_commodity", label: "Commodity", data: apiFacetToRecord(props.searchFacets?.mine_commodity) }, + { key: "has_tsf", label: "TSF", data: apiFacetToRecord(props.searchFacets?.has_tsf) }, + { key: "verified_status", label: "Verified", data: apiFacetToRecord(props.searchFacets?.verified_status) }, + { key: "permit_status", label: "Permit Status", data: apiFacetToRecord(props.searchFacets?.permit_status) }, + ].filter((f) => Object.keys(f.data).length > 0); + + const renderActiveFilters = () => { + if (!hasActiveFilters) return null; + const allFilters: { category: string; value: string; label: string }[] = []; + Object.entries(selectedFilters).forEach(([category, values]) => { + const config = facetConfig.find((f) => f.key === category); + values.forEach((value) => { + allFilters.push({ + category, + value, + label: `${config?.label || category}: ${value}`, + }); + }); + }); + + return ( +
+ + {allFilters.map((filter) => ( + removeFilter(filter.category, filter.value)} + style={{ + background: "rgba(94, 70, 161, 0.1)", + borderColor: "transparent", + color: "#5e46a1", + borderRadius: 16, + padding: "4px 12px", + }} + > + {filter.label} + + ))} + + +
+ ); + }; const renderFacets = () => ( -
- <FilterOutlined /> Filters - - {Object.keys(facets.mine_region).length > 0 && ( - - {Object.entries(facets.mine_region).map(([region, count]) => ( -
- handleFilterChange('mine_region', region, e.target.checked)} - > - {region} ({count}) - -
- ))} -
- )} - {Object.keys(facets.mine_status).length > 0 && ( - - {Object.entries(facets.mine_status).map(([status, count]) => ( -
- handleFilterChange('mine_status', status, e.target.checked)} - > - {status} ({count}) - -
- ))} -
- )} - {Object.keys(facets.mine_tenure).length > 0 && ( - - {Object.entries(facets.mine_tenure).map(([tenure, count]) => ( -
- handleFilterChange('mine_tenure', tenure, e.target.checked)} - > - {tenure} ({count}) - -
- ))} -
- )} - {Object.keys(facets.mine_commodity).length > 0 && ( - - {Object.entries(facets.mine_commodity).map(([commodity, count]) => ( -
- handleFilterChange('mine_commodity', commodity, e.target.checked)} - > - {commodity} ({count}) - -
- ))} -
- )} - {Object.keys(facets.mine_classification).length > 0 && ( - - {Object.entries(facets.mine_classification).map(([classification, count]) => ( -
- handleFilterChange('mine_classification', classification, e.target.checked)} - > - {classification} ({count}) - -
- ))} -
- )} - {Object.keys(facets.mine_tsf).length > 0 && ( - - {Object.entries(facets.mine_tsf).map(([tsf, count]) => ( -
- handleFilterChange('mine_tsf', tsf, e.target.checked)} - > - {tsf} ({count}) - -
- ))} -
- )} - {Object.keys(facets.mine_work_status).length > 0 && ( - - {Object.entries(facets.mine_work_status).map(([status, count]) => ( -
- handleFilterChange('mine_work_status', status, e.target.checked)} - > - {status} ({count}) - -
- ))} -
- )} - {Object.keys(facets.mine_verified_status).length > 0 && ( - - {Object.entries(facets.mine_verified_status).map(([status, count]) => ( -
- handleFilterChange('mine_verified_status', status, e.target.checked)} - > - {status} ({count}) - -
- ))} -
+
+
+ + <FilterOutlined style={{ marginRight: 8 }} /> + Filters + + {hasActiveFilters && ( + )} - {Object.keys(facets.permit_status).length > 0 && ( - - {Object.entries(facets.permit_status).map(([status, count]) => ( -
- handleFilterChange('permit_status', status, e.target.checked)} - > - {status} ({count}) - -
- ))} +
+ f.key)} + ghost + expandIconPosition="end" + > + {facetConfig.map((facet) => ( + + {Object.entries(facet.data) + .sort(([, a], [, b]) => (b as number) - (a as number)) + .map(([value, count]) => ( +
+ handleFilterChange(facet.key, value, e.target.checked)} + > + + {value}{" "} + + ({count}) + + + +
+ ))}
- )} + ))}
); - const getFilteredByType = (type: string) => filteredResults.filter(item => item.type === type); - const getFilteredDocuments = () => filteredResults.filter(item => item.type === 'mine_documents' || item.type === 'permit_documents'); + const getFilteredByType = (type: string) => filteredResults.filter((item) => item.type === type); + const getFilteredDocuments = () => + filteredResults.filter( + (item) => item.type === "mine_documents" || item.type === "permit_documents" + ); + + const tabItems = [ + { + key: "all", + label: `All (${filteredResults.length})`, + children: ( +
+ {renderActiveFilters()} +
+ + Showing {filteredResults.length} results for " + {params.q}" + +
+ {filteredResults.map((item: any, index: number) => ( +
+ {item.type === "mine" && renderMineResult(item)} + {item.type === "party" && renderPartyResult(item)} + {item.type === "permit" && renderPermitResult(item)} + {(item.type === "mine_documents" || item.type === "permit_documents") && + renderDocumentResult(item)} +
+ ))} +
+ ), + }, + { + key: "mine", + label: `Mines (${getFilteredByType("mine").length})`, + children: ( +
+ {renderActiveFilters()} + {getFilteredByType("mine").map((item, index) => ( +
{renderMineResult(item)}
+ ))} +
+ ), + }, + { + key: "party", + label: `Contacts (${getFilteredByType("party").length})`, + children: ( +
+ {renderActiveFilters()} + {getFilteredByType("party").map((item, index) => ( +
{renderPartyResult(item)}
+ ))} +
+ ), + }, + { + key: "permit", + label: `Permits (${getFilteredByType("permit").length})`, + children: ( +
+ {renderActiveFilters()} + {getFilteredByType("permit").map((item, index) => ( +
{renderPermitResult(item)}
+ ))} +
+ ), + }, + { + key: "document", + label: `Documents (${getFilteredDocuments().length})`, + children: ( +
+ {renderActiveFilters()} + {getFilteredDocuments().map((item, index) => ( +
{renderDocumentResult(item)}
+ ))} +
+ ), + }, + ]; return ( - -
+ + {renderFacets()} - - - - { - if (item.type === "mine") return renderMineResult(item); - if (item.type === "party") return renderPartyResult(item); - if (item.type === "permit") return renderPermitResult(item); - if (item.type === "mine_documents" || item.type === "permit_documents") return renderDocumentResult(item); - return null; - }} - /> - - - - - - - - - - - - - - + + ); }; return ( -
-
+
+
-
- Search & Exploration - + + + Search Results + +
+ } + size="large" + value={searchInputValue} + onChange={(e) => setSearchInputValue(e.target.value)} + onSearch={onSearch} + /> +
-
+
-
+ {renderContent()} @@ -650,13 +719,14 @@ export const SearchResults: React.FC = (props) => { ); }; -const mapStateToProps = (state) => ({ +const mapStateToProps = (state: any) => ({ searchOptions: getSearchOptions(state), searchResults: getSearchResults(state), + searchFacets: getSearchFacets(state), searchTerms: getSearchTerms(state), }); -const mapDispatchToProps = (dispatch) => +const mapDispatchToProps = (dispatch: any) => bindActionCreators( { fetchSearchOptions, diff --git a/services/core-web/src/styles/components/GlobalSearch.scss b/services/core-web/src/styles/components/GlobalSearch.scss new file mode 100644 index 0000000000..f895e9ac79 --- /dev/null +++ b/services/core-web/src/styles/components/GlobalSearch.scss @@ -0,0 +1,1066 @@ +@use "../base.scss" as *; + +// Global Search Command Palette Styles +.global-search-trigger { + display: flex !important; + align-items: center; + justify-content: space-between; + gap: 8px; + background: rgba(255, 255, 255, 0.1) !important; + border: 1px solid rgba(255, 255, 255, 0.2) !important; + color: rgba(255, 255, 255, 0.8) !important; + border-radius: 8px !important; + padding: 6px 12px !important; + height: 36px !important; + min-width: 200px; + transition: all 0.2s ease !important; + cursor: pointer; + + &:hover, + &:focus { + background: rgba(255, 255, 255, 0.15) !important; + border-color: rgba(255, 255, 255, 0.3) !important; + color: rgba(255, 255, 255, 0.95) !important; + } + + > .anticon { + font-size: 14px; + } + + .search-placeholder { + flex: 1; + text-align: left; + font-size: 13px; + opacity: 0.8; + margin-left: 4px; + } + + .search-shortcut { + display: flex; + align-items: center; + gap: 2px; + margin-left: auto; + + .ant-typography-keyboard { + background: rgba(255, 255, 255, 0.15) !important; + border: none !important; + border-radius: 4px; + padding: 1px 5px; + font-size: 11px; + color: rgba(255, 255, 255, 0.7); + margin: 0; + } + } + + @include tablet { + min-width: 44px; + padding: 6px 10px !important; + + .search-placeholder, + .search-shortcut { + display: none; + } + } +} + +// Modal Overlay +.global-search-modal { + .ant-modal-content { + border-radius: 12px; + overflow: hidden; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25), + 0 0 0 1px rgba(0, 0, 0, 0.05); + padding: 0; + } + + .ant-modal-body { + padding: 0; + } + + .ant-modal-footer { + padding: 10px 20px; + background: #fafafa; + border-top: 1px solid #f0f0f0; + margin: 0; + } + + // Input styling + .ant-input-affix-wrapper { + padding: 12px 16px; + + .ant-input { + font-size: 16px; + } + } + + // Divider as section header + .ant-divider { + margin: 0; + padding: 10px 20px 6px; + background: #fafafa; + border-bottom: 1px solid #f0f0f0; + + .ant-divider-inner-text { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #8c8c8c; + } + } + + // List styling + .ant-list-item { + padding: 10px 20px !important; + border-left: 3px solid transparent; + cursor: pointer; + transition: all 0.15s ease; + margin: 0; + border-bottom: none !important; + + &:hover { + background: linear-gradient(90deg, rgba($violet, 0.06) 0%, rgba($violet, 0.01) 100%); + } + + .ant-list-item-meta { + align-items: center; + + .ant-list-item-meta-avatar { + margin-right: 12px; + + .ant-avatar { + width: 36px; + height: 36px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + } + } + + .ant-list-item-meta-content { + .ant-list-item-meta-title { + margin-bottom: 2px; + font-size: 14px; + line-height: 1.4; + + mark { + background: rgba($violet, 0.2); + color: inherit; + padding: 0 2px; + border-radius: 2px; + } + } + + .ant-list-item-meta-description { + font-size: 12px; + } + } + } + } + + // Selected state + .global-search__result-item--selected { + background: linear-gradient(90deg, rgba($violet, 0.1) 0%, rgba($violet, 0.03) 100%) !important; + border-left-color: $violet; + } + + // Quick actions + .ant-btn-text { + &:hover { + background: #fafafa; + } + + .ant-avatar { + margin-bottom: 4px; + } + } + + // Recent searches icon alignment + .global-search__recent { + .ant-list-item-meta-avatar { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + font-size: 16px; + } + } + + // Empty state + .global-search__empty { + .ant-typography { + margin: 0; + } + } + + // Filter tags + .ant-tag { + display: inline-flex; + align-items: center; + padding: 4px 10px; + border-radius: 16px; + font-size: 12px; + transition: all 0.15s ease; + + &:hover { + opacity: 0.85; + } + + .anticon { + font-size: 12px; + } + } + +} + +// Search Input Container +.global-search__input-container { + padding: 16px 20px; + border-bottom: 1px solid #f0f0f0; + display: flex; + align-items: center; + gap: 12px; + + .search-icon { + font-size: 20px; + color: $violet; + flex-shrink: 0; + } + + .ant-input { + border: none; + font-size: 16px; + padding: 0; + background: transparent; + + &:focus { + box-shadow: none; + } + + &::placeholder { + color: #bfbfbf; + } + } + + .search-spinner { + color: $violet; + animation: spin 1s linear infinite; + } +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +// Results Container +.global-search__results { + max-height: 400px; + overflow-y: auto; + overflow-x: hidden; + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: #d9d9d9; + border-radius: 3px; + + &:hover { + background: #bfbfbf; + } + } +} + +// Section Headers +.global-search__section { + &-header { + padding: 12px 20px 8px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #8c8c8c; + background: #fafafa; + border-bottom: 1px solid #f0f0f0; + } +} + +// Result Items +.global-search__result-item { + display: flex; + align-items: center; + padding: 12px 20px; + cursor: pointer; + transition: all 0.15s ease; + border-left: 3px solid transparent; + + &:hover, + &--selected { + background: linear-gradient(90deg, rgba($violet, 0.08) 0%, rgba($violet, 0.02) 100%); + border-left-color: $violet; + } + + &--selected { + background: linear-gradient(90deg, rgba($violet, 0.12) 0%, rgba($violet, 0.04) 100%); + } + + // Search result highlights + mark { + background-color: rgba($violet, 0.2); + color: inherit; + padding: 1px 3px; + border-radius: 3px; + font-weight: 500; + } + + .result-icon { + width: 36px; + height: 36px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + margin-right: 14px; + flex-shrink: 0; + font-size: 16px; + + &--mine { + background: linear-gradient(135deg, #e8f5e9 0%, #c8e6c9 100%); + color: #2e7d32; + } + + &--person { + background: linear-gradient(135deg, #e3f2fd 0%, #bbdefb 100%); + color: #1565c0; + } + + &--organization { + background: linear-gradient(135deg, #fff8e1 0%, #ffecb3 100%); + color: #f57c00; + } + + &--party { + background: linear-gradient(135deg, #e3f2fd 0%, #bbdefb 100%); + color: #1565c0; + } + + &--permit { + background: linear-gradient(135deg, #fff3e0 0%, #ffe0b2 100%); + color: #e65100; + } + + &--document { + background: linear-gradient(135deg, #f3e5f5 0%, #e1bee7 100%); + color: #7b1fa2; + } + + img { + width: 18px; + height: 18px; + object-fit: contain; + } + } + + .result-content { + flex: 1; + min-width: 0; + + .result-title { + font-size: 14px; + font-weight: 500; + color: $darkest-grey; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 2px; + + mark { + background: rgba($violet, 0.2); + color: inherit; + padding: 0 2px; + border-radius: 2px; + } + } + + .result-subtitle { + font-size: 12px; + color: #8c8c8c; + display: flex; + align-items: center; + gap: 8px; + } + } + + .result-type-badge { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.3px; + padding: 3px 8px; + border-radius: 4px; + background: #f5f5f5; + color: #8c8c8c; + margin-left: 12px; + flex-shrink: 0; + } + + .result-action { + opacity: 0; + color: $violet; + margin-left: 8px; + transition: opacity 0.15s ease; + } + + &:hover .result-action, + &--selected .result-action { + opacity: 1; + } +} + +// Empty & Loading States +.global-search__empty { + padding: 48px 24px; + text-align: center; + color: #8c8c8c; + + .empty-icon { + font-size: 48px; + color: #d9d9d9; + margin-bottom: 16px; + } + + .empty-title { + font-size: 15px; + font-weight: 500; + color: $darkest-grey; + margin-bottom: 4px; + } + + .empty-description { + font-size: 13px; + margin-bottom: 16px; + } +} + +.global-search__placeholder { + padding: 24px 20px; + color: #8c8c8c; + + .placeholder-title { + font-size: 13px; + font-weight: 500; + color: $darkest-grey; + margin-bottom: 16px; + display: flex; + align-items: center; + gap: 8px; + + .anticon { + color: $violet; + } + } +} + +// Recent Searches +.global-search__recent { + .recent-item { + display: flex; + align-items: center; + padding: 10px 20px; + cursor: pointer; + transition: background 0.15s ease; + gap: 12px; + + &:hover { + background: #fafafa; + } + + .recent-icon { + color: #bfbfbf; + font-size: 14px; + } + + .recent-text { + flex: 1; + font-size: 14px; + color: $darkest-grey; + } + + .recent-remove { + opacity: 0; + color: #bfbfbf; + padding: 4px; + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + color: $alert-red; + } + } + + &:hover .recent-remove { + opacity: 1; + } + } +} + +// Quick Actions +.global-search__quick-actions { + padding: 16px 20px; + border-bottom: 1px solid #f0f0f0; + + .quick-action-title { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #8c8c8c; + margin-bottom: 12px; + } + + .quick-actions-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; + } + + .quick-action-item { + display: flex; + flex-direction: column; + align-items: center; + padding: 12px 8px; + border-radius: 8px; + cursor: pointer; + transition: all 0.15s ease; + border: 1px solid transparent; + + &:hover { + background: #fafafa; + border-color: #f0f0f0; + } + + .quick-action-icon { + width: 32px; + height: 32px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 6px; + font-size: 14px; + } + + .quick-action-label { + font-size: 12px; + color: $darkest-grey; + } + } +} + +// Footer with Keyboard Hints +.global-search__footer { + padding: 10px 20px; + background: #fafafa; + border-top: 1px solid #f0f0f0; + display: flex; + justify-content: space-between; + align-items: center; + font-size: 12px; + color: #8c8c8c; + + .footer-hints { + display: flex; + gap: 16px; + } + + .footer-hint { + display: flex; + align-items: center; + gap: 6px; + + kbd { + background: #fff; + border: 1px solid #e8e8e8; + border-radius: 4px; + padding: 2px 6px; + font-size: 11px; + font-family: inherit; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); + } + } + + .footer-powered { + font-size: 11px; + color: #bfbfbf; + } +} + +// Loading Skeleton +.global-search__skeleton { + padding: 12px 20px; + + .skeleton-item { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 16px; + + &:last-child { + margin-bottom: 0; + } + } + + .skeleton-icon { + width: 36px; + height: 36px; + border-radius: 8px; + background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%); + background-size: 200% 100%; + animation: shimmer 1.5s infinite; + } + + .skeleton-content { + flex: 1; + + .skeleton-title { + height: 14px; + width: 60%; + border-radius: 4px; + background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%); + background-size: 200% 100%; + animation: shimmer 1.5s infinite; + margin-bottom: 8px; + } + + .skeleton-subtitle { + height: 10px; + width: 40%; + border-radius: 4px; + background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%); + background-size: 200% 100%; + animation: shimmer 1.5s infinite; + } + } +} + +@keyframes shimmer { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} + +// Skeleton pulse animation for search results +.skeleton-pulse { + background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%); + background-size: 200% 100%; + animation: shimmer 1.5s infinite; +} + +.search-skeleton { + animation: fadeIn 0.2s ease; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +// Search Results Page Improvements +.search-results-page { + min-height: calc(100vh - #{$nav-height}); + background: #f8f9fa; + + &__header { + background: linear-gradient(135deg, $darkest-grey 0%, lighten($darkest-grey, 10%) 100%); + padding: 40px 0; + + .search-title { + color: #fff; + font-size: 28px; + font-weight: 300; + text-align: center; + margin-bottom: 24px; + } + + .search-input-wrapper { + max-width: 700px; + margin: 0 auto; + + .ant-input-search { + display: flex; + + .ant-input-wrapper { + display: flex; + width: 100%; + + .ant-input-affix-wrapper { + flex: 1; + height: 52px; + border-radius: 12px 0 0 12px; + border: none; + padding: 0 16px; + background: #fff; + + .ant-input { + height: 100%; + font-size: 16px; + + &::placeholder { + color: #bfbfbf; + } + } + + &:focus, + &-focused { + box-shadow: 0 0 0 3px rgba($violet, 0.2); + } + } + + .ant-input-group-addon { + background: transparent; + border: none; + padding: 0; + + .ant-input-search-button { + height: 52px; + width: 56px; + border-radius: 0 12px 12px 0; + background: $violet; + border: none; + display: flex; + align-items: center; + justify-content: center; + + .anticon { + font-size: 18px; + } + + &:hover { + background: $hover-violet; + } + } + } + } + } + + // Clear button styling + .ant-input-clear-icon { + font-size: 14px; + color: #bfbfbf; + + &:hover { + color: #8c8c8c; + } + } + } + } + + &__content { + padding: 32px 24px 48px; + } + + &__filters { + background: #fff; + border-radius: 12px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); + padding: 20px; + position: sticky; + top: calc(#{$nav-height} + 24px); + max-height: calc(100vh - #{$nav-height} - 48px); + overflow-y: auto; + + // Custom scrollbar for filters + &::-webkit-scrollbar { + width: 4px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: #d9d9d9; + border-radius: 2px; + } + + .ant-collapse { + background: transparent; + border: none; + + .ant-collapse-item { + border-bottom: 1px solid #f0f0f0; + + &:last-child { + border-bottom: none; + } + + .ant-collapse-header { + padding: 12px 0; + font-weight: 500; + font-size: 13px; + color: $darkest-grey; + } + + .ant-collapse-content-box { + padding: 0 0 12px; + } + } + } + + .filter-section { + margin-bottom: 20px; + + &:last-child { + margin-bottom: 0; + } + + .filter-title { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #8c8c8c; + margin-bottom: 12px; + display: flex; + align-items: center; + gap: 8px; + } + + .ant-checkbox-wrapper { + display: flex; + margin-bottom: 8px; + font-size: 13px; + + &:last-child { + margin-bottom: 0; + } + } + } + + .active-filters { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 16px; + + .filter-tag { + background: rgba($violet, 0.1); + color: $violet; + border: none; + border-radius: 16px; + padding: 4px 12px; + font-size: 12px; + + .anticon-close { + margin-left: 6px; + font-size: 10px; + } + } + + .clear-all { + font-size: 12px; + color: #8c8c8c; + cursor: pointer; + + &:hover { + color: $violet; + } + } + } + } + + &__results { + .results-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + + .results-count { + font-size: 14px; + color: #8c8c8c; + + strong { + color: $darkest-grey; + } + } + + .results-sort { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: #8c8c8c; + } + } + + .ant-tabs { + .ant-tabs-nav { + margin-bottom: 20px; + + &::before { + border-bottom: none; + } + } + + .ant-tabs-tab { + background: #fff; + border-radius: 8px; + padding: 8px 16px; + margin-right: 8px; + border: 1px solid #e8e8e8; + transition: all 0.2s ease; + + &:hover { + border-color: $violet; + } + + &-active { + background: $violet; + border-color: $violet; + + .ant-tabs-tab-btn { + color: #fff; + } + } + } + + .ant-tabs-ink-bar { + display: none; + } + } + } + + // Responsive adjustments + @include tablet { + &__header { + padding: 32px 0; + + .search-title { + font-size: 24px; + margin-bottom: 20px; + } + + .search-input-wrapper { + padding: 0 16px; + + .ant-input-search { + .ant-input-wrapper { + .ant-input-affix-wrapper { + height: 48px; + } + + .ant-input-group-addon .ant-input-search-button { + height: 48px; + width: 48px; + } + } + } + } + } + + &__content { + padding: 24px 16px 32px; + } + + &__filters { + position: relative; + top: 0; + max-height: none; + margin-bottom: 24px; + } + } +} + +// Result Card Improvements +.search-result-card { + background: #fff; + border-radius: 12px; + border: 1px solid #e8e8e8; + margin-bottom: 12px; + transition: all 0.2s ease; + overflow: hidden; + + &:hover { + border-color: $violet; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + transform: translateY(-2px); + } + + .ant-card-body { + padding: 20px; + } + + .card-icon { + width: 48px; + height: 48px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + + &--mine { + background: linear-gradient(135deg, #e8f5e9 0%, #c8e6c9 100%); + color: #2e7d32; + } + + &--party { + background: linear-gradient(135deg, #e3f2fd 0%, #bbdefb 100%); + color: #1565c0; + } + + &--permit { + background: linear-gradient(135deg, #fff3e0 0%, #ffe0b2 100%); + color: #e65100; + } + + &--document { + background: linear-gradient(135deg, #f3e5f5 0%, #e1bee7 100%); + color: #7b1fa2; + } + } + + .card-title { + font-size: 16px; + font-weight: 600; + color: $darkest-grey; + margin-bottom: 4px; + + a { + color: inherit; + transition: color 0.15s ease; + + &:hover { + color: $violet; + } + } + } + + .card-meta { + display: flex; + align-items: center; + gap: 16px; + font-size: 13px; + color: #8c8c8c; + } + + .card-actions { + display: flex; + gap: 8px; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid #f0f0f0; + } +} diff --git a/services/core-web/src/styles/index.scss b/services/core-web/src/styles/index.scss index cdebff2a0d..30c6dcc651 100755 --- a/services/core-web/src/styles/index.scss +++ b/services/core-web/src/styles/index.scss @@ -23,6 +23,7 @@ @forward "./components/HomePage.scss"; @forward "./components/NoticeOfWork.scss"; @forward "./components/SearchBar.scss"; +@forward "./components/GlobalSearch.scss"; @forward "./components/CoreTooltip.scss"; @forward "./components/MergeContacts.scss"; @forward "./components/NoticeOfDeparture.scss"; diff --git a/services/permits/app/celery/elasticsearch_backend.py b/services/permits/app/celery/elasticsearch_backend.py index 46df305c55..06faba788f 100644 --- a/services/permits/app/celery/elasticsearch_backend.py +++ b/services/permits/app/celery/elasticsearch_backend.py @@ -1,7 +1,10 @@ import os +import logging import elasticsearch from celery.backends.elasticsearch import ElasticsearchBackend +logger = logging.getLogger(__name__) + ca_cert = os.environ.get("ELASTICSEARCH_CA_CERT", None) host = os.environ.get("ELASTICSEARCH_HOST", None) or "https://elasticsearch:9200" username = os.environ.get("ELASTICSEARCH_USERNAME", "") @@ -31,7 +34,14 @@ def _get_server(self): retry_on_timeout=self.es_retry_on_timeout, max_retries=self.es_max_retries, timeout=self.es_timeout, - http_auth=http_auth, + basic_auth=http_auth, verify_certs=True if ca_cert else False, ca_certs=ca_cert if ca_cert else None, ) + + def get(self, key): + try: + return super().get(key) + except elasticsearch.ApiError as e: + logger.error(f"Elasticsearch error during get: {e}") + raise RuntimeError(f"Elasticsearch error: {e}") from e diff --git a/services/pgsync/Dockerfile b/services/pgsync/Dockerfile new file mode 100644 index 0000000000..f3edb516a0 --- /dev/null +++ b/services/pgsync/Dockerfile @@ -0,0 +1,17 @@ +FROM toluaina1/pgsync + +USER root +WORKDIR /app + +COPY start.sh /app/start.sh +RUN chmod +x /app/start.sh +RUN chmod 777 /app + +# Switch back to default user if needed, but for now root is fine or we can check who the default user was. +# Usually it's better to run as non-root, but for fixing permissions root is needed. +# Let's assume we can run as root or switch back to 'pgsync' user if we knew it. +# For now, let's leave it as root to avoid permission issues, or check the base image user. +# But wait, if I run as root, I might create files owned by root that pgsync (if it drops privs) can't read. +# However, pgsync is a python script. + +ENTRYPOINT ["/app/start.sh"] diff --git a/services/pgsync/schema.json b/services/pgsync/schema.json new file mode 100644 index 0000000000..4796c01c2b --- /dev/null +++ b/services/pgsync/schema.json @@ -0,0 +1,318 @@ +[ + { + "database": "mds", + "index": "mines", + "nodes": { + "table": "mine", + "columns": [ + "mine_guid", + "mine_name", + "mine_no", + "mms_alias", + "mine_region", + "major_mine_ind", + "deleted_ind" + ], + "children": [ + { + "table": "mine_status", + "columns": ["mine_status_guid", "effective_date"], + "label": "mine_status", + "relationship": { + "type": "one_to_many", + "variant": "object", + "foreign_key": { + "child": ["mine_guid"], + "parent": ["mine_guid"] + } + }, + "children": [ + { + "table": "mine_status_xref", + "columns": ["mine_operation_status_code", "mine_operation_status_reason_code", "description"], + "label": "status_xref", + "relationship": { + "type": "one_to_one", + "variant": "object", + "foreign_key": { + "child": ["mine_status_xref_guid"], + "parent": ["mine_status_xref_guid"] + } + } + } + ] + }, + { + "table": "mine_type", + "columns": ["mine_type_guid", "mine_tenure_type_code", "active_ind"], + "label": "mine_types", + "relationship": { + "type": "one_to_many", + "variant": "object", + "foreign_key": { + "child": ["mine_guid"], + "parent": ["mine_guid"] + } + }, + "children": [ + { + "table": "mine_type_detail_xref", + "columns": ["mine_commodity_code", "mine_disturbance_code", "active_ind"], + "label": "mine_type_details", + "relationship": { + "type": "one_to_many", + "variant": "object", + "foreign_key": { + "child": ["mine_type_guid"], + "parent": ["mine_type_guid"] + } + } + } + ] + }, + { + "table": "mine_verified_status", + "columns": ["healthy_ind", "verifying_timestamp"], + "label": "verified_status", + "relationship": { + "type": "one_to_one", + "variant": "object", + "foreign_key": { + "child": ["mine_guid"], + "parent": ["mine_guid"] + } + } + }, + { + "table": "mine_work_information", + "columns": ["work_start_date", "work_stop_date", "deleted_ind"], + "label": "work_information", + "relationship": { + "type": "one_to_many", + "variant": "object", + "foreign_key": { + "child": ["mine_guid"], + "parent": ["mine_guid"] + } + } + }, + { + "table": "mine_tailings_storage_facility", + "columns": ["mine_tailings_storage_facility_guid", "mine_tailings_storage_facility_name"], + "label": "tailings_storage_facilities", + "relationship": { + "type": "one_to_many", + "variant": "object", + "foreign_key": { + "child": ["mine_guid"], + "parent": ["mine_guid"] + } + } + } + ] + } + }, + { + "database": "mds", + "index": "parties", + "nodes": { + "table": "party", + "columns": [ + "party_guid", + "first_name", + "party_name", + "party_type_code", + "email", + "phone_no", + "deleted_ind" + ] + } + }, + { + "database": "mds", + "index": "permits", + "nodes": { + "table": "permit", + "columns": [ + "permit_guid", + "permit_no", + "permit_status_code", + "is_exploration", + "deleted_ind" + ], + "children": [ + { + "table": "mine_permit_xref", + "columns": ["mine_guid"], + "label": "mine_guids", + "relationship": { + "type": "one_to_many", + "variant": "scalar", + "foreign_key": { + "child": ["permit_id"], + "parent": ["permit_id"] + } + } + } + ] + } + }, + { + "database": "mds", + "index": "explosives_permits", + "nodes": { + "table": "explosives_permit", + "columns": [ + "explosives_permit_guid", + "explosives_permit_id", + "permit_number", + "application_number", + "application_status", + "description", + "is_closed", + "issue_date", + "expiry_date", + "mine_guid", + "deleted_ind" + ], + "children": [ + { + "table": "mine", + "columns": ["mine_guid", "mine_name", "mine_no"], + "label": "mine", + "relationship": { + "type": "one_to_one", + "variant": "object", + "foreign_key": { + "child": ["mine_guid"], + "parent": ["mine_guid"] + } + } + } + ] + } + }, + { + "database": "mds", + "index": "documents", + "nodes": { + "table": "mine_document", + "columns": [ + "mine_document_guid", + "document_name", + "mine_guid", + "upload_date", + "deleted_ind" + ], + "children": [ + { + "table": "mine", + "columns": ["mine_name"], + "relationship": { + "type": "one_to_one", + "variant": "object", + "foreign_key": { + "child": ["mine_guid"], + "parent": ["mine_guid"] + } + } + } + ] + } + }, + { + "database": "mds", + "index": "notices_of_departure", + "nodes": { + "table": "notice_of_departure", + "columns": [ + "nod_guid", + "nod_no", + "nod_title", + "nod_description", + "nod_type", + "nod_status", + "mine_guid", + "permit_guid", + "submission_timestamp", + "deleted_ind" + ], + "children": [ + { + "table": "mine", + "columns": ["mine_name", "mine_no"], + "label": "mine", + "relationship": { + "type": "one_to_one", + "variant": "object", + "foreign_key": { + "child": ["mine_guid"], + "parent": ["mine_guid"] + } + } + }, + { + "table": "permit", + "columns": ["permit_no"], + "label": "permit", + "relationship": { + "type": "one_to_one", + "variant": "object", + "foreign_key": { + "child": ["permit_guid"], + "parent": ["permit_guid"] + } + } + } + ] + } + }, + { + "database": "mds", + "index": "now_applications", + "nodes": { + "table": "now_application_identity", + "columns": [ + "now_application_guid", + "now_number", + "mine_guid", + "application_type_code" + ], + "children": [ + { + "table": "now_application", + "columns": [ + "now_application_id", + "now_application_status_code", + "notice_of_work_type_code", + "property_name", + "submitted_date", + "received_date" + ], + "label": "application", + "relationship": { + "type": "one_to_one", + "variant": "object", + "foreign_key": { + "child": ["now_application_id"], + "parent": ["now_application_id"] + } + } + }, + { + "table": "mine", + "columns": ["mine_guid", "mine_name", "mine_no"], + "label": "mine", + "relationship": { + "type": "one_to_one", + "variant": "object", + "foreign_key": { + "child": ["mine_guid"], + "parent": ["mine_guid"] + } + } + } + ] + } + } +] \ No newline at end of file diff --git a/services/pgsync/start.sh b/services/pgsync/start.sh new file mode 100755 index 0000000000..4510cb3856 --- /dev/null +++ b/services/pgsync/start.sh @@ -0,0 +1,14 @@ +#!/bin/bash +set -e + +echo "Waiting for Postgres..." +# We rely on docker-compose healthcheck, but a small sleep can help race conditions +sleep 5 + +echo "Running bootstrap..." +# We try to bootstrap. If it fails (e.g. already exists), we continue. +# We capture the output to check for specific errors if needed, but for now || true is a simple way to proceed. +bootstrap --config /config/schema.json || echo "Bootstrap command returned non-zero, possibly already initialized." + +echo "Starting pgsync daemon..." +exec pgsync --config /config/schema.json --daemon From ff23395117a236b97d36e203edf70fd113262953 Mon Sep 17 00:00:00 2001 From: Simen Fivelstad Smaaberg Date: Mon, 22 Dec 2025 14:46:03 -0800 Subject: [PATCH 03/25] Fixed pgsync docker compose --- bin/setenv.sh | 3 +++ docker-compose.yaml | 13 +++++++------ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/bin/setenv.sh b/bin/setenv.sh index 51f0b60313..ba8cbf5ce7 100755 --- a/bin/setenv.sh +++ b/bin/setenv.sh @@ -33,6 +33,9 @@ AZURE_SEARCH_API_KEY ELASTICSEARCH_CA_CERT SYNCFUSION_LICENSE_KEY SYNCFUSION_FRONTEND_LICENSE_KEY +AZURE_STORAGE_CONNECTION_STRING +AZURE_STORAGE_CONTAINER +AZURE_STORAGE_BLOB_SERVICE_ENDPOINT " bold=$(tput bold) diff --git a/docker-compose.yaml b/docker-compose.yaml index 8bc813b2a9..f67970d2f8 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -72,7 +72,7 @@ services: timeout: 5s retries: 5 pgsync: - image: query/pgsync + image: toluaina1/pgsync container_name: mds_pgsync environment: - PG_HOST=postgres @@ -84,16 +84,17 @@ services: - ELASTICSEARCH_PORT=9200 - ELASTICSEARCH_SCHEME=https - ELASTICSEARCH_USER=elastic - - ELASTICSEARCH_PASSWORD=changeme + - ELASTICSEARCH_PASSWORD=elastic - ELASTICSEARCH_VERIFY_CERTS=false - REDIS_HOST=redis - REDIS_PORT=6379 - REDIS_DB=0 - - REDIS_AUTH= + - REDIS_AUTH=redis-password + - REDIS_CHECKPOINT=true + - CONSOLE_LOGGING_HANDLER_MIN_LEVEL=DEBUG volumes: - - ./services/pgsync:/config - command: > - bash -c "pgsync --config /config/schema.json --daemon" + - ./services/pgsync/schema.json:/app/schema.json + command: ["-c", "schema.json", "--daemon"] depends_on: postgres: condition: service_healthy From 13fb992dfcaf864fa2e0de9f1b2b5a0aabd890d5 Mon Sep 17 00:00:00 2001 From: Simen Fivelstad Smaaberg Date: Mon, 22 Dec 2025 14:47:08 -0800 Subject: [PATCH 04/25] Added missing changes --- services/common/src/redux/selectors/searchSelectors.js | 2 ++ services/core-api/.env-example | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/services/common/src/redux/selectors/searchSelectors.js b/services/common/src/redux/selectors/searchSelectors.js index f537f48946..f1f3fd01a8 100644 --- a/services/common/src/redux/selectors/searchSelectors.js +++ b/services/common/src/redux/selectors/searchSelectors.js @@ -2,7 +2,9 @@ import * as searchReducer from "../reducers/searchReducer"; export const { getSearchResults, + getSearchFacets, getSearchTerms, getSearchBarResults, + getSearchBarFacets, getSearchSubsetResults, } = searchReducer; diff --git a/services/core-api/.env-example b/services/core-api/.env-example index 23fe883351..e20709ff8a 100644 --- a/services/core-api/.env-example +++ b/services/core-api/.env-example @@ -149,4 +149,7 @@ JWT_OIDC_TEST_PRIVATE_KEY_PEM="-----BEGIN RSA PRIVATE KEY-----\nMIICXQIBAAKBgQDf AMS_BEARER_TOKEN=e3yC6nkR0XgdnPvIbbXdqiYas82fnXechxSFEt9CT8UfIyJQVpjMusDX4dbrljQsQAqdeEwWcCFvMhYskOI+Ks5tg0GzeruWXYTv37NM3dA= AMS_URL=https://test.j200.gov.bc.ca/ws/EMCORE -CORE_WEB_URL=http://localhost:3000 \ No newline at end of file +CORE_WEB_URL=http://localhost:3000 + +ELASTICSEARCH_USERNAME=elastic +ELASTICSEARCH_PASSWORD=elastic \ No newline at end of file From b27dbd00d9a23baccec455abcee1915267708a48 Mon Sep 17 00:00:00 2001 From: Simen Fivelstad Smaaberg Date: Mon, 5 Jan 2026 13:43:25 -0800 Subject: [PATCH 05/25] Re-instanted feature flag --- .../actionCreators/searchActionCreator.js | 19 +- .../src/redux/reducers/searchReducer.ts | 27 +- services/common/src/utils/featureFlag.ts | 3 +- services/common/src/utils/feature_flags.json | 3 +- .../src/components/navigation/NavBar.tsx | 7 +- .../src/components/search/GlobalSearch.tsx | 120 ++- .../src/components/search/SearchBar.tsx | 145 ++++ .../components/search/SearchBarDropdown.tsx | 128 +++ .../src/components/search/SearchResults.tsx | 743 +----------------- .../components/search/SearchResultsLegacy.tsx | 259 ++++++ .../src/components/search/SearchResultsV2.tsx | 492 ++++++++++++ 11 files changed, 1170 insertions(+), 776 deletions(-) create mode 100644 services/core-web/src/components/search/SearchBar.tsx create mode 100644 services/core-web/src/components/search/SearchBarDropdown.tsx create mode 100644 services/core-web/src/components/search/SearchResultsLegacy.tsx create mode 100644 services/core-web/src/components/search/SearchResultsV2.tsx diff --git a/services/common/src/redux/actionCreators/searchActionCreator.js b/services/common/src/redux/actionCreators/searchActionCreator.js index 7caa7fcc3d..f1c7f84b7e 100644 --- a/services/common/src/redux/actionCreators/searchActionCreator.js +++ b/services/common/src/redux/actionCreators/searchActionCreator.js @@ -7,12 +7,27 @@ import * as API from "@mds/common/constants/API"; import { createRequestHeader } from "../utils/RequestHeaders"; import CustomAxios from "../customAxios"; -export const fetchSearchResults = (searchTerm, searchTypes) => (dispatch) => { +export const fetchSearchResults = (searchTerm, searchTypes, filters = {}) => (dispatch) => { dispatch(request(NetworkReducerTypes.GET_SEARCH_RESULTS)); dispatch(showLoading()); + + // Build query params including filters + const params = { + search_term: searchTerm, + search_types: searchTypes, + ...filters + }; + + // Remove undefined/null/empty values + Object.keys(params).forEach(key => { + if (params[key] === undefined || params[key] === null || params[key] === '') { + delete params[key]; + } + }); + return CustomAxios() .get( - ENVIRONMENT.apiUrl + API.SEARCH({ search_term: searchTerm, search_types: searchTypes }), + ENVIRONMENT.apiUrl + API.SEARCH(params), createRequestHeader() ) .then((response) => { diff --git a/services/common/src/redux/reducers/searchReducer.ts b/services/common/src/redux/reducers/searchReducer.ts index 0c6c414568..8ee80f56b9 100644 --- a/services/common/src/redux/reducers/searchReducer.ts +++ b/services/common/src/redux/reducers/searchReducer.ts @@ -10,7 +10,32 @@ import { ISearchResult, ISearchResultList, ISimpleSearchResult } from "@mds/comm const initialState = { searchOptions: [], searchResults: [], - searchFacets: { mine_region: [], mine_classification: [], permit_status: [], type: [] }, + searchFacets: { + // Mine facets + mine_region: [], + mine_classification: [], + mine_operation_status: [], + mine_tenure: [], + mine_commodity: [], + has_tsf: [], + verified_status: [], + // Permit facets + permit_status: [], + is_exploration: [], + // Party facets + party_type: [], + // Explosives permit facets + explosives_permit_status: [], + explosives_permit_closed: [], + // NOD facets + nod_type: [], + nod_status: [], + // NoW facets + now_application_status: [], + now_type: [], + // Type facet + type: [] + }, searchBarResults: [], searchBarFacets: { mine: 0, person: 0, organization: 0, permit: 0, nod: 0, explosives_permit: 0 }, searchTerms: [], diff --git a/services/common/src/utils/featureFlag.ts b/services/common/src/utils/featureFlag.ts index 2696e90932..94a8d8cd4d 100644 --- a/services/common/src/utils/featureFlag.ts +++ b/services/common/src/utils/featureFlag.ts @@ -35,7 +35,8 @@ export enum Feature { STANDARD_PERMIT_CONDITIONS_EDITOR = "standard_permit_conditions_new_editor", NOW_PERMIT_CONDITIONS_EDITOR = "now_permit_conditions_new_editor", REPORT_MANAGEMENT_V2 = "report_management_v2", - MINESPACE_SIGNUP = "minespace_signup" + MINESPACE_SIGNUP = "minespace_signup", + GLOBAL_SEARCH_V2 = "global_search_v2", } export const initializeFlagsmith = async (flagsmithUrl, flagsmithKey) => { diff --git a/services/common/src/utils/feature_flags.json b/services/common/src/utils/feature_flags.json index 3484d900f3..f88bb7cfb0 100644 --- a/services/common/src/utils/feature_flags.json +++ b/services/common/src/utils/feature_flags.json @@ -30,5 +30,6 @@ "help_guide": true, "PERMIT_CONDITION_SEARCH": true, "report_management_v2": true, - "minespace_signup": true + "minespace_signup": true, + "global_search_v2": true } \ No newline at end of file diff --git a/services/core-web/src/components/navigation/NavBar.tsx b/services/core-web/src/components/navigation/NavBar.tsx index 48ea1eab47..ec4da52bc4 100644 --- a/services/core-web/src/components/navigation/NavBar.tsx +++ b/services/core-web/src/components/navigation/NavBar.tsx @@ -16,6 +16,7 @@ import * as router from "@/constants/routes"; import * as Permission from "@/constants/permissions"; import AuthorizationWrapper from "@/components/common/wrappers/AuthorizationWrapper"; import GlobalSearch from "@/components/search/GlobalSearch"; +import SearchBar from "@/components/search/SearchBar"; import { LOGO, HAMBURGER, CLOSE, SUCCESS_CHECKMARK, YELLOW_HAZARD } from "@/constants/assets"; import NotificationDrawer from "@/components/navigation/NotificationDrawer"; import HelpGuide from "@mds/common/components/help/HelpGuide"; @@ -408,7 +409,11 @@ export const NavBar: FC = ({ activeButton, isMenuOpen, toggleHambur Home
- + {isFeatureEnabled(Feature.GLOBAL_SEARCH_V2) ? ( + + ) : ( + + )}
@@ -750,14 +785,23 @@ const GlobalSearch: React.FC = ({ placeholder = "Search Core. / - ) : ( + - ) + {quickFilter && ( + { e.preventDefault(); setQuickFilter(null); }} + style={{ margin: 0, marginLeft: 4 }} + > + {TYPE_CONFIG[quickFilter]?.icon} + {TYPE_CONFIG[quickFilter]?.label || quickFilter} + + )} + } - placeholder={commandMode ? "Type command name..." : "Search for mines, contacts, permits... (type / for commands)"} - value={commandMode ? commandInput : searchTerm} + placeholder={quickFilter ? "Search within filter..." : "Search for mines, contacts, permits... (type / for commands)"} + value={commandMode ? `/${commandInput}` : searchTerm} onChange={handleSearchChange} onKeyDown={handleKeyDown} bordered={false} diff --git a/services/core-web/src/components/search/SearchBar.tsx b/services/core-web/src/components/search/SearchBar.tsx new file mode 100644 index 0000000000..2b192267fa --- /dev/null +++ b/services/core-web/src/components/search/SearchBar.tsx @@ -0,0 +1,145 @@ +import React, { useState, FC, useRef } from "react"; + +import { bindActionCreators } from "redux"; +import { connect } from "react-redux"; +import { withRouter, useHistory, RouteComponentProps } from "react-router-dom"; + +import { Input, InputProps, Button } from "antd"; + +import { + fetchSearchBarResults, + clearSearchBarResults, +} from "@mds/common/redux/actionCreators/searchActionCreator"; +import * as router from "@/constants/routes"; +import { getSearchBarResults } from "@mds/common/redux/reducers/searchReducer"; + +import { SearchOutlined } from "@ant-design/icons"; +import { useKey } from "@/App"; +import { ISearchResult, ISimpleSearchResult } from "@mds/common/interfaces/search/searchResult.interface"; +import { SearchBarDropdown } from "@/components/search/SearchBarDropdown"; +import { throttle } from "lodash"; +import { ActionCreator } from "@mds/common/interfaces/actionCreator"; + +interface SearchBarProps extends InputProps { + iconPlacement: "prefix" | "suffix" | false; + placeholderText: string; + showFocusButton: boolean; + searchBarResults: ISearchResult[]; + fetchSearchBarResults: ActionCreator; +} + +const SearchBar: FC = ({ + iconPlacement = "suffix", + placeholderText = "Search...", + showFocusButton = false, + ...props +}) => { + const [searchTerm, setSearchTerm] = useState(""); + const [searchTermHistory, setSearchTermHistory] = useState([]); + const [isFocussed, setIsFocussed] = useState(false); + + const history = useHistory(); + const hotKeyRef = useRef(); + + const fetchSearchBarResultsThrottled = throttle(props.fetchSearchBarResults, 2000, { + leading: true, + trailing: true, + }); + + if (showFocusButton) { + useKey((event) => { + const platform: string = window.navigator.platform.toLowerCase(); + const isMac = platform.includes("mac"); + + const actionKeyPressed = isMac ? event.metaKey : event.ctrlKey; + + return actionKeyPressed && event.key === "k"; + }, hotKeyRef); + } + + const changeSearchTerm = (event) => { + const newSearchTerm = event.target.value; + setSearchTerm(newSearchTerm); + + if (newSearchTerm.length >= 2) { + fetchSearchBarResultsThrottled(newSearchTerm); + } + }; + + const clearSearchBar = () => { + setSearchTerm(""); + }; + + const search = () => { + if (searchTerm) { + const newHistory = [searchTerm, ...searchTermHistory]; + setSearchTermHistory(newHistory); + } + clearSearchBar(); + history.push(router.SEARCH_RESULTS.dynamicRoute({ q: searchTerm })); + }; + + const getFocusButton = () => { + if (!showFocusButton) { + return null; + } + const platform: string = window.navigator.platform.toLowerCase(); + const isMac = platform.includes("mac"); + let buttonText = isMac ? "⌘ + K" : "CTRL + K"; + if (isFocussed) { + buttonText = "↵"; + } + const button = ( + + ); + return { suffix: button }; + }; + + const iconProps = iconPlacement ? { [iconPlacement]: } : {}; + + return ( +
+ + { + setIsFocussed(true); + }} + onBlur={() => { + setIsFocussed(false); + }} + ref={hotKeyRef} + {...(showFocusButton ? getFocusButton() : null)} + {...props} + {...iconProps} + /> + +
+ ); +}; + +const mapStateToProps = (state) => ({ + searchBarResults: getSearchBarResults(state), +}); + +const mapDispatchToProps = (dispatch) => + bindActionCreators( + { + fetchSearchBarResults, + clearSearchBarResults, + }, + dispatch + ); + +export default connect(mapStateToProps, mapDispatchToProps)(withRouter(SearchBar)); diff --git a/services/core-web/src/components/search/SearchBarDropdown.tsx b/services/core-web/src/components/search/SearchBarDropdown.tsx new file mode 100644 index 0000000000..aaa85c2507 --- /dev/null +++ b/services/core-web/src/components/search/SearchBarDropdown.tsx @@ -0,0 +1,128 @@ +import React, { FC } from "react"; +import { Dropdown, MenuProps } from "antd"; +import { SearchOutlined, FileSearchOutlined } from "@ant-design/icons"; + +import * as route from "@/constants/routes"; +import { MINE, PROFILE_NOCIRCLE, DOC } from "@/constants/assets"; +import { ISearchResult, ISimpleSearchResult } from "@mds/common/interfaces"; + +interface SearchBarDropdownProps { + history: any; + searchTerm: string; + searchTermHistory: string[]; + searchBarResults: ISearchResult[]; + children: React.ReactNode; +} + +export const SearchBarDropdown: FC = ({ + history, + searchTerm, + searchTermHistory, + searchBarResults, + children, +}) => { + const URLFor = (item: ISearchResult) => + ({ + mine: route.MINE_GENERAL.dynamicRoute(item.result.id), + party: route.PARTY_PROFILE.dynamicRoute(item.result.id), + permit: route.SEARCH_RESULTS.dynamicRoute({ q: item.result.value }), + }[item.type]); + + const IconFor = (item: ISearchResult) => + ({ + mine: {item.result?.value}, + party: ( + {item.result?.value} + ), + permit: {item.result?.value}, + }[item.type]); + + const getDropdownMenuItems = () => { + let items: MenuProps["items"] = []; + + if (searchTerm.length) { + items = [ + { + key: `/search?q=${searchTerm}`, + label: ( +

+ + See All +

+ ), + }, + ]; + if (searchBarResults.length) { + const newItems: MenuProps["items"] = [ + { type: "divider" }, + { + key: "search-dd-quick", + type: "group", + label: "Quick results", + children: searchBarResults.map((item) => ({ + key: URLFor(item), + label: ( +

+ {IconFor(item)} + {`${item.result.value || ""}`} +

+ ), + })), + }, + ]; + items = [...items, ...newItems]; + } + } else if (!searchTermHistory.length && !searchTerm.length) { + items = [ + { + key: "search-dd-empty", + type: "group", + label: "Enter your search, then hit enter or click the 'See All' option", + }, + ]; + } else if (searchTermHistory.length) { + items = [ + { + key: "search-dd-recent", + type: "group", + label: "Recent searches", + children: searchTermHistory.map((pastSearchTerm) => ({ + key: `/search?q=${pastSearchTerm}`, + label: ( +

+ + {pastSearchTerm} +

+ ), + })), + }, + ]; + } + return items; + }; + + const menuItems = getDropdownMenuItems(); + + const handleDropdownClick = (params) => { + const { key } = params; + history.push(key); + }; + + return ( + + {children} + + ); +}; + +export default SearchBarDropdown; diff --git a/services/core-web/src/components/search/SearchResults.tsx b/services/core-web/src/components/search/SearchResults.tsx index 3ee63df9f7..b2d55318e4 100644 --- a/services/core-web/src/components/search/SearchResults.tsx +++ b/services/core-web/src/components/search/SearchResults.tsx @@ -1,738 +1,17 @@ -import React, { useEffect, useState, useMemo } from "react"; -import { connect } from "react-redux"; -import { bindActionCreators } from "redux"; -import { useLocation, useHistory, Link } from "react-router-dom"; -import queryString from "query-string"; -import { - Input, - Tabs, - Card, - Typography, - Tag, - Empty, - Row, - Col, - Button, - Space, - Checkbox, - Collapse, - Tooltip, -} from "antd"; -import { - SearchOutlined, - FileTextOutlined, - TeamOutlined, - EnvironmentOutlined, - FileProtectOutlined, - ArrowRightOutlined, - EyeOutlined, - FilterOutlined, - CloseOutlined, - ClearOutlined, -} from "@ant-design/icons"; -import { getSearchResults, getSearchFacets, getSearchTerms } from "@mds/common/redux/selectors/searchSelectors"; -import { - fetchSearchOptions, - fetchSearchResults, -} from "@mds/common/redux/actionCreators/searchActionCreator"; -import { getSearchOptions } from "@mds/common/redux/reducers/searchReducer"; -import * as router from "@/constants/routes"; -import Highlight from "react-highlighter"; -import DocumentLink from "@mds/common/components/documents/DocumentLink"; -import { ISearchResultList } from "@mds/common/interfaces"; -import { formatDate } from "@common/utils/helpers"; +import React from "react"; +import { useFeatureFlag } from "@mds/common/providers/featureFlags/useFeatureFlag"; +import { Feature } from "@mds/common/utils/featureFlag"; +import SearchResultsV2 from "./SearchResultsV2"; +import SearchResultsLegacy from "./SearchResultsLegacy"; -const { Title, Text } = Typography; -const { Panel } = Collapse; +const SearchResults: React.FC = (props) => { + const { isFeatureEnabled } = useFeatureFlag(); -interface FacetBucket { - key: string; - count: number; -} - -interface SearchFacets { - mine_region: FacetBucket[]; - mine_classification: FacetBucket[]; - mine_operation_status: FacetBucket[]; - mine_tenure: FacetBucket[]; - mine_commodity: FacetBucket[]; - has_tsf: FacetBucket[]; - verified_status: FacetBucket[]; - permit_status: FacetBucket[]; - type: FacetBucket[]; -} - -interface SearchResultsProps { - location: { search: string }; - history: { push: (path: string) => void }; - fetchSearchOptions: () => any; - fetchSearchResults: (query: string, tab?: string) => any; - searchOptions: any[]; - searchTerms: string[]; - searchResults: ISearchResultList; - searchFacets: SearchFacets; - hideLoadingIndicator?: boolean; -} - -type PropsFromRedux = SearchResultsProps; - -const StatusTag = ({ status }: { status: string | string[] }) => { - if (!status) return null; - const statusText = Array.isArray(status) ? status.join(", ") : status; - let color = "default"; - const lowerStatus = statusText.toLowerCase(); - if ( - lowerStatus.includes("operating") || - lowerStatus.includes("open") || - lowerStatus.includes("active") - ) { - color = "success"; - } else if (lowerStatus.includes("closed")) { - color = "error"; - } else if (lowerStatus.includes("care") || lowerStatus.includes("maintenance")) { - color = "warning"; + if (isFeatureEnabled(Feature.GLOBAL_SEARCH_V2)) { + return ; } - return {statusText}; -}; - -const SearchSkeleton = () => ( -
- {[1, 2, 3, 4].map((i) => ( - -
-
-
-
-
-
-
- - ))} -
-); - -const ResultCard = ({ - icon, - iconClass, - title, - link, - children, - actions, - highlightRegex, -}: { - icon: React.ReactNode; - iconClass: string; - title: string; - link: string; - children: React.ReactNode; - actions?: React.ReactNode[]; - highlightRegex?: RegExp | null; -}) => ( - - -
-
{icon}
- - - - {highlightRegex ? {title ?? ""} : title} - -
{children}
- - - - - - , - - - , - ]} - > - •}> - - {highlightRegex ? ( - {item.result.mine_no ?? ""} - ) : ( - item.result.mine_no - )} - - {item.result.mine_region} - {mineStatus && } - - - ); - }; - - const renderPartyResult = (item: any) => ( - } - iconClass="card-icon--party" - title={item.result.name} - link={router.PARTY_PROFILE.dynamicRoute(item.result.party_guid)} - highlightRegex={highlightRegex} - actions={[ - - - , - ]} - > - •}> - {item.result.email && {item.result.email}} - {item.result.phone_no && {item.result.phone_no}} - {item.result.mine_party_appt && item.result.mine_party_appt.length > 0 && ( - {item.result.mine_party_appt[0].mine_party_appt_type_code_description} - )} - - - ); - - const renderPermitResult = (item: any) => ( - } - iconClass="card-icon--permit" - title={`Permit: ${item.result.permit_no}`} - link={router.VIEW_MINE_PERMIT.dynamicRoute( - item.result.mine[0].mine_guid, - item.result.permit_guid - )} - highlightRegex={highlightRegex} - actions={[ - - - , - ]} - > - •}> - {item.result.mine[0].mine_name} - - - - ); - - const renderDocumentResult = (item: any) => ( - - - -
- -
- - - {item.result.document_name ?? ""} - ) : undefined - } - /> -
- •}> - {item.result.mine_name} - Uploaded {formatDate(item.result.upload_date)} - -
- - - - ); - - const renderContent = () => { - if (isSearching && !props.hideLoadingIndicator) return ; - - const allResults = [ - ...(props.searchResults.mine || []), - ...(props.searchResults.party || []), - ...(props.searchResults.permit || []), - ...(props.searchResults.mine_documents || []), - ...(props.searchResults.permit_documents || []), - ].sort((a, b) => b.score - a.score); - - if (allResults.length === 0 && !isSearching) { - return ( - - No results found for "{params.q}" - - } - style={{ padding: 48 }} - > - - - ); - } - - const filteredResults = allResults.filter((item) => { - const result = item.result as any; - if (item.type === "mine") { - // Mine Region filter - if (selectedFilters.mine_region && selectedFilters.mine_region.length > 0) { - if (!selectedFilters.mine_region.includes(result.mine_region)) return false; - } - // Mine Classification filter (Major/Regional) - if (selectedFilters.mine_classification && selectedFilters.mine_classification.length > 0) { - const classification = result.major_mine_ind ? "Major Mine" : "Regional Mine"; - if (!selectedFilters.mine_classification.includes(classification)) return false; - } - // Mine Operation Status filter - if (selectedFilters.mine_operation_status && selectedFilters.mine_operation_status.length > 0) { - const statusCode = - result.mine_status && result.mine_status.length > 0 - ? result.mine_status[0].status_values?.[0] - : null; - if (!statusCode || !selectedFilters.mine_operation_status.includes(statusCode)) return false; - } - // Mine Tenure filter - if (selectedFilters.mine_tenure && selectedFilters.mine_tenure.length > 0) { - const tenures = result.mine_type - ? result.mine_type.map((mt: any) => mt.mine_tenure_type_code) - : []; - if (!selectedFilters.mine_tenure.some((t) => tenures.includes(t))) return false; - } - // Mine Commodity filter - if (selectedFilters.mine_commodity && selectedFilters.mine_commodity.length > 0) { - const commodities = result.mine_type - ? result.mine_type.flatMap((mt: any) => - mt.mine_type_detail - ? mt.mine_type_detail.map((mtd: any) => mtd.mine_commodity_code) - : [] - ) - : []; - if (!selectedFilters.mine_commodity.some((c) => commodities.includes(c))) return false; - } - // TSF filter - if (selectedFilters.has_tsf && selectedFilters.has_tsf.length > 0) { - const tsf = - result.mine_tailings_storage_facilities && - result.mine_tailings_storage_facilities.length > 0 - ? "Has TSF" - : "No TSF"; - if (!selectedFilters.has_tsf.includes(tsf)) return false; - } - // Verified Status filter - if (selectedFilters.verified_status && selectedFilters.verified_status.length > 0) { - const verified = result.verified_status - ? result.verified_status.healthy_ind - ? "Verified" - : "Unverified" - : null; - if (!verified || !selectedFilters.verified_status.includes(verified)) return false; - } - } - if (item.type === "permit") { - if (selectedFilters.permit_status && selectedFilters.permit_status.length > 0) { - if (!selectedFilters.permit_status.includes(result.permit_status_code)) return false; - } - } - return true; - }); - - const activeTab = params.t || "all"; - const hasActiveFilters = Object.keys(selectedFilters).length > 0; - - // Convert API facets (array format) to Record format for consistency - const apiFacetToRecord = (apiFacet: FacetBucket[] | undefined): Record => { - if (!apiFacet) return {}; - return apiFacet.reduce((acc, bucket) => { - acc[bucket.key] = bucket.count; - return acc; - }, {} as Record); - }; - - // Use ES-powered API facets for all filters - const facetConfig = [ - { key: "mine_region", label: "Mine Region", data: apiFacetToRecord(props.searchFacets?.mine_region) }, - { key: "mine_classification", label: "Classification", data: apiFacetToRecord(props.searchFacets?.mine_classification) }, - { key: "mine_operation_status", label: "Operation Status", data: apiFacetToRecord(props.searchFacets?.mine_operation_status) }, - { key: "mine_tenure", label: "Mine Tenure", data: apiFacetToRecord(props.searchFacets?.mine_tenure) }, - { key: "mine_commodity", label: "Commodity", data: apiFacetToRecord(props.searchFacets?.mine_commodity) }, - { key: "has_tsf", label: "TSF", data: apiFacetToRecord(props.searchFacets?.has_tsf) }, - { key: "verified_status", label: "Verified", data: apiFacetToRecord(props.searchFacets?.verified_status) }, - { key: "permit_status", label: "Permit Status", data: apiFacetToRecord(props.searchFacets?.permit_status) }, - ].filter((f) => Object.keys(f.data).length > 0); - - const renderActiveFilters = () => { - if (!hasActiveFilters) return null; - const allFilters: { category: string; value: string; label: string }[] = []; - Object.entries(selectedFilters).forEach(([category, values]) => { - const config = facetConfig.find((f) => f.key === category); - values.forEach((value) => { - allFilters.push({ - category, - value, - label: `${config?.label || category}: ${value}`, - }); - }); - }); - - return ( -
- - {allFilters.map((filter) => ( - removeFilter(filter.category, filter.value)} - style={{ - background: "rgba(94, 70, 161, 0.1)", - borderColor: "transparent", - color: "#5e46a1", - borderRadius: 16, - padding: "4px 12px", - }} - > - {filter.label} - - ))} - - -
- ); - }; - - const renderFacets = () => ( -
-
- - <FilterOutlined style={{ marginRight: 8 }} /> - Filters - - {hasActiveFilters && ( - - )} -
- f.key)} - ghost - expandIconPosition="end" - > - {facetConfig.map((facet) => ( - - {Object.entries(facet.data) - .sort(([, a], [, b]) => (b as number) - (a as number)) - .map(([value, count]) => ( -
- handleFilterChange(facet.key, value, e.target.checked)} - > - - {value}{" "} - - ({count}) - - - -
- ))} -
- ))} -
-
- ); - - const getFilteredByType = (type: string) => filteredResults.filter((item) => item.type === type); - const getFilteredDocuments = () => - filteredResults.filter( - (item) => item.type === "mine_documents" || item.type === "permit_documents" - ); - - const tabItems = [ - { - key: "all", - label: `All (${filteredResults.length})`, - children: ( -
- {renderActiveFilters()} -
- - Showing {filteredResults.length} results for " - {params.q}" - -
- {filteredResults.map((item: any, index: number) => ( -
- {item.type === "mine" && renderMineResult(item)} - {item.type === "party" && renderPartyResult(item)} - {item.type === "permit" && renderPermitResult(item)} - {(item.type === "mine_documents" || item.type === "permit_documents") && - renderDocumentResult(item)} -
- ))} -
- ), - }, - { - key: "mine", - label: `Mines (${getFilteredByType("mine").length})`, - children: ( -
- {renderActiveFilters()} - {getFilteredByType("mine").map((item, index) => ( -
{renderMineResult(item)}
- ))} -
- ), - }, - { - key: "party", - label: `Contacts (${getFilteredByType("party").length})`, - children: ( -
- {renderActiveFilters()} - {getFilteredByType("party").map((item, index) => ( -
{renderPartyResult(item)}
- ))} -
- ), - }, - { - key: "permit", - label: `Permits (${getFilteredByType("permit").length})`, - children: ( -
- {renderActiveFilters()} - {getFilteredByType("permit").map((item, index) => ( -
{renderPermitResult(item)}
- ))} -
- ), - }, - { - key: "document", - label: `Documents (${getFilteredDocuments().length})`, - children: ( -
- {renderActiveFilters()} - {getFilteredDocuments().map((item, index) => ( -
{renderDocumentResult(item)}
- ))} -
- ), - }, - ]; - - return ( - -
- {renderFacets()} - - - - - - ); - }; - - return ( -
-
- -
- - Search Results - -
- } - size="large" - value={searchInputValue} - onChange={(e) => setSearchInputValue(e.target.value)} - onSearch={onSearch} - /> -
- - - -
- -
- {renderContent()} - - - - - ); + return ; }; -const mapStateToProps = (state: any) => ({ - searchOptions: getSearchOptions(state), - searchResults: getSearchResults(state), - searchFacets: getSearchFacets(state), - searchTerms: getSearchTerms(state), -}); - -const mapDispatchToProps = (dispatch: any) => - bindActionCreators( - { - fetchSearchOptions, - fetchSearchResults, - }, - dispatch - ); - -export default connect(mapStateToProps, mapDispatchToProps)(SearchResults); +export default SearchResults; diff --git a/services/core-web/src/components/search/SearchResultsLegacy.tsx b/services/core-web/src/components/search/SearchResultsLegacy.tsx new file mode 100644 index 0000000000..400470386e --- /dev/null +++ b/services/core-web/src/components/search/SearchResultsLegacy.tsx @@ -0,0 +1,259 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { bindActionCreators } from "redux"; +import { ConnectedProps, connect } from "react-redux"; +import queryString from "query-string"; +import { Row, Col } from "antd"; +import { ArrowLeftOutlined } from "@ant-design/icons"; +import { Link } from "react-router-dom"; +import { sumBy, map, mapValues, keyBy } from "lodash"; +import { getSearchResults, getSearchTerms } from "@mds/common/redux/selectors/searchSelectors"; +import { getPartyRelationshipTypeHash } from "@mds/common/redux/selectors/staticContentSelectors"; +import { + fetchSearchOptions, + fetchSearchResults, +} from "@mds/common/redux/actionCreators/searchActionCreator"; +import { getSearchOptions } from "@mds/common/redux/reducers/searchReducer"; +import { MineResultsTable } from "@/components/search/MineResultsTable"; +import { PermitResultsTable } from "@/components/search/PermitResultsTable"; +import { ContactResultsTable } from "@/components/search/ContactResultsTable"; +import { DocumentResultsTable } from "@/components/search/DocumentResultsTable"; +import Loading from "@/components/common/Loading"; +import * as router from "@/constants/routes"; +import { ISearchResultList } from "@mds/common/interfaces"; + +interface SearchResultsProps { + location: { search: string }; + history: { push: (path: string) => void }; + fetchSearchOptions: () => Promise; + fetchSearchResults: (query, tab) => Promise; + searchOptions: any[]; + searchOptionsHash: { [key: string]: any }; + searchTerms: string[]; + searchResults: ISearchResultList; + partyRelationshipTypeHash: { [key: string]: string }; + hideLoadingIndicator?: boolean; +} + +const TableForGroup = ( + group: any, + highlightRegex: RegExp, + partyRelationshipTypeHash: { [key: string]: string }, + query: { q?: string }, + showAdvancedLookup: boolean +) => +({ + mine: ( + + ), + party: ( + + ), + permit: ( + + ), + mine_documents: ( + + ), + permit_documents: ( + + ), +}[group.type]); + +const NoResults = (searchTerms: string[]) => { + const searchTooShort = !searchTerms.find((term) => term.length > 2); + return ( + + +

No Results Found.

+ {searchTooShort && ( +

At least one word in your search needs to be a minimum of three characters.

+ )} +

Please try another search.

+ + + ); +}; + +const CantFindIt = () => ( + + +

Can't find it?

+

+ Try clicking to see more results, or select the advanced lookup if available. Also, double + check your spelling to ensure it is correct. If you feel there is a problem, contact the + Core administrator to ask for assistance. +

+ + +); + +export const SearchResultsLegacy: React.FC = (props) => { + const [isSearching, setIsSearching] = useState(false); + const [hasSearchTerm, setHasSearchTerm] = useState(false); + const [params, setParams] = useState<{ [key: string]: string }>({}); + + const handleSearch = (location: { search: string }) => { + const parsedParams = queryString.parse(location.search); + const { q, t } = parsedParams; + + if (q) { + props.fetchSearchResults(q, t); + setParams(parsedParams); + setIsSearching(true); + setHasSearchTerm(true); + } + }; + + useEffect(() => { + if (!props.searchOptions.length) { + props.fetchSearchOptions(); + } + handleSearch(props.location); + }, []); + + useEffect(() => { + handleSearch(props.location); + }, [props.location]); + + const groupedSearchResults: any[] = useMemo(() => { + const results: any[] = []; + Object.entries(props.searchResults).forEach((entry) => { + const resultGroup = { + type: entry[0], + score: sumBy(entry[1], "score"), + results: map(entry[1], "result"), + }; + if (resultGroup.score > 0) results.push(resultGroup); + }); + + results.sort((a, b) => a.score - b.score); + results.reverse(); + + setIsSearching(false); + + return results; + }, [props.searchResults]); + + const results = useMemo(() => props.searchTerms.map((t) => `"${t}"`).join(", "), [ + props.searchTerms, + ]); + + const type_filter = params.t; + + if (isSearching && !props.hideLoadingIndicator) return ; + + return hasSearchTerm ? ( +
+
+
+

+ {`${type_filter ? props.searchOptionsHash[type_filter] : "Search results" + } for ${results}`} +

+
+ {type_filter ? ( + + + {`Back to all search results for ${results}`} + + ) : ( +

+ Just show me: + {props.searchOptions.map((o) => ( + + + {o.description} + + + ))} +

+ )} +
+
+
+
+ {groupedSearchResults.length === 0 && NoResults(props.searchTerms)} + {groupedSearchResults.map((group) => ( +
+ {TableForGroup( + group, + RegExp(`${props.searchTerms.join("|")}`, "i"), + props.partyRelationshipTypeHash, + params, + !!type_filter + )} + {!type_filter && ( + + See more search results for {props.searchOptionsHash[group.type]} + + )} +
+ ))} + +
+
+
+
+ ) : ( + <> + ); +}; + +const mapStateToProps = (state: any) => ({ + searchOptions: getSearchOptions(state), + searchOptionsHash: mapValues(keyBy(getSearchOptions(state), "model_id"), "description"), + searchResults: getSearchResults(state), + searchTerms: getSearchTerms(state), + partyRelationshipTypeHash: getPartyRelationshipTypeHash(state), +}); + +const mapDispatchToProps = (dispatch) => + bindActionCreators( + { + fetchSearchOptions, + fetchSearchResults, + }, + dispatch + ); + + +const connector = connect(mapStateToProps, mapDispatchToProps); +type PropsFromRedux = ConnectedProps; + +export default connector(SearchResultsLegacy); diff --git a/services/core-web/src/components/search/SearchResultsV2.tsx b/services/core-web/src/components/search/SearchResultsV2.tsx new file mode 100644 index 0000000000..9ff5db8ee2 --- /dev/null +++ b/services/core-web/src/components/search/SearchResultsV2.tsx @@ -0,0 +1,492 @@ +import React, { useEffect, useState, useMemo, useCallback } from "react"; +import { connect } from "react-redux"; +import { bindActionCreators } from "redux"; +import { useLocation, useHistory } from "react-router-dom"; +import queryString from "query-string"; +import { + Input, + Tabs, + Card, + Typography, + Tag, + Empty, + Row, + Col, + Button, + Space, + Spin, + Checkbox, + Collapse, +} from "antd"; +import { + SearchOutlined, + FilterOutlined, + ClearOutlined, +} from "@ant-design/icons"; +import { getSearchResults, getSearchFacets, getSearchTerms } from "@mds/common/redux/selectors/searchSelectors"; +import { + fetchSearchOptions, + fetchSearchResults, +} from "@mds/common/redux/actionCreators/searchActionCreator"; +import { getSearchOptions } from "@mds/common/redux/reducers/searchReducer"; +import { getPartyRelationshipTypeHash } from "@mds/common/redux/selectors/staticContentSelectors"; +import * as router from "@/constants/routes"; +import { ISearchResultList } from "@mds/common/interfaces"; +import { MineResultsTable } from "./MineResultsTable"; +import { PermitResultsTable } from "./PermitResultsTable"; +import { ContactResultsTable } from "./ContactResultsTable"; +import { DocumentResultsTable } from "./DocumentResultsTable"; + +const { Text } = Typography; +const { Panel } = Collapse; + +interface FacetBucket { + key: string; + count: number; +} + +interface SearchFacets { + // Mine facets + mine_region?: FacetBucket[]; + mine_classification?: FacetBucket[]; + mine_operation_status?: FacetBucket[]; + mine_tenure?: FacetBucket[]; + mine_commodity?: FacetBucket[]; + has_tsf?: FacetBucket[]; + verified_status?: FacetBucket[]; + // Permit facets + permit_status?: FacetBucket[]; + is_exploration?: FacetBucket[]; + // Party facets + party_type?: FacetBucket[]; + // Explosives permit facets + explosives_permit_status?: FacetBucket[]; + explosives_permit_closed?: FacetBucket[]; + // NOD facets + nod_type?: FacetBucket[]; + nod_status?: FacetBucket[]; + // NoW facets + now_application_status?: FacetBucket[]; + now_type?: FacetBucket[]; + // Type facet + type?: FacetBucket[]; +} + +interface SearchResultsProps { + location: { search: string }; + fetchSearchOptions: () => any; + fetchSearchResults: (query: string, tab?: string, filters?: Record) => any; + searchOptions: any[]; + searchTerms: string[]; + searchResults: ISearchResultList; + searchFacets: SearchFacets; + partyRelationshipTypeHash: Record; +} + +export const SearchResults: React.FC = (props) => { + const [isSearching, setIsSearching] = useState(false); + const [params, setParams] = useState<{ q?: string; t?: string }>({}); + const [searchInputValue, setSearchInputValue] = useState(""); + const [selectedFilters, setSelectedFilters] = useState>({}); + const history = useHistory(); + const location = useLocation(); + + const highlightRegex = useMemo(() => { + if (!params.q) return null; + const escapedTerm = params.q.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + try { + return new RegExp(escapedTerm, "i"); + } catch { + return null; + } + }, [params.q]); + + // Convert selected filters to API format + const getFiltersForApi = useCallback((filters: Record): Record => { + const apiFilters: Record = {}; + Object.entries(filters).forEach(([key, values]) => { + if (values.length > 0) { + apiFilters[key] = values.join(","); + } + }); + return apiFilters; + }, []); + + // Trigger search with current filters + const triggerSearch = useCallback((searchTerm: string, searchTypes?: string, filters?: Record) => { + if (!searchTerm) return; + setIsSearching(true); + const apiFilters = getFiltersForApi(filters || {}); + props.fetchSearchResults(searchTerm, searchTypes, apiFilters); + }, [props.fetchSearchResults, getFiltersForApi]); + + const handleSearch = useCallback((searchParams: string, resetFilters = true) => { + const parsedParams = queryString.parse(searchParams); + const { q, t } = parsedParams; + if (q) { + if (resetFilters) { + setSelectedFilters({}); + } + setParams({ q: q as string, t: t as string }); + setSearchInputValue(q as string); + triggerSearch(q as string, t as string, resetFilters ? {} : selectedFilters); + } + }, [triggerSearch, selectedFilters]); + + const onSearch = (value: string) => { + if (value) { + setSelectedFilters({}); + history.push(router.SEARCH_RESULTS.dynamicRoute({ q: value })); + } + }; + + const onTabChange = (key: string) => { + const newParams = { q: params.q || "", t: key === "all" ? null : key }; + history.push(router.SEARCH_RESULTS.dynamicRoute(newParams)); + }; + + useEffect(() => { + if (!props.searchOptions.length) { + props.fetchSearchOptions(); + } + handleSearch(props.location.search); + }, []); + + useEffect(() => { + handleSearch(props.location.search); + }, [props.location.search]); + + useEffect(() => { + if (props.searchResults) { + setIsSearching(false); + } + }, [props.searchResults]); + + // Handle filter change - trigger server-side search + const handleFilterChange = (category: string, value: string, checked: boolean) => { + const newFilters = { ...selectedFilters }; + const current = newFilters[category] || []; + + if (checked) { + newFilters[category] = [...current, value]; + } else { + const updated = current.filter((v) => v !== value); + if (updated.length === 0) { + delete newFilters[category]; + } else { + newFilters[category] = updated; + } + } + + setSelectedFilters(newFilters); + + // Trigger server-side search with new filters + if (params.q) { + triggerSearch(params.q, params.t, newFilters); + } + }; + + const clearAllFilters = () => { + setSelectedFilters({}); + if (params.q) { + triggerSearch(params.q, params.t, {}); + } + }; + + const hasActiveFilters = Object.keys(selectedFilters).length > 0; + + // Get results directly from API (server-side filtered) + const mines = props.searchResults.mine || []; + const parties = props.searchResults.party || []; + const permits = props.searchResults.permit || []; + const mineDocuments = props.searchResults.mine_documents || []; + const permitDocuments = props.searchResults.permit_documents || []; + + // Transform results to format expected by table components + const mineResults = mines.map((item: any) => item.result); + const partyResults = parties.map((item: any) => item.result); + const permitResults = permits.map((item: any) => item.result); + const documentResults = [...mineDocuments, ...permitDocuments].map((item: any) => item.result); + + const totalResults = mines.length + parties.length + permits.length + mineDocuments.length + permitDocuments.length; + + // Facet configuration from ES aggregations + const facetConfig = [ + // Mine facets + { key: "mine_region", label: "Mine Region", data: props.searchFacets?.mine_region || [] }, + { key: "mine_classification", label: "Classification", data: props.searchFacets?.mine_classification || [] }, + { key: "mine_operation_status", label: "Operation Status", data: props.searchFacets?.mine_operation_status || [] }, + { key: "mine_tenure", label: "Tenure Type", data: props.searchFacets?.mine_tenure || [] }, + { key: "mine_commodity", label: "Commodity", data: props.searchFacets?.mine_commodity || [] }, + { key: "has_tsf", label: "Tailings Storage Facility", data: props.searchFacets?.has_tsf || [] }, + { key: "verified_status", label: "Verification Status", data: props.searchFacets?.verified_status || [] }, + // Permit facets + { key: "permit_status", label: "Permit Status", data: props.searchFacets?.permit_status || [] }, + { key: "is_exploration", label: "Exploration", data: props.searchFacets?.is_exploration || [] }, + // Party facets + { key: "party_type", label: "Contact Type", data: props.searchFacets?.party_type || [] }, + // Explosives permit facets + { key: "explosives_permit_status", label: "Explosives Status", data: props.searchFacets?.explosives_permit_status || [] }, + { key: "explosives_permit_closed", label: "Explosives Closed", data: props.searchFacets?.explosives_permit_closed || [] }, + // NOD facets + { key: "nod_type", label: "NOD Type", data: props.searchFacets?.nod_type || [] }, + { key: "nod_status", label: "NOD Status", data: props.searchFacets?.nod_status || [] }, + // NoW facets + { key: "now_application_status", label: "NoW Status", data: props.searchFacets?.now_application_status || [] }, + { key: "now_type", label: "NoW Type", data: props.searchFacets?.now_type || [] }, + ].filter((f) => f.data.length > 0); + + const renderFilters = () => ( + +
+ + + Filters + + {hasActiveFilters && ( + + )} +
+ + {hasActiveFilters && ( +
+ + {Object.entries(selectedFilters).map(([category, values]) => + values.map((value) => ( + handleFilterChange(category, value, false)} + color="blue" + style={{ margin: 0 }} + > + {value} + + )) + )} + +
+ )} + + {facetConfig.length > 0 ? ( + f.key)}> + {facetConfig.map((facet) => ( + {facet.label}} key={facet.key}> +
+ {facet.data + .sort((a, b) => b.count - a.count) + .map((bucket) => ( +
+ handleFilterChange(facet.key, bucket.key, e.target.checked)} + > + + {bucket.key}{" "} + ({bucket.count}) + + +
+ ))} +
+
+ ))} +
+ ) : ( + No filters available + )} +
+ ); + + const tabItems = [ + { + key: "all", + label: `All (${totalResults})`, + children: ( + <> + {mineResults.length > 0 && ( + + )} + {partyResults.length > 0 && ( + + )} + {permitResults.length > 0 && ( + + )} + {documentResults.length > 0 && ( + + )} + + ), + }, + { + key: "mine", + label: `Mines (${mines.length})`, + children: ( + + ), + }, + { + key: "party", + label: `Contacts (${parties.length})`, + children: ( + + ), + }, + { + key: "permit", + label: `Permits (${permits.length})`, + children: ( + + ), + }, + { + key: "document", + label: `Documents (${documentResults.length})`, + children: ( + + ), + }, + ]; + + const activeTab = params.t || "all"; + + return ( +
+
+ +
+
+ } + size="large" + value={searchInputValue} + onChange={(e) => setSearchInputValue(e.target.value)} + onSearch={onSearch} + style={{ maxWidth: 600 }} + /> +
+ + + + +
+ +
+ {isSearching ? ( +
+ +
+ Searching... +
+
+ ) : totalResults === 0 && !hasActiveFilters ? ( + No results found for "{params.q}"} + style={{ padding: 48 }} + > + + + ) : totalResults === 0 && hasActiveFilters ? ( + + + {renderFilters()} + + + No results match your filters} + style={{ padding: 48 }} + > + + + + + ) : ( + + + {renderFilters()} + + +
+ + Showing {totalResults} results for "{params.q}" + {hasActiveFilters && " (filtered)"} + +
+ + + + )} + + + + + ); +}; + +const mapStateToProps = (state: any) => ({ + searchOptions: getSearchOptions(state), + searchResults: getSearchResults(state), + searchFacets: getSearchFacets(state), + searchTerms: getSearchTerms(state), + partyRelationshipTypeHash: getPartyRelationshipTypeHash(state), +}); + +const mapDispatchToProps = (dispatch: any) => + bindActionCreators( + { + fetchSearchOptions, + fetchSearchResults, + }, + dispatch + ); + +export default connect(mapStateToProps, mapDispatchToProps)(SearchResults); From ed8fa1325acd5c440fc3bb4c8e5b01e503dc3a90 Mon Sep 17 00:00:00 2001 From: Simen Fivelstad Smaaberg Date: Mon, 5 Jan 2026 13:44:11 -0800 Subject: [PATCH 06/25] Added missing files --- .../app/api/search/response_models.py | 15 + .../app/api/search/search/resources/search.py | 734 ++++++++++++------ services/core-api/app/api/utils/search.py | 11 + services/pgsync/schema.json | 209 ++++- 4 files changed, 670 insertions(+), 299 deletions(-) diff --git a/services/core-api/app/api/search/response_models.py b/services/core-api/app/api/search/response_models.py index f987b0cde0..02c4f1697f 100644 --- a/services/core-api/app/api/search/response_models.py +++ b/services/core-api/app/api/search/response_models.py @@ -153,6 +153,7 @@ SEARCH_FACETS_MODEL = api.model( 'SearchFacets', { + # Mine facets 'mine_region': fields.List(fields.Nested(SEARCH_FACET_BUCKET_MODEL)), 'mine_classification': fields.List(fields.Nested(SEARCH_FACET_BUCKET_MODEL)), 'mine_operation_status': fields.List(fields.Nested(SEARCH_FACET_BUCKET_MODEL)), @@ -160,7 +161,21 @@ 'mine_commodity': fields.List(fields.Nested(SEARCH_FACET_BUCKET_MODEL)), 'has_tsf': fields.List(fields.Nested(SEARCH_FACET_BUCKET_MODEL)), 'verified_status': fields.List(fields.Nested(SEARCH_FACET_BUCKET_MODEL)), + # Permit facets 'permit_status': fields.List(fields.Nested(SEARCH_FACET_BUCKET_MODEL)), + 'is_exploration': fields.List(fields.Nested(SEARCH_FACET_BUCKET_MODEL)), + # Party facets + 'party_type': fields.List(fields.Nested(SEARCH_FACET_BUCKET_MODEL)), + # Explosives permit facets + 'explosives_permit_status': fields.List(fields.Nested(SEARCH_FACET_BUCKET_MODEL)), + 'explosives_permit_closed': fields.List(fields.Nested(SEARCH_FACET_BUCKET_MODEL)), + # NOD facets + 'nod_type': fields.List(fields.Nested(SEARCH_FACET_BUCKET_MODEL)), + 'nod_status': fields.List(fields.Nested(SEARCH_FACET_BUCKET_MODEL)), + # NoW facets + 'now_application_status': fields.List(fields.Nested(SEARCH_FACET_BUCKET_MODEL)), + 'now_type': fields.List(fields.Nested(SEARCH_FACET_BUCKET_MODEL)), + # Type facet 'type': fields.List(fields.Nested(SEARCH_FACET_BUCKET_MODEL)), }) diff --git a/services/core-api/app/api/search/search/resources/search.py b/services/core-api/app/api/search/search/resources/search.py index 88e06a1956..c31c66dcc7 100644 --- a/services/core-api/app/api/search/search/resources/search.py +++ b/services/core-api/app/api/search/search/resources/search.py @@ -1,287 +1,505 @@ import regex from uuid import UUID +from flask import current_app, request +from flask_restx import Resource + from app.api.search.elasticsearch.elastic_search_service import ElasticSearchService from app.api.search.response_models import SEARCH_RESULT_RETURN_MODEL from app.api.utils.access_decorators import requires_role_view_all from app.api.utils.resources_mixins import UserMixin from app.api.utils.search import search_targets from app.extensions import api, db -from flask import current_app, request -from flask_restx import Resource + + +TYPE_TO_INDEX = { + 'mine': 'mines', + 'party': 'parties', + 'permit': 'permits', + 'mine_documents': 'documents', + 'notice_of_departure': 'notices_of_departure', + 'explosives_permit': 'explosives_permits', + 'now_application': 'now_applications', +} + +INDEX_TO_TYPE = {v: k for k, v in TYPE_TO_INDEX.items()} + +ES_AGGREGATIONS = { + "by_index": {"terms": {"field": "_index", "size": 10}}, + # Mine facets + "mine_region": {"terms": {"field": "mine_region.keyword", "size": 20, "missing": "Unknown"}}, + "major_mine_ind": {"terms": {"field": "major_mine_ind", "size": 10}}, + "mine_operation_status": { + "nested": {"path": "mine_status"}, + "aggs": { + "status_codes": { + "nested": {"path": "mine_status.status_xref"}, + "aggs": { + "codes": {"terms": {"field": "mine_status.status_xref.mine_operation_status_code.keyword", "size": 20}} + } + } + } + }, + "mine_tenure": { + "nested": {"path": "mine_types"}, + "aggs": {"tenure_codes": {"terms": {"field": "mine_types.mine_tenure_type_code.keyword", "size": 20}}} + }, + "mine_commodity": { + "nested": {"path": "mine_types"}, + "aggs": { + "details": { + "nested": {"path": "mine_types.mine_type_details"}, + "aggs": {"commodity_codes": {"terms": {"field": "mine_types.mine_type_details.mine_commodity_code.keyword", "size": 30}}} + } + } + }, + "has_tsf": { + "nested": {"path": "tailings_storage_facilities"}, + "aggs": {"count": {"value_count": {"field": "tailings_storage_facilities.mine_tailings_storage_facility_guid"}}} + }, + "verified_status": { + "nested": {"path": "verified_status"}, + "aggs": {"healthy": {"terms": {"field": "verified_status.healthy_ind", "size": 10}}} + }, + # Permit facets + "permit_status": {"terms": {"field": "permit_status_code.keyword", "size": 20}}, + "is_exploration": {"terms": {"field": "is_exploration", "size": 10}}, + # Party facets + "party_type": {"terms": {"field": "party_type_code.keyword", "size": 10}}, + # Explosives permit facets + "explosives_permit_status": {"terms": {"field": "application_status.keyword", "size": 20}}, + "explosives_permit_closed": {"terms": {"field": "is_closed", "size": 10}}, + # Notice of departure facets + "nod_type": {"terms": {"field": "nod_type.keyword", "size": 20}}, + "nod_status": {"terms": {"field": "nod_status.keyword", "size": 20}}, + # NoW application facets + "now_application_status": { + "nested": {"path": "application"}, + "aggs": {"status_codes": {"terms": {"field": "application.now_application_status_code.keyword", "size": 20}}} + }, + "now_type": { + "nested": {"path": "application"}, + "aggs": {"type_codes": {"terms": {"field": "application.notice_of_work_type_code.keyword", "size": 20}}} + }, +} + + +def parse_csv_param(value): + """Parse comma-separated parameter into list.""" + return [v.strip() for v in value.split(',')] if value else [] + + +def build_filter_clauses(filters): + """Build ES filter clauses from filter parameters.""" + clauses = [{"term": {"deleted_ind": False}}] + + if filters.get('mine_region'): + clauses.append({"terms": {"mine_region.keyword": filters['mine_region']}}) + + if filters.get('mine_classification'): + major_values = [] + for c in filters['mine_classification']: + if c == 'Major Mine': + major_values.append(True) + elif c == 'Regional Mine': + major_values.append(False) + if major_values: + clauses.append({"terms": {"major_mine_ind": major_values}}) + + if filters.get('mine_tenure'): + clauses.append({ + "nested": { + "path": "mine_types", + "query": {"terms": {"mine_types.mine_tenure_type_code.keyword": filters['mine_tenure']}} + } + }) + + if filters.get('mine_commodity'): + clauses.append({ + "nested": { + "path": "mine_types", + "query": { + "nested": { + "path": "mine_types.mine_type_details", + "query": {"terms": {"mine_types.mine_type_details.mine_commodity_code.keyword": filters['mine_commodity']}} + } + } + } + }) + + if filters.get('permit_status'): + clauses.append({"terms": {"permit_status_code.keyword": filters['permit_status']}}) + + if filters.get('mine_operation_status'): + clauses.append({ + "nested": { + "path": "mine_status", + "query": { + "nested": { + "path": "mine_status.status_xref", + "query": {"terms": {"mine_status.status_xref.mine_operation_status_code.keyword": filters['mine_operation_status']}} + } + } + } + }) + + if filters.get('has_tsf'): + for tsf_filter in filters['has_tsf']: + if tsf_filter == 'Has TSF': + clauses.append({ + "nested": { + "path": "tailings_storage_facilities", + "query": {"exists": {"field": "tailings_storage_facilities.mine_tailings_storage_facility_guid"}} + } + }) + elif tsf_filter == 'No TSF': + clauses.append({ + "bool": { + "must_not": { + "nested": { + "path": "tailings_storage_facilities", + "query": {"exists": {"field": "tailings_storage_facilities.mine_tailings_storage_facility_guid"}} + } + } + } + }) + + if filters.get('verified_status'): + for status in filters['verified_status']: + is_verified = status == 'Verified' + clauses.append({ + "nested": { + "path": "verified_status", + "query": {"term": {"verified_status.healthy_ind": is_verified}} + } + }) + + # Permit filters + if filters.get('is_exploration'): + exploration_values = [] + for exp in filters['is_exploration']: + if exp == 'Exploration': + exploration_values.append(True) + elif exp == 'Non-Exploration': + exploration_values.append(False) + if exploration_values: + clauses.append({"terms": {"is_exploration": exploration_values}}) + + # Party filters + if filters.get('party_type'): + type_codes = [] + for pt in filters['party_type']: + if pt == 'Organization': + type_codes.append('ORG') + elif pt == 'Person': + type_codes.append('PER') + else: + type_codes.append(pt) + if type_codes: + clauses.append({"terms": {"party_type_code.keyword": type_codes}}) + + # Explosives permit filters + if filters.get('explosives_permit_status'): + clauses.append({"terms": {"application_status.keyword": filters['explosives_permit_status']}}) + + if filters.get('explosives_permit_closed'): + closed_values = [] + for closed in filters['explosives_permit_closed']: + if closed == 'Closed': + closed_values.append(True) + elif closed == 'Open': + closed_values.append(False) + if closed_values: + clauses.append({"terms": {"is_closed": closed_values}}) + + # NOD filters + if filters.get('nod_type'): + clauses.append({"terms": {"nod_type.keyword": filters['nod_type']}}) + + if filters.get('nod_status'): + clauses.append({"terms": {"nod_status.keyword": filters['nod_status']}}) + + # NoW filters + if filters.get('now_application_status'): + clauses.append({ + "nested": { + "path": "application", + "query": {"terms": {"application.now_application_status_code.keyword": filters['now_application_status']}} + } + }) + + if filters.get('now_type'): + clauses.append({ + "nested": { + "path": "application", + "query": {"terms": {"application.notice_of_work_type_code.keyword": filters['now_type']}} + } + }) + + return clauses + + +def build_search_query(search_term, filter_clauses): + """Build the complete ES search query.""" + return { + "query": { + "bool": { + "must": [{"multi_match": {"query": search_term, "fields": ["*"], "fuzziness": "AUTO"}}], + "filter": filter_clauses + } + }, + "aggs": ES_AGGREGATIONS + } + + +def extract_facets(aggs): + """Extract facet data from ES aggregations.""" + facets = { + # Mine facets + 'mine_region': [], + 'mine_classification': [], + 'mine_operation_status': [], + 'mine_tenure': [], + 'mine_commodity': [], + 'has_tsf': [], + 'verified_status': [], + # Permit facets + 'permit_status': [], + 'is_exploration': [], + # Party facets + 'party_type': [], + # Explosives permit facets + 'explosives_permit_status': [], + 'explosives_permit_closed': [], + # NOD facets + 'nod_type': [], + 'nod_status': [], + # NoW facets + 'now_application_status': [], + 'now_type': [], + # Type facets + 'type': [] + } + + # Type facets (by index) + for bucket in aggs.get('by_index', {}).get('buckets', []): + type_name = INDEX_TO_TYPE.get(bucket['key'], bucket['key']) + facets['type'].append({'key': type_name, 'count': bucket['doc_count']}) + + # Mine region + for bucket in aggs.get('mine_region', {}).get('buckets', []): + if bucket['key'] != 'Unknown': + facets['mine_region'].append({'key': bucket['key'], 'count': bucket['doc_count']}) + + # Classification (major vs regional) + for bucket in aggs.get('major_mine_ind', {}).get('buckets', []): + is_major = bucket.get('key_as_string') == 'true' or bucket['key'] is True + facets['mine_classification'].append({ + 'key': 'Major Mine' if is_major else 'Regional Mine', + 'count': bucket['doc_count'] + }) + + # Operation status (nested) + for bucket in aggs.get('mine_operation_status', {}).get('status_codes', {}).get('codes', {}).get('buckets', []): + facets['mine_operation_status'].append({'key': bucket['key'], 'count': bucket['doc_count']}) + + # Tenure (nested) + for bucket in aggs.get('mine_tenure', {}).get('tenure_codes', {}).get('buckets', []): + facets['mine_tenure'].append({'key': bucket['key'], 'count': bucket['doc_count']}) + + # Commodity (nested) + for bucket in aggs.get('mine_commodity', {}).get('details', {}).get('commodity_codes', {}).get('buckets', []): + if bucket['key']: + facets['mine_commodity'].append({'key': bucket['key'], 'count': bucket['doc_count']}) + + # TSF + tsf_count = aggs.get('has_tsf', {}).get('count', {}).get('value', 0) + total_mines = sum(b['doc_count'] for b in aggs.get('by_index', {}).get('buckets', []) if b['key'] == 'mines') + if tsf_count > 0: + facets['has_tsf'].append({'key': 'Has TSF', 'count': tsf_count}) + if total_mines > tsf_count: + facets['has_tsf'].append({'key': 'No TSF', 'count': total_mines - tsf_count}) + + # Verified status (nested) + for bucket in aggs.get('verified_status', {}).get('healthy', {}).get('buckets', []): + is_verified = bucket.get('key_as_string') == 'true' or bucket['key'] is True + facets['verified_status'].append({ + 'key': 'Verified' if is_verified else 'Unverified', + 'count': bucket['doc_count'] + }) + + # Permit status + for bucket in aggs.get('permit_status', {}).get('buckets', []): + facets['permit_status'].append({'key': bucket['key'], 'count': bucket['doc_count']}) + + # Is exploration (permit) + for bucket in aggs.get('is_exploration', {}).get('buckets', []): + is_exp = bucket.get('key_as_string') == 'true' or bucket['key'] is True + facets['is_exploration'].append({ + 'key': 'Exploration' if is_exp else 'Non-Exploration', + 'count': bucket['doc_count'] + }) + + # Party type + for bucket in aggs.get('party_type', {}).get('buckets', []): + label = 'Organization' if bucket['key'] == 'ORG' else 'Person' if bucket['key'] == 'PER' else bucket['key'] + facets['party_type'].append({'key': label, 'count': bucket['doc_count']}) + + # Explosives permit status + for bucket in aggs.get('explosives_permit_status', {}).get('buckets', []): + facets['explosives_permit_status'].append({'key': bucket['key'], 'count': bucket['doc_count']}) + + # Explosives permit closed + for bucket in aggs.get('explosives_permit_closed', {}).get('buckets', []): + is_closed = bucket.get('key_as_string') == 'true' or bucket['key'] is True + facets['explosives_permit_closed'].append({ + 'key': 'Closed' if is_closed else 'Open', + 'count': bucket['doc_count'] + }) + + # NOD type + for bucket in aggs.get('nod_type', {}).get('buckets', []): + facets['nod_type'].append({'key': bucket['key'], 'count': bucket['doc_count']}) + + # NOD status + for bucket in aggs.get('nod_status', {}).get('buckets', []): + facets['nod_status'].append({'key': bucket['key'], 'count': bucket['doc_count']}) + + # NoW application status (nested) + for bucket in aggs.get('now_application_status', {}).get('status_codes', {}).get('buckets', []): + facets['now_application_status'].append({'key': bucket['key'], 'count': bucket['doc_count']}) + + # NoW type (nested) + for bucket in aggs.get('now_type', {}).get('type_codes', {}).get('buckets', []): + facets['now_type'].append({'key': bucket['key'], 'count': bucket['doc_count']}) + + return facets + + +def group_hits_by_type(hits): + """Group ES hits by document type.""" + grouped = {} + for hit in hits: + doc_type = INDEX_TO_TYPE.get(hit['_index']) + if doc_type: + grouped.setdefault(doc_type, []).append(hit) + return grouped + + +def fetch_db_results(doc_type, es_hits): + """Fetch full records from DB for ES hits.""" + if doc_type not in search_targets or not search_targets[doc_type].get('primary_column'): + return [] + + config = search_targets[doc_type] + id_field = config['id_field'] + + # Extract IDs and scores from ES hits + results = [] + for hit in es_hits: + doc_id = hit['_source'].get(id_field) + if doc_id: + results.append({'id': doc_id, 'score': hit['_score'], 'type': doc_type}) + + if not results: + return [] + + # Query DB for full records + ids = [r['id'] for r in results] + try: + uuid_ids = [UUID(id) for id in ids] + except (ValueError, TypeError) as e: + current_app.logger.error(f"UUID conversion error for {doc_type}: {e}") + uuid_ids = ids + + db_records = db.session.query(config['model']).filter(config['primary_column'].in_(uuid_ids)).all() + db_map = {str(getattr(r, id_field)): r for r in db_records} + + # Merge ES scores with DB records + return [ + {'score': r['score'], 'type': r['type'], 'result': db_map[r['id']]} + for r in results if r['id'] in db_map + ] class SearchOptionsResource(Resource, UserMixin): @requires_role_view_all def get(self): - options = [] - for type, type_config in search_targets.items(): - options.append({'model_id': type, 'description': type_config['description']}) - - return options + return [ + {'model_id': type_key, 'description': config['description']} + for type_key, config in search_targets.items() + ] class SearchResource(Resource, UserMixin): @requires_role_view_all @api.marshal_with(SEARCH_RESULT_RETURN_MODEL, 200) def get(self): - search_term = request.args.get('search_term', None, type=str) + search_term = request.args.get('search_term', '', type=str) search_types = request.args.get('search_types', None, type=str) search_types = search_types.split(',') if search_types else list(search_targets.keys()) - - # Split incoming search query by space to search by individual words - reg_exp = regex.compile(r'\'.*?\' | ".*?" | \S+ ', regex.VERBOSE) - search_terms = reg_exp.findall(search_term) - search_terms = [term.replace('"', '') for term in search_terms] - - all_search_results = {} - type_to_index = { - 'mine': 'mines', - 'party': 'parties', - 'permit': 'permits', - 'mine_documents': 'documents', - 'notice_of_departure': 'notices_of_departure', - 'explosives_permit': 'explosives_permits' - # 'permit_documents': 'documents' # TODO: Add permit documents to ES index + # Parse filter parameters + filters = { + # Mine filters + 'mine_region': parse_csv_param(request.args.get('mine_region')), + 'mine_classification': parse_csv_param(request.args.get('mine_classification')), + 'mine_operation_status': parse_csv_param(request.args.get('mine_operation_status')), + 'mine_tenure': parse_csv_param(request.args.get('mine_tenure')), + 'mine_commodity': parse_csv_param(request.args.get('mine_commodity')), + 'has_tsf': parse_csv_param(request.args.get('has_tsf')), + 'verified_status': parse_csv_param(request.args.get('verified_status')), + # Permit filters + 'permit_status': parse_csv_param(request.args.get('permit_status')), + 'is_exploration': parse_csv_param(request.args.get('is_exploration')), + # Party filters + 'party_type': parse_csv_param(request.args.get('party_type')), + # Explosives permit filters + 'explosives_permit_status': parse_csv_param(request.args.get('explosives_permit_status')), + 'explosives_permit_closed': parse_csv_param(request.args.get('explosives_permit_closed')), + # NOD filters + 'nod_type': parse_csv_param(request.args.get('nod_type')), + 'nod_status': parse_csv_param(request.args.get('nod_status')), + # NoW filters + 'now_application_status': parse_csv_param(request.args.get('now_application_status')), + 'now_type': parse_csv_param(request.args.get('now_type')), } - index_to_type = {v: k for k, v in type_to_index.items()} - - # Initialize facets - facets = { - 'mine_region': [], - 'mine_classification': [], - 'mine_operation_status': [], - 'mine_tenure': [], - 'mine_commodity': [], - 'has_tsf': [], - 'verified_status': [], - 'permit_status': [], - 'type': [] - } + # Parse search terms + reg_exp = regex.compile(r'\'.*?\' | ".*?" | \S+ ', regex.VERBOSE) + search_terms = [term.replace('"', '') for term in reg_exp.findall(search_term)] + + # Initialize results + all_results = {t: [] for t in search_types} + facets = {k: [] for k in [ + 'mine_region', 'mine_classification', 'mine_operation_status', + 'mine_tenure', 'mine_commodity', 'has_tsf', 'verified_status', + 'permit_status', 'is_exploration', 'party_type', + 'explosives_permit_status', 'explosives_permit_closed', + 'nod_type', 'nod_status', 'now_application_status', 'now_type', 'type' + ]} - indices = [] - for type in search_types: - if type in type_to_index: - indices.append(type_to_index[type]) + # Build indices to search + indices = [TYPE_TO_INDEX[t] for t in search_types if t in TYPE_TO_INDEX] + if not indices: + return {'search_terms': search_terms, 'search_results': all_results, 'facets': facets} - if indices: - indices_string = ",".join(list(set(indices))) + try: + # Execute search + query = build_search_query(search_term, build_filter_clauses(filters)) + current_app.logger.info(f"Searching ES indices: {','.join(indices)} for: {search_term}") - # Construct query with aggregations for facets - query = { - "query": { - "bool": { - "must": [ - { - "multi_match": { - "query": search_term, - "fields": ["*"], - "fuzziness": "AUTO" - } - } - ], - "filter": [ - {"term": {"deleted_ind": False}} - ] - } - }, - "aggs": { - "by_index": { - "terms": {"field": "_index", "size": 10} - }, - "mine_region": { - "terms": {"field": "mine_region.keyword", "size": 20, "missing": "Unknown"} - }, - "major_mine_ind": { - "terms": {"field": "major_mine_ind", "size": 10} - }, - "mine_operation_status": { - "nested": {"path": "mine_status"}, - "aggs": { - "status_codes": { - "nested": {"path": "mine_status.status_xref"}, - "aggs": { - "codes": { - "terms": {"field": "mine_status.status_xref.mine_operation_status_code.keyword", "size": 20} - } - } - } - } - }, - "mine_tenure": { - "nested": {"path": "mine_types"}, - "aggs": { - "tenure_codes": { - "terms": {"field": "mine_types.mine_tenure_type_code.keyword", "size": 20} - } - } - }, - "mine_commodity": { - "nested": {"path": "mine_types"}, - "aggs": { - "details": { - "nested": {"path": "mine_types.mine_type_details"}, - "aggs": { - "commodity_codes": { - "terms": {"field": "mine_types.mine_type_details.mine_commodity_code.keyword", "size": 30} - } - } - } - } - }, - "has_tsf": { - "nested": {"path": "tailings_storage_facilities"}, - "aggs": { - "count": { - "value_count": {"field": "tailings_storage_facilities.mine_tailings_storage_facility_guid"} - } - } - }, - "verified_status": { - "nested": {"path": "verified_status"}, - "aggs": { - "healthy": { - "terms": {"field": "verified_status.healthy_ind", "size": 10} - } - } - }, - "permit_status": { - "terms": {"field": "permit_status_code.keyword", "size": 20} - } - } - } + es_results = ElasticSearchService.search(','.join(set(indices)), query, size=200) + hits = es_results['hits']['hits'] + current_app.logger.info(f"ES returned {len(hits)} hits") - try: - current_app.logger.info(f"Searching ES indices: {indices_string} for term: {search_term}") - es_results = ElasticSearchService.search(indices_string, query, size=200) - hits = es_results['hits']['hits'] - current_app.logger.info(f"ES returned {len(hits)} hits") - - # Process aggregations for facets - aggs = es_results.get('aggregations', {}) - - # Type facets (by index) - for bucket in aggs.get('by_index', {}).get('buckets', []): - index_name = bucket['key'] - type_name = index_to_type.get(index_name, index_name) - facets['type'].append({'key': type_name, 'count': bucket['doc_count']}) - - # Mine region facets - for bucket in aggs.get('mine_region', {}).get('buckets', []): - if bucket['key'] != 'Unknown': - facets['mine_region'].append({'key': bucket['key'], 'count': bucket['doc_count']}) - - # Mine classification facets (major vs regional) - for bucket in aggs.get('major_mine_ind', {}).get('buckets', []): - label = 'Major Mine' if bucket.get('key_as_string') == 'true' or bucket['key'] == True else 'Regional Mine' - facets['mine_classification'].append({'key': label, 'count': bucket['doc_count']}) - - # Mine operation status facets (nested) - status_agg = aggs.get('mine_operation_status', {}).get('status_codes', {}).get('codes', {}) - for bucket in status_agg.get('buckets', []): - facets['mine_operation_status'].append({'key': bucket['key'], 'count': bucket['doc_count']}) - - # Mine tenure facets (nested) - tenure_agg = aggs.get('mine_tenure', {}).get('tenure_codes', {}) - for bucket in tenure_agg.get('buckets', []): - facets['mine_tenure'].append({'key': bucket['key'], 'count': bucket['doc_count']}) - - # Mine commodity facets (nested) - commodity_agg = aggs.get('mine_commodity', {}).get('details', {}).get('commodity_codes', {}) - for bucket in commodity_agg.get('buckets', []): - if bucket['key']: # Skip empty commodity codes - facets['mine_commodity'].append({'key': bucket['key'], 'count': bucket['doc_count']}) - - # TSF facets - count mines with TSF - tsf_count = aggs.get('has_tsf', {}).get('count', {}).get('value', 0) - total_mines = sum(b['doc_count'] for b in aggs.get('by_index', {}).get('buckets', []) if b['key'] == 'mines') - if tsf_count > 0: - facets['has_tsf'].append({'key': 'Has TSF', 'count': tsf_count}) - if total_mines > tsf_count: - facets['has_tsf'].append({'key': 'No TSF', 'count': total_mines - tsf_count}) - - # Verified status facets (nested) - verified_agg = aggs.get('verified_status', {}).get('healthy', {}) - for bucket in verified_agg.get('buckets', []): - label = 'Verified' if bucket.get('key_as_string') == 'true' or bucket['key'] == True else 'Unverified' - facets['verified_status'].append({'key': label, 'count': bucket['doc_count']}) - - # Permit status facets - for bucket in aggs.get('permit_status', {}).get('buckets', []): - facets['permit_status'].append({'key': bucket['key'], 'count': bucket['doc_count']}) - - grouped_hits = {} - for hit in hits: - index = hit['_index'] - type = index_to_type.get(index) - if not type: - continue - - if type not in grouped_hits: - grouped_hits[type] = [] - grouped_hits[type].append(hit) + # Extract facets + facets = extract_facets(es_results.get('aggregations', {})) + + # Process hits by type + for doc_type, type_hits in group_hits_by_type(hits).items(): + all_results[doc_type] = fetch_db_results(doc_type, type_hits) - for type, hits in grouped_hits.items(): - results = [] - for hit in hits: - source = hit['_source'] - score = hit['_score'] - - id_field = search_targets[type]['id_field'] - id = source.get(id_field) - - if id: - results.append({ - 'score': score, - 'type': type, - 'id': id - }) - - if not results: - all_search_results[type] = [] - continue - - ids = [r['id'] for r in results] - - if type in search_targets and search_targets[type].get('primary_column'): - model = search_targets[type]['model'] - primary_column = search_targets[type]['primary_column'] - - # Convert string IDs to UUIDs for database query - try: - uuid_ids = [UUID(id) for id in ids if id] - except (ValueError, TypeError) as e: - current_app.logger.error(f"UUID conversion error for {type}: {e}") - uuid_ids = ids # Fall back to string IDs - - db_results = db.session.query(model).filter(primary_column.in_(uuid_ids)).all() - current_app.logger.info(f"DB query for {type} returned {len(db_results)} results from {len(uuid_ids)} IDs") - db_results_map = {str(getattr(r, search_targets[type]['id_field'])): r for r in db_results} - - final_results = [] - for r in results: - if r['id'] in db_results_map: - final_results.append({ - 'score': r['score'], - 'type': r['type'], - 'result': db_results_map[r['id']] - }) - - all_search_results[type] = final_results - else: - all_search_results[type] = [] - - except Exception as e: - current_app.logger.error(f"Elasticsearch error: {e}") - # If the single query fails, we might want to return empty results for all requested types - for type in search_types: - if type not in all_search_results: - all_search_results[type] = [] - - # Ensure all requested types are in the result, even if empty - for type in search_types: - if type not in all_search_results: - all_search_results[type] = [] - - return {'search_terms': search_terms, 'search_results': all_search_results, 'facets': facets} + except Exception as e: + current_app.logger.error(f"Elasticsearch error: {e}") + + return {'search_terms': search_terms, 'search_results': all_results, 'facets': facets} diff --git a/services/core-api/app/api/utils/search.py b/services/core-api/app/api/utils/search.py index ee103bed75..4a6385b74a 100644 --- a/services/core-api/app/api/utils/search.py +++ b/services/core-api/app/api/utils/search.py @@ -175,6 +175,17 @@ 'id_field': 'explosives_permit_guid', 'value_field': 'permit_number', 'score_multiplier': 500 + }, + 'now_application': { + 'model': NOWApplicationIdentity, + 'primary_column': NOWApplicationIdentity.now_application_guid, + 'description': 'Notice of Work Applications', + 'entities_to_return': [NOWApplicationIdentity.now_application_guid, NOWApplicationIdentity.now_number], + 'columns_to_search': [NOWApplicationIdentity.now_number], + 'has_deleted_ind': False, + 'id_field': 'now_application_guid', + 'value_field': 'now_number', + 'score_multiplier': 500 } } diff --git a/services/pgsync/schema.json b/services/pgsync/schema.json index 4796c01c2b..f491c09cd0 100644 --- a/services/pgsync/schema.json +++ b/services/pgsync/schema.json @@ -16,27 +16,42 @@ "children": [ { "table": "mine_status", - "columns": ["mine_status_guid", "effective_date"], + "columns": [ + "mine_status_guid", + "effective_date" + ], "label": "mine_status", "relationship": { "type": "one_to_many", "variant": "object", "foreign_key": { - "child": ["mine_guid"], - "parent": ["mine_guid"] + "child": [ + "mine_guid" + ], + "parent": [ + "mine_guid" + ] } }, "children": [ { "table": "mine_status_xref", - "columns": ["mine_operation_status_code", "mine_operation_status_reason_code", "description"], + "columns": [ + "mine_operation_status_code", + "mine_operation_status_reason_code", + "description" + ], "label": "status_xref", "relationship": { "type": "one_to_one", "variant": "object", "foreign_key": { - "child": ["mine_status_xref_guid"], - "parent": ["mine_status_xref_guid"] + "child": [ + "mine_status_xref_guid" + ], + "parent": [ + "mine_status_xref_guid" + ] } } } @@ -44,27 +59,43 @@ }, { "table": "mine_type", - "columns": ["mine_type_guid", "mine_tenure_type_code", "active_ind"], + "columns": [ + "mine_type_guid", + "mine_tenure_type_code", + "active_ind" + ], "label": "mine_types", "relationship": { "type": "one_to_many", "variant": "object", "foreign_key": { - "child": ["mine_guid"], - "parent": ["mine_guid"] + "child": [ + "mine_guid" + ], + "parent": [ + "mine_guid" + ] } }, "children": [ { "table": "mine_type_detail_xref", - "columns": ["mine_commodity_code", "mine_disturbance_code", "active_ind"], + "columns": [ + "mine_commodity_code", + "mine_disturbance_code", + "active_ind" + ], "label": "mine_type_details", "relationship": { "type": "one_to_many", "variant": "object", "foreign_key": { - "child": ["mine_type_guid"], - "parent": ["mine_type_guid"] + "child": [ + "mine_type_guid" + ], + "parent": [ + "mine_type_guid" + ] } } } @@ -72,42 +103,78 @@ }, { "table": "mine_verified_status", - "columns": ["healthy_ind", "verifying_timestamp"], + "columns": [ + "healthy_ind", + "verifying_timestamp" + ], "label": "verified_status", "relationship": { "type": "one_to_one", "variant": "object", "foreign_key": { - "child": ["mine_guid"], - "parent": ["mine_guid"] + "child": [ + "mine_guid" + ], + "parent": [ + "mine_guid" + ] } } }, { "table": "mine_work_information", - "columns": ["work_start_date", "work_stop_date", "deleted_ind"], + "columns": [ + "work_start_date", + "work_stop_date", + "deleted_ind" + ], "label": "work_information", "relationship": { "type": "one_to_many", "variant": "object", "foreign_key": { - "child": ["mine_guid"], - "parent": ["mine_guid"] + "child": [ + "mine_guid" + ], + "parent": [ + "mine_guid" + ] } } }, { "table": "mine_tailings_storage_facility", - "columns": ["mine_tailings_storage_facility_guid", "mine_tailings_storage_facility_name"], + "columns": [ + "mine_tailings_storage_facility_guid", + "mine_tailings_storage_facility_name" + ], "label": "tailings_storage_facilities", "relationship": { "type": "one_to_many", "variant": "object", "foreign_key": { - "child": ["mine_guid"], - "parent": ["mine_guid"] + "child": [ + "mine_guid" + ], + "parent": [ + "mine_guid" + ] } } + }, + { + "table": "permit", + "columns": [ + "permit_guid", + "permit_no", + "permit_status_code" + ], + "label": "permits", + "relationship": { + "type": "one_to_many", + "variant": "object", + "through_tables": ["mine_permit_xref"] + } } ] } @@ -143,16 +210,37 @@ "children": [ { "table": "mine_permit_xref", - "columns": ["mine_guid"], + "columns": [ + "mine_guid" + ], "label": "mine_guids", "relationship": { "type": "one_to_many", "variant": "scalar", "foreign_key": { - "child": ["permit_id"], - "parent": ["permit_id"] + "child": [ + "permit_id" + ], + "parent": [ + "permit_id" + ] } } + }, + { + "table": "party", + "columns": [ + "party_guid", + "first_name", + "party_name", + "email" + ], + "label": "permittees", + "relationship": { + "type": "one_to_many", + "variant": "object", + "through_tables": ["mine_party_appt"] + } } ] } @@ -178,14 +266,22 @@ "children": [ { "table": "mine", - "columns": ["mine_guid", "mine_name", "mine_no"], + "columns": [ + "mine_guid", + "mine_name", + "mine_no" + ], "label": "mine", "relationship": { "type": "one_to_one", "variant": "object", "foreign_key": { - "child": ["mine_guid"], - "parent": ["mine_guid"] + "child": [ + "mine_guid" + ], + "parent": [ + "mine_guid" + ] } } } @@ -207,13 +303,19 @@ "children": [ { "table": "mine", - "columns": ["mine_name"], + "columns": [ + "mine_name" + ], "relationship": { "type": "one_to_one", "variant": "object", "foreign_key": { - "child": ["mine_guid"], - "parent": ["mine_guid"] + "child": [ + "mine_guid" + ], + "parent": [ + "mine_guid" + ] } } } @@ -240,27 +342,40 @@ "children": [ { "table": "mine", - "columns": ["mine_name", "mine_no"], + "columns": [ + "mine_name", + "mine_no" + ], "label": "mine", "relationship": { "type": "one_to_one", "variant": "object", "foreign_key": { - "child": ["mine_guid"], - "parent": ["mine_guid"] + "child": [ + "mine_guid" + ], + "parent": [ + "mine_guid" + ] } } }, { "table": "permit", - "columns": ["permit_no"], + "columns": [ + "permit_no" + ], "label": "permit", "relationship": { "type": "one_to_one", "variant": "object", "foreign_key": { - "child": ["permit_guid"], - "parent": ["permit_guid"] + "child": [ + "permit_guid" + ], + "parent": [ + "permit_guid" + ] } } } @@ -294,21 +409,33 @@ "type": "one_to_one", "variant": "object", "foreign_key": { - "child": ["now_application_id"], - "parent": ["now_application_id"] + "child": [ + "now_application_id" + ], + "parent": [ + "now_application_id" + ] } } }, { "table": "mine", - "columns": ["mine_guid", "mine_name", "mine_no"], + "columns": [ + "mine_guid", + "mine_name", + "mine_no" + ], "label": "mine", "relationship": { "type": "one_to_one", "variant": "object", "foreign_key": { - "child": ["mine_guid"], - "parent": ["mine_guid"] + "child": [ + "mine_guid" + ], + "parent": [ + "mine_guid" + ] } } } From 722d4042a382eb407bcdecc7b0dedd668dd1b7fc Mon Sep 17 00:00:00 2001 From: Simen Fivelstad Smaaberg Date: Thu, 15 Jan 2026 07:22:00 -0800 Subject: [PATCH 07/25] Cleanup of search experience --- .../search/searchResult.interface.ts | 33 ++ .../src/redux/reducers/searchReducer.ts | 4 +- .../app/api/search/response_models.py | 53 +- .../search/search/global_search_service.py | 163 ++++++ .../app/api/search/search/resources/search.py | 517 ++---------------- .../search/search/resources/simple_search.py | 81 ++- .../app/api/search/search/search_constants.py | 114 ++++ .../app/api/search/search/search_facets.py | 130 +++++ .../app/api/search/search/search_filters.py | 148 +++++ .../api/search/search/search_transformers.py | 254 +++++++++ .../src/components/homepage/HomeBanner.tsx | 63 ++- .../components/search/GenericResultsTable.js | 48 ++ .../src/components/search/GlobalSearch.tsx | 108 ++-- .../components/search/PermitResultsTable.js | 8 +- .../src/components/search/SearchResultsV2.tsx | 508 ++++++++++++----- .../src/styles/components/GlobalSearch.scss | 38 +- .../src/styles/components/HomePage.scss | 13 - .../src/styles/components/SearchResults.scss | 41 ++ .../__snapshots__/GlobalSearch.spec.tsx.snap | 37 ++ services/pgsync/Dockerfile | 2 +- services/pgsync/schema.json | 57 +- services/postgres/postgresql.conf | 4 +- 22 files changed, 1672 insertions(+), 752 deletions(-) create mode 100644 services/core-api/app/api/search/search/global_search_service.py create mode 100644 services/core-api/app/api/search/search/search_constants.py create mode 100644 services/core-api/app/api/search/search/search_facets.py create mode 100644 services/core-api/app/api/search/search/search_filters.py create mode 100644 services/core-api/app/api/search/search/search_transformers.py create mode 100644 services/core-web/src/components/search/GenericResultsTable.js create mode 100644 services/core-web/src/styles/components/SearchResults.scss create mode 100644 services/core-web/src/tests/components/search/__snapshots__/GlobalSearch.spec.tsx.snap diff --git a/services/common/src/interfaces/search/searchResult.interface.ts b/services/common/src/interfaces/search/searchResult.interface.ts index 06d800ec48..d78f378b16 100644 --- a/services/common/src/interfaces/search/searchResult.interface.ts +++ b/services/common/src/interfaces/search/searchResult.interface.ts @@ -15,6 +15,36 @@ export interface ISimpleSearchResult { value: string; description?: string; highlight?: string; + mine_guid?: string; +} + +export interface IExplosivesPermitSearchResult { + explosives_permit_guid: string; + explosives_permit_id: string; + application_number: string; + application_status: string; + mine_guid: string; + mine_name: string; + is_closed: boolean; +} + +export interface INowApplicationSearchResult { + now_application_guid: string; + now_number: string; + mine_guid: string; + mine_name: string; + now_application_status_code: string; + notice_of_work_type_code: string; +} + +export interface INodSearchResult { + nod_guid: string; + nod_no: string; + nod_title: string; + mine_guid: string; + mine_name: string; + nod_type: string; + nod_status: string; } export interface ISearchResultList { @@ -23,4 +53,7 @@ export interface ISearchResultList { party: ISearchResult[], permit: ISearchResult[], permit_documents: ISearchResult[], + explosives_permit: ISearchResult[], + now_application: ISearchResult[], + notice_of_departure: ISearchResult[], } \ No newline at end of file diff --git a/services/common/src/redux/reducers/searchReducer.ts b/services/common/src/redux/reducers/searchReducer.ts index 8ee80f56b9..4986a7ba2b 100644 --- a/services/common/src/redux/reducers/searchReducer.ts +++ b/services/common/src/redux/reducers/searchReducer.ts @@ -37,7 +37,7 @@ const initialState = { type: [] }, searchBarResults: [], - searchBarFacets: { mine: 0, person: 0, organization: 0, permit: 0, nod: 0, explosives_permit: 0 }, + searchBarFacets: { mine: 0, person: 0, organization: 0, permit: 0, nod: 0, explosives_permit: 0, now_application: 0, mine_documents: 0, permit_documents: 0 }, searchTerms: [], searchSubsetResults: [], }; @@ -88,7 +88,7 @@ export const getSearchOptions = (state) => state[SEARCH].searchOptions; export const getSearchResults = (state): ISearchResultList => state[SEARCH].searchResults; export const getSearchFacets = (state) => state[SEARCH].searchFacets; export const getSearchBarResults = (state): ISearchResult[] => state[SEARCH].searchBarResults; -export const getSearchBarFacets = (state): { mine: number; person: number; organization: number; permit: number; nod: number; explosives_permit: number } => state[SEARCH].searchBarFacets; +export const getSearchBarFacets = (state): { mine: number; person: number; organization: number; permit: number; nod: number; explosives_permit: number; now_application: number } => state[SEARCH].searchBarFacets; export const getSearchTerms = (state) => state[SEARCH].searchTerms; export const getSearchSubsetResults = (state) => state[SEARCH].searchSubsetResults; diff --git a/services/core-api/app/api/search/response_models.py b/services/core-api/app/api/search/response_models.py index 02c4f1697f..ea9ca1eabc 100644 --- a/services/core-api/app/api/search/response_models.py +++ b/services/core-api/app/api/search/response_models.py @@ -21,6 +21,7 @@ 'value': fields.String, 'description': fields.String, 'highlight': fields.String, + 'mine_guid': fields.String, }) MINE_MODEL = api.model('Mine_simple ', { @@ -31,7 +32,7 @@ PERMIT_SEARCH_MODEL = api.model( 'Permit', { 'permit_guid': fields.String, - 'mine': fields.List(fields.Nested(MINE_MODEL), attribute=lambda x: x._all_mines), + 'mine': fields.List(fields.Nested(MINE_MODEL), attribute=lambda x: x.get('mine', []) if isinstance(x, dict) else x._all_mines), 'permit_no': fields.String, 'current_permittee': fields.String, }) @@ -42,7 +43,7 @@ 'start_date': fields.Date, 'end_date': fields.Date, 'mine': fields.Nested(MINE_MODEL), - 'permit_no': fields.String(attribute='permit.permit_no'), + 'permit_no': fields.String(attribute=lambda x: x.get('permit_no') if isinstance(x, dict) else (x.permit.permit_no if hasattr(x, 'permit') and x.permit else None)), }) MINE_STATUS_MODEL = api.model('MineStatus', { @@ -136,6 +137,50 @@ 'result': fields.Nested(SIMPLE_SEARCH_MODEL), }) +EXPLOSIVES_PERMIT_SEARCH_MODEL = api.model( + 'ExplosivesPermit', { + 'explosives_permit_guid': fields.String, + 'explosives_permit_id': fields.String, + 'application_number': fields.String, + 'application_status': fields.String, + 'mine_guid': fields.String, + 'mine_name': fields.String, + 'is_closed': fields.Boolean, + }) + +NOW_APPLICATION_SEARCH_MODEL = api.model( + 'NowApplication', { + 'now_application_guid': fields.String, + 'now_number': fields.String, + 'mine_guid': fields.String, + 'mine_name': fields.String, + 'now_application_status_code': fields.String, + 'notice_of_work_type_code': fields.String, + }) + +NOD_SEARCH_MODEL = api.model( + 'NoticeOfDeparture', { + 'nod_guid': fields.String, + 'nod_no': fields.String, + 'nod_title': fields.String, + 'mine_guid': fields.String, + 'mine_name': fields.String, + 'nod_type': fields.String, + 'nod_status': fields.String, + }) + +EXPLOSIVES_PERMIT_SEARCH_RESULT_MODEL = api.inherit('ExplosivesPermitSearchResult', SEARCH_RESULT_MODEL, { + 'result': fields.Nested(EXPLOSIVES_PERMIT_SEARCH_MODEL), +}) + +NOW_APPLICATION_SEARCH_RESULT_MODEL = api.inherit('NowApplicationSearchResult', SEARCH_RESULT_MODEL, { + 'result': fields.Nested(NOW_APPLICATION_SEARCH_MODEL), +}) + +NOD_SEARCH_RESULT_MODEL = api.inherit('NodSearchResult', SEARCH_RESULT_MODEL, { + 'result': fields.Nested(NOD_SEARCH_MODEL), +}) + SEARCH_RESULTS_LIST_MODEL = api.model( 'SearchResultList', { 'mine': fields.List(fields.Nested(MINE_SEARCH_RESULT_MODEL)), @@ -143,6 +188,9 @@ 'permit': fields.List(fields.Nested(PERMIT_SEARCH_RESULT_MODEL)), 'mine_documents': fields.List(fields.Nested(MINE_DOCUMENT_SEARCH_RESULT_MODEL)), 'permit_documents': fields.List(fields.Nested(PERMIT_DOCUMENT_SEARCH_RESULT_MODEL)), + 'explosives_permit': fields.List(fields.Nested(EXPLOSIVES_PERMIT_SEARCH_RESULT_MODEL)), + 'now_application': fields.List(fields.Nested(NOW_APPLICATION_SEARCH_RESULT_MODEL)), + 'notice_of_departure': fields.List(fields.Nested(NOD_SEARCH_RESULT_MODEL)), }) SEARCH_FACET_BUCKET_MODEL = api.model( @@ -194,6 +242,7 @@ 'permit': fields.Integer, 'nod': fields.Integer, 'explosives_permit': fields.Integer, + 'now_application': fields.Integer, }) SIMPLE_SEARCH_RESULT_RETURN_MODEL = api.model( diff --git a/services/core-api/app/api/search/search/global_search_service.py b/services/core-api/app/api/search/search/global_search_service.py new file mode 100644 index 0000000000..0c8194836f --- /dev/null +++ b/services/core-api/app/api/search/search/global_search_service.py @@ -0,0 +1,163 @@ +"""Global search service for executing searches against Elasticsearch.""" + +import regex +from flask import current_app + +from app.api.search.elasticsearch.elastic_search_service import ElasticSearchService +from .search_constants import TYPE_TO_INDEX, ES_AGGREGATIONS, FACET_KEYS, FILTER_PARAMS, SEARCH_FIELDS +from .search_filters import build_filter_clauses +from .search_facets import extract_facets +from .search_transformers import transform_es_results, enrich_party_appointments + + +def parse_csv_param(value): + """Parse comma-separated parameter into list.""" + return [v.strip() for v in value.split(',')] if value else [] + + +def parse_search_terms(search_term): + """Parse search term into individual terms.""" + reg_exp = regex.compile(r'\'.*?\' | ".*?" | \S+ ', regex.VERBOSE) + return [term.replace('"', '') for term in reg_exp.findall(search_term)] + + +def parse_filters(request_args): + """Parse filter parameters from request args.""" + return {param: parse_csv_param(request_args.get(param)) for param in FILTER_PARAMS} + + +def build_search_query(search_term, filter_clauses): + """Build the complete ES search query.""" + if not search_term or search_term == "*": + return { + "query": { + "bool": { + "must": [{"match_all": {}}], + "filter": filter_clauses + } + }, + "sort": [{"_score": "desc"}], + "aggs": ES_AGGREGATIONS + } + + # Highlight configuration (optional usage for now) + highlight_config = { + "fields": { + "mine_name": {}, + "mine_no": {}, + "mms_alias": {}, + "mine.mine_name": {}, + "mine.mine_no": {}, + "party_name": {}, + "first_name": {}, + "email": {}, + "permit_no": {}, + "permit_number": {}, + "nod_no": {}, + "nod_title": {}, + "nod_description": {}, + "now_number": {}, + "application.property_name": {}, + "document_name": {}, + "description": {}, + "application_number": {}, + }, + "pre_tags": [""], + "post_tags": [""], + "fragment_size": 150, + "number_of_fragments": 1 + } + + should_clauses = [ + { + "multi_match": { + "query": search_term, + "fields": SEARCH_FIELDS, + "type": "phrase_prefix" + } + } + ] + + # If search term is longer, add fuzzy match + if len(search_term) >= 3: + should_clauses.append({ + "multi_match": { + "query": search_term, + "fields": SEARCH_FIELDS, + "fuzziness": "AUTO" + } + }) + + return { + "query": { + "bool": { + "should": should_clauses, + "minimum_should_match": 1, + "filter": filter_clauses + } + }, + "highlight": highlight_config, + "aggs": ES_AGGREGATIONS + } + + +def get_empty_results(search_types): + """Get empty results structure.""" + return { + 'results': {t: [] for t in search_types}, + 'facets': {k: [] for k in FACET_KEYS} + } + + +class GlobalSearchService: + """Service for executing global searches.""" + + @staticmethod + def search(search_term, search_types, filters, size=200): + """ + Execute a global search. + + Args: + search_term: The search query string + search_types: List of types to search (e.g., ['mine', 'party', 'permit']) + filters: Dict of filter parameters + size: Maximum number of results to return + + Returns: + Dict with 'results' and 'facets' keys + """ + indices = [TYPE_TO_INDEX[t] for t in search_types if t in TYPE_TO_INDEX] + + if not indices: + return get_empty_results(search_types) + + try: + filter_clauses = build_filter_clauses(filters) + query = build_search_query(search_term, filter_clauses) + + current_app.logger.info(f"Searching ES indices: {','.join(indices)} for: {search_term}") + + es_results = ElasticSearchService.search(','.join(set(indices)), query, size=size) + hits = es_results['hits']['hits'] + + current_app.logger.info(f"ES returned {len(hits)} hits") + + facets = extract_facets(es_results.get('aggregations', {})) + results = transform_es_results(hits) + + # Enrich party results with mine/permit names for appointments + if 'party' in results and results['party']: + results['party'] = enrich_party_appointments(results['party']) + + # Ensure all requested types have entries + for t in search_types: + if t not in results: + results[t] = [] + + return {'results': results, 'facets': facets} + + except Exception as e: + current_app.logger.error(f"Elasticsearch error: {e}") + import traceback + current_app.logger.error(traceback.format_exc()) + return get_empty_results(search_types) diff --git a/services/core-api/app/api/search/search/resources/search.py b/services/core-api/app/api/search/search/resources/search.py index c31c66dcc7..0df70728d0 100644 --- a/services/core-api/app/api/search/search/resources/search.py +++ b/services/core-api/app/api/search/search/resources/search.py @@ -1,429 +1,22 @@ -import regex -from uuid import UUID -from flask import current_app, request +"""Search API resources.""" + +from flask import request from flask_restx import Resource -from app.api.search.elasticsearch.elastic_search_service import ElasticSearchService from app.api.search.response_models import SEARCH_RESULT_RETURN_MODEL from app.api.utils.access_decorators import requires_role_view_all from app.api.utils.resources_mixins import UserMixin from app.api.utils.search import search_targets -from app.extensions import api, db - - -TYPE_TO_INDEX = { - 'mine': 'mines', - 'party': 'parties', - 'permit': 'permits', - 'mine_documents': 'documents', - 'notice_of_departure': 'notices_of_departure', - 'explosives_permit': 'explosives_permits', - 'now_application': 'now_applications', -} - -INDEX_TO_TYPE = {v: k for k, v in TYPE_TO_INDEX.items()} - -ES_AGGREGATIONS = { - "by_index": {"terms": {"field": "_index", "size": 10}}, - # Mine facets - "mine_region": {"terms": {"field": "mine_region.keyword", "size": 20, "missing": "Unknown"}}, - "major_mine_ind": {"terms": {"field": "major_mine_ind", "size": 10}}, - "mine_operation_status": { - "nested": {"path": "mine_status"}, - "aggs": { - "status_codes": { - "nested": {"path": "mine_status.status_xref"}, - "aggs": { - "codes": {"terms": {"field": "mine_status.status_xref.mine_operation_status_code.keyword", "size": 20}} - } - } - } - }, - "mine_tenure": { - "nested": {"path": "mine_types"}, - "aggs": {"tenure_codes": {"terms": {"field": "mine_types.mine_tenure_type_code.keyword", "size": 20}}} - }, - "mine_commodity": { - "nested": {"path": "mine_types"}, - "aggs": { - "details": { - "nested": {"path": "mine_types.mine_type_details"}, - "aggs": {"commodity_codes": {"terms": {"field": "mine_types.mine_type_details.mine_commodity_code.keyword", "size": 30}}} - } - } - }, - "has_tsf": { - "nested": {"path": "tailings_storage_facilities"}, - "aggs": {"count": {"value_count": {"field": "tailings_storage_facilities.mine_tailings_storage_facility_guid"}}} - }, - "verified_status": { - "nested": {"path": "verified_status"}, - "aggs": {"healthy": {"terms": {"field": "verified_status.healthy_ind", "size": 10}}} - }, - # Permit facets - "permit_status": {"terms": {"field": "permit_status_code.keyword", "size": 20}}, - "is_exploration": {"terms": {"field": "is_exploration", "size": 10}}, - # Party facets - "party_type": {"terms": {"field": "party_type_code.keyword", "size": 10}}, - # Explosives permit facets - "explosives_permit_status": {"terms": {"field": "application_status.keyword", "size": 20}}, - "explosives_permit_closed": {"terms": {"field": "is_closed", "size": 10}}, - # Notice of departure facets - "nod_type": {"terms": {"field": "nod_type.keyword", "size": 20}}, - "nod_status": {"terms": {"field": "nod_status.keyword", "size": 20}}, - # NoW application facets - "now_application_status": { - "nested": {"path": "application"}, - "aggs": {"status_codes": {"terms": {"field": "application.now_application_status_code.keyword", "size": 20}}} - }, - "now_type": { - "nested": {"path": "application"}, - "aggs": {"type_codes": {"terms": {"field": "application.notice_of_work_type_code.keyword", "size": 20}}} - }, -} - - -def parse_csv_param(value): - """Parse comma-separated parameter into list.""" - return [v.strip() for v in value.split(',')] if value else [] - - -def build_filter_clauses(filters): - """Build ES filter clauses from filter parameters.""" - clauses = [{"term": {"deleted_ind": False}}] - - if filters.get('mine_region'): - clauses.append({"terms": {"mine_region.keyword": filters['mine_region']}}) - - if filters.get('mine_classification'): - major_values = [] - for c in filters['mine_classification']: - if c == 'Major Mine': - major_values.append(True) - elif c == 'Regional Mine': - major_values.append(False) - if major_values: - clauses.append({"terms": {"major_mine_ind": major_values}}) - - if filters.get('mine_tenure'): - clauses.append({ - "nested": { - "path": "mine_types", - "query": {"terms": {"mine_types.mine_tenure_type_code.keyword": filters['mine_tenure']}} - } - }) - - if filters.get('mine_commodity'): - clauses.append({ - "nested": { - "path": "mine_types", - "query": { - "nested": { - "path": "mine_types.mine_type_details", - "query": {"terms": {"mine_types.mine_type_details.mine_commodity_code.keyword": filters['mine_commodity']}} - } - } - } - }) - - if filters.get('permit_status'): - clauses.append({"terms": {"permit_status_code.keyword": filters['permit_status']}}) - - if filters.get('mine_operation_status'): - clauses.append({ - "nested": { - "path": "mine_status", - "query": { - "nested": { - "path": "mine_status.status_xref", - "query": {"terms": {"mine_status.status_xref.mine_operation_status_code.keyword": filters['mine_operation_status']}} - } - } - } - }) - - if filters.get('has_tsf'): - for tsf_filter in filters['has_tsf']: - if tsf_filter == 'Has TSF': - clauses.append({ - "nested": { - "path": "tailings_storage_facilities", - "query": {"exists": {"field": "tailings_storage_facilities.mine_tailings_storage_facility_guid"}} - } - }) - elif tsf_filter == 'No TSF': - clauses.append({ - "bool": { - "must_not": { - "nested": { - "path": "tailings_storage_facilities", - "query": {"exists": {"field": "tailings_storage_facilities.mine_tailings_storage_facility_guid"}} - } - } - } - }) - - if filters.get('verified_status'): - for status in filters['verified_status']: - is_verified = status == 'Verified' - clauses.append({ - "nested": { - "path": "verified_status", - "query": {"term": {"verified_status.healthy_ind": is_verified}} - } - }) - - # Permit filters - if filters.get('is_exploration'): - exploration_values = [] - for exp in filters['is_exploration']: - if exp == 'Exploration': - exploration_values.append(True) - elif exp == 'Non-Exploration': - exploration_values.append(False) - if exploration_values: - clauses.append({"terms": {"is_exploration": exploration_values}}) - - # Party filters - if filters.get('party_type'): - type_codes = [] - for pt in filters['party_type']: - if pt == 'Organization': - type_codes.append('ORG') - elif pt == 'Person': - type_codes.append('PER') - else: - type_codes.append(pt) - if type_codes: - clauses.append({"terms": {"party_type_code.keyword": type_codes}}) - - # Explosives permit filters - if filters.get('explosives_permit_status'): - clauses.append({"terms": {"application_status.keyword": filters['explosives_permit_status']}}) - - if filters.get('explosives_permit_closed'): - closed_values = [] - for closed in filters['explosives_permit_closed']: - if closed == 'Closed': - closed_values.append(True) - elif closed == 'Open': - closed_values.append(False) - if closed_values: - clauses.append({"terms": {"is_closed": closed_values}}) - - # NOD filters - if filters.get('nod_type'): - clauses.append({"terms": {"nod_type.keyword": filters['nod_type']}}) - - if filters.get('nod_status'): - clauses.append({"terms": {"nod_status.keyword": filters['nod_status']}}) - - # NoW filters - if filters.get('now_application_status'): - clauses.append({ - "nested": { - "path": "application", - "query": {"terms": {"application.now_application_status_code.keyword": filters['now_application_status']}} - } - }) - - if filters.get('now_type'): - clauses.append({ - "nested": { - "path": "application", - "query": {"terms": {"application.notice_of_work_type_code.keyword": filters['now_type']}} - } - }) - - return clauses - - -def build_search_query(search_term, filter_clauses): - """Build the complete ES search query.""" - return { - "query": { - "bool": { - "must": [{"multi_match": {"query": search_term, "fields": ["*"], "fuzziness": "AUTO"}}], - "filter": filter_clauses - } - }, - "aggs": ES_AGGREGATIONS - } - - -def extract_facets(aggs): - """Extract facet data from ES aggregations.""" - facets = { - # Mine facets - 'mine_region': [], - 'mine_classification': [], - 'mine_operation_status': [], - 'mine_tenure': [], - 'mine_commodity': [], - 'has_tsf': [], - 'verified_status': [], - # Permit facets - 'permit_status': [], - 'is_exploration': [], - # Party facets - 'party_type': [], - # Explosives permit facets - 'explosives_permit_status': [], - 'explosives_permit_closed': [], - # NOD facets - 'nod_type': [], - 'nod_status': [], - # NoW facets - 'now_application_status': [], - 'now_type': [], - # Type facets - 'type': [] - } - - # Type facets (by index) - for bucket in aggs.get('by_index', {}).get('buckets', []): - type_name = INDEX_TO_TYPE.get(bucket['key'], bucket['key']) - facets['type'].append({'key': type_name, 'count': bucket['doc_count']}) - - # Mine region - for bucket in aggs.get('mine_region', {}).get('buckets', []): - if bucket['key'] != 'Unknown': - facets['mine_region'].append({'key': bucket['key'], 'count': bucket['doc_count']}) - - # Classification (major vs regional) - for bucket in aggs.get('major_mine_ind', {}).get('buckets', []): - is_major = bucket.get('key_as_string') == 'true' or bucket['key'] is True - facets['mine_classification'].append({ - 'key': 'Major Mine' if is_major else 'Regional Mine', - 'count': bucket['doc_count'] - }) - - # Operation status (nested) - for bucket in aggs.get('mine_operation_status', {}).get('status_codes', {}).get('codes', {}).get('buckets', []): - facets['mine_operation_status'].append({'key': bucket['key'], 'count': bucket['doc_count']}) - - # Tenure (nested) - for bucket in aggs.get('mine_tenure', {}).get('tenure_codes', {}).get('buckets', []): - facets['mine_tenure'].append({'key': bucket['key'], 'count': bucket['doc_count']}) - - # Commodity (nested) - for bucket in aggs.get('mine_commodity', {}).get('details', {}).get('commodity_codes', {}).get('buckets', []): - if bucket['key']: - facets['mine_commodity'].append({'key': bucket['key'], 'count': bucket['doc_count']}) - - # TSF - tsf_count = aggs.get('has_tsf', {}).get('count', {}).get('value', 0) - total_mines = sum(b['doc_count'] for b in aggs.get('by_index', {}).get('buckets', []) if b['key'] == 'mines') - if tsf_count > 0: - facets['has_tsf'].append({'key': 'Has TSF', 'count': tsf_count}) - if total_mines > tsf_count: - facets['has_tsf'].append({'key': 'No TSF', 'count': total_mines - tsf_count}) - - # Verified status (nested) - for bucket in aggs.get('verified_status', {}).get('healthy', {}).get('buckets', []): - is_verified = bucket.get('key_as_string') == 'true' or bucket['key'] is True - facets['verified_status'].append({ - 'key': 'Verified' if is_verified else 'Unverified', - 'count': bucket['doc_count'] - }) - - # Permit status - for bucket in aggs.get('permit_status', {}).get('buckets', []): - facets['permit_status'].append({'key': bucket['key'], 'count': bucket['doc_count']}) - - # Is exploration (permit) - for bucket in aggs.get('is_exploration', {}).get('buckets', []): - is_exp = bucket.get('key_as_string') == 'true' or bucket['key'] is True - facets['is_exploration'].append({ - 'key': 'Exploration' if is_exp else 'Non-Exploration', - 'count': bucket['doc_count'] - }) - - # Party type - for bucket in aggs.get('party_type', {}).get('buckets', []): - label = 'Organization' if bucket['key'] == 'ORG' else 'Person' if bucket['key'] == 'PER' else bucket['key'] - facets['party_type'].append({'key': label, 'count': bucket['doc_count']}) - - # Explosives permit status - for bucket in aggs.get('explosives_permit_status', {}).get('buckets', []): - facets['explosives_permit_status'].append({'key': bucket['key'], 'count': bucket['doc_count']}) - - # Explosives permit closed - for bucket in aggs.get('explosives_permit_closed', {}).get('buckets', []): - is_closed = bucket.get('key_as_string') == 'true' or bucket['key'] is True - facets['explosives_permit_closed'].append({ - 'key': 'Closed' if is_closed else 'Open', - 'count': bucket['doc_count'] - }) - - # NOD type - for bucket in aggs.get('nod_type', {}).get('buckets', []): - facets['nod_type'].append({'key': bucket['key'], 'count': bucket['doc_count']}) - - # NOD status - for bucket in aggs.get('nod_status', {}).get('buckets', []): - facets['nod_status'].append({'key': bucket['key'], 'count': bucket['doc_count']}) - - # NoW application status (nested) - for bucket in aggs.get('now_application_status', {}).get('status_codes', {}).get('buckets', []): - facets['now_application_status'].append({'key': bucket['key'], 'count': bucket['doc_count']}) - - # NoW type (nested) - for bucket in aggs.get('now_type', {}).get('type_codes', {}).get('buckets', []): - facets['now_type'].append({'key': bucket['key'], 'count': bucket['doc_count']}) - - return facets - - -def group_hits_by_type(hits): - """Group ES hits by document type.""" - grouped = {} - for hit in hits: - doc_type = INDEX_TO_TYPE.get(hit['_index']) - if doc_type: - grouped.setdefault(doc_type, []).append(hit) - return grouped - - -def fetch_db_results(doc_type, es_hits): - """Fetch full records from DB for ES hits.""" - if doc_type not in search_targets or not search_targets[doc_type].get('primary_column'): - return [] - - config = search_targets[doc_type] - id_field = config['id_field'] - - # Extract IDs and scores from ES hits - results = [] - for hit in es_hits: - doc_id = hit['_source'].get(id_field) - if doc_id: - results.append({'id': doc_id, 'score': hit['_score'], 'type': doc_type}) - - if not results: - return [] - - # Query DB for full records - ids = [r['id'] for r in results] - try: - uuid_ids = [UUID(id) for id in ids] - except (ValueError, TypeError) as e: - current_app.logger.error(f"UUID conversion error for {doc_type}: {e}") - uuid_ids = ids - - db_records = db.session.query(config['model']).filter(config['primary_column'].in_(uuid_ids)).all() - db_map = {str(getattr(r, id_field)): r for r in db_records} - - # Merge ES scores with DB records - return [ - {'score': r['score'], 'type': r['type'], 'result': db_map[r['id']]} - for r in results if r['id'] in db_map - ] +from app.extensions import api +from ..global_search_service import GlobalSearchService, parse_search_terms, parse_filters class SearchOptionsResource(Resource, UserMixin): + """Resource for retrieving available search options.""" + @requires_role_view_all def get(self): + """Get list of searchable types with descriptions.""" return [ {'model_id': type_key, 'description': config['description']} for type_key, config in search_targets.items() @@ -431,75 +24,35 @@ def get(self): class SearchResource(Resource, UserMixin): + """Resource for executing global searches.""" + @requires_role_view_all @api.marshal_with(SEARCH_RESULT_RETURN_MODEL, 200) def get(self): + """ + Execute a global search across mines, parties, permits, and documents. + + Query Parameters: + search_term: The search query string + search_types: Comma-separated list of types to search (optional) + Various filter parameters (mine_region, permit_status, etc.) + + Returns: + search_terms: List of parsed search terms + search_results: Dict of results grouped by type + facets: Dict of facet counts for filtering + """ search_term = request.args.get('search_term', '', type=str) - search_types = request.args.get('search_types', None, type=str) - search_types = search_types.split(',') if search_types else list(search_targets.keys()) - - # Parse filter parameters - filters = { - # Mine filters - 'mine_region': parse_csv_param(request.args.get('mine_region')), - 'mine_classification': parse_csv_param(request.args.get('mine_classification')), - 'mine_operation_status': parse_csv_param(request.args.get('mine_operation_status')), - 'mine_tenure': parse_csv_param(request.args.get('mine_tenure')), - 'mine_commodity': parse_csv_param(request.args.get('mine_commodity')), - 'has_tsf': parse_csv_param(request.args.get('has_tsf')), - 'verified_status': parse_csv_param(request.args.get('verified_status')), - # Permit filters - 'permit_status': parse_csv_param(request.args.get('permit_status')), - 'is_exploration': parse_csv_param(request.args.get('is_exploration')), - # Party filters - 'party_type': parse_csv_param(request.args.get('party_type')), - # Explosives permit filters - 'explosives_permit_status': parse_csv_param(request.args.get('explosives_permit_status')), - 'explosives_permit_closed': parse_csv_param(request.args.get('explosives_permit_closed')), - # NOD filters - 'nod_type': parse_csv_param(request.args.get('nod_type')), - 'nod_status': parse_csv_param(request.args.get('nod_status')), - # NoW filters - 'now_application_status': parse_csv_param(request.args.get('now_application_status')), - 'now_type': parse_csv_param(request.args.get('now_type')), + search_types_param = request.args.get('search_types', None, type=str) + search_types = search_types_param.split(',') if search_types_param else list(search_targets.keys()) + + search_terms = parse_search_terms(search_term) + filters = parse_filters(request.args) + + search_result = GlobalSearchService.search(search_term, search_types, filters) + + return { + 'search_terms': search_terms, + 'search_results': search_result['results'], + 'facets': search_result['facets'] } - - # Parse search terms - reg_exp = regex.compile(r'\'.*?\' | ".*?" | \S+ ', regex.VERBOSE) - search_terms = [term.replace('"', '') for term in reg_exp.findall(search_term)] - - # Initialize results - all_results = {t: [] for t in search_types} - facets = {k: [] for k in [ - 'mine_region', 'mine_classification', 'mine_operation_status', - 'mine_tenure', 'mine_commodity', 'has_tsf', 'verified_status', - 'permit_status', 'is_exploration', 'party_type', - 'explosives_permit_status', 'explosives_permit_closed', - 'nod_type', 'nod_status', 'now_application_status', 'now_type', 'type' - ]} - - # Build indices to search - indices = [TYPE_TO_INDEX[t] for t in search_types if t in TYPE_TO_INDEX] - if not indices: - return {'search_terms': search_terms, 'search_results': all_results, 'facets': facets} - - try: - # Execute search - query = build_search_query(search_term, build_filter_clauses(filters)) - current_app.logger.info(f"Searching ES indices: {','.join(indices)} for: {search_term}") - - es_results = ElasticSearchService.search(','.join(set(indices)), query, size=200) - hits = es_results['hits']['hits'] - current_app.logger.info(f"ES returned {len(hits)} hits") - - # Extract facets - facets = extract_facets(es_results.get('aggregations', {})) - - # Process hits by type - for doc_type, type_hits in group_hits_by_type(hits).items(): - all_results[doc_type] = fetch_db_results(doc_type, type_hits) - - except Exception as e: - current_app.logger.error(f"Elasticsearch error: {e}") - - return {'search_terms': search_terms, 'search_results': all_results, 'facets': facets} diff --git a/services/core-api/app/api/search/search/resources/simple_search.py b/services/core-api/app/api/search/search/resources/simple_search.py index 278e4384a0..127992b0cc 100644 --- a/services/core-api/app/api/search/search/resources/simple_search.py +++ b/services/core-api/app/api/search/search/resources/simple_search.py @@ -1,3 +1,5 @@ +import logging + import regex from app.api.search.elasticsearch.elastic_search_service import ElasticSearchService from app.api.search.response_models import SIMPLE_SEARCH_RESULT_RETURN_MODEL @@ -8,6 +10,8 @@ from flask import current_app, request from flask_restx import Resource +logger = logging.getLogger(__name__) + class SimpleSearchResource(Resource, UserMixin): @requires_role_view_all @@ -28,7 +32,7 @@ def get(self): type_to_index = { 'mine': 'mines', 'party': 'parties', - 'permit': 'permits', + 'permit': 'mine_permits', 'notice_of_departure': 'notices_of_departure', 'explosives_permit': 'explosives_permits', 'now_application': 'now_applications' @@ -84,21 +88,18 @@ def get(self): current_app.logger.info(f"Scoped search for mine_guid: {mine_guid}, indices: {indices_string}") # Search for mine_guid in multiple locations across different indices: # - mines index: mine_guid direct field - # - permits: mine.mine_guid nested field - # - nod/explosives/documents: mine_guid direct field - # - parties: mine_appointments.mine_guid or mine_appointments.mine.mine_guid + # - permits: mine_guids array field (from mine_permit_xref) + # - nod/explosives/documents/now: mine_guid direct field or mine.mine_guid nested # Use both raw and .keyword variants for compatibility base_filters.append({ "bool": { "should": [ {"term": {"mine_guid": mine_guid}}, {"term": {"mine_guid.keyword": mine_guid}}, + {"term": {"mine_guids": mine_guid}}, + {"term": {"mine_guids.keyword": mine_guid}}, {"term": {"mine.mine_guid": mine_guid}}, {"term": {"mine.mine_guid.keyword": mine_guid}}, - {"term": {"mine_appointments.mine_guid": mine_guid}}, - {"term": {"mine_appointments.mine_guid.keyword": mine_guid}}, - {"term": {"mine_appointments.mine.mine_guid": mine_guid}}, - {"term": {"mine_appointments.mine.mine_guid.keyword": mine_guid}}, ], "minimum_should_match": 1 } @@ -296,10 +297,26 @@ def get(self): desc_parts.append(phone_no) description = " | ".join(desc_parts) elif type == 'permit': - value = source.get('permit_no', '') + current_app.logger.info(source) + value = source.get('permit_no') or source.get('permit_number', '') permit_status = source.get('permit_status_code', '') + + # Get permittees + permittees = source.get('permittees', []) + current_permittee = '' + if permittees: + first_permittee = permittees[0] if isinstance(permittees, list) else permittees + if first_permittee: + first_name = first_permittee.get('first_name', '') + party_name = first_permittee.get('party_name', '') + current_permittee = f"{first_name} {party_name}".strip() if first_name else party_name + + desc_parts = [] + if current_permittee: + desc_parts.append(current_permittee) if permit_status: - description = f"Status: {permit_status}" + desc_parts.append(f"Status: {permit_status}") + description = " | ".join(desc_parts) elif type == 'notice_of_departure': result_type = 'nod' value = source.get('nod_title', '') or source.get('nod_no', '') @@ -353,7 +370,20 @@ def get(self): # Filter by result type if search_types specified if allowed_types and result_type not in allowed_types: continue - + + # Extract mine_guid if available (needed for navigation) + mine_guid = None + if type == 'mine': + mine_guid = source.get('mine_guid') + elif type == 'permit': + mine_guids = source.get('mine_guids') + if mine_guids and isinstance(mine_guids, list) and len(mine_guids) > 0: + mine_guid = mine_guids[0] + elif type in ['notice_of_departure', 'explosives_permit', 'now_application']: + mine_info = source.get('mine') + if mine_info and isinstance(mine_info, dict): + mine_guid = mine_info.get('mine_guid') + # Boost if starts with or exact match (skip for wildcard searches) if value and search_term and search_term != "*": if value.lower().startswith(search_term.lower()): @@ -378,7 +408,8 @@ def get(self): 'id': source.get(type_config['id_field']), 'value': value, 'description': description, - 'highlight': highlight_text + 'highlight': highlight_text, + 'mine_guid': mine_guid } )) @@ -397,10 +428,21 @@ def get(self): search_results = search_results[0:4] # Get facet counts (unfiltered) using aggregations - facets = {'mine': 0, 'person': 0, 'organization': 0, 'permit': 0, 'nod': 0, 'explosives_permit': 0, 'now_application': 0} + facets = {'mine': 0, 'person': 0, 'organization': 0, 'permit': 0, 'nod': 0, 'explosives_permit': 0, 'now_application': 0, 'mine_documents': 0, 'permit_documents': 0} all_indices = ",".join([type_to_index[t] for t in simple_search_targets.keys() if t in type_to_index]) if all_indices and search_term: + # Build filter that handles missing deleted_ind field (like NoW applications) + deleted_filter = { + "bool": { + "should": [ + {"term": {"deleted_ind": False}}, + {"bool": {"must_not": {"exists": {"field": "deleted_ind"}}}} + ], + "minimum_should_match": 1 + } + } + # Build facet query with aggregations if len(search_term) < 3: facet_query = { @@ -411,7 +453,7 @@ def get(self): {"multi_match": {"query": search_term, "fields": ["*"]}} ], "minimum_should_match": 1, - "filter": [{"term": {"deleted_ind": False}}] + "filter": [deleted_filter] } }, "aggs": { @@ -430,7 +472,7 @@ def get(self): "query": { "bool": { "must": [{"multi_match": {"query": search_term, "fields": ["*"], "fuzziness": "AUTO"}}], - "filter": [{"term": {"deleted_ind": False}}] + "filter": [deleted_filter] } }, "aggs": { @@ -455,7 +497,7 @@ def get(self): if index_name == 'mines': facets['mine'] = doc_count - elif index_name == 'permits': + elif index_name == 'mine_permits': facets['permit'] = doc_count elif index_name == 'notices_of_departure': facets['nod'] = doc_count @@ -463,6 +505,11 @@ def get(self): facets['explosives_permit'] = doc_count elif index_name == 'now_applications': facets['now_application'] = doc_count + elif index_name == 'documents': + facets['mine_documents'] = doc_count + # Since we don't have separate index for permit documents yet, assume all are mine documents or split if possible + # For now, just setting mine_documents. If permit documents are in same index, we need logic to distinguish. + # Assuming 'documents' index contains mine documents. elif index_name == 'parties': # Split by party_type_code party_buckets = bucket.get('by_party_type', {}).get('buckets', []) @@ -502,4 +549,4 @@ def get(self): elif result.type == 'now_application': facets['now_application'] += 1 - return {'search_terms': search_terms, 'search_results': search_results, 'facets': facets} \ No newline at end of file + return {'search_terms': search_terms, 'search_results': search_results, 'facets': facets} diff --git a/services/core-api/app/api/search/search/search_constants.py b/services/core-api/app/api/search/search/search_constants.py new file mode 100644 index 0000000000..d0f3ba8870 --- /dev/null +++ b/services/core-api/app/api/search/search/search_constants.py @@ -0,0 +1,114 @@ +"""Constants and configuration for search functionality.""" + +TYPE_TO_INDEX = { + 'mine': 'mines', + 'party': 'parties', + 'permit': 'mine_permits', + 'mine_documents': 'documents', + 'notice_of_departure': 'notices_of_departure', + 'explosives_permit': 'explosives_permits', + 'now_application': 'now_applications', +} + +INDEX_TO_TYPE = {v: k for k, v in TYPE_TO_INDEX.items()} + +# Define searchable fields with boosting +SEARCH_FIELDS = [ + # Mine fields + "mine_name^3", + "mine_no^3", + "mms_alias^2", + "mine.mine_name^2", + "mine.mine_no^2", + # Party/contact fields + "party_name^3", + "first_name^2", + "email^2", + "phone_no", + # Permit fields + "permit_no^3", + "permit_number^3", + "application_number^2", + # NOD fields + "nod_no^3", + "nod_title^3", + "nod_description", + # NOW fields + "now_number^3", + "application.property_name^2", + # Document fields + "document_name^2", + # Description fields + "description", + # Catch-all + "*" +] + +FACET_KEYS = [ + 'mine_region', 'mine_classification', 'mine_operation_status', + 'mine_tenure', 'mine_commodity', 'has_tsf', 'verified_status', + 'permit_status', 'is_exploration', 'party_type', + 'explosives_permit_status', 'explosives_permit_closed', + 'nod_type', 'nod_status', 'now_application_status', 'now_type', 'type' +] + +FILTER_PARAMS = [ + 'mine_region', 'mine_classification', 'mine_operation_status', + 'mine_tenure', 'mine_commodity', 'has_tsf', 'verified_status', + 'permit_status', 'is_exploration', 'party_type', + 'explosives_permit_status', 'explosives_permit_closed', + 'nod_type', 'nod_status', 'now_application_status', 'now_type' +] + +ES_AGGREGATIONS = { + "by_index": {"terms": {"field": "_index", "size": 10}}, + "mine_region": {"terms": {"field": "mine_region.keyword", "size": 20, "missing": "Unknown"}}, + "major_mine_ind": {"terms": {"field": "major_mine_ind", "size": 10}}, + "mine_operation_status": { + "nested": {"path": "mine_status"}, + "aggs": { + "status_codes": { + "nested": {"path": "mine_status.status_xref"}, + "aggs": { + "codes": {"terms": {"field": "mine_status.status_xref.mine_operation_status_code.keyword", "size": 20}} + } + } + } + }, + "mine_tenure": { + "nested": {"path": "mine_types"}, + "aggs": {"tenure_codes": {"terms": {"field": "mine_types.mine_tenure_type_code.keyword", "size": 20}}} + }, + "mine_commodity": { + "nested": {"path": "mine_types"}, + "aggs": { + "details": { + "nested": {"path": "mine_types.mine_type_details"}, + "aggs": {"commodity_codes": {"terms": {"field": "mine_types.mine_type_details.mine_commodity_code.keyword", "size": 30}}} + } + } + }, + "has_tsf": { + "nested": {"path": "tailings_storage_facilities"}, + "aggs": {"count": {"value_count": {"field": "tailings_storage_facilities.mine_tailings_storage_facility_guid"}}} + }, + "verified_status": { + "nested": {"path": "verified_status"}, + "aggs": {"healthy": {"terms": {"field": "verified_status.healthy_ind", "size": 10}}} + }, + "permit_status": {"terms": {"field": "permit_status_code.keyword", "size": 20}}, + "is_exploration": {"terms": {"field": "is_exploration", "size": 10}}, + "party_type": {"terms": {"field": "party_type_code.keyword", "size": 10}}, + "explosives_permit_status": {"terms": {"field": "application_status.keyword", "size": 20}}, + "explosives_permit_closed": {"terms": {"field": "is_closed", "size": 10}}, + "nod_type": {"terms": {"field": "nod_type.keyword", "size": 20}}, + "nod_status": {"terms": {"field": "nod_status.keyword", "size": 20}}, + "now_application_status": { + "nested": {"path": "application"}, + "aggs": {"status_codes": {"terms": {"field": "application.now_application_status_code.keyword", "size": 20}}} + }, + "now_type": { + "nested": {"path": "application"}, + "aggs": {"type_codes": {"terms": {"field": "application.notice_of_work_type_code.keyword", "size": 20}}} + }, +} diff --git a/services/core-api/app/api/search/search/search_facets.py b/services/core-api/app/api/search/search/search_facets.py new file mode 100644 index 0000000000..5585c9554b --- /dev/null +++ b/services/core-api/app/api/search/search/search_facets.py @@ -0,0 +1,130 @@ +"""Facet extraction from Elasticsearch aggregations.""" + +from .search_constants import INDEX_TO_TYPE, FACET_KEYS + + +# Predefined values for facets that should always appear +PREDEFINED_FACETS = { + 'mine_classification': ['Major Mine', 'Regional Mine'], + 'has_tsf': ['Has TSF', 'No TSF'], + 'verified_status': ['Verified', 'Unverified'], + 'is_exploration': ['Exploration', 'Non-Exploration'], + 'party_type': ['Person', 'Organization'], + 'explosives_permit_closed': ['Open', 'Closed'], + 'type': ['mine', 'party', 'permit', 'mine_documents', 'notice_of_departure', 'explosives_permit', 'now_application'], +} + + +def _extract_buckets(aggs, key, nested_path=None): + """Extract buckets from aggregation, handling nested paths.""" + data = aggs.get(key, {}) + if nested_path: + for path in nested_path: + data = data.get(path, {}) + return data.get('buckets', []) + + +def _parse_boolean_bucket(bucket, true_label, false_label): + """Parse a boolean aggregation bucket.""" + is_true = bucket.get('key_as_string') == 'true' or bucket['key'] is True + return {'key': true_label if is_true else false_label, 'count': bucket['doc_count']} + + +def _ensure_predefined_values(facet_list, facet_key): + """Ensure all predefined values exist in the facet list, adding 0 counts for missing ones.""" + if facet_key not in PREDEFINED_FACETS: + return facet_list + + existing_keys = {item['key'] for item in facet_list} + for predefined_key in PREDEFINED_FACETS[facet_key]: + if predefined_key not in existing_keys: + facet_list.append({'key': predefined_key, 'count': 0}) + + return facet_list + + +def extract_facets(aggs): + """Extract facet data from ES aggregations.""" + facets = {k: [] for k in FACET_KEYS} + + # Type facets (by index) + for bucket in _extract_buckets(aggs, 'by_index'): + type_name = INDEX_TO_TYPE.get(bucket['key'], bucket['key']) + facets['type'].append({'key': type_name, 'count': bucket['doc_count']}) + facets['type'] = _ensure_predefined_values(facets['type'], 'type') + + # Mine region + for bucket in _extract_buckets(aggs, 'mine_region'): + if bucket['key'] != 'Unknown': + facets['mine_region'].append({'key': bucket['key'], 'count': bucket['doc_count']}) + + # Classification (major vs regional) + for bucket in _extract_buckets(aggs, 'major_mine_ind'): + facets['mine_classification'].append(_parse_boolean_bucket(bucket, 'Major Mine', 'Regional Mine')) + facets['mine_classification'] = _ensure_predefined_values(facets['mine_classification'], 'mine_classification') + + # Operation status (nested) + for bucket in _extract_buckets(aggs, 'mine_operation_status', ['status_codes', 'codes']): + facets['mine_operation_status'].append({'key': bucket['key'], 'count': bucket['doc_count']}) + + # Tenure (nested) + for bucket in _extract_buckets(aggs, 'mine_tenure', ['tenure_codes']): + facets['mine_tenure'].append({'key': bucket['key'], 'count': bucket['doc_count']}) + + # Commodity (nested) + for bucket in _extract_buckets(aggs, 'mine_commodity', ['details', 'commodity_codes']): + if bucket['key']: + facets['mine_commodity'].append({'key': bucket['key'], 'count': bucket['doc_count']}) + + # TSF + tsf_count = aggs.get('has_tsf', {}).get('count', {}).get('value', 0) + total_mines = sum(b['doc_count'] for b in _extract_buckets(aggs, 'by_index') if b['key'] == 'mines') + facets['has_tsf'].append({'key': 'Has TSF', 'count': tsf_count}) + facets['has_tsf'].append({'key': 'No TSF', 'count': max(0, total_mines - tsf_count)}) + + # Verified status (nested) + for bucket in _extract_buckets(aggs, 'verified_status', ['healthy']): + facets['verified_status'].append(_parse_boolean_bucket(bucket, 'Verified', 'Unverified')) + facets['verified_status'] = _ensure_predefined_values(facets['verified_status'], 'verified_status') + + # Permit status + for bucket in _extract_buckets(aggs, 'permit_status'): + facets['permit_status'].append({'key': bucket['key'], 'count': bucket['doc_count']}) + + # Is exploration + for bucket in _extract_buckets(aggs, 'is_exploration'): + facets['is_exploration'].append(_parse_boolean_bucket(bucket, 'Exploration', 'Non-Exploration')) + facets['is_exploration'] = _ensure_predefined_values(facets['is_exploration'], 'is_exploration') + + # Party type + for bucket in _extract_buckets(aggs, 'party_type'): + label = {'ORG': 'Organization', 'PER': 'Person'}.get(bucket['key'], bucket['key']) + facets['party_type'].append({'key': label, 'count': bucket['doc_count']}) + facets['party_type'] = _ensure_predefined_values(facets['party_type'], 'party_type') + + # Explosives permit status + for bucket in _extract_buckets(aggs, 'explosives_permit_status'): + facets['explosives_permit_status'].append({'key': bucket['key'], 'count': bucket['doc_count']}) + + # Explosives permit closed + for bucket in _extract_buckets(aggs, 'explosives_permit_closed'): + facets['explosives_permit_closed'].append(_parse_boolean_bucket(bucket, 'Closed', 'Open')) + facets['explosives_permit_closed'] = _ensure_predefined_values(facets['explosives_permit_closed'], 'explosives_permit_closed') + + # NOD type + for bucket in _extract_buckets(aggs, 'nod_type'): + facets['nod_type'].append({'key': bucket['key'], 'count': bucket['doc_count']}) + + # NOD status + for bucket in _extract_buckets(aggs, 'nod_status'): + facets['nod_status'].append({'key': bucket['key'], 'count': bucket['doc_count']}) + + # NoW application status (nested) + for bucket in _extract_buckets(aggs, 'now_application_status', ['status_codes']): + facets['now_application_status'].append({'key': bucket['key'], 'count': bucket['doc_count']}) + + # NoW type (nested) + for bucket in _extract_buckets(aggs, 'now_type', ['type_codes']): + facets['now_type'].append({'key': bucket['key'], 'count': bucket['doc_count']}) + + return facets diff --git a/services/core-api/app/api/search/search/search_filters.py b/services/core-api/app/api/search/search/search_filters.py new file mode 100644 index 0000000000..f06a8aca6a --- /dev/null +++ b/services/core-api/app/api/search/search/search_filters.py @@ -0,0 +1,148 @@ +"""Filter builders for Elasticsearch queries.""" + + +def build_deleted_filter(): + """Build filter for deleted_ind that handles missing field.""" + return { + "bool": { + "should": [ + {"term": {"deleted_ind": False}}, + {"bool": {"must_not": {"exists": {"field": "deleted_ind"}}}} + ], + "minimum_should_match": 1 + } + } + + +def build_terms_filter(field, values): + """Build simple terms filter.""" + return {"terms": {field: values}} + + +def build_nested_filter(path, query): + """Build nested filter.""" + return {"nested": {"path": path, "query": query}} + + +def build_boolean_filter(field, value_map, values): + """Build filter for boolean fields with string mappings.""" + bool_values = [] + for v in values: + if v in value_map: + bool_values.append(value_map[v]) + return {"terms": {field: bool_values}} if bool_values else None + + +def build_filter_clauses(filters): + """Build ES filter clauses from filter parameters.""" + clauses = [build_deleted_filter()] + + if filters.get('mine_region'): + clauses.append(build_terms_filter("mine_region.keyword", filters['mine_region'])) + + if filters.get('mine_classification'): + clause = build_boolean_filter( + "major_mine_ind", + {'Major Mine': True, 'Regional Mine': False}, + filters['mine_classification'] + ) + if clause: + clauses.append(clause) + + if filters.get('mine_tenure'): + clauses.append(build_nested_filter( + "mine_types", + {"terms": {"mine_types.mine_tenure_type_code.keyword": filters['mine_tenure']}} + )) + + if filters.get('mine_commodity'): + clauses.append(build_nested_filter( + "mine_types", + build_nested_filter( + "mine_types.mine_type_details", + {"terms": {"mine_types.mine_type_details.mine_commodity_code.keyword": filters['mine_commodity']}} + ) + )) + + if filters.get('permit_status'): + clauses.append(build_terms_filter("permit_status_code.keyword", filters['permit_status'])) + + if filters.get('mine_operation_status'): + clauses.append(build_nested_filter( + "mine_status", + build_nested_filter( + "mine_status.status_xref", + {"terms": {"mine_status.status_xref.mine_operation_status_code.keyword": filters['mine_operation_status']}} + ) + )) + + if filters.get('has_tsf'): + for tsf_filter in filters['has_tsf']: + if tsf_filter == 'Has TSF': + clauses.append(build_nested_filter( + "tailings_storage_facilities", + {"exists": {"field": "tailings_storage_facilities.mine_tailings_storage_facility_guid"}} + )) + elif tsf_filter == 'No TSF': + clauses.append({ + "bool": { + "must_not": build_nested_filter( + "tailings_storage_facilities", + {"exists": {"field": "tailings_storage_facilities.mine_tailings_storage_facility_guid"}} + ) + } + }) + + if filters.get('verified_status'): + for status in filters['verified_status']: + clauses.append(build_nested_filter( + "verified_status", + {"term": {"verified_status.healthy_ind": status == 'Verified'}} + )) + + if filters.get('is_exploration'): + clause = build_boolean_filter( + "is_exploration", + {'Exploration': True, 'Non-Exploration': False}, + filters['is_exploration'] + ) + if clause: + clauses.append(clause) + + if filters.get('party_type'): + type_codes = [] + for pt in filters['party_type']: + type_codes.append({'Organization': 'ORG', 'Person': 'PER'}.get(pt, pt)) + clauses.append(build_terms_filter("party_type_code.keyword", type_codes)) + + if filters.get('explosives_permit_status'): + clauses.append(build_terms_filter("application_status.keyword", filters['explosives_permit_status'])) + + if filters.get('explosives_permit_closed'): + clause = build_boolean_filter( + "is_closed", + {'Closed': True, 'Open': False}, + filters['explosives_permit_closed'] + ) + if clause: + clauses.append(clause) + + if filters.get('nod_type'): + clauses.append(build_terms_filter("nod_type.keyword", filters['nod_type'])) + + if filters.get('nod_status'): + clauses.append(build_terms_filter("nod_status.keyword", filters['nod_status'])) + + if filters.get('now_application_status'): + clauses.append(build_nested_filter( + "application", + {"terms": {"application.now_application_status_code.keyword": filters['now_application_status']}} + )) + + if filters.get('now_type'): + clauses.append(build_nested_filter( + "application", + {"terms": {"application.notice_of_work_type_code.keyword": filters['now_type']}} + )) + + return clauses diff --git a/services/core-api/app/api/search/search/search_transformers.py b/services/core-api/app/api/search/search/search_transformers.py new file mode 100644 index 0000000000..bb1abec6d3 --- /dev/null +++ b/services/core-api/app/api/search/search/search_transformers.py @@ -0,0 +1,254 @@ +"""Transformers for converting ES hits to API response format.""" + +from .search_constants import INDEX_TO_TYPE + + +def transform_mine_hit(hit): + """Transform ES hit to mine result format.""" + source = hit['_source'] + + status_labels = [] + mine_status = source.get('mine_status', []) + if mine_status: + for status in mine_status if isinstance(mine_status, list) else [mine_status]: + xref = status.get('status_xref', {}) + if xref and xref.get('mine_operation_status_code'): + status_labels.append(xref['mine_operation_status_code']) + + return { + 'mine_guid': source.get('mine_guid'), + 'mine_name': source.get('mine_name'), + 'mine_no': source.get('mine_no'), + 'mine_region': source.get('mine_region'), + 'mms_alias': source.get('mms_alias'), + 'major_mine_ind': source.get('major_mine_ind'), + 'mine_status': {'status_labels': status_labels} if status_labels else None, + 'permits': source.get('permits', []), + 'mine_type': source.get('mine_types', []), + 'mine_tailings_storage_facilities': source.get('tailings_storage_facilities', []), + 'mine_work_information': source.get('work_information'), + 'verified_status': source.get('verified_status'), + } + + +def enrich_party_appointments(party_results): + """ + Enrich party results with mine names and permit numbers for their appointments. + This is a post-processing step since pgsync can't index nested relationships properly. + """ + from app.api.parties.party_appt.models.mine_party_appt import MinePartyAppointment + from sqlalchemy.orm import joinedload + + # Collect all party GUIDs that have appointments + party_guids_with_appts = [ + p['party_guid'] for p in party_results + if p.get('mine_party_appt') and len(p['mine_party_appt']) > 0 + ] + + if not party_guids_with_appts: + return party_results + + # Fetch all appointments with mine and permit data in one query + appointments = MinePartyAppointment.query.filter( + MinePartyAppointment.party_guid.in_(party_guids_with_appts), + MinePartyAppointment.deleted_ind == False + ).options( + joinedload(MinePartyAppointment.mine), + joinedload(MinePartyAppointment.permit) + ).all() + + # Build a map of party_guid -> appointments with full data + appt_map = {} + for appt in appointments: + if appt.party_guid not in appt_map: + appt_map[appt.party_guid] = [] + + appt_map[appt.party_guid].append({ + 'mine_party_appt_guid': str(appt.mine_party_appt_guid), + 'mine_party_appt_type_code': appt.mine_party_appt_type_code, + 'start_date': str(appt.start_date) if appt.start_date else None, + 'end_date': str(appt.end_date) if appt.end_date else None, + 'mine': { + 'mine_guid': str(appt.mine.mine_guid), + 'mine_name': appt.mine.mine_name + } if appt.mine else None, + 'permit_no': appt.permit.permit_no if appt.permit else None, + }) + + # Enrich the party results with full appointment data + for party in party_results: + party_guid = party['party_guid'] + if party_guid in appt_map: + party['mine_party_appt'] = appt_map[party_guid] + + return party_results + + +def transform_party_hit(hit): + """Transform ES hit to party result format.""" + source = hit['_source'] + first_name = source.get('first_name', '') + party_name = source.get('party_name', '') + + # Transform mine_party_appt relationships + # Note: Only basic appointment data is indexed (type, dates) + # Mine names and permit numbers will be enriched via enrich_party_appointments() + mine_party_appts = source.get('mine_party_appt', []) + transformed_appts = [] + + if mine_party_appts: + if not isinstance(mine_party_appts, list): + mine_party_appts = [mine_party_appts] + + for appt in mine_party_appts: + if not appt: + continue + + transformed_appts.append({ + 'mine_party_appt_guid': appt.get('mine_party_appt_guid'), + 'mine_party_appt_type_code': appt.get('mine_party_appt_type_code'), + 'start_date': appt.get('start_date'), + 'end_date': appt.get('end_date'), + 'mine': None, # Will be enriched by enrich_party_appointments() + 'permit_no': None, # Will be enriched by enrich_party_appointments() + }) + + return { + 'party_guid': source.get('party_guid'), + 'name': f"{first_name} {party_name}".strip() if first_name else party_name, + 'first_name': first_name, + 'party_name': party_name, + 'party_type_code': source.get('party_type_code'), + 'email': source.get('email'), + 'phone_no': source.get('phone_no'), + 'party_orgbook_entity': None, + 'business_role_appts': [], + 'mine_party_appt': transformed_appts, + 'address': [], + } + + +def transform_permit_hit(hit): + """Transform ES hit to permit result format.""" + source = hit['_source'] + + permittees = source.get('permittees', []) + current_permittee = None + if permittees: + first_permittee = permittees[0] if isinstance(permittees, list) else permittees + if first_permittee: + first_name = first_permittee.get('first_name', '') + party_name = first_permittee.get('party_name', '') + current_permittee = f"{first_name} {party_name}".strip() if first_name else party_name + + mine_guids = source.get('mine_guids', []) + mines = [] + if mine_guids: + for guid in (mine_guids if isinstance(mine_guids, list) else [mine_guids]): + mines.append({'mine_guid': guid, 'mine_name': '', 'mine_no': ''}) + + return { + 'permit_guid': source.get('permit_guid'), + 'permit_no': source.get('permit_no'), + 'current_permittee': current_permittee, + 'mine': mines, + } + + +def transform_document_hit(hit): + """Transform ES hit to document result format.""" + source = hit['_source'] + mine_info = source.get('mine', {}) + + return { + 'mine_guid': source.get('mine_guid'), + 'mine_document_guid': source.get('mine_document_guid'), + 'document_name': source.get('document_name'), + 'mine_name': mine_info.get('mine_name') if mine_info else None, + 'document_manager_guid': source.get('document_manager_guid'), + 'upload_date': source.get('upload_date'), + 'create_user': source.get('create_user'), + } + + +def transform_explosives_permit_hit(hit): + """Transform ES hit to explosives permit result format.""" + source = hit['_source'] + mine_info = source.get('mine', {}) + + return { + 'explosives_permit_guid': source.get('explosives_permit_guid'), + 'explosives_permit_id': source.get('explosives_permit_id'), + 'application_number': source.get('application_number'), + 'application_status': source.get('application_status'), + 'mine_guid': source.get('mine_guid'), + 'mine_name': mine_info.get('mine_name') if mine_info else source.get('mine_name'), + 'is_closed': source.get('is_closed'), + } + + +def transform_now_application_hit(hit): + """Transform ES hit to NoW application result format.""" + source = hit['_source'] + mine_info = source.get('mine', {}) + application = source.get('application', {}) + + return { + 'now_application_guid': source.get('now_application_guid'), + 'now_number': source.get('now_number'), + 'mine_guid': source.get('mine_guid'), + 'mine_name': mine_info.get('mine_name') if mine_info else source.get('mine_name'), + 'now_application_status_code': application.get('now_application_status_code') if application else source.get('now_application_status_code'), + 'notice_of_work_type_code': application.get('notice_of_work_type_code') if application else source.get('notice_of_work_type_code'), + } + + +def transform_nod_hit(hit): + """Transform ES hit to NOD result format.""" + source = hit['_source'] + mine_info = source.get('mine', {}) + + return { + 'nod_guid': source.get('nod_guid'), + 'nod_no': source.get('nod_no'), + 'nod_title': source.get('nod_title'), + 'mine_guid': source.get('mine_guid'), + 'mine_name': mine_info.get('mine_name') if mine_info else source.get('mine_name'), + 'nod_type': source.get('nod_type'), + 'nod_status': source.get('nod_status'), + } + + +TRANSFORMERS = { + 'mine': transform_mine_hit, + 'party': transform_party_hit, + 'permit': transform_permit_hit, + 'mine_documents': transform_document_hit, + 'explosives_permit': transform_explosives_permit_hit, + 'now_application': transform_now_application_hit, + 'notice_of_departure': transform_nod_hit, +} + + +def transform_es_results(hits): + """Transform ES hits into grouped results by type.""" + results = {} + + for hit in hits: + doc_type = INDEX_TO_TYPE.get(hit['_index']) + if not doc_type: + continue + + if doc_type not in results: + results[doc_type] = [] + + transformer = TRANSFORMERS.get(doc_type) + result = transformer(hit) if transformer else hit['_source'] + + results[doc_type].append({ + 'score': hit['_score'], + 'type': doc_type, + 'result': result + }) + + return results diff --git a/services/core-web/src/components/homepage/HomeBanner.tsx b/services/core-web/src/components/homepage/HomeBanner.tsx index 9a2b3f4611..8bf32cc598 100644 --- a/services/core-web/src/components/homepage/HomeBanner.tsx +++ b/services/core-web/src/components/homepage/HomeBanner.tsx @@ -15,37 +15,40 @@ const HomeBanner = () => { } id="homepage-banner" > - - - Welcome back to CORE - - - - - - - + + + + Welcome back to CORE + + + + + + + - - Photo Credit: Dominic Yague - + + Photo Credit: Dominic Yague + + ); }; diff --git a/services/core-web/src/components/search/GenericResultsTable.js b/services/core-web/src/components/search/GenericResultsTable.js new file mode 100644 index 0000000000..1a9ab745ac --- /dev/null +++ b/services/core-web/src/components/search/GenericResultsTable.js @@ -0,0 +1,48 @@ +import React from "react"; +import { Table, Typography } from "antd"; +import { Link } from "react-router-dom"; +import * as router from "@/constants/routes"; + +const { Text } = Typography; + +export const GenericResultsTable = ({ header, searchResults, columns, getRecordKey, highlightRegex }) => { + const highlightText = (text) => { + if (!text || !highlightRegex) return text; + const parts = String(text).split(highlightRegex); + return parts.map((part, index) => + highlightRegex.test(part) ? {part} : part + ); + }; + + const enhancedColumns = columns.map((col) => ({ + ...col, + render: col.customRender || ((text, record) => { + if (col.link) { + return {highlightText(text)}; + } + if (col.highlight !== false) { + return highlightText(text); + } + return text; + }), + })); + + return ( + <> + {header && ( +
+ + {header} + +
+ )} +
+ + ); +}; diff --git a/services/core-web/src/components/search/GlobalSearch.tsx b/services/core-web/src/components/search/GlobalSearch.tsx index 371c9be68c..4e90731dac 100644 --- a/services/core-web/src/components/search/GlobalSearch.tsx +++ b/services/core-web/src/components/search/GlobalSearch.tsx @@ -31,6 +31,7 @@ const MAX_RECENT_SEARCHES = 5; interface GlobalSearchProps { placeholder?: string; size?: "small" | "middle" | "large"; + enableShortcut?: boolean; } const TYPE_CONFIG: Record = { @@ -39,7 +40,9 @@ const TYPE_CONFIG: Record, label: "Organizations", color: "#f57c00", types: ["organization"] }, permit: { icon: , label: "Permits", color: "#e65100", types: ["permit"] }, explosives_permit: { icon: , label: "Explosives", color: "#d32f2f", types: ["explosives_permit"] }, - nod: { icon: , label: "NODs", color: "#7b1fa2", types: ["nod"] }, + now_application: { icon: , label: "NoW", color: "#0288d1", types: ["now_application"] }, + nod: { icon: , label: "NODs", color: "#7b1fa2", types: ["nod", "notice_of_departure"] }, + document: { icon: , label: "Documents", color: "#455a64", types: ["mine_documents", "permit_documents"] }, }; const RESULT_TYPE_CONFIG: Record = { @@ -49,7 +52,11 @@ const RESULT_TYPE_CONFIG: Record, label: "Contact", color: "#1565c0" }, permit: { icon: , label: "Permit", color: "#e65100" }, explosives_permit: { icon: , label: "Explosives Permit", color: "#d32f2f" }, + now_application: { icon: , label: "Notice of Work", color: "#0288d1" }, nod: { icon: , label: "NOD", color: "#7b1fa2" }, + notice_of_departure: { icon: , label: "NOD", color: "#7b1fa2" }, + mine_documents: { icon: , label: "Document", color: "#455a64" }, + permit_documents: { icon: , label: "Document", color: "#455a64" }, }; const COMMANDS: Record = { @@ -58,12 +65,14 @@ const COMMANDS: Record = ({ placeholder = "Search Core..." }) => { +const GlobalSearch: React.FC = ({ placeholder = "Search Core...", enableShortcut = true }) => { const [isModalVisible, setIsModalVisible] = useState(false); const [searchTerm, setSearchTerm] = useState(""); const [selectedIndex, setSelectedIndex] = useState(0); @@ -73,7 +82,7 @@ const GlobalSearch: React.FC = ({ placeholder = "Search Core. const [commandMode, setCommandMode] = useState(false); const [commandInput, setCommandInput] = useState(""); const [quickFilter, setQuickFilter] = useState(null); // Filter applied via shortcut - + const dispatch = useDispatch(); const searchResults = useSelector(getSearchBarResults); const facets = useSelector(getSearchBarFacets); @@ -131,15 +140,25 @@ const GlobalSearch: React.FC = ({ placeholder = "Search Core. }, []); useEffect(() => { + if (!enableShortcut) return; + const handleKeyDown = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === "k") { e.preventDefault(); - handleOpen(); + + // Check if any global search modal is already open + const isAnyModalOpen = document.querySelector('.global-search-modal'); + + if (isModalVisible) { + handleClose(); + } else if (!isAnyModalOpen) { + handleOpen(); + } } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, []); + }, [isModalVisible, handleClose, enableShortcut]); const getSearchTypes = (filters: string[], includeQuickFilter?: string | null) => { const allFilters = includeQuickFilter ? [...filters, includeQuickFilter] : filters; @@ -163,7 +182,7 @@ const GlobalSearch: React.FC = ({ placeholder = "Search Core. const getMatchingCommands = (input: string) => { const cmd = input.toLowerCase().trim(); if (!cmd) return Object.entries(COMMANDS); - return Object.entries(COMMANDS).filter(([key, command]) => + return Object.entries(COMMANDS).filter(([key, command]) => key.startsWith(cmd) || command.aliases.some(a => a.startsWith(cmd)) ); }; @@ -172,10 +191,10 @@ const GlobalSearch: React.FC = ({ placeholder = "Search Core. const [type, target] = action.split(":"); let newFilters = activeFilters; let newScopeToMine = scopeToMine; - + if (type === "filter") { - newFilters = activeFilters.includes(target) - ? activeFilters.filter((f) => f !== target) + newFilters = activeFilters.includes(target) + ? activeFilters.filter((f) => f !== target) : [...activeFilters, target]; setActiveFilters(newFilters); } else if (type === "scope" && isOnMinePage) { @@ -187,11 +206,11 @@ const GlobalSearch: React.FC = ({ placeholder = "Search Core. setActiveFilters([]); setScopeToMine(false); } - + setCommandMode(false); setCommandInput(""); setSelectedIndex(0); - + // If there's a follow-up search term, set it and trigger search if (followUpSearch && followUpSearch.trim()) { const term = followUpSearch.trim(); @@ -207,16 +226,16 @@ const GlobalSearch: React.FC = ({ placeholder = "Search Core. const handleSearchChange = (e: React.ChangeEvent) => { const value = e.target.value; - + // Check if input starts with / - this means command mode if (value.startsWith("/")) { if (!commandMode) { setCommandMode(true); } - + const cmdContent = value.slice(1); // Everything after / const { commandPart, searchPart } = parseCommandInput(cmdContent); - + // Auto-apply filter: if user just typed a space and there's a matching command if (cmdContent.endsWith(" ") && !searchPart && commandPart) { const matchingCommands = getMatchingCommands(commandPart.trim()); @@ -250,18 +269,18 @@ const GlobalSearch: React.FC = ({ placeholder = "Search Core. } } } - + setCommandInput(cmdContent); setSelectedIndex(0); return; } - + // If we were in command mode but / is gone, exit command mode if (commandMode) { setCommandMode(false); setCommandInput(""); } - + setSearchTerm(value); setSelectedIndex(0); if (value.length > 0) { @@ -270,8 +289,8 @@ const GlobalSearch: React.FC = ({ placeholder = "Search Core. }; const toggleFilter = (filterKey: string) => { - const newFilters = activeFilters.includes(filterKey) - ? activeFilters.filter((f) => f !== filterKey) + const newFilters = activeFilters.includes(filterKey) + ? activeFilters.filter((f) => f !== filterKey) : [...activeFilters, filterKey]; setActiveFilters(newFilters); setSelectedIndex(0); @@ -300,10 +319,17 @@ const GlobalSearch: React.FC = ({ placeholder = "Search Core. case "party": routeUrl = router.PARTY_PROFILE.dynamicRoute(item.result.id); break; + case "now_application": + routeUrl = router.NOTICE_OF_WORK_APPLICATION.dynamicRoute(item.result.id, "verification"); + break; case "permit": + routeUrl = router.VIEW_MINE_PERMIT.dynamicRoute(item.result.mine_guid, item.result.id); + break; case "explosives_permit": + routeUrl = router.MINE_PERMITS.dynamicRoute(item.result.mine_guid); + break; case "nod": - routeUrl = router.SEARCH_RESULTS.dynamicRoute({ q: item.result.value }); + routeUrl = router.NOTICE_OF_DEPARTURE.dynamicRoute(item.result.mine_guid, item.result.id); break; } if (routeUrl) { @@ -332,9 +358,9 @@ const GlobalSearch: React.FC = ({ placeholder = "Search Core. if (spaceIndex === -1) { return { commandPart: input, searchPart: "" }; } - return { - commandPart: input.slice(0, spaceIndex), - searchPart: input.slice(spaceIndex + 1) + return { + commandPart: input.slice(0, spaceIndex), + searchPart: input.slice(spaceIndex + 1) }; }; @@ -343,7 +369,7 @@ const GlobalSearch: React.FC = ({ placeholder = "Search Core. const { commandPart, searchPart } = parseCommandInput(commandInput); const matchingCommands = getMatchingCommands(commandPart); const totalCommands = matchingCommands.length; - + switch (e.key) { case "ArrowDown": e.preventDefault(); @@ -449,7 +475,7 @@ const GlobalSearch: React.FC = ({ placeholder = "Search Core. {config.label} {item.result.description && • {item.result.description}} {item.result.highlight && ( - @@ -463,12 +489,14 @@ const GlobalSearch: React.FC = ({ placeholder = "Search Core. }; const getFacetCount = (filterKey: string): number => { - if (filterKey === "mine") return facets.mine; - if (filterKey === "contact") return facets.person; - if (filterKey === "organization") return facets.organization; - if (filterKey === "permit") return facets.permit; - if (filterKey === "explosives_permit") return facets.explosives_permit; - if (filterKey === "nod") return facets.nod; + if (filterKey === "mine") return facets.mine ?? 0; + if (filterKey === "contact") return facets.person ?? 0; + if (filterKey === "organization") return facets.organization ?? 0; + if (filterKey === "permit") return facets.permit ?? 0; + if (filterKey === "explosives_permit") return facets.explosives_permit ?? 0; + if (filterKey === "now_application") return facets.now_application ?? 0; + if (filterKey === "nod") return facets.nod ?? 0; + if (filterKey === "document") return (facets.mine_documents ?? 0) + (facets.permit_documents ?? 0); return 0; }; @@ -497,7 +525,7 @@ const GlobalSearch: React.FC = ({ placeholder = "Search Core. {Object.entries(TYPE_CONFIG).map(([key, config]) => { const isActive = activeFilters.includes(key); const count = getFacetCount(key); - + return ( = ({ placeholder = "Search Core. const renderCommands = () => { const { commandPart, searchPart } = parseCommandInput(commandInput); const matchingCommands = getMatchingCommands(commandPart); - + return (
@@ -569,7 +597,7 @@ const GlobalSearch: React.FC = ({ placeholder = "Search Core. const isSelected = index === selectedIndex; const isActive = isCommandActive(command.action); const isDisabled = command.action === "scope:mine" && !isOnMinePage; - + return ( = ({ placeholder = "Search Core. } @@ -620,7 +648,7 @@ const GlobalSearch: React.FC = ({ placeholder = "Search Core. } // Show results if we have a search term OR if scoped to mine (wildcard search) const hasActiveSearch = searchTerm || scopeToMine; - + if (hasActiveSearch && groupedResults) { let globalIndex = 0; return ( @@ -659,8 +687,8 @@ const GlobalSearch: React.FC = ({ placeholder = "Search Core. {scopeToMine && !searchTerm ? "No items found for this mine" : activeFilters.length > 0 - ? "Try removing some filters or adjusting your search" - : "Try adjusting your search or browse all results"} + ? "Try removing some filters or adjusting your search" + : "Try adjusting your search or browse all results"} {searchTerm && (
-
- } - size="large" - value={searchInputValue} - onChange={(e) => setSearchInputValue(e.target.value)} - onSearch={onSearch} - style={{ maxWidth: 600 }} - /> -
- - +
+ +
+
+
+

Search Results

+
+
+
+ } + size="large" + value={searchInputValue} + onChange={(e) => setSearchInputValue(e.target.value)} + onSearch={onSearch} + /> +
-
- -
- {isSearching ? ( -
- -
- Searching... -
+
+
+ {isSearching ? ( +
+ +
+ Searching...
- ) : totalResults === 0 && !hasActiveFilters ? ( - No results found for "{params.q}"} - style={{ padding: 48 }} - > - - - ) : totalResults === 0 && hasActiveFilters ? ( - -
- {renderFilters()} - - - No results match your filters} - style={{ padding: 48 }} - > - - - - - ) : ( - - - {renderFilters()} - - -
- - Showing {totalResults} results for "{params.q}" - {hasActiveFilters && " (filtered)"} - -
- - - - )} - - + + ) : ( + + + {renderFilters()} + + +
+ + {totalResults === 0 ? ( + <>No results for "{params.q}"{hasActiveFilters && " (filtered)"} + ) : ( + <>Showing {totalResults} results for "{params.q}"{hasActiveFilters && " (filtered)"} + )} + +
+ + + + )} + ); diff --git a/services/core-web/src/styles/components/GlobalSearch.scss b/services/core-web/src/styles/components/GlobalSearch.scss index f895e9ac79..38b9f027d6 100644 --- a/services/core-web/src/styles/components/GlobalSearch.scss +++ b/services/core-web/src/styles/components/GlobalSearch.scss @@ -9,6 +9,7 @@ background: rgba(255, 255, 255, 0.1) !important; border: 1px solid rgba(255, 255, 255, 0.2) !important; color: rgba(255, 255, 255, 0.8) !important; + margin-bottom: 0; border-radius: 8px !important; padding: 6px 12px !important; height: 36px !important; @@ -23,7 +24,7 @@ color: rgba(255, 255, 255, 0.95) !important; } - > .anticon { + >.anticon { font-size: 14px; } @@ -69,7 +70,7 @@ border-radius: 12px; overflow: hidden; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25), - 0 0 0 1px rgba(0, 0, 0, 0.05); + 0 0 0 1px rgba(0, 0, 0, 0.05); padding: 0; } @@ -87,7 +88,7 @@ // Input styling .ant-input-affix-wrapper { padding: 12px 16px; - + .ant-input { font-size: 16px; } @@ -99,7 +100,7 @@ padding: 10px 20px 6px; background: #fafafa; border-bottom: 1px solid #f0f0f0; - + .ant-divider-inner-text { font-size: 11px; font-weight: 600; @@ -251,8 +252,13 @@ } @keyframes spin { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } } // Results Container @@ -655,6 +661,7 @@ 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } @@ -672,8 +679,13 @@ } @keyframes fadeIn { - from { opacity: 0; } - to { opacity: 1; } + from { + opacity: 0; + } + + to { + opacity: 1; + } } // Search Results Page Improvements @@ -683,7 +695,7 @@ &__header { background: linear-gradient(135deg, $darkest-grey 0%, lighten($darkest-grey, 10%) 100%); - padding: 40px 0; + padding: 60px 0; .search-title { color: #fff; @@ -715,7 +727,7 @@ .ant-input { height: 100%; font-size: 16px; - + &::placeholder { color: #bfbfbf; } @@ -758,7 +770,7 @@ .ant-input-clear-icon { font-size: 14px; color: #bfbfbf; - + &:hover { color: #8c8c8c; } @@ -954,7 +966,7 @@ .search-input-wrapper { padding: 0 16px; - + .ant-input-search { .ant-input-wrapper { .ant-input-affix-wrapper { @@ -1063,4 +1075,4 @@ padding-top: 12px; border-top: 1px solid #f0f0f0; } -} +} \ No newline at end of file diff --git a/services/core-web/src/styles/components/HomePage.scss b/services/core-web/src/styles/components/HomePage.scss index 4cbd052a97..c5bdfdec30 100644 --- a/services/core-web/src/styles/components/HomePage.scss +++ b/services/core-web/src/styles/components/HomePage.scss @@ -7,19 +7,6 @@ overflow: hidden; } -#home-banner-search-container { - width: 100%; - justify-content: center; - - >.ant-col { - flex-basis: 709px; - } - - .ant-input-affix-wrapper-lg { - height: $nav-height; - } -} - .home-bordered-content { box-shadow: none; border: 1px solid $light-grey; diff --git a/services/core-web/src/styles/components/SearchResults.scss b/services/core-web/src/styles/components/SearchResults.scss new file mode 100644 index 0000000000..ec736a80c4 --- /dev/null +++ b/services/core-web/src/styles/components/SearchResults.scss @@ -0,0 +1,41 @@ +@use "../base.scss" as *; + +.search-results-tabs { + .ant-tabs-nav { + &::before { + border-bottom: 1px solid #f0f0f0; + } + } + + .ant-tabs-nav-list { + flex-wrap: wrap !important; + gap: 0; + } + + .ant-tabs-tab { + flex-shrink: 0; + margin: 0 16px 0 0 !important; + padding: 8px 0 !important; + + &:last-child { + margin-right: 0 !important; + } + } + + // Ensure tabs can wrap to next line + .ant-tabs-nav-wrap { + overflow: visible !important; + } + + .ant-tabs-nav-operations { + display: none !important; // Hide the dropdown arrows that Ant Design adds + } +} + +.search-results-page { + // Inherits from .landing-page styles + // Explicitly set background to match other pages + background-color: $secondary-background-colour !important; + + // tab__content padding comes from layout.scss +} diff --git a/services/core-web/src/tests/components/search/__snapshots__/GlobalSearch.spec.tsx.snap b/services/core-web/src/tests/components/search/__snapshots__/GlobalSearch.spec.tsx.snap new file mode 100644 index 0000000000..6c643b17e7 --- /dev/null +++ b/services/core-web/src/tests/components/search/__snapshots__/GlobalSearch.spec.tsx.snap @@ -0,0 +1,37 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`GlobalSearch renders properly 1`] = ` + + + +`; diff --git a/services/pgsync/Dockerfile b/services/pgsync/Dockerfile index f3edb516a0..257e942cc3 100644 --- a/services/pgsync/Dockerfile +++ b/services/pgsync/Dockerfile @@ -1,4 +1,4 @@ -FROM toluaina1/pgsync +FROM toluaina1/pgsync:7.0.5 USER root WORKDIR /app diff --git a/services/pgsync/schema.json b/services/pgsync/schema.json index f491c09cd0..b097dfbf2b 100644 --- a/services/pgsync/schema.json +++ b/services/pgsync/schema.json @@ -165,6 +165,7 @@ { "table": "permit", "columns": [ + "permit_id", "permit_guid", "permit_no", "permit_status_code" @@ -173,7 +174,9 @@ "relationship": { "type": "one_to_many", "variant": "object", - "through_tables": ["mine_permit_xref"] + "through_tables": [ + "mine_permit_xref" + ] } } ] @@ -192,15 +195,47 @@ "email", "phone_no", "deleted_ind" + ], + "children": [ + { + "table": "mine_party_appt", + "columns": [ + "mine_party_appt_guid", + "mine_party_appt_id", + "mine_guid", + "mine_party_appt_type_code", + "start_date", + "end_date", + "status", + "mine_party_acknowledgement_status", + "permit_id", + "mine_tailings_storage_facility_guid", + "deleted_ind" + ], + "label": "mine_party_appointments", + "relationship": { + "type": "one_to_many", + "variant": "object", + "foreign_key": { + "child": [ + "party_guid" + ], + "parent": [ + "party_guid" + ] + } + } + } ] } }, { "database": "mds", - "index": "permits", + "index": "mine_permits", "nodes": { "table": "permit", "columns": [ + "permit_id", "permit_guid", "permit_no", "permit_status_code", @@ -211,7 +246,8 @@ { "table": "mine_permit_xref", "columns": [ - "mine_guid" + "mine_guid", + "permit_id" ], "label": "mine_guids", "relationship": { @@ -226,21 +262,6 @@ ] } } - }, - { - "table": "party", - "columns": [ - "party_guid", - "first_name", - "party_name", - "email" - ], - "label": "permittees", - "relationship": { - "type": "one_to_many", - "variant": "object", - "through_tables": ["mine_party_appt"] - } } ] } diff --git a/services/postgres/postgresql.conf b/services/postgres/postgresql.conf index ca7053b73c..8193c44de8 100644 --- a/services/postgres/postgresql.conf +++ b/services/postgres/postgresql.conf @@ -208,7 +208,7 @@ dynamic_shared_memory_type = posix # the default is usually the first option # - Settings - -#wal_level = replica # minimal, replica, or logical +wal_level = logical # minimal, replica, or logical # (change requires restart) #fsync = on # flush data to disk for crash safety # (turning this off can cause @@ -313,7 +313,7 @@ min_wal_size = 80MB #max_wal_senders = 10 # max number of walsender processes # (change requires restart) -#max_replication_slots = 10 # max number of replication slots +max_replication_slots = 10 # max number of replication slots # (change requires restart) #wal_keep_size = 0 # in megabytes; 0 disables #max_slot_wal_keep_size = -1 # in megabytes; -1 disables From 21afa57940d29bc50ad76e72d34aa23fa888915c Mon Sep 17 00:00:00 2001 From: Simen Fivelstad Smaaberg Date: Thu, 15 Jan 2026 07:28:04 -0800 Subject: [PATCH 08/25] Added pgsync github actions --- .github/workflows/pgsync.build.dev.yaml | 34 ++++++++++++ .github/workflows/pgsync.deploy.prod.yaml | 65 +++++++++++++++++++++++ .github/workflows/pgsync.deploy.test.yaml | 65 +++++++++++++++++++++++ docker-compose.M1.yaml | 2 +- docker-compose.yaml | 1 + 5 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/pgsync.build.dev.yaml create mode 100644 .github/workflows/pgsync.deploy.prod.yaml create mode 100644 .github/workflows/pgsync.deploy.test.yaml diff --git a/.github/workflows/pgsync.build.dev.yaml b/.github/workflows/pgsync.build.dev.yaml new file mode 100644 index 0000000000..04e26e95be --- /dev/null +++ b/.github/workflows/pgsync.build.dev.yaml @@ -0,0 +1,34 @@ +name: PGSync Image Build DEV + +on: + workflow_dispatch: + push: + branches: + - develop + paths: + - services/pgsync/** + - .github/workflows/pgsync.build.dev.yaml + +env: + INITIAL_TAG: latest + TAG: dev + NAME: pgsync + CONTEXT: services/pgsync/ + +jobs: + build-pgsync: + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Login + run: | + docker login -u ${{ secrets.CLUSTER_REGISTRY_USER }} -p ${{ secrets.BUILD_TOKEN }} ${{ secrets.CLUSTER_REGISTRY }} + - name: Build n Tag + run: | + docker build -t ${{ env.NAME }}:${{ env.INITIAL_TAG }} ${{ env.CONTEXT }} -f ${{ env.CONTEXT }}Dockerfile + docker tag ${{ env.NAME }}:${{ env.INITIAL_TAG }} ${{ secrets.CLUSTER_REGISTRY }}/${{ secrets.NS_TOOLS }}/${{ env.NAME }}:${{ env.INITIAL_TAG }} + docker tag ${{ env.NAME }}:${{ env.INITIAL_TAG }} ${{ secrets.CLUSTER_REGISTRY }}/${{ secrets.NS_TOOLS }}/${{ env.NAME }}:${{ env.TAG }} + - name: Push + run: | + docker push --all-tags ${{ secrets.CLUSTER_REGISTRY }}/${{ secrets.NS_TOOLS }}/${{ env.NAME }} diff --git a/.github/workflows/pgsync.deploy.prod.yaml b/.github/workflows/pgsync.deploy.prod.yaml new file mode 100644 index 0000000000..a4e03d9f7c --- /dev/null +++ b/.github/workflows/pgsync.deploy.prod.yaml @@ -0,0 +1,65 @@ +name: PGSync - Promote PROD + +on: + workflow_dispatch: + +env: + ORIG_TAG: test + PROMOTE_TAG: prod + IMAGE: pgsync + +jobs: + promote-image: + name: promote-image + runs-on: ubuntu-24.04 + steps: + - name: Install oc + uses: redhat-actions/openshift-tools-installer@v1 + with: + oc: "4.7" + + - name: oc login + run: | + oc login --token=${{ secrets.BUILD_TOKEN }} --server=${{ secrets.CLUSTER_API }} + + - name: Promote from test to prod + run: | + oc -n ${{secrets.NS_TOOLS}} tag \ + ${{ secrets.NS_TOOLS }}/${{ env.IMAGE }}:${{ env.ORIG_TAG }} \ + ${{ secrets.NS_TOOLS }}/${{ env.IMAGE }}:${{ env.PROMOTE_TAG }} + + trigger-gitops: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + needs: promote-image + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Setup SSH Agent + uses: ./.github/actions/setup-ssh + with: + ssh-private-key: ${{ secrets.GITOPS_REPO_DEPLOY_KEY }} + + - name: Git Ops Push + run: ./gitops/commit.sh ${{ env.IMAGE }} test prod ${{ github.actor }} + - name: Install oc + uses: redhat-actions/openshift-tools-installer@v1 + with: + oc: "4.7" + - name: Setup ArgoCD CLI + uses: imajeetyadav/argocd-cli@v1 + with: + version: v2.7.9 # optional + - name: oc login + run: oc login --token=${{ secrets.BUILD_TOKEN }} --server=${{ secrets.CLUSTER_API }} + - name: Notification + run: ./gitops/watch-deployment.sh pgsync prod ${{ github.sha }} ${{ secrets.DISCORD_DEPLOYMENT_WEBHOOK }} ${{ secrets.ARGOCD_SERVER }} ${{ secrets.ARGO_CD_CLI_JWT }} + + run-if-failed: + runs-on: ubuntu-24.04 + needs: [trigger-gitops] + if: always() && (needs.trigger-gitops.result == 'failure') + steps: + - name: Notification + run: ./gitops/watch-deployment.sh pgsync prod ${{ github.sha }} ${{ secrets.DISCORD_DEPLOYMENT_WEBHOOK }} 1 diff --git a/.github/workflows/pgsync.deploy.test.yaml b/.github/workflows/pgsync.deploy.test.yaml new file mode 100644 index 0000000000..e0c60fca6e --- /dev/null +++ b/.github/workflows/pgsync.deploy.test.yaml @@ -0,0 +1,65 @@ +name: PGSync - Promote Test + +on: + workflow_dispatch: + +env: + ORIG_TAG: dev + PROMOTE_TAG: test + IMAGE: pgsync + +jobs: + promote-image: + name: promote-image + runs-on: ubuntu-24.04 + steps: + - name: Install oc + uses: redhat-actions/openshift-tools-installer@v1 + with: + oc: "4.7" + + - name: oc login + run: | + oc login --token=${{ secrets.BUILD_TOKEN }} --server=${{ secrets.CLUSTER_API }} + + - name: Promote from dev to test + run: | + oc -n ${{secrets.NS_TOOLS}} tag \ + ${{ secrets.NS_TOOLS }}/${{ env.IMAGE }}:${{ env.ORIG_TAG }} \ + ${{ secrets.NS_TOOLS }}/${{ env.IMAGE }}:${{ env.PROMOTE_TAG }} + + trigger-gitops: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + needs: promote-image + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Setup SSH Agent + uses: ./.github/actions/setup-ssh + with: + ssh-private-key: ${{ secrets.GITOPS_REPO_DEPLOY_KEY }} + + - name: Git Ops Push + run: ./gitops/commit.sh ${{ env.IMAGE }} dev test ${{ github.actor }} + - name: Install oc + uses: redhat-actions/openshift-tools-installer@v1 + with: + oc: "4.7" + - name: Setup ArgoCD CLI + uses: imajeetyadav/argocd-cli@v1 + with: + version: v2.7.9 # optional + - name: oc login + run: oc login --token=${{ secrets.BUILD_TOKEN }} --server=${{ secrets.CLUSTER_API }} + - name: Notification + run: ./gitops/watch-deployment.sh pgsync test ${{ github.sha }} ${{ secrets.DISCORD_DEPLOYMENT_WEBHOOK }} ${{ secrets.ARGOCD_SERVER }} ${{ secrets.ARGO_CD_CLI_JWT }} + + run-if-failed: + runs-on: ubuntu-24.04 + needs: [trigger-gitops] + if: always() && (needs.trigger-gitops.result == 'failure') + steps: + - name: Notification + run: ./gitops/watch-deployment.sh pgsync test ${{ github.sha }} ${{ secrets.DISCORD_DEPLOYMENT_WEBHOOK }} 1 diff --git a/docker-compose.M1.yaml b/docker-compose.M1.yaml index 27f3355909..716f610aba 100644 --- a/docker-compose.M1.yaml +++ b/docker-compose.M1.yaml @@ -132,7 +132,7 @@ services: - ELASTICSEARCH_PORT=9200 - ELASTICSEARCH_SCHEME=https - ELASTICSEARCH_USER=elastic - - ELASTICSEARCH_PASSWORD=changeme + - ELASTICSEARCH_PASSWORD=elastic - ELASTICSEARCH_VERIFY_CERTS=false - ELASTICSEARCH_CA_CERTS=/certs/ca/ca.crt - PGSYNC_CHECKPOINT_PATH=/tmp diff --git a/docker-compose.yaml b/docker-compose.yaml index f67970d2f8..124cf181c3 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -170,6 +170,7 @@ services: - otelcollector - keycloak - core_api_celery + - pgsync healthcheck: test: ["CMD", "curl", "localhost:5000/health"] interval: 5s From cb61d900b326c426677a0ccfbec5aa26588ef3a0 Mon Sep 17 00:00:00 2001 From: Simen Fivelstad Smaaberg Date: Tue, 20 Jan 2026 13:13:56 -0800 Subject: [PATCH 09/25] MDS-6743 Updated search v2 tests --- .../tailings/BasicInformation.spec.tsx | 12 + .../BasicInformation.spec.tsx.snap | 2 +- .../src/redux/reducers/searchReducer.ts | 2 +- .../searchActionCreator.spec.ts | 104 ++ .../common/src/tests/mocks/searchMockData.ts | 303 ++++++ .../tests/selectors/searchSelectors.spec.ts | 397 ++++++++ .../common/src/tests/utils/RequestHeaders.ts | 4 + .../app/api/search/response_models.py | 2 +- .../search/search/global_search_service.py | 6 +- .../app/api/search/search/resources/search.py | 90 +- .../search/search/resources/simple_search.py | 49 +- .../app/api/search/search/search_facets.py | 4 +- .../api/search/search/search_transformers.py | 280 +++--- .../core-api/app/api/utils/feature_flag.py | 5 +- .../search/resource/test_search_resource.py | 42 +- .../resource/test_search_resource_v2.py | 412 ++++++++ .../search/test_global_search_service.py | 313 +++++++ .../tests/search/test_search_constants.py | 205 ++++ .../tests/search/test_search_facets.py | 180 ++++ .../tests/search/test_search_filters.py | 154 +++ .../tests/search/test_search_transformers.py | 357 +++++++ ...tConditionSearch.integration.spec.tsx.snap | 105 ++- .../Search/components/MarkdownViewer.spec.tsx | 28 +- .../MarkdownViewer.spec.tsx.snap | 2 +- ...esultsTable.js => GenericResultsTable.tsx} | 1 - .../src/components/search/GlobalSearch.tsx | 847 ----------------- .../components/EmptySearchState.tsx | 103 ++ .../components/RecentSearches.tsx | 49 + .../GlobalSearch/components/SearchFilters.tsx | 86 ++ .../components/SearchResultItem.tsx | 61 ++ .../components/SearchTriggerButton.tsx | 25 + .../GlobalSearch/hooks/useRecentSearches.ts | 32 + .../components/search/GlobalSearch/index.tsx | 398 ++++++++ .../GlobalSearch/utils/searchConfig.tsx | 167 ++++ .../GlobalSearch/utils/searchHelpers.tsx | 21 + .../src/components/search/SearchResultsV2.tsx | 96 +- .../src/styles/components/GlobalSearch.scss | 562 +---------- .../searchActionCreator.spec.js | 3 +- .../permits/conditions/Condition.spec.tsx | 12 + .../__snapshots__/Condition.spec.tsx.snap | 92 +- .../__snapshots__/HomePage.spec.tsx.snap | 107 ++- .../__snapshots__/NavBar.spec.tsx.snap | 82 +- .../search/GenericResultsTable.spec.tsx | 556 +++++++++++ .../components/search/SearchBar.spec.tsx | 361 +++++++ .../components/search/SearchResults.spec.tsx | 9 +- .../search/SearchResultsV2.spec.tsx | 312 ++++++ .../__snapshots__/SearchBar.spec.tsx.snap | 46 - .../SearchBarDropdown.spec.tsx.snap | 72 -- .../__snapshots__/SearchResults.spec.tsx.snap | 886 ++++++++++++++++-- .../src/tests/reducers/searchReducer.spec.js | 30 + .../DashboardRoutes.spec.tsx.snap | 10 +- 51 files changed, 6065 insertions(+), 2019 deletions(-) create mode 100644 services/common/src/tests/actionCreators/searchActionCreator.spec.ts create mode 100644 services/common/src/tests/mocks/searchMockData.ts create mode 100644 services/common/src/tests/selectors/searchSelectors.spec.ts create mode 100644 services/common/src/tests/utils/RequestHeaders.ts create mode 100644 services/core-api/tests/search/resource/test_search_resource_v2.py create mode 100644 services/core-api/tests/search/test_global_search_service.py create mode 100644 services/core-api/tests/search/test_search_constants.py create mode 100644 services/core-api/tests/search/test_search_facets.py create mode 100644 services/core-api/tests/search/test_search_filters.py create mode 100644 services/core-api/tests/search/test_search_transformers.py rename services/core-web/src/components/search/{GenericResultsTable.js => GenericResultsTable.tsx} (96%) delete mode 100644 services/core-web/src/components/search/GlobalSearch.tsx create mode 100644 services/core-web/src/components/search/GlobalSearch/components/EmptySearchState.tsx create mode 100644 services/core-web/src/components/search/GlobalSearch/components/RecentSearches.tsx create mode 100644 services/core-web/src/components/search/GlobalSearch/components/SearchFilters.tsx create mode 100644 services/core-web/src/components/search/GlobalSearch/components/SearchResultItem.tsx create mode 100644 services/core-web/src/components/search/GlobalSearch/components/SearchTriggerButton.tsx create mode 100644 services/core-web/src/components/search/GlobalSearch/hooks/useRecentSearches.ts create mode 100644 services/core-web/src/components/search/GlobalSearch/index.tsx create mode 100644 services/core-web/src/components/search/GlobalSearch/utils/searchConfig.tsx create mode 100644 services/core-web/src/components/search/GlobalSearch/utils/searchHelpers.tsx create mode 100644 services/core-web/src/tests/components/search/GenericResultsTable.spec.tsx create mode 100644 services/core-web/src/tests/components/search/SearchBar.spec.tsx create mode 100644 services/core-web/src/tests/components/search/SearchResultsV2.spec.tsx delete mode 100644 services/core-web/src/tests/components/search/__snapshots__/SearchBar.spec.tsx.snap delete mode 100644 services/core-web/src/tests/components/search/__snapshots__/SearchBarDropdown.spec.tsx.snap diff --git a/services/common/src/components/tailings/BasicInformation.spec.tsx b/services/common/src/components/tailings/BasicInformation.spec.tsx index 08cef5cd1d..c907863e83 100644 --- a/services/common/src/components/tailings/BasicInformation.spec.tsx +++ b/services/common/src/components/tailings/BasicInformation.spec.tsx @@ -18,6 +18,18 @@ const initialState = { describe("Tailings BasicInformation", () => { + const originalTZ = process.env.TZ; + + beforeAll(() => { + // Set timezone to America/Vancouver (PST/PDT) to match snapshot + process.env.TZ = 'America/Vancouver'; + }); + + afterAll(() => { + // Restore original timezone + process.env.TZ = originalTZ; + }); + it("renders properly", () => { const { container } = render( diff --git a/services/common/src/components/tailings/__snapshots__/BasicInformation.spec.tsx.snap b/services/common/src/components/tailings/__snapshots__/BasicInformation.spec.tsx.snap index 038d4a481d..537c8c3eb4 100644 --- a/services/common/src/components/tailings/__snapshots__/BasicInformation.spec.tsx.snap +++ b/services/common/src/components/tailings/__snapshots__/BasicInformation.spec.tsx.snap @@ -49,7 +49,7 @@ exports[`Tailings BasicInformation renders properly 1`] = `
- Last Updated by test@bceid on Apr 5, 2019 9:05 PM + Last Updated by test@bceid on Apr 5, 2019 2:05 PM
state[SEARCH].searchOptions; export const getSearchResults = (state): ISearchResultList => state[SEARCH].searchResults; export const getSearchFacets = (state) => state[SEARCH].searchFacets; export const getSearchBarResults = (state): ISearchResult[] => state[SEARCH].searchBarResults; -export const getSearchBarFacets = (state): { mine: number; person: number; organization: number; permit: number; nod: number; explosives_permit: number; now_application: number } => state[SEARCH].searchBarFacets; +export const getSearchBarFacets = (state): { mine: number; person: number; organization: number; permit: number; nod: number; explosives_permit: number; now_application: number; mine_documents: number; permit_documents: number } => state[SEARCH].searchBarFacets; export const getSearchTerms = (state) => state[SEARCH].searchTerms; export const getSearchSubsetResults = (state) => state[SEARCH].searchSubsetResults; diff --git a/services/common/src/tests/actionCreators/searchActionCreator.spec.ts b/services/common/src/tests/actionCreators/searchActionCreator.spec.ts new file mode 100644 index 0000000000..0d3d51f8a4 --- /dev/null +++ b/services/common/src/tests/actionCreators/searchActionCreator.spec.ts @@ -0,0 +1,104 @@ +import MockAdapter from "axios-mock-adapter"; +import axios from "axios"; +import { ENVIRONMENT } from "@mds/common/constants/environment"; +import { fetchSearchResults, fetchSearchOptions } from "@mds/common/redux/actionCreators/searchActionCreator"; +import * as API from "@mds/common/constants/API"; +import * as MOCK from "../mocks/dataMocks"; +import * as genericActions from "@mds/common/redux/actions/genericActions"; + +const dispatch = jest.fn(); +const requestSpy = jest.spyOn(genericActions, "request"); +const successSpy = jest.spyOn(genericActions, "success"); +const errorSpy = jest.spyOn(genericActions, "error"); +const mockAxios = new MockAdapter(axios); + +describe("searchActionCreator", () => { + beforeEach(() => { + mockAxios.reset(); + dispatch.mockClear(); + requestSpy.mockClear(); + successSpy.mockClear(); + errorSpy.mockClear(); + }); + + describe("fetchSearchResults", () => { + it("Request successful, dispatches success with correct response", () => { + const searchTerm = "test"; + const searchTypes = "mine,party"; + + const mockResponse = { + search_terms: ["test"], + search_results: MOCK.SEARCH_RESULTS_V2, + facets: MOCK.SEARCH_FACETS, + }; + + mockAxios.onGet().reply(200, mockResponse); + + return fetchSearchResults(searchTerm, searchTypes)(dispatch).then(() => { + expect(requestSpy).toHaveBeenCalledTimes(1); + expect(successSpy).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalled(); + }); + }); + + it("Request failure, dispatches error with correct response", () => { + const searchTerm = "test"; + + mockAxios.onGet().reply(500, { error: "Internal server error" }); + + return fetchSearchResults(searchTerm)(dispatch).then(() => { + expect(requestSpy).toHaveBeenCalledTimes(1); + expect(errorSpy).toHaveBeenCalledTimes(1); + }); + }); + + it("includes search terms in request", () => { + const searchTerm = "test mine"; + + mockAxios.onGet().reply(200, { + search_terms: ["test", "mine"], + search_results: {}, + facets: {}, + }); + + return fetchSearchResults(searchTerm)(dispatch).then(() => { + expect(mockAxios.history.get[0].url).toContain("search_term"); + expect(requestSpy).toHaveBeenCalledTimes(1); + }); + }); + + it("handles network errors", () => { + const searchTerm = "test"; + + mockAxios.onGet().networkError(); + + return fetchSearchResults(searchTerm)(dispatch).then(() => { + expect(requestSpy).toHaveBeenCalledTimes(1); + expect(errorSpy).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe("fetchSearchOptions", () => { + it("Request successful, dispatches success with correct response", () => { + const mockOptions = MOCK.SEARCH_OPTIONS || []; + + mockAxios.onGet().reply(200, mockOptions); + + return fetchSearchOptions()(dispatch).then(() => { + expect(requestSpy).toHaveBeenCalledTimes(1); + expect(successSpy).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalled(); + }); + }); + + it("Request failure, dispatches error with correct response", () => { + mockAxios.onGet().reply(500, { error: "Internal server error" }); + + return fetchSearchOptions()(dispatch).then(() => { + expect(requestSpy).toHaveBeenCalledTimes(1); + expect(errorSpy).toHaveBeenCalledTimes(1); + }); + }); + }); +}); diff --git a/services/common/src/tests/mocks/searchMockData.ts b/services/common/src/tests/mocks/searchMockData.ts new file mode 100644 index 0000000000..2371f0ac62 --- /dev/null +++ b/services/common/src/tests/mocks/searchMockData.ts @@ -0,0 +1,303 @@ +/** + * Mock data for V2 search functionality with facets and filters + */ + +import { ISearchResultList } from "@mds/common/interfaces/search/searchResult.interface"; +import { MINES, PARTY, PERMITS, MINEDOCUMENTS, EXPLOSIVES_PERMITS, NOW } from "./dataMocks"; + +export const SEARCH_RESULTS_V2: ISearchResultList = { + mine: [ + { + type: "mine", + score: 10.5, + result: MINES[0], + }, + { + type: "mine", + score: 8.3, + result: MINES[1], + }, + ], + party: [ + { + type: "party", + score: 9.2, + result: PARTY.parties["18133c75-49ad-4101-85f3-a43e35ae989a"], + }, + ], + permit: [ + { + type: "permit", + score: 8.7, + result: PERMITS[0], + }, + ], + mine_documents: [ + { + type: "mine_documents", + score: 6.5, + result: MINEDOCUMENTS[0], + }, + ], + permit_documents: [ + { + type: "permit_documents", + score: 6.3, + result: MINEDOCUMENTS[0], + }, + ], + explosives_permit: [ + { + type: "explosives_permit", + score: 7.2, + result: EXPLOSIVES_PERMITS[0], + }, + ], + now_application: [ + { + type: "now_application", + score: 6.8, + result: NOW[0], + } + ], + notice_of_departure: [ + { + type: "notice_of_departure", + score: 5.9, + result: { + nod_guid: "test-nod-guid-1", + nod_no: "NOD-001", + nod_title: "Test Notice of Departure", + mine_name: "Test Mine One", + nod_status: "pending_review", + mine_guid: "", + nod_type: "" + }, + }, + ], +}; + +export const SEARCH_FACETS = { + mine_region: [ + { key: "SW", count: 15 }, + { key: "NE", count: 10 }, + { key: "NW", count: 8 }, + { key: "SE", count: 5 }, + { key: "SC", count: 12 }, + ], + mine_classification: [ + { key: "Major Mine", count: 20 }, + { key: "Regional Mine", count: 30 }, + ], + mine_operation_status: [ + { key: "OP", count: 25 }, + { key: "CLD", count: 15 }, + { key: "NS", count: 10 }, + ], + mine_tenure: [ + { key: "PLR", count: 18 }, + { key: "MIN", count: 12 }, + { key: "BCL", count: 8 }, + ], + mine_commodity: [ + { key: "CU", count: 15 }, + { key: "AU", count: 12 }, + { key: "AG", count: 8 }, + { key: "ZN", count: 6 }, + ], + has_tsf: [ + { key: "Yes", count: 22 }, + { key: "No", count: 28 }, + ], + verified_status: [ + { key: "Verified", count: 35 }, + { key: "Unverified", count: 15 }, + ], + permit_status: [ + { key: "O", count: 28 }, + { key: "C", count: 12 }, + { key: "D", count: 5 }, + ], + is_exploration: [ + { key: "Yes", count: 18 }, + { key: "No", count: 27 }, + ], + party_type: [ + { key: "PER", count: 45 }, + { key: "ORG", count: 30 }, + ], + explosives_permit_status: [ + { key: "APP", count: 10 }, + { key: "REC", count: 15 }, + { key: "REJ", count: 3 }, + ], + explosives_permit_closed: [ + { key: "Yes", count: 8 }, + { key: "No", count: 20 }, + ], + nod_type: [ + { key: "temporary", count: 12 }, + { key: "permanent", count: 8 }, + ], + nod_status: [ + { key: "pending_review", count: 10 }, + { key: "approved", count: 15 }, + { key: "rejected", count: 3 }, + ], + now_application_status: [ + { key: "REC", count: 20 }, + { key: "REF", count: 10 }, + { key: "AIA", count: 8 }, + ], + now_type: [ + { key: "QIM", count: 15 }, + { key: "SAG", count: 12 }, + { key: "QCA", count: 8 }, + ], + type: [ + { key: "mine", count: 50 }, + { key: "party", count: 75 }, + { key: "permit", count: 45 }, + { key: "permit_documents", count: 22 }, + { key: "mine_documents", count: 35 }, + { key: "explosives_permit", count: 28 }, + { key: "now_application", count: 38 }, + { key: "notice_of_departure", count: 20 }, + ], +}; + +export const SEARCH_OPTIONS = [ + { model_id: "mine", description: "Mines" }, + { model_id: "party", description: "Contacts" }, + { model_id: "permit", description: "Permits" }, + { model_id: "permit_documents", description: "Permit Documents" }, + { model_id: "mine_documents", description: "Mine Documents" }, + { model_id: "explosives_permit", description: "Explosives Permits" }, + { model_id: "now_application", description: "NoW Applications" }, + { model_id: "notice_of_departure", description: "Notices of Departure" }, +]; + +export const SIMPLE_SEARCH_RESULTS = [ + { + type: "mine", + score: 10.0, + result: { + id: "mine-123", + value: "Test Mine", + description: "M-001", + mine_guid: "test-mine-guid-1", + }, + }, + { + type: "person", + score: 8.5, + result: { + id: "party-123", + value: "John Doe", + description: "john.doe@example.com | 555-1234", + mine_guid: null, + }, + }, + { + type: "organization", + score: 7.5, + result: { + id: "party-456", + value: "ACME Corporation", + description: "contact@acme.com", + mine_guid: null, + }, + }, + { + type: "permit", + score: 9.0, + result: { + id: "permit-789", + value: "P-001", + description: "John Doe | Status: O", + mine_guid: "test-mine-guid-1", + }, + }, +]; + +export const SIMPLE_SEARCH_FACETS = { + mine: 50, + person: 45, + organization: 30, + permit: 45, + nod: 20, + explosives_permit: 28, + now_application: 38, +}; + +export const EMPTY_SEARCH_RESULTS = { + mine: [], + party: [], + permit: [], + permit_documents: [], + mine_documents: [], + explosives_permit: [], + now_application: [], + notice_of_departure: [], +}; + +export const EMPTY_SEARCH_FACETS = { + mine_region: [], + mine_classification: [], + mine_operation_status: [], + mine_tenure: [], + mine_commodity: [], + has_tsf: [], + verified_status: [], + permit_status: [], + party_type: [], + type: [], +}; + +// Search parameters for testing +export const SEARCH_PARAMS = { + basic: { + search_term: "test", + }, + withTypes: { + search_term: "test", + search_types: ["mine", "party"], + }, + withFilters: { + search_term: "test", + mine_region: ["SW", "NE"], + permit_status: ["O"], + }, + withMultipleFilters: { + search_term: "test mine", + mine_region: ["SW"], + mine_classification: ["Major Mine"], + permit_status: ["O"], + party_type: ["PER"], + }, + empty: { + search_term: "nonexistent", + }, +}; + +// URL query string examples +export const SEARCH_URLS = { + basic: "?q=test", + withTypes: "?q=test&search_types=mine,party", + withFilters: "?q=test&mine_region=SW,NE&permit_status=O", + withPagination: "?q=test&page=2", + scoped: "?q=test&mine_guid=test-mine-guid-1", +}; + + +export default { + SEARCH_RESULTS_V2, + SEARCH_FACETS, + SEARCH_OPTIONS, + SIMPLE_SEARCH_RESULTS, + SIMPLE_SEARCH_FACETS, + EMPTY_SEARCH_RESULTS, + EMPTY_SEARCH_FACETS, + SEARCH_PARAMS, + SEARCH_URLS, +}; diff --git a/services/common/src/tests/selectors/searchSelectors.spec.ts b/services/common/src/tests/selectors/searchSelectors.spec.ts new file mode 100644 index 0000000000..13f4f922c7 --- /dev/null +++ b/services/common/src/tests/selectors/searchSelectors.spec.ts @@ -0,0 +1,397 @@ +import { + getSearchResults, + getSearchFacets, + getSearchTerms, + getSearchBarResults, +} from "@mds/common/redux/selectors/searchSelectors"; +import { SEARCH } from "@mds/common/constants/reducerTypes"; +import * as MOCK from "../mocks/dataMocks"; + +describe("searchSelectors", () => { + describe("getSearchResults", () => { + it("returns search results from state", () => { + const state = { + [SEARCH]: { + searchResults: MOCK.SEARCH_RESULTS_V2, + }, + }; + + const results = getSearchResults(state); + expect(results).toEqual(MOCK.SEARCH_RESULTS_V2); + }); + + it("returns empty object when no results", () => { + const state = { + [SEARCH]: { + searchResults: {}, + }, + }; + + const results = getSearchResults(state); + expect(results).toEqual({}); + }); + + it("handles undefined search state", () => { + const state = { + [SEARCH]: undefined, + }; + + expect(() => getSearchResults(state)).toThrow(); + }); + + it("returns results for specific types", () => { + const state = { + [SEARCH]: { + searchResults: { + mine: [{ type: "mine", score: 10, result: {} }], + party: [{ type: "party", score: 8, result: {} }], + }, + }, + }; + + const results = getSearchResults(state); + expect(results.mine).toHaveLength(1); + expect(results.party).toHaveLength(1); + }); + }); + + describe("getSearchFacets", () => { + it("returns search facets from state", () => { + const state = { + [SEARCH]: { + searchFacets: MOCK.SEARCH_FACETS, + }, + }; + + const facets = getSearchFacets(state); + expect(facets).toEqual(MOCK.SEARCH_FACETS); + }); + + it("returns empty object when no facets", () => { + const state = { + [SEARCH]: { + searchFacets: {}, + }, + }; + + const facets = getSearchFacets(state); + expect(facets).toEqual({}); + }); + + it("handles undefined facets", () => { + const state = { + [SEARCH]: { + searchFacets: undefined, + }, + }; + + const facets = getSearchFacets(state); + expect(facets).toBeUndefined(); + }); + + it("returns facets with counts", () => { + const state = { + [SEARCH]: { + searchFacets: { + mine_region: [ + { key: "SW", count: 10 }, + { key: "NE", count: 5 }, + ], + }, + }, + }; + + const facets = getSearchFacets(state); + expect(facets.mine_region).toHaveLength(2); + expect(facets.mine_region[0].count).toBe(10); + }); + }); + + describe("getSearchTerms", () => { + it("returns search terms from state", () => { + const state = { + [SEARCH]: { + searchTerms: ["test", "mine"], + }, + }; + + const terms = getSearchTerms(state); + expect(terms).toEqual(["test", "mine"]); + }); + + it("returns empty array when no terms", () => { + const state = { + [SEARCH]: { + searchTerms: [], + }, + }; + + const terms = getSearchTerms(state); + expect(terms).toEqual([]); + }); + + it("handles undefined search terms", () => { + const state = { + [SEARCH]: { + searchTerms: undefined, + }, + }; + + const terms = getSearchTerms(state); + expect(terms).toBeUndefined(); + }); + + it("returns single term as array", () => { + const state = { + [SEARCH]: { + searchTerms: ["test"], + }, + }; + + const terms = getSearchTerms(state); + expect(terms).toEqual(["test"]); + expect(Array.isArray(terms)).toBe(true); + }); + + it("preserves term order", () => { + const state = { + [SEARCH]: { + searchTerms: ["first", "second", "third"], + }, + }; + + const terms = getSearchTerms(state); + expect(terms[0]).toBe("first"); + expect(terms[1]).toBe("second"); + expect(terms[2]).toBe("third"); + }); + }); + + describe("getSearchBarResults", () => { + it("returns search bar results from state", () => { + const mockBarResults = [ + { + type: "mine", + score: 10, + result: { + id: "mine-123", + value: "Test Mine", + }, + }, + ]; + + const state = { + [SEARCH]: { + searchBarResults: mockBarResults, + }, + }; + + const results = getSearchBarResults(state); + expect(results).toEqual(mockBarResults); + }); + + it("returns empty array when no bar results", () => { + const state = { + [SEARCH]: { + searchBarResults: [], + }, + }; + + const results = getSearchBarResults(state); + expect(results).toEqual([]); + }); + + it("handles undefined bar results", () => { + const state = { + [SEARCH]: { + searchBarResults: undefined, + }, + }; + + const results = getSearchBarResults(state); + expect(results).toBeUndefined(); + }); + + it("returns results with highlights", () => { + const mockBarResults = [ + { + type: "party", + score: 8, + result: { + id: "party-123", + value: "John Doe", + highlight: "John Doe", + }, + }, + ]; + + const state = { + [SEARCH]: { + searchBarResults: mockBarResults, + }, + }; + + const results = getSearchBarResults(state); + expect(results[0].result.highlight).toContain(""); + }); + + it("returns limited number of results", () => { + const mockBarResults = Array.from({ length: 4 }, (_, i) => ({ + type: "mine", + score: 10 - i, + result: { + id: `mine-${i}`, + value: `Mine ${i}`, + }, + })); + + const state = { + [SEARCH]: { + searchBarResults: mockBarResults, + }, + }; + + const results = getSearchBarResults(state); + expect(results).toHaveLength(4); + }); + }); + + describe("Selector Composition", () => { + it("handles complete search state", () => { + const state = { + [SEARCH]: { + searchResults: MOCK.SEARCH_RESULTS_V2, + searchFacets: MOCK.SEARCH_FACETS, + searchTerms: ["test"], + searchBarResults: [], + }, + }; + + expect(getSearchResults(state)).toEqual(MOCK.SEARCH_RESULTS_V2); + expect(getSearchFacets(state)).toEqual(MOCK.SEARCH_FACETS); + expect(getSearchTerms(state)).toEqual(["test"]); + expect(getSearchBarResults(state)).toEqual([]); + }); + + it("handles empty search state", () => { + const state = { + [SEARCH]: { + searchResults: {}, + searchFacets: {}, + searchTerms: [], + searchBarResults: [], + }, + }; + + expect(getSearchResults(state)).toEqual({}); + expect(getSearchFacets(state)).toEqual({}); + expect(getSearchTerms(state)).toEqual([]); + expect(getSearchBarResults(state)).toEqual([]); + }); + }); + + describe("Edge Cases", () => { + it("handles null state", () => { + const state = { + [SEARCH]: null, + }; + + expect(() => getSearchResults(state)).toThrow(); + }); + + it("handles nested null values", () => { + const state = { + [SEARCH]: { + searchResults: null, + searchFacets: null, + searchTerms: null, + }, + }; + + expect(getSearchResults(state)).toBeNull(); + expect(getSearchFacets(state)).toBeNull(); + expect(getSearchTerms(state)).toBeNull(); + }); + + it("handles very large result sets", () => { + const largeResults = { + mine: Array.from({ length: 1000 }, (_, i) => ({ + type: "mine", + score: 10 - i * 0.01, + result: { mine_guid: `mine-${i}` }, + })), + }; + + const state = { + [SEARCH]: { + searchResults: largeResults, + }, + }; + + const results = getSearchResults(state); + expect(results.mine).toHaveLength(1000); + }); + + it("handles facets with many values", () => { + const manyFacetValues = { + mine_region: Array.from({ length: 100 }, (_, i) => ({ + key: `Region-${i}`, + count: Math.floor(Math.random() * 100), + })), + }; + + const state = { + [SEARCH]: { + searchFacets: manyFacetValues, + }, + }; + + const facets = getSearchFacets(state); + expect(facets.mine_region).toHaveLength(100); + }); + }); + + describe("Type Safety", () => { + it("returns correct types for results", () => { + const state = { + [SEARCH]: { + searchResults: { + mine: [{ type: "mine", score: 10, result: {} }], + }, + }, + }; + + const results = getSearchResults(state); + expect(typeof results).toBe("object"); + expect(Array.isArray(results.mine)).toBe(true); + }); + + it("returns correct types for facets", () => { + const state = { + [SEARCH]: { + searchFacets: { + mine_region: [{ key: "SW", count: 10 }], + }, + }, + }; + + const facets = getSearchFacets(state); + expect(typeof facets).toBe("object"); + expect(Array.isArray(facets.mine_region)).toBe(true); + expect(typeof facets.mine_region[0].key).toBe("string"); + expect(typeof facets.mine_region[0].count).toBe("number"); + }); + + it("returns correct types for terms", () => { + const state = { + [SEARCH]: { + searchTerms: ["test", "mine"], + }, + }; + + const terms = getSearchTerms(state); + expect(Array.isArray(terms)).toBe(true); + expect(typeof terms[0]).toBe("string"); + }); + }); +}); diff --git a/services/common/src/tests/utils/RequestHeaders.ts b/services/common/src/tests/utils/RequestHeaders.ts new file mode 100644 index 0000000000..037f73b36f --- /dev/null +++ b/services/common/src/tests/utils/RequestHeaders.ts @@ -0,0 +1,4 @@ +export const createRequestHeader = () => ({ + "Access-Control-Allow-Origin": "*", + "X-Requested-With": "XMLHttpRequest", +}); diff --git a/services/core-api/app/api/search/response_models.py b/services/core-api/app/api/search/response_models.py index ea9ca1eabc..721193e953 100644 --- a/services/core-api/app/api/search/response_models.py +++ b/services/core-api/app/api/search/response_models.py @@ -12,7 +12,7 @@ from flask_restx import fields SEARCH_RESULT_MODEL = api.model('SearchResult', { - 'score': fields.Integer, + 'score': fields.Float, 'type': fields.String, }) diff --git a/services/core-api/app/api/search/search/global_search_service.py b/services/core-api/app/api/search/search/global_search_service.py index 0c8194836f..a94bcb2c0f 100644 --- a/services/core-api/app/api/search/search/global_search_service.py +++ b/services/core-api/app/api/search/search/global_search_service.py @@ -7,7 +7,7 @@ from .search_constants import TYPE_TO_INDEX, ES_AGGREGATIONS, FACET_KEYS, FILTER_PARAMS, SEARCH_FIELDS from .search_filters import build_filter_clauses from .search_facets import extract_facets -from .search_transformers import transform_es_results, enrich_party_appointments +from .search_transformers import transform_es_results def parse_csv_param(value): @@ -144,10 +144,6 @@ def search(search_term, search_types, filters, size=200): facets = extract_facets(es_results.get('aggregations', {})) results = transform_es_results(hits) - - # Enrich party results with mine/permit names for appointments - if 'party' in results and results['party']: - results['party'] = enrich_party_appointments(results['party']) # Ensure all requested types have entries for t in search_types: diff --git a/services/core-api/app/api/search/search/resources/search.py b/services/core-api/app/api/search/search/resources/search.py index 0df70728d0..1f265ca49f 100644 --- a/services/core-api/app/api/search/search/resources/search.py +++ b/services/core-api/app/api/search/search/resources/search.py @@ -1,13 +1,16 @@ """Search API resources.""" -from flask import request +import regex +from concurrent.futures import ThreadPoolExecutor, as_completed +from flask import request, current_app from flask_restx import Resource from app.api.search.response_models import SEARCH_RESULT_RETURN_MODEL from app.api.utils.access_decorators import requires_role_view_all from app.api.utils.resources_mixins import UserMixin -from app.api.utils.search import search_targets -from app.extensions import api +from app.api.utils.search import search_targets, execute_search +from app.api.utils.feature_flag import Feature, is_feature_enabled +from app.extensions import api, db from ..global_search_service import GlobalSearchService, parse_search_terms, parse_filters @@ -40,8 +43,15 @@ def get(self): Returns: search_terms: List of parsed search terms search_results: Dict of results grouped by type - facets: Dict of facet counts for filtering + facets: Dict of facet counts for filtering (v2 only) """ + if is_feature_enabled(Feature.GLOBAL_SEARCH_V2): + return self._search_v2() + else: + return self._search_v1() + + def _search_v2(self): + """New Elasticsearch-based search implementation.""" search_term = request.args.get('search_term', '', type=str) search_types_param = request.args.get('search_types', None, type=str) search_types = search_types_param.split(',') if search_types_param else list(search_targets.keys()) @@ -56,3 +66,75 @@ def get(self): 'search_results': search_result['results'], 'facets': search_result['facets'] } + + def _search_v1(self): + """Original ThreadPoolExecutor-based search implementation.""" + search_results = [] + app = current_app._get_current_object() + + search_term = request.args.get('search_term', None, type=str) + search_types = request.args.get('search_types', None, type=str) + search_types = search_types.split(',') if search_types else search_targets.keys() + + # Split incoming search query by space to search by individual words + reg_exp = regex.compile(r'\'.*?\' | ".*?" | \S+ ', regex.VERBOSE) + search_terms = reg_exp.findall(search_term) + search_terms = [term.replace('"', '') for term in search_terms] + + with ThreadPoolExecutor(max_workers=50) as executor: + task_list = [] + for type, type_config in search_targets.items(): + if type in search_types: + task_list.append( + executor.submit(execute_search, app, search_results, search_term, + search_terms, type, type_config)) + for task in as_completed(task_list): + try: + data = task.result() + except Exception as exc: + current_app.logger.error( + f'generated an exception: {exc} with search term - {search_term}') + + grouped_results = {} + for result in search_results: + if (result.result['id'] in grouped_results): + grouped_results[result.result['id']].score += result.score + else: + grouped_results[result.result['id']] = result + + top_search_results = list(grouped_results.values()) + top_search_results.sort(key=lambda x: x.score, reverse=True) + + all_search_results = {} + + for type in search_types: + top_search_results_by_type = {} + + max_results = 5 + if len(search_types) == 1: + max_results = 50 + + for result in top_search_results: + if len(top_search_results_by_type) > max_results: + break + if result.type == type: + top_search_results_by_type[result.result['id']] = result + if search_targets[type].get('primary_column'): + # Look up result data from the DB if the search type has a primary column + # specified. Otherwise, just return the JSON representation of the result (in the case of the permit search service). + full_results = db.session.query(search_targets[type]['model'])\ + .filter( + search_targets[type]['primary_column'].in_( + top_search_results_by_type.keys()) + )\ + .all() + + for full_result in full_results: + top_search_results_by_type[getattr( + full_result, search_targets[type]['id_field'])].result = full_result + + all_search_results[type] = list(top_search_results_by_type.values()) + else: + all_search_results[type] = [res.json() for res in search_results if res.type == type] + + return {'search_terms': search_terms, 'search_results': all_search_results} diff --git a/services/core-api/app/api/search/search/resources/simple_search.py b/services/core-api/app/api/search/search/resources/simple_search.py index 127992b0cc..92f8e2ef00 100644 --- a/services/core-api/app/api/search/search/resources/simple_search.py +++ b/services/core-api/app/api/search/search/resources/simple_search.py @@ -1,11 +1,13 @@ import logging import regex +from concurrent.futures import ThreadPoolExecutor, as_completed from app.api.search.elasticsearch.elastic_search_service import ElasticSearchService from app.api.search.response_models import SIMPLE_SEARCH_RESULT_RETURN_MODEL from app.api.utils.access_decorators import requires_role_view_all from app.api.utils.resources_mixins import UserMixin -from app.api.utils.search import SearchResult, simple_search_targets +from app.api.utils.search import SearchResult, simple_search_targets, execute_search +from app.api.utils.feature_flag import Feature, is_feature_enabled from app.extensions import api from flask import current_app, request from flask_restx import Resource @@ -17,6 +19,51 @@ class SimpleSearchResource(Resource, UserMixin): @requires_role_view_all @api.marshal_with(SIMPLE_SEARCH_RESULT_RETURN_MODEL, 200) def get(self): + if is_feature_enabled(Feature.GLOBAL_SEARCH_V2): + return self._search_v2() + else: + return self._search_v1() + + def _search_v1(self): + """Original ThreadPoolExecutor-based simple search implementation.""" + search_results = [] + app = current_app._get_current_object() + + search_term = request.args.get('search_term', None, type=str) + + # Split incoming search query by space to search by individual words + reg_exp = regex.compile(r'\'.*?\' | ".*?" | \S+ ', regex.VERBOSE) + search_terms = reg_exp.findall(search_term) + search_terms = [term.replace('"', '') for term in search_terms] + + with ThreadPoolExecutor(max_workers=50) as executor: + task_list = [] + for type, type_config in simple_search_targets.items(): + task_list.append( + executor.submit(execute_search, app, search_results, search_term, search_terms, + type, type_config, 200)) + for task in as_completed(task_list): + try: + data = task.result() + except Exception as exc: + current_app.logger.error( + f'generated an exception: {exc} with search term - {search_term}') + + grouped_results = {} + for result in search_results: + if (result.result['id'] in grouped_results): + grouped_results[result.result['id']].score += result.score + else: + grouped_results[result.result['id']] = result + + search_results = list(grouped_results.values()) + search_results.sort(key=lambda x: x.score, reverse=True) + search_results = search_results[0:4] + + return {'search_terms': search_terms, 'search_results': search_results} + + def _search_v2(self): + """New Elasticsearch-based simple search implementation.""" search_results = [] search_term = request.args.get('search_term', None, type=str) search_types = request.args.get('search_types', None, type=str) diff --git a/services/core-api/app/api/search/search/search_facets.py b/services/core-api/app/api/search/search/search_facets.py index 5585c9554b..3f0c95e39d 100644 --- a/services/core-api/app/api/search/search/search_facets.py +++ b/services/core-api/app/api/search/search/search_facets.py @@ -26,7 +26,9 @@ def _extract_buckets(aggs, key, nested_path=None): def _parse_boolean_bucket(bucket, true_label, false_label): """Parse a boolean aggregation bucket.""" - is_true = bucket.get('key_as_string') == 'true' or bucket['key'] is True + key = bucket.get('key') + key_as_string = bucket.get('key_as_string', '') + is_true = key_as_string == 'true' or key == True or key == 1 return {'key': true_label if is_true else false_label, 'count': bucket['doc_count']} diff --git a/services/core-api/app/api/search/search/search_transformers.py b/services/core-api/app/api/search/search/search_transformers.py index bb1abec6d3..0d544a3d3f 100644 --- a/services/core-api/app/api/search/search/search_transformers.py +++ b/services/core-api/app/api/search/search/search_transformers.py @@ -1,12 +1,21 @@ -"""Transformers for converting ES hits to API response format.""" - +"""Transformers for converting ES hits to API response format using Flask-RESTX marshalling.""" + +from flask_restx import marshal +from app.api.search.response_models import ( + MINE_SEARCH_RESULT_MODEL, + PARTY_SEARCH_RESULT_MODEL, + PERMIT_SEARCH_RESULT_MODEL, + MINE_DOCUMENT_SEARCH_RESULT_MODEL, + EXPLOSIVES_PERMIT_SEARCH_RESULT_MODEL, + NOW_APPLICATION_SEARCH_RESULT_MODEL, + NOD_SEARCH_RESULT_MODEL, +) from .search_constants import INDEX_TO_TYPE -def transform_mine_hit(hit): - """Transform ES hit to mine result format.""" - source = hit['_source'] - +def prepare_mine_source(source): + """Prepare mine source data for marshalling.""" + # Extract status labels from nested structure status_labels = [] mine_status = source.get('mine_status', []) if mine_status: @@ -14,85 +23,25 @@ def transform_mine_hit(hit): xref = status.get('status_xref', {}) if xref and xref.get('mine_operation_status_code'): status_labels.append(xref['mine_operation_status_code']) - - return { - 'mine_guid': source.get('mine_guid'), - 'mine_name': source.get('mine_name'), - 'mine_no': source.get('mine_no'), - 'mine_region': source.get('mine_region'), - 'mms_alias': source.get('mms_alias'), - 'major_mine_ind': source.get('major_mine_ind'), - 'mine_status': {'status_labels': status_labels} if status_labels else None, - 'permits': source.get('permits', []), - 'mine_type': source.get('mine_types', []), - 'mine_tailings_storage_facilities': source.get('tailings_storage_facilities', []), - 'mine_work_information': source.get('work_information'), - 'verified_status': source.get('verified_status'), - } - - -def enrich_party_appointments(party_results): - """ - Enrich party results with mine names and permit numbers for their appointments. - This is a post-processing step since pgsync can't index nested relationships properly. - """ - from app.api.parties.party_appt.models.mine_party_appt import MinePartyAppointment - from sqlalchemy.orm import joinedload - - # Collect all party GUIDs that have appointments - party_guids_with_appts = [ - p['party_guid'] for p in party_results - if p.get('mine_party_appt') and len(p['mine_party_appt']) > 0 - ] - - if not party_guids_with_appts: - return party_results - - # Fetch all appointments with mine and permit data in one query - appointments = MinePartyAppointment.query.filter( - MinePartyAppointment.party_guid.in_(party_guids_with_appts), - MinePartyAppointment.deleted_ind == False - ).options( - joinedload(MinePartyAppointment.mine), - joinedload(MinePartyAppointment.permit) - ).all() - # Build a map of party_guid -> appointments with full data - appt_map = {} - for appt in appointments: - if appt.party_guid not in appt_map: - appt_map[appt.party_guid] = [] - - appt_map[appt.party_guid].append({ - 'mine_party_appt_guid': str(appt.mine_party_appt_guid), - 'mine_party_appt_type_code': appt.mine_party_appt_type_code, - 'start_date': str(appt.start_date) if appt.start_date else None, - 'end_date': str(appt.end_date) if appt.end_date else None, - 'mine': { - 'mine_guid': str(appt.mine.mine_guid), - 'mine_name': appt.mine.mine_name - } if appt.mine else None, - 'permit_no': appt.permit.permit_no if appt.permit else None, - }) + # Prepare the source dict with correct field names for the model + prepared = dict(source) + if status_labels: + prepared['mine_status'] = {'status_labels': status_labels} + prepared['mine_type'] = source.get('mine_types', []) + prepared['mine_tailings_storage_facilities'] = source.get('tailings_storage_facilities', []) + prepared['mine_work_information'] = source.get('work_information') - # Enrich the party results with full appointment data - for party in party_results: - party_guid = party['party_guid'] - if party_guid in appt_map: - party['mine_party_appt'] = appt_map[party_guid] - - return party_results + return prepared -def transform_party_hit(hit): - """Transform ES hit to party result format.""" - source = hit['_source'] +def prepare_party_source(source): + """Prepare party source data for marshalling.""" first_name = source.get('first_name', '') party_name = source.get('party_name', '') - # Transform mine_party_appt relationships - # Note: Only basic appointment data is indexed (type, dates) - # Mine names and permit numbers will be enriched via enrich_party_appointments() + # Transform mine_party_appt relationships from indexed data + # Note: Mine and permit details are left empty to avoid DB queries during search mine_party_appts = source.get('mine_party_appt', []) transformed_appts = [] @@ -109,29 +58,22 @@ def transform_party_hit(hit): 'mine_party_appt_type_code': appt.get('mine_party_appt_type_code'), 'start_date': appt.get('start_date'), 'end_date': appt.get('end_date'), - 'mine': None, # Will be enriched by enrich_party_appointments() - 'permit_no': None, # Will be enriched by enrich_party_appointments() + 'mine': None, + 'permit_no': None, }) - return { - 'party_guid': source.get('party_guid'), - 'name': f"{first_name} {party_name}".strip() if first_name else party_name, - 'first_name': first_name, - 'party_name': party_name, - 'party_type_code': source.get('party_type_code'), - 'email': source.get('email'), - 'phone_no': source.get('phone_no'), - 'party_orgbook_entity': None, - 'business_role_appts': [], - 'mine_party_appt': transformed_appts, - 'address': [], - } - - -def transform_permit_hit(hit): - """Transform ES hit to permit result format.""" - source = hit['_source'] + prepared = dict(source) + prepared['name'] = f"{first_name} {party_name}".strip() if first_name else party_name + prepared['party_orgbook_entity'] = None + prepared['business_role_appts'] = [] + prepared['mine_party_appt'] = transformed_appts + prepared['address'] = [] + + return prepared + +def prepare_permit_source(source): + """Prepare permit source data for marshalling.""" permittees = source.get('permittees', []) current_permittee = None if permittees: @@ -147,108 +89,104 @@ def transform_permit_hit(hit): for guid in (mine_guids if isinstance(mine_guids, list) else [mine_guids]): mines.append({'mine_guid': guid, 'mine_name': '', 'mine_no': ''}) - return { - 'permit_guid': source.get('permit_guid'), - 'permit_no': source.get('permit_no'), - 'current_permittee': current_permittee, - 'mine': mines, - } + prepared = dict(source) + prepared['current_permittee'] = current_permittee + prepared['mine'] = mines + + return prepared -def transform_document_hit(hit): - """Transform ES hit to document result format.""" - source = hit['_source'] +def prepare_document_source(source): + """Prepare document source data for marshalling.""" mine_info = source.get('mine', {}) - - return { - 'mine_guid': source.get('mine_guid'), - 'mine_document_guid': source.get('mine_document_guid'), - 'document_name': source.get('document_name'), - 'mine_name': mine_info.get('mine_name') if mine_info else None, - 'document_manager_guid': source.get('document_manager_guid'), - 'upload_date': source.get('upload_date'), - 'create_user': source.get('create_user'), - } + + prepared = dict(source) + prepared['mine_name'] = mine_info.get('mine_name') if mine_info else None + + return prepared -def transform_explosives_permit_hit(hit): - """Transform ES hit to explosives permit result format.""" - source = hit['_source'] +def prepare_explosives_permit_source(source): + """Prepare explosives permit source data for marshalling.""" mine_info = source.get('mine', {}) - - return { - 'explosives_permit_guid': source.get('explosives_permit_guid'), - 'explosives_permit_id': source.get('explosives_permit_id'), - 'application_number': source.get('application_number'), - 'application_status': source.get('application_status'), - 'mine_guid': source.get('mine_guid'), - 'mine_name': mine_info.get('mine_name') if mine_info else source.get('mine_name'), - 'is_closed': source.get('is_closed'), - } + + prepared = dict(source) + prepared['mine_name'] = mine_info.get('mine_name') if mine_info else source.get('mine_name') + + return prepared -def transform_now_application_hit(hit): - """Transform ES hit to NoW application result format.""" - source = hit['_source'] +def prepare_now_application_source(source): + """Prepare NoW application source data for marshalling.""" mine_info = source.get('mine', {}) application = source.get('application', {}) - - return { - 'now_application_guid': source.get('now_application_guid'), - 'now_number': source.get('now_number'), - 'mine_guid': source.get('mine_guid'), - 'mine_name': mine_info.get('mine_name') if mine_info else source.get('mine_name'), - 'now_application_status_code': application.get('now_application_status_code') if application else source.get('now_application_status_code'), - 'notice_of_work_type_code': application.get('notice_of_work_type_code') if application else source.get('notice_of_work_type_code'), - } + + prepared = dict(source) + prepared['mine_name'] = mine_info.get('mine_name') if mine_info else source.get('mine_name') + prepared['now_application_status_code'] = application.get('now_application_status_code') if application else source.get('now_application_status_code') + prepared['notice_of_work_type_code'] = application.get('notice_of_work_type_code') if application else source.get('notice_of_work_type_code') + + return prepared -def transform_nod_hit(hit): - """Transform ES hit to NOD result format.""" - source = hit['_source'] +def prepare_nod_source(source): + """Prepare NOD source data for marshalling.""" mine_info = source.get('mine', {}) + + prepared = dict(source) + prepared['mine_name'] = mine_info.get('mine_name') if mine_info else source.get('mine_name') + + return prepared + + +# Mapping of document types to their prepare functions +PREPARE_FUNCTIONS = { + 'mine': prepare_mine_source, + 'party': prepare_party_source, + 'permit': prepare_permit_source, + 'mine_documents': prepare_document_source, + 'explosives_permit': prepare_explosives_permit_source, + 'now_application': prepare_now_application_source, + 'notice_of_departure': prepare_nod_source, +} - return { - 'nod_guid': source.get('nod_guid'), - 'nod_no': source.get('nod_no'), - 'nod_title': source.get('nod_title'), - 'mine_guid': source.get('mine_guid'), - 'mine_name': mine_info.get('mine_name') if mine_info else source.get('mine_name'), - 'nod_type': source.get('nod_type'), - 'nod_status': source.get('nod_status'), - } - - -TRANSFORMERS = { - 'mine': transform_mine_hit, - 'party': transform_party_hit, - 'permit': transform_permit_hit, - 'mine_documents': transform_document_hit, - 'explosives_permit': transform_explosives_permit_hit, - 'now_application': transform_now_application_hit, - 'notice_of_departure': transform_nod_hit, +# Mapping of document types to their search result models +SEARCH_RESULT_MODELS = { + 'mine': MINE_SEARCH_RESULT_MODEL, + 'party': PARTY_SEARCH_RESULT_MODEL, + 'permit': PERMIT_SEARCH_RESULT_MODEL, + 'mine_documents': MINE_DOCUMENT_SEARCH_RESULT_MODEL, + 'explosives_permit': EXPLOSIVES_PERMIT_SEARCH_RESULT_MODEL, + 'now_application': NOW_APPLICATION_SEARCH_RESULT_MODEL, + 'notice_of_departure': NOD_SEARCH_RESULT_MODEL, } def transform_es_results(hits): - """Transform ES hits into grouped results by type.""" + """Transform ES hits into grouped results by type using Flask-RESTX marshalling.""" results = {} for hit in hits: doc_type = INDEX_TO_TYPE.get(hit['_index']) - if not doc_type: + if not doc_type or doc_type not in SEARCH_RESULT_MODELS: continue if doc_type not in results: results[doc_type] = [] - transformer = TRANSFORMERS.get(doc_type) - result = transformer(hit) if transformer else hit['_source'] + # Prepare the source data for the specific type + prepare_fn = PREPARE_FUNCTIONS.get(doc_type) + prepared_source = prepare_fn(hit['_source']) if prepare_fn else hit['_source'] - results[doc_type].append({ + # Create the search result dict with score, type, and result + search_result = { 'score': hit['_score'], 'type': doc_type, - 'result': result - }) + 'result': prepared_source + } + + # Marshal using the appropriate search result model + marshalled_result = marshal(search_result, SEARCH_RESULT_MODELS[doc_type]) + results[doc_type].append(marshalled_result) return results diff --git a/services/core-api/app/api/utils/feature_flag.py b/services/core-api/app/api/utils/feature_flag.py index ef6a0c6574..39cfb76414 100644 --- a/services/core-api/app/api/utils/feature_flag.py +++ b/services/core-api/app/api/utils/feature_flag.py @@ -1,6 +1,6 @@ -from enum import Enum -import os import json +import os +from enum import Enum from app.config import Config from flagsmith import Flagsmith @@ -21,6 +21,7 @@ class Feature(Enum): AMS_AGENT = 'ams_agent' RECURRING_REPORTS = 'recurring_reports' MINESPACE_SIGNUP = 'minespace_signup' + GLOBAL_SEARCH_V2 = 'global_search_v2' def __str__(self): return self.value diff --git a/services/core-api/tests/search/resource/test_search_resource.py b/services/core-api/tests/search/resource/test_search_resource.py index e8bbf3d1a3..a4d3d209c5 100644 --- a/services/core-api/tests/search/resource/test_search_resource.py +++ b/services/core-api/tests/search/resource/test_search_resource.py @@ -1,7 +1,31 @@ import json import uuid +import pytest +from unittest.mock import patch from tests.factories import MineFactory, PartyFactory +from app.api.utils.feature_flag import Feature + + +# Feature Flag Fixtures +# These fixtures ensure that all tests in this module use the V1 (original) +# search implementation instead of the V2 (Elasticsearch) implementation. +# This maintains test stability and validates that the legacy code path works correctly. + +@pytest.fixture(autouse=True) +def disable_search_v2_flag(): + """Mock is_feature_enabled to always return False for search.py""" + with patch('app.api.search.search.resources.search.is_feature_enabled') as mock_flag: + mock_flag.return_value = False + yield mock_flag + + +@pytest.fixture(autouse=True) +def disable_simple_search_v2_flag(): + """Mock is_feature_enabled to always return False for simple_search.py""" + with patch('app.api.search.search.resources.simple_search.is_feature_enabled') as mock_flag: + mock_flag.return_value = False + yield mock_flag # GET @@ -11,8 +35,12 @@ def test_get_no_search_results(test_client, db_session, auth_headers): get_data = json.loads(get_resp.data.decode()) assert get_resp.status_code == 200 assert get_data['search_terms'] == ['Abbo'] - assert len( - [key for key, value in get_data['search_results'].items() if len(value) is not 0]) == 0 + # Verify no search results in any category + non_empty_categories = [ + key for key, value in get_data['search_results'].items() if len(value) != 0 + ] + assert len(non_empty_categories) == 0, \ + f"Expected no results, but found results in: {non_empty_categories}" def test_search_party(test_client, db_session, auth_headers): @@ -24,10 +52,14 @@ def test_search_party(test_client, db_session, auth_headers): assert len(parties) == 1 assert party.first_name in parties[0]['result']['name'] assert uuid.UUID(parties[0]['result']['party_guid']) == party.party_guid - assert len([ + # Verify all other search result categories are empty (only party should have results) + empty_categories = [ key for key, value in get_data['search_results'].items() - if key is not 'party' and len(value) is 0 - ]) == 4 + if key != 'party' and len(value) == 0 + ] + total_categories = len(get_data['search_results']) + assert len(empty_categories) == total_categories - 1, \ + f"Expected {total_categories - 1} empty categories, got {len(empty_categories)}" assert get_resp.status_code == 200 diff --git a/services/core-api/tests/search/resource/test_search_resource_v2.py b/services/core-api/tests/search/resource/test_search_resource_v2.py new file mode 100644 index 0000000000..e49258186e --- /dev/null +++ b/services/core-api/tests/search/resource/test_search_resource_v2.py @@ -0,0 +1,412 @@ +"""Tests for search resource with V2 (Elasticsearch) enabled.""" + +import json +import pytest +from unittest.mock import patch +from tests.factories import MineFactory, PartyFactory + + +@pytest.fixture +def enable_search_v2(): + """Enable V2 search for tests in this module.""" + with patch('app.api.search.search.resources.search.is_feature_enabled') as mock_flag: + mock_flag.return_value = True + yield mock_flag + + +@pytest.fixture +def enable_simple_search_v2(): + """Enable V2 simple search for tests in this module.""" + with patch('app.api.search.search.resources.simple_search.is_feature_enabled') as mock_flag: + mock_flag.return_value = True + yield mock_flag + + +@pytest.fixture +def mock_es_service(): + """Mock Elasticsearch service - mocks the class method 'search'.""" + with patch('app.api.search.search.global_search_service.ElasticSearchService.search') as mock_search: + # Default return value to prevent errors + mock_search.return_value = { + 'hits': {'hits': []}, + 'aggregations': {'by_index': {'buckets': []}} + } + yield mock_search + + +class TestSearchResourceV2: + """Test search resource with V2 enabled.""" + + def test_search_v2_mine_results(self, test_client, db_session, auth_headers, enable_search_v2, mock_es_service): + """Test V2 search returns mine results.""" + mock_es_service.return_value = { + 'hits': { + 'hits': [ + { + '_index': 'mines', + '_score': 10.0, + '_source': { + 'mine_guid': 'test-mine-guid', + 'mine_name': 'Test Mine', + 'mine_no': 'M-001', + 'mine_region': 'SW', + 'major_mine_ind': True + } + } + ] + }, + 'aggregations': { + 'by_index': { + 'buckets': [{'key': 'mines', 'doc_count': 1}] + }, + 'mine_region': { + 'buckets': [{'key': 'SW', 'doc_count': 1}] + } + } + } + + response = test_client.get( + '/search?search_term=Test', + headers=auth_headers['full_auth_header'] + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + + assert 'search_terms' in data + assert 'search_results' in data + assert 'facets' in data + + # Check mine results + assert 'mine' in data['search_results'] + mines = data['search_results']['mine'] + assert len(mines) >= 1 + + # Check facets are present + assert 'mine_region' in data['facets'] + assert len(data['facets']['mine_region']) >= 1 + + def test_search_v2_party_results(self, test_client, db_session, auth_headers, enable_search_v2, mock_es_service): + """Test V2 search returns party results.""" + mock_es_service.return_value = { + 'hits': { + 'hits': [ + { + '_index': 'parties', + '_score': 8.5, + '_source': { + 'party_guid': 'test-party-guid', + 'first_name': 'John', + 'party_name': 'Doe', + 'party_type_code': 'PER', + 'email': 'john@example.com', + 'phone_no': '555-1234' + } + } + ] + }, + 'aggregations': { + 'by_index': { + 'buckets': [{'key': 'parties', 'doc_count': 1}] + }, + 'party_type': { + 'buckets': [{'key': 'PER', 'doc_count': 1}] + } + } + } + + response = test_client.get( + '/search?search_term=John&search_types=party', + headers=auth_headers['full_auth_header'] + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + + assert 'party' in data['search_results'] + parties = data['search_results']['party'] + assert len(parties) >= 1 + + party = parties[0] + assert party['result']['name'] == 'John Doe' + + def test_search_v2_with_filters(self, test_client, db_session, auth_headers, enable_search_v2, mock_es_service): + """Test V2 search with filter parameters.""" + mock_es_service.return_value = { + 'hits': {'hits': []}, + 'aggregations': {'by_index': {'buckets': []}} + } + + response = test_client.get( + '/search?search_term=test&mine_region=SW&permit_status=O', + headers=auth_headers['full_auth_header'] + ) + + assert response.status_code == 200 + + # Verify ES service was called with filters + assert mock_es_service.called + call_args = mock_es_service.call_args + query = call_args[0][1] + + # Should have filter clauses + assert 'filter' in query['query']['bool'] + + def test_search_v2_multiple_types(self, test_client, db_session, auth_headers, enable_search_v2, mock_es_service): + """Test V2 search with multiple result types.""" + mock_es_service.return_value = { + 'hits': { + 'hits': [ + { + '_index': 'mines', + '_score': 10.0, + '_source': {'mine_guid': 'mine-1', 'mine_name': 'Mine 1'} + }, + { + '_index': 'parties', + '_score': 8.0, + '_source': { + 'party_guid': 'party-1', + 'first_name': 'John', + 'party_name': 'Doe', + 'party_type_code': 'PER' + } + }, + { + '_index': 'mine_permits', + '_score': 7.5, + '_source': { + 'permit_guid': 'permit-1', + 'permit_no': 'P-001', + 'mine_guids': ['mine-1'] + } + } + ] + }, + 'aggregations': { + 'by_index': { + 'buckets': [ + {'key': 'mines', 'doc_count': 1}, + {'key': 'parties', 'doc_count': 1}, + {'key': 'mine_permits', 'doc_count': 1} + ] + } + } + } + + response = test_client.get( + '/search?search_term=test&search_types=mine,party,permit', + headers=auth_headers['full_auth_header'] + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + + assert 'mine' in data['search_results'] + assert 'party' in data['search_results'] + assert 'permit' in data['search_results'] + + def test_search_v2_empty_results(self, test_client, db_session, auth_headers, enable_search_v2, mock_es_service): + """Test V2 search with no results.""" + mock_es_service.return_value = { + 'hits': {'hits': []}, + 'aggregations': {'by_index': {'buckets': []}} + } + + response = test_client.get( + '/search?search_term=nonexistent', + headers=auth_headers['full_auth_header'] + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + + # Should have structure but empty results + assert 'search_results' in data + for result_type in data['search_results'].values(): + assert len(result_type) == 0 + + def test_search_v2_handles_es_error(self, test_client, db_session, auth_headers, enable_search_v2, mock_es_service): + """Test V2 search handles Elasticsearch errors gracefully.""" + mock_es_service.side_effect = Exception('ES connection failed') + + response = test_client.get( + '/search?search_term=test', + headers=auth_headers['full_auth_header'] + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + + # Should return empty results instead of erroring + assert 'search_results' in data + assert len(data['search_results']) >= 0 + + def test_search_options_returns_available_types(self, test_client, db_session, auth_headers): + """Test search options endpoint returns available types.""" + response = test_client.get( + '/search/options', + headers=auth_headers['full_auth_header'] + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + + # Should return list of search types + assert isinstance(data, list) + assert len(data) > 0 + + # Each item should have model_id and description + for item in data: + assert 'model_id' in item + assert 'description' in item + + +class TestSimpleSearchResourceV2: + """Test simple search resource with V2 enabled.""" + + def test_simple_search_v2_basic(self, test_client, db_session, auth_headers, enable_simple_search_v2, mock_es_service): + """Test V2 simple search returns results.""" + mock_es_service.return_value = { + 'hits': { + 'hits': [ + { + '_index': 'mines', + '_score': 10.0, + '_source': { + 'mine_guid': 'mine-123', + 'mine_name': 'Test Mine', + 'mine_no': 'M-001' + } + } + ] + }, + 'aggregations': { + 'by_index': { + 'buckets': [{'key': 'mines', 'doc_count': 1}] + } + } + } + + response = test_client.get( + '/search/simple?search_term=Test', + headers=auth_headers['full_auth_header'] + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + + assert 'search_results' in data + assert 'facets' in data + assert len(data['search_results']) >= 1 + + def test_simple_search_v2_with_mine_guid(self, test_client, db_session, auth_headers, enable_simple_search_v2, mock_es_service): + """Test V2 simple search with mine_guid filter (scoped search).""" + mock_es_service.return_value = { + 'hits': {'hits': []}, + 'aggregations': {'by_index': {'buckets': []}} + } + + response = test_client.get( + '/search/simple?search_term=test&mine_guid=test-mine-guid', + headers=auth_headers['full_auth_header'] + ) + + assert response.status_code == 200 + + # Verify ES was called with mine_guid filter + assert mock_es_service.called + call_args = mock_es_service.call_args + query = call_args[0][1] + + # Should include mine_guid in filter + assert 'bool' in query['query'] + + def test_simple_search_v2_facets(self, test_client, db_session, auth_headers, enable_simple_search_v2, mock_es_service): + """Test V2 simple search returns facet counts.""" + mock_es_service.return_value = { + 'hits': { + 'hits': [ + { + '_index': 'mines', + '_score': 10.0, + '_source': {'mine_guid': 'mine-1', 'mine_name': 'Mine 1'} + }, + { + '_index': 'parties', + '_score': 8.0, + '_source': { + 'party_guid': 'party-1', + 'first_name': 'John', + 'party_name': 'Doe', + 'party_type_code': 'PER' + } + } + ] + }, + 'aggregations': { + 'by_index': { + 'buckets': [ + {'key': 'mines', 'doc_count': 10}, + {'key': 'parties', 'doc_count': 5} + ] + } + } + } + + response = test_client.get( + '/search/simple?search_term=test', + headers=auth_headers['full_auth_header'] + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + + assert 'facets' in data + # Simple search has different facet structure (counts by type) + assert 'mine' in data['facets'] or isinstance(data['facets'], dict) + + +class TestSearchV1V2Compatibility: + """Test compatibility between V1 and V2 search.""" + + def test_response_structure_compatibility(self, test_client, db_session, auth_headers, enable_search_v2, mock_es_service): + """Test V2 response structure matches V1 for basic fields.""" + mock_es_service.return_value = { + 'hits': {'hits': []}, + 'aggregations': {'by_index': {'buckets': []}} + } + + response = test_client.get( + '/search?search_term=test', + headers=auth_headers['full_auth_header'] + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + + # V1 and V2 should both have these keys + assert 'search_terms' in data + assert 'search_results' in data + + # V2 adds facets + assert 'facets' in data + + def test_search_terms_parsing_matches_v1(self, test_client, db_session, auth_headers, enable_search_v2, mock_es_service): + """Test search terms are parsed the same way as V1.""" + mock_es_service.return_value = { + 'hits': {'hits': []}, + 'aggregations': {'by_index': {'buckets': []}} + } + + response = test_client.get( + '/search?search_term=test mine', + headers=auth_headers['full_auth_header'] + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + + # Should parse into individual terms + assert isinstance(data['search_terms'], list) + assert len(data['search_terms']) >= 1 diff --git a/services/core-api/tests/search/test_global_search_service.py b/services/core-api/tests/search/test_global_search_service.py new file mode 100644 index 0000000000..23f617da9c --- /dev/null +++ b/services/core-api/tests/search/test_global_search_service.py @@ -0,0 +1,313 @@ +"""Tests for global search service.""" + +import pytest +from unittest.mock import patch, MagicMock +from app.api.search.search.global_search_service import ( + GlobalSearchService, + parse_csv_param, + parse_search_terms, + parse_filters, + build_search_query, +) + + +class TestParseHelpers: + """Test parsing helper functions.""" + + def test_parse_csv_param_with_values(self): + result = parse_csv_param('SW,NE,NW') + assert result == ['SW', 'NE', 'NW'] + + def test_parse_csv_param_empty(self): + result = parse_csv_param('') + assert result == [] + + def test_parse_csv_param_none(self): + result = parse_csv_param(None) + assert result == [] + + def test_parse_csv_param_with_spaces(self): + result = parse_csv_param(' SW , NE , NW ') + assert result == ['SW', 'NE', 'NW'] + + def test_parse_search_terms_basic(self): + result = parse_search_terms('test mine') + assert 'test' in result + assert 'mine' in result + + def test_parse_search_terms_with_quotes(self): + result = parse_search_terms('"test mine" another') + assert 'test mine' in result + assert 'another' in result + + def test_parse_search_terms_empty(self): + result = parse_search_terms('') + assert result == [] + + def test_parse_filters(self): + args = MagicMock() + args.get.side_effect = lambda key: { + 'mine_region': 'SW,NE', + 'permit_status': 'O', + 'other_param': 'ignored' + }.get(key) + + result = parse_filters(args) + + assert 'mine_region' in result + assert result['mine_region'] == ['SW', 'NE'] + assert 'permit_status' in result + assert result['permit_status'] == ['O'] + + +class TestBuildSearchQuery: + """Test search query building.""" + + def test_build_search_query_with_term(self): + query = build_search_query('test', []) + + assert 'query' in query + assert 'bool' in query['query'] + assert 'should' in query['query']['bool'] + assert 'aggs' in query + + def test_build_search_query_wildcard(self): + query = build_search_query('*', []) + + assert 'query' in query + assert 'match_all' in query['query']['bool']['must'][0] + + def test_build_search_query_empty(self): + query = build_search_query('', []) + + assert 'match_all' in query['query']['bool']['must'][0] + + def test_build_search_query_with_filters(self): + filter_clauses = [ + {'term': {'mine_region.keyword': 'SW'}} + ] + query = build_search_query('test', filter_clauses) + + assert 'filter' in query['query']['bool'] + assert len(query['query']['bool']['filter']) == 1 + + def test_build_search_query_includes_aggregations(self): + query = build_search_query('test', []) + + assert 'aggs' in query + # Should have various aggregations defined + assert len(query['aggs']) > 0 + + def test_build_search_query_phrase_prefix(self): + query = build_search_query('test mine', []) + + # Should include phrase_prefix match + should_clauses = query['query']['bool']['should'] + phrase_prefix = next((c for c in should_clauses if 'multi_match' in c and c['multi_match'].get('type') == 'phrase_prefix'), None) + assert phrase_prefix is not None + + def test_build_search_query_fuzzy_for_long_term(self): + query = build_search_query('testing', []) + + # Should include fuzzy match for terms >= 3 chars + should_clauses = query['query']['bool']['should'] + fuzzy_match = next((c for c in should_clauses if 'multi_match' in c and 'fuzziness' in c['multi_match']), None) + assert fuzzy_match is not None + + def test_build_search_query_no_fuzzy_for_short_term(self): + query = build_search_query('ab', []) + + # Should not include fuzzy match for short terms + should_clauses = query['query']['bool']['should'] + fuzzy_matches = [c for c in should_clauses if 'multi_match' in c and 'fuzziness' in c['multi_match']] + assert len(fuzzy_matches) == 0 + + +class TestGlobalSearchService: + """Test GlobalSearchService.""" + + @patch('app.api.search.search.global_search_service.ElasticSearchService') + def test_search_basic(self, mock_es_service): + # Mock ES response + mock_es_service.search.return_value = { + 'hits': { + 'hits': [ + { + '_index': 'mines', + '_score': 10.0, + '_source': { + 'mine_guid': 'mine-123', + 'mine_name': 'Test Mine', + 'mine_no': 'M-001' + } + } + ] + }, + 'aggregations': { + 'by_index': { + 'buckets': [{'key': 'mines', 'doc_count': 1}] + } + } + } + + result = GlobalSearchService.search('test', ['mine'], {}) + + assert 'results' in result + assert 'facets' in result + assert 'mine' in result['results'] + assert len(result['results']['mine']) == 1 + mock_es_service.search.assert_called_once() + + @patch('app.api.search.search.global_search_service.ElasticSearchService') + def test_search_multiple_types(self, mock_es_service): + mock_es_service.search.return_value = { + 'hits': { + 'hits': [ + { + '_index': 'mines', + '_score': 10.0, + '_source': {'mine_guid': 'mine-123', 'mine_name': 'Test Mine'} + }, + { + '_index': 'parties', + '_score': 8.0, + '_source': {'party_guid': 'party-123', 'first_name': 'John', 'party_name': 'Doe'} + } + ] + }, + 'aggregations': {} + } + + result = GlobalSearchService.search('test', ['mine', 'party'], {}) + + assert 'mine' in result['results'] + assert 'party' in result['results'] + assert len(result['results']['mine']) == 1 + assert len(result['results']['party']) == 1 + + @patch('app.api.search.search.global_search_service.ElasticSearchService') + def test_search_with_filters(self, mock_es_service): + mock_es_service.search.return_value = { + 'hits': {'hits': []}, + 'aggregations': {} + } + + filters = {'mine_region': ['SW'], 'permit_status': ['O']} + result = GlobalSearchService.search('test', ['mine'], filters) + + # Verify ES service was called + mock_es_service.search.assert_called_once() + call_args = mock_es_service.search.call_args + query = call_args[0][1] + + # Query should include filters + assert 'filter' in query['query']['bool'] + + @patch('app.api.search.search.global_search_service.ElasticSearchService') + def test_search_ensures_all_types_present(self, mock_es_service): + # ES returns only mines + mock_es_service.search.return_value = { + 'hits': { + 'hits': [ + { + '_index': 'mines', + '_score': 10.0, + '_source': {'mine_guid': 'mine-123', 'mine_name': 'Test Mine'} + } + ] + }, + 'aggregations': {} + } + + # Request both mines and parties + result = GlobalSearchService.search('test', ['mine', 'party'], {}) + + # Both should be in results, party should be empty list + assert 'mine' in result['results'] + assert 'party' in result['results'] + assert len(result['results']['mine']) == 1 + assert len(result['results']['party']) == 0 + + @patch('app.api.search.search.global_search_service.ElasticSearchService') + def test_search_handles_es_error(self, mock_es_service): + # Simulate ES error + mock_es_service.search.side_effect = Exception('ES connection failed') + + result = GlobalSearchService.search('test', ['mine'], {}) + + # Should return empty results instead of raising + assert 'results' in result + assert 'facets' in result + assert result['results']['mine'] == [] + + @patch('app.api.search.search.global_search_service.ElasticSearchService') + def test_search_with_custom_size(self, mock_es_service): + mock_es_service.search.return_value = { + 'hits': {'hits': []}, + 'aggregations': {} + } + + GlobalSearchService.search('test', ['mine'], {}, size=100) + + # Verify size parameter was passed + call_args = mock_es_service.search.call_args + assert call_args[1]['size'] == 100 + + @patch('app.api.search.search.global_search_service.ElasticSearchService') + def test_search_builds_correct_indices_string(self, mock_es_service): + mock_es_service.search.return_value = { + 'hits': {'hits': []}, + 'aggregations': {} + } + + GlobalSearchService.search('test', ['mine', 'party', 'permit'], {}) + + # Verify correct indices were passed + call_args = mock_es_service.search.call_args + indices = call_args[0][0] + + # Should be comma-separated index names + assert 'mines' in indices + assert 'parties' in indices + assert 'permits' in indices or 'mine_permits' in indices + + @patch('app.api.search.search.global_search_service.ElasticSearchService') + def test_search_returns_facets(self, mock_es_service): + mock_es_service.search.return_value = { + 'hits': {'hits': []}, + 'aggregations': { + 'mine_region': { + 'buckets': [{'key': 'SW', 'doc_count': 10}] + } + } + } + + result = GlobalSearchService.search('test', ['mine'], {}) + + assert 'facets' in result + assert 'mine_region' in result['facets'] + assert len(result['facets']['mine_region']) == 1 + assert result['facets']['mine_region'][0]['key'] == 'SW' + + def test_search_with_no_indices(self): + result = GlobalSearchService.search('test', [], {}) + + # Should return empty results + assert 'results' in result + assert 'facets' in result + assert result['results'] == {} + + @patch('app.api.search.search.global_search_service.ElasticSearchService') + def test_search_logs_info(self, mock_es_service): + mock_es_service.search.return_value = { + 'hits': {'hits': []}, + 'aggregations': {} + } + + with patch('app.api.search.search.global_search_service.current_app') as mock_app: + GlobalSearchService.search('test query', ['mine'], {}) + + # Should log the search + assert mock_app.logger.info.called + call_args = str(mock_app.logger.info.call_args_list) + assert 'test query' in call_args diff --git a/services/core-api/tests/search/test_search_constants.py b/services/core-api/tests/search/test_search_constants.py new file mode 100644 index 0000000000..a93bf5e8a3 --- /dev/null +++ b/services/core-api/tests/search/test_search_constants.py @@ -0,0 +1,205 @@ +"""Tests for search constants and mappings.""" + +import pytest +from app.api.search.search.search_constants import ( + TYPE_TO_INDEX, + INDEX_TO_TYPE, + FACET_KEYS, + FILTER_PARAMS, + SEARCH_FIELDS, + ES_AGGREGATIONS, +) + + +class TestSearchConstants: + """Test search constant definitions.""" + + def test_type_to_index_mapping(self): + """Test TYPE_TO_INDEX has all expected mappings.""" + expected_types = [ + 'mine', 'party', 'permit', 'mine_documents', + 'explosives_permit', 'now_application', 'notice_of_departure' + ] + + for doc_type in expected_types: + assert doc_type in TYPE_TO_INDEX, f"Missing TYPE_TO_INDEX mapping for {doc_type}" + assert isinstance(TYPE_TO_INDEX[doc_type], str), f"TYPE_TO_INDEX[{doc_type}] should be a string" + + def test_index_to_type_mapping(self): + """Test INDEX_TO_TYPE is inverse of TYPE_TO_INDEX.""" + expected_indices = [ + 'mines', 'parties', 'mine_permits', 'documents', + 'explosives_permits', 'now_applications', 'notices_of_departure' + ] + + for index in expected_indices: + assert index in INDEX_TO_TYPE, f"Missing INDEX_TO_TYPE mapping for {index}" + + def test_type_index_mappings_are_inverse(self): + """Test TYPE_TO_INDEX and INDEX_TO_TYPE are inverses of each other.""" + for doc_type, index in TYPE_TO_INDEX.items(): + assert INDEX_TO_TYPE[index] == doc_type, \ + f"TYPE_TO_INDEX[{doc_type}] = {index} but INDEX_TO_TYPE[{index}] != {doc_type}" + + def test_facet_keys_defined(self): + """Test FACET_KEYS contains expected facet names.""" + expected_facets = [ + 'mine_region', 'mine_classification', 'mine_operation_status', + 'mine_tenure', 'mine_commodity', 'has_tsf', 'verified_status', + 'permit_status', 'party_type', 'type' + ] + + for facet in expected_facets: + assert facet in FACET_KEYS, f"Missing facet key: {facet}" + + def test_filter_params_defined(self): + """Test FILTER_PARAMS contains expected filter names.""" + expected_filters = [ + 'mine_region', 'mine_classification', 'mine_operation_status', + 'mine_tenure', 'mine_commodity', 'has_tsf', 'verified_status', + 'permit_status', 'is_exploration', 'party_type', + 'explosives_permit_status', 'explosives_permit_closed', + 'nod_type', 'nod_status', 'now_application_status', 'now_type' + ] + + for filter_param in expected_filters: + assert filter_param in FILTER_PARAMS, f"Missing filter param: {filter_param}" + + def test_search_fields_defined(self): + """Test SEARCH_FIELDS contains expected searchable fields.""" + # Should have various searchable fields + assert isinstance(SEARCH_FIELDS, list) + assert len(SEARCH_FIELDS) > 0 + + # Should include common fields + expected_fields = [ + 'mine_name', 'mine_no', 'party_name', 'first_name', + 'permit_no', 'document_name' + ] + + for field in expected_fields: + # Check if field or field with boost is present + field_present = any(field in search_field for search_field in SEARCH_FIELDS) + assert field_present, f"Expected search field containing '{field}'" + + def test_es_aggregations_structure(self): + """Test ES_AGGREGATIONS has proper structure.""" + assert isinstance(ES_AGGREGATIONS, dict) + assert len(ES_AGGREGATIONS) > 0 + + # Should have by_index aggregation + assert 'by_index' in ES_AGGREGATIONS + assert 'terms' in ES_AGGREGATIONS['by_index'] + + # Should have mine-related aggregations + assert 'mine_region' in ES_AGGREGATIONS + assert 'major_mine_ind' in ES_AGGREGATIONS + + def test_es_aggregations_nested_properly(self): + """Test nested aggregations have correct structure.""" + # Check nested aggregations + nested_aggs = [ + 'mine_operation_status', + 'mine_tenure_type', + 'mine_commodity_code' + ] + + for agg_name in nested_aggs: + if agg_name in ES_AGGREGATIONS: + assert 'nested' in ES_AGGREGATIONS[agg_name], \ + f"{agg_name} should be a nested aggregation" + + def test_facet_keys_match_aggregations(self): + """Test FACET_KEYS and ES_AGGREGATIONS are consistent.""" + # Most facet keys should have corresponding aggregations + # (some may be derived from multiple aggregations) + core_facets = [ + 'mine_region', 'mine_operation_status', 'mine_tenure', + 'mine_commodity', 'has_tsf', 'permit_status' + ] + + for facet in core_facets: + # Check if there's a related aggregation + # (may not be exact match due to transformation) + agg_exists = facet in ES_AGGREGATIONS or \ + any(facet in agg_name for agg_name in ES_AGGREGATIONS.keys()) + assert agg_exists, f"No aggregation found for facet: {facet}" + + def test_search_fields_have_boosts(self): + """Test important search fields have boost values.""" + # Important fields should have boost notation (^N) + boosted_fields = [f for f in SEARCH_FIELDS if '^' in f] + assert len(boosted_fields) > 0, "Expected some fields to have boost values" + + def test_search_fields_cover_all_types(self): + """Test search fields cover all searchable entity types.""" + # Should have fields from all major entity types + field_str = ' '.join(SEARCH_FIELDS) + + assert 'mine' in field_str.lower() + assert 'party' in field_str.lower() or 'name' in field_str.lower() + assert 'permit' in field_str.lower() + assert 'document' in field_str.lower() + + def test_type_to_index_no_duplicates(self): + """Test TYPE_TO_INDEX has no duplicate index names.""" + indices = list(TYPE_TO_INDEX.values()) + assert len(indices) == len(set(indices)), "Duplicate index names found in TYPE_TO_INDEX" + + def test_index_to_type_no_duplicates(self): + """Test INDEX_TO_TYPE has no duplicate type names.""" + types = list(INDEX_TO_TYPE.values()) + assert len(types) == len(set(types)), "Duplicate type names found in INDEX_TO_TYPE" + + +class TestSearchConstantsUsage: + """Test search constants can be used correctly.""" + + def test_can_lookup_index_from_type(self): + """Test looking up ES index from document type.""" + index = TYPE_TO_INDEX['mine'] + assert index == 'mines' + + index = TYPE_TO_INDEX['party'] + assert index == 'parties' + + def test_can_lookup_type_from_index(self): + """Test looking up document type from ES index.""" + doc_type = INDEX_TO_TYPE['mines'] + assert doc_type == 'mine' + + doc_type = INDEX_TO_TYPE['parties'] + assert doc_type == 'party' + + def test_can_iterate_facet_keys(self): + """Test can iterate over FACET_KEYS.""" + count = 0 + for facet_key in FACET_KEYS: + assert isinstance(facet_key, str) + count += 1 + + assert count > 0, "FACET_KEYS should not be empty" + + def test_can_iterate_filter_params(self): + """Test can iterate over FILTER_PARAMS.""" + count = 0 + for filter_param in FILTER_PARAMS: + assert isinstance(filter_param, str) + count += 1 + + assert count > 0, "FILTER_PARAMS should not be empty" + + def test_can_use_search_fields_in_query(self): + """Test SEARCH_FIELDS format is valid for ES queries.""" + for field in SEARCH_FIELDS: + assert isinstance(field, str) + # Should not have invalid characters + assert not any(char in field for char in ['<', '>', '{', '}']) + # If has boost, should be in format field^number + if '^' in field: + parts = field.split('^') + assert len(parts) == 2, f"Invalid boost format: {field}" + try: + float(parts[1]) # Boost should be a number + except ValueError: + pytest.fail(f"Invalid boost value in field: {field}") diff --git a/services/core-api/tests/search/test_search_facets.py b/services/core-api/tests/search/test_search_facets.py new file mode 100644 index 0000000000..200a6402b8 --- /dev/null +++ b/services/core-api/tests/search/test_search_facets.py @@ -0,0 +1,180 @@ +"""Tests for search facets extraction.""" + +import pytest +from app.api.search.search.search_facets import extract_facets + + +class TestExtractFacets: + """Test facet extraction from ES aggregations.""" + + def test_extract_facets_basic(self): + aggregations = { + 'by_index': { + 'buckets': [ + {'key': 'mines', 'doc_count': 10}, + {'key': 'parties', 'doc_count': 5} + ] + } + } + + facets = extract_facets(aggregations) + + assert 'type' in facets + # extract_facets adds predefined values with 0 counts for missing types + assert len(facets['type']) == 7 + # Find the actual results (non-zero counts) + mine_facet = next(f for f in facets['type'] if f['key'] == 'mine') + party_facet = next(f for f in facets['type'] if f['key'] == 'party') + assert mine_facet == {'key': 'mine', 'count': 10} + assert party_facet == {'key': 'party', 'count': 5} + + def test_extract_facets_mine_region(self): + aggregations = { + 'mine_region': { + 'buckets': [ + {'key': 'SW', 'doc_count': 15}, + {'key': 'NE', 'doc_count': 8} + ] + } + } + + facets = extract_facets(aggregations) + + assert 'mine_region' in facets + assert len(facets['mine_region']) == 2 + assert facets['mine_region'][0] == {'key': 'SW', 'count': 15} + + def test_extract_facets_major_mine_ind(self): + aggregations = { + 'major_mine_ind': { + 'buckets': [ + {'key': 1, 'doc_count': 20}, + {'key': 0, 'doc_count': 30} + ] + } + } + + facets = extract_facets(aggregations) + + assert 'mine_classification' in facets + assert len(facets['mine_classification']) == 2 + # key=1 maps to 'Major Mine', key=0 maps to 'Regional Mine' + assert facets['mine_classification'][0] == {'key': 'Major Mine', 'count': 20} + assert facets['mine_classification'][1] == {'key': 'Regional Mine', 'count': 30} + + def test_extract_facets_nested_aggregation(self): + # Nested aggregation uses specific path structure: ['status_codes', 'codes'] + aggregations = { + 'mine_operation_status': { + 'status_codes': { + 'codes': { + 'buckets': [ + {'key': 'OP', 'doc_count': 25}, + {'key': 'CLD', 'doc_count': 10} + ] + } + } + } + } + + facets = extract_facets(aggregations) + + assert 'mine_operation_status' in facets + assert len(facets['mine_operation_status']) == 2 + assert facets['mine_operation_status'][0] == {'key': 'OP', 'count': 25} + assert facets['mine_operation_status'][1] == {'key': 'CLD', 'count': 10} + + def test_extract_facets_empty_aggregations(self): + aggregations = {} + + facets = extract_facets(aggregations) + + # Should return all expected facet keys + expected_keys = [ + 'mine_region', 'mine_classification', 'mine_operation_status', + 'mine_tenure', 'mine_commodity', 'has_tsf', 'verified_status', + 'permit_status', 'party_type', 'type' + ] + + for key in expected_keys: + assert key in facets + + # Some facets have predefined values with 0 counts + assert facets['mine_classification'] == [ + {'key': 'Major Mine', 'count': 0}, + {'key': 'Regional Mine', 'count': 0} + ] + assert facets['party_type'] == [ + {'key': 'Person', 'count': 0}, + {'key': 'Organization', 'count': 0} + ] + assert len(facets['type']) == 7 # All 7 document types with 0 counts + + # Others should be empty + assert facets['mine_region'] == [] + assert facets['permit_status'] == [] + + def test_extract_facets_boolean_fields(self): + # has_tsf uses special count aggregation, not boolean buckets + aggregations = { + 'has_tsf': { + 'count': { + 'value': 12 + } + }, + 'by_index': { + 'buckets': [ + {'key': 'mines', 'doc_count': 100}, + ] + } + } + + facets = extract_facets(aggregations) + + assert 'has_tsf' in facets + assert len(facets['has_tsf']) == 2 + assert facets['has_tsf'][0] == {'key': 'Has TSF', 'count': 12} + assert facets['has_tsf'][1] == {'key': 'No TSF', 'count': 88} + + def test_extract_facets_party_type(self): + aggregations = { + 'party_type': { + 'buckets': [ + {'key': 'PER', 'doc_count': 50}, + {'key': 'ORG', 'doc_count': 30} + ] + } + } + + facets = extract_facets(aggregations) + + assert 'party_type' in facets + assert len(facets['party_type']) == 2 + # Party types are mapped to display names + assert facets['party_type'][0] == {'key': 'Person', 'count': 50} + assert facets['party_type'][1] == {'key': 'Organization', 'count': 30} + + def test_extract_facets_multiple_aggregations(self): + aggregations = { + 'mine_region': { + 'buckets': [{'key': 'SW', 'doc_count': 10}] + }, + 'permit_status': { + 'buckets': [{'key': 'O', 'doc_count': 5}] + }, + 'major_mine_ind': { + 'buckets': [{'key': 1, 'doc_count': 3}] + } + } + + facets = extract_facets(aggregations) + + assert 'mine_region' in facets + assert 'permit_status' in facets + assert 'mine_classification' in facets + assert len(facets['mine_region']) == 1 + assert len(facets['permit_status']) == 1 + # mine_classification adds predefined values, so we get 2 (Major Mine: 3, Regional Mine: 0) + assert len(facets['mine_classification']) == 2 + assert facets['mine_classification'][0] == {'key': 'Major Mine', 'count': 3} + assert facets['mine_classification'][1] == {'key': 'Regional Mine', 'count': 0} diff --git a/services/core-api/tests/search/test_search_filters.py b/services/core-api/tests/search/test_search_filters.py new file mode 100644 index 0000000000..0d1015882d --- /dev/null +++ b/services/core-api/tests/search/test_search_filters.py @@ -0,0 +1,154 @@ +"""Tests for search filters.""" + +import pytest +from app.api.search.search.search_filters import build_filter_clauses + + +class TestBuildFilterClauses: + """Test building ES filter clauses from filter parameters.""" + + def test_build_filter_clauses_empty_filters(self): + filters = { + 'mine_region': [], + 'mine_classification': [], + 'permit_status': [] + } + + clauses = build_filter_clauses(filters) + + # Should always include deleted_ind filter + assert len(clauses) >= 1 + deleted_filter = next((c for c in clauses if 'bool' in c and 'should' in c['bool']), None) + assert deleted_filter is not None + + def test_build_filter_clauses_mine_region(self): + filters = { + 'mine_region': ['SW', 'NE'] + } + + clauses = build_filter_clauses(filters) + + # Should have region filter + region_filter = next((c for c in clauses if 'terms' in c and 'mine_region.keyword' in c['terms']), None) + assert region_filter is not None + assert set(region_filter['terms']['mine_region.keyword']) == {'SW', 'NE'} + + def test_build_filter_clauses_major_mine(self): + filters = { + 'mine_classification': ['Major Mine'] + } + + clauses = build_filter_clauses(filters) + + # Should have major_mine_ind filter set to true (using terms, not term) + major_mine_filter = next((c for c in clauses if 'terms' in c and 'major_mine_ind' in c['terms']), None) + assert major_mine_filter is not None + assert major_mine_filter['terms']['major_mine_ind'] == [True] + + def test_build_filter_clauses_regional_mine(self): + filters = { + 'mine_classification': ['Regional Mine'] + } + + clauses = build_filter_clauses(filters) + + # Should have major_mine_ind filter set to false (using terms, not term) + regional_mine_filter = next((c for c in clauses if 'terms' in c and 'major_mine_ind' in c['terms']), None) + assert regional_mine_filter is not None + assert regional_mine_filter['terms']['major_mine_ind'] == [False] + + def test_build_filter_clauses_permit_status(self): + filters = { + 'permit_status': ['O', 'C'] + } + + clauses = build_filter_clauses(filters) + + # Should have permit status filter + permit_filter = next((c for c in clauses if 'terms' in c and 'permit_status_code.keyword' in c['terms']), None) + assert permit_filter is not None + assert set(permit_filter['terms']['permit_status_code.keyword']) == {'O', 'C'} + + def test_build_filter_clauses_has_tsf_yes(self): + filters = { + 'has_tsf': ['Has TSF'] # Uses 'Has TSF' label, not 'Yes' + } + + clauses = build_filter_clauses(filters) + + # Should have nested filter with exists for TSF + tsf_filter = next((c for c in clauses if 'nested' in c and c['nested'].get('path') == 'tailings_storage_facilities'), None) + assert tsf_filter is not None + # Check the nested query has an exists clause + assert 'exists' in tsf_filter['nested']['query'] + assert tsf_filter['nested']['query']['exists']['field'] == 'tailings_storage_facilities.mine_tailings_storage_facility_guid' + + def test_build_filter_clauses_has_tsf_no(self): + filters = { + 'has_tsf': ['No TSF'] # Uses 'No TSF' label, not 'No' + } + + clauses = build_filter_clauses(filters) + + # Should have bool must_not with nested filter + tsf_filter = next((c for c in clauses if 'bool' in c and 'must_not' in c['bool']), None) + assert tsf_filter is not None + # Check that must_not contains a nested filter + assert 'nested' in tsf_filter['bool']['must_not'] + assert tsf_filter['bool']['must_not']['nested']['path'] == 'tailings_storage_facilities' + + def test_build_filter_clauses_verified_status(self): + filters = { + 'verified_status': ['Verified'] + } + + clauses = build_filter_clauses(filters) + + # Should have nested filter for verified status + verified_filter = next((c for c in clauses if 'nested' in c and c['nested'].get('path') == 'verified_status'), None) + assert verified_filter is not None + + def test_build_filter_clauses_multiple_filters(self): + filters = { + 'mine_region': ['SW'], + 'permit_status': ['O'], + 'mine_classification': ['Major Mine'] + } + + clauses = build_filter_clauses(filters) + + # Should have multiple filters + assert len(clauses) >= 3 + + def test_build_filter_clauses_mine_operation_status(self): + filters = { + 'mine_operation_status': ['OP', 'CLD'] + } + + clauses = build_filter_clauses(filters) + + # Should have nested filter for operation status + op_status_filter = next((c for c in clauses if 'nested' in c and c['nested'].get('path') == 'mine_status'), None) + assert op_status_filter is not None + + def test_build_filter_clauses_mine_tenure(self): + filters = { + 'mine_tenure': ['PLR', 'MIN'] + } + + clauses = build_filter_clauses(filters) + + # Should have nested filter for tenure + tenure_filter = next((c for c in clauses if 'nested' in c and c['nested'].get('path') == 'mine_types'), None) + assert tenure_filter is not None + + def test_build_filter_clauses_mine_commodity(self): + filters = { + 'mine_commodity': ['CU', 'AU'] + } + + clauses = build_filter_clauses(filters) + + # Should have nested filter for commodity + commodity_filter = next((c for c in clauses if 'nested' in c), None) + assert commodity_filter is not None diff --git a/services/core-api/tests/search/test_search_transformers.py b/services/core-api/tests/search/test_search_transformers.py new file mode 100644 index 0000000000..0e62e58507 --- /dev/null +++ b/services/core-api/tests/search/test_search_transformers.py @@ -0,0 +1,357 @@ +"""Tests for search transformers.""" + +import pytest +from app.api.search.search.search_transformers import ( + prepare_mine_source, + prepare_party_source, + prepare_permit_source, + prepare_document_source, + prepare_explosives_permit_source, + prepare_now_application_source, + prepare_nod_source, + transform_es_results, + PREPARE_FUNCTIONS, + SEARCH_RESULT_MODELS, +) + + +class TestPrepareMineSources: + """Test mine source preparation.""" + + def test_prepare_mine_source_basic(self): + source = { + 'mine_guid': 'test-guid-123', + 'mine_name': 'Test Mine', + 'mine_no': 'M-001', + 'mine_region': 'SW', + 'major_mine_ind': True, + 'mms_alias': 'ALIAS001', + } + + result = prepare_mine_source(source) + + assert result['mine_guid'] == 'test-guid-123' + assert result['mine_name'] == 'Test Mine' + assert result['mine_no'] == 'M-001' + assert result['mine_region'] == 'SW' + assert result['major_mine_ind'] is True + + def test_prepare_mine_source_with_status(self): + source = { + 'mine_guid': 'test-guid-123', + 'mine_name': 'Test Mine', + 'mine_status': [{ + 'status_xref': { + 'mine_operation_status_code': 'OP' + } + }] + } + + result = prepare_mine_source(source) + + assert 'mine_status' in result + assert result['mine_status']['status_labels'] == ['OP'] + + def test_prepare_mine_source_with_nested_fields(self): + source = { + 'mine_guid': 'test-guid-123', + 'mine_types': [{'mine_type_guid': 'type-1'}], + 'tailings_storage_facilities': [{'tsf_guid': 'tsf-1'}], + 'work_information': {'work_start_date': '2024-01-01'}, + 'verified_status': {'healthy_ind': True} + } + + result = prepare_mine_source(source) + + assert result['mine_type'] == [{'mine_type_guid': 'type-1'}] + assert result['mine_tailings_storage_facilities'] == [{'tsf_guid': 'tsf-1'}] + assert result['mine_work_information'] == {'work_start_date': '2024-01-01'} + assert result['verified_status'] == {'healthy_ind': True} + + +class TestPreparePartySources: + """Test party source preparation.""" + + def test_prepare_party_source_person(self): + source = { + 'party_guid': 'party-123', + 'first_name': 'John', + 'party_name': 'Doe', + 'party_type_code': 'PER', + 'email': 'john.doe@example.com', + 'phone_no': '555-1234' + } + + result = prepare_party_source(source) + + assert result['party_guid'] == 'party-123' + assert result['name'] == 'John Doe' + assert result['first_name'] == 'John' + assert result['party_name'] == 'Doe' + assert result['email'] == 'john.doe@example.com' + assert result['party_orgbook_entity'] is None + assert result['business_role_appts'] == [] + assert result['address'] == [] + + def test_prepare_party_source_organization(self): + source = { + 'party_guid': 'party-456', + 'first_name': '', + 'party_name': 'ACME Corporation', + 'party_type_code': 'ORG' + } + + result = prepare_party_source(source) + + assert result['name'] == 'ACME Corporation' + assert result['first_name'] == '' + + def test_prepare_party_source_with_appointments(self): + source = { + 'party_guid': 'party-123', + 'first_name': 'Jane', + 'party_name': 'Smith', + 'mine_party_appt': [{ + 'mine_party_appt_guid': 'appt-123', + 'mine_party_appt_type_code': 'PMT', + 'start_date': '2024-01-01', + 'end_date': None + }] + } + + result = prepare_party_source(source) + + assert len(result['mine_party_appt']) == 1 + appt = result['mine_party_appt'][0] + assert appt['mine_party_appt_guid'] == 'appt-123' + assert appt['mine_party_appt_type_code'] == 'PMT' + assert appt['start_date'] == '2024-01-01' + assert appt['end_date'] is None + assert appt['mine'] is None + assert appt['permit_no'] is None + + +class TestPreparePermitSources: + """Test permit source preparation.""" + + def test_prepare_permit_source_basic(self): + source = { + 'permit_guid': 'permit-123', + 'permit_no': 'P-001', + 'permittees': [{ + 'first_name': 'John', + 'party_name': 'Doe' + }], + 'mine_guids': ['mine-guid-1', 'mine-guid-2'] + } + + result = prepare_permit_source(source) + + assert result['permit_guid'] == 'permit-123' + assert result['permit_no'] == 'P-001' + assert result['current_permittee'] == 'John Doe' + assert len(result['mine']) == 2 + assert result['mine'][0]['mine_guid'] == 'mine-guid-1' + + def test_prepare_permit_source_organization_permittee(self): + source = { + 'permit_guid': 'permit-456', + 'permit_no': 'P-002', + 'permittees': [{ + 'first_name': '', + 'party_name': 'Mining Corp' + }] + } + + result = prepare_permit_source(source) + + assert result['current_permittee'] == 'Mining Corp' + + +class TestPrepareDocumentSources: + """Test document source preparation.""" + + def test_prepare_document_source_with_mine_info(self): + source = { + 'mine_document_guid': 'doc-123', + 'document_name': 'Test Document.pdf', + 'mine': { + 'mine_name': 'Test Mine', + 'mine_guid': 'mine-123' + } + } + + result = prepare_document_source(source) + + assert result['mine_name'] == 'Test Mine' + + def test_prepare_document_source_without_mine_info(self): + source = { + 'mine_document_guid': 'doc-123', + 'document_name': 'Test Document.pdf' + } + + result = prepare_document_source(source) + + assert result['mine_name'] is None + + +class TestPrepareExplosivesPermitSources: + """Test explosives permit source preparation.""" + + def test_prepare_explosives_permit_source(self): + source = { + 'explosives_permit_guid': 'exp-123', + 'application_number': 'APP-001', + 'mine': { + 'mine_name': 'Test Mine' + } + } + + result = prepare_explosives_permit_source(source) + + assert result['mine_name'] == 'Test Mine' + + +class TestPrepareNowApplicationSources: + """Test NoW application source preparation.""" + + def test_prepare_now_application_source(self): + source = { + 'now_application_guid': 'now-123', + 'now_number': 'NOW-001', + 'mine': { + 'mine_name': 'Test Mine' + }, + 'application': { + 'now_application_status_code': 'REC', + 'notice_of_work_type_code': 'QIM' + } + } + + result = prepare_now_application_source(source) + + assert result['mine_name'] == 'Test Mine' + assert result['now_application_status_code'] == 'REC' + assert result['notice_of_work_type_code'] == 'QIM' + + +class TestPrepareNodSources: + """Test NOD source preparation.""" + + def test_prepare_nod_source(self): + source = { + 'nod_guid': 'nod-123', + 'nod_no': 'NOD-001', + 'mine': { + 'mine_name': 'Test Mine' + } + } + + result = prepare_nod_source(source) + + assert result['mine_name'] == 'Test Mine' + + +class TestTransformESResults: + """Test ES results transformation.""" + + def test_transform_es_results_multiple_types(self): + hits = [ + { + '_index': 'mines', + '_score': 10.5, + '_source': { + 'mine_guid': 'mine-123', + 'mine_name': 'Test Mine', + 'mine_no': 'M-001' + } + }, + { + '_index': 'parties', + '_score': 8.3, + '_source': { + 'party_guid': 'party-123', + 'first_name': 'John', + 'party_name': 'Doe', + 'party_type_code': 'PER' + } + } + ] + + results = transform_es_results(hits) + + assert 'mine' in results + assert 'party' in results + assert len(results['mine']) == 1 + assert len(results['party']) == 1 + + mine_result = results['mine'][0] + assert mine_result['score'] == 10.5 + assert mine_result['type'] == 'mine' + assert mine_result['result']['mine_name'] == 'Test Mine' + + party_result = results['party'][0] + assert party_result['score'] == 8.3 + assert party_result['type'] == 'party' + assert party_result['result']['name'] == 'John Doe' + + def test_transform_es_results_unknown_index(self): + hits = [ + { + '_index': 'unknown_index', + '_score': 5.0, + '_source': {'test': 'data'} + } + ] + + results = transform_es_results(hits) + + assert results == {} + + def test_transform_es_results_groups_by_type(self): + hits = [ + { + '_index': 'mines', + '_score': 10.0, + '_source': {'mine_guid': 'mine-1', 'mine_name': 'Mine 1'} + }, + { + '_index': 'mines', + '_score': 9.0, + '_source': {'mine_guid': 'mine-2', 'mine_name': 'Mine 2'} + } + ] + + results = transform_es_results(hits) + + assert 'mine' in results + assert len(results['mine']) == 2 + assert results['mine'][0]['result']['mine_name'] == 'Mine 1' + assert results['mine'][1]['result']['mine_name'] == 'Mine 2' + + +class TestTransformerMappings: + """Test transformer configuration mappings.""" + + def test_prepare_functions_has_all_types(self): + expected_types = [ + 'mine', 'party', 'permit', 'mine_documents', + 'explosives_permit', 'now_application', 'notice_of_departure' + ] + + for doc_type in expected_types: + assert doc_type in PREPARE_FUNCTIONS, f"Missing prepare function for {doc_type}" + + def test_search_result_models_has_all_types(self): + expected_types = [ + 'mine', 'party', 'permit', 'mine_documents', + 'explosives_permit', 'now_application', 'notice_of_departure' + ] + + for doc_type in expected_types: + assert doc_type in SEARCH_RESULT_MODELS, f"Missing search result model for {doc_type}" + + def test_mappings_are_synchronized(self): + """Ensure both mappings have the same keys.""" + assert set(PREPARE_FUNCTIONS.keys()) == set(SEARCH_RESULT_MODELS.keys()) diff --git a/services/core-web/src/components/mine/Permit/Search/__snapshots__/PermitConditionSearch.integration.spec.tsx.snap b/services/core-web/src/components/mine/Permit/Search/__snapshots__/PermitConditionSearch.integration.spec.tsx.snap index 8bd63424c7..fa1d67f9ed 100644 --- a/services/core-web/src/components/mine/Permit/Search/__snapshots__/PermitConditionSearch.integration.spec.tsx.snap +++ b/services/core-web/src/components/mine/Permit/Search/__snapshots__/PermitConditionSearch.integration.spec.tsx.snap @@ -507,6 +507,7 @@ exports[`PermitConditionSearch Integration Tests shows and applies filters 1`] =
@@ -528,16 +530,109 @@ exports[`PermitConditionSearch Integration Tests shows and applies filters 1`] = />
- The permit requires monthly water quality monitoring. +
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+ The permit requires monthly water quality monitoring. +
+
+
+
+
diff --git a/services/core-web/src/components/mine/Permit/Search/components/MarkdownViewer.spec.tsx b/services/core-web/src/components/mine/Permit/Search/components/MarkdownViewer.spec.tsx index 69495ab695..28a295ed87 100644 --- a/services/core-web/src/components/mine/Permit/Search/components/MarkdownViewer.spec.tsx +++ b/services/core-web/src/components/mine/Permit/Search/components/MarkdownViewer.spec.tsx @@ -46,7 +46,7 @@ describe('MarkdownViewer', () => { const mockedMarkdown = screen.getByTestId('mocked-markdown'); expect(mockedMarkdown).toHaveTextContent('Check this'); - expect(mockedMarkdown).toHaveTextContent('[[1]](#condition-abc123)'); + expect(mockedMarkdown).toHaveTextContent('[1](#condition-abc123)'); expect(mockedMarkdown).toHaveTextContent('reference'); expect(container).toMatchSnapshot(); @@ -58,8 +58,8 @@ describe('MarkdownViewer', () => { const mockedMarkdown = screen.getByTestId('mocked-markdown'); expect(mockedMarkdown).toHaveTextContent('Check these references'); - expect(mockedMarkdown).toHaveTextContent('[[1]](#condition-abc123)'); - expect(mockedMarkdown).toHaveTextContent('[[2]](#condition-def456)'); + expect(mockedMarkdown).toHaveTextContent('[1](#condition-abc123)'); + expect(mockedMarkdown).toHaveTextContent('[2](#condition-def456)'); }); test('processes double bracket reference correctly', () => { @@ -68,29 +68,17 @@ describe('MarkdownViewer', () => { const mockedMarkdown = screen.getByTestId('mocked-markdown'); expect(mockedMarkdown).toHaveTextContent('Check this'); - expect(mockedMarkdown).toHaveTextContent('[[1]](#condition-abc123)'); + expect(mockedMarkdown).toHaveTextContent('[1](#condition-abc123)'); expect(mockedMarkdown).toHaveTextContent('reference'); }); test('handles click on non-reference link', () => { const mockMarkdown = 'Check this [regular link](https://example.com)'; - const { container } = render(); - - const markdownDiv: any = container.querySelector('.permit-search__markdown'); - expect(markdownDiv).not.toBeNull(); - - const mockEvent = { - preventDefault: jest.fn(), - target: { - tagName: 'A', - href: 'https://example.com' - } - }; - - markdownDiv!.onclick!(mockEvent as any); + render(); - expect(mockEvent.preventDefault).not.toHaveBeenCalled(); - expect(mockScrollIntoView).not.toHaveBeenCalled(); + const mockedMarkdown = screen.getByTestId('mocked-markdown'); + expect(mockedMarkdown).toHaveTextContent('Check this'); + expect(mockedMarkdown).toHaveTextContent('[regular link](https://example.com)'); }); }); diff --git a/services/core-web/src/components/mine/Permit/Search/components/__snapshots__/MarkdownViewer.spec.tsx.snap b/services/core-web/src/components/mine/Permit/Search/components/__snapshots__/MarkdownViewer.spec.tsx.snap index 9f0be356e5..16703ad5fb 100644 --- a/services/core-web/src/components/mine/Permit/Search/components/__snapshots__/MarkdownViewer.spec.tsx.snap +++ b/services/core-web/src/components/mine/Permit/Search/components/__snapshots__/MarkdownViewer.spec.tsx.snap @@ -12,7 +12,7 @@ exports[`MarkdownViewer processes single reference correctly 1`] = `
- Check this [[1]](#condition-abc123) reference + Check this [1](#condition-abc123) reference
diff --git a/services/core-web/src/components/search/GenericResultsTable.js b/services/core-web/src/components/search/GenericResultsTable.tsx similarity index 96% rename from services/core-web/src/components/search/GenericResultsTable.js rename to services/core-web/src/components/search/GenericResultsTable.tsx index 1a9ab745ac..3220647a88 100644 --- a/services/core-web/src/components/search/GenericResultsTable.js +++ b/services/core-web/src/components/search/GenericResultsTable.tsx @@ -1,7 +1,6 @@ import React from "react"; import { Table, Typography } from "antd"; import { Link } from "react-router-dom"; -import * as router from "@/constants/routes"; const { Text } = Typography; diff --git a/services/core-web/src/components/search/GlobalSearch.tsx b/services/core-web/src/components/search/GlobalSearch.tsx deleted file mode 100644 index 4e90731dac..0000000000 --- a/services/core-web/src/components/search/GlobalSearch.tsx +++ /dev/null @@ -1,847 +0,0 @@ -import React, { useState, useEffect, useRef, useMemo, useCallback } from "react"; -import { useDispatch, useSelector } from "react-redux"; -import { useHistory, useLocation } from "react-router-dom"; -import { Modal, Input, Typography, Button, List, Space, Row, Col, Avatar, Divider, Tag, Switch } from "antd"; -import { AimOutlined } from "@ant-design/icons"; -import { - SearchOutlined, - FileSearchOutlined, - EnterOutlined, - EnvironmentOutlined, - TeamOutlined, - FileProtectOutlined, - ClockCircleOutlined, - DeleteOutlined, - HistoryOutlined, - UserOutlined, - BankOutlined, - ExceptionOutlined, - AlertOutlined, -} from "@ant-design/icons"; -import { fetchSearchBarResults } from "@mds/common/redux/actionCreators/searchActionCreator"; -import { getSearchBarResults, getSearchBarFacets } from "@mds/common/redux/reducers/searchReducer"; -import * as router from "@/constants/routes"; -import { ISearchResult, ISimpleSearchResult } from "@mds/common/interfaces"; - -const { Text, Title } = Typography; - -const RECENT_SEARCHES_KEY = "mds_recent_searches"; -const MAX_RECENT_SEARCHES = 5; - -interface GlobalSearchProps { - placeholder?: string; - size?: "small" | "middle" | "large"; - enableShortcut?: boolean; -} - -const TYPE_CONFIG: Record = { - mine: { icon: , label: "Mines", color: "#2e7d32", types: ["mine"] }, - contact: { icon: , label: "People", color: "#1565c0", types: ["person", "party"] }, - organization: { icon: , label: "Organizations", color: "#f57c00", types: ["organization"] }, - permit: { icon: , label: "Permits", color: "#e65100", types: ["permit"] }, - explosives_permit: { icon: , label: "Explosives", color: "#d32f2f", types: ["explosives_permit"] }, - now_application: { icon: , label: "NoW", color: "#0288d1", types: ["now_application"] }, - nod: { icon: , label: "NODs", color: "#7b1fa2", types: ["nod", "notice_of_departure"] }, - document: { icon: , label: "Documents", color: "#455a64", types: ["mine_documents", "permit_documents"] }, -}; - -const RESULT_TYPE_CONFIG: Record = { - mine: { icon: , label: "Mine", color: "#2e7d32" }, - person: { icon: , label: "Person", color: "#1565c0" }, - organization: { icon: , label: "Organization", color: "#f57c00" }, - party: { icon: , label: "Contact", color: "#1565c0" }, - permit: { icon: , label: "Permit", color: "#e65100" }, - explosives_permit: { icon: , label: "Explosives Permit", color: "#d32f2f" }, - now_application: { icon: , label: "Notice of Work", color: "#0288d1" }, - nod: { icon: , label: "NOD", color: "#7b1fa2" }, - notice_of_departure: { icon: , label: "NOD", color: "#7b1fa2" }, - mine_documents: { icon: , label: "Document", color: "#455a64" }, - permit_documents: { icon: , label: "Document", color: "#455a64" }, -}; - -const COMMANDS: Record = { - mine: { action: "filter:mine", description: "Toggle Mines filter", aliases: ["mines", "m"] }, - contact: { action: "filter:contact", description: "Toggle People filter", aliases: ["contacts", "people", "person", "p"] }, - organization: { action: "filter:organization", description: "Toggle Organizations filter", aliases: ["organizations", "orgs", "org", "o"] }, - permit: { action: "filter:permit", description: "Toggle Permits filter", aliases: ["permits"] }, - explosives: { action: "filter:explosives_permit", description: "Toggle Explosives filter", aliases: ["explosives_permit", "exp", "e"] }, - now: { action: "filter:now_application", description: "Toggle Notice of Work filter", aliases: ["now_application", "notice", "work"] }, - nod: { action: "filter:nod", description: "Toggle NODs filter", aliases: ["nods", "n"] }, - document: { action: "filter:document", description: "Toggle Documents filter", aliases: ["documents", "docs", "doc", "d"] }, - here: { action: "scope:mine", description: "Toggle scope to current mine", aliases: ["this", "scope"] }, - clear: { action: "clear:filters", description: "Clear all filters", aliases: ["reset", "c"] }, -}; - -const GlobalSearch: React.FC = ({ placeholder = "Search Core...", enableShortcut = true }) => { - const [isModalVisible, setIsModalVisible] = useState(false); - const [searchTerm, setSearchTerm] = useState(""); - const [selectedIndex, setSelectedIndex] = useState(0); - const [recentSearches, setRecentSearches] = useState([]); - const [activeFilters, setActiveFilters] = useState([]); - const [scopeToMine, setScopeToMine] = useState(false); - const [commandMode, setCommandMode] = useState(false); - const [commandInput, setCommandInput] = useState(""); - const [quickFilter, setQuickFilter] = useState(null); // Filter applied via shortcut - - const dispatch = useDispatch(); - const searchResults = useSelector(getSearchBarResults); - const facets = useSelector(getSearchBarFacets); - const history = useHistory(); - const location = useLocation(); - const inputRef = useRef(null); - - // Extract mine_guid from URL if on a mine page - const currentMineGuid = useMemo(() => { - const match = location.pathname.match(/\/mine-dashboard\/([a-f0-9-]+)/i); - return match ? match[1] : null; - }, [location.pathname]); - - const isOnMinePage = !!currentMineGuid; - - useEffect(() => { - const stored = localStorage.getItem(RECENT_SEARCHES_KEY); - if (stored) { - try { - setRecentSearches(JSON.parse(stored)); - } catch { - setRecentSearches([]); - } - } - }, []); - - const saveRecentSearch = (term: string) => { - if (!term.trim()) return; - const updated = [term, ...recentSearches.filter((s) => s !== term)].slice(0, MAX_RECENT_SEARCHES); - setRecentSearches(updated); - localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated)); - }; - - const removeRecentSearch = (term: string, e: React.MouseEvent) => { - e.stopPropagation(); - const updated = recentSearches.filter((s) => s !== term); - setRecentSearches(updated); - localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated)); - }; - - const handleOpen = () => { - setIsModalVisible(true); - setTimeout(() => inputRef.current?.focus(), 50); - }; - - const handleClose = useCallback(() => { - setIsModalVisible(false); - setSearchTerm(""); - setSelectedIndex(0); - setActiveFilters([]); - setScopeToMine(false); - setCommandMode(false); - setCommandInput(""); - setQuickFilter(null); - }, []); - - useEffect(() => { - if (!enableShortcut) return; - - const handleKeyDown = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === "k") { - e.preventDefault(); - - // Check if any global search modal is already open - const isAnyModalOpen = document.querySelector('.global-search-modal'); - - if (isModalVisible) { - handleClose(); - } else if (!isAnyModalOpen) { - handleOpen(); - } - } - }; - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [isModalVisible, handleClose, enableShortcut]); - - const getSearchTypes = (filters: string[], includeQuickFilter?: string | null) => { - const allFilters = includeQuickFilter ? [...filters, includeQuickFilter] : filters; - const uniqueFilters = [...new Set(allFilters)]; - if (uniqueFilters.length === 0) return null; - return uniqueFilters.flatMap((f) => TYPE_CONFIG[f]?.types || []); - }; - - const getMineGuidForSearch = () => scopeToMine && currentMineGuid ? currentMineGuid : null; - - const findCommand = (input: string): { key: string; command: typeof COMMANDS[string] } | null => { - const cmd = input.toLowerCase().trim(); - for (const [key, command] of Object.entries(COMMANDS)) { - if (key === cmd || command.aliases.includes(cmd)) { - return { key, command }; - } - } - return null; - }; - - const getMatchingCommands = (input: string) => { - const cmd = input.toLowerCase().trim(); - if (!cmd) return Object.entries(COMMANDS); - return Object.entries(COMMANDS).filter(([key, command]) => - key.startsWith(cmd) || command.aliases.some(a => a.startsWith(cmd)) - ); - }; - - const executeCommand = (action: string, followUpSearch?: string) => { - const [type, target] = action.split(":"); - let newFilters = activeFilters; - let newScopeToMine = scopeToMine; - - if (type === "filter") { - newFilters = activeFilters.includes(target) - ? activeFilters.filter((f) => f !== target) - : [...activeFilters, target]; - setActiveFilters(newFilters); - } else if (type === "scope" && isOnMinePage) { - newScopeToMine = !scopeToMine; - setScopeToMine(newScopeToMine); - } else if (type === "clear") { - newFilters = []; - newScopeToMine = false; - setActiveFilters([]); - setScopeToMine(false); - } - - setCommandMode(false); - setCommandInput(""); - setSelectedIndex(0); - - // If there's a follow-up search term, set it and trigger search - if (followUpSearch && followUpSearch.trim()) { - const term = followUpSearch.trim(); - setSearchTerm(term); - const mineGuid = newScopeToMine && currentMineGuid ? currentMineGuid : null; - dispatch(fetchSearchBarResults(term, getSearchTypes(newFilters), mineGuid)); - } else if (searchTerm) { - // Re-run existing search with new filters - const mineGuid = newScopeToMine && currentMineGuid ? currentMineGuid : null; - dispatch(fetchSearchBarResults(searchTerm, getSearchTypes(newFilters), mineGuid)); - } - }; - - const handleSearchChange = (e: React.ChangeEvent) => { - const value = e.target.value; - - // Check if input starts with / - this means command mode - if (value.startsWith("/")) { - if (!commandMode) { - setCommandMode(true); - } - - const cmdContent = value.slice(1); // Everything after / - const { commandPart, searchPart } = parseCommandInput(cmdContent); - - // Auto-apply filter: if user just typed a space and there's a matching command - if (cmdContent.endsWith(" ") && !searchPart && commandPart) { - const matchingCommands = getMatchingCommands(commandPart.trim()); - if (matchingCommands.length > 0) { - const action = matchingCommands[0][1].action; - // Only set quickFilter for filter commands - if (action.startsWith("filter:")) { - const filterKey = action.split(":")[1]; - setQuickFilter(filterKey); - setCommandMode(false); - setCommandInput(""); - setSearchTerm(""); - setTimeout(() => inputRef.current?.focus(), 0); - return; - } else if (action === "scope:mine" && isOnMinePage) { - setScopeToMine(true); - setCommandMode(false); - setCommandInput(""); - setSearchTerm(""); - setTimeout(() => inputRef.current?.focus(), 0); - return; - } else if (action === "clear:filters") { - setActiveFilters([]); - setQuickFilter(null); - setScopeToMine(false); - setCommandMode(false); - setCommandInput(""); - setSearchTerm(""); - setTimeout(() => inputRef.current?.focus(), 0); - return; - } - } - } - - setCommandInput(cmdContent); - setSelectedIndex(0); - return; - } - - // If we were in command mode but / is gone, exit command mode - if (commandMode) { - setCommandMode(false); - setCommandInput(""); - } - - setSearchTerm(value); - setSelectedIndex(0); - if (value.length > 0) { - dispatch(fetchSearchBarResults(value, getSearchTypes(activeFilters, quickFilter), getMineGuidForSearch())); - } - }; - - const toggleFilter = (filterKey: string) => { - const newFilters = activeFilters.includes(filterKey) - ? activeFilters.filter((f) => f !== filterKey) - : [...activeFilters, filterKey]; - setActiveFilters(newFilters); - setSelectedIndex(0); - if (searchTerm.length > 0) { - dispatch(fetchSearchBarResults(searchTerm, getSearchTypes(newFilters, quickFilter), getMineGuidForSearch())); - } - }; - - const toggleScopeToMine = (checked: boolean) => { - setScopeToMine(checked); - const mineGuid = checked && currentMineGuid ? currentMineGuid : null; - // Trigger search immediately - use "*" as wildcard if no search term - const term = searchTerm || "*"; - dispatch(fetchSearchBarResults(term, getSearchTypes(activeFilters, quickFilter), mineGuid)); - }; - - const navigateToResult = (item: ISearchResult) => { - saveRecentSearch(item.result.value); - let routeUrl = ""; - switch (item.type) { - case "mine": - routeUrl = router.MINE_GENERAL.dynamicRoute(item.result.id); - break; - case "person": - case "organization": - case "party": - routeUrl = router.PARTY_PROFILE.dynamicRoute(item.result.id); - break; - case "now_application": - routeUrl = router.NOTICE_OF_WORK_APPLICATION.dynamicRoute(item.result.id, "verification"); - break; - case "permit": - routeUrl = router.VIEW_MINE_PERMIT.dynamicRoute(item.result.mine_guid, item.result.id); - break; - case "explosives_permit": - routeUrl = router.MINE_PERMITS.dynamicRoute(item.result.mine_guid); - break; - case "nod": - routeUrl = router.NOTICE_OF_DEPARTURE.dynamicRoute(item.result.mine_guid, item.result.id); - break; - } - if (routeUrl) { - handleClose(); - history.push(routeUrl); - } - }; - - const handleEnter = () => { - if (searchResults?.length > 0) { - navigateToResult(searchResults[selectedIndex]); - } else if (searchTerm.length > 0) { - saveRecentSearch(searchTerm); - handleClose(); - history.push(router.SEARCH_RESULTS.dynamicRoute({ q: searchTerm })); - } - }; - - const handleRecentSearchClick = (term: string) => { - setSearchTerm(term); - dispatch(fetchSearchBarResults(term, getSearchTypes(activeFilters, quickFilter), getMineGuidForSearch())); - }; - - const parseCommandInput = (input: string): { commandPart: string; searchPart: string } => { - const spaceIndex = input.indexOf(" "); - if (spaceIndex === -1) { - return { commandPart: input, searchPart: "" }; - } - return { - commandPart: input.slice(0, spaceIndex), - searchPart: input.slice(spaceIndex + 1) - }; - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (commandMode) { - const { commandPart, searchPart } = parseCommandInput(commandInput); - const matchingCommands = getMatchingCommands(commandPart); - const totalCommands = matchingCommands.length; - - switch (e.key) { - case "ArrowDown": - e.preventDefault(); - setSelectedIndex((prev) => (prev + 1) % (totalCommands || 1)); - break; - case "ArrowUp": - e.preventDefault(); - setSelectedIndex((prev) => (prev - 1 + (totalCommands || 1)) % (totalCommands || 1)); - break; - case "Enter": - e.preventDefault(); - if (matchingCommands.length > 0) { - executeCommand(matchingCommands[selectedIndex][1].action, searchPart); - } - break; - case "Tab": - e.preventDefault(); - // Tab autocompletes the command but keeps the search part - if (matchingCommands.length > 0) { - const selectedCmd = matchingCommands[selectedIndex][0]; - setCommandInput(selectedCmd + " " + searchPart); - } - break; - case "Escape": - e.preventDefault(); - setCommandMode(false); - setCommandInput(""); - setSearchTerm(""); - break; - } - return; - } - - const totalItems = searchResults?.length || recentSearches.length || 0; - switch (e.key) { - case "ArrowDown": - e.preventDefault(); - setSelectedIndex((prev) => (prev + 1) % (totalItems || 1)); - break; - case "ArrowUp": - e.preventDefault(); - setSelectedIndex((prev) => (prev - 1 + (totalItems || 1)) % (totalItems || 1)); - break; - case "Enter": - e.preventDefault(); - if (!searchTerm && recentSearches.length > 0) { - handleRecentSearchClick(recentSearches[selectedIndex]); - } else { - handleEnter(); - } - break; - case "Escape": - handleClose(); - break; - case "Backspace": - // Clear quickFilter when backspacing with empty input - if (searchTerm === "" && quickFilter) { - e.preventDefault(); - setQuickFilter(null); - } - break; - } - }; - - const highlightMatch = (text: string, search: string) => { - if (!search || !text) return text; - const regex = new RegExp(`(${search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "gi"); - const parts = text.split(regex); - return parts.map((part, i) => (regex.test(part) ? {part} : part)); - }; - - const groupedResults = useMemo(() => { - if (!searchResults?.length) return null; - const groups: Record[]> = {}; - searchResults.forEach((result) => { - if (!groups[result.type]) groups[result.type] = []; - groups[result.type].push(result); - }); - return groups; - }, [searchResults]); - - const renderResultItem = (item: ISearchResult, index: number) => { - const config = RESULT_TYPE_CONFIG[item.type] || { icon: , label: item.type, color: "#8c8c8c" }; - const isSelected = index === selectedIndex; - - return ( - navigateToResult(item)} - onMouseEnter={() => setSelectedIndex(index)} - > - - } - title={{highlightMatch(item.result.value, searchTerm)}} - description={ - - {config.label} - {item.result.description && • {item.result.description}} - {item.result.highlight && ( - - )} - - } - /> - {isSelected && } - - ); - }; - - const getFacetCount = (filterKey: string): number => { - if (filterKey === "mine") return facets.mine ?? 0; - if (filterKey === "contact") return facets.person ?? 0; - if (filterKey === "organization") return facets.organization ?? 0; - if (filterKey === "permit") return facets.permit ?? 0; - if (filterKey === "explosives_permit") return facets.explosives_permit ?? 0; - if (filterKey === "now_application") return facets.now_application ?? 0; - if (filterKey === "nod") return facets.nod ?? 0; - if (filterKey === "document") return (facets.mine_documents ?? 0) + (facets.permit_documents ?? 0); - return 0; - }; - - const renderFilters = () => ( -
- - {isOnMinePage && ( - toggleScopeToMine(!scopeToMine)} - style={{ - cursor: "pointer", - backgroundColor: scopeToMine ? "#5e46a115" : "transparent", - borderColor: scopeToMine ? "#5e46a1" : "#d9d9d9", - color: scopeToMine ? "#5e46a1" : "#595959", - margin: 0, - fontWeight: scopeToMine ? 600 : 400, - }} - > - - - This Mine - - - )} - {isOnMinePage && } - {Object.entries(TYPE_CONFIG).map(([key, config]) => { - const isActive = activeFilters.includes(key); - const count = getFacetCount(key); - - return ( - toggleFilter(key)} - style={{ - cursor: "pointer", - backgroundColor: isActive ? `${config.color}15` : "transparent", - borderColor: isActive ? config.color : "#d9d9d9", - color: isActive ? config.color : "#595959", - margin: 0, - }} - > - - {config.icon} - {config.label} - {searchTerm && ({count})} - - - ); - })} - -
- ); - - const handleViewAll = () => { - saveRecentSearch(searchTerm); - handleClose(); - history.push(router.SEARCH_RESULTS.dynamicRoute({ q: searchTerm })); - }; - - const getCommandIcon = (action: string) => { - if (action.startsWith("filter:")) { - const filterKey = action.split(":")[1]; - return TYPE_CONFIG[filterKey]?.icon || ; - } - if (action === "scope:mine") return ; - if (action === "clear:filters") return ; - return ; - }; - - const isCommandActive = (action: string): boolean => { - if (action.startsWith("filter:")) { - return activeFilters.includes(action.split(":")[1]); - } - if (action === "scope:mine") return scopeToMine; - return false; - }; - - const renderCommands = () => { - const { commandPart, searchPart } = parseCommandInput(commandInput); - const matchingCommands = getMatchingCommands(commandPart); - - return ( -
- - - - Commands - {searchPart && ( - - → will search "{searchPart}" - - )} - - - { - const isSelected = index === selectedIndex; - const isActive = isCommandActive(command.action); - const isDisabled = command.action === "scope:mine" && !isOnMinePage; - - return ( - !isDisabled && executeCommand(command.action, searchPart)} - onMouseEnter={() => setSelectedIndex(index)} - style={{ opacity: isDisabled ? 0.5 : 1, cursor: isDisabled ? "not-allowed" : "pointer" }} - > - - } - title={ - - /{key} - {isActive && ON} - {isDisabled && (not on mine page)} - - } - description={{command.description}} - /> - {isSelected && } - - ); - }} - split={false} - locale={{ emptyText: No matching commands }} - /> -
- - Space applies filter, Tab autocompletes, Enter executes with search term - -
-
- ); - }; - - const renderResults = () => { - if (commandMode) { - return renderCommands(); - } - // Show results if we have a search term OR if scoped to mine (wildcard search) - const hasActiveSearch = searchTerm || scopeToMine; - - if (hasActiveSearch && groupedResults) { - let globalIndex = 0; - return ( -
- {Object.entries(groupedResults).map(([type, results]) => { - const config = RESULT_TYPE_CONFIG[type] || { label: type }; - return ( -
- - {config.label}s - - renderResultItem(item, globalIndex++)} - split={false} - /> -
- ); - })} -
- -
-
- ); - } - - if (hasActiveSearch && searchResults?.length === 0) { - return ( -
- - - No results found - - {scopeToMine && !searchTerm - ? "No items found for this mine" - : activeFilters.length > 0 - ? "Try removing some filters or adjusting your search" - : "Try adjusting your search or browse all results"} - - {searchTerm && ( - - )} - -
- ); - } - - if (!hasActiveSearch && recentSearches.length > 0) { - return ( -
- - - - Recent Searches - - - ( - handleRecentSearchClick(term)} - onMouseEnter={() => setSelectedIndex(index)} - extra={ - removeRecentSearch(term, e)} - style={{ color: "#bfbfbf", cursor: "pointer", padding: 4 }} - /> - } - > - } - title={term} - /> - - )} - split={false} - /> -
- ); - } - - return ( - - - Quick Actions - - - {[ - { icon: , label: "Browse Mines", color: "#2e7d32", route: router.MINE_HOME_PAGE.dynamicRoute({ page: "1", per_page: "25" }) }, - { icon: , label: "Browse Contacts", color: "#1565c0", route: router.CONTACT_HOME_PAGE.dynamicRoute({ page: "1", per_page: "25" }) }, - { icon: , label: "Reports", color: "#7b1fa2", route: router.REPORTING_DASHBOARD.route }, - ].map((action) => ( -
- - - ))} - - - ); - }; - - return ( - <> - - - - - select - ↑↓ navigate - / commands - esc close - - - } - closable={false} - maskClosable - keyboard - className="global-search-modal" - width={580} - style={{ top: 80 }} - destroyOnClose - > - - - {quickFilter && ( - { e.preventDefault(); setQuickFilter(null); }} - style={{ margin: 0, marginLeft: 4 }} - > - {TYPE_CONFIG[quickFilter]?.icon} - {TYPE_CONFIG[quickFilter]?.label || quickFilter} - - )} - - } - placeholder={quickFilter ? "Search within filter..." : "Search for mines, contacts, permits... (type / for commands)"} - value={commandMode ? `/${commandInput}` : searchTerm} - onChange={handleSearchChange} - onKeyDown={handleKeyDown} - bordered={false} - allowClear - size="large" - style={{ borderBottom: "1px solid #f0f0f0", borderRadius: 0 }} - /> - {renderFilters()} - {renderResults()} - - - ); -}; - -export default GlobalSearch; diff --git a/services/core-web/src/components/search/GlobalSearch/components/EmptySearchState.tsx b/services/core-web/src/components/search/GlobalSearch/components/EmptySearchState.tsx new file mode 100644 index 0000000000..752be53a4c --- /dev/null +++ b/services/core-web/src/components/search/GlobalSearch/components/EmptySearchState.tsx @@ -0,0 +1,103 @@ +import React from "react"; +import { Space, Typography, Button, Row, Col, Avatar } from "antd"; +import { + SearchOutlined, + EnvironmentOutlined, + TeamOutlined, + FileSearchOutlined, +} from "@ant-design/icons"; + +const { Text, Title } = Typography; + +interface EmptySearchStateProps { + hasSearchTerm: boolean; + scopeToMine: boolean; + activeFiltersCount: number; + searchTerm?: string; + onViewAll?: () => void; + onQuickAction?: (route: string) => void; + quickActions?: Array<{ icon: React.ReactNode; label: string; color: string; route: string }>; +} + +export const EmptySearchState: React.FC = ({ + hasSearchTerm, + scopeToMine, + activeFiltersCount, + searchTerm, + onViewAll, + onQuickAction, + quickActions, +}) => { + if (hasSearchTerm) { + return ( +
+ + + No results found + + {scopeToMine && !searchTerm + ? "No items found for this mine" + : activeFiltersCount > 0 + ? "Try removing some filters or adjusting your search" + : "Try adjusting your search or browse all results"} + + {searchTerm && onViewAll && ( + + )} + +
+ ); + } + + // Default state with quick actions + const defaultQuickActions = quickActions || [ + { + icon: , + label: "Browse Mines", + color: "#2e7d32", + route: "/mine-home-page", + }, + { + icon: , + label: "Browse Contacts", + color: "#1565c0", + route: "/contact-home-page", + }, + { + icon: , + label: "Reports", + color: "#7b1fa2", + route: "/reports", + }, + ]; + + return ( + + + Quick Actions + + + {defaultQuickActions.map((action) => ( +
+ + + ))} + + + ); +}; diff --git a/services/core-web/src/components/search/GlobalSearch/components/RecentSearches.tsx b/services/core-web/src/components/search/GlobalSearch/components/RecentSearches.tsx new file mode 100644 index 0000000000..840cf7a896 --- /dev/null +++ b/services/core-web/src/components/search/GlobalSearch/components/RecentSearches.tsx @@ -0,0 +1,49 @@ +import React from "react"; +import { List, Space, Divider } from "antd"; +import { HistoryOutlined, ClockCircleOutlined, DeleteOutlined } from "@ant-design/icons"; + +interface RecentSearchesProps { + recentSearches: string[]; + selectedIndex: number; + onSearchClick: (term: string) => void; + onRemoveSearch: (term: string, e: React.MouseEvent) => void; + onSetSelectedIndex: (index: number) => void; +} + +export const RecentSearches: React.FC = ({ + recentSearches, + selectedIndex, + onSearchClick, + onRemoveSearch, + onSetSelectedIndex, +}) => { + return ( +
+ + + + Recent Searches + + + ( + onSearchClick(term)} + onMouseEnter={() => onSetSelectedIndex(index)} + extra={ + onRemoveSearch(term, e)} + style={{ color: "#bfbfbf", cursor: "pointer", padding: 4 }} + /> + } + > + } title={term} /> + + )} + split={false} + /> +
+ ); +}; diff --git a/services/core-web/src/components/search/GlobalSearch/components/SearchFilters.tsx b/services/core-web/src/components/search/GlobalSearch/components/SearchFilters.tsx new file mode 100644 index 0000000000..4ede3731af --- /dev/null +++ b/services/core-web/src/components/search/GlobalSearch/components/SearchFilters.tsx @@ -0,0 +1,86 @@ +import React from "react"; +import { Tag, Space, Divider } from "antd"; +import { AimOutlined } from "@ant-design/icons"; +import { SEARCH_TYPE_CONFIG } from "../utils/searchConfig"; + +interface SearchFiltersProps { + activeFilters: string[]; + onToggleFilter: (filterKey: string) => void; + facets: Record; + isOnMinePage: boolean; + scopeToMine: boolean; + onToggleScopeToMine: (checked: boolean) => void; + searchTerm: string; +} + +export const SearchFilters: React.FC = ({ + activeFilters, + onToggleFilter, + facets, + isOnMinePage, + scopeToMine, + onToggleScopeToMine, + searchTerm, +}) => { + const getFacetCount = (filterKey: string): number => { + if (filterKey === "mine") return facets.mine ?? 0; + if (filterKey === "contact") return facets.person ?? 0; + if (filterKey === "organization") return facets.organization ?? 0; + if (filterKey === "permit") return facets.permit ?? 0; + if (filterKey === "explosives_permit") return facets.explosives_permit ?? 0; + if (filterKey === "now_application") return facets.now_application ?? 0; + if (filterKey === "nod") return facets.nod ?? 0; + if (filterKey === "document") return (facets.mine_documents ?? 0) + (facets.permit_documents ?? 0); + return 0; + }; + + return ( +
+ + {isOnMinePage && ( + onToggleScopeToMine(!scopeToMine)} + style={{ + cursor: "pointer", + backgroundColor: scopeToMine ? "#5e46a115" : "transparent", + borderColor: scopeToMine ? "#5e46a1" : "#d9d9d9", + color: scopeToMine ? "#5e46a1" : "#595959", + margin: 0, + fontWeight: scopeToMine ? 600 : 400, + }} + > + + + This Mine + + + )} + {isOnMinePage && } + {Object.entries(SEARCH_TYPE_CONFIG).map(([key, config]) => { + const isActive = activeFilters.includes(key); + const count = getFacetCount(key); + + return ( + onToggleFilter(key)} + style={{ + cursor: "pointer", + backgroundColor: isActive ? `${config.color}15` : "transparent", + borderColor: isActive ? config.color : "#d9d9d9", + color: isActive ? config.color : "#595959", + margin: 0, + }} + > + + {config.icon} + {config.pluralLabel} + {searchTerm && ({count})} + + + ); + })} + +
+ ); +}; diff --git a/services/core-web/src/components/search/GlobalSearch/components/SearchResultItem.tsx b/services/core-web/src/components/search/GlobalSearch/components/SearchResultItem.tsx new file mode 100644 index 0000000000..77e939df6c --- /dev/null +++ b/services/core-web/src/components/search/GlobalSearch/components/SearchResultItem.tsx @@ -0,0 +1,61 @@ +import React from "react"; +import { List, Avatar, Typography } from "antd"; +import { EnterOutlined } from "@ant-design/icons"; +import { ISearchResult, ISimpleSearchResult } from "@mds/common/interfaces"; +import { SEARCH_TYPE_CONFIG, RESULT_TYPE_MAP } from "../utils/searchConfig"; +import { highlightMatch } from "../utils/searchHelpers"; + +const { Text } = Typography; + +interface SearchResultItemProps { + item: ISearchResult; + index: number; + selectedIndex: number; + searchTerm: string; + onClick: (item: ISearchResult) => void; + onMouseEnter: (index: number) => void; +} + +export const SearchResultItem: React.FC = ({ + item, + index, + selectedIndex, + searchTerm, + onClick, + onMouseEnter, +}) => { + const configKey = RESULT_TYPE_MAP[item.type] || "document"; + const config = SEARCH_TYPE_CONFIG[configKey]; + const isSelected = index === selectedIndex; + + return ( + onClick(item)} + onMouseEnter={() => onMouseEnter(index)} + > + + } + title={{highlightMatch(item.result.value, searchTerm)}} + description={ + + {config.label} + {item.result.description && • {item.result.description}} + {item.result.highlight && ( + + )} + + } + /> + {isSelected && } + + ); +}; diff --git a/services/core-web/src/components/search/GlobalSearch/components/SearchTriggerButton.tsx b/services/core-web/src/components/search/GlobalSearch/components/SearchTriggerButton.tsx new file mode 100644 index 0000000000..a59c62e403 --- /dev/null +++ b/services/core-web/src/components/search/GlobalSearch/components/SearchTriggerButton.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import { Button, Typography } from "antd"; +import { SearchOutlined } from "@ant-design/icons"; + +const { Text } = Typography; + +interface SearchTriggerButtonProps { + onClick: () => void; + placeholder?: string; +} + +export const SearchTriggerButton: React.FC = ({ + onClick, + placeholder = "Search Core..." +}) => { + return ( + + ); +}; diff --git a/services/core-web/src/components/search/GlobalSearch/hooks/useRecentSearches.ts b/services/core-web/src/components/search/GlobalSearch/hooks/useRecentSearches.ts new file mode 100644 index 0000000000..2a5dfc3a06 --- /dev/null +++ b/services/core-web/src/components/search/GlobalSearch/hooks/useRecentSearches.ts @@ -0,0 +1,32 @@ +import { useState, useEffect } from "react"; +import { RECENT_SEARCHES_KEY, MAX_RECENT_SEARCHES } from "../utils/searchConfig"; + +export const useRecentSearches = () => { + const [recentSearches, setRecentSearches] = useState([]); + + useEffect(() => { + const stored = localStorage.getItem(RECENT_SEARCHES_KEY); + if (stored) { + try { + setRecentSearches(JSON.parse(stored)); + } catch { + setRecentSearches([]); + } + } + }, []); + + const saveRecentSearch = (term: string) => { + if (!term.trim()) return; + const updated = [term, ...recentSearches.filter((s) => s !== term)].slice(0, MAX_RECENT_SEARCHES); + setRecentSearches(updated); + localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated)); + }; + + const removeRecentSearch = (term: string) => { + const updated = recentSearches.filter((s) => s !== term); + setRecentSearches(updated); + localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated)); + }; + + return { recentSearches, saveRecentSearch, removeRecentSearch }; +}; diff --git a/services/core-web/src/components/search/GlobalSearch/index.tsx b/services/core-web/src/components/search/GlobalSearch/index.tsx new file mode 100644 index 0000000000..a77642168d --- /dev/null +++ b/services/core-web/src/components/search/GlobalSearch/index.tsx @@ -0,0 +1,398 @@ +import React, { useState, useEffect, useRef, useMemo, useCallback } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { useHistory, useLocation } from "react-router-dom"; +import { Modal, Input, Typography, Button, List, Space, Row, Divider, Tag } from "antd"; +import { SearchOutlined } from "@ant-design/icons"; +import { fetchSearchBarResults } from "@mds/common/redux/actionCreators/searchActionCreator"; +import { getSearchBarResults, getSearchBarFacets } from "@mds/common/redux/reducers/searchReducer"; +import * as router from "@/constants/routes"; +import { ISearchResult, ISimpleSearchResult } from "@mds/common/interfaces"; +import { SearchTriggerButton } from "./components/SearchTriggerButton"; +import { SearchFilters } from "./components/SearchFilters"; +import { SearchResultItem } from "./components/SearchResultItem"; +import { RecentSearches } from "./components/RecentSearches"; +import { EmptySearchState } from "./components/EmptySearchState"; +import { useRecentSearches } from "./hooks/useRecentSearches"; +import { SEARCH_TYPE_CONFIG, RESULT_TYPE_MAP } from "./utils/searchConfig"; +import { getSearchTypes, extractMineGuidFromPath } from "./utils/searchHelpers"; + +const { Text } = Typography; + +interface GlobalSearchProps { + placeholder?: string; + size?: "small" | "middle" | "large"; + enableShortcut?: boolean; +} + +const GlobalSearch: React.FC = ({ + placeholder = "Search Core...", + enableShortcut = true +}) => { + const [isModalVisible, setIsModalVisible] = useState(false); + const [searchTerm, setSearchTerm] = useState(""); + const [selectedIndex, setSelectedIndex] = useState(0); + const [activeFilters, setActiveFilters] = useState([]); + const [scopeToMine, setScopeToMine] = useState(false); + const [quickFilter, setQuickFilter] = useState(null); + + const dispatch = useDispatch(); + const searchResults = useSelector(getSearchBarResults); + const facets = useSelector(getSearchBarFacets); + const history = useHistory(); + const location = useLocation(); + const inputRef = useRef(null); + + const currentMineGuid = useMemo(() => extractMineGuidFromPath(location.pathname), [location.pathname]); + const isOnMinePage = !!currentMineGuid; + + const { recentSearches, saveRecentSearch, removeRecentSearch } = useRecentSearches(); + + const handleSearch = useCallback( + (term: string, filters: string[], mineGuid: string | null) => { + if (term.length > 0) { + dispatch(fetchSearchBarResults(term, getSearchTypes(filters, quickFilter), mineGuid)); + } + }, + [dispatch, quickFilter] + ); + + const handleOpen = () => { + setIsModalVisible(true); + setTimeout(() => inputRef.current?.focus(), 50); + }; + + const handleClose = useCallback(() => { + setIsModalVisible(false); + setSearchTerm(""); + setSelectedIndex(0); + setActiveFilters([]); + setScopeToMine(false); + setQuickFilter(null); + }, []); + + useEffect(() => { + if (!enableShortcut) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "k") { + e.preventDefault(); + const isAnyModalOpen = document.querySelector(".global-search-modal"); + + if (isModalVisible) { + handleClose(); + } else if (!isAnyModalOpen) { + handleOpen(); + } + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isModalVisible, handleClose, enableShortcut]); + + const getMineGuidForSearch = () => (scopeToMine && currentMineGuid ? currentMineGuid : null); + + const handleSearchChange = (e: React.ChangeEvent) => { + const value = e.target.value; + + setSearchTerm(value); + setSelectedIndex(0); + if (value.length > 0) { + handleSearch(value, activeFilters, getMineGuidForSearch()); + } + }; + + const toggleFilter = (filterKey: string) => { + const newFilters = activeFilters.includes(filterKey) + ? activeFilters.filter((f) => f !== filterKey) + : [...activeFilters, filterKey]; + setActiveFilters(newFilters); + setSelectedIndex(0); + if (searchTerm.length > 0) { + handleSearch(searchTerm, newFilters, getMineGuidForSearch()); + } + }; + + const toggleScopeToMine = (checked: boolean) => { + setScopeToMine(checked); + const mineGuid = checked && currentMineGuid ? currentMineGuid : null; + const term = searchTerm || "*"; + handleSearch(term, activeFilters, mineGuid); + }; + + const navigateToResult = (item: ISearchResult) => { + saveRecentSearch(item.result.value); + let routeUrl = ""; + switch (item.type) { + case "mine": + routeUrl = router.MINE_GENERAL.dynamicRoute(item.result.id); + break; + case "person": + case "organization": + case "party": + routeUrl = router.PARTY_PROFILE.dynamicRoute(item.result.id); + break; + case "now_application": + routeUrl = router.NOTICE_OF_WORK_APPLICATION.dynamicRoute(item.result.id, "verification"); + break; + case "permit": + routeUrl = router.VIEW_MINE_PERMIT.dynamicRoute(item.result.mine_guid, item.result.id); + break; + case "explosives_permit": + routeUrl = router.MINE_PERMITS.dynamicRoute(item.result.mine_guid); + break; + case "nod": + routeUrl = router.NOTICE_OF_DEPARTURE.dynamicRoute(item.result.mine_guid, item.result.id); + break; + } + if (routeUrl) { + handleClose(); + history.push(routeUrl); + } + }; + + const handleEnter = () => { + if (searchResults?.length > 0) { + navigateToResult(searchResults[selectedIndex]); + } else if (searchTerm.length > 0) { + saveRecentSearch(searchTerm); + handleClose(); + history.push(router.SEARCH_RESULTS.dynamicRoute({ q: searchTerm })); + } + }; + + const handleRecentSearchClick = (term: string) => { + setSearchTerm(term); + handleSearch(term, activeFilters, getMineGuidForSearch()); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + const totalItems = searchResults?.length || recentSearches.length || 0; + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + setSelectedIndex((prev) => (prev + 1) % (totalItems || 1)); + break; + case "ArrowUp": + e.preventDefault(); + setSelectedIndex((prev) => (prev - 1 + (totalItems || 1)) % (totalItems || 1)); + break; + case "Enter": + e.preventDefault(); + if (!searchTerm && recentSearches.length > 0) { + handleRecentSearchClick(recentSearches[selectedIndex]); + } else { + handleEnter(); + } + break; + case "Escape": + handleClose(); + break; + case "Backspace": + if (searchTerm === "" && quickFilter) { + e.preventDefault(); + setQuickFilter(null); + } + break; + } + }; + + const groupedResults = useMemo(() => { + if (!searchResults?.length) return null; + const groups: Record[]> = {}; + searchResults.forEach((result) => { + if (!groups[result.type]) groups[result.type] = []; + groups[result.type].push(result); + }); + return groups; + }, [searchResults]); + + const handleViewAll = () => { + saveRecentSearch(searchTerm); + handleClose(); + history.push(router.SEARCH_RESULTS.dynamicRoute({ q: searchTerm })); + }; + + const handleQuickAction = (route: string) => { + handleClose(); + history.push(route); + }; + + const renderResults = () => { + const hasActiveSearch = searchTerm || scopeToMine; + + if (hasActiveSearch && groupedResults) { + let globalIndex = 0; + return ( +
+ {Object.entries(groupedResults).map(([type, results]) => { + const configKey = RESULT_TYPE_MAP[type] || "document"; + const config = SEARCH_TYPE_CONFIG[configKey]; + return ( +
+ + {config.pluralLabel} + + ( + + )} + split={false} + /> +
+ ); + })} +
+ +
+
+ ); + } + + if (hasActiveSearch && searchResults?.length === 0) { + return ( + + ); + } + + if (!hasActiveSearch && recentSearches.length > 0) { + return ( + { + e.stopPropagation(); + removeRecentSearch(term); + }} + onSetSelectedIndex={setSelectedIndex} + /> + ); + } + + return ( + , + label: "Browse Mines", + color: "#2e7d32", + route: router.MINE_HOME_PAGE.dynamicRoute({ page: "1", per_page: "25" }), + }, + { + icon: , + label: "Browse Contacts", + color: "#1565c0", + route: router.CONTACT_HOME_PAGE.dynamicRoute({ page: "1", per_page: "25" }), + }, + { + icon: , + label: "Reports", + color: "#7b1fa2", + route: router.REPORTING_DASHBOARD.route, + }, + ]} + /> + ); + }; + + return ( + <> + + + + + + select + + + ↑↓ navigate + + + esc close + + + + } + closable={false} + maskClosable + keyboard + className="global-search-modal" + width={580} + style={{ top: 80 }} + destroyOnClose + > + + + {quickFilter && ( + { + e.preventDefault(); + setQuickFilter(null); + }} + style={{ margin: 0, marginLeft: 4 }} + > + {SEARCH_TYPE_CONFIG[quickFilter]?.icon} + {SEARCH_TYPE_CONFIG[quickFilter]?.pluralLabel || quickFilter} + + )} + + } + placeholder={ + quickFilter + ? "Search within filter..." + : "Search for mines, contacts, permits..." + } + value={searchTerm} + onChange={handleSearchChange} + onKeyDown={handleKeyDown} + bordered={false} + allowClear + size="large" + style={{ borderBottom: "1px solid #f0f0f0", borderRadius: 0 }} + /> + + {renderResults()} + + + ); +}; + +export default GlobalSearch; diff --git a/services/core-web/src/components/search/GlobalSearch/utils/searchConfig.tsx b/services/core-web/src/components/search/GlobalSearch/utils/searchConfig.tsx new file mode 100644 index 0000000000..456cde9a36 --- /dev/null +++ b/services/core-web/src/components/search/GlobalSearch/utils/searchConfig.tsx @@ -0,0 +1,167 @@ +import React from "react"; +import { + EnvironmentOutlined, + UserOutlined, + BankOutlined, + FileProtectOutlined, + AlertOutlined, + FileSearchOutlined, + ExceptionOutlined, + AimOutlined, + DeleteOutlined, +} from "@ant-design/icons"; + +export interface SearchTypeConfig { + icon: React.ReactNode; + label: string; + pluralLabel: string; + color: string; + types: string[]; +} + +export const SEARCH_TYPE_CONFIG: Record = { + mine: { + icon: , + label: "Mine", + pluralLabel: "Mines", + color: "#2e7d32", + types: ["mine"], + }, + contact: { + icon: , + label: "Person", + pluralLabel: "People", + color: "#1565c0", + types: ["person", "party"], + }, + organization: { + icon: , + label: "Organization", + pluralLabel: "Organizations", + color: "#f57c00", + types: ["organization"], + }, + permit: { + icon: , + label: "Permit", + pluralLabel: "Permits", + color: "#e65100", + types: ["permit"], + }, + explosives_permit: { + icon: , + label: "Explosives Permit", + pluralLabel: "Explosives", + color: "#d32f2f", + types: ["explosives_permit"], + }, + now_application: { + icon: , + label: "Notice of Work", + pluralLabel: "NoW", + color: "#0288d1", + types: ["now_application"], + }, + nod: { + icon: , + label: "NOD", + pluralLabel: "NODs", + color: "#7b1fa2", + types: ["nod", "notice_of_departure"], + }, + document: { + icon: , + label: "Document", + pluralLabel: "Documents", + color: "#455a64", + types: ["mine_documents", "permit_documents"], + }, +}; + +// Mapping for individual result types (more specific than filter types) +export const RESULT_TYPE_MAP: Record = { + mine: "mine", + person: "contact", + party: "contact", + organization: "organization", + permit: "permit", + explosives_permit: "explosives_permit", + now_application: "now_application", + nod: "nod", + notice_of_departure: "nod", + mine_documents: "document", + permit_documents: "document", +}; + +export interface CommandConfig { + action: string; + description: string; + aliases: string[]; + icon?: React.ReactNode; +} + +export const COMMAND_CONFIG: Record = { + mine: { + action: "filter:mine", + description: "Toggle Mines filter", + aliases: ["mines", "m"], + icon: , + }, + contact: { + action: "filter:contact", + description: "Toggle People filter", + aliases: ["contacts", "people", "person", "p"], + icon: , + }, + organization: { + action: "filter:organization", + description: "Toggle Organizations filter", + aliases: ["organizations", "orgs", "org", "o"], + icon: , + }, + permit: { + action: "filter:permit", + description: "Toggle Permits filter", + aliases: ["permits"], + icon: , + }, + explosives: { + action: "filter:explosives_permit", + description: "Toggle Explosives filter", + aliases: ["explosives_permit", "exp", "e"], + icon: , + }, + now: { + action: "filter:now_application", + description: "Toggle Notice of Work filter", + aliases: ["now_application", "notice", "work"], + icon: , + }, + nod: { + action: "filter:nod", + description: "Toggle NODs filter", + aliases: ["nods", "n"], + icon: , + }, + document: { + action: "filter:document", + description: "Toggle Documents filter", + aliases: ["documents", "docs", "doc", "d"], + icon: , + }, + here: { + action: "scope:mine", + description: "Toggle scope to current mine", + aliases: ["this", "scope"], + icon: , + }, + clear: { + action: "clear:filters", + description: "Clear all filters", + aliases: ["reset", "c"], + icon: , + }, +}; + +export const RECENT_SEARCHES_KEY = "mds_recent_searches"; +export const MAX_RECENT_SEARCHES = 5; diff --git a/services/core-web/src/components/search/GlobalSearch/utils/searchHelpers.tsx b/services/core-web/src/components/search/GlobalSearch/utils/searchHelpers.tsx new file mode 100644 index 0000000000..a41d63128b --- /dev/null +++ b/services/core-web/src/components/search/GlobalSearch/utils/searchHelpers.tsx @@ -0,0 +1,21 @@ +import React from "react"; +import { SEARCH_TYPE_CONFIG } from "./searchConfig"; + +export const getSearchTypes = (filters: string[], includeQuickFilter?: string | null): string[] | null => { + const allFilters = includeQuickFilter ? [...filters, includeQuickFilter] : filters; + const uniqueFilters = [...new Set(allFilters)]; + if (uniqueFilters.length === 0) return null; + return uniqueFilters.flatMap((f) => SEARCH_TYPE_CONFIG[f]?.types || []); +}; + +export const highlightMatch = (text: string, search: string): React.ReactNode => { + if (!search || !text) return text; + const regex = new RegExp(`(${search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "gi"); + const parts = text.split(regex); + return parts.map((part, i) => (regex.test(part) ? {part} : part)); +}; + +export const extractMineGuidFromPath = (pathname: string): string | null => { + const match = pathname.match(/\/mine-dashboard\/([a-f0-9-]+)/i); + return match ? match[1] : null; +}; diff --git a/services/core-web/src/components/search/SearchResultsV2.tsx b/services/core-web/src/components/search/SearchResultsV2.tsx index c2042c8193..f409773817 100644 --- a/services/core-web/src/components/search/SearchResultsV2.tsx +++ b/services/core-web/src/components/search/SearchResultsV2.tsx @@ -45,7 +45,6 @@ import { ContactResultsTable } from "./ContactResultsTable"; import { DocumentResultsTable } from "./DocumentResultsTable"; import { GenericResultsTable } from "./GenericResultsTable"; import { PageTracker } from "@common/utils/trackers"; -import "@/styles/components/SearchResults.scss"; const { Text } = Typography; const { Panel } = Collapse; @@ -149,7 +148,6 @@ interface SearchFacets { } interface SearchResultsProps { - location: { search: string }; fetchSearchOptions: () => any; fetchSearchResults: (query: string, tab?: string, filters?: Record) => any; searchOptions: any[]; @@ -191,9 +189,9 @@ export const SearchResults: React.FC = (props) => { // Map tab keys to backend search types and apply automatic filters - const mapTabToSearchType = (tabKey: string | undefined, currentFilters: Record): { types: string | undefined; filters: Record } => { + const mapTabToSearchType = useCallback((tabKey: string | undefined, currentFilters: Record): { types: string | undefined; filters: Record } => { if (!tabKey || tabKey === "all") return { types: undefined, filters: currentFilters }; - + const tabToTypeMap: Record = { "mine": "mine", "people": "party", @@ -204,21 +202,21 @@ export const SearchResults: React.FC = (props) => { "notice_of_departure": "notice_of_departure", "document": "mine_documents,permit_documents", }; - + const newFilters = { ...currentFilters }; - + // Add automatic party_type filter for people/organization tabs if (tabKey === "people") { newFilters.party_type = ["Person"]; } else if (tabKey === "organization") { newFilters.party_type = ["Organization"]; } - - return { + + return { types: tabToTypeMap[tabKey], filters: newFilters }; - }; + }, []); // Trigger search with current filters const triggerSearch = useCallback((searchTerm: string, searchTypes?: string, filters?: Record) => { @@ -227,7 +225,7 @@ export const SearchResults: React.FC = (props) => { const { types, filters: enhancedFilters } = mapTabToSearchType(searchTypes, filters || {}); const apiFilters = getFiltersForApi(enhancedFilters); props.fetchSearchResults(searchTerm, types, apiFilters); - }, [props.fetchSearchResults, getFiltersForApi]); + }, [props.fetchSearchResults, getFiltersForApi, mapTabToSearchType]); const handleSearch = useCallback((searchParams: string, resetFilters = true) => { const parsedParams = queryString.parse(searchParams); @@ -258,12 +256,20 @@ export const SearchResults: React.FC = (props) => { if (!props.searchOptions.length) { props.fetchSearchOptions(); } - handleSearch(props.location.search); - }, []); + }, [props.searchOptions.length, props.fetchSearchOptions]); useEffect(() => { - handleSearch(props.location.search); - }, [props.location.search]); + const parsedParams = queryString.parse(location.search); + const { q, t } = parsedParams; + if (q) { + setParams({ q: q as string, t: t as string }); + setSearchInputValue(q as string); + setIsSearching(true); + const { types, filters: enhancedFilters } = mapTabToSearchType(t as string, {}); + const apiFilters = getFiltersForApi(enhancedFilters); + props.fetchSearchResults(q as string, types, apiFilters); + } + }, [location.search, props.fetchSearchResults, mapTabToSearchType, getFiltersForApi]); useEffect(() => { if (props.searchResults) { @@ -275,7 +281,7 @@ export const SearchResults: React.FC = (props) => { const handleFilterChange = (category: string, value: string, checked: boolean) => { const newFilters = { ...selectedFilters }; const current = newFilters[category] || []; - + if (checked) { newFilters[category] = [...current, value]; } else { @@ -286,9 +292,9 @@ export const SearchResults: React.FC = (props) => { newFilters[category] = updated; } } - + setSelectedFilters(newFilters); - + // Trigger server-side search with new filters if (params.q) { triggerSearch(params.q, params.t, newFilters); @@ -305,31 +311,31 @@ export const SearchResults: React.FC = (props) => { const hasActiveFilters = Object.keys(selectedFilters).length > 0; // Get results directly from API (server-side filtered) - const mines = props.searchResults.mine || []; - const parties = props.searchResults.party || []; - const permits = props.searchResults.permit || []; - const mineDocuments = props.searchResults.mine_documents || []; - const permitDocuments = props.searchResults.permit_documents || []; - const explosivesPermits = props.searchResults.explosives_permit || []; - const nowApplications = props.searchResults.now_application || []; - const nods = props.searchResults.notice_of_departure || []; + const mines = props.searchResults?.mine || []; + const parties = props.searchResults?.party || []; + const permits = props.searchResults?.permit || []; + const mineDocuments = props.searchResults?.mine_documents || []; + const permitDocuments = props.searchResults?.permit_documents || []; + const explosivesPermits = props.searchResults?.explosives_permit || []; + const nowApplications = props.searchResults?.now_application || []; + const nods = props.searchResults?.notice_of_departure || []; // Transform results to format expected by table components - const mineResults = mines.map((item: any) => item.result); - const partyResults = parties.map((item: any) => item.result); - + const mineResults = mines.map((item: any) => item.result).filter(Boolean); + const partyResults = parties.map((item: any) => item.result).filter(Boolean); + // Separate people and organizations - const peopleResults = partyResults.filter((p: any) => p.party_type_code === "PER"); - const organizationResults = partyResults.filter((p: any) => p.party_type_code === "ORG"); - - const permitResults = permits.map((item: any) => item.result); - const documentResults = [...mineDocuments, ...permitDocuments].map((item: any) => item.result); - const explosivesPermitResults = explosivesPermits.map((item: any) => item.result); - const nowApplicationResults = nowApplications.map((item: any) => item.result); - const nodResults = nods.map((item: any) => item.result); - - const totalResults = mines.length + parties.length + permits.length + mineDocuments.length + permitDocuments.length + - explosivesPermits.length + nowApplications.length + nods.length; + const peopleResults = partyResults.filter((p: any) => p?.party_type_code === "PER"); + const organizationResults = partyResults.filter((p: any) => p?.party_type_code === "ORG"); + + const permitResults = permits.map((item: any) => item.result).filter(Boolean); + const documentResults = [...mineDocuments, ...permitDocuments].map((item: any) => item.result).filter(Boolean); + const explosivesPermitResults = explosivesPermits.map((item: any) => item.result).filter(Boolean); + const nowApplicationResults = nowApplications.map((item: any) => item.result).filter(Boolean); + const nodResults = nods.map((item: any) => item.result).filter(Boolean); + + const totalResults = mines.length + parties.length + permits.length + mineDocuments.length + permitDocuments.length + + explosivesPermits.length + nowApplications.length + nods.length; // Build grouped facets from API facets const groupedFacets = useMemo(() => { @@ -386,13 +392,13 @@ export const SearchResults: React.FC = (props) => { {groupedFacets.length > 0 ? ( g.key)}> {groupedFacets.map((group) => ( - {group.icon} {group.label} - } + } key={group.key} > {group.facets.map((facet) => ( @@ -703,10 +709,10 @@ export const SearchResults: React.FC = (props) => { )} - .anticon { + > .anticon { font-size: 14px; } @@ -64,13 +66,12 @@ } } -// Modal Overlay +// Modal .global-search-modal { .ant-modal-content { border-radius: 12px; overflow: hidden; - box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25), - 0 0 0 1px rgba(0, 0, 0, 0.05); + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(0, 0, 0, 0.05); padding: 0; } @@ -85,7 +86,6 @@ margin: 0; } - // Input styling .ant-input-affix-wrapper { padding: 12px 16px; @@ -94,7 +94,6 @@ } } - // Divider as section header .ant-divider { margin: 0; padding: 10px 20px 6px; @@ -110,7 +109,6 @@ } } - // List styling .ant-list-item { padding: 10px 20px !important; border-left: 3px solid transparent; @@ -123,158 +121,49 @@ background: linear-gradient(90deg, rgba($violet, 0.06) 0%, rgba($violet, 0.01) 100%); } - .ant-list-item-meta { - align-items: center; - - .ant-list-item-meta-avatar { - margin-right: 12px; - - .ant-avatar { - width: 36px; - height: 36px; - border-radius: 8px; - display: flex; - align-items: center; - justify-content: center; - font-size: 16px; - } - } - - .ant-list-item-meta-content { - .ant-list-item-meta-title { - margin-bottom: 2px; - font-size: 14px; - line-height: 1.4; - - mark { - background: rgba($violet, 0.2); - color: inherit; - padding: 0 2px; - border-radius: 2px; - } - } - - .ant-list-item-meta-description { - font-size: 12px; - } - } - } - } - - // Selected state - .global-search__result-item--selected { - background: linear-gradient(90deg, rgba($violet, 0.1) 0%, rgba($violet, 0.03) 100%) !important; - border-left-color: $violet; - } - - // Quick actions - .ant-btn-text { - &:hover { - background: #fafafa; - } - - .ant-avatar { - margin-bottom: 4px; - } - } - - // Recent searches icon alignment - .global-search__recent { - .ant-list-item-meta-avatar { - display: flex; - align-items: center; - justify-content: center; + .ant-list-item-meta-avatar .ant-avatar { width: 36px; + height: 36px; + border-radius: 8px; font-size: 16px; } - } - - // Empty state - .global-search__empty { - .ant-typography { - margin: 0; - } - } - // Filter tags - .ant-tag { - display: inline-flex; - align-items: center; - padding: 4px 10px; - border-radius: 16px; - font-size: 12px; - transition: all 0.15s ease; + .ant-list-item-meta-title { + font-size: 14px; - &:hover { - opacity: 0.85; + mark { + background: rgba($violet, 0.2); + color: inherit; + padding: 0 2px; + border-radius: 2px; + } } - .anticon { + .ant-list-item-meta-description { font-size: 12px; } } -} - -// Search Input Container -.global-search__input-container { - padding: 16px 20px; - border-bottom: 1px solid #f0f0f0; - display: flex; - align-items: center; - gap: 12px; - - .search-icon { - font-size: 20px; - color: $violet; - flex-shrink: 0; - } - - .ant-input { - border: none; - font-size: 16px; - padding: 0; - background: transparent; - - &:focus { - box-shadow: none; - } - - &::placeholder { - color: #bfbfbf; - } - } - - .search-spinner { - color: $violet; - animation: spin 1s linear infinite; + .ant-btn-text:hover { + background: #fafafa; } } -@keyframes spin { - from { - transform: rotate(0deg); - } - - to { - transform: rotate(360deg); - } +// Result Items +.global-search__result-item--selected { + background: linear-gradient(90deg, rgba($violet, 0.1) 0%, rgba($violet, 0.03) 100%) !important; + border-left-color: $violet; } // Results Container .global-search__results { max-height: 400px; overflow-y: auto; - overflow-x: hidden; &::-webkit-scrollbar { width: 6px; } - &::-webkit-scrollbar-track { - background: transparent; - } - &::-webkit-scrollbar-thumb { background: #d9d9d9; border-radius: 3px; @@ -285,409 +174,6 @@ } } -// Section Headers -.global-search__section { - &-header { - padding: 12px 20px 8px; - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; - color: #8c8c8c; - background: #fafafa; - border-bottom: 1px solid #f0f0f0; - } -} - -// Result Items -.global-search__result-item { - display: flex; - align-items: center; - padding: 12px 20px; - cursor: pointer; - transition: all 0.15s ease; - border-left: 3px solid transparent; - - &:hover, - &--selected { - background: linear-gradient(90deg, rgba($violet, 0.08) 0%, rgba($violet, 0.02) 100%); - border-left-color: $violet; - } - - &--selected { - background: linear-gradient(90deg, rgba($violet, 0.12) 0%, rgba($violet, 0.04) 100%); - } - - // Search result highlights - mark { - background-color: rgba($violet, 0.2); - color: inherit; - padding: 1px 3px; - border-radius: 3px; - font-weight: 500; - } - - .result-icon { - width: 36px; - height: 36px; - border-radius: 8px; - display: flex; - align-items: center; - justify-content: center; - margin-right: 14px; - flex-shrink: 0; - font-size: 16px; - - &--mine { - background: linear-gradient(135deg, #e8f5e9 0%, #c8e6c9 100%); - color: #2e7d32; - } - - &--person { - background: linear-gradient(135deg, #e3f2fd 0%, #bbdefb 100%); - color: #1565c0; - } - - &--organization { - background: linear-gradient(135deg, #fff8e1 0%, #ffecb3 100%); - color: #f57c00; - } - - &--party { - background: linear-gradient(135deg, #e3f2fd 0%, #bbdefb 100%); - color: #1565c0; - } - - &--permit { - background: linear-gradient(135deg, #fff3e0 0%, #ffe0b2 100%); - color: #e65100; - } - - &--document { - background: linear-gradient(135deg, #f3e5f5 0%, #e1bee7 100%); - color: #7b1fa2; - } - - img { - width: 18px; - height: 18px; - object-fit: contain; - } - } - - .result-content { - flex: 1; - min-width: 0; - - .result-title { - font-size: 14px; - font-weight: 500; - color: $darkest-grey; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - margin-bottom: 2px; - - mark { - background: rgba($violet, 0.2); - color: inherit; - padding: 0 2px; - border-radius: 2px; - } - } - - .result-subtitle { - font-size: 12px; - color: #8c8c8c; - display: flex; - align-items: center; - gap: 8px; - } - } - - .result-type-badge { - font-size: 10px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.3px; - padding: 3px 8px; - border-radius: 4px; - background: #f5f5f5; - color: #8c8c8c; - margin-left: 12px; - flex-shrink: 0; - } - - .result-action { - opacity: 0; - color: $violet; - margin-left: 8px; - transition: opacity 0.15s ease; - } - - &:hover .result-action, - &--selected .result-action { - opacity: 1; - } -} - -// Empty & Loading States -.global-search__empty { - padding: 48px 24px; - text-align: center; - color: #8c8c8c; - - .empty-icon { - font-size: 48px; - color: #d9d9d9; - margin-bottom: 16px; - } - - .empty-title { - font-size: 15px; - font-weight: 500; - color: $darkest-grey; - margin-bottom: 4px; - } - - .empty-description { - font-size: 13px; - margin-bottom: 16px; - } -} - -.global-search__placeholder { - padding: 24px 20px; - color: #8c8c8c; - - .placeholder-title { - font-size: 13px; - font-weight: 500; - color: $darkest-grey; - margin-bottom: 16px; - display: flex; - align-items: center; - gap: 8px; - - .anticon { - color: $violet; - } - } -} - -// Recent Searches -.global-search__recent { - .recent-item { - display: flex; - align-items: center; - padding: 10px 20px; - cursor: pointer; - transition: background 0.15s ease; - gap: 12px; - - &:hover { - background: #fafafa; - } - - .recent-icon { - color: #bfbfbf; - font-size: 14px; - } - - .recent-text { - flex: 1; - font-size: 14px; - color: $darkest-grey; - } - - .recent-remove { - opacity: 0; - color: #bfbfbf; - padding: 4px; - cursor: pointer; - transition: all 0.15s ease; - - &:hover { - color: $alert-red; - } - } - - &:hover .recent-remove { - opacity: 1; - } - } -} - -// Quick Actions -.global-search__quick-actions { - padding: 16px 20px; - border-bottom: 1px solid #f0f0f0; - - .quick-action-title { - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; - color: #8c8c8c; - margin-bottom: 12px; - } - - .quick-actions-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 8px; - } - - .quick-action-item { - display: flex; - flex-direction: column; - align-items: center; - padding: 12px 8px; - border-radius: 8px; - cursor: pointer; - transition: all 0.15s ease; - border: 1px solid transparent; - - &:hover { - background: #fafafa; - border-color: #f0f0f0; - } - - .quick-action-icon { - width: 32px; - height: 32px; - border-radius: 8px; - display: flex; - align-items: center; - justify-content: center; - margin-bottom: 6px; - font-size: 14px; - } - - .quick-action-label { - font-size: 12px; - color: $darkest-grey; - } - } -} - -// Footer with Keyboard Hints -.global-search__footer { - padding: 10px 20px; - background: #fafafa; - border-top: 1px solid #f0f0f0; - display: flex; - justify-content: space-between; - align-items: center; - font-size: 12px; - color: #8c8c8c; - - .footer-hints { - display: flex; - gap: 16px; - } - - .footer-hint { - display: flex; - align-items: center; - gap: 6px; - - kbd { - background: #fff; - border: 1px solid #e8e8e8; - border-radius: 4px; - padding: 2px 6px; - font-size: 11px; - font-family: inherit; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); - } - } - - .footer-powered { - font-size: 11px; - color: #bfbfbf; - } -} - -// Loading Skeleton -.global-search__skeleton { - padding: 12px 20px; - - .skeleton-item { - display: flex; - align-items: center; - gap: 14px; - margin-bottom: 16px; - - &:last-child { - margin-bottom: 0; - } - } - - .skeleton-icon { - width: 36px; - height: 36px; - border-radius: 8px; - background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%); - background-size: 200% 100%; - animation: shimmer 1.5s infinite; - } - - .skeleton-content { - flex: 1; - - .skeleton-title { - height: 14px; - width: 60%; - border-radius: 4px; - background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%); - background-size: 200% 100%; - animation: shimmer 1.5s infinite; - margin-bottom: 8px; - } - - .skeleton-subtitle { - height: 10px; - width: 40%; - border-radius: 4px; - background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%); - background-size: 200% 100%; - animation: shimmer 1.5s infinite; - } - } -} - -@keyframes shimmer { - 0% { - background-position: 200% 0; - } - - 100% { - background-position: -200% 0; - } -} - -// Skeleton pulse animation for search results -.skeleton-pulse { - background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%); - background-size: 200% 100%; - animation: shimmer 1.5s infinite; -} - -.search-skeleton { - animation: fadeIn 0.2s ease; -} - -@keyframes fadeIn { - from { - opacity: 0; - } - - to { - opacity: 1; - } -} - // Search Results Page Improvements .search-results-page { min-height: calc(100vh - #{$nav-height}); diff --git a/services/core-web/src/tests/actionCreators/searchActionCreator.spec.js b/services/core-web/src/tests/actionCreators/searchActionCreator.spec.js index 78e88a751f..43c2a6a641 100644 --- a/services/core-web/src/tests/actionCreators/searchActionCreator.spec.js +++ b/services/core-web/src/tests/actionCreators/searchActionCreator.spec.js @@ -26,7 +26,8 @@ beforeEach(() => { describe("`fetchSearchResults` action creator", () => { const searchTerm = "abb"; - const url = ENVIRONMENT.apiUrl + API.SEARCH({ search_term: searchTerm, search_types: null }); + // Note: null values are filtered out by the action creator before building URL + const url = ENVIRONMENT.apiUrl + API.SEARCH({ search_term: searchTerm }); it("Request successful, dispatches `success` with correct response", () => { const mockResponse = { data: { success: true } }; mockAxios.onGet(url).reply(200, mockResponse); diff --git a/services/core-web/src/tests/components/Forms/permits/conditions/Condition.spec.tsx b/services/core-web/src/tests/components/Forms/permits/conditions/Condition.spec.tsx index c03919c542..2d2640f64c 100644 --- a/services/core-web/src/tests/components/Forms/permits/conditions/Condition.spec.tsx +++ b/services/core-web/src/tests/components/Forms/permits/conditions/Condition.spec.tsx @@ -3,6 +3,18 @@ import { render } from "@testing-library/react"; import { Condition } from "@/components/Forms/permits/conditions/Condition"; import { ReduxWrapper } from "@/tests/utils/ReduxWrapper"; +// Mock ConditionForm to avoid SIGSEGV crash with FormWrapper/AUTO_SIZE_FIELD +jest.mock("@/components/Forms/permits/conditions/ConditionForm", () => ({ + __esModule: true, + default: ({ layer, onCancel, onSubmit, initialValues }: any) => ( +
+