diff --git a/CodeListLibrary_project/clinicalcode/api/views/Ontology.py b/CodeListLibrary_project/clinicalcode/api/views/Ontology.py index 47614b47d..b519677a5 100644 --- a/CodeListLibrary_project/clinicalcode/api/views/Ontology.py +++ b/CodeListLibrary_project/clinicalcode/api/views/Ontology.py @@ -61,7 +61,6 @@ def get_ontology_detail(request, ontology_id): @api_view(['GET']) @permission_classes([IsAuthenticatedOrReadOnly]) -@cache_page(60 * 60 * 2) def get_ontology_node(request, node_id): """ Gets an Ontology node by the given request @@ -86,7 +85,6 @@ def get_ontology_node(request, node_id): @api_view(['GET']) @permission_classes([IsAuthenticatedOrReadOnly]) -@cache_page(60 * 60 * 2) def get_ontology_nodes(request): """ Queries Ontology nodes by the given request diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/concept_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/concept_utils.py index 162264320..9819247d4 100644 --- a/CodeListLibrary_project/clinicalcode/entity_utils/concept_utils.py +++ b/CodeListLibrary_project/clinicalcode/entity_utils/concept_utils.py @@ -29,7 +29,7 @@ def is_concept_published(concept_id, version_id): version_id (int): the Concept's history_id Returns: - bool: Reflects published status + bool: Reflects publish status """ concept_id = gen_utils.parse_int(concept_id, None) @@ -95,7 +95,7 @@ def was_concept_ever_published(concept_id, version_id=None): version_id (int|null): the Concept's history_id Returns: - bool: Reflects all-time published status + bool: Reflects all-time publish status """ concept_id = gen_utils.parse_int(concept_id, None) diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/constants.py b/CodeListLibrary_project/clinicalcode/entity_utils/constants.py index 1cacc0f34..637d27ba0 100644 --- a/CodeListLibrary_project/clinicalcode/entity_utils/constants.py +++ b/CodeListLibrary_project/clinicalcode/entity_utils/constants.py @@ -345,9 +345,9 @@ class ONTOLOGY_TYPES(int, enum.Enum, metaclass=IterableMeta): """ APPENDED_SECTIONS = [ { - "title": "Permissions", - "description": "Settings for sharing and collaboration.", - "fields": ["group", "group_access", "world_access"] + 'title': 'Permissions', + 'description': 'Settings for sharing and collaboration.', + 'fields': ['group', 'group_access', 'world_access'] } ] @@ -359,20 +359,20 @@ class ONTOLOGY_TYPES(int, enum.Enum, metaclass=IterableMeta): """ DETAIL_PAGE_APPENDED_SECTIONS = [ { - "title": "Permissions", - "description": "", - "fields": ["permissions"], - "requires_auth": True + 'title': 'Permissions', + 'description': '', + 'fields': ['permissions'], + 'requires_auth': True }, { - "title": "API", - "description": "", - "fields": ["api"] + 'title': 'API', + 'description': '', + 'fields': ['api'] }, { - "title": "Version History", - "description": "", - "fields": ["version_history"] + 'title': 'Version History', + 'description': '', + 'fields': ['version_history'] } ] @@ -381,29 +381,29 @@ class ONTOLOGY_TYPES(int, enum.Enum, metaclass=IterableMeta): - fields that relate to DETAIL_PAGE_APPENDED_SECTIONS for the detail page """ DETAIL_PAGE_APPENDED_FIELDS = { - "permissions": { - "title": "Permissions", - "field_type": "permissions_section", - "active": True, - "hide_on_create": True - }, - "api": { - "title": "API", - "field_type": "api_section", - "active": True, - "hide_on_create": True - }, - "version_history": { - "title": "Version History", - "field_type": "version_history_section", - "active": True, - "hide_on_create": True - }, - "history_id": { - "title": "Version ID", - "field_type": "history_id", - "active": True, - "hide_on_create": True + 'permissions': { + 'title': 'Permissions', + 'field_type': 'permissions_section', + 'active': True, + 'hide_on_create': True + }, + 'api': { + 'title': 'API', + 'field_type': 'api_section', + 'active': True, + 'hide_on_create': True + }, + 'version_history': { + 'title': 'Version History', + 'field_type': 'version_history_section', + 'active': True, + 'hide_on_create': True + }, + 'history_id': { + 'title': 'Version ID', + 'field_type': 'history_id', + 'active': True, + 'hide_on_create': True } } @@ -450,78 +450,79 @@ class ONTOLOGY_TYPES(int, enum.Enum, metaclass=IterableMeta): 'relative': 'name', } }, - "search": { - "api": True + 'search': { + 'api': True }, }, - "name": { - "title": "Name", - "description": "Unsurprisingly, the name of the phenotype.", - "field_type": "string_inputbox", - "active": True, - "validation": { - "type": "string", - "mandatory": True, - "sanitise": "strict", + 'name': { + 'title': 'Name', + 'description': 'The name or title of this Phenotype.', + 'field_type': 'string_inputbox', + 'active': True, + 'validation': { + 'type': 'string', + 'mandatory': True, + 'sanitise': 'strict', }, 'is_base_field': True }, - "definition": { - "title": "Definition", - "description": "An overview of the phenotype.", - "field_type": "textarea_markdown", - "active": True, - "validation": { - "type": "string", - "mandatory": False, - "sanitise": "markdown", + 'definition': { + 'title': 'Definition', + 'description': 'An overview of the Phenotype.', + 'field_type': 'textarea_markdown', + 'active': True, + 'validation': { + 'type': 'string', + 'mandatory': False, + 'sanitise': 'markdown', }, 'is_base_field': True }, - "implementation": { - "title": "Implementation", - "description": "Information on how the phenotype is applied to data.", - "field_type": "textarea_markdown", - "active": True, - "validation": { - "type": "string", - "mandatory": False, - "sanitise": "markdown", + 'implementation': { + 'title': 'Implementation', + 'description': "Information on how the Phenotype is applied to data (optional).", + 'field_type': 'textarea_markdown', + 'active': True, + 'validation': { + 'type': 'string', + 'mandatory': False, + 'sanitise': 'markdown', }, 'is_base_field': True }, - "publications": { - "title": "Publications", - "description": "Publication(s) where the phenotype was defined or has been used.", - "field_type": "publications", - "sort": {"key": lambda pub: 0 if pub.get('primary') == 1 else 1}, - "active": True, - "validation": { - "type": "publication", - "mandatory": False + 'publications': { + 'title': 'Publications', + 'description': "Publication(s) where this Phenotype was defined or has been used (optional).", + 'field_type': 'publications', + 'sort': {'key': lambda pub: 0 if pub.get('primary') == 1 else 1}, + 'active': True, + 'validation': { + 'type': 'publication', + 'mandatory': False }, 'is_base_field': True }, 'validation': { 'title': 'Validation', + 'description': "A description of the methods used to validate this Phenotype (optional).", 'field_type': 'textarea_markdown', 'active': True, 'validation': { 'type': 'string', 'mandatory': False, - "sanitise": "markdown", + 'sanitise': 'markdown', }, 'is_base_field': True }, - "citation_requirements": { - "title": "Citation Requirements", - "description": "A request for how this phenotype is referenced if used in other work.", - "field_type": "citation_requirements", - "active": True, - "validation": { - "type": "string", - "mandatory": False, - "sanitise": "markdown", + 'citation_requirements': { + 'title': 'Citation Requirements', + 'description': "A request for how this Phenotype is referenced if used in other work (optional).", + 'field_type': 'citation_requirements', + 'active': True, + 'validation': { + 'type': 'string', + 'mandatory': False, + 'sanitise': 'markdown', }, 'is_base_field': True }, @@ -540,37 +541,37 @@ class ONTOLOGY_TYPES(int, enum.Enum, metaclass=IterableMeta): 'hide_on_create': True, 'is_base_field': True }, - "author": { - "title": "Author", - "description": "List of authors who contributed to this phenotype.", - "field_type": "string_inputbox", - "active": True, - "validation": { - "type": "string", - "mandatory": True, - "sanitise": "strict", + 'author': { + 'title': 'Author', + 'description': 'List of authors who contributed to this Phenotype.', + 'field_type': 'string_inputbox', + 'active': True, + 'validation': { + 'type': 'string', + 'mandatory': True, + 'sanitise': 'strict', }, 'is_base_field': True }, - "collections": { - "title": "Collections", - "description": "List of content collections this phenotype belongs to.", - "field_type": "collections", - "active": True, - "hydrated": True, - "compute_statistics": True, - "validation": { - "type": "int_array", - "mandatory": False, - "source": { - "table": "Tag", - "query": "id", - "relative": "description", - "filter": { - "tag_type": 2, + 'collections': { + 'title': 'Collections', + 'description': "A list of content collections that this Phenotype belongs to (optional).", + 'field_type': 'collections', + 'active': True, + 'hydrated': True, + 'compute_statistics': True, + 'validation': { + 'type': 'int_array', + 'mandatory': False, + 'source': { + 'table': 'Tag', + 'query': 'id', + 'relative': 'description', + 'filter': { + 'tag_type': 2, ## Can be added once we det. what we're doing with brands - # "source_by_brand": None + # 'source_by_brand': None } } }, @@ -580,25 +581,25 @@ class ONTOLOGY_TYPES(int, enum.Enum, metaclass=IterableMeta): }, 'is_base_field': True }, - "tags": { - "title": "Tags", - "description": "Optional keywords helping to categorize this content.", - "field_type": "tags", - "active": True, - "hydrated": True, - "compute_statistics": True, - "validation": { - "type": "int_array", - "mandatory": False, - "source": { - "table": "Tag", - "query": "id", - "relative": "description", - "filter": { - "tag_type": 1, + 'tags': { + 'title': 'Tags', + 'description': "A list of keywords helping to categorise this content (optional).", + 'field_type': 'tags', + 'active': True, + 'hydrated': True, + 'compute_statistics': True, + 'validation': { + 'type': 'int_array', + 'mandatory': False, + 'source': { + 'table': 'Tag', + 'query': 'id', + 'relative': 'description', + 'filter': { + 'tag_type': 1, ## Can be added once we det. what we're doing with brands - # "source_by_brand": None + # 'source_by_brand': None } } }, @@ -608,39 +609,39 @@ class ONTOLOGY_TYPES(int, enum.Enum, metaclass=IterableMeta): }, 'is_base_field': True }, - "group": { - "title": "Group", - "description": "The group that owns this phenotype for permissions purposes.", - "field_type": "group_field", - "active": True, - "validation": { - "type": "int", - "mandatory": False, - "computed": True + 'group': { + 'title': 'Group', + 'description': "The group that owns this Phenotype for permissions purposes (optional).", + 'field_type': 'group_field', + 'active': True, + 'validation': { + 'type': 'int', + 'mandatory': False, + 'computed': True }, 'is_base_field': True }, - "group_access": { - "title": "Group Access", - "description": "Optionally enable this phenotype to be viewed or edited by the group.", - "field_type": "access_field_editable", - "active": True, - "validation": { - "type": "int", - "mandatory": True, - "range": [1, 3] + 'group_access': { + 'title': 'Group Access', + 'description': 'Optionally enable this Phenotype to be viewed or edited by the group.', + 'field_type': 'access_field_editable', + 'active': True, + 'validation': { + 'type': 'int', + 'mandatory': True, + 'range': [1, 3] }, 'is_base_field': True }, - "world_access": { - "title": "All authenticated users", - "description": "Enables this phenotype to be viewed by all logged-in users of the Library (does not make it public on the web -- use the Publish action for that).", - "field_type": "access_field", - "active": True, - "validation": { - "type": "int", - "mandatory": True, - "range": [1, 3] + 'world_access': { + 'title': 'All authenticated users', + 'description': "Enables this Phenotype to be viewed by all logged-in users of the Library (does not make it public on the web -- use the Publish action for that).", + 'field_type': 'access_field', + 'active': True, + 'validation': { + 'type': 'int', + 'mandatory': True, + 'range': [1, 3] }, 'is_base_field': True }, @@ -783,7 +784,7 @@ class ONTOLOGY_TYPES(int, enum.Enum, metaclass=IterableMeta): 'concept_information': { 'system_defined': True, - 'description': 'json of concept ids/ver used in phenotype (managed by code snippet)', + 'description': 'json of concept ids/ver used in Phenotype (managed by code snippet)', 'input_type': 'clinical/concept', 'output_type': 'phenotype_clinical_code_lists' }, @@ -801,7 +802,7 @@ class ONTOLOGY_TYPES(int, enum.Enum, metaclass=IterableMeta): }, 'coding_system': { 'system_defined': True, - 'description': 'list of coding system ids (calculated from phenotype concepts) (managed by code snippet)', + 'description': 'list of coding system ids (calculated from Phenotype concepts) (managed by code snippet)', 'input_type': 'tagbox', 'output_type': 'tagbox' }, diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/gen_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/gen_utils.py index 3dc514f00..6d8506832 100644 --- a/CodeListLibrary_project/clinicalcode/entity_utils/gen_utils.py +++ b/CodeListLibrary_project/clinicalcode/entity_utils/gen_utils.py @@ -1,6 +1,7 @@ from uuid import UUID from json import JSONEncoder from functools import wraps +from typing import Pattern from dateutil import parser as dateparser from django.conf import settings from django.http.response import JsonResponse @@ -11,10 +12,12 @@ import time import json import datetime +import logging import urllib from . import constants, sanitise_utils +logger = logging.getLogger(__name__) def is_datetime(x): """ @@ -215,11 +218,33 @@ def jsonify_response(**kwargs): }, status=code) -def try_match_pattern(value, pattern): +def try_match_pattern(value, pattern, flags = re.IGNORECASE): """ Tries to match a string by a pattern """ - return re.match(pattern, value) + if not isinstance(value, str): + return False + + if isinstance(pattern, (str, Pattern)): + return re.match(pattern, value) + elif isinstance(pattern, list): + res = None + try: + for x in pattern: + if not isinstance(x, (str, Pattern)): + continue + + res = re.match(x, value, flags) + if res: + break + except Exception as e: + logger.warning(f'Failed to test Pattern with err: {e}') + res = None + finally: + return res + + logger.warning(f'Expected Pattern to be of (str, Pattern) type but got Arg') + return False def is_valid_uuid(value): diff --git a/CodeListLibrary_project/clinicalcode/middleware/compression.py b/CodeListLibrary_project/clinicalcode/middleware/compression.py index efc9dc2ba..5fb08db8f 100644 --- a/CodeListLibrary_project/clinicalcode/middleware/compression.py +++ b/CodeListLibrary_project/clinicalcode/middleware/compression.py @@ -1,15 +1,17 @@ +from django.http import HttpResponse from django.conf import settings from django_minify_html.middleware import MinifyHtmlMiddleware +import minify_html + class HTMLCompressionMiddleware(MinifyHtmlMiddleware): """ - HTML minifier middleware to determine whether - we should compress a HTML response + HTML minifier middleware to determine how and whether to compress a HTML response """ minify_args = { 'keep_comments': False, 'minify_css': False, - 'minify_js': False + 'minify_js': False, } def should_minify(self, request, response): diff --git a/CodeListLibrary_project/clinicalcode/models/OntologyTag.py b/CodeListLibrary_project/clinicalcode/models/OntologyTag.py index 48c780689..2680e9b86 100644 --- a/CodeListLibrary_project/clinicalcode/models/OntologyTag.py +++ b/CodeListLibrary_project/clinicalcode/models/OntologyTag.py @@ -9,6 +9,7 @@ from django_postgresql_dag.models import node_factory, edge_factory import logging +import psycopg2 from ..entity_utils import gen_utils from ..entity_utils import constants @@ -17,6 +18,16 @@ logger = logging.getLogger(__name__) +""" + Default const. value specifying the minimum amount of characters required before a typeahead request will be queried +""" +TYPEAHEAD_MIN_CHARS = 3 + +""" + Default const. value specifying the maximum number of results to return in a typeahead query +""" +TYPEAHEAD_MAX_RESULTS = 20 + class OntologyTagEdge(edge_factory('OntologyTag', concrete=False)): """ OntologyTagEdge @@ -522,6 +533,9 @@ def get_node_data(cls, node_id, ontology_id=None, model_label=None, default=None else: children = [] + # print(parents.query) + # print(children.query) + is_root = node.is_root() or node.is_island() is_leaf = node.is_leaf() @@ -601,7 +615,7 @@ def build_tree(cls, descendant_ids, default=None): ), ancestors as ( select p0.child_id, - p0.path + p0.path from ancestry as p0 join ( select child_id, @@ -609,13 +623,16 @@ def build_tree(cls, descendant_ids, default=None): from ancestry group by child_id ) as lim - on lim.child_id = p0.child_id - and lim.max_depth = p0.depth + on lim.child_id = p0.child_id + and lim.max_depth = p0.depth ), objects as ( - select selected.child_id, + select + selected.child_id, + nodes.id as nodes_id, jsonb_build_object( 'id', nodes.id, + 'idx', selected.idx, 'label', nodes.name, 'properties', nodes.properties, 'isLeaf', case when count(edges1.child_id) < 1 then True else False end, @@ -623,29 +640,94 @@ def build_tree(cls, descendant_ids, default=None): 'type_id', nodes.type_id, 'reference_id', nodes.reference_id, 'child_count', count(edges1.child_id) - ) as tree + ) as tree from ( select id, - child_id + child_id, + idx from ancestors, - unnest(path) as id - group by id, child_id + unnest(path) with ordinality as ids(id, idx) + group by id, child_id, idx ) as selected join public.clinicalcode_ontologytag as nodes - on nodes.id = selected.id + on nodes.id = selected.id left outer join public.clinicalcode_ontologytagedge as edges0 - on nodes.id = edges0.child_id + on nodes.id = edges0.child_id left outer join public.clinicalcode_ontologytagedge as edges1 - on nodes.id = edges1.parent_id - group by selected.child_id, nodes.id + on nodes.id = edges1.parent_id + group by selected.child_id, nodes.id, selected.idx + ), + recur as ( + select + obj.child_id, + obj.tree || jsonb_build_object( + 'children', coalesce(children.tree, '[]'::json), + 'parents', coalesce(parents.tree, '[]'::json) + ) as tree + from objects as obj + left outer join ( + select c0.id, json_agg(c0.tree) as tree + from ( + select o0.nodes_id as id, jsonb_build_object( + 'id', n0.id, + 'label', n0.name, + 'properties', n0.properties, + 'isRoot', false, + 'isLeaf', case when count(T5.child_id) < 1 then true else false end, + 'type_id', n0.type_id, + 'reference_id', n0.reference_id, + 'child_count', count(T5.child_id), + 'parents', array_agg(distinct T4.parent_id) + ) as tree + from objects as o0 + join public.clinicalcode_ontologytagedge as e0 + on (o0.nodes_id = e0.parent_id) + join public.clinicalcode_ontologytag as n0 + on e0.child_id = n0.id + left outer join public.clinicalcode_ontologytagedge T4 + on (n0.id = T4.child_id) + left outer join public.clinicalcode_ontologytagedge T5 + on (n0.id = T5.parent_id) + group by o0.nodes_id, n0.id + ) as c0 + group by c0.id + ) as children + on children.id = obj.nodes_id + left outer join ( + select c0.id, json_agg(c0.tree) as tree + from ( + select o0.nodes_id as id, jsonb_build_object( + 'id', n0.id, + 'label', n0.name, + 'properties', n0.properties, + 'isRoot', case when count(T4.parent_id) < 1 then true else false end, + 'isLeaf', case when count(T5.child_id) < 1 then true else false end, + 'type_id', n0.type_id, + 'reference_id', n0.reference_id, + 'child_count', count(T5.child_id), + 'parents', array_agg(distinct T4.parent_id) + ) as tree + from objects as o0 + join public.clinicalcode_ontologytagedge as e0 + on (o0.nodes_id = e0.child_id) + join public.clinicalcode_ontologytag as n0 + on e0.parent_id = n0.id + left outer join public.clinicalcode_ontologytagedge T4 + on (n0.id = T4.child_id) + left outer join public.clinicalcode_ontologytagedge T5 + on (n0.id = T5.parent_id) + group by o0.nodes_id, n0.id + ) as c0 + group by c0.id + ) as parents + on parents.id = obj.nodes_id ) - select ancestor.child_id, ancestor.path, - json_agg(obj.tree) as dataset + json_agg(obj.tree order by obj.tree->>'idx') as dataset from ancestors as ancestor - join objects as obj - on obj.child_id = ancestor.child_id + join recur as obj + on obj.child_id = ancestor.child_id group by ancestor.child_id, ancestor.path; ''' @@ -656,7 +738,7 @@ def build_tree(cls, descendant_ids, default=None): columns = [col[0] for col in cursor.description] ancestry = [dict(zip(columns, row)) for row in cursor.fetchall()] - except: + except Exception as e: pass return ancestry @@ -734,13 +816,11 @@ def get_detail_data(cls, node_ids, default=None): ) \ .values_list('tree_dataset', flat=True) - nodes = [ + return [ node | { 'full_names': roots.get(node.get('id')) } if not node.get('isRoot') and roots.get(node.get('id')) else node for node in nodes ] - - return nodes @classmethod @@ -775,14 +855,17 @@ def get_creation_data(cls, node_ids, type_ids, default=None): return default nodes = OntologyTag.objects.filter(id__in=node_ids, type_id__in=type_ids) - ancestors = [ - [ - OntologyTag.get_node_data(ancestor.id, default=None) - for ancestor in node.ancestors() - ] - for node in nodes - if not node.is_root() and not node.is_island() - ] + ancestors = [] + + tree = cls.build_tree(node_ids, None) + if tree: + for node in nodes: + if node.is_root() or node.is_island(): + continue + + obj = next(x for x in tree if x.get('child_id') == node.id) + if obj is not None and isinstance(obj.get('dataset'), list): + ancestors.append(obj.get('dataset')) return { 'ancestors': ancestors, @@ -822,3 +905,90 @@ def get_detailed_source_value(cls, node_ids, type_ids, default=None): return default return list(nodes.annotate(value=F('id')).values('name', 'value')) + + + @classmethod + def query_typeahead(cls, searchterm = '', type_ids=None, result_limit = TYPEAHEAD_MAX_RESULTS): + """ + Autocomplete, typeahead-like web search for Ontology search components + + Note: + The searchterm must satisfy the `TYPEAHEAD_MIN_CHARS` size (gte 3) to return results + + Args: + searchterm (str): some web query search term; defaults to an empty `str` + + type_ids (int|str|int[]): optionally narrow the resultset by specifying the ontology type ids; defaults to `None` + + result_limit (int): maximum number of results to return per request; defaults to `TYPEAHEAD_MAX_RESULTS` + + Returns: + An array, ordered by search rank, listing each of the matching ontological terms + + """ + if not isinstance(searchterm, str) or gen_utils.is_empty_string(searchterm) or len(searchterm) < TYPEAHEAD_MIN_CHARS: + return [] + + if isinstance(type_ids, str): + type_ids = gen_utils.try_value_as_type(type_ids, 'int') + + if isinstance(type_ids, int): + type_ids = [type_ids] + elif isinstance(type_ids, list): + type_ids = gen_utils.try_value_as_type(type_ids, 'int_array') + + if not isinstance(type_ids, list): + type_ids = [] + + with connection.cursor() as cursor: + sql = psycopg2.sql.SQL(''' + with + matches as ( + select + node.id, + node.name, + node.type_id, + node.properties, + ts_rank_cd(node.search_vector, websearch_to_tsquery('pg_catalog.english', %(searchterm)s)) as score + from public.clinicalcode_ontologytag as node + where (( + search_vector + @@ to_tsquery('pg_catalog.english', replace(websearch_to_tsquery('pg_catalog.english', %(searchterm)s)::text || ':*', '<->', '|')) + ) + or ( + (relation_vector @@ to_tsquery('pg_catalog.english', replace(websearch_to_tsquery('pg_catalog.english', %(searchterm)s)::text || ':*', '<->', '|'))) + or (relation_vector @@ to_tsquery('pg_catalog.english', replace(websearch_to_tsquery('pg_catalog.english', %(searchterm)s)::text || ':*', '<->', '|'))) + ) + ) + ''') + + if len(type_ids) > 0: + sql = sql + psycopg2.sql.SQL('''and node.type_id = any(%(type_ids)s::int[])''') + + sql = sql + psycopg2.sql.SQL(''' + group by node.id + limit {lim_size} + ) + select json_agg( + jsonb_build_object( + 'id', node.id, + 'label', node.name, + 'properties', coalesce(node.properties, jsonb_build_object()), + 'type_id', node.type_id + ) + order by node.score desc + ) as agg + from matches as node + ''') \ + .format( + lim_size=psycopg2.sql.Literal(result_limit) + ) + + cursor.execute(sql, params={ + 'type_ids': type_ids, + 'searchterm': searchterm, + }) + + columns = [col[0] for col in cursor.description] + results = [dict(zip(columns, row)) for row in cursor.fetchall()] + return results[0].get('agg') if len(results) > 0 and isinstance(results[0].get('agg'), list) else [] diff --git a/CodeListLibrary_project/clinicalcode/templatetags/entity_publish_renderer.py b/CodeListLibrary_project/clinicalcode/templatetags/entity_publish_renderer.py index c6b277d6e..b5cd6d49a 100644 --- a/CodeListLibrary_project/clinicalcode/templatetags/entity_publish_renderer.py +++ b/CodeListLibrary_project/clinicalcode/templatetags/entity_publish_renderer.py @@ -23,15 +23,15 @@ def render_errors_approval(context, *args, **kwargs): errors.append(message) else: if not context['is_allowed_view_children']: - message = 'You must have view access to all concepts/phenotypes.' + message = 'You must have view access to all Concepts/Phenotypes.' errors.append(message) if not context['all_not_deleted']: - message = 'All concepts/phenotypes must not be deleted.' + message = 'All Concepts/Phenotypes must not be deleted.' errors.append(message) if not context['all_are_published']: - message = 'All concepts/phenotypes must be published.' + message = 'All Concepts/Phenotypes must be published.' errors.append(message) return {'errors': errors} @@ -79,7 +79,7 @@ def render_publish_button(context, *args, **kwargs): button_context.update({'class_modal':"primary-btn bold text-danger dropdown-btn__label", 'disabled': 'true', 'Button_type':"Entity is deleted", - 'title': "Deleted phenotypes cannot be published!" + 'title': "Deleted Phenotypes cannot be published!" }) return button_context elif user_entity_access: @@ -112,7 +112,7 @@ def render_publish_button(context, *args, **kwargs): button_context.update({'class_modal':"primary-btn bold text-danger dropdown-btn__label", 'disabled': 'true', 'Button_type': "Entity is deleted", - 'title': "Deleted phenotypes cannot be published!" + 'title': "Deleted Phenotypes cannot be published!" }) elif context["approval_status"] == constants.APPROVAL_STATUS.REJECTED: diff --git a/CodeListLibrary_project/clinicalcode/tests/constants/test_template.json b/CodeListLibrary_project/clinicalcode/tests/constants/test_template.json index 8d267f209..6e104ccdd 100644 --- a/CodeListLibrary_project/clinicalcode/tests/constants/test_template.json +++ b/CodeListLibrary_project/clinicalcode/tests/constants/test_template.json @@ -135,7 +135,7 @@ }, "mandatory": true }, - "description": "The category of patient characteristic this phenotype falls under." + "description": "The category of patient characteristic this Phenotype falls under." }, "data_sources": { "title": "Data Sources", @@ -158,7 +158,7 @@ }, "mandatory": false }, - "description": "Data sources the phenotype creators have run this phenotype against; or view as appropriate to use this phenotype for." + "description": "Data sources the Phenotype creators have run this Phenotype against; or view as appropriate to use this Phenotype for." }, "event_date_range": { "title": "Valid Event Date Range", @@ -169,7 +169,7 @@ "regex": "(?:\\d+/|\\d+)+[\\s+]?-[\\s+]?(?:\\d+/|\\d+)+", "mandatory": false }, - "description": "If this phenotype is only applicable within a limited time period, please specify that here (optional)." + "description": "If this Phenotype is only applicable within a limited time period, please specify that here (optional)." }, "sex": { "title": "Sex", @@ -197,7 +197,7 @@ } ] }, - "description": "The biological sex this phenotype is applicable to." + "description": "The biological sex this Phenotype is applicable to." }, "agreement_date": { "title": "Agreement Date", @@ -208,7 +208,7 @@ "regex": "(0[1-9]|[12]\\d|30|31)\\/(0[1-9]|1[0-2])\\/(\\d{4})", "mandatory": false }, - "description": "A date representing when this phenotype was first finalized (may predate the Phenotype Library). Deprecated.", + "description": "A date representing when this Phenotype was first finalized (may predate the Phenotype Library). Deprecated.", "hide_if_empty": true, "hide_on_create": true }, @@ -230,7 +230,7 @@ "computed": true, "mandatory": false }, - "description": "Clinical coding system(s) contained within this phenotype. A phenotype may have multiple concepts, each with its own coding system. All contained coding systems are programmatically represented here.", + "description": "Clinical coding system(s) contained within this Phenotype. A Phenotype may have multiple concepts, each with its own coding system. All contained coding systems are programmatically represented here.", "hide_on_create": true }, "phenoflowid": { @@ -244,7 +244,7 @@ "type": "int", "mandatory": false }, - "description": "ID of this phenotype's PhenoFLOW implementation, if applicable. For more information: https://kclhi.org/phenoflow/", + "description": "ID of this Phenotype's PhenoFLOW implementation, if applicable. For more information: https://kclhi.org/phenoflow/", "hide_if_empty": true }, "concept_information": { @@ -256,7 +256,7 @@ "mandatory": false, "has_children": true }, - "description": "A set of concepts, each of which defines a list of clinical codes, contained within this phenotype." + "description": "A set of concepts, each of which defines a list of clinical codes, contained within this Phenotype." }, "source_reference": { "title": "Source Reference", @@ -270,7 +270,7 @@ ], "mandatory": false }, - "description": "If this phenotype is derived from a third-party source, define that here. Deprecated.", + "description": "If this Phenotype is derived from a third-party source, define that here. Deprecated.", "hide_if_empty": true, "hide_on_create": true } @@ -297,7 +297,7 @@ "collections", "tags" ], - "description": "An overview of the phenotype with basic metadata." + "description": "An overview of the Phenotype with basic metadata." }, { "title": "Definition", @@ -310,7 +310,7 @@ "regulatory_approval_organisation", "regulatory_approval_date" ], - "description": "Definition of the phenotype." + "description": "Definition of the Phenotype." }, { "title": "Endorsement", @@ -326,7 +326,7 @@ "phenoflowid", "implementation" ], - "description": "How this phenotype definition is run against data.", + "description": "How this Phenotype definition is run against data.", "hide_if_empty": true }, { @@ -343,7 +343,7 @@ "fields": [ "concept_information" ], - "description": "Clinical codes used to define this phenotype." + "description": "Clinical codes used to define this Phenotype." }, { "title": "Publication", @@ -351,7 +351,7 @@ "publications", "citation_requirements" ], - "description": "Publication(s) where this phenotype is defined and/or used." + "description": "Publication(s) where this Phenotype is defined and/or used." } ], "template_details": { diff --git a/CodeListLibrary_project/clinicalcode/views/GenericEntity.py b/CodeListLibrary_project/clinicalcode/views/GenericEntity.py index c51d4ff6c..31728c67f 100644 --- a/CodeListLibrary_project/clinicalcode/views/GenericEntity.py +++ b/CodeListLibrary_project/clinicalcode/views/GenericEntity.py @@ -30,6 +30,7 @@ from clinicalcode.views import View from clinicalcode.models.Concept import Concept from clinicalcode.models.Template import Template +from clinicalcode.models.OntologyTag import OntologyTag from clinicalcode.models.CodingSystem import CodingSystem from clinicalcode.models.GenericEntity import GenericEntity from clinicalcode.models.PublishedGenericEntity import PublishedGenericEntity @@ -192,7 +193,10 @@ class CreateEntityView(TemplateView): of having a form dynamically created to reflect the dynamic model. """ - fetch_methods = ['search_codes', 'get_options', 'import_rule', 'import_concept'] + fetch_methods = [ + 'search_codes', 'get_options', 'import_rule', 'import_concept', + 'query_ontology_typeahead', 'query_ontology_record', + ] templates = { 'form': 'clinicalcode/generic_entity/creation/create.html', 'select': 'clinicalcode/generic_entity/creation/select.html' @@ -401,6 +405,77 @@ def import_concept(self, request, *args, **kwargs): 'concepts': concepts }) + def query_ontology_record(self, request, *args, **kwargs): + """ + Queries ontology node & its ancestry + + See: :func:`~clinicalcode.models.OntologyTag.OntologyTag.get_creation_data~` + + Query Params: + node_id (int): the node id + + type_id (str|int): the node's type id + + Returns: + An object describing the node and its ancestry if found; otherwise returns an appropriate err + + """ + print(gen_utils.try_get_param(request, 'node_id'), gen_utils.try_get_param(request, 'type_id')) + node_id = gen_utils.try_value_as_type( + gen_utils.try_get_param(request, 'node_id'), + 'int', + default=None + ) + + type_id = gen_utils.try_value_as_type( + gen_utils.try_get_param(request, 'type_id'), + 'int', + default=None + ) + + if node_id is None or type_id is None: + return gen_utils.jsonify_response(message='Invalid NodeId and/or TypeId parameter', code=400, status='false') + + result = OntologyTag.build_tree([node_id], None) # OntologyTag.get_creation_data([node_id], [type_id], None) + if result is None: + return gen_utils.jsonify_response(message=f'Node not found', code=404, status='false') + + return JsonResponse({ 'result': result }) + + def query_ontology_typeahead(self, request, *args, **kwargs): + """ + Autocomplete, typeahead-like web search for Ontology search components + + See: :func:`~clinicalcode.models.OntologyTag.OntologyTag.query_typeahead~` + + Query Params: + search (str): some web query search term; defaults to an empty `str` + + type_ids (str|int|int[]): narrow the resultset by specifying the ontology type ids; defaults to `None` + + Returns: + An array, ordered by search rank, listing each of the matching ontological terms + + """ + searchterm = gen_utils.try_value_as_type( + gen_utils.try_get_param(request, 'search'), + 'string', + default=None + ) + if not isinstance(searchterm, str) or gen_utils.is_empty_string(searchterm): + return JsonResponse({ 'result': [] }) + + type_ids = gen_utils.try_value_as_type( + gen_utils.try_get_param(request, 'type_ids', default='').split(','), + 'int_array', + default=None + ) + + if not isinstance(type_ids, list): + return JsonResponse({ 'result': [] }) + + return JsonResponse({ 'result': OntologyTag.query_typeahead(searchterm, type_ids) }) + def get_options(self, request, *args, **kwargs): """ @desc GET request made by client to retrieve all available diff --git a/CodeListLibrary_project/clinicalcode/views/View.py b/CodeListLibrary_project/clinicalcode/views/View.py index 9cc126e1b..60a6a858b 100644 --- a/CodeListLibrary_project/clinicalcode/views/View.py +++ b/CodeListLibrary_project/clinicalcode/views/View.py @@ -325,14 +325,7 @@ def reference_data(request): 'coding_system': list(CodingSystem.objects.all().order_by('id').values('id', 'name')), 'tags': list(tags), 'collections': list(collections), + 'ontology_groups': [x.value for x in ONTOLOGY_TYPES] } - # - # [!] Note: Temporary solution to block ontology rendering on reference data - # - # i.e. remove reference data to ontology unless template.hide_on_create=False - # - if should_render_template(name='Atlas Phecode Phenotype'): - context.update({ 'ontology': OntologyTag.get_groups([x.value for x in ONTOLOGY_TYPES], default=[]) }) - return render(request, 'clinicalcode/about/reference_data.html', context) diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/components/entitySelector.js b/CodeListLibrary_project/cll/static/js/clinicalcode/components/entitySelector.js index 7435a2008..279c63bd6 100644 --- a/CodeListLibrary_project/cll/static/js/clinicalcode/components/entitySelector.js +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/components/entitySelector.js @@ -1,7 +1,6 @@ /** * ES_DEFAULT_DESCRIPTOR * @desc Default description string if none provided - * */ const ES_DEFAULT_DESCRIPTOR = 'Create a ${name}' @@ -41,7 +40,7 @@ const createGroup = (container, template, id, title, description) => { 'description': description, }); - const doc = parseHTMLFromString(html); + const doc = parseHTMLFromString(html, true); return container.appendChild(doc.body.children[0]); } @@ -64,10 +63,10 @@ const createCard = (container, template, id, type, hint, title, description) => 'id': id, 'hint': hint, 'title': title, - 'description': description, + 'description': linkifyText(description), }); - const doc = parseHTMLFromString(html); + const doc = parseHTMLFromString(html, true); return container.appendChild(doc.body.children[0]); } diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/components/groupedEnumSelector.js b/CodeListLibrary_project/cll/static/js/clinicalcode/components/groupedEnumSelector.js index ac9ea6962..d3c9354fd 100644 --- a/CodeListLibrary_project/cll/static/js/clinicalcode/components/groupedEnumSelector.js +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/components/groupedEnumSelector.js @@ -211,7 +211,7 @@ export default class GroupedEnum { ` - const doc = parseHTMLFromString(html); + const doc = parseHTMLFromString(html, true); return this.element.appendChild(doc.body.children[0]); } } diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/components/modal.js b/CodeListLibrary_project/cll/static/js/clinicalcode/components/modal.js index dd81a1268..4a63966e6 100644 --- a/CodeListLibrary_project/cll/static/js/clinicalcode/components/modal.js +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/components/modal.js @@ -201,7 +201,7 @@ class ModalFactory { const { id, title, content, showFooter, buttons, size } = options; const html = interpolateString(PROMPT_DEFAULT_CONTAINER, { id: id, title: title, content: content, size: size }); - const doc = parseHTMLFromString(html); + const doc = parseHTMLFromString(html, true); const currentHeight = window.scrollY; const modal = document.body.appendChild(doc.body.children[0]); const container = modal.querySelector('.target-modal__container'); @@ -220,7 +220,7 @@ class ModalFactory { if (!isNullOrUndefined(footer)) { for (let i = 0; i < buttons.length; ++i) { let button = buttons[i]; - let item = parseHTMLFromString(button.html); + let item = parseHTMLFromString(button.html, true); item = footer.appendChild(item.body.children[0]); item.innerText = button.name; item.setAttribute('aria-label', button.name); diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/forms/clinical/conceptCreator.js b/CodeListLibrary_project/cll/static/js/clinicalcode/forms/clinical/conceptCreator.js index fe692bcbc..a7cf6cada 100644 --- a/CodeListLibrary_project/cll/static/js/clinicalcode/forms/clinical/conceptCreator.js +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/forms/clinical/conceptCreator.js @@ -1216,7 +1216,7 @@ export default class ConceptCreator { const isImportedItem = concept?.details?.phenotype_owner && !!concept?.details?.requested_entity_id && concept?.details?.phenotype_owner !== concept?.details?.requested_entity_id; const html = interpolateString(template, { 'subheader': access ? 'Codelist' : 'Imported Codelist', - 'concept_name': access ? concept?.details?.name : this.#getImportedName(concept), + 'concept_name': access ? strictSanitiseString(concept?.details?.name) : this.#getImportedName(concept), 'concept_id': concept?.concept_id, 'concept_version_id': concept?.concept_version_id, 'coding_id': concept?.coding_system?.id, @@ -1231,7 +1231,7 @@ export default class ConceptCreator { }); const containerList = this.element.querySelector('#concept-content-list'); - const doc = parseHTMLFromString(html); + const doc = parseHTMLFromString(html, true); const conceptItem = containerList.appendChild(doc.body.children[0]); conceptItem.setAttribute('live', true); @@ -1495,7 +1495,7 @@ export default class ConceptCreator { const html = interpolateString(template, { 'id': rule?.id, 'index': index, - 'name': rule?.name, + 'name': strictSanitiseString(rule?.name), 'source': (isNullOrUndefined(source) && sourceInfo.template == 'file-rule') ? 'Unknown File' : (source || ''), 'used_code': !rule?.used_description ? 'checked' : '', 'used_description': rule?.used_description ? 'checked' : '', @@ -1503,7 +1503,7 @@ export default class ConceptCreator { 'was_wildcard_sensitive': rule?.was_wildcard_sensitive ? 'checked' : '', }); - const doc = parseHTMLFromString(html); + const doc = parseHTMLFromString(html, true); const item = ruleList.appendChild(doc.body.children[0]); const input = item.querySelector('input[data-item="rule"]'); @@ -1727,18 +1727,17 @@ export default class ConceptCreator { accordion.classList.add('is-open'); conceptGroup.setAttribute('editing', true); - const systemOptions = await this.#fetchCodingOptions(dataset); const template = this.templates['concept-editor']; const html = interpolateString(template, { - 'concept_name': dataset?.details?.name, + 'concept_name': strictSanitiseString(dataset?.details?.name), 'coding_system_id': dataset?.coding_system?.id, 'coding_system_options': systemOptions, 'has_inclusions': false, 'has_exclusions': false, }); - const doc = parseHTMLFromString(html); + const doc = parseHTMLFromString(html, true); const editor = conceptGroup.appendChild(doc.body.children[0]); this.state.data = dataset; this.state.editor = editor; diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/forms/clinical/conceptSelectionService.js b/CodeListLibrary_project/cll/static/js/clinicalcode/forms/clinical/conceptSelectionService.js index c8b543c1b..106d48a29 100644 --- a/CodeListLibrary_project/cll/static/js/clinicalcode/forms/clinical/conceptSelectionService.js +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/forms/clinical/conceptSelectionService.js @@ -407,7 +407,7 @@ const CSEL_FILTER_GENERATORS = { datatype: data.details.type, }); - let doc = parseHTMLFromString(html); + let doc = parseHTMLFromString(html, true); let group = container.appendChild(doc.body.children[0]); let descendants = group.querySelector('.filter-group'); for (let i = 0; i < data.options.length; ++i) { @@ -418,7 +418,7 @@ const CSEL_FILTER_GENERATORS = { field: data.details.field, value: option.value, }); - doc = parseHTMLFromString(html); + doc = parseHTMLFromString(html, true); descendants.appendChild(doc.body.children[0]); } @@ -433,14 +433,14 @@ const CSEL_FILTER_GENERATORS = { datatype: data.details.type, }); - let doc = parseHTMLFromString(html); + let doc = parseHTMLFromString(html, true); return container.appendChild(doc.body.children[0]); }, // creates a searchbar filter group SEARCHBAR: (container, data) => { let html = CSEL_FILTER_COMPONENTS.SEARCHBAR_GROUP; - let doc = parseHTMLFromString(html) + let doc = parseHTMLFromString(html, true) return container.appendChild(doc.body.children[0]); }, } @@ -849,7 +849,7 @@ export class ConceptSelectionService { hidden: 'false', }); - let doc = parseHTMLFromString(html); + let doc = parseHTMLFromString(html, true); let modal = document.body.appendChild(doc.body.children[0]); // create footer @@ -863,11 +863,11 @@ export class ConceptSelectionService { // create buttons const buttons = { }; - let confirmBtn = parseHTMLFromString(CSEL_BUTTONS.CONFIRM); + let confirmBtn = parseHTMLFromString(CSEL_BUTTONS.CONFIRM, true); confirmBtn = footer.appendChild(confirmBtn.body.children[0]); confirmBtn.innerText = this.options.promptConfirm; - let cancelBtn = parseHTMLFromString(CSEL_BUTTONS.CANCEL); + let cancelBtn = parseHTMLFromString(CSEL_BUTTONS.CANCEL, true); cancelBtn = footer.appendChild(cancelBtn.body.children[0]); cancelBtn.innerText = this.options.promptCancel; @@ -888,7 +888,7 @@ export class ConceptSelectionService { let contentContainer = body; if (this.options.allowMultiple) { html = CSEL_INTERFACE.TAB_VIEW; - doc = parseHTMLFromString(html); + doc = parseHTMLFromString(html, true); contentContainer = body.appendChild(doc.body.children[0]); const tabs = contentContainer.querySelectorAll('button.tab-view__tab'); @@ -981,7 +981,7 @@ export class ConceptSelectionService { #renderSearchView() { // Draw page let html = CSEL_INTERFACE.SEARCH_VIEW; - let doc = parseHTMLFromString(html); + let doc = parseHTMLFromString(html, true); let page = this.dialogue.content.appendChild(doc.body.children[0]); this.dialogue.page = page; @@ -1011,7 +1011,7 @@ export class ConceptSelectionService { noneSelectedMessage: this.options?.noneSelectedMessage, }); - let doc = parseHTMLFromString(html); + let doc = parseHTMLFromString(html, true); let page = this.dialogue.content.appendChild(doc.body.children[0]); this.dialogue.page = page; @@ -1083,7 +1083,7 @@ export class ConceptSelectionService { 'index': i, }); - let doc = parseHTMLFromString(html); + let doc = parseHTMLFromString(html, true); let checkbox = content.appendChild(doc.body.children[0]); checkbox.addEventListener('change', this.#handleSelectedItem.bind(this)); } @@ -1197,7 +1197,7 @@ export class ConceptSelectionService { 'next_disabled': !response?.details?.has_next, }); - let doc = parseHTMLFromString(html); + let doc = parseHTMLFromString(html, true); let pagination = pageContainer.appendChild(doc.body.children[0]); this.filters['page'] = { @@ -1251,7 +1251,7 @@ export class ConceptSelectionService { 'tags': '', }); - let doc = parseHTMLFromString(html); + let doc = parseHTMLFromString(html, true); let card = resultContainer.appendChild(doc.body.children[0]); let datagroup = card.querySelector('#datagroup'); @@ -1276,7 +1276,7 @@ export class ConceptSelectionService { title: `Available Concepts (${children.length})`, content: childContents, }); - doc = parseHTMLFromString(html); + doc = parseHTMLFromString(html, true); let accordion = datagroup.appendChild(doc.body.children[0]); let checkboxes = accordion.querySelectorAll('#child-selector > input[type="checkbox"]'); diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/forms/entityCreator/creator.js b/CodeListLibrary_project/cll/static/js/clinicalcode/forms/entityCreator/creator.js index b65d382e5..e3c13c4d7 100644 --- a/CodeListLibrary_project/cll/static/js/clinicalcode/forms/entityCreator/creator.js +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/forms/entityCreator/creator.js @@ -272,9 +272,7 @@ export default class EntityCreator { this.initialisedData = data; return content; }) - .then(content => { - this.#redirectFormClosure(content); - }) + .then(content => this.#redirectFormClosure(content)) .catch(error => { if (typeof error.json === 'function') { this.#handleAPIError(error); @@ -573,7 +571,7 @@ export default class EntityCreator { continue; } - this.form[field].handler = createFormHandler(pkg.element, cls, this.data); + this.form[field].handler = createFormHandler(pkg.element, cls, this.data, pkg?.validation); this.form[field].dataclass = cls; } diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/forms/entityCreator/utils.js b/CodeListLibrary_project/cll/static/js/clinicalcode/forms/entityCreator/utils.js index 265b3a330..792862024 100644 --- a/CodeListLibrary_project/cll/static/js/clinicalcode/forms/entityCreator/utils.js +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/forms/entityCreator/utils.js @@ -111,12 +111,13 @@ export const ENTITY_HANDLERS = { value = value.split(/[\.\,\-]/) .map(date => moment(date.trim(), ENTITY_ACCEPTABLE_DATE_FORMAT)) - .filter(date => date.isValid()) - .slice(0, 2) - .sort((a, b) => a.diff(b)) - .map(date => date.format('YYYY-MM-DD')); + .slice(0, 2); - const [start, end] = value; + if (value.every(x => x.isValid())) { + value = value.sort((a, b) => a.diff(b)); + } + + const [start, end] = value.map(x => x.isValid() ? x.format('YYYY-MM-DD') : undefined); startDateInput.setAttribute('value', start); endDateInput.setAttribute('value', end); }, @@ -481,29 +482,44 @@ export const ENTITY_FIELD_COLLECTOR = { const startDateInput = element.querySelector(`#${id}-startdate`); const endDateInput = element.querySelector(`#${id}-enddate`); + const validation = packet?.validation; + const dateClosureOptional = typeof validation === 'object' && validation?.date_closure_optional; + + const startValid = startDateInput.checkValidity(), + endValid = endDateInput.checkValidity(); + + const meetsCriteria = (startValid && endValid) || (dateClosureOptional && (startValid || endValid)); let value; - if (startDateInput.checkValidity() && endDateInput.checkValidity()) { + if (meetsCriteria) { let dates = [moment(startDateInput.value, ['YYYY-MM-DD']), moment(endDateInput.value, ['YYYY-MM-DD'])] dates = dates.sort((a, b) => a.diff(b)) - .filter(date => date.isValid()); - if (dates.length === 2) { - let [ startDate, endDate ] = dates.map(date => date.format(ENTITY_DATEPICKER_FORMAT)); - value = `${startDate} - ${endDate}`; - } - } - - if (isMandatoryField(packet)) { - if (!startDateInput.checkValidity() || !endDateInput.checkValidity() || isNullOrUndefined(value) || isStringEmpty(value)) { - return { - valid: false, - value: value, - message: (isNullOrUndefined(value) || isStringEmpty(value)) ? ENTITY_TEXT_PROMPTS.REQUIRED_FIELD : ENTITY_TEXT_PROMPTS.INVALID_FIELD - } + const count = dates.reduce((filtered, x) => x.isValid() ? ++filtered : filtered, 0); + switch (count) { + case 1: { + if (dateClosureOptional) { + const [ startDate, endDate ] = dates.map(date => date.isValid() ? date.format(ENTITY_DATEPICKER_FORMAT) : ''); + value = `${startDate} - ${endDate}`; + } + } break; + + case 2: { + const [ startDate, endDate ] = dates.map(date => date.format(ENTITY_DATEPICKER_FORMAT)); + value = `${startDate} - ${endDate}`; + } break; + + default: + break; } } - if (isNullOrUndefined(value) || isStringEmpty(value)) { + if (isMandatoryField(packet) && (!meetsCriteria || isNullOrUndefined(value) || isStringEmpty(value))) { + return { + valid: false, + value: value, + message: (isNullOrUndefined(value) || isStringEmpty(value)) ? ENTITY_TEXT_PROMPTS.REQUIRED_FIELD : ENTITY_TEXT_PROMPTS.INVALID_FIELD + }; + } else if (isNullOrUndefined(value) || isStringEmpty(value)) { return { valid: true, value: null, @@ -947,12 +963,12 @@ export const getTemplateFields = (template) => { * @param {string} cls The data-class attribute value of that particular element * @return {object} An interface to control the behaviour of the component */ -export const createFormHandler = (element, cls, data) => { +export const createFormHandler = (element, cls, data, validation = undefined) => { if (!ENTITY_HANDLERS.hasOwnProperty(cls)) { return; } - return ENTITY_HANDLERS[cls](element, data); + return ENTITY_HANDLERS[cls](element, data, validation); } /** @@ -1004,14 +1020,36 @@ export const parseAsFieldType = (packet, value) => { case 'string': { value = String(value); - + const pattern = validation?.regex; if (isNullOrUndefined(pattern)) { valid = true; break; } - valid = new RegExp(pattern).test(value); + try { + if (typeof pattern === 'string') { + valid = new RegExp(pattern).test(value); + } else if (Array.isArray(pattern)) { + let test = undefined, i = 0; + while (i < pattern.length) { + test = pattern[i]; + if (typeof test !== 'string') { + continue; + } + + valid = new RegExp(test).test(value); + if (valid) { + break; + } + } + } + } + catch (e) { + console.error(`Failed to test String with err: ${e}`); + valid = false; + } + } break; case 'string_array': { diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/forms/generic/ontologySelector/index.js b/CodeListLibrary_project/cll/static/js/clinicalcode/forms/generic/ontologySelector/index.js index 833b4867d..b8b67dfe8 100644 --- a/CodeListLibrary_project/cll/static/js/clinicalcode/forms/generic/ontologySelector/index.js +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/forms/generic/ontologySelector/index.js @@ -8,7 +8,6 @@ import VirtualisedList, { DebouncedTask } from '../../../components/virtualisedL * directed acyclic graphs that define taggable ontologies * */ - export default class OntologySelectionService { static DataTarget = 'ontology-service'; #originalValue = null; @@ -27,6 +26,15 @@ export default class OntologySelectionService { .then(dataset => { this.dataset.splice(this.dataset.length, 0, ...dataset); this.#initialiseTree(); + + // this.#fetchTypeahead('atrial') + // .then(res => this.#appendTypeaheadNode(res?.[0]?.id, res?.[0]?.type_id)) + // .then(console.log) + // .catch(console.error); + + if (componentData && componentData?.value) { + this.#computeComponentValue(componentData?.value, true); + } }) .catch(console.error); } @@ -206,7 +214,7 @@ export default class OntologySelectionService { this.options = mergeObjects(options || { }, Constants.OPTIONS); this.#initialiseTree(); - this.#computeComponentValue(componentData?.value); + this.#computeComponentValue(componentData?.value, true); // Initialise element & child template(s) const button = this.element.querySelector('#add-input-btn'); @@ -292,14 +300,19 @@ export default class OntologySelectionService { /** * computeComponentValue * @desc computes the component data, given a dict containing the ancestry & value - * @param {object} param + * + * @param {object} param * @param {object|null} param.ancestors optional ancestor-related data to be spliced into the tree * @param {array|null} param.value optional value for this field, e.g. when editing + * @param {boolean} setInitValue specify whether to set the initial value of this component + * * @returns {object} this class for chaining * */ - #computeComponentValue({ ancestors = undefined, value = [] } = { }) { - this.value = Array.isArray(value) ? value : []; + #computeComponentValue({ ancestors = undefined, value = [] } = { }, setInitValue = false) { + if (setInitValue) { + this.value = Array.isArray(value) ? value : []; + } if (Array.isArray(ancestors)) { OntologySelectionService.applyToTree(ancestors, elem => { @@ -338,9 +351,10 @@ export default class OntologySelectionService { elem.children.push(child); } } else { + node.children = Array.isArray(node?.children) ? node.children : []; elem.children = node.children; } - elem.parents = node.parents; + elem.parents = Array.isArray(node?.parents) ? node.parents : []; return true; }); @@ -358,7 +372,96 @@ export default class OntologySelectionService { selected.processed = true; } - this.#originalValue = deepCopy(this.value); + if (setInitValue) { + this.#originalValue = deepCopy(this.value); + } + } + + /** + * @desc typeahead search query fn (searches only current template source types) + * + * @param {string} searchterm expects min char len of 3 + * + * @returns {Promise} an array of nodes that meet the search criteria + */ + async #fetchTypeahead(searchterm = '') { + if (isStringEmpty(searchterm) || isStringWhitespace(searchterm) || searchterm.length < 3) { + return []; + } + + const parameters = new URLSearchParams({ + search: searchterm, + type_ids: this.dataset.reduce((filtered, x) => { + if (typeof x?.model?.source === 'number') { + filtered.push(x.model.source); + } + + return filtered; + }, []), + }); + + return fetch( + `${getCurrentURL()}?` + parameters, + { + method: 'GET', + headers: { + 'X-Target': 'query_ontology_typeahead', + 'X-Requested-With': 'XMLHttpRequest', + } + } + ) + .then(response => response.json()) + .then(response => { + if (!Array.isArray(response?.result)) { + return []; + } + + return response.result; + }) + } + + + /** + * @desc fetches a known node by its ID & type resolved from a typeahead query + * + * @param {number} nodeId expects min char len of 3 + * @param {number} typeId expects min char len of 3 + * + * @returns {Promise} thenable, resolving a nullable array if successful + */ + async #fetchKnownNode(nodeId, typeId) { + if (typeof nodeId !== 'number' || typeof typeId !== 'number') { + return Promise.reject(new Error('Expected nodeId and typeId to be typeof number')); + } + + const parameters = new URLSearchParams({ node_id: nodeId, type_id: typeId }); + return fetch( + `${getCurrentURL()}?` + parameters, + { + method: 'GET', + headers: { + 'X-Target': 'query_ontology_record', + 'X-Requested-With': 'XMLHttpRequest', + } + } + ) + .then(async response => { + if (!response.ok) { + return response.text() + .then(text => { + throw new Error(`Failed to retrieve known node Err and message:\n${text ?? 'Unknown err'}`); + }) + } + + return response.json(); + }) + .then(response => { + if (!typeof response === 'object' || !Array.isArray(response?.ancestors) || !Array.isArray(response?.value)) { + return null; + } + + return response; + }) } /** @@ -394,6 +497,139 @@ export default class OntologySelectionService { return res; } + async #appendTypeaheadNode(nodeId, typeId) { + return this.#fetchKnownNode(nodeId, typeId) + .then(obj => { + if (!Array.isArray(obj?.value) || !Array.isArray(obj?.ancestors)) { + throw new Error(`Node contained no results on fetch`); + } + + const treeComponent = this.renderable.treeComponent; + + return obj; + }) + } + + /** + * @desc attempts to fetch & load some node resource by its ID + * + * @param {object} [param0={}] the data object describing the node + * @param {number} [id=undefined] the node id + * @param {number} [type_id=undefined] the node source type id + * @param {Array} [nodeChildren=undefined] the node's current children, if any + * @param {Function} [callback=undefined] optionally define some callback to process the loading + * + * @returns {Promise} some thenable whose status will evaluate to the success of the operation + */ + async #buildNode({ id = undefined, type_id: sourceId = undefined } = { }, nodeChildren = undefined, callback = undefined) { + if (isNullOrUndefined(id) || isNullOrUndefined(sourceId)) { + return Promise.resolve(); + } + + const dataIndex = this.dataset.findIndex(e => e?.model?.source == sourceId); + const dataset = dataIndex >= 0 ? this.dataset[dataIndex] : null; + if (isNullOrUndefined(dataset)) { + return Promise.reject(new Error(`Expected dataset at Group but got null`)); + } + + let children = nodeChildren; + if (!Array.isArray(nodeChildren)) { + children = []; + } + + const treeComponent = this.renderable.treeComponent; + return this.#fetchNodeData(id) + .then(async node => { + const isRoot = node.isRoot; + const isLeaf = node.isLeaf; + + OntologySelectionService.applyToTree(node.children, elem => { + if (typeof(elem?.label) === 'string' && !elem?.processed) { + elem.label = OntologySelectionService.getLabel(elem); + elem.processed = true; + } + }); + + if (isRoot) { + for (let i = 0; i < dataset.nodes.length; ++i) { + const elem = dataset.nodes[i]; + const { id } = elem; + if (id === node.id) { + const newChildren = node.children.filter(x => elem?.children && elem?.children?.findIndex(e => e.id === x.id) < 0); + const newAncestors = node.parents.filter(x => elem?.parents && elem?.parents?.findIndex(e => e.id === x.id) < 0); + + if (newChildren.length > 0) { + elem.children = [...deepCopy(newChildren), ...(elem?.children ?? [])]; + } + + if (newAncestors.length > 0) { + elem.parents = [...deepCopy(newAncestors), ...(elem?.parents ?? [])]; + } + } + } + } else if (!isLeaf) { + OntologySelectionService.applyToTree(dataset.nodes, elem => { + const { id } = elem; + if (id === node.id) { + const newChildren = node.children.filter(x => elem?.children && elem?.children?.findIndex(e => e.id === x.id) < 0); + const newAncestors = node.parents.filter(x => elem?.parents && elem?.parents?.findIndex(e => e.id === x.id) < 0); + + if (newChildren.length > 0) { + elem.children = [...deepCopy(newChildren), ...(elem?.children ?? [])]; + } + + if (newAncestors.length > 0) { + elem.parents = [...deepCopy(newAncestors), ...(elem?.parents ?? [])]; + } + } + }); + } + + const mapped = { }; + children = deepCopy(node?.children); + for (let i = 0; i < children.length; ++i) { + let child = children[i]; + let parents = child?.parents; + if (!Array.isArray(parents)) { + continue; + } + + for (let j = 0; j < parents.length; ++j) { + let parentId = parents[j]; + if (isNullOrUndefined(parentId) || parentId === node.id) { + continue; + } + + if (!mapped.hasOwnProperty(parentId)) { + mapped[parentId] = []; + } + + if (mapped[parentId].findIndex(x => x?.id === child.id) < 0) { + mapped[parentId].push(child); + } + } + } + + OntologySelectionService.applyToTree(treeComponent.getAllNodeData(), elem => { + const { id } = elem; + if (mapped[id]) { + if (!Array.isArray(elem.children)) { + elem.children = deepCopy(mapped[id]); + } else { + const related = mapped[id].filter(e => elem.children.findIndex(x => x.id === e.id) < 0); + for (let i = 0; i < related; ++i) { + elem.children.push(deepCopy(related[i])); + } + } + } + }); + + callback?.(children); + }) + .then(() => this.#resolveSelectedItems() && this.#toggleDeselectorButtons()) + .then(() => { treeComponent.setChecked(this.selectedItems.map(x => x.id)) }) + } + /** * pushDataset * @desc event to handle the changes required by eleTree @@ -529,6 +765,39 @@ export default class OntologySelectionService { * * *************************************/ + /** + * @desc toggles the deselection buttons + */ + #toggleDeselectorButtons() { + if (!this.isOpen()) { + return; + } + + const dialogue = this.renderable.dialogue; + const activeId = this.activeItem?.model?.source + + const activeSelector = dialogue?.container?.querySelector('[data-target="deselect-available-group"]'); + const activeSelections = this.selectedItems?.reduce((filtered, e) => { + const id = e?.id; + const sourceId = e?.type_id; + if (!isNullOrUndefined(id) && sourceId === activeId) { + filtered++; + } + + return filtered; + }, 0) ?? 0; + + if (activeSelector) { + activeSelector.setAttribute('disabled', activeSelections <= 0); + } + + const allSelector = dialogue?.container?.querySelector('[data-target="deselect-all-group"]'); + const totalSelections = this.selectedItems?.length ?? 0; + if (allSelector) { + allSelector.setAttribute('disabled', totalSelections <= 0); + } + } + /** * showDialogue * @desc internal renderable handler to instantiate @@ -579,6 +848,8 @@ export default class OntologySelectionService { if (checked.length > 0) { this.renderable?.treeComponent.setChecked(checked, true); } + + this.#toggleDeselectorButtons(); } } this.renderable = renderable; @@ -652,7 +923,7 @@ export default class OntologySelectionService { label: dataset.model.label, }); - let component = parseHTMLFromString(html); + let component = parseHTMLFromString(html, true); component = ontologyContainer.appendChild(component.body.children[0]); let active = parseInt(component.getAttribute('data-source')) === activeId; @@ -710,17 +981,18 @@ export default class OntologySelectionService { }, onRender: (index, height) => { let selectedItem = selectedItems[index]; + if (!selectedItem) { return document.createElement('div'); } const html = interpolateString(this.templates.item, { - id: selectedItem?.id, - source: selectedItem?.type_id, - label: selectedItem?.label, + id: selectedItem.id, + source: selectedItem.type_id.toString(), + label: selectedItem.label, }); - let component = parseHTMLFromString(html); + let component = parseHTMLFromString(html, true); component = component.body.children[0]; const btn = component.querySelector('[data-target="delete"]'); @@ -736,6 +1008,12 @@ export default class OntologySelectionService { this.renderable.selectionComponent = selectionComponent; this.renderable.resizeObserverGroup = resizeObserverGroup this.#toggleSelectionView(selectedLength); + + const allSelector = dialogue.container.querySelector('[data-target="deselect-all-group"]'); + allSelector.addEventListener('click', this.#handleDeselectorButton.bind(this)); + + const activeSelector = dialogue.container.querySelector('[data-target="deselect-available-group"]'); + activeSelector.addEventListener('click', this.#handleDeselectorButton.bind(this)); } /** @@ -809,13 +1087,13 @@ export default class OntologySelectionService { label: sources[type_id], }); - let component = parseHTMLFromString(html); + let component = parseHTMLFromString(html, true); component = ontologyList.appendChild(component.body.children[0]); } let html = interpolateString(this.templates.value, { label: label }); - let component = parseHTMLFromString(html); + let component = parseHTMLFromString(html, true); component = ontologyList.appendChild(component.body.children[0]); } } @@ -839,6 +1117,65 @@ export default class OntologySelectionService { this.#showDialogue(); } + /** + * handleDeselectorButton + * @desc handles the 'Deselect all' and 'Deselect items' button(s) + * @param {event} e the assoc. event + */ + #handleDeselectorButton(e) { + if (!this.isOpen()) { + return; + } + + const target = e.target; + if (isNullOrUndefined(target) || target.getAttribute('disabled') == 'true') { + return; + } + + const activeId = this.activeItem?.model?.source; + const btnTarget = target.getAttribute('data-target'); + + let removable; + switch (btnTarget) { + case 'deselect-all-group': { + removable = this.selectedItems.slice(0); + } break; + + case 'deselect-available-group': { + removable = this.selectedItems?.reduce((filtered, e) => { + const id = e?.id; + const sourceId = e?.type_id; + if (!isNullOrUndefined(id) && sourceId === activeId) { + filtered.push(e); + } + + return filtered; + }, []); + } break; + + default: + break + } + + if (removable) { + for (let i = 0; i < removable.length; ++i) { + const { id: targetId, type_id: targetSourceId } = removable[i]; + + if (targetSourceId === activeId) { + this.renderable.treeComponent.unChecked([parseInt(targetId)]); + } else { + const index = this.selectedItems.findIndex(x => x?.id === targetId && x?.type_id === targetSourceId); + if (index >= 0) { + this.selectedItems.splice(index, 1); + } + } + } + + this.#resolveSelectedItems(); + this.#toggleDeselectorButtons(); + } + } + /** * handleDeleteButton * @desc handles the trash icon button for selected item(s) @@ -867,6 +1204,7 @@ export default class OntologySelectionService { } } this.#resolveSelectedItems(); + this.#toggleDeselectorButtons(); } /** @@ -898,91 +1236,12 @@ export default class OntologySelectionService { if (!isNullOrUndefined(children)) { load([]); - this.#resolveSelectedItems() + this.#resolveSelectedItems(); + this.#toggleDeselectorButtons(); return; } - const treeComponent = this.renderable.treeComponent; - this.#fetchNodeData(data.id) - .then(async node => { - const isRoot = node.isRoot; - const isLeaf = node.isLeaf; - - OntologySelectionService.applyToTree(node.children, elem => { - if (typeof(elem?.label) === 'string' && !elem?.processed) { - elem.label = OntologySelectionService.getLabel(elem); - elem.processed = true; - } - }); - - if (isRoot) { - for (let i = 0; i < dataset.nodes.length; ++i) { - const elem = dataset.nodes[i]; - const { id } = elem; - if (id === node.id) { - const newChildren = node.children.filter(x => !elem?.children || elem.children.findIndex(e => e.id === x.id) < 0); - const newAncestors = node.parents.filter(x => !elem?.parents || !elem.parents.includes(x.id)); - elem.children = [...deepCopy(newChildren), ...(elem?.children || [])]; - elem.parents = [...deepCopy(newAncestors), ...(elem?.parents || [])]; - } - } - } else if (!isLeaf) { - OntologySelectionService.applyToTree(dataset.nodes, elem => { - const { id } = elem; - if (id === node.id) { - const newChildren = node.children.filter(x => !elem?.children || elem.children.findIndex(e => e.id === x.id) < 0); - const newAncestors = node.parents.filter(x => !elem?.parents || !elem.parents.includes(x.id)); - elem.children = [...deepCopy(newChildren), ...(elem?.children || [])]; - elem.parents = [...deepCopy(newAncestors), ...(elem?.parents || [])]; - } - }); - } - - const mapped = { }; - children = deepCopy(node.children); - for (let i = 0; i < children.length; ++i) { - let child = children[i]; - let parents = child?.parents; - if (!Array.isArray(parents)) { - continue; - } - - for (let j = 0; j < parents.length; ++j) { - let parentId = parents[j]; - if (isNullOrUndefined(parentId) || parentId === node.id) { - continue; - } - - if (!mapped.hasOwnProperty(parentId)) { - mapped[parentId] = []; - } - - if (!mapped[parentId].includes(child.id)) { - mapped[parentId].push(child); - } - } - } - - OntologySelectionService.applyToTree(treeComponent.getAllNodeData(), elem => { - const { id } = elem; - if (mapped[id]) { - if (!Array.isArray(elem.children)) { - elem.children = [...deepCopy(mapped[id])]; - } else { - const related = mapped[id].filter(e => elem.children.findIndex(x => x.id === e.id) < 0); - for (let i = 0; i < related; ++i) { - elem.children.push(deepCopy(related[i])); - } - } - } - }); - - load(children); - }) - .then(() => this.#resolveSelectedItems()) - .then(() => treeComponent.setChecked( - this.selectedItems.map(x => x.id) - )) + this.#buildNode(data, children, load) .catch(console.error); } @@ -1025,5 +1284,6 @@ export default class OntologySelectionService { this.#resolveSelectedItems(); this.renderable.treeComponent.setChecked(this.selectedItems.map(x => x.id)); + this.#toggleDeselectorButtons(); } } diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/forms/generic/ontologySelector/modal.js b/CodeListLibrary_project/cll/static/js/clinicalcode/forms/generic/ontologySelector/modal.js index 742601105..3f5fcf914 100644 --- a/CodeListLibrary_project/cll/static/js/clinicalcode/forms/generic/ontologySelector/modal.js +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/forms/generic/ontologySelector/modal.js @@ -1,7 +1,7 @@ import * as Constants from './constants.js'; /** - * @class OntologySelectionService + * @class OntologySelectionModal * @desc Class that allows the selection of items from arbitrary * directed acyclic graphs that define taggable ontologies * @@ -167,7 +167,7 @@ export default class OntologySelectionModal { modalConfirm: this.options.modalConfirm, }); - let modal = parseHTMLFromString(html); + let modal = parseHTMLFromString(html, true); modal = document.body.appendChild(modal.body.children[0]); let buttons = modal.querySelectorAll('#target-modal-footer > button'); diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/services/referenceDataService.js b/CodeListLibrary_project/cll/static/js/clinicalcode/services/referenceDataService.js index 7643623f9..91129e508 100644 --- a/CodeListLibrary_project/cll/static/js/clinicalcode/services/referenceDataService.js +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/services/referenceDataService.js @@ -72,10 +72,40 @@ const getReferenceData = () => { * * @param {string|any} key the data associated key * @param {node} container the relevant container node - * @param {object} data the associated data object + * @param {object} groups the associated data object * @returns */ -const renderTreeViewComponent = (key, container, sources) => { +const renderTreeViewComponent = async (key, container, _groups) => { + const sources = await fetch( + `${getBrandedHost()}/api/v1/ontology/`, + { + method: 'GET', + headers: { + 'X-Target': 'get_options', + 'X-Requested-With': 'XMLHttpRequest', + } + } + ) + .then(response => { + if (!response.ok) { + throw new Error(`Failed to GET ontology options with Err and response:\n${response}`); + } + + return response.json(); + }) + .then(dataset => { + if (!(dataset instanceof Object) || !Array.isArray(dataset)) { + throw new Error(`Expected ontology init data to be an object with a result array, got ${dataset}`); + } + + return dataset; + }); + + if (!Array.isArray(sources)) { + console.warn('Failed to load tree component with err:', sources); + return; + } + const tabItems = container.querySelector('#tab-items'); const tabContent = container.querySelector('#tab-content'); @@ -196,7 +226,7 @@ const renderTreeViewComponent = (key, container, sources) => { - `); + `, true); const elem = tabItems.appendChild(doc.body.children[0]); elem.addEventListener('click', () => { diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/strings.js b/CodeListLibrary_project/cll/static/js/clinicalcode/strings.js index 4857abad9..72d5b5dc9 100644 --- a/CodeListLibrary_project/cll/static/js/clinicalcode/strings.js +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/strings.js @@ -5,6 +5,7 @@ import DOMPurify from '../lib/purify.min.js'; * @desc strictly sanitise the given string (remove html, svg, mathML) * * @param {string} str + * * @return {str} The sanitised string */ window.strictSanitiseString = (dirty, opts) => { @@ -27,9 +28,10 @@ window.strictSanitiseString = (dirty, opts) => { * @desc Interpolate string template * Ref @ https://stackoverflow.com/posts/41015840/revisions * - * @param {string} str The string to interpolate - * @param {object} params The parameters + * @param {string} str The string to interpolate + * @param {object} params The parameters * @param {boolean|any} noSanitise Skip string sanitisation + * * @return {str} The interpolated string * */ @@ -47,14 +49,17 @@ window.interpolateString = (str, params, noSanitise) => { /** * parseHTMLFromString * @desc given a string of HTML, will return a parsed DOM - * @param {str} str The string to parse as DOM elem + * + * @param {str} str The string to parse as DOM elem * @param {boolean|any} noSanitise Skip string sanitisation + * @param {vararg} params1 Sanitiser args + * * @returns {DOM} the parsed html */ -window.parseHTMLFromString = (str, noSanitise) => { +window.parseHTMLFromString = (str, noSanitise, ...sanitiseArgs) => { const parser = new DOMParser(); if (!noSanitise) { - str = DOMPurify.sanitize(str); + str = DOMPurify.sanitize(str, ...sanitiseArgs); } return parser.parseFromString(str, 'text/html'); diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/utils.js b/CodeListLibrary_project/cll/static/js/clinicalcode/utils.js index c0be36710..45f8e611c 100644 --- a/CodeListLibrary_project/cll/static/js/clinicalcode/utils.js +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/utils.js @@ -37,8 +37,16 @@ const * @desc Regex pattern to match urls * */ - CLU_TRIAL_LINK_PATTERN = /^(https?:\/\/)([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,6}(:\d+)?(\/\S*)?$/gm; - + CLU_TRIAL_LINK_PATTERN = /^(https?:\/\/)([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,6}(:\d+)?(\/\S*)?$/gm, + /** + * ES_REGEX_URL + * @desc Regex pattern matching URLs + * @type {RegExp} + */ + CLU_URL_PATTERN = new RegExp( + /((https?|ftps?):\/\/[^"<\s]+)(?![^<>]*>|[^"]*?<\/a)/, + 'gm' + ); /* METHODS */ @@ -947,3 +955,151 @@ const isElementSizeExplicit = (element, axes = undefined) => { return results; } + +/** + * linkifyText + * @desc process a plain-text string, finding all valid URLs and converts them to HTML tags as specified by the given options + * + * @example + * const source = `Some block of text describing URLs: + * 1. http://some-website.co.uk + * 2. https://some-url.co.uk/ + * 3. The inner link here processed:

http://website.com

+ * 4. This anchor is ignored: http://ignored.com + * 5. This link is ignored:
+ * 6. http://some-removed-link.com + * 7. http://some-replaced-link.com + * 8. http://retarget-underlying-text.com + * `; + * + * const result = linkifyText( + * // The text to process + * source, + * // Optionally specify a set of options + * { + * // Optionally specify default anchor attributes + * cls: 'some-anchor-class', + * rel: 'noopener', + * trg: '_blank', + * // Optionally specify a callback to process the url + * // -> [Fallthrough]: return `null | undefined` to accept defaults + * // -> [ Retarget]: return `{ retarget: string }` to retarget the underlying text + * // -> [ Deletion]: return `{ remove: true }` to remove the link entirely + * // -> [ Replace]: return `{ replace: string }` to skip linkify and replace with text + * // -> [VaryContent]: return `{ url?: string, title?: string, rel?: string, trg?: string, cls?: string }` + * anchorCallback: (url, offset, text) => { + * const idx = text.lastIndexOf('.', offset); + * const pos = text.substring(idx - 1, idx); + * if (pos === '6') { + * return { remove: true }; + * } else if (pos === '7') { + * return { replace: '{REDACTED}' }; + * } else if (pos === '8') { + * return { retarget: text.substring(0, idx - 1) + text.substring(offset + url.length) }; + * } + * + * return { title: 'Linkified', rel: 'noopener noreferrer' }; + * }, + * // Optionally specify a callback to render the URL + * // -> The arguments given to the callback are derived from the default set and may be varied by the `anchorCallback` + * // -> Returning an empty or non-string-like object will + * renderCallback: (url, title, rel, trg, cls) => { + * return `${title}`; + * }, + * } + * ); + * + * @param {string} source the string to process + * @param {Object} [param1] optionally specify a set of options that vary this function's behaviour + * @param {string} [param1.cls] optionally specify the anchor's `class` attribute + * @param {string} [param1.rel='noopener'] optionally specify the relationship between the linked resource and this document; defaults to `noopener` + * @param {string} [param1.trg='_blank'] optionally specify how to display the linked resource; defaults to `_blank` + * @param {Function} [param1.anchorCallback=undefined] optionally specify a callback to vary processed links; defaults to `undefined` + * @param {Function} [param1.renderCallback=undefined] optionally specify a callback to render processed links; defaults to `undefined` + * + * @returns {string} the processed text + */ +const linkifyText = ( + source, + { cls = '', rel = 'noopener', trg = '_blank', anchorCallback = undefined, renderCallback = undefined } = {} +) => { + if (isNullOrUndefined(source) || isStringEmpty(source) || isStringWhitespace(source)) { + return ''; + } + + if (typeof cls !== 'string') { + cls = ''; + } + + if (typeof rel !== 'string') { + rel = ''; + } + + if (typeof trg !== 'string') { + trg = ''; + } + + const hasAnchorCallback = typeof anchorCallback === 'function', + hasRenderCallback = typeof renderCallback === 'function'; + + let url, len, uTitle, uRel, uTarget, uClass; + while ((match = CLU_URL_PATTERN.exec(source)) != null) { + url = match[0], offset = match.index, len = url.length; + uTitle = url, uRel = rel, uTarget = trg, uClass = cls; + if (hasAnchorCallback) { + const result = anchorCallback(url, offset, match.input); + if (typeof result === 'object') { + if (result?.replace) { + const str = typeof result.replace === 'string' ? result.replace : String(result.replace); + source = source.substring(0, offset) + str + source.substring(offset + len); + continue; + } else if (result?.retarget) { + source = result.retarget; + continue; + } + + const shouldDelete = result?.remove; + if (!shouldDelete) { + if (typeof result?.url === 'string') { + if (!isStringEmpty(result.url)) { + url = result.url; + } else { + shouldDelete = true; + } + } + + if (typeof result?.title === 'string') { + if (!isStringEmpty(result.title)) { + uTitle = result.title; + } else { + shouldDelete = true; + } + } + } + + if (shouldDelete) { + source = source.substring(0, offset) + source.substring(offset + len); + continue; + } + + uRel = typeof result?.rel === 'string' ? result.rel : uRel; + uClass = typeof result?.cls === 'string' ? result.cls : uClass; + uTarget = typeof result?.trg === 'string' ? result.trg : uTarget; + } + } + + let result; + if (hasRenderCallback) { + result = renderCallback(url, uTitle, uRel, uTarget, uClass); + if (typeof result !== 'string' || isStringEmpty(result)) { + result = ''; + } + } else { + result = `${uTitle}`; + } + + source = source.substring(0, offset) + result + source.substring(offset + len); + } + + return source; +}; diff --git a/CodeListLibrary_project/cll/static/scss/components/_inputs.scss b/CodeListLibrary_project/cll/static/scss/components/_inputs.scss index 62739673e..245cb0ff1 100644 --- a/CodeListLibrary_project/cll/static/scss/components/_inputs.scss +++ b/CodeListLibrary_project/cll/static/scss/components/_inputs.scss @@ -282,7 +282,7 @@ padding: 0.5rem; margin-bottom: 0.5rem; - & > h4 { + & h4 { padding: 0; margin: 0; font-size: 18px; @@ -291,10 +291,42 @@ color: col(text-darkest); } - & > p { + & p { padding: 0; margin: 0.5rem 0 0 0; } + + &--action-header { + @include flex-row(); + justify-content: space-between; + } + + &-actions { + @include flex-row($gap: 1rem); + margin-left: auto; + + & > p { + margin: 0 0.5rem 0 0; + } + + &-deselector { + &:after { + @include fontawesome-icon(); + content: '\f2d3'; + color: col(accent-danger); + } + + &[disabled="true"]:after { + color: col(accent-washed) !important; + } + } + + @include media(' p { + display: none; + } + } + } } &__window { diff --git a/CodeListLibrary_project/cll/static/scss/components/_markdown.scss b/CodeListLibrary_project/cll/static/scss/components/_markdown.scss index 5fa712a91..d149f2c45 100644 --- a/CodeListLibrary_project/cll/static/scss/components/_markdown.scss +++ b/CodeListLibrary_project/cll/static/scss/components/_markdown.scss @@ -595,6 +595,7 @@ span.CodeMirror-selectedtext { .EasyMDEContainer { display: block; + max-width: 100%; } .EasyMDEContainer.sided--no-fullscreen { diff --git a/CodeListLibrary_project/cll/static/svg/cookies_icon.svg b/CodeListLibrary_project/cll/static/svg/cookies_icon.svg index 2d6c0ad3d..1f91f1fc4 100644 --- a/CodeListLibrary_project/cll/static/svg/cookies_icon.svg +++ b/CodeListLibrary_project/cll/static/svg/cookies_icon.svg @@ -1,5 +1,3 @@ - - diff --git a/CodeListLibrary_project/cll/templates/clinicalcode/about/reference_data.html b/CodeListLibrary_project/cll/templates/clinicalcode/about/reference_data.html index bfbb7c0f6..4788791f8 100644 --- a/CodeListLibrary_project/cll/templates/clinicalcode/about/reference_data.html +++ b/CodeListLibrary_project/cll/templates/clinicalcode/about/reference_data.html @@ -112,11 +112,9 @@

Data Sources

- {% if ontology %} + {% if ontology_groups %}
- {% if ontology %} - {% to_json_script ontology data-owner="reference-data-service" name="ontology" desc-type="text/json" %} - {% endif %} + {% to_json_script ontology_groups data-owner="reference-data-service" name="ontology-groups" desc-type="text/json" %}

Ontology

A set of taggable categories and concepts that describes a Phenotype.

diff --git a/CodeListLibrary_project/cll/templates/components/create/inputs/generic/ontology.html b/CodeListLibrary_project/cll/templates/components/create/inputs/generic/ontology.html index cc8d47ee9..052f9fc2b 100644 --- a/CodeListLibrary_project/cll/templates/components/create/inputs/generic/ontology.html +++ b/CodeListLibrary_project/cll/templates/components/create/inputs/generic/ontology.html @@ -112,8 +112,21 @@

Ontologies

-
+

Available Items

+
+

Deselect items:

+ + + + +
@@ -121,8 +134,21 @@

Available Items

-
-

Selected Items

+
+

Selected

+
+

Deselect all:

+ + + + +
diff --git a/CodeListLibrary_project/cll/templates/components/create/section/section_start.html b/CodeListLibrary_project/cll/templates/components/create/section/section_start.html index 863443019..23373e588 100644 --- a/CodeListLibrary_project/cll/templates/components/create/section/section_start.html +++ b/CodeListLibrary_project/cll/templates/components/create/section/section_start.html @@ -4,9 +4,11 @@

{{ section.title }}

{% if section.documentation %} -

- {{ section.description }} -

+ {% if section.description and section.description|length %} +

+ {{ section.description }} +

+ {% endif %}

    @@ -20,7 +22,7 @@

- {% else %} + {% elif section.description and section.description|length %}

{{ section.description }}

diff --git a/CodeListLibrary_project/cll/templates/components/details/outputs/clinical/endorsement.html b/CodeListLibrary_project/cll/templates/components/details/outputs/clinical/endorsement.html index c72b8ee1c..aa63e2dcc 100644 --- a/CodeListLibrary_project/cll/templates/components/details/outputs/clinical/endorsement.html +++ b/CodeListLibrary_project/cll/templates/components/details/outputs/clinical/endorsement.html @@ -10,18 +10,27 @@

{% if component.value|length %} -
    +
      {% for p in component.value %} -
    • - Organisation: {{p.endorsement_organisation}} - Date: {{p.date}} -
    • +
    • +
      +
      +
      + Organisation: {{p.endorsement_organisation}} +
      +
      +
      +
      + Date: {{p.date}} +
      +
      +
      +
    • {% endfor %} -
    +
{% else %} No endorsement {% endif %} -
diff --git a/CodeListLibrary_project/cll/templates/components/details/outputs/clinical/trial.html b/CodeListLibrary_project/cll/templates/components/details/outputs/clinical/trial.html index a8a210931..79bd4f8c7 100644 --- a/CodeListLibrary_project/cll/templates/components/details/outputs/clinical/trial.html +++ b/CodeListLibrary_project/cll/templates/components/details/outputs/clinical/trial.html @@ -13,14 +13,29 @@

    {% for p in component.value %}
  • - ID: {{ p.id }} - Link: {{ p.link }} - Name: {{ p.name }} - {% if p.primary and p.primary == 1 %} - - Primary - - {% endif %} +
    +
    +
    +
    +
    + ID: {{ p.id }} +
    +
    +
    +
    + Trial: {{ p.name }} +
    +
    +
    + {% if p.primary and p.primary == 1 %} +
    + + Primary + +
    + {% endif %} +
    +
  • {% endfor %}
diff --git a/CodeListLibrary_project/cll/templates/components/details/outputs/version_history.html b/CodeListLibrary_project/cll/templates/components/details/outputs/version_history.html index 3c78cfa38..f3ccd8b03 100644 --- a/CodeListLibrary_project/cll/templates/components/details/outputs/version_history.html +++ b/CodeListLibrary_project/cll/templates/components/details/outputs/version_history.html @@ -39,8 +39,8 @@

{% if user.is_authenticated %} Date User - Published Status - Publish date + Publish Status + Publish Date {% else %} Owner Publish date diff --git a/CodeListLibrary_project/dynamic_templates/OpenCodelists_phenotype.json b/CodeListLibrary_project/dynamic_templates/OpenCodelists_phenotype.json index 1e99a3530..ce310442f 100644 --- a/CodeListLibrary_project/dynamic_templates/OpenCodelists_phenotype.json +++ b/CodeListLibrary_project/dynamic_templates/OpenCodelists_phenotype.json @@ -2,7 +2,7 @@ "template_details": { "version": 1, "name": "OpenCodelists Phenotype", - "description": "OpenSafely's OpenCodelist Phenotypes, visit https://www.opencodelists.org/ to learn more", + "description": "OpenSafely's OpenCodelist Phenotype, visit https://www.opencodelists.org/ to learn more", "card_type": "generic" }, @@ -15,7 +15,7 @@ }, { "title": "Overview", - "description": "An overview of the phenotype with basic metadata.", + "description": "An overview of the Phenotype with basic metadata.", "fields": [ "open_codelist_id", "open_codelist_version_id", "open_codelist_version_tag", "coding_system", "coding_system_release", "collections", "tags" @@ -44,7 +44,7 @@ { "title": "Clinical Code List", "documentation": "clinical-coded-phenotype-docs", - "description": "Clinical codes used to define this phenotype.", + "description": "Clinical codes that defines this Phenotype.", "fields": ["concept_information"] } ], @@ -147,7 +147,7 @@ }, "coding_system": { "title": "Coding System", - "description":"Clinical coding system(s) contained within this phenotype. A phenotype may have multiple concepts, each with its own coding system. All contained coding systems are programmatically represented here.", + "description":"Clinical coding system(s) contained within this Phenotype. A Phenotype may have multiple concepts, each with its own coding system. All contained coding systems are programmatically represented here.", "field_type": "coding_system", "active": true, "validation": { @@ -168,7 +168,7 @@ }, "concept_information": { "title": "Clinical Codes", - "description": "A set of concepts, each of which defines a list of clinical codes, contained within this phenotype.", + "description": "A set of concepts, each of which defines a list of clinical codes, contained within this Phenotype.", "field_type": "concept_information", "active": true, "validation": { @@ -179,7 +179,7 @@ }, "source_reference": { "title": "Source Reference", - "description": "If this phenotype is derived from a third-party source, define that here.", + "description": "If this Phenotype is derived from a third-party source, define that here.", "field_type": "source_reference", "active": true, "validation": { @@ -191,4 +191,4 @@ "hide_if_empty": true } } -} \ No newline at end of file +} diff --git a/CodeListLibrary_project/dynamic_templates/atlas_phecode.json b/CodeListLibrary_project/dynamic_templates/atlas_phecode.json index 9b225c58b..014f1d545 100644 --- a/CodeListLibrary_project/dynamic_templates/atlas_phecode.json +++ b/CodeListLibrary_project/dynamic_templates/atlas_phecode.json @@ -15,7 +15,7 @@ }, { "title": "Overview", - "description": "An overview of the phenotype with basic metadata.", + "description": "An overview of the Phenotype with basic metadata.", "fields": ["type", "coding_system", "data_sources", "collections", "tags", "ontology", "source_reference"] }, { @@ -25,7 +25,7 @@ }, { "title": "Implementation", - "description": "How this phenotype definition is run against data.", + "description": "How this Phenotype definition is run against data.", "fields": ["implementation"], "hide_if_empty": true }, @@ -39,12 +39,12 @@ { "title": "Phecodes", "documentation": "clinical-coded-phenotype-docs", - "description": "Clinical codes used to define this phenotype.", + "description": "Clinical codes used to define this Phenotype.", "fields": ["concept_information"] }, { "title": "Publication", - "description": "Publication(s) where this phenotype is defined and/or used.", + "description": "Publication(s) where this Phenotype is defined and/or used.", "fields": ["publications", "citation_requirements"] } ], @@ -52,7 +52,7 @@ "fields": { "type": { "title": "Phenotype Type", - "description": "The category of patient characteristic this phenotype falls under.", + "description": "The category of patient characteristic this Phenotype falls under.", "field_type": "enum_dropdown_badge", "active": true, "validation": { @@ -75,7 +75,7 @@ }, "data_sources": { "title": "Data Sources", - "description": "Data sources the phenotype creators have run this phenotype against; or view as appropriate to use this phenotype for.", + "description": "Data sources the Phenotype creators have run this Phenotype against; or view as appropriate to use this Phenotype for.", "field_type": "data_sources", "active": true, "hydrated": true, @@ -206,7 +206,7 @@ }, "coding_system": { "title": "Coding System", - "description":"Clinical coding system(s) contained within this phenotype. A phenotype may have multiple concepts, each with its own coding system. All contained coding systems are programmatically represented here.", + "description":"Clinical coding system(s) contained within this Phenotype. A Phenotype may have multiple concepts, each with its own coding system. All contained coding systems are programmatically represented here.", "field_type": "coding_system", "active": true, "validation": { diff --git a/CodeListLibrary_project/dynamic_templates/bhf_phenotype.json b/CodeListLibrary_project/dynamic_templates/bhf_phenotype.json index c28a692ff..5ef6df103 100644 --- a/CodeListLibrary_project/dynamic_templates/bhf_phenotype.json +++ b/CodeListLibrary_project/dynamic_templates/bhf_phenotype.json @@ -9,11 +9,11 @@ "mandatory": false, "sanitise": "strict" }, - "description": "The name of the corresponding author." + "description": "The name of the corresponding author (optional)." }, "ontology": { "title": "Ontology", - "description": "A set of taggable categories and concepts that describe this Phenotype.", + "description": "A set of taggable categories and concepts that best describe this Phenotype (optional).", "field_type": "ontology", "active": true, "hydrated": true, @@ -124,16 +124,18 @@ "corresp_email": { "title": "Email Address", "active": true, + "requires_auth": true, "field_type": "string_inputbox", "validation": { "type": "string", "regex": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", "mandatory": false }, - "description": "The preferred email address." + "description": "The preferred e-mail address of the corresponding author (optional)." }, "phenotype_uuid": { "title": "Phenotype UUID", + "description": "If applicable, the UUID referencing this Phenotype's source (optional).", "active": true, "search": { "api": true @@ -174,29 +176,28 @@ }, "mandatory": true }, - "description": "The category of patient characteristic this phenotype falls under." + "description": "The category of patient characteristics this Phenotype is best described by." }, "endorsements": { "title": "Endorsements", - "description": "Endorsements for this phenotype.", + "description": "Endorsements for this Phenotype (optional).", "field_type": "endorsements", "active": true, "validation": { - "type": "endorsement", - "mandatory": false + "type": "endorsement", + "mandatory": false } }, "trials": { "title": "Clinical Trials", - "description": "Clinical Trials associated with this phenotype.", + "description": "Clinical Trials associated with this Phenotype.", "field_type": "trials", "active": true, "validation": { - "type": "trial", - "mandatory": false + "type": "trial", + "mandatory": false } }, - "data_sources": { "title": "Data Sources", "active": true, @@ -219,7 +220,7 @@ }, "mandatory": false }, - "description": "Data sources the phenotype creators have run this phenotype against; or view as appropriate to use this phenotype for." + "description": "A list identifying data sources referenced, used or required by this Phenotype (optional)." }, "event_date_range": { "title": "Valid Event Date Range", @@ -231,7 +232,7 @@ "mandatory": false, "sanitise": "strict" }, - "description": "If this phenotype is only applicable within a limited time period, please specify that here (optional)." + "description": "If this Phenotype is only applicable within a limited time period, please specify that here (optional)." }, "sex": { "title": "Sex", @@ -259,20 +260,7 @@ } ] }, - "description": "The biological sex this phenotype is applicable to." - }, - "agreement_date": { - "title": "Agreement Date", - "active": false, - "field_type": "date", - "validation": { - "type": "string", - "regex": "(0[1-9]|[12]\\d|30|31)\\/(0[1-9]|1[0-2])\\/(\\d{4})", - "mandatory": false - }, - "description": "A date representing when this phenotype was first finalized (may predate the Phenotype Library). Deprecated.", - "hide_if_empty": true, - "hide_on_create": true + "description": "The biological sex this Phenotype is applicable to." }, "coding_system": { "title": "Coding System", @@ -292,12 +280,12 @@ "computed": true, "mandatory": false }, - "description": "Clinical coding system(s) contained within this phenotype. A phenotype may have multiple concepts, each with its own coding system. All contained coding systems are programmatically represented here.", + "description": "Clinical coding system(s) contained within this Phenotype. A Phenotype may have multiple concepts, each with its own coding system. All contained coding systems are programmatically represented here.", "hide_on_create": true }, "phenoflowid": { "title": "Phenoflow ID", - "description": "URL of this phenotype's PhenoFLOW implementation, if applicable. For more information: https://kclhi.org/phenoflow/", + "description": "URL of this Phenotype's PhenoFLOW implementation, if applicable (optional). For more information: https://kclhi.org/phenoflow/", "field_type": "phenoflowid", "active": true, "validation": { @@ -317,29 +305,13 @@ "mandatory": false, "has_children": true }, - "description": "A set of concepts, each of which defines a list of clinical codes, contained within this phenotype." - }, - "source_reference": { - "title": "Source Reference", - "active": false, - "field_type": "string_inputbox", - "validation": { - "type": "string", - "length": [ - 0, - 250 - ], - "mandatory": false, - "sanitise": "strict" - }, - "description": "If this phenotype is derived from a third-party source, define that here. Deprecated.", - "hide_if_empty": true, - "hide_on_create": true + "description": "A set of concepts, each of which defines a list of clinical codes, contained within this Phenotype." } }, + "sections": [ { - "title": "Name - Author", + "title": "Name & Author", "fields": [ "name", "author", @@ -362,7 +334,7 @@ "collections", "tags" ], - "description": "An overview of the phenotype with basic metadata." + "description": "An overview of the Phenotype with metadata, or descriptors, that identifies this Phenotype." }, { "title": "Definition", @@ -379,7 +351,7 @@ "phenoflowid", "implementation" ], - "description": "How this phenotype definition is run against data.", + "description": "How this Phenotype definition might be used in practice, and its relation to other practical applications.", "hide_if_empty": true }, { @@ -387,7 +359,7 @@ "fields": [ "validation" ], - "description": "Description of how correctness, appropriateness, and/or quality was assessed.", + "description": "A description of the methods used to validate this Phenotype and any assessment of its quality.", "hide_if_empty": true, "do_not_show_in_production": true }, @@ -396,7 +368,8 @@ "fields": [ "concept_information" ], - "description": "Clinical codes used to define this phenotype." + "documentation": "clinical-coded-phenotype-docs", + "description": "Clinical codes that defines this Phenotype." }, { "title": "Publication", @@ -404,14 +377,14 @@ "publications", "citation_requirements" ], - "description": "Publication(s) where this phenotype is defined and/or used." + "description": "Publications that reference this Phenotype and how this Phenotype might be cited in further research." } - ], + "template_details": { "name": "BHF Data Science Centre Phenotype", "version": 1, "card_type": "clinical", - "description": "BHF Data Science Centre Phenotype definitions that are based on lists of clinical codes, or algorithms using clinical codes." + "description": "BHF Data Science Centre Phenotype definitions that describe a list of clinical codes, algorithms using clinical codes, or set of clinical trials. Visit the BHF Data Science Centre website to learn more: https://bhfdatasciencecentre.org" } -} \ No newline at end of file +} diff --git a/CodeListLibrary_project/dynamic_templates/clinical_coded_phenotype.json b/CodeListLibrary_project/dynamic_templates/clinical_coded_phenotype.json index 2cfd73ed4..20f5cf10a 100644 --- a/CodeListLibrary_project/dynamic_templates/clinical_coded_phenotype.json +++ b/CodeListLibrary_project/dynamic_templates/clinical_coded_phenotype.json @@ -15,7 +15,7 @@ }, { "title": "Overview", - "description": "An overview of the phenotype with basic metadata.", + "description": "An overview of the Phenotype with metadata, or descriptors, that identifies this Phenotype.", "fields": ["type", "phenotype_uuid", "sex", "event_date_range", "coding_system", "data_sources", "collections", "tags", "ontology"] }, { @@ -25,13 +25,13 @@ }, { "title": "Implementation", - "description": "How this phenotype definition is run against data.", + "description": "How this Phenotype definition might be used in practice, and its relation to other practical applications.", "fields": ["phenoflowid", "implementation"], "hide_if_empty": true }, { "title": "Validation", - "description": "Description of how correctness, appropriateness, and/or quality was assessed.", + "description": "A description of the methods used to validate this Phenotype and any assessment of its quality.", "fields": ["validation"], "do_not_show_in_production": true, "hide_if_empty": true @@ -39,12 +39,12 @@ { "title": "Clinical Code List", "documentation": "clinical-coded-phenotype-docs", - "description": "Clinical codes used to define this phenotype.", + "description": "Clinical codes that defines this Phenotype.", "fields": ["concept_information"] }, { "title": "Publication", - "description": "Publication(s) where this phenotype is defined and/or used.", + "description": "Publications that reference this Phenotype and how this Phenotype might be cited in further research.", "fields": ["publications", "citation_requirements"] } ], @@ -52,6 +52,7 @@ "fields": { "phenotype_uuid": { "title": "Phenotype UUID", + "description": "If applicable, the UUID referencing this Phenotype's HDRUK source (optional).", "field_type": "string_inputbox_code", "active": true, "requires_auth": true, @@ -70,7 +71,7 @@ }, "type": { "title": "Phenotype Type", - "description": "The category of patient characteristic this phenotype falls under.", + "description": "The category of patient characteristics this Phenotype is best described by.", "field_type": "enum_dropdown_badge", "active": true, "validation": { @@ -93,7 +94,7 @@ }, "ontology": { "title": "Ontology", - "description": "A set of taggable categories and concepts that describe this Phenotype.", + "description": "A set of taggable categories and concepts that best describe this Phenotype (optional).", "field_type": "ontology", "active": true, "hydrated": true, @@ -203,7 +204,7 @@ }, "data_sources": { "title": "Data Sources", - "description": "Data sources the phenotype creators have run this phenotype against; or view as appropriate to use this phenotype for.", + "description": "A list identifying data sources referenced, used or required by this Phenotype (optional).", "field_type": "data_sources", "active": true, "hydrated": true, @@ -224,18 +225,23 @@ }, "event_date_range": { "title": "Valid Event Date Range", - "description": "If this phenotype is only applicable within a limited time period, please specify that here (optional).", + "description": "If this Phenotype is only applicable within a limited time period, please specify that here (optional).", "field_type": "daterange", "active": true, "validation": { "type": "string", "mandatory": false, - "regex": "(?:\\d+\/|\\d+)+[\\s+]?-[\\s+]?(?:\\d+\/|\\d+)+" + "regex": [ + "(?:\\d+\\/|\\d+)+[\\s+]?-[\\s+]?(?:\\d+\\/|\\d+)+", + "[\\s+]?\\-[\\s+]?(?:\\d+\\/|\\d+)+", + "(?:\\d+\\/|\\d+)+[\\s+]?\\-[\\s+]?" + ], + "date_closure_optional": true } }, "sex": { "title": "Sex", - "description": "The biological sex this phenotype is applicable to.", + "description": "The biological sex this Phenotype is applicable to.", "field_type": "grouped_enum", "active": true, "validation": { @@ -260,7 +266,7 @@ }, "agreement_date": { "title": "Agreement Date", - "description":"A date representing when this phenotype was first finalized (may predate the Phenotype Library). Deprecated.", + "description":"A date representing when this Phenotype was first finalised (may predate the Phenotype Library). Deprecated.", "field_type": "date", "active": false, "validation": { @@ -273,7 +279,7 @@ }, "coding_system": { "title": "Coding System", - "description":"Clinical coding system(s) contained within this phenotype. A phenotype may have multiple concepts, each with its own coding system. All contained coding systems are programmatically represented here.", + "description":"Clinical coding system(s) contained by this Phenotype. A Phenotype may have multiple concepts, each with its own coding system. All contained coding systems are programmatically represented here.", "field_type": "coding_system", "active": true, "validation": { @@ -294,7 +300,7 @@ }, "phenoflowid": { "title": "Phenoflow ID", - "description": "URL of this phenotype's PhenoFLOW implementation, if applicable. For more information: https://kclhi.org/phenoflow/", + "description": "URL of this Phenotype's PhenoFLOW implementation, if applicable (optional). For more information: https://kclhi.org/phenoflow/", "field_type": "phenoflowid", "active": true, "validation": { @@ -307,7 +313,7 @@ }, "concept_information": { "title": "Clinical Codes", - "description": "A set of concepts, each of which defines a list of clinical codes, contained within this phenotype.", + "description": "A set of concepts, each of which defines a list of clinical codes, contained within this Phenotype.", "field_type": "concept_information", "active": true, "validation": { @@ -318,7 +324,7 @@ }, "source_reference": { "title": "Source Reference", - "description": "If this phenotype is derived from a third-party source, define that here. Deprecated.", + "description": "If this Phenotype is derived from a third-party source, define that here. Deprecated.", "field_type": "string_inputbox", "active": false, "validation": { diff --git a/CodeListLibrary_project/dynamic_templates/structured_data_algorithm_phenotype.json b/CodeListLibrary_project/dynamic_templates/structured_data_algorithm_phenotype.json index 30823b3a7..bdc90677b 100644 --- a/CodeListLibrary_project/dynamic_templates/structured_data_algorithm_phenotype.json +++ b/CodeListLibrary_project/dynamic_templates/structured_data_algorithm_phenotype.json @@ -15,12 +15,12 @@ }, { "title": "Definition", - "description": "An overview of the phenotype with basic metadata.", + "description": "An overview of the Phenotype with metadata, or descriptors, that identifies this Phenotype.", "fields": ["definition", "event_date_range", "sex", "type", "tags", "collections", "data_sources"] }, { "title": "Implementation", - "description": "How this phenotype definition is run against data.", + "description": "How this Phenotype definition might be applied to data.", "fields": ["phenoflowid", "code_repository", "implementation"] }, { @@ -30,7 +30,7 @@ }, { "title": "Publication", - "description": "Publication(s) where this phenotype is defined and/or used.", + "description": "Publication(s) in which this Phenotype was first defined or any publications that make reference to this Phenotype.", "fields": ["publications", "citation_requirements"] } ], @@ -38,7 +38,7 @@ "fields": { "type": { "title": "Phenotype Type", - "description": "The category of patient characteristic this phenotype falls under.", + "description": "The category of patient characteristics that best describe this Phenotype.", "field_type": "enum_dropdown_badge", "active": true, "validation": { @@ -61,7 +61,7 @@ }, "data_sources": { "title": "Data Sources", - "description": "Data sources the phenotype creators have run this phenotype against; or view as appropriate to use this phenotype for.", + "description": "A list identifying data sources referenced, used or required by this Phenotype (optional).", "field_type": "data_sources", "active": true, "validation": { @@ -80,7 +80,7 @@ }, "event_date_range": { "title": "Valid Event Date Range", - "description": "If this phenotype is only applicable within a limited time period, please specify that here (optional).", + "description": "If this Phenotype is only applicable within a limited time period, please specify that here (optional).", "field_type": "daterange", "active": false, "validation": { @@ -91,7 +91,7 @@ }, "sex": { "title": "Sex", - "description": "Biological sex this phenotype is applicable to.", + "description": "Biological sex this Phenotype is applicable to.", "field_type": "grouped_enum", "active": true, "validation": { @@ -116,7 +116,7 @@ }, "phenoflowid": { "title": "Phenoflow ID", - "description": "ID of this phenotype's PhenoFLOW implementation, if applicable. For more information: https://kclhi.org/phenoflow/", + "description": "ID of this Phenotype's PhenoFLOW implementation, if applicable. For more information: https://kclhi.org/phenoflow/", "field_type": "phenoflowid", "active": true, "validation": { @@ -129,7 +129,7 @@ }, "code_repository": { "title": "Code Repository", - "description": "Link to a code repository where an implementation of this phenotype is held", + "description": "Link to a code repository where an implementation of this Phenotype is held", "field_type": "string_inputbox", "active": true, "validation": { @@ -144,7 +144,7 @@ }, "source_reference": { "title": "Source Reference", - "description": "If this phenotype is derived from a third-party source, define that here. Deprecated.", + "description": "If this Phenotype is derived from a third-party source, define that here. Deprecated.", "field_type": "string_inputbox", "active": false, "validation": {