diff --git a/backend/fms_core/serializers.py b/backend/fms_core/serializers.py index 964564951b..af3ec20bbb 100644 --- a/backend/fms_core/serializers.py +++ b/backend/fms_core/serializers.py @@ -103,6 +103,8 @@ "UserSerializer", "GroupSerializer", "ProjectSerializer", + "ParentProjectSerializer", + "ParentProjectReadsetSerializer", "ProjectExportSerializer", "SequenceSerializer", "TaxonSerializer", @@ -123,7 +125,7 @@ "SampleIdentityMatchSerializer", "SampleIdentitySerializer", "ProfileSerializer", - "DerivedSampleSerializer" + "DerivedSampleSerializer", "FreezemanPermissionSerializer", ] @@ -616,6 +618,49 @@ class Meta: fields = '__all__' +class ParentProjectReadsetSerializer(serializers.Serializer): + id = serializers.IntegerField() + name = serializers.CharField() + readset_sample_name = serializers.CharField() + biosample_id = serializers.IntegerField(allow_null=True) + external_id = serializers.CharField() + run_name = serializers.CharField() + lane = serializers.IntegerField() + reference_genome_id = serializers.IntegerField(allow_null=True,) + reference_genome_assembly_name = serializers.CharField(allow_null=True,) + sequencing_index_name = serializers.CharField(allow_null=True) + run_start_date = serializers.DateField() + alias = serializers.CharField(allow_null=True) + cohort = serializers.CharField(allow_blank=True,allow_null=True,) + library_type = serializers.CharField(allow_null=True,) + container_barcodes = serializers.ListField(child=serializers.CharField(allow_null=True),allow_empty=True,) + number_of_reads = serializers.IntegerField(allow_null=True,) + number_of_bases = serializers.IntegerField(allow_null=True,) + average_quality = serializers.DecimalField( + max_digits=40, + decimal_places=20, + allow_null=True, + ) + + pf_reads_aligned = serializers.DecimalField( + max_digits=40, + decimal_places=20, + allow_null=True, + ) + + duplicate_aligned = serializers.DecimalField( + max_digits=40, + decimal_places=20, + allow_null=True, + ) + readset_files = serializers.ListField( + child=serializers.DictField(), + required=False, + ) + run_validation_status = serializers.IntegerField(allow_null=True,) + + + class IndexSetSerializer(serializers.ModelSerializer): index_count = serializers.SerializerMethodField() diff --git a/backend/fms_core/viewsets/parent_project.py b/backend/fms_core/viewsets/parent_project.py index 0989a8df47..5ec7e71255 100644 --- a/backend/fms_core/viewsets/parent_project.py +++ b/backend/fms_core/viewsets/parent_project.py @@ -1,9 +1,15 @@ +from django.db.models import F, Max, Q, Value +from django.db.models.functions import JSONObject +from django.contrib.postgres.aggregates import ArrayAgg + from rest_framework import viewsets +from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response -from fms_core.models import ParentProject -from fms_core.serializers import ParentProjectSerializer +from fms_core.models import ParentProject, Readset +from fms_core.serializers import ParentProjectReadsetSerializer, ParentProjectSerializer from ._utils import _list_keys from ._constants import _parent_project_filterset_fields @@ -21,4 +27,108 @@ class ParentProjectViewSet(viewsets.ModelViewSet): **_parent_project_filterset_fields, } - ordering = ["external_id"] \ No newline at end of file + ordering = ["external_id"] + + def _get_readsets_queryset(self, parent_project : ParentProject): + + PARENT_PROJECT_READSET_ORDERING = [ + "dataset__experiment_run__start_date", + "dataset__experiment_run__name", + "dataset__lane", + "id", + ] + + PARENT_PROJECT_READSET_VALUE_FIELDS = [ + "id", + "name", + "average_quality", + "pf_reads_aligned", + "duplicate_aligned", + "number_of_reads", + "number_of_bases", + "readset_files", + "container_barcodes", + "run_validation_status", + ] + + PARENT_PROJECT_READSET_VALUE_ALIASES = { + "readset_sample_name": F("sample_name"), + "biosample_id": F("derived_sample__biosample_id"), + "external_id": F("dataset__project__parent_project__external_id"), + "run_name": F("dataset__experiment_run__name"), + "lane": F("dataset__lane"), + "reference_genome_id": F("derived_sample__biosample__individual__reference_genome_id"), + "reference_genome_assembly_name": F("derived_sample__biosample__individual__reference_genome__assembly_name"), + "sequencing_index_name": F("derived_sample__library__index__name"), + "run_validation_status": F("validation_status"), + "run_start_date": F("dataset__experiment_run__start_date"), + "alias": F("derived_sample__biosample__alias"), + "cohort": F("derived_sample__biosample__individual__cohort"), + "library_type": F("derived_sample__library__library_type__name"), + + } + + return ( + Readset.objects.filter( + dataset__project__parent_project=parent_project, + ) + .annotate( + average_quality=Max( + "metrics__value_numeric", + filter=Q(metrics__name="avg_qual"), + ), + pf_reads_aligned=Max( + "metrics__value_numeric", + filter=Q(metrics__name="pf_read_alignment_rate"), + ), + duplicate_aligned=Max( + "metrics__value_numeric", + filter=Q(metrics__name="duplicate_rate"), + ), + number_of_reads=Max( + "metrics__value_numeric", + filter=Q(metrics__name="nb_reads"), + ), + number_of_bases=Max( + "metrics__value_numeric", + filter=Q(metrics__name="yield"), + ), + readset_files=ArrayAgg( + JSONObject( + file_path=F("files__file_path"), + size=F("files__size"), + ), + filter=Q(files__isnull=False), + distinct=True, + default=Value([]), + ), + container_barcodes=ArrayAgg( + "derived_sample__derived_by_samples__sample__container__barcode", + distinct=True, + ), + ) + .order_by(*PARENT_PROJECT_READSET_ORDERING) + .values( + *PARENT_PROJECT_READSET_VALUE_FIELDS, + **PARENT_PROJECT_READSET_VALUE_ALIASES, + ) + + ) + + + @action(detail=True, methods=["get"], url_path="readsets") + def overview_readsets(self, request, pk=None): + parent_project = self.get_object() + + queryset = self._get_readsets_queryset(parent_project) + page = self.paginate_queryset(queryset) + + if page is not None: + serializer = ParentProjectReadsetSerializer(page, many=True,) + return self.get_paginated_response(serializer.data) + + else: + + serializer = ParentProjectReadsetSerializer(queryset, many=True,) + return Response(serializer.data) + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5b4c55587f..e6e4f82599 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "license": "LGPL-3.0-only", "dependencies": { + "@ant-design/charts": "^2.6.7", "@ant-design/icons": "^4.2.1", "@types/redux-logger": "^3.0.9", "antd": "^6.3.2", @@ -73,6 +74,34 @@ "webpack-dev-server": "^4.9.2" } }, + "node_modules/@ant-design/charts": { + "version": "2.6.7", + "resolved": "https://registry.npmjs.org/@ant-design/charts/-/charts-2.6.7.tgz", + "integrity": "sha512-XfmsnspUpfrMlRFGTwmHJ2TPKcosq5a5nSxAfIOpEXAvmJBT2N16oejGTZhUFTzba8W3XtBOziwRAXmDmLUqvA==", + "license": "MIT", + "dependencies": { + "@ant-design/graphs": "^2.1.1", + "@ant-design/plots": "^2.6.7", + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": ">=16.8.4", + "react-dom": ">=16.8.4" + } + }, + "node_modules/@ant-design/charts-util": { + "version": "0.0.1-alpha.7", + "resolved": "https://registry.npmjs.org/@ant-design/charts-util/-/charts-util-0.0.1-alpha.7.tgz", + "integrity": "sha512-Yh0o6EdO6SvdSnStFZMbnUzjyymkVzV+TQ9ymVW9hlVgO/fUkUII3JYSdV+UVcFnYwUF0YiDKuSTLCZNAzg2bQ==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": ">=16.8.4", + "react-dom": ">=16.8.4" + } + }, "node_modules/@ant-design/colors": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-6.0.0.tgz", @@ -125,6 +154,66 @@ "node": ">=8.x" } }, + "node_modules/@ant-design/graphs": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@ant-design/graphs/-/graphs-2.1.1.tgz", + "integrity": "sha512-qT3Oo8BWeoAmZEy9gfR6uIk+rczbNJ3sWXKonoOD5koATWv7dY0kgvS1JnhdM1QW4FkfPPJTeQVSlRRUtvWDwA==", + "license": "MIT", + "dependencies": { + "@ant-design/charts-util": "0.0.1-alpha.7", + "@antv/g6": "^5.0.44", + "@antv/g6-extension-react": "^0.2.0", + "@antv/graphin": "^3.0.4", + "lodash": "^4.17.21", + "styled-components": "^6.1.15" + }, + "peerDependencies": { + "react": ">=16.8.4", + "react-dom": ">=16.8.4" + } + }, + "node_modules/@ant-design/graphs/node_modules/styled-components": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.5.3.tgz", + "integrity": "sha512-vAX79sfpmUerP9fsTTxoTrBDE0RuO4ahjInyWYoohNgqrdg63Ms4q6FJ/o2Fyity82NU3cujOT8Ewl9TThBdwg==", + "license": "MIT", + "dependencies": { + "@emotion/is-prop-valid": "1.4.0", + "css-to-react-native": "3.2.0", + "csstype": "3.2.3", + "stylis": "4.3.6" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/styled-components" + }, + "peerDependencies": { + "css-to-react-native": ">= 3.2.0", + "react": ">= 16.8.0", + "react-dom": ">= 16.8.0", + "react-native": ">= 0.68.0" + }, + "peerDependenciesMeta": { + "css-to-react-native": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@ant-design/graphs/node_modules/stylis": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", + "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", + "license": "MIT" + }, "node_modules/@ant-design/icons": { "version": "4.8.3", "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-4.8.3.tgz", @@ -152,6 +241,37 @@ "integrity": "sha512-1BTUFyKPTBZ53MuTP8s0k5SFEXL7o3VHEOwLgzaoWKwnBeqIcqUtVshc4SKzhI6uACfqhJqBwBUE9FsWR3uULA==", "license": "MIT" }, + "node_modules/@ant-design/plots": { + "version": "2.6.8", + "resolved": "https://registry.npmjs.org/@ant-design/plots/-/plots-2.6.8.tgz", + "integrity": "sha512-QsunUs2d5rbq/1BwVhga/siA5H50OaG23YopMYwPD4sPsza6NQzPQ8FM3elNIsD/BIk298tihqX1cJ/MmvVJbQ==", + "license": "MIT", + "dependencies": { + "@ant-design/charts-util": "0.0.3", + "@antv/event-emitter": "^0.1.3", + "@antv/g": "^6.1.7", + "@antv/g2": "^5.2.7", + "@antv/g2-extension-plot": "^0.2.1", + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": ">=16.8.4", + "react-dom": ">=16.8.4" + } + }, + "node_modules/@ant-design/plots/node_modules/@ant-design/charts-util": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@ant-design/charts-util/-/charts-util-0.0.3.tgz", + "integrity": "sha512-x1H7UT6t4dXAyGRoHqlOnEsEqBSTANFGTZEAMI0CWYhYUpp13n0o9grl9oPtoL6FEQMjUBTY+zGJKlHkz8smMw==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": ">=16.8.4", + "react-dom": ">=16.8.4" + } + }, "node_modules/@ant-design/react-slick": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-2.0.0.tgz", @@ -168,6 +288,366 @@ "react-dom": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@antv/algorithm": { + "version": "0.1.26", + "resolved": "https://registry.npmjs.org/@antv/algorithm/-/algorithm-0.1.26.tgz", + "integrity": "sha512-DVhcFSQ8YQnMNW34Mk8BSsfc61iC1sAnmcfYoXTAshYHuU50p/6b7x3QYaGctDNKWGvi1ub7mPcSY0bK+aN0qg==", + "license": "MIT", + "dependencies": { + "@antv/util": "^2.0.13", + "tslib": "^2.0.0" + } + }, + "node_modules/@antv/algorithm/node_modules/@antv/util": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/@antv/util/-/util-2.0.17.tgz", + "integrity": "sha512-o6I9hi5CIUvLGDhth0RxNSFDRwXeywmt6ExR4+RmVAzIi48ps6HUy+svxOCayvrPBN37uE6TAc2KDofRo0nK9Q==", + "license": "ISC", + "dependencies": { + "csstype": "^3.0.8", + "tslib": "^2.0.3" + } + }, + "node_modules/@antv/component": { + "version": "2.1.11", + "resolved": "https://registry.npmjs.org/@antv/component/-/component-2.1.11.tgz", + "integrity": "sha512-dTdz8VAd3rpjOaGEZTluz82mtzrP4XCtNlNQyrxY7VNRNcjtvpTLDn57bUL2lRu1T+iklKvgbE2llMriWkq9vQ==", + "license": "MIT", + "dependencies": { + "@antv/g": "^6.1.11", + "@antv/scale": "^0.4.16", + "@antv/util": "^3.3.10", + "svg-path-parser": "^1.1.0" + } + }, + "node_modules/@antv/component/node_modules/@antv/scale": { + "version": "0.4.16", + "resolved": "https://registry.npmjs.org/@antv/scale/-/scale-0.4.16.tgz", + "integrity": "sha512-5wg/zB5kXHxpTV5OYwJD3ja6R8yTiqIOkjOhmpEJiowkzRlbEC/BOyMvNUq5fqFIHnMCE9woO7+c3zxEQCKPjw==", + "license": "MIT", + "dependencies": { + "@antv/util": "^3.3.7", + "color-string": "^1.5.5", + "fecha": "^4.2.1" + } + }, + "node_modules/@antv/coord": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@antv/coord/-/coord-0.4.7.tgz", + "integrity": "sha512-UTbrMLhwJUkKzqJx5KFnSRpU3BqrdLORJbwUbHK2zHSCT3q3bjcFA//ZYLVfIlwqFDXp/hzfMyRtp0c77A9ZVA==", + "license": "MIT", + "dependencies": { + "@antv/scale": "^0.4.12", + "@antv/util": "^2.0.13", + "gl-matrix": "^3.4.3" + } + }, + "node_modules/@antv/coord/node_modules/@antv/scale": { + "version": "0.4.16", + "resolved": "https://registry.npmjs.org/@antv/scale/-/scale-0.4.16.tgz", + "integrity": "sha512-5wg/zB5kXHxpTV5OYwJD3ja6R8yTiqIOkjOhmpEJiowkzRlbEC/BOyMvNUq5fqFIHnMCE9woO7+c3zxEQCKPjw==", + "license": "MIT", + "dependencies": { + "@antv/util": "^3.3.7", + "color-string": "^1.5.5", + "fecha": "^4.2.1" + } + }, + "node_modules/@antv/coord/node_modules/@antv/scale/node_modules/@antv/util": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/@antv/util/-/util-3.3.11.tgz", + "integrity": "sha512-FII08DFM4ABh2q5rPYdr0hMtKXRgeZazvXaFYCs7J7uTcWDHUhczab2qOCJLNDugoj8jFag1djb7wS9ehaRYBg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "gl-matrix": "^3.3.0", + "tslib": "^2.3.1" + } + }, + "node_modules/@antv/coord/node_modules/@antv/util": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/@antv/util/-/util-2.0.17.tgz", + "integrity": "sha512-o6I9hi5CIUvLGDhth0RxNSFDRwXeywmt6ExR4+RmVAzIi48ps6HUy+svxOCayvrPBN37uE6TAc2KDofRo0nK9Q==", + "license": "ISC", + "dependencies": { + "csstype": "^3.0.8", + "tslib": "^2.0.3" + } + }, + "node_modules/@antv/event-emitter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@antv/event-emitter/-/event-emitter-0.1.3.tgz", + "integrity": "sha512-4ddpsiHN9Pd4UIlWuKVK1C4IiZIdbwQvy9i7DUSI3xNJ89FPUFt8lxDYj8GzzfdllV0NkJTRxnG+FvLk0llidg==", + "license": "MIT" + }, + "node_modules/@antv/expr": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@antv/expr/-/expr-1.0.2.tgz", + "integrity": "sha512-vrfdmPHkTuiS5voVutKl2l06w1ihBh9A8SFdQPEE+2KMVpkymzGOF1eWpfkbGZ7tiFE15GodVdhhHomD/hdIwg==", + "license": "MIT" + }, + "node_modules/@antv/g": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@antv/g/-/g-6.3.1.tgz", + "integrity": "sha512-WYEKqy86LHB2PzTmrZXrIsIe+3Epeds2f68zceQ+BJtRoGki7Sy4IhlC8LrUMztgfT1t3d/0L745NWZwITroKA==", + "license": "MIT", + "dependencies": { + "@antv/g-lite": "2.7.0", + "@antv/util": "^3.3.5", + "@babel/runtime": "^7.25.6", + "gl-matrix": "^3.4.3", + "html2canvas": "^1.4.1" + } + }, + "node_modules/@antv/g-canvas": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@antv/g-canvas/-/g-canvas-2.2.0.tgz", + "integrity": "sha512-h7zVBBo2aO64DuGKvq9sG+yTU3sCUb9DALCVm7nz8qGPs8hhLuFOkKPEzUDNfNYZGJUGzY8UDtJ3QRGRFcvEQg==", + "license": "MIT", + "dependencies": { + "@antv/g-lite": "2.7.0", + "@antv/g-math": "3.1.0", + "@antv/util": "^3.3.5", + "@babel/runtime": "^7.25.6", + "gl-matrix": "^3.4.3", + "tslib": "^2.5.3" + } + }, + "node_modules/@antv/g-lite": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@antv/g-lite/-/g-lite-2.7.0.tgz", + "integrity": "sha512-uSzgHYa5bwR5L2Au7/5tsOhFmXKZKLPBH90+Q9bP9teVs5VT4kOAi0isPSpDI8uhdDC2/VrfTWu5K9HhWI6FWw==", + "license": "MIT", + "dependencies": { + "@antv/g-math": "3.1.0", + "@antv/util": "^3.3.5", + "@antv/vendor": "^1.0.3", + "@babel/runtime": "^7.25.6", + "eventemitter3": "^5.0.1", + "gl-matrix": "^3.4.3", + "tslib": "^2.5.3" + } + }, + "node_modules/@antv/g-math": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@antv/g-math/-/g-math-3.1.0.tgz", + "integrity": "sha512-DtN1Gj/yI0UiK18nSBsZX8RK0LszGwqfb+cBYWgE+ddyTm8dZnW4tPUhV7QXePsS6/A5hHC+JFpAAK7OEGo5ZQ==", + "license": "MIT", + "dependencies": { + "@antv/util": "^3.3.5", + "@babel/runtime": "^7.25.6", + "gl-matrix": "^3.4.3", + "tslib": "^2.5.3" + } + }, + "node_modules/@antv/g-plugin-dragndrop": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@antv/g-plugin-dragndrop/-/g-plugin-dragndrop-2.1.1.tgz", + "integrity": "sha512-+aesDUJVQDs6UJ2bOBbDlaGAPCfHmU0MbrMTlQlfpwNplWueqtgVAZ3L57oZ2ZGHRWUHiRwZGPjXMBM3O2LELw==", + "license": "MIT", + "dependencies": { + "@antv/g-lite": "2.7.0", + "@antv/util": "^3.3.5", + "@babel/runtime": "^7.25.6", + "tslib": "^2.5.3" + } + }, + "node_modules/@antv/g-svg": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@antv/g-svg/-/g-svg-2.1.1.tgz", + "integrity": "sha512-gVzBkjqA8FzDTbkuIxj6L0Omz/X/hFbYLzK6alWr0sHTfywqP6czcjDUJU8DF2MRIY1Twy55uZYW4dqqLXOXXg==", + "license": "MIT", + "dependencies": { + "@antv/g-lite": "2.7.0", + "@antv/util": "^3.3.5", + "@babel/runtime": "^7.25.6", + "gl-matrix": "^3.4.3", + "tslib": "^2.5.3" + } + }, + "node_modules/@antv/g2": { + "version": "5.4.8", + "resolved": "https://registry.npmjs.org/@antv/g2/-/g2-5.4.8.tgz", + "integrity": "sha512-IvgIpwmT4M5/QAd3Mn2WiHIDeBqFJ4WA2gcZhRRSZuZ2KmgCqZWZwwIT0hc+kIGxwYeDoCQqf//t6FMVu3ryBg==", + "license": "MIT", + "dependencies": { + "@antv/component": "^2.1.9", + "@antv/coord": "^0.4.7", + "@antv/event-emitter": "^0.1.3", + "@antv/expr": "^1.0.2", + "@antv/g": "^6.1.24", + "@antv/g-canvas": "^2.0.43", + "@antv/g-plugin-dragndrop": "^2.0.35", + "@antv/scale": "^0.5.1", + "@antv/util": "^3.3.10", + "@antv/vendor": "^1.0.11", + "flru": "^1.0.2", + "pdfast": "^0.2.0" + } + }, + "node_modules/@antv/g2-extension-plot": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@antv/g2-extension-plot/-/g2-extension-plot-0.2.2.tgz", + "integrity": "sha512-KJXCXO7as+h0hDqirGXf1omrNuYzQmY3VmBmp7lIvkepbQ7sz3pPwy895r1FWETGF3vTk5UeFcAF5yzzBHWgbw==", + "dependencies": { + "@antv/g2": "^5.1.8", + "@antv/util": "^3.3.5", + "@antv/vendor": "^1.0.10" + } + }, + "node_modules/@antv/g6": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@antv/g6/-/g6-5.1.1.tgz", + "integrity": "sha512-50bXxMUf4mChyOv4ePVeWZLwotih9VunKfp0a++Wofv/wCyY8fb9+CV2wouIBCOZnd5ydBRA4NNaX9yLJzqa2w==", + "license": "MIT", + "dependencies": { + "@antv/algorithm": "^0.1.26", + "@antv/component": "^2.1.7", + "@antv/event-emitter": "^0.1.3", + "@antv/g": "^6.1.28", + "@antv/g-canvas": "^2.0.48", + "@antv/g-plugin-dragndrop": "^2.0.38", + "@antv/graphlib": "^2.0.4", + "@antv/hierarchy": "^0.7.1", + "@antv/layout": "^2.0.0", + "@antv/util": "^3.3.11", + "bubblesets-js": "^2.3.4" + } + }, + "node_modules/@antv/g6-extension-react": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/@antv/g6-extension-react/-/g6-extension-react-0.2.7.tgz", + "integrity": "sha512-X/zxGiL/kyJ+5xteX1+P2mI07oLw+zfvKcIHxfynL7IGCQCwQ6q91LkJaOlSDTuWhNRXwnwJ4Cf2Nt/9Dhq5Dg==", + "license": "MIT", + "dependencies": { + "@antv/g": "^6.1.24", + "@antv/g-svg": "^2.0.38" + }, + "peerDependencies": { + "@antv/g6": "^5.1.0", + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@antv/graphin": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@antv/graphin/-/graphin-3.0.5.tgz", + "integrity": "sha512-V/j8R8Ty44wUqxVIYLdpPuIO8WWCTIVq1eBJg5YRunL5t5o5qAFpC/qkQxslbBMWyKdIH0oWBnvHA74riGi7cw==", + "license": "MIT", + "dependencies": { + "@antv/g6": "^5.0.28" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.1.0", + "react-dom": "^18.0.0 || ^19.1.0" + } + }, + "node_modules/@antv/graphlib": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@antv/graphlib/-/graphlib-2.0.4.tgz", + "integrity": "sha512-zc/5oQlsdk42Z0ib1mGklwzhJ5vczLFiPa1v7DgJkTbgJ2YxRh9xdarf86zI49sKVJmgbweRpJs7Nu5bIiwv4w==", + "license": "MIT", + "dependencies": { + "@antv/event-emitter": "^0.1.3" + } + }, + "node_modules/@antv/hierarchy": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@antv/hierarchy/-/hierarchy-0.7.1.tgz", + "integrity": "sha512-7r22r+HxfcRZp79ZjGmsn97zgC1Iajrv0Mm9DIgx3lPfk+Kme2MG/+EKdZj1iEBsN0rJRzjWVPGL5YrBdVHchw==", + "license": "MIT" + }, + "node_modules/@antv/layout": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@antv/layout/-/layout-2.0.0.tgz", + "integrity": "sha512-aCZ3UdNc40SfT7meFV7QTADY2HCnc0DShVw56CJNTI6oExUIVU736grPuL5Dhb8/JrVaU4Y83QPN/P7KafBzlw==", + "license": "MIT", + "dependencies": { + "@antv/event-emitter": "^0.1.3", + "@antv/expr": "^1.0.2", + "@antv/graphlib": "^2.0.0", + "@antv/util": "^3.3.2", + "comlink": "^4.4.1", + "d3-force": "^3.0.0", + "d3-force-3d": "^3.0.5", + "d3-octree": "^1.0.2", + "d3-quadtree": "^3.0.1", + "dagre": "^0.8.5", + "ml-matrix": "^6.10.4", + "tslib": "^2.8.1" + } + }, + "node_modules/@antv/scale": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@antv/scale/-/scale-0.5.2.tgz", + "integrity": "sha512-rTHRAwvpHWC5PGZF/mJ2ZuTDqwwvVBDRph0Uu5PV9BXwzV7K8+9lsqGJ+XHVLxe8c6bKog5nlzvV/dcYb0d5Ow==", + "license": "MIT", + "dependencies": { + "@antv/util": "^3.3.7", + "color-string": "^1.5.5", + "fecha": "^4.2.1" + } + }, + "node_modules/@antv/util": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/@antv/util/-/util-3.3.11.tgz", + "integrity": "sha512-FII08DFM4ABh2q5rPYdr0hMtKXRgeZazvXaFYCs7J7uTcWDHUhczab2qOCJLNDugoj8jFag1djb7wS9ehaRYBg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "gl-matrix": "^3.3.0", + "tslib": "^2.3.1" + } + }, + "node_modules/@antv/vendor": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@antv/vendor/-/vendor-1.0.11.tgz", + "integrity": "sha512-LmhPEQ+aapk3barntaiIxJ5VHno/Tyab2JnfdcPzp5xONh/8VSfed4bo/9xKo5HcUAEydko38vYLfj6lJliLiw==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.2.1", + "@types/d3-color": "^3.1.3", + "@types/d3-dispatch": "^3.0.6", + "@types/d3-dsv": "^3.0.7", + "@types/d3-ease": "^3.0.2", + "@types/d3-fetch": "^3.0.7", + "@types/d3-force": "^3.0.10", + "@types/d3-format": "^3.0.4", + "@types/d3-geo": "^3.1.0", + "@types/d3-hierarchy": "^3.1.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-path": "^3.1.0", + "@types/d3-quadtree": "^3.0.6", + "@types/d3-random": "^3.0.3", + "@types/d3-scale": "^4.0.9", + "@types/d3-scale-chromatic": "^3.1.0", + "@types/d3-shape": "^3.1.7", + "@types/d3-time": "^3.0.4", + "@types/d3-timer": "^3.0.2", + "d3-array": "^3.2.4", + "d3-color": "^3.1.0", + "d3-dispatch": "^3.0.1", + "d3-dsv": "^3.0.1", + "d3-ease": "^3.0.1", + "d3-fetch": "^3.0.1", + "d3-force": "^3.0.0", + "d3-force-3d": "^3.0.5", + "d3-format": "^3.1.0", + "d3-geo": "^3.1.1", + "d3-geo-projection": "^4.0.0", + "d3-hierarchy": "^3.1.2", + "d3-interpolate": "^3.0.1", + "d3-path": "^3.1.0", + "d3-quadtree": "^3.0.1", + "d3-random": "^3.0.1", + "d3-regression": "^1.3.10", + "d3-scale": "^4.0.2", + "d3-scale-chromatic": "^3.1.0", + "d3-shape": "^3.2.0", + "d3-time": "^3.1.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -1948,6 +2428,21 @@ "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", "license": "MIT" }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", + "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, "node_modules/@emotion/unitless": { "version": "0.7.5", "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", @@ -3511,12 +4006,60 @@ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", "license": "MIT" }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, "node_modules/@types/d3-ease": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", "license": "MIT" }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, "node_modules/@types/d3-interpolate": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", @@ -3532,6 +4075,18 @@ "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", "license": "MIT" }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.4.tgz", + "integrity": "sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==", + "license": "MIT" + }, "node_modules/@types/d3-scale": { "version": "4.0.9", "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", @@ -3541,6 +4096,12 @@ "@types/d3-time": "*" } }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, "node_modules/@types/d3-shape": { "version": "3.1.8", "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", @@ -3614,6 +4175,12 @@ "@types/send": "*" } }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/hoist-non-react-statics": { "version": "3.3.7", "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.7.tgz", @@ -4674,6 +5241,15 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.42", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", @@ -4857,6 +5433,12 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bubblesets-js": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/bubblesets-js/-/bubblesets-js-2.3.4.tgz", + "integrity": "sha512-DyMjHmpkS2+xcFNtyN00apJYL3ESdp9fTrkDr5+9Qg/GPqFmcWgGsK1akZnttE1XFxJ/VMy4DNNGMGYtmFp1Sg==", + "license": "MIT" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -4915,6 +5497,15 @@ "tslib": "^2.0.3" } }, + "node_modules/camelize": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", + "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001803", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001803.tgz", @@ -5057,6 +5648,22 @@ "node": ">=6" } }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, "node_modules/colorette": { "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", @@ -5064,6 +5671,12 @@ "dev": true, "license": "MIT" }, + "node_modules/comlink": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/comlink/-/comlink-4.4.2.tgz", + "integrity": "sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g==", + "license": "Apache-2.0" + }, "node_modules/commander": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", @@ -5261,6 +5874,24 @@ "node": ">= 8" } }, + "node_modules/css-color-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", + "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==", + "license": "ISC", + "engines": { + "node": ">=4" + } + }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/css-loader": { "version": "5.2.7", "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-5.2.7.tgz", @@ -5338,6 +5969,17 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/css-to-react-native": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz", + "integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==", + "license": "MIT", + "dependencies": { + "camelize": "^1.0.0", + "css-color-keywords": "^1.0.0", + "postcss-value-parser": "^4.0.2" + } + }, "node_modules/css-what": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", @@ -5402,6 +6044,12 @@ "node": ">=12" } }, + "node_modules/d3-binarytree": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz", + "integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==", + "license": "MIT" + }, "node_modules/d3-color": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", @@ -5411,6 +6059,49 @@ "node": ">=12" } }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/d3-ease": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", @@ -5420,6 +6111,48 @@ "node": ">=12" } }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force-3d": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/d3-force-3d/-/d3-force-3d-3.0.6.tgz", + "integrity": "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA==", + "license": "MIT", + "dependencies": { + "d3-binarytree": "1", + "d3-dispatch": "1 - 3", + "d3-octree": "1", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-format": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", @@ -5429,6 +6162,57 @@ "node": ">=12" } }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo-projection": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/d3-geo-projection/-/d3-geo-projection-4.0.0.tgz", + "integrity": "sha512-p0bK60CEzph1iqmnxut7d/1kyTmm3UWtPlwdkM31AU+LW+BXazd5zJdoCn7VFxNCHXRngPHRnsNn5uGjLRGndg==", + "license": "ISC", + "dependencies": { + "commander": "7", + "d3-array": "1 - 3", + "d3-geo": "1.12.0 - 3" + }, + "bin": { + "geo2svg": "bin/geo2svg.js", + "geograticule": "bin/geograticule.js", + "geoproject": "bin/geoproject.js", + "geoquantize": "bin/geoquantize.js", + "geostitch": "bin/geostitch.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo-projection/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-interpolate": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", @@ -5441,6 +6225,12 @@ "node": ">=12" } }, + "node_modules/d3-octree": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-1.1.0.tgz", + "integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==", + "license": "MIT" + }, "node_modules/d3-path": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", @@ -5450,6 +6240,30 @@ "node": ">=12" } }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-regression": { + "version": "1.3.10", + "resolved": "https://registry.npmjs.org/d3-regression/-/d3-regression-1.3.10.tgz", + "integrity": "sha512-PF8GWEL70cHHWpx2jUQXc68r1pyPHIA+St16muk/XRokETzlegj5LriNKg7o4LR0TySug4nHYPJNNRz/W+/Niw==", + "license": "BSD-3-Clause" + }, "node_modules/d3-scale": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", @@ -5466,6 +6280,19 @@ "node": ">=12" } }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-shape": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", @@ -6490,6 +7317,12 @@ } } }, + "node_modules/fecha": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", + "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -6618,6 +7451,15 @@ "dev": true, "license": "ISC" }, + "node_modules/flru": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/flru/-/flru-1.0.2.tgz", + "integrity": "sha512-kWyh8ADvHBFz6ua5xYOPnUroZTT/bwWfrCeL0Wj1dzG4/YOmOcfJ99W8dOVyyynJN35rZ9aCOtHChqQovV7yog==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/follow-redirects": { "version": "1.16.0", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", @@ -6779,6 +7621,12 @@ "node": ">=6.11.5" } }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -7047,6 +7895,19 @@ } } }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/htmlparser2": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", @@ -7299,6 +8160,18 @@ "node": ">= 10" } }, + "node_modules/is-any-array": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-any-array/-/is-any-array-3.0.0.tgz", + "integrity": "sha512-o4h+tylWykC4BD1vaejp6gDxoM13bwW8FGuNs4yIKpj8xbBJcRxJx8vZpq0dCr7ZDEfeKjmsi/euolKhX6f/ww==", + "license": "MIT" + }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -8069,6 +8942,45 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/ml-array-max": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ml-array-max/-/ml-array-max-2.0.0.tgz", + "integrity": "sha512-QQZ4kENwpWmyNb98UXRDFXrmtIXuXtt1+bSbda/2KA85+F+rrJP8hZk6QOkCQXM2Th9mUDYdq/PNByPdT9ID4A==", + "license": "MIT", + "dependencies": { + "is-any-array": "^3.0.0" + } + }, + "node_modules/ml-array-min": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ml-array-min/-/ml-array-min-2.0.0.tgz", + "integrity": "sha512-GRj6Ky6sW9vGL6yIjgsHmXZ9YgrdmcQ8nCxPqEGeKc6dkfYg1XDYxGFxADUjNuZyoCd5PUscWAS4N+cFaX6hFg==", + "license": "MIT", + "dependencies": { + "is-any-array": "^3.0.0" + } + }, + "node_modules/ml-array-rescale": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ml-array-rescale/-/ml-array-rescale-2.0.0.tgz", + "integrity": "sha512-2GGtKfSno94/kIloWGvpp/U5Q5vLvLrza+SAaGsLeo6Xj4mEbA6Gqx+oTfZFkxnd1grT2X007HfJNs3T5BsiVg==", + "license": "MIT", + "dependencies": { + "is-any-array": "^3.0.0", + "ml-array-max": "^2.0.0", + "ml-array-min": "^2.0.0" + } + }, + "node_modules/ml-matrix": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ml-matrix/-/ml-matrix-6.15.0.tgz", + "integrity": "sha512-wFa1v6KP8bKp+fj0nYmRs1Pb5K4zRkXGKsOvLinvILENFIADncm4XlOI+S1M7yuACMGfI6cfk0IifDgd4j5xmw==", + "license": "MIT", + "dependencies": { + "is-any-array": "^3.0.0", + "ml-array-rescale": "^2.0.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -8541,6 +9453,12 @@ "dev": true, "license": "MIT" }, + "node_modules/pdfast": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/pdfast/-/pdfast-0.2.0.tgz", + "integrity": "sha512-cq6TTu6qKSFUHwEahi68k/kqN2mfepjkGrG9Un70cgdRRKLKY6Rf8P8uvP2NvZktaQZNF3YE7agEkLj0vGK9bA==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8739,7 +9657,6 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true, "license": "MIT" }, "node_modules/prelude-ls": { @@ -9436,6 +10353,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -9892,6 +10815,15 @@ "dev": true, "license": "ISC" }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, "node_modules/sockjs": { "version": "0.3.24", "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", @@ -10060,6 +10992,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svg-path-parser": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/svg-path-parser/-/svg-path-parser-1.1.0.tgz", + "integrity": "sha512-jGCUqcQyXpfe38R7RFfhrMyfXcBmpMNJI/B+4CE9/Unkh98UporAc461GTthv+TVDuZXsBx7/WiwJb1Oh4tt4A==", + "license": "MIT" + }, "node_modules/tapable": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", @@ -10097,6 +11035,15 @@ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "license": "MIT" }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/throttle-debounce": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz", @@ -10189,7 +11136,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/type-check": { @@ -10399,6 +11345,15 @@ "node": ">= 0.4.0" } }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 88bad9cb9d..7c56d4e9c8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -35,6 +35,7 @@ } }, "dependencies": { + "@ant-design/charts": "^2.6.7", "@ant-design/icons": "^4.2.1", "@types/redux-logger": "^3.0.9", "antd": "^6.3.2", diff --git a/frontend/src/components/app/App.js b/frontend/src/components/app/App.js index 8520173179..419787052e 100644 --- a/frontend/src/components/app/App.js +++ b/frontend/src/components/app/App.js @@ -5,7 +5,7 @@ import { DashboardOutlined, ExperimentOutlined, FileZipOutlined, - FlagOutlined, + FlagOutlined, HddOutlined, InfoCircleOutlined, LogoutOutlined, @@ -16,63 +16,69 @@ import { TableOutlined, TeamOutlined, UserOutlined, -} from "@ant-design/icons"; -import { Layout, Menu, Spin, Typography } from "antd"; -import React, { useEffect, useMemo } from "react"; -import { connect } from "react-redux"; -import { Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom"; + GlobalOutlined, + ApartmentOutlined, +} from "@ant-design/icons" +import { Layout, Menu, Spin, Typography } from "antd" +import React, { useEffect, useMemo } from "react" +import { connect } from "react-redux" +import { Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom" -import About from "../About"; -import ContainersPage from "../containers/ContainersPage"; -import DashboardPage from "../DashboardPage"; -import ExperimentRunsPage from "../experimentRuns/ExperimentRunsPage"; -import { IndexCurationPage } from "../management/IndexCurationPage"; -import { SampleRenamePage } from "../management/SampleRename/SampleRenamePage"; -import IndicesPage from "../indices/IndicesPage"; -import IndividualsPage from "../individuals/IndividualsPage"; -import JumpBar from "../JumpBar"; -import LibrariesPage from "../libraries/LibrariesPage"; -import LoginPage from "../login/LoginPage"; -import ProcessesPage from "../processes/ProcessesPage"; -import ProcessMeasurementsPage from "../processMeasurements/ProcessMeasurementsPage"; -import ProfilePage from "../profile/ProfilePage"; -import ProjectsPage from "../projects/ProjectsPage"; -import SamplesPage from "../samples/SamplesPage"; -import UsersPage from "../users/UsersPage"; +import About from "../About" +import ContainersPage from "../containers/ContainersPage" +import DashboardPage from "../DashboardPage" +import ExperimentRunsPage from "../experimentRuns/ExperimentRunsPage" +import { IndexCurationPage } from "../management/IndexCurationPage" +import { SampleRenamePage } from "../management/SampleRename/SampleRenamePage" +import IndicesPage from "../indices/IndicesPage" +import IndividualsPage from "../individuals/IndividualsPage" +import JumpBar from "../JumpBar" +import LibrariesPage from "../libraries/LibrariesPage" +import LoginPage from "../login/LoginPage" +import ProcessesPage from "../processes/ProcessesPage" +import ProcessMeasurementsPage from "../processMeasurements/ProcessMeasurementsPage" +import ProfilePage from "../profile/ProfilePage" +import ProjectsPage from "../projects/ProjectsPage" +import ExternalProjectsPage from "../projectOverview/ExternalProjectsPage" +import ExternalProjectDetailsPage from "../projectOverview/ExternalProjectDetailsPage" +import SamplesPage from "../samples/SamplesPage" +import UsersPage from "../users/UsersPage" -import PrivateNavigate from "../PrivateNavigate"; +import PrivateNavigate from "../PrivateNavigate" -import { matchingMenuKeys, resolveBadMenuItem } from "../../utils/menus"; -import { hour } from "../../utils/time"; -import useUserInputExpiration from "../../utils/useUserInputExpiration"; - -import { useAppDispatch, useAppSelector } from "../../hooks"; -import { setAppInitialized } from "../../modules/app/actions"; -import { logOut } from "../../modules/auth/actions"; -import { fetchStaticData } from "../../modules/shared/actions"; -import { get } from "../../modules/users/actions"; -import { selectAppInitialized } from "../../selectors"; -import DatasetsPage from "../datasets/DatasetsPage"; -import LabworkPage from "../labwork/LabworkPage"; -import ReferenceGenomesRoute from "../referenceGenomes/ReferenceGenomesRoute"; -import TaxonsRoute from "../taxons/TaxonsRoute"; -import WorkflowDefinitionsRoute from "../workflows/WorkflowDefinitionsRoute"; -import { useAuthInit } from "./useAuthInit"; -import { useRefreshHook } from "./useRefreshHook"; -import InstrumentsRoute from "../instruments/InstrumentsRoute"; -import { Reports } from "../reports/Reports"; -import { useNavigateToWorkflowAssignment, WorkflowAssignmentPage } from "../management/WorkflowAssignmentPage"; -import { getProfile } from "../../modules/profiles"; -import api from "../../utils/api"; -import store from "../../store"; +import { matchingMenuKeys, resolveBadMenuItem } from "../../utils/menus" +import { hour } from "../../utils/time" +import useUserInputExpiration from "../../utils/useUserInputExpiration" +import { useAppDispatch, useAppSelector } from "../../hooks" +import { setAppInitialized } from "../../modules/app/actions" +import { logOut } from "../../modules/auth/actions" +import { fetchStaticData } from "../../modules/shared/actions" +import { get } from "../../modules/users/actions" +import { selectAppInitialized } from "../../selectors" +import DatasetsPage from "../datasets/DatasetsPage" +import LabworkPage from "../labwork/LabworkPage" +import ReferenceGenomesRoute from "../referenceGenomes/ReferenceGenomesRoute" +import TaxonsRoute from "../taxons/TaxonsRoute" +import WorkflowDefinitionsRoute from "../workflows/WorkflowDefinitionsRoute" +import { useAuthInit } from "./useAuthInit" +import { useRefreshHook } from "./useRefreshHook" +import InstrumentsRoute from "../instruments/InstrumentsRoute" +import { Reports } from "../reports/Reports" +import { + useNavigateToWorkflowAssignment, + WorkflowAssignmentPage, +} from "../management/WorkflowAssignmentPage" +import { getProfile } from "../../modules/profiles" +import api from "../../utils/api" +import store from "../../store" -const { Title } = Typography; +const { Title } = Typography /** - * - * @param {import("../../models/frontend_models").User | undefined} user - * @param {() => void} logOut + * + * @param {import("../../models/frontend_models").User | undefined} user + * @param {() => void} logOut * @returns {BadMenuItem[]} */ const getMenuItems = (user, logOut) => [ @@ -93,11 +99,12 @@ const getMenuItems = (user, logOut) => [ icon: , text: `Sign Out (${user?.username})`, onClick: logOut, - style: { marginBottom: '50px' } + style: { marginBottom: "50px" }, }, ] -const DEV_QC_BACKGROUND = "repeating-linear-gradient(45deg, #423d01, #423d01 10px, #000000 10px, #000000 20px)"; +const DEV_QC_BACKGROUND = + "repeating-linear-gradient(45deg, #423d01, #423d01 10px, #000000 10px, #000000 20px)" const colorStyle = { color: "white", @@ -110,17 +117,16 @@ const titleStyle = { lineHeight: "unset", padding: 0, margin: 0, -}; +} -export const mapStateToProps = state => ({ +export const mapStateToProps = (state) => ({ userID: state.auth.currentUserID, usersByID: state.users.itemsByID, -}); +}) -export const actionCreators = { logOut }; +export const actionCreators = { logOut } -const App = ({userID, usersByID, logOut }) => { - /* global FMS_ENV */ +const App = ({ userID, usersByID, logOut }) => { const env = FMS_ENV const dispatch = useAppDispatch() const isInitialized = useAppSelector(selectAppInitialized) @@ -141,27 +147,28 @@ const App = ({userID, usersByID, logOut }) => { if (isLoggedIn) { loadInitialData() } - }, [userID, isLoggedIn, dispatch]); + }, [userID, isLoggedIn, dispatch]) useRefreshHook(isLoggedIn) - const user = usersByID[userID]; + const user = usersByID[userID] - const menuItems = getMenuItems(user, logOut); + const menuItems = getMenuItems(user, logOut) - useEffect(onDidMount, []); + useEffect(onDidMount, []) // Logout the user after 12 hours in all cases where the tab stays open - useUserInputExpiration(logOut, 12 * hour); + useUserInputExpiration(logOut, 12 * hour) - const loadingIcon = + const loadingIcon = const navigateToWorkflowAssignment = useNavigateToWorkflowAssignment() /** * @type {import("../../utils/menus").BadMenuItem[]} */ - const MENU_ITEMS = useMemo(() => [ + const MENU_ITEMS = useMemo( + () => [ { url: "/dashboard", icon: , @@ -175,10 +182,23 @@ const App = ({userID, usersByID, logOut }) => { key: "lab-work", }, { - url: "/projects", icon: , text: "Projects", key: "projects", + children: [ + { + icon: , + url: "/external-projects-overview", + text: "External Overview", + key: "external-project-overview", + }, + { + icon: , + url: "/projects", + text: "Internal Projects", + key: "internal-project", + }, + ], }, { icon: , @@ -203,7 +223,7 @@ const App = ({userID, usersByID, logOut }) => { text: "Rename Sample", key: "sample-rename", }, - ] + ], }, { url: "/containers", @@ -276,7 +296,7 @@ const App = ({userID, usersByID, logOut }) => { text: "Workflows", key: "workflows", }, - ] + ], }, { url: "/individuals", @@ -296,13 +316,14 @@ const App = ({userID, usersByID, logOut }) => { text: "Users", key: "users", }, - ] - , [navigateToWorkflowAssignment]) + ], + [navigateToWorkflowAssignment], + ) return ( - {isLoggedIn && + {isLoggedIn && ( { collapsedWidth={80} // Ant requires a width, so pick one relative to the sidebar font-size. You can use 'auto' but then // the sidebar width changes whenever a submenu is expanded or collapsed. - width={'17em'} - style={{ overflow: 'auto' }} + width={"17em"} + style={{ overflow: "auto" }} > -
-
+
+
- <div style={{marginRight: '0.25rem', paddingRight: '0.25rem'}}> - <b>F</b><span>reeze</span><b>M</b><span>an</span> - {env !== 'PROD' && <span style={{ color: 'red', fontSize: '14px' }}> {env}</span>} + <div style={{ marginRight: "0.25rem", paddingRight: "0.25rem" }}> + <b>F</b> + <span>reeze</span> + <b>M</b> + <span>an</span> + {env !== "PROD" && ( + <span style={{ color: "red", fontSize: "14px" }}> {env}</span> + )} </div> - { // Display a spinner while the initial data is being fetched at startup - !isInitialized && -
- + { + // Display a spinner while the initial data is being fetched at startup + !isInitialized && ( +
+
+ ) }
- {isLoggedIn && -
+ {isLoggedIn && ( +
- } + )} { defaultOpenKeys={MENU_ITEMS.filter((i) => i.children).map((i) => i.key)} // Submenus should be open by default items={MENU_ITEMS.map(resolveBadMenuItem)} /> - {isLoggedIn && + {isLoggedIn && ( { style={{ flex: 1 }} items={menuItems.map(resolveBadMenuItem)} /> - } + )} - } + )} } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - } /> - - - - }/> - - - - } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> } /> - ); -}; + ) +} -export default withRouter(connect(mapStateToProps, actionCreators)(App)); +export default withRouter(connect(mapStateToProps, actionCreators)(App)) // Helpers function onDidMount() { - const title = document.querySelector('.App__title') + const title = document.querySelector(".App__title") if (title) { - const span = title.querySelectorAll('span')[0] - span.style.width = span.getBoundingClientRect().width + 'px' + const span = title.querySelectorAll("span")[0] + span.style.width = span.getBoundingClientRect().width + "px" } } function withRouter(Child) { - // eslint-disable-next-line react/display-name return (props) => { - const location = useLocation(); - const navigate = useNavigate(); - return ; + const location = useLocation() + const navigate = useNavigate() + return } } diff --git a/frontend/src/components/projectOverview/ExternalIDProjectDashboard.tsx b/frontend/src/components/projectOverview/ExternalIDProjectDashboard.tsx new file mode 100644 index 0000000000..55b6300e02 --- /dev/null +++ b/frontend/src/components/projectOverview/ExternalIDProjectDashboard.tsx @@ -0,0 +1,94 @@ +import React, { useMemo } from "react" +import { + CheckCircleOutlined, + FolderOpenOutlined, + TeamOutlined, + UserOutlined, +} from "@ant-design/icons" +import { Card, Col, Row, Statistic, Tag } from "antd" + +import { FMSProject } from "../../models/fms_api_models" + +interface ExternalIDProjectsDashboardProps { + data: FMSProject[] +} + +const dashboardCardStyle: React.CSSProperties = { + height: "100%", + border: "1px solid #d9d9d9", + boxShadow: "0 1px 3px rgba(0, 0, 0, 0.08)", +} + +const iconStyle = (color: string): React.CSSProperties => ({ + color, + fontSize: 24, + padding: 8, + borderRadius: 8, + background: `${color}15`, +}) + +const ExternalIDProjectsDashboard = ({ data }: ExternalIDProjectsDashboardProps) => { + const total = data.length + + const openCount = useMemo( + () => data.filter((project) => project.status === "Open").length, + [data], + ) + + const uniquePIs = useMemo( + () => new Set(data.map((project) => project.principal_investigator).filter(Boolean)).size, + [data], + ) + + const uniqueRequestors = useMemo( + () => new Set(data.map((project) => project.requestor_name).filter(Boolean)).size, + [data], + ) + + return ( + + + + } + /> + + + + + + } + suffix={Open} + /> + + + + + + } + /> + + + + + + } + /> + + + + ) +} + +export default ExternalIDProjectsDashboard diff --git a/frontend/src/components/projectOverview/ExternalIDReadSetDashboard.tsx b/frontend/src/components/projectOverview/ExternalIDReadSetDashboard.tsx new file mode 100644 index 0000000000..1acde590be --- /dev/null +++ b/frontend/src/components/projectOverview/ExternalIDReadSetDashboard.tsx @@ -0,0 +1,410 @@ +import React, { useMemo } from "react" +import { Card, Col, Progress, Row, Space, Statistic, Tooltip, Typography } from "antd" +import { + CheckCircleOutlined, + ClusterOutlined, + DatabaseOutlined, + InfoCircleOutlined, + ExperimentOutlined, + TeamOutlined, +} from "@ant-design/icons" +import { Column } from "@ant-design/charts" +import { ProjectOverviewReadset } from "./types" + +const { Text } = Typography + +const getQcCompletenessData = (items: ProjectOverviewReadset[]) => { + const total = items.length + + const complete = items.filter((item) => { + return ( + item.average_quality !== null && + item.average_quality !== undefined && + item.pf_reads_aligned !== null && + item.pf_reads_aligned !== undefined && + item.duplicate_aligned !== null && + item.duplicate_aligned !== undefined + ) + }).length + + const incomplete = items.length - complete + + return { + complete: total === 0 ? 0 : Math.round((complete / total) * 100), + incomplete: total === 0 ? 0 : Math.round((incomplete / total) * 100), + completeCount: complete, + incompleteCount: incomplete, + } +} + +const iconStyle = (color: string, backgroundColor: string): React.CSSProperties => ({ + color, + backgroundColor, + fontSize: 18, + padding: 6, + borderRadius: 8, + marginRight: 4, +}) + +function ExternalIDReadSetDashboard({ readsets }: { readsets: ProjectOverviewReadset[] }) { + const metrics = useMemo(() => { + // Avg. Alignment Calculation + const readsetsWithAlignment = readsets.filter((readset) => readset.pf_reads_aligned !== null) + + const allAlignmentsHaveNumberOfReads = readsetsWithAlignment.every( + (readset) => readset.number_of_reads !== null && Number(readset.number_of_reads) > 0, + ) + + const simpleAverageAlignment = + readsetsWithAlignment.length === 0 + ? null + : readsetsWithAlignment.reduce( + (sum, readset) => sum + Number(readset.pf_reads_aligned), + 0, + ) / readsetsWithAlignment.length + + const totalNumberOfReadsForAlignment = readsetsWithAlignment.reduce( + (sum, readset) => sum + Number(readset.number_of_reads), + 0, + ) + + const weightedAlignmentSum = readsetsWithAlignment.reduce( + (sum, readset) => sum + Number(readset.pf_reads_aligned) * Number(readset.number_of_reads), + 0, + ) + + const averageAlignment = + readsetsWithAlignment.length === 0 + ? null + : allAlignmentsHaveNumberOfReads + ? weightedAlignmentSum / totalNumberOfReadsForAlignment + : simpleAverageAlignment + + // Avg. Quality Calculation + const readsetsWithQuality = readsets.filter((readset) => readset.average_quality !== null) + + const allHaveNumberOfBases = readsetsWithQuality.every( + (readset) => readset.number_of_bases !== null && Number(readset.number_of_bases) > 0, + ) + + const simpleAverageQuality = + readsetsWithQuality.length === 0 + ? null + : readsetsWithQuality.reduce((sum, readset) => sum + Number(readset.average_quality), 0) / + readsetsWithQuality.length + + const totalNumberOfBases = readsetsWithQuality.reduce( + (sum, readset) => sum + Number(readset.number_of_bases), + 0, + ) + + const weightedQualitySum = readsetsWithQuality.reduce( + (sum, readset) => sum + Number(readset.average_quality) * Number(readset.number_of_bases), + 0, + ) + + const averageQuality = + readsetsWithQuality.length === 0 + ? null + : allHaveNumberOfBases + ? weightedQualitySum / totalNumberOfBases + : simpleAverageQuality + + // Avg. Duplication Calculation + const readsetsWithDuplication = readsets.filter((readset) => readset.duplicate_aligned !== null) + + const allDuplicationsHaveNumberOfReads = readsetsWithDuplication.every( + (readset) => readset.number_of_reads !== null && Number(readset.number_of_reads) > 0, + ) + + const simpleAverageDuplication = + readsetsWithDuplication.length === 0 + ? null + : readsetsWithDuplication.reduce( + (sum, readset) => sum + Number(readset.duplicate_aligned), + 0, + ) / readsetsWithDuplication.length + + const totalNumberOfReadsForDuplication = readsetsWithDuplication.reduce( + (sum, readset) => sum + Number(readset.number_of_reads), + 0, + ) + + const weightedDuplicationSum = readsetsWithDuplication.reduce( + (sum, readset) => sum + Number(readset.duplicate_aligned) * Number(readset.number_of_reads), + 0, + ) + + const averageDuplication = + readsetsWithDuplication.length === 0 + ? null + : allDuplicationsHaveNumberOfReads + ? weightedDuplicationSum / totalNumberOfReadsForDuplication + : simpleAverageDuplication + + return { + totalReadsets: readsets.length, + totalReads: readsets.reduce((sum, x) => sum + Number(x.number_of_reads || 0), 0), + totalRuns: new Set(readsets.map((x) => x.run_name)).size, + totalSamples: new Set( + readsets.map((x) => x.biosample_id).filter((id) => id !== null && id !== undefined), + ).size, + totalCohorts: new Set(readsets.map((x) => x.cohort)).size, + avgQuality: averageQuality, + avgAlignment: averageAlignment, + avgDuplication: averageDuplication, + } + }, [readsets]) + + const qcCompleteness = useMemo(() => getQcCompletenessData(readsets), [readsets]) + + const libraryTypeData = useMemo(() => { + const grouped = new Map() + + readsets.forEach((item) => { + const libraryType = item.library_type?.trim() || "Unknown" + + grouped.set(libraryType, (grouped.get(libraryType) || 0) + 1) + }) + + return Array.from(grouped.entries()).map(([libraryType, count]) => ({ + libraryType, + count, + })) + }, [readsets]) + + return ( +
+ + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + + + + + + + } + /> + + + + + + } + /> + + + + + + + + + + + + + + + + + + + + + Run Statistics + + } + > + + + + + + + + + + QC Completeness + + + + + } + > + + Complete QC Metrics + + + Missing QC Metrics + + + + {qcCompleteness.completeCount} / {readsets.length} fully usable readsets + + + + + + + + + + + + Quality Statistics + + } + > + + + + + + {""} + + + + + + + + + + + + + + +
+ ) +} + +export default ExternalIDReadSetDashboard diff --git a/frontend/src/components/projectOverview/ExternalProjectDetailsPage.tsx b/frontend/src/components/projectOverview/ExternalProjectDetailsPage.tsx new file mode 100644 index 0000000000..b095c3b768 --- /dev/null +++ b/frontend/src/components/projectOverview/ExternalProjectDetailsPage.tsx @@ -0,0 +1,163 @@ +import { Alert, Tabs } from "antd" + +import React, { useCallback, useState, useEffect } from "react" + +import useHashURL from "../../hooks/useHashURL" +import AppPageHeader from "../AppPageHeader" +import PageContent from "../PageContent" + +import ProjectSubmissionsTab from "./ProjectSubmissionsTab" +import ProjectReadSetsTab from "./ProjectReadSetsTab" + +import api from "../../utils/api" + +import { useAppDispatch } from "../../hooks" +import { useParams, useNavigate } from "react-router-dom" +import { FMSParentProject, FMSProject } from "../../models/fms_api_models" + +const MAX_PROJECT_NAME_LENGTH = 60 + +// Convertit en nombre l’ID du projet parent reçu dans l’URL. +const parseParentProjectID = (parentProjectID: string): number => { + return Number(parentProjectID) +} + +const ExternalProjectDetailsPage = () => { + const { parentProjectId: paramParentProjectId } = useParams() + const navigate = useNavigate() + const parentProjectId = paramParentProjectId ? parseParentProjectID(paramParentProjectId) : null + + const [parentProject, setParentProject] = useState(null) + const [internalProjects, setInternalProjects] = useState([]) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + + const dispatch = useAppDispatch() + + // Charge depuis l’API le projet parent correspondant à l’ID reçu. + const fetchParentProject = useCallback( + async (parentProjectId: number): Promise => { + const response = await dispatch(api.parentProjects.get(parentProjectId)) + return response.data + }, + [dispatch], + ) + + // Charge les projets internes correspondant aux IDs associés au projet parent. + const fetchInternalProjectsByIDs = useCallback( + async (projectIDs: number[]): Promise => { + if (projectIDs.length === 0) { + return [] + } + const response = await dispatch( + api.projects.list( + { + id__in: projectIDs.join(","), + limit: 100000, + }, + true, + ), + ) + + return response.data.results + }, + [dispatch], + ) + + // Charge le projet parent et tous les projets internes qui lui sont associés. + const fetchParentProjectWithInternalProjects = useCallback( + async (parentProjectId: number): Promise => { + try { + setIsLoading(true) + setError(null) + + const fetchedParentProject = await fetchParentProject(parentProjectId) + setParentProject(fetchedParentProject) + + const internalProjectIDs = fetchedParentProject.projects ?? [] + const fetchedInternalProjects = await fetchInternalProjectsByIDs(internalProjectIDs) + setInternalProjects(fetchedInternalProjects) + } catch (requestError) { + setParentProject(null) + setInternalProjects([]) + + if (!(requestError instanceof Error && requestError.name === "AbortError")) { + setError("Unable to fetch the external project") + } + } finally { + setIsLoading(false) + } + }, + [fetchParentProject, fetchInternalProjectsByIDs], + ) + + useEffect(() => { + if (parentProjectId === null) { + navigate("/external-projects-overview", { + replace: true, + }) + return + } + + fetchParentProjectWithInternalProjects(parentProjectId) + }, [parentProjectId, fetchParentProjectWithInternalProjects, navigate]) + + const [activeKey, setActiveKey] = useHashURL("projects") + const externalID = parentProject?.external_id ?? "" + + const projectName = parentProject?.name ?? "" + + const displayedProjectName = + projectName.length > MAX_PROJECT_NAME_LENGTH + ? `${projectName.slice(0, MAX_PROJECT_NAME_LENGTH)}…` + : projectName + + return ( + <> + + + + {error && } + + + ), + }, + { + label: "Read Sets", + key: "readsets", + children: ( + + ), + }, + ]} + /> + + + ) +} + +export default ExternalProjectDetailsPage diff --git a/frontend/src/components/projectOverview/ExternalProjectsPage.tsx b/frontend/src/components/projectOverview/ExternalProjectsPage.tsx new file mode 100644 index 0000000000..5b7f025a7c --- /dev/null +++ b/frontend/src/components/projectOverview/ExternalProjectsPage.tsx @@ -0,0 +1,276 @@ +import { Table, Tag } from "antd" +import type { ColumnsType } from "antd/es/table" +import React, { useCallback, useEffect, useMemo, useState } from "react" +import AppPageHeader from "../AppPageHeader" +import { Link } from "react-router-dom" +import { FMSProject, FMSParentProject } from "../../models/fms_api_models" +import api from "../../utils/api" + +import PageContent from "../PageContent" + +import { useAppDispatch } from "../../hooks" + +import FiltersBar from "../filters/filtersBar/FiltersBar" +import { FilterDescription, FilterSet, SetFilterFunc } from "../../models/paged_items" + +import { getFilterPropsForDescription } from "../filters/getFilterPropsTS" +import { setFilterValue } from "../../models/filter_set_reducers" + +const EXTERNAL_PROJECT_NAME_FILTER_KEY = "external_project_name" +const EXTERNAL_PROJECT_ID_FILTER_KEY = "external_project_id" + +const EXTERNAL_PROJECT_NAME_FILTER_DESCRIPTION: FilterDescription = { + type: "INPUT", + key: EXTERNAL_PROJECT_NAME_FILTER_KEY, + label: "External Project Name", + width: 260, +} +const EXTERNAL_PROJECT_ID_FILTER_DESCRIPTION: FilterDescription = { + type: "INPUT", + key: EXTERNAL_PROJECT_ID_FILTER_KEY, + label: "External Project ID", + width: 260, +} + +const internalProjectColumns: ColumnsType = [ + { + title: "ID", + dataIndex: "id", + key: "id", + render: (id: number) => {id}, + }, + { + title: "Project Name", + dataIndex: "name", + key: "name", + render: (name: string, project: FMSProject) => ( + {name} + ), + }, + { + title: "Principal Investigator", + dataIndex: "principal_investigator", + key: "principal_investigator", + }, + { + title: "Requestor Name", + dataIndex: "requestor_name", + key: "requestor_name", + }, + { + title: "Status", + dataIndex: "status", + key: "status", + }, + { + title: "Created At", + dataIndex: "created_at", + key: "created_at", + render: (createdAt: string) => + createdAt + ? new Date(createdAt).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }) + : "", + }, +] + +// Organise les projets internes dans un objet afin de pouvoir retrouver rapidement chaque projet à partir de son ID. +const indexProjectsByID = (projects: FMSProject[]): Partial> => + projects.reduce>>((projectsByID, project) => { + projectsByID[project.id] = project + return projectsByID + }, {}) + +// Récupère les IDs uniques des projets internes associés aux projets externes. +const getUniqueInternalProjectIDs = (parentProjects: FMSParentProject[]): number[] => [ + ...new Set(parentProjects.flatMap((parentProject) => parentProject.projects ?? [])), +] + +const ExternalProjectsPage = () => { + const [parentProjects, setParentProjects] = useState([]) + const [internalProjectsByID, setInternalProjectsByID] = useState< + Partial> + >({}) + + const [isLoading, setIsLoading] = useState(false) + const [filters, setFilters] = useState({}) + + const setFilter = useCallback((filterKey, value, description) => { + setFilters((currentFilters) => setFilterValue(currentFilters, description, value)) + }, []) + + const clearFilters = useCallback(() => { + setFilters({}) + }, []) + + const parentProjectColumns = useMemo>( + () => [ + { + title: "External Project ID", + dataIndex: "external_id", + key: "external_id", + width: 120, + filteredValue: filters[EXTERNAL_PROJECT_ID_FILTER_KEY]?.value + ? [String(filters[EXTERNAL_PROJECT_ID_FILTER_KEY].value)] + : null, + ...getFilterPropsForDescription( + EXTERNAL_PROJECT_ID_FILTER_DESCRIPTION, + filters[EXTERNAL_PROJECT_ID_FILTER_KEY], + setFilter, + ), + onFilter: (value, record) => + (record.external_id || "").toLowerCase().includes(String(value).toLowerCase()), + + render: (externalID: string, parentProject: FMSParentProject) => ( + {externalID} + ), + }, + { + title: "External Project Name", + dataIndex: "name", + key: "external_project_name", + filteredValue: filters[EXTERNAL_PROJECT_NAME_FILTER_KEY]?.value + ? [String(filters[EXTERNAL_PROJECT_NAME_FILTER_KEY].value)] + : null, + ...getFilterPropsForDescription( + EXTERNAL_PROJECT_NAME_FILTER_DESCRIPTION, + filters[EXTERNAL_PROJECT_NAME_FILTER_KEY], + setFilter, + ), + onFilter: (value, record) => + (record.name || "").toLowerCase().includes(String(value).toLowerCase()), + render: (externalProjectName: string | null) => externalProjectName || "", + }, + { + title: "Freezeman Projects", + dataIndex: "projects", + key: "projects", + width: 20, + render: (projects: FMSParentProject["projects"]) => { + const projectCount = projects?.length ?? 0 + return 1 ? "blue" : "default"}>{projectCount} + }, + }, + ], + [filters, setFilter], + ) + + const dispatch = useAppDispatch() + + // Charge depuis l’API la liste des projets parents, triés par identifiant externe. + const fetchParentProjects = useCallback(async (): Promise => { + const response = await dispatch( + api.parentProjects.list( + { + limit: 100000, + ordering: "external_id", + }, + true, + ), + ) + return response.data.results + }, [dispatch]) + + // Charge depuis l’API les projets internes correspondant aux IDs reçus, + // ou retourne une liste vide si aucun ID n’est fourni. + const fetchInternalProjectsByIDs = useCallback( + async (projectIDs: number[]): Promise => { + if (projectIDs.length === 0) { + return [] + } + + const response = await dispatch( + api.projects.list( + { + id__in: projectIDs.join(","), + limit: 100000, + }, + true, + ), + ) + + return response.data.results + }, + [dispatch], + ) + + const fetchParentProjectsWithInternalProjects = useCallback(async () => { + try { + setIsLoading(true) + + const fetchedParentProjects = await fetchParentProjects() + setParentProjects(fetchedParentProjects) + + const internalProjectIDs = getUniqueInternalProjectIDs(fetchedParentProjects) + const fetchedInternalProjects = await fetchInternalProjectsByIDs(internalProjectIDs) + const fetchedInternalProjectsByID = indexProjectsByID(fetchedInternalProjects) + setInternalProjectsByID(fetchedInternalProjectsByID) + } catch (error) { + if (error instanceof Error && error.name !== "AbortError") { + setParentProjects([]) + setInternalProjectsByID({}) + } + } finally { + setIsLoading(false) + } + }, [fetchParentProjects, fetchInternalProjectsByIDs]) + + useEffect(() => { + fetchParentProjectsWithInternalProjects() + }, [fetchParentProjectsWithInternalProjects]) + + return ( + <> + + + +
+ +
+ { + const internalProjects = (parentProject.projects ?? []).reduce( + (projects, projectID) => { + const project = internalProjectsByID[projectID] + if (project) { + projects.push(project) + } + return projects + }, + [], + ) + return ( +
+ ) + }, + }} + pagination={{ + pageSize: 20, + showSizeChanger: true, + pageSizeOptions: ["20", "50", "100"], + showTotal: (total, range) => `${range[0]}-${range[1]} of ${total} external IDs`, + }} + /> + + + ) +} + +export default ExternalProjectsPage diff --git a/frontend/src/components/projectOverview/ProjectOverviewExportButton.tsx b/frontend/src/components/projectOverview/ProjectOverviewExportButton.tsx new file mode 100644 index 0000000000..f9ff15394c --- /dev/null +++ b/frontend/src/components/projectOverview/ProjectOverviewExportButton.tsx @@ -0,0 +1,21 @@ +import React from "react" +import ExportButton from "../ExportButton" +import { ProjectOverviewExportButtonData } from "./types" + +interface ProjectOverviewExportButtonProps { + data: ProjectOverviewExportButtonData +} + +const ProjectOverviewExportButton = ({ data }: ProjectOverviewExportButtonProps) => { + return ( + + ) +} + +export default ProjectOverviewExportButton diff --git a/frontend/src/components/projectOverview/ProjectReadSetsTab.tsx b/frontend/src/components/projectOverview/ProjectReadSetsTab.tsx new file mode 100644 index 0000000000..39cb635ac0 --- /dev/null +++ b/frontend/src/components/projectOverview/ProjectReadSetsTab.tsx @@ -0,0 +1,551 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react" +import dayjs, { Dayjs } from "dayjs" +import { ProjectOverviewExportButtonData, ProjectOverviewReadset } from "./types" +import ExternalIDReadSetDashboard from "./ExternalIDReadSetDashboard" +import api from "../../utils/api" +import { useAppDispatch } from "../../hooks" + +import type { ColumnsType } from "antd/es/table" +import type { FilterDropdownProps } from "antd/es/table/interface" +import { Alert, Button, DatePicker, Empty, Input, Spin, Table, Tag, Typography } from "antd" +import { CopyOutlined, SearchOutlined, CheckCircleTwoTone, FilterOutlined } from "@ant-design/icons" +import ProjectOverviewExportButton from "./ProjectOverviewExportButton" +import { useCreateCsvExportFunction } from "./useCsvExport" +import LaneValidationStatus from "../experimentRuns/LaneValidationStatus" +import { ValidationStatus } from "../../modules/experimentRunLanes/models" + +const { Text } = Typography + +interface ProjectReadSetsTabProps { + parentProjectId: number | null + externalID: string + isActive: boolean +} +const compactHeaderCell = () => ({ + style: { + padding: "4px 8px", + lineHeight: "16px", + height: 20, + }, +}) + +const nowrapCell = { + style: { + whiteSpace: "nowrap", + }, +} + +function CopyableReadsetFilePath({ file }: { file: string }) { + const [copiedToClipboard, setCopiedToClipboard] = useState(false) + + const handleCopy = async (event: React.MouseEvent) => { + event.stopPropagation() + await navigator.clipboard.writeText(file) + setCopiedToClipboard(true) + + setTimeout(() => { + setCopiedToClipboard(false) + }, 2000) + } + + return ( +
+ {file} +
+ ) +} + +const getProjectOverviewReadsetColumns = ( + libraryTypeFilters: { text: string; value: string }[], +): ColumnsType => [ + { + title: "ID", + dataIndex: "id", + key: "id", + //fixed: 'left', + width: 70, + onHeaderCell: compactHeaderCell, + onCell: () => nowrapCell, + render: (id: number) => {id}, + }, + { + title: "Readset", + dataIndex: "name", + key: "name", + //fixed: 'left', + width: 450, + onHeaderCell: compactHeaderCell, + render: (name: string) => {name}, + }, + { + title: "Sample", + dataIndex: "readset_sample_name", + key: "readset_sample_name", + width: 450, + onHeaderCell: compactHeaderCell, + filterIcon: (filtered) => ( + + ), + filterDropdown: ({ + setSelectedKeys, + selectedKeys, + confirm, + clearFilters, + }: FilterDropdownProps) => ( +
+ { + setSelectedKeys(event.target.value ? [event.target.value] : []) + }} + onPressEnter={() => confirm()} + style={{ + marginBottom: 8, + display: "block", + }} + /> + + + + +
+ ), + onFilter: (value, record) => + String(record.readset_sample_name ?? "") + .toLowerCase() + .includes(String(value).toLowerCase()), + }, + { + title: "Alias", + dataIndex: "alias", + key: "alias", + width: 450, + onHeaderCell: compactHeaderCell, + render: (alias: string | null) => alias || N/A, + }, + { + title: "Cohort", + dataIndex: "cohort", + key: "cohort", + width: 120, + onHeaderCell: compactHeaderCell, + render: (cohort: string | null) => cohort || N/A, + }, + { + title: "Library Type", + dataIndex: "library_type", + key: "library_type", + width: 140, + onHeaderCell: compactHeaderCell, + filters: libraryTypeFilters, + filterIcon: (filtered) => ( + + ), + onFilter: (value, record) => record.library_type === value, + render: (libraryType: string | null) => + libraryType ? {libraryType} : N/A, + }, + { + title: "Run", + dataIndex: "run_name", + key: "run_name", + width: 260, + onHeaderCell: compactHeaderCell, + filterIcon: (filtered) => ( + + ), + filterDropdown: ({ + setSelectedKeys, + selectedKeys, + confirm, + clearFilters, + }: FilterDropdownProps) => ( +
+ { + setSelectedKeys(event.target.value ? [event.target.value] : []) + }} + onPressEnter={() => confirm()} + style={{ marginBottom: 8, display: "block" }} + /> + + +
+ ), + + onFilter: (value, record) => + String(record.run_name ?? "") + .toLowerCase() + .includes(String(value).toLowerCase()), + }, + { + title: "Run Start", + dataIndex: "run_start_date", + key: "run_start_date", + width: 120, + onHeaderCell: compactHeaderCell, + filterIcon: (filtered) => ( + + ), + filterDropdown: ({ + setSelectedKeys, + selectedKeys, + confirm, + clearFilters, + }: FilterDropdownProps) => ( +
+ { + const [startDate, endDate] = String(selectedKeys[0]).split("|") + + return startDate && endDate + ? ([dayjs(startDate), dayjs(endDate)] as [Dayjs, Dayjs]) + : null + })() + : null + } + onChange={(dates) => { + if (!dates || !dates[0] || !dates[1]) { + setSelectedKeys([]) + return + } + + setSelectedKeys([`${dates[0].format("YYYY-MM-DD")}|${dates[1].format("YYYY-MM-DD")}`]) + }} + /> + + +
+ ), + + onFilter: (value, record) => { + const [startDate, endDate] = String(value).split("|") + + if (!startDate || !endDate) { + return true + } + + return record.run_start_date >= startDate && record.run_start_date <= endDate + }, + }, + { + title: "Validation Status", + dataIndex: "run_validation_status", + key: "run_validation_status", + width: 170, + onHeaderCell: compactHeaderCell, + render: (validationStatus: ValidationStatus | null) => + validationStatus === null ? ( + N/A + ) : ( + + ), + }, + // { + // title: 'Container Barcodes', + // dataIndex: 'barcodes', + // key: 'barcodes', + // width: 280, + // onHeaderCell: compactHeaderCell, + // render: (barcodes: string[]) => + // barcodes?.length ? ( + // + // {barcodes.map((barcode) => ( + // {barcode} + // ))} + // + // ) : ( + // N/A + // ), + // }, + { + title: "Reads", + dataIndex: "number_of_reads", + key: "number_of_reads", + align: "right", + width: 180, + onHeaderCell: compactHeaderCell, + render: (reads: number | null) => + reads !== null ? reads.toLocaleString("fr-CA") : N/A, + }, + { + title: "Avg Quality", + dataIndex: "average_quality", + key: "average_quality", + align: "right", + width: 100, + onHeaderCell: compactHeaderCell, + render: (value: string | null) => + value !== null ? Number(value).toFixed(2) : N/A, + }, + { + title: "% PF Aligned", + dataIndex: "pf_reads_aligned", + key: "pf_reads_aligned", + align: "right", + width: 100, + onHeaderCell: compactHeaderCell, + render: (value: string | null) => + value !== null ? `${(Number(value) * 100).toFixed(2)}` : N/A, + }, + { + title: "% Duplicate", + dataIndex: "duplicate_aligned", + key: "duplicate_aligned", + align: "right", + width: 100, + onHeaderCell: compactHeaderCell, + render: (value: string | null) => + value !== null ? `${(Number(value) * 100).toFixed(2)}` : N/A, + }, + { + title: "Readset Files", + dataIndex: "readset_files", + key: "readset_files", + onHeaderCell: compactHeaderCell, + render: (files?: ProjectOverviewReadset["readset_files"] | null) => + files?.length ? ( +
+ {files.map((file, index) => + file.file_path ? ( +
+ + + {file.size !== null && file.size !== undefined + ? `${(Number(file.size) / 1024 / 1024).toFixed(2)} MB` + : "N/A"} + +
+ ) : null, + )} +
+ ) : ( + N/A + ), + }, +] + +const formatReadsetFilesForCsv = (files: ProjectOverviewReadset["readset_files"]): string => { + if (!files?.length) { + return "" + } + + return files + .flatMap((file) => { + if (!file.file_path) { + return [] + } + + if (file.size === null || file.size === undefined) { + return [file.file_path] + } + + const sizeInMb = (Number(file.size) / 1024 / 1024).toFixed(2) + return [`${file.file_path} (${sizeInMb} MB)`] + }) + .join("; ") +} + +function ProjectReadSetsTab({ parentProjectId, externalID, isActive }: ProjectReadSetsTabProps) { + const [projectOverviewReadsets, setProjectOverviewReadsets] = useState( + [], + ) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + + const dispatch = useAppDispatch() + + // Charge les Read Sets associés au projet parent donné. + const fetchReadsetsByParentProjectID = useCallback( + async (parentProjectId: number): Promise => { + const response = await dispatch( + api.parentProjects.readsets( + parentProjectId, + { + limit: 100000, + }, + true, + ), + ) + + return response.data.results + }, + [dispatch], + ) + + // Charge les Read Sets du projet parent et met à jour l’état du composant. + const loadParentProjectReadsets = useCallback( + async (parentProjectId: number): Promise => { + try { + setIsLoading(true) + setError(null) + + const fetchedReadsets = await fetchReadsetsByParentProjectID(parentProjectId) + setProjectOverviewReadsets(fetchedReadsets) + } catch (error) { + setProjectOverviewReadsets([]) + setError(error instanceof Error ? error.message : "Failed to fetch read sets") + } finally { + setIsLoading(false) + } + }, + [fetchReadsetsByParentProjectID], + ) + + useEffect(() => { + if (!isActive) { + return + } + + if (parentProjectId === null) { + setProjectOverviewReadsets([]) + setError("Invalid parent project ID") + return + } + + loadParentProjectReadsets(parentProjectId) + }, [isActive, parentProjectId, loadParentProjectReadsets]) + + const exportReadsets = useMemo( + () => + projectOverviewReadsets.map((readset) => ({ + ...readset, + readset_files: formatReadsetFilesForCsv(readset.readset_files), + })), + [projectOverviewReadsets], + ) + + const generateCsvContent = useCreateCsvExportFunction(exportReadsets) + + const libraryTypeFilters = Array.from( + new Set( + projectOverviewReadsets + .map((readset) => readset.library_type) + .filter((libraryType): libraryType is string => Boolean(libraryType)), + ), + ).map((libraryType) => ({ + text: libraryType, + value: libraryType, + })) + + const projectOverviewReadsetColumns = useMemo( + () => getProjectOverviewReadsetColumns(libraryTypeFilters), + [libraryTypeFilters], + ) + + if (isLoading) { + return + } + + if (error) { + return + } + + const exportButtonData: ProjectOverviewExportButtonData = { + exportType: "Project Readsets", + exportFunction: generateCsvContent, + filename: "Project Readsets", + itemsCount: projectOverviewReadsets.length, + disabled: projectOverviewReadsets.length === 0, + } + + return ( + <> + {!isLoading && isActive && } + {!isLoading && projectOverviewReadsets.length > 0 && ( +
+ +
+ )} + {projectOverviewReadsets.length > 0 ? ( +
`${range[0]}-${range[1]} of ${total} readsets`, + }} + /> + ) : ( + + )} + + ) +} + +export default ProjectReadSetsTab diff --git a/frontend/src/components/projectOverview/ProjectSubmissionsTab.tsx b/frontend/src/components/projectOverview/ProjectSubmissionsTab.tsx new file mode 100644 index 0000000000..0daec1c15d --- /dev/null +++ b/frontend/src/components/projectOverview/ProjectSubmissionsTab.tsx @@ -0,0 +1,129 @@ +import React, { useCallback, useMemo } from "react" + +import ExternalIDProjectsDashboard from "./ExternalIDProjectDashboard" +import { Empty, Table } from "antd" +import { Link } from "react-router-dom" + +import { FMSProject } from "../../models/fms_api_models" + +import { useCreateCsvExportFunction } from "./useCsvExport" +import { ProjectOverviewExportButtonData } from "./types" +import ProjectOverviewExportButton from "./ProjectOverviewExportButton" + +interface ProjectSubmissionsTabProps { + internalProjects: FMSProject[] + isLoading: boolean + externalID: string +} + +const submissionColumns = [ + { + title: "ID", + dataIndex: "id", + key: "id", + render: (id: number) => {id}, + }, + { + title: "Project Submissions Names", + dataIndex: "name", + key: "name", + render: (name: string, project: FMSProject) => ( + {name} + ), + }, + { + title: "External ID", + dataIndex: "external_id", + key: "external_id", + }, + { + title: "Principal Investigator", + dataIndex: "principal_investigator", + key: "principal_investigator", + }, + { + title: "Requestor Name", + dataIndex: "requestor_name", + key: "requestor_name", + }, + { + title: "Status", + dataIndex: "status", + key: "status", + }, + { + title: "Created At", + dataIndex: "created_at", + key: "created_at", + render: (createdAt: string) => + createdAt + ? new Date(createdAt).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }) + : "", + }, +] + +const ProjectSubmissionsTab = ({ + internalProjects, + isLoading, + externalID, +}: ProjectSubmissionsTabProps) => { + const exportProjects = useMemo[]>( + () => + internalProjects.map((project) => ({ + id: project.id, + name: project.name, + external_id: project.external_id ?? "", + principal_investigator: project.principal_investigator, + requestor_name: project.requestor_name, + status: project.status, + created_at: project.created_at, + })), + [internalProjects], + ) + + const generateCsvContent = useCreateCsvExportFunction(exportProjects) + + if (!isLoading && internalProjects.length === 0) { + return + } + + const exportButtonData: ProjectOverviewExportButtonData = { + exportType: "Associated Projects", + exportFunction: generateCsvContent, + filename: "Associated Projects", + itemsCount: internalProjects.length, + disabled: internalProjects.length === 0, + } + + return ( + <> + {!isLoading && ( +
+ +
+ )} + {!isLoading && } + +
`${range[0]}-${range[1]} of ${total} items`, + }} + /> + + ) +} + +export default ProjectSubmissionsTab diff --git a/frontend/src/components/projectOverview/types.ts b/frontend/src/components/projectOverview/types.ts new file mode 100644 index 0000000000..d9f0093127 --- /dev/null +++ b/frontend/src/components/projectOverview/types.ts @@ -0,0 +1,103 @@ +import { FMSProject } from "../../models/fms_api_models" + +export type ExternalIDProjectSample = { + biosample_id: number + id: number + external_id: string + project_id: number + project_name: string + name: string + alias: string | null + container?: string | null + individual: string | null + creation_date?: string | null + collection_site: string | null + comment?: string | null + experimental_group: string[] + volume?: number | null + concentration?: number | null + quality_flag?: boolean | null + quantity_flag?: boolean | null + identity_flag?: boolean | null + number_of_reads: number + last_process_id?: number | null + last_process_name?: string | null + last_process_execution_date?: string | null +} + +export type ExternalIDProjectSamplesSummary = { + total_samples: number + + qc_passed_count: number + qc_review_count: number + missing_qc_count: number + + samples_with_assigned_process_count: number + samples_without_assigned_process_count: number + samples_assigned_to_a_process_rate: number + + total_quantity: number + avg_concentration: number | null + + total_reads: number | null + avg_reads_per_sample: number | null +} + +export type ExternalIDProjectSamplesResponse = { + external_id: string + count: number + summary: ExternalIDProjectSamplesSummary + samples: ExternalIDProjectSample[] +} + +export type ProjectOverviewReadsetFile = { + file_path: string | null + size: number | null +} + +export type ProjectOverviewReadset = { + id: number + name: string + readset_sample_name: string + biosample_id: number | null + external_id: string + run_name: string + run_start_date: string // YYYY-MM-DD + run_validation_status: number | null + + alias: string | null + cohort: string | null + library_type: string | null + + barcodes: string[] + + number_of_reads: number | null + number_of_bases: number | null + + average_quality: string | null + pf_reads_aligned: string | null + duplicate_aligned: string | null + + lane: number + reference_genome_id: number | null + reference_genome_assembly_name: string | null + sequencing_index_name: string | null + + readset_files?: ProjectOverviewReadsetFile[] +} + +export interface ProjectOverviewExportButtonData { + exportType: string + exportFunction: () => Promise + filename: string + itemsCount: number + disabled: boolean +} + +export type ProjectsByExternalIDGroup = { + external_id: string | null + external_id_number: number | null + external_project_name: string | null + project_count: number + projects: FMSProject[] +} diff --git a/frontend/src/components/projectOverview/useCsvExport.ts b/frontend/src/components/projectOverview/useCsvExport.ts new file mode 100644 index 0000000000..c1970f46a6 --- /dev/null +++ b/frontend/src/components/projectOverview/useCsvExport.ts @@ -0,0 +1,128 @@ +import { useCallback, useMemo } from "react" +import { csvEscape } from "./utils" + +// Custom hook that creates the final CSV export function. +// Input: array of objects. +// Output: function returning Promise, ready for ExportButton. +export const useCreateCsvExportFunction = >( + items: T[], +): (() => Promise) => { + //// FUNCTION DEFINITIONS + + ///1-A + + // Returns the object keys as strings. + // Example: { id: 1, name: "A" } -> ["id", "name"] + const getObjectKeys = useCallback((item: T): string[] => { + return Object.keys(item) + }, []) + + // Gets CSV headers from the first item of the array. + // Headers are simple string keys. + // If there are no items, returns []. + const getHeadersFromItems = useCallback( + (items: T[]): string[] => { + if (items.length === 0) { + return [] + } + + return getObjectKeys(items[0]) + }, + [getObjectKeys], + ) + + // Public helper for getting headers. + // It wraps getHeadersFromItems so the rest of the code calls one clear function. + const getHeaders = useCallback( + (items: T[]): string[] => { + return getHeadersFromItems(items) + }, + [getHeadersFromItems], + ) + + ///1-B + // Returns object keys, but typed as keyof T. + // Example: Array<"id" | "name"> instead of string[]. + const getTypedObjectKeys = useCallback((item: T): Array => { + return Object.keys(item) as Array + }, []) + + // Gets export fields from the first item. + // These fields are typed and are used to safely read values from each row. + const getTypedFieldsFromItems = useCallback( + (items: T[]): Array => { + if (items.length === 0) { + return [] + } + + return getTypedObjectKeys(items[0]) + }, + [getTypedObjectKeys], + ) + + // Public helper for getting typed export fields. + const getExportFields = useCallback( + (items: T[]): Array => { + return getTypedFieldsFromItems(items) + }, + [getTypedFieldsFromItems], + ) + + // Converts the items into CSV rows. + // Each item becomes one row. + // Each field becomes one cell in that row. + const formatExportRows = >( + items: T[], + fields: Array, + ) => { + return items.map((item) => + fields.map((field) => { + const value = item[field] + + // Formats date-like fields. + // Note: field is normally a key, so this check only works if field itself is a Date. + if (field instanceof Date) { + return value + ? new Date(String(value)).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }) + : "" + } + + return value + }), + ) + } + + //// FUNCTION CALLS + + // Builds CSV headers once when items change. + const headers = useMemo(() => { + return getHeaders(items) + }, [items]) + + // Builds typed fields once when items change. + const exportFields = useMemo(() => { + return getExportFields(items) + }, [items]) + + // Builds CSV rows once when items or fields change. + const rows = useMemo(() => { + return formatExportRows(items, exportFields) + }, [items, exportFields]) + + // Returns the function used by the export button. + // When called, it creates the final CSV string. + return useCallback(() => { + const csv = [ + headers.map(csvEscape).join(","), + ...rows.map((row) => row.map(csvEscape).join(",")), + ].join("\n") + + return Promise.resolve(csv) + }, [headers, rows]) +} + +////////////////////////////////////////////////////////////// diff --git a/frontend/src/components/projectOverview/utils.ts b/frontend/src/components/projectOverview/utils.ts new file mode 100644 index 0000000000..ec9b6736ab --- /dev/null +++ b/frontend/src/components/projectOverview/utils.ts @@ -0,0 +1,32 @@ +import { Project } from "../../models/frontend_models" + +export const csvEscape = (value: unknown) => { + const stringValue = value == null ? "" : String(value) + return `"${stringValue.replace(/"/g, '""')}"` +} + +/* + * This function formats the project submission rows for CSV export. + * It takes an array of projects and an array of fields to include in the export. + * It returns an array of arrays, where each inner array represents a row in the CSV. + */ + +export const formatProjectSubmissionRows = (projects: Project[], fields: Array) => { + return projects.map((project) => + fields.map((field) => { + const value = project[field] + + if (field === "created_at") { + return value + ? new Date(String(value)).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }) + : "" + } + + return value + }), + ) +} diff --git a/frontend/src/utils/api.ts b/frontend/src/utils/api.ts index 60900971d1..e32952e547 100644 --- a/frontend/src/utils/api.ts +++ b/frontend/src/utils/api.ts @@ -1,21 +1,54 @@ -import {stringify as qs} from "querystring"; -import {API_BASE_PATH} from "../config"; -import { FMSDataset, FMSId, FMSPagedResultsReponse, FMSParentProject, FMSProject, FMSProtocol, FMSReadset, FMSSample, FMSSampleNextStep, FMSSampleNextStepByStudy, FMSStep, FMSStepHistory, FMSStudy, FMSWorkflow, LabworkStepInfo, ReleaseStatus, FMSReportInformation, WorkflowStepOrder, FMSReportData, FMSPooledSample, FMSSampleIdentity, FMSSampleIdentityMatch, FMSBiosample, FMSUser, FMSProfile, FMSSampleLineageGraph, FMSTemplateAction, FMSTemplatePrefillOption, FMSVersion, FMSExperimentRun } from "../models/fms_api_models"; -import { AnyAction, Dispatch } from "redux"; -import { RootState } from "../store"; -import { notifyError } from "../modules/notification/actions"; +import { stringify as qs } from "querystring" +import { API_BASE_PATH } from "../config" +import { + FMSDataset, + FMSId, + FMSPagedResultsReponse, + FMSParentProject, + FMSProject, + FMSProtocol, + FMSReadset, + FMSSample, + FMSSampleNextStep, + FMSSampleNextStepByStudy, + FMSStep, + FMSStepHistory, + FMSStudy, + FMSWorkflow, + LabworkStepInfo, + ReleaseStatus, + FMSReportInformation, + WorkflowStepOrder, + FMSReportData, + FMSPooledSample, + FMSSampleIdentity, + FMSSampleIdentityMatch, + FMSBiosample, + FMSUser, + FMSProfile, + FMSSampleLineageGraph, + FMSTemplateAction, + FMSTemplatePrefillOption, + FMSVersion, + FMSExperimentRun, +} from "../models/fms_api_models" +import { AnyAction, Dispatch } from "redux" +import { RootState } from "../store" +import { notifyError } from "../modules/notification/actions" +import { ProjectOverviewReadset } from "../components/projectOverview/types" const api = { auth: { - token: credentials => post("/token/", credentials), - tokenRefresh: tokens => post("/token/refresh/", tokens), - resetPassword: email => post("/password_reset/", { email }), + token: (credentials) => post("/token/", credentials), + tokenRefresh: (tokens) => post("/token/refresh/", tokens), + resetPassword: (email) => post("/password_reset/", { email }), changePassword: (token, password) => post("/password_reset/confirm/", { token, password }), }, biosamples: { get: (biosampleId: FMSId) => get>(`/biosamples/${biosampleId}/`), - list: (options: QueryParams, abort?: boolean) => get>>(`/biosamples/`, options, { abort }), + list: (options: QueryParams, abort?: boolean) => + get>>(`/biosamples/`, options, { abort }), }, containerKinds: { @@ -23,96 +56,116 @@ const api = { }, containers: { - get: id => get(`/containers/${id}/`), - add: container => post("/containers/", container), - update: container => patch(`/containers/${container.id}/`, container), + get: (id) => get(`/containers/${id}/`), + add: (container) => post("/containers/", container), + update: (container) => patch(`/containers/${container.id}/`, container), list: (options, abort?: boolean) => get("/containers/", options, { abort }), - listExport: options => get("/containers/list_export/", {format: "csv", ...options}), - listParents: id => get(`/containers/${id}/list_parents/`), - listChildren: id => get(`/containers/${id}/list_children/`), - listChildrenRecursively: id => get(`/containers/${id}/list_children_recursively/`), + listExport: (options) => get("/containers/list_export/", { format: "csv", ...options }), + listParents: (id) => get(`/containers/${id}/list_parents/`), + listChildren: (id) => get(`/containers/${id}/list_children/`), + listChildrenRecursively: (id) => get(`/containers/${id}/list_children_recursively/`), template: { actions: () => get(`/containers/template_actions/`), - check: (action, template) => post(`/containers/template_check/`, form({ action, template })), - submit: (action, template) => post(`/containers/template_submit/`, form({ action, template })), + check: (action, template) => post(`/containers/template_check/`, form({ action, template })), + submit: (action, template) => + post(`/containers/template_submit/`, form({ action, template })), }, prefill: { templates: () => get(`/containers/list_prefills/`), - request: (options, template) => filteredpost(`/containers/prefill_template/`, {...options}, form({ template: template })), + request: (options, template) => + filteredpost(`/containers/prefill_template/`, { ...options }, form({ template: template })), }, search: (q, { parent, sample_holding, exact_match, except_kinds }) => get("/containers/search/", { q, parent, sample_holding, exact_match, except_kinds }), }, coordinates: { - get: coordinateId => get(`/coordinates/${coordinateId}/`), + get: (coordinateId) => get(`/coordinates/${coordinateId}/`), list: (options, abort?: boolean) => get("/coordinates/", options, { abort }), search: (q, options) => get("/coordinates/search/", { q, ...options }), }, datasets: { get: (id: FMSDataset["id"]) => get>(`/datasets/${id}/`), - list: (options, abort?: boolean) => get>>("/datasets/", options, { abort }), - setReleaseStatus: ( - id: FMSDataset["id"], - updates: Record, - ) => patch(`/datasets/${id}/set_release_status/`, updates), + list: (options, abort?: boolean) => + get>>("/datasets/", options, { abort }), + setReleaseStatus: (id: FMSDataset["id"], updates: Record) => + patch(`/datasets/${id}/set_release_status/`, updates), addArchivedComment: (id, comment) => post(`/datasets/${id}/add_archived_comment/`, { comment }), - getRootFolder: (id) => get(`/datasets/${id}/get_dataset_files_root_folder/`) + getRootFolder: (id) => get(`/datasets/${id}/get_dataset_files_root_folder/`), }, datasetFiles: { - get: id => get(`/dataset-files/${id}/`), - update: dataset => patch(`/dataset-files/${dataset.id}/`, dataset), + get: (id) => get(`/dataset-files/${id}/`), + update: (dataset) => patch(`/dataset-files/${dataset.id}/`, dataset), list: (options, abort?: boolean) => get("/dataset-files/", options, { abort }), }, derivedSamples: { - get: (derivedSampleId: FMSId) => get>(`/derivedsamples/${derivedSampleId}/`), - list: (options: QueryParams, abort?: boolean) => get>>(`/derivedsamples/`, options, { abort }), + get: (derivedSampleId: FMSId) => + get>(`/derivedsamples/${derivedSampleId}/`), + list: (options: QueryParams, abort?: boolean) => + get>>(`/derivedsamples/`, options, { + abort, + }), }, experimentRuns: { - get: experimentRunId => get(`/experiment-runs/${experimentRunId}/`), - list: (options, abort?: boolean, requestID?: string) => get>>("/experiment-runs/", options, {abort, requestID}), - listExport: options => get("/experiment-runs/list_export/", {format: "csv", ...options}), + get: (experimentRunId) => get(`/experiment-runs/${experimentRunId}/`), + list: (options, abort?: boolean, requestID?: string) => + get>>("/experiment-runs/", options, { + abort, + requestID, + }), + listExport: (options) => get("/experiment-runs/list_export/", { format: "csv", ...options }), template: { actions: () => get(`/experiment-runs/template_actions/`), - check: (action, template) => post(`/experiment-runs/template_check/`, form({ action, template })), - submit: (action, template) => post(`/experiment-runs/template_submit/`, form({ action, template })), + check: (action, template) => + post(`/experiment-runs/template_check/`, form({ action, template })), + submit: (action, template) => + post(`/experiment-runs/template_submit/`, form({ action, template })), }, - launchRunProcessing: experimentRunId => patch(`/experiment-runs/${experimentRunId}/launch_run_processing/`, {}), - relaunchRunProcessing: experimentRunId => patch(`/experiment-runs/${experimentRunId}/relaunch_run_processing/`, {}), - fetchRunInfo: experimentRunId => get(`/experiment-runs/${experimentRunId}/run_info/`, {}), - setLaneValidationStatus: (experimentRunId, lane, validation_status) => post(`/experiment-runs/${experimentRunId}/set_experiment_run_lane_validation_status/`, {lane, validation_status}), - getLaneValidationStatus: (experimentRunId, lane) => get(`/experiment-runs/${experimentRunId}/get_experiment_run_lane_validation_status/`, {lane}) + launchRunProcessing: (experimentRunId) => + patch(`/experiment-runs/${experimentRunId}/launch_run_processing/`, {}), + relaunchRunProcessing: (experimentRunId) => + patch(`/experiment-runs/${experimentRunId}/relaunch_run_processing/`, {}), + fetchRunInfo: (experimentRunId) => get(`/experiment-runs/${experimentRunId}/run_info/`, {}), + setLaneValidationStatus: (experimentRunId, lane, validation_status) => + post(`/experiment-runs/${experimentRunId}/set_experiment_run_lane_validation_status/`, { + lane, + validation_status, + }), + getLaneValidationStatus: (experimentRunId, lane) => + get(`/experiment-runs/${experimentRunId}/get_experiment_run_lane_validation_status/`, { + lane, + }), }, importedFiles: { - get: fileId => get(`/imported-files/${fileId}/`), + get: (fileId) => get(`/imported-files/${fileId}/`), list: (options, abort?: boolean) => get("/imported-files/", options, { abort }), - download: fileId => get(`/imported-files/${fileId}/download/`), + download: (fileId) => get(`/imported-files/${fileId}/download/`), }, indices: { - get: indexId => get(`/indices/${indexId}/`), + get: (indexId) => get(`/indices/${indexId}/`), list: (options, abort?: boolean) => get("/indices/", options, { abort }), - listExport: options => get("/indices/list_export/", {format: "csv", ...options}), + listExport: (options) => get("/indices/list_export/", { format: "csv", ...options }), listSets: () => get("/indices/list_sets/"), template: { actions: () => get(`/indices/template_actions/`), - check: (action, template) => post(`/indices/template_check/`, form({ action, template })), + check: (action, template) => post(`/indices/template_check/`, form({ action, template })), submit: (action, template) => post(`/indices/template_submit/`, form({ action, template })), }, validate: (options) => get("/indices/validate/", options), }, individuals: { - get: individualId => get(`/individuals/${individualId}/`), - add: individual => post("/individuals/", individual), - update: individual => patch(`/individuals/${individual.id}/`, individual), + get: (individualId) => get(`/individuals/${individualId}/`), + add: (individual) => post("/individuals/", individual), + update: (individual) => patch(`/individuals/${individual.id}/`, individual), list: (options, abort?: boolean) => get("/individuals/", options, { abort }), - listExport: options => get("/individuals/list_export/", {format: "csv", ...options}), + listExport: (options) => get("/individuals/list_export/", { format: "csv", ...options }), search: (q, options) => get("/individuals/search/", { q, ...options }), }, @@ -125,80 +178,119 @@ const api = { }, libraries: { - get: libraryId => get(`/libraries/${libraryId}/`), + get: (libraryId) => get(`/libraries/${libraryId}/`), list: (options, abort?: boolean) => get("/libraries/", options, { abort }), - listExport: options => get("/libraries/list_export/", {format: "csv", ...options}), + listExport: (options) => get("/libraries/list_export/", { format: "csv", ...options }), template: { actions: () => get(`/libraries/template_actions/`), - check: (action, template) => post(`/libraries/template_check/`, form({ action, template })), + check: (action, template) => post(`/libraries/template_check/`, form({ action, template })), submit: (action, template) => post(`/libraries/template_submit/`, form({ action, template })), }, prefill: { templates: () => get(`/libraries/list_prefills/`), - request: (options, template) => filteredpost(`/libraries/prefill_template/`, {...options}, form({ template: template })), + request: (options, template) => + filteredpost(`/libraries/prefill_template/`, { ...options }, form({ template: template })), }, - search: q => get("/libraries/search/", { q }), + search: (q) => get("/libraries/search/", { q }), }, libraryTypes: { - get: libraryTypeId => get(`/library-types/${libraryTypeId}/`), + get: (libraryTypeId) => get(`/library-types/${libraryTypeId}/`), list: (options, abort?: boolean) => get("/library-types/", options, { abort }), }, metrics: { - getReadsPerSampleForLane: (experimentRunId, lane) => get(`/metrics/`, {limit: 100000, name: 'nb_reads', metric_group: 'qc', readset__dataset__experiment_run_id: experimentRunId, readset__dataset__lane: lane}) + getReadsPerSampleForLane: (experimentRunId, lane) => + get(`/metrics/`, { + limit: 100000, + name: "nb_reads", + metric_group: "qc", + readset__dataset__experiment_run_id: experimentRunId, + readset__dataset__lane: lane, + }), }, parentProjects: { - get: (parentProjectId: FMSId) => get>(`/parent-projects/${parentProjectId}/`), - list: (options: object, abort?: boolean, requestID?: string) => get>>("/parent-projects/", options, { abort, requestID }), + get: (parentProjectId: FMSId) => + get>(`/parent-projects/${parentProjectId}/`), + list: (options: object, abort?: boolean, requestID?: string) => + get>>("/parent-projects/", options, { + abort, + requestID, + }), + readsets: (parentProjectId: FMSId, options: QueryParams, abort?: boolean) => + get>>( + `/parent-projects/${parentProjectId}/readsets/`, + options, + { abort }, + ), }, platforms: { - get: platformId => get(`/platforms/${platformId}/`), + get: (platformId) => get(`/platforms/${platformId}/`), list: (options, abort?: boolean) => get("/platforms/", options, { abort }), }, pooledSamples: { - list: (options: any, apiOptions?: APIFetchOptions) => get>>("/pooled-samples/", options, apiOptions), - listExport: options => get("/pooled-samples/list_export/", {format: "csv", ...options}), + list: (options: any, apiOptions?: APIFetchOptions) => + get>>( + "/pooled-samples/", + options, + apiOptions, + ), + listExport: (options) => get("/pooled-samples/list_export/", { format: "csv", ...options }), template: { actions: () => get>(`/pooled-samples/template_actions/`), - check: (action, template) => post(`/pooled-samples/template_check/`, form({ action, template })), - submit: (action, template) => post(`/pooled-samples/template_submit/`, form({ action, template })), + check: (action, template) => + post(`/pooled-samples/template_check/`, form({ action, template })), + submit: (action, template) => + post(`/pooled-samples/template_submit/`, form({ action, template })), }, prefill: { - templates: () => get>(`/pooled-samples/list_prefills/`), - request: (options: any, template: number) => filteredpost(`/pooled-samples/prefill_template/`, {...options}, form({ template: template })), + templates: () => + get>(`/pooled-samples/list_prefills/`), + request: (options: any, template: number) => + filteredpost( + `/pooled-samples/prefill_template/`, + { ...options }, + form({ template: template }), + ), }, }, processes: { - get: processId => get(`/processes/${processId}/`), + get: (processId) => get(`/processes/${processId}/`), list: (options, abort?: boolean) => get("/processes/", options, { abort }), }, processMeasurements: { - get: processMeasurementId => get(`/process-measurements/${processMeasurementId}/`), + get: (processMeasurementId) => get(`/process-measurements/${processMeasurementId}/`), list: (options, abort?: boolean) => get("/process-measurements/", options, { abort }), - listExport: options => get("/process-measurements/list_export/", {format: "csv", ...options}), - search: q => get("/process-measurements/search/", { q }), + listExport: (options) => + get("/process-measurements/list_export/", { format: "csv", ...options }), + search: (q) => get("/process-measurements/search/", { q }), template: { actions: () => get(`/process-measurements/template_actions/`), - check: (action, template) => post(`/process-measurements/template_check/`, form({ action, template })), - submit: (action, template) => post(`/process-measurements/template_submit/`, form({ action, template })), + check: (action, template) => + post(`/process-measurements/template_check/`, form({ action, template })), + submit: (action, template) => + post(`/process-measurements/template_submit/`, form({ action, template })), }, }, projects: { - get: projectId => get(`/projects/${projectId}/`), - add: project => post("/projects/", project), - update: project => patch(`/projects/${project.id}/`, project), - list: (options, abort?: boolean, requestID?: string) => get>>("/projects/", options, { abort, requestID }), - listExport: options => get("/projects/list_export/", {format: "csv", ...options}), + get: (projectId) => get(`/projects/${projectId}/`), + add: (project) => post("/projects/", project), + update: (project) => patch(`/projects/${project.id}/`, project), + list: (options, abort?: boolean, requestID?: string) => + get>>("/projects/", options, { + abort, + requestID, + }), + listExport: (options) => get("/projects/list_export/", { format: "csv", ...options }), template: { actions: () => get(`/projects/template_actions/`), - check: (action, template) => post(`/projects/template_check/`, form({ action, template })), + check: (action, template) => post(`/projects/template_check/`, form({ action, template })), submit: (action, template) => post(`/projects/template_submit/`, form({ action, template })), }, }, @@ -208,21 +300,28 @@ const api = { }, protocols: { - list: (options, abort?: boolean) => get("/protocols/", options, { abort }), - lastProtocols: (options, abort?: boolean) => get>("/protocols/last_protocols/", options, { abort }), + list: (options, abort?: boolean) => get("/protocols/", options, { abort }), + lastProtocols: (options, abort?: boolean) => + get>( + "/protocols/last_protocols/", + options, + { abort }, + ), }, readsets: { - get: id => get(`/readsets/${id}/`), - list: (options: QueryParams, abort?: boolean) => get>>(`/readsets/`, options, { abort }), + get: (id) => get(`/readsets/${id}/`), + list: (options: QueryParams, abort?: boolean) => + get>>(`/readsets/`, options, { abort }), }, referenceGenomes: { - get: referenceGenomeId => get(`/reference-genomes/${referenceGenomeId}`), - add: referenceGenome => post(`/reference-genomes/`, referenceGenome), - update: referenceGenome => patch(`/reference-genomes/${referenceGenome.id}/`, referenceGenome), - list: (options, abort?: boolean) => get('/reference-genomes/', options, { abort }), - search: q => get("/reference-genomes/search/", { q }), + get: (referenceGenomeId) => get(`/reference-genomes/${referenceGenomeId}`), + add: (referenceGenome) => post(`/reference-genomes/`, referenceGenome), + update: (referenceGenome) => + patch(`/reference-genomes/${referenceGenome.id}/`, referenceGenome), + list: (options, abort?: boolean) => get("/reference-genomes/", options, { abort }), + search: (q) => get("/reference-genomes/search/", { q }), }, runTypes: { @@ -230,40 +329,66 @@ const api = { }, samples: { - get: sampleId => get>(`/samples/${sampleId}/`), - add: sample => post("/samples/", sample), - addSamplesToStudy: (exceptedSampleIDs: Array, defaultSelection: boolean, projectId: FMSProject['id'], studyLetter: FMSStudy['letter'], stepOrder: WorkflowStepOrder['order'], queryParams?: QueryParams) => - filteredpost(`/samples/add_samples_to_study/`, queryParams, { excepted_sample_ids: exceptedSampleIDs, default_selection: defaultSelection, project_id: projectId, study_letter: studyLetter, step_order: stepOrder }), - update: sample => patch(`/samples/${sample.id}/`, sample), - list: (options, abort?: boolean) => get>>("/samples/", options, { abort }), - listExport: options => get("/samples/list_export/", {format: "csv", ...options}), - listExportMetadata: options => get("/samples/list_export_metadata/", {format: "csv", ...options}), + get: (sampleId) => get>(`/samples/${sampleId}/`), + add: (sample) => post("/samples/", sample), + addSamplesToStudy: ( + exceptedSampleIDs: Array, + defaultSelection: boolean, + projectId: FMSProject["id"], + studyLetter: FMSStudy["letter"], + stepOrder: WorkflowStepOrder["order"], + queryParams?: QueryParams, + ) => + filteredpost(`/samples/add_samples_to_study/`, queryParams, { + excepted_sample_ids: exceptedSampleIDs, + default_selection: defaultSelection, + project_id: projectId, + study_letter: studyLetter, + step_order: stepOrder, + }), + update: (sample) => patch(`/samples/${sample.id}/`, sample), + list: (options, abort?: boolean) => + get>>("/samples/", options, { abort }), + listExport: (options) => get("/samples/list_export/", { format: "csv", ...options }), + listExportMetadata: (options) => + get("/samples/list_export_metadata/", { format: "csv", ...options }), listCollectionSites: (filter) => get("/samples/list_collection_sites/", { filter }), - listVersions: sampleId => get>(`/samples/${sampleId}/versions/`), + listVersions: (sampleId) => get>(`/samples/${sampleId}/versions/`), template: { actions: () => get(`/samples/template_actions/`), - check: (action, template) => post(`/samples/template_check/`, form({ action, template })), + check: (action, template) => post(`/samples/template_check/`, form({ action, template })), submit: (action, template) => post(`/samples/template_submit/`, form({ action, template })), }, prefill: { templates: () => get(`/samples/list_prefills/`), - request: (options, template) => filteredpost(`/samples/prefill_template/`, {...options}, form({ template: template })), + request: (options, template) => + filteredpost(`/samples/prefill_template/`, { ...options }, form({ template: template })), }, - search: q => get("/samples/search/", { q }), + search: (q) => get("/samples/search/", { q }), }, sampleIdentity: { - get: (id: FMSSampleIdentity['id']) => get>(`/sample-identities/${id}/`), - list: (options: any, abort?: boolean) => get>>(`/sample-identities/`, options, { abort }), + get: (id: FMSSampleIdentity["id"]) => + get>(`/sample-identities/${id}/`), + list: (options: any, abort?: boolean) => + get>>(`/sample-identities/`, options, { + abort, + }), }, sampleIdentityMatch: { - get: (id: FMSSampleIdentityMatch['id']) => get>(`/sample-identity-matches/${id}/`), - list: (options: any, abort?: boolean) => get>>(`/sample-identity-matches/`, options, { abort }), + get: (id: FMSSampleIdentityMatch["id"]) => + get>(`/sample-identity-matches/${id}/`), + list: (options: any, abort?: boolean) => + get>>( + `/sample-identity-matches/`, + options, + { abort }, + ), }, sampleMetadata: { - get: options => get(`/sample-metadata/`, options), + get: (options) => get(`/sample-metadata/`, options), search: (q, options) => get("/sample-metadata/search/", { q, ...options }), }, @@ -272,75 +397,148 @@ const api = { }, sampleNextStep: { - listSamples: (sampleIDs: FMSId[]) => get>>('/sample-next-step/', {sample__id__in: sampleIDs.join(','), limit: 100000}), - getStudySamples: (studyId) => get('/sample-next-step/', {studies__id__in : studyId}), - executeAutomation: (stepId, additionalData, options) => filteredpost(`/sample-next-step/execute_automation/`, {...options}, form({step_id: stepId, additional_data: additionalData, ...options}),), - labworkSummary: () => get('/sample-next-step/labwork_info/'), - labworkStepSummary: (stepId: FMSId, groupBy: string, options?: QueryParams, sample__id__in?: FMSId[]) => filteredpost>('/sample-next-step/labwork_step_info/', {...options, step__id__in: stepId, group_by: groupBy}, { sample__id__in }), - listSamplesAtStep: (stepId: FMSId, options?: QueryParams, sample__id__in?: FMSId[]) => filteredpost>>('/sample-next-step/list_post/', {limit: 100000, ...options, step__id__in: stepId}, { sample__id__in }), + listSamples: (sampleIDs: FMSId[]) => + get>>("/sample-next-step/", { + sample__id__in: sampleIDs.join(","), + limit: 100000, + }), + getStudySamples: (studyId) => get("/sample-next-step/", { studies__id__in: studyId }), + executeAutomation: (stepId, additionalData, options) => + filteredpost( + `/sample-next-step/execute_automation/`, + { ...options }, + form({ step_id: stepId, additional_data: additionalData, ...options }), + ), + labworkSummary: () => get("/sample-next-step/labwork_info/"), + labworkStepSummary: ( + stepId: FMSId, + groupBy: string, + options?: QueryParams, + sample__id__in?: FMSId[], + ) => + filteredpost>( + "/sample-next-step/labwork_step_info/", + { ...options, step__id__in: stepId, group_by: groupBy }, + { sample__id__in }, + ), + listSamplesAtStep: (stepId: FMSId, options?: QueryParams, sample__id__in?: FMSId[]) => + filteredpost>>( + "/sample-next-step/list_post/", + { limit: 100000, ...options, step__id__in: stepId }, + { sample__id__in }, + ), prefill: { - templates: (protocolId) => get('/sample-next-step/list_prefills/', {protocol: protocolId}), - request: (templateID: FMSId, user_prefill_data: string, placement_data: string, sample__id__in: string, options?: QueryParams) => filteredpost('/sample-next-step/prefill_template/',{...options}, form({user_prefill_data: user_prefill_data, placement_data: placement_data, template: templateID.toString(), sample__id__in }), { notifyError: true }) + templates: (protocolId) => get("/sample-next-step/list_prefills/", { protocol: protocolId }), + request: ( + templateID: FMSId, + user_prefill_data: string, + placement_data: string, + sample__id__in: string, + options?: QueryParams, + ) => + filteredpost( + "/sample-next-step/prefill_template/", + { ...options }, + form({ + user_prefill_data: user_prefill_data, + placement_data: placement_data, + template: templateID.toString(), + sample__id__in, + }), + { notifyError: true }, + ), }, template: { actions: () => get(`/sample-next-step/template_actions/`), - check: (action, template) => post(`/sample-next-step/template_check/`, form({ action, template })), - submit: (action, template) => post(`/sample-next-step/template_submit/`, form({ action, template })), + check: (action, template) => + post(`/sample-next-step/template_check/`, form({ action, template })), + submit: (action, template) => + post(`/sample-next-step/template_submit/`, form({ action, template })), }, }, sampleNextStepByStudy: { - getStudySamples: (options: any) => get>>('/sample-next-step-by-study/', {...options}), - getStudySamplesForStepOrder: (studyId, stepOrderID, options) => get(`/sample-next-step-by-study/`, {...options, study__id__in : studyId, step_order__id__in : stepOrderID }), - countStudySamples: (studyId, options) => get(`/sample-next-step-by-study/summary_by_study/`, {...options, study__id__in: studyId}), - remove: sampleNextStepByStudyId => remove(`/sample-next-step-by-study/${sampleNextStepByStudyId}/`), - removeList: (sampleIDs: FMSId[], study: FMSStudy['id'], stepOrder: number) => post>>(`/sample-next-step-by-study/destroy_list/`, { sample_ids: sampleIDs, study, step_order: stepOrder }), - list: (options, abort?: boolean) => get("/sample-next-step-by-study/", { limit: 100000, ...options }, { abort }), + getStudySamples: (options: any) => + get>>( + "/sample-next-step-by-study/", + { ...options }, + ), + getStudySamplesForStepOrder: (studyId, stepOrderID, options) => + get(`/sample-next-step-by-study/`, { + ...options, + study__id__in: studyId, + step_order__id__in: stepOrderID, + }), + countStudySamples: (studyId, options) => + get(`/sample-next-step-by-study/summary_by_study/`, { ...options, study__id__in: studyId }), + remove: (sampleNextStepByStudyId) => + remove(`/sample-next-step-by-study/${sampleNextStepByStudyId}/`), + removeList: (sampleIDs: FMSId[], study: FMSStudy["id"], stepOrder: number) => + post>>(`/sample-next-step-by-study/destroy_list/`, { + sample_ids: sampleIDs, + study, + step_order: stepOrder, + }), + list: (options, abort?: boolean) => + get("/sample-next-step-by-study/", { limit: 100000, ...options }, { abort }), }, samplesheets: { - getSamplesheet: (barcode, kind, placementData) => post('/samplesheets/get_samplesheet/', { container_barcode: barcode, container_kind: kind, placement: placementData }), + getSamplesheet: (barcode, kind, placementData) => + post("/samplesheets/get_samplesheet/", { + container_barcode: barcode, + container_kind: kind, + placement: placementData, + }), }, sequences: { - get: sequenceId => get(`/sequences/${sequenceId}/`), + get: (sequenceId) => get(`/sequences/${sequenceId}/`), list: (options, abort?: boolean) => get("/sequences/", options, { abort }), }, stepHistory: { - getCompletedSamplesForStudy: (studyId, options) => get>>('/step-histories/', {...options, study__id__in: studyId}), - countStudySamples: (studyId) => get(`/step-histories/summary_by_study/`, {study__id__in: studyId}) + getCompletedSamplesForStudy: (studyId, options) => + get>>("/step-histories/", { + ...options, + study__id__in: studyId, + }), + countStudySamples: (studyId) => + get(`/step-histories/summary_by_study/`, { study__id__in: studyId }), }, steps: { - list: (options, abort?: boolean) => get>>('/steps/', options, { abort} ), + list: (options, abort?: boolean) => + get>>("/steps/", options, { abort }), }, studies: { - get: studyId => get>(`/studies/${studyId}/`), - add: study => post("/studies/", study), - update: study => patch(`/studies/${study.id}/`, study), - list: (options, abort?: boolean) => get>>('/studies/', options, {abort}), - listProjectStudies: projectId => get('/studies/', { project_id: projectId}), - remove: (studyId) => remove(`/studies/${studyId}/`) + get: (studyId) => get>(`/studies/${studyId}/`), + add: (study) => post("/studies/", study), + update: (study) => patch(`/studies/${study.id}/`, study), + list: (options, abort?: boolean) => + get>>("/studies/", options, { abort }), + listProjectStudies: (projectId) => get("/studies/", { project_id: projectId }), + remove: (studyId) => remove(`/studies/${studyId}/`), }, taxons: { - get: taxonId => get(`/taxons/${taxonId}/`), - add: taxon => post(`/taxons/`, taxon), - update: taxon => patch(`/taxons/${taxon.id}/`, taxon), + get: (taxonId) => get(`/taxons/${taxonId}/`), + add: (taxon) => post(`/taxons/`, taxon), + update: (taxon) => patch(`/taxons/${taxon.id}/`, taxon), list: (options, abort?: boolean) => get("/taxons/", options, { abort }), - search: q => get("/taxons/search/", { q }), + search: (q) => get("/taxons/search/", { q }), }, users: { - get: userId => get>(`/users/${userId}/`), - add: user => post("/users/", user), - update: user => patch(`/users/${user.id}/`, user), - updateSelf: user => patch(`/users/update_self/`, user), + get: (userId) => get>(`/users/${userId}/`), + add: (user) => post("/users/", user), + update: (user) => patch(`/users/${user.id}/`, user), + updateSelf: (user) => patch(`/users/update_self/`, user), list: (options, abort?: boolean) => get("/users/", options, { abort }), listRevisions: (userId, options = {}) => get(`/revisions/`, { user_id: userId, ...options }), - listVersions: (userId, options = {}) => get(`/versions/`, { revision__user: userId, ...options }), + listVersions: (userId, options = {}) => + get(`/versions/`, { revision__user: userId, ...options }), }, profiles: { @@ -348,8 +546,10 @@ const api = { }, workflows: { - get: (workflowId: FMSWorkflow['id']) => get>(`/workflows/${workflowId}/`), - list: (options, abort?: boolean) => get>>('/workflows/', options, { abort }) + get: (workflowId: FMSWorkflow["id"]) => + get>(`/workflows/${workflowId}/`), + list: (options, abort?: boolean) => + get>>("/workflows/", options, { abort }), }, groups: { @@ -357,203 +557,269 @@ const api = { }, query: { - search: q => get("/query/search/", { q }, { abort: true }), + search: (q) => get("/query/search/", { q }, { abort: true }), }, sample_lineage: { - get: (sampleId: FMSId) => get>(`/sample-lineage/${sampleId}/graph/`) + get: (sampleId: FMSId) => + get>(`/sample-lineage/${sampleId}/graph/`), }, report: { - listReports: () => get>("/reports/"), - listReportInformation: (name: string) => get>(`/reports/${name}/`), - getReport: (name: string, start_date: string, end_date: string, time_window = "month", group_by: string[] = []) => get>(`/reports/${name}/`, { group_by, time_window, start_date, end_date }), - getReportAsExcel: (name: string, start_date: string, end_date: string, time_window = "month", group_by: string[] = []) => get (`/reports/${name}/`, { group_by, time_window, start_date, end_date, export: true }), - } + listReports: () => get>("/reports/"), + listReportInformation: (name: string) => + get>(`/reports/${name}/`), + getReport: ( + name: string, + start_date: string, + end_date: string, + time_window = "month", + group_by: string[] = [], + ) => + get>(`/reports/${name}/`, { + group_by, + time_window, + start_date, + end_date, + }), + getReportAsExcel: ( + name: string, + start_date: string, + end_date: string, + time_window = "month", + group_by: string[] = [], + ) => + get(`/reports/${name}/`, { + group_by, + time_window, + start_date, + end_date, + export: true, + }), + }, } - -export default api; - -type AuthTokensAccess = Partial> & Pick - -export function dispatchForApi(token: string | undefined, thunk: (_: Dispatch, getState: () => AuthTokensAccess) => T): T { - return thunk(undefined as unknown as Dispatch, () => ({ auth: { isFetching: false, error: null, currentUserID: null, tokens: { access: token, refresh: null }, _persist: { version: 0, rehydrated: false } } })) +export default api + +type AuthTokensAccess = Partial> & Pick + +export function dispatchForApi( + token: string | undefined, + thunk: (_: Dispatch, getState: () => AuthTokensAccess) => T, +): T { + return thunk(undefined as unknown as Dispatch, () => ({ + auth: { + isFetching: false, + error: null, + currentUserID: null, + tokens: { access: token, refresh: null }, + _persist: { version: 0, rehydrated: false }, + }, + })) } -type WithTokenFn, Args extends any[]> = (...args: Args) => (dispatch: Dispatch, getState: () => AuthTokensAccess) => Promise -export function withToken, Args extends any[]>(token: string | undefined, fn: WithTokenFn) { - // dispatch is hopefully not used in the fn function - return (...args: Parameters) => dispatchForApi(token, fn(...args)) +type WithTokenFn, Args extends any[]> = ( + ...args: Args +) => (dispatch: Dispatch, getState: () => AuthTokensAccess) => Promise +export function withToken, Args extends any[]>( + token: string | undefined, + fn: WithTokenFn, +) { + // dispatch is hopefully not used in the fn function + return (...args: Parameters) => dispatchForApi(token, fn(...args)) } const ongoingRequests: Record = {} -type HTTPMethod = 'GET' | 'POST' | 'DELETE' | 'PATCH' +type HTTPMethod = "GET" | "POST" | "DELETE" | "PATCH" export interface APIFetchOptions { - abort?: boolean - requestID?: string - notifyError?: boolean + abort?: boolean + requestID?: string + notifyError?: boolean } -export const ABORT_ERROR_NAME = 'AbortError' +export const ABORT_ERROR_NAME = "AbortError" -function apiFetch>(method: HTTPMethod, route: string, body?: any, options: APIFetchOptions = { abort: false, notifyError: false }) { - const baseRoute = getPathname(route) +function apiFetch>( + method: HTTPMethod, + route: string, + body?: any, + options: APIFetchOptions = { abort: false, notifyError: false }, +) { + const baseRoute = getPathname(route) - return (dispatch: Dispatch, getState: (() => AuthTokensAccess)) => { + return (dispatch: Dispatch, getState: () => AuthTokensAccess) => { + const accessToken = getState().auth.tokens.access - const accessToken = getState().auth.tokens.access; + const headers = {} - const headers = {} + if (accessToken) headers["authorization"] = `Bearer ${accessToken}` - if (accessToken) - headers["authorization"] = `Bearer ${accessToken}` + if (!isFormData(body) && isObject(body)) headers["content-type"] = "application/json" - if (!isFormData(body) && isObject(body)) - headers["content-type"] = "application/json" - - const requestID = options.requestID ?? baseRoute + const requestID = options.requestID ?? baseRoute - // For abortable requests - let signal: AbortSignal | undefined - if (options.abort) { - const controller = new AbortController() - signal = controller.signal - if (ongoingRequests[requestID]) { - ongoingRequests[requestID].abort({ - name: ABORT_ERROR_NAME, - message: `Request aborted for request to ${requestID}`, - }) - } - ongoingRequests[requestID] = controller - } - - const request = fetch(`${API_BASE_PATH}${route}`, { - method, - headers, - credentials: 'omit', - signal, - body: - isFormData(body) ? - body : - isObject(body) ? - JSON.stringify(body) : - undefined, + // For abortable requests + let signal: AbortSignal | undefined + if (options.abort) { + const controller = new AbortController() + signal = controller.signal + if (ongoingRequests[requestID]) { + ongoingRequests[requestID].abort({ + name: ABORT_ERROR_NAME, + message: `Request aborted for request to ${requestID}`, }) + } + ongoingRequests[requestID] = controller + } - return request - .then(res => { - if (options.abort) { - delete ongoingRequests[requestID] - } - return res - }) - .then((response) => attachData(response)) - .then(response => { - if (response.ok) { - return response; - } - if (options.notifyError) { - let detail = response.data.detail - if (Array.isArray(detail)) { - detail = detail.join('; ') - } - dispatch(notifyError({ - id: requestID, - title: detail || 'API request failed', - })) - } - return Promise.reject(createAPIError(response)); - }) - }; + const request = fetch(`${API_BASE_PATH}${route}`, { + method, + headers, + credentials: "omit", + signal, + body: isFormData(body) ? body : isObject(body) ? JSON.stringify(body) : undefined, + }) + + return request + .then((res) => { + if (options.abort) { + delete ongoingRequests[requestID] + } + return res + }) + .then((response) => attachData(response)) + .then((response) => { + if (response.ok) { + return response + } + if (options.notifyError) { + let detail = response.data.detail + if (Array.isArray(detail)) { + detail = detail.join("; ") + } + dispatch( + notifyError({ + id: requestID, + title: detail || "API request failed", + }), + ) + } + return Promise.reject(createAPIError(response)) + }) + } } export type QueryParams = Parameters[0] -function get>(route: string, queryParams?: QueryParams, options?: APIFetchOptions) { - const fullRoute = route + (queryParams ? '?' + qs(queryParams) : '') - return apiFetch('GET', fullRoute, undefined, options); +function get>( + route: string, + queryParams?: QueryParams, + options?: APIFetchOptions, +) { + const fullRoute = route + (queryParams ? "?" + qs(queryParams) : "") + return apiFetch("GET", fullRoute, undefined, options) } -function filteredpost>(route: string, queryParams: QueryParams, body: any, options?: APIFetchOptions) { - const fullRoute = route + (queryParams ? '?' + qs(queryParams) : '') - return apiFetch('POST', fullRoute, body, options); +function filteredpost>( + route: string, + queryParams: QueryParams, + body: any, + options?: APIFetchOptions, +) { + const fullRoute = route + (queryParams ? "?" + qs(queryParams) : "") + return apiFetch("POST", fullRoute, body, options) } -function post>(route: string, body: any, options?: APIFetchOptions) { - return apiFetch('POST', route, body, options); +function post>( + route: string, + body: any, + options?: APIFetchOptions, +) { + return apiFetch("POST", route, body, options) } -function patch>(route: string, body: any, options?: APIFetchOptions) { - return apiFetch('PATCH', route, body, options); +function patch>( + route: string, + body: any, + options?: APIFetchOptions, +) { + return apiFetch("PATCH", route, body, options) } function remove>(route: string) { - return apiFetch('DELETE', route); + return apiFetch("DELETE", route) } -interface ApiError extends Omit { - name: 'APIError' - message: string - stack: string[] - data: Record - fromAPI: boolean - status: number - statusText: string - url: string +interface ApiError extends Omit { + name: "APIError" + message: string + stack: string[] + data: Record + fromAPI: boolean + status: number + statusText: string + url: string } function createAPIError>(response: R): ApiError { - const data = response.data; - let detail: any; - - // Server errors - if (response.isJSON && response.status === 400) { - detail = JSON.stringify(data, null, 2) - } - else { - // API error as { ok: false, detail: ... } - try { - detail = data.detail || - (data.revision__user && ('User: ' + data.revision__user.join(', '))); - } catch (_) { } - } + const data = response.data + let detail: any + + // Server errors + if (response.isJSON && response.status === 400) { + detail = JSON.stringify(data, null, 2) + } else { + // API error as { ok: false, detail: ... } + try { + detail = data.detail || (data.revision__user && "User: " + data.revision__user.join(", ")) + } catch (_) {} + } - const message = detail ? - ('API error: ' + detail) : - (`HTTP error ${response.status}: ` + response.statusText + ': ' + response.url) + const message = detail + ? "API error: " + detail + : `HTTP error ${response.status}: ` + response.statusText + ": " + response.url - const error = new Error(message) as unknown as ApiError; - error.name = 'APIError'; - error.fromAPI = Boolean(detail); - error.data = data || {}; - error.url = response.url; - error.status = response.status; - error.statusText = response.statusText; - error.stack = [] + const error = new Error(message) as unknown as ApiError + error.name = "APIError" + error.fromAPI = Boolean(detail) + error.data = data || {} + error.url = response.url + error.status = response.status + error.statusText = response.statusText + error.stack = [] - return error; + return error } export interface FMSResponse extends Response { - isJSON: boolean - data: T - filename?: string + isJSON: boolean + data: T + filename?: string +} +interface JsonResponse extends FMSResponse { + isJSON: true +} +interface ArrayBufferResponse extends FMSResponse { + isJSON: false } -interface JsonResponse extends FMSResponse { isJSON: true } -interface ArrayBufferResponse extends FMSResponse { isJSON: false } -interface StringResponse extends FMSResponse { isJSON: false } -interface AttachDataErrorResponse extends FMSResponse> { isJSON: false } -type ResponseWithData = JsonResponse | ArrayBufferResponse | StringResponse | AttachDataErrorResponse - -function attachData>(response: Response & Partial) { - const contentType = response.headers.get('content-type') || ''; - const contentDispo = response.headers.get('content-disposition'); - const filename = getFilenameOrNull(contentDispo) - if (filename) - response.filename = filename - - /* +interface StringResponse extends FMSResponse { + isJSON: false +} +interface AttachDataErrorResponse extends FMSResponse> { + isJSON: false +} +type ResponseWithData = + JsonResponse | ArrayBufferResponse | StringResponse | AttachDataErrorResponse + +function attachData>( + response: Response & Partial, +) { + const contentType = response.headers.get("content-type") || "" + const contentDispo = response.headers.get("content-disposition") + const filename = getFilenameOrNull(contentDispo) + if (filename) response.filename = filename + + /* TODO: This code was causing downloaded excel templates to become corrupted because the backend was sending "None" as a Content-Type, due to a problem with mime types. We tried to fix that by hard-coding the content-type as 'application/octet-stream' but @@ -565,49 +831,50 @@ function attachData>(response: Response & Partia This was a difficult problem to figure out. This code needs to be improved to avoid the same problem in the future if we transer other binary data types. */ - const isJSON = contentType.includes('/json') - const isExcel = contentType.includes('/ms-excel') || contentType.includes('/vnd.openxmlformats-officedocument.spreadsheetml.sheet') - const isZip = contentType.includes('/zip') - - response.isJSON = isJSON - return (isJSON ? response.json() : isExcel || isZip ? response.arrayBuffer() : response.text()) - .then(data => { - response.data = data; - return response as R - }) - .catch(() => { - response.data = {}; - return response as R // as AttachDataErrorResponse (ideally) - }) + const isJSON = contentType.includes("/json") + const isExcel = + contentType.includes("/ms-excel") || + contentType.includes("/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + const isZip = contentType.includes("/zip") + + response.isJSON = isJSON + return (isJSON ? response.json() : isExcel || isZip ? response.arrayBuffer() : response.text()) + .then((data) => { + response.data = data + return response as R + }) + .catch(() => { + response.data = {} + return response as R // as AttachDataErrorResponse (ideally) + }) } function getFilenameOrNull(contentDispo: string | null) { - if (contentDispo) - return contentDispo.split('filename=').length > 1 - // eslint-disable-next-line no-useless-escape - ? contentDispo.split('filename=')[1].replace(/^.*[\\\/]/, '') - : null - else - return null + if (contentDispo) + return contentDispo.split("filename=").length > 1 + ? // eslint-disable-next-line no-useless-escape + contentDispo.split("filename=")[1].replace(/^.*[\\\/]/, "") + : null + else return null } function form(params: Record) { - const formData = new FormData() - for (const key in params) { - const value = params[key] - formData.append(key, value) - } - return formData + const formData = new FormData() + for (const key in params) { + const value = params[key] + formData.append(key, value) + } + return formData } function isObject(object: any): object is object { - return object !== null && typeof object === 'object' + return object !== null && typeof object === "object" } function isFormData(object: any): object is FormData { - return object instanceof FormData + return object instanceof FormData } function getPathname(route: string) { - return route.replace(/\?.*$/, '') -} \ No newline at end of file + return route.replace(/\?.*$/, "") +}