diff --git a/frontend/packages/data-portal/.env.sample b/frontend/packages/data-portal/.env.sample new file mode 100644 index 000000000..910128bd3 --- /dev/null +++ b/frontend/packages/data-portal/.env.sample @@ -0,0 +1,6 @@ +API_URL=https://graphql-cryoet-api.cryoet.prod.si.czi.technology/v1/graphql +E2E_CONFIG={} +LOCALHOST_PLAUSIBLE_TRACKING=false + +# Possible values: local, dev, staging, prod +ENV=local diff --git a/frontend/packages/data-portal/app/graphql/getBrowseDatasets.server.ts b/frontend/packages/data-portal/app/graphql/getBrowseDatasets.server.ts new file mode 100644 index 000000000..7385d61e2 --- /dev/null +++ b/frontend/packages/data-portal/app/graphql/getBrowseDatasets.server.ts @@ -0,0 +1,431 @@ +import type { ApolloClient, NormalizedCacheObject } from '@apollo/client' +import { isNumber } from 'lodash-es' +import { match } from 'ts-pattern' + +import { gql } from 'app/__generated__' +import { Datasets_Bool_Exp, Order_By } from 'app/__generated__/graphql' +import { MAX_PER_PAGE } from 'app/constants/pagination' +import { DEFAULT_TILT_MAX, DEFAULT_TILT_MIN } from 'app/constants/tiltSeries' +import { + DatasetFilterState, + getDatasetFilter, +} from 'app/hooks/useDatasetFilter' + +const GET_DATASETS_DATA_QUERY = gql(` + query GetDatasetsData( + $limit: Int, + $offset: Int, + $order_by_dataset: order_by, + $filter: datasets_bool_exp, + ) { + datasets( + limit: $limit, + offset: $offset, + order_by: { title: $order_by_dataset }, + where: $filter + ) { + id + title + organism_name + dataset_publications + key_photo_thumbnail_url + related_database_entries + authors( + order_by: { + author_list_order: asc, + }, + ) { + name + primary_author_status + } + + runs_aggregate { + aggregate { + count + } + } + + runs { + tomogram_voxel_spacings { + annotations(distinct_on: object_name) { + object_name + } + } + } + } + + datasets_aggregate { + aggregate { + count + } + } + + filtered_datasets_aggregate: datasets_aggregate(where: $filter) { + aggregate { + count + } + } + + organism_names: datasets(distinct_on: organism_name) { + organism_name + } + + camera_manufacturers: tiltseries(distinct_on: camera_manufacturer) { + camera_manufacturer + } + + reconstruction_methods: tomograms(distinct_on: reconstruction_method) { + reconstruction_method + } + + reconstruction_softwares: tomograms(distinct_on: reconstruction_software) { + reconstruction_software + } + + object_names: annotations(distinct_on: object_name) { + object_name + } + + object_shape_types: annotations { + files(distinct_on: shape_type) { + shape_type + } + } + } +`) + +function getTiltValue(value: string | null) { + if (value && !Number.isNaN(+value)) { + return +value + } + + return null +} + +function getFilter(datasetFilter: DatasetFilterState, query: string) { + const filters: Datasets_Bool_Exp[] = [] + + // Text search by dataset title + if (query) { + filters.push({ + title: { + _ilike: `%${query}%`, + }, + }) + } + + // Included contents filters + // Ground truth filter + if (datasetFilter.includedContents.isGroundTruthEnabled) { + filters.push({ + runs: { + tomogram_voxel_spacings: { + annotations: { + ground_truth_status: { + _eq: true, + }, + }, + }, + }, + }) + } + + // Available files filter + datasetFilter.includedContents.availableFiles.forEach((file) => + match(file) + .with('raw-frames', () => + filters.push({ + runs: { + tiltseries: { + frames_count: { + _gt: 0, + }, + }, + }, + }), + ) + .with('tilt-series', () => + filters.push({ + runs: { + tiltseries_aggregate: { + count: { + predicate: { + _gt: 0, + }, + }, + }, + }, + }), + ) + .with('tilt-series-alignment', () => + filters.push({ + runs: { + tiltseries: { + https_alignment_file: { + _is_null: false, + }, + }, + }, + }), + ) + .with('tomogram', () => + filters.push({ + runs: { + tomogram_voxel_spacings: { + tomograms_aggregate: { + count: { + predicate: { + _gt: 0, + }, + }, + }, + }, + }, + }), + ) + .exhaustive(), + ) + + // Number of runs filter + if (datasetFilter.includedContents.numberOfRuns) { + const runCount = +datasetFilter.includedContents.numberOfRuns.slice(1) + filters.push({ + runs_aggregate: { + count: { + predicate: { _gte: runCount }, + }, + }, + }) + } + + // Id filters + const idFilters: Datasets_Bool_Exp[] = [] + + // Portal ID filter + const portalId = +(datasetFilter.ids.portal ?? Number.NaN) + if (!Number.isNaN(portalId) && portalId > 0) { + idFilters.push({ + id: { + _eq: portalId, + }, + }) + } + + // Empiar filter + const empiarId = datasetFilter.ids.empiar + if (empiarId) { + idFilters.push({ + related_database_entries: { + _like: `%EMPIAR-${empiarId}%`, + }, + }) + } + + // EMDB filter + const emdbId = datasetFilter.ids.emdb + if (emdbId) { + idFilters.push({ + related_database_entries: { + _like: `%EMD-${emdbId}%`, + }, + }) + } + + if (idFilters.length > 0) { + filters.push({ _or: idFilters }) + } + + // Author filters + + // Author name filter + if (datasetFilter.author.name) { + filters.push({ + authors: { + name: { + _ilike: `%${datasetFilter.author.name}%`, + }, + }, + }) + } + + // Author Orcid filter + if (datasetFilter.author.orcid) { + filters.push({ + authors: { + orcid: { + _ilike: `%${datasetFilter.author.orcid}%`, + }, + }, + }) + } + + // Sample and experiment condition filters + const { organismNames } = datasetFilter.sampleAndExperimentConditions + + // Organism name filter + if (organismNames.length > 0) { + filters.push({ + organism_name: { _in: organismNames }, + }) + } + + // Hardware filters + // Camera manufacturer filter + if (datasetFilter.hardware.cameraManufacturer) { + filters.push({ + runs: { + tiltseries: { + camera_manufacturer: { + _eq: datasetFilter.hardware.cameraManufacturer, + }, + }, + }, + }) + } + + // Tilt series metadata filters + let tiltMin = getTiltValue(datasetFilter.tiltSeries.min) + let tiltMax = getTiltValue(datasetFilter.tiltSeries.max) + + if (isNumber(tiltMin) && !isNumber(tiltMax)) { + tiltMax = DEFAULT_TILT_MAX + } + + if (!isNumber(tiltMin) && isNumber(tiltMax)) { + tiltMin = DEFAULT_TILT_MIN + } + + // Tilt range filter + if (tiltMin) { + filters.push({ + runs: { + tiltseries: { + tilt_range: { + _gte: tiltMin, + }, + }, + }, + }) + } + + if (tiltMax) { + filters.push({ + runs: { + tiltseries: { + tilt_range: { + _lte: tiltMax, + }, + }, + }, + }) + } + + // Tomogram metadata filters + if (datasetFilter.tomogram.fiducialAlignmentStatus) { + filters.push({ + runs: { + tomogram_voxel_spacings: { + tomograms: { + fiducial_alignment_status: { + _eq: + datasetFilter.tomogram.fiducialAlignmentStatus === 'true' + ? 'FIDUCIAL' + : 'NON_FIDUCIAL', + }, + }, + }, + }, + }) + } + + // Reconstruction method filter + if (datasetFilter.tomogram.reconstructionMethod) { + filters.push({ + runs: { + tomogram_voxel_spacings: { + tomograms: { + reconstruction_method: { + _eq: datasetFilter.tomogram.reconstructionMethod, + }, + }, + }, + }, + }) + } + + // Reconstruction software filter + if (datasetFilter.tomogram.reconstructionSoftware) { + filters.push({ + runs: { + tomogram_voxel_spacings: { + tomograms: { + reconstruction_software: { + _eq: datasetFilter.tomogram.reconstructionSoftware, + }, + }, + }, + }, + }) + } + + // Annotation filters + const { objectNames, objectShapeTypes } = datasetFilter.annotation + + // Object names filter + if (objectNames.length > 0) { + filters.push({ + runs: { + tomogram_voxel_spacings: { + annotations: { + object_name: { + _in: objectNames, + }, + }, + }, + }, + }) + } + + // Object shape type filter + if (objectShapeTypes.length > 0) { + filters.push({ + runs: { + tomogram_voxel_spacings: { + annotations: { + files: { + shape_type: { + _in: objectShapeTypes, + }, + }, + }, + }, + }, + }) + } + + return { _and: filters } as Datasets_Bool_Exp +} + +export async function getBrowseDatasets({ + client, + orderBy, + page = 1, + params = new URLSearchParams(), + query = '', +}: { + client: ApolloClient + orderBy?: Order_By | null + page?: number + params?: URLSearchParams + query?: string +}) { + return client.query({ + query: GET_DATASETS_DATA_QUERY, + variables: { + filter: getFilter(getDatasetFilter(params), query), + limit: MAX_PER_PAGE, + offset: (page - 1) * MAX_PER_PAGE, + order_by_dataset: orderBy, + }, + }) +} diff --git a/frontend/packages/data-portal/app/graphql/getDatasetById.server.ts b/frontend/packages/data-portal/app/graphql/getDatasetById.server.ts new file mode 100644 index 000000000..9c71a7257 --- /dev/null +++ b/frontend/packages/data-portal/app/graphql/getDatasetById.server.ts @@ -0,0 +1,134 @@ +import type { ApolloClient, NormalizedCacheObject } from '@apollo/client' + +import { gql } from 'app/__generated__' +import { MAX_PER_PAGE } from 'app/constants/pagination' + +const GET_DATASET_BY_ID = gql(` + query GetDatasetById($id: Int, $run_limit: Int, $run_offset: Int) { + datasets(where: { id: { _eq: $id } }) { + s3_prefix + + # key photo + key_photo_url + + # Dataset dates + last_modified_date + release_date + deposition_date + + # Dataset metadata + id + title + description + + funding_sources { + funding_agency_name + grant_id + } + + related_database_entries + dataset_citations + + # Sample and experiments data + cell_component_name + cell_component_id + cell_name + cell_strain_name + cell_strain_id + cell_type_id + grid_preparation + organism_name + organism_taxid + other_setup + sample_preparation + sample_type + tissue_name + tissue_id + authors( + order_by: { + author_list_order: asc, + }, + ) { + name + email + primary_author_status + corresponding_author_status + } + + authors_with_affiliation: authors(where: {affiliation_name: {_is_null: false}}) { + name + affiliation_name + } + + # publication info + related_database_entries + dataset_publications + + # Tilt Series + run_metadata: runs(limit: 1) { + tiltseries(limit: 1) { + acceleration_voltage + spherical_aberration_constant + microscope_manufacturer + microscope_model + microscope_energy_filter + microscope_phase_plate + microscope_image_corrector + microscope_additional_info + camera_manufacturer + camera_model + } + } + + runs(limit: $run_limit, offset: $run_offset) { + id + name + + tiltseries_aggregate { + aggregate { + avg { + tilt_series_quality + } + } + } + + tomogram_voxel_spacings { + annotations(distinct_on: object_name) { + object_name + } + + tomograms(limit: 1) { + id + key_photo_thumbnail_url + neuroglancer_config + } + } + } + + runs_aggregate { + aggregate { + count + } + } + } + } +`) + +export async function getDatasetById({ + client, + id, + page = 1, +}: { + client: ApolloClient + id: number + page?: number +}) { + return client.query({ + query: GET_DATASET_BY_ID, + variables: { + id, + run_limit: MAX_PER_PAGE, + run_offset: (page - 1) * MAX_PER_PAGE, + }, + }) +} diff --git a/frontend/packages/data-portal/app/graphql/getRunById.server.ts b/frontend/packages/data-portal/app/graphql/getRunById.server.ts new file mode 100644 index 000000000..d14af93af --- /dev/null +++ b/frontend/packages/data-portal/app/graphql/getRunById.server.ts @@ -0,0 +1,221 @@ +import type { ApolloClient, NormalizedCacheObject } from '@apollo/client' + +import { gql } from 'app/__generated__' +import { MAX_PER_PAGE } from 'app/constants/pagination' + +const GET_RUN_BY_ID_QUERY = gql(` + query GetRunById($id: Int, $limit: Int, $offset: Int) { + runs(where: { id: { _eq: $id } }) { + id + name + + tiltseries { + acceleration_voltage + aligned_tiltseries_binning + binning_from_frames + camera_manufacturer + camera_model + data_acquisition_software + id + is_aligned + microscope_additional_info + microscope_energy_filter + microscope_image_corrector + microscope_manufacturer + microscope_model + microscope_phase_plate + pixel_spacing + related_empiar_entry + spherical_aberration_constant + tilt_axis + tilt_max + tilt_min + tilt_range + tilt_series_quality + tilt_step + tilting_scheme + total_flux + } + + dataset { + cell_component_name + cell_component_id + cell_name + cell_strain_name + cell_strain_id + cell_type_id + dataset_citations + dataset_publications + deposition_date + description + grid_preparation + id + last_modified_date + organism_name + organism_taxid + other_setup + related_database_entries + related_database_entries + release_date + s3_prefix + sample_preparation + sample_type + tissue_name + tissue_id + title + + authors( + order_by: { + author_list_order: asc, + }, + ) { + name + email + primary_author_status + corresponding_author_status + } + + authors_with_affiliation: authors(where: { affiliation_name: { _is_null: false } }) { + name + affiliation_name + } + + funding_sources { + funding_agency_name + grant_id + } + } + + tomogram_voxel_spacings(limit: 1) { + id + s3_prefix + + tomograms( + limit: 1, + where: { + is_canonical: { _eq: true } + }, + ) { + affine_transformation_matrix + ctf_corrected + fiducial_alignment_status + id + key_photo_url + name + neuroglancer_config + processing + processing_software + reconstruction_method + reconstruction_software + size_x + size_y + size_z + voxel_spacing + } + } + + annotation_table: tomogram_voxel_spacings { + annotations(limit: $limit, offset: $offset) { + annotation_method + annotation_publication + annotation_software + confidence_precision + confidence_recall + deposition_date + ground_truth_status + ground_truth_used + is_curator_recommended + last_modified_date + object_count + object_description + object_id + object_name + object_state + release_date + + files { + https_path + s3_path + shape_type + } + + authors(order_by: { primary_annotator_status: desc }) { + name + primary_annotator_status + } + + author_affiliations: authors(distinct_on: affiliation_name) { + affiliation_name + } + + authors_aggregate { + aggregate { + count + } + } + } + } + + tomogram_stats: tomogram_voxel_spacings { + annotations(distinct_on: object_name) { + object_name + } + + annotations_aggregate { + aggregate { + count + } + } + + tomogram_processing: tomograms(distinct_on: processing) { + processing + } + + tomogram_resolutions: tomograms(distinct_on: voxel_spacing) { + https_mrc_scale0 + id + processing + s3_mrc_scale0 + size_x + size_y + size_z + voxel_spacing + } + + tomograms_aggregate { + aggregate { + count + } + } + } + + tiltseries_aggregate { + aggregate { + count + avg { + tilt_series_quality + } + } + } + } + } +`) + +export async function getRunById({ + client, + id, + page = 1, +}: { + client: ApolloClient + id: number + page?: number +}) { + return client.query({ + query: GET_RUN_BY_ID_QUERY, + variables: { + id, + limit: MAX_PER_PAGE, + offset: (page - 1) * MAX_PER_PAGE, + }, + }) +} diff --git a/frontend/packages/data-portal/app/routes/browse-data.datasets.tsx b/frontend/packages/data-portal/app/routes/browse-data.datasets.tsx index 4d623f8e4..6bca2e80d 100644 --- a/frontend/packages/data-portal/app/routes/browse-data.datasets.tsx +++ b/frontend/packages/data-portal/app/routes/browse-data.datasets.tsx @@ -1,428 +1,25 @@ import { Button, CellHeaderDirection } from '@czi-sds/components' import { json, LoaderFunctionArgs } from '@remix-run/node' -import { isNumber } from 'lodash-es' -import { match } from 'ts-pattern' -import { gql } from 'app/__generated__' -import { Datasets_Bool_Exp, Order_By } from 'app/__generated__/graphql' +import { Order_By } from 'app/__generated__/graphql' import { apolloClient } from 'app/apollo.server' import { DatasetTable } from 'app/components/BrowseData' import { DatasetFilter } from 'app/components/DatasetFilter' import { ErrorBoundary } from 'app/components/ErrorBoundary' import { NoResults } from 'app/components/NoResults' import { TablePageLayout } from 'app/components/TablePageLayout' -import { MAX_PER_PAGE } from 'app/constants/pagination' -import { DEFAULT_TILT_MAX, DEFAULT_TILT_MIN } from 'app/constants/tiltSeries' -import { - DatasetFilterState, - getDatasetFilter, - useDatasetFilter, -} from 'app/hooks/useDatasetFilter' +import { getBrowseDatasets } from 'app/graphql/getBrowseDatasets.server' +import { useDatasetFilter } from 'app/hooks/useDatasetFilter' import { useDatasets } from 'app/hooks/useDatasets' import { i18n } from 'app/i18n' -const GET_DATASETS_DATA_QUERY = gql(` - query GetDatasetsData( - $limit: Int, - $offset: Int, - $order_by_dataset: order_by, - $filter: datasets_bool_exp, - ) { - datasets( - limit: $limit, - offset: $offset, - order_by: { title: $order_by_dataset }, - where: $filter - ) { - id - title - organism_name - dataset_publications - key_photo_thumbnail_url - related_database_entries - authors( - order_by: { - author_list_order: asc, - }, - ) { - name - primary_author_status - } - - runs_aggregate { - aggregate { - count - } - } - - runs { - tomogram_voxel_spacings { - annotations(distinct_on: object_name) { - object_name - } - } - } - } - - datasets_aggregate { - aggregate { - count - } - } - - filtered_datasets_aggregate: datasets_aggregate(where: $filter) { - aggregate { - count - } - } - - organism_names: datasets(distinct_on: organism_name) { - organism_name - } - - camera_manufacturers: tiltseries(distinct_on: camera_manufacturer) { - camera_manufacturer - } - - reconstruction_methods: tomograms(distinct_on: reconstruction_method) { - reconstruction_method - } - - reconstruction_softwares: tomograms(distinct_on: reconstruction_software) { - reconstruction_software - } - - object_names: annotations(distinct_on: object_name) { - object_name - } - - object_shape_types: annotations { - files(distinct_on: shape_type) { - shape_type - } - } - } -`) - -function getTiltValue(value: string | null) { - if (value && !Number.isNaN(+value)) { - return +value - } - - return null -} - -function getFilter(datasetFilter: DatasetFilterState, query: string) { - const filters: Datasets_Bool_Exp[] = [] - - // Text search by dataset title - if (query) { - filters.push({ - title: { - _ilike: `%${query}%`, - }, - }) - } - - // Included contents filters - // Ground truth filter - if (datasetFilter.includedContents.isGroundTruthEnabled) { - filters.push({ - runs: { - tomogram_voxel_spacings: { - annotations: { - ground_truth_status: { - _eq: true, - }, - }, - }, - }, - }) - } - - // Available files filter - datasetFilter.includedContents.availableFiles.forEach((file) => - match(file) - .with('raw-frames', () => - filters.push({ - runs: { - tiltseries: { - frames_count: { - _gt: 0, - }, - }, - }, - }), - ) - .with('tilt-series', () => - filters.push({ - runs: { - tiltseries_aggregate: { - count: { - predicate: { - _gt: 0, - }, - }, - }, - }, - }), - ) - .with('tilt-series-alignment', () => - filters.push({ - runs: { - tiltseries: { - https_alignment_file: { - _is_null: false, - }, - }, - }, - }), - ) - .with('tomogram', () => - filters.push({ - runs: { - tomogram_voxel_spacings: { - tomograms_aggregate: { - count: { - predicate: { - _gt: 0, - }, - }, - }, - }, - }, - }), - ) - .exhaustive(), - ) - - // Number of runs filter - if (datasetFilter.includedContents.numberOfRuns) { - const runCount = +datasetFilter.includedContents.numberOfRuns.slice(1) - filters.push({ - runs_aggregate: { - count: { - predicate: { _gte: runCount }, - }, - }, - }) - } - - // Id filters - const idFilters: Datasets_Bool_Exp[] = [] - - // Portal ID filter - const portalId = +(datasetFilter.ids.portal ?? Number.NaN) - if (!Number.isNaN(portalId) && portalId > 0) { - idFilters.push({ - id: { - _eq: portalId, - }, - }) - } - - // Empiar filter - const empiarId = datasetFilter.ids.empiar - if (empiarId) { - idFilters.push({ - related_database_entries: { - _like: `%EMPIAR-${empiarId}%`, - }, - }) - } - - // EMDB filter - const emdbId = datasetFilter.ids.emdb - if (emdbId) { - idFilters.push({ - related_database_entries: { - _like: `%EMD-${emdbId}%`, - }, - }) - } - - if (idFilters.length > 0) { - filters.push({ _or: idFilters }) - } - - // Author filters - - // Author name filter - if (datasetFilter.author.name) { - filters.push({ - authors: { - name: { - _ilike: `%${datasetFilter.author.name}%`, - }, - }, - }) - } - - // Author Orcid filter - if (datasetFilter.author.orcid) { - filters.push({ - authors: { - orcid: { - _ilike: `%${datasetFilter.author.orcid}%`, - }, - }, - }) - } - - // Sample and experiment condition filters - const { organismNames } = datasetFilter.sampleAndExperimentConditions - - // Organism name filter - if (organismNames.length > 0) { - filters.push({ - organism_name: { _in: organismNames }, - }) - } - - // Hardware filters - // Camera manufacturer filter - if (datasetFilter.hardware.cameraManufacturer) { - filters.push({ - runs: { - tiltseries: { - camera_manufacturer: { - _eq: datasetFilter.hardware.cameraManufacturer, - }, - }, - }, - }) - } - - // Tilt series metadata filters - let tiltMin = getTiltValue(datasetFilter.tiltSeries.min) - let tiltMax = getTiltValue(datasetFilter.tiltSeries.max) - - if (isNumber(tiltMin) && !isNumber(tiltMax)) { - tiltMax = DEFAULT_TILT_MAX - } - - if (!isNumber(tiltMin) && isNumber(tiltMax)) { - tiltMin = DEFAULT_TILT_MIN - } - - // Tilt range filter - if (tiltMin) { - filters.push({ - runs: { - tiltseries: { - tilt_range: { - _gte: tiltMin, - }, - }, - }, - }) - } - - if (tiltMax) { - filters.push({ - runs: { - tiltseries: { - tilt_range: { - _lte: tiltMax, - }, - }, - }, - }) - } - - // Tomogram metadata filters - if (datasetFilter.tomogram.fiducialAlignmentStatus) { - filters.push({ - runs: { - tomogram_voxel_spacings: { - tomograms: { - fiducial_alignment_status: { - _eq: - datasetFilter.tomogram.fiducialAlignmentStatus === 'true' - ? 'FIDUCIAL' - : 'NON_FIDUCIAL', - }, - }, - }, - }, - }) - } - - // Reconstruction method filter - if (datasetFilter.tomogram.reconstructionMethod) { - filters.push({ - runs: { - tomogram_voxel_spacings: { - tomograms: { - reconstruction_method: { - _eq: datasetFilter.tomogram.reconstructionMethod, - }, - }, - }, - }, - }) - } - - // Reconstruction software filter - if (datasetFilter.tomogram.reconstructionSoftware) { - filters.push({ - runs: { - tomogram_voxel_spacings: { - tomograms: { - reconstruction_software: { - _eq: datasetFilter.tomogram.reconstructionSoftware, - }, - }, - }, - }, - }) - } - - // Annotation filters - const { objectNames, objectShapeTypes } = datasetFilter.annotation - - // Object names filter - if (objectNames.length > 0) { - filters.push({ - runs: { - tomogram_voxel_spacings: { - annotations: { - object_name: { - _in: objectNames, - }, - }, - }, - }, - }) - } - - // Object shape type filter - if (objectShapeTypes.length > 0) { - filters.push({ - runs: { - tomogram_voxel_spacings: { - annotations: { - files: { - shape_type: { - _in: objectShapeTypes, - }, - }, - }, - }, - }, - }) - } - - return { _and: filters } as Datasets_Bool_Exp -} - export async function loader({ request }: LoaderFunctionArgs) { const url = new URL(request.url) const page = +(url.searchParams.get('page') ?? '1') const sort = (url.searchParams.get('sort') ?? undefined) as | CellHeaderDirection | undefined - const search = url.searchParams.get('search') ?? '' + const query = url.searchParams.get('search') ?? '' let orderBy: Order_By | null = null @@ -430,14 +27,12 @@ export async function loader({ request }: LoaderFunctionArgs) { orderBy = sort === 'asc' ? Order_By.Asc : Order_By.Desc } - const { data } = await apolloClient.query({ - query: GET_DATASETS_DATA_QUERY, - variables: { - limit: MAX_PER_PAGE, - offset: (page - 1) * MAX_PER_PAGE, - order_by_dataset: orderBy, - filter: getFilter(getDatasetFilter(url.searchParams), search), - }, + const { data } = await getBrowseDatasets({ + orderBy, + page, + query, + client: apolloClient, + params: url.searchParams, }) return json(data) diff --git a/frontend/packages/data-portal/app/routes/datasets.$id.tsx b/frontend/packages/data-portal/app/routes/datasets.$id.tsx index 2bcf1c483..df09af98c 100644 --- a/frontend/packages/data-portal/app/routes/datasets.$id.tsx +++ b/frontend/packages/data-portal/app/routes/datasets.$id.tsx @@ -3,130 +3,18 @@ import { ShouldRevalidateFunctionArgs } from '@remix-run/react' import { json, LoaderFunctionArgs } from '@remix-run/server-runtime' -import { gql } from 'app/__generated__' import { apolloClient } from 'app/apollo.server' import { DatasetMetadataDrawer } from 'app/components/Dataset' import { DatasetHeader } from 'app/components/Dataset/DatasetHeader' import { RunsTable } from 'app/components/Dataset/RunsTable' import { DownloadModal } from 'app/components/Download' import { TablePageLayout } from 'app/components/TablePageLayout' -import { MAX_PER_PAGE } from 'app/constants/pagination' import { QueryParams } from 'app/constants/query' +import { getDatasetById } from 'app/graphql/getDatasetById.server' import { useDatasetById } from 'app/hooks/useDatasetById' import { i18n } from 'app/i18n' import { shouldRevalidatePage } from 'app/utils/revalidate' -const GET_DATASET_BY_ID = gql(` - query GetDatasetById($id: Int, $run_limit: Int, $run_offset: Int) { - datasets(where: { id: { _eq: $id } }) { - s3_prefix - - # key photo - key_photo_url - - # Dataset dates - last_modified_date - release_date - deposition_date - - # Dataset metadata - id - title - description - - funding_sources { - funding_agency_name - grant_id - } - - related_database_entries - dataset_citations - - # Sample and experiments data - cell_component_name - cell_component_id - cell_name - cell_strain_name - cell_strain_id - cell_type_id - grid_preparation - organism_name - organism_taxid - other_setup - sample_preparation - sample_type - tissue_name - tissue_id - authors( - order_by: { - author_list_order: asc, - }, - ) { - name - email - primary_author_status - corresponding_author_status - } - - authors_with_affiliation: authors(where: {affiliation_name: {_is_null: false}}) { - name - affiliation_name - } - - # publication info - related_database_entries - dataset_publications - - # Tilt Series - run_metadata: runs(limit: 1) { - tiltseries(limit: 1) { - acceleration_voltage - spherical_aberration_constant - microscope_manufacturer - microscope_model - microscope_energy_filter - microscope_phase_plate - microscope_image_corrector - microscope_additional_info - camera_manufacturer - camera_model - } - } - - runs(limit: $run_limit, offset: $run_offset) { - id - name - - tiltseries_aggregate { - aggregate { - avg { - tilt_series_quality - } - } - } - - tomogram_voxel_spacings { - annotations(distinct_on: object_name) { - object_name - } - - tomograms(limit: 1) { - id - key_photo_thumbnail_url - neuroglancer_config - } - } - } - - runs_aggregate { - aggregate { - count - } - } - } - } -`) - export async function loader({ params, request }: LoaderFunctionArgs) { const id = params.id ? +params.id : NaN @@ -140,13 +28,10 @@ export async function loader({ params, request }: LoaderFunctionArgs) { }) } - const { data } = await apolloClient.query({ - query: GET_DATASET_BY_ID, - variables: { - id: +id, - run_limit: MAX_PER_PAGE, - run_offset: (page - 1) * MAX_PER_PAGE, - }, + const { data } = await getDatasetById({ + id, + page, + client: apolloClient, }) if (data.datasets.length === 0) { diff --git a/frontend/packages/data-portal/app/routes/runs.$id.tsx b/frontend/packages/data-portal/app/routes/runs.$id.tsx index 60b6803da..a06beee14 100644 --- a/frontend/packages/data-portal/app/routes/runs.$id.tsx +++ b/frontend/packages/data-portal/app/routes/runs.$id.tsx @@ -5,7 +5,6 @@ import { json, LoaderFunctionArgs } from '@remix-run/server-runtime' import { AxiosResponse } from 'axios' import { isNumber, sum } from 'lodash-es' -import { gql } from 'app/__generated__' import { apolloClient } from 'app/apollo.server' import { axios } from 'app/axios' import { DownloadModal } from 'app/components/Download' @@ -14,212 +13,14 @@ import { AnnotationDrawer } from 'app/components/Run/AnnotationDrawer' import { AnnotationTable } from 'app/components/Run/AnnotationTable' import { RunMetadataDrawer } from 'app/components/Run/RunMetadataDrawer' import { TablePageLayout } from 'app/components/TablePageLayout' -import { MAX_PER_PAGE } from 'app/constants/pagination' import { QueryParams } from 'app/constants/query' +import { getRunById } from 'app/graphql/getRunById.server' import { useDownloadModalQueryParamState } from 'app/hooks/useDownloadModalQueryParamState' import { useRunById } from 'app/hooks/useRunById' import { i18n } from 'app/i18n' import { DownloadConfig } from 'app/types/download' import { shouldRevalidatePage } from 'app/utils/revalidate' -const GET_RUN_BY_ID_QUERY = gql(` - query GetRunById($id: Int, $limit: Int, $offset: Int) { - runs(where: { id: { _eq: $id } }) { - id - name - - tiltseries { - acceleration_voltage - aligned_tiltseries_binning - binning_from_frames - camera_manufacturer - camera_model - data_acquisition_software - id - is_aligned - microscope_additional_info - microscope_energy_filter - microscope_image_corrector - microscope_manufacturer - microscope_model - microscope_phase_plate - pixel_spacing - related_empiar_entry - spherical_aberration_constant - tilt_axis - tilt_max - tilt_min - tilt_range - tilt_series_quality - tilt_step - tilting_scheme - total_flux - } - - dataset { - cell_component_name - cell_component_id - cell_name - cell_strain_name - cell_strain_id - cell_type_id - dataset_citations - dataset_publications - deposition_date - description - grid_preparation - id - last_modified_date - organism_name - organism_taxid - other_setup - related_database_entries - related_database_entries - release_date - s3_prefix - sample_preparation - sample_type - tissue_name - tissue_id - title - - authors( - order_by: { - author_list_order: asc, - }, - ) { - name - email - primary_author_status - corresponding_author_status - } - - authors_with_affiliation: authors(where: { affiliation_name: { _is_null: false } }) { - name - affiliation_name - } - - funding_sources { - funding_agency_name - grant_id - } - } - - tomogram_voxel_spacings(limit: 1) { - id - s3_prefix - - tomograms( - limit: 1, - where: { - is_canonical: { _eq: true } - }, - ) { - affine_transformation_matrix - ctf_corrected - fiducial_alignment_status - id - key_photo_url - name - neuroglancer_config - processing - processing_software - reconstruction_method - reconstruction_software - size_x - size_y - size_z - voxel_spacing - } - } - - annotation_table: tomogram_voxel_spacings { - annotations(limit: $limit, offset: $offset) { - annotation_method - annotation_publication - annotation_software - confidence_precision - confidence_recall - deposition_date - ground_truth_status - ground_truth_used - is_curator_recommended - last_modified_date - object_count - object_description - object_id - object_name - object_state - release_date - - files { - https_path - s3_path - shape_type - } - - authors(order_by: { primary_annotator_status: desc }) { - name - primary_annotator_status - } - - author_affiliations: authors(distinct_on: affiliation_name) { - affiliation_name - } - - authors_aggregate { - aggregate { - count - } - } - } - } - - tomogram_stats: tomogram_voxel_spacings { - annotations(distinct_on: object_name) { - object_name - } - - annotations_aggregate { - aggregate { - count - } - } - - tomogram_processing: tomograms(distinct_on: processing) { - processing - } - - tomogram_resolutions: tomograms(distinct_on: voxel_spacing) { - https_mrc_scale0 - id - processing - s3_mrc_scale0 - size_x - size_y - size_z - voxel_spacing - } - - tomograms_aggregate { - aggregate { - count - } - } - } - - tiltseries_aggregate { - aggregate { - count - avg { - tilt_series_quality - } - } - } - } - } -`) - export async function loader({ request, params }: LoaderFunctionArgs) { const id = params.id ? +params.id : NaN @@ -233,13 +34,10 @@ export async function loader({ request, params }: LoaderFunctionArgs) { const url = new URL(request.url) const page = +(url.searchParams.get(QueryParams.Page) ?? '1') - const { data } = await apolloClient.query({ - query: GET_RUN_BY_ID_QUERY, - variables: { - id: +id, - limit: MAX_PER_PAGE, - offset: (page - 1) * MAX_PER_PAGE, - }, + const { data } = await getRunById({ + id, + page, + client: apolloClient, }) if (data.runs.length === 0) { diff --git a/frontend/packages/data-portal/e2e/apollo.ts b/frontend/packages/data-portal/e2e/apollo.ts new file mode 100644 index 000000000..69ce56521 --- /dev/null +++ b/frontend/packages/data-portal/e2e/apollo.ts @@ -0,0 +1,17 @@ +/** + * Version of `apollo.server.ts` that is safe to use in e2e tests. + */ + +import apollo from '@apollo/client' + +import { ENVIRONMENT_CONTEXT_DEFAULT_VALUE } from '../app/context/Environment.context' + +export function getApolloClient() { + return new apollo.ApolloClient({ + ssrMode: true, + cache: new apollo.InMemoryCache(), + link: apollo.createHttpLink({ + uri: process.env.API_URL ?? ENVIRONMENT_CONTEXT_DEFAULT_VALUE.API_URL, + }), + }) +} diff --git a/frontend/packages/data-portal/e2e/config.example.json b/frontend/packages/data-portal/e2e/config.example.json new file mode 100644 index 000000000..f4d767e16 --- /dev/null +++ b/frontend/packages/data-portal/e2e/config.example.json @@ -0,0 +1,17 @@ +{ + "authorName": "", + "authorOrcId": "", + "cameraManufacturer": "FEI", + "datasetId": "10002", + "emdbId": "EMD-17241", + "empairId": "EMPIAR-11221", + "objectName": "Actin filament", + "objectShapeType": "OrientedPoint", + "organismName1": "Bacillus subtilis", + "organismName2": "Bdellovibrio bacteriovorus", + "organismNameQuery": "bac", + "reconstructionMethod": "Weighted back projection", + "reconstructionObjectName": "Actin filament", + "reconstructionSoftware": "IMOD", + "url": "http://localhost:8080" +} diff --git a/frontend/packages/data-portal/e2e/constants.ts b/frontend/packages/data-portal/e2e/constants.ts new file mode 100644 index 000000000..e96f07ea3 --- /dev/null +++ b/frontend/packages/data-portal/e2e/constants.ts @@ -0,0 +1,5 @@ +import type config from 'e2e/config.example.json' + +export const E2E_CONFIG = JSON.parse(process.env.E2E_CONFIG) as typeof config + +export const BROWSE_DATASETS_URL = `${E2E_CONFIG.url}/browse-data/datasets` diff --git a/frontend/packages/data-portal/e2e/datasetFilter.test.ts b/frontend/packages/data-portal/e2e/datasetFilter.test.ts new file mode 100644 index 000000000..6909e0604 --- /dev/null +++ b/frontend/packages/data-portal/e2e/datasetFilter.test.ts @@ -0,0 +1,61 @@ +import { QueryParams } from 'app/constants/query' + +import { E2E_CONFIG } from './constants' +import { + testAuthorFilter, + testAvailableFilesFilter, + testDatasetIdsFilter, + testGroundTruthAnnotationFilter, + testOrganismNameFilter, + testSingleSelectFilter, +} from './filters' + +testGroundTruthAnnotationFilter() +testAvailableFilesFilter() +testDatasetIdsFilter() +testAuthorFilter() +testOrganismNameFilter() + +testSingleSelectFilter({ + label: 'Number of Runs', + queryParam: QueryParams.NumberOfRuns, + values: ['>1', '>5', '>10', '>20', '>100'], + serialize: JSON.stringify, +}) + +testSingleSelectFilter({ + label: 'Camera Manufacturer', + queryParam: QueryParams.CameraManufacturer, + values: [E2E_CONFIG.cameraManufacturer], +}) + +testSingleSelectFilter({ + label: 'Fiducial Alignment Status', + queryParam: QueryParams.FiducialAlignmentStatus, + values: ['True', 'False'], + serialize: (value) => value.toLowerCase(), +}) + +testSingleSelectFilter({ + label: 'Reconstruction Method', + queryParam: QueryParams.ReconstructionMethod, + values: [E2E_CONFIG.reconstructionMethod], +}) + +testSingleSelectFilter({ + label: 'Reconstruction Software', + queryParam: QueryParams.ReconstructionSoftware, + values: [E2E_CONFIG.reconstructionSoftware], +}) + +testSingleSelectFilter({ + label: 'Object Name', + queryParam: QueryParams.ObjectName, + values: [E2E_CONFIG.objectName], +}) + +testSingleSelectFilter({ + label: 'Object Shape Type', + queryParam: QueryParams.ObjectShapeType, + values: [E2E_CONFIG.objectShapeType], +}) diff --git a/frontend/packages/data-portal/e2e/filters/index.ts b/frontend/packages/data-portal/e2e/filters/index.ts new file mode 100644 index 000000000..83c130c3c --- /dev/null +++ b/frontend/packages/data-portal/e2e/filters/index.ts @@ -0,0 +1,6 @@ +export * from './testAuthorFilter' +export * from './testAvailableFilesFilter' +export * from './testDatasetIdsFilter' +export * from './testGroundTruthAnnotationFilter' +export * from './testOrganismNameFilter' +export * from './testSingleSelectFilter' diff --git a/frontend/packages/data-portal/e2e/filters/testAuthorFilter.ts b/frontend/packages/data-portal/e2e/filters/testAuthorFilter.ts new file mode 100644 index 000000000..e76e4f130 --- /dev/null +++ b/frontend/packages/data-portal/e2e/filters/testAuthorFilter.ts @@ -0,0 +1,125 @@ +import apollo from '@apollo/client' +import { Page, test } from '@playwright/test' +import { getApolloClient } from 'e2e/apollo' +import { BROWSE_DATASETS_URL, E2E_CONFIG } from 'e2e/constants' + +import { QueryParams } from 'app/constants/query' +import { getBrowseDatasets } from 'app/graphql/getBrowseDatasets.server' + +import { getDatasetTableFilterValidator, validateTable } from './utils' + +async function openAuthorFilter(page: Page) { + await page.getByRole('button', { name: 'Author' }).click() +} + +async function fillAuthorFilterInput(page: Page, label: string, value: string) { + const inputLabel = `${label}:` + await page.getByLabel(inputLabel).click() + await page.getByLabel(inputLabel).fill(value) +} + +async function applyAuthorFilter(page: Page) { + await page.getByRole('button', { name: 'Apply' }).click() +} + +async function clearAuthorFilter(page: Page, label: string, value: string) { + await page + .locator('div') + .filter({ hasText: new RegExp(`^Author${label}:${value}$`) }) + .getByRole('button') + .nth(1) + .click() +} + +function testFilter({ + client, + label, + queryParam, + valueKey, +}: { + client: apollo.ApolloClient + label: string + queryParam: QueryParams + valueKey: keyof typeof E2E_CONFIG +}) { + const value = E2E_CONFIG[valueKey] + + test(`should filter by ${label}`, async ({ page }) => { + const expectedUrl = new URL(BROWSE_DATASETS_URL) + const params = expectedUrl.searchParams + params.append(queryParam, value) + const fetchExpectedData = getBrowseDatasets({ client, params }) + + await page.goto(BROWSE_DATASETS_URL) + await openAuthorFilter(page) + await fillAuthorFilterInput(page, label, value) + await applyAuthorFilter(page) + await page.waitForURL(expectedUrl.href) + + const { data } = await fetchExpectedData + await validateTable({ + page, + browseDatasetsData: data, + validateRows: getDatasetTableFilterValidator(data), + }) + }) + + test(`should filter by ${label} when opening URL`, async ({ page }) => { + const expectedUrl = new URL(BROWSE_DATASETS_URL) + const params = expectedUrl.searchParams + params.append(queryParam, value) + const fetchExpectedData = getBrowseDatasets({ client, params }) + + await page.goto(expectedUrl.href) + + const { data } = await fetchExpectedData + await validateTable({ + page, + browseDatasetsData: data, + validateRows: getDatasetTableFilterValidator(data), + }) + }) + + test(`should clear ${label} filter`, async ({ page }) => { + const fetchExpectedData = getBrowseDatasets({ client }) + + const expectedUrl = new URL(BROWSE_DATASETS_URL) + const params = expectedUrl.searchParams + params.append(queryParam, value) + + await page.goto(expectedUrl.href) + await clearAuthorFilter(page, label, value) + await page.waitForURL(BROWSE_DATASETS_URL) + + const { data } = await fetchExpectedData + await validateTable({ + page, + browseDatasetsData: data, + validateRows: getDatasetTableFilterValidator(data), + }) + }) +} + +export function testAuthorFilter() { + test.describe('Author', () => { + let client = getApolloClient() + + test.beforeEach(() => { + client = getApolloClient() + }) + + testFilter({ + client, + queryParam: QueryParams.AuthorName, + label: 'Author Name', + valueKey: 'authorName', + }) + + testFilter({ + client, + queryParam: QueryParams.AuthorOrcid, + label: 'Author ORCID', + valueKey: 'authorOrcId', + }) + }) +} diff --git a/frontend/packages/data-portal/e2e/filters/testAvailableFilesFilter.ts b/frontend/packages/data-portal/e2e/filters/testAvailableFilesFilter.ts new file mode 100644 index 000000000..71de0bc42 --- /dev/null +++ b/frontend/packages/data-portal/e2e/filters/testAvailableFilesFilter.ts @@ -0,0 +1,143 @@ +import { Page, test } from '@playwright/test' +import { getApolloClient } from 'e2e/apollo' +import { BROWSE_DATASETS_URL } from 'e2e/constants' + +import { QueryParams } from 'app/constants/query' +import { getBrowseDatasets } from 'app/graphql/getBrowseDatasets.server' + +import { getDatasetTableFilterValidator, validateTable } from './utils' + +async function clickAvailableFilesButton(page: Page) { + await page.getByRole('button', { name: 'Available Files' }).click() +} + +async function selectAvailableFilesOption(page: Page, label: string) { + await page.click(`li:has-text("${label}")`) + await page.keyboard.press('Escape') +} + +async function clearAvailableFileOption(page: Page, labels: string[]) { + await Promise.all( + labels.map((label) => page.click(`[role=button]:has-text("${label}") svg`)), + ) +} + +export function testAvailableFilesFilter() { + test.describe('Available Files', () => { + let client = getApolloClient() + + test.beforeEach(() => { + client = getApolloClient() + }) + + const filterOptions = [ + { value: 'raw-frames', label: 'Raw Frames' }, + { value: 'tilt-series', label: 'Tilt Series' }, + { value: 'tilt-series-alignment', label: 'Tilt Series Alignment' }, + { value: 'tomogram', label: 'Tomograms' }, + ] + + filterOptions.forEach((option) => + test(`should filter when selecting ${option.label}`, async ({ page }) => { + const expectedUrl = new URL(BROWSE_DATASETS_URL) + const params = expectedUrl.searchParams + params.append(QueryParams.AvailableFiles, option.value) + + const fetchExpectedData = getBrowseDatasets({ + client, + params, + }) + + await page.goto(BROWSE_DATASETS_URL) + await clickAvailableFilesButton(page) + await selectAvailableFilesOption(page, option.label) + await page.waitForURL(expectedUrl.href) + + const { data } = await fetchExpectedData + await validateTable({ + page, + browseDatasetsData: data, + validateRows: getDatasetTableFilterValidator(data), + }) + }), + ) + + test('should filter when selecting multiple', async ({ page }) => { + const expectedUrl = new URL(BROWSE_DATASETS_URL) + await page.goto(expectedUrl.href) + + const params = expectedUrl.searchParams + const files = [ + { value: 'raw-frames', label: 'Raw Frames' }, + { value: 'tilt-series-alignment', label: 'Tilt Series Alignment' }, + ] + + for (const { value, label } of files) { + params.append(QueryParams.AvailableFiles, value) + + const fetchExpectedData = getBrowseDatasets({ + client, + params, + }) + + await clickAvailableFilesButton(page) + await selectAvailableFilesOption(page, label) + await page.waitForURL(expectedUrl.href) + + const { data } = await fetchExpectedData + await validateTable({ + page, + browseDatasetsData: data, + validateRows: getDatasetTableFilterValidator(data), + }) + } + }) + + test('should filter when opening URL', async ({ page }) => { + const expectedUrl = new URL(BROWSE_DATASETS_URL) + const params = expectedUrl.searchParams + params.append(QueryParams.AvailableFiles, 'raw-frames') + params.append(QueryParams.AvailableFiles, 'tilt-series-alignment') + + const fetchExpectedData = getBrowseDatasets({ + client, + params, + }) + + await page.goto(expectedUrl.href) + + const { data } = await fetchExpectedData + await validateTable({ + page, + browseDatasetsData: data, + validateRows: getDatasetTableFilterValidator(data), + }) + }) + + test('should disable filter when deselecting', async ({ page }) => { + const fetchExpectedData = getBrowseDatasets({ + client, + }) + + const expectedUrl = new URL(BROWSE_DATASETS_URL) + expectedUrl.searchParams.append(QueryParams.AvailableFiles, 'raw-frames') + expectedUrl.searchParams.append( + QueryParams.AvailableFiles, + 'tilt-series-alignment', + ) + + await page.goto(expectedUrl.href) + await clickAvailableFilesButton(page) + await selectAvailableFilesOption(page, 'Raw Frames') + await clearAvailableFileOption(page, ['Tilt Series Alignment']) + await page.waitForURL(BROWSE_DATASETS_URL) + + const { data } = await fetchExpectedData + await validateTable({ + page, + browseDatasetsData: data, + validateRows: getDatasetTableFilterValidator(data), + }) + }) + }) +} diff --git a/frontend/packages/data-portal/e2e/filters/testDatasetIdsFilter.ts b/frontend/packages/data-portal/e2e/filters/testDatasetIdsFilter.ts new file mode 100644 index 000000000..e1cdddf39 --- /dev/null +++ b/frontend/packages/data-portal/e2e/filters/testDatasetIdsFilter.ts @@ -0,0 +1,132 @@ +import apollo from '@apollo/client' +import { Page, test } from '@playwright/test' +import { getApolloClient } from 'e2e/apollo' +import { BROWSE_DATASETS_URL, E2E_CONFIG } from 'e2e/constants' + +import { QueryParams } from 'app/constants/query' +import { getBrowseDatasets } from 'app/graphql/getBrowseDatasets.server' + +import { getDatasetTableFilterValidator, validateTable } from './utils' + +async function openDatasetIds(page: Page) { + await page.getByRole('button', { name: 'Dataset IDs' }).click() +} + +async function fillDatasetIdInput(page: Page, label: string, value: string) { + const pageLabel = `${label}:` + await page.getByLabel(pageLabel).click() + await page.getByLabel(pageLabel).fill(value) +} + +async function applyDatasetIdFilter(page: Page) { + await page.getByRole('button', { name: 'Apply' }).click() +} + +async function clearDatasetIdFilter(page: Page, label: string, value: string) { + await page + .locator('div') + .filter({ hasText: new RegExp(`^Dataset IDs${label}:${value}$`) }) + .getByRole('button') + .nth(1) + .click() +} + +function testFilter({ + client, + queryParam, + label, + valueKey, +}: { + client: apollo.ApolloClient + queryParam: QueryParams + label: string + valueKey: keyof typeof E2E_CONFIG +}) { + const value = E2E_CONFIG[valueKey] + + test(`should filter by ${label}`, async ({ page }) => { + const expectedUrl = new URL(BROWSE_DATASETS_URL) + const params = expectedUrl.searchParams + params.append(queryParam, value) + const fetchExpectedData = getBrowseDatasets({ client, params }) + + await page.goto(BROWSE_DATASETS_URL) + await openDatasetIds(page) + await fillDatasetIdInput(page, label, value) + await applyDatasetIdFilter(page) + await page.waitForURL(expectedUrl.href) + + const { data } = await fetchExpectedData + await validateTable({ + page, + browseDatasetsData: data, + validateRows: getDatasetTableFilterValidator(data), + }) + }) + + test(`should filter by ${label} when opening URL`, async ({ page }) => { + const expectedUrl = new URL(BROWSE_DATASETS_URL) + const params = expectedUrl.searchParams + params.append(queryParam, value) + + const fetchExpectedData = getBrowseDatasets({ client, params }) + + await page.goto(expectedUrl.href) + + const { data } = await fetchExpectedData + await validateTable({ + page, + browseDatasetsData: data, + validateRows: getDatasetTableFilterValidator(data), + }) + }) + + test(`should clear ${label} filter`, async ({ page }) => { + const fetchExpectedData = getBrowseDatasets({ client }) + + const expectedUrl = new URL(BROWSE_DATASETS_URL) + expectedUrl.searchParams.append(queryParam, value) + + await page.goto(expectedUrl.href) + await clearDatasetIdFilter(page, label, value) + await page.waitForURL(BROWSE_DATASETS_URL) + + const { data } = await fetchExpectedData + await validateTable({ + page, + browseDatasetsData: data, + validateRows: getDatasetTableFilterValidator(data), + }) + }) +} + +export function testDatasetIdsFilter() { + test.describe('Dataset IDs', () => { + let client = getApolloClient() + + test.beforeEach(() => { + client = getApolloClient() + }) + + testFilter({ + client, + queryParam: QueryParams.PortalId, + label: 'Portal ID', + valueKey: 'datasetId', + }) + + testFilter({ + client, + queryParam: QueryParams.EmpiarId, + label: 'Empiar ID', + valueKey: 'empairId', + }) + + testFilter({ + client, + queryParam: QueryParams.EmdbId, + label: 'EMDB', + valueKey: 'emdbId', + }) + }) +} diff --git a/frontend/packages/data-portal/e2e/filters/testGroundTruthAnnotationFilter.ts b/frontend/packages/data-portal/e2e/filters/testGroundTruthAnnotationFilter.ts new file mode 100644 index 000000000..374d82cb8 --- /dev/null +++ b/frontend/packages/data-portal/e2e/filters/testGroundTruthAnnotationFilter.ts @@ -0,0 +1,77 @@ +import { test } from '@playwright/test' +import { getApolloClient } from 'e2e/apollo' +import { BROWSE_DATASETS_URL } from 'e2e/constants' + +import { QueryParams } from 'app/constants/query' +import { getBrowseDatasets } from 'app/graphql/getBrowseDatasets.server' + +import { getDatasetTableFilterValidator, validateTable } from './utils' + +export function testGroundTruthAnnotationFilter() { + test.describe('Ground Truth Annotation', () => { + let client = getApolloClient() + + test.beforeEach(() => { + client = getApolloClient() + }) + + test('should filter on click', async ({ page }) => { + const expectedUrl = new URL(BROWSE_DATASETS_URL) + const params = expectedUrl.searchParams + params.set(QueryParams.GroundTruthAnnotation, 'true') + + const fetchExpectedData = getBrowseDatasets({ + client, + params, + }) + + await page.goto(BROWSE_DATASETS_URL) + await page.getByText('Ground Truth Annotation').click() + await page.waitForURL(expectedUrl.href) + + const { data } = await fetchExpectedData + await validateTable({ + page, + browseDatasetsData: data, + validateRows: getDatasetTableFilterValidator(data), + }) + }) + + test('should filter when opening URL', async ({ page }) => { + const expectedUrl = new URL(BROWSE_DATASETS_URL) + expectedUrl.searchParams.set(QueryParams.GroundTruthAnnotation, 'true') + const fetchExpectedData = getBrowseDatasets({ + client, + params: expectedUrl.searchParams, + }) + + await page.goto(expectedUrl.href) + + const { data } = await fetchExpectedData + await validateTable({ + page, + browseDatasetsData: data, + validateRows: getDatasetTableFilterValidator(data), + }) + }) + + test('should disable filter on click', async ({ page }) => { + const fetchExpectedData = getBrowseDatasets({ client }) + + const expectedUrl = new URL(BROWSE_DATASETS_URL) + const params = expectedUrl.searchParams + params.set(QueryParams.GroundTruthAnnotation, 'true') + + await page.goto(expectedUrl.href) + await page.getByText('Ground Truth Annotation').click() + await page.waitForURL(BROWSE_DATASETS_URL) + + const { data } = await fetchExpectedData + await validateTable({ + page, + browseDatasetsData: data, + validateRows: getDatasetTableFilterValidator(data), + }) + }) + }) +} diff --git a/frontend/packages/data-portal/e2e/filters/testOrganismNameFilter.ts b/frontend/packages/data-portal/e2e/filters/testOrganismNameFilter.ts new file mode 100644 index 000000000..bbc0d61d3 --- /dev/null +++ b/frontend/packages/data-portal/e2e/filters/testOrganismNameFilter.ts @@ -0,0 +1,145 @@ +import { expect, Page, test } from '@playwright/test' +import { getApolloClient } from 'e2e/apollo' +import { BROWSE_DATASETS_URL, E2E_CONFIG } from 'e2e/constants' +import { isString } from 'lodash-es' + +import { QueryParams } from 'app/constants/query' +import { getBrowseDatasets } from 'app/graphql/getBrowseDatasets.server' + +import { getDatasetTableFilterValidator, validateTable } from './utils' + +async function openOrganismNameFilter(page: Page) { + await page.getByRole('button', { name: 'Organism Name' }).click() +} + +async function selectOrganismNames(page: Page, ...values: string[]) { + for (const value of values) { + await page.getByRole('option', { name: value }).locator('div').click() + } + + await page.keyboard.press('Escape') +} + +async function deselectNumberOfRuns(page: Page, value: string) { + await page.click(`[role=button]:has-text("${value}") svg`) +} + +export function testOrganismNameFilter() { + test.describe('Organism Name', () => { + let client = getApolloClient() + + test.beforeEach(() => { + client = getApolloClient() + }) + + test('should filter by organism name', async ({ page }) => { + const expectedUrl = new URL(BROWSE_DATASETS_URL) + const params = expectedUrl.searchParams + params.append(QueryParams.Organism, E2E_CONFIG.organismName1) + const fetchExpectedData = getBrowseDatasets({ + client, + params, + }) + + await page.goto(BROWSE_DATASETS_URL) + await openOrganismNameFilter(page) + await selectOrganismNames(page, E2E_CONFIG.organismName1) + await page.waitForURL(expectedUrl.href) + + const { data } = await fetchExpectedData + await validateTable({ + page, + browseDatasetsData: data, + validateRows: getDatasetTableFilterValidator(data), + }) + }) + + test('should filter when opening URL', async ({ page }) => { + const expectedUrl = new URL(BROWSE_DATASETS_URL) + const params = expectedUrl.searchParams + params.append(QueryParams.Organism, E2E_CONFIG.organismName1) + + const fetchExpectedData = getBrowseDatasets({ + client, + params, + }) + + await page.goto(expectedUrl.href) + + const { data } = await fetchExpectedData + await validateTable({ + page, + browseDatasetsData: data, + validateRows: getDatasetTableFilterValidator(data), + }) + }) + + test('should filter multiple values', async ({ page }) => { + const expectedUrl = new URL(BROWSE_DATASETS_URL) + const params = expectedUrl.searchParams + params.append(QueryParams.Organism, E2E_CONFIG.organismName1) + params.append(QueryParams.Organism, E2E_CONFIG.organismName2) + + const fetchExpectedData = getBrowseDatasets({ + client, + params, + }) + + await page.goto(expectedUrl.href) + + const { data } = await fetchExpectedData + await validateTable({ + page, + browseDatasetsData: data, + validateRows: getDatasetTableFilterValidator(data), + }) + }) + + test('should filter values within filter dropdown', async ({ page }) => { + const fetchExpectedData = getBrowseDatasets({ client }) + + await page.goto(BROWSE_DATASETS_URL) + await openOrganismNameFilter(page) + + const searchInput = page.getByRole('combobox', { name: 'Search' }) + await searchInput.click() + await searchInput.fill(E2E_CONFIG.organismNameQuery) + + const { data } = await fetchExpectedData + const organismNames = data.organism_names + .map((name) => name.organism_name) + .filter(isString) + + const filteredOrganismNames = organismNames.filter((name) => + name.toLowerCase().includes(E2E_CONFIG.organismNameQuery), + ) + + await Promise.all( + filteredOrganismNames.map((name) => + expect( + page.getByRole('option', { name }).locator('div'), + ).toBeVisible(), + ), + ) + }) + + test('should clear filter', async ({ page }) => { + const fetchExpectedData = getBrowseDatasets({ client }) + + const expectedUrl = new URL(BROWSE_DATASETS_URL) + const params = expectedUrl.searchParams + params.append(QueryParams.Organism, E2E_CONFIG.organismName1) + + await page.goto(expectedUrl.href) + await deselectNumberOfRuns(page, E2E_CONFIG.organismName1) + await page.waitForURL(BROWSE_DATASETS_URL) + + const { data } = await fetchExpectedData + await validateTable({ + page, + browseDatasetsData: data, + validateRows: getDatasetTableFilterValidator(data), + }) + }) + }) +} diff --git a/frontend/packages/data-portal/e2e/filters/testSingleSelectFilter.ts b/frontend/packages/data-portal/e2e/filters/testSingleSelectFilter.ts new file mode 100644 index 000000000..96c578b3e --- /dev/null +++ b/frontend/packages/data-portal/e2e/filters/testSingleSelectFilter.ts @@ -0,0 +1,112 @@ +import { Page, test } from '@playwright/test' +import { getApolloClient } from 'e2e/apollo' +import { BROWSE_DATASETS_URL } from 'e2e/constants' + +import { QueryParams } from 'app/constants/query' +import { getBrowseDatasets } from 'app/graphql/getBrowseDatasets.server' + +import { getDatasetTableFilterValidator, validateTable } from './utils' + +async function openFilterDropdown(page: Page, label: string) { + await page.getByRole('button', { name: label }).click() +} + +async function selectFilterOption(page: Page, label: string) { + await page + .getByRole('option', { name: label }) + .locator('span') + .first() + .click() + + await page.keyboard.press('Escape') +} + +async function removeFilterOption(page: Page, label: string) { + await page.click(`[role=button]:has-text("${label}") svg`) +} + +export function testSingleSelectFilter({ + label, + queryParam, + serialize = (value) => value, + values, +}: { + label: string + queryParam: QueryParams + serialize?(value: string): string + values: string[] +}) { + test.describe(label, () => { + let client = getApolloClient() + + test.beforeEach(() => { + client = getApolloClient() + }) + + values.forEach((value) => + test(`should filter when selecting ${value}`, async ({ page }) => { + const expectedUrl = new URL(BROWSE_DATASETS_URL) + const params = expectedUrl.searchParams + params.set(queryParam, serialize(value) ?? value) + + const fetchExpectedData = getBrowseDatasets({ + client, + params, + }) + + await page.goto(BROWSE_DATASETS_URL) + + await openFilterDropdown(page, label) + await selectFilterOption(page, value) + await page.waitForURL(expectedUrl.href) + + const { data } = await fetchExpectedData + await validateTable({ + page, + browseDatasetsData: data, + validateRows: getDatasetTableFilterValidator(data), + }) + }), + ) + + test('should filter when opening URL', async ({ page }) => { + const expectedUrl = new URL(BROWSE_DATASETS_URL) + const params = expectedUrl.searchParams + params.append(queryParam, serialize(values[0])) + + const fetchExpectedData = getBrowseDatasets({ + client, + params, + }) + + await page.goto(expectedUrl.href) + + const { data } = await fetchExpectedData + await validateTable({ + page, + browseDatasetsData: data, + validateRows: getDatasetTableFilterValidator(data), + }) + }) + + test('should disable filter when deselecting', async ({ page }) => { + const fetchExpectedData = getBrowseDatasets({ + client, + }) + + const expectedUrl = new URL(BROWSE_DATASETS_URL) + expectedUrl.searchParams.append(queryParam, serialize(values[0])) + + await page.goto(expectedUrl.href) + await removeFilterOption(page, values[0]) + await page.waitForURL(BROWSE_DATASETS_URL) + + const { data } = await fetchExpectedData + await validateTable({ + page, + browseDatasetsData: data, + validateRows: getDatasetTableFilterValidator(data), + }) + }) + }) +} diff --git a/frontend/packages/data-portal/e2e/filters/utils.ts b/frontend/packages/data-portal/e2e/filters/utils.ts new file mode 100644 index 000000000..586c48b1e --- /dev/null +++ b/frontend/packages/data-portal/e2e/filters/utils.ts @@ -0,0 +1,66 @@ +import { expect, Page } from '@playwright/test' + +import { GetDatasetsDataQuery } from 'app/__generated__/graphql' + +async function waitForTableCountChange( + page: Page, + expectedFilterCount: number, + expectedTotalCount: number, +) { + await page + .getByText( + new RegExp(`^${expectedFilterCount} of ${expectedTotalCount} Datasets$`), + ) + .waitFor() +} + +/** + * Validator for testing filters on the dataset table. This works by checking if + * each row in the table has an existing dataset within the provided data. No + * additional data is tested in this function to keep tests fast and focused on + * testing the functionality of the filter. + */ +export function getDatasetTableFilterValidator( + expectedData: GetDatasetsDataQuery, +) { + const datasetIdSet = new Set( + expectedData.datasets.map((dataset) => dataset.id), + ) + + return async (page: Page) => { + const datasetIds = await Promise.all( + (await page.getByText(/Portal ID: [0-9]+/).all()).map(async (node) => { + const text = await node.innerText() + return text.replace('Portal ID: ', '') + }), + ) + + datasetIds.forEach((id) => + expect( + datasetIdSet.has(+id), + `Check if dataset ${id} is found within available set: ${Array.from( + datasetIdSet, + ).join(', ')}`, + ).toBe(true), + ) + } +} + +export async function validateTable({ + browseDatasetsData, + page, + validateRows, +}: { + browseDatasetsData?: GetDatasetsDataQuery + page: Page + validateRows(page: Page): Promise +}) { + const expectedFilterCount = + browseDatasetsData?.filtered_datasets_aggregate.aggregate?.count ?? 0 + + const expectedTotalCount = + browseDatasetsData?.datasets_aggregate.aggregate?.count ?? 0 + + await waitForTableCountChange(page, expectedFilterCount, expectedTotalCount) + await validateRows(page) +} diff --git a/frontend/packages/data-portal/globals.d.ts b/frontend/packages/data-portal/globals.d.ts index 3494e2af7..09aee8aa7 100644 --- a/frontend/packages/data-portal/globals.d.ts +++ b/frontend/packages/data-portal/globals.d.ts @@ -5,6 +5,7 @@ declare namespace NodeJS { readonly CLOUDWATCH_RUM_APP_NAME?: string readonly CLOUDWATCH_RUM_IDENTITY_POOL_ID?: string readonly CLOUDWATCH_RUM_ROLE_ARN?: string + readonly E2E_CONFIG: string readonly ENV: 'local' | 'dev' | 'staging' | 'prod' readonly LOCALHOST_PLAUSIBLE_TRACKING: 'true' | 'false' } diff --git a/frontend/packages/data-portal/jest.config.cjs b/frontend/packages/data-portal/jest.config.cjs index 8750df42c..3f7bb137e 100644 --- a/frontend/packages/data-portal/jest.config.cjs +++ b/frontend/packages/data-portal/jest.config.cjs @@ -2,6 +2,7 @@ module.exports = { preset: 'ts-jest/presets/default-esm', testEnvironment: 'jsdom', + testPathIgnorePatterns: ['./e2e'], moduleNameMapper: { '^app/(.*)$': '/app/$1', diff --git a/frontend/packages/data-portal/package.json b/frontend/packages/data-portal/package.json index 750c61e9c..a9dda2bab 100644 --- a/frontend/packages/data-portal/package.json +++ b/frontend/packages/data-portal/package.json @@ -90,7 +90,7 @@ }, "devDependencies": { "@parcel/watcher": "^2.3.0", - "@playwright/test": "^1.41.1", + "@playwright/test": "^1.41.2", "@remix-run/dev": "^2.0.1", "@tailwindcss/typography": "^0.5.10", "@testing-library/jest-dom": "^6.1.4", @@ -113,7 +113,7 @@ "eslint-plugin-cryoet-data-portal": "*", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", - "playwright": "^1.41.1", + "playwright": "^1.41.2", "postcss": "^8.4.31", "postcss-nesting": "^12.0.1", "stylelint": "^15.10.3", diff --git a/frontend/packages/data-portal/playwright.config.ts b/frontend/packages/data-portal/playwright.config.ts index ce09f8ef3..cfc1ac1fd 100644 --- a/frontend/packages/data-portal/playwright.config.ts +++ b/frontend/packages/data-portal/playwright.config.ts @@ -1,10 +1,7 @@ import { defineConfig, devices } from '@playwright/test' +import dotenv from 'dotenv' -/** - * Read environment variables from file. - * https://github.com/motdotla/dotenv - */ -// require('dotenv').config(); +dotenv.config() /** * See https://playwright.dev/docs/test-configuration. @@ -38,15 +35,15 @@ export default defineConfig({ use: { ...devices['Desktop Chrome'] }, }, - { - name: 'firefox', - use: { ...devices['Desktop Firefox'] }, - }, + // { + // name: 'firefox', + // use: { ...devices['Desktop Firefox'] }, + // }, - { - name: 'webkit', - use: { ...devices['Desktop Safari'] }, - }, + // { + // name: 'webkit', + // use: { ...devices['Desktop Safari'] }, + // }, /* Test against mobile viewports. */ // { diff --git a/frontend/packages/data-portal/public/locales/en/translation.json b/frontend/packages/data-portal/public/locales/en/translation.json index 35f28b3a2..a3d2d018b 100644 --- a/frontend/packages/data-portal/public/locales/en/translation.json +++ b/frontend/packages/data-portal/public/locales/en/translation.json @@ -92,7 +92,7 @@ "fileSize": "File Size", "filterBy": "Filter by", "filterByAnyOfTheFollowing": "Filter by any of the following", - "filterCountOfMaxType": "{{count} of {max} {type}}", + "filterCountOfMaxType": "{{count}} of {{max}} {{type}}", "filterNoResultsFound": "No results were found", "filterRange": "Filter Range", "filterTooRestrictive": "The applied filters may be too restrictive.", diff --git a/frontend/packages/data-portal/tsconfig.json b/frontend/packages/data-portal/tsconfig.json index 2e4c8ae75..f1ffa55bc 100644 --- a/frontend/packages/data-portal/tsconfig.json +++ b/frontend/packages/data-portal/tsconfig.json @@ -18,6 +18,6 @@ "target": "esnext", "incremental": true }, - "include": ["*.d.ts", "*.ts", "app/**/*.ts", "app/**/*.tsx"], + "include": ["*.d.ts", "*.ts", "app/**/*.ts", "app/**/*.tsx", "e2e/**/*.ts"], "exclude": ["node_modules"] } diff --git a/frontend/packages/eslint-config/e2e.cjs b/frontend/packages/eslint-config/e2e.cjs new file mode 100644 index 000000000..9905f387e --- /dev/null +++ b/frontend/packages/eslint-config/e2e.cjs @@ -0,0 +1,13 @@ +module.exports = { + extends: ['plugin:playwright/recommended'], + + rules: { + // Useful for testing things sequentially + 'no-await-in-loop': 'off', + + // Disable so that we can create utility tester functions. For example some + // tests use `validateTable()` which should have call `expect()` within the + // function. + 'playwright/expect-expect': 'off', + }, +} diff --git a/frontend/packages/eslint-config/index.cjs b/frontend/packages/eslint-config/index.cjs index bc373b222..5269bc9f0 100644 --- a/frontend/packages/eslint-config/index.cjs +++ b/frontend/packages/eslint-config/index.cjs @@ -5,6 +5,7 @@ const configs = { dev: require.resolve('./dev.cjs'), + e2e: require.resolve('./e2e.cjs'), react: require.resolve('./react.cjs'), tests: require.resolve('./tests.cjs'), typescript: require.resolve('./typescript.cjs'), @@ -39,6 +40,12 @@ module.exports = { extends: [configs.typescript, configs.react, configs.tests], }, + // E2E tests + { + files: ['./e2e/**/*.ts'], + extends: [configs.typescript, configs.react, configs.dev, configs.e2e], + }, + /* Disable explicit return types for TSX files. Prefer inferred return types for React components, hooks, and tests: diff --git a/frontend/packages/eslint-config/package.json b/frontend/packages/eslint-config/package.json index 197868d2d..2b92dc6e2 100644 --- a/frontend/packages/eslint-config/package.json +++ b/frontend/packages/eslint-config/package.json @@ -19,12 +19,14 @@ "eslint-plugin-jest": "^27.4.2", "eslint-plugin-jest-dom": "^5.1.0", "eslint-plugin-jsx-a11y": "^6.7.1", - "eslint-plugin-playwright": "^0.16.0", "eslint-plugin-prettier": "^5.0.1", "eslint-plugin-react": "^7.33.2", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-simple-import-sort": "^10.0.0", "eslint-plugin-testing-library": "^6.0.2", "eslint-plugin-unused-imports": "^3.0.0" + }, + "devDependencies": { + "eslint-plugin-playwright": "^0.16.0" } } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index ffbe6a491..17cb2b238 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -199,8 +199,8 @@ importers: specifier: ^2.3.0 version: 2.3.0 '@playwright/test': - specifier: ^1.41.1 - version: 1.41.1 + specifier: ^1.41.2 + version: 1.41.2 '@remix-run/dev': specifier: ^2.0.1 version: 2.0.1(@types/node@20.8.4)(ts-node@10.9.1)(typescript@5.2.2) @@ -268,8 +268,8 @@ importers: specifier: ^29.7.0 version: 29.7.0 playwright: - specifier: ^1.41.1 - version: 1.41.1 + specifier: ^1.41.2 + version: 1.41.2 postcss: specifier: ^8.4.31 version: 8.4.31 @@ -339,9 +339,6 @@ importers: eslint-plugin-jsx-a11y: specifier: ^6.7.1 version: 6.7.1(eslint@8.51.0) - eslint-plugin-playwright: - specifier: ^0.16.0 - version: 0.16.0(eslint-plugin-jest@27.4.2)(eslint@8.51.0) eslint-plugin-prettier: specifier: ^5.0.1 version: 5.0.1(eslint-config-prettier@9.0.0)(eslint@8.51.0)(prettier@3.0.3) @@ -360,6 +357,10 @@ importers: eslint-plugin-unused-imports: specifier: ^3.0.0 version: 3.0.0(@typescript-eslint/eslint-plugin@6.7.3)(eslint@8.51.0) + devDependencies: + eslint-plugin-playwright: + specifier: ^0.16.0 + version: 0.16.0(eslint-plugin-jest@27.4.2)(eslint@8.51.0) packages/eslint-plugin: {} @@ -3444,12 +3445,12 @@ packages: tslib: 2.6.2 dev: false - /@playwright/test@1.41.1: - resolution: {integrity: sha512-9g8EWTjiQ9yFBXc6HjCWe41msLpxEX0KhmfmPl9RPLJdfzL4F0lg2BdJ91O9azFdl11y1pmpwdjBiSxvqc+btw==} + /@playwright/test@1.41.2: + resolution: {integrity: sha512-qQB9h7KbibJzrDpkXkYvsmiDJK14FULCCZgEcoe2AvFAS64oCirWTwzTlAYEbKaRxWs5TFesE1Na6izMv3HfGg==} engines: {node: '>=16'} hasBin: true dependencies: - playwright: 1.41.1 + playwright: 1.41.2 dev: true /@popperjs/core@2.11.8: @@ -3948,7 +3949,6 @@ packages: /@types/json-schema@7.0.13: resolution: {integrity: sha512-RbSSoHliUbnXj3ny0CNFOoxrIDV6SUGyStHsvDqosw6CkdPV8TtWGlfecuK4ToyMEAql6pzNxgCFKanovUzlgQ==} - dev: false /@types/json-stable-stringify@1.0.34: resolution: {integrity: sha512-s2cfwagOQAS8o06TcwKfr9Wx11dNGbH2E9vJz1cqV+a/LOyhWNLUNd6JSRYNzvB4d29UuJX2M0Dj9vE1T8fRXw==} @@ -4058,7 +4058,6 @@ packages: /@types/semver@7.5.3: resolution: {integrity: sha512-OxepLK9EuNEIPxWNME+C6WwbRAOOI2o2BaQEGzz5Lu2e4Z5eDnEo+/aVEDMIXywoJitJ7xWd641wrGLZdtwRyw==} - dev: false /@types/send@0.17.2: resolution: {integrity: sha512-aAG6yRf6r0wQ29bkS+x97BIs64ZLxeE/ARwyS6wrldMm3C1MdKwCcnnEwMC1slI8wuxJOpiUH9MioC0A0i+GJw==} @@ -4135,7 +4134,6 @@ packages: typescript: 5.2.2 transitivePeerDependencies: - supports-color - dev: false /@typescript-eslint/parser@6.7.3(eslint@8.51.0)(typescript@5.2.2): resolution: {integrity: sha512-TlutE+iep2o7R8Lf+yoer3zU6/0EAUc8QIBB3GYBc1KGz4c4TRm83xwXUZVPlZ6YCLss4r77jbu6j3sendJoiQ==} @@ -4156,7 +4154,6 @@ packages: typescript: 5.2.2 transitivePeerDependencies: - supports-color - dev: false /@typescript-eslint/scope-manager@5.62.0: resolution: {integrity: sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==} @@ -4164,7 +4161,6 @@ packages: dependencies: '@typescript-eslint/types': 5.62.0 '@typescript-eslint/visitor-keys': 5.62.0 - dev: false /@typescript-eslint/scope-manager@6.7.3: resolution: {integrity: sha512-wOlo0QnEou9cHO2TdkJmzF7DFGvAKEnB82PuPNHpT8ZKKaZu6Bm63ugOTn9fXNJtvuDPanBc78lGUGGytJoVzQ==} @@ -4172,7 +4168,6 @@ packages: dependencies: '@typescript-eslint/types': 6.7.3 '@typescript-eslint/visitor-keys': 6.7.3 - dev: false /@typescript-eslint/type-utils@6.7.3(eslint@8.51.0)(typescript@5.2.2): resolution: {integrity: sha512-Fc68K0aTDrKIBvLnKTZ5Pf3MXK495YErrbHb1R6aTpfK5OdSFj0rVN7ib6Tx6ePrZ2gsjLqr0s98NG7l96KSQw==} @@ -4192,17 +4187,14 @@ packages: typescript: 5.2.2 transitivePeerDependencies: - supports-color - dev: false /@typescript-eslint/types@5.62.0: resolution: {integrity: sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - dev: false /@typescript-eslint/types@6.7.3: resolution: {integrity: sha512-4g+de6roB2NFcfkZb439tigpAMnvEIg3rIjWQ+EM7IBaYt/CdJt6em9BJ4h4UpdgaBWdmx2iWsafHTrqmgIPNw==} engines: {node: ^16.0.0 || >=18.0.0} - dev: false /@typescript-eslint/typescript-estree@5.62.0(typescript@5.2.2): resolution: {integrity: sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==} @@ -4223,7 +4215,6 @@ packages: typescript: 5.2.2 transitivePeerDependencies: - supports-color - dev: false /@typescript-eslint/typescript-estree@6.7.3(typescript@5.2.2): resolution: {integrity: sha512-YLQ3tJoS4VxLFYHTw21oe1/vIZPRqAO91z6Uv0Ss2BKm/Ag7/RVQBcXTGcXhgJMdA4U+HrKuY5gWlJlvoaKZ5g==} @@ -4244,7 +4235,6 @@ packages: typescript: 5.2.2 transitivePeerDependencies: - supports-color - dev: false /@typescript-eslint/utils@5.62.0(eslint@8.51.0)(typescript@5.2.2): resolution: {integrity: sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==} @@ -4264,7 +4254,6 @@ packages: transitivePeerDependencies: - supports-color - typescript - dev: false /@typescript-eslint/utils@6.7.3(eslint@8.51.0)(typescript@5.2.2): resolution: {integrity: sha512-vzLkVder21GpWRrmSR9JxGZ5+ibIUSudXlW52qeKpzUEQhRSmyZiVDDj3crAth7+5tmN1ulvgKaCU2f/bPRCzg==} @@ -4283,7 +4272,6 @@ packages: transitivePeerDependencies: - supports-color - typescript - dev: false /@typescript-eslint/visitor-keys@5.62.0: resolution: {integrity: sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==} @@ -4291,7 +4279,6 @@ packages: dependencies: '@typescript-eslint/types': 5.62.0 eslint-visitor-keys: 3.4.3 - dev: false /@typescript-eslint/visitor-keys@6.7.3: resolution: {integrity: sha512-HEVXkU9IB+nk9o63CeICMHxFWbHWr3E1mpilIQBe9+7L/lH97rleFLVtYsfnWB+JVMaiFnEaxvknvmIzX+CqVg==} @@ -4299,7 +4286,6 @@ packages: dependencies: '@typescript-eslint/types': 6.7.3 eslint-visitor-keys: 3.4.3 - dev: false /@vanilla-extract/babel-plugin-debug-ids@1.0.3: resolution: {integrity: sha512-vm4jYu1xhSa6ofQ9AhIpR3DkAp4c+eoR1Rpm8/TQI4DmWbmGbOjYRcqV0aWsfaIlNhN4kFuxFMKBNN9oG6iRzA==} @@ -6335,7 +6321,6 @@ packages: transitivePeerDependencies: - supports-color - typescript - dev: false /eslint-plugin-jsx-a11y@6.7.1(eslint@8.51.0): resolution: {integrity: sha512-63Bog4iIethyo8smBklORknVjB0T2dwB8Mr/hIC+fBS0uyHdYYpzM/Ed+YC8VxTjlXHEWFOdmgwcDn1U2L9VCA==} @@ -6373,7 +6358,7 @@ packages: dependencies: eslint: 8.51.0 eslint-plugin-jest: 27.4.2(@typescript-eslint/eslint-plugin@6.7.3)(eslint@8.51.0)(typescript@5.2.2) - dev: false + dev: true /eslint-plugin-prettier@5.0.1(eslint-config-prettier@9.0.0)(eslint@8.51.0)(prettier@3.0.3): resolution: {integrity: sha512-m3u5RnR56asrwV/lDC4GHorlW75DsFfmUcjfCYylTUs85dBRnB7VM6xG8eCMJdeDRnppzmxZVf1GEPJvl1JmNg==} @@ -6477,7 +6462,6 @@ packages: dependencies: esrecurse: 4.3.0 estraverse: 4.3.0 - dev: false /eslint-scope@7.2.2: resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} @@ -6564,7 +6548,6 @@ packages: /estraverse@4.3.0: resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} engines: {node: '>=4.0'} - dev: false /estraverse@5.3.0: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} @@ -10316,18 +10299,18 @@ packages: pathe: 1.1.1 dev: true - /playwright-core@1.41.1: - resolution: {integrity: sha512-/KPO5DzXSMlxSX77wy+HihKGOunh3hqndhqeo/nMxfigiKzogn8kfL0ZBDu0L1RKgan5XHCPmn6zXd2NUJgjhg==} + /playwright-core@1.41.2: + resolution: {integrity: sha512-VaTvwCA4Y8kxEe+kfm2+uUUw5Lubf38RxF7FpBxLPmGe5sdNkSg5e3ChEigaGrX7qdqT3pt2m/98LiyvU2x6CA==} engines: {node: '>=16'} hasBin: true dev: true - /playwright@1.41.1: - resolution: {integrity: sha512-gdZAWG97oUnbBdRL3GuBvX3nDDmUOuqzV/D24dytqlKt+eI5KbwusluZRGljx1YoJKZ2NRPaeWiFTeGZO7SosQ==} + /playwright@1.41.2: + resolution: {integrity: sha512-v0bOa6H2GJChDL8pAeLa/LZC4feoAMbSQm1/jF/ySsWWoaNItvrMP7GEkvEEFyCTUYKMxjQKaTSg5up7nR6/8A==} engines: {node: '>=16'} hasBin: true dependencies: - playwright-core: 1.41.1 + playwright-core: 1.41.2 optionalDependencies: fsevents: 2.3.2 dev: true @@ -12044,7 +12027,6 @@ packages: typescript: '>=4.2.0' dependencies: typescript: 5.2.2 - dev: false /ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} @@ -12150,7 +12132,6 @@ packages: /tslib@1.14.1: resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} - dev: false /tslib@2.5.3: resolution: {integrity: sha512-mSxlJJwl3BMEQCUNnxXBU9jP4JBktcEGhURcPR6VQVlnP0FdDEsIaz0C35dXNGLyRfrATNofF0F5p2KPxQgB+w==} @@ -12168,7 +12149,6 @@ packages: dependencies: tslib: 1.14.1 typescript: 5.2.2 - dev: false /type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}