diff --git a/backend/src/main/kotlin/org/loculus/backend/model/ReleasedDataModel.kt b/backend/src/main/kotlin/org/loculus/backend/model/ReleasedDataModel.kt index 8a1e9664c0..9b74b39174 100644 --- a/backend/src/main/kotlin/org/loculus/backend/model/ReleasedDataModel.kt +++ b/backend/src/main/kotlin/org/loculus/backend/model/ReleasedDataModel.kt @@ -2,6 +2,9 @@ package org.loculus.backend.model import com.fasterxml.jackson.databind.node.LongNode import com.fasterxml.jackson.databind.node.TextNode +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toInstant import mu.KotlinLogging import org.loculus.backend.api.Organism import org.loculus.backend.api.ProcessedData @@ -42,8 +45,8 @@ class ReleasedDataModel(private val submissionDatabaseService: SubmissionDatabas ("accessionVersion" to TextNode(rawProcessedData.displayAccessionVersion())) + ("isRevocation" to TextNode(rawProcessedData.isRevocation.toString())) + ("submitter" to TextNode(rawProcessedData.submitter)) + - ("submittedAt" to TextNode(rawProcessedData.submittedAt.toString())) + - ("releasedAt" to TextNode(rawProcessedData.releasedAt.toString())) + + ("submittedAt" to LongNode(rawProcessedData.submittedAt.toTimestamp())) + + ("releasedAt" to LongNode(rawProcessedData.releasedAt.toTimestamp())) + ("versionStatus" to TextNode(siloVersionStatus.name)) return ProcessedData( @@ -75,3 +78,5 @@ class ReleasedDataModel(private val submissionDatabaseService: SubmissionDatabas return SiloVersionStatus.REVISED } } + +private fun LocalDateTime.toTimestamp() = this.toInstant(TimeZone.UTC).epochSeconds diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/submission/GetReleasedDataEndpointTest.kt b/backend/src/test/kotlin/org/loculus/backend/controller/submission/GetReleasedDataEndpointTest.kt index 1b70fdf1ef..3a0e465b1a 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/submission/GetReleasedDataEndpointTest.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/submission/GetReleasedDataEndpointTest.kt @@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.node.IntNode import com.fasterxml.jackson.databind.node.NullNode import com.fasterxml.jackson.databind.node.TextNode import kotlinx.datetime.Clock +import kotlinx.datetime.Instant import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime import org.hamcrest.CoreMatchers.`is` @@ -24,8 +25,6 @@ import org.loculus.backend.controller.submission.SubmitFiles.DefaultFiles.firstA import org.loculus.backend.utils.Accession import org.loculus.backend.utils.Version import org.springframework.beans.factory.annotation.Autowired -import java.time.LocalDateTime -import java.time.format.DateTimeFormatter private val ADDED_FIELDS_WITH_UNKNOWN_VALUES_FOR_RELEASE = listOf("releasedAt", "submissionId", "submittedAt") @@ -91,8 +90,8 @@ class GetReleasedDataEndpointTest( ) for ((key, value) in it.metadata) { when (key) { - "submittedAt" -> expectIsDateWithCurrentYear(value) - "releasedAt" -> expectIsDateWithCurrentYear(value) + "submittedAt" -> expectIsTimestampWithCurrentYear(value) + "releasedAt" -> expectIsTimestampWithCurrentYear(value) "submissionId" -> assertThat(value.textValue(), matchesPattern("^custom\\d$")) else -> assertThat(value, `is`(expectedMetadata[key])) } @@ -176,8 +175,8 @@ class GetReleasedDataEndpointTest( when (key) { "isRevocation" -> assertThat(value, `is`(TextNode("true"))) "versionStatus" -> assertThat(value, `is`(TextNode("LATEST_VERSION"))) - "submittedAt" -> expectIsDateWithCurrentYear(value) - "releasedAt" -> expectIsDateWithCurrentYear(value) + "submittedAt" -> expectIsTimestampWithCurrentYear(value) + "releasedAt" -> expectIsTimestampWithCurrentYear(value) "submitter" -> assertThat(value, `is`(TextNode(DEFAULT_USER_NAME))) "accession", "version", "accessionVersion", "submissionId" -> {} else -> assertThat("value for $key", value, `is`(NullNode.instance)) @@ -229,8 +228,8 @@ class GetReleasedDataEndpointTest( ) } - private fun expectIsDateWithCurrentYear(value: JsonNode) { - val dateTime = LocalDateTime.parse(value.textValue(), DateTimeFormatter.ISO_LOCAL_DATE_TIME) + private fun expectIsTimestampWithCurrentYear(value: JsonNode) { + val dateTime = Instant.fromEpochSeconds(value.asLong()).toLocalDateTime(TimeZone.UTC) assertThat(dateTime.year, `is`(currentYear)) } } diff --git a/kubernetes/loculus/templates/_common-metadata.tpl b/kubernetes/loculus/templates/_common-metadata.tpl index aebb10c174..81bb55ef7c 100644 --- a/kubernetes/loculus/templates/_common-metadata.tpl +++ b/kubernetes/loculus/templates/_common-metadata.tpl @@ -16,9 +16,9 @@ fields: - name: submitter type: string - name: submittedAt - type: string + type: timestamp - name: releasedAt - type: string + type: timestamp - name: versionStatus type: string notSearchable: true diff --git a/kubernetes/loculus/templates/lapis-silo-database-config.yaml b/kubernetes/loculus/templates/lapis-silo-database-config.yaml index ac279144ec..51fc9707d1 100644 --- a/kubernetes/loculus/templates/lapis-silo-database-config.yaml +++ b/kubernetes/loculus/templates/lapis-silo-database-config.yaml @@ -17,7 +17,7 @@ data: metadata: {{- range (concat $commonMetadata .metadata) }} - name: {{ .name }} - type: {{ .type }} + type: {{ (.type | eq "timestamp") | ternary "int" .type }} {{- if .generateIndex }} generateIndex: {{ .generateIndex }} {{- end }} diff --git a/website/src/components/SearchPage/SearchForm.spec.tsx b/website/src/components/SearchPage/SearchForm.spec.tsx index c1c8c96409..d874c45f82 100644 --- a/website/src/components/SearchPage/SearchForm.spec.tsx +++ b/website/src/components/SearchPage/SearchForm.spec.tsx @@ -92,4 +92,42 @@ describe('SearchForm', () => { expect(screen.getByPlaceholderText('Field 1')).toBeDefined(); expect(screen.queryByPlaceholderText('NotSearchable')).not.toBeInTheDocument(); }); + + test('should display dates of timestamp fields', async () => { + const timestampFieldName = 'timestampField'; + renderSearchForm([ + { + name: timestampFieldName, + type: 'timestamp' as const, + filterValue: '1706147200', + }, + ]); + + const timestampField = screen.getByLabelText('Timestamp field'); + expect(timestampField).toHaveValue('2024-01-25'); + + await userEvent.type(timestampField, '2024-01-26'); + await userEvent.click(screen.getByRole('button', { name: 'Search' })); + + expect(window.location.href).toContain(`${timestampFieldName}=1706233600`); + }); + + test('should display dates of date fields', async () => { + const dateFieldName = 'dateField'; + renderSearchForm([ + { + name: dateFieldName, + type: 'date' as const, + filterValue: '2024-01-25', + }, + ]); + + const dateField = screen.getByLabelText('Date field'); + expect(dateField).toHaveValue('2024-01-25'); + + await userEvent.type(dateField, '2024-01-26'); + await userEvent.click(screen.getByRole('button', { name: 'Search' })); + + expect(window.location.href).toContain(`${dateFieldName}=2024-01-26`); + }); }); diff --git a/website/src/components/SearchPage/SearchForm.tsx b/website/src/components/SearchPage/SearchForm.tsx index 412bd03339..3a84d2e078 100644 --- a/website/src/components/SearchPage/SearchForm.tsx +++ b/website/src/components/SearchPage/SearchForm.tsx @@ -6,7 +6,8 @@ import { sentenceCase } from 'change-case'; import { type FC, type FormEventHandler, useMemo, useState } from 'react'; import { AutoCompleteField } from './fields/AutoCompleteField'; -import { DateField } from './fields/DateField'; +import { DateField, TimestampField } from './fields/DateField'; +import type { FieldProps } from './fields/FieldProps.tsx'; import { MutationField } from './fields/MutationField.tsx'; import { NormalTextField } from './fields/NormalTextField'; import { PangoLineageField } from './fields/PangoLineageField'; @@ -77,30 +78,16 @@ export const SearchForm: FC = ({ const fields = useMemo( () => - fieldValues.map((field) => { - if (field.notSearchable === true) return null; - - const props = { - key: field.name, - field, - handleFieldChange, - isLoading, - lapisUrl, - allFields: fieldValues, - }; - - switch (field.type) { - case 'date': - return ; - case 'pango_lineage': - return ; - default: - if (field.autocomplete === true) { - return ; - } - return ; - } - }), + fieldValues.map((field) => ( + + )), [lapisUrl, fieldValues, isLoading], ); @@ -155,6 +142,28 @@ export const SearchForm: FC = ({ ); }; +const SearchField: FC = (props) => { + const { field } = props; + + if (field.notSearchable === true) { + return null; + } + + switch (field.type) { + case 'date': + return ; + case 'timestamp': + return ; + case 'pango_lineage': + return ; + default: + if (field.autocomplete === true) { + return ; + } + return ; + } +}; + const SearchButton: FC<{ isLoading: boolean }> = ({ isLoading }) => (