diff --git a/.github/workflows/testing-pipline.yml b/.github/workflows/testing-pipline.yml index 0967d52f7..8b2dc2c96 100644 --- a/.github/workflows/testing-pipline.yml +++ b/.github/workflows/testing-pipline.yml @@ -43,9 +43,8 @@ jobs: steps: - uses: actions/checkout@v3 - run: | - sudo apt-get update - sudo apt-get install -y libgconf-2-4 libatk1.0-0 libatk-bridge2.0-0 libgdk-pixbuf2.0-0 libgtk-3-0 libgbm-dev libnss3-dev libxss-dev libasound2 - wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | sudo apt-key add - + sudo apt-get update + sudo apt-get install -y libgconf-2-4 libatk1.0-0 libatk-bridge2.0-0 libgdk-pixbuf2.0-0 libgtk-3-0 libgbm-dev libnss3-dev libxss-dev libasound2 - uses: browser-actions/setup-chrome@v1 - uses: actions/cache@v3 with: @@ -65,6 +64,7 @@ jobs: - name: Install Dependencies run: | + sudo apt-get install -y -q dirmngr python -m pip install --upgrade pip pip install --upgrade --upgrade-strategy eager --default-timeout 100 -r docker/requirements/test.txt diff --git a/CodeListLibrary_project/clinicalcode/admin.py b/CodeListLibrary_project/clinicalcode/admin.py index ef5b30ff2..dff35d0df 100644 --- a/CodeListLibrary_project/clinicalcode/admin.py +++ b/CodeListLibrary_project/clinicalcode/admin.py @@ -8,9 +8,12 @@ from .models.GenericEntity import GenericEntity from .models.Template import Template from .models.OntologyTag import OntologyTag +from .models.Organisation import Organisation from .models.DMD_CODES import DMD_CODES + from .forms.TemplateForm import TemplateAdminForm from .forms.EntityClassForm import EntityAdminForm +from .forms.OrganisationForms import OrganisationAdminForm, OrganisationMembershipInline, OrganisationAuthorityInline @admin.register(OntologyTag) class OntologyTag(admin.ModelAdmin): @@ -51,10 +54,9 @@ def save_model(self, request, obj, form, change): @admin.register(Brand) class BrandAdmin(admin.ModelAdmin): - list_display = ['name', 'id', 'logo_path', 'owner', 'description'] - list_filter = ['name', 'description', 'created', 'modified', 'owner'] + list_filter = ['name', 'description', 'created', 'modified'] + list_display = ['name', 'id', 'logo_path', 'description'] search_fields = ['name', 'id', 'description'] - exclude = ['created_by', 'updated_by'] @admin.register(DataSource) @@ -72,6 +74,47 @@ class CodingSystemAdmin(admin.ModelAdmin): search_fields = ['name', 'codingsystem_id', 'description'] exclude = [] +@admin.register(Organisation) +class OrganisationAdmin(admin.ModelAdmin): + """ + Organisation admin representation + """ + form = OrganisationAdminForm + inlines = [OrganisationMembershipInline, OrganisationAuthorityInline] + #exclude = ['created', 'owner', 'members', 'brands'] + + list_filter = ['id', 'name'] + search_fields = ['id', 'name'] + list_display = ['id', 'name', 'slug'] + prepopulated_fields = {'slug': ['name']} + + def get_form(self, request, obj=None, **kwargs): + """ + Responsible for pre-populating form data & resolving the associated model form + + Args: + request (RequestContext): the request context of the form + obj (dict|None): an Organisation model instance (optional; defaults to `None`) + **kwargs (**kwargs): arbitrary form key-value pair data + + Returns: + (OrganisationModelForm) - the prepared ModelForm instance + """ + form = super(OrganisationAdmin, self).get_form(request, obj, **kwargs) + + if obj is None: + form.base_fields['slug'].initial = '' + form.base_fields['created'].initial = timezone.now() + else: + form.base_fields['slug'].initial = obj.slug + form.base_fields['created'].initial = obj.created + + form.base_fields['slug'].disabled = True + form.base_fields['slug'].help_text = 'This field is not editable' + form.base_fields['created'].disabled = True + form.base_fields['created'].help_text = 'This field is not editable' + + return form @admin.register(Template) class TemplateAdmin(admin.ModelAdmin): diff --git a/CodeListLibrary_project/clinicalcode/api/urls.py b/CodeListLibrary_project/clinicalcode/api/urls.py index 6034628f4..27dbec60c 100644 --- a/CodeListLibrary_project/clinicalcode/api/urls.py +++ b/CodeListLibrary_project/clinicalcode/api/urls.py @@ -139,6 +139,9 @@ def get_schema(self, request=None, public=False): url(r'^data-sources/$', DataSource.get_datasources, name='data_sources'), + url(r'^data-sources/(?P[\d-]+)/export/$', + DataSource.get_datasource_internal_detail, + name='data_source_by_internal_id'), url(r'^data-sources/(?P[\w-]+)/detail/$', DataSource.get_datasource_detail, name='data_source_by_id'), diff --git a/CodeListLibrary_project/clinicalcode/api/views/Collection.py b/CodeListLibrary_project/clinicalcode/api/views/Collection.py index 66877f063..d20baa556 100644 --- a/CodeListLibrary_project/clinicalcode/api/views/Collection.py +++ b/CodeListLibrary_project/clinicalcode/api/views/Collection.py @@ -1,31 +1,46 @@ +from rest_framework import status +from django.db.models import F, Q +from rest_framework.response import Response from rest_framework.decorators import (api_view, permission_classes) from rest_framework.permissions import IsAuthenticatedOrReadOnly -from rest_framework.response import Response -from rest_framework import status -from django.db.models import F +from django.contrib.postgres.search import TrigramWordSimilarity from ...models import Tag, GenericEntity -from ...entity_utils import api_utils -from ...entity_utils import constants +from ...entity_utils import constants, gen_utils, api_utils @api_view(['GET']) @permission_classes([IsAuthenticatedOrReadOnly]) def get_collections(request): """ - Get all collections + Get all Collections + + Available parameters: + + | Param | Type | Default | Desc | + |---------------|-----------------|---------|---------------------------------------------------------------| + | search | `str` | `NULL` | Full-text search across _name_ field | + | id | `int/list[int]` | `NULL` | Match by a single `int` _id_ field, or match by array overlap | """ - collections = Tag.objects.filter( - tag_type=constants.TAG_TYPE.COLLECTION.value - ) \ - .order_by('id') - - result = collections.annotate( - name=F('description') - ) \ - .values('id', 'name') + search = request.query_params.get('search', '') + + collections = Tag.get_brand_records_by_request(request, params={ 'tag_type': 2 }) + if collections is not None: + if not gen_utils.is_empty_string(search) and len(search.strip()) > 1: + collections = collections.annotate( + similarity=TrigramWordSimilarity(search, 'description') + ) \ + .filter(Q(similarity__gte=0.7)) \ + .order_by('-similarity') + else: + collections = collections.order_by('id') + + collections = collections.annotate( + name=F('description') + ) \ + .values('id', 'name') return Response( - data=list(result), + data=collections.values('id', 'name'), status=status.HTTP_200_OK ) @@ -36,8 +51,11 @@ def get_collection_detail(request, collection_id): Get detail of specified collection by collection_id, including associated published entities """ - collection = Tag.objects.filter(id=collection_id) - if not collection.exists(): + collection = Tag.get_brand_assoc_queryset(request.BRAND_OBJECT, 'collections') + if collection is not None: + collection = collection.filter(id=collection_id) + + if not collection or not collection.exists(): return Response( data={ 'message': 'Collection with id does not exist' diff --git a/CodeListLibrary_project/clinicalcode/api/views/Concept.py b/CodeListLibrary_project/clinicalcode/api/views/Concept.py index 2437f8c59..060fb63c8 100644 --- a/CodeListLibrary_project/clinicalcode/api/views/Concept.py +++ b/CodeListLibrary_project/clinicalcode/api/views/Concept.py @@ -119,7 +119,7 @@ def get_concepts(request): query_clauses.append(psycopg2.sql.SQL('''( setweight(to_tsvector('pg_catalog.english', coalesce(historical.name,'')), 'A') || setweight(to_tsvector('pg_catalog.english', coalesce(historical.description,'')), 'B') - ) @@ to_tsquery('pg_catalog.english', replace(websearch_to_tsquery('pg_catalog.english', %(search_query)s)::text || ':*', '<->', '|')) + ) @@ to_tsquery('pg_catalog.english', replace(to_tsquery('pg_catalog.english', concat(regexp_replace(trim(%(search_query)s), '\W+', ':* & ', 'gm'), ':*'))::text, '<->', '|')) ''')) # Resolve pagination behaviour @@ -422,7 +422,7 @@ def get_concept_detail(request, concept_id, version_id=None, export_codes=False, if not user_can_access: return Response( data={ - 'message': 'Concept version must be published or you must have permission to access it' + 'message': 'Entity version must be published or you must have permission to access it' }, content_type='json', status=status.HTTP_401_UNAUTHORIZED diff --git a/CodeListLibrary_project/clinicalcode/api/views/DataSource.py b/CodeListLibrary_project/clinicalcode/api/views/DataSource.py index 82dc53827..4fd8d2698 100644 --- a/CodeListLibrary_project/clinicalcode/api/views/DataSource.py +++ b/CodeListLibrary_project/clinicalcode/api/views/DataSource.py @@ -1,24 +1,145 @@ -from rest_framework.decorators import (api_view, permission_classes) -from rest_framework.permissions import IsAuthenticatedOrReadOnly -from rest_framework.response import Response from rest_framework import status -from django.db.models import Subquery, OuterRef +from django.db.models import Q, Subquery, OuterRef +from rest_framework.response import Response +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import IsAuthenticatedOrReadOnly +from django.contrib.postgres.search import TrigramWordSimilarity from ...models import DataSource, Template, GenericEntity -from ...entity_utils import api_utils -from ...entity_utils import gen_utils -from ...entity_utils import constants +from ...entity_utils import api_utils, gen_utils, constants @api_view(['GET']) @permission_classes([IsAuthenticatedOrReadOnly]) def get_datasources(request): """ - Get all datasources + Get all DataSources + + Available parameters: + + | Param | Type | Default | Desc | + |---------------|-----------------|---------|---------------------------------------------------------------| + | search | `str` | `NULL` | Full-text search across _name_ and _description_ fields | + | id | `int/list[int]` | `NULL` | Match by a single `int` _id_ field, or match by array overlap | + | name | `str` | `NULL` | Case insensitive direct match of _name_ field | + | uid | `str/uuid` | `NULL` | Case insensitive direct match of _uid_ field | + | datasource_id | `int` | `NULL` | Match by exact _datasource_id_ | + | url | `str` | `NULL` | Case insensitive direct match of _url_ field | + | source | `str` | `NULL` | Case insensitive direct match of _source_ field | """ - datasources = DataSource.objects.all().order_by('id') - datasources = list(datasources.values('id', 'name', 'url', 'uid', 'source')) + params = gen_utils.parse_model_field_query(DataSource, request, ignored_fields=['description']) + if params is not None: + datasources = DataSource.objects.filter(**params) + else: + datasources = DataSource.objects.all() + + search = request.query_params.get('search') + if not gen_utils.is_empty_string(search) and len(search.strip()) > 3: + datasources = datasources.annotate( + similarity=( + TrigramWordSimilarity(search, 'name') + \ + TrigramWordSimilarity(search, 'description') + ) + ) \ + .filter(Q(similarity__gte=0.7)) \ + .order_by('-similarity') + else: + datasources = datasources.order_by('id') + return Response( - data=datasources, + data=datasources.values('id', 'name', 'description', 'url', 'uid', 'datasource_id', 'source'), + status=status.HTTP_200_OK + ) + +@api_view(['GET']) +@permission_classes([IsAuthenticatedOrReadOnly]) +def get_datasource_internal_detail(request, datasource_id): + """ + Get detail of specified datasource by by its internal Id + """ + query = None + if gen_utils.parse_int(datasource_id, default=None) is not None: + query = { 'id': int(datasource_id) } + + if not query: + return Response( + data={ + 'message': 'Invalid id, expected int-like value' + }, + content_type='json', + status=status.HTTP_400_BAD_REQUEST + ) + + datasource = DataSource.objects.filter(**query) + if not datasource.exists(): + return Response( + data={ + 'message': 'Datasource with this internal Id does not exist' + }, + content_type='json', + status=status.HTTP_404_NOT_FOUND + ) + + datasource = datasource.first() + + # Get all templates and their versions where data_sources exist + templates = Template.history.filter( + definition__fields__has_key='data_sources' + ) \ + .annotate( + was_deleted=Subquery( + Template.history.filter( + id=OuterRef('id'), + history_date__gte=OuterRef('history_date'), + history_type='-' + ) + .order_by('id', '-history_id') + .distinct('id') + .values('id') + ) + ) \ + .exclude(was_deleted__isnull=False) \ + .order_by('id', '-template_version', '-history_id') \ + .distinct('id', 'template_version') + + template_ids = list(templates.values_list('id', flat=True)) + template_versions = list(templates.values_list('template_version', flat=True)) + + # Get all published entities with this datasource + entities = GenericEntity.history.filter( + template_id__in=template_ids, + template_version__in=template_versions, + publish_status=constants.APPROVAL_STATUS.APPROVED.value + ) \ + .extra(where=[f""" + exists( + select 1 + from jsonb_array_elements( + case jsonb_typeof(template_data->'data_sources') when 'array' + then template_data->'data_sources' + else '[]' + end + ) as val + where val in ('{datasource.id}') + )""" + ]) \ + .order_by('id', '-history_id') \ + .distinct('id') + + # Format results + entities = api_utils.annotate_linked_entities(entities) + + result = { + 'id': datasource.id, + 'name': datasource.name, + 'url': datasource.url, + 'uid': datasource.uid, + 'description': datasource.description, + 'source': datasource.source, + 'phenotypes': list(entities) + } + + return Response( + data=result, status=status.HTTP_200_OK ) @@ -26,14 +147,13 @@ def get_datasources(request): @permission_classes([IsAuthenticatedOrReadOnly]) def get_datasource_detail(request, datasource_id): """ - Get detail of specified datasource by datasource_id (id or HDRUK UUID for - linkage between applications), including associated published entities + Get detail of specified datasource by `datasource_id`, _i.e._ the HDRUK DataSource `pid` or its `UUID` for linkage between applications, including associated published entities. """ query = None if gen_utils.is_valid_uuid(datasource_id): query = { 'uid': datasource_id } elif gen_utils.parse_int(datasource_id, default=None) is not None: - query = { 'id': int(datasource_id) } + query = { 'datasource_id': int(datasource_id) } if not query: return Response( diff --git a/CodeListLibrary_project/clinicalcode/api/views/GenericEntity.py b/CodeListLibrary_project/clinicalcode/api/views/GenericEntity.py index 9cf6654d6..49742b1f8 100644 --- a/CodeListLibrary_project/clinicalcode/api/views/GenericEntity.py +++ b/CodeListLibrary_project/clinicalcode/api/views/GenericEntity.py @@ -6,6 +6,8 @@ from django.conf import settings from django.core.exceptions import BadRequest +import logging + from ...models import GenericEntity, Template from ...entity_utils import permission_utils from ...entity_utils import template_utils @@ -15,6 +17,10 @@ from ...entity_utils import gen_utils from ...entity_utils import constants + +logger = logging.getLogger(__name__) + + """ Create/Update GenericEntity """ @swagger_auto_schema(method='post', auto_schema=None) @@ -33,17 +39,17 @@ def create_generic_entity(request): content_type='json', status=status.HTTP_403_FORBIDDEN ) - + form = api_utils.validate_api_create_update_form( request, method=constants.FORM_METHODS.CREATE.value ) if isinstance(form, Response): return form - + entity = api_utils.create_update_from_api_form(request, form) if isinstance(entity, Response): return entity - + entity_data = { 'id': entity.id, 'version_id': entity.history_id, @@ -159,11 +165,11 @@ def get_generic_entities(request): - **Metadata Parameters** → _i.e._ Top-level fields associated with all `Phenotypes` - | Param | Type | Default | Desc | - |-------------|----------------|---------|----------------------------------------------| - | tags | `list` | `NULL` | Filter results by one or more tag IDs | - | collections | `list` | `NULL` | Filter results by one or more collection IDs | - | created | `list` | `NULL` | Date range filter on `created` field | + | Param | Type | Default | Desc | + |-------------|--------------------|---------|------------------------------------------------------| + | tags | `list` | `NULL` | Filter results by one or more tag IDs / names | + | collections | `list` | `NULL` | Filter results by one or more collection IDs / names | + | created | `list` | `NULL` | Date range filter on `created` field | - **Template Parameters** → _i.e._ Fields relating to a specific `Template` @@ -184,8 +190,8 @@ def get_generic_entities(request): | Search across ID, Name and Code | `?ontology=([^&]+)` | Search string or List of deliminated strings | | Search across ID | `?ontology_id=([^&]+)` | Single `ID` (`int`) or List of deliminated `ID`s | | Search across Code | `?ontology_code=([^&]+)` | Single `Code` string (_e.g._ ICD-10, SNOMED _etc_) or List of deliminated `Codes`s | - | Search acrross Name | `?ontology_name=([^&]+)` | Single `Name` string or List of deliminated `Name`s | - | Search across Type | `?ontology_type=([^&]+)` | Single `Type` (`int`) or List of deliminated `Type`s | + | Search acrros Name | `?ontology_name=([^&]+)` | Single `Name` string or List of deliminated `Name`s | + | Search across Type | `?ontology_type=([^&]+)` | Single `Type` (`int` or `str`) or List of deliminated `Type` IDs/Names | | Search across Reference | `?ontology_reference=([^&]+)` | Single `ReferenceID` (`int`) or List of deliminated `ReferenceID`s | """ @@ -256,20 +262,39 @@ def get_generic_entities(request): # Finalise accessibility clause(s) user_id = None - brand_id = None - group_ids = None + brand_ids = None + group_ids = None # [!] Change brand = model_utils.try_get_brand(request) if brand is not None: - brand_id = brand.id - accessible_clauses.append('''%(brand_id)s = any(entity.brands)''') + vis_rules = brand.get_vis_rules() + brand_ctx = None + if isinstance(vis_rules, dict): + allow_null = vis_rules.get('allow_null') + allowed_brands = vis_rules.get('ids') + if isinstance(allowed_brands, list) and isinstance(allow_null, bool) and allow_null: + brand_ids = allowed_brands + brand_ctx = '''((entity.brands is null or array_length(entity.brands, 1) < 1) or %(brand_ids)s && entity.brands)''' + elif isinstance(allowed_brands, list) and len(allowed_brands): + brand_ids = allowed_brands + brand_ctx = '''(entity.brands is not null and array_length(entity.brands, 1) > 0 and %(brand_ids)s && entity.brands)''' + elif isinstance(allow_null, bool) and allow_null: + brand_ids = allowed_brands + brand_ctx = '''(entity.brands is null or array_length(entity.brands, 1) < 1)''' + + if brand_ctx is None: + brand_ids = [brand.id] + brand_ctx = '''(%(brand_ids)s && entity.brands)''' + + accessible_clauses.append(brand_ctx) user = request.user if request.user and not request.user.is_anonymous else None user_clause = '''entity.publish_status = 2''' if user: user_id = user.id - user_clause = f'''({user_clause} or entity.world_access = 2) or entity.owner_id = %(user_id)s''' + user_clause = f'''{user_clause} or entity.owner_id = %(user_id)s''' + # [!] Change groups = list(user.groups.all().values_list('id', flat=True)) if len(groups) > 0: group_ids = [ int(group) for group in groups if gen_utils.parse_int(group, default=None) ] @@ -280,7 +305,7 @@ def get_generic_entities(request): # Cache base params base_params = { 'user_id': user_id, - 'brand_id': brand_id, + 'brand_ids': brand_ids, 'group_ids': group_ids, 'template_id': template_id, 'template_version_id': template_version_id, @@ -350,7 +375,7 @@ def get_generic_entities(request): continue success, query, query_params = api_utils.build_query_string_from_param( - key, data, validation, field_type, + request, key, data, field_data, field_type, prefix='mt', is_dynamic=False ) @@ -391,7 +416,7 @@ def get_generic_entities(request): continue success, query, query_params = api_utils.build_query_string_from_param( - key, data, validation, field_type, + request, key, data, field_data, field_type, prefix=prefix, is_dynamic=True ) @@ -479,7 +504,7 @@ def get_generic_entities(request): query = query + ''' where t.search_vector @@ to_tsquery( 'pg_catalog.english', - replace(websearch_to_tsquery('pg_catalog.english', %(search)s)::text || ':*', '<->', '|') + replace(to_tsquery('pg_catalog.english', concat(regexp_replace(trim(%(search)s), '\W+', ':* & ', 'gm'), ':*'))::text, '<->', '|') ) ''' query_params.update({ 'search': search }) @@ -505,7 +530,7 @@ def get_generic_entities(request): on template.id = live_tmpl.id where (live_entity.is_deleted is null or live_entity.is_deleted = false) and (entity.is_deleted is null or entity.is_deleted = false) - and entity.publish_status = 2 + {accessible_clauses} {tmpl_clauses} ) as t0 join public.clinicalcode_historicalgenericentity as t1 @@ -525,7 +550,7 @@ def get_generic_entities(request): order by entity.history_id desc ) as rn_ref_n from public.clinicalcode_historicalgenericentity as entity - left join public.clinicalcode_genericentity as live_entity + join public.clinicalcode_genericentity as live_entity on entity.id = live_entity.id join public.clinicalcode_historicaltemplate as template on entity.template_id = template.id and entity.template_version = template.template_version @@ -576,7 +601,7 @@ def get_generic_entities(request): for entity in entities: entity_detail = api_utils.get_entity_detail( request, - entity.id, + entity.id, entity, is_authed, fields_to_ignore=constants.ENTITY_LIST_API_HIDDEN_FIELDS, @@ -584,6 +609,7 @@ def get_generic_entities(request): ) if not isinstance(entity_detail, Response): + entity_detail.update(phenotype_version_id=entity.history_id) formatted_entities.append(entity_detail) result = formatted_entities if not should_paginate else { @@ -592,9 +618,8 @@ def get_generic_entities(request): 'page_size': page_size, 'data': formatted_entities } - except Exception as e: - # log exception? + logger.error('Encountered error on Phenotype API Query: \n%s\n' % (str(e))) raise BadRequest('Invalid request, failed to perform query') else: return Response( diff --git a/CodeListLibrary_project/clinicalcode/api/views/Healthcheck.py b/CodeListLibrary_project/clinicalcode/api/views/Healthcheck.py index 772ea6a15..f0bb86266 100644 --- a/CodeListLibrary_project/clinicalcode/api/views/Healthcheck.py +++ b/CodeListLibrary_project/clinicalcode/api/views/Healthcheck.py @@ -40,7 +40,7 @@ def get(self, request): pg_connected = self.__ping_db() is_readonly = not settings.IS_DEVELOPMENT_PC and (settings.IS_DEMO or settings.CLL_READ_ONLY or settings.IS_INSIDE_GATEWAY) - if settings.DEBUG or (not settings.DEBUG and is_readonly): + if settings.DEBUG or (not settings.DEBUG and is_readonly and not settings.ENABLE_DEMO_TASK_QUEUE): return Response( data={ 'healthcheck_mode': HealthcheckMode.DEBUG.value, diff --git a/CodeListLibrary_project/clinicalcode/api/views/Ontology.py b/CodeListLibrary_project/clinicalcode/api/views/Ontology.py index dd6473d2b..c6478d9d2 100644 --- a/CodeListLibrary_project/clinicalcode/api/views/Ontology.py +++ b/CodeListLibrary_project/clinicalcode/api/views/Ontology.py @@ -150,8 +150,8 @@ def get_ontology_nodes(request): if 'exact_codes' not in request.query_params.keys(): # Fuzzy across every code mapping clauses.append('''( - (relation_vector @@ to_tsquery('pg_catalog.english', replace(websearch_to_tsquery('pg_catalog.english', array_to_string(%(codes)s, '|'))::text || ':*', '<->', '|'))) - or (relation_vector @@ to_tsquery('pg_catalog.english', replace(websearch_to_tsquery('pg_catalog.english', array_to_string(%(alt_codes)s, '|'))::text || ':*', '<->', '|'))) + (relation_vector @@ to_tsquery('pg_catalog.english', replace(to_tsquery('pg_catalog.english', concat(regexp_replace(trim(array_to_string(%(codes)s, '|')), '\W+', ':* & ', 'gm'), ':*'))::text, '<->', '|'))) + or (relation_vector @@ to_tsquery('pg_catalog.english',replace(to_tsquery('pg_catalog.english', concat(regexp_replace(trim(array_to_string(%(alt_codes)s, '|')), '\W+', ':* & ', 'gm'), ':*'))::text, '<->', '|'))) )''') else: # Direct search for snomed @@ -169,10 +169,10 @@ def get_ontology_nodes(request): # Fuzzy across code / desc / synonyms / relation clauses.append('''( node.search_vector - @@ to_tsquery('pg_catalog.english', replace(websearch_to_tsquery('pg_catalog.english', %(search)s)::text || ':*', '<->', '|')) + @@ to_tsquery('pg_catalog.english', replace(to_tsquery('pg_catalog.english', concat(regexp_replace(trim(%(search)s), '\W+', ':* & ', 'gm'), ':*'))::text, '<->', '|')) )''') - search_rank = '''ts_rank_cd(node.search_vector, websearch_to_tsquery('pg_catalog.english', %(search)s))''' + search_rank = '''ts_rank_cd(node.search_vector, to_tsquery('pg_catalog.english', replace(to_tsquery('pg_catalog.english', concat(regexp_replace(trim(%(search)s), '\W+', ':* & ', 'gm'), ':*'))::text, '<->', '|')))''' row_clause = '''row_number() over (order by %s) as rn,''' % search_rank search_rank = search_rank + ' as score,' diff --git a/CodeListLibrary_project/clinicalcode/api/views/Tag.py b/CodeListLibrary_project/clinicalcode/api/views/Tag.py index da9d5b057..692899b9e 100644 --- a/CodeListLibrary_project/clinicalcode/api/views/Tag.py +++ b/CodeListLibrary_project/clinicalcode/api/views/Tag.py @@ -1,31 +1,47 @@ +from rest_framework import status +from django.db.models import F, Q +from rest_framework.response import Response from rest_framework.decorators import (api_view, permission_classes) from rest_framework.permissions import IsAuthenticatedOrReadOnly -from rest_framework.response import Response -from rest_framework import status -from django.db.models import F +from django.contrib.postgres.search import TrigramWordSimilarity from ...models import Tag, GenericEntity -from ...entity_utils import api_utils -from ...entity_utils import constants +from ...entity_utils import constants, gen_utils, api_utils @api_view(['GET']) @permission_classes([IsAuthenticatedOrReadOnly]) def get_tags(request): """ - Get all tags + Get all Tags + + Available parameters: + + | Param | Type | Default | Desc | + |---------------|-----------------|---------|---------------------------------------------------------------| + | search | `str` | `NULL` | Full-text search across _name_ field | + | id | `int/list[int]` | `NULL` | Match by a single `int` _id_ field, or match by array overlap | """ - tags = Tag.objects.filter( - tag_type=constants.TAG_TYPE.TAG.value - ) \ - .order_by('id') - - result = tags.annotate( - name=F('description') - ) \ - .values('id', 'name') + search = request.query_params.get('search', '') + + tags = Tag.get_brand_records_by_request(request, params={ 'tag_type': 1 }) + if tags is not None: + if not gen_utils.is_empty_string(search) and len(search.strip()) > 1: + tags = tags.annotate( + similarity=TrigramWordSimilarity(search, 'description') + ) \ + .filter(Q(similarity__gte=0.7)) \ + .order_by('-similarity') + else: + tags = tags.order_by('id') + + tags = tags.annotate( + name=F('description') + ) \ + .values('id', 'name') + return Response( - data=list(result), + data=tags.values('id', 'name'), status=status.HTTP_200_OK ) @@ -36,8 +52,11 @@ def get_tag_detail(request, tag_id): Get detail of specified tag by tag_id, including associated published entities """ - tag = Tag.objects.filter(id=tag_id) - if not tag.exists(): + tag = Tag.get_brand_assoc_queryset(request.BRAND_OBJECT, 'tags') + if tag is not None: + tag = tag.filter(id=tag_id) + + if not tag or not tag.exists(): return Response( data={ 'message': 'Tag with id does not exist' @@ -54,7 +73,7 @@ def get_tag_detail(request, tag_id): ) \ .order_by('id', '-history_id') \ .distinct('id') - + # Format results entities = api_utils.annotate_linked_entities(entities) diff --git a/CodeListLibrary_project/clinicalcode/api/views/Template.py b/CodeListLibrary_project/clinicalcode/api/views/Template.py index 5efb3b68d..9129e2701 100644 --- a/CodeListLibrary_project/clinicalcode/api/views/Template.py +++ b/CodeListLibrary_project/clinicalcode/api/views/Template.py @@ -1,20 +1,48 @@ +from rest_framework import status +from django.db.models import Q +from rest_framework.response import Response from rest_framework.decorators import (api_view, permission_classes) from rest_framework.permissions import IsAuthenticatedOrReadOnly -from rest_framework.response import Response -from rest_framework import status +from django.contrib.postgres.search import TrigramWordSimilarity from ...models import Template -from ...entity_utils import api_utils -from ...entity_utils import template_utils +from ...entity_utils import template_utils, gen_utils, api_utils @api_view(['GET']) @permission_classes([IsAuthenticatedOrReadOnly]) def get_templates(request): """ - Get all templates - """ - templates = Template.objects.all() + Get all Collections + + Available parameters: + + | Param | Type | Default | Desc | + |---------------|-----------------|---------|----------------------------------------------------------------------------| + | search | `str` | `NULL` | Full-text search across _name_ and _description_ field | + | id | `int/list[int]` | `NULL` | Match by a single `int` _id_ field, or match by array overlap | + | name | `str` | `NULL` | Case insensitive direct match of _name_ field | + """ + templates = Template.get_brand_records_by_request(request) + if templates is None: + return Response( + data=[], + status=status.HTTP_200_OK + ) + + search = request.query_params.get('search') + if not gen_utils.is_empty_string(search) and len(search.strip()) > 3: + templates = templates.annotate( + similarity=( + TrigramWordSimilarity(search, 'name') + \ + TrigramWordSimilarity(search, 'description') + ) + ) \ + .filter(Q(similarity__gte=0.7)) \ + .order_by('-similarity') + else: + templates = templates.order_by('id') + result = [] for template in templates: result.append({ @@ -83,7 +111,7 @@ def get_template(request, template_id, version_id=None): status=status.HTTP_404_NOT_FOUND ) template = template.latest() - + merged_definition = template_utils.get_merged_definition(template, default={}) template_fields = template_utils.try_get_content(merged_definition, 'fields') diff --git a/CodeListLibrary_project/clinicalcode/apps.py b/CodeListLibrary_project/clinicalcode/apps.py new file mode 100644 index 000000000..fe6dc9d0d --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/apps.py @@ -0,0 +1,29 @@ +"""App registry""" +from django.conf import settings +from django.apps import AppConfig +from django.template import base as template_base +from django.core.signals import request_started + +import re + +# Enable multi-line tag support +template_base.tag_re = re.compile(template_base.tag_re.pattern, re.DOTALL) + +# App registration +class ClinicalCodeConfig(AppConfig): + """CLL Base App Config""" + name = 'clinicalcode' + + def ready(self): + """Initialises signals on app start""" + + # Enable EasyAudit signal override + if settings.REMOTE_TEST: + return + + from clinicalcode.audit.request_signals import request_started_watchdog + + request_started.connect( + receiver=request_started_watchdog, + dispatch_uid='easy_audit_signals_request_started' + ) diff --git a/CodeListLibrary_project/clinicalcode/audit/__init__.py b/CodeListLibrary_project/clinicalcode/audit/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/CodeListLibrary_project/clinicalcode/audit/request_signals.py b/CodeListLibrary_project/clinicalcode/audit/request_signals.py new file mode 100644 index 000000000..208d68316 --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/audit/request_signals.py @@ -0,0 +1,342 @@ +"""Custom EasyAudit request handling.""" +from importlib import import_module +from ipaddress import ip_address as validate_ip +from easyaudit import settings as EasySettings +from django.conf import settings as AppSettings +from django.utils import timezone +from django.http.cookie import SimpleCookie +from django.contrib.auth import get_user_model, SESSION_KEY +from django.http.request import HttpRequest, split_domain_port, validate_host +from django.core.handlers.wsgi import WSGIHandler +from django.utils.regex_helper import _lazy_re_compile +from django.utils.module_loading import import_string +from django.contrib.sessions.models import Session + +import re +import inspect + +from clinicalcode.models import Brand + + +audit_logger = import_string(EasySettings.LOGGING_BACKEND)() +session_engine = import_module(AppSettings.SESSION_ENGINE) + + +def validate_ip_addr(addr): + """ + Attempts to validate the given IP address + + Args: + default (str): the IP address to evaluate + + Returns: + A (bool) reflecting the validity of the specified IP address + + """ + try: + validate_ip(addr) + except ValueError: + return False + else: + return True + + +def resolve_ip_addr(request, validate_ip=False, default='10.0.0.1'): + """ + Resolves the IP address assoc. with some request / the sender's _environ_ + + Args: + request (HttpRequest|Dict[string, Any]): either (a) a :class:`HttpRequest` assoc. with the signal or (b) a `Dict[str, Any]` describing its _environ_ + validate_ip (bool): optionally specify whether to validate the IP address; defaults to `False` + default (Any): optionally specify the return value; defaults to `10.0.0.1` to represent an empty IP, likely from a proxy hiding the client + + Returns: + A (str) specifying the IP if applicable; otherwise returns the given `default` parameter + + """ + addr = None + if isinstance(request, HttpRequest): + if isinstance(request.headers.get('X-Forwarded-For'), str): + addr = request.headers.get('X-Forwarded-For') + + if addr is None: + request = request.META + + if isinstance(request, dict): + for x in ['HTTP_X_FORWARDED_FOR', EasySettings.REMOTE_ADDR_HEADER, 'REMOTE_ADDR']: + var = request.get(x) + if isinstance(var, str): + addr = var + break + + if validate_ip and addr is not None and not validate_ip_addr(addr): + addr = default + elif addr is None: + addr = default + + return addr + + +def match_url_patterns(url, patterns, flags=re.MULTILINE | re.IGNORECASE): + """ + Tests a variadic number of patterns against the given URL. + + Args: + url (str): the URL to evaluate + patterns (list[Pattern]): specifies the patterns to be matched against the URL test case + flags (int): optionally specify the regex flags to be used across all patterns; defaults to `MULTILINE` and `IGNORECASE` + + Returns: + A (bool) specifying whether the URL test case has been successfully matched against one of the given pattern(s). + + """ + if not isinstance(url, str) or not isinstance(patterns, list): + return False + + for target in patterns: + pattern = _lazy_re_compile(target, flags=flags) + if pattern.match(url): + return True + + return False + + +def get_request_info(sender, params): + """ + Attempts to resolve to resolve RequestContext data from the given WSGIRequest event kwargs. + + Args: + sender (WSGIHandler|Any): the sender assoc. with this request + params (Dict[str, Any]): the kwargs associated with a request event + + Returns: + A (Dict[str, Any]) specifying the request information if resolved; otherwise returns a (None) value + + """ + scope = params.get('scope') + environ = params.get('environ') + + # Resolve header + info = None + if environ: + info = environ + path = environ['PATH_INFO'] + cookie_string = environ.get('HTTP_COOKIE') + method = environ['REQUEST_METHOD'] + query_string = environ['QUERY_STRING'] + + remote_ip = None + if isinstance(sender, WSGIHandler) or (inspect.isclass(sender) and issubclass(sender, WSGIHandler)): + try: + request = sender.request_class(environ) + remote_ip = resolve_ip_addr(request, validate_ip=True, default=None) + except: + pass + + if remote_ip is None: + remote_ip = resolve_ip_addr(environ) + else: + info = scope + path = scope.get('path') + method = scope.get('method') + headers = dict(scope.get('headers')) + cookie_string = headers.get(b'cookie') + if isinstance(cookie_string, bytes): + cookie_string = cookie_string.decode('utf-8') + + remote_ip = next(iter(scope.get('client', ('0.0.0.0', 0)))) + query_string = scope.get('query_string') + + # Resolve protocol + protocol = None + if AppSettings.SECURE_PROXY_SSL_HEADER: + try: + header, secure_value = AppSettings.SECURE_PROXY_SSL_HEADER + except ValueError: + header = None + + if header is not None: + header_value = info.get(header) + if header_value is not None: + header_value, *_ = header_value.split(',', 1) + protocol = 'https' if header_value.strip() == secure_value else 'http' + + if protocol is None: + protocol = info.get('wsgi.url_scheme', 'http') + + # Resolve raw port + if AppSettings.USE_X_FORWARDED_PORT and 'HTTP_X_FORWARDED_HOST' in info: + rport = str(info.get('HTTP_X_FORWARDED_HOST')) + else: + rport = str(info.get('SERVER_PORT')) + + # Resolve raw host + host = None + if AppSettings.USE_X_FORWARDED_HOST and 'HTTP_X_FORWARDED_HOST' in info: + host = info.get('HTTP_X_FORWARDED_HOST') + elif 'HTTP_HOST' in info: + host = info.get('HTTP_HOST') + else: + host = info.get('SERVER_NAME') + if rport != ('443' if protocol == 'https' else '80'): + host = '%s:%s' % (host, rport) + + # Attempt host validation + allowed_hosts = AppSettings.ALLOWED_HOSTS + if not isinstance(allowed_hosts, list) and AppSettings.DEBUG: + allowed_hosts = ['.localhost', '127.0.0.1', '[::1]'] + + domain, port = split_domain_port(host) + if domain and not validate_host(domain, allowed_hosts): + return None + + return { + 'method': method, + 'protocol': protocol, + 'host': host, + 'domain': domain, + 'port': port, + 'request_port': rport, + 'path': path, + 'remote_ip': remote_ip, + 'query_string': query_string, + 'cookie_string': cookie_string, + } + + +def get_brand_from_request_info(info): + """ + Attempts to resolve a Brand from the request info, if applicable. + + Args: + info (Dict[str, Any]): the request information derived from `get_request_info()` + + Returns: + A (str) describing the name of the Brand associated with this request if applicable, otherwise returns a (None) value. + + """ + if not isinstance(info, dict): + return None + + domain = info.get('domain') + if isinstance(domain, str): + pattern = _lazy_re_compile(r'^(phenotypes\.healthdatagateway|web\-phenotypes\-hdr)', flags=re.MULTILINE | re.IGNORECASE) + if pattern.match(domain): + return 'HDRUK' + + url = info.get('path') + root = url.lstrip('/').split('/')[0].upper().rstrip('/') + if root in Brand.all_names(): + return root + + return None + + +def is_blacklisted_url(url, brand_name=None): + """ + Determines whether the requested URL event is blacklisted from being audited, considers Brand context if the Brand is specified. + + Args: + url (str): the desired url path, _e.g._ `/api/v1/some-endpoint/` + brand_name (str|None): optionally specify the Brand context; defaults to `None` + + Returns: + A (boolean) specifying whether this event's URL is blacklisted + + """ + if not isinstance(url, str): + return False + + # Det. whether URL is blacklisted per override rules (if applicable) + override = AppSettings.OVERRIDE_EASY_AUDIT_IGNORE_URLS \ + if (hasattr(AppSettings, 'OVERRIDE_EASY_AUDIT_IGNORE_URLS') and isinstance(AppSettings.OVERRIDE_EASY_AUDIT_IGNORE_URLS, dict)) \ + else None + + all_override = override.get('all_brands') if override is not None else None + brand_override = override.get(brand_name) if override is not None and isinstance(brand_name, str) else None + if all_override or brand_override: + if all_override and match_url_patterns(url, all_override): + return True + + if brand_override and match_url_patterns(url, brand_override): + return True + + return False + + # Otherwise, default to EasyAudit rules if not found + return match_url_patterns(url, EasySettings.UNREGISTERED_URLS) + + +def should_log_url(url, brand_name=None): + """ + Determines whether the requested URL event should be logged to the audit table, considers Brand context if the Brand is specified. + + Args: + url (str): the desired url path, _e.g._ `/api/v1/some-endpoint/` + brand_name (str|None): optionally specify the Brand context; defaults to `None` + + Returns: + A (boolean) specifying whether this event should be logged + + """ + if not isinstance(url, str): + return False + + # Only include registered URLs if defined + if brand_name is not None: + url = url.lstrip('/' + brand_name) + + if not url.startswith('/'): + url = '/' + url + + if isinstance(EasySettings.REGISTERED_URLS, list) and len(EasySettings.REGISTERED_URLS) > 0: + for registered_url in EasySettings.REGISTERED_URLS: + pattern = re.compile(registered_url) + if pattern.match(url): + return True + return False + + # Otherwise, record all except those that are blacklisted + return not is_blacklisted_url(url, brand_name) + + +def request_started_watchdog(sender, *args, **kwargs): + """A signal handler to observe Django `request_started `__ events""" + # Reconcile request context + info = get_request_info(sender, kwargs) + path = info.get('path') + brand_name = get_brand_from_request_info(info) + if not should_log_url(path, brand_name): + return + + # Resolve the user from the auth cookie if applicable + user = None + cookie = info.get('cookie_string') + if not user and cookie: + cookie = SimpleCookie() + cookie.load(cookie) + + session_cookie_name = AppSettings.SESSION_COOKIE_NAME + if session_cookie_name in cookie: + session_id = cookie[session_cookie_name].value + try: + session = session_engine.SessionStore(session_key=session_id).load() + except Session.DoesNotExist: + session = None + + if session and SESSION_KEY in session: + user_id = session.get(SESSION_KEY) + try: + user = get_user_model().objects.get(id=user_id) + except Exception: + user = None + + # Log request interaction + audit_logger.request({ + 'url': path, + 'method': info.get('method'), + 'query_string': info.get('query_string'), + 'user_id': getattr(user, 'id', None) if user is not None else None, + 'remote_ip': info.get('remote_ip'), + 'datetime': timezone.now(), + }) diff --git a/CodeListLibrary_project/clinicalcode/context_processors/general.py b/CodeListLibrary_project/clinicalcode/context_processors/general.py index 113a884f6..8d6e93a83 100644 --- a/CodeListLibrary_project/clinicalcode/context_processors/general.py +++ b/CodeListLibrary_project/clinicalcode/context_processors/general.py @@ -1,10 +1,12 @@ from django.conf import settings +from clinicalcode.entity_utils import constants, permission_utils from clinicalcode.api.views.View import get_canonical_path -from clinicalcode.entity_utils import constants def general_var(request): return { + 'USER_CREATE_CONTEXT': permission_utils.user_has_create_context(request), + 'IS_BRAND_ADMIN': permission_utils.is_requestor_brand_admin(request), 'MEDIA_URL': settings.MEDIA_URL, 'CLL_READ_ONLY': settings.CLL_READ_ONLY, 'SHOWADMIN': settings.SHOWADMIN, diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/api_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/api_utils.py index 18ac6bdb7..e2e278909 100644 --- a/CodeListLibrary_project/clinicalcode/entity_utils/api_utils.py +++ b/CodeListLibrary_project/clinicalcode/entity_utils/api_utils.py @@ -1,12 +1,17 @@ from django.db import connection -from django.contrib.auth.models import User +from difflib import SequenceMatcher as SM from rest_framework.response import Response from rest_framework import status from django.db.models.functions import JSONObject from django.db.models import ForeignKey, F +from django.core.exceptions import FieldDoesNotExist from rest_framework.renderers import JSONRenderer +from django.contrib.auth import get_user_model + +import psycopg2 from ..models.GenericEntity import GenericEntity +from ..models.Organisation import Organisation from ..models.Template import Template from ..models.Concept import Concept from . import model_utils @@ -18,6 +23,8 @@ from . import gen_utils from . import constants +User = get_user_model() + """ REST renderer """ class PrettyJsonRenderer(JSONRenderer): def get_indent(self, accepted_media_type, renderer_context): @@ -87,16 +94,16 @@ def exists_historical_entity(entity_id, user, historical_id=None): returns response 404 """ if not historical_id: - historical_id = permission_utils.get_latest_entity_historical_id( - entity_id, user + historical_entity = GenericEntity.history.filter(id=entity_id).latest_of_each() + else: + historical_entity = model_utils.try_get_instance( + GenericEntity, + id=entity_id ) + if historical_entity: + historical_entity = historical_entity.history.filter(history_id=historical_id) - historical_entity = model_utils.try_get_instance( - GenericEntity, - id=entity_id - ).history.filter(history_id=historical_id) - - if not historical_entity.exists(): + if not historical_entity or not historical_entity.exists(): return Response( data={ 'message': 'Historical entity version does not exist' @@ -124,7 +131,7 @@ def exists_concept(concept_id): if not concept: return Response( data={ - 'message': 'Concept does not exist' + 'message': 'Entity does not exist' }, content_type='json', status=status.HTTP_404_NOT_FOUND @@ -160,7 +167,7 @@ def exists_historical_concept(request, concept_id, historical_id=None): if not historical_concept: return Response( data={ - 'message': 'Historical concept version does not exist' + 'message': 'Historical entity version does not exist' }, content_type='json', status=status.HTTP_404_NOT_FOUND @@ -517,8 +524,28 @@ def build_template_subquery_from_string(param, data, top_ref, sub_ref, validatio datatype = None processor = '' if sub_type == 'int_array': - data = [ int(x) for x in data.split(',') if isinstance(gen_utils.parse_int(x, default=None), int) ] - datatype = 'bigint' + data = data.split(',') + + coercion = sub_validation.get('coerce') + if isinstance(coercion, list): + res = [] + for x in data: + if isinstance(x, str): + matched = [{ 'out': v.get('out'), 'ratio': SM(None, x.strip().lower(), v.get('compare').lower()).ratio() } for v in coercion] + matched.sort(key=lambda x: x.get('ratio'), reverse=True) + + matched = matched[0] if len(matched) > 0 else None + if matched and matched.get('ratio') > 0.3: + res.append(matched.get('out')) + continue + + if gen_utils.is_int(x): + res.append(int(x)) + data = res + else: + data = [ int(x) for x in data if isinstance(gen_utils.parse_int(x, default=None), int) ] + + datatype = 'bigint' elif sub_type == 'string_array': data = [ str(x).lower() for x in data.split(',') if gen_utils.try_value_as_type(x, 'string') is not None ] datatype = 'text' @@ -598,18 +625,20 @@ def build_template_subquery_from_string(param, data, top_ref, sub_ref, validatio return True, [ query, dataset ] -def build_query_string_from_param(param, data, validation, field_type, is_dynamic=False, prefix=''): +def build_query_string_from_param(request, param, data, field_data, field_type, is_dynamic=False, prefix=''): """ Builds query (terms and where clauses) based on a template [!] NOTE: Parameters & types should be validated _BEFORE_ calling this function Args: + request (HTTPContext): Request context + param (string): the name of the request param that's been mapped to the template data (any): the value portion of the key-value pair param - validation (dict): validation dictionary defined by the template + field_data (dict): the field data dict field_type (str): the associated field type as defined by the template @@ -626,12 +655,16 @@ def build_query_string_from_param(param, data, validation, field_type, is_dynami 3. dict|None - the processed data if successful """ + validation = field_data.get('validation') + if not isinstance(validation, dict): + return False, None, None + if len(prefix) > 0: prefix = prefix + '_' query = None if field_type == 'int' or field_type == 'enum': - if 'options' in validation or 'source' in validation: + if not validation.get('ugc', False) and ('options' in validation or 'source' in validation): data = [ int(x) for x in data.split(',') if gen_utils.parse_int(x, default=None) is not None ] if is_dynamic: @@ -650,6 +683,7 @@ def build_query_string_from_param(param, data, validation, field_type, is_dynami if is_dynamic: source = validation.get('source') trees = source.get('trees') if source and 'trees' in validation.get('source') else None + if trees: data = [ str(x) for x in data.split(',') if gen_utils.try_value_as_type(x, 'string') is not None ] model = source.get('model') if isinstance(source.get('model'), str) else None @@ -685,10 +719,7 @@ def build_query_string_from_param(param, data, validation, field_type, is_dynami t1.search_vector @@ to_tsquery( 'pg_catalog.english', - replace( - websearch_to_tsquery('pg_catalog.english', %({prefix}{param}_trsearch)s)::text - || ':*', '<->', '|' - ) + replace(to_tsquery('pg_catalog.english', concat(regexp_replace(trim(%({prefix}{param}_trsearch)s), '\W+', ':* & ', 'gm'), ':*'))::text, '<->', '|') ) ) ) @@ -717,6 +748,9 @@ def build_query_string_from_param(param, data, validation, field_type, is_dynami ) ''' else: + data = coerce_into_opts(request, field_data, data) + if data is None: + return False, None, None query = f''' exists( select 1 @@ -727,11 +761,13 @@ def build_query_string_from_param(param, data, validation, field_type, is_dynami else '[]' end ) as val - where val::text = any(%({prefix}{param}_data)s) + where val::text = any(%({prefix}{param}_data)s::text[]) ) ''' else: - data = [ int(x) for x in data.split(',') if gen_utils.parse_int(x, default=None) is not None ] + data = coerce_into_opts(request, field_data, data) + if data is None: + return False, None, None query = f'''entity.{param} && %({prefix}{param}_data)s''' return True, query, { f'{prefix}{param}_data': data } @@ -750,6 +786,72 @@ def build_query_string_from_param(param, data, validation, field_type, is_dynami return False, None, None +def coerce_into_opts(request, field_data, data, default=None): + """ + Attempts to coerce str|int list values for `int_array` targets into an `int`-only array by querying the options available to the client + + Args: + request (HTTPContext): Request context + + field_data (dict): the field data assoc. with the queryable field + + data (list|string): the data query input + + default (Any): optionally specify a default return value; defaults to `None` + + Returns: + Either (a) a list of IDs assoc. with the specified input; or (b) if invalid, a None-type value + """ + if isinstance(data, str): + data = data.split(',') + + if not isinstance(data, list): + return default + + data_ids = [ int(x.strip()) for x in data if gen_utils.is_int(x.strip()) ] + data_str = [ x.lower().strip() for x in data if isinstance(x, str) ] + + validation = field_data.get('validation') + source = validation.get('source') if isinstance(validation, dict) else None + if not isinstance(source, dict): + return data_ids + + model = source.get('table') + field = source.get('query') + relative = source.get('relative') + if gen_utils.is_empty_string(model) or gen_utils.is_empty_string(field) or gen_utils.is_empty_string(relative): + return data_ids + + if len(data_str) < 1: + return data_ids + + model = f'clinicalcode_{model}'.lower() + with connection.cursor() as cursor: + sql = psycopg2.sql.SQL(''' + select + {field} as id + from {model} as item + where lower({relative}::text) = any(%(str_comp)s::text[]) + or {field}::bigint = any(%(int_comp)s::bigint[]); + ''') \ + .format( + field=psycopg2.sql.Identifier(field), + model=psycopg2.sql.Identifier(model), + relative=psycopg2.sql.Identifier(relative) + ) + + cursor.execute(sql, params={ + 'int_comp': data_ids, + 'str_comp': data_str, + }) + + columns = [col[0] for col in cursor.description] + results = [dict(zip(columns, row)) for row in cursor.fetchall()] + return [x.get('id') for x in results] + + return data_ids + + def build_query_from_template(request, user_authed, template=None): """ Builds query (terms and where clauses) based on a template @@ -837,26 +939,54 @@ def get_entity_detail_from_layout( if is_active == False: continue - requires_auth = template_utils.try_get_content( - field_definition, 'requires_auth') + requires_auth = template_utils.try_get_content(field_definition, 'requires_auth') if requires_auth and not user_authed: continue if template_utils.is_metadata(entity, field): - validation = template_utils.get_field_item( - constants.metadata, field, 'validation', {} - ) + field_info = template_utils.get_layout_field(fields, field) + validation = field_info.get('validation') is_source = validation.get('source') if is_source: - if template_utils.is_metadata(entity, field=field): - value = template_utils.get_metadata_value_from_source( - entity, field, default=None - ) - else: + value = template_utils.get_metadata_value_from_source( + entity, field, default=None + ) + else: + value = None + try: + field_ref = entity._meta.get_field(field) + field_type = field_ref.get_internal_type() + if field_type in constants.STRIPPED_FIELDS: + continue + + if isinstance(field_ref, ForeignKey): + model = field_ref.target_field.model + model_type = str(model) + if model_type in constants.USERDATA_MODELS: + if model_type == str(User): + value = template_utils.get_one_of_field(value, ['username', 'name']) + elif hasattr(model, 'serialise_api') and callable(getattr(model, 'serialise_api')): + value = getattr(entity, field).serialise_api() + else: + value = { + 'id': field.id, + 'name': template_utils.get_one_of_field(field, ['username', 'name']) + } + except: + pass + + if value is None: value = template_utils.get_entity_field(entity, field) - result[field] = value + if field in constants.API_MAP_FIELD_NAMES: + field_name = constants.API_MAP_FIELD_NAMES.get(field) + elif isinstance(field_info.get('shunt'), str): + field_name = field_info.get('shunt') + else: + field_name = field + + result[field_name] = value continue if field == 'concept_information': @@ -880,13 +1010,14 @@ def get_entity_detail_from_layout( return result -def get_entity_detail_from_meta(entity, data, fields_to_ignore=[], target_field=None): +def get_entity_detail_from_meta(entity, fields, data, fields_to_ignore=[], target_field=None): """ Retrieves entity detail in the format required for detail API endpoint, specifically from metadata fields, e.g. constants Args: entity (GenericEntity): Entity object to get the detail for + fields (dict): dict containing layout of the entity data (dict): dict containing previously built detail fields_to_ignore (list of strings): List of fields to remove from output target_field (string): Field to be targeted, i.e. only build the detail @@ -904,6 +1035,10 @@ def get_entity_detail_from_meta(entity, data, fields_to_ignore=[], target_field= if field_name.lower() in data or field_name.lower() in fields_to_ignore: continue + field_tmpl = fields.get(field_name) + if isinstance(field_tmpl, dict): + continue + field_type = field.get_internal_type() if field_type and field_type in constants.STRIPPED_FIELDS: continue @@ -911,6 +1046,10 @@ def get_entity_detail_from_meta(entity, data, fields_to_ignore=[], target_field= if field_name in constants.API_HIDDEN_FIELDS: continue + field_data = constants.metadata.get(field_name) + if field_data and field_data.get('active') == False: + continue + field_value = template_utils.get_entity_field(entity, field_name) if field_value is None: result[field_name] = None @@ -923,6 +1062,8 @@ def get_entity_detail_from_meta(entity, data, fields_to_ignore=[], target_field= if model_type == str(User): result[field_name] = template_utils.get_one_of_field( field_value, ['username', 'name']) + elif hasattr(model, 'serialise_api') and callable(getattr(model, 'serialise_api')): + result[field_name] = getattr(entity, field_name).serialise_api() else: result[field_name] = { 'id': field_value.id, @@ -1004,8 +1145,7 @@ def get_entity_detail( return layout_response layout = layout_response - layout_definition = template_utils.get_merged_definition( - layout, default={}) + layout_definition = template_utils.get_merged_definition(layout, default={}) layout_version = layout.template_version fields = template_utils.try_get_content(layout_definition, 'fields') @@ -1017,20 +1157,19 @@ def get_entity_detail( ) result = result | get_entity_detail_from_meta( - entity, result, fields_to_ignore=fields_to_ignore, target_field=target_field + entity, fields, result, fields_to_ignore=fields_to_ignore, target_field=target_field ) entity_versions = get_entity_version_history(request, entity_id) if target_field is None: - result = get_ordered_entity_detail( - fields, layout, layout_version, entity_versions, result) + result = get_ordered_entity_detail(fields, layout, layout_version, entity_versions, result) + result = {'phenotype_id': entity.id, 'phenotype_version_id': entity.history_id} | result if return_data: return result return Response( - data=[{'phenotype_id': entity.id, - 'phenotype_version_id': entity.history_id} | result], + data=[result], status=status.HTTP_200_OK ) @@ -1077,10 +1216,6 @@ def build_final_codelist_from_concepts( if include_headers: concept_data |= { 'code_attribute_header': concept_entity.code_attribute_header} - if 'attributes' in concept and concept['attributes']: - concept_data |= {'attributes': concept['attributes']} - - # Get codes concept_codes = concept_utils.get_concept_codelist( concept_id, @@ -1325,30 +1460,51 @@ def get_formatted_concept_codes(concept, concept_codelist, headers=None): return concept_codes -def annotate_linked_entities(entities): +def annotate_linked_entities(entities, has_hx_id=True): """ Annotates linked entities with phenotype and template details Args: entities (QuerySet): Entities queryset + has_hx_id (bool): Optionally specify whether to append the phenotype history id Returns: Queryset containing annotated entities """ - return entities.annotate( - phenotype_id=F('id'), - phenotype_version_id=F('history_id'), - phenotype_name=F('name') - ) \ - .values( - 'phenotype_id', - 'phenotype_version_id', - 'phenotype_name' - ) \ - .annotate( + if has_hx_id: + res = entities.annotate( + phenotype_id=F('id'), + phenotype_version_id=F('history_id'), + phenotype_name=F('name') + ) \ + .values( + 'phenotype_id', + 'phenotype_version_id', + 'phenotype_name' + ) \ + .annotate( + template=JSONObject( + id=F('template__id'), + version_id=F('template_version'), + name=F('template__name') + ) + ) + else: + res = entities.annotate( + phenotype_id=F('id'), + phenotype_name=F('name') + ) \ + .values( + 'phenotype_id', + 'phenotype_name' + ) + + res = res.annotate( template=JSONObject( id=F('template__id'), version_id=F('template_version'), name=F('template__name') ) ) + + return res diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/concept_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/concept_utils.py index df63116ee..18be99d41 100644 --- a/CodeListLibrary_project/clinicalcode/entity_utils/concept_utils.py +++ b/CodeListLibrary_project/clinicalcode/entity_utils/concept_utils.py @@ -844,9 +844,9 @@ def get_minimal_concept_data(concept): 'coding_system': model_utils.get_coding_system_details(concept.coding_system) } | concept_data -def get_clinical_concept_data(concept_id, concept_history_id,include_reviewed_codes=False, +def get_clinical_concept_data(concept_id, concept_history_id, include_reviewed_codes=False, aggregate_component_codes=False, include_component_codes=True, - include_attributes=False, strippable_fields=None,concept_attributes=None, + include_attributes=False, strippable_fields=None, remove_userdata=False, hide_user_details=False, derive_access_from=None, requested_entity_id=None, format_for_api=False, include_source_data=False): @@ -867,7 +867,6 @@ def get_clinical_concept_data(concept_id, concept_history_id,include_reviewed_co include_attributes (boolean): Should we include attributes? strippable_fields (list): Whether to strip any fields from the Concept model when building the concept's data result - concept_attributes (list): The workingset list of attributes remove_userdata (boolean): Whether to remove userdata related fields from the result (assoc. with each Concept) derive_access_from (RequestContext): Using the RequestContext, determine whether a user can edit a Concept format_for_api (boolean): Flag to format against legacy API @@ -1005,7 +1004,6 @@ def get_clinical_concept_data(concept_id, concept_history_id,include_reviewed_co 'concept_version_id': concept_history_id, 'coding_system': model_utils.get_coding_system_details(historical_concept.coding_system), 'details': concept_data, - 'attributes': concept_attributes, 'components': components_data.get('components') if components_data is not None else [], } else: diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/constants.py b/CodeListLibrary_project/clinicalcode/entity_utils/constants.py index 637d27ba0..33c0c58d8 100644 --- a/CodeListLibrary_project/clinicalcode/entity_utils/constants.py +++ b/CodeListLibrary_project/clinicalcode/entity_utils/constants.py @@ -1,9 +1,13 @@ from django.http.request import HttpRequest -from django.contrib.auth.models import User, Group +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group import enum +User = get_user_model() + + class TypeStatus: """ Legacy type status - needs removal during cleanup """ Disease = 0 @@ -34,6 +38,27 @@ def __contains__(cls, lhs): return True +class ORGANISATION_ROLES(int, enum.Enum, metaclass=IterableMeta): + """ + Defines organisation roles + """ + MEMBER = 0 + EDITOR = 1 + MODERATOR = 2 + ADMIN = 3 + + +class ORGANISATION_INVITE_STATUS(int, enum.Enum, metaclass=IterableMeta): + """ + Defines organisation invite status + """ + EXPIRED = 0 + ACTIVE = 1 + SEEN = 2 + ACCEPTED = 3 + REJECTED = 4 + + class TAG_TYPE(int, enum.Enum): """ Tag types used for differentiate Collections & Tags @@ -146,6 +171,17 @@ class ONTOLOGY_TYPES(int, enum.Enum, metaclass=IterableMeta): CLINICAL_DISEASE = 0 CLINICAL_DOMAIN = 1 CLINICAL_FUNCTIONAL_ANATOMY = 2 + MESH_CODES = 3 + +""" + Number of days before organisation invite expires +""" +INVITE_TIMEOUT = 30 + +""" + Describes a set of names representing a float/double-like numeric value +""" +NUMERIC_NAMES = ['float', 'numeric', 'decimal', 'percentage'] """ Used to define the labels for each @@ -153,6 +189,7 @@ class ONTOLOGY_TYPES(int, enum.Enum, metaclass=IterableMeta): """ ONTOLOGY_LABELS = { + ONTOLOGY_TYPES.MESH_CODES: 'Medical Subject Headings (MeSH)', ONTOLOGY_TYPES.CLINICAL_DOMAIN: 'Clinical Domain', ONTOLOGY_TYPES.CLINICAL_DISEASE: 'Clinical Disease Category (SNOMED)', ONTOLOGY_TYPES.CLINICAL_FUNCTIONAL_ANATOMY: 'Functional Anatomy', @@ -252,7 +289,7 @@ class ONTOLOGY_TYPES(int, enum.Enum, metaclass=IterableMeta): Used to strip userdata from models when JSONifying them e.g. user account, user profile, membership """ -USERDATA_MODELS = [str(User), str(Group)] +USERDATA_MODELS = [str(User), str(Group), ""] STRIPPED_FIELDS = ['SearchVectorField'] """ @@ -281,6 +318,7 @@ class ONTOLOGY_TYPES(int, enum.Enum, metaclass=IterableMeta): Describes fields that should be stripped from entity list api response """ ENTITY_LIST_API_HIDDEN_FIELDS = [ + 'deleted', 'created_by', 'updated_by', 'deleted_by', 'brands', 'concept_information', 'definition', 'implementation' ] @@ -347,7 +385,7 @@ class ONTOLOGY_TYPES(int, enum.Enum, metaclass=IterableMeta): { 'title': 'Permissions', 'description': 'Settings for sharing and collaboration.', - 'fields': ['group', 'group_access', 'world_access'] + 'fields': ['organisation', 'world_access'] } ] @@ -385,7 +423,8 @@ class ONTOLOGY_TYPES(int, enum.Enum, metaclass=IterableMeta): 'title': 'Permissions', 'field_type': 'permissions_section', 'active': True, - 'hide_on_create': True + 'hide_on_create': True, + 'requires_auth': True }, 'api': { 'title': 'API', @@ -407,6 +446,14 @@ class ONTOLOGY_TYPES(int, enum.Enum, metaclass=IterableMeta): } } +"""Default brand ctx content name mapping""" +DEFAULT_CONTENT_MAPPING = { + 'phenotype': 'Phenotype', + 'phenotype_url': 'phenotypes', + 'concept': 'Concept', + 'concept_url': 'concepts', +} + """ [!] Note: Will be moved to a table once tooling is finished, accessible through the 'base_template_version' @@ -437,7 +484,7 @@ class ONTOLOGY_TYPES(int, enum.Enum, metaclass=IterableMeta): }, 'brands': { 'title': 'Brand', - 'description': 'The brand that this Phenotype is related to.', + 'description': 'The brand that this entity is related to.', 'field_type': '???', 'active': True, 'validation': { @@ -571,7 +618,15 @@ class ONTOLOGY_TYPES(int, enum.Enum, metaclass=IterableMeta): 'tag_type': 2, ## Can be added once we det. what we're doing with brands - # 'source_by_brand': None + 'source_by_brand': { + 'ADP': { + 'allowed_brands': [3], + 'allow_null': True, + }, + 'HDRUK': 'allow_null', + 'HDRN': True, + 'SAIL': False, + }, } } }, @@ -599,7 +654,12 @@ class ONTOLOGY_TYPES(int, enum.Enum, metaclass=IterableMeta): 'tag_type': 1, ## Can be added once we det. what we're doing with brands - # 'source_by_brand': None + 'source_by_brand': { + 'ADP': 'allow_null', + 'HDRUK': 'allow_null', + 'HDRN': True, + 'SAIL': False, + }, } } }, @@ -609,13 +669,25 @@ class ONTOLOGY_TYPES(int, enum.Enum, metaclass=IterableMeta): }, 'is_base_field': True }, + 'organisation': { + 'title': 'Organisation', + 'description': "The organisation that owns this entity for permissions purposes (optional).", + 'field_type': 'group_field', + 'active': True, + 'validation': { + 'type': 'organisation', + 'mandatory': False, + 'computed': True + }, + 'is_base_field': True + }, 'group': { 'title': 'Group', - 'description': "The group that owns this Phenotype for permissions purposes (optional).", + 'description': "The group that owns this entity for permissions purposes (optional).", 'field_type': 'group_field', - 'active': True, + 'active': False, 'validation': { - 'type': 'int', + 'type': 'group', 'mandatory': False, 'computed': True }, @@ -623,27 +695,43 @@ class ONTOLOGY_TYPES(int, enum.Enum, metaclass=IterableMeta): }, 'group_access': { 'title': 'Group Access', - 'description': 'Optionally enable this Phenotype to be viewed or edited by the group.', + 'description': 'Optionally enable this entity to be viewed or edited by the group.', 'field_type': 'access_field_editable', - 'active': True, + 'active': False, + 'validation': { + 'type': 'int', + 'mandatory': True, + 'range': [1, 3], + 'default': 1 + }, + 'is_base_field': True + }, + 'owner_access': { + 'title': 'Owner Access', + 'description': 'Owner permissions', + 'field_type': 'access_field_editable', + 'active': False, 'validation': { 'type': 'int', 'mandatory': True, - 'range': [1, 3] + 'range': [1, 3], + 'default': 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).", + 'title': 'Share with other organisations', + 'description': "Enables this entity to be viewed by all logged-in users of the Library who are members of an Organisation.", 'field_type': 'access_field', 'active': True, 'validation': { 'type': 'int', 'mandatory': True, - 'range': [1, 3] + 'range': [1, 3], + 'default': 1 }, - 'is_base_field': True + 'is_base_field': True, + 'hide_non_org_managed': True }, 'updated': { 'title': 'Updated', @@ -756,6 +844,23 @@ class ONTOLOGY_TYPES(int, enum.Enum, metaclass=IterableMeta): 'output_type': 'dropdown-list' }, + 'age_group': { + 'input_type': 'generic/age_group', + 'output_type': 'generic/age_group' + }, + + 'single_slider': { + 'data_type': 'string', + 'input_type': 'single_slider', + 'output_type': 'single_slider' + }, + + 'double_range_slider': { + 'data_type': 'string', + 'input_type': 'double_range_slider', + 'output_type': 'double_range' + }, + 'grouped_enum': { 'data_type': 'int', 'input_type': 'grouped_enum', @@ -763,6 +868,18 @@ class ONTOLOGY_TYPES(int, enum.Enum, metaclass=IterableMeta): 'apply_badge_style': True }, + 'list_enum': { + 'data_type': 'int_array', + 'input_type': 'list_enum', + 'output_type': 'radiobutton', + 'apply_badge_style': True + }, + + 'contact_information': { + 'input_type': 'clinical/contact_information', + 'output_type': 'clinical/contact_information', + }, + 'ontology': { 'input_type': 'generic/ontology', 'output_type': 'generic/ontology' @@ -800,6 +917,10 @@ class ONTOLOGY_TYPES(int, enum.Enum, metaclass=IterableMeta): 'input_type': 'clinical/trial', 'output_type': 'clinical/trial', }, + 'references': { + 'input_type': 'clinical/references', + 'output_type': 'clinical/references', + }, 'coding_system': { 'system_defined': True, 'description': 'list of coding system ids (calculated from Phenotype concepts) (managed by code snippet)', @@ -810,19 +931,25 @@ class ONTOLOGY_TYPES(int, enum.Enum, metaclass=IterableMeta): 'system_defined': True, 'description': 'list of tags ids (managed by code snippet)', 'input_type': 'tagbox', - 'output_type': 'tagbox' + 'output_type': 'tagbox', + 'vis_vary_on_opts': True, }, 'collections': { 'system_defined': True, 'description': 'list of collections ids (managed by code snippet)', 'input_type': 'tagbox', - 'output_type': 'tagbox' + 'output_type': 'tagbox', + 'vis_vary_on_opts': True, }, 'data_sources': { 'system_defined': True, 'description': 'list of data_sources ids (managed by code snippet)', 'input_type': 'tagbox', - 'output_type': 'data_source' + 'output_type': 'data_source', + }, + 'data_assets': { + 'input_type': 'data_assets', + 'output_type': 'data_assets', }, 'phenoflowid': { 'system_defined': True, @@ -872,4 +999,52 @@ class ONTOLOGY_TYPES(int, enum.Enum, metaclass=IterableMeta): 'input_type': 'inputbox', 'output_type': 'source_reference' }, + 'model_relations': { + 'input_type': 'tagbox', + 'output_type': 'tagbox', + }, + 'validation_measures': { + 'input_type': 'var_selector', + 'output_type': 'var_selector', + 'appearance': { + 'txt': { + 'single': 'Validation Measure', + 'plural': 'Validation Measures', + }, + 'add_btn': { + 'label': 'Create new Measure', + 'icon': '', + }, + 'clear_btn': { + 'label': 'Clear all Measures', + 'icon': '', + }, + }, + }, + 'srv_list': { + 'input_type': 'var_selector', + 'output_type': 'var_selector', + 'appearance': { + 'txt': { + 'single': 'Variable', + 'plural': 'Variables', + }, + 'add_btn': { + 'label': 'Create new Variable', + 'icon': '', + }, + 'clear_btn': { + 'label': 'Clear all Variables', + 'icon': '', + }, + }, + }, + 'indicator_calculation': { + 'input_type': 'indicator_calculation', + 'output_type': 'indicator_calculation', + }, + 'related_entities': { + 'input_type': 'related_entities', + 'output_type': 'related_entities', + }, } diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/create_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/create_utils.py index 53e6e8387..12b6de99b 100644 --- a/CodeListLibrary_project/clinicalcode/entity_utils/create_utils.py +++ b/CodeListLibrary_project/clinicalcode/entity_utils/create_utils.py @@ -1,29 +1,32 @@ +from operator import and_ +from datetime import datetime +from functools import reduce from django.db import transaction, IntegrityError, connection from django.apps import apps from django.db.models import Q from django.utils.timezone import make_aware -from datetime import datetime import logging +import inspect import psycopg2 -from ..models.EntityClass import EntityClass +from ..models.Tag import Tag +from ..models.Code import Code +from ..models.Brand import Brand +from ..models.Concept import Concept +from ..models.CodeList import CodeList from ..models.Template import Template -from ..models.GenericEntity import GenericEntity +from ..models.Component import Component +from ..models.EntityClass import EntityClass from ..models.CodingSystem import CodingSystem -from ..models.Concept import Concept +from ..models.Organisation import Organisation, OrganisationAuthority +from ..models.GenericEntity import GenericEntity from ..models.ConceptCodeAttribute import ConceptCodeAttribute -from ..models.Component import Component -from ..models.CodeList import CodeList -from ..models.Code import Code -from ..models.Tag import Tag -from . import gen_utils -from . import model_utils -from . import permission_utils -from . import template_utils -from . import concept_utils -from . import constants +from . import ( + gen_utils, model_utils, permission_utils, + template_utils, concept_utils, constants +) logger = logging.getLogger(__name__) @@ -48,7 +51,7 @@ def get_createable_entities(request): be created and their associated templates """ entities = EntityClass.objects.all().values('id', 'name', 'description', 'entity_prefix') - templates = Template.objects.filter( + templates = Template.get_brand_records_by_request(request).filter( entity_class__id__in=entities.values_list('id', flat=True) ) \ .exclude(hide_on_create=True) \ @@ -59,37 +62,33 @@ def get_createable_entities(request): 'templates': list(templates) } -def get_template_creation_data(request, entity, layout, field, default=None): +def get_template_creation_data(request, entity, layout, field, default=None, info=None): """ Used to retrieve assoc. data values for specific keys, e.g. concepts, in its expanded format for use with create/update pages """ - data = template_utils.get_entity_field(entity, field) - info = template_utils.get_layout_field(layout, field) - if not info and template_utils.is_metadata(entity, field): - info = template_utils.try_get_content(constants.metadata, field) + if info is None: + info = template_utils.get_template_field_info(layout, field) + data = template_utils.get_entity_field(entity, field) if not info or not data: return default - validation = template_utils.try_get_content(info, 'validation') + field_info = info.get('field') + validation = template_utils.try_get_content(field_info, 'validation') if validation is None: return default field_type = template_utils.try_get_content(validation, 'type') if field_type is None: return default - + if field_type == 'concept': values = [] for item in data: - workingset_concept_attributes = None - if 'attributes' in item: - workingset_concept_attributes = item['attributes'] value = concept_utils.get_clinical_concept_data( item['concept_id'], item['concept_version_id'], - concept_attributes=workingset_concept_attributes, aggregate_component_codes=False, requested_entity_id=entity.id, derive_access_from=request, @@ -99,7 +98,7 @@ def get_template_creation_data(request, entity, layout, field, default=None): if value: values.append(value) - + return values elif field_type == 'int_array': source_info = validation.get('source') @@ -113,17 +112,19 @@ def get_template_creation_data(request, entity, layout, field, default=None): logger.warning(f'Failed to retrieve template "{field}" property from Entity<{entity}> with err: {e}') return default - if template_utils.is_metadata(entity, field): - return template_utils.get_metadata_value_from_source(entity, field, default=default) + if info.get('is_metadata'): + return template_utils.get_metadata_value_from_source(entity, field, field_info=info, layout=layout, default=default) return template_utils.get_template_data_values(entity, layout, field, default=default) -def try_add_computed_fields(field, form_data, form_template, data): +def try_add_computed_fields(field, form_data, form_template, data, field_data=None): """ Checks to see if any of our fields have any computed data that we need to collect from a child or related field """ - field_data = template_utils.get_layout_field(form_template, field) + if field_data is None: + field_data = template_utils.get_layout_field(form_template, field) + if field_data is None: return @@ -305,21 +306,18 @@ def try_validate_sourced_value(field, template, data, default=None, request=None try: source_info = validation.get('source') model = apps.get_model(app_label='clinicalcode', model_name=model_name) - - if isinstance(data, list): - query = { - 'pk__in': data - } - else: - query = { - 'pk': data - } + query = { 'pk__in': data } if isinstance(data, list) else { 'pk': data } if 'filter' in source_info: filter_query = template_utils.try_get_filter_query(field, source_info.get('filter'), request=request) - query = {**query, **filter_query} - - queryset = model.objects.filter(Q(**query)) + if isinstance(filter_query, list): + query = [Q(**query), *filter_query] + + if isinstance(query, list): + queryset = model.objects.filter(reduce(and_, query)) + else: + queryset = model.objects.filter(**query) + queryset = list(queryset.values_list('id', flat=True)) if isinstance(data, list): @@ -450,6 +448,84 @@ def validate_template_field(template, field): return field in fields +def validate_freeform_field(request, field, field_data, value, errors=[]): + """ + Validates fields with behaviour variations, e.g. freeform tags + """ + validation = template_utils.try_get_content(field_data, 'validation') + source = validation.get('source') if isinstance(validation, dict) else { } + + model = source.get('table') + query = source.get('query') + relative = source.get('relative') + + if value is not None and not isinstance(value, list): + if value is None: + return None + + errors.append(f'Expected {field} as array, got {type(value)}') + return None + + data_ids = [ x.get('value') for x in value if x is not None and gen_utils.is_int(x.get('value')) ] + data_str = [ x.get('name').strip() for x in value if x is None or not gen_utils.is_int(x.get('value')) ] + + creatable = None + invalid = gen_utils.is_empty_string(model) or gen_utils.is_empty_string(query) or gen_utils.is_empty_string(relative) + if len(data_str) > 0 and not invalid: + model = f'clinicalcode_{model}'.lower() + with connection.cursor() as cursor: + sql = psycopg2.sql.SQL(''' + select + distinct on ({relative}) + {field} as id, + {relative} as name + from {model} as item + where lower({relative}::text) = any(%(str_comp)s::text[]); + ''') \ + .format( + field=psycopg2.sql.Identifier(query), + model=psycopg2.sql.Identifier(model), + relative=psycopg2.sql.Identifier(relative) + ) + + cursor.execute(sql, params={ + 'str_comp': [ x.lower() for x in data_str ], + }) + + columns = [col[0] for col in cursor.description] + results = [dict(zip(columns, row)) for row in cursor.fetchall()] + + resultset = [] + remainder = list(set(data_str.copy())) + for x in results: + try: + idx = remainder.index(x.get('name')) + if idx is not None: + remainder.pop(idx) + except: + pass + + resultset.append(x.get('id')) + + if len(resultset) > 0: + data_ids = list(set(data_ids + resultset)) + else: + data_ids = list(set(data_ids)) + + if len(remainder) > 0: + creatable = remainder + + if 'source' in validation or 'options' in validation: + field_value = try_validate_sourced_value(field, field_data, data_ids, request=request) + if not isinstance(field_value, list) or len(field_value) < 1: + field_value = None + + if field_value is not None or creatable is not None: + return { 'value': field_value, 'creatable': creatable } + + return None + + def validate_computed_field(request, field, field_data, value, errors=[]): """ Computed fields, e.g. Groups, that can be computed based on RequestContext @@ -459,6 +535,8 @@ def validate_computed_field(request, field, field_data, value, errors=[]): errors.append('RequestContext invalid') return None + current_brand = model_utils.try_get_brand(request) + validation = template_utils.try_get_content(field_data, 'validation') if validation is None: return value @@ -469,29 +547,48 @@ def validate_computed_field(request, field, field_data, value, errors=[]): field_info = GenericEntity._meta.get_field(field) if not field_info: - return + return None field_type = field_info.get_internal_type() if field_type != 'ForeignKey': - return + return None model = field_info.target_field.model if model is not None: - field_value = gen_utils.try_value_as_type(value, field_type, validation) + field_value = gen_utils.try_value_as_type(value, validation.get('type', field_type), validation) if field_value is None: return None - + instance = model_utils.try_get_instance(model, pk=field_value) if instance is None: errors.append(f'"{field}" is invalid') return None - - if field == 'group': - is_member = user.is_superuser or user.groups.filter(name__iexact=instance.name).exists() + + if field == 'organisation' or field == 'group': + is_member = user.is_superuser or ( + not user.is_anonymous and ( + instance.owner_id == user.id or + user.organisationmembership_set.filter( + Q(organisation_id=instance.id) & + Q(role__gte=constants.ORGANISATION_ROLES.EDITOR.value) + ).exists() + ) + ) if not is_member: - errors.append(f'Tried to set {field} without being a member of that group.') + errors.append(f'Tried to set {field}<{instance}> without being a member of that group.') return None - + + if current_brand and current_brand.org_user_managed: + if not isinstance(instance, Organisation): + errors.append(f'You must supply an Organisation.') + return None + + brand_authority = OrganisationAuthority.objects \ + .filter(organisation_id=instance.id, brand_id=current_brand.id) + if not brand_authority.exists(): + errors.append(f'You are trying to use an Organisation that\'s not associated with this Brand, please use another organisation.') + return None + return instance return value @@ -510,37 +607,14 @@ def validate_concept_form(form, errors): 'components': [ ], } - # Validate concept attributes based on their type - concept_attributes = [] - if form.get('attributes'): - for attribute in form.get('attributes'): - if attribute['value']: - attribute_type = attribute.get('type') - attribute_value = attribute.get('value') - if not gen_utils.is_empty_string(attribute_value): - if attribute_type == 'INT': - try: - attribute_value = str(int(attribute_value)) - except ValueError: - errors.append(f'Attribute {attribute["name"]} must be an integer.') - continue - elif attribute_type == 'FLOAT': - try: - attribute['value'] = str(float(attribute_value)) - except ValueError: - errors.append(f'Attribute {attribute["name"]} must be a float.') - continue - concept_attributes.append(attribute) - if not is_new_concept and concept_id is not None and concept_history_id is not None: concept = model_utils.try_get_instance(Concept, id=concept_id) concept = model_utils.try_get_entity_history(concept, history_id=concept_history_id) if concept is None: - errors.append(f'Child Concept entity with ID {concept_id} and history ID {concept_history_id} does not exist.') + errors.append(f'Child entity with ID {concept_id} and history ID {concept_history_id} does not exist.') return None field_value['concept']['id'] = concept_id field_value['concept']['history_id'] = concept_history_id - field_value['concept']['attributes'] = concept_attributes else: is_new_concept = True @@ -549,19 +623,19 @@ def validate_concept_form(form, errors): concept_details = form.get('details') if is_new_concept and (concept_details is None or not isinstance(concept_details, dict)): - errors.append(f'Invalid concept with ID {concept_id} - details is a non-nullable dict field.') + errors.append(f'Invalid child entity with ID {concept_id} - details is a non-nullable dict field.') return None if isinstance(concept_details, dict): concept_name = gen_utils.try_value_as_type(concept_details.get('name'), 'string', { 'sanitise': 'strict' }) if is_new_concept and concept_name is None: - errors.append(f'Invalid concept with ID {concept_id} - name is non-nullable, string field.') + errors.append(f'Invalid child entity with ID {concept_id} - name is non-nullable, string field.') return None concept_coding = gen_utils.parse_int(concept_details.get('coding_system'), None) concept_coding = model_utils.try_get_instance(CodingSystem, pk=concept_coding) if is_new_concept and concept_coding is None: - errors.append(f'Invalid concept with ID {concept_id} - coding_system is non-nullable int field.') + errors.append(f'Invalid child entity with ID {concept_id} - coding_system is non-nullable int field.') return None attribute_headers = gen_utils.try_value_as_type( @@ -572,7 +646,7 @@ def validate_concept_form(form, errors): concept_components = form.get('components') if is_new_concept and (concept_components is None or not isinstance(concept_components, list)): - errors.append(f'Invalid concept with ID {concept_id} - components is a non-nullable list field.') + errors.append(f'Invalid child entity with ID {concept_id} - components is a non-nullable list field.') return None components = [ ] @@ -585,7 +659,7 @@ def validate_concept_form(form, errors): if not is_new_component and component_id is not None: historical_component = Component.history.filter(id=component_id) if not historical_component.exists(): - errors.append(f'Invalid concept with ID {concept_id} - component is not valid') + errors.append(f'Invalid child entity with ID {concept_id} - component is not valid') return None component['id'] = component_id else: @@ -593,37 +667,37 @@ def validate_concept_form(form, errors): component_name = gen_utils.try_value_as_type(concept_component.get('name'), 'string', { 'sanitise': 'strict' }) if component_name is None or gen_utils.is_empty_string(component_name): - errors.append(f'Invalid concept with ID {concept_id} - Component names are non-nullable, string fields.') + errors.append(f'Invalid child entity with ID {concept_id} - Component names are non-nullable, string fields.') return None component_logical_type = concept_component.get('logical_type') if component_logical_type is None or component_logical_type not in constants.CLINICAL_RULE_TYPE: - errors.append(f'Invalid concept with ID {concept_id} - Component logical types are non-nullable, string fields.') + errors.append(f'Invalid child entity with ID {concept_id} - Component logical types are non-nullable, string fields.') return None component_logical_type = constants.CLINICAL_RULE_TYPE.from_name(component_logical_type) component_source_type = concept_component.get('source_type') if component_source_type is None or component_source_type not in constants.CLINICAL_CODE_SOURCE: - errors.append(f'Invalid concept with ID {concept_id} - Component source types are non-nullable, string fields.') + errors.append(f'Invalid child entity with ID {concept_id} - Component source types are non-nullable, string fields.') return None component_source_type = constants.CLINICAL_CODE_SOURCE.from_name(component_source_type) component_source = concept_component.get('source') if component_source_type == constants.CLINICAL_CODE_SOURCE.SEARCH_TERM and (component_source is None or gen_utils.is_empty_string(component_source)): - errors.append(f'Invalid concept with ID {concept_id} - Component sources are non-nullable, string fields for search terms.') + errors.append(f'Invalid child entity with ID {concept_id} - Component sources are non-nullable, string fields for search terms.') return None component_codes = concept_component.get('codes') component_codes = list() if not isinstance(component_codes, list) else component_codes # if len(component_codes) < 1: - # errors.append(f'Invalid concept with ID {concept_id} - Component codes is a non-nullable, list field') + # errors.append(f'Invalid child entity with ID {concept_id} - Component codes is a non-nullable, list field') # return None codes = { } for component_code in component_codes: code = { } if not isinstance(component_code, dict): - errors.append(f'Invalid concept with ID {concept_id} - Component code items are non-nullable, dict field') + errors.append(f'Invalid child entity with ID {concept_id} - Component code items are non-nullable, dict field') return None is_new_code = is_new_component or component_code.get('is_new') @@ -631,7 +705,7 @@ def validate_concept_form(form, errors): if not is_new_code and code_id is not None: historical_code = Code.history.filter(id=code_id) if not historical_code.exists(): - errors.append(f'Invalid concept with ID {concept_id} - Code is not valid') + errors.append(f'Invalid child entity with ID {concept_id} - Code is not valid') return None code['id'] = code_id else: @@ -639,7 +713,7 @@ def validate_concept_form(form, errors): code_name = gen_utils.try_value_as_type(component_code.get('code'), 'code', { 'sanitise': 'strict' }) if gen_utils.is_empty_string(code_name): - errors.append(f'Invalid concept with ID {concept_id} - A code\'s code is a non-nullable, string field') + errors.append(f'Invalid child entity with ID {concept_id} - A code\'s code is a non-nullable, string field') return None if code_name in codes: @@ -656,7 +730,7 @@ def validate_concept_form(form, errors): if isinstance(code_attributes, list): if len(set(attribute_headers)) != len(code_attributes): - errors.append(f'Invalid concept with ID {concept_id} - attribute headers must be unique.') + errors.append(f'Invalid child entity with ID {concept_id} - attribute headers must be unique.') return None code_attributes = code_attributes[:len(attribute_headers)] @@ -691,7 +765,6 @@ def validate_concept_form(form, errors): field_value['concept']['name'] = concept_name field_value['concept']['coding_system'] = concept_coding field_value['concept']['code_attribute_header'] = attribute_headers - field_value['concept']['attributes'] = concept_attributes field_value['components'] = components return field_value @@ -734,33 +807,47 @@ def validate_related_entities(field, field_data, value, errors): return value -def validate_metadata_value(request, field, value, errors=[]): +def validate_metadata_value(request, field, value, errors=[], field_data=None): """ Validates the form's field value against the metadata fields """ - field_data = template_utils.try_get_content(constants.metadata, field) + if field_data is None: + field_data = template_utils.try_get_content(constants.metadata, field) + if field_data is None: return None, True validation = template_utils.try_get_content(field_data, 'validation') if validation is None: # Exit without error since we haven't included any validation - return value, False + return value, True field_required = template_utils.try_get_content(validation, 'mandatory') if field_required and value is None: + default_value = template_utils.try_get_content(validation, 'default') + if 'default' in validation: + return default_value, True + errors.append(f'"{field}" is a non-nullable, required field') return value, False - + + field_behaviour = template_utils.try_get_content(field_data, 'behaviour') + if field_behaviour is not None and field_behaviour.get('freeform', False): + field_value = validate_freeform_field(request, field, field_data, value, errors) + if field_value is None and field_required: + errors.append(f'"{field}" is invalid.') + return field_value, False + return field_value, True + field_type = template_utils.try_get_content(validation, 'type') - if 'source' in validation or 'options' in validation: + if not validation.get('ugc', False) and ('source' in validation or 'options' in validation): field_value = gen_utils.try_value_as_type(value, field_type, validation) field_value = try_validate_sourced_value(field, field_data, field_value, request=request) if field_value is None and field_required: errors.append(f'"{field}" is invalid') return field_value, False return field_value, True - + field_computed = template_utils.try_get_content(validation, 'computed') if field_computed is not None: field_value = validate_computed_field(request, field, field_data, value, errors) @@ -772,11 +859,13 @@ def validate_metadata_value(request, field, value, errors=[]): field_value = gen_utils.try_value_as_type(value, field_type, validation) return field_value, True -def is_computed_template_field(field, form_template): +def is_computed_template_field(field, form_template, field_data=None): """ Checks whether a field is considered a computed field within its template """ - field_data = template_utils.get_layout_field(form_template, field) + if field_data is None: + field_data = template_utils.get_layout_field(form_template, field) + if field_data is None: return False @@ -790,33 +879,47 @@ def is_computed_template_field(field, form_template): return False -def validate_template_value(request, field, form_template, value, errors=[]): +def validate_template_value(request, field, form_template, value, errors=[], field_data=None): """ Validates the form's field value against the entity template """ - field_data = template_utils.get_layout_field(form_template, field) + if field_data is None: + field_data = template_utils.get_layout_field(form_template, field) + if field_data is None: return None, True - + validation = template_utils.try_get_content(field_data, 'validation') if validation is None: # Exit without error since we haven't included any validation - return value, False - + return value, True + field_required = template_utils.try_get_content(validation, 'mandatory') if field_required and value is None: + default_value = template_utils.try_get_content(validation, 'default') + if 'default' in validation: + return default_value, True + errors.append(f'"{field}" is a non-nullable, required field') return value, False - + + field_behaviour = template_utils.try_get_content(field_data, 'behaviour') + if field_behaviour is not None and field_behaviour.get('freeform', False): + field_value = validate_freeform_field(request, field, field_data, value, errors) + if field_value is None and field_required: + errors.append(f'"{field}" is invalid.') + return field_value, False + return field_value, True + field_type = template_utils.try_get_content(validation, 'type') - if 'source' in validation or 'options' in validation: + if not validation.get('ugc', False) and ('source' in validation or 'options' in validation): field_value = gen_utils.try_value_as_type(value, field_type, validation) field_value = try_validate_sourced_value(field, field_data, field_value, request=request) if field_value is None and field_required: - errors.append(f'"{field}" is invalid') + errors.append(f'"{field}" is invalid.') return field_value, False return field_value, True - + field_computed = template_utils.try_get_content(validation, 'computed') if field_computed is not None: field_value = validate_computed_field(request, field, field_data, value, errors) @@ -867,26 +970,44 @@ def validate_entity_form(request, content, errors=[], method=None): if len(errors) > 0: return - + + current_brand = model_utils.try_get_brand(request) + current_brand = current_brand if current_brand and current_brand.org_user_managed else None + if current_brand is not None: + organisation = form_data.get('organisation') + if organisation is None: + errors.append('Your work must be associated with an organisation') + return + + valid_user_orgs = permission_utils.get_user_organisations(request) + if organisation not in [org.get('id') for org in valid_user_orgs]: + errors.append('Your organisation doesn\'t have authorisation to post this work') + return + # Validate & Clean the form data top_level_data = { } template_data = { } for field, value in form_data.items(): - if template_utils.is_metadata(GenericEntity, field): - field_value, validated = validate_metadata_value(request, field, value, errors) + if field == 'group': + field = 'organisation' + + info = template_utils.get_template_field_info(form_template, field) + struct = info.get('field') + if info.get('is_metadata'): + field_value, validated = validate_metadata_value(request, field, value, errors, field_data=struct) if not validated or field_value is None: continue top_level_data[field] = field_value - elif validate_template_field(form_template, field): + elif struct: if is_computed_template_field(field, form_template): continue - field_value, validated = validate_template_value(request, field, form_template, value, errors) + field_value, validated = validate_template_value(request, field, form_template, value, errors, field_data=struct) if not validated or field_value is None: continue template_data[field] = field_value - try_add_computed_fields(field, form_data, form_template, template_data) + try_add_computed_fields(field, form_data, form_template, template_data, field_data=struct) if len(errors) > 0: return @@ -1194,7 +1315,7 @@ def build_related_entities(request, field_data, packet, override_dirty=False, en if override_dirty or concept.get('is_dirty'): result = try_update_concept(request, item) if result is not None: - entities.append({'method': 'update', 'entity': result, 'historical': result.history.latest(), 'attributes': concept.get('attributes')}) + entities.append({'method': 'update', 'entity': result, 'historical': result.history.latest() }) continue # If we're not dirty, append the current concept @@ -1203,51 +1324,109 @@ def build_related_entities(request, field_data, packet, override_dirty=False, en result = model_utils.try_get_instance(Concept, id=concept_id) historical = model_utils.try_get_entity_history(result, history_id=concept_history_id) if historical is not None: - entities.append({ 'method': 'set', 'entity': result, 'historical': historical, 'attributes': concept.get('attributes') }) + entities.append({ 'method': 'set', 'entity': result, 'historical': historical }) continue # Create new concept & components result = try_create_concept(request, item, entity=entity) if result is None: continue - entities.append({ 'method': 'create', 'entity': result, 'historical': result.history.latest(), 'attributes': concept.get('attributes') }) + entities.append({ 'method': 'create', 'entity': result, 'historical': result.history.latest() }) # Build concept list return True, [ 'phenotype_owner', [obj.get('entity') for obj in entities if obj.get('method') == 'create'], - [{ 'concept_id': obj.get('historical').id, 'concept_version_id': obj.get('historical').history_id, 'attributes': obj.get('attributes')} for obj in entities] + [{ 'concept_id': obj.get('historical').id, 'concept_version_id': obj.get('historical').history_id } for obj in entities] ] + elif field_type == 'int_array': + behaviour = template_utils.try_get_content(field_data, 'behaviour') + defaults = behaviour.get('defaults', None) if isinstance(behaviour, dict) and isinstance(behaviour.get('defaults'), dict) else {} + branding = behaviour.get('branding', None) if isinstance(behaviour, dict) and isinstance(behaviour.get('branding'), str) else None + + if branding: + brand = model_utils.try_get_brand(request) + defaults[branding] = brand if isinstance(brand, Brand) else None + + values = packet.get('value') if isinstance(packet.get('value'), list) else None + creatable = packet.get('creatable') if isinstance(packet.get('creatable'), list) else [] + + source_info = validation.get('source') + model = source_info.get('table') if isinstance(source_info, dict) else None + query = source_info.get('query') if isinstance(source_info, dict) else None + relative = source_info.get('relative') if isinstance(source_info, dict) else None + + if gen_utils.is_empty_string(model) or gen_utils.is_empty_string(query) or gen_utils.is_empty_string(relative): + return True, [None, None, values] + + try: + table = apps.get_model(app_label='clinicalcode', model_name=model) + except: + table = None + + if table is None: + return True, [None, None, values] + + entities = [ ] + for item in creatable: + if gen_utils.is_empty_string(item): + continue + entities.append(table(**({ relative: item } | defaults))) + + entities = table.objects.bulk_create(entities) if len(entities) > 0 else [] + entities = [getattr(x, query) for x in entities] + entities += (values if isinstance(values, list) else []) + + if len(entities) < 1: + entities = None + + return True, [None, None, entities] return False, None -def compute_brand_context(request, form_data): - """ - Computes the brand context given the metadata of an entity, - where brand is computed by the RequestContext's brand and its - given collections +def compute_brand_context(request, form_data, form_entity=None): """ - related_brands = set([]) + Computes the brand context given the metadata of an entity, where brand is computed by the RequestContext's brand and its given collections + + Args: + request (RequestContext): the HTTP request ctx + form_data (dict): the form data assoc. with this request + form_entity (GenericEntity|None): the entity assoc. with this request, if applicable + Returns: + A (list) specifying a unique set of brands assoc. with this entity + """ brand = model_utils.try_get_brand(request) - if brand: + metadata = form_data.get('metadata') if isinstance(form_data.get('metadata'), dict) else {} + compute_collections = True + + # Derive current brand ctx from the entity + if isinstance(form_entity, GenericEntity) or (inspect.isclass(form_entity) and issubclass(form_entity, GenericEntity)): + related_brands = getattr(form_entity, 'brands') if isinstance(getattr(form_entity, 'brands'), list) else [] + related_brands = set(related_brands) + else: + related_brands = set([]) + + # Compute request brand context + if brand is not None: + # Add current brand ctx related_brands.add(brand.id) - - metadata = form_data.get('metadata') - if not metadata: - return list(related_brands) - - collections = metadata.get('collections') - if isinstance(collections, list): - for collection_id in collections: - collection = Tag.objects.filter(id=collection_id) - if not collection.exists(): - continue - - brand = collection.first().collection_brand - if brand is None: - continue - related_brands.add(brand.id) + + # Det. whether to compute brands from collection(s) + ctx = getattr(brand, 'overrides').get('ignore_collection_ctx') if isinstance(getattr(brand, 'overrides'), dict) else None + if isinstance(ctx, bool) and ctx: + compute_collections = False + + # Compute brand collection ctx if applicable for this request's Brand context + if compute_collections: + collections = metadata.get('collections') + if isinstance(collections, list): + collections = gen_utils.parse_as_int_list(collections, default=[]) + collections = Tag.objects.filter(Q(id__in=collections) & Q(collection_brand__isnull=False)) + collections = list(collections.values_list('collection_brand_id', flat=True)) + if len(collections) > 0: + related_brands.update(collections) + return list(related_brands) @transaction.atomic @@ -1302,9 +1481,11 @@ def create_or_update_entity_from_form(request, form, errors=[], override_dirty=F errors.append('You do not have permissions to modify this entity') return form_entity = entity - + else: + form_entity = None + # Build related brand instances - related_brands = compute_brand_context(request, form_data) + related_brands = compute_brand_context(request, form_data, form_entity) # Atomically create the instance and its children entity = None @@ -1313,23 +1494,33 @@ def create_or_update_entity_from_form(request, form, errors=[], override_dirty=F # Build any validated children template_data = { } new_entities = [ ] - for field, packet in template.items(): - field_data = template_utils.get_layout_field(form_template, field) - if field_data is None: - continue + for group in [metadata, template]: + for field, packet in group.items(): + info = template_utils.get_template_field_info(form_template, field) + field_data = info.get('field') + if field_data is None: + continue - validation = template_utils.try_get_content(field_data, 'validation') - if validation is None or not validation.get('has_children'): - template_data[field] = packet - continue - - success, res = build_related_entities(request, field_data, packet, override_dirty, entity=form_entity) - if not success or not res: - continue + validation = template_utils.try_get_content(field_data, 'validation') + freeform = template_utils.try_get_content(field_data, 'behaviour') + freeform = freeform.get('freeform', False) if isinstance(freeform, dict) else None - ownership_key, created_entities, field_value = res - template_data[field] = field_value - new_entities.append({'field': ownership_key, 'entities': created_entities}) + data_value = packet + if validation is not None and (validation.get('has_children') or freeform): + success, res = build_related_entities(request, field_data, packet, override_dirty, entity=form_entity) + if not success or not res: + continue + + ownership_key, created_entities, field_value = res + data_value = field_value + + if ownership_key is not None: + new_entities.append({'field': ownership_key, 'entities': created_entities}) + + if not info.get('is_metadata'): + template_data[field] = data_value + else: + metadata[field] = data_value # Create or update the entity template_data['version'] = form_template.template_version @@ -1347,9 +1538,12 @@ def create_or_update_entity_from_form(request, form, errors=[], override_dirty=F elif form_method == constants.FORM_METHODS.UPDATE: entity = form_entity - group = metadata.get('group') - if not group and permission_utils.has_derived_edit_access(request, entity.id): - group = entity.group + org = metadata.get('organisation') + if not org and permission_utils.has_derived_edit_access(request, entity.id): + try: + org = entity.organisation + except Organisation.DoesNotExist: + org = None entity.name = metadata.get('name') entity.status = constants.ENTITY_STATUS.DRAFT @@ -1361,9 +1555,10 @@ def create_or_update_entity_from_form(request, form, errors=[], override_dirty=F entity.tags = metadata.get('tags') entity.collections = metadata.get('collections') entity.publications = metadata.get('publications') - entity.group = group - entity.group_access = metadata.get('group_access') - entity.world_access = metadata.get('world_access') + entity.organisation = org + entity.owner_access = metadata.get('owner_access', entity.owner_access) + entity.world_access = metadata.get('world_access', entity.world_access) + entity.group_access = metadata.get('group_access', entity.group_access) entity.template = template_instance entity.template_version = form_template.template_version entity.template_data = template_data @@ -1373,7 +1568,7 @@ def create_or_update_entity_from_form(request, form, errors=[], override_dirty=F entity.brands = related_brands entity.save() - # Update child related entities with entity object + # Update child related entities with entity object for group in new_entities: field = group.get('field') instances = group.get('entities') diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/email_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/email_utils.py index 50162efa5..368e49a1d 100644 --- a/CodeListLibrary_project/clinicalcode/entity_utils/email_utils.py +++ b/CodeListLibrary_project/clinicalcode/entity_utils/email_utils.py @@ -1,19 +1,73 @@ from django.conf import settings -from django.contrib.auth.models import User +from django.db.models import Model +from email.mime.image import MIMEImage from django.core.mail import BadHeaderError, EmailMultiAlternatives +from django.contrib.auth import get_user_model from django.template.loader import render_to_string from django.contrib.staticfiles import finders -from email.mime.image import MIMEImage -import datetime +import os import logging +import datetime +from clinicalcode.entity_utils import model_utils, gen_utils from clinicalcode.models.Phenotype import Phenotype from clinicalcode.models.PublishedPhenotype import PublishedPhenotype + +User = get_user_model() logger = logging.getLogger(__name__) + +def send_invite_email(request, invite): + brand_title = model_utils.try_get_brand_string(request.BRAND_OBJECT, 'site_title', default='Concept Library') + + owner_email = User.objects.filter(id=invite.user_id) + if not owner_email.exists(): + return + owner_email = owner_email.first().email + + if not owner_email or len(owner_email.strip()) < 1: + return + + email_subject = f'{brand_title} - Organisation Invite' + email_content = render_to_string( + 'clinicalcode/email/invite_email.html', + { + 'invite': { + 'uuid': invite.id + } + }, + request=request + ) + + if not settings.IS_DEVELOPMENT_PC or settings.HAS_MAILHOG_SERVICE: + try: + branded_imgs = get_branded_email_images(request.BRAND_OBJECT) + + msg = EmailMultiAlternatives( + email_subject, + email_content, + settings.DEFAULT_FROM_EMAIL, + to=[owner_email] + ) + msg.content_subtype = 'related' + msg.attach_alternative(email_content, "text/html") + + msg.attach(attach_image_to_email(branded_imgs.get('apple', 'img/email_images/apple-touch-icon.jpg'), 'mainlogo')) + msg.attach(attach_image_to_email(branded_imgs.get('logo', 'img/email_images/combine.jpg'), 'sponsors')) + msg.send() + return True + except BadHeaderError as error: + logging.error(f'Failed to send emails to:\n- Targets: {owner_email}\n-Error: {str(error)}') + return False + else: + logging.info(f'Scheduled emails sent:\n- Targets: {owner_email}') + return True + + def send_review_email_generic(request,data,message_from_reviewer=None): + brand_title = model_utils.try_get_brand_string(request.BRAND_OBJECT, 'site_title', default='Concept Library') owner_email = User.objects.filter(id=data.get('entity_user_id','')) owner_email = owner_email.first().email if owner_email and owner_email.exists() else '' staff_emails = data.get('staff_emails', []) @@ -22,7 +76,7 @@ def send_review_email_generic(request,data,message_from_reviewer=None): if len(owner_email.strip()) > 1: all_emails.append(owner_email) - email_subject = 'Concept Library - Phenotype %s: %s' % (data['id'], data['message']) + email_subject = '%s - %s: %s' % (brand_title, data['id'], data['message']) email_content = render_to_string( 'clinicalcode/email/email_content.html', data, @@ -30,6 +84,8 @@ def send_review_email_generic(request,data,message_from_reviewer=None): ) if not settings.IS_DEVELOPMENT_PC or settings.HAS_MAILHOG_SERVICE: try: + branded_imgs = get_branded_email_images(request.BRAND_OBJECT) + msg = EmailMultiAlternatives(email_subject, email_content, settings.DEFAULT_FROM_EMAIL, @@ -37,10 +93,9 @@ def send_review_email_generic(request,data,message_from_reviewer=None): ) msg.content_subtype = 'related' msg.attach_alternative(email_content, "text/html") - - msg.attach(attach_image_to_email('img/email_images/apple-touch-icon.jpg','mainlogo')) - msg.attach(attach_image_to_email('img/email_images/combine.jpg','sponsors')) + msg.attach(attach_image_to_email(branded_imgs.get('apple', 'img/email_images/apple-touch-icon.jpg'), 'mainlogo')) + msg.attach(attach_image_to_email(branded_imgs.get('logo', 'img/email_images/combine.jpg'), 'sponsors')) msg.send() return True except BadHeaderError as error: @@ -49,7 +104,8 @@ def send_review_email_generic(request,data,message_from_reviewer=None): else: logging.info(f'Scheduled emails sent:\n- Targets: {all_emails}') return True - + + def attach_image_to_email(image,cid): with open(finders.find(image), 'rb') as f: img = MIMEImage(f.read()) @@ -59,6 +115,33 @@ def attach_image_to_email(image,cid): return img +def get_branded_email_images(brand=None): + """ + Gets the brand-related e-mail image path(s) + + Args: + brand (Brand|dict|None): the brand from which to resolve the info + + Returns: + A (dict) with key-value pairs specifying the `logo`, `apple`, and `favicon` path target(s) + """ + if isinstance(brand, dict): + path = brand.get('logo_path', None) + elif isinstance(brand, Model): + path = getattr(brand, 'logo_path', None) if hasattr(brand, 'logo_path') else None + else: + path = None + + if path is None or gen_utils.is_empty_string(path): + path = settings.APP_LOGO_PATH + + return { + 'logo': os.path.join(path, 'header_logo.png'), + 'apple': os.path.join(path, 'apple-touch-icon.png'), + 'favicon': os.path.join(path, 'favicon-32x32.png'), + } + + def get_scheduled_email_to_send(): HDRUK_pending_phenotypes = PublishedPhenotype.objects.filter(approval_status=1) HDRUK_declined_phenotypes = PublishedPhenotype.objects.filter(approval_status=3) @@ -91,17 +174,17 @@ def get_scheduled_email_to_send(): review_message = '' if result['data'][i]['approval_status'] == 1: review_decision = 'Pending' - review_message = "Phenotype is waiting to be approved" + review_message = "Your work is waiting to be approved" elif result['data'][i]['approval_status'] == 3: review_decision = 'Declined' - review_message = 'Phenotype has been declined' + review_message = 'Your work has been declined' owner_email = User.objects.get(id=phenotype_owner_id).email if owner_email == '': return False email_message = '''

- Phenotype:
{id} - {name}

+ Entity:
{id} - {name}

Decision:
{decision}

Reviewer message:
{message} '''.format(id=phenotype_id, name=phenotype_name, decision=review_decision, message=review_message) diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/entity_db_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/entity_db_utils.py index feddbc9a1..20c14c05a 100644 --- a/CodeListLibrary_project/clinicalcode/entity_utils/entity_db_utils.py +++ b/CodeListLibrary_project/clinicalcode/entity_utils/entity_db_utils.py @@ -123,26 +123,6 @@ def get_concept_ids_from_phenotypes(data, return_id_or_history_id="both"): return [] -def getConceptBrands(request, concept_list): - ''' - return concept brands - ''' - conceptBrands = {} - concepts = Concept.objects.filter(id__in=concept_list).values('id', 'name', 'group') - - for c in concepts: - conceptBrands[c['id']] = [] - if c['group'] != None: - g = Group.objects.get(pk=c['group']) - for item in request.BRAND_GROUPS: - for brand, groups in item.items(): - if g.name in groups: - #conceptBrands[c['id']].append('' + brand + '') - conceptBrands[c['id']].append(brand) - - return conceptBrands - - #============================================================================= # TO BE CONVERTED TO THE GENERIC ENTITY ....... @@ -483,7 +463,7 @@ def get_entity_full_template_data(entity_record, template_id, return_queryset_as entity_data_sources = fields_data[field_name]['value'] if entity_data_sources: if return_queryset_as_list: - data_sources = list(DataSource.objects.filter(pk__in=entity_data_sources).values('datasource_id', 'name', 'url')) + data_sources = list(DataSource.objects.filter(pk__in=entity_data_sources).values('id', 'name', 'url')) else: data_sources = DataSource.objects.filter(pk__in=entity_data_sources) fields_data[field_name]['value'] = data_sources @@ -750,10 +730,16 @@ def get_concept_ids_versions_of_historical_phenotype(phenotype_id, phenotype_his ''' concept_id_version = [] - concept_information = GenericEntity.history.get(id=phenotype_id, history_id=phenotype_history_id).template_data['concept_information'] + concept_information = GenericEntity.history.get(id=phenotype_id, history_id=phenotype_history_id).template_data + concept_information = concept_information.get('concept_information') if isinstance(concept_information, dict) else None if concept_information: - for c in concept_information: - concept_id_version.append((c['concept_id'], c['concept_version_id'])) + concept_id_version = [ + (c['concept_id'], c['concept_version_id']) + for c in concept_information + if isinstance(c.get('concept_id'), int) and isinstance(c.get('concept_version_id'), int) + ] + else: + concept_id_version = [] return concept_id_version diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/filter_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/filter_utils.py index 4038668b9..d5a3783c3 100644 --- a/CodeListLibrary_project/clinicalcode/entity_utils/filter_utils.py +++ b/CodeListLibrary_project/clinicalcode/entity_utils/filter_utils.py @@ -1,6 +1,11 @@ +from operator import or_ +from functools import reduce +from django.db.models import Q, Model + import inspect -from . import model_utils +from . import gen_utils +from ..models import Brand def is_class_method(method, expected_class): """ @@ -34,8 +39,7 @@ def test_params(cls, expected_params, **kwargs): **kwargs: the passed parameters Returns: - (boolean) that reflects whether the kwargs incl. the expected - parameters + A boolean reflecting the kwargs validity as described by the expected parameters arg """ for key, expected in expected_params.items(): item = kwargs.get(key) @@ -58,22 +62,22 @@ def try_generate_filter(cls, desired_filter, expected_params=None, **kwargs): by ENTITY_FILTER_PARAMS[(%s)].PROPERTIES Returns: - Either (a) a null result if it fails or (b) the generated filter query + Either (a) a `None` type result if it fails or (b) the generated filter query """ desired_filter = getattr(cls, desired_filter) if desired_filter is None or not is_class_method(desired_filter, cls): - return - + return None + if isinstance(expected_params, dict) and not cls.test_params(expected_params, **kwargs): - return - + return None + return desired_filter(**kwargs) """ Filters """ @classmethod - def brand_filter(cls, request, column_name): + def brand_filter(cls, **kwargs): """ - @desc Generates the brand-related filter query + Generates the brand-related filter query Args: request (RequestContext): the request context during this execution @@ -81,12 +85,57 @@ def brand_filter(cls, request, column_name): column_name (string): the name of the column to filter by Result: - Either (a) a null result or (b) the generated filter query + Either (a) a `None` type result or (b) the generated filter query """ - current_brand = model_utils.try_get_brand(request) - if current_brand is None: - return - - return { - f'{column_name}': current_brand.id - } + column_name = kwargs.get('column_name', None) + if not column_name: + return None + + request = kwargs.get('request', None) + if request is None: + current_brand = kwargs.get('brand_target', None) + else: + current_brand = getattr(request, 'BRAND_OBJECT', None) + if not isinstance(current_brand, Model): + current_brand = getattr(request, 'CURRENT_BRAND', None) + if not isinstance(current_brand, str) or gen_utils.is_empty_string(current_brand): + current_brand = None + else: + current_brand = Brand.objects.filter(name=current_brand) + if current_brand.exists(): + current_brand = current_brand.first() + else: + current_brand = None + + if not isinstance(current_brand, Model): + return None + + target_id = current_brand.id + source_value = kwargs.get('source_value') + + result = [ Q(**{column_name: target_id}) ] + if isinstance(source_value, dict): + # Modify behaviour on brand request target + modifier = source_value.get(current_brand.name) + if isinstance(modifier, bool) and not modifier: + # Don't apply filter if this request target ignores brand context + return [] + elif isinstance(modifier, str) and modifier == 'allow_null': + # Allow null values as well as request target + result.append(Q(**{f'{column_name}__isnull': True})) + result = [reduce(or_, result)] + elif isinstance(modifier, dict): + allowed_brands = gen_utils.parse_as_int_list(modifier.get('allowed_brands'), None) + if isinstance(allowed_brands, list): + # Vary the filter if this request target desires different behaviour + if target_id not in allowed_brands: + allowed_brands = [target_id, *allowed_brands] + + result = [ Q(**{f'{column_name}__id__in': allowed_brands}) ] + + if modifier.get('allow_null', False): + # Allow null values + result.append(Q(**{f'{column_name}__isnull': True})) + result = [reduce(or_, result)] + + return result diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/gen_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/gen_utils.py index f21300644..601457043 100644 --- a/CodeListLibrary_project/clinicalcode/entity_utils/gen_utils.py +++ b/CodeListLibrary_project/clinicalcode/entity_utils/gen_utils.py @@ -1,25 +1,36 @@ -from uuid import UUID from json import JSONEncoder -from functools import wraps from typing import Pattern from dateutil import parser as dateparser +from functools import wraps +from django.http import HttpRequest +from django.apps import apps from django.conf import settings +from django.db.models import Q, Model +from django.core.cache import cache from django.http.response import JsonResponse +from rest_framework.request import Request from django.core.exceptions import BadRequest +from django.utils.translation import gettext_lazy as _ +from django.contrib.auth.models import Group from django.http.multipartparser import MultiPartParser import re import time import json -import datetime -import logging +import uuid import urllib +import inspect +import hashlib +import logging +import datetime from cll.settings import Symbol from . import constants, sanitise_utils + logger = logging.getLogger(__name__) + def is_datetime(x): """ Legacy method from ./utils.py @@ -42,6 +53,9 @@ def is_float(x): Desc: - "Checks if a param can be parsed as a float" """ + if x is None: + return False + try: a = float(x) except ValueError: @@ -57,6 +71,9 @@ def is_int(x): Desc: - "Checks if a number is an integer" """ + if x is None: + return False + try: a = float(x) b = int(a) @@ -84,9 +101,7 @@ def clean_str_as_db_col_name(txt): def try_parse_form(request): - """ - Attempts to parse multipart/form-data from the request body - """ + """Attempts to parse multipart/form-data from the request body""" try: parser = MultiPartParser(request.META, request.body, request.upload_handlers) post, files = parser.parse() @@ -97,9 +112,7 @@ def try_parse_form(request): def get_request_body(request): - """ - Decodes the body of a request and attempts to load it as JSON - """ + """Decodes the body of a request and attempts to load it as JSON""" try: body = request.body.decode('utf-8') body = json.loads(body) @@ -136,10 +149,10 @@ def is_empty_string(value): Checks whether a string is empty or contains only spaces Args: - value (string): the value to check + value (str): the value to check Returns: - boolean + A (bool) reflecting whether the value is an empty string and/or contains only spaces """ if value is None: return True @@ -163,10 +176,7 @@ def is_fetch_request(request): def handle_fetch_request(request, obj, *args, **kwargs): - """ - @desc Parses the X-Target header to determine which GET method - to respond with - """ + """Parses the X-Target header to determine which GET method to respond with""" target = request.headers.get('X-Target', None) if target is None or target not in obj.fetch_methods: raise BadRequest('No such target') @@ -184,7 +194,7 @@ def decode_uri_parameter(value, default=None): Decodes an ecoded URI parameter e.g. 'wildcard:C\d+' encoded as 'wildcard%3AC%5Cd%2B' Args: - value (string): the value to decode + value (str): the value to decode default (*): the default value to return if this method fails Returns: @@ -205,9 +215,9 @@ def jsonify_response(**kwargs): Creates a JSON response with the given status Args: - code (integer): the status code - status (string): the status response - message (string): the message response + code (int): the status code + status (str): the status response + message (str): the message response Returns: A JSONResponse that matches the kwargs @@ -252,19 +262,63 @@ def try_match_pattern(value, pattern, flags=re.IGNORECASE): def is_valid_uuid(value): """ - Validates value as a UUID + Validates value as a `UUID` + + Args: + value (Any): some value to evaluate + + Returns: + A (bool) value specifying whether this value is a valid `UUID` """ + if isinstance(value, uuid.UUID): + return True + elif value is None or not isinstance(value, (str, int)): + return False + + typed = 'int' if isinstance(value, int) else 'hex' try: - uuid = UUID(value) + uid = uuid.UUID(**{typed: value}) except ValueError: return False - return str(uuid) == value + return getattr(uid, typed, None) == value + + +def parse_uuid(value, default=None): + """ + Attempts to parse a `UUID` from a value, if it fails to do so, returns the default value + + Args: + value (Any): some value to parse + default (Any): optionally specify the default return value if the given value is both (1) not an `UUID` and (2) cannot be cast (or coerced) to a `UUID`; defaults to `None` + + Returns: + A (uuid.UUID) value, if applicable; otherwise returns the specified default value + """ + if isinstance(value, uuid.UUID): + return value + elif value is None or not isinstance(value, (str, int)): + return default + + typed = 'int' if isinstance(value, int) else 'hex' + try: + uid = uuid.UUID(**{typed: value}) + except ValueError: + return default + + return value if getattr(uid, typed, None) == value else default def parse_int(value, default=None): """ - Attempts to parse an int from a value, if it fails to do so, returns the default value + Attempts to parse an `int` from a value, if it fails to do so, returns the default value + + Args: + value (Any): some value to parse + default (Any): optionally specify the default return value if the given value is both (1) not an `int` and (2) cannot be cast to a `int`; defaults to `None` + + Returns: + A (int) value, if applicable; otherwise returns the specified default value """ if isinstance(value, int): return value @@ -280,6 +334,31 @@ def parse_int(value, default=None): return value +def parse_float(value, default=None): + """ + Attempts to parse a `float` from a value, if it fails to do so, returns the default value + + Args: + value (Any): some value to parse + default (Any): optionally specify the default return value if the given value is both (1) not a `float` and (2) cannot be cast to a `float`; defaults to `None` + + Returns: + A (float) value, if applicable; otherwise returns the specified default value + """ + if isinstance(value, float): + return value + + if value is None: + return default + + try: + value = float(value) + except: + return default + else: + return value + + def get_start_and_end_dates(daterange): """ Sorts a date range to [min, max] and sets their timepoints to the [start] and [end] of the day respectively @@ -341,6 +420,237 @@ def parse_as_int_list(value, default=Symbol('EmptyList')): return default + +def parse_model_field_query(model, params, ignored_fields=None, default=None): + """ + Attempts to parse ORM query fields & desired values for the specified model given the request parameters + + Args: + model (Model): the model from which to build the ORM query + params (dict[str, str]|Request|HttpRequest): either the query parameter dict or the request assoc. with this query + ignored_fields (list[str]): optionally specify a list of fields on the given model to ignore when building the query; defaults to `None` + default (Any): optionally specify the default value to return on failure; defaults to `None` + + Returns: + Either (a) a `dict[str, Any]` containing the ORM query, or (b) the specified `default` param if no query could be resolved + """ + ignored_fields = ignored_fields if isinstance(ignored_fields, list) else None + + if (not inspect.isclass(model) or not issubclass(model, Model)) and not isinstance(model, Model): + return default + + if isinstance(params, Request): + params = params.query_params + elif isinstance(params, HttpRequest): + params = params.GET.dict() + + if not isinstance(params, dict): + return default + + result = None + for field in model._meta.get_fields(): + field_name = field.name + if ignored_fields is not None and field_name in ignored_fields: + continue + + value = params.get(field_name) + if value is None: + continue + + typed = field.get_internal_type() + is_fk = typed == 'ForeignKey' and (field.many_to_one or field.one_to_one) + if is_fk: + typed = field.target_field.get_internal_type() + + query = None + match typed: + case 'AutoField' | 'SmallAutoField' | 'BigAutoField': + if isinstance(value, str): + if is_empty_string(value): + continue + + value = parse_as_int_list(value, default=None) + length = len(value) if isinstance(value, list) else 0 + if length == 1: + value = value[0] + query = f'{field_name}' + elif length > 1: + value = value + query = f'{field_name}__in' + else: + value = None + elif isinstance(value, (int, float, complex)) and not isinstance(value, bool): + try: + value = int(value) + query = f'{field_name}' + except: + pass + elif isinstance(value, list): + arr = [int(x) for x in value if is_int(x, default=None)] + if isinstance(arr, list) and len(arr) > 1: + value = arr + query = f'{field_name}__in' + + case 'BooleanField': + if isinstance(value, str): + if is_empty_string(value): + continue + value = value.lower() in ('y', 'yes', 't', 'true', 'on', '1') + + if isinstance(value, bool): + value = value + query = f'{field_name}' + + case 'SmallIntegerField' | 'PositiveSmallIntegerField' | \ + 'IntegerField' | 'PositiveIntegerField' | \ + 'BigIntegerField' | 'PositiveBigIntegerField': + if isinstance(value, str): + if is_empty_string(value): + continue + + if value.find(','): + arr = [int(x) for x in value.split(',') if parse_int(x, default=None) is not None] + if isinstance(arr, list) and len(arr) > 1: + value = arr + query = f'{field_name}__in' + elif value.find(':'): + bounds = [int(x) for x in value.split(':') if is_int(x)] + if isinstance(bounds, list) and len(bounds) >= 2: + value = [min(bounds), max(bounds)] + query = f'{field_name}__range' + + if query is None: + value = try_value_as_type(value, 'int') + query = f'{field_name}' + elif isinstance(value, (int, float, complex)) and not isinstance(value, bool): + try: + value = int(value) + query = f'{field_name}' + except: + pass + elif isinstance(value, list): + arr = [int(x) for x in value if is_int(x, default=None)] + if isinstance(arr, list) and len(arr) > 1: + value = arr + query = f'{field_name}__in' + + case 'FloatField' | 'DecimalField': + if isinstance(value, str): + if is_empty_string(value): + continue + + if value.find(','): + arr = [float(x) for x in value.split(',') if is_float(x)] + if isinstance(arr, list) and len(arr) > 1: + value = arr + query = f'{field_name}__in' + elif value.find(':'): + bounds = [float(x) for x in value.split(':') if is_float(x)] + if isinstance(bounds, list) and len(bounds) >= 2: + value = [min(bounds), max(bounds)] + query = f'{field_name}__range' + + if query is None: + value = float(value) if is_float(value) else None + query = f'{field_name}' + elif isinstance(value, (int, float, complex)) and not isinstance(value, bool): + try: + value = float(value) + query = f'{field_name}' + except: + pass + elif isinstance(value, list): + arr = [float(x) for x in value if is_float(x, default=None)] + if isinstance(arr, list) and len(arr) > 1: + value = arr + query = f'{field_name}__in' + + case 'SlugField' | 'CharField' | 'TextField': + value = str(value) + if is_fk: + values = value.split(',') + if len(values) > 1: + value = values + query = f'{field_name}__contained_by' + + if query is None: + value = str(value) + query = f'{field_name}__icontains' + + case 'UUIDField' | 'EmailField' | \ + 'URLField' | 'FilePathField': + value = str(value) + if value.find(','): + values = value.split(',') + if len(values) > 1: + value = values + query = f'{field_name}__contained_by' + + if query is None: + value = value + query = f'{field_name}__exact' + + case 'DateField': + if is_empty_string(value): + continue + + try: + bounds = [dateparser.parse(x).date() for x in value.split(':')] if value.find(':') else None + if bounds and len(bounds) >= 2: + value = [min(value), max(value)] + query = f'{field_name}__range' + + if query is None: + value = dateparser.parse(value).date() + query = f'{field_name}' + except: + value = None + + case 'TimeField': + if is_empty_string(value): + continue + + try: + bounds = [dateparser.parse(x).time() for x in value.split(':') if dateparser.parse(x).time()] if value.find(':') else None + if bounds and len(bounds) >= 2: + value = [min(bounds), max(bounds)] + query = f'{field_name}__range' + + if query is None: + value = dateparser.parse(value).time() + query = f'{field_name}' + except: + value = None + + case 'DateTimeField': + if is_empty_string(value): + continue + + try: + bounds = [dateparser.parse(x) for x in value.split(':')] if value.find(':') else None + if bounds and len(bounds) >= 2: + value = [datetime.datetime.combine(x, datetime.time(23, 59, 59, 999)) if not x.time() else x for x in bounds] + value = [min(value), max(value)] + query = f'{field_name}__range' + + if query is None: + value = dateparser.parse(value) + value = datetime.datetime.combine(value, datetime.time(23, 59, 59, 999)) if not value.time() else value + query = f'{field_name}' + except: + value = None + + case _: + pass + + if query is not None and value is not None: + if not isinstance(result, dict): + result = { } + result[query] = value + + return result if isinstance(result, dict) else default + + def parse_prefixed_references(values, acceptable=None, pattern=None, transform=None, should_trim=False, default=None): """ Attempts to parse prefixed references from a list object. Note that this @@ -463,6 +773,25 @@ def try_value_as_type( """ if field_type == 'enum' or field_type == 'int': field_value = parse_int(field_value, default) + if field_value is not None and validation is not None: + limits = validation.get('range') + if isinstance(limits, list) and isinstance(field_type, int) and (field_value < limits[0] or field_value > limits[1]): + return default + + properties = validation.get('properties') + if isinstance(properties, dict): + fmin = parse_int(properties.get('min'), None) + fmax = parse_int(properties.get('max'), None) + + if fmin is None or fmax is None: + return field_value + + if field_value < fmin or field_value > fmax: + return default + + return field_value + elif field_type in constants.NUMERIC_NAMES: + field_value = parse_float(field_value, default) if field_value is not None and validation is not None: limits = validation.get('range') if isinstance(limits, list) and isinstance(field_type, int) and (field_value < limits[0] or field_value > limits[1]): @@ -517,6 +846,83 @@ def try_value_as_type( if not strict_elements: return cleaned return cleaned if valid else default + elif field_type == 'int_range': + properties = validation.get('properties') if validation is not None else None + + fmin = None + fmax = None + if isinstance(field_value, dict): + fmin = parse_int(field_value.get('min'), default) + fmax = parse_int(field_value.get('max'), default) + elif isinstance(field_value, list) and len(field_value) >= 2 and all(is_int(x) for x in field_value): + fmin = parse_int(field_value[0], default) + fmax = parse_int(field_value[1], default) + + if fmin is None or fmax is None: + return default + + if properties is not None: + vrange = properties.get('range') + if isinstance(vrange, list) and len(vrange) >= 2 and all(is_int(x) for x in vrange): + vmin = min(vrange[0], vrange[1]) + vmax = max(vrange[0], vrange[1]) + else: + vmin = parse_int(properties.get('min'), default) + vmax = parse_int(properties.get('max'), default) + + if vmin is not None and vmax is not None: + min_valid = fmin >= vmin + max_valid = fmax <= vmax + if not min_valid or not max_valid: + return default + return field_value + elif field_type.endswith('_range') and field_type.split('_')[0] in constants.NUMERIC_NAMES: + properties = validation.get('properties') if validation is not None else None + + fmin = None + fmax = None + if isinstance(field_value, dict): + fmin = parse_float(field_value.get('min'), default) + fmax = parse_float(field_value.get('max'), default) + elif isinstance(field_value, list) and len(field_value) >= 2 and all(is_float(x) for x in field_value): + fmin = parse_float(field_value[0], default) + fmax = parse_float(field_value[1], default) + + if fmin is None or fmax is None: + return default + + if properties is not None: + vrange = properties.get('range') + if isinstance(vrange, list) and len(vrange) >= 2 and all(is_float(x) for x in vrange): + vmin = min(vrange[0], vrange[1]) + vmax = max(vrange[0], vrange[1]) + else: + vmin = parse_float(properties.get('min'), default) + vmax = parse_float(properties.get('max'), default) + + if vmin is not None and vmax is not None: + min_valid = fmin >= vmin + max_valid = fmax <= vmax + if not min_valid or not max_valid: + return default + return field_value + elif field_type == 'ci_interval': + if not isinstance(field_value, dict): + return default + + lower = parse_float(field_value.get('lower'), None) + upper = parse_float(field_value.get('upper'), None) + probability = parse_float(field_value.get('probability'), None) + + if lower is None or upper is None or probability is None: + return default + + probability = min(max(probability, 0), 100) + return { + 'lower': lower, + 'upper': upper, + 'probability': probability, + } elif field_type == 'code': try: value = str(field_value) if field_value is not None else '' @@ -602,7 +1008,6 @@ def try_value_as_type( else None pattern = validation.get('regex') if isinstance(pattern, (list, str, Pattern)) else None - sanitiser = validation.get('sanitise') if isinstance(validation.get('sanitise'), str) else None for val in field_value: @@ -641,6 +1046,26 @@ def try_value_as_type( if not isinstance(field_value, list): return default return field_value + elif field_type == 'organisation' or field_type == 'group': + if is_int(field_value) or (isinstance(field_value, str) and not is_empty_string(field_value)): + org = apps.get_model(app_label='clinicalcode', model_name='Organisation') + if is_int(field_value): + org = org.objects.filter(Q(pk=int(field_value))) + + if org.exists(): + return org.first().pk + + org = org.objects.filter(Q(name__iexact=field_value) | Q(slug__iexact=field_value)) + if org.exists(): + return org.first().pk if org.exists() else default + + if field_value is not None and is_int(field_value): + group = Group.objects.filter(id=int(field_value)) + field_value = group.first().name if group.exists() else str(field_value) + org = org.objects.filter(Q(name__iexact=field_value) | Q(slug__iexact=field_value)) + return org.first().pk if org.exists() else default + + return default elif field_type == 'url_list': if not isinstance(field_value, list): return default @@ -687,6 +1112,136 @@ def try_value_as_type( break return field_value if valid else default + elif field_type == 'indicator_calculation': + if not isinstance(field_value, dict): + return default + + keys = set(field_value.keys()) + expected_keys = set(['description', 'numerator', 'denominator']) + if not keys.issubset(expected_keys): + return default + + valid = False + output = { } + for key, val in field_value.items(): + if not isinstance(val, str) or is_empty_string(val): + continue + + value = sanitise_utils.sanitise_value(val, method='markdown', default=None) + if value is None or is_empty_string(value): + continue + else: + output[key] = value + valid = True + + return output if valid else default + elif field_type == 'contact': + if not isinstance(field_value, list): + default + + if len(field_value) < 1: + return field_value + + valid = True + for val in field_value: + if not isinstance(val, dict): + valid = False + break + + name = sanitise_utils.sanitise_value(val.get('name'), method='strict', default=None) + if not name or not isinstance(name, str) or is_empty_string(name): + valid = False + break + + email = sanitise_utils.sanitise_value(val.get('email'), method='strict', default=None) + if not email or not isinstance(email, str) or is_empty_string(email): + valid = False + break + return field_value if valid else default + elif field_type == 'var_data': + if not isinstance(field_value, dict): + return default + + options = validation.get('options') if validation is not None and isinstance(validation.get('options'), dict) else None + properties = validation.get('properties') if validation is not None else None + if isinstance(properties, dict): + allow_types = properties.get('allow_types', []) + allow_unknown = properties.get('allow_unknown', False) + allow_description = properties.get('allow_description', False) + else: + allow_types = [] + allow_unknown = False + allow_description = False + + result = { } + for key, item in field_value.items(): + if not isinstance(item, dict): + continue + + out = { 'name': key } + name = try_value_as_type(item.get('name'), 'string', { 'properties': { 'sanitise': 'strict', 'length': [2, 500] } }) + typed = item.get('type') + value = None + + props = options.get(key) if options is not None else None + if isinstance(props, dict) and isinstance(props.get('type'), str): + name = props.get('name') + typed = props.get('type') + value = try_value_as_type(item.get('value'), typed, { 'properties': props }) + + if value is None and allow_unknown and isinstance(allow_types, list): + if not name: + continue + + typed = item.get('type') + if isinstance(typed, str) and typed in allow_types: + value = try_value_as_type(item.get('value'), typed, { 'properties': { 'sanitise': 'strict', 'length': 1024 } }) + + if value is None or typed is None: + continue + + if allow_description: + desc = try_value_as_type(item.get('description'), 'string', { 'properties': { 'sanitise': 'strict', 'length': [2, 500] } }) + if desc is not None: + out.update(description=desc) + + out.update(name=name, type=typed, value=value) + result.update({ key: out }) + + return result + elif field_type == 'related_entities': + if not isinstance(field_value, list): + return default + + if len(field_value) < 1: + return default + + target = validation.get('target') if validation is not None else None + tbl_name = target.get('table') if target is not None else None + selector = target.get('select') if target is not None else None + + properties = validation.get('properties') if validation is not None else None + storage = properties.get('storage') if properties is not None else None + + if not isinstance(tbl_name, str) or not isinstance(storage, list) or not isinstance(selector, list): + return default + + valid = True + result = [] + try: + model = apps.get_model(app_label='clinicalcode', model_name=tbl_name) + for item in field_value: + selection = { k: item.get(k) for k in selector } + entity = model.objects.filter(**selection) + if entity is None or not entity.exists(): + continue + + entity = entity.first() + result.append({ k: getattr(entity, k) for k in storage }) + except: + valid = False + + return result if valid and len(result) > 0 else default elif field_type == 'publication': if not isinstance(field_value, list): return default @@ -720,15 +1275,139 @@ def try_value_as_type( break return field_value if valid else default + elif field_type == 'age_group': + if not isinstance(field_value, dict): + return default + + comparator = field_value.get('comparator') + data_value = field_value.get('value') + + if comparator == 'between': + if not isinstance(data_value, list): + return default + + data_value = try_value_as_type(data_value, 'int_range', validation) + if data_value is None: + return default + else: + if not is_int(data_value): + return default + + data_value = try_value_as_type(data_value, 'int', validation) + if data_value is None: + return default + return field_value return field_value -def measure_perf(func, show_args=False): +def cache_resultset( + cache_age=3600, + cache_params=False, + cache_key=None, + cache_prefix='rs__', + cache_suffix='__ctrg', + debug_metrics=False +): """ - Helper function to estimate view execution time + Tries to parse a value as a given type, otherwise returns default + + .. Note:: + - The `debug_metrics` parameter will only log _perf._ metrics if global `DEBUG` setting is active; + - Cache prefixes & suffixes are ignored if the `cache_key` param is specified; + - Returned values will not be cached if the optionally specified `cache_age` param is less than `1s`; + - Cache results are _NOT_ varied by arguments unless the `cache_params` param is flagged `True`. + + Args: + cache_age (int): optionally specify the max age, in seconds, of the callable's cached result; defaults to `3600` + cache_params (bool): optionally specify whether to vary the cache key by the given parameters; defaults to `False` + cache_key (str): optionally specify the key pair name; defaults to `None` - non-string type cache keys are built using the `cache_prefix` and `cache_suffix` args unless specified + cache_prefix (str): optionally specify the cache key prefix; defaults to `rs__` + cache_suffix (str): optionally specify the cache key suffix; defautls to `__ctrg` + debug_metrics (bool): optionally specify whether to log performance metrics; defaults to `False` - Ref @ https://stackoverflow.com/posts/62522469/revisions + Returns: + The cached value, if applicable + """ + cache_age = max(cache_age if isinstance(cache_age, int) else 0, 0) + has_cache_key = isinstance(cache_key, str) and not is_empty_string(cache_key) + debug_metrics = debug_metrics and settings.DEBUG + + def _cache_resultset(func): + """ + Cache resultset decorator + + Args: + func (Callable): some callable function to decorate + + Returns: + A (Callable) decorator + """ + if not has_cache_key: + cache_key = f'{cache_prefix}{func.__name__}' + + @wraps(func) + def wrapper(*args, **kwargs): + if cache_age < 1: + return func(*args, **kwargs) + + perf_start = None + perf_hashed = None + perf_duration = None + if debug_metrics: + perf_start = time.time() + + if cache_params: + key = hashlib.md5(repr([args, kwargs]).encode('utf-8'), usedforsecurity=False).hexdigest() + key = '%s__%s%s' % (cache_key, key, '' if has_cache_key else cache_suffix) + + perf_hashed = time.time() if debug_metrics else None + else: + key = cache_key + + resultset = cache.get(key) + if not isinstance(resultset, dict): + resultset = func(*args, **kwargs) + resultset = { 'value': resultset, 'timepoint': time.time() } + cache.set(key, resultset, cache_age) + + resultset = resultset.get('value') + + if debug_metrics: + perf_duration = (time.time() - perf_start)*1000 + if perf_hashed: + perf_hashed = (perf_hashed - perf_start)*1000 + logger.info( + ('CachedCallable {\n' + \ + ' - Key: \'%s\'\n' + \ + ' - Perf:\n' + \ + ' - Hashed: %.2f ms\n' + \ + ' - Duration: %.2f ms\n' + \ + '}') % (func.__name__, cache_age, key, perf_hashed, perf_duration) + ) + else: + logger.info( + ('CachedCallable {\n' + \ + ' - Key: \'%s\'\n' + \ + ' - Duration: %.2f ms\n' + \ + '}') % (func.__name__, cache_age, key, perf_duration) + ) + + return resultset + + return wrapper + return _cache_resultset + + +def measure_perf(func): + """ + Helper decorator to estimate view execution time + + Args: + func (Callable): some callable function to decorate + + Returns: + A (Callable) decorator """ @wraps(func) @@ -737,10 +1416,7 @@ def wrapper(*args, **kwargs): start = time.time() result = func(*args, **kwargs) duration = (time.time() - start) * 1000 - if show_args: - print('view {} takes {:.2f} ms... \n 1. args: {}\n 2.kwargs:{}'.format(func.__name__, duration, args, kwargs)) - else: - print('view {} takes {:.2f} ms'.format(func.__name__, duration)) + logger.info('View {\n 1. args: %s\n 2. kwargs: %s\n}\n' % (func.__name__, duration, args, kwargs)) return result return func(*args, **kwargs) @@ -756,3 +1432,12 @@ class ModelEncoder(JSONEncoder): def default(self, obj): if isinstance(obj, (datetime.date, datetime.datetime)): return obj.isoformat() + + +class PrettyPrintOrderedDefinition(json.JSONEncoder): + """ + Indents and prettyprints the definition field so that it's readable + Preserves order that was given by `template_utils.get_ordered_definition` + """ + def __init__(self, *args, indent, sort_keys, **kwargs): + super().__init__(*args, indent=2, sort_keys=False, **kwargs) diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/model_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/model_utils.py index b0cbeee71..735e389e4 100644 --- a/CodeListLibrary_project/clinicalcode/entity_utils/model_utils.py +++ b/CodeListLibrary_project/clinicalcode/entity_utils/model_utils.py @@ -1,25 +1,22 @@ +from django.db import connection from django.apps import apps -from django.db.models import ForeignKey +from django.db.models import Model, ForeignKey +from django.core.cache import cache from django.forms.models import model_to_dict -from django.contrib.auth.models import User, Group +from django.contrib.auth.models import Group +from django.contrib.auth import get_user_model import re import json +import inspect import simple_history -from . import gen_utils -from ..models.GenericEntity import GenericEntity -from ..models.Tag import Tag -from ..models.CodingSystem import CodingSystem -from ..models import Brand -from ..models import Tag -from .constants import (USERDATA_MODELS, STRIPPED_FIELDS, APPROVAL_STATUS, - GROUP_PERMISSIONS, WORLD_ACCESS_PERMISSIONS) +from . import gen_utils, filter_utils, constants + +User = get_user_model() def try_get_instance(model, **kwargs): - """ - Safely attempts to get an instance - """ + """Safely attempts to get an instance""" try: instance = model.objects.get(**kwargs) except: @@ -28,10 +25,7 @@ def try_get_instance(model, **kwargs): return instance def try_get_entity_history(entity, history_id): - """ - Safely attempts to get an entity's historical record given an entity - and a history id - """ + """Safely attempts to get an entity's historical record given an entity and a history id.""" if not entity: return None @@ -43,13 +37,41 @@ def try_get_entity_history(entity, history_id): return instance def try_get_brand(request, default=None): + """Safely get the Brand instance from the RequestContext""" + current_brand = request.BRAND_OBJECT + if inspect.isclass(current_brand) and issubclass(current_brand, Model): + return current_brand + + current_brand = request.CURRENT_BRAND + if gen_utils.is_empty_string(current_brand) or current_brand.lower() == 'ALL': + return default + return try_get_instance(apps.get_model(app_label='clinicalcode', model_name='Brand'), name=current_brand) + +def try_get_brand_string(brand, field_name, default=None): """ - Safely get the Brand instance from the RequestContext + Attempts to safely get a `str` attribute from a Brand object + + Args: + brand (Model|dict): the specified Brand from which to resolve the field + field_name (str): the name of the attribute to resolve + default (Any): some default to value to return on failure; defaults to `None` + + Returns: + If successfully resolved will return a (str) field; otherwise returns the specified `default` value """ - current_brand = request.CURRENT_BRAND - if gen_utils.is_empty_string(current_brand): + try: + if isinstance(brand, dict): + value = brand.get(field_name, None) + elif isinstance(brand, Model): + value = getattr(brand, field_name, None) if hasattr(brand, field_name) else None + else: + value = None + except: + value = None + + if value is None or gen_utils.is_empty_string(value): return default - return try_get_instance(Brand, name=current_brand) + return value def get_entity_id(primary_key): """ @@ -61,30 +83,128 @@ def get_entity_id(primary_key): return entity_id[:2] return False -def get_brand_collection_ids(brand_name): +def get_brand_tag_ids(brand): """ - Returns list of collections (tags) ids associated with the brand + Resolves a list of tags (tags with tag_type=1) ids associated with the brand + + Args: + brand (Brand|str): the Brand instance or name of the brand to query + + Returns: + A (list) of tags assoc. with this brand + """ + src_tag = apps.get_model(app_label='clinicalcode', model_name='Tag') + src_brand = apps.get_model(app_label='clinicalcode', model_name='Brand') + if isinstance(brand, str): + brand = src_brand.objects.filter(name__iexact=brand) + if brand.exists(): + brand = brand.first() + + if not isinstance(brand, src_brand): + brand = None + brand_name = 'ALL' + else: + brand_name = brand.name + + cache_key = f'tag_brands__{brand_name}__cache' + + resultset = cache.get(cache_key) + if resultset is None: + if brand: + source = constants.metadata.get('tags', {}) \ + .get('validation', {}) \ + .get('source', {}) \ + .get('filter', {}) \ + .get('source_by_brand', None) + + if source is not None: + result = filter_utils.DataTypeFilters.try_generate_filter( + desired_filter='brand_filter', + expected_params=None, + source_value=source, + column_name='collection_brand', + brand_target=brand + ) + + if isinstance(result, list) and len(result) > 0: + resultset = list(src_tag.objects.filter(*result).values_list('id', flat=True)) + + if resultset is None: + resultset = list(src_tag.objects.filter(collection_brand=brand.id, tag_type=1).values_list('id', flat=True)) + else: + resultset = list(src_tag.objects.filter(tag_type=1).values_list('id', flat=True)) + cache.set(cache_key, resultset, 3600) + + return resultset + +def get_brand_collection_ids(brand): + """ + Resolves a list of collections (tags with tag_type=2) ids associated with the brand + + Args: + brand (Brand|str): the Brand instance or name of the brand to query + + Returns: + A (list) of collections assoc. with this brand """ - if Brand.objects.all().filter(name__iexact=brand_name).exists(): - brand = Brand.objects.get(name__iexact=brand_name) - brand_collection_ids = list(Tag.objects.filter(collection_brand=brand.id).values_list('id', flat=True)) - return brand_collection_ids - return [-1] + src_tag = apps.get_model(app_label='clinicalcode', model_name='Tag') + src_brand = apps.get_model(app_label='clinicalcode', model_name='Brand') + if isinstance(brand, str): + brand = src_brand.objects.filter(name__iexact=brand) + if brand.exists(): + brand = brand.first() + + if not isinstance(brand, src_brand): + brand_name = 'ALL' + else: + brand_name = brand.name + + cache_key = f'collections_brands__{brand_name}__cache' + + resultset = cache.get(cache_key) + if resultset is None: + if brand: + source = constants.metadata.get('tags', {}) \ + .get('validation', {}) \ + .get('source', {}) \ + .get('filter', {}) \ + .get('source_by_brand', None) + + if source is not None: + result = filter_utils.DataTypeFilters.try_generate_filter( + desired_filter='brand_filter', + expected_params=None, + source_value=source, + column_name='collection_brand', + brand_target=brand + ) + + if isinstance(result, list): + resultset = list(src_tag.objects.filter(*result).values_list('id', flat=True)) + + if resultset is None: + resultset = list(src_tag.objects.filter(collection_brand=brand.id, tag_type=2).values_list('id', flat=True)) + else: + resultset = list(src_tag.objects.filter(tag_type=2).values_list('id', flat=True)) + cache.set(cache_key, resultset, 3600) + + return resultset def get_entity_approval_status(entity_id, historical_id): """ Gets the entity's approval status, given an entity id and historical id """ - entity = GenericEntity.history.filter(id=entity_id, history_id=historical_id) + entity = apps.get_model(app_label='clinicalcode', model_name='GenericEntity').history.filter(id=entity_id, history_id=historical_id) if entity.exists(): return entity.first().publish_status +@gen_utils.measure_perf def is_legacy_entity(entity_id, entity_history_id): """ Checks whether this entity_id and entity_history_id match the latest record to determine whether a historical entity is legacy or not """ - latest_entity = GenericEntity.history.filter(id=entity_id) + latest_entity = apps.get_model(app_label='clinicalcode', model_name='GenericEntity').history.filter(id=entity_id) if not latest_entity.exists(): return False @@ -104,7 +224,7 @@ def jsonify_object(obj, remove_userdata=True, strip_fields=True, strippable_fiel if remove_userdata or strip_fields: for field in obj._meta.fields: field_type = field.get_internal_type() - if strip_fields and field_type and field.get_internal_type() in STRIPPED_FIELDS: + if strip_fields and field_type and field.get_internal_type() in constants.STRIPPED_FIELDS: instance.pop(field.name, None) continue @@ -120,7 +240,7 @@ def jsonify_object(obj, remove_userdata=True, strip_fields=True, strippable_fiel continue model = str(field.target_field.model) - if model not in USERDATA_MODELS: + if model not in constants.USERDATA_MODELS: continue instance.pop(field.name, None) @@ -132,7 +252,7 @@ def get_tag_attribute(tag_id, tag_type): """ Returns a dict that describes a tag given its id and type """ - tag = Tag.objects.filter(id=tag_id, tag_type=tag_type) + tag = apps.get_model(app_label='clinicalcode', model_name='Tag').objects.filter(id=tag_id, tag_type=tag_type) if tag.exists(): tag = tag.first() return { @@ -149,13 +269,14 @@ def get_coding_system_details(coding_system): the coding_system parameter passed is either an obj or an int that references a coding_system by its codingsystem_id """ + src = apps.get_model(app_label='clinicalcode', model_name='CodingSystem') if isinstance(coding_system, int): - coding_system = CodingSystem.objects.filter(codingsystem_id=coding_system) + coding_system = src.objects.filter(codingsystem_id=coding_system) if not coding_system.exists(): return None coding_system = coding_system.first() - if not isinstance(coding_system, CodingSystem): + if not isinstance(coding_system, src): return None return { @@ -165,10 +286,7 @@ def get_coding_system_details(coding_system): } def get_userdata_details(model, **kwargs): - """ - Attempts to return a dict that describes a userdata field e.g. a user, or a group - in a human readable format - """ + """Attempts to return a dict that describes a userdata field e.g. a user, or a group in a human readable format""" hide_user_id = False if kwargs.get('hide_user_details'): hide_user_id = kwargs.pop('hide_user_details') @@ -197,25 +315,78 @@ def append_coding_system_data(systems): whether a search rule is applicable Args: - systems (list of dicts) A list of dicts that contains the coding systems of interest - e.g. {name: (str), value: (int)} where value is the pk + systems (list[dict]): A list of dicts that contains the coding systems of interest e.g. {name: (str), value: (int)} where value is the pk Returns: A list of dicts that has the number of codes/searchable status appended, as defined by their code reference tables """ - for i, system in enumerate(systems): - try: - coding_system = CodingSystem.objects.get(codingsystem_id=system.get('value')) - table = coding_system.table_name.replace('clinicalcode_', '') - codes = apps.get_model(app_label='clinicalcode', model_name=table) - count = codes.objects.count() > 0 - systems[i]['code_count'] = count - systems[i]['can_search'] = count - except: - continue - - return systems + with connection.cursor() as cursor: + sql = ''' + do + $bd$ + declare + _query text; + _ref text; + _row_cnt int; + _record json; + _coding_tables json; + + _result jsonb := '[]'::jsonb; + _cursor constant refcursor := '_cursor'; + begin + select + json_agg(json_build_object( + 'name', coding.name, + 'value', coding.id, + 'table_name', coding.table_name + )) as tbl + from public.clinicalcode_codingsystem as coding + into _coding_tables; + + for _record, _ref + in select obj, obj->>'table_name'::text from json_array_elements(_coding_tables) obj + loop + if exists(select 1 from information_schema.tables where table_name = _ref) then + _query := format('select count(*) from %I', _ref); + execute _query into _row_cnt; + else + _row_cnt = 0; + end if; + + _result = _result || format( + '[{ + "name": "%s", + "value": %s, + "table_name": "%s", + "code_count": %s, + "can_search": %s + }]', + _record->>'name'::text, + _record->>'value'::text, + _record->>'table_name'::text, + _row_cnt::int, + (_row_cnt::int > 0)::text + )::jsonb; + end loop; + + _query := format('select %L::jsonb as res', _result::text); + open _cursor for execute _query; + end; + $bd$; + fetch all from _cursor; + ''' + cursor.execute(sql) + + results = cursor.fetchone() + results = results[0] if isinstance(results, (list, tuple)) else None + if isinstance(results, str): + try: + results = json.loads(results) + except: + results = None + + return results if isinstance(results, list) else systems def modify_entity_change_reason(entity, reason): """ diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/permission_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/permission_utils.py index 115b28e0b..f5d9eb4ee 100644 --- a/CodeListLibrary_project/clinicalcode/entity_utils/permission_utils.py +++ b/CodeListLibrary_project/clinicalcode/entity_utils/permission_utils.py @@ -1,11 +1,20 @@ +"""Permission-related utilities; defines functions to vary content access & render behaviour.""" +from functools import wraps from django.db import connection +from django.http import HttpRequest from django.conf import settings -from django.db.models import Q, Subquery, OuterRef +from rest_framework import status as RestHttpStatus +from django.db.models import Q, Model +from django.contrib.auth import get_user_model +from rest_framework.request import Request from django.core.exceptions import PermissionDenied +from rest_framework.exceptions import APIException, MethodNotAllowed, NotAuthenticated +from rest_framework.permissions import BasePermission, SAFE_METHODS from django.contrib.auth.models import Group -from functools import wraps +import inspect +from ..models.Organisation import Organisation, OrganisationAuthority, OrganisationMembership from . import model_utils, gen_utils from ..models.Brand import Brand from ..models.Concept import Concept @@ -13,42 +22,296 @@ from ..models.GenericEntity import GenericEntity from ..models.PublishedConcept import PublishedConcept from ..models.PublishedGenericEntity import PublishedGenericEntity -from .constants import APPROVAL_STATUS, GROUP_PERMISSIONS, WORLD_ACCESS_PERMISSIONS +from ..models.Organisation import Organisation, OrganisationAuthority + +from .constants import ( + ORGANISATION_ROLES, APPROVAL_STATUS, + GROUP_PERMISSIONS, WORLD_ACCESS_PERMISSIONS +) + -""" Permission decorators """ +User = get_user_model() + +'''Permission decorators''' def redirect_readonly(fn): - """ - Method decorator to raise 403 if we're on the read only site - to avoid insert / update methods via UI + """ + Method decorator to raise 403 if we're on the read only site to avoid insert / update methods via UI - e.g. + Args: + fn (Callable): the view `Callable` to wrap - @permission_utils.redirect_readonly - def some_view_func(request): - # stuff - """ - @wraps(fn) - def wrap(request, *args, **kwargs): - if settings.CLL_READ_ONLY: - raise PermissionDenied("ERR_403_GATEWAY") - return fn(request, *args, **kwargs) + Returns: + A (Callable) decorator + + Raises: + PermissionDenied - return wrap + Example: + ```py + from clinicalcode.entity_utils import permission_utils + @permission_utils.redirect_readonly + def some_view_func(request): + pass + ``` + """ + @wraps(fn) + def wrap(request, *args, **kwargs): + if settings.CLL_READ_ONLY: + raise PermissionDenied('ERR_403_GATEWAY') + return fn(request, *args, **kwargs) -""" Render helpers """ + return wrap + +def brand_admin_required(fn): + """ + Method decorator to raise a 403 if a view isn't accessed by a Brand Administrator + + .. Note:: + Brand administration privileges are checked by comparing the user against the current brand (per request middleware). + + Args: + fn (Callable): the view `Callable` to wrap + Returns: + A (Callable) decorator + + Raises: + PermissionDenied + + Example: + ```py + from django.utils.decorators import method_decorator + + from clinicalcode.entity_utils import permission_utils + + @method_decorator([permission_utils.brand_admin_required, *other_decorators]) + def some_view_func(request): + pass + + # Or... + @permission_utils.brand_admin_required + def some_view_func(request): + pass + ``` + """ + @wraps(fn) + def wrap(request, *args, **kwargs): + user = request.user if hasattr(request, 'user') and request.user.is_authenticated else None + brand = request.BRAND_OBJECT if hasattr(request, 'BRAND_OBJECT') else None + + if user is None: + raise PermissionDenied + + if not user.is_superuser: + if not isinstance(brand, Brand) or brand.id is None: + raise PermissionDenied + + administrable = user.administered_brands \ + .filter(id=brand.id, is_administrable=True) \ + .exists() + + if not administrable: + raise PermissionDenied + + return fn(request, *args, **kwargs) + + return wrap + + +'''REST Permission Classes''' +class IsReadOnlyRequest(BasePermission): + """ + Ensures that a request is one of `GET`, `HEAD`, or `OPTIONS`. + + Raises: + MethodNotAllowed (405) + + Example: + ```py + from rest_framework.views import APIView + from rest_framework.response import Response + from rest_framework.decorators import decorators + + from clinicalcode.entity_utils import permission_utils + + @schema(None) + class SomeEndpoint(APIView): + permission_classes = [permission_utils.IsReadOnlyRequest] + + def get(self, request): + return Response({ 'message': 'Hello, world!' }) + ``` + """ + ERR_STATUS_CODE = RestHttpStatus.HTTP_405_METHOD_NOT_ALLOWED + ERR_REQUEST_MSG = ( + 'Method Not Allowed: ' + \ + 'Expected read only method of type `GET`, `HEAD`, `OPTIONS` but got `%s`' + ) + + def has_permission(self, request, view): + method = request.method + if not method in SAFE_METHODS: + raise MethodNotAllowed( + method=method, + detail=self.ERR_REQUEST_MSG % method, + code=self.ERR_STATUS_CODE + ) + + return True + +class IsReadOnlyOrNotGateway(BasePermission): + """ + Ensures that a request is either (a) read-only or (b) not being made from a TRE read-only site. + + .. Note:: + - A request must be one of `GET`, `HEAD`, or `OPTIONS` to be considered `ReadOnly`; + - TRE read-only sites are declared as such by the deployment environment variables. + + Raises: + MethodNotAllowed (405) + + Example: + ```py + from rest_framework.views import APIView + from rest_framework.response import Response + from rest_framework.decorators import decorators + + from clinicalcode.entity_utils import permission_utils + + @schema(None) + class SomeEndpoint(APIView): + permission_classes = [permission_utils.IsReadOnlyOrNotGateway] + + def get(self, request): + return Response({ 'message': 'Hello, world!' }) + ``` + """ + ERR_STATUS_CODE = RestHttpStatus.HTTP_405_METHOD_NOT_ALLOWED + ERR_REQUEST_MSG = ( + 'Method Not Allowed: ' + \ + 'Only methods of type `GET`, `HEAD`, `OPTIONS` are accessible from the ReadOnly site, ' + \ + 'request of type `%s` is not allowed.' + ) + + def has_permission(self, request, view): + method = request.method + if not request.method in SAFE_METHODS and settings.CLL_READ_ONLY: + raise MethodNotAllowed( + method=method, + detail=self.ERR_REQUEST_MSG % method, + code=self.ERR_STATUS_CODE + ) + + return True + +class IsNotGateway(BasePermission): + """ + Ensures that a request is either not being made from a TRE read-only site. + + .. Note:: + - TRE read-only sites are declared as such by the deployment environment variables. + + Raises: + MethodNotAllowed (405) + + Example: + ```py + from rest_framework.views import APIView + from rest_framework.response import Response + from rest_framework.decorators import decorators + + from clinicalcode.entity_utils import permission_utils + + @schema(None) + class SomeEndpoint(APIView): + permission_classes = [permission_utils.IsNotGateway] + + def get(self, request): + return Response({ 'message': 'Hello, world!' }) + ``` + """ + ERR_STATUS_CODE = RestHttpStatus.HTTP_405_METHOD_NOT_ALLOWED + ERR_REQUEST_MSG = ( + 'Method Not Allowed: ' + \ + 'Not accessible from the TRE ReadOnly site, ' + \ + 'request of type `%s` is not allowed.' + ) + + def has_permission(self, request, view): + method = request.method + if settings.CLL_READ_ONLY: + raise MethodNotAllowed( + method=method, + detail=self.ERR_REQUEST_MSG % method, + code=self.ERR_STATUS_CODE + ) + + return True + +class IsBrandAdmin(BasePermission): + """ + Ensures that the request user is authorised as a Brand Administrator for the request's Brand context. + + .. Note:: + Requests made by superusers will always granted regardless of Brand context. + + Raises: + | Error | Status | Reason | + |----------------|--------|--------------------------------------------------------------------------------| + | Unauthorised | 401 | If the request user is anonymous/hasn't supplied auth token | + | Forbidden | 403 | If the request user is _not_ a known Brand Administrator | + | Not Acceptable | 406 | If the request user is attempting to access the Dashboard for an unknown Brand | + + Example: + ```py + from rest_framework.views import APIView + from rest_framework.response import Response + from rest_framework.decorators import decorators + + from clinicalcode.entity_utils import permission_utils + + @schema(None) + class SomeEndpoint(APIView): + permission_classes = [permission_utils.IsBrandAdmin] + + def get(self, request): + return Response({ 'message': 'Hello, world!' }) + ``` + """ + def has_permission(self, request, view): + user = request.user if hasattr(request, 'user') and not request.user.is_anonymous else None + if user is None: + raise NotAuthenticated + + if user.is_superuser: + return True + + brand = request.BRAND_OBJECT if hasattr(request, 'BRAND_OBJECT') else None + if not isinstance(brand, Brand) or brand.id is None: + raise APIException(detail='Could not resolve Brand context', code=RestHttpStatus.HTTP_406_NOT_ACCEPTABLE) + + administrable = user.administered_brands \ + .filter(id=brand.id, is_administrable=True) \ + .exists() + + if not administrable: + raise PermissionDenied + + return True + + +'''Render helpers''' def should_render_template(template=None, **kwargs): """ - Method to det. whether a template should be renderable - based on its `hide_on_create` property + Method to det. whether a template should be renderable based on its `hide_on_create` property. Args: template {model}: optional parameter to check a model instance directly **kwargs (any): params to use when querying the template model - + Returns: A boolean reflecting the renderable status of a template model @@ -68,23 +331,171 @@ def should_render_template(template=None, **kwargs): return not template.hide_on_create -""" Status helpers """ - +'''Status helpers''' def is_member(user, group_name): """ Checks if a User instance is a member of a group """ return user.groups.filter(name__iexact=group_name).exists() -def has_member_access(user, entity, permissions): +def is_requestor_brand_admin(request=None): + """Evaluates a request, the brand context, and the assoc. user (if any) to determine whether the user can access the Brand Administration panel""" + if not isinstance(request, (Request, HttpRequest)): + return False + + user = request.user if hasattr(request, 'user') and not request.user.is_anonymous else None + if user is None: + return False + + brand = request.BRAND_OBJECT if hasattr(request, 'BRAND_OBJECT') else None + if not isinstance(brand, Brand) or brand.id is None or not brand.is_administrable: + return False + + if user.is_superuser: + return True + + administrable = user.administered_brands \ + .filter(id=brand.id, is_administrable=True) \ + .exists() + + return administrable + +def get_brand_related_users(req_brand=None): """ - Checks if a user has access to an entity via its group membership + Resolves users associated with a specific Brand context + + Args: + req_brand (str|int|Brand|Request|HttpRequest): either (a) the name/id of the Brand, (b) the Brand object itself, or (c) the HTTP request assoc. with this operation + + Returns: + A (QuerySet) containing the users assoc. with this request/brand context + """ + if isinstance(req_brand, (Request, HttpRequest)): + brand = model_utils.try_get_brand(req_brand) + elif isinstance(req_brand, str) and not gen_utils.is_empty_string(req_brand): + brand = model_utils.try_get_instance(Brand, name__iexact=req_brand) + elif isinstance(req_brand, int) and req_brand >= 0: + brand = model_utils.try_get_instance(Brand, pk=req_brand) + elif isinstance(req_brand, Brand) and (inspect.isclass(req_brand) and issubclass(req_brand, Brand)): + brand = req_brand + + records = None + if brand is not None: + vis_rules = brand.get_vis_rules() + if isinstance(vis_rules, dict): + allow_null = vis_rules.get('allow_null') + allowed_brands = vis_rules.get('ids') + if isinstance(allowed_brands, list) and isinstance(allow_null, bool) and allow_null: + records = User.objects.filter(Q(accessible_brands__id__isnull=True) | Q(accessible_brands__id__in=allowed_brands)) + elif isinstance(allowed_brands, list): + records = User.objects.filter(accessible_brands__id__in=allowed_brands) + elif isinstance(allow_null, bool) and allow_null: + records = User.objects.filter(Q(accessible_brands__id__isnull=True) | Q(accessible_brands__id__in=[brand.id])) + + if records is None: + records = User.objects.filter(accessible_brands__id=brand.id) + + return records if records is not None else User.objects.all() + +def has_member_org_access(user, slug, min_permission): """ - if entity.group_id in user.groups.all().values_list('id', flat=True): - return entity.group_access in permissions + Checks if a user has access to an organisation. + Min_permissions relates to the organisation role, e.g. + min_permission=1 means the user has to be an editor or above. + """ + if user and not user.is_anonymous: + if not gen_utils.is_empty_string(slug): + organisation = Organisation.objects.filter(slug=slug) + if organisation.exists(): + organisation = organisation.first() + + if organisation.owner == user: + return True + + membership = user.organisationmembership_set \ + .filter(organisation__id=organisation.id) + if membership.exists(): + return membership.first().role >= min_permission return False +def has_member_access(user, entity, min_permission): + """ + Checks if a user has access to an entity via its organisation + membership. Min_permissions relates to the organisation role, e.g. + min_permission=1 means the user has to be an editor or above. + """ + try: + org = entity.organisation + except Organisation.DoesNotExist: + org = None + + if org: + if org.owner == user: + return True + + if user and not user.is_anonymous: + membership = user.organisationmembership_set \ + .filter(organisation__id=org.id) + if membership.exists(): + return membership.first().role >= min_permission + + return False + +def get_organisation_role(user, organisation): + if organisation.owner == user: + return ORGANISATION_ROLES.ADMIN + + org_membership = model_utils.try_get_instance(OrganisationMembership, user_id=user.id, organisation_id=organisation.id) + if org_membership is None: + return None + + if org_membership.role in ORGANISATION_ROLES: + return ORGANISATION_ROLES(org_membership.role) + return -1 + +def has_org_member(user, organisation): + if organisation.owner == user: + return True + + org_membership = model_utils.try_get_instance(OrganisationMembership, user_id=user.id, organisation_id=organisation.id) + return org_membership is not None and org_membership.role in ORGANISATION_ROLES + +def has_org_authority(request, organisation): + if not has_org_member(request.user, organisation): + return False + + brand = model_utils.try_get_brand(request) + authorities = list(OrganisationAuthority.objects.filter(organisation_id=organisation.id).values('can_post','can_moderate','brand_id')) + requested_authority = None + if brand is not None: + requested_authority = next( + ({**authority, 'org_user_managed': brand.org_user_managed} for authority in authorities if brand.id == authority['brand_id']), + None + ) + + if requested_authority is None: + return {'org_user_managed': False, 'can_moderate': False, 'can_post': False} + return requested_authority + +def get_organisation(request, entity_id=None, default=None): + if entity_id is None: + return None + + entity = model_utils.try_get_instance(GenericEntity, id=entity_id) + try: + org = entity.organisation + except Organisation.DoesNotExist: + org = default + return org + +def is_org_managed(request, brand_id=None): + if brand_id is not None: + brand = model_utils.try_get_instance(Brand, id=brand_id) + else: + brand = model_utils.try_get_brand(request) + return brand.org_user_managed if brand is not None else False + def is_publish_status(entity, status): """ Checks the publication status of an entity @@ -101,8 +512,8 @@ def is_publish_status(entity, status): return approval_status in status return False -""" General permissions """ +'''General permissions''' def was_archived(entity_id): """ Checks whether an entity was ever archived: @@ -135,6 +546,78 @@ def get_user_groups(request): return list(user.groups.all().exclude(name='ReadOnlyUsers').values('id', 'name')) +def get_user_organisations(request, min_role_permission=ORGANISATION_ROLES.EDITOR): + """ + Get the organisations related to the requesting user + """ + user = request.user + if not user or user.is_anonymous: + return [] + + if user.is_superuser: + return list(Organisation.objects.all().values('id', 'name')) + + current_brand = model_utils.try_get_brand(request) + current_brand = current_brand if current_brand and current_brand.org_user_managed else None + + with connection.cursor() as cursor: + if current_brand is None: + cursor.execute( + ''' + select org.id, org.slug, org.name + from public.clinicalcode_organisation org + join public.clinicalcode_organisationmembership mem + on org.id = mem.organisation_id + where org.owner_id = %(user_id)s + or (mem.user_id = %(user_id)s and mem.role >= %(role_enum)s) + ''', + params={ + 'user_id': user.id, + 'role_enum': min_role_permission + } + ) + else: + cursor.execute( + ''' + select org.id, org.slug, org.name + from public.clinicalcode_organisation org + join public.clinicalcode_organisationmembership mem + on org.id = mem.organisation_id + join public.clinicalcode_organisationauthority aut + on org.id = aut.organisation_id + and aut.brand_id = %(brand)s + where (org.owner_id = %(user_id)s or (mem.user_id = %(user_id)s and mem.role >= %(role_enum)s)) + and aut.can_post = true + ''', + params={ + 'user_id': user.id, + 'role_enum': min_role_permission, + 'brand': current_brand.id + } + ) + + columns = [col[0] for col in cursor.description] + results = [dict(zip(columns, row)) for row in cursor.fetchall()] + return results + +def user_has_create_context(request=None): + if request is None or settings.CLL_READ_ONLY: + return False + + user = request.user + if user is None or user.is_anonymous or user.is_superuser: + return True + + brand = model_utils.try_get_brand(request) + if brand is not None and brand.org_user_managed: + user_orgs = get_user_organisations( + request, min_role_permission=ORGANISATION_ROLES.EDITOR + ) + if not user_orgs or len(user_orgs) < 1: + return False + + return True + def get_moderation_entities( request, status=None @@ -152,8 +635,16 @@ def get_moderation_entities( entities = GenericEntity.history.all() \ .order_by('id', '-history_id') \ .distinct('id') + entities = entities.filter(Q(publish_status__in=status)) + + current_brand = model_utils.try_get_brand(request) + current_brand = current_brand if current_brand and current_brand.org_user_managed else None + if current_brand is not None: + entities = entities.filter( + Q(brands__overlap=[current_brand.id]) + ) - return entities.filter(Q(publish_status__in=status)) + return entities def get_editable_entities( request, @@ -171,38 +662,69 @@ def get_editable_entities( List of all editable entities """ user = request.user - entities = GenericEntity.history.all() \ - .order_by('id', '-history_id') \ - .distinct('id') + if not user or user.is_anonymous: + return None - brand = model_utils.try_get_brand(request) - if consider_brand and brand: - entities = entities.filter(Q(brands__overlap=[brand.id])) + brand = model_utils.try_get_brand(request) if consider_brand else None + if brand: + brand_sql = 'and %(brand)s = any(live.brands)' + else: + brand_sql = '' + + if only_deleted: + delete_sql = 'and live.is_deleted = true' + else: + delete_sql = 'and (live.is_deleted is null or live.is_deleted = false)' - if user and not user.is_anonymous: - query = Q(owner=user.id) - query |= Q( - group_id__in=user.groups.all(), - group_access__in=[GROUP_PERMISSIONS.EDIT] + sql = ''' + with entity as ( + select distinct on (id) * + from public.clinicalcode_historicalgenericentity entity + order by id, history_id desc ) - - entities = entities.filter(query) \ - .annotate( - was_deleted=Subquery( - GenericEntity.objects.filter( - id=OuterRef('id'), - is_deleted=True - ) \ - .values('id') - ) - ) - - if only_deleted: - return entities.exclude(was_deleted__isnull=True) - else: - return entities.exclude(was_deleted__isnull=False) - - return None + select entity.id, + entity.name, + entity.history_id, + entity.updated, + entity.publish_status, + live.is_deleted as was_deleted, + organisation.name as group_name, + uac.username as owner_name + from entity + join public.clinicalcode_genericentity live + on live.id = entity.id + join public.auth_user uac + on uac.id = entity.owner_id + left join public.clinicalcode_organisation organisation + on organisation.id = live.organisation_id + left join public.clinicalcode_organisationmembership membership + on membership.organisation_id = organisation.id + where (live.owner_id = %(user_id)s + or organisation.owner_id = %(user_id)s + or (membership.user_id = %(user_id)s AND membership.role >= %(role_enum)s)) + {brand} + {delete} + group by entity.id, entity.name, entity.history_id, + entity.updated, entity.publish_status, + live.is_deleted, organisation.name, uac.username + '''.format( + brand=brand_sql, + delete=delete_sql + ) + + with connection.cursor() as cursor: + cursor.execute( + sql, + params={ + 'user_id': user.id, + 'role_enum': ORGANISATION_ROLES.EDITOR, + 'brand': brand.id if brand else None + } + ) + + columns = [col[0] for col in cursor.description] + results = [dict(zip(columns, row)) for row in cursor.fetchall()] + return results def get_accessible_entity_history( request, @@ -230,7 +752,7 @@ def get_accessible_entity_history( brand_clause = '' brand = model_utils.try_get_brand(request) if brand is not None: - brand_clause = 'and t0.brands && %(brand_ids)s' + brand_clause = 'and live.brands && %(brand_ids)s' query_params.update({ 'brand_ids': [brand.id] }) data = f''' @@ -313,6 +835,8 @@ def get_accessible_entity_history( from public.clinicalcode_historicalgenericentity as t0 join pub_data as pub on t0.history_id = any(pub.published_ids) + join public.clinicalcode_genericentity as live + on live.id = t0.id left join public.auth_user as uau on t0.updated_by_id = uau.id left join public.auth_user as cau @@ -330,7 +854,11 @@ def get_accessible_entity_history( on true left join ( select json_agg(row_to_json(t.*) order by t.history_id desc) as entities - from historical_entities as t + from ( + select *, row_number() over(partition by id, history_id) as rn + from historical_entities + ) as t + where rn = 1 ) as t2 on true ''' @@ -345,10 +873,24 @@ def get_accessible_entity_history( # i.e. dependent on user role clauses = 'true' if not is_superuser: - clauses = '''t0.world_access = %(world_access)s or t0.owner_id = %(user_id)s''' + org_view_clause = '' + if brand is not None and brand.org_user_managed: + user_orgs = get_user_organisations(request, min_role_permission=ORGANISATION_ROLES.MEMBER) + if user_orgs and len(user_orgs) >= 1: + org_view_clause = f''' + or t0.world_access = {WORLD_ACCESS_PERMISSIONS.VIEW.value} + ''' + + clauses = f''' + (live.owner_id = %(user_id)s + or org.owner_id = %(user_id)s + or (mem.user_id = %(user_id)s AND mem.role >= %(role_enum)s) + {org_view_clause}) + ''' + query_params.update({ 'user_id': user.id, - 'world_access': WORLD_ACCESS_PERMISSIONS.VIEW.value, + 'role_enum': ORGANISATION_ROLES.MEMBER }) pub_status = [APPROVAL_STATUS.APPROVED.value] @@ -369,17 +911,6 @@ def get_accessible_entity_history( or t0.publish_status = {APPROVAL_STATUS.APPROVED.value} ''' - group_ids = [x for x in list(user.groups.all().values_list('id', flat=True))] - - if len(group_ids): - clauses += ''' - or (t0.group_id = any(%(group_ids)s) and t0.group_access = any(%(group_perms)s)) - ''' - query_params.update({ - 'group_ids': group_ids, - 'group_perms': [GROUP_PERMISSIONS.VIEW.value, GROUP_PERMISSIONS.EDIT.value], - }) - sql = f''' {data}, historical_entities as ( @@ -402,6 +933,12 @@ def get_accessible_entity_history( on t0.owner_id = oau.id left join (select json_array_elements(objects::json) as obj from pub_data pd) as pd on t0.history_id = (pd.obj->>'entity_history_id')::int + join public.clinicalcode_genericentity live + on live.id = t0.id + left join public.clinicalcode_organisation org + on org.id = live.organisation_id + left join public.clinicalcode_organisationmembership mem + on mem.organisation_id = org.id where t0.id = %(pk)s and ({clauses}) {brand_clause} @@ -412,7 +949,11 @@ def get_accessible_entity_history( on true left join ( select json_agg(row_to_json(t.*) order by t.history_id desc) as entities - from historical_entities as t + from ( + select *, row_number() over(partition by id, history_id) as rn + from historical_entities + ) as t + where rn = 1 ) as t2 on true ''' @@ -428,7 +969,7 @@ def get_accessible_entities( consider_user_perms=True, only_deleted=False, status=[APPROVAL_STATUS.APPROVED], - group_permissions=[GROUP_PERMISSIONS.VIEW, GROUP_PERMISSIONS.EDIT], + min_group_permission=ORGANISATION_ROLES.MEMBER, consider_brand=True, raw_query=False, pk=None @@ -456,7 +997,7 @@ def get_accessible_entities( brand = model_utils.try_get_brand(request) if consider_brand else None brand_clause = '' if consider_brand and brand is not None: - brand_clause = 'and hist_entity.brands && %(brand_ids)s' + brand_clause = 'and live_entity.brands && %(brand_ids)s' query_params.update({ 'brand_ids': [brand.id] }) # Append pk clause if entity_id is valid @@ -468,7 +1009,15 @@ def get_accessible_entities( # Early exit if we're a superuser and want to consider privileges if len(pk_clause) < 1 and consider_user_perms and user and user.is_superuser: if brand is not None: - results = GenericEntity.history.filter(brands__overlap=[brand.id]).latest_of_each() + results = GenericEntity.objects.filter(brands__overlap=[brand.id]) + + if only_deleted: + results = results.filter(is_deleted=True) + else: + results = results.exclude(is_deleted=True) + + results = GenericEntity.history \ + .filter(id__in=list(results.values_list('id', flat=True))) else: results = GenericEntity.history.all() return results.latest_of_each() @@ -532,10 +1081,21 @@ def get_accessible_entities( # Non-anon user # i.e. dependent on user role - clauses = '''hist_entity.world_access = %(world_access)s or hist_entity.owner_id = %(user_id)s''' + org_view_clause = '' + if consider_brand and brand is not None and brand.org_user_managed: + user_orgs = get_user_organisations(request, min_role_permission=ORGANISATION_ROLES.MEMBER) + if user_orgs and len(user_orgs) >= 1: + org_view_clause = f'''or live_entity.world_access = {WORLD_ACCESS_PERMISSIONS.VIEW.value}''' + + clauses = f''' + (live_entity.owner_id = %(user_id)s + or org.owner_id = %(user_id)s + or (mem.user_id = %(user_id)s AND mem.role >= %(role_enum)s){org_view_clause}) + ''' + query_params.update({ 'user_id': user.id, - 'world_access': WORLD_ACCESS_PERMISSIONS.VIEW.value, + 'role_enum': min_group_permission }) if len(pub_status) < 1: @@ -552,23 +1112,6 @@ def get_accessible_entities( ''' query_params.update({ 'pub_status': pub_status }) - if isinstance(group_permissions, list): - group_ids = [x for x in list(user.groups.all().values_list('id', flat=True))] - group_perms = [ - x.value if x in GROUP_PERMISSIONS else gen_utils.parse_int(x, default=None) - for x in group_permissions - if x in GROUP_PERMISSIONS or gen_utils.parse_int(x, default=None) is not None - ] - - if len(group_ids) | len(group_perms): - clauses += ''' - or (hist_entity.group_id = any(%(group_ids)s) and hist_entity.group_access = any(%(group_perms)s)) - ''' - query_params.update({ - 'group_ids': group_ids, - 'group_perms': group_perms, - }) - conditional = '' if only_deleted: conditional = ''' @@ -576,7 +1119,7 @@ def get_accessible_entities( ''' else: conditional = ''' - and (live_entity.id is not null and (live_entity.is_deleted is null or live_entity.is_deleted = false)) + and (live_entity.is_deleted is null or live_entity.is_deleted = false) and (hist_entity.is_deleted is null or hist_entity.is_deleted = false) ''' @@ -591,12 +1134,16 @@ def get_accessible_entities( order by hist_entity.history_id desc ) as rn_ref_n from public.clinicalcode_historicalgenericentity as hist_entity - left join public.clinicalcode_genericentity as live_entity + join public.clinicalcode_genericentity as live_entity on hist_entity.id = live_entity.id join public.clinicalcode_historicaltemplate as hist_tmpl on hist_entity.template_id = hist_tmpl.id and hist_entity.template_version = hist_tmpl.template_version join public.clinicalcode_template as live_tmpl on hist_tmpl.id = live_tmpl.id + left join public.clinicalcode_organisation org + on org.id = live_entity.organisation_id + left join public.clinicalcode_organisationmembership mem + on mem.organisation_id = org.id where ( {clauses} ) @@ -722,7 +1269,6 @@ def get_latest_owner_version_from_concept(phenotype_id, concept_id, concept_vers on pheno.phenotype_id = entity.id and pheno.phenotype_version_id = entity.history_id group by phenotype_id; - ''' cursor.execute(sql, params=params) @@ -739,7 +1285,7 @@ def get_latest_owner_version_from_concept(phenotype_id, concept_id, concept_vers def get_accessible_concepts( request, - group_permissions=[GROUP_PERMISSIONS.VIEW, GROUP_PERMISSIONS.EDIT] + min_group_permission=ORGANISATION_ROLES.MEMBER ): """ Tries to get all the concepts that are accessible to a specific user @@ -781,7 +1327,7 @@ def get_accessible_concepts( ''' cursor.execute( sql, - params=[WORLD_ACCESS_PERMISSIONS.VIEW.value] + params=[APPROVAL_STATUS.APPROVED.value] ) columns = [col[0] for col in cursor.description] @@ -794,16 +1340,16 @@ def get_accessible_concepts( return concepts - group_access = [x.value for x in group_permissions] with connection.cursor() as cursor: sql = ''' select distinct on (concept_id) - id as phenotype_id, - cast(concepts->>'concept_id' as integer) as concept_id, - cast(concepts->>'concept_version_id' as integer) as concept_version_id + results.id as phenotype_id, + cast(results.concepts->>'concept_id' as integer) as concept_id, + cast(results.concepts->>'concept_version_id' as integer) as concept_version_id from ( select id, - concepts + concepts, + publish_status from public.clinicalcode_historicalgenericentity as entity, json_array_elements(entity.template_data::json->'concept_information') as concepts where @@ -812,31 +1358,31 @@ def get_accessible_concepts( from public.clinicalcode_genericentity as ge where ge.is_deleted = true and ge.id = entity.id ) - and ( - entity.publish_status = %s - or ( - exists ( - select 1 - from public.auth_user_groups as t - where t.user_id = %s and t.group_id = entity.group_id - ) - and entity.group_access in %s - ) - or entity.owner_id = %s - or entity.world_access = %s - ) ) results + join public.clinicalcode_genericentity as live + on live.id = results.id + left join public.clinicalcode_organisation as org + on org.id = live.organisation_id + left join public.clinicalcode_organisationmembership as mem + on mem.organisation_id = org.id + where ( + results.publish_status = %(publish_status)s + or org.owner_id = %(user_id)s + or (mem.user_id = %(user_id)s and mem.role >= %(role_enum)s) + or live.owner_id = %(user_id)s + ) order by concept_id desc, concept_version_id desc ''' cursor.execute( sql, - params=[ - APPROVAL_STATUS.APPROVED.value, user.id, tuple(group_access), - user.id, WORLD_ACCESS_PERMISSIONS.VIEW.value - ] + params={ + 'publish_status': APPROVAL_STATUS.APPROVED.value, + 'user_id': user.id, + 'role_enum': min_group_permission, + } ) - + columns = [col[0] for col in cursor.description] results = [dict(zip(columns, row)) for row in cursor.fetchall()] @@ -889,12 +1435,17 @@ def can_user_view_entity(request, entity_id, entity_history_id=None): if live_entity.owner == user: return check_brand_access(request, is_published, entity_id, entity_history_id) - if has_member_access(user, live_entity, [GROUP_PERMISSIONS.VIEW, GROUP_PERMISSIONS.EDIT]): + if has_member_access(user, live_entity, ORGANISATION_ROLES.MEMBER): return check_brand_access(request, is_published, entity_id, entity_history_id) - if user and not user.is_anonymous: - if live_entity.world_access == WORLD_ACCESS_PERMISSIONS.VIEW: - return check_brand_access(request, is_published, entity_id, entity_history_id) + brand = model_utils.try_get_brand(request) + if brand is not None and brand.org_user_managed: + if live_entity.world_access == WORLD_ACCESS_PERMISSIONS.VIEW: + user_orgs = get_user_organisations( + request, min_role_permission=ORGANISATION_ROLES.MEMBER + ) + if user_orgs and len(user_orgs) >= 1: + return check_brand_access(request, is_published, entity_id, entity_history_id) return False @@ -908,17 +1459,17 @@ def get_accessible_detail_entity(request, entity_id, entity_history_id=None): entity_id (number): The entity ID of interest Returns: - Either (a) a dict containing the entity and the user's edit/view access - or; (b) a boolean value reflecting the failed state of this func + Either tuple in which the first element is either (a) a dict containing the entity and the user's edit/view access or (b) a `None` type value; + while the latter element, if unsuccessful, specifies a dict containing a `status_code`, a `title`, and a `message` property describing the failed state of this operation """ live_entity = model_utils.try_get_instance(GenericEntity, pk=entity_id) if live_entity is None: - return False + return None, { 'title': 'Page Not Found - Missing Entity', 'message': 'No entity exists with the ID.', 'status_code': 404 } if entity_history_id is not None: historical_entity = model_utils.try_get_entity_history(live_entity, entity_history_id) if historical_entity is None: - return False + return None, { 'title': 'Page Not Found - Missing Version', 'message': 'No entity version exists with the specified version ID.', 'status_code': 404 } else: historical_entity = live_entity.history.latest() entity_history_id = historical_entity.history_id @@ -928,19 +1479,19 @@ def get_accessible_detail_entity(request, entity_id, entity_history_id=None): brand_id = brand.get('id') if isinstance(brand, dict) else getattr(brand, 'id') brand_id = gen_utils.parse_int(brand_id, default=None) + brand_accessible = False + brand_org_managed = False if brand_id is not None: related_brands = live_entity.brands - if not isinstance(related_brands, list) or len(related_brands) < 1: - brand_accessible = True - elif isinstance(related_brands, list): + if isinstance(related_brands, list): brand_accessible = brand_id in related_brands + brand_org_managed = brand.org_user_managed else: brand_accessible = True user = request.user if request.user and not request.user.is_anonymous else None user_groups = user.groups.all() if user is not None else None user_has_groups = user_groups.exists() if user_groups is not None else None - user_group_ids = list(user_groups.values_list('id', flat=True)) if user_has_groups else [] user_is_admin = user.is_superuser if user else False user_is_moderator = ('Moderators' in list(user_groups.values_list('name', flat=True))) if user_has_groups else False @@ -948,38 +1499,57 @@ def get_accessible_detail_entity(request, entity_id, entity_history_id=None): is_viewable = False is_editable = False is_published = historical_entity.publish_status == APPROVAL_STATUS.APPROVED.value + is_org_accessible = False if user is not None: is_owner = live_entity.owner == user is_moderatable = False - is_group_member = False - is_world_accessible = False + is_org_member = False if user_is_admin: is_editable = True elif brand_accessible: - entity_group_id = live_entity.group_id if isinstance(live_entity.group_id, int) else None if live_entity.owner == user or live_entity.created_by == user: is_editable = True - if entity_group_id is not None: - is_editable = live_entity.group_access == GROUP_PERMISSIONS.EDIT and entity_group_id in user_group_ids - is_group_member = ( - live_entity.group_access in [GROUP_PERMISSIONS.VIEW, GROUP_PERMISSIONS.EDIT] - and entity_group_id in user_group_ids - ) + entity_org = live_entity.organisation + if entity_org is not None: + if brand and brand.org_user_managed and live_entity.world_access == WORLD_ACCESS_PERMISSIONS.VIEW: + user_orgs = get_user_organisations(request, min_role_permission=ORGANISATION_ROLES.MEMBER) + if user_orgs and len(user_orgs) >= 1: + is_org_accessible = True + + if entity_org.owner_id == user.id: + is_org_member = True + elif not user.is_anonymous: + membership = user.organisationmembership_set \ + .filter(organisation__id=entity_org.id) + + if membership.exists(): + membership = membership.first() + + is_org_member = True + is_editable = membership.role >= ORGANISATION_ROLES.EDITOR + + if brand_org_managed: + brand_authority = OrganisationAuthority.objects \ + .filter(organisation_id=entity_org.id, brand_id=brand.id) + + if brand_authority.exists(): + brand_authority = brand_authority.first() + + if brand_authority.can_moderate: + user_is_moderator = user_is_moderator or membership.role >= ORGANISATION_ROLES.MODERATOR if user_is_moderator and historical_entity.publish_status is not None: is_moderatable = historical_entity.publish_status in [APPROVAL_STATUS.REQUESTED, APPROVAL_STATUS.PENDING, APPROVAL_STATUS.REJECTED] - is_world_accessible = live_entity.world_access == WORLD_ACCESS_PERMISSIONS.VIEW - is_viewable = brand_accessible and ( user_is_admin or is_published or is_moderatable - or is_world_accessible - or (is_owner or is_group_member or is_world_accessible) + or is_org_accessible + or (is_owner or is_org_member) ) else: is_viewable = brand_accessible and is_published @@ -990,11 +1560,13 @@ def get_accessible_detail_entity(request, entity_id, entity_history_id=None): 'edit_access': is_editable, 'view_access': is_viewable, 'is_published': is_published, - } + }, None -def can_user_view_concept(request, - historical_concept, - group_permissions=[GROUP_PERMISSIONS.VIEW, GROUP_PERMISSIONS.EDIT]): +def can_user_view_concept( + request, + historical_concept, + min_group_permission=ORGANISATION_ROLES.MEMBER +): """ Checks whether a user has the permissions to view a concept @@ -1023,13 +1595,6 @@ def can_user_view_concept(request, if historical_concept.owner == user: return True - if has_member_access(user, historical_concept, [GROUP_PERMISSIONS.VIEW, GROUP_PERMISSIONS.EDIT]): - return True - - if user and not user.is_anonymous: - if historical_concept.world_access == WORLD_ACCESS_PERMISSIONS.VIEW: - return True - # Check associated phenotypes concept = getattr(historical_concept, 'instance') if not concept: @@ -1079,49 +1644,63 @@ def can_user_view_concept(request, ] ) else: - group_access = [x.value for x in group_permissions] - sql = ''' - select * + brand = model_utils.try_get_brand(request) + + org_view_clause = '' + if brand is not None and brand.org_user_managed: + user_orgs = get_user_organisations( + request, min_role_permission=ORGANISATION_ROLES.MEMBER + ) + if user_orgs and len(user_orgs) >= 1: + org_view_clause = f''' + or live.world_access = {WORLD_ACCESS_PERMISSIONS.VIEW.value} + ''' + + sql = f''' + select distinct results.concept_id, results.concept_version_id from ( select distinct on (id) + entity.id, + entity.publish_status, cast(concepts->>'concept_id' as integer) as concept_id, cast(concepts->>'concept_version_id' as integer) as concept_version_id from public.clinicalcode_historicalgenericentity as entity, json_array_elements(entity.template_data::json->'concept_information') as concepts where ( - cast(concepts->>'concept_id' as integer) = %s - and cast(concepts->>'concept_version_id' as integer) = %s + cast(concepts->>'concept_id' as integer) = %(concept_id)s + and cast(concepts->>'concept_version_id' as integer) = %(concept_version_id)s ) and not exists ( select * from public.clinicalcode_genericentity as ge where ge.is_deleted = true and ge.id = entity.id ) - and ( - entity.publish_status = %s - or ( - exists ( - select 1 - from public.auth_user_groups as t - where t.user_id = %s and t.group_id = entity.group_id - ) - and entity.group_access in %s - ) - or entity.owner_id = %s - or entity.world_access = %s - ) ) results + join public.clinicalcode_genericentity as live + on live.id = results.id + left join public.clinicalcode_organisation as org + on org.id = live.organisation_id + left join public.clinicalcode_organisationmembership as mem + on mem.organisation_id = org.id + where ( + results.publish_status = %(publish_status)s + or org.owner_id = %(user_id)s + or (mem.user_id = %(user_id)s and mem.role >= %(role_enum)s) + or live.owner_id = %(user_id)s + {org_view_clause} + ) limit 1 ''' cursor.execute( sql, - params=[ - historical_concept.id, historical_concept.history_id, - APPROVAL_STATUS.APPROVED.value, - user.id, tuple(group_access), - user.id, WORLD_ACCESS_PERMISSIONS.VIEW.value - ] + params={ + 'concept_id': historical_concept.id, + 'concept_version_id': historical_concept.history_id, + 'publish_status': APPROVAL_STATUS.APPROVED.value, + 'user_id': user.id, + 'role_enum': min_group_permission + } ) row = cursor.fetchone() @@ -1139,6 +1718,25 @@ def check_brand_access(request, is_published, entity_id, entity_history_id=None) return is_brand_accessible(request, entity_id, entity_history_id) return True +def can_user_create_entities(request): + """ + Checks whether a user can create entities with its request's Brand context + + Args: + request (RequestContext): the HTTPRequest + + Returns: + A boolean value reflecting whether the user is able to create entities + """ + current_brand = model_utils.try_get_brand(request) + if current_brand is None or not current_brand.org_user_managed: + return True + + user_orgs = get_user_organisations(request) + if len(user_orgs) < 1: + return False + return True + def can_user_edit_entity(request, entity_id, entity_history_id=None): """ Checks whether a user has the permissions to modify an entity @@ -1172,7 +1770,7 @@ def can_user_edit_entity(request, entity_id, entity_history_id=None): if live_entity.owner == user or live_entity.created_by == user: is_allowed_to_edit = True - if has_member_access(user, live_entity, [GROUP_PERMISSIONS.EDIT]): + if has_member_access(user, live_entity, ORGANISATION_ROLES.EDITOR): is_allowed_to_edit = True if is_allowed_to_edit: @@ -1215,7 +1813,7 @@ def has_derived_edit_access(request, entity_id, entity_history_id=None): if user.is_superuser or live_entity.owner == user or live_entity.created_by == user: return False - elif has_member_access(user, live_entity, [GROUP_PERMISSIONS.EDIT]): + elif has_member_access(user, live_entity, ORGANISATION_ROLES.EDITOR): return True return False @@ -1307,13 +1905,19 @@ def user_has_concept_ownership(user, concept): Returns: (boolean) that reflects whether the user has top-level access """ + if user.is_superuser: + return True + if user is None or concept is None: return False if concept.owner == user: return True - return user.is_superuser or has_member_access(user, concept, [GROUP_PERMISSIONS.EDIT]) + owner = concept.phenotype_owner + if owner is None: + return False + return has_member_access(user, owner, ORGANISATION_ROLES.EDITOR) def validate_access_to_view(request, entity_id, entity_history_id=None): """ @@ -1344,7 +1948,7 @@ def is_brand_accessible(request, entity_id, entity_history_id=None): Returns: A (boolean) that reflects its accessibility to the request context """ - entity = model_utils.try_get_instance(GenericEntity, id=entity_id) + entity = model_utils.try_get_instance(GenericEntity, pk=entity_id) if entity is None: return False @@ -1380,17 +1984,6 @@ def allowed_to_permit(user, entity_id): return GenericEntity.objects.filter(Q(id=entity_id), Q(owner=user)).exists() -class HasAccessToViewGenericEntityCheckMixin(object): - """ - Mixin to check if user has view access to a working set - this mixin is used within class based views and can be overridden - """ - def dispatch(self, request, *args, **kwargs): - if not can_user_view_entity(request, self.kwargs['pk']): - raise PermissionDenied - - return super(HasAccessToViewGenericEntityCheckMixin, self).dispatch(request, *args, **kwargs) - def get_latest_entity_published(entity_id): """ Gets latest published entity given an entity id @@ -1404,77 +1997,21 @@ def get_latest_entity_published(entity_id): entity = entity.first() return entity -def get_latest_entity_historical_id(entity_id, user): - """ - Gets the latest entity history id for a given entity - and user, given the user has the permissions to access that - particular entity - """ - entity = model_utils.try_get_instance(GenericEntity, id=entity_id) - - if entity: - if user.is_superuser: - return int(entity.history.latest().history_id) - if user and not user.is_anonymous: - history = entity.history.filter( - Q(owner=user.id) | - Q( - group_id__in=user.groups.all(), - group_access__in=[ - GROUP_PERMISSIONS.VIEW, GROUP_PERMISSIONS.EDIT] - ) | - Q( - world_access=WORLD_ACCESS_PERMISSIONS.VIEW - ) - ) \ - .order_by('-history_id') - - if history.exists(): - return history.first().history_id - - published = get_latest_entity_published(entity.id) - if published: - return published.history_id - - return None - -def get_latest_concept_historical_id(concept_id, user): - """ - Gets the latest concept history id for a given concept - and user, given the user has the permissions to access that - particular concept - """ - concept = model_utils.try_get_instance(Concept, pk=concept_id) +'''Permission Mixins''' +class HasAccessToViewGenericEntityCheckMixin(object): + """ + Mixin to check if user has view access to a working set + this mixin is used within class based views and can be overridden + """ + def dispatch(self, request, *args, **kwargs): + if not can_user_view_entity(request, self.kwargs['pk']): + raise PermissionDenied + + return super(HasAccessToViewGenericEntityCheckMixin, self).dispatch(request, *args, **kwargs) - if concept: - if user.is_superuser: - return int(concept.history.latest().history_id) - if user and not user.is_anonymous: - history = concept.history.filter( - Q(owner=user.id) | - Q( - group_id__in=user.groups.all(), - group_access__in=[ - GROUP_PERMISSIONS.VIEW, GROUP_PERMISSIONS.EDIT] - ) | - Q( - world_access=WORLD_ACCESS_PERMISSIONS.VIEW - ) - ) \ - .order_by('-history_id') - - if history.exists(): - return history.first().history_id - - published = get_latest_publicly_accessible_concept(concept_id) - if published: - return published.history_id - - return None - -""" Legacy methods that require clenaup """ +'''Legacy methods that require cleanup''' def get_publish_approval_status(set_class, set_id, set_history_id): """ [!] Note: Legacy method from ./permissions.py @@ -1515,59 +2052,6 @@ def check_if_published(set_class, set_id, set_history_id): return False -def get_latest_published_version(set_class, set_id): - """ - [!] Note: Legacy method from ./permissions.py - - Updated to only check GenericEntity since Phenotype/WorkingSet - no longer exists in the current application - - Get latest published version - """ - - latest_published_version = None - if set_class == GenericEntity: - latest_published_version = PublishedGenericEntity.objects.filter( - entity_id=set_id, - approval_status=2 - ) \ - .order_by('-entity_history_id') \ - .first() - - if latest_published_version is not None: - return latest_published_version.entity_history_id - - return latest_published_version - -def try_get_valid_history_id(request, set_class, set_id): - """ - [!] Note: Legacy method from ./permissions.py - - Tries to resolve a valid history id for an entity query. - If the entity is accessible (i.e. validate_access_to_view() is TRUE), - then return the most recent version if the user is authenticated, - Otherwise, this method will return the most recently published version, if available. - - Args: - request (RequestContext): the request - set_class (str): a model - set_id (str): the id of the entity - - Returns: - int representing history_id - """ - set_history_id = None - is_authenticated = request.user.is_authenticated - - if is_authenticated: - set_history_id = int(set_class.objects.get(pk=set_id).history.latest().history_id) - - if not set_history_id: - latest_published_version_id = get_latest_published_version(set_class, set_id) - if latest_published_version_id: - set_history_id = latest_published_version_id - - return set_history_id def allowed_to_edit(request, set_class, set_id, user=None): """ diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py index 1a29a0296..6b25b5e7d 100644 --- a/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py +++ b/CodeListLibrary_project/clinicalcode/entity_utils/publish_utils.py @@ -1,15 +1,18 @@ from django.urls import reverse +from django.contrib.auth import get_user_model +from clinicalcode.entity_utils import model_utils import re -from django.contrib.auth.models import User -from django.urls import reverse, reverse_lazy -from django.template.loader import render_to_string + from clinicalcode.tasks import send_review_email from clinicalcode.entity_utils import constants, permission_utils, entity_db_utils - from clinicalcode.models.Concept import Concept +from clinicalcode.models.Organisation import Organisation, OrganisationMembership from clinicalcode.models.GenericEntity import GenericEntity from clinicalcode.models.PublishedGenericEntity import PublishedGenericEntity +from clinicalcode.templatetags.entity_renderer import get_template_entity_name + +User = get_user_model() def form_validation(request, data, entity_history_id, pk, entity,checks): """ @@ -131,8 +134,11 @@ def check_entity_to_publish(request, pk, entity_history_id): if not entity_has_data and entity_class == "Workingset": allow_to_publish = False + organisation_checks = check_organisation_authorities(request,entity) + checks = { 'entity_type': entity_class, + 'branded_entity_cls': get_template_entity_name(entity.template.entity_class, entity.template), 'name': entity_ver.name, 'errors': errors or None, 'allowed_to_publish': allow_to_publish, @@ -148,9 +154,59 @@ def check_entity_to_publish(request, pk, entity_history_id): 'is_latest_pending_version': is_latest_pending_version, 'all_are_published': all_are_published, 'all_not_deleted': all_not_deleted - } + } + + checks |= organisation_checks return checks +def check_organisation_authorities(request, entity): + organisation_checks = {} + + try: + organisation = entity.organisation + except Organisation.DoesNotExist: + organisation = None + + if organisation: + organisation_permissions = permission_utils.has_org_authority(request,organisation) + organisation_user_role = permission_utils.get_organisation_role(request.user,organisation) + else: + brand = model_utils.try_get_brand(request) + if brand is not None: + organisation_checks['org_user_managed'] = brand.org_user_managed + organisation_checks['allowed_to_publish'] = not brand.org_user_managed + return organisation_checks + + if isinstance(organisation_permissions,dict) and organisation_user_role is not None: + if organisation_permissions['org_user_managed']: + #Todo Fix the bug if moderator has 2 groups unless has to organisations and check if it from the same org + + organisation_checks['org_user_managed'] = organisation_permissions['org_user_managed'] + # Reset everything because of organisations + organisation_checks["allowed_to_publish"] = False + organisation_checks["is_moderator"] = False + organisation_checks["is_publisher"] = False + organisation_checks["other_pending"] = False + organisation_checks["is_lastapproved"] = False + organisation_checks["is_published"] = False + organisation_checks["is_latest_pending_version"] = False + organisation_checks["all_are_published"] = False + + + if organisation_permissions.get("can_moderate", False): + if organisation_user_role.value >= 1: + organisation_checks["allowed_to_publish"] = True + if organisation_user_role.value >= 2: + organisation_checks["is_moderator"] = True + else: + if permission_utils.is_org_managed(request): + organisation_checks["allowed_to_publish"] = False + return organisation_checks + + + return organisation_checks + + def check_child_validity(request, entity, entity_class, check_publication_validity=False): """ Wrapper for 'check_children' method authored by @zinnurov @@ -274,38 +330,62 @@ def format_message_and_send_email(request, pk, data, entity, entity_history_id, Format the message, send an email, and update data with the new message """ data['message'] = message_template.format( - entity_type=checks['entity_type'], + entity_type=checks.get('branded_entity_cls'), url=reverse('entity_history_detail', args=(pk, entity_history_id)), pk=pk, history=entity_history_id ) - send_email_decision_entity(request,entity, entity_history_id, checks['entity_type'], data) + send_email_decision_entity(request,entity, entity_history_id, checks, data) return data def get_emails_by_groupname(groupname): user_list = User.objects.filter(groups__name=groupname) return [i.email for i in user_list] -def send_email_decision_entity(request, entity, entity_history_id, entity_type,data): +def get_emails_by_organization(request,entity_id=None): + organisation = permission_utils.get_organisation(request,entity_id=entity_id) + + if organisation: + user_list = OrganisationMembership.objects.filter(organisation_id=organisation.id) + email_list = [] + for membership in user_list: + + if membership.role >= 2: + email_list.append(membership.user.email) + + return email_list + else: + return None + + + +def send_email_decision_entity(request, entity, entity_history_id, checks,data): """ Call util function to send email decision @param workingset: workingset object @param approved: approved status flag """ url_redirect = reverse('entity_history_detail', kwargs={'pk': entity.id, 'history_id': entity_history_id}) - context = {"id":entity.id,"history_id":entity_history_id, "entity_name":data['entity_name_requested'], "entity_user_id": entity.owner_id,"url_redirect":url_redirect} + + requested_userid = entity.created_by.id + + + context = {"id":entity.id,"history_id":entity_history_id, "entity_name":data['entity_name_requested'], "entity_user_id":requested_userid,"url_redirect":url_redirect} if data['approval_status'].value == constants.APPROVAL_STATUS.PENDING: context["status"] = "Pending" - context["message"] = "Your Phenotype has been submitted and is under review" + context["message"] = "Your work has been submitted and is under review" context["staff_emails"] = get_emails_by_groupname("Moderators") + if checks.get('org_user_managed',False): + context['staff_emails'] = get_emails_by_organization(request,entity.entity_id) + send_review_email(request, context) elif data['approval_status'].value == constants.APPROVAL_STATUS.APPROVED: # This line for the case when user want to get notification of same workingset id but different version context["status"] = "Published" - context["message"] = "Your Phenotype has been approved and successfully published" + context["message"] = "The work you submitted has been approved and successfully published" send_review_email(request, context) elif data['approval_status'].value == constants.APPROVAL_STATUS.REJECTED: context["status"] = "Rejected" - context["message"] = "Your Phenotype submission has been rejected by the moderator" - context["custom_message"] = "We welcome you to try again but please address these concerns with your Phenotype first" #TODO add custom message logic + context["message"] = "The work you submitted has been rejected by the moderator" + context["custom_message"] = "We welcome you to try again but please address these concerns with your work first" #TODO add custom message logic send_review_email(request, context) diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/sanitise_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/sanitise_utils.py index 1f4264743..1d3deeba2 100644 --- a/CodeListLibrary_project/clinicalcode/entity_utils/sanitise_utils.py +++ b/CodeListLibrary_project/clinicalcode/entity_utils/sanitise_utils.py @@ -1,6 +1,8 @@ from functools import partial from django.conf import settings +from html_to_markdown import convert_to_markdown +import re import bleach import logging import markdown @@ -22,6 +24,10 @@ 'strip_comments': True } +def nl_transform(match): + m = match.group(0) + return m + '\n' + def sanitise_markdown_html(text): """ Parses markdown as html and then sanitises it before reinterpreting it @@ -45,6 +51,9 @@ def sanitise_markdown_html(text): if len(text) < 1 or text.isspace(): return text + # text = re.sub(r'(^[^>\n].+[^\|\s])\n(?!\n)', nl_transform, text, flags=re.MULTILINE | re.IGNORECASE) + text = text.strip() + markdown_settings = settings.MARKDOWNIFY.get('default') whitelist_tags = markdown_settings.get('WHITELIST_TAGS', bleach.sanitizer.ALLOWED_TAGS) @@ -70,21 +79,36 @@ def sanitise_markdown_html(text): parse_email=linkify_parse_email )] - html = markdown.markdown(text, extensions=extensions, extension_configs=extension_configs) + html = markdown.markdown(text, extensions=extensions, extension_configs=extension_configs, output_format='html', tab_length=2) css_sanitizer = bleach.css_sanitizer.CSSSanitizer(allowed_css_properties=whitelist_styles) - cleaner = bleach.Cleaner(tags=whitelist_tags, + cleaner = bleach.Cleaner( + tags=whitelist_tags, attributes=whitelist_attrs, css_sanitizer=css_sanitizer, protocols=whitelist_protocols, - strip=strip, filters=linkify, + strip=True ) # See [!note] above for why we're doing this... text = cleaner.clean(html) if isinstance(text, str) and len(text) > 0 and not text.isspace(): - text = pyhtml2md.convert(text) + # options = pyhtml2md.Options() + # options.splitLines = False + + # converter = pyhtml2md.Converter(text, options) + # text = converter.convert() + text = convert_to_markdown( + html, + heading_style="atx", + strong_em_symbol="*", + bullets="-", + wrap=False, + escape_asterisks=False, + escape_underscores=False, + autolinks=False + ) else: text = '' diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/search_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/search_utils.py index e2c3f8d83..02d069851 100644 --- a/CodeListLibrary_project/clinicalcode/entity_utils/search_utils.py +++ b/CodeListLibrary_project/clinicalcode/entity_utils/search_utils.py @@ -1,3 +1,5 @@ +from operator import and_ +from functools import reduce from django.apps import apps from django.db import connection from django.db.models import Q @@ -99,7 +101,7 @@ def get_metadata_filters(request): options = get_metadata_stats_by_field(field, brand=current_brand) if options is None and 'source' in validation: - options = get_source_references(packet, default=[]) + options = get_source_references(packet, default=[], request=request) filters.append({ 'details': details, @@ -240,21 +242,24 @@ def validate_query_param(param, template, data, default=None, request=None): try: source_info = validation.get('source') model = apps.get_model(app_label='clinicalcode', model_name=source_info.get('table')) - query = { - 'pk__in': data - } + query = { 'pk__in': data } if 'filter' in source_info: filter_query = template_utils.try_get_filter_query(param, source_info.get('filter'), request=request) - query = {**query, **filter_query} - - queryset = model.objects.filter(Q(**query)) + if isinstance(filter_query, list): + query = [Q(**query), *filter_query] + + if isinstance(query, list): + queryset = model.objects.filter(reduce(and_, query)) + else: + queryset = model.objects.filter(**query) + queryset = list(queryset.values_list('id', flat=True)) except: return default else: return queryset if len(queryset) > 0 else default - elif 'options' in validation: + elif 'options' in validation and not validation.get('ugc', False): options = validation['options'] cleaned = [ ] for item in data: @@ -285,7 +290,7 @@ def apply_param_to_query(query, where, params, template, param, data, return False if field_type == 'int' or field_type == 'enum': - if 'options' in validation or 'source' in validation: + if not validation.get('ugc', False) and ('source' in validation or 'options' in validation): data = [int(x) for x in data.split(',') if gen_utils.parse_int(x, default=None) is not None] clean = validate_query_param(param, template_data, data, default=None, request=request) if clean is None and force_term: @@ -518,17 +523,14 @@ def try_search_child_concepts(entities, search=None, order_clause=None): cast(regexp_replace(id, '[a-zA-Z]+', '') as integer) as true_id, ts_rank_cd( hge.search_vector, - websearch_to_tsquery('pg_catalog.english', %(searchterm)s) + to_tsquery('pg_catalog.english', replace(to_tsquery('pg_catalog.english', concat(regexp_replace(trim(%(searchterm)s), '\W+', ':* & ', 'gm'), ':*'))::text, '<->', '|')) ) as score from public.clinicalcode_historicalgenericentity as hge where id = ANY(%(entity_ids)s) and history_id = ANY(%(history_ids)s) and hge.search_vector @@ to_tsquery( 'pg_catalog.english', - replace( - websearch_to_tsquery('pg_catalog.english', %(searchterm)s)::text || ':*', - '<->', '|' - ) + replace(to_tsquery('pg_catalog.english', concat(regexp_replace(trim(%(searchterm)s), '\W+', ':* & ', 'gm'), ':*'))::text, '<->', '|') ) {0} ), @@ -888,12 +890,12 @@ def get_renderable_entities(request, entity_types=None, method='GET', force_term history_ids = list(entities.values_list('history_id', flat=True)) entities = GenericEntity.history.extra( - select={ 'score': '''ts_rank_cd("clinicalcode_historicalgenericentity"."search_vector", websearch_to_tsquery('pg_catalog.english', %s))'''}, + select={ 'score': '''ts_rank_cd("clinicalcode_historicalgenericentity"."search_vector", to_tsquery('pg_catalog.english', replace(to_tsquery('pg_catalog.english', concat(regexp_replace(trim(%s), '\W+', ':* & ', 'gm'), ':*'))::text, '<->', '|')))'''}, select_params=[search], where=[ '''"clinicalcode_historicalgenericentity"."id" = ANY(%s)''', '''"clinicalcode_historicalgenericentity"."history_id" = ANY(%s)''', - '''"clinicalcode_historicalgenericentity"."search_vector" @@ to_tsquery('pg_catalog.english', replace(websearch_to_tsquery('pg_catalog.english', %s)::text || ':*', '<->', '|'))''' + '''"clinicalcode_historicalgenericentity"."search_vector" @@ to_tsquery('pg_catalog.english', replace(to_tsquery('pg_catalog.english', concat(regexp_replace(trim(%s), '\W+', ':* & ', 'gm'), ':*'))::text, '<->', '|'))''' ], params=[entity_ids, history_ids, search] ) @@ -937,7 +939,7 @@ def try_get_paginated_results(request, entities, page=None, page_size=None): page_obj = pagination.page(pagination.num_pages) return page_obj -def get_source_references(struct, default=None, modifier=None): +def get_source_references(struct, default=None, modifier=None, request=None): """ Retrieves the refence values from source fields e.g. tags, collections, entity type """ @@ -957,7 +959,14 @@ def get_source_references(struct, default=None, modifier=None): try: model = apps.get_model(app_label='clinicalcode', model_name=source) - objs = model.objects.all() + objs = None + if request is not None: + recfn = getattr(model, 'get_brand_records_by_request') if hasattr(model, 'get_brand_records_by_request') else None + if callable(recfn): + objs = model.get_brand_records_by_request(request) + + if objs is None: + objs = model.objects.all() if isinstance(modifier, dict): objs = objs.filter(**modifier) diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/stats_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/stats_utils.py index 070d7facf..0ef5c9158 100644 --- a/CodeListLibrary_project/clinicalcode/entity_utils/stats_utils.py +++ b/CodeListLibrary_project/clinicalcode/entity_utils/stats_utils.py @@ -30,12 +30,31 @@ def sort_by_count(a, b): return -1 return 0 -def get_field_values(field, validation, struct): +def get_field_values(field, field_value, validation, struct, brand=None): value = None if 'options' in validation: - value = template_utils.get_options_value(field, struct) + value = template_utils.get_options_value(field_value, struct) elif 'source' in validation: - value = template_utils.get_sourced_value(field, struct) + source = None + if brand is not None and (field == 'collections' or field == 'tags'): + source = constants.metadata.get(field, {}) \ + .get('validation', {}) \ + .get('source', {}) \ + .get('filter', {}) \ + .get('source_by_brand', None) + + if source is not None: + value = template_utils.get_sourced_value(field_value, struct, filter_params={ + 'field_name': field, + 'desired_filter': 'brand_filter', + 'expected_params': None, + 'source_value': source, + 'column_name': 'collection_brand', + 'brand_target': brand + }) + else: + value = template_utils.get_sourced_value(field_value, struct) + return value def transform_counted_field(data): @@ -50,23 +69,24 @@ def transform_counted_field(data): array.sort(key=sort_fn) return array -def try_get_cached_data(cache, entity, template, field, field_value, validation, struct, brand=None): +def try_get_cached_data(cache, _entity, template, field, field_value, validation, struct, brand=None): if template is None or not isinstance(cache, dict): - return get_field_values(field_value, validation, struct) - + return get_field_values(field, field_value, validation, struct, brand) + if brand is not None: cache_key = f'{brand.name}__{field}__{field_value}__{template.id}__{template.template_version}' else: cache_key = f'{field}__{field_value}__{template.id}__{template.template_version}' - + if cache_key not in cache: - value = get_field_values(field_value, validation, struct) + value = get_field_values(field, field_value, validation, struct, brand) if value is None: + cache[cache_key] = None return None - + cache[cache_key] = value return value - + return cache[cache_key] def build_statistics(statistics, entity, field, struct, data_cache=None, template_entity=None, brand=None): @@ -104,7 +124,8 @@ def build_statistics(statistics, entity, field, struct, data_cache=None, templat stats[entity_field]['count'] += 1 elif field_type == 'int_array': - if 'source' in validation: + src_field = validation.get('source') + if isinstance(src_field, dict) and not src_field.get('trees'): for item in entity_field: value = try_get_cached_data(data_cache, entity, template_entity, field, item, validation, struct, brand=brand) if value is None: @@ -161,7 +182,6 @@ def compute_statistics(statistics, entity, data_cache=None, template_cache=None, if not isinstance(struct, dict): continue - build_statistics(statistics['all'], entity, field, struct, data_cache=data_cache, template_entity=template, brand=brand) if entity.publish_status == constants.APPROVAL_STATUS.APPROVED: @@ -199,10 +219,11 @@ def collect_statistics(request): cache = { } template_cache = { } - all_entities = GenericEntity.objects.all() - to_update = [ ] to_create = [ ] + + results = [] + all_entities = GenericEntity.objects.all() for brand in Brand.objects.all(): stats = collate_statistics( all_entities, @@ -217,20 +238,24 @@ def collect_statistics(request): ) if obj.exists(): + action = 'update' obj = obj.first() obj.stat = stats + obj.modified = datetime.datetime.now() obj.updated_by = user to_update.append(obj) - continue + else: + action = 'create' + obj = Statistics( + org=brand.name, + type='GenericEntity', + stat=stats, + created_by=user + ) + to_create.append(obj) + + results.append({ 'brand': brand.name, 'value': stats, 'action': action }) - obj = Statistics( - org=brand.name, - type='GenericEntity', - stat=stats, - created_by=user - ) - to_create.append(obj) - stats = collate_statistics( all_entities, data_cache=cache, @@ -243,11 +268,14 @@ def collect_statistics(request): ) if obj.exists(): + action = 'update' obj = obj.first() obj.stat = stats + obj.modified = datetime.datetime.now() obj.updated_by = user to_update.append(obj) else: + action = 'create' obj = Statistics( org='ALL', type='GenericEntity', @@ -255,12 +283,15 @@ def collect_statistics(request): created_by=user ) to_create.append(obj) - + + results.append({ 'brand': 'all', 'value': stats, 'action': action }) + # Create / Update stat objs Statistics.objects.bulk_create(to_create) - Statistics.objects.bulk_update(to_update, ['stat', 'updated_by']) + Statistics.objects.bulk_update(to_update, ['stat', 'updated_by', 'modified']) clear_statistics_history() + return results def clear_statistics_history(): @@ -332,14 +363,6 @@ def get_homepage_stats(request, brand=None): if brand is None: brand = request.CURRENT_BRAND if request.CURRENT_BRAND is not None and request.CURRENT_BRAND != '' else 'ALL' - - collection_ids = [ ] - if brand == 'ALL': - collection_ids = Tag.objects.filter(tag_type=2) - collection_ids = [str(i) for i in collection_ids] - else: - collection_ids = model_utils.get_brand_collection_ids(brand) - collection_ids = [str(i) for i in collection_ids] published_phenotypes = entity_db_utils.get_visible_live_or_published_generic_entity_versions( request, diff --git a/CodeListLibrary_project/clinicalcode/entity_utils/template_utils.py b/CodeListLibrary_project/clinicalcode/entity_utils/template_utils.py index dfdd0aa6c..3a353378f 100644 --- a/CodeListLibrary_project/clinicalcode/entity_utils/template_utils.py +++ b/CodeListLibrary_project/clinicalcode/entity_utils/template_utils.py @@ -1,11 +1,19 @@ +from operator import and_ +from functools import reduce from django.apps import apps from django.db.models import Q, ForeignKey +import copy +import logging + from . import concept_utils from . import filter_utils from . import constants +logger = logging.getLogger(__name__) + + def try_get_content(body, key, default=None): """ Attempts to get content within a dict by a key, if it fails to do so, returns the default value @@ -37,6 +45,15 @@ def is_metadata(entity, field): data = model._meta.get_field(field) return True except: + pass + + try: + template_field = get_layout_field(entity.template, field) if entity.template is not None else None + if template_field is not None and template_field.get('is_base_field', False): + return True + except: + pass + finally: return False @@ -84,14 +101,47 @@ def get_layout_field(layout, field, default=None): """ Safely gets a field from a layout's field within its definition """ + result = None if is_layout_safe(layout): definition = try_get_content(layout, 'definition') if isinstance(layout, dict) else getattr(layout, 'definition') fields = try_get_content(definition, 'fields') if fields is not None: - return try_get_content(fields, field, default) + result = try_get_content(fields, field, default) + + if result is None: + result = try_get_content(layout, field, default) + + if isinstance(result, dict) and result.get('is_base_field'): + merged = copy.deepcopy(constants.metadata.get(field)) + merged = merged | result + return merged + return result + + +def get_template_field_info(layout, field_name, copy_field=True): + """""" + fields = get_layout_fields(layout) + if fields: + field = fields.get(field_name) + if isinstance(field, dict): + result = { 'key': field_name, 'field': field, 'is_metadata': False } + if not field.get('is_base_field'): + return result + + merged = copy.deepcopy(constants.metadata.get(field_name)) if copy_field else constants.metadata.get(field_name) + merged = merged | field + + result |= { + 'field': merged, + 'shunt': field.get('shunt') if isinstance(field.get('shunt'), str) else None, + 'is_metadata': True, + } - return try_get_content(layout, field, default) + return result + + field = constants.metadata.get(field_name, None) + return { 'key': field_name, 'field': field, 'is_metadata': True } def get_merged_definition(template, default=None): @@ -105,7 +155,9 @@ def get_merged_definition(template, default=None): return default fields = {field: packet for field, packet in constants.metadata.items() if not packet.get('ignore')} - fields.update(definition.get('fields') or {}) + for k, v in definition.get('fields', {}).items(): + fields.update({ k: copy.deepcopy(fields.get(k, {})) | v }) + fields = { field: packet for field, packet in fields.items() @@ -286,9 +338,13 @@ def is_valid_field(entity, field): if is_metadata(entity, field): return True - template = entity.template - if template is None: + try: + template = entity.template + except: return False + else: + if template is None: + return False if get_layout_field(template, field): return True @@ -373,13 +429,13 @@ def try_get_filter_query(field_name, source, request=None): request (RequestContext): the current request context, if available Returns: - The final filter query as a (dict) + The final filter query as a (list) """ - output = {} + output = [] for key, value in source.items(): filter_packet = constants.ENTITY_FILTER_PARAMS.get(key) if not isinstance(filter_packet, dict): - output[key] = value + output.append(Q(**{key: value})) continue filter_name = filter_packet.get('filter') @@ -400,20 +456,21 @@ def try_get_filter_query(field_name, source, request=None): result = None try: result = filter_utils.DataTypeFilters.try_generate_filter( - desired_filter=filter_name, - expected_params=filter_packet.get('expected_params'), - **filter_props + desired_filter=filter_name, + expected_params=filter_packet.get('expected_params'), + source_value=value, + **filter_props ) except Exception as e: - pass + logger.warning(f'Failed to build filter of field "{field_name}" with err:\n\n{e}') - if isinstance(result, dict): - output = output | result + if isinstance(result, list): + output = output + result return output -def get_metadata_value_from_source(entity, field, default=None, request=None): +def get_metadata_value_from_source(entity, field, field_info=None, layout=None, default=None, request=None): """ [!] Note: RequestContext is an optional parameter that can be provided to further filter the results based on the request's Brand @@ -423,14 +480,32 @@ def get_metadata_value_from_source(entity, field, default=None, request=None): to another table """ try: - data = getattr(entity, field) - if field in constants.metadata: - validation = get_field_item(constants.metadata, field, 'validation', {}) + info = None + if field_info is not None: + info = field_info + elif layout is not None: + info = get_template_field_info(layout, field) + + if info: + if not info.get('is_metadata'): + return default + else: + info = info.get('field') + + if info is None: + info = constants.metadata.get(field) + + if info is not None: + data = get_entity_field(entity, field) + validation = info.get('validation') + if not validation: + return default + source_info = validation.get('source') + if not source_info: + return default - model = apps.get_model( - app_label='clinicalcode', model_name=source_info.get('table') - ) + model = apps.get_model(app_label='clinicalcode', model_name=source_info.get('table')) column = 'id' if 'query' in source_info: @@ -439,47 +514,44 @@ def get_metadata_value_from_source(entity, field, default=None, request=None): if isinstance(data, model): data = getattr(data, column) - if isinstance(data, list): - query = { - f'{column}__in': data - } - else: - query = { - f'{column}': data - } + query = { f'{column}__in': data } if isinstance(data, list) else { f'{column}': data } if 'filter' in source_info: filter_query = try_get_filter_query(field, source_info.get('filter'), request=request) - query = {**query, **filter_query} + if isinstance(filter_query, list): + query = [Q(**query), *filter_query] + + if isinstance(query, list): + queryset = model.objects.filter(reduce(and_, query)) + else: + queryset = model.objects.filter(**query) - queryset = model.objects.filter(Q(**query)) if queryset.exists(): relative = 'name' if 'relative' in source_info: relative = source_info['relative'] - output = [] - for instance in queryset: - output.append({ - 'name': getattr(instance, relative), - 'value': getattr(instance, column) - }) + output = [ + { 'name': getattr(instance, relative), 'value': getattr(instance, column) } + for instance in queryset + ] return output if len(output) > 0 else default - except: - pass - else: - return default + except Exception as e: + logger.warning(f'Failed to build metadata value of field "{field}" with err:\n\n{e}') + return default -def get_template_sourced_values(template, field, default=None, request=None): +def get_template_sourced_values(template, field, default=None, request=None, struct=None): """ [!] Note: RequestContext is an optional parameter that can be provided to further filter the results based on the request's Brand Returns the complete option list of an enum or a sourced field """ - struct = get_layout_field(template, field) + if struct is None: + struct = get_layout_field(template, field) + if struct is None: return default @@ -490,9 +562,16 @@ def get_template_sourced_values(template, field, default=None, request=None): if 'options' in validation: output = [] for i, v in validation.get('options').items(): + if isinstance(v, dict): + ref = i + val = v + else: + ref = v + val = i + output.append({ - 'name': v, - 'value': i + 'name': ref, + 'value': val, }) return output @@ -513,8 +592,7 @@ def get_template_sourced_values(template, field, default=None, request=None): if isinstance(output, list): return output except Exception as e: - # Logging - pass + logger.warning(f'Failed to derive template sourced values of tree, from field "{field}" with err:\n\n{e}') elif isinstance(model_name, str): try: model = apps.get_model(app_label='clinicalcode', model_name=model_name) @@ -523,28 +601,43 @@ def get_template_sourced_values(template, field, default=None, request=None): if 'query' in source_info: column = source_info.get('query') + query = { } if 'filter' in source_info: filter_query = try_get_filter_query(field, source_info.get('filter'), request=request) - query = {**filter_query} + if isinstance(filter_query, list): + query = [Q(**query), *filter_query] + + if isinstance(query, list): + queryset = model.objects.filter(reduce(and_, query)) else: - query = {} + queryset = model.objects.filter(**query) - queryset = model.objects.filter(Q(**query)) if queryset.exists(): relative = 'name' if 'relative' in source_info: relative = source_info.get('relative') + include = source_info.get('include') + if isinstance(include, list): + include = [x.strip() for x in include if isinstance(x, str) and len(x.strip()) > 0 and not x.strip().isspace()] + include = include if len(include) > 0 else None + else: + include = None + output = [] for instance in queryset: - output.append({ - 'name': getattr(instance, relative), - 'value': getattr(instance, column) - }) + item = { + 'name': getattr(instance, relative), + 'value': getattr(instance, column), + } + + if include is not None: + item.update({ k: getattr(instance, k) for k in include if hasattr(instance, k) }) + output.append(item) return output if len(output) > 0 else default - except: - pass + except Exception as e: + logger.warning(f'Failed to derive template sourced values of column, from field "{field}" with err:\n\n{e}') return default @@ -593,24 +686,16 @@ def get_detailed_sourced_value(data, info, default=None): if 'relative' in source_info: relative = source_info['relative'] - query = None - if 'query' in source_info: - query = { - source_info['query']: data - } - else: - query = { - 'pk': data - } - + query = { source_info['query']: data } if 'query' in source_info else { 'pk': data } queryset = model.objects.filter(Q(**query)) if queryset.exists(): queryset = queryset.first() packet = { - 'name': try_get_instance_field(queryset, relative, default), - 'value': data + 'name': try_get_instance_field(queryset, relative, default), + 'value': data } + included_fields = source_info.get('include') if included_fields: for included_field in included_fields: @@ -620,12 +705,15 @@ def get_detailed_sourced_value(data, info, default=None): packet[included_field] = value return packet + return default - except: - return default + except Exception as e: + field = info.get('title', 'Unknown Field') + logger.warning(f'Failed to build detailed source value of field "{field}" with err:\n\n{e}') + return default -def get_sourced_value(data, info, default=None): +def get_sourced_value(data, info, default=None, filter_params=None): """ Tries to get the sourced value of a dynamic field from its layout and/or another model (if sourced) """ @@ -640,23 +728,37 @@ def get_sourced_value(data, info, default=None): if 'relative' in source_info: relative = source_info['relative'] - query = None - if 'query' in source_info: - query = { - source_info['query']: data - } + query = { source_info['query']: data } if 'query' in source_info else { 'pk': data } + + if 'filter' in source_info and isinstance(filter_params, dict): + filter_query = None + try: + if 'source_value' not in filter_params: + filter_params.update({ 'source_value': source_info.get('filter') }) + + filter_query = filter_utils.DataTypeFilters.try_generate_filter(**filter_params) + if isinstance(filter_query, list): + query = [Q(**query), *filter_query] + + except Exception as e: + field = info.get('title', 'Unknown Field') + logger.warning(f'Failed to build filter of field "{field}" with err:\n\n{e}') + + if isinstance(query, list): + queryset = model.objects.filter(reduce(and_, query)) else: - query = { - 'pk': data - } + queryset = model.objects.filter(**query) - queryset = model.objects.filter(Q(**query)) if queryset.exists(): queryset = queryset.first() return try_get_instance_field(queryset, relative, default) + return default - except: - return default + except Exception as e: + field = info.get('title', 'Unknown Field') + logger.warning(f'Failed to build sourced value of field "{field}" with err:\n\n{e}') + + return default def get_template_data_values(entity, layout, field, hide_user_details=False, request=None, default=None): @@ -678,7 +780,7 @@ def get_template_data_values(entity, layout, field, hide_user_details=False, req if field_type == 'enum' or field_type == 'int': output = None - if 'options' in validation: + if 'options' in validation and not validation.get('ugc', False): output = get_detailed_options_value(data, info) elif 'source' in validation: output = get_detailed_sourced_value(data, info) @@ -686,32 +788,35 @@ def get_template_data_values(entity, layout, field, hide_user_details=False, req return [output] elif field_type == 'int_array': source_info = validation.get('source') - if not source_info: - return default - - model_name = source_info.get('table') - tree_models = source_info.get('trees') - - if isinstance(tree_models, list): - model_source = source_info.get('model') - if isinstance(model_source, str): - try: - model = apps.get_model(app_label='clinicalcode', model_name=model_source) - output = model.get_detailed_source_value(data, tree_models, default=default) - if isinstance(output, list): - return output - except Exception as e: - # Logging - pass - elif isinstance(model_name, str): - values = [] - for item in data: - value = get_detailed_sourced_value(item, info) - if value is None: - continue + options = validation.get('options') + if source_info: + model_name = source_info.get('table') + tree_models = source_info.get('trees') + + if isinstance(tree_models, list): + model_source = source_info.get('model') + if isinstance(model_source, str): + try: + model = apps.get_model(app_label='clinicalcode', model_name=model_source) + output = model.get_detailed_source_value(data, tree_models, default=default) + if isinstance(output, list): + return output + except Exception as e: + logger.warning(f'Failed to derive template data values of "{field}" with err:\n\n{e}') + elif isinstance(model_name, str): + values = [] + for item in data: + value = get_detailed_sourced_value(item, info) + if value is None: + continue - values.append(value) - return values + values.append(value) + return values + elif isinstance(options, dict) and isinstance(data, list): + values = [{ 'name': options.get(x), 'value': x } for x in data if isinstance(options.get(x), str)] + return values if len(values) > 0 else default + else: + return default elif field_type == 'concept': values = [] for item in data: diff --git a/CodeListLibrary_project/clinicalcode/forms/ArchiveForm.py b/CodeListLibrary_project/clinicalcode/forms/ArchiveForm.py index 40ffd40bf..22b988f0f 100644 --- a/CodeListLibrary_project/clinicalcode/forms/ArchiveForm.py +++ b/CodeListLibrary_project/clinicalcode/forms/ArchiveForm.py @@ -25,7 +25,7 @@ class ArchiveForm(forms.Form): widget=forms.TextInput( attrs={ 'class': 'text-input', - 'aria-label': 'Enter the Phenotype ID to confirm', + 'aria-label': 'Enter the entity ID to confirm', 'autocomplete': 'off', 'autocorrect': 'off', } @@ -38,7 +38,7 @@ class ArchiveForm(forms.Form): attrs={ 'class': 'text-area-input', 'style': 'resize: none;', - 'aria-label': 'Enter the reason you\'re archiving this Phenotype', + 'aria-label': 'Enter the reason you\'re archiving this entity', 'rows': '4', 'autocomplete': 'off', 'autocorrect': 'on', diff --git a/CodeListLibrary_project/clinicalcode/forms/EntityClassForm.py b/CodeListLibrary_project/clinicalcode/forms/EntityClassForm.py index dca33c1fb..e6d959c27 100644 --- a/CodeListLibrary_project/clinicalcode/forms/EntityClassForm.py +++ b/CodeListLibrary_project/clinicalcode/forms/EntityClassForm.py @@ -1,8 +1,10 @@ from django import forms -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from ..models.EntityClass import EntityClass +User = get_user_model() + class EntityAdminForm(forms.ModelForm): """ excludes the created_by field so that the request.user who creates/updates this form is set as the diff --git a/CodeListLibrary_project/clinicalcode/forms/OrganisationForms.py b/CodeListLibrary_project/clinicalcode/forms/OrganisationForms.py new file mode 100644 index 000000000..f63ea899d --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/forms/OrganisationForms.py @@ -0,0 +1,286 @@ +from django import forms +from django.forms.models import modelformset_factory +from django.core.validators import RegexValidator +from django.core.exceptions import ValidationError +from django.contrib import admin +from django.utils.text import slugify +from django.contrib.auth import get_user_model + +from ..models.Organisation import Organisation, OrganisationMembership, OrganisationAuthority +from ..entity_utils import gen_utils, permission_utils, model_utils + +from django.utils import timezone + +User = get_user_model() + +""" Admin """ + +class OrganisationMembershipInline(admin.TabularInline): + model = OrganisationMembership + extra = 1 + +class OrganisationAuthorityInline(admin.TabularInline): + model = OrganisationAuthority + extra = 1 + +class OrganisationAdminForm(forms.ModelForm): + """ + Override organisation admin form management + """ + name = forms.CharField( + required=True, + widget=forms.TextInput( + attrs={ } + ), + min_length=3, + max_length=250 + ) + slug = forms.CharField( + required=False, + widget=forms.TextInput( + attrs={ } + ) + ) + description = forms.CharField( + required=False, + widget=forms.Textarea( + attrs={ } + ), + max_length=1000 + ) + email = forms.EmailField(required=False) + website = forms.URLField(required=False) + owner = forms.ModelChoiceField(queryset=User.objects.all()) + created = forms.DateTimeField( + required=False, + widget=forms.DateTimeInput(attrs={'readonly': 'readonly'}) + ) + + def __init__(self, *args, **kwargs): + super(OrganisationAdminForm, self).__init__(*args, **kwargs) + + class Meta: + model = Organisation + fields = '__all__' + + def clean_created(self): + """ + Responsible for cleaning the `created` field + + Returns: + (timezone) - the cleaned `created` value + """ + # Example clean individual fields + if self.cleaned_data.get('created', None) is None: + return timezone.now() + + return self.cleaned_data['created'] + + def clean(self): + """ + Responsible for cleaning the model fields form data + + Returns: + (dict) - the cleaned model form data + """ + # Example clean multiple fields + data = self.cleaned_data + + create_datetime = data.get('created', None) + if create_datetime is None: + data.update({ 'created': timezone.now() }) + + return data + +""" Create / Update """ + +class OrganisationCreateForm(forms.ModelForm): + NameValidator = RegexValidator( + r'^(?=.*[a-zA-Z].*)([a-zA-Z0-9\-_\(\) ]+)*$', + 'Name can only contain a-z, 0-9, -, _, ( and )' + ) + + name = forms.CharField( + required=True, + widget=forms.TextInput( + attrs={ + 'class': 'text-input', + 'aria-label': 'Enter the organisation\'s name', + 'autocomplete': 'off', + 'autocorrect': 'off', + } + ), + min_length=3, + max_length=250, + validators=[NameValidator] + ) + description = forms.CharField( + required=False, + widget=forms.Textarea( + attrs={ + 'class': 'text-area-input', + 'style': 'resize: none;', + 'aria-label': 'Describe your organisation', + 'rows': '4', + 'autocomplete': 'off', + 'autocorrect': 'on', + 'spellcheck': 'default', + 'wrap': 'soft', + } + ), + max_length=1000 + ) + email = forms.EmailField( + required=False, + widget=forms.EmailInput( + attrs={ + 'class': 'text-input', + 'aria-label': 'Enter the organisation\'s name', + 'autocomplete': 'on', + 'autocorrect': 'off', + } + ) + ) + website = forms.URLField( + required=False, + widget=forms.URLInput( + attrs={ + 'class': 'text-input', + 'aria-label': 'Enter the organisation\'s name', + 'autocomplete': 'off', + 'autocorrect': 'off', + } + ) + ) + + def __init__(self, *args, **kwargs): + initial = kwargs.get('initial') + super(OrganisationCreateForm, self).__init__(*args, **kwargs) + + owner = initial.get('owner') + self.fields['owner'] = forms.ModelChoiceField( + queryset=User.objects.filter(pk=owner.id), + initial=owner, + widget=forms.HiddenInput(), + required=False + ) + + class Meta: + model = Organisation + fields = '__all__' + exclude = ['slug', 'created', 'members', 'brands'] + + def clean(self): + """ + Responsible for cleaning the model fields form data + + Returns: + (dict) - the cleaned model form data + """ + data = super(OrganisationCreateForm, self).clean() + + name = data.get('name', None) + if gen_utils.is_empty_string(name): + self.add_error( + 'name', + ValidationError('Name cannot be empty') + ) + else: + data.update({ 'slug': slugify(name) }) + + create_datetime = data.get('created', None) + if create_datetime is None: + data.update({ 'created': timezone.now() }) + + return data + +class OrganisationManageForm(forms.ModelForm): + NameValidator = RegexValidator( + r'^(?=.*[a-zA-Z].*)([a-zA-Z0-9\-_\(\) ]+)*$', + 'Name can only contain a-z, 0-9, -, _, ( and )' + ) + + name = forms.CharField( + required=True, + widget=forms.TextInput( + attrs={ + 'class': 'text-input', + 'aria-label': 'Enter the organisation\'s name', + 'autocomplete': 'off', + 'autocorrect': 'off', + } + ), + min_length=3, + max_length=250, + validators=[NameValidator] + ) + description = forms.CharField( + required=False, + widget=forms.Textarea( + attrs={ + 'class': 'text-area-input', + 'style': 'resize: none;', + 'aria-label': 'Describe your organisation', + 'rows': '4', + 'autocomplete': 'off', + 'autocorrect': 'on', + 'spellcheck': 'default', + 'wrap': 'soft', + } + ), + max_length=1000 + ) + email = forms.EmailField( + required=False, + widget=forms.EmailInput( + attrs={ + 'class': 'text-input', + 'aria-label': 'Enter the organisation\'s name', + 'autocomplete': 'on', + 'autocorrect': 'off', + } + ) + ) + website = forms.URLField( + required=False, + widget=forms.URLInput( + attrs={ + 'class': 'text-input', + 'aria-label': 'Enter the organisation\'s name', + 'autocomplete': 'off', + 'autocorrect': 'off', + } + ) + ) + + def __init__(self, *args, **kwargs): + super(OrganisationManageForm, self).__init__(*args, **kwargs) + + class Meta: + model = Organisation + fields = '__all__' + exclude = ['slug', 'created', 'owner', 'brands', 'members'] + + def clean(self): + """ + Responsible for cleaning the model fields form data + + Returns: + (dict) - the cleaned model form data + """ + data = super(OrganisationManageForm, self).clean() + + name = data.get('name', None) + if gen_utils.is_empty_string(name): + self.add_error( + 'name', + ValidationError('Name cannot be empty') + ) + else: + data.update({ 'slug': slugify(name) }) + + create_datetime = data.get('created', None) + if create_datetime is None: + data.update({ 'created': timezone.now() }) + + return data diff --git a/CodeListLibrary_project/clinicalcode/forms/TemplateForm.py b/CodeListLibrary_project/clinicalcode/forms/TemplateForm.py index 358e8e476..614348f1c 100644 --- a/CodeListLibrary_project/clinicalcode/forms/TemplateForm.py +++ b/CodeListLibrary_project/clinicalcode/forms/TemplateForm.py @@ -1,17 +1,7 @@ from django import forms -import json - +from ..entity_utils import gen_utils, template_utils from ..models.Template import Template -from ..entity_utils import template_utils - -class PrettyPrintOrderedDefinition(json.JSONEncoder): - """ - Indents and prettyprints the definition field so that it's readable - Preserves order that was given by template_utils.get_ordered_definition - """ - def __init__(self, *args, indent, sort_keys, **kwargs): - super().__init__(*args, indent=2, sort_keys=False, **kwargs) class TemplateAdminForm(forms.ModelForm): """ @@ -23,7 +13,7 @@ class TemplateAdminForm(forms.ModelForm): name = forms.CharField(widget=forms.TextInput(attrs={'readonly': 'readonly'}), required=False) description = forms.CharField(widget=forms.Textarea(attrs={'readonly': 'readonly'}), required=False) template_version = forms.IntegerField(widget=forms.NumberInput(attrs={'readonly':'readonly'})) - definition = forms.JSONField(encoder=PrettyPrintOrderedDefinition) + definition = forms.JSONField(encoder=gen_utils.PrettyPrintOrderedDefinition) def __init__(self, *args, **kwargs): super(TemplateAdminForm, self).__init__(*args, **kwargs) @@ -52,7 +42,6 @@ def clean(self): return data - class Meta: model = Template fields = '__all__' \ No newline at end of file diff --git a/CodeListLibrary_project/clinicalcode/middleware/brands.py b/CodeListLibrary_project/clinicalcode/middleware/brands.py index 6122fc3c9..69a08106b 100644 --- a/CodeListLibrary_project/clinicalcode/middleware/brands.py +++ b/CodeListLibrary_project/clinicalcode/middleware/brands.py @@ -1,18 +1,20 @@ +from importlib import import_module from django.conf import settings +from django.urls import clear_url_caches, set_urlconf from django.contrib import auth, messages -from django.core.exceptions import ImproperlyConfigured, PermissionDenied from django.shortcuts import redirect -from django.urls import clear_url_caches, set_urlconf -from django.utils.deprecation import MiddlewareMixin from rest_framework.reverse import reverse -from importlib import import_module +from django.core.exceptions import ImproperlyConfigured, PermissionDenied +from django.utils.deprecation import MiddlewareMixin import os +import re import sys import numbers import importlib from clinicalcode.models import Brand +from clinicalcode.entity_utils import gen_utils class BrandMiddleware(MiddlewareMixin): ''' @@ -24,41 +26,26 @@ def process_request(self, request): # if the user is a member of 'ReadOnlyUsers' group, make READ-ONLY True if request.user.is_authenticated: CLL_READ_ONLY_org = self.get_env_value('CLL_READ_ONLY', cast='bool') - if settings.DEBUG: - print("CLL_READ_ONLY_org = ", str(CLL_READ_ONLY_org)) settings.CLL_READ_ONLY = CLL_READ_ONLY_org - if settings.DEBUG: - print("CLL_READ_ONLY_org (after) = ", str(CLL_READ_ONLY_org)) - #self.chkReadOnlyUsers(request) if not settings.CLL_READ_ONLY: if (request.user.groups.filter(name='ReadOnlyUsers').exists()): msg1 = "You are assigned as a Read-Only-User." if request.session.get('read_only_msg', "") == "": request.session['read_only_msg'] = msg1 messages.error(request, msg1) - settings.CLL_READ_ONLY = True - if settings.DEBUG: - print("settings.CLL_READ_ONLY = ", str(settings.CLL_READ_ONLY)) - #--------------------------------- - - #if request.user.is_authenticated: - #print "...........start..............." - #brands = Brand.objects.values_list('name', flat=True) - brands = Brand.objects.all() - brands_list = [x.upper() for x in list(brands.values_list('name', flat=True)) ] + #--------------------------------- + brands_list = Brand.all_names() + is_live_hdruk = re.search(r'(phenotypes\.healthdatagateway)|(web\-phenotypes\-hdr)', request.get_host(), flags=re.IGNORECASE) current_page_url = request.path_info.lstrip('/') - #print "**** get_host= " , str(request.get_host()) - request.IS_HDRUK_EXT = "0" settings.IS_HDRUK_EXT = "0" root = current_page_url.split('/')[0] - if (request.get_host().lower().find('phenotypes.healthdatagateway') != -1 or - request.get_host().lower().find('web-phenotypes-hdr') != -1): + if is_live_hdruk: root = 'HDRUK' request.IS_HDRUK_EXT = "1" settings.IS_HDRUK_EXT = "1" @@ -82,63 +69,38 @@ def process_request(self, request): urlconf = None urlconf = settings.ROOT_URLCONF - request.session['all_brands'] = brands_list #json.dumps(brands_list) + request.session['all_brands'] = brands_list request.session['current_brand'] = root - request.BRAND_GROUPS = [] - userBrands = [] - all_brands_groups = [] - for b in brands: - b_g = {} - groups = b.groups.all() - if (any(x in request.user.groups.all() for x in groups) or b.owner == request.user): - userBrands.append(b.name.upper()) - - b_g[b.name.upper()] = list(groups.values_list('name', flat=True)) - all_brands_groups.append(b_g) - - request.session['user_brands'] = userBrands #json.dumps(userBrands) - request.BRAND_GROUPS = all_brands_groups - do_redirect = False if root in brands_list: - if settings.DEBUG: print("root=", root) settings.CURRENT_BRAND = root request.CURRENT_BRAND = root settings.CURRENT_BRAND_WITH_SLASH = "/" + root request.CURRENT_BRAND_WITH_SLASH = "/" + root - brand_object = Brand.objects.get(name__iexact=root) + brand_object = next((x for x in Brand.all_instances() if x.name.upper() == root.upper()), {}) + settings.BRAND_OBJECT = brand_object request.BRAND_OBJECT = brand_object - if brand_object.site_title is not None: + + has_brand = brand_object is not None and not isinstance(brand_object, dict) + if has_brand and brand_object.site_title is not None and not gen_utils.is_empty_string(brand_object.site_title): request.SWAGGER_TITLE = brand_object.site_title + " API" settings.SWAGGER_TITLE = brand_object.site_title + " API" if not current_page_url.strip().endswith('/'): current_page_url = current_page_url.strip() + '/' - if (request.get_host().lower().find('phenotypes.healthdatagateway') != -1 or - request.get_host().lower().find('web-phenotypes-hdr') != -1): - - pass - else: - # # path_info does not change address bar urls + if not is_live_hdruk: request.path_info = '/' + '/'.join([root.upper()] + current_page_url.split('/')[1:]) -# print "-------" -# print current_page_url -# print current_page_url.split('/')[1:] -# print '/'.join([root.upper()] + current_page_url.split('/')[1:]) -# print request.path_info -# print "-------" - - urlconf = "cll.urls_brand" - set_urlconf(urlconf) - request.urlconf = urlconf # this is the python file path to custom urls.py file + urlconf = "cll.urls_brand" + set_urlconf(urlconf) + request.urlconf = urlconf - # redirect /{brand}/api/ to /{brand}/api/v1/ + # redirect `/{brand}/api/` to `/{brand}/api/v1/` to appear in URL address bar if current_page_url.strip().rstrip('/').split('/')[-1].lower() in ['api']: do_redirect = True current_page_url = current_page_url.strip().rstrip('/') + '/v1/' @@ -148,29 +110,14 @@ def process_request(self, request): importlib.reload(sys.modules[urlconf]) importlib.reload(import_module(urlconf)) importlib.reload(sys.modules["clinicalcode.api.urls"]) - importlib.reload(import_module("clinicalcode.api.urls")) + importlib.reload(import_module("clinicalcode.api.urls")) if settings.DEBUG: - print(request.path_info) - print(str(request.get_full_path())) - - # Do NOT allow concept create under HDRUK - for now - if (str(request.get_full_path()).upper().replace('/', '') == "/HDRUK/concepts/create/".upper().replace('/', '') or - ((request.get_host().lower().find('phenotypes.healthdatagateway') != -1 or request.get_host().lower().find('web-phenotypes-hdr') != -1) - and str(request.get_full_path()).upper().replace('/', '').endswith('/concepts/create/'.upper().replace('/', '')))): + print(f'Brand Ctx: {settings.CURRENT_BRAND} | Route: {str(request.get_full_path())} | Info: {request.path_info}') - raise PermissionDenied - - # redirect /{brand}/api/ to /{brand}/api/v1/ to appear in URL address bar if do_redirect: return redirect(reverse('api:root')) - #print "get_urlconf=" , str(get_urlconf()) - #print "settings.CURRENT_BRAND=" , settings.CURRENT_BRAND - #print "request.CURRENT_BRAND=" , request.CURRENT_BRAND - - #print "...........end..............." - return None def chkReadOnlyUsers(self, request): diff --git a/CodeListLibrary_project/clinicalcode/middleware/exceptions.py b/CodeListLibrary_project/clinicalcode/middleware/exceptions.py new file mode 100644 index 000000000..c09b3af71 --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/middleware/exceptions.py @@ -0,0 +1,26 @@ +from django.conf import settings +from django.http import HttpResponseServerError +from django.http.response import JsonResponse +from django.template.loader import get_template + +import logging + +class ExceptionMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + return self.get_response(request) + + def process_exception(self, request, exception): + if settings.DEBUG: + raise exception + + logging.exception(f'Exception on View with err:\n{str(exception)}') + + if request.accepts('text/html'): + response = HttpResponseServerError(get_template('500.html').render(request=request)) + else: + response = JsonResponse({ 'status': 'false', 'message': 'Server Error' }, status=500) + + return response diff --git a/CodeListLibrary_project/clinicalcode/migrations/0121_organisation_genericentity_organisation_and_more.py b/CodeListLibrary_project/clinicalcode/migrations/0121_organisation_genericentity_organisation_and_more.py new file mode 100644 index 000000000..26c0c0a6f --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/migrations/0121_organisation_genericentity_organisation_and_more.py @@ -0,0 +1,82 @@ +# Generated by Django 5.1.2 on 2024-11-26 22:01 + +import django.db.models.deletion +import django.utils.timezone +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('clinicalcode', '0120_ontologytag_clinicalcod_type_id_c68d0b_idx_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Organisation', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('slug', models.SlugField(unique=True)), + ('name', models.CharField(max_length=250, unique=True)), + ('description', models.TextField(blank=True, max_length=1000)), + ('email', models.EmailField(blank=True, max_length=254, null=True)), + ('website', models.URLField(blank=True, null=True)), + ('created', models.DateTimeField(default=django.utils.timezone.now)), + ('owner', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_organisations', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddField( + model_name='genericentity', + name='organisation', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='entities', to='clinicalcode.organisation'), + ), + migrations.AddField( + model_name='historicalgenericentity', + name='organisation', + field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='clinicalcode.organisation'), + ), + migrations.CreateModel( + name='OrganisationAuthority', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('can_post', models.BooleanField(default=False)), + ('can_moderate', models.BooleanField(default=False)), + ('brand', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='clinicalcode.brand')), + ('organisation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='clinicalcode.organisation')), + ], + ), + migrations.AddField( + model_name='organisation', + name='brands', + field=models.ManyToManyField(related_name='organisations', through='clinicalcode.OrganisationAuthority', to='clinicalcode.brand'), + ), + migrations.CreateModel( + name='OrganisationInvite', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('outcome', models.IntegerField(choices=[(0, 'EXPIRED'), (1, 'ACTIVE'), (2, 'SEEN'), (3, 'ACCEPTED'), (4, 'REJECTED')], default=1)), + ('sent', models.BooleanField(default=False)), + ('created', models.DateTimeField(default=django.utils.timezone.now, editable=False)), + ('organisation', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='invites', to='clinicalcode.organisation')), + ('user', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='organisation_invites', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='OrganisationMembership', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('role', models.IntegerField(choices=[(0, 'MEMBER'), (1, 'EDITOR'), (2, 'MODERATOR'), (3, 'ADMIN')], default=0)), + ('joined', models.DateTimeField(default=django.utils.timezone.now, editable=False)), + ('organisation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='clinicalcode.organisation')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddField( + model_name='organisation', + name='members', + field=models.ManyToManyField(related_name='organisations', through='clinicalcode.OrganisationMembership', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/CodeListLibrary_project/clinicalcode/migrations/0122_brand_org_user_managed_and_more.py b/CodeListLibrary_project/clinicalcode/migrations/0122_brand_org_user_managed_and_more.py new file mode 100644 index 000000000..07d59c14b --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/migrations/0122_brand_org_user_managed_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.2 on 2024-11-26 22:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('clinicalcode', '0121_organisation_genericentity_organisation_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='brand', + name='org_user_managed', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='historicalbrand', + name='org_user_managed', + field=models.BooleanField(default=False), + ), + ] diff --git a/CodeListLibrary_project/clinicalcode/migrations/0123_remove_brand_css_path_remove_brand_groups_and_more.py b/CodeListLibrary_project/clinicalcode/migrations/0123_remove_brand_css_path_remove_brand_groups_and_more.py new file mode 100644 index 000000000..f72b49179 --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/migrations/0123_remove_brand_css_path_remove_brand_groups_and_more.py @@ -0,0 +1,40 @@ +# Generated by Django 5.1.2 on 2025-03-14 13:43 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('clinicalcode', '0122_brand_org_user_managed_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RemoveField( + model_name='brand', + name='css_path', + ), + migrations.RemoveField( + model_name='brand', + name='groups', + ), + migrations.RemoveField( + model_name='brand', + name='owner', + ), + migrations.AddField( + model_name='brand', + name='admins', + field=models.ManyToManyField(related_name='administered_brands', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='brand', + name='overrides', + field=models.JSONField(blank=True, null=True), + ), + migrations.DeleteModel( + name='HistoricalBrand', + ), + ] diff --git a/CodeListLibrary_project/clinicalcode/migrations/0124_hdrnsite_brand_site_description_and_more.py b/CodeListLibrary_project/clinicalcode/migrations/0124_hdrnsite_brand_site_description_and_more.py new file mode 100644 index 000000000..0a22fbfd6 --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/migrations/0124_hdrnsite_brand_site_description_and_more.py @@ -0,0 +1,84 @@ +# Generated by Django 5.1.2 on 2025-03-16 18:16 + +import django.contrib.postgres.fields +import django.contrib.postgres.indexes +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('clinicalcode', '0123_remove_brand_css_path_remove_brand_groups_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='HDRNSite', + fields=[ + ('created', models.DateTimeField(auto_now_add=True)), + ('modified', models.DateTimeField(auto_now_add=True)), + ('id', models.AutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=512, unique=True)), + ('description', models.TextField(blank=True, null=True)), + ('metadata', models.JSONField(blank=True, null=True)), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='brand', + name='site_description', + field=models.CharField(blank=True, max_length=160, null=True), + ), + migrations.AlterField( + model_name='datasource', + name='datasource_id', + field=models.IntegerField(null=True), + ), + migrations.AlterField( + model_name='historicaldatasource', + name='datasource_id', + field=models.IntegerField(null=True), + ), + migrations.CreateModel( + name='HDRNDataCategory', + fields=[ + ('created', models.DateTimeField(auto_now_add=True)), + ('modified', models.DateTimeField(auto_now_add=True)), + ('id', models.AutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=512)), + ('description', models.TextField(blank=True, null=True)), + ('metadata', models.JSONField(blank=True, null=True)), + ], + options={ + 'indexes': [django.contrib.postgres.indexes.GinIndex(fields=['name'], name='hdrn_dcnm_trgm_idx', opclasses=['gin_trgm_ops'])], + }, + ), + migrations.CreateModel( + name='HDRNDataAsset', + fields=[ + ('created', models.DateTimeField(auto_now_add=True)), + ('modified', models.DateTimeField(auto_now_add=True)), + ('id', models.AutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=512)), + ('description', models.TextField(blank=True, null=True)), + ('hdrn_id', models.IntegerField(blank=True, null=True)), + ('hdrn_uuid', models.UUIDField(blank=True, null=True)), + ('link', models.URLField(blank=True, max_length=500, null=True)), + ('years', models.CharField(blank=True, max_length=256, null=True)), + ('scope', models.TextField(blank=True, null=True)), + ('region', models.CharField(blank=True, max_length=2048, null=True)), + ('purpose', models.TextField(blank=True, null=True)), + ('collection_period', models.TextField(blank=True, null=True)), + ('data_level', models.CharField(blank=True, max_length=256, null=True)), + ('data_categories', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), blank=True, null=True, size=None)), + ('site', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='data_assets', to='clinicalcode.hdrnsite')), + ], + options={ + 'ordering': ('name',), + 'indexes': [django.contrib.postgres.indexes.GinIndex(fields=['name'], name='hdrn_danm_trgm_idx', opclasses=['gin_trgm_ops']), django.contrib.postgres.indexes.GinIndex(fields=['data_categories'], name='hdrn_dadc_arr_idx', opclasses=['array_ops'])], + }, + ), + ] diff --git a/CodeListLibrary_project/clinicalcode/migrations/0125_brand_is_administrable.py b/CodeListLibrary_project/clinicalcode/migrations/0125_brand_is_administrable.py new file mode 100644 index 000000000..edb1a0e54 --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/migrations/0125_brand_is_administrable.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.2 on 2025-03-18 11:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('clinicalcode', '0124_hdrnsite_brand_site_description_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='brand', + name='is_administrable', + field=models.BooleanField(default=False), + ), + ] diff --git a/CodeListLibrary_project/clinicalcode/migrations/0126_historicaltemplate_brands_template_brands.py b/CodeListLibrary_project/clinicalcode/migrations/0126_historicaltemplate_brands_template_brands.py new file mode 100644 index 000000000..e8be7d820 --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/migrations/0126_historicaltemplate_brands_template_brands.py @@ -0,0 +1,24 @@ +# Generated by Django 5.1.2 on 2025-03-24 14:13 + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('clinicalcode', '0125_brand_is_administrable'), + ] + + operations = [ + migrations.AddField( + model_name='historicaltemplate', + name='brands', + field=django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), blank=True, null=True, size=None), + ), + migrations.AddField( + model_name='template', + name='brands', + field=django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), blank=True, null=True, size=None), + ), + ] diff --git a/CodeListLibrary_project/clinicalcode/migrations/0127_alter_brand_options.py b/CodeListLibrary_project/clinicalcode/migrations/0127_alter_brand_options.py new file mode 100644 index 000000000..2f30922a6 --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/migrations/0127_alter_brand_options.py @@ -0,0 +1,17 @@ +# Generated by Django 5.1.2 on 2025-03-27 11:05 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('clinicalcode', '0126_historicaltemplate_brands_template_brands'), + ] + + operations = [ + migrations.AlterModelOptions( + name='brand', + options={'ordering': ('name',), 'verbose_name': 'Brand', 'verbose_name_plural': 'Brands'}, + ), + ] diff --git a/CodeListLibrary_project/clinicalcode/migrations/0128_alter_hdrndataasset_options_and_more.py b/CodeListLibrary_project/clinicalcode/migrations/0128_alter_hdrndataasset_options_and_more.py new file mode 100644 index 000000000..1591dee76 --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/migrations/0128_alter_hdrndataasset_options_and_more.py @@ -0,0 +1,41 @@ +# Generated by Django 5.1.2 on 2025-03-28 10:17 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('clinicalcode', '0127_alter_brand_options'), + ] + + operations = [ + migrations.AlterModelOptions( + name='hdrndataasset', + options={'ordering': ('name',), 'verbose_name': 'HDRN Data Asset', 'verbose_name_plural': 'HDRN Data Assets'}, + ), + migrations.AlterModelOptions( + name='hdrndatacategory', + options={'verbose_name': 'HDRN Data Category', 'verbose_name_plural': 'HDRN Data Categories'}, + ), + migrations.AlterModelOptions( + name='hdrnsite', + options={'verbose_name': 'HDRN Site', 'verbose_name_plural': 'HDRN Sites'}, + ), + migrations.AlterModelOptions( + name='historicaltag', + options={'get_latest_by': ('history_date', 'history_id'), 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical Tag', 'verbose_name_plural': 'historical Tags'}, + ), + migrations.AlterModelOptions( + name='historicaltemplate', + options={'get_latest_by': ('history_date', 'history_id'), 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical Template', 'verbose_name_plural': 'historical Templates'}, + ), + migrations.AlterModelOptions( + name='tag', + options={'ordering': ('description',), 'verbose_name': 'Tag', 'verbose_name_plural': 'Tags'}, + ), + migrations.AlterModelOptions( + name='template', + options={'ordering': ('name',), 'verbose_name': 'Template', 'verbose_name_plural': 'Templates'}, + ), + ] diff --git a/CodeListLibrary_project/clinicalcode/migrations/0129_brand_users_alter_brand_admins.py b/CodeListLibrary_project/clinicalcode/migrations/0129_brand_users_alter_brand_admins.py new file mode 100644 index 000000000..4a36fe513 --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/migrations/0129_brand_users_alter_brand_admins.py @@ -0,0 +1,25 @@ +# Generated by Django 5.1.2 on 2025-03-28 14:56 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('clinicalcode', '0128_alter_hdrndataasset_options_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='brand', + name='users', + field=models.ManyToManyField(blank=True, related_name='accessible_brands', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='brand', + name='admins', + field=models.ManyToManyField(blank=True, related_name='administered_brands', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/CodeListLibrary_project/clinicalcode/migrations/0130_hdrnjurisdiction_remove_hdrndataasset_region_and_more.py b/CodeListLibrary_project/clinicalcode/migrations/0130_hdrnjurisdiction_remove_hdrndataasset_region_and_more.py new file mode 100644 index 000000000..65bd9c529 --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/migrations/0130_hdrnjurisdiction_remove_hdrndataasset_region_and_more.py @@ -0,0 +1,76 @@ +# Generated by Django 5.1.8 on 2025-04-20 14:15 + +import django.contrib.postgres.indexes +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('clinicalcode', '0129_brand_users_alter_brand_admins'), + ] + + operations = [ + migrations.CreateModel( + name='HDRNJurisdiction', + fields=[ + ('created', models.DateTimeField(auto_now_add=True)), + ('modified', models.DateTimeField(auto_now_add=True)), + ('id', models.AutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=512, unique=True)), + ('abbreviation', models.CharField(blank=True, max_length=256, null=True)), + ('description', models.TextField(blank=True, null=True)), + ('metadata', models.JSONField(blank=True, null=True)), + ], + options={ + 'verbose_name': 'HDRN Jurisdiction', + 'verbose_name_plural': 'HDRN Jurisdictions', + }, + ), + migrations.RemoveField( + model_name='hdrndataasset', + name='region', + ), + migrations.AddField( + model_name='hdrnsite', + name='abbreviation', + field=models.CharField(blank=True, max_length=256, null=True), + ), + migrations.AlterField( + model_name='ontologytag', + name='type_id', + field=models.IntegerField(choices=[('CLINICAL_DISEASE', 0), ('CLINICAL_DOMAIN', 1), ('CLINICAL_FUNCTIONAL_ANATOMY', 2), ('MESH_CODES', 3)]), + ), + migrations.CreateModel( + name='CCI_CODES', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('code', models.CharField(blank=True, max_length=64, null=True)), + ('description', models.CharField(blank=True, max_length=255, null=True)), + ('location', models.CharField(blank=True, max_length=255, null=True)), + ('extent', models.CharField(blank=True, max_length=255, null=True)), + ('status', models.CharField(blank=True, max_length=255, null=True)), + ('created_date', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'indexes': [django.contrib.postgres.indexes.GinIndex(fields=['code'], name='hcci_cd_ln_gin_idx', opclasses=['gin_trgm_ops']), django.contrib.postgres.indexes.GinIndex(fields=['description'], name='hcci_lds_ln_gin_idx', opclasses=['gin_trgm_ops'])], + }, + ), + migrations.AddField( + model_name='hdrndataasset', + name='regions', + field=models.ManyToManyField(blank=True, related_name='data_assets', to='clinicalcode.hdrnjurisdiction'), + ), + migrations.CreateModel( + name='PHYSICIAN_FEE_CODES', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('code', models.CharField(blank=True, max_length=64, null=True)), + ('description', models.CharField(blank=True, max_length=255, null=True)), + ('created_date', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'indexes': [django.contrib.postgres.indexes.GinIndex(fields=['code'], name='hpfc_cd_ln_gin_idx', opclasses=['gin_trgm_ops']), django.contrib.postgres.indexes.GinIndex(fields=['description'], name='hpfc_lds_ln_gin_idx', opclasses=['gin_trgm_ops'])], + }, + ), + ] diff --git a/CodeListLibrary_project/clinicalcode/migrations/0131_alter_genericentity_group_access_and_more.py b/CodeListLibrary_project/clinicalcode/migrations/0131_alter_genericentity_group_access_and_more.py new file mode 100644 index 000000000..24e6dcdba --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/migrations/0131_alter_genericentity_group_access_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 5.1.8 on 2025-06-16 09:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('clinicalcode', '0130_hdrnjurisdiction_remove_hdrndataasset_region_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='genericentity', + name='group_access', + field=models.IntegerField(choices=[(1, 'NONE'), (2, 'VIEW'), (3, 'EDIT')], default=1), + ), + migrations.AlterField( + model_name='genericentity', + name='owner_access', + field=models.IntegerField(blank=True, choices=[(1, 'NONE'), (2, 'VIEW'), (3, 'EDIT')], default=3, null=True), + ), + migrations.AlterField( + model_name='genericentity', + name='world_access', + field=models.IntegerField(blank=True, choices=[(1, 'NONE'), (2, 'VIEW')], default=1, null=True), + ), + migrations.AlterField( + model_name='historicalgenericentity', + name='group_access', + field=models.IntegerField(choices=[(1, 'NONE'), (2, 'VIEW'), (3, 'EDIT')], default=1), + ), + migrations.AlterField( + model_name='historicalgenericentity', + name='owner_access', + field=models.IntegerField(blank=True, choices=[(1, 'NONE'), (2, 'VIEW'), (3, 'EDIT')], default=3, null=True), + ), + migrations.AlterField( + model_name='historicalgenericentity', + name='world_access', + field=models.IntegerField(blank=True, choices=[(1, 'NONE'), (2, 'VIEW')], default=1, null=True), + ), + ] diff --git a/CodeListLibrary_project/clinicalcode/models/Brand.py b/CodeListLibrary_project/clinicalcode/models/Brand.py index 8fa43e08e..f1965dc9e 100644 --- a/CodeListLibrary_project/clinicalcode/models/Brand.py +++ b/CodeListLibrary_project/clinicalcode/models/Brand.py @@ -1,35 +1,340 @@ -from django.contrib.auth.models import Group, User -from django.db.models import JSONField +"""Multi-site branded domain targets.""" + from django.db import models -from simple_history.models import HistoricalRecords +from django.core.cache import cache +from django.utils.translation import gettext_lazy as _ from django.contrib.postgres.fields import ArrayField +from django.contrib.auth import get_user_model from .TimeStampedModel import TimeStampedModel +from clinicalcode.entity_utils import constants + +User = get_user_model() class Brand(TimeStampedModel): + """Domain Brand specifying site appearance and behaviour variation.""" + + '''Fields''' id = models.AutoField(primary_key=True) + + # Brand metadata name = models.CharField(max_length=250, unique=True) description = models.TextField(blank=True, null=True) - logo_path = models.CharField(max_length=250) - index_path = models.CharField(max_length=250, blank=True, null=True) - css_path = models.CharField(max_length=250, blank=True, null=True) website = models.URLField(max_length=1000, blank=True, null=True) - owner = models.ForeignKey(User, - on_delete=models.SET_NULL, - null=True, - related_name="brand_owner") - groups = models.ManyToManyField(Group, related_name="brand_groups") + # Brand site modifiers + # - title: modifies ``-related content + # - description: modifies the `<meta name="description"/>` content + # site_title = models.CharField(max_length=50, blank=True, null=True) - about_menu = JSONField(blank=True, null=True) - allowed_tabs = JSONField(blank=True, null=True) - footer_images = JSONField(blank=True, null=True) + site_description = models.CharField(max_length=160, blank=True, null=True) + + # Brand page & logo appearance + logo_path = models.CharField(max_length=250) + index_path = models.CharField(max_length=250, blank=True, null=True) + + # Brand administration + admins = models.ManyToManyField(User, related_name='administered_brands', blank=True) + users = models.ManyToManyField(User, related_name='accessible_brands', blank=True) + + # Brand overrides + # - e.g. entity name override ('Concept' instead of 'Phenotype' _etc_ for HDRN brand) + overrides = models.JSONField(blank=True, null=True) + + # Brand organisation controls + is_administrable = models.BooleanField(default=False) + org_user_managed = models.BooleanField(default=False) + + # Brand menu targets + about_menu = models.JSONField(blank=True, null=True) + allowed_tabs = models.JSONField(blank=True, null=True) + footer_images = models.JSONField(blank=True, null=True) collections_excluded_from_filters = ArrayField(models.IntegerField(), blank=True, null=True) - history = HistoricalRecords() + '''Static methods''' + @staticmethod + def get_verbose_names(*args, **kwargs): + return { 'verbose_name': Brand._meta.verbose_name, 'verbose_name_plural': Brand._meta.verbose_name_plural } + + @staticmethod + def all_instances(cached=True): + """ + Gets all Brand instances from this model + + Args: + cached (bool): optionally specify whether to retrieve the cached instances; defaults to `True` + + Returns: + A (QuerySet) containing all Brands + """ + if not cached: + return Brand.objects.all() + + all_brands = cache.get('brands_all__cache') + if all_brands is None: + all_brands = Brand.objects.all() + cache.set('brands_all__cache', all_brands, 3600) + return all_brands + + @staticmethod + def all_names(cached=True): + """ + Gets a list of all Brand name targets + + Args: + cached (bool): optionally specify whether to retrieve the cached name list; defaults to `True` + + Returns: + A (list) containing the names of each Brand instance _assoc._ with this model + """ + if not cached: + return [x.upper() for x in Brand.objects.all().values_list('name', flat=True)] + + named_brands = cache.get('brands_names__cache') + if named_brands is None: + named_brands = [x.upper() for x in Brand.objects.all().values_list('name', flat=True)] + cache.set('brands_names__cache', named_brands, 3600) + return named_brands + + def all_map_rules(cached=True): + """ + Resolves all Brand content mapping rules + + Note: + - Brands that do not specify mapping rules will resolve those specified in `constants.py`; + - Please beware that mapping rules are merged with those defined by `constants.py` + + Args: + cached (bool): optionally specify whether to retrieve the cached resultset; defaults to `True` + + Returns: + A (dict) containing key-value pairs in which the key describes the Brand name, and the value describes the content mapping rules _assoc._ with that Brand - _i.e._ a (Dict[str, str]). + """ + mapping_rules = cache.get('brands_mapping-rules__cache') if cached else None + if mapping_rules is None: + brands = Brand.all_instances(cached=cached) + mapping_rules = [ x.get_map_rules(cached=False) for x in brands ] + mapping_rules = { brand.name: rule for brand, rule in zip(dict(brands, mapping_rules)).items() } + + if cached: + cache.set('brands_mapping-rules__cache', mapping_rules, 3600) + return mapping_rules + + @staticmethod + def all_asset_rules(cached=True): + """ + Resolves all Brand asset rules + + Note: + - Beware that not all Brands are _assoc._ with asset rules rules; + - Brands that do not specify asset rules will not be present in the resulting dict. + + Args: + cached (bool): optionally specify whether to retrieve the cached resultset; defaults to `True` + + Returns: + A (dict) containing key-value pairs in which the key describes the Brand name, and the value describes the asset rules _assoc._ with that Brand - _i.e._ a (list) of (Dict[str, str]). + """ + asset_rules = cache.get('brands_asset-rules__cache') if cached else None + if asset_rules is None: + brands = Brand.all_instances(cached=cached) + asset_rules = [ x.get_asset_rules() for x in brands ] + asset_rules = { brand.name: rule for brand, rule in zip(dict(brands, asset_rules)).items() } + + if cached: + cache.set('brands_asset-rules__cache', asset_rules, 3600) + return asset_rules + + @staticmethod + def all_vis_rules(cached=True): + """ + Resolves all Brand content visibility rules + + Note: + - Beware that not all Brands are _assoc._ with content visibility rules; + - Beware that not all Brands are _assoc._ with asset rules rules; + - Brands that do not specify content visibility rules will not be present in the resulting dict. + - Brands that do not specify asset rules will not be present in the resulting dict. + + Args: + cached (bool): optionally specify whether to retrieve the cached resultset; defaults to `True` + + Returns: + A (dict) containing key-value pairs in which the key describes the Brand name, and the value describes the content visibility rules _assoc._ with that Brand. + """ + vis_rules = cache.get('brands_vis-rules__cache') if cached else None + if vis_rules is None: + brands = Brand.all_instances(cached=cached) + vis_rules = [ x.get_vis_rules() for x in brands ] + vis_rules = { brand.name: rule for brand, rule in zip(dict(brands, vis_rules)).items() } + + if cached: + cache.set('brands_vis-rules__cache', vis_rules, 3600) + return vis_rules + + + '''Instance methods''' + def get_map_rules(self, cached=True, default=constants.DEFAULT_CONTENT_MAPPING): + ''' + Attempts to resolve this Brand's `content_mapping` override attribute + + Note: + A Brand's `content_mapping` should define a (Dict[str, str]) which specifies a key-value translation pair + + Args: + cached (bool): optionally specify whether to retrieve the cached resultset; defaults to `False` + default (Any): optionally specify the default return value if the `content_visibility` attr is undefined; defaults to `constants.DEFAULT_CONTENT_MAPPING` + + Returns: + This Brand's `content_mapping` (Dict[str, str]) rule if applicable, otherwise returns the specified `default` value + ''' + # Handle case where instance has yet to be saved + if self.id is None: + return {} | default if isinstance(default, dict) else None + + cache_key = f'brands_mappings__{self.name}__cache' if cached else None + mapping_rules = cache.get(cache_key) if cached else None + if mapping_rules is not None: + return mapping_rules.get('value') + + mapping_rules = getattr(self, 'overrides') + mapping_rules = mapping_rules.get('content_mapping') if isinstance(mapping_rules, dict) else None + if isinstance(mapping_rules, dict): + mapping_rules = {} | constants.DEFAULT_CONTENT_MAPPING | mapping_rules + else: + mapping_rules = {} | default if isinstance(default, dict) else None + + if cached: + cache.set(cache_key, { 'value': mapping_rules }, 3600) + + return mapping_rules + + def get_asset_rules(self, cached=False, default=None): + ''' + Attempts to resolve this Brand's `asset_rules` override attribute + + Note: + A Brand's `asset_rules` should define a (list) describing a set of (Dict[str, str]) which specifies: + + - `name` → the name of the asset + - `model` → the `apps.model` reference of the asset + - `target` → the name of the `TargetEndpoint` resolver + + Args: + cached (bool): optionally specify whether to retrieve the cached resultset; defaults to `False` + default (Any): optionally specify the default return value if the `content_visibility` attr is undefined; defaults to `None` + + Returns: + This Brand's `asset_rules` (list) rule if applicable, otherwise returns the specified `default` value + ''' + # Handle case where instance has yet to be saved + if self.id is None: + return default + + cache_key = f'brands_assets__{self.name}__cache' if cached else None + asset_rules = cache.get(cache_key) if cached else None + if asset_rules is not None: + return asset_rules.get('value') + + asset_rules = getattr(self, 'overrides') + asset_rules = asset_rules.get('asset_rules') if isinstance(asset_rules, dict) else None + if asset_rules is None: + asset_rules = default + + if cached: + cache.set(cache_key, { 'value': asset_rules }, 3600) + + return asset_rules + + def get_vis_rules(self, cached=False, default=None): + """ + Attempts to resolve this Brand's `content_visibility` override attribute + + Note: + A Brand's `content_visibility` may be one of: <br/> + + a.) A "_falsy_" value, _e.g._ a `None` or `False` value, in which: + - Falsy values specifies that no `content_visibility` rules should be applied; + - _i.e._ that all content should be visible. + + b.) Or, a `Literal` `str` value of either (a) `self` or (b) `allow_null`, such that: + - `self` → only content created for this `Brand` should be visible; + - `allow_null` → the `self` rule but allows all content not associated with a particular `Brand` to be rendered alongside it. + + c.) Or, a `list[int]` describing the Brand IDs that should be visible on this domain alongside itself; + + d.) Or, a `dict[str, Any]` with the following key-value pairs: + - `allow_null` → optionally specifies whether content not associated with a particular `Brand` should be visibile; + - `allowed_brands` → optionally specifies the Brand IDs whose content should also be visible on this domain. + + Args: + cached (bool): optionally specify whether to retrieve the cached resultset; defaults to `False` + default (Any): optionally specify the default return value if the `content_visibility` attr is undefined; defaults to `None` + + Returns: + This Brand's `content_visibility` (dict) rule if applicable, otherwise returns the specified `default` value + """ + # Handle case where instance has yet to be saved + if self.id is None: + return default + + # Build vis rules + cache_key = f'brands_vis-rules__{self.name}__cache' if cached else None + vis_rules = cache.get(cache_key) if cached else None + if vis_rules is not None: + return vis_rules.get('value') + + vis_rules = getattr(self, 'overrides') + vis_rules = vis_rules.get('content_visibility') if isinstance(vis_rules, dict) else None + if isinstance(vis_rules, bool) and vis_rules: + vis_rules = { 'ids': [self.id], 'allow_null': False } + if isinstance(vis_rules, str): + vis_rules = vis_rules.lower() + if vis_rules in ('self', 'allow_null'): + vis_rules = { + 'ids': [self.id], + 'allow_null': vis_rules == 'allow_null' + } + else: + vis_rules = default + elif isinstance(vis_rules, list): + if self.id not in vis_rules: + vis_rules.insert(0, self.id) + + vis_rules = { 'ids': [x for x in vis_rules if isinstance(x, int)], 'allow_null': False } + elif isinstance(vis_rules, dict): + allow_null = vis_rules.get('allow_null') + allowed_brands = vis_rules.get('allowed_brands') + if isinstance(allow_null, bool) or isinstance(allowed_brands, list): + allow_null = False if not isinstance(allow_null, bool) else allow_null + allowed_brands = [] if not allowed_brands else allowed_brands + + if self.id not in allowed_brands: + allowed_brands.insert(0, self.id) + + vis_rules = { + 'ids': [x for x in allowed_brands if isinstance(x, int)], + 'allow_null': allow_null + } + else: + vis_rules = default + elif (vis_rules is None or isinstance(vis_rules, bool)) and not vis_rules: + vis_rules = default + + if cached: + cache.set(cache_key, { 'value': vis_rules }, 3600) + + return vis_rules + + + '''Metadata''' class Meta: ordering = ('name', ) + verbose_name = _('Brand') + verbose_name_plural = _('Brands') + + '''Dunder methods''' def __str__(self): return self.name diff --git a/CodeListLibrary_project/clinicalcode/models/CCI_CODES.py b/CodeListLibrary_project/clinicalcode/models/CCI_CODES.py new file mode 100644 index 000000000..88580f1fc --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/models/CCI_CODES.py @@ -0,0 +1,25 @@ +from django.db import models +from django.contrib.postgres.indexes import GinIndex + +class CCI_CODES(models.Model): + id = models.BigAutoField(auto_created=True, primary_key=True) + code = models.CharField(max_length=64, null=True, blank=True) + description = models.CharField(max_length=255, null=True, blank=True) + location = models.CharField(max_length=255, null=True, blank=True) + extent = models.CharField(max_length=255, null=True, blank=True) + status = models.CharField(max_length=255, null=True, blank=True) + created_date = models.DateTimeField(auto_now_add=True) + + class Meta: + indexes = [ + GinIndex( + name='hcci_cd_ln_gin_idx', + fields=['code'], + opclasses=['gin_trgm_ops'] + ), + GinIndex( + name='hcci_lds_ln_gin_idx', + fields=['description'], + opclasses=['gin_trgm_ops'] + ), + ] diff --git a/CodeListLibrary_project/clinicalcode/models/Component.py b/CodeListLibrary_project/clinicalcode/models/Component.py index bc677c67d..99ae3c39e 100644 --- a/CodeListLibrary_project/clinicalcode/models/Component.py +++ b/CodeListLibrary_project/clinicalcode/models/Component.py @@ -7,12 +7,14 @@ ''' from django.db import models -from django.contrib.auth.models import User from simple_history.models import HistoricalRecords +from django.contrib.auth import get_user_model from .Concept import Concept, HistoricalConcept from .TimeStampedModel import TimeStampedModel +User = get_user_model() + class Component(TimeStampedModel): ''' Component of a concept. diff --git a/CodeListLibrary_project/clinicalcode/models/Concept.py b/CodeListLibrary_project/clinicalcode/models/Concept.py index 967a2cc6c..548caec92 100644 --- a/CodeListLibrary_project/clinicalcode/models/Concept.py +++ b/CodeListLibrary_project/clinicalcode/models/Concept.py @@ -3,16 +3,19 @@ A Concept contains a list of Components specified by inclusion/exclusion. ''' -from django.contrib.auth.models import Group, User +from django.contrib.auth.models import Group from django.contrib.postgres.fields import ArrayField from django.db import models from django.template.defaultfilters import default from simple_history.models import HistoricalRecords +from django.contrib.auth import get_user_model from .CodingSystem import CodingSystem from .TimeStampedModel import TimeStampedModel from ..entity_utils import constants +User = get_user_model() + class Concept(TimeStampedModel): id = models.AutoField(primary_key=True) name = models.CharField(max_length=250) diff --git a/CodeListLibrary_project/clinicalcode/models/ConceptCodeAttribute.py b/CodeListLibrary_project/clinicalcode/models/ConceptCodeAttribute.py index 9f0be88a0..0e41ec801 100644 --- a/CodeListLibrary_project/clinicalcode/models/ConceptCodeAttribute.py +++ b/CodeListLibrary_project/clinicalcode/models/ConceptCodeAttribute.py @@ -6,14 +6,16 @@ --------------------------------------------------------------------------- ''' -from django.contrib.auth.models import User from django.contrib.postgres.fields import ArrayField from django.db import models from simple_history.models import HistoricalRecords +from django.contrib.auth import get_user_model from .Concept import Concept from .TimeStampedModel import TimeStampedModel +User = get_user_model() + class ConceptCodeAttribute(TimeStampedModel): ''' Store attributes of codes in a concept. diff --git a/CodeListLibrary_project/clinicalcode/models/ConceptReviewStatus.py b/CodeListLibrary_project/clinicalcode/models/ConceptReviewStatus.py index 6b6c22485..76a65c693 100644 --- a/CodeListLibrary_project/clinicalcode/models/ConceptReviewStatus.py +++ b/CodeListLibrary_project/clinicalcode/models/ConceptReviewStatus.py @@ -1,6 +1,8 @@ from django.db import models -from django.contrib.auth.models import Group, User from django.contrib.postgres.fields import ArrayField +from django.contrib.auth import get_user_model + +User = get_user_model() class ConceptReviewStatus(models.Model): id = models.AutoField(primary_key=True) diff --git a/CodeListLibrary_project/clinicalcode/models/DataSource.py b/CodeListLibrary_project/clinicalcode/models/DataSource.py index a962097f0..055ff439d 100644 --- a/CodeListLibrary_project/clinicalcode/models/DataSource.py +++ b/CodeListLibrary_project/clinicalcode/models/DataSource.py @@ -1,9 +1,11 @@ -from django.contrib.auth.models import User from django.db import models from simple_history.models import HistoricalRecords +from django.contrib.auth import get_user_model from clinicalcode.models.TimeStampedModel import TimeStampedModel +User = get_user_model() + class DataSource(TimeStampedModel): """ Data Source Model @@ -22,11 +24,10 @@ class DataSource(TimeStampedModel): on_delete=models.SET_NULL, null=True, related_name="data_source_updated") - datasource_id = models.IntegerField(unique=True, null=True) + datasource_id = models.IntegerField(unique=False, null=True) + source = models.CharField(max_length=100, null=True, blank=True) history = HistoricalRecords() - source = models.CharField(max_length=100, null=True, blank=True) - def __str__(self): return self.name diff --git a/CodeListLibrary_project/clinicalcode/models/EntityClass.py b/CodeListLibrary_project/clinicalcode/models/EntityClass.py index f368ddda3..bcaf0c02a 100644 --- a/CodeListLibrary_project/clinicalcode/models/EntityClass.py +++ b/CodeListLibrary_project/clinicalcode/models/EntityClass.py @@ -1,6 +1,8 @@ from django.db import models -from django.contrib.auth.models import User from django.utils.timezone import now +from django.contrib.auth import get_user_model + +User = get_user_model() class EntityClass(models.Model): ''' diff --git a/CodeListLibrary_project/clinicalcode/models/GenericEntity.py b/CodeListLibrary_project/clinicalcode/models/GenericEntity.py index 99d26674c..f856dde90 100644 --- a/CodeListLibrary_project/clinicalcode/models/GenericEntity.py +++ b/CodeListLibrary_project/clinicalcode/models/GenericEntity.py @@ -1,4 +1,4 @@ -from django.contrib.auth.models import Group, User +from django.contrib.auth.models import Group from django.contrib.postgres.fields import ArrayField from django.db.models import JSONField from django.db import models @@ -6,12 +6,16 @@ from django.contrib.postgres.indexes import GinIndex from django.contrib.postgres.search import SearchVectorField from django.core.exceptions import ValidationError +from django.contrib.auth import get_user_model from simple_history.models import HistoricalRecords from .Template import Template from .EntityClass import EntityClass +from .Organisation import Organisation from ..entity_utils import gen_utils, constants +User = get_user_model() + class GenericEntityManager(models.Manager): """ Generic Entity Manager - responsible for transfering previous phenotype records to generic entities @@ -66,12 +70,27 @@ class GenericEntity(models.Model): deleted = models.DateTimeField(null=True, blank=True) deleted_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="entity_deleted") owner = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name="entity_owned") + organisation = models.ForeignKey(Organisation, on_delete=models.SET_NULL, null=True, related_name="entities") + + # TODO: WILL BE DEPRECATED group = models.ForeignKey(Group, on_delete=models.SET_NULL, null=True, blank=True) + owner_access = models.IntegerField( + choices=[(e.value, e.name) for e in constants.OWNER_PERMISSIONS], + default=constants.OWNER_PERMISSIONS.EDIT.value, + null=True, + blank=True + ) + group_access = models.IntegerField( + choices=[(e.value, e.name) for e in constants.GROUP_PERMISSIONS], + default=constants.GROUP_PERMISSIONS.NONE.value + ) + world_access = models.IntegerField( + choices=[(e.value, e.name) for e in constants.WORLD_ACCESS_PERMISSIONS], + default=constants.WORLD_ACCESS_PERMISSIONS.NONE.value, + null=True, + blank=True + ) - owner_access = models.IntegerField(choices=[(e.name, e.value) for e in constants.OWNER_PERMISSIONS], default=constants.OWNER_PERMISSIONS.EDIT) - group_access = models.IntegerField(choices=[(e.name, e.value) for e in constants.GROUP_PERMISSIONS], default=constants.GROUP_PERMISSIONS.NONE) - world_access = models.IntegerField(choices=[(e.name, e.value) for e in constants.WORLD_ACCESS_PERMISSIONS], default=constants.WORLD_ACCESS_PERMISSIONS.NONE) - ''' Publish status ''' publish_status = models.IntegerField(null=True, choices=[(e.name, e.value) for e in constants.APPROVAL_STATUS], default=constants.APPROVAL_STATUS.ANY) diff --git a/CodeListLibrary_project/clinicalcode/models/HDRNDataAsset.py b/CodeListLibrary_project/clinicalcode/models/HDRNDataAsset.py new file mode 100644 index 000000000..2ea40b604 --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/models/HDRNDataAsset.py @@ -0,0 +1,114 @@ +from django.db import models +from django.http import HttpRequest +from django.db.models import Q +from django.core.paginator import EmptyPage, Paginator, Page +from rest_framework.request import Request +from django.utils.translation import gettext_lazy as _ +from django.contrib.postgres.fields import ArrayField +from django.contrib.postgres.indexes import GinIndex + +from clinicalcode.entity_utils import gen_utils, model_utils, constants +from clinicalcode.models.HDRNSite import HDRNSite +from clinicalcode.models.HDRNJurisdiction import HDRNJurisdiction +from clinicalcode.models.TimeStampedModel import TimeStampedModel + +class HDRNDataAsset(TimeStampedModel): + """ + HDRN Inventory Data Assets + """ + # Top-level + id = models.AutoField(primary_key=True) + name = models.CharField(max_length=512, unique=False, null=False, blank=False) + description = models.TextField(null=True, blank=True) + + # Reference + hdrn_id = models.IntegerField(null=True, blank=True) + hdrn_uuid = models.UUIDField(primary_key=False, null=True, blank=True) + + # Metadata + link = models.URLField(max_length=500, blank=True, null=True) + site = models.ForeignKey(HDRNSite, on_delete=models.SET_NULL, null=True, related_name='data_assets') + years = models.CharField(max_length=256, unique=False, null=True, blank=True) + scope = models.TextField(null=True, blank=True) + regions = models.ManyToManyField(HDRNJurisdiction, related_name='data_assets', blank=True) + purpose = models.TextField(null=True, blank=True) + + collection_period = models.TextField(null=True, blank=True) + + data_level = models.CharField(max_length=256, unique=False, null=True, blank=True) + data_categories = ArrayField(models.IntegerField(), blank=True, null=True) # Note: ref to `Tag` + + @staticmethod + def get_verbose_names(*args, **kwargs): + return { 'verbose_name': HDRNDataAsset._meta.verbose_name, 'verbose_name_plural': HDRNDataAsset._meta.verbose_name_plural } + + @staticmethod + def get_brand_records_by_request(request, params=None): + brand = model_utils.try_get_brand(request) + if brand is None or brand.name == 'HDRN': + records = HDRNDataAsset.objects.all() + + if records is None: + return HDRNDataAsset.objects.none() + + if not isinstance(params, dict): + params = { } + + if isinstance(request, Request) and hasattr(request, 'query_params'): + params = { key: value for key, value in request.query_params.items() } | params + elif isinstance(request, HttpRequest) and hasattr(request, 'GET'): + params = { key: value for key, value in request.GET.dict().items() } | params + + search = params.pop('search', None) + query = gen_utils.parse_model_field_query(HDRNDataAsset, params, ignored_fields=['description']) + if query is not None: + records = records.filter(**query) + + if not gen_utils.is_empty_string(search) and len(search) >= 3: + records = records.filter(Q(name__icontains=search) | Q(description__icontains=search)) + + records = records.order_by('id') + return records + + @staticmethod + def get_brand_paginated_records_by_request(request, params=None): + if not isinstance(params, dict): + params = { } + + if isinstance(request, Request) and hasattr(request, 'query_params'): + params = { key: value for key, value in request.query_params.items() } | params + elif isinstance(request, HttpRequest) and hasattr(request, 'GET'): + params = { key: value for key, value in request.GET.dict().items() } | params + + records = HDRNDataAsset.get_brand_records_by_request(request, params) + + page = gen_utils.try_value_as_type(params.get('page'), 'int', default=1) + page = max(page, 1) + + page_size = params.get('page_size', '1') + if page_size not in constants.PAGE_RESULTS_SIZE: + page_size = constants.PAGE_RESULTS_SIZE.get('1') + else: + page_size = constants.PAGE_RESULTS_SIZE.get(page_size) + + if records is None: + return Page(HDRNDataAsset.objects.none(), 0, Paginator([], page_size, allow_empty_first_page=True)) + + pagination = Paginator(records, page_size, allow_empty_first_page=True) + try: + page_obj = pagination.page(page) + except EmptyPage: + page_obj = pagination.page(pagination.num_pages) + return page_obj + + class Meta: + ordering = ('name', ) + indexes = [ + GinIndex(name='hdrn_danm_trgm_idx', fields=['name'], opclasses=['gin_trgm_ops']), + GinIndex(name='hdrn_dadc_arr_idx', fields=['data_categories'], opclasses=['array_ops']), + ] + verbose_name = _('HDRN Data Asset') + verbose_name_plural = _('HDRN Data Assets') + + def __str__(self): + return self.name diff --git a/CodeListLibrary_project/clinicalcode/models/HDRNDataCategory.py b/CodeListLibrary_project/clinicalcode/models/HDRNDataCategory.py new file mode 100644 index 000000000..06b046dc4 --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/models/HDRNDataCategory.py @@ -0,0 +1,92 @@ +from django.db import models +from django.http import HttpRequest +from django.db.models import Q +from django.core.paginator import EmptyPage, Paginator, Page +from rest_framework.request import Request +from django.utils.translation import gettext_lazy as _ +from django.contrib.postgres.indexes import GinIndex + +from clinicalcode.entity_utils import gen_utils, model_utils, constants +from clinicalcode.models.TimeStampedModel import TimeStampedModel + +class HDRNDataCategory(TimeStampedModel): + """ + HDRN Data Categories (e.g. Phenotype Type) + """ + id = models.AutoField(primary_key=True) + name = models.CharField(max_length=512, unique=False, null=False, blank=False) + description = models.TextField(null=True, blank=True) + metadata = models.JSONField(blank=True, null=True) + + @staticmethod + def get_verbose_names(*args, **kwargs): + return { 'verbose_name': HDRNDataCategory._meta.verbose_name, 'verbose_name_plural': HDRNDataCategory._meta.verbose_name_plural } + + @staticmethod + def get_brand_records_by_request(request, params=None): + brand = model_utils.try_get_brand(request) + if brand is None or brand.name == 'HDRN': + records = HDRNDataCategory.objects.all() + + if records is None: + return HDRNDataCategory.objects.none() + + if not isinstance(params, dict): + params = { } + + if isinstance(request, Request) and hasattr(request, 'query_params'): + params = { key: value for key, value in request.query_params.items() } | params + elif isinstance(request, HttpRequest) and hasattr(request, 'GET'): + params = { key: value for key, value in request.GET.dict().items() } | params + + search = params.pop('search', None) + query = gen_utils.parse_model_field_query(HDRNDataCategory, params, ignored_fields=['description']) + if query is not None: + records = records.filter(**query) + + if not gen_utils.is_empty_string(search) and len(search) >= 3: + records = records.filter(Q(name__icontains=search) | Q(description__icontains=search)) + + records = records.order_by('id') + return records + + @staticmethod + def get_brand_paginated_records_by_request(request, params=None): + if not isinstance(params, dict): + params = { } + + if isinstance(request, Request) and hasattr(request, 'query_params'): + params = { key: value for key, value in request.query_params.items() } | params + elif isinstance(request, HttpRequest) and hasattr(request, 'GET'): + params = { key: value for key, value in request.GET.dict().items() } | params + + records = HDRNDataCategory.get_brand_records_by_request(request, params) + + page = gen_utils.try_value_as_type(params.get('page'), 'int', default=1) + page = max(page, 1) + + page_size = params.get('page_size', '1') + if page_size not in constants.PAGE_RESULTS_SIZE: + page_size = constants.PAGE_RESULTS_SIZE.get('1') + else: + page_size = constants.PAGE_RESULTS_SIZE.get(page_size) + + if records is None: + return Page(HDRNDataCategory.objects.none(), 0, Paginator([], page_size, allow_empty_first_page=True)) + + pagination = Paginator(records, page_size, allow_empty_first_page=True) + try: + page_obj = pagination.page(page) + except EmptyPage: + page_obj = pagination.page(pagination.num_pages) + return page_obj + + class Meta: + indexes = [ + GinIndex(name='hdrn_dcnm_trgm_idx', fields=['name'], opclasses=['gin_trgm_ops']), + ] + verbose_name = _('HDRN Data Category') + verbose_name_plural = _('HDRN Data Categories') + + def __str__(self): + return self.name diff --git a/CodeListLibrary_project/clinicalcode/models/HDRNJurisdiction.py b/CodeListLibrary_project/clinicalcode/models/HDRNJurisdiction.py new file mode 100644 index 000000000..4bf25e075 --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/models/HDRNJurisdiction.py @@ -0,0 +1,89 @@ +from django.db import models +from django.http import HttpRequest +from django.db.models import Q +from django.core.paginator import EmptyPage, Paginator, Page +from rest_framework.request import Request +from django.utils.translation import gettext_lazy as _ + +from clinicalcode.entity_utils import gen_utils, model_utils, constants +from clinicalcode.models.TimeStampedModel import TimeStampedModel + +class HDRNJurisdiction(TimeStampedModel): + """HDRN Jurisdictions: Provinces & Territories of Canada""" + id = models.AutoField(primary_key=True) + name = models.CharField(max_length=512, unique=True, null=False, blank=False) + abbreviation = models.CharField(max_length=256, null=True, blank=True) + description = models.TextField(null=True, blank=True) + metadata = models.JSONField(blank=True, null=True) + + @staticmethod + def get_verbose_names(*args, **kwargs): + return { 'verbose_name': HDRNJurisdiction._meta.verbose_name, 'verbose_name_plural': HDRNJurisdiction._meta.verbose_name_plural } + + @staticmethod + def get_brand_records_by_request(request, params=None): + brand = model_utils.try_get_brand(request) + if brand is None or brand.name == 'HDRN': + records = HDRNJurisdiction.objects.all() + + if records is None: + return HDRNJurisdiction.objects.none() + + if not isinstance(params, dict): + params = { } + + if isinstance(request, Request) and hasattr(request, 'query_params'): + params = { key: value for key, value in request.query_params.items() } | params + elif isinstance(request, HttpRequest) and hasattr(request, 'GET'): + params = { key: value for key, value in request.GET.dict().items() } | params + + search = params.pop('search', None) + query = gen_utils.parse_model_field_query(HDRNJurisdiction, params, ignored_fields=['description']) + if query is not None: + records = records.filter(**query) + + if not gen_utils.is_empty_string(search) and len(search) >= 3: + records = records.filter(Q(name__icontains=search) | Q(description__icontains=search)) + + records = records.order_by('id') + return records + + @staticmethod + def get_brand_paginated_records_by_request(request, params=None): + if not isinstance(params, dict): + params = { } + + if isinstance(request, Request) and hasattr(request, 'query_params'): + params = { key: value for key, value in request.query_params.items() } | params + elif isinstance(request, HttpRequest) and hasattr(request, 'GET'): + params = { key: value for key, value in request.GET.dict().items() } | params + + records = HDRNJurisdiction.get_brand_records_by_request(request, params) + + page = gen_utils.try_value_as_type(params.get('page'), 'int', default=1) + page = max(page, 1) + + page_size = params.get('page_size', '1') + if page_size not in constants.PAGE_RESULTS_SIZE: + page_size = constants.PAGE_RESULTS_SIZE.get('1') + else: + page_size = constants.PAGE_RESULTS_SIZE.get(page_size) + + if records is None: + return Page(HDRNJurisdiction.objects.none(), 0, Paginator([], page_size, allow_empty_first_page=True)) + + pagination = Paginator(records, page_size, allow_empty_first_page=True) + try: + page_obj = pagination.page(page) + except EmptyPage: + page_obj = pagination.page(pagination.num_pages) + return page_obj + + class Meta: + verbose_name = _('HDRN Jurisdiction') + verbose_name_plural = _('HDRN Jurisdictions') + + def __str__(self): + if isinstance(self.abbreviation, str) and not gen_utils.is_empty_string(self.abbreviation): + return f'{self.name} ({self.abbreviation})' + return self.name diff --git a/CodeListLibrary_project/clinicalcode/models/HDRNSite.py b/CodeListLibrary_project/clinicalcode/models/HDRNSite.py new file mode 100644 index 000000000..2d4a5bbeb --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/models/HDRNSite.py @@ -0,0 +1,91 @@ +from django.db import models +from django.http import HttpRequest +from django.db.models import Q +from django.core.paginator import EmptyPage, Paginator, Page +from rest_framework.request import Request +from django.utils.translation import gettext_lazy as _ + +from clinicalcode.entity_utils import gen_utils, model_utils, constants +from clinicalcode.models.TimeStampedModel import TimeStampedModel + +class HDRNSite(TimeStampedModel): + """ + HDRN Institution Sites + """ + id = models.AutoField(primary_key=True) + name = models.CharField(max_length=512, unique=True, null=False, blank=False) + abbreviation = models.CharField(max_length=256, null=True, blank=True) + description = models.TextField(null=True, blank=True) + metadata = models.JSONField(blank=True, null=True) + + @staticmethod + def get_verbose_names(*args, **kwargs): + return { 'verbose_name': HDRNSite._meta.verbose_name, 'verbose_name_plural': HDRNSite._meta.verbose_name_plural } + + @staticmethod + def get_brand_records_by_request(request, params=None): + brand = model_utils.try_get_brand(request) + if brand is None or brand.name == 'HDRN': + records = HDRNSite.objects.all() + + if records is None: + return HDRNSite.objects.none() + + if not isinstance(params, dict): + params = { } + + if isinstance(request, Request) and hasattr(request, 'query_params'): + params = { key: value for key, value in request.query_params.items() } | params + elif isinstance(request, HttpRequest) and hasattr(request, 'GET'): + params = { key: value for key, value in request.GET.dict().items() } | params + + search = params.pop('search', None) + query = gen_utils.parse_model_field_query(HDRNSite, params, ignored_fields=['description']) + if query is not None: + records = records.filter(**query) + + if not gen_utils.is_empty_string(search) and len(search) >= 3: + records = records.filter(Q(name__icontains=search) | Q(description__icontains=search)) + + records = records.order_by('id') + return records + + @staticmethod + def get_brand_paginated_records_by_request(request, params=None): + if not isinstance(params, dict): + params = { } + + if isinstance(request, Request) and hasattr(request, 'query_params'): + params = { key: value for key, value in request.query_params.items() } | params + elif isinstance(request, HttpRequest) and hasattr(request, 'GET'): + params = { key: value for key, value in request.GET.dict().items() } | params + + records = HDRNSite.get_brand_records_by_request(request, params) + + page = gen_utils.try_value_as_type(params.get('page'), 'int', default=1) + page = max(page, 1) + + page_size = params.get('page_size', '1') + if page_size not in constants.PAGE_RESULTS_SIZE: + page_size = constants.PAGE_RESULTS_SIZE.get('1') + else: + page_size = constants.PAGE_RESULTS_SIZE.get(page_size) + + if records is None: + return Page(HDRNSite.objects.none(), 0, Paginator([], page_size, allow_empty_first_page=True)) + + pagination = Paginator(records, page_size, allow_empty_first_page=True) + try: + page_obj = pagination.page(page) + except EmptyPage: + page_obj = pagination.page(pagination.num_pages) + return page_obj + + class Meta: + verbose_name = _('HDRN Site') + verbose_name_plural = _('HDRN Sites') + + def __str__(self): + if isinstance(self.abbreviation, str) and not gen_utils.is_empty_string(self.abbreviation): + return self.abbreviation + return self.name diff --git a/CodeListLibrary_project/clinicalcode/models/OntologyTag.py b/CodeListLibrary_project/clinicalcode/models/OntologyTag.py index 7104d938e..62b89b33a 100644 --- a/CodeListLibrary_project/clinicalcode/models/OntologyTag.py +++ b/CodeListLibrary_project/clinicalcode/models/OntologyTag.py @@ -1,7 +1,6 @@ from django.apps import apps from django.db import models, transaction, connection -from django.db.models import F, Count, Max, Case, When, Exists, OuterRef -from django.db.models.query import QuerySet +from django.db.models import F, Count, Case, When, Exists, OuterRef from django.db.models.functions import JSONObject from django.contrib.postgres.search import SearchVectorField from django.contrib.postgres.indexes import GinIndex @@ -1241,15 +1240,15 @@ def query_typeahead(cls, searchterm = '', type_ids=None, result_limit = TYPEAHEA node.name, node.type_id, node.properties, - ts_rank_cd(node.search_vector, websearch_to_tsquery('pg_catalog.english', %(searchterm)s)) as score + ts_rank_cd(node.search_vector, to_tsquery('pg_catalog.english', replace(to_tsquery('pg_catalog.english', concat(regexp_replace(trim(%(searchterm)s), '\W+', ':* & ', 'gm'), ':*'))::text, '<->', '|'))) 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 || ':*', '<->', '|')) + @@ to_tsquery('pg_catalog.english', replace(to_tsquery('pg_catalog.english', concat(regexp_replace(trim(%(searchterm)s), '\W+', ':* & ', 'gm'), ':*'))::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 || ':*', '<->', '|'))) + (relation_vector @@ to_tsquery('pg_catalog.english', replace(to_tsquery('pg_catalog.english', concat(regexp_replace(trim(%(searchterm)s), '\W+', ':* & ', 'gm'), ':*'))::text, '<->', '|'))) + or (synonyms_vector @@ to_tsquery('pg_catalog.english', replace(to_tsquery('pg_catalog.english', concat(regexp_replace(trim(%(searchterm)s), '\W+', ':* & ', 'gm'), ':*'))::text, '<->', '|'))) ) ) ''') diff --git a/CodeListLibrary_project/clinicalcode/models/Organisation.py b/CodeListLibrary_project/clinicalcode/models/Organisation.py new file mode 100644 index 000000000..ef629c431 --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/models/Organisation.py @@ -0,0 +1,227 @@ +from django.db import models +from django.http import HttpRequest +from django.urls import reverse +from django.utils import timezone +from django.utils.text import slugify +from django.contrib.auth import get_user_model +from django.core.paginator import Page, EmptyPage, Paginator +from django.db.models.query import QuerySet +from rest_framework.request import Request + +import uuid +import datetime + +from .Brand import Brand +from ..entity_utils import constants, model_utils, gen_utils + +User = get_user_model() + +class Organisation(models.Model): + """ + + """ + id = models.AutoField(primary_key=True) + slug = models.SlugField(db_index=True, unique=True) + name = models.CharField(max_length=250, unique=True) + description = models.TextField(blank=True, max_length=1000) + email = models.EmailField(null=True, blank=True) + website = models.URLField(blank=True, null=True) + + owner = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + default=None, + related_name='owned_organisations' + ) + members = models.ManyToManyField( + User, + through='OrganisationMembership', + related_name='organisations' + ) + brands = models.ManyToManyField( + Brand, + through='OrganisationAuthority', + related_name='organisations' + ) + + created = models.DateTimeField(default=timezone.now) + + def save(self, *args, **kwargs): + self.slug = slugify(self.name) + super(Organisation, self).save(*args, **kwargs) + + def get_view_absolute_url(self): + return reverse( + 'view_organisation', + kwargs={'slug': self.slug} + ) + + def get_edit_absolute_url(self): + return reverse( + 'edit_organisation', + kwargs={'slug': self.slug} + ) + + def serialise_api(self): + if self.id is None: + return None + + return { + 'id': self.id, + 'slug': self.slug, + 'name': self.name + } + + def __str__(self): + return f'name={self.name}, slug={self.slug}' + +class OrganisationMembership(models.Model): + """ + + """ + id = models.AutoField(primary_key=True) + user = models.ForeignKey( + User, on_delete=models.CASCADE + ) + organisation = models.ForeignKey( + Organisation, on_delete=models.CASCADE + ) + + role = models.IntegerField( + choices=[(e.value, e.name) for e in constants.ORGANISATION_ROLES], + default=constants.ORGANISATION_ROLES.MEMBER.value + ) + + joined = models.DateTimeField(default=timezone.now, editable=False) + + def __str__(self): + return f'user: {self.user}, org: {self.organisation}' + + @staticmethod + def get_brand_records_by_request(request, params=None): + # Step 1: Get the Brand from the request + brand = model_utils.try_get_brand(request) + + members = None + if brand is None: + # If no specific brand, return all organisation members + members = OrganisationMembership.objects.all() + elif isinstance(brand, Brand): + # Get organisations that have this brand associated through OrganisationAuthority + + members = OrganisationMembership.objects.filter(organisation__brands__id=brand.id) + + # If no members were found, return an empty queryset + if members is None: + return OrganisationMembership.objects.none() + + # Handle the query params and search logic + if not isinstance(params, dict): + params = {} + + if isinstance(request, Request) and hasattr(request, 'query_params'): + params = {key: value for key, value in request.query_params.items()} | params + elif isinstance(request, HttpRequest) and hasattr(request, 'GET'): + params = {key: value for key, value in request.GET.dict().items()} | params + + # Apply additional filters based on the params (e.g., search) + query = gen_utils.parse_model_field_query(OrganisationMembership, params) + if query is not None: + members = members.filter(**query) + + # Step 5: Order by id + members = members.order_by('id') + + return members + + @staticmethod + def get_brand_paginated_records_by_request(request, params=None): + if not isinstance(params, dict): + params = {} + + if isinstance(request, Request) and hasattr(request, 'query_params'): + params = {key: value for key, value in request.query_params.items()} | params + elif isinstance(request, HttpRequest) and hasattr(request, 'GET'): + params = {key: value for key, value in request.GET.dict().items()} | params + + records = OrganisationMembership.get_brand_records_by_request(request, params) + + page = params.get('page', 1) + page = max(page, 1) + + page_size = params.get('page_size', '1') + if page_size not in constants.PAGE_RESULTS_SIZE: + page_size = constants.PAGE_RESULTS_SIZE.get('1') + else: + page_size = constants.PAGE_RESULTS_SIZE.get(page_size) + + if records is None: + return Page(QuerySet(), 0, Paginator([], page_size, allow_empty_first_page=True)) + + pagination = Paginator(records, page_size, allow_empty_first_page=True) + try: + page_obj = pagination.page(page) + except EmptyPage: + page_obj = pagination.page(pagination.num_pages) + return page_obj + +class OrganisationAuthority(models.Model): + """ + + """ + id = models.AutoField(primary_key=True) + brand = models.ForeignKey( + Brand, on_delete=models.CASCADE + ) + organisation = models.ForeignKey( + Organisation, on_delete=models.CASCADE + ) + + can_post = models.BooleanField(default=False) + can_moderate = models.BooleanField(default=False) + + def __str__(self): + return f'brand={self.brand}, org={self.organisation}' + +class OrganisationInvite(models.Model): + """ + + """ + id = models.UUIDField( + primary_key = True, + default = uuid.uuid4, + editable = False + ) + + user = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + default=None, + related_name='organisation_invites' + ) + organisation = models.ForeignKey( + Organisation, + on_delete=models.SET_NULL, + null=True, + default=None, + related_name='invites' + ) + + outcome = models.IntegerField( + choices=[(e.value, e.name) for e in constants.ORGANISATION_INVITE_STATUS], + default=constants.ORGANISATION_INVITE_STATUS.ACTIVE.value + ) + sent = models.BooleanField(default=False) + created = models.DateTimeField(default=timezone.now, editable=False) + + def is_expired(self): + date_expired = self.created + datetime.timedelta(days=constants.INVITE_TIMEOUT) + return date_expired <= timezone.now() + + def is_sent(self): + return self.sent + + def __str__(self): + return f'user={self.user}, org={self.organisation}' diff --git a/CodeListLibrary_project/clinicalcode/models/PHYSICIAN_FEE_CODES.py b/CodeListLibrary_project/clinicalcode/models/PHYSICIAN_FEE_CODES.py new file mode 100644 index 000000000..ce7087966 --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/models/PHYSICIAN_FEE_CODES.py @@ -0,0 +1,22 @@ +from django.db import models +from django.contrib.postgres.indexes import GinIndex + +class PHYSICIAN_FEE_CODES(models.Model): + id = models.BigAutoField(auto_created=True, primary_key=True) + code = models.CharField(max_length=64, null=True, blank=True) + description = models.CharField(max_length=255, null=True, blank=True) + created_date = models.DateTimeField(auto_now_add=True) + + class Meta: + indexes = [ + GinIndex( + name='hpfc_cd_ln_gin_idx', + fields=['code'], + opclasses=['gin_trgm_ops'] + ), + GinIndex( + name='hpfc_lds_ln_gin_idx', + fields=['description'], + opclasses=['gin_trgm_ops'] + ), + ] diff --git a/CodeListLibrary_project/clinicalcode/models/Phenotype.py b/CodeListLibrary_project/clinicalcode/models/Phenotype.py index 8b1bfd60a..22b1d549c 100644 --- a/CodeListLibrary_project/clinicalcode/models/Phenotype.py +++ b/CodeListLibrary_project/clinicalcode/models/Phenotype.py @@ -1,13 +1,16 @@ import datetime -from django.contrib.auth.models import Group, User +from django.contrib.auth.models import Group from django.contrib.postgres.fields import ArrayField from django.db.models import JSONField from django.db import models from simple_history.models import HistoricalRecords +from django.contrib.auth import get_user_model from .TimeStampedModel import TimeStampedModel from ..entity_utils import constants +User = get_user_model() + class Phenotype(TimeStampedModel): """ Phenotype Model diff --git a/CodeListLibrary_project/clinicalcode/models/PhenotypeWorkingset.py b/CodeListLibrary_project/clinicalcode/models/PhenotypeWorkingset.py index 56d8b4fd4..c23081984 100644 --- a/CodeListLibrary_project/clinicalcode/models/PhenotypeWorkingset.py +++ b/CodeListLibrary_project/clinicalcode/models/PhenotypeWorkingset.py @@ -3,15 +3,18 @@ A working set is a list of columns from a number of Concepts and Phenotypes ''' -from django.contrib.auth.models import Group, User +from django.contrib.auth.models import Group from django.contrib.postgres.fields import ArrayField from django.db.models import JSONField from django.db import models from simple_history.models import HistoricalRecords +from django.contrib.auth import get_user_model from .TimeStampedModel import TimeStampedModel from ..entity_utils import constants +User = get_user_model() + class PhenotypeWorkingset(TimeStampedModel): id = models.CharField(primary_key=True, editable=False, default=None,max_length=50) name = models.CharField(max_length=250) diff --git a/CodeListLibrary_project/clinicalcode/models/PublishedConcept.py b/CodeListLibrary_project/clinicalcode/models/PublishedConcept.py index a7eda5585..82695a2ee 100644 --- a/CodeListLibrary_project/clinicalcode/models/PublishedConcept.py +++ b/CodeListLibrary_project/clinicalcode/models/PublishedConcept.py @@ -1,9 +1,12 @@ -from django.contrib.auth.models import User + from django.db import models from simple_history.models import HistoricalRecords +from django.contrib.auth import get_user_model from .Concept import Concept +User = get_user_model() + class PublishedConcept(models.Model): concept = models.ForeignKey(Concept, on_delete=models.CASCADE) concept_history_id = models.IntegerField() diff --git a/CodeListLibrary_project/clinicalcode/models/PublishedGenericEntity.py b/CodeListLibrary_project/clinicalcode/models/PublishedGenericEntity.py index 23aec5edd..3d500572f 100644 --- a/CodeListLibrary_project/clinicalcode/models/PublishedGenericEntity.py +++ b/CodeListLibrary_project/clinicalcode/models/PublishedGenericEntity.py @@ -1,13 +1,15 @@ -from django.contrib.auth.models import User from django.db import models from simple_history.models import HistoricalRecords from django.db import connection, transaction +from django.contrib.auth import get_user_model import enum from .GenericEntity import GenericEntity from ..entity_utils import constants +User = get_user_model() + class PublishedGenericEntity(models.Model): entity = models.ForeignKey(GenericEntity, on_delete=models.CASCADE) entity_history_id = models.IntegerField(null=False) diff --git a/CodeListLibrary_project/clinicalcode/models/PublishedPhenotype.py b/CodeListLibrary_project/clinicalcode/models/PublishedPhenotype.py index fc2831812..4dc9695e4 100644 --- a/CodeListLibrary_project/clinicalcode/models/PublishedPhenotype.py +++ b/CodeListLibrary_project/clinicalcode/models/PublishedPhenotype.py @@ -1,9 +1,11 @@ -from django.contrib.auth.models import User from django.db import models from simple_history.models import HistoricalRecords +from django.contrib.auth import get_user_model from .Phenotype import Phenotype +User = get_user_model() + class PublishedPhenotype(models.Model): phenotype = models.ForeignKey(Phenotype, on_delete=models.CASCADE) phenotype_history_id = models.IntegerField(null=False) diff --git a/CodeListLibrary_project/clinicalcode/models/PublishedWorkingset.py b/CodeListLibrary_project/clinicalcode/models/PublishedWorkingset.py index 646c6f270..adccdf7cc 100644 --- a/CodeListLibrary_project/clinicalcode/models/PublishedWorkingset.py +++ b/CodeListLibrary_project/clinicalcode/models/PublishedWorkingset.py @@ -1,9 +1,11 @@ -from django.contrib.auth.models import User from django.db import models from simple_history.models import HistoricalRecords +from django.contrib.auth import get_user_model from .PhenotypeWorkingset import PhenotypeWorkingset +User = get_user_model() + class PublishedWorkingset(models.Model): workingset = models.ForeignKey(PhenotypeWorkingset, on_delete=models.CASCADE) workingset_history_id = models.IntegerField(null=False) diff --git a/CodeListLibrary_project/clinicalcode/models/Statistics.py b/CodeListLibrary_project/clinicalcode/models/Statistics.py index 31e61dd18..59fb92209 100644 --- a/CodeListLibrary_project/clinicalcode/models/Statistics.py +++ b/CodeListLibrary_project/clinicalcode/models/Statistics.py @@ -1,10 +1,12 @@ -from django.contrib.auth.models import User from django.db.models import JSONField from django.db import models from simple_history.models import HistoricalRecords +from django.contrib.auth import get_user_model from clinicalcode.models.TimeStampedModel import TimeStampedModel +User = get_user_model() + class Statistics(TimeStampedModel): org = models.CharField(max_length=50) type = models.CharField(max_length=50) diff --git a/CodeListLibrary_project/clinicalcode/models/Tag.py b/CodeListLibrary_project/clinicalcode/models/Tag.py index cb02fe71f..59e20d429 100644 --- a/CodeListLibrary_project/clinicalcode/models/Tag.py +++ b/CodeListLibrary_project/clinicalcode/models/Tag.py @@ -1,10 +1,17 @@ -from django.contrib.auth.models import User from django.db import models +from django.http import HttpRequest from simple_history.models import HistoricalRecords +from django.core.paginator import EmptyPage, Paginator, Page +from rest_framework.request import Request +from django.utils.translation import gettext_lazy as _ +from django.contrib.auth import get_user_model from clinicalcode.models.Brand import Brand +from clinicalcode.entity_utils import constants, gen_utils, filter_utils, model_utils from clinicalcode.models.TimeStampedModel import TimeStampedModel +User = get_user_model() + class Tag(TimeStampedModel): default = 1 primary = 2 @@ -41,8 +48,149 @@ class Tag(TimeStampedModel): history = HistoricalRecords() + @staticmethod + def get_verbose_names(subtype=None, *args, **kwargs): + if subtype == 'all': + return { 'verbose_name': _('Tags & Collections'), 'verbose_name_plural': _('Tags & Collections') } + + is_str = isinstance(subtype, str) + type_id = gen_utils.parse_int(subtype) if is_str else subtype + + is_valid = isinstance(type_id, int) + if not is_valid and is_str: + subtype = subtype.lower() + if subtype.startswith('tag'): + type_id = 1 + elif subtype.startswith('collection'): + type_id = 2 + else: + type_id = 1 + elif not is_valid: + type_id = 1 + + if type_id == 1 or not (1 <= type_id <= 2): + verbose_name = _('Tag') + verbose_name_plural = _('Tags') + elif type_id == 2: + verbose_name = _('Collection') + verbose_name_plural = _('Collections') + return { 'verbose_name': verbose_name, 'verbose_name_plural': verbose_name_plural } + + @staticmethod + def get_brand_assoc_queryset(brand=None, desired_tag=None): + if isinstance(desired_tag, int) and desired_tag in (Tag.tag, Tag.collection): + tag_type = desired_tag + tag_name = 'tags' if desired_tag == 1 else 'collections' + elif isinstance(desired_tag, str): + if desired_tag.lower() == 'tags': + tag_type = 1 + tag_name = 'tags' + else: + tag_type = 2 + tag_name = 'collections' + else: + tag_type = 1 + tag_name = 'tags' + + records = None + if brand: + source = constants.metadata.get(tag_name, {}) \ + .get('validation', {}) \ + .get('source', {}) \ + .get('filter', {}) \ + .get('source_by_brand', None) + + if source is not None: + result = filter_utils.DataTypeFilters.try_generate_filter( + desired_filter='brand_filter', + expected_params=None, + source_value=source, + column_name='collection_brand', + brand_target=brand + ) + + if isinstance(result, list) and len(result) > 0: + records = Tag.objects.filter(*result) + + if records is None: + records = Tag.objects.filter(collection_brand=brand.id, tag_type=tag_type) + else: + records = Tag.objects.filter(tag_type=tag_type) + + if records is None: + return Tag.objects.none() + return records + + @staticmethod + def get_brand_records_by_request(request, params=None): + brand = model_utils.try_get_brand(request) + + if not isinstance(params, dict): + params = { } + + if isinstance(request, Request) and hasattr(request, 'query_params'): + params = { key: value for key, value in request.query_params.items() } | params + elif isinstance(request, HttpRequest) and hasattr(request, 'GET'): + params = { key: value for key, value in request.GET.dict().items() } | params + + tag_type = params.pop('tag_type', None) + if params.get('all_tags', False) and tag_type is None: + records = ( + Tag.get_brand_assoc_queryset(brand, 'tags') | \ + Tag.get_brand_assoc_queryset(brand, 'collections') + ) + else: + tag_type = tag_type if isinstance(tag_type, int) else 1 + records = Tag.get_brand_assoc_queryset(brand, tag_type) + + if records is None: + return Tag.objects.none() + + search = params.pop('search', None) + query = gen_utils.parse_model_field_query(Tag, params, ignored_fields=['description']) + if query is not None: + records = records.filter(**query) + + if not gen_utils.is_empty_string(search) and len(search) >= 3: + records = records.filter(description__icontains=search) + + records = records.order_by('id') + return records + + @staticmethod + def get_brand_paginated_records_by_request(request, params=None): + if not isinstance(params, dict): + params = { } + + if isinstance(request, Request) and hasattr(request, 'query_params'): + params = { key: value for key, value in request.query_params.items() } | params + elif isinstance(request, HttpRequest) and hasattr(request, 'GET'): + params = { key: value for key, value in request.GET.dict().items() } | params + + records = Tag.get_brand_records_by_request(request, params) + if records is None: + return Page(Tag.objects.none(), 0, Paginator([], page_size, allow_empty_first_page=True)) + + page = gen_utils.try_value_as_type(params.get('page'), 'int', default=1) + page = max(page, 1) + + page_size = params.get('page_size', '1') + if page_size not in constants.PAGE_RESULTS_SIZE: + page_size = constants.PAGE_RESULTS_SIZE.get('1') + else: + page_size = constants.PAGE_RESULTS_SIZE.get(page_size) + + pagination = Paginator(records, page_size, allow_empty_first_page=True) + try: + page_obj = pagination.page(page) + except EmptyPage: + page_obj = pagination.page(pagination.num_pages) + return page_obj + class Meta: ordering = ('description', ) + verbose_name = _('Tag') + verbose_name_plural = _('Tags') def __str__(self): return self.description diff --git a/CodeListLibrary_project/clinicalcode/models/Template.py b/CodeListLibrary_project/clinicalcode/models/Template.py index c3b5513d1..ed81b64e1 100644 --- a/CodeListLibrary_project/clinicalcode/models/Template.py +++ b/CodeListLibrary_project/clinicalcode/models/Template.py @@ -1,13 +1,22 @@ -from django.contrib.auth.models import User -from django.db.models import JSONField from django.db import models +from django.http import HttpRequest +from django.db.models import Q from simple_history.models import HistoricalRecords +from django.core.paginator import EmptyPage, Paginator, Page +from rest_framework.request import Request +from django.utils.translation import gettext_lazy as _ +from django.contrib.postgres.fields import ArrayField +from django.contrib.auth import get_user_model +from .Brand import Brand from .EntityClass import EntityClass from .TimeStampedModel import TimeStampedModel +from clinicalcode.entity_utils import constants, gen_utils, model_utils + +User = get_user_model() class Template(TimeStampedModel): - ''' + """ Template @desc describes the structure of the data for that type of generic entity and holds statistics information e.g. @@ -17,24 +26,109 @@ class Template(TimeStampedModel): also holds information relating to represents the filterable fields as a hasmap to improve performance as well as ensuring order is kept through creating a 'layout_order' field and an 'order' field within the template definition - ''' + """ ''' Metadata ''' id = models.AutoField(primary_key=True) name = models.CharField(max_length=250, unique=True) description = models.TextField(blank=True, null=True) - definition = JSONField(blank=True, null=True, default=dict) - entity_class = models.ForeignKey(EntityClass, on_delete=models.SET_NULL, null=True, related_name="entity_class_type") + definition = models.JSONField(blank=True, null=True, default=dict) + entity_class = models.ForeignKey(EntityClass, on_delete=models.SET_NULL, null=True, related_name='entity_class_type') template_version = models.IntegerField(null=True, editable=False) hide_on_create = models.BooleanField(null=False, default=False) + ''' Brand behaviour ''' + brands = ArrayField(models.IntegerField(), blank=True, null=True) + ''' Instance data ''' - created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name="template_created") - updated_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name="template_updated") + created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name='template_created') + updated_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name='template_updated') history = HistoricalRecords() + ''' Static methods ''' + @staticmethod + def get_verbose_names(*args, **kwargs): + return { 'verbose_name': Template._meta.verbose_name, 'verbose_name_plural': Template._meta.verbose_name_plural } + + @staticmethod + def get_brand_records_by_request(request, params=None): + brand = model_utils.try_get_brand(request) + + records = None + if brand is None: + records = Template.objects.all() + elif isinstance(brand, Brand): + vis_rules = brand.get_vis_rules() + if isinstance(vis_rules, dict): + allow_null = vis_rules.get('allow_null') + allowed_brands = vis_rules.get('ids') + if isinstance(allowed_brands, list) and isinstance(allow_null, bool) and allow_null: + records = Template.objects.filter((Q(brands__isnull=True) | Q(brands__len__lt=1)) | Q(brands__overlap=allowed_brands)) + elif isinstance(allowed_brands, list): + records = Template.objects.filter(brands__overlap=allowed_brands) + elif isinstance(allow_null, bool) and allow_null: + records = Template.objects.filter((Q(brands__isnull=True) | Q(brands__len__lt=1)) | Q(brands__overlap=[brand.id])) + + if records is None: + records = Template.objects.filter(brands__overlap=[brand.id]) + + if records is None: + return Template.objects.none() + + if not isinstance(params, dict): + params = { } + + if isinstance(request, Request) and hasattr(request, 'query_params'): + params = { key: value for key, value in request.query_params.items() } | params + elif isinstance(request, HttpRequest) and hasattr(request, 'GET'): + params = { key: value for key, value in request.GET.dict().items() } | params + + search = params.get('search', None) + query = gen_utils.parse_model_field_query(Template, params, ignored_fields=['description']) + if query is not None: + records = records.filter(**query) + + if not gen_utils.is_empty_string(search) and len(search) >= 3: + records = records.filter(Q(name__icontains=search) | Q(description__icontains=search)) + + records = records.order_by('id') + return records + + @staticmethod + def get_brand_paginated_records_by_request(request, params=None): + if not isinstance(params, dict): + params = { } + + if isinstance(request, Request) and hasattr(request, 'query_params'): + params = { key: value for key, value in request.query_params.items() } | params + elif isinstance(request, HttpRequest) and hasattr(request, 'GET'): + params = { key: value for key, value in request.GET.dict().items() } | params + + records = Template.get_brand_records_by_request(request, params) + + page = gen_utils.try_value_as_type(params.get('page'), 'int', default=1) + page = max(page, 1) + + page_size = params.get('page_size', '1') + if page_size not in constants.PAGE_RESULTS_SIZE: + page_size = constants.PAGE_RESULTS_SIZE.get('1') + else: + page_size = constants.PAGE_RESULTS_SIZE.get(page_size) + + if records is None: + return Page(Template.objects.none(), 0, Paginator([], page_size, allow_empty_first_page=True)) + + pagination = Paginator(records, page_size, allow_empty_first_page=True) + try: + page_obj = pagination.page(page) + except EmptyPage: + page_obj = pagination.page(pagination.num_pages) + return page_obj + + + ''' Public methods ''' def save(self, *args, **kwargs): - ''' + """ [!] Note: 1. The 'template_version' field is computed from JSONB data - it's non-editable. When trying to query templates using historical records, the 'template_version' field can be used safely @@ -42,10 +136,10 @@ def save(self, *args, **kwargs): 2. 'layout_order' fields are added to the template.definition when saving and can be queried using get_ordered_definition within template_utils 3. Entity type/prefix can be queried through template.entity_class - ''' + """ super(Template, self).save(*args, **kwargs) - + def save_without_historical_record(self, *args, **kwargs): self.skip_history_when_saving = True try: @@ -53,9 +147,15 @@ def save_without_historical_record(self, *args, **kwargs): finally: del self.skip_history_when_saving return ret - + + + ''' Meta ''' class Meta: ordering = ('name', ) + verbose_name = _('Template') + verbose_name_plural = _('Templates') + + ''' Dunder methods ''' def __str__(self): return self.name diff --git a/CodeListLibrary_project/clinicalcode/models/WorkingSet.py b/CodeListLibrary_project/clinicalcode/models/WorkingSet.py index 0a8e20dc2..4cd64f0bb 100644 --- a/CodeListLibrary_project/clinicalcode/models/WorkingSet.py +++ b/CodeListLibrary_project/clinicalcode/models/WorkingSet.py @@ -3,14 +3,17 @@ A working set is a list of columns from a number of Concepts. ''' -from django.contrib.auth.models import Group, User +from django.contrib.auth.models import Group from django.db.models import JSONField from django.db import models from simple_history.models import HistoricalRecords +from django.contrib.auth import get_user_model from .TimeStampedModel import TimeStampedModel from ..entity_utils import constants +User = get_user_model() + class WorkingSet(TimeStampedModel): name = models.CharField(max_length=250) author = models.CharField(max_length=250) diff --git a/CodeListLibrary_project/clinicalcode/models/WorkingSetTagMap.py b/CodeListLibrary_project/clinicalcode/models/WorkingSetTagMap.py index 27eab08d1..942147eb6 100644 --- a/CodeListLibrary_project/clinicalcode/models/WorkingSetTagMap.py +++ b/CodeListLibrary_project/clinicalcode/models/WorkingSetTagMap.py @@ -1,11 +1,13 @@ -from django.contrib.auth.models import User from django.db import models from simple_history.models import HistoricalRecords +from django.contrib.auth import get_user_model from .Tag import Tag from .WorkingSet import WorkingSet from .TimeStampedModel import TimeStampedModel +User = get_user_model() + class WorkingSetTagMap(TimeStampedModel): workingset = models.ForeignKey(WorkingSet, on_delete=models.CASCADE) tag = models.ForeignKey(Tag, on_delete=models.CASCADE) diff --git a/CodeListLibrary_project/clinicalcode/models/__init__.py b/CodeListLibrary_project/clinicalcode/models/__init__.py index b29c33a40..73954aced 100644 --- a/CodeListLibrary_project/clinicalcode/models/__init__.py +++ b/CodeListLibrary_project/clinicalcode/models/__init__.py @@ -43,7 +43,15 @@ from .Template import Template from .GenericEntity import GenericEntity from .PublishedGenericEntity import PublishedGenericEntity +from .Organisation import ( + Organisation, + OrganisationMembership, + OrganisationAuthority, + OrganisationInvite +) +from .CCI_CODES import CCI_CODES +from .PHYSICIAN_FEE_CODES import PHYSICIAN_FEE_CODES from .ATCDDD_CODES import ATCDDD_CODES from .ICD10CA_CODES import ICD10CA_CODES from .ICD10CM_CODES import ICD10CM_CODES diff --git a/CodeListLibrary_project/clinicalcode/tasks.py b/CodeListLibrary_project/clinicalcode/tasks.py index 381621a46..66d7f55fd 100644 --- a/CodeListLibrary_project/clinicalcode/tasks.py +++ b/CodeListLibrary_project/clinicalcode/tasks.py @@ -20,7 +20,7 @@ def send_review_email(request,data): @shared_task(bind=True) def send_scheduled_email(self): - email_subject = 'Weekly email Concept Library' + email_subject = 'Weekly Email' email_content = email_utils.get_scheduled_email_to_send() owner_ids = list(set([c['owner_id'] for c in email_content])) diff --git a/CodeListLibrary_project/clinicalcode/templatetags/breadcrumbs.py b/CodeListLibrary_project/clinicalcode/templatetags/breadcrumbs.py index f89e0ed24..46af2881f 100644 --- a/CodeListLibrary_project/clinicalcode/templatetags/breadcrumbs.py +++ b/CodeListLibrary_project/clinicalcode/templatetags/breadcrumbs.py @@ -112,12 +112,16 @@ class BreadcrumbsNode(template.Node): DEFAULT_STR = '<section class="breadcrumbs"></section>' def __init__(self, params, nodelist): - self.request = template.Variable('request') + self.reqvar = template.Variable('request') self.params = params self.nodelist = nodelist def __is_brand_token(self, token): - return Brand.objects.filter(name__iexact=token).exists() + if not isinstance(token, str): + return False + + element = next((x for x in Brand.all_instances() if x.name.lower() == token.lower()), None) + return element is not None def __is_valid_token(self, token): if self.__is_brand_token(token): @@ -184,7 +188,7 @@ def map_resolver(self, path, rqst): return '' def render(self, context): - rqst = self.request.resolve(context) + rqst = self.reqvar.resolve(context) path = rqst.path.split('/') if len(path) > 0: diff --git a/CodeListLibrary_project/clinicalcode/templatetags/cards_dropdown_render.py b/CodeListLibrary_project/clinicalcode/templatetags/cards_dropdown_render.py index 07e1b45fc..3c289a458 100644 --- a/CodeListLibrary_project/clinicalcode/templatetags/cards_dropdown_render.py +++ b/CodeListLibrary_project/clinicalcode/templatetags/cards_dropdown_render.py @@ -4,14 +4,17 @@ register = template.Library() @register.inclusion_tag('components/navigation/dropdown_list_item.html', takes_context=True, name='card_render') -def render_card(context,url_drop, *args, **kwargs): - card_context = {"title": "", "description": "", "icon": "about_icon"} - - card_context["title"] = context['lnk'].get('title','') - card_context["description"] = context['lnk'].get('description','') - card_context["icon"] = context['lnk'].get('svg','') - card_context["url"] = url_drop - if isinstance(context['lnk'].get('page_name', ''), list): - card_context["list_pages"] = context['lnk'].get('page_name','') - - return card_context +def render_card(context, url_drop, *args, **kwargs): + lnk = context.get('lnk', {}) + ctx = { + 'title': lnk.get('title', ''), + 'description': lnk.get('description', ''), + 'icon': lnk.get('svg',''), + 'url': url_drop, + } + + pages = lnk.get('page_name') + if isinstance(pages, list): + ctx |= { 'list_pages': pages } + + return ctx diff --git a/CodeListLibrary_project/clinicalcode/templatetags/cl_extras.py b/CodeListLibrary_project/clinicalcode/templatetags/cl_extras.py index 3b400f1b0..8fac346d8 100644 --- a/CodeListLibrary_project/clinicalcode/templatetags/cl_extras.py +++ b/CodeListLibrary_project/clinicalcode/templatetags/cl_extras.py @@ -7,8 +7,10 @@ from ..entity_utils import gen_utils from ..entity_utils.constants import TypeStatus + register = template.Library() + @register.simple_tag(takes_context=True) def render_og_tags(context, *args, **kwargs): request = context['request'] @@ -20,8 +22,12 @@ def render_og_tags(context, *args, **kwargs): title = settings.APP_TITLE embed = settings.APP_EMBED_ICON.format(logo_path=settings.APP_LOGO_PATH) else: - title = brand.site_title - embed = settings.APP_EMBED_ICON.format(logo_path=brand.logo_path) + title = brand.get('site_title') if isinstance(brand, dict) else brand.site_title + lpath = brand.get('logo_path') if isinstance(brand, dict) else brand.logo_path + if lpath is None or gen_utils.is_empty_string(lpath): + lpath = settings.APP_LOGO_PATH + + embed = settings.APP_EMBED_ICON.format(logo_path=lpath) desc = kwargs.pop('desc', settings.APP_DESC.format(app_title=title)) title = kwargs.pop('title', title) @@ -53,6 +59,11 @@ def render_og_tags(context, *args, **kwargs): ) ) +@register.filter +def get_type(value): + """Resolves the type of the specified value""" + return type(value).__name__ + @register.filter(name='from_phenotype') def from_phenotype(value, index, default=''): if index in value: @@ -85,46 +96,42 @@ def has_group(user, group_name): return user.groups.filter(name=group_name).exists() @register.filter -def islist(value): +def is_list(value): """Check if value is of type list""" - return type(value) == list + return isinstance(value, list) @register.filter -def tolist(value, arg): +def to_list(value, arg): """Convert comma separated value to a list of type arg""" - if arg == "int": return [int(t) for t in value.split(',')] - else: - return [str(t) for t in value.split(',')] + return [str(t) for t in value.split(',')] @register.filter -def toString(value): +def to_string(value): """Convert value to string""" return str(value) @register.filter -def addStr(value, arg): +def add_str(value, arg): """concatenate value & arg""" return str(value) + str(arg) @register.filter -def getBrandLogo(value): +def get_brand_logo(value): """get brand logos""" return f'/static/img/brands/{value}/apple-touch-icon.png' @register.filter def get_ws_type_name(type_int): - ''' - get working set type name - ''' + """get working set type name""" return str([t[1] for t in TypeStatus.Type_status if t[0]==type_int][0]) @register.filter def get_title(txt, makeCapital=''): - ''' + """ get title case - ''' + """ txt = txt.replace('_', ' ') txt = txt.title() if makeCapital.strip() != '': @@ -133,16 +140,12 @@ def get_title(txt, makeCapital=''): @register.filter def is_in_list(txt, list_values): - ''' - check is value is in list - ''' + """check is value is in list""" return (txt in [i.strip() for i in list_values.split(',')]) @register.filter def concat_str(txt0, txt1): - ''' - Safely concatenate the given string(s) - ''' + """Safely concatenate the given string(s)""" if txt0 is not None and txt1 is not None: return '%s %s' % (str(txt0), str(txt1),) elif txt0 is not None: @@ -153,9 +156,7 @@ def concat_str(txt0, txt1): @register.filter def concat_doi(details, doi): - ''' - concat publications details + doi - ''' + """concat publications details + doi""" details = str(details) if doi is None or (isinstance(doi, str) and (len(doi.strip()) < 1 or doi.isspace())): return details diff --git a/CodeListLibrary_project/clinicalcode/templatetags/detail_pg_renderer.py b/CodeListLibrary_project/clinicalcode/templatetags/detail_pg_renderer.py deleted file mode 100644 index 4b3be02cc..000000000 --- a/CodeListLibrary_project/clinicalcode/templatetags/detail_pg_renderer.py +++ /dev/null @@ -1,428 +0,0 @@ -from django.apps import apps -from django import template -from jinja2.exceptions import TemplateSyntaxError -from django.template.loader import render_to_string -from django.utils.translation import gettext_lazy as _ -from django.http.request import HttpRequest -from django.conf import settings - -import re -import json - -from ..entity_utils import permission_utils, template_utils, model_utils, gen_utils, constants, concept_utils -from ..models.GenericEntity import GenericEntity - -register = template.Library() - - -@register.filter(name='is_member') -def is_member(user, args): - ''' - Det. whether has a group membership - - Args: - user {RequestContext.user()} - the user model - args {string} - a string, can be deliminated by ',' to confirm membership in multiple groups - - Returns: - {boolean} that reflects membership status - ''' - if args is None: - return False - - args = [arg.strip() for arg in args.split(',')] - for arg in args: - if permission_utils.is_member(user, arg): - return True - return False - - -@register.filter(name='jsonify') -def jsonify(value, should_print=False): - ''' - Attempts to dump a value to JSON - ''' - if should_print: - print(type(value), value) - - if value is None: - value = {} - - if isinstance(value, (dict, list)): - return json.dumps(value, cls=gen_utils.ModelEncoder) - return model_utils.jsonify_object(value) - - -@register.filter(name='trimmed') -def trimmed(value): - return re.sub(r'\s+', '_', value).lower() - - -@register.filter(name='stylise_number') -def stylise_number(n): - ''' - Stylises a number so that it adds a comma delimiter for numbers greater than 1000 - ''' - return '{:,}'.format(n) - - -@register.filter(name='stylise_date') -def stylise_date(date): - ''' - Stylises a datetime object in the YY-MM-DD format - ''' - return date.strftime('%Y-%m-%d') - - -@register.simple_tag(name='truncate') -def truncate(value, lim=0, ending=None): - ''' - Truncates a string if its length is greater than the limit - - can append an ending, e.g. an ellipsis, by passing the 'ending' parameter - ''' - if lim <= 0: - return value - - try: - truncated = str(value) - if len(truncated) > lim: - truncated = truncated[0:lim] - if ending is not None: - truncated = truncated + ending - except: - return value - else: - return truncated - - -@register.tag(name='render_wizard_sidemenu') -def render_aside_wizard(parser, token): - ''' - Responsible for rendering the <aside/> sidemenu item for detail pages - ''' - params = { - # Any future modifiers - } - - try: - parsed = token.split_contents()[1:] - if len(parsed) > 0 and parsed[0] == 'with': - parsed = parsed[1:] - - for param in parsed: - ctx = param.split('=') - params[ctx[0]] = eval(ctx[1]) - except ValueError: - raise TemplateSyntaxError('Unable to parse wizard aside renderer tag') - - nodelist = parser.parse(('endrender_wizard_sidemenu')) - parser.delete_first_token() - return EntityWizardAside(params, nodelist) - - -class EntityWizardAside(template.Node): - def __init__(self, params, nodelist): - self.request = template.Variable('request') - self.params = params - self.nodelist = nodelist - - def render(self, context): - request = self.request.resolve(context) - output = '' - template = context.get('template', None) - if template is None: - return output - - # We should be getting the FieldTypes.json related to the template - detail_page_sections = [] - template_sections = template.definition.get('sections') - template_sections.extend(constants.DETAIL_PAGE_APPENDED_SECTIONS) - for section in template_sections: - if section.get('hide_on_detail', False): - continue - - if section.get('requires_auth', False): - if not request.user.is_authenticated: - #print('SECTION: requires_auth') - continue - - if section.get('do_not_show_in_production', False): - if (not settings.IS_DEMO and not settings.IS_DEVELOPMENT_PC): - #print('SECTION: do_not_show_in_production') - continue - - detail_page_sections.append(section) - - # still need to handle: section 'hide_if_empty' ??? - - output = render_to_string(constants.DETAIL_WIZARD_ASIDE, { - 'detail_page_sections': detail_page_sections - }) - - return output - - -@register.tag(name='render_wizard_sections_detail_pg') -def render_steps_wizard(parser, token): - ''' - Responsible for rendering the <li/> sections for detail pages - ''' - params = { - # Any future modifiers - } - - try: - parsed = token.split_contents()[1:] - if len(parsed) > 0 and parsed[0] == 'with': - parsed = parsed[1:] - - for param in parsed: - ctx = param.split('=') - params[ctx[0]] = eval(ctx[1]) - except ValueError: - raise TemplateSyntaxError('Unable to parse wizard aside renderer tag') - - nodelist = parser.parse(('endrender_wizard_sections_detail_pg')) - parser.delete_first_token() - return EntityWizardSections(params, nodelist) - - -def get_data_sources(ds_ids, info, default=None): - ''' - Tries to get the sourced value of data_sources id/name/url - ''' - validation = template_utils.try_get_content(info, 'validation') - if validation is None: - return default - - try: - source_info = validation.get('source') - model = apps.get_model(app_label='clinicalcode', model_name=source_info.get('table')) - # relative = None - # if 'relative' in source_info: - # relative = source_info['relative'] - - # query = None - # if 'query' in source_info: - # query = { - # source_info['query']: data - # } - # else: - # query = { - # 'pk': data - # } - - if ds_ids: - queryset = model.objects.filter(id__in=ds_ids) - if queryset.exists(): - #queryset = model.objects.get(id__in = ds_ids) - return queryset - - return default - except: - return default - - -def get_template_creation_data(entity, layout, field, request=None, default=None): - ''' - Used to retrieve assoc. data values for specific keys, e.g. - concepts, in its expanded format for use with create/update pages - ''' - data = template_utils.get_entity_field(entity, field) - info = template_utils.get_layout_field(layout, field) - if not info or not data: - return default - - if info.get('is_base_field'): - info = template_utils.try_get_content(constants.metadata, field) - - validation = template_utils.try_get_content(info, 'validation') - if validation is None: - return default - - field_type = template_utils.try_get_content(validation, 'type') - if field_type is None: - return default - - if field_type == 'concept': - return concept_utils.get_concept_headers(data) - elif field_type == 'int_array': - source_info = validation.get('source') - tree_models = source_info.get('trees') if isinstance(source_info, dict) else None - model_source = source_info.get('model') - if isinstance(tree_models, list) and isinstance(model_source, str): - try: - model = apps.get_model(app_label='clinicalcode', model_name=model_source) - output = model.get_detail_data(node_ids=data, default=default) - if isinstance(output, list): - return output - except Exception as e: - # Logging - return default - if info.get('field_type') == 'data_sources': - return get_data_sources(data, info, default=default) - - if template_utils.is_metadata(entity, field): - return template_utils.get_metadata_value_from_source(entity, field, default=default) - - return template_utils.get_template_data_values(entity, layout, field, default=default) - - -class EntityWizardSections(template.Node): - SECTION_END = render_to_string(template_name=constants.DETAIL_WIZARD_SECTION_END) - - def __init__(self, params, nodelist): - self.request = template.Variable('request') - self.params = params - self.nodelist = nodelist - - def __try_get_entity_value(self, template, entity, field): - value = get_template_creation_data(entity, template, field, request=self.request, default=None) - if value is None: - return template_utils.get_entity_field(entity, field) - - return value - - def __try_render_item(self, **kwargs): - try: - html = render_to_string(**kwargs) - except: - return '' - else: - return html - - def __try_get_computed(self, request, field): - struct = template_utils.get_layout_field(constants.metadata, field) - if struct is None: - return - - validation = template_utils.try_get_content(struct, 'validation') - if validation is None: - return - - if not validation.get('computed'): - return - - if field == 'group': - return self.user_groups - - def __append_section(self, output, section_content): - if gen_utils.is_empty_string(section_content): - return output - return output + section_content + self.SECTION_END - - def __generate_wizard(self, request, context): - output = '' - template = context.get('template', None) - entity = context.get('entity', None) - if template is None: - return output - - flat_ctx = context.flatten() - is_prod_env = not settings.IS_DEMO and not settings.IS_DEVELOPMENT_PC - is_unauthenticated = not request.user or not request.user.is_authenticated - - merged_definition = template_utils.get_merged_definition(template, default={}) - template_fields = template_utils.try_get_content(merged_definition, 'fields') - template_fields.update(constants.DETAIL_PAGE_APPENDED_FIELDS) - template.definition['fields'] = template_fields - - # We should be getting the FieldTypes.json related to the template - field_types = constants.FIELD_TYPES - template_sections = template.definition.get('sections') - #template_sections.extend(constants.DETAIL_PAGE_APPENDED_SECTIONS) - for section in template_sections: - is_hidden = ( - section.get('hide_on_detail', False) - or section.get('hide_on_detail', False) - or (section.get('requires_auth', False) and is_unauthenticated) - or (section.get('do_not_show_in_production', False) and is_prod_env) - ) - if is_hidden: - continue - - section['hide_description'] = True - section_content = self.__try_render_item(template_name=constants.DETAIL_WIZARD_SECTION_START - , request=request - , context=flat_ctx | {'section': section}) - - field_count = 0 - for field in section.get('fields'): - template_field = template_utils.get_field_item(template.definition, 'fields', field) - if not template_field: - template_field = template_utils.try_get_content(constants.metadata, field) - - component = template_utils.try_get_content(field_types, template_field.get('field_type')) if template_field else None - if component is None: - continue - - active = template_field.get('active', False) - is_hidden = ( - (isinstance(active, bool) and not active) - or template_field.get('hide_on_detail') - or (template_field.get('requires_auth', False) and is_unauthenticated) - or (template_field.get('do_not_show_in_production', False) and is_prod_env) - ) - if is_hidden: - continue - - if template_field.get('is_base_field', False): - template_field = constants.metadata.get(field) | template_field - - if template_utils.is_metadata(GenericEntity, field): - field_data = template_utils.try_get_content(constants.metadata, field) - else: - field_data = template_utils.get_layout_field(template, field) - - component['field_name'] = field - component['field_data'] = '' if field_data is None else field_data - - desc = template_utils.try_get_content(template_field, 'description') - if desc is not None: - component['description'] = desc - component['hide_input_details'] = False - else: - component['hide_input_details'] = True - - # don't show field description in detail page - component['hide_input_details'] = True - - component['hide_input_title'] = False - if len(section.get('fields')) <= 1: - # don't show field title if it is the only field in the section - component['hide_input_title'] = True - - if entity: - component['value'] = self.__try_get_entity_value(template, entity, field) - else: - component['value'] = '' - - if 'sort' in component['field_data'] and component['value'] is not None: - component['value'] = sorted(component['value'], **component['field_data']['sort']) - - if template_field.get('hide_if_empty', False): - comp_value = component.get('value') - if comp_value is None or str(comp_value) == '' or comp_value == [] or comp_value == {}: - continue - - output_type = component.get("output_type") - uri = f'{constants.DETAIL_WIZARD_OUTPUT_DIR}/{output_type}.html' - field_count += 1 - section_content += self.__try_render_item(template_name=uri, request=request, - context=flat_ctx | {'component': component}) - - if field_count > 0: - output = self.__append_section(output, section_content) - - return output - - def render(self, context): - if not isinstance(self.request, HttpRequest): - self.request = self.request.resolve(context) - - if self.request and self.request.user is not None and not self.request.user.is_anonymous: - self.user_groups = permission_utils.get_user_groups(self.request) - else: - self.user_groups = [] - - return self.__generate_wizard(self.request, context) diff --git a/CodeListLibrary_project/clinicalcode/templatetags/entity_publish_renderer.py b/CodeListLibrary_project/clinicalcode/templatetags/entity_publish_renderer.py index b5cd6d49a..6a21767bf 100644 --- a/CodeListLibrary_project/clinicalcode/templatetags/entity_publish_renderer.py +++ b/CodeListLibrary_project/clinicalcode/templatetags/entity_publish_renderer.py @@ -2,7 +2,10 @@ from django.utils.translation import gettext_lazy as _ from django.urls import reverse -from ..entity_utils import permission_utils, constants + + +from ..entity_utils import permission_utils, publish_utils, constants +from .entity_renderer import get_template_entity_name register = template.Library() @@ -15,7 +18,7 @@ def render_errors_approval(context, *args, **kwargs): if not context['is_entity_user'] and not context['is_moderator']: - message = 'You must be the owner to publish.' + message = 'You must be the owner or have organisation permissions to publish.' errors.append(message) if not context['entity_has_data']: @@ -23,15 +26,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 children of this entity to publish.' errors.append(message) if not context['all_not_deleted']: - message = 'All Concepts/Phenotypes must not be deleted.' + message = 'All entities must not be deleted.' errors.append(message) if not context['all_are_published']: - message = 'All Concepts/Phenotypes must be published.' + message = 'All entities must be published.' errors.append(message) return {'errors': errors} @@ -39,36 +42,44 @@ def render_errors_approval(context, *args, **kwargs): @register.inclusion_tag('components/publish_request/publish_button.html', takes_context=True, name='render_publish_button') def render_publish_button(context, *args, **kwargs): - user_is_moderator = permission_utils.is_member(context['request'].user, "Moderators") - user_entity_access = permission_utils.can_user_edit_entity(context['request'], context['entity'].id) #context['entity'].owner == context['request'].user - user_is_publisher = user_entity_access and permission_utils.is_member(context['request'].user, "publishers") + entity = context.get('entity') + request = context.get('request') + + entity_cls_name = get_template_entity_name(entity.template.entity_class, entity.template) + publish_checks = publish_utils.check_entity_to_publish(request, entity.id, entity.history_id) + user_is_moderator = publish_checks['is_moderator'] + user_is_publisher = publish_checks['is_publisher'] + user_allowed_publish = publish_checks['allowed_to_publish'] + user_entity_access = permission_utils.can_user_edit_entity(request, entity.id) #entity.owner == request.user + button_context = { - 'url_decline': reverse('generic_entity_decline', kwargs={'pk': context['entity'].id, 'history_id': context['entity'].history_id}), - 'url_redirect': reverse('entity_history_detail', kwargs={'pk': context['entity'].id, 'history_id': context['entity'].history_id}), + 'url_decline': reverse('generic_entity_decline', kwargs={'pk': entity.id, 'history_id': entity.history_id}), + 'url_redirect': reverse('entity_history_detail', kwargs={'pk': entity.id, 'history_id': entity.history_id}), } if user_is_moderator: if not context['live_ver_is_deleted']: if context["approval_status"]== constants.APPROVAL_STATUS.PENDING and context["is_latest_pending_version"]: button_context.update({'class_modal':"primary-btn text-warning bold dropdown-btn__label ", - 'Button_type':"Approve", - 'url': reverse('generic_entity_publish', kwargs={'pk': context['entity'].id, 'history_id': context['entity'].history_id}), + 'Button_type': "Approve", + 'url': reverse('generic_entity_publish', kwargs={'pk': entity.id, 'history_id': entity.history_id}), 'title': "Requires approval" - }) + }) + elif context['approval_status'] == constants.APPROVAL_STATUS.REJECTED: + button_context.update({'class_modal':"primary-btn text-danger bold dropdown-btn__label", + 'url': reverse('generic_entity_publish', kwargs={'pk': entity.id, 'history_id': entity.history_id}), + 'Button_type': "Publish", + 'title': f"Approve declined {entity_cls_name}" + }) + elif user_allowed_publish: + button_context.update({'class_modal':"primary-btn bold dropdown-btn__label ", + 'url': reverse('generic_entity_publish', kwargs={'pk': entity.id, 'history_id': entity.history_id}), + 'Button_type': "Publish", + 'title': "Publish immediately" + }) else: - if context['approval_status'] == constants.APPROVAL_STATUS.REJECTED: - button_context.update({'class_modal':"primary-btn text-danger bold dropdown-btn__label", - 'url': reverse('generic_entity_publish', kwargs={'pk': context['entity'].id, 'history_id': context['entity'].history_id}), - 'Button_type':"Publish", - 'title': "Approve declined entity" - }) - else: - button_context.update({'class_modal':"primary-btn bold dropdown-btn__label ", - 'url': reverse('generic_entity_publish', kwargs={'pk': context['entity'].id, 'history_id': context['entity'].history_id}), - 'Button_type':"Publish", - 'title': "Publish immediately" - }) + button_context.update({ 'pub_btn_hidden': True }) else: if context["is_published"] and context["approval_status"] == constants.APPROVAL_STATUS.APPROVED: button_context.update({'class_modal':"primary-btn__text-success bold dropdown-btn__label", @@ -78,41 +89,41 @@ def render_publish_button(context, *args, **kwargs): else: 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!" + 'Button_type': f"{entity_cls_name} is deleted", + 'title': f"Deleted {entity_cls_name}s cannot be published!" }) return button_context - elif user_entity_access: - if not context["is_lastapproved"] and (context["approval_status"] is None or context["approval_status"] == constants.APPROVAL_STATUS.ANY) and user_entity_access and not context["live_ver_is_deleted"]: + elif user_allowed_publish: + if not publish_checks["is_lastapproved"] and (publish_checks["approval_status"] is None or publish_checks["approval_status"] == constants.APPROVAL_STATUS.ANY) and user_entity_access and not context["live_ver_is_deleted"]: if user_is_publisher: button_context.update({'class_modal':"primary-btn bold dropdown-btn__label", - 'url': reverse('generic_entity_publish', kwargs={'pk': context['entity'].id, 'history_id': context['entity'].history_id}), + 'url': reverse('generic_entity_publish', kwargs={'pk': entity.id, 'history_id': entity.history_id}), 'Button_type':"Publish", 'title': "Publish immediately" }) else: button_context.update({'class_modal':"primary-btn bold dropdown-btn__label", 'Button_type': "Request publication", - 'url': reverse('generic_entity_request_publish', kwargs={'pk': context['entity'].id, 'history_id': context['entity'].history_id}), - 'title': "Needs to be approved" + 'url': reverse('generic_entity_request_publish', kwargs={'pk': entity.id, 'history_id': entity.history_id}), + 'title': "Request publication" }) - elif context["is_lastapproved"] and not context["live_ver_is_deleted"] and context["approval_status"] != constants.APPROVAL_STATUS.REJECTED: + elif publish_checks["is_lastapproved"] and not context["live_ver_is_deleted"] and context["approval_status"] != constants.APPROVAL_STATUS.REJECTED: button_context.update({'class_modal':"primary-btn bold dropdown-btn__label", - 'url': reverse('generic_entity_publish', kwargs={'pk': context['entity'].id, 'history_id': context['entity'].history_id}), + 'url': reverse('generic_entity_publish', kwargs={'pk': entity.id, 'history_id': entity.history_id}), 'Button_type': "Publish", 'title': "Publish immediately" }) else: if context["is_published"] and context["approval_status"] == constants.APPROVAL_STATUS.APPROVED: button_context.update({'class_modal':"primary-btn__text-success bold dropdown-btn__label", - 'title': f"This version is already {constants.APPROVAL_STATUS.APPROVED.name.lower()} " + 'title': f"This version is already approved." }) elif context["live_ver_is_deleted"]: 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': f"Deleted {entity_cls_name}s cannot be published!" }) elif context["approval_status"] == constants.APPROVAL_STATUS.REJECTED: @@ -127,9 +138,12 @@ def render_publish_button(context, *args, **kwargs): 'disabled': 'true', 'Button_type': 'Pending Approval', 'title': "This version is pending approval.", - 'url': reverse('generic_entity_publish', kwargs={'pk': context['entity'].id, 'history_id': context['entity'].history_id}), + 'url': reverse('generic_entity_publish', kwargs={'pk': entity.id, 'history_id': entity.history_id}), }) else: button_context.update({ 'pub_btn_hidden': True }) + + else: + button_context.update({ 'pub_btn_hidden': True }) - return button_context + return button_context diff --git a/CodeListLibrary_project/clinicalcode/templatetags/entity_renderer.py b/CodeListLibrary_project/clinicalcode/templatetags/entity_renderer.py index 0b3890feb..9a7e55c3b 100644 --- a/CodeListLibrary_project/clinicalcode/templatetags/entity_renderer.py +++ b/CodeListLibrary_project/clinicalcode/templatetags/entity_renderer.py @@ -1,28 +1,45 @@ +from copy import deepcopy from django import template +from datetime import datetime +from django.apps import apps from django.conf import settings from django.urls import reverse +from django.db.models import Model from django.utils.html import _json_script_escapes as json_script_escapes from jinja2.exceptions import TemplateSyntaxError, FilterArgumentError from django.template.loader import render_to_string -from django.utils.translation import gettext_lazy as _ from django.utils.safestring import mark_safe -from datetime import datetime +from django.utils.translation import gettext_lazy as _ import re import json -import warnings +import inspect +import numbers +import logging -from ..entity_utils import permission_utils, template_utils, search_utils, model_utils, create_utils, gen_utils, constants from ..models.Brand import Brand -from ..models.GenericEntity import GenericEntity +from ..entity_utils import ( + concept_utils, permission_utils, template_utils, search_utils, + model_utils, create_utils, gen_utils, constants +) + register = template.Library() +logger = logging.getLogger(__name__) + @register.simple_tag def sort_by_alpha(arr, column="name", order="desc"): """ - Sorts an array of objects by the defined column, and orders by - asc/desc given its params + Sorts a `list` of objects by the defined column, and orders by asc/desc given its params; erroneous inputs are caught and ignored + + Args: + arr (list|any): an array of objects to sort + column (str): specify a column of the object to sort by; defaults to `name` + order (str): specify one of `asc` or `desc` to set the array sort order; defaults to `desc` + + Returns: + The sorted (list) if applicable; returns the `arr` input argument if invalid """ sorted_arr = None try: @@ -32,49 +49,286 @@ def sort_by_alpha(arr, column="name", order="desc"): sorted_arr = arr return sorted_arr + +@register.simple_tag(takes_context=True) +def get_brand_map_rules(context, brand=None, default=constants.DEFAULT_CONTENT_MAPPING): + if brand is None: + brand = model_utils.try_get_brand(context.get('request')) + elif isinstance(brand, str) and not gen_utils.is_empty_string(brand): + brand = model_utils.try_get_instance(Brand, name__iexact=brand) + elif isinstance(brand, int) and brand >= 0: + brand = model_utils.try_get_instance(Brand, pk=brand) + elif not isinstance(brand, Brand) and (not inspect.isclass(brand) or not issubclass(brand, Brand)): + brand = None + + if brand is None: + return default + + return brand.get_map_rules(default=default) + + +@register.simple_tag(takes_context=True) +def fmt_brand_mapped_string(context, target, brand=None, default=None): + brand = get_brand_map_rules(context, brand, default=None) + if brand is None: + if isinstance(default, str): + return default + brand = constants.DEFAULT_CONTENT_MAPPING if not isinstance(default, dict) else default + return target.format(**brand) + + +@register.simple_tag(takes_context=True) +def get_brand_mapped_string(context, target, default=None, brand=None): + if not isinstance(default, str): + raise Exception('Failed to map string, expected a default value of type str, got %s' % type(default).__name__) + + if not isinstance(target, str): + raise Exception('Expected mapping key as str, got %s' % type(target).__name__) + + brand = get_brand_map_rules(context, brand) + return brand.get(target, default) + + @register.simple_tag def get_brand_base_icons(brand): + """ + Gets the brand-related favicon & apple-touch-icons; defaults to base icons if not applicable for this brand + + Args: + brand (Brand|dict|None): the brand from which to resolve the info + + Returns: + A (dict) with key-value pairs specifying the `favicon` and `apple` (`apple-touch-icon`) path + """ path = settings.APP_LOGO_PATH - if brand and getattr(brand, 'logo_path'): - path = brand.logo_path + if brand and hasattr(brand, 'logo_path') and getattr(brand, 'logo_path', None): + path = brand.logo_path if not gen_utils.is_empty_string(brand.logo_path) else path return { 'favicon': path + 'favicon-32x32.png', 'apple': path + 'apple-touch-icon.png', } + @register.simple_tag def get_brand_base_title(brand): """ - Gets the brand-related site title if available, otherwise returns - the APP_TITLE per settings.py + Gets the brand-related site title if available, otherwise returns the `APP_TITLE` per `settings.py` + + Args: + brand (Brand|dict|None): the brand from which to resolve the info + + Returns: + A (str) specifying the site title """ - if not brand or not getattr(brand, 'site_title'): + if isinstance(brand, dict): + title = brand.get('site_title', None) + elif isinstance(brand, Model): + title = getattr(brand, 'site_title', None) if hasattr(brand, 'site_title') else None + else: + title = None + + if title is None or gen_utils.is_empty_string(title): return settings.APP_TITLE - return brand.site_title + return title + + +@register.simple_tag +def get_brand_base_desc(brand): + """ + Gets the brand-related site description if available, otherwise returns the base embed description (see `APP_DESC` in `settings.py`) + + Args: + brand (Brand|dict|None): the brand from which to resolve the info + + Returns: + A (str) specifying the site description + """ + if isinstance(brand, dict): + desc = brand.get('site_description', None) + elif isinstance(brand, Model): + desc = getattr(brand, 'site_description', None) if hasattr(brand, 'site_description') else None + else: + desc = None + + if desc is None or gen_utils.is_empty_string(desc): + return settings.APP_DESC.format(app_title=settings.APP_TITLE) + return desc + + +@register.simple_tag(takes_context=True) +def get_brand_base_website(context, brand=None): + """ + Gets the brand-related site description if available, otherwise returns the base embed description (see `APP_DESC` in `settings.py`) + + Args: + brand (Brand|dict|None): the brand from which to resolve the info + + Returns: + A (str) specifying the site description + """ + request = context.get('request') + if brand is None: + brand = model_utils.try_get_brand(request) + elif isinstance(brand, str) and not gen_utils.is_empty_string(brand): + brand = model_utils.try_get_instance(Brand, name__iexact=brand) + elif isinstance(brand, int) and brand >= 0: + brand = model_utils.try_get_instance(Brand, pk=brand) + elif not isinstance(brand, dict) and not isinstance(brand, Brand) and (not inspect.isclass(brand) or not issubclass(brand, Brand)): + brand = None + + if isinstance(brand, dict): + url = brand.get('website', None) + elif isinstance(brand, Model): + url = getattr(brand, 'website', None) if hasattr(brand, 'website') else None + else: + url = None + + if url is not None and not gen_utils.is_empty_string(url): + return url + + if brand is not None: + return f'/{brand.name}' + return request.build_absolute_uri() + + +@register.simple_tag(takes_context=True) +def get_brand_citation_req(context, brand=None): + """ + Gets the brand-related citation requirement if available, otherwise returns the base embed description (see `APP_CITATION` in `settings.py`) + + Args: + context (RequestContext): the page's request context (auto-prepended) + brand (Request|Brand|dict|None): the brand from which to resolve the info + + Returns: + A (str) specifying the site citation requirement message + """ + request = context.get('request') + if brand is None: + brand = model_utils.try_get_brand(request) + elif isinstance(brand, str) and not gen_utils.is_empty_string(brand): + brand = model_utils.try_get_instance(Brand, name__iexact=brand) + elif isinstance(brand, int) and brand >= 0: + brand = model_utils.try_get_instance(Brand, pk=brand) + elif not isinstance(brand, Brand) and (not inspect.isclass(brand) or not issubclass(brand, Brand)): + brand = None + + if brand is not None: + map_rules = brand.get_map_rules() + citation = map_rules.get('citation') + website = map_rules.get('website') + else: + map_rules = constants.DEFAULT_CONTENT_MAPPING + citation = None + website = map_rules.get('website') + + has_valid_website = isinstance(website, str) and not gen_utils.is_empty_string(website) + if isinstance(brand, dict): + title = brand.get('site_title', None) + website = brand.get('website', None) if not has_valid_website else website + elif isinstance(brand, Model): + title = getattr(brand, 'site_title', None) if hasattr(brand, 'site_title') else None + website = getattr(brand, 'website', None) if not has_valid_website and hasattr(brand, 'website') else website + else: + title = None + website = website if has_valid_website else None + + if title is None or gen_utils.is_empty_string(title): + title = settings.APP_TITLE + + if website is None or gen_utils.is_empty_string(website): + website = request.build_absolute_uri() + + if not isinstance(citation, str) or gen_utils.is_empty_string(citation): + citation = settings.APP_CITATION + + return citation.format( + **map_rules, + app_title=title, + brand_name=brand.name if brand else 'SAIL', + brand_website=website + ) + @register.simple_tag def get_brand_base_embed_desc(brand): """ - Gets the brand-related site desc if available, otherwise returns - the APP_DESC per settings.py + Gets the brand-related embedding desc if available, otherwise returns the `APP_DESC` per `settings.py` (OG tags) + + Note: + - Interpolated by the `Brand`'s `site_title` attribute + + Args: + brand (Brand|dict|None): the brand from which to resolve the info + + Returns: + A (str) specifying the embed description """ - if not brand or not getattr(brand, 'site_title'): + if isinstance(brand, dict): + title = brand.get('site_title', None) + elif isinstance(brand, Model): + title = getattr(brand, 'site_title', None) if hasattr(brand, 'site_title') else None + else: + title = None + + if title is None or gen_utils.is_empty_string(title): return settings.APP_DESC.format(app_title=settings.APP_TITLE) - return settings.APP_DESC.format(app_title=brand.site_title) + return settings.APP_DESC.format(app_title=title) + @register.simple_tag def get_brand_base_embed_img(brand): """ - Gets the brand-related site desc if available, otherwise returns - the APP_DESC per settings.py + Gets the brand-related site open-graph embed image if applicable, otherwise returns the `APP_EMBED_ICON` per `settings.py` + + Args: + brand (Brand|dict|None): the brand from which to resolve the info + + Returns: + A (str) specifying the site embed icon """ - if not brand or not getattr(brand, 'logo_path'): + if isinstance(brand, dict): + path = brand.get('logo_path', None) + elif isinstance(brand, Model): + path = getattr(brand, 'logo_path', None) if hasattr(brand, 'logo_path') else None + else: + path = None + + if path is None or gen_utils.is_empty_string(path): return settings.APP_EMBED_ICON.format(logo_path=settings.APP_LOGO_PATH) - return settings.APP_EMBED_ICON.format(logo_path=brand.logo_path) + return settings.APP_EMBED_ICON.format(logo_path=path) + + +@register.simple_tag +def get_template_entity_name(entity_class, template=None): + tmpl_def = None + if isinstance(template, Model) or inspect.isclass(template) and issubclass(template, Model): + tmpl_def = template.definition.get('template_details') if isinstance(template.definition, dict) else None + elif isinstance(template, dict): + tmpl_def = template.get('definition').get('template_details') if isinstance(template.get('definition'), dict) else None + + if isinstance(tmpl_def, dict): + shortname = tmpl_def.get('shortname') + else: + shortname = None + + if isinstance(shortname, str) and not gen_utils.is_empty_string(shortname): + return shortname + return entity_class.name @register.simple_tag def render_citation_block(entity, request): + """ + Computes an example citation block for the given entity entity + + Args: + entity (GenericEntity): some `GenericEntity` instance + request (RequestContext): the HTTP request context assoc. with this render + + Returns: + A (str) specifying the citation block content + """ phenotype_id = f'{entity.id} / {entity.history_id}' name = entity.name author = entity.author @@ -91,14 +345,32 @@ def render_citation_block(entity, request): return f'{author}. *{phenotype_id} - {name}*. {site_name} [Online]. {updated}. Available from: [{url}]({url}). [Accessed {date}]' + @register.inclusion_tag('components/search/pagination/pagination.html', takes_context=True, name='render_entity_pagination') -def render_pagination(context, *args, **kwargs): +def render_pagination(context): """ Renders pagination button(s) for search pages - - Provides page range so that it always includes the first and last page, - and if available, provides the page numbers 1 page to the left and the right of the current page + + Note: + - Provides page range so that it always includes the first and last page; + - And if available, provides the page numbers 1 page to the left and the right of the current page. + + Args: + context (Context|dict): specify the rendering context assoc. with this component; see `TemplateContext`_ + + Returns: + A (dict) specifying the pagination options + + .. _TemplateContext: https://docs.djangoproject.com/en/5.1/ref/templates/api/#django.template.Context """ - page_obj = context['page_obj'] + page_obj = context.get('page_obj', None) + if page_obj is None: + return { + 'page': 1, + 'page_range': [1], + 'has_previous': False, + 'has_next': False, + } page = page_obj.number num_pages = page_obj.paginator.num_pages @@ -134,17 +406,18 @@ def render_pagination(context, *args, **kwargs): packet['pages'] = page_items return packet + @register.filter(name='is_member') def is_member(user, args): """ Det. whether has a group membership Args: - user (RequestContext.user()) - the user model - args (string) - a string, can be deliminated by ',' to confirm membership in multiple groups + user (RequestContext.user()): the user model + args (string): a string, can be deliminated by ',' to confirm membership in multiple groups Returns: - (boolean) that reflects membership status + A (bool) that reflects membership status """ if args is None: return False @@ -155,62 +428,98 @@ def is_member(user, args): return True return False + @register.filter(name='jsonify') -def jsonify(value, should_print=False): - ''' +def jsonify(value, remove_userdata=True, should_print=False): + """ Attempts to dump a value to JSON - ''' - if should_print: - print(type(value), value) - - if value is None: - value = { } - - if isinstance(value, (dict, list)): - return json.dumps(value, cls=gen_utils.ModelEncoder) - return model_utils.jsonify_object(value) -@register.simple_tag -def parse_as_json_object(value, remove_userdata=True, should_print=False): - ''' - Attempts to dump a value to JSON - ''' + Args: + value (*): some JSONifiable-value, _e.g._ some `Model` instance, a `dict`, or `list` + remove_userdata (bool): optionally specify whether to remove userdata assoc. with some `Model` input instance; defaults to `True` + should_print (bool): optionally specify whether to print-debug the value before dumping it; defaults to `False` + + Returns: + A (str) specifying the citation block content + """ if should_print: print(type(value), value) - + if value is None: value = { } - + if isinstance(value, (dict, list)): return json.dumps(value, cls=gen_utils.ModelEncoder) return model_utils.jsonify_object(value, remove_userdata=remove_userdata) -@register.filter(name='trimmed') -def trimmed(value): - return re.sub(r'\s+', '_', value).lower() + +@register.filter(name='shrink_underscore') +def shrink_underscore(value): + """ + Replaces the whitespace of strings with an underscore, and performs a lower case transform + + Args: + value (str): the `str` value to transform + + Returns: + The transformed (str) value if applicable; otherwise returns an empty `str` + """ + return re.sub(r'\s+', '_', value).lower() if isinstance(value, str) else '' + @register.filter(name='stylise_number') -def stylise_number(n): +def stylise_number(value): """ - Stylises a number so that it adds a comma delimiter for numbers greater than 1000 + Stylises (transforms) a number such that it contains a comma delimiter for numbers greater than 1000, _e.g._ `1,000`, or `1,000,000` _etc_ + + Args: + value (numbers.Number|str): the number or representation of a number to stylise + + Returns: + The stylised (str) value if applicable; otherwise returns an empty `str` """ - if n is not None: - return '{:,}'.format(n) - return '' + if isinstance(value, str): + try: + test = float(value) + except ValueError: + value = '' + else: + value = int(test) if test.is_integer() else test + + if isinstance(value, numbers.Number): + value = '{:,}'.format(value) + + return value if isinstance(value, str) else '' + @register.filter(name='stylise_date') -def stylise_date(date): +def stylise_date(value): """ - Stylises a datetime object in the YY-MM-DD format + Stylises a datetime object in the `YY-MM-DD` format + + Args: + value (datetime): the date to format + + Returns: + The stylised (str) value if applicable; otherwise returns an empty `str` """ - return date.strftime('%Y-%m-%d') + return value.strftime('%Y-%m-%d') if isinstance(value, datetime) else '' + @register.simple_tag(name='truncate') -def truncate(value, lim=0, ending=None): +def truncate(value, lim=10, ending=None): """ - Truncates a string if its length is greater than the limit - - can append an ending, e.g. an ellipsis, by passing the 'ending' parameter + Truncates a string if its length is greater than the limit; can append an ending, _e.g._ an ellipsis, by passing the 'ending' parameter + + Args: + value (str|*): some value to truncate; note that this value is coerced into a `str` before being truncated + lim (int): optionally specify the max length of the `str`; defaults to `10` + ending (str|None): optionally specify a suffix to append to the resulting `str`; defaults to `None` + + Returns: + The truncated (str) if applicable; otherwise returns an empty `str` """ + lim = lim if isinstance(lim, numbers.Number) else 0 if lim <= 0: return value @@ -225,30 +534,37 @@ def truncate(value, lim=0, ending=None): else: return truncated + @register.simple_tag(name='render_field_value') -def render_field_value(entity, layout, field, through=None): +def render_field_value(entity, layout, field, through=None, default=''): """ - Responsible for rendering fields after transforming them using their respective layouts - - in the case of 'type' (in this case, phenotype clinical types) where pk__eq=1 would be 'Disease or Syndrome' - instead of returning the pk, it would return the field's string representation from either (a) its source or (b) the options parameter - - - in the case of 'coding_system', it would read each individual element within the ArrayField, - and return a rendered output based on the 'desired_output' parameter - OR - it would render output based on the 'through' parameter, which points to a component to be rendered + Responsible for rendering fields after transforming them using their respective layouts, such that: + - In the case of `type` (in this case, phenotype clinical types) where `pk__eq=1` would be "_Disease or Syndrome_" instead of returning the `pk`, it would return the field's string representation from either (a) its source or (b) the options parameter; + + - In the case of `coding_system`, it would read each individual element within the `ArrayField`, and return a rendered output based on the `desired_output` parameter ***OR*** it would render output based on the `through` parameter, which points to a component to be rendered. + + Args: + entity (GenericEntity): some entity from which to resolve the field value + layout (dict): the entity's template data + field (str): the name of the field to resolve + through (str|None): optionally specify the through field target, if applicable; defaults to `None` + default (Any): optionally specify the default value; defaults to an empty (str) `''` + + Returns: + The renderable (str) value resolved from this entity's field value """ data = template_utils.get_entity_field(entity, field) info = template_utils.get_layout_field(layout, field) - if not info or not data: - return '' + return default validation = template_utils.try_get_content(info, 'validation') if validation is None: - return '' + return default + field_type = template_utils.try_get_content(validation, 'type') if field_type is None: - return '' + return default if field_type == 'enum' or field_type == 'int': output = template_utils.get_template_data_values(entity, layout, field, default=None) @@ -261,32 +577,54 @@ def render_field_value(entity, layout, field, through=None): if values is not None: if through is not None: # Use override template - return '' + return default else: # Use desired output - return '' + return default + + return default - return '' @register.simple_tag(name='renderable_field_values') def renderable_field_values(entity, layout, field): """ Gets the field's value from an entity, compares it with it's expected layout (per the template), and returns - a list of values that relate to that field - e.g. in the case of CodingSystems it would return [{name: 'ICD-10', value: 1}] where 'value' is the PK + a list of values that relate to that field; _e.g._ in the case of CodingSystems it would return `[{name: 'ICD-10', value: 1}]` where `value` is the PK + + Args: + entity (GenericEntity): some entity from which to resolve the field value + layout (dict): the entity's template data + field (str): the name of the field to resolve + + Returns: + The resolved (Any)-typed value from the entity's field """ - if template_utils.is_metadata(entity, field): + info = template_utils.get_template_field_info(layout, field) + if info.get('is_metadata'): # handle metadata e.g. collections, tags etc - return template_utils.get_metadata_value_from_source(entity, field, default=[]) + return template_utils.get_metadata_value_from_source(entity, field, field_info=info, layout=layout, default=[]) return template_utils.get_template_data_values(entity, layout, field, default=[]) + +@register.simple_tag +def get_str_validation(component): + val = { 'has_range': False, 'has_min': False } + validation = component.get('validation') if isinstance(component, dict) else None + if not isinstance(validation, dict): + return val + + val.update({ + 'has_min': isinstance(validation.get('length'), int), + 'has_range': isinstance(validation.get('length'), list) and len(validation.get('length')) >= 2, + }) + return val + + @register.tag(name="to_json_script") def render_jsonified_object(parser, token): """ - Attempts to dump a value to JSON - and render it as a HTML element in - the form of: + Attempts to dump a value to JSON and render it as a HTML element in the form of: ```html <script type="application/json" other-attributes="some-value"> @@ -294,12 +632,24 @@ def render_jsonified_object(parser, token): </script> ``` - Example usage: - + Example: + ```html {% url 'some_url_var' as some_variable %} {% test_jsonify some_jsonifiable_content some-attribute="some_value" other-attribute=some_variable %} ``` + + Args: + parser (template.Parser): the Django template tag parser (supplied by renderer) + token (template.Token): the processed Django template token (supplied by HTML renderer) + + Kwargs: + should_print (bool): optionally specify whether to print-debug the value before dumping it; defaults to `False` + remove_userdata (bool): optionally specify whether to remove userdata assoc. with some `Model` input instance; defaults to `True` + attributes (**kwargs): optionally specify a set of attributes to be applied to the rendered `<script />` node + + Returns: + A (JsonifiedNode), a subclass of `template.Node`, to be rendered by Django's template renderer """ kwargs = { 'should_print': False, @@ -332,11 +682,9 @@ def render_jsonified_object(parser, token): return JsonifiedNode(content, attributes, **kwargs) + class JsonifiedNode(template.Node): - """ - Renders the JSON node given the parameters - called from `render_jsonified_object` - """ + """Renders the JSON node given the parameters called from `render_jsonified_object`""" def __init__(self, content, attributes, **kwargs): # opts self.should_print = kwargs.pop('should_print', False) @@ -347,6 +695,7 @@ def __init__(self, content, attributes, **kwargs): self.attributes = attributes def render(self, context): + """Inherited method to render the nodes""" content = self.content.resolve(context) if self.should_print: @@ -372,12 +721,31 @@ def render(self, context): content_string = mark_safe(content_string.translate(json_script_escapes)) return mark_safe(f'<script type="application/json"{attribute_string}>{content_string}</script>') + @register.tag(name='render_entity_cards') def render_entities(parser, token): """ Responsible for rendering the entity cards on a search page - - Uses the entity's template to determine how to render the card (e.g. which to use) - - Each card is rendered with its own context pertaining to that entity + - Uses the entity's template to determine how to render the card (_e.g._ which to use); + - Each card is rendered with its own context pertaining to that entity. + + Note: + - This tag uses the `TemplateContext`_ to render the cards + + Example: + ```html + {% render_entity_cards %} + {% endrender_entity_cards %} + ``` + + Args: + parser (template.Parser): the Django template tag parser (supplied by renderer) + token (template.Token): the processed Django template token (supplied by HTML renderer) + + Returns: + A (EntityCardsNode), a subclass of `template.Node`, to be rendered by Django's template renderer + + .. _TemplateContext: https://docs.djangoproject.com/en/5.1/ref/templates/api/#django.template.Context """ params = { # Any future params that modifies behaviour @@ -398,14 +766,20 @@ def render_entities(parser, token): parser.delete_first_token() return EntityCardsNode(params, nodelist) + class EntityCardsNode(template.Node): + """Renders the cards associated with an entity on the search page""" def __init__(self, params, nodelist): - self.request = template.Variable('request') + self.reqvar = template.Variable('request') self.params = params self.nodelist = nodelist def render(self, context): - request = self.request.resolve(context) + """Inherited method to render the nodes""" + user = context.get('user') + request = self.reqvar.resolve(context) + request.user = user + entities = context['page_obj'].object_list layouts = context['layouts'] @@ -414,23 +788,35 @@ def render(self, context): layout = template_utils.try_get_content(layouts, f'{entity.template.id}/{entity.template_version}') if not template_utils.is_layout_safe(layout): continue + card = template_utils.try_get_content(layout['definition'].get('template_details'), 'card_type', constants.DEFAULT_CARD) card = f'{constants.CARDS_DIRECTORY}/{card}.html' - try: - html = render_to_string(card, { - 'entity': entity, - 'layout': layout - }) - except: - raise - else: - output += html + output += render_to_string(card, { 'entity': entity, 'layout': layout }) return output + @register.tag(name='render_entity_filters') def render_filters(parser, token): """ Responsible for rendering filters for entities on the search pages + + Note: + - This tag uses the `TemplateContext`_ to render the filters + + Example: + ```html + {% render_entity_filters %} + {% endrender_entity_filters %} + ``` + + Args: + parser (template.Parser): the Django template tag parser (supplied by renderer) + token (template.Token): the processed Django template token (supplied by HTML renderer) + + Returns: + A (EntityFiltersNode), a subclass of `template.Node`, to be rendered by Django's template renderer + + .. _TemplateContext: https://docs.djangoproject.com/en/5.1/ref/templates/api/#django.template.Context """ params = { # Any future modifiers @@ -451,16 +837,16 @@ def render_filters(parser, token): parser.delete_first_token() return EntityFiltersNode(params, nodelist) + class EntityFiltersNode(template.Node): + """Renders the filters on the search page""" def __init__(self, params, nodelist): - self.request = template.Variable('request') + self.reqvar = template.Variable('request') self.params = params self.nodelist = nodelist def __try_compile_reference(self, context, field, structure): - """ - Attempts to compile the reference data for a metadata field - """ + """Attempts to compile the reference data for a metadata field""" if field == 'template': layouts = context.get('layouts', None) modifier = { @@ -469,12 +855,10 @@ def __try_compile_reference(self, context, field, structure): else: modifier = None - return search_utils.get_source_references(structure, default=[], modifier=modifier) + return search_utils.get_source_references(structure, default=[], modifier=modifier, request=self.request) def __check_excluded_brand_collections(self, context, field, current_brand, options): - """ - Checks and removes Collections excluded from filters - """ + """Checks and removes Collections excluded from filters""" updated_options = options if field == 'collections': if current_brand == '' or current_brand == 'ALL': @@ -492,10 +876,7 @@ def __check_excluded_brand_collections(self, context, field, current_brand, opti return updated_options def __render_metadata_component(self, context, field, structure): - """ - Renders a metadata field, as defined by constants.py - """ - request = self.request.resolve(context) + """Renders a metadata field, as defined by constants.py""" filter_info = search_utils.get_filter_info(field, structure) if not filter_info: return '' @@ -506,26 +887,25 @@ def __render_metadata_component(self, context, field, structure): options = None if 'compute_statistics' in structure: - current_brand = request.CURRENT_BRAND or 'ALL' + current_brand = self.request.CURRENT_BRAND or 'ALL' options = search_utils.get_metadata_stats_by_field(field, brand=current_brand) - options = self.__check_excluded_brand_collections(context, field, current_brand, options) + # options = self.__check_excluded_brand_collections(context, field, current_brand, options) if options is None: validation = template_utils.try_get_content(structure, 'validation') if validation is not None: if 'source' in validation: options = self.__try_compile_reference(context, field, structure) - + + if options is None or (isinstance(options, list) and len(options) < 1): + return '' + filter_info['options'] = options context['filter_info'] = filter_info return render_to_string(f'{constants.FILTER_DIRECTORY}/{component}.html', context.flatten()) def __render_template_component(self, context, field, structure, layout): - """ - Renders a component for a template field after computing its reference data - as defined by its validation & field type - """ - request = self.request.resolve(context) + """Renders a component for a template field after computing its reference data as defined by its validation & field type""" filter_info = search_utils.get_filter_info(field, structure) if not filter_info: return '' @@ -534,7 +914,7 @@ def __render_template_component(self, context, field, structure, layout): if component is None: return '' - current_brand = request.CURRENT_BRAND or 'ALL' + current_brand = self.request.CURRENT_BRAND or 'ALL' statistics = search_utils.try_get_template_statistics(filter_info.get('field'), brand=current_brand) if statistics is None or len(statistics) < 1: return '' @@ -547,9 +927,7 @@ def __render_template_component(self, context, field, structure, layout): return render_to_string(f'{constants.FILTER_DIRECTORY}/{component}.html', context.flatten()) def __generate_metadata_filters(self, context, is_single_search=False): - """ - Generates the filters for all metadata fields within a template - """ + """Generates the filters for all metadata fields within a template""" output = '' for field, structure in constants.metadata.items(): search = template_utils.try_get_content(structure, 'search') @@ -564,9 +942,7 @@ def __generate_metadata_filters(self, context, is_single_search=False): return output def __generate_template_filters(self, context, output, layouts): - """ - Generates a filter for each field of a template - """ + """Generates a filter for each field of a template""" layout = next((x for x in layouts.values()), None) if not template_utils.is_layout_safe(layout): return output @@ -589,6 +965,11 @@ def __generate_template_filters(self, context, output, layouts): return output def render(self, context): + """Inherited method to render the nodes""" + self.user = context.get('user') + self.request = self.reqvar.resolve(context) + self.request.user = self.user + entity_type = context.get('entity_type', None) layouts = context.get('layouts', None) if layouts is None: @@ -605,15 +986,29 @@ def render(self, context): return output + @register.tag(name='render_wizard_navigation') def render_aside_wizard(parser, token): """ - Responsible for rendering the <aside/> navigation item for create pages - """ - params = { - # Any future modifiers - } + Responsible for rendering the `<aside/>` navigation item for create pages & detail pages + Example: + ```html + {% render_wizard_navigation %} + {% endrender_wizard_navigation %} + ``` + + Args: + parser (template.Parser): the Django template tag parser (supplied by renderer) + token (template.Token): the processed Django template token (supplied by HTML renderer) + + Kwargs: + detail_pg (bool): optionally specify whether to render this aside menu for the detail page; defaults to `False` + + Returns: + A (EntityWizardAside), a subclass of `template.Node`, to be rendered by Django's template renderer + """ + params = { 'detail_pg': False } try: parsed = token.split_contents()[1:] if len(parsed) > 0 and parsed[0] == 'with': @@ -629,19 +1024,42 @@ def render_aside_wizard(parser, token): parser.delete_first_token() return EntityWizardAside(params, nodelist) + class EntityWizardAside(template.Node): + """Responsible for rendering the aside component of the steps wizard""" def __init__(self, params, nodelist): - self.request = template.Variable('request') + self.reqvar = template.Variable('request') self.params = params self.nodelist = nodelist - - def render(self, context): - output = '' - template = context.get('template', None) - if template is None: - return output - - sections = template.definition.get('sections') + + def __render_detail(self, context, tmpl): + # We should be getting the FieldTypes.json related to the template + request = self.request + detail_page_sections = [] + template_sections = tmpl.definition.get('sections') + template_sections.extend(constants.DETAIL_PAGE_APPENDED_SECTIONS) + for section in template_sections: + if section.get('hide_on_detail', False): + continue + + if section.get('requires_auth', False) and not request.user.is_authenticated: + continue + + if section.get('do_not_show_in_production', False) and (not settings.IS_DEMO and not settings.IS_DEVELOPMENT_PC): + continue + + detail_page_sections.append(section) + + # still need to handle: section 'hide_if_empty' ??? + + output = render_to_string(constants.DETAIL_WIZARD_ASIDE, { + 'detail_page_sections': detail_page_sections + }) + + return output + + def __render_create(self, context, tmpl): + sections = tmpl.definition.get('sections') if sections is None: return '' @@ -653,15 +1071,46 @@ def render(self, context): }) return output + + def render(self, context): + """Inherited method to render the nodes""" + self.user = context.get('user') + self.request = self.reqvar.resolve(context) + self.request.user = self.user + + tmpl = context.get('template', None) + if tmpl is None: + return '' + + is_detail_pg = self.params.get('detail_pg', False) + if is_detail_pg: + return self.__render_detail(context, tmpl) + + return self.__render_create(context, tmpl) + @register.tag(name='render_wizard_sections') def render_steps_wizard(parser, token): """ - Responsible for rendering the <li/> sections for create pages + Responsible for rendering the `<li/>` sections for create & detail pages + + Example: + ```html + {% render_wizard_sections %} + {% endrender_wizard_sections %} + ``` + + Args: + parser (template.Parser): the Django template tag parser (supplied by renderer) + token (template.Token): the processed Django template token (supplied by HTML renderer) + + Kwargs: + detail_pg (bool): optionally specify whether to render this component for the detail page; defaults to `False` + + Returns: + A subclass of (template.Node) to be rendered by Django's template renderer, representing either a (EntityCreateWizardSections) or a (EntityDetailWizardSections) """ - params = { - # Any future modifiers - } + params = { 'detail_pg': False } try: parsed = token.split_contents()[1:] @@ -676,21 +1125,24 @@ def render_steps_wizard(parser, token): nodelist = parser.parse(('endrender_wizard_sections')) parser.delete_first_token() - return EntityWizardSections(params, nodelist) -class EntityWizardSections(template.Node): + if params.get('detail_pg', False): + return EntityDetailWizardSections(params, nodelist) + return EntityCreateWizardSections(params, nodelist) + + +class EntityCreateWizardSections(template.Node): + """Responsible for rendering the sections associated with the create steps wizard""" SECTION_END = render_to_string(template_name=constants.CREATE_WIZARD_SECTION_END) def __init__(self, params, nodelist): - self.request = template.Variable('request') + self.reqvar = template.Variable('request') self.params = params self.nodelist = nodelist - def __try_get_entity_value(self, request, template, entity, field): - """ - Attempts to safely generate the creation data for field within a template - """ - value = create_utils.get_template_creation_data(request, entity, template, field, default=None) + def __try_get_entity_value(self, tmpl, entity, field, info=None): + """Attempts to safely generate the creation data for field within a template""" + value = create_utils.get_template_creation_data(self.request, entity, tmpl, field, default=None, info=info) if value is None: return template_utils.get_entity_field(entity, field) @@ -698,23 +1150,20 @@ def __try_get_entity_value(self, request, template, entity, field): return value def __try_render_item(self, **kwargs): - """ - Attempts to safely render the HTML to string and sinks exceptions - """ + """Attempts to safely render the HTML to string and sinks exceptions""" try: html = render_to_string(**kwargs) except Exception as e: - if settings.DEBUG: - warnings.warn(str(e)) + logger.warning(str(e)) return '' else: return html - def __try_get_props(self, template, field): - """ - Attempts to safely get the properties of a validation field, if present - """ - struct = template_utils.get_layout_field(template, field) + def __try_get_props(self, tmpl, field, struct=None): + """Attempts to safely get the properties of a validation field, if present""" + if struct is None: + struct = template_utils.get_layout_field(tmpl, field) + if not isinstance(struct, dict): return @@ -723,11 +1172,11 @@ def __try_get_props(self, template, field): return return validation.get('properties') - def __try_get_computed(self, request, field): - """ - Attempts to safely parse computed fields - """ - struct = template_utils.get_layout_field(constants.metadata, field) + def __try_get_computed(self, field, struct=None): + """Attempts to safely parse computed fields""" + if struct is None: + struct = template_utils.get_layout_field(constants.metadata, field) + if struct is None: return @@ -739,39 +1188,40 @@ def __try_get_computed(self, request, field): return # append other computed fields if required - if field == 'group': - return permission_utils.get_user_groups(request) + if field == 'organisation' or field == 'group': + return permission_utils.get_user_organisations(self.request) return - def __apply_mandatory_property(self, template, field): + def __apply_properties(self, component, tmpl, _field): """ - Returns boolean that reflects the mandatory status of a field given its - template's validation field (if present) + Applies properties assoc. with some template's field to some target + + Returns: + Updates in place but returns the updated (dict) """ - validation = template_utils.try_get_content(template, 'validation') - if validation is None: - return False - - mandatory = template_utils.try_get_content(validation, 'mandatory') - return mandatory if isinstance(mandatory, bool) else False + validation = template_utils.try_get_content(tmpl, 'validation') + if validation is not None: + mandatory = template_utils.try_get_content(validation, 'mandatory') + component['mandatory'] = mandatory if isinstance(mandatory, bool) else False + + return component def __append_section(self, output, section_content): + """Appends the given section to the current output target""" if gen_utils.is_empty_string(section_content): return output return output + section_content + self.SECTION_END - def __generate_wizard(self, request, context): - """ - Generates the creation wizard template - """ + def __generate_wizard(self, context): + """Generates the creation wizard template""" output = '' - template = context.get('template', None) + tmpl = context.get('template', None) entity = context.get('entity', None) - if template is None: + if tmpl is None: return output field_types = constants.FIELD_TYPES - sections = template.definition.get('sections') + sections = tmpl.definition.get('sections') if sections is None: return '' @@ -779,14 +1229,15 @@ def __generate_wizard(self, request, context): sections.extend(constants.APPENDED_SECTIONS) for section in sections: - section_content = self.__try_render_item(template_name=constants.CREATE_WIZARD_SECTION_START, request=request, context=context.flatten() | { 'section': section }) + section_content = self.__try_render_item( + template_name=constants.CREATE_WIZARD_SECTION_START, + request=self.request, + context=context.flatten() | { 'section': section } + ) for field in section.get('fields'): - if template_utils.is_metadata(GenericEntity, field): - template_field = constants.metadata.get(field) - else: - template_field = template_utils.get_field_item(template.definition, 'fields', field) - + field_info = template_utils.get_template_field_info(tmpl, field) + template_field = field_info.get('field') if not template_field: continue @@ -796,20 +1247,20 @@ def __generate_wizard(self, request, context): if template_field.get('hide_on_create'): continue + + if template_field.get('hide_non_org_managed'): + current_brand = model_utils.try_get_brand(self.request) + current_brand = current_brand if current_brand and current_brand.org_user_managed else None + if current_brand is None: + continue component = template_utils.try_get_content(field_types, template_field.get('field_type')) if component is None: continue - if template_utils.is_metadata(GenericEntity, field): - field_data = template_utils.try_get_content(constants.metadata, field) - else: - field_data = template_utils.get_layout_field(template, field) - - if field_data is None: - continue + component = deepcopy(component) component['field_name'] = field - component['field_data'] = field_data + component['field_data'] = template_field desc = template_utils.try_get_content(template_field, 'description') if desc is not None: @@ -818,42 +1269,315 @@ def __generate_wizard(self, request, context): else: component['hide_input_details'] = True - is_metadata = template_utils.is_metadata(GenericEntity, field) - field_struct = template_utils.get_layout_field(constants.metadata if is_metadata else template, field) - will_hydrate = field_struct is not None and field_struct.get('hydrated', False) + is_metadata = field_info.get('is_metadata') + will_hydrate = template_field.get('hydrated', False) options = None if not will_hydrate: if is_metadata: - options = template_utils.get_template_sourced_values(constants.metadata, field, request=request) + options = template_utils.get_template_sourced_values( + constants.metadata, + field, + request=self.request, + struct=template_field + ) if options is None: - options = self.__try_get_computed(request, field) + options = self.__try_get_computed(field, struct=template_field) else: - options = template_utils.get_template_sourced_values(template, field, request=request) + options = template_utils.get_template_sourced_values(tmpl, field, request=self.request, struct=template_field) if options is not None: component['options'] = options - field_properties = self.__try_get_props(template, field) + field_properties = self.__try_get_props(tmpl, field, struct=template_field) if field_properties is not None: component['properties'] = field_properties - + if entity: - component['value'] = self.__try_get_entity_value(request, template, entity, field) + component['value'] = self.__try_get_entity_value(tmpl, entity, field, info=field_info) else: component['value'] = '' - component['mandatory'] = self.__apply_mandatory_property(template_field, field) + + self.__apply_properties(component, template_field, field) uri = f'{constants.CREATE_WIZARD_INPUT_DIR}/{component.get("input_type")}.html' - section_content += self.__try_render_item(template_name=uri, request=request, context=context.flatten() | { 'component': component }) + section_content += self.__try_render_item(template_name=uri, context=context.flatten() | { 'component': component }) output = self.__append_section(output, section_content) return output def render(self, context): - """ - Renders the wizard - """ - request = self.request.resolve(context) - return self.__generate_wizard(request, context) + """Inherited method to render the nodes""" + self.user = context.get('user') + self.request = self.reqvar.resolve(context) + self.request.user = self.user + return self.__generate_wizard(context) + + +## NOTE: +## - Need to ask M.E. to document the following at some point +## + +def get_data_sources(ds_ids, info, default=None): + """Tries to get the sourced value of data_sources id/name/url""" + validation = template_utils.try_get_content(info, 'validation') + if validation is None: + return default + + try: + source_info = validation.get('source') + model = apps.get_model(app_label='clinicalcode', model_name=source_info.get('table')) + if ds_ids: + queryset = model.objects.filter(id__in=ds_ids) + if queryset.exists(): + return queryset + except: + return default + else: + return default + + +def get_template_creation_data(entity, layout, field, request=None, default=None, info=None): + """Used to retrieve assoc. data values for specific keys, e.g. concepts, in its expanded format for use with create/update pages""" + if info is None: + info = template_utils.get_template_field_info(layout, field) + + data = template_utils.get_entity_field(entity, field) + if not info or not data: + return default + + field_info = info.get('field') + validation = template_utils.try_get_content(field_info, 'validation') + if validation is None: + return default + + field_type = template_utils.try_get_content(validation, 'type') + if field_type is None: + return default + + if field_type == 'concept': + return concept_utils.get_concept_headers(data) + elif field_type == 'int_array': + source_info = validation.get('source') + tree_models = source_info.get('trees') if isinstance(source_info, dict) else None + model_source = source_info.get('model') if isinstance(source_info, dict) else None + if isinstance(tree_models, list) and isinstance(model_source, str): + try: + model = apps.get_model(app_label='clinicalcode', model_name=model_source) + output = model.get_detail_data(node_ids=data, default=default) + if isinstance(output, list): + return output + except: + # Logging + return default + elif field_type == 'related_entities': + if isinstance(data, list) and len(data) > 0: + props = validation.get('properties') if isinstance(validation.get('properties'), dict) else None + display = props.get('display') if props else None + + if not isinstance(display, list): + return default + + anchor = props.get('anchor') if props else None + target = anchor.get('target') if anchor and isinstance(anchor.get('target'), str) else None + + result = [] + for item in data: + name = '' + for i, key in enumerate(display): + res = item.get(key, '') + if i == 0: + name = str(res) + elif i == 1: + name += '/' + str(res) + else: + name += ' - ' + str(res) + + ref = item.get(target) if target else None + result.append({ + 'anchor': anchor.get('name'), + 'target': ref, + 'label': name, + }) + return result if len(result) > 0 else None + return default + elif field_type == 'var_data': + if isinstance(data, dict) and len(data.keys()) > 0: + options = validation.get('options') if validation else None + + result = [] + for key, item in data.items(): + typed = item.get('type') + opt = options.get(key) if options else None + value = None + if opt: + fmt = opt.get('format') + if fmt: + value = fmt.format(**item.get('value')) if isinstance(item.get('value'), dict) else fmt.format(value=item.get('value')) + + if value is None: + value = item.get('value') + if typed.endswith('_range'): + suffix = '%' if 'percentage' in typed else '' + value = '{v0}{suffix} - {v1}{suffix}'.format(v0=value[0], v1=value[1], suffix=suffix) + elif 'percentage' in typed: + value = '{0}%'.format(value) + else: + value = str(value) + + typed = typed.replace('_', ' ').title() + result.append({ 'name': item.get('name'), 'type': typed, 'value': value, 'description': item.get('description') }) + + return result if len(result) > 0 else None + return default + + if field_info.get('field_type') == 'data_sources': + return get_data_sources(data, field_info, default=default) + + if info.get('is_metadata'): + return template_utils.get_metadata_value_from_source(entity, field, field_info=info, layout=layout, default=default) + + return template_utils.get_template_data_values(entity, layout, field, default=default) + + +class EntityDetailWizardSections(template.Node): + """Renders the detail page template sections""" + SECTION_END = render_to_string(template_name=constants.DETAIL_WIZARD_SECTION_END) + + def __init__(self, params, nodelist): + self.user = None + self.reqvar = template.Variable('request') + self.params = params + self.nodelist = nodelist + + def __try_get_entity_value(self, tmpl, entity, field, info=None): + value = get_template_creation_data(entity, tmpl, field, request=self.request, default=None, info=info) + if value is None: + return template_utils.get_entity_field(entity, field) + + return value + + def __try_render_item(self, **kwargs): + try: + html = render_to_string(**kwargs) + except: + return '' + else: + return html + + def __append_section(self, output, section_content): + if gen_utils.is_empty_string(section_content): + return output + return output + section_content + self.SECTION_END + + def __generate_wizard(self, context): + output = '' + tmpl = context.get('template', None) + entity = context.get('entity', None) + if tmpl is None: + return output + + flat_ctx = context.flatten() + is_prod_env = not settings.IS_DEMO and not settings.IS_DEVELOPMENT_PC + is_unauthenticated = not self.request.user or self.request.user.is_anonymous + + merged_definition = template_utils.get_merged_definition(tmpl, default={}) + template_fields = template_utils.try_get_content(merged_definition, 'fields') + template_fields.update(constants.DETAIL_PAGE_APPENDED_FIELDS) + tmpl.definition['fields'] = template_fields + + # We should be getting the FieldTypes.json related to the template + field_types = constants.FIELD_TYPES + template_sections = tmpl.definition.get('sections') + #template_sections.extend(constants.DETAIL_PAGE_APPENDED_SECTIONS) + for section in template_sections: + is_hidden = ( + section.get('hide_on_detail', False) + or section.get('hide_on_detail', False) + or (section.get('requires_auth', False) and is_unauthenticated) + or (section.get('do_not_show_in_production', False) and is_prod_env) + ) + if is_hidden: + continue + + section['hide_description'] = True + section_content = self.__try_render_item( + template_name=constants.DETAIL_WIZARD_SECTION_START, + request=self.request, + context=flat_ctx | {'section': section} + ) + + field_count = 0 + for field in section.get('fields'): + field_info = template_utils.get_template_field_info(tmpl, field) + template_field = field_info.get('field') + if not template_field: + continue + + component = template_utils.try_get_content(field_types, template_field.get('field_type')) if template_field else None + if component is None: + continue + + component = deepcopy(component) + active = template_field.get('active', False) + is_hidden = ( + (isinstance(active, bool) and not active) + or template_field.get('hide_on_detail') + or (template_field.get('requires_auth', False) and is_unauthenticated) + or (template_field.get('do_not_show_in_production', False) and is_prod_env) + ) + if is_hidden: + continue + + component['field_name'] = field + component['field_data'] = '' if template_field is None else template_field + + desc = template_utils.try_get_content(template_field, 'description') + if desc is not None: + component['description'] = desc + component['hide_input_details'] = False + else: + component['hide_input_details'] = True + + # don't show field description in detail page + component['hide_input_details'] = True + + component['hide_input_title'] = False + if len(section.get('fields')) <= 1: + # don't show field title if it is the only field in the section + component['hide_input_title'] = True + + if entity: + component['value'] = self.__try_get_entity_value(tmpl, entity, field, info=field_info) + else: + component['value'] = '' + + sorting_behaviour = component.get('field_data', {}).get('sort') + if isinstance(sorting_behaviour, dict) and isinstance(sorting_behaviour.get('key'), str) and component['value'] is not None: + component['value'] = sorted(component['value'], key=lambda x: x.get(sorting_behaviour.get('key'))) + + if template_field.get('hide_if_empty', False): + comp_value = component.get('value') + if comp_value is None or str(comp_value) == '' or comp_value == [] or comp_value == {}: + continue + + output_type = component.get("output_type") + uri = f'{constants.DETAIL_WIZARD_OUTPUT_DIR}/{output_type}.html' + field_count += 1 + section_content += self.__try_render_item( + template_name=uri, request=self.request, + context=flat_ctx | {'component': component} + ) + + if field_count > 0: + output = self.__append_section(output, section_content) + + return output + + def render(self, context): + """Inherited method to render the nodes""" + self.user = context.get('user') + self.request = self.reqvar.resolve(context) + self.request.user = self.user + return self.__generate_wizard(context) diff --git a/CodeListLibrary_project/clinicalcode/tests/conftest.py b/CodeListLibrary_project/clinicalcode/tests/conftest.py index e0b241c9d..d9b232400 100644 --- a/CodeListLibrary_project/clinicalcode/tests/conftest.py +++ b/CodeListLibrary_project/clinicalcode/tests/conftest.py @@ -6,11 +6,12 @@ from datetime import datetime from django.db import connection from django.utils.timezone import make_aware -from django.contrib.auth.models import User, Group +from django.contrib.auth.models import Group from selenium import webdriver from selenium.webdriver import Keys from selenium.webdriver.common.by import By from selenium.webdriver.support.wait import WebDriverWait +from django.contrib.auth import get_user_model from clinicalcode.models import Brand from clinicalcode.models import Concept @@ -25,6 +26,9 @@ from cll.test_settings import REMOTE_TEST_HOST, REMOTE_TEST, chrome_options +User = get_user_model() + + @pytest.fixture def generate_user(create_groups): """ @@ -43,7 +47,7 @@ def generate_user(create_groups): 'view_group_user': View group user instance 'edit_group_user': Edit group user instance """ - su_user = User.objects.create_superuser(username='superuser', password='superuserpassword', email=None) + su_user = User.objects.create_user(username='superuser', password='superuserpassword', email=None, is_superuser=True, is_staff=True) nm_user = User.objects.create_user(username='normaluser', password='normaluserpassword', email=None) ow_user = User.objects.create_user(username='owneruser', password='owneruserpassword', email=None) gp_user = User.objects.create_user(username='groupuser', password='groupuserpassword', email=None) @@ -63,6 +67,10 @@ def generate_user(create_groups): 'edit_group_user': egp_user, } + for uobj in users.values(): + setattr(uobj, 'BRAND_OBJECT', {}) + setattr(uobj, 'CURRENT_BRAND', '') + yield users # Clean up the users after the tests are finished @@ -125,12 +133,13 @@ def generate_entity_session(template, generate_user, brands=None): name='TEST_%s_Entity' % status.name, author=user.username, status=ENTITY_STATUS.DRAFT.value, - publish_status=APPROVAL_STATUS.ANY.value, + publish_status=status.value, template=template, template_version=1, template_data=template_data, created_by=user, - world_access=WORLD_ACCESS_PERMISSIONS.VIEW + world_access=WORLD_ACCESS_PERMISSIONS.VIEW, + owner=user ) record = {'entity': entity} diff --git a/CodeListLibrary_project/clinicalcode/tests/constants/test_template.json b/CodeListLibrary_project/clinicalcode/tests/constants/test_template.json index 6e104ccdd..6c65710f2 100644 --- a/CodeListLibrary_project/clinicalcode/tests/constants/test_template.json +++ b/CodeListLibrary_project/clinicalcode/tests/constants/test_template.json @@ -166,7 +166,7 @@ "field_type": "daterange", "validation": { "type": "string", - "regex": "(?:\\d+/|\\d+)+[\\s+]?-[\\s+]?(?:\\d+/|\\d+)+", + "regex": "[\\S\\s]*?(\\d+(?:-\\d+)*)[^\\d]*", "mandatory": false }, "description": "If this Phenotype is only applicable within a limited time period, please specify that here (optional)." @@ -339,7 +339,7 @@ "do_not_show_in_production": true }, { - "title": "Clinical Code List", + "title": "Clinical Codelist", "fields": [ "concept_information" ], diff --git a/CodeListLibrary_project/clinicalcode/tests/functional_tests/test_auth_user.py b/CodeListLibrary_project/clinicalcode/tests/functional_tests/test_auth_user.py index 9d53929d1..511c206f8 100644 --- a/CodeListLibrary_project/clinicalcode/tests/functional_tests/test_auth_user.py +++ b/CodeListLibrary_project/clinicalcode/tests/functional_tests/test_auth_user.py @@ -26,7 +26,8 @@ def test_user_with_access(self, generate_user, user_type, live_server, generate_ user = generate_user[user_type] client = Client( - username=user.username, password=user.username + "password", + username=user.username, + password=user.username + "password", url=live_server.url ) @@ -40,13 +41,14 @@ def test_user_with_access(self, generate_user, user_type, live_server, generate_ normal_user = generate_user[normal_user_type] normal_user_client = Client( - username=normal_user.username, password=normal_user.username + "password", + username=normal_user.username, + password=normal_user.username + "password", url=live_server.url ) pheno_ver_normal_user = normal_user_client.phenotypes.get_versions('PH2') - print("Phenotype PH2 with group/world access:", pheno_ver_normal_user) - assert pheno_ver_normal_user != [] + print("Inaccessible to other user (non-group):", pheno_ver_normal_user) + assert pheno_ver_normal_user == [] non_auth_client = Client(public=True, url=live_server.url) non_auth_user = non_auth_client.phenotypes.get_versions('PH2') diff --git a/CodeListLibrary_project/clinicalcode/tests/functional_tests/test_collection_components.py b/CodeListLibrary_project/clinicalcode/tests/functional_tests/test_collection_components.py index 93cbc3720..1310ca494 100644 --- a/CodeListLibrary_project/clinicalcode/tests/functional_tests/test_collection_components.py +++ b/CodeListLibrary_project/clinicalcode/tests/functional_tests/test_collection_components.py @@ -6,7 +6,7 @@ import pytest -@pytest.mark.django_db +@pytest.mark.django_db(reset_sequences=True,transaction=True) @pytest.mark.usefixtures('setup_webdriver') class TestCollectionComponents: @@ -19,11 +19,11 @@ def test_collection_redirects(self, login, logout, generate_user, user_type, liv login(self.driver, user.username, user.username + 'password') user_details = user_type if user else 'Anonymous' - self.driver.get(live_server + reverse('search_phenotypes')) + self.driver.get(live_server + reverse('search_entities')) element = None try: - element = self.driver.find_element(By.CSS_SELECTOR, '''a.referral-card__title[href='%s']''' % reverse('my_collection')) + element = self.driver.find_element(By.CSS_SELECTOR, '''.referral-card[data-target='%s']''' % reverse('my_collection')) except Exception as e: if not isinstance(e, NoSuchElementException): raise e diff --git a/CodeListLibrary_project/clinicalcode/tests/functional_tests/test_detail_components.py b/CodeListLibrary_project/clinicalcode/tests/functional_tests/test_detail_components.py index ce6048b86..23365b306 100644 --- a/CodeListLibrary_project/clinicalcode/tests/functional_tests/test_detail_components.py +++ b/CodeListLibrary_project/clinicalcode/tests/functional_tests/test_detail_components.py @@ -1,10 +1,13 @@ + from django.urls import reverse -from selenium.common.exceptions import NoSuchElementException +from selenium.webdriver.support import expected_conditions +from selenium.common.exceptions import NoSuchElementException, TimeoutException from selenium.webdriver.common.by import By +from selenium.webdriver.support.ui import WebDriverWait import pytest -@pytest.mark.django_db(reset_sequences=True,transaction=True) +@pytest.mark.django_db @pytest.mark.usefixtures('setup_webdriver') class TestDetailComponents: @@ -28,6 +31,7 @@ def test_anonymous_publication_presence(self, live_server): ('moderator_user', 'REQUESTED'), ('owner_user', 'ANY') ]) + @pytest.mark.skip(reason="Legacy") def test_publish_btn_presence(self, login, logout, generate_entity_session, user_type, entity_status, live_server): users = generate_entity_session['users'] user = users.get(user_type) @@ -35,20 +39,25 @@ def test_publish_btn_presence(self, login, logout, generate_entity_session, user entities = generate_entity_session['entities'] record = entities.get(entity_status) entity = record.get('entity') + print('Test Pub Ent:', entity, '|', entity.owner, '|', user) + historical = entity.history.latest() login(self.driver, user.username, user.username + 'password') - self.driver.get(live_server + reverse('entity_detail_shortcut', kwargs={ 'pk' : entity.id })) + print(f'Test Pub: PH{entity.id}/V{historical.history_id}') + self.driver.get(live_server + reverse('entity_history_detail', kwargs={ 'pk' : entity.id, 'history_id': historical.history_id })) + present = False try: - self.driver.find_element(By.ID, 'publish-btn') + wait = WebDriverWait(self.driver, 5) + wait.until(expected_conditions.presence_of_element_located((By.ID, 'publish-btn'))) except Exception as e: if not isinstance(e, NoSuchElementException): raise e present = False else: present = True - finally: - assert present, f'Publication button not visible for {user_type} when approval_status={entity_status}!' + + assert present, f'Publication button not visible for {user_type} when approval_status={entity_status}!' logout(self.driver) diff --git a/CodeListLibrary_project/clinicalcode/tests/functional_tests/test_moderation_components.py b/CodeListLibrary_project/clinicalcode/tests/functional_tests/test_moderation_components.py index 3827aebcc..fa6166541 100644 --- a/CodeListLibrary_project/clinicalcode/tests/functional_tests/test_moderation_components.py +++ b/CodeListLibrary_project/clinicalcode/tests/functional_tests/test_moderation_components.py @@ -6,13 +6,13 @@ import pytest -@pytest.mark.django_db +@pytest.mark.django_db(reset_sequences=True) @pytest.mark.usefixtures('setup_webdriver') class TestModerationComponents: COMPONENT_VISIBILITY_RULES = ['moderator_user', 'super_user'] @pytest.mark.functional_test - @pytest.mark.parametrize('user_type', [None, 'normal_user', 'moderator_user', 'super_user']) + @pytest.mark.parametrize('user_type', [None, 'normal_user']) def test_moderation_redirect(self, login, logout, generate_user, user_type, live_server): user = None if isinstance(user_type, str): @@ -30,7 +30,7 @@ def test_moderation_redirect(self, login, logout, generate_user, user_type, live if not isinstance(e, TimeoutException): raise e finally: - assert 'permission denied' in self.driver.title.lower(), \ + assert 'Moderation' not in self.driver.title.lower(), \ f'Failed to present 403 status to {user_details} for Moderation page' else: expected_url = None @@ -61,12 +61,12 @@ def test_moderation_component_presence(self, login, logout, generate_user, user_ login(self.driver, user.username, user.username + 'password') user_details = user_type if user else 'Anonymous' - self.driver.get(live_server + reverse('search_phenotypes')) + self.driver.get(live_server + reverse('search_entities')) desired_visibility = user_type in self.COMPONENT_VISIBILITY_RULES component_presence = False try: - elem = self.driver.find_element(By.CSS_SELECTOR, '''a.referral-card__title[href='%s']''' % reverse('moderation_page')) + elem = self.driver.find_element(By.CSS_SELECTOR, '''.referral-card[data-target='%s']''' % reverse('moderation_page')) component_presence = elem is not None except Exception as e: if not isinstance(e, NoSuchElementException): diff --git a/CodeListLibrary_project/clinicalcode/tests/functional_tests/test_search_filters.py b/CodeListLibrary_project/clinicalcode/tests/functional_tests/test_search_filters.py index 4c0688372..da74412b0 100644 --- a/CodeListLibrary_project/clinicalcode/tests/functional_tests/test_search_filters.py +++ b/CodeListLibrary_project/clinicalcode/tests/functional_tests/test_search_filters.py @@ -1,31 +1,44 @@ -import time from django.urls import reverse -import pytest +from selenium.webdriver.support import expected_conditions as EC +from selenium.common.exceptions import NoSuchElementException, TimeoutException from selenium.webdriver.common.by import By +from selenium.webdriver.support.ui import WebDriverWait +import time +import pytest -@pytest.mark.django_db +@pytest.mark.django_db(reset_sequences=True,transaction=True) @pytest.mark.usefixtures("setup_webdriver") class TestSearchFilters: @pytest.mark.functional_test @pytest.mark.parametrize('user_type', ['super_user']) def test_tags_filter(self, login, logout, generate_user, user_type, live_server): - user = generate_user[user_type] + user = None + if isinstance(user_type, str): + user = generate_user[user_type] + login(self.driver, user.username, user.username + 'password') # generate_entity.created_by = generate_user[user_type] this needed to test the page for at least some data - login(self.driver, user.username, user.username + "password") + self.driver.get(live_server + reverse('search_entities')) + + uname = self.driver.find_element(By.CLASS_NAME, 'text-username').text if user is not None else user_type + print(f"Current username: {uname}") - self.driver.get(live_server + reverse('search_phenotypes')) + try: + wait = WebDriverWait(self.driver, 5) + accordion = wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, '.accordion[data-field="tags"] label'))) + accordion.click() - print(f"Current username:{self.driver.find_element(By.CLASS_NAME, 'text-username').text}") + time.sleep(1) - accordion = self.driver.find_element(By.XPATH, "/html/body/main/div/div/aside/div[2]/div[4]") - time.sleep(5) - accordion.click() - checkboxes = self.driver.find_elements(By.XPATH, "//input[(@class='checkbox-item') and (@aria-label = 'Tags')]") + checkboxes = self.driver.find_elements(By.CSS_SELECTOR, 'input[data-field="tags"]') + for checkbox in checkboxes: + assert checkbox.is_enabled() is True - for checkbox in checkboxes: - assert checkbox.is_enabled() is True + except Exception as e: + if not isinstance(e, TimeoutException): + raise e - logout(self.driver) + if user is not None: + logout(self.driver) diff --git a/CodeListLibrary_project/clinicalcode/tests/unit_tests/test_publishing.py b/CodeListLibrary_project/clinicalcode/tests/unit_tests/test_publishing.py index c2fdb7bcc..b38c1857b 100644 --- a/CodeListLibrary_project/clinicalcode/tests/unit_tests/test_publishing.py +++ b/CodeListLibrary_project/clinicalcode/tests/unit_tests/test_publishing.py @@ -9,7 +9,7 @@ from clinicalcode.views.Publish import Publish, RequestPublish -@pytest.mark.django_db +@pytest.mark.django_db(reset_sequences=True,transaction=True) class TestPublishing: def __build_http_request(self, user, url='', resolver_name='', resolver_kwargs=None, method='GET'): @@ -23,7 +23,8 @@ def __build_http_request(self, user, url='', resolver_name='', resolver_kwargs=N request.method = method request.session = { } request.IS_HDRUK_EXT = '0' - request.CURRENT_BRAND = '' + setattr(request, 'BRAND_OBJECT', {}) + setattr(request, 'CURRENT_BRAND', '') return request @@ -34,6 +35,7 @@ def __build_http_request(self, user, url='', resolver_name='', resolver_kwargs=N ('owner_user', 'ANY'), ('moderator_user', 'ANY'), ]) + @pytest.mark.skip(reason="Legacy") def test_publish_request_get(self, generate_entity_session, user_type, entity_status, live_server): user = None if isinstance(user_type, str): @@ -50,6 +52,7 @@ def test_publish_request_get(self, generate_entity_session, user_type, entity_st entity_history_id = entity.history.first().history_id entity_kwargs = { 'pk': entity_id, 'history_id': entity_history_id } + print('Test PostPub Ent:', entity, '|', entity.owner, '|', user) request = self.__build_http_request( user, url=live_server, @@ -101,6 +104,7 @@ def test_publish_request_get(self, generate_entity_session, user_type, entity_st ('owner_user', 'ANY'), ('moderator_user', 'ANY'), ]) + @pytest.mark.skip(reason="Legacy") def test_publish_request_post(self, generate_entity_session, user_type, entity_status, live_server): user = None if isinstance(user_type, str): @@ -117,6 +121,7 @@ def test_publish_request_post(self, generate_entity_session, user_type, entity_s entity_history_id = entity.history.first().history_id entity_kwargs = { 'pk': entity_id, 'history_id': entity_history_id } + print('Test PostPub Ent:', entity, '|', entity.owner, '|', user) request = self.__build_http_request( user, url=live_server, @@ -157,6 +162,7 @@ def test_publish_request_post(self, generate_entity_session, user_type, entity_s ('owner_user', 'ANY'), ('moderator_user', 'ANY'), ]) + @pytest.mark.skip(reason="Legacy") def test_publish_approve_get(self, generate_entity_session, user_type, entity_status, live_server): user = None if isinstance(user_type, str): @@ -224,6 +230,7 @@ def test_publish_approve_get(self, generate_entity_session, user_type, entity_st ('owner_user', 'ANY'), ('moderator_user', 'ANY'), ]) + @pytest.mark.skip(reason="Legacy") def test_publish_approve_post(self, generate_entity_session, user_type, entity_status, live_server): user = None if isinstance(user_type, str): diff --git a/CodeListLibrary_project/clinicalcode/urls.py b/CodeListLibrary_project/clinicalcode/urls.py index f6f4a1a34..c421a8699 100644 --- a/CodeListLibrary_project/clinicalcode/urls.py +++ b/CodeListLibrary_project/clinicalcode/urls.py @@ -1,18 +1,22 @@ -''' - URL Configuration for the Clinical-Code application. - - Pages appear as Working-sets, Concepts and Components within a Concept. -''' +"""Static URL Configuration for the Clinical-Code application.""" from django.conf import settings from django.urls import re_path as url -from django.views.generic.base import RedirectView -from django.contrib.auth import views as auth_views +from clinicalcode.views.dashboard import BrandAdmin from clinicalcode.views.DocumentationViewer import DocumentationViewer -from clinicalcode.views import (View, Admin, adminTemp, - GenericEntity, Profile, Moderation, - Publish, Decline, site) + +from clinicalcode.views import ( + site, View, Admin, adminTemp, + GenericEntity, Moderation, Profile, Organisation +) + +from clinicalcode.views.dashboard.targets import ( + BrandTarget, UserTarget, OrganisationTarget, + TemplateTarget, TagTarget, HDRNSiteTarget, + HDRNCategoryTarget, HDRNDataAssetTarget, HDRNJurisdictionTarget +) + # Main urlpatterns = [ @@ -27,7 +31,7 @@ url(r'^privacy-and-cookie-policy/$', View.cookiespage, name='privacy_and_cookie_policy'), ## Technical - url(r'^reference-data/$', View.reference_data, name='reference_data'), + url(r'^reference-data/$', View.ReferenceData.as_view(), name='reference_data'), url(r'^technical_documentation/$', View.technicalpage, name='technical_documentation'), ## Cookies @@ -35,32 +39,57 @@ ## About pages url(r'^about/(?P<pg_name>([A-Za-z0-9\-\_]+))/$', View.brand_about_index_return, name='about_page'), - - ## Changing password(s) - url( - route='^change-password/$', - view=auth_views.PasswordChangeView.as_view(), - name='password_change', - kwargs={ 'post_change_redirect': 'concept_library_home' } - ), - - # GenericEnities (Phenotypes) - ## Search - url(r'^phenotypes/$', GenericEntity.EntitySearchView.as_view(), name='search_phenotypes'), - # url(r'^phenotypes/(?P<entity_type>([A-Za-z0-9\-]+))/?$', GenericEntity.EntitySearchView.as_view(), name='search_phenotypes'), - - ## Detail - url(r'^phenotypes/(?P<pk>\w+)/$', RedirectView.as_view(pattern_name='entity_detail'), name='entity_detail_shortcut'), - url(r'^phenotypes/(?P<pk>\w+)/detail/$', GenericEntity.generic_entity_detail, name='entity_detail'), - url(r'^phenotypes/(?P<pk>\w+)/version/(?P<history_id>\d+)/detail/$', GenericEntity.generic_entity_detail, name='entity_history_detail'), - url(r'^phenotypes/(?P<pk>\w+)/export/codes/$', GenericEntity.export_entity_codes_to_csv, name='export_entity_latest_version_codes_to_csv'), - url(r'^phenotypes/(?P<pk>\w+)/version/(?P<history_id>\d+)/export/codes/$', GenericEntity.export_entity_codes_to_csv, name='export_entity_version_codes_to_csv'), + ## Moderation + url(r'^moderation/$', Moderation.EntityModeration.as_view(), name='moderation_page'), - ## Profile - url(r'profile/$', Profile.MyCollection.as_view(), name='my_profile'), - url(r'profile/collection/$', Profile.MyCollection.as_view(), name='my_collection'), + ## Contact + url(r'^contact-us/$', View.contact_us, name='contact_us'), + # User + ## Profile + url(r'^profile/$', Profile.MyCollection.as_view(), name='my_profile'), + url(r'^profile/collection/$', Profile.MyCollection.as_view(), name='my_collection'), + url(r'^profile/organisations/$', Profile.MyOrganisations.as_view(), name='my_organisations'), + + ## Organisation + url(r'^org/view/(?P<slug>([\w\d\-\_]+))/?$', Organisation.OrganisationView.as_view(), name='view_organisation'), + # url(r'^org/create/?$', Organisation.OrganisationCreateView.as_view(), name='create_organisation'), + url(r'^org/manage/(?P<slug>([\w\d\-\_]+))/?$', Organisation.OrganisationManageView.as_view(), name='manage_organisation'), + url(r'^org/invite/(?P<uuid>([\w\d\-\_]+))/?$', Organisation.OrganisationInviteView.as_view(), name='view_invite_organisation'), + + # Brand + ## Brand Administration + ### Endpoints: dashboard view controllers + url(r'^dashboard/$', BrandAdmin.BrandDashboardView.as_view(), name=BrandAdmin.BrandDashboardView.reverse_name), + url(r'^dashboard/view/overview/$', BrandAdmin.BrandOverviewView.as_view(), name=BrandAdmin.BrandOverviewView.reverse_name), + url(r'^dashboard/view/inventory/$', BrandAdmin.BrandInventoryView.as_view(), name=BrandAdmin.BrandInventoryView.reverse_name), + + ### Endpoints: dashboard model administration + url(r'^dashboard/target/brand/$', BrandTarget.BrandEndpoint.as_view(), name=BrandTarget.BrandEndpoint.reverse_name_default), + + url(r'^dashboard/target/users/$', UserTarget.UserEndpoint.as_view(), name=UserTarget.UserEndpoint.reverse_name_default), + url(r'^dashboard/target/users/(?P<pk>\w+)/$', UserTarget.UserEndpoint.as_view(), name=UserTarget.UserEndpoint.reverse_name_retrieve), + + url(r'^dashboard/target/organisations/$', OrganisationTarget.OrganisationEndpoint.as_view(), name=OrganisationTarget.OrganisationEndpoint.reverse_name_default), + url(r'^dashboard/target/organisations/(?P<pk>\w+)/$', OrganisationTarget.OrganisationEndpoint.as_view(), name=OrganisationTarget.OrganisationEndpoint.reverse_name_retrieve), + + url(r'^dashboard/target/template/$', TemplateTarget.TemplateEndpoint.as_view(), name=TemplateTarget.TemplateEndpoint.reverse_name_default), + url(r'^dashboard/target/template/(?P<pk>\w+)/$', TemplateTarget.TemplateEndpoint.as_view(), name=TemplateTarget.TemplateEndpoint.reverse_name_retrieve), + + url(r'^dashboard/target/tags/$', TagTarget.TagEndpoint.as_view(), name=TagTarget.TagEndpoint.reverse_name_default), + url(r'^dashboard/target/tags/(?P<pk>\w+)/$', TagTarget.TagEndpoint.as_view(), name=TagTarget.TagEndpoint.reverse_name_retrieve), + + url(r'^dashboard/target/sites/$', HDRNSiteTarget.HDRNSiteEndpoint.as_view(), name=HDRNSiteTarget.HDRNSiteEndpoint.reverse_name_default), + url(r'^dashboard/target/sites/(?P<pk>\w+)/$', HDRNSiteTarget.HDRNSiteEndpoint.as_view(), name=HDRNSiteTarget.HDRNSiteEndpoint.reverse_name_retrieve), + url(r'^dashboard/target/jurisdictions/$', HDRNJurisdictionTarget.HDRNJurisdictionEndpoint.as_view(), name=HDRNJurisdictionTarget.HDRNJurisdictionEndpoint.reverse_name_default), + url(r'^dashboard/target/jurisdictions/(?P<pk>\w+)/$', HDRNJurisdictionTarget.HDRNJurisdictionEndpoint.as_view(), name=HDRNJurisdictionTarget.HDRNJurisdictionEndpoint.reverse_name_retrieve), + url(r'^dashboard/target/category/$', HDRNCategoryTarget.HDRNCategoryEndpoint.as_view(), name=HDRNCategoryTarget.HDRNCategoryEndpoint.reverse_name_default), + url(r'^dashboard/target/category/(?P<pk>\w+)/$', HDRNCategoryTarget.HDRNCategoryEndpoint.as_view(), name=HDRNCategoryTarget.HDRNCategoryEndpoint.reverse_name_retrieve), + url(r'^dashboard/target/data_assets/$', HDRNDataAssetTarget.HDRNDataAssetEndpoint.as_view(), name=HDRNDataAssetTarget.HDRNDataAssetEndpoint.reverse_name_default), + url(r'^dashboard/target/data_assets/(?P<pk>\w+)/$', HDRNDataAssetTarget.HDRNDataAssetEndpoint.as_view(), name=HDRNDataAssetTarget.HDRNDataAssetEndpoint.reverse_name_retrieve), + + # GenericEnities (GenericEntity) ## Selection service(s) url(r'^query/(?P<template_id>\w+)/?$', GenericEntity.EntityDescendantSelection.as_view(), name='entity_descendants'), @@ -71,24 +100,13 @@ ## Documentation for create url(r'^documentation/(?P<documentation>([A-Za-z0-9\-]+))/?$', DocumentationViewer.as_view(), name='documentation_viewer'), - ## Moderation - url(r'moderation/$', Moderation.EntityModeration.as_view(), name='moderation_page'), - - ## Contact - url(r'^contact-us/$', View.contact_us, name='contact_us'), - - # GenericEnities (Phenotypes) ## Create / Update url(r'^create/$', GenericEntity.CreateEntityView.as_view(), name='create_phenotype'), url(r'^create/(?P<template_id>[\d]+)/?$', GenericEntity.CreateEntityView.as_view(), name='create_phenotype'), url(r'^update/(?P<entity_id>\w+)/(?P<entity_history_id>\d+)/?$', GenericEntity.CreateEntityView.as_view(), name='update_phenotype'), - - ## Publication - url(r'^phenotypes/(?P<pk>\w+)/(?P<history_id>\d+)/publish/$', Publish.Publish.as_view(),name='generic_entity_publish'), - url(r'^phenotypes/(?P<pk>\w+)/(?P<history_id>\d+)/decline/$', Decline.EntityDecline.as_view(),name='generic_entity_decline'), - url(r'^phenotypes/(?P<pk>\w+)/(?P<history_id>\d+)/submit/$', Publish.RequestPublish.as_view(),name='generic_entity_request_publish'), ] + # Add sitemaps & robots if required (only for HDRUK .org site (or local dev.) -- check is done in site.py) urlpatterns += [ url(r'^robots.txt/$', site.robots_txt, name='robots.txt'), @@ -115,9 +133,13 @@ # url(r'^adminTemp/admin_force_links_dt/$', adminTemp.admin_force_concept_linkage_dt, name='admin_force_links_dt'), # url(r'^adminTemp/admin_fix_breathe_dt/$', adminTemp.admin_fix_breathe_dt, name='admin_fix_breathe_dt'), # url(r'^adminTemp/admin_fix_malformed_codes/$', adminTemp.admin_fix_malformed_codes, name='admin_fix_malformed_codes'), + #url(r'^adminTemp/admin_update_phenoflowids/$', adminTemp.admin_update_phenoflowids, name='admin_update_phenoflowids'), url(r'^adminTemp/admin_force_adp_links/$', adminTemp.admin_force_adp_linkage, name='admin_force_adp_links'), url(r'^adminTemp/admin_fix_coding_system_linkage/$', adminTemp.admin_fix_coding_system_linkage, name='admin_fix_coding_system_linkage'), url(r'^adminTemp/admin_fix_concept_linkage/$', adminTemp.admin_fix_concept_linkage, name='admin_fix_concept_linkage'), url(r'^adminTemp/admin_force_brand_links/$', adminTemp.admin_force_brand_links, name='admin_force_brand_links'), - url(r'^adminTemp/admin_update_phenoflowids/$', adminTemp.admin_update_phenoflowids, name='admin_update_phenoflowids'), + url(r'^adminTemp/admin_update_phenoflow_targets/$', adminTemp.admin_update_phenoflow_targets, name='admin_update_phenoflow_targets'), + url(r'^adminTemp/admin_upload_hdrn_assets/$', adminTemp.admin_upload_hdrn_assets, name='admin_upload_hdrn_assets'), + url(r'^adminTemp/admin_convert_entity_groups/$', adminTemp.admin_convert_entity_groups, name='admin_convert_entity_groups'), + url(r'^adminTemp/admin_fix_icd_ca_cm_codes/$', adminTemp.admin_fix_icd_ca_cm_codes, name='admin_fix_icd_ca_cm_codes'), ] diff --git a/CodeListLibrary_project/clinicalcode/urls_account.py b/CodeListLibrary_project/clinicalcode/urls_account.py new file mode 100644 index 000000000..fd0218fcc --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/urls_account.py @@ -0,0 +1,64 @@ +"""URLs & Views _assoc._ with user accounts and their management.""" + +from django.urls import re_path as url +from django.contrib.auth import views as auth_views + +from clinicalcode.views.Account import AccountPasswordResetView, AccountResetConfirmView, AccountManagementResultView, AccountPasswordResetForm + +# Account interface +urlpatterns = [ + ## Sign in/out + url('login/', auth_views.LoginView.as_view(), name='login'), + url('logout/', auth_views.LogoutView.as_view(), name='logout'), + + ## Reset password request + url( + route=r'^password_reset/$', + view=AccountPasswordResetView.as_view(template_name='registration/request_reset.html'), + name='password_reset', + ), + url( + route=r'^password_reset/done/$', + view=AccountManagementResultView.as_view( + template_title='Password Reset Request', + template_target='registration/messages/reset_requested.html', + ), + name='password_reset_done' + ), + + ## Reset password impl + url( + route=r'^reset/(?P<uidb64>[_\-A-Za-z0-9+\/=]+)/(?P<token>[_\-A-Za-z0-9+\/=]+)/$', + view=AccountResetConfirmView.as_view(template_name='registration/reset_form.html'), + name='password_reset_confirm', + ), + url( + route=r'^reset/done/$', + view=AccountManagementResultView.as_view( + requires_auth=True, + template_title='Password Reset Success', + template_target='registration/messages/reset_done.html', + template_prompt_signin=False, + template_incl_redirect=False, + ), + name='password_reset_complete' + ), + + ## Change password + url( + route=r'^password_change/$', + view=auth_views.PasswordChangeView.as_view(template_name='registration/change_form.html'), + name='password_change' + ), + url( + route=r'^password_change/done/$', + view=AccountManagementResultView.as_view( + requires_auth=True, + template_title='Password Change Success', + template_target='registration/messages/change_done.html', + template_prompt_signin=False, + template_incl_redirect=False, + ), + name='password_change_done' + ), +] diff --git a/CodeListLibrary_project/clinicalcode/views/Account.py b/CodeListLibrary_project/clinicalcode/views/Account.py new file mode 100644 index 000000000..85ba7af21 --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/views/Account.py @@ -0,0 +1,236 @@ +"""Views relating to Django User account management.""" + +from django.conf import settings +from django.urls import reverse_lazy +from django.http import HttpResponseRedirect, JsonResponse +from django.core.mail import EmailMultiAlternatives +from django.utils.http import urlsafe_base64_decode +from django.contrib.auth import login as auth_login +from django.contrib.auth import get_user_model +from django.template.loader import render_to_string +from django.core.exceptions import PermissionDenied, ImproperlyConfigured, ValidationError +from django.utils.decorators import method_decorator +from django.utils.translation import gettext_lazy as _ +from django.contrib.auth.views import PasswordResetView +from django.contrib.auth.forms import SetPasswordForm, PasswordResetForm +from django.views.generic.edit import FormView +from django.views.generic.base import TemplateView +from django.contrib.auth.views import PasswordContextMixin +from django.contrib.auth.tokens import default_token_generator +from django.views.decorators.csrf import csrf_protect +from django.views.decorators.cache import never_cache +from django.views.decorators.debug import sensitive_post_parameters +from django.contrib.auth.decorators import login_not_required + +import logging + +from clinicalcode.entity_utils import email_utils, model_utils + + +# State +logger = logging.getLogger(__name__) + +UserModel = get_user_model() + + +# Const +INTERNAL_RESET_SESSION_TOKEN = '_password_reset_token' + + +# Forms +class AccountPasswordResetForm(PasswordResetForm): + """See Django :form:`PasswordResetForm` base""" + def __init__(self, *args, **kwargs): + if not hasattr(self, 'request'): + self.request = kwargs.pop('request', None) if 'request' in kwargs else None + + super(AccountPasswordResetForm, self).__init__(*args, **kwargs) + + def send_mail( + self, + subject_template_name, + email_template_name, + context, + from_email, + to_email, + html_email_template_name=None, + ): + """Sends the password reset e-mail""" + request = getattr(self, 'request', None) if hasattr(self, 'request') else None + + brand = model_utils.try_get_brand(request) + brand_title = model_utils.try_get_brand_string(brand, 'site_title', default='Concept Library') + + email_subject = f'{brand_title} - Password Reset' + email_content = render_to_string( + 'clinicalcode/email/password_reset_email.html', + context, + request=request + ) + + user = context.get('user') + uname = user.username if user else None + logger.info(f'Sending AccPWD Reset, with target: User<name: {uname}, email: {to_email}, req: {request}>') + + if not settings.IS_DEVELOPMENT_PC or settings.HAS_MAILHOG_SERVICE: + try: + branded_imgs = email_utils.get_branded_email_images(brand) + + msg = EmailMultiAlternatives(email_subject, email_content, settings.DEFAULT_FROM_EMAIL, to=[to_email]) + msg.content_subtype = 'related' + msg.attach_alternative(email_content, 'text/html') + + msg.attach(email_utils.attach_image_to_email(branded_imgs.get('apple', 'img/email_images/apple-touch-icon.jpg'), 'mainlogo')) + msg.attach(email_utils.attach_image_to_email(branded_imgs.get('logo', 'img/email_images/combine.jpg'), 'sponsors')) + msg.send() + except Exception as e: + logger.error(f'AccPWD exception {e}') + logger.exception( + 'Failed to send password reset email to %s', context['user'].pk + ) + else: + logger.info(f'Successfully sent AccPWD email with target: User<name: {uname}, email: {to_email}, sub: {email_subject}>') + return True + else: + logger.info(f'[DEMO] Successfully sent AccPWD email with target: User<name: {uname}, email: {to_email}, sub: {email_subject}>') + return True + + +# Views +@method_decorator(login_not_required, name='dispatch') +class AccountPasswordResetView(PasswordResetView): + """See Django :form:`PasswordResetView` base""" + form_class = AccountPasswordResetForm + + def get_form_kwargs(self): + kwargs = super(AccountPasswordResetView, self).get_form_kwargs() + kwargs['request'] = self.request + return kwargs + + @method_decorator(csrf_protect) + def dispatch(self, *args, **kwargs): + return super().dispatch(*args, **kwargs) + + +class AccountManagementResultView(TemplateView): + """ + Simple account management result page. + + Note: + - Intention is to display success/failure message relating to `Django Account Management <https://github.com/django/django/tree/ef6a83789b310a441237a190a493c9586a4cb260/django/contrib/admin/templates/registration>`__ registration pages; + - The content of this page can be modified using the `as_view()` method of the `TemplateView` class. + """ + requires_auth = False + template_name = 'registration/management_result.html' + + template_title = _('Account Management') + template_header = None + template_target = None + template_message = '<p>%(msg)s</p>' % { 'msg': _('Account property changed') } + template_prompt_signin = True + template_incl_redirect = True + + def dispatch(self, request, *args, **kwargs): + if self.requires_auth and (not request.user or not request.user.is_authenticated): + raise PermissionDenied + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + return context | { + 'template_title': self.template_title, + 'template_header': self.template_header, + 'template_target': self.template_target, + 'template_message': self.template_message, + 'template_prompt_signin': self.template_prompt_signin and not self.requires_auth, + 'template_incl_redirect': self.template_incl_redirect, + } + + +class AccountResetConfirmView(PasswordContextMixin, FormView): + """Password reset form & view.""" + + title = _('Enter new password') + form_class = SetPasswordForm + success_url = reverse_lazy('password_reset_complete') + template_name = 'registration/reset_form.html' + + post_reset_login = True + post_reset_login_backend = None + + reset_url_token = 'set-password' + token_generator = default_token_generator + + @method_decorator(sensitive_post_parameters()) + @method_decorator(never_cache) + def dispatch(self, *args, **kwargs): + if 'uidb64' not in kwargs or 'token' not in kwargs: + raise ImproperlyConfigured( + 'The URL path must contain \'uidb64\' and \'token\' parameters.' + ) + + self.validlink = False + self.user = self.get_user(kwargs['uidb64']) + + if self.user is not None: + token = kwargs['token'] + if token == self.reset_url_token: + session_token = self.request.session.get(INTERNAL_RESET_SESSION_TOKEN) + if self.token_generator.check_token(self.user, session_token): + self.validlink = True + return super().dispatch(*args, **kwargs) + else: + if self.token_generator.check_token(self.user, token): + self.request.session[INTERNAL_RESET_SESSION_TOKEN] = token + redirect_url = self.request.path.replace( + token, self.reset_url_token + ) + return HttpResponseRedirect(redirect_url) + + return self.render_to_response(self.get_context_data()) + + def get_user(self, uidb64): + try: + uid = urlsafe_base64_decode(uidb64).decode() + pk = UserModel._meta.pk.to_python(uid) + user = UserModel._default_manager.get(pk=pk) + except ( + TypeError, + ValueError, + OverflowError, + UserModel.DoesNotExist, + ValidationError, + ): + user = None + return user + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['user'] = self.user + return kwargs + + def form_invalid(self, form): + response = super().form_invalid(form) + if self.request.accepts('text/html'): + return response + return JsonResponse(form.errors, status=400) + + def form_valid(self, form): + user = form.save() + del self.request.session[INTERNAL_RESET_SESSION_TOKEN] + if self.post_reset_login: + auth_login(self.request, user, self.post_reset_login_backend) + return super().form_valid(form) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + if self.validlink: + context['validlink'] = True + else: + context.update({ + 'form': None, + 'title': _('Password reset unsuccessful'), + 'validlink': False, + }) + + return context diff --git a/CodeListLibrary_project/clinicalcode/views/Admin.py b/CodeListLibrary_project/clinicalcode/views/Admin.py index 083016e48..195690c5d 100644 --- a/CodeListLibrary_project/clinicalcode/views/Admin.py +++ b/CodeListLibrary_project/clinicalcode/views/Admin.py @@ -1,19 +1,21 @@ -from django.db.models import Q +from http import HTTPStatus +from celery import shared_task from django.conf import settings -from django.contrib.auth.models import AnonymousUser -from django.core.exceptions import PermissionDenied -from django.core.exceptions import BadRequest from django.test import RequestFactory +from django.db.models import Q from django.shortcuts import render from django.views.generic import TemplateView -from django.contrib.auth.decorators import login_required +from django.core.exceptions import BadRequest +from django.core.exceptions import PermissionDenied from django.utils.decorators import method_decorator -from celery import shared_task +from django.contrib.auth.models import AnonymousUser +from django.contrib.auth.decorators import login_required -import json +import time import logging import requests +from clinicalcode.entity_utils import gen_utils from clinicalcode.models.DataSource import DataSource from ..entity_utils import permission_utils, stats_utils @@ -22,26 +24,21 @@ ### Entity Statistics class EntityStatisticsView(TemplateView): - """ - Admin job panel to save statistics for templates across entities - """ + """Admin job panel to save statistics for templates across entities""" @method_decorator([login_required, permission_utils.redirect_readonly]) def get(self, request, *args, **kwargs): if not request.user.is_superuser: raise PermissionDenied - stats_utils.collect_statistics(request) - context = { + stat = stats_utils.collect_statistics(request) + return render(request, 'clinicalcode/admin/run_statistics.html', { 'successMsg': ['Filter statistics for Concepts/Phenotypes saved'], - } - - return render(request, 'clinicalcode/admin/run_statistics.html', context) + 'stat': stat, + }) def run_homepage_statistics(request): - """ - save home page statistics - """ + """Manual run for administrators to save home page statistics""" if not request.user.is_superuser: raise PermissionDenied @@ -55,45 +52,147 @@ def run_homepage_statistics(request): 'clinicalcode/admin/run_statistics.html', { 'successMsg': ['Homepage statistics saved'], - 'stat': stat + 'stat': stat, } ) raise BadRequest + ##### Datasources +def query_hdruk_datasource( + page=1, + req_timeout=30, + retry_attempts=3, + retry_delay=2, + retry_codes=[ + HTTPStatus.REQUEST_TIMEOUT.value, + HTTPStatus.TOO_MANY_REQUESTS.value, + HTTPStatus.BAD_GATEWAY.value, + HTTPStatus.SERVICE_UNAVAILABLE.value, + HTTPStatus.GATEWAY_TIMEOUT.value, + ] +): + """ + Attempts to query HDRUK HealthDataGateway API + + Args: + page (int): the page id to query + req_timeout (int): request timeout (in seconds) + retry_attempts (int): max num. of attempts for each page request + retry_delay (int): timeout, in seconds, between retry attempts (backoff algo) + retry_codes (list): a list of ints specifying HTTP Status Codes from which to trigger retry attempts + + Returns: + A dict containing the assoc. data and page bounds, if applicable + """ + url = f'https://api.healthdatagateway.org/api/v1/datasets/?page={page}' + proxy = { + 'http': '' if settings.IS_DEVELOPMENT_PC else 'http://proxy:8080/', + 'https': '' if settings.IS_DEVELOPMENT_PC else 'http://proxy:8080/' + } + + response = None + retry_attempts = max(retry_attempts, 0) + 1 + for attempts in range(0, retry_attempts, 1): + try: + response = requests.get(url, req_timeout, proxies=proxy) + if retry_attempts - attempts > 0 and (not retry_codes or response.status_code in retry_codes): + time.sleep(retry_delay*pow(2, attempts - 1)) + continue + except requests.exceptions.ConnectionError: + pass + + if not response or response.status_code != 200: + status = response.status_code if response else 'INT_ERR' + raise Exception(f'Err response from server, Status<code: {status}, attempts_made: {retry_attempts}>') + + result = response.json() + if not isinstance(result, dict): + raise Exception(f'Invalid resultset, expected result as `dict` but got `{type(result)}`') + + last_page = result.get('last_page') + if not isinstance(last_page, int): + raise TypeError(f'Invalid resultset, expected `last_page` as `int` but got `{type(last_page)}`') + + data = result.get('data') + if not isinstance(data, list): + raise TypeError(f'Invalid resultset, expected `data` as `list` but got `{type(last_page)}`') + + return { 'data': data, 'last_page': last_page, 'page': page } + + +def collect_datasources(data): + """ + Safely parses datasource records from a resultset page + + Args: + data (list): a list specifying the datasources contained by a resultset + + Returns: + A dict containing the parsed data + """ + sources = {} + for ds in data: + meta = ds.get('latest_metadata') + idnt = ds.get('id') + uuid = ds.get('pid') + + if not isinstance(meta, dict) or not isinstance(idnt, int) or not isinstance(uuid, str): + continue + + meta = meta.get('metadata').get('metadata') if isinstance(meta.get('metadata'), dict) else None + meta = meta.get('summary') if isinstance(meta, dict) else None + if meta is None or not isinstance(meta.get('title'), str): + continue + + name = meta.get('title') + if not isinstance(name, str) or gen_utils.is_empty_string(name): + continue + + sources[uuid] = { + 'id': idnt, + 'uuid': uuid, + 'name': name[:500].strip(), + 'description': meta.get('description').strip()[:500] if isinstance(meta.get('description'), str) else None, + 'url': f'https://healthdatagateway.org/en/dataset/{idnt}', + } + + return sources + def get_hdruk_datasources(): + """ + Attempts to collect HDRUK datasources via its API + + Returns: + A tuple variant specifying the resulting datasources, specified as a dict, and an optional err message, defined as a string, if an err occurs + """ try: - result = requests.get( - 'https://api.www.healthdatagateway.org/api/v2/datasets', - proxies={ - 'http': '' if settings.IS_DEVELOPMENT_PC else 'http://proxy:8080/', - 'https': '' if settings.IS_DEVELOPMENT_PC else 'http://proxy:8080/' - } - ) + result = query_hdruk_datasource(page=1) except Exception as e: - return {}, 'Unable to sync HDRUK datasources, failed to reach api' - - datasources = {} - if result.status_code == 200: - datasets = json.loads(result.content)['datasets'] - - for dataset in datasets: - if 'pid' in dataset and 'datasetv2' in dataset: - dataset_name = dataset['datasetv2']['summary']['title'].strip() - dataset_uid = dataset['pid'].strip() - dataset_url = 'https://web.www.healthdatagateway.org/dataset/%s' % dataset_uid - dataset_description = dataset['datasetv2']['summary']['abstract'].strip() - - datasources[dataset_uid] = { - 'name': dataset_name if dataset_name != '' else dataset['datasetfields']['metadataquality']['title'].strip(), - 'url': dataset_url, - 'description': dataset_description - } + msg = f'Unable to sync HDRUK datasources, failed to reach api with err:\n\n{str(e)}' + logger.warning(msg) + + return {}, msg + + datasources = collect_datasources(result.get('data')) + for page in range(2, result.get('last_page') + 1, 1): + try: + result = query_hdruk_datasource(page=page) + datasources |= collect_datasources(result.get('data')) + except Exception as e: + logger.warning(f'Failed to retrieve HDRUK DataSource @ Page[{page}] with err:\n\n{str(e)}') + return datasources, None def create_or_update_internal_datasources(): + """ + Attempts to sync the DataSource model with those resolved from the HDRUK HealthDataGateway API + + Returns: + Either (a) an err message (str), or (b) a dict specifying the result of the diff + """ hdruk_datasources, error_message = get_hdruk_datasources() if error_message: return error_message @@ -102,52 +201,57 @@ def create_or_update_internal_datasources(): 'created': [], 'updated': [] } + for uid, datasource in hdruk_datasources.items(): + idnt = datasource.get('id') + name = datasource.get('name') + try: - internal_datasource = DataSource.objects.filter(Q(uid__iexact=uid) | Q(name__iexact=datasource['name'])) + internal_datasource = DataSource.objects.filter( + Q(uid__iexact=uid) | \ + Q(name__iexact=name) | \ + Q(datasource_id=idnt) + ) except DataSource.DoesNotExist: internal_datasource = False - if internal_datasource: - for internal in internal_datasource: - if internal.source == 'HDRUK': - update_uid = internal.uid != uid - update_name = internal.name != datasource['name'] - update_url = internal.url != datasource['url'] - update_description = internal.description != datasource['description'][:500] - - if update_uid or update_name or update_url or update_description: - internal.uid = uid - internal.name = datasource['name'] - internal.url = datasource['url'] - internal.description = datasource['description'] - internal.save() - - results['updated'].append({ - 'uid': uid, - 'name': datasource['name'] - }) + if internal_datasource and internal_datasource.exists(): + for internal in internal_datasource.all(): + if internal.source != 'HDRUK': + continue + + desc = datasource['description'] if isinstance(datasource['description'], str) else internal.description + + update_id = internal.datasource_id != idnt + update_uid = internal.uid != uid + update_url = internal.url != datasource['url'] + update_name = internal.name != name + update_description = internal.description != desc + + if update_id or update_uid or update_url or update_name or update_description: + internal.uid = uid + internal.url = datasource['url'] + internal.name = name + internal.description = desc + internal.datasource_id = idnt + internal.save() + results['updated'].append({ 'id': idnt, 'name': name }) else: new_datasource = DataSource() new_datasource.uid = uid - new_datasource.name = datasource['name'] new_datasource.url = datasource['url'] - new_datasource.description = datasource['description'] + new_datasource.name = name + new_datasource.description = datasource['description'] if datasource['description'] else '' + new_datasource.datasource_id = idnt new_datasource.source = 'HDRUK' new_datasource.save() - - new_datasource.datasource_id = new_datasource.id - new_datasource.save() - - results['created'].append({ - 'uid': uid, - 'name': datasource['name'] - }) + results['created'].append({ 'id': idnt, 'name': name }) return results def run_datasource_sync(request): + """Manual run of the DataSource sync""" if settings.CLL_READ_ONLY: raise PermissionDenied @@ -158,6 +262,7 @@ def run_datasource_sync(request): 'successMsg': ['HDR-UK datasources synced'], 'result': results } + if isinstance(results, str): message = { 'errorMsg': [results] @@ -172,6 +277,7 @@ def run_datasource_sync(request): @shared_task(bind=True) def run_celery_datasource(self): + """Celery cron task, used to synchronise DataSources on a schedule""" request_factory = RequestFactory() my_url = r'^admin/run-datasource-sync/$' request = request_factory.get(my_url) @@ -180,6 +286,4 @@ def run_celery_datasource(self): request.CURRENT_BRAND = '' if request.method == 'GET': results = create_or_update_internal_datasources() - - return True,results - + return True, results diff --git a/CodeListLibrary_project/clinicalcode/views/Decline.py b/CodeListLibrary_project/clinicalcode/views/Decline.py index 97c0d50fe..4b17bebe4 100644 --- a/CodeListLibrary_project/clinicalcode/views/Decline.py +++ b/CodeListLibrary_project/clinicalcode/views/Decline.py @@ -53,7 +53,7 @@ def post(self, request, pk, history_id): data['form_is_valid'] = True data['approval_status'] = constants.APPROVAL_STATUS.REJECTED data['entity_name_requested'] = GenericEntity.history.get(id=pk, history_id=history_id).name - data = publish_utils.form_validation(request, data, history_id, pk, entity, checks) + data = publish_utils.form_validation(request, data, history_id, pk, published_entity, checks) except Exception as e: #print(e) data['form_is_valid'] = False diff --git a/CodeListLibrary_project/clinicalcode/views/GenericEntity.py b/CodeListLibrary_project/clinicalcode/views/GenericEntity.py index e4236d102..feb9c04ab 100644 --- a/CodeListLibrary_project/clinicalcode/views/GenericEntity.py +++ b/CodeListLibrary_project/clinicalcode/views/GenericEntity.py @@ -1,59 +1,60 @@ -''' +""" --------------------------------------------------------------------------- GENERIC-ENTITY VIEW --------------------------------------------------------------------------- -''' +""" from django.urls import reverse -from django.contrib import messages -from django.core.exceptions import BadRequest -from django.contrib.auth.decorators import login_required -from django.core.exceptions import PermissionDenied from django.http import HttpResponseNotFound, HttpResponseBadRequest -from django.http.response import HttpResponse, JsonResponse, Http404 +from collections import OrderedDict +from django.contrib import messages from django.shortcuts import render, redirect -from django.template.loader import render_to_string +from rest_framework.views import APIView from django.views.generic import TemplateView +from django.http.response import HttpResponse, JsonResponse, Http404 +from django.core.exceptions import BadRequest, PermissionDenied +from django.template.loader import render_to_string from django.utils.decorators import method_decorator -from rest_framework.views import APIView from rest_framework.decorators import schema -from collections import OrderedDict +from django.contrib.auth.decorators import login_required import csv import json -import logging import time +import logging from ..entity_utils import (concept_utils, entity_db_utils, permission_utils, template_utils, gen_utils, model_utils, create_utils, search_utils, constants) from clinicalcode.views import View +from clinicalcode.api.views.View import get_canonical_path_by_brand 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 -from clinicalcode.api.views.View import get_canonical_path_by_brand + logger = logging.getLogger(__name__) + class EntitySearchView(TemplateView): """ Entity single search view - - Responsible for: - -> Managing context of template and which entities to render - -> SSR of entities at initial GET request based on request params - -> AJAX-driven update of template based on request params (through JsonResponse) + + Note: + Responsibilities: + - Managing context of template and which entities to render + - SSR of entities at initial GET request based on request params + - AJAX-driven update of template based on request params (through JsonResponse) """ template_name = 'clinicalcode/generic_entity/search/search.html' result_template = 'components/search/results.html' pagination_template = 'components/search/pagination_container.html' def get_context_data(self, *args, **kwargs): - ''' - Provides contextful data to template based on request parameters - ''' + """Provides contextful data to template based on request parameters""" context = super(EntitySearchView, self).get_context_data(*args, **kwargs) request = self.request @@ -86,13 +87,14 @@ def get(self, request, *args, **kwargs): """ Manages get requests to this view - @note if search_filtered is passed as a parameter (through a fetch req), - the GET request will return the pagination and results - for hotreloading relevant search results instead of forcing - a page reload + Note: + If `search_filtered` is passed as a parameter (through a fetch req), + the `GET` request will return the pagination and results + for hotreloading relevant search results instead of forcing + a page reload. - in reality, we should change this to a JSON Response at some point - and make the client render it rather than wasting server resources + In reality, we should change this to a `JSON` Response at some point + and make the client render it rather than wasting server resources. """ context = self.get_context_data(*args, **kwargs) filtered = gen_utils.try_get_param(request, 'search_filtered', None) @@ -106,23 +108,20 @@ def get(self, request, *args, **kwargs): return render(request, self.template_name, context) + @schema(None) class EntityDescendantSelection(APIView): """ - Selection Service View - @desc API-like view for internal services to discern - template-related information and to retrieve - entity descendant data via search - - @note Could be moved to API in future? + API-like view for internal services to discern template-related information and to retrieve entity descendant data via search. + + TODO: + Could be moved to API in future? """ fetch_methods = ['get_filters', 'get_results'] ''' Private methods ''' def __get_template(self, template_id): - """ - Attempts to get the assoc. template if available or raises a bad request - """ + """Attempts to get the assoc. template if available or raises a bad request""" template = model_utils.try_get_instance(Template, pk=template_id) if template is None: raise BadRequest('Template ID is invalid') @@ -131,15 +130,11 @@ def __get_template(self, template_id): ''' View methods ''' @method_decorator([login_required, permission_utils.redirect_readonly]) def dispatch(self, request, *args, **kwargs): - """ - @desc Dispatch view if not in read-only and user is authenticated - """ + """Dispatch view if not in read-only and user is authenticated""" return super(EntityDescendantSelection, self).dispatch(request, *args, **kwargs) def get_context_data(self, *args, **kwargs): - """ - @desc Provides contextual data - """ + """Provides contextual data""" context = { } request = self.request @@ -149,10 +144,7 @@ def get_context_data(self, *args, **kwargs): return context | { 'template_id': template_id } def get(self, request, *args, **kwargs): - """ - @desc Handles GET requests made by the client and directs - the params to the appropriate method given the fetch target - """ + """Handles GET requests made by the client and directs the params to the appropriate method given the fetch target""" if gen_utils.is_fetch_request(request): method = gen_utils.handle_fetch_request(request, self, *args, **kwargs) return method(request, *args, **kwargs) @@ -160,9 +152,7 @@ def get(self, request, *args, **kwargs): ''' Fetch methods ''' def get_filters(self, request, *args, **kwargs): - """ - @desc Gets the filter specification for this template - """ + """Gets the filter specification for this template""" context = self.get_context_data(*args, **kwargs) template = self.__get_template(context.get('template_id')) @@ -175,23 +165,19 @@ def get_filters(self, request, *args, **kwargs): }) def get_results(self, request, *args, **kwargs): - """ - @desc Gets the search results for the desired template - after applying query params - """ + """Gets the search results for the desired template after applying query params""" context = self.get_context_data(*args, **kwargs) template_id = context.get('template_id') result = search_utils.get_template_entities(request, template_id) return JsonResponse(result) + class CreateEntityView(TemplateView): """ - Entity Create View - @desc Used to create entities + Used to create entities - @note CreateView isn't used due to the requirements - of having a form dynamically created to - reflect the dynamic model. + Note: + - `CreateView` isn't used due to the requirements of having a form dynamically created to reflect the dynamic model. """ fetch_methods = [ 'search_codes', 'get_options', 'import_rule', 'import_concept', @@ -205,9 +191,7 @@ class CreateEntityView(TemplateView): ''' View methods ''' @method_decorator([login_required, permission_utils.redirect_readonly]) def dispatch(self, request, *args, **kwargs): - """ - @desc Dispatch view - """ + """Dispatch view""" return super(CreateEntityView, self).dispatch(request, *args, **kwargs) def get_context_data(self, *args, **kwargs): @@ -220,11 +204,10 @@ def get_context_data(self, *args, **kwargs): @method_decorator([login_required, permission_utils.redirect_readonly]) def get(self, request, *args, **kwargs): """ - @desc Handles get requests by determining whether it was made - through the fetch method, or accessed via a browser. - - If requested via browser, will render a view. Otherwise - will respond with appropriate method, if applicable. + Handles get requests by determining whether it was made through the fetch method, or accessed via a browser. + + Note: + - If requested via browser, will render a view. Otherwise will respond with appropriate method, if applicable. """ if gen_utils.is_fetch_request(request): method = gen_utils.handle_fetch_request(request, self, *args, **kwargs) @@ -235,7 +218,7 @@ def get(self, request, *args, **kwargs): @method_decorator([login_required, permission_utils.redirect_readonly]) def post(self, request, *args, **kwargs): """ - @desc Handles form submissions for both: + Handles form submissions for both: - creating - updating """ @@ -277,11 +260,10 @@ def post(self, request, *args, **kwargs): ''' Main view render ''' def render_view(self, request, *args, **kwargs): """ - @desc Template and entity is tokenised in the URL - providing the latter requires - users to be permitted to modify that particular entity. + Template and entity is tokenised in the URL - providing the latter requires users to be permitted to modify that particular entity. - If no entity_id is passed, a creation form is returned, otherwise the user is - redirected to an update form. + Note: + - If no `entity_id` is passed, a creation form is returned, otherwise the user is redirected to an update form. """ context = self.get_context_data(*args, **kwargs) @@ -289,6 +271,9 @@ def render_view(self, request, *args, **kwargs): template_id = kwargs.get('template_id') entity_id = kwargs.get('entity_id') if template_id is None and entity_id is None: + if not permission_utils.can_user_create_entities(request): + raise PermissionDenied + return self.select_form(request, context) # Send to create form if template_id is selected @@ -297,6 +282,10 @@ def render_view(self, request, *args, **kwargs): template = model_utils.try_get_instance(Template, pk=template_id) if template is None or template.hide_on_create: raise Http404 + + if not permission_utils.can_user_create_entities(request): + raise PermissionDenied + return self.create_form(request, context, template) # Send to update form if entity_id is selected @@ -305,11 +294,11 @@ def render_view(self, request, *args, **kwargs): entity = create_utils.try_validate_entity(request, entity_id, entity_history_id) if not entity: raise Http404 - + template = entity.template if template is None: raise Http404 - + return self.update_form(request, context, template, entity) # Raise 400 if no param matches views @@ -317,25 +306,19 @@ def render_view(self, request, *args, **kwargs): ''' Forms ''' def select_form(self, request, context): - """ - @desc Renders the template selection form - """ + """Renders the template selection form""" context['entity_data'] = create_utils.get_createable_entities(request) return render(request, self.templates.get('select'), context) def create_form(self, request, context, template): - """ - @desc Renders the entity create form - """ + """Renders the entity create form""" context['metadata'] = constants.metadata context['template'] = template context['form_method'] = constants.FORM_METHODS.CREATE return render(request, self.templates.get('form'), context) def update_form(self, request, context, template, entity): - """ - @desc Renders the entity update form - """ + """Renders the entity update form""" context['metadata'] = constants.metadata context['template'] = template context['entity'] = entity @@ -347,10 +330,7 @@ def update_form(self, request, context, template, entity): ''' Fetch methods ''' def import_rule(self, request, *args, **kwargs): - """ - @desc GET request made by client to retrieve the codelist assoc. - with the concept they are attempting to import as a rule - """ + """GET request made by client to retrieve the codelist _assoc._ with the concept they are attempting to import as a rule""" concept_id = gen_utils.try_get_param(request, 'concept_id') concept_version_id = gen_utils.try_get_param(request, 'concept_version_id') if concept_id is None or concept_version_id is None: @@ -376,10 +356,7 @@ def import_rule(self, request, *args, **kwargs): }) def import_concept(self, request, *args, **kwargs): - """ - @desc GET request made by client to retrieve codelists assoc. - with the concepts they are attempting to import as top-level objects - """ + """GET request made by client to retrieve codelists _assoc._ with the concepts they are attempting to import as top-level objects""" concept_ids = gen_utils.try_get_param(request, 'concept_ids') concept_version_ids = gen_utils.try_get_param(request, 'concept_version_ids') if concept_ids is None or concept_version_ids is None: @@ -411,9 +388,8 @@ def query_ontology_record(self, request, *args, **kwargs): See: :func:`~clinicalcode.models.OntologyTag.OntologyTag.get_creation_data~` - Query Params: - node_id (int): the node id - + QueryParams: + node_id (int): the node id type_id (str|int): the node's type id Returns: @@ -448,8 +424,7 @@ def query_ontology_typeahead(self, request, *args, **kwargs): See: :func:`~clinicalcode.models.OntologyTag.OntologyTag.query_typeahead~` Query Params: - search (str): some web query search term; defaults to an empty `str` - + 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: @@ -476,12 +451,7 @@ def query_ontology_typeahead(self, request, *args, **kwargs): 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 - options for a given field within its template - - Atm, it is exclusively used to retrieve Coding Systems - """ + """GET request made by client to retrieve all available options for a given field within its template. Atm, it is exclusively used to retrieve Coding Systems""" template_id = gen_utils.parse_int(gen_utils.try_get_param(request, 'template'), default=None) if not template_id: return gen_utils.jsonify_response(message='Invalid template parameter', code=400, status='false') @@ -494,26 +464,32 @@ def get_options(self, request, *args, **kwargs): if field is None or gen_utils.is_empty_string(field): return gen_utils.jsonify_response(message='Invalid field parameter', code=400, status='false') - if template_utils.is_metadata(GenericEntity, field): - options = template_utils.get_template_sourced_values(constants.metadata, field, request=request) + info = template_utils.get_template_field_info(template, field) + struct = info.get('field') + if info.get('is_metadata'): + default_value = None + + if struct is not None: + validation = template_utils.try_get_content(struct, 'validation') + if validation is not None: + default_value = [] if validation.get('type') == 'int_array' else default_value + + options = template_utils.get_template_sourced_values(constants.metadata, field, request=request, default=default_value, struct=struct) else: - options = template_utils.get_template_sourced_values(template, field, request=request) - + options = template_utils.get_template_sourced_values(template, field, request=request, struct=struct) + if options is None: return gen_utils.jsonify_response(message='Invalid field parameter, does not exist or is not an optional parameter', code=400, status='false') - options = model_utils.append_coding_system_data(options) + if field == 'coding_system': + options = model_utils.append_coding_system_data(options) + return JsonResponse({ 'result': options }) def search_codes(self, request, *args, **kwargs): - """ - @desc GET request made by client to search a codelist given its coding id, - a search term, and the relevant template - - e.g. entity/{update|create}/?search=C1&coding_system=4&template=1 - """ + """GET request made by client to search a codelist given its coding id, a search term, and the relevant template, _e.g._ `entity/{update|create}/?search=C1&coding_system=4&template=1`""" template_id = gen_utils.parse_int(gen_utils.try_get_param(request, 'template'), default=None) if not template_id: return gen_utils.jsonify_response(message='Invalid template parameter', code=400, status='false') @@ -551,13 +527,13 @@ def search_codes(self, request, *args, **kwargs): 'result': codelist }) + class RedirectConceptView(TemplateView): """ - [!] Note: Used to maintain legacy URLs where users could visit concepts/<pk>/detail - - @desc Redirects requests to the phenotype page, assuming a phenotype owner - can be resolved from the child Concept + Redirects requests to the phenotype page, assuming a phenotype owner can be resolved from the child Concept + Note: + - Used to maintain legacy URLs where users could visit `concepts/C<pk>/detail` """ # URL Name of the detail page @@ -570,31 +546,61 @@ def get(self, request, *args, **kwargs): 2. Will then try to find its Phenotype owner 3. Finally, redirect the user to the Phenotype page """ + brand = model_utils.try_get_brand(request) + if brand is not None: + brand_mapping = brand.get_map_rules() + else: + brand_mapping = constants.DEFAULT_CONTENT_MAPPING + + tx_concept = brand_mapping.get('concept', 'Concept') + tx_phenotype = brand_mapping.get('phenotype', 'Phenotype') + concept_id = gen_utils.parse_int(kwargs.get('pk'), default=None) if concept_id is None: - raise Http404 - + return View.notify_err( + request, + title='Bad Request - Invalid ID', + status_code=400, + details=[f'The {tx_concept} ID you supplied is invalid. If we sent you here, please contact us and let us know.'] + ) + concept = model_utils.try_get_instance(Concept, id=concept_id) if concept is None: - raise Http404 - + return View.notify_err( + request, + title=f'Page Not Found - Missing {tx_concept}', + status_code=404, + details=[f'Sorry but it looks like this {tx_concept} doesn\'t exist. If we sent you here, please contact us and let us know.'] + ) + entity_owner = concept.phenotype_owner if entity_owner is None: - raise Http404 + return View.notify_err( + request, + title='Page Not Found - Failed To Resolve', + status_code=404, + details=[f'Sorry but it looks like this {tx_concept} isn\'t currently associated with a {tx_phenotype}. Please use the API to access the contents of this {tx_concept}.'] + ) return redirect(reverse(self.ENTITY_DETAIL_VIEW, kwargs={ 'pk': entity_owner.id })) + def generic_entity_detail(request, pk, history_id=None): - ''' - Display the detail of a generic entity at a point in time. - ''' + """Display the detail of a generic entity at a point in time.""" # validate pk param + brand = model_utils.try_get_brand(request) + if brand is not None: + brand_mapping = brand.get_map_rules() + else: + brand_mapping = constants.DEFAULT_CONTENT_MAPPING + + tx_phenotype = brand_mapping.get('phenotype', 'Phenotype') if not model_utils.get_entity_id(pk): return View.notify_err( request, - title='Bad request', + title='Bad Request - Invalid ID', status_code=400, - details=['Invalid Phenotype ID'] + details=[f'The {tx_phenotype} ID you supplied is invalid. If we sent you here, please contact us and let us know.'] ) # find latest accessible entity for given pk if historical id not specified @@ -603,13 +609,30 @@ def generic_entity_detail(request, pk, history_id=None): entities = permission_utils.get_accessible_entities(request, pk=pk) if not entities.exists(): - raise Http404 + return View.notify_err( + request, + title=f'Page Not Found - Missing {tx_phenotype}', + status_code=404, + details=[f'Sorry but it looks like this {tx_phenotype} doesn\'t exist. If we sent you here, please contact us and let us know.'] + ) else: history_id = entities.first().history_id - accessibility = permission_utils.get_accessible_detail_entity(request, pk, history_id) - if not accessibility or not accessibility.get('view_access'): - raise PermissionDenied + accessibility, err = permission_utils.get_accessible_detail_entity(request, pk, history_id) + if err is not None: + return View.notify_err( + request, + title=err.get('title'), + status_code=err.get('status_code'), + details=[err.get('message')] + ) + elif not accessibility or not accessibility.get('view_access'): + return View.notify_err( + request, + title='Forbidden - Permission Denied', + status_code=403, + details=[f'Sorry but it looks like this {tx_phenotype} hasn\'t been made accessible to you yet. Please contact the author of this {tx_phenotype} to grant you access.'] + ) entity = accessibility.get('historical_entity') live_entity = accessibility.get('entity') @@ -629,7 +652,7 @@ def generic_entity_detail(request, pk, history_id=None): published_historical_ids = entity_dataset.get('published_ids', []) template = entity.template.history.filter(template_version=entity.template_version).latest() - entity_class = template.entity_class.name + entity_class = template.entity_class if request.user.is_authenticated: can_edit = accessibility.get('edit_access', False) @@ -668,10 +691,7 @@ def generic_entity_detail(request, pk, history_id=None): def get_history_table_data(request, pk): - """" - get history table data for the template - """ - + """"Gets history table data for the template""" versions = GenericEntity.objects.get(pk=pk).history.all() historical_versions = [] @@ -697,7 +717,6 @@ def get_history_table_data(request, pk): if ver.approval_status != constants.APPROVAL_STATUS.ANY: ver.approval_status_label = [s.name for s in constants.APPROVAL_STATUS if s == ver.approval_status][0] - if request.user.is_authenticated: if permission_utils.can_user_edit_entity(request, pk) or permission_utils.can_user_view_entity(request, pk): historical_versions.append(ver) @@ -709,15 +728,24 @@ def get_history_table_data(request, pk): historical_versions.append(ver) return historical_versions - - + + def export_entity_codes_to_csv(request, pk, history_id=None): - """ - Return a csv file of codes for a clinical-coded phenotype for a specific historical version. - """ - if history_id is None: - # get the latest version/ or latest published version - history_id = permission_utils.try_get_valid_history_id(request, GenericEntity, pk) + """Returns a csv file of codes for a clinical-coded phenotype for a specific historical version.""" + + # get the latest version/ or latest published version + if not isinstance(history_id, int): + entities = permission_utils.get_accessible_entities(request, pk=pk) + + if not entities.exists(): + return View.notify_err( + request, + title='Page Not Found - Missing Codelist', + status_code=404, + details=['Sorry but it looks like this codelist doesn\'t exist.'] + ) + else: + history_id = entities.first().history_id # validate access for login and public site permission_utils.validate_access_to_view(request, pk, history_id) @@ -758,7 +786,7 @@ def export_entity_codes_to_csv(request, pk, history_id=None): 'creation_date': time.strftime("%Y%m%dT%H%M%S") } response = HttpResponse(content_type='text/csv') - response['Content-Disposition'] = ('attachment; filename="phenotype_%(phenotype_id)s_ver_%(history_id)s_concepts_%(creation_date)s.csv"' % my_params) + response['Content-Disposition'] = ('attachment; filename="%(phenotype_id)s_ver_%(history_id)s_codelists_%(creation_date)s.csv"' % my_params) writer = csv.writer(response) diff --git a/CodeListLibrary_project/clinicalcode/views/Moderation.py b/CodeListLibrary_project/clinicalcode/views/Moderation.py index 43d2cb177..515edec7c 100644 --- a/CodeListLibrary_project/clinicalcode/views/Moderation.py +++ b/CodeListLibrary_project/clinicalcode/views/Moderation.py @@ -1,13 +1,17 @@ from django.views.generic import TemplateView from django.shortcuts import render from django.db.models import Subquery, OuterRef -from django.contrib.auth.models import User, Group +from django.contrib.auth.models import Group from django.contrib.auth.decorators import login_required from django.utils.decorators import method_decorator from django.core.exceptions import PermissionDenied +from django.contrib.auth import get_user_model +from ..models.Organisation import Organisation from ..entity_utils import permission_utils, constants +User = get_user_model() + class EntityModeration(TemplateView): template_name = 'clinicalcode/moderation/index.html' template_fields = [ @@ -21,7 +25,7 @@ def __annotate_fields(self, queryset): annotated = queryset.annotate( group_name=Subquery( - Group.objects.filter(id=OuterRef('group_id')).values('name') + Organisation.objects.filter(id=OuterRef('organisation_id')).values('name') ), owner_name=Subquery( User.objects.filter(id=OuterRef('owner')).values('username') diff --git a/CodeListLibrary_project/clinicalcode/views/Organisation.py b/CodeListLibrary_project/clinicalcode/views/Organisation.py new file mode 100644 index 000000000..a4b0eb232 --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/views/Organisation.py @@ -0,0 +1,723 @@ +from datetime import datetime +from django.utils.timezone import make_aware +from django.views.generic import TemplateView, CreateView, UpdateView +from django.shortcuts import render, redirect +from django.conf import settings +from django.db.models import F, When, Case, Value, Subquery, OuterRef +from django.db import transaction, IntegrityError +from django.contrib.auth.models import Group +from django.contrib.auth.decorators import login_required +from django.utils.decorators import method_decorator +from django.http.response import JsonResponse, Http404 +from django.urls import reverse_lazy +from django.db import models, connection +from django.core.exceptions import BadRequest, PermissionDenied +from django.contrib.auth import get_user_model + +import logging + +from ..models.GenericEntity import GenericEntity +from ..models.Brand import Brand +from ..models.Organisation import Organisation, OrganisationMembership, OrganisationAuthority, OrganisationInvite +from ..forms.OrganisationForms import OrganisationCreateForm, OrganisationManageForm +from ..entity_utils import permission_utils, model_utils, gen_utils, constants, email_utils + +logger = logging.getLogger(__name__) + +''' Create Organisation ''' + +User = get_user_model() + +class OrganisationCreateView(CreateView): + model = Organisation + template_name = 'clinicalcode/organisation/create.html' + form_class = OrganisationCreateForm + + @method_decorator([login_required, permission_utils.redirect_readonly]) + def dispatch(self, request, *args, **kwargs): + return super(OrganisationCreateView, self).dispatch(request, *args, **kwargs) + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['label_suffix'] = '' + return kwargs + + def get_context_data(self, **kwargs): + context = super(OrganisationCreateView, self).get_context_data(**kwargs) + return context + + def get_initial(self): + self.initial.update({'owner': self.request.user}) + return self.initial + + def get_success_url(self): + resolve_target = self.request.GET.get('resolve-target') + if not gen_utils.is_empty_string(resolve_target): + return reverse_lazy(resolve_target) + return reverse_lazy('view_organisation', kwargs={ 'slug': self.object.slug }) + + @transaction.atomic + def form_valid(self, form): + form.instance.owner = self.request.user + obj = form.save() + + brand = self.request.BRAND_OBJECT + if isinstance(brand, Brand): + obj.brands.add( + brand, + through_defaults={ + 'can_post': False, + 'can_moderate': False + } + ) + + return super(OrganisationCreateView, self).form_valid(form) + +''' Manage Organisation ''' + +class OrganisationManageView(UpdateView): + model = Organisation + template_name = 'clinicalcode/organisation/manage.html' + form_class = OrganisationManageForm + fetch_methods = [ + 'change_user_role', 'delete_member', 'invite_member', + 'cancel_invite', 'get_reloaded_data' + ] + + @method_decorator([login_required, permission_utils.redirect_readonly]) + def dispatch(self, request, *args, **kwargs): + user = request.user + slug = kwargs.get('slug') + + has_access = permission_utils.has_member_org_access( + user, + slug, + constants.ORGANISATION_ROLES.ADMIN + ) + if not has_access: + raise PermissionDenied + + return super(OrganisationManageView, self).dispatch(request, *args, **kwargs) + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['label_suffix'] = '' + return kwargs + + def get_object(self, queryset=None): + if queryset is None: + queryset = self.get_queryset() + + slug = self.kwargs.get('slug') + try: + obj = queryset.filter(slug=slug).get() + except queryset.model.DoesNotExist: + raise Http404('No organisation found') + return obj + + def get_page_data(self, request): + user = request.user + roles = [{ 'name': x.name, 'value': x.value } for x in constants.ORGANISATION_ROLES] + + members = self.object.members.through.objects \ + .filter(organisation_id=self.object.id) \ + .annotate( + role_name=Case( + *[When(role=v.value, then=Value(v.name)) for v in constants.ORGANISATION_ROLES], + default=Value(constants.ORGANISATION_ROLES.MEMBER.name), + output_field=models.CharField() + ), + username=Subquery( + User.objects.filter( + id=OuterRef('user_id') + ) + .values('username') + ) + ) \ + .distinct('user_id') \ + .values('id', 'user_id', 'organisation_id', 'username', 'role', 'role_name') + members = list(members) + + invites = OrganisationInvite.objects.filter( + organisation_id=self.object.id, + outcome__in=[ + constants.ORGANISATION_INVITE_STATUS.ACTIVE, + constants.ORGANISATION_INVITE_STATUS.SEEN + ] + ) \ + .annotate( + username=Subquery( + User.objects.filter( + id=OuterRef('user_id') + ) + .values('username') + ) + ) \ + .values('id', 'user_id', 'username') + invites = list(invites) + + user_list = permission_utils.get_brand_related_users(request) \ + .exclude( + id__in=( + [user.id, self.object.owner_id] + + [member.get('user_id') for member in members] + + [invite.get('user_id') for invite in invites] + ) + ) \ + .values('id', 'username') + user_list = list(user_list) + + return { + 'roles': roles, + 'members': members, + 'invites': { + 'users': user_list, + 'active': invites, + 'oid': self.object.id + } + } + + def get_context_data(self, **kwargs): + self.object = self.get_object() + + context = super(OrganisationManageView, self).get_context_data(**kwargs) + context.update(instance=self.object) + + return context | self.get_page_data(self.request) + + def get_success_url(self): + resolve_target = self.request.GET.get('resolve-target') + if not gen_utils.is_empty_string(resolve_target): + return reverse_lazy(resolve_target) + return reverse_lazy('view_organisation', kwargs={ 'slug': self.object.slug }) + + @transaction.atomic + def form_valid(self, form): + obj = form.save() + + brand = self.request.BRAND_OBJECT + if isinstance(brand, Brand): + obj.brands.add( + brand, + through_defaults={ + 'can_post': False, + 'can_moderate': False + } + ) + + return super(OrganisationManageView, self).form_valid(form) + + @method_decorator([login_required, permission_utils.redirect_readonly]) + def get(self, request, *args, **kwargs): + if gen_utils.is_fetch_request(request): + target = request.headers.get('X-Target', None) + if target is not None and target in self.fetch_methods: + target = getattr(self, target) + return target(request, *args, **kwargs) + + return super().get(request, *args, **kwargs) + + def get_reloaded_data(self, request, *args, **kwargs): + self.object = self.get_object() + + ctx = self.get_page_data(request) + return JsonResponse(ctx) + + @method_decorator([login_required, permission_utils.redirect_readonly]) + def post(self, request, *args, **kwargs): + if gen_utils.is_fetch_request(request): + target = request.headers.get('X-Target', None) + if target is not None and target in self.fetch_methods: + target = getattr(self, target) + return target(request, *args, **kwargs) + + return super().post(request, *args, **kwargs) + + def change_user_role(self, request, *args, **kwargs): + body = gen_utils.get_request_body(request) + + uid = body.get('uid') + oid = body.get('oid') + rid = body.get('rid') + if not isinstance(uid, int) or not isinstance(oid, int) or not isinstance(rid, int): + return gen_utils.jsonify_response(code=400) + + if uid == request.user.id: + return gen_utils.jsonify_response(code=400) + + roles = [x.value for x in constants.ORGANISATION_ROLES] + if rid not in roles: + return gen_utils.jsonify_response(code=400) + + membership = OrganisationMembership.objects.filter( + user_id=uid, + organisation_id=oid + ) + if not membership.exists(): + return gen_utils.jsonify_response(code=400) + membership = membership.first() + + try: + membership.role = rid + membership.save() + except IntegrityError as e: + logger.warning( + f'Integrity error when attempting to change organisation roles: {body}> with err: {e}' + ) + return gen_utils.jsonify_response(code=400) + + return gen_utils.jsonify_response(code=200) + + def delete_member(self, request, *args, **kwargs): + body = gen_utils.get_request_body(request) + + uid = body.get('uid') + oid = body.get('oid') + if not isinstance(uid, int) or not isinstance(oid, int): + return gen_utils.jsonify_response(code=400) + + if uid == request.user.id: + return gen_utils.jsonify_response(code=400) + + membership = OrganisationMembership.objects.filter( + user_id=uid, + organisation_id=oid + ) + if not membership.exists(): + return gen_utils.jsonify_response(code=400) + membership = membership.first() + + try: + membership.delete() + except Exception as e: + logger.warning( + f'Integrity error when attempting to remove user from organisation: {body}> with err: {e}' + ) + return gen_utils.jsonify_response(code=400) + + return gen_utils.jsonify_response(code=200) + + def invite_member(self, request, *args, **kwargs): + body = gen_utils.get_request_body(request) + + uid = body.get('id') + oid = body.get('oid') + if not isinstance(uid, int) or not isinstance(oid, int): + return gen_utils.jsonify_response(code=400) + + membership = OrganisationMembership.objects.filter( + user_id=uid, + organisation_id=oid + ) + if membership.exists(): + return gen_utils.jsonify_response(code=400) + + current_invite = OrganisationInvite.objects.filter( + user_id=uid, + organisation_id=oid + ) \ + .exclude( + outcome__in=[ + constants.ORGANISATION_INVITE_STATUS.EXPIRED, + constants.ORGANISATION_INVITE_STATUS.ACCEPTED, + constants.ORGANISATION_INVITE_STATUS.REJECTED + ] + ) + if current_invite.exists(): + return gen_utils.jsonify_response(code=400) + + try: + invite = OrganisationInvite.objects.create( + user_id=uid, + organisation_id=oid + ) + + has_sent = email_utils.send_invite_email(request, invite) + if has_sent: + invite.sent = True + invite.save() + except Exception as e: + logger.warning( + f'Integrity error when attempting to invite user to organisation: {body}> with err: {e}' + ) + return gen_utils.jsonify_response(code=400) + + return gen_utils.jsonify_response(code=200) + + def cancel_invite(self, request, *args, **kwargs): + body = gen_utils.get_request_body(request) + + uid = body.get('uid') + oid = body.get('oid') + if not isinstance(uid, int) or not isinstance(oid, int): + return gen_utils.jsonify_response(code=400) + + current_invite = OrganisationInvite.objects.filter( + user_id=uid, + organisation_id=oid + ) \ + .exclude( + outcome__in=[ + constants.ORGANISATION_INVITE_STATUS.EXPIRED, + constants.ORGANISATION_INVITE_STATUS.REJECTED + ] + ) + if current_invite.exists(): + current_invite = current_invite.first() + + try: + current_invite.outcome = constants.ORGANISATION_INVITE_STATUS.REJECTED + current_invite.save() + except IntegrityError as e: + logger.warning( + f'Integrity error when attempting to delete organisation invite: {body}> with err: {e}' + ) + return gen_utils.jsonify_response(code=400) + + return gen_utils.jsonify_response(code=200) + +''' View Organisation ''' + +class OrganisationView(TemplateView): + template_name = 'clinicalcode/organisation/view.html' + fetch_methods = [ + 'leave_organisation' + ] + + def dispatch(self, request, *args, **kwargs): + return super(OrganisationView, self).dispatch(request, *args, **kwargs) + + def get_context_data(self, *args, **kwargs): + context = super(OrganisationView, self).get_context_data(*args, **kwargs) + request = self.request + + user = request.user if request.user and not request.user.is_anonymous else None + + current_brand = model_utils.try_get_brand(request) + current_brand = current_brand if current_brand and current_brand.org_user_managed else None + + slug = kwargs.get('slug') + if not gen_utils.is_empty_string(slug): + organisation = Organisation.objects.filter(slug=slug) + if organisation.exists(): + organisation = organisation.first() + + members = organisation.members.through.objects \ + .filter(organisation_id=organisation.id) \ + .annotate( + role_name=Case( + *[When(role=v.value, then=Value(v.name)) for v in constants.ORGANISATION_ROLES], + default=Value(constants.ORGANISATION_ROLES.MEMBER.name), + output_field=models.CharField() + ) + ) \ + .distinct('user_id') + + is_owner = False + is_member = False + is_admin = False + if user: + is_owner = organisation.owner_id == user.id + + membership = organisation.organisationmembership_set.filter(user_id=user.id) + if membership.exists(): + membership = membership.first() + + is_member = True + is_admin = membership.role >= constants.ORGANISATION_ROLES.ADMIN + + entity_brand_clause = '' + user_perms_sql = f''' + select memb.user_id, memb.organisation_id, memb.role + from public.auth_user as uau + join public.clinicalcode_organisationmembership as memb + on uau.id = memb.user_id + join public.clinicalcode_organisation as org + on memb.organisation_id = org.id + where memb.organisation_id = {organisation.id} + and memb.user_id = {user.id if user else 'null'} + union + select uau.id as user_id, org.id as organisation_id, 3 as role + from public.auth_user as uau + join public.clinicalcode_organisation as org + on org.owner_id = uau.id + where uau.id = {user.id if user else 'null'} + ''' + if current_brand is not None: + entity_brand_clause = f'and ge.brands && array[{current_brand.id}]' + + user_perms_sql = f''' + select memb.user_id, memb.organisation_id, memb.role + from public.auth_user as uau + join public.clinicalcode_organisationmembership as memb + on uau.id = memb.user_id + join public.clinicalcode_organisation as org + on memb.organisation_id = org.id + join public.clinicalcode_organisationauthority as auth + on auth.organisation_id = org.id + where memb.organisation_id = {organisation.id} + and memb.user_id = {user.id if user else 'null'} + and auth.brand_id = {current_brand.id} + union + select uau.id as user_id, org.id as organisation_id, 3 as role + from public.auth_user as uau + join public.clinicalcode_organisation as org + on org.owner_id = uau.id + where uau.id = {user.id if user else 'null'} + ''' + + sql = f''' + with user_perms as ( + {user_perms_sql} + ), + entities as ( + select hge.id, hge.history_id, hge.name, hge.updated, + hge.publish_status, ge.organisation_id, ge.is_deleted, + row_number() over (partition by hge.id order by hge.history_id desc) as rn + from public.clinicalcode_historicalgenericentity as hge + join public.clinicalcode_genericentity as ge + on hge.id = ge.id + where ge.organisation_id = {organisation.id} + {entity_brand_clause} + ), + published as ( + select id, history_id, name, updated, publish_status + from ( + select *, + min(rn) over (partition by entities.id) as min_rn + from entities + where publish_status = {constants.APPROVAL_STATUS.APPROVED} + ) as sq + where rn = min_rn + ), + draft as ( + select id, history_id, name, updated, publish_status + from ( + select entities.*, + min(entities.rn) over (partition by entities.id) as min_rn + from entities + join user_perms + on entities.organisation_id = user_perms.organisation_id + left join published + on entities.id = published.id + and published.history_id > entities.history_id + where entities.publish_status != {constants.APPROVAL_STATUS.APPROVED} + and published.id is null + and entities.is_deleted is null or entities.is_deleted = false + ) as sq + where rn = min_rn + ), + moderated as ( + select entities.id, entities.history_id, entities.name, + entities.updated, entities.publish_status + from entities + join user_perms + on entities.organisation_id = user_perms.organisation_id + where user_perms.role >= 2 + and entities.publish_status in ( + {constants.APPROVAL_STATUS.REQUESTED}, {constants.APPROVAL_STATUS.PENDING} + ) + ) + select json_build_object( + 'published', ( + select json_agg(json_build_object( + 'id', id, + 'history_id', history_id, + 'name', name, + 'updated', updated, + 'publish_status', publish_status + )) from published + ), + 'draft', ( + select json_agg(json_build_object( + 'id', id, + 'history_id', history_id, + 'name', name, + 'updated', updated, + 'publish_status', publish_status + )) from draft + ), + 'moderated', ( + select json_agg(json_build_object( + 'id', id, + 'history_id', history_id, + 'name', name, + 'updated', updated, + 'publish_status', publish_status + )) from moderated + ) + ) as "data" + ''' + entities = None + with connection.cursor() as cursor: + cursor.execute(sql) + + columns = [col[0] for col in cursor.description] + entities = [dict(zip(columns, row)) for row in cursor.fetchall()][0] + entities = entities.get('data') + + return context | { + 'instance': organisation, + 'is_owner': is_owner, + 'is_member': is_member, + 'is_admin': is_admin, + 'members': members, + 'published': entities.get('published'), + 'draft': entities.get('draft'), + 'moderated': entities.get('moderated') + } + + raise Http404('No organisation found') + + def get(self, request, *args, **kwargs): + context = self.get_context_data(*args, **kwargs) + return render(request, self.template_name, context) + + @method_decorator([login_required, permission_utils.redirect_readonly]) + def post(self, request, *args, **kwargs): + if gen_utils.is_fetch_request(request): + target = request.headers.get('X-Target', None) + if target is not None and target in self.fetch_methods: + target = getattr(self, target) + return target(request, *args, **kwargs) + + return super().post(request, *args, **kwargs) + + def leave_organisation(self, request, *args, **kwargs): + context = self.get_context_data(*args, **kwargs) + user = request.user if request.user and not request.user.is_anonymous else None + + if context.get('is_owner') or context.get('is_member') == False: + return gen_utils.jsonify_response(code=400) + + slug = context.get('slug') + membership = OrganisationMembership.objects.filter( + organisation__slug=slug, user_id=user.id + ) + if not membership.exists(): + return gen_utils.jsonify_response(code=404) + membership = membership.first() + + try: + membership.delete() + except Exception as e: + logger.warning( + f'Integrity error when attempting to remove user from organisation: {slug}> with err: {e}' + ) + return gen_utils.jsonify_response(code=400) + + return gen_utils.jsonify_response(code=200, status='true') + +''' View Invite ''' + +class OrganisationInviteView(TemplateView): + template_name = 'clinicalcode/organisation/invite.html' + fetch_methods = [ + 'invitation_reponse' + ] + + @method_decorator([login_required, permission_utils.redirect_readonly]) + def dispatch(self, request, *args, **kwargs): + uuid = kwargs.get('uuid') + if gen_utils.is_empty_string(uuid): + return BadRequest + + invite = OrganisationInvite.objects.all() \ + .filter( + id=uuid + ) + if not invite.exists(): + return BadRequest + invite = invite.first() + + user = request.user + if not user: + return BadRequest + + if invite.user_id != user.id: + return BadRequest + + if invite.is_expired(): + return BadRequest + + if invite.outcome not in [constants.ORGANISATION_INVITE_STATUS.ACTIVE, constants.ORGANISATION_INVITE_STATUS.SEEN]: + return BadRequest + + return super(OrganisationInviteView, self).dispatch(request, *args, **kwargs) + + def get_context_data(self, *args, **kwargs): + context = super(OrganisationInviteView, self).get_context_data(*args, **kwargs) + request = self.request + + uuid = kwargs.get('uuid') + if not gen_utils.is_empty_string(uuid): + invite = OrganisationInvite.objects.all() \ + .filter( + id=uuid + ) + if invite.exists(): + invite = invite.first() + + return context | { + 'instance': invite.organisation, + 'invite': invite + } + + raise Http404('No organisation invite found') + + def get(self, request, *args, **kwargs): + context = self.get_context_data(*args, **kwargs) + return render(request, self.template_name, context) + + @method_decorator([login_required, permission_utils.redirect_readonly]) + def post(self, request, *args, **kwargs): + if gen_utils.is_fetch_request(request): + target = request.headers.get('X-Target', None) + if target is not None and target in self.fetch_methods: + target = getattr(self, target) + return target(request, *args, **kwargs) + + return super().post(request, *args, **kwargs) + + def invitation_reponse(self, request, *args, **kwargs): + context = self.get_context_data(*args, **kwargs) + user = request.user if request.user and not request.user.is_anonymous else None + body = gen_utils.get_request_body(request) + + invitation_response = body.get('result') + if invitation_response is None: + return gen_utils.jsonify_response(code=400) + + invite = context.get('invite') + if not invite or not isinstance(invite, OrganisationInvite): + return gen_utils.jsonify_response(code=500) + + if user.id != invite.user_id: + return gen_utils.jsonify_response(code=400) + + current_membership = OrganisationMembership.objects.filter( + organisation_id=invite.organisation_id, + user_id=invite.user_id + ) + if current_membership.exists(): + return gen_utils.jsonify_response(code=400) + + try: + if invitation_response: + membership = OrganisationMembership.objects.create( + user_id=invite.user_id, + organisation_id=invite.organisation_id + ) + + invite.outcome = constants.ORGANISATION_INVITE_STATUS.ACCEPTED + invite.save() + else: + invite.outcome = constants.ORGANISATION_INVITE_STATUS.REJECTED + invite.save() + except Exception as e: + logger.warning( + f'Integrity error when attempting to remove user from organisation: {body}> with err: {e}' + ) + return gen_utils.jsonify_response(code=400) + + return gen_utils.jsonify_response(code=200, status='true') diff --git a/CodeListLibrary_project/clinicalcode/views/Profile.py b/CodeListLibrary_project/clinicalcode/views/Profile.py index f9218c34d..73bbfc763 100644 --- a/CodeListLibrary_project/clinicalcode/views/Profile.py +++ b/CodeListLibrary_project/clinicalcode/views/Profile.py @@ -3,16 +3,20 @@ from django.views.generic import TemplateView from django.shortcuts import render from django.conf import settings -from django.db.models import Subquery, OuterRef -from django.contrib.auth.models import User, Group +from django.db.models import Q, Subquery, OuterRef +from django.contrib.auth.models import Group from django.contrib.auth.decorators import login_required from django.utils.decorators import method_decorator from django.http.response import JsonResponse +from django.contrib.auth import get_user_model from ..forms.ArchiveForm import ArchiveForm from ..models.GenericEntity import GenericEntity +from ..models.Organisation import Organisation from ..entity_utils import permission_utils, model_utils, gen_utils +User = get_user_model() + class MyProfile(TemplateView): template_name = 'clinicalcode/profile/index.html' @@ -36,21 +40,6 @@ class MyCollection(TemplateView): 'id', 'name', 'history_id', 'updated', 'owner_name', 'group_name', 'publish_status', 'is_deleted' ] - - def __annotate_fields(self, queryset): - if not queryset: - return list() - - annotated = queryset.annotate( - group_name=Subquery( - Group.objects.filter(id=OuterRef('group_id')).values('name') - ), - owner_name=Subquery( - User.objects.filter(id=OuterRef('owner')).values('username') - ) - ) - - return list(annotated.values(*self.template_fields)) @method_decorator([login_required, permission_utils.redirect_readonly]) def dispatch(self, request, *args, **kwargs): @@ -60,13 +49,8 @@ def get_context_data(self, *args, **kwargs): context = super(MyCollection, self).get_context_data(*args, **kwargs) request = self.request - content = self.__annotate_fields( - permission_utils.get_editable_entities(request) - ) - - archived_content = self.__annotate_fields( - permission_utils.get_editable_entities(request, only_deleted=True) - ) + content = permission_utils.get_editable_entities(request) + archived_content = permission_utils.get_editable_entities(request, only_deleted=True) form = None if not settings.CLL_READ_ONLY: @@ -106,7 +90,7 @@ def __try_restore_entity(self, request, entity_id): if entity is None: return JsonResponse({ 'success': False, - 'message': 'Phenotype ID is not valid', + 'message': 'Entity ID is not valid', }) if not permission_utils.can_user_edit_entity(request, entity_id): @@ -156,3 +140,40 @@ def __try_archive_entity(self, request): return JsonResponse({ 'success': True, }) + +class MyOrganisations(TemplateView): + template_name = 'clinicalcode/profile/my_organisations.html' + + @method_decorator([login_required, permission_utils.redirect_readonly]) + def dispatch(self, request, *args, **kwargs): + return super(MyOrganisations, self).dispatch(request, *args, **kwargs) + + def get_context_data(self, *args, **kwargs): + context = super(MyOrganisations, self).get_context_data(*args, **kwargs) + request = self.request + user = request.user + + current_brand = model_utils.try_get_brand(request) + is_brand_managed = current_brand.org_user_managed if current_brand else False + + owned_orgs = Organisation.objects.filter( + owner_id=user.id + ) \ + .values('id', 'name', 'slug') + owned_orgs = list(owned_orgs) + + member_orgs = Organisation.objects.filter( + members__id__exact=user.id + ) \ + .values('id', 'name', 'slug') + member_orgs = list(member_orgs) + + return context | { + 'is_brand_managed': is_brand_managed, + 'owned_orgs': owned_orgs, + 'member_orgs': member_orgs + } + + def get(self, request, *args, **kwargs): + context = self.get_context_data(*args, **kwargs) + return render(request, self.template_name, context) diff --git a/CodeListLibrary_project/clinicalcode/views/Publish.py b/CodeListLibrary_project/clinicalcode/views/Publish.py index 1d9c35cd5..534938bba 100644 --- a/CodeListLibrary_project/clinicalcode/views/Publish.py +++ b/CodeListLibrary_project/clinicalcode/views/Publish.py @@ -68,31 +68,38 @@ def post(self,request,pk, history_id): with transaction.atomic(): entity = GenericEntity.objects.get(pk=pk) - #Check if moderator first and if was already approved to filter by only approved entitys - if checks['is_moderator']: - if checks['is_lastapproved']: - self.last_approved_publish(self.request,entity,history_id) - else: - self.moderator_publish(self.request,history_id,pk,checks,data) - - if checks['is_publisher']: - published_entity = PublishedGenericEntity(entity=entity,entity_history_id=history_id, moderator_id=request.user.id, + if checks.get('org_user_managed', False): + if checks['is_moderator']: + published_entity = PublishedGenericEntity(entity=entity,entity_history_id=history_id, moderator_id=request.user.id, created_by_id=GenericEntity.objects.get(pk=pk).created_by.id,approval_status=constants.APPROVAL_STATUS.APPROVED) - published_entity.save() - - #Check if was already published by user only to filter entitys and take the moderator id - if checks['is_lastapproved'] and not checks['is_moderator'] and not checks['is_publisher']: - self.last_approved_publish(self.request,entity,history_id) - - #Approve other pending entity if available to publish - if checks['other_pending']: - published_entity = PublishedGenericEntity.objects.filter(entity_id=entity.id, - approval_status=constants.APPROVAL_STATUS.PENDING) - for en in published_entity: - en.approval_status = constants.APPROVAL_STATUS.APPROVED - en.moderator_id = self.request.user.id - en.modified = make_aware(datetime.now()) - en.save() + published_entity.save() + + else: + #Check if moderator first and if was already approved to filter by only approved entitys + if checks['is_moderator']: + if checks['is_lastapproved']: + self.last_approved_publish(self.request,entity,history_id) + else: + self.moderator_publish(self.request,history_id,pk,checks,data) + + if checks['is_publisher']: + published_entity = PublishedGenericEntity(entity=entity,entity_history_id=history_id, moderator_id=request.user.id, + created_by_id=GenericEntity.objects.get(pk=pk).created_by.id,approval_status=constants.APPROVAL_STATUS.APPROVED) + published_entity.save() + + #Check if was already published by user only to filter entitys and take the moderator id + if checks['is_lastapproved'] and not checks['is_moderator'] and not checks['is_publisher']: + self.last_approved_publish(self.request,entity,history_id) + + #Approve other pending entity if available to publish + if checks['other_pending']: + published_entity = PublishedGenericEntity.objects.filter(entity_id=entity.id, + approval_status=constants.APPROVAL_STATUS.PENDING) + for en in published_entity: + en.approval_status = constants.APPROVAL_STATUS.APPROVED + en.moderator_id = self.request.user.id + en.modified = make_aware(datetime.now()) + en.save() data['form_is_valid'] = True data['approval_status'] = constants.APPROVAL_STATUS.APPROVED @@ -138,19 +145,26 @@ def condition_to_publish(self,checks,is_published): def moderator_publish(self,request,history_id,pk,conditions,data): entity = GenericEntity.objects.get(pk=pk) if conditions['approval_status'] == constants.APPROVAL_STATUS.PENDING: - published_entity = PublishedGenericEntity.objects.filter(entity_id=entity.id, - approval_status=constants.APPROVAL_STATUS.PENDING) - #filter and publish all pending ws - for en in published_entity: - en.approval_status = constants.APPROVAL_STATUS.APPROVED - en.modified = make_aware(datetime.now()) - en.moderator_id = request.user.id - en.save() + if conditions.get('org_user_managed',False): + published_entity = PublishedGenericEntity.objects.get(entity_id=entity.id,entity_history_id=history_id,approval_status=constants.APPROVAL_STATUS.PENDING) + published_entity.approval_status = constants.APPROVAL_STATUS.APPROVED + published_entity.modified = make_aware(datetime.now()) + published_entity.moderator_id = request.user.id + published_entity.save() + else: + published_entity = PublishedGenericEntity.objects.filter(entity_id=entity.id, + approval_status=constants.APPROVAL_STATUS.PENDING) + #filter and publish all pending ws + for en in published_entity: + en.approval_status = constants.APPROVAL_STATUS.APPROVED + en.modified = make_aware(datetime.now()) + en.moderator_id = request.user.id + en.save() data['approval_status'] = constants.APPROVAL_STATUS.APPROVED data['form_is_valid'] = True data['entity_name_requested'] = GenericEntity.history.get(id=pk, history_id=history_id).name - data = publish_utils.form_validation(request, data, history_id, pk, entity, conditions) + data = publish_utils.form_validation(request, data, history_id, pk, published_entity, conditions) elif conditions['approval_status'] == constants.APPROVAL_STATUS.REJECTED: #filter by declined ws @@ -176,7 +190,7 @@ def moderator_publish(self,request,history_id,pk,conditions,data): data['form_is_valid'] = True data['entity_name_requested'] = GenericEntity.history.get(id=pk, history_id=history_id).name #send message to the client - data = publish_utils.form_validation(request, data, history_id, pk, entity, conditions) + data = publish_utils.form_validation(request, data, history_id, pk, published_entity, conditions) else: published_entity = PublishedGenericEntity(entity=entity,entity_history_id=history_id, moderator_id=request.user.id, @@ -228,6 +242,7 @@ def post(self,request, pk, history_id): """ is_published = permission_utils.check_if_published(GenericEntity, pk, history_id) checks = publish_utils.check_entity_to_publish(self.request, pk, history_id) + if not is_published: checks = publish_utils.check_entity_to_publish(self.request, pk, history_id) @@ -250,7 +265,7 @@ def post(self,request, pk, history_id): data['form_is_valid'] = True data['approval_status'] = constants.APPROVAL_STATUS.PENDING data['entity_name_requested'] = GenericEntity.history.get(id=pk, history_id=history_id).name - data = publish_utils.form_validation(self.request, data, history_id, pk, entity, checks) + data = publish_utils.form_validation(self.request, data, history_id, pk, published_entity, checks) except Exception as e: logger.warning('Failed <PublishRequest> POST with error: %s' % (str(e),)) diff --git a/CodeListLibrary_project/clinicalcode/views/View.py b/CodeListLibrary_project/clinicalcode/views/View.py index 60a6a858b..993a27539 100644 --- a/CodeListLibrary_project/clinicalcode/views/View.py +++ b/CodeListLibrary_project/clinicalcode/views/View.py @@ -4,15 +4,17 @@ --------------------------------------------------------------------------- """ from django.conf import settings -from django.contrib import messages -from django.core.exceptions import PermissionDenied -from django.core.mail import BadHeaderError, EmailMultiAlternatives from django.http import HttpResponse -from django.http.response import Http404 +from django.contrib import messages from django.shortcuts import render +from django.core.mail import BadHeaderError, EmailMultiAlternatives +from django.core.cache import cache +from django.http.response import Http404, JsonResponse +from django.core.exceptions import PermissionDenied +from django.views.generic.base import TemplateView -import logging import sys +import logging import requests from ..forms.ContactUsForm import ContactForm @@ -22,11 +24,15 @@ from ..models.CodingSystem import CodingSystem from ..models.DataSource import DataSource from ..models.Statistics import Statistics -from ..models.OntologyTag import OntologyTag +from ..models.Template import Template + +from ..entity_utils import ( + gen_utils, template_utils, constants, + model_utils, sanitise_utils, create_utils +) -from ..entity_utils import gen_utils from ..entity_utils.constants import ONTOLOGY_TYPES -from ..entity_utils.permission_utils import should_render_template, redirect_readonly +from ..entity_utils.permission_utils import redirect_readonly logger = logging.getLogger(__name__) @@ -34,18 +40,33 @@ # -------------------------------------------------------------------------- # Brand / Homepages incl. about # -------------------------------------------------------------------------- -def get_brand_index_stats(request, brand): - if Statistics.objects.all().filter(org__iexact=brand, type__iexact='landing-page').exists(): - stat = Statistics.objects.filter(org__iexact=brand, type__iexact='landing-page') - if stat.exists(): - stat = stat.order_by('-modified').first() - stats = stat.stat if stat else None - else: - from ..entity_utils.stats_utils import save_homepage_stats - # update stat - stat_obj = save_homepage_stats(request, brand) - stats = stat_obj[0] - return stats +def get_brand_index_stats(request, brand_name='ALL'): + """ + Attempts to resolve the index page statistics for the given Brand; defaults to `ALL` + + Args: + request (RequestContext): the HTTP request context + brand_name (str): the name of the brand to query + + Returns: + A (dict) containing the statistics for the specified brand + """ + cache_key = f'idx_stats__{brand_name}__cache' + + brand_stats = cache.get(cache_key) + if brand_stats is None: + brand_stats = Statistics.objects.filter(org__iexact=brand_name, type__iexact='landing-page') + if brand_stats.exists(): + brand_stats = brand_stats.order_by('-modified').first().stat + + if not isinstance(brand_stats, dict): + from ..entity_utils.stats_utils import save_homepage_stats + brand_stats = save_homepage_stats(request, brand_name) + brand_stats = brand_stats[0] + + cache.set(cache_key, brand_stats, 3600) + + return brand_stats def index(request): @@ -54,14 +75,13 @@ def index(request): Assigns brand defined in the Django Admin Portal under "index_path". If brand is not available it will rely on the default index path. """ + brand = request.BRAND_OBJECT index_path = settings.INDEX_PATH - brand = Brand.objects.filter(name__iexact=settings.CURRENT_BRAND) # if the index_ function doesn't exist for the current brand force render of the default index_path try: - if not brand.exists(): + if not brand or not isinstance(brand, Brand): return index_home(request, index_path) - brand = brand.first() return getattr(sys.modules[__name__], 'index_%s' % brand)(request, brand.index_path) except: return index_home(request, index_path) @@ -69,10 +89,21 @@ def index(request): def index_home(request, index_path): stats = get_brand_index_stats(request, 'ALL') - brands = Brand.objects.all().values('name', 'description') + brand = model_utils.try_get_brand(request) + + cache_key = f'idx_brand__{brand.name}__cache' if brand is not None else 'idx_brand__cache' + brand_descriptors = cache.get(cache_key) + if brand_descriptors is None: + brand_descriptors = list(Brand.all_instances().values('id', 'name', 'description', 'website')) + if brand is not None: + index = [ x.get('id') for x in brand_descriptors ].index(brand.id) + first = brand_descriptors.pop(index) + brand_descriptors.insert(0, first) + + cache.set(cache_key, brand_descriptors, 3600) return render(request, index_path, { - 'known_brands': brands, + 'known_brands': brand_descriptors, 'published_concept_count': stats.get('published_concept_count'), 'published_phenotype_count': stats.get('published_phenotype_count'), 'published_clinical_codes': stats.get('published_clinical_codes'), @@ -125,10 +156,8 @@ def brand_about_index_return(request, pg_name): Returns: HttpResponse: The rendered template response. """ - brand = Brand.objects.filter(name__iexact=settings.CURRENT_BRAND) - + brand = request.BRAND_OBJECT try: - brand = brand.first() # Retrieve the 'about_menu' JSON from Django about_pages_dj_data = brand.about_menu @@ -236,11 +265,13 @@ def contact_us(request): from_email = form.cleaned_data['from_email'] message = form.cleaned_data['message'] category = form.cleaned_data['categories'] - email_subject = 'Concept Library - New Message From %s' % name + + brand_title = model_utils.try_get_brand_string(request.BRAND_OBJECT, 'site_title', default='Concept Library') + email_subject = '%s - New Message From %s' % (brand_title, name) try: html_content = \ - '<strong>New Message from Concept Library Website</strong><br><br>' \ + '<strong>New Message from {site} Website</strong><br><br>' \ '<strong>Name:</strong><br>' \ '{name}' \ '<br><br>' \ @@ -252,7 +283,11 @@ def contact_us(request): '<br><br>' \ '<strong> Tell us about your Enquiry: </strong><br>' \ '{message}'.format( - name=name, from_email=from_email, category=category, message=message + site=brand_title, + name=name, + from_email=from_email, + category=category, + message=sanitise_utils.sanitise_value(message, default='[Sanitisation failure]') ) if not settings.IS_DEVELOPMENT_PC or settings.HAS_MAILHOG_SERVICE: @@ -308,24 +343,151 @@ def check_recaptcha(request): return False +class ReferenceData(TemplateView): + EXCLUDED_FIELDS = ['ontology', 'data_sources', 'brands'] + + template_name = 'clinicalcode/about/reference_data.html' + + def get_sourced_values(self, request, template, field): + field_info = template_utils.get_template_field_info(template, field) + template_field = field_info.get('field') + if not template_field: + return None + + is_metadata = field_info.get('is_metadata') + will_hydrate = template_field.get('hydrated', False) + + options = None + if not will_hydrate: + if is_metadata: + options = template_utils.get_template_sourced_values( + constants.metadata, field, struct=template_field + ) + if options is None: + options = self.try_get_computed( + field, + struct=template_field + ) + else: + options = template_utils.get_template_sourced_values( + template, field, struct=template_field + ) + return options + + def get_template_data(self, request, template_id, default=None): + template = model_utils.try_get_instance(Template, pk=template_id) + if template is None: + return default + + template_fields = template_utils.try_get_content( + template_utils.get_merged_definition(template, default={}), + 'fields' + ) + if template_fields is None: + return default + + result = [] + for field, definition in template_fields.items(): + if field in self.EXCLUDED_FIELDS: + continue + + is_active = template_utils.try_get_content(definition, 'active') + if not is_active: + continue + + validation = template_utils.try_get_content(definition, 'validation') + if validation is None: + continue + + field_type = template_utils.try_get_content(validation, 'type') + if field_type is None: + continue + + formatted_field = { + 'name': template_utils.try_get_content(definition, 'title'), + 'description': template_utils.try_get_content(definition, 'description') + } + + is_option = template_utils.try_get_content(validation, 'options') + if is_option is not None: + if field_type not in ['enum', 'int_array']: + continue + + field_values = template_utils.get_template_sourced_values( + template, field + ) + if field_values is not None: + formatted_field |= { + 'options': field_values + } + result.append(formatted_field) + + return result + + def get_templates(self, request): + templates = create_utils.get_createable_entities(request) + + result = {} + for template in templates.get('templates'): + template_id = template.get('id') + template_name = template.get('name') + template_description = template.get('description') + + result[template_name] = { + 'id': template_id, + 'description': template_description + } + + return result + + def get_context_data(self, *args, **kwargs): + context = super(ReferenceData, self).get_context_data(*args, **kwargs) + request = self.request + + templates = self.get_templates(request) + + data = None + if templates is not None: + default_template = next(iter(templates.values())) + data = self.get_template_data( + request, default_template.get('id') + ) + + return context | { + 'ontology_groups': [x.value for x in ONTOLOGY_TYPES], + 'templates': templates, + 'default_data': data + } -def reference_data(request): - """ - Open page to list Data sources, Coding systems, Tags, Collections, Phenotype types, etc - """ - tags = Tag.objects.extra(select={ - 'name': 'description' - }).order_by('id') - - collections = tags.filter(tag_type=2).values('id', 'name') - tags = tags.filter(tag_type=1).values('id', 'name') - - context = { - 'data_sources': list(DataSource.objects.all().order_by('id').values('id', 'name')), - '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] - } - - return render(request, 'clinicalcode/about/reference_data.html', context) + def get(self, request, *args, **kwargs): + context = self.get_context_data(*args, **kwargs) + return render(request, self.template_name, context) + + def options(self, request, *args, **kwargs): + body = gen_utils.get_request_body(request) + if not isinstance(body, dict): + return gen_utils.jsonify_response( + code=400, + message='Invalid, no body included with request' + ) + + template_id = gen_utils.try_value_as_type( + body.get('template_id', None), 'int', default=None + ) + + if template_id is None: + return gen_utils.jsonify_response( + code=400, + message='Invalid, expected integer-like `template_id` property' + ) + + template_data = self.get_template_data(request, template_id) + if template_data is None: + return gen_utils.jsonify_response( + code=404, + message='Failed to find template associated with the given `template_id` of `%d`' % template_id + ) + + return JsonResponse({ + 'data': template_data + }) diff --git a/CodeListLibrary_project/clinicalcode/views/adminTemp.py b/CodeListLibrary_project/clinicalcode/views/adminTemp.py index bce9a64d1..cc52478b3 100644 --- a/CodeListLibrary_project/clinicalcode/views/adminTemp.py +++ b/CodeListLibrary_project/clinicalcode/views/adminTemp.py @@ -1,32 +1,68 @@ from datetime import datetime -from django.db.models import Q -from django.utils.timezone import make_aware -from django.db import connection, transaction +from operator import is_not +from difflib import SequenceMatcher as SM +from functools import partial +from django.db import transaction, connection from django.conf import settings -from django.contrib import messages from django.urls import reverse -from django.core.exceptions import BadRequest, PermissionDenied -from django.contrib.auth.decorators import login_required -from django.contrib.auth.models import Group +from django.db.models import Q from django.shortcuts import render +from django.utils.timezone import make_aware from rest_framework.reverse import reverse +from django.core.exceptions import BadRequest, PermissionDenied +from django.contrib.auth.models import User, Group +from django.contrib.auth.decorators import login_required import re import json import logging +import dateutil + +from clinicalcode.entity_utils import permission_utils, gen_utils -from clinicalcode.entity_utils import permission_utils, gen_utils, create_utils from clinicalcode.models.Tag import Tag +from clinicalcode.models.Brand import Brand from clinicalcode.models.Concept import Concept from clinicalcode.models.Template import Template from clinicalcode.models.Phenotype import Phenotype -from clinicalcode.models.WorkingSet import WorkingSet from clinicalcode.models.GenericEntity import GenericEntity from clinicalcode.models.PublishedPhenotype import PublishedPhenotype +from clinicalcode.models.Organisation import Organisation, OrganisationMembership + +from clinicalcode.models.HDRNSite import HDRNSite +from clinicalcode.models.HDRNJurisdiction import HDRNJurisdiction +from clinicalcode.models.HDRNDataAsset import HDRNDataAsset +from clinicalcode.models.HDRNDataCategory import HDRNDataCategory logger = logging.getLogger(__name__) +#### Const #### +BASE_LINKAGE_TEMPLATE = { + # all sex is '3' unless specified by user + 'sex': '3', + # all PhenotypeType is 'Disease or syndrome' unless specified by user + 'type': '2', + # all version is '1' for migration + 'version': '1', +} + #### Dynamic Template #### +def try_parse_hdrn_datetime(obj, default=None): + if not isinstance(obj, dict): + return default + + typed = obj.get('type') + value = obj.get('value') + if typed != 'datetime' or not isinstance(value, str) or gen_utils.is_empty_string(value): + return default + + try: + result = datetime.strptime(value, '%Y-%m-%dT%H:%M:%S.%f') + except: + return default + else: + return make_aware(result) + def sort_pk_list(a, b): pk1 = int(a.replace('PH', '')) pk2 = int(b.replace('PH', '')) @@ -72,15 +108,6 @@ def compute_related_brands(pheno, default=''): related_brands = ','.join([str(x) for x in list(related_brands)]) return "brands='{%s}' " % related_brands -BASE_LINKAGE_TEMPLATE = { - # all sex is '3' unless specified by user - 'sex': '3', - # all PhenotypeType is 'Disease or syndrome' unless specified by user - 'type': '2', - # all version is '1' for migration - 'version': '1', -} - def get_publications(concept): publication_doi = concept.publication_doi publication_link = concept.publication_link @@ -732,6 +759,298 @@ def admin_update_phenoflowids(request): } ) +@login_required +def admin_upload_hdrn_assets(request): + if settings.CLL_READ_ONLY: + raise PermissionDenied + + if not request.user.is_superuser: + raise PermissionDenied + + if not permission_utils.is_member(request.user, 'system developers'): + raise PermissionDenied + + # get + if request.method == 'GET': + return render( + request, + 'clinicalcode/adminTemp/admin_temp_tool.html', + { + 'url': reverse('admin_upload_hdrn_assets'), + 'action_title': 'Upload HDRN Assets', + 'hide_phenotype_options': False, + } + ) + + # post + if request.method != 'POST': + raise BadRequest('Invalid') + + try: + input_data = request.POST.get('input_data') + input_data = json.loads(input_data) + except: + return render( + request, + 'clinicalcode/adminTemp/admin_temp_tool.html', + { + 'pk': -10, + 'errorMsg': { 'message': 'Unable to read data provided' }, + 'action_title': 'Upload HDRN Assets', + 'hide_phenotype_options': True, + } + ) + + overrides = { + # Dashboard statistics retrieval + "stats_context": "^/HDRN.*$", + # Dashboard renderable / manageable assets + "asset_rules": { + # Global assets + "template": {"model": "Template", "target": "TemplateTarget", "endpoint": "TemplateEndpoint"}, + "tags": {"model": "Tag", "target": "TagTarget", "subtype": "all", "endpoint": "TagEndpoint"}, + # HDRN specific assets + "sites": {"model": "HDRNSite", "target": "HDRNSiteTarget", "endpoint": "HDRNSiteEndpoint"}, + "category": {"model": "HDRNDataCategory", "target": "HDRNDataCategoryTarget", "endpoint": "HDRNDataCategoryEndpoint"}, + "data_assets": {"model": "HDRNDataAsset", "target": "HDRNDataAssetTarget", "endpoint": "HDRNDataAssetEndpoint"}, + "jurisdictions": {"model": "HDRNJurisdiction", "target": "HDRNJurisdictionTarget", "endpoint": "HDRNJurisdictionEndpoint"} + }, + # Controls how Brand ctx is computed for entities on creation + "ignore_collection_ctx": True, + # Controls the visibility of Templates and related content (e.g. Collections, Tags, etc) + "content_visibility": None, + # Controls entity name mapping + "content_mapping": { + "phenotype": "Concept", + "phenotype_url": "concepts", + "concept": "Codelist", + "concept_url": "codelists", + }, + } + + brand_data = { + 'name': 'HDRN', + 'site_title': 'Concept Dictionary', + 'description': ( + 'Health Data Research Network Canada (HDRN Canada) is a pan-Canadian network of member ' + 'organizations that either hold linkable health and health-related data for entire populations ' + 'and/or have mandates and roles relating directly to access or use of those data. ' + ), + 'website': 'https://www.hdrn.ca', + 'logo_path': 'img/brands/HDRN/', + 'index_path': 'clinicalcode/index.html', + 'footer_images': [ + {"url": "https://www.hdrn.ca", "brand": "HDRN", + "image_src": "img/Footer_logos/HDRN_logo.png"}, + {"url": "https://conceptlibrary.saildatabank.com/", "brand": "Concept Library", + "image_src": "img/Footer_logos/concept_library_on_white.png"}, + {"url": "http://saildatabank.com", "brand": "SAIL Databank ", + "image_src": "img/Footer_logos/SAIL_alt_logo_on_white.png"} + ], + 'overrides': overrides, + 'org_user_managed': True, + 'is_administrable': True, + } + + with transaction.atomic(): + brand = Brand.objects.filter(name__iexact='HDRN') + if brand.exists(): + brand = brand.first() + brand.__dict__.update(**brand_data) + brand.save() + else: + brand = Brand.objects.create(**brand_data) + + models = {} + metadata = input_data.get('metadata') + for key, data in metadata.items(): + result = None + match key: + case 'site': + '''HDRNSite''' + result = HDRNSite.objects.bulk_create([ + HDRNSite(name=v.get('name'), abbreviation=v.get('abbreviation')) + for v in data + ]) + case 'jurisdictions': + '''HDRNJurisdiction''' + result = HDRNJurisdiction.objects.bulk_create([ + HDRNJurisdiction(name=v.get('name'), abbreviation=v.get('abbreviation')) + for v in data + ]) + case 'categories': + '''HDRNDataCategory''' + result = HDRNDataCategory.objects.bulk_create([HDRNDataCategory(name=v) for v in data]) + case 'tags': + '''Tag (tag_type=1)''' + result = Tag.objects.bulk_create([Tag(description=v, tag_type=1, collection_brand=brand) for v in data]) + case 'collections': + '''Tag (tag_type=2)''' + result = Tag.objects.bulk_create([Tag(description=v, tag_type=2, collection_brand=brand) for v in data]) + case _: + pass + + if result is not None: + models.update({ key: result }) + + now = make_aware(datetime.now()) + assets = input_data.get('assets') + to_create = [] + to_regions = [] + + for data in assets: + site = data.get('site') + cats = data.get('data_categories') + regions = data.get('region') + + if isinstance(cats, list): + cats = [models.get('tags')[v - 1].id for v in cats if models.get('tags')[v - 1] is not None] + + if isinstance(regions, str): + rlkup = models.get('jurisdictions') + + reg = [] + regions = regions.split(',') + for region in regions: + match = [{ 'item': x, 'score': SM(None, region.strip(), x.name).ratio() } for x in rlkup] + match.sort(key=lambda x:x.get('score'), reverse=True) + + match = match[0] if len(match) > 0 else None + if match and match.get('item') is not None: + reg.append(match.get('item')) + + if len(reg) > 0: + regions = reg + else: + regions = None + to_regions.append(regions) + + site = next((x for x in models.get('site') if x.id == site), None) if isinstance(site, int) else None + created_date = try_parse_hdrn_datetime(data.get('created_date', None), default=now) + modified_date = try_parse_hdrn_datetime(data.get('modified_date', None), default=now) + + to_create.append(HDRNDataAsset( + name=data.get('name'), + description=data.get('description'), + hdrn_id=data.get('hdrn_id'), + hdrn_uuid=data.get('hdrn_uuid'), + site=site, + link=data.get('link'), + years=data.get('years'), + scope=data.get('scope'), + purpose=data.get('purpose'), + collection_period=data.get('collection_period'), + data_level=data.get('data_level'), + data_categories=cats, + created=created_date, + modified=modified_date + )) + + models.update({ 'assets': HDRNDataAsset.objects.bulk_create(to_create) }) + + for i, asset in enumerate(models.get('assets')): + regions = to_regions[i] + if not isinstance(regions, list): + continue + asset.regions.set(regions) + + return render( + request, + 'clinicalcode/adminTemp/admin_temp_tool.html', + { + 'pk': -10, + 'rowsAffected' : { k: len(v) for k, v in models.items() }, + 'action_title': 'Upload HDRN Assets', + 'hide_phenotype_options': True, + } + ) + +@login_required +def admin_update_phenoflow_targets(request): + if settings.CLL_READ_ONLY: + raise PermissionDenied + + if not request.user.is_superuser: + raise PermissionDenied + + if not permission_utils.is_member(request.user, 'system developers'): + raise PermissionDenied + + # get + if request.method == 'GET': + return render( + request, + 'clinicalcode/adminTemp/admin_temp_tool.html', + { + 'url': reverse('admin_update_phenoflow_targets'), + 'action_title': 'Update Phenoflow Targets', + 'hide_phenotype_options': False, + } + ) + + # post + if request.method != 'POST': + raise BadRequest('Invalid') + + try: + input_data = request.POST.get('input_data') + input_data = json.loads(input_data) + except: + return render( + request, + 'clinicalcode/adminTemp/admin_temp_tool.html', + { + 'pk': -10, + 'errorMsg': { 'message': 'Unable to read data provided' }, + 'action_title': 'Update Phenoflow Targets', + 'hide_phenotype_options': True, + } + ) + + with connection.cursor() as cursor: + sql = f''' + update public.clinicalcode_genericentity as trg + set template_data['phenoflowid'] = to_jsonb(src.target) + from ( + select * + from jsonb_to_recordset( + '{json.dumps(input_data)}'::jsonb + ) as x(id varchar, source int, target varchar) + ) as src + where trg.id = src.id + and trg.template_data::jsonb ? 'phenoflowid'; + ''' + cursor.execute(sql) + entity_updates = cursor.rowcount + + sql = f''' + update public.clinicalcode_historicalgenericentity as trg + set template_data['phenoflowid'] = to_jsonb(src.target) + from ( + select * + from jsonb_to_recordset( + '{json.dumps(input_data)}'::jsonb + ) as x(id varchar, source int, target varchar) + ) as src + where trg.id = src.id + and trg.template_data::jsonb ? 'phenoflowid'; + ''' + cursor.execute(sql) + historical_updates = cursor.rowcount + + return render( + request, + 'clinicalcode/adminTemp/admin_temp_tool.html', + { + 'pk': -10, + 'rowsAffected' : { + '1': f'entities: {entity_updates}, historical: {historical_updates}' + }, + 'action_title': 'Update Phenoflow Targets', + 'hide_phenotype_options': True, + } + ) + @login_required def admin_force_concept_linkage_dt(request): """ @@ -968,10 +1287,6 @@ def admin_mig_phenotypes_dt(request): elif request.method == 'POST': if not settings.CLL_READ_ONLY: - code = request.POST.get('code') - if code.strip() != "6)r&9hpr_a0_4g(xan5p@=kaz2q_cd(v5n^!#ru*_(+d)#_0-i": - raise PermissionDenied - phenotype_ids = request.POST.get('phenotype_ids') phenotype_ids = phenotype_ids.strip().upper() @@ -1324,6 +1639,173 @@ def admin_fix_breathe_dt(request): } ) +@login_required +def admin_convert_entity_groups(request): + if settings.CLL_READ_ONLY: + raise PermissionDenied + + if not request.user.is_superuser: + raise PermissionDenied + + if not permission_utils.is_member(request.user, 'system developers'): + raise PermissionDenied + + # get + if request.method == 'GET': + return render( + request, + 'clinicalcode/adminTemp/admin_temp_tool.html', + { + 'url': reverse('admin_convert_entity_groups'), + 'action_title': 'Convert groups to organisations', + 'hide_phenotype_options': True, + } + ) + + # post + if request.method != 'POST': + raise BadRequest('Invalid') + + GROUP_LOOKUP = { + 2: 32, + 6: 60, + 7: 9, + 12: 197, + 13: 240 + } + + entities = GenericEntity.objects \ + .filter(group_id__isnull=False) + + entity_groups = entities \ + .order_by('group_id') \ + .distinct('group_id') \ + .values_list('group_id', flat=True) + + groups = Group.objects \ + .filter(id__in=list(entity_groups)) + + for entity_group in entity_groups: + owner_id = GROUP_LOOKUP.get(entity_group) + if not owner_id: + continue + + current_owner = User.objects.filter(id=owner_id) + if not current_owner.exists(): + continue + current_owner = current_owner.first() + + current_group = Group.objects.filter(id=entity_group) + if not current_group.exists(): + continue + current_group = current_group.first() + + current_members = User.objects.filter(groups=current_group) \ + .exclude(id=owner_id) + + org = Organisation.objects.create( + id=entity_group, + name=current_group.name, + owner=current_owner + ) + for current_member in current_members: + member = OrganisationMembership.objects.create( + user_id=current_member.id, + organisation_id=org.id + ) + + current_entities = entities.filter(group_id=entity_group) + for entity in current_entities: + entity.organisation = org + entity.save() + + return render( + request, + 'clinicalcode/adminTemp/admin_temp_tool.html', + { + 'pk': -10, + 'rowsAffected' : { '1': 'ALL'}, + 'action_title': 'Convert groups to organisations', + 'hide_phenotype_options': True, + } + ) + +@login_required +def admin_fix_icd_ca_cm_codes(request): + if settings.CLL_READ_ONLY: + raise PermissionDenied + + if not request.user.is_superuser: + raise PermissionDenied + + if not permission_utils.is_member(request.user, 'system developers'): + raise PermissionDenied + + # get + if request.method == 'GET': + return render( + request, + 'clinicalcode/adminTemp/admin_temp_tool.html', + { + 'url': reverse('admin_fix_icd_ca_cm_codes'), + 'action_title': 'Fix ICD-10 CA/CM codes', + 'hide_phenotype_options': True, + } + ) + + # post + if request.method != 'POST': + raise BadRequest('Invalid') + + with connection.cursor() as cursor: + cursor.execute(''' + insert into public.clinicalcode_icd10cm_codes + select (select max("id") from public.clinicalcode_icd10cm_codes) + row_number() over (order by code) as id, + code, description, + CURRENT_TIMESTAMP as created_date + from ( + select replace(src.code, '.', '') as code, + src.description + from public.clinicalcode_icd10_codes_and_titles_and_metadata as src + left join public.clinicalcode_icd10cm_codes as trg + on lower(replace(src.code, '.', '')) = lower(replace(trg.code, '.', '')) + where length(replace(src.code, '.', '')) = 3 + and trg.code is null + group by src.code, src.description + ) + order by code; + ''') + + cursor.execute(''' + insert into public.clinicalcode_icd10ca_codes + select (select max("id") from public.clinicalcode_icd10ca_codes) + row_number() over (order by code) as id, + code, description, long_desc, + CURRENT_TIMESTAMP as created_date + from ( + select replace(src.code, '.', '') as code, + left(src.description, 128) as description, + src.description as long_desc + from public.clinicalcode_icd10_codes_and_titles_and_metadata as src + left join public.clinicalcode_icd10ca_codes as trg + on lower(replace(src.code, '.', '')) = lower(replace(trg.code, '.', '')) + where length(replace(src.code, '.', '')) = 3 + and trg.code is null + group by src.code, left(src.description, 128), src.description + ) + order by code; + ''') + + return render( + request, + 'clinicalcode/adminTemp/admin_temp_tool.html', + { + 'pk': -10, + 'rowsAffected' : { '1': 'ALL'}, + 'action_title': 'Fix ICD-10 CA/CM codes', + 'hide_phenotype_options': True, + } + ) + def get_serial_id(): count_all = GenericEntity.objects.count() if count_all: diff --git a/CodeListLibrary_project/clinicalcode/views/dashboard/BrandAdmin.py b/CodeListLibrary_project/clinicalcode/views/dashboard/BrandAdmin.py new file mode 100644 index 000000000..5eef479b8 --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/views/dashboard/BrandAdmin.py @@ -0,0 +1,416 @@ +"""Brand Administration View(s) & Request Handling.""" +from datetime import datetime +from django.db import connection +from django.apps import apps +from django.conf import settings +from django.shortcuts import render +from django.core.cache import cache +from rest_framework.views import APIView +from django.views.generic import TemplateView +from rest_framework.response import Response +from django.utils.decorators import method_decorator +from rest_framework.decorators import schema +from django.contrib.staticfiles import finders +from django.contrib.staticfiles.storage import staticfiles_storage + +import os +import math +import logging +import psycopg2 + +from clinicalcode.views import View +from clinicalcode.models import Brand +from clinicalcode.entity_utils import gen_utils, model_utils, permission_utils + + +logger = logging.getLogger(__name__) + + +def get_or_resolve_assets(brand=None, cache_timeout=3600): + """ + Attempts to resolve the known quick access assets available for the specified Brand, either by retrieving it from the cache store or, if not found, by computing it. + + Args: + brand (:model:`Brand`|None): the Request-specified Brand context + cache_timeout (int|None): optionally specify the cache timeout; defaults to 3600 + + Returns: + A (dict) describing the quick access items + """ + cache_key = brand.name if isinstance(brand, Brand) else 'all' + cache_key = f'dashboard-asset-summary__{cache_key}__cache' + + if not isinstance(cache_timeout, int) or not math.isfinite(cache_timeout) or math.isnan(cache_timeout): + cache_timeout = 0 + else: + cache_timeout = max(cache_timeout, 0) + + assets = cache.get(cache_key) + if assets is not None: + return assets.get('value') + + if brand is not None: + assets = brand.get_asset_rules(default={}) + else: + assets = {} + + for key, asset in assets.items(): + model = asset.get('model') + if model is None: + assets.pop(key, None) + continue + + details = None + try: + model = apps.get_model(app_label='clinicalcode', model_name=model) + if hasattr(model, 'get_verbose_names'): + details = model.get_verbose_names(subtype=asset.get('subtype', key)) + elif hasattr(model, '_meta') and getattr(model, '_meta', None) is not None: + meta = getattr(model, '_meta') + verbose_name = meta.verbose_name if hasattr(meta, 'verbose_name') else None + verbose_name_plural = meta.verbose_name_plural if hasattr(meta, 'verbose_name_plural') else None + details = { + 'verbose_name': verbose_name if isinstance(verbose_name, str) else model.__name__, + 'verbose_name_plural': verbose_name_plural if isinstance(verbose_name_plural, str) else (f'{model.__name__}s'), + } + else: + details = { + 'verbose_name': model.__name__, + 'verbose_name_plural': f'{model.__name__}s', + } + except: + assets.pop(key, None) + continue + else: + if details is not None: + asset.update({ 'details': details }) + + if cache_timeout > 0: + cache.set(cache_key, { 'value': assets }, cache_timeout) + return assets + + +class BrandDashboardView(TemplateView): + """ + Dashboard View for Brand Administration. + + :template:`clinicalcode/dashboard/index.html` + """ + reverse_name = 'brand_dashboard' + template_name = 'clinicalcode/dashboard/index.html' + preferred_logos = ['logo-transparent.png', 'apple-touch-icon.png'] + + def __get_admin_logo_target(self, brand=None): + """ + Resolves the best Brand-related logo to use for the Dashboard view + + Args: + brand (:model:`Brand`|Dict[str, Any]): optionally specify the Brand from which to resolve the logo; defaults to `None` + + Returns: + A (str) describing the static storage relative path to the image file + """ + if isinstance(brand, dict): + path = brand.get('logo_path', None) + elif isinstance(brand, Brand): + path = getattr(brand, 'logo_path', None) if hasattr(brand, 'logo_path') else None + else: + path = None + + if not isinstance(path, str) or gen_utils.is_empty_string(path): + path = settings.APP_LOGO_PATH + + for fname in self.preferred_logos: + trg_path = os.path.join(path, fname) + abs_path = staticfiles_storage.path(trg_path) + if abs_path is not None and staticfiles_storage.exists(abs_path): + return trg_path + + abs_path = finders.find(trg_path) + if abs_path is not None and os.path.isfile(abs_path): + return trg_path + return None + + @method_decorator([permission_utils.redirect_readonly, permission_utils.brand_admin_required]) + def dispatch(self, request, *args, **kwargs): + """ + Request-Response Middleman. + + .. Note:: + Decporated such that it only dispatches when: + - The app isn't in a read-only state; + - The Brand context is administrable; + - And either (a) the user is a superuser, or (b) the user is authenticated & is a brand administrator of the current Brand. + """ + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, *args, **kwargs): + """ + Resolves the View context data. + + Args: + *args: Variable length argument list. + **kwargs: Arbitrary keyword arguments. + + Kwargs: + brand (:model:`Brand`): the current HttpRequest's :model:`Brand` context + + Returns: + The resulting Template context (`Dict[str, Any]` _OR_ :py:class:`Context`) + """ + brand = kwargs.get('brand') + context = super().get_context_data(*args, **kwargs) + return context | { + 'brand': brand, + 'logo_path': self.__get_admin_logo_target(brand), + } + + def get(self, request, *args, **kwargs): + """ + Display a :model:`clinicalcode.Brand` administration dashboard. + + .. Context:: + + ``logo_path`` + A (str) specifying the static path to the branded logo + + ``brand`` + A (Brand|None) specifying the current Brand instance + + .. Template:: + + :template:`clinicalcode/dashboard/index.html` + + .. Reverse:: + `brand_dashboard` + """ + brand = model_utils.try_get_brand(request) + if brand is None: + return View.notify_err( + request, + title='Bad Request - Invalid Brand', + status_code=400, + details=['You cannot administrate a Brand on an unknown domain.'] + ) + + context = self.get_context_data(*args, **kwargs, brand=brand) + return render(request, self.template_name, context) + + +@schema(None) +class BrandInventoryView(APIView): + """Brand Inventory APIView for the Brand Dashboard, used to retrieve administrable Data Models""" + reverse_name = 'brand_dashboard_inventory' + permission_classes = [permission_utils.IsReadOnlyRequest & permission_utils.IsNotGateway & permission_utils.IsBrandAdmin] + + # Statistics summary cache duration (in seconds) + CACHE_TIMEOUT = 3600 + + def get(self, request): + """GET request handler for BrandInventoryView""" + brand = model_utils.try_get_brand(request) + + return Response({ + 'assets': get_or_resolve_assets(brand), + }) + + +@schema(None) +class BrandOverviewView(APIView): + """Dashboard Landing Overview APIView, used to retrieve the interaction & accessible data for the Brand Dashboard""" + # View behaviour + reverse_name = 'brand_dashboard_overview' + permission_classes = [permission_utils.IsReadOnlyRequest & permission_utils.IsNotGateway & permission_utils.IsBrandAdmin] + + # Statistics summary cache duration (in seconds) + CACHE_TIMEOUT = 3600 + + def get(self, request): + """GET request handler for BrandOverviewView""" + brand = model_utils.try_get_brand(request) + summary = self.__get_or_compute_summary(brand) + + return Response({ + 'assets': get_or_resolve_assets(brand), + 'summary': summary, + }) + + def __get_or_compute_summary(self, brand=None): + """ + Attempts to resolve the statistics summary for the specified Brand, either by retrieving it from the cache store or, if not found, by computing it. + + Args: + brand (:model:`Brand`|None): the Request-specified Brand context + + Returns: + A (dict) describing the statistics/analytics summary + """ + cache_key = brand.name if isinstance(brand, Brand) else 'all' + cache_key = f'dashboard-stat-summary__{cache_key}__cache' + + summary = cache.get(cache_key) + if summary is not None: + return summary.get('value') + + summary = self.__compute_summary(brand) + cache.set(cache_key, { 'value': summary }, self.CACHE_TIMEOUT) + return summary + + def __compute_summary(self, brand): + """ + Attempts to compute the statistics summary for the specified Brand + + .. Note:: + Computes the following: + - No. of Phenotypes created in the last 7 days + - No. of Phenotypes edited in the last 7 days + - No. of Phenotypes published in the last 7 days + - No. of unique Daily Active Users today + - No. of unique Monthly Active Users for this month + - No. of page hits over the last 7 days + + These can be modified by overrides defined for each brand, _e.g._ ... + - HDRN: `{"stats_context": "^/HDRN.*$"}` + - HDRUK: `{"stats_context": "^(?!/HDRN)", "content_visibility": {"allow_null": true, "allowed_brands": [1, 2, 3]}}` + + Args: + brand (:model:`Brand`|None): the Request-specified Brand context + + Returns: + A (dict) describing the statistics/analytics summary + """ + query_params = {} + stats_context = None + content_visibility = None + + if brand: + content_visibility = brand.get_vis_rules() + + overrides = getattr(brand, 'overrides', None) + stats_context = overrides.get('stats_context') if isinstance(overrides, dict) else None + if not isinstance(stats_context, str) or gen_utils.is_empty_string(stats_context): + stats_context = '^\/%s.*$' % brand.name + query_params.update({ 'stats_ctx': stats_context }) + + with connection.cursor() as cursor: + if isinstance(content_visibility, dict): + allowed_brands = content_visibility.get('ids') + allowed_brands = allowed_brands if isinstance(allowed_brands, list) and len(allowed_brands) > 0 else None + allowed_null_brand = content_visibility.get('allow_null') + query_params.update({ 'brand_ids': allowed_brands if allowed_brands is not None else [brand.id] }) + + if allowed_brands and allowed_null_brand: + content_visibility = psycopg2.sql.SQL('''and (ge.brands is null or array_length(ge.brands, 1) < 1 or ge.brands && %(brand_ids)s::int[])''') + elif allowed_brands: + content_visibility = psycopg2.sql.SQL('''and (ge.brands is not null and ge.brands && %(brand_ids)s::int[])''') + elif allowed_null_brand: + content_visibility = psycopg2.sql.SQL('''and (ge.brands is null or array_length(ge.brands, 1) < 1)''') + elif brand: + query_params.update({ 'brand_ids': [brand.id] }) + content_visibility = psycopg2.sql.SQL('''and (ge.brands is not null and ge.brands && %(brand_ids)s::int[])''') + else: + content_visibility = psycopg2.sql.SQL('') + + if stats_context is not None: + stats_context = psycopg2.sql.SQL('''and (req.url ~ %(stats_ctx)s)''') + else: + stats_context = psycopg2.sql.SQL('') + + sql = psycopg2.sql.Composed([ + # Visible phenotypes + psycopg2.sql.SQL(''' + with + pheno_vis as ( + select ge.* + from public.clinicalcode_genericentity as ge + where (ge.is_deleted is null or ge.is_deleted = false) + '''), + content_visibility, + psycopg2.sql.SQL(''' + ), + '''), + # Visible requests + psycopg2.sql.SQL(''' + request_vis as ( + select req.*, + (case + when req.user_id is not null then req.user_id::text + else null + end) as uid + from public.easyaudit_requestevent as req + where req.method in ('GET', 'POST', 'PUT') + '''), + stats_context, + psycopg2.sql.SQL(''' + ), + '''), + # Count created + psycopg2.sql.SQL(''' + pheno_created as ( + select count(ge.*) as cnt + from pheno_vis as ge + where ge.created >= date_trunc('day', now()) - interval '7 day' + ), + '''), + # Count edits + psycopg2.sql.SQL(''' + pheno_edited as ( + select count(hge.*) as cnt + from pheno_vis as ge + join public.clinicalcode_historicalgenericentity as hge + using (id) + where hge.history_date >= date_trunc('day', now()) - interval '7 day' + ), + '''), + # Count publications + psycopg2.sql.SQL(''' + pheno_pubs as ( + select count(pge.*) as cnt + from pheno_vis as ge + join public.clinicalcode_publishedgenericentity as pge + on ge.id = pge.entity_id + where pge.approval_status = 2 + and pge.modified >= date_trunc('day', now()) - interval '7 day' + ), + '''), + # Count DAU + psycopg2.sql.SQL(''' + unq_dau as ( + select count(distinct coalesce(req.uid, req.remote_ip, '')) as cnt + from request_vis as req + where req.datetime >= date_trunc('day', now()) + ), + '''), + # Count MAU + psycopg2.sql.SQL(''' + unq_mau as ( + select count(distinct coalesce(req.uid, req.remote_ip, '')) as cnt + from request_vis as req + where req.datetime >= date_trunc('month', now()) + ), + '''), + # Count page hits last 7 day + psycopg2.sql.SQL(''' + page_hits as ( + select count(*) as cnt + from request_vis as req + where req.datetime >= date_trunc('day', now()) - interval '7 day' + ) + '''), + # Collect summary + psycopg2.sql.SQL(''' + select + (select cnt from pheno_created) as created, + (select cnt from pheno_edited) as edited, + (select cnt from pheno_pubs) as published, + (select cnt from unq_dau) as dau, + (select cnt from unq_mau) as mau, + (select cnt from page_hits) as hits + ''') + ]) + + cursor.execute(sql, params=query_params) + columns = [col[0] for col in cursor.description] + + result = { 'data': dict(zip(columns, row)) for row in cursor.fetchall() } + return result | { 'timestamp': datetime.now().isoformat() } diff --git a/CodeListLibrary_project/clinicalcode/views/dashboard/__init__.py b/CodeListLibrary_project/clinicalcode/views/dashboard/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/CodeListLibrary_project/clinicalcode/views/dashboard/targets/BaseTarget.py b/CodeListLibrary_project/clinicalcode/views/dashboard/targets/BaseTarget.py new file mode 100644 index 000000000..970669051 --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/views/dashboard/targets/BaseTarget.py @@ -0,0 +1,296 @@ +"""Brand Dashboard: Base extensible/abstract classes""" +from django.http import HttpRequest +from rest_framework import status, generics, mixins, serializers, exceptions, fields +from django.db.models import Model +from rest_framework.request import Request +from rest_framework.response import Response +from django.utils.functional import classproperty + +import inspect +import builtins + +from clinicalcode.entity_utils import permission_utils, model_utils, gen_utils + + +"""Default Model `pk` field filter, _e.g._ the `ID` integer primary key""" +DEFAULT_LOOKUP_FIELD = 'pk' + + +class BaseSerializer(serializers.ModelSerializer): + """Extensible serializer class for Target(s)""" + + # Getters + @property + def form_fields(self): + instance = getattr(self, 'instance') if hasattr(self, 'instance') else None + + form = {} + for name, field in self.fields.items(): + group = None + for k, v in vars(field).items(): + if k.startswith('_'): + continue + + value = v + if v == fields.empty or isinstance(v, fields.empty): + value = None + elif v is not None and type(v).__name__ != '__proxy__' and (type(v).__name__ == 'type' or ((hasattr(v, '__name__') or hasattr(v, '__class__') or inspect.isclass(v)) and not type(v).__name__ in dir(builtins))): + value = type(v).__name__ + + if group is None: + group = {} + group.update({ k: value }) + + trg = field + if isinstance(trg, serializers.ListSerializer): + trg = trg.child + + disp = getattr(trg, '_str_display') if hasattr(trg, '_str_display') else None + if isinstance(disp, str) and not gen_utils.is_empty_string(disp): + group.update({ 'str_display': disp }) + + if hasattr(trg, 'resolve_options') and callable(getattr(trg, 'resolve_options', None)): + group.update({ 'value_options': trg.resolve_options() }) + elif hasattr(trg, 'get_choices') and callable(getattr(trg, 'get_choices', None)): + group.update({ 'value_options': trg.get_choices() }) + + if hasattr(trg, 'resolve_format') and callable(getattr(trg, 'resolve_format', None)): + group.update({ 'value_format': trg.resolve_format() }) + + if isinstance(field, (serializers.ListField, serializers.ListSerializer)): + group.update({ 'type': type(field.child).__qualname__, 'subtype': type(field).__qualname__ }) + else: + group.update({ 'type': type(field).__qualname__, 'subtype': None }) + + form.update({ name: group }) + return form + + # Private utility methods + def _get_user(self): + request = self.context.get('request') + if request and hasattr(request, 'user'): + return request.user + return None + + def _get_brand(self): + request = self.context.get('request') + if request: + return model_utils.try_get_brand(request) + return None + + @staticmethod + def _update(instance, validated_data): + """ + Dynamically updates a model instance, setting Many-to-Many fields first if present. + @param instance: + @param validated_data: + @return: + """ + + # Handle ManyToMany fields + # Loop through fields and set values dynamically + for field in instance._meta.get_fields(include_parents=True): + field_name = field.name + + # Skip fields not in validated_data + if field_name not in validated_data: + continue + + value = validated_data.pop(field_name) + + # Handle ManyToMany relationships dynamically + if field.many_to_many: + getattr(instance, field_name).set(value) + else: + setattr(instance, field_name, value) + return instance + + @staticmethod + def _create(model_class, validated_data): + """ + Dynamically create a new model instance, setting Many-to-Many fields first if present. + + Args: + model_class (Model): The Django model class to create an instance for. + validated_data (dict): The validated data for creation. + + Returns: + Model: The newly created instance. + """ + # Step 1: Extract Many-to-Many fields + m2m_data = { + field.name: validated_data.pop(field.name) + for field in model_class._meta.get_fields(include_parents=True) + if field.many_to_many and field.name in validated_data + } + + # Step 2: Create the instance with remaining fields + instance = model_class.objects.create(**validated_data) + + # Step 3: Set Many-to-Many fields + for field_name, value in m2m_data.items(): + getattr(instance, field_name).set(value) + + return instance + + +class BaseEndpoint( + generics.GenericAPIView, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + mixins.ListModelMixin, + mixins.CreateModelMixin +): + """Extensible endpoint class for TargetEndpoint(s)""" + + # QuerySet kwargs + filter = None + + # View behaviour + permission_classes = [permission_utils.IsBrandAdmin & permission_utils.IsNotGateway] + + # Exclude endpoint(s) from swagger + swagger_schema = None + + # Properties + @classproperty + def lookup_field(cls): + if hasattr(cls, '_lookup_field'): + lookup = getattr(cls, '_lookup_field') + if isinstance(lookup, str) and not gen_utils.is_empty_string(lookup): + return lookup + elif hasattr(cls, 'model'): + model = getattr(cls, 'model', None) + if inspect.isclass(model) or not issubclass(model, Model): + return model._meta.pk.name + return DEFAULT_LOOKUP_FIELD + + # Mixin views + def retrieve(self, request, *args, **kwargs): + try: + instance = self.get_object(*args, **kwargs) + serializer = self.get_serializer(instance) + response = { 'data': serializer.data } + self._format_list_data(response, serializer=serializer) + except Exception as e: + return Response( + data={ 'detail': str(e) }, + status=status.HTTP_400_BAD_REQUEST + ) + else: + return Response(response) + + def list(self, request, *args, **kwargs): + params = getattr(self, 'filter', None) + params = params if isinstance(params, dict) else None + + page_obj = self.model.get_brand_paginated_records_by_request(request, params=params) + + results = self.serializer_class(page_obj.object_list, many=True) + response = self._format_list_data({ + 'detail': self._format_page_details(page_obj), + 'results': results.data, + }) + + return Response(response) + + # Mixin methods + def get_queryset(self, *args, **kwargs): + params = getattr(self, 'filter', None) + if isinstance(params, dict): + params = kwargs | params + else: + params = kwargs + + return self.model.get_brand_records_by_request(self.request, params=params) + + def get_object(self, *args, **kwargs): + inst = self.get_queryset().filter(*args, **kwargs) + if not inst.exists(): + raise exceptions.NotFound(f'A {self.model._meta.model_name} matching the given parameters does not exist.') + + return inst.first() + + def update(self, request, *args, **kwargs): + """Overrides the update mixin""" + partial = kwargs.pop('partial', False) + instance = self.get_object(*args, **kwargs) # Now uses kwargs dynamically + serializer = self.get_serializer(instance, data=request.data, partial=partial) + serializer.is_valid(raise_exception=True) + self.perform_update(serializer) + return Response(serializer.data) + + # Private methods + def _get_query_params(self, request, kwargs=None): + params = getattr(self, 'filter', None) + params = params if isinstance(params, dict) else { } + if not isinstance(kwargs, dict): + kwargs = { } + + if isinstance(request, Request) and hasattr(request, 'query_params'): + params = { key: value for key, value in request.query_params.items() } | kwargs | params + elif isinstance(request, HttpRequest) and hasattr(request, 'GET'): + params = { key: value for key, value in request.GET.dict().items() } | kwargs | params + + return params + + def _format_list_data(self, response, serializer=None): + if serializer is None: + serializer = self.get_serializer() + + renderable = { 'form': serializer.form_fields } + show_fields = getattr(serializer, '_list_fields', None) if hasattr(serializer, '_list_fields') else None + show_fields = show_fields if isinstance(show_fields, list) else None + if not isinstance(show_fields, list): + show_fields = [self.model._meta.pk.name] + + item_fields = getattr(serializer, '_item_fields', None) if hasattr(serializer, '_item_fields') else None + if not isinstance(item_fields, list): + item_fields = [ + k + for k, v in renderable.get('form').items() + if isinstance(v.get('style'), dict) and v.get('style').get('data-itemdisplay', True) + ] + + features = getattr(serializer, '_features', None) if hasattr(serializer, '_features') else None + if not isinstance(features, dict): + features = None + + response.update(renderable=renderable | { 'fields': show_fields, 'order': item_fields, 'features': features }) + return response + + def _format_page_details(self, page_obj): + num_pages = page_obj.paginator.num_pages + page = min(page_obj.number, num_pages) + + detail = { + 'page': page, + 'total_pages': num_pages, + 'page_size': page_obj.paginator.per_page, + 'has_previous': page_obj.has_previous(), + 'has_next': page_obj.has_next(), + 'max_results': page_obj.paginator.count, + } + + if num_pages <= 9: + detail.update(pages=set(range(1, num_pages + 1))) + else: + page_items = [] + min_page = page - 1 + max_page = page + 1 + if min_page <= 1: + min_page = 1 + max_page = min(page + 2, num_pages) + else: + page_items += [1, 'divider'] + + if max_page > num_pages: + min_page = max(page - 2, 1) + max_page = min(page, num_pages) + + page_items += list(range(min_page, max_page + 1)) + if num_pages not in page_items: + page_items += ['divider', num_pages] + detail.update(pages=page_items) + + return detail diff --git a/CodeListLibrary_project/clinicalcode/views/dashboard/targets/BrandTarget.py b/CodeListLibrary_project/clinicalcode/views/dashboard/targets/BrandTarget.py new file mode 100644 index 000000000..f8b2fda5f --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/views/dashboard/targets/BrandTarget.py @@ -0,0 +1,273 @@ +"""Brand Dashboard: API endpoints relating to Template model""" +from rest_framework import status, serializers +from django.contrib.auth import get_user_model +from django.utils.timezone import make_aware +from rest_framework.response import Response + +import datetime + +from .UserTarget import UserSerializer +from .BaseTarget import BaseSerializer, BaseEndpoint +from clinicalcode.models.Brand import Brand +from clinicalcode.entity_utils import model_utils + + +User = get_user_model() + + +class BrandSerializer(BaseSerializer): + """ + Serializer for the `Brand` model. This serializer handles serialization, validation, and + creation/updating of Brand objects. It is used to manage the Brand data when making API + requests (GET, PUT). + """ + + # Fields + admins = UserSerializer( + many=True, + help_text=( + 'Specifies a set of Users to be designated as administrators, this grants each ' + 'of them access to this dashboard.' + ) + ) + + # Appearance + _str_display = 'name' + _list_fields = ['id', 'name'] + _item_fields = ['id', 'name', 'website', 'description', 'site_title', 'site_description', 'admins'] + + # Metadata + class Meta: + model = Brand + # Fields that should be included in the serialized output + exclude = [ + 'logo_path', 'index_path', 'about_menu', + 'allowed_tabs', 'footer_images', 'is_administrable', + 'collections_excluded_from_filters', 'created', 'modified', + 'org_user_managed', 'users', + ] + extra_kwargs = { + # RO + 'id': { 'read_only': True, 'required': False }, + 'name': { 'read_only': True, 'required': False }, + 'logo_path': { 'read_only': True, 'required': False }, + 'index_path': { 'read_only': True, 'required': False }, + 'about_menu': { 'read_only': True, 'required': False }, + 'allowed_tabs': { 'read_only': True, 'required': False }, + 'footer_images': { 'read_only': True, 'required': False }, + 'is_administrable': { 'read_only': True, 'required': False }, + 'collections_excluded_from_filters': { 'read_only': True, 'required': False }, + # WO + 'created': { 'write_only': True, 'read_only': False, 'required': False }, + 'modified': { 'write_only': True, 'read_only': False, 'required': False }, + 'created_by': { 'write_only': True, 'read_only': False, 'required': False }, + 'updated_by': { 'write_only': True, 'read_only': False, 'required': False }, + # WO | RO + 'overrides': { 'help_text': 'Overrides website behaviour for this specific Brand, please seek Administrator advice before modifying.' }, + 'description': { 'style': { 'as_type': 'TextField' }, 'help_text': 'Human-friendly description of the Brand (appears on the home page)' }, + 'website': { 'help_text': 'Specifies the Brand\'s website (used for back-linking)' }, + 'site_title': { 'help_text': 'Specifies the title of the website, e.g. as it appears within the browser tab' }, + 'site_description': { 'help_text': 'Optionally specify the site description metadata tag, e.g. as it appears on search engines' }, + } + + # GET + def to_representation(self, instance): + """ + Convert a Brand instance into a JSON-serializable dictionary. + + Ensures that the `collections_excluded_from_filters` field is always returned + as a list, even if it is empty or None. + + Args: + instance (Brand): The Brand instance to serialize. + + Returns: + dict: The serialized Brand data. + """ + if isinstance(instance, list): + instance = self.Meta.model.objects.filter(pk__in=instance) + instance = instance if instance.exists() else None + elif isinstance(instance, int): + instance = self.Meta.model.objects.filter(pk=instance) + if instance.exists(): + instance = instance.first() + else: + instance = None + + if instance is not None: + data = super(BrandSerializer, self).to_representation(instance) + return data + return None + + def resolve_format(self): + return { 'type': 'ForeignKey' } + + def resolve_options(self): + return list(self.Meta.model.objects.all().values('name', 'pk')) + + # POST / PUT + def create(self, validated_data): + """ + Create and save a new Brand instance using the validated data. + + Args: + validated_data (dict): The validated data for creating the Brand. + + Returns: + Brand: The newly created Brand instance. + """ + return self._create(self.Meta.model, validated_data) + + def update(self, instance, validated_data): + """ + Update an existing Brand instance with the validated data. + + Updates each field of the instance with the corresponding validated data and + saves the changes. Additionally, updates the `modified` timestamp to the current time. + + Args: + instance (Brand): The Brand instance to update. + validated_data (dict): The validated data to update the Brand with. + + Returns: + Brand: The updated Brand instance. + """ + instance = self._update(instance, validated_data) + instance.modified = make_aware(datetime.datetime.now()) # Set `modified` timestamp + instance.save() + return instance + + # Instance & Field validation + def validate(self, data): + """ + Validate the provided data before creating or updating a Brand. + + Args: + data (dict): The data to validate. + + Returns: + dict: The validated data. + """ + user = self._get_user() + instance = getattr(self, 'instance') if hasattr(self, 'instance') else None + + prev_users = instance.users.all() if instance is not None else User.objects.none() + prev_admins = instance.admins.all() if instance is not None else User.objects.none() + + users = data.get('users') if isinstance(data.get('users'), list) else prev_users + users = list(User.objects.filter(id__in=users)) + if user is not None and not next((x for x in users if x.id != user.id), None): + users.append(user) + + admins = data.get('admins') if isinstance(data.get('admins'), list) else prev_admins + admins = list(User.objects.filter(id__in=admins)) + if user is not None and not next((x for x in admins if x.id != user.id), None): + admins.append(user) + + data.update({ + 'users': users, + 'admins': admins, + }) + + return data + + +class BrandEndpoint(BaseEndpoint): + """ + API endpoint for managing `Brand` resources. + + This view handles API requests related to the `Brand` model, including retrieving, + updating, and creating Brand instances. It uses the `BrandSerializer` to handle + serialization and validation. + """ + + model = Brand + fields = [] + serializer_class = BrandSerializer + queryset = Brand.objects.all() + + reverse_name_default = 'brand_target' # Default reverse URL name for this endpoint + + def get_queryset(self, *args, **kwargs): + """ + Override the `get_queryset` method to return the queryset for the `Brand` model. + + Args: + *args: Positional arguments passed to the method. + **kwargs: Keyword arguments passed to the method. + + Returns: + QuerySet: A queryset of all Brand objects. + """ + return self.queryset + + def get(self, request, *args, **kwargs): + """ + Handle GET requests to retrieve the current brand. + + This method retrieves the current brand associated with the request and + returns its data serialized via `BrandSerializer`. + + Args: + request (Request): The incoming request. + + Returns: + Response: The serialized data of the current brand. + """ + current_brand = model_utils.try_get_brand(request) + if current_brand is None: + return Response( + data={ 'detail': 'Unknown Brand context' }, + status=status.HTTP_400_BAD_REQUEST + ) + kwargs.update(pk=current_brand.id) + return self.retrieve(request, *args, **kwargs) + + def put(self, request, *args, **kwargs): + """ + Handle PUT requests to update the current brand. + + This method retrieves the current brand associated with the request, validates + the data, and updates the brand's information. + + Args: + request (Request): The incoming request. + *args: Positional arguments passed to the method. + **kwargs: Keyword arguments passed to the method. + + Returns: + Response: The response containing the updated brand data. + """ + partial = kwargs.pop('partial', False) + instance = model_utils.try_get_brand(request) + + serializer = self.get_serializer(instance, data=request.data, partial=partial) + try: + serializer.is_valid(raise_exception=True) + except serializers.ValidationError as e: + if isinstance(e.detail, dict): + detail = {k: v for k, v in e.detail.items() if k not in ('users', 'admins')} + if len(detail) > 0: + raise serializers.ValidationError(detail=detail) + except Exception as e: + raise e + + data = serializer.data + data = self.get_serializer(instance).validate(data) + + admins = data.pop('admins', []) + users = data.pop('users', []) + + instance.__dict__.update(**data) + instance.save() + + instance.admins.set(admins) + instance.users.set(users) + + return Response(self.get_serializer(instance).data) + + def post(self, request, *args, **kwargs): + """ + Handle POST requests to create a new Brand instance. + """ + raise Response(status=status.HTTP_403_PERMISSION_DENIED) diff --git a/CodeListLibrary_project/clinicalcode/views/dashboard/targets/HDRNCategoryTarget.py b/CodeListLibrary_project/clinicalcode/views/dashboard/targets/HDRNCategoryTarget.py new file mode 100644 index 000000000..d81184898 --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/views/dashboard/targets/HDRNCategoryTarget.py @@ -0,0 +1,100 @@ +"""Brand Dashboard: API endpoints relating to Template model""" +import datetime + +from rest_framework import status +from django.utils.timezone import make_aware +from rest_framework.response import Response + +from .BaseTarget import BaseSerializer, BaseEndpoint +from clinicalcode.entity_utils import gen_utils +from clinicalcode.models.HDRNDataCategory import HDRNDataCategory + + +class HDRNCategorySerializer(BaseSerializer): + """""" + + # Appearance + _str_display = 'name' + _list_fields = ['id', 'name'] + _item_fields = ['id', 'name', 'description'] + + # Metadata + class Meta: + model = HDRNDataCategory + exclude = ['created', 'modified'] + extra_kwargs = { + # RO + 'id': { 'read_only': True, 'required': False }, + # WO + 'created': { 'write_only': True, 'read_only': False, 'required': False }, + 'modified': { 'write_only': True, 'read_only': False, 'required': False }, + # WO | RO + 'description': { 'style': { 'as_type': 'TextField' } }, + 'metadata': { 'help_text': 'Optionally specify a JSON object describing metadata related to this entity.' }, + } + + # GET + def to_representation(self, instance): + if isinstance(instance, list): + instance = self.Meta.model.objects.filter(pk__in=instance) + instance = instance if instance.exists() else None + elif isinstance(instance, int): + instance = self.Meta.model.objects.filter(pk=instance) + if instance.exists(): + instance = instance.first() + else: + instance = None + + if instance is not None: + data = super(HDRNCategorySerializer, self).to_representation(instance) + return data + return None + + def resolve_format(self): + return { 'type': 'ForeignKey' } + + def resolve_options(self): + return list(self.Meta.model.objects.all().values('name', 'pk')) + + # POST / PUT + def create(self, validated_data): + return self._create(self.Meta.model, validated_data) + + def update(self, instance, validated_data): + instance = self._update(instance, validated_data) + instance.modified = make_aware(datetime.datetime.now()) # Set `modified` timestamp + instance.save() + return instance + + +class HDRNCategoryEndpoint(BaseEndpoint): + """API views for the `HDRNSite` model""" + model = HDRNDataCategory + fields = [] + queryset = HDRNDataCategory.objects.all() + serializer_class = HDRNCategorySerializer + + reverse_name_default = 'hdrn_category_target' + reverse_name_retrieve = 'hdrn_category_target_with_id' + + # Endpoint methods + def get(self, request, *args, **kwargs): + inst_id = kwargs.get('pk', None) + if inst_id: + inst_id = gen_utils.try_value_as_type(inst_id, 'int') + if inst_id is None: + return Response( + data={'detail': 'Expected int-like `pk` parameter'}, + status=status.HTTP_400_BAD_REQUEST + ) + + kwargs.update(pk=inst_id) + return self.retrieve(request, *args, **kwargs) + + return self.list(request, *args, **kwargs) + + def put(self, request, *args, **kwargs): + return self.update(request, partial=True, *args, **kwargs) + + def post(self, request, *args, **kwargs): + return self.create(request, *args, **kwargs) diff --git a/CodeListLibrary_project/clinicalcode/views/dashboard/targets/HDRNDataAssetTarget.py b/CodeListLibrary_project/clinicalcode/views/dashboard/targets/HDRNDataAssetTarget.py new file mode 100644 index 000000000..7c0a6d92f --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/views/dashboard/targets/HDRNDataAssetTarget.py @@ -0,0 +1,224 @@ +import datetime + +from django.utils.timezone import make_aware +from rest_framework import status, serializers +from rest_framework.response import Response + +from .BaseTarget import BaseEndpoint, BaseSerializer +from .HDRNSiteTarget import HDRNSiteSerializer +from .HDRNCategoryTarget import HDRNCategorySerializer +from .HDRNJurisdictionTarget import HDRNJurisdictionSerializer +from clinicalcode.entity_utils import gen_utils +from clinicalcode.models.HDRNSite import HDRNSite +from clinicalcode.models.HDRNJurisdiction import HDRNJurisdiction +from clinicalcode.models.HDRNDataAsset import HDRNDataAsset +from clinicalcode.models.HDRNDataCategory import HDRNDataCategory + + +class HDRNDataAssetSerializer(BaseSerializer): + """ + Serializer for HDRN Data Asset. + """ + + # Fields + site = HDRNSiteSerializer(many=False, required=False) + regions = HDRNJurisdictionSerializer(many=True, required=False) + data_categories = HDRNCategorySerializer(many=True, required=False) + + # Appearance + _str_display = 'name' + _list_fields = ['id', 'name'] + _item_fields = ['id', 'name', 'description'] + + # Metadata + class Meta: + model = HDRNDataAsset + exclude = ['created', 'modified'] + extra_kwargs = { + # RO + 'id': { 'read_only': True, 'required': False }, + # WO + 'created': { 'write_only': True, 'read_only': False, 'required': False }, + 'modified': { 'write_only': True, 'read_only': False, 'required': False }, + # RO | WO + 'name': { 'min_length': 3, 'required': True }, + 'site': { 'required': False }, + 'data_categories': { 'required': False }, + 'scope': { 'style': { 'as_type': 'TextField' } }, + 'purpose': { 'style': { 'as_type': 'TextField' } }, + 'description': { 'style': { 'as_type': 'TextField' } }, + 'collection_period': { 'style': { 'as_type': 'TextField' } }, + } + + # GET + def resolve_options(self): + return list(self.Meta.model.objects.all().values('name', 'pk')) + + # POST / PUT + def create(self, validated_data): + """ + Method to create a new HDRNDataAsset instance. + """ + return self._create(self.Meta.model, validated_data) + + def update(self, instance, validated_data): + """ + Update an existing HDRNDataAsset instance. + """ + instance = self._update(instance, validated_data) + instance.modified = make_aware(datetime.datetime.now()) # Set `modified` timestamp + instance.save() + return instance + + # Instance & Field validation + def validate(self, data): + """ + Custom validation method for `HDRNDataAsset` fields. + """ + # Validate `data_categories` field (should be a list of integers) + data_categories = data.get('data_categories', []) + if isinstance(data_categories, list) and not all(isinstance(i, int) for i in data_categories): + raise serializers.ValidationError({ + 'data_categories': 'Data Categories, if provided, must be a list of pk.' + }) + elif isinstance(data_categories, list): + data_categories = HDRNDataCategory.objects.filter(pk__in=data_categories) + if data_categories is None or not data_categories.exists(): + raise serializers.ValidationError({ + 'data_categories': 'Failed to find specified `data_categories`' + }) + data_categories = list(data_categories.values_list('id', flat=True)) + else: + data_categories = None + + # Regions: M2M + regions = data.get('regions', []) + if isinstance(regions, list) and not all(isinstance(i, int) for i in regions): + raise serializers.ValidationError({ + 'regions': 'Regions, if provided, must be a list of known HDRN Jurisdictions.' + }) + elif isinstance(regions, list): + regions = HDRNJurisdiction.objects.filter(pk__in=regions) + if regions is None or not regions.exists(): + raise serializers.ValidationError({ + 'regions': 'Failed to find specified `regions`' + }) + + regions = list(regions) + else: + regions = None + + # Site: FK + site = data.get('site', None) + if site is not None and not isinstance(site, int): + raise serializers.ValidationError({ + 'site': 'Site, if provided, must be a valid `pk` value' + }) + elif isinstance(site, int): + site = HDRNSite.objects.filter(pk=site) + if site is None or not site.exists(): + raise serializers.ValidationError({ + 'site': 'Found no existing object at specified `site` pk' + }) + site = site.first() + else: + site = None + + uuid = data.get('hdrn_uuid') + if not gen_utils.is_valid_uuid(uuid): + uuid = None + + data.update({ + 'site': site, + 'regions': regions, + 'hdrn_uuid': uuid, + 'data_categories': data_categories, + }) + + return data + + +class HDRNDataAssetEndpoint(BaseEndpoint): + """API views for the HDRN Data Asset model.""" + + model = HDRNDataAsset + queryset = HDRNDataAsset.objects.all() + serializer_class = HDRNDataAssetSerializer + + reverse_name_default = 'hdrn_data_asset_target' + reverse_name_retrieve = 'hdrn_data_asset_target_with_id' + + def get(self, request, *args, **kwargs): + """ + Handle GET requests. + Retrieves the list of HDRNDataAsset instances or a single instance by ID. + """ + inst_id = kwargs.get('pk', None) + if inst_id: + # Convert pk to integer and validate + inst_id = gen_utils.try_value_as_type(inst_id, 'int') + if inst_id is None: + return Response( + data={'detail': 'Expected int-like `pk` parameter'}, + status=status.HTTP_400_BAD_REQUEST + ) + + kwargs.update(pk=inst_id) + return self.retrieve(request, *args, **kwargs) + + return self.list(request, *args, **kwargs) + + def put(self, request, *args, **kwargs): + """ + Handle PUT requests to update an HDRNDataAsset instance. + """ + partial = kwargs.pop('partial', False) + instance = self.get_object(*args, **kwargs) + + serializer = self.get_serializer(instance, data=request.data, partial=partial) + try: + serializer.is_valid(raise_exception=True) + except serializers.ValidationError as e: + if isinstance(e.detail, dict): + detail = {k: v for k, v in e.detail.items() if k not in ('site', 'data_categories', 'regions')} + if len(detail) > 0: + raise serializers.ValidationError(detail=detail) + except Exception as e: + raise e + + data = serializer.data + data = self.get_serializer().validate(data) + + regions = data.pop('regions', []) + + instance.__dict__.update(**data) + instance.site = data.get('site') + instance.save() + + instance.regions.set(regions) + + return Response(self.get_serializer(instance).data) + + def post(self, request, *args, **kwargs): + """ + Handle POST requests to create a new HDRNDataAsset instance. + """ + serializer = self.get_serializer(data=request.data) + try: + serializer.is_valid(raise_exception=True) + except serializers.ValidationError as e: + if isinstance(e.detail, dict): + detail = {k: v for k, v in e.detail.items() if k not in ('site', 'data_categories', 'regions')} + if len(detail) > 0: + raise serializers.ValidationError(detail=detail) + except Exception as e: + raise e + + data = serializer.data + data = self.get_serializer().validate(data) + regions = data.pop('regions', []) + + instance, _ = self.model.objects.get_or_create(**data) + instance.regions.set(regions) + + return Response(self.get_serializer(instance).data) diff --git a/CodeListLibrary_project/clinicalcode/views/dashboard/targets/HDRNJurisdictionTarget.py b/CodeListLibrary_project/clinicalcode/views/dashboard/targets/HDRNJurisdictionTarget.py new file mode 100644 index 000000000..7fb329db8 --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/views/dashboard/targets/HDRNJurisdictionTarget.py @@ -0,0 +1,84 @@ +"""Brand Dashboard: API endpoints relating to Template model""" +import datetime + +from django.utils.timezone import make_aware +from rest_framework import status +from rest_framework.response import Response + +from clinicalcode.entity_utils import gen_utils +from clinicalcode.models.HDRNJurisdiction import HDRNJurisdiction +from .BaseTarget import BaseSerializer, BaseEndpoint + + +class HDRNJurisdictionSerializer(BaseSerializer): + """""" + + # Appearance + _str_display = 'name' + _list_fields = ['id', 'name', 'abbreviation'] + _item_fields = ['id', 'name', 'abbreviation', 'description'] + + # Metadata + class Meta: + model = HDRNJurisdiction + exclude = ['created', 'modified'] + extra_kwargs = { + # RO + 'id': { 'read_only': True, 'required': False }, + # WO + 'created': { 'write_only': True, 'read_only': False, 'required': False }, + 'modified': { 'write_only': True, 'read_only': False, 'required': False }, + # WO | RO + 'description': { 'style': { 'as_type': 'TextField' } }, + 'metadata': { 'help_text': 'Optionally specify a JSON object describing metadata related to this entity.' }, + } + + # GET + def resolve_format(self): + return { 'type': 'ForeignKey' } + + def resolve_options(self): + return list(self.Meta.model.objects.all().values('name', 'pk')) + + # POST / PUT + def create(self, validated_data): + return self._create(self.Meta.model, validated_data) + + def update(self, instance, validated_data): + instance = self._update(instance, validated_data) + instance.modified = make_aware(datetime.datetime.now()) # Set `modified` timestamp + instance.save() + return instance + + +class HDRNJurisdictionEndpoint(BaseEndpoint): + """API views for the `HDRNJurisdiction` model""" + model = HDRNJurisdiction + fields = [] + queryset = HDRNJurisdiction.objects.all() + serializer_class = HDRNJurisdictionSerializer + + reverse_name_default = 'hdrn_jurisdiction_target' + reverse_name_retrieve = 'hdrn_jurisdiction_target_with_id' + + # Endpoint methods + def get(self, request, *args, **kwargs): + inst_id = kwargs.get('pk', None) + if inst_id: + inst_id = gen_utils.try_value_as_type(inst_id, 'int') + if inst_id is None: + return Response( + data={'detail': 'Expected int-like `pk` parameter'}, + status=status.HTTP_400_BAD_REQUEST + ) + + kwargs.update(pk=inst_id) + return self.retrieve(request, *args, **kwargs) + + return self.list(request, *args, **kwargs) + + def put(self, request, *args, **kwargs): + return self.update(request, partial=True, *args, **kwargs) + + def post(self, request, *args, **kwargs): + return self.create(request, *args, **kwargs) diff --git a/CodeListLibrary_project/clinicalcode/views/dashboard/targets/HDRNSiteTarget.py b/CodeListLibrary_project/clinicalcode/views/dashboard/targets/HDRNSiteTarget.py new file mode 100644 index 000000000..4f3a474ed --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/views/dashboard/targets/HDRNSiteTarget.py @@ -0,0 +1,84 @@ +"""Brand Dashboard: API endpoints relating to Template model""" +import datetime + +from django.utils.timezone import make_aware +from rest_framework import status +from rest_framework.response import Response + +from clinicalcode.entity_utils import gen_utils +from clinicalcode.models.HDRNSite import HDRNSite +from .BaseTarget import BaseSerializer, BaseEndpoint + + +class HDRNSiteSerializer(BaseSerializer): + """""" + + # Appearance + _str_display = 'name' + _list_fields = ['id', 'name', 'abbreviation'] + _item_fields = ['id', 'name', 'abbreviation', 'description'] + + # Metadata + class Meta: + model = HDRNSite + exclude = ['created', 'modified'] + extra_kwargs = { + # RO + 'id': { 'read_only': True, 'required': False }, + # WO + 'created': { 'write_only': True, 'read_only': False, 'required': False }, + 'modified': { 'write_only': True, 'read_only': False, 'required': False }, + # WO | RO + 'description': { 'style': { 'as_type': 'TextField' } }, + 'metadata': { 'help_text': 'Optionally specify a JSON object describing metadata related to this entity.' }, + } + + # GET + def resolve_format(self): + return { 'type': 'ForeignKey' } + + def resolve_options(self): + return list(self.Meta.model.objects.all().values('name', 'pk')) + + # POST / PUT + def create(self, validated_data): + return self._create(self.Meta.model, validated_data) + + def update(self, instance, validated_data): + instance = self._update(instance, validated_data) + instance.modified = make_aware(datetime.datetime.now()) # Set `modified` timestamp + instance.save() + return instance + + +class HDRNSiteEndpoint(BaseEndpoint): + """API views for the `HDRNSite` model""" + model = HDRNSite + fields = [] + queryset = HDRNSite.objects.all() + serializer_class = HDRNSiteSerializer + + reverse_name_default = 'hdrn_site_target' + reverse_name_retrieve = 'hdrn_site_target_with_id' + + # Endpoint methods + def get(self, request, *args, **kwargs): + inst_id = kwargs.get('pk', None) + if inst_id: + inst_id = gen_utils.try_value_as_type(inst_id, 'int') + if inst_id is None: + return Response( + data={'detail': 'Expected int-like `pk` parameter'}, + status=status.HTTP_400_BAD_REQUEST + ) + + kwargs.update(pk=inst_id) + return self.retrieve(request, *args, **kwargs) + + return self.list(request, *args, **kwargs) + + def put(self, request, *args, **kwargs): + return self.update(request, partial=True, *args, **kwargs) + + def post(self, request, *args, **kwargs): + return self.create(request, *args, **kwargs) diff --git a/CodeListLibrary_project/clinicalcode/views/dashboard/targets/OrganisationTarget.py b/CodeListLibrary_project/clinicalcode/views/dashboard/targets/OrganisationTarget.py new file mode 100644 index 000000000..74e817f26 --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/views/dashboard/targets/OrganisationTarget.py @@ -0,0 +1,459 @@ +"""Brand Dashboard: API endpoints relating to Organisation model""" +from django.db import transaction +from rest_framework import status, serializers +from django.db.models import Q, F +from django.contrib.auth import get_user_model +from django.core.paginator import EmptyPage, Paginator, Page +from rest_framework.response import Response + +from .UserTarget import UserSerializer +from .BaseTarget import BaseSerializer, BaseEndpoint +from .BrandTarget import BrandSerializer +from clinicalcode.entity_utils import constants, gen_utils, model_utils +from clinicalcode.models.Brand import Brand +from clinicalcode.models.Organisation import Organisation, OrganisationAuthority, OrganisationMembership + + +User = get_user_model() + + +class OrganisationAuthoritySerializer(BaseSerializer): + """Responsible for serialising the `Organisation.brands` through-field model and to handle PUT/POST validation""" + + # Fields + brand = BrandSerializer(many=False) + + # Appearance + _str_display = 'id' + + # Metadata + class Meta: + model = OrganisationAuthority + fields = '__all__' + extra_kwargs = { + # RO + 'id': { 'read_only': True, 'required': False }, + } + + # GET + def resolve_format(self): + return { + 'type': 'through', + 'component': 'OrgAuthoritySelector', + 'fields': { + 'brand': 'ForeignKey', + 'can_post': 'BooleanField', + 'can_moderate': 'BooleanField', + } + } + + def resolve_options(self): + return { + 'brand': list(Brand.objects.all().values('name', 'pk')), + } + + +class OrganisationMembershipSerializer(BaseSerializer): + """Responsible for serialising the `Organisation.members` through-field model and to handle PUT/POST validation""" + + # Fields + user = UserSerializer(many=False) + role = serializers.ChoiceField(choices=[(e.value, e.name) for e in constants.ORGANISATION_ROLES]) + + # Appearance + _str_display = 'id' + + # Metadata + class Meta: + model = OrganisationMembership + fields = '__all__' + extra_kwargs = { + # RO + 'id': { 'read_only': True, 'required': False }, + 'joined': { 'read_only': True, 'required': False }, + # WO | RO + 'description': { 'style': { 'as_type': 'TextField' } }, + } + + # GET + def resolve_format(self): + return { + 'type': 'through', + 'component': 'OrgMemberSelector', + 'fields': { + 'user': 'ForeignKey', + 'role': 'ChoiceField', + } + } + + def resolve_options(self): + brand = self._get_brand() + + records = None + if brand is not None: + vis_rules = brand.get_vis_rules() + if isinstance(vis_rules, dict): + allow_null = vis_rules.get('allow_null') + allowed_brands = vis_rules.get('ids') + if isinstance(allowed_brands, list) and isinstance(allow_null, bool) and allow_null: + records = User.objects.filter(Q(accessible_brands__id__isnull=True) | Q(accessible_brands__id__in=allowed_brands)) + elif isinstance(allowed_brands, list): + records = User.objects.filter(accessible_brands__id__in=allowed_brands) + elif isinstance(allow_null, bool) and allow_null: + records = User.objects.filter(Q(accessible_brands__id__isnull=True) | Q(accessible_brands__id__in=[brand.id])) + + if records is None: + records = User.objects.filter(accessible_brands__id=brand.id) + else: + records = User.objects.all() + + return { + 'role': [{ 'name': e.name, 'pk': e.value } for e in constants.ORGANISATION_ROLES], + 'user': list(records.annotate(name=F('username')).values('name', 'pk')) if records is not None else [], + } + + +class OrganisationSerializer(BaseSerializer): + """Responsible for serialising the `Organisation` model and to handle PUT/POST validation""" + + # Fields + owner = UserSerializer( + required=True, + many=False, + help_text=( + 'The owner of an organisation is automatically given administrative privileges ' + 'within an Organisation and does not need to be included as a Member.' + ) + ) + brands = OrganisationAuthoritySerializer( + source='organisationauthority_set', + many=True, + help_text=( + 'Specifies the visibility & the control this Organisation has on different Brands.' + ) + ) + members = OrganisationMembershipSerializer( + source='organisationmembership_set', + many=True, + help_text=( + 'Describes a set of users associated with this Organisation and the role they play within it. ' + 'Note that the owner of an Organisation does not need to be included as a member of an Organisation, ' + 'they are automatically assigned an Administrative role.' + ) + ) + + # Appearance + _str_display = 'name' + _list_fields = ['id', 'name', 'owner'] + _item_fields = [ + 'id', 'slug', 'name', + 'description', 'website', 'email', + 'owner', 'members', 'brands', + ] + + # Metadata + class Meta: + model = Organisation + exclude = ['created'] + extra_kwargs = { + # RO + 'id': { 'read_only': True, 'required': False }, + 'slug': { 'read_only': True, 'required': False }, + # WO + 'created': { 'write_only': True, 'required': False }, + # WO | RO + 'email': { 'help_text': 'Specifies this Organisation\'s e-mail address (optional).'}, + 'website': { 'help_text': 'Specifies this Organisation\'s website (optional).'}, + } + + # Instance & Field validation + def validate(self, data): + """ + Validate the provided data before creating or updating a Brand. + + Args: + data (dict): The data to validate. + + Returns: + dict: The validated data. + """ + instance = getattr(self, 'instance') if hasattr(self, 'instance') else None + current_brand = self._get_brand() + + prev_brands = instance.admins.all() if instance is not None else OrganisationAuthority.objects.none() + prev_members = instance.members.all() if instance is not None else OrganisationMembership.objects.none() + + data_brands = data.get('brands') if isinstance(data.get('brands'), list) else None + if isinstance(data_brands, list): + brands = [] + for x in data_brands: + brand = Brand.objects.filter(id=x.get('brand_id', -1)) + if not brand.exists(): + continue + + brands.append({ + 'brand': brand.first(), + 'can_post': not not x.get('can_post', False), + 'can_moderate': not not x.get('can_moderate', False), + }) + + if current_brand and not next((x for x in brands if x.get('brand').id == current_brand.id), None): + brands.append({ + 'brand': current_brand, + 'can_post': False, + 'can_moderate': False, + }) + else: + brands = prev_brands + + data_members = data.get('members') if isinstance(data.get('members'), list) else None + if isinstance(data_members, list): + members = [] + for x in data_members: + user = User.objects.filter(id=x.get('user_id', -1)) + if not user.exists(): + continue + + members.append({ + 'user': user.first(), + 'role': x.get('role', 0), + }) + else: + members = prev_members + + owner = data.get('owner', instance.owner if instance is not None else None) + if isinstance(owner, int): + owner = User.objects.filter(id=owner) + if owner is not None or owner.exists(): + owner = owner.first() + + if not isinstance(owner, User): + raise serializers.ValidationError({ + 'owner': 'The `owner` field must be supplied.' + }) + + data.update({ + 'owner': owner, + 'brands': brands, + 'members': members, + }) + + return data + +class OrganisationEndpoint(BaseEndpoint): + """Responsible for API views relating to `Organisation` model accessed via Brand dashboard""" + + # Metadata + model = Organisation + fields = [] + queryset = Organisation.objects.all() + serializer_class = OrganisationSerializer + + # View behaviour + reverse_name_default = 'brand_user_target' + reverse_name_retrieve = 'brand_user_target_with_id' + + # Endpoint methods + def get(self, request, *args, **kwargs): + inst_id = kwargs.get('pk', None) + if inst_id: + inst_id = gen_utils.try_value_as_type(inst_id, 'int') + if inst_id is None: + return Response( + data={ 'detail': 'Expected int-like `pk` parameter' }, + status=status.HTTP_400_BAD_REQUEST + ) + + kwargs.update(pk=inst_id) + return self.retrieve(request, *args, **kwargs) + + return self.list(request, *args, **kwargs) + + def put(self, request, *args, **kwargs): + """ + Handle PUT requests to update an Organisation instance. + """ + partial = kwargs.pop('partial', False) + instance = self.get_object(*args, **kwargs) + + serializer = self.get_serializer(instance, data=request.data, partial=partial) + try: + serializer.is_valid(raise_exception=True) + except serializers.ValidationError as e: + if isinstance(e.detail, dict): + detail = {k: v for k, v in e.detail.items() if k not in ('owner', 'brands', 'members')} + if len(detail) > 0: + raise serializers.ValidationError(detail=detail) + except Exception as e: + raise e + + try: + data = serializer.data + data = self.get_serializer().validate(data) + + brands = data.pop('brands', None) + members = data.pop('members', None) + + with transaction.atomic(): + instance.__dict__.update(**data) + instance.owner = data.get('owner') + instance.save() + + if isinstance(brands, list): + instance.brands.clear() + + brands = Organisation.brands.through.objects.bulk_create([ + Organisation.brands.through(**({ 'organisation': instance } | obj)) for obj in brands + ]) + elif not brands is None: + instance.brands.set(brands) + else: + instance.brands.set([]) + + if isinstance(members, list): + instance.members.clear() + + members = Organisation.members.through.objects.bulk_create([ + Organisation.members.through(**({ 'organisation': instance } | obj)) for obj in members + ]) + elif not members is None: + instance.members.set(members) + else: + instance.members.set([]) + except Exception as e: + raise e + else: + return Response(self.get_serializer(instance).data) + + def post(self, request, *args, **kwargs): + """ + Handle POST requests to create a new Organisation instance. + """ + serializer = self.get_serializer(data=request.data) + try: + serializer.is_valid(raise_exception=True) + except serializers.ValidationError as e: + if isinstance(e.detail, dict): + detail = {k: v for k, v in e.detail.items() if k not in ('owner', 'brands', 'members')} + if len(detail) > 0: + raise serializers.ValidationError(detail=detail) + except Exception as e: + raise e + + try: + data = serializer.data + data = self.get_serializer().validate(data) + + brands = data.pop('brands', None) + members = data.pop('members', None) + + with transaction.atomic(): + instance, created = self.model.objects.get_or_create(**data) + if created: + if isinstance(brands, list): + instance.brands.clear() + + brands = Organisation.brands.through.objects.bulk_create([ + Organisation.brands.through(**({ 'organisation': instance } | obj)) for obj in brands + ]) + elif not brands is None: + instance.brands.set(brands) + else: + instance.brands.set([]) + + if isinstance(members, list): + instance.members.clear() + + members = Organisation.members.through.objects.bulk_create([ + Organisation.members.through(**({ 'organisation': instance } | obj)) for obj in members + ]) + elif not members is None: + instance.members.set(members) + else: + instance.members.set([]) + except Exception as e: + raise e + else: + return Response(self.get_serializer(instance).data) + + # Override queryset + def list(self, request, *args, **kwargs): + params = self._get_query_params(request) + + page = gen_utils.try_value_as_type(params.get('page'), 'int', default=1) + page = max(page, 1) + + page_size = params.get('page_size', '1') + if page_size not in constants.PAGE_RESULTS_SIZE: + page_size = constants.PAGE_RESULTS_SIZE.get('1') + else: + page_size = constants.PAGE_RESULTS_SIZE.get(page_size) + + records = self.get_queryset(request, **params) + if records is None: + page_obj = Page(Organisation.objects.none(), 0, Paginator([], page_size, allow_empty_first_page=True)) + else: + records = records.order_by('id') + pagination = Paginator(records, page_size, allow_empty_first_page=True) + try: + page_obj = pagination.page(page) + except EmptyPage: + page_obj = pagination.page(pagination.num_pages) + + results = self.serializer_class(page_obj.object_list, many=True) + response = self._format_list_data({ + 'detail': self._format_page_details(page_obj), + 'results': results.data, + }) + + self._format_list_data(response) + return Response(response) + + def get_queryset(self, *args, **kwargs): + request = self.request + + params = getattr(self, 'filter', None) + if isinstance(params, dict): + params = kwargs | params + else: + params = kwargs + + brand = model_utils.try_get_brand(request) + records = None + if brand is not None: + vis_rules = brand.get_vis_rules() + if isinstance(vis_rules, dict): + allow_null = vis_rules.get('allow_null') + allowed_brands = vis_rules.get('ids') + if isinstance(allowed_brands, list) and isinstance(allow_null, bool) and allow_null: + records = Organisation.objects.filter( + Q(brands__isnull=allow_null) | \ + Q(brands__in=allowed_brands) + ) + elif isinstance(allowed_brands, list): + records = Organisation.objects.filter(brands__in=allowed_brands) + elif isinstance(allow_null, bool) and allow_null: + records = Organisation.objects.filter(Q(brands__isnull=True) | Q(brands__in=[brand.id])) + + if records is None: + records = Organisation.objects.filter(brands__id__contains=brand.id) + else: + records = Organisation.objects.all() + + page = gen_utils.try_value_as_type(params.get('page'), 'int', default=1) + page = max(page, 1) + + search = params.get('search', None) + query = gen_utils.parse_model_field_query(Organisation, params, ignored_fields=['description']) + + if query is not None: + records = records.filter(**query) + + if not gen_utils.is_empty_string(search) and len(search) >= 3: + records = records.filter( + Q(name__icontains=search) | \ + Q(email__icontains=search) | \ + Q(description__icontains=search) + ) + + return records diff --git a/CodeListLibrary_project/clinicalcode/views/dashboard/targets/PeopleTarget.py b/CodeListLibrary_project/clinicalcode/views/dashboard/targets/PeopleTarget.py new file mode 100644 index 000000000..c817e5af0 --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/views/dashboard/targets/PeopleTarget.py @@ -0,0 +1,71 @@ +from functools import partial + +from rest_framework import status, serializers +from rest_framework.response import Response + +from clinicalcode.entity_utils import gen_utils, constants +from clinicalcode.models.Organisation import OrganisationMembership +from .BaseTarget import BaseSerializer, BaseEndpoint + + +class OrganisationMembershipSerializer(BaseSerializer): + + class Meta: + model = OrganisationMembership + fields = ['id', 'user', 'organisation', 'role', 'joined'] + + def to_representation(self, instance): + data = super(OrganisationMembershipSerializer, self).to_representation(instance) + return data + + def create(self, validated_data): + + return self._create(self.Meta.model, validated_data) + + def update(self, instance, validated_data): + instance = self._update(instance, validated_data) + instance.save() + return instance + + + def validate(self, data): + role = data.get('role') + if role not in [e.value for e in constants.ORGANISATION_ROLES]: + raise serializers.ValidationError(f"Invalid role.") + + return data + +class PeopleEndpoint(BaseEndpoint): + """Responsible for API views relating to `Template` model accessed via Brand dashboard""" + + model = OrganisationMembership + fields = [] + queryset = OrganisationMembership.objects.all() + serializer_class = OrganisationMembershipSerializer + + # View behaviour + reverse_name_default = 'brand_people_target' + # View behaviour + reverse_name_retrieve = 'brand_people_target_with_id' + + # Endpoint methods + def get(self, request, *args, **kwargs): + inst_id = kwargs.get('pk', None) + if inst_id: + inst_id = gen_utils.try_value_as_type(inst_id, 'int') + if inst_id is None: + return Response( + data={'detail': 'Expected int-like `pk` parameter'}, + status=status.HTTP_400_BAD_REQUEST + ) + + kwargs.update(pk=inst_id) + return self.retrieve(request, *args, **kwargs) + + return self.list(request, *args, **kwargs) + + def put(self, request, *args, **kwargs): + return self.update(request, partial=True, *args, **kwargs) + + def post(self, request, *args, **kwargs): + return self.create(request, *args, **kwargs) \ No newline at end of file diff --git a/CodeListLibrary_project/clinicalcode/views/dashboard/targets/TagTarget.py b/CodeListLibrary_project/clinicalcode/views/dashboard/targets/TagTarget.py new file mode 100644 index 000000000..684280b3c --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/views/dashboard/targets/TagTarget.py @@ -0,0 +1,140 @@ +"""Brand Dashboard: API endpoints relating to Template model""" +from rest_framework import status, serializers +from django.utils.timezone import make_aware +from rest_framework.response import Response + +import datetime + +from .BaseTarget import BaseSerializer, BaseEndpoint +from clinicalcode.models.Tag import Tag +from clinicalcode.models.Brand import Brand +from clinicalcode.entity_utils import gen_utils + + +class TagSerializer(BaseSerializer): + """Responsible for serialising the `Brand` model and to handle PUT/POST validation""" + + # Fields + description = serializers.CharField(max_length=50, label='Name') + display = serializers.ChoiceField(choices=Tag.DISPLAY_CHOICES, help_text='This descriptor hints the colour temperature used to display the tag (e.g. via search page).') + tag_type = serializers.ChoiceField(choices=Tag.TAG_TYPES, help_text='This field determines whether this item is to be considered a \'Collection\' or a \'Tag\'.') + + # Appearance + _str_display = 'name' + _list_fields = ['id', 'description', 'tag_type'] + _item_fields = ['id', 'description', 'tag_type', 'display'] + + # Metadata + class Meta: + model = Tag + exclude = ['collection_brand', 'created_by', 'updated_by', 'created', 'modified'] + extra_kwargs = { + # RO + 'id': { 'read_only': True, 'required': False }, + # WO + 'created': { 'write_only': True, 'read_only': False, 'required': False }, + 'modified': { 'write_only': True, 'read_only': False, 'required': False }, + 'created_by': { 'write_only': True, 'required': False }, + 'updated_by': { 'write_only': True, 'required': False }, + } + + # GET + def to_representation(self, instance): + data = super(TagSerializer, self).to_representation(instance) + return data + + def resolve_options(self): + return list(self.Meta.model.get_brand_assoc_queryset(self._get_brand(), 'all').values('name', 'pk', 'tag_type')) + + # POST / PUTx + def create(self, validated_data): + user = self._get_user() + validated_data.update({ + 'created_by': user, + 'updated_by': user, + }) + return self._create(self.Meta.model, validated_data) + + def update(self, instance, validated_data): + instance = self._update(instance, validated_data) + instance.modified = make_aware(datetime.datetime.now()) # Set `modified` timestamp + instance.updated_by = self._get_user() + instance.save() + return instance + + # Instance & Field validation + def validate(self, data): + current_brand = self._get_brand() + instance = getattr(self, 'instance') if hasattr(self, 'instance') else None + + data_brand = data.get('collection_brand') + tag_type = data.get('tag_type') + display = data.get('display') + + if current_brand is not None: + if instance is not None: + data_brand = instance.collection_brand if instance.collection_brand is not None else current_brand.id + else: + data_brand = current_brand.id + elif not isinstance(data_brand, (Brand, int)): + data_brand = instance.collection_brand + + if isinstance(data_brand, int): + data_brand = Brand.objects.filter(pk=data_brand) + if data_brand is None or not data_brand.exists(): + raise serializers.ValidationError({ + 'collection_brand': 'Invalid Brand' + }) + data_brand = data_brand.first() + + data.update(collection_brand=data_brand) + + if display is not None and display not in dict(self.Meta.model.DISPLAY_CHOICES).keys(): + raise serializers.ValidationError({ + 'display': 'Invalid display choice.' + }) + if tag_type not in dict(self.Meta.model.TAG_TYPES).keys(): + raise serializers.ValidationError({ + 'tag_type': 'Invalid Tag Type' + }) + + return data + + +class TagEndpoint(BaseEndpoint): + """Responsible for API views relating to `Template` model accessed via Brand dashboard""" + + # QuerySet kwargs + filter = { 'all_tags': True } + + # Metadata + model = Tag + fields = [] + queryset = Tag.objects.all() + serializer_class = TagSerializer + + # View behaviour + reverse_name_default = 'brand_tag_target' + reverse_name_retrieve = 'brand_tag_target_with_id' + + # Endpoint methods + def get(self, request, *args, **kwargs): + inst_id = kwargs.get('pk', None) + if inst_id: + inst_id = gen_utils.try_value_as_type(inst_id, 'int') + if inst_id is None: + return Response( + data={'detail': 'Expected int-like `pk` parameter'}, + status=status.HTTP_400_BAD_REQUEST + ) + + kwargs.update(pk=inst_id) + return self.retrieve(request, *args, **kwargs) + + return self.list(request, *args, **kwargs) + + def put(self, request, *args, **kwargs): + return self.update(request, partial=True, *args, **kwargs) + + def post(self, request, *args, **kwargs): + return self.create(request, *args, **kwargs) diff --git a/CodeListLibrary_project/clinicalcode/views/dashboard/targets/TemplateTarget.py b/CodeListLibrary_project/clinicalcode/views/dashboard/targets/TemplateTarget.py new file mode 100644 index 000000000..21bf6da7c --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/views/dashboard/targets/TemplateTarget.py @@ -0,0 +1,304 @@ +"""Brand Dashboard: API endpoints relating to Template model""" +from rest_framework import status, serializers +from django.contrib.auth import get_user_model +from django.utils.timezone import make_aware +from rest_framework.response import Response + +import json +import datetime + +from .BaseTarget import BaseSerializer, BaseEndpoint +from .BrandTarget import BrandSerializer +from clinicalcode.entity_utils import gen_utils, template_utils +from clinicalcode.models.Brand import Brand +from clinicalcode.models.Template import Template +from clinicalcode.models.EntityClass import EntityClass + + +User = get_user_model() + + +# Const +TEMPLATE_NOTE_DESC = ( + 'Please note that a Template\'s name, description, version and other metadata are defined by a Template\'s definition.' + ' These attributes can be specified within a Template\'s definition field, specifically its `template_details` property' + ' - please see the Template documentation for more information.' +) + + +class EntityClassSerializer(BaseSerializer): + """""" + + # Appearance + _str_display = 'name' + _list_fields = ['id', 'name'] + _item_fields = ['id', 'name', 'description'] + + # Metadata + class Meta: + model = EntityClass + exclude = ['created_by', 'modified_by', 'created', 'modified', 'entity_count'] + extra_kwargs = { + # RO + 'id': { 'read_only': True, 'required': False }, + 'entity_prefix': { 'read_only': True, 'required': False }, + # WO + 'created': { 'write_only': True, 'read_only': False, 'required': False }, + 'modified': { 'write_only': True, 'read_only': False, 'required': False }, + 'created_by': { 'write_only': True, 'read_only': False, 'required': False }, + 'modified_by': { 'write_only': True, 'read_only': False, 'required': False }, + } + + # GET + def resolve_format(self): + return { 'type': 'ForeignKey' } + + def resolve_options(self): + return list(self.Meta.model.objects.all().values('name', 'pk')) + + +class TemplateSerializer(BaseSerializer): + """Responsible for serialising the `Template` model and to handle PUT/POST validation""" + + # Fields + brands = BrandSerializer(many=True, help_text='Specifies which Brands can use & interact with this Template and its descendants.') + entity_class = EntityClassSerializer(help_text='Specifies how to categorise this Template & determines entity behaviour.') + + # Appearance + _str_display = 'name' + _list_fields = ['id', 'name', 'template_version'] + _item_fields = ['id', 'definition', 'entity_class', 'brands'] + _features = { + 'create': { + 'note': TEMPLATE_NOTE_DESC + }, + 'update': { + 'note': TEMPLATE_NOTE_DESC + }, + } + + # Metadata + class Meta: + model = Template + exclude = ['created_by', 'updated_by', 'created', 'modified'] + extra_kwargs = { + # RO + 'id': { 'read_only': True, 'required': False }, + 'name': { 'read_only': True, 'required': False }, + 'description': { 'read_only': True, 'required': False }, + # WO + 'created': { 'write_only': True, 'read_only': False, 'required': False }, + 'modified': { 'write_only': True, 'read_only': False, 'required': False }, + 'created_by': { 'write_only': True, 'read_only': False, 'required': False }, + 'updated_by': { 'write_only': True, 'read_only': False, 'required': False }, + 'entity_class': { 'write_only': True, 'read_only': False, 'required': False }, + # WO | RO + 'definition': { 'required': True, 'help_text': 'Specifies the fields, datatypes, and features associated with this Template.' }, + 'hide_on_create': { 'required': False, 'help_text': 'Specifies whether to hide this Template from the Create interface.' }, + } + + # GET + def to_representation(self, instance): + data = super(TemplateSerializer, self).to_representation(instance) + definition = data.get('definition') + if not instance or not hasattr(instance, 'pk') or not isinstance(definition, dict): + return data + + details = definition.get('template_details') + if isinstance(details, dict): + details['name'] = details.get('name', '') + details['description'] = details.get('description', '') + + data['definition'] = template_utils.get_ordered_definition(definition, clean_fields=True) + return data + + # Instance & Field validation + def validate(self, data): + user = self._get_user() + instance = getattr(self, 'instance') if hasattr(self, 'instance') else None + current_brand = self._get_brand() + + entity_class = data.get('entity_class', instance.entity_class if instance else None) + if entity_class is not None: + if isinstance(entity_class, int): + entity_class = EntityClass.objects.filter(pk=entity_class) + if entity_class is None or not entity_class.exists(): + raise serializers.ValidationError({ + 'entity_class': 'Found no existing object at specified `entity_class` pk.' + }) + entity_class = entity_class.first() + elif not isinstance(entity_class, EntityClass): + entity_class = None + + if entity_class is None: + raise serializers.ValidationError({ + 'entity_class': 'Required `entity_class` field of `pk` type is invalid JSON, or is missing.' + }) + + if instance is not None: + definition = data.get('definition', instance.definition) + else: + definition = data.get('definition') + + if isinstance(definition, str): + try: + definition = json.loads(definition) + except: + raise serializers.ValidationError({ + 'definition': 'Required JSONField `definition` is invalid' + }) + + if not isinstance(definition, dict): + raise serializers.ValidationError({ + 'definition': 'Required JSONField `definition` is missing' + }) + + try: + json.dumps(definition) + except: + raise serializers.ValidationError({ + 'definition': 'Template definition is not valid JSON' + }) + + definition = template_utils.get_ordered_definition(definition, clean_fields=True) + + template_fields = definition.get('fields') + template_details = definition.get('template_details') + template_sections = definition.get('sections') + if not isinstance(template_fields, dict): + raise serializers.ValidationError({ + 'definition': 'Template `definition` field requires a `fields` key-value pair of type `dict`' + }) + elif not isinstance(template_details, dict): + raise serializers.ValidationError({ + 'definition': 'Template `definition` field requires a `template_details` key-value pair of type `dict`' + }) + elif not isinstance(template_sections, list): + raise serializers.ValidationError({ + 'definition': 'Template `definition` field requires a `sections` key-value pair of type `list`' + }) + + name = template_details.get('name') + if not isinstance(name, str) or gen_utils.is_empty_string(name): + raise serializers.ValidationError({ + 'definition': 'Template requires that the `definition->template_details.name` field be a non-empty string' + }) + + brands = data.get('brands') + if isinstance(brands, Brand): + brands = [brands.id] + elif isinstance(brands, int): + brands = [brands] + + if isinstance(brands, list): + brands = [x.id if isinstance(x, Brand) else x for x in brands if isinstance(x, (Brand, int))] + if current_brand and not current_brand.id in brands: + brands.append(current_brand.id) + elif current_brand: + brands = instance.brands if instance is not None and isinstance(instance.brands, list) else [] + if current_brand and not current_brand.id in brands: + brands.append(current_brand.id) + elif instance: + brands = instance.brands + + data.update({ + 'name': template_details.get('name', instance.name if instance else ''), + 'description': template_details.get('description', instance.name if instance else ''), + 'template_version': template_details.get('version', instance.template_version if instance else 1), + 'brands': brands, + 'definition': self.__apply_def_ordering(definition), + 'description': template_details.get('description', ''), + 'created_by': instance.user if instance is not None else user, + 'updated_by': user, + 'entity_class': entity_class, + 'modified': make_aware(datetime.datetime.now()), + }) + + return data + + # Private utility methods + def __apply_def_ordering(self, definition): + order = [] + for field in definition['fields']: + definition['fields'][field]['order'] = len(order) + order.append(field) + + definition['layout_order'] = order + return definition + + +class TemplateEndpoint(BaseEndpoint): + """Responsible for API views relating to `Template` model accessed via Brand dashboard""" + + # Metadata + model = Template + fields = [] + queryset = Template.objects.all() + serializer_class = TemplateSerializer + + # View behaviour + reverse_name_default = 'brand_template_target' + reverse_name_retrieve = 'brand_template_target_with_id' + + # Endpoint methods + def get(self, request, *args, **kwargs): + inst_id = kwargs.get('pk', None) + if inst_id: + inst_id = gen_utils.try_value_as_type(inst_id, 'int') + if inst_id is None: + return Response( + data={ 'detail': 'Expected int-like `pk` parameter' }, + status=status.HTTP_400_BAD_REQUEST + ) + + kwargs.update(pk=inst_id) + return self.retrieve(request, *args, **kwargs) + + return self.list(request, *args, **kwargs) + + def put(self, request, *args, **kwargs): + """ + Handle PUT requests to update a Template instance. + """ + partial = kwargs.pop('partial', False) + instance = self.get_object(*args, **kwargs) + + serializer = self.get_serializer(instance, data=request.data, partial=partial) + try: + serializer.is_valid(raise_exception=True) + except serializers.ValidationError as e: + if isinstance(e.detail, dict): + detail = {k: v for k, v in e.detail.items() if k not in ('brands', 'entity_class')} + if len(detail) > 0: + raise serializers.ValidationError(detail=detail) + except Exception as e: + raise e + + data = serializer.data + data = self.get_serializer().validate(data) + instance.__dict__.update(**data) + instance.entity_class = data.get('entity_class') + instance.save() + + return Response(self.get_serializer(instance).data) + + def post(self, request, *args, **kwargs): + """ + Handle POST requests to create a new Template instance. + """ + serializer = self.get_serializer(data=request.data) + try: + serializer.is_valid(raise_exception=True) + except serializers.ValidationError as e: + if isinstance(e.detail, dict): + detail = {k: v for k, v in e.detail.items() if k not in ('brands', 'entity_class')} + if len(detail) > 0: + raise serializers.ValidationError(detail=detail) + except Exception as e: + raise e + + data = serializer.data + data = self.get_serializer().validate(data) + + instance, _ = self.model.objects.get_or_create(**data) + return Response(self.get_serializer(instance).data) diff --git a/CodeListLibrary_project/clinicalcode/views/dashboard/targets/UserTarget.py b/CodeListLibrary_project/clinicalcode/views/dashboard/targets/UserTarget.py new file mode 100644 index 000000000..3e50cf6ee --- /dev/null +++ b/CodeListLibrary_project/clinicalcode/views/dashboard/targets/UserTarget.py @@ -0,0 +1,349 @@ +"""Brand Dashboard: API endpoints relating to default Django User model""" +from django.conf import settings +from rest_framework import status, serializers, exceptions +from django.db.models import Q, F +from django.core.mail import EmailMultiAlternatives +from django.utils.http import urlsafe_base64_encode +from django.contrib.auth import get_user_model +from django.utils.encoding import force_bytes +from django.core.paginator import EmptyPage, Paginator, Page +from django.template.loader import render_to_string +from rest_framework.response import Response +from django.utils.regex_helper import _lazy_re_compile +from django.contrib.auth.models import Group +from django.contrib.auth.tokens import default_token_generator + +import re +import uuid +import logging + +from .BaseTarget import BaseSerializer, BaseEndpoint +from clinicalcode.entity_utils import constants, gen_utils, model_utils, email_utils, permission_utils + + +logger = logging.getLogger(__name__) + + +User = get_user_model() + + +class UserSerializer(BaseSerializer): + """Responsible for serialising the `User` model and to handle PUT/POST validation""" + + # Const + MODERATOR_GROUP = Group.objects.get(name__iexact='Moderators') + EMAIL_PATTERN = _lazy_re_compile(r'\b([^\s\/@:"]+)(?<=[\w])@(\S+)\.([\w]+)\b', re.MULTILINE | re.IGNORECASE) + + # Fields + is_moderator = serializers.BooleanField(default=False, initial=False, help_text='Specifies whether this User is a global moderator (unrelated to organisations/sites)') + + # Appearance + _str_display = 'username' + _list_fields = ['id', 'username', 'first_name', 'last_name'] + _item_fields = ['id', 'username', 'first_name', 'last_name', 'email', 'date_joined', 'last_login'] + _features = { + 'create': { + 'note': ( + 'Please note that creating a new User account will send an e-mail to the specified address. ' + 'The e-mail will contain a link for the User to follow, prompting them to enter their password.\n\n' + 'Beware that the e-mail might appear in the User\'s spam folder, please recommend this as a resolution if the e-mail cannot be found.' + ) + }, + 'update': { + 'actionbar': ['reset_pwd'], + }, + } + + # Metadata + class Meta: + model = User + fields = ['id', 'username', 'first_name', 'last_name', 'email', 'is_moderator'] + extra_kwargs = { + # RO + 'id': { 'read_only': True, 'required': False }, + 'last_login': { 'read_only': True, 'required': False }, + 'date_joined': { 'read_only': True, 'required': False }, + # WO + 'groups': { 'write_only': True, 'required': False }, + 'password': { 'write_only': True }, + 'is_superuser': { 'read_only': True, 'required': False }, + 'is_staff': { 'write_only': True, 'required': False }, + 'is_active': { 'write_only': True, 'required': False }, + 'user_permissions': { 'write_only': True, 'required': False }, + # RO | WO + 'email': { 'required': True }, + } + + # GET + def to_representation(self, instance): + data = super(UserSerializer, self).to_representation(instance) + data.update({ + 'is_moderator': permission_utils.is_member(instance, 'Moderators'), + 'is_superuser': instance.is_superuser, + }) + return data + + def resolve_format(self): + return { 'type': 'ForeignKey' } + + def resolve_options(self): + brand = self._get_brand() + + records = None + if brand is not None: + vis_rules = brand.get_vis_rules() + if isinstance(vis_rules, dict): + allow_null = vis_rules.get('allow_null') + allowed_brands = vis_rules.get('ids') + if isinstance(allowed_brands, list) and isinstance(allow_null, bool) and allow_null: + records = User.objects.filter(Q(accessible_brands__id__isnull=True) | Q(accessible_brands__id__in=allowed_brands)) + elif isinstance(allowed_brands, list): + records = User.objects.filter(accessible_brands__id__in=allowed_brands) + elif isinstance(allow_null, bool) and allow_null: + records = User.objects.filter(Q(accessible_brands__id__isnull=True) | Q(accessible_brands__id__in=[brand.id])) + + if records is None: + records = User.objects.filter(accessible_brands__id=brand.id) + else: + records = User.objects.all() + + if records is None: + return list() + + return list(records.annotate(name=F('username')).values('name', 'pk')) + + def send_pwd_email(self, request, user): + if not isinstance(user, User) or not hasattr(user, 'id') or not isinstance(user.id, int): + raise exceptions.APIException('User model must be saved before sending the reset e-mail.') + + brand = self._get_brand() + email = getattr(user, 'email') + username = getattr(user, 'username') + logger.info(f'Sending DashPWD Reset, with target: User<name: {username}, email: {email}, req: {request}>') + + if not isinstance(email, str) or gen_utils.is_empty_string(email) or not self.EMAIL_PATTERN.match(email): + raise exceptions.APIException(f'Failed to match e-mail pattern for User\'s known e-mail: {email}') + + user_pk_bytes = force_bytes(User._meta.pk.value_to_string(user)) + brand_title = model_utils.try_get_brand_string(brand, 'site_title', default='Concept Library') + + email_subject = f'{brand_title} - Account Invite' + email_content = render_to_string( + 'clinicalcode/email/account_inv_email.html', + { + 'uid': urlsafe_base64_encode(user_pk_bytes), + 'token': default_token_generator.make_token(user), + 'username': username, + }, + request=request + ) + + if not settings.IS_DEVELOPMENT_PC or settings.HAS_MAILHOG_SERVICE: + try: + branded_imgs = email_utils.get_branded_email_images(brand) + + msg = EmailMultiAlternatives( + email_subject, + email_content, + settings.DEFAULT_FROM_EMAIL, + to=[email] + ) + msg.content_subtype = 'related' + msg.attach_alternative(email_content, 'text/html') + + msg.attach(email_utils.attach_image_to_email(branded_imgs.get('apple', 'img/email_images/apple-touch-icon.jpg'), 'mainlogo')) + msg.attach(email_utils.attach_image_to_email(branded_imgs.get('logo', 'img/email_images/combine.jpg'), 'sponsors')) + msg.send() + except Exception as e: + raise exceptions.APIException(f'Failed to send emails to:\n- Targets: {email}\n-Error: {str(e)}') + else: + logger.info(f'Successfully sent DashPWD email with target: User<name: {username}, email: {email}, sub: {email_subject}>') + return True + else: + logger.info(f'[DEMO] Successfully sent DashPWD email with target: User<name: {username}, email: {email}, sub: {email_subject}>') + return True + + # POST / PUT + def create(self, validated_data): + request = self.context.get('request') + + validated_data.update({ + 'is_staff': False, + 'is_active': True, + 'is_superuser': False, + 'password': uuid.uuid4(), + }) + + is_mod = validated_data.pop('is_moderator', False) + user = self._create(self.Meta.model, validated_data) + user.save() + + brand = self._get_brand() + if brand is not None: + rel = brand.users.filter(id=user.id) + if not rel.exists(): + brand.users.add(user) + + if isinstance(is_mod, bool) and is_mod: + user.groups.add(self.MODERATOR_GROUP) + + self.send_pwd_email(request, user) + return user + + def update(self, instance, validated_data): + brand = self._get_brand() + + is_mod = validated_data.pop('is_moderator', False) + user = self._update(instance, validated_data) + user.save() + + if brand is not None: + rel = brand.users.filter(id=user.id) + if not rel.exists(): + brand.users.add(user) + + if isinstance(is_mod, bool): + if is_mod: + user.groups.add(self.MODERATOR_GROUP) + elif user.groups.filter(pk=self.MODERATOR_GROUP.pk).exists(): + user.groups.remove(self.MODERATOR_GROUP) + + return user + + # Instance & Field validation + def validate(self, data): + instance = getattr(self, 'instance') if hasattr(self, 'instance') else None + + email = data.get('email', instance.email if instance is not None else None) + if not isinstance(email, str): + raise exceptions.ValidationError({ + 'email': f'Failed to match e-mail pattern for User\'s known e-mail: {email}' + }) + + if gen_utils.is_empty_string(email) or not self.EMAIL_PATTERN.match(email): + raise exceptions.ValidationError({ + 'email': f'Failed to match e-mail regex pattern for User\'s known e-mail' + }) + + data.update(email=email) + return data + + +class UserEndpoint(BaseEndpoint): + """Responsible for API views relating to `User` model accessed via Brand dashboard""" + + # Metadata + model = User + fields = [] + queryset = User.objects.all() + serializer_class = UserSerializer + + # View behaviour + reverse_name_default = 'brand_user_target' + reverse_name_retrieve = 'brand_user_target_with_id' + + # Endpoint methods + def get(self, request, *args, **kwargs): + inst_id = kwargs.get('pk', None) + if inst_id: + inst_id = gen_utils.try_value_as_type(inst_id, 'int') + if inst_id is None: + return Response( + data={ 'detail': 'Expected int-like `pk` parameter' }, + status=status.HTTP_400_BAD_REQUEST + ) + + kwargs.update(pk=inst_id) + return self.retrieve(request, *args, **kwargs) + + return self.list(request, *args, **kwargs) + + def put(self, request, *args, **kwargs): + target = request.headers.get('X-Target', None) + if isinstance(target, str) and target.lower() == 'reset_pwd': + instance = self.get_object(*args, **kwargs) + if isinstance(instance, User) and not instance.is_superuser: + self.get_serializer().send_pwd_email(request, instance) + return Response({ 'sent': True, 'user_id': instance.id }) + + return self.update(request, *args, **kwargs) + + def post(self, request, *args, **kwargs): + return self.create(request, *args, **kwargs) + + # Override queryset + def list(self, request, *args, **kwargs): + params = self._get_query_params(request) + + page = gen_utils.try_value_as_type(params.get('page'), 'int', default=1) + page = max(page, 1) + + page_size = params.get('page_size', '1') + if page_size not in constants.PAGE_RESULTS_SIZE: + page_size = constants.PAGE_RESULTS_SIZE.get('1') + else: + page_size = constants.PAGE_RESULTS_SIZE.get(page_size) + + records = self.get_queryset(request, **params) + if records is None: + page_obj = Page(User.objects.none(), 0, Paginator([], page_size, allow_empty_first_page=True)) + else: + records = records.order_by('id') + pagination = Paginator(records, page_size, allow_empty_first_page=True) + try: + page_obj = pagination.page(page) + except EmptyPage: + page_obj = pagination.page(pagination.num_pages) + + results = self.serializer_class(page_obj.object_list, many=True) + response = self._format_list_data({ + 'detail': self._format_page_details(page_obj), + 'results': results.data, + }) + + self._format_list_data(response) + return Response(response) + + def get_queryset(self, *args, **kwargs): + request = self.request + + params = getattr(self, 'filter', None) + if isinstance(params, dict): + params = kwargs | params + else: + params = kwargs + + brand = model_utils.try_get_brand(request) + records = None + if brand is not None: + vis_rules = brand.get_vis_rules() + if isinstance(vis_rules, dict): + allow_null = vis_rules.get('allow_null') + allowed_brands = vis_rules.get('ids') + if isinstance(allowed_brands, list) and isinstance(allow_null, bool) and allow_null: + records = User.objects.filter(Q(accessible_brands__id__isnull=True) | Q(accessible_brands__id__in=allowed_brands)) + elif isinstance(allowed_brands, list): + records = User.objects.filter(accessible_brands__id__in=allowed_brands) + elif isinstance(allow_null, bool) and allow_null: + records = User.objects.filter(Q(accessible_brands__id__isnull=True) | Q(accessible_brands__id__in=[brand.id])) + + if records is None: + records = User.objects.filter(accessible_brands__id=brand.id) + else: + records = User.objects.all() + + search = params.get('search', None) + query = gen_utils.parse_model_field_query(User, params, ignored_fields=['password']) + + if query is not None: + records = records.filter(**query) + + if not gen_utils.is_empty_string(search) and len(search) >= 3: + records = records.filter( + Q(username__icontains=search) | \ + Q(first_name__icontains=search) | \ + Q(last_name__icontains=search) | \ + Q(email__icontains=search) + ) + + return records diff --git a/CodeListLibrary_project/clinicalcode/views/dashboard/targets/__init__.py b/CodeListLibrary_project/clinicalcode/views/dashboard/targets/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/CodeListLibrary_project/clinicalcode/views/site.py b/CodeListLibrary_project/clinicalcode/views/site.py index 777a358ca..d54f0999a 100644 --- a/CodeListLibrary_project/clinicalcode/views/site.py +++ b/CodeListLibrary_project/clinicalcode/views/site.py @@ -11,40 +11,30 @@ @require_GET def robots_txt(request): - if not(settings.IS_HDRUK_EXT == "1" or settings.IS_DEVELOPMENT_PC): + if settings.IS_HDRUK_EXT != "1" or not settings.IS_DEVELOPMENT_PC: raise PermissionDenied - - + lines = [ "User-Agent: *", "Allow: /", ] - - # sitemap - # site = "https://conceptlibrary.saildatabank.com" - # if settings.IS_HDRUK_EXT == "1": - # site = "https://phenotypes.healthdatagateway.org" - # - # lines += ["Sitemap22: " + site + "/sitemap.xml"] lines += ["Sitemap: " + request.build_absolute_uri(reverse('concept_library_home')).replace('http://' , 'https://') + "sitemap.xml"] - return HttpResponse("\n".join(lines), content_type="text/plain") @require_GET def get_sitemap(request): - if not(settings.IS_HDRUK_EXT == "1" or settings.IS_DEVELOPMENT_PC): + if settings.IS_HDRUK_EXT != "1" or not settings.IS_DEVELOPMENT_PC: raise PermissionDenied links = [ (request.build_absolute_uri(reverse('concept_library_home')), cur_time, "1.00"), (request.build_absolute_uri(reverse('concept_library_home2')), cur_time, "1.00"), - (request.build_absolute_uri(reverse('search_phenotypes')), cur_time, "1.00"), + (request.build_absolute_uri(reverse('search_entities')), cur_time, "1.00"), (request.build_absolute_uri(reverse('reference_data')), cur_time, "1.00"), (request.build_absolute_uri(reverse('login')), cur_time, "1.00"), ] - - + # About pages # brand/main about pages if settings.IS_HDRUK_EXT == "1" or settings.IS_DEVELOPMENT_PC: @@ -56,24 +46,15 @@ def get_sitemap(request): ('https://phenotypes.healthdatagateway.org/about/hdruk_about_publications/', cur_time, "0.80"), ('https://phenotypes.healthdatagateway.org/about/breathe/', cur_time, "0.80"), ('https://phenotypes.healthdatagateway.org/about/bhf_data_science_centre/', cur_time, "0.80"), - # ('https://phenotypes.healthdatagateway.org/about/eurolinkcat/', cur_time, "0.80"), - + ('https://phenotypes.healthdatagateway.org/about/eurolinkcat/', cur_time, "0.80"), ] - - # # privacy /terms /cookies - # links += [ - # (request.build_absolute_uri(reverse('cookies_settings')), cur_time, "0.40"), - # (request.build_absolute_uri(reverse('terms')), cur_time, "0.40"), - # (request.build_absolute_uri(reverse('privacy_and_cookie_policy')), cur_time, "0.40"), - # (request.build_absolute_uri(reverse('technical_documentation')), cur_time, "0.40"), - # ] # contact us page if not settings.CLL_READ_ONLY: links += [ (request.build_absolute_uri(reverse('contact_us')), cur_time, "1.00"), ] - + # API links += [ (request.build_absolute_uri(reverse('api:root')), cur_time, "1.00"), @@ -84,9 +65,8 @@ def get_sitemap(request): ] # add links of published concepts/phenotypes - #if settings.CURRENT_BRAND != "": links += get_published_phenotypes_and_concepts(request) - + links_str = """ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" @@ -102,10 +82,8 @@ def get_sitemap(request): <priority>""" + t[2] + """</priority> </url> """ - - links_str += "</urlset>" - + links_str += "</urlset>" return HttpResponse(links_str, content_type="application/xml") diff --git a/CodeListLibrary_project/cll/settings.py b/CodeListLibrary_project/cll/settings.py index e5de1c4ce..bab7c5d31 100644 --- a/CodeListLibrary_project/cll/settings.py +++ b/CodeListLibrary_project/cll/settings.py @@ -104,7 +104,8 @@ def get_env_value(env_variable, cast=None, default=Symbol('None')): ''' Application base ''' APP_TITLE = 'Concept Library' -APP_DESC = 'The {app_title} is a system for storing, managing, sharing, and documenting clinical code lists in health research.' +APP_DESC = 'The {app_title} is a system for storing, managing, sharing, and documenting clinical codelists in health research.' +APP_CITATION = 'Users should cite the {app_title} in all publications, presentations and reports as follows: “<em>{brand_name} {app_title}, website: <a href="{brand_website}" target=_blank>{brand_website}</a>.</em>”' APP_LOGO_PATH = 'img/' APP_EMBED_ICON = '{logo_path}embed_img.png' INDEX_PATH = 'clinicalcode/index.html' @@ -138,7 +139,10 @@ def get_env_value(env_variable, cast=None, default=Symbol('None')): ''' Application variables ''' -# separate settings for different environments +# Test-related config +REMOTE_TEST = get_env_value('REMOTE_TEST', cast='bool', default=False) + +# Env config IS_DEMO = get_env_value('IS_DEMO', cast='bool') CLINICALCODE_SESSION_ID = 'concept' @@ -162,7 +166,7 @@ def get_env_value(env_variable, cast=None, default=Symbol('None')): # Allowed application hots ALLOWED_HOSTS = [i.strip() for i in get_env_value('ALLOWED_HOSTS').split(',')] -ROOT_URLCONF = 'cll.urls' +ROOT_URLCONF = 'cll.urls_brand' DATA_UPLOAD_MAX_MEMORY_SIZE = None # Setup support for proxy headers @@ -197,9 +201,23 @@ def get_env_value(env_variable, cast=None, default=Symbol('None')): ## Brand related settings IS_HDRUK_EXT = '0' +BRAND_OBJECT = {} CURRENT_BRAND = '' CURRENT_BRAND_WITH_SLASH = '' -BRAND_OBJECT = {} + +## Brand variant URL resolver overrides +BRAND_VAR_REFERENCE = { + 'default': { + 'urls': { + 'phenotypes': 'phenotypes', + }, + }, + 'HDRN': { + 'urls': { + 'phenotypes': 'concepts', + }, + } +} ## Graph settings GRAPH_MODELS = { @@ -271,24 +289,27 @@ def get_env_value(env_variable, cast=None, default=Symbol('None')): ] INSTALLED_APPS = INSTALLED_APPS + [ + # Base 'django.contrib.postgres', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + # Apps 'clinicalcode', 'cll', + # Extensions 'simple_history', - 'rest_framework', - # 'mod_wsgi.server', 'markdownify.apps.MarkdownifyConfig', 'cookielaw', + # API + 'drf_yasg', + 'rest_framework', + # Site 'django_celery_results', 'django_celery_beat', - # 'rest_framework_swagger', - 'drf_yasg', - 'django.contrib.sitemaps', + 'django.contrib.sitemaps', # 'mod_wsgi.server', # SCSS 'sass_processor', # Compressor @@ -297,7 +318,7 @@ def get_env_value(env_variable, cast=None, default=Symbol('None')): 'django_minify_html', ] -if not CLL_READ_ONLY and not IS_GATEWAY_PC: +if not CLL_READ_ONLY and not IS_GATEWAY_PC and not REMOTE_TEST: INSTALLED_APPS += [ # Engagelens-related 'easyaudit' @@ -305,7 +326,6 @@ def get_env_value(env_variable, cast=None, default=Symbol('None')): # ==============================================================================# - ''' Middleware ''' MIDDLEWARE = [ @@ -327,9 +347,11 @@ def get_env_value(env_variable, cast=None, default=Symbol('None')): 'clinicalcode.middleware.brands.BrandMiddleware', # Handle user session expiry 'clinicalcode.middleware.sessions.SessionExpiryMiddleware', + # Handle exceptions + 'clinicalcode.middleware.exceptions.ExceptionMiddleware', ] -if not CLL_READ_ONLY and not IS_GATEWAY_PC: +if not CLL_READ_ONLY and not IS_GATEWAY_PC and not REMOTE_TEST: MIDDLEWARE += [ # Engagelens-related 'easyaudit.middleware.easyaudit.EasyAuditMiddleware', @@ -341,7 +363,7 @@ def get_env_value(env_variable, cast=None, default=Symbol('None')): # Keep ModelBackend around for per-user permissions and a local superuser. # Don't check AD on development PCs due to network connection -if IS_DEVELOPMENT_PC or (not ENABLE_LDAP_AUTH): +if IS_DEVELOPMENT_PC or not ENABLE_LDAP_AUTH: AUTHENTICATION_BACKENDS = [ # 'django_auth_ldap.backend.LDAPBackend', 'django.contrib.auth.backends.ModelBackend', @@ -425,7 +447,6 @@ def get_env_value(env_variable, cast=None, default=Symbol('None')): 'svg': 'clinicalcode.templatetags.svg', 'breadcrumbs': 'clinicalcode.templatetags.breadcrumbs', 'entity_renderer': 'clinicalcode.templatetags.entity_renderer', - 'detail_pg_renderer': 'clinicalcode.templatetags.detail_pg_renderer', } }, }, @@ -521,9 +542,13 @@ def get_env_value(env_variable, cast=None, default=Symbol('None')): }, }, 'loggers': { + '': { + 'level': 'INFO', + 'handlers': ['console'], + }, 'django': { 'handlers': ['console'], - 'level': os.getenv('DJANGO_LOG_LEVEL', 'INFO'), + 'level': 'INFO', }, }, } @@ -567,12 +592,46 @@ def get_env_value(env_variable, cast=None, default=Symbol('None')): # ==============================================================================# +''' Easyaudit settings ''' + +# Ignores `Request` event signals in favour of custom implementation +# +# Note: +# - See custom override setting(s) below, prefixed `OVERIDE_EASY_AUDIT_*`` +# +DJANGO_EASY_AUDIT_WATCH_REQUEST_EVENTS = False + +# Overrides `Request` event signal URL registration +OVERRIDE_EASY_AUDIT_IGNORE_URLS = { + # The following URL patterns will be ignored for all branded sites + 'all_brands': [ + # Ignore non-consumer usage + r'^/admin/', + r'^/adminTemp/', + r'^/dashboard/', + + # Ignore healthchecks + r'^/api/v1/health' + + # Ignore bots & crawlers + r'^/sitemap.xml', + r'^/robots.txt', + + # Ignore static file requests + r'^/media/', + r'^/static/', + r'^/favicon.ico', + ], +} + +# ==============================================================================# + ''' Installed application settings ''' # General settings ## Django auth settings -> Redirect to home URL after login (Default redirects to /accounts/profile/) -LOGIN_REDIRECT_URL = reverse_lazy('search_phenotypes') +LOGIN_REDIRECT_URL = reverse_lazy('search_entities') LOGIN_URL = reverse_lazy('login') LOGOUT_URL = reverse_lazy('logout') @@ -644,6 +703,9 @@ def get_env_value(env_variable, cast=None, default=Symbol('None')): ## Celery beat settings CELERY_BEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler' +## Task queue +ENABLE_DEMO_TASK_QUEUE = get_env_value('ENABLE_DEMO_TASK_QUEUE', cast='bool', default=False) + ## Swagger settings ## SWAGGER_SETTINGS = { 'JSON_EDITOR': True, } SWAGGER_TITLE = 'Concept Library API' diff --git a/CodeListLibrary_project/cll/static/img/Footer_logos/HDRN_logo.png b/CodeListLibrary_project/cll/static/img/Footer_logos/HDRN_logo.png new file mode 100644 index 000000000..e0b1f14f4 Binary files /dev/null and b/CodeListLibrary_project/cll/static/img/Footer_logos/HDRN_logo.png differ diff --git a/CodeListLibrary_project/cll/static/img/brands/HDRN/apple-touch-icon.png b/CodeListLibrary_project/cll/static/img/brands/HDRN/apple-touch-icon.png new file mode 100644 index 000000000..06bc556c0 Binary files /dev/null and b/CodeListLibrary_project/cll/static/img/brands/HDRN/apple-touch-icon.png differ diff --git a/CodeListLibrary_project/cll/static/img/brands/HDRN/favicon-32x32.png b/CodeListLibrary_project/cll/static/img/brands/HDRN/favicon-32x32.png new file mode 100644 index 000000000..600951d71 Binary files /dev/null and b/CodeListLibrary_project/cll/static/img/brands/HDRN/favicon-32x32.png differ diff --git a/CodeListLibrary_project/cll/static/img/brands/HDRN/header_logo.png b/CodeListLibrary_project/cll/static/img/brands/HDRN/header_logo.png new file mode 100644 index 000000000..e0b1f14f4 Binary files /dev/null and b/CodeListLibrary_project/cll/static/img/brands/HDRN/header_logo.png differ diff --git a/CodeListLibrary_project/cll/static/img/brands/HDRN/logo-transparent.png b/CodeListLibrary_project/cll/static/img/brands/HDRN/logo-transparent.png new file mode 100644 index 000000000..4ec7c94d2 Binary files /dev/null and b/CodeListLibrary_project/cll/static/img/brands/HDRN/logo-transparent.png differ diff --git a/CodeListLibrary_project/cll/static/img/brands/HDRN/logo.jpg b/CodeListLibrary_project/cll/static/img/brands/HDRN/logo.jpg new file mode 100644 index 000000000..3d29ca306 Binary files /dev/null and b/CodeListLibrary_project/cll/static/img/brands/HDRN/logo.jpg differ diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/accessibility.js b/CodeListLibrary_project/cll/static/js/clinicalcode/accessibility.js index cdb3ceb1c..9a3bb3a6b 100644 --- a/CodeListLibrary_project/cll/static/js/clinicalcode/accessibility.js +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/accessibility.js @@ -1,13 +1,3 @@ -/** - * CL_ACCESSIBILITY_KEYS - * @desc Keycodes used for accessibility on elements - * that are not necessarily meant to be accessible by default - */ -const CL_ACCESSIBILITY_KEYS = { - // Enter, to activate click elements via accessibility readers - 'ENTER': 13, -} - /** * Main thread * @desc listens to key events to see if the user has utilised any accessibility keys, @@ -28,8 +18,8 @@ domReady.finally(() => { document.addEventListener('keydown', e => { const elem = document.activeElement; - const code = e.keyIdentifier || e.which || e.keyCode; - if (code !== CL_ACCESSIBILITY_KEYS.ENTER) { + const code = e.code; + if (!code.endsWith('Enter') || elem.disabled) { return; } @@ -37,14 +27,14 @@ domReady.finally(() => { elem.click(); } else if (elem.matches('[role="dropdown"]')) { const radio = elem.querySelector('input[type="radio"]'); - if (radio) { + if (radio && !radio.disabled) { radio.checked = !radio.checked; } } else if (elem.matches('[type="checkbox"]')) { - elem.checked = !elem.checked; + elem.click(); } else if (elem.matches('[role="collapsible"]')) { const collapsible = elem.querySelector('input[type="checkbox"]'); - if (collapsible) { + if (collapsible && !collapsible.disabled) { collapsible.checked = !collapsible.checked; } } diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/components/autocomplete.js b/CodeListLibrary_project/cll/static/js/clinicalcode/components/autocomplete.js new file mode 100644 index 000000000..a1f986db3 --- /dev/null +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/components/autocomplete.js @@ -0,0 +1,328 @@ +/** + * Class wrapping & managing an autocomplete component + * + * @note can be combined with `components/fuzzyQuery.js` for FTS + * + * @example + * const options = [{ id: 1, term: 'hello' }, { id: 2, term: 'world' }]; + * + * const element = new Autocomplete({ + * rootNode: document.querySelector('.autocomplete-container'), + * inputNode: document.querySelector('.autocomplete-input'), + * resultsNode: document.querySelector('.autocomplete-results'), + * searchFn: (input) => { + * if (input.length <= 3) { + * return []; + * } + * + * return options.filter(x => x.term.toLocaleLowerCase().startsWith(input.toLocaleLowerCase())); + * }, + * }); + * + * @class + * @constructor + */ +export class Autocomplete { + /** + * @desc a set of `aria-autocomplete` values specifying inline autocomplete behaviour + * @see {@link https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-autocomplete|aria-autocomplete} + * @type {Array<string>} + * @static + * @private + */ + static #AriaAutocompleteInline = ['both', 'inline']; + + /** + * @desc a set of disposable functions to clean up this class, executed on class disposal + * @type {Array<Function>} + * @private + */ + #disposables = []; + + /** + * @param {object} param0 constructor args + * @param {HTMLElement} param0.rootNode the autocomplete component container element + * @param {HTMLElement} param0.inputNode the autocomplete text input element + * @param {HTMLElement} param0.resultsNode the autocomplete results dropdown container element + * @param {Function} param0.searchFn a function to evaluate the search term, returning an array matching the specified input query + * @param {boolean} [param0.shouldAutoSelect=false] optionally specify whether to automatically select the element; defaults to `false` + * @param {boolean|null} [param0.shouldAutoInline=null] optionally specify whether to provide inline autocomplete, this _will_ be derived from the element `aria-autocomplete` tag if not specified; defaults to `null` (i.e. false unless element is tagged as such) + * @param {Function} [param0.onShow=Function] optionally specify a function to be called when the results are shown; defaults to a nullable callable + * @param {Function} [param0.onHide=Function] optionally specify a function to be called when the results are hidden; defaults to a nullable callable + */ + constructor({ + rootNode, + inputNode, + resultsNode, + searchFn, + shouldAutoSelect = false, + shouldAutoInline = null, + onShow = () => {}, + onHide = () => {}, + }) { + this.rootNode = rootNode; + this.inputNode = inputNode; + this.resultsNode = resultsNode; + this.searchFn = searchFn; + this.shouldAutoSelect = shouldAutoSelect; + this.onShow = onShow; + this.onHide = onHide; + this.activeIndex = -1; + this.resultsCount = 0; + this.showResults = false; + + if (typeof shouldAutoInline !== 'boolean') { + const ariaAutocomplete = this.inputNode.getAttribute('aria-autocomplete'); + this.hasInlineAutocomplete = stringHasChars(ariaAutocomplete) + ? Autocomplete.#AriaAutocompleteInline.includes(ariaAutocomplete.toLowerCase()) + : false; + } else { + this.hasInlineAutocomplete = shouldAutoInline; + } + this.inputNode.setAttribute('aria-autocomplete', shouldAutoInline ? 'both' : 'list'); + + // Setup events + const focusHnd = this.#handleFocus.bind(this); + const keyUpHnd = this.#handleKeyup.bind(this); + const keyDownHnd = this.#handleKeydown.bind(this); + const resClickHnd = this.#handleResultClick.bind(this); + const docClickHnd = this.#handleDocumentClick.bind(this); + document.body.addEventListener('click', docClickHnd); + this.resultsNode.addEventListener('click', resClickHnd); + this.inputNode.addEventListener('focus', focusHnd); + this.inputNode.addEventListener('keyup', keyUpHnd); + this.inputNode.addEventListener('keydown', keyDownHnd); + + // Cleanup + this.#disposables.push(() => { + document.body.removeEventListener('click', docClickHnd); + this.resultsNode.removeEventListener('click', resClickHnd); + this.inputNode.removeEventListener('focus', focusHnd); + this.inputNode.removeEventListener('keyup', keyUpHnd); + this.inputNode.removeEventListener('keydown', keyDownHnd); + }); + } + + + /************************************* + * * + * Public * + * * + *************************************/ + + getItemAt(index) { + return this.resultsNode.querySelector(`#autocomplete-result-${index}`); + } + + selectItem(node) { + if (node) { + this.inputNode.value = node.innerText; + this.hideResults(); + } + } + + checkSelection() { + if (this.activeIndex < 0) { + return; + } + + const activeItem = this.getItemAt(this.activeIndex); + this.selectItem(activeItem); + } + + autocompleteItem() { + const autocompletedItem = this.resultsNode.querySelector('.selected'); + const input = this.inputNode.value; + if (!autocompletedItem || !input) { + return; + } + + const autocomplete = autocompletedItem.innerText; + if (input !== autocomplete) { + this.inputNode.value = autocomplete; + this.inputNode.setSelectionRange(input.length, autocomplete.length); + } + } + + updateResults() { + const input = this.inputNode.value; + const results = this.searchFn(input); + this.hideResults(); + + if (results.length === 0) { + return; + } + + clearAllChildren(this.resultsNode); + + for (let index = 0; index < results.length; ++index) { + const result = results[index]; + const isSelected = this.shouldAutoSelect && index === 0; + if (isSelected) { + this.activeIndex = 0; + } + + createElement('li', { + text: String(result), + class: `autocomplete-result${isSelected ? ' selected' : ''}`, + attr: { id: `autocomplete-result-${index}` }, + aria: { selected: !!isSelected }, + parent: this.resultsNode, + }); + } + + this.resultsNode.classList.remove('hidden'); + this.rootNode.setAttribute('aria-expanded', true); + this.resultsCount = results.length; + this.shown = true; + this.onShow(); + } + + hideResults() { + this.shown = false; + this.activeIndex = -1; + this.resultsCount = 0; + + this.resultsNode.classList.add('hidden'); + this.rootNode.setAttribute('aria-expanded', 'false'); + this.inputNode.setAttribute('aria-activedescendant', ''); + clearAllChildren(this.resultsNode); + + this.onHide(); + } + + dispose() { + let disposable; + for (let i = this.#disposables.length; i > 0; i--) { + disposable = this.#disposables.pop(); + if (typeof disposable !== 'function') { + continue; + } + + disposable(); + } + } + + + /************************************* + * * + * Events * + * * + *************************************/ + #handleDocumentClick(event) { + if (event.target === this.inputNode || this.rootNode.contains(event.target)) { + return; + } + + this.hideResults(); + } + + #handleKeyup(event) { + const { code } = event + switch (code) { + case 'ArrowUp': + case 'ArrowDown': + case 'Escape': + case 'Enter': + event.preventDefault(); + return; + + default: + this.updateResults(); + break; + } + + if (!this.hasInlineAutocomplete || code === 'Backspace') { + return; + } + + this.autocompleteItem(); + } + + #handleKeydown(event) { + let activeItem, activeIndex; + activeIndex = this.activeIndex; + + const { code } = event; + if (code === 'Escape') { + this.hideResults(); + this.inputNode.value = ''; + return; + } + + if (this.resultsCount < 1) { + if (!this.hasInlineAutocomplete || (code !== 'ArrowDown' & code !== 'ArrowUp')) { + return; + } + + this.updateResults(); + } + + const prevActive = this.getItemAt(activeIndex); + switch (code) { + case 'ArrowUp': + if (activeIndex <= 0) { + activeIndex = this.resultsCount - 1; + } else { + activeIndex -= 1; + } + break; + + case 'ArrowDown': + if (activeIndex === -1 || activeIndex >= this.resultsCount - 1) { + activeIndex = 0; + } else { + activeIndex += 1; + } + break; + + case 'Enter': + activeItem = this.getItemAt(activeIndex); + this.selectItem(activeItem); + return; + + case 'Tab': + this.checkSelection(); + this.hideResults(); + return; + + default: + return; + } + + event.preventDefault(); + activeItem = this.getItemAt(activeIndex); + this.activeIndex = activeIndex; + + if (prevActive) { + prevActive.classList.remove('selected'); + prevActive.setAttribute('aria-selected', 'false'); + } + + if (activeItem) { + this.inputNode.setAttribute('aria-activedescendant', `autocomplete-result-${activeIndex}`); + activeItem.classList.add('selected'); + + if (activeItem.getAttribute('aria-selected') !== 'true') { + scrollContainerTo(this.resultsNode, activeItem); + } + activeItem.setAttribute('aria-selected', 'true'); + + if (this.hasInlineAutocomplete) { + this.inputNode.value = activeItem.innerText; + } + } else { + this.inputNode.setAttribute('aria-activedescendant', ''); + } + } + + #handleFocus(event) { + this.updateResults(); + } + + #handleResultClick(event) { + if (event.target && event.target.nodeName === 'LI') { + this.selectItem(event.target); + } + } +}; diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/components/doubleRangeSlider.js b/CodeListLibrary_project/cll/static/js/clinicalcode/components/doubleRangeSlider.js new file mode 100644 index 000000000..a70b5547e --- /dev/null +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/components/doubleRangeSlider.js @@ -0,0 +1,318 @@ +import { parseAsFieldType } from '../forms/entityCreator/utils.js'; + +/** + * @class DoubleRangeSlider + * @desc handler for DoubleRangeSlider components + * + * @param {string|node} obj The ID of the input element or the input element itself + * @param {object} data Should describe the properties & validation assoc. with this component + * + * @return {object} An interface to control the behaviour of the component + */ +export default class DoubleRangeSlider { + /** + * @desc describes the types that can be managed by this slider + * @static + * @constant + */ + static AllowedTypes = ['int', 'float', 'decimal', 'numeric', 'percentage']; + + constructor(obj, data) { + if (typeof obj === 'string') { + this.id = id; + this.element = document.getElementById(id); + } else { + this.element = obj; + if (typeof this.element !== 'undefined') { + this.id = this.element.getAttribute('id'); + } + } + + this.value = null; + this.dirty = false; + + this.data = data; + this.data.properties = DoubleRangeSlider.ComputeProperties(this.data?.properties); + + this.#initialise(); + } + + /** + * @desc attempts to parse, validate, and compute the properties of the range slider + * @static + * + * @param {any} props the range properties, if available + * + * @returns {Record<string, string|number>} the resulting range props + */ + static ComputeProperties(props) { + props = isObjectType(props) ? props : { }; + + let valueType = props?.type; + let valueStep = typeof props?.step === 'string' ? Number(props?.step) : props.step; + + let hasValueType = false; + let hasValidStep = typeof valueStep === 'number' && !isNaN(valueStep) && Number.isFinite(valueStep); + if (!isObjectType(props)) { + props = { min: 0, max: 100, step: 1, type: 'int' }; + } + + if (stringHasChars(valueType)) { + valueType = valueType.toLowerCase(); + valueType = DoubleRangeSlider.AllowedTypes.includes(valueType) ? valueType : null; + hasValueType = typeof valueType === 'string'; + } + + let min = typeof props.min === 'string' ? Number(props.min) : props.min; + let max = typeof props.max === 'string' ? Number(props.max) : props.max; + + const validMin = typeof min === 'number' && !isNaN(min) && Number.isFinite(min); + const validMax = typeof max === 'number' && !isNaN(max) && Number.isFinite(max); + + if (!hasValidStep) { + if (hasValueType) { + valueStep = valueType === 'int' ? 1 : 0.1; + } else if (validMin) { + const precision = String(min).split('.')?.[1]?.length || 0; + valueStep = Math.pow(10, -precision); + } else { + valueStep = 1; + } + } + + if (validMin && validMax) { + let tmp = Math.max(min, max); + min = Math.min(min, max); + max = tmp; + } else if (validMin) { + max = min + (hasValidStep ? valueStep : 1)*100; + } else if (validMax) { + min = max - (hasValidStep ? valueStep : 1)*100; + } else { + min = 0; + max = valueStep*100; + } + props.min = min; + props.max = max; + + if (!hasValueType) { + let precision = hasValidStep ? step : (isNullOrUndefined(min) ? min : 0); + precision = String(precision).split('.')?.[1]?.length || 0; + valueType = precision === 0 ? 'int' : 'float'; + } + + props.type = valueType; + props.step = valueStep; + return props; + } + + /************************************* + * * + * Getter * + * * + *************************************/ + /** + * getValue + * @desc gets the current value of the component + * @returns {any} the value selected via its options data + */ + getValue() { + return this.value; + } + + /** + * getElement + * @returns {node} the assoc. element + */ + getElement() { + return this.element; + } + + /** + * isDirty + * @returns {bool} returns the dirty state of this component + */ + isDirty() { + return this.dirty; + } + + /************************************* + * * + * Setter * + * * + *************************************/ + /** + * makeDirty + * @desc informs the top-level parent that we're dirty + * and updates our internal dirty state + * @return {object} return this for chaining + */ + makeDirty() { + window.entityForm.makeDirty(); + this.dirty = true; + return this; + } + + /** + * setProgressBar + * @desc sets the current value of the component + * @param {any} value the value that should be selected from its list of options + */ + setProgressBar() { + const distance = this.data.properties.max - this.data.properties.min; + const pos0 = (this.value.min / distance) * 100, + pos1 = (this.value.max / distance) * 100; + + this.elements.progressBar.style.background = ` + linear-gradient( + to right, + var(--color-accent-semi-transparent) ${pos0}%, + var(--color-accent-primary) ${pos0}%, + var(--color-accent-primary) ${pos1}%, + var(--color-accent-semi-transparent) ${pos1}% + ) + `; + } + + /** + * setValue + * @desc sets the current value of the component + * @param {any} value the value that should be selected from its list of options + */ + setValue(value) { + const { min, max, type, step } = this.data.properties; + if (this.value) { + this.dirty = !value || value.min != this.value.min || this.max != this.value.max; + } + + if (!isObjectType(value)) { + this.value = { min, max }; + return this; + } + + let { min: vmin, max: vmax } = value; + vmin = parseAsFieldType({ validation: { type }}, value.min); + vmax = parseAsFieldType({ validation: { type }}, value.max); + + vmin = (!isNullOrUndefined(vmin) && !!vmin.success) ? vmin.value : min; + vmax = (!isNullOrUndefined(vmax) && !!vmax.success) ? vmax.value : max; + value = { min: vmin, max: vmax }; + + vmin = Math.min(value.min, value.max); + vmax = Math.max(value.min, value.max); + + if (type === 'int') { + vmin = Math.trunc(vmin); + vmax = Math.trunc(vmax); + } else { + const m = Math.pow(10, String(step).split('.')?.[1]?.length || 0); + vmin = Math.round(vmin * m) / m; + vmax = Math.round(vmax * m) / m; + } + + value.min = clampNumber(vmin, min, max); + value.max = clampNumber(vmax, min, max); + this.value = value; + + this.elements.inputs.min.value = value.min; + this.elements.inputs.max.value = value.max; + this.elements.sliders.min.value = value.min; + this.elements.sliders.max.value = value.max; + + this.setProgressBar(); + fireChangedEvent(this.element); + return this; + } + + /************************************* + * * + * Private * + * * + *************************************/ + /** + * initialise + * @desc private method to initialise & render the component + */ + #initialise() { + const progress = this.element.querySelector('#progress-bar'); + + const minValueInput = this.element.querySelector('#min-value'); + minValueInput.min = this.data.properties.min; + minValueInput.max = this.data.properties.max; + minValueInput.step = this.data.properties.step; + minValueInput.value = this.data.properties.min; + minValueInput.addEventListener('blur', this.#onChangeCallback.bind(this)); + minValueInput.addEventListener('change', this.#onChangeCallback.bind(this)); + + const maxValueInput = this.element.querySelector('#max-value'); + maxValueInput.min = this.data.properties.min; + maxValueInput.max = this.data.properties.max; + maxValueInput.step = this.data.properties.step; + maxValueInput.value = this.data.properties.max; + maxValueInput.addEventListener('blur', this.#onChangeCallback.bind(this)); + maxValueInput.addEventListener('change', this.#onChangeCallback.bind(this)); + + const minValueSlider = this.element.querySelector('#min-slider'); + minValueSlider.min = this.data.properties.min; + minValueSlider.max = this.data.properties.max; + minValueSlider.step = this.data.properties.step; + minValueSlider.value = this.data.properties.min; + minValueSlider.addEventListener('input', this.#onChangeCallback.bind(this)); + + const maxValueSlider = this.element.querySelector('#max-slider'); + maxValueSlider.min = this.data.properties.min; + maxValueSlider.max = this.data.properties.max; + maxValueSlider.step = this.data.properties.step; + maxValueSlider.value = this.data.properties.max; + maxValueSlider.addEventListener('input', this.#onChangeCallback.bind(this)); + + this.elements = { + progressBar: progress, + inputs: { min: minValueInput, max: maxValueInput }, + sliders: { min: minValueSlider, max: maxValueSlider } + } + + let value = this.data?.value; + if (!isNullOrUndefined(value) && !isNullOrUndefined(value.min) && !isNullOrUndefined(value.max)) { + this.setValue(value); + } else { + this.setValue(null); + } + } + + /************************************* + * * + * Events * + * * + *************************************/ + /** + * onChangeCallback + * @desc handles the change event for all related checkboxes + * @param {event} e the change event of an element + */ + #onChangeCallback(e) { + let target; + if (e.type === 'blur') { + target = e.originalTarget; + } else { + target = e.target; + } + + if (!target || !this.element.contains(target) || !target.matches('input[type="range"], input[type="number"]')) { + return; + } + + let { type } = this.data.properties || { }; + type = stringHasChars(type) ? type : 'int'; + + const dataTarget = target.getAttribute('data-target'); + const currentValue = { min: this.value.min, max: this.value.max }; + if (type === 'int') { + currentValue[dataTarget] = parseInt(target.value); + } else { + currentValue[dataTarget] = parseFloat(target.value); + } + + this.setValue(currentValue); + } +} diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/components/dropdown.js b/CodeListLibrary_project/cll/static/js/clinicalcode/components/dropdown.js index f6ca04b26..08c414ffd 100644 --- a/CodeListLibrary_project/cll/static/js/clinicalcode/components/dropdown.js +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/components/dropdown.js @@ -1,15 +1,3 @@ -/** - * DROPDOWN_KEYS - * @desc Keycodes used to navigate through dropdown - */ -const DROPDOWN_KEYS = { - // Navigate dropdown - 'DOWN': 40, - 'UP': 38, - // Exit component - 'ESC': 27, -}; - /** * createDropdownSelectionElement * @desc Creates an accessible dropdown element @@ -26,9 +14,9 @@ const createDropdownSelectionElement = (element) => { }); const btn = createElement('button', { + 'type': 'button', 'className': 'dropdown-selection__button', 'data-value': '', - 'type': 'button' }); const title = createElement('span', { @@ -85,10 +73,10 @@ const createDropdownSelectionElement = (element) => { return; } - const code = e.keyIdentifier || e.which || e.keyCode; + const code = e.code; switch (code) { - case DROPDOWN_KEYS.UP: - case DROPDOWN_KEYS.DOWN: + case 'ArrowUp': + case 'ArrowDown': let target = e.target; if (container.contains(target)) { e.stopPropagation(); @@ -99,13 +87,13 @@ const createDropdownSelectionElement = (element) => { target = children[0]; } - let index = children.indexOf(target) + (DROPDOWN_KEYS.UP == code ? -1 : 1); + let index = children.indexOf(target) + (code === 'ArrowUp' ? -1 : 1); index = index > children.length - 1 ? 0 : (index < 0 ? children.length - 1 : index); children[index].focus(); } break; - case DROPDOWN_KEYS.ESC: + case 'Escape': list.classList.remove('active'); break; diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/components/entitySelector.js b/CodeListLibrary_project/cll/static/js/clinicalcode/components/entitySelector.js index 279c63bd6..4e7b33099 100644 --- a/CodeListLibrary_project/cll/static/js/clinicalcode/components/entitySelector.js +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/components/entitySelector.js @@ -23,25 +23,38 @@ const getDescriptor = (description, name) => { /** * createGroup * @desc method to interpolate a card using a template - * @param {node} container the container element - * @param {string} template the template fragment - * @param {number} id the id of the associated element - * @param {string} title string, as formatted by the `getDescriptor` method + * + * @param {node} container the container element + * @param {string} template the template fragment + * @param {number} entityLen the num. of creatable entities + * @param {number} id the id of the associated element + * @param {string} title string, as formatted by the `getDescriptor` method * @param {string} description string, as formatted by the `getDescriptor` method + * * @returns {node} the interpolated element after appending to the container node * */ -const createGroup = (container, template, id, title, description) => { - description = getDescriptor(description, title); +const createGroup = (container, template, entityLen, id, title, description) => { + let descCls; + if (entityLen > 1) { + title = title.toLocaleUpperCase(); + descCls = ''; + description = getDescriptor(description, title); + } else { + title = 'Please select:' + descCls = 'hide'; + description = ''; + } const html = interpolateString(template, { 'id': id, - 'title': title.toLocaleUpperCase(), + 'title': title, 'description': description, + 'descCls': descCls, }); const doc = parseHTMLFromString(html, true); - return container.appendChild(doc.body.children[0]); + return container.appendChild(doc[0]); } /** @@ -67,7 +80,7 @@ const createCard = (container, template, id, type, hint, title, description) => }); const doc = parseHTMLFromString(html, true); - return container.appendChild(doc.body.children[0]); + return container.appendChild(doc[0]); } /** @@ -122,7 +135,8 @@ const initialiseSelector = (formData) => { const entities = datasets?.data?.entities; if (!isNullOrUndefined(entities)) { - for (let i = 0; i < entities.length; ++i) { + const entityLen = entities.length; + for (let i = 0; i < entityLen; ++i) { let entity = entities[i]; const available = datasets?.data?.templates.filter(item => item.entity_class__id == entity.id); if (available.length < 1) { @@ -132,6 +146,7 @@ const initialiseSelector = (formData) => { let group = createGroup( groupContainer, templates?.group, + entityLen, entity.id, entity.name, entity.description diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/components/fuzzyQuery.js b/CodeListLibrary_project/cll/static/js/clinicalcode/components/fuzzyQuery.js index f507b6c88..9a3de5817 100644 --- a/CodeListLibrary_project/cll/static/js/clinicalcode/components/fuzzyQuery.js +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/components/fuzzyQuery.js @@ -1,29 +1,26 @@ /** - * @class FuzzyQuery - * @desc A static class that uses Levenshtein distance to search a haystack. - * - * e.g. - ```js - import FuzzyQuery from '../components/fuzzyQuery.js'; - - // i.e. some haystack of item(s) - const haystack = [ - 'some_item1', - 'some_item2', - 'another_thing', - 'another_thing_1', - ]; - - // e.g. some search string - const query = 'some_item'; - - // ...attempt to search haystack - const results = FuzzyQuery.Search(haystack, query, FuzzyQuery.Results.Sort, FuzzyQuery.Transformers.IgnoreCase); - console.log(results); // --> Of result: ['some_item1', 'some_item2'] - - ``` - * - */ + * @class FuzzyQuery + * @desc A static class that uses Levenshtein distance to search a haystack. + * + * @example + * import FuzzyQuery from '../components/fuzzyQuery.js'; + * + * // i.e. some haystack of item(s) + * const haystack = [ + * 'some_item1', + * 'some_item2', + * 'another_thing', + * 'another_thing_1', + * ]; + * + * // e.g. some search string + * const query = 'some_item'; + * + * // ...attempt to search haystack + * const results = FuzzyQuery.Search(haystack, query, FuzzyQuery.Results.Sort, FuzzyQuery.Transformers.IgnoreCase); + * console.log(results); // --> Of result: ['some_item1', 'some_item2'] + * + */ export default class FuzzyQuery { /** * @desc transformers are preprocessors that modify both the haystack and the needle prior to fuzzy matching @@ -147,13 +144,14 @@ export default class FuzzyQuery { let results = []; for (let i = 0; i < haystack.length; i++) { let item = String(haystack[i]); + let comp = item; if (typeof transformer === 'function') { - item = transformer(item); + comp = transformer(comp); } - - if (FuzzyQuery.Match(item, query)) { + + if (FuzzyQuery.Match(comp, query)) { if (sort) { - results.push({ item: item, score: FuzzyQuery.Distance(item, query) }); + results.push({ item: item, score: FuzzyQuery.Distance(comp, query) }); } else { results.push({ item: item }); } diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/components/groupedEnumSelector.js b/CodeListLibrary_project/cll/static/js/clinicalcode/components/groupedEnumSelector.js index d3c9354fd..6bcaf44ca 100644 --- a/CodeListLibrary_project/cll/static/js/clinicalcode/components/groupedEnumSelector.js +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/components/groupedEnumSelector.js @@ -58,11 +58,13 @@ export default class GroupedEnum { for (let i = 0; i < checked.length; ++i) { checked[i].checked = false; } + return this; } + + const matchedGroup = this.data?.properties.find(x => x.result === value); this.value = value; - let matchedGroup = this.data?.properties.find(x => x.result === value); if (!isNullOrUndefined(matchedGroup)) { const inputs = this.element.querySelectorAll('input'); for (let i = 0; i < inputs.length; ++i) { @@ -99,9 +101,12 @@ export default class GroupedEnum { // Build renderables for (let i = 0; i < this.data?.options.length; ++i) { const option = this.data?.options[i]; - const group = this.data?.properties.find(x => x.result == option.value); - if (!isNullOrUndefined(group)) { - continue + const properties = this.data?.properties + if (!isNullOrUndefined(properties)) { + const group = properties.find(x => x.result == option.value); + if (!isNullOrUndefined(group)) { + continue + } } const item = this.#createCheckbox( @@ -110,7 +115,7 @@ export default class GroupedEnum { option.value ); - // assign changed behaviour + // Assign changed behaviour const checkbox = item.querySelector('input'); checkbox.addEventListener('change', this.#onChangeCallback.bind(this)); } @@ -119,11 +124,12 @@ export default class GroupedEnum { let value = this.data?.value?.[0]?.value; if (!isNullOrUndefined(value)) { this.setValue(value); - } else { - const defaultValue = this.element.getAttribute('data-default'); - if (!isNullOrUndefined(defaultValue)) { - this.setValue(defaultValue); - } + return this; + } + + const defaultValue = this.element.getAttribute('data-default'); + if (!isNullOrUndefined(defaultValue)) { + this.setValue(defaultValue); } } @@ -138,27 +144,53 @@ export default class GroupedEnum { * @param {event} e the change event of an element */ #onChangeCallback(e) { - const checked = this.element.querySelectorAll('input:checked') - const selected = Array.prototype.slice.call(checked) - .map(node => { - return this.data?.options.find(x => x.value == node.getAttribute('data-value')) - }); + const checked = this.element.querySelectorAll('input:checked'); + const selectedValues = Array.prototype.slice.call(checked) + .reduce((res, node) => { + const item = this.data?.options?.find?.(x => x.value == node.getAttribute('data-value')); + if (isNullOrUndefined(item)) { + node.checked = false; + return res; + } - // Select a group if a match is found + res.push(item.value); + return res; + }, []); + + // Det. groups, if any let matchedGroup; - let selectedValues = selected.map(x => x?.value); - for (let i = 0; i < this.data?.properties.length; ++i) { - let group = this.data?.properties[i]; - if (isNullOrUndefined(group?.when)) { - continue; - } - - if (isArrayEqual(selectedValues, group?.when)) { - matchedGroup = group; - break + let selectedGroup; + + const target = e.target; + const properties = this.data?.properties; + const singleSelected = selectedValues.length === 1; + if (!isNullOrUndefined(properties)) { + for (let i = 0; i < properties.length; ++i) { + let group = properties[i]; + if (isNullOrUndefined(group?.when)) { + continue; + } + + if (isArrayEqual(selectedValues, group?.when)) { + matchedGroup = group; + break; + } + + if (!target.checked) { + const test = [...selectedValues]; + const indx = target.getAttribute('data-value'); + if (!test.includes(indx)) { + test.push(indx); + } + + if (isArrayEqual(test, group.when)) { + selectedGroup = group; + } + } } } + // Select a group if a match is found if (!isNullOrUndefined(matchedGroup)) { for (let i = 0; i < checked.length; ++i) { let checkbox = checked[i]; @@ -175,21 +207,32 @@ export default class GroupedEnum { } // None found, clear current selection & apply state of our current checkbox - const target = e.currentTarget; + let desiredValue; + if (target.checked) { + desiredValue = target.getAttribute('data-value'); + } else if (singleSelected && selectedGroup) { + desiredValue = selectedValues.shift(); + } else { + desiredValue = null; + } + for (let i = 0; i < checked.length; ++i) { let checkbox = checked[i]; - if (target == checkbox) { + if (!isNullOrUndefined(desiredValue) && singleSelected && selectedGroup) { + if (target !== checkbox && !selectedGroup.when.includes(checkbox.getAttribute('data-value'))) { + checkbox.checked = false; + } + + continue; + } + + if (target === checkbox) { continue; } checkbox.checked = false; } - - if (target.checked) { - this.value = target.getAttribute('data-value'); - } else { - this.value = null; - } + this.value = desiredValue; } /************************************* @@ -212,6 +255,6 @@ export default class GroupedEnum { </div>` const doc = parseHTMLFromString(html, true); - return this.element.appendChild(doc.body.children[0]); + return this.element.appendChild(doc[0]); } } diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/components/listEnumSelector.js b/CodeListLibrary_project/cll/static/js/clinicalcode/components/listEnumSelector.js new file mode 100644 index 000000000..e8d4fe2ea --- /dev/null +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/components/listEnumSelector.js @@ -0,0 +1,220 @@ +/** + * @class ListEnum + * @desc handler for ListEnum components, where: + * - Selection of individual values via checkboxes + * @param {string/node} obj The ID of the input element or the input element itself + * @param {object} data Should contain both (1) the available options and (2) the available groups + * @return {object} An interface to control the behaviour of the component + * + */ +export default class ListEnum { + constructor(obj, data, defaultValue) { + if (typeof obj === 'string') { + this.id = id; + this.element = document.getElementById(id); + } else { + this.element = obj; + if (typeof this.element !== 'undefined') { + this.id = this.element.getAttribute('id'); + } + } + + this.value = []; + this.data = data; + this.#initialise() + } + + /************************************* + * * + * Getter * + * * + *************************************/ + /** + * getValue + * @desc gets the current value of the component + * @returns {any} the value selected via its options data + */ + getValue() { + return this.value; + } + + /************************************* + * * + * Setter * + * * + *************************************/ + /** + * setValue + * @desc sets the current value of the component + * @param {any} value the value that should be selected from its list of options + */ + setValue(value) { + if (isNullOrUndefined(value)) { + this.value = []; + + const checked = this.element.querySelectorAll('input:checked') + for (let i = 0; i < checked.length; ++i) { + checked[i].checked = false; + } + return this; + } + this.value = value; + + const inputs = this.element.querySelectorAll('input'); + for (let i = 0; i < inputs.length; ++i) { + let val = inputs[i].getAttribute('data-value'); + inputs[i].checked = value.includes(val); + } + + return this; + } + + /************************************* + * * + * Private * + * * + *************************************/ + /** + * initialise + * @desc private method to initialise & render the component + */ + #initialise() { + // Build renderables + let opts = Array.isArray(this?.data?.options) ? this.data.options : []; + opts = opts.sort((a, b) => a.name.localeCompare(b.name)); + this.data.options = opts; + + let noneSelector = this?.data?.properties?.none_selector; + if (!isNullOrUndefined(noneSelector)) { + const idx = opts.findIndex(x => x?.value === noneSelector); + if (idx >= 0) { + noneSelector = opts.splice(idx, 1)[0]; + opts.push(noneSelector); + } + } + + for (let i = 0; i < opts.length; ++i) { + const option = opts[i]; + const item = this.#createCheckbox( + `${option.name}-${option.value}`, + option.name, + option.value + ); + + // assign changed behaviour + const checkbox = item.querySelector('input'); + checkbox.addEventListener('change', this.#onChangeCallback.bind(this)); + } + + // Assign default value + let value = this.data.value; + if (Array.isArray(value) && value.length > 0) { + value = value.map(x => x.value) + this.setValue(value); + } else { + this.setValue(null); + } + } + + /************************************* + * * + * Events * + * * + *************************************/ + /** + * onChangeCallback + * @desc handles the change event for all related checkboxes + * @param {event} e the change event of an element + */ + #onChangeCallback(e) { + const checked = this.element.querySelectorAll('input:checked') + const selected = Array.prototype.slice.call(checked) + .map(node => { + return this.data?.options.find(x => x.value == node.getAttribute('data-value')) + }); + + let selectedValues = selected.map(x => x?.value); + + let deselectExcept = null; + let properties = this.data?.properties?.groups; + if (!isNullOrUndefined(properties)) { + for (let i = 0; i < properties.length; ++i) { + let group = properties[i]; + if (isNullOrUndefined(group?.when)) { + continue; + } + + if (isNullOrUndefined(group?.result)) { + continue; + } + + if (group.result == 'deselect') { + if (this.value[0] === group?.when) { + this.value = this.value.filter((el) => { + return el !== group?.when; + }); + + for (let i = 0; i < checked.length; ++i) { + let checkbox = checked[i]; + let checkboxValue = checkbox.getAttribute('data-value'); + if (checkboxValue === group?.when) { + checkbox.checked = false; + } + } + continue; + } + + if (selectedValues.includes(group?.when)) { + deselectExcept = group?.when; + } + } + } + } + + if (!isNullOrUndefined(deselectExcept)) { + for (let i = 0; i < checked.length; ++i) { + let checkbox = checked[i]; + let checkboxValue = checkbox.getAttribute('data-value'); + + checkbox.checked = checkboxValue === deselectExcept; + } + + this.value = [deselectExcept]; + return; + } + + const target = e.currentTarget; + const targetValue = target.getAttribute('data-value'); + if (target.checked) { + this.value.push(targetValue); + this.value.sort(); + } else { + this.value = this.value.filter((el) => { + return el !== targetValue; + }); + } + } + + /************************************* + * * + * Render * + * * + *************************************/ + /** + * createCheckbox + * @desc creates a checkbox element + * @param {string} id html attr + * @param {string} title the display title + * @param {any} value the value of the input + * @returns {node} a checkbox element + */ + #createCheckbox(id, title, value) { + const html = `<div class="checkbox-item-container min-gap"> + <input id="${id}" aria-label="${title}" type="checkbox" class="checkbox-item" data-value="${value}" data-name="${title}"/> + <label for="${id}">${title}</label> + </div>` + + const doc = parseHTMLFromString(html, true); + return this.element.appendChild(doc[0]); + } +} diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/components/modal.js b/CodeListLibrary_project/cll/static/js/clinicalcode/components/modal.js index 4a63966e6..cb871051a 100644 --- a/CodeListLibrary_project/cll/static/js/clinicalcode/components/modal.js +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/components/modal.js @@ -49,6 +49,7 @@ const PROMPT_MODAL_SIZES = { Small: 'sm', Medium: 'md', Large: 'lg', + XLarge: 'xl', }; /** @@ -60,6 +61,7 @@ const PROMPT_DEFAULT_PARAMS = { title: 'Modal', content: '', showFooter: true, + showActionSpinner: true, size: PROMPT_MODAL_SIZES.Medium, buttons: PROMPT_BUTTONS_DEFAULT, onRender: () => { }, @@ -100,7 +102,7 @@ class CancellablePromise { * */ class ModalResult { - constructor(name, type, data) { + constructor(name, type = PROMPT_BUTTON_TYPES.REJECT, data = null) { this.name = name; this.type = type; this.data = data; @@ -111,60 +113,58 @@ class ModalResult { * @class ModalFactory * @desc A window-level instance to create modals * - * e.g. - ```js - const ModalFactory = window.ModalFactory; - ModalFactory.create({ - id: 'test-dialog', - title: 'Hello', - content: '<p>Hello</p>', - buttons: [ - { - name: 'Cancel', - type: ModalFactory.ButtonTypes.REJECT, - html: `<button class="secondary-btn text-accent-darkest bold washed-accent" id="cancel-button"></button>`, - }, - { - name: 'Reject', - type: ModalFactory.ButtonTypes.REJECT, - html: `<button class="secondary-btn text-accent-darkest bold washed-accent" id="reject-button"></button>`, - }, - { - name: 'Confirm', - type: ModalFactory.ButtonTypes.CONFIRM, - html: `<button class="primary-btn text-accent-darkest bold secondary-accent" id="confirm-button"></button>`, - }, - { - name: 'Accept', - type: ModalFactory.ButtonTypes.CONFIRM, - html: `<button class="primary-btn text-accent-darkest bold secondary-accent" id="accept-button"></button>`, - }, - ] - }) - .then((result) => { - // e.g. user pressed a button that has type=ModalFactory.ButtonTypes.CONFIRM - const name = result.name; - if (name == 'Confirm') { - console.log('[success] user confirmed', result); - } else if (name == 'Accept') { - console.log('[success] user accepted', result); - } - }) - .catch((result) => { - // An error occurred somewhere (unrelated to button input) - if (!(result instanceof ModalFactory.ModalResults)) { - return console.error(result); - } - - // e.g. user pressed a button that has type=ModalFactory.ButtonTypes.REJECT - const name = result.name; - if (name == 'Cancel') { - console.log('[failure] user cancelled', result); - } else if (name == 'Reject') { - console.log('[failure] rejected', result); - } - }); - ``` + * @example + * const ModalFactory = window.ModalFactory; + * ModalFactory.create({ + * id: 'test-dialog', + * title: 'Hello', + * content: '<p>Hello</p>', + * buttons: [ + * { + * name: 'Cancel', + * type: ModalFactory.ButtonTypes.REJECT, + * html: `<button class="secondary-btn text-accent-darkest bold washed-accent" id="cancel-button"></button>`, + * }, + * { + * name: 'Reject', + * type: ModalFactory.ButtonTypes.REJECT, + * html: `<button class="secondary-btn text-accent-darkest bold washed-accent" id="reject-button"></button>`, + * }, + * { + * name: 'Confirm', + * type: ModalFactory.ButtonTypes.CONFIRM, + * html: `<button class="primary-btn text-accent-darkest bold secondary-accent" id="confirm-button"></button>`, + * }, + * { + * name: 'Accept', + * type: ModalFactory.ButtonTypes.CONFIRM, + * html: `<button class="primary-btn text-accent-darkest bold secondary-accent" id="accept-button"></button>`, + * }, + * ] + * }) + * .then((result) => { + * // e.g. user pressed a button that has type=ModalFactory.ButtonTypes.CONFIRM + * const name = result.name; + * if (name == 'Confirm') { + * console.log('[success] user confirmed', result); + * } else if (name == 'Accept') { + * console.log('[success] user accepted', result); + * } + * }) + * .catch((result) => { + * // An error occurred somewhere (unrelated to button input) + * if (!(result instanceof ModalFactory.ModalResults)) { + * return console.error(result); + * } + * + * // e.g. user pressed a button that has type=ModalFactory.ButtonTypes.REJECT + * const name = result.name; + * if (name == 'Cancel') { + * console.log('[failure] user cancelled', result); + * } else if (name == 'Reject') { + * console.log('[failure] rejected', result); + * } + * }); * */ class ModalFactory { @@ -174,7 +174,42 @@ class ModalFactory { ModalResults = ModalResult; constructor() { - this.modal = null; + this.modals = { }; + + document.addEventListener('keyup', (e) => { + let modal = Object.values(this.modals) + .filter(x => !!x.escape) + .sort((a, b) => { + const d0 = a?.timestamp || 0; + const d1 = b?.timestamp || 0; + return d0 > d1 ? -1 : (d0 < d1 ? 1 : 0); + }) + .shift(); + + if (isObjectType(modal)) { + modal.escape(e); + } + }); + } + + /** + * @param {string|null} [ref=null] optionally specify the modal ref name + * + * @returns {object|null} either (a) the base modal descriptor, or (b) the modal by the specified name if provided + */ + getModal(ref = null) { + let state = this.modals; + if (typeof ref === 'string') { + state = state[ref]; + } else { + state = state.__base; + } + + if (!isObjectType(state)) { + return null; + } + + return state; } /** @@ -192,25 +227,33 @@ class ModalFactory { * */ create(options) { - options = options || { }; - this.closeCurrentModal(); + options = isObjectType(options) ? options : { }; + + const modalRef = stringHasChars(options.ref) ? options.ref : '__base'; + this.closeCurrentModal(modalRef); try { options = mergeObjects(options, PROMPT_DEFAULT_PARAMS); const { id, title, content, showFooter, buttons, size } = options; - - const html = interpolateString(PROMPT_DEFAULT_CONTAINER, { id: id, title: title, content: content, size: size }); + const html = interpolateString(PROMPT_DEFAULT_CONTAINER, { + id: id, + size: size, + title: title, + content: content, + }); + + const modalState = { }; const doc = parseHTMLFromString(html, true); const currentHeight = window.scrollY; - const modal = document.body.appendChild(doc.body.children[0]); + const modal = document.body.appendChild(doc[0]); const container = modal.querySelector('.target-modal__container'); let footer; if (showFooter) { footer = createElement('div', { - class: 'target-modal__footer', id: 'target-modal-footer', + class: 'target-modal__footer', }); footer = container.appendChild(footer); @@ -221,10 +264,10 @@ class ModalFactory { for (let i = 0; i < buttons.length; ++i) { let button = buttons[i]; let item = parseHTMLFromString(button.html, true); - item = footer.appendChild(item.body.children[0]); + item = footer.appendChild(item[0]); item.innerText = button.name; item.setAttribute('aria-label', button.name); - + footerButtons.push({ name: button.name, type: button.type, @@ -233,12 +276,23 @@ class ModalFactory { } } - const closeModal = (method, details) => { - document.body.classList.remove('modal-open'); - window.scrollTo({ top: currentHeight, left: window.scrollX, behaviour: 'instant'}); - modal.remove(); - history.pushState("", document.title, window.location.pathname + window.location.search); - this.modal = null; + let escapeHnd, closeModal, spinner; + closeModal = (method, details) => { + delete this.modals[modalRef]; + + if (Object.values(this.modals).length > 0) { + modal.remove(); + } else { + document.body.classList.remove('modal-open'); + window.scrollTo({ top: currentHeight, left: window.scrollX, behaviour: 'instant'}); + modal.remove(); + history.pushState('', document.title, window.location.pathname + window.location.search); + } + modalState.isActioning = false; + + if (!isNullOrUndefined(spinner)) { + spinner?.remove?.(); + } if (!method) { return; @@ -255,36 +309,94 @@ class ModalFactory { for (let i = 0; i < footerButtons.length; ++i) { let btn = footerButtons[i]; btn.element.addEventListener('click', (e) => { + if (modalState.isActioning) { + return; + } + modalState.isActioning = true; + switch (btn.type) { case this.ButtonTypes.CONFIRM: { - let data; - if (options.beforeAccept) { - data = options.beforeAccept(modal); + let closure; + if (!options.beforeAccept) { + closure = Promise.resolve(); + } else { + if (options.showActionSpinner) { + spinner = document.body.querySelector(':scope > .loading-spinner'); + if (isNullOrUndefined(spinner)) { + spinner = startLoadingSpinner(); + } + } + + closure = Promise.resolve(options.beforeAccept(modal)); } - - closeModal(resolve, Object.assign({}, btn, { data: data })); + + closure + .then(data => { + if (!!data && data instanceof ModalResult && data.name === 'Cancel') { + return; + } + + closeModal(resolve, Object.assign({}, btn, { data })); + }) + .catch(e => { + console.error(`[Modal] Failed to confirm modal selection with err:\n\n${e}\n`); + }) + .finally(_ => { + const spinElem = spinner; + spinner = null; + + if (!isNullOrUndefined(spinElem)) { + spinElem?.remove?.(); + } + modalState.isActioning = false; + }); } break; case this.ButtonTypes.REJECT: - default: { closeModal(reject, btn); - } break; + break; + + default: + modalState.isActioning = false; + break; } }); - } + }; const exit = modal.querySelector('#modal-close-btn'); if (exit) { exit.addEventListener('click', (e) => { e.preventDefault(); + + if (modalState.isActioning) { + return; + } + modalState.isActioning = true; + closeModal(reject); }); } - + + escapeHnd = (e) => { + if (modalState.isActioning) { + return; + } + + const activeFocusElem = document.activeElement; + if (!!activeFocusElem && activeFocusElem.matches('input, textarea, button, select')) { + return; + } + + if (e.code === 'Escape') { + modalState.isActioning = true; + closeModal(reject); + } + }; + // Show the modal - createElement('a', { href: `#${id}` }).click(); + createElement('a', { href: `#${options.id}` }).click(); window.scrollTo({ top: currentHeight, left: window.scrollX, behaviour: 'instant'}); - + // Inform screen readers of alert modal.setAttribute('aria-hidden', false); modal.setAttribute('role', 'alert'); @@ -298,11 +410,13 @@ class ModalFactory { } }); - this.modal = { - element: modal, - promise: promise, - close: closeModal, - }; + modalState.close = closeModal; + modalState.escape = escapeHnd; + modalState.element = modal; + modalState.promise = promise; + modalState.timestamp = Date.now(); + modalState.isActioning = false; + this.modals[modalRef] = modalState; return promise; } @@ -313,17 +427,30 @@ class ModalFactory { /** * closeCurrentModal - * @desc closes the current modal and resolves its associated - * promise + * @desc closes the current modal and resolves its associated promise + * + * @param {string|null} [ref=null] optionally specify the modal ref name * */ - closeCurrentModal() { - if (isNullOrUndefined(this.modal)) { + closeCurrentModal(ref = null) { + let state = this.modals; + if (typeof ref === 'string') { + state = state[ref]; + } else { + state = state.__base; + } + + if (!isObjectType(state)) { + return; + } + + const { promise, close, isActioning } = state; + if (isActioning) { return; } - const { modal, promise, close: closeModal } = this.modal; - closeModal(promise.cancel); + state.isActioning = true; + close(promise.cancel); } }; diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/components/navigation.js b/CodeListLibrary_project/cll/static/js/clinicalcode/components/navigation.js index 4997ce22d..ca7e6afb2 100644 --- a/CodeListLibrary_project/cll/static/js/clinicalcode/components/navigation.js +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/components/navigation.js @@ -85,7 +85,7 @@ const initHamburgerMenu = () => { */ const submenuMobile = () => { // JavaScript for submenu behavior - const navText = document.querySelector('.nav-dropdown__text'); + const navText = document.querySelector('.nav-dropdown__text a'); const submenu = document.querySelector('.nav-dropdown__content'); const avataText = document.querySelector('.avatar-content'); @@ -187,8 +187,12 @@ const setNavigation = (navbar) => { // match by link let href = link.getAttribute('href'); + if (!stringHasChars(href)) { + continue; + } + href = href.replace(/\/$/, '').toLocaleLowerCase(); - + const dist = FuzzyQuery.Distance(path, href); if (typeof closest === 'undefined' || dist < distance) { distance = dist; @@ -202,7 +206,7 @@ const setNavigation = (navbar) => { } const manageBrandTargets = () => { - const elements = document.querySelectorAll('.userBrand'); + const elements = [...document.querySelectorAll('.userBrand')]; const brandSource = document.querySelector('script[type="application/json"][name="brand-targets"]'); if (elements.length < 1 || isNullOrUndefined(brandSource)) { return; @@ -219,7 +223,67 @@ const manageBrandTargets = () => { isProductionRoot = ['true', '1'].indexOf(isProductionRoot.toLowerCase()) >= 0; } - const handleBrandTarget = (e) => getBrandUrlTarget(brandTargets, isProductionRoot, e.target, oldRoot, path); + const tryGetBrandNavMap = (ref) => { + const map = isHtmlObject(ref) + ? ref.querySelector('script[type="application/json"][name="brand-mapping"]') + : null; + + let parsed = null; + if (!isNullOrUndefined(map)) { + try { + parsed = JSON.parse(map.innerText); + } + catch { } + + if (!isNullOrUndefined(parsed) && !isObjectType(parsed)) { + parsed = null; + } + } + + return parsed; + } + + const handleBrandTarget = (e) => { + let current = getCurrentBrandPrefix(); + if (current.startsWith('/')) { + current = current.substring(1); + } + current = current.toUpperCase(); + + const trg = e.target; + const ref = elements.find(x => (stringHasChars(x.getAttribute('value')) ? x.getAttribute('value').toUpperCase() : '') === current); + + const m0 = tryGetBrandNavMap(ref); + const m1 = tryGetBrandNavMap(trg); + if (!isNullOrUndefined(m0) && !isNullOrUndefined(m1)) { + const pathIndex = brandTargets.indexOf(oldRoot.toUpperCase()) == -1 ? 0 : 1; + const pathTarget = path.split('/').slice(pathIndex); + const pathRoot = pathTarget?.[0]; + if (stringHasChars(pathRoot)) { + let uRes = Object.entries(m0).find(([k, v]) => k.endsWith('_url') && v === pathRoot); + uRes = (Array.isArray(uRes) && uRes.length >= 2) + ? m1?.[uRes[0]] + : null; + + if (stringHasChars(uRes) && uRes !== pathRoot) { + pathTarget[0] = uRes; + + uRes = ( + pathIndex + ? [oldRoot, ...pathTarget] + : pathTarget + ) + .join('/'); + + navigateBrandTargetURL(brandTargets, isProductionRoot, trg, oldRoot, uRes); + return; + } + } + } + + navigateBrandTargetURL(brandTargets, isProductionRoot, trg, oldRoot, path); + }; + for (const element of elements) { element.addEventListener('click', handleBrandTarget); } diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/components/numberInput.js b/CodeListLibrary_project/cll/static/js/clinicalcode/components/numberInput.js new file mode 100644 index 000000000..4157429bc --- /dev/null +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/components/numberInput.js @@ -0,0 +1,141 @@ +domReady.finally(() => { + const ctrlKeys = [ + 'KeyA', 'KeyF', 'KeyX', + 'KeyZ', 'KeyY', 'KeyC', + 'Backspace', 'Delete' + ]; + + createGlobalListener( + 'inputs.number:keydown', + 'input.number-input__group-input[type="number"]', + (e) => { + const target = e.target; + const keyCode = e.which ?? e.keyCode ?? e.keyIdentifier; + if (!keyCode || (keyCode >= 8 && keyCode <= 46) || (e.ctrlKey && ctrlKeys.includes(e.code))) { + return true; + } + + if (e.shiftKey) { + e.preventDefault(); + return false; + } + + let datatype = target.getAttribute('data-type'); + if (!stringHasChars(datatype)) { + datatype = 'numeric'; + } else { + datatype = datatype.trim().toLowerCase(); + } + + let allowed = ( + (keyCode >= 48 && keyCode <= 57) || + (keyCode >= 96 && keyCode <= 105) || + keyCode === 173 + ); + + if (datatype !== 'int') { + allowed = allowed || keyCode === 190; + } + + if (!allowed) { + e.preventDefault(); + return true; + } + + return false; + } + ); + + createGlobalListener( + 'inputs.number:paste', + 'input.number-input__group-input[type="number"]', + (e) => { + e.preventDefault(); + + let paste = (e.clipboardData || window.clipboardData).getData('text'); + paste = paste.toUpperCase().trim().replace(/[^\-\d\.]/gmi, ''); + paste = Number(paste) + + if (!isNaN(paste)) { + e.target.value = paste + } else { + e.target.value = null; + } + + return false; + } + ); + + createGlobalListener( + 'inputs.number:click', + 'button.number-input__group-action', + (e) => { + e.preventDefault(); + + const trg = e.target; + const opr = trg.getAttribute('data-op').trim().toLowerCase(); + + const input = trg?.parentElement?.parentElement?.querySelector?.('input.number-input__group-input'); + if (!input) { + return true; + } + + let datatype = input.getAttribute('data-type'); + if (!stringHasChars(datatype)) { + datatype = 'numeric'; + } else { + datatype = datatype.trim().toLowerCase(); + } + + let step = input.getAttribute('data-step'); + step = stringHasChars(step) ? step.trim() : input.getAttribute('step')?.trim?.(); + + let value, diff; + switch (datatype) { + case 'int': { + diff = parseInt(step); + if (isNaN(diff)) { + diff = 1; + step = '1'; + } + + value = parseInt(input.value.trim()); + } break; + + case 'float': + case 'numeric': + case 'decimal': + case 'percentage': { + diff = parseFloat(step); + if (isNaN(diff)) { + diff = 0.1; + step = '0.1'; + } + + value = parseFloat(input.value.trim()); + } break; + + default: + value = null; + break; + } + + if (isNullOrUndefined(value) || isNaN(value)) { + value = 0; + } + + value += diff*(opr === 'decrement' ? -1 : 1); + if (datatype === 'int') { + value = Math.trunc(value); + } else { + const m = Math.pow(10, step.split('.')?.[1]?.length || 0); + value = Math.round(value * m) / m; + } + + input.value = value; + fireChangedEvent(input); + + return false; + } + ); +}); diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/components/popoverMenu.js b/CodeListLibrary_project/cll/static/js/clinicalcode/components/popoverMenu.js new file mode 100644 index 000000000..805285d17 --- /dev/null +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/components/popoverMenu.js @@ -0,0 +1,222 @@ +/** + * @desc closes all popover menus except for the specified target + * + * @param {Array<object>} managed an array of objects defining a set of popover menu item descriptors + * @param {HTMLElement|null} target optionally specify a popover menu to ignore + */ +export const closePopovers = (managed, target) => { + if (!Array.isArray(managed)) { + return; + } + + if (!target) { + for (let i = 0; i < managed.length; ++i) { + const group = managed[i]; + group.menu.setAttribute('hidden', false); + group.menu.setAttribute('aria-live', 'off'); + group.menu.setAttribute('aria-hidden', 'true'); + group.toggleBtn.setAttribute('aria-live', 'off'); + } + + return; + } + + for (let i = 0; i < managed.length; ++i) { + const group = managed[i]; + if (group.item === target || group.item.contains(target)) { + continue; + } + + group.menu.setAttribute('hidden', false); + group.menu.setAttribute('aria-live', 'off'); + group.menu.setAttribute('aria-hidden', 'true'); + group.toggleBtn.setAttribute('aria-live', 'off'); + } +}; + +/** + * @desc initialises & manages events relating to the `.popover-menu` class + * + * @param {object} param0 popover behaviour opts + * @param {HTMLElement} param0.parent optionally a HTMLElement in which to find popover menu item(s); defaults to `document` + * @param {Function|null} param0.callback optionally specify a callback to be called when a menu item is clicked; defaults to `null` + * @param {boolean} param0.observeMutations optionally specify whether to observe the addition & removal of popover menu items; defaults to `false` + * @param {boolean} param0.observeDescendants optionally specify whether to observe the descendant subtree when observing descendants; defaults to `false` + * + * @returns {Function|null} either (a) a cleanup disposable `function`; or (b) a `null` value if no valid menus were found + */ +export const managePopoverMenu = ({ + parent = document, + callback = null, + observeMutations = false, + observeDescendants = false, +} = {}) => { + const managed = []; + const popoverMenus = parent.querySelectorAll('[data-controlledby="popover-menu"]'); + for (let i = 0; i < popoverMenus.length; ++i) { + const item = popoverMenus[i]; + const menu = item.querySelector('[data-role="menu"]'); + const toggleBtn = item.querySelector('[data-role="toggle"]'); + if (isNullOrUndefined(menu) || isNullOrUndefined(toggleBtn)) { + continue; + } + + managed.push({ item, menu, toggleBtn }); + } + + if (!observeMutations && managed.length < 1) { + return null; + } + + // Listen to interaction(s) + const disposables = []; + const popoverDisposable = createGlobalListener( + 'popover.toggle:click', + '[data-controlledby="popover-menu"] [data-role="toggle"]', + (e) => { + const group = !!e.target ? managed.find(x => e.target === x.toggleBtn) : null; + if (!group) { + return; + } + + const { menu, toggleBtn } = group; + const disabled = !toggleBtn.disabled ? toggleBtn.getAttribute('disabled') === 'true' : false; + if (disabled) { + return; + } + + e.preventDefault(); + + const state = isVisibleObj(menu); + if (state) { + menu.setAttribute('hidden', false); + menu.setAttribute('aria-live', 'off'); + menu.setAttribute('aria-hidden', 'true'); + toggleBtn.setAttribute('aria-live', 'assertive'); + } else { + menu.setAttribute('aria-live', 'assertive'); + menu.setAttribute('aria-hidden', 'false'); + toggleBtn.setAttribute('aria-live', 'off'); + } + } + ); + disposables.push(popoverDisposable); + + // Initialise menu listener (if applicable) + if (typeof callback === 'function') { + const menuDisposable = createGlobalListener( + 'popover.toggle:click', + '[data-controlledby="popover-menu"] [data-role="menu"] [data-role="button"]', + (e) => { + const trg = e.target; + const group = !!trg ? managed.find(x => x.item.contains(trg)) : null; + if (!group) { + return; + } + + const { toggleBtn } = group; + const disabled = ( + (trg.disabled || toggleBtn.getAttribute('disabled') === 'true') || + (!toggleBtn.disabled ? toggleBtn.getAttribute('disabled') === 'true' : false) + ); + + if (disabled) { + return; + } + + callback(e, group, (trg) => closePopovers(managed, trg)); + } + ); + disposables.push(menuDisposable); + } + + // Blur / Closure interaction(s) + const blurHandler = (e) => { + const type = e.type; + switch (type) { + case 'click': { + const trg = e.target; + if (managed.some(x => x.item.contains(trg))) { + return; + } + + closePopovers(managed, null); + } break; + + case 'focusout': { + closePopovers(managed, e.relatedTarget); + } break; + + case 'visibilitychange': { + closePopovers(managed, null); + } break; + + default: + break; + } + }; + + window.addEventListener('click', blurHandler); + window.addEventListener('focusout', blurHandler); + document.addEventListener('visibilitychange', blurHandler); + + disposables.push(() => { + window.removeEventListener('click', blurHandler); + window.removeEventListener('focusout', blurHandler); + document.removeEventListener('visibilitychange', blurHandler); + }); + + // Observe addition & removal of menu item(s) + if (observeMutations) { + const observer = new MutationObserver((muts) => { + for (let i = 0; i < muts.length; ++i) { + const added = muts[i].addedNodes; + const removed = muts[i].removedNodes; + for (let j = 0; j < added.length; ++j) { + const node = added[j] + if (!isHtmlObject(node) || !node.matches('[data-controlledby="popover-menu"]')) { + continue; + } + + const index = managed.findIndex(x => x.item === node); + if (index < 0) { + const menu = node.querySelector('[data-role="menu"]'); + const toggleBtn = node.querySelector('[data-role="toggle"]'); + if (isNullOrUndefined(menu) || isNullOrUndefined(toggleBtn)) { + continue; + } + + managed.push({ item: node, menu: menu, toggleBtn: toggleBtn }); + } + } + + for (let j = 0; j < removed.length; ++j) { + const node = removed[j] + if (!node.matches('[data-controlledby="popover-menu"]')) { + continue; + } + + const index = managed.findIndex(x => x.item === node); + if (index >= 0) { + managed.splice(index, 1); + } + } + } + }); + + observer.observe(parent, { subtree: observeDescendants, childList: true }); + disposables.push(() => observer.disconnect()); + } + + return () => { + let disposable; + for (let i = disposables.length; i > 0; --i) { + disposable = disposables.pop(); + if (typeof disposable !== 'function') { + continue; + } + + disposable(); + } + }; +}; diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/components/singleSlider.js b/CodeListLibrary_project/cll/static/js/clinicalcode/components/singleSlider.js new file mode 100644 index 000000000..5a824c56c --- /dev/null +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/components/singleSlider.js @@ -0,0 +1,444 @@ +import { parseAsFieldType, resolveRangeOpts } from '../forms/entityCreator/utils.js'; + +/** + * A `number`, or `string`, describing a numeric object. + * @typedef {(number|string)} NumberLike + */ + +/** + * A list of known numeric types that can are managed by the {@link SingleSlider} instance + * @typedef {'int'|'float'|'decimal'|'numeric'|'percentage'} NumericFormat + */ + +/** + * Properties assoc. with slider inputs; used to constrain and/or to compute the slider value + * @typedef {Object} SliderProps + * @property {NumberLike} min the minimum value of the slider + * @property {NumberLike} max the maximum value of the slider + * @property {NumberLike} step specifies the slider increment + * @property {NumericFormat} type specifies how the numeric value will be formatted + */ + +/** + * Slider initialisation data + * @typedef {Object} SliderData + * @property {NumberLike} value specifies the initial slider value + * @property {SliderProps} properties specifies the properties of the slider, see {@link SliderProps} + */ + +/** + * @desc computes the properties assoc. with a slider + * + * @param {?SliderData} data an object containing the properties + * @param {?Array<string>} [allowedTypes] optionally specify an arr of allowed data types + * + * @returns {SliderData} the computed properties + */ +const computeParams = (data, allowedTypes = null) => { + if (!Array.isArray(allowedTypes)) { + allowedTypes = ['int', 'float', 'decimal', 'numeric', 'percentage']; + } + + const hasData = isObjectType(data) + data = hasData ? data : { }; + + let props = data?.properties; + if (!isObjectType(props)) { + props = { min: 0, max: 1, step: 0.1, type: 'float' }; + } + + let valueType = props?.type; + let hasValueType = stringHasChars(valueType); + if (hasValueType) { + valueType = valueType.toLowerCase(); + valueType = allowedTypes.includes(valueType) ? valueType : null; + hasValueType = typeof valueType === 'string'; + } + + if (!hasValueType) { + let example = props?.step; + if (!isSafeNumber(example)) { + example = [...Object.values(props)].concat([props?.value]).find(x => isSafeNumber(x)); + example = isSafeNumber(example) ? example : 0.1; + } + + example = tryParseNumber(example) + valueType = example.type; + + if (!allowedTypes.includes(valueType)) { + throw new Error('Failed to resolve valid type'); + } + hasValueType = true; + } + + let valueStep = typeof props?.step === 'string' ? Number(props?.step) : props.step; + let hasValidStep = isSafeNumber(valueStep); + + let min = typeof props.min === 'string' ? Number(props.min) : props.min; + let max = typeof props.max === 'string' ? Number(props.max) : props.max; + + const validMin = isSafeNumber(min); + const validMax = isSafeNumber(max); + + if (!hasValidStep) { + if (hasValueType) { + valueStep = valueType.includes('int') ? 1 : 0.1; + } else if (validMin || validMax) { + const precision = String(validMin ? min : max).split('.')?.[1]?.length || 0; + valueStep = Math.pow(10, -precision); + } else { + valueStep = 1; + } + } + + if (validMin && validMax) { + let tmp = Math.max(min, max); + min = Math.min(min, max); + max = tmp; + } else if (validMin) { + max = min + (hasValidStep ? valueStep : 1)*100; + } else if (validMax) { + min = max - (hasValidStep ? valueStep : 1)*100; + } else { + min = 0; + max = valueStep*100; + } + props.min = min; + props.max = max; + + if (!hasValueType) { + let precision = hasValidStep ? step : (isNullOrUndefined(min) ? min : 0); + precision = String(precision).split('.')?.[1]?.length || 0; + valueType = precision === 0 ? 'int' : 'float'; + } + + props.type = valueType; + props.step = valueStep; + + data.props = props; + data.value = clampNumber( + isSafeNumber(data?.value) ? data.value : 0, + props.min, + props.max + ); + + return data; +} + +/** + * A class that instantiates and manages a slider to select a single numeric value + * @class + * @alias module:SingleSlider + */ +export default class SingleSlider { + /** + * @desc describes the types that can be managed by this slider + * @type {Array<NumericFormat>} + * @static + * @constant + * @readonly + */ + static AllowedTypes = Object.freeze(['int', 'float', 'decimal', 'numeric', 'percentage']); + + /** + * @desc describes the HTMLElements = by way of their query selectors - assoc. with this instance + * @type {Record<string, string>} + * @static + * @constant + */ + static #Composition = { + input: '#value-input', + slider: '#slider-input', + progressBar: '#progress-bar', + }; + + /** + * @desc a Recordset containing a set of assoc. HTML frag templates + * @type {!Record<string, Record<string, HTMLElement>>} + * @private + */ + #templates = null; + + /** + * @param {HTMLElement|string} obj Either (a) a HTMLElement assoc. with this instance, or (b) a query selector string to locate said element + * @param {Partial<SliderData>} data Should describe the properties & validation assoc. with this component, see {@link SliderData} + */ + constructor(obj, data) { + let element; + if (isHtmlObject(obj)) { + element = obj; + } else if (typeof obj === 'string') { + if (!isValidSelector(obj)) { + throw new Error(`Query selector of Param<obj: ${obj}> is invalid`); + } + + element = document.querySelector(obj); + } + + if (!isHtmlObject(element)) { + throw new Error(`Failed to locate a valid assoc. HTMLElement with Params<obj: ${String(obj)}>`); + } + + /** + * @desc the ID attribute assoc. with this instance's `element` (`HTMLElement`), if applicable + * @type {?string} + * @default null + * @public + */ + this.id = element.getAttribute('id') ?? null; + + /** + * @desc the `HTMLElement` assoc. with this instance + * @type {!HTMLElement} + * @public + */ + this.element = element; + + /** + * @desc + * @type {!SliderData} + * @public + */ + this.data = computeParams(data, SingleSlider.AllowedTypes); + + /** + * @desc the current numeric value selected by the client + * @type {!number} + * @public + */ + this.value = this.data.value; + + /** + * @desc + * @type {!boolean} + * @default false + * @public + */ + this.dirty = false; + + /** + * @desc a Recordset containing the elements assoc. with this instance + * @type {!Record<string, HTMLElement>} + * @public + */ + this.elements = this.#initialiseElements(); + } + + + /************************************* + * * + * Getter * + * * + *************************************/ + /** + * getValue + * @desc gets the current value of the component + * @returns {any} the value selected via its options data + */ + getValue() { + return this.value; + } + + /** + * getElement + * @returns {node} the assoc. element + */ + getElement() { + return this.element; + } + + /** + * isDirty + * @returns {bool} returns the dirty state of this component + */ + isDirty() { + return this.dirty; + } + + + /************************************* + * * + * Setter * + * * + *************************************/ + /** + * makeDirty + * @desc informs the top-level parent that we're dirty + * and updates our internal dirty state + * @return {object} return this for chaining + */ + makeDirty() { + window.entityForm.makeDirty(); + this.dirty = true; + return this; + } + + /** + * setProgressBar + * @desc sets the current value of the component + * @param {any} value the value that should be selected from its list of options + */ + setProgressBar() { + const distance = this.data.properties.max - this.data.properties.min; + const position = (this.value / distance) * 100; + + this.elements.progressBar.style.background = ` + linear-gradient( + to right, + var(--color-accent-semi-transparent) 0%, + var(--color-accent-primary) 0%, + var(--color-accent-primary) ${position}%, + var(--color-accent-semi-transparent) ${position}% + ) + `; + } + + /** + * @desc updates this instance's value + * @note + * - will ignore non-safe numbers + * - will constrain the number as specified by this instance's props + * + * @param {NumberLike} val some number-like value + * + * @returns {boolean} reflecting whether this instance's value was updating or not + */ + setValue(val) { + const { min, max, type } = this.data.properties; + const parsed = parseAsFieldType({ validation: { type, properties: { min, max } } }, val) + if (!parsed || !parsed?.success) { + return false; + } + + val = parsed.value; + if (!isSafeNumber(val)) { + return false; + } + + const elements = this.elements; + elements.input.value = val; + elements.slider.value = val; + this.value = val; + this.setProgressBar(); + + if (val !== this.value) { + this.makeDirty(); + } + + return true; + } + + + /************************************* + * * + * Private * + * * + *************************************/ + #collectTemplates() { + let templates = this.#templates; + if (isObjectType(templates)) { + return templates; + } + + templates = { } + this.#templates = templates; + + const elem = this.element.parentElement; + elem.querySelectorAll('template[data-name]').forEach(v => { + let name = v.getAttribute('data-name'); + let view = v.getAttribute('data-view'); + if (!stringHasChars(view)) { + view = 'base'; + } + + let group = templates?.[view]; + if (!group) { + group = { }; + templates[view] = group; + } + + group[name] = v; + }); + + return templates; + } + + #initialiseElements() { + const elem = this.element; + const data = this.data; + const props = data.properties; + + const value = this.value; + const range = resolveRangeOpts(props.type, props); + + const fieldValidation = { validation: { type: props.type } }; + if (!isNullOrUndefined(range.values.min) && !isNullOrUndefined(range.values.max)) { + fieldValidation.validation.range = [range.values.min, range.values.max]; + } + + const templates = this.#collectTemplates(); + composeTemplate(templates.inputs.number, { + params: { + id: 'value-input', + ref: 'value', + type: props.type, + step: range.attr.step, + label: 'Value', + btnStep: range.values.step, + rangemin: range.attr.min, + rangemax: range.attr.max, + value: value, + placeholder: 'Number value...', + disabled: '', + mandatory: true, + }, + render: (obj) => { + obj = obj.shift(); + obj = elem.appendChild(obj); + + const input = obj.querySelector('input'); + input.value = value; + + input.addEventListener('change', (e) => { + let val = parseAsFieldType(fieldValidation, input.value); + if (!!val && val?.success) { + val = val.value; + } else { + val = this.value; + } + + this.setValue(val); + }); + }, + }); + + const tree = { }; + for (const [name, selector] of Object.entries(SingleSlider.#Composition)) { + const obj = elem.querySelector(selector); + if (!isHtmlObject(obj)) { + throw new Error(`Failed to find assoc. Element<name: ${name}, sel: ${selector}> for Obj...\n${String(elem)}`); + } + tree[name] = obj; + } + + tree.slider.min = props.min; + tree.slider.max = props.max; + tree.slider.step = props.step; + tree.slider.value = props.min; + + tree.slider.addEventListener('input', (e) => { + let trg = e.target; + let val = parseAsFieldType(fieldValidation, trg.value); + if (!!val && val?.success) { + val = val.value; + } else { + val = this.value; + } + + this.setValue(val); + }); + + this.setValue(this.value); + return tree; + } +}; diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/components/tagify.js b/CodeListLibrary_project/cll/static/js/clinicalcode/components/tagify.js index 63e4ab8be..d48f20390 100644 --- a/CodeListLibrary_project/cll/static/js/clinicalcode/components/tagify.js +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/components/tagify.js @@ -12,20 +12,6 @@ const TAGIFY__DELAY = 1; */ const TAGIFY__TIMEOUT = 10; -/** - * TAGIFY__KEYCODES - * @desc Keycodes used to navigate through the tag dropdown, and to add/remove tags - */ -const TAGIFY__KEYCODES = { - // Add tag - 'ENTER': 13, - // Remove tag - 'BACK': 8, - // Navigate tag dropdown - 'DOWN': 40, - 'UP': 38, -}; - /** * TAGIFY__TAG_OPTIONS * @desc Available options for the tagify component. @@ -34,18 +20,25 @@ const TAGIFY__KEYCODES = { */ const TAGIFY__TAG_OPTIONS = { // A predefined list of tags that can be used for autocomplete, or to control the input provided by the user - 'items': [ ], + items: [ ], // Whether to use the value or the name keys for autocomplete and tag selected components - 'useValue': false, + useValue: false, // Whether to perform autocomplete from a predefined list of items - 'autocomplete': false, + autocomplete: false, // Whether to allow users to input duplicate tags - 'allowDuplicates': false, - // Determines whether the user is restricted to the items within the predefined items list, or can input their own - 'restricted': false, + allowDuplicates: false, // Determines whether to show tooltips // [!] Note: This option requires tooltipFactory.js as a dependency - 'showTooltips': true, + showTooltips: true, + // Component behaviour + behaviour: { + // Determines whether the user is restricted to the items within the predefined items list, or can input their own + freeform: false, + // Describes how to format the tag when displaying it + format: { + component: '{name}', + }, + }, }; /** @@ -58,32 +51,37 @@ const TAGIFY__TAG_OPTIONS = { * [!] Note: After the addition or removal of a tag, a custom event is dispatched to * a the Tagify instance's element through the 'TagChanged' hook * - * e.g. - ```js - import Tagify from '../components/tagify.js'; - - const tags = [ - { - name: 'SomeTagName', - value: 'SomeTagValue', - }, - { - name: 'SomeTagName', - value: 'SomeTagValue', - } - ]; - - const tagComponent = new Tagify('phenotype-tags', { - 'autocomplete': true, - 'useValue': false, - 'allowDuplicates': false, - 'restricted': true, - 'items': tags, - }); - ``` + * @example + * import Tagify from '../components/tagify.js'; + * + * const tags = [ + * { + * name: 'SomeTagName', + * value: 'SomeTagValue', + * }, + * { + * name: 'SomeTagName', + * value: 'SomeTagValue', + * } + * ]; + * + * const tagComponent = new Tagify('phenotype-tags', { + * items: tags, + * useValue: false, + * restricted: true, + * autocomplete: true, + * allowDuplicates: false, + * }); * */ export default class Tagify { + /** + * @desc + * @type {Array<Function>} + * @private + */ + #disposables = []; + constructor(obj, options, phenotype) { this.uuid = generateUUID(); @@ -102,9 +100,9 @@ export default class Tagify { this.fieldName = this.element.getAttribute('data-field'); } - this.isBackspace = false; - this.tags = [ ]; + this.tags = []; this.currentFocus = -1; + this.isBackspaceState = false; this.#initialise(options, phenotype); } @@ -117,12 +115,35 @@ export default class Tagify { /** * getActiveTags * @desc method to retrieve current tag data - * @returns {list} a list of objects describing active tags + * @returns {Array<Object>} a list of objects describing active tags */ getActiveTags() { return this.tags; } + /** + * getDataValue + * @desc method to retrieve current tag data array + * @returns {Array} a list of data value(s) + */ + getDataValue() { + if (!this.options?.behaviour?.freeform) { + return this.tags.reduce((res, x) => { + if (!isNullOrUndefined(x?.value)) { + res.push(x.value); + } + + return res; + }, []); + } + + return this.tags.reduce((res, x) => { + res.push({ name: x?.name, value: typeof x?.value === 'number' ? x.value : null }); + return res; + }, []); + } + + /************************************* * * * Setter * @@ -136,7 +157,14 @@ export default class Tagify { * @return {object} Returns a tag object */ addTag(name, value) { - if (this.options.restricted) { + if (!stringHasChars(name)) { + return false; + } + + name = strictSanitiseString(name); + value = typeof value === 'string' ? strictSanitiseString(value) : value; + + if (!this.options?.behaviour?.freeform) { const index = this.options.items.map(e => e.name.toLocaleLowerCase()).indexOf(name.toLocaleLowerCase()); if (index < 0) { return false; @@ -154,7 +182,7 @@ export default class Tagify { if (elem) { this.#wobbleElement(elem); } - + return false; } } @@ -171,7 +199,7 @@ export default class Tagify { return tag; } - + /** * removeTag * @desc removes a tag from the current list of tags @@ -211,6 +239,24 @@ export default class Tagify { delete this; } + /** + * dispose + * @desc disposes events & objs assoc. with this cls + */ + dispose() { + let disposable; + for (let i = this.#disposables.length; i > 0; i--) { + disposable = this.#disposables.pop(); + if (typeof disposable !== 'function') { + continue; + } + + disposable(); + } + + this.destroy(); + } + /** * getElement * @desc returns this instance's target element, which can be used to determine whether a tag @@ -232,15 +278,32 @@ export default class Tagify { * @param {event} e the associated event */ #onClick(e) { + const trg = document.activeElement; + if (isNullOrUndefined(this.container) || isNullOrUndefined(trg) || !this.container.contains(trg)) { + return; + } e.preventDefault(); - if (e.target.className == 'tag__remove') { - this.removeTag(tryGetRootElement(e.target, 'tag')); + if (trg.className == 'tag__remove') { + this.removeTag(tryGetRootElement(trg, '.tag')); } - this.field.focus(); } + /** + * onFocusIn + * @desc when the input box is focused by the client + * + * @param {event} e the associated event + */ + #onFocusIn(e) { + this.#deselectHighlighted(); + + if (this.options.autocomplete) { + this.#tryPopulateAutocomplete(); + } + } + /** * onFocusLost * @desc when the input box loses focus @@ -248,14 +311,13 @@ export default class Tagify { */ #onFocusLost(e) { this.#deselectHighlighted(); - - const target = e.relatedTarget; - if (target && target.classList.contains('autocomplete-item')) { - const name = target.getAttribute('data-name'); + + const { relatedTarget } = e; + if (!!relatedTarget && this.autocomplete.contains(relatedTarget) && relatedTarget.classList.contains('autocomplete-item')) { + const name = relatedTarget.getAttribute('data-name'); this.addTag(name); } this.field.value = ''; - this.#clearAutocomplete(); this.autocomplete.classList.remove('show'); } @@ -263,6 +325,7 @@ export default class Tagify { /** * onKeyDown * @desc handles events assoc. with the input box receiving a key down event + * * @param {event} e the associated event */ #onKeyDown(e) { @@ -270,82 +333,84 @@ export default class Tagify { const target = e.target; if (e.target.id == this.uuid) { let name = target.value.trim(); - const code = e.which || e.keyCode; + + const code = e.code; switch (code) { - case TAGIFY__KEYCODES.ENTER: { + case 'Enter': + case 'NumpadEnter': { e.preventDefault(); e.stopPropagation(); if (this.currentFocus >= 0) { name = this.#getFocusedName(); } - + if (name === '') { this.#deselectHighlighted(); break; } - + target.blur(); target.value = ''; - + this.addTag(name); this.#clearAutocomplete(true); - + if (this.timer) clearTimeout(this.timer); - + this.timer = setTimeout(() => { target.focus(); }, TAGIFY__TIMEOUT); } break; - - case TAGIFY__KEYCODES.BACK: { + + case 'Backspace': { if (name === '') { this.#clearAutocomplete(true); - - if (!this.isBackspace) { + + if (!this.isBackspaceState) { this.#popTag(); } - } else { - if (this.options.autocomplete) { - this.#tryPopulateAutocomplete(name); - } + + break; + } + + if (this.options.autocomplete) { + this.#tryPopulateAutocomplete(name); } } break; - - case TAGIFY__KEYCODES.UP: - case TAGIFY__KEYCODES.DOWN: { + + case 'ArrowUp': + case 'ArrowDown': { if (this.autocomplete.classList.contains('show')) { e.preventDefault(); - this.currentFocus += (code == TAGIFY__KEYCODES.UP ? -1 : 1); + this.currentFocus += (code === 'ArrowUp' ? -1 : 1); this.#focusAutocompleteElement(); } } break; - + + case 'Tab': + this.#deselectHighlighted(); + this.#clearAutocomplete(true); + if (!e.shiftKey) { + focusNextElement(this.field, 'next'); + } + break; + default: { this.#deselectHighlighted(); - + if (this.options.autocomplete) { this.#tryPopulateAutocomplete(name); } - } break; } } - this.isBackspace = false; + this.isBackspaceState = false; }, TAGIFY__DELAY) } - /** - * onKeyUp - * @desc handles events assoc. with the input box receiving a key up event - * @param {event} e the associated event - */ - #onKeyUp(e) { - - } - /************************************* * * * Private * @@ -354,27 +419,28 @@ export default class Tagify { /** * initialise * @desc responsible for the main initialisation & render of this component + * * @param {dict} options the option parameter * @param {dict|*} phenotype optional initialisation template */ async #initialise(options, phenotype) { this.container = createElement('div', { - 'className': 'tags-root-container', + className: 'tags-root-container', }); this.tagbox = createElement('div', { - 'className': 'tags-container', + className: 'tags-container', }); this.autocomplete = createElement('div', { - 'className': 'tags-autocomplete-container filter-scrollbar', + className: 'tags-autocomplete-container filter-scrollbar', }); this.field = createElement('input', { - 'type': 'text', - 'className': 'tags-input-field', - 'id': this.uuid, - 'placeholder': this.element.placeholder || '', + id: this.uuid, + type: 'text', + className: 'tags-input-field', + placeholder: this.element.placeholder || '', }); this.tagbox.appendChild(this.field); @@ -386,11 +452,16 @@ export default class Tagify { this.#buildOptions(options || { }, phenotype) .catch(e => console.error(e)) .finally(() => { + let callback; if (this.options?.onLoad && this.options.onLoad instanceof Function) { - this.options.onLoad(this); + callback = this.options.onLoad(this); } this.#bindEvents(); + + if (typeof callback === 'function') { + callback(this); + } }); } @@ -400,13 +471,15 @@ export default class Tagify { * through the input box using the backspace key */ #popTag() { - if (this.isBackspace) + if (this.isBackspaceState) { return; - - this.isBackspace = true; - if (this.tags.length <= 0) + } + + this.isBackspaceState = true; + if (this.tags.length <= 0) { return; - + } + const index = this.tags.length - 1; const tag = this.tags[index]; if (!tag.element.classList.contains('tag__highlighted')) { @@ -423,10 +496,16 @@ export default class Tagify { * @desc binds the associated events to the rendered components */ #bindEvents() { - this.container.addEventListener('click', this.#onClick.bind(this), false); + this.field.addEventListener('focusin', this.#onFocusIn.bind(this), false); this.tagbox.addEventListener('focusout', this.#onFocusLost.bind(this), false); this.tagbox.addEventListener('keydown', this.#onKeyDown.bind(this), false); - this.tagbox.addEventListener('keyup', this.#onKeyUp.bind(this), false); + + const clickHnd = this.#onClick.bind(this); + document.addEventListener('click', clickHnd); + + this.#disposables.push(() => { + document.removeEventListener('click', clickHnd) + }); } /** @@ -445,7 +524,8 @@ export default class Tagify { * @param {dict} phenotype the initialisation template */ async #buildOptions(options, phenotype) { - this.options = mergeObjects(options, TAGIFY__TAG_OPTIONS); + options = isRecordType(options) ? options : { }; + this.options = mergeObjects(options, TAGIFY__TAG_OPTIONS, true, true); const hasFieldName = !isStringEmpty(this.fieldName); const needsInitialiser = !isNullOrUndefined(phenotype) && (isNullOrUndefined(options?.items) || options?.items.length < 1); @@ -454,17 +534,44 @@ export default class Tagify { parameter: this.fieldName, template: phenotype.template.id, }); - - const response = await fetch( + + let hasWarnedClient = false; + const response = await fetchWithCtrl( `${getCurrentURL()}?` + parameters, { method: 'GET', headers: { 'X-Target': 'get_options', 'X-Requested-With': 'XMLHttpRequest', - 'Cache-Control': 'max-age=28800', - 'Pragma': 'max-age=28800', - } + 'Cache-Control': 'max-age=300', + 'Pragma': 'max-age=300', + }, + }, + { + retries: 5, + backoff: 100, + onRetry: (retryCount, remainingTries) => { + if (!hasWarnedClient && retryCount >= 2) { + hasWarnedClient = true; + window.ToastFactory.push({ + type: 'danger', + message: `We're struggling to connect to the server, if this persists you might not be able to save the form.`, + duration: 3500, + }); + } + }, + onError: (_err, _retryCount, remainingTries) => { + if (hasWarnedClient && remainingTries < 1) { + window.ToastFactory.push({ + type: 'danger', + message: `We've not been able to connect to the server, please refresh the page and try again.`, + duration: 7000, + }); + } + + return true; + }, + beforeAccept: (response, _retryCount, _remainingTries) => response.ok, } ); @@ -477,6 +584,14 @@ export default class Tagify { throw new Error(`Expected tagify init data to be an object with a result array, got ${dataset}`); } + if (hasWarnedClient) { + window.ToastFactory.push({ + type: 'success', + message: `We have reestablished a connection with the server.`, + duration: 2000, + }); + } + this.options.items = dataset.result; } @@ -497,6 +612,37 @@ export default class Tagify { return ''; } + /** + * tryFmtName + * @desc attempts to format the tag item per the template field info + * + * @param {string|object} data the data assoc. with the tag + * + * @return {string} element name + */ + #tryFmtName(data) { + let format = this.options?.behaviour?.format; + format = isRecordType(format) && stringHasChars(format?.component) + ? format.component + : '{name}'; + + let params; + if (isRecordType(data)) { + params = data; + } else { + params = { name: data }; + } + + let res; + try { + res = pyFormat(format, params); + } catch (e) { + res = stringHasChars(params?.name) ? params.name : 'TAG'; + } + + return res; + } + /************************************* * * * Render * @@ -510,25 +656,34 @@ export default class Tagify { * @returns {node} the tag */ #createTag(name, value) { + let label = this.options.items.find(x => x.name.toLocaleLowerCase() === name.toLocaleLowerCase()); + if (!isNullOrUndefined(label)) { + label = this.#tryFmtName(label); + } else { + label = name; + } + const tag = createElement('div', { - 'className': 'tag', - 'data-value': value, - 'innerHTML': `<span class="tag__name">${name}</span><button class="tag__remove" aria-label="Remove Tag ${name}">×</button>` + data: { value: value }, + className: 'tag', + innerHTML: { + src: `<span class="tag__name">${label}</span><button class="tag__remove" aria-label="Remove Item">×</button>`, + noSanitise: true, + }, }); this.tagbox.insertBefore(tag, this.field); this.tags.push({ - 'element': tag, - 'name': name, - 'value': value, + name: name, + value: value, + element: tag, }); if (this.options.showTooltips) { - window.TooltipFactory.addTooltip(tag.querySelector('button'), 'Remove Tag', 'left'); + window.TooltipFactory.addElement(tag.querySelector('button'), 'Remove Item', 'up'); } this.#updateElement(); - return tag; } @@ -550,14 +705,11 @@ export default class Tagify { */ #wobbleElement(elem) { const method = getTransitionMethod(); - if (typeof method === 'undefined') + if (typeof method === 'undefined') { return; - - elem.addEventListener(method, function handle(e) { - elem.classList.remove('tag__wobble'); - elem.removeEventListener(e.type, handle, false); - }, false); + } + elem.addEventListener(method, (e) => elem.classList.remove('tag__wobble'), { once: true }); elem.classList.add('tag__wobble'); } @@ -588,14 +740,14 @@ export default class Tagify { children[i].classList.remove('autocomplete-item__highlighted'); } } - + /** * focusAutoCompleteElement * @desc focuses an element in the autocomplete list by selecting the element by its index (based on sel id) */ #focusAutocompleteElement() { this.#popFocusedElement(); - + const children = this.autocomplete.children; const childLength = children.length; this.currentFocus = this.currentFocus < 0 ? (childLength - 1) : (this.currentFocus >= childLength ? 0 : this.currentFocus); @@ -604,7 +756,9 @@ export default class Tagify { const element = children[this.currentFocus]; element.classList.add('autocomplete-item__highlighted'); - this.autocomplete.scrollTop = element.offsetTop; + if (!isScrolledIntoView(element, this.autocomplete, element.offsetHeight*0.5)) { + this.autocomplete.scrollTop = element.offsetTop; + } } } @@ -616,19 +770,21 @@ export default class Tagify { #generateAutocompleteElements(results) { for (let i = 0; i < results.length; ++i) { const data = results[i]; - const item = createElement('button', { - 'className': 'autocomplete-item', - 'data-value': data.value, - 'data-name': data.name, - }); - - const text = createElement('span', { - 'className': 'autocomplete-item__title', - 'innerHTML': data.name, + createElement('a', { + class: 'autocomplete-item', + href: '#', + dataset: { + name: data.name, + value: data.value, + }, + childNodes: [ + createElement('span', { + className: 'autocomplete-item__title', + innerText: this.#tryFmtName(data), + }) + ], + parent: this.autocomplete }); - - item.appendChild(text); - this.autocomplete.appendChild(item); } } @@ -636,40 +792,46 @@ export default class Tagify { * tryPopulateAutocomplete * @desc tries to determine which elements need to be rendered through fuzzymatching, * and then renders the autocomplete elements - * @param {string} value the search term to consider + * + * @param {string} [value=''] the search term to consider + * * @returns */ - #tryPopulateAutocomplete(value) { - if (value === '' || this.options.items.length <= 0) { + #tryPopulateAutocomplete(value = '') { + if (this.options.items.length < 1) { this.#clearAutocomplete(true); return; } - if (!this.haystack) { - this.haystack = this.options.items.map(e => e.name); - } - - let results = FuzzyQuery.Search(this.haystack, value, FuzzyQuery.Results.SORT, FuzzyQuery.Transformers.IgnoreCase); - results.sort((a, b) => { - if (a.score === b.score) { - return 0; - } else if (a.score > b.score) { - return 1; - } else if (a.score < b.score) { - return -1; + let results; + if (stringHasChars(value)) { + if (!this.haystack) { + this.haystack = this.options.items.map(e => e.name); } - }); - results = results.map((e) => { - const item = this.options.items.find(x => x.name.toLocaleLowerCase() === e.item.toLocaleLowerCase()); - return item; - }); - + results = FuzzyQuery.Search(this.haystack, value, FuzzyQuery.Results.SORT, FuzzyQuery.Transformers.IgnoreCase); + results.sort((a, b) => { + if (a.score === b.score) { + return 0; + } else if (a.score > b.score) { + return 1; + } else if (a.score < b.score) { + return -1; + } + }); + + results = results.map((e) => { + const item = this.options.items.find(x => x.name.toLocaleLowerCase() === e.item.toLocaleLowerCase()); + return item; + }); + } else { + results = this.options.items.slice(0, this.options.items.length); + } + if (results.length > 0) { this.#clearAutocomplete(false); this.autocomplete.classList.add('show'); this.#generateAutocompleteElements(results); - return; } diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/components/virtualisedList/debouncedTask.js b/CodeListLibrary_project/cll/static/js/clinicalcode/components/tasks/debouncedTask.js similarity index 65% rename from CodeListLibrary_project/cll/static/js/clinicalcode/components/virtualisedList/debouncedTask.js rename to CodeListLibrary_project/cll/static/js/clinicalcode/components/tasks/debouncedTask.js index f9190451e..c17d5ef25 100644 --- a/CodeListLibrary_project/cll/static/js/clinicalcode/components/virtualisedList/debouncedTask.js +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/components/tasks/debouncedTask.js @@ -1,7 +1,8 @@ /** - * @class DebouncedTask - * @desc Extensible fn to throttle / debounce method calls + * Extensible fn class to throttle / debounce method calls * + * @class + * @constructor */ export default class DebouncedTask extends Function { handle = null; @@ -11,15 +12,37 @@ export default class DebouncedTask extends Function { result = null; params = { ctx: undefined, args: undefined }; lastCalled = 0; + resetSubsequent = false; - constructor(task, delay) { + /** + * @param {Function} task some function to be called after the specified delay + * @param {number} [delay=100] optionally specify the debounce duration, i.e. the delay time, in milliseconds, before calling the fn + * @param {boolean} [resetSubsequent=false] optionally specify whether to reset the timeout after each subsequent call, otherwise the call time is dependent on when the initial call was made; defaults to `false` + */ + constructor(task, delay = 100, resetSubsequent = false) { assert(typeof(task) === 'function', `Expected fn for task but got "${typeof(task)}"`); super(); + this.task = task; this.delay = (typeof(delay) === 'number' && !isNaN(delay)) ? Math.max(0, delay) : this.delay; + this.resetSubsequent = !!resetSubsequent; + + const res = new Proxy(this, { + get: (target, key) => { + if (target?.[key]) { + return target[key]; + } else { + return target.__inherit__[key]; + } + }, + apply: (target, thisArg, args) => { + return target?.__calL__.apply(target, args); + } + }); + res.__inherit__ = DebouncedTask; - return Object.setPrototypeOf(this.__proto__.__call__.bind(this), new.target.prototype); + return res; } @@ -65,9 +88,9 @@ export default class DebouncedTask extends Function { if (isNullOrUndefined(hnd)) { return; } + this.handle = null; clearTimeout(hnd); - this.handle = null; return this; } @@ -88,23 +111,25 @@ export default class DebouncedTask extends Function { const delay = this.delay; const elapsed = now - this.lastCalled; - if (elapsed < delay && elapsed > 0) { - let hnd = this?.handle; - if (hnd) { - clearTimeout(hnd); - } + let hnd = this?.handle; + if (!!hnd) { + this.handle = null; + clearTimeout(hnd); + } - this.handle = setTimeout(() => this.deferredCall(true), elapsed - delay); + if (elapsed < delay && elapsed > 0) { + this.handle = setTimeout(() => { this.deferredCall(true) }, elapsed - delay); return; } - this.handle = null; const { ctx, args = undefined } = this.params; this.params.ctx = undefined; this.params.args = undefined; this.result = this.task.apply(ctx, args); - this.lastCalled = now; + if (!this.resetSubsequent) { + this.lastCalled = now; + } } @@ -125,7 +150,7 @@ export default class DebouncedTask extends Function { * @returns the last result (if any) * */ - __call__(...args) { + __calL__(...args) { const { ctx } = this.params; if (ctx && this !== ctx) { throw new Error('[DebouncedTask] Context mismatch'); @@ -134,8 +159,16 @@ export default class DebouncedTask extends Function { this.params.ctx = this; this.params.args = args; + const now = performance.now(); + if (this.resetSubsequent) { + this.clear(); + this.lastCalled = now; + } + if (isNullOrUndefined(this.handle)) { - this.handle = setTimeout(this.deferredCall.bind(this), this.delay); + this.handle = setTimeout(() => { + this.deferredCall.bind(this)(); + }, this.delay); } return this.result; diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/components/virtualisedList/deferredThreadGroup.js b/CodeListLibrary_project/cll/static/js/clinicalcode/components/tasks/deferredThreadGroup.js similarity index 92% rename from CodeListLibrary_project/cll/static/js/clinicalcode/components/virtualisedList/deferredThreadGroup.js rename to CodeListLibrary_project/cll/static/js/clinicalcode/components/tasks/deferredThreadGroup.js index 3043f7ae6..16b0d5dea 100644 --- a/CodeListLibrary_project/cll/static/js/clinicalcode/components/virtualisedList/deferredThreadGroup.js +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/components/tasks/deferredThreadGroup.js @@ -1,9 +1,9 @@ /** - * @class DeferredThreadGroup - * @desc Deferred group of threads, similar impl. to requestAnimationFrame - * - * See ref @ https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame + * Class describing a deferred group of threads, similar impl. to requestAnimationFrame + * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame|RAF} * + * @class + * @constructor */ export default class DeferredThreadGroup { #iHnd = 0; @@ -57,13 +57,13 @@ export default class DeferredThreadGroup { this.#silentlyThrow(e); } } - }, Math.round(timeout)) + }, Math.round(timeout)); } const id = ++this.#iHnd; this.#queue.push({ handle: id, callback: callback }); - return id + return id; } /** diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/components/templateDetailRenderer.js b/CodeListLibrary_project/cll/static/js/clinicalcode/components/templateDetailRenderer.js index ebc16f307..00870dfc4 100644 --- a/CodeListLibrary_project/cll/static/js/clinicalcode/components/templateDetailRenderer.js +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/components/templateDetailRenderer.js @@ -43,7 +43,10 @@ const renderTemplateDetail = (parent, data) => { const container = createElement('div', { 'className': 'template-detail__container', - 'innerHTML': templateDetail + 'innerHTML': { + src: templateDetail, + noSanitise: true, + } }); parent.appendChild(container); diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/components/toastNotification.js b/CodeListLibrary_project/cll/static/js/clinicalcode/components/toastNotification.js index 5e571d5a9..fa704f028 100644 --- a/CodeListLibrary_project/cll/static/js/clinicalcode/components/toastNotification.js +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/components/toastNotification.js @@ -7,16 +7,45 @@ import TouchHandler from './touchHandler.js'; * * The factory is accessible through the window object via window.ToastFactory * - * e.g. - ```js - window.ToastFactory.push({ - type: 'warning', - message: 'Some warning message', - duration: 2000, // 2s - }); - ``` + * @example + * window.ToastFactory.push({ + * type: 'warning', + * message: 'Some warning message', + * duration: 2000, // 2s + * }); + * */ class ToastNotificationFactory { + static Types = { + // Display success + success: 'success', + + // Display caution + warn: 'warning', + caution: 'warning', + warning: 'warning', + + // Display error + error: 'danger', + danger: 'danger', + + // Display info + info: 'information', + information: 'information', + + // Palette-based + primary: 'primary', + secondary: 'secondary', + tertiary: 'tertiary', + + // Misc. + debug: 'anchor', + anchor: 'anchor', + + bubble: 'highlight', + highlight: 'bubble', + }; + constructor() { this.#createContainer(); } @@ -101,6 +130,15 @@ class ToastNotificationFactory { * @returns {node} the toast notification */ #createToast(type, content, duration) { + if (!stringHasChars(type)) { + type = 'information'; + } + + type = type.toLowerCase(); + type = ToastNotificationFactory.Types?.[type] + ? ToastNotificationFactory.Types[type] + : 'information' + const toast = createElement('div', { 'className': `toast toast--${type}`, 'role': 'alert', diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/components/tooltipFactory.js b/CodeListLibrary_project/cll/static/js/clinicalcode/components/tooltipFactory.js index 23924655e..ef834a6bc 100644 --- a/CodeListLibrary_project/cll/static/js/clinicalcode/components/tooltipFactory.js +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/components/tooltipFactory.js @@ -7,7 +7,7 @@ * * e.g. ```js - window.TooltipFactory.addTooltip( + window.TooltipFactory.addElement( // the element to observe document.querySelector('.some-element'), @@ -46,15 +46,36 @@ class TooltipFactory { * * *************************************/ /** - * addTooltip + * addElement * @desc adds a tooltip to an element - * @param {node} elem the element we wish to observe - * @param {string} tip the tooltip to present to the client - * @param {string} direction the direction of the tooltip when active [right, left, up, down] + * @param {node} elem the element we wish to observe + * @param {string} tip optionally specify the tooltip to present to the client; defaults to data attribute of name `data-tipcontent` if not specified + * @param {string} direction optionally specify the direction of the tooltip when active [right, left, up, down]; defaults to `data-tipdirection` if not specified, or `right` if that fails */ - addTooltip(elem, tip, direction) { + addElement(elem, tip = null, direction = null) { + let trg = elem.getAttribute('data-tiptarget'); + if (trg === 'parent') { + trg = elem.parentNode; + } else { + trg = elem; + } + const uuid = generateUUID(); - elem.setAttribute('data-tooltip', uuid); + trg.setAttribute('data-tooltip', uuid); + + if (typeof tip !== 'string') { + tip = elem.getAttribute('data-tipcontent'); + } + + tip = strictSanitiseString(tip); + if (!stringHasChars(tip)) { + return; + } + + if (typeof direction !== 'string') { + direction = elem.getAttribute('data-tipdirection'); + direction = stringHasChars(direction) ? direction : 'right'; + } let tooltip = this.#createTooltip(tip, direction); tooltip = this.element.appendChild(tooltip); @@ -62,56 +83,92 @@ class TooltipFactory { this.#tooltips[uuid] = tooltip; + const showTooltip = () => { + if (isNullOrUndefined(tooltip)) { + return; + } + + tooltip.style.setProperty('display', 'block'); + + let span = tooltip.querySelector('span'); + let height = window.getComputedStyle(span, ':after').getPropertyValue('height'); + height = height.matchAll(/(\d+)px/gm); + height = Array.from(height, x => parseInt(x[1])) + .filter(x => !isNaN(x)) + .shift(); + height = height || 0; + + const rect = trg.getBoundingClientRect(); + switch (direction) { + case 'up': { + tooltip.style.left = `${rect.left + rect.width / 2}px`; + tooltip.style.top = `${rect.top + height / 2 + height / 4}px`; + } break; + + case 'down': { + tooltip.style.left = `${rect.left + rect.width / 2}px`; + tooltip.style.top = `${rect.top + rect.height / 2 - height / 4}px`; + } break; + + case 'right': { + tooltip.style.left = `${rect.left + rect.width}px`; + tooltip.style.top = `${rect.top + rect.height / 2 - height / 4}px`; + } break; + + case 'left': { + tooltip.style.left = `${rect.left}px`; + tooltip.style.top = `${rect.top + rect.height / 2 - height / 4}px`; + } break; + + default: { + tooltip.style.left = `${rect.left + rect.width / 2}px`; + tooltip.style.top = `${rect.top + rect.height - height / 4}px`; + } break; + } + }; + + const hideTooltip = () => { + if (!isNullOrUndefined(tooltip)) { + window.removeEventListener('focusout', blurTooltip, { once: true }); + window.removeEventListener('pointermove', blurTooltip, { once: true }); + window.removeEventListener('resize', blurTooltip, { once: true }); + + tooltip.style.setProperty('display', 'none'); + } + }; + + const blurTooltip = (e) => { + const type = (!!e && typeof e === 'object' && 'type' in e) ? e.type : null; + if (type !== 'focusout' && document.activeElement === this.focusElement) { + document.activeElement.blur(); + } + + hideTooltip(); + }; + const methods = { - enter: (e) => { - if (isNullOrUndefined(tooltip)) { + longpress: (e) => { + if (e.pointerType !== 'touch') { return; } - tooltip.style.setProperty('display', 'block'); - - let span = tooltip.querySelector('span'); - let height = window.getComputedStyle(span, ':after').getPropertyValue('height'); - height = height.matchAll(/(\d+)px/gm); - height = Array.from(height, x => parseInt(x[1])) - .filter(x => !isNaN(x)) - .shift(); - height = height || 0; - - const rect = elem.getBoundingClientRect(); - switch (direction) { - case 'up': { - tooltip.style.left = `${rect.left + rect.width / 2}px`; - tooltip.style.top = `${rect.top + height / 2}px`; - } break; - case 'down': { - tooltip.style.left = `${rect.left + rect.width / 2}px`; - tooltip.style.top = `${rect.top + rect.height - height / 4}px`; - } break; - case 'right': { - tooltip.style.left = `${rect.left + rect.width}px`; - tooltip.style.top = `${rect.top + rect.height - height / 4}px`; - } break; - case 'left': { - tooltip.style.left = `${rect.left}px`; - tooltip.style.top = `${rect.top + rect.height - height / 4}px`; - } break; - default: { - tooltip.style.left = `${rect.left + rect.width / 2}px`; - tooltip.style.top = `${rect.top + rect.height - height / 4}px`; - } break; - } - }, - leave: (e) => { - if (isNullOrUndefined(tooltip)) { - return; - } - tooltip.style.setProperty('display', 'none'); + + e.preventDefault(); + + showTooltip(); + + this.focusElement.focus(); + window.addEventListener('resize', blurTooltip, { once: true }); + window.addEventListener('focusout', blurTooltip, { once: true }); + window.addEventListener('pointerdown', blurTooltip, { once: true }); }, + enter: () => showTooltip(), + leave: () => hideTooltip(), }; this.#handlers[uuid] = methods; - elem.addEventListener('mouseenter', methods.enter); - elem.addEventListener('mouseleave', methods.leave); + trg.addEventListener('mouseenter', methods.enter); + trg.addEventListener('mouseleave', methods.leave); + trg.addEventListener('contextmenu', methods.longpress); } /** @@ -139,6 +196,7 @@ class TooltipFactory { if (!isNullOrUndefined(methods)) { elem.removeEventListener('mouseenter', methods.enter); elem.removeEventListener('mouseleave', methods.leave); + elem.removeEventListener('contextmenu', methods.longpress); } this.#handlers[uuid] = null; @@ -159,6 +217,21 @@ class TooltipFactory { className: 'tooltip-container', }); + const focusElem = document.createElement('input'); + focusElem.setAttribute('id', 'ctx-focusable'); + focusElem.setAttribute('type', 'text'); + focusElem.setAttribute('aria-live', 'off'); + focusElem.setAttribute('aria-hidden', 'true'); + focusElem.style.display = 'block'; + focusElem.style.position = 'absolute'; + focusElem.style.width = '0'; + focusElem.style.height = '0'; + focusElem.style.opacity = 0; + focusElem.style.overflow = 'hidden'; + + this.element.appendChild(focusElem); + this.focusElement = focusElem; + document.body.prepend(this.element); } @@ -170,8 +243,11 @@ class TooltipFactory { */ #createTooltip(tip, direction) { const container = createElement('div', { - 'className': 'tooltip-container__item', - 'innerHTML': `<span tooltip="${tip}" direction="${direction}" class="force-active"></span>` + className: 'tooltip-container__item', + innerHTML: { + src: `<span tooltip="${tip}" direction="${direction}" class="force-active"></span>`, + noSanitise: true, + } }); return container; diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/components/virtualisedList/index.js b/CodeListLibrary_project/cll/static/js/clinicalcode/components/virtualisedList/index.js index 1dee69093..fbe8b5747 100644 --- a/CodeListLibrary_project/cll/static/js/clinicalcode/components/virtualisedList/index.js +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/components/virtualisedList/index.js @@ -1,7 +1,3 @@ -/** - * Aggregated export - */ +export * as vlConst from './constants.js'; export { default } from './virtualList.js'; -export { default as DebouncedTask } from './debouncedTask.js'; -export { default as DeferredThreadGroup } from './deferredThreadGroup.js'; diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/components/virtualisedList/virtualList.js b/CodeListLibrary_project/cll/static/js/clinicalcode/components/virtualisedList/virtualList.js index 0c8b1ca72..38d25fe79 100644 --- a/CodeListLibrary_project/cll/static/js/clinicalcode/components/virtualisedList/virtualList.js +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/components/virtualisedList/virtualList.js @@ -1,7 +1,8 @@ -import DebouncedTask from './debouncedTask.js'; -import DeferredThreadGroup from './deferredThreadGroup.js'; import * as Constants from './constants.js'; +import DebouncedTask from '../tasks/debouncedTask.js'; +import DeferredThreadGroup from '../tasks/deferredThreadGroup.js'; + /** * boundaryComparator * @desc default comparator for binary search diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/cookies/cookie-settings.js b/CodeListLibrary_project/cll/static/js/clinicalcode/cookies/cookie-settings.js index 05757fd80..7764db9c5 100644 --- a/CodeListLibrary_project/cll/static/js/clinicalcode/cookies/cookie-settings.js +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/cookies/cookie-settings.js @@ -15,7 +15,7 @@ const cookieSettings = (privacyurl) => { Otherwise, you can consent to our use of cookies by clicking <em>"Save selection"</em>. </p> <p> - For more information about what information is collected and how it is shared with our partners, please read our <a href="${privacyurl}">Privacy and cookie policy</a>. + For more information about what information is collected and how it is shared with our partners, please read our <a href="${privacyurl}" target=_blank rel="noopener">Privacy and cookie policy</a>. </p> <div class="checkbox-item-container min-size"> <input id="neccesary-cookies" type="checkbox" disabled checked class="checkbox-input" data-value="1" data-name="must-cookies"/> @@ -42,7 +42,7 @@ const cookieSettings = (privacyurl) => { }) .catch((result) => { // An error occurred somewhere (unrelated to button input) - if (!(result instanceof ModalFactory.ModalResults)) { + if (!!result && !(result instanceof ModalFactory.ModalResults)) { return console.error(result); } }); diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/cookies/generateGtag.js b/CodeListLibrary_project/cll/static/js/clinicalcode/cookies/generateGtag.js index bea15059b..91bcc2b51 100644 --- a/CodeListLibrary_project/cll/static/js/clinicalcode/cookies/generateGtag.js +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/cookies/generateGtag.js @@ -8,7 +8,6 @@ const generateTag = (type,configId, parameters) => { gtag("js", new Date()); gtag("consent", type , parameters); gtag("config", configId); - console.log("gtag", type, configId, parameters); }; const removeGATags = () => { diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/data/conceptUtils.js b/CodeListLibrary_project/cll/static/js/clinicalcode/data/conceptUtils.js index 05540eea8..75d60c55f 100644 --- a/CodeListLibrary_project/cll/static/js/clinicalcode/data/conceptUtils.js +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/data/conceptUtils.js @@ -84,6 +84,7 @@ const applyCodelistsFromConcepts = (conceptData, options) => { fixedColumns: false, classes: { wrapper: 'overflow-table-constraint', + container: 'datatable-container slim-scrollbar', }, data: { headings: headings, diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/forms/clinical/attributeSelectionService.js b/CodeListLibrary_project/cll/static/js/clinicalcode/forms/clinical/attributeSelectionService.js deleted file mode 100644 index 454f6d8d3..000000000 --- a/CodeListLibrary_project/cll/static/js/clinicalcode/forms/clinical/attributeSelectionService.js +++ /dev/null @@ -1,1334 +0,0 @@ -/** - * CSEL_VIEWS - * @desc describes the view states of this component - */ - -const CSEL_VIEWS = { - // The search view whereby users can select Concepts - ATTRIBUTE_TABLE: 0, - - // The current selection view, only accessible when allowMultiple flag is set to true - SELECTION: 1, -}; - -/** - * CSEL_EVENTS - * @desc used internally to track final state of dialogue - */ -const CSEL_EVENTS = { - // When a dialogue is cancelled without changes - CANCELLED: 0, - - // When a dialogue is confirmed, with or without changes - CONFIRMED: 1, -}; - -/** - * CSEL_OPTIONS - * @desc Available options for this component, - * where each of the following options are used as default values - * and are appended automatically if not overriden. - */ -const CSEL_OPTIONS = { - // Which template to query when retrieving accessible entities - template: 1, - - concept_data: null, - - // Allow more than a single Concept to be selected - allowMultiple: true, - - // Whether to remember the selection when previously opened - // [!] Note: Only works when allowMultiple flag is set to true - maintainSelection: true, - - // Flag to determine whether we scroll to the top of the result page when pagination occurs - scrollOnResultChange: true, - - // The title of the prompt - promptTitle: "Import Concepts", - - // The confirm button text - promptConfirm: "Confirm", - - // The cancel button text - promptCancel: "Cancel", - - // The size of the prompt (ModalFactory.ModalSizes.%s, i.e., {sm, md, lg}) - promptSize: "lg", - - // The message shown when no items are selected - noneSelectedMessage: "You haven't selected any attributes yet", -}; - -/** - * CSEL_BUTTONS - * @desc The styleguide for the prompt's buttons - */ -const CSEL_BUTTONS = { - CONFIRM: - '<button class="primary-btn text-accent-darkest bold secondary-accent" aria-label="Confirm" id="confirm-button"></button>', - CANCEL: - '<button class="secondary-btn text-accent-darkest bold washed-accent" aria-label="Cancel" id="reject-button"></button>', -}; - -const CSEL_UTILITY_BUTTONS = { - DELETE_BUTTON: - '<button class="fill-accordion__label__delete-icon" id="children-button-${id}" type="button" aria-label="Delete"></button> ', -}; - -/** - * CSEL_INTERFACE - * @desc defines the HTML used to render the selection interface - */ -const CSEL_INTERFACE = { - // Main dialogue modal - DIALOGUE: - ' \ - <div class="target-modal target-modal-${promptSize}" id="${id}" aria-hidden="${hidden}"> \ - <div class="target-modal__container"> \ - <div class="target-modal__header"> \ - <h2 id="target-modal-title">${promptTitle}</h2> \ - </div> \ - <div class="target-modal__body" id="target-modal-content"> \ - </div> \ - </div> \ - </div> \ - <div id="wrapper">\ - </div>', - - // Tabbed views when allowMultiple flag is active - TAB_VIEW: - ' \ - <div class="tab-view" id="tab-view"> \ - <div class="tab-view__tabs tab-view__tabs-z-buffer"> \ - <button aria-label="tab" id="ATTRIBUTE_TABLE" class="tab-view__tab active">Attributed Concepts</button> \ - <button aria-label="tab" id="SELECTION" class="tab-view__tab">All attributes</button> \ - </div> \ - <div class="tab-view__content" id="tab-content"> \ - </div> \ - </div>', - - SELECTION_VIEW: - ' \ - <div class="detailed-input-group fill no-margin"> \ - <div class="detailed-input-group__header"> \ - <button class="secondary-btn text-accent-darkest bold icon secondary-accent" style="margin-bottom:0.5rem" id="add-attribute-btn">Add attribute +</button> \ - </div> \ - <section class="detailed-input-group__none-available" id="no-items-selected"> \ - <div class="detailed-input-group"> \ - <p class="detailed-input-group__none-available-message">${noneSelectedMessage}</p> \ - </div> \ - </section> \ - <fieldset class="code-search-group indented scrollable slim-scrollbar" id="item-list"> \ - </fieldset> \ - </div>', - - ATTRIBUTE_ACCORDION: - ' \ - <div class="fill-accordion" id="attribute-accordion-${id}" style="margin-top: 0.5rem"> \ - <input class="fill-accordion__input" id="children-${id}" name="children-${id}" type="checkbox" /> \ - <label class="fill-accordion__label" id="children-label-${id}" for="children-${id}" role="button" tabindex="0"> \ - ${title} \ - </label> \ - <article class="fill-accordion__container" id="data" style="padding: 0.5rem;"> \ - ${content} \ - </article> \ - </div>', -}; - - -/** - * AttributeSelectionService class provides methods to manage and manipulate attribute data for clinical concepts. - * It allows for the creation, validation, and rendering of attribute data within a dialogue interface. - * - * @class - * @example - * const service = new AttributeSelectionService(options); - * service.show(); - */ -export class AttributeSelectionService { - static Views = CSEL_VIEWS; - - constructor(options) { - this.options = mergeObjects(options || {}, CSEL_OPTIONS); - this.attribute_component = options.attribute_component; - - this.temporarly_concept_data = JSON.parse( - JSON.stringify(options.concept_data) - ); - - for (let j = 0; j < this.temporarly_concept_data.length; j++) { - if (this.temporarly_concept_data[j].attributes) { - this.temporarly_concept_data[j].attributes.forEach((attribute) => { - attribute.id = this.#generateUUID(); - attribute.type = this.#typeDeconversion(attribute.type); - }); - } else { - if (this.temporarly_concept_data[0].attributes) { - this.temporarly_concept_data[j].attributes = - this.temporarly_concept_data[0].attributes.map((attribute) => ({ - ...attribute, - value: " ", - })); - } else { - this.temporarly_concept_data[j].attributes = []; - this.temporarly_concept_data[j].attributes.push({ - id: this.#generateUUID(), - name: "Attribute test name", - type: "1", - value: " ", - }); - } - } - } - this.attribute_data = []; - this.temporarly_concept_data[0].attributes.forEach((attribute) => { - this.attribute_data.push({ - id: attribute.id, - name: attribute.name, - type: attribute.type, - }); - }); - } - - /************************************* - * * - * Getter * - * * - *************************************/ - /** - /** - * getSelection - * @desc gets the currently selected concepts - * @returns {array} the assoc. data - */ - getAttributeData() { - return this.attribute_data; - } - - /** - * isOpen - * @desc reflects whether the dialogue is currently open - * @returns {boolean} whether the dialogue is open - */ - isOpen() { - return !!this.dialogue; - } - - /** - * getDialogue - * @desc get currently active dialogue, if any - * @returns {object} the dialogue and assoc. elems/methods - */ - getDialogue() { - return this.dialogue; - } - - /** - * isSelected - * @param {number} childId - * @param {number} childVersion - * @returns {boolean} that reflects the selected state of a Concept - */ - isSelected(childId, childVersion) { - if (isNullOrUndefined(this.dialogue?.data)) { - return false; - } - - return !!this.dialogue.data.find((item) => { - return item.id == childId && item.history_id == childVersion; - }); - } - - /************************************* - * * - * Public * - * * - *************************************/ - /** - * show - * @desc shows the dialogue - * @param {enum|int} view the view to open the modal with - * @param {object|null} params query parameters to be provided to server to modify Concept results - * @returns {promise} a promise that resolves if the selection was confirmed, otherwise rejects - */ - show(view = CSEL_VIEWS.ATTRIBUTE_TABLE, params) { - params = params || {}; - - // Reject immediately if we currently have a dialogue open - if (this.dialogue) { - return Promise.reject(); - } - - return new Promise((resolve, reject) => { - this.#buildDialogue(params); - this.#renderView(view); - this.#createGridTable(this.temporarly_concept_data); - const tableElement = document.querySelector("#tab-content table"); - this.#invokeGridElements(tableElement); - - this.dialogue.element.addEventListener("selectionUpdate", (e) => { - this.close(); - - const detail = e.detail; - const eventType = detail.type; - const data = detail.data; - switch (eventType) { - case CSEL_EVENTS.CONFIRMED: - { - if ( - this.options.allowMultiple && - this.options.maintainSelection - ) { - this.attribute_data = data; - } - - if (this.options.allowMultiple) { - resolve(data); - return; - } - resolve(data?.[0]); - } - break; - - case CSEL_EVENTS.CANCELLED: - { - reject(); - } - break; - - default: - break; - } - }); - - this.dialogue.show(); - }); - } - - /** - * close - * @desc closes the dialogue if active - */ - close() { - if (this.dialogue) { - this.dialogue.close(); - } - - return this; - } - - /************************************* - * * - * Render * - * * - *************************************/ - /** - * buildDialogue - * @desc renders the top-level modal according to the options given - * @param {object} params the given query params - * @returns {object} the dialogue object as assigned to this.dialogue - */ - #buildDialogue(params) { - // create dialogue - const currentHeight = window.scrollY; - let html = interpolateString(CSEL_INTERFACE.DIALOGUE, { - id: 'attribute-concepts', - promptTitle: this.options?.promptTitle, - promptSize: this.options?.promptSize, - hidden: "false", - },true); - - let doc = parseHTMLFromString(html,true); - let modal = document.body.appendChild(doc[0]); - - // create footer - let footer = createElement("div", { - id: "target-modal-footer", - class: "target-modal__footer", - }); - - const container = modal.querySelector(".target-modal__container"); - footer = container.appendChild(footer); - - // create buttons - const buttons = {}; - let confirmBtn = parseHTMLFromString(CSEL_BUTTONS.CONFIRM,true); - confirmBtn = footer.appendChild(confirmBtn[0]); - confirmBtn.innerText = this.options.promptConfirm; - - let cancelBtn = parseHTMLFromString(CSEL_BUTTONS.CANCEL,true); - cancelBtn = footer.appendChild(cancelBtn[0]); - cancelBtn.innerText = this.options.promptCancel; - - buttons["confirm"] = confirmBtn; - buttons["cancel"] = cancelBtn; - - // initiate main event handling - buttons?.confirm.addEventListener("click", this.#handleConfirm.bind(this)); - buttons?.cancel.addEventListener("click", this.#handleCancel.bind(this)); - - // create content handler - const body = container.querySelector("#target-modal-content"); - if (this.options?.allowMultiple) { - body.classList.add("target-modal__body--no-pad"); - body.classList.add("target-modal__body--constrained"); - } - - let contentContainer = body; - if (this.options.allowMultiple) { - html = CSEL_INTERFACE.TAB_VIEW; - doc = parseHTMLFromString(html,true); - contentContainer = body.appendChild(doc[0]); - - const tabs = contentContainer.querySelectorAll("button.tab-view__tab"); - for (let i = 0; i < tabs.length; ++i) { - tabs[i].addEventListener("click", this.#changeTabView.bind(this)); - } - - contentContainer = contentContainer.querySelector("#tab-content"); - } - - // build dialogue - this.dialogue = { - // data - data: this.options?.maintainSelection ? this.attribute_data : [], - params: params, - view: CSEL_VIEWS.ATTRIBUTE_TABLE, - - // dialogue elements - element: modal, - buttons: buttons, - content: contentContainer, - - // dialogue methods - show: () => { - createElement("a", { href: `#attribute-concepts` }).click(); - window.scrollTo({ - top: currentHeight, - left: window.scrollX, - behaviour: "instant", - }); - - // inform screen readers of alert - modal.setAttribute("aria-hidden", false); - modal.setAttribute("role", "alert"); - modal.setAttribute("aria-live", true); - - // stop body scroll - document.body.classList.add("modal-open"); - }, - close: () => { - this.dialogue = null; - - document.body.classList.remove("modal-open"); - modal.remove(); - history.replaceState({}, document.title, "#"); - window.scrollTo({ - top: currentHeight, - left: window.scrollX, - behaviour: "instant", - }); - }, - }; - - return this.dialogue; - } - - /** - * Creates and renders a grid table with the provided concept data. - * - * This method initializes a grid table using the Grid.js library and populates it with the given concept data. - * If the concept data contains attributes, it transforms the data to include these attributes and updates the grid table configuration. - * - * @param {Array} concept_data - An array of concept objects. Each concept object should have the following structure: - * { - * is_new: {boolean}, // Indicates if the concept is new - * details: { - * name: {string}, // The name of the concept - * phenotype_owner: {string}, // The owner of the phenotype - * phenotype_owner_history_id: {number}, // The history ID of the phenotype owner - * }, - * concept_id: {number}, // The ID of the concept - * attributes: {Array} // An array of attribute objects, each with a 'value' property - * } - */ - #createGridTable(concept_data) { - document.getElementById("tab-content").innerHTML = ""; - - const table = new gridjs.Grid({ - columns: ["Concept"], - data: concept_data.map((concept) => [ - concept.is_new - ? `${concept.details.name}` - : `${concept.details.phenotype_owner}/${concept.details.phenotype_owner_history_id}/${concept.concept_id} - ${concept.details.name}`, - ]), - }).render(document.getElementById("tab-content")); - - if (!concept_data.every((concept) => !concept.attributes)) { - const transformedData = []; - for (let i = 0; i < concept_data.length; i++) { - const concept = concept_data[i]; - const rowData = [ - concept.is_new - ? `${concept.details.name}` - : `${concept.details.phenotype_owner}/${concept.details.phenotype_owner_history_id}/${concept.concept_id} - ${concept.details.name}`, - ]; - if (concept.attributes) { - for (let j = 0; j < concept.attributes.length; j++) { - let attribute = concept.attributes[j]; - attribute.attributes = (cell) => { - if (cell) { - let styleText; - let innertText; - if (cell.trim() === "") { - styleText = "color: grey;"; - innertText = "Enter value"; - } - return { - style: "cursor: pointer;" + styleText, - contenteditable: true, - innerText: innertText, - onfocus: (e) => { - if (e.target.innerText === "Enter value") { - e.target.innerText = ""; - e.target.style.color = "black"; - } - }, - onblur: (e) => { - if (e.target.innerText.trim() === "") { - e.target.innerText = "Enter value"; - e.target.style.color = "grey"; - } else { - e.target.style.color = "black"; - } - }, - }; - } - }; - rowData.push(concept.attributes[j].value); - } - } - transformedData.push(rowData); - } - const columns = ["Concept"]; - if (concept_data[0].attributes) { - concept_data[0].attributes.forEach((attribute) => { - columns.push(attribute); - }); - } - - table - .updateConfig({ - columns: columns, - data: transformedData, - }) - .forceRender(); - } - } - - /** - * Adds event listeners to table cells for editing and updates the concept data accordingly. - * - * @param {HTMLElement} tableElement - The table element containing the rows and cells to be edited. - */ - #addCellEditListeners(tableElement) { - const rows = tableElement.querySelector("tbody").childNodes; - - const changedCell = []; - for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) { - const row = rows[rowIndex]; - const columns = row.querySelectorAll("td"); - - for (let columnIndex = 1; columnIndex < columns.length; columnIndex++) { - // Skip the first column (Concept) - const tempCol = columns[columnIndex]; - if (tempCol.innerText !== "" && tempCol.innerText !== "Enter value") { - changedCell.push({ - row: rowIndex, - column: columnIndex, - value: tempCol.innerText, - }); - } else { - changedCell.push({ - row: rowIndex, - column: columnIndex, - value: " ", - }); - } - } - } - for (let i = 0; i < changedCell.length; i++) { - const cell = changedCell[i]; - const concept = this.temporarly_concept_data[cell.row]; - concept.attributes[cell.column - 1] = { - id: concept.attributes[cell.column - 1].id, - value: cell.value, - name: concept.attributes[cell.column - 1].name, - type: concept.attributes[cell.column - 1].type, - }; - } - } - - /** - * Validates the input value based on the specified type and attribute name. - * - * @param {string} targetInput - The input value to be validated. - * @param {string} type - The type of the attribute (e.g., "1" for integer, "2" for string, "3" for float). - * @param {string} attributeName - The name of the attribute being validated. - * @param {number} rIndex - The row index of the attribute in the table. - * @returns {boolean} - Returns true if the input is valid, otherwise false. - */ - #cellValidation(targetInput, type, attributeName, rIndex) { - if (targetInput.trim() === "") { - return true; - } - if (!/^[a-zA-Z0-9]+$/.test(targetInput) && type === "2") { - this.#pushToast({ - type: "danger", - message: `Attribute ${attributeName} with row index ${rIndex} is not a string`, - }); - return false; - } - if (isNaN(targetInput) && type === "1") { - this.#pushToast({ - type: "danger", - message: `Attribute ${attributeName} with row index ${rIndex} is not an integer`, - }); - return false; - } - if (!/^[-+]?[0-9]*\.?[0-9]+$/.test(targetInput) && type === "3") { - this.#pushToast({ - type: "danger", - message: `Attribute ${attributeName} with row index ${rIndex} is not a float`, - }); - return false; - } - return true; - } - - /** - * renderView - * @desc renders the given view - * @param {enum|int} view the view to render within the active dialogue - */ - #renderView(view) { - if (!this.isOpen()) { - return; - } - - if (!this.options.allowMultiple && view == CSEL_VIEWS.SELECTION) { - view = CSEL_VIEWS.ATTRIBUTE_TABLE; - } - this.dialogue.view = view; - - const content = this.dialogue?.content; - if (!isNullOrUndefined(content)) { - content.innerHTML = ""; - } - - if (this.options.allowMultiple) { - this.#pushActiveTab(view); - } - - switch (view) { - case CSEL_VIEWS.ATTRIBUTE_TABLE: - { - this.#createGridTable(this.temporarly_concept_data); - const tableElement = document.querySelector("#tab-content table"); - this.#invokeGridElements(tableElement); - } - break; - - case CSEL_VIEWS.SELECTION: - { - this.#renderSelectionView(); - } - break; - - default: - break; - } - } - - /** - * renderSelectionView - * @desc renders the selection view where users can manage their currently selected concepts - */ - #renderSelectionView() { - // Draw page - let html = interpolateString(CSEL_INTERFACE.SELECTION_VIEW, { - noneSelectedMessage: this.options?.noneSelectedMessage, - },true); - - let doc = parseHTMLFromString(html,true); - let page = this.dialogue.content.appendChild(doc[0]); - this.dialogue.page = page; - - this.#paintSelectionAttributes(); - } - - /** - * pushActiveTab - * @desc updates the tab view objects when allowMultiple flag is true - * @param {int|enum} view an enum of CSEL_VIEWS - */ - #pushActiveTab(view) { - let tabs = this.dialogue.element.querySelectorAll("button.tab-view__tab"); - for (let i = 0; i < tabs.length; ++i) { - let tab = tabs[i]; - let relative = tab.getAttribute("id"); - if (!CSEL_VIEWS.hasOwnProperty(relative)) { - continue; - } - - relative = CSEL_VIEWS[relative]; - if (relative == view) { - tab.classList.add("active"); - } else { - tab.classList.remove("active"); - } - } - } - - /************************************* - * * - * Events * - * * - *************************************/ - - /** - * handleConfirm - * @desc handles the confirmation btn - * @param {event} e the assoc. event - */ - #handleConfirm(e) { - if (!this.isOpen()) { - return; - } - - // Clean up the attributes in concept_data and validate - let validated = true; - - for (let i = 0; i < this.temporarly_concept_data.length; i++) { - const concept = this.temporarly_concept_data[i]; - if (concept.attributes) { - for (let j = 0; j < concept.attributes.length; j++) { - const attribute = concept.attributes[j]; - if ( - !this.#cellValidation( - attribute.value, - attribute.type, - attribute.name, - i + 1 - ) - ) { - validated = false; - } - } - } - } - - if (validated) { - this.temporarly_concept_data.forEach((concept) => { - if (concept.attributes) { - concept.attributes = concept.attributes.map((attr) => ({ - name: attr.name, - value: attr.value, - type: this.#typeConversion(attr.type), - })); - } - }); - - this.options.concept_data = JSON.parse( - JSON.stringify(this.temporarly_concept_data) - ); - - const event = new CustomEvent("selectionUpdate", { - detail: { - data: this.options.concept_data, - type: CSEL_EVENTS.CONFIRMED, - }, - }); - this.dialogue?.element.dispatchEvent(event); - } else { - return; - } - } - - /** - * handleCancel - * @desc handles the cancel/exit btn - * @param {event} e the assoc. event - */ - #handleCancel(e) { - if (!this.isOpen()) { - return; - } - - const event = new CustomEvent("selectionUpdate", { - detail: { - type: CSEL_EVENTS.CANCELLED, - }, - }); - this.dialogue?.element.dispatchEvent(event); - } - - /** - * Generates a UUID (Universally Unique Identifier). - * The UUID is in the format of "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx". - * - * @returns {string} A randomly generated UUID. - */ - #generateUUID() { - return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace( - /[xy]/g, - function (c) { - var r = (Math.random() * 16) | 0, - v = c == "x" ? r : (r & 0x3) | 0x8; - return v.toString(16); - } - ); - } - - /** - * Handles the creation of a new attribute. - * - * This method is triggered by an event and performs the following steps: - * 1. Disables the "add attribute" button. - * 2. Parses the attribute component and generates a unique ID for the new attribute. - * 3. Interpolates the attribute component with the unique ID. - * 4. If there are no existing attributes, it creates a new attribute row and appends it to the page. - * 5. If there are existing attributes, it creates a new attribute row and appends it to the page. - * 6. Initializes a new attribute object with default values. - * 7. Invokes methods to handle attribute inputs and buttons. - * - * @param {Event} e - The event object that triggered the attribute creation. - * @private - */ - #handleAttributeCreation(e) { - const page = this.dialogue.page; - page.querySelector("#add-attribute-btn").setAttribute("disabled", true); - const noneAvailable = page.querySelector("#no-items-selected"); - - let attribute_progress = parseHTMLFromString(this.attribute_component,true); - const uniqueId = this.#generateUUID(); - attribute_progress = interpolateString(this.attribute_component, { - id: uniqueId, - }); - - if (this.attribute_data.length <= 0) { - let attributerow = interpolateString(CSEL_INTERFACE.ATTRIBUTE_ACCORDION, { - id: uniqueId, - title: `New attribute value`, - content: attribute_progress, - },true); - let doc = parseHTMLFromString(attributerow); - noneAvailable.classList.remove("show"); - page.appendChild(doc[0]); - } else { - let attributerow = interpolateString(CSEL_INTERFACE.ATTRIBUTE_ACCORDION, { - id: uniqueId, - title: "New attribute value", - content: attribute_progress, - },true); - let doc = parseHTMLFromString(attributerow); - page.appendChild(doc[0]); - } - - let attribute = { - id: uniqueId, - name: "", - type: "-1", - value: " ", - }; - - attribute = this.#invokeAttributeInputs(attribute, page); - - this.#invokeAttributeButtons(attribute, page); - } - - /** - * Invokes event listeners for attribute buttons on the given page. - * - * @param {Object} attribute - The attribute object containing the id. - * @param {HTMLElement} page - The page element where the buttons are located. - * @private - */ - #invokeAttributeButtons(attribute, page) { - const confirmChanges = page.querySelector( - "#confirm-changes-" + attribute.id - ); - confirmChanges.addEventListener("click", () => - this.#handleConfirmEditor(attribute) - ); - - const cancelChanges = page.querySelector("#cancel-changes-" + attribute.id); - cancelChanges.addEventListener("click", () => - this.#handleCancelEditor(attribute) - ); - - if (page.querySelector("#children-button-" + attribute.id)) { - const deleteAttributeButton = page.querySelector( - "#children-button-" + attribute.id - ); - deleteAttributeButton.addEventListener("click", () => - this.#deleteAttribute(attribute) - ); - } - } - - /** - * Invokes grid elements within the specified table element. - * - * This method adds event listeners to each cell in the table to handle focus and blur events. - * When a cell gains focus and contains the text "Enter value", the text is cleared. - * When a cell loses focus and is empty, the text "Enter value" is set. - * Additionally, an input event listener is added to the table element to call the - * `#addCellEditListeners` method. - * - * @param {HTMLElement} tableElement - The table element containing the grid cells. - * @private - */ - #invokeGridElements(tableElement) { - if (tableElement) { - tableElement.querySelectorAll("td").forEach((cell) => { - cell.addEventListener("focus", (e) => { - if (e.target.innerText === "Enter value") { - e.target.innerText = ""; - } - }); - - cell.addEventListener("blur", (e) => { - if (e.target.innerText.trim() === "") { - e.target.innerText = "Enter value"; - } - }); - }); - tableElement.addEventListener("input", (e) => { - this.#addCellEditListeners(tableElement); - }); - } - } - - /** - * Invokes attribute inputs and sets up event listeners for attribute name and type changes. - * - * @param {Object} attribute - The attribute object to be updated. - * @param {HTMLElement} page - The page element containing the input fields. - * @returns {Object} The updated attribute object. - */ - #invokeAttributeInputs(attribute, page) { - const attribute_name_input = page.querySelector( - "#attribute-name-input-" + attribute.id - ); - const attribute_type = page.querySelector( - "#attribute-type-" + attribute.id - ); - - attribute_type.addEventListener("change", () => { - attribute.type = attribute_type.value; - }); - - attribute_name_input.addEventListener("input", () => { - attribute.name = `${attribute_name_input.value}`; - }); - - return attribute; - } - - /** - * Converts a given type identifier to its corresponding type string. - * - * @param {string} type - The type identifier to be converted. Expected values are: - * "1" for integer (INT), - * "2" for string (STRING), - * "3" for float (FLOAT). - * @returns {string} The corresponding type string ("INT", "STRING", "FLOAT"). - */ - #typeConversion(type) { - switch (type) { - case "1": - return "INT"; - case "2": - return "STRING"; - case "3": - return "FLOAT"; - } - } - - /** - * Converts a given type to its corresponding string representation. - * - * @param {string} type - The type to be converted. Possible values are "INT", "STRING", and "FLOAT". - * @returns {string} - The string representation of the type. "1" for "INT", "2" for "STRING", and "3" for "FLOAT". - */ - #typeDeconversion(type) { - switch (type) { - case "INT": - return "1"; - case "STRING": - return "2"; - case "FLOAT": - return "3"; - } - } - - /** - * Deletes the specified attribute from the attribute data and updates the UI accordingly. - * - * @param {Object} attribute - The attribute object to be deleted. - * @param {number} attribute.id - The unique identifier of the attribute. - * @param {string} attribute.name - The name of the attribute. - * - * @private - */ - #deleteAttribute(attribute) { - const page = this.dialogue.page; - - const accordion = page.querySelector( - "#attribute-accordion-" + attribute.id - ); - - const noneAvailable = page.querySelector("#no-items-selected"); - - const indexToDelete = this.attribute_data.findIndex( - (attr) => attr.id === attribute.id - ); - if (indexToDelete !== -1) { - // Remove the attribute from attribute_data - this.attribute_data.splice(indexToDelete, 1); - - // Remove the related attributes from concept_data - this.temporarly_concept_data.forEach((concept) => { - if (concept.attributes) { - concept.attributes = concept.attributes.filter( - (attr) => attr.name !== attribute.name - ); - } - }); - - // Remove the accordion element - accordion.remove(); - - // Show the "no items selected" message if there are no attributes left - if ( - this.attribute_data.length <= 0 && - page.querySelectorAll(".fill-accordion").length <= 0 - ) { - noneAvailable.classList.add("show"); - } - - this.#pushToast({ - type: "danger", - message: "Attribute has been deleted", - }); - } - } - - /** - * Displays a toast notification with the specified type, message, and duration. - * - * @param {Object} options - The options for the toast notification. - * @param {string} [options.type="information"] - The type of the toast notification (e.g., "information", "error"). - * @param {string|null} [options.message=null] - The message to display in the toast notification. - * @param {string|number} [options.duration="5000"] - The duration for which the toast notification should be displayed (in milliseconds). - */ - #pushToast({ type = "information", message = null, duration = "5000" }) { - if (isNullOrUndefined(message)) { - return; - } - - window.ToastFactory.push({ - type: type, - message: message, - duration: Math.max(duration, "444"), - }); - } - - /** - * Handles the confirmation of the attribute editor. - * Validates the attribute data, updates or adds the attribute to the attribute_data, - * updates the concept_data with the updated attribute, and updates the UI accordingly. - * - * @param {Object} attribute - The attribute object to be confirmed. - * @param {string} attribute.id - The unique identifier of the attribute. - * @param {string} attribute.name - The name of the attribute. - * @param {string} attribute.type - The type of the attribute. - * @private - */ - #handleConfirmEditor(attribute) { - // Validate the concept data - if (!attribute || attribute.name === "") { - this.#pushToast({ - type: "danger", - message: "Attribute name cannot be empty", - }); - return; - } - - if (attribute.type === "-1") { - this.#pushToast({ type: "danger", message: "Please select a type" }); - return; - } - - // Check if the attribute already exists in attribute_data - const existingAttributeIndex = this.attribute_data.findIndex( - (attr) => attr.id === attribute.id - ); - - if (existingAttributeIndex !== -1) { - // Update the existing attribute with the new name and type if they have changed - const existingAttribute = this.attribute_data[existingAttributeIndex]; - if ( - existingAttribute.name !== attribute.name || - existingAttribute.type !== attribute.type - ) { - existingAttribute.name = attribute.name; - existingAttribute.type = attribute.type; - } - } else { - const existingAttributeIndex = this.attribute_data.findIndex( - (attr) => attr.name.trim() === attribute.name.trim() - ); - if (existingAttributeIndex !== -1) { - this.#pushToast({ - type: "danger", - message: "Attribute cannot have the same name as another attribute", - }); - return; - } - // Add the new attribute to attribute_data if it doesn't exist - this.attribute_data.push(attribute); - this.#pushToast({ - type: "success", - message: "Attribute added successfully", - }); - } - - // Update the concept_data with the updated attribute - this.temporarly_concept_data.forEach((concept) => { - if (!concept.hasOwnProperty("attributes")) { - concept.attributes = []; - } - const existingConceptAttributeIndex = concept.attributes.findIndex( - (attr) => attr.name === attribute.name - ); - if (existingAttributeIndex !== -1) { - // Update the existing attribute in concept.attributes with the new name and type if they have changed - const existingConceptAttribute = - concept.attributes[existingAttributeIndex]; - if ( - existingConceptAttribute.name !== attribute.name || - existingConceptAttribute.type !== attribute.type - ) { - existingConceptAttribute.name = attribute.name; - existingConceptAttribute.type = attribute.type; - } - } else { - // Add the new attribute to concept.attributes if it doesn't exist - concept.attributes.push(attribute); - } - }); - - // Update the accordion label with the new attribute details - const accordion = this.dialogue.page.querySelector( - "#attribute-accordion-" + attribute.id - ); - const accordionLabel = accordion.querySelector( - "#children-label-" + attribute.id - ); - let accordionDeleteButton = interpolateString( - CSEL_UTILITY_BUTTONS.DELETE_BUTTON, - { - id: attribute.id, - } - ); - const deleteButtonElement = parseHTMLFromString(accordionDeleteButton)[0]; - accordionLabel.textContent = ""; - accordionLabel.insertBefore(deleteButtonElement, accordionLabel.firstChild); - accordionLabel.appendChild( - document.createTextNode( - ` ${attribute.name} - ${this.#typeConversion(attribute.type)}` - ) - ); - - // Close the accordion and re-enable the add attribute button - accordionLabel.click(); - this.dialogue.page - .querySelector("#add-attribute-btn") - .removeAttribute("disabled"); - - this.dialogue.page - .querySelector("#children-button-" + attribute.id) - .addEventListener("click", () => { - this.#deleteAttribute(attribute); - }); - } - - /** - * Handles the cancellation of the attribute editor. - * - * This method performs the following actions: - * - Retrieves various elements related to the attribute from the page. - * - If there are no attributes in `this.attribute_data`, it resets the input fields, removes the accordion, - * shows a "none available" message, and enables the "add attribute" button. - * - If there are attributes but the input fields are empty or invalid, it removes the accordion and enables - * the "add attribute" button. - * - If the input fields are valid, it simulates a click on the accordion's children label. - * - * @param {Object} attribute - The attribute object containing the attribute's id. - */ - #handleCancelEditor(attribute) { - const page = this.dialogue.page; - const attribute_name_input = page.querySelector( - "#attribute-name-input-" + attribute.id - ); - const attribute_type = page.querySelector( - "#attribute-type-" + attribute.id - ); - const accordion = page.querySelector( - "#attribute-accordion-" + attribute.id - ); - const noneAvailable = page.querySelector("#no-items-selected"); - - if (this.attribute_data.length <= 0) { - attribute_name_input.value = null; - attribute_type.value = -1; - accordion.remove(); - noneAvailable.classList.add("show"); - page.querySelector("#add-attribute-btn").removeAttribute("disabled"); - } else { - if (attribute_name_input.value === "" || attribute_type.value === "-1") { - accordion.remove(); - page.querySelector("#add-attribute-btn").removeAttribute("disabled"); - } - accordion.querySelector("#children-label-" + attribute.id).click(); - } - } - - /** - * changeTabView - * @desc handles the tab buttons - * @param {event} e the assoc. event - */ - #changeTabView(e) { - const target = e.target; - const desired = target.getAttribute("id"); - if (target.classList.contains("active")) { - return; - } - - if (!desired || !CSEL_VIEWS.hasOwnProperty(desired)) { - return; - } - - this.#renderView(CSEL_VIEWS[desired]); - } - - /** - * Paints the selection attributes on the page. - * - * This method updates the UI to reflect the current state of attribute selection. - * It hides or shows elements based on whether attributes are selected and sets up - * event listeners for attribute creation and deletion. - * - * @private - * @method #paintSelectionAttributes - * @memberof AttributeSelectionService - * - * @returns {void} - */ - #paintSelectionAttributes() { - const page = this.dialogue.page; - if ( - !this.dialogue?.view == CSEL_VIEWS.SELECTION || - isNullOrUndefined(page) - ) { - return; - } - - const content = page.querySelector("#item-list"); - const noneAvailable = page.querySelector("#no-items-selected"); - if (isNullOrUndefined(content) || isNullOrUndefined(noneAvailable)) { - return; - } - - const hasSelectedItems = - !isNullOrUndefined(this.attribute_data) && this.attribute_data.length > 0; - - // Display none available if no items selected - let addAttributeButton = page.querySelector("#add-attribute-btn"); - if (!hasSelectedItems) { - content.classList.add("hide"); - noneAvailable.classList.add("show"); - - if (addAttributeButton) { - addAttributeButton.addEventListener("click", () => { - this.#handleAttributeCreation(this); - }); - } - return; - } else { - content.classList.remove("hide"); - noneAvailable.classList.remove("show"); - - for (let i = 0; i < this.attribute_data.length; ++i) { - let attribute = this.attribute_data[i]; - let attribute_progress = interpolateString(this.attribute_component, { - id: attribute.id, - }); - - attribute_progress = parseHTMLFromString(attribute_progress,true)[0]; - - attribute_progress.querySelector("#attribute-name-input-" + attribute.id).setAttribute("value", attribute.name); - attribute_progress.querySelector("#attribute-type-" + attribute.id).querySelector(`option[value="${attribute.type}"]`).setAttribute("selected", true); - - let attributerow = interpolateString( - CSEL_INTERFACE.ATTRIBUTE_ACCORDION, - { - id: attribute.id, - title: `${attribute.name} - ${this.#typeConversion( - attribute.type - )}`, - content: attribute_progress.outerHTML, - } - ); - - let doc = parseHTMLFromString(attributerow)[0]; - - const accordionLabel = doc.querySelector( - "#children-label-" + attribute.id - ); - let accordionDeleteButton = interpolateString( - CSEL_UTILITY_BUTTONS.DELETE_BUTTON, - { - id: attribute.id, - } - ); - const deleteButtonElement = parseHTMLFromString(accordionDeleteButton)[0]; - accordionLabel.textContent = ""; - accordionLabel.insertBefore( - deleteButtonElement, - accordionLabel.firstChild - ); - accordionLabel.appendChild( - document.createTextNode( - `${attribute.name} - ${this.#typeConversion(attribute.type)}` - ) - ); - - page.appendChild(doc); - - attribute = this.#invokeAttributeInputs(attribute, page); - this.#invokeAttributeButtons(attribute, page); - } - if (addAttributeButton) { - addAttributeButton.addEventListener("click", () => { - this.#handleAttributeCreation(this); - }); - } - } - } -} 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 451bc03fa..0752ab148 100644 --- a/CodeListLibrary_project/cll/static/js/clinicalcode/forms/clinical/conceptCreator.js +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/forms/clinical/conceptCreator.js @@ -1,5 +1,4 @@ import { parse as parseCSV } from '../../../lib/csv.min.js'; -import { AttributeSelectionService } from './attributeSelectionService.js'; import { ConceptSelectionService } from './conceptSelectionService.js'; /** @@ -46,7 +45,7 @@ const tryCleanCodingItem = (val, row, col) => { return; } - return strictSanitiseString(val.replace(/^\s+|\s+$/gm, '')); + return strictSanitiseString(val.toString().replace(/^\s+|\s+$/gm, '')); } /** @@ -120,6 +119,14 @@ const tryParseCodingExcelFile = (file) => { content = tryCleanCodingFile(content); return content; }) + .catch(error => { + console.error(`Failed to retrieve codes from file: ${error})`); + window.ToastFactory.push({ + type: 'error', + message: `Failed to retrieve codes from file: ${error})`, + duration: 4000, + }); + }); } /** @@ -207,15 +214,6 @@ const CONCEPT_CREATOR_SOURCE_TYPES = { }, } -/** - * CONCEPT_CREATOR_KEYCODES - * @desc Keycodes used by Concept Creator - */ -const CONCEPT_CREATOR_KEYCODES = { - // To modify rule name, apply search term etc - ENTER: 13, -} - /** * CONCEPT_CREATOR_FILE_UPLOAD * @desc File upload settings for file rule @@ -307,17 +305,22 @@ const CONCEPT_CREATOR_TEXT = { // Rule deletion prompt RULE_DELETION: { title: 'Are you sure?', - content: '<p>Are you sure you want to delete this Ruleset from your Concept?</p>', + content: '<p>Are you sure you want to delete this Ruleset from your ${brandMapping.concept}?</p>', }, // Concept deletion prompt CONCEPT_DELETION: { title: 'Are you sure?', - content: '<p>Are you sure you want to delete this Concept from your Phenotype?</p>', + content: '<p>Are you sure you want to delete this ${brandMapping.concept} from your ${brandMapping.phenotype}?</p>', + }, + // Concept deletion prompt + CODING_CHANGE: { + title: 'Are you sure?', + content: '<p>Are you sure you want to change your coding system? Any changes you\'ve made so far will be deleted.</p>', }, // Toast to inform user to close editor - REQUIRE_EDIT_CLOSURE: 'Please close the editor before trying to delete a Concept.', + REQUIRE_EDIT_CLOSURE: 'Please close the editor before trying to delete a ${brandMapping.concept}.', // Toast for Concept name validation - REQUIRE_CONCEPT_NAME: 'You need to name your Concept before saving', + REQUIRE_CONCEPT_NAME: 'You need to name your ${brandMapping.concept} before saving', // Toast for Concept CodingSystem validation REQUIRE_CODING_SYSTEM: 'You need to select a coding system before saving!', // Toast to inform the user that no exclusionary codes were addded since they aren't present in an inclusionary rule @@ -335,21 +338,19 @@ const CONCEPT_CREATOR_TEXT = { // Toast to inform the user there was an error when trying to upload their code file NO_CODE_FILE_MATCH: 'Unable to parse uploaded file. Please try again.', // Toast to inform the user that the codes from the imported concept(s) were added - ADDED_CONCEPT_CODES: 'Added ${code_len} codes via Concept Import', + ADDED_CONCEPT_CODES: 'Added ${code_len} codes via ${brandMapping.concept} Import', // Toast to inform the user there was an error when trying to upload their code file - NO_CONCEPT_MATCH: 'We were unable to add this Concept. Please try again.', + NO_CONCEPT_MATCH: 'We were unable to add this ${brandMapping.concept}. Please try again.', // Toast to inform the user they tried to import non-distinct top-level concepts CONCEPT_IMPORTS_ARE_PRESENT: 'Already imported ${failed}', // Toast to inform the user they tried to import non-distinct rule-level concepts - CONCEPT_RULE_IS_PRESENT: 'You have already imported this Concept as a rule', + CONCEPT_RULE_IS_PRESENT: 'You have already imported this ${brandMapping.concept} as a rule', // Toast to inform successful update to new concept version - CONCEPT_UPDATE_SUCCESS: 'Updated Concept to Version ${version}', + CONCEPT_UPDATE_SUCCESS: 'Updated ${brandMapping.concept} to Version ${version}', // Toast to inform failed update to new concept version - CONCEPT_UPDATE_FAILED: 'Failed to update Concept, please try again.', + CONCEPT_UPDATE_FAILED: 'Failed to update ${brandMapping.concept}, please try again.', } - - /** * @class ConceptCreator * @desc A class that can be used to control concept creation @@ -441,7 +442,7 @@ export default class ConceptCreator { * @returns {string|boolean} returns the title of this component if present, otherwise returns false */ getTitle() { - const group = tryGetRootElement(this.element, 'phenotype-progress__item'); + const group = tryGetRootElement(this.element, '.phenotype-progress__item'); if (!isNullOrUndefined(group)) { const title = group.querySelector('.phenotype-progress__item-title'); if (!isNullOrUndefined(title)) { @@ -519,36 +520,23 @@ export default class ConceptCreator { * @returns {promise} that can be used as a Thenable if required */ tryImportConcepts() { + const brandMapping = this.parent.mapping; const prompt = new ConceptSelectionService({ - promptTitle: 'Import Concepts', + promptTitle: `Import ${brandMapping.concept}`, + mapping: brandMapping, template: this.template?.id, entity_id: this.entity?.id, entity_history_id: this.entity?.history_id, allowMultiple: true, + noneSelectedMessage: `You haven't selected any ${brandMapping.concept}s yet`, }); - return prompt.show() .then((data) => { return this.#tryRetrieveCodelists(data); }); } - tryCallAttributeSettings() { - const prompt = new AttributeSelectionService({ - promptTitle: 'Attribute settings', - attribute_component: this.templates['attribute-component'], - template: this.template?.id, - entity_id: this.entity?.id, - entity_history_id: this.entity?.history_id, - concept_data: this.data, - }); - - return prompt.show() - .then((data) => { - this.data = data; - }); - } /** * tryPromptConceptRuleImport * @desc tries to prompt the user to import a Concept as a rule, @@ -568,8 +556,9 @@ export default class ConceptCreator { return Promise.reject(); } + const brandMapping = this.parent.mapping; const prompt = new ConceptSelectionService({ - promptTitle: `Import Concept as Rule (${codingSystemName})`, + promptTitle: `Import ${brandMapping.concept} as Rule (${codingSystemName})`, template: this.template?.id, allowMultiple: false, entity_id: this.entity?.id, @@ -696,10 +685,6 @@ export default class ConceptCreator { * @returns {promise} a promise that resolves with the template's option/source data if successful */ tryQueryOptionsParameter(param) { - if (!isNullOrUndefined(this.coding_data)) { - return Promise.resolve(this.coding_data); - } - const parameters = new URLSearchParams({ parameter: param, template: this.template?.id, @@ -715,11 +700,7 @@ export default class ConceptCreator { } } ) - .then(response => response.json()) - .then(response => { - this.coding_data = response?.result; - return this.coding_data; - }); + .then(response => response.json()); } /** @@ -861,10 +842,6 @@ export default class ConceptCreator { const importBtn = this.element.querySelector('#import-concept-btn'); importBtn.addEventListener('click', this.#handleConceptImporting.bind(this)); - - const addAttrBtn = this.element.querySelector('#add-concept-attribute-btn'); - this.#hideAttributeSettingsButton(this.data.length <= 0); - addAttrBtn.addEventListener('click', this.#handleAttributeSettings.bind(this)); } /************************************* @@ -1143,15 +1120,6 @@ export default class ConceptCreator { noConcepts.classList[hide ? 'remove' : 'add']('show'); } - #hideAttributeSettingsButton(hide) { - const addAttrBtn = this.element.querySelector('#add-concept-attribute-btn'); - if (hide) { - addAttrBtn.setAttribute('disabled','disabled'); - } else { - addAttrBtn.removeAttribute('disabled'); - } - } - /** * collapseConcepts * @desc method to collapse all concept accordions @@ -1177,7 +1145,7 @@ export default class ConceptCreator { * @param {boolean} forceUpdate whether to force update the codelist */ #toggleConcept(target, forceUpdate) { - const conceptGroup = tryGetRootElement(target, 'concept-list__group'); + const conceptGroup = tryGetRootElement(target, '.concept-list__group'); const conceptId = conceptGroup.getAttribute('data-concept-id'); const historyId = conceptGroup.getAttribute('data-concept-history-id'); @@ -1196,7 +1164,6 @@ export default class ConceptCreator { // Render codelist let dataset = this.data.filter(concept => concept.concept_version_id == historyId && concept.concept_id == conceptId); dataset = dataset.shift(); - console.log(dataset); return this.#tryRenderCodelist(container, dataset); } @@ -1217,7 +1184,6 @@ export default class ConceptCreator { containerList.innerHTML = ''; this.#toggleNoConceptBox(this.data.length > 0); - this.#hideAttributeSettingsButton(this.data.length <= 0); if (this.data.length > 0) { for (let i = 0; i < this.data.length; ++i) { @@ -1243,9 +1209,14 @@ export default class ConceptCreator { * @returns {node} the rendered concept group */ #tryRenderConceptComponent(concept) { + let urlTarget = this?.parent?.mapping?.phenotype_url; + if (!stringHasChars(urlTarget)) { + urlTarget = 'phenotypes'; + } + const template = this.templates['concept-item']; const access = this.#deriveEditAccess(concept); - const phenotype_version_url = `${window.location.origin}/phenotypes/${concept.details.phenotype_owner}/version/${concept.details.phenotype_owner_history_id}/detail`; + const phenotype_version_url = `${getBrandedHost()}/${urlTarget}/${concept.details.phenotype_owner}/version/${concept.details.phenotype_owner_history_id}/detail`; const isImportedItem = concept?.details?.phenotype_owner && !!concept?.details?.requested_entity_id && concept?.details?.phenotype_owner !== concept?.details?.requested_entity_id; const html = interpolateString(template, { @@ -1266,7 +1237,7 @@ export default class ConceptCreator { const containerList = this.element.querySelector('#concept-content-list'); const doc = parseHTMLFromString(html, true); - const conceptItem = containerList.appendChild(doc.body.children[0]); + const conceptItem = containerList.appendChild(doc[0]); conceptItem.setAttribute('live', true); const headerButtons = conceptItem.querySelectorAll('#concept-accordion-header span[role="button"]'); @@ -1371,36 +1342,73 @@ export default class ConceptCreator { */ #fetchCodingOptions(dataset) { // Fetch coding system from server - const promise = this.tryQueryOptionsParameter('coding_system') - .then(codingSystems => { - // Build <select/> option HTML - let options = interpolateString(CONCEPT_CREATOR_DEFAULTS.CODING_DEFAULT_HIDDEN_OPTION, { - 'is_unselected': codingSystems.length < 1, - }); + let promise; + if (Array.isArray(this.coding_data) && this.coding_data.length > 0) { + promise = Promise.resolve({ result: this.coding_data }); + } else { + promise = this.tryQueryOptionsParameter('coding_system'); + } - // Sort alphabetically in desc. order - codingSystems.sort((a, b) => { - if (a.name < b.name) { - return -1; - } + let codingSystems; + return new Promise((resolve, reject) => { + promise + .then((response) => { + codingSystems = response?.result; + if (!Array.isArray(codingSystems)) { + reject(new Error( + 'Failed to fetch valid Coding Systems from server', + { cause: { type: 'fetch', detail: 'coding_system', msg: 'Failed to retrieve coding systems, please try again.' } + })); - return (a.name > b.name) ? 1 : 0; - }); - - // Build each coding system option - for (let i = 0; i < codingSystems.length; ++i) { - const item = codingSystems[i]; - options += interpolateString(CONCEPT_CREATOR_DEFAULTS.CODING_DEFAULT_ACTIVE_OPTION, { - 'is_selected': item.value == dataset?.coding_system?.id, - 'name': item.name, - 'value': item.value, + return; + } + + // Build <select/> option HTML + let options = interpolateString(CONCEPT_CREATOR_DEFAULTS.CODING_DEFAULT_HIDDEN_OPTION, { + 'is_unselected': codingSystems.length < 1, }); - } + + // Sort alphabetically in desc. order + codingSystems.sort((a, b) => { + if (a.name < b.name) { + return -1; + } + + return (a.name > b.name) ? 1 : 0; + }); + + // Build each coding system option + for (let i = 0; i < codingSystems.length; ++i) { + const item = codingSystems[i]; + options += interpolateString(CONCEPT_CREATOR_DEFAULTS.CODING_DEFAULT_ACTIVE_OPTION, { + 'is_selected': item.value == dataset?.coding_system?.id, + 'name': item.name, + 'value': item.value, + }); + } + + resolve(options); + }) + .catch(e => { + if (isNullOrUndefined(e) || !(e instanceof Error)) { + e = new Error('Failed to retrieve coding system from server.'); + } + if (!isNullOrUndefined(e) && e instanceof Error && !isRecordType(e?.cause)) { + e.cause = { + type: 'fetch', + detail: 'coding_system', + msg: 'Failed to fetch coding systems, please try again.' + }; + } + + reject(e); + }); + }) + .then((options) => { + this.coding_data = codingSystems; return options; }); - - return promise; } /** @@ -1494,20 +1502,8 @@ export default class ConceptCreator { } } - // Only disable/enable the coding selector if not updated via the change event - if (ignoreSelection) { - return; - } - - // Don't allow users to reselect the coding system once we've created at least 1 rule + selected a system - const selector = editor.querySelector('#coding-system-select') - selector.disabled = hasCodingSystem; - - // Only enable to change event if no coding system is present - if (hasCodingSystem) { - return; - } - selector.addEventListener('change', this.#handleCodingSelection.bind(this)); + const selector = editor.querySelector('#coding-system-select'); + selector.disabled = !this.state.data?.is_new; } /** @@ -1538,7 +1534,7 @@ export default class ConceptCreator { }); const doc = parseHTMLFromString(html, true); - const item = ruleList.appendChild(doc.body.children[0]); + const item = ruleList.appendChild(doc[0]); const input = item.querySelector('input[data-item="rule"]'); // Add handler for each rule type, otherwise disable element @@ -1717,6 +1713,7 @@ export default class ConceptCreator { ], classes: { wrapper: 'overflow-table-constraint', + container: 'datatable-container slim-scrollbar', }, data: { headings: [ @@ -1742,11 +1739,12 @@ export default class ConceptCreator { /** * tryRenderEditor [async] * @desc async method to render the editor when a user enters the editor state - * @param {node} conceptGroup the concept group node related to the Concept being edited - * @param {object} dataset the concept dataset + * @param {node} conceptGroup the concept group node related to the Concept being edited + * @param {object} dataset the concept dataset + * @param {object} codingSystems a set of available coding systems (selected by the concept) * @returns {node} the editor element */ - async #tryRenderEditor(conceptGroup, dataset) { + async #tryRenderEditor(conceptGroup, dataset, codingSystems) { const conceptId = conceptGroup.getAttribute('data-concept-id'); const historyId = conceptGroup.getAttribute('data-concept-history-id'); @@ -1761,23 +1759,22 @@ 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': strictSanitiseString(dataset?.details?.name), 'coding_system_id': dataset?.coding_system?.id, - 'coding_system_options': systemOptions, + 'coding_system_options': codingSystems, 'has_inclusions': false, 'has_exclusions': false, }); const doc = parseHTMLFromString(html, true); - const editor = conceptGroup.appendChild(doc.body.children[0]); + const editor = conceptGroup.appendChild(doc[0]); this.state.data = dataset; this.state.editor = editor; this.state.element = conceptGroup; this.state.editing = { id: conceptId, history_id: historyId }; - this.#applyRulesetState({ id: dataset?.coding_system?.id, editor: editor}); + this.#applyRulesetState({ id: dataset?.coding_system?.id, editor: editor }); this.#tryRenderRulesets(); // Handle name changing @@ -1797,6 +1794,10 @@ export default class ConceptCreator { const confirmChanges = editor.querySelector('#confirm-changes'); confirmChanges.addEventListener('click', this.#handleConfirmEditor.bind(this)); + + // Handle coding system selector + const selector = editor.querySelector('#coding-system-select'); + selector.addEventListener('change', this.#handleCodingSelection.bind(this)); // Render codelist this.#tryRenderAggregatedCodelist(); @@ -1907,7 +1908,13 @@ export default class ConceptCreator { } new Promise((resolve, reject) => { - window.ModalFactory.create(CONCEPT_CREATOR_TEXT.RULE_DELETION) + window.ModalFactory.create({ + title: CONCEPT_CREATOR_TEXT.RULE_DELETION.title, + content: interpolateString( + CONCEPT_CREATOR_TEXT.RULE_DELETION.content, + { brandMapping: this.parent.mapping } + ) + }) .then(resolve) .catch(reject); }) @@ -1937,14 +1944,89 @@ export default class ConceptCreator { } const target = e.target; - const selection = target.options[target.selectedIndex]; - this.state.data.coding_system = { - id: parseInt(selection.value), - name: selection.text, - description: selection.text, - }; + const dataset = this.state.data; + const selectedIndex = target.selectedIndex; + const currentSelection = dataset?.coding_system?.id ?? null; + + let selection = target.options[selectedIndex]; + if (isNullOrUndefined(selection)) { + if (isNullOrUndefined(currentSelection)) { + selection = selectedIndex; + } else { + for (let i = 0; i < target.options.length; ++i) { + const opt = target.options[i]; + if (parseInt(opt.value) === currentSelection) { + selection = i + break; + } + } + } + + target.selectedIndex = selection; + return; + } - this.#applyRulesetState({ id: selection.value, editor: this.state.editor, ignoreSelection: true }); + const codingId = parseInt(selection.value); + if (codingId === currentSelection) { + return; + } + + const hasUnsavedWork = !isNullOrUndefined(dataset) && (dataset?.aggregatedStateView?.length || dataset?.component?.length); + const hasCurrentSelection = !isNullOrUndefined(currentSelection); + + let promise; + if (hasCurrentSelection && hasUnsavedWork) { + promise = window.ModalFactory.create(CONCEPT_CREATOR_TEXT.CODING_CHANGE); + } else { + promise = new Promise((resolve) => resolve()); + } + + promise + .then(async () => { + if (hasCurrentSelection) { + dataset?.components?.splice?.(0, dataset?.components?.length); + dataset?.aggregatedStateView?.splice(0, dataset?.aggregatedStateView?.length); + } + + dataset.coding_system = { + id: codingId, + name: selection.text, + description: selection.text, + selectedIndex: selectedIndex, + }; + + this.#applyRulesetState({ id: codingId, editor: this.state.editor }); + + await this.#recalculateExclusionaryRules(); + this.#tryRenderRulesets(); + this.#tryRenderAggregatedCodelist(true); + }) + .catch((e) => { + if (!(e instanceof ModalFactory.ModalResults)) { + window.ToastFactory.push({ + type: 'error', + message: 'Failed to change codelist, please try again.', + duration: 4000, + }); + + return console.error(e); + } + + const action = e.name; + if (hasCurrentSelection && (action === 'Reject' || action === 'Cancel')) { + selection = selectedIndex; + + for (let i = 0; i < target.options.length; ++i) { + const opt = target.options[i]; + if (parseInt(opt.value) === currentSelection) { + selection = i; + break; + } + } + + target.selectedIndex = selection; + } + }); } /** @@ -1961,6 +2043,8 @@ export default class ConceptCreator { e.stopPropagation(); const value = strictSanitiseString(input.value); + input.value = value; + if (!input.checkValidity() || isNullOrUndefined(value) || isStringEmpty(value)) { input.classList.add('fill-accordion__name-input--invalid'); return; @@ -1983,13 +2067,13 @@ export default class ConceptCreator { const searchBtn = input.parentNode.querySelector('.code-text-input__icon'); if (!isNullOrUndefined(searchBtn)) { searchBtn.addEventListener('click', (e) => { - input.dispatchEvent(new KeyboardEvent('keyup', { keyCode: CONCEPT_CREATOR_KEYCODES.ENTER })); + input.dispatchEvent(new KeyboardEvent('keyup', { code: 'Enter', keyCode: 13 })); }); } input.addEventListener('keyup', (e) => { - const code = e.keyIdentifier || e.which || e.keyCode; - if (code != CONCEPT_CREATOR_KEYCODES.ENTER) { + const code = e.code; + if (code !== 'Enter') { return; } @@ -2188,7 +2272,13 @@ export default class ConceptCreator { .then(result => { spinner = startLoadingSpinner(); if (!this.#isConceptRuleImportDistinct(result, logicalType)) { - this.#pushToast({ type: 'danger', message: CONCEPT_CREATOR_TEXT.CONCEPT_RULE_IS_PRESENT}); + this.#pushToast({ + type: 'danger', + message: interpolateString( + CONCEPT_CREATOR_TEXT.CONCEPT_RULE_IS_PRESENT, + { brandMapping: this.parent.mapping } + ) + }); return; } @@ -2199,9 +2289,10 @@ export default class ConceptCreator { this.#tryAddNewRule(logicalType, sourceType, result); this.#pushToast({ type: 'success', - message: interpolateString(CONCEPT_CREATOR_TEXT.ADDED_CONCEPT_CODES, { - code_len: result?.codelist.length.toLocaleString(), - }) + message: interpolateString( + CONCEPT_CREATOR_TEXT.ADDED_CONCEPT_CODES, + { brandMapping: this.parent.mapping, code_len: result?.codelist.length.toLocaleString() } + ) }); return; @@ -2214,7 +2305,13 @@ export default class ConceptCreator { }) .catch(e => { if (!isNullOrUndefined(e)) { - this.#pushToast({ type: 'danger', message: CONCEPT_CREATOR_TEXT.NO_CONCEPT_MATCH}); + this.#pushToast({ + type: 'danger', + message: interpolateString( + CONCEPT_CREATOR_TEXT.NO_CONCEPT_MATCH, + { brandMapping: this.parent.mapping } + ) + }); console.error(e); return; } @@ -2243,6 +2340,8 @@ export default class ConceptCreator { #handleConceptNameChange(e) { const input = e.target; const value = strictSanitiseString(input.value); + input.value = value; + if (!input.checkValidity() || isNullOrUndefined(value) || isStringEmpty(value)) { return; } @@ -2259,7 +2358,7 @@ export default class ConceptCreator { * @param {event} e the associated event */ #handleCancelEditor(e) { - const conceptGroup = tryGetRootElement(e.target, 'concept-list__group'); + const conceptGroup = tryGetRootElement(e.target, '.concept-list__group'); this.tryCloseEditor() .then((res) => { this.#toggleConcept(conceptGroup); @@ -2281,12 +2380,24 @@ export default class ConceptCreator { // Validate the concept data if (isNullOrUndefined(data?.details?.name) || isStringEmpty(data?.details?.name)) { - this.#pushToast({ type: 'danger', message: CONCEPT_CREATOR_TEXT.REQUIRE_CONCEPT_NAME }); + this.#pushToast({ + type: 'danger', + message: interpolateString( + CONCEPT_CREATOR_TEXT.REQUIRE_CONCEPT_NAME, + { brandMapping: this.parent.mapping } + ), + }); return; } if (isNullOrUndefined(data?.coding_system)) { - this.#pushToast({ type: 'danger', message: CONCEPT_CREATOR_TEXT.REQUIRE_CODING_SYSTEM}); + this.#pushToast({ + type: 'danger', + message: interpolateString( + CONCEPT_CREATOR_TEXT.REQUIRE_CODING_SYSTEM, + { brandMapping: this.parent.mapping } + ), + }); return; } @@ -2352,7 +2463,6 @@ export default class ConceptCreator { this.#tryRenderConceptComponent(data); } this.#toggleNoConceptBox(this.data.length > 0); - this.#hideAttributeSettingsButton(this.data.length <= 0); if (failedImports.length > 0) { this.#pushToast({ @@ -2364,32 +2474,23 @@ export default class ConceptCreator { } }) .catch((e) => { - if (!isNullOrUndefined(e)) { - console.error(e); + if (!!e && !(e instanceof ModalFactory.ModalResults)) { + console.error(`[ConceptCreator->handleConceptImporting] Failed with err:\n\n${e}\n`); } }) } - /** - * Calling the attribute settings - * @param {*} e - */ - #handleAttributeSettings(e){ - this.tryCloseEditor() - .then(() => { - return this.tryCallAttributeSettings(); - }) - } - - /** * handleConceptCreation * @desc handles the creation of a concept when the user selects the 'Add Concept' button * @param {event} e the associated event */ #handleConceptCreation(e) { + let spinner; this.tryCloseEditor() - .then(() => { + .then(async () => { + spinner = startLoadingSpinner(); + const conceptIncrement = this.#getNextConceptCount(); const concept = { is_new: true, @@ -2397,17 +2498,60 @@ export default class ConceptCreator { concept_version_id: generateUUID(), components: [ ], details: { - name: `Concept ${conceptIncrement}`, + name: `Codelist ${conceptIncrement}`, has_edit_access: true, }, + }; + + const codingRequest = await this.#fetchCodingOptions(concept) + .then(r => { + return { success: true, result: r }; + }) + .catch(e => { + if (!isNullOrUndefined(e) && e instanceof Error) { + const cause = e?.cause; + if (isRecordType(cause) && typeof cause?.message === 'string') { + return { success: false, result: e }; + } + } + + return { + success: false, + result: new Error( + 'Failed to resolve data from server', + { cause: { type: 'fetch', detail: 'coding_system', msg: 'Failed to fetch results from server, please try again.' }} + ), + }; + }); + + if (!codingRequest?.success) { + window.ToastFactory.push({ + type: 'error', + message: codingRequest.result.cause.msg, + duration: 4000, + }); + + throw codingRequest.result; + } + + if (!isNullOrUndefined(spinner)) { + spinner?.remove?.(); } const conceptGroup = this.#tryRenderConceptComponent(concept); - this.#tryRenderEditor(conceptGroup, concept); + this.#tryRenderEditor(conceptGroup, concept, codingRequest.result); this.#toggleNoConceptBox(true); - this.#hideAttributeSettingsButton(this.data.length <= 0); }) - .catch(() => { /* User does not want to lose progress, sink edit request */ }) + .catch((e) => { + if (!!e && !(e instanceof ModalFactory.ModalResults)) { + console.error(`[ConceptCreator->handleConceptCreation] Failed with err:\n\n${e}\n`); + } + }) + .finally(() => { + if (!isNullOrUndefined(spinner)) { + spinner?.remove?.(); + } + }); } /** @@ -2417,11 +2561,13 @@ export default class ConceptCreator { */ #handleEditing(target) { // If editing, prompt before continuing + let spinner, failed; return this.tryCloseEditor() - .then((res) => { - const spinner = startLoadingSpinner(); + .then(async (res) => { + spinner = startLoadingSpinner(); + const [id, history_id] = res || [ ]; - const conceptGroup = tryGetRootElement(target, 'concept-list__group'); + const conceptGroup = tryGetRootElement(target, '.concept-list__group'); const conceptId = conceptGroup.getAttribute('data-concept-id'); const historyId = conceptGroup.getAttribute('data-concept-history-id'); @@ -2435,10 +2581,55 @@ export default class ConceptCreator { let dataset = this.data.filter(concept => concept.concept_version_id == historyId && concept.concept_id == conceptId); dataset = deepCopy(dataset.shift()); - this.#tryRenderEditor(conceptGroup, dataset); - spinner.remove(); + const codingRequest = await this.#fetchCodingOptions(dataset) + .then(r => { + return { success: true, result: r }; + }) + .catch(e => { + if (!isNullOrUndefined(e) && e instanceof Error) { + const cause = e?.cause; + if (isRecordType(cause) && typeof cause?.message === 'string') { + return { success: false, result: e }; + } + } + + return { + success: false, + result: new Error( + 'Failed to resolve data from server', + { cause: { type: 'fetch', detail: 'coding_system', msg: 'Failed to fetch results from server, please try again.' }} + ), + }; + }); + + if (!codingRequest?.success) { + failed = true; + window.ToastFactory.push({ + type: 'error', + message: codingRequest.result.cause.msg, + duration: 4000, + }); + + throw codingRequest.result; + } + + if (!isNullOrUndefined(spinner)) { + spinner?.remove?.(); + } + + this.#tryRenderEditor(conceptGroup, dataset, codingRequest.result); }) - .catch(() => { /* User does not want to lose progress, sink edit request */ }) + .catch((e) => { + /* User does not want to lose progress, sink edit request */ + if (!!e && !(e instanceof ModalFactory.ModalResults)) { + console.error(`[ConceptCreator->handleEditing] Failed with err:\n\n${e}\n`); + } + }) + .finally(() => { + if (spinner) { + spinner.remove(); + } + }); } /** @@ -2449,15 +2640,24 @@ export default class ConceptCreator { */ #handleDeletion(target) { if (this.state.editing) { - this.#pushToast({ type: 'danger', message: CONCEPT_CREATOR_TEXT.REQUIRE_EDIT_CLOSURE }); + this.#pushToast({ type: 'danger', message: interpolateString( + CONCEPT_CREATOR_TEXT.REQUIRE_EDIT_CLOSURE, + { brandMapping: this.parent.mapping } + ) }); return; } return new Promise((resolve, reject) => { - window.ModalFactory.create(CONCEPT_CREATOR_TEXT.CONCEPT_DELETION).then(resolve).catch(reject); + window.ModalFactory.create({ + title: CONCEPT_CREATOR_TEXT.CONCEPT_DELETION.title, + content: interpolateString( + CONCEPT_CREATOR_TEXT.CONCEPT_DELETION.content, + { brandMapping: this.parent.mapping } + ) + }).then(resolve).catch(reject); }) .then(() => { - const conceptGroup = tryGetRootElement(target, 'concept-list__group'); + const conceptGroup = tryGetRootElement(target, '.concept-list__group'); const conceptId = conceptGroup.getAttribute('data-concept-id'); const historyId = conceptGroup.getAttribute('data-concept-history-id'); @@ -2535,20 +2735,24 @@ export default class ConceptCreator { this.#tryUpdateRenderConceptComponents(updatedConcept.concept_id, updatedConcept.concept_version_id, true); this.#pushToast({ type: 'success', - message: interpolateString(CONCEPT_CREATOR_TEXT.CONCEPT_UPDATE_SUCCESS, { - version: updatedConcept.concept_version_id.toString(), - }) + message: interpolateString( + CONCEPT_CREATOR_TEXT.CONCEPT_RULE_IS_PRESENT, + { brandMapping: this.parent.mapping, version: updatedConcept.concept_version_id.toString() } + ) }); }) .catch((e) => { - if (!isNullOrUndefined(e)) { - console.error(e); - } + if (!!e && !(e instanceof ModalFactory.ModalResults)) { + this.#pushToast({ + type: 'danger', + message: interpolateString( + CONCEPT_CREATOR_TEXT.CONCEPT_UPDATE_FAILED, + { brandMapping: this.parent.mapping } + ) + }); - this.#pushToast({ - type: 'danger', - message: CONCEPT_CREATOR_TEXT.CONCEPT_UPDATE_FAILED - }); + console.error(`[ConceptCreator->handleConceptImportUpdate] Failed with err:\n\n${e}\n`); + } }); } } 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 450b5a173..76a2c53d6 100644 --- a/CodeListLibrary_project/cll/static/js/clinicalcode/forms/clinical/conceptSelectionService.js +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/forms/clinical/conceptSelectionService.js @@ -33,11 +33,6 @@ const CSEL_BEHAVIOUR = { // Defines the output format behaviour of datetime objects DATE_FORMAT: 'YYYY-MM-DD', - // Describes keycodes for filter-related events - KEY_CODES: { - ENTER: 13, - }, - // Describes non-numerical data-value targets for pagination buttons PAGINATION: { NEXT: 'next', @@ -111,6 +106,12 @@ const CSEL_OPTIONS = { // Whether to cache the resulting queries for quicker, // albeit possibly out of date, Phenotypes and their assoc. Concepts useCachedResults: false, + + // Brand text mapping + mapping: { + phenotype: 'Phenotype', + concept: 'Concept', + }, }; /** @@ -143,8 +144,8 @@ const CSEL_INTERFACE = { TAB_VIEW: ' \ <div class="tab-view" id="tab-view"> \ <div class="tab-view__tabs tab-view__tabs-z-buffer"> \ - <button aria-label="tab" id="SEARCH" class="tab-view__tab active">Search Concepts</button> \ - <button aria-label="tab" id="SELECTION" class="tab-view__tab">Selected Concepts</button> \ + <button aria-label="tab" id="SEARCH" class="tab-view__tab active">Search ${brandMapping.concept}</button> \ + <button aria-label="tab" id="SELECTION" class="tab-view__tab">Selected ${brandMapping.concept}</button> \ </div> \ <div class="tab-view__content" id="tab-content"> \ </div> \ @@ -154,7 +155,7 @@ const CSEL_INTERFACE = { <div class="detailed-input-group fill no-margin"> \ <div class="detailed-input-group__header"> \ <div class="detailed-input-group__header-item"> \ - <p class="detailed-input-group__description">Your currently selected items:</p> \ + <pre class="detailed-input-group__description">Your currently selected items:</pre> \ </div> \ </div> \ <section class="detailed-input-group__none-available" id="no-items-selected"> \ @@ -259,6 +260,7 @@ const CSEL_INTERFACE = { <input class="fill-accordion__input" id="children-${id}" name="children-${id}" type="checkbox" /> \ <label class="fill-accordion__label" id="children-${id}" for="children-${id}" role="button" tabindex="0"> \ <span>${title}</span> \ + <span class="fill-accordion__label-icon"></span> \ </label> \ <article class="fill-accordion__container" id="data" style="padding: 0.5rem;"> \ ${content} \ @@ -408,7 +410,7 @@ const CSEL_FILTER_GENERATORS = { }); let doc = parseHTMLFromString(html, true); - let group = container.appendChild(doc.body.children[0]); + let group = container.appendChild(doc[0]); let descendants = group.querySelector('.filter-group'); for (let i = 0; i < data.options.length; ++i) { let option = data.options[i]; @@ -419,7 +421,7 @@ const CSEL_FILTER_GENERATORS = { value: option.value, }); doc = parseHTMLFromString(html, true); - descendants.appendChild(doc.body.children[0]); + descendants.appendChild(doc[0]); } return group; @@ -434,14 +436,14 @@ const CSEL_FILTER_GENERATORS = { }); let doc = parseHTMLFromString(html, true); - return container.appendChild(doc.body.children[0]); + return container.appendChild(doc[0]); }, // creates a searchbar filter group SEARCHBAR: (container, data) => { let html = CSEL_FILTER_COMPONENTS.SEARCHBAR_GROUP; let doc = parseHTMLFromString(html, true) - return container.appendChild(doc.body.children[0]); + return container.appendChild(doc[0]); }, } @@ -682,8 +684,8 @@ export class ConceptSelectionService { headers: { 'X-Target': CSEL_BEHAVIOUR.ENDPOINTS.SPECIFICATION, 'X-Requested-With': 'XMLHttpRequest', - 'Cache-Control': 'max-age=3600', - 'Pragma': 'max-age=3600', + 'Cache-Control': 'max-age=600', + 'Pragma': 'max-age=600', } } ); @@ -850,7 +852,7 @@ export class ConceptSelectionService { }); let doc = parseHTMLFromString(html, true); - let modal = document.body.appendChild(doc.body.children[0]); + let modal = document.body.appendChild(doc[0]); // create footer let footer = createElement('div', { @@ -864,19 +866,39 @@ export class ConceptSelectionService { // create buttons const buttons = { }; let confirmBtn = parseHTMLFromString(CSEL_BUTTONS.CONFIRM, true); - confirmBtn = footer.appendChild(confirmBtn.body.children[0]); + confirmBtn = footer.appendChild(confirmBtn[0]); confirmBtn.innerText = this.options.promptConfirm; let cancelBtn = parseHTMLFromString(CSEL_BUTTONS.CANCEL, true); - cancelBtn = footer.appendChild(cancelBtn.body.children[0]); + cancelBtn = footer.appendChild(cancelBtn[0]); cancelBtn.innerText = this.options.promptCancel; buttons['confirm'] = confirmBtn; buttons['cancel'] = cancelBtn; // initiate main event handling - buttons?.confirm.addEventListener('click', this.#handleConfirm.bind(this)); - buttons?.cancel.addEventListener('click', this.#handleCancel.bind(this)); + let cancelHnd, escapeHnd; + cancelHnd = (e) => { + const willClose = this.#handleCancel(e); + if (willClose) { + document.removeEventListener('keyup', escapeHnd); + } + }; + + escapeHnd = (e) => { + const activeFocusElem = document.activeElement; + if (!!activeFocusElem && activeFocusElem.matches('input, textarea, button, select')) { + return; + } + + if (e.code === 'Escape') { + cancelHnd(e); + } + }; + + document.addEventListener('keyup', escapeHnd); + buttons?.cancel?.addEventListener?.('click', cancelHnd); + buttons?.confirm?.addEventListener?.('click', this.#handleConfirm.bind(this)); // create content handler const body = container.querySelector('#target-modal-content'); @@ -887,9 +909,9 @@ export class ConceptSelectionService { let contentContainer = body; if (this.options.allowMultiple) { - html = CSEL_INTERFACE.TAB_VIEW; + html = interpolateString(CSEL_INTERFACE.TAB_VIEW, { brandMapping: this.options.mapping }, false); doc = parseHTMLFromString(html, true); - contentContainer = body.appendChild(doc.body.children[0]); + contentContainer = body.appendChild(doc[0]); const tabs = contentContainer.querySelectorAll('button.tab-view__tab'); for (let i = 0; i < tabs.length; ++i) { @@ -982,7 +1004,7 @@ export class ConceptSelectionService { // Draw page let html = CSEL_INTERFACE.SEARCH_VIEW; let doc = parseHTMLFromString(html, true); - let page = this.dialogue.content.appendChild(doc.body.children[0]); + let page = this.dialogue.content.appendChild(doc[0]); this.dialogue.page = page; // Draw content @@ -1012,7 +1034,7 @@ export class ConceptSelectionService { }); let doc = parseHTMLFromString(html, true); - let page = this.dialogue.content.appendChild(doc.body.children[0]); + let page = this.dialogue.content.appendChild(doc[0]); this.dialogue.page = page; // Draw content @@ -1049,7 +1071,6 @@ export class ConceptSelectionService { #paintSelectionList() { const page = this.dialogue.page; const selectedData = this.dialogue?.data; - console.log(selectedData) if (!this.dialogue?.view == CSEL_VIEWS.SELECTION || isNullOrUndefined(page)) { return; } @@ -1085,7 +1106,7 @@ export class ConceptSelectionService { }); let doc = parseHTMLFromString(html, true); - let checkbox = content.appendChild(doc.body.children[0]); + let checkbox = content.appendChild(doc[0]); checkbox.addEventListener('change', this.#handleSelectedItem.bind(this)); } } @@ -1199,7 +1220,7 @@ export class ConceptSelectionService { }); let doc = parseHTMLFromString(html, true); - let pagination = pageContainer.appendChild(doc.body.children[0]); + let pagination = pageContainer.appendChild(doc[0]); this.filters['page'] = { name: 'page', @@ -1253,7 +1274,7 @@ export class ConceptSelectionService { }); let doc = parseHTMLFromString(html, true); - let card = resultContainer.appendChild(doc.body.children[0]); + let card = resultContainer.appendChild(doc[0]); let datagroup = card.querySelector('#datagroup'); let childContents = ''; @@ -1274,12 +1295,12 @@ export class ConceptSelectionService { html = interpolateString(CSEL_INTERFACE.CARD_ACCORDION, { id: result?.id, - title: `Available Concepts (${children.length})`, + title: `Available ${this.options.mapping.concept} (${children.length})`, content: childContents, }); doc = parseHTMLFromString(html, true); - let accordion = datagroup.appendChild(doc.body.children[0]); + let accordion = datagroup.appendChild(doc[0]); let checkboxes = accordion.querySelectorAll('#child-selector > input[type="checkbox"]'); for (let j = 0; j < checkboxes.length; j++) { let checkbox = checkboxes[j]; @@ -1627,8 +1648,8 @@ export class ConceptSelectionService { * @param {event} e the assoc. event */ #handleSearchbarUpdate(e) { - const code = e.keyIdentifier || e.which || e.keyCode; - if (code != CSEL_BEHAVIOUR.KEY_CODES.ENTER) { + const code = e.code; + if (code !== 'Enter') { return; } diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/forms/clinical/contactListCreator.js b/CodeListLibrary_project/cll/static/js/clinicalcode/forms/clinical/contactListCreator.js new file mode 100644 index 000000000..46bb448f2 --- /dev/null +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/forms/clinical/contactListCreator.js @@ -0,0 +1,315 @@ +import { TOAST_MSG_DURATION } from '../entityFormConstants.js'; + +/** + * CONTACT LIST OPTIONS + * @desc describes the optional parameters for this class + * + */ +const CONTACT_LIST_OPTIONS = { + // The minimum message duration for toast notif popups + notificationDuration: TOAST_MSG_DURATION, + + /* Attribute name(s) */ + // - dataAttribute: defines the attribute that's used to retrieve contextual data + dataAttribute: 'data-field', + // - targetAttribute: defines the data target for individual elements which defines their index + targetAttribute: 'data-target', + + /* Related element IDs */ + // - textInputId: describes the text input box for contact name + textInputId: '#publication-input-box', + // - emailInputId: describes the email text input box + emailInputId: '#doi-input-box', + // - addButtonId: describes the 'Add' button used to add contacts + addButtonId: '#add-input-btn', + // - availabilityId: describes the 'No available contacts' element + availabilityId: '#no-available-publications', + // - publicationGroupId: describes the parent element of the contact list + publicationGroupId: '#publication-group', + // - publicationListId: describes the contact list in which elements are held + publicationListId: '#publication-list', +}; + + +/** + * CONTACT_ITEM_ELEMENT + * @desc describes the contact item element and its interpolable targets + * + */ +const CONTACT_ITEM_ELEMENT = '<div class="publication-list-group__list-item" data-target="${index}"> \ + <div class="publication-list-group__list-item-names"> \ + <p> \ + ${name}${emailElement} \ + </p> \ + </div> \ + <button class="publication-list-group__list-item-btn" data-target="${index}"> \ + <span class="delete-icon"></span> \ + <span>Remove</span> \ + </button> \ +</div>'; + + +/** + * CONTACT_EMAIL_ELEMENT + * @desc describes the contact email element + * and its interpolable targets + * + */ +const CONTACT_EMAIL_ELEMENT = '<br/><br/><a href="mailto:{email}">${email}</a>'; + + +/** + * CONTACT_NOTIFICATIONS + * @desc notification text that is used to present information + * to the client, _e.g._ to inform them of a validation + * error, or to confirm them of a forced change _etc_ + * + */ +const CONTACT_NOTIFICATIONS = { + // e.g. in the case of a user providing a email + // that isn't matched by utils.js' `CLU_EMAIL_PATTERN` regex + InvalidEmailProvided: 'We couldn\'t validate the email you provided. Are you sure it\'s correct?', +} + + +/** + * @class ContactListCreator + * @desc A class that can be used to control publication lists + * + * e.g. + * + ```js + // initialise + const startValue = [ + { name: 'some publication title', email: 'email@email.com' }, + { name: 'some other title', email: 'email@email.com' } + ]; + + const element = document.querySelector('#publication-component'); + const creator = new ContactListCreator(element, startValue); + + // ...when retrieving data + if (creator.isDirty()) { + const data = creator.getData(); + + // TODO: some save method + + + } + ``` + * + */ +export default class ContactListCreator { + constructor(element, data, options) { + this.data = Array.isArray(data) ? data : [ ]; + this.dirty = false; + this.element = element; + + // parse opts + if (!isObjectType(options)) { + options = { }; + } + this.options = mergeObjects(options, CONTACT_LIST_OPTIONS); + + // init + this.#setUp(); + this.#redrawPublications(); + } + + /************************************* + * * + * Getter * + * * + *************************************/ + /** + * getData + * @returns {object} the publication data + */ + getData() { + return this.data; + } + + /** + * getElement + * @returns {node} the assoc. element + */ + getElement() { + return this.element; + } + + /** + * isDirty + * @returns {bool} returns the dirty state of this component + */ + isDirty() { + return this.dirty; + } + + /************************************* + * * + * Setter * + * * + *************************************/ + /** + * makeDirty + * @desc informs the top-level parent that we're dirty + * and updates our internal dirty state + * @return {object} return this for chaining + */ + makeDirty() { + window.entityForm.makeDirty(); + this.dirty = true; + return this; + } + + /************************************* + * * + * Render * + * * + *************************************/ + /** + * drawItem + * @param {int} index the index of the publication in our data + * @param email the contact email + * @param {string} name the contact name + * @returns {string} html string representing the element + */ + #drawItem(index, email, name) { + let emailElement; + if (!isNullOrUndefined(email) && !isStringEmpty(email)) { + emailElement = interpolateString(CONTACT_EMAIL_ELEMENT, { email: email }); + } else { + emailElement = ''; + } + + return interpolateString(CONTACT_ITEM_ELEMENT, { + index: index, + emailElement: emailElement, + name: name + }); + } + + /** + * redrawPublications + * @desc redraws the entire publication list + */ + #redrawPublications() { + this.dataResult.innerText = JSON.stringify(this.data); + this.renderables.list.innerHTML = ''; + + if (this.data.length > 0) { + this.renderables.group.classList.add('show'); + this.renderables.none.classList.remove('show'); + + for (let i = 0; i < this.data.length; ++i) { + const node = this.#drawItem(i, this.data[i]?.email, this.data[i]?.name); + this.renderables.list.insertAdjacentHTML('beforeend', node); + } + + return; + } + + this.renderables.none.classList.add('show'); + this.renderables.group.classList.remove('show'); + } + + /** + * setUp + * @desc initialises the publication component + */ + #setUp() { + this.nameInput = this.element.querySelector(this.options.textInputId); + this.emailInput = this.element.querySelector(this.options.emailInputId); + + this.addButton = this.element.querySelector(this.options.addButtonId); + this.addButton.addEventListener('click', this.#handleInput.bind(this)); + window.addEventListener('click', this.#handleClick.bind(this)); + + const noneAvailable = this.element.parentNode.querySelector(this.options.availabilityId); + const publicationGroup = this.element.parentNode.querySelector(this.options.publicationGroupId); + const publicationList = this.element.parentNode.querySelector(this.options.publicationListId); + this.renderables = { + none: noneAvailable, + group: publicationGroup, + list: publicationList, + }; + + const attr = this.element.getAttribute(this.options.dataAttribute); + this.dataResult = this.element.parentNode.querySelector(`[for="${attr}"]`); + } + + /************************************* + * * + * Events * + * * + *************************************/ + /** + * handleInput + * @desc bindable event handler for key up events of the publication input box + * @param {event} e the event of the input + */ + #handleInput(e) { + e.preventDefault(); + e.stopPropagation(); + + const email = strictSanitiseString(this.emailInput.value); + const name = strictSanitiseString(this.nameInput.value); + + if (!this.nameInput.checkValidity() || isNullOrUndefined(name) || isStringEmpty(name)) { + window.ToastFactory.push({ + type: 'danger', + message: 'You must provide a name for the contact', + duration: this.options.notificationDuration, + }); + + this.emailInput.value = email; + this.nameInput.value = name; + + return; + } + + const matches = parseString(email.toLowerCase(), CLU_EMAIL_PATTERN); + if (!matches?.[0]) { + window.ToastFactory.push({ + type: 'danger', + message: CONTACT_NOTIFICATIONS.InvalidEmailProvided, + duration: this.options.notificationDuration, + }); + } else { + this.emailInput.value = ''; + this.nameInput.value = ''; + this.data.push({ + name: name, + email: matches?.[0] + }); + + this.makeDirty(); + this.#redrawPublications(); + } + } + + /** + * handleClick + * @desc bindable event handler for click events of the publication item's delete button + * @param {event} e the event of the input + */ + #handleClick(e) { + const target = e.target; + if (!target || !this.renderables.list.contains(target)) { + return; + } + + if (target.nodeName != 'BUTTON') { + return; + } + + const index = target.getAttribute(this.options.targetAttribute); + if (isNullOrUndefined(index)) { + return; + } + + this.data.splice(parseInt(index), 1); + this.#redrawPublications(); + this.makeDirty(); + } +} diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/forms/clinical/endorsementCreator.js b/CodeListLibrary_project/cll/static/js/clinicalcode/forms/clinical/endorsementCreator.js index e7acb464e..af3db778a 100644 --- a/CodeListLibrary_project/cll/static/js/clinicalcode/forms/clinical/endorsementCreator.js +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/forms/clinical/endorsementCreator.js @@ -36,23 +36,23 @@ import { } from '../entityFormConstants.js'; /** - * endorsement_ITEM_ELEMENT + * ENDORSEMENT_ITEM_ELEMENT * @desc describes the endorsement item element and its interpolable targets * */ -const endorsement_ITEM_ELEMENT = - '<div class="publication-list-group__list-item" data-target="${index}" style="display: flex; justify-content: space-between; align-items: center;"> \ - <div class="publication-list-group__list-item-url" style="flex: 1;">\ - <p style="margin: 0;">${date}</p> \ - </div>\ - <div class="publication-list-group__list-item-date" style="flex: 1; text-align: center;">\ - <p style="margin: 0;">${endorsement_organisation}</p> \ - </div>\ - <button class="publication-list-group__list-item-btn" data-target="${index}" style="margin-left: 10px;"> \ - <span class="delete-icon"></span> \ - <span>Remove</span> \ - </button> \ -</div>'; +const ENDORSEMENT_ITEM_ELEMENT = '\ + <div class="publication-list-group__list-item" data-target="${index}"> \ + <div class="publication-list-group__list-item-names">\ + <p>${endorsement_organisation}</p> \ + </div>\ + <div class="publication-list-group__list-item-bhfdate">\ + <p>${date}</p> \ + </div>\ + <button class="publication-list-group__list-item-btn" data-target="${index}"> \ + <span class="delete-icon"></span> \ + <span>Remove</span> \ + </button> \ + </div>'; /** * @class endorsementCreator @@ -94,7 +94,7 @@ export default class endorsementCreator { // init this.#setUp(); - this.#redrawendorsements(); + this.#redrawEndorsements(); } /************************************* @@ -150,24 +150,25 @@ export default class endorsementCreator { *************************************/ /** * drawItem - * @param {integer} index the index of the endorsement in our data + * @param {number} index the index of the endorsement in our data * @param {string} endorsement the endorsement name + * @param {string} date the endorsement date + * * @returns {string} html string representing the element */ - #drawItem(index, endorsement_organisation,date) { - - return interpolateString(endorsement_ITEM_ELEMENT, { + #drawItem(index, endorsement, date) { + return interpolateString(ENDORSEMENT_ITEM_ELEMENT, { index: index, date: date, - endorsement_organisation: endorsement_organisation + endorsement_organisation: endorsement, }); } /** - * redrawendorsements + * redrawEndorsements * @desc redraws the entire endorsement list */ - #redrawendorsements() { + #redrawEndorsements() { this.dataResult.innerText = JSON.stringify(this.data); this.renderables.list.innerHTML = ""; @@ -177,8 +178,8 @@ export default class endorsementCreator { for (let i = 0; i < this.data.length; ++i) { const node = this.#drawItem( i, + this.data[i]?.endorsement_organisation, this.data[i]?.date, - this.data[i]?.endorsement_organisation ); this.renderables.list.insertAdjacentHTML("beforeend", node); } @@ -196,30 +197,23 @@ export default class endorsementCreator { */ #setUp() { this.endorsementInput = this.element.querySelector(this.options.textInputId); + this.datepickerElement = this.element.querySelector(this.options.endorsementDatepickerId); - this.datepicker = ENTITY_HANDLERS['datepicker'](this.element.querySelector(this.options.endorsementDatepickerId), []) + this.datepicker = ENTITY_HANDLERS['datepicker'](this.datepickerElement, []); - let initialDate = moment(this.element.querySelector(this.options.endorsementDatepickerId).getAttribute('data-value'), ENTITY_ACCEPTABLE_DATE_FORMAT); + let initialDate = moment(this.datepickerElement.getAttribute('data-value'), ENTITY_ACCEPTABLE_DATE_FORMAT); initialDate = initialDate.isValid() ? initialDate : moment(); initialDate = initialDate.format('DD/MM/YYYY'); this.datepicker.setDate(initialDate,true); - - this.element.querySelector(this.options.endorsementDatepickerId).setAttribute('data-value',initialDate); - + this.datepickerElement.setAttribute('data-value', initialDate); this.addButton = this.element.querySelector(this.options.addButtonId); this.addButton.addEventListener("click", this.#handleInput.bind(this)); window.addEventListener("click", this.#handleClick.bind(this)); - const noneAvailable = this.element.parentNode.querySelector( - this.options.availabilityId - ); - const endorsementGroup = this.element.parentNode.querySelector( - this.options.endorsementGroupId - ); - const endorsementList = this.element.parentNode.querySelector( - this.options.endorsementListId - ); + const noneAvailable = this.element.parentNode.querySelector(this.options.availabilityId); + const endorsementGroup = this.element.parentNode.querySelector(this.options.endorsementGroupId); + const endorsementList = this.element.parentNode.querySelector(this.options.endorsementListId); this.renderables = { none: noneAvailable, group: endorsementGroup, @@ -245,30 +239,39 @@ export default class endorsementCreator { e.stopPropagation(); const endorsement = strictSanitiseString(this.endorsementInput.value); - const date = this.element.querySelector(this.options.endorsementDatepickerId); if (!this.endorsementInput.checkValidity() || isNullOrUndefined(endorsement) || isStringEmpty(endorsement)) { window.ToastFactory.push({ type: 'danger', - message: "Incorrect endorsement details provided", + message: 'Incorrect endorsement details provided', duration: this.options.notificationDuration, }); + this.endorsementInput.value = endorsement; + return; } - if (!date.getAttribute('data-value')) { + const picker = this.datepickerElement; + const dateValue = picker ? picker.getAttribute('data-value') : null; + if (!dateValue) { + window.ToastFactory.push({ + type: 'danger', + message: 'Date is required for endorsement', + duration: this.options.notificationDuration, + }); + return; } - this.endorsementInput.value = ""; - let filteredDate = moment(date.getAttribute('data-value'), ENTITY_ACCEPTABLE_DATE_FORMAT); + let filteredDate = moment(dateValue, ENTITY_ACCEPTABLE_DATE_FORMAT); filteredDate = filteredDate.isValid() ? filteredDate : moment(); filteredDate = filteredDate.format('DD/MM/YYYY'); - this.element.querySelector(this.options.endorsementDatepickerId).setAttribute('data-value',filteredDate); - - this.data.push({ endorsement_organisation: endorsement, date: date.getAttribute('data-value')}); - this.makeDirty(); + picker.setAttribute('data-value', filteredDate); + + this.endorsementInput.value = ''; + this.data.push({ endorsement_organisation: endorsement, date: filteredDate }); - this.#redrawendorsements(); + this.makeDirty(); + this.#redrawEndorsements(); } /** @@ -292,7 +295,7 @@ export default class endorsementCreator { } this.data.splice(parseInt(index), 1); - this.#redrawendorsements(); + this.#redrawEndorsements(); this.makeDirty(); } } diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/forms/clinical/publicationCreator.js b/CodeListLibrary_project/cll/static/js/clinicalcode/forms/clinical/publicationCreator.js index 9566e73db..612e5eab4 100644 --- a/CodeListLibrary_project/cll/static/js/clinicalcode/forms/clinical/publicationCreator.js +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/forms/clinical/publicationCreator.js @@ -1,4 +1,4 @@ -import { PUBLICATION_MIN_MSG_DURATION } from '../entityFormConstants.js'; +import { TOAST_MSG_DURATION } from '../entityFormConstants.js'; /** * PUBLICATION_OPTIONS @@ -7,7 +7,7 @@ import { PUBLICATION_MIN_MSG_DURATION } from '../entityFormConstants.js'; */ const PUBLICATION_OPTIONS = { // The minimum message duration for toast notif popups - notificationDuration: PUBLICATION_MIN_MSG_DURATION, + notificationDuration: TOAST_MSG_DURATION, /* Attribute name(s) */ // - dataAttribute: defines the attribute that's used to retrieve contextual data @@ -39,12 +39,12 @@ const PUBLICATION_OPTIONS = { * */ const PUBLICATION_ITEM_ELEMENT = '<div class="publication-list-group__list-item" data-target="${index}"> \ - <div class="publication-list-group__list-item-url"> \ - <p>${publication}${doiElement}</p> \ + <div class="publication-list-group__list-item-names"> \ + <p> \ + ${primary === 1 ? \'<span class="publication-list-group__list-item--is-primary"></span>\' : \'\'} \ + ${publication}${doiElement} \ + </p> \ </div> \ - <div class="publication-list-group__list-item-primary" style="flex: 1; text-align: center">\ - \${primary === 1 ? \'<p><strong>Primary</strong></p>\' : \'\'}\ - </div>\ <button class="publication-list-group__list-item-btn" data-target="${index}"> \ <span class="delete-icon"></span> \ <span>Remove</span> \ @@ -58,7 +58,7 @@ const PUBLICATION_ITEM_ELEMENT = '<div class="publication-list-group__list-item" * and its interpolable targets * */ -const PUBLICATION_DOI_ELEMENT = '<br/><br/><a href="https://doi.org/${doi}">${doi}</a>'; +const PUBLICATION_DOI_ELEMENT = '<br/><br/><a href="https://doi.org/${doi}" target=_blank rel="noopener">${doi}</a>'; /** @@ -201,11 +201,41 @@ export default class PublicationCreator { #redrawPublications() { this.dataResult.innerText = JSON.stringify(this.data); this.renderables.list.innerHTML = ''; - + if (this.data.length > 0) { this.renderables.group.classList.add('show'); this.renderables.none.classList.remove('show'); + this.data.sort((a, b) => { + let { details: t0, primary: p0 } = a; + p0 = typeof p0 === 'boolean' ? Number(p0) : p0; + + let { details: t1, primary: p1 } = b; + p1 = typeof p1 === 'boolean' ? Number(p1) : p1; + + const twoPrimary = typeof p0 === 'number' && typeof p1 === 'number'; + const equalPrimary = p0 === p1; + if (twoPrimary && !equalPrimary) { + return p0 > p1 ? -1 : 1; + } else if (!twoPrimary || (twoPrimary && !equalPrimary)) { + if (typeof p0 === 'number') { + return -1; + } else if (typeof p1 === 'number') { + return 1; + } + } + + if (typeof t0 === 'string' && typeof t1 === 'string') { + return t0 < t1 ? -1 : (t0 > t1 ? 1 : 0); + } else if (typeof t0 === 'string') { + return -1; + } else if (typeof t1 === 'string') { + return 1; + } + + return 0; + }); + for (let i = 0; i < this.data.length; ++i) { const node = this.#drawItem(i, this.data[i]?.doi, this.data[i]?.details, this.data[i]?.primary); this.renderables.list.insertAdjacentHTML('beforeend', node); @@ -238,7 +268,7 @@ export default class PublicationCreator { none: noneAvailable, group: publicationGroup, list: publicationList, - } + }; const attr = this.element.getAttribute(this.options.dataAttribute); this.dataResult = this.element.parentNode.querySelector(`[for="${attr}"]`); @@ -258,14 +288,22 @@ export default class PublicationCreator { e.preventDefault(); e.stopPropagation(); - const publication = strictSanitiseString(this.publicationInput.value); const doi = strictSanitiseString(this.doiInput.value); - const primary= Number(this.primaryPubCheckbox.checked ? this.primaryPubCheckbox.dataset.value: '0'); + const publication = strictSanitiseString(this.publicationInput.value); if (!this.publicationInput.checkValidity() || isNullOrUndefined(publication) || isStringEmpty(publication)) { + window.ToastFactory.push({ + type: 'danger', + message: 'Cannot add a publication without adding the publication details.', + duration: this.options.notificationDuration, + }); + + this.doiInput.value = doi; + this.publicationInput.value = publication; return; } + const primary = Number(this.primaryPubCheckbox.checked ? this.primaryPubCheckbox.dataset.value: '0'); const matches = parseString(doi, CLU_DOI_PATTERN); if (!matches?.[0]) { window.ToastFactory.push({ @@ -283,8 +321,8 @@ export default class PublicationCreator { doi: matches?.[0], primary: primary }); + this.makeDirty(); - this.#redrawPublications(); } diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/forms/clinical/referenceCreator.js b/CodeListLibrary_project/cll/static/js/clinicalcode/forms/clinical/referenceCreator.js new file mode 100644 index 000000000..bb076bb12 --- /dev/null +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/forms/clinical/referenceCreator.js @@ -0,0 +1,304 @@ +import { TOAST_MSG_DURATION } from '../entityFormConstants.js'; + +/** + * REFERENCE_OPTIONS + * @desc describes the optional parameters for this class + * + */ +const REFERENCE_OPTIONS = { + // The minimum message duration for toast notif popups + notificationDuration: TOAST_MSG_DURATION, + + /* Attribute name(s) */ + // - dataAttribute: defines the attribute that's used to retrieve contextual data + dataAttribute: 'data-field', + // - targetAttribute: defines the data target for individual elements which defines their index + targetAttribute: 'data-target', + + /* Related element IDs */ + // - textInputId: describes the text input box for reference title + titleInputId: '#reference-title-input-box', + // - urlInputId: describes the url text input box + urlInputId: '#url-input-box', + // - primaryPubCheckboxId: describes the primary reference checkbox + addButtonId: '#add-input-btn', + // - availabilityId: describes the 'No available reference's element + availabilityId: '#no-available-references', + // - referenceGroupId: describes the parent element of the reference list + referenceGroupId: '#reference-group', + // - referenceListId: describes the reference list in which elements are held + referenceListId: '#reference-list', +}; + + +/** + * REFERENCE_ITEM_ELEMENT + * @desc describes the reference item element and its interpolable targets + * + */ +const REFERENCE_ITEM_ELEMENT = '<div class="publication-list-group__list-item" data-target="${index}"> \ + <div class="publication-list-group__list-item-url"> \ + <p>title: ${title}   url: <a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a></p> \ + </div> \ + <button class="publication-list-group__list-item-btn" data-target="${index}"> \ + <span class="delete-icon"></span> \ + <span>Remove</span> \ + </button> \ +</div>'; + + +/** + * REFERENCE_NOTIFICATIONS + * @desc notification text that is used to present information + * to the client, _e.g._ to inform them of a validation + * error, or to confirm them of a forced change _etc_ + * + */ +const REFERENCE_NOTIFICATIONS = { + // e.g. in the case of a user providing a DOI + // that isn't matched by utils.js' `CLU_DOI_PATTERN` regex + InvalidURLProvided: 'We couldn\'t validate the url you provided. Are you sure it\'s correct?', +} + + +/** + * @class ReferenceCreator + * @desc A class that can be used to control reference lists + * + * e.g. + * + ```js + // initialise + const startValue = [ + { details: 'some reference title', doi?: 'some optional DOI' }, + { details: 'some other title', doi?: 'some other optional DOI' } + ]; + + const element = document.querySelector('#reference-component'); + const creator = new referenceCreator(element, startValue); + + // ...when retrieving data + if (creator.isDirty()) { + const data = creator.getData(); + + // TODO: some save method + + + } + ``` + * + */ +export default class ReferenceCreator { + constructor(element, data, options) { + this.data = Array.isArray(data) ? data : [ ]; + this.dirty = false; + this.element = element; + + // parse opts + if (!isObjectType(options)) { + options = { }; + } + this.options = mergeObjects(options, REFERENCE_OPTIONS); + + // init + this.#setUp(); + this.#redrawReferences(); + } + + /************************************* + * * + * Getter * + * * + *************************************/ + /** + * getData + * @returns {object} the reference data + */ + getData() { + return this.data; + } + + /** + * getElement + * @returns {node} the assoc. element + */ + getElement() { + return this.element; + } + + /** + * isDirty + * @returns {bool} returns the dirty state of this component + */ + isDirty() { + return this.dirty; + } + + /************************************* + * * + * Setter * + * * + *************************************/ + /** + * makeDirty + * @desc informs the top-level parent that we're dirty + * and updates our internal dirty state + * @return {object} return this for chaining + */ + makeDirty() { + window.entityForm.makeDirty(); + this.dirty = true; + return this; + } + + /************************************* + * * + * Render * + * * + *************************************/ + /** + * drawItem + * @param {int} index the index of the reference in our data + * @param {url} url the reference url + * @param {string} title the reference name + * @returns {string} html string representing the element + */ + #drawItem(index, url, title) { + let urlElement; + if (isNullOrUndefined(url) && isStringEmpty(url)) { + urlElement = ''; + } else { + urlElement = url + } + + return interpolateString(REFERENCE_ITEM_ELEMENT, { + index: index, + title: title, + url: urlElement, + }); + } + + /** + * redrawReferences + * @desc redraws the entire reference list + */ + #redrawReferences() { + this.dataResult.innerText = JSON.stringify(this.data); + this.renderables.list.innerHTML = ''; + + if (this.data.length > 0) { + this.renderables.group.classList.add('show'); + this.renderables.none.classList.remove('show'); + + for (let i = 0; i < this.data.length; ++i) { + const node = this.#drawItem(i, this.data[i]?.url, this.data[i]?.title); + this.renderables.list.insertAdjacentHTML('beforeend', node); + } + + return; + } + + this.renderables.none.classList.add('show'); + this.renderables.group.classList.remove('show'); + } + + /** + * setUp + * @desc initialises the reference component + */ + #setUp() { + this.tileInput = this.element.querySelector(this.options.titleInputId); + this.urlInput = this.element.querySelector(this.options.urlInputId); + + this.addButton = this.element.querySelector(this.options.addButtonId); + this.addButton.addEventListener('click', this.#handleInput.bind(this)); + window.addEventListener('click', this.#handleClick.bind(this)); + + const noneAvailable = this.element.parentNode.querySelector(this.options.availabilityId); + const referenceGroup = this.element.parentNode.querySelector(this.options.referenceGroupId); + const referenceList = this.element.parentNode.querySelector(this.options.referenceListId); + this.renderables = { + none: noneAvailable, + group: referenceGroup, + list: referenceList, + } + + const attr = this.element.getAttribute(this.options.dataAttribute); + this.dataResult = this.element.parentNode.querySelector(`[for="${attr}"]`); + } + + /************************************* + * * + * Events * + * * + *************************************/ + /** + * handleInput + * @desc bindable event handler for key up events of the reference input box + * @param {event} e the event of the input + */ + #handleInput(e) { + e.preventDefault(); + e.stopPropagation(); + + const url = strictSanitiseString(this.urlInput.value); + const title = strictSanitiseString(this.tileInput.value); + + if (!this.tileInput.checkValidity() || isNullOrUndefined(title) || isStringEmpty(title)) { + window.ToastFactory.push({ + type: 'danger', + message: 'Incorrect reference details provided', + duration: this.options.notificationDuration, + }); + + this.urlInput.value = url; + this.tileInput.value = title; + + return; + } + + const matches = parseString(url, CLU_URL_PATTERN); + if (!matches?.[0]) { + window.ToastFactory.push({ + type: 'danger', + message: REFERENCE_NOTIFICATIONS.InvalidURLProvided, + duration: this.options.notificationDuration, + }); + } + + this.urlInput.value = ''; + this.tileInput.value = ''; + this.data.push({ + title: title, + url: matches?.[0] + }); + + this.makeDirty(); + this.#redrawReferences(); + } + + /** + * handleClick + * @desc bindable event handler for click events of the reference item's delete button + * @param {event} e the event of the input + */ + #handleClick(e) { + const target = e.target; + if (!target || !this.renderables.list.contains(target)) { + return; + } + + if (target.nodeName != 'BUTTON') { + return; + } + + const index = target.getAttribute(this.options.targetAttribute); + if (isNullOrUndefined(index)) { + return; + } + + this.data.splice(parseInt(index), 1); + this.#redrawReferences(); + this.makeDirty(); + } +} diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/forms/clinical/trialCreator.js b/CodeListLibrary_project/cll/static/js/clinicalcode/forms/clinical/trialCreator.js index bb22ae8fe..28b2e6e10 100644 --- a/CodeListLibrary_project/cll/static/js/clinicalcode/forms/clinical/trialCreator.js +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/forms/clinical/trialCreator.js @@ -1,4 +1,4 @@ -import { PUBLICATION_MIN_MSG_DURATION } from '../entityFormConstants.js'; +import { TOAST_MSG_DURATION } from '../entityFormConstants.js'; /** * TRIAL_OPTIONS @@ -7,7 +7,7 @@ import { PUBLICATION_MIN_MSG_DURATION } from '../entityFormConstants.js'; */ const TRIAL_OPTIONS = { // The minimum message duration for toast notif popups - notificationDuration: PUBLICATION_MIN_MSG_DURATION, + notificationDuration: TOAST_MSG_DURATION, /* Attribute name(s) */ // - dataAttribute: defines the attribute that's used to retrieve contextual data @@ -40,18 +40,15 @@ const TRIAL_OPTIONS = { * */ const TRIAL_ITEM_ELEMENT = '<div class="publication-list-group__list-item" data-target="${index}"> \ - <div class="publication-list-group__list-item-url" style="flex: 1;"> \ - <p>${id}</p> \ + <div class="publication-list-group__list-item-id"> \ + <p>${primary === 1 ? \'<span class="publication-list-group__list-item--is-primary"></span>\' : \'\'}${id}</p> \ </div> \ - <div class="publication-list-group__list-item-url" style="flex: 1;">\ - <p style="margin: 0;">${link}</p> \ + <div class="publication-list-group__list-item-url">\ + <p>${link}</p> \ </div>\ - <div class="publication-list-group__list-item-names" style="flex: 1;"> \ + <div class="publication-list-group__list-item-names"> \ <p>${name}</p> \ </div> \ - <div class="publication-list-group__list-item-primary" style="flex: 1">\ - \${primary === 1 ? \'<p><strong>Primary</strong></p>\' : \'\'}\ - </div>\ <button class="publication-list-group__list-item-btn" data-target="${index}"> \ <span class="delete-icon"></span> \ <span>Remove</span> \ @@ -64,7 +61,7 @@ const TRIAL_ITEM_ELEMENT = '<div class="publication-list-group__list-item" data- * and its interpolable targets * */ -const TRIAL_LINK_ELEMENT = '<a href="${link}">${link}</a>'; +const TRIAL_LINK_ELEMENT = '<a href="${link}" data-shrinkreplace="Trial Link" data-shrinkcontent="true" aria-label="Trial Link" target=_blank rel="noopener"></a>'; /** * TRIAL_NOTIFICATIONS @@ -74,8 +71,9 @@ const TRIAL_LINK_ELEMENT = '<a href="${link}">${link}</a>'; * */ const TRIAL_LINK_NOTIFICATIONS = { - // e.g. in the case of a user providing a DOI - // that isn't matched by utils.js' `CLU_DOI_PATTERN` regex + // e.g. in the case of a user not providing an Trial ID / Name + InvalidInput: 'You must provide the ID and Name at a minimum.', + // e.g. in the case of a user providing a Trial URL InvalidLinkProvided: 'Invalid link. Please check if it starts with "http://" or "https://"—that might be the issue.', } @@ -157,7 +155,6 @@ export default class TrialCreator { * @returns {string} html string representing the element */ #drawItem(index, id, link, name, primary) { - let linkElement; if (!isNullOrUndefined(link) && !isStringEmpty(link)) { linkElement = interpolateString(TRIAL_LINK_ELEMENT, { link: link }); @@ -166,12 +163,11 @@ export default class TrialCreator { } return interpolateString(TRIAL_ITEM_ELEMENT, { - index: index, id: id, - link: linkElement, name: name, + link: linkElement, + index: index, primary: primary - }); } @@ -187,6 +183,37 @@ export default class TrialCreator { this.renderables.group.classList.add('show'); this.renderables.none.classList.remove('show'); + // TODO: we should have binary inserted the elems... + this.data.sort((a, b) => { + let { id: t0, primary: p0 } = a; + p0 = typeof p0 === 'boolean' ? Number(p0) : p0; + + let { id: t1, primary: p1 } = b; + p1 = typeof p1 === 'boolean' ? Number(p1) : p1; + + const twoPrimary = typeof p0 === 'number' && typeof p1 === 'number'; + const equalPrimary = p0 === p1; + if (twoPrimary && !equalPrimary) { + return p0 > p1 ? -1 : 1; + } else if (!twoPrimary || (twoPrimary && !equalPrimary)) { + if (typeof p0 === 'number') { + return -1; + } else if (typeof p1 === 'number') { + return 1; + } + } + + if (typeof t0 === 'string' && typeof t1 === 'string') { + return t0 < t1 ? -1 : (t0 > t1 ? 1 : 0); + } else if (typeof t0 === 'string') { + return -1; + } else if (typeof t1 === 'string') { + return 1; + } + + return 0; + }); + for (let i = 0; i < this.data.length; ++i) { const node = this.#drawItem(i, this.data[i]?.id, this.data[i]?.link, this.data[i].name, this.data[i]?.primary); this.renderables.list.insertAdjacentHTML('beforeend', node); @@ -242,15 +269,30 @@ export default class TrialCreator { e.stopPropagation(); const id = strictSanitiseString(this.regId.value); - const link = strictSanitiseString(this.regLink.value); const name = strictSanitiseString(this.trialName.value); - const primary= Number(this.primaryTrialCheckbox.checked ? this.primaryTrialCheckbox.dataset.value: '0'); + const primary = Number(this.primaryTrialCheckbox.checked ? this.primaryTrialCheckbox.dataset.value : '0'); + + if (!stringHasChars(id) || !stringHasChars(name)) { + window.ToastFactory.push({ + type: 'error', + message: TRIAL_LINK_NOTIFICATIONS.InvalidInput, + duration: this.options.notificationDuration, + }); + + return; + } + + let link = strictSanitiseString(this.regLink.value); + link = typeof link === 'string' ? link.trim() : ''; + + if (!link.startsWith('http')) { + link = `https://${link}`; + } const matches = parseString(link, CLU_TRIAL_LINK_PATTERN); if (!matches?.[0]) { - window.ToastFactory.push( - { - type: 'danger', + window.ToastFactory.push({ + type: 'warning', message: TRIAL_LINK_NOTIFICATIONS.InvalidLinkProvided, duration: this.options.notificationDuration, }); @@ -260,16 +302,14 @@ export default class TrialCreator { this.regLink.value = ''; this.trialName.value = ''; this.primaryTrialCheckbox.checked = false; - this.data.push( - { - id: id, - link: matches?.[0], - name: name, - primary: primary - } - ); + this.data.push({ + id: id, + link: matches?.[0], + name: name, + primary: primary + }); + this.makeDirty(); - this.#redrawTrials(); } 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 e3c13c4d7..68d94eaf0 100644 --- a/CodeListLibrary_project/cll/static/js/clinicalcode/forms/entityCreator/creator.js +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/forms/entityCreator/creator.js @@ -9,7 +9,7 @@ import { ENTITY_FIELD_COLLECTOR, getTemplateFields, createFormHandler, - tryGetFieldTitle + tryGetFieldTitle, } from './utils.js'; /** @@ -157,7 +157,7 @@ export default class EntityCreator { }); } - return this.form?.group?.value; + return this.form?.organisation?.value; } return groupId; @@ -329,16 +329,31 @@ export default class EntityCreator { const message = e?.message; if (!message) { this.#handleServerError(e); + return; } const { type: errorType, errors } = message; console.error(`API Error<${errorType}> occurred:`, errors); - window.ToastFactory.push({ - type: 'danger', - message: ENTITY_TEXT_PROMPTS.API_ERROR_INFORM, - duration: ENTITY_TOAST_MIN_DURATION, - }); + if (Array.isArray(errors) && errors.length > 0) { + for (let i = 0; i < errors.length; i++) { + if (!stringHasChars(errors[i])) { + continue; + } + + window.ToastFactory.push({ + type: 'danger', + message: errors[i], + duration: ENTITY_TOAST_MIN_DURATION, + }); + } + } else { + window.ToastFactory.push({ + type: 'danger', + message: stringHasChars(message) ? message : ENTITY_TEXT_PROMPTS.API_ERROR_INFORM, + duration: ENTITY_TOAST_MIN_DURATION, + }); + } }) .catch(e => this.#handleServerError); } @@ -349,15 +364,18 @@ export default class EntityCreator { * @param {*} error the server error response */ #handleServerError(error) { - if (error?.statusText) { + let message; + if (stringHasChars(error.statusText)) { console.error(error.statusText); + message = error.statusText; } else { console.error(error); + message = ENTITY_TEXT_PROMPTS.SERVER_ERROR_MESSAGE; } window.ToastFactory.push({ type: 'danger', - message: ENTITY_TEXT_PROMPTS.SERVER_ERROR_MESSAGE, + message: message, duration: ENTITY_TOAST_MIN_DURATION, }); } @@ -377,11 +395,11 @@ export default class EntityCreator { if (data.hasOwnProperty(key) || !templateFields.hasOwnProperty(key)) { continue; } - + data[key] = value; } } - + // package the data const packet = { method: this.getFormMethod(), @@ -431,7 +449,7 @@ export default class EntityCreator { * @desc iteratively collects the form data and validates it against the template data * @returns {object} which describes the form data and associated errors */ - #collectFieldData() { + #collectFieldData(init = false) { const data = { }; const errors = [ ]; for (const [field, packet] of Object.entries(this.form)) { @@ -440,7 +458,7 @@ export default class EntityCreator { } // Collect the field value & validate it - const result = ENTITY_FIELD_COLLECTOR[packet?.dataclass](field, packet, this); + const result = ENTITY_FIELD_COLLECTOR[packet?.dataclass](field, packet, this, init); if (result && result?.valid) { data[field] = result.value; continue; @@ -509,7 +527,11 @@ export default class EntityCreator { if (!metadata) { return null; } - + + if (isObjectType(packet?.validation) && isObjectType(metadata[field]?.validation)) { + return mergeObjects(packet.validation, metadata[field].validation, true, true); + } + return metadata[field]?.validation; } @@ -571,7 +593,7 @@ export default class EntityCreator { continue; } - this.form[field].handler = createFormHandler(pkg.element, cls, this.data, pkg?.validation); + this.form[field].handler = createFormHandler(pkg.element, cls, this.data, pkg?.validation, pkg); this.form[field].dataclass = cls; } @@ -579,7 +601,7 @@ export default class EntityCreator { window.addEventListener('beforeunload', this.#handleOnLeaving.bind(this), { capture: true }); } - const { data, errors } = this.#collectFieldData(); + const { data, errors } = this.#collectFieldData(true); this.initialisedData = data; } @@ -630,7 +652,7 @@ export default class EntityCreator { */ #clearErrorMessages() { // Remove appended error messages - const items = document.querySelectorAll('.detailed-input-group__error'); + const items = document.querySelectorAll('.validation-block--error'); for (let i = 0; i < items.length; ++i) { const item = items[i]; item.remove(); @@ -663,13 +685,30 @@ export default class EntityCreator { // Add __error class below title if available & the forceErrorToasts parameter was not passed if (!this.options.forceErrorToasts) { - const inputGroup = tryGetRootElement(element, 'detailed-input-group'); + const inputGroup = tryGetRootElement(element, '.detailed-input-group'); if (!isNullOrUndefined(inputGroup)) { const titleNode = inputGroup.querySelector('.detailed-input-group__title'); - const errorNode = createElement('p', { - 'aria-live': 'true', - 'className': 'detailed-input-group__error', - 'innerText': error.message, + + const errorNode = createElement('div', { + className: 'validation-block validation-block--error', + childNodes: [ + createElement('div', { + className: 'validation-block__container', + childNodes: [ + createElement('div', { + className: 'validation-block__title', + childNodes: [ + '<span class="as-icon" data-icon="" aria-hidden="true"></span>', + createElement('p', { innerText: 'Error' }), + ], + }), + createElement('p', { + className: 'validation-block__message', + innerText: error.message, + }), + ] + }) + ], }); titleNode.after(errorNode); 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 792862024..ce682166b 100644 --- a/CodeListLibrary_project/cll/static/js/clinicalcode/forms/entityCreator/utils.js +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/forms/entityCreator/utils.js @@ -1,12 +1,21 @@ import Tagify from '../../components/tagify.js'; import ConceptCreator from '../clinical/conceptCreator.js'; import GroupedEnum from '../../components/groupedEnumSelector.js'; +import ListEnum from '../../components/listEnumSelector.js'; +import AgeGroupSelector from '../generic/ageGroupSelector.js'; +import SingleSlider from '../../components/singleSlider.js'; +import DoubleRangeSlider from '../../components/doubleRangeSlider.js'; +import ContactListCreator from '../clinical/contactListCreator.js'; import PublicationCreator from '../clinical/publicationCreator.js'; import TrialCreator from '../clinical/trialCreator.js'; import EndorsementCreator from '../clinical/endorsementCreator.js'; +import ReferenceCreator from '../clinical/referenceCreator.js'; import StringInputListCreator from '../stringInputListCreator.js'; import UrlReferenceListCreator from '../generic/urlReferenceListCreator.js'; import OntologySelectionService from '../generic/ontologySelector/index.js'; +import VariableCreator from '../generic/variableCreator.js'; +import RelationSelector from '../generic/relationSelector.js'; +import IndicatorCalculationCreator from '../generic/indicatorCalculationCreator.js'; import { ENTITY_DATEPICKER_FORMAT, @@ -21,10 +30,75 @@ import { * */ export const ENTITY_HANDLERS = { + // Generates an age group component with a UDF comparator + 'age-group': (element) => { + const data = element.parentNode.querySelectorAll(`script[type="application/json"][for="${element.getAttribute('data-field')}"]`); + const packet = { }; + for (let i = 0; i < data.length; ++i) { + let datafield = data[i]; + if (!datafield.innerText.trim().length) { + continue; + } + + let type = datafield.getAttribute('data-type'); + try { + packet[type] = JSON.parse(datafield.innerText); + } + catch (e) { + console.warn(`Unable to parse datafield for GroupedEnum element with target field: ${datafield.getAttribute('for')}`); + } + } + + return new AgeGroupSelector(element, packet); + }, + + // Generates a single numeric slider component context + 'single-slider': (element) => { + const data = element.parentNode.querySelectorAll(`script[type="application/json"][for="${element.getAttribute('data-field')}"]`); + const packet = { }; + for (let i = 0; i < data.length; ++i) { + let datafield = data[i]; + if (!datafield.innerText.trim().length) { + continue; + } + + let type = datafield.getAttribute('data-type'); + try { + packet[type] = JSON.parse(datafield.innerText); + } + catch (e) { + console.warn(`Unable to parse datafield for GroupedEnum element with target field: ${datafield.getAttribute('for')}`); + } + } + + return new SingleSlider(element, packet); + }, + + // Generates a doublerangeslider component context + 'doublerangeslider': (element) => { + const data = element.parentNode.querySelectorAll(`script[type="application/json"][for="${element.getAttribute('data-field')}"]`); + const packet = { }; + for (let i = 0; i < data.length; ++i) { + let datafield = data[i]; + if (!datafield.innerText.trim().length) { + continue; + } + + let type = datafield.getAttribute('data-type'); + try { + packet[type] = JSON.parse(datafield.innerText); + } + catch (e) { + console.warn(`Unable to parse datafield for GroupedEnum element with target field: ${datafield.getAttribute('for')}`); + } + } + + return new DoubleRangeSlider(element, packet); + }, + // Generates a groupedenum component context 'groupedenum': (element) => { const data = element.parentNode.querySelectorAll(`script[type="application/json"][for="${element.getAttribute('data-field')}"]`); - const packet = { }; for (let i = 0; i < data.length; ++i) { let datafield = data[i]; @@ -44,52 +118,94 @@ export const ENTITY_HANDLERS = { return new GroupedEnum(element, packet); }, - // Generates a tagify component for an element - 'tagify': (element, dataset) => { + // Generates a listenum component context + 'listenum': (element) => { const data = element.parentNode.querySelectorAll(`script[type="application/json"][for="${element.getAttribute('data-field')}"]`); - - let value = []; - let options = []; + const packet = { }; for (let i = 0; i < data.length; ++i) { - const datafield = data[i]; - const type = datafield.getAttribute('desc-type'); + let datafield = data[i]; if (!datafield.innerText.trim().length) { continue; } + let type = datafield.getAttribute('data-type'); try { - switch (type) { - case 'options': { - options = JSON.parse(datafield.innerText); - } break; + packet[type] = JSON.parse(datafield.innerText); + } + catch (e) { + console.warn(`Unable to parse datafield for ListEnum element with target field: ${datafield.getAttribute('for')}`); + } + } - case 'value': { - value = JSON.parse(datafield.innerText); - } break; - } + return new ListEnum(element, packet); + }, + + // Generates a tagify component for an element + 'tagify': (element, dataset, validation, pkg) => { + const elem = element.parentElement; + const data = elem.querySelectorAll(`script[type="application/json"][for="${element.getAttribute('data-field')}"]`); + + let varyDataVis = parseInt(element.getAttribute('data-vis') ?? '0'); + varyDataVis = !Number.isNaN(varyDataVis) && Boolean(varyDataVis); + + const opts = { }; + for (let i = 0; i < data.length; ++i) { + const datafield = data[i]; + const name = datafield.getAttribute('data-name'); + const type = datafield.getAttribute('data-type'); + if (!stringHasChars(name) || !stringHasChars(type)) { + continue; } - catch(e) { - console.warn(`Unable to parse datafield for Tagify element with target field: ${datafield.getAttribute('for')}`); + + if (type === 'text/json' && stringHasChars(datafield.innerText)) { + try { + const dataValue = JSON.parse(datafield.innerText); + opts[name] = dataValue; + } + catch (e) { + console.warn(`Unable to parse datafield for Tagify element with target field: ${datafield.getAttribute('for')}`); + } } } + opts.value = Array.isArray(opts?.value) ? opts.value : []; + opts.options = Array.isArray(opts?.options) ? opts.options : []; + opts.behaviour = isRecordType(opts?.behaviour) ? opts.behaviour : { }; + + const tagbox = new Tagify( + element, + { + items: opts.options, + useValue: true, + behaviour: opts.behaviour, + restricted: true, + autocomplete: true, + allowDuplicates: false, + onLoad: (box) => { + for (let i = 0; i < opts.value.length; ++i) { + const item = opts.value[i]; + if (typeof item !== 'object' || !item.hasOwnProperty('name') || !item.hasOwnProperty('value')) { + continue; + } - const tagbox = new Tagify(element, { - 'autocomplete': true, - 'useValue': true, - 'allowDuplicates': false, - 'restricted': true, - 'items': options, - 'onLoad': (box) => { - for (let i = 0; i < value.length; ++i) { - const item = value[i]; - if (typeof item !== 'object' || !item.hasOwnProperty('name') || !item.hasOwnProperty('value')) { - continue; + box.addTag(item.name, item.value); } + pkg.value = tagbox.getDataValue(); + + return () => { + if (!varyDataVis) { + return; + } + + const choices = box?.options?.items?.length ?? 0; + if (choices < 1) { + elem.style.setProperty('display', 'none'); + } - box.addTag(item.name, item.value); + } } - } - }, dataset); + }, + dataset + ); return tagbox; }, @@ -184,8 +300,8 @@ export const ENTITY_HANDLERS = { const mde = new EasyMDE({ // Elem element: element, - maxHeight: '500px', - minHeight: '300px', + maxHeight: '300px', + minHeight: '200px', // Behaviour autofocus: false, @@ -204,7 +320,7 @@ export const ENTITY_HANDLERS = { // Controls status: ['lines', 'words', 'cursor'], - tabSize: 2, + tabSize: 4, toolbar: [ 'heading', 'bold', 'italic', 'strikethrough', '|', 'unordered-list', 'ordered-list', 'code', 'quote', '|', @@ -215,6 +331,38 @@ export const ENTITY_HANDLERS = { toolbarButtonClassPrefix: 'mde', }); + // mde.codemirror.on("beforeChange", (cm, change) => { + // if(change.origin === 'paste') { + // const newText = change.text + // .join('\n') + // .replace(/(?<!\r?\n)\r?\n(?!\r?\n)/g, (match, index, str) => { + // if (index > 0) { + // switch (str[index - 1]) { + // case '.': + // return '\n'; + + // case '|': + // return '\n'; + + // default: + // break; + // } + // } + + // if (index + match.length < str.length) { + // if (str.substring(index + match.length).match(/^(\s*[\u{2022}\u{2023}\u{25E6}\u{2043}\u{2219}\*\-]\s*\w+|\d\.|[A-z]\)|[A-z]\.)/iu)) { + // return '\n'; + // } + // } + + // return ' '; + // }) + // .split('\n'); + + // change.update(null, null, newText); + // } + // }); + if (!isStringEmpty(value) && !isStringWhitespace(value)) { mde.value(value); } @@ -254,6 +402,20 @@ export const ENTITY_HANDLERS = { return new UrlReferenceListCreator(element, parsed) }, + 'contact-list': (element) => { + const data = element.parentNode.querySelector(`script[type="application/json"][for="${element.getAttribute('data-field')}"]`); + + let parsed; + try { + parsed = JSON.parse(data.innerText); + } + catch (e) { + parsed = []; + } + + return new ContactListCreator(element, parsed) + }, + // Generates a clinical publication list component for an element 'clinical-publication': (element) => { const data = element.parentNode.querySelector(`script[type="application/json"][for="${element.getAttribute('data-field')}"]`); @@ -297,6 +459,21 @@ export const ENTITY_HANDLERS = { }, + 'clinical-references':(element) => { + const data = element.parentNode.querySelector(`script[type="application/json"][for="${element.getAttribute('data-field')}"]`); + + let parsed; + try { + parsed = JSON.parse(data.innerText); + } + catch (e) { + parsed = []; + } + + return new ReferenceCreator(element, parsed) + + }, + // Generates a clinical concept component for an element 'clinical-concept': (element, dataset) => { const data = element.querySelector(`script[type="application/json"][for="${element.getAttribute('data-field')}"]`); @@ -340,7 +517,102 @@ export const ENTITY_HANDLERS = { } return new OntologySelectionService(element, dataset, data); - } + }, + + // HDRN-related + 'related_entities': (element, dataset) => { + const nodes = element.querySelectorAll(`script[type="application/json"][for="${element.getAttribute('data-field')}"]`); + + const data = { }; + for (let i = 0; i < nodes.length; ++i) { + let node = nodes[i]; + + const datatype = node.getAttribute('data-type'); + if (isStringEmpty(datatype)) { + continue; + } + + let innerText = node.innerText; + if (isStringEmpty(innerText) || isStringWhitespace(innerText)) { + continue; + } + + try { + innerText = JSON.parse(innerText); + data[datatype] = innerText; + } + catch (e) { + console.warn(`Failed to parse relation selector attr "${datatype}" data:`, e) + } + } + + return new RelationSelector(element, data, { + method: dataset.method, + object: dataset.object, + templateId: dataset.template.id, + }); + }, + + 'var_data': (element) => { + const nodes = element.querySelectorAll(`script[type="application/json"][for="${element.getAttribute('data-field')}"]`); + + const data = { }; + for (let i = 0; i < nodes.length; ++i) { + let node = nodes[i]; + + const datatype = node.getAttribute('data-type'); + if (isStringEmpty(datatype)) { + continue; + } + + let innerText = node.innerText; + if (isStringEmpty(innerText) || isStringWhitespace(innerText)) { + continue; + } + + try { + innerText = JSON.parse(innerText); + data[datatype] = innerText; + } + catch (e) { + console.warn(`Failed to parse validation measures attr "${datatype}" data:`, e) + } + } + + return new VariableCreator(element, data); + }, + + 'indicator_calculation': (element) => { + const nodes = element.querySelectorAll(`script[for="${element.getAttribute('data-field')}"]`); + + const data = { }; + for (let i = 0; i < nodes.length; ++i) { + let node = nodes[i]; + + const datatype = node.getAttribute('type'); + const dataname = node.getAttribute('data-name'); + if (isStringEmpty(datatype) || isStringEmpty(dataname)) { + continue; + } + + let innerText = node.innerText; + if (isStringEmpty(innerText) || isStringWhitespace(innerText)) { + continue; + } + + try { + if (datatype === 'application/json') { + innerText = JSON.parse(innerText); + } + data[dataname] = innerText; + } + catch (e) { + console.warn(`Failed to parse indicator calculations attr "${datatype}" data:`, e) + } + } + + return new IndicatorCalculationCreator(element, data); + }, }; /** @@ -483,7 +755,7 @@ export const ENTITY_FIELD_COLLECTOR = { const endDateInput = element.querySelector(`#${id}-enddate`); const validation = packet?.validation; - const dateClosureOptional = typeof validation === 'object' && validation?.date_closure_optional; + const dateClosureOptional = !!validation && typeof validation === 'object' && validation?.closure_optional; const startValid = startDateInput.checkValidity(), endValid = endDateInput.checkValidity(); @@ -614,8 +886,8 @@ export const ENTITY_FIELD_COLLECTOR = { } }, - // Retrieves and validates groupedenum compoonents - 'groupedenum': (field, packet) => { + // Retrieves and validates age-group component(s) + 'age-group': (field, packet) => { const handler = packet.handler; const value = handler.getValue(); @@ -629,6 +901,13 @@ export const ENTITY_FIELD_COLLECTOR = { } } + if (isNullOrUndefined(value) || (isObjectType(value) && value?.comparator == 'na')) { + return { + valid: true, + value: null, + } + } + const parsedValue = parseAsFieldType(packet, value); if (!parsedValue || !parsedValue?.success) { return { @@ -644,26 +923,26 @@ export const ENTITY_FIELD_COLLECTOR = { } }, - // Retrieves and validates tagify components - 'tagify': (field, packet) => { + // Retrieves and validates groupedenum components + 'single-slider': (field, packet) => { const handler = packet.handler; - const tags = handler.getActiveTags().map(item => item.value); - + const value = handler.getValue(); + if (isMandatoryField(packet)) { - if (isNullOrUndefined(tags) || tags.length < 1) { + if (isNullOrUndefined(value)) { return { valid: false, - value: tags, - message: (isNullOrUndefined(tags) || tags.length < 1) ? ENTITY_TEXT_PROMPTS.REQUIRED_FIELD : ENTITY_TEXT_PROMPTS.INVALID_FIELD + value: value, + message: ENTITY_TEXT_PROMPTS.REQUIRED_FIELD } } } - const parsedValue = parseAsFieldType(packet, tags); + const parsedValue = parseAsFieldType(packet, value); if (!parsedValue || !parsedValue?.success) { return { valid: false, - value: tags, + value: value, message: ENTITY_TEXT_PROMPTS.INVALID_FIELD } } @@ -674,82 +953,237 @@ export const ENTITY_FIELD_COLLECTOR = { } }, - // Retrieves and validates list components - 'string_inputlist': (field, packet) => { + // Retrieves and validates groupedenum components + 'doublerangeslider': (field, packet) => { const handler = packet.handler; - const listItems = handler.getData(); + const value = handler.getValue(); if (isMandatoryField(packet)) { - if (isNullOrUndefined(listItems) || listItems.length < 1) { + if (isNullOrUndefined(value)) { return { valid: false, - value: listItems, - message: (isNullOrUndefined(listItems) || listItems.length < 1) ? ENTITY_TEXT_PROMPTS.REQUIRED_FIELD : ENTITY_TEXT_PROMPTS.INVALID_FIELD + value: value, + message: ENTITY_TEXT_PROMPTS.REQUIRED_FIELD } } } - const parsedValue = parseAsFieldType(packet, listItems); + const parsedValue = parseAsFieldType(packet, value); if (!parsedValue || !parsedValue?.success) { return { valid: false, - value: listItems, + value: value, message: ENTITY_TEXT_PROMPTS.INVALID_FIELD } } - + return { valid: true, value: parsedValue?.value } }, - // Retrieves and validates list components - 'url_list': (field, packet) => { + // Retrieves and validates groupedenum components + 'groupedenum': (field, packet) => { const handler = packet.handler; - const listItems = handler.getData(); + const value = handler.getValue(); if (isMandatoryField(packet)) { - if (isNullOrUndefined(listItems) || listItems.length < 1) { + if (isNullOrUndefined(value)) { return { valid: false, - value: listItems, - message: (isNullOrUndefined(listItems) || listItems.length < 1) ? ENTITY_TEXT_PROMPTS.REQUIRED_FIELD : ENTITY_TEXT_PROMPTS.INVALID_FIELD + value: value, + message: ENTITY_TEXT_PROMPTS.REQUIRED_FIELD } } } - const parsedValue = parseAsFieldType(packet, listItems); + const parsedValue = parseAsFieldType(packet, value); if (!parsedValue || !parsedValue?.success) { return { valid: false, - value: listItems, + value: value, message: ENTITY_TEXT_PROMPTS.INVALID_FIELD } } - + return { valid: true, value: parsedValue?.value } }, - // Retrieves and validates publication components - 'clinical-publication': (field, packet) => { + // Retrieves and validates listenum compoonents + 'listenum': (field, packet) => { const handler = packet.handler; - const publications = handler.getData(); + const value = handler.getValue(); if (isMandatoryField(packet)) { - if (isNullOrUndefined(publications) || publications.length < 1) { + if (isNullOrUndefined(value)) { return { valid: false, - value: publications, - message: (isNullOrUndefined(publications) || publications.length < 1) ? ENTITY_TEXT_PROMPTS.REQUIRED_FIELD : ENTITY_TEXT_PROMPTS.INVALID_FIELD + value: value, + message: ENTITY_TEXT_PROMPTS.REQUIRED_FIELD } } } - const parsedValue = parseAsFieldType(packet, publications); + const parsedValue = parseAsFieldType(packet, value); + if (!parsedValue || !parsedValue?.success) { + return { + valid: false, + value: value, + message: ENTITY_TEXT_PROMPTS.INVALID_FIELD + } + } + + return { + valid: true, + value: parsedValue?.value + } + }, + + // Retrieves and validates tagify components + 'tagify': (field, packet, creator, isInit) => { + const handler = packet.handler; + if (!isInit) { + const dataValue = handler.getDataValue(); + if (isMandatoryField(packet)) { + if (isNullOrUndefined(dataValue) || dataValue.length < 1) { + return { + valid: false, + value: dataValue, + message: (isNullOrUndefined(dataValue) || dataValue.length < 1) + ? ENTITY_TEXT_PROMPTS.REQUIRED_FIELD + : ENTITY_TEXT_PROMPTS.INVALID_FIELD, + }; + } + } + + const parsedValue = parseAsFieldType(packet, dataValue, handler?.options?.behaviour); + if (!parsedValue || !parsedValue?.success) { + return { + valid: false, + value: dataValue, + message: ENTITY_TEXT_PROMPTS.INVALID_FIELD, + }; + } + + return { + valid: true, + value: parsedValue?.value + }; + } + + return { valid: true, value: Array.isArray(packet?.value) ? packet.value : [] }; + }, + + // Retrieves and validates list components + 'string_inputlist': (field, packet) => { + const handler = packet.handler; + const listItems = handler.getData(); + + if (isMandatoryField(packet)) { + if (isNullOrUndefined(listItems) || listItems.length < 1) { + return { + valid: false, + value: listItems, + message: (isNullOrUndefined(listItems) || listItems.length < 1) ? ENTITY_TEXT_PROMPTS.REQUIRED_FIELD : ENTITY_TEXT_PROMPTS.INVALID_FIELD + } + } + } + + const parsedValue = parseAsFieldType(packet, listItems); + if (!parsedValue || !parsedValue?.success) { + return { + valid: false, + value: listItems, + message: ENTITY_TEXT_PROMPTS.INVALID_FIELD + } + } + + return { + valid: true, + value: parsedValue?.value + } + }, + + // Retrieves and validates list components + 'url_list': (field, packet) => { + const handler = packet.handler; + const listItems = handler.getData(); + + if (isMandatoryField(packet)) { + if (isNullOrUndefined(listItems) || listItems.length < 1) { + return { + valid: false, + value: listItems, + message: (isNullOrUndefined(listItems) || listItems.length < 1) ? ENTITY_TEXT_PROMPTS.REQUIRED_FIELD : ENTITY_TEXT_PROMPTS.INVALID_FIELD + } + } + } + + const parsedValue = parseAsFieldType(packet, listItems); + if (!parsedValue || !parsedValue?.success) { + return { + valid: false, + value: listItems, + message: ENTITY_TEXT_PROMPTS.INVALID_FIELD + } + } + + return { + valid: true, + value: parsedValue?.value + } + }, + + // Retrieves and validates contact list components + 'contact-list': (field, packet) => { + const handler = packet.handler; + const contacts = handler.getData(); + + if (isMandatoryField(packet)) { + if (isNullOrUndefined(contacts) || contacts.length < 1) { + return { + valid: false, + value: contacts, + message: (isNullOrUndefined(contacts) || contacts.length < 1) ? ENTITY_TEXT_PROMPTS.REQUIRED_FIELD : ENTITY_TEXT_PROMPTS.INVALID_FIELD + } + } + } + + const parsedValue = parseAsFieldType(packet, contacts); + if (!parsedValue || !parsedValue?.success) { + return { + valid: false, + value: contacts, + message: ENTITY_TEXT_PROMPTS.INVALID_FIELD + } + } + + return { + valid: true, + value: parsedValue?.value + } + }, + + // Retrieves and validates publication components + 'clinical-publication': (field, packet) => { + const handler = packet.handler; + const publications = handler.getData(); + + if (isMandatoryField(packet)) { + if (isNullOrUndefined(publications) || publications.length < 1) { + return { + valid: false, + value: publications, + message: (isNullOrUndefined(publications) || publications.length < 1) ? ENTITY_TEXT_PROMPTS.REQUIRED_FIELD : ENTITY_TEXT_PROMPTS.INVALID_FIELD + } + } + } + + const parsedValue = parseAsFieldType(packet, publications); if (!parsedValue || !parsedValue?.success) { return { valid: false, @@ -822,11 +1256,40 @@ export const ENTITY_FIELD_COLLECTOR = { } }, + 'clinical-references': (field, packet) => { + const handler = packet.handler; + const endorsements = handler.getData(); + + if (isMandatoryField(packet)) { + if (isNullOrUndefined(endorsements) || endorsements.length < 1) { + return { + valid: false, + value: endorsements, + message: (isNullOrUndefined(endorsements) || endorsements.length < 1) ? ENTITY_TEXT_PROMPTS.REQUIRED_FIELD : ENTITY_TEXT_PROMPTS.INVALID_FIELD + } + } + } + + const parsedValue = parseAsFieldType(packet, endorsements); + if (!parsedValue || !parsedValue?.success) { + return { + valid: false, + value: endorsements, + message: ENTITY_TEXT_PROMPTS.INVALID_FIELD + } + } + + return { + valid: true, + value: parsedValue?.value + } + }, + // Retrieves and validates MDE components 'md-editor': (field, packet) => { const handler = packet.handler; - const value = handler.editor.value(); - + + let value = handler.editor.value(); if (isMandatoryField(packet)) { if (isNullOrUndefined(value) || isStringEmpty(value)) { return { @@ -894,6 +1357,66 @@ export const ENTITY_FIELD_COLLECTOR = { valid: true, value: data, } + }, + + // HDRN-related + 'related_entities': (field, packet) => { + const handler = packet.handler; + const data = handler.getData(); + if (isMandatoryField(packet) && (!Array.isArray(data) || data.length < 1)) { + return { + valid: false, + value: data, + message: ENTITY_TEXT_PROMPTS.REQUIRED_FIELD + } + } + + return { + valid: true, + value: data, + } + }, + + 'var_data': (field, packet) => { + const handler = packet.handler; + const data = handler.getData(); + if (isMandatoryField(packet) && (!isObjectType(data) || Object.values(data).length < 1)) { + return { + valid: false, + value: data, + message: ENTITY_TEXT_PROMPTS.REQUIRED_FIELD + } + } + + return { + valid: true, + value: data, + } + }, + + 'indicator_calculation': (field, packet, creator, isInit) => { + const handler = packet.handler; + + let values = { }, + length = 0; + Object.entries(handler.elements).forEach(([role, editor]) => { + values[role] = editor.value() + + if (!isInit && IndicatorCalculationCreator.IsDefaultValue(role, values[role])) { + values[role] = ''; + } + + length += values[role].length; + }); + + if (length === 0) { + values = null; + } + + return { + valid: true, + value: values, + } } }; @@ -939,7 +1462,13 @@ export const collectFormData = () => { // merge metadata into template's fields for easy access if (result?.metadata && result?.template) { for (const [key, value] of Object.entries(result.metadata)) { - result.template.definition.fields[key] = { is_base_field: true }; + const prev = result.template.definition.fields[key]; + result.template.definition.fields[key] = mergeObjects( + isObjectType(prev) ? prev : { }, + { is_base_field: true }, + true, + true + ); } } @@ -963,12 +1492,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, validation = undefined) => { +export const createFormHandler = (element, cls, data, validation = undefined, pkg = undefined) => { if (!ENTITY_HANDLERS.hasOwnProperty(cls)) { return; } - return ENTITY_HANDLERS[cls](element, data, validation); + return ENTITY_HANDLERS[cls](element, data, validation, pkg); } /** @@ -986,6 +1515,56 @@ export const isMandatoryField = (packet) => { return !isNullOrUndefined(validation?.mandatory) && validation.mandatory; } +/** + * resolveRangeOpts + * @desc resolves the range assoc. with some component's properties (if available) + * + * @param {string} type the name of the type assoc. with this range + * @param {Record<string, any>} opts the properties assoc. with the component containing this value/range + * @param {boolean} [forceStep=true] optionally specify whether to resolve a step interval regardless of range availability; defaults to `true` + * + * @returns {Record<string, Record<string, string|number>>} the range values (if applicable) + */ +export const resolveRangeOpts = (type, opts, forceStep = true) => { + let fmin = null; + let fmax = null; + let fstep = null; + if (isObjectType(opts)) { + fmin = typeof opts.min === 'number' ? opts.min : null; + fmax = typeof opts.max === 'number' ? opts.max : null; + fstep = typeof opts.step === 'number' ? opts.step : null; + } else if (Array.isArray(opts) && opts.length >= 2) { + fmin = typeof opts[0] === 'number' ? opts[0] : null; + fmax = typeof opts[1] === 'number' ? opts[1] : null; + fstep = null; + } + + if (typeof fmin == 'number' && typeof fmax === 'number') { + let tmp = Math.max(fmin, fmax); + fmin = Math.min(fmin, fmax); + fmax = tmp; + } + + if (fstep === null && forceStep) { + fstep = type.startsWith('int') ? 1 : 0.001; + } + + return { + hasStep: fstep !== null, + hasRange: fmin !== null && fmax !== null, + attr: { + min: fmin !== null ? `min="${fmin}"` : '', + max: fmax !== null ? `max="${fmax}"` : '', + step: fstep !== null ? `step="${fstep}"` : '', + }, + values: { + min: fmin, + max: fmax, + step: fstep, + }, + }; +} + /** * parseAsFieldType * @desc parses the field as its type, returns true if no validation or type field @@ -993,7 +1572,7 @@ export const isMandatoryField = (packet) => { * @param {*} value the value retrieved from the form * @returns {object} that returns the success state of the parsing & the parsed value, if applicable */ -export const parseAsFieldType = (packet, value) => { +export const parseAsFieldType = (packet, value, modifier) => { const validation = packet?.validation; if (isNullOrUndefined(validation)) { return { @@ -1002,7 +1581,11 @@ export const parseAsFieldType = (packet, value) => { } } - const type = validation?.type; + let type = validation?.type; + if (isObjectType(modifier) && modifier?.type) { + type = modifier.type; + } + if (isNullOrUndefined(type)) { return { success: true, @@ -1014,81 +1597,774 @@ export const parseAsFieldType = (packet, value) => { switch (type) { case 'int': case 'enum': { - value = parseInt(value); - valid = !isNaN(value); - } break; - - case 'string': { - value = String(value); - - const pattern = validation?.regex; - if (isNullOrUndefined(pattern)) { - valid = true; - break; + if (typeof value === 'string') { + value = parseInt(value.trim()); } - 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; - } + if (typeof value === 'number') { + value = Math.trunc(value); + valid = !isNaN(value) && Number.isFinite(value) && Number.isSafeInteger(value); + + let proc; + let range = validation?.range; + if (!isNullOrUndefined(range)) { + range = resolveRangeOpts('int', validation?.range); + if (range.hasRange) { + proc = true; + value = clampNumber(value, range.values.min, range.values.max); + } + } - valid = new RegExp(test).test(value); - if (valid) { - break; + if (!proc) { + const props = validation?.properties; + if (isObjectType(props)) { + if (isSafeNumber(props?.min) || isSafeNumber(props?.max)) { + value = clampNumber(value, props.min, props.max); + } else { + range = resolveRangeOpts('int', validation?.properties?.range); + if (range.hasRange) { + value = clampNumber(value, range.values.min, range.values.max); + } } } } - } - catch (e) { - console.error(`Failed to test String<value: ${value}> with err: ${e}`); + } else { valid = false; } - } break; - case 'string_array': { - if (!Array.isArray(value)) { - valid = false; - break; - } - - value = value.map(item => String(item)); - } break; + case 'int_range': { + if (isObjectType(value)) { + let { min, max } = value; + if (typeof min === 'number' && typeof max === 'number') { + min = Math.min(min, max); + max = Math.max(min, max); - case 'int_array': { - if (!Array.isArray(value)) { - valid = false; - break; - } + const range = resolveRangeOpts('int', validation?.range || validation?.properties?.range); + if (valid && range.hasRange) { + value = clampNumber(value, range.values.min, range.values.max); + } - const output = [ ]; - for (let i = 0; i < value.length; ++i) { - const item = parseInt(value[i]); - if (isNaN(item)) { - valid = false; + value = { min: Math.trunc(min), max: Math.trunc(max) }; break; } - output.push(item); - } - if (!valid) { - break; - } - value = output; - } break; - - case 'publication': { - if (!Array.isArray(value)) { valid = false; + } else if (!isNullOrUndefined(value)) { + const output = []; + if (typeof value === 'string') { + value = value.trim().split(','); + } else if (typeof value === 'number') { + value = [value]; + } + + if (!Array.isArray(value)) { + return false; + } + + for (let i = 0; i < value.length; ++i) { + let item = value[i]; + if (typeof item === 'string') { + item = parseInt(item.trim()); + } + + if (typeof item !== 'number') { + continue; + } + + item = Math.trunc(item); + valid = !isNaN(item) && Number.isFinite(item) && Number.isSafeInteger(item); + + if (!valid) { + break; + } + + output.push(item); + } + value = output; + + if (value.length === 1 && validation?.closure_optional) { + const num = Math.trunc(value.shift()); + valid = !isNaN(num) && Number.isFinite(num) && Number.isSafeInteger(num); + value = [num]; + } else if (value.length < 2 && !validation?.closure_optional) { + valid = false; + } else { + const lower = Math.min(value[0], value[1]); + const upper = Math.max(value[0], value[1]); + value[0] = lower; + value[1] = upper; + } + + const range = resolveRangeOpts('int', validation?.range || validation?.properties?.range); + if (valid && range.hasRange) { + value[0] = clampNumber(value[0], range.values.min, range.values.max); + value[1] = clampNumber(value[1], range.values.min, range.values.max); + } + } + } break; + + case 'int_array': { + const output = []; + if (typeof value === 'string') { + value = value.trim().split(','); + } else if (typeof value === 'number') { + value = [value]; + } + + if (!Array.isArray(value)) { + break; + } + + if (isNullOrUndefined(modifier) || !modifier.freeform) { + for (let i = 0; i < value.length; ++i) { + let item = value[i]; + if (typeof item === 'string') { + item = parseInt(item.trim()); + } + + if (typeof item !== 'number') { + continue; + } + + item = Math.trunc(item); + valid = !isNaN(item) && Number.isFinite(item) && Number.isSafeInteger(item); + + if (!valid) { + break; + } + output.push(item); + } + } else { + output.splice(0, value.length, ...value); + } + + if (!valid) { + break; + } + value = output; + } break; + + case 'float': + case 'decimal': + case 'numeric': { + if (typeof value === 'string') { + const matches = value.trim().match(/(\+|-)?(((\d{1,3}([,?\d{3}])*(\.\d+)?)|(\d{1,}))|(\.\d{0,}))/m); + if (!isNullOrUndefined(matches)) { + value = parseFloat(matches.shift().trim().replaceAll(/,/g, '')); + } + } + + if (typeof value === 'number') { + valid = !isNaN(value) && Number.isFinite(value); + + let proc; + let range = validation?.range; + if (!isNullOrUndefined(range)) { + range = resolveRangeOpts(type, validation?.range); + if (range.hasRange) { + proc = true; + value = clampNumber(value, range.values.min, range.values.max); + } + } + + if (!proc) { + const props = validation?.properties; + if (isObjectType(props)) { + if (isSafeNumber(props?.min) || isSafeNumber(props?.max)) { + value = clampNumber(value, props.min, props.max); + } else { + range = resolveRangeOpts(type, validation?.properties?.range); + if (range.hasRange) { + value = clampNumber(value, range.values.min, range.values.max); + } + } + } + } + } else { + valid = false; + } + } break; + + case 'float_range': + case 'decimal_range': + case 'numeric_range': { + if (isObjectType(value)) { + let { min, max } = value; + if (typeof min === 'number' && typeof max === 'number') { + min = Math.min(min, max); + max = Math.max(min, max); + + const range = resolveRangeOpts(type, validation?.range || validation?.properties?.range); + if (valid && range.hasRange) { + min = clampNumber(min, range.values.min, range.values.max); + max = clampNumber(max, range.values.min, range.values.max); + } + + value = { min, max }; + break; + } + } else { + const output = []; + if (typeof value === 'string') { + value = value.trim().split(','); + } else if (typeof value === 'number') { + value = [value]; + } + + if (!Array.isArray(value)) { + return false; + } + + for (let i = 0; i < value.length; ++i) { + let item = value[i]; + if (typeof item === 'string') { + const matches = item.trim().match(/(\+|-)?(((\d{1,3}([,?\d{3}])*(\.\d+)?)|(\d{1,}))|(\.\d{0,}))/m); + if (isNullOrUndefined(matches)) { + valid = false; + break; + } + + item = parseFloat(matches.shift().trim()); + } + + if (typeof item !== 'number') { + continue; + } + + valid = !isNaN(item) && Number.isFinite(item); + + if (!valid) { + break; + } + + output.push(item); + } + value = output; + + if (value.length === 1 && validation?.closure_optional) { + const num = value.shift(); + valid = !isNaN(num) && Number.isFinite(num); + value = [num]; + } else if (value.length < 2 && !validation?.closure_optional) { + valid = false; + } else { + const lower = Math.min(value[0], value[1]); + const upper = Math.max(value[0], value[1]); + value[0] = lower; + value[1] = upper; + } + + const range = resolveRangeOpts(type, validation?.range || validation?.properties?.range); + if (valid && range.hasRange) { + value[0] = clampNumber(value[0], range.values.min, range.values.max); + value[1] = clampNumber(value[1], range.values.min, range.values.max); + } + } + } break; + + case 'float_array': + case 'decimal_array': + case 'numeric_array': { + const output = []; + if (typeof value === 'string') { + value = value.trim().split(','); + } else if (typeof value === 'number') { + value = [value]; + } + + if (!Array.isArray(value)) { + break; + } + + for (let i = 0; i < value.length; ++i) { + let item = value[i]; + if (typeof item === 'string') { + const matches = item.trim().match(/(\+|-)?(((\d{1,3}([,?\d{3}])*(\.\d+)?)|(\d{1,}))|(\.\d{0,}))/m); + if (isNullOrUndefined(matches)) { + valid = false; + break; + } + + item = parseFloat(matches.shift().trim()); + } + + if (typeof item !== 'number') { + continue; + } + + valid = !isNaN(item) && Number.isFinite(item); + + if (!valid) { + break; + } + + output.push(item); + } + + if (!valid) { + break; + } + value = output; + } break; + + case 'percentage': { + if (typeof value === 'string') { + const matches = value.trim().match(/(\+|-)?(((\d{1,3}([,?\d{3}])*(\.\d+)?)|(\d{1,}))|(\.\d{0,}))(%)?/m); + if (!isNullOrUndefined(matches)) { + value = parseFloat(matches.shift().trim()); + + let coercion = (isObjectType(modifier) && !isNullOrUndefined(modifier?.coercion)) + ? modifier.coercion + : null; + + if (isNullOrUndefined(coercion)) { + coercion = validation?.coercion; + } + + const hasPercentageSymbol = !isNullOrUndefined(matches) ? (matches[matches.length - 1] === '%') : false; + if (coercion === 'normalised' && hasPercentageSymbol) { + value /= 100; + } else if (coercion === 'percentage' && !hasPercentageSymbol) { + value *= 100; + } + } + } + + if (typeof value === 'number') { + valid = !isNaN(value) && Number.isFinite(value); + + let proc; + let range = validation?.range; + if (!isNullOrUndefined(range)) { + range = resolveRangeOpts(type, validation?.range); + if (range.hasRange) { + proc = true; + value = clampNumber(value, range.values.min, range.values.max); + } + } + + if (!proc) { + const props = validation?.properties; + if (isObjectType(props)) { + if (isSafeNumber(props?.min) || isSafeNumber(props?.max)) { + value = clampNumber(value, props.min, props.max); + } else { + range = resolveRangeOpts(type, validation?.properties?.range); + if (range.hasRange) { + value = clampNumber(value, range.values.min, range.values.max); + } + } + } + } + } else { + valid = false; + } + } break; + + case 'percentage_range': { + if (isObjectType(value)) { + let { min, max } = value; + if (typeof min === 'number' && typeof max === 'number') { + min = Math.min(min, max); + max = Math.max(min, max); + + const range = resolveRangeOpts(type, validation?.range || validation?.properties?.range); + if (valid && range.hasRange) { + min = clampNumber(min, range.values.min, range.values.max); + max = clampNumber(max, range.values.min, range.values.max); + } + + value = { min, max }; + break; + } + } else { + const output = []; + if (typeof value === 'string') { + value = value.trim().split(','); + } else if (typeof value === 'number') { + value = [value]; + } + + if (!Array.isArray(value)) { + break; + } + + for (let i = 0; i < value.length; ++i) { + let item = value[i]; + if (typeof item === 'string') { + const matches = item.trim().match(/(\+|-)?(((\d{1,3}([,?\d{3}])*(\.\d+)?)|(\d{1,}))|(\.\d{0,}))(%)?/m); + if (!isNullOrUndefined(matches)) { + item = parseFloat(matches.shift().trim()); + + let coercion = (isObjectType(modifier) && !isNullOrUndefined(modifier?.coercion)) + ? modifier.coercion + : null; + + if (isNullOrUndefined(coercion)) { + coercion = validation?.coercion; + } + + const hasPercentageSymbol = !isNullOrUndefined(matches) ? (matches[matches.length - 1] === '%') : false; + if (coercion === 'normalised' && hasPercentageSymbol) { + item /= 100; + } else if (coercion === 'percentage' && !hasPercentageSymbol) { + item *= 100; + } + } + } + + if (typeof item !== 'number') { + continue; + } + + valid = !isNaN(item) && Number.isFinite(item); + if (!valid) { + break; + } + + output.push(item); + } + + if (!valid) { + break; + } + value = output; + + const range = resolveRangeOpts(type, validation?.range || validation?.properties?.range); + if (valid && range.hasRange) { + value[0] = clampNumber(value[0], range.values.min, range.values.max); + value[1] = clampNumber(value[1], range.values.min, range.values.max); + } } + } break; + + case 'ci_interval': { + if (!isObjectType(value)) { + valid = false; + break; + } + + const output = { }; + for (const key in value) { + let item = value[key]; + if (key === 'probability') { + item = parseAsFieldType({ validation: { type: 'percentage', range: [0, 100] }}, item); + if (!item || !item?.success) { + valid = false; + break; + } + + item = item.value; + } else { + let attemptCoercion; + if (typeof item === 'string') { + attemptCoercion = item.match(/^(\+|-)?(((\d{1,3}([,?\d{3}])*(\.?\d+)?)|(\d{1,}))|(\.\d+)?)(%)?$/m); + attemptCoercion = !isNullOrUndefined(attemptCoercion); + } else if (typeof item === 'number') { + attemptCoercion = true; + } + + let failure = false; + if (attemptCoercion) { + const res = parseAsFieldType({ validation: { type: 'float' } }, item); + if (res && res?.success) { + item = res.value; + } else { + failure = true; + } + } + + if (!failure && typeof item !== 'number' && typeof item !== 'string') { + valid = false; + break; + } + + if (typeof item === 'number' && (isNaN(item) || !Number.isFinite(item))) { + valid = false; + break; + } + + if (failure && typeof item === 'number') { + item = item.toString(); + } + + if (typeof item === 'string' && !stringHasChars(item)) { + valid = false; + break; + } + } + + output[key] = item; + } + + if (!valid) { + break; + } + + value = output; + } break; + + case 'string': { + value = !isNullOrUndefined(value) ? String(value) : null; + + const pattern = validation?.regex; + if (!isNullOrUndefined(pattern) && typeof value === 'string') { + 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<value: ${value}> with err: ${e}`); + valid = false; + } + + if (!valid) { + break; + } + } + + const { clippable, validateLen } = isObjectType(validation.properties) ? validation.properties : {}; + if (clippable || validateLen) { + let len = validation?.['length'] || validation?.properties?.['length']; + if (Array.isArray(len)) { + len = len.length >= 2 ? len.slice(0, 2) : len?.[0]; + } + + if (Array.isArray(len)) { + len = len.map(x => Math.floor(x)); + + let min = Math.min(...len); + let max = Math.max(...len); + if (min === max) { + len = min; + } else { + if (clippable) { + value = value.substring(0, Math.min(value.length, max)); + } else if (validateLen && (value.length < min || value.length > max)) { + value = { len: value.length }; + valid = false; + } + } + + if (!valid) { + break; + } + } + + if (typeof len === 'number') { + len = Math.floor(len); + + if (clippable) { + value = value.substring(0, Math.min(value.length, len)); + } else if (validateLen && value.length > len) { + value = { len: value.length }; + valid = false; + } + + if (!valid) { + break; + } + } + } + } break; + + case 'string_array': { + if (!Array.isArray(value)) { + valid = false; + break; + } + + value = value.map(item => String(item)); + } break; + + case 'publication': { + if (!Array.isArray(value)) { + valid = false; + } + } break; + + case 'organisation': { + if (!isNullOrUndefined(value) && typeof value !== 'string' && typeof value !== 'number') { + valid = false; + break; + } + + if (typeof value === 'string') { + value = parseInt(value.trim()); + } + + if (typeof value === 'number') { + value = Math.trunc(value); + valid = !isNaN(value) && Number.isFinite(value) && Number.isSafeInteger(value); + } else if (!isNullOrUndefined(value)) { + valid = false; + } + } break; + + case 'var_data': { + if (!isObjectType(value)) { + valid = false; + break; + } + + const options = isObjectType(validation.options) ? validation.options : null; + const properties = isObjectType(validation.properties) ? validation.properties : null; + + const allowUnknown = !!properties ? properties.allow_unknown : false; + const allowDescription = !!properties ? properties.allow_description : false; + + const output = {}; + for (const key in value) { + let item = value[key]; + if (!isObjectType(item)) { + valid = false; + break; + } + + let description = null; + if (allowDescription) { + if (!isNullOrUndefined(item?.description) && typeof item?.description !== 'string') { + item.description = String(item?.description); + } + + if (typeof item.description === 'string') { + description = item.description; + } + + if (!stringHasChars(description)) { + delete item.description; + } + } + + let success = false; + if (options) { + const props = options[key]; + if (!isNullOrUndefined(props)) { + const res = parseAsFieldType(packet, item.value, props); + if (!res || !res?.success) { + valid = false; + break + } + + item = { + name: typeof props.name === 'string' ? props.name : key, + type: props.type, + value: res.value, + description: description, + }; + success = true; + } + } + + if (!success && !allowUnknown) { + valid = false; + break; + } + + if (!success) { + const vtype = typeof item.type === 'string' && stringHasChars(item.type) ? item.type : null; + const vlabel = typeof item.name === 'string' && stringHasChars(item.name) ? item.name : null; + if (!vtype || !vlabel) { + valid = false; + break + } + + const res = parseAsFieldType({ validation: { type: vtype }}, item.value); + if (!res || !res?.success) { + valid = false; + break + } + + item = { + name: vlabel, + type: vtype, + value: res.value, + description: description, + }; + } + + if (item.hasOwnProperty('description') && (!allowDescription || item.description === null)) { + delete item.description; + } + + output[key] = item; + } + + if (!valid) { + break; + } + value = output; + } break; + + case 'contacts': + case 'publication': + case 'related_entities': { + if (!Array.isArray(value)) { + valid = false; + } + } break; + + case 'age_group': { + if (!isObjectType(value)) { + valid = false; + break + } + + const range = resolveRangeOpts( + 'int', + isObjectType(validation) && isObjectType(validation?.properties) + ? validation?.properties + : { } + ); + + let { value: val, comparator } = value; + if (comparator === 'between') { + if (!Array.isArray(val)) { + valid = false; + break; + } + + val = parseAsFieldType({ validation: { type: 'int_range', properties: { min: range.values.min, max: range.values.max } } }, val); + if (!val || !val?.success) { + valid = false; + break; + } + + value.value = val.value; + } else { + if (!isSafeNumber(val)) { + valid = false; + break; + } + + val = parseAsFieldType({ validation: { type: 'int', properties: { min: range.values.min, max: range.values.max } } }, val); + if (!val || !val?.success) { + valid = false; + break; + } + + value.value = val.value; + } + } break; + + default: + valid = true; break; - } } return { @@ -1109,7 +2385,7 @@ export const parseAsFieldType = (packet, value) => { * @return {string} the title of this field */ export const tryGetFieldTitle = (field, packet) => { - const group = tryGetRootElement(packet.element, 'detailed-input-group'); + const group = tryGetRootElement(packet.element, '.detailed-input-group'); const title = !isNullOrUndefined(group) ? group.querySelector('.detailed-input-group__title') : null; if (!isNullOrUndefined(title)) { return title.innerText.trim(); diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/forms/entityFormConstants.js b/CodeListLibrary_project/cll/static/js/clinicalcode/forms/entityFormConstants.js index 11b7a4e12..6ed7e370d 100644 --- a/CodeListLibrary_project/cll/static/js/clinicalcode/forms/entityFormConstants.js +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/forms/entityFormConstants.js @@ -52,8 +52,8 @@ export const content: ` <p> <strong> - You are saving a legacy Phenotype. - Updating this Phenotype will overwrite the most recent version. + You are saving a legacy entity. + Updating this entity will overwrite the most recent version. </strong> </p> <p>Are you sure you want to do this?</p> @@ -78,39 +78,35 @@ export const }; /** - * StringInputListCreator constant(s) + * Toast constant(s) * */ -export const - /** - * STR_INPUT_LIST_KEYCODES - * @desc Keycodes used by list creator - */ - STR_INPUT_LIST_KEYCODES = { - // Add list element - ENTER: 13, - }, +export const /** - * STR_INPUT_LIST_MIN_MSG_DURATION + * TOAST_MSG_DURATION * @desc Min. message duration for toast notif popups */ - STR_INPUT_LIST_MIN_MSG_DURATION = 5000; + TOAST_MSG_DURATION = 5000; /** - * PublicationCreator constant(s) - * + * VariableCreator const(s) */ export const /** - * PUBLICATION_KEYCODES - * @desc Keycodes used by publication creator - */ - PUBLICATION_KEYCODES = { - // Add publication - ENTER: 13, - }, - /** - * PUBLICATION_MIN_MSG_DURATION - * @desc Min. message duration for toast notif popups + * VFCREATOR_TYPE_MAP + * @desc maps allowable types to template types */ - PUBLICATION_MIN_MSG_DURATION = 5000; + VFCREATOR_TYPE_MAP = { + // Numeric + int: 'number', + numeric: 'number', + percentage: 'number', + + // Numeric range(s) + int_range: 'range', + numeric_range: 'range', + percentage_range: 'range', + + // Str-related + string: 'inputbox', + }; diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/forms/entityPublish.js b/CodeListLibrary_project/cll/static/js/clinicalcode/forms/entityPublish.js index 6ad864c8e..f846732e2 100644 --- a/CodeListLibrary_project/cll/static/js/clinicalcode/forms/entityPublish.js +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/forms/entityPublish.js @@ -7,7 +7,7 @@ * */ class PublishModal { - constructor(publish_url, decline_url,redirect_url) { + constructor(publish_url, decline_url, redirect_url) { this.publish_url = publish_url; this.decline_url = decline_url; this.redirect_url = redirect_url; @@ -26,7 +26,7 @@ class PublishModal { }, }); const data = await response.json(); - spinner.remove(); + spinner?.remove?.(); const publishButton = [ { @@ -83,12 +83,13 @@ class PublishModal { } }) .catch((result) => { - if (!(result instanceof ModalFactory.ModalResults)) { + if (!!result && !(result instanceof ModalFactory.ModalResults)) { return console.error(result); } }); } catch (error) { console.error(error); + spinner?.remove?.(); } } @@ -113,11 +114,11 @@ class PublishModal { } return response.json(); }).finally(() => { - spinner.remove(); + spinner?.remove?.(); }); } catch (error) { - spinner.remove(); + spinner?.remove?.(); console.error(error); } } @@ -126,27 +127,28 @@ class PublishModal { let paragraph; switch (data.approval_status) { case 1: - paragraph = `<p>Are you sure you want to approve this version of "${data.name}"?</p> - <p>Publishing a ${data.entity_type} cannot be undone.</p>`; + paragraph = + `<p>Are you sure you want to approve this version of "${data.name}"?</p>` + + `<p>Publishing a ${data.branded_entity_cls} cannot be undone.</p>`; break; + case 3: - paragraph = `<p>Are you sure you want to approve previously declined version of "${data.name}"?</p> - <p>Changes made to this ${data.entity_type} cannot be undone.</p>`; + paragraph = + `<p>Are you sure you want to approve a previously declined version of "${data.name}"?</p>` + + `<p>Changes made to this ${data.branded_entity_cls} cannot be undone.</p>`; break; - case null: + + default: if (data.is_moderator || data.is_lastapproved) { - paragraph = `<p>Are you sure you want to publish this version of "${data.name}"?</p> - <p>Publishing a ${data.entity_type} cannot be undone.</p>`; + paragraph = + `<p>Are you sure you want to publish this version of "${data.name}"?</p>` + + `<p>Publishing a ${data.branded_entity_cls} cannot be undone.</p>`; } else { - paragraph = `<p>Are you sure you want submit to publish this version of "${data.name}"?</p> - <p>Changes made to this ${data.entity_type} cannot be undone.</p> - <p>This ${data.entity_type} is going to be reviewed by the moderator and you will be notified when is published</p>`; + paragraph = + `<p>Are you sure you want to publish this version of "${data.name}"?</p>` + + `<p>This ${data.branded_entity_cls} is going to be reviewed by the moderator; you will be notified via e-mail when they have decided to publish your work.</p>`; } break; - default: - paragraph = `<p>Are you sure you want to publish this version of "${data.name}"?</p> - <p>Publishing a ${data.entity_type} cannot be undone.</p>`; - break; } return paragraph; } @@ -178,13 +180,13 @@ class PublishModal { let title; switch (data.approval_status) { case 1: - title = `Approve - ${data.entity_id} ${data.entity_type} - ${data.name}`; + title = `Approve - ${data.entity_id} ${data.branded_entity_cls} - ${data.name}`; break; case 3: - title = `Publish declined - ${data.entity_id} ${data.entity_type} - ${data.name}`; + title = `Publish declined - ${data.entity_id} ${data.branded_entity_cls} - ${data.name}`; break; default: - title = `Publish - ${data.entity_id} ${data.entity_type} - ${data.name}`; + title = `Publish - ${data.entity_id} ${data.branded_entity_cls} - ${data.name}`; break; } return title; diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/forms/generic/ageGroupSelector.js b/CodeListLibrary_project/cll/static/js/clinicalcode/forms/generic/ageGroupSelector.js new file mode 100644 index 000000000..df708f0ae --- /dev/null +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/forms/generic/ageGroupSelector.js @@ -0,0 +1,623 @@ +import { parseAsFieldType, resolveRangeOpts } from '../entityCreator/utils.js'; + +/** + * A `number`, or `string`, describing a numeric object. + * @typedef {(number|string)} NumberLike + */ + +/** + * Age group value object + * @typedef {Object} AgeValue + * @property {NumberLike} value the maximum value of the age group + * @property {('between'|'lte'|'gte')} comparator the minimum value of the age group + */ + +/** + * Properties assoc. with age group inputs; used to constrain and/or to compute the value + * @typedef {Object} AgeProps + * @property {NumberLike} min the minimum value of the age group + * @property {NumberLike} max the maximum value of the age group + * @property {NumberLike} step specifies the age group increment + */ + +/** + * Age group initialisation data + * @typedef {Object} AgeData + * @property {AgeValue} value specifies the initial age group component value + * @property {AgeProps} properties specifies the properties of the age group component, see {@link AgeProps} + */ + +/** + * @desc computes the properties assoc. with an age group component + * + * @param {?AgeData} data an object containing the properties + * + * @returns {AgeData} the computed properties + */ +const computeParams = (data) => { + const hasData = isObjectType(data) + data = hasData ? data : { }; + + let props = data?.properties; + if (!isObjectType(props)) { + props = { min: 0, max: 100, step: 1 }; + } + + let valueStep = typeof props?.step === 'string' ? Number(props?.step) : props.step; + let hasValidStep = isSafeNumber(valueStep); + + let min = typeof props.min === 'string' ? Number(props.min) : props.min; + let max = typeof props.max === 'string' ? Number(props.max) : props.max; + + const validMin = isSafeNumber(min); + const validMax = isSafeNumber(max); + + if (!hasValidStep) { + if ((validMin || validMax)) { + const precision = String(validMin ? min : max).split('.')?.[1]?.length || 0; + valueStep = Math.pow(10, -precision); + } else { + valueStep = 1; + } + } + + if (validMin && validMax) { + let tmp = Math.max(min, max); + min = Math.min(min, max); + max = tmp; + } else if (validMin) { + max = min + (hasValidStep ? valueStep : 1)*100; + } else if (validMax) { + min = max - (hasValidStep ? valueStep : 1)*100; + } else { + min = 0; + max = valueStep*100; + } + props.min = min; + props.max = max; + props.step = valueStep; + + data.props = props; + data.value = mergeObjects( + isObjectType(data.value) ? data.value : { }, + { comparator: 'na', value: [min, max] }, + true, + true + ); + + if (data.value.comparator === 'between' && !Array.isArray(data.value.value)) { + data.value.value = [min, max]; + } else if (data.value.comparator.includes('te') && !isSafeNumber(data.value.value)) { + data.value.value = min; + } + + return data; +} + +/** + * A class that instantiates and manages a age group component to select a single numeric value + * @class + * @alias module:AgeGroupSelector + */ +export default class AgeGroupSelector { + /** + * @desc describes the HTMLElements = by way of their query selectors - assoc. with this instance + * @type {Record<string, string>} + * @static + * @constant + */ + static #Composition = { + container: '#age-container', + comparator: '#comparator-dropdown', + }; + + /** + * @desc a Recordset containing a set of assoc. HTML frag templates + * @type {!Record<string, Record<string, HTMLElement>>} + * @private + */ + #templates = null; + + /** + * @desc an object describing the active interface + * @type {?object} + * @private + */ + #interface = null; + + /** + * @param {HTMLElement|string} obj Either (a) a HTMLElement assoc. with this instance, or (b) a query selector string to locate said element + * @param {Partial<AgeData>} data Should describe the properties & validation assoc. with this component, see {@link AgeData} + */ + constructor(obj, data) { + let element; + if (isHtmlObject(obj)) { + element = obj; + } else if (typeof obj === 'string') { + if (!isValidSelector(obj)) { + throw new Error(`Query selector of Param<obj: ${obj}> is invalid`); + } + + element = document.querySelector(obj); + } + + if (!isHtmlObject(element)) { + throw new Error(`Failed to locate a valid assoc. HTMLElement with Params<obj: ${String(obj)}>`); + } + + /** + * @desc the ID attribute assoc. with this instance's `element` (`HTMLElement`), if applicable + * @type {?string} + * @default null + * @public + */ + this.id = element.getAttribute('id') ?? null; + + /** + * @desc the `HTMLElement` assoc. with this instance + * @type {!HTMLElement} + * @public + */ + this.element = element; + + /** + * @desc initialisation data & properties assoc. with this instance + * @type {!AgeData} + * @public + */ + this.data = computeParams(data); + + /** + * @desc range assoc. with this component + * @type {!object} + * @public + */ + this.rangeOpts = resolveRangeOpts('int', this.data.properties); + + /** + * @desc the current numeric value selected by the client + * @type {!AgeValue} + * @public + */ + this.value = data.value; + + /** + * @desc describes the dirty (changed) state of this instance + * @type {!boolean} + * @default false + * @public + */ + this.dirty = false; + + /** + * @desc a Recordset containing the elements assoc. with this instance + * @type {!Record<string, HTMLElement>} + * @public + */ + this.elements = this.#initialiseElements(); + + this.#initialise(); + } + + + /************************************* + * * + * Getter * + * * + *************************************/ + /** + * getValue + * @desc gets the current value of the component + * @returns {any} the value selected via its options data + */ + getValue() { + if (this.value.comparator === 'na') { + return null; + } + + return { + value: this.value.value, + comparator: this.value.comparator, + }; + } + + /** + * getElement + * @returns {node} the assoc. element + */ + getElement() { + return this.element; + } + + /** + * isDirty + * @returns {bool} returns the dirty state of this component + */ + isDirty() { + return this.dirty; + } + + + /************************************* + * * + * Setter * + * * + *************************************/ + /** + * makeDirty + * @desc informs the top-level parent that we're dirty + * and updates our internal dirty state + * @return {object} return this for chaining + */ + makeDirty() { + window.entityForm.makeDirty(); + this.dirty = true; + return this; + } + + /** + * @desc updates this instance's value + * + * @param {AgeValue} val component value describing the number/numeric value alongside the comparator + * + * @returns {boolean} reflecting whether this instance's value was updating or not + */ + setValue(val) { + if (!isObjectType(val)) { + return false; + } + + const instVal = this.value; + const { min, max } = this.data.properties; + const { comparator, value } = val; + + let changed; + switch (comparator) { + case 'between': { + let [vmin, vmax] = value; + vmin = parseAsFieldType({ validation: { type: 'int', properties: { min, max } } }, vmin); + vmax = parseAsFieldType({ validation: { type: 'int', properties: { min, max } } }, vmax); + if ((!vmin || !vmin?.success) || (!vmax || !vmax?.success)) { + return false; + } + + vmin = vmin.value; + vmax = vmax.value; + + let tmp = Math.max(vmin, vmax); + vmin = Math.min(vmin, vmax); + vmax = tmp; + + changed = Array.isArray(instVal.value) + ? instVal.value[0] !== vmin || instVal.value[1] != vmax + : true; + + instVal.value = [vmin, vmax]; + } break; + + case 'lte': + case 'gte': { + let vx = parseAsFieldType({ validation: { type: 'int', properties: { min, max } } }, value); + if (!vx || !vx?.success) { + return false; + } + + changed = instVal.value != vx.value; + instVal.value = vx.value; + } break; + } + + if (!!changed) { + this.makeDirty(); + } + + if (!!this.#interface && this.#interface?.setValue) { + this.#interface?.setValue?.(instVal.value); + } + + return true; + } + + /** + * @desc updates this instance's mode + * + * @param {string} classType specify the component mode type + * @param {string?} [relation] optionally specify the comparator + * + * @returns {boolean} reflecting whether the component was changed + */ + setMode(classType, relation = 'between') { + switch (classType) { + case 'na': { + relation = 'na'; + } break; + + case 'range': { + relation = 'between'; + } break; + + case 'bounds': { + relation = stringHasChars(relation) && ['lte', 'gte'].includes(relation) + ? relation + : 'lte'; + } break; + + default: + return false; + } + + const val = this.value; + const hasChanged = relation !== val.comparator; + if (this.#interface && !hasChanged) { + return false; + } + + if (hasChanged) { + const range = this.rangeOpts.values; + val.value = relation === 'between' + ? [range.min, range.max] + : range.min; + + val.comparator = relation; + this.makeDirty(); + } + + this.#drawInterface(); + return true; + } + + /************************************* + * * + * Private * + * * + *************************************/ + #collectTemplates() { + let templates = this.#templates; + if (isObjectType(templates)) { + return templates; + } + + templates = { } + this.#templates = templates; + + const elem = this.element.parentElement; + elem.querySelectorAll('template[data-name]').forEach(v => { + let name = v.getAttribute('data-name'); + let view = v.getAttribute('data-view'); + if (!stringHasChars(view)) { + view = 'base'; + } + + let group = templates?.[view]; + if (!group) { + group = { }; + templates[view] = group; + } + + group[name] = v; + }); + + return templates; + } + + #drawInterface() { + const elements = this.elements; + const templates = this.#templates; + + const instVal = this.value; + const { comparator } = instVal; + + const range = this.rangeOpts; + const fieldValidation = { validation: { type: 'int' } }; + if (!isNullOrUndefined(range.values.min) && !isNullOrUndefined(range.values.max)) { + fieldValidation.validation.range = [range.values.min, range.values.max]; + } + + switch (comparator) { + case 'na': { + clearAllChildren(elements.container); + } break; + + case 'between': { + composeTemplate(templates.inputs.range, { + params: { + id: 'value-input', + ref: 'value', + type: 'int', + step: range.attr.step, + label: 'Value', + btnStep: range.values.step, + rangemin: range.attr.min, + rangemax: range.attr.max, + value_min: instVal.value[0], + value_max: instVal.value[1], + placeholder: 'Number value...', + disabled: '', + mandatory: true, + }, + render: (obj) => { + clearAllChildren(elements.container); + + obj = obj.shift(); + obj = elements.container.appendChild(obj); + + const minInput = obj.querySelector('#min-value'); + const maxInput = obj.querySelector('#max-value'); + const minSlider = obj.querySelector('#min-slider'); + const maxSlider = obj.querySelector('#max-slider'); + const progressBar = obj.querySelector('#progress-bar'); + + this.#interface = { + minInput: minInput, + maxInput: maxInput, + minSlider: minSlider, + maxSlider: maxSlider, + progressBar: progressBar, + setValue: (num) => { + const distance = range.values.max - range.values.min; + const pos0 = (num[0] / distance) * 100, + pos1 = (num[1] / distance) * 100; + + minSlider.value = minInput.value = num[0]; + maxSlider.value = maxInput.value = num[1]; + + this.#interface.progressBar.style.background = ` + linear-gradient( + to right, + var(--color-accent-semi-transparent) ${pos0}%, + var(--color-accent-primary) ${pos0}%, + var(--color-accent-primary) ${pos1}%, + var(--color-accent-semi-transparent) ${pos1}% + )`; + }, + }; + + const hnd = (e) => { + let target; + if (e.type === 'blur') { + target = e.originalTarget; + } else { + target = e.target; + } + + if (!target || !this.element.contains(target) || !target.matches('input[type="range"], input[type="number"]')) { + return; + } + + const dataTarget = target.getAttribute('data-target'); + const num = [...instVal.value]; + num[dataTarget === 'min' ? 0 : 1] = target.value + + this.setValue({ comparator, value: num }); + }; + + minInput.addEventListener('change', hnd); + minSlider.addEventListener('input', hnd); + + maxInput.addEventListener('change', hnd); + maxSlider.addEventListener('input', hnd); + this.setValue(instVal); + }, + }); + } break; + + default: { + composeTemplate(templates.inputs.bounds, { + params: { + id: 'value-input', + ref: 'value', + type: 'int', + step: range.attr.step, + label: 'Value', + btnStep: range.values.step, + rangemin: range.attr.min, + rangemax: range.attr.max, + value: instVal.value, + placeholder: 'Number value...', + disabled: '', + mandatory: true, + }, + render: (obj) => { + clearAllChildren(elements.container); + + obj = obj.shift(); + obj = elements.container.appendChild(obj); + + const input = obj.querySelector('#value-input'); + const slider = obj.querySelector('#slider-input'); + const progressBar = obj.querySelector('#progress-bar'); + + this.#interface = { + input: input, + slider: slider, + progressBar: progressBar, + setValue: (num) => { + const distance = range.values.max - range.values.min; + const position = (num / distance) * 100; + + input.value = num; + slider.value = num; + progressBar.style.background = ( + comparator == 'lte' + ? `linear-gradient( + to right, + var(--color-accent-semi-transparent) 0%, + var(--color-accent-primary) 0%, + var(--color-accent-primary) ${position}%, + var(--color-accent-semi-transparent) ${position}% + )` + : `linear-gradient( + to left, + var(--color-accent-semi-transparent) 0%, + var(--color-accent-primary) 0%, + var(--color-accent-primary) ${100 - position}%, + var(--color-accent-semi-transparent) ${100 - position}% + )` + ) + }, + }; + + input.addEventListener('change', (e) => { + let val = parseAsFieldType(fieldValidation, input.value); + if (!!val && val?.success) { + val = { comparator, value: val.value }; + } else { + val = { comparator, value: instVal.value }; + } + + this.setValue(val); + }); + + slider.addEventListener('input', (e) => { + let trg = e.target; + let val = parseAsFieldType(fieldValidation, trg.value); + if (!!val && val?.success) { + val = { comparator, value: val.value }; + } else { + val = { comparator, value: instVal.value }; + } + + this.setValue(val); + }); + this.setValue(instVal); + }, + }); + } break; + } + } + + #initialiseElements() { + const elem = this.element; + this.#collectTemplates(); + + const tree = { }; + for (const [name, selector] of Object.entries(AgeGroupSelector.#Composition)) { + const obj = elem.querySelector(selector); + if (!isHtmlObject(obj)) { + throw new Error(`Failed to find assoc. Element<name: ${name}, sel: ${selector}> for Obj...\n${String(elem)}`); + } + tree[name] = obj; + } + + return tree; + } + + #initialise() { + const val = this.value; + const tree = this.elements; + tree.comparator.selectedIndex = [...tree.comparator.options].findIndex(x => val.comparator === x.getAttribute('data-rel')); + tree.comparator.options[tree.comparator.selectedIndex].selected = true; + tree.comparator.dispatchEvent(new CustomEvent('change', { bubbles: true })); + + tree.comparator.addEventListener('change', (e) => { + const target = e.target; + const selected = target.options[target.selectedIndex]; + + const relation = selected.getAttribute('data-rel'); + const classType = selected.getAttribute('data-cls'); + this.setMode(classType, relation); + }); + + this.#drawInterface(); + } +}; diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/forms/generic/indicatorCalculationCreator.js b/CodeListLibrary_project/cll/static/js/clinicalcode/forms/generic/indicatorCalculationCreator.js new file mode 100644 index 000000000..7bae3bf68 --- /dev/null +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/forms/generic/indicatorCalculationCreator.js @@ -0,0 +1,166 @@ +export default class IndicatorCalculationCreator { + /** + * @desc default constructor options + * @type {Record<string, any>} + * @static + * @constant + * + */ + static #DefaultOpts = { + + }; + + /** + * @desc default field placeholder / values to compare against + * @type {string} + * @static + * @constant + * + */ + static #DefaultValues = { + description: `#### Type of Measurement\n\n\n\n#### Description\n\n`, + numerator: `#### Description\n\n\n\n#### Inclusions\n\n- ...\n\n#### Exclusions\n\n- ...\n`, + denominator: `#### Description\n\n\n\n#### Inclusions\n\n- ...\n\n#### Exclusions\n\n- ...\n`, + }; + + /** + * @desc + * @type {Record<string, HTMLElement>} + * @private + */ + #layout = {}; + + /** + * @desc + * @type {Record<string, HTMLElement>} + * @private + */ + elements = {}; + + /** + * @param {HTMLElement} element the HTMLElement assoc. with this component + * @param {Record<string, any>} fieldData specifies the initial value, properties, validation, and options of the component + * @param {Record<string, any>} [opts] optionally specify any additional component opts; see {@link VariableCreator.#DefaultOpts} + */ + constructor(element, fieldData, opts) { + this.data = isObjectType(fieldData) ? fieldData : { description: '', numerator: '', denominator: '' }; + this.dirty = false; + this.element = element; + + this.#initialise(); + } + + /************************************* + * * + * Getter * + * * + *************************************/ + static IsDefaultValue(role, value) { + if (!stringHasChars(value)) { + return true; + } + + const comp = IndicatorCalculationCreator.#DefaultValues?.[role]; + if (!stringHasChars(comp)) { + return false; + } + + return comp.trim() === value.trim(); + } + + /************************************* + * * + * Getter * + * * + *************************************/ + /** + * @returns {HTMLElement} the assoc. element + */ + getElement() { + return this.element; + } + + /** + * @returns {bool} returns the dirty state of this component + */ + isDirty() { + return this.dirty; + } + + /************************************* + * * + * Setter * + * * + *************************************/ + /** + * @desc informs the top-level parent that we're dirty and updates our internal dirty state + * + * @return {this} + */ + makeDirty() { + window.entityForm.makeDirty(); + this.dirty = true; + return this; + } + + /************************************* + * * + * Initialiser * + * * + *************************************/ + #initialise() { + const elem = this.element; + const layout = this.#layout; + const elements = this.elements; + elem.querySelectorAll('[data-role]').forEach(v => { + const role = v.getAttribute('data-role'); + if (!stringHasChars(role)) { + return; + } + + const mde = new EasyMDE({ + // Elem + element: v, + maxHeight: '300px', + minHeight: '200px', + + // Behaviour + autofocus: false, + forceSync: false, + autosave: { enabled: false }, + placeholder: 'Enter content here...', + promptURLs: false, + spellChecker: false, + lineWrapping: true, + unorderedListStyle: '-', + renderingConfig: { + singleLineBreaks: false, + codeSyntaxHighlighting: false, + sanitizerFunction: (renderedHTML) => strictSanitiseString(renderedHTML, { html: true }), + }, + + // Controls + status: ['lines', 'words', 'cursor'], + tabSize: 4, + toolbar: [ + 'heading', 'bold', 'italic', 'strikethrough', '|', + 'unordered-list', 'ordered-list', 'code', 'quote', '|', + 'link', 'image', 'table', '|', + 'preview', 'guide', + ], + toolbarTips: true, + toolbarButtonClassPrefix: 'mde', + }); + + let curContent = this.data?.[role]; + curContent = !stringHasChars(curContent) ? IndicatorCalculationCreator.#DefaultValues?.[role] : curContent; + + if (stringHasChars(curContent)) { + mde.value(curContent); + } + + layout[role] = v; + elements[role] = mde; + }); + } +}; 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 33e1c11a7..efd26364a 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 @@ -1,6 +1,8 @@ import * as Constants from './constants.js'; + +import DebouncedTask from '../../../components/tasks/debouncedTask.js'; +import VirtualisedList from '../../../components/virtualisedList/index.js'; import OntologySelectionModal from './modal.js'; -import VirtualisedList, { DebouncedTask } from '../../../components/virtualisedList/index.js'; /** * @desc constructs a predicate to find an object within an arr @@ -11,7 +13,7 @@ import VirtualisedList, { DebouncedTask } from '../../../components/virtualisedL */ const hasParentPred = (id) => { return (x) => { - if (typeof x === 'object' && x?.id === id) { + if (!!x && typeof x === 'object' && x?.id === id) { return true; } else if (typeof x === 'number' && x === id) { return true; @@ -38,11 +40,23 @@ export default class OntologySelectionService { const hasInitData = !isNullOrUndefined(componentData?.dataset); this.dataset = hasInitData ? componentData?.dataset : []; + this.allowedTypes = isRecordType(componentData?.validation?.source) && Array.isArray(componentData?.validation?.source?.trees) + ? componentData.validation.source.trees + : []; + this.#initialise(componentData, options); if (!hasInitData) { this.#fetchComponentData() .then(dataset => { + let i = 0; + while (i < dataset.length) { + if (this.allowedTypes.includes(dataset?.[i]?.model?.source)) { + i++; + continue; + } + dataset.splice(i, 1); + } this.dataset.splice(this.dataset.length, 0, ...dataset); this.#initialiseTree(); @@ -54,8 +68,11 @@ export default class OntologySelectionService { if (componentData && componentData?.value) { this.#computeComponentValue(componentData?.value, true); } + this.#renderCreateComponent(); }) .catch(console.error); + } else { + this.#renderCreateComponent(); } } @@ -260,7 +277,6 @@ export default class OntologySelectionService { ); } this.templates = templates; - this.#renderCreateComponent(); } /** @@ -297,6 +313,8 @@ export default class OntologySelectionService { headers: { 'X-Target': 'get_options', 'X-Requested-With': 'XMLHttpRequest', + 'Cache-Control': 'max-age=3600', + 'Pragma': 'max-age=3600', } } ) @@ -462,6 +480,8 @@ export default class OntologySelectionService { headers: { 'X-Target': 'query_ontology_typeahead', 'X-Requested-With': 'XMLHttpRequest', + 'Cache-Control': 'max-age=3600', + 'Pragma': 'max-age=3600', } } ) @@ -497,6 +517,8 @@ export default class OntologySelectionService { headers: { 'X-Target': 'query_ontology_record', 'X-Requested-With': 'XMLHttpRequest', + 'Cache-Control': 'max-age=3600', + 'Pragma': 'max-age=3600', } } ) @@ -511,7 +533,7 @@ export default class OntologySelectionService { return response.json(); }) .then(response => { - if (!typeof response === 'object' || !Array.isArray(response?.ancestors) || !Array.isArray(response?.value)) { + if (!isRecordType(response) || !Array.isArray(response?.result)) { return null; } @@ -536,7 +558,13 @@ export default class OntologySelectionService { { host: this.domain, id: id.toString() } ); - const response = await fetch(url, { method: 'GET' }); + const response = await fetch(url, { + method: 'GET', + headers: { + 'Cache-Control': 'max-age=3600', + 'Pragma': 'max-age=3600', + }, + }); if (!response.ok) { throw new Error(`An error has occurred: ${response.status}`); } @@ -910,7 +938,15 @@ export default class OntologySelectionService { this.renderable = renderable; this.#initialiseDialogue(); - this.#pushDataset(0); + + const trg = this.dataset.reduce((res, x) => { + if (!isNullOrUndefined(res) && typeof x?.model?.source === 'number') { + return Math.min(x.model.source, res); + } + return x?.model?.source + }, null); + + this.#pushDataset(typeof trg === 'number' ? trg : 0); this.#resolveSelectedItems(); }) .then(state => { @@ -979,7 +1015,7 @@ export default class OntologySelectionService { }); let component = parseHTMLFromString(html, true); - component = ontologyContainer.appendChild(component.body.children[0]); + component = ontologyContainer.appendChild(component[0]); let active = parseInt(component.getAttribute('data-source')) === activeId; if (active) { @@ -996,6 +1032,7 @@ export default class OntologySelectionService { const treeComponent = eleTree({ el: treeContainer, lazy: true, + sort: true, data: this.activeData, showCheckbox: true, highlightCurrent: true, @@ -1048,7 +1085,7 @@ export default class OntologySelectionService { }); let component = parseHTMLFromString(html, true); - component = component.body.children[0]; + component = component[0]; const btn = component.querySelector('[data-target="delete"]'); btn.addEventListener('click', this.#handleDeleteButton.bind(this)); @@ -1143,13 +1180,12 @@ export default class OntologySelectionService { }); let component = parseHTMLFromString(html, true); - component = ontologyList.appendChild(component.body.children[0]); + component = ontologyList.appendChild(component[0]); } let html = interpolateString(this.templates.value, { label: label }); - let component = parseHTMLFromString(html, true); - component = ontologyList.appendChild(component.body.children[0]); + component = ontologyList.appendChild(component[0]); } } 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 3f5fcf914..5cdd8cd3f 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 @@ -168,15 +168,35 @@ export default class OntologySelectionModal { }); let modal = parseHTMLFromString(html, true); - modal = document.body.appendChild(modal.body.children[0]); + modal = document.body.appendChild(modal[0]); let buttons = modal.querySelectorAll('#target-modal-footer > button'); buttons = Object.fromEntries([...buttons].map(elem => { return [elem.getAttribute('id'), elem]; })); - buttons.cancel.addEventListener('click', this.#handleCancel.bind(this)); - buttons.confirm.addEventListener('click', this.#handleConfirm.bind(this)); + let cancelHnd, escapeHnd; + cancelHnd = (e) => { + const willClose = this.#handleCancel(e); + if (willClose) { + document.removeEventListener('keyup', escapeHnd); + } + }; + + escapeHnd = (e) => { + const activeFocusElem = document.activeElement; + if (!!activeFocusElem && activeFocusElem.matches('input, textarea, button, select')) { + return; + } + + if (e.code === 'Escape') { + cancelHnd(e); + } + }; + + document.addEventListener('keyup', escapeHnd); + buttons?.cancel?.addEventListener?.('click', cancelHnd); + buttons?.confirm?.addEventListener?.('click', this.#handleConfirm.bind(this)); this.dialogue = { // elements @@ -228,7 +248,7 @@ export default class OntologySelectionModal { */ #handleCancel(e) { if (!this.isOpen()) { - return; + return false; } const event = new CustomEvent( @@ -240,6 +260,7 @@ export default class OntologySelectionModal { } ); this.dialogue?.element.dispatchEvent(event); + return true; } /** diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/forms/generic/relationSelector.js b/CodeListLibrary_project/cll/static/js/clinicalcode/forms/generic/relationSelector.js new file mode 100644 index 000000000..e144242e6 --- /dev/null +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/forms/generic/relationSelector.js @@ -0,0 +1,881 @@ +import DebouncedTask from '../../components/tasks/debouncedTask.js'; + +/** + * Class to manage related entity selector component(s) + * + * @class + * @constructor + */ +export default class RelationSelector { + /** + * @desc default constructor options + * @type {Record<string, any>} + * @static + * @constant + * + */ + static #DefaultOpts = { + /** + * @desc describes a set of props assoc. the field represented by this component + * @type {Record<string, any>} + */ + properties: { + /** + * @desc the fetch request target, i.e. the endpoint from which we retrieve results + * @type {string} + */ + lookup: '/api/v1/phenotypes/', + /** + * @desc describes how to generate a label for a selected item + * @type {Array<string>} + */ + display: ['id', 'history_id', 'name'], + /** + * @desc describes how to generate a label for an entity search result + * @type {Array<string>} + */ + labeling: ['phenotype_id', 'name'], + /** + * @desc describes how to generate a reference from the entity's detail + * @type {Array<string>} + */ + reference: ['phenotype_id', 'phenotype_version_id'], + /** + * @desc describes how references should be stored in the resulting data (mapped by index) + * @type {Array<string>} + */ + storage: ['id', 'history_id'], + /** + * @desc the delay duration, in milliseconds, before we attempt to fetch results + * @type {number} + */ + searchDelay: 250, + /** + * @desc the minimum num. of chars required before querying the results endpoint + * @note excludes whitespace-only strings + * @type {number} + */ + searchMinChars: 1, + }, + /** + * @desc + * @type {Record<string, number>} + * @see {module:utils.fetchWithCtrl} + */ + requestCtrl: { + /** + * @desc fetch req timeout (in seconds) + * @type {number} + */ + timeout: 5, + /** + * @desc fetch req backoff factor for delay on failed retry attempts + * @type {number} + */ + backoff: 50, + /** + * @desc max number of retry attempts on each search attempt + * @type {number} + */ + retries: 1, + } + }; + + /** + * @desc + * @type {Record<string, HTMLElement>} + * @private + */ + #layout = {}; + + /** + * @desc + * @type {Record<string, Record<string, HTMLElement>>} + * @private + */ + #templates = {}; + + /** + * @desc + * @type {string} + * @private + */ + #token = null; + + /** + * @desc + * @type {DebouncedTask} + * @private + */ + #searchTask = null; + + /** + * @desc specifies a reference to the the object being edited + * @type {string} + * @private + */ + #selfTarget = null; + + /** + * @desc describes the state of the component, i.e. index of the active element within the dropdown selector and the resultset (if any) + * @type {Record<string, any>} + * @private + */ + #state = { + results: null, + activeIndex: -1, + activeObject: null, + }; + + /** + * @param {HTMLElement} element the HTMLElement assoc. with this component + * @param {Record<string, any>} fieldData specifies the initial value, properties, validation, and options of the component + * @param {Record<string, any>} [opts] optionally specify any additional component opts; see {@link RelationSelector.#DefaultOpts} + */ + constructor(element, fieldData, opts) { + let { value, properties, mapping } = isObjectType(fieldData) ? fieldData : { }; + mapping = isObjectType(mapping) ? mapping : { }; + properties = isObjectType(properties) ? properties : { }; + + opts = isObjectType(opts) ? opts : {}; + opts.mapping = mergeObjects( + isObjectType(opts.mapping) + ? opts.mapping + : { }, + mapping + ); + opts.properties = mergeObjects( + isObjectType(opts.properties) ? opts.properties : {}, + properties, + false, + true + ); + + opts = mergeObjects(opts, RelationSelector.#DefaultOpts, false, true); + + const lookup = opts.properties.lookup; + opts.properties.lookup = stringHasChars(lookup) + ? RelationSelector.#GetBrandedLookup(lookup) + : ''; + + this.data = Array.isArray(value) ? value : []; + this.props = opts; + this.dirty = false; + this.element = element; + + const obj = this.props.object; + if (isObjectType(obj)) { + const { reference, storage } = this.props.properties; + + let comp = !isNullOrUndefined(obj?.[storage[0]]) ? obj[storage[0]] : ''; + for (let i = 1; i < Math.min(reference.length, storage.length); ++i) { + if (obj?.[storage[i]]) { + comp += '/' + obj[storage[i]]; + } + } + + this.#selfTarget = comp; + } + + this.#token = getCookie('csrftoken'); + this.#searchTask = new DebouncedTask(this.#fetchResults.bind(this), opts.properties.searchDelay, true); + + this.#initialise(); + } + + /************************************* + * * + * Static * + * * + *************************************/ + static #GetBrandedLookup(url, keepParameters = false) { + if (typeof url !== 'string' || !stringHasChars(url)) { + const details = typeof url === 'string' + ? `a "${typeof url}` + : (url.length && isStringWhitespace(url) ? 'a whitespace-only "string"' : 'an empty "string"'); + + throw new Error(`[RelationSelector::GetBrandedLookup] Invalid lookup URL, expected a valid URL string but got ${details}; where URL<value="${url}">`); + } + + const host = getBrandedHost(); + url = `${host}/${url}`; + url = url.replace(/(?<!:\/)(?<=\/)(?:\/{1})|(.)(?:\/+$)/g, '$1'); + + if (!stringHasChars(url)) { + const details = url.length && isStringWhitespace(url) ? 'a whitespace-only "string"' : 'an empty "string"'; + throw new Error(`[RelationSelector::GetBrandedLookup] Invalid lookup URL, expected non-empty string but got ${details}; where URL<value="${url}">`); + } + + try { + url = new URL(url); + } catch (e) { + throw new Error(`[RelationSelector::GetBrandedLookup] Failed to parse the provided lookup URL<value="${url}"> with err:\n\n${e}`); + } + + if (keepParameters) { + return url.href; + } + + return url.origin + url.pathname; + } + + + /************************************* + * * + * Getter * + * * + *************************************/ + /** + * @returns {Array<Record<string, string|number>} an array describing the entities contained by this component + */ + getData() { + return this.data; + } + + /** + * @returns {HTMLElement} the assoc. element + */ + getElement() { + return this.element; + } + + /** + * @returns {bool} returns the dirty state of this component + */ + isDirty() { + return this.dirty; + } + + + /************************************* + * * + * Setter * + * * + *************************************/ + /** + * @desc informs the top-level parent that we're dirty and updates our internal dirty state + * + * @return {this} + */ + makeDirty() { + window.entityForm.makeDirty(); + this.dirty = true; + return this; + } + + + /************************************* + * * + * Renderables * + * * + *************************************/ + /** + * @desc toggles the expansion of the result section + * + * @return {this} + */ + #toggleResultVis(val) { + const layout = this.#layout; + if (!layout) { + return; + } + + layout.search.setAttribute('aria-expanded', val) + return this; + } + + /** + * @desc toggles the page content presentation section + * + * @return {this} + */ + #toggleLayoutContentVis(val) { + const layout = this.#layout; + if (!layout) { + return; + } + + if (val) { + layout.contentGroup.classList.add('show'); + layout.noneAvailable.classList.remove('show'); + } else { + layout.noneAvailable.classList.add('show'); + layout.contentGroup.classList.remove('show'); + } + + return this; + } + + #renderInfobox(show = false, details = null, isLoading = false) { + const layout = this.#layout; + if (!layout) { + return; + } + + const { loader, infobox, infolabel } = layout; + if (!show) { + infobox.classList.remove('entity-dropdown__infobox--show'); + return; + } + + let labelContent; + clearAllChildren(infolabel); + + if (isObjectType(details) && details?.label && stringHasChars(details?.label)) { + labelContent = details.label; + } else if (typeof details === 'string' && stringHasChars(details)) { + labelContent = details; + } + + if (!!labelContent) { + createElement('p', { parent: infolabel, text: labelContent, className: 'entity-dropdown__infobox-label' }); + } + + if (isLoading) { + loader.classList.add('bounce-loader--show'); + } else { + loader.classList.remove('bounce-loader--show'); + } + + infobox.classList.add('entity-dropdown__infobox--show'); + return this; + } + + #renderResults(results, isLoading = false) { + // i.e. render all results + /* + <template data-name="item" data-view="base"> + - ${ref} -> id + - ${index} -> list index + - ${label} -> text content + */ + + const props = this.props; + const state = this.#state; + const layout = this.#layout; + const templates = this.#templates; + state.results = results; + state.activeIndex = -1; + state.activeObject = null; + + clearAllChildren(layout.results, '.entity-dropdown__item'); + + const data = isObjectType(results) ? results?.data : null; + if (!data) { + this.#renderInfobox(true, null, true); + } else { + let { resultSz, totalSz } = results.info; + + const wasEqual = resultSz === totalSz; + const lblTarget = props.properties.labeling; + const refTarget = props.properties.reference; + for (let i = 0; i < data.length; ++i) { + const res = data[i]; + + let ref = res[refTarget[0]]; + for (let i = 1; i < refTarget.length; ++i) { + ref += '/' + res[refTarget[i]]; + } + + if (this.#selfTarget === ref || this.getSelectedItem(ref)) { + resultSz -= 1; + continue; + } + + let label = res[lblTarget[0]]; + for (let i = 1; i < lblTarget.length; ++i) { + label += (i === 1 ? '/' : ' - ') + res[lblTarget[i]]; + } + + composeTemplate(templates.results.item, { + params: { + ref: ref, + index: i, + label: label, + }, + parent: layout.results, + }); + } + + if (resultSz > 0) { + if (wasEqual) { + totalSz = Math.min(resultSz, totalSz); + } + + results.info.label = `Showing ${resultSz.toLocaleString()} of ${totalSz.toLocaleString()} result(s)`; + } else { + results.info.label = `No known results for these search terms`; + } + + this.#renderInfobox(true, results.info, false); + } + + const isExpanded = results || isLoading; + if (!isExpanded) { + state.results = null; + state.activeIndex = -1; + state.activeObject = null; + } + this.#toggleResultVis(isExpanded); + } + + #renderLayout() { + const data = this.data; + const props = this.props; + const hasData = data.length > 0; + this.#toggleLayoutContentVis(hasData); + + const layout = this.#layout; + clearAllChildren(layout.contentList, '.entity-selector-group__item'); + + // Draw children + if (hasData) { + const templates = this.#templates; + const dspTarget = props.properties.display; + const srcTarget = props.properties.storage; + const refTarget = props.properties.reference; + + let item, comp, label; + for (let i = 0; i < data.length; ++i) { + item = data[i]; + + comp = item?.__comp; + if (!comp) { + comp = item[srcTarget[0]]; + for (let i = 1; i < Math.min(refTarget.length, srcTarget.length); ++i) { + comp += '/' + item[srcTarget[i]]; + } + + item.__comp = comp; + } + + label = item?.__label; + if (!label) { + label = item[dspTarget[0]]; + for (let i = 1; i < dspTarget.length; ++i) { + label += (i === 1 ? '/' : ' - ') + item[dspTarget[i]]; + } + + item.__label = label; + } + + composeTemplate(templates.content.item, { + params: { + ref: comp, + label: label, + }, + parent: layout.contentList, + }); + } + } + } + + + /************************************* + * * + * Private * + * * + *************************************/ + getSelectedItem(ref) { + const props = this.props; + const selected = this.data; + const srcTarget = props.properties.storage; + const refTarget = props.properties.reference; + return selected.find(x => { + let comp = x?.__comp; + if (!comp) { + let comp = x[srcTarget[0]]; + for (let i = 1; i < Math.min(refTarget.length, srcTarget.length); ++i) { + comp += '/' + x[srcTarget[i]]; + } + + x.__comp = comp; + } + + return ref === comp; + }); + } + + #handleFetchAccept(res) { + if (!res.ok) { + throw new Error(`Failed request with Status<code: ${res.status}>`, { cause: 'status' }); + } + + return true; + } + + #handleFetchResolve(res) { + const layout = this.#layout; + if (!layout) { + return; + } + + if (!layout.search.contains(document.activeElement)) { + return; + } + + const { + data = [], + page_size = 20, + total_pages = 1 + } = res; + + if (!Array.isArray(data)) { + throw new Error(`Failed to retrieve results`); + } + + let totalSz, resultSz; + resultSz = data.length; + if (typeof page_size === 'number' && typeof total_pages === 'number') { + totalSz = total_pages > 1 ? page_size*total_pages : Math.min(page_size, resultSz); + } else { + totalSz = resultSz; + } + + this.#renderResults({ + data: data, + info: { totalSz, resultSz }, + hasResults: resultSz > 0, + }); + } + + #handleFetchErrors(err, url) { + const fieldName = this.element.getAttribute('data-name'); + + let msg; + if (!err || err instanceof Error) { + const cause = err?.cause; + if (getObjectClassName(cause).match(/(object|string)/gi)) { + if (typeof cause === 'string') { + switch (cause) { + case 'unknown': + case 'timeout': + msg = `[${fieldName}] ${e.message}`; + break; + + default: + break; + } + } else if (isObjectType(cause)) { + // Future custom err handling if required??? + + } + } + } + + msg = stringHasChars(msg) + ? msg + : `[${fieldName}] It looks like we failed to get the results, please try again in a moment.`; + + window.ToastFactory.push({ + type: 'danger', + message: msg, + duration: 4000, + }); + + console.error(`[RelationSelector->fetchResults] Failed to fetch results from URL<${url}> with err:\n\n${err}`); + } + + #fetchResults({ url, opts, ctrl, parameters } = {}) { + const props = this.props; + + if (!!parameters && parameters instanceof URLSearchParams) { + parameters = '?' + parameters; + } else if (isObjectType(parameters)) { + parameters = '?' + new URLSearchParams(parameters); + } else { + parameters = ''; + } + + url = typeof url === 'string' + ? url + parameters + : props.properties.lookup + parameters; + + const token = this.#token; + ctrl = mergeObjects(isObjectType(ctrl) ? ctrl : { }, props.requestCtrl, false, true); + opts = mergeObjects( + isObjectType(opts) ? opts : { }, + { + method: 'GET', + credentials: 'same-origin', + withCredentials: true, + headers: { + 'Accept': 'application/json', + 'X-CSRFToken': token, + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + }, + false, + true + ); + + const errorHandler = opts.errorHandler; + const resultHandler = opts.resultHandler; + return fetchWithCtrl(url, opts, ctrl) + .then(res => res.json()) + .then(res => { + if (typeof resultHandler === 'function') { + resultHandler?.(res); + } else if (resultHandler !== null) { + this.#handleFetchResolve(res); + } + }) + .catch(err => { + if (typeof errorHandler === 'function') { + errorHandler?.(err, url) + } else if (errorHandler !== null) { + this.#handleFetchErrors(err, url); + } + }); + } + + + /************************************* + * * + * Events * + * * + *************************************/ + #initEvents() { + const layout = this.#layout; + window.addEventListener('focusout', this.#handleBlur.bind(this)); + document.addEventListener('click', this.#handleClick.bind(this)); + + const inputbox = layout.inputbox; + inputbox.addEventListener('input', this.#handleInput.bind(this)); + inputbox.addEventListener('keyup', this.#handleInputKeyUp.bind(this)); + inputbox.addEventListener('keydown', this.#handleInputKeyDown.bind(this)); + } + + #handleClick(e) { + const state = this.#state; + const props = this.props; + const layout = this.#layout; + const data = isObjectType(state?.results) && Array.isArray(state?.results?.data) + ? state.results.data + : null; + + if (!layout) { + return; + } + + const { target } = e; + const { results, inputbox, contentList } = layout; + if (data && results.contains(target) && target.matches('[data-action="select"]')) { + let index = target.getAttribute('data-index'); + index = parseInt(index); + if (typeof index !== 'number' || isNaN(index)) { + return; + } + + const srcTarget = props.properties.storage; + const refTarget = props.properties.reference; + const selection = data[index]; + this.#searchTask.clear(); + this.#renderResults(null, false); + + const ref = { }; + for (let i = 0; i < srcTarget.length; ++i) { + if (refTarget?.[i]) { + ref[srcTarget[i]] = selection[refTarget[i]]; + } else { + ref[srcTarget[i]] = selection[srcTarget[i]]; + } + } + this.data.push(ref); + this.#renderLayout(); + this.makeDirty(); + + inputbox.focus(); + inputbox.blur(); + inputbox.value = ''; + } else if (contentList.contains(target) && target.matches('[data-fn="button"][data-action="remove"]')) { + const ref = target.getAttribute('data-ref'); + if (!stringHasChars(ref)) { + return; + } + + const item = this.getSelectedItem(ref); + const index = this.data.findIndex(x => x === item); + if (index >= 0) { + this.data.splice(index, 1); + this.makeDirty(); + this.#renderLayout(); + } + } + } + + #handleBlur(e) { + const layout = this.#layout; + if (!layout) { + return; + } + + const { search } = layout; + const { relatedTarget } = e; + if (!relatedTarget || !search.contains(relatedTarget)) { + this.#state.results = null; + this.#state.activeIndex = -1; + this.#state.activeObject = null; + this.#searchTask.clear(); + this.#renderResults(null, false); + } + } + + #handleInput(e) { + const value = e.target.value; + const props = this.props?.properties; + const minSearchChars = typeof props?.searchMinChars === 'number' ? props?.searchMinChars : 0; + if (!stringHasChars(value) || (minSearchChars && value.length < minSearchChars)) { + this.#renderResults(null, false); + return; + } + this.#renderResults(null, true); + + this.#searchTask({ + ctrl: { beforeAccept: this.#handleFetchAccept.bind(this) }, + parameters: { search: value } + }); + } + + #handleInputKeyDown(e) { + const state = this.#state; + const props = this.props?.properties; + const layout = this.#layout; + const inputbox = e.target; + if (!layout) { + return; + } + + const key = e.key; + const data = isObjectType(state?.results) && Array.isArray(state?.results?.data) + ? state.results.data + : null; + + switch (key) { + case 'ArrowUp': { + e.preventDefault(); + + if (data) { + const len = data.length; + state.activeIndex = state.activeIndex < 1 ? len - 1 : ((state.activeIndex + 1)%len + len)%len; + } else { + state.activeIndex = -1; + } + } break; + + case 'ArrowDown': { + e.preventDefault(); + + if (data) { + const len = data.length; + state.activeIndex = state.activeIndex < 1 ? 0 : ((state.activeIndex - 1)%len + len)%len; + } else { + state.activeIndex = -1; + } + } break; + + case 'Enter': { + if (state.activeObject && state.activeIndex >= 0) { + const srcTarget = props.storage; + const refTarget = props.reference; + const selection = state.activeObject.selection; + this.#searchTask.clear(); + this.#renderResults(null, false); + + const ref = { }; + for (let i = 0; i < srcTarget.length; ++i) { + if (refTarget?.[i]) { + ref[srcTarget[i]] = selection[refTarget[i]]; + } else { + ref[srcTarget[i]] = selection[srcTarget[i]]; + } + } + this.data.push(ref); + this.#renderLayout(); + this.makeDirty(); + + inputbox.blur(); + inputbox.value = ''; + } + } break; + + case 'Escape': + this.#searchTask.clear(); + this.#renderResults(null, false); + return; + + default: + return; + } + + if (data && state.activeIndex >= 0) { + if (state?.activeObject?.element) { + state.activeObject.element.setAttribute('aria-selected', false); + } + + const elem = layout.results.querySelector(`[data-index="${state.activeIndex}"]`); + if (elem) { + elem.setAttribute('aria-selected', true); + scrollContainerTo(layout.results, elem); + } + + state.activeObject = { + element: elem, + selection: data[state.activeIndex], + }; + } else if (state.activeObject) { + const elem = state.activeObject.element; + if (elem) { + elem.setAttribute('aria-selected', false); + layout.results.scrollTo({ top: 0, left: 0, behavior: 'instant' }); + } + state.activeObject = null; + } + } + + #handleInputKeyUp(e) { + const key = e.key; + if (key === 'Enter') { + e.preventDefault(); + return; + } + } + + + /************************************* + * * + * Initialiser * + * * + *************************************/ + #initialise() { + const elem = this.element; + const layout = this.#layout; + elem.querySelectorAll('[data-area]').forEach(v => { + const role = v.getAttribute('data-area'); + if (stringHasChars(role)) { + layout[role] = v; + } + }); + + const templates = this.#templates; + elem.querySelectorAll('template[data-name]').forEach(v => { + let name = v.getAttribute('data-name'); + let view = v.getAttribute('data-view'); + if (!stringHasChars(view)) { + view = 'base'; + } + + let group = templates?.[view]; + if (!group) { + group = { }; + templates[view] = group; + } + + group[name] = v; + }); + + this.#initEvents(); + this.#renderLayout(); + } +}; diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/forms/generic/urlReferenceListCreator.js b/CodeListLibrary_project/cll/static/js/clinicalcode/forms/generic/urlReferenceListCreator.js index da199e4fa..e1c0fd9dd 100644 --- a/CodeListLibrary_project/cll/static/js/clinicalcode/forms/generic/urlReferenceListCreator.js +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/forms/generic/urlReferenceListCreator.js @@ -1,4 +1,4 @@ -import { PUBLICATION_MIN_MSG_DURATION } from '../entityFormConstants.js'; +import { TOAST_MSG_DURATION } from '../entityFormConstants.js'; /** * PUBLICATION_NOTIFICATIONS * @desc notification text that is used to present information @@ -89,7 +89,7 @@ export default class UrlReferenceListCreator { return ` <div class="publication-list-group__list-item" data-target="${index}"> <div class="publication-list-group__list-item-url"> - <a href="${listItem.url}">${listItem.title}</a> + <a href="${listItem.url}" target=_blank rel="noopener">${listItem.title}</a> </div> <button class="publication-list-group__list-item-btn" data-target="${index}"> <span class="delete-icon"></span> @@ -161,11 +161,13 @@ export default class UrlReferenceListCreator { e.stopPropagation(); const textItem = strictSanitiseString(this.textInput.value); + this.textInput.value = textItem; + if (!this.textInput.checkValidity() || isNullOrUndefined(textItem) || isStringEmpty(textItem)) { window.ToastFactory.push({ type: 'danger', message: URL_REF_NOTIFICATIONS.InvalidTitle, - duration: PUBLICATION_MIN_MSG_DURATION, + duration: TOAST_MSG_DURATION, }); return; } @@ -175,7 +177,7 @@ export default class UrlReferenceListCreator { window.ToastFactory.push({ type: 'danger', message: URL_REF_NOTIFICATIONS.InvalidURL, - duration: PUBLICATION_MIN_MSG_DURATION, + duration: TOAST_MSG_DURATION, }); return; } diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/forms/generic/variableCreator.js b/CodeListLibrary_project/cll/static/js/clinicalcode/forms/generic/variableCreator.js new file mode 100644 index 000000000..8bb1fd4e0 --- /dev/null +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/forms/generic/variableCreator.js @@ -0,0 +1,1265 @@ +import DoubleRangeSlider from '../../components/doubleRangeSlider.js'; +import { parseAsFieldType, resolveRangeOpts } from '../entityCreator/utils.js'; + +/** + * Class to manage variable & measurement creation + * + * @class + * @constructor + */ +export default class VariableCreator { + /** + * @desc default constructor options + * @type {Record<string, any>} + * @static + * @constant + * + */ + static #DefaultOpts = {}; + + /** + * @desc describes the validation params for specific field(s) + * @type {Record<string, Record<string, any>>} + * @static + * @constant + * + */ + static #Validation = { + name: { + minlength: 2, + maxlength: 500, + }, + description: { + minlength: 0, + maxlength: 500, + }, + string: { + minlength: 0, + maxlength: 1024, + } + }; + + /** + * @desc + * @type {Record<string, HTMLElement>} + * @private + */ + #layout = {}; + + /** + * @desc + * @type {Record<string, Record<string, HTMLElement>>} + * @private + */ + #templates = {}; + + /** + * @desc + * @type {Record<string, any>|null} + * @private + */ + #modalState = null; + + /** + * @param {HTMLElement} element the HTMLElement assoc. with this component + * @param {Record<string, any>} fieldData specifies the initial value, properties, validation, and options of the component + * @param {Record<string, any>} [opts] optionally specify any additional component opts; see {@link VariableCreator.#DefaultOpts} + */ + constructor(element, fieldData, opts) { + let { value, options, properties, txt } = isObjectType(fieldData) ? fieldData : { }; + txt = isObjectType(txt) ? txt : { }; + options = Array.isArray(options) ? options : []; + properties = isObjectType(properties) ? properties : { }; + + let fieldName = element.getAttribute('data-field'); + fieldName = stringHasChars(fieldName) ? fieldName : 'Variable'; + fieldName = transformTitleCase(fieldName).replace('_', ' '); + + opts = isObjectType(opts) ? opts : {}; + opts.txt = mergeObjects( + isObjectType(opts.txt) + ? opts.txt + : { single: fieldName, plural: fieldName + '(s)' }, + txt + ); + opts.options = mergeObjects(Array.isArray(opts.options) ? opts.options : [], options, false, true); + opts.properties = mergeObjects(isObjectType(opts.properties) ? opts.properties : {}, properties, false, true); + + this.data = isObjectType(value) ? value : {}; + this.props = mergeObjects(opts, VariableCreator.#DefaultOpts); + this.dirty = false; + this.element = element; + + this.#initialise(); + } + + + /************************************* + * * + * Getter * + * * + *************************************/ + /** + * @returns {Record<string, any>} an obj describing the variables contained by this component + */ + getData() { + return this.data; + } + + /** + * @returns {HTMLElement} the assoc. element + */ + getElement() { + return this.element; + } + + /** + * @returns {bool} returns the dirty state of this component + */ + isDirty() { + return this.dirty; + } + + + /************************************* + * * + * Setter * + * * + *************************************/ + /** + * @desc informs the top-level parent that we're dirty and updates our internal dirty state + * + * @return {this} + */ + makeDirty() { + window.entityForm.makeDirty(); + this.dirty = true; + return this; + } + + /** + * @desc toggles the page content presentation section + * + * @return {this} + */ + #toggleLayoutContentVis(val) { + const layout = this.#layout; + if (!layout) { + return; + } + + if (val) { + layout.contentGroup.classList.add('show'); + layout.noneAvailable.classList.remove('show'); + layout?.clearBtn?.classList?.remove?.('hide'); + } else { + layout.noneAvailable.classList.add('show'); + layout.contentGroup.classList.remove('show'); + layout?.clearBtn?.classList?.add?.('hide'); + } + + return this; + } + + /** + * @desc toggles the modal content presentation section + * + * @return {this} + */ + #toggleModalContentVis(val) { + const ctx = this.#modalState?.ctx; + if (!ctx) { + return; + } + + if (val) { + ctx.content.classList.add('show'); + ctx.none.classList.remove('show'); + } else { + ctx.none.classList.add('show'); + ctx.content.classList.remove('show'); + } + + return this; + } + + + /************************************* + * * + * Renderables * + * * + *************************************/ + #renderLayout() { + const layout = this.#layout; + const templates = this.#templates; + const { options } = this.props; + + const data = this.data; + const dataLen = Object.values(data).length; + clearAllChildren(layout.contentList); + + if (!dataLen) { + this.#toggleLayoutContentVis(false); + return this; + } + + const hasOptions = Array.isArray(options); + for (const key in data) { + const item = data[key]; + if (!item) { + continue; + } + + let { value, type } = item; + + let processed = false; + if (hasOptions) { + let relative = options.find(x => x.name == key); + relative = !!relative ? relative.value : null; + if (!isNullOrUndefined(relative) && stringHasChars(relative.format)) { + if (isRecordType(item.value)) { + value = pyFormat(relative.format, item.value); + } else { + value = pyFormat(relative.format, { value: item.value }); + } + processed = true; + } + } + + if (!processed) { + if (type.endsWith('_range')) { + const suffix = type.includes('percentage') ? '%' : ''; + value = `${value[0]}${suffix} - ${value[1]}${suffix}`; + } else if (type.includes('percentage')) { + value = `${value}%`; + } else { + value = value.toString(); + } + } + + type = transformTitleCase(item.type).replace('_', ' '); + composeTemplate(templates.vinterface.item, { + params: { + ref: key, + name: `${item.name}`, + type: `${type}`, + value: value, + }, + parent: layout.contentList, + render: (elem) => { + elem = elem.shift(); + + }, + }); + } + this.#toggleLayoutContentVis(true); + return this; + } + + #openModal(editKey = null) { + const tmpl = this.props; + window.ModalFactory.create({ + id: 'var-creator-dialog', + title: stringHasChars(tmpl?.properties?.label) ? tmpl?.properties?.label : 'Variable Creator', + size: window.ModalFactory.ModalSizes.Large, + content: '', + buttons: [ + { + name: 'Confirm', + type: window.ModalFactory.ButtonTypes.CONFIRM, + html: `<button class="primary-btn text-accent-darkest bold secondary-accent" id="confirm-button"></button>`, + }, + { + name: 'Cancel', + type: window.ModalFactory.ButtonTypes.REJECT, + html: `<button class="secondary-btn text-accent-darkest bold washed-accent" id="reject-button"></button>`, + }, + ], + beforeAccept: () => { + const packet = this.#validatePacket(); + if (!packet || !packet?.success) { + let message = packet?.message; + let reqClosure = !stringHasChars(message); + message = !reqClosure ? message : 'Validation'; + + window.ToastFactory.push({ + type: 'danger', + message: message, + duration: 3000, + }); + + if (reqClosure) { + console.error('[VariableCreator::Submit] Failed to submit form.'); + return; + } + + return new window.ModalFactory.ModalResults('Cancel'); + } + + if (packet.editKey !== this.#modalState.info.editKey) { + if (this.data?.[this.#modalState.info.editKey]) { + delete this.data[this.#modalState.info.editKey]; + } + } + this.data[packet.editKey] = packet.value; + return this.data; + }, + onRender: (modal) => this.#renderOptionPanel(modal, editKey), + }) + .then(res => { + this.makeDirty(); + this.#renderLayout(); + }) + .catch(res => { + this.#modalState = null; + + if (!!res && !(res instanceof ModalFactory.ModalResults)) { + return console.error(res); + } + }); + } + + #renderOptionPanel(modal, editKey = null) { + const isUpdate = stringHasChars(editKey); + + const ctx = { }; + const state = { + ctx: ctx, + data: null, + isUpdate: isUpdate ? editKey : null, + isDirty: false, + isInEditor: false, + }; + this.#modalState = state; + + const tmpl = this.props; + const templates = this.#templates; + + const opts = tmpl.options; + const props = tmpl.properties; + + const hasOpts = Array.isArray(opts) && opts.length > 0; + const typesAllowed = Array.isArray(props.allow_types) && props.allow_types.length > 0 ? props.allow_types : null; + const unknownAllowed = !!typesAllowed && !!props.allow_unknown; + const descriptionAllowed = !!props.allow_description; + state.hasOpts = hasOpts; + state.typesAllowed = typesAllowed; + state.unknownAllowed = unknownAllowed; + state.descriptionAllowed = descriptionAllowed; + + const innerModal = modal.querySelector('#target-modal-content'); + innerModal.classList.add('slim-scrollbar'); + + composeTemplate(templates.vinterface.panel, { + params: props.selector, + parent: innerModal, + render: (elems) => { + ctx.panel = elems[0]; + ctx.none = ctx.panel.querySelector(':scope > [data-section="none"]'); + ctx.header = ctx.panel.querySelector(':scope > [data-section="header"]'); + ctx.content = ctx.panel.querySelector(':scope > [data-section="content"]'); + + if (hasOpts) { + const selItems = []; + ctx.selector = ctx.panel.querySelector('#tmpl-selector'); + + for (let i = 0; i < opts.length; ++i) { + selItems.push(createElement('option', { + value: opts[i].name, + innerText: opts[i].value.name, + })); + } + + if (unknownAllowed) { + selItems.push(createElement('option', { + value: 'unknown', + innerText: 'Custom Measure', + parent: ctx.selector, + })); + } + + selItems.sort((a, b) => { + return a.innerText < b.innerText ? -1 : (a.innerText > b.innerText ? 1 : 0); + }); + + for (let i = 0; i < selItems.length; ++i) { + selItems[i] = ctx.selector.appendChild(selItems[i]); + } + ctx.selItems = selItems; + } + + if (!hasOpts || isUpdate) { + ctx.header.style.cssText = 'display: none !important; visibility: hidden;'; + } + } + }); + + if (isUpdate) { + // Render form with data + const varopt = hasOpts ? opts.find(x => x.name === editKey) : null; + + const ref = varopt ? editKey : 'unknown'; + const data = deepCopy(this.data[editKey]); + state.data = data; + state.info = { + editKey: editKey, + ref: ref, + opts: ref === 'unknown' ? state.typesAllowed : varopt?.value, + type: data.type, + label: data.name, + }; + this.#renderVariableForm(); + } else if (hasOpts) { + // Render specified + ctx.selItems.forEach(x => x.hidden = x.value !== 'unknown' && x.value in this.data); + + ctx.selector.addEventListener('change', (e) => { + const trg = e.target; + + let idx = trg.selectedIndex; + if (typeof idx !== 'number' || idx < 1) { + this.#toggleModalContentVis(false); + return; + } + + const opt = trg.options[idx]; + const val = opt.value; + this.#tryOpenEditor(val); + }); + this.#toggleModalContentVis(false); + } else { + // Render custom + const ref = 'unknown'; + const len = Object.keys(this.data).length + 1; + const lbl = `Item ${len}`; + + const info = { + ref: ref, + opts: state.typesAllowed, + type: state.typesAllowed[0], + label: lbl, + editKey: transformSnakeCase(lbl), + }; + + state.info = info; + state.data = { + name: info.label, + type: info.type, + value: null, + }; + + this.#renderVariableForm(); + } + } + + #tryOpenEditor(editKey) { + const tmpl = this.props; + const state = this.#modalState; + + let ref, type, opts, label; + if (editKey === 'unknown') { + ref = editKey; + opts = state.typesAllowed; + type = opts[0]; + + const len = Object.keys(this.data).length + 1; + label = `Item ${len}` + editKey = transformSnakeCase(label); + } else { + opts = tmpl.options.find(x => x.name === editKey); + opts = opts.value; + + ref = editKey; + type = opts.type; + label = opts.name; + } + + let promise; + if (!state.isInEditor || !state.isDirty) { + promise = Promise.resolve(); + } else { + promise = window.ModalFactory.create({ + id: generateUUID(), + ref: 'prompt', + title: 'Are you sure?', + content: 'You will lose your current progress if you switch to another type without confirming.', + }) + } + + return promise + .then(() => { + let data = this.data[editKey]; + data = isObjectType(data) ? deepCopy(data) : { }; + + state.info = { editKey, ref, type, label, opts }; + state.data = { + name: label, + type: type, + value: null, + }; + + this.#renderVariableForm(); + }) + .catch((res) => { + if (!!res && !(res instanceof ModalFactory.ModalResults)) { + return console.error(res); + } + }) + .finally(() => { + createElement('a', { href: '#var-creator-dialog' }).click(); + }); + } + + #renderVariableForm() { + const state = this.#modalState; + const templates = this.#templates; + + const { ctx, data, info } = state; + state.isInEditor = true; + + if (!state.isUpdate) { + state.isDirty = false; + } + + const disableInputs = state.isUpdate || info.ref !== 'unknown'; + clearAllChildren(ctx.content); + + const ctrls = { }; + state.ctrls = ctrls; + + composeTemplate(templates.inputs.inputbox, { + params: { + id: 'name', + ref: 'name', + value: data.name, + label: 'Name', + placeholder: 'Enter name...', + disabled: disableInputs ? 'disabled' : '', + mandatory: true, + minlength: VariableCreator.#Validation.name.minlength, + maxlength: VariableCreator.#Validation.name.maxlength, + }, + parent: ctx.content, + render: (elem) => { + elem = elem.shift(); + ctrls.name = elem; + + const input = elem.querySelector('input'); + input.addEventListener('change', (e) => { + const val = parseAsFieldType({ validation: { type: 'string' } }, e.target.value); + if (!!val && val?.success) { + if (val.value !== data.name) { + state.isDirty = true; + } + + data.name = val.value; + e.target.value = val.value; + } + }); + }, + }); + + if (info.ref === 'unknown') { + const typeItems = []; + for (let i = 0; i < state.typesAllowed.length; ++i) { + const type = state.typesAllowed[i]; + const typeLabel = transformTitleCase(type).replace('_', ' '); + typeItems.push(createElement('option', { value: type, innerText: typeLabel })); + } + + typeItems.sort((a, b) => { + return a.innerText < b.innerText ? -1 : (a.innerText > b.innerText ? 1 : 0); + }); + + const idx = typeItems.findIndex(x => x.value === data.type); + const dropdown = createElement('select', { + childNodes: typeItems, + attributes: { + id: 'type', + name: 'type', + }, + selectedIndex: idx, + }); + dropdown.setAttribute('placeholder-text', 'Select Type...'); + + ctrls.dropdown = { + group: createElement('div', { + parent: ctx.content, + className: 'input-field-container number-input', + dataset: { + ref: 'type', + ctrl: 'dropdown', + }, + childNodes: [ + `<p class="input-field-container__label input-field-container--fill-w"> + Datatype + <span class="input-field-container__mandatory">*</span> + </p>`, + dropdown + ], + }), + element: dropdown, + }; + + if (disableInputs) { + dropdown.disabled = true; + } else { + dropdown.addEventListener('change', (e) => { + const trg = dropdown.options?.[dropdown.selectedIndex]; + if (isHtmlObject(ctrls.value)) { + ctrls.value.remove(); + } else if (ctrls?.value?.getElement) { + ctrls.value.getElement().remove(); + } + + data.type = trg.value; + data.value = null; + + this.#renderValueFieldComponent(state.descriptionAllowed ? ctrls.description : null); + }); + } + } + this.#renderValueFieldComponent(); + + if (state.descriptionAllowed) { + composeTemplate(templates.inputs.inputbox, { + params: { + id: 'description', + ref: 'description', + value: !isNullOrUndefined(data.description) ? data.description : '', + label: 'Description', + placeholder: 'Enter description...', + disabled: '', + mandatory: false, + minlength: VariableCreator.#Validation.description.minlength, + maxlength: VariableCreator.#Validation.description.maxlength, + }, + parent: ctx.content, + render: (elem) => { + elem = elem.shift(); + ctrls.description = elem; + + const input = elem.querySelector('input'); + input.addEventListener('change', (e) => { + const val = parseAsFieldType({ validation: { type: 'string' } }, e.target.value); + if (!!val && val?.success) { + if (val.value !== data.description) { + state.isDirty = true; + } + + data.description = val.value; + e.target.value = val.value; + } + }); + }, + }); + } + + this.#toggleModalContentVis(true); + } + + #renderValueFieldComponent(parent = null) { + const state = this.#modalState; + const templates = this.#templates; + + const { ctx, data, info, ctrls } = state; + switch (data.type) { + case 'int': + case 'float': + case 'decimal': + case 'numeric': + case 'percentage': { + const range = resolveRangeOpts(data.type, info.opts); + data.value = !isNullOrUndefined(data.value) ? data.value : 0; + + composeTemplate(templates.inputs.number, { + params: { + id: 'value', + ref: 'value', + type: data.type, + step: range.attr.step, + label: 'Value', + btnStep: range.values.step, + rangemin: range.attr.min, + rangemax: range.attr.max, + value: !isNullOrUndefined(data.value) ? data.value : '', + placeholder: 'Number value...', + disabled: '', + mandatory: true, + }, + render: (elem) => { + elem = elem.shift(); + if (!isHtmlObject(parent)) { + elem = ctx.content.appendChild(elem); + } else { + elem = ctx.content.insertBefore(elem, parent); + } + ctrls.value = elem; + + const fieldValidation = { validation: { type: data.type } }; + if (!isNullOrUndefined(range.values.min) && !isNullOrUndefined(range.values.max)) { + fieldValidation.validation.range = [range.values.min, range.values.max]; + } + + const input = elem.querySelector('input'); + input.value = data.value; + + input.addEventListener('change', (e) => { + const val = parseAsFieldType(fieldValidation, input.value); + if (!!val && val?.success) { + if (val.value !== data.value) { + state.isDirty = true; + } + data.value = val.value; + input.value = val.value; + } + }); + }, + }); + } break; + + case 'string': { + data.value = !isNullOrUndefined(data.value) ? data.value : ''; + + composeTemplate(templates.inputs.inputbox, { + params: { + id: 'value', + ref: 'value', + value: !isNullOrUndefined(data.value) ? data.value : '', + label: 'Value', + placeholder: 'String value...', + disabled: '', + mandatory: true, + minlength: VariableCreator.#Validation.string.minlength, + maxlength: VariableCreator.#Validation.string.maxlength, + }, + render: (elem) => { + elem = elem.shift(); + if (!isHtmlObject(parent)) { + elem = ctx.content.appendChild(elem); + } else { + elem = ctx.content.insertBefore(elem, parent); + } + ctrls.value = elem; + + elem.querySelector('input').addEventListener('change', (e) => { + const val = parseAsFieldType({ validation: { type: 'string' } }, e.target.value); + if (!!val && val?.success) { + if (val.value !== data.value) { + state.isDirty = true; + } + + data.value = val.value; + e.target.value = val.value; + } + }); + }, + }); + } break; + + case 'ci_interval': { + if (!isObjectType(data.value)) { + data.value = { + probability: 95, + lower: 0, + upper: 0, + }; + } + + composeTemplate(templates.inputs.ciinterval, { + params: { + id: 'value', + ref: 'value', + label: 'Value', + upper: data.value.upper, + lower: data.value.lower, + probability: data.value.probability, + disabled: '', + mandatory: true, + }, + render: (elem) => { + elem = elem.shift(); + if (!isHtmlObject(parent)) { + elem = ctx.content.appendChild(elem); + } else { + elem = ctx.content.insertBefore(elem, parent); + } + ctrls.value = elem; + + const lowerElem = elem.querySelector('input[name="lower"]'); + const upperElem = elem.querySelector('input[name="upper"]'); + const probabilityElem = elem.querySelector('input[name="probability"]'); + + const onChange = (e) => { + const trg = e.target.getAttribute('name'); + + let val = { ...data.value }; + val[trg] = e.target.value ?? 0; + + val = parseAsFieldType({ validation: { type: data.type } }, val); + if (!!val && val?.success) { + const { lower, upper, probability } = val.value; + data.value.lower = Math.min(lower, upper); + data.value.upper = Math.max(lower, upper); + data.value.probability = probability; + + lowerElem.value = data.value.lower; + upperElem.value = data.value.upper; + probabilityElem.value = data.value.probability; + + state.isDirty = true; + } + }; + + lowerElem.addEventListener('change', onChange); + upperElem.addEventListener('change', onChange); + probabilityElem.addEventListener('change', onChange); + }, + }); + } break; + + case 'int_range': + case 'float_range': + case 'decimal_range': + case 'numeric_range': + case 'percentage_range': { + const type = data.type.split('_').shift(); + const range = resolveRangeOpts(data.type, info.opts); + + const { + min: fmin, + max: fmax, + step: fstep + } = range.values; + + const hasRangeValues = ( + typeof fmin === 'number' && + typeof fmax === 'number' && + typeof fstep === 'number' + ); + + if (!Array.isArray(data.value)) { + let vmax = type === 'int' ? 100 : 1; + vmax = isNullOrUndefined(fmin) ? vmax : fmin + vmax; + + data.value = [ + isNullOrUndefined(fmin) ? fmin : 0, + isNullOrUndefined(fmax) ? fmax : vmax, + ]; + } + + if (hasRangeValues) { + composeTemplate(templates.inputs.rangeslider, { + params: { + id: 'value', + ref: 'value', + type: type, + label: 'Value', + disabled: '', + mandatory: true, + }, + render: (elem) => { + elem = elem.shift(); + if (!isHtmlObject(parent)) { + elem = ctx.content.appendChild(elem); + } else { + elem = ctx.content.insertBefore(elem, parent); + } + + ctrls.value = new DoubleRangeSlider(elem, { + value: { + min: data.value[0], + max: data.value[1], + }, + properties: { + min: fmin, + max: fmax, + step: fstep, + type: 'float', + }, + }); + + elem.addEventListener('change', (e) => { + let val = ctrls.value.getValue(); + val = [val.min, val.max].map(x => typeof x !== 'number' || Number.isNaN(x) || !Number.isFinite(x) ? 0 : x); + val.sort((a, b) => a < b ? -1 : (a > b ? 1 : 0)); + + val = parseAsFieldType({ validation: { type: data.type, range: [fmin, fmax] } }, [val.min, val.max]); + if (!!val && val?.success) { + data.value = val.value; + state.isDirty = true; + } + }); + }, + }); + } else { + const minValue = typeof data.value[0] === 'number' ? `${data.value[0]}` : '0'; + const maxValue = typeof data.value[1] === 'number' ? `${data.value[1]}` : '0'; + composeTemplate(templates.inputs.numericrange, { + params: { + id: 'value', + ref: 'value', + label: 'Value', + type: type, + min: minValue, + max: maxValue, + step: range.attr.step, + btnStep: range.values.step, + disabled: '', + mandatory: true, + }, + render: (elem) => { + elem = elem.shift(); + if (!isHtmlObject(parent)) { + elem = ctx.content.appendChild(elem); + } else { + elem = ctx.content.insertBefore(elem, parent); + } + ctrls.value = elem; + + const minElem = elem.querySelector('input[name="min"]'); + const maxElem = elem.querySelector('input[name="max"]'); + + const onChange = (e) => { + const trg = e.target.getAttribute('name'); + const val = parseAsFieldType({ validation: { type: type } }, e.target.value); + data.value[trg === 'min' ? 0 : 1] = !!val && val.success ? val.value : 0; + data.value = data.value.map(x => typeof x !== 'number' || Number.isNaN(x) || !Number.isFinite(x) ? 0 : x); + data.value.sort((a, b) => a < b ? -1 : (a > b ? 1 : 0)); + + const [minV, maxV] = data.value; + minElem.value = minV; + maxElem.value = maxV; + state.isDirty = true; + }; + + minElem.addEventListener('change', onChange); + maxElem.addEventListener('change', onChange); + }, + }); + } + } break; + + default: + break; + } + } + + + /************************************* + * * + * Events * + * * + *************************************/ + #initEvents() { + const data = this.data; + const layout = this.#layout; + const element = this.element; + const fieldText = this.props.txt; + + document.addEventListener('click', (e) => { + const target = e.target; + if (!element.contains(target) || !target.matches('[data-fn="button"][data-owner="var-selector"]')) { + return true; + } + + const action = target.getAttribute('data-action'); + if (!stringHasChars(action)) { + return true; + } + + const ref = target.getAttribute('data-ref'); + e.preventDefault(); + + if (stringHasChars(ref) && layout.contentList.contains(target)) { + // Item action button(s) + const item = this.data[ref]; + if (!item) { + return true; + } + + switch (action) { + case 'edit': { + this.#openModal(ref); + } break; + + case 'remove': { + window.ModalFactory.create({ + id: generateUUID(), + ref: 'prompt', + title: 'Are you sure?', + content: `Are you sure you want to delete the ${fieldText.single} "${item.name}"? This action cannot be reverted.`, + }) + .then(_ => { + if (this.data?.[ref]) { + delete this.data[ref]; + } + + const elem = tryGetRootElement(target, '[data-area="item"]'); + if (!elem) { + this.#renderLayout(); + return; + } + + const len = Object.keys(data).length; + elem.remove(); + + this.#toggleLayoutContentVis(len > 0); + }) + .catch(res => { + if (!!res && !(res instanceof ModalFactory.ModalResults)) { + return console.error(res); + } + }); + } break; + + default: + break; + } + + return false; + } + + // Action bar button(s) + switch (action) { + case 'add': + this.#openModal(); + break; + + case 'clear': { + const len = Object.keys(data).length; + if (len < 1) { + break; + } + + window.ModalFactory.create({ + id: generateUUID(), + ref: 'prompt', + title: 'Are you sure?', + content: `Are you sure you want to delete all ${len}x ${fieldText.plural}? Please note that this action cannot be undone without losing all other page progress.`, + }) + .then(_ => { + for (const key in data) { + if (!data.hasOwnProperty(key)) { + continue; + } + + delete data[key]; + } + + this.#renderLayout(); + }) + .catch(res => { + if (!!res && !(res instanceof ModalFactory.ModalResults)) { + return console.error(res); + } + }); + } break; + + default: + break; + } + + return false; + }); + } + + + /************************************* + * * + * Private * + * * + *************************************/ + #validatePacket() { + const modalState = this.#modalState; + const validation = VariableCreator.#Validation; + if (!modalState) { + return false; + } + + let { info, data, typesAllowed, unknownAllowed } = modalState; + if (!info || !data) { + return false; + } + + let fval = validation.name; + let name = parseAsFieldType( + { + validation: { + type: 'string', + regex: '[^\s]+', + properties: { + validateLen: true, + length: [fval.minlength, fval.maxlength], + }, + }, + }, + strictSanitiseString(data.name), + ); + + if (!name || !name?.success) { + if (isObjectType(name?.value) && name.value?.len) { + let msg; + if (fval.minlength > 0) { + msg = `must be at least ${fval.minlength} characters long, with a maximum length of ${fval.maxlength} characters.`; + } else { + msg = `cannot have more than ${fval.maxlength} characters.`; + } + + return { + success: false, + message: `Name ${msg}`, + }; + } + + return { + success: false, + message: 'Name is a required string field.', + }; + } else { + name = name.value; + } + + let description = data.description; + fval = validation.description; + + if (typeof description === 'string') { + description = parseAsFieldType( + { + validation: { + type: 'string', + regex: '[^\s]+', + properties: { + validateLen: true, + length: [fval.minlength, fval.maxlength], + }, + }, + }, + strictSanitiseString(description), + ); + + if (!description || !description?.success) { + if (isObjectType(description?.value) && description.value?.len) { + let msg; + if (fval.minlength > 0) { + msg = `must be at least ${fval.minlength} characters long, with a maximum length of ${fval.maxlength} characters.`; + } else { + msg = `cannot have more than ${fval.maxlength} characters.`; + } + + return { + success: false, + message: `If provided, the description ${msg}`, + }; + } + description = null; + } else { + description = description.value; + } + } else { + description = null; + } + + let type, value; + type = data.type; + + let invalid = false; + const relative = this.props.options.find(x => x.name === info.editKey); + if (info.ref === 'unknown') { + invalid = !unknownAllowed || typeof type !== 'string' || !typesAllowed.includes(type); + } else { + invalid = typeof type !== 'string' || !relative || relative?.value?.type !== type; + } + + if (invalid) { + return false; + } + + fval = validation?.[type]; + value = data.value; + + if (type === 'string') { + value = strictSanitiseString(value); + value = parseAsFieldType( + { + validation: { + type: 'string', + regex: '[^\s]+', + properties: { + validateLen: true, + length: [fval.minlength, fval.maxlength], + }, + }, + }, + value, + ); + + if (!value || !value?.success) { + if (isObjectType(value?.value) && value.value?.len) { + let msg; + if (fval.minlength > 0) { + msg = `must be at least ${fval.minlength} characters long, with a maximum length of ${fval.maxlength} characters.`; + } else { + msg = `cannot have more than ${fval.maxlength} characters.`; + } + + return { + success: false, + message: `The value ${msg}`, + }; + } + + return { + success: false, + message: 'The value field must not be empty.', + }; + } else { + value = value.value; + } + } + + return { + success: true, + editKey: relative ? info.editKey : transformSnakeCase(name), + isRelative: !!relative, + value: { name, type, value, description }, + }; + } + + + /************************************* + * * + * Initialiser * + * * + *************************************/ + #initialise() { + const elem = this.element; + const layout = this.#layout; + elem.querySelectorAll('[data-area]').forEach(v => { + const role = v.getAttribute('data-area'); + if (stringHasChars(role)) { + layout[role] = v; + } + }); + + const templates = this.#templates; + elem.querySelectorAll('template[data-name]').forEach(v => { + let name = v.getAttribute('data-name'); + let view = v.getAttribute('data-view'); + if (!stringHasChars(view)) { + view = 'base'; + } + + let group = templates?.[view]; + if (!group) { + group = { }; + templates[view] = group; + } + + group[name] = v; + }); + + this.#initEvents(); + this.#renderLayout(); + } +}; diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/forms/stringInputListCreator.js b/CodeListLibrary_project/cll/static/js/clinicalcode/forms/stringInputListCreator.js index fde33e118..4ab207140 100644 --- a/CodeListLibrary_project/cll/static/js/clinicalcode/forms/stringInputListCreator.js +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/forms/stringInputListCreator.js @@ -151,6 +151,8 @@ export default class StringInputListCreator { e.stopPropagation(); const listItem = strictSanitiseString(this.listInput.value); + this.listInput.value = listItem; + if (!this.listInput.checkValidity() || isNullOrUndefined(listItem) || isStringEmpty(listItem)) { return; } diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/services/collectionService.js b/CodeListLibrary_project/cll/static/js/clinicalcode/services/collectionService.js index 5936b2a5a..e5628e0e1 100644 --- a/CodeListLibrary_project/cll/static/js/clinicalcode/services/collectionService.js +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/services/collectionService.js @@ -6,7 +6,7 @@ const * @desc describes the URL(s) associated with the action button(s) * */ - DETAIL_URL = '/phenotypes/${id}/version/${version_id}/detail/', + DETAIL_URL = '/${url_target}/${id}/version/${version_id}/detail/', UPDATE_URL = '/update/${id}/${version_id}', /** * COLLECTION_HEADINGS @@ -144,9 +144,14 @@ const getCollectionData = () => { * @returns {string} returns the formatted render html target * */ -const renderNameAnchor = (pageType, key, entity) => { +const renderNameAnchor = (pageType, key, entity, mapping) => { const { id, history_id, name, publish_status } = entity; + let urlTarget = mapping?.phenotype_url; + if (!stringHasChars(urlTarget)) { + urlTarget = 'phenotypes'; + } + let text = `${id} - ${strictSanitiseString(name)}`; text = text.length > MAX_NAME_LENGTH ? `${text.substring(0, MAX_NAME_LENGTH).trim()}...` @@ -155,12 +160,13 @@ const renderNameAnchor = (pageType, key, entity) => { const brand = getBrandedHost(); const url = interpolateString(brand + DETAIL_URL, { id: id, - version_id: history_id + version_id: history_id, + url_target: urlTarget, }); if (isNullOrUndefined(ARCHIVE_TEMPLATE) || pageType !== 'PROFILE_COLLECTIONS') { return ` - <a href='${url}'>${text}</a> + <a href='${url}' target=_blank rel="noopener">${text}</a> `; } @@ -172,11 +178,11 @@ const renderNameAnchor = (pageType, key, entity) => { }); let target = ` - <a href='${url}'>${text}</a> - <span tooltip="Edit Phenotype" direction="left"> + <a href='${url}' target=_blank rel="noopener">${text}</a> + <span tooltip="Edit ${mapping.phenotype}" direction="right"> <span class="profile-collection__edit-icon" tabindex="0" - aria-label="Edit Phenotype" + aria-label="Edit ${mapping.phenotype}" role="button" data-target="edit" data-href="${update}"></span> @@ -185,25 +191,25 @@ const renderNameAnchor = (pageType, key, entity) => { if (publish_status != 2) { target += ` - <span tooltip="Archive Phenotype" direction="left"> + <span tooltip="Archive ${mapping.phenotype}" direction="right"> <span class="profile-collection__delete-icon" - tabindex="0" aria-label="Archive Phenotype" + tabindex="0" aria-label="Archive ${mapping.phenotype}" role="button" data-target="archive" data-id="${id}"></span> </span> `; + } return target; } - } case 'archived': { return ` - <a href='${url}'>${text}</a> - <span tooltip="Restore Phenotype" direction="left"> + <a href='${url}' target=_blank rel="noopener">${text}</a> + <span tooltip="Restore ${mapping.phenotype}" direction="right"> <span class="profile-collection__restore-icon" - tabindex="0" aria-label="Restore Phenotype" + tabindex="0" aria-label="Restore ${mapping.phenotype}" role="button" data-target="restore" data-id="${id}"></span> @@ -213,7 +219,7 @@ const renderNameAnchor = (pageType, key, entity) => { default: { return ` - <a href='${url}'>${text}</a> + <a href='${url}' target=_blank rel="noopener">${text}</a> `; } } @@ -243,13 +249,15 @@ const renderStatusTag = (data) => { /** * renderCollectionComponent * @desc method to render the collection component - * @param {string} pageType the component page type, e.g. in the case of profile/moderation pages - * @param {string} key the component type associated with this component, e.g. collection - * @param {node} container the container node associated with this element - * @param {object} data the data associated with this element + * + * @param {string} pageType the component page type, e.g. in the case of profile/moderation pages + * @param {string} key the component type associated with this component, e.g. collection + * @param {node} container the container node associated with this element + * @param {object} data the data associated with this element + * @param {object} mapping brand context text data * */ -const renderCollectionComponent = (pageType, key, container, data) => { +const renderCollectionComponent = (pageType, key, container, data, mapping) => { if (isNullOrUndefined(data) || Object.keys(data).length == 0) { return; } @@ -272,6 +280,7 @@ const renderCollectionComponent = (pageType, key, container, data) => { fixedColumns: false, classes: { wrapper: 'overflow-table-constraint', + container: 'datatable-container slim-scrollbar', }, template: (options, dom) => `<div class='${options.classes.top}'> <div class='${options.classes.dropdown}'> @@ -296,7 +305,7 @@ const renderCollectionComponent = (pageType, key, container, data) => { render: (value, cell, rowIndex) => { const [entityId, ...others] = value.match(/^\w+-?/g); const entity = data.find(e => e.id == entityId); - return renderNameAnchor(pageType, key, entity); + return renderNameAnchor(pageType, key, entity, mapping); }, }, { select: 2, type: 'number', hidden: true }, @@ -374,7 +383,7 @@ const renderCollectionComponent = (pageType, key, container, data) => { let columnIndex = head.getAttribute('column-index'); columnIndex = parseInt(columnIndex); - let uniqueValues = [...new Set(datatable.data.data.map(tr => tr[columnIndex].data))]; + let uniqueValues = [...new Set(datatable.data.data.map(tr => tr.cells[columnIndex].data))]; let option = document.createElement('option'); option.value = '-1'; option.selected = true; @@ -410,10 +419,11 @@ const renderCollectionComponent = (pageType, key, container, data) => { * whether they want to archive a phenotype; and then * attempts to send a request to the server to archive a phenotype * - * @param {number} id the associated phenotype id + * @param {number} id the associated phenotype id + * @param {object} mapping brand context text data * */ -const tryArchivePhenotype = (id) => { +const tryArchivePhenotype = (id, mapping) => { window.ModalFactory.create({ title: `Are you sure you want to archive ${id}?`, content: ARCHIVE_TEMPLATE.innerHTML, @@ -459,11 +469,14 @@ const tryArchivePhenotype = (id) => { /** * tryRestorePhenotype * @desc attempts to send a request to the server to restore a phenotype - * @param {number} id the associated phenotype id + * + * @param {number} id the associated phenotype id + * @param {object} mapping brand context text data + * * @returns {object<Promise>} returns the request promise * */ -const tryRestorePhenotype = (id) => { +const tryRestorePhenotype = (id, mapping) => { const token = getCookie('csrftoken'); return fetch( window.location.href, @@ -502,12 +515,15 @@ domReady.finally(() => { ARCHIVE_TEMPLATE = document.querySelector('#archive-form'); const data = getCollectionData(); + const mapping = data.mapping.data; + delete data.mapping; + for (let [key, value] of Object.entries(data)) { - if (value.data.length < 1) { + if (value.data.length < 1 || !stringHasChars(value.pageType)) { continue; } - renderCollectionComponent(value.pageType, key, value.container, value.data); + renderCollectionComponent(value.pageType, key, value.container, value.data, mapping); } if (!isNullOrUndefined(ARCHIVE_TEMPLATE)) { @@ -521,7 +537,7 @@ domReady.finally(() => { switch (trg) { case 'archive': { const id = target.getAttribute('data-id'); - tryArchivePhenotype(id); + tryArchivePhenotype(id, mapping); } break; case 'edit': { @@ -535,7 +551,7 @@ domReady.finally(() => { content: `<p>Would you like to restore <strong>${id}</strong>?</p>` }) .then((result) => { - return tryRestorePhenotype(id); + return tryRestorePhenotype(id, mapping); }) .then(() => { window.location.reload(); diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/services/dashboardService/components/orgAuthoritySelector.js b/CodeListLibrary_project/cll/static/js/clinicalcode/services/dashboardService/components/orgAuthoritySelector.js new file mode 100644 index 000000000..c423e0519 --- /dev/null +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/services/dashboardService/components/orgAuthoritySelector.js @@ -0,0 +1,471 @@ +import FuzzyQuery from '../../../components/fuzzyQuery.js'; + +import { Autocomplete } from '../../../components/autocomplete.js'; + + +/** + * @desc an object specifying templates expected to be defined by the `template` constructor args + * @type {Record<string, Array<string>} + * @constant + */ +const EXPECTED_TEMPLATES = { + form: ['autocomplete', 'selector'], + OrgAuthority: ['table', 'row'], +}; + + +/** + * Class to render the `Organisation.brands::OrganisationAuthority` component + * + * @note ideally we would be extending from a parent class but ES6 doesn't support protected members natively + * + * @class + * @constructor + */ +export class OrgAuthoritySelector { + /** + * @desc default constructor props + * @type {Record<string, any>} + * @static + * @constant + * + */ + static #DefaultOpts = { + field: null, + value: null, + options: { + brand: [], + }, + element: null, + templates: null, + }; + + /** + * @desc + * @type {HTMLElement} + * @public + */ + element = null; + + /** + * @desc + * @type {object} + * @private + */ + #props = { }; + + /** + * @desc + * @type {Record<string, HTMLElement>} + * @private + */ + #layout = {}; + + /** + * @desc + * @type {Record<string, Record<string, HTMLElement>} + * @private + */ + #templates = { }; + + /** + * @desc + * @type {Array<Function>} + * @private + */ + #disposables = []; + + /** + * @param {Record<string, any>} [opts] constructor arguments; see {@link OrgAuthoritySelector.#DefaultOpts} + */ + constructor(opts) { + opts = isRecordType(opts) ? opts : { }; + opts = mergeObjects(opts, OrgAuthoritySelector.#DefaultOpts, true); + OrgAuthoritySelector.#ValidateOpts(opts); + + this.#initialise(opts); + } + + + /************************************* + * * + * Static * + * * + *************************************/ + /** + * @desc validates constructor opts + * @static + * @private + * + * @param {Record<string, any>} [opts] constructor arguments; see {@link OrgAuthoritySelector.#DefaultOpts} + */ + static #ValidateOpts(opts) { + if (!isRecordType(opts.field)) { + throw new Error('Expected `field` as valid `Record<string, any>`'); + } + + if (!isRecordType(opts.options)) { + throw new Error('Expected `options` as valid `Record<string, any>`'); + } + + if (!isHtmlObject(opts.element) && !(typeof opts.element === 'string' && stringHasChars(opts.element))) { + throw new Error('Expected `element` to specify either (a) a HTMLElement, or (b) a string specifying a query selector'); + } + + if (!isRecordType(opts.templates)) { + throw new Error('Expected `templates` to specify a `Record<string, string|HTMLElement>` describing the HTML templates used by this component'); + } + + let missing = findMissingComponents(opts.templates, EXPECTED_TEMPLATES); + if (Array.isArray(missing) && missing.length > 0) { + missing = missing.join(', '); + throw new Error(`The specified \`templates\` record does not define the following required templates: ${missing}`); + } + } + + + /************************************* + * * + * Public * + * * + *************************************/ + getValue() { + return this.value.map(x => { + const elem = { ...x }; + delete elem.elements; + return elem; + }); + } + + getDataValue() { + return this.value.map(x => ({ brand_id: x.brand.id, can_post: !!x.can_post, can_moderate: !!x.can_moderate })); + } + + dispose() { + let disposable; + for (let i = this.#disposables.length; i > 0; i--) { + disposable = this.#disposables.pop(); + if (typeof disposable !== 'function') { + continue; + } + + disposable(); + } + } + + + /************************************* + * * + * Renderables * + * * + *************************************/ + #addAuthority(authority) { + if (!isRecordType(authority)) { + return false; + } + + const brand = authority.brand; + if (!isRecordType(brand) || typeof brand.id !== 'number' || typeof brand.name !== 'string') { + return false; + } + + let canPost = authority.can_post; + if (isNullOrUndefined(canPost)) { + canPost = false; + authority.can_post = canPost; + } + + let canModerate = authority.can_moderate; + if (isNullOrUndefined(canModerate)) { + canModerate = false; + authority.can_moderate = canModerate; + } + + let authElements; + composeTemplate(this.#templates.OrgAuthority.row, { + params: { + brand: brand.name, + brandPk: brand.id, + canPostValue: canPost, + canModerateValue: canModerate, + }, + sanitiseTemplate: false, + parent: this.#layout.tableBody, + render: (elems) => { + const row = elems[0]; + const button = row.querySelector('[data-role="brand-delete-btn"]'); + const chkPost = row.querySelector('[data-role="brand-post-checkbox"]'); + const chkModerate = row.querySelector('[data-role="brand-moderate-checkbox"]'); + authElements = { row, button, chkPost, chkModerate }; + + chkPost.checked = canPost; + chkModerate.checked = canModerate; + } + }); + authority.elements = authElements; + + const checkboxToggleHnd = this.#checkboxToggleHandle.bind(this); + const removeAuthorityHnd = this.#removeAuthorityHandle.bind(this); + + const { button, chkPost, chkModerate } = authElements; + button.addEventListener('click', removeAuthorityHnd); + chkPost.addEventListener('change', checkboxToggleHnd); + chkModerate.addEventListener('change', checkboxToggleHnd); + + return true; + } + + #render() { + const props = this.#props + const layout = this.#layout; + + const brandOptions = Array.isArray(props?.options?.brand) ? props.options.brand : []; + const brandHaystack = brandOptions.map(x => x.name); + + const autocomplete = new Autocomplete({ + rootNode: layout.autocomplete.container, + inputNode: layout.autocomplete.input, + resultsNode: layout.autocomplete.results, + shouldAutoSelect: false, + searchFn: (input) => { + if (input.length < 1) { + return []; + } + + return FuzzyQuery.Search( + brandHaystack, + input, + FuzzyQuery.Results.SORT, + FuzzyQuery.Transformers.IgnoreCase + ) + .sort((a, b) => { + if (a.score === b.score) { + return 0; + } else if (a.score > b.score) { + return 1; + } else if (a.score < b.score) { + return -1; + } + }) + .map(x => x.item); + }, + }); + + this.#disposables.push(() => autocomplete.dispose()); + + if (brandOptions.length > 0) { + const authAddHnd = this.#addAuthorityHandle.bind(this); + layout.autocomplete.button.addEventListener('click', authAddHnd); + + this.#disposables.push(() => { + layout.autocomplete.button.removeEventListener('click', authAddHnd); + }); + } + + for (let i = 0; i < this.value.length; ++i) { + const success = this.#addAuthority(this.value[i]); + if (success) { + continue; + } + + console.warn('[OrgSelector] Failed to add authority:', this.value[i]); + this.value.splice(i, 1); + } + this.#toggleContentVisibility(); + } + + #toggleContentVisibility() { + const { content, empty } = this.#layout; + + const isVisible = this.value.length > 0; + empty.setAttribute('data-visible', !isVisible); + content.setAttribute('data-visible', isVisible); + } + + + /************************************* + * * + * Events * + * * + *************************************/ + #addAuthorityHandle(e) { + e.preventDefault(); + + const { input } = this.#layout.autocomplete; + const brandOptions = this.#props.options.brand; + + let inputValue = input.value; + input.value = ''; + + if (!stringHasChars(inputValue)) { + return; + } + + inputValue = brandOptions.find(x => x.name.toLocaleLowerCase() === inputValue.toLocaleLowerCase()); + if (isNullOrUndefined(inputValue)) { + return; + } + + if (!!this.value.find(x => x.brand.id === inputValue.pk)) { + return; + } + + const authority = { brand: { id: inputValue.pk, name: inputValue.name } }; + const success = this.#addAuthority(authority); + if (success) { + this.value.push(authority); + this.#toggleContentVisibility(); + } + } + + #checkboxToggleHandle(e) { + const target = document.activeElement; + + let rowRefPk = target.getAttribute('data-ref'); + rowRefPk = parseInt(rowRefPk); + if (typeof rowRefPk !== 'number' || isNaN(rowRefPk)) { + return; + } + + const index = this.value.findIndex(x => x.brand.id == rowRefPk); + const authority = index >= 0 ? this.value[index] : null; + if (!authority) { + return; + } + + const column = target.getAttribute('data-column'); + authority[column] = !!target.checked; + } + + #removeAuthorityHandle(e) { + const target = document.activeElement; + e.preventDefault(); + + let rowRefPk = target.getAttribute('data-ref'); + rowRefPk = parseInt(rowRefPk); + if (typeof rowRefPk !== 'number' || isNaN(rowRefPk)) { + return; + } + + const index = this.value.findIndex(x => x.brand.id == rowRefPk); + const authority = index >= 0 ? this.value[index] : null; + if (!authority) { + return; + } + this.value.splice(index, 1); + this.#toggleContentVisibility(); + + const elements = authority.elements; + if (!isRecordType(elements)) { + return; + } + + for (const key in elements) { + const elem = elements[key]; + if (!isHtmlObject(elem)) { + continue; + } + + elem.remove(); + } + } + + + /************************************* + * * + * Initialiser * + * * + *************************************/ + #initialise(opts) { + let element = opts.element; + delete opts.element; + + if (typeof element === 'string') { + element = document.querySelector(element); + } + + if (!isHtmlObject(element)) { + throw new Error(`Failed to resolve ${Object.getPrototypeOf(this).constructor.name} element`); + } + + const templates = opts.templates; + this.value = opts.value; + this.element = element; + delete opts.value; + + this.#props = opts; + this.#templates = templates; + delete opts.templates; + + const field = opts.field; + const layout = this.#layout; + composeTemplate(templates.form.selector, { + params: { + key: field.key, + cls: '', + help: field.help ?? '', + title: field.label ?? 'Brand Authority', + owner: 'OrgAuthoritySelector', + required: field.required ? 'required="true"' : '', + emptyMessage: 'You haven\'t added any Brands yet.', + }, + parent: element, + render: (elems) => { + const group = elems[0]; + layout.group = group; + + const idents = group.querySelectorAll('[data-identifier]'); + for (let i = 0; i < idents.length; ++i) { + const elem = idents[i]; + const ident = elem.getAttribute('data-identifier'); + if (!stringHasChars(ident)) { + continue; + } + + layout[ident] = elem; + } + } + }); + + composeTemplate(templates.OrgAuthority.table, { + debug: true, + sanitiseTemplate: false, + parent: layout.table, + render: (elems) => { + const [head, body] = elems; + layout.tableHead = head; + layout.tableBody = body; + } + }); + + composeTemplate(templates.form.autocomplete, { + params: { + id: 'brand-auto-selector', + searchValue: '', + searchLabel: 'Find Brand to add', + searchPlaceholder: 'Search Brand...', + btnId: 'add-authority-btn', + btnIcon: 'folder-plus', + btnTitle: 'Add Selected Brand', + btnContent: 'Add', + }, + parent: layout.header, + render: (elems) => { + const matched = elems[0].parentElement.querySelectorAll('[data-role^="autocomplete-"]'); + const objects = { }; + layout.autocomplete = objects; + + for (let i = 0; i < matched.length; ++i) { + const obj = matched[i] + const drole = obj.getAttribute('data-role').split('-').pop(); + objects[drole] = obj; + } + } + }); + + if (!Array.isArray(this.value)) { + this.value = []; + } + this.#render(); + } +}; diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/services/dashboardService/components/orgMemberSelector.js b/CodeListLibrary_project/cll/static/js/clinicalcode/services/dashboardService/components/orgMemberSelector.js new file mode 100644 index 000000000..13535850e --- /dev/null +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/services/dashboardService/components/orgMemberSelector.js @@ -0,0 +1,475 @@ +import FuzzyQuery from '../../../components/fuzzyQuery.js'; + +import { Autocomplete } from '../../../components/autocomplete.js'; + + +/** + * @desc an object specifying templates expected to be defined by the `template` constructor args + * @type {Record<string, Array<string>} + * @constant + */ +const EXPECTED_TEMPLATES = { + form: ['autocomplete', 'selector'], + OrgMember: ['table', 'row'], +}; + + +/** + * Class to render the `Organisation.members::OrganisationMember` component + * + * @note ideally we would be extending from a parent class but ES6 doesn't support protected members natively + * + * @class + * @constructor + */ +export class OrgMemberSelector { + /** + * @desc default constructor props + * @type {Record<string, any>} + * @static + * @constant + * + */ + static #DefaultOpts = { + field: null, + value: [], + options: { + user: [], + role: [], + }, + element: null, + template: null, + }; + + /** + * @desc + * @type {HTMLElement} + * @public + */ + element = null; + + /** + * @desc + * @type {object} + * @private + */ + #props = { }; + + /** + * @desc + * @type {Record<string, HTMLElement>} + * @private + */ + #layout = {}; + + /** + * @desc + * @type {Record<string, Record<string, HTMLElement>} + * @private + */ + #templates = { }; + + /** + * @desc + * @type {Array<Function>} + * @private + */ + #disposables = []; + + /** + * @param {Record<string, any>} [opts] constructor arguments; see {@link OrgMemberSelector.#DefaultOpts} + */ + constructor(opts) { + opts = isRecordType(opts) ? opts : { }; + opts = mergeObjects(opts, OrgMemberSelector.#DefaultOpts, true); + OrgMemberSelector.#ValidateOpts(opts); + + this.#initialise(opts); + } + + + /************************************* + * * + * Static * + * * + *************************************/ + /** + * @desc validates constructor opts + * @static + * @private + * + * @param {Record<string, any>} [opts] constructor arguments; see {@link OrgMemberSelector.#DefaultOpts} + */ + static #ValidateOpts(opts) { + if (!isRecordType(opts.field)) { + throw new Error('Expected `field` as valid `Record<string, any>`'); + } + + if (!isRecordType(opts.options)) { + throw new Error('Expected `options` as valid `Record<string, any>`'); + } + + if (!isHtmlObject(opts.element) && !(typeof opts.element === 'string' && stringHasChars(opts.element))) { + throw new Error('Expected `element` to specify either (a) a HTMLElement, or (b) a string specifying a query selector'); + } + + if (!isRecordType(opts.templates)) { + throw new Error('Expected `templates` to specify a `Record<string, string|HTMLElement>` describing the HTML templates used by this component'); + } + + let missing = findMissingComponents(opts.templates, EXPECTED_TEMPLATES); + if (Array.isArray(missing) && missing.length > 0) { + missing = missing.join(', '); + throw new Error(`The specified \`templates\` record does not define the following required templates: ${missing}`); + } + } + + + /************************************* + * * + * Public * + * * + *************************************/ + getValue() { + return this.value.map(x => { + const elem = { ...x }; + delete elem.elements; + return elem; + }); + } + + getDataValue() { + return this.value.map(x => ({ user_id: x.user.id, role: x.role })); + } + + dispose() { + let disposable; + for (let i = this.#disposables.length; i > 0; i--) { + disposable = this.#disposables.pop(); + if (typeof disposable !== 'function') { + continue; + } + + disposable(); + } + } + + + /************************************* + * * + * Renderables * + * * + *************************************/ + #addMember(member) { + if (!isRecordType(member)) { + return false; + } + + const user = member.user; + if (!isRecordType(user) || typeof user.id !== 'number' || typeof user.username !== 'string') { + return false; + } + + const roleOptions = this.#props.options.role; + if (typeof member.role !== 'number' || !roleOptions.find(x => x.pk === member.role)) { + member.role = 0; + } + + let memberElements; + composeTemplate(this.#templates.OrgMember.row, { + params: { + userPk: member.user.id, + username: member.user.username, + }, + sanitiseTemplate: false, + parent: this.#layout.tableBody, + render: (elems) => { + const row = elems[0]; + const select = row.querySelector('[data-role="user-role-select"]'); + const button = row.querySelector('[data-role="user-delete-btn"]'); + memberElements = { row, select, button }; + + let selectedIndex; + for (let i = 0; i < roleOptions.length; ++i) { + let opt = roleOptions[i]; + selectedIndex = opt.pk === member.role ? i : selectedIndex; + + opt = createElement('option', { + innerText: opt.name, + attributes: { + value: opt.pk.toString(), + selected: selectedIndex === i, + }, + parent: select, + }); + } + select.selectedIndex = selectedIndex; + } + }); + member.elements = memberElements; + + const roleSelectHnd = this.#roleSelectHandle.bind(this); + const removeMemberHnd = this.#removeMemberHandle.bind(this); + + const { select, button } = memberElements; + select.addEventListener('change', roleSelectHnd); + button.addEventListener('click', removeMemberHnd); + + return true; + } + + #render() { + const props = this.#props + const layout = this.#layout; + + const userOptions = Array.isArray(props?.options?.user) ? props.options.user : []; + const userHaystack = userOptions.map(x => x.name); + + const autocomplete = new Autocomplete({ + rootNode: layout.autocomplete.container, + inputNode: layout.autocomplete.input, + resultsNode: layout.autocomplete.results, + shouldAutoSelect: false, + searchFn: (input) => { + if (input.length < 1) { + return []; + } + + return FuzzyQuery.Search( + userHaystack, + input, + FuzzyQuery.Results.SORT, + FuzzyQuery.Transformers.IgnoreCase + ) + .sort((a, b) => { + if (a.score === b.score) { + return 0; + } else if (a.score > b.score) { + return 1; + } else if (a.score < b.score) { + return -1; + } + }) + .map(x => x.item); + }, + }); + + this.#disposables.push(() => autocomplete.dispose()); + + if (userOptions.length > 0) { + const memberAddHnd = this.#addMemberHandle.bind(this); + layout.autocomplete.button.addEventListener('click', memberAddHnd); + + this.#disposables.push(() => { + layout.autocomplete.button.removeEventListener('click', memberAddHnd); + }); + } + + for (let i = 0; i < this.value.length; ++i) { + const success = this.#addMember(this.value[i]); + if (success) { + continue; + } + + console.warn('[OrgSelector] Failed to add member:', this.value[i]); + this.value.splice(i, 1); + } + this.#toggleContentVisibility(); + } + + #toggleContentVisibility() { + const { content, empty } = this.#layout; + + const isVisible = this.value.length > 0; + empty.setAttribute('data-visible', !isVisible); + content.setAttribute('data-visible', isVisible); + } + + + /************************************* + * * + * Events * + * * + *************************************/ + #addMemberHandle(e) { + e.preventDefault(); + + const { input } = this.#layout.autocomplete; + const userOptions = this.#props.options.user; + + let inputValue = input.value; + input.value = ''; + + if (!stringHasChars(inputValue)) { + return; + } + + inputValue = userOptions.find(x => x.name.toLocaleLowerCase() === inputValue.toLocaleLowerCase()); + if (isNullOrUndefined(inputValue)) { + return; + } + + if (!!this.value.find(x => x.user.id === inputValue.pk)) { + return; + } + + const member = { user: { id: inputValue.pk, username: inputValue.name } }; + const success = this.#addMember(member); + if (success) { + this.value.push(member); + this.#toggleContentVisibility(); + } + } + + #roleSelectHandle(e) { + const target = document.activeElement; + + let rowRefPk = target.getAttribute('data-ref'); + rowRefPk = parseInt(rowRefPk); + if (typeof rowRefPk !== 'number' || isNaN(rowRefPk)) { + return; + } + + const index = this.value.findIndex(x => x.user.id == rowRefPk); + const member = index >= 0 ? this.value[index] : null; + if (!member) { + return; + } + + member.role = target?.options?.[target.selectedIndex] + ? parseInt(target.options[target.selectedIndex].value) + : member.role; + } + + #removeMemberHandle(e) { + const target = document.activeElement; + e.preventDefault(); + + let rowRefPk = target.getAttribute('data-ref'); + rowRefPk = parseInt(rowRefPk); + if (typeof rowRefPk !== 'number' || isNaN(rowRefPk)) { + return; + } + + const index = this.value.findIndex(x => x.user.id == rowRefPk); + const member = index >= 0 ? this.value[index] : null; + if (!member) { + return; + } + this.value.splice(index, 1); + this.#toggleContentVisibility(); + + const elements = member.elements; + if (!isRecordType(elements)) { + return; + } + + for (const key in elements) { + const elem = elements[key]; + if (!isHtmlObject(elem)) { + continue; + } + + elem.remove(); + } + } + + + /************************************* + * * + * Initialiser * + * * + *************************************/ + #initialise(opts) { + let element = opts.element; + delete opts.element; + + if (typeof element === 'string') { + element = document.querySelector(element); + } + + if (!isHtmlObject(element)) { + throw new Error(`Failed to resolve ${Object.getPrototypeOf(this).constructor.name} element`); + } + + const templates = opts.templates; + this.value = opts.value; + this.element = element; + delete opts.value; + + this.#props = opts; + this.#templates = templates; + delete opts.templates; + + const field = opts.field; + const layout = this.#layout; + composeTemplate(templates.form.selector, { + params: { + key: field.key, + cls: '', + help: field.help ?? '', + title: field.label ?? 'Membership', + owner: 'OrgMemberSelector', + required: field.required ? 'required="true"' : '', + emptyMessage: 'You haven\'t added any Members yet.', + }, + parent: element, + render: (elems) => { + const group = elems[0]; + layout.group = group; + + const idents = group.querySelectorAll('[data-identifier]'); + for (let i = 0; i < idents.length; ++i) { + const elem = idents[i]; + const ident = elem.getAttribute('data-identifier'); + if (!stringHasChars(ident)) { + continue; + } + + layout[ident] = elem; + } + } + }); + + composeTemplate(templates.OrgMember.table, { + debug: true, + sanitiseTemplate: false, + parent: layout.table, + render: (elems) => { + const [head, body] = elems; + layout.tableHead = head; + layout.tableBody = body; + } + }); + + composeTemplate(templates.form.autocomplete, { + params: { + id: 'user-auto-selector', + searchValue: '', + searchLabel: 'Find User to add', + searchPlaceholder: 'Search User...', + btnId: 'add-member-btn', + btnIcon: 'user-plus', + btnTitle: 'Add Selected User', + btnContent: 'Add', + }, + parent: layout.header, + render: (elems) => { + const matched = elems[0].parentElement.querySelectorAll('[data-role^="autocomplete-"]'); + const objects = { }; + layout.autocomplete = objects; + + for (let i = 0; i < matched.length; ++i) { + const obj = matched[i] + const drole = obj.getAttribute('data-role').split('-').pop(); + objects[drole] = obj; + } + } + }); + + if (!Array.isArray(this.value)) { + this.value = []; + } + this.#render(); + } +}; diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/services/dashboardService/constants.js b/CodeListLibrary_project/cll/static/js/clinicalcode/services/dashboardService/constants.js new file mode 100644 index 000000000..d8346f964 --- /dev/null +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/services/dashboardService/constants.js @@ -0,0 +1,104 @@ +export const + /** + * CLU_DASH_TARGETS + * @desc Describes `data-value` modifier(s) + * + */ + CLU_LABELS = { + pages: { + // Views + 'inventory': 'Inventory', + 'brand-config': 'Brand Config', + + // Models + 'users': 'Users', + 'organisations': 'Organisations', + } + }, + /** + * CLU_ACTIVITY_CARDS + * @desc render information relating to activity statistics card(s) + * + */ + CLU_ACTIVITY_CARDS = [ + { + key: 'dau', + name: 'DAU', + desc: 'No. of unique Daily Active Users today.', + icon: '', + iconCls: 'as-icon--warning', + }, + { + key: 'mau', + name: 'MAU', + desc: 'No. of unique Monthly Active Users this month.', + icon: '', + iconCls: 'as-icon--warning', + }, + { + key: 'hits', + name: 'Page Hits', + desc: 'No. of page hits in the last 7 days.', + icon: '', + iconCls: 'as-icon--warning', + }, + { + key: 'created', + name: '${brandMapping.phenotype}s Created', + desc: 'No. of ${brandMapping.phenotype}s created in the last 7 days', + icon: '', + iconCls: 'as-icon--warning', + }, + { + key: 'edited', + name: '${brandMapping.phenotype}s Edited', + desc: 'No. of ${brandMapping.phenotype}s edited in the last 7 days', + icon: '', + iconCls: 'as-icon--warning', + }, + { + key: 'published', + name: '${brandMapping.phenotype}s Published', + desc: 'No. of ${brandMapping.phenotype}s published in the last 7 days', + icon: '', + iconCls: 'as-icon--warning', + }, + ], + /** + * CLU_DASH_TARGETS + * @desc Describes `data-value` modifier(s) + * + */ + CLU_DASH_TARGETS = { + NEXT: 'next', + PREVIOUS: 'previous', + }, + /** + * CLU_DASH_ATTRS + * @desc Field attr lookup + * + */ + CLU_DASH_ATTRS = { + username: { + inputType: 'text', + autocomplete: 'username', + }, + first_name: { + inputType: 'text', + autocomplete: 'given-name', + }, + last_name: { + inputType: 'text', + autocomplete: 'family-name', + }, + }, + /** + * CLU_DATATYPE_ATTR + * @desc Field data attr lookup + * + */ + CLU_DATATYPE_ATTR = { + TimeField: 'time', + DateField: 'date', + DateTimeField: 'datetime-local', + }; diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/services/dashboardService/dashboard.js b/CodeListLibrary_project/cll/static/js/clinicalcode/services/dashboardService/dashboard.js new file mode 100644 index 000000000..7ec13e57e --- /dev/null +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/services/dashboardService/dashboard.js @@ -0,0 +1,1052 @@ +import * as Const from './constants.js'; + +import { FormView } from './views/formView.js'; +import { TableView } from './views/tableView.js'; +import { managePlugins } from './managers/plugins.js'; +import { manageNavigation } from './managers/navigation.js'; +import { managePopoverMenu } from '../../components/popoverMenu.js'; + +/** + * Class to serve & manage Dashboard page content + * + * @class + * @constructor + */ +export class DashboardService { + /** + * @desc + * @type {string} + * @static + * @constant + */ + static #UrlPath = 'dashboard'; + + /** + * @desc default constructor props + * @type {Record<string, any>} + * @static + * @constant + * + * @property {string} [page='overview'] Page to open on initialisation + * @property {string} [view] View to open on initialisation + * @property {string} [token=*] CSRF cookie (auto-filled) + * @property {*} [target] Entity to target on initialisation + * @property {string|HTMLElement} [element='#app'] The root app element in which to render the service + */ + static #DefaultOpts = { + page: 'overview', + view: null, + token: null, + target: null, + element: '#app', + }; + + /** + * @desc + * @type {HTMLElement} + * @public + */ + element = null; + + /** + * @desc + * @type {object} + * @private + */ + #state = { + initialised: false, + }; + + /** + * @desc + * @type {Nullable<object>} + * @private + */ + #controller = null; + + /** + * @desc + * @type {Record<string, HTMLElement>} + * @private + */ + #layout = {}; + + /** + * @desc + * @type {Record<string, Record<string, HTMLElement>} + * @private + */ + #templates = {}; + + /** + * @desc + * @type {Array<Function>} + * @private + */ + #disposables = []; + + /** + * @param {Record<string, any>} [opts] constructor arguments; see {@link DashboardService.#DefaultOpts} + */ + constructor(opts = null) { + opts = isRecordType(opts) ? opts : { }; + opts = mergeObjects(opts, DashboardService.#DefaultOpts, true); + + this.#initialise(opts); + } + + /************************************* + * * + * Public * + * * + *************************************/ + openPage(page, view = 'list', target = null, pushState = true) { + const state = this.#state; + if (state.page === page && state.view === view && state.target === target && state.initialised) { + return; + } + + let hnd; + switch (page) { + case 'overview': + hnd = this.#renderOverview; + view = view ?? 'view'; + break; + + case 'inventory': + hnd = this.#renderInventory; + view = view ?? 'view'; + break; + + case 'brand-config': + hnd = this.#renderBrand; + view = view ?? 'update'; + break; + + default: + hnd = this.#renderModelView; + view = view ?? 'list'; + break; + } + + if (hnd) { + state.page = page; + state.view = view; + state.target = target; + state.initialised = true; + this.#toggleNavElement(page); + + if (pushState) { + this.#pushDashboardState(); + } + hnd.apply(this); + } + } + + dispose() { + const controller = this.#controller; + if (controller) { + this.#controller = null; + controller.dispose(); + } + + let disposable; + for (let i = this.#disposables.length; i > 0; i--) { + disposable = this.#disposables.pop(); + if (typeof disposable !== 'function') { + continue; + } + + disposable(); + } + } + + + /************************************* + * * + * Private * + * * + *************************************/ + #getTargetUrl( + target, + { + view = 'view', + kwargs = null, + parameters = null, + useBranded = true, + } = {} + ) { + view = view.toLowerCase(); + + if (!!parameters && parameters instanceof URLSearchParams) { + parameters = '?' + parameters; + } else if (isObjectType(parameters)) { + parameters = '?' + new URLSearchParams(parameters); + } else if (typeof parameters !== 'string') { + parameters = ''; + } + + const host = useBranded ? getBrandedHost() : getCurrentHost(); + const root = host + '/' + DashboardService.#UrlPath; + switch (view) { + case 'view': + return `${root}/view/${target}/` + parameters; + + case 'list': + return `${root}/target/${target}/` + parameters; + + case 'create': + return `${root}/target/${target}/` + parameters; + + case 'update': + if (!isNullOrUndefined(kwargs)) { + return `${root}/target/${target}/${kwargs}/` + parameters; + } + return `${root}/target/${target}/` + parameters; + + default: + return null + } + } + + #fetch(url, opts = {}) { + const token = this.#state.token; + opts = mergeObjects( + isObjectType(opts) ? opts : {}, + { + method: 'GET', + cache: 'no-cache', + credentials: 'same-origin', + withCredentials: true, + headers: { + 'Accept': 'application/json', + 'X-CSRFToken': token, + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + }, + false, + true + ); + + return fetch(url, opts); + } + + #pushDashboardState(parameters = null, useBranded = true) { + const state = this.#state; + state.view = state.view ?? 'view'; + + let { page, view, target } = state; + if (!!parameters && parameters instanceof URLSearchParams) { + parameters = '?' + parameters; + } else if (isObjectType(parameters)) { + parameters = '?' + new URLSearchParams(parameters); + } else { + parameters = ''; + } + + const host = useBranded ? getBrandedHost() : getCurrentHost(); + const root = host + '/' + DashboardService.#UrlPath; + + let url; + switch (view) { + case 'view': + url = `${root}/${parameters}#view~${page}`; + break; + + case 'list': + if (!isRecordType(target)) { + url = `${root}/${parameters}#list~${page}`; + } else { + url = `${root}/${parameters}#list~${page}~${target.type}`; + } + break; + + case 'create': + if (!isRecordType(target)) { + url = `${root}/${parameters}#create~${page}`; + } else { + url = `${root}/${parameters}#create~${page}~${target.type}`; + } + break; + + case 'update': + let kwargs, type; + if (isRecordType(target)) { + type = !isNullOrUndefined(target.type) ? `~${target.type}` : ''; + kwargs = !isNullOrUndefined(target.kwargs) ? `@${target.kwargs}` : ''; + } else if (!isNullOrUndefined(target)) { + type = ''; + kwargs = `@${target}`; + } else { + type = ''; + kwargs = ''; + } + + url = `${root}/${parameters}#update~${page}${type}${kwargs}`; + break; + + default: + break; + } + + window.history.pushState({ dashboardRef: state }, null, url); + } + + + /************************************* + * * + * Renderables * + * * + *************************************/ + #toggleNavElement(target) { + const items = this.#layout.nav.querySelectorAll('[data-controlledby="navigation"]'); + for (let i = 0; i < items.length; ++i) { + const btn = items[i]; + const ref = btn.getAttribute('data-ref'); + btn.setAttribute('data-active', ref === target); + } + } + + #clearContent() { + const controller = this.#controller; + if (controller) { + this.#controller = null; + controller.dispose(); + } + + this.#layout.content.replaceChildren(); + } + + #displayAssets({ + container, + assets, + spinner = null, + callback = null, + } = {}) { + if (!isHtmlObject(container) || !isObjectType(assets)) { + console.warn('[Dashboard]: Failed to render Asset cards'); + return; + } + + const keys = Object.keys(assets).sort((a, b) => { + const v0 = assets[a]?.details?.verbose_name ?? ''; + const v1 = assets[b]?.details?.verbose_name ?? ''; + return v0 < v1 ? -1 : (v0 > v1 ? 1 : 0); + }); + + for (let i = 0; i < keys.length; ++i) { + const key = keys[i]; + const asset = assets[key]; + const details = isObjectType(asset) ? asset?.details : null + if (!details) { + continue; + } + + const [card] = composeTemplate(this.#templates.base.action_card, { + params: { + ref: key, + name: details.verbose_name, + desc: `Manage ${details.verbose_name_plural}`, + icon: '', + iconCls: 'as-icon--primary', + action: 'Manage', + }, + parent: container, + }); + + const button = card.querySelector('[data-role="action-btn"]'); + button.addEventListener('click', () => { + if (typeof callback === 'function') { + callback(key); + } + }); + } + + if (isRecordType(spinner) && typeof spinner.remove === 'function') { + spinner.remove(); + } + } + + #renderOverview() { + const state = this.#state; + const brandMapping = this.#templates.brandMapping; + this.#clearContent(); + + const [activity] = composeTemplate(this.#templates.base.group, { + params: { + id: 'activity', + title: 'Activity', + level: '1', + articleCls: 'dashboard-view__content--fill-w', + sectionCls: 'dashboard-view__content-grid slim-scrollbar', + content: '', + }, + parent: this.#layout.content, + }); + + const [quickAccess] = composeTemplate(this.#templates.base.group, { + params: { + id: 'quick-access', + title: 'Quick Access', + level: '2', + articleCls: 'dashboard-view__content--fill-w', + sectionCls: 'dashboard-view__content-grid slim-scrollbar', + content: '', + }, + parent: this.#layout.content, + }); + + let spinners; + let spinnerTimeout = setTimeout(() => { + spinners = { + activity: startLoadingSpinner(activity, true), + quickAccess: startLoadingSpinner(quickAccess, true), + }; + }, 200); + + const url = this.#getTargetUrl('overview'); + this.#fetch(url, { method: 'GET' }) + .then(res => { + if (!spinners) { + clearTimeout(spinnerTimeout); + } + + return res.json(); + }) + .then(res => { + const stats = res.summary.data; + const statsTimestamp = new Date(Date.parse(res.summary.timestamp)); + + const [statsDatetime] = composeTemplate(this.#templates.base.time, { + params: { + datefmt: statsTimestamp.toLocaleString(), + timestamp: statsTimestamp.toISOString(), + } + }); + + const activityContent = activity.querySelector('section'); + for (let key in stats) { + const details = Const.CLU_ACTIVITY_CARDS.find(x => x.key === key); + if (!details) { + continue; + } + + composeTemplate(this.#templates.base.display_card, { + params: { + name: interpolateString(details.name, { brandMapping }), + desc: interpolateString(details.desc, { brandMapping }), + icon: details.icon, + iconCls: details.iconCls, + content: `<figure class="card__data">${stats[key].toLocaleString()}</figure>`, + footer: statsDatetime.outerHTML, + }, + parent: activityContent, + }); + } + spinners?.activity?.remove?.(); + + const assets = res.assets; + const quickAccessContent = quickAccess.querySelector('section'); + this.#displayAssets({ + assets: assets, + container: quickAccessContent, + spinner: spinners?.quickAccess ?? null, + callback: (key) => { + this.openPage('inventory', 'list', { type: key, labels: assets?.[key]?.details }); + } + }); + spinners?.quickAccess?.remove?.(); + + if (quickAccessContent.childElementCount < 1) { + quickAccess.remove(); + } + }) + .catch(e => { + console.error(`[Dashboard] Failed to load view:\n\n- State: ${this.#state}- with err: ${e}\n`); + + window.ToastFactory.push({ + type: 'warning', + message: 'Failed to load view, please try again.', + duration: 4000, + }); + }); + } + + #renderModelView() { + const state = this.#state; + + const type = state.page; + const view = state.view ?? 'list'; + state.view = view; + + const label = transformTitleCase(state.page.replace('_', ' ')); + this.#clearContent(); + + let article, container, header, createBtn; + composeTemplate(this.#templates.base.model, { + params: { + id: type, + title: `${transformTitleCase(view)} ${label}`, + level: '1', + content: '', + headerCls: '', + articleCls: 'dashboard-view__content--fill-w', + sectionCls: view === 'list' ? 'dashboard-view__content-display' : 'dashboard-view__content-form', + }, + parent: this.#layout.content, + render: (elem) => { + article = elem[0]; + + header = article.querySelector('header'); + createBtn = header.querySelector('[data-role="create-btn"]'); + container = article.querySelector('section'); + } + }); + + let url; + switch (view) { + case 'list': { + url = this.#getTargetUrl(type, { view: 'list' }); + header.classList.add('dashboard-view__content-header--constrain'); + + const ctrl = new TableView({ + url: url, + state: state, + element: container, + displayCallback: (_ref, trg) => { + state.label = label; + this.openPage(type, 'update', trg); + } + }); + this.#controller = ctrl; + + createBtn.addEventListener('click', (e) => { + this.openPage(type, 'create', null); + }); + } break; + + case 'create': { + url = this.#getTargetUrl(type, { view: 'create' }); + header.classList.add('dashboard-view__content-header--constrain-sm'); + createBtn.remove(); + + const ctrl = new FormView({ + url: url, + type: 'create', + state: state, + element: container, + actionCallback: this.#actionCallback.bind(this), + completeCallback: this.#completeCallback.bind(this), + }); + this.#controller = ctrl; + + } break; + + case 'update': + url = this.#getTargetUrl(type, { view: 'update', kwargs: state.target }); + header.classList.add('dashboard-view__content-header--constrain-sm'); + createBtn.remove(); + + const ctrl = new FormView({ + url: url, + type: 'update', + state: state, + element: container, + actionCallback: this.#actionCallback.bind(this), + completeCallback: this.#completeCallback.bind(this), + }); + this.#controller = ctrl; + + break; + + default: + break; + } + } + + #renderBrand() { + const state = this.#state; + const type = state.page; + const view = 'update'; + state.view = view; + state.target = null; + + const label = transformTitleCase(type.replace('_', ' ')); + this.#clearContent(); + + let article, container, header, createBtn; + composeTemplate(this.#templates.base.model, { + params: { + id: 'brand', + title: `${transformTitleCase(view)} ${label}`, + level: '1', + content: '', + headerCls: '', + articleCls: 'dashboard-view__content--fill-w', + sectionCls: view === 'list' ? 'dashboard-view__content-display' : 'dashboard-view__content-form', + }, + parent: this.#layout.content, + render: (elem) => { + article = elem[0]; + + header = article.querySelector('header'); + createBtn = header.querySelector('[data-role="create-btn"]'); + container = article.querySelector('section'); + } + }); + + const url = this.#getTargetUrl('brand', { view: 'update', kwargs: null }); + header.classList.add('dashboard-view__content-header--constrain-sm'); + createBtn.remove(); + + const ctrl = new FormView({ + url: url, + type: 'update', + state: state, + element: container, + actionCallback: this.#actionCallback.bind(this), + completeCallback: this.#completeCallback.bind(this), + }); + this.#controller = ctrl; + } + + #renderInventory() { + const state = this.#state; + this.#clearContent(); + + let view, url, target; + view = state.view; + target = state.target; + if (!isRecordType(target)) { + url = this.#getTargetUrl('inventory'); + + let spinner; + let spinnerTimeout = setTimeout(() => { + spinner = startLoadingSpinner(activity, true); + }, 200); + + const [assetList] = composeTemplate(this.#templates.base.group, { + params: { + id: 'asset-list', + title: 'Inventory', + level: '2', + articleCls: 'dashboard-view__content--fill-w', + sectionCls: 'dashboard-view__content-grid slim-scrollbar', + content: '', + }, + parent: this.#layout.content, + }); + + this.#fetch(url, { method: 'GET' }) + .then(res => { + if (!spinner) { + clearTimeout(spinnerTimeout); + } + + return res.json(); + }) + .then(res => { + const assets = res.assets; + const content = assetList.querySelector('section'); + this.#displayAssets({ + assets: assets, + container: content, + spinner: spinner ?? null, + callback: (key) => { + this.openPage('inventory', 'list', { type: key, labels: assets?.[key]?.details }); + } + }); + spinner?.remove?.(); + + if (content.childElementCount < 1) { + assetList.remove(); + } + }) + .catch(e => { + console.error(`[Dashboard] Failed to load view:\n\n- State: ${this.#state}- with err: ${e}\n`); + + window.ToastFactory.push({ + type: 'warning', + message: 'Failed to load view, please try again.', + duration: 4000, + }); + }); + } else { + view = view ?? 'list'; + state.view = view; + + url = this.#getTargetUrl(target.type, { view: view, kwargs: target.kwargs }); + + const type = target.type; + const label = target?.labels?.verbose_name ?? transformTitleCase((target.type ?? 'Model').replace('_', ' '));; + + let article, container, header, createBtn; + composeTemplate(this.#templates.base.model, { + params: { + id: type, + title: `${transformTitleCase(view)} ${label}`, + level: '1', + content: '', + headerCls: '', + articleCls: 'dashboard-view__content--fill-w', + sectionCls: view === 'list' ? 'dashboard-view__content-display' : 'dashboard-view__content-form', + }, + parent: this.#layout.content, + render: (elem) => { + article = elem[0]; + + header = article.querySelector('header'); + createBtn = header.querySelector('[data-role="create-btn"]'); + container = article.querySelector('section'); + } + }); + + switch (view) { + case 'list': { + header.classList.add('dashboard-view__content-header--constrain'); + + const ctrl = new TableView({ + url: url, + state: state, + element: container, + displayCallback: (_ref, trg) => { + this.openPage('inventory', 'update', { type: type, labels: target?.labels, kwargs: trg }); + } + }); + this.#controller = ctrl; + + createBtn.addEventListener('click', (e) => { + this.openPage('inventory', 'create', { type: type, labels: target?.labels }); + }); + } break; + + case 'create': { + header.classList.add('dashboard-view__content-header--constrain-sm'); + createBtn.remove(); + + const ctrl = new FormView({ + url: url, + type: 'create', + state: state, + element: container, + actionCallback: this.#actionCallback.bind(this), + completeCallback: this.#completeCallback.bind(this), + }); + this.#controller = ctrl; + + } break; + + case 'update': + header.classList.add('dashboard-view__content-header--constrain-sm'); + createBtn.remove(); + + const ctrl = new FormView({ + url: url, + type: 'update', + state: state, + element: container, + actionCallback: this.#actionCallback.bind(this), + completeCallback: this.#completeCallback.bind(this), + }); + this.#controller = ctrl; + + break; + + default: + break; + } + } + } + + + /************************************* + * * + * Events * + * * + *************************************/ + #eventHandler(e) { + + } + + #handleHistory(e) { + const ref = !isNullOrUndefined(e.state) ? e.state.dashboardRef : null; + if (isNullOrUndefined(ref)) { + return; + } + + const { page, view, target } = ref; + this.openPage(page, view, target, false); + } + + #handleNavigation(e, targetName) { + this.openPage(targetName); + } + + #handlePopoverMenu(e, _group, _closeHnd) { + const btn = e.target; + const link = btn.getAttribute('data-link'); + if (typeof link === 'string') { + tryNavigateLink(btn, { relatedEvent: e }); + } + } + + #completeCallback(eventType, data) { + switch (eventType) { + case 'submitForm': { + const state = data.ok ? data.form.state : null; + if (!isRecordType(state)) { + break; + } + + let actionType; + const method = state.view; + if (method === 'create') { + const entityId = isRecordType(data.result) && !isNullOrUndefined(data.result.id) + ? data.result.id + : null; + + if (isRecordType(state.target)) { + state.target.kwargs = entityId; + } else { + state.target = entityId; + } + + state.view = 'update'; + actionType = 'created'; + } else { + actionType = 'updated'; + } + + window.ToastFactory.push({ + type: 'success', + message: `Entity successfully ${actionType}.`, + duration: 4000, + }); + + this.openPage(state.page, state.view, state.target); + } break; + + default: + console.warn(`[Dashboard] Failed to process completion callback signal of type '${eventType}'`); + break; + } + } + + #actionCallback(action, e, props, _formView, _btn) { + e.preventDefault(); + + const { type, url } = props; + if (type !== 'update' || !stringHasChars(url)) { + return; + } + + switch (action) { + case 'reset_pwd': { + let spinner; + ModalFactory.create({ + title: 'Are you sure?', + content: 'This will immediately send a password reset e-mail to the user.', + beforeAccept: () => { + spinner = startLoadingSpinner(); + return this.#fetch(url, { method: 'PUT', headers: { 'X-Target': action } }) + .then(async response => { + if (!response.ok) { + const headers = response.headers; + + let msg; + if (headers.get('content-type').search('json')) { + try { + let packet = await response.json(); + packet = JSON.stringify(packet); + msg = packet; + } catch (e) { + console.warn(`[Dashboard::${action}] Failed to parse reset json error with err:\n\n${e}\n`); + } + } + + if (!isNullOrUndefined(msg)) { + msg = `[Dashboard::${action}::${response.status}] Failed to send reset e-mail with err:\n\n${msg}\n`; + } else { + msg = `[Dashboard::${action}::${response.status}] ${response.statusText}`; + } + + throw new Error(msg); + } + + return response.json(); + }); + } + }) + .then(async result => { + await result.data; // SINK + + window.ToastFactory.push({ + type: 'success', + message: 'Password reset e-mail successfully sent.', + duration: 4000, + }); + }) + .catch((e) => { + if (!!e && !(e instanceof ModalFactory.ModalResults)) { + window.ToastFactory.push({ + type: 'error', + message: 'Failed to send reset e-mail, please check their e-mail and try again.', + duration: 4000, + }); + + return console.error(e); + } + }) + .finally(() => { + spinner?.remove?.(); + }); + } break; + + default: + break; + } + } + + + /************************************* + * * + * Initialiser * + * * + *************************************/ + #initialise(opts) { + let element = opts.element; + if (typeof element === 'string') { + element = document.querySelector(element); + } + + if (!isHtmlObject(element)) { + throw new Error('InitError: Failed to resolve DashboardService element'); + } + + let token = opts.token; + if (typeof token !== 'string' || !stringHasChars(token)) { + token = getCookie('csrftoken'); + } + + this.#state = mergeObjects(this.#state, { page: opts.page, token: token }, false); + this.element = element; + this.#collectPage(); + + // Init event listeners + const eventHandler = this.#eventHandler.bind(this); + element.addEventListener('dashboard', eventHandler, false); + + // Observe hx + const hxHnd = this.#handleHistory.bind(this); + window.addEventListener('popstate', hxHnd); + + // Initialise managers + this.#disposables.push( + manageNavigation({ + parent: element, + callback: this.#handleNavigation.bind(this), + }), + managePlugins({ + parent: element, + observeMutations: true, + observeDescendants: true, + }), + managePopoverMenu({ + parent: element, + callback: this.#handlePopoverMenu.bind(this), + }), + ); + + // Init render + + const { hash } = window.location; + if (stringHasChars(hash)) { + const crumbs = hash.trim() + .split(/~/g) + .reduce((res, x) => { + if (stringHasChars(x)) { + res.push(x.trim()); + } + return res; + }, []); + + if (crumbs.length === 2) { + let [view, trg] = crumbs; + view = view.replace('#', ''); + + const [_, page, ref] = trg.match(/([\w_\-]+)@?(\d+)?/); + this.openPage(page, view, typeof ref !== 'undefined' ? ref : null); + return; + } else if (crumbs.length === 3) { + let [view, page, trg] = crumbs; + view = view.replace('#', ''); + + const url = this.#getTargetUrl('overview'); + const [_, asset, ref] = trg.match(/([\w_\-]+)@?(\d+)?/); + + const spinner = startLoadingSpinner(); + this.#fetch(url, { method: 'GET' }) + .then(res => { + return res.json(); + }) + .then(res => { + this.openPage(page, view, { + type: asset, + kwargs: typeof ref !== 'undefined' ? ref : null, + labels: res?.assets?.[asset]?.details + }); + }) + .catch((e) => { + console.error(`[Dashboard] Failed to initialise from hash with err:\n\n${e}\n`); + + this.openPage(this.#state.page, 'view'); + }) + .finally(() => { + if (spinner) { + spinner?.remove?.(); + } + }); + + return; + } + } + this.openPage(this.#state.page, 'view'); + } + + #collectPage() { + const layout = document.querySelectorAll('[id^="app-"]'); + const templates = document.querySelectorAll('template[data-for="dashboard"], script[data-for="dashboard"]'); + + // Collect base layout + let elem, name; + for (let i = 0; i < layout.length; ++i) { + elem = layout[i]; + name = elem.getAttribute('id').replace('app-', ''); + this.#layout[name] = elem; + } + + // Collect templates + let view, group; + for (let i = 0; i < templates.length; ++i) { + elem = templates[i]; + name = elem.getAttribute('data-name'); + view = elem.getAttribute('data-view'); + if (!stringHasChars(view)) { + view = 'base'; + } + + if (elem.tagName === 'TEMPLATE') { + group = this.#templates?.[view]; + if (!group) { + group = { }; + this.#templates[view] = group; + } + + group[name] = elem; + } else if (elem.tagName === 'SCRIPT' && elem.getAttribute('desc-type') === 'text/json') { + this.#templates[view] = JSON.parse(elem.innerText.trim()); + } + } + } +}; diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/services/dashboardService/index.js b/CodeListLibrary_project/cll/static/js/clinicalcode/services/dashboardService/index.js new file mode 100644 index 000000000..5d19824b9 --- /dev/null +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/services/dashboardService/index.js @@ -0,0 +1,5 @@ +import { DashboardService } from './dashboard.js'; + +domReady.finally(() => { + window.Dashboard = new DashboardService(); +}); diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/services/dashboardService/managers/navigation.js b/CodeListLibrary_project/cll/static/js/clinicalcode/services/dashboardService/managers/navigation.js new file mode 100644 index 000000000..e025e81f5 --- /dev/null +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/services/dashboardService/managers/navigation.js @@ -0,0 +1,83 @@ +/** + * @desc initialises & manages: + * - navigation menu selection; + * - events relating to the `.dashboard-nav-toggle` class. + * + * @param {object} param0 navigation behaviour opts + * @param {Function|any} param0.callback optionally specify the menu item click handler + * @param {HTMLElement} param0.parent optionally specify the parent `HTMLElement` + * + * @returns {Function} a disposable to clean up the navigation interaction handlers + */ +export const manageNavigation = ({ + callback = (e, ref) => { }, + parent = document +} = { }) => { + const disposables = []; + + const menuDisposable = createGlobalListener( + 'nav.menu:click', + '[data-controlledby="navigation"]', + (e) => { + const btn = e.target; + const ref = btn.getAttribute('data-ref'); + if (!stringHasChars(ref)) { + return; + } + + callback(e, ref); + } + ); + disposables.push(menuDisposable); + + const states = new Map(); + const toggleDisposable = createGlobalListener( + 'nav.toggle:click', + '[data-controlledby="nav-toggle"] [data-role="toggle"]', + (e) => { + const group = tryGetRootElement(e.target, '.dashboard-nav-toggle'); + if (!group) { + return; + } + + let state = states.get(group); + if (typeof state === 'undefined') { + let toggle = group.getAttribute('data-toggle'); + toggle = stringHasChars(toggle) ? strictSanitiseString(toggle) : null; + + if (!stringHasChars(toggle)) { + return; + } + + const relative = parent.querySelector(`[data-ref="${toggle}"]`); + if (!relative) { + return; + } + + state = relative.getAttribute('data-open'); + state = typeof state === 'string' + ? state.toLowerCase() === 'true' + : false; + + state = { open: state, relative: relative }; + states.set(group, state); + } + + state.open = !state.open; + state.relative.setAttribute('data-open', state.open); + } + ) + disposables.push(toggleDisposable); + + return () => { + let disposable; + for (let i = disposables.length; i > 0; --i) { + disposable = disposables.pop(); + if (typeof disposable !== 'function') { + continue; + } + + disposable(); + } + }; +}; diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/services/dashboardService/managers/plugins.js b/CodeListLibrary_project/cll/static/js/clinicalcode/services/dashboardService/managers/plugins.js new file mode 100644 index 000000000..0145361c8 --- /dev/null +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/services/dashboardService/managers/plugins.js @@ -0,0 +1,182 @@ +/** + * @desc resolves the plugin class associated with the specified name (if found) + * + * @param {string} pluginName the name of the desired plugin + * @param {Record<string, Object>|null} cache optionally specify a cache if the manager hnd is to be instantiated just once; defaults to `null` + * @param {Record<string, any>|null} opts optionally specify a recordset containing options to be supplied to the plugin constructor(s); defaults to `null` + * + * @returns {Object|null} either (a) a `Record<str, Any>` describing the plugin's `handle` class and whether this cls was instantiated, or (b) null if plugin name is not known + */ +export const tryInitPluginManager = (pluginName, cache = null, opts = null) => { + if (!stringHasChars(pluginName)) { + return null; + } + + const hasCache = isRecordType(cache); + pluginName = pluginName.trim(); + + if (hasCache && cache.hasOwnProperty(pluginName)) { + const cached = cache[pluginName]; + if (isRecordType(cached)) { + return { handle: cached.handle, wasInstantiated: false }; + } + + return null; + } + + let hnd; + switch (pluginName) { + case 'tooltip': + hnd = window.TooltipFactory; + break; + + default: + hnd = null; + break; + } + + if (!!hnd && hnd.toString().startsWith('class')) { + opts = isRecordType(opts) && opts.hasOwnProperty(pluginName) + ? opts[pluginName] + : []; + + if (Array.isArray(opts)) { + hnd = new hnd(...opts); + } else if (typeof opts === 'function') { + hnd = opts(pluginName, cache, opts); + } else if (isRecordType(opts)) { + if (Array.isArray(opts.instantiate)) { + hnd = new hnd(...opts.instantiate); + } else if (typeof opts.instantiate === 'function') { + hnd = opts.instantiate(pluginName, cache, opts); + } + + if (typeof opts.postInstantiate === 'function') { + opts.postInstantiate(hnd, pluginName, cache, opts); + } + } + + if (typeof hnd === 'undefined' || hnd === null) { + return null; + } + } + + if (hasCache) { + cache[pluginName] = { handle: hnd, elements: [] }; + } + + return { handle: hnd, wasInstantiated: true }; +}; + +/** + * @desc requests an element be managed by a particular plugin + * + * @param {string} pluginName the name of the desired plugin + * @param {HTMLElement} node the HTMLElement to add to the specified plugin + * @param {Array<Function>} disposables optionally specify an array in which to store the dispose methods for use on cleanup + * @param {Record<string, Object>|null} cache optionally specify a cache if the manager hnd is to be instantiated just once; defaults to `null` + * @param {Record<string, any>|null} opts optionally specify a recordset containing options to be supplied to the plugin constructor(s); defaults to `null` + * + * @returns {Object|null} either (a) a `Record<str, Any>` describing the plugin's `handle` class and whether this cls was instantiated, or (b) null if plugin name is not known + */ +export const addElemToPlugin = (pluginName, node, disposables = null, cache = null, opts = null) => { + const res = tryInitPluginManager(pluginName, cache, opts); + if (!res) { + return null; + } + + res.handle.addElement(node); + + if (cache) { + let cached = isRecordType(cache[pluginName]) && Array.isArray(cache[pluginName].elements) + ? cache[pluginName] + : null; + + if (!cached) { + cached = { handle: hnd, elements: [] }; + cache[pluginName] = cached; + } + + cached.elements.push(node); + } + + if (res.handle.wasInstantiated && Array.isArray(disposables)) { + if (res.handle.hasOwnProperty('dispose') && typeof res.handle.dispose === 'function') { + disposables.push(() => res.handle.dispose()); + } else { + opts = isRecordType(opts) && opts.hasOwnProperty(pluginName) + ? opts[pluginName] + : []; + + const disposer = isRecordType(opts) && typeof opts.dispose === 'function' + if (disposer) { + disposables.push(() => disposer(res.handle, pluginName, cache, opts)); + } + } + } + + return res; +} + +/** + * @desc initialises plugin managers for page components + * + * @param {object} param0 plugin behaviour opts + * @param {HTMLElement} param0.parent optionally a HTMLElement in which to find popover menu item(s); defaults to `document` + * @param {Record<string, Array<any>>} param0.options optionally specify a set of options describing plugin constructor arguments for each plugin type + * @param {boolean} param0.observeMutations optionally specify whether to observe the addition & removal of plugin items; defaults to `false` + * @param {boolean} param0.observeDescendants optionally specify whether to observe the descendant subtree when observing descendants; defaults to `false` + */ +export const managePlugins = ({ + parent = document, + options = { }, + observeMutations = false, + observeDescendants = false, +}) => { + const elements = parent.querySelectorAll('[data-plugins]'); + + const handlers = { }; + const disposables = []; + for (let i = 0; i < elements.length; ++i) { + const element = elements[i]; + + let plugins = element.getAttribute('data-plugins'); + if (!stringHasChars(plugins)) { + continue; + } + + plugins = plugins.split(/(?:,|;)\s*/); + for (let j = 0; j < plugins.length; ++j) { + addElemToPlugin(plugins[j], element, disposables, handlers, options); + } + } + + if (observeMutations) { + const observer = new MutationObserver((muts) => { + for (let i = 0; i < muts.length; ++i) { + const added = muts[i].addedNodes; + for (let j = 0; j < added.length; ++j) { + const node = added[j]; + if (!isHtmlObject(node) || !node.matches('[data-plugins]')) { + continue; + } + + let plugins = node.getAttribute('data-plugins'); + if (!stringHasChars(plugins)) { + continue; + } + + plugins = plugins.split(/(?:,|;)\s*/); + for (let j = 0; j < plugins.length; ++j) { + addElemToPlugin(plugins[j], element, disposables, handlers, options); + } + } + } + }); + + observer.observe(parent, { subtree: observeDescendants, childList: true }); + disposables.push(() => observer.disconnect()); + } + + return disposables; +}; diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/services/dashboardService/views/formView.js b/CodeListLibrary_project/cll/static/js/clinicalcode/services/dashboardService/views/formView.js new file mode 100644 index 000000000..a1ed8ac53 --- /dev/null +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/services/dashboardService/views/formView.js @@ -0,0 +1,1283 @@ +import * as Const from '../constants.js'; + +import Tagify from '../../../components/tagify.js'; +import { OrgMemberSelector } from '../components/orgMemberSelector.js'; +import { OrgAuthoritySelector } from '../components/orgAuthoritySelector.js'; + +/** + * @desc coerces the given value into the required string representation + * + * @param {'TimeField'|'DateField'|'DateTimeField'} fieldType a string specifying the desired field type + * @param {Date|string} value either a `Date` instance or a datetime `string` to coerce + * @param {any} [defaultValue=''] optionally specify a default value to return if coercion failure; defaults to an empty string + * + * @returns {string} either (a) a string representing the given date/time/datetime field; or (b) the specified default value + */ +const coerceDateTimeFieldValue = (fieldType, value, defaultValue = '') => { + if (typeof value === 'string' && stringHasChars(value)) { + value = new Date(Date.parse(value)); + } else if (!(value instanceof Date)) { + return defaultValue; + } + + try { + switch (fieldType) { + case 'TimeField': + value = `${value.getHours()}:${value.getMinutes()}`; + break; + + case 'DateField': + value = `${value.getFullYear()}-${value.getMonth()}-${value.getDay()}`; + break; + + case 'DateTimeField': + value = value.toISOString().slice(0, 16); + break; + + default: + value = defaultValue; + break + } + } catch { + value = defaultValue; + } + + return value ?? defaultValue; +} + +/** + * @desc resolves the input attributes associated with the given field + * + * @param {string} fieldName the dict key name, as derived from the model, of the field being evaluated + * @param {string} fieldType the desired type of the field as derived from the model + * @param {Record<string, any>} style the style dict assoc. with this field + * @param {Record<string,string>|any} [defaults={}] optionally specify the default return value + * + * @returns {Record<string, string>} the input attributes associated with this field + */ +const resolveInputType = (fieldName, fieldType, style, defaults = { inputType: 'text', autocomplete: 'off' }) => { + let inputType = stringHasChars(style.input_type) ? style.input_type : null; + let autocomplete = stringHasChars(style.autocomplete) ? style.autocomplete : null; + if (!!inputType && !!autocomplete) { + return { inputType, autocomplete }; + } + + switch (fieldType) { + case 'URLField': + inputType = inputType ?? 'url'; + autocomplete = autocomplete ?? 'url'; + break; + + case 'EmailField': + inputType = inputType ?? 'email'; + autocomplete = autocomplete ?? 'email'; + break; + + case 'PhoneNumberField': + inputType = inputType ?? 'tel'; + autocomplete = autocomplete ?? 'tel'; + break; + + case 'PasswordField': + inputType = inputType ?? 'password'; + autocomplete = autocomplete ?? 'current-password'; + break; + + default: { + const lookup = Const.CLU_DASH_ATTRS[fieldName]; + if (isRecordType(lookup)) { + inputType = inputType ?? lookup.inputType; + autocomplete = autocomplete ?? lookup.autocomplete; + } + } break; + } + + return { + inputType: !isNullOrUndefined(inputType) ? inputType : defaults.inputType, + autocomplete: !isNullOrUndefined(autocomplete) ? autocomplete : defaults.autocomplete, + }; +} + +/** + * Class to dynamically render data model forms + * + * @class + * @constructor + */ +export class FormView { + /** + * @desc default constructor props + * @type {Record<string, any>} + * @static + * @constant + * + * @property {string} url Table query URL + * @property {object} [state={}] Current table state; defaults to empty state; defaults to an empty object + * @property {object} [type='create'] Optionally specify the form method type; defaults to `create` object + * @property {string|HTMLElement} [element='#tbl'] The table view root element container; defaults to `#tbl` + * @property {Record<string, Record<string, HTMLElement>} [templates=null] Optionally specify the templates to be rendered (will collect from page otherwise); defaults to `null` + * @property {(...args) => void} [actionCallback=null] Optionally specify the actionbar callback (used to respond to action events, e.g. reset pwd); defaults to `null` + * @property {(...args) => void} [completeCallback=null] Optionally specify the completion callback (i.e. submission event, can be success/fail); defaults to `null` + */ + static #DefaultOpts = { + url: null, + type: 'create', + state: {}, + element: null, + templates: null, + actionCallback: null, + completeCallback: null, + }; + + /** + * @desc + * @type {HTMLElement} + * @public + */ + element = null; + + /** + * @desc + * @type {object} + * @private + */ + #props = { }; + + /** + * @desc + * @type {Record<string, Record<string, HTMLElement>} + * @private + */ + #templates = { }; + + /** + * @desc + * @type {Record<string, HTMLElement>} + * @private + */ + #layout = { }; + + /** + * @desc + * @type {Array<Function>} + * @private + */ + #disposables = []; + + /** + * @param {Record<string, any>} [opts] constructor arguments; see {@link FormView.#DefaultOpts} + */ + constructor(opts) { + opts = isRecordType(opts) ? opts : { }; + opts = mergeObjects(opts, FormView.#DefaultOpts, true); + + this.#initialise(opts); + } + + + /************************************* + * * + * Public * + * * + *************************************/ + dispose() { + let disposable; + for (let i = this.#disposables.length; i > 0; i--) { + disposable = this.#disposables.pop(); + if (typeof disposable !== 'function') { + continue; + } + + disposable(); + } + } + + + /************************************* + * * + * Renderables * + * * + *************************************/ + #render() { + const url = this.#props.url; + const type = this.#props.type; + const props = this.#props; + const layout = this.#layout; + const element = this.element; + const templates = this.#templates; + + const [dashForm] = composeTemplate(templates.form.entity_form, { + params: { + method: 'POST', + action: url, + }, + parent: element, + }); + layout.dashForm = dashForm; + + let spinners; + let spinnerTimeout = setTimeout(() => { + spinners = { + load: startLoadingSpinner(dashForm, true), + }; + }, 200); + + this.#fetch(url) + .then(res => res.json()) + .then(res => { + const { fields, order, form } = res.renderable; + + let data; + if (type === 'update' && res?.data) { + data = res.data; + } else if (type === 'create') { + data = { }; + } else { + throw new Error(`Fetch Err: Failed to retrieve Model data on View<${type}>`); + } + + let fieldOrder = Array.isArray(order) ? order : fields; + if (!Array.isArray(fieldOrder)) { + fieldOrder = []; + } + + const keys = [...Object.keys(form)].sort((a, b) => { + const relA = fieldOrder.indexOf(a); + const relB = fieldOrder.indexOf(b); + if (relA > -1 && relB > -1) { + return relA < relB ? -1 : (relA > relB ? 1 : 0); + } else if (relA > -1) { + return -1; + } else if (relB > -1) { + return 1; + } + + return a < b ? -1 : ((a > b) ? 1 : 0); + }); + + const formData = { }; + for (let i = 0; i < keys.length; ++i) { + const key = keys[i]; + const field = form[key]; + if (field.read_only) { + continue; + } + + let fieldType; + if (isRecordType(field.style) && stringHasChars(field.style.as_type)) { + fieldType = field.style.as_type; + } else if (field.value_format) { + fieldType = field.value_format; + } else { + fieldType = field.type; + } + + const value = data.hasOwnProperty(key) ? data[key] : (field.default ?? field.initial); + formData[key] = { + key: key, + type: fieldType, + subtype: field.subtype, + form: field, + help: field.help ?? field.help_text ?? '', + label: field.label ?? transformTitleCase(key.replace('_', ' ')), + value: value, + options: field.value_options, + }; + } + + props.formData = formData; + props.modelData = data; + + const features = isRecordType(res.renderable.features) ? res.renderable.features[props.type] : null; + if (isRecordType(features)) { + this.#renderFeatures(features); + } + + this.#renderForm(); + }) + .catch(e => { + console.error(`[FormView] Failed to load form:\n\n- Props: ${this.#props}- with err: ${e}\n`); + + window.ToastFactory.push({ + type: 'warning', + message: 'Failed to load view, please try again.', + duration: 4000, + }); + }) + .finally(() => { + if (!spinners) { + clearTimeout(spinnerTimeout); + } + spinners?.load?.remove?.(); + }); + } + + #renderFeatures(features) { + const element = this.element; + const dashForm = this.#layout.dashForm; + const callback = this.#props.actionCallback; + const modelData = this.#props.modelData; + const templates = this.#templates; + for (const key in features) { + switch (key) { + case 'note': { + createElement('div', { + className: 'note-block', + childNodes: [ + createElement('div', { + className: 'note-block__title', + childNodes: [ + '<span class="as-icon" data-icon="" aria-hidden="true"></span>', + createElement('p', { innerText: 'Note' }), + ], + }), + createElement('pre', { + className: 'note-block__message', + innerText: features[key], + }), + ], + parent: dashForm + }); + } break; + + case 'actionbar': { + const actions = features[key]; + if (!Array.isArray(actions)) { + break; + } + + for (let i = 0; i < actions.length; ++i) { + let action = actions[i]; + if (action === 'reset_pwd' && (!modelData || !modelData?.is_superuser)) { + composeTemplate(templates.form.button, { + params: { + id: action, + icon: 'padlock-unlock', + role: 'button', + style: 'fit-w margin-left-auto', + title: 'Send Password Reset', + }, + render: (elems) => { + const btn = elems[0]; + element.prepend(btn); + + if (typeof callback === 'function') { + btn.addEventListener('click', (e) => callback( + action, + e, + { type: this.#props.type, url: this.#props.url }, + this, + btn + )); + } + } + }); + } + } + + } break; + + default: + break + } + } + } + + #renderForm() { + const props = this.#props; + const element = this.element; + const dashForm = this.#layout.dashForm; + const templates = this.#templates; + + const data = props.formData; + const collectors = { }; + for (const key in data) { + const field = data[key]; + const formset = field.form; + + let fieldType = field.type; + if (isRecordType(fieldType)) { + fieldType = stringHasChars(fieldType.type) ? fieldType.type : field.subtype; + } + + // Handle ManyToMany `Through` fields + if (fieldType === 'through') { + const component = this.#renderComponent(field); + if (!isNullOrUndefined(component)) { + collectors[field.key] = () => { + return component.getDataValue(); + }; + } + + continue; + } + + // Handle misc. type-derived component(s) + let value = field.value ?? formset.default ?? formset.initial; + const label = field.label ?? formset.label ?? transformTitleCase(key); + const style = isRecordType(formset.style) ? formset.style : { }; + const helpText = field.help ?? formset.help_text ?? ''; + const isRequired = typeof formset.required === 'boolean' && formset.required; + + let renderParameters, renderTemplate, renderCallback; + switch (fieldType) { + // Single or Multi ForeignKey + case 'ForeignKey': + case 'PrimaryKeyRelatedField': { + const subtype = field.subtype; + if (typeof subtype === 'string' && subtype.toLowerCase().startsWith('list')) { + // Multi select FK + const options = field.options; + if (!Array.isArray(options)) { + break; + } + + if (!Array.isArray(value)) { + value = []; + } + + renderTemplate = templates.form.MultiForeignKeyField; + renderParameters = { + cls: style.class ?? '', + key: key, + help: helpText, + title: label, + required: isRequired ? 'required="true"' : '', + }; + renderCallback = (elems) => { + const input = elems[0].querySelector('input'); + if (isNullOrUndefined(input)) { + return; + } + + const tagbox = new Tagify( + input, + { + items: options.map(item => { + const itemPk = !isNullOrUndefined(item.pk) ? item.pk : item.id; + const itemName = !isNullOrUndefined(item.name) ? item.name : item.username; + return { name: itemName, value: itemPk }; + }), + useValue: true, + behaviour: { + freeform: false, + }, + autocomplete: true, + allowDuplicates: false, + onLoad: (box) => { + for (let i = 0; i < value.length; ++i) { + const item = value[i]; + if (!isRecordType(item)) { + continue; + } + + const itemPk = !isNullOrUndefined(item.pk) ? item.pk : item.id; + const itemName = !isNullOrUndefined(item.name) ? item.name : item.username; + if (typeof itemName !== 'string' || isNullOrUndefined(itemPk)) { + continue; + } + + box.addTag(itemName, itemPk); + } + + return () => { + const choices = box?.options?.items?.length ?? 0; + if (choices < 1) { + parent.style.setProperty('display', 'none'); + } + } + } + }, + { } + ); + this.#disposables.push(() => tagbox.dispose()); + + collectors[key] = () => { + let sel = tagbox.getDataValue(); + if (!Array.isArray(sel) || sel.length < 1) { + sel = null; + } + + return sel; + }; + }; + + } else { + // Single select FK + let fkValue; + if (typeof value === 'number') { + fkValue = value; + } else if (isRecordType(value)) { + fkValue = value.id ?? value.pk; + } else { + fkValue = null; + } + + renderTemplate = templates.form.ForeignKeyField; + renderParameters = { + cls: style.class ?? '', + key: key, + help: helpText, + title: label, + required: isRequired ? 'required="true"' : '', + }; + renderCallback = (elems) => { + const select = elems[0].querySelector('select'); + if (!select) { + return; + } + + let index = 0; + let selectedIndex; + if (isNullOrUndefined(fkValue)) { + selectedIndex = index; + index++; + + createElement('option', { + innerText: '-----', + attributes: { + value: '-1', + disabled: 'true', + selected: 'true', + hidden: 'true', + }, + parent: select, + }); + } + + const options = field.options; + for (let i = 0; i < options.length; ++i) { + const option = options[i]; + createElement('option', { + innerText: option.name, + attributes: { + value: option.pk, + selected: option.pk === fkValue, + }, + parent: select, + }); + + if (option.pk === fkValue) { + selectedIndex = index; + } + index++; + } + + if (!isNullOrUndefined(selectedIndex)) { + select.selectedIndex = selectedIndex; + } + + collectors[key] = () => { + let sel = select.selectedIndex; + if (isNullOrUndefined(sel) || isNaN(sel) || sel < 0) { + return null; + } + + sel = select.options[sel]; + if (isNullOrUndefined(sel) || isNullOrUndefined(sel.value)) { + return null; + } + + sel = Number(sel.value) + return sel >= 0 ? sel : null; + }; + }; + } + } break; + + // BooleanField(s) + case 'BooleanField': { + if (typeof value === 'string') { + value = value.lower() === 'true'; + } else if (typeof value === 'boolean') { + value = value; + } + + renderTemplate = templates.form.BooleanField; + renderParameters = { + cls: style.class ?? '', + key: key, + title: label, + help: helpText, + required: isRequired ? 'required="true"' : '', + }; + renderCallback = (elems) => { + const chk = elems[0].querySelector('[data-class="checkbox"]'); + chk.checked = !!value; + + collectors[key] = () => { + return !!chk.checked; + }; + }; + } break; + + // Numeric + case 'FloatField': + case 'DecimalField': + case 'IntegerField': + case 'DurationField': + const rounding = formset.rounding; + const maxDigits = formset.max_digits; + const decimalPlaces = formset.decimal_places; + + let inputMode; + if (fieldType === 'IntegerField') { + inputMode = 'numeric'; + } else { + inputMode = 'decimal'; + } + + const minValue = formset.min_value ?? ''; + const maxValue = formset.max_value ?? ''; + + renderTemplate = templates.form.NumericField; + renderParameters = { + cls: style.class ?? '', + key: key, + title: label, + help: helpText, + value: value ?? '', + inputmode: inputMode, + required: isRequired ? 'required="true"' : '', + placeholder: formset.initial ?? '', + minvalue: typeof minValue === 'number' ? `min="${minValue}"` : '', + maxvalue: typeof maxValue === 'number' ? `max="${maxValue}"` : '', + rounding: rounding ?? '', + maxDigits: maxDigits ?? '', + decimalPlaces: decimalPlaces ?? '', + }; + renderCallback = (elems) => { + const input = elems[0]; + collectors[key] = () => { + return Number(input.value); + }; + }; + break; + + // Choice(s) + case 'ChoiceField': + value = !isNullOrUndefined(value) ? value : (formset.default ?? formset.initial); + + renderTemplate = templates.form.ChoiceField; + renderParameters = { + cls: style.class ?? '', + key: key, + help: helpText, + title: label, + required: isRequired ? 'required="true"' : '', + }; + renderCallback = (elems) => { + const select = elems[0].querySelector('select'); + if (!select) { + return; + } + + const options = isRecordType(formset.grouped_choices) ? formset.grouped_choices : { }; + const optionValues = isRecordType(formset.choice_strings_to_values) ? formset.choice_strings_to_values : { }; + + let index = 0; + let selectedIndex; + if (isNullOrUndefined(value) && isNullOrUndefined(formset.default ?? formset.initial)) { + selectedIndex = index; + index++; + + createElement('option', { + innerText: '-----', + attributes: { + value: '-1', + disabled: 'true', + selected: 'true', + hidden: 'true', + }, + parent: select, + }); + } + + for (const optKey in options) { + const optVal = options[optKey]; + const optTrg = optionValues[optKey]; + createElement('option', { + innerText: optVal, + attributes: { + value: optKey, + selected: optTrg === value, + }, + parent: select, + }); + + if (optTrg === value) { + selectedIndex = index; + } + index++; + } + + if (!isNullOrUndefined(selectedIndex)) { + select.selectedIndex = selectedIndex; + } + + collectors[key] = () => { + let sel = select.selectedIndex; + if (isNullOrUndefined(sel) || isNaN(sel) || sel < 0) { + return null; + } + + sel = select.options[sel]; + if (isNullOrUndefined(sel) || isNullOrUndefined(sel.value)) { + return null; + } + + sel = Number(sel.value) + return sel >= 0 ? sel : null; + }; + }; + break; + + // Large Text field(s) + case 'TextField': + case 'JSONField': + const isMarkdown = fieldType !== 'JSONField' && !!style.markdown; + const minLength = formset.minLength ?? ''; + const maxLength = formset.maxLength ?? ''; + const placeholder = formset.initial ?? ''; + const spellcheck = typeof style.spellcheck === 'boolean' ? style.spellcheck : true; + + let className = isMarkdown ? 'filter-scrollbar' : 'text-area-input'; + className = `${className} ${style.class ?? ''}` + + if (fieldType === 'JSONField') { + if (Array.isArray(value) || isRecordType(value)) { + try { + value = JSON.stringify(value, null, 2); + } catch { + value = ''; + } + } else if (typeof value !== 'string' && !isNullOrUndefined(value)) { + value = value.toString(); + } else { + value = ''; + } + } + + renderTemplate = templates.form.TextAreaField; + renderParameters = { + cls: className, + key: key, + title: label, + help: helpText, + value: '', + placeholder: placeholder, + required: isRequired ? 'required="true"' : '', + minlength: typeof minLength === 'number' ? `minlength="${minLength}"` : '', + maxlength: typeof maxLength === 'number' ? `maxlength="${maxLength}"` : '', + useMarkdown: isMarkdown, + spellcheck: spellcheck, + }; + renderCallback = (elems) => { + const textarea = elems[0].querySelector('textarea'); + if (!textarea) { + return; + } + + collectors[key] = () => { + let sel; + try { + sel = textarea.value.trim(); + + if (fieldType === 'JSONField') { + sel = JSON.parse(sel); + } + } catch (e) { + console.warn(`[FormView] Failed to parse JSONField, invalid data:\n\n${e}`); + sel = null; + } + + return sel; + }; + + textarea.value = value; + }; + break; + + // String + case 'URLField': + case 'UUIDField': + case 'SlugField': + case 'CharField': + case 'EmailField': + case 'PasswordField': + case 'FilePathField': { + const pattern = (typeof style.pattern === 'string' && stringHasChars(style.pattern)) ? style.pattern : null; + const minLength = formset.minLength ?? ''; + const maxLength = formset.maxLength ?? ''; + const placeholder = formset.initial ?? ''; + + const inputAttributes = resolveInputType(key, fieldType, style); + renderTemplate = templates.form.TextField; + renderParameters = { + cls: style.class ?? '', + key: key, + title: label, + help: helpText, + value: '', + inputtype: inputAttributes.inputType, + autocomplete: inputAttributes.autocomplete, + placeholder: placeholder, + required: isRequired ? 'required="true"' : '', + minlength: typeof minLength === 'number' ? `minlength="${minLength}"` : '', + maxlength: typeof maxLength === 'number' ? `maxlength="${maxLength}"` : '', + }; + renderCallback = (elems) => { + const input = elems[0].querySelector('input'); + if (isNullOrUndefined(input)) { + return; + } + input.value = value ?? ''; + + collectors[key] = () => { + if (fieldType === 'UUIDField' && !stringHasChars(input.value)) { + return null; + } + + return input.value.trim(); + }; + + if (isNullOrUndefined(pattern) || !stringHasChars(pattern)) { + return; + } + + input.setAttribute('pattern', pattern); + } + } break; + + case 'DateField': + case 'TimeField': + case 'DateTimeField': + value = coerceDateTimeFieldValue(fieldType, value); + + const datatype = Const.CLU_DATATYPE_ATTR[fieldType] ?? 'date'; + renderTemplate = templates.form.DateTimeLikeField; + renderParameters = { + cls: style.class ?? '', + key: key, + title: label, + help: helpText, + datatype: datatype, + value: value ?? '', + required: isRequired ? 'required="true"' : '', + }; + renderCallback = (elems) => { + const input = elems[0].querySelector('input'); + if (isNullOrUndefined(input)) { + return; + } + + collectors[key] = () => { + const sel = coerceDateTimeFieldValue(fieldType, input.value, null); + return sel; + }; + + input.value = value; + } + break; + + // // MultiChoice? + // case 'MultipleChoiceField': + // break; + + // // List(s)? + // case 'ListField': + // case 'ListSerializer': + // break; + + default: + console.warn(`[FormView] Failed to render form field for '${key}' as '${fieldType}'`); + break; + } + + if (renderTemplate && renderParameters) { + composeTemplate(renderTemplate, { + params: renderParameters, + parent: dashForm, + render: (elems) => { + if (typeof renderCallback === 'function') { + renderCallback(elems); + } + } + }); + } + } + + const [btn] = composeTemplate(templates.form.button, { + params: { + id: 'confirm-btn', + icon: 'save', + role: 'button', + style: 'fit-w margin-left-auto', + title: 'Save', + }, + parent: element, + }); + + let spinner, isLocked; + const submitHnd = (e) => { + e.preventDefault(); + + if (isLocked) { + return; + } + + isLocked = true; + spinner = startLoadingSpinner(); + + let success = true; + let submissionData = { }; + try { + for (const key in collectors) { + const res = collectors[key](); + submissionData[key] = res; + } + } catch (e) { + success = false; + console.error(`[FormView] Failed to buid form with err:\n\n:${e}`); + + window.ToastFactory.push({ + type: 'warning', + message: '[E: FB01] Contact an Admin if this error persists.', + duration: 4000, + }); + } + + if (!success) { + isLocked = false; + return; + } + + const completeCallback = typeof this.#props.completeCallback === 'function' + ? this.#props.completeCallback + : null; + + this.#submitForm(submissionData) + .then(async response => { + let message, result; + if (response.ok) { + return await response.json(); + } + + const headers = response.headers; + if (headers.get('content-type').search('json')) { + try { + result = await response.json(); + if (!isRecordType(result)) { + throw new Error(`Not parseable, expected Record-like response on Res<code: ${response.status}> but got '${typeof result}'`); + } + + if (response.status === 400) { + this.#renderValidationErrors(result); + message = `[${response.status}]: Please fix the errors on the form.`; + } else if (!isNullOrUndefined(result.detail)) { + if (typeof result.detail === 'string' && stringHasChars(result.detail)) { + message = `[${response.status}]: ${result.detail}`; + } else if (Array.isArray(result.detail)) { + message = result.detail.filter(x => stringHasChars(x)).join(', '); + message = `[${response.status}]: ${message}`; + } + } else { + throw new Error(`Not parseable, expected 'detail' element on Res<code: ${response.status}>`); + } + } catch (e) { + console.warn(`[FormView] Failed to resolve response\'s json with err:\n\t-${e}\n`); + } + } + + if (isNullOrUndefined(message)) { + message = `[${response.status}] ${response.statusText}`; + } + + const err = new Error(message); + if (completeCallback) { + completeCallback('submitForm', { + ok: false, + form: this.#props, + error: err, + result: result, + }); + } + + throw err; + }) + .then(result => { + this.#renderValidationErrors(); + + if (completeCallback) { + completeCallback('submitForm', { + ok: true, + form: this.#props, + error: null, + result: result, + }); + } + }) + .catch(e => { + console.error('[ERROR]', e); + + window.ToastFactory.push({ + type: 'error', + message: e instanceof Error ? e.message : String(e), + duration: 4000, + }); + }) + .finally(() => { + isLocked = false; + spinner?.remove?.(); + }); + }; + + btn.addEventListener('click', submitHnd); + } + + #renderValidationErrors(errors) { + const dashForm = this.#layout.dashForm; + if (!dashForm) { + return; + } + + const keys = isRecordType(errors) ? [...Object.keys(errors)] : []; + if (keys.length < 1) { + const errs = dashForm.querySelectorAll('[data-ref="validation"]'); + for (let i = 0; i < errs.length; ++i) { + errs[i].remove(); + } + + return; + } + + const allowed = []; + for (let i = 0; i < keys.length; ++i) { + const key = keys[i]; + + const component = dashForm.querySelector(`[data-fieldset="${key}"]`); + const descriptor = dashForm.querySelector(`[data-fieldset="${key}"] p[data-ref="help"]`); + if (isNullOrUndefined(component)) { + continue; + } + + let errorMessages = errors[key]; + if (typeof errorMessages === 'string' && stringHasChars(errorMessages)) { + errorMessages = [errorMessages]; + } + + if (!Array.isArray(errorMessages) || errorMessages.length < 1) { + continue; + } + + let elements; + for (let j = 0; j < errorMessages.length; ++j) { + const msg = errorMessages[j]; + if (typeof msg !== 'string' || !stringHasChars(msg)) { + continue; + } + + if (!elements) { + elements = []; + } + + elements.push(createElement('p', { innerText: msg })); + } + + if (!Array.isArray(elements) || elements.length < 1) { + continue; + } + + let validation = component.querySelector(`[data-ref="validation"]`); + if (!isNullOrUndefined(validation)) { + validation.remove(); + } + + validation = createElement('div', { + data: { ref: 'validation' }, + childNodes: [ + createElement('div', { + className: 'validation__title', + childNodes: [ + '<span class="as-icon" data-icon="" aria-hidden="true"></span>', + createElement('p', { innerText: 'Error:' }), + ], + }), + ...elements + ], + }); + + if (!isNullOrUndefined(descriptor)) { + descriptor.after(validation); + } else { + descriptor.after(component.firstChild); + } + allowed.push(validation); + } + + dashForm.querySelectorAll(`[data-ref="validation"]`) + .forEach(x => { + if (!allowed.includes(x)) { + x.remove(); + } + }); + } + + #renderComponent(field) { + const dashForm = this.#layout.dashForm; + const templates = this.#templates; + const { component: componentName } = field.type; + + let component; + switch (componentName) { + case 'OrgAuthoritySelector': + component = new OrgAuthoritySelector({ + field: field, + value: field.value, + options: field.options, + element: dashForm, + templates: templates, + }); + break; + + case 'OrgMemberSelector': + component = new OrgMemberSelector({ + field: field, + value: field.value, + options: field.options, + element: dashForm, + templates: templates, + }); + break; + + default: + break; + } + + if (!!component && typeof component?.dispose === 'function') { + this.#disposables.push(() => component.dispose()); + } + + return component; + } + + + /************************************* + * * + * Events * + * * + *************************************/ + + + + /************************************* + * * + * Private * + * * + *************************************/ + #fetch(url, opts = {}) { + const token = this.#props.state.token; + opts = mergeObjects( + isObjectType(opts) ? opts : {}, + { + method: 'GET', + cache: 'no-cache', + credentials: 'same-origin', + withCredentials: true, + headers: { + 'Accept': 'application/json', + 'X-CSRFToken': token, + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + }, + false, + true + ); + + return fetch(url, opts); + } + + #submitForm(formData) { + const props = this.#props; + return new Promise((resolve, reject) => { + const url = props.url; + const formType = props.type; + + let data; + try { + data = JSON.stringify(formData); + } catch (e) { + reject(new Error('[E: FB02] Contact an Admin if this error persists.')); + return; + } + + this.#fetch(url, { + method: formType === 'create' ? 'POST' : 'PUT', + body: data, + }) + .then(resolve) + .catch(reject); + }) + } + + + /************************************* + * * + * Initialiser * + * * + *************************************/ + #initialise(opts) { + if (!stringHasChars(opts.url)) { + throw new Error('InitError: Failed to resolve FormView target URL'); + } + + let element = opts.element; + delete opts.element; + + if (typeof element === 'string') { + element = document.querySelector(element); + } + + if (!isHtmlObject(element)) { + throw new Error('InitError: Failed to resolve FormView element'); + } + + let templates = opts.templates; + if (isRecordType(templates)) { + this.#templates = templates; + delete opts.templates; + } else { + let elem, view, group, name; + const tmpl = document.querySelectorAll('template[data-for="dashboard"]'); + for (let i = 0; i < tmpl.length; ++i) { + elem = tmpl[i]; + name = elem.getAttribute('data-name'); + view = elem.getAttribute('data-view'); + if (!stringHasChars(view)) { + view = 'base'; + } + + group = this.#templates?.[view]; + if (!group) { + group = { }; + this.#templates[view] = group; + } + + group[name] = elem; + } + + templates = this.#templates; + } + + this.#props = opts; + this.element = element; + + this.#initEvents(); + this.#render(); + } + + #initEvents() { + + } +}; diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/services/dashboardService/views/tableView.js b/CodeListLibrary_project/cll/static/js/clinicalcode/services/dashboardService/views/tableView.js new file mode 100644 index 000000000..6a473c683 --- /dev/null +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/services/dashboardService/views/tableView.js @@ -0,0 +1,478 @@ +import * as Const from '../constants.js'; + +/** + * Class to dynamically render data model tables + * + * @class + * @constructor + */ +export class TableView { + /** + * @desc default constructor props + * @type {Record<string, any>} + * @static + * @constant + * + * @property {string} url Table query URL + * @property {object} [state={}] Current table state; defaults to empty state; defaults to an empty object + * @property {string|HTMLElement} [element='#tbl'] The table view root element container; defaults to `#tbl` + * @property {Record<string, Record<string, HTMLElement>} [templates=null] Optionally specify the templates to be rendered (will collect from page otherwise); defaults to `null` + * @property {(ref, trg) => void} [displayCallback=null] Optionally specify the display callback (used to open view panel); defaults to `null` + */ + static #DefaultOpts = { + url: null, + state: {}, + element: null, + templates: null, + displayCallback: null, + }; + + /** + * @desc + * @type {HTMLElement} + * @public + */ + element = null; + + /** + * @desc + * @type {object} + * @private + */ + #props = { }; + + /** + * @desc + * @type {object} + * @private + */ + #queryState = { + search: '', + page: 1, + }; + + /** + * @desc + * @type {Record<string, Record<string, HTMLElement>} + * @private + */ + #templates = { }; + + /** + * @desc + * @type {Record<string, HTMLElement>} + * @private + */ + #layout = { }; + + /** + * @desc + * @type {Array<Function>} + * @private + */ + #disposables = []; + + /** + * @param {Record<string, any>} [opts] constructor arguments; see {@link TableView.#DefaultOpts} + */ + constructor(opts) { + opts = isRecordType(opts) ? opts : { }; + opts = mergeObjects(opts, TableView.#DefaultOpts, true); + + this.#initialise(opts); + } + + + /************************************* + * * + * Public * + * * + *************************************/ + dispose() { + let disposable; + for (let i = this.#disposables.length; i > 0; i--) { + disposable = this.#disposables.pop(); + if (typeof disposable !== 'function') { + continue; + } + + disposable(); + } + } + + + /************************************* + * * + * Renderables * + * * + *************************************/ + #clear() { + const layout = this.#layout; + layout.head.innerHTML = ''; + layout.body.innerHTML = ''; + layout.footer.innerHTML = ''; + } + + #render() { + const url = this.#props.url; + const layout = this.#layout; + const element = this.element; + const templates = this.#templates; + + let parameters = this.#queryState; + if (!!parameters && parameters instanceof URLSearchParams) { + parameters = '?' + parameters; + } else if (isObjectType(parameters)) { + parameters = '?' + new URLSearchParams(parameters); + } else if (typeof parameters !== 'string') { + parameters = ''; + } + + let spinners; + let spinnerTimeout = setTimeout(() => { + spinners = { + load: startLoadingSpinner(element, true), + }; + }, 200); + + this.#fetch(url + parameters) + .then(res => res.json()) + .then(res => { + const { detail, renderable, results } = res; + const { form, fields } = renderable; + this.#clear(); + + let headContent = []; + for (let i = 0; i < fields.length; ++i) { + const field = fields[i]; + const column = form[field]; + headContent.push(createElement('td', { text: column.label ?? field })); + } + + headContent = createElement('tr', { + childNodes: headContent, + parent: layout.head, + }); + + const bodyContent = []; + for (let i = 0; i < results.length; ++i) { + const row = results[i]; + const items = fields.map((k, j) => { + const field = fields?.[j]; + const column = form?.[field]; + const strDisplay = column?.str_display; + + let trg = row[k]; + if (typeof strDisplay === 'string') { + if (isRecordType(trg)) { + trg = trg[strDisplay] ?? trg; + } else if (Array.isArray(trg)) { + trg = trg.map(x => isRecordType(x) ? (x[strDisplay] ?? x) : x).join(', '); + } + } else if (Array.isArray(trg)) { + trg = trg.join(', '); + } + + if (j === 0) { + return createElement('td', { + childNodes: createElement('a', { + text: trg, + attributes: { + 'role': 'button', + 'target': '_blank', + 'tabindex': '0', + 'aria-label': 'View Item', + 'data-for': 'display', + 'data-ref': k, + 'data-trg': trg, + 'data-controller': 'filter', + } + }) + }); + } + + return createElement('td', { text: trg }); + }); + + bodyContent.push(createElement('tr', { + childNodes: items, + parent: layout.body, + })); + } + + // Init pagination + const pageControls = []; + const pageRangeLen = detail.pages.length; + if (pageRangeLen > 0) { + for (let i = 0; i < pageRangeLen; ++i) { + let item = detail.pages[i]; + if (typeof item === 'number') { + pageControls.push(interpolateString(templates.pages.button.innerHTML.trim(), { + page: item, + cls: detail.page === item ? 'is-active' : '', + })); + } else if (item === 'divider') { + pageControls.push(templates.pages.divider.innerHTML.trim()); + } + } + } else { + pageControls.push(templates.pages.empty.innerHTML.trim()); + } + + composeTemplate(templates.pages.controls, { + params: { + page: detail.page.toLocaleString(), + totalPages: detail.total_pages.toLocaleString(), + hasNext: detail.has_next, + hasPrevious: detail.has_previous, + startIdx: ((detail.page - 1)*detail.page_size + 1).toLocaleString(), + endIdx: Math.min(detail.page*detail.page_size + 1, detail.max_results).toLocaleString(), + rowCount: detail.max_results.toLocaleString(), + content: pageControls.join('\n'), + }, + parent: layout.footer, + }); + }) + .catch(e => { + console.error(`[TableView] Failed to load form:\n\n- Props: ${this.#props}- with err: ${e}\n`); + + window.ToastFactory.push({ + type: 'warning', + message: 'Failed to load view, please try again.', + duration: 4000, + }); + }) + .finally(() => { + if (!spinners) { + clearTimeout(spinnerTimeout); + } + spinners?.load?.remove?.(); + }); + } + + + /************************************* + * * + * Events * + * * + *************************************/ + #searchHandle(e) { + const searchBox = this.#layout.searchBox; + const queryState = this.#queryState; + + const field = searchBox.getAttribute('data-field'); + if (!stringHasChars(field)) { + return; + } + + let value = searchBox.value; + if (!stringHasChars(value)) { + value = null; + } + + const prevValue = queryState.hasOwnProperty(field) ? queryState[field] : null; + if (prevValue === value) { + return; + } + + if (e.type === 'keyup') { + const code = e.code; + if (code !== 'Enter') { + return; + } + } + + if (value === null || typeof value === 'undefined') { + delete queryState[field]; + } else { + queryState[field] = value; + } + window.scrollTo({ top: 0, left: 0, behavior: 'smooth' }); + this.#render(); + } + + #pageHandle(e) { + const queryState = this.#queryState; + + const evTarget = e.target; + if (!evTarget.matches('[data-field="page"]:not(.disabled):not(:disabled):not([disabled="true"])')) { + return; + } + + const field = evTarget.getAttribute('data-field'); + if (field !== 'page') { + return; + } + + e.stopPropagation(); + e.preventDefault(); + + let value = evTarget.getAttribute('data-value'); + if (value === Const.CLU_DASH_TARGETS.NEXT || value === Const.CLU_DASH_TARGETS.PREVIOUS) { + const offset = value === Const.CLU_DASH_TARGETS.NEXT ? 1 : -1; + const current = queryState.hasOwnProperty(field) ? queryState[field] : 1; + value = current + offset; + } + + value = parseInt(value); + if (isNaN(value)) { + return; + } + + queryState[field] = value; + window.scrollTo({ top: 0, left: 0, behavior: 'smooth' }); + this.#render(); + } + + #displayHandle(e) { + const evTarget = e.target; + if (!evTarget.matches('a[data-controller="filter"][data-for="display"]:not(:disabled):not(.disabled)')) { + return; + } + + const evRef = evTarget.getAttribute('data-ref'); + const evTrg = evTarget.getAttribute('data-trg'); + if (!stringHasChars(evRef) || !stringHasChars(evTrg)) { + return; + } + + e.stopPropagation(); + e.preventDefault(); + + const callback = this.#props.displayCallback; + if (typeof callback !== 'function') { + return; + } + + callback(evRef, evTrg); + } + + + /************************************* + * * + * Private * + * * + *************************************/ + #fetch(url, opts = {}) { + const token = this.#props.state.token; + opts = mergeObjects( + isObjectType(opts) ? opts : {}, + { + method: 'GET', + credentials: 'same-origin', + withCredentials: true, + headers: { + 'Accept': 'application/json', + 'X-CSRFToken': token, + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + }, + false, + true + ); + + return fetch(url, opts); + } + + + /************************************* + * * + * Initialiser * + * * + *************************************/ + #initialise(opts) { + if (!stringHasChars(opts.url)) { + throw new Error('InitError: Failed to resolve TableView target URL'); + } + + let element = opts.element; + delete opts.element; + + if (typeof element === 'string') { + element = document.querySelector(element); + } + + if (!isHtmlObject(element)) { + throw new Error('InitError: Failed to resolve TableView element'); + } + + let templates = opts.templates; + if (isRecordType(templates)) { + this.#templates = templates; + delete opts.templates; + } else { + let elem, view, group, name; + const tmpl = document.querySelectorAll('template[data-for="dashboard"]'); + for (let i = 0; i < tmpl.length; ++i) { + elem = tmpl[i]; + name = elem.getAttribute('data-name'); + view = elem.getAttribute('data-view'); + if (!stringHasChars(view)) { + view = 'base'; + } + + group = this.#templates?.[view]; + if (!group) { + group = { }; + this.#templates[view] = group; + } + + group[name] = elem; + } + + templates = this.#templates; + } + + this.#props = opts; + this.element = element; + + const layout = this.#layout; + const queryState = this.#queryState; + composeTemplate(templates.base.table, { + params: { + query: queryState.search ?? '', + }, + parent: element, + render: (elems) => { + const [ searchContainer, table ] = elems; + + const head = table.querySelector('thead'); + const body = table.querySelector('tbody'); + const footer = table.querySelector('footer'); + layout.head = head; + layout.body = body; + layout.table = table; + layout.footer = footer; + + const searchBox = searchContainer.querySelector('#searchbar'); + const searchBtn = searchContainer.querySelector('#searchbar-icon-btn'); + layout.searchBox = searchBox; + layout.searchBtn = searchBtn; + layout.searchContainer = searchContainer; + + this.#initEvents(); + this.#render(); + }, + }); + } + + #initEvents() { + const pageHnd = this.#pageHandle.bind(this); + const searchHnd = this.#searchHandle.bind(this); + const displayHnd = this.#displayHandle.bind(this); + + const { body, footer, searchBox, searchBtn } = this.#layout; + body.addEventListener('click', displayHnd); + footer.addEventListener('click', pageHnd); + searchBox.addEventListener('keyup', searchHnd); + searchBtn.addEventListener('click', searchHnd); + + this.#disposables.push(() => { + body.removeEventListener('click', displayHnd); + footer.removeEventListener('click', pageHnd); + searchBox.removeEventListener('keyup', searchHnd); + searchBtn.removeEventListener('click', searchHnd); + }); + } +}; diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/services/filterService.js b/CodeListLibrary_project/cll/static/js/clinicalcode/services/filterService.js index e820ee2e9..289f8e50a 100644 --- a/CodeListLibrary_project/cll/static/js/clinicalcode/services/filterService.js +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/services/filterService.js @@ -11,15 +11,6 @@ const FILTER_SCROLL_TOP_ON_PAGE_CHANGE = true; */ const FILTER_DATEPICKER_FORMAT = 'YYYY-MM-DD'; -/** - * FILTER_KEYCODES - * @desc Describes keycodes for filter related events - * - */ -const FILTER_KEYCODES = { - ENTER: 13, -}; - /** * FILTER_PAGINATION * @desc Describes non-numerical data-value targets for pagination buttons, @@ -273,7 +264,7 @@ class FilterService { */ #postQuery() { let query = this.#cleanQuery(); - query = mergeObjects(query, {'search_filtered': true}); + query = mergeObjects(query, { 'search_filtered': true }); const parameters = new URLSearchParams(query); fetch( @@ -549,8 +540,8 @@ class FilterService { * @param {event} e the associated event */ #handleSearchbarUpdate(e) { - const code = e.keyIdentifier || e.which || e.keyCode; - if (code != FILTER_KEYCODES.ENTER) { + const code = e.code; + if (code !== 'Enter') { return; } @@ -654,9 +645,10 @@ class FilterService { * @param {string} html the html to render to the page */ #renderResponse(html) { - const parser = new DOMParser(); - const response = parser.parseFromString(html, 'text/html'); + const template = document.createElement('template'); + template.innerHTML = html; + const response = template.content; const resultsHeader = response.querySelector(FILTER_RESPONSE_CONTENT_IDS.HEADER); if (!isNullOrUndefined(resultsHeader)) { const header = document.querySelector(FILTER_RESPONSE_CONTENT_IDS.HEADER); diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/services/organisationService/invite.js b/CodeListLibrary_project/cll/static/js/clinicalcode/services/organisationService/invite.js new file mode 100644 index 000000000..271e44170 --- /dev/null +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/services/organisationService/invite.js @@ -0,0 +1,39 @@ +const handleResponse = (token, invitation_reponse, redirect) => { + fetch(getCurrentURL(), { + method: 'POST', + cache: 'no-cache', + credentials: 'same-origin', + withCredentials: true, + headers: { + 'X-Target': 'invitation_reponse', + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRFToken': token, + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ + result: invitation_reponse + }) + }) + .then(response => response.json()) + .then(response => { + if (response.status) { + window.location.href = redirect; + } + }) +} + +domReady.finally(() => { + const token = getCookie('csrftoken'); + + const acceptButton = document.querySelector('#accept-btn'); + acceptButton.addEventListener( + 'click', + e => handleResponse(token, true, acceptButton.getAttribute('data-href')) + ); + + const rejectButton = document.querySelector('#reject-btn'); + rejectButton.addEventListener( + 'click', + e => handleResponse(token, false, rejectButton.getAttribute('data-href')) + ); +}); diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/services/organisationService/manage.js b/CodeListLibrary_project/cll/static/js/clinicalcode/services/organisationService/manage.js new file mode 100644 index 000000000..ee73a1b25 --- /dev/null +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/services/organisationService/manage.js @@ -0,0 +1,290 @@ +import * as orgUtils from './utils.js'; +import { Autocomplete } from '../../components/autocomplete.js'; + +const renderMembersList = (root, token, data, uid) => { + if (isNullOrUndefined(data)) { + return fetch(getCurrentURL(), { + method: 'GET', + cache: 'no-cache', + credentials: 'same-origin', + withCredentials: true, + headers: { + 'X-Target': 'get_reloaded_data', + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRFToken': token, + 'Authorization': `Bearer ${token}` + } + }) + .then(response => response.json()) + .then(response => renderMembersList(root, token, response.members, uid)) + } + + const memberList = root.querySelector('#member-role-list'); + while (memberList.firstChild) { + memberList.removeChild(memberList.firstChild); + } + + const memberMessage = root.querySelector('#no-members') + if (isNullOrUndefined(data) || data.length < 1) { + memberMessage.classList.add('show'); + } else { + memberMessage.classList.remove('show'); + } + + const templates = { }; + root.querySelectorAll('[data-owner="management"]').forEach((v, k) => { + const name = v.getAttribute('data-name'); + templates[name] = v.innerHTML; + }); + + for (let i = 0; i < data.length; i++) { + const obj = data[i]; + const [card] = composeTemplate(templates.member, { + params: { + userid: obj.user_id, + name: obj.username, + }, + parent: memberList, + render: (elems) => { + const [tr] = elems; + const roleContainer = tr.querySelector('#member-role-container'); + + let [btn] = composeTemplate(templates.dropdown, { + params: { + uid: obj.user_id, + } + }); + btn = roleContainer.appendChild(btn); + btn.disabled = (uid === obj.user_id); + + btn.addEventListener('change', (e) => { + const selected = btn.options[btn.selectedIndex]; + const targetValue = parseInt(selected.value); + + if (targetValue !== obj.role && !isNaN(targetValue)) { + const prevValue = obj.role; + + fetch(getCurrentURL(), { + method: 'POST', + cache: 'no-cache', + credentials: 'same-origin', + withCredentials: true, + headers: { + 'X-Target': 'change_user_role', + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRFToken': token, + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ + uid: obj.user_id, + oid: obj.organisation_id, + rid: targetValue + }) + }) + .then(response => response.json()) + .then(() => { + if (parseInt(selected.value) !== prevValue) { + obj.role = targetValue; + } + }) + .catch((err) => { + btn.value = prevValue; + obj.role = prevValue; + }); + } + }); + + const opt = btn.querySelector(`[value="${obj.role}"]`); + if (opt) { + opt.selected = true; + } + }, + sanitiseTemplate: false + }); + + const delete_btn = card.querySelector(`[data-target="delete"]`); + delete_btn.disabled = (uid === obj.user_id); + delete_btn.addEventListener('click', (e) => { + orgUtils.confirmationPrompt({ + title: 'Are you sure?', + content: '<p>This will remove the user from the organisation</p>', + onAccept: () => { + return fetch(getCurrentURL(), { + method: 'POST', + cache: 'no-cache', + credentials: 'same-origin', + withCredentials: true, + headers: { + 'X-Target': 'delete_member', + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRFToken': token, + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ + uid: obj.user_id, + oid: obj.organisation_id + }) + }) + .then(response => response.json()) + .then(response => renderMembersList(root, token, null, uid)) + } + }); + }); + } +} + +const renderInvitesList = (root, token, data) => { + if (isNullOrUndefined(data)) { + return fetch(getCurrentURL(), { + method: 'GET', + cache: 'no-cache', + credentials: 'same-origin', + withCredentials: true, + headers: { + 'X-Target': 'get_reloaded_data', + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRFToken': token, + 'Authorization': `Bearer ${token}` + } + }) + .then(response => response.json()) + .then(response => { + return renderInvitesList(root, token, response.invites); + }) + } + + const user_list = data.users; + const active_invites = data.active; + + const invitesList = root.querySelector('#invite-list-container'); + while (invitesList.firstChild) { + invitesList.removeChild(invitesList.firstChild); + } + + const inviteMessage = root.querySelector('#no-invites') + if (isNullOrUndefined(active_invites) || active_invites.length < 1) { + inviteMessage.classList.add('show'); + } else { + inviteMessage.classList.remove('show'); + } + + const templates = { }; + root.querySelectorAll('[data-owner="invite"]').forEach((v, k) => { + const name = v.getAttribute('data-name'); + templates[name] = v.innerHTML; + }); + + const autocomplete = new Autocomplete({ + rootNode: document.querySelector('.autocomplete-container'), + inputNode: document.querySelector('.autocomplete-input'), + resultsNode: document.querySelector('.autocomplete-results'), + searchFn: (input) => { + if (input.length < 1) { + return [] + } + + return user_list.filter( + item => item.username.toLowerCase().startsWith(input.toLowerCase()) + ).map( + item => item.username + ); + }, + shouldAutoSelect: true + }); + + document.querySelector('#invite-btn').addEventListener('click', (e) => { + e.preventDefault(); + + const input = document.querySelector('.autocomplete-input'); + const inputValue = input.value; + input.value = ''; + + let user = user_list.filter(item => item.username === inputValue); + if (isNullOrUndefined(user) || user.length <= 0) { + return; + } + user = user[0]; + + orgUtils.confirmationPrompt({ + title: 'Are you sure?', + content: '<p>This will invite the user to your organisation</p>', + onAccept: () => { + fetch(getCurrentURL(), { + method: 'POST', + cache: 'no-cache', + credentials: 'same-origin', + withCredentials: true, + headers: { + 'X-Target': 'invite_member', + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRFToken': token, + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ + ...user, + oid: data.oid + }) + }) + .then(response => response.json()) + .then(response => renderInvitesList(root, token, null)) + } + }); + }); + + for (let i = 0; i < active_invites.length; i++) { + const obj = active_invites[i]; + const [card] = composeTemplate(templates.invite, { + params: { + userid: obj.user_id, + name: obj.username, + }, + parent: invitesList, + sanitiseTemplate: false + }); + + const delete_btn = card.querySelector(`[data-target="delete"]`); + delete_btn.addEventListener('click', (e) => { + orgUtils.confirmationPrompt({ + title: 'Are you sure?', + content: '<p>This will rescind the invite to your organisation</p>', + onAccept: () => { + fetch(getCurrentURL(), { + method: 'POST', + cache: 'no-cache', + credentials: 'same-origin', + withCredentials: true, + headers: { + 'X-Target': 'cancel_invite', + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRFToken': token, + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ + uid: obj.user_id, + oid: data.oid + }) + }) + .then(response => response.json()) + .then(response => renderInvitesList(root, token, null)) + } + }); + }); + } +} + +domReady.finally(() => { + const root = document.querySelector('#root'); + const token = getCookie('csrftoken'); + + const data = root.querySelector('script[for="organisation-members"]'); + const member_data = JSON.parse( + data.innerText.trim() + ); + const user_id = parseInt(data.getAttribute('uid')); + renderMembersList(root, token, member_data, user_id); + + const invite_data = JSON.parse( + root.querySelector('script[for="organisation-invites"]').innerText.trim() + ); + renderInvitesList(root, token, invite_data); +}); diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/services/organisationService/service.js b/CodeListLibrary_project/cll/static/js/clinicalcode/services/organisationService/service.js new file mode 100644 index 000000000..9c0d8d15f --- /dev/null +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/services/organisationService/service.js @@ -0,0 +1,283 @@ +let ARCHIVE_TEMPLATE; + +const + /** + * DETAIL_URL + * @desc describes the URL(s) associated with the action button(s) + * + */ + DETAIL_URL = '/org/view/${slug}/', + /** + * COLLECTION_HEADINGS + * @desc describes the headings associated with each key's table + * + */ + COLLECTION_HEADINGS = { + OWNED_ORG_COLLECTIONS: ['index', 'ID', 'Name', 'Slug'], + MEMBER_ORG_COLLECTIONS: ['index', 'ID', 'Name', 'Slug'] + }, + /** + * COLLECTION_TABLE_LIMITS + * @desc describes the default params for each table + * + */ + COLLECTION_TABLE_LIMITS = { + PER_PAGE: 5, + PER_PAGE_SELECT: [5, 10, 20] + }, + /** + * MAX_NAME_LENGTH + * @desc describes the max length of a name field + * + */ + MAX_NAME_LENGTH = 50; + +/** + * COLLECTION_MAP + * @desc handler methods for mapping data into its respective table + * + */ +const COLLECTION_MAP = { + OWNED_ORG_COLLECTIONS: (item, index) => { + return [ + index, + item.id, + `${strictSanitiseString(item.name)}`, + item.slug + ]; + }, + MEMBER_ORG_COLLECTIONS: (item, index) => { + return [ + index, + item.id, + `${strictSanitiseString(item.name)}`, + item.slug + ]; + } +} + +/** + * renderNameAnchor + * @desc method to render the anchor associated with an element + * @param {object} data the data associated with the element + * @param {number|any} id the `id` of the element + * @param {number|any} version_id the `version_id` of the element + * @returns {string} returns the formatted render html target + * + */ +const renderNameAnchor = (pageType, key, entity) => { + const { id, name, slug } = entity; + + let text = `${strictSanitiseString(name)}`; + text = text.length > MAX_NAME_LENGTH + ? `${text.substring(0, MAX_NAME_LENGTH).trim()}...` + : text; + + const brand = getBrandedHost(); + const url = interpolateString(brand + DETAIL_URL, { + slug: slug + }); + + return ` + <a href='${url}'>${text}</a> + `; +}; + +/** + * getCollectionData + * @desc Method that retrieves all relevant <script type="application/json" /> elements with + * its data-owner attribute pointing to the entity creator. + * + * @returns {object} An object describing the data, with each key representing + * the name of the <script type="application/json" /> element + */ +const getCollectionData = () => { + const values = document.querySelectorAll('script[type="application/json"][data-owner="organisation-service"]'); + + const result = { }; + for (let i = 0; i < values.length; i++) { + const data = values[i]; + const name = data.getAttribute('name'); + const type = data.getAttribute('desc-type'); + const pageType = data.getAttribute('page-type'); + + let value = data.innerText.trim(); + if (!isNullOrUndefined(value) && !isStringEmpty(value.trim())) { + if (type == 'text/json') { + value = JSON.parse(value); + } + } + + result[name] = { + pageType: pageType, + container: data.parentNode.querySelector('.profile-collection__table-container'), + data: value || [ ] + } + } + + return result; +}; + +/** + * renderCollectionComponent + * @desc method to render the collection component + * @param {string} pageType the component page type, e.g. in the case of profile/moderation pages + * @param {string} key the component type associated with this component, e.g. collection + * @param {node} container the container node associated with this element + * @param {object} data the data associated with this element + * + */ +const renderCollectionComponent = (pageType, key, container, data) => { + if (isNullOrUndefined(data) || Object.keys(data).length == 0) { + return; + } + + const emptyCollection = container.parentNode.querySelector('#empty-collection'); + if (!isNullOrUndefined(emptyCollection)) { + emptyCollection.classList.remove('show'); + } + + const table = container.appendChild(createElement('table', { + 'id': `collection-datatable-${key}`, + 'class': 'profile-collection-table__wrapper', + })); + + const datatable = new window.simpleDatatables.DataTable(table, { + perPage: COLLECTION_TABLE_LIMITS.PER_PAGE, + perPageSelect: COLLECTION_TABLE_LIMITS.PER_PAGE_SELECT, + fixedColumns: false, + classes: { + wrapper: 'overflow-table-constraint', + container: 'datatable-container slim-scrollbar', + }, + template: (options, dom) => ` + <div class='${options.classes.top}'> + <div class='${options.classes.dropdown}'> + <label> + <select class='${options.classes.selector}'></select> ${options.labels.perPage} + </label> + </div> + <div class='${options.classes.search}'> + <input id="column-searchbar" class='${options.classes.input}' placeholder='Search...' type='search' title='${options.labels.searchTitle}'${dom.id ? ` aria-controls="${dom.id}"` : ""}> + </div> + <div class='${options.classes.container}'${options.scrollY.length ? ` style='height: ${options.scrollY}; overflow-Y: auto;'` : ""}></div> + <div class='${options.classes.bottom}'> + <div class='${options.classes.info}'></div> + <nav class='${options.classes.pagination}'></nav> + </div>`, + columns: [ + { select: 0, type: 'number', hidden: true }, + { + select: 1, + type: 'number', + render: (value, cell, rowIndex) => { + const entity = data.find(e => e.id == value); + return renderNameAnchor(pageType, key, entity); + }, + }, + { select: 2, type: 'string', hidden: true }, + { select: 3, type: 'string', hidden: true } + ], + tableRender: (_data, table, type) => { + if (type === 'print' || key !== 'content') { + return table + } + + const header = table.childNodes[0]; + header.childNodes = header.childNodes[0].childNodes.map((_th, index) => { + if (index < 4) { + return _th; + } + + return { + nodeName: 'TH', + attributes: { + 'column-index': index + 2, + 'heading': COLLECTION_HEADINGS?.[pageType][index + 2], + }, + childNodes: [ + { + nodeName: 'select', + attributes: { + 'class': 'selection-input', + 'data-js-filter': 'true', + 'data-columns': `[${index}]`, + }, + } + ] + } + }); + + return table; + }, + data: { + headings: COLLECTION_HEADINGS?.[pageType], + data: data.map((item, index) => COLLECTION_MAP?.[pageType](item, index)), + }, + }); + + const searchbar = datatable.wrapperDOM.querySelector('#column-searchbar'); + searchbar.addEventListener('change', (event) => { + event.stopPropagation(); + event.preventDefault(); + + const value = event.target.value; + if (isStringEmpty(value)) { + datatable.search('', undefined); + return; + } + datatable.search(value, [1, 2, 3, 4]); + }); + + table.querySelectorAll('[data-js-filter]').forEach(select => { + let head = select.closest('th'); + let columnIndex = head.getAttribute('column-index'); + columnIndex = parseInt(columnIndex); + + let uniqueValues = [...new Set(datatable.data.data.map(tr => tr[columnIndex].data))]; + let option = document.createElement('option'); + option.value = '-1'; + option.selected = true; + option.hidden = true; + option.textContent = head.getAttribute('heading'); + select.appendChild(option); + + uniqueValues.forEach((value) => { + const option = document.createElement('option'); + option.textContent = value; + option.value = value; + select.appendChild(option); + }); + + select.addEventListener('change', (event) => { + const selectedValue = event.target.value; + if (selectedValue) { + let params = [{term: selectedValue, columns: [columnIndex]}]; + datatable.multiSearch(params); + return; + } + + datatable.search('', undefined); + }); + }); + + datatable.columns.sort(2, 'desc'); +}; + +/** + * Main thread + * @desc initialises the component(s) once the DOM resolves + * + */ +domReady.finally(() => { + ARCHIVE_TEMPLATE = document.querySelector('#archive-form'); + + const data = getCollectionData(); + for (let [key, value] of Object.entries(data)) { + if (value.data.length < 1) { + continue; + } + + renderCollectionComponent(value.pageType, key, value.container, value.data); + } +}); diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/services/organisationService/utils.js b/CodeListLibrary_project/cll/static/js/clinicalcode/services/organisationService/utils.js new file mode 100644 index 000000000..fb444e434 --- /dev/null +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/services/organisationService/utils.js @@ -0,0 +1,32 @@ +export const confirmationPrompt = ({ + title, + content, + onAccept, + onRender=null, + beforeAccept=null, + onReject=null, + onError=null +}) => { + return ModalFactory.create({ + title: title, + content: content, + onRender: onRender, + beforeAccept: beforeAccept + }) + .then((result) => onAccept(result)) + .catch((e) => { + if (!!e && !(e instanceof ModalFactory.ModalResults)) { + if (typeof onError === 'function') { + return onError(); + } + + return console.error(e); + } + + if (e.name === ModalFactory.ButtonTypes.REJECT) { + if (typeof onReject === 'function') { + return onReject(); + } + } + }); +} diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/services/organisationService/view.js b/CodeListLibrary_project/cll/static/js/clinicalcode/services/organisationService/view.js new file mode 100644 index 000000000..14f56386d --- /dev/null +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/services/organisationService/view.js @@ -0,0 +1,394 @@ +import * as orgUtils from './utils.js'; + +let ARCHIVE_TEMPLATE; + +const + /** + * DETAIL_URL + * @desc describes the URL(s) associated with the action button(s) + * + */ + DETAIL_URL = '/${url_target}/${id}/version/${version_id}/detail/', + /** + * COLLECTION_HEADINGS + * @desc describes the headings associated with each key's table + * + */ + COLLECTION_HEADINGS = { + PUBLISHED_COLLECTIONS: ['index', 'Name', 'ID', 'Version ID', 'Updated'], + DRAFT_COLLECTIONS: ['index', 'Name', 'ID', 'Version ID', 'Updated'], + MODERATION_COLLECTIONS: ['index', 'Name', 'ID', 'Version ID', 'Updated'] + }, + /** + * COLLECTION_TABLE_LIMITS + * @desc describes the default params for each table + * + */ + COLLECTION_TABLE_LIMITS = { + PER_PAGE: 5, + PER_PAGE_SELECT: [5, 10, 20] + }, + /** + * MAX_NAME_LENGTH + * @desc describes the max length of a name field + * + */ + MAX_NAME_LENGTH = 50, + /** + * STATUSES + * @desc describes the status element name(s) + * + */ + STATUSES = ['REQUESTED', 'PENDING', 'PUBLISHED', 'REJECTED'], + /** + * PUBLISH_STATUS_TAGS + * @desc describes the attributes associated with a status + * + */ + PUBLISH_STATUS_TAGS = [ + { text: 'REQUESTED', bg_colour: 'bubble-accent', text_colour: 'accent-dark' }, + { text: 'PENDING', bg_colour: 'bubble-accent', text_colour: 'accent-dark' }, + { text: 'PUBLISHED', bg_colour: 'tertiary-accent', text_colour: 'accent-dark' }, + { text: 'REJECTED', bg_colour: 'danger-accent', text_colour: 'accent-dark' } + ]; + +/** + * COLLECTION_MAP + * @desc handler methods for mapping data into its respective table + * + */ +const COLLECTION_MAP = { + PUBLISHED_COLLECTIONS: (item, index) => { + return [ + index, + `${item.id} - ${strictSanitiseString(item.name)}`, + item.id, + item.history_id, + new Date(item.updated) + ]; + }, + DRAFT_COLLECTIONS: (item, index) => { + return [ + index, + `${item.id} - ${strictSanitiseString(item.name)}`, + item.id, + item.history_id, + new Date(item.updated) + ]; + }, + MODERATION_COLLECTIONS: (item, index) => { + let status = (isNullOrUndefined(item.publish_status) || item.publish_status < 0) ? 5 : item.publish_status; + status = STATUSES[status]; + + return [ + index, + `${item.id} - ${strictSanitiseString(item.name)}`, + item.id, + item.history_id, + new Date(item.created)//, + //status + ]; + } +} + +/** + * renderNameAnchor + * @desc method to render the anchor associated with an element + * @param {object} data the data associated with the element + * @param {number|any} id the `id` of the element + * @param {number|any} version_id the `version_id` of the element + * @returns {string} returns the formatted render html target + * + */ +const renderNameAnchor = (pageType, key, entity, mapping) => { + const { id, history_id, name, publish_status } = entity; + + let urlTarget = mapping?.phenotype_url; + if (!stringHasChars(urlTarget)) { + urlTarget = 'phenotypes'; + } + + let text = `${id} - ${strictSanitiseString(name)}`; + text = text.length > MAX_NAME_LENGTH + ? `${text.substring(0, MAX_NAME_LENGTH).trim()}...` + : text; + + const brand = getBrandedHost(); + const url = interpolateString(brand + DETAIL_URL, { + id: id, + version_id: history_id, + url_target: urlTarget, + }); + + return ` + <a href='${url}' target=_blank rel="noopener">${text}</a> + `; +}; + +/** + * getCollectionData + * @desc Method that retrieves all relevant <script type="application/json" /> elements with + * its data-owner attribute pointing to the entity creator. + * + * @returns {object} An object describing the data, with each key representing + * the name of the <script type="application/json" /> element + */ +const getCollectionData = () => { + const values = document.querySelectorAll('script[type="application/json"][data-owner="organisation-service"]'); + + const result = { }; + for (let i = 0; i < values.length; i++) { + const data = values[i]; + const name = data.getAttribute('name'); + const type = data.getAttribute('desc-type'); + const pageType = data.getAttribute('page-type'); + + let value = data.innerText.trim(); + if (!isNullOrUndefined(value) && !isStringEmpty(value.trim())) { + if (type == 'text/json') { + value = JSON.parse(value); + } + } + + result[name] = { + pageType: pageType, + container: data.parentNode.querySelector('.organisation-collection__table-container'), + data: value || [ ], + } + } + + return result; +}; + +/** + * renderStatusTag + * @desc method to render the status associated with an element + * @param {object} data the data associated with the element + * @param {boolean|any} is_deleted whether that item is considered to be deleted + * @returns {string} the html render target + * + */ +const renderStatusTag = (data) => { + let tagData = STATUSES.findIndex(e => e == data); + tagData = PUBLISH_STATUS_TAGS[tagData]; + + return ` + <div class="meta-chip meta-chip-${tagData.bg_colour} meta-chip-center-text"> + <span class="meta-chip__name meta-chip__name-text-${tagData.text_colour} meta-chip__name-bold"> + ${tagData.text} + </span> + </div> + `; +} + +/** + * renderCollectionComponent + * @desc method to render the collection component + * @param {string} pageType the component page type, e.g. in the case of profile/moderation pages + * @param {string} key the component type associated with this component, e.g. collection + * @param {node} container the container node associated with this element + * @param {object} data the data associated with this element + * + */ +const renderCollectionComponent = (pageType, key, container, data, mapping) => { + if (isNullOrUndefined(data) || Object.keys(data).length == 0) { + return; + } + + const emptyCollection = container.parentNode.querySelector('#empty-collection'); + if (!isNullOrUndefined(emptyCollection)) { + emptyCollection.classList.remove('show'); + } + + const table = container.appendChild(createElement('table', { + 'id': `collection-datatable-${key}`, + 'class': 'profile-collection-table__wrapper', + })); + + data.sort((a, b) => { + return new Date(b.updated) - new Date(a.updated); + }); + + const datatable = new window.simpleDatatables.DataTable(table, { + perPage: COLLECTION_TABLE_LIMITS.PER_PAGE, + perPageSelect: COLLECTION_TABLE_LIMITS.PER_PAGE_SELECT, + fixedColumns: false, + classes: { + wrapper: 'overflow-table-constraint', + container: 'datatable-container slim-scrollbar', + }, + template: (options, dom) => ` + <div class='${options.classes.top}'> + <div class='${options.classes.dropdown}'> + <label> + <select class='${options.classes.selector}'></select> ${options.labels.perPage} + </label> + </div> + <div class='${options.classes.search}'> + <input id="column-searchbar" class='${options.classes.input}' placeholder='Search...' type='search' title='${options.labels.searchTitle}'${dom.id ? ` aria-controls="${dom.id}"` : ""}> + </div> + <div class='${options.classes.container}'${options.scrollY.length ? ` style='height: ${options.scrollY}; overflow-Y: auto;'` : ""}></div> + <div class='${options.classes.bottom}'> + <div class='${options.classes.info}'></div> + <nav class='${options.classes.pagination}'></nav> + </div>`, + columns: [ + { select: 0, type: 'number', hidden: true }, + { + select: 1, + type: 'string', + render: (value, cell, rowIndex) => { + const [entityId, ...others] = value.match(/^\w+-?/g); + const entity = data.find(e => e.id == entityId); + return renderNameAnchor(pageType, key, entity, mapping); + } + }, + { select: 2, type: 'number', hidden: true }, + { select: 3, type: 'number' }, + { + select: 4, + type: 'date', + format: 'YYYY-MM-DD', + render: (value, cell, rowIndex) => { + return moment(value).format('YYYY-MM-DD'); + } + } + ], + tableRender: (_data, table, type) => { + if (type === 'print' || key !== 'content') { + return table + } + + const header = table.childNodes[0]; + header.childNodes = header.childNodes[0].childNodes.map((_th, index) => { + if (index < 4) { + return _th; + } + + return { + nodeName: 'TH', + attributes: { + 'column-index': index + 2, + 'heading': COLLECTION_HEADINGS?.[pageType][index + 2], + }, + childNodes: [ + { + nodeName: 'select', + attributes: { + 'class': 'selection-input', + 'data-js-filter': 'true', + 'data-columns': `[${index}]`, + }, + } + ] + } + }); + + return table; + }, + data: { + headings: COLLECTION_HEADINGS?.[pageType], + data: data.map((item, index) => COLLECTION_MAP?.[pageType](item, index)), + }, + }); + + const searchbar = datatable.wrapperDOM.querySelector('#column-searchbar'); + searchbar.addEventListener('change', (event) => { + event.stopPropagation(); + event.preventDefault(); + + const value = event.target.value; + if (isStringEmpty(value)) { + datatable.search('', undefined); + return; + } + datatable.search(value, [1, 2, 3, 4]); + }); + + table.querySelectorAll('[data-js-filter]').forEach(select => { + let head = select.closest('th'); + let columnIndex = head.getAttribute('column-index'); + columnIndex = parseInt(columnIndex); + + let uniqueValues = [...new Set(datatable.data.data.map(tr => tr[columnIndex].data))]; + let option = document.createElement('option'); + option.value = '-1'; + option.selected = true; + option.hidden = true; + option.textContent = head.getAttribute('heading'); + select.appendChild(option); + + uniqueValues.forEach((value) => { + const option = document.createElement('option'); + option.textContent = value; + option.value = value; + select.appendChild(option); + }); + + select.addEventListener('change', (event) => { + const selectedValue = event.target.value; + if (selectedValue) { + let params = [{term: selectedValue, columns: [columnIndex]}]; + datatable.multiSearch(params); + return; + } + + datatable.search('', undefined); + }); + }); + + datatable.columns.sort(2, 'desc'); +}; + +/** + * Main thread + * @desc initialises the component(s) once the DOM resolves + * + */ +domReady.finally(() => { + ARCHIVE_TEMPLATE = document.querySelector('#archive-form'); + + const data = getCollectionData(); + const mapping = data.mapping.data; + delete data.mapping; + + for (let [key, value] of Object.entries(data)) { + if (value.data.length < 1) { + continue; + } + + renderCollectionComponent(value.pageType, key, value.container, value.data, mapping); + } + + const leaveButton = document.querySelector('#leave-btn'); + if (!isNullOrUndefined(leaveButton)) { + const token = getCookie('csrftoken'); + + leaveButton.addEventListener('click', (e) => { + orgUtils.confirmationPrompt({ + title: 'Are you sure?', + content: '<p>You will lose access to this organisation and it\'s content</p>', + onAccept: () => { + return fetch(getCurrentURL(), { + method: 'POST', + cache: 'no-cache', + credentials: 'same-origin', + withCredentials: true, + headers: { + 'X-Target': 'leave_organisation', + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRFToken': token, + 'Authorization': `Bearer ${token}` + } + }) + .then(response => response.json()) + .then(response => { + if (response.status) { + window.location.reload(); + } + }) + } + }); + }); + } +}); diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/services/referenceDataService.js b/CodeListLibrary_project/cll/static/js/clinicalcode/services/referenceDataService.js index 3a12be8db..4196da80c 100644 --- a/CodeListLibrary_project/cll/static/js/clinicalcode/services/referenceDataService.js +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/services/referenceDataService.js @@ -26,7 +26,7 @@ const const RDS_REFERENCE_MAP = (item, index) => { return [ index, - item.id, + item?.id ?? item?.value, item.name ]; } @@ -55,8 +55,13 @@ const getReferenceData = () => { } } + let container = data.parentNode.querySelector('.reference-collection__table-container'); + if (isNullOrUndefined(container)) { + container = data.parentNode; + } + result[name] = { - container: data.parentNode.querySelector('.reference-collection__table-container'), + container: container, data: value || [ ] } } @@ -163,6 +168,7 @@ const renderTreeViewComponent = async (key, container, _groups) => { const tree = eleTree({ el: viewer, lazy: true, + sort: true, data: source.nodes, showCheckbox: false, highlightCurrent: true, @@ -211,7 +217,7 @@ const renderTreeViewComponent = async (key, container, _groups) => { } const nodes = item.nodes; - const model = typeof item.model === 'object' ? item.model : { }; + const model = !!item?.model && typeof item.model === 'object' ? item.model : { }; const sourceId = model?.source; const sourceLabel = model?.label; if (typeof sourceId !== 'number' || typeof sourceLabel !== 'string' || !Array.isArray(nodes)) { @@ -228,7 +234,7 @@ const renderTreeViewComponent = async (key, container, _groups) => { </button> `, true); - const elem = tabItems.appendChild(doc.body.children[0]); + const elem = tabItems.appendChild(doc[0]); elem.addEventListener('click', () => { createViewer(sourceId); }); @@ -237,6 +243,51 @@ const renderTreeViewComponent = async (key, container, _groups) => { return createViewer(selectedIndex); } +const renderTableViewComponent = (container, data) => { + const referenceTemplate = document.querySelector('[data-name="reference-data"]'); + + container.innerHTML = ""; + for (const [index, value] of Object.entries(data)) { + const [tableContainer] = composeTemplate(referenceTemplate, { + params: { + name: value.name, + description: value.description, + }, + render: (elem) => { + elem = elem.shift(); + elem = container.appendChild(elem); + + const refContainer = elem.querySelector('#ref-container'); + const table = refContainer.appendChild(createElement('table', { + 'id': `reference-datatable-${index}`, + 'class': 'reference-collection-table__wrapper', + })); + + const datatable = new window.simpleDatatables.DataTable(table, { + perPage: RDS_REFERENCE_TABLE_LIMITS.PER_PAGE, + perPageSelect: RDS_REFERENCE_TABLE_LIMITS.PER_PAGE_SELECT, + fixedColumns: true, + classes: { + wrapper: 'overflow-table-constraint', + container: 'datatable-container slim-scrollbar', + }, + columns: [ + { select: 0, type: 'number', hidden: true }, + { select: 1, type: 'number' }, + { select: 2, type: 'string' } + ], + data: { + headings: RDS_REFERENCE_HEADINGS, + data: value.options.map((item, index) => RDS_REFERENCE_MAP(item, index)), + }, + }); + + datatable.columns.sort(1, 'asc'); + }, + }); + } +} + /** * renderReferenceComponent * @desc given the data associated with the `<script type="application/json" />` elements, @@ -260,30 +311,94 @@ const renderReferenceComponent = (key, container, data) => { } // list view - const table = container.appendChild(createElement('table', { - 'id': `reference-datatable-${key}`, - 'class': 'reference-collection-table__wrapper', - })); - - const datatable = new window.simpleDatatables.DataTable(table, { - perPage: RDS_REFERENCE_TABLE_LIMITS.PER_PAGE, - perPageSelect: RDS_REFERENCE_TABLE_LIMITS.PER_PAGE_SELECT, - fixedColumns: false, - classes: { - wrapper: 'overflow-table-constraint', - }, - columns: [ - { select: 0, type: 'number', hidden: true }, - { select: 1, type: 'number' }, - { select: 2, type: 'string' } - ], - data: { - headings: RDS_REFERENCE_HEADINGS, - data: data.map((item, index) => RDS_REFERENCE_MAP(item, index)), + return renderTableViewComponent(container, data); +}; + +const getTemplateData = async ({ url, req, ctrl, params }) => { + const token = getCookie('csrftoken'); + + req = mergeObjects( + isObjectType(req) ? req : { }, + { + method: 'GET', + credentials: 'same-origin', + withCredentials: true, + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'X-CSRFToken': token, + 'Authorization': `Bearer ${token}`, + 'Pragma': 'max-age=3600', + 'Cache-Control': 'max-age=3600', + }, }, - }); + false, + true + ); - return datatable.columns.sort(1, 'asc'); + ctrl = mergeObjects( + isObjectType(ctrl) ? ctrl : { }, + { + retries: 1, + backoff: 100, + onError: (_err, retryCount, remainingTries) => { + if (retryCount > 1 && remainingTries < 1) { + window.ToastFactory.push({ + type: 'danger', + message: `We've not been able to connect to the server, please refresh the page and try again.`, + duration: 7000, + }); + } + + return true; + }, + beforeAccept: (response, _retryCount, _remainingTries) => response.ok, + } + ); + + if (isObjectType(params)) { + params = new URLSearchParams(params); + } + + if (!isNullOrUndefined(params) && !params instanceof URLSearchParams) { + params = '?' + params; + } else { + params = ''; + } + + if (!stringHasChars(url)) { + url = getCurrentURL(); + } + + const response = await fetchWithCtrl( + url + params, + req, + ctrl, + ); + + if (response instanceof Error) { + throw response; + } + + if (!response.ok) { + let msg; + try { + msg = await response.json(); + msg = stringHasChars(msg?.message) ? msg.message : String(response); + } catch { + msg = String(response); + } + + throw new Error(`Failed to retrieve Template data with Err<code: ${response.status}> and message:\n${msg}`); + } + + const results = await response.json(); + if (isNullOrUndefined(results) || !isObjectType(results) || isNullOrUndefined(results?.data)) { + const msg = `${typeof results}<data: ${isObjectType(results) ? typeof results?.data : 'null'}>`; + throw new Error('Expected response to describe an Object containing a \'data\' key but got result: ' + msg); + } + + return results; }; /** @@ -300,4 +415,29 @@ domReady.finally(() => { renderReferenceComponent(key, value.container, value.data); } + + const templateSelector = document.querySelector('#template-selector'); + templateSelector.addEventListener('change', (e) => { + const selected = e.target.value; + + getTemplateData({ + req: { + method: 'OPTIONS', + body: JSON.stringify({ + template_id: selected + }), + }, + ctrl: { + retries: 2, + } + }) + .then(result => { + renderReferenceComponent( + null, + document.querySelector('#template-fields'), + result.data + ) + }) + .catch(console.error); + }); }); diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/strings.js b/CodeListLibrary_project/cll/static/js/clinicalcode/strings.js index 72d5b5dc9..64486f584 100644 --- a/CodeListLibrary_project/cll/static/js/clinicalcode/strings.js +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/strings.js @@ -9,6 +9,10 @@ import DOMPurify from '../lib/purify.min.js'; * @return {str} The sanitised string */ window.strictSanitiseString = (dirty, opts) => { + if (typeof dirty === 'undefined' || dirty === null) { + return dirty; + } + if (isNullOrUndefined(opts)) { opts = { html: false, mathMl: false, svg: false, svgFilters: false }; } else { @@ -39,13 +43,35 @@ window.interpolateString = (str, params, noSanitise) => { let names = Object.keys(params); let values = Object.values(params); if (!noSanitise) { - names = names.map(x => DOMPurify.sanitize(x)); - values = values.map(x => DOMPurify.sanitize(x)); + names = names.map(x => typeof x === 'string' ? DOMPurify.sanitize(x) : x); + values = values.map(x => typeof x === 'string' ? DOMPurify.sanitize(x) : x); } return new Function(...names, `return \`${str}\`;`)(...values); } +/** + * pyFormat + * @desc interpolates a str in a similar fashion to python + * @note does not support operators + * + * @param {string} str the string to be formatted + * @param {Record<any, any>} params the parameter lookup + * @param {boolean} [sanitise=false] optionally specify whether to sanitise the output; defaults to `false` + * + * @returns {string} the resulting format string + */ +window.pyFormat = (str, params, sanitise = false) => { + return str.replace(/{([^{}]+)}/g, (_, param) => { + let value = param.split('.').reduce((res, x) => res[x], params); + if (sanitise && typeof value === 'string') { + value = DOMPurify.sanitize(x); + } + + return value; + }); +} + /** * parseHTMLFromString * @desc given a string of HTML, will return a parsed DOM @@ -57,10 +83,110 @@ window.interpolateString = (str, params, noSanitise) => { * @returns {DOM} the parsed html */ window.parseHTMLFromString = (str, noSanitise, ...sanitiseArgs) => { - const parser = new DOMParser(); if (!noSanitise) { str = DOMPurify.sanitize(str, ...sanitiseArgs); } - return parser.parseFromString(str, 'text/html'); + const template = document.createElement('template'); + template.innerHTML = str.trim(); + + return [...template.content.childNodes].filter(x => isHtmlObject(x)); +} + +/** + * composeTemplate + * @desc Interpolates a `<template />` element + * + * @param {HTMLElement|string} template some `<template />` element (or its `string` contents) to interpolate + * @param {object} options optionally specify how to interpolate the template + * @param {object} options.params optionally specify a key-value pair describing how to interpolate the `template.innerHTML` content + * @param {object} options.modify optionally specify a key-value pair describing a set of modifcations/alterations to be applied to the resulting output + * @param {boolean} options.sanitiseParams optionally specify whether to sanitise the interpolation parameters; defaults to `false` + * @param {boolean|Array} options.sanitiseTemplate optionally specify whether to sanitise and/or how to sanitise the resulting template string; defaults to `true` + * + * @return {HTMLElement[]} the newly interpolated output elements + */ +window.composeTemplate = (template, options) => { + options = isRecordType(options) ? options : { }; + options = mergeObjects(options, { sanitiseParams: false, sanitiseTemplate: true }, true); + + if (typeof template !== 'string') { + template = template.innerHTML.trim(); + } + + const params = options.params; + if (isRecordType(params)) { + let names = Object.keys(params); + let values = Object.values(params); + if (options.sanitiseParams) { + names = names.map(x => typeof x === 'string' ? DOMPurify.sanitize(x) : x); + values = values.map(x => typeof x === 'string' ? DOMPurify.sanitize(x) : x); + } + + template = new Function(...names, `return \`${template}\`;`)(...values); + } + + const sanitiseTemplate = options.sanitiseTemplate; + if (Array.isArray(sanitiseTemplate)) { + template = DOMPurify.sanitize(template, ...sanitiseTemplate); + } else if (!!sanitiseTemplate) { + template = DOMPurify.sanitize(template); + } + + let result = document.createElement('template'); + result.innerHTML = template.trim(); + + const parent = options.parent; + result = [...result.content.childNodes].filter(x => isHtmlObject(x)); + if (isHtmlObject(parent)) { + for (let i = 0; i < result.length; ++i) { + result[i] = parent.appendChild(result[i]); + } + } + + const render = options.render; + if (typeof render === 'function') { + const res = render(result); + if (Array.isArray(res)) { + result = res; + } + } + + const mods = options.modify; + if (!Array.isArray(mods)) { + return result; + } + + let mod, sel, obj; + for (let i = 0; i < mods.length; ++i) { + mod = mods[i]; + sel = mod.select; + if (!stringHasChars(sel)) { + continue; + } + + const apply = typeof mod.apply === 'function' ? mod.apply : null; + const parent = isHtmlObject(mod.parent) ? mod.parent : null; + for (let j = 0; j < result.length; ++j) { + obj = result[j]; + if (!obj.matches(sel)) { + continue; + } + + if (parent) { + obj = parent.appendChild(obj); + result[j] = obj; + } + + if (apply) { + const res = apply(obj, mod); + if (!!res && isHtmlObject(res)) { + obj = res; + result[j] = obj; + } + } + } + } + + return result; } diff --git a/CodeListLibrary_project/cll/static/js/clinicalcode/utils.js b/CodeListLibrary_project/cll/static/js/clinicalcode/utils.js index 45f8e611c..311217e21 100644 --- a/CodeListLibrary_project/cll/static/js/clinicalcode/utils.js +++ b/CodeListLibrary_project/cll/static/js/clinicalcode/utils.js @@ -1,3 +1,7 @@ +/** + * @module utils + */ + /* CONSTANTS */ const /** @@ -7,7 +11,15 @@ const CLU_DOMAINS = { ROOT: 'https://conceptlibrary.saildatabank.com', HDRUK: 'https://phenotypes.healthdatagateway.org', - } + }, + /** + * CLU_HOST + * @desc Domain host regex + */ + CLU_HOST = { + ROOT: /(conceptlibrary\.saildatabank)/i, + HDRUK: /(phenotypes\.healthdatagateway)|(web\-phenotypes\-hdr)/i, + }, /** * CLU_TRANSITION_METHODS * @desc defines the transition methods associated @@ -20,6 +32,11 @@ const 'OTransition': 'oTransitionEnd otransitionend', 'MozTransition': 'mozTransitionEnd', }, + /** + * CLU_EMAIL_PATTERN + * @desc Regex pattern to match emails + */ + CLU_EMAIL_PATTERN = /(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/, /** * CLU_DOI_PATTERN * @desc Regex pattern to match DOI @@ -30,8 +47,7 @@ const * @desc Regex pattern to match `[object (.*)]` classname * */ - CLU_OBJ_PATTERN = /^\[object\s(.*)\]$/; - + CLU_OBJ_PATTERN = /^\[object\s(.*)\]$/, /** * CLU_TRIAL_LINK_PATTERN * @desc Regex pattern to match urls @@ -39,17 +55,220 @@ const */ CLU_TRIAL_LINK_PATTERN = /^(https?:\/\/)([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,6}(:\d+)?(\/\S*)?$/gm, /** - * ES_REGEX_URL + * CLU_URL_PATTERN * @desc Regex pattern matching URLs * @type {RegExp} */ CLU_URL_PATTERN = new RegExp( /((https?|ftps?):\/\/[^"<\s]+)(?![^<>]*>|[^"]*?<\/a)/, 'gm' - ); + ), + /** + * CLU_CSS_IMPORTANT + * @desc important suffix for DOM styling + * @type {string} + */ + CLU_CSS_IMPORTANT = 'important!', + /** + * CLU_ORIGIN_TYPE + * @desc URL origin descriptor enum + * @readonly + * @enum {string} + */ + CLU_ORIGIN_TYPE = { + Unknown : 'Unknown', + Malformed : 'Malformed', + Empty : 'Empty', + Internal : 'Internal', + External : 'External', + }; + + +/* UTILITIES */ + +/** + * clampNumber + * @desc clamps a number within the given range + * + * @param {number} value some value to clamp + * @param {number} min lower lim of the range + * @param {number} max upper lim of the range + * + * @return {number} the resultant value clamped within the specified range + */ +const clampNumber = (value, min, max) => { + return Math.min(Math.max(value, min), max); +} +/** + * @desc tests whether `a` is approximately `b` within some threshold described by `eps` + * + * @param {number} a some number + * @param {number} b some number + * @param {number} eps epsilon - defaults to 1e-6 (see `Const.EPS`) + * + * @returns {number} a boolean reflecting its approximate equality + */ +const approximately = (a, b, eps = 1e-4) => { + return a === b || Math.abs(a - b) <= eps; +}; + +/** + * fetchWithCtrl + * @desc an async fetch with extended functionality + * + * @example + * const params = new URLSearchParams({ someQueryParams: '...' }); + * fetchwithCtrl( + * '/some/target/url/' + params, + * { method: 'GET', headers: { 'Cache-Control': 'max-age=28800' } }, + * { + * timeout: 10, + * retries: 3, + * backoff: 50, + * onRetry: (retryCount, remainingTries) => { + * console.log('Retrying request, on attempt:', retryCount); + * }, + * onError: (err, retryCount) => { + * if (!!err && err instanceof Error && err.message == '...') { + * // Ignore the error and continue retrying (if retries are available) + * return true; + * } + * + * // Raise the error + * return false; + * }, + * beforeAccept: (response, retryCount) => { + * if (!response.ok) { + * // Ignore this response & continue retrying (if retries are available) + * return false; + * } + * + * // Accept this result + * return true; + * }, + * } + * ) + * .then(res => res.json()) + * .then(console.log) + * .catch(console.error); + * + * @param {string} url the URL to fetch + * @param {RequestInit} opts the fetch request init options + * @param {object} param2 optionally specify parameters used to control the fetch request behaviour + * @param {number} [param2.retries=1] optionally specify the number of times to retry the request; defaults to `1` + * @param {number} [param2.backoff=50] optionally specify whether to the backoff factor - given a `null` value the backoff will be ignored; defaults to `50` + * @param {number} [param2.timeout] optionally specify the maximum timeout period in seconds + * @param {Function} [param2.onRetry] optionally specify a callback to perform an action on each retry attempt + * @param {Function} [param2.onError] optionally specify a predicate to examine errors between retries where returning a truthy value will continue the request retries + * @param {Function} [param2.beforeAccept] optionally specify a predicate to examine the result of the retry before it is accepted and resolved, returning a truthy value will accept the response + * + * @returns {Promise<Response>} a promise containing the request response + */ +const fetchWithCtrl = async ( + url, + opts, + { + retries = 1, + backoff = 50, + timeout = undefined, + onRetry = undefined, + onError = undefined, + beforeAccept = undefined, + } = {} +) => { + let ref = null; + let response = null; + let controller = null; + let remainingTries = null; + + const hasTimeout = typeof timeout === 'number' && timeout > 0; + const hasBackoff = typeof backoff === 'number' && backoff > 0; + retries = (typeof retries === 'number' && retries > 0) ? retries : 1; + + onRetry = typeof onRetry === 'function' ? onRetry : null; + onError = typeof onError === 'function' ? onError : null; + beforeAccept = typeof beforeAccept === 'function' ? beforeAccept : null; + + const clearAbortTimer = () => { + if (ref !== null) { + clearTimeout(ref); + } + return ref = null; + }; -/* METHODS */ + for (let i = 0; i < retries; ++i) { + remainingTries = retries - i - 1; + + if (onRetry) { + Promise + .try(onRetry, i, remainingTries) + .catch(console.error); + } + + try { + if (hasTimeout) { + controller = new AbortController(); + ref = setTimeout(_ => { controller.abort() }, timeout*1000); + } + + response = await fetch( + url, + hasTimeout + ? { ...opts, signal: controller.signal } + : opts + ); + ref = clearAbortTimer(); + + if (beforeAccept) { + const pred = await Promise.resolve(beforeAccept(response, i, remainingTries)); + if (pred || remainingTries < 1) { + return response; + } + } else { + return response; + } + } catch (err) { + ref = clearAbortTimer(); + + if (!(err instanceof DOMException)) { + if (onError) { + const pred = await Promise.resolve(onError(err, i, remainingTries)); + if (remainingTries < 1) { + if (!pred) { + throw err; + } + + return err; + } + } else { + throw err; + } + } + } + + if (hasBackoff && remainingTries > 0) { + await new Promise((resolve) => setTimeout(resolve, 2**retries*backoff)); + } + } + + if (hasTimeout && retries > 1) { + throw new Error( + `Failed request after ${retries}, none finished within the timeout period of ${timeout}s`, + { cause: 'timeout' }, + ); + } else if (hasTimeout) { + throw new Error( + `Failed request as it did not resolve within ${timeout}s`, + { cause: 'timeout' }, + ); + } + + throw new Error( + `Failed to resolve request with no known errors`, + { cause: 'unknown' }, + ); +} /** * getObjectClassName @@ -65,7 +284,7 @@ const getObjectClassName = (val) => { } try { - if (val.constructor == Object && !(typeof val === 'function')) { + if (val.constructor == Object && typeof val !== 'function') { return 'Object'; } } @@ -91,6 +310,18 @@ const isObjectType = (val) => { return className === 'Object' || className === 'Map'; } +/** + * isRecordType + * @desc Record (Object) type guard + * + * @param {*} obj some object to evaluate + * + * @returns {boolean} flagging whether this object is Record-like + */ +const isRecordType = (obj) => { + return typeof obj === 'object' && obj instanceof Object && obj.constructor === Object; +} + /** * isCloneableType * @desc determines whether a value is a structured-cloneable type @@ -126,13 +357,13 @@ const isCloneableType = (val) => { case 'Map': { return [...val.entries()] .every(element => isCloneableType(element[0]) && isCloneableType(element[1])); - }; + } case 'Array': case 'Object': { return Object.keys(val) .every(key => isCloneableType(val[key])) - }; + } } return false; @@ -141,16 +372,50 @@ const isCloneableType = (val) => { /** * cloneObject * @desc Simplistic clone of an object/array + * * @param {object|array} obj the object to clone + * * @returns {array|object} the cloned object */ const cloneObject = (obj) => { - let result = { }; - Object.keys(obj).forEach(key => { - result[key] = cloneObject(obj[key]); - }); + const className = getObjectClassName(obj); + switch (className) { + case 'Set': { + if ([...obj].every(isCloneableType)) { + return structuredClone(obj); + } + + } break; + + case 'Map': { + const cloneable =- [...obj.entries()].every(x => isCloneableType(x[0]) && isCloneableType(x[1])); + if (cloneable) { + return structuredClone(obj); + } + + } break; + + case 'Array': { + const result = []; + Object.keys(obj).forEach(key => { + result[key] = cloneObject(obj[key]); + }); + + return result; + } + + case 'Object': { + const result = {}; + Object.keys(obj).forEach(key => { + result[key] = cloneObject(obj[key]); + }); + + return result; + } - return result; + default: + return obj; + } } /** @@ -161,7 +426,7 @@ const cloneObject = (obj) => { */ const deepCopy = (obj) => { let className = getObjectClassName(obj); - if (isCloneableType(obj) && typeof structuredClone === 'function') { + if (isCloneableType(obj) && window.structuredClone && typeof structuredClone === 'function') { let result; try { result = structuredClone(obj); @@ -197,29 +462,39 @@ const deepCopy = (obj) => { * @param {object} a An object to clone that takes precedence * @param {object} b The object to clone and merge into the first object * @param {boolean} copy Whether to copy the object(s) + * @param {boolean} deepMerge Whether to deep merge objects * @returns {object} The merged object */ -const mergeObjects = (a, b, copy = false) => { +const mergeObjects = (a, b, copy = false, deepMerge = false) => { if (copy) { a = deepCopy(a); } Object.keys(b) .forEach(key => { - if (key in a) { - return; - } - - let value = b[key]; - if (copy) { - if (!isCloneableType(value)) { - a[key] = value; + if (!deepMerge) { + if (key in a) { return; } - a[key] = deepCopy(value); + const value = b[key]; + if (copy) { + a[key] = deepCopy(value); + } else { + a[key] = value; + } } else { - a[key] = value; + const v0 = a?.[key]; + const v1 = b?.[key]; + if (isObjectType(v0) && isObjectType(v1)) { + a[key] = mergeObjects(v0, v1, copy, deepMerge); + } else if (typeof v0 === 'undefined') { + if (copy) { + a[key] = deepCopy(v1); + } else { + a[key] = v1; + } + } } }); @@ -258,61 +533,674 @@ const getTransitionMethod = () => { return undefined; } +/** + * fireChangedEvent + * @desc attempts to fire the changed event for a particular DOM element + * + * @param {HTMLElement} elem + */ +const fireChangedEvent = (elem) => { + if ('createEvent' in document) { + const evt = document.createEvent('HTMLEvents'); + evt.initEvent('change', false, true); + elem.dispatchEvent(evt); + return; + } + + elem.fireEvent('onchange'); +} + +/** + * @desc attempts to parse the global listener key + * + * @param {string} key the desired global listener key name + * + * @returns {object|null} specifying the namespace, name, and type of listener; will return `null` if invalid + */ +const parseListenerKey = (key) => { + if (typeof key !== 'string' || !stringHasChars(key)) { + return null; + } + + let target = key.trim().split(':'); + if (target.length === 2) { + key = target[1]; + target = target[0].split('.'); + return { + name: target?.[1] ?? key, + type: key, + namespace: target[0], + }; + } + + return { + name: target[0], + type: target[0], + namespace: '__base', + }; +} + +/** + * @param {string|null} namespace optionally specify the global namespace listener key + * + * @returns {object} specifying the global listener event(s) + */ +const getGlobalListeners = (namespace = null) => { + let listeners = window.hasOwnProperty('__globalListeners') ? window.__globalListeners : null; + if (!isRecordType(listeners)) { + listeners = { }; + window.__globalListeners = listeners; + } + + if (typeof namespace === 'string' && stringHasChars(namespace)) { + let group = listeners?.[namespace]; + if (!group) { + group = { }; + listeners[namespace] = group; + } + + return group; + } + + return listeners; +} + +/** + * @param {string|object} key either (a) the event key; or (b) a parsed event key per `parseListenerKey()` + * + * @returns {boolean} specifying whether a listener exists at the given key + */ +const hasGlobalListener = (key) => { + let listeners = window.hasOwnProperty('__globalListeners') ? window.__globalListeners : null; + if (!isRecordType(listeners)) { + return false; + } + + if (!isRecordType(key)) { + key = parseListenerKey(key); + } + + if (!key) { + return false; + } + + return !!(listeners?.[key.namespace]?.[key.name]); +} + +/** + * @desc utility method to listen to an event relating to a set of elements matched by the given CSS selector + * + * @param {string|object} key either (a) the event key; or (b) a parsed event key per `parseListenerKey()` + * @param {string} selector a CSS selector to compare against the event target + * @param {Function} callback a callback function to call against each related event target + * @param {object} opts optionally specify the event listener options; defaults to `undefined` + * @param {HTMLElement|null} parent optionally specify the parent element context; defaults to `document` otherwise + * + * @returns {Function} a disposable to cleanup this listener + */ +const createGlobalListener = (key, selector, callback, opts = undefined, parent = document) => { + if (!stringHasChars(selector) || !isHtmlObject(parent)) { + return null; + } + + if (!isRecordType(key)) { + key = parseListenerKey(key); + } + + if (!key) { + return null; + } + + let hnd; + const listeners = getGlobalListeners(key.namespace); + + const handler = (e) => { + const target = e.target; + if (!target || !target.matches(selector)) { + return; + } + + callback(e); + }; + + const dispose = () => { + const prev = listeners?.[key.name]; + if (!!hnd && prev === hnd) { + delete listeners[key.name]; + } + + parent.removeEventListener(key.type, handler, opts); + }; + + hnd = { ...key, dispose }; + listeners[key.name] = hnd; + + parent.addEventListener(key.type, handler, opts); + return dispose; +} + +/** + * @param {string|object} key either (a) the event key; or (b) a parsed event key per `parseListenerKey()` + * + * @returns {boolean} specifying whether a listener was disposed at the given key + */ +const removeGlobalListener = (key) => { + if (!isRecordType(key)) { + key = parseListenerKey(key); + } + + if (!key) { + return false; + } + + const listeners = getGlobalListeners(key.namespace); + const listenerHnd = listeners?.[key.name]; + if (!listenerHnd) { + return false; + } + + listenerHnd.dispose(); + return true; +} + /** * createElement - * @desc Creates an element - * @param {string} tag The node tag e.g. div - * @param {object} attributes The object's attributes + * @desc Creates a DOM element + * + * @note + * If the `behaviour` property is specified as sanitisation behaviour you should note that it expects _three_ key-value pairs, such that: + * 1. `key` - sanitisation behaviour opts for the key component + * 2. `value` - sanitisation behaviour opts for the attribute value component + * 3. `html` - specifies how to sanitise HTML string children + * + * @param {string} tag The node tag e.g. div + * @param {object} attributes The object's attributes + * @param {object|boolean} behaviour Optionally specify the sanitisation behaviour of attributes; supplying a `true` boolean will enable strict sanitisation + * @param {...*} children Optionally specify the children to be appended to this element + * * @returns {node} The created element */ -const createElement = (tag, attributes) => { +const createElement = (tag, attributes = null, behaviour = null, ...children) => { + if (!!behaviour && typeof behaviour === 'boolean') { + behaviour = { key: { }, value: { }, html: { USE_PROFILES: { html: true, mathMl: false, svg: true, svgFilters: false } } }; + } + + let udfSanHtml, ustrSanitise; + if (!isRecordType(behaviour)) { + behaviour = null; + udfSanHtml = { USE_PROFILES: { html: true, mathMl: false, svg: true, svgFilters: false } }; + ustrSanitise = (_type, value) => value; + } else { + udfSanHtml = isRecordType(behaviour.udfSanHtml) ? behaviour.udfSanHtml : null; + ustrSanitise = (type, value) => { + const opts = behaviour?.[type]; + if (opts) { + return strictSanitiseString(value, opts); + } + + return value; + }; + } + let element = document.createElement(tag); - if (attributes !== null) { - for (var name in attributes) { - if (element[name] !== undefined) { - element[name] = attributes[name]; - } else { - element.setAttribute(name, attributes[name]); + if (isRecordType(attributes)) { + let attr, name; + name = Object.keys(attributes).find(x => typeof x === 'string' && !!x.match(/\b(html|innerhtml)/i)); + attr = !!name ? attributes[name] : null; + if (name && attr) { + let res; + if (typeof attr === 'string') { + res = parseHTMLFromString(attr.trim(), !udfSanHtml, udfSanHtml); + } else if (isRecordType(attr)) { + const src = attr.src; + if (typeof src === 'string') { + const ignore = !!attr.noSanitise; + const params = Array.isArray(attr.sanitiseArgs) ? attr.sanitiseArgs : []; + res = parseHTMLFromString.apply(null, [src.trim(), ignore, ...params]); + } + } + + if (Array.isArray(res)) { + for (let i = 0; i < res.length; ++i) { + if (!isHtmlObject(res[i])) { + continue; + } + + element.appendChild(res[i]); + } + } + } + + for (const keyName in attributes) { + attr = attributes[keyName]; + name = ustrSanitise('key', keyName); + switch (keyName.toLowerCase()) { + case 'class': + case 'classname': + case 'classlist': { + if (Array.isArray(attr)) { + for (let i = 0; i < attr.length; ++i) { + element.classList.add(ustrSanitise('value', attr[i])); + } + } else { + element.className = ustrSanitise('value', attr); + } + } break; + + case 'aria': { + for (const key in attr) { + if (typeof key !== 'string' && typeof key !== 'number') { + console.error(`[createElement->${name}] Failed to append 'aria-*' attr, expected key as String|Number but got ${typeof key}`); + continue; + } + + let dataKey = String(key).trim().toLowerCase(); + if (!dataKey.startsWith('aria-')) { + dataKey = `aria-${dataKey}`; + } + element.setAttribute(dataKey, ustrSanitise('value', attr[key])) + } + } break; + + case 'data': + case 'dataset': { + for (const key in attr) { + if (typeof key !== 'string' && typeof key !== 'number') { + console.error(`[createElement->${name}] Failed to append 'data-*' attr, expected key as String|Number but got ${typeof key}`); + continue; + } + + let dataKey = String(key).trim().toLowerCase(); + if (dataKey.startsWith('data-')) { + dataKey = dataKey.replace(/^(data\-)/i, ''); + } + + if (dataKey.length < 1) { + console.error(`[createElement->${name}] Failed to append a 'data-*' attr of Key<from: '${key}', to: '${dataKey}'>, expected transformed key to have length of >= 1`); + continue; + } + + if (dataKey !== key) { + console.warn(`[createElement->${name}] A 'data-*' attr was transformed from '${key}' to '${dataKey}'`); + } + element.dataset[dataKey] = ustrSanitise('value', attr[key]); + } + } break; + + case 'attr': + case 'attributes': { + for (const key in attr) { + if (typeof key !== 'string' && typeof key !== 'number') { + console.error(`[createElement->${name}] Failed to append an attribute, expected key as String|Number but got ${typeof key}`); + continue; + } + + let dataKey = String(key).trim().toLowerCase(); + if (dataKey.length < 1) { + console.error(`[createElement->${name}] Failed to append an attribute of Key<from: '${key}', to: '${dataKey}'>, expected transformed key to have length of >= 1`); + continue; + } + + if (dataKey !== key) { + console.warn(`[createElement->${name}] An attribute was transformed from '${key}' to '${dataKey}'`); + } + element.setAttribute(dataKey, ustrSanitise('value', attr[key])); + } + } break; + + case 'text': + case 'innertext': + case 'textcontent': { + if (Array.isArray(attr)) { + for (let i = 0; i < attr.length; ++i) { + element.textContent += attr[i]; + } + } else if (isObjectType(attr) && !isNullOrUndefined(attr.text)) { + const insert = (typeof attr.insert === 'string' && !!attr.insert.match(/\b(append|prepend)/i)) + ? attr.insert.trim().toLowerCase() + : 'append'; + + if (insert === 'prepend') { + element.prepend(document.createTextNode(attr.text)); + } else { + element.textContent += attr.text; + } + } else { + element.textContent = attr; + } + } break; + + case 'style': { + let value, priority; + if (isRecordType(attr)) { + for (let key in attr) { + value = ustrSanitise('value', attr[key]); + priority = value.indexOf(CLU_CSS_IMPORTANT); + if (priority > 0) { + value = value.substring(priority, priority + CLU_CSS_IMPORTANT.length - 1); + priority = 'important'; + } else { + priority = value?.[3]; + } + element.style.setProperty(key, value, priority); + } + } else if (Array.isArray(attr) && attr.length >= 2) { + value = ustrSanitise('value', attr[0]); + priority = value.indexOf(CLU_CSS_IMPORTANT); + if (priority > 0) { + attr = value.substring(priority, priority + CLU_CSS_IMPORTANT.length - 1); + priority = 'important'; + } else { + priority = attr?.[3]; + } + + element.style.setProperty(value, attr[1], priority); + } else if (typeof attr === 'string') { + element.style.cssText = ustrSanitise('value', attr); + } + } break; + + case 'children': + case 'childnodes': { + if (Array.isArray(attr)) { + for (let i = 0; i < attr.length; ++i) { + let res = attr[i]; + if (typeof res === 'string') { + res = parseHTMLFromString(res.trim(), !udfSanHtml, udfSanHtml); + res.forEach(x => { + if (isHtmlObject(x)) { + element.appendChild(x); + } + }); + } else { + element.appendChild(res); + } + } + } else { + element.appendChild(attr); + } + } break; + + case 'parent': { + if (isObjectType(element) && typeof attr.insert === 'string' && !isNullOrUndefined(attr.element)) { + const insert = (typeof attr.insert === 'string' && !!attr.insert.match(/\b(append|prepend|insertbefore)/i)) + ? attr.insert.trim().toLowerCase() + : 'append'; + + if (insert === 'prepend') { + attr.prepend(attr.element); + } else if (insert === 'insertbefore') { + attr.element.parentElement.insertBefore(element, attr.element); + } else if (insert === 'append') { + attr.appendChild(attr.element); + } + } else { + attr.appendChild(element); + } + } break; + + case 'html': + case 'innerhtml': + break; + + default: { + if (name.startsWith('on') && typeof attr === 'function') { + element.addEventListener(name.substring(2), attr); + } else if ((!element.hasOwnProperty(name) && isNullOrUndefined(element[name])) || name.startsWith('data-')) { + element.setAttribute(name, ustrSanitise('value', attr)); + } else { + element[name] = ustrSanitise('value', attr); + } + } break; } } } + let child; + for (let i = 0; i < children.length; ++i) { + child = children[i]; + if (typeof child === 'string' || typeof child === 'number') { + child = document.createTextNode(child); + } + + if (isHtmlObject(child)) { + element.appendChild(child); + } + } + return element; } /** * isScrolledIntoView * @desc Checks whether an element is scrolled into view - * @param {node} elem The element to examine - * @param {number} offset An offset modifier (if required) + * @param {HTMLElement} elem The element to examine + * @param {HTMLElement} [container=document.body] The container element to watch + * @param {number} [offset=0] An offset modifier (if required) * @returns {boolean} that reflects the scroll view status of an element */ -const isScrolledIntoView = (elem, offset = 0) => { - const rect = elem.getBoundingClientRect(); - const elemTop = rect.top; - const elemBottom = rect.bottom - offset; +const isScrolledIntoView = (elem, container = null, offset = 0) => { + if (isNullOrUndefined(container)) { + const rect = elem.getBoundingClientRect(); + const elemTop = rect.top; + const elemBottom = rect.bottom - offset; + return (elemTop >= 0) && (elemBottom <= window.innerHeight); + } + + let eRect; + if (isHtmlObject(elem)) { + eRect = elem.getBoundingClientRect(); + } else { + eRect = elem; + } + + let pRect; + if (isHtmlObject(container) || container === document.body) { + pRect = container.getBoundingClientRect(); + } else { + pRect = container; + } + + let { height, width } = eRect; + height = height - offset > 0 ? height - offset : height; + width = width - offset > 0 ? width - offset : width; + + const topVisible = eRect.top <= pRect.top + ? pRect.top - eRect.top <= height + : eRect.bottom - pRect.bottom <= height; - return (elemTop >= 0) && (elemBottom <= window.innerHeight); + const leftVisible = eRect.left <= pRect.left + ? pRect.left - eRect.left <= width + : eRect.right - pRect.right <= width; + + return (topVisible && leftVisible); } /** * elementScrolledIntoView * @desc A promise that resolves when an element is scrolled into view - * @param {node} elem The element to examine - * @param {number} offset An offset modifier (if required) + * + * @param {HTMLElement} elem The element to examine + * @param {HTMLElement} [container=document.body] The container element to watch + * @param {number} [offset=0] An offset modifier (if required) + * * @returns {promise} a promise that resolves once the element scrolls into the view */ -const elementScrolledIntoView = (elem, offset = 0) => { +const elementScrolledIntoView = (elem, container = null, offset = 0) => { + let rel = container; + if (isNullOrUndefined(container)) { + rel = null; + container = document; + } + return new Promise(resolve => { const handler = (e) => { - if (isScrolledIntoView(elem, offset)) { - document.removeEventListener('scroll', handler); + if (isScrolledIntoView(elem, rel, offset)) { + container.removeEventListener('scroll', handler); resolve(); } }; - document.addEventListener('scroll', handler); + container.addEventListener('scroll', handler); + }); +} + +/** + * getRelativeElementPos + * @desc computes the element's relative rect + * + * @param {HTMLElement} elem the element of interest + * + * @returns {Record<string, number>} relative element rect + */ +const getRelativeElementRect = (elem) => { + const pOff = elem.parentNode.scrollTop; + const pRect = elem.parentNode.getBoundingClientRect(); + const eRect = elem.getBoundingClientRect(); + return { + top: eRect.top - pRect.top + pOff, + right: eRect.right - pRect.right, + bottom: eRect.bottom - pRect.bottom, + left: eRect.left - pRect.left, + }; +} + +/** + * scrollContainerTo + * @desc scrolls a container towards an element/target rect + * + * @param {HTMLElement} container the parent container in which to scroll + * @param {HTMLElement} element the descendant HTMLElement to scroll towards + * @param {object} [param2] optionally specify a set of props varying this operation's behaviour + * @param {'auto'|'smooth'|'instant'} [param2.behaviour='smooth'] optionally specify the `scrollTo` behaviour; defaults to `smooth` + * @param {number} [param2.threshold=5] optionally specify the approximation epsilon; defaults to `5` (i.e. 5 pixel) + * @param {number} [param2.failureTimeout=1000] optionally specify the time, in milliseconds, before we container the scroll to have failed; defaults to 1000ms/1s + * + * @returns {Promise<object>} a promise that resolves an obj describing the both (a) the `target` rect, and (b) the current `scroll` position + */ +const scrollContainerTo = ( + container, + element, + { + behaviour = 'smooth', + threshold = 5, + failureTimeout = 1000, + } = {} +) => { + let _watchdog, _handleScroll, _listening; + + const promise = new Promise((resolve) => { + const target = getRelativeElementRect(element); + behaviour = behaviour.trim().toLowerCase(); + behaviour = behaviour.match(/^(auto|smooth|instant)$/gi) ? behaviour : 'smooth'; + + threshold = (typeof threshold === 'number' && !Number.isNaN(threshold) && Number.isFinite(threshold) && threshold >= 1e-4) + ? threshold + : 1e-4; + + let { top, left } = target; + top = Math.floor(typeof target.top === 'number' ? target.top : 0); + left = Math.floor(typeof target.left === 'number' ? target.left : 0); + + const cleanup = (shouldCancel = true) => { + if (shouldCancel && !isNullOrUndefined(_watchdog)) { + clearTimeout(_watchdog); + } + _watchdog = null; + + if (_listening && _handleScroll) { + container?.removeEventListener?.('scroll', _handleScroll); + } + _listening = false; + + resolve({ + target: { top, left }, + scroll: { top: container.scrollTop, left: container.scrollLeft }, + }); + } + + if (isScrolledIntoView(container, element, threshold)) { + cleanup(); + return; + } + + if (typeof failureTimeout === 'number' && !Number.isNaN(failureTimeout) && Number.isFinite(failureTimeout) && failureTimeout > 1e-6) { + _watchdog = setTimeout(_ => cleanup(false), failureTimeout); + } + + container.scrollTo({ top, left, behavior: behaviour }); + + _handleScroll = () => { + if (!_listening || (!approximately(container.scrollTop, top, threshold) && !approximately(container.scrollLeft, left, threshold))) { + return; + } + + cleanup(); + }; + + if (approximately(container.scrollTop, top, threshold) || approximately(container.scrollLeft, left, threshold)) { + cleanup(); + return; + } + + _listening = true; + container.addEventListener('scroll', _handleScroll); }); + + return new Promise((resolve, reject) => { + promise + .then(resolve) + .catch(reject) + .finally(_ => { + if (!isNullOrUndefined(_watchdog)) { + clearTimeout(_watchdog); + } + + if (!isNullOrUndefined(_handleScroll)) { + container?.removeEventListener?.('scroll', _handleScroll); + } + }); + }); +} + +/** + * focusNextElement + * @desc attempts to tab cycle focusable elements + * + * @param {HTMLElement} [active=null] optionally specify the actively selected item if applicable; defaults to `document.activeElement` + * @param {number|string} [dir='next'] optionally specify the tab cycle direction; defaults to `next` | `+1` + * + * @returns {Nullable<HTMLElement>} the newly focused item (if applicable) + */ +const focusNextElement = (active = null, dir = 'next') => { + const element = !isNullOrUndefined(document?.activeElement?.form) ? document?.activeElement?.form : document; + active = !isNullOrUndefined(active) ? active : document?.activeElement; + + dir = typeof dir === 'number' + ? clampNumber(dir, -1, 1) + : (dir.toLowerCase() === 'previous' ? -1 : 1); + + let elements = [...element.querySelectorAll( + 'a:not([disabled]):not([aria-hidden="true"]), \ + button:not([disabled]):not([aria-hidden="true"]), \ + input[type=text]:not([disabled]):not([aria-hidden="true"]), \ + [tabindex]:not([disabled]):not([tabindex="-1"]):not([aria-hidden="true"]) \ + ')] + elements = elements.filter(x => x.offsetWidth > 0 || x.offsetHeight > 0 || x === active); + + let elm = elements.indexOf(active); + if (elm >= 0) { + elm = elm + dir < 0 ? elements.length : (elm + dir > elements.length ? 0 : elm + dir); + } else { + elm = 0; + } + elm = elements[elm]; + + if (!isNullOrUndefined(elm)) { + elm.focus(); + } + + return elm; } /** @@ -337,7 +1225,7 @@ const getCookie = (name) => { } return cookieValue; -}; +} /** * getCurrentHost @@ -389,7 +1277,7 @@ const getBrandedHost = () => { const host = getCurrentHost(); const brand = document.documentElement.getAttribute('data-brand'); const isUnbranded = isNullOrUndefined(brand) || isStringEmpty(brand) || brand === 'none'; - if ((host === CLU_DOMAINS.HDRUK) || isUnbranded) { + if (!!host.match(CLU_HOST.HDRUK) || isUnbranded) { return host; } @@ -397,16 +1285,17 @@ const getBrandedHost = () => { } /** - * getBrandUrlTarget - * @desc Returns the brand URL target for management redirect buttons (used in navigation menu) - * @param {string[]} brandTargets an array of strings containing the brand target names - * @param {boolean} productionTarget a boolean flag specifying whether this is a production target - * @param {Node} element the html event node - * @param {string} oldRoot the path root (excluding brand context) - * @param {string} path the window's location href - * @returns {string} the target URL + * navigateBrandTargetURL + * @desc Sets the browser's current URL to the brand associated with management redirect buttons + * @note used in base navigation menu + * + * @param {string[]} brandTargets an array of strings containing the brand target names + * @param {boolean} productionTarget a boolean flag specifying whether this is a production target + * @param {HTMLElement|HTMLNode} element the html event target node + * @param {string} oldRoot the path root (excluding brand context) + * @param {string} path the window's location href */ -const getBrandUrlTarget = (brandTargets, productionTarget, element, oldRoot, path) =>{ +const navigateBrandTargetURL = (brandTargets, productionTarget, element, oldRoot, path) =>{ const pathIndex = brandTargets.indexOf(oldRoot.toUpperCase()) == -1 ? 0 : 1; const pathTarget = path.split('/').slice(pathIndex).join('/'); @@ -427,10 +1316,7 @@ const getBrandUrlTarget = (brandTargets, productionTarget, element, oldRoot, pat } break; default: { - const isHDRUKSubdomain = window.location.href - .toLowerCase() - .includes('phenotypes.healthdatagateway'); - + const isHDRUKSubdomain = !!window.location.href.match(CLU_HOST.HDRUK); targetLocation = isHDRUKSubdomain ? CLU_DOMAINS.ROOT : document.location.origin; targetLocation = `${targetLocation}/${elementTarget}`; } break; @@ -482,7 +1368,7 @@ const isNullOrUndefined = (value) => typeof value === 'undefined' || value === n * @returns {boolean} determines whether the value is (a) undefined; or (b) empty * */ -const isStringEmpty = (value) => isNullOrUndefined(value) || !value.length; +const isStringEmpty = (value) => typeof value !== 'string' || !value.length; /** * isStringWhitespace @@ -491,19 +1377,149 @@ const isStringEmpty = (value) => isNullOrUndefined(value) || !value.length; * @returns {boolean} reflecting whether the string contains only whitespace * */ -const isStringWhitespace = (value) => !value.replace(/\s/g, '').length; +const isStringWhitespace = (value) => typeof value !== 'string' || !value.replace(/\s/g, '').length; + +/** + * stringHasChars + * @desc checks if a `string` has any number of characters aside from whitespace chars + * @param {string} value the value to consider + * @returns {boolean} specifying whether the `string` has chars + */ +const stringHasChars = (value) => typeof value === 'string' && value.length && value.replace(/\s/g, '').length; + +/** + * @desc det. whether a numeric value is safe + * @note + * - Determines whether the value is a number, is finite, and is within min/max numeric bounds + * - Integers are represented by `fp` values in JS, which are IEEE 754 64-bit `double`s; this means we're getting 53 bits of precision for `int` types, i.e. a min/max value of `pow(2, 53) - 1` (`int53`) + * - It follows then that Floats/Doubles have a maximum precision of 15 decimal numerals + * + * @param {number} value the value to evaluate + * + * @returns {boolean} specifies whether the value is safe + */ +const isSafeNumber = (value) => { + if (typeof value !== 'number') { + return false; + } + + return ( + !Number.isNaN(value) && + Number.isFinite(value) && + (Number.isSafeInteger(value) || Math.abs(value) < Number.MAX_VALUE) + ); +} + +/** + * @desc det. whether the specified value is within the desired range or, if provided, is approximately within such a range + * + * @param {number!} value some numeric value to evaluate + * @param {number!} min the minimum bounds of the range + * @param {number!} max the maximum bounds of the range + * @param {number?} [threshold] optionally specify a threshold in which the value should be considered to be approximately within the bounds of the specified range + * + * @returns {boolean} specifies whether the value is within the specified range + */ +const isWithinBounds = (value, min, max, threshold = null) => { + if (!isSafeNumber(min) || !isSafeNumber(max)) { + return false; + } + + if (min === max) { + return isSafeNumber(threshold) + ? approximately(value, max, threshold) + : value === max; + } + + const tmp = Math.max(min, max); + min = Math.min(min, max); + max = tmp; + + return ( + (value >= min && value <= max) || + ( + isSafeNumber(threshold) + ? ((value < min && approximately(value, min, threshold)) || (value > max && approximately(value, max, threshold))) + : false + ) + ); +} + +/** + * @desc attempts to parse a number and to derive its fmt from a string|number + * + * @param {number|string} value some number-like object to evaluate + * + * @returns {{value: number?, type: ('NaN' | 'int' | 'float')}} an Object describing the resulting value and its fmt type + */ +const tryParseNumber = (value) => { + if (stringHasChars(value)) { + value = Number(value); + } + + if (!isSafeNumber(value)) { + return { value: null, type: 'NaN' }; + } + + const remainder = value - (value | 0); + if (remainder < Number.MIN_VALUE || Math.abs(remainder - 1) < Number.MIN_VALUE) { + value = Math.trunc(value); + } + + let type; + if (Number.isInteger(value)) { + type = 'int'; + } else { + type = 'float'; + } + + return { value, type }; +} /** * clearAllChildren * @desc removes all children from a node - * @param {node} element the node to remove - * @param {fn} cond conditional to determine fate of elem + * + * @param {HTMLElement} element the node to remove + * @param {Function|string} [cond = null] optionally specify either (a) a predicate `function`, or (b) a string selector, to determine fate of element + * + * @example + * // clear all children + * clearAllChildren(document.body); + * + * // clear using predicate + * clearAllChildren(document.body, (x) => x.tagName === 'DIV'); + * + * // clear all matching selector + * clearAllChildren(document.body, 'button[data-attr="some-attr-value"]'); + * */ -const clearAllChildren = (element, cond) => { - for (const [index, child] of Object.entries(element.children)) { - if (child.nodeType == 1 && cond && cond(child)) { +const clearAllChildren = (element, cond = null) => { + let child; + if (typeof cond === 'string') { + const selector = cond; + cond = (x) => !x.matches(selector); + } + + if (!!cond && typeof cond !== 'function') { + cond = null; + console.warn(`[utils->clearAllChildren] Condition has been ignored, expected a function but got a "${typeof cond}"`); + } + + if (!cond) { + while (element.firstChild) { + element.removeChild(element.lastChild); + } + } + + const children = element.children; + for (let i = 0; i < children.length; ++i) { + child = children[i]; + + if (child.nodeType === Node.ELEMENT_NODE && cond(child)) { continue; } + element.removeChild(child); } } @@ -513,13 +1529,35 @@ const clearAllChildren = (element, cond) => { * @desc onClick handler for content cards, primarily used for ./search page - referral to detail page * @param {node} element the clicked node */ -const redirectToTarget = (elem) => { - const target = elem.getAttribute('data-target'); - if (!target) { +const redirectToTarget = (elem, event) => { + if (!elem) { return; } - - window.location.href = strictSanitiseString(target); + + let target = elem.getAttribute('data-target'); + if (!stringHasChars(target) && elem.matches('.referral-card')) { + target = elem.querySelector('.referral-card__title[href]'); + target = !!target ? target.getAttribute('href') : ''; + } + + target = typeof target === 'string' + ? strictSanitiseString(target) + : ''; + + if (!stringHasChars(target)) { + return; + } + + const metaActive = !!event && (event.ctrlKey || event.metaKey); + if (target === '__blank' || metaActive) { + let rel = elem.getAttribute('rel') + rel = typeof rel === 'string' ? rel : ''; + + window.open(target, '_blank', rel); + return true; + } + + window.location.href = target; } /** @@ -531,17 +1569,14 @@ const redirectToTarget = (elem) => { * -> @param {null, list} extensions the expected file extensions (leave null for all file types) * -> @param {null, function(selected[bool], files[list])} callback the callback function for when a file is selected * - * e.g. usage: + * @example + * const files = tryOpenFileDialogue({ extensions: ['.csv', '.tsv'], callback: (selected, files) => { + * if (!selected) { + * return; + * } * - ```js - const files = tryOpenFileDialogue({ extensions: ['.csv', '.tsv'], callback: (selected, files) => { - if (!selected) { - return; - } - - console.log(files); --> [file_1, ..., file_n] - }}); - ``` + * console.log(files); --> [file_1, ..., file_n] + * }}); * */ const tryOpenFileDialogue = ({ allowMultiple = false, extensions = null, callback = null }) => { @@ -584,6 +1619,26 @@ const transformTitleCase = (str) => { return str.replace(/\w\S*/g, (text) => text.charAt(0).toLocaleUpperCase() + text.substring(1).toLocaleLowerCase()); } +/** + * transformCamelCase + * @desc transforms a string to camelCase + * @param {string} str the string to transform + * @returns {string} the resultant, transformed string + */ +const transformCamelCase = (str) => { + return str.toLowerCase().replace(/([-_\s][a-z])/g, group => group.toUpperCase()).replace(/[-_\s]/gm, ''); +} + +/** + * transformSnakeCase + * @desc transforms a string to snake_case + * @param {string} str the string to transform + * @returns {string} the resultant, transformed string + */ +const transformSnakeCase = (str) => { + return str.toLowerCase().replace(/[^a-zA-Z0-9\s-_]/g, '').replace(/([-_\s])/g, '_'); +} + /** * tryGetRootElement * @desc Iterates through the parent of an element until it either @@ -593,18 +1648,18 @@ const transformTitleCase = (str) => { * @param {string} expectedClass the expected class name * @return {node|none} the parent element, if found */ -const tryGetRootElement = (item, expectedClass) => { +const tryGetRootElement = (item, selector) => { if (isNullOrUndefined(item)) { return null; } - if (item.classList.contains(expectedClass)) { + if (item.nodeType === Node.ELEMENT_NODE && item.matches(selector)) { return item; } - while (!isNullOrUndefined(item.parentNode) && item.parentNode.classList) { + while (!isNullOrUndefined(item?.parentNode) && item?.nodeType === Node.ELEMENT_NODE) { item = item.parentNode; - if (item.classList.contains(expectedClass)) { + if (item?.matches?.(selector)) { return item; } } @@ -655,7 +1710,7 @@ const getDeltaDiff = (lhs, rhs) => { if (diff.length > 0) { filtered.push(...diff.map(([i, ...val]) => [`${key} ${i}`, ...val])); } - + return filtered; } @@ -663,7 +1718,7 @@ const getDeltaDiff = (lhs, rhs) => { filtered.push([key, 'created', rhs[key]]); return filtered; } - + if (key in lhs && !(key in rhs)) { filtered.push([key, 'deleted', lhs[key]]); return filtered; @@ -724,7 +1779,6 @@ const waitForElement = (selector) => { }); } - /** * onElementRemoved * @desc waits for an element to be removed from the document before resolving @@ -811,23 +1865,32 @@ const hideLoader = () => { /** * startLoadingSpinner * @desc instantiate a loading spinner, either within an element or at the root <body/> - * @param {node|null} container the container - if null, uses the <body/> + * + * @param {node|null} container optionally specify the container - if null, uses the <body/> + * @param {boolean} fillContent optionally specify whether to apply the absolute fill style; defaults to `false` + * * @returns {node} the spinner element or its container - whichever is topmost */ -const startLoadingSpinner = (container) => { - +const startLoadingSpinner = (container, fillContent = false) => { let spinner; if (isNullOrUndefined(container)) { container = document.body; spinner = createElement('div', { className: 'loading-spinner', - innerHTML: '<div class="loading-spinner__icon"></div>' + childNodes: [ + createElement('div', { className: 'loading-spinner__icon' }) + ], }); - } else { + } else if (fillContent) { spinner = createElement('div', { - className: 'loading-spinner__icon', + className: 'loading-spinner loading-spinner--absolute', + childNodes: [ + createElement('div', { className: 'loading-spinner__icon' }) + ], }); + } else { + spinner = createElement('div', { className: 'loading-spinner__icon' }); } container.appendChild(spinner) @@ -898,7 +1961,6 @@ const hasFixedElementSize = (element, axes = undefined) => { return results; } - /** * isElementSizeExplicit * @desc det. whether an element's height or width is explicit @@ -1102,4 +2164,377 @@ const linkifyText = ( } return source; -}; +} + +/** + * @desc determines whether the specified URL is malformed or not + * + * @param {string|any} url some URL to evaluate + * + * @returns {boolean} specifying whether this URL is valid + */ +const isValidURL = (url) => { + if (!stringHasChars(url)) { + return false; + } + + try { + url = new URL(url); + } catch { + return false; + } + + return true; +} + +/** + * @desc determines the origin of the URL, i.e. whether it's external or internal + * @note this function may resolve a `CLU_ORIGIN_TYPE.Empty` or `CLU_ORIGIN_TYPE.Malformed` value if the URL is empty/malformed + * + * @param {string|any} url some URL to evaluate + * + * @returns {enum<string>} a `CLU_ORIGIN_TYPE` descriptor + */ +const getOriginTypeURL = (url, forceBrand = true) => { + if (!stringHasChars(url)) { + return CLU_ORIGIN_TYPE.Empty; + } + + let target, malformed; + try { + target = new URL(url); + return target.origin !== window.location.origin ? CLU_ORIGIN_TYPE.External : CLU_ORIGIN_TYPE.Internal; + } catch { + malformed = true; + } + + if (malformed) { + try { + if (forceBrand) { + target = new URL(url, getBrandedHost()); + } else { + target = new URL(url, getCurrentURL()) + } + + return target.origin !== window.location.origin ? CLU_ORIGIN_TYPE.External : CLU_ORIGIN_TYPE.Internal; + } + catch { + return CLU_ORIGIN_TYPE.Malformed; + } + } + + return CLU_ORIGIN_TYPE.Internal; +} + +/** + * @desc determines whether the given URL is absolute/relative + * @note where: + * - relative → _e.g._ `/some/path/` or `some/path`; + * - absolute → _e.g._ `https://some.website/some/path` _etc_. + * + * @param {string} url some URL to evaluate + * + * @returns {boolean} specifying whether the given URL is absolute + */ +const isAbsoluteURL = (url) => { + return /^(?:[a-z]+:)?\/\//i.test(url); +} + +/** + * @desc opens the specified link on the client + * @note attempts to replicate client content navigation via `<a/>` tags + * + * @param {HTMLElement|string} link either (a) some node specifying a `[href] | [data-link]` or (b) a `string` URL + * @param {object} param1 navigation optuions + * @param {string|null} param1.rel optionally specify the `[rel]` attribute assoc. with this link; defaults to `null` + * @param {string|null} param1.target optionally specify the `[target]` attribute assoc. with this link; defaults to `null` + * @param {boolean} param1.forceBrand optionally specify whether to ensure that the Brand target is applied to the final URL; defaults to `true` + * @param {boolean} param1.followEmpty optionally specify whether empty links (i.e. towards index page) can be followed; defaults to `true` + * @param {boolean} param1.allowNewTab optionally specify whether `target=__blank` behaviour is allowed; defaults to `true` + * @param {boolean} param1.metaKeyDown optionally specify whether to navigate as if the meta key is held (_i.e._ ctrl + click); defaults to `false` + * @param {Event|null} param1.relatedEvent optionally specify some DOM `Event` relating to this method (used to derive `ctrlKey` | `metaKey`); defaults to `null` + * + * @returns + */ +const tryNavigateLink = (link, { + rel = null, + target = null, + forceBrand = true, + followEmpty = true, + allowNewTab = true, + metaKeyDown = false, + relatedEvent = null, +} = {}) => { + let url; + if (isHtmlObject(link)) { + url = link.getAttribute('href'); + if (typeof url === 'string') { + rel = rel ?? link.getAttribute('rel'); + target = target ?? link.getAttribute('target'); + } else { + url = link.getAttribute('data-link'); + if (url) { + rel = rel ?? link.getAttribute('data-linkrel'); + target = target ?? link.getAttribute('data-linktarget'); + } + } + } else if (typeof link === 'string') { + url = link; + } + + if (typeof url !== 'string') { + return false; + } + + const originType = getOriginTypeURL(url, forceBrand); + if (originType === 'Malformed' || (originType === 'Empty' && !followEmpty)) { + return false; + } + + if (forceBrand && (originType === 'Internal' || originType === 'Empty')) { + let brandedHost = getBrandedHost(); + brandedHost = new URL(brandedHost); + + const absolute = isAbsoluteURL(url); + if (!absolute) { + const hasSlash = url.startsWith('/'); + url = new URL(url, brandedHost.origin); + + if (hasSlash || originType === 'Empty') { + const prefix = getCurrentBrandPrefix(); + if (!url.pathname.startsWith(prefix)) { + url = new URL(prefix + url.pathname + url.search + url.hash, brandedHost.origin); + } + } else { + let path = getCurrentURL(); + if (!path.endsWith('/')) { + path += '/'; + } + + url = new URL(path + url.pathname.substring(1) + url.search + url.hash); + } + } else { + url = new URL(url, brandedHost.origin); + if (url.origin.match(CLU_DOMAINS.HDRUK) && url.origin !== brandedHost.origin) { + url = new URL(url.pathname + url.search + url.hash, brandedHost.origin); + } + } + } else if (originType === 'Internal' || originType === 'Empty') { + const absolute = isAbsoluteURL(url); + if (!absolute) { + if (!url.startsWith('/')) { + let path = getCurrentURL(); + if (!path.endsWith('/')) { + path += '/'; + } + + url = new URL(url, getCurrentHost()); + url = new URL(path + url.pathname.substring(1) + url.search + url.hash); + } else { + url = new URL(url, getCurrentHost()); + } + } else { + url = new URL(url); + } + } else { + url = new URL(url); + } + + const metaActive = metaKeyDown || (!!relatedEvent && (relatedEvent.ctrlKey || relatedEvent.metaKey)); + if (allowNewTab && (target === '__blank' || metaActive)) { + window.open(url.href, '_blank', rel); + return true; + } + + window.location = url.href; + return true; +} + +/** + * @desc validates a query selector string + * + * @param {any} selector some value to evaluate + * + * @returns {boolean} specifies whether the given query selector is valid + */ +const isValidSelector = (selector) => { + if (!stringHasChars(selector)) { + return false; + } + + let fragment = globalThis?.__docFrag; + if (isNullOrUndefined(fragment)) { + fragment = document.createDocumentFragment(); + globalThis.__docFrag = fragment; + } + + let invalid = false; + try { + fragment.querySelector(selector) + } catch { + invalid = true; + } finally { + return !invalid; + } +} + +/** + * @desc a type-guard for `HTMLElement` | `HTMLNode` objects + * + * @param {*} obj some DOM element to consider + * @param {string} desiredType optionally specify the type, expects one of `element` | `node` | `any`; defaults to `Any` + * + * @returns {boolean} specifies whether the given obj is a `HTMLElement` | `HTMLNode` as specified by the `desiredType` param + */ +const isHtmlObject = (obj, desiredType = 'Any') => { + if (isNullOrUndefined(obj)) { + return false; + } + + if (typeof desiredType !== 'string') { + desiredType = 'Any'; + } + + desiredType = desiredType.toLowerCase(); + if (desiredType.startsWith('html')) { + desiredType = desiredType.substring(4); + } + + let condition; + switch (desiredType) { + case 'node': { + condition = typeof Node === 'object' + ? obj instanceof Node + : typeof obj === 'object' && typeof obj.nodeType === 'number' && obj.nodeType !== Node.TEXT_NODE && typeof obj.nodeName === 'string'; + } break; + + case 'element': { + condition = typeof HTMLElement === 'object' + ? obj instanceof HTMLElement + : typeof obj === 'object' && obj.nodeType === Node.ELEMENT_NODE && typeof obj.nodeName === 'string'; + } break; + + case 'any': + default: { + condition = (typeof Node === 'object' && typeof HTMLElement === 'object') + ? (obj instanceof Node || obj instanceof HTMLElement) + : typeof obj === 'object' && typeof obj.nodeType === 'number' && obj.nodeType !== Node.TEXT_NODE && typeof obj.nodeName === 'string'; + } break; + } + + return !!condition; +} + +/** + * @desc determines element visibility + * @note does not check client window intersection + * + * @param {HTMLElement} elem some DOM element to evaluate + * + * @returns {boolean} specifies whether the elem is currently rendered + */ +const isVisibleObj = (elem) => { + if (!isHtmlObject(elem, 'element')) { + return false; + } + + try { + if (window.checkVisibility) { + return elem.checkVisibility({ + opacityProperty: true, + visibilityProperty: true, + contentVisibilityAuto: true, + }); + } + + const style = document.defaultView.getComputedStyle(elem); + return ( + style.width !== '0' && + style.height !== '0' && + style.display != 'none' && + style.visibility !== 'hidden' && + Number(style.opacity) > 0 + ); + } + catch { + // Default true on failure + return true; + } +} + +/** + * @desc attempts to find any missing components within a `Object|Array` + * + * @example + * const missing = findMissingComponents( + * { + * str: '<p>Hello</p>', + * arr: { + * str: '<p>Hello</p>', + * str1: '<p>Hello</p>', + * }, + * obj: { + * str2: '<p>Hello</p>', + * str3: '<p>Hello</p>', + * } + * }, + * { + * // ensure that a key with a `string|HTMLElement` exists at this key-value pair + * str: true, + * // ensure that an Object exists at this key-value pair specifying a set of keys to exist + * arr: ['str0', 'str1'], // Note: former is missing + * // ensure an object exists with the follow key-value pair(s) + * obj: { + * str2: true, + * str4: true, // Note: latter is missing + * } + * } + * ); + * console.log('Missing items:', missing); // --> [stdout] Missing items: [ 'str0', 'str4 ] + * + * @param {Record<string, Record<string, string|HTMLElement} templates a set of components to evaluate + * @param {string|Array<string>|Record<string, any|Record|Array>} expected the components expected to be present within the object + * @param {Array<string>} [missing=[]] a list of missing components (filled by the fn) + * + * @returns {Array<string>} an array of missing components (length 0 if none are missing) + */ +const findMissingComponents = (templates, expected, missing = []) => { + if (isRecordType(expected)) { + for (const key in expected) { + const desired = expected[key]; + if (isRecordType(desired) || Array.isArray(desired)) { + const relative = templates[key]; + if (!isRecordType(relative)) { + if (Array.isArray(desired)) { + missing.push(...desired.filter(x => typeof x === 'string')); + } else { + missing.push(key); + } + + continue; + } + + findMissingComponents(relative, desired, missing); + continue; + } + + findMissingComponents(templates, key, missing); + } + } else if (Array.isArray(expected)) { + for (let i = 0; i < expected.length; ++i) { + const desired = expected[i]; + if (typeof desired !== 'string' || !stringHasChars(desired)) { + continue; + } + + findMissingComponents(templates, desired, missing); + } + } else if (typeof expected === 'string') { + const obj = templates[expected]; + if (!isHtmlObject(obj) && (typeof obj !== 'string' || !stringHasChars(obj))) { + missing.push(expected); + } + } + + return missing; +} diff --git a/CodeListLibrary_project/cll/static/js/lib/gridjs/gridjs.production.min.js b/CodeListLibrary_project/cll/static/js/lib/gridjs/gridjs.production.min.js deleted file mode 100644 index aa9058115..000000000 --- a/CodeListLibrary_project/cll/static/js/lib/gridjs/gridjs.production.min.js +++ /dev/null @@ -1,3547 +0,0 @@ -!(function (t, n) { - "object" == typeof exports && "undefined" != typeof module - ? n(exports) - : "function" == typeof define && define.amd - ? define(["exports"], n) - : n(((t || self).gridjs = {})); -})(this, function (t) { - function n(t, n) { - for (var e = 0; e < n.length; e++) { - var r = n[e]; - (r.enumerable = r.enumerable || !1), - (r.configurable = !0), - "value" in r && (r.writable = !0), - Object.defineProperty( - t, - "symbol" == - typeof (o = (function (t, n) { - if ("object" != typeof t || null === t) return t; - var e = t[Symbol.toPrimitive]; - if (void 0 !== e) { - var r = e.call(t, "string"); - if ("object" != typeof r) return r; - throw new TypeError( - "@@toPrimitive must return a primitive value." - ); - } - return String(t); - })(r.key)) - ? o - : String(o), - r - ); - } - var o; - } - function e(t, e, r) { - return ( - e && n(t.prototype, e), - r && n(t, r), - Object.defineProperty(t, "prototype", { writable: !1 }), - t - ); - } - function r() { - return ( - (r = Object.assign - ? Object.assign.bind() - : function (t) { - for (var n = 1; n < arguments.length; n++) { - var e = arguments[n]; - for (var r in e) - Object.prototype.hasOwnProperty.call(e, r) && (t[r] = e[r]); - } - return t; - }), - r.apply(this, arguments) - ); - } - function o(t, n) { - (t.prototype = Object.create(n.prototype)), - (t.prototype.constructor = t), - i(t, n); - } - function i(t, n) { - return ( - (i = Object.setPrototypeOf - ? Object.setPrototypeOf.bind() - : function (t, n) { - return (t.__proto__ = n), t; - }), - i(t, n) - ); - } - function u(t) { - if (void 0 === t) - throw new ReferenceError( - "this hasn't been initialised - super() hasn't been called" - ); - return t; - } - function s(t, n) { - (null == n || n > t.length) && (n = t.length); - for (var e = 0, r = new Array(n); e < n; e++) r[e] = t[e]; - return r; - } - function a(t, n) { - var e = - ("undefined" != typeof Symbol && t[Symbol.iterator]) || t["@@iterator"]; - if (e) return (e = e.call(t)).next.bind(e); - if ( - Array.isArray(t) || - (e = (function (t, n) { - if (t) { - if ("string" == typeof t) return s(t, n); - var e = Object.prototype.toString.call(t).slice(8, -1); - return ( - "Object" === e && t.constructor && (e = t.constructor.name), - "Map" === e || "Set" === e - ? Array.from(t) - : "Arguments" === e || - /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(e) - ? s(t, n) - : void 0 - ); - } - })(t)) || - (n && t && "number" == typeof t.length) - ) { - e && (t = e); - var r = 0; - return function () { - return r >= t.length ? { done: !0 } : { done: !1, value: t[r++] }; - }; - } - throw new TypeError( - "Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method." - ); - } - var l; - !(function (t) { - (t[(t.Init = 0)] = "Init"), - (t[(t.Loading = 1)] = "Loading"), - (t[(t.Loaded = 2)] = "Loaded"), - (t[(t.Rendered = 3)] = "Rendered"), - (t[(t.Error = 4)] = "Error"); - })(l || (l = {})); - var c, - f, - p, - d, - h, - _, - m, - v = {}, - g = [], - y = /acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i; - function b(t, n) { - for (var e in n) t[e] = n[e]; - return t; - } - function w(t) { - var n = t.parentNode; - n && n.removeChild(t); - } - function x(t, n, e) { - var r, - o, - i, - u = {}; - for (i in n) - "key" == i ? (r = n[i]) : "ref" == i ? (o = n[i]) : (u[i] = n[i]); - if ( - (arguments.length > 2 && - (u.children = arguments.length > 3 ? c.call(arguments, 2) : e), - "function" == typeof t && null != t.defaultProps) - ) - for (i in t.defaultProps) void 0 === u[i] && (u[i] = t.defaultProps[i]); - return k(t, u, r, o, null); - } - function k(t, n, e, r, o) { - var i = { - type: t, - props: n, - key: e, - ref: r, - __k: null, - __: null, - __b: 0, - __e: null, - __d: void 0, - __c: null, - __h: null, - constructor: void 0, - __v: null == o ? ++p : o, - }; - return null == o && null != f.vnode && f.vnode(i), i; - } - function P(t) { - return t.children; - } - function S(t, n) { - (this.props = t), (this.context = n); - } - function N(t, n) { - if (null == n) return t.__ ? N(t.__, t.__.__k.indexOf(t) + 1) : null; - for (var e; n < t.__k.length; n++) - if (null != (e = t.__k[n]) && null != e.__e) return e.__e; - return "function" == typeof t.type ? N(t) : null; - } - function C(t) { - var n, e; - if (null != (t = t.__) && null != t.__c) { - for (t.__e = t.__c.base = null, n = 0; n < t.__k.length; n++) - if (null != (e = t.__k[n]) && null != e.__e) { - t.__e = t.__c.base = e.__e; - break; - } - return C(t); - } - } - function E(t) { - ((!t.__d && (t.__d = !0) && h.push(t) && !I.__r++) || - _ !== f.debounceRendering) && - ((_ = f.debounceRendering) || setTimeout)(I); - } - function I() { - for (var t; (I.__r = h.length); ) - (t = h.sort(function (t, n) { - return t.__v.__b - n.__v.__b; - })), - (h = []), - t.some(function (t) { - var n, e, r, o, i, u; - t.__d && - ((i = (o = (n = t).__v).__e), - (u = n.__P) && - ((e = []), - ((r = b({}, o)).__v = o.__v + 1), - M( - u, - o, - r, - n.__n, - void 0 !== u.ownerSVGElement, - null != o.__h ? [i] : null, - e, - null == i ? N(o) : i, - o.__h - ), - R(e, o), - o.__e != i && C(o))); - }); - } - function T(t, n, e, r, o, i, u, s, a, l) { - var c, - f, - p, - d, - h, - _, - m, - y = (r && r.__k) || g, - b = y.length; - for (e.__k = [], c = 0; c < n.length; c++) - if ( - null != - (d = e.__k[c] = - null == (d = n[c]) || "boolean" == typeof d - ? null - : "string" == typeof d || - "number" == typeof d || - "bigint" == typeof d - ? k(null, d, null, null, d) - : Array.isArray(d) - ? k(P, { children: d }, null, null, null) - : d.__b > 0 - ? k(d.type, d.props, d.key, d.ref ? d.ref : null, d.__v) - : d) - ) { - if ( - ((d.__ = e), - (d.__b = e.__b + 1), - null === (p = y[c]) || (p && d.key == p.key && d.type === p.type)) - ) - y[c] = void 0; - else - for (f = 0; f < b; f++) { - if ((p = y[f]) && d.key == p.key && d.type === p.type) { - y[f] = void 0; - break; - } - p = null; - } - M(t, d, (p = p || v), o, i, u, s, a, l), - (h = d.__e), - (f = d.ref) && - p.ref != f && - (m || (m = []), - p.ref && m.push(p.ref, null, d), - m.push(f, d.__c || h, d)), - null != h - ? (null == _ && (_ = h), - "function" == typeof d.type && d.__k === p.__k - ? (d.__d = a = L(d, a, t)) - : (a = A(t, d, p, y, h, a)), - "function" == typeof e.type && (e.__d = a)) - : a && p.__e == a && a.parentNode != t && (a = N(p)); - } - for (e.__e = _, c = b; c--; ) null != y[c] && W(y[c], y[c]); - if (m) for (c = 0; c < m.length; c++) U(m[c], m[++c], m[++c]); - } - function L(t, n, e) { - for (var r, o = t.__k, i = 0; o && i < o.length; i++) - (r = o[i]) && - ((r.__ = t), - (n = - "function" == typeof r.type ? L(r, n, e) : A(e, r, r, o, r.__e, n))); - return n; - } - function A(t, n, e, r, o, i) { - var u, s, a; - if (void 0 !== n.__d) (u = n.__d), (n.__d = void 0); - else if (null == e || o != i || null == o.parentNode) - t: if (null == i || i.parentNode !== t) t.appendChild(o), (u = null); - else { - for (s = i, a = 0; (s = s.nextSibling) && a < r.length; a += 1) - if (s == o) break t; - t.insertBefore(o, i), (u = i); - } - return void 0 !== u ? u : o.nextSibling; - } - function O(t, n, e) { - "-" === n[0] - ? t.setProperty(n, e) - : (t[n] = - null == e ? "" : "number" != typeof e || y.test(n) ? e : e + "px"); - } - function j(t, n, e, r, o) { - var i; - t: if ("style" === n) - if ("string" == typeof e) t.style.cssText = e; - else { - if (("string" == typeof r && (t.style.cssText = r = ""), r)) - for (n in r) (e && n in e) || O(t.style, n, ""); - if (e) for (n in e) (r && e[n] === r[n]) || O(t.style, n, e[n]); - } - else if ("o" === n[0] && "n" === n[1]) - (i = n !== (n = n.replace(/Capture$/, ""))), - (n = n.toLowerCase() in t ? n.toLowerCase().slice(2) : n.slice(2)), - t.l || (t.l = {}), - (t.l[n + i] = e), - e - ? r || t.addEventListener(n, i ? D : H, i) - : t.removeEventListener(n, i ? D : H, i); - else if ("dangerouslySetInnerHTML" !== n) { - if (o) n = n.replace(/xlink(H|:h)/, "h").replace(/sName$/, "s"); - else if ( - "href" !== n && - "list" !== n && - "form" !== n && - "tabIndex" !== n && - "download" !== n && - n in t - ) - try { - t[n] = null == e ? "" : e; - break t; - } catch (t) {} - "function" == typeof e || - (null == e || (!1 === e && -1 == n.indexOf("-")) - ? t.removeAttribute(n) - : t.setAttribute(n, e)); - } - } - function H(t) { - this.l[t.type + !1](f.event ? f.event(t) : t); - } - function D(t) { - this.l[t.type + !0](f.event ? f.event(t) : t); - } - function M(t, n, e, r, o, i, u, s, a) { - var l, - c, - p, - d, - h, - _, - m, - v, - g, - y, - w, - x, - k, - N, - C, - E = n.type; - if (void 0 !== n.constructor) return null; - null != e.__h && - ((a = e.__h), (s = n.__e = e.__e), (n.__h = null), (i = [s])), - (l = f.__b) && l(n); - try { - t: if ("function" == typeof E) { - if ( - ((v = n.props), - (g = (l = E.contextType) && r[l.__c]), - (y = l ? (g ? g.props.value : l.__) : r), - e.__c - ? (m = (c = n.__c = e.__c).__ = c.__E) - : ("prototype" in E && E.prototype.render - ? (n.__c = c = new E(v, y)) - : ((n.__c = c = new S(v, y)), - (c.constructor = E), - (c.render = B)), - g && g.sub(c), - (c.props = v), - c.state || (c.state = {}), - (c.context = y), - (c.__n = r), - (p = c.__d = !0), - (c.__h = []), - (c._sb = [])), - null == c.__s && (c.__s = c.state), - null != E.getDerivedStateFromProps && - (c.__s == c.state && (c.__s = b({}, c.__s)), - b(c.__s, E.getDerivedStateFromProps(v, c.__s))), - (d = c.props), - (h = c.state), - p) - ) - null == E.getDerivedStateFromProps && - null != c.componentWillMount && - c.componentWillMount(), - null != c.componentDidMount && c.__h.push(c.componentDidMount); - else { - if ( - (null == E.getDerivedStateFromProps && - v !== d && - null != c.componentWillReceiveProps && - c.componentWillReceiveProps(v, y), - (!c.__e && - null != c.shouldComponentUpdate && - !1 === c.shouldComponentUpdate(v, c.__s, y)) || - n.__v === e.__v) - ) { - for ( - c.props = v, - c.state = c.__s, - n.__v !== e.__v && (c.__d = !1), - c.__v = n, - n.__e = e.__e, - n.__k = e.__k, - n.__k.forEach(function (t) { - t && (t.__ = n); - }), - w = 0; - w < c._sb.length; - w++ - ) - c.__h.push(c._sb[w]); - (c._sb = []), c.__h.length && u.push(c); - break t; - } - null != c.componentWillUpdate && c.componentWillUpdate(v, c.__s, y), - null != c.componentDidUpdate && - c.__h.push(function () { - c.componentDidUpdate(d, h, _); - }); - } - if ( - ((c.context = y), - (c.props = v), - (c.__v = n), - (c.__P = t), - (x = f.__r), - (k = 0), - "prototype" in E && E.prototype.render) - ) { - for ( - c.state = c.__s, - c.__d = !1, - x && x(n), - l = c.render(c.props, c.state, c.context), - N = 0; - N < c._sb.length; - N++ - ) - c.__h.push(c._sb[N]); - c._sb = []; - } else - do { - (c.__d = !1), - x && x(n), - (l = c.render(c.props, c.state, c.context)), - (c.state = c.__s); - } while (c.__d && ++k < 25); - (c.state = c.__s), - null != c.getChildContext && (r = b(b({}, r), c.getChildContext())), - p || - null == c.getSnapshotBeforeUpdate || - (_ = c.getSnapshotBeforeUpdate(d, h)), - (C = - null != l && l.type === P && null == l.key ? l.props.children : l), - T(t, Array.isArray(C) ? C : [C], n, e, r, o, i, u, s, a), - (c.base = n.__e), - (n.__h = null), - c.__h.length && u.push(c), - m && (c.__E = c.__ = null), - (c.__e = !1); - } else null == i && n.__v === e.__v ? ((n.__k = e.__k), (n.__e = e.__e)) : (n.__e = F(e.__e, n, e, r, o, i, u, a)); - (l = f.diffed) && l(n); - } catch (t) { - (n.__v = null), - (a || null != i) && - ((n.__e = s), (n.__h = !!a), (i[i.indexOf(s)] = null)), - f.__e(t, n, e); - } - } - function R(t, n) { - f.__c && f.__c(n, t), - t.some(function (n) { - try { - (t = n.__h), - (n.__h = []), - t.some(function (t) { - t.call(n); - }); - } catch (t) { - f.__e(t, n.__v); - } - }); - } - function F(t, n, e, r, o, i, u, s) { - var a, - l, - f, - p = e.props, - d = n.props, - h = n.type, - _ = 0; - if (("svg" === h && (o = !0), null != i)) - for (; _ < i.length; _++) - if ( - (a = i[_]) && - "setAttribute" in a == !!h && - (h ? a.localName === h : 3 === a.nodeType) - ) { - (t = a), (i[_] = null); - break; - } - if (null == t) { - if (null === h) return document.createTextNode(d); - (t = o - ? document.createElementNS("http://www.w3.org/2000/svg", h) - : document.createElement(h, d.is && d)), - (i = null), - (s = !1); - } - if (null === h) p === d || (s && t.data === d) || (t.data = d); - else { - if ( - ((i = i && c.call(t.childNodes)), - (l = (p = e.props || v).dangerouslySetInnerHTML), - (f = d.dangerouslySetInnerHTML), - !s) - ) { - if (null != i) - for (p = {}, _ = 0; _ < t.attributes.length; _++) - p[t.attributes[_].name] = t.attributes[_].value; - (f || l) && - ((f && ((l && f.__html == l.__html) || f.__html === t.innerHTML)) || - (t.innerHTML = (f && f.__html) || "")); - } - if ( - ((function (t, n, e, r, o) { - var i; - for (i in e) - "children" === i || "key" === i || i in n || j(t, i, null, e[i], r); - for (i in n) - (o && "function" != typeof n[i]) || - "children" === i || - "key" === i || - "value" === i || - "checked" === i || - e[i] === n[i] || - j(t, i, n[i], e[i], r); - })(t, d, p, o, s), - f) - ) - n.__k = []; - else if ( - ((_ = n.props.children), - T( - t, - Array.isArray(_) ? _ : [_], - n, - e, - r, - o && "foreignObject" !== h, - i, - u, - i ? i[0] : e.__k && N(e, 0), - s - ), - null != i) - ) - for (_ = i.length; _--; ) null != i[_] && w(i[_]); - s || - ("value" in d && - void 0 !== (_ = d.value) && - (_ !== t.value || - ("progress" === h && !_) || - ("option" === h && _ !== p.value)) && - j(t, "value", _, p.value, !1), - "checked" in d && - void 0 !== (_ = d.checked) && - _ !== t.checked && - j(t, "checked", _, p.checked, !1)); - } - return t; - } - function U(t, n, e) { - try { - "function" == typeof t ? t(n) : (t.current = n); - } catch (t) { - f.__e(t, e); - } - } - function W(t, n, e) { - var r, o; - if ( - (f.unmount && f.unmount(t), - (r = t.ref) && ((r.current && r.current !== t.__e) || U(r, null, n)), - null != (r = t.__c)) - ) { - if (r.componentWillUnmount) - try { - r.componentWillUnmount(); - } catch (t) { - f.__e(t, n); - } - (r.base = r.__P = null), (t.__c = void 0); - } - if ((r = t.__k)) - for (o = 0; o < r.length; o++) - r[o] && W(r[o], n, e || "function" != typeof t.type); - e || null == t.__e || w(t.__e), (t.__ = t.__e = t.__d = void 0); - } - function B(t, n, e) { - return this.constructor(t, e); - } - function q(t, n, e) { - var r, o, i; - f.__ && f.__(t, n), - (o = (r = "function" == typeof e) ? null : (e && e.__k) || n.__k), - (i = []), - M( - n, - (t = ((!r && e) || n).__k = x(P, null, [t])), - o || v, - v, - void 0 !== n.ownerSVGElement, - !r && e ? [e] : o ? null : n.firstChild ? c.call(n.childNodes) : null, - i, - !r && e ? e : o ? o.__e : n.firstChild, - r - ), - R(i, t); - } - function z() { - return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace( - /[xy]/g, - function (t) { - var n = (16 * Math.random()) | 0; - return ("x" == t ? n : (3 & n) | 8).toString(16); - } - ); - } - (c = g.slice), - (f = { - __e: function (t, n, e, r) { - for (var o, i, u; (n = n.__); ) - if ((o = n.__c) && !o.__) - try { - if ( - ((i = o.constructor) && - null != i.getDerivedStateFromError && - (o.setState(i.getDerivedStateFromError(t)), (u = o.__d)), - null != o.componentDidCatch && - (o.componentDidCatch(t, r || {}), (u = o.__d)), - u) - ) - return (o.__E = o); - } catch (n) { - t = n; - } - throw t; - }, - }), - (p = 0), - (d = function (t) { - return null != t && void 0 === t.constructor; - }), - (S.prototype.setState = function (t, n) { - var e; - (e = - null != this.__s && this.__s !== this.state - ? this.__s - : (this.__s = b({}, this.state))), - "function" == typeof t && (t = t(b({}, e), this.props)), - t && b(e, t), - null != t && this.__v && (n && this._sb.push(n), E(this)); - }), - (S.prototype.forceUpdate = function (t) { - this.__v && ((this.__e = !0), t && this.__h.push(t), E(this)); - }), - (S.prototype.render = P), - (h = []), - (I.__r = 0), - (m = 0); - var V = /*#__PURE__*/ (function () { - function t(t) { - (this._id = void 0), (this._id = t || z()); - } - return ( - e(t, [ - { - key: "id", - get: function () { - return this._id; - }, - }, - ]), - t - ); - })(); - function $(t) { - return x(t.parentElement || "span", { - dangerouslySetInnerHTML: { __html: t.content }, - }); - } - function G(t, n) { - return x($, { content: t, parentElement: n }); - } - var K, - X = /*#__PURE__*/ (function (t) { - function n(n) { - var e; - return ((e = t.call(this) || this).data = void 0), e.update(n), e; - } - o(n, t); - var e = n.prototype; - return ( - (e.cast = function (t) { - return t instanceof HTMLElement ? G(t.outerHTML) : t; - }), - (e.update = function (t) { - return (this.data = this.cast(t)), this; - }), - n - ); - })(V), - Z = /*#__PURE__*/ (function (t) { - function n(n) { - var e; - return ( - ((e = t.call(this) || this)._cells = void 0), (e.cells = n || []), e - ); - } - o(n, t); - var r = n.prototype; - return ( - (r.cell = function (t) { - return this._cells[t]; - }), - (r.toArray = function () { - return this.cells.map(function (t) { - return t.data; - }); - }), - (n.fromCells = function (t) { - return new n( - t.map(function (t) { - return new X(t.data); - }) - ); - }), - e(n, [ - { - key: "cells", - get: function () { - return this._cells; - }, - set: function (t) { - this._cells = t; - }, - }, - { - key: "length", - get: function () { - return this.cells.length; - }, - }, - ]), - n - ); - })(V), - J = /*#__PURE__*/ (function (t) { - function n(n) { - var e; - return ( - ((e = t.call(this) || this)._rows = void 0), - (e._length = void 0), - (e.rows = n instanceof Array ? n : n instanceof Z ? [n] : []), - e - ); - } - return ( - o(n, t), - (n.prototype.toArray = function () { - return this.rows.map(function (t) { - return t.toArray(); - }); - }), - (n.fromRows = function (t) { - return new n( - t.map(function (t) { - return Z.fromCells(t.cells); - }) - ); - }), - (n.fromArray = function (t) { - return new n( - (t = (function (t) { - return !t[0] || t[0] instanceof Array ? t : [t]; - })(t)).map(function (t) { - return new Z( - t.map(function (t) { - return new X(t); - }) - ); - }) - ); - }), - e(n, [ - { - key: "rows", - get: function () { - return this._rows; - }, - set: function (t) { - this._rows = t; - }, - }, - { - key: "length", - get: function () { - return this._length || this.rows.length; - }, - set: function (t) { - this._length = t; - }, - }, - ]), - n - ); - })(V), - Q = /*#__PURE__*/ (function () { - function t() { - this.callbacks = void 0; - } - var n = t.prototype; - return ( - (n.init = function (t) { - this.callbacks || (this.callbacks = {}), - t && !this.callbacks[t] && (this.callbacks[t] = []); - }), - (n.listeners = function () { - return this.callbacks; - }), - (n.on = function (t, n) { - return this.init(t), this.callbacks[t].push(n), this; - }), - (n.off = function (t, n) { - var e = t; - return ( - this.init(), - this.callbacks[e] && 0 !== this.callbacks[e].length - ? ((this.callbacks[e] = this.callbacks[e].filter(function (t) { - return t != n; - })), - this) - : this - ); - }), - (n.emit = function (t) { - var n = arguments, - e = t; - return ( - this.init(e), - this.callbacks[e].length > 0 && - (this.callbacks[e].forEach(function (t) { - return t.apply(void 0, [].slice.call(n, 1)); - }), - !0) - ); - }), - t - ); - })(); - function Y(t, n) { - if (typeof t != typeof n) return !1; - if (null === t && null === n) return !0; - if ("object" != typeof t) return t === n; - if (Array.isArray(t) && Array.isArray(n)) { - if (t.length !== n.length) return !1; - for (var e = 0; e < t.length; e++) if (!Y(t[e], n[e])) return !1; - return !0; - } - if ( - t.hasOwnProperty("constructor") && - n.hasOwnProperty("constructor") && - t.hasOwnProperty("props") && - n.hasOwnProperty("props") && - t.hasOwnProperty("key") && - n.hasOwnProperty("key") && - t.hasOwnProperty("ref") && - n.hasOwnProperty("ref") && - t.hasOwnProperty("type") && - n.hasOwnProperty("type") - ) - return Y(t.props, n.props); - var r = Object.keys(t), - o = Object.keys(n); - if (r.length !== o.length) return !1; - for (var i = 0, u = r; i < u.length; i++) { - var s = u[i]; - if (!n.hasOwnProperty(s) || !Y(t[s], n[s])) return !1; - } - return !0; - } - !(function (t) { - (t[(t.Initiator = 0)] = "Initiator"), - (t[(t.ServerFilter = 1)] = "ServerFilter"), - (t[(t.ServerSort = 2)] = "ServerSort"), - (t[(t.ServerLimit = 3)] = "ServerLimit"), - (t[(t.Extractor = 4)] = "Extractor"), - (t[(t.Transformer = 5)] = "Transformer"), - (t[(t.Filter = 6)] = "Filter"), - (t[(t.Sort = 7)] = "Sort"), - (t[(t.Limit = 8)] = "Limit"); - })(K || (K = {})); - var tt = /*#__PURE__*/ (function (t) { - function n(n) { - var e; - return ( - ((e = t.call(this) || this).id = void 0), - (e._props = void 0), - (e._props = {}), - (e.id = z()), - n && e.setProps(n), - e - ); - } - o(n, t); - var i = n.prototype; - return ( - (i.process = function () { - var t = [].slice.call(arguments); - this.validateProps instanceof Function && - this.validateProps.apply(this, t), - this.emit.apply(this, ["beforeProcess"].concat(t)); - var n = this._process.apply(this, t); - return this.emit.apply(this, ["afterProcess"].concat(t)), n; - }), - (i.setProps = function (t) { - var n = r({}, this._props, t); - return ( - Y(n, this._props) || - ((this._props = n), this.emit("propsUpdated", this)), - this - ); - }), - e(n, [ - { - key: "props", - get: function () { - return this._props; - }, - }, - ]), - n - ); - })(Q), - nt = /*#__PURE__*/ (function (t) { - function n() { - return t.apply(this, arguments) || this; - } - return ( - o(n, t), - (n.prototype._process = function (t) { - return this.props.keyword - ? ((n = String(this.props.keyword).trim()), - (e = this.props.columns), - (r = this.props.ignoreHiddenColumns), - (o = t), - (i = this.props.selector), - (n = n.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&")), - new J( - o.rows.filter(function (t, o) { - return t.cells.some(function (t, u) { - if (!t) return !1; - if ( - r && - e && - e[u] && - "object" == typeof e[u] && - e[u].hidden - ) - return !1; - var s = ""; - if ("function" == typeof i) s = i(t.data, o, u); - else if ("object" == typeof t.data) { - var a = t.data; - a && a.props && a.props.content && (s = a.props.content); - } else s = String(t.data); - return new RegExp(n, "gi").test(s); - }); - }) - )) - : t; - var n, e, r, o, i; - }), - e(n, [ - { - key: "type", - get: function () { - return K.Filter; - }, - }, - ]), - n - ); - })(tt); - function et() { - var t = "gridjs"; - return ( - "" + - t + - [].slice.call(arguments).reduce(function (t, n) { - return t + "-" + n; - }, "") - ); - } - function rt() { - return [].slice - .call(arguments) - .map(function (t) { - return t ? t.toString() : ""; - }) - .filter(function (t) { - return t; - }) - .reduce(function (t, n) { - return (t || "") + " " + n; - }, "") - .trim(); - } - var ot, - it, - ut, - st, - at = /*#__PURE__*/ (function (t) { - function n() { - return t.apply(this, arguments) || this; - } - return ( - o(n, t), - (n.prototype._process = function (t) { - if (!this.props.keyword) return t; - var n = {}; - return ( - this.props.url && - (n.url = this.props.url(t.url, this.props.keyword)), - this.props.body && - (n.body = this.props.body(t.body, this.props.keyword)), - r({}, t, n) - ); - }), - e(n, [ - { - key: "type", - get: function () { - return K.ServerFilter; - }, - }, - ]), - n - ); - })(tt), - lt = 0, - ct = [], - ft = [], - pt = f.__b, - dt = f.__r, - ht = f.diffed, - _t = f.__c, - mt = f.unmount; - function vt(t, n) { - f.__h && f.__h(it, t, lt || n), (lt = 0); - var e = it.__H || (it.__H = { __: [], __h: [] }); - return t >= e.__.length && e.__.push({ __V: ft }), e.__[t]; - } - function gt(t) { - return ( - (lt = 1), - (function (t, n, e) { - var r = vt(ot++, 2); - if ( - ((r.t = t), - !r.__c && - ((r.__ = [ - Et(void 0, n), - function (t) { - var n = r.__N ? r.__N[0] : r.__[0], - e = r.t(n, t); - n !== e && ((r.__N = [e, r.__[1]]), r.__c.setState({})); - }, - ]), - (r.__c = it), - !it.u)) - ) { - it.u = !0; - var o = it.shouldComponentUpdate; - it.shouldComponentUpdate = function (t, n, e) { - if (!r.__c.__H) return !0; - var i = r.__c.__H.__.filter(function (t) { - return t.__c; - }); - if ( - i.every(function (t) { - return !t.__N; - }) - ) - return !o || o.call(this, t, n, e); - var u = !1; - return ( - i.forEach(function (t) { - if (t.__N) { - var n = t.__[0]; - (t.__ = t.__N), (t.__N = void 0), n !== t.__[0] && (u = !0); - } - }), - !(!u && r.__c.props === t) && (!o || o.call(this, t, n, e)) - ); - }; - } - return r.__N || r.__; - })(Et, t) - ); - } - function yt(t, n) { - var e = vt(ot++, 3); - !f.__s && Ct(e.__H, n) && ((e.__ = t), (e.i = n), it.__H.__h.push(e)); - } - function bt(t) { - return ( - (lt = 5), - wt(function () { - return { current: t }; - }, []) - ); - } - function wt(t, n) { - var e = vt(ot++, 7); - return Ct(e.__H, n) ? ((e.__V = t()), (e.i = n), (e.__h = t), e.__V) : e.__; - } - function xt() { - for (var t; (t = ct.shift()); ) - if (t.__P && t.__H) - try { - t.__H.__h.forEach(St), t.__H.__h.forEach(Nt), (t.__H.__h = []); - } catch (n) { - (t.__H.__h = []), f.__e(n, t.__v); - } - } - (f.__b = function (t) { - (it = null), pt && pt(t); - }), - (f.__r = function (t) { - dt && dt(t), (ot = 0); - var n = (it = t.__c).__H; - n && - (ut === it - ? ((n.__h = []), - (it.__h = []), - n.__.forEach(function (t) { - t.__N && (t.__ = t.__N), (t.__V = ft), (t.__N = t.i = void 0); - })) - : (n.__h.forEach(St), n.__h.forEach(Nt), (n.__h = []))), - (ut = it); - }), - (f.diffed = function (t) { - ht && ht(t); - var n = t.__c; - n && - n.__H && - (n.__H.__h.length && - ((1 !== ct.push(n) && st === f.requestAnimationFrame) || - ((st = f.requestAnimationFrame) || Pt)(xt)), - n.__H.__.forEach(function (t) { - t.i && (t.__H = t.i), - t.__V !== ft && (t.__ = t.__V), - (t.i = void 0), - (t.__V = ft); - })), - (ut = it = null); - }), - (f.__c = function (t, n) { - n.some(function (t) { - try { - t.__h.forEach(St), - (t.__h = t.__h.filter(function (t) { - return !t.__ || Nt(t); - })); - } catch (e) { - n.some(function (t) { - t.__h && (t.__h = []); - }), - (n = []), - f.__e(e, t.__v); - } - }), - _t && _t(t, n); - }), - (f.unmount = function (t) { - mt && mt(t); - var n, - e = t.__c; - e && - e.__H && - (e.__H.__.forEach(function (t) { - try { - St(t); - } catch (t) { - n = t; - } - }), - (e.__H = void 0), - n && f.__e(n, e.__v)); - }); - var kt = "function" == typeof requestAnimationFrame; - function Pt(t) { - var n, - e = function () { - clearTimeout(r), kt && cancelAnimationFrame(n), setTimeout(t); - }, - r = setTimeout(e, 100); - kt && (n = requestAnimationFrame(e)); - } - function St(t) { - var n = it, - e = t.__c; - "function" == typeof e && ((t.__c = void 0), e()), (it = n); - } - function Nt(t) { - var n = it; - (t.__c = t.__()), (it = n); - } - function Ct(t, n) { - return ( - !t || - t.length !== n.length || - n.some(function (n, e) { - return n !== t[e]; - }) - ); - } - function Et(t, n) { - return "function" == typeof n ? n(t) : n; - } - function It() { - return (function (t) { - var n = it.context[t.__c], - e = vt(ot++, 9); - return ( - (e.c = t), - n ? (null == e.__ && ((e.__ = !0), n.sub(it)), n.props.value) : t.__ - ); - })(fn); - } - var Tt = { - search: { placeholder: "Type a keyword..." }, - sort: { - sortAsc: "Sort column ascending", - sortDesc: "Sort column descending", - }, - pagination: { - previous: "Previous", - next: "Next", - navigate: function (t, n) { - return "Page " + t + " of " + n; - }, - page: function (t) { - return "Page " + t; - }, - showing: "Showing", - of: "of", - to: "to", - results: "results", - }, - loading: "Loading...", - noRecordsFound: "No matching records found", - error: "An error happened while fetching the data", - }, - Lt = /*#__PURE__*/ (function () { - function t(t) { - (this._language = void 0), - (this._defaultLanguage = void 0), - (this._language = t), - (this._defaultLanguage = Tt); - } - var n = t.prototype; - return ( - (n.getString = function (t, n) { - if (!n || !t) return null; - var e = t.split("."), - r = e[0]; - if (n[r]) { - var o = n[r]; - return "string" == typeof o - ? function () { - return o; - } - : "function" == typeof o - ? o - : this.getString(e.slice(1).join("."), o); - } - return null; - }), - (n.translate = function (t) { - var n, - e = this.getString(t, this._language); - return (n = e || this.getString(t, this._defaultLanguage)) - ? n.apply(void 0, [].slice.call(arguments, 1)) - : t; - }), - t - ); - })(); - function At() { - var t = It(); - return function (n) { - var e; - return (e = t.translator).translate.apply( - e, - [n].concat([].slice.call(arguments, 1)) - ); - }; - } - var Ot = function (t) { - return function (n) { - return r({}, n, { search: { keyword: t } }); - }; - }; - function jt() { - return It().store; - } - function Ht(t) { - var n = jt(), - e = gt(t(n.getState())), - r = e[0], - o = e[1]; - return ( - yt(function () { - return n.subscribe(function () { - var e = t(n.getState()); - r !== e && o(e); - }); - }, []), - r - ); - } - function Dt() { - var t, - n = gt(void 0), - e = n[0], - r = n[1], - o = It(), - i = o.search, - u = At(), - s = jt().dispatch, - a = Ht(function (t) { - return t.search; - }); - yt( - function () { - e && e.setProps({ keyword: null == a ? void 0 : a.keyword }); - }, - [a, e] - ), - yt( - function () { - r( - i.server - ? new at({ - keyword: i.keyword, - url: i.server.url, - body: i.server.body, - }) - : new nt({ - keyword: i.keyword, - columns: o.header && o.header.columns, - ignoreHiddenColumns: - i.ignoreHiddenColumns || void 0 === i.ignoreHiddenColumns, - selector: i.selector, - }) - ), - i.keyword && s(Ot(i.keyword)); - }, - [i] - ), - yt( - function () { - if (e) - return ( - o.pipeline.register(e), - function () { - return o.pipeline.unregister(e); - } - ); - }, - [o, e] - ); - var l, - c, - f, - p = (function (t, n) { - return ( - (lt = 8), - wt(function () { - return t; - }, n) - ); - })( - ((l = function (t) { - t.target instanceof HTMLInputElement && s(Ot(t.target.value)); - }), - (c = e instanceof at ? i.debounceTimeout || 250 : 0), - function () { - var t = arguments; - return new Promise(function (n) { - f && clearTimeout(f), - (f = setTimeout(function () { - return n(l.apply(void 0, [].slice.call(t))); - }, c)); - }); - }), - [i, e] - ); - return x( - "div", - { - className: et( - rt("search", null == (t = o.className) ? void 0 : t.search) - ), - }, - x("input", { - type: "search", - placeholder: u("search.placeholder"), - "aria-label": u("search.placeholder"), - onInput: p, - className: rt(et("input"), et("search", "input")), - defaultValue: (null == a ? void 0 : a.keyword) || "", - }) - ); - } - var Mt = /*#__PURE__*/ (function (t) { - function n() { - return t.apply(this, arguments) || this; - } - o(n, t); - var r = n.prototype; - return ( - (r.validateProps = function () { - if (isNaN(Number(this.props.limit)) || isNaN(Number(this.props.page))) - throw Error("Invalid parameters passed"); - }), - (r._process = function (t) { - var n = this.props.page; - return new J( - t.rows.slice(n * this.props.limit, (n + 1) * this.props.limit) - ); - }), - e(n, [ - { - key: "type", - get: function () { - return K.Limit; - }, - }, - ]), - n - ); - })(tt), - Rt = /*#__PURE__*/ (function (t) { - function n() { - return t.apply(this, arguments) || this; - } - return ( - o(n, t), - (n.prototype._process = function (t) { - var n = {}; - return ( - this.props.url && - (n.url = this.props.url( - t.url, - this.props.page, - this.props.limit - )), - this.props.body && - (n.body = this.props.body( - t.body, - this.props.page, - this.props.limit - )), - r({}, t, n) - ); - }), - e(n, [ - { - key: "type", - get: function () { - return K.ServerLimit; - }, - }, - ]), - n - ); - })(tt); - function Ft() { - var t = It(), - n = t.pagination, - e = n.server, - r = n.summary, - o = void 0 === r || r, - i = n.nextButton, - u = void 0 === i || i, - s = n.prevButton, - a = void 0 === s || s, - l = n.buttonsCount, - c = void 0 === l ? 3 : l, - f = n.limit, - p = void 0 === f ? 10 : f, - d = n.page, - h = void 0 === d ? 0 : d, - _ = n.resetPageOnUpdate, - m = void 0 === _ || _, - v = bt(null), - g = gt(h), - y = g[0], - b = g[1], - w = gt(0), - k = w[0], - S = w[1], - N = At(); - yt(function () { - return ( - e - ? ((v.current = new Rt({ - limit: p, - page: y, - url: e.url, - body: e.body, - })), - t.pipeline.register(v.current)) - : ((v.current = new Mt({ limit: p, page: y })), - t.pipeline.register(v.current)), - v.current instanceof Rt - ? t.pipeline.on("afterProcess", function (t) { - return S(t.length); - }) - : v.current instanceof Mt && - v.current.on("beforeProcess", function (t) { - return S(t.length); - }), - t.pipeline.on("updated", C), - t.pipeline.on("error", function () { - S(0), b(0); - }), - function () { - t.pipeline.unregister(v.current), t.pipeline.off("updated", C); - } - ); - }, []); - var C = function (t) { - m && - t !== v.current && - (b(0), 0 !== v.current.props.page && v.current.setProps({ page: 0 })); - }, - E = function () { - return Math.ceil(k / p); - }, - I = function (t) { - if (t >= E() || t < 0 || t === y) return null; - b(t), v.current.setProps({ page: t }); - }; - return x( - "div", - { className: rt(et("pagination"), t.className.pagination) }, - x( - P, - null, - o && - k > 0 && - x( - "div", - { - role: "status", - "aria-live": "polite", - className: rt(et("summary"), t.className.paginationSummary), - title: N("pagination.navigate", y + 1, E()), - }, - N("pagination.showing"), - " ", - x("b", null, N("" + (y * p + 1))), - " ", - N("pagination.to"), - " ", - x("b", null, N("" + Math.min((y + 1) * p, k))), - " ", - N("pagination.of"), - " ", - x("b", null, N("" + k)), - " ", - N("pagination.results") - ) - ), - x( - "div", - { className: et("pages") }, - a && - x( - "button", - { - tabIndex: 0, - role: "button", - disabled: 0 === y, - onClick: function () { - return I(y - 1); - }, - title: N("pagination.previous"), - "aria-label": N("pagination.previous"), - className: rt( - t.className.paginationButton, - t.className.paginationButtonPrev - ), - }, - N("pagination.previous") - ), - (function () { - if (c <= 0) return null; - var n = Math.min(E(), c), - e = Math.min(y, Math.floor(n / 2)); - return ( - y + Math.floor(n / 2) >= E() && (e = n - (E() - y)), - x( - P, - null, - E() > n && - y - e > 0 && - x( - P, - null, - x( - "button", - { - tabIndex: 0, - role: "button", - onClick: function () { - return I(0); - }, - title: N("pagination.firstPage"), - "aria-label": N("pagination.firstPage"), - className: t.className.paginationButton, - }, - N("1") - ), - x( - "button", - { - tabIndex: -1, - className: rt(et("spread"), t.className.paginationButton), - }, - "..." - ) - ), - Array.from(Array(n).keys()) - .map(function (t) { - return y + (t - e); - }) - .map(function (n) { - return x( - "button", - { - tabIndex: 0, - role: "button", - onClick: function () { - return I(n); - }, - className: rt( - y === n - ? rt( - et("currentPage"), - t.className.paginationButtonCurrent - ) - : null, - t.className.paginationButton - ), - title: N("pagination.page", n + 1), - "aria-label": N("pagination.page", n + 1), - }, - N("" + (n + 1)) - ); - }), - E() > n && - E() > y + e + 1 && - x( - P, - null, - x( - "button", - { - tabIndex: -1, - className: rt(et("spread"), t.className.paginationButton), - }, - "..." - ), - x( - "button", - { - tabIndex: 0, - role: "button", - onClick: function () { - return I(E() - 1); - }, - title: N("pagination.page", E()), - "aria-label": N("pagination.page", E()), - className: t.className.paginationButton, - }, - N("" + E()) - ) - ) - ) - ); - })(), - u && - x( - "button", - { - tabIndex: 0, - role: "button", - disabled: E() === y + 1 || 0 === E(), - onClick: function () { - return I(y + 1); - }, - title: N("pagination.next"), - "aria-label": N("pagination.next"), - className: rt( - t.className.paginationButton, - t.className.paginationButtonNext - ), - }, - N("pagination.next") - ) - ) - ); - } - function Ut(t, n) { - return "string" == typeof t - ? t.indexOf("%") > -1 - ? (n / 100) * parseInt(t, 10) - : parseInt(t, 10) - : t; - } - function Wt(t) { - return t ? Math.floor(t) + "px" : ""; - } - function Bt(t) { - var n = t.tableRef.cloneNode(!0); - return ( - (n.style.position = "absolute"), - (n.style.width = "100%"), - (n.style.zIndex = "-2147483640"), - (n.style.visibility = "hidden"), - x("div", { - ref: function (t) { - t && t.appendChild(n); - }, - }) - ); - } - function qt(t) { - if (!t) return ""; - var n = t.split(" "); - return 1 === n.length && /([a-z][A-Z])+/g.test(t) - ? t - : n - .map(function (t, n) { - return 0 == n - ? t.toLowerCase() - : t.charAt(0).toUpperCase() + t.slice(1).toLowerCase(); - }) - .join(""); - } - var zt, - Vt = new /*#__PURE__*/ ((function () { - function t() {} - var n = t.prototype; - return ( - (n.format = function (t, n) { - return "[Grid.js] [" + n.toUpperCase() + "]: " + t; - }), - (n.error = function (t, n) { - void 0 === n && (n = !1); - var e = this.format(t, "error"); - if (n) throw Error(e); - console.error(e); - }), - (n.warn = function (t) { - console.warn(this.format(t, "warn")); - }), - (n.info = function (t) { - console.info(this.format(t, "info")); - }), - t - ); - })())(); - (t.PluginPosition = void 0), - ((zt = t.PluginPosition || (t.PluginPosition = {}))[(zt.Header = 0)] = - "Header"), - (zt[(zt.Footer = 1)] = "Footer"), - (zt[(zt.Cell = 2)] = "Cell"); - var $t = /*#__PURE__*/ (function () { - function t() { - (this.plugins = void 0), (this.plugins = []); - } - var n = t.prototype; - return ( - (n.get = function (t) { - return this.plugins.find(function (n) { - return n.id === t; - }); - }), - (n.add = function (t) { - return t.id - ? this.get(t.id) - ? (Vt.error("Duplicate plugin ID: " + t.id), this) - : (this.plugins.push(t), this) - : (Vt.error("Plugin ID cannot be empty"), this); - }), - (n.remove = function (t) { - var n = this.get(t); - return n && this.plugins.splice(this.plugins.indexOf(n), 1), this; - }), - (n.list = function (t) { - var n; - return ( - (n = - null != t || null != t - ? this.plugins.filter(function (n) { - return n.position === t; - }) - : this.plugins), - n.sort(function (t, n) { - return t.order && n.order ? t.order - n.order : 1; - }) - ); - }), - t - ); - })(); - function Gt(t) { - var n = this, - e = It(); - if (t.pluginId) { - var o = e.plugin.get(t.pluginId); - return o ? x(P, {}, x(o.component, r({ plugin: o }, t.props))) : null; - } - return void 0 !== t.position - ? x( - P, - {}, - e.plugin.list(t.position).map(function (t) { - return x(t.component, r({ plugin: t }, n.props.props)); - }) - ) - : null; - } - var Kt = /*#__PURE__*/ (function (n) { - function i() { - var t; - return ( - ((t = n.call(this) || this)._columns = void 0), (t._columns = []), t - ); - } - o(i, n); - var u = i.prototype; - return ( - (u.adjustWidth = function (t, n, e) { - var o = t.container, - u = t.autoWidth; - if (!o) return this; - var s = o.clientWidth, - l = {}; - n.current && - u && - (q(x(Bt, { tableRef: n.current }), e.current), - (l = (function (t) { - var n = t.querySelector("table"); - if (!n) return {}; - var e = n.className, - o = n.style.cssText; - (n.className = e + " " + et("shadowTable")), - (n.style.tableLayout = "auto"), - (n.style.width = "auto"), - (n.style.padding = "0"), - (n.style.margin = "0"), - (n.style.border = "none"), - (n.style.outline = "none"); - var i = Array.from( - n.parentNode.querySelectorAll("thead th") - ).reduce(function (t, n) { - var e; - return ( - (n.style.width = n.clientWidth + "px"), - r( - (((e = {})[n.getAttribute("data-column-id")] = { - minWidth: n.clientWidth, - }), - e), - t - ) - ); - }, {}); - return ( - (n.className = e), - (n.style.cssText = o), - (n.style.tableLayout = "auto"), - Array.from(n.parentNode.querySelectorAll("thead th")).reduce( - function (t, n) { - return ( - (t[n.getAttribute("data-column-id")].width = - n.clientWidth), - t - ); - }, - i - ) - ); - })(e.current))); - for ( - var c, - f = a( - i.tabularFormat(this.columns).reduce(function (t, n) { - return t.concat(n); - }, []) - ); - !(c = f()).done; - - ) { - var p = c.value; - (p.columns && p.columns.length > 0) || - (!p.width && u - ? p.id in l && - ((p.width = Wt(l[p.id].width)), - (p.minWidth = Wt(l[p.id].minWidth))) - : (p.width = Wt(Ut(p.width, s)))); - } - return n.current && u && q(null, e.current), this; - }), - (u.setSort = function (t, n) { - for (var e, o = a(n || this.columns || []); !(e = o()).done; ) { - var i = e.value; - i.columns && i.columns.length > 0 - ? (i.sort = void 0) - : void 0 === i.sort && t - ? (i.sort = {}) - : i.sort - ? "object" == typeof i.sort && (i.sort = r({}, i.sort)) - : (i.sort = void 0), - i.columns && this.setSort(t, i.columns); - } - }), - (u.setResizable = function (t, n) { - for (var e, r = a(n || this.columns || []); !(e = r()).done; ) { - var o = e.value; - void 0 === o.resizable && (o.resizable = t), - o.columns && this.setResizable(t, o.columns); - } - }), - (u.setID = function (t) { - for (var n, e = a(t || this.columns || []); !(n = e()).done; ) { - var r = n.value; - r.id || "string" != typeof r.name || (r.id = qt(r.name)), - r.id || - Vt.error( - 'Could not find a valid ID for one of the columns. Make sure a valid "id" is set for all columns.' - ), - r.columns && this.setID(r.columns); - } - }), - (u.populatePlugins = function (n, e) { - for (var o, i = a(e); !(o = i()).done; ) { - var u = o.value; - void 0 !== u.plugin && - n.add( - r({ id: u.id }, u.plugin, { position: t.PluginPosition.Cell }) - ); - } - }), - (i.fromColumns = function (t) { - for (var n, e = new i(), r = a(t); !(n = r()).done; ) { - var o = n.value; - if ("string" == typeof o || d(o)) e.columns.push({ name: o }); - else if ("object" == typeof o) { - var u = o; - u.columns && (u.columns = i.fromColumns(u.columns).columns), - "object" == typeof u.plugin && - void 0 === u.data && - (u.data = null), - e.columns.push(o); - } - } - return e; - }), - (i.createFromConfig = function (t) { - var n = new i(); - return ( - t.from - ? (n.columns = i.fromHTMLTable(t.from).columns) - : t.columns - ? (n.columns = i.fromColumns(t.columns).columns) - : !t.data || - "object" != typeof t.data[0] || - t.data[0] instanceof Array || - (n.columns = Object.keys(t.data[0]).map(function (t) { - return { name: t }; - })), - n.columns.length - ? (n.setID(), - n.setSort(t.sort), - n.setResizable(t.resizable), - n.populatePlugins(t.plugin, n.columns), - n) - : null - ); - }), - (i.fromHTMLTable = function (t) { - for ( - var n, - e = new i(), - r = a(t.querySelector("thead").querySelectorAll("th")); - !(n = r()).done; - - ) { - var o = n.value; - e.columns.push({ name: o.innerHTML, width: o.width }); - } - return e; - }), - (i.tabularFormat = function (t) { - var n = [], - e = t || [], - r = []; - if (e && e.length) { - n.push(e); - for (var o, i = a(e); !(o = i()).done; ) { - var u = o.value; - u.columns && u.columns.length && (r = r.concat(u.columns)); - } - r.length && (n = n.concat(this.tabularFormat(r))); - } - return n; - }), - (i.leafColumns = function (t) { - var n = [], - e = t || []; - if (e && e.length) - for (var r, o = a(e); !(r = o()).done; ) { - var i = r.value; - (i.columns && 0 !== i.columns.length) || n.push(i), - i.columns && (n = n.concat(this.leafColumns(i.columns))); - } - return n; - }), - (i.maximumDepth = function (t) { - return this.tabularFormat([t]).length - 1; - }), - e(i, [ - { - key: "columns", - get: function () { - return this._columns; - }, - set: function (t) { - this._columns = t; - }, - }, - { - key: "visibleColumns", - get: function () { - return this._columns.filter(function (t) { - return !t.hidden; - }); - }, - }, - ]), - i - ); - })(V), - Xt = function () {}, - Zt = /*#__PURE__*/ (function (t) { - function n(n) { - var e; - return ((e = t.call(this) || this).data = void 0), e.set(n), e; - } - o(n, t); - var e = n.prototype; - return ( - (e.get = function () { - try { - return Promise.resolve(this.data()).then(function (t) { - return { data: t, total: t.length }; - }); - } catch (t) { - return Promise.reject(t); - } - }), - (e.set = function (t) { - return ( - t instanceof Array - ? (this.data = function () { - return t; - }) - : t instanceof Function && (this.data = t), - this - ); - }), - n - ); - })(Xt), - Jt = /*#__PURE__*/ (function (t) { - function n(n) { - var e; - return ( - ((e = t.call(this) || this).options = void 0), (e.options = n), e - ); - } - o(n, t); - var e = n.prototype; - return ( - (e.handler = function (t) { - return "function" == typeof this.options.handle - ? this.options.handle(t) - : t.ok - ? t.json() - : (Vt.error( - "Could not fetch data: " + t.status + " - " + t.statusText, - !0 - ), - null); - }), - (e.get = function (t) { - var n = r({}, this.options, t); - return "function" == typeof n.data - ? n.data(n) - : fetch(n.url, n) - .then(this.handler.bind(this)) - .then(function (t) { - return { - data: n.then(t), - total: "function" == typeof n.total ? n.total(t) : void 0, - }; - }); - }), - n - ); - })(Xt), - Qt = /*#__PURE__*/ (function () { - function t() {} - return ( - (t.createFromConfig = function (t) { - var n = null; - return ( - t.data && (n = new Zt(t.data)), - t.from && - ((n = new Zt(this.tableElementToArray(t.from))), - (t.from.style.display = "none")), - t.server && (n = new Jt(t.server)), - n || Vt.error("Could not determine the storage type", !0), - n - ); - }), - (t.tableElementToArray = function (t) { - for ( - var n, - e, - r = [], - o = a(t.querySelector("tbody").querySelectorAll("tr")); - !(n = o()).done; - - ) { - for ( - var i, u = [], s = a(n.value.querySelectorAll("td")); - !(i = s()).done; - - ) { - var l = i.value; - 1 === l.childNodes.length && - l.childNodes[0].nodeType === Node.TEXT_NODE - ? u.push( - ((e = l.innerHTML), - new DOMParser().parseFromString(e, "text/html") - .documentElement.textContent) - ) - : u.push(G(l.innerHTML)); - } - r.push(u); - } - return r; - }), - t - ); - })(), - Yt = - "undefined" != typeof Symbol - ? Symbol.iterator || (Symbol.iterator = Symbol("Symbol.iterator")) - : "@@iterator"; - function tn(t, n, e) { - if (!t.s) { - if (e instanceof nn) { - if (!e.s) return void (e.o = tn.bind(null, t, n)); - 1 & n && (n = e.s), (e = e.v); - } - if (e && e.then) - return void e.then(tn.bind(null, t, n), tn.bind(null, t, 2)); - (t.s = n), (t.v = e); - var r = t.o; - r && r(t); - } - } - var nn = /*#__PURE__*/ (function () { - function t() {} - return ( - (t.prototype.then = function (n, e) { - var r = new t(), - o = this.s; - if (o) { - var i = 1 & o ? n : e; - if (i) { - try { - tn(r, 1, i(this.v)); - } catch (t) { - tn(r, 2, t); - } - return r; - } - return this; - } - return ( - (this.o = function (t) { - try { - var o = t.v; - 1 & t.s - ? tn(r, 1, n ? n(o) : o) - : e - ? tn(r, 1, e(o)) - : tn(r, 2, o); - } catch (t) { - tn(r, 2, t); - } - }), - r - ); - }), - t - ); - })(); - function en(t) { - return t instanceof nn && 1 & t.s; - } - var rn = /*#__PURE__*/ (function (t) { - function n(n) { - var e; - return ( - ((e = t.call(this) || this)._steps = new Map()), - (e.cache = new Map()), - (e.lastProcessorIndexUpdated = -1), - n && - n.forEach(function (t) { - return e.register(t); - }), - e - ); - } - o(n, t); - var r = n.prototype; - return ( - (r.clearCache = function () { - (this.cache = new Map()), (this.lastProcessorIndexUpdated = -1); - }), - (r.register = function (t, n) { - if ((void 0 === n && (n = null), !t)) - throw Error("Processor is not defined"); - if (null === t.type) throw Error("Processor type is not defined"); - if (this.findProcessorIndexByID(t.id) > -1) - throw Error("Processor ID " + t.id + " is already defined"); - return ( - t.on("propsUpdated", this.processorPropsUpdated.bind(this)), - this.addProcessorByPriority(t, n), - this.afterRegistered(t), - t - ); - }), - (r.tryRegister = function (t, n) { - void 0 === n && (n = null); - try { - return this.register(t, n); - } catch (t) {} - }), - (r.unregister = function (t) { - if (t && -1 !== this.findProcessorIndexByID(t.id)) { - var n = this._steps.get(t.type); - n && - n.length && - (this._steps.set( - t.type, - n.filter(function (n) { - return n != t; - }) - ), - this.emit("updated", t)); - } - }), - (r.addProcessorByPriority = function (t, n) { - var e = this._steps.get(t.type); - if (!e) { - var r = []; - this._steps.set(t.type, r), (e = r); - } - if (null === n || n < 0) e.push(t); - else if (e[n]) { - var o = e.slice(0, n - 1), - i = e.slice(n + 1); - this._steps.set(t.type, o.concat(t).concat(i)); - } else e[n] = t; - }), - (r.getStepsByType = function (t) { - return this.steps.filter(function (n) { - return n.type === t; - }); - }), - (r.getSortedProcessorTypes = function () { - return Object.keys(K) - .filter(function (t) { - return !isNaN(Number(t)); - }) - .map(function (t) { - return Number(t); - }); - }), - (r.process = function (t) { - try { - var n = function (t) { - return ( - (e.lastProcessorIndexUpdated = o.length), - e.emit("afterProcess", i), - i - ); - }, - e = this, - r = e.lastProcessorIndexUpdated, - o = e.steps, - i = t, - u = (function (t, n) { - try { - var u = (function (t, n, e) { - if ("function" == typeof t[Yt]) { - var r, - o, - i, - u = t[Yt](); - if ( - ((function t(e) { - try { - for (; !(r = u.next()).done; ) - if ((e = n(r.value)) && e.then) { - if (!en(e)) - return void e.then( - t, - i || (i = tn.bind(null, (o = new nn()), 2)) - ); - e = e.v; - } - o ? tn(o, 1, e) : (o = e); - } catch (t) { - tn(o || (o = new nn()), 2, t); - } - })(), - u.return) - ) { - var s = function (t) { - try { - r.done || u.return(); - } catch (t) {} - return t; - }; - if (o && o.then) - return o.then(s, function (t) { - throw s(t); - }); - s(); - } - return o; - } - if (!("length" in t)) - throw new TypeError("Object is not iterable"); - for (var a = [], l = 0; l < t.length; l++) a.push(t[l]); - return (function (t, n, e) { - var r, - o, - i = -1; - return ( - (function e(u) { - try { - for (; ++i < t.length; ) - if ((u = n(i)) && u.then) { - if (!en(u)) - return void u.then( - e, - o || (o = tn.bind(null, (r = new nn()), 2)) - ); - u = u.v; - } - r ? tn(r, 1, u) : (r = u); - } catch (t) { - tn(r || (r = new nn()), 2, t); - } - })(), - r - ); - })(a, function (t) { - return n(a[t]); - }); - })(o, function (t) { - var n = e.findProcessorIndexByID(t.id), - o = (function () { - if (n >= r) - return Promise.resolve(t.process(i)).then(function ( - n - ) { - e.cache.set(t.id, (i = n)); - }); - i = e.cache.get(t.id); - })(); - if (o && o.then) return o.then(function () {}); - }); - } catch (t) { - return n(t); - } - return u && u.then ? u.then(void 0, n) : u; - })(0, function (t) { - throw (Vt.error(t), e.emit("error", i), t); - }); - return Promise.resolve(u && u.then ? u.then(n) : n()); - } catch (t) { - return Promise.reject(t); - } - }), - (r.findProcessorIndexByID = function (t) { - return this.steps.findIndex(function (n) { - return n.id == t; - }); - }), - (r.setLastProcessorIndex = function (t) { - var n = this.findProcessorIndexByID(t.id); - this.lastProcessorIndexUpdated > n && - (this.lastProcessorIndexUpdated = n); - }), - (r.processorPropsUpdated = function (t) { - this.setLastProcessorIndex(t), - this.emit("propsUpdated"), - this.emit("updated", t); - }), - (r.afterRegistered = function (t) { - this.setLastProcessorIndex(t), - this.emit("afterRegister"), - this.emit("updated", t); - }), - e(n, [ - { - key: "steps", - get: function () { - for ( - var t, n = [], e = a(this.getSortedProcessorTypes()); - !(t = e()).done; - - ) { - var r = this._steps.get(t.value); - r && r.length && (n = n.concat(r)); - } - return n.filter(function (t) { - return t; - }); - }, - }, - ]), - n - ); - })(Q), - on = /*#__PURE__*/ (function (t) { - function n() { - return t.apply(this, arguments) || this; - } - return ( - o(n, t), - (n.prototype._process = function (t) { - try { - return Promise.resolve(this.props.storage.get(t)); - } catch (t) { - return Promise.reject(t); - } - }), - e(n, [ - { - key: "type", - get: function () { - return K.Extractor; - }, - }, - ]), - n - ); - })(tt), - un = /*#__PURE__*/ (function (t) { - function n() { - return t.apply(this, arguments) || this; - } - return ( - o(n, t), - (n.prototype._process = function (t) { - var n = J.fromArray(t.data); - return (n.length = t.total), n; - }), - e(n, [ - { - key: "type", - get: function () { - return K.Transformer; - }, - }, - ]), - n - ); - })(tt), - sn = /*#__PURE__*/ (function (t) { - function n() { - return t.apply(this, arguments) || this; - } - return ( - o(n, t), - (n.prototype._process = function () { - return Object.entries(this.props.serverStorageOptions) - .filter(function (t) { - return "function" != typeof t[1]; - }) - .reduce(function (t, n) { - var e; - return r({}, t, (((e = {})[n[0]] = n[1]), e)); - }, {}); - }), - e(n, [ - { - key: "type", - get: function () { - return K.Initiator; - }, - }, - ]), - n - ); - })(tt), - an = /*#__PURE__*/ (function (t) { - function n() { - return t.apply(this, arguments) || this; - } - o(n, t); - var r = n.prototype; - return ( - (r.castData = function (t) { - if (!t || !t.length) return []; - if (!this.props.header || !this.props.header.columns) return t; - var n = Kt.leafColumns(this.props.header.columns); - return t[0] instanceof Array - ? t.map(function (t) { - var e = 0; - return n.map(function (n, r) { - return void 0 !== n.data - ? (e++, "function" == typeof n.data ? n.data(t) : n.data) - : t[r - e]; - }); - }) - : "object" != typeof t[0] || t[0] instanceof Array - ? [] - : t.map(function (t) { - return n.map(function (n, e) { - return void 0 !== n.data - ? "function" == typeof n.data - ? n.data(t) - : n.data - : n.id - ? t[n.id] - : (Vt.error( - "Could not find the correct cell for column at position " + - e + - ".\n Make sure either 'id' or 'selector' is defined for all columns." - ), - null); - }); - }); - }), - (r._process = function (t) { - return { data: this.castData(t.data), total: t.total }; - }), - e(n, [ - { - key: "type", - get: function () { - return K.Transformer; - }, - }, - ]), - n - ); - })(tt), - ln = /*#__PURE__*/ (function () { - function t() {} - return ( - (t.createFromConfig = function (t) { - var n = new rn(); - return ( - t.storage instanceof Jt && - n.register(new sn({ serverStorageOptions: t.server })), - n.register(new on({ storage: t.storage })), - n.register(new an({ header: t.header })), - n.register(new un()), - n - ); - }), - t - ); - })(), - cn = function (t) { - var n = this; - (this.state = void 0), - (this.listeners = []), - (this.isDispatching = !1), - (this.getState = function () { - return n.state; - }), - (this.getListeners = function () { - return n.listeners; - }), - (this.dispatch = function (t) { - if ("function" != typeof t) - throw new Error("Reducer is not a function"); - if (n.isDispatching) - throw new Error("Reducers may not dispatch actions"); - n.isDispatching = !0; - var e = n.state; - try { - n.state = t(n.state); - } finally { - n.isDispatching = !1; - } - for (var r, o = a(n.listeners); !(r = o()).done; ) - (0, r.value)(n.state, e); - return n.state; - }), - (this.subscribe = function (t) { - if ("function" != typeof t) - throw new Error("Listener is not a function"); - return ( - (n.listeners = [].concat(n.listeners, [t])), - function () { - return (n.listeners = n.listeners.filter(function (n) { - return n !== t; - })); - } - ); - }), - (this.state = t); - }, - fn = (function (t, n) { - var e = { - __c: (n = "__cC" + m++), - __: null, - Consumer: function (t, n) { - return t.children(n); - }, - Provider: function (t) { - var e, r; - return ( - this.getChildContext || - ((e = []), - ((r = {})[n] = this), - (this.getChildContext = function () { - return r; - }), - (this.shouldComponentUpdate = function (t) { - this.props.value !== t.value && e.some(E); - }), - (this.sub = function (t) { - e.push(t); - var n = t.componentWillUnmount; - t.componentWillUnmount = function () { - e.splice(e.indexOf(t), 1), n && n.call(t); - }; - })), - t.children - ); - }, - }; - return (e.Provider.__ = e.Consumer.contextType = e); - })(), - pn = /*#__PURE__*/ (function () { - function n() { - Object.assign(this, n.defaultConfig()); - } - var e = n.prototype; - return ( - (e.assign = function (t) { - return Object.assign(this, t); - }), - (e.update = function (t) { - return t - ? (this.assign(n.fromPartialConfig(r({}, this, t))), this) - : this; - }), - (n.defaultConfig = function () { - return { - store: new cn({ status: l.Init, header: void 0, data: null }), - plugin: new $t(), - tableRef: { current: null }, - width: "100%", - height: "auto", - processingThrottleMs: 100, - autoWidth: !0, - style: {}, - className: {}, - }; - }), - (n.fromPartialConfig = function (e) { - var r = new n().assign(e); - return ( - "boolean" == typeof e.sort && - e.sort && - r.assign({ sort: { multiColumn: !0 } }), - r.assign({ header: Kt.createFromConfig(r) }), - r.assign({ storage: Qt.createFromConfig(r) }), - r.assign({ pipeline: ln.createFromConfig(r) }), - r.assign({ translator: new Lt(r.language) }), - (r.plugin = new $t()), - r.search && - r.plugin.add({ - id: "search", - position: t.PluginPosition.Header, - component: Dt, - }), - r.pagination && - r.plugin.add({ - id: "pagination", - position: t.PluginPosition.Footer, - component: Ft, - }), - r.plugins && - r.plugins.forEach(function (t) { - return r.plugin.add(t); - }), - r - ); - }), - n - ); - })(); - function dn(t) { - var n, - e = It(); - return x( - "td", - r( - { - role: t.role, - colSpan: t.colSpan, - "data-column-id": t.column && t.column.id, - className: rt(et("td"), t.className, e.className.td), - style: r({}, t.style, e.style.td), - onClick: function (n) { - t.messageCell || - e.eventEmitter.emit("cellClick", n, t.cell, t.column, t.row); - }, - }, - (n = t.column) - ? "function" == typeof n.attributes - ? n.attributes(t.cell.data, t.row, t.column) - : n.attributes - : {} - ), - t.column && "function" == typeof t.column.formatter - ? t.column.formatter(t.cell.data, t.row, t.column) - : t.column && t.column.plugin - ? x(Gt, { - pluginId: t.column.id, - props: { column: t.column, cell: t.cell, row: t.row }, - }) - : t.cell.data - ); - } - function hn(t) { - var n = It(), - e = Ht(function (t) { - return t.header; - }); - return x( - "tr", - { - className: rt(et("tr"), n.className.tr), - onClick: function (e) { - t.messageRow || n.eventEmitter.emit("rowClick", e, t.row); - }, - }, - t.children - ? t.children - : t.row.cells.map(function (n, r) { - var o = (function (t) { - if (e) { - var n = Kt.leafColumns(e.columns); - if (n) return n[t]; - } - return null; - })(r); - return o && o.hidden - ? null - : x(dn, { key: n.id, cell: n, row: t.row, column: o }); - }) - ); - } - function _n(t) { - return x( - hn, - { messageRow: !0 }, - x(dn, { - role: "alert", - colSpan: t.colSpan, - messageCell: !0, - cell: new X(t.message), - className: rt(et("message"), t.className ? t.className : null), - }) - ); - } - function mn() { - var t = It(), - n = Ht(function (t) { - return t.data; - }), - e = Ht(function (t) { - return t.status; - }), - r = Ht(function (t) { - return t.header; - }), - o = At(), - i = function () { - return r ? r.visibleColumns.length : 0; - }; - return x( - "tbody", - { className: rt(et("tbody"), t.className.tbody) }, - n && - n.rows.map(function (t) { - return x(hn, { key: t.id, row: t }); - }), - e === l.Loading && - (!n || 0 === n.length) && - x(_n, { - message: o("loading"), - colSpan: i(), - className: rt(et("loading"), t.className.loading), - }), - e === l.Rendered && - n && - 0 === n.length && - x(_n, { - message: o("noRecordsFound"), - colSpan: i(), - className: rt(et("notfound"), t.className.notfound), - }), - e === l.Error && - x(_n, { - message: o("error"), - colSpan: i(), - className: rt(et("error"), t.className.error), - }) - ); - } - var vn = /*#__PURE__*/ (function (t) { - function n() { - return t.apply(this, arguments) || this; - } - o(n, t); - var r = n.prototype; - return ( - (r.validateProps = function () { - for (var t, n = a(this.props.columns); !(t = n()).done; ) { - var e = t.value; - void 0 === e.direction && (e.direction = 1), - 1 !== e.direction && - -1 !== e.direction && - Vt.error("Invalid sort direction " + e.direction); - } - }), - (r.compare = function (t, n) { - return t > n ? 1 : t < n ? -1 : 0; - }), - (r.compareWrapper = function (t, n) { - for (var e, r = 0, o = a(this.props.columns); !(e = o()).done; ) { - var i = e.value; - if (0 !== r) break; - var u = t.cells[i.index].data, - s = n.cells[i.index].data; - r |= - "function" == typeof i.compare - ? i.compare(u, s) * i.direction - : this.compare(u, s) * i.direction; - } - return r; - }), - (r._process = function (t) { - var n = [].concat(t.rows); - n.sort(this.compareWrapper.bind(this)); - var e = new J(n); - return (e.length = t.length), e; - }), - e(n, [ - { - key: "type", - get: function () { - return K.Sort; - }, - }, - ]), - n - ); - })(tt), - gn = function (t, n, e, o) { - return function (i) { - var u, - s = - null != (u = i.sort) && u.columns - ? i.sort.columns.map(function (t) { - return r({}, t); - }) - : [], - a = s.length, - l = s.find(function (n) { - return n.index === t; - }), - c = !1, - f = !1, - p = !1, - d = !1; - if ( - (void 0 !== l - ? e - ? -1 === l.direction - ? (p = !0) - : (d = !0) - : 1 === a - ? (d = !0) - : a > 1 && ((f = !0), (c = !0)) - : 0 === a - ? (c = !0) - : a > 0 && !e - ? ((c = !0), (f = !0)) - : a > 0 && e && (c = !0), - f && (s = []), - c) - ) - s.push({ index: t, direction: n, compare: o }); - else if (d) { - var h = s.indexOf(l); - s[h].direction = n; - } else if (p) { - var _ = s.indexOf(l); - s.splice(_, 1); - } - return r({}, i, { sort: { columns: s } }); - }; - }, - yn = function (t, n, e) { - return function (o) { - var i = (o.sort ? [].concat(o.sort.columns) : []).find(function (n) { - return n.index === t; - }); - return r( - {}, - o, - i ? gn(t, 1 === i.direction ? -1 : 1, n, e)(o) : gn(t, 1, n, e)(o) - ); - }; - }, - bn = /*#__PURE__*/ (function (t) { - function n() { - return t.apply(this, arguments) || this; - } - return ( - o(n, t), - (n.prototype._process = function (t) { - var n = {}; - return ( - this.props.url && - (n.url = this.props.url(t.url, this.props.columns)), - this.props.body && - (n.body = this.props.body(t.body, this.props.columns)), - r({}, t, n) - ); - }), - e(n, [ - { - key: "type", - get: function () { - return K.ServerSort; - }, - }, - ]), - n - ); - })(tt); - function wn(t) { - var n = It(), - e = jt().dispatch, - o = At(), - i = gt(0), - u = i[0], - s = i[1], - a = n.sort, - l = Ht(function (t) { - return t.sort; - }), - c = - "object" == typeof (null == a ? void 0 : a.server) - ? K.ServerSort - : K.Sort, - f = function () { - var t = n.pipeline.getStepsByType(c); - if (t.length) return t[0]; - }; - return ( - yt( - function () { - var t = - f() || - (c === K.ServerSort - ? new bn(r({ columns: l ? l.columns : [] }, a.server)) - : new vn({ columns: l ? l.columns : [] })); - return ( - n.pipeline.tryRegister(t), - function () { - return n.pipeline.unregister(t); - } - ); - }, - [n] - ), - yt( - function () { - if (l) { - var n, - e = l.columns.find(function (n) { - return n.index === t.index; - }); - e - ? (0 === u && (e.direction = null != (n = t.direction) ? n : 1), - s(e.direction)) - : s(0); - } - }, - [l] - ), - yt( - function () { - var t = f(); - t && l && t.setProps({ columns: l.columns }); - }, - [l] - ), - x("button", { - tabIndex: -1, - "aria-label": o("sort.sort" + (1 === u ? "Desc" : "Asc")), - title: o("sort.sort" + (1 === u ? "Desc" : "Asc")), - className: rt( - et("sort"), - et( - "sort", - (function (t) { - return 1 === t ? "asc" : -1 === t ? "desc" : "neutral"; - })(u) - ), - n.className.sort - ), - onClick: function (n) { - n.preventDefault(), - n.stopPropagation(), - e(yn(t.index, !0 === n.shiftKey && a.multiColumn, t.compare)); - }, - }) - ); - } - var xn = function (t, n) { - var e; - void 0 === n && (n = 100); - var r = Date.now(), - o = function () { - (r = Date.now()), t.apply(void 0, [].slice.call(arguments)); - }; - return function () { - var t = [].slice.call(arguments), - i = Date.now(), - u = i - r; - u >= n - ? o.apply(void 0, t) - : (e && clearTimeout(e), - (e = setTimeout(function () { - o.apply(void 0, t), (e = null); - }, n - u))); - }; - }; - function kn(t) { - var n, - e = function (t) { - return t instanceof MouseEvent - ? Math.floor(t.pageX) - : Math.floor(t.changedTouches[0].pageX); - }, - r = function (r) { - r.stopPropagation(); - var u = parseInt(t.thRef.current.style.width, 10) - e(r); - (n = xn(function (t) { - return o(t, u); - }, 10)), - document.addEventListener("mouseup", i), - document.addEventListener("touchend", i), - document.addEventListener("mousemove", n), - document.addEventListener("touchmove", n); - }, - o = function (n, r) { - n.stopPropagation(); - var o = t.thRef.current; - r + e(n) >= parseInt(o.style.minWidth, 10) && - (o.style.width = r + e(n) + "px"); - }, - i = function t(e) { - e.stopPropagation(), - document.removeEventListener("mouseup", t), - document.removeEventListener("mousemove", n), - document.removeEventListener("touchmove", n), - document.removeEventListener("touchend", t); - }; - return x("div", { - className: rt(et("th"), et("resizable")), - onMouseDown: r, - onTouchStart: r, - onClick: function (t) { - return t.stopPropagation(); - }, - }); - } - function Pn(t) { - var n = It(), - e = bt(null), - o = gt({}), - i = o[0], - u = o[1], - s = jt().dispatch; - yt( - function () { - if (n.fixedHeader && e.current) { - var t = e.current.offsetTop; - "number" == typeof t && u({ top: t }); - } - }, - [e] - ); - var a, - l = function () { - return null != t.column.sort; - }, - c = function (e) { - e.stopPropagation(), - l() && - s( - yn( - t.index, - !0 === e.shiftKey && n.sort.multiColumn, - t.column.sort.compare - ) - ); - }; - return x( - "th", - r( - { - ref: e, - "data-column-id": t.column && t.column.id, - className: rt( - et("th"), - l() ? et("th", "sort") : null, - n.fixedHeader ? et("th", "fixed") : null, - n.className.th - ), - onClick: c, - style: r( - {}, - n.style.th, - { minWidth: t.column.minWidth, width: t.column.width }, - i, - t.style - ), - onKeyDown: function (t) { - l() && 13 === t.which && c(t); - }, - rowSpan: t.rowSpan > 1 ? t.rowSpan : void 0, - colSpan: t.colSpan > 1 ? t.colSpan : void 0, - }, - (a = t.column) - ? "function" == typeof a.attributes - ? a.attributes(null, null, t.column) - : a.attributes - : {}, - l() ? { tabIndex: 0 } : {} - ), - x( - "div", - { className: et("th", "content") }, - void 0 !== t.column.name - ? t.column.name - : void 0 !== t.column.plugin - ? x(Gt, { pluginId: t.column.plugin.id, props: { column: t.column } }) - : null - ), - l() && x(wn, r({ index: t.index }, t.column.sort)), - t.column.resizable && - t.index < n.header.visibleColumns.length - 1 && - x(kn, { column: t.column, thRef: e }) - ); - } - function Sn() { - var t, - n = It(), - e = Ht(function (t) { - return t.header; - }); - return e - ? x( - "thead", - { key: e.id, className: rt(et("thead"), n.className.thead) }, - (t = Kt.tabularFormat(e.columns)).map(function (n, r) { - return (function (t, n, r) { - var o = Kt.leafColumns(e.columns); - return x( - hn, - null, - t.map(function (t) { - return t.hidden - ? null - : (function (t, n, e, r) { - var o = (function (t, n, e) { - var r = Kt.maximumDepth(t), - o = e - n; - return { - rowSpan: Math.floor(o - r - r / o), - colSpan: (t.columns && t.columns.length) || 1, - }; - })(t, n, r); - return x(Pn, { - column: t, - index: e, - colSpan: o.colSpan, - rowSpan: o.rowSpan, - }); - })(t, n, o.indexOf(t), r); - }) - ); - })(n, r, t.length); - }) - ) - : null; - } - var Nn = function (t) { - return function (n) { - return r({}, n, { header: t }); - }; - }; - function Cn() { - var t = It(), - n = bt(null), - e = jt().dispatch; - return ( - yt( - function () { - n && - e( - (function (t) { - return function (n) { - return r({}, n, { tableRef: t }); - }; - })(n) - ); - }, - [n] - ), - x( - "table", - { - ref: n, - role: "grid", - className: rt(et("table"), t.className.table), - style: r({}, t.style.table, { height: t.height }), - }, - x(Sn, null), - x(mn, null) - ) - ); - } - function En() { - var n = gt(!0), - e = n[0], - o = n[1], - i = bt(null), - u = It(); - return ( - yt( - function () { - 0 === i.current.children.length && o(!1); - }, - [i] - ), - e - ? x( - "div", - { - ref: i, - className: rt(et("head"), u.className.header), - style: r({}, u.style.header), - }, - x(Gt, { position: t.PluginPosition.Header }) - ) - : null - ); - } - function In() { - var n = bt(null), - e = gt(!0), - o = e[0], - i = e[1], - u = It(); - return ( - yt( - function () { - 0 === n.current.children.length && i(!1); - }, - [n] - ), - o - ? x( - "div", - { - ref: n, - className: rt(et("footer"), u.className.footer), - style: r({}, u.style.footer), - }, - x(Gt, { position: t.PluginPosition.Footer }) - ) - : null - ); - } - function Tn() { - var t = It(), - n = jt().dispatch, - e = Ht(function (t) { - return t.status; - }), - o = Ht(function (t) { - return t.data; - }), - i = Ht(function (t) { - return t.tableRef; - }), - u = { current: null }, - s = xn(function () { - try { - n(function (t) { - return r({}, t, { status: l.Loading }); - }); - var e = (function (e, o) { - try { - var i = Promise.resolve(t.pipeline.process()).then(function (t) { - n( - (function (t) { - return function (n) { - return t ? r({}, n, { data: t, status: l.Loaded }) : n; - }; - })(t) - ), - setTimeout(function () { - n(function (t) { - return t.status === l.Loaded - ? r({}, t, { status: l.Rendered }) - : t; - }); - }, 0); - }); - } catch (t) { - return o(t); - } - return i && i.then ? i.then(void 0, o) : i; - })(0, function (t) { - Vt.error(t), - n(function (t) { - return r({}, t, { data: null, status: l.Error }); - }); - }); - return Promise.resolve(e && e.then ? e.then(function () {}) : void 0); - } catch (t) { - return Promise.reject(t); - } - }, t.processingThrottleMs); - return ( - yt(function () { - return ( - n(Nn(t.header)), - s(), - t.pipeline.on("updated", s), - function () { - return t.pipeline.off("updated", s); - } - ); - }, []), - yt( - function () { - t.header && - e === l.Loaded && - null != o && - o.length && - n(Nn(t.header.adjustWidth(t, i, u))); - }, - [o, t, u] - ), - x( - "div", - { - role: "complementary", - className: rt( - "gridjs", - et("container"), - e === l.Loading ? et("loading") : null, - t.className.container - ), - style: r({}, t.style.container, { width: t.width }), - }, - e === l.Loading && x("div", { className: et("loading-bar") }), - x(En, null), - x( - "div", - { className: et("wrapper"), style: { height: t.height } }, - x(Cn, null) - ), - x(In, null), - x("div", { ref: u, id: "gridjs-temp", className: et("temp") }) - ) - ); - } - var Ln = /*#__PURE__*/ (function (t) { - function n(n) { - var e; - return ( - ((e = t.call(this) || this).config = void 0), - (e.plugin = void 0), - (e.config = new pn() - .assign({ instance: u(e), eventEmitter: u(e) }) - .update(n)), - (e.plugin = e.config.plugin), - e - ); - } - o(n, t); - var e = n.prototype; - return ( - (e.updateConfig = function (t) { - return this.config.update(t), this; - }), - (e.createElement = function () { - return x(fn.Provider, { value: this.config, children: x(Tn, {}) }); - }), - (e.forceRender = function () { - return ( - (this.config && this.config.container) || - Vt.error( - "Container is empty. Make sure you call render() before forceRender()", - !0 - ), - this.destroy(), - q(this.createElement(), this.config.container), - this - ); - }), - (e.destroy = function () { - this.config.pipeline.clearCache(), q(null, this.config.container); - }), - (e.render = function (t) { - return ( - t || Vt.error("Container element cannot be null", !0), - t.childNodes.length > 0 - ? (Vt.error( - "The container element " + - t + - " is not empty. Make sure the container is empty and call render() again" - ), - this) - : ((this.config.container = t), q(this.createElement(), t), this) - ); - }), - n - ); - })(Q); - (t.Cell = X), - (t.Component = S), - (t.Config = pn), - (t.Grid = Ln), - (t.Row = Z), - (t.className = et), - (t.createElement = x), - (t.createRef = function () { - return { current: null }; - }), - (t.h = x), - (t.html = G), - (t.useConfig = It), - (t.useEffect = yt), - (t.useRef = bt), - (t.useSelector = Ht), - (t.useState = gt), - (t.useStore = jt), - (t.useTranslator = At); -}); -//# sourceMappingURL=gridjs.umd.js.map diff --git a/CodeListLibrary_project/cll/static/js/lib/simple-datatables/simple-datatables.min.js b/CodeListLibrary_project/cll/static/js/lib/simple-datatables/simple-datatables.min.js index 53a0fe7cb..b44cd895b 100644 --- a/CodeListLibrary_project/cll/static/js/lib/simple-datatables/simple-datatables.min.js +++ b/CodeListLibrary_project/cll/static/js/lib/simple-datatables/simple-datatables.min.js @@ -1,8 +1,8 @@ /** - * Minified by jsDelivr using Terser v5.15.1. - * Original file: /npm/simple-datatables@7.2.0/dist/umd/simple-datatables.js + * Minified by jsDelivr using Terser v5.37.0. + * Original file: /npm/simple-datatables@10.0.0/dist/umd/simple-datatables.js * * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files */ -!function(t){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=t();else if("function"==typeof define&&define.amd)define([],t);else{("undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this).simpleDatatables=t()}}((function(){return function t(e,s,i){function n(o,r){if(!s[o]){if(!e[o]){var l="function"==typeof require&&require;if(!r&&l)return l(o,!0);if(a)return a(o,!0);var d=new Error("Cannot find module '"+o+"'");throw d.code="MODULE_NOT_FOUND",d}var c=s[o]={exports:{}};e[o][0].call(c.exports,(function(t){return n(e[o][1][t]||t)}),c,c.exports,t,e,s,i)}return s[o].exports}for(var a="function"==typeof require&&require,o=0;o<i.length;o++)n(i[o]);return n}({1:[function(t,e,s){(function(t){(function(){"use strict";const e=t=>"[object Object]"===Object.prototype.toString.call(t),i=t=>{let s=!1;try{s=JSON.parse(t)}catch(t){return!1}return!(null===s||!Array.isArray(s)&&!e(s))&&s},n=(t,e)=>{const s=document.createElement(t);if(e&&"object"==typeof e)for(const t in e)"html"===t?s.innerHTML=e[t]:s.setAttribute(t,e[t]);return s},a=t=>["#text","#comment"].includes(t.nodeName)?t.data:t.childNodes?t.childNodes.map((t=>a(t))).join(""):"",o=function(t){return t.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""")},r=function(t,e){let s=0,i=0;for(;s<t+1;)e[i].hidden||(s+=1),i+=1;return i-1},l=function(t,e){let s=t,i=0;for(;i<t;)e[t].hidden&&(s-=1),i++;return s};function d(t,e,s){var i;return"#text"===t.nodeName?i=s.document.createTextNode(t.data):"#comment"===t.nodeName?i=s.document.createComment(t.data):(e?i=s.document.createElementNS("http://www.w3.org/2000/svg",t.nodeName):"svg"===t.nodeName.toLowerCase()?(i=s.document.createElementNS("http://www.w3.org/2000/svg","svg"),e=!0):i=s.document.createElement(t.nodeName),t.attributes&&Object.entries(t.attributes).forEach((function(t){var e=t[0],s=t[1];return i.setAttribute(e,s)})),t.childNodes&&t.childNodes.forEach((function(t){return i.appendChild(d(t,e,s))})),s.valueDiffing&&(t.value&&(i instanceof HTMLButtonElement||i instanceof HTMLDataElement||i instanceof HTMLInputElement||i instanceof HTMLLIElement||i instanceof HTMLMeterElement||i instanceof HTMLOptionElement||i instanceof HTMLProgressElement||i instanceof HTMLParamElement)&&(i.value=t.value),t.checked&&i instanceof HTMLInputElement&&(i.checked=t.checked),t.selected&&i instanceof HTMLOptionElement&&(i.selected=t.selected))),i}var c=function(t,e){for(e=e.slice();e.length>0;){var s=e.splice(0,1)[0];t=t.childNodes[s]}return t};function h(t,e,s){var i,n,a,o=e[s._const.action],r=e[s._const.route];[s._const.addElement,s._const.addTextElement].includes(o)||(i=c(t,r));var l={diff:e,node:i};if(s.preDiffApply(l))return!0;switch(o){case s._const.addAttribute:if(!(i&&i instanceof Element))return!1;i.setAttribute(e[s._const.name],e[s._const.value]);break;case s._const.modifyAttribute:if(!(i&&i instanceof Element))return!1;i.setAttribute(e[s._const.name],e[s._const.newValue]),i instanceof HTMLInputElement&&"value"===e[s._const.name]&&(i.value=e[s._const.newValue]);break;case s._const.removeAttribute:if(!(i&&i instanceof Element))return!1;i.removeAttribute(e[s._const.name]);break;case s._const.modifyTextElement:if(!(i&&i instanceof Text))return!1;s.textDiff(i,i.data,e[s._const.oldValue],e[s._const.newValue]);break;case s._const.modifyValue:if(!i||void 0===i.value)return!1;i.value=e[s._const.newValue];break;case s._const.modifyComment:if(!(i&&i instanceof Comment))return!1;s.textDiff(i,i.data,e[s._const.oldValue],e[s._const.newValue]);break;case s._const.modifyChecked:if(!i||void 0===i.checked)return!1;i.checked=e[s._const.newValue];break;case s._const.modifySelected:if(!i||void 0===i.selected)return!1;i.selected=e[s._const.newValue];break;case s._const.replaceElement:i.parentNode.replaceChild(d(e[s._const.newValue],"svg"===e[s._const.newValue].nodeName.toLowerCase(),s),i);break;case s._const.relocateGroup:Array.apply(void 0,new Array(e[s._const.groupLength])).map((function(){return i.removeChild(i.childNodes[e[s._const.from]])})).forEach((function(t,n){0===n&&(a=i.childNodes[e[s._const.to]]),i.insertBefore(t,a||null)}));break;case s._const.removeElement:i.parentNode.removeChild(i);break;case s._const.addElement:var h=(u=r.slice()).splice(u.length-1,1)[0];if(!((i=c(t,u))instanceof Element))return!1;i.insertBefore(d(e[s._const.element],"http://www.w3.org/2000/svg"===i.namespaceURI,s),i.childNodes[h]||null);break;case s._const.removeTextElement:if(!i||3!==i.nodeType)return!1;i.parentNode.removeChild(i);break;case s._const.addTextElement:var u;if(h=(u=r.slice()).splice(u.length-1,1)[0],n=s.document.createTextNode(e[s._const.value]),!(i=c(t,u)).childNodes)return!1;i.insertBefore(n,i.childNodes[h]||null);break;default:console.log("unknown action")}return s.postDiffApply({diff:l.diff,node:l.node,newNode:n}),!0}function u(t,e,s){var i=t[e];t[e]=t[s],t[s]=i}var p=function(t){var e=[];return e.push(t.nodeName),"#text"!==t.nodeName&&"#comment"!==t.nodeName&&t.attributes&&(t.attributes.class&&e.push("".concat(t.nodeName,".").concat(t.attributes.class.replace(/ /g,"."))),t.attributes.id&&e.push("".concat(t.nodeName,"#").concat(t.attributes.id))),e},f=function(t){var e={},s={};return t.forEach((function(t){p(t).forEach((function(t){var i=t in e;i||t in s?i&&(delete e[t],s[t]=!0):e[t]=!0}))})),e},m=function(t,e){var s=f(t),i=f(e),n={};return Object.keys(s).forEach((function(t){i[t]&&(n[t]=!0)})),n},g=function(t){return delete t.outerDone,delete t.innerDone,delete t.valueDone,!t.childNodes||t.childNodes.every(g)},b=function(t){if(Object.prototype.hasOwnProperty.call(t,"data"))return{nodeName:"#text"===t.nodeName?"#text":"#comment",data:t.data};var e={nodeName:t.nodeName};return Object.prototype.hasOwnProperty.call(t,"attributes")&&(e.attributes=t.attributes),Object.prototype.hasOwnProperty.call(t,"checked")&&(e.checked=t.checked),Object.prototype.hasOwnProperty.call(t,"value")&&(e.value=t.value),Object.prototype.hasOwnProperty.call(t,"selected")&&(e.selected=t.selected),Object.prototype.hasOwnProperty.call(t,"childNodes")&&(e.childNodes=t.childNodes.map((function(t){return b(t)}))),e},v=function(t,e){if(!["nodeName","value","checked","selected","data"].every((function(s){return t[s]===e[s]})))return!1;if(Object.prototype.hasOwnProperty.call(t,"data"))return!0;if(Boolean(t.attributes)!==Boolean(e.attributes))return!1;if(Boolean(t.childNodes)!==Boolean(e.childNodes))return!1;if(t.attributes){var s=Object.keys(t.attributes),i=Object.keys(e.attributes);if(s.length!==i.length)return!1;if(!s.every((function(s){return t.attributes[s]===e.attributes[s]})))return!1}if(t.childNodes){if(t.childNodes.length!==e.childNodes.length)return!1;if(!t.childNodes.every((function(t,s){return v(t,e.childNodes[s])})))return!1}return!0},w=function(t,e,s,i,n){if(void 0===n&&(n=!1),!t||!e)return!1;if(t.nodeName!==e.nodeName)return!1;if(["#text","#comment"].includes(t.nodeName))return!!n||t.data===e.data;if(t.nodeName in s)return!0;if(t.attributes&&e.attributes){if(t.attributes.id){if(t.attributes.id!==e.attributes.id)return!1;if("".concat(t.nodeName,"#").concat(t.attributes.id)in s)return!0}if(t.attributes.class&&t.attributes.class===e.attributes.class&&"".concat(t.nodeName,".").concat(t.attributes.class.replace(/ /g,"."))in s)return!0}if(i)return!0;var a=t.childNodes?t.childNodes.slice().reverse():[],o=e.childNodes?e.childNodes.slice().reverse():[];if(a.length!==o.length)return!1;if(n)return a.every((function(t,e){return t.nodeName===o[e].nodeName}));var r=m(a,o);return a.every((function(t,e){return w(t,o[e],r,!0,!0)}))},_=function(t,e){return Array.apply(void 0,new Array(t)).map((function(){return e}))},y=function(t,e){for(var s=t.childNodes?t.childNodes:[],i=e.childNodes?e.childNodes:[],n=_(s.length,!1),a=_(i.length,!1),o=[],r=function(){return arguments[1]},l=!1,d=function(){var t=function(t,e,s,i){var n=0,a=[],o=t.length,r=e.length,l=Array.apply(void 0,new Array(o+1)).map((function(){return[]})),d=m(t,e),c=o===r;c&&t.some((function(t,s){var i=p(t),n=p(e[s]);return i.length!==n.length?(c=!1,!0):(i.some((function(t,e){if(t!==n[e])return c=!1,!0})),!c||void 0)}));for(var h=0;h<o;h++)for(var u=t[h],f=0;f<r;f++){var g=e[f];s[h]||i[f]||!w(u,g,d,c)?l[h+1][f+1]=0:(l[h+1][f+1]=l[h][f]?l[h][f]+1:1,l[h+1][f+1]>=n&&(n=l[h+1][f+1],a=[h+1,f+1]))}return 0!==n&&{oldValue:a[0]-n,newValue:a[1]-n,length:n}}(s,i,n,a);t?(o.push(t),Array.apply(void 0,new Array(t.length)).map(r).forEach((function(e){return function(t,e,s,i){t[s.oldValue+i]=!0,e[s.newValue+i]=!0}(n,a,t,e)}))):l=!0};!l;)d();return t.subsets=o,t.subsetsAge=100,o},x=function(){function t(){this.list=[]}return t.prototype.add=function(t){var e;(e=this.list).push.apply(e,t)},t.prototype.forEach=function(t){this.list.forEach((function(e){return t(e)}))},t}(),N=function(){function t(t){void 0===t&&(t={});var e=this;Object.entries(t).forEach((function(t){var s=t[0],i=t[1];return e[s]=i}))}return t.prototype.toString=function(){return JSON.stringify(this)},t.prototype.setValue=function(t,e){return this[t]=e,this},t}();function D(t,e){var s,i,n=t;for(e=e.slice();e.length>0;)i=e.splice(0,1)[0],s=n,n=n.childNodes?n.childNodes[i]:void 0;return{node:n,parentNode:s,nodeIndex:i}}function M(t,e,s){return e.forEach((function(e){!function(t,e,s){var i,n,a,o;if(![s._const.addElement,s._const.addTextElement].includes(e[s._const.action])){var r=D(t,e[s._const.route]);n=r.node,a=r.parentNode,o=r.nodeIndex}var l,d,c=[],h={diff:e,node:n};if(s.preVirtualDiffApply(h))return!0;switch(e[s._const.action]){case s._const.addAttribute:n.attributes||(n.attributes={}),n.attributes[e[s._const.name]]=e[s._const.value],"checked"===e[s._const.name]?n.checked=!0:"selected"===e[s._const.name]?n.selected=!0:"INPUT"===n.nodeName&&"value"===e[s._const.name]&&(n.value=e[s._const.value]);break;case s._const.modifyAttribute:n.attributes[e[s._const.name]]=e[s._const.newValue];break;case s._const.removeAttribute:delete n.attributes[e[s._const.name]],0===Object.keys(n.attributes).length&&delete n.attributes,"checked"===e[s._const.name]?n.checked=!1:"selected"===e[s._const.name]?delete n.selected:"INPUT"===n.nodeName&&"value"===e[s._const.name]&&delete n.value;break;case s._const.modifyTextElement:n.data=e[s._const.newValue];break;case s._const.modifyValue:n.value=e[s._const.newValue];break;case s._const.modifyComment:n.data=e[s._const.newValue];break;case s._const.modifyChecked:n.checked=e[s._const.newValue];break;case s._const.modifySelected:n.selected=e[s._const.newValue];break;case s._const.replaceElement:l=e[s._const.newValue],a.childNodes[o]=l;break;case s._const.relocateGroup:n.childNodes.splice(e[s._const.from],e[s._const.groupLength]).reverse().forEach((function(t){return n.childNodes.splice(e[s._const.to],0,t)})),n.subsets&&n.subsets.forEach((function(t){if(e[s._const.from]<e[s._const.to]&&t.oldValue<=e[s._const.to]&&t.oldValue>e[s._const.from])t.oldValue-=e[s._const.groupLength],(i=t.oldValue+t.length-e[s._const.to])>0&&(c.push({oldValue:e[s._const.to]+e[s._const.groupLength],newValue:t.newValue+t.length-i,length:i}),t.length-=i);else if(e[s._const.from]>e[s._const.to]&&t.oldValue>e[s._const.to]&&t.oldValue<e[s._const.from]){var i;t.oldValue+=e[s._const.groupLength],(i=t.oldValue+t.length-e[s._const.to])>0&&(c.push({oldValue:e[s._const.to]+e[s._const.groupLength],newValue:t.newValue+t.length-i,length:i}),t.length-=i)}else t.oldValue===e[s._const.from]&&(t.oldValue=e[s._const.to])}));break;case s._const.removeElement:a.childNodes.splice(o,1),a.subsets&&a.subsets.forEach((function(t){t.oldValue>o?t.oldValue-=1:t.oldValue===o?t.delete=!0:t.oldValue<o&&t.oldValue+t.length>o&&(t.oldValue+t.length-1===o?t.length--:(c.push({newValue:t.newValue+o-t.oldValue,oldValue:o,length:t.length-o+t.oldValue-1}),t.length=o-t.oldValue))})),n=a;break;case s._const.addElement:var u=(d=e[s._const.route].slice()).splice(d.length-1,1)[0];n=null===(i=D(t,d))||void 0===i?void 0:i.node,l=e[s._const.element],n.childNodes||(n.childNodes=[]),u>=n.childNodes.length?n.childNodes.push(l):n.childNodes.splice(u,0,l),n.subsets&&n.subsets.forEach((function(t){if(t.oldValue>=u)t.oldValue+=1;else if(t.oldValue<u&&t.oldValue+t.length>u){var e=t.oldValue+t.length-u;c.push({newValue:t.newValue+t.length-e,oldValue:u+1,length:e}),t.length-=e}}));break;case s._const.removeTextElement:a.childNodes.splice(o,1),"TEXTAREA"===a.nodeName&&delete a.value,a.subsets&&a.subsets.forEach((function(t){t.oldValue>o?t.oldValue-=1:t.oldValue===o?t.delete=!0:t.oldValue<o&&t.oldValue+t.length>o&&(t.oldValue+t.length-1===o?t.length--:(c.push({newValue:t.newValue+o-t.oldValue,oldValue:o,length:t.length-o+t.oldValue-1}),t.length=o-t.oldValue))})),n=a;break;case s._const.addTextElement:var p=(d=e[s._const.route].slice()).splice(d.length-1,1)[0];(l={}).nodeName="#text",l.data=e[s._const.value],(n=D(t,d).node).childNodes||(n.childNodes=[]),p>=n.childNodes.length?n.childNodes.push(l):n.childNodes.splice(p,0,l),"TEXTAREA"===n.nodeName&&(n.value=e[s._const.newValue]),n.subsets&&n.subsets.forEach((function(t){if(t.oldValue>=p&&(t.oldValue+=1),t.oldValue<p&&t.oldValue+t.length>p){var e=t.oldValue+t.length-p;c.push({newValue:t.newValue+t.length-e,oldValue:p+1,length:e}),t.length-=e}}));break;default:console.log("unknown action")}n.subsets&&(n.subsets=n.subsets.filter((function(t){return!t.delete&&t.oldValue!==t.newValue})),c.length&&(n.subsets=n.subsets.concat(c))),s.postVirtualDiffApply({node:h.node,diff:h.diff,newNode:l})}(t,e,s)})),!0}function E(t,e){void 0===e&&(e={});var s={nodeName:t.nodeName};return t instanceof Text||t instanceof Comment?s.data=t.data:(t.attributes&&t.attributes.length>0&&(s.attributes={},Array.prototype.slice.call(t.attributes).forEach((function(t){return s.attributes[t.name]=t.value}))),t instanceof HTMLTextAreaElement?s.value=t.value:t.childNodes&&t.childNodes.length>0&&(s.childNodes=[],Array.prototype.slice.call(t.childNodes).forEach((function(t){return s.childNodes.push(E(t,e))}))),e.valueDiffing&&(t instanceof HTMLInputElement&&["radio","checkbox"].includes(t.type.toLowerCase())&&void 0!==t.checked?s.checked=t.checked:(t instanceof HTMLButtonElement||t instanceof HTMLDataElement||t instanceof HTMLInputElement||t instanceof HTMLLIElement||t instanceof HTMLMeterElement||t instanceof HTMLOptionElement||t instanceof HTMLProgressElement||t instanceof HTMLParamElement)&&(s.value=t.value),t instanceof HTMLOptionElement&&(s.selected=t.selected))),s}var O=/<\s*\/*[a-zA-Z:_][a-zA-Z0-9:_\-.]*\s*(?:"[^"]*"['"]*|'[^']*'['"]*|[^'"/>])*\/*\s*>|<!--(?:.|\n|\r)*?-->/g,V=Object.create?Object.create(null):{},$=/\s([^'"/\s><]+?)[\s/>]|([^\s=]+)=\s?(".*?"|'.*?')/g;function C(t){return t.replace(/</g,"<").replace(/>/g,">").replace(/&/g,"&")}var L={area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,menuItem:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0},S=function(t){var e={nodeName:"",attributes:{}},s=!1,i=t.match(/<\/?([^\s]+?)[/\s>]/);if(i&&(e.nodeName=i[1].toUpperCase(),(L[i[1]]||"/"===t.charAt(t.length-2))&&(s=!0),e.nodeName.startsWith("!--"))){var n=t.indexOf("--\x3e");return{type:"comment",node:{nodeName:"#comment",data:-1!==n?t.slice(4,n):""},voidElement:s}}for(var a=new RegExp($),o=null,r=!1;!r;)if(null===(o=a.exec(t)))r=!0;else if(o[0].trim())if(o[1]){var l=o[1].trim(),d=[l,""];l.indexOf("=")>-1&&(d=l.split("=")),e.attributes[d[0]]=d[1],a.lastIndex--}else o[2]&&(e.attributes[o[2]]=o[3].trim().substring(1,o[3].length-1));return{type:"tag",node:e,voidElement:s}},k=function(t,e){void 0===e&&(e={components:V});var s,i=[],n=-1,a=[],o=!1;if(0!==t.indexOf("<")){var r=t.indexOf("<");i.push({nodeName:"#text",data:-1===r?t:t.substring(0,r)})}return t.replace(O,(function(r,l){if(o){if(r!=="</".concat(s.node.nodeName,">"))return"";o=!1}var d="/"!==r.charAt(1),c=r.startsWith("\x3c!--"),h=l+r.length,u=t.charAt(h);if(c){var p=S(r).node;if(n<0)return i.push(p),"";var f=a[n];return f&&p.nodeName&&(f.node.childNodes||(f.node.childNodes=[]),f.node.childNodes.push(p)),""}if(d){s=S(r),n++,"tag"===s.type&&e.components[s.node.nodeName]&&(s.type="component",o=!0),s.voidElement||o||!u||"<"===u||(s.node.childNodes||(s.node.childNodes=[]),s.node.childNodes.push({nodeName:"#text",data:C(t.slice(h,t.indexOf("<",h)))})),0===n&&s.node.nodeName&&i.push(s.node);var m=a[n-1];m&&s.node.nodeName&&(m.node.childNodes||(m.node.childNodes=[]),m.node.childNodes.push(s.node)),a[n]=s}if((!d||s.voidElement)&&(n>-1&&(s.voidElement||s.node.nodeName===r.slice(2,-1).toUpperCase())&&--n>-1&&(s=a[n]),!o&&"<"!==u&&u)){var g=-1===n?i:a[n].node.childNodes||[],b=t.indexOf("<",h),v=C(t.slice(h,-1===b?void 0:b));g.push({nodeName:"#text",data:v})}return""})),i[0]},T=function(){function t(t,e,s){this.options=s,this.t1="undefined"!=typeof Element&&t instanceof Element?E(t,this.options):"string"==typeof t?k(t,this.options):JSON.parse(JSON.stringify(t)),this.t2="undefined"!=typeof Element&&e instanceof Element?E(e,this.options):"string"==typeof e?k(e,this.options):JSON.parse(JSON.stringify(e)),this.diffcount=0,this.foundAll=!1,this.debug&&(this.t1Orig="undefined"!=typeof Element&&t instanceof Element?E(t,this.options):"string"==typeof t?k(t,this.options):JSON.parse(JSON.stringify(t)),this.t2Orig="undefined"!=typeof Element&&e instanceof Element?E(e,this.options):"string"==typeof e?k(e,this.options):JSON.parse(JSON.stringify(e))),this.tracker=new x}return t.prototype.init=function(){return this.findDiffs(this.t1,this.t2)},t.prototype.findDiffs=function(t,e){var s;do{if(this.options.debug&&(this.diffcount+=1,this.diffcount>this.options.diffcap))throw new Error("surpassed diffcap:".concat(JSON.stringify(this.t1Orig)," -> ").concat(JSON.stringify(this.t2Orig)));0===(s=this.findNextDiff(t,e,[])).length&&(v(t,e)||(this.foundAll?console.error("Could not find remaining diffs!"):(this.foundAll=!0,g(t),s=this.findNextDiff(t,e,[])))),s.length>0&&(this.foundAll=!1,this.tracker.add(s),M(t,s,this.options))}while(s.length>0);return this.tracker.list},t.prototype.findNextDiff=function(t,e,s){var i,n;if(this.options.maxDepth&&s.length>this.options.maxDepth)return[];if(!t.outerDone){if(i=this.findOuterDiff(t,e,s),this.options.filterOuterDiff&&(n=this.options.filterOuterDiff(t,e,i))&&(i=n),i.length>0)return t.outerDone=!0,i;t.outerDone=!0}if(Object.prototype.hasOwnProperty.call(t,"data"))return[];if(!t.innerDone){if((i=this.findInnerDiff(t,e,s)).length>0)return i;t.innerDone=!0}if(this.options.valueDiffing&&!t.valueDone){if((i=this.findValueDiff(t,e,s)).length>0)return t.valueDone=!0,i;t.valueDone=!0}return[]},t.prototype.findOuterDiff=function(t,e,s){var i,n,a,o,r,l,d=[];if(t.nodeName!==e.nodeName){if(!s.length)throw new Error("Top level nodes have to be of the same kind.");return[(new N).setValue(this.options._const.action,this.options._const.replaceElement).setValue(this.options._const.oldValue,b(t)).setValue(this.options._const.newValue,b(e)).setValue(this.options._const.route,s)]}if(s.length&&this.options.diffcap<Math.abs((t.childNodes||[]).length-(e.childNodes||[]).length))return[(new N).setValue(this.options._const.action,this.options._const.replaceElement).setValue(this.options._const.oldValue,b(t)).setValue(this.options._const.newValue,b(e)).setValue(this.options._const.route,s)];if(Object.prototype.hasOwnProperty.call(t,"data")&&t.data!==e.data)return"#text"===t.nodeName?[(new N).setValue(this.options._const.action,this.options._const.modifyTextElement).setValue(this.options._const.route,s).setValue(this.options._const.oldValue,t.data).setValue(this.options._const.newValue,e.data)]:[(new N).setValue(this.options._const.action,this.options._const.modifyComment).setValue(this.options._const.route,s).setValue(this.options._const.oldValue,t.data).setValue(this.options._const.newValue,e.data)];for(n=t.attributes?Object.keys(t.attributes).sort():[],a=e.attributes?Object.keys(e.attributes).sort():[],o=n.length,l=0;l<o;l++)i=n[l],-1===(r=a.indexOf(i))?d.push((new N).setValue(this.options._const.action,this.options._const.removeAttribute).setValue(this.options._const.route,s).setValue(this.options._const.name,i).setValue(this.options._const.value,t.attributes[i])):(a.splice(r,1),t.attributes[i]!==e.attributes[i]&&d.push((new N).setValue(this.options._const.action,this.options._const.modifyAttribute).setValue(this.options._const.route,s).setValue(this.options._const.name,i).setValue(this.options._const.oldValue,t.attributes[i]).setValue(this.options._const.newValue,e.attributes[i])));for(o=a.length,l=0;l<o;l++)i=a[l],d.push((new N).setValue(this.options._const.action,this.options._const.addAttribute).setValue(this.options._const.route,s).setValue(this.options._const.name,i).setValue(this.options._const.value,e.attributes[i]));return d},t.prototype.findInnerDiff=function(t,e,s){var i=t.childNodes?t.childNodes.slice():[],n=e.childNodes?e.childNodes.slice():[],a=Math.max(i.length,n.length),o=Math.abs(i.length-n.length),r=[],l=0;if(!this.options.maxChildCount||a<this.options.maxChildCount){var d=Boolean(t.subsets&&t.subsetsAge--),c=d?t.subsets:t.childNodes&&e.childNodes?y(t,e):[];if(c.length>0&&(r=this.attemptGroupRelocation(t,e,c,s,d)).length>0)return r}for(var h=0;h<a;h+=1){var u=i[h],p=n[h];if(o&&(u&&!p?"#text"===u.nodeName?(r.push((new N).setValue(this.options._const.action,this.options._const.removeTextElement).setValue(this.options._const.route,s.concat(l)).setValue(this.options._const.value,u.data)),l-=1):(r.push((new N).setValue(this.options._const.action,this.options._const.removeElement).setValue(this.options._const.route,s.concat(l)).setValue(this.options._const.element,b(u))),l-=1):p&&!u&&("#text"===p.nodeName?r.push((new N).setValue(this.options._const.action,this.options._const.addTextElement).setValue(this.options._const.route,s.concat(l)).setValue(this.options._const.value,p.data)):r.push((new N).setValue(this.options._const.action,this.options._const.addElement).setValue(this.options._const.route,s.concat(l)).setValue(this.options._const.element,b(p))))),u&&p)if(!this.options.maxChildCount||a<this.options.maxChildCount)r=r.concat(this.findNextDiff(u,p,s.concat(l)));else if(!v(u,p))if(i.length>n.length)"#text"===u.nodeName?r.push((new N).setValue(this.options._const.action,this.options._const.removeTextElement).setValue(this.options._const.route,s.concat(l)).setValue(this.options._const.value,u.data)):r.push((new N).setValue(this.options._const.action,this.options._const.removeElement).setValue(this.options._const.element,b(u)).setValue(this.options._const.route,s.concat(l))),i.splice(h,1),h-=1,l-=1,o-=1;else if(i.length<n.length){var f=b(p);r=r.concat([(new N).setValue(this.options._const.action,this.options._const.addElement).setValue(this.options._const.element,f).setValue(this.options._const.route,s.concat(l))]),i.splice(h,0,f),o-=1}else r=r.concat([(new N).setValue(this.options._const.action,this.options._const.replaceElement).setValue(this.options._const.oldValue,b(u)).setValue(this.options._const.newValue,b(p)).setValue(this.options._const.route,s.concat(l))]);l+=1}return t.innerDone=!0,r},t.prototype.attemptGroupRelocation=function(t,e,s,i,n){for(var a,o,r,l,d,c,h=function(t,e,s){var i=t.childNodes?_(t.childNodes.length,!0):[],n=e.childNodes?_(e.childNodes.length,!0):[],a=0;return s.forEach((function(t){for(var e=t.oldValue+t.length,s=t.newValue+t.length,o=t.oldValue;o<e;o+=1)i[o]=a;for(o=t.newValue;o<s;o+=1)n[o]=a;a+=1})),{gaps1:i,gaps2:n}}(t,e,s),u=h.gaps1,p=h.gaps2,f=Math.min(u.length,p.length),m=[],g=0,v=0;g<f;v+=1,g+=1)if(!n||!0!==u[g]&&!0!==p[g])if(!0===u[g])if("#text"===(l=t.childNodes[v]).nodeName)if("#text"===e.childNodes[g].nodeName){if(l.data!==e.childNodes[g].data){for(c=v;t.childNodes.length>c+1&&"#text"===t.childNodes[c+1].nodeName;)if(c+=1,e.childNodes[g].data===t.childNodes[c].data){d=!0;break}if(!d)return m.push((new N).setValue(this.options._const.action,this.options._const.modifyTextElement).setValue(this.options._const.route,i.concat(g)).setValue(this.options._const.oldValue,l.data).setValue(this.options._const.newValue,e.childNodes[g].data)),m}}else m.push((new N).setValue(this.options._const.action,this.options._const.removeTextElement).setValue(this.options._const.route,i.concat(g)).setValue(this.options._const.value,l.data)),u.splice(g,1),f=Math.min(u.length,p.length),g-=1;else m.push((new N).setValue(this.options._const.action,this.options._const.removeElement).setValue(this.options._const.route,i.concat(g)).setValue(this.options._const.element,b(l))),u.splice(g,1),f=Math.min(u.length,p.length),g-=1;else if(!0===p[g])"#text"===(l=e.childNodes[g]).nodeName?(m.push((new N).setValue(this.options._const.action,this.options._const.addTextElement).setValue(this.options._const.route,i.concat(g)).setValue(this.options._const.value,l.data)),u.splice(g,0,!0),f=Math.min(u.length,p.length),v-=1):(m.push((new N).setValue(this.options._const.action,this.options._const.addElement).setValue(this.options._const.route,i.concat(g)).setValue(this.options._const.element,b(l))),u.splice(g,0,!0),f=Math.min(u.length,p.length),v-=1);else if(u[g]!==p[g]){if(m.length>0)return m;if(r=s[u[g]],(o=Math.min(r.newValue,t.childNodes.length-r.length))!==r.oldValue){a=!1;for(var y=0;y<r.length;y+=1)w(t.childNodes[o+y],t.childNodes[r.oldValue+y],{},!1,!0)||(a=!0);if(a)return[(new N).setValue(this.options._const.action,this.options._const.relocateGroup).setValue(this.options._const.groupLength,r.length).setValue(this.options._const.from,r.oldValue).setValue(this.options._const.to,o).setValue(this.options._const.route,i)]}}return m},t.prototype.findValueDiff=function(t,e,s){var i=[];return t.selected!==e.selected&&i.push((new N).setValue(this.options._const.action,this.options._const.modifySelected).setValue(this.options._const.oldValue,t.selected).setValue(this.options._const.newValue,e.selected).setValue(this.options._const.route,s)),(t.value||e.value)&&t.value!==e.value&&"OPTION"!==t.nodeName&&i.push((new N).setValue(this.options._const.action,this.options._const.modifyValue).setValue(this.options._const.oldValue,t.value||"").setValue(this.options._const.newValue,e.value||"").setValue(this.options._const.route,s)),t.checked!==e.checked&&i.push((new N).setValue(this.options._const.action,this.options._const.modifyChecked).setValue(this.options._const.oldValue,t.checked).setValue(this.options._const.newValue,e.checked).setValue(this.options._const.route,s)),i},t}(),A={debug:!1,diffcap:10,maxDepth:!1,maxChildCount:50,valueDiffing:!0,textDiff:function(t,e,s,i){t.data=i},preVirtualDiffApply:function(){},postVirtualDiffApply:function(){},preDiffApply:function(){},postDiffApply:function(){},filterOuterDiff:null,compress:!1,_const:!1,document:!("undefined"==typeof window||!window.document)&&window.document,components:[]},P=function(){function t(t){if(void 0===t&&(t={}),Object.entries(A).forEach((function(e){var s=e[0],i=e[1];Object.prototype.hasOwnProperty.call(t,s)||(t[s]=i)})),!t._const){var e=["addAttribute","modifyAttribute","removeAttribute","modifyTextElement","relocateGroup","removeElement","addElement","removeTextElement","addTextElement","replaceElement","modifyValue","modifyChecked","modifySelected","modifyComment","action","route","oldValue","newValue","element","group","groupLength","from","to","name","value","data","attributes","nodeName","childNodes","checked","selected"],s={};t.compress?e.forEach((function(t,e){return s[t]=e})):e.forEach((function(t){return s[t]=t})),t._const=s}this.options=t}return t.prototype.apply=function(t,e){return function(t,e,s){return e.every((function(e){return h(t,e,s)}))}(t,e,this.options)},t.prototype.undo=function(t,e){return function(t,e,s){(e=e.slice()).reverse(),e.forEach((function(e){!function(t,e,s){switch(e[s._const.action]){case s._const.addAttribute:e[s._const.action]=s._const.removeAttribute,h(t,e,s);break;case s._const.modifyAttribute:u(e,s._const.oldValue,s._const.newValue),h(t,e,s);break;case s._const.removeAttribute:e[s._const.action]=s._const.addAttribute,h(t,e,s);break;case s._const.modifyTextElement:case s._const.modifyValue:case s._const.modifyComment:case s._const.modifyChecked:case s._const.modifySelected:case s._const.replaceElement:u(e,s._const.oldValue,s._const.newValue),h(t,e,s);break;case s._const.relocateGroup:u(e,s._const.from,s._const.to),h(t,e,s);break;case s._const.removeElement:e[s._const.action]=s._const.addElement,h(t,e,s);break;case s._const.addElement:e[s._const.action]=s._const.removeElement,h(t,e,s);break;case s._const.removeTextElement:e[s._const.action]=s._const.addTextElement,h(t,e,s);break;case s._const.addTextElement:e[s._const.action]=s._const.removeTextElement,h(t,e,s);break;default:console.log("unknown action")}}(t,e,s)}))}(t,e,this.options)},t.prototype.diff=function(t,e){return new T(t,e,this.options).init()},t}();const H=(t,e,s,{classes:i,format:n,hiddenHeader:a,sortable:o,scrollY:r,type:l},{noColumnWidths:d,unhideHeader:c})=>({nodeName:"TR",childNodes:t.map(((t,h)=>{const u=e[h]||{type:l,format:n,sortable:!0,searchable:!0};if(u.hidden)return;const p={};if(u.sortable&&o&&(!r.length||c)&&(u.filter?p["data-filterable"]="true":p["data-sortable"]="true"),u.headerClass&&(p.class=u.headerClass),s.sort&&s.sort.column===h){const t="asc"===s.sort.dir?i.ascending:i.descending;p.class=p.class?`${p.class} ${t}`:t,p["aria-sort"]="asc"===s.sort.dir?"ascending":"descending"}else s.filters[h]&&(p.class=p.class?`${p.class} ${i.filterActive}`:i.filterActive);let f="";s.widths[h]&&!d&&(f+=`width: ${s.widths[h]}%;`),r.length&&!c&&(f+="padding-bottom: 0;padding-top: 0;border: 0;"),f.length&&(p.style=f),u.headerClass&&(p.class=u.headerClass);const m="html"===t.type?t.data:[{nodeName:"#text",data:t.text??String(t.data)}];return{nodeName:"TH",attributes:p,childNodes:!a&&!r.length||c?u.sortable&&o?[{nodeName:"a",attributes:{href:"#",class:u.filter?i.filter:i.sorter},childNodes:m}]:m:[{nodeName:"#text",data:""}]}})).filter((t=>t))}),R=(t,e,s,i,n,a,{classes:o,hiddenHeader:r,header:l,footer:d,format:c,sortable:h,scrollY:u,type:p,rowRender:f,tabIndex:m},{noColumnWidths:g,unhideHeader:b,renderHeader:v})=>{const w={nodeName:"TABLE",attributes:{...t},childNodes:[{nodeName:"TBODY",childNodes:s.map((({row:t,index:e})=>{const s={nodeName:"TR",attributes:{"data-index":String(e)},childNodes:t.map(((t,s)=>{const a=i[s]||{type:p,format:c,sortable:!0,searchable:!0};if(a.hidden)return;const o="html"===a.type?{nodeName:"TD",childNodes:t.data}:{nodeName:"TD",childNodes:[{nodeName:"#text",data:t.text??String(t.data)}]};if(l||d||!n.widths[s]||g||(o.attributes={style:`width: ${n.widths[s]}%;`}),a.cellClass&&(o.attributes||(o.attributes={}),o.attributes.class=a.cellClass),a.render){const i=a.render(t.data,o,e,s);if(i){if("string"!=typeof i)return i;{const t=k(`<td>${i}</td>`);1===t.childNodes.length&&["#text","#comment"].includes(t.childNodes[0].nodeName)?o.childNodes[0].data=i:o.childNodes=t.childNodes}}}return o})).filter((t=>t))};if(e===a&&(s.attributes.class=o.cursor),f){const i=f(t,s,e);if(i){if("string"!=typeof i)return i;{const t=k(`<tr>${i}</tr>`);!t.childNodes||1===t.childNodes.length&&["#text","#comment"].includes(t.childNodes[0].nodeName)?s.childNodes[0].data=i:s.childNodes=t.childNodes}}}return s}))}]};if(w.attributes.class=w.attributes.class?`${w.attributes.class} ${o.table}`:o.table,l||d||v){const t=H(e,i,n,{classes:o,hiddenHeader:r,sortable:h,scrollY:u},{noColumnWidths:g,unhideHeader:b});if(l||v){const e={nodeName:"THEAD",childNodes:[t]};!u.length&&!r||b||(e.attributes={style:"height: 0px;"}),w.childNodes.unshift(e)}if(d){const e={nodeName:"TFOOT",childNodes:[l?structuredClone(t):t]};!u.length&&!r||b||(e.attributes={style:"height: 0px;"}),w.childNodes.push(e)}}return!1!==m&&(w.attributes.tabindex=String(m)),w};"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof window?window:void 0!==t||"undefined"!=typeof self&&self;var I={},Y={get exports(){return I},set exports(t){I=t}};Y.exports=function(){var t=6e4,e=36e5,s="millisecond",i="second",n="minute",a="hour",o="day",r="week",l="month",d="quarter",c="year",h="date",u="Invalid Date",p=/^(\d{4})[-/]?(\d{1,2})?[-/]?(\d{0,2})[Tt\s]*(\d{1,2})?:?(\d{1,2})?:?(\d{1,2})?[.:]?(\d+)?$/,f=/\[([^\]]+)]|Y{1,4}|M{1,4}|D{1,2}|d{1,4}|H{1,2}|h{1,2}|a|A|m{1,2}|s{1,2}|Z{1,2}|SSS/g,m={name:"en",weekdays:"Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday".split("_"),months:"January_February_March_April_May_June_July_August_September_October_November_December".split("_"),ordinal:function(t){var e=["th","st","nd","rd"],s=t%100;return"["+t+(e[(s-20)%10]||e[s]||e[0])+"]"}},g=function(t,e,s){var i=String(t);return!i||i.length>=e?t:""+Array(e+1-i.length).join(s)+t},b={s:g,z:function(t){var e=-t.utcOffset(),s=Math.abs(e),i=Math.floor(s/60),n=s%60;return(e<=0?"+":"-")+g(i,2,"0")+":"+g(n,2,"0")},m:function t(e,s){if(e.date()<s.date())return-t(s,e);var i=12*(s.year()-e.year())+(s.month()-e.month()),n=e.clone().add(i,l),a=s-n<0,o=e.clone().add(i+(a?-1:1),l);return+(-(i+(s-n)/(a?n-o:o-n))||0)},a:function(t){return t<0?Math.ceil(t)||0:Math.floor(t)},p:function(t){return{M:l,y:c,w:r,d:o,D:h,h:a,m:n,s:i,ms:s,Q:d}[t]||String(t||"").toLowerCase().replace(/s$/,"")},u:function(t){return void 0===t}},v="en",w={};w[v]=m;var _=function(t){return t instanceof D},y=function t(e,s,i){var n;if(!e)return v;if("string"==typeof e){var a=e.toLowerCase();w[a]&&(n=a),s&&(w[a]=s,n=a);var o=e.split("-");if(!n&&o.length>1)return t(o[0])}else{var r=e.name;w[r]=e,n=r}return!i&&n&&(v=n),n||!i&&v},x=function(t,e){if(_(t))return t.clone();var s="object"==typeof e?e:{};return s.date=t,s.args=arguments,new D(s)},N=b;N.l=y,N.i=_,N.w=function(t,e){return x(t,{locale:e.$L,utc:e.$u,x:e.$x,$offset:e.$offset})};var D=function(){function m(t){this.$L=y(t.locale,null,!0),this.parse(t)}var g=m.prototype;return g.parse=function(t){this.$d=function(t){var e=t.date,s=t.utc;if(null===e)return new Date(NaN);if(N.u(e))return new Date;if(e instanceof Date)return new Date(e);if("string"==typeof e&&!/Z$/i.test(e)){var i=e.match(p);if(i){var n=i[2]-1||0,a=(i[7]||"0").substring(0,3);return s?new Date(Date.UTC(i[1],n,i[3]||1,i[4]||0,i[5]||0,i[6]||0,a)):new Date(i[1],n,i[3]||1,i[4]||0,i[5]||0,i[6]||0,a)}}return new Date(e)}(t),this.$x=t.x||{},this.init()},g.init=function(){var t=this.$d;this.$y=t.getFullYear(),this.$M=t.getMonth(),this.$D=t.getDate(),this.$W=t.getDay(),this.$H=t.getHours(),this.$m=t.getMinutes(),this.$s=t.getSeconds(),this.$ms=t.getMilliseconds()},g.$utils=function(){return N},g.isValid=function(){return!(this.$d.toString()===u)},g.isSame=function(t,e){var s=x(t);return this.startOf(e)<=s&&s<=this.endOf(e)},g.isAfter=function(t,e){return x(t)<this.startOf(e)},g.isBefore=function(t,e){return this.endOf(e)<x(t)},g.$g=function(t,e,s){return N.u(t)?this[e]:this.set(s,t)},g.unix=function(){return Math.floor(this.valueOf()/1e3)},g.valueOf=function(){return this.$d.getTime()},g.startOf=function(t,e){var s=this,d=!!N.u(e)||e,u=N.p(t),p=function(t,e){var i=N.w(s.$u?Date.UTC(s.$y,e,t):new Date(s.$y,e,t),s);return d?i:i.endOf(o)},f=function(t,e){return N.w(s.toDate()[t].apply(s.toDate("s"),(d?[0,0,0,0]:[23,59,59,999]).slice(e)),s)},m=this.$W,g=this.$M,b=this.$D,v="set"+(this.$u?"UTC":"");switch(u){case c:return d?p(1,0):p(31,11);case l:return d?p(1,g):p(0,g+1);case r:var w=this.$locale().weekStart||0,_=(m<w?m+7:m)-w;return p(d?b-_:b+(6-_),g);case o:case h:return f(v+"Hours",0);case a:return f(v+"Minutes",1);case n:return f(v+"Seconds",2);case i:return f(v+"Milliseconds",3);default:return this.clone()}},g.endOf=function(t){return this.startOf(t,!1)},g.$set=function(t,e){var r,d=N.p(t),u="set"+(this.$u?"UTC":""),p=(r={},r[o]=u+"Date",r[h]=u+"Date",r[l]=u+"Month",r[c]=u+"FullYear",r[a]=u+"Hours",r[n]=u+"Minutes",r[i]=u+"Seconds",r[s]=u+"Milliseconds",r)[d],f=d===o?this.$D+(e-this.$W):e;if(d===l||d===c){var m=this.clone().set(h,1);m.$d[p](f),m.init(),this.$d=m.set(h,Math.min(this.$D,m.daysInMonth())).$d}else p&&this.$d[p](f);return this.init(),this},g.set=function(t,e){return this.clone().$set(t,e)},g.get=function(t){return this[N.p(t)]()},g.add=function(s,d){var h,u=this;s=Number(s);var p=N.p(d),f=function(t){var e=x(u);return N.w(e.date(e.date()+Math.round(t*s)),u)};if(p===l)return this.set(l,this.$M+s);if(p===c)return this.set(c,this.$y+s);if(p===o)return f(1);if(p===r)return f(7);var m=(h={},h[n]=t,h[a]=e,h[i]=1e3,h)[p]||1,g=this.$d.getTime()+s*m;return N.w(g,this)},g.subtract=function(t,e){return this.add(-1*t,e)},g.format=function(t){var e=this,s=this.$locale();if(!this.isValid())return s.invalidDate||u;var i=t||"YYYY-MM-DDTHH:mm:ssZ",n=N.z(this),a=this.$H,o=this.$m,r=this.$M,l=s.weekdays,d=s.months,c=function(t,s,n,a){return t&&(t[s]||t(e,i))||n[s].slice(0,a)},h=function(t){return N.s(a%12||12,t,"0")},p=s.meridiem||function(t,e,s){var i=t<12?"AM":"PM";return s?i.toLowerCase():i},m={YY:String(this.$y).slice(-2),YYYY:this.$y,M:r+1,MM:N.s(r+1,2,"0"),MMM:c(s.monthsShort,r,d,3),MMMM:c(d,r),D:this.$D,DD:N.s(this.$D,2,"0"),d:String(this.$W),dd:c(s.weekdaysMin,this.$W,l,2),ddd:c(s.weekdaysShort,this.$W,l,3),dddd:l[this.$W],H:String(a),HH:N.s(a,2,"0"),h:h(1),hh:h(2),a:p(a,o,!0),A:p(a,o,!1),m:String(o),mm:N.s(o,2,"0"),s:String(this.$s),ss:N.s(this.$s,2,"0"),SSS:N.s(this.$ms,3,"0"),Z:n};return i.replace(f,(function(t,e){return e||m[t]||n.replace(":","")}))},g.utcOffset=function(){return 15*-Math.round(this.$d.getTimezoneOffset()/15)},g.diff=function(s,h,u){var p,f=N.p(h),m=x(s),g=(m.utcOffset()-this.utcOffset())*t,b=this-m,v=N.m(this,m);return v=(p={},p[c]=v/12,p[l]=v,p[d]=v/3,p[r]=(b-g)/6048e5,p[o]=(b-g)/864e5,p[a]=b/e,p[n]=b/t,p[i]=b/1e3,p)[f]||b,u?v:N.a(v)},g.daysInMonth=function(){return this.endOf(l).$D},g.$locale=function(){return w[this.$L]},g.locale=function(t,e){if(!t)return this.$L;var s=this.clone(),i=y(t,e,!0);return i&&(s.$L=i),s},g.clone=function(){return N.w(this.$d,this)},g.toDate=function(){return new Date(this.valueOf())},g.toJSON=function(){return this.isValid()?this.toISOString():null},g.toISOString=function(){return this.$d.toISOString()},g.toString=function(){return this.$d.toUTCString()},m}(),M=D.prototype;return x.prototype=M,[["$ms",s],["$s",i],["$m",n],["$H",a],["$W",o],["$M",l],["$y",c],["$D",h]].forEach((function(t){M[t[1]]=function(e){return this.$g(e,t[0],t[1])}})),x.extend=function(t,e){return t.$i||(t(e,D,x),t.$i=!0),x},x.locale=y,x.isDayjs=_,x.unix=function(t){return x(1e3*t)},x.en=w[v],x.Ls=w,x.p={},x}();var j=I,q={},B={get exports(){return q},set exports(t){q=t}};B.exports=function(){var t={LTS:"h:mm:ss A",LT:"h:mm A",L:"MM/DD/YYYY",LL:"MMMM D, YYYY",LLL:"MMMM D, YYYY h:mm A",LLLL:"dddd, MMMM D, YYYY h:mm A"},e=/(\[[^[]*\])|([-_:/.,()\s]+)|(A|a|YYYY|YY?|MM?M?M?|Do|DD?|hh?|HH?|mm?|ss?|S{1,3}|z|ZZ?)/g,s=/\d\d/,i=/\d\d?/,n=/\d*[^-_:/,()\s\d]+/,a={},o=function(t){return(t=+t)+(t>68?1900:2e3)},r=function(t){return function(e){this[t]=+e}},l=[/[+-]\d\d:?(\d\d)?|Z/,function(t){(this.zone||(this.zone={})).offset=function(t){if(!t)return 0;if("Z"===t)return 0;var e=t.match(/([+-]|\d\d)/g),s=60*e[1]+(+e[2]||0);return 0===s?0:"+"===e[0]?-s:s}(t)}],d=function(t){var e=a[t];return e&&(e.indexOf?e:e.s.concat(e.f))},c=function(t,e){var s,i=a.meridiem;if(i){for(var n=1;n<=24;n+=1)if(t.indexOf(i(n,0,e))>-1){s=n>12;break}}else s=t===(e?"pm":"PM");return s},h={A:[n,function(t){this.afternoon=c(t,!1)}],a:[n,function(t){this.afternoon=c(t,!0)}],S:[/\d/,function(t){this.milliseconds=100*+t}],SS:[s,function(t){this.milliseconds=10*+t}],SSS:[/\d{3}/,function(t){this.milliseconds=+t}],s:[i,r("seconds")],ss:[i,r("seconds")],m:[i,r("minutes")],mm:[i,r("minutes")],H:[i,r("hours")],h:[i,r("hours")],HH:[i,r("hours")],hh:[i,r("hours")],D:[i,r("day")],DD:[s,r("day")],Do:[n,function(t){var e=a.ordinal,s=t.match(/\d+/);if(this.day=s[0],e)for(var i=1;i<=31;i+=1)e(i).replace(/\[|\]/g,"")===t&&(this.day=i)}],M:[i,r("month")],MM:[s,r("month")],MMM:[n,function(t){var e=d("months"),s=(d("monthsShort")||e.map((function(t){return t.slice(0,3)}))).indexOf(t)+1;if(s<1)throw new Error;this.month=s%12||s}],MMMM:[n,function(t){var e=d("months").indexOf(t)+1;if(e<1)throw new Error;this.month=e%12||e}],Y:[/[+-]?\d+/,r("year")],YY:[s,function(t){this.year=o(t)}],YYYY:[/\d{4}/,r("year")],Z:l,ZZ:l};function u(s){var i,n;i=s,n=a&&a.formats;for(var o=(s=i.replace(/(\[[^\]]+])|(LTS?|l{1,4}|L{1,4})/g,(function(e,s,i){var a=i&&i.toUpperCase();return s||n[i]||t[i]||n[a].replace(/(\[[^\]]+])|(MMMM|MM|DD|dddd)/g,(function(t,e,s){return e||s.slice(1)}))}))).match(e),r=o.length,l=0;l<r;l+=1){var d=o[l],c=h[d],u=c&&c[0],p=c&&c[1];o[l]=p?{regex:u,parser:p}:d.replace(/^\[|\]$/g,"")}return function(t){for(var e={},s=0,i=0;s<r;s+=1){var n=o[s];if("string"==typeof n)i+=n.length;else{var a=n.regex,l=n.parser,d=t.slice(i),c=a.exec(d)[0];l.call(e,c),t=t.replace(c,"")}}return function(t){var e=t.afternoon;if(void 0!==e){var s=t.hours;e?s<12&&(t.hours+=12):12===s&&(t.hours=0),delete t.afternoon}}(e),e}}return function(t,e,s){s.p.customParseFormat=!0,t&&t.parseTwoDigitYear&&(o=t.parseTwoDigitYear);var i=e.prototype,n=i.parse;i.parse=function(t){var e=t.date,i=t.utc,o=t.args;this.$u=i;var r=o[1];if("string"==typeof r){var l=!0===o[2],d=!0===o[3],c=l||d,h=o[2];d&&(h=o[2]),a=this.$locale(),!l&&h&&(a=s.Ls[h]),this.$d=function(t,e,s){try{if(["x","X"].indexOf(e)>-1)return new Date(("X"===e?1e3:1)*t);var i=u(e)(t),n=i.year,a=i.month,o=i.day,r=i.hours,l=i.minutes,d=i.seconds,c=i.milliseconds,h=i.zone,p=new Date,f=o||(n||a?1:p.getDate()),m=n||p.getFullYear(),g=0;n&&!a||(g=a>0?a-1:p.getMonth());var b=r||0,v=l||0,w=d||0,_=c||0;return h?new Date(Date.UTC(m,g,f,b,v,w,_+60*h.offset*1e3)):s?new Date(Date.UTC(m,g,f,b,v,w,_)):new Date(m,g,f,b,v,w,_)}catch(t){return new Date("")}}(e,r,i),this.init(),h&&!0!==h&&(this.$L=this.locale(h).$L),c&&e!=this.format(r)&&(this.$d=new Date("")),a={}}else if(r instanceof Array)for(var p=r.length,f=1;f<=p;f+=1){o[1]=r[f-1];var m=s.apply(this,o);if(m.isValid()){this.$d=m.$d,this.$L=m.$L,this.init();break}f===p&&(this.$d=new Date(""))}else n.call(this,t)}}}();var F=q;j.extend(F);const U=(t,e)=>{let s;if(e)switch(e){case"ISO_8601":s=t;break;case"RFC_2822":s=j(t.slice(5),"DD MMM YYYY HH:mm:ss ZZ").unix();break;case"MYSQL":s=j(t,"YYYY-MM-DD hh:mm:ss").unix();break;case"UNIX":s=j(t).unix();break;default:s=j(t,e,!0).valueOf()}return s},z=(t,e)=>{if(t?.constructor===Object&&Object.prototype.hasOwnProperty.call(t,"data")&&!Object.keys(t).find((t=>!["text","order","data"].includes(t))))return t;const s={data:t};switch(e.type){case"string":"string"!=typeof t&&(s.text=String(s.data),s.order=s.text);break;case"date":e.format&&(s.order=U(String(s.data),e.format));break;case"number":s.text=String(s.data),s.data=parseInt(s.data,10);break;case"html":{const t=Array.isArray(s.data)?{nodeName:"TD",childNodes:s.data}:k(`<td>${String(s.data)}</td>`);s.data=t.childNodes||[];const e=a(t);s.text=e,s.order=e;break}case"boolean":"string"==typeof s.data&&(s.data=s.data.toLowerCase().trim()),s.data=!["false",!1,null,void 0,0].includes(s.data),s.order=s.data?1:0,s.text=String(s.data);break;case"other":s.text="",s.order=0;break;default:s.text=JSON.stringify(s.data)}return s},J=t=>{if(t instanceof Object&&t.constructor===Object&&t.hasOwnProperty("data")&&("string"==typeof t.text||"string"==typeof t.data))return t;const e={data:t};if("string"==typeof t){if(t.length){const s=k(`<th>${t}</th>`);if(s.childNodes&&(1!==s.childNodes.length||"#text"!==s.childNodes[0].nodeName)){e.data=s.childNodes,e.type="html";const t=a(s);e.text=t}}}else[null,void 0].includes(t)?e.text="":e.text=JSON.stringify(t);return e},W=(t,e,s,i,n)=>{const o={data:[],headings:[]};t.headings?o.headings=t.headings.map((t=>J(t))):e?.tHead?o.headings=Array.from(e.tHead.querySelectorAll("th")).map(((t,e)=>{const o=(t=>{const e=E(t,{valueDiffing:!1});let s;return s=!e.childNodes||1===e.childNodes.length&&"#text"===e.childNodes[0].nodeName?{data:t.innerText,type:"string"}:{data:e.childNodes,type:"html",text:a(e)},s})(t);s[e]||(s[e]={type:i,format:n,searchable:!0,sortable:!0});const r=s[e];return"false"!==t.dataset.sortable?.trim().toLowerCase()&&"false"!==t.dataset.sort?.trim().toLowerCase()||(r.sortable=!1),"false"===t.dataset.searchable?.trim().toLowerCase()&&(r.searchable=!1),"true"!==t.dataset.hidden?.trim().toLowerCase()&&"true"!==t.getAttribute("hidden")?.trim().toLowerCase()||(r.hidden=!0),["number","string","html","date","boolean","other"].includes(t.dataset.type)&&(r.type=t.dataset.type,"date"===r.type&&t.dataset.format&&(r.format=t.dataset.format)),o})):t.data?.length?o.headings=t.data[0].map((t=>J(""))):e?.tBodies.length&&(o.headings=Array.from(e.tBodies[0].rows[0].cells).map((t=>J(""))));for(let t=0;t<o.headings.length;t++)s[t]||(s[t]={type:i,format:n,sortable:!0,searchable:!0});if(t.data?o.data=t.data.map((t=>t.map(((t,e)=>z(t,s[e]))))):e?.tBodies?.length&&(o.data=Array.from(e.tBodies[0].rows).map((t=>Array.from(t.cells).map(((t,e)=>{const i=t.dataset.content?z(t.dataset.content,s[e]):((t,e)=>{let s;switch(e.type){case"string":s={data:t.innerText};break;case"date":{const i=t.innerText;s={data:i,order:U(i,e.format)};break}case"number":s={data:parseInt(t.innerText,10),text:t.innerText};break;case"boolean":{const e=!["false","0","null","undefined"].includes(t.innerText.toLowerCase().trim());s={data:e,order:e?1:0,text:e?"1":"0"};break}default:s={data:E(t,{valueDiffing:!1}).childNodes||[],text:t.innerText,order:t.innerText}}return s})(t,s[e]);return t.dataset.order&&(i.order=isNaN(parseFloat(t.dataset.order))?t.dataset.order:parseFloat(t.dataset.order)),i}))))),o.data.length&&o.data[0].length!==o.headings.length)throw new Error("Data heading length mismatch.");return o};class Q{constructor(t){this.dt=t,this.cursor=!1}setCursor(t=!1){if(t===this.cursor)return;const e=this.cursor;if(this.cursor=t,this.dt._renderTable(),!1!==t&&this.dt.options.scrollY){const t=this.dt.dom.querySelector(`tr.${this.dt.options.classes.cursor}`);t&&t.scrollIntoView({block:"nearest"})}this.dt.emit("datatable.cursormove",this.cursor,e)}add(t){const e=t.map(((t,e)=>{const s=this.dt.columns.settings[e];return z(t,s)}));this.dt.data.data.push(e),this.dt.data.data.length&&(this.dt.hasRows=!0),this.dt.update(!0)}remove(t){if(!Array.isArray(t))return this.remove([t]);this.dt.data.data=this.dt.data.data.filter(((e,s)=>!t.includes(s))),this.dt.data.data.length||(this.dt.hasRows=!1),this.dt.update(!0)}findRowIndex(t,e){return this.dt.data.data.findIndex((s=>(s[t].text??String(s[t].data)).toLowerCase().includes(String(e).toLowerCase())))}findRow(t,e){const s=this.findRowIndex(t,e);if(s<0)return{index:-1,row:null,cols:[]};const i=this.dt.data.data[s],n=i.map((t=>t.data));return{index:s,row:i,cols:n}}updateRow(t,e){const s=e.map(((t,e)=>{const s=this.dt.columns.settings[e];return z(t,s)}));this.dt.data.data.splice(t,1,s),this.dt.update(!0)}}class Z{constructor(t){this.dt=t,this.init()}init(){[this.settings,this._state]=((t=[],e,s)=>{let i=[],n=!1;const a=[];return t.forEach((t=>{(Array.isArray(t.select)?t.select:[t.select]).forEach((o=>{i[o]||(i[o]={type:t.type||e,sortable:!0,searchable:!0});const r=i[o];t.render&&(r.render=t.render),t.format?r.format=t.format:"date"===t.type&&(r.format=s),t.cellClass&&(r.cellClass=t.cellClass),t.headerClass&&(r.headerClass=t.headerClass),t.locale&&(r.locale=t.locale),!1===t.sortable?r.sortable=!1:(t.numeric&&(r.numeric=t.numeric),t.caseFirst&&(r.caseFirst=t.caseFirst)),!1===t.searchable?r.searchable=!1:t.sensitivity&&(r.sensitivity=t.sensitivity),(r.searchable||r.sortable)&&t.ignorePunctuation&&(r.ignorePunctuation=t.ignorePunctuation),t.hidden&&(r.hidden=!0),t.filter&&(r.filter=t.filter),t.sortSequence&&(r.sortSequence=t.sortSequence),t.sort&&(t.filter?a[o]=t.sort:n={column:o,dir:t.sort})}))})),i=i.map((t=>t||{type:e,format:"date"===e?s:void 0,sortable:!0,searchable:!0})),[i,{filters:a,sort:n,widths:[]}]})(this.dt.options.columns,this.dt.options.type,this.dt.options.format)}swap(t){if(2===t.length){const e=this.dt.data.headings.map(((t,e)=>e)),s=t[0],i=t[1],n=e[i];return e[i]=e[s],e[s]=n,this.order(e)}}order(t){this.dt.data.headings=t.map((t=>this.dt.data.headings[t])),this.dt.data.data=this.dt.data.data.map((e=>t.map((t=>e[t])))),this.settings=t.map((t=>this.settings[t])),this.dt.update()}hide(t){t.length&&(t.forEach((t=>{this.settings[t]||(this.settings[t]={type:"string"}),this.settings[t].hidden=!0})),this.dt.update())}show(t){t.length&&(t.forEach((t=>{this.settings[t]||(this.settings[t]={type:"string",sortable:!0}),delete this.settings[t].hidden})),this.dt.update())}visible(t){return void 0===t&&(t=[...Array(this.dt.data.headings.length).keys()]),Array.isArray(t)?t.map((t=>!this.settings[t]?.hidden)):!this.settings[t]?.hidden}add(t){const e=this.dt.data.headings.length;if(this.dt.data.headings=this.dt.data.headings.concat([J(t.heading)]),this.dt.data.data=this.dt.data.data.map(((e,s)=>e.concat([z(t.data[s],t)]))),this.settings[e]={type:t.type||"string",sortable:!0,searchable:!0},t.type||t.format||t.sortable||t.render||t.filter){const s=this.settings[e];t.render&&(s.render=t.render),t.format&&(s.format=t.format),t.cellClass&&(s.cellClass=t.cellClass),t.headerClass&&(s.headerClass=t.headerClass),t.locale&&(s.locale=t.locale),!1===t.sortable?s.sortable=!1:(t.numeric&&(s.numeric=t.numeric),t.caseFirst&&(s.caseFirst=t.caseFirst)),!1===t.searchable?s.searchable=!1:t.sensitivity&&(s.sensitivity=t.sensitivity),(s.searchable||s.sortable)&&t.ignorePunctuation&&(s.ignorePunctuation=t.ignorePunctuation),t.hidden&&(s.hidden=!0),t.filter&&(s.filter=t.filter),t.sortSequence&&(s.sortSequence=t.sortSequence)}this.dt.update(!0)}remove(t){if(!Array.isArray(t))return this.remove([t]);this.dt.data.headings=this.dt.data.headings.filter(((e,s)=>!t.includes(s))),this.dt.data.data=this.dt.data.data.map((e=>e.filter(((e,s)=>!t.includes(s))))),this.dt.update(!0)}filter(t,e=!1){if(!this.settings[t]?.filter?.length)return;const s=this._state.filters[t];let i;if(s){let e=!1;i=this.settings[t].filter.find((t=>!!e||(t===s&&(e=!0),!1)))}else{const e=this.settings[t].filter;i=e?e[0]:void 0}i?this._state.filters[t]=i:s&&(this._state.filters[t]=void 0),this.dt._currentPage=1,this.dt.update(),e||this.dt.emit("datatable.filter",t,i)}sort(t,e,s=!1){const i=this.settings[t];if(s||this.dt.emit("datatable.sorting",t,e),!e){const t=!!this._state.sort&&this._state.sort?.dir,s=i?.sortSequence||["asc","desc"];if(t){const i=s.indexOf(t);e=-1===i?"asc":i===s.length-1?s[0]:s[i+1]}else e=s.length?s[0]:"asc"}const n=!!["string","html"].includes(i.type)&&new Intl.Collator(i.locale||this.dt.options.locale,{usage:"sort",numeric:i.numeric||this.dt.options.numeric,caseFirst:i.caseFirst||this.dt.options.caseFirst,ignorePunctuation:i.ignorePunctuation||this.dt.options.ignorePunctuation});this.dt.data.data.sort(((s,i)=>{let a=s[t].order||s[t].data,o=i[t].order||i[t].data;if("desc"===e){const t=a;a=o,o=t}return n?n.compare(String(a),String(o)):a<o?-1:a>o?1:0})),this._state.sort={column:t,dir:e},this.dt._searchQueries.length?(this.dt.multiSearch(this.dt._searchQueries),this.dt.emit("datatable.sort",t,e)):s||(this.dt._currentPage=1,this.dt.update(),this.dt.emit("datatable.sort",t,e))}_measureWidths(){const t=this.dt.data.headings.filter(((t,e)=>!this.settings[e]?.hidden));if((this.dt.options.scrollY.length||this.dt.options.fixedColumns)&&t?.length){this._state.widths=[];const t={noPaging:!0};if(this.dt.options.header||this.dt.options.footer){this.dt.options.scrollY.length&&(t.unhideHeader=!0),this.dt.headerDOM&&this.dt.headerDOM.parentElement.removeChild(this.dt.headerDOM),t.noColumnWidths=!0,this.dt._renderTable(t);const e=Array.from(this.dt.dom.querySelector("thead, tfoot")?.firstElementChild?.querySelectorAll("th")||[]);let s=0;const i=this.dt.data.headings.map(((t,i)=>{if(this.settings[i]?.hidden)return 0;const n=e[s].offsetWidth;return s+=1,n})),n=i.reduce(((t,e)=>t+e),0);this._state.widths=i.map((t=>t/n*100))}else{t.renderHeader=!0,this.dt._renderTable(t);const e=Array.from(this.dt.dom.querySelector("thead, tfoot")?.firstElementChild?.querySelectorAll("th")||[]);let s=0;const i=this.dt.data.headings.map(((t,i)=>{if(this.settings[i]?.hidden)return 0;const n=e[s].offsetWidth;return s+=1,n})),n=i.reduce(((t,e)=>t+e),0);this._state.widths=i.map((t=>t/n*100))}this.dt._renderTable()}}}const X={sortable:!0,locale:"en",numeric:!0,caseFirst:"false",searchable:!0,sensitivity:"base",ignorePunctuation:!0,destroyable:!0,data:{},type:"html",format:"YYYY-MM-DD",columns:[],paging:!0,perPage:10,perPageSelect:[5,10,15,20,25],nextPrev:!0,firstLast:!1,prevText:"‹",nextText:"›",firstText:"«",lastText:"»",ellipsisText:"…",truncatePager:!0,pagerDelta:2,scrollY:"",fixedColumns:!0,fixedHeight:!1,footer:!1,header:!0,hiddenHeader:!1,rowNavigation:!1,tabIndex:!1,pagerRender:!1,rowRender:!1,tableRender:!1,labels:{placeholder:"Search...",searchTitle:"Search within table",perPage:"entries per page",noRows:"No entries found",noResults:"No results match your search query",info:"Showing {start} to {end} of {rows} entries"},template:(t,e)=>`<div class='${t.classes.top}'>\n ${t.paging&&t.perPageSelect?`<div class='${t.classes.dropdown}'>\n <label>\n <select class='${t.classes.selector}'></select> ${t.labels.perPage}\n </label>\n </div>`:""}\n ${t.searchable?`<div class='${t.classes.search}'>\n <input class='${t.classes.input}' placeholder='${t.labels.placeholder}' type='search' title='${t.labels.searchTitle}'${e.id?` aria-controls="${e.id}"`:""}>\n </div>`:""}\n</div>\n<div class='${t.classes.container}'${t.scrollY.length?` style='height: ${t.scrollY}; overflow-Y: auto;'`:""}></div>\n<div class='${t.classes.bottom}'>\n ${t.paging?`<div class='${t.classes.info}'></div>`:""}\n <nav class='${t.classes.pagination}'></nav>\n</div>`,classes:{active:"datatable-active",ascending:"datatable-ascending",bottom:"datatable-bottom",container:"datatable-container",cursor:"datatable-cursor",descending:"datatable-descending",disabled:"datatable-disabled",dropdown:"datatable-dropdown",ellipsis:"datatable-ellipsis",filter:"datatable-filter",filterActive:"datatable-filter-active",empty:"datatable-empty",headercontainer:"datatable-headercontainer",hidden:"datatable-hidden",info:"datatable-info",input:"datatable-input",loading:"datatable-loading",pagination:"datatable-pagination",paginationList:"datatable-pagination-list",paginationListItem:"datatable-pagination-list-item",paginationListItemLink:"datatable-pagination-list-item-link",search:"datatable-search",selector:"datatable-selector",sorter:"datatable-sorter",table:"datatable-table",top:"datatable-top",wrapper:"datatable-wrapper"}},G=(t,e,s,i={})=>({nodeName:"LI",attributes:{class:i.active&&!i.hidden?`${s.classes.paginationListItem} ${s.classes.active}`:i.hidden?`${s.classes.paginationListItem} ${s.classes.hidden} ${s.classes.disabled}`:s.classes.paginationListItem},childNodes:[{nodeName:"A",attributes:{"data-page":String(t),class:s.classes.paginationListItemLink},childNodes:[{nodeName:"#text",data:e}]}]}),K={classes:{row:"datatable-editor-row",form:"datatable-editor-form",item:"datatable-editor-item",menu:"datatable-editor-menu",save:"datatable-editor-save",block:"datatable-editor-block",close:"datatable-editor-close",inner:"datatable-editor-inner",input:"datatable-editor-input",label:"datatable-editor-label",modal:"datatable-editor-modal",action:"datatable-editor-action",header:"datatable-editor-header",wrapper:"datatable-editor-wrapper",editable:"datatable-editor-editable",container:"datatable-editor-container",separator:"datatable-editor-separator"},labels:{closeX:"x",editCell:"Edit Cell",editRow:"Edit Row",removeRow:"Remove Row",reallyRemove:"Are you sure?",reallyClose:"Do you really want to close?",save:"Save"},closeModal:t=>confirm(t.options.labels.reallyClose),inline:!0,hiddenColumns:!1,contextMenu:!0,clickEvent:"dblclick",excludeColumns:[],menuItems:[{text:t=>t.options.labels.editCell,action:(t,e)=>{if(!(t.event.target instanceof Element))return;const s=t.event.target.closest("td");return t.editCell(s)}},{text:t=>t.options.labels.editRow,action:(t,e)=>{if(!(t.event.target instanceof Element))return;const s=t.event.target.closest("tr");return t.editRow(s)}},{separator:!0},{text:t=>t.options.labels.removeRow,action:(t,e)=>{if(t.event.target instanceof Element&&confirm(t.options.labels.reallyRemove)){const e=t.event.target.closest("tr");t.removeRow(e)}}}]};class tt{constructor(t,e={}){this.dt=t,this.options={...K,...e}}init(){this.initialized||(this.dt.wrapperDOM.classList.add(this.options.classes.editable),this.options.inline&&(this.originalRowRender=this.dt.options.rowRender,this.dt.options.rowRender=(t,e,s)=>{let i=this.rowRender(t,e,s);return this.originalRowRender&&(i=this.originalRowRender(t,i,s)),i}),this.options.contextMenu&&(this.containerDOM=n("div",{id:this.options.classes.container}),this.wrapperDOM=n("div",{class:this.options.classes.wrapper}),this.menuDOM=n("ul",{class:this.options.classes.menu}),this.options.menuItems&&this.options.menuItems.length&&this.options.menuItems.forEach((t=>{const e=n("li",{class:t.separator?this.options.classes.separator:this.options.classes.item});if(!t.separator){const s=n("a",{class:this.options.classes.action,href:t.url||"#",html:"function"==typeof t.text?t.text(this):t.text});e.appendChild(s),t.action&&"function"==typeof t.action&&s.addEventListener("click",(e=>{e.preventDefault(),t.action(this,e)}))}this.menuDOM.appendChild(e)})),this.wrapperDOM.appendChild(this.menuDOM),this.containerDOM.appendChild(this.wrapperDOM),this.update()),this.data={},this.closed=!0,this.editing=!1,this.editingRow=!1,this.editingCell=!1,this.bindEvents(),setTimeout((()=>{this.initialized=!0,this.dt.emit("editable.init")}),10))}bindEvents(){this.events={context:this.context.bind(this),update:this.update.bind(this),dismiss:this.dismiss.bind(this),keydown:this.keydown.bind(this),click:this.click.bind(this)},this.dt.dom.addEventListener(this.options.clickEvent,this.events.click),document.addEventListener("click",this.events.dismiss),document.addEventListener("keydown",this.events.keydown),this.options.contextMenu&&(this.dt.dom.addEventListener("contextmenu",this.events.context),this.events.reset=function(t,e=300){let s;return(...i)=>{clearTimeout(s),s=window.setTimeout((()=>t()),e)}}((()=>this.events.update()),50),window.addEventListener("resize",this.events.reset),window.addEventListener("scroll",this.events.reset))}context(t){const e=t.target;if(!(e instanceof Element))return;this.event=t;const s=e.closest("tbody td");if(this.options.contextMenu&&!this.disabled&&s){t.preventDefault();let e=t.pageX,s=t.pageY;e>this.limits.x&&(e-=this.rect.width),s>this.limits.y&&(s-=this.rect.height),this.wrapperDOM.style.top=`${s}px`,this.wrapperDOM.style.left=`${e}px`,this.openMenu(),this.update()}}click(t){const e=t.target;if(e instanceof Element)if(this.editing&&this.data&&this.editingCell){const t=this.modalDOM?this.modalDOM.querySelector(`input.${this.options.classes.input}[type=text]`):this.dt.wrapperDOM.querySelector(`input.${this.options.classes.input}[type=text]`);this.saveCell(t.value)}else if(!this.editing){const s=e.closest("tbody td");s&&(this.editCell(s),t.preventDefault())}}keydown(t){if(this.modalDOM){if("Escape"===t.key)this.options.closeModal(this)&&this.closeModal();else if("Enter"===t.key)if(this.editingCell){const t=this.modalDOM.querySelector(`input.${this.options.classes.input}[type=text]`);this.saveCell(t.value)}else{const t=Array.from(this.modalDOM.querySelectorAll(`input.${this.options.classes.input}[type=text]`));this.saveRow(t.map((t=>t.value.trim())),this.data.row)}}else if(this.editing&&this.data)if("Enter"===t.key){if(this.editingCell){const t=this.dt.wrapperDOM.querySelector(`input.${this.options.classes.input}[type=text]`);this.saveCell(t.value)}else if(this.editingRow){const t=Array.from(this.dt.wrapperDOM.querySelectorAll(`input.${this.options.classes.input}[type=text]`));this.saveRow(t.map((t=>t.value.trim())),this.data.row)}}else"Escape"===t.key&&this.saveCell(this.data.content)}editCell(t){const e=r(t.cellIndex,this.dt.columns.settings);if(this.options.excludeColumns.includes(e))return void this.closeMenu();const s=parseInt(t.parentElement.dataset.index,10),i=this.dt.data.data[s][e];this.data={cell:i,rowIndex:s,columnIndex:e,content:i.text||String(i.data)},this.editing=!0,this.editingCell=!0,this.options.inline?this.dt.update():this.editCellModal(),this.closeMenu()}editCellModal(){const t=this.data.cell,e=this.data.columnIndex,s=this.dt.data.headings[e].text||String(this.dt.data.headings[e].data),i=[`<div class='${this.options.classes.inner}'>`,`<div class='${this.options.classes.header}'>`,`<h4>${this.options.labels.editCell}</h4>`,`<button class='${this.options.classes.close}' type='button' data-editor-close>${this.options.labels.closeX}</button>`," </div>",`<div class='${this.options.classes.block}'>`,`<form class='${this.options.classes.form}'>`,`<div class='${this.options.classes.row}'>`,`<label class='${this.options.classes.label}'>${o(s)}</label>`,`<input class='${this.options.classes.input}' value='${o(t.text||String(t.data)||"")}' type='text'>`,"</div>",`<div class='${this.options.classes.row}'>`,`<button class='${this.options.classes.save}' type='button' data-editor-save>${this.options.labels.save}</button>`,"</div>","</form>","</div>","</div>"].join(""),a=n("div",{class:this.options.classes.modal,html:i});this.modalDOM=a,this.openModal();const r=a.querySelector(`input.${this.options.classes.input}[type=text]`);r.focus(),r.selectionStart=r.selectionEnd=r.value.length,a.addEventListener("click",(t=>{const e=t.target;if(e instanceof Element)if(e.hasAttribute("data-editor-close")){if(t.preventDefault(),!this.options.closeModal(this))return;this.closeModal()}else e.hasAttribute("data-editor-save")&&(t.preventDefault(),this.saveCell(r.value))}))}saveCell(t){const e=this.data.content,s=this.dt.columns.settings[this.data.columnIndex].type||this.dt.options.type,i=t.trim();let n;if("number"===s)n={data:parseFloat(i)};else if("boolean"===s)n=["","false","0"].includes(i)?{data:!1,text:"false",order:0}:{data:!0,text:"true",order:1};else if("html"===s)n={data:[{nodeName:"#text",data:t}],text:t,order:t};else if("string"===s)n={data:t};else if("date"===s){const e=this.dt.columns.settings[this.data.columnIndex].format||this.dt.options.format;n={data:t,order:U(String(t),e)}}else n={data:t};this.dt.data.data[this.data.rowIndex][this.data.columnIndex]=n,this.closeModal();const a=this.data.rowIndex,o=this.data.columnIndex;this.data={},this.dt.update(!0),this.editing=!1,this.editingCell=!1,this.dt.emit("editable.save.cell",t,e,a,o)}editRow(t){if(!t||"TR"!==t.nodeName||this.editing)return;const e=parseInt(t.dataset.index,10),s=this.dt.data.data[e];this.data={row:s,rowIndex:e},this.editing=!0,this.editingRow=!0,this.options.inline?this.dt.update():this.editRowModal(),this.closeMenu()}editRowModal(){const t=this.data.row,e=[`<div class='${this.options.classes.inner}'>`,`<div class='${this.options.classes.header}'>`,`<h4>${this.options.labels.editRow}</h4>`,`<button class='${this.options.classes.close}' type='button' data-editor-close>${this.options.labels.closeX}</button>`," </div>",`<div class='${this.options.classes.block}'>`,`<form class='${this.options.classes.form}'>`,`<div class='${this.options.classes.row}'>`,`<button class='${this.options.classes.save}' type='button' data-editor-save>${this.options.labels.save}</button>`,"</div>","</form>","</div>","</div>"].join(""),s=n("div",{class:this.options.classes.modal,html:e}),i=s.firstElementChild;if(!i)return;const a=i.lastElementChild?.firstElementChild;if(!a)return;t.forEach(((t,e)=>{const s=this.dt.columns.settings[e];if((!s.hidden||s.hidden&&this.options.hiddenColumns)&&!this.options.excludeColumns.includes(e)){const s=this.dt.data.headings[e].text||String(this.dt.data.headings[e].data);a.insertBefore(n("div",{class:this.options.classes.row,html:[`<div class='${this.options.classes.row}'>`,`<label class='${this.options.classes.label}'>${o(s)}</label>`,`<input class='${this.options.classes.input}' value='${o(t.text||String(t.data)||"")}' type='text'>`,"</div>"].join("")}),a.lastElementChild)}})),this.modalDOM=s,this.openModal();const r=Array.from(a.querySelectorAll(`input.${this.options.classes.input}[type=text]`));r.pop(),s.addEventListener("click",(t=>{const e=t.target;e instanceof Element&&(e.hasAttribute("data-editor-close")?this.options.closeModal(this)&&this.closeModal():e.hasAttribute("data-editor-save")&&this.saveRow(r.map((t=>t.value.trim())),this.data.row))}))}saveRow(t,e){const s=e.map((t=>t.text??String(t.data)));this.dt.data.data[this.data.rowIndex]=this.dt.data.data[this.data.rowIndex].map(((e,s)=>{if(this.dt.columns.settings[s].hidden||this.options.excludeColumns.includes(s))return e;const i=this.dt.columns.settings[s].type||this.dt.options.type,n=t[l(s,this.dt.columns.settings)],a=n.trim();let o;if("number"===i)o={data:parseFloat(a)};else if("boolean"===i)o=["","false","0"].includes(a)?{data:!1,text:"false",order:0}:{data:!0,text:"true",order:1};else if("html"===i)o={data:[{nodeName:"#text",data:n}],text:n,order:n};else if("string"===i)o={data:n};else if("date"===i){const t=this.dt.columns.settings[s].format||this.dt.options.format;o={data:n,order:U(String(n),t)}}else o={data:n};return o})),this.data={},this.dt.update(!0),this.closeModal(),this.dt.emit("editable.save.row",t,s,e)}openModal(){this.modalDOM&&document.body.appendChild(this.modalDOM)}closeModal(){this.editing&&this.modalDOM&&(document.body.removeChild(this.modalDOM),this.modalDOM=this.editing=this.editingRow=this.editingCell=!1)}removeRow(t){if(!t||"TR"!==t.nodeName||this.editing)return;const e=parseInt(t.dataset.index,10);this.dt.rows.remove(e),this.closeMenu()}update(){const t=window.scrollX||window.pageXOffset,e=window.scrollY||window.pageYOffset;this.rect=this.wrapperDOM.getBoundingClientRect(),this.limits={x:window.innerWidth+t-this.rect.width,y:window.innerHeight+e-this.rect.height}}dismiss(t){const e=t.target;if(!(e instanceof Element)||this.wrapperDOM.contains(e))return;let s=!0;this.options.contextMenu&&this.editing&&(s=!e.matches(`input.${this.options.classes.input}[type=text]`)),s&&this.closeMenu()}openMenu(){if(this.editing&&this.data&&this.editingCell){const t=this.modalDOM?this.modalDOM.querySelector(`input.${this.options.classes.input}[type=text]`):this.dt.wrapperDOM.querySelector(`input.${this.options.classes.input}[type=text]`);this.saveCell(t.value)}this.options.contextMenu&&(document.body.appendChild(this.containerDOM),this.closed=!1,this.dt.emit("editable.context.open"))}closeMenu(){this.options.contextMenu&&!this.closed&&(this.closed=!0,document.body.removeChild(this.containerDOM),this.dt.emit("editable.context.close"))}destroy(){this.dt.dom.removeEventListener(this.options.clickEvent,this.events.click),this.dt.dom.removeEventListener("contextmenu",this.events.context),document.removeEventListener("click",this.events.dismiss),document.removeEventListener("keydown",this.events.keydown),window.removeEventListener("resize",this.events.reset),window.removeEventListener("scroll",this.events.reset),document.body.contains(this.containerDOM)&&document.body.removeChild(this.containerDOM),this.options.inline&&(this.dt.options.rowRender=this.originalRowRender),this.initialized=!1}rowRender(t,e,s){return this.data&&this.data.rowIndex===s?(this.editingCell?e.childNodes[l(this.data.columnIndex,this.dt.columns.settings)].childNodes=[{nodeName:"INPUT",attributes:{type:"text",value:this.data.content,class:this.options.classes.input}}]:e.childNodes.forEach(((s,i)=>{const n=r(i,this.dt.columns.settings),a=t[n];this.options.excludeColumns.includes(n)||(e.childNodes[i].childNodes=[{nodeName:"INPUT",attributes:{type:"text",value:o(a.text||String(a.data)||""),class:this.options.classes.input}}])})),e):e}}s.DataTable=class{constructor(t,e={}){const s="string"==typeof t?document.querySelector(t):t;s instanceof HTMLTableElement?this.dom=s:(this.dom=document.createElement("table"),s.appendChild(this.dom));const i={...X.labels,...e.labels},n={...X.classes,...e.classes};this.options={...X,...e,labels:i,classes:n},this._initialInnerHTML=this.options.destroyable?this.dom.innerHTML:"",this.options.tabIndex?this.dom.tabIndex=this.options.tabIndex:this.options.rowNavigation&&-1===this.dom.tabIndex&&(this.dom.tabIndex=0),this._listeners={onResize:()=>this._onResize()},this._dd=new P({valueDiffing:!1}),this.initialized=!1,this._events={},this._currentPage=0,this.onFirstPage=!0,this.hasHeadings=!1,this.hasRows=!1,this._searchQueries=[],this.init()}init(){if(this.initialized||this.dom.classList.contains(this.options.classes.table))return!1;this._virtualDOM=E(this.dom,{valueDiffing:!1}),this._tableAttributes={...this._virtualDOM.attributes},this.rows=new Q(this),this.columns=new Z(this),this.data=W(this.options.data,this.dom,this.columns.settings,this.options.type,this.options.format),this._render(),setTimeout((()=>{this.emit("datatable.init"),this.initialized=!0}),10)}_render(){this.wrapperDOM=n("div",{class:`${this.options.classes.wrapper} ${this.options.classes.loading}`}),this.wrapperDOM.innerHTML=this.options.template(this.options,this.dom);const t=this.wrapperDOM.querySelector(`select.${this.options.classes.selector}`);t&&this.options.paging&&this.options.perPageSelect?this.options.perPageSelect.forEach((e=>{const[s,i]=Array.isArray(e)?[e[0],e[1]]:[String(e),e],n=i===this.options.perPage,a=new Option(s,String(i),n,n);t.appendChild(a)})):t&&t.parentElement.removeChild(t),this.containerDOM=this.wrapperDOM.querySelector(`.${this.options.classes.container}`),this._pagerDOMs=[],Array.from(this.wrapperDOM.querySelectorAll(`.${this.options.classes.pagination}`)).forEach((t=>{t instanceof HTMLElement&&(t.innerHTML=`<ul class="${this.options.classes.paginationList}"></ul>`,this._pagerDOMs.push(t.firstElementChild))})),this._virtualPagerDOM={nodeName:"UL",attributes:{class:this.options.classes.paginationList}},this._label=this.wrapperDOM.querySelector(`.${this.options.classes.info}`),this.dom.parentElement.replaceChild(this.wrapperDOM,this.dom),this.containerDOM.appendChild(this.dom),this._rect=this.dom.getBoundingClientRect(),this._fixHeight(),this.options.header||this.wrapperDOM.classList.add("no-header"),this.options.footer||this.wrapperDOM.classList.add("no-footer"),this.options.sortable&&this.wrapperDOM.classList.add("sortable"),this.options.searchable&&this.wrapperDOM.classList.add("searchable"),this.options.fixedHeight&&this.wrapperDOM.classList.add("fixed-height"),this.options.fixedColumns&&this.wrapperDOM.classList.add("fixed-columns"),this._bindEvents(),this.columns._state.sort&&this.columns.sort(this.columns._state.sort.column,this.columns._state.sort.dir,!0),this.update(!0)}_renderTable(t={}){let e=R(this._tableAttributes,this.data.headings,(this.options.paging||this._searchQueries.length)&&this._currentPage&&this.pages.length&&!t.noPaging?this.pages[this._currentPage-1]:this.data.data.map(((t,e)=>({row:t,index:e}))),this.columns.settings,this.columns._state,this.rows.cursor,this.options,t);if(this.options.tableRender){const t=this.options.tableRender(this.data,e,"main");t&&(e=t)}const s=this._dd.diff(this._virtualDOM,e);this._dd.apply(this.dom,s),this._virtualDOM=e}_renderPage(t=!1){this.hasRows&&this.totalPages?(this._currentPage>this.totalPages&&(this._currentPage=1),this._renderTable(),this.onFirstPage=1===this._currentPage,this.onLastPage=this._currentPage===this.lastPage):this.setMessage(this.options.labels.noRows);let e,s=0,i=0,n=0;if(this.totalPages&&(s=this._currentPage-1,i=s*this.options.perPage,n=i+this.pages[s].length,i+=1,e=this._searchQueries.length?this._searchData.length:this.data.data.length),this._label&&this.options.labels.info.length){const t=this.options.labels.info.replace("{start}",String(i)).replace("{end}",String(n)).replace("{page}",String(this._currentPage)).replace("{pages}",String(this.totalPages)).replace("{rows}",String(e));this._label.innerHTML=e?t:""}if(1==this._currentPage&&this._fixHeight(),this.options.rowNavigation&&this._currentPage&&(!this.rows.cursor||!this.pages[this._currentPage-1].find((t=>t.index===this.rows.cursor)))){const e=this.pages[this._currentPage-1];e.length&&(t?this.rows.setCursor(e[e.length-1].index):this.rows.setCursor(e[0].index))}}_renderPagers(){if(!this.options.paging)return;let t=((t,e,s,i,n)=>{let a=[];if(n.firstLast&&a.push(G(1,n.firstText,n)),n.nextPrev){const e=t?1:s-1;a.push(G(e,n.prevText,n,{hidden:t}))}let o=[...Array(i).keys()].map((t=>G(t+1,String(t+1),n,{active:t===s-1})));if(n.truncatePager&&(o=((t,e,s,i)=>{const n=i.pagerDelta,a=i.classes,o=i.ellipsisText,r=2*n;let l=e-n,d=e+n;e<4-n+r?d=3+r:e>s-(3-n+r)&&(l=s-(2+r));const c=[];for(let e=1;e<=s;e++)if(1==e||e==s||e>=l&&e<=d){const s=t[e-1];c.push(s)}let h;const u=[];return c.forEach((e=>{const s=parseInt(e.childNodes[0].attributes["data-page"],10);if(h){const e=parseInt(h.childNodes[0].attributes["data-page"],10);if(s-e==2)u.push(t[e]);else if(s-e!=1){const t={nodeName:"LI",attributes:{class:`${a.paginationListItem} ${a.ellipsis} ${a.disabled}`},childNodes:[{nodeName:"A",attributes:{class:a.paginationListItemLink},childNodes:[{nodeName:"#text",data:o}]}]};u.push(t)}}u.push(e),h=e})),u})(o,s,i,n)),a=a.concat(o),n.nextPrev){const t=e?i:s+1;a.push(G(t,n.nextText,n,{hidden:e}))}return n.firstLast&&a.push(G(i,n.lastText,n)),{nodeName:"UL",attributes:{class:n.classes.paginationList},childNodes:o.length>1?a:[]}})(this.onFirstPage,this.onLastPage,this._currentPage,this.totalPages,this.options);if(this.options.pagerRender){const e=this.options.pagerRender([this.onFirstPage,this.onLastPage,this._currentPage,this.totalPages],t);e&&(t=e)}const e=this._dd.diff(this._virtualPagerDOM,t);this._pagerDOMs.forEach((t=>{this._dd.apply(t,e)})),this._virtualPagerDOM=t}_renderSeparateHeader(){const t=this.dom.parentElement;this.headerDOM||(this.headerDOM=document.createElement("div"),this._virtualHeaderDOM={nodeName:"DIV"}),t.parentElement.insertBefore(this.headerDOM,t);let e={nodeName:"TABLE",attributes:{class:this.options.classes.table},childNodes:[{nodeName:"THEAD",childNodes:[H(this.data.headings,this.columns.settings,this.columns._state,this.options,{unhideHeader:!0})]}]};if(this.options.tableRender){const t=this.options.tableRender(this.data,e,"header");t&&(e=t)}const s={nodeName:"DIV",attributes:{class:this.options.classes.headercontainer},childNodes:[e]},i=this._dd.diff(this._virtualHeaderDOM,s);this._dd.apply(this.headerDOM,i),this._virtualHeaderDOM=s;const n=this.headerDOM.firstElementChild.clientWidth-this.dom.clientWidth;if(n){const t=structuredClone(this._virtualHeaderDOM);t.attributes.style=`padding-right: ${n}px;`;const e=this._dd.diff(this._virtualHeaderDOM,t);this._dd.apply(this.headerDOM,e),this._virtualHeaderDOM=t}t.scrollHeight>t.clientHeight&&(t.style.overflowY="scroll")}_bindEvents(){if(this.options.perPageSelect){const t=this.wrapperDOM.querySelector(`select.${this.options.classes.selector}`);t&&t instanceof HTMLSelectElement&&t.addEventListener("change",(()=>{this.options.perPage=parseInt(t.value,10),this.update(),this._fixHeight(),this.emit("datatable.perpage",this.options.perPage)}),!1)}this.options.searchable&&this.wrapperDOM.addEventListener("keyup",(t=>{const e=t.target;if(!(e instanceof HTMLInputElement&&e.matches(`.${this.options.classes.input}`)))return;t.preventDefault();const s=Array.from(this.wrapperDOM.querySelectorAll(`.${this.options.classes.input}`)).filter((t=>t.value.length)).map((t=>t.dataset.columns?{term:t.value,columns:JSON.parse(t.dataset.columns)}:{term:t.value,columns:void 0}));if(1===s.length){const t=s[0];this.search(t.term,t.columns)}else this.multiSearch(s)})),this.wrapperDOM.addEventListener("click",(t=>{const e=t.target.closest("a");if(e)if(e.hasAttribute("data-page"))this.page(parseInt(e.getAttribute("data-page"),10)),t.preventDefault();else if(e.classList.contains(this.options.classes.sorter)){const s=Array.from(e.parentElement.parentElement.children).indexOf(e.parentElement),i=r(s,this.columns.settings);this.columns.sort(i),t.preventDefault()}else if(e.classList.contains(this.options.classes.filter)){const s=Array.from(e.parentElement.parentElement.children).indexOf(e.parentElement),i=r(s,this.columns.settings);this.columns.filter(i),t.preventDefault()}}),!1),this.options.rowNavigation?(this.dom.addEventListener("keydown",(t=>{if("ArrowUp"===t.key){let e;t.preventDefault(),t.stopPropagation(),this.pages[this._currentPage-1].find((t=>t.index===this.rows.cursor||(e=t,!1))),e?this.rows.setCursor(e.index):this.onFirstPage||this.page(this._currentPage-1,!0)}else if("ArrowDown"===t.key){let e;t.preventDefault(),t.stopPropagation();const s=this.pages[this._currentPage-1].find((t=>!!e||(t.index===this.rows.cursor&&(e=!0),!1)));s?this.rows.setCursor(s.index):this.onLastPage||this.page(this._currentPage+1)}else["Enter"," "].includes(t.key)&&this.emit("datatable.selectrow",this.rows.cursor,t)})),this.dom.addEventListener("mousedown",(t=>{const e=t.target;if(e instanceof Element&&this.dom.matches(":focus")){const s=Array.from(this.dom.querySelectorAll("body tr")).find((t=>t.contains(e)));s&&s instanceof HTMLElement&&this.emit("datatable.selectrow",parseInt(s.dataset.index,10),t)}}))):this.dom.addEventListener("mousedown",(t=>{const e=t.target;if(!(e instanceof Element))return;const s=Array.from(this.dom.querySelectorAll("body tr")).find((t=>t.contains(e)));s&&s instanceof HTMLElement&&this.emit("datatable.selectrow",parseInt(s.dataset.index,10),t)})),window.addEventListener("resize",this._listeners.onResize)}_onResize(){this._rect=this.containerDOM.getBoundingClientRect(),this._rect.width&&this.update(!0)}destroy(){this.options.destroyable&&(this.dom.innerHTML=this._initialInnerHTML,this.dom.classList.remove(this.options.classes.table),this.wrapperDOM.parentElement&&this.wrapperDOM.parentElement.replaceChild(this.dom,this.wrapperDOM),this.initialized=!1,window.removeEventListener("resize",this._listeners.onResize))}update(t=!1){t&&(this.columns._measureWidths(),this.hasRows=Boolean(this.data.data.length),this.hasHeadings=Boolean(this.data.headings.length)),this.wrapperDOM.classList.remove(this.options.classes.empty),this._paginate(),this._renderPage(),this._renderPagers(),this.options.scrollY.length&&this._renderSeparateHeader(),this.emit("datatable.update")}_paginate(){let t=this.data.data.map(((t,e)=>({row:t,index:e})));return this._searchQueries.length&&(t=[],this._searchData.forEach((e=>t.push({index:e,row:this.data.data[e]})))),this.columns._state.filters.length&&this.columns._state.filters.forEach(((e,s)=>{e&&(t=t.filter((t=>"function"==typeof e?e(t.row[s].data):(t.row[s].text??t.row[s].data)===e)))})),this.options.paging&&this.options.perPage>0?this.pages=t.map(((e,s)=>s%this.options.perPage==0?t.slice(s,s+this.options.perPage):null)).filter((t=>t)):this.pages=[t],this.totalPages=this.lastPage=this.pages.length,this._currentPage||(this._currentPage=1),this.totalPages}_fixHeight(){this.options.fixedHeight&&(this.containerDOM.style.height=null,this._rect=this.containerDOM.getBoundingClientRect(),this.containerDOM.style.height=`${this._rect.height}px`)}search(t,e){if(!t.length)return this._currentPage=1,this._searchQueries=[],this._searchData=[],this.update(),this.emit("datatable.search","",[]),this.wrapperDOM.classList.remove("search-results"),!1;this.multiSearch([{term:t,columns:e||void 0}]),this.emit("datatable.search",t,this._searchData)}multiSearch(t){if(!this.hasRows)return!1;if(this._currentPage=1,this._searchQueries=t,this._searchData=[],!(t=t.filter((t=>t.term.length))).length)return this.update(),this.emit("datatable.multisearch",t,this._searchData),this.wrapperDOM.classList.remove("search-results"),!1;const e=t.map((t=>this.columns.settings.map(((e,s)=>{if(e.hidden||!e.searchable||t.columns&&!t.columns.includes(s))return!1;let i=t.term;const n=e.sensitivity||this.options.sensitivity;return["base","accent"].includes(n)&&(i=i.toLowerCase()),["base","case"].includes(n)&&(i=i.normalize("NFD").replace(/\p{Diacritic}/gu,"")),(e.ignorePunctuation||this.options.ignorePunctuation)&&(i=i.replace(/[.,/#!$%^&*;:{}=-_`~()]/g,"")),i}))));this.data.data.forEach(((t,s)=>{const i=t.map(((t,e)=>{let s=(t.text||String(t.data)).trim();if(s.length){const t=this.columns.settings[e],i=t.sensitivity||this.options.sensitivity;["base","accent"].includes(i)&&(s=s.toLowerCase()),["base","case"].includes(i)&&(s=s.normalize("NFD").replace(/\p{Diacritic}/gu,"")),(t.ignorePunctuation||this.options.ignorePunctuation)&&(s=s.replace(/[.,/#!$%^&*;:{}=-_`~()]/g,""))}return s}));e.every((t=>t.find(((t,e)=>!!t&&t.split(" ").find((t=>i[e].includes(t)))))))&&this._searchData.push(s)})),this.wrapperDOM.classList.add("search-results"),this._searchData.length?this.update():(this.wrapperDOM.classList.remove("search-results"),this.setMessage(this.options.labels.noResults)),this.emit("datatable.multisearch",t,this._searchData)}page(t,e=!1){return t!==this._currentPage&&(isNaN(t)||(this._currentPage=t),!(t>this.pages.length||t<0)&&(this._renderPage(e),this._renderPagers(),void this.emit("datatable.page",t)))}insert(t){let s=[];if(Array.isArray(t)){const e=this.data.headings.map((t=>t.text??String(t.data)));t.forEach(((t,i)=>{const n=[];Object.entries(t).forEach((([t,s])=>{const a=e.indexOf(t);a>-1?n[a]=z(s,this.columns.settings[a]):this.hasHeadings||this.hasRows||0!==i||(n[e.length]=z(s,this.columns.settings[e.length]),e.push(t),this.data.headings.push(J(t)))})),s.push(n)}))}else e(t)&&(!t.headings||this.hasHeadings||this.hasRows?t.data&&Array.isArray(t.data)&&(s=t.data.map((t=>t.map(((t,e)=>z(t,this.columns.settings[e])))))):this.data=W(t,void 0,this.columns.settings,this.options.type,this.options.format));s.length&&s.forEach((t=>this.data.data.push(t))),this.hasHeadings=Boolean(this.data.headings.length),this.columns._state.sort&&this.columns.sort(this.columns._state.sort.column,this.columns._state.sort.dir,!0),this.update(!0)}refresh(){this.options.searchable&&(Array.from(this.wrapperDOM.querySelectorAll(`.${this.options.classes.input}`)).forEach((t=>{t.value=""})),this._searchQueries=[]),this._currentPage=1,this.onFirstPage=!0,this.update(!0),this.emit("datatable.refresh")}print(){const t=n("table");let e=R(this._tableAttributes,this.data.headings,this.data.data.map(((t,e)=>({row:t,index:e}))),this.columns.settings,this.columns._state,!1,this.options,{noColumnWidths:!0,unhideHeader:!0});if(this.options.tableRender){const t=this.options.tableRender(this.data,e,"print");t&&(e=t)}const s=this._dd.diff({nodeName:"TABLE"},e);this._dd.apply(t,s);const i=window.open();i.document.body.appendChild(t),i.print()}setMessage(t){const e=this.data.headings.filter(((t,e)=>!this.columns.settings[e]?.hidden)).length||1;this.wrapperDOM.classList.add(this.options.classes.empty),this._label&&(this._label.innerHTML=""),this.totalPages=0,this._renderPagers();let s={nodeName:"TABLE",attributes:{class:this.options.classes.table},childNodes:[{nodeName:"THEAD",childNodes:[H(this.data.headings,this.columns.settings,this.columns._state,this.options,{})]},{nodeName:"TBODY",childNodes:[{nodeName:"TR",childNodes:[{nodeName:"TD",attributes:{class:this.options.classes.empty,colspan:String(e)},childNodes:[{nodeName:"#text",data:t}]}]}]}]};if(this.options.tableRender){const t=this.options.tableRender(this.data,s,"message");t&&(s=t)}const i=this._dd.diff(this._virtualDOM,s);this._dd.apply(this.dom,i),this._virtualDOM=s}on(t,e){this._events[t]=this._events[t]||[],this._events[t].push(e)}off(t,e){t in this._events!=0&&this._events[t].splice(this._events[t].indexOf(e),1)}emit(t,...e){if(t in this._events!=0)for(let s=0;s<this._events[t].length;s++)this._events[t][s](...e)}},s.convertCSV=function(t){let s;if(!e(t))return!1;const i={lineDelimiter:"\n",columnDelimiter:",",removeDoubleQuotes:!1,...t};if(i.data.length){s={data:[]};const t=i.data.split(i.lineDelimiter);if(t.length&&(i.headings&&(s.headings=t[0].split(i.columnDelimiter),i.removeDoubleQuotes&&(s.headings=s.headings.map((t=>t.trim().replace(/(^"|"$)/g,"")))),t.shift()),t.forEach(((t,e)=>{s.data[e]=[];const n=t.split(i.columnDelimiter);n.length&&n.forEach((t=>{i.removeDoubleQuotes&&(t=t.trim().replace(/(^"|"$)/g,"")),s.data[e].push(t)}))}))),s)return s}return!1},s.convertJSON=function(t){let s;if(!e(t))return!1;const n={data:"",...t};if(n.data.length||e(n.data)){const t=!!i(n.data)&&JSON.parse(n.data);if(t?(s={headings:[],data:[]},t.forEach(((t,e)=>{s.data[e]=[],Object.entries(t).forEach((([t,i])=>{s.headings.includes(t)||s.headings.push(t),s.data[e].push(i)}))}))):console.warn("That's not valid JSON!"),s)return s}return!1},s.createElement=n,s.exportCSV=function(t,s={}){if(!t.hasHeadings&&!t.hasRows)return!1;if(!e(s))return!1;const i={download:!0,skipColumn:[],lineDelimiter:"\n",columnDelimiter:",",...s},n=e=>!i.skipColumn.includes(e)&&!t.columns.settings[e]?.hidden;let a=[];const o=t.data.headings.filter(((t,e)=>n(e))).map((t=>t.text??t.data));if(a[0]=o,i.selection)if(Array.isArray(i.selection))for(let e=0;e<i.selection.length;e++)a=a.concat(t.pages[i.selection[e]-1].map((t=>t.row.filter(((t,e)=>n(e))).map((t=>t.text??t.data)))));else a=a.concat(t.pages[i.selection-1].map((t=>t.row.filter(((t,e)=>n(e))).map((t=>t.text??t.data)))));else a=a.concat(t.data.data.map((t=>t.filter(((t,e)=>n(e))).map((t=>t.text??t.data)))));if(a.length){let t="";if(a.forEach((e=>{e.forEach((e=>{"string"==typeof e&&(e=(e=(e=(e=(e=e.trim()).replace(/\s{2,}/g," ")).replace(/\n/g," ")).replace(/"/g,'""')).replace(/#/g,"%23")).includes(",")&&(e=`"${e}"`),t+=e+i.columnDelimiter})),t=t.trim().substring(0,t.length-1),t+=i.lineDelimiter})),t=t.trim().substring(0,t.length-1),i.download){const e=document.createElement("a");e.href=encodeURI(`data:text/csv;charset=utf-8,${t}`),e.download=`${i.filename||"datatable_export"}.csv`,document.body.appendChild(e),e.click(),document.body.removeChild(e)}return t}return!1},s.exportJSON=function(t,s={}){if(!t.hasHeadings&&!t.hasRows)return!1;if(!e(s))return!1;const i={download:!0,skipColumn:[],replacer:null,space:4,...s},n=e=>!i.skipColumn.includes(e)&&!t.columns.settings[e]?.hidden;let a=[];if(i.selection)if(Array.isArray(i.selection))for(let e=0;e<i.selection.length;e++)a=a.concat(t.pages[i.selection[e]-1].map((t=>t.row.filter(((t,e)=>n(e))).map((t=>t.data)))));else a=a.concat(t.pages[i.selection-1].map((t=>t.row.filter(((t,e)=>n(e))).map((t=>t.data)))));else a=a.concat(t.data.data.map((t=>t.filter(((t,e)=>n(e))).map((t=>t.data)))));const o=t.data.headings.filter(((t,e)=>n(e))).map((t=>t.text??String(t.data)));if(a.length){const t=[];a.forEach(((e,s)=>{t[s]=t[s]||{},e.forEach(((e,i)=>{t[s][o[i]]=e}))}));const e=JSON.stringify(t,i.replacer,i.space);if(i.download){const t=new Blob([e],{type:"data:application/json;charset=utf-8"}),s=URL.createObjectURL(t),n=document.createElement("a");n.href=s,n.download=`${i.filename||"datatable_export"}.json`,document.body.appendChild(n),n.click(),document.body.removeChild(n),URL.revokeObjectURL(s)}return e}return!1},s.exportSQL=function(t,s={}){if(!t.hasHeadings&&!t.hasRows)return!1;if(!e(s))return!1;const i={download:!0,skipColumn:[],tableName:"myTable",...s},n=e=>!i.skipColumn.includes(e)&&!t.columns.settings[e]?.hidden;let a=[];if(i.selection)if(Array.isArray(i.selection))for(let e=0;e<i.selection.length;e++)a=a.concat(t.pages[i.selection[e]-1].map((t=>t.row.filter(((t,e)=>n(e))).map((t=>t.text??t.data)))));else a=a.concat(t.pages[i.selection-1].map((t=>t.row.filter(((t,e)=>n(e))).map((t=>t.text??t.data)))));else a=a.concat(t.data.data.map((t=>t.filter(((t,e)=>n(e))).map((t=>t.data)))));const o=t.data.headings.filter(((t,e)=>n(e))).map((t=>t.text??String(t.data)));if(a.length){let t=`INSERT INTO \`${i.tableName}\` (`;if(o.forEach((e=>{t+=`\`${e}\`,`})),t=t.trim().substring(0,t.length-1),t+=") VALUES ",a.forEach((e=>{t+="(",e.forEach((e=>{t+="string"==typeof e?`"${e}",`:`${e},`})),t=t.trim().substring(0,t.length-1),t+="),"})),t=t.trim().substring(0,t.length-1),t+=";",i.download&&(t=`data:application/sql;charset=utf-8,${t}`),i.download){const e=document.createElement("a");e.href=encodeURI(t),e.download=`${i.filename||"datatable_export"}.sql`,document.body.appendChild(e),e.click(),document.body.removeChild(e)}return t}return!1},s.exportTXT=function(t,s={}){if(!t.hasHeadings&&!t.hasRows)return!1;if(!e(s))return!1;const i={download:!0,skipColumn:[],lineDelimiter:"\n",columnDelimiter:",",...s},n=e=>!i.skipColumn.includes(e)&&!t.columns.settings[e]?.hidden;let a=[];const o=t.data.headings.filter(((t,e)=>n(e))).map((t=>t.text??t.data));if(a[0]=o,i.selection)if(Array.isArray(i.selection))for(let e=0;e<i.selection.length;e++)a=a.concat(t.pages[i.selection[e]-1].map((t=>t.row.filter(((t,e)=>n(e))).map((t=>t.data)))));else a=a.concat(t.pages[i.selection-1].map((t=>t.row.filter(((t,e)=>n(e))).map((t=>t.data)))));else a=a.concat(t.data.data.map((t=>t.filter(((t,e)=>n(e))).map((t=>t.data)))));if(a.length){let t="";if(a.forEach((e=>{e.forEach((e=>{"string"==typeof e&&(e=(e=(e=(e=(e=e.trim()).replace(/\s{2,}/g," ")).replace(/\n/g," ")).replace(/"/g,'""')).replace(/#/g,"%23")).includes(",")&&(e=`"${e}"`),t+=e+i.columnDelimiter})),t=t.trim().substring(0,t.length-1),t+=i.lineDelimiter})),t=t.trim().substring(0,t.length-1),i.download&&(t=`data:text/csv;charset=utf-8,${t}`),i.download){const e=document.createElement("a");e.href=encodeURI(t),e.download=`${i.filename||"datatable_export"}.txt`,document.body.appendChild(e),e.click(),document.body.removeChild(e)}return t}return!1},s.isJson=i,s.isObject=e,s.makeEditable=function(t,e={}){const s=new tt(t,e);return t.initialized?s.init():t.on("datatable.init",(()=>s.init())),s}}).call(this)}).call(this,"undefined"!=typeof global?global:"undefined"!=typeof self?self:"undefined"!=typeof window?window:{})},{}]},{},[1])(1)})); -//# sourceMappingURL=/sm/b3ca363f820fa7b796cc283e0190046ee1f83934229a9c6dc428254ac195763c.map \ No newline at end of file +!function(t){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=t();else if("function"==typeof define&&define.amd)define([],t);else{("undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this).simpleDatatables=t()}}((function(){return function t(e,s,i){function n(o,r){if(!s[o]){if(!e[o]){var l="function"==typeof require&&require;if(!r&&l)return l(o,!0);if(a)return a(o,!0);var d=new Error("Cannot find module '"+o+"'");throw d.code="MODULE_NOT_FOUND",d}var c=s[o]={exports:{}};e[o][0].call(c.exports,(function(t){return n(e[o][1][t]||t)}),c,c.exports,t,e,s,i)}return s[o].exports}for(var a="function"==typeof require&&require,o=0;o<i.length;o++)n(i[o]);return n}({1:[function(t,e,s){(function(t){(function(){"use strict";const e=t=>"[object Object]"===Object.prototype.toString.call(t),i=t=>{let s=!1;try{s=JSON.parse(t)}catch(t){return!1}return!(null===s||!Array.isArray(s)&&!e(s))&&s},n=(t,e)=>{const s=document.createElement(t);if(e&&"object"==typeof e)for(const t in e)"html"===t?s.innerHTML=e[t]:s.setAttribute(t,e[t]);return s},a=t=>["#text","#comment"].includes(t.nodeName)?t.data:t.childNodes?t.childNodes.map((t=>a(t))).join(""):"",o=t=>{if(null==t)return"";if(t.hasOwnProperty("text")||t.hasOwnProperty("data")){const e=t;return e.text??o(e.data)}return t.hasOwnProperty("nodeName")?a(t):String(t)},r=function(t){return t.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""")},l=function(t,e){let s=0,i=0;for(;s<t+1;)e[i].hidden||(s+=1),i+=1;return i-1},d=function(t){const e={};if(t)for(const s of t)e[s.name]=s.value;return e},c=t=>t?t.trim().split(" ").map((t=>`.${t}`)).join(""):null,h=(t,e)=>{const s=e?.split(" ").some((e=>!t.classList.contains(e)));return!s},u=(t,e)=>t?e?`${t} ${e}`:t:e||"",p=function(t,e=300){let s;return(...i)=>{clearTimeout(s),s=window.setTimeout((()=>t()),e)}};var f=function(){return f=Object.assign||function(t){for(var e,s=arguments,i=1,n=arguments.length;i<n;i++)for(var a in e=s[i])Object.prototype.hasOwnProperty.call(e,a)&&(t[a]=e[a]);return t},f.apply(this,arguments)};function m(t,e,s){if(s||2===arguments.length)for(var i,n=0,a=e.length;n<a;n++)!i&&n in e||(i||(i=Array.prototype.slice.call(e,0,n)),i[n]=e[n]);return t.concat(i||Array.prototype.slice.call(e))}"function"==typeof SuppressedError&&SuppressedError;var g=function(){function t(t){void 0===t&&(t={});var e=this;Object.entries(t).forEach((function(t){var s=t[0],i=t[1];return e[s]=i}))}return t.prototype.toString=function(){return JSON.stringify(this)},t.prototype.setValue=function(t,e){return this[t]=e,this},t}();function b(t){for(var e=arguments,s=[],i=1;i<arguments.length;i++)s[i-1]=e[i];return null!=t&&s.some((function(e){var s,i;return"function"==typeof(null===(i=null===(s=null==t?void 0:t.ownerDocument)||void 0===s?void 0:s.defaultView)||void 0===i?void 0:i[e])&&t instanceof t.ownerDocument.defaultView[e]}))}function v(t,e,s){var i;return"#text"===t.nodeName?i=s.document.createTextNode(t.data):"#comment"===t.nodeName?i=s.document.createComment(t.data):(e?(i=s.document.createElementNS("http://www.w3.org/2000/svg",t.nodeName),"foreignObject"===t.nodeName&&(e=!1)):"svg"===t.nodeName.toLowerCase()?(i=s.document.createElementNS("http://www.w3.org/2000/svg","svg"),e=!0):i=s.document.createElement(t.nodeName),t.attributes&&Object.entries(t.attributes).forEach((function(t){var e=t[0],s=t[1];return i.setAttribute(e,s)})),t.childNodes&&t.childNodes.forEach((function(t){return i.appendChild(v(t,e,s))})),s.valueDiffing&&(t.value&&b(i,"HTMLButtonElement","HTMLDataElement","HTMLInputElement","HTMLLIElement","HTMLMeterElement","HTMLOptionElement","HTMLProgressElement","HTMLParamElement")&&(i.value=t.value),t.checked&&b(i,"HTMLInputElement")&&(i.checked=t.checked),t.selected&&b(i,"HTMLOptionElement")&&(i.selected=t.selected))),i}var w=function(t,e){for(e=e.slice();e.length>0;){var s=e.splice(0,1)[0];t=t.childNodes[s]}return t};function _(t,e,s){var i,n,a,o=e[s._const.action],r=e[s._const.route];[s._const.addElement,s._const.addTextElement].includes(o)||(i=w(t,r));var l={diff:e,node:i};if(s.preDiffApply(l))return!0;switch(o){case s._const.addAttribute:if(!i||!b(i,"Element"))return!1;i.setAttribute(e[s._const.name],e[s._const.value]);break;case s._const.modifyAttribute:if(!i||!b(i,"Element"))return!1;i.setAttribute(e[s._const.name],e[s._const.newValue]),b(i,"HTMLInputElement")&&"value"===e[s._const.name]&&(i.value=e[s._const.newValue]);break;case s._const.removeAttribute:if(!i||!b(i,"Element"))return!1;i.removeAttribute(e[s._const.name]);break;case s._const.modifyTextElement:if(!i||!b(i,"Text"))return!1;s.textDiff(i,i.data,e[s._const.oldValue],e[s._const.newValue]),b(i.parentNode,"HTMLTextAreaElement")&&(i.parentNode.value=e[s._const.newValue]);break;case s._const.modifyValue:if(!i||void 0===i.value)return!1;i.value=e[s._const.newValue];break;case s._const.modifyComment:if(!i||!b(i,"Comment"))return!1;s.textDiff(i,i.data,e[s._const.oldValue],e[s._const.newValue]);break;case s._const.modifyChecked:if(!i||void 0===i.checked)return!1;i.checked=e[s._const.newValue];break;case s._const.modifySelected:if(!i||void 0===i.selected)return!1;i.selected=e[s._const.newValue];break;case s._const.replaceElement:var d="svg"===e[s._const.newValue].nodeName.toLowerCase()||"http://www.w3.org/2000/svg"===i.parentNode.namespaceURI;i.parentNode.replaceChild(v(e[s._const.newValue],d,s),i);break;case s._const.relocateGroup:m([],new Array(e[s._const.groupLength]),!0).map((function(){return i.removeChild(i.childNodes[e[s._const.from]])})).forEach((function(t,n){0===n&&(a=i.childNodes[e[s._const.to]]),i.insertBefore(t,a||null)}));break;case s._const.removeElement:i.parentNode.removeChild(i);break;case s._const.addElement:var c=(u=r.slice()).splice(u.length-1,1)[0];if(!b(i=w(t,u),"Element"))return!1;i.insertBefore(v(e[s._const.element],"http://www.w3.org/2000/svg"===i.namespaceURI,s),i.childNodes[c]||null);break;case s._const.removeTextElement:if(!i||3!==i.nodeType)return!1;var h=i.parentNode;h.removeChild(i),b(h,"HTMLTextAreaElement")&&(h.value="");break;case s._const.addTextElement:var u;if(c=(u=r.slice()).splice(u.length-1,1)[0],n=s.document.createTextNode(e[s._const.value]),!(i=w(t,u)).childNodes)return!1;i.insertBefore(n,i.childNodes[c]||null),b(i.parentNode,"HTMLTextAreaElement")&&(i.parentNode.value=e[s._const.value]);break;default:console.log("unknown action")}return s.postDiffApply({diff:l.diff,node:l.node,newNode:n}),!0}function y(t,e,s){var i=t[e];t[e]=t[s],t[s]=i}var D=function(t){var e=[];return e.push(t.nodeName),"#text"!==t.nodeName&&"#comment"!==t.nodeName&&t.attributes&&(t.attributes.class&&e.push("".concat(t.nodeName,".").concat(t.attributes.class.replace(/ /g,"."))),t.attributes.id&&e.push("".concat(t.nodeName,"#").concat(t.attributes.id))),e},M=function(t){var e={},s={};return t.forEach((function(t){D(t).forEach((function(t){var i=t in e;i||t in s?i&&(delete e[t],s[t]=!0):e[t]=!0}))})),e},N=function(t,e){var s=M(t),i=M(e),n={};return Object.keys(s).forEach((function(t){i[t]&&(n[t]=!0)})),n},x=function(t){return delete t.outerDone,delete t.innerDone,delete t.valueDone,!t.childNodes||t.childNodes.every(x)},O=function(t){if(Object.prototype.hasOwnProperty.call(t,"data"))return{nodeName:"#text"===t.nodeName?"#text":"#comment",data:t.data};var e={nodeName:t.nodeName};return Object.prototype.hasOwnProperty.call(t,"attributes")&&(e.attributes=f({},t.attributes)),Object.prototype.hasOwnProperty.call(t,"checked")&&(e.checked=t.checked),Object.prototype.hasOwnProperty.call(t,"value")&&(e.value=t.value),Object.prototype.hasOwnProperty.call(t,"selected")&&(e.selected=t.selected),Object.prototype.hasOwnProperty.call(t,"childNodes")&&(e.childNodes=t.childNodes.map((function(t){return O(t)}))),e},E=function(t,e){if(!["nodeName","value","checked","selected","data"].every((function(s){return t[s]===e[s]})))return!1;if(Object.prototype.hasOwnProperty.call(t,"data"))return!0;if(Boolean(t.attributes)!==Boolean(e.attributes))return!1;if(Boolean(t.childNodes)!==Boolean(e.childNodes))return!1;if(t.attributes){var s=Object.keys(t.attributes),i=Object.keys(e.attributes);if(s.length!==i.length)return!1;if(!s.every((function(s){return t.attributes[s]===e.attributes[s]})))return!1}if(t.childNodes){if(t.childNodes.length!==e.childNodes.length)return!1;if(!t.childNodes.every((function(t,s){return E(t,e.childNodes[s])})))return!1}return!0},V=function(t,e,s,i,n){if(void 0===n&&(n=!1),!t||!e)return!1;if(t.nodeName!==e.nodeName)return!1;if(["#text","#comment"].includes(t.nodeName))return!!n||t.data===e.data;if(t.nodeName in s)return!0;if(t.attributes&&e.attributes){if(t.attributes.id){if(t.attributes.id!==e.attributes.id)return!1;if("".concat(t.nodeName,"#").concat(t.attributes.id)in s)return!0}if(t.attributes.class&&t.attributes.class===e.attributes.class&&"".concat(t.nodeName,".").concat(t.attributes.class.replace(/ /g,"."))in s)return!0}if(i)return!0;var a=t.childNodes?t.childNodes.slice().reverse():[],o=e.childNodes?e.childNodes.slice().reverse():[];if(a.length!==o.length)return!1;if(n)return a.every((function(t,e){return t.nodeName===o[e].nodeName}));var r=N(a,o);return a.every((function(t,e){return V(t,o[e],r,!0,!0)}))},$=function(t,e){return m([],new Array(t),!0).map((function(){return e}))},C=function(t,e){for(var s=t.childNodes?t.childNodes:[],i=e.childNodes?e.childNodes:[],n=$(s.length,!1),a=$(i.length,!1),o=[],r=function(){return arguments[1]},l=!1,d=function(){var t=function(t,e,s,i){var n=0,a=[],o=t.length,r=e.length,l=m([],new Array(o+1),!0).map((function(){return[]})),d=N(t,e),c=o===r;c&&t.some((function(t,s){var i=D(t),n=D(e[s]);return i.length!==n.length?(c=!1,!0):(i.some((function(t,e){if(t!==n[e])return c=!1,!0})),!c||void 0)}));for(var h=0;h<o;h++)for(var u=t[h],p=0;p<r;p++){var f=e[p];s[h]||i[p]||!V(u,f,d,c)?l[h+1][p+1]=0:(l[h+1][p+1]=l[h][p]?l[h][p]+1:1,l[h+1][p+1]>=n&&(n=l[h+1][p+1],a=[h+1,p+1]))}return 0!==n&&{oldValue:a[0]-n,newValue:a[1]-n,length:n}}(s,i,n,a);t?(o.push(t),m([],new Array(t.length),!0).map(r).forEach((function(e){return function(t,e,s,i){t[s.oldValue+i]=!0,e[s.newValue+i]=!0}(n,a,t,e)}))):l=!0};!l;)d();return t.subsets=o,t.subsetsAge=100,o},S=function(){function t(){this.list=[]}return t.prototype.add=function(t){var e;(e=this.list).push.apply(e,t)},t.prototype.forEach=function(t){this.list.forEach((function(e){return t(e)}))},t}();function k(t,e){var s,i,n=t;for(e=e.slice();e.length>0;)i=e.splice(0,1)[0],s=n,n=n.childNodes?n.childNodes[i]:void 0;return{node:n,parentNode:s,nodeIndex:i}}function T(t,e,s){return e.forEach((function(e){!function(t,e,s){var i,n,a,o;if(![s._const.addElement,s._const.addTextElement].includes(e[s._const.action])){var r=k(t,e[s._const.route]);n=r.node,a=r.parentNode,o=r.nodeIndex}var l,d,c=[],h={diff:e,node:n};if(s.preVirtualDiffApply(h))return!0;switch(e[s._const.action]){case s._const.addAttribute:n.attributes||(n.attributes={}),n.attributes[e[s._const.name]]=e[s._const.value],"checked"===e[s._const.name]?n.checked=!0:"selected"===e[s._const.name]?n.selected=!0:"INPUT"===n.nodeName&&"value"===e[s._const.name]&&(n.value=e[s._const.value]);break;case s._const.modifyAttribute:n.attributes[e[s._const.name]]=e[s._const.newValue];break;case s._const.removeAttribute:delete n.attributes[e[s._const.name]],0===Object.keys(n.attributes).length&&delete n.attributes,"checked"===e[s._const.name]?n.checked=!1:"selected"===e[s._const.name]?delete n.selected:"INPUT"===n.nodeName&&"value"===e[s._const.name]&&delete n.value;break;case s._const.modifyTextElement:n.data=e[s._const.newValue],"TEXTAREA"===a.nodeName&&(a.value=e[s._const.newValue]);break;case s._const.modifyValue:n.value=e[s._const.newValue];break;case s._const.modifyComment:n.data=e[s._const.newValue];break;case s._const.modifyChecked:n.checked=e[s._const.newValue];break;case s._const.modifySelected:n.selected=e[s._const.newValue];break;case s._const.replaceElement:l=O(e[s._const.newValue]),a.childNodes[o]=l;break;case s._const.relocateGroup:n.childNodes.splice(e[s._const.from],e[s._const.groupLength]).reverse().forEach((function(t){return n.childNodes.splice(e[s._const.to],0,t)})),n.subsets&&n.subsets.forEach((function(t){if(e[s._const.from]<e[s._const.to]&&t.oldValue<=e[s._const.to]&&t.oldValue>e[s._const.from])t.oldValue-=e[s._const.groupLength],(i=t.oldValue+t.length-e[s._const.to])>0&&(c.push({oldValue:e[s._const.to]+e[s._const.groupLength],newValue:t.newValue+t.length-i,length:i}),t.length-=i);else if(e[s._const.from]>e[s._const.to]&&t.oldValue>e[s._const.to]&&t.oldValue<e[s._const.from]){var i;t.oldValue+=e[s._const.groupLength],(i=t.oldValue+t.length-e[s._const.to])>0&&(c.push({oldValue:e[s._const.to]+e[s._const.groupLength],newValue:t.newValue+t.length-i,length:i}),t.length-=i)}else t.oldValue===e[s._const.from]&&(t.oldValue=e[s._const.to])}));break;case s._const.removeElement:a.childNodes.splice(o,1),a.subsets&&a.subsets.forEach((function(t){t.oldValue>o?t.oldValue-=1:t.oldValue===o?t.delete=!0:t.oldValue<o&&t.oldValue+t.length>o&&(t.oldValue+t.length-1===o?t.length--:(c.push({newValue:t.newValue+o-t.oldValue,oldValue:o,length:t.length-o+t.oldValue-1}),t.length=o-t.oldValue))})),n=a;break;case s._const.addElement:var u=(d=e[s._const.route].slice()).splice(d.length-1,1)[0];n=null===(i=k(t,d))||void 0===i?void 0:i.node,l=O(e[s._const.element]),n.childNodes||(n.childNodes=[]),u>=n.childNodes.length?n.childNodes.push(l):n.childNodes.splice(u,0,l),n.subsets&&n.subsets.forEach((function(t){if(t.oldValue>=u)t.oldValue+=1;else if(t.oldValue<u&&t.oldValue+t.length>u){var e=t.oldValue+t.length-u;c.push({newValue:t.newValue+t.length-e,oldValue:u+1,length:e}),t.length-=e}}));break;case s._const.removeTextElement:a.childNodes.splice(o,1),"TEXTAREA"===a.nodeName&&delete a.value,a.subsets&&a.subsets.forEach((function(t){t.oldValue>o?t.oldValue-=1:t.oldValue===o?t.delete=!0:t.oldValue<o&&t.oldValue+t.length>o&&(t.oldValue+t.length-1===o?t.length--:(c.push({newValue:t.newValue+o-t.oldValue,oldValue:o,length:t.length-o+t.oldValue-1}),t.length=o-t.oldValue))})),n=a;break;case s._const.addTextElement:var p=(d=e[s._const.route].slice()).splice(d.length-1,1)[0];l={nodeName:"#text",data:e[s._const.value]},(n=k(t,d).node).childNodes||(n.childNodes=[]),p>=n.childNodes.length?n.childNodes.push(l):n.childNodes.splice(p,0,l),"TEXTAREA"===n.nodeName&&(n.value=e[s._const.newValue]),n.subsets&&n.subsets.forEach((function(t){if(t.oldValue>=p&&(t.oldValue+=1),t.oldValue<p&&t.oldValue+t.length>p){var e=t.oldValue+t.length-p;c.push({newValue:t.newValue+t.length-e,oldValue:p+1,length:e}),t.length-=e}}));break;default:console.log("unknown action")}n.subsets&&(n.subsets=n.subsets.filter((function(t){return!t.delete&&t.oldValue!==t.newValue})),c.length&&(n.subsets=n.subsets.concat(c))),s.postVirtualDiffApply({node:h.node,diff:h.diff,newNode:l})}(t,e,s)})),!0}function A(t,e){void 0===e&&(e={valueDiffing:!0});var s={nodeName:t.nodeName};return b(t,"Text","Comment")?s.data=t.data:(t.attributes&&t.attributes.length>0&&(s.attributes={},Array.prototype.slice.call(t.attributes).forEach((function(t){return s.attributes[t.name]=t.value}))),t.childNodes&&t.childNodes.length>0&&(s.childNodes=[],Array.prototype.slice.call(t.childNodes).forEach((function(t){return s.childNodes.push(A(t,e))}))),e.valueDiffing&&(b(t,"HTMLTextAreaElement")&&(s.value=t.value),b(t,"HTMLInputElement")&&["radio","checkbox"].includes(t.type.toLowerCase())&&void 0!==t.checked?s.checked=t.checked:b(t,"HTMLButtonElement","HTMLDataElement","HTMLInputElement","HTMLLIElement","HTMLMeterElement","HTMLOptionElement","HTMLProgressElement","HTMLParamElement")&&(s.value=t.value),b(t,"HTMLOptionElement")&&(s.selected=t.selected))),s}var L=/<\s*\/*[a-zA-Z:_][a-zA-Z0-9:_\-.]*\s*(?:"[^"]*"['"]*|'[^']*'['"]*|[^'"/>])*\/*\s*>|<!--(?:.|\n|\r)*?-->/g,P=/\s([^'"/\s><]+?)[\s/>]|([^\s=]+)=\s?(".*?"|'.*?')/g;function R(t){return t.replace(/</g,"<").replace(/>/g,">").replace(/&/g,"&")}var H={area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,menuItem:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0},I=function(t,e){var s={nodeName:"",attributes:{}},i=!1,n=t.match(/<\/?([^\s]+?)[/\s>]/);if(n&&(s.nodeName=e||"svg"===n[1]?n[1]:n[1].toUpperCase(),(H[n[1]]||"/"===t.charAt(t.length-2))&&(i=!0),s.nodeName.startsWith("!--"))){var a=t.indexOf("--\x3e");return{type:"comment",node:{nodeName:"#comment",data:-1!==a?t.slice(4,a):""},voidElement:i}}for(var o=new RegExp(P),r=null,l=!1;!l;)if(null===(r=o.exec(t)))l=!0;else if(r[0].trim())if(r[1]){var d=r[1].trim(),c=[d,""];d.indexOf("=")>-1&&(c=d.split("=")),s.attributes[c[0]]=c[1],o.lastIndex--}else r[2]&&(s.attributes[r[2]]=r[3].trim().substring(1,r[3].length-1));return{type:"tag",node:s,voidElement:i}},Y=function(t,e){void 0===e&&(e={valueDiffing:!0,caseSensitive:!1});var s,i=[],n=-1,a=[],o=!1;if(0!==t.indexOf("<")){var r=t.indexOf("<");i.push({nodeName:"#text",data:-1===r?t:t.substring(0,r)})}return t.replace(L,(function(r,l){var d="/"!==r.charAt(1),c=r.startsWith("\x3c!--"),h=l+r.length,u=t.charAt(h);if(c){var p=I(r,e.caseSensitive).node;if(n<0)return i.push(p),"";var f=a[n];return f&&p.nodeName&&(f.node.childNodes||(f.node.childNodes=[]),f.node.childNodes.push(p)),""}if(d){if("svg"===(s=I(r,e.caseSensitive||o)).node.nodeName&&(o=!0),n++,!s.voidElement&&u&&"<"!==u){s.node.childNodes||(s.node.childNodes=[]);var m=R(t.slice(h,t.indexOf("<",h)));s.node.childNodes.push({nodeName:"#text",data:m}),e.valueDiffing&&"TEXTAREA"===s.node.nodeName&&(s.node.value=m)}0===n&&s.node.nodeName&&i.push(s.node);var g=a[n-1];g&&s.node.nodeName&&(g.node.childNodes||(g.node.childNodes=[]),g.node.childNodes.push(s.node)),a[n]=s}if((!d||s.voidElement)&&(n>-1&&(s.voidElement||e.caseSensitive&&s.node.nodeName===r.slice(2,-1)||!e.caseSensitive&&s.node.nodeName.toUpperCase()===r.slice(2,-1).toUpperCase())&&--n>-1&&("svg"===s.node.nodeName&&(o=!1),s=a[n]),"<"!==u&&u)){var b=-1===n?i:a[n].node.childNodes||[],v=t.indexOf("<",h);m=R(t.slice(h,-1===v?void 0:v)),b.push({nodeName:"#text",data:m})}return""})),i[0]},j=function(){function t(t,e,s){this.options=s,this.t1="undefined"!=typeof Element&&b(t,"Element")?A(t,this.options):"string"==typeof t?Y(t,this.options):JSON.parse(JSON.stringify(t)),this.t2="undefined"!=typeof Element&&b(e,"Element")?A(e,this.options):"string"==typeof e?Y(e,this.options):JSON.parse(JSON.stringify(e)),this.diffcount=0,this.foundAll=!1,this.debug&&(this.t1Orig="undefined"!=typeof Element&&b(t,"Element")?A(t,this.options):"string"==typeof t?Y(t,this.options):JSON.parse(JSON.stringify(t)),this.t2Orig="undefined"!=typeof Element&&b(e,"Element")?A(e,this.options):"string"==typeof e?Y(e,this.options):JSON.parse(JSON.stringify(e))),this.tracker=new S}return t.prototype.init=function(){return this.findDiffs(this.t1,this.t2)},t.prototype.findDiffs=function(t,e){var s;do{if(this.options.debug&&(this.diffcount+=1,this.diffcount>this.options.diffcap))throw new Error("surpassed diffcap:".concat(JSON.stringify(this.t1Orig)," -> ").concat(JSON.stringify(this.t2Orig)));0===(s=this.findNextDiff(t,e,[])).length&&(E(t,e)||(this.foundAll?console.error("Could not find remaining diffs!"):(this.foundAll=!0,x(t),s=this.findNextDiff(t,e,[])))),s.length>0&&(this.foundAll=!1,this.tracker.add(s),T(t,s,this.options))}while(s.length>0);return this.tracker.list},t.prototype.findNextDiff=function(t,e,s){var i,n;if(this.options.maxDepth&&s.length>this.options.maxDepth)return[];if(!t.outerDone){if(i=this.findOuterDiff(t,e,s),this.options.filterOuterDiff&&(n=this.options.filterOuterDiff(t,e,i))&&(i=n),i.length>0)return t.outerDone=!0,i;t.outerDone=!0}if(Object.prototype.hasOwnProperty.call(t,"data"))return[];if(!t.innerDone){if((i=this.findInnerDiff(t,e,s)).length>0)return i;t.innerDone=!0}if(this.options.valueDiffing&&!t.valueDone){if((i=this.findValueDiff(t,e,s)).length>0)return t.valueDone=!0,i;t.valueDone=!0}return[]},t.prototype.findOuterDiff=function(t,e,s){var i,n,a,o,r,l,d=[];if(t.nodeName!==e.nodeName){if(!s.length)throw new Error("Top level nodes have to be of the same kind.");return[(new g).setValue(this.options._const.action,this.options._const.replaceElement).setValue(this.options._const.oldValue,O(t)).setValue(this.options._const.newValue,O(e)).setValue(this.options._const.route,s)]}if(s.length&&this.options.diffcap<Math.abs((t.childNodes||[]).length-(e.childNodes||[]).length))return[(new g).setValue(this.options._const.action,this.options._const.replaceElement).setValue(this.options._const.oldValue,O(t)).setValue(this.options._const.newValue,O(e)).setValue(this.options._const.route,s)];if(Object.prototype.hasOwnProperty.call(t,"data")&&t.data!==e.data)return"#text"===t.nodeName?[(new g).setValue(this.options._const.action,this.options._const.modifyTextElement).setValue(this.options._const.route,s).setValue(this.options._const.oldValue,t.data).setValue(this.options._const.newValue,e.data)]:[(new g).setValue(this.options._const.action,this.options._const.modifyComment).setValue(this.options._const.route,s).setValue(this.options._const.oldValue,t.data).setValue(this.options._const.newValue,e.data)];for(n=t.attributes?Object.keys(t.attributes).sort():[],a=e.attributes?Object.keys(e.attributes).sort():[],o=n.length,l=0;l<o;l++)i=n[l],-1===(r=a.indexOf(i))?d.push((new g).setValue(this.options._const.action,this.options._const.removeAttribute).setValue(this.options._const.route,s).setValue(this.options._const.name,i).setValue(this.options._const.value,t.attributes[i])):(a.splice(r,1),t.attributes[i]!==e.attributes[i]&&d.push((new g).setValue(this.options._const.action,this.options._const.modifyAttribute).setValue(this.options._const.route,s).setValue(this.options._const.name,i).setValue(this.options._const.oldValue,t.attributes[i]).setValue(this.options._const.newValue,e.attributes[i])));for(o=a.length,l=0;l<o;l++)i=a[l],d.push((new g).setValue(this.options._const.action,this.options._const.addAttribute).setValue(this.options._const.route,s).setValue(this.options._const.name,i).setValue(this.options._const.value,e.attributes[i]));return d},t.prototype.findInnerDiff=function(t,e,s){var i=t.childNodes?t.childNodes.slice():[],n=e.childNodes?e.childNodes.slice():[],a=Math.max(i.length,n.length),o=Math.abs(i.length-n.length),r=[],l=0;if(!this.options.maxChildCount||a<this.options.maxChildCount){var d=Boolean(t.subsets&&t.subsetsAge--),c=d?t.subsets:t.childNodes&&e.childNodes?C(t,e):[];if(c.length>0&&(r=this.attemptGroupRelocation(t,e,c,s,d)).length>0)return r}for(var h=0;h<a;h+=1){var u=i[h],p=n[h];o&&(u&&!p?"#text"===u.nodeName?(r.push((new g).setValue(this.options._const.action,this.options._const.removeTextElement).setValue(this.options._const.route,s.concat(l)).setValue(this.options._const.value,u.data)),l-=1):(r.push((new g).setValue(this.options._const.action,this.options._const.removeElement).setValue(this.options._const.route,s.concat(l)).setValue(this.options._const.element,O(u))),l-=1):p&&!u&&("#text"===p.nodeName?r.push((new g).setValue(this.options._const.action,this.options._const.addTextElement).setValue(this.options._const.route,s.concat(l)).setValue(this.options._const.value,p.data)):r.push((new g).setValue(this.options._const.action,this.options._const.addElement).setValue(this.options._const.route,s.concat(l)).setValue(this.options._const.element,O(p))))),u&&p&&(!this.options.maxChildCount||a<this.options.maxChildCount?r=r.concat(this.findNextDiff(u,p,s.concat(l))):E(u,p)||(i.length>n.length?("#text"===u.nodeName?r.push((new g).setValue(this.options._const.action,this.options._const.removeTextElement).setValue(this.options._const.route,s.concat(l)).setValue(this.options._const.value,u.data)):r.push((new g).setValue(this.options._const.action,this.options._const.removeElement).setValue(this.options._const.element,O(u)).setValue(this.options._const.route,s.concat(l))),i.splice(h,1),h-=1,l-=1,o-=1):i.length<n.length?(r=r.concat([(new g).setValue(this.options._const.action,this.options._const.addElement).setValue(this.options._const.element,O(p)).setValue(this.options._const.route,s.concat(l))]),i.splice(h,0,O(p)),o-=1):r=r.concat([(new g).setValue(this.options._const.action,this.options._const.replaceElement).setValue(this.options._const.oldValue,O(u)).setValue(this.options._const.newValue,O(p)).setValue(this.options._const.route,s.concat(l))]))),l+=1}return t.innerDone=!0,r},t.prototype.attemptGroupRelocation=function(t,e,s,i,n){for(var a,o,r,l,d,c=function(t,e,s){var i=t.childNodes?$(t.childNodes.length,!0):[],n=e.childNodes?$(e.childNodes.length,!0):[],a=0;return s.forEach((function(t){for(var e=t.oldValue+t.length,s=t.newValue+t.length,o=t.oldValue;o<e;o+=1)i[o]=a;for(o=t.newValue;o<s;o+=1)n[o]=a;a+=1})),{gaps1:i,gaps2:n}}(t,e,s),h=c.gaps1,u=c.gaps2,p=t.childNodes.slice(),f=e.childNodes.slice(),m=Math.min(h.length,u.length),b=[],v=0,w=0;v<m;w+=1,v+=1)if(!n||!0!==h[v]&&!0!==u[v])if(!0===h[w])if("#text"===(l=p[w]).nodeName)if("#text"===f[v].nodeName){if(l.data!==f[v].data){for(var _=w;p.length>_+1&&"#text"===p[_+1].nodeName;)if(_+=1,f[v].data===p[_].data){d=!0;break}d||b.push((new g).setValue(this.options._const.action,this.options._const.modifyTextElement).setValue(this.options._const.route,i.concat(w)).setValue(this.options._const.oldValue,l.data).setValue(this.options._const.newValue,f[v].data))}}else b.push((new g).setValue(this.options._const.action,this.options._const.removeTextElement).setValue(this.options._const.route,i.concat(w)).setValue(this.options._const.value,l.data)),h.splice(w,1),p.splice(w,1),m=Math.min(h.length,u.length),w-=1,v-=1;else!0===u[v]?b.push((new g).setValue(this.options._const.action,this.options._const.replaceElement).setValue(this.options._const.oldValue,O(l)).setValue(this.options._const.newValue,O(f[v])).setValue(this.options._const.route,i.concat(w))):(b.push((new g).setValue(this.options._const.action,this.options._const.removeElement).setValue(this.options._const.route,i.concat(w)).setValue(this.options._const.element,O(l))),h.splice(w,1),p.splice(w,1),m=Math.min(h.length,u.length),w-=1,v-=1);else if(!0===u[v])"#text"===(l=f[v]).nodeName?(b.push((new g).setValue(this.options._const.action,this.options._const.addTextElement).setValue(this.options._const.route,i.concat(w)).setValue(this.options._const.value,l.data)),h.splice(w,0,!0),p.splice(w,0,{nodeName:"#text",data:l.data}),m=Math.min(h.length,u.length)):(b.push((new g).setValue(this.options._const.action,this.options._const.addElement).setValue(this.options._const.route,i.concat(w)).setValue(this.options._const.element,O(l))),h.splice(w,0,!0),p.splice(w,0,O(l)),m=Math.min(h.length,u.length));else if(h[w]!==u[v]){if(b.length>0)return b;if(r=s[h[w]],(o=Math.min(r.newValue,p.length-r.length))!==r.oldValue){a=!1;for(var y=0;y<r.length;y+=1)V(p[o+y],p[r.oldValue+y],{},!1,!0)||(a=!0);if(a)return[(new g).setValue(this.options._const.action,this.options._const.relocateGroup).setValue(this.options._const.groupLength,r.length).setValue(this.options._const.from,r.oldValue).setValue(this.options._const.to,o).setValue(this.options._const.route,i)]}}return b},t.prototype.findValueDiff=function(t,e,s){var i=[];return t.selected!==e.selected&&i.push((new g).setValue(this.options._const.action,this.options._const.modifySelected).setValue(this.options._const.oldValue,t.selected).setValue(this.options._const.newValue,e.selected).setValue(this.options._const.route,s)),(t.value||e.value)&&t.value!==e.value&&"OPTION"!==t.nodeName&&i.push((new g).setValue(this.options._const.action,this.options._const.modifyValue).setValue(this.options._const.oldValue,t.value||"").setValue(this.options._const.newValue,e.value||"").setValue(this.options._const.route,s)),t.checked!==e.checked&&i.push((new g).setValue(this.options._const.action,this.options._const.modifyChecked).setValue(this.options._const.oldValue,t.checked).setValue(this.options._const.newValue,e.checked).setValue(this.options._const.route,s)),i},t}(),q={debug:!1,diffcap:10,maxDepth:!1,maxChildCount:50,valueDiffing:!0,textDiff:function(t,e,s,i){t.data=i},preVirtualDiffApply:function(){},postVirtualDiffApply:function(){},preDiffApply:function(){},postDiffApply:function(){},filterOuterDiff:null,compress:!1,_const:!1,document:!("undefined"==typeof window||!window.document)&&window.document,components:[]},F=function(){function t(t){if(void 0===t&&(t={}),Object.entries(q).forEach((function(e){var s=e[0],i=e[1];Object.prototype.hasOwnProperty.call(t,s)||(t[s]=i)})),!t._const){var e=["addAttribute","modifyAttribute","removeAttribute","modifyTextElement","relocateGroup","removeElement","addElement","removeTextElement","addTextElement","replaceElement","modifyValue","modifyChecked","modifySelected","modifyComment","action","route","oldValue","newValue","element","group","groupLength","from","to","name","value","data","attributes","nodeName","childNodes","checked","selected"],s={};t.compress?e.forEach((function(t,e){return s[t]=e})):e.forEach((function(t){return s[t]=t})),t._const=s}this.options=t}return t.prototype.apply=function(t,e){return function(t,e,s){return e.every((function(e){return _(t,e,s)}))}(t,e,this.options)},t.prototype.undo=function(t,e){return function(t,e,s){(e=e.slice()).reverse(),e.forEach((function(e){!function(t,e,s){switch(e[s._const.action]){case s._const.addAttribute:e[s._const.action]=s._const.removeAttribute,_(t,e,s);break;case s._const.modifyAttribute:y(e,s._const.oldValue,s._const.newValue),_(t,e,s);break;case s._const.removeAttribute:e[s._const.action]=s._const.addAttribute,_(t,e,s);break;case s._const.modifyTextElement:case s._const.modifyValue:case s._const.modifyComment:case s._const.modifyChecked:case s._const.modifySelected:case s._const.replaceElement:y(e,s._const.oldValue,s._const.newValue),_(t,e,s);break;case s._const.relocateGroup:y(e,s._const.from,s._const.to),_(t,e,s);break;case s._const.removeElement:e[s._const.action]=s._const.addElement,_(t,e,s);break;case s._const.addElement:e[s._const.action]=s._const.removeElement,_(t,e,s);break;case s._const.removeTextElement:e[s._const.action]=s._const.addTextElement,_(t,e,s);break;case s._const.addTextElement:e[s._const.action]=s._const.removeTextElement,_(t,e,s);break;default:console.log("unknown action")}}(t,e,s)}))}(t,e,this.options)},t.prototype.diff=function(t,e){return new j(t,e,this.options).init()},t}();const B=(t,e,s,{classes:i,format:n,hiddenHeader:a,sortable:o,scrollY:r,type:l},{noColumnWidths:d,unhideHeader:c})=>({nodeName:"TR",childNodes:t.map(((t,h)=>{const p=e[h]||{type:l,format:n,sortable:!0,searchable:!0};if(p.hidden)return;const f=t.attributes?{...t.attributes}:{};if(p.sortable&&o&&(!r.length||c)&&(p.filter?f["data-filterable"]="true":f["data-sortable"]="true"),p.headerClass&&(f.class=u(f.class,p.headerClass)),s.sort&&s.sort.column===h){const t="asc"===s.sort.dir?i.ascending:i.descending;f.class=u(f.class,t),f["aria-sort"]="asc"===s.sort.dir?"ascending":"descending"}else s.filters[h]&&(f.class=u(f.class,i.filterActive));if(s.widths[h]&&!d){const t=`width: ${s.widths[h]}%;`;f.style=u(f.style,t)}if(r.length&&!c){const t="padding-bottom: 0;padding-top: 0;border: 0;";f.style=u(f.style,t)}const m="html"===t.type?t.data:[{nodeName:"#text",data:t.text??String(t.data)}];return{nodeName:"TH",attributes:f,childNodes:!a&&!r.length||c?p.sortable&&o?[{nodeName:"BUTTON",attributes:{class:p.filter?i.filter:i.sorter},childNodes:m}]:m:[{nodeName:"#text",data:""}]}})).filter((t=>t))}),z=(t,e,s,i,n,a,{classes:r,hiddenHeader:l,header:d,footer:c,format:h,sortable:p,scrollY:f,type:m,rowRender:g,tabIndex:b},{noColumnWidths:v,unhideHeader:w,renderHeader:_},y,D)=>{const M={nodeName:"TABLE",attributes:{...t},childNodes:[{nodeName:"TBODY",childNodes:s.map((({row:t,index:e})=>{const s={nodeName:"TR",attributes:{...t.attributes,"data-index":String(e)},childNodes:t.cells.map(((t,s)=>{const a=i[s]||{type:m,format:h,sortable:!0,searchable:!0};if(a.hidden)return;const r={nodeName:"TD",attributes:t.attributes?{...t.attributes}:{},childNodes:"html"===a.type?t.data:[{nodeName:"#text",data:o(t)}]};if(d||c||!n.widths[s]||v||(r.attributes.style=u(r.attributes.style,`width: ${n.widths[s]}%;`)),a.cellClass&&(r.attributes.class=u(r.attributes.class,a.cellClass)),a.render){const i=a.render(t.data,r,e,s);if(i){if("string"!=typeof i)return i;{const t=Y(`<td>${i}</td>`);1===t.childNodes.length&&["#text","#comment"].includes(t.childNodes[0].nodeName)?r.childNodes[0].data=i:r.childNodes=t.childNodes}}}return r})).filter((t=>t))};if(e===a&&(s.attributes.class=u(s.attributes.class,r.cursor)),g){const i=g(t,s,e);if(i){if("string"!=typeof i)return i;{const t=Y(`<tr>${i}</tr>`);!t.childNodes||1===t.childNodes.length&&["#text","#comment"].includes(t.childNodes[0].nodeName)?s.childNodes[0].data=i:s.childNodes=t.childNodes}}}return s}))}]};if(M.attributes.class=u(M.attributes.class,r.table),d||c||_){const t=B(e,i,n,{classes:r,hiddenHeader:l,sortable:p,scrollY:f},{noColumnWidths:v,unhideHeader:w});if(d||_){const e={nodeName:"THEAD",childNodes:[t]};!f.length&&!l||w||(e.attributes={style:"height: 0px;"}),M.childNodes.unshift(e)}if(c){const e={nodeName:"TFOOT",childNodes:[d?structuredClone(t):t]};!f.length&&!l||w||(e.attributes={style:"height: 0px;"}),M.childNodes.push(e)}}return y.forEach((t=>M.childNodes.push(t))),D.forEach((t=>M.childNodes.push(t))),!1!==b&&(M.attributes.tabindex=String(b)),M};function U(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof window?window:void 0!==t||"undefined"!=typeof self&&self;var J=U({exports:{}}.exports=function(){var t=6e4,e=36e5,s="millisecond",i="second",n="minute",a="hour",o="day",r="week",l="month",d="quarter",c="year",h="date",u="Invalid Date",p=/^(\d{4})[-/]?(\d{1,2})?[-/]?(\d{0,2})[Tt\s]*(\d{1,2})?:?(\d{1,2})?:?(\d{1,2})?[.:]?(\d+)?$/,f=/\[([^\]]+)]|Y{1,4}|M{1,4}|D{1,2}|d{1,4}|H{1,2}|h{1,2}|a|A|m{1,2}|s{1,2}|Z{1,2}|SSS/g,m={name:"en",weekdays:"Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday".split("_"),months:"January_February_March_April_May_June_July_August_September_October_November_December".split("_"),ordinal:function(t){var e=["th","st","nd","rd"],s=t%100;return"["+t+(e[(s-20)%10]||e[s]||e[0])+"]"}},g=function(t,e,s){var i=String(t);return!i||i.length>=e?t:""+Array(e+1-i.length).join(s)+t},b={s:g,z:function(t){var e=-t.utcOffset(),s=Math.abs(e),i=Math.floor(s/60),n=s%60;return(e<=0?"+":"-")+g(i,2,"0")+":"+g(n,2,"0")},m:function t(e,s){if(e.date()<s.date())return-t(s,e);var i=12*(s.year()-e.year())+(s.month()-e.month()),n=e.clone().add(i,l),a=s-n<0,o=e.clone().add(i+(a?-1:1),l);return+(-(i+(s-n)/(a?n-o:o-n))||0)},a:function(t){return t<0?Math.ceil(t)||0:Math.floor(t)},p:function(t){return{M:l,y:c,w:r,d:o,D:h,h:a,m:n,s:i,ms:s,Q:d}[t]||String(t||"").toLowerCase().replace(/s$/,"")},u:function(t){return void 0===t}},v="en",w={};w[v]=m;var _="$isDayjsObject",y=function(t){return t instanceof x||!(!t||!t[_])},D=function t(e,s,i){var n;if(!e)return v;if("string"==typeof e){var a=e.toLowerCase();w[a]&&(n=a),s&&(w[a]=s,n=a);var o=e.split("-");if(!n&&o.length>1)return t(o[0])}else{var r=e.name;w[r]=e,n=r}return!i&&n&&(v=n),n||!i&&v},M=function(t,e){if(y(t))return t.clone();var s="object"==typeof e?e:{};return s.date=t,s.args=arguments,new x(s)},N=b;N.l=D,N.i=y,N.w=function(t,e){return M(t,{locale:e.$L,utc:e.$u,x:e.$x,$offset:e.$offset})};var x=function(){function m(t){this.$L=D(t.locale,null,!0),this.parse(t),this.$x=this.$x||t.x||{},this[_]=!0}var g=m.prototype;return g.parse=function(t){this.$d=function(t){var e=t.date,s=t.utc;if(null===e)return new Date(NaN);if(N.u(e))return new Date;if(e instanceof Date)return new Date(e);if("string"==typeof e&&!/Z$/i.test(e)){var i=e.match(p);if(i){var n=i[2]-1||0,a=(i[7]||"0").substring(0,3);return s?new Date(Date.UTC(i[1],n,i[3]||1,i[4]||0,i[5]||0,i[6]||0,a)):new Date(i[1],n,i[3]||1,i[4]||0,i[5]||0,i[6]||0,a)}}return new Date(e)}(t),this.init()},g.init=function(){var t=this.$d;this.$y=t.getFullYear(),this.$M=t.getMonth(),this.$D=t.getDate(),this.$W=t.getDay(),this.$H=t.getHours(),this.$m=t.getMinutes(),this.$s=t.getSeconds(),this.$ms=t.getMilliseconds()},g.$utils=function(){return N},g.isValid=function(){return!(this.$d.toString()===u)},g.isSame=function(t,e){var s=M(t);return this.startOf(e)<=s&&s<=this.endOf(e)},g.isAfter=function(t,e){return M(t)<this.startOf(e)},g.isBefore=function(t,e){return this.endOf(e)<M(t)},g.$g=function(t,e,s){return N.u(t)?this[e]:this.set(s,t)},g.unix=function(){return Math.floor(this.valueOf()/1e3)},g.valueOf=function(){return this.$d.getTime()},g.startOf=function(t,e){var s=this,d=!!N.u(e)||e,u=N.p(t),p=function(t,e){var i=N.w(s.$u?Date.UTC(s.$y,e,t):new Date(s.$y,e,t),s);return d?i:i.endOf(o)},f=function(t,e){return N.w(s.toDate()[t].apply(s.toDate("s"),(d?[0,0,0,0]:[23,59,59,999]).slice(e)),s)},m=this.$W,g=this.$M,b=this.$D,v="set"+(this.$u?"UTC":"");switch(u){case c:return d?p(1,0):p(31,11);case l:return d?p(1,g):p(0,g+1);case r:var w=this.$locale().weekStart||0,_=(m<w?m+7:m)-w;return p(d?b-_:b+(6-_),g);case o:case h:return f(v+"Hours",0);case a:return f(v+"Minutes",1);case n:return f(v+"Seconds",2);case i:return f(v+"Milliseconds",3);default:return this.clone()}},g.endOf=function(t){return this.startOf(t,!1)},g.$set=function(t,e){var r,d=N.p(t),u="set"+(this.$u?"UTC":""),p=(r={},r[o]=u+"Date",r[h]=u+"Date",r[l]=u+"Month",r[c]=u+"FullYear",r[a]=u+"Hours",r[n]=u+"Minutes",r[i]=u+"Seconds",r[s]=u+"Milliseconds",r)[d],f=d===o?this.$D+(e-this.$W):e;if(d===l||d===c){var m=this.clone().set(h,1);m.$d[p](f),m.init(),this.$d=m.set(h,Math.min(this.$D,m.daysInMonth())).$d}else p&&this.$d[p](f);return this.init(),this},g.set=function(t,e){return this.clone().$set(t,e)},g.get=function(t){return this[N.p(t)]()},g.add=function(s,d){var h,u=this;s=Number(s);var p=N.p(d),f=function(t){var e=M(u);return N.w(e.date(e.date()+Math.round(t*s)),u)};if(p===l)return this.set(l,this.$M+s);if(p===c)return this.set(c,this.$y+s);if(p===o)return f(1);if(p===r)return f(7);var m=(h={},h[n]=t,h[a]=e,h[i]=1e3,h)[p]||1,g=this.$d.getTime()+s*m;return N.w(g,this)},g.subtract=function(t,e){return this.add(-1*t,e)},g.format=function(t){var e=this,s=this.$locale();if(!this.isValid())return s.invalidDate||u;var i=t||"YYYY-MM-DDTHH:mm:ssZ",n=N.z(this),a=this.$H,o=this.$m,r=this.$M,l=s.weekdays,d=s.months,c=s.meridiem,h=function(t,s,n,a){return t&&(t[s]||t(e,i))||n[s].slice(0,a)},p=function(t){return N.s(a%12||12,t,"0")},m=c||function(t,e,s){var i=t<12?"AM":"PM";return s?i.toLowerCase():i};return i.replace(f,(function(t,i){return i||function(t){switch(t){case"YY":return String(e.$y).slice(-2);case"YYYY":return N.s(e.$y,4,"0");case"M":return r+1;case"MM":return N.s(r+1,2,"0");case"MMM":return h(s.monthsShort,r,d,3);case"MMMM":return h(d,r);case"D":return e.$D;case"DD":return N.s(e.$D,2,"0");case"d":return String(e.$W);case"dd":return h(s.weekdaysMin,e.$W,l,2);case"ddd":return h(s.weekdaysShort,e.$W,l,3);case"dddd":return l[e.$W];case"H":return String(a);case"HH":return N.s(a,2,"0");case"h":return p(1);case"hh":return p(2);case"a":return m(a,o,!0);case"A":return m(a,o,!1);case"m":return String(o);case"mm":return N.s(o,2,"0");case"s":return String(e.$s);case"ss":return N.s(e.$s,2,"0");case"SSS":return N.s(e.$ms,3,"0");case"Z":return n}return null}(t)||n.replace(":","")}))},g.utcOffset=function(){return 15*-Math.round(this.$d.getTimezoneOffset()/15)},g.diff=function(s,h,u){var p,f=this,m=N.p(h),g=M(s),b=(g.utcOffset()-this.utcOffset())*t,v=this-g,w=function(){return N.m(f,g)};switch(m){case c:p=w()/12;break;case l:p=w();break;case d:p=w()/3;break;case r:p=(v-b)/6048e5;break;case o:p=(v-b)/864e5;break;case a:p=v/e;break;case n:p=v/t;break;case i:p=v/1e3;break;default:p=v}return u?p:N.a(p)},g.daysInMonth=function(){return this.endOf(l).$D},g.$locale=function(){return w[this.$L]},g.locale=function(t,e){if(!t)return this.$L;var s=this.clone(),i=D(t,e,!0);return i&&(s.$L=i),s},g.clone=function(){return N.w(this.$d,this)},g.toDate=function(){return new Date(this.valueOf())},g.toJSON=function(){return this.isValid()?this.toISOString():null},g.toISOString=function(){return this.$d.toISOString()},g.toString=function(){return this.$d.toUTCString()},m}(),O=x.prototype;return M.prototype=O,[["$ms",s],["$s",i],["$m",n],["$H",a],["$W",o],["$M",l],["$y",c],["$D",h]].forEach((function(t){O[t[1]]=function(e){return this.$g(e,t[0],t[1])}})),M.extend=function(t,e){return t.$i||(t(e,x,M),t.$i=!0),M},M.locale=D,M.isDayjs=y,M.unix=function(t){return M(1e3*t)},M.en=w[v],M.Ls=w,M.p={},M}()),W=U({exports:{}}.exports=function(){var t={LTS:"h:mm:ss A",LT:"h:mm A",L:"MM/DD/YYYY",LL:"MMMM D, YYYY",LLL:"MMMM D, YYYY h:mm A",LLLL:"dddd, MMMM D, YYYY h:mm A"},e=/(\[[^[]*\])|([-_:/.,()\s]+)|(A|a|YYYY|YY?|MM?M?M?|Do|DD?|hh?|HH?|mm?|ss?|S{1,3}|z|ZZ?)/g,s=/\d\d/,i=/\d\d?/,n=/\d*[^-_:/,()\s\d]+/,a={},o=function(t){return(t=+t)+(t>68?1900:2e3)},r=function(t){return function(e){this[t]=+e}},l=[/[+-]\d\d:?(\d\d)?|Z/,function(t){(this.zone||(this.zone={})).offset=function(t){if(!t)return 0;if("Z"===t)return 0;var e=t.match(/([+-]|\d\d)/g),s=60*e[1]+(+e[2]||0);return 0===s?0:"+"===e[0]?-s:s}(t)}],d=function(t){var e=a[t];return e&&(e.indexOf?e:e.s.concat(e.f))},c=function(t,e){var s,i=a.meridiem;if(i){for(var n=1;n<=24;n+=1)if(t.indexOf(i(n,0,e))>-1){s=n>12;break}}else s=t===(e?"pm":"PM");return s},h={A:[n,function(t){this.afternoon=c(t,!1)}],a:[n,function(t){this.afternoon=c(t,!0)}],S:[/\d/,function(t){this.milliseconds=100*+t}],SS:[s,function(t){this.milliseconds=10*+t}],SSS:[/\d{3}/,function(t){this.milliseconds=+t}],s:[i,r("seconds")],ss:[i,r("seconds")],m:[i,r("minutes")],mm:[i,r("minutes")],H:[i,r("hours")],h:[i,r("hours")],HH:[i,r("hours")],hh:[i,r("hours")],D:[i,r("day")],DD:[s,r("day")],Do:[n,function(t){var e=a.ordinal,s=t.match(/\d+/);if(this.day=s[0],e)for(var i=1;i<=31;i+=1)e(i).replace(/\[|\]/g,"")===t&&(this.day=i)}],M:[i,r("month")],MM:[s,r("month")],MMM:[n,function(t){var e=d("months"),s=(d("monthsShort")||e.map((function(t){return t.slice(0,3)}))).indexOf(t)+1;if(s<1)throw new Error;this.month=s%12||s}],MMMM:[n,function(t){var e=d("months").indexOf(t)+1;if(e<1)throw new Error;this.month=e%12||e}],Y:[/[+-]?\d+/,r("year")],YY:[s,function(t){this.year=o(t)}],YYYY:[/\d{4}/,r("year")],Z:l,ZZ:l};function u(s){var i,n;i=s,n=a&&a.formats;for(var o=(s=i.replace(/(\[[^\]]+])|(LTS?|l{1,4}|L{1,4})/g,(function(e,s,i){var a=i&&i.toUpperCase();return s||n[i]||t[i]||n[a].replace(/(\[[^\]]+])|(MMMM|MM|DD|dddd)/g,(function(t,e,s){return e||s.slice(1)}))}))).match(e),r=o.length,l=0;l<r;l+=1){var d=o[l],c=h[d],u=c&&c[0],p=c&&c[1];o[l]=p?{regex:u,parser:p}:d.replace(/^\[|\]$/g,"")}return function(t){for(var e={},s=0,i=0;s<r;s+=1){var n=o[s];if("string"==typeof n)i+=n.length;else{var a=n.regex,l=n.parser,d=t.slice(i),c=a.exec(d)[0];l.call(e,c),t=t.replace(c,"")}}return function(t){var e=t.afternoon;if(void 0!==e){var s=t.hours;e?s<12&&(t.hours+=12):12===s&&(t.hours=0),delete t.afternoon}}(e),e}}return function(t,e,s){s.p.customParseFormat=!0,t&&t.parseTwoDigitYear&&(o=t.parseTwoDigitYear);var i=e.prototype,n=i.parse;i.parse=function(t){var e=t.date,i=t.utc,o=t.args;this.$u=i;var r=o[1];if("string"==typeof r){var l=!0===o[2],d=!0===o[3],c=l||d,h=o[2];d&&(h=o[2]),a=this.$locale(),!l&&h&&(a=s.Ls[h]),this.$d=function(t,e,s){try{if(["x","X"].indexOf(e)>-1)return new Date(("X"===e?1e3:1)*t);var i=u(e)(t),n=i.year,a=i.month,o=i.day,r=i.hours,l=i.minutes,d=i.seconds,c=i.milliseconds,h=i.zone,p=new Date,f=o||(n||a?1:p.getDate()),m=n||p.getFullYear(),g=0;n&&!a||(g=a>0?a-1:p.getMonth());var b=r||0,v=l||0,w=d||0,_=c||0;return h?new Date(Date.UTC(m,g,f,b,v,w,_+60*h.offset*1e3)):s?new Date(Date.UTC(m,g,f,b,v,w,_)):new Date(m,g,f,b,v,w,_)}catch(t){return new Date("")}}(e,r,i),this.init(),h&&!0!==h&&(this.$L=this.locale(h).$L),c&&e!=this.format(r)&&(this.$d=new Date("")),a={}}else if(r instanceof Array)for(var p=r.length,f=1;f<=p;f+=1){o[1]=r[f-1];var m=s.apply(this,o);if(m.isValid()){this.$d=m.$d,this.$L=m.$L,this.init();break}f===p&&(this.$d=new Date(""))}else n.call(this,t)}}}());J.extend(W);const Q=(t,e)=>{let s;if(e)switch(e){case"ISO_8601":s=t;break;case"RFC_2822":s=J(t.slice(5),"DD MMM YYYY HH:mm:ss ZZ").unix();break;case"MYSQL":s=J(t,"YYYY-MM-DD hh:mm:ss").unix();break;case"UNIX":s=J(t).unix();break;default:s=J(t,e,!0).valueOf()}return s},X=(t,e)=>{if(t?.constructor===Object&&Object.prototype.hasOwnProperty.call(t,"data")&&!Object.keys(t).find((t=>!["text","order","data","attributes"].includes(t))))return t;const s={data:t};switch(e.type){case"string":"string"!=typeof t&&(s.text=String(s.data),s.order=s.text);break;case"date":e.format&&(s.order=Q(String(s.data),e.format));break;case"number":s.text=String(s.data),s.data=parseFloat(s.data),s.order=s.data;break;case"html":{const t=Array.isArray(s.data)?{nodeName:"TD",childNodes:s.data}:Y(`<td>${String(s.data)}</td>`);s.data=t.childNodes||[];const e=a(t);s.text=e,s.order=e;break}case"boolean":"string"==typeof s.data&&(s.data=s.data.toLowerCase().trim()),s.data=!["false",!1,null,void 0,0].includes(s.data),s.order=s.data?1:0,s.text=String(s.data);break;case"other":s.text="",s.order=0;break;default:s.text=JSON.stringify(s.data)}return s},Z=t=>{if(t instanceof Object&&t.constructor===Object&&t.hasOwnProperty("data")&&("string"==typeof t.text||"string"==typeof t.data))return t;const e={data:t};if("string"==typeof t){if(t.length){const s=Y(`<th>${t}</th>`);if(s.childNodes&&(1!==s.childNodes.length||"#text"!==s.childNodes[0].nodeName)){e.data=s.childNodes,e.type="html";const t=a(s);e.text=t}}}else[null,void 0].includes(t)?e.text="":e.text=JSON.stringify(t);return e},G=(t,e=void 0,s,i,n)=>{const o={data:[],headings:[]};if(t.headings)o.headings=t.headings.map((t=>Z(t)));else if(e?.tHead)o.headings=Array.from(e.tHead.querySelectorAll("th")).map(((t,e)=>{const o=(t=>{const e=A(t,{valueDiffing:!1});let s;return s=!e.childNodes||1===e.childNodes.length&&"#text"===e.childNodes[0].nodeName?{data:t.innerText,type:"string"}:{data:e.childNodes,type:"html",text:a(e)},s.attributes=e.attributes,s})(t);s[e]||(s[e]={type:i,format:n,searchable:!0,sortable:!0});const r=s[e];return"false"!==t.dataset.sortable?.trim().toLowerCase()&&"false"!==t.dataset.sort?.trim().toLowerCase()||(r.sortable=!1),"false"===t.dataset.searchable?.trim().toLowerCase()&&(r.searchable=!1),"true"!==t.dataset.hidden?.trim().toLowerCase()&&"true"!==t.getAttribute("hidden")?.trim().toLowerCase()||(r.hidden=!0),["number","string","html","date","boolean","other"].includes(t.dataset.type)&&(r.type=t.dataset.type,"date"===r.type&&t.dataset.format&&(r.format=t.dataset.format)),o}));else if(t.data?.length){const e=t.data[0],s=Array.isArray(e)?e:e.cells;o.headings=s.map((t=>Z("")))}else e?.tBodies.length&&(o.headings=Array.from(e.tBodies[0].rows[0].cells).map((t=>Z(""))));for(let t=0;t<o.headings.length;t++)s[t]||(s[t]={type:i,format:n,sortable:!0,searchable:!0});if(t.data){const e=o.headings.map((t=>t.data?String(t.data):t.text));o.data=t.data.map((t=>{let i,n;return Array.isArray(t)?(i={},n=t):t.hasOwnProperty("cells")&&Object.keys(t).every((t=>["cells","attributes"].includes(t)))?(i=t.attributes,n=t.cells):(i={},n=[],Object.entries(t).forEach((([t,s])=>{const i=e.indexOf(t);i>-1&&(n[i]=s)}))),{attributes:i,cells:n.map(((t,e)=>X(t,s[e])))}}))}else e?.tBodies?.length&&(o.data=Array.from(e.tBodies[0].rows).map((t=>({attributes:d(t.attributes),cells:Array.from(t.cells).map(((t,e)=>{const i=t.dataset.content?X(t.dataset.content,s[e]):((t,e)=>{let s;switch(e.type){case"string":s={data:t.innerText};break;case"date":{const i=t.innerText;s={data:i,order:Q(i,e.format)};break}case"number":{const e=parseFloat(t.innerText);s={data:e,order:e,text:t.innerText};break}case"boolean":{const e=!["false","0","null","undefined"].includes(t.innerText.toLowerCase().trim());s={data:e,text:e?"1":"0",order:e?1:0};break}default:s={data:A(t,{valueDiffing:!1}).childNodes||[],text:t.innerText,order:t.innerText}}return s.attributes=d(t.attributes),s})(t,s[e]);return t.dataset.order&&(i.order=isNaN(parseFloat(t.dataset.order))?t.dataset.order:parseFloat(t.dataset.order)),i}))}))));if(o.data.length&&o.data[0].cells.length!==o.headings.length)throw new Error("Data heading length mismatch.");return o};class K{cursor;dt;constructor(t){this.dt=t,this.cursor=!1}setCursor(t=!1){if(t===this.cursor)return;const e=this.cursor;if(this.cursor=t,this.dt._renderTable(),!1!==t&&this.dt.options.scrollY){const t=c(this.dt.options.classes.cursor),e=this.dt.dom.querySelector(`tr${t}`);e&&e.scrollIntoView({block:"nearest"})}this.dt.emit("datatable.cursormove",this.cursor,e)}add(t){if(!Array.isArray(t)||t.length<1)return;const e={cells:t.map(((t,e)=>{const s=this.dt.columns.settings[e];return X(t,s)}))};this.dt.data.data.push(e),this.dt.hasRows=!0,this.dt.update(!0)}remove(t){if(!Array.isArray(t))return this.remove([t]);this.dt.data.data=this.dt.data.data.filter(((e,s)=>!t.includes(s))),this.dt.data.data.length||(this.dt.hasRows=!1),this.dt.update(!0)}findRowIndex(t,e){return this.dt.data.data.findIndex((s=>{const i=s.cells[t];return o(i).toLowerCase().includes(String(e).toLowerCase())}))}findRow(t,e){const s=this.findRowIndex(t,e);if(s<0)return{index:-1,row:null,cols:[]};const i=this.dt.data.data[s],n=i.cells.map((t=>t.data));return{index:s,row:i,cols:n}}updateRow(t,e){const s={cells:e.map(((t,e)=>{const s=this.dt.columns.settings[e];return X(t,s)}))};this.dt.data.data.splice(t,1,s),this.dt.update(!0)}}class tt{dt;settings;_state;constructor(t){this.dt=t,this.init()}init(){[this.settings,this._state]=((t=[],e,s)=>{let i=[],n=!1;const a=[];return t.forEach((t=>{(Array.isArray(t.select)?t.select:[t.select]).forEach((o=>{i[o]?t.type&&(i[o].type=t.type):i[o]={type:t.type||e,sortable:!0,searchable:!0};const r=i[o];t.render&&(r.render=t.render),t.format?r.format=t.format:"date"===t.type&&(r.format=s),t.cellClass&&(r.cellClass=t.cellClass),t.headerClass&&(r.headerClass=t.headerClass),t.locale&&(r.locale=t.locale),!1===t.sortable?r.sortable=!1:(t.numeric&&(r.numeric=t.numeric),t.caseFirst&&(r.caseFirst=t.caseFirst)),!1===t.searchable?r.searchable=!1:t.sensitivity&&(r.sensitivity=t.sensitivity),(r.searchable||r.sortable)&&void 0!==t.ignorePunctuation&&(r.ignorePunctuation=t.ignorePunctuation),t.searchMethod&&(r.searchMethod=t.searchMethod),t.hidden&&(r.hidden=!0),t.filter&&(r.filter=t.filter),t.sortSequence&&(r.sortSequence=t.sortSequence),t.sort&&(t.filter?a[o]=t.sort:n={column:o,dir:t.sort}),void 0!==t.searchItemSeparator&&(r.searchItemSeparator=t.searchItemSeparator)}))})),i=i.map((t=>t||{type:e,format:"date"===e?s:void 0,sortable:!0,searchable:!0})),[i,{filters:a,sort:n,widths:[]}]})(this.dt.options.columns,this.dt.options.type,this.dt.options.format)}get(t){return t<0||t>=this.size()?null:{...this.settings[t]}}size(){return this.settings.length}swap(t){if(2===t.length){const e=this.dt.data.headings.map(((t,e)=>e)),s=t[0],i=t[1],n=e[i];return e[i]=e[s],e[s]=n,this.order(e)}}order(t){this.dt.data.headings=t.map((t=>this.dt.data.headings[t])),this.dt.data.data.forEach((e=>e.cells=t.map((t=>e.cells[t])))),this.settings=t.map((t=>this.settings[t])),this.dt.update()}hide(t){Array.isArray(t)||(t=[t]),t.length&&(t.forEach((t=>{this.settings[t]||(this.settings[t]={type:"string"}),this.settings[t].hidden=!0})),this.dt.update())}show(t){Array.isArray(t)||(t=[t]),t.length&&(t.forEach((t=>{this.settings[t]||(this.settings[t]={type:"string",sortable:!0}),delete this.settings[t].hidden})),this.dt.update())}visible(t){return void 0===t&&(t=[...Array(this.dt.data.headings.length).keys()]),Array.isArray(t)?t.map((t=>!this.settings[t]?.hidden)):!this.settings[t]?.hidden}add(t){const e=this.dt.data.headings.length;if(this.dt.data.headings=this.dt.data.headings.concat([Z(t.heading)]),this.dt.data.data.forEach(((e,s)=>{e.cells=e.cells.concat([X(t.data[s],t)])})),this.settings[e]={type:t.type||"string",sortable:!0,searchable:!0},t.type||t.format||t.sortable||t.render||t.filter){const s=this.settings[e];t.render&&(s.render=t.render),t.format&&(s.format=t.format),t.cellClass&&(s.cellClass=t.cellClass),t.headerClass&&(s.headerClass=t.headerClass),t.locale&&(s.locale=t.locale),!1===t.sortable?s.sortable=!1:(t.numeric&&(s.numeric=t.numeric),t.caseFirst&&(s.caseFirst=t.caseFirst)),!1===t.searchable?s.searchable=!1:t.sensitivity&&(s.sensitivity=t.sensitivity),(s.searchable||s.sortable)&&t.ignorePunctuation&&(s.ignorePunctuation=t.ignorePunctuation),t.hidden&&(s.hidden=!0),t.filter&&(s.filter=t.filter),t.sortSequence&&(s.sortSequence=t.sortSequence)}this.dt.update(!0)}remove(t){Array.isArray(t)||(t=[t]),this.dt.data.headings=this.dt.data.headings.filter(((e,s)=>!t.includes(s))),this.dt.data.data.forEach((e=>e.cells=e.cells.filter(((e,s)=>!t.includes(s))))),this.dt.update(!0)}filter(t,e=!1){if(!this.settings[t]?.filter?.length)return;const s=this._state.filters[t];let i;if(s){let e=!1;i=this.settings[t].filter.find((t=>!!e||(t===s&&(e=!0),!1)))}else{const e=this.settings[t].filter;i=e?e[0]:void 0}i?this._state.filters[t]=i:s&&(this._state.filters[t]=void 0),this.dt._currentPage=1,this.dt.update(),e||this.dt.emit("datatable.filter",t,i)}sort(t,e=void 0,s=!1){const i=this.settings[t];if(s||this.dt.emit("datatable.sorting",t,e),!e){const s=!(!this._state.sort||this._state.sort.column!==t)&&this._state.sort?.dir,n=i?.sortSequence||["asc","desc"];if(s){const t=n.indexOf(s);e=-1===t?n[0]||"asc":t===n.length-1?n[0]:n[t+1]}else e=n.length?n[0]:"asc"}const n=!!["string","html"].includes(i.type)&&new Intl.Collator(i.locale||this.dt.options.locale,{usage:"sort",numeric:i.numeric||this.dt.options.numeric,caseFirst:i.caseFirst||this.dt.options.caseFirst,ignorePunctuation:i.ignorePunctuation||this.dt.options.ignorePunctuation});this.dt.data.data.sort(((s,i)=>{const a=s.cells[t],r=i.cells[t];let l=a.order??o(a),d=r.order??o(r);if("desc"===e){const t=l;l=d,d=t}return n&&"number"!=typeof l&&"number"!=typeof d?n.compare(String(l),String(d)):l<d?-1:l>d?1:0})),this._state.sort={column:t,dir:e},this.dt._searchQueries.length?(this.dt.multiSearch(this.dt._searchQueries),this.dt.emit("datatable.sort",t,e)):s||(this.dt._currentPage=1,this.dt.update(),this.dt.emit("datatable.sort",t,e))}_measureWidths(){const t=this.dt.data.headings.filter(((t,e)=>!this.settings[e]?.hidden));if((this.dt.options.scrollY.length||this.dt.options.fixedColumns)&&t?.length){this._state.widths=[];const t={noPaging:!0};if(this.dt.options.header||this.dt.options.footer){this.dt.options.scrollY.length&&(t.unhideHeader=!0),this.dt.headerDOM&&this.dt.headerDOM.parentElement.removeChild(this.dt.headerDOM),t.noColumnWidths=!0,this.dt._renderTable(t);const e=Array.from(this.dt.dom.querySelector("thead, tfoot")?.firstElementChild?.querySelectorAll("th")||[]);let s=0;const i=this.dt.data.headings.map(((t,i)=>{if(this.settings[i]?.hidden)return 0;const n=e[s].offsetWidth;return s+=1,n})),n=i.reduce(((t,e)=>t+e),0);this._state.widths=i.map((t=>t/n*100))}else{t.renderHeader=!0,this.dt._renderTable(t);const e=Array.from(this.dt.dom.querySelector("thead, tfoot")?.firstElementChild?.querySelectorAll("th")||[]);let s=0;const i=this.dt.data.headings.map(((t,i)=>{if(this.settings[i]?.hidden)return 0;const n=e[s].offsetWidth;return s+=1,n})),n=i.reduce(((t,e)=>t+e),0);this._state.widths=i.map((t=>t/n*100))}this.dt._renderTable()}}}const et={sortable:!0,locale:"en",numeric:!0,caseFirst:"false",searchable:!0,sensitivity:"base",ignorePunctuation:!0,destroyable:!0,searchItemSeparator:"",searchQuerySeparator:" ",searchAnd:!1,searchMethod:!1,data:{},type:"html",format:"YYYY-MM-DD",columns:[],paging:!0,perPage:10,perPageSelect:[5,10,15,20,25],nextPrev:!0,firstLast:!1,prevText:"‹",nextText:"›",firstText:"«",lastText:"»",ellipsisText:"…",truncatePager:!0,pagerDelta:2,scrollY:"",fixedColumns:!0,fixedHeight:!1,footer:!1,header:!0,hiddenHeader:!1,caption:void 0,rowNavigation:!1,rowSelectionKeys:["Enter"," "],tabIndex:!1,pagerRender:!1,rowRender:!1,tableRender:!1,diffDomOptions:{valueDiffing:!1},labels:{placeholder:"Search...",searchTitle:"Search within table",perPage:"entries per page",pageTitle:"Page {page}",noRows:"No entries found",noResults:"No results match your search query",info:"Showing {start} to {end} of {rows} entries"},template:(t,e)=>`<div class='${t.classes.top}'>\n ${t.paging&&t.perPageSelect?`<div class='${t.classes.dropdown}'>\n <label>\n <select class='${t.classes.selector}' name="per-page"></select> ${t.labels.perPage}\n </label>\n </div>`:""}\n ${t.searchable?`<div class='${t.classes.search}'>\n <input class='${t.classes.input}' placeholder='${t.labels.placeholder}' type='search' name="search" title='${t.labels.searchTitle}'${e.id?` aria-controls="${e.id}"`:""}>\n </div>`:""}\n</div>\n<div class='${t.classes.container}'${t.scrollY.length?` style='height: ${t.scrollY}; overflow-Y: auto;'`:""}></div>\n<div class='${t.classes.bottom}'>\n ${t.paging?`<div class='${t.classes.info}'></div>`:""}\n <nav class='${t.classes.pagination}'></nav>\n</div>`,classes:{active:"datatable-active",ascending:"datatable-ascending",bottom:"datatable-bottom",container:"datatable-container",cursor:"datatable-cursor",descending:"datatable-descending",disabled:"datatable-disabled",dropdown:"datatable-dropdown",ellipsis:"datatable-ellipsis",filter:"datatable-filter",filterActive:"datatable-filter-active",empty:"datatable-empty",headercontainer:"datatable-headercontainer",hidden:"datatable-hidden",info:"datatable-info",input:"datatable-input",loading:"datatable-loading",pagination:"datatable-pagination",paginationList:"datatable-pagination-list",paginationListItem:"datatable-pagination-list-item",paginationListItemLink:"datatable-pagination-list-item-link",search:"datatable-search",selector:"datatable-selector",sorter:"datatable-sorter",table:"datatable-table",top:"datatable-top",wrapper:"datatable-wrapper"}},st=(t,e,s,i={})=>({nodeName:"LI",attributes:{class:i.active&&!i.hidden?`${s.classes.paginationListItem} ${s.classes.active}`:i.hidden?`${s.classes.paginationListItem} ${s.classes.hidden} ${s.classes.disabled}`:s.classes.paginationListItem},childNodes:[{nodeName:"BUTTON",attributes:{"data-page":String(t),class:s.classes.paginationListItemLink,"aria-label":s.labels.pageTitle.replace("{page}",String(t))},childNodes:[{nodeName:"#text",data:e}]}]}),it={classes:{row:"datatable-editor-row",form:"datatable-editor-form",item:"datatable-editor-item",menu:"datatable-editor-menu",save:"datatable-editor-save",block:"datatable-editor-block",cancel:"datatable-editor-cancel",close:"datatable-editor-close",inner:"datatable-editor-inner",input:"datatable-editor-input",label:"datatable-editor-label",modal:"datatable-editor-modal",action:"datatable-editor-action",header:"datatable-editor-header",wrapper:"datatable-editor-wrapper",editable:"datatable-editor-editable",container:"datatable-editor-container",separator:"datatable-editor-separator"},labels:{closeX:"x",editCell:"Edit Cell",editRow:"Edit Row",removeRow:"Remove Row",reallyRemove:"Are you sure?",reallyCancel:"Do you really want to cancel?",save:"Save",cancel:"Cancel"},cancelModal:t=>confirm(t.options.labels.reallyCancel),inline:!0,hiddenColumns:!1,contextMenu:!0,clickEvent:"dblclick",excludeColumns:[],menuItems:[{text:t=>t.options.labels.editCell,action:(t,e)=>{if(!(t.event.target instanceof Element))return;const s=t.event.target.closest("td");return t.editCell(s)}},{text:t=>t.options.labels.editRow,action:(t,e)=>{if(!(t.event.target instanceof Element))return;const s=t.event.target.closest("tr");return t.editRow(s)}},{separator:!0},{text:t=>t.options.labels.removeRow,action:(t,e)=>{if(t.event.target instanceof Element&&confirm(t.options.labels.reallyRemove)){const e=t.event.target.closest("tr");t.removeRow(e)}}}]};class nt{menuOpen;containerDOM;data;disabled;dt;editing;editingCell;editingRow;event;events;initialized;limits;menuDOM;modalDOM;options;originalRowRender;rect;wrapperDOM;constructor(t,e={}){this.dt=t,this.options={...it,...e}}init(){this.initialized||(this.options.classes.editable?.split(" ").forEach((t=>this.dt.wrapperDOM.classList.add(t))),this.options.inline&&(this.originalRowRender=this.dt.options.rowRender,this.dt.options.rowRender=(t,e,s)=>{let i=this.rowRender(t,e,s);return this.originalRowRender&&(i=this.originalRowRender(t,i,s)),i}),this.options.contextMenu&&(this.containerDOM=n("div",{id:this.options.classes.container}),this.wrapperDOM=n("div",{class:this.options.classes.wrapper}),this.menuDOM=n("ul",{class:this.options.classes.menu}),this.options.menuItems&&this.options.menuItems.length&&this.options.menuItems.forEach((t=>{const e=n("li",{class:t.separator?this.options.classes.separator:this.options.classes.item});if(!t.separator){const s=n("a",{class:this.options.classes.action,href:t.url||"#",html:"function"==typeof t.text?t.text(this):t.text});e.appendChild(s),t.action&&"function"==typeof t.action&&s.addEventListener("click",(e=>{e.preventDefault(),t.action(this,e)}))}this.menuDOM.appendChild(e)})),this.wrapperDOM.appendChild(this.menuDOM),this.containerDOM.appendChild(this.wrapperDOM),this.updateMenu()),this.data={},this.menuOpen=!1,this.editing=!1,this.editingRow=!1,this.editingCell=!1,this.bindEvents(),setTimeout((()=>{this.initialized=!0,this.dt.emit("editable.init")}),10))}bindEvents(){this.events={keydown:this.keydown.bind(this),click:this.click.bind(this)},this.dt.dom.addEventListener(this.options.clickEvent,this.events.click),document.addEventListener("keydown",this.events.keydown),this.options.contextMenu&&(this.events.context=this.context.bind(this),this.events.updateMenu=this.updateMenu.bind(this),this.events.dismissMenu=this.dismissMenu.bind(this),this.events.reset=p((()=>this.events.updateMenu()),50),this.dt.dom.addEventListener("contextmenu",this.events.context),document.addEventListener("click",this.events.dismissMenu),window.addEventListener("resize",this.events.reset),window.addEventListener("scroll",this.events.reset))}context(t){const e=t.target;if(!(e instanceof Element))return;this.event=t;const s=e.closest("tbody td");if(!this.disabled&&s){t.preventDefault();let e=t.pageX,s=t.pageY;e>this.limits.x&&(e-=this.rect.width),s>this.limits.y&&(s-=this.rect.height),this.wrapperDOM.style.top=`${s}px`,this.wrapperDOM.style.left=`${e}px`,this.openMenu(),this.updateMenu()}}click(t){const e=t.target;if(e instanceof Element)if(this.editing&&this.data&&this.editingCell){const t=c(this.options.classes.input),e=this.modalDOM?this.modalDOM.querySelector(`input${t}[type=text]`):this.dt.wrapperDOM.querySelector(`input${t}[type=text]`);this.saveCell(e.value)}else if(!this.editing){const s=e.closest("tbody td");s&&(this.editCell(s),t.preventDefault())}}keydown(t){const e=c(this.options.classes.input);if(this.modalDOM){if("Escape"===t.key)this.options.cancelModal(this)&&this.closeModal();else if("Enter"===t.key)if(this.editingCell){const t=this.modalDOM.querySelector(`input${e}[type=text]`);this.saveCell(t.value)}else{const t=Array.from(this.modalDOM.querySelectorAll(`input${e}[type=text]`)).map((t=>t.value.trim()));this.saveRow(t,this.data.row)}}else if(this.editing&&this.data)if("Enter"===t.key){if(this.editingCell){const t=this.dt.wrapperDOM.querySelector(`input${e}[type=text]`);this.saveCell(t.value)}else if(this.editingRow){const t=Array.from(this.dt.wrapperDOM.querySelectorAll(`input${e}[type=text]`)).map((t=>t.value.trim()));this.saveRow(t,this.data.row)}}else"Escape"===t.key&&(this.editingCell?this.saveCell(this.data.content):this.editingRow&&this.saveRow(null,this.data.row))}editCell(t){const e=l(t.cellIndex,this.dt.columns.settings);if(this.options.excludeColumns.includes(e))return void this.closeMenu();const s=parseInt(t.parentElement.dataset.index,10),i=this.dt.data.data[s].cells[e];this.data={cell:i,rowIndex:s,columnIndex:e,content:o(i)},this.editing=!0,this.editingCell=!0,this.options.inline?this.dt.update():this.editCellModal(),this.closeMenu()}editCellModal(){const t=this.data.cell,e=this.data.columnIndex,s=this.dt.data.headings[e].text||String(this.dt.data.headings[e].data),i=[`<div class='${this.options.classes.inner}'>`,`<div class='${this.options.classes.header}'>`,`<h4>${this.options.labels.editCell}</h4>`,`<button class='${this.options.classes.close}' type='button' data-editor-cancel>${this.options.labels.closeX}</button>`," </div>",`<div class='${this.options.classes.block}'>`,`<form class='${this.options.classes.form}'>`,`<div class='${this.options.classes.row}'>`,`<label class='${this.options.classes.label}'>${r(s)}</label>`,`<input class='${this.options.classes.input}' value='${r(o(t))}' type='text'>`,"</div>",`<div class='${this.options.classes.row}'>`,`<button class='${this.options.classes.cancel}' type='button' data-editor-cancel>${this.options.labels.cancel}</button>`,`<button class='${this.options.classes.save}' type='button' data-editor-save>${this.options.labels.save}</button>`,"</div>","</form>","</div>","</div>"].join(""),a=n("div",{class:this.options.classes.modal,html:i});this.modalDOM=a,this.openModal();const l=c(this.options.classes.input),d=a.querySelector(`input${l}[type=text]`);d.focus(),d.selectionStart=d.selectionEnd=d.value.length,a.addEventListener("click",(t=>{const e=t.target;e instanceof Element&&(e.hasAttribute("data-editor-cancel")?(t.preventDefault(),this.options.cancelModal(this)&&this.closeModal()):e.hasAttribute("data-editor-save")&&(t.preventDefault(),this.saveCell(d.value)))}))}saveCell(t){const e=this.data.content,s=this.dt.columns.settings[this.data.columnIndex].type||this.dt.options.type,i=t.trim();let n;if("number"===s)n={data:parseFloat(i)};else if("boolean"===s)n=["","false","0"].includes(i)?{data:!1,text:"false",order:0}:{data:!0,text:"true",order:1};else if("html"===s)n={data:[{nodeName:"#text",data:t}],text:t,order:t};else if("string"===s)n={data:t};else if("date"===s){const e=this.dt.columns.settings[this.data.columnIndex].format||this.dt.options.format;n={data:t,order:Q(String(t),e)}}else n={data:t};this.dt.data.data[this.data.rowIndex].cells[this.data.columnIndex]=n,this.closeModal();const a=this.data.rowIndex,o=this.data.columnIndex;this.data={},this.dt.update(!0),this.editing=!1,this.editingCell=!1,this.dt.emit("editable.save.cell",t,e,a,o)}editRow(t){if(!t||"TR"!==t.nodeName||this.editing)return;const e=parseInt(t.dataset.index,10),s=this.dt.data.data[e];this.data={row:s.cells,rowIndex:e},this.editing=!0,this.editingRow=!0,this.options.inline?this.dt.update():this.editRowModal(),this.closeMenu()}editRowModal(){const t=this.data.row,e=[`<div class='${this.options.classes.inner}'>`,`<div class='${this.options.classes.header}'>`,`<h4>${this.options.labels.editRow}</h4>`,`<button class='${this.options.classes.close}' type='button' data-editor-cancel>${this.options.labels.closeX}</button>`," </div>",`<div class='${this.options.classes.block}'>`,`<form class='${this.options.classes.form}'>`,`<div class='${this.options.classes.row}'>`,`<button class='${this.options.classes.cancel}' type='button' data-editor-cancel>${this.options.labels.cancel}</button>`,`<button class='${this.options.classes.save}' type='button' data-editor-save>${this.options.labels.save}</button>`,"</div>","</form>","</div>","</div>"].join(""),s=n("div",{class:this.options.classes.modal,html:e}),i=s.firstElementChild;if(!i)return;const a=i.lastElementChild?.firstElementChild;if(!a)return;t.forEach(((t,e)=>{const s=this.dt.columns.settings[e];if((!s.hidden||s.hidden&&this.options.hiddenColumns)&&!this.options.excludeColumns.includes(e)){const s=this.dt.data.headings[e].text||String(this.dt.data.headings[e].data);a.insertBefore(n("div",{class:this.options.classes.row,html:[`<div class='${this.options.classes.row}'>`,`<label class='${this.options.classes.label}'>${r(s)}</label>`,`<input class='${this.options.classes.input}' value='${r(o(t))}' type='text'>`,"</div>"].join("")}),a.lastElementChild)}})),this.modalDOM=s,this.openModal();const l=c(this.options.classes.input),d=Array.from(a.querySelectorAll(`input${l}[type=text]`));s.addEventListener("click",(t=>{const e=t.target;if(e instanceof Element)if(e.hasAttribute("data-editor-cancel"))this.options.cancelModal(this)&&this.closeModal();else if(e.hasAttribute("data-editor-save")){const t=d.map((t=>t.value.trim()));this.saveRow(t,this.data.row)}}))}saveRow(t,e){const s=e.map((t=>o(t))),i=this.dt.data.data[this.data.rowIndex];if(t){let s=0;i.cells=e.map(((e,i)=>{if(this.options.excludeColumns.includes(i)||this.dt.columns.settings[i].hidden)return e;const n=this.dt.columns.settings[i].type||this.dt.options.type,a=t[s++];let o;if("number"===n)o={data:parseFloat(a)};else if("boolean"===n)o=["","false","0"].includes(a)?{data:!1,text:"false",order:0}:{data:!0,text:"true",order:1};else if("html"===n)o={data:[{nodeName:"#text",data:a}],text:a,order:a};else if("string"===n)o={data:a};else if("date"===n){const t=this.dt.columns.settings[i].format||this.dt.options.format;o={data:a,order:Q(String(a),t)}}else o={data:a};return o}))}const n=i.cells.map((t=>o(t)));this.data={},this.dt.update(!0),this.closeModal(),this.editing=!1,this.dt.emit("editable.save.row",n,s,e)}openModal(){this.modalDOM&&document.body.appendChild(this.modalDOM)}closeModal(){this.editing&&this.modalDOM&&(document.body.removeChild(this.modalDOM),this.modalDOM=this.editing=this.editingRow=this.editingCell=!1)}removeRow(t){if(!t||"TR"!==t.nodeName||this.editing)return;const e=parseInt(t.dataset.index,10);this.dt.rows.remove(e),this.closeMenu()}updateMenu(){const t=window.scrollX||window.pageXOffset,e=window.scrollY||window.pageYOffset;this.rect=this.wrapperDOM.getBoundingClientRect(),this.limits={x:window.innerWidth+t-this.rect.width,y:window.innerHeight+e-this.rect.height}}dismissMenu(t){const e=t.target;if(!(e instanceof Element)||this.wrapperDOM.contains(e))return;let s=!0;if(this.editing){const t=c(this.options.classes.input);s=!e.matches(`input${t}[type=text]`)}s&&this.closeMenu()}openMenu(){if(this.editing&&this.data&&this.editingCell){const t=c(this.options.classes.input),e=this.modalDOM?this.modalDOM.querySelector(`input${t}[type=text]`):this.dt.wrapperDOM.querySelector(`input${t}[type=text]`);this.saveCell(e.value)}document.body.appendChild(this.containerDOM),this.menuOpen=!0,this.dt.emit("editable.context.open")}closeMenu(){this.menuOpen&&(this.menuOpen=!1,document.body.removeChild(this.containerDOM),this.dt.emit("editable.context.close"))}destroy(){this.dt.dom.removeEventListener(this.options.clickEvent,this.events.click),this.dt.dom.removeEventListener("contextmenu",this.events.context),document.removeEventListener("click",this.events.dismissMenu),document.removeEventListener("keydown",this.events.keydown),window.removeEventListener("resize",this.events.reset),window.removeEventListener("scroll",this.events.reset),document.body.contains(this.containerDOM)&&document.body.removeChild(this.containerDOM),this.options.inline&&(this.dt.options.rowRender=this.originalRowRender),this.initialized=!1}rowRender(t,e,s){return this.data&&this.data.rowIndex===s?(this.editingCell?e.childNodes[function(t,e){let s=t,i=0;for(;i<t;)e[i].hidden&&(s-=1),i++;return s}(this.data.columnIndex,this.dt.columns.settings)].childNodes=[{nodeName:"INPUT",attributes:{type:"text",value:this.data.content,class:this.options.classes.input}}]:e.childNodes.forEach(((s,i)=>{const n=l(i,this.dt.columns.settings),a=t[n];this.options.excludeColumns.includes(n)||(e.childNodes[i].childNodes=[{nodeName:"INPUT",attributes:{type:"text",value:r(a.text||String(a.data)||""),class:this.options.classes.input}}])})),e):e}}const at={classes:{button:"datatable-column-filter-button",menu:"datatable-column-filter-menu",container:"datatable-column-filter-container",wrapper:"datatable-column-filter-wrapper"},labels:{button:"Filter columns within the table"},hiddenColumns:[]};class ot{addedButtonDOM;menuOpen;buttonDOM;dt;events;initialized;options;menuDOM;containerDOM;wrapperDOM;limits;rect;event;constructor(t,e={}){this.dt=t,this.options={...at,...e}}init(){if(this.initialized)return;const t=c(this.options.classes.button);let e=this.dt.wrapperDOM.querySelector(t);if(!e){e=n("button",{class:this.options.classes.button,html:"▦"});const t=c(this.dt.options.classes.search),s=this.dt.wrapperDOM.querySelector(t);s?s.appendChild(e):this.dt.wrapperDOM.appendChild(e),this.addedButtonDOM=!0}this.buttonDOM=e,this.containerDOM=n("div",{id:this.options.classes.container}),this.wrapperDOM=n("div",{class:this.options.classes.wrapper}),this.menuDOM=n("ul",{class:this.options.classes.menu,html:this.dt.data.headings.map(((t,e)=>{const s=this.dt.columns.settings[e];return this.options.hiddenColumns.includes(e)?"":`<li data-column="${e}">\n <input type="checkbox" value="${t.text||t.data}" ${s.hidden?"":"checked=''"}>\n <label>\n ${t.text||t.data}\n </label>\n </li>`})).join("")}),this.wrapperDOM.appendChild(this.menuDOM),this.containerDOM.appendChild(this.wrapperDOM),this._measureSpace(),this._bind(),this.initialized=!0}dismiss(){this.addedButtonDOM&&this.buttonDOM.parentElement&&this.buttonDOM.parentElement.removeChild(this.buttonDOM),document.removeEventListener("click",this.events.click)}_bind(){this.events={click:this._click.bind(this)},document.addEventListener("click",this.events.click)}_openMenu(){document.body.appendChild(this.containerDOM),this._measureSpace(),this.menuOpen=!0,this.dt.emit("columnFilter.menu.open")}_closeMenu(){this.menuOpen&&(this.menuOpen=!1,document.body.removeChild(this.containerDOM),this.dt.emit("columnFilter.menu.close"))}_measureSpace(){const t=window.scrollX||window.pageXOffset,e=window.scrollY||window.pageYOffset;this.rect=this.wrapperDOM.getBoundingClientRect(),this.limits={x:window.innerWidth+t-this.rect.width,y:window.innerHeight+e-this.rect.height}}_click(t){const e=t.target;if(e instanceof Element)if(this.event=t,this.buttonDOM.contains(e)){if(t.preventDefault(),this.menuOpen)return void this._closeMenu();this._openMenu();let e=t.pageX,s=t.pageY;e>this.limits.x&&(e-=this.rect.width),s>this.limits.y&&(s-=this.rect.height),this.wrapperDOM.style.top=`${s}px`,this.wrapperDOM.style.left=`${e}px`}else if(this.menuDOM.contains(e)){const t=c(this.options.classes.menu),s=e.closest(`${t} > li`);if(!s)return;const i=s.querySelector("input[type=checkbox]");i.contains(e)||(i.checked=!i.checked);const n=Number(s.dataset.column);i.checked?this.dt.columns.show([n]):this.dt.columns.hide([n])}else this.menuOpen&&this._closeMenu()}}s.DataTable=class{columns;containerDOM;_currentPage;data;_dd;dom;_events;hasHeadings;hasRows;headerDOM;_initialHTML;initialized;_label;lastPage;_listeners;onFirstPage;onLastPage;options;_pagerDOMs;_virtualPagerDOM;pages;_rect;rows;_searchData;_searchQueries;_tableAttributes;_tableFooters;_tableCaptions;totalPages;_virtualDOM;_virtualHeaderDOM;wrapperDOM;constructor(t,e={}){const s="string"==typeof t?document.querySelector(t):t;s instanceof HTMLTableElement?this.dom=s:(this.dom=document.createElement("table"),s.appendChild(this.dom));const i={...et.diffDomOptions,...e.diffDomOptions},n={...et.labels,...e.labels},a={...et.classes,...e.classes};this.options={...et,...e,diffDomOptions:i,labels:n,classes:a},this._initialHTML=this.options.destroyable?s.outerHTML:"",this.options.tabIndex?this.dom.tabIndex=this.options.tabIndex:this.options.rowNavigation&&-1===this.dom.tabIndex&&(this.dom.tabIndex=0),this._listeners={onResize:()=>this._onResize()},this._dd=new F(this.options.diffDomOptions||{}),this.initialized=!1,this._events={},this._currentPage=0,this.onFirstPage=!0,this.hasHeadings=!1,this.hasRows=!1,this._searchQueries=[],this.init()}init(){if(this.initialized||h(this.dom,this.options.classes.table))return!1;this._virtualDOM=A(this.dom,this.options.diffDomOptions||{}),this._tableAttributes={...this._virtualDOM.attributes},this._tableFooters=this._virtualDOM.childNodes?.filter((t=>"TFOOT"===t.nodeName))??[],this._tableCaptions=this._virtualDOM.childNodes?.filter((t=>"CAPTION"===t.nodeName))??[],void 0!==this.options.caption&&this._tableCaptions.push({nodeName:"CAPTION",childNodes:[{nodeName:"#text",data:this.options.caption}]}),this.rows=new K(this),this.columns=new tt(this),this.data=G(this.options.data,this.dom,this.columns.settings,this.options.type,this.options.format),this._render(),setTimeout((()=>{this.emit("datatable.init"),this.initialized=!0}),10)}_render(){this.wrapperDOM=n("div",{class:`${this.options.classes.wrapper} ${this.options.classes.loading}`}),this.wrapperDOM.innerHTML=this.options.template(this.options,this.dom);const t=c(this.options.classes.selector),e=this.wrapperDOM.querySelector(`select${t}`);e&&this.options.paging&&this.options.perPageSelect?this.options.perPageSelect.forEach((t=>{const[s,i]=Array.isArray(t)?[t[0],t[1]]:[String(t),t],n=i===this.options.perPage,a=new Option(s,String(i),n,n);e.appendChild(a)})):e&&e.parentElement.removeChild(e);const s=c(this.options.classes.container);this.containerDOM=this.wrapperDOM.querySelector(s),this._pagerDOMs=[];const i=c(this.options.classes.pagination);Array.from(this.wrapperDOM.querySelectorAll(i)).forEach((t=>{t instanceof HTMLElement&&(t.innerHTML=`<ul class="${this.options.classes.paginationList}"></ul>`,this._pagerDOMs.push(t.firstElementChild))})),this._virtualPagerDOM={nodeName:"UL",attributes:{class:this.options.classes.paginationList}};const a=c(this.options.classes.info);this._label=this.wrapperDOM.querySelector(a),this.dom.parentElement.replaceChild(this.wrapperDOM,this.dom),this.containerDOM.appendChild(this.dom),this._rect=this.dom.getBoundingClientRect(),this._fixHeight(),this.options.header||this.wrapperDOM.classList.add("no-header"),this.options.footer||this.wrapperDOM.classList.add("no-footer"),this.options.sortable&&this.wrapperDOM.classList.add("sortable"),this.options.searchable&&this.wrapperDOM.classList.add("searchable"),this.options.fixedHeight&&this.wrapperDOM.classList.add("fixed-height"),this.options.fixedColumns&&this.wrapperDOM.classList.add("fixed-columns"),this._bindEvents(),this.columns._state.sort&&this.columns.sort(this.columns._state.sort.column,this.columns._state.sort.dir,!0),this.update(!0)}_renderTable(t={}){let e;e=(this.options.paging||this._searchQueries.length||this.columns._state.filters.length)&&this._currentPage&&this.pages.length&&!t.noPaging?this.pages[this._currentPage-1]:this.data.data.map(((t,e)=>({row:t,index:e})));let s=z(this._tableAttributes,this.data.headings,e,this.columns.settings,this.columns._state,this.rows.cursor,this.options,t,this._tableFooters,this._tableCaptions);if(this.options.tableRender){const t=this.options.tableRender(this.data,s,"main");t&&(s=t)}const i=this._dd.diff(this._virtualDOM,s);this._dd.apply(this.dom,i),this._virtualDOM=s}_renderPage(t=!1){this.hasRows&&this.totalPages?(this._currentPage>this.totalPages&&(this._currentPage=1),this._renderTable(),this.onFirstPage=1===this._currentPage,this.onLastPage=this._currentPage===this.lastPage):this.setMessage(this.options.labels.noRows);let e,s=0,i=0,n=0;if(this.totalPages&&(s=this._currentPage-1,i=s*this.options.perPage,n=i+this.pages[s].length,i+=1,e=this._searchQueries.length?this._searchData.length:this.data.data.length),this._label&&this.options.labels.info.length){const t=this.options.labels.info.replace("{start}",String(i)).replace("{end}",String(n)).replace("{page}",String(this._currentPage)).replace("{pages}",String(this.totalPages)).replace("{rows}",String(e));this._label.innerHTML=e?t:""}if(1==this._currentPage&&this._fixHeight(),this.options.rowNavigation&&this._currentPage&&(!this.rows.cursor||!this.pages[this._currentPage-1].find((t=>t.index===this.rows.cursor)))){const e=this.pages[this._currentPage-1];e.length&&(t?this.rows.setCursor(e[e.length-1].index):this.rows.setCursor(e[0].index))}}_renderPagers(){if(!this.options.paging)return;let t=((t,e,s,i,n)=>{let a=[];if(n.firstLast&&a.push(st(1,n.firstText,n)),n.nextPrev){const e=t?1:s-1;a.push(st(e,n.prevText,n,{hidden:t}))}let o=[...Array(i).keys()].map((t=>st(t+1,String(t+1),n,{active:t===s-1})));if(n.truncatePager&&(o=((t,e,s,i)=>{const n=i.pagerDelta,a=i.classes,o=i.ellipsisText,r=2*n;let l=e-n,d=e+n;e<4-n+r?d=3+r:e>s-(3-n+r)&&(l=s-(2+r));const c=[];for(let e=1;e<=s;e++)if(1==e||e==s||e>=l&&e<=d){const s=t[e-1];c.push(s)}let h;const u=[];return c.forEach((e=>{const s=parseInt(e.childNodes[0].attributes["data-page"],10);if(h){const e=parseInt(h.childNodes[0].attributes["data-page"],10);if(s-e==2)u.push(t[e]);else if(s-e!=1){const t={nodeName:"LI",attributes:{class:`${a.paginationListItem} ${a.ellipsis} ${a.disabled}`},childNodes:[{nodeName:"BUTTON",attributes:{class:a.paginationListItemLink},childNodes:[{nodeName:"#text",data:o}]}]};u.push(t)}}u.push(e),h=e})),u})(o,s,i,n)),a=a.concat(o),n.nextPrev){const t=e?i:s+1;a.push(st(t,n.nextText,n,{hidden:e}))}return n.firstLast&&a.push(st(i,n.lastText,n)),{nodeName:"UL",attributes:{class:n.classes.paginationList},childNodes:o.length>1?a:[]}})(this.onFirstPage,this.onLastPage,this._currentPage,this.totalPages,this.options);if(this.options.pagerRender){const e=this.options.pagerRender([this.onFirstPage,this.onLastPage,this._currentPage,this.totalPages],t);e&&(t=e)}const e=this._dd.diff(this._virtualPagerDOM,t);this._pagerDOMs.forEach((t=>{this._dd.apply(t,e)})),this._virtualPagerDOM=t}_renderSeparateHeader(){const t=this.dom.parentElement;this.headerDOM||(this.headerDOM=document.createElement("div"),this._virtualHeaderDOM={nodeName:"DIV"}),t.parentElement.insertBefore(this.headerDOM,t);let e={nodeName:"TABLE",attributes:this._tableAttributes,childNodes:[{nodeName:"THEAD",childNodes:[B(this.data.headings,this.columns.settings,this.columns._state,this.options,{unhideHeader:!0})]}]};if(e.attributes.class=u(e.attributes.class,this.options.classes.table),this.options.tableRender){const t=this.options.tableRender(this.data,e,"header");t&&(e=t)}const s={nodeName:"DIV",attributes:{class:this.options.classes.headercontainer},childNodes:[e]},i=this._dd.diff(this._virtualHeaderDOM,s);this._dd.apply(this.headerDOM,i),this._virtualHeaderDOM=s;const n=this.headerDOM.firstElementChild.clientWidth-this.dom.clientWidth;if(n){const t=structuredClone(this._virtualHeaderDOM);t.attributes.style=`padding-right: ${n}px;`;const e=this._dd.diff(this._virtualHeaderDOM,t);this._dd.apply(this.headerDOM,e),this._virtualHeaderDOM=t}t.scrollHeight>t.clientHeight&&(t.style.overflowY="scroll")}_bindEvents(){if(this.options.perPageSelect){const t=c(this.options.classes.selector),e=this.wrapperDOM.querySelector(t);e&&e instanceof HTMLSelectElement&&e.addEventListener("change",(()=>{this.emit("datatable.perpage:before",this.options.perPage),this.options.perPage=parseInt(e.value,10),this.update(),this._fixHeight(),this.emit("datatable.perpage",this.options.perPage)}),!1)}this.options.searchable&&this.wrapperDOM.addEventListener("input",(t=>{const e=c(this.options.classes.input),s=t.target;if(!(s instanceof HTMLInputElement&&s.matches(e)))return;t.preventDefault();const i=[];if(Array.from(this.wrapperDOM.querySelectorAll(e)).filter((t=>t.value.length)).forEach((t=>{const e=t.dataset.and||this.options.searchAnd,s=t.dataset.querySeparator||this.options.searchQuerySeparator?t.value.split(this.options.searchQuerySeparator):[t.value];e?s.forEach((e=>{t.dataset.columns?i.push({terms:[e],columns:JSON.parse(t.dataset.columns)}):i.push({terms:[e],columns:void 0})})):t.dataset.columns?i.push({terms:s,columns:JSON.parse(t.dataset.columns)}):i.push({terms:s,columns:void 0})})),1===i.length&&1===i[0].terms.length){const t=i[0];this.search(t.terms[0],t.columns)}else this.multiSearch(i)})),this.wrapperDOM.addEventListener("click",(t=>{const e=t.target.closest("a, button");if(e)if(e.hasAttribute("data-page"))this.page(parseInt(e.getAttribute("data-page"),10)),t.preventDefault();else if(h(e,this.options.classes.sorter)){const s=Array.from(e.parentElement.parentElement.children).indexOf(e.parentElement),i=l(s,this.columns.settings);this.columns.sort(i),t.preventDefault()}else if(h(e,this.options.classes.filter)){const s=Array.from(e.parentElement.parentElement.children).indexOf(e.parentElement),i=l(s,this.columns.settings);this.columns.filter(i),t.preventDefault()}}),!1),this.options.rowNavigation&&this.dom.addEventListener("keydown",(t=>{if("ArrowUp"===t.key){let e;t.preventDefault(),t.stopPropagation(),this.pages[this._currentPage-1].find((t=>t.index===this.rows.cursor||(e=t,!1))),e?this.rows.setCursor(e.index):this.onFirstPage||this.page(this._currentPage-1,!0)}else if("ArrowDown"===t.key){let e;t.preventDefault(),t.stopPropagation();const s=this.pages[this._currentPage-1].find((t=>!!e||(t.index===this.rows.cursor&&(e=!0),!1)));s?this.rows.setCursor(s.index):this.onLastPage||this.page(this._currentPage+1)}else this.options.rowSelectionKeys.includes(t.key)&&this.emit("datatable.selectrow",this.rows.cursor,t,!0)})),this.dom.addEventListener("mousedown",(t=>{const e=t.target;if(!(e instanceof Element))return;const s=Array.from(this.dom.querySelectorAll("tbody > tr")).find((t=>t.contains(e)));s&&s instanceof HTMLElement&&this.emit("datatable.selectrow",parseInt(s.dataset.index,10),t,this.dom.matches(":focus"))})),window.addEventListener("resize",this._listeners.onResize)}_onResize=p((()=>{this._rect=this.containerDOM.getBoundingClientRect(),this._rect.width&&this.update(!0)}),250);destroy(){if(this.options.destroyable){if(this.wrapperDOM){const t=this.wrapperDOM.parentElement;if(t){const e=n("div");e.innerHTML=this._initialHTML;const s=e.firstElementChild;t.replaceChild(s,this.wrapperDOM),this.dom=s}else this.options.classes.table?.split(" ").forEach((t=>this.wrapperDOM.classList.remove(t)))}window.removeEventListener("resize",this._listeners.onResize),this.initialized=!1}}update(t=!1){this.emit("datatable.update:before"),t&&(this.columns._measureWidths(),this.hasRows=Boolean(this.data.data.length),this.hasHeadings=Boolean(this.data.headings.length)),this.options.classes.empty?.split(" ").forEach((t=>this.wrapperDOM.classList.remove(t))),this._paginate(),this._renderPage(),this._renderPagers(),this.options.scrollY.length&&this._renderSeparateHeader(),this.emit("datatable.update")}_paginate(){let t=this.data.data.map(((t,e)=>({row:t,index:e})));return this._searchQueries.length&&(t=[],this._searchData.forEach((e=>t.push({index:e,row:this.data.data[e]})))),this.columns._state.filters.length&&this.columns._state.filters.forEach(((e,s)=>{e&&(t=t.filter((t=>{const i=t.row.cells[s];return"function"==typeof e?e(i.data):o(i)===e})))})),this.options.paging&&this.options.perPage>0?this.pages=t.map(((e,s)=>s%this.options.perPage==0?t.slice(s,s+this.options.perPage):null)).filter((t=>t)):this.pages=[t],this.totalPages=this.lastPage=this.pages.length,this._currentPage||(this._currentPage=1),this.totalPages}_fixHeight(){this.options.fixedHeight&&(this.containerDOM.style.height=null,this._rect=this.containerDOM.getBoundingClientRect(),this.containerDOM.style.height=`${this._rect.height}px`)}search(t,e=void 0,s="search"){if(this.emit("datatable.search:before",t,this._searchData),!t.length)return this._currentPage=1,this._searchQueries=[],this._searchData=[],this.update(),this.emit("datatable.search","",[]),this.wrapperDOM.classList.remove("search-results"),!1;this.multiSearch([{terms:[t],columns:e||void 0}],s),this.emit("datatable.search",t,this._searchData)}multiSearch(t,e="search"){if(!this.hasRows)return!1;this._currentPage=1,this._searchData=[];let s=t.map((t=>({columns:t.columns,terms:t.terms.map((t=>t.trim())).filter((t=>t)),source:e}))).filter((t=>t.terms.length));if(this.emit("datatable.multisearch:before",s,this._searchData),e.length&&(s=s.concat(this._searchQueries.filter((t=>t.source!==e)))),this._searchQueries=s,!s.length)return this.update(),this.emit("datatable.multisearch",s,this._searchData),this.wrapperDOM.classList.remove("search-results"),!1;const i=s.map((t=>this.columns.settings.map(((e,s)=>{if(e.hidden||!e.searchable||t.columns&&!t.columns.includes(s))return!1;let i=t.terms;const n=e.sensitivity||this.options.sensitivity;return["base","accent"].includes(n)&&(i=i.map((t=>t.toLowerCase()))),["base","case"].includes(n)&&(i=i.map((t=>t.normalize("NFD").replace(/\p{Diacritic}/gu,"")))),(e.ignorePunctuation??this.options.ignorePunctuation)&&(i=i.map((t=>t.replace(/[.,/#!$%^&*;:{}=-_`~()]/g,"")))),i}))));this.data.data.forEach(((t,e)=>{const n=t.cells.map(((t,e)=>{const s=this.columns.settings[e];if(s.searchMethod||this.options.searchMethod)return t;let i=o(t).trim();if(i.length){const t=s.sensitivity||this.options.sensitivity;["base","accent"].includes(t)&&(i=i.toLowerCase()),["base","case"].includes(t)&&(i=i.normalize("NFD").replace(/\p{Diacritic}/gu,"")),(s.ignorePunctuation??this.options.ignorePunctuation)&&(i=i.replace(/[.,/#!$%^&*;:{}=-_`~()]/g,""))}const n=s.searchItemSeparator||this.options.searchItemSeparator;return n?i.split(n):[i]}));i.every(((e,i)=>e.find(((e,a)=>{if(!e)return!1;const o=this.columns.settings[a].searchMethod||this.options.searchMethod;return o?o(e,n[a],t,a,s[i].source):e.find((t=>n[a].find((e=>e.includes(t)))))}))))&&this._searchData.push(e)})),this.wrapperDOM.classList.add("search-results"),this._searchData.length?this.update():(this.wrapperDOM.classList.remove("search-results"),this.setMessage(this.options.labels.noResults)),this.emit("datatable.multisearch",s,this._searchData)}page(t,e=!1){return this.emit("datatable.page:before",t),t!==this._currentPage&&(isNaN(t)||(this._currentPage=t),!(t>this.pages.length||t<0)&&(this._renderPage(e),this._renderPagers(),void this.emit("datatable.page",t)))}insert(t){let s=[];if(Array.isArray(t)){const e=this.data.headings.map((t=>t.data?String(t.data):t.text));t.forEach(((t,i)=>{const n=[];Object.entries(t).forEach((([t,s])=>{const a=e.indexOf(t);a>-1?n[a]=X(s,this.columns.settings[a]):this.hasHeadings||this.hasRows||0!==i||(n[e.length]=X(s,this.columns.settings[e.length]),e.push(t),this.data.headings.push(Z(t)))})),s.push({cells:n})}))}else e(t)&&(!t.headings||this.hasHeadings||this.hasRows?t.data&&Array.isArray(t.data)&&(s=t.data.map((t=>{let e,s;return Array.isArray(t)?(e={},s=t):(e=t.attributes,s=t.cells),{attributes:e,cells:s.map(((t,e)=>X(t,this.columns.settings[e])))}}))):this.data=G(t,void 0,this.columns.settings,this.options.type,this.options.format));s.length&&s.forEach((t=>this.data.data.push(t))),this.hasHeadings=Boolean(this.data.headings.length),this.columns._state.sort&&this.columns.sort(this.columns._state.sort.column,this.columns._state.sort.dir,!0),this.update(!0)}refresh(){if(this.emit("datatable.refresh:before"),this.options.searchable){const t=c(this.options.classes.input);Array.from(this.wrapperDOM.querySelectorAll(t)).forEach((t=>t.value="")),this._searchQueries=[]}this._currentPage=1,this.onFirstPage=!0,this.update(!0),this.emit("datatable.refresh")}print(){const t=n("table");let e=z(this._tableAttributes,this.data.headings,this.data.data.map(((t,e)=>({row:t,index:e}))),this.columns.settings,this.columns._state,!1,this.options,{noColumnWidths:!0,unhideHeader:!0},this._tableFooters,this._tableCaptions);if(this.options.tableRender){const t=this.options.tableRender(this.data,e,"print");t&&(e=t)}const s=this._dd.diff({nodeName:"TABLE"},e);this._dd.apply(t,s);const i=window.open();i.document.body.appendChild(t),i.print()}setMessage(t){const e=this.data.headings.filter(((t,e)=>!this.columns.settings[e]?.hidden)).length||1;this.options.classes.empty?.split(" ").forEach((t=>this.wrapperDOM.classList.add(t))),this._label&&(this._label.innerHTML=""),this.totalPages=0,this._renderPagers();let s={nodeName:"TABLE",attributes:this._tableAttributes,childNodes:[{nodeName:"THEAD",childNodes:[B(this.data.headings,this.columns.settings,this.columns._state,this.options,{})]},{nodeName:"TBODY",childNodes:[{nodeName:"TR",childNodes:[{nodeName:"TD",attributes:{class:this.options.classes.empty,colspan:String(e)},childNodes:[{nodeName:"#text",data:t}]}]}]}]};if(this._tableFooters.forEach((t=>s.childNodes.push(t))),this._tableCaptions.forEach((t=>s.childNodes.push(t))),s.attributes.class=u(s.attributes.class,this.options.classes.table),this.options.tableRender){const t=this.options.tableRender(this.data,s,"message");t&&(s=t)}const i=this._dd.diff(this._virtualDOM,s);this._dd.apply(this.dom,i),this._virtualDOM=s}on(t,e){this._events[t]=this._events[t]||[],this._events[t].push(e)}off(t,e){t in this._events!=0&&this._events[t].splice(this._events[t].indexOf(e),1)}emit(t,...e){if(t in this._events!=0)for(let s=0;s<this._events[t].length;s++)this._events[t][s](...e)}},s.addColumnFilter=function(t,e={}){const s=new ot(t,e);return t.initialized?s.init():t.on("datatable.init",(()=>s.init())),s},s.convertCSV=function(t){let s;if(!e(t))return!1;const i={lineDelimiter:"\n",columnDelimiter:",",removeDoubleQuotes:!1,...t};if(i.data.length){s={data:[]};const t=i.data.split(i.lineDelimiter);if(t.length&&(i.headings&&(s.headings=t[0].split(i.columnDelimiter),i.removeDoubleQuotes&&(s.headings=s.headings.map((t=>t.trim().replace(/(^"|"$)/g,"")))),t.shift()),t.forEach(((t,e)=>{s.data[e]=[];const n=t.split(i.columnDelimiter);n.length&&n.forEach((t=>{i.removeDoubleQuotes&&(t=t.trim().replace(/(^"|"$)/g,"")),s.data[e].push(t)}))}))),s)return s}return!1},s.convertJSON=function(t){let s;if(!e(t))return!1;const n={data:"",...t};if(n.data.length||e(n.data)){const t=!!i(n.data)&&JSON.parse(n.data);if(t?(s={headings:[],data:[]},t.forEach(((t,e)=>{s.data[e]=[],Object.entries(t).forEach((([t,i])=>{s.headings.includes(t)||s.headings.push(t),s.data[e].push(i)}))}))):console.warn("That's not valid JSON!"),s)return s}return!1},s.createElement=n,s.exportCSV=function(t,s={}){if(!t.hasHeadings&&!t.hasRows)return!1;if(!e(s))return!1;const i={download:!0,skipColumn:[],lineDelimiter:"\n",columnDelimiter:",",...s},n=e=>!i.skipColumn.includes(e)&&!t.columns.settings[e]?.hidden,a=t.data.headings.filter(((t,e)=>n(e))).map((t=>t.text??t.data));let r;if(i.selection)if(Array.isArray(i.selection)){r=[];for(let e=0;e<i.selection.length;e++)r=r.concat(t.pages[i.selection[e]-1].map((t=>t.row)))}else r=t.pages[i.selection-1].map((t=>t.row));else r=t.data.data;let l=[];if(l[0]=a,l=l.concat(r.map((t=>t.cells.filter(((t,e)=>n(e))).map((t=>o(t)))))),l.length){let t="";if(l.forEach((e=>{e.forEach((e=>{"string"==typeof e&&(e=(e=(e=(e=(e=e.trim()).replace(/\s{2,}/g," ")).replace(/\n/g," ")).replace(/"/g,'""')).replace(/#/g,"%23")).includes(",")&&(e=`"${e}"`),t+=e+i.columnDelimiter})),t=t.trim().substring(0,t.length-1),t+=i.lineDelimiter})),t=t.trim().substring(0,t.length-1),i.download){const e=document.createElement("a");e.href=encodeURI(`data:text/csv;charset=utf-8,${t}`),e.download=`${i.filename||"datatable_export"}.csv`,document.body.appendChild(e),e.click(),document.body.removeChild(e)}return t}return!1},s.exportJSON=function(t,s={}){if(!t.hasHeadings&&!t.hasRows)return!1;if(!e(s))return!1;const i={download:!0,skipColumn:[],replacer:null,space:4,...s},n=e=>!i.skipColumn.includes(e)&&!t.columns.settings[e]?.hidden;let a;if(i.selection)if(Array.isArray(i.selection)){a=[];for(let e=0;e<i.selection.length;e++)a=a.concat(t.pages[i.selection[e]-1].map((t=>t.row)))}else a=t.pages[i.selection-1].map((t=>t.row));else a=t.data.data;const r=a.map((t=>t.cells.filter(((t,e)=>n(e))).map((t=>o(t))))),l=t.data.headings.filter(((t,e)=>n(e))).map((t=>t.text??String(t.data)));if(r.length){const t=[];r.forEach(((e,s)=>{t[s]=t[s]||{},e.forEach(((e,i)=>{t[s][l[i]]=e}))}));const e=JSON.stringify(t,i.replacer,i.space);if(i.download){const t=new Blob([e],{type:"data:application/json;charset=utf-8"}),s=URL.createObjectURL(t),n=document.createElement("a");n.href=s,n.download=`${i.filename||"datatable_export"}.json`,document.body.appendChild(n),n.click(),document.body.removeChild(n),URL.revokeObjectURL(s)}return e}return!1},s.exportSQL=function(t,s={}){if(!t.hasHeadings&&!t.hasRows)return!1;if(!e(s))return!1;const i={download:!0,skipColumn:[],tableName:"myTable",...s},n=e=>!i.skipColumn.includes(e)&&!t.columns.settings[e]?.hidden;let a=[];if(i.selection)if(Array.isArray(i.selection))for(let e=0;e<i.selection.length;e++)a=a.concat(t.pages[i.selection[e]-1].map((t=>t.row)));else a=t.pages[i.selection-1].map((t=>t.row));else a=t.data.data;const r=a.map((t=>t.cells.filter(((t,e)=>n(e))).map((t=>o(t))))),l=t.data.headings.filter(((t,e)=>n(e))).map((t=>t.text??String(t.data)));if(r.length){let t=`INSERT INTO \`${i.tableName}\` (`;if(l.forEach((e=>{t+=`\`${e}\`,`})),t=t.trim().substring(0,t.length-1),t+=") VALUES ",r.forEach((e=>{t+="(",e.forEach((e=>{t+="string"==typeof e?`"${e}",`:`${e},`})),t=t.trim().substring(0,t.length-1),t+="),"})),t=t.trim().substring(0,t.length-1),t+=";",i.download&&(t=`data:application/sql;charset=utf-8,${t}`),i.download){const e=document.createElement("a");e.href=encodeURI(t),e.download=`${i.filename||"datatable_export"}.sql`,document.body.appendChild(e),e.click(),document.body.removeChild(e)}return t}return!1},s.exportTXT=function(t,s={}){if(!t.hasHeadings&&!t.hasRows)return!1;if(!e(s))return!1;const i={download:!0,skipColumn:[],lineDelimiter:"\n",columnDelimiter:",",...s},n=e=>!i.skipColumn.includes(e)&&!t.columns.settings[e]?.hidden,a=t.data.headings.filter(((t,e)=>n(e))).map((t=>t.text??t.data));let r;if(i.selection)if(Array.isArray(i.selection)){r=[];for(let e=0;e<i.selection.length;e++)r=r.concat(t.pages[i.selection[e]-1].map((t=>t.row)))}else r=t.pages[i.selection-1].map((t=>t.row));else r=t.data.data;let l=[];if(l[0]=a,l=l.concat(r.map((t=>t.cells.filter(((t,e)=>n(e))).map((t=>o(t)))))),l.length){let t="";if(l.forEach((e=>{e.forEach((e=>{"string"==typeof e&&(e=(e=(e=(e=(e=e.trim()).replace(/\s{2,}/g," ")).replace(/\n/g," ")).replace(/"/g,'""')).replace(/#/g,"%23")).includes(",")&&(e=`"${e}"`),t+=e+i.columnDelimiter})),t=t.trim().substring(0,t.length-1),t+=i.lineDelimiter})),t=t.trim().substring(0,t.length-1),i.download&&(t=`data:text/csv;charset=utf-8,${t}`),i.download){const e=document.createElement("a");e.href=encodeURI(t),e.download=`${i.filename||"datatable_export"}.txt`,document.body.appendChild(e),e.click(),document.body.removeChild(e)}return t}return!1},s.isJson=i,s.isObject=e,s.makeEditable=function(t,e={}){const s=new nt(t,e);return t.initialized?s.init():t.on("datatable.init",(()=>s.init())),s}}).call(this)}).call(this,"undefined"!=typeof global?global:"undefined"!=typeof self?self:"undefined"!=typeof window?window:{})},{}]},{},[1])(1)})); +//# sourceMappingURL=/sm/24f69c53b1479d19ec6b00a3a1883d4be0b145ad8188e41dff78df0e8b14d732.map \ No newline at end of file diff --git a/CodeListLibrary_project/cll/static/scss/_brands.scss b/CodeListLibrary_project/cll/static/scss/_brands.scss index 2c72563fc..c4cc681b9 100644 --- a/CodeListLibrary_project/cll/static/scss/_brands.scss +++ b/CodeListLibrary_project/cll/static/scss/_brands.scss @@ -16,6 +16,7 @@ $brand-map-none: ( 'color-clear': $transparent, 'color-accent-bright': $white, 'color-accent-lightest': $white_smoke, + 'color-accent-grey': $light_grey, 'color-accent-darkish': $light_grey, 'color-accent-dark': $dark_grey, 'color-accent-washed': $lightest_grey, @@ -75,10 +76,12 @@ $brand-map-none: ( 'loading-spinner-text-color': $white, // Scrollbars + 'slim-scrollbar-track-color': $lightest_grey_02, 'slim-scrollbar-inactive-color': $lightest_grey, 'slim-scrollbar-active-color': $transparent_black, 'slim-scrollbar-radius': 6px, 'slim-scrollbar-width': 6px, + 'slim-scrollbar-height': 8px, // Breadcrumbs 'breadcrumb-color': $dark_grey, @@ -200,6 +203,7 @@ $brand-map-ADP: ( 'color-clear': $transparent, 'color-accent-bright': $white, 'color-accent-lightest': $light_grayish_blue, + 'color-accent-grey': $light_grey, 'color-accent-darkish': $light_grey, 'color-accent-dark': $very_dark_blue, 'color-accent-washed': $lightest_grey, @@ -263,6 +267,7 @@ $brand-map-ADP: ( 'slim-scrollbar-active-color': $transparent_black, 'slim-scrollbar-radius': 6px, 'slim-scrollbar-width': 6px, + 'slim-scrollbar-height': 8px, // Breadcrumbs 'breadcrumb-color': $dark_grey, @@ -378,6 +383,7 @@ $brand-map-HDRUK: ( 'color-clear': $transparent, 'color-accent-bright': $white, 'color-accent-lightest': $white_smoke, + 'color-accent-grey': $light_grey, 'color-accent-darkish': $light_grey, 'color-accent-dark': $dark_grey, 'color-accent-washed': $lightest_grey, @@ -437,10 +443,12 @@ $brand-map-HDRUK: ( 'loading-spinner-text-color': $white, // Scrollbars + 'slim-scrollbar-track-color': $lightest_grey_02, 'slim-scrollbar-inactive-color': $lightest_grey, 'slim-scrollbar-active-color': $transparent_black, 'slim-scrollbar-radius': 6px, 'slim-scrollbar-width': 6px, + 'slim-scrollbar-height': 8px, // Breadcrumbs 'breadcrumb-color': $dark_grey, @@ -547,6 +555,7 @@ $brand-map-SAIL: ( 'color-clear': $transparent, 'color-accent-bright': $white, 'color-accent-lightest': $white_smoke, + 'color-accent-grey': $light_grey, 'color-accent-darkish': $light_grey, 'color-accent-dark': $dark_grey, 'color-accent-washed': $lightest_grey, @@ -606,10 +615,12 @@ $brand-map-SAIL: ( 'loading-spinner-text-color': $white, // Scrollbars + 'slim-scrollbar-track-color': $lightest_grey_02, 'slim-scrollbar-inactive-color': $lightest_grey, 'slim-scrollbar-active-color': $transparent_black, 'slim-scrollbar-radius': 6px, 'slim-scrollbar-width': 6px, + 'slim-scrollbar-height': 8px, // Breadcrumbs 'breadcrumb-color': $dark_grey, @@ -717,6 +728,196 @@ $brand-map-SAIL: ( 'phenotype-banner-title-color': $black, ); +$brand-map-HDRN: ( + // Brand logo + 'logo-url': url('/static/img/brands/HDRN/header_logo.png'), + + // Fonts + 'font-name': 'Arial', + 'heading-font': 'Lato', + 'font-family': sans-serif, + 'font-size': 14px, + + // Color accents + 'color-bg': $white, + 'color-clear': $transparent, + 'color-accent-bright': $white, + 'color-accent-lightest': $hdrn_lighter_gray, + 'color-accent-grey': $hdrn_gray, + 'color-accent-darkish': $hdrn_darkish_blue, + 'color-accent-dark': $hdrn_steel_blue, + 'color-accent-washed': $hdrn_light_gray, + 'color-accent-primary': $hdrn_light_blue, + 'color-accent-secondary': $hdrn_pale_aqua, + 'color-accent-tertiary': $hdrn_sky_mint, + 'color-accent-highlight': $bright_blue, //$hdrn_steel_blue, + 'color-accent-bubble': $hdrn_pale_aqua, + 'color-accent-success': $light_grey_green, + 'color-accent-danger': $pastel_red, + 'color-accent-warning': $wax_flower, + 'color-accent-anchor': $bright_blue, + 'color-accent-transparent': $transparent, + 'color-accent-semi-transparent': rgba($black, 0.05), + + // Text accents + 'color-text-darkest': $hdrn_darker_blue, + 'color-text-darker': $hdrn_dark_blue, + 'color-text-dark': $hdrn_steel_blue, + 'color-text-washed': $hdrn_light_gray, + 'color-text-brightest': $white, + 'color-text-success': $light_grey_green, + 'color-text-danger': $pastel_red, + 'color-text-warning': $wax_flower, + + // Icons + 'icons-name': var(--fa-style-family-classic), + 'icons-style': var(--fa-font-solid), + 'icons-size': 1rem, + + // Navigation + 'navigation-offset': 1.5rem, + 'navigation-gutter': 6rem, + 'navigation-mobile-offset': 1rem, + 'navigation-mobile-gutter': 1rem, + 'navigation-hamburger-size': 1.5rem, + 'navigation-height': 2rem, + 'navigation-line-width': 0.15rem, + 'navigation-selection-box': $white, + 'navigation-item-selector': $hdrn_sky_mint, + 'navigation-scroll-shadow': 'inset 0px -1px 0px #f3f3f4', + 'navigation-panel-shadow': '0px 5px 15px -1px rgba(0, 0, 0, 0.5)', + + // About_dropdown + 'panel-shadow': 0px 2px 50px 7px rgba(0, 0, 0, 0.25), + 'floating-shadow': 0px 10px 30px 3px rgba(0, 0, 0, 0.35), + 'dropdown-shadow': 0px 10px 70px rgba(0, 0, 0, 0.15), + 'item-icon-color': $hdrn_sky_mint, + 'icon-width': 40px, + 'icon-height': 40px, + 'icon-background-color': $light_red, + 'stroke-icon': $blackish-grey, + 'description-text-color': $lightblack-grey, + + // Loading spinner color + 'loading-spinner-color': $solitude, + 'loading-spinner-text-color': $white, + + // Scrollbars + 'slim-scrollbar-track-color': $lightest_grey_02, + 'slim-scrollbar-inactive-color': $lightest_grey, + 'slim-scrollbar-active-color': $transparent_black, + 'slim-scrollbar-radius': 6px, + 'slim-scrollbar-width': 6px, + 'slim-scrollbar-height': 8px, + + // Breadcrumbs + 'breadcrumb-color': $dark_grey, + 'breadcrumb-hover-color': $black, + 'breadcrumb-active-weight': bold, + + // Main page + 'main-top-padding': 5rem, + 'main-top-mobile-padding': 4rem, + 'main-gutter': 6rem, + 'main-mobile-gutter': 1.75rem, + + // Footer + 'footer-gutter': 6rem, + 'footer-mobile-gutter': 1rem, + 'footer-page-offset': 1rem, + 'footer-top-offset': 0.25rem, + 'footer-bottom-offset': 1rem, + 'footer-copyright-size': 12px, + 'footer-text-size': 12px, + 'footer-row-gap': 4rem, + + // Markdown + 'blockquote-bg-color': $lightest_grey, + 'blockquote-edge-color': $light_grey, + + // Item lists + 'item-list-odd-bg-color': $off_white02, + 'item-list-even-bg-color': $white, + + // Inputs + 'switch-size': 50px, + 'switch-thumb-color': $off_white, + 'switch-bg-color': $lightest_grey, + 'switch-bg-active-color': $soft_blue, + + 'search-shadow-inactive-color': $cow_black01, + 'search-shadow-active-color': $cow_black02, + + // Chips + 'base-metachip-bg-color': $pickled_bluewood, + + // Cards + 'card-shadow-color': $cow_black01, + 'card-border-radius': 0.1rem, + 'referral-card-anchor-color': $iris, + 'entity-card-anchor-color': $iris, + + // Pagination controller + 'pagination-inactive-color': $white_smoke, + 'pagination-active-color': $platinum, + 'pagination-disabled-color': $boulder, + + /* Homepage */ + + // Hero + 'hero-area-bg-color': $hdrn_light_blue, + 'hero-gutter': 0px, + 'hero-mobile-gutter': 0px, + 'hero-drop-shadow': 0 3rem 0.05rem $periwinkle, + + // Feature + 'feature-highlight-color': $hdrn_steel_blue, + 'feature-icon-color': $hdrn_steel_blue, + + // Brand cards + 'brand-card-bg-color': $hdrn_light_blue, + + /* Articles */ + + // Phenotype creation + 'phenotype-article-sm-size': 55%, + 'phenotype-article-lg-size': calc(100% - 2.5rem), + 'phenotype-article-header-border': $light_grey, + 'phenotype-article-h1-size': 2em, + 'phenotype-article-h1-weight': bold, + 'phenotype-max-codelist-size': 200px, + + 'wizard-step-color': $pixie_green, + 'progress-item-min-step-height': 4rem, + 'progress-tracker-width': 2px, + 'progress-tracker-offset': -1.5rem, + 'progress-tracker-size': 26px, + 'progress-tracker-bg-color': $white, + 'progress-tracker-counter-weight': bold, + 'progress-tracker-counter-color': black, + 'progress-tracker-color': $light_grey, + 'progress-tracker-active-color': $pixie_green, + 'progress-tracker-off-color': $light_grey, + 'progress-tracker-line-width': 2px, + + 'progress-tracker-header-size': 28px, + 'progress-tracker-header-weight': bold, + 'progress-tracker-header-color': black, + 'progress-tracker-description-size': 16px, + + 'ruleset-bg-color': $lightest_grey, + 'ruleset-icon-normal-color': $dark_grey, + 'ruleset-include-icon-checked-color': $lime_green, + 'ruleset-exclude-icon-checked-color': $pastel_red, + + // Phenotype search + 'phenotype-banner-bg-color': $hdrn_light_blue, + 'phenotype-banner-title-size': 30px, + 'phenotype-banner-title-color': $black, +); + + + // Append all brand-specific variables :root[data-brand="none"], @@ -742,3 +943,9 @@ $brand-map-SAIL: ( :root[data-brand="SAIL"]::before { @include map-brand($brand-map-SAIL); } + +:root[data-brand="HDRN"], +:root[data-brand="HDRN"]::after, +:root[data-brand="HDRN"]::before { + @include map-brand($brand-map-HDRN); +} diff --git a/CodeListLibrary_project/cll/static/scss/_fonts.scss b/CodeListLibrary_project/cll/static/scss/_fonts.scss index b054fe192..9503c842e 100644 --- a/CodeListLibrary_project/cll/static/scss/_fonts.scss +++ b/CodeListLibrary_project/cll/static/scss/_fonts.scss @@ -6,6 +6,13 @@ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } +@font-face { + font-family: 'Lato'; + font-style: normal; + font-weight: 400; + src: url(fonts/lato-regular.ttf) format('truetype'); +} + :host, :root { --fa-style-family-classic: "Font Awesome 6 Free"; --fa-font-solid: normal 900 1em/1 "Font Awesome 6 Free" diff --git a/CodeListLibrary_project/cll/static/scss/_utils.scss b/CodeListLibrary_project/cll/static/scss/_utils.scss index 957b41dbc..8ec87d39d 100644 --- a/CodeListLibrary_project/cll/static/scss/_utils.scss +++ b/CodeListLibrary_project/cll/static/scss/_utils.scss @@ -1,3 +1,18 @@ +/// @desc applies a focus style to the element +/// @example see `dashboard.scss` +@mixin apply-focusable-item($style: '0 0 2px 2px #51a7e8') { + outline: none; + @include prefix(box-shadow, safely-unquote($style), webkit moz o); +} + +/// @desc Applies a border style to the element +/// @example see `dashboard.scss` +@mixin apply-border($style: solid, $col: $lightest_grey, $w: 1px) { + border-style: $style; + border-width: $w; + border-color: $col; +} + /// Mixin to apply the application's main font style /// @param {Number} $sz - To apply an alternative font size @mixin app-font-style($sz: false) { @@ -12,10 +27,15 @@ /// Mixin to apply a columnn wise flex-box with wrapping /// @param {Number} $gap - To apply a flexbox gap /// Ref @ https://developer.mozilla.org/en-US/docs/Learn/CSS/CSS_layout/Flexbox -@mixin flex-col($gap: false) { +@mixin flex-col($gap: false, $nowrap: false) { display: flex; flex-direction: column; - flex-wrap: wrap; + + @if not $nowrap { + flex-wrap: wrap; + } @else { + flex-wrap: nowrap; + } @if $gap { gap: $gap; @@ -25,17 +45,22 @@ /// Mixin to apply a row wise flex-box with wrapping /// @param {Number} $gap - To apply a flexbox gap /// Ref @ https://developer.mozilla.org/en-US/docs/Learn/CSS/CSS_layout/Flexbox -@mixin flex-row($gap: false) { +@mixin flex-row($gap: false, $nowrap: false) { display: flex; flex-direction: row; - flex-wrap: wrap; + + @if not $nowrap { + flex-wrap: wrap; + } @else { + flex-wrap: nowrap; + } @if $gap { gap: $gap; } } -@mixin grid-template($gap: false,$col: 2, $fr: 1fr) { +@mixin grid-template($gap: false, $col: 2, $fr: 1fr) { display: grid; grid-template-columns: repeat($col, $fr); flex-wrap: wrap; @@ -70,7 +95,7 @@ /// Mixin to stop users from being able to highlight or drag an element @mixin ignore-user { - -webkit-user-select: none; + -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none; @@ -93,23 +118,46 @@ cursor: pointer; text-decoration: inherit; color: inherit; + font-size: inherit; } /// Mixin to create a bottom divider on an element -@mixin bottom-divider($size_pad: false, $border_col: false) { +@mixin bottom-divider($size_pad: false, $border_col: false, $offset: false) { @if not $border_col { $border_col: black; } - background-image: linear-gradient(90deg, $border_col 0%, $border_col 100%); - background-image: -webkit-linear-gradient(90deg, $border_col 0%, $border_col 100%); - background-repeat: no-repeat; - background-position: 50% 100%; - - @if $size_pad { - background-size: calc(100% - #{$size_pad}) 1px, auto; + @if not $offset { + background-image: linear-gradient(90deg, $border_col 0%, $border_col 100%); + background-image: -webkit-linear-gradient(90deg, $border_col 0%, $border_col 100%); + background-repeat: no-repeat; + background-position: 50% 100%; + + @if $size_pad { + background-size: calc(100% - #{$size_pad}) 1px, auto; + } @else { + background-size: 100% 1px, auto; + } } @else { - background-size: 100% 1px, auto; + @at-root { + &:after { + position: absolute; + content: ''; + bottom: $offset; + width: 100%; + height: 100%; + background-image: linear-gradient(90deg, $border_col 0%, $border_col 100%); + background-image: -webkit-linear-gradient(90deg, $border_col 0%, $border_col 100%); + background-repeat: no-repeat; + background-position: 50% 100%; + + @if $size_pad { + background-size: calc(100% - #{$size_pad}) 1px, auto; + } @else { + background-size: 100% 1px, auto; + } + } + } } } @@ -130,3 +178,10 @@ -moz-hyphens: auto; hyphens: auto; } + +/// Mixin to apply box-sizing `border-box` +@mixin apply-bbox() { + box-sizing: border-box; + -moz-box-sizing: border-box; + -webkit-box-sizing: border-box; +} diff --git a/CodeListLibrary_project/cll/static/scss/_variables.scss b/CodeListLibrary_project/cll/static/scss/_variables.scss index 8859b90df..70a723fa7 100644 --- a/CodeListLibrary_project/cll/static/scss/_variables.scss +++ b/CodeListLibrary_project/cll/static/scss/_variables.scss @@ -6,12 +6,14 @@ $off_white: rgb(251, 251, 250); $off_white02: rgb(247, 247, 247); $white_smoke: rgb(245, 245, 245); $black: rgb(0, 0, 0); +$mostly_black: rgb(31, 28, 46); $near-black: rgb(40, 40, 40); $blackish-grey: rgb(82, 82, 82); $lightblack-grey:rgb(110,109,122); $dark_grey: rgb(140, 140, 140); $light_grey: rgb(170, 170, 170); $lightest_grey: rgb(220, 220, 220); +$lightest_grey_02: rgba(220, 220, 220, 0.2); $pastel_red: rgb(255, 135, 135); $light_red:rgb(242,242,242); $bright_blue: rgb(0, 0, 255); @@ -34,6 +36,8 @@ $indigo: rgb(90, 103, 216); $cornflower_blue: rgb(102, 126, 234); $san_juan_blue: rgb(55, 72, 113); $pickled_bluewood: rgb(51, 65, 85); +$dark_mod_lime_green: rgb(52, 125, 57); +$light_mod_lime_green: rgb(87, 171, 90); $pixie_green: rgb(196, 223, 170); $light_grey_green: rgb(182, 226, 161); $yellow_green: rgb(185, 217, 129); @@ -46,6 +50,7 @@ $lavender_mist: rgb(225, 230, 249); $zircon: rgb(246, 248, 255); $wax_flower: rgb(254, 190, 140); $debian_red: rgb(214, 19, 85); +$strong_red: rgb(214, 50, 19); /* ADP COLOURS */ // Legacy @@ -72,6 +77,18 @@ $debian_red: rgb(214, 19, 85); $bright_orange: rgb(229,157,35); $bright_cyan: rgb(221, 250, 255); +/* HDRN colours */ +$hdrn_darker_blue: rgb(0, 22, 39); +$hdrn_dark_blue: rgb(0, 39, 72); +$hdrn_darkish_blue: rgb(0, 67, 123); +$hdrn_steel_blue: rgb(0, 106, 120); +$hdrn_light_blue: rgb(186, 216, 220); +$hdrn_pale_aqua: rgb(154, 234, 206); +$hdrn_sky_mint: rgb(209, 255, 246); +$hdrn_gray: rgb(87, 87, 87); +$hdrn_light_gray: rgb(167, 165, 167); +$hdrn_lighter_gray: rgb(199, 199, 199); + /* Layout definitions */ $column-min: 1; $column-max: 12; @@ -256,7 +273,7 @@ $modifiers: ( ) ), 'margin-bottom': ( - property: margin-left, + property: margin-bottom, values: ( sm: 0.2rem, md: 0.5rem, @@ -265,7 +282,7 @@ $modifiers: ( ) ), 'margin-left': ( - property: margin-bottom, + property: margin-left, values: ( sm: 0.2rem, md: 0.5rem, diff --git a/CodeListLibrary_project/cll/static/scss/components/_accordions.scss b/CodeListLibrary_project/cll/static/scss/components/_accordions.scss index cfe5f2437..7b11cbcad 100644 --- a/CodeListLibrary_project/cll/static/scss/components/_accordions.scss +++ b/CodeListLibrary_project/cll/static/scss/components/_accordions.scss @@ -32,10 +32,11 @@ } &:after { - @include prefix(transition, transform 250ms ease-in-out, webkit moz o); + // @include prefix(transition, transform 250ms ease-in-out, webkit moz o); content: '\f107'; display: block; position: absolute; + box-sizing: border-box; font-family: var(--icons-name); font-style: var(--icons-style); font-size: var(--icons-size); @@ -46,11 +47,25 @@ line-height: 2.25em; text-align: center; background-color: var(--accordion-accent); + // -webkit-transform-origin: center center; + // -moz-transform-origin: center center; + // -ms-transform-origin: center center; + // -o-transform-origin: center center; + // transform-origin: center center; + // transform-box: content-box; } } &__input:checked + &__label:after { - @include prefix(transform, rotate(180deg), webkit moz o); + // @include prefix(transform, rotate(180deg) transformY(50%), webkit moz o); + content: '\f106'; + } + + &[tooltip]:has(.accordion__input:checked) { + &:after, + &:before { + display: none + } } &__container { @@ -75,7 +90,7 @@ /// @desc A filled, toggleable accordion with stylistic state. /// Used to control visibility of content in the phenotype/create page .fill-accordion { - @include flex-col(); + @include flex-col(false, true); position: relative; width: 100%; max-width: 100%; @@ -89,21 +104,28 @@ } &__input:checked + &__label { - background-color: col(accent-darkish); outline: 0; + &:not(:hover) { + background-color: col(accent-grey); + + .fill-accordion__label-icon { + background-color: col(accent-grey); + } + } + .fill-accordion__name-input { - --bg-color: var(--color-accent-darkish); + --bg-color: var(--color-accent-lightest); --dot-color: rgb(0, 0, 0); } .fill-accordion__input-title { display: flex; } - } - &__input:checked + &__label:after { - content: '\f077'; + .fill-accordion__label-icon:after { + content: '\f077'; + } } &__input:checked ~ &__container { @@ -111,14 +133,6 @@ height: auto; } - &__label:hover { - background-color: col(accent-darkish); - - .fill-accordion__name-input { - --bg-color: var(--color-accent-darkish); - } - } - // Used to define an input field within the accordion, used exclusively for create page &__input-title { display: none; @@ -135,6 +149,18 @@ max-width: calc(100% - 2.5rem); } + &__label:hover { + background-color: col(accent-washed); + + .fill-accordion__name-input { + --bg-color: var(--color-accent-lightest); + } + + & .fill-accordion__label-icon { + background-color: col(accent-washed); + } + } + &__name-input { @include remove-appearance(); @include prefix(transition, 'background-color 250ms ease-in-out, border-color 250ms ease-in-out', webkit moz o); @@ -180,12 +206,13 @@ flex-flow: row nowrap; align-items: center; position: relative; - padding: 0.5rem 1rem; + padding: 0; background-color: col(accent-lightest); max-width: 100%; outline: 1px solid col(accent-washed); - span { + span:not(.fill-accordion__label-icon) { + padding: 1rem 1ch; position: relative; white-space: nowrap; overflow: hidden; @@ -204,28 +231,52 @@ background: transparent; border: none; - &:after { - @include fontawesome-icon(); - content: '\f1f8'; - color: col(accent-danger); + &:after { + @include fontawesome-icon(); + content: '\f1f8'; + color: col(accent-danger); + } } - } - &:after { - @include prefix(transition, background-color 250ms ease-in-out, webkit moz o); - content: '\f107'; + & .fill-accordion__label-icon { display: block; - position: absolute; - font-family: var(--icons-name); - font-style: var(--icons-style); - font-size: var(--icons-size); - right: 0; - top: 0; - width: 2em; + position: relative; + width: 3em; height: 100%; - line-height: 2.25em; - text-align: center; + padding: 1rem 0; + margin-left: auto; background-color: col(accent-lightest); + @include prefix(transition, 'background-color 250ms ease-in-out, border-color 250ms ease-in-out', webkit moz o); + + &:after { + content: '\f107'; + display: block; + position: relative; + font-family: var(--icons-name); + font-style: var(--icons-style); + font-size: var(--icons-size); + top: calc(50% - 1em); + width: 100%; + height: 100%; + line-height: 2em; + text-align: center; + color: transparent; + background: inherit; + background-clip: text; + -webkit-background-clip: text; + filter: invert(1) contrast(200%); + -webkit-filter: invert(1) contrast(200%); + } + } + + &--slim { + & .fill-accordion__wrap-label { + padding: 0.5rem 1ch; + } + + & .fill-accordion__label-icon { + padding: 0.5rem 0; + } } } @@ -240,4 +291,15 @@ overflow: hidden; padding: 0rem 0.5rem; } + + &__input:checked + &__label:not(:hover) { + & .fill-accordion__wrap-label, + & .fill-accordion__wrap-label * { + color: col(text-brightest); + } + + & .fill-accordion__input-title { + color: col(text-brightest); + } + } } diff --git a/CodeListLibrary_project/cll/static/scss/components/_banner.scss b/CodeListLibrary_project/cll/static/scss/components/_banner.scss index 702f09c62..172718144 100644 --- a/CodeListLibrary_project/cll/static/scss/components/_banner.scss +++ b/CodeListLibrary_project/cll/static/scss/components/_banner.scss @@ -87,6 +87,35 @@ } } + &__footer-items { + display: flex; + flex-flow: row wrap; + gap: 1rem 2rem; + margin: 1rem 0 0.5rem 0; + + & > p { + margin: 0; + + & > a, & > a:visited, & > a:hover, & > a:active { + color: col(accent-anchor); + } + } + + .email-icon:before { + @include fontawesome-icon(); + content: '\f0e0'; + margin: 0 0.25rem 0 0; + cursor: initial; + } + + .website-icon:before { + @include fontawesome-icon(); + content: '\f0ac'; + margin: 0 0.25rem 0 0; + cursor: initial; + } + } + &__cards { @include flex-col(); padding: 1rem 0 2rem 0; diff --git a/CodeListLibrary_project/cll/static/scss/components/_breadcrumbs.scss b/CodeListLibrary_project/cll/static/scss/components/_breadcrumbs.scss index a98ee427d..281be9f50 100644 --- a/CodeListLibrary_project/cll/static/scss/components/_breadcrumbs.scss +++ b/CodeListLibrary_project/cll/static/scss/components/_breadcrumbs.scss @@ -25,7 +25,11 @@ margin: 1rem 0 0.5rem 0; padding: 0.5rem 0.5rem 0rem 0rem; padding: 0; - max-width: 100%; + max-width: calc(100% - 0.5rem); + + @include media('<desktop', 'screen') { + max-width: calc(100% - 1rem); + } a, a:visited { @include clear-anchor(); diff --git a/CodeListLibrary_project/cll/static/scss/components/_buttons.scss b/CodeListLibrary_project/cll/static/scss/components/_buttons.scss index 72835288b..bf5bb5300 100644 --- a/CodeListLibrary_project/cll/static/scss/components/_buttons.scss +++ b/CodeListLibrary_project/cll/static/scss/components/_buttons.scss @@ -1,7 +1,7 @@ -@import "../_methods"; -@import "../_variables"; -@import "../_media"; -@import "../_utils"; +@import '../_methods'; +@import '../_variables'; +@import '../_media'; +@import '../_utils'; /// Quit icon button /// @desc i.e. used for single tap action to exit a behaviour, action or event @@ -12,7 +12,7 @@ right: 1rem; &:after { - content: "\f057"; + content: '\f057'; cursor: pointer; position: absolute; pointer-events: auto; @@ -23,7 +23,7 @@ color: col(accent-dark); text-align: center; transition: all 250ms ease; - } + } &:disabled { opacity: 0.5; @@ -52,21 +52,23 @@ border: none; background-color: col(accent-transparent); color: col(text-darkest); - border-radius: 0.25rem; + border-radius: 0.25rem; margin: 0; padding: 1rem 2rem; - @include media("<tablet", "screen") { + @include media('<tablet', 'screen') { padding: 0.5rem 1rem; + text-wrap: wrap; + word-wrap: break-word; } - + &:not(:disabled):active { transition: all 250ms ease; - transform: translateY(0.2rem); - -webkit-transform: translateY(0.2rem); + @include prefix(transform, translateY(0.2rem), webkit moz o); } &:disabled { + pointer-events: none; opacity: 0.5; } @@ -74,63 +76,37 @@ outline: 1px solid col(accent-dark); border-radius: 2pt; } - + /* Icon style */ - &.save-icon:after { - content: "\f0c7"; - cursor: pointer; - position: absolute; - pointer-events: auto; - height: 100%; - top: 1.1rem; - aspect-ratio: 1 / 1; - font-family: var(--icons-name); - font-style: var(--icons-style); - font-size: var(--icons-size); - text-align: center; - } - - &.create-icon:after { - content: "\f382"; - cursor: pointer; - position: absolute; - pointer-events: auto; - height: 100%; - top: 1.1rem; - aspect-ratio: 1 / 1; - font-family: var(--icons-name); - font-style: var(--icons-style); - font-size: var(--icons-size); - text-align: center; - } + &.icon { + &:after { + cursor: pointer; + position: absolute; + pointer-events: auto; + height: 100%; + top: calc(50% - var(--icons-size)*0.5); + aspect-ratio: 1 / 1; + font-family: var(--icons-name); + font-style: var(--icons-style); + font-size: var(--icons-size); + text-align: center; + } - &.next-icon:after { - content: "\f35a"; - cursor: pointer; - position: absolute; - pointer-events: auto; - height: 100%; - top: 1.1rem; - aspect-ratio: 1 / 1; - font-family: var(--icons-name); - font-style: var(--icons-style); - font-size: var(--icons-size); - text-align: center; - } + &-save:after { + content: '\f0c7'; + } - &.r-arrow-icon:after { - content: "\f061"; - cursor: pointer; - pointer-events: auto; - position: absolute; - right: 0; - height: 100%; - top: 32.75%; - aspect-ratio: 1 / 1; - font-family: var(--icons-name); - font-style: var(--icons-style); - font-size: var(--icons-size); - text-align: center; + &-create:after { + content: '\f382'; + } + + &-next:after { + content: '\f35a'; + } + + &-r-arrow:after { + content: '\f061'; + } } /* Effects */ @@ -185,15 +161,15 @@ } } - &.text-danger { + &.text-danger{ color: col(text-danger); } - &.text-success { + &.text-success{ color: col(text-success); } - &.text-warning { + &.text-warning{ color: col(text-warning); } @@ -219,7 +195,7 @@ border-color: col(accent-bubble); } } - + /* Fill accents */ &.primary-accent { background-color: col(accent-primary); @@ -248,17 +224,19 @@ cursor: pointer; pointer-events: auto; position: relative; - white-space: nowrap; - overflow: hidden; - background-color: var(--accent-color); - color: col(text-darkest); - border-radius: 0.25rem; + white-space: nowrap; + overflow: hidden; + background-color: var(--accent-color); + color: col(text-darkest); + border-radius: 0.25rem; text-align: center; border: none; padding: 1rem 2rem; - @include media("<tablet", "screen") { + @include media('<tablet', 'screen') { padding: 0.5rem 1rem; + text-wrap: wrap; + word-wrap: break-word; } &:focus-visible { @@ -268,76 +246,47 @@ &:disabled { opacity: 0.5; + pointer-events: none; } /* Icon style */ &.icon { padding: 0.5rem 2.5rem 0.5rem 1rem; text-align: left; - } - &.import-icon:after { - content: "\f381"; - cursor: pointer; - pointer-events: auto; - position: absolute; - right: 0; - height: 100%; - top: 25%; - aspect-ratio: 1 / 1; - font-family: var(--icons-name); - font-style: var(--icons-style); - font-size: var(--icons-size); - text-align: center; - } - - &.attribute-icon:after { - content: "\f0db"; - cursor: pointer; - pointer-events: auto; - position: absolute; - right: 0; - height: 100%; - top: 25%; - aspect-ratio: 1 / 1; - font-family: var(--icons-name); - font-style: var(--icons-style); - font-size: var(--icons-size); - text-align: center; - } - - &.create-icon:after { - content: "\f0cb"; - cursor: pointer; - pointer-events: auto; - position: absolute; - right: 0; - height: 100%; - top: 25%; - aspect-ratio: 1 / 1; - font-family: var(--icons-name); - font-style: var(--icons-style); - font-size: var(--icons-size); - text-align: center; - } + &:after { + cursor: pointer; + pointer-events: auto; + position: absolute; + right: 0; + height: 100%; + top: calc(50% - var(--icons-size)*0.5); + aspect-ratio: 1 / 1; + font-family: var(--icons-name); + font-style: var(--icons-style); + font-size: var(--icons-size); + text-align: center; + } - &.ruleset-icon:after { - content: "\f542"; - cursor: pointer; - pointer-events: auto; - position: absolute; - right: 0; - height: 100%; - top: 25%; - aspect-ratio: 1 / 1; - font-family: var(--icons-name); - font-style: var(--icons-style); - font-size: var(--icons-size); - text-align: center; + &-import:after { + content: '\f381'; + } + + &-create:after { + content: '\f0cb'; + } + + &-ruleset:after { + content: '\f542'; + } + + &-file:after { + content: '\f574'; + } } - &.file-icon:after { - content: "\f574"; + &.delete-icon:after { + content: '\f235'; cursor: pointer; pointer-events: auto; position: absolute; @@ -403,7 +352,7 @@ border-color: col(accent-bubble); } } - + /* Fill accents */ &.primary-accent { --accent-color: var(--color-accent-primary); @@ -449,20 +398,22 @@ border: none; background-color: col(accent-transparent); color: col(text-darkest); - border-radius: 0.25rem; + border-radius: 0.25rem; padding: 0.5rem 1rem; text-align: center; margin: 0; padding: 1rem 2rem; - @include media("<tablet", "screen") { + @include media('<tablet', 'screen') { padding: 0.5rem 1rem; + text-wrap: wrap; + word-wrap: break-word; } &:disabled { opacity: 0.5; } - + &:focus-visible { outline: 1px solid col(accent-dark); border-radius: 2pt; @@ -474,24 +425,23 @@ -webkit-transform: translateY(0.2rem); } - &.import-icon:after { - content: "\f0c7"; - cursor: pointer; - position: absolute; - pointer-events: auto; - height: 100%; - top: 0.75rem; - right: 0; - aspect-ratio: 1 / 1; - font-family: var(--icons-name); - font-style: var(--icons-style); - font-size: var(--icons-size); - text-align: center; - } - &.icon { padding: 0.5rem 2rem 0.5rem 0.5rem; + &:after { + cursor: pointer; + position: absolute; + pointer-events: auto; + height: 100%; + top: calc(50% - var(--icons-size)*0.5); + right: 0; + aspect-ratio: 1 / 1; + font-family: var(--icons-name); + font-style: var(--icons-style); + font-size: var(--icons-size); + text-align: center; + } + &.left { padding: 0.5rem 0.5rem 0.5rem 2rem; @@ -499,6 +449,10 @@ left: 0; } } + + &-import:after { + content: '\f0c7'; + } } &.text-accent-darkest { @@ -547,7 +501,7 @@ border-color: col(accent-bubble); } } - + /* Fill accents */ &.primary-accent { background-color: col(accent-primary); diff --git a/CodeListLibrary_project/cll/static/scss/components/_cards.scss b/CodeListLibrary_project/cll/static/scss/components/_cards.scss index 1d6d9b1ca..2e263c5d3 100644 --- a/CodeListLibrary_project/cll/static/scss/components/_cards.scss +++ b/CodeListLibrary_project/cll/static/scss/components/_cards.scss @@ -109,9 +109,10 @@ &__title { @include clear-anchor(); color: var(--referral-card-anchor-color); + max-width: 100%; + margin: 0; font-weight: bold; font-size: 18px; - max-width: 100%; &-icon::after { content: '\f061'; @@ -339,7 +340,7 @@ &-metadata { @include flex-row(); margin: 0.5rem 0 0 0; - + &-divider:after { content: '•'; margin: 0 0.5rem; @@ -373,7 +374,9 @@ @include flex-col(); flex-wrap: nowrap; width: 100%; + max-width: calc(100% - 8px); gap: 0.25rem; + margin-left: 2px; } } } diff --git a/CodeListLibrary_project/cll/static/scss/components/_chips.scss b/CodeListLibrary_project/cll/static/scss/components/_chips.scss index ee3349e8a..71291a352 100644 --- a/CodeListLibrary_project/cll/static/scss/components/_chips.scss +++ b/CodeListLibrary_project/cll/static/scss/components/_chips.scss @@ -50,6 +50,33 @@ } } + &:has(a.meta-chip__name) { + --sat-trg: 1; + + border-radius: 5pt; + filter: saturate(var(--sat-trg)); + } + + &:has(a.meta-chip__name:hover) { + --sat-trg: 0; + } + + &:has(a.meta-chip__name:active) { + transform: scale(0.95); + transition: transform 0.1s ease-in-out; + } + + & a.meta-chip__name { + color: col(text-dark); + font-weight: bold; + text-transform: none; + text-decoration-style: dashed; + + &:visited { + font-weight: normal; + } + } + &-primary-accent { background-color: col(accent-primary); } @@ -150,11 +177,52 @@ margin: 0; padding: 0; + max-width: 100%; list-style-type: none; + &--dense { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(20ch, 1fr)); + grid-auto-flow: row dense; + max-width: 100%; + gap: 1ch; + justify-items: flex-start; + + & > .chip { + display: flex; + height: calc(100% - 0.5rem); + align-items: center; + + & > a, + & > p { + display: -webkit-box; + width: max-content; + max-width: 17ch; + margin-top: auto; + margin-bottom: auto; + line-clamp: 2; + -webkit-line-clamp: 2; + -moz-box-orient: vertical; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + } + } + + @include media('<=phone', 'screen') { + grid-template-columns: 1fr; + + & > .chip a, + & > .chip p { + width: 100%; + max-width: 100%; + } + } + } + &--presentation { - overflow-y: auto; overflow-x: hide; + overflow-y: auto; max-height: 100px; padding: 0.5rem 0.5rem calc(0.5rem + 12px) 0.2rem; } diff --git a/CodeListLibrary_project/cll/static/scss/components/_dropdown.scss b/CodeListLibrary_project/cll/static/scss/components/_dropdown.scss index a990e130a..62b825029 100644 --- a/CodeListLibrary_project/cll/static/scss/components/_dropdown.scss +++ b/CodeListLibrary_project/cll/static/scss/components/_dropdown.scss @@ -3,12 +3,176 @@ @import '../_media'; @import '../_utils'; +/// Popover / Dropdown menu +/// @desc describes a popover menu, activated on click +/// @controller see `components/popoverMenu.js` +/// @example see `dashboard/index.html` for HTML; or `dashboardService/dashboard.js` for JS +/// +.popover-menu { + width: fit-content; + height: 50px; + max-height: 50px; + padding: 0 0.5rem; + border-radius: 5px; + background-color: transparent; + + @include prefix(transition, box-shadow 250ms ease-in-out, webkit moz); + + &:focus-visible { + outline: none; + } + + & a, button { + @include remove-appearance; + @include clear-anchor(); + } + + & a, button { + display: block; + cursor: pointer; + width: 100%; + height: 100%; + color: $mostly_black; + line-height: 50px; + text-align: center; + text-decoration: none; + background: none; + background-color: transparent; + border: none; + font-size: inherit; + + &:disabled, + &[disabled] { + cursor: pointer; + pointer-events: none; + color: $light_grey; + } + + &:not(:disabled):not([disabled]):hover, + &:not(:disabled):not([disabled]):focus-visible { + outline: none; + color: $dark_grey; + + & .as-icon { + color: $dark_grey; + } + } + } + + &:has(.popover-menu__controls:not(:disabled):not([disabled]):not(:focus-visible):hover) { + @include prefix(box-shadow, 'rgba(0, 0, 0, 0.02) 0px 1px 3px 0px, rgba(27, 31, 35, 0.15) 0px 0px 0px 1px', webkit moz o); + } + + &:has(.popover-menu__controls:not(:disabled):not([disabled]):focus-visible) { + @include apply-focusable-item; + } + + &__popover { + display: block; + visibility: hidden; + position: absolute; + right: 0px; + width: fit-content; + height: 0px; + margin-top: 5px; + opacity: 0; + z-index: 100; + + @include prefix(transition, all 150ms ease-out, webkit moz); + + &:before { + content: ''; + position: absolute; + top: 0; + right: 20px; + width: 8px; + height: 8px; + margin: -4px 0 0 -4px; + background-color: $white; + transform: rotate(45deg); + + @include prefix(transform, rotate(45deg), webkit moz o); + @include prefix(box-shadow, 'rgba(0, 0, 0, 0.02) 0px 1px 3px 0px, rgba(27, 31, 35, 0.35) 0px 0px 0px 1px', webkit moz o); + } + + &-content { + @include flex-col(false, true); + width: max-content; + height: fit-content; + max-height: 25vh; + padding: 0.5rem 1rem; + z-index: 101; + overflow-y: auto; + background-color: $white; + + @include prefix(box-shadow, 'rgba(0, 0, 0, 0.02) 0px 1px 3px 0px, rgba(27, 31, 35, 0.15) 0px 0px 0px 1px', webkit moz o); + } + + & a, button { + @include ignore-user(); + pointer-events: none; + width: fit-content; + + & .as-icon { + margin-right: 1rem; + } + + &:not(:disabled):not([disabled]):active { + @include prefix(transform, scale(0.95), webkit moz o); + } + } + + &[aria-hidden="false"], + &:not([hidden])[aria-hidden=""] { + visibility: visible; + height: fit-content; + opacity: 1; + + & a, button { + pointer-events: auto; + + &:focus-visible { + @include apply-focusable-item; + } + } + } + } + + &__controls { + display: flex !important; + flex-flow: row nowrap !important; + gap: 0.25rem; + align-content: center; + justify-content: flex-end; + + & > span { + pointer-events: none; + } + + &-user { + display: inline-block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100px; + } + } +} + /// Navigation dropdown /// @desc describes the dropdown, used exclusively in the navigation component .nav-dropdown { float: left; overflow: hidden; + &--offset { + right: 25%; + + @include media("<desktop", "screen") { + right: unset; + } + } + &__content { display: none; position: fixed; @@ -17,6 +181,11 @@ z-index: 1; } + &:focus-within, + &:focus-visible { + outline: 1px solid col(accent-anchor); + } + &:hover { .nav-dropdown__content { display: flex; @@ -26,7 +195,7 @@ box-shadow: 0px 10px 70px rgba(0, 0, 0, 0.15); } - @include media("<1250px", "screen") { + @include media("<desktop", "screen") { .nav-dropdown__content { display: none; } @@ -49,7 +218,34 @@ } } - @include media(">=1250px", "screen") { + @include media(">=desktop", "screen") { + &:focus-within, + &:focus-visible { + .nav-dropdown__content { + display: flex; + justify-content: center; + align-items: flex-start; + -webkit-box-shadow: 0px 10px 70px rgba(0, 0, 0, 0.15); + box-shadow: 0px 10px 70px rgba(0, 0, 0, 0.15); + } + + .nav-dropdown__text { + a { + @include prefix(transform, translateY(-2px), webkit moz o); + } + + &::after { + opacity: 1; + } + } + + .nav-dropdown__content--target { + &::after { + opacity: 1; + } + } + } + &__content--target::after { content: ""; top: -10px; @@ -79,7 +275,7 @@ z-index: 999; } - @include media("<1250px", "screen") { + @include media("<desktop", "screen") { .nav-dropdown__content { overflow: hidden; @@ -91,7 +287,7 @@ } /* Define styles for the mobile version */ -@include media("<1250px", "screen") { +@include media("<desktop", "screen") { .nav-dropdown { display: flex; justify-content: center; @@ -178,8 +374,10 @@ .content-container { &__nested { - @include media(">=1250px", "screen") { - &:hover { + @include media(">=desktop", "screen") { + &:hover, + &:focus-within, + & .item-dropdown__text:focus-visible { .nested-menu { display: block; } @@ -208,6 +406,8 @@ width: fit-content; outline: none; border: 0px; + margin: 0; + padding: 0; &__label { cursor: pointer; @@ -215,7 +415,7 @@ vertical-align: middle; display: inline-block; border: 1px solid col(accent-washed); - margin: 0 0.5rem 0 0; + margin: 0 0 0 0; border-radius: 4px; padding: 0.5rem 1rem; background-color: col(bg); @@ -261,7 +461,7 @@ border: 1px solid col(accent-washed); border-radius: 2px; padding: 0; - margin: 2px 0 0 0; + margin: 2px 4px 0 0; box-shadow: 0 0 6px 0 rgba(0,0,0,0.1); background-color: col(bg); list-style-type: none; @@ -425,12 +625,17 @@ } } -// Dropdown group -// @desc Primarily used for detail page +/// Dropdown group +/// @desc Primarily used for detail page .dropdown-group { display: inline-block; position: relative; + max-width: 100%; margin: 0; + text-wrap: wrap; + word-wrap: break-word; + word-break: break-word; + box-sizing: border-box; &__input { display: none; @@ -438,12 +643,14 @@ &__button { cursor: pointer; - display: inline-block; + display: inline-flex; + flex-flow: row wrap; + gap: 0.5em; border: 1px solid col(accent-washed); border-radius: 4px; padding: 0.5rem 2rem 0.5rem 1rem; background-color: col(bg); - white-space: nowrap; + box-sizing: border-box; &:hover { background-color: col(accent-washed); @@ -514,4 +721,250 @@ } } } + + &--detail { + flex: auto; + color: col(text-darkest); + font-weight: bold; + text-align: center; + + & > .dropdown-group__button { + width: 100%; + justify-content: center; + } + + @include media('<phone', 'screen') { + & .as-icon { + display: none; + } + } + } +} + +/// autocomplete search selector +/// @desc Primarily used for organisation management page +/// @example see `organisation/manage.html` +/// +.autocomplete { + position: relative; + padding: 1rem; + + &-container { + display: flex; + flex-wrap: wrap; + gap: 1rem; + } + + &-input { + width: calc(100% - 2rem); + padding: 1rem; + line-height: 1.5; + flex: 1; + min-width: 100px; + } + + &-controls { + display: block; + min-width: 100px; + max-width: 100%; + max-height: 100%; + flex-grow: 1; + } + + &-result { + cursor: default; + padding: 0.5rem 1rem; + background-color: col(bg); + + &:active { + filter: saturate(0.5); + } + + &:hover, + &:focus, + &:focus-within, + &:focus-visible, + &__highlighted, + &[aria-selected="true"] { + outline: 1px solid col(accent-anchor); + color: col(text-brightest); + background-color: col(accent-highlight); + } + } + + &-results { + position: absolute; + width: 100%; + margin: 0; + padding: 0; + left: 0; + top: 100%; + border: 1px solid rgba(0, 0, 0, 0.5); + list-style: none; + transition: none; + max-height: 100px; + overflow-y: auto; + z-index: 999; + background-color: col(bg); + + &.hidden { + display: none !important; + } + } +} + + +/// entity dropdown search selector +/// @desc Primarily used for create page; specifically for selecting related entities +/// @example see `related_entities.html` +/// +.entity-dropdown { + display: flex; + position: relative; + width: 100%; + min-width: 100px; + max-width: 100%; + max-height: 100%; + margin: 0.5rem 0; + box-sizing: border-box; + + &__input { + display: block; + position: relative; + width: 100%; + max-width: 100%; + margin: 0; + padding: 0.5rem 1rem; + line-height: 1.5em; + resize: none; + border: 1px solid col(accent-dark); + border-radius: 2pt; + background-color: col(bg); + box-sizing: border-box; + } + + &__results { + @include flex-col(false, true); + position: absolute; + width: 100%; + height: fit-content; + max-width: 100%; + max-height: 100px; + top: calc(100% - 1px); + left: 0; + margin: 0; + padding: 0; + overflow-y: auto; + overflow-x: hidden; + border: 1px solid col(accent-dark); + border-radius: 2pt; + border-top-left-radius: 0; + border-top-right-radius: 0; + background-color: col(bg); + z-index: 999; + box-sizing: border-box; + } + + &__infobox { + @include flex-col(false, true); + width: 100%; + max-width: 100%; + height: fit-content; + background-color: transparent; + padding: 0.5rem; + box-sizing: border-box; + color: col(text-darker); + border-bottom: 1px dashed col(accent-highlight); + text-wrap: wrap; + word-break: break-all; + word-wrap: break-word; + box-sizing: border-box; + + &:not(.entity-dropdown__infobox--show) { + display: none; + } + + &-details { + @include flex-row(false, false); + width: 100%; + max-width: 100%; + background-color: transparent; + box-sizing: border-box; + } + + &-label { + width: 100%; + max-width: 100%; + text-align: center; + } + + & p, + & a { + margin: 0; + } + } + + &__item { + @include flex-row(false, false); + position: relative; + width: 100%; + max-width: 100%; + padding: 0.5rem; + text-wrap: wrap; + word-wrap: break-word; + word-break: break-all; + text-align: left; + box-sizing: border-box; + border: 0; + outline: 0; + color: col(text-darkest); + background-color: transparent; + box-sizing: border-box; + + &:not(:disabled) { + &:active { + filter: saturate(0.5); + } + + &:hover, + &:focus, + &:focus-within, + &:focus-visible, + &[aria-selected="true"] { + outline: 1px solid col(accent-anchor); + color: col(text-brightest); + background-color: col(accent-highlight); + } + } + + &-label { + pointer-events: none; + display: -webkit-box; + width: max-content; + max-width: 100%; + margin-top: auto; + margin-bottom: auto; + line-clamp: 2; + -webkit-line-clamp: 2; + -moz-box-orient: vertical; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + box-sizing: border-box; + } + } + + &[aria-expanded="true"] { + & .entity-dropdown__results { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } + } + + &[aria-expanded="false"] { + & .entity-dropdown__results { + display: none; + visibility: collapse; + } + } } diff --git a/CodeListLibrary_project/cll/static/scss/components/_inputs.scss b/CodeListLibrary_project/cll/static/scss/components/_inputs.scss index 245cb0ff1..187c0a08f 100644 --- a/CodeListLibrary_project/cll/static/scss/components/_inputs.scss +++ b/CodeListLibrary_project/cll/static/scss/components/_inputs.scss @@ -9,6 +9,516 @@ * */ + /* + Idea: + -> Tagbox with autocomplete (doesnt allow non-defined) + -> Tagbox has button beside it to allow you to create your own + -> Modal with inputs + */ + +/// Data Asset component +/// @desc handles data asset tagbox & creator +/// @example see `assetCreator.js` +.asset-component { + --ac-ol-color: var(--color-clear); + --ac-bd-color: var(--color-accent-dark); + + @include flex-col(0.5rem, true); + max-width: 100%; + padding: 0; + margin: 0; + margin-top: 0.25rem; + margin-bottom: 0.5rem; + box-sizing: border-box; + + &__actions { + @include flex-row(1rem, false); + width: 100%; + max-width: 100%; + padding: 0; + margin: 0; + align-items: center; + justify-content: space-between; + } + + &__create-btn { + @include flex-row(0.5rem, false); + flex: max-content 0; + width: auto; + padding: 0.6rem 1rem !important; + overflow: visible !important; + white-space: collapse !important; + + &:before { + display: block; + content: ''; + position: absolute; + width: 2px; + height: calc(100% - 0.5rem); + top: 0.25rem; + left: calc(-0.5rem - 1px); + opacity: 0.75; + background-color: col(accent-washed); + } + } + + &__field { + @include flex-col(0, true); + + flex: auto 1; + width: auto; + max-width: 100%; + min-height: 1rem; + justify-content: flex-start; + word-wrap: break-word; + border: 1px solid var(--ac-bd-color); + border-radius: 2px; + outline: 1px solid var(--ac-ol-color); + transition: border-color 250ms ease; + box-sizing: border-box; + + &:has(> input:disabled) { + opacity: 0.5; + } + + &:has(> input:focus) { + --ac-bd-color: var(--color-accent-dark); + --ac-ol-color: var(--color-clear); + } + + &:has(> input:focus:valid) { + --ac-bd-color: var(--color-accent-success); + } + + &:has(> input:focus-visible) { + --ac-ol-color: var(--color-accent-anchor); + } + + &:has(> input:invalid) { + --ac-bd-color: var(--color-accent-danger); + } + + &-input { + @include remove-appearance(); + + resize: none; + width: 100%; + height: 100%; + padding: 0.5rem 2rem 0.5rem 0.5rem; + border: none; + font-family: inherit; + box-sizing: border-box; + + &:not(:disabled) + .asset-component__field-btn { + &:focus-visible { + outline: 1px solid col(accent-dark); + border-radius: 2pt; + } + + &:active { + transform: scale(0.9); + transition: transform 0.1s ease-in-out; + } + } + + &:disabled + .asset-component__field-btn:before { + color: col(accent-washed) !important; + } + } + + &-btn { + all: unset; + + cursor: pointer; + display: flex; + position: absolute; + flex-flow: column nowrap; + height: 2rem; + aspect-ratio: 1 / 1; + align-items: center; + justify-content: center; + text-align: center; + top: 0; + right: 0; + padding: 0; + margin: 0; + background-color: transparent; + outline: 0; + border: 0; + + &:before { + @include fontawesome-icon(); + content: '\f055'; + display: block; + margin: auto; + font-size: 1em; + font-weight: bold; + position: absolute; + color: col(accent-secondary); + } + } + + &-suggestions { + @include flex-col(false, true); + + display: none; + position: absolute; + top: 100%; + left: -1px; + width: 100%; + height: auto; + max-height: 200px; + background-color: col(bg); + border: 1px solid col(accent-dark); + border-radius: 2px; + border-top-left-radius: 0px; + border-top-right-radius: 0px; + border-top: 0px; + overflow-y: auto; + z-index: 99; + + &[aria-expanded="true"] { + display: flex; + } + + &-item { + cursor: pointer; + pointer-events: all; + padding: 0.25rem 0.5rem; + margin: 0; + height: auto; + background-color: col(bg); + transition: background-color 250ms ease; + text-align: left; + border: none; + color: col(text-darkest); + text-decoration: none; + + &:visited { + all: unset; + } + + &:hover, + &--highlighted { + background-color: col(accent-highlight); + + .asset-component__suggestions-title { + color: col(text-brightest); + } + } + } + } + } + + &__none-available { + padding: 1rem; + border-radius: 0.1rem; + background-color: col(accent-semi-transparent); + + &-message { + text-align: center; + } + + &:not(.show) { + display: none; + } + } + + &__selection { + @include flex-row(0.5rem, false); + + width: 100%; + max-width: 100%; + height: fit-content; + min-height: 1rem; + max-height: 100px; + justify-items: flex-start; + border: 1px dashed col(accent-washed); + padding: 0.5rem; + word-wrap: break-word; + overflow-x: hidden; + overflow-y: auto; + box-sizing: border-box; + + &:not(.show) { + display: none; + } + + & .tag { + gap: 0.5rem; + width: fit-content; + height: fit-content; + max-width: 140px; + max-height: 50px; + text-align: left; + padding: 0.25rem 1rem; + border-radius: 1rem; + + &:not([data-new="true"]) > [data-role="edit"] { + display: none; + } + + & .as-icon { + margin-right: 0; + font-weight: bold; + color: var(--xcolor); + border: none; + background-color: transparent; + + &[data-role="edit"]:hover { + color: col(accent-bubble); + } + + &[data-role="remove"]:hover { + color: col(accent-danger); + } + } + } + } + + @include media('<tablet', 'screen') { + &__actions { + flex-wrap: wrap-reverse; + } + + &__create-btn { + flex: 100%; + text-align: center; + justify-content: center; + + &:before { + width: calc(100% - 0.5rem); + height: 2px; + top: calc(100% + 0.5rem - 1px); + left: 0.25rem; + } + } + + &__field { + flex: 100%; + } + } + + @include media('<phone', 'screen') { + &__field { + &-input { + padding: 0.5rem; + } + + &-btn { + display: none; + } + } + } +} + +/// Page layout containing UGC variables +/// @desc primarily used for create page; specifically `validation_measures` and `variable_list` +/// @example see `variableCreator.js` +/// +.var-selection-group { + @include flex-col(0.5rem, true); + position: relative; + width: 100%; + max-width: 100%; + height: fit-content; + margin-bottom: 0.5rem; + + &__none-available { + padding: 1rem; + border-radius: 0.1rem; + background-color: col(accent-semi-transparent); + + &-message { + text-align: center; + } + + &:not(.show) { + display: none; + } + } + + &__list { + @include flex-col(0.5rem, true); + position: relative; + width: 100%; + max-width: 100%; + + &:not(.show) { + display: none; + } + + &-header { + display: block; + width: 100%; + max-width: 100%; + border-bottom: 1px solid col(accent-dark); + + h3 { + padding: 0; + } + } + + &-container { + @include flex-col(0.5rem, true); + position: relative; + width: 100%; + max-width: calc(100% - 0.5rem); + max-height: 200px; + padding: 0.5rem; + overflow-y: auto; + overflow-x: none; + } + } + + &__interface { + @include flex-row(0.5rem, false); + height: min-content; + width: 100%; + max-width: 100%; + align-items: center; + justify-content: flex-start; + + @include media('<tablet', 'screen') { + flex-wrap: wrap-reverse; + } + + & button { + font-weight: normal; + padding: 0.5rem 1rem; + margin: 0; + + & > .as-icon { + margin-left: 0.5em; + } + + &:last-child { + margin-left: auto; + } + } + } + + &__item { + @include flex-row(0.5rem, false); + max-width: calc(100% - 0.5rem); + align-items: center; + justify-content: space-between; + padding: 0.5rem 0; + + & p { + margin: 0; + } + + &-text { + flex: 1; + max-width: 100%; + + & p { + text-wrap: wrap; + word-wrap: break-word; + max-width: 100%; + } + + & a { + text-wrap: wrap; + word-wrap: break-word; + max-width: 100%; + } + + @include media("<tablet", "screen") { + flex: 1; + min-width: 100%; + + & p { + width: fit-content; + } + } + } + + &-btn { + @include flex-row(); + @include prefix(transition, all 250ms ease, webkit moz o ms); + cursor: pointer; + pointer-events: auto; + flex-wrap: nowrap; + width: fit-content; + height: fit-content; + padding: 0.5rem 0.5rem; + margin-right: 0.25rem; + vertical-align: middle; + align-items: center; + justify-content: space-evenly; + color: col(text-brightest); + border-radius: 0.25rem; + border: none; + outline: none; + + & > .as-icon { + pointer-events: none; + } + + &[data-action="edit"] { + background-color: col(accent-primary); + } + + &[data-action="remove"] { + background-color: col(accent-danger); + } + + & > span { + font-weight: bold; + pointer-events: none; + } + + &:disabled { + opacity: 0.5; + } + + &:focus-visible { + outline: 1px solid col(accent-dark); + border-radius: 2pt; + } + + &:focus { + outline: none; + border-radius: 0.25rem; + } + + &:hover { + @include prefix(filter, brightness(80%), webkit moz o ms); + } + + &:active { + @include prefix(transform, scale(0.95), webkit moz o ms); + } + + &:not(:last-child) { + margin-left: auto; + } + } + + &:not(:last-child) { + border-bottom: 1px solid col(accent-washed); + } + } + + &__badge { + display: inline-block; + height: max-content; + min-width: 10px; + padding: 3px 7px !important; + font-size: 12px; + font-weight: 700 !important; + line-height: 1; + text-align: center; + white-space: nowrap; + vertical-align: middle; + border-radius: 10px; + color: col(text-darkest); + background-color: col(accent-lightest); + } +} + /// publication-list-group /// @desc Defines the publication component controlled by publicationCreator.js .publication-list-group { @@ -32,28 +542,45 @@ display: flex; flex-flow: row wrap; align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; + + &--right { + justify-content: flex-end; + } input { flex: 1; - margin-right: 1rem; } + input:first-child { flex: 2; - margin-right: 0.25rem; } + button { + margin-bottom: 0.25rem; padding-top: 0.5rem; padding-bottom: 0.5rem; font-weight: normal; - margin-bottom: 0.25rem; } - + + & > input.date-range-picker { + min-width: 10ch; + } + &--references { & > button { margin-top: 2rem; @include media('<tablet', 'screen') { - margin-top: 1rem; + margin-top: 0; + flex: 100%; + } + } + + & input { + @include media('<tablet', 'screen') { + margin-right: 0 !important; } } } @@ -78,6 +605,10 @@ flex-flow: row wrap; align-items: center; + &--right { + justify-content: flex-end; + } + input { flex: 1; margin-right: 0.25rem; @@ -153,15 +684,43 @@ } &-item { - @include flex-row(); + @include flex-row(0.5rem, false); + max-width: calc(100% - 0.5rem); align-items: center; justify-content: space-between; padding: 0.5rem 0; + & p { + margin: 0; + } + + &--is-primary { + display: inline-block; + width: var(--icons-size); + height: var(--icons-size); + margin-right: 1ch; + + &:before { + content: '\f005'; + cursor: inherit; + pointer-events: inherit; + position: absolute; + height: 100%; + aspect-ratio: 1 / 1; + font-family: var(--icons-name); + font-style: var(--icons-style); + font-size: var(--icons-size); + text-align: center; + color: col(accent-tertiary); + } + } + + &-id, &-url { display: flex; vertical-align: middle; - max-width: 60%; + flex: 1; + max-width: 100%; @include media("<desktop", "screen") { max-width: 175px; @@ -174,16 +733,123 @@ @include media("<=phone", "screen") { max-width: 100px; } - - p { + + & p { + text-wrap: wrap; word-wrap: break-word; + max-width: 100%; } - - a { - text-align: right; + + & a { + text-wrap: wrap; + word-wrap: break-word; + max-width: 100%; + + &[data-shrinkcontent="true"] { + display: block; + max-width: 10em; + + &::after { + --shrink-replacement: "Link"; + + display: -webkit-box; + content: attr(href); + max-width: 10em; + line-clamp: 2; + -webkit-line-clamp: 2; + -moz-box-orient: vertical; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + + @include media("<tablet", "screen") { + content: var(--shrink-replacement); + } + } + + &:not([data-shrinkreplace=""])::after { + --shrink-replacement: attr(data-shrinkreplace); + } + } + } + } + + &-primary { + flex: 1; + max-width: 100%; + + &:empty { + flex: 0; + } + } + + &-names { + flex: 1; + max-width: 100%; + + & p { + text-wrap: wrap; + word-wrap: break-word; + max-width: 100%; + } + + & a { + text-wrap: wrap; + word-wrap: break-word; + max-width: 100%; + } + } + + &-bhfdate { + display: block; + flex: 0.5; + min-width: fit-content; + max-width: 100%; + + & > p { + display: block; + width: fit-content; + max-width: calc(100% - 0.5em); + margin: 0; + margin-left: auto; + margin-right: auto; + padding: 0 0.5em; + border-bottom: 5px dotted col(accent-tertiary); + } + + @include media("<tablet", "screen") { + flex: 1; + min-width: 100%; + text-align: left; + + & > p { + width: fit-content; + margin: 0; + } + } + } + + &-id, + &-url, + &-names { + @include media("<tablet", "screen") { + flex: 1; + min-width: 100%; + + & p { + width: fit-content; + } + + & a:not([data-shrinkcontent]) { + width: fit-content; + } } } + &-id { + word-break: break-all; + } + &-btn { @include flex-row(); @include prefix(transition, all 250ms ease, webkit moz o ms); @@ -203,6 +869,10 @@ border: none; outline: none; + @include media("<tablet", "screen") { + margin-left: auto; + } + & > span { font-weight: bold; pointer-events: none; @@ -550,21 +1220,43 @@ &__header { display: flex; - flex-flow: row nowrap; + flex-flow: row wrap; + width: 100%; height: min-content; + max-width: 100%; justify-content: space-between; align-items: flex-end; + & > p { + display: block; + flex: 1; + max-width: 100%; + } + &-actions { display: flex; - flex-flow: row wrap; + flex: 1; + flex-flow: row nowrap; justify-content: flex-end; button { + font-weight: normal; padding-top: 0.5rem; padding-bottom: 0.5rem; - font-weight: normal; margin-bottom: 0.25rem; + margin-left: auto; + } + } + + @include media('<tablet', 'screen') { + & > p { + width: 100%; + flex: unset; + } + + &-actions { + width: 100%; + flex: unset; } } } @@ -686,13 +1378,14 @@ } } -// detailed-input-group -// @desc defines a group of one or more inputs, and allows the addition of other -// elements, e.g. a title/description/mandatory status, to communicate its -// use to the user +/// detailed-input-group +/// @desc defines a group of one or more inputs, and allows the addition of other +/// elements, e.g. a title/description/mandatory status, to communicate its +/// use to the user .detailed-input-group { - @include flex-col(); + @include flex-col(false, true); + max-width: 100%; font-family: inherit; margin: 1rem 0 0 0; @@ -713,9 +1406,14 @@ float: right; color: col(text-danger); font-weight: bold; + + &.hidden { + display: none; + } } &__title { + max-width: 100%; padding: 0; margin: 0 0 0.5rem 0; font-size: 18px; @@ -729,8 +1427,11 @@ } &__description { + font-family: inherit; + max-width: 100%; margin: 0 0 0.5rem 0; font-size: 14px; + text-wrap: wrap; } &__error { @@ -741,8 +1442,9 @@ &__header { @include flex-row(); - justify-content: space-between; + max-width: 100%; align-items: center; + justify-content: space-between; &--nowrap { flex-wrap: nowrap; @@ -751,10 +1453,12 @@ &__header-item { @include flex-col(); + max-width: 100%; } &__none-available { display: block; + max-width: calc(100% - 1rem); padding: 1rem; border-radius: 0.1rem; background-color: col(accent-semi-transparent); @@ -770,8 +1474,8 @@ } } -// date-range-field -// @desc describes a date range field, where each input describes either the start or end of the date +/// date-range-field +/// @desc describes a date range field, where each input describes either the start or end of the date .date-range-field { display: flex; flex-flow: row nowrap; @@ -780,14 +1484,35 @@ border: 0; width: 100%; + &--padding0_5 { + padding: 0 0.5rem; + max-width: calc(100% - 1rem); + } + &--wrapped { flex-wrap: wrap; max-width: 100%; - } - &--padding0_5 { - padding: 0 0.5rem; - max-width: calc(100% - 0.5rem); + @include media('<tablet', 'screen') { + display: grid; + grid: 1fr 1fr / 1fr 1fr; + gap: 0.5rem 0.25rem; + justify-items: safe; + + & > .date-range-field__label { + margin-left: 0 !important; + margin-right: 0 !important; + } + } + + @include media('<phone', 'screen') { + @include flex-row(0, false); + padding: 0; + + & > input { + max-width: calc(100% - 1rem); + } + } } &__label { @@ -800,8 +1525,8 @@ } } -// radio-chips-group -// @desc describes a group of radio chips +/// radio-chips-group +/// @desc describes a group of radio chips .radio-chips-group { @include flex-row($gap: 0.5rem); @@ -810,8 +1535,8 @@ list-style-type: none; } -// hstack-checkbox-group -// @desc defines a horizontal stack of checkboxes +/// hstack-checkbox-group +/// @desc defines a horizontal stack of checkboxes .hstack-checkbox-group { @include flex-row(); @include remove-appearance(); @@ -836,8 +1561,195 @@ } } -// hstack-radio-group -// @desc defines a horizontal stack of radio inputs +// single-slider +// @desc defines styles for a single numeric slider +.age-group { + @include flex-col(0.5rem, true); + @include remove-appearance(); + + border: none; + position: relative; + + & .age-group__container:has(.single-slider) { + @include flex-row(0.5rem, true); + position: relative; + + @include media('<desktop', 'screen') { + flex-wrap: wrap; + + & .single-slider .number-input { + margin-left: auto; + } + } + } + + & .age-group__container:has(.double-slider) { + @include flex-col(0.5rem, true); + position: relative; + } +} + +// single-slider +// @desc defines styles for a single numeric slider +.single-slider { + @include flex-row(0.5rem, true); + @include remove-appearance(); + + border: none; + position: relative; + align-items: center; + justify-content: space-between; + + &__input { + position: relative; + width: 100%; + height: 10px; + + &__progress { + position: absolute; + width: 100%; + height: 10px; + margin: auto; + top: 2.5px; + border-radius: 0.5rem; + background: linear-gradient( + to right, + var(--color-accent-semi-transparent) 0%, + var(--color-accent-primary) 0%, + var(--color-accent-primary) 100%, + var(--color-accent-semi-transparent) 100% + ); + } + + & > input[type="range"] { + position: absolute; + pointer-events: none; + appearance: none; + background-color: transparent; + width: 100%; + height: 10px; + top: 0; + + &::-ms-thumb { + cursor: pointer; + pointer-events: auto; + appearance: none; + background-color: var(--color-accent-secondary); + width: 16px; + height: 16px; + } + + &::-webkit-slider-thumb { + cursor: pointer; + pointer-events: auto; + appearance: none; + background-color: var(--color-accent-secondary); + width: 16px; + height: 16px; + } + + &::-moz-range-thumb { + cursor: pointer; + pointer-events: auto; + appearance: none; + background-color: var(--color-accent-secondary); + width: 16px; + height: 16px; + } + } + } + + @include media('<desktop', 'screen') { + flex-wrap: wrap; + + & .number-input { + margin-left: auto; + } + } +} + +// double-slider +// @desc desfines styles for double range slider +.double-slider { + @include flex-row(); + @include remove-appearance(); + + border: none; + position: relative; + align-items: center; + + &__group { + @include flex-row(); + width: 100%; + margin-top: 25px; + justify-content: space-between; + + &__progress { + + } + } + + &__input { + position: relative; + width: 100%; + + &__progress { + position: absolute; + width: 100%; + height: 10px; + margin: auto; + top: 2.5px; + border-radius: 0.5rem; + background: linear-gradient( + to right, + var(--color-accent-semi-transparent) 0%, + var(--color-accent-primary) 0%, + var(--color-accent-primary) 100%, + var(--color-accent-semi-transparent) 100% + ); + } + + &>input[type=range] { + position: absolute; + pointer-events: none; + appearance: none; + background-color: transparent; + width: 100%; + height: 10px; + top: 0; + + &::-ms-thumb { + cursor: pointer; + pointer-events: auto; + appearance: none; + background-color: var(--color-accent-secondary); + width: 16px; + height: 16px; + } + + &::-webkit-slider-thumb { + cursor: pointer; + pointer-events: auto; + appearance: none; + background-color: var(--color-accent-secondary); + width: 16px; + height: 16px; + } + + &::-moz-range-thumb { + cursor: pointer; + pointer-events: auto; + appearance: none; + background-color: var(--color-accent-secondary); + width: 16px; + height: 16px; + } + } + } +} + +/// hstack-radio-group +/// @desc defines a horizontal stack of radio inputs .hstack-radio-group { @include flex-row(); @include remove-appearance(); @@ -858,10 +1770,18 @@ width: 100%; } } + + &--grid { + display: grid; + gap: 0.25rem 0.5rem; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + align-items: flex-start; + justify-items: flex-start; + } } -// checkbox-item-container -// @desc defines a horizontal group of checkbox items +/// checkbox-item-container +/// @desc defines a horizontal group of checkbox items .checkbox-item-container { display: flex; flex-flow: row nowrap; @@ -873,14 +1793,29 @@ max-width: fit-content; margin-right: 0.5rem; } + + &.min-gap { + gap: 0.5rem; + max-width: fit-content; + margin-bottom: 0; + + & > label { + margin: 0 !important; + } + } &.ignore-overflow { overflow: visible; } + + &:has(input:focus), + &:has(input:focus-visible) { + outline: 1px solid col(accent-anchor); + } } -// search-options -// @desc defines a set of search options, example in create/concept.html +/// search-options +/// @desc defines a set of search options, example in create/concept.html .search-options { @include flex-row(); position: relative; @@ -1007,8 +1942,8 @@ } } -// search-container -// @desc defines a search input field +/// search-container +/// @desc defines a search input field .search-container { @include flex-col(); position: relative; @@ -1018,150 +1953,516 @@ width: 100%; height: fit-content; - &__field { - @include box-shadow(0px, 8px, 15px, var(--search-shadow-inactive-color)); - box-sizing: border-box; - position: relative; - border: 0; - margin: 0; - width: 100%; - padding: 0.5rem 40px 0.5rem 1rem; - border-radius: 0.2rem; - transition: all 250ms ease; + &__field { + @include box-shadow(0px, 8px, 15px, var(--search-shadow-inactive-color)); + box-sizing: border-box; + position: relative; + border: 0; + margin: 0; + width: 100%; + padding: 0.5rem 40px 0.5rem 1rem; + border-radius: 0.2rem; + transition: all 250ms ease; + + &:focus { + @include box-shadow(0px, 10px, 20px, var(--search-shadow-active-color)); + outline: none; + border-radius: 0.2rem; + } + + &:focus-visible { + @include box-shadow(0px, 10px, 20px, var(--search-shadow-active-color)); + outline: none; + border-radius: 0.2rem; + } + + &:disabled { + opacity: 0.5; + } + + /* Outline */ + &.primary-outline { + border-width: 1px; + border-style: solid; + border-color: col(accent-primary); + } + &.secondary-outline { + border-width: 1px; + border-style: solid; + border-color: col(accent-secondary); + } + &.tertiary-outline { + border-width: 1px; + border-style: solid; + border-color: col(accent-tertiary); + } + &.washed-outline { + border-width: 1px; + border-style: solid; + border-color: col(accent-washed); + } + &.bubble-outline { + border-width: 1px; + border-style: solid; + border-color: col(accent-bubble); + } + &.dark-outline { + border-width: 1px; + border-style: solid; + border-color: col(accent-dark); + } + &.bright-outline { + border-width: 1px; + border-style: solid; + border-color: col(accent-bright); + } + } + + &__icon { + all: unset; + cursor: pointer; + display: block; + position: absolute; + right: 10px; + top: 27.25%; + width: 20px; + height: 20px; + + &:after { + @include fontawesome-icon(); + content: '\f002'; + display: block; + top: 0; + width: 100%; + height: 100%; + position: absolute; + color: var(--color-accent-primary); + } + + &:focus-visible { + outline: 1px solid col(accent-dark); + border-radius: 2pt; + } + } + + &__icon-landing { + all: unset; + cursor: pointer; + display: block; + position: absolute; + right: 10px; + top: 27.25%; + width: 20px; + height: 20px; + background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 56.966 56.966' fill='%23687FCB'%3e%3cpath d='M55.146 51.887L41.588 37.786A22.926 22.926 0 0046.984 23c0-12.682-10.318-23-23-23s-23 10.318-23 23 10.318 23 23 23c4.761 0 9.298-1.436 13.177-4.162l13.661 14.208c.571.593 1.339.92 2.162.92.779 0 1.518-.297 2.079-.837a3.004 3.004 0 00.083-4.242zM23.984 6c9.374 0 17 7.626 17 17s-7.626 17-17 17-17-7.626-17-17 7.626-17 17-17z'/%3e%3c/svg%3e"); + background-size: 20px; + background-repeat: no-repeat; + background-position: 50%, 50%; + + &:focus-visible { + outline: 1px solid col(accent-dark); + border-radius: 2pt; + } + } +} + +/// date-range-picker +/// @desc defines a date range picker for use with lightpick.js +.date-range-picker { + @include remove-appearance(); + + outline: none; + width: 100%; + font-family: inherit; + margin-top: 0.25rem; + margin-bottom: 0.5rem; + resize: none; + border: 1px solid col(accent-dark); + border-radius: 2px; + padding: 0.5rem; + transition: border-color 250ms ease; + + &:focus { + outline: none; + border-color: col(accent-washed); + } + + &:disabled { + opacity: 0.5; + } +} + +/// selection panel +/// @desc defines a panel for the variable selection modal +/// @example see create frontend, _e.g._ `variableCreator.js` +.var-selection { + @include flex-col(false, true); + height: fit-content; + max-width: 100%; + + &__none-available { + padding: 1rem; + border-radius: 0.1rem; + background-color: col(accent-semi-transparent); + + &-message { + text-align: center; + } + + &:not(.show) { + display: none; + } + } + + &__header { + @include flex-row(0.5rem, false); + height: min-content; + width: 100%; + max-width: 100%; + align-items: center; + justify-content: flex-end; + + &-group { + @include flex-col(0.5rem, true); + height: fit-content; + max-width: 100%; + align-items: flex-end; + } + + &-label { + display: inline-block; + width: fit-content; + max-width: 100%; + margin: 0; + font-weight: bold; + font-family: inherit; + line-height: 1em; + text-wrap: wrap; + color: col(text-darkest); + } + } + + &__content { + @include flex-col(0.5rem, true); + height: fit-content; + max-width: 100%; + + &:not(.show) { + display: none; + } + } +} + +/// an input group +/// @desc defines a fielset +/// @example see create frontend, _e.g._ `variableCreator.js` +.input-field-container { + @include flex-col(0.5rem, true); + height: fit-content; + max-width: 100%; + + &--fill-w { + width: 100% !important; + } + + &__grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); + grid-gap: 0; + height: fit-content; + max-width: 100%; + } + + &__row { + @include flex-row(0.5rem, false); + height: fit-content; + width: 100%; + max-width: 100%; + } + + &__label { + display: inline-block; + width: fit-content; + margin: 0; + font-family: inherit; + line-height: 1.2em; + letter-spacing: 2px; + text-wrap: wrap; + word-wrap: break-word; + font-size: 1.2em; + text-transform: uppercase; + color: col(text-darkest); + } + + &__desc { + display: inline-block; + width: fit-content; + max-width: 100%; + margin: 0; + color: col(text-darker); + font-family: inherit; + line-height: 1em; + text-wrap: wrap; + word-wrap: break-word; + } + + &__mandatory { + float: right; + color: col(text-danger); + font-weight: bold; + font-size: 1.5em; + + &.hidden { + display: none; + } + } +} + +/// number input types +/// @desc defines a set of number inputs +/// @example see create frontend, _e.g._ `variableCreator.js` +.number-input { + --num-input-height: 1.5rem; + --num-input-icon-sz: 1rem; + + &__group { + @include flex-row(false, true); + width: fit-content; + height: fit-content; + max-width: 100%; + max-height: 100%; + + &:has(.number-input__group-input:invalid) { + outline: 1px solid col(accent-danger) !important; + } + + &-action { + appearance: none; + -webkit-appearance: none; + + cursor: pointer; + position: relative; + margin: 0; + padding: 0; + color: col(text-darkest); + border: 1px solid col(accent-dark); + border-radius: none; + background-color: col(bg); + height: 100%; + width: 100%; + + &:after { + content: '\f057'; + position: absolute; + width: var(--num-input-icon-sz); + height: var(--num-input-icon-sz); + top: calc(50% - var(--num-input-icon-sz)*0.5); + left: calc(50% - var(--num-input-icon-sz)*0.5); + font-family: var(--icons-name); + font-style: var(--icons-style); + font-size: var(--num-input-icon-sz); + color: col(accent-dark); + text-align: center; + transition: all 0.1s ease-out; + transform: scale(1); + -webkit-transform: scale(1); + } + + &:not(:disabled) { + &:active, + &:focus { + outline: none; + } + + &:not(:active):focus-visible { + outline: 1px solid col(accent-highlight); + z-index: 2; + } + + &:hover { + border: 1px solid col(accent-dark); + } + + &:active:after { + transform: scale(0.8); + -webkit-transform: scale(0.8); + } + } + + &:disabled { + pointer-events: none; + opacity: 0.25; + } - &:focus { - @include box-shadow(0px, 10px, 20px, var(--search-shadow-active-color)); - outline: none; - border-radius: 0.2rem; - } + &[data-op="decrement"]:after { + content: '\f068'; + } - &:focus-visible { - @include box-shadow(0px, 10px, 20px, var(--search-shadow-active-color)); - outline: none; - border-radius: 0.2rem; + &[data-op="increment"]:after { + content: '\2b'; + } } - &:disabled { - opacity: 0.5; + &-inner { + @include flex-row(false, false); + width: fit-content; } - /* Outline */ - &.primary-outline { - border-width: 1px; - border-style: solid; - border-color: col(accent-primary); - } - &.secondary-outline { - border-width: 1px; - border-style: solid; - border-color: col(accent-secondary); - } - &.tertiary-outline { - border-width: 1px; - border-style: solid; - border-color: col(accent-tertiary); - } - &.washed-outline { - border-width: 1px; - border-style: solid; - border-color: col(accent-washed); - } - &.bubble-outline { - border-width: 1px; - border-style: solid; - border-color: col(accent-bubble); - } - &.dark-outline { - border-width: 1px; - border-style: solid; - border-color: col(accent-dark); - } - &.bright-outline { - border-width: 1px; - border-style: solid; - border-color: col(accent-bright); + &-spin { + display: grid; + grid-template-columns: 1fr; + grid-template-rows: 1fr 1fr; + width: var(--num-input-height); } - } - &__icon { - all: unset; - cursor: pointer; - display: block; - position: absolute; - right: 10px; - top: 27.25%; - width: 20px; - height: 20px; + &-input[type="number"] { + position: relative; + appearance: none; + -moz-appearance: none; + -webkit-appearance: none; + -moz-appearance: textfield; - &:after { - @include fontawesome-icon(); - content: '\f002'; - display: block; - top: 0; + flex: 1; width: 100%; - height: 100%; - position: absolute; - color: var(--color-accent-primary); - } + max-width: calc(10ch + 1rem); + height: var(--num-input-height); + padding: 0.5rem 1ch; + line-height: 1rem; + text-align: center; + transition: all 0.2s ease-out; + border: 1px solid col(accent-dark); + border-right: 1px solid rgba(0, 0, 0, 0); + border-radius: none; + background-color: col(bg); + -webkit-border-radius: 0px; + + &:not(:disabled) { + &:focus { + outline: none; + border: 1px solid col(accent-dark); + } + + &:focus-visible { + outline: 1px solid col(accent-highlight); + z-index: 2; + } + + &:hover { + outline: none; + border: 1px solid col(accent-washed); + } + } - &:focus-visible { - outline: 1px solid col(accent-dark); - border-radius: 2pt; - } - } + &:disabled { + pointer-events: none; + opacity: 0.25; + } - &__icon-landing { - all: unset; - cursor: pointer; - display: block; - position: absolute; - right: 10px; - top: 27.25%; - width: 20px; - height: 20px; - background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 56.966 56.966' fill='%23687FCB'%3e%3cpath d='M55.146 51.887L41.588 37.786A22.926 22.926 0 0046.984 23c0-12.682-10.318-23-23-23s-23 10.318-23 23 10.318 23 23 23c4.761 0 9.298-1.436 13.177-4.162l13.661 14.208c.571.593 1.339.92 2.162.92.779 0 1.518-.297 2.079-.837a3.004 3.004 0 00.083-4.242zM23.984 6c9.374 0 17 7.626 17 17s-7.626 17-17 17-17-7.626-17-17 7.626-17 17-17z'/%3e%3c/svg%3e"); - background-size: 20px; - background-repeat: no-repeat; - background-position: 50%, 50%; + &::-webkit-inner-spin-button, + &::-webkit-outer-spin-button { + margin: 0; + -webkit-appearance: none; + } - &:focus-visible { - outline: 1px solid col(accent-dark); - border-radius: 2pt; + & ~ .as-icon { + display: none; + } + + &[data-type="percentage"] { + max-width: calc(15ch + 1rem); + padding-right: calc(2ch + var(--icons-size)); + + & ~ .as-icon { + display: block; + position: absolute; + right: 1ch; + top: 25%; + } + + &:focus-visible ~ .as-icon { + z-index: 2; + } + } + + &[data-type="percentage"] ~ .as-icon:before { + content: '\25'; + } } } } -// date-range-picker -// @desc defines a date range picker for use with lightpick.js -.date-range-picker { - @include remove-appearance(); - outline: none; +.validation-block { + --col: var(--color-accent-warning); + + display: block; width: 100%; - font-family: inherit; - margin-top: 0.25rem; - margin-bottom: 0.5rem; - resize: none; - border: 1px solid col(accent-dark); - border-radius: 2px; - padding: 0.5rem; - transition: border-color 250ms ease; + max-width: 100%; + box-sizing: border-box; - &:focus { - outline: none; - border-color: col(accent-washed); + &__container { + display: block; + width: 100%; + max-width: 100%; + padding: 0.5rem 1rem; + margin-bottom: 1rem; + border-left: 0.25em solid transparent; + border-left-color: var(--col); + color: $mostly_black; + box-sizing: border-box; } - &:disabled { - opacity: 0.5; + &__title { + @include flex-row(0.5rem, false); + width: 100%; + max-width: 100%; + align-items: center; + justify-content: flex-start; + line-height: 1; + color: var(--col); + font-weight: bold; + } + + &__message { + width: 100%; + max-width: 100%; + margin-top: 0; + margin-bottom: 0; + text-wrap: wrap; + } + + ul { + padding-left: 0.95rem; + } + + &--error { + --col: var(--color-accent-danger); + + & ~ .validation-block:not(.validation-block--error) { + display: none; + } + } + + &--warning:not(:has(~ input:focus-visible)):not(:has(~ * input:focus-visible)) .validation-block__container { + display: none; + } + + &--warning { + & .validation-block__container { + position: absolute; + left: 0; + bottom: 0; + background-color: rgba(255, 255, 255, 0.95); + margin-bottom: 0; + } } } -// text inputs -// @desc defines a set of text inputs -.code-text-input, +/// text input types +/// @desc defines a set of text inputs .text-input, -.text-area-input { +.text-area-input, +.code-text-input { @include remove-appearance(); font-family: inherit; @@ -1175,6 +2476,10 @@ max-width: calc(100% - 1rem); transition: border-color 250ms ease; + &--bbox-size { + box-sizing: border-box; + } + &:focus { outline: none; border-color: col(accent-dark); @@ -1183,10 +2488,22 @@ &:disabled { opacity: 0.5; } + + &:focus-visible { + outline: 1px solid col(accent-anchor); + } + + &:focus-visible:valid { + border-color: col(accent-success); + } + + &:invalid { + border-color: col(accent-danger); + } } -// code inputs -// @desc defines a set of input elements for code search +/// code inputs +/// @desc defines a set of input elements for code search .code-search-group { @include flex-col(); position: relative; @@ -1237,8 +2554,8 @@ } } -// text-area-input -// @desc defines an area for text input +/// text-area-input +/// @desc defines an area for text input .text-area-input { resize: vertical; @@ -1256,8 +2573,8 @@ } } -// selection inputs -// @desc defines a group of selection inputs for radio buttons, checkboxes, dropdown etc +/// selection inputs +/// @desc defines a group of selection inputs for radio buttons, checkboxes, dropdown etc .selection-input { font-family: inherit; margin-top: 0.25rem; @@ -1381,11 +2698,10 @@ padding: 0; border: 1px solid var(--border-color, var(--border-accent)); background: var(--bg-color, var(--background-accent)); - box-sizing: inherit; + box-sizing: border-box; &:focus-visible + label { - outline: 1px solid col(accent-dark); - border-radius: 2pt; + outline: none; } &:after { @@ -1399,9 +2715,9 @@ border: 2px solid var(--icon-accent); border-top: 0; border-left: 0; - left: 6px; - top: 2px; - box-sizing: inherit; + left: calc(50% - 5px*0.5); + top: calc(50% - 9px*0.5); + box-sizing: border-box; } &:before { @@ -1518,8 +2834,8 @@ } } -// switch inputs -// @desc defines a switch, similar in function to a radiobutton, to describe a binary state +/// switch inputs +/// @desc defines a switch, similar in function to a radiobutton, to describe a binary state .switch-input { display: none; @@ -1688,4 +3004,102 @@ /* Loading style */ .eletree_icon-loading1:before { content: '\f110'; -} \ No newline at end of file +} + +.item-display { + @include flex-col(0.5rem, true); + position: relative; + width: 100%; + max-width: 100%; + height: fit-content; + margin-bottom: 0.5rem; + + &__none { + padding: 1rem; + border-radius: 0.1rem; + background-color: col(accent-semi-transparent); + + &-label { + text-align: center; + } + } + + &__list { + @include flex-col(0.5rem, true); + position: relative; + width: 100%; + max-width: calc(100% - 0.5rem); + max-height: 200px; + padding: 0.5rem; + overflow-y: auto; + overflow-x: none; + } + + &__item { + @include flex-row(0.5rem, false); + max-width: calc(100% - 0.5rem); + align-items: center; + justify-content: space-between; + padding: 0.5rem 0; + border-bottom: 1px dashed col(accent-washed); + + & p { + margin: 0; + } + } + + &__text { + flex: 1; + max-width: 100%; + + & p { + text-wrap: wrap; + word-wrap: break-word; + max-width: 100%; + } + + & a { + text-wrap: wrap; + word-wrap: break-word; + max-width: 100%; + } + + @include media("<tablet", "screen") { + flex: 1; + min-width: 100%; + + & p { + width: fit-content; + } + } + } + + &__value { + display: inline-block; + flex: 0.5; + height: max-content; + min-width: 10px; + max-width: fit-content; + padding: 0.25rem 0.5rem; + font-size: 0.8em; + font-weight: 700 !important; + line-height: 1; + white-space: nowrap; + vertical-align: middle; + border-radius: 2pt; + color: col(text-darkest); + background-color: col(accent-lightest); + text-wrap: wrap; + word-wrap: break-word; + + @include media('<tablet', 'screen') { + flex: 1; + } + } + + &__desc-indent { + margin-left: 2em; + margin-right: 2em; + max-width: calc(100% - 4em); + } +} diff --git a/CodeListLibrary_project/cll/static/scss/components/_markdown.scss b/CodeListLibrary_project/cll/static/scss/components/_markdown.scss index d149f2c45..014ad37e3 100644 --- a/CodeListLibrary_project/cll/static/scss/components/_markdown.scss +++ b/CodeListLibrary_project/cll/static/scss/components/_markdown.scss @@ -33,7 +33,7 @@ /// Markdown render container & associated style(s) /// @desc Used to contain `django-markdownify` results, e.g. in the case of the detail page .markdown-render-container { - @include flex-col(); + @include flex-col(false, true); position: relative; overflow: hidden; @@ -42,6 +42,10 @@ max-width: calc(100% - 1rem); overflow: auto; + code { + text-wrap: wrap; + } + img { max-width: 100%; } @@ -51,14 +55,15 @@ min-width: min-content; width: 100%; height: fit-content; - padding: 0.5rem; max-width: calc(100% - 1rem); border-collapse: collapse; border-spacing: 0px; box-sizing: border-box; - border: 1px solid col(accent-washed); + border: 2px solid col(accent-washed); background-color: transparent; - margin-bottom: 0.25rem; + margin: 0.5rem; + border-collapse: collapse; + border-spacing: 0; } th, diff --git a/CodeListLibrary_project/cll/static/scss/components/_misc.scss b/CodeListLibrary_project/cll/static/scss/components/_misc.scss index 844dd67bb..b445db426 100644 --- a/CodeListLibrary_project/cll/static/scss/components/_misc.scss +++ b/CodeListLibrary_project/cll/static/scss/components/_misc.scss @@ -3,22 +3,11 @@ @import '../_media'; @import '../_utils'; -@keyframes loading-rotation { - from { - transform: rotate(0deg) - } - to { - transform: rotate(360deg) - } -} - -@-webkit-keyframes loading-rotation { - from { - -webkit-transform: rotate(0deg) - } - to { - -webkit-transform: rotate(360deg) - } +/// Loading spinner +/// @desc instantiates a `LOADING` element above the content contained by its parent +/// +*:has(> .loading-spinner) { + min-height: 100px !important; } .loading-spinner { @@ -29,6 +18,11 @@ background-color: rgba(0, 0, 0, 0.5); z-index: 9999999999; + &--absolute { + position: absolute; + z-index: 99; + } + &__icon { display: block; position: absolute; @@ -50,12 +44,30 @@ display: block; position: absolute; top: calc(50% + 25px); - right: calc(50% - 3.5ch - 11px); + right: calc(50% - 3em); letter-spacing: 3px; color: var(--loading-spinner-text-color); } } +@keyframes loading-rotation { + from { + transform: rotate(0deg) + } + to { + transform: rotate(360deg) + } +} + +@-webkit-keyframes loading-rotation { + from { + -webkit-transform: rotate(0deg) + } + to { + -webkit-transform: rotate(360deg) + } +} + // Ontology node reference /// @desc used to define a the reference ontology node item /// within the reference data page @@ -80,4 +92,140 @@ text-align: right; } } -} \ No newline at end of file +} + +/// Icon span +/// @desc displays the icon specified by a `<span/>`'s `data-icon` attribute +/// @example see `dashboard/index.html` +.as-icon { + display: inline-block; + text-align: center; + vertical-align: middle; + + &:before { + content: attr(data-icon); + font-size: var(--icons-size); + font-style: var(--icons-style); + font-family: var(--icons-name); + color: inherit; + } + + &--black { + color: $mostly_black; + } + + &--white { + color: $white; + } + + &--primary { + color: col(accent-primary); + } + + &--secondary { + color: col(accent-secondary); + } + + &--tertiary { + color: col(accent-tertiary); + } + + &--washed { + color: col(accent-washed); + } + + &--bubble { + color: col(accent-bubble); + } + + &--highlight { + color: col(accent-highlight); + } + + &--success { + color: col(accent-success); + } + + &--danger { + color: col(accent-danger); + } + + &--warning { + color: col(accent-warning); + } +} + +.bounce-loader { + display: none; + position: relative; + width: 100%; + margin: 0; + padding: 0.5rem; + text-align: center; + background-color: transparent; + box-sizing: border-box; + + &--show { + display: block; + } + + &__container { + display: block; + position: relative; + width: 100px; + margin: auto; + text-align: center; + background-color: transparent; + box-sizing: border-box; + } + + &__dot { + display: inline-block; + position: relative; + width: 1rem; + height: 1rem; + background-color: col(accent-bubble); + border-radius: 100%; + animation-fill-mode: both; + -webkit-animation-fill-mode: both; + animation: bouncing-anim 1.4s infinite ease-in-out; + -webkit-animation: bouncing-anim 1.4s infinite ease-in-out; + box-sizing: border-box; + + &:nth-child(2) { + animation-delay: 0.16s; + -webkit-animation-delay: 0.16s; + } + + &:nth-child(3) { + animation-delay: 0.32s; + -webkit-animation-delay: 0.32s; + } + } +} + +@keyframes bouncing-anim { + 0%, + 80%, + 100% { + transform: scale(0.0); + -webkit-transform: scale(0.0); + } + + 40% { + transform: scale(1.0); + -webkit-transform: scale(1.0); + } +} + +@-webkit-keyframes bouncing-anim { + 0%, + 80%, + 100% { + -webkit-transform: scale(0.0) + } + + 40% { + -webkit-transform: scale(1.0) + } +} diff --git a/CodeListLibrary_project/cll/static/scss/components/_navigation.scss b/CodeListLibrary_project/cll/static/scss/components/_navigation.scss index 536c0411a..2888d8782 100644 --- a/CodeListLibrary_project/cll/static/scss/components/_navigation.scss +++ b/CodeListLibrary_project/cll/static/scss/components/_navigation.scss @@ -126,6 +126,14 @@ @include prefix(transform, translateY(-2px), webkit moz o); } + &:focus-within, + &:focus-visible { + outline: 1px solid col(accent-anchor); + background-color: transparent; + background-size: 25% var(--navigation-line-width), auto; + @include prefix(transform, translateY(-2px), webkit moz o); + } + &:active { outline: unset; background-color: transparent; @@ -164,6 +172,10 @@ } } } + + .content-container .userBrand { + cursor: pointer; + } } /// About row @@ -272,7 +284,7 @@ font-weight: 400; } - @include media("<1250px", "screen") { + @include media("<=desktop", "screen") { &__icon { display: none; } @@ -293,7 +305,7 @@ } } -@include media("<1250px", "screen") { +@include media("<desktop", "screen") { .about-row { display: block; background-color:var(--phenotype-banner-bg-color); @@ -352,7 +364,7 @@ } } -@include media("<1250px", "screen") { +@include media("<desktop", "screen") { .avatar-content { display: inline-flex; align-items: center; @@ -465,6 +477,7 @@ } .item-dropdown button.item-dropdown__submit:hover { + outline: 1px solid col(accent-anchor) !important; transform: translateY(0px) !important; } } @@ -506,9 +519,15 @@ $navigation_transparent_gradient: 90deg col(bg) 0%, col(bg) 100%; background-position: center bottom; background-size: 0 var(--navigation-line-width), auto; - &:not(.active):not([role="login"]):not([role="profile"]):hover { - background-size: 25% var(--navigation-line-width), auto; - @include prefix(transform, translateY(-2px), webkit moz o); + &:not(.active):not([role="login"]):not([role="profile"]) { + &:hover { + background-size: 25% var(--navigation-line-width), auto; + @include prefix(transform, translateY(-2px), webkit moz o); + } + + &:focus-visible { + outline: 1px solid col(accent-anchor) !important; + } } &.active { @@ -528,7 +547,9 @@ $navigation_transparent_gradient: 90deg col(bg) 0%, col(bg) 100%; } &[role="login"], - &[role="profile"] { + &[role="profile"], + &:focus-visible:has([role="login"]), + &:focus-visible:has([role="profile"]) { @include prefix(transition, $navigation_login_transition, webkit moz o); background-color: rgba(255, 255, 255, 0); border-radius: 0.25rem; @@ -536,6 +557,7 @@ $navigation_transparent_gradient: 90deg col(bg) 0%, col(bg) 100%; &:hover { background-color: var(--navigation-item-selector); } + &:active { @include prefix(transition, scale(0.95), webkit moz o); } @@ -559,6 +581,12 @@ $navigation_transparent_gradient: 90deg col(bg) 0%, col(bg) 100%; margin: 0; z-index: 99998; + & .search-navigation__search { + @media screen and (min-width: 1024px) and (max-width: 1250px) { + display: none; + } + } + &.scrolled { box-shadow: var(--navigation-scroll-shadow); } @@ -602,7 +630,7 @@ $navigation_transparent_gradient: 90deg col(bg) 0%, col(bg) 100%; } } - @include media("<1250px", "screen") { + @include media("<desktop", "screen") { &__container { margin: var(--navigation-mobile-offset) var(--navigation-mobile-gutter); width: calc( @@ -621,9 +649,14 @@ $navigation_transparent_gradient: 90deg col(bg) 0%, col(bg) 100%; display: flex; height: auto; width: auto; + visibility: hidden; z-index: 1; - @include media("<1250px", "screen") { + @include media("<desktop", "screen") { + & { + visibility: visible; + } + &:not(.open):after { --as-burger: "true"; @@ -645,7 +678,7 @@ $navigation_transparent_gradient: 90deg col(bg) 0%, col(bg) 100%; } } - @include media(">=1250px", "screen") { + @include media(">=desktop", "screen") { &__items { @include flex-row($gap: 1rem); height: var(--navigation-height); @@ -665,7 +698,7 @@ $navigation_transparent_gradient: 90deg col(bg) 0%, col(bg) 100%; } } - @include media("<1250px", "screen") { + @include media("<desktop", "screen") { &__items { @include flex-col(); @@ -697,7 +730,7 @@ $navigation_transparent_gradient: 90deg col(bg) 0%, col(bg) 100%; right: 1rem; visibility: visible; - @include media("<=1023px", "screen") { + @include media("<desktop", "screen") { top: -2rem; max-width: 100%; } diff --git a/CodeListLibrary_project/cll/static/scss/components/_scrollbars.scss b/CodeListLibrary_project/cll/static/scss/components/_scrollbars.scss index 3cf9553cf..5d7e893cd 100644 --- a/CodeListLibrary_project/cll/static/scss/components/_scrollbars.scss +++ b/CodeListLibrary_project/cll/static/scss/components/_scrollbars.scss @@ -5,61 +5,101 @@ /// Slim scrollbar /// @desc used for table content -.slim-scrollbar { - /* Webkit */ - &::-webkit-scrollbar { - width: var(--slim-scrollbar-width); - height: var(--slim-scrollbar-width); - } - - &::-webkit-scrollbar-track { - border-radius: var(--slim-scrollbar-radius); - -webkit-border-radius: var(--slim-scrollbar-radius); - } - - &::-webkit-scrollbar-thumb { - background: var(--slim-scrollbar-inactive-color); - border-radius: var(--slim-scrollbar-radius); - -webkit-border-radius: var(--slim-scrollbar-radius); - } - &::-webkit-scrollbar-thumb:hover { - background: var(--slim-scrollbar-active-color); - } +/* Webkit rules, see {@link "Slim scrollbar"} */ +@media screen and (-webkit-min-device-pixel-ratio:0) { + .slim-scrollbar { + &:not(.slim-scrollbar--thick)::-webkit-scrollbar { + width: var(--slim-scrollbar-width); + height: var(--slim-scrollbar-height); + } + + &--thick::-webkit-scrollbar { + width: auto; + height: auto; + } + + &::-webkit-scrollbar-track { + background: var(--slim-scrollbar-track-color); + border-radius: var(--slim-scrollbar-radius); + -webkit-border-radius: var(--slim-scrollbar-radius); + } + + &::-webkit-scrollbar-thumb { + background: var(--slim-scrollbar-inactive-color); + border-radius: var(--slim-scrollbar-radius); + -webkit-border-radius: var(--slim-scrollbar-radius); + } - &::-webkit-scrollbar-thumb:window-inactive { - background: col(clear); + &::-webkit-scrollbar-thumb:hover { + background: var(--slim-scrollbar-active-color); + } + + &::-webkit-scrollbar-thumb:window-inactive { + background-color: col(clear); + } } +} + +/* Moz rules, see {@link "Slim scrollbar"} */ +@-moz-document url-prefix() { + .slim-scrollbar { + scrollbar-color: var(--slim-scrollbar-active-color) transparent !important; + + &:not(.slim-scrollbar--thick) { + scrollbar-width: thin !important; + } - /* Firefox */ - scrollbar-color: var(--slim-scrollbar-active-color) col(clear) !important; - scrollbar-width: thin !important; + &--thick { + scrollbar-width: auto; + } + } } /// Filter scrollbar /// @desc vertical scrollbar used for filters on search pages -.filter-scrollbar { - /* Webkit */ - &::-webkit-scrollbar { - width: 6px; - height: 12px; - } - &::-webkit-scrollbar-track { - background: col(accent-transparent); - border-radius: 10px; - } +/* Webkit rules, see {@link "Filter scrollbar"} */ +@media screen and (-webkit-min-device-pixel-ratio:0) { + .filter-scrollbar { + &:not(.slim-scrollbar--thick)::-webkit-scrollbar { + width: 6px; + height: 12px; + } - &::-webkit-scrollbar-thumb { - border-radius: 10px; - background: var(--slim-scrollbar-inactive-color); - } + &--thick::-webkit-scrollbar { + width: auto; + height: auto; + } + + &::-webkit-scrollbar-track { + background: col(accent-transparent); + border-radius: 10px; + } + + &::-webkit-scrollbar-thumb { + border-radius: 10px; + background: var(--slim-scrollbar-inactive-color); + } - &::-webkit-scrollbar-thumb:hover { - background: var(--slim-scrollbar-active-color); + &::-webkit-scrollbar-thumb:hover { + background: var(--slim-scrollbar-active-color); + } } +} + +/* Moz rules, see {@link "Filter scrollbar"} */ +@-moz-document url-prefix() { + .filter-scrollbar { + scrollbar-color: var(--slim-scrollbar-active-color) transparent !important; + + &:not(.filter-scrollbar--thick) { + scrollbar-width: thin !important; + } - /* Firefox */ - scrollbar-color: var(--slim-scrollbar-active-color) col(clear) !important; - scrollbar-width: thin !important; + &--thick { + scrollbar-width: auto; + } + } } + diff --git a/CodeListLibrary_project/cll/static/scss/components/_search-navigation.scss b/CodeListLibrary_project/cll/static/scss/components/_search-navigation.scss index 623b040bb..cab21f849 100644 --- a/CodeListLibrary_project/cll/static/scss/components/_search-navigation.scss +++ b/CodeListLibrary_project/cll/static/scss/components/_search-navigation.scss @@ -14,7 +14,7 @@ box-sizing: border-box; position: relative; - height: 2rem; + height: 2.5rem; width: 0; padding: 0 2rem 0 0.5rem; color: col(text-darkest); @@ -22,6 +22,10 @@ outline: none; border: none; + &:focus-visible { + outline: 1px solid col(accent-anchor); + } + &:focus { @include box-shadow(0px, 5px, 10px, var(--search-shadow-active-color)); right: 0; @@ -57,6 +61,10 @@ aspect-ratio: 1 / 1; right: 0; + &:focus-visible { + outline: 1px solid col(accent-anchor); + } + &:before { @include fontawesome-icon(); content: "\f002"; @@ -84,15 +92,16 @@ @include box-shadow(0px, 1px, 5px, rgba(0, 0, 0, 0.1)); box-sizing: border-box; position: relative; - height: 2rem; + height: 2.5rem; width: calc(100%); padding: 0 2rem 0 0.5rem; color: col(text-darkest); background-color: col(accent-bright); outline: none; border: none; - - &:focus { + + &:focus, + &:focus-within { @include box-shadow(0px, 5px, 10px, var(--search-shadow-active-color)); outline: none; border-radius: 0.5rem; diff --git a/CodeListLibrary_project/cll/static/scss/components/_tabView.scss b/CodeListLibrary_project/cll/static/scss/components/_tabView.scss index c85c697c5..0d7fed34d 100644 --- a/CodeListLibrary_project/cll/static/scss/components/_tabView.scss +++ b/CodeListLibrary_project/cll/static/scss/components/_tabView.scss @@ -76,3 +76,94 @@ border-top: 1px solid col(accent-washed); } } + +// tab group +// @desc describes a scss-driven tab-based view +// @TODO temp tab group +.tab-group { + display: flex; + flex-wrap: wrap; + position: relative; + max-width: 100%; + padding: 0; + margin: 0.5rem 0; + border-radius: 0.1rem; + background-color: col(bg); + list-style: none; + + .tab { + display: none; + + @for $i from 1 through 5 { + &:checked:nth-of-type(#{$i}) ~ .tab__content:nth-of-type(#{$i}) { + opacity: 1; + position: relative; + top: 0; + z-index: 100; + transform: translateY(0px); + text-shadow: 0 0 0; + } + } + + &:first-of-type:not(:last-of-type) + label { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + + &:not(:first-of-type):not(:last-of-type) + label { + border-radius: 0; + } + + &:last-of-type:not(:first-of-type) + label { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + + &:checked + label { + background-color: col(bg); + cursor: default; + border-bottom: 0; + + &:hover { + background-color: col(bg); + } + } + + + label { + flex-grow: 3; + display: block; + height: 50px; + padding: 15px; + border: 1px solid col(accent-washed); + border-radius: 0.1rem 0.1rem 0 0; + text-decoration: none; + color: col(text-darker); + background-color: col(accent-washed); + text-align: center; + user-select: none; + text-align: center; + box-sizing: border-box; + cursor: pointer; + + &:hover { + background-color: col(accent-lightest); + } + } + + &__content { + display: flex; + flex-flow: column nowrap; + position: absolute; + width: 100%; + padding: 0.5rem 0; + z-index: -1; + left: 0; + opacity: 0; + border-radius: 0 0 0.1rem 0.1rem; + border: 1px solid col(accent-washed); + border-top: 0; + background-color: transparent; + transform: translateY(-3px); + } + } +} diff --git a/CodeListLibrary_project/cll/static/scss/components/_tables.scss b/CodeListLibrary_project/cll/static/scss/components/_tables.scss index 429276cb1..f92821697 100644 --- a/CodeListLibrary_project/cll/static/scss/components/_tables.scss +++ b/CodeListLibrary_project/cll/static/scss/components/_tables.scss @@ -5,36 +5,55 @@ /// Scrollbar base for datatables /// @desc i.e. slim scrollbar -.overflow-table-constraint { - & > .datatable-container { - /* Webkit */ - &::-webkit-scrollbar { - width: var(--slim-scrollbar-width); - height: var(--slim-scrollbar-width); - } - &::-webkit-scrollbar-track { - border-radius: var(--slim-scrollbar-radius); - -webkit-border-radius: var(--slim-scrollbar-radius); - } - - &::-webkit-scrollbar-thumb { - background: var(--slim-scrollbar-inactive-color); - border-radius: var(--slim-scrollbar-radius); - -webkit-border-radius: var(--slim-scrollbar-radius); +/* Webkit rules, see {@link "Scrollbar base for datatables"} */ +@media screen and (-webkit-min-device-pixel-ratio:0) { + .overflow-table-constraint { + & > .datatable-container { + &:not(.datatable-container--thick)::-webkit-scrollbar { + width: var(--slim-scrollbar-width); + height: var(--slim-scrollbar-width); + } + + &--thick::-webkit-scrollbar { + width: auto; + height: auto; + } + + &::-webkit-scrollbar-track { + border-radius: var(--slim-scrollbar-radius); + -webkit-border-radius: var(--slim-scrollbar-radius); + } + + &::-webkit-scrollbar-thumb { + background: var(--slim-scrollbar-inactive-color); + border-radius: var(--slim-scrollbar-radius); + -webkit-border-radius: var(--slim-scrollbar-radius); + } + + &::-webkit-scrollbar-thumb:hover { + background: var(--slim-scrollbar-active-color); + } + + &::-webkit-scrollbar-thumb:window-inactive { + background-color: col(clear); + } } + } +} - &::-webkit-scrollbar-thumb:hover { - background: var(--slim-scrollbar-active-color); - } - - &::-webkit-scrollbar-thumb:window-inactive { - background: col(clear); +/* Moz rules, see {@link "Scrollbar base for datatables"} */ +@-moz-document url-prefix() { + .overflow-table-constraint { + & > .datatable-container { + &:not(.datatable-container--thick) { + scrollbar-width: thin !important; + } + + &--thick { + scrollbar-width: auto; + } } - - /* Firefox */ - scrollbar-color: var(--slim-scrollbar-active-color) col(clear) !important; - scrollbar-width: thin !important; } } diff --git a/CodeListLibrary_project/cll/static/scss/components/_tags.scss b/CodeListLibrary_project/cll/static/scss/components/_tags.scss index 2e6e888df..a03f12876 100644 --- a/CodeListLibrary_project/cll/static/scss/components/_tags.scss +++ b/CodeListLibrary_project/cll/static/scss/components/_tags.scss @@ -6,20 +6,19 @@ /// Tags /// @desc e.g. chip-like interface to represent a label, assoc. with js/tagify.js .tags-root-container { - @include flex-col(); - - flex-wrap: nowrap; + @include flex-col(false, true); + max-width: 100%; margin-top: 0.25rem; margin-bottom: 0.5rem; } .tags-container { - @include flex-row($gap: 0.5rem); + @include flex-row(0.5rem, false); - justify-content: flex-start; + max-width: 100%; min-height: 1rem; + justify-content: flex-start; word-wrap: break-word; - overflow: hidden; border: 1px solid col(accent-dark); border-radius: 2px; padding: 0.5rem 0.5rem; @@ -29,6 +28,11 @@ border-bottom-right-radius: 0px; } + &:has(input:focus), + &:has(input:focus-visible) { + outline: 1px solid col(accent-anchor); + } + input.tags-input-field { @include remove-appearance(); @@ -40,77 +44,161 @@ &:focus { outline: none; } + + &:focus-visible { + outline: none; + } + } +} + +.tag { + --xcolor: var(--color-accent-bright); + --bcolor: var(--color-accent-secondary); + --tcolor: var(--color-text-darkest); + + @include flex-row(0.5rem, true); + + cursor: default; + position: relative; + padding: 0.25rem 0.5rem; + text-align: center; + width: fit-content; + color: var(--tcolor); + justify-content: space-between; + background-color: var(--bcolor); + border-radius: 0.25rem; + word-wrap: break-word; + -ms-word-wrap: break-word; + text-overflow: hidden; + -o-text-overflow: hidden; + -ms-text-overflow: hidden; + overflow: hidden; + transition: background-color 250ms ease; + + &__remove { + cursor: pointer; + position: relative; + margin: 0; + padding: 0; + font-weight: bold; + font-size: 1rem; + transition: color 250ms ease; + border: 0; + line-height: 1; + background-color: transparent; + color: var(--xcolor); + + &:hover { + color: col(accent-danger); + } + } + + &.warning-accent { + --bcolor: var(--color-accent-warning); + } + + &__highlighted { + --bcolor: var(--color-accent-washed); } - .tag { - @include flex-row(); + &__wobble { + --bcolor: var(--color-accent-primary); + animation: tag-wobble 0.5s ease-in-out; + } - cursor: default; + &__name { + display: -webkit-box; position: relative; - padding: 0.25rem 0.5rem; - text-align: center; width: auto; - color: col(text-darkest); - background-color: col(accent-secondary); - border-radius: 0.25rem; + max-width: 90%; + text-align: left; + font-size: 1em; + font-weight: bold; + margin: 0.0675rem 0 0 0; word-wrap: break-word; -ms-word-wrap: break-word; - text-overflow: hidden; - -o-text-overflow: hidden; - -ms-text-overflow: hidden; + line-clamp: 2; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; overflow: hidden; - transition: background-color 250ms ease; + text-overflow: ellipsis; + } + + &--detail { + gap: 0.5rem; + width: 100%; + max-width: 100%; + text-align: left; + padding: 0.25rem 1rem; + border-radius: 1rem; - &__remove { - cursor: pointer; - position: relative; - margin: 0; - padding: 0; + & .as-icon { + margin-right: 0; font-weight: bold; - font-size: 1.5rem; - transition: color 250ms ease; - border: 0; - line-height: 1; - background-color: transparent; - color: #fff; - - &:hover { - color: col(accent-danger); - } } - &.warning-accent { - background-color: col(accent-warning); + & > strong { + @include flex-row(0.5rem, true); } + } - &__highlighted { - background-color: col(accent-washed); + @include media('<550px', 'screen') { + &--detail { + text-align: left; + + & > strong { + display: grid; + gap: 0.5rem; + flex: auto; + grid-template-columns: 1em auto; + align-items: first baseline; + justify-items: first baseline; + align-content: flex-start; + justify-content: space-between; + } } + } - &__wobble { - background-color: col(accent-primary); - animation: tag-wobble 0.5s ease-in-out; - } + &--primary { + --bcolor: var(--color-accent-primary); + } - &__name { - position: relative; - width: auto; - max-width: 90%; - text-align: left; - font-size: 1rem; - margin: 0.0675rem 0.5rem 0rem 0; - word-wrap: break-word; - -ms-word-wrap: break-word; - text-overflow: hidden; - -o-text-overflow: hidden; - -ms-text-overflow: hidden; - overflow: hidden; - } + &--secondary { + --bcolor: var(--color-accent-secondary); + } + + &--tertiary { + --bcolor: var(--color-accent-tertiary); + } + + &--success { + --bcolor: var(--color-accent-success); + } + + &--warning { + --bcolor: var(--color-accent-warning); + } + + &--error, + &--danger { + --bcolor: var(--color-accent-danger); + } + + &--anchor { + --bcolor: var(--color-accent-anchor); + } + + &--bubble { + --bcolor: var(--color-accent-bubble); + } + + &--highlight { + --bcolor: var(--color-accent-highlight); } } .tags-autocomplete-container { - @include flex-col(); + @include flex-col(false, true); position: absolute; flex-wrap: nowrap; @@ -133,6 +221,7 @@ .autocomplete-item { cursor: pointer; + pointer-events: all; padding: 0.25rem 0.5rem; margin: 0; height: auto; @@ -140,7 +229,12 @@ transition: background-color 250ms ease; text-align: left; border: none; - + text-decoration: none; + + &:visited { + all: unset; + } + &__title { color: col(text-darkest); } @@ -152,7 +246,6 @@ color: col(text-brightest); } } - } } diff --git a/CodeListLibrary_project/cll/static/scss/components/_toast.scss b/CodeListLibrary_project/cll/static/scss/components/_toast.scss index 62ff5dc6c..d17fd0521 100644 --- a/CodeListLibrary_project/cll/static/scss/components/_toast.scss +++ b/CodeListLibrary_project/cll/static/scss/components/_toast.scss @@ -145,5 +145,35 @@ --progress-color: var(--color-accent-danger); } } + + &--primary { + #{$self}__progress { + --progress-color: var(--color-accent-primary); + } + } + + &--secondary { + #{$self}__progress { + --progress-color: var(--color-accent-secondary); + } + } + + &--tertiary { + #{$self}__progress { + --progress-color: var(--color-accent-tertiary); + } + } + + &--anchor { + #{$self}__progress { + --progress-color: var(--color-accent-anchor); + } + } + + &--highlight { + #{$self}__progress { + --progress-color: var(--color-accent-highlight); + } + } } } diff --git a/CodeListLibrary_project/cll/static/scss/components/_tooltips.scss b/CodeListLibrary_project/cll/static/scss/components/_tooltips.scss index 5e97d7b77..ce37a72e7 100644 --- a/CodeListLibrary_project/cll/static/scss/components/_tooltips.scss +++ b/CodeListLibrary_project/cll/static/scss/components/_tooltips.scss @@ -47,7 +47,7 @@ @include box-shadow(0, 5px 10px, -2.5px, rgba(0, 0, 0, 0.5)); content: ''; border: 5px solid transparent; - z-index: 9999999; + z-index: 9999998; } &:after { @@ -64,7 +64,7 @@ white-space: normal; text-align: left; text-overflow: ellipsis; - z-index: 9999998; + z-index: 9999999; } &:hover:before, @@ -128,4 +128,17 @@ top: 50%; right: calc(100% + 5px); } + + &[direction^="left-inwards"]:before { + @include prefix(transform, translate(-0.5em, -50%), webkit moz o ms); + left: 100%; + top: 50%; + border-right-width: 0; + border-right-color: var(--color-accent-dark); + } + &[direction^="left-inwards"]:after { + @include prefix(transform, translate(0.5em, -50%), webkit moz o ms); + right: 15px; + top: 50%; + } } diff --git a/CodeListLibrary_project/cll/static/scss/fonts/lato-regular.ttf b/CodeListLibrary_project/cll/static/scss/fonts/lato-regular.ttf new file mode 100644 index 000000000..bb2e8875a Binary files /dev/null and b/CodeListLibrary_project/cll/static/scss/fonts/lato-regular.ttf differ diff --git a/CodeListLibrary_project/cll/static/scss/main.scss b/CodeListLibrary_project/cll/static/scss/main.scss index 8c7acffe8..66a5809e4 100644 --- a/CodeListLibrary_project/cll/static/scss/main.scss +++ b/CodeListLibrary_project/cll/static/scss/main.scss @@ -45,6 +45,7 @@ html { *:not(body) { position: relative; + -webkit-font-smoothing: antialiased; &.hide { display: none; @@ -61,6 +62,17 @@ body { padding: 0; background-color: col(bg); flex-wrap: nowrap; + + // Font anti-aliasing + font-smooth: auto; + -webkit-font-smoothing: antialiased; + + // Disable zooming via touble tap on safari + touch-action: manipulation; +} + +h1, h2, h3, h4, h5, h6 { + font-family: var(--heading-font, inherit); } data { @@ -105,12 +117,12 @@ data { } &--constrained { - max-width: 2160px; width: calc(100% - var(--main-gutter) - var(--main-gutter)); + max-width: 2160px; @include media("<desktop", "screen") { - max-width: 2160px; width: calc(100% - var(--main-mobile-gutter) - var(--main-mobile-gutter)); + max-width: 2160px; } } @@ -122,6 +134,28 @@ data { &--centred { align-self: center; + + @include media('<tablet', 'screen') { + padding-left: 0; + padding-right: 0; + width: 100%; + max-width: 100%; + } + } + + &--centred-constrained { + width: calc(100% - var(--main-gutter) - var(--main-gutter)); + max-width: 2160px; + align-self: center; + + @include media("<desktop", "screen") { + width: calc(100% - var(--main-mobile-gutter) - var(--main-mobile-gutter)); + max-width: 2160px; + padding-left: 0; + padding-right: 0; + width: 100%; + max-width: 100%; + } } } } diff --git a/CodeListLibrary_project/cll/static/scss/pages/about.scss b/CodeListLibrary_project/cll/static/scss/pages/about.scss index 114b7fb0a..d4fc6fad4 100644 --- a/CodeListLibrary_project/cll/static/scss/pages/about.scss +++ b/CodeListLibrary_project/cll/static/scss/pages/about.scss @@ -44,17 +44,26 @@ .contactus-container { @include flex-col(); + flex-direction: column; align-self: center; width: var(--phenotype-article-lg-size); max-width: var(--phenotype-article-lg-size); - margin-top: 1.5rem; + margin: auto; padding: 1rem; box-shadow: none; border-radius: 0.5rem; - @include media(">desktop-lg", "screen") { - @include flex-row(); - margin-top: 3rem; + @include media(">=desktop", "screen") { + flex-direction: column; + flex-wrap: nowrap; + margin-top: 1.5rem; + width: var(--phenotype-article-sm-size); + max-width: var(--phenotype-article-sm-size); + box-shadow: rgba(0, 0, 0, 0.24) 0px 3px 8px; + } + + @include media(">=desktop-lg", "screen") { + flex-direction: row; width: var(--phenotype-article-sm-size); max-width: var(--phenotype-article-sm-size); box-shadow: rgba(0, 0, 0, 0.24) 0px 3px 8px; @@ -64,13 +73,16 @@ @include flex-col(); max-width: calc(100% - 1rem); padding: 0.5rem; + flex: 0; - @include media(">desktop-lg", "screen") { - flex: 0 0 40%; + @include media(">=desktop", "screen") { background-color: var(--phenotype-banner-bg-color); border-radius: 1rem; padding: 1.5rem 1rem; - //justify-content: center; + } + + @include media(">=desktop-lg", "screen") { + flex: 0 0 40%; } &__content { } @@ -94,16 +106,24 @@ } &__form-container { - max-width: calc(100% - 1rem); + // max-width: calc(100% - 1rem); padding: 0.5rem; - @include media(">desktop-lg", "screen") { + @include media(">=desktop", "screen") { flex-grow: 1; + } + + @include media(">=desktop-lg", "screen") { padding: 0.5rem 0.5rem 0.5rem 1.5rem; } & > form { + display: flex; + flex-flow: column nowrap; + width: auto; + height: fit-content; max-width: 100%; + max-height: 100%; } &__captcha-container { diff --git a/CodeListLibrary_project/cll/static/scss/pages/admin/dashboard.scss b/CodeListLibrary_project/cll/static/scss/pages/admin/dashboard.scss new file mode 100644 index 000000000..c7a6ec199 --- /dev/null +++ b/CodeListLibrary_project/cll/static/scss/pages/admin/dashboard.scss @@ -0,0 +1,1167 @@ +@import '../../_methods'; +@import '../../_variables'; +@import '../../_media'; +@import '../../_utils'; + +/************************************* + * * + * Root * + * * + *************************************/ + +body { + align-items: center; +} + + +/************************************* + * * + * Components & Utils * + * * + *************************************/ + +.no-select { + @include ignore-user(); +} + +.visibility-hidden { + visibility: hidden; +} + +.fit-w { + width: fit-content; +} + +.margin-left-auto { + margin-left: auto !important; +} + +%load-skeleton { + position: absolute; + background-color: #dddbdd; + + &:after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + transform: translateX(-100%); + background-image: linear-gradient( + 90deg, + rgba(#fff, 0.00) 0%, + rgba(#fff, 0.50) 50%, + rgba(#fff, 0.00) 100%, + ); + animation: shimmer 1.5s infinite; + } +} + +.loading-cover { + @extend %load-skeleton; + + width: 100%; + height: 100%; + + &--min-htext { + min-height: 1em; + } +} + +@keyframes shimmer { + 100% { + transform: translateX(100%); + } +} + +.autocomplete-input { + padding: 0.5rem 1rem; + line-height: 1.5em; +} + +.note-block { + display: block; + padding: 0.5rem 1rem; + margin-bottom: 1rem; + border-left: 0.25em solid transparent; + border-left-color: $light_mod_lime_green; + color: $mostly_black; + width: 100%; + max-width: 100%; + + &__title { + @include flex-row(0.5rem, false); + width: 100%; + max-width: 100%; + align-items: center; + justify-content: flex-start; + line-height: 1; + color: $dark_mod_lime_green; + } + + &__message { + width: 100%; + max-width: 100%; + margin-top: 0; + margin-bottom: 0; + text-wrap: wrap; + } +} + +[data-ref="validation"] { + @include flex-col(0.25rem, true); + width: 100%; + max-width: calc(100% - 2rem); + margin: 0 0 0.5rem 0; + padding: 0 1rem; + border-left: 0.25em solid transparent; + border-left-color: $debian_red; + + & p { + margin-top: 0; + margin-bottom: 0; + font-size: 1em; + white-space: normal; + word-break: break-word; + word-wrap: break-word; + text-wrap: wrap; + + &::first-letter { + text-transform: capitalize; + } + } + + & p:not(.validation__title p) { + display: list-item; + width: 100%; + max-width: calc(100% - 2em); + margin-left: 1.25em; + text-indent: 0; + list-style-type: disc; + color: $strong_red; + } + + & .validation__title { + @include flex-row(0.5rem, false); + width: 100%; + max-width: 100%; + align-items: center; + justify-content: flex-start; + line-height: 1; + + & > p, + & > span { + color: $debian_red; + font-weight: bold; + } + } +} + +[tooltip] { + --bd-col: rgba(27, 31, 35, 0.15); + + &:after { + @include prefix(box-shadow, 'rgba(0, 0, 0, 0.02) 0px 1px 3px 0px, rgba(27, 31, 35, 0.15) 0px 0px 0px 1px', webkit moz o); + color: $mostly_black !important; + background: $white !important; + } + + &:before { + background-color: $white; + } + + &[direction^="up"]:before { + border-top-color: var(--bd-col) !important; + } + + &[direction^="right"]:before { + border-right-color: var(--bd-col) !important; + } + + &[direction^="down"]:before { + border-bottom-color: var(--bd-col) !important; + } + + &[direction^="left"]:before { + border-left-color: var(--bd-col) !important; + } +} + +.outline-btn { + @include app-font-style(); + + cursor: pointer; + pointer-events: auto; + position: relative; + white-space: nowrap; + overflow: hidden; + border: none; + color: $mostly_black; + border-radius: 0.25rem; + border-width: 1px; + border-style: solid; + background-color: transparent; + margin: 0; + padding: 1rem 2rem; + font-weight: bold; + + &:after { + font-weight: bold; + } + + @include media('<tablet', 'screen') { + padding: 0.5rem 1rem; + } + + &:not(:disabled):active { + transition: all 250ms ease; + @include prefix(transform, translateY(0.2rem), webkit moz o); + } + + &:disabled { + opacity: 0.5; + } + + &:focus-visible { + outline: 1px solid $mostly_black; + border-radius: 2pt; + } + + &--icon { + padding: 0.5rem 2.5rem 0.5rem 1rem; + text-align: left; + + &:after { + cursor: pointer; + pointer-events: auto; + position: absolute; + right: 0; + height: 100%; + top: calc(50% - 0.5rem); + aspect-ratio: 1 / 1; + font-family: var(--icons-name); + font-style: var(--icons-style); + font-size: var(--icons-size); + text-align: center; + } + + &-edit:after { + content: '\f044'; + } + + &-add:after { + content: '\f15b'; + } + + &-plus:after { + content: '\2b'; + } + + &-folder-plus:after { + content: '\f65e' + } + + &-user-plus:after { + content: '\f234'; + } + + &-save:after { + content: '\f574'; + } + + &-cancel:after { + content: '\f00d'; + } + + &-delete:after { + content: '\f1f8'; + } + + &-user-delete:after { + content: '\f235'; + } + + &-padlock-unlock:after { + content: '\f13e'; + } + } +} + +.card { + @include flex-col(false, true); + width: 100%; + flex: 1; + padding: 20px 20px; + overflow: hidden; + color: $mostly_black; + background-color: $white; + + @include apply-border; + @include prefix(box-shadow, rgba(99, 99, 99, 0.3) 0px 2px 8px 0px, webkit moz o); + + &--flex-1 { + flex: 1; + } + + &__desc { + color: $lightblack-grey; + margin: 0.25rem 0 1rem 0; + } + + &__header { + @include flex-row(false, true); + width: 100%; + max-width: 100%; + align-items: flex-start; + justify-content: space-between; + vertical-align: middle; + + &:has(> :last-child:nth-child(1)) { + justify-content: flex-start; + } + + & > h3 { + color: $blackish-grey; + font-size: 1.2em; + margin: 0 0 0.5rem 0 + } + } + + &__data { + display: flex; + flex: 1; + width: auto; + height: auto; + align-items: center; + justify-content: center; + text-align: center; + font-size: 2em; + } + + &__footer { + @include flex-col(false, true); + width: 100%; + max-width: 100%; + overflow: clip; + color: $lightblack-grey; + + span:not(:last-of-type){ + margin-right: 1rem; + } + + svg { + margin-right: 5px; + } + + &--action { + @include flex-row(0.5rem, false); + align-items: center; + justify-content: flex-end; + padding: 5px 2px; + } + } +} + + +/************************************* + * * + * Field Components * + * * + *************************************/ + +.selector-component { + @include flex-col(0.5rem, true); + width: 100%; + max-width: 100%; + height: 100%; + max-height: fit-content; + + &__header { + @include flex-row(0.5rem, false); + align-items: center; + justify-content: space-between; + + &:has(> :last-child:nth-child(1)) { + justify-content: flex-end; + } + } + + &__content { + display: block; + position: relative; + width: 100%; + max-width: 100%; + height: 100%; + min-height: fit-content; + max-height: calc(100% - 100px); + overflow: auto; + color: inherit; + border: 1px solid $dark_grey; + border-width: 1px; + } + + &__table { + display: block; + box-sizing: border-box; + position: relative; + color: inherit; + width: 100%; + height: 100%; + min-width: fit-content; + min-height: fit-content; + + tr { + display: flex; + flex-flow: row nowrap; + width: 100%; + min-height: max-content; + align-items: center; + background-color: transparent; + overflow: visible; + } + + td { + display: block; + padding: 5px 10px; + min-width: 10ch; + width: auto; + background-color: transparent; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + box-sizing: content-box; + flex: 1; + } + + thead { + display: block; + position: sticky; + min-width: fit-content; + min-height: max-content; + top: 0; + z-index: 1; + font-weight: 500; + text-transform: uppercase; + border: 1px solid $dark_grey; + border-width: 0 0 1px 0; + line-height: 1.2; + background-color: $white; + overflow: visible; + + tr { + top: 0; + padding: 10px 0px; + } + } + + tbody { + display: block; + min-width: fit-content; + min-height: max-content; + + tr:nth-child(even) { + background-color: $lightest_grey; + } + } + } + + &__none-available { + display: block; + padding: 1rem; + border-radius: 0.1rem; + margin-bottom: 0.5rem; + background-color: col(accent-semi-transparent); + + & > p { + text-align: center; + } + } + + & [data-visible="false"] { + display: none; + } +} + + +/************************************* + * * + * Dashboard Elements * + * * + *************************************/ + +.dash-search { + width: 100%; + max-width: 100%; + color: inherit; + + @include media('>desktop', 'screen') { + max-width: 1024px !important; + } +} + +.dash-list { + @include flex-col(0.5rem, true); + position: relative; + width: 100%; + height: 100%; + max-width: 100%; + max-height: 100%; + color: $mostly_black; + background-color: $white; + + &__container { + display: block; + position: relative; + width: 100%; + max-width: 100%; + height: 100%; + min-height: fit-content; + max-height: calc(100% - 100px); + overflow: auto; + color: inherit; + border: 1px solid $dark_grey; + border-width: 1px; + + @include media('>desktop', 'screen') { + max-width: 1024px; + } + } + + &__footer { + @include flex-row(0.5rem, false); + align-items: flex-start; + justify-content: space-between; + overflow: hidden; + max-width: 100%; + max-height: fit-content; + + @include media('>desktop', 'screen') { + max-width: 1024px !important; + } + + & > p { + word-break: break-word; + word-wrap: break-word; + text-wrap: pretty; + white-space: normal; + max-width: 100%; + } + } + + &__table { + display: block; + box-sizing: border-box; + position: relative; + color: inherit; + width: 100%; + height: 100%; + min-width: fit-content; + min-height: fit-content; + + & a[data-ref] { + @include prefix(appearance, revert, webkit moz ms o); + cursor: pointer; + color: LinkText; + text-decoration: underline; + + &:visited { + color: LinkText; + } + } + + tr { + display: flex; + flex-flow: row nowrap; + width: 100%; + min-height: max-content; + align-items: center; + background-color: transparent; + overflow: visible; + } + + td { + display: block; + padding: 5px 10px; + min-width: 5ch; + width: fit-content; + background-color: transparent; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + box-sizing: content-box; + flex: 1; + } + + thead { + display: block; + position: sticky; + min-width: fit-content; + min-height: max-content; + top: 0; + z-index: 1; + font-weight: 500; + text-transform: uppercase; + border: 1px solid $dark_grey; + border-width: 0 0 1px 0; + line-height: 1.2; + background-color: $white; + overflow: visible; + + tr { + top: 0; + padding: 10px 0px; + } + } + + tbody { + display: block; + min-width: fit-content; + min-height: max-content; + + tr:nth-child(even) { + background-color: $lightest_grey; + } + } + } +} + + +/************************************* + * * + * Dashboard Page * + * * + *************************************/ + +.dashboard-layout { + @include apply-bbox(); + @include flex-row(false, true); + flex: 1; + width: 100vw; + height: 100vh; + max-width: 100vw; + max-height: 100vh; + + &--constrained { + max-width: 2160px; + } +} + +.dashboard-nav { + @include apply-bbox(); + @include flex-col(false, true); + @include prefix(transition, all 250ms ease-in-out, webkit moz); + width: calc(50px + 2rem); + height: 100%; + max-height: 100vh; + padding: 0.5rem 1rem; + z-index: 1; + color: $mostly_black; + background-color: $white; + + @include apply-border; + + &__header { + @include flex-row(0.5rem, true); + width: 100%; + height: fit-content; + max-width: 100%; + align-items: center; + justify-content: space-between; + margin: 0 0 1rem 0; + + @include bottom-divider($size_pad: false, $border_col: col(accent-washed), $offset: -4px); + + &-logo { + width: 50px; + height: 50px; + } + + & > h1 { + @include ignore-user(); + margin: 0; + font-size: 1.5em; + } + } + + &__targets { + @include apply-bbox(); + @include flex-col(0.5rem, true); + + overflow-x: hidden; + overflow-y: auto; + width: 100%; + height: 100%; + max-width: 100%; + max-height: 100%; + + & > h2 { + @include ignore-user(); + font-size: 1.35em; + margin: 0 0 0.5rem 0; + } + + &-list { + @include flex-col(0.5rem, true); + width: 100%; + height: 100%; + max-width: calc(100% - 4px); + max-height: calc(100% - 2rem - 2px); + margin: 0; + padding: 2px 4px; + justify-content: flex-start; + overflow-x: hidden; + overflow-y: auto; + list-style: none; + list-style-position: inside; + + &-item { + @include remove-appearance; + @include clear-anchor(); + @include ignore-user(); + + @include flex-row(0.5rem, true); + width: calc(100% - 1rem); + align-items: center; + justify-content: flex-start; + vertical-align: middle; + margin: 0; + padding: 0.25rem; + list-style: none; + color: $lightblack-grey; + font-size: 1.2em; + font-weight: bold; + + & span { + pointer-events: none; + font-weight: inherit + } + + & .as-icon { + display: inline-block; + width: 25px; + + &:before { + font-size: inherit; + } + } + + & [data-role="icon"] { + display: none; + width: 100%; + text-align: center; + vertical-align: middle; + @include prefix(transition, transform 200ms ease-out, webkit moz); + + &:before { + width: 45px; + height: 45px; + line-height: 45px; + } + } + + & [data-role="label"] { + @include flex-row(0.5rem, false); + justify-content: space-between; + align-items: center; + } + + &[data-active="true"] { + color: $mostly_black; + + & [data-role="icon"] { + @include prefix(transform, scale(1.5), webkit moz o); + } + + & [data-role="label"] { + font-size: 1.1em; + } + } + + &:not(:disabled):not([disabled]):hover { + color: $near-black; + } + + &:not(:disabled):not([disabled]):focus-visible { + color: $near-black; + @include apply-focusable-item; + } + } + } + } + + &:not([data-open="true"]) { + & .dashboard-nav__header { + justify-content: center; + + & > h1 { + display: none; + } + + &-logo { + margin: auto; + width: 50px; + height: 50px; + } + } + + & .dashboard-nav__targets { + & > h2 { + display: none; + } + + &-list { + align-items: center; + + &-item { + width: calc(100% - 0.5em); + padding: 0; + + & [data-role="label"] { + display: none; + } + + & [data-role="icon"] { + display: inline-block; + } + } + } + } + } + + @include media('>desktop', 'screen') { + &[data-open="true"] { + width: 250px; + } + } +} + +.dashboard-nav-toggle { + @include flex-col(0.5rem, true); + width: fit-content; + height: fit-content; + align-items: center; + justify-content: space-between; + + &__logo { + display: none; + width: 50px; + height: 50px; + } + + &__btn { + @include remove-appearance; + @include clear-anchor(); + + display: inline-block; + cursor: pointer; + width: 25px; + height: 25px; + color: $mostly_black; + + &:before { + content: '\f0c9'; + display: inline-block; + width: 25px; + height: 25px; + text-align: center; + vertical-align: center; + line-height: 25px; + font-size: var(--icons-size); + font-style: var(--icons-style); + font-family: var(--icons-name); + color: inherit; + } + + &:not(:disabled):not([disabled]):focus-visible { + color: $dark_grey; + @include apply-focusable-item; + } + } +} + +.dashboard-entity-form { + @include flex-col(0.5rem, true); + width: 100%; + height: fit-content; + max-width: 100%; +} + +.dashboard-view { + @include apply-bbox(); + @include flex-col(0.5rem, true); + @include prefix(transition, all 250ms ease-in-out, webkit moz); + width: 100%; + height: 100%; + max-width: calc(100% - 250px); + max-height: 100%; + padding: 0.5rem 1rem; + background-color: transparent; + + &__header { + @include flex-row(false, true); + + width: 100%; + height: fit-content; + align-items: center; + justify-content: center; + overflow: visible; + min-height: 1rem; + z-index: 999; + + &--split-bar { + justify-content: space-between; + + &:has(> :last-child:nth-child(1)) { + justify-content: flex-end; + } + } + } + + &__content { + @include apply-bbox(); + @include flex-row(1rem, false); + + overflow-x: none; + overflow-y: auto; + width: 100%; + max-width: 100%; + max-height: 100%; + + &-group { + @include apply-bbox(); + @include flex-col(false, true); + overflow: none; + width: fit-content; + height: fit-content; + max-width: 100%; + } + + &-title { + color: $mostly_black; + } + + &-header { + @include flex-row(0.5rem, false); + align-items: center; + justify-content: space-between; + width: 100%; + max-width: 100%; + height: fit-content; + + &--constrain { + max-width: 100%; + max-height: fit-content; + + @include media('>desktop', 'screen') { + max-width: 1024px !important; + } + } + + &--constrain-sm { + max-width: 100%; + max-height: fit-content; + + @include media('>tablet', 'screen') { + max-width: 850px !important; + } + } + } + + &-heading { + color: $mostly_black; + margin: 1rem 0; + } + + &--fill-w { + width: 100%; + max-width: 100%; + } + + &--fill-h { + height: 100%; + max-height: 100%; + } + + &--fill-wh { + width: 100%; + height: 100%; + max-width: 100%; + max-height: 100%; + } + + &-grid { + @include apply-bbox(); + // @include flex-row(1rem, true); + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + grid-gap: 20px; + width: 100%; + height: fit-content; + max-width: 100%; + padding: 5px 10px; + align-items: stretch; + justify-content: space-evenly; + overflow-x: auto; + overflow-y: hidden; + + &:has(> :last-child:nth-child(1)) { + justify-content: flex-start; + } + + .card { + width: calc(100% - 40px); + } + + @include media('<tablet', 'screen') { + height: calc(max-content + 40px); + overflow-x: hidden; + padding-right: 2px; + } + } + + &-display { + @include flex-col(0.5rem, true); + height: auto; + padding: 0.5rem; + margin-bottom: 0.5rem; + max-width: calc(100% - 1rem); + } + + &-form { + @include flex-col(1rem, true); + padding: 0.5rem; + margin-bottom: 0.5rem; + max-width: 100%; + max-height: fit-content; + + @include media('>tablet', 'screen') { + max-width: 850px !important; + } + } + + &-table { + @include flex-col(); + height: auto; + padding: 0.5rem; + margin-bottom: 0.5rem; + max-width: calc(100% - 1rem); + + & > tbody > tr { + border-bottom: 1px solid col(accent-washed); + } + + & .overflow-table-constraint { + padding: 0.5rem; + position: relative; + width: calc(100% - 1rem); + max-width: 100%; + } + } + } +} + +.dashboard-nav[data-open="true"] ~ .dashboard-view { + .dashboard-nav-toggle { + &__btn:before { + content: '\f550'; + } + } +} + +@include media('>=tablet', 'screen') { + .dashboard-nav:not([data-open="true"]) ~ .dashboard-view { + max-width: calc(100% - (50px + 2rem)); + } +} + +@include media('<desktop', 'screen') { + .dashboard-nav-toggle__btn { + visibility: hidden; + } + + .dashboard-nav { + width: calc(50px + 2rem); + + &__header { + justify-content: center; + + & > h1 { + display: none; + } + + &-logo { + margin: auto; + width: 50px; + height: 50px; + } + } + + &__targets { + & > h2 { + display: none; + } + + &-list { + align-items: center; + + &-item { + width: calc(100% - 0.5em); + padding: 0; + + & [data-role="label"] { + display: none; + } + + & [data-role="icon"] { + display: inline-block; + } + } + } + } + } + + .dashboard-view { + max-width: calc(100% - (50px + 2rem)); + } +} + +@include media('<800px', 'screen') { + body { + overflow-x: hidden; + } + + .dashboard-nav { + display: none; + } + + .dashboard-view { + max-width: 100%; + + &__header { + position: fixed; + top: 0; + left: 0.25rem; + width: calc(100% - 1.5rem); + padding: 0.5rem; + background-color: $white; + } + + &__content { + margin-top: 50px; + height: max-content; + max-height: max-content; + overflow-y: unset; + } + } + + .dashboard-nav-toggle { + &__btn { + display: none; + } + + &__logo { + display: inline-block; + } + } +} diff --git a/CodeListLibrary_project/cll/static/scss/pages/create.scss b/CodeListLibrary_project/cll/static/scss/pages/create.scss index dba5057dd..e64689f25 100644 --- a/CodeListLibrary_project/cll/static/scss/pages/create.scss +++ b/CodeListLibrary_project/cll/static/scss/pages/create.scss @@ -1,10 +1,8 @@ -@import '../vendor/_gridjs'; @import '../_methods'; @import '../_variables'; @import '../_media'; @import '../_utils'; - /* Create template * @desc Stylesheet relating to create and update form * @@ -41,11 +39,18 @@ &-row { @include flex-row($gap: 0.5rem); - + flex-wrap: wrap-reverse; justify-content: center; + max-width: 100%; + @include media(">phone", "screen") { - & { - justify-content: right; + justify-content: right; + } + + @include media("<=phone", "screen") { + & > #submit-entity-btn { + text-wrap: wrap; + word-wrap: break-all; } } } @@ -63,6 +68,8 @@ color: col(text-darkest); line-height: 1; margin: 1rem auto; + text-wrap: wrap; + word-break: break-all; } span { @@ -182,6 +189,8 @@ font-size: var(--progress-tracker-header-size); color: var(--progress-tracker-header-color); line-height: 1; + text-wrap: wrap; + word-break: break-word; } @include media("<desktop", "screen") { @@ -221,11 +230,11 @@ @include flex-col(); position: relative; - padding: 0; - margin: 1rem 0 0 0; width: 100%; max-width: 100%; - + margin: 1rem 0 0.5rem 0; + padding: 0; + &__header { @include flex-row(); width: 100%; @@ -254,6 +263,7 @@ font-size: 18px; font-weight: bold; line-height: 1; + color: col(text-darker); } &__error { @@ -451,13 +461,14 @@ } .ruleset-group { - @include flex-col(); + @include flex-col(false, true); + max-width: 100%; &__none-available { padding: 1rem; border-radius: 0.1rem; background-color: col(accent-semi-transparent); - margin-bottom: 0.5rem; + margin: 0.5rem 0; &-message { text-align: center; @@ -472,7 +483,7 @@ @include flex-col(); position: relative; height: auto; - padding: 0.5rem; + padding: 0.5rem 0; margin-bottom: 0.5rem; &:not(.show) { @@ -515,3 +526,159 @@ color: col(accent-danger); } } + +.entity-selector-group { + @include flex-col(0.5rem, true); + position: relative; + width: 100%; + max-width: 100%; + height: fit-content; + margin-bottom: 0.5rem; + + &__none-available { + padding: 1rem; + border-radius: 0.1rem; + background-color: col(accent-semi-transparent); + + &-message { + text-align: center; + } + + &:not(.show) { + display: none; + } + } + + &__list { + @include flex-col(0.5rem, true); + position: relative; + width: 100%; + max-width: 100%; + + &:not(.show) { + display: none; + } + + &-header { + display: block; + width: 100%; + max-width: 100%; + border-bottom: 1px solid col(accent-dark); + + h3 { + padding: 0; + } + } + + &-container { + @include flex-col(0.5rem, true); + position: relative; + width: 100%; + max-width: calc(100% - 0.5rem); + max-height: 200px; + padding: 0.5rem; + overflow-y: auto; + overflow-x: none; + } + } + + &__item { + @include flex-row(0.5rem, false); + max-width: calc(100% - 0.5rem); + align-items: center; + justify-content: space-between; + padding: 0.5rem 0; + + & p { + margin: 0; + } + + &-text { + flex: 1; + max-width: 100%; + + & p { + text-wrap: wrap; + word-wrap: break-word; + max-width: 100%; + } + + & a { + text-wrap: wrap; + word-wrap: break-word; + max-width: 100%; + } + + @include media("<tablet", "screen") { + flex: 1; + min-width: 100%; + + & p { + width: fit-content; + } + } + } + + &-btn { + @include flex-row(); + @include prefix(transition, all 250ms ease, webkit moz o ms); + cursor: pointer; + pointer-events: auto; + flex-wrap: nowrap; + width: fit-content; + height: fit-content; + padding: 0.5rem 0.5rem; + margin-right: 0.25rem; + vertical-align: middle; + align-items: center; + justify-content: space-evenly; + color: col(text-brightest); + border-radius: 0.25rem; + border: none; + outline: none; + + & > .as-icon { + pointer-events: none; + } + + &[data-action="remove"] { + background-color: col(accent-danger); + } + + & > span { + font-weight: bold; + pointer-events: none; + } + + &:disabled { + opacity: 0.5; + } + + &:focus-visible { + outline: 1px solid col(accent-dark); + border-radius: 2pt; + } + + &:focus { + outline: none; + border-radius: 0.25rem; + } + + &:hover { + @include prefix(filter, brightness(80%), webkit moz o ms); + } + + &:active { + @include prefix(transform, scale(0.95), webkit moz o ms); + } + + &:not(:last-child) { + margin-left: auto; + } + } + + &:not(:last-child) { + border-bottom: 1px solid col(accent-washed); + } + } +} diff --git a/CodeListLibrary_project/cll/static/scss/pages/detail.scss b/CodeListLibrary_project/cll/static/scss/pages/detail.scss index 745a25fb3..2e6d0ea43 100644 --- a/CodeListLibrary_project/cll/static/scss/pages/detail.scss +++ b/CodeListLibrary_project/cll/static/scss/pages/detail.scss @@ -21,10 +21,18 @@ // Top-level container for detail header buttons // @desc defines button layout for detail page header actions, e.g. export/edit/publish .action-buttons-container { - align-items: flex-end; + display: flex; + flex-flow: column nowrap; + flex: auto; + align-items: center; + justify-content: flex-end; - @include media("<=phone", "screen") { - align-items: center; + @include media("<desktop", "screen") { + flex: auto; + width: 100%; + justify-content: center; + margin-left: auto; + margin-right: auto; } } @@ -33,13 +41,13 @@ .detail-actions { display: flex; flex-flow: row wrap; - width: fit-content; + width: 100%; box-sizing: content-box; gap: 0.5rem; justify-content: flex-end; margin: 0.5rem 0rem 0.5rem 0rem; - @include media("<=phone", "screen") { + @include media("<desktop", "screen") { justify-content: center; } @@ -83,6 +91,23 @@ max-width: var(--phenotype-article-lg-size); } + &--icn { + display: flex; + flex-flow: column nowrap; + width: 1.25em; + height: 1.25em; + align-items: center; + justify-content: center; + text-align: center; + + &:before { + display: block; + margin: auto; + font-size: 1em; + font-weight: bold; + } + } + &__completed { @include flex-col(); @@ -104,33 +129,126 @@ } } } + + &__action-btn { + display: flex; + flex-flow: row wrap; + flex: fit-content; + height: fit-content; + padding: 0.5rem 1rem; + gap: 0.5rem; + align-items: center; + justify-content: center; + } &__header { + @include flex-col(0.5rem, true); @include app-font-style(); - @include bottom-divider(2rem); - padding: 0.5rem 0.5rem 0.5rem 0.5rem; - - span { - padding-left: 0rem; + background-image: linear-gradient(90deg, col(accent-dark) 0%, col(accent-dark) 100%); + background-image: -webkit-linear-gradient(90deg, col(accent-dark) 0%, col(accent-dark) 100%); + background-repeat: no-repeat; + background-position: calc(50% + 0.25rem) 100%; + background-size: calc(100% - 0.25rem) 1px, auto; + + span.badge { color: col(text-dark); font-weight: normal; line-height: 1; - margin: 0.25rem auto; + margin: 0; } p { line-height: 1; - padding: 0.5rem 0rem 0.2rem 0rem; - margin: 0.5rem 0 0.2rem 0; + padding: 0; + margin: 0; } h1 { + flex: auto; font-size: var(--phenotype-article-h1-size); font-weight: var(--phenotype-article-h1-weight); color: col(text-darkest); line-height: 1; margin: 0 0 0.5rem 0; + } + + &--fmax-1 { + flex: max-content 1; + max-width: 260px; + + & p { + display: -webkit-box; + line-clamp: 2; + -webkit-line-clamp: 2; + -moz-box-orient: vertical; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + box-sizing: border-box; + } + + @include media('<550px', 'screen') { + flex: 100%; + } + } + + &--fmin-0 { + flex: fit-content 0; + + @include media('<550px', 'screen') { + flex: auto; + } + } + + &--fill { + width: 100%; + max-width: 100%; + } + + &--no-wrap { + flex-wrap: nowrap !important; + } + + &--ai-fe { + align-items: flex-end; + } + + &--jc-sb { + justify-content: space-between; + } + + &--jc-fs { + justify-content: flex-start; + } + + &--mr-05 { + margin-right: 0.5em; + } + + &-col { + @include flex-col(0.5rem, true); + } + + &-row { + @include flex-row(0.5rem, false); + align-items: center; + } + + &-title { + @include flex-row(0.5rem, false); + width: 100%; + max-width: 100%; + justify-content: space-between; + text-wrap: wrap; + word-break: break-word; + word-wrap: break-word; + box-sizing: border-box; + + & span.badge { + margin-left: auto; + margin-right: 0; + } & + p { position: relative; @@ -229,13 +347,17 @@ } &-container { - @include flex-col(); + @include flex-col(false, true); position: relative; width: 100%; max-width: 100%; height: fit-content; } } + + @include media('<tablet', 'screen') { + padding-left: 0.5rem; + } } .concepts-view { @@ -533,8 +655,8 @@ white-space: nowrap; vertical-align: middle; border-radius: 10px; - color: #3C3C3B; - background-color: rgba(208, 211, 212, 0.5); + color: col(text-darkest); + background-color: col(accent-tertiary); } .label { diff --git a/CodeListLibrary_project/cll/static/scss/pages/home/base.scss b/CodeListLibrary_project/cll/static/scss/pages/home/base.scss index af3a54b98..ea884173b 100644 --- a/CodeListLibrary_project/cll/static/scss/pages/home/base.scss +++ b/CodeListLibrary_project/cll/static/scss/pages/home/base.scss @@ -60,10 +60,15 @@ &__search { position: relative; - width: calc(100% - 0.5rem); + width: calc(100% - 2rem); + max-width: calc(100% - 2rem); padding: 0; margin: 0; + @include media('<tablet', 'screen') { + max-width: 100%; + } + &-input { @include box-shadow(0px, 1px, 5px, rgba(0, 0, 0, 0.1)); @include prefix(transition, all 250ms ease, webkit moz ms o); @@ -71,7 +76,8 @@ box-sizing: border-box; position: relative; height: 2.5rem; - width: calc(100%); + width: 100%; + max-width: 100%; padding: 0 2.5rem 0 1rem; color: col(text-darkest); background-color: col(accent-bright); @@ -132,9 +138,15 @@ } &__details { + width: 100%; max-width: 550px; + text-wrap: wrap; + word-wrap: break-word; + text-wrap-style: pretty; h1 { + width: 100%; + max-width: 100%; color: col(text-darker); font-size: 3rem; font-weight: 700; @@ -143,20 +155,32 @@ } p { + width: 100%; + max-width: 100%; font-size: 1rem; - } - - p { color: col(text-dark); } h2 { + width: 100%; + max-width: 100%; color: col(text-dark); font-size: 1.2rem; font-weight: 700; margin-top: 0; margin-bottom: 0.5rem; } + + @include media('<tablet', 'screen') { + max-width: 100%; + word-break: break-all; + } + } + + @include media('<tablet', 'screen') { + padding-right: 1rem; + padding-left: 1rem; + max-width: calc(100% - 2rem); } } @@ -868,10 +892,13 @@ } &-text { + max-width: 100%; + font-family: inherit; font-size: 1rem; text-align: left; color: col(text-darker); - word-wrap: anywhere; + text-wrap: wrap; + word-wrap: break-word; } } } diff --git a/CodeListLibrary_project/cll/static/scss/pages/login.scss b/CodeListLibrary_project/cll/static/scss/pages/login.scss index 779278049..7a295dc6c 100644 --- a/CodeListLibrary_project/cll/static/scss/pages/login.scss +++ b/CodeListLibrary_project/cll/static/scss/pages/login.scss @@ -3,9 +3,9 @@ @import '../_media'; @import '../_utils'; -/// Login template override -/// @desc Stylesheet relating to login form -.login-container { +/// Account template override +/// @desc Stylesheet relating to login / password reset form +.account-container { @include flex-col(); align-self: center; width: var(--phenotype-article-lg-size); @@ -15,8 +15,28 @@ border-radius: 0.5rem; flex-direction: column-reverse; - @include media(">desktop-lg", "screen") { - @include flex-row(); + &--text-wrappable { + display: inline-block; + width: min-content; + min-width: 100%; + max-width: 100%; + word-break: break-word; + word-wrap: break-word; + text-wrap: pretty; + white-space: normal; + } + + @include media(">=desktop", "screen") { + flex-direction: column-reverse; + flex-wrap: nowrap; + margin: auto; + width: var(--phenotype-article-sm-size); + max-width: var(--phenotype-article-sm-size); + box-shadow: rgba(0, 0, 0, 0.24) 0px 3px 8px; + } + + @include media(">=desktop-lg", "screen") { + flex-direction: row; margin: auto; width: var(--phenotype-article-sm-size); max-width: var(--phenotype-article-sm-size); @@ -27,13 +47,16 @@ @include flex-col(); padding: 0.5rem; margin-top: 1rem; + flex: 0; - @include media(">desktop-lg", "screen") { - flex: 0 0 40%; + @include media(">=desktop", "screen") { background-color: var(--phenotype-banner-bg-color); border-radius: 1rem; padding: 1.5rem 1rem; - //justify-content: center; + } + + @include media(">=desktop-lg", "screen") { + flex: 0 0 40%; } &__section { @@ -57,11 +80,23 @@ &__form-container { padding: 0.5rem; - @include media(">desktop-lg", "screen") { + @include media(">=desktop", "screen") { flex-grow: 1; + } + + @include media(">=desktop-lg", "screen") { padding: 0.5rem 0.5rem 0.5rem 1.5rem; } + & > form { + display: flex; + flex-flow: column nowrap; + width: auto; + height: fit-content; + max-width: 100%; + max-height: 100%; + } + &__options-container { @include flex-row(); margin: 0.5rem 0; diff --git a/CodeListLibrary_project/cll/static/scss/pages/organisations.scss b/CodeListLibrary_project/cll/static/scss/pages/organisations.scss new file mode 100644 index 000000000..08da7853e --- /dev/null +++ b/CodeListLibrary_project/cll/static/scss/pages/organisations.scss @@ -0,0 +1,393 @@ +@import '../_methods'; +@import '../_variables'; +@import '../_media'; +@import '../_utils'; + +/// Organisation page +/// @desc Stylesheet relating to organisations +.organisation-page { + @include flex-col(); + align-self: center; + width: var(--phenotype-article-lg-size); + max-width: var(--phenotype-article-lg-size); + + @include media(">desktop", "screen") { + width: var(--phenotype-article-sm-size); + max-width: var(--phenotype-article-sm-size); + } + + @include media("<desktop", "screen") { + width: var(--phenotype-article-lg-size); + max-width: var(--phenotype-article-lg-size); + } + + &__section { + max-width: 100%; + + &__invite { + display: flex; + gap: 1rem; + justify-content: center; + margin-top: 1rem; + } + + &__title { + display: flex; + flex-flow: row wrap; + justify-content: space-between; + padding: 0.5rem 0 0.5rem 0; + + &__buttons { + display: flex; + flex-flow: row wrap; + max-height: 35px; + margin-left: auto; + } + } + + &--constrained { + max-width: 100%; + max-height: 80px; + overflow-x: none; + overflow-y: auto; + } + + &__body { + @include flex-col(); + gap: 0.25rem; + + &.row { + @include flex-row(); + } + + &__collaborator { + padding: 0.5rem 1rem; + width: fit-content; + border-radius: 1rem; + background-color: col(accent-washed); + } + + &__none-available { + display: block; + padding: 1rem; + border-radius: 0.1rem; + background-color: col(accent-semi-transparent); + margin-bottom: 0.5rem; + + &-message { + text-align: center; + } + + &:not(.show) { + display: none; + } + } + } + } + + &__submit { + display: flex; + flex-flow: row nowrap; + width: 100%; + justify-content: flex-end; + margin-top: 1rem; + } + + .member-management { + @include flex-col(); + position: relative; + width: 100%; + max-width: 100%; + + &__table { + display: block; + box-sizing: border-box; + position: relative; + color: inherit; + width: 100%; + height: 100%; + min-width: fit-content; + min-height: fit-content; + + td { + display: block; + padding: 5px 10px; + min-width: 10ch; + width: auto; + background-color: transparent; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + box-sizing: content-box; + flex: 1; + } + + tbody { + display: block; + min-width: fit-content; + min-height: max-content; + + tr:nth-child(even) { + background-color: $lightest_grey; + } + } + + tr { + display: flex; + flex-flow: row nowrap; + width: 100%; + min-height: max-content; + align-items: center; + background-color: transparent; + overflow: visible; + } + + thead { + display: block; + position: sticky; + min-width: fit-content; + min-height: max-content; + top: 0; + z-index: 1; + font-weight: 500; + text-transform: uppercase; + border: 1px solid #8c8c8c; + border-top-width: 1px; + border-right-width: 1px; + border-bottom-width: 1px; + border-left-width: 1px; + border-width: 0 0 1px 0; + line-height: 1.2; + background-color: white; + overflow: visible; + + &>tr { + top: 0; + padding: 10px 0px; + } + } + } + + &__invites { + @include flex-col(); + width: 100%; + + &>h3 { + padding: 0.5rem 0 0.5rem 0.5rem; + } + } + + &__actions { + @include flex-col(); + + width: 100%; + align-items: center; + margin-bottom: 1rem; + } + + &__header { + display: flex; + flex-flow: row nowrap; + height: min-content; + justify-content: space-between; + align-items: flex-end; + + h3 { + color: col(text-darkest); + } + + &-actions { + display: flex; + flex-flow: row wrap; + justify-content: flex-end; + + button { + padding-top: 0.5rem; + padding-bottom: 0.5rem; + font-weight: normal; + margin-bottom: 0.25rem; + } + } + } + + &__container { + @include flex-col(); + flex-wrap: nowrap; + position: relative; + width: 100%; + max-width: 100%; + } + + &__none-available { + max-width: calc(100% - 2rem); + width: calc(100% - 2rem); + padding: 1rem; + border-radius: 0.1rem; + + &-message { + text-align: center; + } + + &:not(.show) { + display: none; + } + } + + &__list { + @include flex-col(); + position: relative; + width: 100%; + max-width: 100%; + flex-wrap: nowrap; + overflow-y: auto; + max-height: 150px; + + &-item { + @include flex-row(); + position: relative; + color: col(text-darkest); + padding: 0.5rem 0.5rem 0.5rem 1rem; + max-width: 100%; + + -webkit-box-shadow: inset 0px -1px 0px 0px rgba(0,0,0,0.75); + -moz-box-shadow: inset 0px -1px 0px 0px rgba(0,0,0,0.75); + box-shadow: inset 0px -1px 0px 0px rgba(0,0,0,0.75); + + .edit-icon:after { + @include fontawesome-icon(); + content: '\f304'; + color: col(accent-primary); + } + + .delete-icon:after { + @include fontawesome-icon(); + content: '\f1f8'; + color: col(accent-danger); + } + + .member-name { + @include wrap-words(); + pointer-events: auto; + font-weight: normal; + text-overflow: ellipsis; + overflow: hidden; + max-width: 80%; + + a { + @include wrap-words(); + } + } + + .action-buttons { + @include flex-row($gap: 1rem); + margin-left: auto; + flex-direction: row-reverse; + align-items: center; + justify-content: space-between; + + @include media("<tablet", "screen") { + &__text { + display: none; + } + } + } + } + } + } +} + +.organisation-collection { + @include flex-col(); + align-self: center; + width: var(--phenotype-article-lg-size); + max-width: var(--phenotype-article-lg-size); + + @include media(">desktop", "screen") { + width: var(--phenotype-article-sm-size); + max-width: var(--phenotype-article-sm-size); + } + + @include media("<desktop", "screen") { + width: var(--phenotype-article-lg-size); + max-width: var(--phenotype-article-lg-size); + } + + &__edit-icon:after { + @include fontawesome-icon(); + content: '\f304'; + color: col(accent-primary); + margin: 0 0 0 0.25rem; + } + + &__delete-icon:after { + @include fontawesome-icon(); + content: '\f1f8'; + color: col(accent-danger); + margin: 0 0 0 0.25rem; + } + + &__restore-icon:after { + @include fontawesome-icon(); + content: '\f0e2'; + color: col(accent-warning); + margin: 0 0 0 0.25rem; + } + + &__inner-container { + max-width: 100%; + padding: 0; + } + + &__none-available { + display: block; + padding: 1rem; + border-radius: 0.1rem; + background-color: col(accent-semi-transparent); + margin-bottom: 0.5rem; + + &-message { + text-align: center; + } + + &:not(.show) { + display: none; + } + } + + &__table-container { + @include flex-col(); + position: relative; + height: auto; + padding: 0.5rem; + margin-bottom: 0.5rem; + max-width: calc(100% - 1rem); + } + + &__tag { + padding: 0.2rem 1rem; + border-radius: 0.2rem; + + &.REQUESTED { background-color: var(--publication-requested-bg-color); }; + &.PENDING { background-color: var(--publication-pending-bg-color); }; + &.APPROVED { background-color: var(--publication-approved-bg-color); }; + &.REJECTED { background-color: var(--publication-rejected-bg-color); }; + &.NaN { background-color: var(--publication-undefined-bg-color); }; + } + + .overflow-table-constraint { + padding: 0.5rem; + position: relative; + width: calc(100% - 1rem); + max-width: 100%; + } + + &-table{ + &__wrapper > tbody > tr { + border-bottom: 1px solid col(accent-washed); + } + } +} + +.overflow-table-constraint { + max-width: 100%; +} diff --git a/CodeListLibrary_project/cll/static/scss/vendor/_datatables.scss b/CodeListLibrary_project/cll/static/scss/vendor/_datatables.scss index 3a57e7001..04dfb5013 100644 --- a/CodeListLibrary_project/cll/static/scss/vendor/_datatables.scss +++ b/CodeListLibrary_project/cll/static/scss/vendor/_datatables.scss @@ -98,7 +98,10 @@ visibility: hidden; } -.datatable-pagination a { +.datatable-pagination button { + padding: 0; + border: none; + background: none; cursor: pointer; border: 1px solid transparent; float: left; @@ -109,33 +112,33 @@ color: col(text-darkest); } -.datatable-pagination a:hover { +.datatable-pagination button:hover { background-color: col(accent-washed); } -.datatable-pagination .datatable-active a, -.datatable-pagination .datatable-active a:focus, -.datatable-pagination .datatable-active a:hover { +.datatable-pagination .datatable-active button, +.datatable-pagination .datatable-active button:focus, +.datatable-pagination .datatable-active button:hover { cursor: default; background-color: col(accent-washed); } -.datatable-pagination .datatable-ellipsis a, -.datatable-pagination .datatable-disabled a, -.datatable-pagination .datatable-disabled a:focus, -.datatable-pagination .datatable-disabled a:hover { +.datatable-pagination .datatable-ellipsis button, +.datatable-pagination .datatable-disabled button, +.datatable-pagination .datatable-disabled button:focus, +.datatable-pagination .datatable-disabled button:hover { cursor: default; pointer-events: none; } -.datatable-pagination .datatable-disabled a, -.datatable-pagination .datatable-disabled a:focus, -.datatable-pagination .datatable-disabled a:hover { +.datatable-pagination .datatable-disabled button, +.datatable-pagination .datatable-disabled button:focus, +.datatable-pagination .datatable-disabled button:hover { cursor: not-allowed; opacity: 0.4; } -.datatable-pagination .datatable-pagination a { +.datatable-pagination .datatable-pagination button { font-weight: bold; } @@ -184,8 +187,12 @@ text-align: left; } -.datatable-table th a { +.datatable-table th button { + padding: 0; + border: none; + background: none; text-decoration: none; + text-align: left; color: inherit; } diff --git a/CodeListLibrary_project/cll/static/scss/vendor/_gridjs.scss b/CodeListLibrary_project/cll/static/scss/vendor/_gridjs.scss deleted file mode 100644 index 857b5d8f6..000000000 --- a/CodeListLibrary_project/cll/static/scss/vendor/_gridjs.scss +++ /dev/null @@ -1,377 +0,0 @@ -@import "../_methods"; -@import "../_variables"; -@import "../_media"; -@import "../_utils"; - -// Color change if need for future -$color_1: #000; -$color_2: #3d4044; -$color_3: #3c4257; -$color_4: #6b7280; -$background-color_1: transparent; -$background-color_2: #fff; -$background-color_3: #f7f7f7; -$background-color_4: #f9fafb; -$background-color_5: #e5e7eb; -$background-color_6: #ebf5ff; -$background-color_7: #9bc2f7; -$border-color_1: #e5e7eb; -$border-color_2: #9bc2f7; - -@keyframes shimmer { - to { - transform: translateX(100%); - } -} -.gridjs-footer { - button { - background-color: $background-color_1; - background-image: none; - border: none; - cursor: pointer; - margin: 0; - outline: none; - padding: 0; - } - background-color: $background-color_2; - border-bottom-width: 1px; - border-color: $border-color_1; - border-radius: 0 0 8px 8px; - border-top: 1px solid #e5e7eb; - box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.26); - display: block; - padding: 12px 24px; - position: relative; - width: 100%; - z-index: 5; - &:empty { - border: none; - padding: 0; - } -} -.gridjs-head { - button { - background-color: $background-color_1; - background-image: none; - border: none; - cursor: pointer; - margin: 0; - outline: none; - padding: 0; - } - margin-bottom: 5px; - padding: 5px 1px; - width: 100%; - &:after { - clear: both; - content: ""; - display: block; - } - &:empty { - border: none; - padding: 0; - } -} -.gridjs-temp { - position: relative; -} -.gridjs-container { - color: $color_1; - display: inline-block; - overflow: hidden; - padding: 2px; - position: relative; - z-index: 0; -} -input.gridjs-input { - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; - background-color: $background-color_2; - border: 1px solid #d2d6dc; - border-radius: 5px; - font-size: 14px; - line-height: 1.45; - outline: none; - padding: 10px 13px; - &:focus { - border-color: $border-color_2; - box-shadow: 0 0 0 3px rgba(149, 189, 243, 0.5); - } -} -.gridjs-pagination { - color: $color_2; - &:after { - clear: both; - content: ""; - display: block; - } - .gridjs-summary { - float: left; - margin-top: 5px; - } - .gridjs-pages { - float: right; - button { - background-color: $background-color_2; - border: 1px solid #d2d6dc; - border-right: none; - outline: none; - padding: 5px 14px; - -webkit-user-select: none; - -moz-user-select: none; - user-select: none; - &:focus { - border-right: 1px solid #d2d6dc; - box-shadow: 0 0 0 2px rgba(149, 189, 243, 0.5); - margin-right: -1px; - position: relative; - } - &:hover { - background-color: $background-color_3; - color: $color_3; - outline: none; - &:disabled { - background-color: $background-color_2; - color: $color_4; - cursor: default; - } - } - &:disabled { - background-color: $background-color_2; - color: $color_4; - cursor: default; - } - &:last-child { - border-bottom-right-radius: 6px; - border-right: 1px solid #d2d6dc; - border-top-right-radius: 6px; - &:focus { - margin-right: 0; - } - } - &:first-child { - border-bottom-left-radius: 6px; - border-top-left-radius: 6px; - } - } - button[disabled] { - background-color: $background-color_2; - color: $color_4; - cursor: default; - } - button.gridjs-spread { - background-color: $background-color_2; - box-shadow: none; - cursor: default; - } - button.gridjs-currentPage { - background-color: $background-color_3; - font-weight: 700; - } - } -} -button.gridjs-sort { - background-color: $background-color_1; - background-position-x: center; - background-repeat: no-repeat; - background-size: contain; - border: none; - cursor: pointer; - float: right; - height: 24px; - margin: 0; - outline: none; - padding: 0; - width: 13px; - &:focus { - outline: none; - } -} -button.gridjs-sort-neutral { - background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MDEuOTk4IiBoZWlnaHQ9IjQwMS45OTgiIHN0eWxlPSJlbmFibGUtYmFja2dyb3VuZDpuZXcgMCAwIDQwMS45OTggNDAxLjk5OCIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHBhdGggZD0iTTczLjA5MiAxNjQuNDUyaDI1NS44MTNjNC45NDkgMCA5LjIzMy0xLjgwNyAxMi44NDgtNS40MjQgMy42MTMtMy42MTYgNS40MjctNy44OTggNS40MjctMTIuODQ3cy0xLjgxMy05LjIyOS01LjQyNy0xMi44NUwyMTMuODQ2IDUuNDI0QzIxMC4yMzIgMS44MTIgMjA1Ljk1MSAwIDIwMC45OTkgMHMtOS4yMzMgMS44MTItMTIuODUgNS40MjRMNjAuMjQyIDEzMy4zMzFjLTMuNjE3IDMuNjE3LTUuNDI0IDcuOTAxLTUuNDI0IDEyLjg1IDAgNC45NDggMS44MDcgOS4yMzEgNS40MjQgMTIuODQ3IDMuNjIxIDMuNjE3IDcuOTAyIDUuNDI0IDEyLjg1IDUuNDI0ek0zMjguOTA1IDIzNy41NDlINzMuMDkyYy00Ljk1MiAwLTkuMjMzIDEuODA4LTEyLjg1IDUuNDIxLTMuNjE3IDMuNjE3LTUuNDI0IDcuODk4LTUuNDI0IDEyLjg0N3MxLjgwNyA5LjIzMyA1LjQyNCAxMi44NDhMMTg4LjE0OSAzOTYuNTdjMy42MjEgMy42MTcgNy45MDIgNS40MjggMTIuODUgNS40MjhzOS4yMzMtMS44MTEgMTIuODQ3LTUuNDI4bDEyNy45MDctMTI3LjkwNmMzLjYxMy0zLjYxNCA1LjQyNy03Ljg5OCA1LjQyNy0xMi44NDggMC00Ljk0OC0xLjgxMy05LjIyOS01LjQyNy0xMi44NDctMy42MTQtMy42MTYtNy44OTktNS40Mi0xMi44NDgtNS40MnoiLz48L3N2Zz4="); - background-position-y: center; - opacity: 0.3; -} -button.gridjs-sort-asc { - background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyOTIuMzYyIiBoZWlnaHQ9IjI5Mi4zNjEiIHN0eWxlPSJlbmFibGUtYmFja2dyb3VuZDpuZXcgMCAwIDI5Mi4zNjIgMjkyLjM2MSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHBhdGggZD0iTTI4Ni45MzUgMTk3LjI4NyAxNTkuMDI4IDY5LjM4MWMtMy42MTMtMy42MTctNy44OTUtNS40MjQtMTIuODQ3LTUuNDI0cy05LjIzMyAxLjgwNy0xMi44NSA1LjQyNEw1LjQyNCAxOTcuMjg3QzEuODA3IDIwMC45MDQgMCAyMDUuMTg2IDAgMjEwLjEzNHMxLjgwNyA5LjIzMyA1LjQyNCAxMi44NDdjMy42MjEgMy42MTcgNy45MDIgNS40MjUgMTIuODUgNS40MjVoMjU1LjgxM2M0Ljk0OSAwIDkuMjMzLTEuODA4IDEyLjg0OC01LjQyNSAzLjYxMy0zLjYxMyA1LjQyNy03Ljg5OCA1LjQyNy0xMi44NDdzLTEuODE0LTkuMjMtNS40MjctMTIuODQ3eiIvPjwvc3ZnPg=="); - background-position-y: 35%; - background-size: 10px; -} -button.gridjs-sort-desc { - background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyOTIuMzYyIiBoZWlnaHQ9IjI5Mi4zNjIiIHN0eWxlPSJlbmFibGUtYmFja2dyb3VuZDpuZXcgMCAwIDI5Mi4zNjIgMjkyLjM2MiIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHBhdGggZD0iTTI4Ni45MzUgNjkuMzc3Yy0zLjYxNC0zLjYxNy03Ljg5OC01LjQyNC0xMi44NDgtNS40MjRIMTguMjc0Yy00Ljk1MiAwLTkuMjMzIDEuODA3LTEyLjg1IDUuNDI0QzEuODA3IDcyLjk5OCAwIDc3LjI3OSAwIDgyLjIyOGMwIDQuOTQ4IDEuODA3IDkuMjI5IDUuNDI0IDEyLjg0N2wxMjcuOTA3IDEyNy45MDdjMy42MjEgMy42MTcgNy45MDIgNS40MjggMTIuODUgNS40MjhzOS4yMzMtMS44MTEgMTIuODQ3LTUuNDI4TDI4Ni45MzUgOTUuMDc0YzMuNjEzLTMuNjE3IDUuNDI3LTcuODk4IDUuNDI3LTEyLjg0NyAwLTQuOTQ4LTEuODE0LTkuMjI5LTUuNDI3LTEyLjg1eiIvPjwvc3ZnPg=="); - background-position-y: 65%; - background-size: 10px; -} -table.gridjs-table { - border-collapse: collapse; - display: table; - margin: 0; - max-width: 100%; - overflow: auto; - padding: 0; - table-layout: fixed; - text-align: left; - width: 100%; -} -.gridjs-tbody { - background-color: $background-color_2; -} -td.gridjs-td { - background-color: $background-color_2; - border: 1px solid #e5e7eb; - box-sizing: content-box; - padding: 12px 24px; - &:first-child { - border-left: none; - } - &:last-child { - border-right: none; - } -} -td.gridjs-message { - text-align: center; -} -th.gridjs-th { - background-color: $background-color_4; - border: 1px solid #e5e7eb; - border-top: none; - box-sizing: border-box; - color: $color_4; - outline: none; - padding: 14px 24px; - position: relative; - -webkit-user-select: none; - -moz-user-select: none; - user-select: none; - vertical-align: middle; - white-space: nowrap; - .gridjs-th-content { - float: left; - overflow: hidden; - text-overflow: ellipsis; - width: 100%; - } - &:last-child { - border-right: none; - } -} -th.gridjs-th-sort { - cursor: pointer; - .gridjs-th-content { - width: calc(100% - 15px); - } - &:focus { - background-color: $background-color_5; - } - &:hover { - background-color: $background-color_5; - } -} -th.gridjs-th-fixed { - box-shadow: 0 1px 0 0 #e5e7eb; - position: sticky; -} -@supports (-moz-appearance: none) { - th.gridjs-th-fixed { - box-shadow: 0 0 0 1px #e5e7eb; - } -} - -th.gridjs-th { - &:first-child { - border-left: none; - } -} -.gridjs-tr { - border: none; - &:last-child { - td { - border-bottom: 0; - } - } -} -.gridjs-tr-selected { - td { - background-color: $background-color_6; - } -} -.gridjs { - * { - box-sizing: border-box; - } - &:after { - box-sizing: border-box; - } - &:before { - box-sizing: border-box; - } -} -.gridjs-wrapper { - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - border-color: $border-color_1; - border-radius: 8px 8px 0 0; - border-top-width: 1px; - box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.26); - display: block; - overflow: auto; - position: relative; - width: 100%; - z-index: 1; - &:nth-last-of-type(2) { - border-bottom-width: 1px; - border-radius: 8px; - } -} -.gridjs-search { - float: left; -} -.gridjs-search-input { - width: 250px; -} -.gridjs-loading-bar { - background-color: $background-color_2; - opacity: 0.5; - z-index: 10; - bottom: 0; - left: 0; - position: absolute; - right: 0; - top: 0; - &:after { - bottom: 0; - left: 0; - position: absolute; - right: 0; - top: 0; - animation: shimmer 2s infinite; - background-image: linear-gradient( - 90deg, - hsla(0, 0%, 80%, 0), - hsla(0, 0%, 80%, 0.2) 20%, - hsla(0, 0%, 80%, 0.5) 60%, - hsla(0, 0%, 80%, 0) - ); - content: ""; - transform: translateX(-100%); - } -} -.gridjs-td { - .gridjs-checkbox { - cursor: pointer; - display: block; - margin: auto; - } -} -.gridjs-resizable { - bottom: 0; - position: absolute; - right: 0; - top: 0; - width: 5px; - &:hover { - background-color: $background-color_7; - cursor: ew-resize; - } -} diff --git a/CodeListLibrary_project/cll/static/svg/organisation_icon.svg b/CodeListLibrary_project/cll/static/svg/organisation_icon.svg new file mode 100644 index 000000000..9bad8fbc5 --- /dev/null +++ b/CodeListLibrary_project/cll/static/svg/organisation_icon.svg @@ -0,0 +1,74 @@ +<svg + width="40" + height="40" + viewBox="0 0 40 40" + fill="none" + xmlns="http://www.w3.org/2000/svg"> + <path + d="m 15.327037,25.004723 v -1.112161 c 0,-0.589946 -0.234333,-1.155702 -0.651504,-1.572817 -0.417116,-0.417172 -0.982872,-0.651504 -1.572818,-0.651504 H 8.6540732 c -0.5899456,0 -1.1557017,0.234332 -1.5728175,0.651504 -0.4171714,0.417115 -0.6515036,0.982871 -0.6515036,1.572817 v 1.112161" + fill="#ccf3ee" + id="path526" + style="stroke-width:0.55608" /> + <path + d="m 15.327037,25.004723 v -1.112161 c 0,-0.589946 -0.234333,-1.155702 -0.651504,-1.572817 -0.417116,-0.417172 -0.982872,-0.651504 -1.572818,-0.651504 H 8.6540732 c -0.5899456,0 -1.1557017,0.234332 -1.5728175,0.651504 -0.4171714,0.417115 -0.6515036,0.982871 -0.6515036,1.572817 v 1.112161" + stroke="#5c5c5c" + stroke-width="1.11216" + stroke-linecap="round" + stroke-linejoin="round" + id="path528" /> + <path + d="m 10.878394,19.44392 c 1.228437,0 2.224321,-0.995884 2.224321,-2.224321 0,-1.228437 -0.995884,-2.224322 -2.224321,-2.224322 -1.2284366,0 -2.2243208,0.995885 -2.2243208,2.224322 0,1.228437 0.9958842,2.224321 2.2243208,2.224321 z" + fill="#ccf3ee" + stroke="#5c5c5c" + stroke-width="1.11216" + stroke-linecap="round" + stroke-linejoin="round" + id="path530" /> + <path + d="m 33.570248,25.004723 v -1.112161 c 0,-0.589946 -0.234332,-1.155702 -0.651504,-1.572817 -0.417116,-0.417172 -0.982872,-0.651504 -1.572817,-0.651504 h -4.448642 c -0.589946,0 -1.155702,0.234332 -1.572818,0.651504 -0.417171,0.417115 -0.651504,0.982871 -0.651504,1.572817 v 1.112161" + fill="#ccf3ee" + id="path532" + style="stroke-width:0.55608" /> + <path + d="m 33.570248,25.004723 v -1.112161 c 0,-0.589946 -0.234332,-1.155702 -0.651504,-1.572817 -0.417116,-0.417172 -0.982872,-0.651504 -1.572817,-0.651504 h -4.448642 c -0.589946,0 -1.155702,0.234332 -1.572818,0.651504 -0.417171,0.417115 -0.651504,0.982871 -0.651504,1.572817 v 1.112161" + stroke="#5c5c5c" + stroke-width="1.11216" + stroke-linecap="round" + stroke-linejoin="round" + id="path534" /> + <path + d="m 29.121606,19.44392 c 1.228437,0 2.224321,-0.995884 2.224321,-2.224321 0,-1.228437 -0.995884,-2.224322 -2.224321,-2.224322 -1.228437,0 -2.224321,0.995885 -2.224321,2.224322 0,1.228437 0.995884,2.224321 2.224321,2.224321 z" + fill="#ccf3ee" + stroke="#5c5c5c" + stroke-width="1.11216" + stroke-linecap="round" + stroke-linejoin="round" + id="path536" /> + <path + d="M 28,29 V 27 C 28,25.9391 27.5786,24.9217 26.8284,24.1716 26.0783,23.4214 25.0609,23 24,23 h -8 c -1.0609,0 -2.0783,0.4214 -2.8284,1.1716 C 12.4214,24.9217 12,25.9391 12,27 v 2" + fill="#ccf3ee" + id="path182" /> + <path + d="m 28.553279,29.200793 v -2.186735 c 0,-1.159954 -0.450543,-2.272347 -1.252627,-3.092481 -0.801977,-0.820244 -1.88974,-1.280989 -3.024012,-1.280989 h -8.55328 c -1.134272,0 -2.222035,0.460745 -3.024012,1.280989 -0.802084,0.820134 -1.252627,1.932527 -1.252627,3.092481 v 2.186735" + stroke="#5c5c5c" + stroke-width="2.1624" + stroke-linecap="round" + stroke-linejoin="round" + id="path1286" + style="stroke:#ffffff;stroke-opacity:1" /> + <path + d="M 28,29 V 27 C 28,25.9391 27.5786,24.9217 26.8284,24.1716 26.0783,23.4214 25.0609,23 24,23 h -8 c -1.0609,0 -2.0783,0.4214 -2.8284,1.1716 C 12.4214,24.9217 12,25.9391 12,27 v 2" + stroke="#5c5c5c" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + id="path184" /> + <path + d="m 20,19 c 2.2091,0 4,-1.7909 4,-4 0,-2.2091 -1.7909,-4 -4,-4 -2.2091,0 -4,1.7909 -4,4 0,2.2091 1.7909,4 4,4 z" + fill="#ccf3ee" + stroke="#5c5c5c" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + id="path186" /> +</svg> diff --git a/CodeListLibrary_project/cll/templates/400.html b/CodeListLibrary_project/cll/templates/400.html index b9de52053..e11d7a8a0 100644 --- a/CodeListLibrary_project/cll/templates/400.html +++ b/CodeListLibrary_project/cll/templates/400.html @@ -1,14 +1,17 @@ {% extends "base.html" %} + {% load static %} +{% load entity_renderer %} {% block title %}| Bad request{% endblock title %} {% block container %} + {% get_brand_base_title request.BRAND_OBJECT as base_page_title %} <header class="main-header search-banner"> <div class="main-header__inner-container main-header__inner-container--constrained main-header__inner-container--centred"> <div class="search-banner__header search-banner__header--pad-bottom-2"> - <h2 class="search-banner__title">Concept Library</h2> + <h2 class="search-banner__title">{{ base_page_title }}</h2> </div> </div> </header> diff --git a/CodeListLibrary_project/cll/templates/403.html b/CodeListLibrary_project/cll/templates/403.html index 7520173c3..a0a574002 100644 --- a/CodeListLibrary_project/cll/templates/403.html +++ b/CodeListLibrary_project/cll/templates/403.html @@ -1,14 +1,17 @@ {% extends "base.html" %} + {% load static %} +{% load entity_renderer %} {% block title %}| Permission denied{% endblock title %} {% block container %} + {% get_brand_base_title request.BRAND_OBJECT as base_page_title %} <header class="main-header search-banner"> <div class="main-header__inner-container main-header__inner-container--constrained main-header__inner-container--centred"> <div class="search-banner__header search-banner__header--pad-bottom-2"> - <h2 class="search-banner__title">Concept Library</h2> + <h2 class="search-banner__title">{{ base_page_title }}</h2> </div> </div> </header> @@ -20,13 +23,13 @@ <h2>403: Forbidden</h2> <p>Apologies, but it seems as if you're not allowed to access this page.</p> {% else %} {% if exception == "ERR_403_PERM" %} - <h2>You don't have permission to view this</h2> + <h2>You don't have permission to view this item.</h2> <p>It looks like you're trying to access content that's not published yet.</p> <p>You'll be able to view this content once it's been published by the author.</p> {% elif exception == "ERR_403_GATEWAY" %} <h2>403: Forbidden</h2> - <p>You cannot complete this action whilst inside of the SAIL Gateway</p> - <p>Please visit the site outside of the gateway to continue with this action</p> + <p>You cannot complete this action whilst inside of the SAIL Gateway.</p> + <p>Please visit the site outside of the gateway to continue with this action.</p> {% else %} <h2>403: Forbidden</h2> <p>It seems as if you're not allowed to access this page, please get in touch if you don't think this is correct.</p> diff --git a/CodeListLibrary_project/cll/templates/404.html b/CodeListLibrary_project/cll/templates/404.html index 8733851bf..d001b5bec 100644 --- a/CodeListLibrary_project/cll/templates/404.html +++ b/CodeListLibrary_project/cll/templates/404.html @@ -1,14 +1,17 @@ {% extends "base.html" %} + {% load static %} +{% load entity_renderer %} {% block title %}| Page not found{% endblock title %} {% block container %} + {% get_brand_base_title request.BRAND_OBJECT as base_page_title %} <header class="main-header search-banner"> <div class="main-header__inner-container main-header__inner-container--constrained main-header__inner-container--centred"> <div class="search-banner__header search-banner__header--pad-bottom-2"> - <h2 class="search-banner__title">Concept Library</h2> + <h2 class="search-banner__title">{{ base_page_title }}</h2> </div> </div> </header> diff --git a/CodeListLibrary_project/cll/templates/500.html b/CodeListLibrary_project/cll/templates/500.html index 0974ad9ca..e59c8d989 100644 --- a/CodeListLibrary_project/cll/templates/500.html +++ b/CodeListLibrary_project/cll/templates/500.html @@ -1,22 +1,25 @@ {% extends "base.html" %} + {% load static %} +{% load entity_renderer %} {% block title %}| Page unavailable{% endblock title %} {% block container %} + {% get_brand_base_title request.BRAND_OBJECT as base_page_title %} <header class="main-header search-banner"> <div class="main-header__inner-container main-header__inner-container--constrained main-header__inner-container--centred"> <div class="search-banner__header search-banner__header--pad-bottom-2"> - <h2 class="search-banner__title">Concept Library</h2> + <h2 class="search-banner__title">{{ base_page_title }}</h2> </div> </div> </header> <main class="main-content main-content--inner-padding"> <div class="main-content__inner-container main-content__inner-container--constrained main-content__inner-container--centred"> - <h2>500: Page unavailable</h2> - <p>We're sorry but the requested page is currently unavailable.</p> + <h2>500: Server Error</h2> + <p>We're sorry but it looks like an error has occurred.</p> <p>Click <a href="javascript:history.back()">here</a> to go back.</p> </div> </main> diff --git a/CodeListLibrary_project/cll/templates/base.html b/CodeListLibrary_project/cll/templates/base.html index 62487b8a3..685e16de5 100644 --- a/CodeListLibrary_project/cll/templates/base.html +++ b/CodeListLibrary_project/cll/templates/base.html @@ -1,9 +1,9 @@ +{% load i18n %} {% load static %} {% load compress %} {% load sass_tags %} -{% load entity_renderer %} -{% load i18n %} {% load cl_extras %} +{% load entity_renderer %} {% if SHOW_COOKIE_ALERT %} {% load cookielaw_tags %} @@ -11,18 +11,17 @@ <!DOCTYPE html> {% get_current_language as LANGUAGE_CODE %} -{% if request.CURRENT_BRAND|length %} - <html data-brand="{{ request.CURRENT_BRAND }}" lang="{{ LANGUAGE_CODE }}"> -{% else %} - <html data-brand="none" lang="{{ LANGUAGE_CODE }}"> -{% endif %} +<html data-brand="{% if request.CURRENT_BRAND|length %}{{ request.CURRENT_BRAND }}{% else %}none{% endif %}" lang="{{ LANGUAGE_CODE }}" {% block htmltag %}{% endblock htmltag %}> <head> <!-- Metadata incl. robots --> + {% get_brand_base_desc request.BRAND_OBJECT as base_desc %} + {% get_brand_base_title request.BRAND_OBJECT as base_page_title %} <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <meta name="description" content="{% block description %}{% if not request.CURRENT_BRAND|length %}The concept library is a system for storing, managing, sharing, and documenting clinical code lists in health research.{% endif %}{% endblock description %}"> - <meta name="keywords" content="Concept Library{% if request.CURRENT_BRAND|length and request.BRAND_OBJECT.site_title is not none %}, {{ request.BRAND_OBJECT.site_title }}{% endif %}{% block keywords %}{% endblock keywords %}"> + <meta name="description" content="{% block description %}{{ base_desc }}{% endblock description %}"> + <meta name="keywords" content="Concept Library{% if request.CURRENT_BRAND|length %}, {{ base_page_title }}{% endif %}{% block keywords %}{% endblock keywords %}"> + {% block indexing_robots %} {% if IS_DEMO or IS_DEVELOPMENT_PC or IS_HDRUK_EXT == '0' or stop_robot_indexing %} <meta name="robots" content="noindex, nofollow"> @@ -46,23 +45,22 @@ <link rel="icon" type="image/png" sizes="32x32" href="{% static base_icons.favicon %}"> <!-- URL embedding --> - {% get_brand_base_title request.BRAND_OBJECT as base_page_title %} {% block embedding_wrapper %} {% if not hide_embedding %} - {% get_brand_base_embed_desc request.BRAND_OBJECT as base_desc %} + {% get_brand_base_embed_desc request.BRAND_OBJECT as base_embed_desc %} {% get_brand_base_embed_img request.BRAND_OBJECT as base_embed_img %} <!-- Open Graph / Facebook --> <meta property="og:type" content="website"> <meta property="og:url" content="{{ request.build_absolute_uri }}"> <meta property="og:title" content="{{ base_page_title }}"> - <meta property="og:description" content="{{ base_desc }}"> + <meta property="og:description" content="{{ base_embed_desc }}"> <meta property="og:image" content="{% static base_embed_img %}"> <!-- Twitter --> <meta property="twitter:card" content="summary_large_image"> <meta property="twitter:url" content="{{ request.build_absolute_uri }}"> <meta property="twitter:title" content="{{ base_page_title }}"> - <meta property="twitter:description" content="{{ base_desc }}"> + <meta property="twitter:description" content="{{ base_embed_desc }}"> <meta property="twitter:image" content="{% static base_embed_img %}"> {% endif %} {% endblock embedding_wrapper %} @@ -103,13 +101,15 @@ {% include "components/base/footer.html" %} {% endif %} {% endblock footer_wrapper %} - + {% block scripts %} <!-- Scripts --> {% endblock scripts %} - {% if SHOW_COOKIE_ALERT %} - {% include "components/cookies/banner.html" %} - {% endif %} + {% block cookie_wrapper %} + {% if SHOW_COOKIE_ALERT %} + {% include "components/cookies/banner.html" %} + {% endif %} + {% endblock cookie_wrapper %} </body> </html> diff --git a/CodeListLibrary_project/cll/templates/cl-docs/contact-us.html b/CodeListLibrary_project/cll/templates/cl-docs/contact-us.html index a19485a5e..09de962b5 100644 --- a/CodeListLibrary_project/cll/templates/cl-docs/contact-us.html +++ b/CodeListLibrary_project/cll/templates/cl-docs/contact-us.html @@ -16,9 +16,11 @@ {% endcompress %} <!-- Main --> + {% get_brand_map_rules as brand_mapping %} + {% get_brand_base_title request.BRAND_OBJECT as base_page_title %} <header class="main-header banner"> <div class="main-header__inner-container main-header__inner-container--constrained-no-pad main-header__inner-container--centred"> - <div class="banner__container container--desktop-lg"> + <div class="banner__container"> <h2 class="banner__title">Contact Us</h2> <p class="banner__description"> Fill out the form below and a member of our team will get back to you as soon as @@ -35,10 +37,10 @@ <h2 class="banner__title">Contact Us</h2> <div class="contactus-container__info-container"> <div class="contactus-container__info-container__content"> <div class="contactus-container__info-container__section"> - <h2>Submitting Phenotypes</h2> + <h2>Submitting {{ brand_mapping.phenotype }}s</h2> <p> Please contact us using the form below to request a user account to be able - to submit phenotypes. + to submit {{ brand_mapping.phenotype }}s. </p> <br/> <p> @@ -53,7 +55,7 @@ <h2>Submitting Phenotypes</h2> <h2>Issues and Feature Requests</h2> <p> If you would like to report a bug or give feedback or suggestions for the - Phenotype Library website, please submit an issue to our + {{ base_page_title }} website, please submit an issue to our <a href="https://github.com/SwanseaUniversityMedical/concept-library/issues"> Github Repository </a> @@ -73,7 +75,7 @@ <h2>General Enquiries</h2> By submitting your enquiry you are consenting to your data being held by the SAIL Databank, Swansea University. Your data will be used for the purposes of dealing with your enquiry and sending you relevant information about the - Concept Library. + {{ base_page_title }}. </p> </div> </div> diff --git a/CodeListLibrary_project/cll/templates/cl-docs/terms-conditions.html b/CodeListLibrary_project/cll/templates/cl-docs/terms-conditions.html index a059efc1d..ab6c8cdfd 100644 --- a/CodeListLibrary_project/cll/templates/cl-docs/terms-conditions.html +++ b/CodeListLibrary_project/cll/templates/cl-docs/terms-conditions.html @@ -67,7 +67,7 @@ <h3 class="subheader"> <li>From time to time this website may also include links to other websites. These links are provided for your convenience to provide further information. They do not signify that we endorse the website(s). We have no responsibility for the content of the linked website(s).</li> <li>Your use of this website and any dispute arising out of such use of the website is subject to the laws of England, Northern Ireland, Scotland and Wales.</li> <li> - User-submitted content held in the Library is openly licensed for non-commercial use via + User-submitted content held in the {{ base_page_title }} is openly licensed for non-commercial use via <a href='https://creativecommons.org/licenses/by-sa/4.0/'> CC BY-SA 4.0 </a>. diff --git a/CodeListLibrary_project/cll/templates/clinicalcode/about/reference_data.html b/CodeListLibrary_project/cll/templates/clinicalcode/about/reference_data.html index 4788791f8..9460e7f0a 100644 --- a/CodeListLibrary_project/cll/templates/clinicalcode/about/reference_data.html +++ b/CodeListLibrary_project/cll/templates/clinicalcode/about/reference_data.html @@ -17,6 +17,7 @@ <!-- Dependencies --> {% compress js %} + <script type="text/javascript" src="{% static 'js/clinicalcode/components/dropdown.js' %}"></script> <script type="module" src="{% static 'js/clinicalcode/services/referenceDataService.js' %}"></script> {% endcompress %} @@ -26,40 +27,62 @@ {% endcompress %} <!-- Main --> + {% get_brand_map_rules as brand_mapping %} + {% get_brand_base_title request.BRAND_OBJECT as base_page_title %} <header class="main-header banner"> <div class="main-header__inner-container main-header__inner-container--constrained-no-pad main-header__inner-container--centred"> <div class="banner__container"> <h2 class="banner__title">Reference Data</h2> <p class="banner__description"> - Look up values for phenotype fields. + Look up values for {{ brand_mapping.phenotype }} fields. </p> <div class="banner__cards"> <div class="hstack-cards-banner hstack-cards-banner-justify-content-space-evenly slim-scrollbar"> {% url 'api:root' as api_url %} - <article class="referral-card referral-card referral-card-fill-area-evenly bright-accent referral-card-shadow referral-card-border-radius referral-card-no-margin"> + <article + class="referral-card referral-card referral-card-fill-area-evenly bright-accent referral-card-shadow referral-card-border-radius referral-card-no-margin" + data-target="{{ api_url }}" + onclick="redirectToTarget(this, event);" + tabindex="0" + role="button" + aria-label="Visit API documentation"> <div class="referral-card__header"> - <a class="referral-card__title" href="{{ api_url }}">API Documentation<span class="referral-card__title-icon"></span></a> + <p class="referral-card__title" href="{{ api_url }}">API Documentation<span class="referral-card__title-icon"></span></p> </div> <div class="referral-card__body"> <p>View the API documentation.</p> </div> </article> {% url 'create_phenotype' as create_url %} - <article class="referral-card referral-card referral-card-fill-area-evenly bright-accent referral-card-shadow referral-card-border-radius referral-card-no-margin"> - <div class="referral-card__header"> - <a class="referral-card__title" href="{{ create_url }}">Create a Phenotype<span class="referral-card__title-icon"></span></a> - </div> - <div class="referral-card__body"> - <p>Start here to contribute to the Library.</p> - </div> - </article> - {% url 'search_phenotypes' as search_url %} - <article class="referral-card referral-card referral-card-fill-area-evenly bright-accent referral-card-shadow referral-card-border-radius referral-card-no-margin"> + {% if USER_CREATE_CONTEXT %} + <article + class="referral-card referral-card referral-card-fill-area-evenly bright-accent referral-card-shadow referral-card-border-radius referral-card-no-margin" + data-target="{{ create_url }}" + onclick="redirectToTarget(this, event);" + tabindex="0" + role="button" + aria-label="Create a {{ brand_mapping.phenotype }}"> + <div class="referral-card__header"> + <p class="referral-card__title" href="{{ create_url }}">Create a {{ brand_mapping.phenotype }}<span class="referral-card__title-icon"></span></p> + </div> + <div class="referral-card__body"> + <p>Start here to contribute to the {{ base_page_title }}.</p> + </div> + </article> + {% endif %} + {% url 'search_entities' as search_url %} + <article + class="referral-card referral-card referral-card-fill-area-evenly bright-accent referral-card-shadow referral-card-border-radius referral-card-no-margin" + data-target="{{ search_url }}" + onclick="redirectToTarget(this, event);" + tabindex="0" + role="button" + aria-label="Visit {{ brand_mapping.phenotype }} Search"> <div class="referral-card__header"> - <a class="referral-card__title" href="{{ search_url }}">Search Phenotypes<span class="referral-card__title-icon"></span></a> + <p class="referral-card__title" href="{{ search_url }}">Search {{ brand_mapping.phenotype }}s<span class="referral-card__title-icon"></span></p> </div> <div class="referral-card__body"> - <p>Find Phenotypes within the Library.</p> + <p>Find {{ brand_mapping.phenotype }}s within the {{ base_page_title }}.</p> </div> </article> </div> @@ -71,45 +94,36 @@ <h2 class="banner__title">Reference Data</h2> <main class="main-content"> <div class="main-content__inner-container main-content__inner-container--constrained main-content__inner-container--centred"> <article class="reference-collection"> - <section class="reference-collection__inner-container" id="tags"> - {% if tags %} - {% to_json_script tags data-owner="reference-data-service" name="tags" desc-type="text/json" %} - {% endif %} - <h3>Tags</h3> - <p>Optional keywords helping to categorize this content.</p> - <div class="reference-collection__table-container constrained-codelist-table" id="tags-area"> - - </div> - </section> - <section class="reference-collection__inner-container" id="collections"> - {% if collections %} - {% to_json_script collections data-owner="reference-data-service" name="collections" desc-type="text/json" %} - {% endif %} - <h3>Collections</h3> - <p>List of content collections this phenotype belongs to.</p> - <div class="reference-collection__table-container constrained-codelist-table" id="collections-area"> - + {% if templates %} + <div class="selection-group"> + <p class="selection-group__title">Template:</p> + <select + id="template-selector" + placeholder-text="Select Template" + data-class="option" + data-element="dropdown"> + {% for key, value in templates.items %} + {% if forloop.first %} + <option + value="{{ value.id }}" + class="dropdown-selection__list-item" + selected> + {{ key }} + </option> + {% else %} + <option + value="{{ value.id }}" + class="dropdown-selection__list-item"> + {{ key }} + </option> + {% endif %} + {% endfor %} + </select> </div> - </section> - <section class="reference-collection__inner-container" id="coding_system"> - {% if coding_system %} - {% to_json_script coding_system data-owner="reference-data-service" name="coding_system" desc-type="text/json" %} - {% endif %} - <h3>Coding Systems</h3> - <p>Clinical coding system(s) that relate to Phenotypes.</p> - <div class="reference-collection__table-container constrained-codelist-table" id="coding-systems-area"> - - </div> - </section> - <section class="reference-collection__inner-container" id="data_sources"> - {% if data_sources %} - {% to_json_script data_sources data-owner="reference-data-service" name="data_sources" desc-type="text/json" %} - {% endif %} - <h3>Data Sources</h3> - <p>Data sources the phenotype creators have run this phenotype against; or view as appropriate to use this phenotype for.</p> - <div class="reference-collection__table-container constrained-codelist-table" id="data-sources-area"> + {% endif %} - </div> + <section class="reference-collection__inner-container" id="template-fields"> + {% to_json_script default_data data-owner="reference-data-service" name="default-data" desc-type="text/json" %} </section> {% if ontology_groups %} @@ -117,7 +131,7 @@ <h3>Data Sources</h3> {% to_json_script ontology_groups data-owner="reference-data-service" name="ontology-groups" desc-type="text/json" %} <h3>Ontology</h3> - <p>A set of taggable categories and concepts that describes a Phenotype.</p> + <p>A set of taggable categories and concepts that describes a {{ brand_mapping.phenotype }}.</p> <div class="reference-collection__table-container constrained-codelist-table" id="ontology-area" type="tree"> <div class="tab-view" id="tab-view"> @@ -134,6 +148,19 @@ <h3>Ontology</h3> </article> </div> + + <!-- Templates --> + <template data-name="reference-data"> + <section class="reference-collection__inner-container" id="tags"> + <h3>${name}</h3> + <p>${description}</p> + <div + id="ref-container" + class="reference-collection__table-container constrained-codelist-table"> + + </div> + </section> + </template> </main> {% endblock container %} diff --git a/CodeListLibrary_project/cll/templates/clinicalcode/adminTemp/admin_temp_tool.html b/CodeListLibrary_project/cll/templates/clinicalcode/adminTemp/admin_temp_tool.html index e7427d5f8..8b6f80ceb 100644 --- a/CodeListLibrary_project/cll/templates/clinicalcode/adminTemp/admin_temp_tool.html +++ b/CodeListLibrary_project/cll/templates/clinicalcode/adminTemp/admin_temp_tool.html @@ -70,17 +70,6 @@ <h2 class="banner__title">{{ action_title }}</h2> <form name="form1" id="form1" class="tool-panel__form" action="{{ url }}" method="POST" autocomplete="off" > {% csrf_token %} {% if not hide_phenotype_options %} - <div class="detailed-input-group fill"> - <h3 class="detailed-input-group__title"> - Auth-Code - </h3> - <p class="detailed-input-group__description"> - Authentication Code - </p> - <input class="text-input" aria-label="Auth Code" id="code" name="code" - type="text" value="6)r&9hpr_a0_4g(xan5p@=kaz2q_cd(v5n^!#ru*_(+d)#_0-i"> - </div> - <div class="detailed-input-group fill"> <h3 class="detailed-input-group__title"> Data @@ -88,8 +77,10 @@ <h3 class="detailed-input-group__title"> <p class="detailed-input-group__description"> Data to send </p> - <input class="text-input" aria-label="Data" id="cphenotype_idsode" name="input_data" - type="text" value=""> + <textarea + class="text-area-input text-area-input--bbox-size" aria-label="Data" + id="cphenotype_idsode" name="input_data" + type="text" placeholder=""></textarea> </div> {% endif %} @@ -97,7 +88,7 @@ <h3 class="detailed-input-group__title"> <div class="col-md-6"> <div> <br/> - <button class="primary-btn text-accent-darkest bold tertiary-accent icon create-icon sweep-left" aria-label="Confirm" id="save-changes"> + <button class="primary-btn text-accent-darkest bold tertiary-accent icon icon-create sweep-left" aria-label="Confirm" id="save-changes"> {{ action_title }} </button> </div> diff --git a/CodeListLibrary_project/cll/templates/clinicalcode/brand/ADP/index_ADP.html b/CodeListLibrary_project/cll/templates/clinicalcode/brand/ADP/index_ADP.html index 5a1c91d1c..595bacb7d 100644 --- a/CodeListLibrary_project/cll/templates/clinicalcode/brand/ADP/index_ADP.html +++ b/CodeListLibrary_project/cll/templates/clinicalcode/brand/ADP/index_ADP.html @@ -1,18 +1,22 @@ {% extends "base.html" %} + {% load static %} {% load compress %} {% load sass_tags %} {% load cl_extras %} {% load breadcrumbs %} {% load entity_renderer %} + {% block title %}| Home{% endblock title %} + {% block container %} +{% get_brand_base_title request.BRAND_OBJECT as base_page_title %} <header class="main-header search-banner"> <div class="main-header__inner-container main-header__inner-container--constrained main-header__inner-container--centred"> <div class="search-banner__header search-banner__header--pad-bottom-2"> - <h2 class="search-banner__title">Concept Library</h2> + <h2 class="search-banner__title">{{ base_page_title }}</h2> </div> </div> </header> @@ -23,24 +27,24 @@ <h2 class="search-banner__title">Concept Library</h2> <div class="headline__section__general"> <h2>Overview</h2> <p> - The <strong>Concept Library</strong> project intends to create a system that describes research study designs in a machine-readable + The <strong>{{ base_page_title }}</strong> project intends to create a system that describes research study designs in a machine-readable format to facilitate rapid study development; higher quality research; easier replication; and sharing of methods between researchers, institutions, and countries. </p> <h2>Goals</h2> <p> - The Concept Library is a system for storing, managing, sharing, and documenting clinical code lists in health + The {{ base_page_title }} is a system for storing, managing, sharing, and documenting clinical codelists in health research. The specific goals of this work are: </p> <ul> - <li>Store code lists along with metadata that captures important information about quality, author, etc.</li> - <li>Store version history and provide a way to unambiguously reference a particular version of a code list.</li> + <li>Store codelists along with metadata that captures important information about quality, author, etc.</li> + <li>Store version history and provide a way to unambiguously reference a particular version of a codelist.</li> <li> - Allow programmatic interaction with code lists via an API, so that they can be directly used in queries, + Allow programmatic interaction with codelists via an API, so that they can be directly used in queries, statistical scripts, etc. </li> - <li>Provide a mechanism for sharing code lists between projects and organizations.</li> + <li>Provide a mechanism for sharing codelists between projects and organizations.</li> </ul> <h2>Background</h2> @@ -68,14 +72,14 @@ <h2>Background</h2> <h2>Documentation</h2> {% if IS_INSIDE_GATEWAY or request.get_host == "conceptlibrary.serp.ac.uk" %} <p> - Concept Library documentation is available within the external application, or on Github. + {{ base_page_title }} documentation is available within the external application, or on Github. </p> <p> Please access the documentation outside of the gateway. </p> {% else %} <p> - Concept Library documentation is available on + {{ base_page_title }} documentation is available on <a href="https://github.com/SwanseaUniversityMedical/concept-library/wiki/Concept-Library-Documentation" target=_blank rel="noopener noreferrer"> Github. diff --git a/CodeListLibrary_project/cll/templates/clinicalcode/brand/HDRUK/about/about-the-project.html b/CodeListLibrary_project/cll/templates/clinicalcode/brand/HDRUK/about/about-the-project.html index 270c29adc..18a2b71ac 100644 --- a/CodeListLibrary_project/cll/templates/clinicalcode/brand/HDRUK/about/about-the-project.html +++ b/CodeListLibrary_project/cll/templates/clinicalcode/brand/HDRUK/about/about-the-project.html @@ -4,6 +4,7 @@ {% load compress %} {% load sass_tags %} {% load breadcrumbs %} +{% load entity_renderer %} {% block title %}| About Us {% endblock title %} @@ -19,6 +20,7 @@ {% endcompress %} <!-- Main --> +{% get_brand_base_title request.BRAND_OBJECT as base_page_title %} <main class="main-content"> <div class="main-content__inner-container main-content__inner-container--constrained main-content__inner-container--centred"> <article class="about-container"> @@ -309,10 +311,10 @@ <h3 class="subheader"> the Creative Commons Attribution 4.0 (CC-A). </li> <li> - Users should cite the Phenotype Library in all publications, + Users should cite the {{ base_page_title }} in all publications, presentations and reports as follows: <a href="https://phenotypes.healthdatagateway.org"> - HDR UK Phenotype Library + HDR UK {{ base_page_title }} </a> </li> <li> diff --git a/CodeListLibrary_project/cll/templates/clinicalcode/brand/HDRUK/about/covid-19-response.html b/CodeListLibrary_project/cll/templates/clinicalcode/brand/HDRUK/about/covid-19-response.html index f76e93057..413f585b5 100644 --- a/CodeListLibrary_project/cll/templates/clinicalcode/brand/HDRUK/about/covid-19-response.html +++ b/CodeListLibrary_project/cll/templates/clinicalcode/brand/HDRUK/about/covid-19-response.html @@ -61,11 +61,7 @@ <h3 class="subheader"> questions related to the COVID-19 pandemic. </p> <p> - <a href="{% url 'concept_library_home' %}"> - Phenotype Portal: - Developing an open-access catalogue of codes for defining COVID-19-related - phenotypes - </a> + <a href="{% url 'concept_library_home' %}">Phenotype Portal: Developing an open-access catalogue of codes for defining COVID-19-related phenotypes</a>. </p> <p> In order for research into COVID-19 to be carried out consistently across the @@ -96,12 +92,8 @@ <h3 class="subheader"> Understanding and Protecting Vulnerable Groups </h3> <p> - Researchers harnessed the - <a href="{% url 'concept_library_home' %}"> - Phenotype Portal - </a> - to address the wider impact of the COVID-19 pandemic by identifying high-risk - vulnerable groups. + Researchers harnessed the <a href="{% url 'concept_library_home' %}">Phenotype Portal</a> to + address the wider impact of the COVID-19 pandemic by identifying high-risk vulnerable groups. </p> <p> <a href="https://doi.org/10.1016/S0140-6736(20)30854-0"> @@ -124,9 +116,7 @@ <h3 class="subheader"> death from COVID-19 for someone with heart disease is five times higher than a healthy person and 10 times higher for someone with heart disease and diabetes. The research won the - <a href="https://www.hdruk.ac.uk/news/announcing-the-winners-of-hdr-uks-annual-awards-2020/"> - Health Data Research UK Impact of the Year Award 2020 - </a> + <a href="https://www.hdruk.ac.uk/news/announcing-the-winners-of-hdr-uks-annual-awards-2020/">Health Data Research UK Impact of the Year Award 2020</a>. </p> <p> <a href="http://dx.doi.org/10.13140/RG.2.2.34254.82242"> @@ -136,12 +126,8 @@ <h3 class="subheader"> <p> UCL-led research along with DATA-CAN: The Health Data Research Hub for Cancer used data from hospitals in London, Leeds and Northern Ireland and the health - records of nearly 4 million patients in England ( - <a href="https://www.ucl.ac.uk/health-informatics/research/caliber"> - CALIBER - </a> - ) to look at changes in - cancer service provision and model excess deaths in the COVID-19 emergency in + records of nearly 4 million patients in England (<a href="https://www.ucl.ac.uk/health-informatics/research/caliber">CALIBER</a>) + to look at changes in cancer service provision and model excess deaths in the COVID-19 emergency in patients with cancer and other underlying health conditions. Researchers estimated there could be 6,270 more deaths among newly diagnosed cancer patients over the next year - a 20% increase in deaths among people newly @@ -151,9 +137,7 @@ <h3 class="subheader"> suffering from one or more other underlying health condition, including cardiovascular diseases, hypertension, obesity and diabetes. Dr Alvina Lai, lead researcher, won the - <a href="https://www.hdruk.ac.uk/news/announcing-the-winners-of-hdr-uks-annual-awards-2020/"> - Health Data Research UK Early Career Lightning Talk Award 2020 - </a> + <a href="https://www.hdruk.ac.uk/news/announcing-the-winners-of-hdr-uks-annual-awards-2020/">Health Data Research UK Early Career Lightning Talk Award 2020</a>. </p> </section> <section class="about-container__section__text"> @@ -192,10 +176,7 @@ <h3 class="subheader"> <p> HDR London entered into a major collaboration with the Alan Turing Institute and others to establish - <a href="https://www.decovid.org/"> - DECOVID - </a> - - a scalable, national data repository and data + <a href="https://www.decovid.org/">DECOVID</a> - a scalable, national data repository and data analytics centre providing clinicians with real-time actionable intelligence into patient care and operational planning during the COVID-19 pandemic. The resource will be used to answer clinically pertinent questions on the acute diff --git a/CodeListLibrary_project/cll/templates/clinicalcode/brand/HDRUK/about/technical-details.html b/CodeListLibrary_project/cll/templates/clinicalcode/brand/HDRUK/about/technical-details.html index 08b5db108..9c53adb02 100644 --- a/CodeListLibrary_project/cll/templates/clinicalcode/brand/HDRUK/about/technical-details.html +++ b/CodeListLibrary_project/cll/templates/clinicalcode/brand/HDRUK/about/technical-details.html @@ -4,6 +4,7 @@ {% load compress %} {% load sass_tags %} {% load breadcrumbs %} +{% load entity_renderer %} {% block title %}| Technical Details {% endblock title %} @@ -35,6 +36,7 @@ </script> <!-- Main --> +{% get_brand_base_title request.BRAND_OBJECT as base_page_title %} <main class="main-content"> <div class="main-content__inner-container main-content__inner-container--constrained main-content__inner-container--centred"> <article class="about-container"> @@ -43,11 +45,11 @@ <aside class="scrollspy"> <div class="scrollspy__container"> <button class="scrollspy__container__item scrollspy__container__item--tertiary active" - aria-label="Go to Phenotype Library Inclusion Criteria" data-target="#home"> - <a href="#home">Phenotype Library Inclusion Criteria</a> + aria-label="Go to {{ base_page_title }} Inclusion Criteria" data-target="#home"> + <a href="#home">{{ base_page_title }} Inclusion Criteria</a> </button> <button class="scrollspy__container__item scrollspy__container__item--tertiary" - aria-label="Go to Phenotype Library Inclusion Criteria" data-target="#specification"> + aria-label="Go to {{ base_page_title }} Inclusion Criteria" data-target="#specification"> <a href="#specification">Specification</a> </button> <button class="scrollspy__container__item scrollspy__container__item--tertiary" @@ -73,7 +75,7 @@ <h2> <section class="about-container__section__text"> <h3 class="subheader"> <span class="scrollspy-target" id="home"></span> - Phenotype Library Inclusion Criteria + {{ base_page_title }} Inclusion Criteria </h3> <ul> <li> @@ -100,7 +102,7 @@ <h3 class="subheader"> Specification </h3> <p> - Phenotyping algorithms are stored in the Phenotype Library usign a combination + Phenotyping algorithms are stored in the {{ base_page_title }} usign a combination of YAML and CSV files. There are two main components to each algorithm: </p> <ul class="decimal"> @@ -146,11 +148,11 @@ <h3>From an existing concept:</h3> <p><strong>type: </strong>existing_concept<p> <p> <strong>concept_id: </strong> - The concept ID as displayed on the Phenotype Library + The concept ID as displayed on the {{ base_page_title }} <p> <p> <strong>concept_version_id: </strong> - The concent version ID as displayed on the Phenotype Library + The concent version ID as displayed on the {{ base_page_title }} </p> </div> <div class="template-detail__container"> @@ -176,7 +178,7 @@ <h3 class="subheader"> </h3> <p> You can download a sample - <a href="https://raw.githubusercontent.com/SwanseaUniversityMedical/ConceptLibraryClient/main/template.yaml"> + <a href="https://raw.githubusercontent.com/SwanseaUniversityMedical/ConceptLibraryClient/main/template.yaml" target=_blank> template file </a> from the repository. @@ -187,19 +189,23 @@ <h3 class="subheader"> <ul> <li> By using the - <a href="{% url 'create_phenotype' %}"> - Phenotype Builder - </a> + {% if USER_CREATE_CONTEXT %} + <a href="{% url 'create_phenotype' %}"> + Phenotype Builder + </a> + {% else %} + <p>Phenotype Builder</p> + {% endif %} </li> <li> By using the - <a href="https://github.com/SwanseaUniversityMedical/ConceptLibraryClient"> + <a href="https://github.com/SwanseaUniversityMedical/ConceptLibraryClient" target=_blank> ConceptLibraryClient R Package </a> </li> <li> By using the - <a href="https://github.com/SwanseaUniversityMedical/pyconceptlibraryclient"> + <a href="https://github.com/SwanseaUniversityMedical/pyconceptlibraryclient" target=_blank> pyconceptlibraryclient Python Package </a> </li> diff --git a/CodeListLibrary_project/cll/templates/clinicalcode/brand/HDRUK/collections/bhf-data-science-centre.html b/CodeListLibrary_project/cll/templates/clinicalcode/brand/HDRUK/collections/bhf-data-science-centre.html index 922b5ad9c..9918e58d4 100644 --- a/CodeListLibrary_project/cll/templates/clinicalcode/brand/HDRUK/collections/bhf-data-science-centre.html +++ b/CodeListLibrary_project/cll/templates/clinicalcode/brand/HDRUK/collections/bhf-data-science-centre.html @@ -73,8 +73,8 @@ <h3 class="subheader"> <span class="scrollspy-target" id="phenotypes"></span> Phenotypes in this Collection </h3> - <a href="{% url 'search_phenotypes' %}?collections=20"> - <button class="secondary-btn text-accent-darkest bold icon create-icon secondary-accent"> + <a href="{% url 'search_entities' %}?collections=20"> + <button class="secondary-btn text-accent-darkest bold icon icon-create secondary-accent"> Browse Phenotypes </button> </a> diff --git a/CodeListLibrary_project/cll/templates/clinicalcode/brand/HDRUK/collections/breathe.html b/CodeListLibrary_project/cll/templates/clinicalcode/brand/HDRUK/collections/breathe.html index 3e3b390c2..fa2e29658 100644 --- a/CodeListLibrary_project/cll/templates/clinicalcode/brand/HDRUK/collections/breathe.html +++ b/CodeListLibrary_project/cll/templates/clinicalcode/brand/HDRUK/collections/breathe.html @@ -112,12 +112,12 @@ <h3 class="subheader"> <p> BREATHE has brought together a set of phenotypes (found below) covering a number of key respiratory conditions. This is not an exhaustive list of codes - or mandatory, but a guide to aid researchers in starting to make code lists + or mandatory, but a guide to aid researchers in starting to make codelists appropriate for their study. We will continue to update existing lists and add more over time, so do keep checking back! </p> - <a href="{% url 'search_phenotypes' %}?collections=19"> - <button class="secondary-btn text-accent-darkest bold icon create-icon secondary-accent"> + <a href="{% url 'search_entities' %}?collections=19"> + <button class="secondary-btn text-accent-darkest bold icon icon-create secondary-accent"> Browse Phenotypes </button> </a> diff --git a/CodeListLibrary_project/cll/templates/clinicalcode/brand/HDRUK/collections/eurolinkcat.html b/CodeListLibrary_project/cll/templates/clinicalcode/brand/HDRUK/collections/eurolinkcat.html index a51630eae..30f203c5f 100644 --- a/CodeListLibrary_project/cll/templates/clinicalcode/brand/HDRUK/collections/eurolinkcat.html +++ b/CodeListLibrary_project/cll/templates/clinicalcode/brand/HDRUK/collections/eurolinkcat.html @@ -97,8 +97,8 @@ <h3 class="subheader"> <span class="scrollspy-target" id="phenotypes"></span> Phenotypes in this Collection </h3> - <a href="{% url 'search_phenotypes' %}?collections=30"> - <button class="secondary-btn text-accent-darkest bold icon create-icon secondary-accent"> + <a href="{% url 'search_entities' %}?collections=30"> + <button class="secondary-btn text-accent-darkest bold icon icon-create secondary-accent"> Browse Phenotypes </button> </a> diff --git a/CodeListLibrary_project/cll/templates/clinicalcode/brand/HDRUK/index_HDRUK.html b/CodeListLibrary_project/cll/templates/clinicalcode/brand/HDRUK/index_HDRUK.html index caf94e944..54e78e929 100644 --- a/CodeListLibrary_project/cll/templates/clinicalcode/brand/HDRUK/index_HDRUK.html +++ b/CodeListLibrary_project/cll/templates/clinicalcode/brand/HDRUK/index_HDRUK.html @@ -5,7 +5,9 @@ {% load cl_extras %} {% load breadcrumbs %} {% load entity_renderer %} + {% block title %}| HDRUK Home {% endblock title %} + {% block container %} <!-- Page Stylesheets --> @@ -14,21 +16,22 @@ {% endcompress %} <!-- Main --> +{% get_brand_base_title request.BRAND_OBJECT as base_page_title %} <header class="main-header banner banner-image banner-image--transparent"> <div class="main-header__inner-container main-header__inner-container--constrained-no-pad main-header__inner-container--centred banner--background"> <div class="banner__container__left-landing-page"> <h3 class="banner__description__landing-page "> - The HDR UK Phenotype Library is a comprehensive, open access resource providing the research community with + The HDR UK {{ base_page_title }} is a comprehensive, open access resource providing the research community with information, tools and phenotyping algorithms for UK electronic health records. </h3> </div> <div class="search-banner__container-landing"> <form class="search-container" data-controller="filter" data-class="searchbar" data-field="search" - action="{% url 'search_phenotypes' %}" method="GET"> - <input type="text" placeholder="Search our Phenotype Library" id="searchbar" + action="{% url 'search_entities' %}" method="GET"> + <input type="text" placeholder="Search our {{ base_page_title }}" id="searchbar" class="search-container__field washed-outline" data-class="searchbar" data-field="search" name="search" - value="{{ search|default_if_none:'' }}" aria-label="Search our Phenotype Library" /> + value="{{ search|default_if_none:'' }}" aria-label="Search our {{ base_page_title }}" /> <button class="search-container__icon-landing" name="page" value="1" aria-label="Search Phenotypes" tabindex="0"> @@ -93,14 +96,14 @@ <h2>A Reference Catalogue of Human Diseases</h2> <div class="headline__section"> <p> <strong>Connected.</strong> - The Phenotype Library is accessible via an API to support interoperability, is integrated with health dataset information in HDR-UK's Innovation Gateway, + The {{ base_page_title }} is accessible via an API to support interoperability, is integrated with health dataset information in HDR-UK's Innovation Gateway, and hosts content from numerous contributing organisations. </p> </div> <div class="headline__section"> <p> <strong>Patient-focused.</strong> - The Library is enabling important research to improve patient health and well-being. Content spans major disease areas, including heart disease, cancer, + The {{ base_page_title }} is enabling important research to improve patient health and well-being. Content spans major disease areas, including heart disease, cancer, COVID-19 and other common and rare human health conditions. Curated collections from contributors such as the HDR UK BREATHE Hub for respiratory health share clinical expertise to tackle critical research questions. </p> @@ -109,7 +112,7 @@ <h2>A Reference Catalogue of Human Diseases</h2> <p> <strong>Cutting-edge.</strong> Built with a focus on computability, this resource aims to drive the next generation of research methods. Integration with Phenoflow enables executable - implementations of the phenotypes in our collection, while the API and R package client facilitate integration of the Library content directly into + implementations of the phenotypes in our collection, while the API and R package client facilitate integration of the {{ base_page_title }} content directly into other analysis workflows. </p> </div> @@ -132,7 +135,7 @@ <h3>What is a phenotyping algorithm?</h3> <div class="headline__section__general headline--margin-bottom-2rem"> <h3>How are we enabling this work?</h3> <p> - The <strong>HDR UK Phenotype Library</strong> is a platform to host phenotyping algorithms and harness their power for research. + The <strong>HDR UK {{ base_page_title }}</strong> is a platform to host phenotyping algorithms and harness their power for research. Our aim is to provide researchers with the “GitHub of phenotyping”: an open platform for <strong>creation, storage, dissemination, re-use, evaluation, and citation</strong> of curated algorithms and metadata. Our goal is for this to be a place where researchers can share their own work, benefit from what others have shared, and even build new @@ -153,39 +156,40 @@ <h3>How are we enabling this work?</h3> <p class="referral-card__title referral-card__title--align-centre">Search</p> </div> <div class="referral-card__body referral-card__body--hide-overflow"> - <a class="referral-card__anchor" href="{% url 'search_phenotypes' %}"> + <a class="referral-card__anchor" href="{% url 'search_entities' %}"> <p>Start here to explore phenotypes in our library.</p> </a> </div> </article> - <article - class="referral-card referral-card--landing-card referral-card-fill-area-evenly bright-accent referral-card-shadow referral-card-border-radius referral-card-no-margin"> - <div class="referral-card__header referral-card__header--icon-column"> - <div class="referral-card__icon-before referral-card__icon-before__plus"></div> - <p class="referral-card__title referral-card__title--align-centre">Submit a phenotype</p> - </div> - <div class="referral-card__body referral-card__body--hide-overflow"> - - <a class="referral-card__anchor" href="{% url 'create_phenotype' %}"> - <p>Learn how you contribute your phenotypes to our library.</p> - </a> - </div> - </article> + {% if USER_CREATE_CONTEXT %} + <article + class="referral-card referral-card--landing-card referral-card-fill-area-evenly bright-accent referral-card-shadow referral-card-border-radius referral-card-no-margin"> + <div class="referral-card__header referral-card__header--icon-column"> + <div class="referral-card__icon-before referral-card__icon-before__plus"></div> + <p class="referral-card__title referral-card__title--align-centre">Submit a phenotype</p> + </div> + <div class="referral-card__body referral-card__body--hide-overflow"> + <a class="referral-card__anchor" href="{% url 'create_phenotype' %}"> + <p>Learn how you contribute your phenotypes to our library.</p> + </a> + </div> + </article> + {% endif %} </div> </div> <section class="key-section"> <h2 class="key-section__title">Key Principles</h2> <div class="key-section__columns"> <div class="key-section__column"> - <p>The Library stores phenotyping algorithms, metadata and tools only. No data are stored in the Library.</p> + <p>The {{ base_page_title }} stores phenotyping algorithms, metadata and tools only. No data are stored in the {{ base_page_title }}.</p> </div> <div class="key-section__column"> - <p>Ideally, phenotypes that are deposited in the Library will have undergone some form of peer-review to + <p>Ideally, phenotypes that are deposited in the {{ base_page_title }} will have undergone some form of peer-review to assess validity and quality either through peer-reviewed publication or some other means of sharing the definition(s)</p> </div> <div class="key-section__column"> - <p>All material deposited in the Library remain the intellectual property of the research group who created + <p>All material deposited in the {{ base_page_title }} remain the intellectual property of the research group who created the phenotype(s) – the default licensing agreement that information is available under is the Creative Commons Attribution 4.0 (CC-A)</p> </div> @@ -194,11 +198,11 @@ <h2 class="key-section__title">Key Principles</h2> identification of the phenotype</p> </div> <div class="key-section__column"> - <p>Users should cite the Phenotype Library in all publications, presentations and reports as follows: "HDR UK + <p>Users should cite the {{ base_page_title }} in all publications, presentations and reports as follows: "HDR UK CALIBER Phenotype Library https://portal.caliberresearch.org/"</p> </div> <div class="key-section__column"> - <p>The aim of the Library is not to standardize or harmonize disease definitions, therefore several phenotypes + <p>The aim of the {{ base_page_title }} is not to standardize or harmonize disease definitions, therefore several phenotypes may be stored for the same condition and the onus is on individual researchers to explore which phenotypes they wish to use</p> </div> @@ -207,7 +211,7 @@ <h2 class="key-section__title">Key Principles</h2> <div class="headline__section__general headline__section__general--align-items-center"> <h2 class="headline--text-align-center">How to contribute?</h2> <p> - To submit an EHR phenotyping algorithm to the Phenotype Library please read <a href="{% url 'about_page' 'hdruk_about_technical_details' %}" rel="noopener noreferrer">our documentation pages</a>. + To submit an EHR phenotyping algorithm to the {{ base_page_title }} please read <a href="{% url 'about_page' 'hdruk_about_technical_details' %}" rel="noopener noreferrer">our documentation pages</a>. </p> </div> </div> diff --git a/CodeListLibrary_project/cll/templates/clinicalcode/dashboard/index.html b/CodeListLibrary_project/cll/templates/clinicalcode/dashboard/index.html new file mode 100644 index 000000000..caede5d90 --- /dev/null +++ b/CodeListLibrary_project/cll/templates/clinicalcode/dashboard/index.html @@ -0,0 +1,856 @@ +{% extends "base.html" %} + +{% load static %} +{% load compress %} +{% load sass_tags %} +{% load entity_renderer %} + +{% block title %}| Dashboard{% endblock title %} + +{% block indexing_robots %}{% endblock indexing_robots %} + +{% block htmltag %}class="slim-scrollbar slim-scrollbar--thick"{% endblock htmltag %} + +{% block head %} + {% compress css %} + <link rel="stylesheet" href="{% sass_src 'scss/pages/admin/dashboard.scss' %}" type="text/css" /> + {% endcompress %} + {% compress js %} + <script type="module" src="{% static 'js/clinicalcode/components/toastNotification.js' %}"></script> + <script type="text/javascript" src="{% static 'js/clinicalcode/components/tooltipFactory.js' %}"></script> + {% endcompress js %} +{% endblock head %} + +{% block navigation_wrapper %}{% endblock navigation_wrapper %} + +{% block container %} + {% compress js %} + <script type="module" src="{% static 'js/clinicalcode/services/dashboardService/index.js' %}"></script> + {% endcompress js %} + + {% get_brand_map_rules as brand_mapping %} + {% get_brand_base_title request.BRAND_OBJECT as base_page_title %} + <main id="app" class="dashboard-layout dashboard-layout--constrained"> + + <!-- Navigation Content --> + <nav id="app-nav" class="dashboard-nav" data-ref="aside-bar" data-open="true"> + <header class="dashboard-nav__header"> + <img + class="dashboard-nav__header-logo" + loading="lazy" + src="{% static logo_path %}" /> + <h1> + Dashboard + </h1> + </header> + <section class="dashboard-nav__targets"> + <h2 role="heading" aria-level="5"> + Navigation + </h2> + <section class="dashboard-nav__targets-list"> + <a + class="dashboard-nav__targets-list-item" + title="See Overview" + aria-label="See Overview" + tabindex="0" + role="button" + data-ref="overview" + data-active="true" + data-controlledby="navigation" + > + <span + class="as-icon" + data-icon="" + data-role="icon" + data-plugins="tooltip" + data-tipcontent="See Overview" + data-tipdirection="right" + data-tiptarget="parent" + ></span> + <span data-role="label"> + <span class="as-icon" data-icon="" aria-hidden="true"></span> + Overview + </span> + </a> + <a + class="dashboard-nav__targets-list-item" + title="Manage Users" + aria-label="Manage Users" + tabindex="0" + role="button" + data-ref="users" + data-controlledby="navigation" + > + <span + class="as-icon" + data-icon="" + data-role="icon" + data-plugins="tooltip" + data-tipcontent="Manage Users" + data-tipdirection="right" + data-tiptarget="parent" + ></span> + <span data-role="label"> + <span class="as-icon" data-icon="" aria-hidden="true"></span> + Users + </span> + </a> + <a + class="dashboard-nav__targets-list-item" + title="Manage Organisations" + aria-label="Manage Organisations" + tabindex="0" + role="button" + data-ref="organisations" + data-controlledby="navigation" + > + <span + class="as-icon" + data-icon="" + data-role="icon" + data-plugins="tooltip" + data-tipcontent="Manage Organisations" + data-tipdirection="right" + data-tiptarget="parent" + ></span> + <span data-role="label"> + <span class="as-icon" data-icon="" aria-hidden="true"></span> + Organisations + </span> + </a> + <a + class="dashboard-nav__targets-list-item" + title="Manage Inventory" + aria-label="Manage Inventory" + tabindex="0" + role="button" + data-ref="inventory" + data-controlledby="navigation" + > + <span + class="as-icon" + data-icon="" + data-role="icon" + data-plugins="tooltip" + data-tipcontent="Manage Inventory" + data-tipdirection="right" + data-tiptarget="parent" + ></span> + <span data-role="label"> + <span class="as-icon" data-icon="" aria-hidden="true"></span> + Inventory + </span> + </a> + <a + class="dashboard-nav__targets-list-item" + title="Manage Brand Configuration" + aria-label="Manage Brand Configuration" + tabindex="0" + role="button" + data-ref="brand-config" + data-controlledby="navigation" + > + <span + class="as-icon" + data-icon="" + data-role="icon" + data-plugins="tooltip" + data-tipcontent="Manage Brand Configuration" + data-tipdirection="right" + data-tiptarget="parent" + ></span> + <span data-role="label"> + <span class="as-icon" data-icon="" aria-hidden="true"></span> + Brand Config + </span> + </a> + </section> + </section> + </nav> + + <!-- Main View --> + <section id="app-main" class="dashboard-view slim-scrollbar"> + + <!-- Topbar Navigation --> + <header id="app-header" class="dashboard-view__header dashboard-view__header--split-bar"> + <div id="app-bar" class="dashboard-nav-toggle" data-controlledby="nav-toggle" data-toggle="aside-bar"> + <img + class="dashboard-nav-toggle__logo" + loading="lazy" + src="{% static logo_path %}" + /> + <a + class="dashboard-nav-toggle__btn" + title="Toggle Navigation" + aria-label="Toggle Navigation Menu" + aria-live="off" + tabindex="0" + role="button" + data-role="toggle" + > + </a> + </div> + <div class="popover-menu" data-controlledby="popover-menu"> + <a + class="popover-menu__controls" + title="Toggle Account Menu" + aria-label="Toggle Account Menu" + aria-live="off" + tabindex="0" + role="button" + data-role="toggle" + > + <span class="popover-menu__controls-user"> + {{ request.user.username }} + </span> + <span class="as-icon" data-icon="" aria-hidden="true"></span> + </a> + <div class="popover-menu__popover" aria-live="off" aria-hidden="true" data-role="menu"> + <div class="popover-menu__popover-content"> + {% url 'concept_library_home' as home_url %} + <button + title="Go to Home" + aria-label="Go to Home" + data-role="button" + data-link="{{ home_url }}" + > + <span class="as-icon" data-icon="" aria-hidden="true"></span> + Home + </button> + <form method="post" action="{% url 'logout' %}?next={% url 'search_entities' %}"> + {% csrf_token %} + <button + type="submit" + title="Sign out" + aria-label="Sign out" + data-role="button" + > + <span class="as-icon" data-icon="" aria-hidden="true"></span> + Sign out + </button> + </form> + </div> + </div> + </div> + </header> + + <!-- Renderable Content --> + <section id="app-content" class="dashboard-view__content slim-scrollbar"> + + </section> + + </section> + </main> + + {% to_json_script brand_mapping data-for="dashboard" data-view="brandMapping" desc-type="text/json" %} + + <template data-name="model" data-for="dashboard"> + <article class="dashboard-view__content-group ${articleCls}"> + <header class="dashboard-view__content-header ${headerCls}"> + <h2 id="${id}" class="dashboard-view__content-title" role="heading" aria-level="${level}"> + ${title} + </h2> + <button + class="outline-btn outline-btn--icon outline-btn--icon-add" + title="Add New ${title}" + aria-label="Add New ${title}" + role="button" + tabindex="0" + data-ref="${id}" + data-role="create-btn" + > + Add New + </button> + </header> + <section class="${sectionCls}"> + ${content} + </section> + </article> + </template> + + <template data-name="group" data-for="dashboard"> + <article class="dashboard-view__content-group ${articleCls}"> + <h2 id="${id}" class="dashboard-view__content-heading" role="heading" aria-level="${level}"> + ${title} + </h2> + <section class="${sectionCls}"> + ${content} + </section> + </article> + </template> + + <template data-name="display_card" data-for="dashboard"> + <article class="card"> + <header class="card__header"> + <h3>${name}</h3> + <span class="as-icon ${iconCls}" data-icon="${icon}" aria-hidden="true"></span> + </header> + <p class="card__desc"> + ${desc} + </p> + ${content} + <footer class="card__footer"> + ${footer} + </footer> + </article> + </template> + + <template data-name="action_card" data-for="dashboard"> + <article class="card"> + <header class="card__header"> + <h3>${name}</h3> + <span class="as-icon ${iconCls}" data-icon="${icon}" aria-hidden="true"></span> + </header> + <p class="card__desc"> + ${desc} + </p> + <footer class="card__footer card__footer--action"> + <button + class="outline-btn outline-btn--icon outline-btn--icon-edit" + title="Manage ${name}" + aria-label="Manage ${name}" + data-ref="${ref}" + data-role="action-btn" + > + ${action} + </button> + </footer> + </article> + </template> + + <template data-name="icon" data-for="dashboard"> + <span class="as-icon ${cls}" data-icon="${icon}" aria-hidden="true"></span> + </template> + + <template data-name="time" data-for="dashboard"> + <time datetime="${timestamp}"> + <span class="as-icon" data-icon="" aria-hidden="true"></span> + ${datefmt} + </time> + </template> + + <template data-name="table" data-for="dashboard"> + <fieldset class="dash-search search-container" data-controller="filter" data-class="searchbar" data-field="search"> + <input + id="searchbar" + class="search-container__field washed-outline" + type="text" + value="${query}" + placeholder="Search..." + data-class="searchbar" data-field="search" + aria-label="Search {{ brand_mapping.phenotype }}s..." + /> + <button + id="searchbar-icon-btn" + class="search-container__icon" + tabindex="0" + aria-label="Search {{ brand_mapping.phenotype }}s"> + </button> + </fieldset> + <article class="dash-list"> + <section class="dash-list__container slim-scrollbar"> + <table class="dash-list__table"> + <thead> + + </thead> + <tbody> + + </tbody> + </table> + </section> + <footer class="dash-list__footer"> + + </footer> + </article> + </template> + + <template data-name="empty" data-view="pages" data-for="dashboard"> + <li class="is-active"> + <a class="no-select" data-value="ignore" target="_blank" disabled> + 1 + </a> + </li> + </template> + + <template data-name="divider" data-view="pages" data-for="dashboard"> + <li class="divider"> + <a class="no-select" data-value="ignore" target="_blank" disabled> + </a> + </li> + </template> + + <template data-name="button" data-view="pages" data-for="dashboard"> + <li class="${cls}"> + <a + class="no-select" + aria-label="Go Page ${page}" + target="_blank" + tabindex="0" + data-value="${page}" + data-field="page" + role="button" + >${page}</a> + </li> + </template> + + <template data-name="controls" data-view="pages" data-for="dashboard"> + <p class="dash-list__footer-desc"> + Showing ${startIdx} to ${endIdx} of ${rowCount} entries + </p> + <section + class="pagination-container" + data-field="page" + data-value="${page}" + data-class="pagination" + data-controller="filter" + > + <div class="pagination-container__details"> + <p class="pagination-container__details-number"> + Page + <span id="page-number"> + ${page} + </span> + of + <span id="page-total"> + ${totalPages} + </span> + </p> + </div> + <ul class="pagination-container__previous"> + <li class="${ !hasPrevious ? 'disabled' : ''}"> + <a + class="no-select" + role="button" + target="_blank" + tabindex="0" + aria-label="Go Previous Page" + data-value="previous" + data-field="page" + > + Previous + </a> + </li> + </ul> + <ul class="pagination-container__pages" id="page-items"> + ${content} + </ul> + <ul class="pagination-container__next"> + <li class="${ !hasNext ? 'disabled' : ''}"> + <a + class="no-select" + role="button" + target="_blank" + tabindex="0" + aria-label="Go Next Page" + data-value="next" + data-field="page" + > + Next + </a> + </li> + </ul> + </section> + </template> + + <template data-name="entity_form" data-view="form" data-for="dashboard"> + <form method="${method}" action="${action}" class="dashboard-entity-form"> + </form> + </template> + + <template data-name="button" data-view="form" data-for="dashboard"> + <button + class="outline-btn outline-btn--icon outline-btn--icon-${icon} ${style}" + title="${title}" + aria-label="${title}" + role="button" + tabindex="0" + data-ref="${id}" + data-role="${role}" + > + ${title} + </button> + </template> + + <template data-name="TextField" data-view="form" data-for="dashboard"> + <div class="detailed-input-group fill" data-fieldset="${key}"> + <h3 class="detailed-input-group__title"> + ${title} + <span class="detailed-input-group__mandatory ${required.length ? '' : 'hidden'}">*</span> + </h3> + <p class="detailed-input-group__description" data-ref="help"> + ${help} + </p> + <input + id="field-${key}" + class="text-input ${cls}" + placeholder="${placeholder}" + type="${inputtype}" + value="${value}" + ${minLength} + ${maxLength} + ${required} + aria-label="${title}" + data-class="inputbox" + data-type="string" + data-field="${key}" + autocomplete="${autocomplete}" + /> + </div> + </template> + + <template data-name="TextAreaField" data-view="form" data-for="dashboard"> + <div class="detailed-input-group fill" data-fieldset="${key}"> + <h3 class="detailed-input-group__title"> + ${title} + <span class="detailed-input-group__mandatory ${required.length ? '' : 'hidden'}">*</span> + </h3> + <p class="detailed-input-group__description" data-ref="help"> + ${help} + </p> + <textarea + id="field-${key}" + class="${cls}" + value="${value}" + placeholder="${placeholder}" + ${minLength} + ${maxLength} + ${required} + autocorrect="${spellcheck ? 'true' : 'false'}" + autocomplete="on" + spellcheck="default" + wrap="soft" + data-class="${useMarkdown ? 'md-editor' : 'textarea'}" + data-type="string" + data-field="${key}" + ></textarea> + </div> + </template> + + <template data-name="NumericField" data-view="form" data-for="dashboard"> + <div class="detailed-input-group fill" data-fieldset="${key}"> + <h3 class="detailed-input-group__title"> + ${title} + <span class="detailed-input-group__mandatory ${required.length ? '' : 'hidden'}">*</span> + </h3> + <p class="detailed-input-group__description" data-ref="help"> + ${help} + </p> + <input + id="field-${key}" + class="text-input ${cls}" + placeholder="${placeholder}" + type="number" + inputmode="${inputmode}" + value="${value}" + ${minvalue} + ${maxvalue} + ${required} + aria-label="${title}" + data-class="inputbox" + data-type="number" + data-field="${key}" + data-rounding="${rounding}" + data-decimals="${decimalPlaces}" + data-maxdigits="${maxDigits}" + /> + </div> + </template> + + <template data-name="BooleanField" data-view="form" data-for="dashboard"> + <div class="detailed-input-group fill" data-fieldset="${key}"> + <h3 class="detailed-input-group__title"> + ${title} + <span class="detailed-input-group__mandatory ${required.length ? '' : 'hidden'}">*</span> + </h3> + <p class="detailed-input-group__description" data-ref="help"> + ${help} + </p> + <div class="checkbox-item-container"> + <input + id="field-${key}" + class="checkbox-item ${cls}" + type="checkbox" + aria-label="${title}" + data-class="checkbox" + data-type="boolean" + data-field="${key}" + ${required} + /> + <label class="constrained-filter-item" for="field-${key}"> + ${title} + </label> + </div> + </div> + </template> + + <template data-name="ChoiceField" data-view="form" data-for="dashboard"> + <div class="detailed-input-group fill" data-fieldset="${key}"> + <h3 class="detailed-input-group__title"> + ${title} + <span class="detailed-input-group__mandatory ${required.length ? '' : 'hidden'}">*</span> + </h3> + <p class="detailed-input-group__description" data-ref="help"> + ${help} + </p> + <select + id="field-${key}" + class="selection-input ${cls}" + aria-label="${title}" + data-class="dropdown" + data-type="choice" + data-field="${key}" + ${required} + > + </select> + </div> + </template> + + <template data-name="DateTimeLikeField" data-view="form" data-for="dashboard"> + <div class="detailed-input-group fill" data-fieldset="${key}"> + <h3 class="detailed-input-group__title"> + ${title} + <span class="detailed-input-group__mandatory ${required.length ? '' : 'hidden'}">*</span> + </h3> + <p class="detailed-input-group__description" data-ref="help"> + ${help} + </p> + <fieldset class="date-range-field date-range-field--wrapped date-range-field--padding0_5 ${cls}"> + <input + id="field-${key}" + type="${datatype}" + value="${value}" + aria-label="${title}" + data-class="datetime" + data-type="${datatype}" + data-field="${key}" + ${required} + /> + </fieldset> + </div> + </template> + + <template data-name="ForeignKeyField" data-view="form" data-for="dashboard"> + <div class="detailed-input-group fill" data-fieldset="${key}"> + <h3 class="detailed-input-group__title"> + ${title} + <span class="detailed-input-group__mandatory ${required.length ? '' : 'hidden'}">*</span> + </h3> + <p class="detailed-input-group__description" data-ref="help"> + ${help} + </p> + <select + id="field-${key}" + class="selection-input ${cls}" + aria-label="${title}" + data-class="dropdown" + data-type="foreign-key" + data-field="${key}" + ${required} + > + </select> + </div> + </template> + + <template data-name="MultiForeignKeyField" data-view="form" data-for="dashboard"> + <div class="detailed-input-group fill" data-fieldset="${key}"> + <h3 class="detailed-input-group__title"> + ${title} + <span class="detailed-input-group__mandatory ${required.length ? '' : 'hidden'}">*</span> + </h3> + <p class="detailed-input-group__description" data-ref="help"> + ${help} + </p> + <input + id="field-${key}" + class="text-input ${cls}" + type="text" + placeholder="" + aria-label="${title}" + data-class="tagify" + data-type="multi-foreign-key" + data-field="${key}" + /> + </div> + </template> + + <template data-name="autocomplete" data-view="form" data-for="dashboard"> + <div + id="${id}" + class="autocomplete-container" + role="combobox" + data-role="autocomplete-container" + aria-owns="autocomplete-results" + aria-expanded="false" + aria-haspopup="listbox" + > + <div class="autocomplete-controls" data-role="autocomplete-controls"> + <input + class="autocomplete-input" + value="${searchValue}" + placeholder="${searchPlaceholder}" + data-role="autocomplete-input" + aria-label="${searchLabel}" + aria-controls="autocomplete-results" + aria-autocomplete="both" + /> + <ul + class="autocomplete-results" + role="listbox" + data-role="autocomplete-results" + aria-label="${searchLabel} results"> + </ul> + </div> + <button + class="outline-btn outline-btn--icon outline-btn--icon-${btnIcon}" + type="button" + title="${btnTitle}" + aria-label="${btnTitle}" + role="button" + tabindex="0" + data-ref="${btnId}" + data-role="autocomplete-button" + > + ${btnContent} + </button> + </div> + </template> + + <template data-name="selector" data-view="form" data-for="dashboard"> + <div class="detailed-input-group fill" data-fieldset="${key}"> + <h3 class="detailed-input-group__title"> + ${title} + <span class="detailed-input-group__mandatory ${required.length ? '' : 'hidden'}">*</span> + </h3> + <p class="detailed-input-group__description" data-ref="help"> + ${help} + </p> + <section class="selector-component" data-owner="${owner}"> + <header class="selector-component__header" data-identifier="header"> + + </header> + <div class="selector-component__none-available" data-visible="true" data-identifier="empty"> + <p>${emptyMessage}</p> + </div> + <div class="selector-component__content slim-scrollbar" data-visible="false" data-identifier="content"> + <table class="selector-component__table" data-identifier="table"> + + </table> + </div> + </section> + </div> + </template> + + <template data-name="table" data-view="OrgMember" data-for="dashboard"> + <thead> + <tr> + <td>User</td> + <td>Role</td> + <td>Actions</td> + </tr> + </thead> + <tbody> + + </tbody> + </template> + + <template data-name="row" data-view="OrgMember" data-for="dashboard"> + <tr data-ref="${userPk}"> + <td>${username}</td> + <td> + <select + class="selection-input" + aria-label="Select User Role" + data-ref="${userPk}" + data-role="user-role-select" + data-column="role" + > + </select> + </td> + <td> + <button + class="outline-btn outline-btn--icon outline-btn--icon-user-delete" + title="Remove User" + aria-label="Remove User" + role="button" + tabindex="0" + data-ref="${userPk}" + data-role="user-delete-btn" + data-column="action" + > + Remove + </button> + </td> + </tr> + </template> + + <template data-name="table" data-view="OrgAuthority" data-for="dashboard"> + <thead> + <tr> + <td>Brand</td> + <td>Can Post</td> + <td>Can Moderate</td> + <td>Actions</td> + </tr> + </thead> + <tbody> + + </tbody> + </template> + + <template data-name="row" data-view="OrgAuthority" data-for="dashboard"> + <tr> + <td>${brand}</td> + <td> + <div class="checkbox-item-container"> + <input + id="${brand}-can-post" + class="checkbox-item" + type="checkbox" + title="Can Post" + aria-label="Can Post" + data-ref="${brandPk}" + data-role="brand-post-checkbox" + data-column="can_post" + /> + </div> + </td> + <td> + <div class="checkbox-item-container"> + <input + id="${brand}-can-moderate" + class="checkbox-item" + type="checkbox" + title="Can Moderate" + aria-label="Can Moderate" + data-ref="${brandPk}" + data-role="brand-moderate-checkbox" + data-column="can_moderate" + /> + </div> + </td> + <td> + <button + class="outline-btn outline-btn--icon outline-btn--icon-delete" + title="Remove Brand" + aria-label="Remove Brand" + role="button" + tabindex="0" + data-ref="${brandPk}" + data-role="brand-delete-btn" + data-column="action" + > + Remove + </button> + </td> + </tr> + </template> + +{% endblock container %} + +{% block footer_wrapper %} + {% with hide_footer=True %} + {{ block.super }} + {% endwith %} +{% endblock footer_wrapper %} + +{% block cookie_wrapper %}{% endblock cookie_wrapper %} diff --git a/CodeListLibrary_project/cll/templates/clinicalcode/documentation/views/clinical-coded-phenotype-docs.html b/CodeListLibrary_project/cll/templates/clinicalcode/documentation/views/clinical-coded-phenotype-docs.html index fdbc52c84..6aecb866f 100644 --- a/CodeListLibrary_project/cll/templates/clinicalcode/documentation/views/clinical-coded-phenotype-docs.html +++ b/CodeListLibrary_project/cll/templates/clinicalcode/documentation/views/clinical-coded-phenotype-docs.html @@ -1,15 +1,17 @@ {% load static %} +{% load entity_renderer %} +{% get_brand_map_rules as brand_mapping %} <div class="page-split__left"> <aside class="scrollspy"> <div class="scrollspy__container"> <button class="scrollspy__container__item scrollspy__container__item--tertiary active" - aria-label="What are Phenotypes?" data-target="#what-are-phenotypes"> - <a href="#what-are-phenotypes">What are Phenotypes?</a> + aria-label="What are {{ brand_mapping.phenotype }}s?" data-target="#what-are-{{ brand_mapping.phenotype }}s"> + <a href="#what-are-{{ brand_mapping.phenotype }}s">What are {{ brand_mapping.phenotype }}s?</a> </button> <button class="scrollspy__container__item scrollspy__container__item--tertiary" - aria-label="Clinical Coded Phenotypes" data-target="#clinical-coded-phenotypes"> - <a href="#clinical-coded-phenotypes">Clinical Coded Phenotypes</a> + aria-label="{{ brand_mapping.phenotype }}s" data-target="#{{ brand_mapping.phenotype }}s"> + <a href="#{{ brand_mapping.phenotype }}s">{{ brand_mapping.phenotype }}s</a> </button> <button class="scrollspy__container__item scrollspy__container__item--tertiary" aria-label="Accessing the creation pages" data-target="#accessing-create"> @@ -24,16 +26,16 @@ <a href="#creation-wizard">The creation wizard</a> </button> <button class="scrollspy__container__item scrollspy__container__item--tertiary" - aria-label="Adding Concepts" data-target="#adding-concepts"> - <a href="#adding-concepts">Adding Concepts</a> + aria-label="Adding {{ brand_mapping.concept }}s" data-target="#adding-{{ brand_mapping.concept }}s"> + <a href="#adding-{{ brand_mapping.concept }}s">Adding {{ brand_mapping.concept }}s</a> </button> <button class="scrollspy__container__item scrollspy__container__item--tertiary" - aria-label="Creating a Concept" data-target="#creating-concepts"> - <a href="#creating-concepts">Creating a Concept</a> + aria-label="Creating a {{ brand_mapping.concept }}" data-target="#creating-{{ brand_mapping.concept }}s"> + <a href="#creating-{{ brand_mapping.concept }}s">Creating a {{ brand_mapping.concept }}</a> </button> <button class="scrollspy__container__item scrollspy__container__item--tertiary" - aria-label="Finalising your Concept" data-target="#finalising-concepts"> - <a href="#finalising-concepts">Finalising your Concept</a> + aria-label="Finalising your {{ brand_mapping.concept }}" data-target="#finalising-{{ brand_mapping.concept }}s"> + <a href="#finalising-{{ brand_mapping.concept }}s">Finalising your {{ brand_mapping.concept }}</a> </button> <button class="scrollspy__container__item scrollspy__container__item--tertiary" aria-label="Searchterm rules" data-target="#searchterm-rules"> @@ -44,12 +46,12 @@ <a href="#file-imports">File import rules</a> </button> <button class="scrollspy__container__item scrollspy__container__item--tertiary" - aria-label="Using Concepts as rules" data-target="#concept-as-rules"> - <a href="#concept-as-rules">Using Concepts as rules</a> + aria-label="Using {{ brand_mapping.concept }}s as rules" data-target="#{{ brand_mapping.concept }}-as-rules"> + <a href="#{{ brand_mapping.concept }}-as-rules">Using {{ brand_mapping.concept }}s as rules</a> </button> <button class="scrollspy__container__item scrollspy__container__item--tertiary" - aria-label="Importing Concepts" data-target="#importing-concepts"> - <a href="#importing-concepts">Importing Concepts</a> + aria-label="Importing {{ brand_mapping.concept }}s" data-target="#importing-{{ brand_mapping.concept }}s"> + <a href="#importing-{{ brand_mapping.concept }}s">Importing {{ brand_mapping.concept }}s</a> </button> </div> </aside> @@ -57,20 +59,20 @@ <div class="page-split__right"> <div class="page-split__container"> <h2> - Learn about Clinical Coded Phenotypes + Learn about {{ brand_mapping.phenotype }}s </h2> <section class="about-container__section__text"> <h3 class="subheader"> - <span class="scrollspy-target" id="what-are-phenotypes"></span> - What are Phenotypes? + <span class="scrollspy-target" id="what-are-{{ brand_mapping.phenotype }}s"></span> + What are {{ brand_mapping.phenotype }}s? </h3> <p> - A Phenotype is an observable and measurable piece of information that is relevant to health or healthcare. + A {{ brand_mapping.phenotype }} is an observable and measurable piece of information that is relevant to health or healthcare. For example, it can be a disease (e.g. type 2 diabetes), a blood pressure measurement, a blood sugar value or a prescription of antibiotics. </p> <p> - Phenotyping algorithms are special tools that enable researchers to extract Phenotypes from complex, and + Phenotyping algorithms are special tools that enable researchers to extract {{ brand_mapping.phenotype }}s from complex, and often messy data that get generated during routine interactions within the healthcare system. They identify and extract data from medical records using clinical codes which are the building block of how information is recorded in healthcare (for example ICD-10). Using these specialised algorithms, @@ -80,35 +82,35 @@ <h3 class="subheader"> </section> <section class="about-container__section__text"> <h3 class="subheader"> - <span class="scrollspy-target" id="clinical-coded-phenotypes"></span> - Clinical Coded Phenotypes + <span class="scrollspy-target" id="{{ brand_mapping.phenotype }}s"></span> + {{ brand_mapping.phenotype }}s </h3> <p> - Clinical Coded Phenotype are Phenotype definitions that are applied to structured health data and - involve the use of clinical code lists. A Clinical Coded Phenotype may have one or more code lists, - referred to as a “<em>Concept</em>”. + {{ brand_mapping.phenotype }} are definitions that are applied to structured health data and + involve the use of clinical codelists. A {{ brand_mapping.phenotype }} may have one or more components associated with them, + referred to as a “<em>{{ brand_mapping.concept }}</em>”. </p> <h4> - Concepts + {{ brand_mapping.concept }}s </h4> <p> - Each Concept defines a single code list using a single clinical coding system, and having a particular meaning. + Each {{ brand_mapping.concept }} defines a single codelist using a single clinical coding system, and having a particular meaning. </p> <p> - Different concepts may define the Phenotype in different contexts: for example, an asthma Phenotype may have a - Concept defining asthma in secondary care using ICD-10, and another Concept defining asthma in primary care + Different {{ brand_mapping.concept }}s may define the {{ brand_mapping.phenotype }} in different contexts: for example, an asthma {{ brand_mapping.phenotype }} may have a + {{ brand_mapping.concept }} defining asthma in secondary care using ICD-10, and another {{ brand_mapping.concept }} defining asthma in primary care using SNOMED-CT. </p> <p> - Concepts may also define different attributes or events associated with a Phenotype. For example, a depression - Phenotype may have a Concept defining depression symptoms, another defining a diagnosis of depression, and a + {{ brand_mapping.concept }} may also define different attributes or events associated with a {{ brand_mapping.phenotype }}. For example, a depression + {{ brand_mapping.phenotype }} may have a {{ brand_mapping.concept }} defining depression symptoms, another defining a diagnosis of depression, and a third defining antidepressant medication. </p> <h4> - Defining Concepts with Rules + Defining {{ brand_mapping.concept }}s with Rules </h4> <p> - The codes within a Concept are defined using one or more rules. The rules are as follows: + The codes within a {{ brand_mapping.concept }} are defined using one or more rules. The rules are as follows: </p> <ul> <li> @@ -128,14 +130,14 @@ <h4> <em><strong>Please note:</strong></em> <ul> <li> - It is expected that your file contains only a single code list containing a set of codes - that are only associated with a single type of clinical terminology, <em>e.g.</em> an ICD-10 code list. + It is expected that your file contains only a single codelist containing a set of codes + that are only associated with a single type of clinical terminology, <em>e.g.</em> an ICD-10 codelist. </li> <li> - It is also expected that the file is composed of <strong>at least two rows</strong>: <em><strong>(1)</strong>the first containing the code</em>; + It is also expected that the file is composed of <strong>at least two columns</strong>: <em><strong>(1)</strong>the first containing the code</em>; and <em><strong>(1)</strong>the second containing the terms or description associated with that code</em>. <br/> - In the case of an ICD-10 code list, a row might look like this: “<em>A00.0,Cholera due to Vibrio cholerae 01, biovar cholerae</em>” + In the case of an ICD-10 codelist, a row might look like this: “<em>A00.0,Cholera due to Vibrio cholerae 01, biovar cholerae</em>” <br/> <strong>Note:</strong> In this example, the first column denotes the code and the second contains the description - the remaining columns, if any, would be ignored. </li> @@ -144,15 +146,15 @@ <h4> </li> <li> <p> - <strong>Import from another Phenotype:</strong> concepts that exist in other Phenotype in - the library can be referred to and used as building blocks to create a new Phenotype. - For example, a new Phenotype “<em>Heart Conditions</em>” could be created by combining existing + <strong>Import from another {{ brand_mapping.phenotype }}:</strong> {{ brand_mapping.concept }}s that exist in other {{ brand_mapping.phenotype }} in + the library can be referred to and used as building blocks to create a new {{ brand_mapping.phenotype }}. + For example, a new {{ brand_mapping.phenotype }} “<em>Heart Conditions</em>” could be created by combining existing definitions of heart attack, heart failure, high blood pressure, and other diseases. </p> </li> </ul> <p> - Multiple inclusion rules can be used to add codes and build up the code list; additionally, + Multiple inclusion rules can be used to add codes and build up the codelist; additionally, exclusion rules can be used to remove codes that would otherwise be included. </p> <h4> @@ -165,19 +167,19 @@ <h4> <li> <p> <strong>Excluding codes from a file upload:</strong> it is not possible to exclude codes imported with a file rule. - If you encounter this, we recommend you remove that code from your code list file. + If you encounter this, we recommend you remove that code from your codelist file. </p> </li> <li> <p> <strong>Custom coding systems:</strong> it is not possible to exclude custom codes - - the codes must match an existing, known code list that is available from our clinical terminology repository. + the codes must match an existing, known codelist that is available from our clinical terminology repository. </p> </li> <li> <p> <strong>Exclusionary types:</strong> it is not possible to exclude codes through a file upload - - codes can only be excluded via search rules or imported code lists. + codes can only be excluded via search rules or imported codelists. </p> </li> </ul> @@ -189,8 +191,8 @@ <h3 class="subheader"> </h3> <p> Users can access the creation page via the search page - this can be accessed by selecting - the “Phenotypes“ button (marked in red) seen in the navigation bar. - To begin working on a Phenotype users must click the link within the “Create a Phenotype“ + the “{{ brand_mapping.phenotype }}s“ button (marked in red) seen in the navigation bar. + To begin working on a {{ brand_mapping.phenotype }} users must click the link within the “Create a {{ brand_mapping.phenotype }}“ card (seen below in pink). </p> <img loading="lazy" class="about-container__alt-image" @@ -203,27 +205,19 @@ <h3 class="subheader"> Selecting your template </h3> <p> - To start creating a Phenotype the users must select the template they wish to follow. + To start creating a {{ brand_mapping.phenotype }} the users must select the template they wish to follow. Different templates may be created for different types of algorithms, data settings, etc. - Currently, two types of Phenotype are supported: </p> <ul> <li> <p> - <strong>Clinical Coded Phenotype:</strong> Phenotypes that apply to structured + <strong>Clinical Coded {{ brand_mapping.phenotype }}:</strong> {{ brand_mapping.phenotype }}s that apply to structured health data and involve the use of clinical codes. </p> </li> - <li> - <p> - <strong>Structured data algorithm:</strong> Phenotypes that apply to structured - health data, but do not relate to specific clinical codes and don’t contain - any list of codes. - </p> - </li> </ul> <p> - This tutorial relates to the process for Clinical Coded Phenotypes, though other + This tutorial relates to the process for Clinical Coded {{ brand_mapping.phenotype }}s, though other templates will be similar. Click the appropriate template button as shown below: </p> <img loading="lazy" class="about-container__alt-image" @@ -247,28 +241,28 @@ <h3 class="subheader"> </section> <section class="about-container__section__text"> <h3 class="subheader"> - <span class="scrollspy-target" id="adding-concepts"></span> - Adding Concepts + <span class="scrollspy-target" id="adding-{{ brand_mapping.concept }}s"></span> + Adding {{ brand_mapping.concept }}s </h3> <p> - The “<em>Clinical Code List</em>” section allows researchers to define the Concepts - and associated codes that will be used to define their Phenotype. To start, - the user can either (a) create a new Concept (red) or (b) import an existing - Concept from another Phenotype (pink). + The “<em>Clinical codelist</em>” section allows researchers to define the {{ brand_mapping.concept }}s + and associated codes that will be used to define their {{ brand_mapping.phenotype }}. To start, + the user can either (a) create a new {{ brand_mapping.concept }} (red) or (b) import an existing + {{ brand_mapping.concept }} from another {{ brand_mapping.phenotype }} (pink). </p> <img loading="lazy" class="about-container__alt-image" src="{% static 'img/documentation/clinical-coded-phenotypes/image4.png' %}" - alt="Adding Concepts to your Phenotype" /> + alt="Adding {{ brand_mapping.concept }}s to your {{ brand_mapping.phenotype }}" /> </section> <section class="about-container__section__text"> <h3 class="subheader"> - <span class="scrollspy-target" id="creating-concepts"></span> - Creating a Concept + <span class="scrollspy-target" id="creating-{{ brand_mapping.concept }}s"></span> + Creating a {{ brand_mapping.concept }} </h3> <p> - If the user decides to create a new Concept they will be met with the + If the user decides to create a new {{ brand_mapping.concept }} they will be met with the option to define the coding system that will be used to create the - Concept, and to define some basic metadata such as the Concept’s name. + {{ brand_mapping.concept }}, and to define some basic metadata such as the {{ brand_mapping.concept }}’s name. </p> <p> As seen in the image below, a coding system must be selected first @@ -286,15 +280,15 @@ <h3 class="subheader"> alt="Adding a new Rule" /> <p> After adding or modifying a rule the user will be notified - of the changes made to the code list by a toast notification on + of the changes made to the codelist by a toast notification on the bottom right of the screen (seen below in pink). </p> <img loading="lazy" class="about-container__alt-image" src="{% static 'img/documentation/clinical-coded-phenotypes/image7.png' %}" alt="Adding a codes with your Rule" /> <p> - The code list, as generated by the user’s rules, will be visible - in the “<em>code list</em>” section. The table columns can be sorted by + The codelist, as generated by the user’s rules, will be visible + in the “<em>codelist</em>” section. The table columns can be sorted by clicking on the header of each column. </p> <p> @@ -304,7 +298,7 @@ <h3 class="subheader"> <li> <p> The “<em>Final</em>” column (red box) describes whether the code will - be included or excluded from the final code list + be included or excluded from the final codelist </p> </li> <li> @@ -321,22 +315,22 @@ <h3 class="subheader"> </section> <section class="about-container__section__text"> <h3 class="subheader"> - <span class="scrollspy-target" id="finalising-concepts"></span> - Finalising your Concept + <span class="scrollspy-target" id="finalising-{{ brand_mapping.concept }}s"></span> + Finalising your {{ brand_mapping.concept }} </h3> <p> - After finalising the Concept’s associated rules and codes, the user can + After finalising the {{ brand_mapping.concept }}’s associated rules and codes, the user can click the “<em>Confirm</em>” button to save those changes locally. - They will be prompted to do so if they try to submit the Phenotype + They will be prompted to do so if they try to submit the {{ brand_mapping.phenotype }} before doing so. </p> <p> If they decide to cancel these changes by clicking the “<em>Cancel</em>” button - then the Concept will be reverted to its previous state, if any. + then the {{ brand_mapping.concept }} will be reverted to its previous state, if any. </p> <img loading="lazy" class="about-container__alt-image" src="{% static 'img/documentation/clinical-coded-phenotypes/image9.png' %}" - alt="Finalising your Concept" /> + alt="Finalising your {{ brand_mapping.concept }}" /> </section> <section class="about-container__section__text"> <h3 class="subheader"> @@ -363,9 +357,9 @@ <h3 class="subheader"> File import rules </h3> <p> - These can be used to fulfil the need to upload code lists that have + These can be used to fulfil the need to upload codelists that have been created in the past, or can be used to support workflows where - users are modifying or iterating on code lists that have been used + users are modifying or iterating on codelists that have been used in research papers. </p> <p> @@ -377,50 +371,50 @@ <h3 class="subheader"> </section> <section class="about-container__section__text"> <h3 class="subheader"> - <span class="scrollspy-target" id="concept-as-rules"></span> - Using Concepts as rules + <span class="scrollspy-target" id="{{ brand_mapping.concept }}-as-rules"></span> + Using {{ brand_mapping.concept }}s as rules </h3> <p> - Concepts from other Phenotypes can be used as rules to build up or - modify a code list. This allows combining different specific - concepts to create a more general one, etc. + {{ brand_mapping.concept }}s from other {{ brand_mapping.phenotype }}s can be used as rules to build up or + modify a codelist. This allows combining different specific + {{ brand_mapping.concept }}s to create a more general one, etc. </p> <p> - When a Concept is added as a rule, only the final code list of - that Concept is imported, and those codes are used as an + When a {{ brand_mapping.concept }} is added as a rule, only the final codelist of + that {{ brand_mapping.concept }} is imported, and those codes are used as an inclusion or exclusion criterion in a similar manner to other rules. </p> <p> - If you want to add an entire existing Concept to a Phenotype + If you want to add an entire existing {{ brand_mapping.concept }} to a {{ brand_mapping.phenotype }} and present it in its original form, do not add it as a rule, - but use “<em>Import concepts</em>” instead (see below). + but use “<em>Import {{ brand_mapping.concept }}s</em>” instead (see below). </p> <img loading="lazy" class="about-container__alt-image" src="{% static 'img/documentation/clinical-coded-phenotypes/image12.png' %}" - alt="Using a Concept as a Rule" /> + alt="Using a {{ brand_mapping.concept }} as a Rule" /> </section> <section class="about-container__section__text"> <h3 class="subheader"> - <span class="scrollspy-target" id="importing-concepts"></span> - Importing Concepts + <span class="scrollspy-target" id="importing-{{ brand_mapping.concept }}s"></span> + Importing {{ brand_mapping.concept }}s </h3> <p> - Importing a Concept from another Phenotype adds it to the new - Phenotype as a top-level entity, with all its rules presented - as they were in the original Phenotype. + Importing a {{ brand_mapping.concept }} from another {{ brand_mapping.phenotype }} adds it to the new + {{ brand_mapping.phenotype }} as a top-level entity, with all its rules presented + as they were in the original {{ brand_mapping.phenotype }}. </p> <p> - By selecting the “<em>Import Concepts</em>” button, the user will - be prompted by a modal to select the Concepts they would + By selecting the “<em>Import {{ brand_mapping.concept }}s</em>” button, the user will + be prompted by a modal to select the {{ brand_mapping.concept }}s they would like to import. </p> <img loading="lazy" class="about-container__alt-image" src="{% static 'img/documentation/clinical-coded-phenotypes/image13.png' %}" - alt="Importing one or more Concepts" /> + alt="Importing one or more {{ brand_mapping.concept }}s" /> <p> - The user can select one or more Concepts to import by expanding - the “<em>Available Concepts</em>” section and clicking each individual - Concept they would like to import. + The user can select one or more {{ brand_mapping.concept }}s to import by expanding + the “<em>Available {{ brand_mapping.concept }}s</em>” section and clicking each individual + {{ brand_mapping.concept }} they would like to import. </p> <p> After confirming their choices, they will be met with a screen @@ -428,7 +422,7 @@ <h3 class="subheader"> </p> <img loading="lazy" class="about-container__alt-image" src="{% static 'img/documentation/clinical-coded-phenotypes/image14.png' %}" - alt="Imported Concepts in the viewer" /> + alt="Imported {{ brand_mapping.concept }}s in the viewer" /> </section> </div> </div> diff --git a/CodeListLibrary_project/cll/templates/clinicalcode/email/account_inv_email.html b/CodeListLibrary_project/cll/templates/clinicalcode/email/account_inv_email.html new file mode 100644 index 000000000..b49f0c192 --- /dev/null +++ b/CodeListLibrary_project/cll/templates/clinicalcode/email/account_inv_email.html @@ -0,0 +1,162 @@ +{% load entity_renderer %} + +{% block content %} + +<!DOCTYPE html> +<html> + <head> + <style type="text/css"> + body { + margin:0; + padding: 1em 0 1em 0; + font-family: Arial, sans-serif; + text-align:left; + background-color:#ebebeb; + } + + @media (max-width: 600px) { + .content { + width: 100%; + padding: 0; + } + + a.card, + .footer-icons img, + .title-icon { + width: 100%; + height: auto; + } + } + + @media only screen and (max-width: 480px) { + img.title-icon { + height: 40px !important; + width: auto !important; + } + img[src="cid:combined"] { + height: 50px !important; + width: auto !important; + } + } + + .mail-content { + background-color: #fff; + border-radius: 0.625rem; + margin: 0 auto; + max-width: 38.5rem; + padding: 2em; + } + + .mail-title { + color: #525252; + } + + .mail-title__icon { + margin-right: 0.625em; + vertical-align: middle; + } + + .mail-footer p, + .mail-footer a { + color: #8c8c8c; + } + + .mail-footer__icons { + display:flex; + flex-flow: row wrap; + align-items: center; + justify-content: flex-start; + gap: 1.5rem; + margin-top: 1rem; + } + + .mail-footer__image { + height: 100%; + max-width: 100%; + max-height: 70px; + aspect-ratio: 6 / 1; + } + + .status-card { + display: block; + margin: 1.25em 0; + padding: 1.25em; + border: 0.0625rem solid #ddd; + border-radius: 0.625rem; + box-shadow: 0 0.1875rem 1.25rem rgba(0, 0, 0, 0.15); + color: black; + text-decoration:none; + } + + .status-report { + color: #8c8c8c; + line-height: 1.70em; + margin-bottom: 1.56em; + } + + .status-report strong { + color: #363636; + } + + .status-report--status { + display: flex; + flex-flow: row wrap; + align-items: center; + } + + .status-title { + color: #4e69c2; + margin-bottom: 1.23em; + } + + .status-button { + border: none; + border-radius: 0.3125rem; + color: #fff; + cursor: pointer; + padding: 0.625em; + text-decoration:none; + } + + .status-button--published { + background-color: #13d601; + } + + .status-button--pending { + background-color: #ffcc00; + } + + .status-button--rejected { + background-color: #ff0101; + } + </style> + </head> + <body> + {% get_brand_base_title request.BRAND_OBJECT as base_page_title %} + <div class="mail-content"> + <h2 class="mail-title"> + <img class="mail-title__icon" src="cid:mainlogo" alt="{{ base_page_title }}" height="60" valign="middle" /> + Notification from {{ base_page_title }} + </h2> + + <p>You've been invited to join the {{ base_page_title }}.</p> + <p>Your username has been set to: {{ username }}</p> + <p> + Please click + <a href="{{ request.scheme }}://{{ request.get_host }}{% url 'password_reset_confirm' uidb64=uid token=token %}">here</a> + to set up your password. + </p> + + <div class="mail-footer"> + <div class="mail-footer__icons"> + <img class="mail-footer__image" src="cid:sponsors" alt="Sponsors" /> + </div> + <p> + <a href="{{ request.scheme }}://{{ request.get_host }}/contact-us">Contact Us</a> + - Copyright © 2023 - SAIL Databank - Swansea University. All rights reserved. + </p> + </div> + </div> + </body> +</html> +{% endblock content %} diff --git a/CodeListLibrary_project/cll/templates/clinicalcode/email/email_content.html b/CodeListLibrary_project/cll/templates/clinicalcode/email/email_content.html index 38b0e9bef..ac30a251b 100644 --- a/CodeListLibrary_project/cll/templates/clinicalcode/email/email_content.html +++ b/CodeListLibrary_project/cll/templates/clinicalcode/email/email_content.html @@ -1,4 +1,7 @@ +{% load entity_renderer %} + {% block content %} + <!DOCTYPE html> <html> <head> @@ -129,10 +132,11 @@ </style> </head> <body> + {% get_brand_base_title request.BRAND_OBJECT as base_page_title %} <div class="mail-content"> <h2 class="mail-title"> - <img class="mail-title__icon" src="cid:mainlogo" alt="Concept Library" height="60" valign="middle" /> - Notification from Concept Library + <img class="mail-title__icon" src="cid:mainlogo" alt="{{ base_page_title }}" height="60" valign="middle" /> + Notification from {{ base_page_title }} </h2> {% include "clinicalcode/email/email_card.html" %} diff --git a/CodeListLibrary_project/cll/templates/clinicalcode/email/invite_email.html b/CodeListLibrary_project/cll/templates/clinicalcode/email/invite_email.html new file mode 100644 index 000000000..357f8c1c8 --- /dev/null +++ b/CodeListLibrary_project/cll/templates/clinicalcode/email/invite_email.html @@ -0,0 +1,161 @@ +{% load entity_renderer %} + +{% block content %} + +<!DOCTYPE html> +<html> + <head> + <style type="text/css"> + body { + margin:0; + padding: 1em 0 1em 0; + font-family: Arial, sans-serif; + text-align:left; + background-color:#ebebeb; + } + + @media (max-width: 600px) { + .content { + width: 100%; + padding: 0; + } + + a.card, + .footer-icons img, + .title-icon { + width: 100%; + height: auto; + } + } + + @media only screen and (max-width: 480px) { + img.title-icon { + height: 40px !important; + width: auto !important; + } + img[src="cid:combined"] { + height: 50px !important; + width: auto !important; + } + } + + .mail-content { + background-color: #fff; + border-radius: 0.625rem; + margin: 0 auto; + max-width: 38.5rem; + padding: 2em; + } + + .mail-title { + color: #525252; + } + + .mail-title__icon { + margin-right: 0.625em; + vertical-align: middle; + } + + .mail-footer p, + .mail-footer a { + color: #8c8c8c; + } + + .mail-footer__icons { + display:flex; + flex-flow: row wrap; + align-items: center; + justify-content: flex-start; + gap: 1.5rem; + margin-top: 1rem; + } + + .mail-footer__image { + height: 100%; + max-width: 100%; + max-height: 70px; + aspect-ratio: 6 / 1; + } + + .status-card { + display: block; + margin: 1.25em 0; + padding: 1.25em; + border: 0.0625rem solid #ddd; + border-radius: 0.625rem; + box-shadow: 0 0.1875rem 1.25rem rgba(0, 0, 0, 0.15); + color: black; + text-decoration:none; + } + + .status-report { + color: #8c8c8c; + line-height: 1.70em; + margin-bottom: 1.56em; + } + + .status-report strong { + color: #363636; + } + + .status-report--status { + display: flex; + flex-flow: row wrap; + align-items: center; + } + + .status-title { + color: #4e69c2; + margin-bottom: 1.23em; + } + + .status-button { + border: none; + border-radius: 0.3125rem; + color: #fff; + cursor: pointer; + padding: 0.625em; + text-decoration:none; + } + + .status-button--published { + background-color: #13d601; + } + + .status-button--pending { + background-color: #ffcc00; + } + + .status-button--rejected { + background-color: #ff0101; + } + </style> + </head> + <body> + {% get_brand_base_title request.BRAND_OBJECT as base_page_title %} + <div class="mail-content"> + <h2 class="mail-title"> + <img class="mail-title__icon" src="cid:mainlogo" alt="{{ base_page_title }}" height="60" valign="middle" /> + Notification from {{ base_page_title }} + </h2> + + <p>You've been invited to join an organisation.</p> + <p> + Please click + <a href="{{ request.scheme }}://{{ request.get_host }}{% url 'view_invite_organisation' uuid=invite.uuid %}">here</a> + if you would like to view the invitation. + </p> + + <div class="mail-footer"> + <div class="mail-footer__icons"> + <img class="mail-footer__image" src="cid:sponsors" alt="Sponsors" /> + </div> + <p> + <a href="{{ request.scheme }}://{{ request.get_host }}/contact-us">Contact Us</a> + - Copyright © 2023 - SAIL Databank - Swansea University. All rights reserved. + </p> + </div> + </div> + </body> +</html> +{% endblock content %} diff --git a/CodeListLibrary_project/cll/templates/clinicalcode/email/password_reset_email.html b/CodeListLibrary_project/cll/templates/clinicalcode/email/password_reset_email.html new file mode 100644 index 000000000..b7e90b078 --- /dev/null +++ b/CodeListLibrary_project/cll/templates/clinicalcode/email/password_reset_email.html @@ -0,0 +1,162 @@ +{% load entity_renderer %} + +{% block content %} + +<!DOCTYPE html> +<html> + <head> + <style type="text/css"> + body { + margin:0; + padding: 1em 0 1em 0; + font-family: Arial, sans-serif; + text-align:left; + background-color:#ebebeb; + } + + @media (max-width: 600px) { + .content { + width: 100%; + padding: 0; + } + + a.card, + .footer-icons img, + .title-icon { + width: 100%; + height: auto; + } + } + + @media only screen and (max-width: 480px) { + img.title-icon { + height: 40px !important; + width: auto !important; + } + img[src="cid:combined"] { + height: 50px !important; + width: auto !important; + } + } + + .mail-content { + background-color: #fff; + border-radius: 0.625rem; + margin: 0 auto; + max-width: 38.5rem; + padding: 2em; + } + + .mail-title { + color: #525252; + } + + .mail-title__icon { + margin-right: 0.625em; + vertical-align: middle; + } + + .mail-footer p, + .mail-footer a { + color: #8c8c8c; + } + + .mail-footer__icons { + display:flex; + flex-flow: row wrap; + align-items: center; + justify-content: flex-start; + gap: 1.5rem; + margin-top: 1rem; + } + + .mail-footer__image { + height: 100%; + max-width: 100%; + max-height: 70px; + aspect-ratio: 6 / 1; + } + + .status-card { + display: block; + margin: 1.25em 0; + padding: 1.25em; + border: 0.0625rem solid #ddd; + border-radius: 0.625rem; + box-shadow: 0 0.1875rem 1.25rem rgba(0, 0, 0, 0.15); + color: black; + text-decoration:none; + } + + .status-report { + color: #8c8c8c; + line-height: 1.70em; + margin-bottom: 1.56em; + } + + .status-report strong { + color: #363636; + } + + .status-report--status { + display: flex; + flex-flow: row wrap; + align-items: center; + } + + .status-title { + color: #4e69c2; + margin-bottom: 1.23em; + } + + .status-button { + border: none; + border-radius: 0.3125rem; + color: #fff; + cursor: pointer; + padding: 0.625em; + text-decoration:none; + } + + .status-button--published { + background-color: #13d601; + } + + .status-button--pending { + background-color: #ffcc00; + } + + .status-button--rejected { + background-color: #ff0101; + } + </style> + </head> + <body> + {% get_brand_base_title request.BRAND_OBJECT as base_page_title %} + <div class="mail-content"> + <h2 class="mail-title"> + <img class="mail-title__icon" src="cid:mainlogo" alt="{{ base_page_title }}" height="60" valign="middle" /> + Notification from {{ base_page_title }} + </h2> + + <p>A password reset request has been received for your {{ user.username }} account.</p> + <p>If you didn't request this then please ignore this email.</p> + <p> + Otherwise, please click + <a href="{{ request.scheme }}://{{ request.get_host }}{% url 'password_reset_confirm' uidb64=uid token=token %}">here</a> + to reset your password. + </p> + + <div class="mail-footer"> + <div class="mail-footer__icons"> + <img class="mail-footer__image" src="cid:sponsors" alt="Sponsors" /> + </div> + <p> + <a href="{{ request.scheme }}://{{ request.get_host }}/contact-us">Contact Us</a> + - Copyright © 2023 - SAIL Databank - Swansea University. All rights reserved. + </p> + </div> + </div> + </body> +</html> +{% endblock content %} diff --git a/CodeListLibrary_project/cll/templates/clinicalcode/generic_entity/creation/create.html b/CodeListLibrary_project/cll/templates/clinicalcode/generic_entity/creation/create.html index 06d4cb107..cce7b6d79 100644 --- a/CodeListLibrary_project/cll/templates/clinicalcode/generic_entity/creation/create.html +++ b/CodeListLibrary_project/cll/templates/clinicalcode/generic_entity/creation/create.html @@ -7,13 +7,14 @@ {% load breadcrumbs %} {% load entity_renderer %} -{% block title %}| {% if form_method.value == 1 %} Create your Phenotype {% else %} Update your Phenotype{% endif %}{% endblock title %} +{% block title %}| {% get_template_entity_name template.entity_class template as entity_shortname %}{% if form_method.value == 1 %} Create {{ entity_shortname }}{% else %} Update {{ entity_shortname }}{% endif %}{% endblock title %} {% block embedding_wrapper %} + {% get_template_entity_name template.entity_class template as entity_shortname %} {% if form_method.value == 1 %} - {% render_og_tags header='Create Phenotype' %} + {% render_og_tags header='Create '|add:entity_shortname %} {% else %} - {% render_og_tags header='Update Phenotype' %} + {% render_og_tags header='Update '|add:entity_shortname %} {% endif %} {% endblock embedding_wrapper %} @@ -24,15 +25,15 @@ {% endblock indexing_robots %} {% block container %} + {% get_brand_map_rules as brand_mapping %} <script src="{% static 'js/lib/simple-datatables/simple-datatables.min.js' %}"></script> - <script src="{% static 'js/lib/gridjs/gridjs.production.min.js' %}"></script> <script src="{% static 'js/lib/easymde/easymde.min.js' %}"></script> <script src="{% static 'js/lib/moment.min.js' %}"></script> <script src="{% static 'js/lib/xlsx.mini.min.js' %}"></script> {% compress js %} <script type="text/javascript" src="{% static 'js/lib/lightpick.js' %}"></script> - <script type="text/javascript" src="{% static 'js/lib/gridjs/gridjs.production.min.js' %}"></script> + <script type="text/javascript" src="{% static 'js/clinicalcode/components/numberInput.js' %}"></script> <script type="text/javascript" src="{% static 'js/clinicalcode/components/dropdown.js' %}"></script> <script type="text/javascript" src="{% static 'js/clinicalcode/components/tooltipFactory.js' %}"></script> <script type="module" src="{% static 'js/clinicalcode/components/entityCreator.js' %}"></script> @@ -44,10 +45,23 @@ <link rel="stylesheet" href="{% sass_src 'scss/pages/create.scss' %}" type="text/css" /> {% endcompress %} + {% get_template_entity_name template.entity_class template as entity_shortname %} <header class="main-header"> - <div class="main-header__inner-container main-header__inner-container--constrained main-header__inner-container--centred"> - {% breadcrumbs useMap=False includeHome=True includeHeader=False %} - {% endbreadcrumbs %} + <div class="main-header__inner-container main-header__inner-container--centre-constrained"> + <section class="breadcrumbs"> + <span class="breadcrumb-item"> + <span class="marker"></span> + <span class="breadcrumb"> + <a href="/">Home</a> + </span> + </span> + <span class="breadcrumb-item"> + <span class="marker"></span> + <span class="breadcrumb"> + <a href="/HDRN/create/4">Create {{ entity_shortname }}</a> + </span> + </span> + </section> </div> </header> @@ -59,11 +73,11 @@ <article class="phenotype-creation"> <section class="phenotype-creation__header"> {% if form_method.value == 1 %} - <h1>Create a new Phenotype:<span>step by step</span></h1> - <p>Follow the steps below to create and publish your Phenotype.</p> + <h1>Create a new {{ entity_shortname }}:<span>step by step</span></h1> + <p>Follow the steps below to create and publish your {{ entity_shortname }}.</p> {% else %} - <h1>Update your Phenotype:<span>step by step</span></h1> - <p>Follow the steps below to update and publish your Phenotype.</p> + <h1>Update your {{ entity_shortname }}:<span>step by step</span></h1> + <p>Follow the steps below to update and publish your {{ entity_shortname }}.</p> {% endif %} </section> @@ -72,11 +86,11 @@ <h1>Update your Phenotype:<span>step by step</span></h1> <div class="phenotype-creation__alert-content"> <div class="phenotype-creation__alert-icon"></div> <div class="text"> - <h3>Legacy Phenotype</h3> - <p>You are editing a legacy version of this Phenotype.</p> + <h3>Legacy {{ entity_shortname }}</h3> + <p>You are editing a legacy version of this {{ entity_shortname }}.</p> <p> <strong>Please note: </strong> - If you save any changes to this Phenotype it will overwrite the most recent version. + If you save any changes to this {{ entity_shortname }} it will overwrite the most recent version. </p> </div> </div> @@ -93,11 +107,11 @@ <h3>Legacy Phenotype</h3> <button class="secondary-btn text-accent-darkest bold washed-accent" aria-label="Save Draft" id="cancel-entity-btn"> Cancel </button> - <button class="primary-btn text-accent-darkest bold tertiary-accent icon create-icon sweep-left" aria-label="Create or Update Entity" id="submit-entity-btn"> + <button class="primary-btn text-accent-darkest bold tertiary-accent icon icon-create sweep-left" aria-label="Create or Update Entity" id="submit-entity-btn"> {% if form_method.value == 1 %} - Create Phenotype + Create {{ entity_shortname }} {% else %} - Update Phenotype + Update {{ entity_shortname }} {% endif %} </button> </div> @@ -106,7 +120,7 @@ <h3>Legacy Phenotype</h3> </div> </main> - {% url 'search_phenotypes' as referral_url %} + {% url 'search_entities' as referral_url %} {% to_json_script None data-owner="entity-creator" id="referral-links" name="links" desc-type="text/json" referral-url=referral_url %} <script type="application/json" data-owner="entity-creator" id="form-method" name="method" desc-type="int">{{ form_method.value }}</script> @@ -127,6 +141,7 @@ <h3>Legacy Phenotype</h3> {% to_json_script metadata data-owner="entity-creator" id="metadata-data" name="metadata" desc-type="text/json" %} {% to_json_script template data-owner="entity-creator" id="template-data" name="template" desc-type="text/json" %} + {% to_json_script brand_mapping data-owner="entity-creator" id="mapping-data" name="mapping" desc-type="text/json" data-shortname=entity_shortname %} {% if entity is not None %} {% to_json_script entity data-owner="entity-creator" id="entity-data" name="entity" desc-type="text/json" %} diff --git a/CodeListLibrary_project/cll/templates/clinicalcode/generic_entity/creation/select.html b/CodeListLibrary_project/cll/templates/clinicalcode/generic_entity/creation/select.html index d793fb0e0..0531a4cc8 100644 --- a/CodeListLibrary_project/cll/templates/clinicalcode/generic_entity/creation/select.html +++ b/CodeListLibrary_project/cll/templates/clinicalcode/generic_entity/creation/select.html @@ -7,7 +7,7 @@ {% load breadcrumbs %} {% load entity_renderer %} -{% block title %}| Create a Phenotype {% endblock title %} +{% block title %}| Create a {% get_brand_mapped_string target="phenotype" default="Phenotype" %}{% endblock title %} {% block embedding_wrapper %} {% render_og_tags header='Create your Phenotype' %} @@ -27,16 +27,21 @@ <!-- Dependencies --> {% compress js %} - <script type="module" src="{% static 'js/clinicalcode/components/entitySelector.js' %}"></script> + <script type="module" src="{% static 'js/clinicalcode/components/entitySelector.js' %}"></script> {% endcompress %} + {% get_brand_map_rules as brand_mapping %} + <!-- Main --> + {% get_brand_base_title request.BRAND_OBJECT as base_page_title %} <header class="main-header banner"> <div class="main-header__inner-container main-header__inner-container--constrained-no-pad main-header__inner-container--centred"> <div class="banner__container"> - <h2 class="banner__title">Create a Phenotype</h2> + <h2 class="banner__title"> + Create a {{ brand_mapping.phenotype }} + </h2> <p class="banner__description"> - Select the type of Phenotype you want to create to start contributing to the Library. + Select the type of {{ brand_mapping.phenotype }} you want to create to start contributing to the {{ base_page_title }}. </p> <div class="banner__cards"></div> </div> @@ -55,7 +60,7 @@ <h2 class="banner__title">Create a Phenotype</h2> <article class="entity-panel"> <section class="entity-panel__group"> <h3 class="entity-panel__title">${title}</h3> - <p class="entity-panel__description">${description}</p> + <p class="entity-panel__description ${descCls}">${description}</p> <div class="entity-panel__container slim-scrollbar" id="entity-options"></div> </section> </article> diff --git a/CodeListLibrary_project/cll/templates/clinicalcode/generic_entity/detail/detail.html b/CodeListLibrary_project/cll/templates/clinicalcode/generic_entity/detail/detail.html index 9bee131df..37834f707 100644 --- a/CodeListLibrary_project/cll/templates/clinicalcode/generic_entity/detail/detail.html +++ b/CodeListLibrary_project/cll/templates/clinicalcode/generic_entity/detail/detail.html @@ -1,17 +1,17 @@ {% extends "base.html" %} {% load static %} -{% load markdownify %} +{% load compress %} +{% load sass_tags %} {% load cl_extras %} +{% load markdownify %} {% load breadcrumbs %} -{% load detail_pg_renderer %} +{% load entity_renderer %} {% load entity_publish_renderer %} -{% load compress %} -{% load sass_tags %} -{% block title %}| {{ entity_class }}: {{ entity.name }}{% endblock title %} -{% block description %}{{ entity_class }} {{ entity.id }}/{{ entity.history_id }} - {{ entity.name }}{% endblock description %} -{% block keywords %}, {{ entity.name }}, {{ entity.id }}/{{ entity.history_id }}, {{ entity_class }}{% endblock keywords %} +{% block title %}| {% get_template_entity_name entity_class template %}: {{ entity.name }}{% endblock title %} +{% block description %}{% get_template_entity_name entity_class template %} {{ entity.id }}/{{ entity.history_id }} - {{ entity.name }}{% endblock description %} +{% block keywords %}, {{ entity.name }}, {{ entity.id }}/{{ entity.history_id }}, {% get_template_entity_name entity_class template %}{% endblock keywords %} {% block canonical_path %} <link rel="canonical" href="{{ page_canonical_path }}" /> @@ -28,7 +28,7 @@ <script src="{% static 'js/lib/simple-datatables/simple-datatables.min.js' %}"></script> {% compress js %} - <script type="module" src="{% static 'js/clinicalcode/components/stepsWizard.js' %}"></script> + <script type="module" src="{% static 'js/clinicalcode/components/stepsWizard.js' %}"></script> {% endcompress %} <!-- Page Stylesheets --> @@ -38,7 +38,7 @@ <!-- Form --> <header class="main-header"> - <div class="main-header__inner-container main-header__inner-container--constrained main-header__inner-container--centred"> + <div class="main-header__inner-container main-header__inner-container--centre-constrained"> {% comment %} {% breadcrumbs useMap=False includeHome=True includeHeader=False %} {% endbreadcrumbs %} {% endcomment %} @@ -46,7 +46,7 @@ <span class="breadcrumb-item"> <span class="marker"></span> <span class="breadcrumb"> - <a href="{% url 'search_phenotypes' %}">Search</a> + <a href="{% url 'search_entities' %}">Search</a> </span> </span> <span class="breadcrumb-item"> @@ -58,38 +58,64 @@ </header> <main class="main-content"> - {% render_wizard_sidemenu %} - {% endrender_wizard_sidemenu %} + {% render_wizard_navigation detail_pg=True %} + {% endrender_wizard_navigation %} <div class="main-content__inner-container main-content__inner-container--constrained main-content__inner-container--centred"> <article class="phenotype-creation"> - {% include './detail_buttons.html' %} - <section class="phenotype-creation__header"> - <h1>{{ entity.name }}</h1> - <p><i>{{ entity.author }}</i></p> - <p> - <strong>{{ entity.id }} / {{ entity.history_id }}</strong> - {% if user.is_authenticated %} - ( - {% if is_latest_version %} - <span class="tag label label-highlighted wrapped-tspan">LATEST VERSION</span> - {% else %} - <span class="tag label label-default wrapped-tspan">LEGACY VERSION</span> + <div class="phenotype-creation__header-title"> + <h1>{{ entity.name }}</h1> + {% include './detail_buttons.html' %} + </div> + <div class="phenotype-creation__header-row phenotype-creation__header--fill phenotype-creation__header--jc-sb"> + <div class="phenotype-creation__header-col phenotype-creation__header--fmax-1"> + <div class="phenotype-creation__header-row phenotype-creation__header--no-wrap"> + <span class="as-icon phenotype-creation--icn" data-icon="" aria-hidden="true"></span> + <p><strong>{{ entity.id }} / {{ entity.history_id }}</strong></p> + </div> + {% if not entity.author|is_empty_value %} + <div class="phenotype-creation__header-row phenotype-creation__header--no-wrap"> + <span class="as-icon phenotype-creation--icn" data-icon="@" aria-hidden="true"></span> + <p><em>{{ entity.author }}</em></p> + </div> + {% endif %} + <div class="phenotype-creation__header-row phenotype-creation__header--no-wrap"> + <span class="as-icon phenotype-creation--icn" data-icon="" aria-hidden="true"></span> + <p>{{ entity.updated|date:'M j, Y' }}</p> + </div> + </div> + <div class="phenotype-creation__header-col phenotype-creation__header--ai-fe phenotype-creation__header--fmin-0"> + <span class="tag tag--detail tag--success"> + <strong> + <span class="as-icon phenotype-creation__header--mr-05" data-icon="" aria-hidden="true"></span> + {{ template.definition.template_details.name }} + </strong> + </span> + {% if user.is_authenticated %} + {% if is_latest_version %} + <span class="tag tag--detail tag--warning"> + <strong> + <span class="as-icon phenotype-creation__header--mr-05" data-icon="" aria-hidden="true"></span> + LATEST VERSION + </strong> + </span> + {% else %} + <span class="tag tag--detail tag--danger"> + <strong> + <span class="as-icon phenotype-creation__header--mr-05" data-icon="" aria-hidden="true"></span> + LEGACY VERSION + </strong> + </span> + {% endif %} {% endif %} - ) - {% endif %} - <span class="badge wrapped-tspan"> - <strong> - {{ template.definition.template_details.name }} - </strong> - </span> - </p> + </div> + </div> </section> <ol class="phenotype-progress" id="main-wizard"> - {% render_wizard_sections_detail_pg %} - {% endrender_wizard_sections_detail_pg %} + {% render_wizard_sections detail_pg=True %} + {% endrender_wizard_sections %} </ol> </article> </div> diff --git a/CodeListLibrary_project/cll/templates/clinicalcode/generic_entity/detail/detail_buttons.html b/CodeListLibrary_project/cll/templates/clinicalcode/generic_entity/detail/detail_buttons.html index 8ecad13d5..507f6902e 100644 --- a/CodeListLibrary_project/cll/templates/clinicalcode/generic_entity/detail/detail_buttons.html +++ b/CodeListLibrary_project/cll/templates/clinicalcode/generic_entity/detail/detail_buttons.html @@ -3,17 +3,21 @@ {% load entity_renderer %} {% load entity_publish_renderer %} -<div id="topButtons" class="box-container action-buttons-container"> +{% get_brand_map_rules as brand_mapping %} +<div id="topButtons" class="action-buttons-container"> <div class="detail-actions"> - <label class="dropdown-group"> + <label class="dropdown-group dropdown-group--detail"> <div class="dropdown-group__button"> - Export {{ entity_class }} + <span class="as-icon phenotype-creation--icn" data-icon="" aria-hidden="true"></span> + <span> + Export {% get_template_entity_name entity_class template %} + </span> </div> <input type="checkbox" class="dropdown-group__input" id="dd_1"> - <ul class="dropdown-group__menu dropdown-group__menu--fall-right" title="Export Phenotype"> - <li aria-label="Export phenotype as JSON" role="button" tabindex="0"> + <ul class="dropdown-group__menu dropdown-group__menu--fall-right" title="Export {{ brand_mapping.phenotype }}"> + <li aria-label="Export {{ brand_mapping.phenotype }} as JSON" role="button" tabindex="0"> <a href="{% url 'api:get_generic_entity_detail_by_version' entity.id entity.history_id %}?format=json" target=_blank aria-label="Export as JSON"> @@ -27,22 +31,24 @@ {% if user_can_edit %} {% url 'update_phenotype' entity.id entity.history_id as update_entity_url %} <button role="link" - class="primary-btn bold dropdown-btn__label" - title="Edit Phenotype" - aria-label="Edit Phenotype" + class="primary-btn bold dropdown-btn__label phenotype-creation__action-btn" + title="Edit {{ brand_mapping.phenotype }}" + aria-label="Edit {{ brand_mapping.phenotype }}" onClick="window.location.href='{{ update_entity_url }}';"> -  Edit  + <span class="as-icon phenotype-creation--icn" data-icon="" aria-hidden="true"></span> + <span>Edit</span> </button> {% endif %} {% if user_can_edit or user|is_member:"Moderators" or user.is_superuser%} {% if is_published and approval_status == APPROVED_STATUS_DICT.APPROVED %} <button id="publication-information" - class="primary-btn bold dropdown-btn__label text-success" + class="phenotype-creation__action-btn primary-btn bold dropdown-btn__label text-success" title="This version is already published" aria-disabled="true" disabled> -  Published  + <span class="as-icon phenotype-creation--icn" data-icon="" aria-hidden="true"></span> + <span>Published</span> </button> {% else %} {% render_publish_button %} diff --git a/CodeListLibrary_project/cll/templates/clinicalcode/generic_entity/examples/examples.html b/CodeListLibrary_project/cll/templates/clinicalcode/generic_entity/examples/examples.html index a99f0cd96..df82b389a 100644 --- a/CodeListLibrary_project/cll/templates/clinicalcode/generic_entity/examples/examples.html +++ b/CodeListLibrary_project/cll/templates/clinicalcode/generic_entity/examples/examples.html @@ -94,17 +94,17 @@ <h2 class="detailed-input-group__title">Buttons</h2> <!-- Primary button --> <div class="col-md-3"> <!-- Can change icons, accent, whether to bold etc --> - <button class="primary-btn text-accent-darkest bold tertiary-accent icon create-icon sweep-left" aria-label="Create Phenotype" id="create-phenotype">Create Phenotype</button> + <button class="primary-btn text-accent-darkest bold tertiary-accent icon icon-create sweep-left" aria-label="Create Phenotype" id="create-phenotype">Create Phenotype</button> </div> <!-- Secondary button --> <div class="col-md-2"> - <button class="secondary-btn text-accent-darkest bold icon import-icon secondary-accent" aria-label="Create new Concept" id="create-concept-btn">New Concept</button> + <button class="secondary-btn text-accent-darkest bold icon icon-import secondary-accent" aria-label="Create new Concept" id="create-concept-btn">New Concept</button> </div> <!-- Tertiary button --> <div class="col-md-2"> - <button class="tertiary-btn text-accent-darkest bold tertiary-accent icon left import-icon" aria-label="Create Phenotype" id="create-phenotype">Create Phenotype</button> + <button class="tertiary-btn text-accent-darkest bold tertiary-accent icon left icon-import" aria-label="Create Phenotype" id="create-phenotype">Create Phenotype</button> </div> <!-- Dropdown --> diff --git a/CodeListLibrary_project/cll/templates/clinicalcode/generic_entity/search/search.html b/CodeListLibrary_project/cll/templates/clinicalcode/generic_entity/search/search.html index fdb36015c..de388f1f6 100644 --- a/CodeListLibrary_project/cll/templates/clinicalcode/generic_entity/search/search.html +++ b/CodeListLibrary_project/cll/templates/clinicalcode/generic_entity/search/search.html @@ -6,10 +6,13 @@ {% load breadcrumbs %} {% load entity_renderer %} -{% block title %}| Search Phenotypes {% endblock title %} +{% block title %}| Search {% get_brand_mapped_string target="phenotype" default="Phenotype" %}s{% endblock title %} {% block embedding_wrapper %} - {% render_og_tags header='Search Phenotypes' %} + {% get_brand_mapped_string target="phenotype" default="Phenotype" as ph_entity_name %} + {% with header_trg='Search '|add:ph_entity_name|add:'s' %} + {% render_og_tags header=header_trg %} + {% endwith %} {% endblock embedding_wrapper %} {% block container %} diff --git a/CodeListLibrary_project/cll/templates/clinicalcode/index.html b/CodeListLibrary_project/cll/templates/clinicalcode/index.html index ae13017e6..5ee732490 100644 --- a/CodeListLibrary_project/cll/templates/clinicalcode/index.html +++ b/CodeListLibrary_project/cll/templates/clinicalcode/index.html @@ -1,4 +1,5 @@ {% extends "base.html" %} + {% load static %} {% load compress %} {% load sass_tags %} @@ -16,28 +17,30 @@ <script type="text/javascript" src="{% static 'js/clinicalcode/components/homeCounter.js' %}"></script> {% endcompress %} +{% get_brand_base_title request.BRAND_OBJECT as base_page_title %} <header class="main-header main-header--collapse homepage-hero-banner"> + {% get_brand_map_rules as brand_mapping %} <div class="main-header__inner-container main-header__inner-container--constrained main-header__inner-container--centred"> <section class="homepage-hero"> <div class="homepage-hero__container"> <div class="homepage-hero__details"> <h1> - Concept Library + {{ base_page_title }} </h1> <h2> - The Concept Library is a system for storing, managing, sharing, and documenting clinical code lists in health research. + {% get_brand_base_desc request.BRAND_OBJECT %} </h2> <p> Our goal is to create a system that describes research study designs in a machine-readable format to facilitate rapid study development; higher quality research; easier replication; and sharing of methods between researchers, institutions, and countries. </p> </div> - <form class="homepage-hero__search" action="{% url 'search_phenotypes' %}" method="GET"> - <input class="homepage-hero__search-input" aria-label="Search Phenotypes" type="text" + <form class="homepage-hero__search" action="{% url 'search_entities' %}" method="GET"> + <input class="homepage-hero__search-input" aria-label="Search {{ brand_mapping.phenotype }}s" type="text" id="data-search" name="search" placeholder="Search..." minlength="3"> <button class="homepage-hero__search-icon" tabindex="0" - name="page" value="1" aria-label="Go to Phenotype Search"> + name="page" value="1" aria-label="Go to {{ brand_mapping.phenotype }} Search"> </button> </form> </div> @@ -63,14 +66,14 @@ <h3 class="homepage-statistics__header-title"> <div class="homepage-statistics__card-content"> <header class="homepage-statistics__card-header"> <p> - Phenotypes + {{ brand_mapping.phenotype }}s </p> <p id="entity-counter" x-value="{{ published_phenotype_count }}" x-init="countup"> {{ published_phenotype_count|stylise_number }} </p> </header> <p class="homepage-statistics__card-text"> - A Phenotype defines how to measure real-world attributes of human health in data + A {{ brand_mapping.phenotype }} defines how to measure real-world attributes of human health in data. </p> </div> </div> @@ -78,14 +81,14 @@ <h3 class="homepage-statistics__header-title"> <div class="homepage-statistics__card-content"> <header class="homepage-statistics__card-header"> <p> - Concepts + {{ brand_mapping.concept }}s </p> <p id="entity-counter" x-value="{{ published_concept_count }}" x-init="countup"> {{ published_concept_count|stylise_number }} </p> </header> <p class="homepage-statistics__card-text"> - Concepts are individual lists of clinical codes defining a condition, treatment, and so forth + {{ brand_mapping.concept }} are individual lists of clinical codes defining a condition, treatment, and so forth. </p> </div> </div> @@ -100,7 +103,7 @@ <h3 class="homepage-statistics__header-title"> </p> </header> <p class="homepage-statistics__card-text"> - Data sources are datasets against which phenotypes may be defined - for example, + Data sources are datasets against which {{ brand_mapping.phenotype }}s may be defined - for example, routinely collected health datasets. </p> </div> @@ -116,7 +119,7 @@ <h3 class="homepage-statistics__header-title"> </p> </header> <p class="homepage-statistics__card-text"> - Clinical codes are the 'words' in standardized languages used to create electronic health records + Clinical codes are the 'words' in standardized languages used to create electronic health records. </p> </div> </div> @@ -132,7 +135,7 @@ <h3 class="homepage-statistics__header-title"> </header> <p class="homepage-statistics__card-text"> Clinical Coding Systems are the languages used to capture electronic health records - in a standardized format + in a standardized format. </p> </div> </div> @@ -159,7 +162,7 @@ <h4> </div> </div> <div class="homepage-about__brand-footer"> - <a class="homepage-about__brand-anchor" href="/{{ obj.name }}"> + <a class="homepage-about__brand-anchor" href="{% get_brand_base_website brand=obj %}" target=_blank> View Site <span class="homepage-about__brand-anchor-icon"></span> </a> @@ -172,20 +175,25 @@ <h3 class="homepage-about__info-title"> What do we do? </h3> <p> - The Concept Library is an open source software application enabling researchers to create, + The {{ base_page_title }} is an open source software application enabling researchers to create, document and share definitions and algorithms that are used in health data research. This tool serves as a key enabler to the open research agenda, driving efficient, high quality, and repeatable research. </p> <p> - For example, the <a href="https://www.go-fair.org/fair-principles/">FAIR principles</a> state that + For example, the <a href="https://www.go-fair.org/fair-principles/" target=_blank>FAIR principles</a> state that digital assets used in research should be <em>"Findable, Accessible, Interoperable, and Reusable"</em>. - The Library implements a solution to those goals within the electronic phenotype space. + The {{ base_page_title }} implements a solution to those goals within the electronic {{ brand_mapping.phenotype }} space. </p> <p> - Originally developed by the SAIL Databank team, the Concept Library has been adopted as - a sharing solution by multiple organizations, including Health Data Research UK. - A multi-institutional, interdisciplinary team is responsible for ongoing development. + Originally developed by the SAIL Databank team, the {{ base_page_title }} has been adopted as + a sharing solution by multiple organizations, including + {% if request.CURRENT_BRAND != '' %} + HDRN. + {% else %} + Health Data Research UK. + A multi-institutional, interdisciplinary team is responsible for ongoing development. + {% endif %} </p> <div class="homepage-about__info-buttons"> @@ -220,7 +228,7 @@ <h3 class="homepage-features__header-title"> Build </span> <span class="homepage-features__details-text"> - Build codelists from our wide range of coding systems to define your phenotypes and concepts. + Build codelists from our wide range of coding systems to define your {{ brand_mapping.phenotype }}s and {{ brand_mapping.concept }}s. </span> </div> </div> @@ -231,7 +239,7 @@ <h3 class="homepage-features__header-title"> Repository </span> <span class="homepage-features__details-text"> - Store code lists along with metadata that captures important information about quality, author, etc. + Store codelists along with metadata that captures important information about quality, author, etc. </span> </div> </div> @@ -242,7 +250,7 @@ <h3 class="homepage-features__header-title"> Versioning </span> <span class="homepage-features__details-text"> - Store version history and provide a way to unambiguously reference a particular version of a code list. + Store version history and provide a way to unambiguously reference a particular version of a codelist. </span> </div> </div> @@ -264,7 +272,7 @@ <h3 class="homepage-features__header-title"> Sharing </span> <span class="homepage-features__details-text"> - Provide a mechanism for sharing code lists between projects and organizations. + Provide a mechanism for sharing codelists between projects and organizations. </span> </div> </div> @@ -294,28 +302,30 @@ <h3 class="homepage-carousel__header-title"> <div class="homepage-carousel__items"> <div class="homepage-carousel__item"> <span class="homepage-carousel__item-subtitle"> - Explore Phenotypes + Explore {{ brand_mapping.phenotype }}s </span> <span class="homepage-carousel__item-text"> - View the Library's Phenotypes. + View the {{ base_page_title }}'s {{ brand_mapping.phenotype }}s. </span> - <a class="secondary-btn text-accent-darkest bold bubble-accent" aria-label="Search Phenotypes" - id="explore-btn" href="{% url 'search_phenotypes' %}"> + <a class="secondary-btn text-accent-darkest bold bubble-accent" aria-label="Search {{ brand_mapping.phenotype }}s" + id="explore-btn" href="{% url 'search_entities' %}"> Search </a> </div> - <div class="homepage-carousel__item"> - <span class="homepage-carousel__item-subtitle"> - Create a Phenotype - </span> - <span class="homepage-carousel__item-text"> - Start here to contribute to the Library. - </span> - <a class="secondary-btn text-accent-darkest bold bubble-accent" aria-label="Create a Phenotype" - id="create-btn" href="{% url 'create_phenotype' %}"> - Create - </a> - </div> + {% if USER_CREATE_CONTEXT %} + <div class="homepage-carousel__item"> + <span class="homepage-carousel__item-subtitle"> + Create a {{ brand_mapping.phenotype }} + </span> + <span class="homepage-carousel__item-text"> + Start here to contribute to the {{ base_page_title }}. + </span> + <a class="secondary-btn text-accent-darkest bold bubble-accent" aria-label="Create a {{ brand_mapping.phenotype }}" + id="create-btn" href="{% url 'create_phenotype' %}"> + Create + </a> + </div> + {% endif %} </div> </div> </section> @@ -334,22 +344,22 @@ <h3 class="homepage-principles__header-title"> <div class="homepage-principles__principle"> <div class="homepage-principles__details"> <p class="homepage-principles__details-text"> - The Library stores phenotyping algorithms, metadata and tools only. No data is stored in the Library. + The {{ base_page_title }} stores phenotyping algorithms, metadata and tools only. No health data is stored in the {{ base_page_title }}. </p> </div> </div> <div class="homepage-principles__principle"> <div class="homepage-principles__details"> <p class="homepage-principles__details-text"> - Phenotype definitions will be assigned a unique Digital Object Identifier (DOI) - to facilitate identification of the phenotype. + {{ brand_mapping.phenotype }} definitions will be assigned a unique Digital Object Identifier (DOI) + to facilitate identification of the {{ brand_mapping.phenotype }}. </p> </div> </div> <div class="homepage-principles__principle"> <div class="homepage-principles__details"> <p class="homepage-principles__details-text"> - Ideally, phenotypes that are deposited in the Library will have undergone some form of + Ideally, {{ brand_mapping.phenotype }}s that are deposited in the {{ base_page_title }} will have undergone some form of peer-review to assess validity and quality either through peer-reviewed publication or some other means of sharing the definition(s). </p> @@ -357,16 +367,13 @@ <h3 class="homepage-principles__header-title"> </div> <div class="homepage-principles__principle"> <div class="homepage-principles__details"> - <p class="homepage-principles__details-text"> - Users should cite the Phenotype Library in all publications, - presentations and reports as follows: “HDR UK CALIBER Phenotype Library https://portal.caliberresearch.org/”. - </p> + <pre class="homepage-principles__details-text">{% get_brand_citation_req as citation_block %}{{ citation_block|safe }}</pre> </div> </div> <div class="homepage-principles__principle"> <div class="homepage-principles__details"> <p class="homepage-principles__details-text"> - All material deposited in the Library remain the intellectual property of the research group who created the phenotype(s) + All material deposited in the {{ base_page_title }} remain the intellectual property of the research group who created the {{ brand_mapping.phenotype }}(s) ‐ the default licensing agreement that information is available under is the Creative Commons Attribution 4.0 (CC-A). </p> </div> @@ -374,9 +381,9 @@ <h3 class="homepage-principles__header-title"> <div class="homepage-principles__principle"> <div class="homepage-principles__details"> <p class="homepage-principles__details-text"> - The aim of the Library is not to standardize or harmonize disease definitions, - therefore several phenotypes may be stored for the same condition and the onus is on - individual researchers to explore which phenotypes they wish to use. + The aim of the {{ base_page_title }} is not to standardize or harmonize disease definitions, + therefore several {{ brand_mapping.phenotype }}s may be stored for the same condition and the onus is on + individual researchers to explore which {{ brand_mapping.phenotype }}s they wish to use. </p> </div> </div> diff --git a/CodeListLibrary_project/cll/templates/clinicalcode/moderation/index.html b/CodeListLibrary_project/cll/templates/clinicalcode/moderation/index.html index bad23b7cf..4d1843df8 100644 --- a/CodeListLibrary_project/cll/templates/clinicalcode/moderation/index.html +++ b/CodeListLibrary_project/cll/templates/clinicalcode/moderation/index.html @@ -7,7 +7,7 @@ {% load breadcrumbs %} {% load entity_renderer %} -{% block title %}| Phenotype Moderation {% endblock title %} +{% block title %}| {% get_brand_mapped_string target="phenotype" default="Phenotype" %} Moderation {% endblock title %} {% block container %} <!-- Vendor --> @@ -25,10 +25,11 @@ {% endcompress %} <!-- Main --> + {% get_brand_map_rules as brand_mapping %} <header class="main-header banner"> <div class="main-header__inner-container main-header__inner-container--constrained-no-pad main-header__inner-container--centred"> <div class="banner__container"> - <h2 class="banner__title">Phenotype Moderation</h2> + <h2 class="banner__title">{{ brand_mapping.phenotype }} Moderation</h2> <p class="banner__description"> Review content before publication. </p> @@ -45,8 +46,8 @@ <h2 class="banner__title">Phenotype Moderation</h2> {% if requested_content %} {% to_json_script requested_content data-owner="collection-service" page-type="MODERATION_COLLECTIONS" name="requested" desc-type="text/json" %} {% endif %} - <h3>1. Phenotypes to be Reviewed</h3> - <p>Phenotypes that are awaiting a review before publication</p> + <h3>1. {{ brand_mapping.phenotype }}s to be Reviewed</h3> + <p>{{ brand_mapping.phenotype }}s that are awaiting a review before publication</p> <section class="profile-collection__none-available show" id="empty-collection"> <p class="profile-collection__none-available-message"> There are no review requests at the moment @@ -60,8 +61,8 @@ <h3>1. Phenotypes to be Reviewed</h3> {% if pending_content %} {% to_json_script pending_content data-owner="collection-service" page-type="MODERATION_COLLECTIONS" name="pending" desc-type="text/json" %} {% endif %} - <h3>2. Phenotypes under Review</h3> - <p>Table that lists all phenotypes that this individual is reviewing</p> + <h3>2. {{ brand_mapping.phenotype }}s under Review</h3> + <p>Table that lists all {{ brand_mapping.phenotype }}s that this individual is reviewing</p> <section class="profile-collection__none-available show" id="empty-collection"> <p class="profile-collection__none-available-message"> There are no pending reviews at the moment @@ -71,6 +72,7 @@ <h3>2. Phenotypes under Review</h3> </div> </section> + {% to_json_script brand_mapping data-owner="collection-service" name="mapping" desc-type="text/json" %} </article> </div> </main> diff --git a/CodeListLibrary_project/cll/templates/clinicalcode/organisation/create.html b/CodeListLibrary_project/cll/templates/clinicalcode/organisation/create.html new file mode 100644 index 000000000..9e46f5a70 --- /dev/null +++ b/CodeListLibrary_project/cll/templates/clinicalcode/organisation/create.html @@ -0,0 +1,67 @@ +{% extends "base.html" %} + +{% load static %} +{% load compress %} +{% load sass_tags %} +{% load cl_extras %} +{% load breadcrumbs %} +{% load entity_renderer %} + +{% block title %}| Create Organisation {% endblock title %} + +{% block container %} + <!-- Dependencies --> + {% compress js %} + <script type="module" src="{% static 'js/clinicalcode/components/toastNotification.js' %}"></script> + {% endcompress %} + + <!-- Page Stylesheets --> + {% compress css %} + <link rel="stylesheet" href="{% sass_src 'scss/pages/organisations.scss' %}" type="text/css" /> + {% endcompress %} + + <!-- Main --> + <header class="main-header banner"> + <div class="main-header__inner-container main-header__inner-container--constrained-no-pad main-header__inner-container--centred"> + <div class="banner__container"> + <h2 class="banner__title">Create Organisation</h2> + <p class="banner__description"> + Set up your organisation. + </p> + </div> + </div> + </header> + + <main class="main-content"> + <div class="main-content__inner-container main-content__inner-container--constrained main-content__inner-container--centred"> + <article class="organisation-page"> + <form method="post" id="archive-form-area" class="base-form"> + {% csrf_token %} + + {% for field in form.visible_fields %} + <div class="detailed-input-group fill"> + <h3 class="detailed-input-group__title"> + {{ field.label_tag }} + {% if field.field.required %} + <span class="detailed-input-group__mandatory">*</span> + {% endif %} + </h3> + <p class="detailed-input-group__description">Your organisation's {{ field.label_tag|lower }}</p> + {{ field }} + {% if field.errors %} + <small class="error">{{ field.errors|striptags }}</small> + {% endif %} + </div> + {% endfor %} + + <footer class="organisation-page__submit"> + <button class="primary-btn text-accent-darkest bold secondary-accent" aria-label="Create" type="submit" id="submit-btn"> + Create + </button> + </footer> + </form> + </article> + </div> + </main> + +{% endblock container %} diff --git a/CodeListLibrary_project/cll/templates/clinicalcode/organisation/invite.html b/CodeListLibrary_project/cll/templates/clinicalcode/organisation/invite.html new file mode 100644 index 000000000..24e52f41c --- /dev/null +++ b/CodeListLibrary_project/cll/templates/clinicalcode/organisation/invite.html @@ -0,0 +1,61 @@ +{% extends "base.html" %} + +{% load static %} +{% load compress %} +{% load sass_tags %} +{% load cl_extras %} +{% load breadcrumbs %} +{% load entity_renderer %} + +{% block title %}| Organisation Invite {% endblock title %} + +{% block container %} + <!-- Dependencies --> + {% compress js %} + <script type="module" src="{% static 'js/clinicalcode/services/organisationService/invite.js' %}"></script> + <script type="module" src="{% static 'js/clinicalcode/components/toastNotification.js' %}"></script> + {% endcompress %} + + <!-- Page Stylesheets --> + {% compress css %} + <link rel="stylesheet" href="{% sass_src 'scss/pages/organisations.scss' %}" type="text/css" /> + {% endcompress %} + + <!-- Main --> + <header class="main-header banner"> + <div class="main-header__inner-container main-header__inner-container--constrained-no-pad main-header__inner-container--centred"> + <div class="banner__container"> + <h2 class="banner__title">Invitation to join {{instance.name}}</h2> + <p class="banner__description"> + Please accept or reject the invitation + </p> + </div> + </div> + </header> + + <main class="main-content"> + {% url 'view_organisation' instance.slug as redirect_org %} + {% url 'concept_library_home' as redirect_home %} + <div class="main-content__inner-container main-content__inner-container--constrained main-content__inner-container--centred"> + <article class="organisation-page"> + <section class="organisation-page__section"> + <div class="organisation-page__section__invite"> + <button class="primary-btn text-accent-darkest bold secondary-accent" + aria-label="Accept invitation" + id="accept-btn" + data-href="{{ redirect_org }}"> + Accept + </button> + <button class="primary-btn text-accent-darkest bold danger-accent" + aria-label="Reject invitation" + id="reject-btn" + data-href="{{ redirect_home }}"> + Reject + </button> + </div> + </section> + </article> + </div> + </main> + +{% endblock container %} diff --git a/CodeListLibrary_project/cll/templates/clinicalcode/organisation/manage.html b/CodeListLibrary_project/cll/templates/clinicalcode/organisation/manage.html new file mode 100644 index 000000000..67007fc92 --- /dev/null +++ b/CodeListLibrary_project/cll/templates/clinicalcode/organisation/manage.html @@ -0,0 +1,229 @@ +{% extends "base.html" %} + +{% load static %} +{% load compress %} +{% load sass_tags %} +{% load cl_extras %} +{% load breadcrumbs %} +{% load entity_renderer %} + +{% block title %}| Manage Organisation {% endblock title %} + +{% block container %} + <!-- Dependencies --> + {% compress js %} + <script type="module" src="{% static 'js/clinicalcode/services/organisationService/manage.js' %}"></script> + <script type="module" src="{% static 'js/clinicalcode/components/toastNotification.js' %}"></script> + {% endcompress %} + + <!-- Page Stylesheets --> + {% compress css %} + <link rel="stylesheet" href="{% sass_src 'scss/pages/organisations.scss' %}" type="text/css" /> + {% endcompress %} + + <!-- Main --> + <header class="main-header banner"> + <div class="main-header__inner-container main-header__inner-container--constrained-no-pad main-header__inner-container--centred"> + <div class="banner__container"> + <h2 class="banner__title"> + Manage {{ instance.name }} + </h2> + <p class="banner__description"> + Customise your organisation's profile and its members. + </p> + </div> + </div> + </header> + + <main class="main-content" id="root"> + {% to_json_script members desc-type="value" for="organisation-members" uid=request.user.id %} + {% to_json_script invites desc-type="value" for="organisation-invites" %} + <div class="main-content__inner-container main-content__inner-container--constrained main-content__inner-container--centred"> + <article class="organisation-page"> + <form method="post" id="archive-form-area" class="base-form"> + {% csrf_token %} + + <div class="detailed-input-group fill"> + <h3 class="detailed-input-group__title"> + Name + <span class="detailed-input-group__mandatory">*</span> + </h3> + <p class="detailed-input-group__description">Your organisation's name</p> + {{ form.name }} + </div> + <div class="detailed-input-group fill"> + <h3 class="detailed-input-group__title"> + Description + </h3> + <p class="detailed-input-group__description">Describe your organisation</p> + {{ form.description }} + </div> + <div class="detailed-input-group fill"> + <h3 class="detailed-input-group__title"> + Email + </h3> + <p class="detailed-input-group__description">Your organisation's contact email</p> + {{ form.email }} + </div> + <div class="detailed-input-group fill"> + <h3 class="detailed-input-group__title"> + Website + </h3> + <p class="detailed-input-group__description">Your organisation's website</p> + {{ form.website }} + </div> + + + <footer class="organisation-page__submit"> + <button class="primary-btn text-accent-darkest bold secondary-accent" aria-label="Save form" id="submit-btn"> + Save + </button> + </footer> + </form> + + <div class="member-management"> + <header class="member-management__header"> + <h3> + Manage Access + </h3> + </header> + <section class="member-management__container"> + <p class="detailed-input-group__description"> + Manage your organisation members + </p> + <div class="tab-group"> + <input type="radio" id="tab1" name="tabGroup1" class="tab" checked> + <label for="tab1"><strong>Members</strong></label> + + <input type="radio" id="tab2" name="tabGroup1" class="tab"> + <label for="tab2"><strong>Invites</strong></label> + + <article class="tab__content"> + <section class="member-management__none-available show" id="no-members"> + <p class="member-management__none-available-message"> + Your organisation has no members yet. + </p> + </section> + + <table class="member-management__table"> + <thead> + <tr> + <td>User</td> + <td>Role</td> + <td>Actions</td> + </tr> + </thead> + <tbody id="member-role-list"> + + </tbody> + </table> + </article> + + <article class="tab__content"> + <section id="member-invite-list"> + <form class="autocomplete"> + <div class="autocomplete-container" + role="combobox" + aria-expanded="false" + aria-owns="autocomplete-results" + aria-haspopup="listbox"> + <div class="autocomplete-controls"> + <input class="autocomplete-input" + placeholder="Search for a user" + aria-label="Search for a user" + aria-autocomplete="both" + aria-controls="autocomplete-results" /> + <ul class="autocomplete-results" + id="autocomplete-results" + role="listbox" + aria-label="Search for a user"> + </ul> + </div> + <button class="primary-btn text-accent-darkest bold secondary-accent" + aria-label="Invite user" + id="invite-btn" + type="button"> + Invite user + </button> + </div> + </form> + <hr/> + <section class="member-management__none-available show" id="no-invites"> + <p class="member-management__none-available-message"> + Your organisation has no active invitations. + </p> + </section> + + <table class="member-management__table"> + <thead> + <tr> + <td>User</td> + <td>Actions</td> + </tr> + </thead> + <tbody id="invite-list-container"> + + </tbody> + </table> + </section> + </article> + </div> + </section> + </div> + </article> + </div> + + <!-- Member list --> + <template data-name="member" data-owner="management"> + <tr data-target="${userid}"> + <td> + ${name} + </td> + <td id="member-role-container"> + + </td> + <td> + <button aria-label="Remove User" + class="secondary-btn text-accent-darkest bold icon delete-icon outline" + title="Remove User" + data-target="delete" + role="button"> + Remove + </button> + </td> + </tr> + </template> + + <template data-name="dropdown" data-owner="management"> + <select class="selection-input" + aria-label="Change role" + name="user-role-${uid}" + id="user-role-${uid}" + data-user="${uid}" + data-class="dropdown"> + {% for role in roles %} + <option value="{{ role.value }}">{{ role.name }}</option> + {% endfor %} + </select> + </template> + + <!-- Invite list --> + <template data-name="invite" data-owner="invite"> + <tr data-target="${userid}"> + <td> + ${name} + </td> + <td> + <button aria-label="Remove Invite" + class="secondary-btn text-accent-darkest bold icon delete-icon outline" + title="Remove User" + data-target="delete" + role="button"> + Remove + </button> + </td> + </tr> + </template> + </main> + +{% endblock container %} diff --git a/CodeListLibrary_project/cll/templates/clinicalcode/organisation/view.html b/CodeListLibrary_project/cll/templates/clinicalcode/organisation/view.html new file mode 100644 index 000000000..67f0ae9f2 --- /dev/null +++ b/CodeListLibrary_project/cll/templates/clinicalcode/organisation/view.html @@ -0,0 +1,153 @@ +{% extends "base.html" %} + +{% load static %} +{% load compress %} +{% load sass_tags %} +{% load cl_extras %} +{% load breadcrumbs %} +{% load entity_renderer %} + +{% block title %}| {{instance.name}} {% endblock title %} + +{% block container %} + <!-- Vendor --> + <script src="{% static 'js/lib/moment.min.js' %}"></script> + <script src="{% static 'js/lib/simple-datatables/simple-datatables.min.js' %}"></script> + + <!-- Dependencies --> + {% compress js %} + <script type="module" src="{% static 'js/clinicalcode/services/organisationService/view.js' %}"></script> + <script type="module" src="{% static 'js/clinicalcode/components/toastNotification.js' %}"></script> + {% endcompress %} + + <!-- Page Stylesheets --> + {% compress css %} + <link rel="stylesheet" href="{% sass_src 'scss/pages/organisations.scss' %}" type="text/css" /> + {% endcompress %} + + <!-- Main --> + {% get_brand_map_rules as brand_mapping %} + <header class="main-header banner"> + <div class="main-header__inner-container main-header__inner-container--constrained-no-pad main-header__inner-container--centred"> + <div class="banner__container"> + <h2 class="banner__title">{{instance.name}}</h2> + <p class="banner__description"> + {{instance.description}} + </p> + <div class="banner__footer-items" tabindex="0" aria-label="Organisation email" > + {% if instance.email|length %} + {% if user.is_authenticated %} + <p> + <span class="email-icon"></span> + <a href="mailto:{{instance.email}}" aria-label="Contact email"> + Contact us + </a> + </p> + {% else %} + <p> + <span class="email-icon"></span> + Sign in to see our e-mail + </p> + {% endif %} + {% endif %} + {% if instance.website|length %} + <p> + <span class="website-icon" tabindex="0" aria-label="Organisation website" ></span> + <a href="{{instance.website}}" rel="noopener" target="_blank" aria-label="View {{ instance.name}}'s website"> + Find out more about us here + </a> + </p> + {% endif %} + </div> + </div> + </div> + </header> + + <main class="main-content"> + <div class="main-content__inner-container main-content__inner-container--constrained main-content__inner-container--centred"> + <article class="organisation-page"> + <section class="organisation-page__section"> + <article class="organisation-page__section__title"> + <h3>Members</h3> + <div class="organisation-page__section__title__buttons"> + {% if is_owner or is_admin %} + {% url 'manage_organisation' instance.slug as manage_url %} + <button role="link" + class="primary-btn bold dropdown-btn__label" + aria-label="Manage Organisation" + id="manage-btn" + onClick="window.location.href='{{ manage_url }}';"> + Manage + </button> + {% endif %} + {% if is_member %} + <button role="link" + class="primary-btn bold dropdown-btn__label text-danger" + aria-label="Leave Organisation" + id="leave-btn" + onClick=""> + Leave + </button> + {% endif %} + </div> + </article> + <article class="organisation-page__section__body row organisation-page__section--constrained slim-scrollbar"> + <div class="organisation-page__section__body__collaborator"> + {{instance.owner.username}} + </div> + {% for member in members %} + <div class="organisation-page__section__body__collaborator"> + {{member.user.username}} + </div> + {% endfor %} + </article> + </section> + <section class="organisation-page__section"> + <article class="organisation-page__section__title"> + <h3>Published {{ brand_mapping.phenotype }}s</h3> + </article> + <article class="organisation-page__section__body"> + {% to_json_script published data-owner="organisation-service" page-type="PUBLISHED_COLLECTIONS" name="published" desc-type="text/json" %} + <section class="organisation-collection__none-available show" id="empty-collection"> + <p class="organisation-collection__none-available-message"> + This organisation hasn't published anything yet. + </p> + </section> + <div class="organisation-collection__table-container" id="published-area"> + + </div> + </article> + </section> + {% if draft is not None %} + <section class="organisation-page__section"> + <article class="organisation-page__section__title"> + <h3>Draft {{ brand_mapping.phenotype }}s</h3> + </article> + <article class="organisation-page__section__body"> + {% to_json_script draft data-owner="organisation-service" page-type="DRAFT_COLLECTIONS" name="draft" desc-type="text/json" %} + <div class="organisation-collection__table-container" id="draft-area"> + + </div> + </article> + </section> + {% endif %} + {% if moderated is not None %} + <section class="organisation-page__section"> + <article class="organisation-page__section__title"> + <h3>{{ brand_mapping.phenotype }}s Awaiting Review</h3> + </article> + <article class="organisation-page__section__body"> + {% to_json_script moderated data-owner="organisation-service" page-type="MODERATION_COLLECTIONS" name="moderated" desc-type="text/json" %} + <div class="organisation-collection__table-container" id="moderated-area"> + + </div> + </article> + </section> + {% endif %} + </article> + </div> + + {% to_json_script brand_mapping data-owner="organisation-service" id="mapping-data" name="mapping" desc-type="text/json" %} + </main> + +{% endblock container %} diff --git a/CodeListLibrary_project/cll/templates/clinicalcode/profile/my_collection.html b/CodeListLibrary_project/cll/templates/clinicalcode/profile/my_collection.html index a3ef14f29..b17247bfe 100644 --- a/CodeListLibrary_project/cll/templates/clinicalcode/profile/my_collection.html +++ b/CodeListLibrary_project/cll/templates/clinicalcode/profile/my_collection.html @@ -7,7 +7,7 @@ {% load breadcrumbs %} {% load entity_renderer %} -{% block title %}| My Collection {% endblock title %} +{% block title %}| My Collection{% endblock title %} {% block container %} <!-- Vendor --> @@ -26,6 +26,8 @@ {% endcompress %} <!-- Main --> + {% get_brand_map_rules as brand_mapping %} + {% get_brand_base_title request.BRAND_OBJECT as base_page_title %} <header class="main-header banner"> <div class="main-header__inner-container main-header__inner-container--constrained-no-pad main-header__inner-container--centred"> <div class="banner__container"> @@ -35,24 +37,51 @@ <h2 class="banner__title">My Collection</h2> </p> <div class="banner__cards"> <div class="hstack-cards-banner hstack-cards-banner-justify-content-space-evenly slim-scrollbar"> - {% if not CLL_READ_ONLY %} - {% url 'create_phenotype' as create_url %} - <article class="referral-card referral-card referral-card-fill-area-evenly bright-accent referral-card-shadow referral-card-border-radius referral-card-no-margin"> + {% url 'create_phenotype' as create_url %} + {% if USER_CREATE_CONTEXT %} + <article + class="referral-card referral-card referral-card-fill-area-evenly bright-accent referral-card-shadow referral-card-border-radius referral-card-no-margin" + data-target="{{ create_url }}" + onclick="redirectToTarget(this, event);" + tabindex="0" + role="button" + aria-label="Create a {{ brand_mapping.phenotype }}"> <div class="referral-card__header"> - <a class="referral-card__title" href="{{ create_url }}">Create a Phenotype<span class="referral-card__title-icon"></span></a> + <p class="referral-card__title" href="{{ create_url }}">Create a {{ brand_mapping.phenotype }}<span class="referral-card__title-icon"></span></p> </div> <div class="referral-card__body"> - <p>Start here to contribute to the Library.</p> + <p>Start here to contribute to the {{ base_page_title }}.</p> </div> </article> {% endif %} - {% url 'search_phenotypes' as search_url %} - <article class="referral-card referral-card referral-card-fill-area-evenly bright-accent referral-card-shadow referral-card-border-radius referral-card-no-margin"> + {% url 'search_entities' as search_url %} + <article + class="referral-card referral-card referral-card-fill-area-evenly bright-accent referral-card-shadow referral-card-border-radius referral-card-no-margin" + data-target="{{ search_url }}" + onclick="redirectToTarget(this, event);" + tabindex="0" + role="button" + aria-label="Go to {{ brand_mapping.phenotype }} Search"> <div class="referral-card__header"> - <a class="referral-card__title" href="{{ search_url }}">Search Phenotypes<span class="referral-card__title-icon"></span></a> + <p class="referral-card__title" href="{{ search_url }}">Search {{ brand_mapping.phenotype }}s<span class="referral-card__title-icon"></span></p> </div> <div class="referral-card__body"> - <p>Find Phenotypes within the Library.</p> + <p>Find {{ brand_mapping.phenotype }}s within the {{ base_page_title }}.</p> + </div> + </article> + {% url 'my_organisations' as organisations_url %} + <article + class="referral-card referral-card referral-card-fill-area-evenly bright-accent referral-card-shadow referral-card-border-radius referral-card-no-margin" + data-target="{{ organisations_url }}" + onclick="redirectToTarget(this, event);" + tabindex="0" + role="button" + aria-label="View Organisations"> + <div class="referral-card__header"> + <p class="referral-card__title" href="{{ organisations_url }}">My Organisations<span class="referral-card__title-icon"></span></p> + </div> + <div class="referral-card__body"> + <p>View organisations you're associated with.</p> </div> </article> </div> @@ -68,8 +97,8 @@ <h2 class="banner__title">My Collection</h2> {% if content %} {% to_json_script content data-owner="collection-service" page-type="PROFILE_COLLECTIONS" name="content" desc-type="text/json" %} {% endif %} - <h3>1. My Phenotypes</h3> - <p>Phenotypes that you own or have access to.</p> + <h3>1. My {{ brand_mapping.phenotype }}s</h3> + <p>{{ brand_mapping.phenotype }}s that you own or have access to.</p> <section class="profile-collection__none-available show" id="empty-collection"> <p class="profile-collection__none-available-message"> You haven't published anything yet. @@ -83,7 +112,7 @@ <h3>1. My Phenotypes</h3> {% if archived_content %} {% to_json_script archived_content data-owner="collection-service" page-type="PROFILE_COLLECTIONS" name="archived" desc-type="text/json" %} {% endif %} - <h3>2. Archived Phenotypes</h3> + <h3>2. Archived {{ brand_mapping.phenotype }}s</h3> <p>Content that has been marked as archived is still present on the system but is hidden by default.</p> <section class="profile-collection__none-available show" id="empty-collection"> <p class="profile-collection__none-available-message"> @@ -98,6 +127,8 @@ <h3>2. Archived Phenotypes</h3> </div> </main> + {% to_json_script brand_mapping data-owner="collection-service" id="mapping-data" name="mapping" desc-type="text/json" %} + {% if not CLL_READ_ONLY %} <template id="archive-form"> {% include "components/forms/archive.html" %} diff --git a/CodeListLibrary_project/cll/templates/clinicalcode/profile/my_organisations.html b/CodeListLibrary_project/cll/templates/clinicalcode/profile/my_organisations.html new file mode 100644 index 000000000..29213a2e3 --- /dev/null +++ b/CodeListLibrary_project/cll/templates/clinicalcode/profile/my_organisations.html @@ -0,0 +1,132 @@ +{% extends "base.html" %} + +{% load static %} +{% load compress %} +{% load sass_tags %} +{% load cl_extras %} +{% load breadcrumbs %} +{% load entity_renderer %} + +{% block title %}| My Organisations {% endblock title %} + +{% block container %} + <!-- Vendor --> + <script src="{% static 'js/lib/moment.min.js' %}"></script> + <script src="{% static 'js/lib/simple-datatables/simple-datatables.min.js' %}"></script> + + <!-- Dependencies --> + {% compress js %} + <script type="module" src="{% static 'js/clinicalcode/services/organisationService/service.js' %}"></script> + <script type="module" src="{% static 'js/clinicalcode/components/toastNotification.js' %}"></script> + {% endcompress %} + + <!-- Page Stylesheets --> + {% compress css %} + <link rel="stylesheet" href="{% sass_src 'scss/pages/profile.scss' %}" type="text/css" /> + {% endcompress %} + + <!-- Main --> + {% get_brand_map_rules as brand_mapping %} + {% get_brand_base_title request.BRAND_OBJECT as base_page_title %} + <header class="main-header banner"> + <div class="main-header__inner-container main-header__inner-container--constrained-no-pad main-header__inner-container--centred"> + <div class="banner__container"> + <h2 class="banner__title">My Organisations</h2> + <p class="banner__description"> + View organisations you're associated with. + </p> + <div class="banner__cards"> + <div class="hstack-cards-banner hstack-cards-banner-justify-content-space-evenly slim-scrollbar"> + {% url 'create_phenotype' as create_url %} + {% if USER_CREATE_CONTEXT %} + <article + class="referral-card referral-card referral-card-fill-area-evenly bright-accent referral-card-shadow referral-card-border-radius referral-card-no-margin" + data-target="{{ create_url }}" + onclick="redirectToTarget(this, event);" + tabindex="0" + role="button" + aria-label="Create a {{ brand_mapping.phenotype }}"> + <div class="referral-card__header"> + <p class="referral-card__title" href="{{ create_url }}">Create a {{ brand_mapping.phenotype }}<span class="referral-card__title-icon"></span></p> + </div> + <div class="referral-card__body"> + <p>Start here to contribute to the {{ base_page_title }}.</p> + </div> + </article> + <!-- {% url 'create_organisation' as create_org_url %} + {% if not is_brand_managed %} + <article + class="referral-card referral-card referral-card-fill-area-evenly bright-accent referral-card-shadow referral-card-border-radius referral-card-no-margin" + data-target="{{ create_org_url }}" + onclick="redirectToTarget(this, event);" + tabindex="0" + role="button" + aria-label="Create an Organisation"> + <div class="referral-card__header"> + <p class="referral-card__title" href="{{ create_org_url }}">Create an Organisation<span class="referral-card__title-icon"></span></p> + </div> + <div class="referral-card__body"> + <p>Create a new organisation.</p> + </div> + </article> + {% endif %} --> + {% endif %} + {% url 'my_collection' as collections_url %} + <article + class="referral-card referral-card referral-card-fill-area-evenly bright-accent referral-card-shadow referral-card-border-radius referral-card-no-margin" + data-target="{{ collections_url }}" + onclick="redirectToTarget(this, event);" + tabindex="0" + role="button" + aria-label="View {{ brand_mapping.phenotype }} Collection"> + <div class="referral-card__header"> + <p class="referral-card__title" href="{{ collections_url }}">My Collection<span class="referral-card__title-icon"></span></p> + </div> + <div class="referral-card__body"> + <p>View content owned by or shared with you.</p> + </div> + </article> + </div> + </div> + </div> + </div> + </header> + + <main class="main-content"> + <div class="main-content__inner-container main-content__inner-container--constrained main-content__inner-container--centred"> + <article class="profile-collection"> + <section class="profile-collection__inner-container" id="owned-orgs"> + {% if owned_orgs %} + {% to_json_script owned_orgs data-owner="organisation-service" page-type="OWNED_ORG_COLLECTIONS" name="owned-orgs" desc-type="text/json" %} + {% endif %} + <h3>1. Owned Organisations</h3> + <p>Organisations you're the owner of.</p> + <section class="profile-collection__none-available show" id="empty-collection"> + <p class="profile-collection__none-available-message"> + You haven't created any organisations yet. + </p> + </section> + <div class="profile-collection__table-container" id="owned-orgs-area"> + + </div> + </section> + <section class="profile-collection__inner-container" id="member-orgs"> + {% if member_orgs %} + {% to_json_script member_orgs data-owner="organisation-service" page-type="MEMBER_ORG_COLLECTIONS" name="member_orgs" desc-type="text/json" %} + {% endif %} + <h3>2. Member Organisations</h3> + <p>Organisations you're a member of.</p> + <section class="profile-collection__none-available show" id="empty-collection"> + <p class="profile-collection__none-available-message"> + You haven't joined any organisations yet. + </p> + </section> + <div class="profile-collection__table-container" id="member-orgs-area"> + + </div> + </section> + </article> + </div> + </main> + +{% endblock container %} diff --git a/CodeListLibrary_project/cll/templates/components/base/about_menu.html b/CodeListLibrary_project/cll/templates/components/base/about_menu.html index f4763dc8e..2032bef00 100644 --- a/CodeListLibrary_project/cll/templates/components/base/about_menu.html +++ b/CodeListLibrary_project/cll/templates/components/base/about_menu.html @@ -10,7 +10,7 @@ <a href="#" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false" id="About">About</a> </div> - <div class="nav-dropdown__content"> + <div class="nav-dropdown__content{% if not request.user or not request.user.is_authenticated %}{% if request.BRAND_OBJECT.about_menu is not none %} nav-dropdown--offset{% endif %}{% endif %}"> <ul class="about-row slim-scrollbar"> {% if request.BRAND_OBJECT.about_menu is not none %} {% for lnk in request.BRAND_OBJECT.about_menu %} diff --git a/CodeListLibrary_project/cll/templates/components/base/footer.html b/CodeListLibrary_project/cll/templates/components/base/footer.html index bf007f157..1a318d7c1 100644 --- a/CodeListLibrary_project/cll/templates/components/base/footer.html +++ b/CodeListLibrary_project/cll/templates/components/base/footer.html @@ -1,40 +1,47 @@ -{% load static %} {% load i18n %} +{% load cache %} +{% load static %} +{% load entity_renderer %} {% block content %} - <footer class="page-footer" id="footer_main"> - <section class="page-footer__row"> - <div class="page-footer__listlinks"> - <hr class='footer-hr'> - <p> - <a href="{% url 'terms' %}"> - Terms & Conditions - </a> - | - <a href="{% url 'privacy_and_cookie_policy' %}"> - Privacy & Cookie Policy - </a> - | - <a href="https://github.com/SwanseaUniversityMedical/concept-library/wiki/Concept-Library-Documentation" target=_blank> - Support & Documentation - </a> - | - <a href="{% url 'contact_us' %}">Contact Us</a> - </p> - <p> - Copyright © {% now 'Y' %} - SAIL Databank - Swansea University. - User-submitted content held in the Library is openly licensed for - non-commercial use via - <a href='https://creativecommons.org/licenses/by-sa/4.0/'> - CC BY-SA 4.0 - </a>. - All other rights reserved. - </p> - - {% block footer_links %} - {% include "components/base/footer_link_img.html" %} - {% endblock footer_links %} - </div> - </section> - </footer> + {% with request.CURRENT_BRAND|default:"BASE_BRAND" as brand_target %} + {% cache 3600 brand_footer_content brand_target %} + {% get_brand_base_title request.BRAND_OBJECT as base_page_title %} + <footer class="page-footer" id="footer_main"> + <section class="page-footer__row"> + <div class="page-footer__listlinks"> + <hr class='footer-hr'> + <p> + <a href="{% url 'terms' %}"> + Terms & Conditions + </a> + | + <a href="{% url 'privacy_and_cookie_policy' %}"> + Privacy & Cookie Policy + </a> + | + <a href="https://github.com/SwanseaUniversityMedical/concept-library/wiki/" target=_blank> + Support & Documentation + </a> + | + <a href="{% url 'contact_us' %}">Contact Us</a> + </p> + <p> + Copyright © {% now 'Y' %} - SAIL Databank - Swansea University. + User-submitted content held in the {{ base_page_title }} is openly licensed for + non-commercial use via + <a href='https://creativecommons.org/licenses/by-sa/4.0/'> + CC BY-SA 4.0 + </a>. + All other rights reserved. + </p> + + {% block footer_links %} + {% include "components/base/footer_link_img.html" %} + {% endblock footer_links %} + </div> + </section> + </footer> + {% endcache %} + {% endwith %} {% endblock content %} diff --git a/CodeListLibrary_project/cll/templates/components/base/footer_link_img.html b/CodeListLibrary_project/cll/templates/components/base/footer_link_img.html index 5a193127c..9255e88d0 100644 --- a/CodeListLibrary_project/cll/templates/components/base/footer_link_img.html +++ b/CodeListLibrary_project/cll/templates/components/base/footer_link_img.html @@ -1,41 +1,45 @@ -{% load static %} {% load i18n %} +{% load cache %} +{% load static %} {% load cl_extras %} +{% load entity_renderer %} {% block content %} -<div class="footer-links"> - {% for items in request.BRAND_OBJECT.footer_images %} - <div class="footer-links__item"> - <a href="{{items.url}}" target=_blank class="footer-brand" rel="noopener noreferrer"> - <img loading="lazy" style="margin-top: -10px;" src="{% static items.image_src %}" height="30px" alt="{{items.brand}} Logo" /> - </a> - </div> - {% endfor %} - - {% if request.BRAND_OBJECT.footer_images is none %} - <div class="footer-links__item"> - <a href="https://conceptlibrary.saildatabank.com/" target=_blank class="footer-brand" rel="noopener noreferrer"> - <img loading="lazy" style="margin-top: -10px;" src="{% static 'img/Footer_logos/concept_library_on_white.png' %}" height="30px" - alt="Concept Library Logo" /> - </a> - </div> - <div class="footer-links__item"> - <a href="http://saildatabank.com" target=_blank class="navbar-brand" rel="noopener noreferrer"> - <img loading="lazy" style="margin-top: -10px;" src="{% static 'img/Footer_logos/SAIL_alt_logo_on_white.png' %}" height="30px" - alt="SAIL Databank Logo" /> - </a> - </div> - {% endif %} - - {% if DEV_PRODUCTION|length or CLL_READ_ONLY %} - <span class="footer-alert footer-alert--warning footer-alert--right" role="alert"> - {% if CLL_READ_ONLY %} - <strong>READONLY</strong> - {% else %} - <strong>{{ DEV_PRODUCTION|safe }}</strong> - {% endif %} - </span> - {% endif %} -</div> + {% get_brand_base_title request.BRAND_OBJECT as base_page_title %} + {% with request.CURRENT_BRAND|default:"BASE_BRAND" as brand_target %} + {% cache 3600 brand_footer_imgs brand_target %} + <div class="footer-links"> + {% for items in request.BRAND_OBJECT.footer_images %} + <div class="footer-links__item"> + <a href="{{items.url}}" target=_blank class="footer-brand" rel="noopener noreferrer"> + <img loading="lazy" style="margin-top: -10px;" src="{% static items.image_src %}" height="30px" alt="{{ items.brand }} Logo" /> + </a> + </div> + {% empty %} + <div class="footer-links__item"> + <a href="https://conceptlibrary.saildatabank.com/" target=_blank class="footer-brand" rel="noopener noreferrer"> + <img loading="lazy" style="margin-top: -10px;" src="{% static 'img/Footer_logos/concept_library_on_white.png' %}" height="30px" + alt="{{ base_page_title }} Logo" /> + </a> + </div> + <div class="footer-links__item"> + <a href="http://saildatabank.com" target=_blank class="navbar-brand" rel="noopener noreferrer"> + <img loading="lazy" style="margin-top: -10px;" src="{% static 'img/Footer_logos/SAIL_alt_logo_on_white.png' %}" height="30px" + alt="SAIL Databank Logo" /> + </a> + </div> + {% endfor %} + {% if DEV_PRODUCTION|length or CLL_READ_ONLY %} + <span class="footer-alert footer-alert--warning footer-alert--right" role="alert"> + {% if CLL_READ_ONLY %} + <strong>READONLY</strong> + {% else %} + <strong>{{ DEV_PRODUCTION|safe }}</strong> + {% endif %} + </span> + {% endif %} + </div> + {% endcache %} + {% endwith %} {% endblock content %} diff --git a/CodeListLibrary_project/cll/templates/components/base/navigation.html b/CodeListLibrary_project/cll/templates/components/base/navigation.html index e9b3829c8..55fbb8397 100644 --- a/CodeListLibrary_project/cll/templates/components/base/navigation.html +++ b/CodeListLibrary_project/cll/templates/components/base/navigation.html @@ -1,44 +1,59 @@ +{% load i18n %} +{% load cache %} {% load static %} {% load compress %} -{% load i18n %} +{% load cl_extras %} {% block content %} {% compress js %} -<script type="module" src="{% static "js/clinicalcode/components/navigation.js" %}"></script> + <script type="module" src="{% static "js/clinicalcode/components/navigation.js" %}"></script> {% endcompress %} <nav class="page-navigation"> <div class="page-navigation__container"> - {% url 'concept_library_home' as home_url %} - <a class="page-navigation__logo-anchor" href="{{ home_url }}" id="Logourl"> - <div class="page-navigation__logo"></div> - </a> - - <div class="page-navigation__buttons"></div> + {% with request.CURRENT_BRAND|default:"BASE_BRAND" as brand_target %} + {% cache 3600 brand_nav_logo brand_target %} + {% url 'concept_library_home' as home_url %} + <a class="page-navigation__logo-anchor" tabindex="0" href="{{ home_url }}" id="Logourl" aria-label="Sign out"> + <div class="page-navigation__logo"></div> + </a> + <div class="page-navigation__buttons" tabindex="0" aria-label="Toggle Site Menu" role="button"></div> + {% endcache %} + {% endwith %} <div class="page-navigation__items slim-scrollbar"> - {% block search_bar %} - {% include "components/navigation/search_navigation.html" %} - {% endblock search_bar %} + {% with request.CURRENT_BRAND|default:"BASE_BRAND" as brand_target %} + {% cache 3600 brand_nav_tabs brand_target %} + {% block search_bar %} + {% include "components/navigation/search_navigation.html" %} + {% endblock search_bar %} - {% if request.BRAND_OBJECT.allowed_tabs is none or 'home' in request.BRAND_OBJECT.allowed_tabs %} - {% url 'concept_library_home' as home_url %} - <a href="{{ home_url }}" id="Home">Home</a> - {% endif %} + {% if request.BRAND_OBJECT.allowed_tabs is none or 'home' in request.BRAND_OBJECT.allowed_tabs %} + {% url 'concept_library_home' as home_url %} + <a href="{{ home_url }}" id="Home">Home</a> + {% endif %} - {% url 'search_phenotypes' as entity_url %} - <a href="{{ entity_url }}" id="Phenotypes" data-root="search,update,create">Phenotypes</a> + {% if request.BRAND_OBJECT.allowed_tabs is none or 'phenotypes' in request.BRAND_OBJECT.allowed_tabs %} + {% if request.BRAND_OBJECT.allowed_tabs.phenotypes|get_type == 'dict' %} + <a href="{% url 'search_entities' %}" id="{{ request.BRAND_OBJECT.allowed_tabs.phenotypes.id|default:'Phenotypes' }}" data-root="search,update,create"> + {{ request.BRAND_OBJECT.allowed_tabs.phenotypes.title|default:'Phenotypes' }} + </a> + {% else %} + <a href="{% url 'search_entities' %}" id="Phenotypes" data-root="search,update,create">Phenotypes</a> + {% endif %} + {% endif %} - {% if request.BRAND_OBJECT.allowed_tabs is none or 'api' in request.BRAND_OBJECT.allowed_tabs %} - {% url 'api:root' as api_url %} - <a href="{{ api_url }}" id="API" data-root="api">API</a> - {% endif %} + {% if request.BRAND_OBJECT.allowed_tabs is none or 'api' in request.BRAND_OBJECT.allowed_tabs %} + {% url 'api:root' as api_url %} + <a href="{{ api_url }}" id="API" data-root="api">API</a> + {% endif %} - {% block about_wrapper %} - {% include "components/base/about_menu.html" %} - {% endblock about_wrapper %} + {% block about_wrapper %} + {% include "components/base/about_menu.html" %} + {% endblock about_wrapper %} + {% endcache %} + {% endwith %} - {% url 'login' as login_url %} {% if not user.is_authenticated %} <!-- Need to change to profile later --> diff --git a/CodeListLibrary_project/cll/templates/components/base/profile_menu.html b/CodeListLibrary_project/cll/templates/components/base/profile_menu.html index 30aecc360..e9809d56d 100644 --- a/CodeListLibrary_project/cll/templates/components/base/profile_menu.html +++ b/CodeListLibrary_project/cll/templates/components/base/profile_menu.html @@ -1,12 +1,13 @@ -{% load static %} -{% load i18n %} {% load svg %} -{% load cl_extras %} +{% load i18n %} +{% load static %} {% load compress %} +{% load cl_extras %} {% load entity_renderer %} {% block content %} <!-- about tab --> + {% get_brand_base_title request.BRAND_OBJECT as base_page_title %} <div class="nav-dropdown"> {% include "components/navigation/avatar_component.html" %} @@ -21,11 +22,20 @@ </div> <div class="item-dropdown__title">Admin</div> </a> - <!--Dash--> + <!--Analytics Dash--> <a href="/dash" target=_blank class="item-dropdown"> <div class="item-dropdown__icon profile-row--stylised-icon" id="admin_icon"> {% svg "bar_chart_icon" %} </div> + <div class="item-dropdown__title">Analytics</div> + </a> + {% endif %} + {% if IS_BRAND_ADMIN and not CLL_READ_ONLY %} + <!--Dashboard--> + <a href="{% url 'brand_dashboard' %}" target=_blank class="item-dropdown"> + <div class="item-dropdown__icon profile-row--stylised-icon" id="admin_icon"> + {% svg "technical_icon" %} + </div> <div class="item-dropdown__title">Dashboard</div> </a> {% endif %} @@ -33,27 +43,51 @@ <div class="item-dropdown__icon profile-row--stylised-icon" id="collection_icon"> {% svg "collection_icon" %} </div> - <div class="item-dropdown__title">My collection</div> + <div class="item-dropdown__title">My Collection</div> + </a> + <a href="{% url 'my_organisations' %}" class="item-dropdown"> + <div class="item-dropdown__icon profile-row--stylised-icon" id="organisation_icon"> + {% svg "organisation_icon" %} + </div> + <div class="item-dropdown__title">My Organisations</div> </a> </li> <hr/> <!-- Brands --> - {% if request.user.is_superuser and request.session.all_brands %} - {% include "components/navigation/dropdown_profile_item.html" with currentBrand="" image="/static/img/brands/SAIL/apple-touch-icon.png" title="Concept Library" %} + {% if request.user.is_superuser and request.session.all_brands %} + {% get_brand_map_rules False as emap %} + {% + include "components/navigation/dropdown_profile_item.html" + with + rules=emap + currentBrand="" + image="/static/img/brands/SAIL/apple-touch-icon.png" + title=base_page_title + %} {% for brand in request.session.all_brands %} - {% include "components/navigation/dropdown_profile_item.html" with currentBrand=brand|upper image=brand|getBrandLogo title=brand|upper %} + {% get_brand_map_rules brand as bmap %} + {% + include "components/navigation/dropdown_profile_item.html" + with + rules=bmap + currentBrand=brand|upper + image=brand|get_brand_logo + title=brand|upper + %} {% endfor %} + {% to_json_script request.session.all_brands id="brand-target-source" name="brand-targets" desc-type="text/json" host-target=IS_PRODUCTION_SERVER %} + <hr/> {% endif %} <!-- Logout --> <li class="content-container"> - <form method="post" action="{% url 'logout' %}?next={% url 'search_phenotypes' %}" class="item-dropdown"> + <form method="post" action="{% url 'logout' %}?next={% url 'search_entities' %}" class="item-dropdown"> {% csrf_token %} - <button class="item-dropdown__submit" type="submit"> + <button class="item-dropdown__submit" type="submit" aria-label="Log out" role="button" tabindex="0"> <div class="item-dropdown__icon" id="logout-icon"> {% svg "exit_icon" %} </div> @@ -64,6 +98,4 @@ </ul> </div> </div> - - {% to_json_script request.session.all_brands id="brand-target-source" name="brand-targets" desc-type="text/json" host-target=IS_PRODUCTION_SERVER %} {% endblock content %} diff --git a/CodeListLibrary_project/cll/templates/components/create/aside.html b/CodeListLibrary_project/cll/templates/components/create/aside.html index 9b6437d82..898ee59d5 100644 --- a/CodeListLibrary_project/cll/templates/components/create/aside.html +++ b/CodeListLibrary_project/cll/templates/components/create/aside.html @@ -3,9 +3,9 @@ <section class="steps-wizard__panel" onselectstart="return false;"> <span class="steps-wizard__header">Skip to step...</span> {% for section in create_sections %} - <span class="steps-wizard__item" id="{{ section.title|trimmed|escape }}-step" + <span class="steps-wizard__item" id="{{ section.title|shrink_underscore|escape }}-step" data-value="{{ forloop.counter0|add:'1' }}" - data-target="{{ section.title|trimmed|escape }}-progress"> + data-target="{{ section.title|shrink_underscore|escape }}-progress"> {{ section.title }} </span> {% endfor %} diff --git a/CodeListLibrary_project/cll/templates/components/create/attribute_component.html b/CodeListLibrary_project/cll/templates/components/create/attribute_component.html deleted file mode 100644 index 3d234d585..000000000 --- a/CodeListLibrary_project/cll/templates/components/create/attribute_component.html +++ /dev/null @@ -1,33 +0,0 @@ -<div class="attribute-progress_item-container" id="attributeContainer"> - <div class="detailed-input-group "> - <h3 class="detailed-input-group__title"> - Type - <span class="detailed-input-group__mandatory">*</span> - </h3> - <p class="detailed-input-group__description"> - The data type of the attribute - </p> - <div class="selection-group"> - <select aria-label="Attribute type" class="selection-input" data-class="dropdown" data-field="type" id="attribute-type-${id}" name="attribute-type"> - <option disabled="" hidden="" selected="" value="-1">Attribute Type</option> - <option value="1">INT</option> - <option value="2">STRING</option> - <option value="3">FLOAT</option> - </select> - </div> - </div> - <div class="detailed-input-group "> - <h3 class="detailed-input-group__title"> - Attribute Name - <span class="detailed-input-group__mandatory">*</span> - </h3> - <p class="detailed-input-group__description"> - String formatted name of attribute - </p> - <input aria-label="" class="text-input" data-class="inputbox" data-field="name" id="attribute-name-input-${id}"> - </div> - <div class="concept-group-content__editor-buttons"> - <button class="secondary-btn text-accent-darkest bold washed-accent" aria-label="Cancel Changes" id="cancel-changes-${id}">Cancel</button> - <button class="primary-btn text-accent-darkest bold secondary-accent" aria-label="Confirm Changes" id="confirm-changes-${id}">Confirm</button> - </div> -</div> diff --git a/CodeListLibrary_project/cll/templates/components/create/attribute_settings.html b/CodeListLibrary_project/cll/templates/components/create/attribute_settings.html deleted file mode 100644 index 220f4c8cf..000000000 --- a/CodeListLibrary_project/cll/templates/components/create/attribute_settings.html +++ /dev/null @@ -1,172 +0,0 @@ -<div class="tab-view" id="tab-view"> - <div class="tab-view__tabs tab-view__tabs-z-buffer"> - <button aria-label="tab" id="SEARCH" class="tab-view__tab active"> - All concepts - </button> - <button aria-label="tab" id="SELECTION" class="tab-view__tab"> - All Attributes - </button> - </div> - <div class="tab-view__content" id="tab-content"> - <div class="search-page as-selection" id="selection-search"> - <section class="entity-search-results entity-search-results--constrained"> - <div class="entity-search-results__header"> - <div - class="entity-search-results__header-results" - id="search-response-header" - ></div> - <div class="entity-search-results__header-modifiers"> - <div class="selection-group"> - <p class="selection-group__title">Order By:</p> - <div class="dropdown-selection"> - <select - id="order-by-filter" - placeholder-text="By Relevance" - data-element="dropdown" - data-field="order_by" - data-class="option" - class="hide" - > - <option - value="1" - class="dropdown-selection__list-item" - selected="" - > - Relevance - </option> - <option value="2" class="dropdown-selection__list-item"> - Created (Asc) - </option> - <option value="3" class="dropdown-selection__list-item"> - Created (Desc) - </option> - <option value="4" class="dropdown-selection__list-item"> - Updated (Asc) - </option> - <option value="5" class="dropdown-selection__list-item"> - Updated (Desc) - </option></select - ><button - class="dropdown-selection__button" - data-value="null" - type="button" - > - <span>Relevance</span - ><span class="dropdown-selection__button-icon"></span> - </button> - <ul class="dropdown-selection__list"> - <li - class="dropdown-selection__list-item" - data-value="1" - aria-label="Relevance" - tabindex="0" - role="button" - > - Relevance - </li> - <li - class="dropdown-selection__list-item" - data-value="2" - aria-label="Created (Asc)" - tabindex="0" - role="button" - > - Created (Asc) - </li> - <li - class="dropdown-selection__list-item" - data-value="3" - aria-label="Created (Desc)" - tabindex="0" - role="button" - > - Created (Desc) - </li> - <li - class="dropdown-selection__list-item" - data-value="4" - aria-label="Updated (Asc)" - tabindex="0" - role="button" - > - Updated (Asc) - </li> - <li - class="dropdown-selection__list-item" - data-value="5" - aria-label="Updated (Desc)" - tabindex="0" - role="button" - > - Updated (Desc) - </li> - </ul> - </div> - </div> - </div> - </div> - <div - class="entity-search-results__container scrollable slim-scrollbar" - id="search-response-content" - > - <article - class="entity-card inactive" - data-entity-id="PH1" - data-entity-version-id="2901" - style="padding: 0" - > - <div class="entity-card__header"> - <div class="entity-card__header-item"> - <h3 class="entity-card__title"> - PH7 / 2897 / C722 - Psoriasis - Primary Care | Read codes v2 - </h3> - </div> - </div> - <div class="entity-card__snippet"> - <div class="entity-card__snippet-datagroup" id="datagroup"> - <div - style="margin-top: 0.5rem" - class="fill-accordion" - id="children-accordion-PH1" - > - <input - class="fill-accordion__input" - id="children-PH1" - name="children-PH1" - type="checkbox" - /><label - class="fill-accordion__label" - for="children-PH1" - id="children-PH1" - role="button" - tabindex="0" - ><span>Added attributes</span></label - > - <article - style="padding: 0.5rem" - class="fill-accordion__container" - id="data" - > - <div class="checkbox-item-container" id="child-selector"> - <label class="constrained-filter-item" for="concept-715" - >INT - Attribute name - Attribute value</label - > - </div> - <div class="checkbox-item-container" id="child-selector"> - <label class="constrained-filter-item" for="concept-714" - >INT - Attribute name - Attribute value</label - > - </div> - </article> - </div> - </div> - </div> - <div class="entity-card__snippet" style="padding: 0.5rem"></div> - </article> - - - </div> - </section> - </div> - </div> -</div> diff --git a/CodeListLibrary_project/cll/templates/components/create/inputs/access_select.html b/CodeListLibrary_project/cll/templates/components/create/inputs/access_select.html index 15f8ccc60..8b60f022e 100644 --- a/CodeListLibrary_project/cll/templates/components/create/inputs/access_select.html +++ b/CodeListLibrary_project/cll/templates/components/create/inputs/access_select.html @@ -6,21 +6,19 @@ <h3 class="detailed-input-group__title"> <span class="detailed-input-group__mandatory">*</span> {% endif %} </h3> - <p class="detailed-input-group__description"> - {{ component.description }} - </p> + <pre class="detailed-input-group__description">{{ component.description }}</pre> {% endif %} <fieldset class="hstack-radio-group" id="entity-{{ component.field_name }}" data-class="radiobutton" data-field="{{ component.field_name }}"> <input class="radio-input" data-value="1" aria-label="No Access" type="radio" id="{{ component.field_name }}-1" name="entity-radio-{{ component.field_name }}" {% if component.value == 1 or not component.value %} checked {% endif %}> <label for="{{ component.field_name }}-1"> - Hide from Public + Hide </label> <input class="radio-input" data-value="2" aria-label="View Access" type="radio" id="{{ component.field_name }}-2" name="entity-radio-{{ component.field_name }}" {% if component.value == 2 %} checked {% endif %}> <label for="{{ component.field_name }}-2"> - Allow Public Access + Share with other organisations </label> </fieldset> </div> diff --git a/CodeListLibrary_project/cll/templates/components/create/inputs/access_select_editable.html b/CodeListLibrary_project/cll/templates/components/create/inputs/access_select_editable.html index 7dd10cd1a..b81b3fc80 100644 --- a/CodeListLibrary_project/cll/templates/components/create/inputs/access_select_editable.html +++ b/CodeListLibrary_project/cll/templates/components/create/inputs/access_select_editable.html @@ -6,9 +6,7 @@ <h3 class="detailed-input-group__title"> <span class="detailed-input-group__mandatory">*</span> {% endif %} </h3> - <p class="detailed-input-group__description"> - {{ component.description }} - </p> + <pre class="detailed-input-group__description">{{ component.description }}</pre> {% endif %} <fieldset class="hstack-radio-group" id="entity-{{ component.field_name }}" data-class="radiobutton" data-field="{{ component.field_name }}"> diff --git a/CodeListLibrary_project/cll/templates/components/create/inputs/clinical/concept.html b/CodeListLibrary_project/cll/templates/components/create/inputs/clinical/concept.html index bf9bb7dd5..79e385582 100644 --- a/CodeListLibrary_project/cll/templates/components/create/inputs/clinical/concept.html +++ b/CodeListLibrary_project/cll/templates/components/create/inputs/clinical/concept.html @@ -11,27 +11,30 @@ {% else %} <script type="application/json" desc-type="value" for="{{ component.field_name }}"></script> {% endif %} - + + {% get_brand_map_rules as brand_mapping %} <section class="concepts-view__header"> <div class="concepts-view__container"> - <p class="concepts-view__title">Your Concepts {% if component.mandatory %}<span class="detailed-input-group__mandatory">*</span>{% endif %}</h3> - <p class="concepts-view__description">The Concepts that relate to this Phenotype:</p> + <p class="concepts-view__title"> + Your {{ brand_mapping.concept }}s + {% if component.mandatory %}<span class="detailed-input-group__mandatory">*</span>{% endif %} + </h3> + <p class="concepts-view__description"> + The {{ brand_mapping.concept }}s that relate to this {{ brand_mapping.phenotype }}: + </p> </div> <div class="concepts-view__container as-row"> - <button class="secondary-btn text-accent-darkest bold icon attribute-icon secondary-accent" aria-label="Add concept attribute" id="add-concept-attribute-btn"> - Add attribute + <button class="secondary-btn text-accent-darkest bold icon icon-create secondary-accent" aria-label="Create new {{ brand_mapping.concept }}" id="create-concept-btn"> + New {{ brand_mapping.concept }} </button> - <button class="secondary-btn text-accent-darkest bold icon create-icon secondary-accent" aria-label="Create new Concept" id="create-concept-btn"> - New Concept - </button> - <button class="secondary-btn text-accent-darkest bold icon import-icon secondary-accent" aria-label="Import Concepts" id="import-concept-btn"> - Import Concepts + <button class="secondary-btn text-accent-darkest bold icon icon-import secondary-accent" aria-label="Import {{ brand_mapping.concept }}s" id="import-concept-btn"> + Import {{ brand_mapping.concept }}s </button> </div> </section> <section class="concept-list__none-available" id="no-available-concepts"> - <p class="concept-list__none-available-message">You haven't added any concepts yet.</p> + <p class="concept-list__none-available-message">You haven't added any {{ brand_mapping.concept }}s yet.</p> </section> <section class="concept-list" id="concept-content-list"> @@ -41,8 +44,8 @@ <template id="concept-item"> <section class="concept-list__group" data-concept-id="${concept_id}" data-concept-history-id="${concept_version_id}"> <span class="concept-list__group-item" id="concept-accordion-header"> - <span class="contextual-icon" tabindex="0" aria-label="Expand Concept" role="button" data-target="is-open"></span> - <span class="concept-name" tabindex="0" aria-label="Expand Concept" role="button" data-target="is-open"> + <span class="contextual-icon" tabindex="0" aria-label="Expand {{ brand_mapping.concept }}" role="button" data-target="is-open"></span> + <span class="concept-name" tabindex="0" aria-label="Expand {{ brand_mapping.concept }}" role="button" data-target="is-open"> ${is_imported_item ? `<a href="${phenotype_owner_version_url}" target="_blank">${phenotype_owner} / ${phenotype_owner_history_id} / ${concept_name}</a>` : (phenotype_owner ? `${phenotype_owner} / ${phenotype_owner_history_id} / ${concept_name}` : (concept_name || '')) } @@ -52,14 +55,14 @@ <span class="concept-buttons"> ${can_edit ? ` - <span tooltip="Edit Concept" direction="left"> - <span class="edit-icon" tabindex="0" aria-label="Edit Concept" role="button" data-target="edit"></span> + <span tooltip="Edit {{ brand_mapping.concept }}" direction="left"> + <span class="edit-icon" tabindex="0" aria-label="Edit {{ brand_mapping.concept }}" role="button" data-target="edit"></span> </span> ` : `` } - <span tooltip="Delete Concept" direction="left"> - <span class="delete-icon" tabindex="0" aria-label="Delete Concept" role="button" data-target="delete"></span> + <span tooltip="Delete {{ brand_mapping.concept }}" direction="left"> + <span class="delete-icon" tabindex="0" aria-label="Delete {{ brand_mapping.concept }}" role="button" data-target="delete"></span> </span> </span> </span> @@ -97,12 +100,12 @@ <h4> <section class="concept-group-content__container show" id="concept-editing"> <div class="detailed-input-group fill"> <h3 class="detailed-input-group__title">Name</h3> - <p class="detailed-input-group__description">The name of the Concept</p> - <input class="text-input" aria-label="Concept Name" type="text" id="concept-name" name="concept-name" placeholder="" minlength="3" pattern="^[a-zA-Z]{1,}.*?" value="${concept_name}" aria-required="true"> + <pre class="detailed-input-group__description">The name of this codelist.</pre> + <input class="text-input" aria-label="{{ brand_mapping.concept }} Name" type="text" id="concept-name" name="concept-name" placeholder="" minlength="3" pattern="^[a-zA-Z]{1,}.*?" value="${concept_name}" aria-required="true"> </div> <div class="detailed-input-group fill"> <h3 class="detailed-input-group__title">Coding System</h3> - <p class="detailed-input-group__description">Clinical coding system used to build this Concept's codelist.</p> + <pre class="detailed-input-group__description">The clinical coding system associated with this codelist.</pre> <select class="selection-input" name="coding-system" id="coding-system-select" aria-label="Select Coding System" data-value="${coding_system_id}" aria-required="true"> ${coding_system_options} </select> @@ -121,9 +124,15 @@ <h3>Inclusion Rules</h3> <input type="radio" class="dropdown-btn__close" id="close-incl-ruleset" name="incl-ruleset-btn"> <label aria-label="Close Ruleset Selection" role="button" tabindex="0" id="close-ruleset-selection" class="dropdown-btn__close-label" for="close-incl-ruleset"></label> <ul class="dropdown-btn__menu fall-right"> - <li aria-label="Add Searchterm Rule" role="button" tabindex="0" data-source="SEARCH_TERM">Add Searchterm</li> - <li aria-label="Add Import File Rule" role="button" tabindex="0" data-source="FILE_IMPORT">Import from File</li> - <li aria-label="Add Concept Import Rule" role="button" tabindex="0" data-source="CONCEPT_IMPORT">Import from Concept</li> + <li aria-label="Add Searchterm Rule" role="button" tabindex="0" data-source="SEARCH_TERM"> + Add Searchterm + </li> + <li aria-label="Add Import File Rule" role="button" tabindex="0" data-source="FILE_IMPORT"> + Import from File + </li> + <li aria-label="Add {{ brand_mapping.concept }} Import Rule" role="button" tabindex="0" data-source="CONCEPT_IMPORT"> + Import from {{ brand_mapping.concept }} + </li> </ul> </label> </fieldset> @@ -152,8 +161,12 @@ <h3>Exclusion Rules</h3> <input type="radio" class="dropdown-btn__close" id="close-excl-ruleset" name="excl-ruleset-btn" checked> <label aria-label="Close Ruleset Selection" role="button" tabindex="0" id="close-ruleset-selection" class="dropdown-btn__close-label" for="close-excl-ruleset"></label> <ul class="dropdown-btn__menu fall-right"> - <li aria-label="Add Searchterm Rule" role="button" tabindex="0" data-source="SEARCH_TERM">Add Searchterm</li> - <li aria-label="Add Concept Import Rule" role="button" tabindex="0" data-source="CONCEPT_IMPORT">Import from Concept</li> + <li aria-label="Add Searchterm Rule" role="button" tabindex="0" data-source="SEARCH_TERM"> + Add Searchterm + </li> + <li aria-label="Add {{ brand_mapping.concept }} Import Rule" role="button" tabindex="0" data-source="CONCEPT_IMPORT"> + Import from {{ brand_mapping.concept }} + </li> </ul> </label> </fieldset> @@ -171,8 +184,8 @@ <h3>Exclusion Rules</h3> <section class="concept-group-content__details"> <div class="concept-group-content__details-explanation"> - <h3>Codelist</h3> - <p class="concept-group-content__details-explanation-description">The codelist associated with this Concept</p> + <h3>Final Codelist</h3> + <p class="concept-group-content__details-explanation-description">The computed codelist having evaluated the rulesets.</p> </div> </section> <section class="concept-group-content__no-codes" id="no-available-codelist"> @@ -189,33 +202,21 @@ <h3>Codelist</h3> </section> </template> - - <template id="attribute-component"> - {% include "components/create/attribute_component.html" %} - </template> - - <template id="attribute-settings"> - {% include "components/create/attribute_settings.html" %} - </template> - <template id="search-rule"> <div class="fill-accordion" id="rule-item-${id}" data-index="${index}"> <input class="fill-accordion__input" id="rule-${id}" name="rule-${id}" type="checkbox" /> <label class="fill-accordion__label" id="rule-${id}" for="rule-${id}" role="button" tabindex="0"> <span class="fill-accordion__input-title">Name:</span> <input class="fill-accordion__name-input" aria-label="Rule Name" type="text" id="rule-name" name="rule-name" placeholder="" minlength="3" maxlength="20" pattern="^(.*[a-zA-Z]){3}(.*){1,}" value="${name}"> + <span class="fill-accordion__label-icon"></span> </label> <article class="fill-accordion__container"> <div class="detailed-input-group fill"> <div class="detailed-input-group__header detailed-input-group__header--nowrap"> <div class="detailed-input-group__header-item"> <h3 class="detailed-input-group__title sm">Searchterm Rule</h3> - <p class="detailed-input-group__description"> - A searchterm rule attempts to include codes by their code or description. - </p> - <p class="detailed-input-group__description"> - You can learn about pattern matching with Regex <a href="https://regexone.com/" target=”_blank”>here</a>. - </p> + <pre class="detailed-input-group__description">A searchterm rule attempts to include codes by their code or description.</pre> + <pre class="detailed-input-group__description">You can learn about pattern matching with Regex <a href="https://regexone.com/" target=”_blank”>here</a>.</pre> </div> <div class="detailed-input-group__header-item"> <span tooltip="Remove Ruleset" direction="left"> @@ -270,13 +271,14 @@ <h3 class="detailed-input-group__title sm">Searchterm Rule</h3> <label class="fill-accordion__label" id="rule-${id}" for="rule-${id}" role="button" tabindex="0"> <span class="fill-accordion__input-title">Name:</span> <input class="fill-accordion__name-input" aria-label="Rule Name" type="text" id="rule-name" name="rule-name" placeholder="" minlength="3" maxlength="20" pattern="^(.*[a-zA-Z]){3}(.*){1,}" value="${name}"> + <span class="fill-accordion__label-icon"></span> </label> <article class="fill-accordion__container"> <div class="detailed-input-group fill"> <div class="detailed-input-group__header"> <div class="detailed-input-group__header-item"> <h3 class="detailed-input-group__title sm">File Import Rule</h3> - <p class="detailed-input-group__description">This rule describes any codes retrieved from a file</p> + <pre class="detailed-input-group__description">This rule describes any codes retrieved from a file</pre> </div> <div class="detailed-input-group__header-item"> <span tooltip="Remove Ruleset" direction="left"> @@ -300,13 +302,14 @@ <h3 class="detailed-input-group__title sm">File Import Rule</h3> <label class="fill-accordion__label" id="rule-${id}" for="rule-${id}" role="button" tabindex="0"> <span class="fill-accordion__input-title">Name:</span> <input class="fill-accordion__name-input" aria-label="Rule Name" type="text" id="rule-name" name="rule-name" placeholder="" minlength="3" maxlength="20" pattern="^(.*[a-zA-Z]){3}(.*){1,}" value="${name}"> + <span class="fill-accordion__label-icon"></span> </label> <article class="fill-accordion__container"> <div class="detailed-input-group fill"> <div class="detailed-input-group__header"> <div class="detailed-input-group__header-item"> - <h3 class="detailed-input-group__title sm">Concept Import Rule</h3> - <p class="detailed-input-group__description">Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p> + <h3 class="detailed-input-group__title sm">{{ brand_mapping.concept }} Import Rule</h3> + <pre class="detailed-input-group__description">Lorem ipsum dolor sit amet, consectetur adipiscing elit.</pre> </div> <div class="detailed-input-group__header-item"> <span tooltip="Remove Ruleset" direction="left"> @@ -317,7 +320,7 @@ <h3 class="detailed-input-group__title sm">Concept Import Rule</h3> </span> </div> </div> - <input class="text-input" aria-label="Imported Concept" + <input class="text-input" aria-label="Imported {{ brand_mapping.concept }}" type="text" value="${source}" data-item="rule" disabled> </div> </article> diff --git a/CodeListLibrary_project/cll/templates/components/create/inputs/clinical/contact_information.html b/CodeListLibrary_project/cll/templates/components/create/inputs/clinical/contact_information.html new file mode 100644 index 000000000..16ef00722 --- /dev/null +++ b/CodeListLibrary_project/cll/templates/components/create/inputs/clinical/contact_information.html @@ -0,0 +1,50 @@ +{% load entity_renderer %} + +<div class="detailed-input-group fill"> + <h3 class="detailed-input-group__title">Contacts {% if component.mandatory %}<span class="detailed-input-group__mandatory">*</span>{% endif %}</h3> + {% if not component.hide_input_details %} + <pre class="detailed-input-group__description">{{ component.description }}</pre> + {% endif %} + + <div class="publication-list-group"> + {% if component.value %} + {% to_json_script component.value desc-type="value" for=component.field_name %} + {% else %} + <script type="application/json" data-type="value" for="{{ component.field_name }}"></script> + {% endif %} + + <div class="publication-list-group__interface" data-class="contact-list" data-field="{{ component.field_name }}"> + <p>Add Contact</p> + <div class="publication-list-group__interface-children"> + <input class="text-input" + aria-label="Contact Name" + type="text" + placeholder="e.g. John Doe" + minlength="3" + pattern="^[a-zA-Z]{1,}.*?" + id="publication-input-box"> + <input class="text-input" + aria-label="Contact email" + type="text" + placeholder="e.g. name@organisation.com" + id="doi-input-box"> + </div> + <div class="publication-list-group__interface-children publication-list-group__interface-children--right"> + <button class="primary-btn text-accent-darkest bold secondary-accent" aria-label="Add Contact" id="add-input-btn">Add</button> + </div> + </div> + + <section class="publication-list-group__none-available" id="no-available-publications"> + <p class="publication-list-group__none-available-message">You haven't added any contacts yet.</p> + </section> + + <section class="publication-list-group__list show" id="publication-group"> + <div class="publication-list-group__list-header" id="pub-header"> + <h3>Your contacts</h3> + </div> + <div class="publication-list-group__list-container slim-scrollbar" id="publication-list"> + + </div> + </section> + </div> +</div> diff --git a/CodeListLibrary_project/cll/templates/components/create/inputs/clinical/endorsement.html b/CodeListLibrary_project/cll/templates/components/create/inputs/clinical/endorsement.html index f5d49bc36..244c721a2 100644 --- a/CodeListLibrary_project/cll/templates/components/create/inputs/clinical/endorsement.html +++ b/CodeListLibrary_project/cll/templates/components/create/inputs/clinical/endorsement.html @@ -3,7 +3,7 @@ <div class="detailed-input-group fill"> <h3 class="detailed-input-group__title">Endorsements {% if component.mandatory %}<span class="detailed-input-group__mandatory">*</span>{% endif %}</h3> {% if not component.hide_input_details %} - <p class="detailed-input-group__description">Add a endorsement by providing the reference details</p> + <pre class="detailed-input-group__description">Add a endorsement by providing the reference details</pre> {% endif %} <div class="publication-list-group"> diff --git a/CodeListLibrary_project/cll/templates/components/create/inputs/clinical/publication.html b/CodeListLibrary_project/cll/templates/components/create/inputs/clinical/publication.html index 5e00ebf6b..3c2662a16 100644 --- a/CodeListLibrary_project/cll/templates/components/create/inputs/clinical/publication.html +++ b/CodeListLibrary_project/cll/templates/components/create/inputs/clinical/publication.html @@ -1,9 +1,14 @@ {% load entity_renderer %} <div class="detailed-input-group fill"> - <h3 class="detailed-input-group__title">Publications {% if component.mandatory %}<span class="detailed-input-group__mandatory">*</span>{% endif %}</h3> + <h3 class="detailed-input-group__title"> + {{ component.field_data.title }} + {% if component.mandatory %} + <span class="detailed-input-group__mandatory">*</span> + {% endif %} + </h3> {% if not component.hide_input_details %} - <p class="detailed-input-group__description">Add a publication by providing the reference details and its DOI.</p> + <pre class="detailed-input-group__description">{{ component.description }}</pre> {% endif %} <div class="publication-list-group"> @@ -20,10 +25,12 @@ <h3 class="detailed-input-group__title">Publications {% if component.mandatory % placeholder="e.g. Doe J. et al. Publication. Publisher" minlength="3" pattern="^[a-zA-Z]{1,}.*?" id="publication-input-box"> - <input class="text-input" aria-label="Publication DOI" type="text" placeholder="e.g. DOI 10.111" id="doi-input-box"> + <input class="text-input" aria-label="Publication DOI" type="text" placeholder="e.g. DOI 10.1000/111" id="doi-input-box"> + </div> + <div class="publication-list-group__interface-children publication-list-group__interface-children--right"> <div class="checkbox-item-container min-size"> - <input id="primary-publication-checkbox" aria-label="Primary Publication" type="checkbox" class="checkbox-item" data-value="1" data-name="Primary Publication"> - <label for="primary-publication-checkbox">Primary Publication</label> + <input id="primary-publication-checkbox" aria-label="Primary Publication" type="checkbox" class="checkbox-item" data-value="1" data-name="Primary Publication"> + <label for="primary-publication-checkbox">Primary Publication</label> </div> <button class="primary-btn text-accent-darkest bold secondary-accent" aria-label="Add Publication" id="add-input-btn">Add</button> </div> diff --git a/CodeListLibrary_project/cll/templates/components/create/inputs/clinical/references.html b/CodeListLibrary_project/cll/templates/components/create/inputs/clinical/references.html new file mode 100644 index 000000000..f18da974e --- /dev/null +++ b/CodeListLibrary_project/cll/templates/components/create/inputs/clinical/references.html @@ -0,0 +1,41 @@ +{% load entity_renderer %} + +<div class="detailed-input-group fill"> + <h3 class="detailed-input-group__title">Related References {% if component.mandatory %}<span class="detailed-input-group__mandatory">*</span>{% endif %}</h3> + {% if not component.hide_input_details %} + <p class="detailed-input-group__description">A list of third party references related to this Concept.</p> + {% endif %} + + <div class="publication-list-group"> + {% if component.value %} + {% to_json_script component.value desc-type="value" for=component.field_name %} + {% else %} + <script type="application/json" data-type="value" for="{{ component.field_name }}"></script> + {% endif %} + + <div class="publication-list-group__interface" data-class="clinical-references" data-field="{{ component.field_name }}"> + <p>Add References</p> + <div class="publication-list-group__interface-children"> + <input class="text-input" aria-label="Reference Title" type="text" + placeholder="Enter Title" + minlength="3" pattern="^[a-zA-Z]{1,}.*?" + id="reference-title-input-box"> + <input class="text-input" aria-label="Reference Link" type="text" placeholder="Enter URL" id="url-input-box"> + <button class="primary-btn text-accent-darkest bold secondary-accent" aria-label="Add Reference" id="add-input-btn">Add</button> + </div> + </div> + + <section class="publication-list-group__none-available" id="no-available-references"> + <p class="publication-list-group__none-available-message">You haven't added any references yet.</p> + </section> + + <section class="publication-list-group__list show" id="reference-group"> + <div class="publication-list-group__list-header" id="pub-header"> + <h3>Your references</h3> + </div> + <div class="publication-list-group__list-container slim-scrollbar" id="reference-list"> + + </div> + </section> + </div> +</div> diff --git a/CodeListLibrary_project/cll/templates/components/create/inputs/clinical/trial.html b/CodeListLibrary_project/cll/templates/components/create/inputs/clinical/trial.html index 14efdd1e0..15b4744ba 100644 --- a/CodeListLibrary_project/cll/templates/components/create/inputs/clinical/trial.html +++ b/CodeListLibrary_project/cll/templates/components/create/inputs/clinical/trial.html @@ -3,7 +3,7 @@ <div class="detailed-input-group fill"> <h3 class="detailed-input-group__title">Clinical Trials {% if component.mandatory %}<span class="detailed-input-group__mandatory">*</span>{% endif %}</h3> {% if not component.hide_input_details %} - <p class="detailed-input-group__description">Add a clinical trial by providing registration ID, link and name.</p> + <pre class="detailed-input-group__description">Add a clinical trial by providing registration ID, link and name.</pre> {% endif %} <div class="publication-list-group"> @@ -19,12 +19,18 @@ <h3 class="detailed-input-group__title">Clinical Trials {% if component.mandator <input class="text-input" aria-label="Registration ID" type="text" placeholder="Registration ID" id="id-input-box"> <input class="text-input" aria-label="Registration Link" type="text" placeholder="Registration Link" id="link-input-box"> <input class="text-input" aria-label="Trial Name" type="text" placeholder="Trial Name" id="name-input-box"> + </div> + <div class="publication-list-group__interface__trial-children publication-list-group__interface__trial-children--right"> <div class="checkbox-item-container min-size"> - <input id="primary-trial-checkbox" aria-label="Primary Trial" type="checkbox" class="checkbox-item" data-value="1" data-name="Primary Trial"> - <label for="primary-trial-checkbox">Primary Trial</label> + <input id="primary-trial-checkbox" aria-label="Primary Trial" type="checkbox" class="checkbox-item" data-value="1" data-name="Primary Trial"> + <label for="primary-trial-checkbox"> + Primary Trial + </label> </div> - <button class="primary-btn text-accent-darkest bold secondary-accent" aria-label="Add Publication" id="add-input-btn">Add</button> - </div> + <button class="primary-btn text-accent-darkest bold secondary-accent" aria-label="Add Publication" id="add-input-btn"> + Add + </button> + </div> </div> <section class="publication-list-group__none-available" id="no-available-trials"> diff --git a/CodeListLibrary_project/cll/templates/components/create/inputs/data_assets.html b/CodeListLibrary_project/cll/templates/components/create/inputs/data_assets.html new file mode 100644 index 000000000..3724dee85 --- /dev/null +++ b/CodeListLibrary_project/cll/templates/components/create/inputs/data_assets.html @@ -0,0 +1,191 @@ +{% load cl_extras %} +{% load entity_renderer %} + +<div class="detailed-input-group fill"> + {% if not component.hide_input_details %} + <h3 class="detailed-input-group__title"> + {{ component.field_data.title }} + {% if component.mandatory %} + <span class="detailed-input-group__mandatory">*</span> + {% endif %} + </h3> + <pre class="detailed-input-group__description">{{ component.description }}</pre> + {% endif %} + + {% if component.options %} + {% to_json_script component.options data-name="options" data-type="text/json" for=component.field_name %} + {% endif %} + + {% if component.value %} + {% to_json_script component.value data-name="value" data-type="text/json" for=component.field_name %} + {% else %} + <script type="application/json" data-name="value" data-type="text/json" for="{{ component.field_name }}"></script> + {% endif %} + + {% if component.field_data.behaviour and component.field_data.behaviour|get_type == "dict" %} + {% to_json_script component.field_data.behaviour data-name="behaviour" data-type="text/json" for=component.field_name %} + {% endif %} + + {% if component.mandatory or component.field_data.hint %} + <div class="validation-block validation-block--warning"> + <div class="validation-block__container"> + <div class="validation-block__title"> + <span class="as-icon" data-icon="" aria-hidden="true"></span> + <p>Note:</p> + </div> + <ul> + {% if component.mandatory %} + <li><pre class="validation-block__message">This field is mandatory.</pre></li> + {% endif %} + {% if component.field_data.hint %} + <li><pre class="validation-block__message">{{ component.field_data.hint }}</pre></li> + {% endif %} + </ul> + </div> + </div> + {% endif %} + <section + id="entity-{{ component.field_name }}" + name="entity-{{ component.field_name }}" + class="asset-component" + data-vis="{% if component.vis_vary_on_opts %}1{% else %}0{% endif %}" + data-class="data-assets" + data-field="{{ component.field_name }}" + > + <header class="asset-component__actions"> + <section + class="asset-component__field" + role="combobox" + aria-owns="asset-input" + aria-expanded="false" + aria-haspopup="listbox" + data-area="field" + > + <input + id="asset-input" + name="asset-input" + type="text" + class="asset-component__field-input" + placeholder="Add {{ component.field_data.title }} by name..." + aria-label="Add {{ component.field_data.title }} by name" + data-area="input" + /> + <button + class="asset-component__field-btn" + title="Add Item" + aria-label="Add Item" + data-area="add-btn" + > + </button> + <section + id="asset-results" + name="asset-results" + class="asset-component__field-suggestions" + role="listbox" + aria-role="listbox" + aria-expanded="false" + data-area="suggestions" + > + + </section> + </section> + <button + class="asset-component__create-btn primary-btn text-accent-darkest bold secondary-accent" + aria-label="Create {{ component.field_data.title }}" + data-area="create-btn" + > + Create {{ component.field_data.title }} + </button> + </header> + + <section class="asset-component__none-available" data-area="none-available"> + <p class="asset-component__none-available-message"> + No {{ component.field_data.title }} selected. + </p> + </section> + + <section class="asset-component__selection slim-scrollbar" data-area="selection"> + + </section> + + <template data-name="item" data-view="selection"> + <div class="tag tag--highlight" data-new="${new}" data-area="asset"> + <span class="tag__name">${label}</span> + <button class="as-icon" data-icon="" aria-label="Edit Item" data-role="edit"></button> + <button class="as-icon" data-icon="" aria-label="Remove Item" data-role="remove"></button> + </div> + </template> + + <template data-name="suggestion" data-view="builder"> + <a + class="asset-component__field-suggestions-item" + href="#" + role="option" + aria-role="option" + aria-label="${label}" + aria-selected="${selected}" + data-ref="${ref}" + data-area="item" + > + ${label} + </a> + </template> + + <template data-name="tags" data-view="builder"> + <div class="input-field-container" data-ctrl="string" data-ref="${ref}"> + <p class="input-field-container__label input-field-container--fill-w"> + ${label} + <span class="input-field-container__mandatory ${required ? '' : 'hidden'}">*</span> + </p> + <input + class="text-input input-field-container__input" + class="text-input" + placeholder="" + aria-label="${label}" + data-ref="${ref}" + /> + </div> + </template> + + <template data-name="string" data-view="builder"> + <div class="input-field-container" data-ctrl="string" data-ref="${ref}"> + <p class="input-field-container__label input-field-container--fill-w"> + ${label} + <span class="input-field-container__mandatory ${required ? '' : 'hidden'}">*</span> + </p> + <input + class="text-input input-field-container__input" + type="text" + value="${value}" + ${minlength} + ${maxlength} + placeholder="" + aria-label="${label}" + data-ref="${ref}" + ${required} + /> + </div> + </template> + + <template data-name="text" data-view="builder"> + <div class="input-field-container" data-ctrl="text" data-ref="${ref}"> + <p class="input-field-container__label input-field-container--fill-w"> + ${label} + <span class="input-field-container__mandatory ${required ? '' : 'hidden'}">*</span> + </p> + <textarea + class="text-area-input input-field-container__input" + rows="2" + autocorrect="on" + autocomplete="off" + spellcheck="default" + wrap="soft" + aria-label="${label}" + data-class="textarea" + data-field="{{ component.field_name }}" + ${required} + >${value}</textarea> + </div> + </template> + </section> +</div> diff --git a/CodeListLibrary_project/cll/templates/components/create/inputs/datepicker.html b/CodeListLibrary_project/cll/templates/components/create/inputs/datepicker.html index c08e34acc..c5aab75ff 100644 --- a/CodeListLibrary_project/cll/templates/components/create/inputs/datepicker.html +++ b/CodeListLibrary_project/cll/templates/components/create/inputs/datepicker.html @@ -6,9 +6,7 @@ <h3 class="detailed-input-group__title"> <span class="detailed-input-group__mandatory">*</span> {% endif %} </h3> - <p class="detailed-input-group__description"> - {{ component.description }} - </p> + <pre class="detailed-input-group__description">{{ component.description }}</pre> {% endif %} <fieldset class="date-range-field" id="entity-{{ component.field_name }}"> <input type="text" class="date-range-picker" aria-label="{{ component.field_data.title }}" id="entity-{{ component.field_name }}-input" data-class="datepicker" data-range="false" data-field="{{ component.field_name }}" data-value="{{ component.value }}" /> diff --git a/CodeListLibrary_project/cll/templates/components/create/inputs/datepicker_range.html b/CodeListLibrary_project/cll/templates/components/create/inputs/datepicker_range.html index a7dfa3ebd..ce9447b65 100644 --- a/CodeListLibrary_project/cll/templates/components/create/inputs/datepicker_range.html +++ b/CodeListLibrary_project/cll/templates/components/create/inputs/datepicker_range.html @@ -6,9 +6,7 @@ <h3 class="detailed-input-group__title"> <span class="detailed-input-group__mandatory">*</span> {% endif %} </h3> - <p class="detailed-input-group__description"> - {{ component.description }} - </p> + <pre class="detailed-input-group__description">{{ component.description }}</pre> {% endif %} <fieldset class="date-range-field" id="entity-{{ component.field_name }}"> <input type="text" class="date-range-picker" aria-label="{{ component.field_data.title }}" id="entity-{{ component.field_name }}-input" data-class="datepicker" data-range="true" data-field="{{ component.field_name }}" data-value="{{ component.value }}" /> diff --git a/CodeListLibrary_project/cll/templates/components/create/inputs/daterange_selector.html b/CodeListLibrary_project/cll/templates/components/create/inputs/daterange_selector.html index 217c7b7bf..f287b55bc 100644 --- a/CodeListLibrary_project/cll/templates/components/create/inputs/daterange_selector.html +++ b/CodeListLibrary_project/cll/templates/components/create/inputs/daterange_selector.html @@ -6,9 +6,7 @@ <h3 class="detailed-input-group__title"> <span class="detailed-input-group__mandatory">*</span> {% endif %} </h3> - <p class="detailed-input-group__description"> - {{ component.description }} - </p> + <pre class="detailed-input-group__description">{{ component.description }}</pre> {% endif %} <fieldset class="date-range-field date-range-field--wrapped date-range-field--padding0_5" id="entity-{{ component.field_name }}" data-class="daterange" data-field="{{ component.field_name }}" data-value="{{ component.value }}"> <span class="date-range-field__label">Start:</span> diff --git a/CodeListLibrary_project/cll/templates/components/create/inputs/double_range_slider.html b/CodeListLibrary_project/cll/templates/components/create/inputs/double_range_slider.html new file mode 100644 index 000000000..7a8974e0c --- /dev/null +++ b/CodeListLibrary_project/cll/templates/components/create/inputs/double_range_slider.html @@ -0,0 +1,134 @@ +{% load entity_renderer %} +<div class="detailed-input-group fill"> + {% if not component.hide_input_details %} + <h3 class="detailed-input-group__title"> + {{ component.field_data.title }} + {% if component.mandatory %} + <span class="detailed-input-group__mandatory">*</span> + {% endif %} + </h3> + <pre class="detailed-input-group__description">{{ component.description }}</pre> + {% endif %} + + {% if component.properties %} + {% to_json_script component.properties data-type="properties" for=component.field_name %} + {% endif %} + + {% if component.value %} + {% to_json_script component.value data-type="value" for=component.field_name %} + {% endif %} + + <fieldset class="double-slider" + id="entity-{{ component.field_name }}" + data-class="doublerangeslider" + data-field="{{ component.field_name }}" + data-value=""> + <div class="double-slider__input"> + <div class="double-slider__input__progress" id="progress-bar"></div> + <input type="range" min="0" max="100" value="0" id="min-slider" data-target="min"> + <input type="range" min="0" max="100" value="100" id="max-slider" data-target="max"> + </div> + + <div class="double-slider__group"> + <div class="input-field-container number-input"> + <div class="number-input__group"> + <div class="number-input__group-inner"> + <input + id="min-value" + name="min-value" + class="number-input__group-input" + type="number" + value="0" + pattern="(\\-)?(\\d+(\\.\\d{0,})?)|(\\.\\d+)" + step="1" + min="0" + max="100" + tabindex="0" + placeholder="..." + aria-label="Enter min number value" + data-step="1" + data-target="min" + /> + <span class="as-icon" aria-hidden="true"></span> + </div> + <div class="number-input__group-spin"> + <button + class="number-input__group-action" + type="button" + role="button" + tabindex="0" + title="Increase number value" + aria-label="Increase number value" + data-op="increment" + data-ref="min-value" + data-role="button" + > + </button> + <button + class="number-input__group-action" + type="button" + role="button" + tabindex="0" + title="Decrease number value" + aria-label="Decrease number value" + data-op="decrement" + data-ref="min-value" + data-role="button" + > + </button> + </div> + </div> + </div> + + <div class="input-field-container number-input"> + <div class="number-input__group"> + <div class="number-input__group-inner"> + <input + id="max-value" + name="max-value" + class="number-input__group-input" + type="number" + value="0" + pattern="(\\-)?(\\d+(\\.\\d{0,})?)|(\\.\\d+)" + step="1" + min="0" + max="100" + tabindex="0" + placeholder="..." + aria-label="Enter max number value" + data-step="1" + data-target="max" + /> + <span class="as-icon" aria-hidden="true"></span> + </div> + <div class="number-input__group-spin"> + <button + class="number-input__group-action" + type="button" + role="button" + tabindex="0" + title="Increase number value" + aria-label="Increase number value" + data-op="increment" + data-ref="max-value" + data-role="button" + > + </button> + <button + class="number-input__group-action" + type="button" + role="button" + tabindex="0" + title="Decrease number value" + aria-label="Decrease number value" + data-op="decrement" + data-ref="max-value" + data-role="button" + > + </button> + </div> + </div> + </div> + </div> + </fieldset> +</div> diff --git a/CodeListLibrary_project/cll/templates/components/create/inputs/dropdown.html b/CodeListLibrary_project/cll/templates/components/create/inputs/dropdown.html index 088556069..ca9d4ced2 100644 --- a/CodeListLibrary_project/cll/templates/components/create/inputs/dropdown.html +++ b/CodeListLibrary_project/cll/templates/components/create/inputs/dropdown.html @@ -7,9 +7,7 @@ <h3 class="detailed-input-group__title"> <span class="detailed-input-group__mandatory">*</span> {% endif %} </h3> - <p class="detailed-input-group__description"> - {{ component.description }} - </p> + <pre class="detailed-input-group__description">{{ component.description }}</pre> {% endif %} <select class="selection-input" aria-label="{{ component.field_data.title }}" name="entity-{{ component.field_name }}" id="entity-{{ component.field_name }}" data-field="{{ component.field_name }}" data-class="dropdown"> <option disabled value="-1" {% if component.value|length == 0 %} selected {% endif %} hidden>{{ component.field_data.title }}</option> diff --git a/CodeListLibrary_project/cll/templates/components/create/inputs/generic/age_group.html b/CodeListLibrary_project/cll/templates/components/create/inputs/generic/age_group.html new file mode 100644 index 000000000..2452fceb4 --- /dev/null +++ b/CodeListLibrary_project/cll/templates/components/create/inputs/generic/age_group.html @@ -0,0 +1,227 @@ +{% load entity_renderer %} +<div class="detailed-input-group fill"> + {% if not component.hide_input_details %} + <h3 class="detailed-input-group__title"> + {{ component.field_data.title }} + {% if component.mandatory %} + <span class="detailed-input-group__mandatory">*</span> + {% endif %} + </h3> + <pre class="detailed-input-group__description">{{ component.description }}</pre> + {% endif %} + + {% if component.properties %} + {% to_json_script component.properties data-type="properties" for=component.field_name %} + {% endif %} + + {% if component.value %} + {% to_json_script component.value data-type="value" for=component.field_name %} + {% endif %} + + <fieldset + id="entity-{{ component.field_name }}" + class="age-group" + data-class="age-group" + data-field="{{ component.field_name }}" + > + <div class="selection-group"> + <p class="selection-group__title">Comparator:</p> + <select + id="comparator-dropdown" + placeholder-text="Between" + data-class="option" + data-element="dropdown" + data-target="inequality" + > + <option value="0" class="dropdown-selection__list-item" data-cls="na" data-rel="na" selected> + Not Specified + </option> + <option value="1" class="dropdown-selection__list-item" data-cls="range" data-rel="between"> + Between + </option> + <option value="2" class="dropdown-selection__list-item" data-cls="bounds" data-rel="lte"> + Less Than Or Equal + </option> + <option value="3" class="dropdown-selection__list-item" data-cls="bounds" data-rel="gte"> + Greater Than Or Equal + </option> + </select> + </div> + + <div id="age-container" class="age-group__box"> + + </div> + </fieldset> + + <template data-name="bounds" data-view="inputs"> + <div class="single-slider" data-group="input"> + <div class="single-slider__input"> + <div class="single-slider__input__progress" id="progress-bar"></div> + <input type="range" ${rangemin} ${rangemax} ${step} value="${value}" id="slider-input"> + </div> + <div class="input-field-container number-input"> + <div class="number-input__group"> + <div class="number-input__group-inner"> + <input + id="${id}" + name="${ref}" + class="number-input__group-input" + type="number" + value="${value}" + pattern="(\\-)?(\\d+(\\.\\d{0,})?)|(\\.\\d+)" + ${step} + ${rangemin} + ${rangemax} + tabindex="0" + placeholder="${placeholder}" + aria-label="Enter number value" + data-step="${btnStep}" + data-type="${type}" + /> + <span class="as-icon" aria-hidden="true"></span> + </div> + <div class="number-input__group-spin"> + <button + class="number-input__group-action" + type="button" + role="button" + tabindex="0" + title="Increase number value" + aria-label="Increase number value" + data-op="increment" + data-ref="${ref}" + data-role="button" + > + </button> + <button + class="number-input__group-action" + type="button" + role="button" + tabindex="0" + title="Decrease number value" + aria-label="Decrease number value" + data-op="decrement" + data-ref="${ref}" + data-role="button" + > + </button> + </div> + </div> + </div> + </div> + </template> + + <template data-name="range" data-view="inputs"> + <div class="double-slider"> + <div class="double-slider__input"> + <div class="double-slider__input__progress" id="progress-bar"></div> + <input type="range" ${rangemin} ${rangemax} ${step} value="${value_min}" id="min-slider" data-target="min"> + <input type="range" ${rangemin} ${rangemax} ${step} value="${value_max}" id="max-slider" data-target="max"> + </div> + + <div class="double-slider__group"> + <div class="input-field-container number-input"> + <div class="number-input__group"> + <div class="number-input__group-inner"> + <input + id="min-value" + name="min-value" + class="number-input__group-input" + type="number" + value="${value_min}" + pattern="(\\-)?(\\d+(\\.\\d{0,})?)|(\\.\\d+)" + ${step} + ${rangemin} + ${rangemax} + tabindex="0" + placeholder="..." + aria-label="Enter min number value" + data-step="${btnStep}" + data-type="${type}" + data-target="min" + /> + <span class="as-icon" aria-hidden="true"></span> + </div> + <div class="number-input__group-spin"> + <button + class="number-input__group-action" + type="button" + role="button" + tabindex="0" + title="Increase number value" + aria-label="Increase number value" + data-op="increment" + data-ref="min-value" + data-role="button" + > + </button> + <button + class="number-input__group-action" + type="button" + role="button" + tabindex="0" + title="Decrease number value" + aria-label="Decrease number value" + data-op="decrement" + data-ref="min-value" + data-role="button" + > + </button> + </div> + </div> + </div> + + <div class="input-field-container number-input"> + <div class="number-input__group"> + <div class="number-input__group-inner"> + <input + id="max-value" + name="max-value" + class="number-input__group-input" + type="number" + value="${value_max}" + pattern="(\\-)?(\\d+(\\.\\d{0,})?)|(\\.\\d+)" + ${step} + ${rangemin} + ${rangemax} + tabindex="0" + placeholder="..." + aria-label="Enter max number value" + data-step="${btnStep}" + data-type="${type}" + data-target="max" + /> + <span class="as-icon" aria-hidden="true"></span> + </div> + <div class="number-input__group-spin"> + <button + class="number-input__group-action" + type="button" + role="button" + tabindex="0" + title="Increase number value" + aria-label="Increase number value" + data-op="increment" + data-ref="max-value" + data-role="button" + > + </button> + <button + class="number-input__group-action" + type="button" + role="button" + tabindex="0" + title="Decrease number value" + aria-label="Decrease number value" + data-op="decrement" + data-ref="max-value" + data-role="button" + > + </button> + </div> + </div> + </div> + </div> + </div> + </template> +</div> 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 052f9fc2b..db68e352c 100644 --- a/CodeListLibrary_project/cll/templates/components/create/inputs/generic/ontology.html +++ b/CodeListLibrary_project/cll/templates/components/create/inputs/generic/ontology.html @@ -11,12 +11,13 @@ <h3 class="detailed-input-group__title"> <span class="detailed-input-group__mandatory">*</span> {% endif %} </h3> - <p class="detailed-input-group__description"> - {{ component.description }} - </p> + <pre class="detailed-input-group__description">{{ component.description }}</pre> {% endif %} <div class="ontology-group-creator" data-class="ontology" data-field="{{ component.field_name }}"> + {% if component.field_data.validation %} + {% to_json_script component.field_data.validation data-type="validation" for=component.field_name %} + {% endif %} {% if component.options %} {% to_json_script component.options data-type="dataset" for=component.field_name %} @@ -102,7 +103,7 @@ <h2 id="target-modal-title">${modalTitle}</h2> <div id="ontology-selector" class="ontology-modal-body__section ontology-modal-body__section--flex-order-0-0"> <div class="ontology-modal-body__header"> - <h4>Ontologies</h4> + <h4>Ontology Set</h4> </div> <div class="ontology-modal-body__window slim-scrollbar"> <div class="ontology-modal-body__layout" id="ontology-source-view"> diff --git a/CodeListLibrary_project/cll/templates/components/create/inputs/generic/url_list.html b/CodeListLibrary_project/cll/templates/components/create/inputs/generic/url_list.html index ebc3417fe..20a233e72 100644 --- a/CodeListLibrary_project/cll/templates/components/create/inputs/generic/url_list.html +++ b/CodeListLibrary_project/cll/templates/components/create/inputs/generic/url_list.html @@ -3,7 +3,7 @@ <div class="detailed-input-group fill"> <h3 class="detailed-input-group__title">{{ component.field_data.title }} {% if component.mandatory %}<span class="detailed-input-group__mandatory">*</span>{% endif %}</h3> {% if not component.hide_input_details %} - <p class="detailed-input-group__description">{{ component.description }}</p> + <pre class="detailed-input-group__description">{{ component.description }}</pre> {% endif %} <div class="publication-list-group"> {% if component.value %} diff --git a/CodeListLibrary_project/cll/templates/components/create/inputs/group_select.html b/CodeListLibrary_project/cll/templates/components/create/inputs/group_select.html index 92417d41c..88064ca58 100644 --- a/CodeListLibrary_project/cll/templates/components/create/inputs/group_select.html +++ b/CodeListLibrary_project/cll/templates/components/create/inputs/group_select.html @@ -6,9 +6,7 @@ <h3 class="detailed-input-group__title"> <span class="detailed-input-group__mandatory">*</span> {% endif %} </h3> - <p class="detailed-input-group__description"> - {{ component.description }} - </p> + <pre class="detailed-input-group__description">{{ component.description }}</pre> {% endif %} {% if component.options and component.options|length > 0 %} @@ -16,7 +14,7 @@ <h3 class="detailed-input-group__title"> id="entity-{{ component.field_name }}" data-class="group-select" data-field="{{ component.field_name }}"> <option value="-1" {% if component.value %} selected {% endif %}>Select Organisation</option> - {% for option in component.options %} + {% for option in component.options|dictsort:"name" %} <option value="{{ option.id }}" {% if component.value and component.value.name == option.name %} selected{% endif %}>{{ option.name }}</option> {% endfor %} </select> diff --git a/CodeListLibrary_project/cll/templates/components/create/inputs/grouped_enum.html b/CodeListLibrary_project/cll/templates/components/create/inputs/grouped_enum.html index c222c59ca..ab9845909 100644 --- a/CodeListLibrary_project/cll/templates/components/create/inputs/grouped_enum.html +++ b/CodeListLibrary_project/cll/templates/components/create/inputs/grouped_enum.html @@ -7,9 +7,7 @@ <h3 class="detailed-input-group__title"> <span class="detailed-input-group__mandatory">*</span> {% endif %} </h3> - <p class="detailed-input-group__description"> - {{ component.description }} - </p> + <pre class="detailed-input-group__description">{{ component.description }}</pre> {% endif %} {% if component.options %} diff --git a/CodeListLibrary_project/cll/templates/components/create/inputs/indicator_calculation.html b/CodeListLibrary_project/cll/templates/components/create/inputs/indicator_calculation.html new file mode 100644 index 000000000..bd0f9021d --- /dev/null +++ b/CodeListLibrary_project/cll/templates/components/create/inputs/indicator_calculation.html @@ -0,0 +1,51 @@ +{% load cl_extras %} + +<div class="detailed-input-group fill"> + {% if not component.hide_input_details %} + <h3 class="detailed-input-group__title"> + {{ component.field_data.title }} + {% if component.mandatory %} + <span class="detailed-input-group__mandatory">*</span> + {% endif %} + </h3> + <pre class="detailed-input-group__description">{{ component.description }}</pre> + {% endif %} + <div + class="tab-group" + data-field="{{ component.field_name }}" + data-class="indicator_calculation" + > + <input type="radio" id="tab1" name="tabGroup1" class="tab" checked> + <label for="tab1"><strong>Description</strong></label> + + <input type="radio" id="tab2" name="tabGroup1" class="tab"> + <label for="tab2"><strong>Numerator</strong></label> + + <input type="radio" id="tab3" name="tabGroup1" class="tab"> + <label for="tab3"><strong>Denominator</strong></label> + + <div class="tab__content"> + <textarea + id="entity-{{ component.field_name }}-description" + class="filter-scrollbar" + data-role="description"></textarea> + <script type="text/html" data-name="description" aria-hidden="true" for="{{ component.field_name }}">{% if component.value.description %}{{ component.value.description|safe }}{% endif %}</script> + </div> + + <div class="tab__content"> + <textarea + id="entity-{{ component.field_name }}-numerator" + class="filter-scrollbar" + data-role="numerator"></textarea> + <script type="text/html" data-name="numerator" aria-hidden="true" for="{{ component.field_name }}">{% if component.value.numerator %}{{ component.value.numerator|safe }}{% endif %}</script> + </div> + + <div class="tab__content"> + <textarea + id="entity-{{ component.field_name }}-denominator" + class="filter-scrollbar" + data-role="denominator"></textarea> + <script type="text/html" data-name="denominator" aria-hidden="true" for="{{ component.field_name }}">{% if component.value.denominator %}{{ component.value.denominator|safe }}{% endif %}</script> + </div> + </div> +</div> diff --git a/CodeListLibrary_project/cll/templates/components/create/inputs/inputbox.html b/CodeListLibrary_project/cll/templates/components/create/inputs/inputbox.html index 3eb516939..5aae6fb96 100644 --- a/CodeListLibrary_project/cll/templates/components/create/inputs/inputbox.html +++ b/CodeListLibrary_project/cll/templates/components/create/inputs/inputbox.html @@ -1,16 +1,64 @@ +{% load cl_extras %} +{% load entity_renderer %} + <div class="detailed-input-group fill"> {% if not component.hide_input_details %} - <h3 class="detailed-input-group__title"> - {{ component.field_data.title }} - {% if component.mandatory %} - <span class="detailed-input-group__mandatory">*</span> - {% endif %} - </h3> - <p class="detailed-input-group__description"> - {{ component.description }} - </p> + <h3 class="detailed-input-group__title"> + {{ component.field_data.title }} + {% if component.mandatory %} + <span class="detailed-input-group__mandatory">*</span> + {% endif %} + </h3> + <pre class="detailed-input-group__description">{{ component.description }}</pre> + {% endif %} + + {% get_str_validation component.field_data as str_val %} + {% if component.mandatory or str_val.has_range or str_val.has_min or component.field_data.hint %} + <div class="validation-block validation-block--warning"> + <div class="validation-block__container"> + <div class="validation-block__title"> + <span class="as-icon" data-icon="" aria-hidden="true"></span> + <p>Note:</p> + </div> + <ul> + {% if component.mandatory %} + <li><pre class="validation-block__message">This field is mandatory.</pre></li> + {% endif %} + {% if component.field_data.hint %} + <li><pre class="validation-block__message">{{ component.field_data.hint }}</pre></li> + {% endif %} + {% if str_val.has_range %} + {% if component.field_data.validation.length.0 < 1 %} + <li><pre class="validation-block__message">Must have a minimum of a maximum length of {{ component.field_data.validation.length.1 }} characters.</pre></li> + {% else %} + <li><pre class="validation-block__message">Must have a length between {{ component.field_data.validation.length.0 }} and {{ component.field_data.validation.length.1 }} characters.</pre></li> + {% endif %} + {% elif str_val.has_min %} + <li><pre class="validation-block__message">Must be at least {{ component.field_data.validation.length }} characters in length.</pre></li> + {% endif %} + </ul> + </div> + </div> {% endif %} - <input class="text-input" aria-label="{{ component.field.title }}" id="entity-{{ component.field_name }}" - placeholder="" {% if component.field_data.validation and component.field_data.validation == 'string' %} minlength="3" pattern="^[a-zA-Z]{1,}.*?" {% endif %} data-class="inputbox" - data-field="{{ component.field_name }}" value="{% if component.value %}{{ component.value }}{% endif %}"> + <input + id="entity-{{ component.field_name }}" + class="text-input" + value="{% if component.value %}{{ component.value }}{% endif %}" + {% if component.placeholder|get_type == "str" and component.placeholder.length > 0 %} + placeholder="{{ component.placeholder }}" + {% else %} + placeholder="Enter {{ component.field_data.title }}" + {% endif %} + {% if str_val.has_range %} + minlength="{{ component.field_data.validation.length.0 }}" + maxlength="{{ component.field_data.validation.length.1 }}" + {% elif str_val.has_min %} + minlength="{{ component.field_data.validation.length }}" + {% endif %} + {% if component.field_data.validation.regex|get_type == 'str' and component.field_data.validation.regex|length > 0 %} + pattern="{{ component.field_data.validation.regex }}" + {% endif %} + aria-label="{{ component.field_data.title }}" + data-class="inputbox" + data-field="{{ component.field_name }}"> </div> diff --git a/CodeListLibrary_project/cll/templates/components/create/inputs/list_enum.html b/CodeListLibrary_project/cll/templates/components/create/inputs/list_enum.html new file mode 100644 index 000000000..4cd613250 --- /dev/null +++ b/CodeListLibrary_project/cll/templates/components/create/inputs/list_enum.html @@ -0,0 +1,29 @@ +{% load entity_renderer %} +<div class="detailed-input-group fill"> + {% if not component.hide_input_details %} + <h3 class="detailed-input-group__title"> + {{ component.field_data.title }} + {% if component.mandatory %} + <span class="detailed-input-group__mandatory">*</span> + {% endif %} + </h3> + <pre class="detailed-input-group__description">{{ component.description }}</pre> + {% endif %} + + {% if component.options %} + {% to_json_script component.options data-type="options" for=component.field_name %} + {% endif %} + + {% if component.properties %} + {% to_json_script component.properties data-type="properties" for=component.field_name %} + {% endif %} + + {% if component.value %} + {% to_json_script component.value data-type="value" for=component.field_name %} + {% endif %} + + <fieldset class="hstack-radio-group hstack-radio-group--grid" id="entity-{{ component.field_name }}" data-class="listenum" + data-field="{{ component.field_name }}" data-value=""> + + </fieldset> +</div> diff --git a/CodeListLibrary_project/cll/templates/components/create/inputs/markdown.html b/CodeListLibrary_project/cll/templates/components/create/inputs/markdown.html index 1af252934..a7e75bd20 100644 --- a/CodeListLibrary_project/cll/templates/components/create/inputs/markdown.html +++ b/CodeListLibrary_project/cll/templates/components/create/inputs/markdown.html @@ -7,14 +7,12 @@ <h3 class="detailed-input-group__title"> <span class="detailed-input-group__mandatory">*</span> {% endif %} </h3> - <p class="detailed-input-group__description"> - {{ component.description }} - </p> + <pre class="detailed-input-group__description">{{ component.description }}</pre> {% endif %} <textarea id="entity-{{ component.field_name }}" class="filter-scrollbar" data-class="md-editor" data-field="{{ component.field_name }}" ></textarea> - <script type="text/html" aria-hidden="true" for="{{ component.field_name }}">{% if component.value %}{{ component.value|safe }}{% endif %}</script> + <script type="text/plain" aria-hidden="true" for="{{ component.field_name }}">{% if component.value %}{{ component.value|safe }}{% endif %}</script> </div> diff --git a/CodeListLibrary_project/cll/templates/components/create/inputs/radiobutton.html b/CodeListLibrary_project/cll/templates/components/create/inputs/radiobutton.html index 1454b2bdf..e9cf1a2db 100644 --- a/CodeListLibrary_project/cll/templates/components/create/inputs/radiobutton.html +++ b/CodeListLibrary_project/cll/templates/components/create/inputs/radiobutton.html @@ -6,9 +6,7 @@ <h3 class="detailed-input-group__title"> <span class="detailed-input-group__mandatory">*</span> {% endif %} </h3> - <p class="detailed-input-group__description"> - {{ component.description }} - </p> + <pre class="detailed-input-group__description">{{ component.description }}</pre> {% endif %} <fieldset class="hstack-radio-group" id="entity-{{ component.field_name }}" data-class="radiobutton" data-field="{{ component.field_name }}"> {% for option in component.options %} diff --git a/CodeListLibrary_project/cll/templates/components/create/inputs/related_entities.html b/CodeListLibrary_project/cll/templates/components/create/inputs/related_entities.html new file mode 100644 index 000000000..a007f2df3 --- /dev/null +++ b/CodeListLibrary_project/cll/templates/components/create/inputs/related_entities.html @@ -0,0 +1,149 @@ +{% load entity_renderer %} + +{% get_brand_map_rules as brand_mapping %} +{% get_template_entity_name template.entity_class template as entity_shortname %} +<div class="detailed-input-group fill"> + {% if not component.hide_input_details %} + <h3 class="detailed-input-group__title"> + {{ component.field_data.title }} + {% if component.mandatory %} + <span class="detailed-input-group__mandatory">*</span> + {% endif %} + </h3> + <pre class="detailed-input-group__description">{{ component.description }}</pre> + {% endif %} + + <section + id="entity-{{ component.field_name }}" + class="entity-selector-group" + data-name="{{ component.field_data.title }}" + data-class="related_entities" + data-field="{{ component.field_name }}" + > + {% to_json_script brand_mapping data-type="mapping" for=component.field_name data-shortname=entity_shortname %} + + {% if component.properties %} + {% to_json_script component.properties data-type="properties" for=component.field_name %} + {% else %} + <script type="application/json" data-type="properties" for="{{ component.field_name }}"></script> + {% endif %} + + {% if component.value %} + {% to_json_script component.value data-type="value" for=component.field_name %} + {% else %} + <script type="application/json" data-type="value" for="{{ component.field_name }}"></script> + {% endif %} + + <div + class="entity-dropdown" + role="combobox" + aria-owns="entity-input" + aria-expanded="false" + aria-haspopup="listbox" + data-area="search" + > + <input + id="entity-input" + name="entity-{{ component.field_name }}" + type="text" + class="entity-dropdown__input" + placeholder="Find {{ component.field_data.title }} to add..." + aria-label="Find {{ component.field_data.title }} to add..." + aria-controls="entity-results" + aria-autocomplete="list" + aria-activedescendant="" + data-area="inputbox" + /> + <div + id="entity-results" + name="entity-results" + class="entity-dropdown__results slim-scrollbar" + aria-role="listbox" + data-area="results" + > + <div class="entity-dropdown__infobox" data-area="infobox"> + <div class="entity-dropdown__infobox-details" data-area="infolabel"> + + </div> + <div class="bounce-loader" data-area="loader"> + <div class="bounce-loader__container"> + <div class="bounce-loader__dot"></div> + <div class="bounce-loader__dot"></div> + <div class="bounce-loader__dot"></div> + </div> + </div> + </div> + + + </div> + </div> + + <section + class="entity-selector-group__none-available" + data-area="noneAvailable" + > + <p class="entity-selector-group__none-available-message"> + You haven't added any {{ component.field_data.title }} yet. + </p> + </section> + + <section + class="entity-selector-group__list" + data-area="contentGroup" + > + <header class="entity-selector-group__list-header"> + <h3> + {{ component.field_data.title }} + </h3> + </header> + <div + class="entity-selector-group__list-container slim-scrollbar" + data-area="contentList" + > + + </div> + </section> + + <template data-name="item" data-view="results"> + <button + id="entity-result-${ref}" + class="entity-dropdown__item" + role="option" + aria-label="${label}" + aria-selected="false" + data-ref="${ref}" + data-index="${index}" + data-action="select" + > + <span class="entity-dropdown__item-label"> + ${label} + </span> + </button> + </template> + + <template data-name="item" data-view="content"> + <div + class="entity-selector-group__item" + data-area="item" + data-ref="${ref}" + > + <div class="entity-selector-group__item-text"> + <p>${label}</p> + </div> + <button + class="entity-selector-group__item-btn" + role="button" + tabindex="0" + title="Remove Item" + aria-label="Remove Item" + data-fn="button" + data-ref="${ref}" + data-owner="related_entities" + data-action="remove" + > + <span class="as-icon" data-icon=""></span> + </button> + </div> + </template> + </section> +</div> diff --git a/CodeListLibrary_project/cll/templates/components/create/inputs/single_slider.html b/CodeListLibrary_project/cll/templates/components/create/inputs/single_slider.html new file mode 100644 index 000000000..318cce311 --- /dev/null +++ b/CodeListLibrary_project/cll/templates/components/create/inputs/single_slider.html @@ -0,0 +1,88 @@ +{% load entity_renderer %} +<div class="detailed-input-group fill"> + {% if not component.hide_input_details %} + <h3 class="detailed-input-group__title"> + {{ component.field_data.title }} + {% if component.mandatory %} + <span class="detailed-input-group__mandatory">*</span> + {% endif %} + </h3> + <pre class="detailed-input-group__description">{{ component.description }}</pre> + {% endif %} + + {% if component.properties %} + {% to_json_script component.properties data-type="properties" for=component.field_name %} + {% endif %} + + {% if component.value %} + {% to_json_script component.value data-type="value" for=component.field_name %} + {% endif %} + + <fieldset + id="entity-{{ component.field_name }}" + class="single-slider" + data-class="single-slider" + data-field="{{ component.field_name }}" + > + <div class="single-slider__input"> + <div class="single-slider__input__progress" id="progress-bar"></div> + <input type="range" min="0" max="100" value="0" id="slider-input"> + </div> + + </fieldset> + + <template data-name="number" data-view="inputs"> + <div class="input-field-container number-input"> + <div class="number-input__group"> + <div class="number-input__group-inner"> + <input + id="${id}" + name="${ref}" + class="number-input__group-input" + type="number" + value="${value}" + pattern="(\\-)?(\\d+(\\.\\d{0,})?)|(\\.\\d+)" + ${step} + ${rangemin} + ${rangemax} + tabindex="0" + placeholder="${placeholder}" + aria-label="Enter number value" + data-step="${btnStep}" + data-type="${type}" + ${disabled} + /> + <span class="as-icon" aria-hidden="true"></span> + </div> + <div class="number-input__group-spin"> + <button + class="number-input__group-action" + type="button" + role="button" + tabindex="0" + title="Increase number value" + aria-label="Increase number value" + data-op="increment" + data-ref="${ref}" + data-role="button" + ${disabled} + > + </button> + <button + class="number-input__group-action" + type="button" + role="button" + tabindex="0" + title="Decrease number value" + aria-label="Decrease number value" + data-op="decrement" + data-ref="${ref}" + data-role="button" + ${disabled} + > + </button> + </div> + </div> + </div> + </template> +</div> diff --git a/CodeListLibrary_project/cll/templates/components/create/inputs/string_inputlist.html b/CodeListLibrary_project/cll/templates/components/create/inputs/string_inputlist.html index c129aad1e..93cfc7f40 100644 --- a/CodeListLibrary_project/cll/templates/components/create/inputs/string_inputlist.html +++ b/CodeListLibrary_project/cll/templates/components/create/inputs/string_inputlist.html @@ -3,7 +3,7 @@ <div class="detailed-input-group fill"> <h3 class="detailed-input-group__title">{{ component.field_data.title }} {% if component.mandatory %}<span class="detailed-input-group__mandatory">*</span>{% endif %}</h3> {% if not component.hide_input_details %} - <p class="detailed-input-group__description">{{ component.description }}</p> + <pre class="detailed-input-group__description">{{ component.description }}</pre> {% endif %} <div class="publication-list-group"> {% if component.value %} diff --git a/CodeListLibrary_project/cll/templates/components/create/inputs/tagbox.html b/CodeListLibrary_project/cll/templates/components/create/inputs/tagbox.html index a3ddf83ac..56a34e1a8 100644 --- a/CodeListLibrary_project/cll/templates/components/create/inputs/tagbox.html +++ b/CodeListLibrary_project/cll/templates/components/create/inputs/tagbox.html @@ -1,27 +1,58 @@ +{% load cl_extras %} {% load entity_renderer %} + <div class="detailed-input-group fill"> {% if not component.hide_input_details %} <h3 class="detailed-input-group__title"> {{ component.field_data.title }} {% if component.mandatory %} - <span class="detailed-input-group__mandatory">*</span> + <span class="detailed-input-group__mandatory">*</span> {% endif %} </h3> - <p class="detailed-input-group__description"> - {{ component.description }} - </p> + <pre class="detailed-input-group__description">{{ component.description }}</pre> {% endif %} {% if component.options %} - {% to_json_script component.options desc-type="options" for=component.field_name %} + {% to_json_script component.options data-name="options" data-type="text/json" for=component.field_name %} {% endif %} {% if component.value %} - {% to_json_script component.value desc-type="value" for=component.field_name %} + {% to_json_script component.value data-name="value" data-type="text/json" for=component.field_name %} {% else %} - <script type="application/json" desc-type="value" for="{{ component.field_name }}"></script> + <script type="application/json" data-name="value" data-type="text/json" for="{{ component.field_name }}"></script> + {% endif %} + + {% if component.field_data.behaviour and component.field_data.behaviour|get_type == "dict" %} + {% to_json_script component.field_data.behaviour data-name="behaviour" data-type="text/json" for=component.field_name %} {% endif %} - <input class="text-input" aria-label="{{ component.field_data.title }}" type="text" id="entity-{{ component.field_name }}" - name="entity-{{ component.field_name }}" placeholder="" data-class="tagify" data-field="{{ component.field_name }}"> + {% if component.mandatory or component.field_data.hint %} + <div class="validation-block validation-block--warning"> + <div class="validation-block__container"> + <div class="validation-block__title"> + <span class="as-icon" data-icon="" aria-hidden="true"></span> + <p>Note:</p> + </div> + <ul> + {% if component.mandatory %} + <li><pre class="validation-block__message">This field is mandatory.</pre></li> + {% endif %} + {% if component.field_data.hint %} + <li><pre class="validation-block__message">{{ component.field_data.hint }}</pre></li> + {% endif %} + </ul> + </div> + </div> + {% endif %} + <input + id="entity-{{ component.field_name }}" + type="text" + class="text-input" + aria-label="{{ component.field_data.title }}" + name="entity-{{ component.field_name }}" + placeholder="" + data-class="tagify" + data-field="{{ component.field_name }}" + data-vis="{% if component.vis_vary_on_opts %}1{% else %}0{% endif %}" + /> </div> diff --git a/CodeListLibrary_project/cll/templates/components/create/inputs/textarea.html b/CodeListLibrary_project/cll/templates/components/create/inputs/textarea.html index 2608d9a34..5ec2cdb75 100644 --- a/CodeListLibrary_project/cll/templates/components/create/inputs/textarea.html +++ b/CodeListLibrary_project/cll/templates/components/create/inputs/textarea.html @@ -1,7 +1,7 @@ <div class="detailed-input-group fill"> {% if not component.hide_input_details %} <h3 class="detailed-input-group__title">{{ component.field_data.title }} {% if component.mandatory %}<span class="detailed-input-group__mandatory">*</span>{% endif %}</h3> - <p class="detailed-input-group__description">{{ component.description }}</p> + <pre class="detailed-input-group__description">{{ component.description }}</pre> {% endif %} <textarea class="text-area-input" aria-label="{{ component.field_data.title }}" id="entity-{{ component.field_name }}" rows="4" autocorrect="on" autocomplete="off" minlength="5" spellcheck="default" wrap="soft" pattern="w{1,}.*?" data-class="textarea" data-field="{{ component.field_name }}">{% if component.value %}{{ component.value }}{% endif %}</textarea> </div> diff --git a/CodeListLibrary_project/cll/templates/components/create/inputs/var_selector.html b/CodeListLibrary_project/cll/templates/components/create/inputs/var_selector.html new file mode 100644 index 000000000..cd9a1d86b --- /dev/null +++ b/CodeListLibrary_project/cll/templates/components/create/inputs/var_selector.html @@ -0,0 +1,606 @@ +{% load entity_renderer %} + +<div class="detailed-input-group fill"> + <h3 class="detailed-input-group__title"> + {{ component.field_data.title }} + {% if component.mandatory %} + <span class="detailed-input-group__mandatory">*</span> + {% endif %} + </h3> + {% if not component.hide_input_details %} + <pre class="detailed-input-group__description">{{ component.description }}</pre> + {% endif %} + + <div + class="var-selection-group" + data-class="var_data" + data-field="{{ component.field_name }}" + > + {% if component.value %} + {% to_json_script component.value data-type="value" for=component.field_name %} + {% else %} + <script type="application/json" data-type="value" for="{{ component.field_name }}"></script> + {% endif %} + + {% if component.options %} + {% to_json_script component.options data-type="options" for=component.field_name %} + {% else %} + <script type="application/json" data-type="options" for="{{ component.field_name }}"></script> + {% endif %} + + {% if component.properties %} + {% to_json_script component.properties data-type="properties" for=component.field_name %} + {% else %} + <script type="application/json" data-type="properties" for="{{ component.field_name }}"></script> + {% endif %} + + {% if component.appearance and component.appearance.txt %} + {% to_json_script component.appearance.txt data-type="txt" for=component.field_name %} + {% else %} + <script type="application/json" data-type="txt" for="{{ component.field_name }}"></script> + {% endif %} + + <header id="var-interface" class="var-selection-group__interface"> + {% if component.appearance and component.appearance.clear_btn %} + <button + id="clear-btn" + class="primary-btn text-accent-darkest bold danger-accent" + data-fn="button" + data-area="clearBtn" + data-owner="var-selector" + data-action="clear" + aria-label="{{ component.appearance.clear_btn.label }}" + > + {{ component.appearance.clear_btn.label }} + {% if component.appearance.clear_btn.icon %} + <span class="as-icon" data-icon="{{ component.appearance.clear_btn.icon|safe }}" aria-hidden="true"></span> + {% endif %} + </button> + {% else %} + <button + id="clear-btn" + class="primary-btn text-accent-darkest bold danger-accent" + data-fn="button" + data-area="clearBtn" + data-owner="var-selector" + data-action="clear" + aria-label="Clear All {{ component.field_data.title }}" + > + Clear All + <span class="as-icon" data-icon="" aria-hidden="true"></span> + </button> + {% endif %} + {% if component.appearance and component.appearance.add_btn %} + <button + id="add-btn" + class="primary-btn text-accent-darkest bold secondary-accent" + data-fn="button" + data-area="addBtn" + data-owner="var-selector" + data-action="add" + aria-label="{{ component.appearance.add_btn.label }}" + > + {{ component.appearance.add_btn.label }} + {% if component.appearance.add_btn.icon %} + <span class="as-icon" data-icon="{{ component.appearance.add_btn.icon|safe }}" aria-hidden="true"></span> + {% endif %} + </button> + {% else %} + <button + id="add-btn" + class="primary-btn text-accent-darkest bold secondary-accent" + data-fn="button" + data-area="addBtn" + data-owner="var-selector" + data-action="add" + aria-label="Add New {{ component.field_data.title }}" + > + Create New + <span class="as-icon" data-icon="" aria-hidden="true"></span> + </button> + {% endif %} + </header> + + <section + id="none-available" + class="var-selection-group__none-available show" + data-area="noneAvailable"> + <p class="var-selection-group__none-available-message"> + You haven't added any {{ component.field_data.title }} yet. + </p> + </section> + + <section + id="var-group" + class="var-selection-group__list" + data-area="contentGroup"> + <header id="var-header" class="var-selection-group__list-header"> + <h3> + Your {{ component.field_data.title }} + </h3> + </header> + <div + id="var-list" + class="var-selection-group__list-container show slim-scrollbar" + data-area="contentList"> + + </div> + </section> + + <template data-name="item" data-view="vinterface"> + <div + class="var-selection-group__item" + data-area="item" + data-ref="${ref}" + > + <div class="var-selection-group__item-text"> + <p>${name} (<em>${type}</em>)</p> + <p class="var-selection-group__badge">${value}</p> + </div> + <button + class="var-selection-group__item-btn" + role="button" + tabindex="0" + title="Edit Item" + aria-label="Edit Item" + data-fn="button" + data-ref="${ref}" + data-owner="var-selector" + data-action="edit" + > + <span class="as-icon" data-icon=""></span> + </button> + <button + class="var-selection-group__item-btn" + role="button" + tabindex="0" + title="Remove Item" + aria-label="Remove Item" + data-fn="button" + data-ref="${ref}" + data-owner="var-selector" + data-action="remove" + > + <span class="as-icon" data-icon=""></span> + </button> + </div> + </template> + + <template data-name="panel" data-view="vinterface"> + <section class="var-selection"> + <header class="var-selection__header" data-section="header"> + <div class="var-selection__header-group"> + <p class="var-selection__header-label"> + ${title} + </p> + <select + id="tmpl-selector" + class="selection-input" + aria-label="${title}" + > + <option value="-1" disabled selected hidden> + ${label} + </option> + </select> + </div> + </header> + <section + id="none-available" + class="var-selection__none-available" + data-section="none"> + <p class="var-selection-group__none-available-message"> + Please select an option. + </p> + </section> + <section + class="var-selection__content" + data-section="content" + > + + </section> + <section + class="var-selection__content" + data-section="prompt" + > + + </section> + </section> + </template> + + <template data-name="number" data-view="inputs"> + <div class="input-field-container number-input" data-ctrl="number" data-ref="${ref}"> + <p class="input-field-container__label input-field-container--fill-w"> + ${label} + <span class="input-field-container__mandatory ${mandatory ? '' : 'hidden'}">*</span> + </p> + <div class="number-input__group"> + <div class="number-input__group-inner"> + <input + id="${id}" + name="${ref}" + class="number-input__group-input" + type="number" + value="${value}" + pattern="(\\-)?(\\d+(\\.\\d{0,})?)|(\\.\\d+)" + ${step} + ${rangemin} + ${rangemax} + tabindex="0" + placeholder="${placeholder}" + aria-label="Enter number value" + data-step="${btnStep}" + data-type="${type}" + ${disabled} + /> + <span class="as-icon" aria-hidden="true"></span> + </div> + <div class="number-input__group-spin"> + <button + class="number-input__group-action" + type="button" + role="button" + tabindex="0" + title="Increase number value" + aria-label="Increase number value" + data-op="increment" + data-ref="${ref}" + data-role="button" + ${disabled} + > + </button> + <button + class="number-input__group-action" + type="button" + role="button" + tabindex="0" + title="Decrease number value" + aria-label="Decrease number value" + data-op="decrement" + data-ref="${ref}" + data-role="button" + ${disabled} + > + </button> + </div> + </div> + </div> + </template> + + <template data-name="inputbox" data-view="inputs"> + <div class="input-field-container" data-ctrl="inputbox" data-ref="${ref}"> + <p class="input-field-container__label input-field-container--fill-w"> + ${label} + <span class="input-field-container__mandatory ${mandatory ? '' : 'hidden'}">*</span> + </p> + <input + id="${id}" + name="${ref}" + class="text-input input-field-container__input" + value="${value}" + ${minlength} + ${maxlength} + placeholder="${placeholder}" + aria-label="Text field" + ${disabled} + /> + </div> + </template> + + <template data-name="rangeslider" data-view="inputs"> + <div class="input-field-container" data-ctrl="rangeslider" data-ref="${ref}"> + <p class="input-field-container__label input-field-container--fill-w"> + ${label} + <span class="input-field-container__mandatory ${mandatory ? '' : 'hidden'}">*</span> + </p> + <fieldset + id="${id}" + name="${ref}" + class="double-slider" + > + <div class="double-slider__input"> + <div class="double-slider__input__progress" id="progress-bar"></div> + <input type="range" min="0" max="100" value="0" id="min-slider" data-target="min" ${disabled}> + <input type="range" min="0" max="100" value="100" id="max-slider" data-target="max" ${disabled}> + </div> + <div class="double-slider__group"> + <div class="double-slider__group__input"> + <input type="number" value="0" id="min-value" data-target="min" ${disabled}> + </div> + <div class="double-slider__group__input"> + <input type="number" value="100" id="max-value" data-target="max" ${disabled}> + </div> + </div> + </fieldset> + </div> + </template> + + <template data-name="numericrange" data-view="inputs"> + <div class="input-field-container number-input" data-ctrl="numericrange" data-ref="${ref}"> + <p class="input-field-container__label input-field-container--fill-w"> + ${label} + <span class="input-field-container__mandatory ${mandatory ? '' : 'hidden'}">*</span> + </p> + <div class="input-field-container__row"> + <div class="input-field-container__group"> + <p class="input-field-container__desc"> + Min value + </p> + <div class="number-input__group"> + <div class="number-input__group-inner"> + <input + id="min" + name="min" + class="number-input__group-input" + type="number" + value="${min}" + pattern="(\\-)?(\\d+(\\.\\d{0,})?)|(\\.\\d+)" + ${step} + tabindex="0" + placeholder="Enter number..." + aria-label="Enter number" + data-step="${btnStep}" + data-type="${type}" + ${disabled} + /> + <span class="as-icon" aria-hidden="true"></span> + </div> + <div class="number-input__group-spin"> + <button + class="number-input__group-action" + type="button" + role="button" + tabindex="0" + title="Increase number value" + aria-label="Increase number value" + data-op="increment" + data-ref="min" + data-role="button" + ${disabled} + > + </button> + <button + class="number-input__group-action" + type="button" + role="button" + tabindex="0" + title="Decrease number value" + aria-label="Decrease number value" + data-op="decrement" + data-ref="min" + data-role="button" + ${disabled} + > + </button> + </div> + </div> + </div> + <div class="input-field-container__group"> + <p class="input-field-container__desc"> + Max value + </p> + <div class="number-input__group"> + <div class="number-input__group-inner"> + <input + id="max" + name="max" + class="number-input__group-input" + type="number" + value="${max}" + pattern="(\\-)?(\\d+(\\.\\d{0,})?)|(\\.\\d+)" + tabindex="0" + ${step} + placeholder="Enter number..." + aria-label="Enter number" + data-step="${btnStep}" + data-type="${type}" + ${disabled} + /> + <span class="as-icon" aria-hidden="true"></span> + </div> + <div class="number-input__group-spin"> + <button + class="number-input__group-action" + type="button" + role="button" + tabindex="0" + title="Increase number value" + aria-label="Increase number value" + data-op="increment" + data-ref="max" + data-role="button" + ${disabled} + > + </button> + <button + class="number-input__group-action" + type="button" + role="button" + tabindex="0" + title="Decrease number value" + aria-label="Decrease number value" + data-op="decrement" + data-ref="max" + data-role="button" + ${disabled} + > + </button> + </div> + </div> + </div> + </div> + </div> + </template> + + <template data-name="ciinterval" data-view="inputs"> + <div class="input-field-container number-input" data-ctrl="ciinterval" data-ref="${ref}"> + <p class="input-field-container__label input-field-container--fill-w"> + ${label} + <span class="input-field-container__mandatory ${mandatory ? '' : 'hidden'}">*</span> + </p> + <div class="input-field-container__row"> + <div class="input-field-container__group"> + <p class="input-field-container__desc"> + CI Probability + </p> + <div class="number-input__group"> + <div class="number-input__group-inner"> + <input + id="probability" + name="probability" + class="number-input__group-input" + type="number" + value="${probability}" + pattern="(\\-)?(\\d+(\\.\\d{0,})?)|(\\.\\d+)" + min="0" + max="100" + step="0.1" + tabindex="0" + placeholder="Enter CI Probability" + aria-label="Enter probability" + data-step="0.1" + data-type="percentage" + ${disabled} + /> + <span class="as-icon" aria-hidden="true"></span> + </div> + <div class="number-input__group-spin"> + <button + class="number-input__group-action" + type="button" + role="button" + tabindex="0" + title="Increase number value" + aria-label="Increase number value" + data-op="increment" + data-ref="probability" + data-role="button" + ${disabled} + > + </button> + <button + class="number-input__group-action" + type="button" + role="button" + tabindex="0" + title="Decrease number value" + aria-label="Decrease number value" + data-op="decrement" + data-ref="probability" + data-role="button" + ${disabled} + > + </button> + </div> + </div> + </div> + <div class="input-field-container__group"> + <p class="input-field-container__desc"> + Lower Bound + </p> + <div class="number-input__group"> + <div class="number-input__group-inner"> + <input + id="lower" + name="lower" + class="number-input__group-input" + type="number" + value="${lower}" + pattern="(\\-)?(\\d+(\\.\\d{0,})?)|(\\.\\d+)" + step="0.1" + tabindex="0" + placeholder="Enter CI lower bound" + aria-label="Enter lower bound" + data-step="0.1" + data-type="numeric" + ${disabled} + /> + <span class="as-icon" aria-hidden="true"></span> + </div> + <div class="number-input__group-spin"> + <button + class="number-input__group-action" + type="button" + role="button" + tabindex="0" + title="Increase number value" + aria-label="Increase number value" + data-op="increment" + data-ref="lower" + data-role="button" + ${disabled} + > + </button> + <button + class="number-input__group-action" + type="button" + role="button" + tabindex="0" + title="Decrease number value" + aria-label="Decrease number value" + data-op="decrement" + data-ref="lower" + data-role="button" + ${disabled} + > + </button> + </div> + </div> + </div> + <div class="input-field-container__group"> + <p class="input-field-container__desc"> + Upper Bound + </p> + <div class="number-input__group"> + <div class="number-input__group-inner"> + <input + id="upper" + name="upper" + class="number-input__group-input" + type="number" + value="${upper}" + pattern="(\\-)?(\\d+(\\.\\d{0,})?)|(\\.\\d+)" + step="0.1" + tabindex="0" + placeholder="Enter CI upper bound" + aria-label="Enter upper bound" + data-step="0.1" + data-type="numeric" + ${disabled} + /> + <span class="as-icon" aria-hidden="true"></span> + </div> + <div class="number-input__group-spin"> + <button + class="number-input__group-action" + type="button" + role="button" + tabindex="0" + title="Increase number value" + aria-label="Increase number value" + data-op="increment" + data-ref="upper" + data-role="button" + ${disabled} + > + </button> + <button + class="number-input__group-action" + type="button" + role="button" + tabindex="0" + title="Decrease number value" + aria-label="Decrease number value" + data-op="decrement" + data-ref="upper" + data-role="button" + ${disabled} + > + </button> + </div> + </div> + </div> + </div> + </div> + </template> + </div> +</div> 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 23373e588..02a5b3220 100644 --- a/CodeListLibrary_project/cll/templates/components/create/section/section_start.html +++ b/CodeListLibrary_project/cll/templates/components/create/section/section_start.html @@ -1,5 +1,5 @@ {% load entity_renderer %} -<li class="phenotype-progress__item" id="{{ section.title|trimmed|escape }}-progress"> +<li class="phenotype-progress__item" id="{{ section.title|shrink_underscore|escape }}-progress"> <h2 class="phenotype-progress__item-title"> {{ section.title }} </h2> diff --git a/CodeListLibrary_project/cll/templates/components/details/aside.html b/CodeListLibrary_project/cll/templates/components/details/aside.html index ca28ed388..58efdf814 100644 --- a/CodeListLibrary_project/cll/templates/components/details/aside.html +++ b/CodeListLibrary_project/cll/templates/components/details/aside.html @@ -1,11 +1,11 @@ -{% load detail_pg_renderer %} +{% load entity_renderer %} <aside class="steps-wizard" id="steps-wizard-area"> <section class="steps-wizard__panel" onselectstart="return false;"> {% for section in detail_page_sections %} - <span class="steps-wizard__item" id="{{ section.title|trimmed|escape }}-step" + <span class="steps-wizard__item" id="{{ section.title|shrink_underscore|escape }}-step" data-value="{{ forloop.counter0|add:'1' }}" - data-target="{{ section.title|trimmed|escape }}-progress"> + data-target="{{ section.title|shrink_underscore|escape }}-progress"> {{ section.title }} </span> {% endfor %} diff --git a/CodeListLibrary_project/cll/templates/components/details/outputs/api.html b/CodeListLibrary_project/cll/templates/components/details/outputs/api.html index 7af7168b1..9ea3fd9c4 100644 --- a/CodeListLibrary_project/cll/templates/components/details/outputs/api.html +++ b/CodeListLibrary_project/cll/templates/components/details/outputs/api.html @@ -1,4 +1,5 @@ {% load compress %} +{% load entity_renderer %} <!-- API --> <div class="col-sm-12 no-print shadow-frame"> @@ -9,8 +10,10 @@ <h3 class="paneltitle" id="API_Header" style="margin-top:20px; margin-bottom:20p </h3> {% endcomment %} + {% get_brand_map_rules as brand_mapping %} + {% get_template_entity_name entity_class=entity_class template=template as entity_tmpl_name %} <span id="api_id" entity_id="{{ entity.id }}"> - <p> To Export {{ entity_class }} Details: </p> + <p> To Export {{ entity_tmpl_name }} Details: </p> <table class="table table-striped table-responsive-md table-flex-rows"> <tr> @@ -54,10 +57,10 @@ <h3 class="paneltitle" id="API_Header" style="margin-top:20px; margin-bottom:20p </p> <br> <p class="code-comment-highlight"> - # Get details of phenotype + # Get details of {{ brand_mapping.phenotype }} </p> <p class="code-general-highlight"> - phenotype_details = client$phenotypes$get_detail(<br/> + {{ brand_mapping.phenotype|lower }}_details = client$phenotypes$get_detail(<br/>  <span class="code-text-highlight">'{{ entity.id }}'</span>,<br/>  version_id=<span class="code-value-highlight">{{ entity.history_id }}</span><br/> ) @@ -85,10 +88,10 @@ <h3 class="paneltitle" id="API_Header" style="margin-top:20px; margin-bottom:20p </p> <br> <p class="code-comment-highlight"> - # Get codelist of phenotype + # Get details of {{ brand_mapping.phenotype }} </p> <p class="code-general-highlight"> - phenotype_codelist = client.phenotypes.get_detail(<br/> + {{ brand_mapping.phenotype|lower }}_detail = client.phenotypes.get_detail(<br/>  <span class="code-text-highlight">'{{ entity.id }}'</span>,<br/>  version_id=<span class="code-value-highlight">{{ entity.history_id }}</span><br/> ) @@ -98,7 +101,7 @@ <h3 class="paneltitle" id="API_Header" style="margin-top:20px; margin-bottom:20p </tr> </table> - <p> To Export {{ entity_class }} Code List:</p> + <p> To Export {{ entity_tmpl_name }} Code List:</p> <table class="table table-striped table-responsive-md table-flex-rows"> <tr> <th>Format</th> @@ -146,10 +149,10 @@ <h3 class="paneltitle" id="API_Header" style="margin-top:20px; margin-bottom:20p </p> <br> <p class="code-comment-highlight"> - # Get codelist of phenotype + # Get codelist of {{ brand_mapping.phenotype }} </p> <p class="code-general-highlight"> - phenotype_codelist = client$phenotypes$get_codelist(<br/> + {{ brand_mapping.phenotype|lower }}_codelist = client$phenotypes$get_codelist(<br/>  <span class="code-text-highlight">'{{ entity.id }}'</span>,<br/>  version_id=<span class="code-value-highlight">{{ entity.history_id }}</span><br/> ) @@ -177,10 +180,10 @@ <h3 class="paneltitle" id="API_Header" style="margin-top:20px; margin-bottom:20p </p> <br> <p class="code-comment-highlight"> - # Get codelist of phenotype + # Get codelist of {{ brand_mapping.phenotype }} </p> <p class="code-general-highlight"> - phenotype_codelist = client.phenotypes.get_codelist(<br/> + {{ brand_mapping.phenotype|lower }}_codelist = client.phenotypes.get_codelist(<br/>  <span class="code-text-highlight">'{{ entity.id }}'</span>,<br/>  version_id=<span class="code-value-highlight">{{ entity.history_id }}</span><br/> ) diff --git a/CodeListLibrary_project/cll/templates/components/details/outputs/citation_requirements.html b/CodeListLibrary_project/cll/templates/components/details/outputs/citation_requirements.html index 7f29a889b..2b3df5ece 100644 --- a/CodeListLibrary_project/cll/templates/components/details/outputs/citation_requirements.html +++ b/CodeListLibrary_project/cll/templates/components/details/outputs/citation_requirements.html @@ -16,12 +16,16 @@ <h3 class="detailed-input-group__title">{{ component.field_data.title }}</h3> <p class="detailed-input-group__description">{{ component.description }}</p> {% endif %} - {{ component.value|markdownify }} + <div class="markdown-render-container slim-scrollbar"> + {{ component.value|markdownify }} + </div> </h3> {% else %} <h3 class="detailed-input-group__title">Citation Example</h3> {% render_citation_block entity request as citation %} - {{ citation|markdownify }} + <div class="markdown-render-container slim-scrollbar"> + {{ citation|markdownify }} + </div> {% endif %} </div> </div> diff --git a/CodeListLibrary_project/cll/templates/components/details/outputs/clinical/contact_information.html b/CodeListLibrary_project/cll/templates/components/details/outputs/clinical/contact_information.html new file mode 100644 index 000000000..fe721b67d --- /dev/null +++ b/CodeListLibrary_project/cll/templates/components/details/outputs/clinical/contact_information.html @@ -0,0 +1,27 @@ +{% load static %} +{% load markdownify %} +{% load cl_extras %} +{% load entity_renderer %} + +<div class="detailed-input-group fill"> + <div class="publication-list-group"> + <section class="publication-list-group__list show" id="publication-group"> + <h3 id="pub-header"> + Contacts + </h3> + {% if component.value|length %} + <ul> + {% for p in component.value %} + <li aria-label="Email {{ p.name|striptags|escape }} ({{ p.email|striptags|escape }})"> + <a href="mailto:{{ p.email|striptags|escape }}" aria-label="Send an email"> + {{ p.name|striptags|escape }} + </a> + </li> + {% endfor %} + </ul> + {% else %} + <span class="card-no-data">No known contacts</span> + {% endif %} + </section> + </div> +</div> diff --git a/CodeListLibrary_project/cll/templates/components/details/outputs/clinical/publication.html b/CodeListLibrary_project/cll/templates/components/details/outputs/clinical/publication.html index 0d70e1968..09345682c 100644 --- a/CodeListLibrary_project/cll/templates/components/details/outputs/clinical/publication.html +++ b/CodeListLibrary_project/cll/templates/components/details/outputs/clinical/publication.html @@ -22,7 +22,7 @@ <h3 class="publication-list-group__list-detail-title" id="pub-header"> {% endif %} {{ p.details|markdownify }} {% if p.doi %} - (DOI: <a href="https://doi.org/{{ p.doi }}" aria-label="Visit Publication DOI page">{{ p.doi|striptags|escape }}</a>) + (DOI: <a target=_blank rel="noopener" href="https://doi.org/{{ p.doi }}" aria-label="Visit Publication DOI page">{{ p.doi|striptags|escape }}</a>) {% endif %} </li> {% endfor %} diff --git a/CodeListLibrary_project/cll/templates/components/details/outputs/clinical/references.html b/CodeListLibrary_project/cll/templates/components/details/outputs/clinical/references.html new file mode 100644 index 000000000..8600889e5 --- /dev/null +++ b/CodeListLibrary_project/cll/templates/components/details/outputs/clinical/references.html @@ -0,0 +1,29 @@ +{% load static %} +{% load markdownify %} +{% load cl_extras %} +{% load entity_renderer %} + +<div class="detailed-input-group fill"> + <div class="publication-list-group"> + <h3 class="detailed-input-group__title"> + {{ component.field_data.title }} + </h3> + <section class="publication-list-group__list show" id="reference-group"> + {% if component.value|length %} + <ul> + {% for p in component.value %} + <li> + {% if p.url %} + <a target=_blank rel="noopener" href="{{ p.url }}" target="_blank" rel="noopener noreferrer">{{ p.title }}</a> + {% else %} + {{ p.title }} + {% endif %} + </li> + {% endfor %} + </ul> + {% else %} + <span class="card-no-data">No Related References</span> + {% endif %} + </section> + </div> +</div> 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 79bd4f8c7..2c7db7574 100644 --- a/CodeListLibrary_project/cll/templates/components/details/outputs/clinical/trial.html +++ b/CodeListLibrary_project/cll/templates/components/details/outputs/clinical/trial.html @@ -23,7 +23,7 @@ <h3 class="detailed-input-group__title"> </div> <div class="row"> <div class="col-sm-12"> - <strong>Trial</strong>: <a href="{{ p.link }}">{{ p.name }}</a> + <strong>Trial</strong>: <a target=_blank rel="noopener" href="{{ p.link }}">{{ p.name }}</a> </div> </div> </div> diff --git a/CodeListLibrary_project/cll/templates/components/details/outputs/data_source.html b/CodeListLibrary_project/cll/templates/components/details/outputs/data_source.html index 372a6fbb8..6faa625ab 100644 --- a/CodeListLibrary_project/cll/templates/components/details/outputs/data_source.html +++ b/CodeListLibrary_project/cll/templates/components/details/outputs/data_source.html @@ -9,11 +9,27 @@ {% for ds in component.value %} {% if ds.url|length %} <div class="chip bold primary-accent text-accent-darkest"> - <a href="{{ ds.url }}" target="_blank" rel="noopener" class="chip-text no-icon">{{ ds.name }}</a> + <a href="{{ ds.url }}" target="_blank" rel="noopener" class="chip-text no-icon"> + {{ ds.name }} + </a> + </div> + {% elif ds.link|length %} + <div class="chip bold primary-accent text-accent-darkest"> + <a href="{{ ds.link }}" target="_blank" rel="noopener" class="chip-text no-icon"> + {{ ds.name }} + </a> + </div> + {% elif component.field_data.target %} + <div class="chip bold primary-accent text-accent-darkest"> + <a href="{% url 'api:'|add:component.field_data.target c.value %}" target="_blank" rel="noopener" class="chip-text no-icon"> + {{ ds.name }} + </a> </div> {% else %} <div class="chip bold primary-accent text-accent-darkest" disabled> - <a target="_blank" rel="noopener noreferrer" class="chip-text no-icon" aria-disabled="true" disabled>{{ ds.name }}</a> + <a target="_blank" rel="noopener noreferrer" class="chip-text no-icon" aria-disabled="true" disabled> + {{ ds.name }} + </a> </div> {% endif %} {% endfor %} diff --git a/CodeListLibrary_project/cll/templates/components/details/outputs/double_range.html b/CodeListLibrary_project/cll/templates/components/details/outputs/double_range.html new file mode 100644 index 000000000..b319bd6e8 --- /dev/null +++ b/CodeListLibrary_project/cll/templates/components/details/outputs/double_range.html @@ -0,0 +1,21 @@ +<div class="detailed-input-group fill"> + <div class="box-container"> + <div class="row{% if component.field_data.icon %} gap-md{% endif %}"> + {% if component.field_data.icon %} + <div class="col-sm-2 row-padding detail-field-title display-flex justify-content-start gap-md"> + <span class="as-icon phenotype-creation--icn" data-icon="{{ component.field_data.icon|safe }}" aria-hidden="true"></span> + {{ component.field_data.title }} + </div> + {% else %} + <div class="col-sm-2 row-padding detail-field-title"> + {{ component.field_data.title }} + </div> + {% endif %} + <div class="col-sm-{% if component.field_data.icon %}8{% else %}10{% endif %}"> + <span class="badge wrapped-tspan"> + {{ component.value.min }} ≤ x ≤ {{ component.value.max }} + </span> + </div> + </div> + </div> +</div> diff --git a/CodeListLibrary_project/cll/templates/components/details/outputs/generic/age_group.html b/CodeListLibrary_project/cll/templates/components/details/outputs/generic/age_group.html new file mode 100644 index 000000000..2fd3893b5 --- /dev/null +++ b/CodeListLibrary_project/cll/templates/components/details/outputs/generic/age_group.html @@ -0,0 +1,33 @@ +<div class="detailed-input-group fill"> + <div class="box-container"> + <div class="row{% if component.field_data.icon %} gap-md{% endif %}"> + {% if component.field_data.icon %} + <div class="col-sm-2 row-padding detail-field-title display-flex justify-content-start gap-md"> + <span class="as-icon phenotype-creation--icn" data-icon="{{ component.field_data.icon|safe }}" aria-hidden="true"></span> + {{ component.field_data.title }} + </div> + {% else %} + <div class="col-sm-2 row-padding detail-field-title"> + {{ component.field_data.title }} + </div> + {% endif %} + <div class="col-sm-{% if component.field_data.icon %}8{% else %}10{% endif %}"> + <span class="badge wrapped-tspan"> + {% if component.value %} + {% if not component.value.comparator %} + {{ component.value.min }} ≤ x ≤ {{ component.value.max }} + {% elif component.value.comparator == 'between' %} + {{ component.value.value.0 }} ≤ x ≤ {{ component.value.value.1 }} + {% elif component.value.comparator == 'lte' %} + x ≤ {{ component.value.value }} + {% elif component.value.comparator == 'gte' %} + x ≥ {{ component.value.value }} + {% endif %} + {% else %} + N/A + {% endif %} + </span> + </div> + </div> + </div> +</div> diff --git a/CodeListLibrary_project/cll/templates/components/details/outputs/generic/ontology.html b/CodeListLibrary_project/cll/templates/components/details/outputs/generic/ontology.html index 316aefd84..bb49db0d9 100644 --- a/CodeListLibrary_project/cll/templates/components/details/outputs/generic/ontology.html +++ b/CodeListLibrary_project/cll/templates/components/details/outputs/generic/ontology.html @@ -14,7 +14,7 @@ {% for item in component.value %} <span class="ontology-group__item" style="--type: {{ item.type_id }};"> {% url 'api:ontology_node_by_id' node_id=item.id as node_api %} - <a class="chip-text" target="_blank" + <a target=_blank rel="noopener" class="chip-text" target="_blank" href="{{ node_api }}" title="'{{ item.label }}' {{ item.full_names }}"> {{ item.label }} </a> diff --git a/CodeListLibrary_project/cll/templates/components/details/outputs/generic/url_list.html b/CodeListLibrary_project/cll/templates/components/details/outputs/generic/url_list.html index 4c0cc9a06..24d736eb4 100644 --- a/CodeListLibrary_project/cll/templates/components/details/outputs/generic/url_list.html +++ b/CodeListLibrary_project/cll/templates/components/details/outputs/generic/url_list.html @@ -4,9 +4,17 @@ {% load entity_renderer %} <div class="detailed-input-group fill"> - <p class="detailed-input-group__description"> - {{ component.field_data.title }} - </p> + <div class="col-sm-12 shadow-frame"> + <h3 class="paneltitle"> + <span id="entity-{{ component.field_name }}"></span> + {% if not component.hide_input_title %} + <h3 class="detailed-input-group__title">{{ component.field_data.title }}</h3> + {% endif %} + {% if not component.hide_input_details %} + <p class="detailed-input-group__description">{{ component.description }}</p> + {% endif %} + </h3> + </div> <div class="publication-list-group"> <section class="publication-list-group__list mg-sm show" id="string_inputlist-group"> {% if component.value|length %} @@ -14,7 +22,7 @@ {% for p in component.value %} {% if p.title and p.title|length %} <li> - <a href="{% if p.url and p.url|length %}{{ p.url }}{% endif %}" + <a target=_blank rel="noopener" href="{% if p.url and p.url|length %}{{ p.url }}{% endif %}" aria-label="Visit Reference: {{ p.title }}"> {{ p.title }} </a> diff --git a/CodeListLibrary_project/cll/templates/components/details/outputs/indicator_calculation.html b/CodeListLibrary_project/cll/templates/components/details/outputs/indicator_calculation.html new file mode 100644 index 000000000..25f12f539 --- /dev/null +++ b/CodeListLibrary_project/cll/templates/components/details/outputs/indicator_calculation.html @@ -0,0 +1,96 @@ +{% load static %} +{% load markdownify %} +{% load cl_extras %} + +{% if not component.hide_if_empty or component.value %} + <div class="detailed-input-group fill"> + <div class="col-sm-12 shadow-frame"> + <h3 class="paneltitle"> + <span id="entity-{{ component.field_name }}"></span> + {% if not component.hide_input_title %} + <h3 class="detailed-input-group__title">{{ component.field_data.title }}</h3> + {% endif %} + {% if not component.hide_input_details %} + <p class="detailed-input-group__description">{{ component.description }}</p> + {% endif %} + </h3> + </div> + + {% if component.value.description|length %} + <div id="indicator-calculation-description" class="fill-accordion"> + <input class="fill-accordion__input" + id="indicator-description" + name="indicator-description" + type="checkbox" /> + <label class="fill-accordion__label fill-accordion__label--slim" + id="indicator-description-label" + for="indicator-description" + role="button" + tabindex="0" + aria-label="Toggle Accordion: Indicator Calculation Description" + title="Description"> + <span aria-label="Indicator Calculation Description" class="fill-accordion__wrap-label"> + <span>Description</span> + </span> + <span class="fill-accordion__label-icon"></span> + </label> + <article class="fill-accordion__container codelist-extents"> + <div class="markdown-render-container slim-scrollbar"> + {{ component.value.description|markdownify }} + </div> + </article> + </div> + {% endif %} + + {% if component.value.numerator|length %} + <div id="indicator-calculation-numerator" class="fill-accordion"> + <input class="fill-accordion__input" + id="indicator-numerator" + name="indicator-numerator" + type="checkbox" /> + <label class="fill-accordion__label fill-accordion__label--slim" + id="indicator-numerator-label" + for="indicator-numerator" + role="button" + tabindex="0" + aria-label="Toggle Accordion: Indicator Calculation Numerator" + title="Numerator"> + <span aria-label="Indicator Calculation Numerator" class="fill-accordion__wrap-label"> + <span>Numerator</span> + </span> + <span class="fill-accordion__label-icon"></span> + </label> + <article class="fill-accordion__container codelist-extents"> + <div class="markdown-render-container slim-scrollbar"> + {{ component.value.numerator|markdownify }} + </div> + </article> + </div> + {% endif %} + + {% if component.value.denominator|length %} + <div id="indicator-calculation-denominator" class="fill-accordion"> + <input class="fill-accordion__input" + id="indicator-denominator" + name="indicator-denominator" + type="checkbox" /> + <label class="fill-accordion__label fill-accordion__label--slim" + id="indicator-denominator-label" + for="indicator-denominator" + role="button" + tabindex="0" + aria-label="Toggle Accordion: Indicator Calculation Denominator" + title="Denominator"> + <span aria-label="Indicator Calculation Denominator" class="fill-accordion__wrap-label"> + <span>Denominator</span> + </span> + <span class="fill-accordion__label-icon"></span> + </label> + <article class="fill-accordion__container codelist-extents"> + <div class="markdown-render-container slim-scrollbar"> + {{ component.value.denominator|markdownify }} + </div> + </article> + </div> + {% endif %} +{% endif %} diff --git a/CodeListLibrary_project/cll/templates/components/details/outputs/permissions.html b/CodeListLibrary_project/cll/templates/components/details/outputs/permissions.html index 87044f99e..a4ba85ec1 100644 --- a/CodeListLibrary_project/cll/templates/components/details/outputs/permissions.html +++ b/CodeListLibrary_project/cll/templates/components/details/outputs/permissions.html @@ -18,25 +18,6 @@ <h3 class="paneltitle" style="margin-top:20px; margin-bottom:20px;" aria-label=" <div class="col-sm-4" aria-label="{{ entity.owner.username }}"> <span>{{ entity.owner.username }}</span> </div> - - <div class="col-sm-2" aria-label="Owner access">Owner access</div> - {% if entity.owner_access == 1 %} - <div class="col-sm-4" aria-label="None"> - <span class="card-noweight">None</span> - </div> - {% elif entity.owner_access == 2 %} - <div class="col-sm-4" aria-label="View only"> - <span class="card-noweight">View only</span> - </div> - {% elif entity.owner_access == 3 %} - <div class="col-sm-4" aria-label="View and edit"> - <span class="card-noweight">View and edit</span> - </div> - {% else %} - <div class="col-sm-4" aria-label="Unknown"> - <span class="card-noweight">Unknown</span> - </div> - {% endif %} </div> </div> @@ -44,60 +25,18 @@ <h3 class="paneltitle" style="margin-top:20px; margin-bottom:20px;" aria-label=" <div class="box-container"> <div class="row"> - <div class="col-sm-2" aria-label="Group">Group</div> - {% if entity.group %} - <div class="col-sm-4" aria-label="{{ entity.group }}"> - <span>{{ entity.group }}</span> + <div class="col-sm-2" aria-label="Organisation">Organisation</div> + {% if entity.organisation %} + <div class="col-sm-4" aria-label="{{ entity.organisation }}"> + <a href="{% url 'view_organisation' slug=entity.organisation.slug %}">{{ entity.organisation.name }}</a> </div> {% else %} <div class="col-sm-4" aria-label="No associated group"> - <span class="card-no-data">No associated group</span> - </div> - {% endif %} - - <div class="col-sm-2" aria-label="Group access">Group access</div> - {% if entity.group_access == 1 %} - <div class="col-sm-4" aria-label="No Access"> - <span class="card-noweight text-right">No Access</span> - </div> - {% elif entity.group_access == 2 %} - <div class="col-sm-4" aria-label="View only"> - <span class="card-noweight text-right">View only</span> - </div> - {% elif entity.group_access == 3 %} - <div class="col-sm-4" aria-label="View and edit"> - <span class="card-noweight text-right">View and edit</span> - </div> - {% else %} - <div class="col-sm-4" aria-label="Unknown"> - <span class="card-noweight text-right">Unknown</span> + <span class="card-no-data">No associated organisation</span> </div> {% endif %} </div> </div> - - <hr class="small-divider" /> - - <div class="box-container"> - <div class="row"> - <div class="col-sm-2" aria-label="All other users">All other users</div> - {% if entity.world_access == 1 %} - <div class="col-sm-10" aria-label="No access"> - <span>No access</span> - </div> - {% elif entity.world_access == 2 %} - <div class="col-sm-10" aria-label="View only"> - <span>View only</span> - </div> - {% elif entity.world_access == 3 %} - <div class="col-sm-10" aria-label="View and edit"> - <span>View and edit</span> - </div> - {% else %} - <div class="col-sm-10" aria-hidden="true"></div> - {% endif %} - </div> - </div> </span> </div> {% endif %} diff --git a/CodeListLibrary_project/cll/templates/components/details/outputs/phenoflowid.html b/CodeListLibrary_project/cll/templates/components/details/outputs/phenoflowid.html index 0991e9eb9..94fcb0a54 100644 --- a/CodeListLibrary_project/cll/templates/components/details/outputs/phenoflowid.html +++ b/CodeListLibrary_project/cll/templates/components/details/outputs/phenoflowid.html @@ -6,12 +6,12 @@ <h3 class="detailed-input-group__title"> {{ component.field_data.title }} </h3> <p class="detailed-input-group__description"> - Please see the PhenoFLOW website <a href="https://kclhi.org/phenoflow/">here</a> for more information on workflow-based Phenotypes. + Please see the PhenoFLOW website <a target=_blank rel="noopener" href="https://kclhi.org/phenoflow/">here</a> for more information on workflow-based Phenotypes. </p> <div class="box-container"> <div class="row row-cols-auto gap-md"> <p> - <strong>PhenoFLOW Reference:</strong> <a href="{{ component.value }}">{{ component.value }}</a> + <strong>PhenoFLOW Reference:</strong> <a target=_blank rel="noopener" href="{{ component.value }}">{{ component.value }}</a> </p> </div> </div> diff --git a/CodeListLibrary_project/cll/templates/components/details/outputs/phenotype_clinical_code_lists.html b/CodeListLibrary_project/cll/templates/components/details/outputs/phenotype_clinical_code_lists.html index db32a9527..6eebe4d95 100644 --- a/CodeListLibrary_project/cll/templates/components/details/outputs/phenotype_clinical_code_lists.html +++ b/CodeListLibrary_project/cll/templates/components/details/outputs/phenotype_clinical_code_lists.html @@ -2,17 +2,29 @@ {% load compress %} {% load sass_tags %} {% load cl_extras %} -{% load entity_renderer %} {% load markdownify %} -<!-- phenotype_clinical_code_lists --> +{% load entity_renderer %} +<!-- phenotype_clinical_code_lists --> +{% get_brand_map_rules as brand_mapping %} <script src="{% static 'js/lib/simple-datatables/simple-datatables.min.js' %}"></script> {% compress js %} -<script type="text/javascript" src="{% static 'js/clinicalcode/data/conceptUtils.js' %}"></script> + <script type="text/javascript" src="{% static 'js/clinicalcode/data/conceptUtils.js' %}"></script> {% endcompress %} {% if component.value|length %} <div class="detailed-input-group fill-width"> + <div class="col-sm-12 shadow-frame"> + <h3 class="paneltitle"> + <span id="entity-{{ component.field_name }}"></span> + {% if not component.hide_input_title %} + <h3 class="detailed-input-group__title">{{ component.field_data.title }}</h3> + {% endif %} + {% if not component.hide_input_details %} + <p class="detailed-input-group__description">{{ component.description }}</p> + {% endif %} + </h3> + </div> <div id="export-code-button" class="box-container"> <div class="row no-pad"> @@ -20,18 +32,21 @@ </div> <div class="col-md-6 text-align-right"> - <label class="dropdown-group"> + <label class="dropdown-group dropdown-group--detail"> <div class="dropdown-group__button"> - Export Code List + <span class="as-icon phenotype-creation--icn" data-icon="" aria-hidden="true"></span> + <span> + Export Codelist + </span> </div> <input type="checkbox" class="dropdown-group__input" id="dd_2"> - <ul class="dropdown-group__menu dropdown-group__menu--fall-right" title="Export Code List"> - <li aria-label="Export code list as CSV" role="button" tabindex="0"> + <ul class="dropdown-group__menu dropdown-group__menu--fall-right" title="Export Codelists"> + <li aria-label="Export codelist as CSV" role="button" tabindex="0"> <a href="{% url 'export_entity_version_codes_to_csv' pk=entity.id history_id=entity.history_id %}"> CSV </a> </li> - <li aria-label="Export code list as Json" role="button" tabindex="0"> + <li aria-label="Export codelist as JSON" role="button" tabindex="0"> <a href="{% url 'api:get_generic_entity_field_by_version' entity.id entity.history_id 'codes' %}?format=json" target="_blank"> JSON </a> @@ -65,21 +80,20 @@ aria-label="Toggle Accordion: {{ c.name }} ({{ c.coding_system_name }})" title="{{ c.name }} ({{ c.coding_system_name }})"> <span aria-label="{{ c.name }} ({{ c.coding_system_name }})" class="fill-accordion__wrap-label"> - <a href="{% url 'entity_detail' pk=c.phenotype_owner_id %}" - title="Visit Linked Phenotype: {{c.phenotype_owner_id}} / C{{ c.concept_id }}"> + <a target=_blank rel="noopener" href="{% url 'entity_detail' pk=c.phenotype_owner_id %}" + title="Visit Linked {{ brand_mapping.phenotype }}: {{c.phenotype_owner_id}} / C{{ c.concept_id }}"> {{c.phenotype_owner_id}} / C{{ c.concept_id }} </a> - -  - {{ c.name }} -  | <em>{{ c.coding_system_name }}</em> - +  - <span>{{ c.name }}</span> |  + <em>{{ c.coding_system_name }}</em> <span id="ood-{{ c.concept_id }}-{{ c.concept_version_id }}" class="hide" - aria-label="Legacy Concept Version"> + aria-label="Legacy {{ brand_mapping.concept }} Version">  |  <strong>LEGACY VERSION</strong> </span> </span> + <span class="fill-accordion__label-icon"></span> </label> <article class="fill-accordion__container codelist-extents"> <section class="concept-group-content__container show" id="concept-information"> @@ -104,23 +118,6 @@ <h4 aria-label="Unpublished"> </li> </ul> </section> - {% if c.attributes %} - <section class="concept-group-content__details"> - <h4> - CONCEPT ATTRIBUTE: - <br> - {% for attribute in c.attributes %} - <br> - {% if not attribute.value.strip %} - <li> TYPE: {{ attribute.type}} - NAME: {{ attribute.name }} </li> - {% else %} - <li> TYPE: {{ attribute.type}} - NAME: {{ attribute.name }} - VALUE: {{ attribute.value}} </li> - {% endif %} - {% endfor %} - - </h4> - </section> - {% endif %} </section> <div class="detailed-input-group fill"> @@ -132,7 +129,7 @@ <h4> </div> {% endfor %} {% else %} - <span class="card-no-data">No Clinical Code Lists</span> + <span class="card-no-data">No Clinical Codelists</span> {% endif %} </div> @@ -152,7 +149,7 @@ <h4> queue[target] = 1; fetch( - `/api/v1/concepts/C${conceptId}/version/${conceptVersionId}/export/component-data?requested_entity=${entityId}`, + `${getBrandedHost()}/api/v1/concepts/C${conceptId}/version/${conceptVersionId}/export/component-data?requested_entity=${entityId}`, { method: 'GET' } ) .then(response => response.json()) diff --git a/CodeListLibrary_project/cll/templates/components/details/outputs/radiobutton.html b/CodeListLibrary_project/cll/templates/components/details/outputs/radiobutton.html index 32c1cc56f..7984afb9b 100644 --- a/CodeListLibrary_project/cll/templates/components/details/outputs/radiobutton.html +++ b/CodeListLibrary_project/cll/templates/components/details/outputs/radiobutton.html @@ -1,17 +1,25 @@ <div class="detailed-input-group fill"> <div class="box-container"> - <div class="row"> - <div class="col-sm-2 row-padding detail-field-title">{{ component.field_data.title }}</div> - <div class="col-sm-10"> - <span id="entity-{{ component.field_name }}"> - {% if component.value|length > 0 %} - {% for val in component.value %} + <div class="row{% if component.field_data.icon %} gap-md{% endif %}"> + {% if component.field_data.icon %} + <div class="col-sm-2 row-padding detail-field-title display-flex justify-content-start gap-md"> + <span class="as-icon phenotype-creation--icn" data-icon="{{ component.field_data.icon|safe }}" aria-hidden="true"></span> + {{ component.field_data.title }} + </div> + {% else %} + <div class="col-sm-2 row-padding detail-field-title"> + {{ component.field_data.title }} + </div> + {% endif %} + <div class="col-sm-{% if component.field_data.icon %}8{% else %}10{% endif %}"> + {% if component.value|length > 0 %} + {% for val in component.value %} + <span class="badge wrapped-tspan" id="entity-{{ component.field_name }}"> {{ val.name }} - {% endfor %} - {% endif %} - </span> + </span> + {% endfor %} + {% endif %} </div> </div> </div> </div> - diff --git a/CodeListLibrary_project/cll/templates/components/details/outputs/related_entities.html b/CodeListLibrary_project/cll/templates/components/details/outputs/related_entities.html new file mode 100644 index 000000000..393f300fb --- /dev/null +++ b/CodeListLibrary_project/cll/templates/components/details/outputs/related_entities.html @@ -0,0 +1,48 @@ +{% load entity_renderer %} + +{% if not component.hide_if_empty or component.value %} + <div class="detailed-input-group fill"> + <div class="col-sm-12 shadow-frame"> + <h3 class="paneltitle"> + <span id="entity-{{ component.field_name }}"></span> + {% if not component.hide_input_title %} + <h3 class="detailed-input-group__title">{{ component.field_data.title }}</h3> + {% endif %} + {% if not component.hide_input_details %} + <p class="detailed-input-group__description">{{ component.description }}</p> + {% endif %} + </h3> + </div> + + <section class="item-display"> + {% if not component.value %} + <section class="item-display__none"> + <p class="item-display__none-label"> + No data. + </p> + </section> + {% else %} + <section class="item-display__list slim-scrollbar"> + {% for item in component.value %} + <div class="item-display__item"> + <div class="item-display__text item-display__text--fill-w"> + {% if item.target %} + <a + target=_blank + rel="noopener" + href="{% url item.anchor pk=item.target %}" + title="View {{ item.label }}" + > + {{ item.label }} + </a> + {% else %} + <p><strong>{{ item.name }}</strong></p> + {% endif %} + </div> + </div> + {% endfor %} + </section> + {% endif %} + </section> + </div> +{% endif %} diff --git a/CodeListLibrary_project/cll/templates/components/details/outputs/single_slider.html b/CodeListLibrary_project/cll/templates/components/details/outputs/single_slider.html new file mode 100644 index 000000000..b258c2aaa --- /dev/null +++ b/CodeListLibrary_project/cll/templates/components/details/outputs/single_slider.html @@ -0,0 +1,21 @@ +<div class="detailed-input-group fill"> + <div class="box-container"> + <div class="row{% if component.field_data.icon %} gap-md{% endif %}"> + {% if component.field_data.icon %} + <div class="col-sm-2 row-padding detail-field-title display-flex justify-content-start gap-md"> + <span class="as-icon phenotype-creation--icn" data-icon="{{ component.field_data.icon|safe }}" aria-hidden="true"></span> + {{ component.field_data.title }} + </div> + {% else %} + <div class="col-sm-2 row-padding detail-field-title"> + {{ component.field_data.title }} + </div> + {% endif %} + <div class="col-sm-{% if component.field_data.icon %}8{% else %}10{% endif %}"> + <span class="badge wrapped-tspan"> + {{ component.value }} + </span> + </div> + </div> + </div> +</div> diff --git a/CodeListLibrary_project/cll/templates/components/details/outputs/source_reference.html b/CodeListLibrary_project/cll/templates/components/details/outputs/source_reference.html index fd4b8d989..277efecf4 100644 --- a/CodeListLibrary_project/cll/templates/components/details/outputs/source_reference.html +++ b/CodeListLibrary_project/cll/templates/components/details/outputs/source_reference.html @@ -1,12 +1,20 @@ <div class="detailed-input-group fill-width"> <div class="box-container"> <div class="row"> - <div class="col-sm-2 row-padding"> - {{ component.field_data.title }} + <div class="col-sm-12 shadow-frame"> + <h3 class="paneltitle"> + <span id="entity-{{ component.field_name }}"></span> + {% if not component.hide_input_title %} + <h3 class="detailed-input-group__title">{{ component.field_data.title }}</h3> + {% endif %} + {% if not component.hide_input_details %} + <p class="detailed-input-group__description">{{ component.description }}</p> + {% endif %} + </h3> </div> <div class="col-sm-10"> <span id="entity-{{ component.field_name }}"> - <a href="{{ component.value }}">{{ component.value }}</a> + <a target=_blank rel="noopener" href="{{ component.value }}">{{ component.value }}</a> </span> </div> </div> diff --git a/CodeListLibrary_project/cll/templates/components/details/outputs/tagbox.html b/CodeListLibrary_project/cll/templates/components/details/outputs/tagbox.html index 10f993b0e..3cf1107d3 100644 --- a/CodeListLibrary_project/cll/templates/components/details/outputs/tagbox.html +++ b/CodeListLibrary_project/cll/templates/components/details/outputs/tagbox.html @@ -1,4 +1,5 @@ {% load entity_renderer %} + <div class="detailed-input-group fill"> <div class="box-container"> <div class="row"> @@ -7,7 +8,13 @@ <div class="col-sm-10 display-flex flex-direction-row flex-wrap-wrap gap-sm"> {% if component.value|length %} {% for c in component.value %} - <span class="badge wrapped-tspan">{{ c.name }}</span> + {% if component.field_data.target %} + <div class="meta-chip meta-chip-bubble-accent"> + <a class="meta-chip__name" target=_blank href="{% url 'api:'|add:component.field_data.target c.value %}">{{ c.name }}</a> + </div> + {% else %} + <span class="badge wrapped-tspan">{{ c.name }}</span> + {% endif %} {% endfor %} {% else %} <span class="card-no-data wrapped-tspan">No data</span> diff --git a/CodeListLibrary_project/cll/templates/components/details/outputs/var_selector.html b/CodeListLibrary_project/cll/templates/components/details/outputs/var_selector.html new file mode 100644 index 000000000..5b7b0932f --- /dev/null +++ b/CodeListLibrary_project/cll/templates/components/details/outputs/var_selector.html @@ -0,0 +1,66 @@ +{% load entity_renderer %} + +{% if not component.hide_if_empty or component.value %} + <div class="detailed-input-group fill"> + <div class="col-sm-12 shadow-frame"> + <h3 class="paneltitle"> + <span id="entity-{{ component.field_name }}"></span> + {% if not component.hide_input_title %} + <h3 class="detailed-input-group__title">{{ component.field_data.title }}</h3> + {% endif %} + {% if not component.hide_input_details %} + <p class="detailed-input-group__description">{{ component.description }}</p> + {% endif %} + </h3> + </div> + + <section class="item-display"> + {% if not component.value %} + <section class="item-display__none"> + <p class="item-display__none-label"> + No data. + </p> + </section> + {% else %} + <section class="item-display__list slim-scrollbar"> + {% for item in component.value|dictsort:"name" %} + <div + id="{{ component.field_name }}-{{ forloop.counter }}-root" + class="accordion" + data-class="checkbox" + data-field="{{ item.name }}" + role="collapsible" + tabindex="0" + aria-controls="{{ component.field_name }}-{{ forloop.counter }}" + aria-label="Toggle Description: {{ item.name }}" + tooltip="Toggle Description" + direction="left-inwards" + > + <input class="accordion__input" id="{{ component.field_name }}-{{ forloop.counter }}" name="{{ component.field_name }}-{{ forloop.counter }}" type="checkbox" /> + <label class="accordion__label" for="{{ component.field_name }}-{{ forloop.counter }}" aria-describedby="{{ component.field_name }}-{{ forloop.counter }}-root"> + <div class="item-display__item"> + <div class="item-display__text item-display__text--fill-w"> + <strong>{{ item.name }}</strong> (<em>{{ item.type }}</em>) + </div> + <div class="item-display__value"> + {{ item.value }} + </div> + </div> + </label> + <article class="accordion__container"> + <div class="item-display__desc-indent"> + <h4>Description:</h4> + {% if item.description and item.description|length %} + <p>{{ item.description }}</p> + {% else %} + <p><i>No description provided.</i></p> + {% endif %} + </div> + </article> + </div> + {% endfor %} + </section> + {% endif %} + </section> + </div> +{% endif %} 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 f3ccd8b03..8db693ff6 100644 --- a/CodeListLibrary_project/cll/templates/components/details/outputs/version_history.html +++ b/CodeListLibrary_project/cll/templates/components/details/outputs/version_history.html @@ -1,4 +1,6 @@ {% load cl_extras %} +{% load entity_renderer %} + <!-- ---------------------------------------------------------------------- The table of history entries. @@ -6,6 +8,7 @@ ---------------------------------------------------------------------- --> +{% get_brand_map_rules as brand_mapping %} <div class="col-sm-12 no-print constrained-version-history" id="history-section"> {% comment %} <h3 class="paneltitle" > @@ -65,7 +68,7 @@ <h3 class="paneltitle" > </td> {% endif %} <td> - <a href="{% url 'entity_history_detail' pk=h.id history_id=h.history_id %}" title="Visit Phenotype: {{ h.name }}"> + <a target=_blank rel="noopener" href="{% url 'entity_history_detail' pk=h.id history_id=h.history_id %}" title="Visit {{ brand_mapping.phenotype }}: {{ h.name }}"> {% if h.name_highlighted|length %} {{ h.name_highlighted|striptags }} {% else %} diff --git a/CodeListLibrary_project/cll/templates/components/details/section/section_start.html b/CodeListLibrary_project/cll/templates/components/details/section/section_start.html index 07d028034..d3283b9b8 100644 --- a/CodeListLibrary_project/cll/templates/components/details/section/section_start.html +++ b/CodeListLibrary_project/cll/templates/components/details/section/section_start.html @@ -1,5 +1,5 @@ {% load entity_renderer %} -<li class="phenotype-progress__item" id="{{ section.title|trimmed|escape }}-progress"> +<li class="phenotype-progress__item" id="{{ section.title|shrink_underscore|escape }}-progress"> <h2 class="phenotype-progress__item-title">{{ section.title }}</h2> {% if not section.hide_description %} <p class="phenotype-progress__item-description">{{ section.description }}</p> diff --git a/CodeListLibrary_project/cll/templates/components/forms/archive.html b/CodeListLibrary_project/cll/templates/components/forms/archive.html index cf4b31914..899efbad2 100644 --- a/CodeListLibrary_project/cll/templates/components/forms/archive.html +++ b/CodeListLibrary_project/cll/templates/components/forms/archive.html @@ -1,17 +1,21 @@ +{% load entity_renderer %} + +{% get_brand_map_rules as brand_mapping %} <form method="post" id="archive-form-area" class="base-form"> {% csrf_token %} {{ form.entity_id }} <div class="detailed-input-group fill"> <h3 class="detailed-input-group__title"> - Please explain why you are deleting this Phenotype + Please explain why you are deleting this {{ brand_mapping.phenotype }} <span class="detailed-input-group__mandatory">*</span> </h3> - <p class="detailed-input-group__description">This will only be visible to the Concept Library team</p> + {% get_brand_base_title request.BRAND_OBJECT as base_page_title %} + <p class="detailed-input-group__description">This will only be visible to the {{ base_page_title }} team</p> {{ form.comments }} </div> <div class="detailed-input-group fill"> <h3 class="detailed-input-group__title"> - Please re-enter the Phenotype ID to confirm deletion + Please re-enter the {{ brand_mapping.phenotype }} ID to confirm deletion <span class="detailed-input-group__mandatory">*</span> </h3> <p class="detailed-input-group__description">e.g. if you are deleting PH000, please enter 'PH000'</p> diff --git a/CodeListLibrary_project/cll/templates/components/navigation/dropdown_profile_item.html b/CodeListLibrary_project/cll/templates/components/navigation/dropdown_profile_item.html index 4bc0f2660..8507f3f6f 100644 --- a/CodeListLibrary_project/cll/templates/components/navigation/dropdown_profile_item.html +++ b/CodeListLibrary_project/cll/templates/components/navigation/dropdown_profile_item.html @@ -1,13 +1,20 @@ -{% load static %} {% load i18n %} -{% load cl_extras %} +{% load static %} +{% load entity_renderer %} {% block content %} -<li class="content-container"> - <a href="javascript:void(null);" value="{{currentBrand}}" class="item-dropdown userBrand" > - <div class="item-dropdown__icon"><img loading="lazy" alt="{{currentBrand}} Logo" src="{{image}}" /></div> - - <div class="item-dropdown__title">{{title}}</div> - </a> -</li> + <li class="content-container"> + <a + class="item-dropdown userBrand" + value="{{ currentBrand }}" + > + {% if rules %} + {% to_json_script rules name="brand-mapping" %} + {% endif %} + <div class="item-dropdown__icon"> + <img loading="lazy" alt="{{ currentBrand }} Logo" src="{{ image }}" /> + </div> + <div class="item-dropdown__title">{{ title }}</div> + </a> + </li> {% endblock content %} diff --git a/CodeListLibrary_project/cll/templates/components/navigation/search_navigation.html b/CodeListLibrary_project/cll/templates/components/navigation/search_navigation.html index 0874a019b..befd1e853 100644 --- a/CodeListLibrary_project/cll/templates/components/navigation/search_navigation.html +++ b/CodeListLibrary_project/cll/templates/components/navigation/search_navigation.html @@ -1,11 +1,13 @@ -{% load static %} {% load i18n %} +{% load static %} {% load cl_extras %} +{% load entity_renderer %} {% block content %} -<form action="{% url 'search_phenotypes' %}" class="search-navigation__search" method="GET"> +{% get_brand_map_rules as brand_mapping %} +<form action="{% url 'search_entities' %}" class="search-navigation__search" method="GET"> <input - aria-label="Search Phenotypes" + aria-label="Search {{ brand_mapping.phenotype }}s" class="search-navigation__search-input" id="data-search" minlength="3" @@ -20,6 +22,4 @@ value="1" ></button> </form> - - {% endblock content %} - \ No newline at end of file +{% endblock content %} diff --git a/CodeListLibrary_project/cll/templates/components/publish_request/publish_button.html b/CodeListLibrary_project/cll/templates/components/publish_request/publish_button.html index ed7c681ce..ca850ec03 100644 --- a/CodeListLibrary_project/cll/templates/components/publish_request/publish_button.html +++ b/CodeListLibrary_project/cll/templates/components/publish_request/publish_button.html @@ -1,15 +1,17 @@ {% if not pub_btn_hidden %} <button - role="link" - class="{{class_modal}}" - title="{{title}}" - + class="phenotype-creation__action-btn {{class_modal}}" + title="{{ title }}" + aria-label="{% if ariaLabel %}{{ ariaLabel }}{% else %}{{ title }}{% endif %}" + role="button" {%if disabled%} - disabled + disabled + aria-disabled="true" {%endif%} id="publish-btn" > - {{ Button_type }} + <span class="as-icon phenotype-creation--icn" data-icon="" aria-hidden="true"></span> + <span>{{ Button_type }}</span> </button> {% include 'clinicalcode/generic_entity/publish/publish.html' %} diff --git a/CodeListLibrary_project/cll/templates/components/search/cards/clinical.html b/CodeListLibrary_project/cll/templates/components/search/cards/clinical.html index bee19b826..fcbaeb14e 100644 --- a/CodeListLibrary_project/cll/templates/components/search/cards/clinical.html +++ b/CodeListLibrary_project/cll/templates/components/search/cards/clinical.html @@ -23,9 +23,14 @@ <h3 class="entity-card__title">{{ entity.id }} - {{ entity.name }}</h3> </div> <div class="entity-card__snippet"> <div class="entity-card__snippet-metadata"> - <span class="entity-card__snippet-metadata-type">{% render_field_value entity layout "type" %}</span> - <span class="entity-card__snippet-metadata-divider"></span> - <span class="entity-card__snippet-metadata-date">Last updated {{ entity.created|stylise_date }}</span> + {% render_field_value entity layout "type" None as type_value %} + {% if not type_value %} + <span class="entity-card__snippet-metadata-date">Last updated {{ entity.created|stylise_date }}</span> + {% else %} + <span class="entity-card__snippet-metadata-type">{{ type_value }}</span> + <span class="entity-card__snippet-metadata-divider"></span> + <span class="entity-card__snippet-metadata-date">Last updated {{ entity.created|stylise_date }}</span> + {% endif %} </div> <div class="entity-card__snippet-tags"> <div class="entity-card__snippet-tags-group"> diff --git a/CodeListLibrary_project/cll/templates/components/search/results.html b/CodeListLibrary_project/cll/templates/components/search/results.html index be84ad752..29c3a0d96 100644 --- a/CodeListLibrary_project/cll/templates/components/search/results.html +++ b/CodeListLibrary_project/cll/templates/components/search/results.html @@ -1,4 +1,5 @@ {% load entity_renderer %} + <div class="entity-search-results__header"> <div class="entity-search-results__header-results" id="entity-search-response-header"> <h4>Results</h4> @@ -28,4 +29,4 @@ <h4>Results</h4> <div class="entity-search-results__container" id="entity-search-response-content"> {% render_entity_cards %} {% endrender_entity_cards %} -</div> \ No newline at end of file +</div> diff --git a/CodeListLibrary_project/cll/templates/components/search/search_banner.html b/CodeListLibrary_project/cll/templates/components/search/search_banner.html index 0823f638f..f1577c7b1 100644 --- a/CodeListLibrary_project/cll/templates/components/search/search_banner.html +++ b/CodeListLibrary_project/cll/templates/components/search/search_banner.html @@ -1,54 +1,71 @@ {% load entity_renderer %} + +{% get_brand_map_rules as brand_mapping %} +{% get_brand_base_title request.BRAND_OBJECT as base_page_title %} <header class="main-header search-banner"> <div class="main-header__inner-container main-header__inner-container--constrained main-header__inner-container--centred"> <div class="search-banner__header"> - <h2 class="search-banner__title">Phenotypes</h2> + <h2 class="search-banner__title">{{ brand_mapping.phenotype }}s</h2> </div> <div class="search-banner__container"> <fieldset class="search-container" data-controller="filter" data-class="searchbar" data-field="search"> <input type="text" placeholder="Search..." value="{{ search_query }}" id="searchbar" class="search-container__field washed-outline" data-class="searchbar" data-field="search" - aria-label="Search Phenotypes..."/> - <button class="search-container__icon" aria-label="Search Phenotypes" id="searchbar-icon-btn" tabindex="0"> + aria-label="Search {{ brand_mapping.phenotype }}s..."/> + <button class="search-container__icon" aria-label="Search {{ brand_mapping.phenotype }}s" id="searchbar-icon-btn" tabindex="0"> </button> </fieldset> </div> <div class="search-banner__cards"> <div class="hstack-cards-banner hstack-cards-banner-justify-content-space-evenly slim-scrollbar"> {% url 'my_collection' as collections_url %} - <article class="referral-card referral-card referral-card-bannerised referral-card-fill-area bright-accent referral-card-shadow referral-card-border-radius" - data-target="{{ collections_url }}" onclick="redirectToTarget(this);" - tabindex="0" aria-label="My Collection"> + <article + class="referral-card referral-card referral-card-bannerised referral-card-fill-area bright-accent referral-card-shadow referral-card-border-radius" + data-target="{{ collections_url }}" + onclick="redirectToTarget(this, event);" + tabindex="0" + role="button" + aria-label="My Collection"> <div class="referral-card__header"> - <a class="referral-card__title" href="{{ collections_url }}">My Collection <span class="referral-card__title-icon"></span></a> + <p class="referral-card__title" href="{{ collections_url }}">My Collection <span class="referral-card__title-icon"></span></p> </div> <div class="referral-card__body"> <p>View content owned by or shared with you.</p> </div> </article> - {% url 'create_phenotype' as create_url %} - <article class="referral-card referral-card-bannerised referral-card-fill-area bright-accent referral-card-shadow referral-card-border-radius" - data-target="{{ create_url }}" onclick="redirectToTarget(this);" - tabindex="0" aria-label="Create a Phenotype"> - - <div class="referral-card__header"> - <a class="referral-card__title" href="{{ create_url }}">Create a Phenotype <span class="referral-card__title-icon"></span></a> - </div> - <div class="referral-card__body"> - <p>Start here to contribute to the Library.</p> - </div> - </article> + {% if USER_CREATE_CONTEXT %} + {% url 'create_phenotype' as create_url %} + <article + class="referral-card referral-card-bannerised referral-card-fill-area bright-accent referral-card-shadow referral-card-border-radius" + data-target="{{ create_url }}" + onclick="redirectToTarget(this, event);" + tabindex="0" + role="button" + aria-label="Create a {{ brand_mapping.phenotype }}"> + + <div class="referral-card__header"> + <p class="referral-card__title" href="{{ create_url }}">Create a {{ brand_mapping.phenotype }} <span class="referral-card__title-icon"></span></p> + </div> + <div class="referral-card__body"> + <p>Start here to contribute to the {{ base_page_title }}.</p> + </div> + </article> + {% endif %} {% if user|is_member:"Moderators" or user.is_superuser %} {% url 'moderation_page' as moderator_url %} - <article class="referral-card referral-card-bannerised referral-card-fill-area bright-accent referral-card-shadow referral-card-border-radius" - data-target="{{ moderator_url }}" onclick="redirectToTarget(this);" - tabindex="0" aria-label="View Moderation"> + <article + class="referral-card referral-card-bannerised referral-card-fill-area bright-accent referral-card-shadow referral-card-border-radius" + data-target="{{ moderator_url }}" + onclick="redirectToTarget(this, event);" + tabindex="0" + role="button" + aria-label="View Moderation"> <div class="referral-card__header"> - <a class="referral-card__title" href="{{ moderator_url }}">View Moderation <span class="referral-card__title-icon"></span></a> + <p class="referral-card__title" href="{{ moderator_url }}">View Moderation <span class="referral-card__title-icon"></span></a> </div> <div class="referral-card__body"> <p>Review content before publication.</p> @@ -56,15 +73,19 @@ <h2 class="search-banner__title">Phenotypes</h2> </article> {% else %} {% url 'reference_data' as reference_url %} - <article class="referral-card referral-card-bannerised referral-card-fill-area bright-accent referral-card-shadow referral-card-border-radius" - data-target="{{ reference_url }}" onclick="redirectToTarget(this);" - tabindex="0" aria-label="Reference Data"> + <article + class="referral-card referral-card-bannerised referral-card-fill-area bright-accent referral-card-shadow referral-card-border-radius" + data-target="{{ reference_url }}" + onclick="redirectToTarget(this, event);" + tabindex="0" + role="button" + aria-label="Reference Data"> <div class="referral-card__header"> - <a class="referral-card__title" href="{{ reference_url }}">Reference Data <span class="referral-card__title-icon"></span></a> + <p class="referral-card__title" href="{{ reference_url }}">Reference Data <span class="referral-card__title-icon"></span></p> </div> <div class="referral-card__body"> - <p>Look up values for phenotype fields.</p> + <p>Look up values for {{ brand_mapping.phenotype }} fields.</p> </div> </article> {% endif %} diff --git a/CodeListLibrary_project/cll/templates/custom-msg.html b/CodeListLibrary_project/cll/templates/custom-msg.html index a3daf2081..40c43fb51 100644 --- a/CodeListLibrary_project/cll/templates/custom-msg.html +++ b/CodeListLibrary_project/cll/templates/custom-msg.html @@ -1,14 +1,17 @@ {% extends "base.html" %} + {% load static %} +{% load entity_renderer %} {% block title %}| Page Unavailable{% endblock title %} {% block container %} + {% get_brand_base_title request.BRAND_OBJECT as base_page_title %} <header class="main-header search-banner"> <div class="main-header__inner-container main-header__inner-container--constrained main-header__inner-container--centred"> <div class="search-banner__header search-banner__header--pad-bottom-2"> - <h2 class="search-banner__title">Concept Library</h2> + <h2 class="search-banner__title">{{ base_page_title }}</h2> </div> </div> </header> diff --git a/CodeListLibrary_project/cll/templates/drf-yasg/swagger-ui.html b/CodeListLibrary_project/cll/templates/drf-yasg/swagger-ui.html index c65026fcd..c8a937909 100644 --- a/CodeListLibrary_project/cll/templates/drf-yasg/swagger-ui.html +++ b/CodeListLibrary_project/cll/templates/drf-yasg/swagger-ui.html @@ -3,6 +3,7 @@ {% load static %} {% load compress %} {% load sass_tags %} +{% load entity_renderer %} {% block head %} <meta charset="utf-8" /> @@ -13,6 +14,8 @@ {% block title %}| API Documentation {% endblock title %} {% block container %} + {% get_brand_base_title request.BRAND_OBJECT as base_page_title %} + {% compress css %} <link rel="stylesheet" href="{% sass_src 'scss/pages/select.scss' %}" type="text/css" /> <link rel="stylesheet" href="{% sass_src 'scss/pages/swagger.scss' %}" type="text/css" /> @@ -21,12 +24,12 @@ <div class="api-about"> <div class="api-about__section"> <p> - The Concept Library team maintain an R and Python package for easy integration into existing research projects, you can find more information here: + The {{ base_page_title }} team maintain an R and Python package for easy integration into existing research projects, you can find more information here: </p> <ul> <li><a href="https://github.com/SwanseaUniversityMedical/concept-library-client-r">R package GitHub repository</a></li> <li><a href="https://github.com/SwanseaUniversityMedical/pyconceptlibraryclient">Python package GitHub repository</a></li> - <li><a href="https://github.com/SwanseaUniversityMedical/concept-library/wiki">Concept Library GitHub wiki</a></li> + <li><a href="https://github.com/SwanseaUniversityMedical/concept-library/wiki">{{ base_page_title }} GitHub wiki</a></li> </ul> </div> <div class="api-about__section"> diff --git a/CodeListLibrary_project/cll/templates/fmt-error.html b/CodeListLibrary_project/cll/templates/fmt-error.html index 6d5813dd0..688d09225 100644 --- a/CodeListLibrary_project/cll/templates/fmt-error.html +++ b/CodeListLibrary_project/cll/templates/fmt-error.html @@ -1,14 +1,17 @@ {% extends "base.html" %} + {% load static %} +{% load entity_renderer %} {% block title %}|{% if errheader and errheader.title and errheader.title|length %} {{ errheader.title }} {% else %} Error! {% endif %}{% endblock title %} {% block container %} + {% get_brand_base_title request.BRAND_OBJECT as base_page_title %} <header class="main-header search-banner"> <div class="main-header__inner-container main-header__inner-container--constrained main-header__inner-container--centred"> <div class="search-banner__header search-banner__header--pad-bottom-2"> - <h2 class="search-banner__title">Concept Library</h2> + <h2 class="search-banner__title">{{ base_page_title }}</h2> </div> </div> </header> @@ -35,7 +38,7 @@ <h2> <p{% if message.tags %} class="{{ message.tags }}"{% endif %}>{{ message }}</p> {% endfor %} {% else %} - <p>W're sorry but we couldn't process this request</p> + <p>We're sorry but we couldn't process this request</p> {% endif %} <p>Follow this link to get back to the <a href="{% url 'concept_library_home' %}">homepage</a>.</p> <p>Or, click <a href="javascript:history.back()">here</a> to go back.</p> diff --git a/CodeListLibrary_project/cll/templates/registration/change_form.html b/CodeListLibrary_project/cll/templates/registration/change_form.html new file mode 100644 index 000000000..d5d4be165 --- /dev/null +++ b/CodeListLibrary_project/cll/templates/registration/change_form.html @@ -0,0 +1,138 @@ +{% extends "base.html" %} + +{% load static %} +{% load compress %} +{% load sass_tags %} +{% load cl_extras %} +{% load breadcrumbs %} +{% load entity_renderer %} + +{% block title %}| {% if form.errors %}Error: {% endif %}Change Password{% endblock %} + +{% block container %} + <!-- Page Stylesheets --> + {% compress css %} + <link rel="stylesheet" href="{% sass_src 'scss/pages/login.scss' %}" type="text/css" /> + {% endcompress %} + + <!-- Main --> + <header class="main-header"> + </header> + + <main class="main-content"> + <div class="main-content__inner-container main-content__inner-container--constrained main-content__inner-container--centred"> + <div class="account-container"> + <div class="account-container__info-container"> + <div class="account-container__info-container__content"> + <div class="account-container__info-container__section"> + <h2>Terms and Conditions</h2> + <p> + Please explore our terms and conditions + <a target=_blank rel="noopener" href="{% url 'terms' %}"> + here + </a> + prior to accessing the site. + </p> + </div> + <div class="account-container__info-container__section"> + <h2>Privacy & Cookie Policy</h2> + <p> + Please explore our privacy and cookie policy + <a target=_blank rel="noopener" href="{% url 'privacy_and_cookie_policy' %}"> + here + </a> + prior to accessing the site. + </p> + </div> + </div> + </div> + <div class="account-container__form-container"> + <h1>Change your password</h1> + <form method="post"> + <div class="detailed-input-group constrained"> + <h3 class="detailed-input-group__title"> + Current Password: + <span class="detailed-input-group__mandatory">*</span> + </h3> + {% if form.old_password.errors %} + <div class="detailed-input-group__error" aria-live="true"> + {{ form.old_password.errors }} + </div> + {% endif %} + <p class="detailed-input-group__description account-container--text-wrappable"> + Please confirm your old password for security purposes. + </p> + <input + id="id_old_password" + name="old_password" + type="password" + class="text-input" + autocomplete="current-password" + autofocus + required + /> + </div> + + <div class="detailed-input-group constrained"> + <h3 class="detailed-input-group__title"> + New Password: + <span class="detailed-input-group__mandatory">*</span> + </h3> + {% if form.new_password1.errors %} + <div class="detailed-input-group__error" aria-live="true"> + {{ form.new_password1.errors }} + </div> + {% endif %} + <p class="detailed-input-group__description account-container--text-wrappable"> + Please enter your new password. + </p> + <input + id="id_new_password1" + name="new_password1" + type="password" + class="text-input" + autocomplete="new-password" + minlength="8" + required + /> + </div> + + <div class="detailed-input-group constrained"> + <h3 class="detailed-input-group__title"> + Confirm New Password: + <span class="detailed-input-group__mandatory">*</span> + </h3> + {% if form.new_password2.errors %} + <div class="detailed-input-group__error" aria-live="true"> + {{ form.new_password2.errors }} + </div> + {% endif %} + <p class="detailed-input-group__description account-container--text-wrappable"> + Please confirm your new password. + </p> + <input + id="id_new_password2" + name="new_password2" + type="password" + class="text-input" + autocomplete="new-password" + minlength="8" + required + /> + </div> + + <div class="account-container__form-container__submit-container"> + {% csrf_token %} + <input + id="save-changes" + class="primary-btn text-accent-darkest bold tertiary-accent sweep-left" + type="submit" + aria-label="Confirm" + value="Confirm"> + </div> + </form> + </div> + </div> + </div> + </main> +{% endblock container %} diff --git a/CodeListLibrary_project/cll/templates/registration/logged_out.html b/CodeListLibrary_project/cll/templates/registration/logged_out.html index 48c605893..80be9f265 100644 --- a/CodeListLibrary_project/cll/templates/registration/logged_out.html +++ b/CodeListLibrary_project/cll/templates/registration/logged_out.html @@ -10,4 +10,4 @@ <a href="{% url 'login' %}">Click here to login again.</a> -{% endblock container %} \ No newline at end of file +{% endblock container %} diff --git a/CodeListLibrary_project/cll/templates/registration/login.html b/CodeListLibrary_project/cll/templates/registration/login.html index 7dd04f104..493a6e4c8 100644 --- a/CodeListLibrary_project/cll/templates/registration/login.html +++ b/CodeListLibrary_project/cll/templates/registration/login.html @@ -21,34 +21,34 @@ <main class="main-content"> <div class="main-content__inner-container main-content__inner-container--constrained main-content__inner-container--centred"> - <div class="login-container"> - <div class="login-container__info-container"> - <div class="login-container__info-container__content"> - <div class="login-container__info-container__section"> + <div class="account-container"> + <div class="account-container__info-container"> + <div class="account-container__info-container__content"> + <div class="account-container__info-container__section"> <h2>Request an Account</h2> <p> We're registering users by request at the moment. If you would like to request an account, please use the - <a href="{% url 'contact_us' %}"> + <a target=_blank rel="noopener" href="{% url 'contact_us' %}"> contact form. </a> </p> </div> - <div class="login-container__info-container__section"> + <div class="account-container__info-container__section"> <h2>Terms and Conditions</h2> <p> Please explore our terms and conditions - <a href="{% url 'terms' %}"> + <a target=_blank rel="noopener" href="{% url 'terms' %}"> here </a> prior to accessing the site. </p> </div> - <div class="login-container__info-container__section"> + <div class="account-container__info-container__section"> <h2>Privacy & Cookie Policy</h2> <p> Please explore our privacy and cookie policy - <a href="{% url 'privacy_and_cookie_policy' %}"> + <a target=_blank rel="noopener" href="{% url 'privacy_and_cookie_policy' %}"> here </a> prior to accessing the site. @@ -56,7 +56,7 @@ <h2>Privacy & Cookie Policy</h2> </div> </div> </div> - <div class="login-container__form-container"> + <div class="account-container__form-container"> <h1>Login</h1> <form method="post" action="{% url 'login' %}"> <input type="hidden" name="next" value="{{ next }}" /> @@ -90,7 +90,7 @@ <h3 class="detailed-input-group__title"> required /> </div> - <div class="login-container__form-container__options-container"> + <div class="account-container__form-container__options-container"> <div class="checkbox-item-container"> <input class="checkbox-item" id="login-remember" @@ -111,7 +111,7 @@ <h3 class="detailed-input-group__title"> {% endif %} </div> - <div class="login-container__form-container__submit-container"> + <div class="account-container__form-container__submit-container"> {% csrf_token %} <button class="primary-btn text-accent-darkest bold tertiary-accent sweep-left" id="save-changes" @@ -124,5 +124,5 @@ <h3 class="detailed-input-group__title"> </div> </div> </div> - + </main> {% endblock container %} diff --git a/CodeListLibrary_project/cll/templates/registration/management_result.html b/CodeListLibrary_project/cll/templates/registration/management_result.html new file mode 100644 index 000000000..9ffef137f --- /dev/null +++ b/CodeListLibrary_project/cll/templates/registration/management_result.html @@ -0,0 +1,54 @@ +{% extends "base.html" %} + +{% load static %} +{% load entity_renderer %} + +{% block title %}| {{ template_title|default:'Account Management' }}{% endblock title %} + +{% block container %} + {% with hdr_trg=template_title|default:'Account Management' %} + {% get_brand_base_title request.BRAND_OBJECT as base_page_title %} + <header class="main-header search-banner"> + <div + class="main-header__inner-container main-header__inner-container--constrained main-header__inner-container--centred"> + <div class="search-banner__header search-banner__header--pad-bottom-2"> + <h2 class="search-banner__title">{{ base_page_title }}</h2> + </div> + </div> + </header> + <main class="main-content main-content--inner-padding"> + <div + class="main-content__inner-container main-content__inner-container--constrained main-content__inner-container--centred"> + <h2> + {% if template_header %} + {{ template_header }} + {% else %} + {{ hdr_trg }} + {% endif %} + </h2> + + {% if template_target %} + {% include template_target %} + {% elif template_message %} + {{ template_message|safe }} + {% endif %} + + {% if template_incl_redirect %} + <hr/> + + {% if template_prompt_signin %} + <p> + Click <a href="{% url 'login' %}">here</a> to sign in again. Alternatively, you can: + </p> + {% else %} + <p>You can:</p> + {% endif %} + <ul> + <li>Follow this link to get back to the <a href="{% url 'concept_library_home' %}">homepage</a>;</li> + <li>Or, click <a href="javascript:history.back()">here</a> to go back.</li> + </ul> + {% endif %} + </div> + </main> + {% endwith %} +{% endblock container %} diff --git a/CodeListLibrary_project/cll/templates/registration/messages/change_done.html b/CodeListLibrary_project/cll/templates/registration/messages/change_done.html new file mode 100644 index 000000000..e874679ce --- /dev/null +++ b/CodeListLibrary_project/cll/templates/registration/messages/change_done.html @@ -0,0 +1,2 @@ +<p>You have successfully changed your password.</p> +<p>Follow this link to get back to the <a href="{% url 'concept_library_home' %}">homepage</a>.</p> diff --git a/CodeListLibrary_project/cll/templates/registration/messages/reset_done.html b/CodeListLibrary_project/cll/templates/registration/messages/reset_done.html new file mode 100644 index 000000000..0a8b76d08 --- /dev/null +++ b/CodeListLibrary_project/cll/templates/registration/messages/reset_done.html @@ -0,0 +1,7 @@ +<p>Your password has been successfully reset and are now signed in!</p> +<p>Follow this link to get back to the <a href="{% url 'concept_library_home' %}">homepage</a>.</p> +<p> + If you continue to have trouble then please do + <a href="{% url 'contact_us' %}">contact us</a> + for assistance. +</p> diff --git a/CodeListLibrary_project/cll/templates/registration/messages/reset_requested.html b/CodeListLibrary_project/cll/templates/registration/messages/reset_requested.html new file mode 100644 index 000000000..cf5d44f0f --- /dev/null +++ b/CodeListLibrary_project/cll/templates/registration/messages/reset_requested.html @@ -0,0 +1,11 @@ +<p>We've emailed you instructions for setting your password, if an account exists with the email you entered. You should receive them shortly.</p> +<p>Please note:</p> +<ul> + <li>The e-mail could end up in your spam inbox so please make sure to check there;</li> + <li>And that it might take up to 30 minutes for it to arrive in your inbox.</li> +</ul> +<p> + If you still haven't received the reset e-mail after following the above instructions then please do + <a href="{% url 'contact_us' %}">contact us</a> + for assistance. +</p> diff --git a/CodeListLibrary_project/cll/templates/registration/password_reset_complete.html b/CodeListLibrary_project/cll/templates/registration/password_reset_complete.html deleted file mode 100644 index c1ad49aea..000000000 --- a/CodeListLibrary_project/cll/templates/registration/password_reset_complete.html +++ /dev/null @@ -1,10 +0,0 @@ -{% extends "base.html" %} - -{% load static %} - -{% block container %} - <h1>The password has been changed!</h1> - <p> - <a href="{% url 'login' %}">log in again?</a> - </p> -{% endblock container %} diff --git a/CodeListLibrary_project/cll/templates/registration/password_reset_confirm.html b/CodeListLibrary_project/cll/templates/registration/password_reset_confirm.html deleted file mode 100644 index a6c537d36..000000000 --- a/CodeListLibrary_project/cll/templates/registration/password_reset_confirm.html +++ /dev/null @@ -1,47 +0,0 @@ -{% extends "base.html" %} - -{% load static %} - -{% block container %} - - {% if validlink %} - <p>Please enter (and confirm) your new password.</p> - - <form action="" method="post" class="form-horizontal"> - <div style="display:none"> - <input type="hidden" value="{{ crsf_token }}" name="csrfmiddlewaretoken"> - </div> - <div class="form-group {% if form.new_password1.errors %}has-error{% endif %}"> - <label for="{{ form.new_password1.id_for_label }}" class="col-sm-3 required">{{ form.new_password1.label }}</label> - <div class="col-sm-7"> - {{ form.new_password1 }} - {% if form.new_password1.errors %} - <span class="help-block"> - {% for error in form.new_password1.errors %}{{ error }}{% endfor %} - </span> - {% endif %} - </div> - </div> - <div class="form-group {% if form.new_password2.errors %}has-error{% endif %}"> - <label for="{{ form.new_password2.id_for_label }}" class="col-sm-3 required">{{ form.new_password2.label }}</label> - <div class="col-sm-7"> - {{ form.new_password2 }} - {% if form.new_password2.errors %} - <span class="help-block"> - {% for error in form.new_password2.errors %}{{ error }}{% endfor %} - </span> - {% endif %} - </div> - </div> - <div class="form-group"> - <div class="col-xs-offset-2 col-xs-10"> - <button class="btn btn-primary" id="change-password">Change my password</button> - </div> - </div> - </form> - {% else %} - <h1>Password reset failed</h1> - <p>The password reset link was invalid, possibly because it has already been used. Please request a new password reset.</p> - {% endif %} - -{% endblock container %} \ No newline at end of file diff --git a/CodeListLibrary_project/cll/templates/registration/password_reset_done.html b/CodeListLibrary_project/cll/templates/registration/password_reset_done.html deleted file mode 100644 index e12aecd64..000000000 --- a/CodeListLibrary_project/cll/templates/registration/password_reset_done.html +++ /dev/null @@ -1,9 +0,0 @@ -{% extends "base.html" %} - -{% load static %} - -{% block title %}Password reset done{% endblock title %} - -{% block container %} -<p>We've emailed you instructions for setting your password. If they haven't arrived in a few minutes, check your spam folder.</p> -{% endblock container %} \ No newline at end of file diff --git a/CodeListLibrary_project/cll/templates/registration/password_reset_email.html b/CodeListLibrary_project/cll/templates/registration/password_reset_email.html deleted file mode 100644 index b310e7d36..000000000 --- a/CodeListLibrary_project/cll/templates/registration/password_reset_email.html +++ /dev/null @@ -1,2 +0,0 @@ -Someone asked for password reset for email {{ email }}. Follow the link below: -{{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %} \ No newline at end of file diff --git a/CodeListLibrary_project/cll/templates/registration/password_reset_form.html b/CodeListLibrary_project/cll/templates/registration/password_reset_form.html deleted file mode 100644 index aadf78c5a..000000000 --- a/CodeListLibrary_project/cll/templates/registration/password_reset_form.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends "base.html" %} - -{% load static %} - -{% block title %}Password reset{% endblock title %} - -{% block container %} - -<form action="" method="post"> - {% csrf_token %} - - <p>{{ form.email }}</p> - <input type="submit" class="btn btn-default btn-lg" value="Reset password" /> - -</form> - -{% endblock container %} \ No newline at end of file diff --git a/CodeListLibrary_project/cll/templates/registration/request_reset.html b/CodeListLibrary_project/cll/templates/registration/request_reset.html new file mode 100644 index 000000000..387d58642 --- /dev/null +++ b/CodeListLibrary_project/cll/templates/registration/request_reset.html @@ -0,0 +1,99 @@ +{% extends "base.html" %} + +{% load static %} +{% load compress %} +{% load sass_tags %} +{% load cl_extras %} +{% load breadcrumbs %} +{% load entity_renderer %} + +{% block title %}| Password Reset {% endblock title %} + +{% block container %} + <!-- Page Stylesheets --> + {% compress css %} + <link rel="stylesheet" href="{% sass_src 'scss/pages/login.scss' %}" type="text/css" /> + {% endcompress %} + + <!-- Main --> + <header class="main-header"> + </header> + + <main class="main-content"> + <div class="main-content__inner-container main-content__inner-container--constrained main-content__inner-container--centred"> + <div class="account-container"> + <div class="account-container__info-container"> + <div class="account-container__info-container__content"> + <div class="account-container__info-container__section"> + <h2>Request an Account</h2> + <p> + We're registering users by request at the moment. If you would like to + request an account, please use the + <a target=_blank rel="noopener" href="{% url 'contact_us' %}"> + contact form. + </a> + </p> + </div> + <div class="account-container__info-container__section"> + <h2>Terms and Conditions</h2> + <p> + Please explore our terms and conditions + <a target=_blank rel="noopener" href="{% url 'terms' %}"> + here + </a> + prior to accessing the site. + </p> + </div> + <div class="account-container__info-container__section"> + <h2>Privacy & Cookie Policy</h2> + <p> + Please explore our privacy and cookie policy + <a target=_blank rel="noopener" href="{% url 'privacy_and_cookie_policy' %}"> + here + </a> + prior to accessing the site. + </p> + </div> + </div> + </div> + <div class="account-container__form-container"> + <h1>Password Reset</h1> + <form method="post" action="{% url 'password_reset' %}"> + <div class="detailed-input-group constrained"> + <h3 class="detailed-input-group__title"> + E-mail + <span class="detailed-input-group__mandatory">*</span> + </h3> + <p class="detailed-input-group__description account-container--text-wrappable"> + Please + <a target="_blank" href="{% url 'contact_us' %}">contact us</a> + for assistance if you are unable to recall the e-mail associated with your account. + </p> + <input + id="id_email" + class="text-input" + name="email" + type="email" + maxlength="254" + placeholder="your.account.email@email.com" + autocomplete="email" + autofocus + required + /> + </div> + + <div class="account-container__form-container__submit-container"> + {% csrf_token %} + <button id="save-changes" + class="primary-btn text-accent-darkest bold tertiary-accent sweep-left" + type="submit" + aria-label="Log in to account"> + Reset Password + </button> + </div> + </form> + </div> + </div> + </div> + </main> +{% endblock container %} diff --git a/CodeListLibrary_project/cll/templates/registration/reset_form.html b/CodeListLibrary_project/cll/templates/registration/reset_form.html new file mode 100644 index 000000000..681dfe35b --- /dev/null +++ b/CodeListLibrary_project/cll/templates/registration/reset_form.html @@ -0,0 +1,139 @@ +{% extends "base.html" %} + +{% load static %} +{% load compress %} +{% load sass_tags %} +{% load cl_extras %} +{% load breadcrumbs %} +{% load entity_renderer %} + +{% block title %}| {% if form.errors %}Error: {% endif %}Password Reset Confirmation{% endblock %} + +{% block container %} + <!-- Page Stylesheets --> + {% compress css %} + <link rel="stylesheet" href="{% sass_src 'scss/pages/login.scss' %}" type="text/css" /> + {% endcompress %} + + {% if validlink %} + <!-- Main --> + <header class="main-header"> + </header> + + <main class="main-content"> + <div class="main-content__inner-container main-content__inner-container--constrained main-content__inner-container--centred"> + <div class="account-container"> + <div class="account-container__info-container"> + <div class="account-container__info-container__content"> + <div class="account-container__info-container__section"> + <h2>Why did I get this link?</h2> + <p> + You may have received this link if you or someone else requested their password to be reset. Please ignore this form and the e-mail if you do not wish to change your password. + </p> + </div> + <div class="account-container__info-container__section"> + <h2>Terms and Conditions</h2> + <p> + Please explore our terms and conditions + <a target=_blank rel="noopener" href="{% url 'terms' %}"> + here + </a> + prior to accessing the site. + </p> + </div> + <div class="account-container__info-container__section"> + <h2>Privacy & Cookie Policy</h2> + <p> + Please explore our privacy and cookie policy + <a target=_blank rel="noopener" href="{% url 'privacy_and_cookie_policy' %}"> + here + </a> + prior to accessing the site. + </p> + </div> + </div> + </div> + <div class="account-container__form-container"> + <h1>Password Reset</h1> + <form method="post"> + <div class="detailed-input-group constrained"> + <h3 class="detailed-input-group__title"> + New Password: + <span class="detailed-input-group__mandatory">*</span> + </h3> + {% if form.new_password1.errors %} + <div class="detailed-input-group__error" aria-live="true"> + {{ form.new_password1.errors }} + </div> + {% endif %} + <input + id="id_new_password1" + name="new_password1" + type="password" + class="text-input" + autocomplete="new-password" + minlength="8" + autofocus + required + /> + </div> + + <div class="detailed-input-group constrained"> + <h3 class="detailed-input-group__title"> + Confirm New Password: + <span class="detailed-input-group__mandatory">*</span> + </h3> + {% if form.new_password2.errors %} + <div class="detailed-input-group__error" aria-live="true"> + {{ form.new_password2.errors }} + </div> + {% endif %} + <input + id="id_new_password2" + name="new_password2" + type="password" + class="text-input" + autocomplete="new-password" + minlength="8" + required + /> + </div> + + <div class="account-container__form-container__submit-container"> + {% csrf_token %} + <input + id="save-changes" + class="primary-btn text-accent-darkest bold tertiary-accent sweep-left" + type="submit" + aria-label="Change my password" + value="Change my password"> + </div> + </form> + </div> + </div> + </div> + </main> + {% else %} + {% get_brand_base_title request.BRAND_OBJECT as base_page_title %} + <header class="main-header search-banner"> + <div + class="main-header__inner-container main-header__inner-container--constrained main-header__inner-container--centred"> + <div class="search-banner__header search-banner__header--pad-bottom-2"> + <h2 class="search-banner__title">{{ base_page_title }}</h2> + </div> + </div> + </header> + <main class="main-content main-content--inner-padding"> + <div + class="main-content__inner-container main-content__inner-container--constrained main-content__inner-container--centred"> + <p>The password reset link was invalid, possibly because it has already been used. Please request a new password reset.</p> + <p>You can do so by:</p> + <ul> + <li>Requesting a new password reset e-mail <a href="{% url 'password_reset' %}" aria-label="Request a password reset">here</a>;</li> + <li>Or by following this link to get back to the <a href="{% url 'concept_library_home' %}">homepage</a>;</li> + <li>Or, clicking <a href="javascript:history.back()">here</a> to go back.</li> + </ul> + </div> + </main> + {% endif %} +{% endblock container %} diff --git a/CodeListLibrary_project/cll/urls.py b/CodeListLibrary_project/cll/urls.py index b10472add..29779faa7 100644 --- a/CodeListLibrary_project/cll/urls.py +++ b/CodeListLibrary_project/cll/urls.py @@ -19,35 +19,14 @@ """ +from django.db import connection from django.conf import settings -from django.urls import re_path as url from django.urls import include -from django.conf.urls.static import static +from django.urls import re_path as url from django.contrib import admin -from django.contrib.staticfiles.views import serve -from django.db import connection +from django.conf.urls.static import static from django.views.decorators.cache import cache_control - -from clinicalcode.views import View - -#-------------------------------------------------------------------- -# Utils -def db_table_exists(table_name): - return table_name.lower() in [x.lower() for x in connection.introspection.table_names()] - -#-------------------------------------------------------------------- -# Brands -current_brand = "" -current_brand = settings.CURRENT_BRAND -if settings.DEBUG: - print("main url file ...") - print("current_brand(settings.CURRENT_BRAND)=", current_brand) - -brands = [] -if db_table_exists("clinicalcode_brand"): - from clinicalcode.models import Brand - brands = Brand.objects.values_list('name', flat=True) - brands = [x.upper() for x in brands] +from django.contrib.staticfiles.views import serve #-------------------------------------------------------------------- # URLs @@ -60,24 +39,14 @@ def db_table_exists(table_name): if settings.DEBUG: urlpatterns += static(settings.STATIC_URL, view=cache_control(no_cache=True, must_revalidate=True)(serve)) -# Add django site authentication urls (for login, logout, password management) -# and enable login for all brands -for brand in brands: - urlpatterns += [ - # index for each brand - url(r'^' + brand + '', View.index, name='concept_library_home'), - - # login for each brand - url(r"^" + brand + "/account/", include('django.contrib.auth.urls')), - ] # Application URLs urlpatterns = [ # api url(r'^api/v1/', include(('clinicalcode.api.urls', 'cll'), namespace='api')), - # login - url(r'^account/', include('django.contrib.auth.urls')), + # account management + url(r'account/', include('clinicalcode.urls_account')), # app urls url(r'^', include('clinicalcode.urls')), diff --git a/CodeListLibrary_project/cll/urls_brand.py b/CodeListLibrary_project/cll/urls_brand.py index 5baa31cb7..0748ae731 100644 --- a/CodeListLibrary_project/cll/urls_brand.py +++ b/CodeListLibrary_project/cll/urls_brand.py @@ -18,34 +18,141 @@ 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) """ +from django.apps import apps from django.conf import settings from django.urls import re_path as url from django.urls import include -from django.conf.urls.static import static from django.contrib import admin -from django.contrib.staticfiles.views import serve +from django.core.cache import cache +from django.conf.urls.static import static +from django.views.generic.base import RedirectView from django.views.decorators.cache import cache_control -from django.views.generic import RedirectView +from django.contrib.staticfiles.views import serve + +import re +import logging + +from clinicalcode.views import (GenericEntity, Publish, Decline) #-------------------------------------------------------------------- -# Brands -current_brand = "" -current_brand = settings.CURRENT_BRAND + "/" -if settings.IS_HDRUK_EXT == "1": - current_brand = "" +# Const +"""Default URL transform reference""" +DEFAULT_TRGT = settings.BRAND_VAR_REFERENCE.get('default') -if settings.DEBUG: - print("BRANDING url file ...") - print("current_brand(settings.CURRENT_BRAND)=", current_brand) +"""Variable URL target(s): Route varies by Brand context""" +URL_VARIANTS = [ + # GenericEntity/Phenotype variants + ## Search + { 'route': r'%(base)s{phenotypes}/$', 'view': GenericEntity.EntitySearchView.as_view(), 'name': 'search_entities' }, + + ## Detail + { 'route': r'%(base)s{phenotypes}/(?P<pk>\w+)/$', 'view': RedirectView.as_view(pattern_name='entity_detail'), 'name': 'entity_detail_shortcut' }, + { 'route': r'%(base)s{phenotypes}/(?P<pk>\w+)/detail/$', 'view': GenericEntity.generic_entity_detail, 'name': 'entity_detail' }, + { 'route': r'%(base)s{phenotypes}/(?P<pk>\w+)/version/(?P<history_id>\d+)/detail/$', 'view': GenericEntity.generic_entity_detail, 'name': 'entity_history_detail' }, + + { 'route': r'%(base)s{phenotypes}/(?P<pk>\w+)/export/codes/$', 'view': GenericEntity.export_entity_codes_to_csv, 'name': 'export_entity_latest_version_codes_to_csv' }, + { 'route': r'%(base)s{phenotypes}/(?P<pk>\w+)/version/(?P<history_id>\d+)/export/codes/$', 'view': GenericEntity.export_entity_codes_to_csv, 'name': 'export_entity_version_codes_to_csv' }, + + ## Publication + { 'route': r'%(base)s{phenotypes}/(?P<pk>\w+)/(?P<history_id>\d+)/publish/$', 'view': Publish.Publish.as_view(), 'name': 'generic_entity_publish' }, + { 'route': r'%(base)s{phenotypes}/(?P<pk>\w+)/(?P<history_id>\d+)/decline/$', 'view': Decline.EntityDecline.as_view(), 'name': 'generic_entity_decline' }, + { 'route': r'%(base)s{phenotypes}/(?P<pk>\w+)/(?P<history_id>\d+)/submit/$', 'view': Publish.RequestPublish.as_view(), 'name': 'generic_entity_request_publish' }, +] + +#-------------------------------------------------------------------- +# Resolvers +"""Resulting URL configuration""" +urlpatterns = [] + +#-------------------------------------------------------------------- +# Utilities +def get_brand_ctx_transform(urls): + """ + Build the brand context replace Callable, used as a lambda in a `re.sub` operation + + Args: + urls (Dict[str, Any]): a (dict) containing the replace targets, keyed by the `{(\w+)}` match groups + + Returns: + A (Callable) to be used in a `re.sub` operation + """ + def replace(match): + """Replaces matched regex groups with the route target""" + m = match.group(1) + r = urls.get(m) + return r if isinstance(r, str) else m + + return replace + +def append_branded_urls(brand=None, variants=[], patterns=[]): + """ + Method decorator to raise a 403 if a view isn't accessed by a Brand Administrator + + Args: + brand (Any|None): optionally specify the assoc. Brand context; defaults to using the `DEFAULT_TRGT` if not specified + variants (List[Dict[str, Any]]): optionally specify the URL resolver variants; defaults to an empty list + patterns (List[URLResolver]): optionally specify the URL patterns, i.e. a list of URLResolvers, in which we will append the transformed routes; defaults to an empty list + + Returns: + The specified `patterns`, or (List[URLResolver]), updated in place + """ + try: + if brand is None: + trgt = 'default' + urls = DEFAULT_TRGT + base = '^' + else: + trgt = brand.name + urls = settings.BRAND_VAR_REFERENCE.get(trgt, None) + urls = urls.get('urls') if isinstance(urls, dict) and isinstance(urls.get('urls'), dict) else DEFAULT_TRGT.get('urls') + base = f'^{brand.name}/' + + if not isinstance(urls, dict): + logging.warning(f'Expected URL ref dict for Target<name: {trgt}> but got typeof "{type(urls)}"') + return patterns + + tx_ctx = get_brand_ctx_transform(urls) + for var in variants: + if not isinstance(var, dict): + logging.warning(f'Failed to process variant, expected dict but got "{type(var)}<{str(var)}>"') + continue + + name = var.get('name') + view = var.get('view') + route = var.get('route') + kwargs = var.get('kwargs') if isinstance(var.get('kwargs'), dict) else None + + if not isinstance(name, str) or view is None or not isinstance(route, str): + logging.warning(( + f'Failed to validate variant, expected members {{ "name": str, "view": Any, "route": str, "kwargs": Any|None }} but got:\n' + f' -> "name" as Value<type: {type(name)}, data: {str(name)}>\n' + f' -> "view" as Value<type: {type(view)}, data: {str(view)}>\n' + f' -> "route" as Value<type: {type(route)}, data: {str(route)}>\n' + )) + continue + + route = route % { 'base': base } + route = re.sub(r'{([^{}]+)}', tx_ctx, route) + patterns.append(url(route=route, view=view, kwargs=kwargs, name=name)) + except Exception as e: + logging.exception(f'Failed to create URL variants on Target<name: {trgt}> with err:\n\n{str(e)}') + + return patterns + +#-------------------------------------------------------------------- +# Brands +current_brand = f'{settings.CURRENT_BRAND}/' if isinstance(settings.CURRENT_BRAND, str) and settings.CURRENT_BRAND != '' else '' +if settings.IS_HDRUK_EXT == '1': + current_brand = '' #-------------------------------------------------------------------- # Urls -urlpatterns = [ +urlpatterns += [ # api url(r'^' + current_brand + 'api/v1/', include(('clinicalcode.api.urls', 'cll'), namespace='api')), - # login - url(r'^' + current_brand + 'account/', include('django.contrib.auth.urls')), + # account management + url(r'^' + current_brand + 'account/', include('clinicalcode.urls_account')), # app urls url(r'^' + current_brand + '', include('clinicalcode.urls')), @@ -63,3 +170,13 @@ urlpatterns += [ url(r'^' + current_brand + 'admin/', admin.site.urls), ] + +# Variant URL resolvers +try: + brands = apps.get_model(app_label='clinicalcode', model_name='Brand') + brands = brands.all_instances() + + target = next((x for x in brands if x.name == settings.CURRENT_BRAND), None) + append_branded_urls(brand=target, variants=URL_VARIANTS, patterns=urlpatterns) +except Exception as e: + logging.exception(f'Failed to create branded URL variants with err:\n\n{str(e)}') diff --git a/CodeListLibrary_project/dynamic_templates/OpenCodelists_phenotype.json b/CodeListLibrary_project/dynamic_templates/OpenCodelists_phenotype.json index 421ee4032..e216f49d0 100644 --- a/CodeListLibrary_project/dynamic_templates/OpenCodelists_phenotype.json +++ b/CodeListLibrary_project/dynamic_templates/OpenCodelists_phenotype.json @@ -42,7 +42,7 @@ "fields": ["source_reference", "references"] }, { - "title": "Clinical Code List", + "title": "Clinical Codelist", "documentation": "clinical-coded-phenotype-docs", "description": "Clinical codes that defines this Phenotype.", "fields": ["concept_information"] @@ -150,8 +150,7 @@ "active": true, "validation": { "type": "string", - "mandatory": false, - "length": [0, 250] + "mandatory": false }, "hide_if_empty": true }, diff --git a/CodeListLibrary_project/dynamic_templates/bhf_phenotype.json b/CodeListLibrary_project/dynamic_templates/bhf_phenotype.json index 5ef6df103..8477fd379 100644 --- a/CodeListLibrary_project/dynamic_templates/bhf_phenotype.json +++ b/CodeListLibrary_project/dynamic_templates/bhf_phenotype.json @@ -99,7 +99,13 @@ }, "type": { "type": "int_array", - "field": "type_id" + "field": "type_id", + "coerce": [ + { "compare": "SNOMED", "out": 0 }, + { "compare": "Clinical Disease Category (SNOMED)", "out": 0 }, + { "compare": "Clinical Domain", "out": 1 }, + { "compare": "Functional Anatomy", "out": 2 } + ] }, "reference": { "type": "int_array", @@ -228,7 +234,7 @@ "field_type": "daterange", "validation": { "type": "string", - "regex": "(?:\\d+/|\\d+)+[\\s+]?-[\\s+]?(?:\\d+/|\\d+)+", + "regex": "[\\S\\s]*?(\\d+(?:-\\d+)*)[^\\d]*", "mandatory": false, "sanitise": "strict" }, @@ -291,8 +297,8 @@ "validation": { "type": "string", "mandatory": false, - "length": [0, 250], - "regex": "^https?:\\/\\/(?:www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b(?:[-a-zA-Z0-9()@:%_\\+.~#?&\\/=]*)$" + "length": [0, 500], + "regex": "^(https?:\\/\\/)((?!-)(?!.*--)[a-zA-Z\\-0-9]{1,63}(?<!-)\\.)+[a-zA-Z]{2,63}(\\/[^\\s]*)?$" }, "hide_if_empty": true }, @@ -364,7 +370,7 @@ "do_not_show_in_production": true }, { - "title": "Clinical Code List", + "title": "Clinical Codelist", "fields": [ "concept_information" ], diff --git a/CodeListLibrary_project/dynamic_templates/clinical_coded_phenotype.json b/CodeListLibrary_project/dynamic_templates/clinical_coded_phenotype.json index 20f5cf10a..deae6086a 100644 --- a/CodeListLibrary_project/dynamic_templates/clinical_coded_phenotype.json +++ b/CodeListLibrary_project/dynamic_templates/clinical_coded_phenotype.json @@ -37,7 +37,7 @@ "hide_if_empty": true }, { - "title": "Clinical Code List", + "title": "Clinical Codelist", "documentation": "clinical-coded-phenotype-docs", "description": "Clinical codes that defines this Phenotype.", "fields": ["concept_information"] @@ -180,7 +180,13 @@ }, "type": { "type": "int_array", - "field": "type_id" + "field": "type_id", + "coerce": [ + { "compare": "SNOMED", "out": 0 }, + { "compare": "Clinical Disease Category (SNOMED)", "out": 0 }, + { "compare": "Clinical Domain", "out": 1 }, + { "compare": "Functional Anatomy", "out": 2 } + ] }, "reference": { "type": "int_array", @@ -232,11 +238,11 @@ "type": "string", "mandatory": false, "regex": [ - "(?:\\d+\\/|\\d+)+[\\s+]?-[\\s+]?(?:\\d+\\/|\\d+)+", + "[\\S\\s]*?(\\d+(?:-\\d+)*)[^\\d]*", "[\\s+]?\\-[\\s+]?(?:\\d+\\/|\\d+)+", "(?:\\d+\\/|\\d+)+[\\s+]?\\-[\\s+]?" ], - "date_closure_optional": true + "closure_optional": true } }, "sex": { @@ -306,8 +312,8 @@ "validation": { "type": "string", "mandatory": false, - "length": [0, 250], - "regex": "^https?:\\/\\/(?:www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b(?:[-a-zA-Z0-9()@:%_\\+.~#?&\\/=]*)$" + "length": [0, 500], + "regex": "^(https?:\\/\\/)((?!-)(?!.*--)[a-zA-Z\\-0-9]{1,63}(?<!-)\\.)+[a-zA-Z]{2,63}(\\/[^\\s]*)?$" }, "hide_if_empty": true }, diff --git a/CodeListLibrary_project/dynamic_templates/hdrn_template.json b/CodeListLibrary_project/dynamic_templates/hdrn_template.json new file mode 100644 index 000000000..9d3996bd4 --- /dev/null +++ b/CodeListLibrary_project/dynamic_templates/hdrn_template.json @@ -0,0 +1,861 @@ +{ + "template_details": { + "name": "HDRN Concept", + "shortname": "Concept", + "version": 1, + "description": "Concept definitions that are based on lists of clinical codes, or algorithms using clinical codes.", + "card_type": "clinical" + }, + + "shunt": { + "definition": "brief_description", + "implementation": "description" + }, + + "sections": [ + { + "title": "Identification", + "fields": [ + "name", + "author", + "hdrn_resource_ref" + ], + "hide_on_detail": true + }, + { + "title": "Overview", + "description": "A set of descriptors that identifies this Concept.", + "fields": [ + "definition", + "sites", + "jurisdictions", + "category", + "data_sources", + "date_range_sources", + "tags", + "collections", + "ontology", + "contacts" + ], + "hide_if_empty": true + }, + { + "title": "Population", + "description": "The population used to develop this Concept or the population to which it applies (e.g., age, sex, gender, race, disease group).\nIndicate any cautions in the \"Limitations\" field to further contextualize the information.", + "fields": [ + "population_age", + "population_sex", + "population_gender", + "population_race" + ], + "hide_if_empty": true + }, + { + "title": "Methods", + "description": "Information describing this Concept and documentation concerning its methodology.", + "fields": [ + "implementation", + "description", + "validation", + "validation_measures", + "limitations", + "additional_information" + ], + "hide_if_empty": true + }, + { + "title": "Components", + "description": "A set of data, codes and files used to derive the Concept.", + "fields": [ + "concept_information", + "variable_list", + "indicator_calculation", + "asset_files" + ], + "hide_if_empty": true + }, + { + "title": "Publication", + "description": "Publications that reference this Concept and how this Concept might be cited in further research.", + "fields": [ + "publications", + "acknowledgements", + "citation_requirements" + ], + "hide_if_empty": true + }, + { + "title": "References & Relationships", + "description": "A set of related Concepts, sources, and resources.", + "fields": [ + "related_entities", + "source_reference", + "references" + ], + "hide_if_empty": true + } + ], + + + "fields": { + "name": { + "title": "Name", + "description": "The full name of this Concept.", + "field_type": "string_inputbox", + "active": true, + "validation": { + "type": "string", + "length": [1, 250], + "regex": "^([\\w]+)(.*)$", + "mandatory": true, + "sanitise": "strict" + }, + "is_base_field": true + }, + "sites": { + "title": "Site(s)", + "description": "Site(s) where the Concept has been developed and/or applied.", + "field_type": "model_relations", + "active": true, + "hydrated": true, + "hint": "Your selection must be selected from the provided list.", + "behaviour": { + "freeform": false, + "format": { + "view": "{abbreviation}", + "component": "{abbreviation} - {name}" + } + }, + "validation": { + "type": "int_array", + "source": { + "table": "HDRNSite", + "query": "id", + "relative": "name", + "include": ["abbreviation", "description"] + }, + "mandatory": false + }, + "search": { + "filterable": true, + "api": true + } + }, + "author": { + "title": "Author", + "description": "Name(s) of individual(s), project and/or research group that created this Concept.", + "field_type": "string_inputbox", + "active": true, + "validation": { + "type": "string", + "length": [0, 1000], + "regex": "^([\\w]+)(.*)$", + "mandatory": false, + "sanitise": "strict" + }, + "is_base_field": true + }, + "hdrn_resource_ref": { + "title": "HDRN Resource Reference", + "description": "If applicable, this describes an internal HDRN identifier, i.e. an ID or UUID, referencing a historic HDRN resource (optional).", + "field_type": "string_inputbox", + "active": true, + "hint": "Must be in the shape of a UUID, a numeric ID, or a URL (e.g. https://website.com/).", + "validation": { + "type": "string", + "mandatory": false, + "unique": true, + "length": [0, 250], + "sanitise": "strict", + "regex": "(https?:\\/\\/)(.*)|(.*)([\\d]+)(.*)|([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})" + }, + "search": { + "api": true + }, + "hide_if_empty": true + }, + + + "definition": { + "title": "Brief Description", + "description": "A succinct summary of the Concept in plain language including its definition, purpose, and applications.", + "field_type": "string_inputbox", + "active": true, + "shunt": "brief_description", + "validation": { + "type": "string", + "length": [1, 1000], + "regex": "^([\\w]+)(.*)$", + "mandatory": true, + "sanitise": "strict" + }, + "is_base_field": true, + "hide_if_empty": true + }, + "jurisdictions": { + "title": "Jurisdiction(s)", + "description": "The province or territory where the Concept was developed and/or applied.", + "field_type": "model_relations", + "active": true, + "hydrated": true, + "hint": "Your selection MUST be selected from the provided list.", + "behaviour": { + "freeform": false, + "format": { + "view": "{abbreviation}", + "component": "{abbreviation} - {name}" + } + }, + "validation": { + "type": "int_array", + "source": { + "table": "HDRNJurisdiction", + "query": "id", + "relative": "name", + "include": ["abbreviation", "description"] + }, + "mandatory": true + }, + "search": { + "filterable": true, + "api": true + } + }, + "category": { + "title": "Category", + "description": "The category of patient characteristics this Concept is best described by.", + "field_type": "model_relations", + "active": true, + "hydrated": true, + "hint": "You may select from the provided list or create your own.", + "behaviour": { + "freeform": true + }, + "validation": { + "type": "int_array", + "source": { + "table": "HDRNDataCategory", + "query": "id", + "relative": "name", + "include": ["description"] + }, + "mandatory": false + }, + "search": { + "filterable": true, + "api": true + } + }, + "population_age": { + "title": "Age", + "description": "The age, or age range, this Concept is applicable to.", + "field_type": "age_group", + "active": true, + "icon": "", + "validation": { + "type": "age_group", + "mandatory": false, + "properties": { + "min": 0, + "max": 120, + "step": 1 + } + } + }, + "population_sex": { + "title": "Sex", + "description": "Population sex description", + "field_type": "grouped_enum", + "active": true, + "icon": "", + "validation": { + "type": "enum", + "mandatory": false, + "options": { + "1": "Male", + "2": "Female", + "3": "Both", + "4": "Neither" + }, + "properties": [ + { + "when": ["1", "2"], + "result": "3" + } + ] + }, + "search": { + "api": true + } + }, + "population_gender": { + "title": "Gender", + "description": "Population gender description", + "field_type": "list_enum", + "active": true, + "icon": "", + "validation": { + "type": "int_array", + "mandatory": false, + "options": { + "1": "Cisgender men", + "2": "Cisgender women", + "3": "Transgender men", + "4": "Transgender women", + "5": "Non-binary persons", + "6": "N/A" + }, + "properties": { + "none_selector": "6", + "groups": [ + { + "when": "6", + "result": "deselect" + } + ] + } + }, + "search": { + "api": true + } + }, + "population_race": { + "title": "Race", + "description": "Population race description", + "field_type": "list_enum", + "active": true, + "icon": "", + "validation": { + "type": "int_array", + "mandatory": false, + "options": { + "1": "Black", + "2": "East Asian", + "3": "Latin American", + "4": "Middle Eastern", + "5": "South Asian", + "6": "Southeast Asian", + "7": "White", + "8": "Another Race Category", + "9": "N/A" + }, + "properties": { + "none_selector": "9", + "groups": [ + { + "when": "9", + "result": "deselect" + } + ] + } + }, + "search": { + "api": true + } + }, + "data_sources": { + "title": "Data Sources", + "description": "A list identifying data sources referenced, used or required by this Concept.", + "field_type": "data_sources", + "active": true, + "hydrated": true, + "hint": "Your selection must be selected from the provided list.", + "behaviour": { + "freeform": true + }, + "validation": { + "type": "int_array", + "mandatory": true, + "source": { + "table": "HDRNDataAsset", + "query": "id", + "relative": "name", + "include": ["description", "link"] + } + }, + "search": { + "filterable": true, + "api": true + }, + "hide_if_empty": true + }, + "date_range_sources": { + "title": "Date Range for Source Data", + "description": "A date range identifying the time period for the data.", + "field_type": "daterange", + "active": true, + "validation": { + "type": "string", + "mandatory": false, + "regex": [ + "[\\S\\s]*?(\\d+(?:-\\d+)*)[^\\d]*", + "[\\s+]?\\-[\\s+]?(?:\\d+\\/|\\d+)+", + "(?:\\d+\\/|\\d+)+[\\s+]?\\-[\\s+]?" + ], + "closure_optional": true + }, + "hide_if_empty": true + }, + "tags": { + "title": "Tags", + "description": "A list of keywords helping to categorize this content.", + "hint": "You may select from the provided list or create your own.", + "target": "tag_list_by_id", + "behaviour": { + "freeform": true, + "branding": "collection_brand", + "defaults": { + "tag_type": 1 + } + }, + "is_base_field": true + }, + "collections": { + "title": "Collections", + "description": "A list of content collections that this Concept belongs to.", + "hint": "You may select from the provided list or create your own.", + "target": "collections_list_by_id", + "behaviour": { + "freeform": true, + "branding": "collection_brand", + "defaults": { + "tag_type": 2 + } + }, + "is_base_field": true + }, + "ontology": { + "title": "Ontology", + "description": "A set of categories, terms and concepts that describe this Concept.", + "field_type": "ontology", + "active": true, + "hydrated": true, + "validation": { + "type": "int_array", + "mandatory": false, + "source": { + "model": "OntologyTag", + "trees": [3], + "references": { + "trim": true, + "pattern": "^(\\w+):([\\w\\.\\-\\_ ]+)$", + "transform": "([^a-zA-Z0-9]+)", + "mapping": { + "mesh": { + "type": "string", + "match_in": "mesh_codes", + "match_out": "code", + "match_src": "clinicalcode_SNOMED_CODES", + "match_type": "overlap", + "link_target": "properties__code" + }, + "icd9": { + "type": "string", + "match_in": "icd9_codes", + "match_out": "code", + "match_src": "clinicalcode_SNOMED_CODES", + "match_type": "overlap", + "link_target": "properties__code" + }, + "icd10": { + "type": "string", + "match_in": "icd10_codes", + "match_out": "code", + "match_src": "clinicalcode_SNOMED_CODES", + "match_type": "overlap", + "link_target": "properties__code" + }, + "opsc4": { + "type": "string", + "match_in": "opcs4_codes", + "match_out": "code", + "match_src": "clinicalcode_SNOMED_CODES", + "match_type": "overlap", + "link_target": "properties__code" + }, + "readv2": { + "type": "string", + "match_in": "readcv2_codes", + "match_out": "code", + "match_src": "clinicalcode_SNOMED_CODES", + "match_type": "overlap", + "link_target": "properties__code" + }, + "readv3": { + "type": "string", + "match_in": "readcv3_codes", + "match_out": "code", + "match_src": "clinicalcode_SNOMED_CODES", + "match_type": "overlap", + "link_target": "properties__code" + }, + "snomed": { + "type": "string", + "match_in": "code", + "match_out": "code", + "match_src": "clinicalcode_SNOMED_CODES", + "match_type": "in", + "link_target": "properties__code" + } + } + }, + "subquery": { + "id": { + "type": "int_array", + "field": "id", + "modifiers": ["descendants"] + }, + "name": { + "type": "string_array", + "field": "name", + "modifiers": ["descendants"] + }, + "type": { + "type": "int_array", + "field": "type_id", + "coerce": [ + { "compare": "MeSH", "out": 3 }, + { "compare": "Medical Subject Headings (MeSH)", "out": 3 } + ] + }, + "reference": { + "type": "int_array", + "field": "reference_id", + "modifiers": ["descendants"] + }, + "code": { + "key": "code", + "type": "string_array", + "field": "properties", + "field_type": "jsonb", + "modifiers": ["descendants"] + } + } + } + }, + "search": { + "filterable": true, + "api": true + }, + "hide_if_empty": true + }, + "contacts": { + "title": "Contacts", + "description": "Identified contact to provide additional information related to the Concept.", + "field_type": "contact_information", + "active": true, + "requires_auth": true, + "validation": { + "type": "contacts", + "mandatory": false + }, + "hide_if_empty": true + }, + + + "background": { + "title": "Background", + "description": "The context for the development of this Concept, including the problem it addresses, the rationale for its development, and its significance.\n\nProvide a brief overview of why this Concept was developed, including the problem it addresses and its intended use. Indicate whether it is a new Concept or an adaptation of previous work, explaining any modifications made. If relevant, include references to supporting research or documentation.", + "field_type": "textarea_markdown", + "active": true, + "validation": { + "type": "string", + "mandatory": false, + "sanitise": "markdown" + }, + "hide_if_empty": true + }, + "implementation": { + "title": "Description", + "description": "The process to develop and implement the Concept, including key methodological details, criteria, and decision points.\n\nSummarize the methodology, including step-by-step details or a high-level overview. Specify implementation aspects such as the timeframe, observation/lookback window, and inclusion/exclusion criteria. If the Concept applies across multiple regions, describe how data were standardized or aligned (e.g., crosswalk algorithms).\n\nProvide details on how different components contribute to the Concept or the algorithm logic (e.g., \"at least 2 ICD codes within 6 months\" or \"1 ICD code + positive lab test\"). If alternative methods were considered but not used, briefly explain why. Link to any relevant documentation or publications for additional details.", + "field_type": "textarea_markdown", + "active": true, + "shunt": "description", + "validation": { + "type": "string", + "mandatory": false, + "sanitise": "markdown" + }, + "is_base_field": true, + "hide_if_empty": true + }, + "validation": { + "title": "Validation", + "description": "A description of the methods used to validate this Concept.\n\nSummarize the statistical or other methods used to validate the Concept, including how scales, ranges, or outcomes were measured. Reference any relevant research used as a validation source.", + "hide_if_empty": true, + "is_base_field": true + }, + "validation_measures": { + "title": "Validation Measures", + "description": "Include any specific validation metrics (e.g., sensitivity, specificity) if available.", + "field_type": "validation_measures", + "active": true, + "validation": { + "ugc": true, + "type": "var_data", + "mandatory": false, + "options": { + "sensitivity": { + "name": "Sensitivity", + "type": "percentage", + "format": "{value}%" + }, + "specificity": { + "name": "Specificity", + "type": "percentage", + "format": "{value}%" + }, + "ppv": { + "name": "Positive Predictive Value", + "type": "percentage", + "format": "{value}%" + }, + "npv": { + "name": "Negative Predictive Value", + "type": "percentage", + "format": "{value}%" + }, + "confidence_interval": { + "name": "Confidence Interval", + "type": "ci_interval", + "format": "{probability}% CI ({lower}; {upper})" + }, + "c_statistic": { + "name": "C-statistic", + "type": "numeric" + }, + "hazard_ratio": { + "name": "Hazard Ratio", + "type": "numeric" + }, + "kappa": { + "name": "Kappa", + "type": "numeric" + } + }, + "properties": { + "label": "Validation Measures", + "selector": { + "label": "Options...", + "title": "Select Measures" + }, + "sanitise": "strict", + "allow_unknown": true, + "allow_description": true, + "allow_types": [ + "int", + "numeric", + "percentage", + "percentage_range", + "int_range", + "numeric_range", + "string" + ] + } + }, + "hide_if_empty": true + }, + "limitations": { + "title": "Limitations & Cautions", + "description": "An overview of the limitations and cautions associated with the Concept.\n\nDescribe any limitations, constraints, or challenges encountered during the development of the Concept. This may include issues like placeholder variables, small cell sizes, data availability, or time-dependent data changes. Note any potential impact these limitations may have on analysis and interpretation of results.", + "field_type": "textarea_markdown", + "active": true, + "validation": { + "type": "string", + "mandatory": false, + "sanitise": "markdown" + }, + "hide_if_empty": true + }, + "additional_information": { + "title": "Additional Information", + "description": "Any additional relevant methodological information that is important to understanding the Concept.", + "field_type": "textarea_markdown", + "active": true, + "validation": { + "type": "string", + "mandatory": false, + "sanitise": "markdown" + }, + "hide_if_empty": true + }, + + + "concept_information": { + "title": "Codelists", + "description": "Identify the clinical or fee codes used in the Concept. For example, for Concepts looking at how we define certain diseases / medical conditions or surgical procedures, include the International Classification of Disease (ICD) diagnoses or ICD / CCI (Canadian Classification of Health Interventions) procedure / intervention codes.", + "field_type": "concept_information", + "active": true, + "shunt": "codelist", + "validation": { + "type": "concept", + "mandatory": false, + "has_children": true + }, + "hide_if_empty": true + }, + "coding_system": { + "title": "Coding System", + "description":"Clinical coding system(s) contained by this Concept. A Concept may have multiple codelists, each with its own coding system. All contained coding systems are programmatically represented here.", + "field_type": "coding_system", + "active": true, + "validation": { + "type": "int_array", + "mandatory": false, + "computed": true, + "source": { + "table": "CodingSystem", + "query": "codingsystem_id", + "relative": "name" + } + }, + "search": { + "filterable": true, + "api": true + }, + "hide_on_create": true + }, + "variable_list": { + "title": "Scales, Range of Values, and Variable Lists", + "description": "Identify the scales or ranges of values, include the measures for each and describe what they represent.", + "field_type": "srv_list", + "active": true, + "validation": { + "ugc": true, + "type": "var_data", + "mandatory": false, + "properties": { + "allow_unknown": true, + "allow_description": true, + "allow_types": [ + "int", + "numeric", + "percentage", + "percentage_range", + "int_range", + "numeric_range", + "string" + ] + } + }, + "hide_if_empty": true + }, + "indicator_calculation": { + "title": "Indicator Calculation", + "description": "If applicable, identify a indicator calculation specifying the numerator (population) and relevant denominator (outcome criteria).", + "field_type": "indicator_calculation", + "active": true, + "validation": { + "type": "indicator_calculation", + "mandatory": false + }, + "hide_if_empty": true + }, + "asset_files": { + "title": "Asset Files & Links", + "description": "Attach programming codes, macros, assets or data files used to generate the Concept; or provide link/path to internal repository.", + "field_type": "asset_list", + "active": false, + "validation": { + "type": "asset_files_links", + "mandatory": false + }, + "hide_if_empty": true + }, + + + "publications": { + "title": "Publications", + "description": "Publication(s) that define or describe development, validation, or modification of this Concept.", + "field_type": "publications", + "sort": {"key": "primary"}, + "active": true, + "validation": { + "type": "publication", + "mandatory": false + }, + "is_base_field": true, + "hide_if_empty": true + }, + "acknowledgements": { + "title": "Acknowledgements", + "description": "An acknowledgement of funding sources or other support received during development of this Concept.", + "field_type": "textarea_markdown", + "active": true, + "validation": { + "type": "string", + "mandatory": false, + "sanitise": "markdown" + }, + "hide_if_empty": true + }, + "citation_requirements": { + "title": "Citation Requirements", + "description": "A request for how this Concept is referenced if used in other work.\nThis will be automatically generated if not specified.", + "field_type": "citation_requirements", + "active": true, + "validation": { + "type": "string", + "mandatory": false, + "sanitise": "markdown" + }, + "is_base_field": true + }, + + + "related_entities": { + "title": "Related Concepts", + "description": "Any related Concepts that already exist in the dictionary and are used or referenced in the Concept.", + "field_type": "related_entities", + "active": true, + "validation": { + "type": "related_entities", + "mandatory": false, + "target": { + "table": "HistoricalGenericEntity", + "select": ["id", "history_id"], + "relative": "name" + }, + "properties": { + "anchor": { + "name": "entity_detail", + "target": "id" + }, + "lookup": "/api/v1/phenotypes/", + "storage": ["id", "history_id", "name"], + "display": ["id", "history_id", "name"], + "labeling": ["phenotype_id", "phenotype_version_id", "name"], + "reference": ["phenotype_id", "phenotype_version_id"] + } + }, + "hide_if_empty": true + }, + "source_reference": { + "title": "Source Reference", + "description": "A link to the original third-party source if this Concept is based on or derived from an external reference.", + "field_type": "source_reference", + "active": true, + "hint": "Must be in the shape of a URL, e.g. https://website.com/", + "validation": { + "type": "string", + "mandatory": false, + "length": [0, 500], + "regex": "^(https?:\\/\\/)((?!-)(?!.*--)[a-zA-Z\\-0-9]{1,63}(?<!-)\\.)+[a-zA-Z]{2,63}(\\/[^\\s]*)?$" + }, + "hide_if_empty": true + }, + "references": { + "title": "References", + "description": "Any publications or other sources that were referenced in the development of this Concept, i.e., previous research, or foundational sources that informed the creation of the Concept or served as a starting point for its development.", + "field_type": "url_list", + "active": true, + "validation": { + "type": "url_list", + "mandatory": false, + "regex": "^(https?:\\/\\/)((?!-)(?!.*--)[a-zA-Z\\-0-9]{1,63}(?<!-)\\.)+[a-zA-Z]{2,63}(\\/[^\\s]*)?$", + "sanitise": "strict" + }, + "hide_if_empty": true + } + } +} diff --git a/CodeListLibrary_project/dynamic_templates/structured_data_algorithm_phenotype.json b/CodeListLibrary_project/dynamic_templates/structured_data_algorithm_phenotype.json index bdc90677b..4518a0e66 100644 --- a/CodeListLibrary_project/dynamic_templates/structured_data_algorithm_phenotype.json +++ b/CodeListLibrary_project/dynamic_templates/structured_data_algorithm_phenotype.json @@ -86,7 +86,7 @@ "validation": { "type": "string", "mandatory": false, - "regex": "(?:\\d+\/|\\d+)+[\\s+]?-[\\s+]?(?:\\d+\/|\\d+)+" + "regex": "[\\S\\s]*?(\\d+(?:-\\d+)*)[^\\d]*" } }, "sex": { @@ -122,7 +122,7 @@ "validation": { "type": "string", "mandatory": false, - "length": [0, 250], + "length": [0, 500], "regex": "^https?:\\/\\/(?:www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b(?:[-a-zA-Z0-9()@:%_\\+.~#?&\\/=]*)$" }, "hide_if_empty": true diff --git a/docker/.env/web/live.compose.env b/docker/.env/web/live.compose.env index f9a40d4f8..a8ce91172 100644 --- a/docker/.env/web/live.compose.env +++ b/docker/.env/web/live.compose.env @@ -47,7 +47,7 @@ GOOGLE_RECAPTCHA_SECRET_KEY="" ## 5. Mailhog settings ## - Specifies whether the app should attempt to send e-mails via Mailhog -HAS_MAILHOG_SERVICE=False +HAS_MAILHOG_SERVICE=True #> Db settings diff --git a/docker/app/scripts/build/dependencies.sh b/docker/app/scripts/build/dependencies.sh index e4839a4a9..c1175ec37 100644 --- a/docker/app/scripts/build/dependencies.sh +++ b/docker/app/scripts/build/dependencies.sh @@ -17,4 +17,4 @@ fi # Install esbuild / other npm deps npm install -g config set user root; -npm install -g "esbuild@0.19.0" +npm install -g "esbuild@0.25.2" diff --git a/docker/app/scripts/init/beat-start.sh b/docker/app/scripts/init/beat-start.sh index 9eacefc23..578e43411 100644 --- a/docker/app/scripts/init/beat-start.sh +++ b/docker/app/scripts/init/beat-start.sh @@ -4,13 +4,15 @@ start_worker=0; if [ -z $DEBUG ] || [ $DEBUG = "False" ]; then start_worker=1; - if [ -z $IS_DEVELOPMENT_PC ] || [ $IS_DEVELOPMENT_PC = "False" ]; then - if [ ! -z $IS_DEMO ] && [ $IS_DEMO = "True" ]; then - start_worker=0; - elif [ ! -z $CLL_READ_ONLY ] && [ $CLL_READ_ONLY = "True" ]; then - start_worker=0; - elif [ ! -z $IS_INSIDE_GATEWAY ] && [ $IS_INSIDE_GATEWAY = "True" ]; then - start_worker=0; + if [ -z $ENABLE_DEMO_TASK_QUEUE ] || [ $ENABLE_DEMO_TASK_QUEUE = "False" ]; then + if [ -z $IS_DEVELOPMENT_PC ] || [ $IS_DEVELOPMENT_PC = "False" ]; then + if [ ! -z $IS_DEMO ] && [ $IS_DEMO = "True" ]; then + start_worker=0; + elif [ ! -z $CLL_READ_ONLY ] && [ $CLL_READ_ONLY = "True" ]; then + start_worker=0; + elif [ ! -z $IS_INSIDE_GATEWAY ] && [ $IS_INSIDE_GATEWAY = "True" ]; then + start_worker=0; + fi fi fi fi diff --git a/docker/app/scripts/init/worker-start.sh b/docker/app/scripts/init/worker-start.sh index b0fb4fdc0..69e35a9d2 100644 --- a/docker/app/scripts/init/worker-start.sh +++ b/docker/app/scripts/init/worker-start.sh @@ -4,13 +4,15 @@ start_worker=0; if [ -z $DEBUG ] || [ $DEBUG = "False" ]; then start_worker=1; - if [ -z $IS_DEVELOPMENT_PC ] || [ $IS_DEVELOPMENT_PC = "False" ]; then - if [ ! -z $IS_DEMO ] && [ $IS_DEMO = "True" ]; then - start_worker=0; - elif [ ! -z $CLL_READ_ONLY ] && [ $CLL_READ_ONLY = "True" ]; then - start_worker=0; - elif [ ! -z $IS_INSIDE_GATEWAY ] && [ $IS_INSIDE_GATEWAY = "True" ]; then - start_worker=0; + if [ -z $ENABLE_DEMO_TASK_QUEUE ] || [ $ENABLE_DEMO_TASK_QUEUE = "False" ]; then + if [ -z $IS_DEVELOPMENT_PC ] || [ $IS_DEVELOPMENT_PC = "False" ]; then + if [ ! -z $IS_DEMO ] && [ $IS_DEMO = "True" ]; then + start_worker=0; + elif [ ! -z $CLL_READ_ONLY ] && [ $CLL_READ_ONLY = "True" ]; then + start_worker=0; + elif [ ! -z $IS_INSIDE_GATEWAY ] && [ $IS_INSIDE_GATEWAY = "True" ]; then + start_worker=0; + fi fi fi fi diff --git a/docker/requirements/base.txt b/docker/requirements/base.txt index c6c582bf2..ce2c90f9d 100644 --- a/docker/requirements/base.txt +++ b/docker/requirements/base.txt @@ -1,33 +1,31 @@ # Base application dependencies -bleach==6.1.0 -requests==2.32.0 -tzdata==2024.2 -urllib3==2.2.3 +bleach==6.2.0 +requests==2.32.3 +tzdata==2025.2 +urllib3==2.3.0 libsass==0.23.0 psycopg2-binary==2.9.10 -redis==5.1.1 -celery==5.4.0 -Django==5.1.2 -Jinja2==3.1.4 -django-appconf==1.0.6 +redis==5.2.1 +celery==5.5.0 +Django==5.1.8 +Jinja2==3.1.6 django-auth-ldap==5.1.0 -djangorestframework==3.15.2 +djangorestframework==3.16.0 djangorestframework-xml==2.0.0 -django-rest-swagger==2.2.0 -django-minify-html==1.9.0 +django-minify-html==1.12.0 django-compressor==4.5.1 django-cookie-law==2.2.0 django-markdownify==0.9.5 django-sass-processor==1.4.1 django-postgresql-dag==0.4.0 -django-simple-history==3.1.1 +django-simple-history==3.8.0 django-redis==5.4.0 django-celery-beat==2.7.0 django-celery-results==2.5.1 -drf-yasg==1.21.8 -swagger-spec-validator==3.0.4 +drf-yasg==1.21.10 python-crontab==3.2.0 python-dateutil==2.9.0 python-decouple==3.8 -pyhtml2md==1.6.0 +pyhtml2md==1.6.6 django-easy-audit==1.3.7 +html-to-markdown==1.3.3 diff --git a/docker/requirements/production.txt b/docker/requirements/production.txt index b9e845aae..014c3f56d 100644 --- a/docker/requirements/production.txt +++ b/docker/requirements/production.txt @@ -5,4 +5,4 @@ -r engagelens.txt # Production requirements -mod-wsgi==5.0.1 +mod-wsgi==5.0.2 diff --git a/docs/sql-scripts/.processing/coding/inspect.sql b/docs/sql-scripts/.processing/coding/inspect.sql new file mode 100644 index 000000000..151b6848c --- /dev/null +++ b/docs/sql-scripts/.processing/coding/inspect.sql @@ -0,0 +1,53 @@ +-- improved perf. counting coding systems +do +$bd$ +declare + _query text; + _ref text; + _row_cnt int; + _record json; + _coding_tables json; + + _result jsonb := '[]'::jsonb; + _cursor constant refcursor := '_cursor'; +begin + select + json_agg(json_build_object( + 'name', coding.name, + 'value', coding.id, + 'table_name', coding.table_name + )) as tbl + from public.clinicalcode_codingsystem as coding + into _coding_tables; + + for _record, _ref + in select obj, obj->>'table_name'::text from json_array_elements(_coding_tables) obj + loop + if exists(select 1 from information_schema.tables where table_name = _ref) then + _query := format('select count(*) from %I', _ref); + execute _query into _row_cnt; + else + _row_cnt = 0; + end if; + + _result = _result || format( + '[{ + "name": "%s", + "value": %s, + "table_name": "%s", + "code_count": %s, + "can_search": %s + }]', + _record->>'name'::text, + _record->>'value'::text, + _record->>'table_name'::text, + _row_cnt::int, + (_row_cnt::int > 0)::text + )::jsonb; + end loop; + + _query := format('select %L::jsonb as res', _result::text); + open _cursor for execute _query; +end; +$bd$; +fetch all from _cursor; diff --git a/docs/sql-scripts/.processing/coding/mesh_ontology.sql b/docs/sql-scripts/.processing/coding/mesh_ontology.sql new file mode 100644 index 000000000..b30769aa6 --- /dev/null +++ b/docs/sql-scripts/.processing/coding/mesh_ontology.sql @@ -0,0 +1,70 @@ +-- 1. create table(s) +-- Quote: " | Escape: null +create table tmp_mesh_rel ( + id serial primary key, + child varchar(18) not null, + parent varchar(18) not null +); + +-- Quote: " | Escape: null +create table tmp_mesh_desc ( + id serial primary key, + code varchar(18) not null, + name varchar(512) not null, + type varchar(18) not null +); + + +-- 2. Import data... + +--[[ ... ]]-- + + +-- 3. Run the following to create +-- -> Coding Mesh ID: 26 +-- -> Reference Mesh Id: 3 +-- +insert into public.clinicalcode_ontologytag (name, type_id, properties, search_vector) + select + mesh.name, + 3 as type_id, + json_build_object( + 'code', mesh.code, + 'coding_system_id', 26, + 'type', mesh.type + ) as properties, + setweight( + (to_tsvector('pg_catalog.english', coalesce(mesh.name, '')) || to_tsvector('pg_catalog.english', coalesce(mesh.code, ''))), + 'A' + ) as search_vector + from tmp_mesh_desc as mesh; + + +-- 4. Build relationships +with + ontology as ( + select + id, + properties::json->>'code'::varchar as code + from public.clinicalcode_ontologytag + where type_id = 3 + ), + relationships as ( + select * + from public.tmp_mesh_rel + ) +insert into public.clinicalcode_ontologytagedge (child_id, parent_id) + select + c.id as child_id, + p.id as parent_id + from relationships as r + join ontology as c + on r.child = c.code + join ontology as p + on r.parent = p.code + on conflict (child_id, parent_id) do nothing; + + +-- 5. Drop tmp table(s) +drop table tmp_mesh_rel; +drop table tmp_mesh_desc; diff --git a/docs/sql-scripts/.processing/hdrn/hdrn_data_assets.py b/docs/sql-scripts/.processing/hdrn/hdrn_data_assets.py new file mode 100644 index 000000000..26ed539a5 --- /dev/null +++ b/docs/sql-scripts/.processing/hdrn/hdrn_data_assets.py @@ -0,0 +1,110 @@ +'''See: https://dash.hdrn.ca/en/inventory/''' +from typing import Any, Dict + +import os +import re +import json +import datetime + +import numpy as np +import pandas as pd + +COL_NAMES = ['Name', 'UUID', 'Site', 'Region', 'Data Categories', 'Description', 'Purpose', 'Scope', 'Data Level', 'Collection Period', 'Link', 'Years'] + +class JsonEncoder(json.JSONEncoder): + '''Encodes PyObj to JSON serialisable objects, see `jencoder`_ + + .. _jencoder: https://docs.python.org/3/library/json.html#json.JSONEncoder + ''' + def default(self, obj: Any) -> None | Dict[str, str]: + '''JSON encoder extensible implementation, see `jencoder`_ + + Args: + obj (Any): URL to target resource + + Returns: + A JSON-encoded object + + .. _jencoder: https://docs.python.org/3/library/json.html#json.JSONEncoder.default + ''' + if isinstance(obj, (datetime.date, datetime.datetime)): + return { 'type': 'datetime', 'value': obj.isoformat() } + + +def tx_link(link: str | None) -> pd.Series: + '''Parse id & convert links from fmt: `https://www.hdrn.ca/en/inventory/` to `https://dash.hdrn.ca/en/inventory/` + + Args: + link (str|None): URL to target resource + + Returns: + A `pd.Series` with the parsed `id` & the transformed `link`, if applicable + ''' + if isinstance(link, str): + ident = re.findall(r'(\d+)\/$', link) + if ident is not None and len(ident) > 0: + ident = int(ident[-1]) + return pd.Series([ + ident, + f'https://dash.hdrn.ca/en/inventory/{ident}/' + ]) + + return pd.Series([None, None]) + + +def build_pkg(inv_path: str ='./assets/HDRN_CanadaInventoryList.xlsx', out_fpath: str = './.out/HDRN_Data.json') -> None: + '''Builds the HDRN Data Asset JSON packet + + Note: + XLSX file located @ `inv_path` expects the following shape: + - Sheet: `Inventory` + - Headers: `Name | UUID | Site | Region | Data Categories | Description | Purpose | Scope | Data Level | Collection Period | Link | Years | Created Date | Modified By` + + Args: + inv_path (str): path to inventory list; defaults to `./assets/HDRN_CanadaInventoryList.xlsx` + out_fpath (str): output file path; defaults to `./.out/HDRN_Data.json` + ''' + # Process data + pkg = pd.read_excel(inv_path, sheet_name='Inventory') + pkg = pkg.replace({ np.nan: '' }) + + pkg[COL_NAMES] = pkg[COL_NAMES].astype(str) + pkg = pkg.replace(r'^\s*$', None, regex=True) + + pkg[['Id', 'Link']] = pkg.apply(lambda x: tx_link(x['Link']), axis=1) + pkg['Created Date'] = pd.to_datetime(pkg['Created Date'], format='%Y-%m-%d %H:%M:%S') + pkg['Modified By'] = pd.to_datetime(pkg['Created Date'], format='%Y-%m-%d %H:%M:%S') + + pkg = pkg.rename(columns={'Modified By': 'Modified Date'}) + pkg = pkg.sort_values(by='Created Date', axis=0, ascending=True, ignore_index=True) + + pkg.columns = [x.strip().replace(' ', '_').lower() for x in pkg.columns] + + # Compute unique categorical data + unq_sites = pkg['site'].str.split(r'(?:;)\s*').dropna().to_numpy() + unq_sites = np.unique(sum(unq_sites, [])) + + unq_cats = pkg['data_categories'].str.split(r'(?:,|;)\s*').dropna().to_numpy() + unq_cats = np.unique(sum(unq_cats, [])) + unq_cats = np.strings.capitalize(unq_cats) + + # Save pkg + dpath = os.path.realpath(os.path.dirname(out_fpath)) + if not os.path.exists(dpath): + os.makedirs(dpath) + + pkg = pkg.to_dict('records') + pkg = { + 'assets': pkg, + 'metadata': { + 'site': list(unq_sites), + 'data_categories': list(unq_cats), + }, + } + + with open(out_fpath, 'w') as f: + json.dump(pkg, f, cls=JsonEncoder) + + +if __name__ == '__main__': + build_pkg() diff --git a/docs/sql-scripts/.processing/phenoflow/phenoflow_map.py b/docs/sql-scripts/.processing/phenoflow/phenoflow_map.py new file mode 100644 index 000000000..d817a62fb --- /dev/null +++ b/docs/sql-scripts/.processing/phenoflow/phenoflow_map.py @@ -0,0 +1,104 @@ +'''See: https://kclhi.org/phenoflow/''' +from typing import Any, Dict + +import json +import requests + + +def get_workflow(group: Dict[str, Any]) -> tuple[Dict[str, Any] | None, str | None]: + ''' + Attempts to resolve the Phenoflow from the specified Phenotype + + Args: + group (dict): some Phenotype-Phenoflow group + + Returns: + A tuple variant specifying the resulting workflow and/or an err msg, if applicable + ''' + legacy_id = group.get('phenoflow_id') + phenotypes = group.get('related_phenotypes') + if not isinstance(legacy_id, int) or not isinstance(phenotypes, list): + return None, f'Workflow {repr(group)} is invalid, expected `related_phenotypes` as `list` type and property `phenoflow_id` to be an int' + + result = None + for pheno in phenotypes: + pheno_id = pheno.get('id') + if not isinstance(pheno_id, str): + continue + + try: + response = requests.post('https://kclhi.org/phenoflow/phenotype/all', headers={'Accept': 'application/json'}, json={'importedId': pheno_id}) + if response.status_code != 200: + continue + + result = response.json() + result = result.get('url') if isinstance(result, dict) else None + if isinstance(result, str): + break + except requests.exceptions.ConnectionError: + return None, 'Failed to retrieve workflows' + except Exception: + continue + + if not result: + return None, f'Failed to resolve Group<target: {repr(group)}>' + + return { 'id': legacy_id, 'url': result }, None + + +def query_phenoflow(in_fpath: str = './assets/minified.json', out_fpath: str = './.out/phenoflow.json') -> None: + ''' + Queries an entire Phenotype set to derive the new Phenoflow URL target + + Args: + in_fpath (str): file path to a JSON file specifying the Phenotypes & Phenoflow relationships; defaults to `./assets/minified.json` + out_fpath (str): output file path; defaults to `./.out/phenoflow.json` + ''' + with open(in_fpath) as f: + groups = json.load(f) + + workflows = [] + for group in groups: + result, err = get_workflow(group) + if err is not None: + continue + workflows.append(result) + + with open(out_fpath, 'w') as f: + json.dump(workflows, f, indent=2) + + +def map_phenoflow(in_fpath: str = './assets/minified.json', trg_fpath: str = './.out/phenoflow.json', out_fpath: str = './.out/mapped.json') -> None: + ''' + Attempts to map a Phenotype and its assoc. Phenoflow relationships to the newest implementation + + Args: + in_fpath (str): file path to a JSON file specifying the Phenotypes & Phenoflow relationships; defaults to `./assets/minified.json` + out_fpath (str): file path to a JSON file specifying the resolved targets; defaults to `./.out/phenoflow.json` + out_fpath (str): output file path; defaults to `./.out/mapped.json` + ''' + with open(in_fpath) as f: + groups = json.load(f) + + with open(trg_fpath) as f: + relation = json.load(f) + + mapped = {} + for group in groups: + phenotypes = group.get('related_phenotypes') + phenoflow_id = group.get('phenoflow_id') + if not isinstance(phenotypes, list): + continue + + mapping = next((x for x in relation if x.get('id') == phenoflow_id), None) + mapping = mapping.get('url') if isinstance(mapping, dict) else None + for pheno in phenotypes: + mapped[pheno.get('id')] = { 'source': phenoflow_id, 'target': mapping } + + with open(out_fpath, 'w') as f: + json.dump(mapped, f, indent=2) + + +if __name__ == '__main__': + query_phenoflow() + map_phenoflow() diff --git a/docs/sql-scripts/.processing/phenoflow/phenoflow_query.sql b/docs/sql-scripts/.processing/phenoflow/phenoflow_query.sql new file mode 100644 index 000000000..fa79a6a1c --- /dev/null +++ b/docs/sql-scripts/.processing/phenoflow/phenoflow_query.sql @@ -0,0 +1,29 @@ +--> Used to collect all historical records of Phenotypes assoc. with some unique Phenoflow obj +--> See Phenoflow @ https://kclhi.org/phenoflow/ + +with + phenoflow_objs as ( + select + ent.id, + ent.history_id, + (ent.template_data::jsonb->>'phenoflowid')::int as phenoflow_id + from public.clinicalcode_historicalgenericentity as ent + where ent.template_data::jsonb ? 'phenoflowid' + and ent.template_data::jsonb->>'phenoflowid' ~ E'^\\d+$' + ), + aggregated_objs as ( + select + ent.phenoflow_id, + json_agg(json_build_object( + 'id', ent.id, + 'history_id', ent.history_id + )) as related_phenotypes + from phenoflow_objs as ent + group by ent.phenoflow_id + ) +select + json_agg(json_build_object( + 'phenoflow_id', ent.phenoflow_id, + 'related_phenotypes', ent.related_phenotypes + )) as results + from aggregated_objs as ent; diff --git a/engagelens/utils.py b/engagelens/utils.py index 44a26b47c..0ba8fa83d 100644 --- a/engagelens/utils.py +++ b/engagelens/utils.py @@ -172,12 +172,12 @@ def get_conn(use_engine=False): 'host': os.getenv('POSTGRES_HOST'), 'dbname': os.getenv('POSTGRES_DB'), 'user': os.getenv('POSTGRES_USER'), - 'password': quote(os.getenv('POSTGRES_PASSWORD')), + 'password': os.getenv('POSTGRES_PASSWORD'), 'port': os.getenv('POSTGRES_PORT') } if use_engine: - db_url = f"postgresql://{config_params['user']}:{config_params['password']}@{config_params['host']}:{config_params['port']}/{config_params['dbname']}" + db_url = f"postgresql://{config_params['user']}:{quote(config_params['password'])}@{config_params['host']}:{config_params['port']}/{config_params['dbname']}" engine = create_engine(db_url) return engine