Merge pull request #3538 from inaturalist/mob-713-project-search-typing-in-k-causes-a-crash

Mob 713 project search typing in k causes a crash
This commit is contained in:
Abbey Campbell
2026-04-23 12:20:49 -07:00
committed by GitHub
5 changed files with 117 additions and 68 deletions

View File

@@ -1,61 +1,46 @@
import type { ApiProject } from "api/types";
import type { i18n as i18next, TFunction } from "i18next";
import { formatProjectsApiDatetimeLong } from "sharedHelpers/dateAndTime";
const getFieldValue = item => item?.[0]?.value;
interface ProjectRulePreference {
field?: string;
value?: string | null;
}
type ProjectWithDateRules = Omit<ApiProject, "project_type"> & {
project_type?: ApiProject["project_type"];
rule_preferences?: ProjectRulePreference[] | null;
};
interface FormattedProjectDate {
projectDate: string | null;
shouldDisplayDateRange: boolean;
}
const getFieldValue = ( item?: ProjectRulePreference[] | null ) => item?.[0]?.value ?? null;
// https://github.com/inaturalist/inaturalist/blob/0994c85e2b87661042289ff080d3fc29ed8e70b3/app/webpack/projects/show/components/requirements.jsx#L100C3-L114C4
const formatProjectDate = ( project, t, i18n ) => {
const monthValues = {
1: {
label: t( "January" ),
value: 1,
},
2: {
label: t( "February" ),
value: 2,
},
3: {
label: t( "March" ),
value: 3,
},
4: {
label: t( "April" ),
value: 4,
},
5: {
label: t( "May" ),
value: 5,
},
6: {
label: t( "June" ),
value: 6,
},
7: {
label: t( "July" ),
value: 7,
},
8: {
label: t( "August" ),
value: 8,
},
9: {
label: t( "September" ),
value: 9,
},
10: {
label: t( "October" ),
value: 10,
},
11: {
label: t( "November" ),
value: 11,
},
12: {
label: t( "December" ),
value: 12,
},
const formatProjectDate = (
project: ProjectWithDateRules | null | undefined,
t: TFunction,
i18n: i18next,
): FormattedProjectDate => {
const monthValues: Record<string, string> = {
1: t( "January" ),
2: t( "February" ),
3: t( "March" ),
4: t( "April" ),
5: t( "May" ),
6: t( "June" ),
7: t( "July" ),
8: t( "August" ),
9: t( "September" ),
10: t( "October" ),
11: t( "November" ),
12: t( "December" ),
};
let projectDate = null;
let projectDate: string | null = null;
const projectStartDate = getFieldValue( project?.rule_preferences
?.filter( pref => pref.field === "d1" ) );
@@ -65,29 +50,45 @@ const formatProjectDate = ( project, t, i18n ) => {
?.filter( pref => pref.field === "observed_on" ) );
const months = getFieldValue( project?.rule_preferences
?.filter( pref => pref.field === "month" ) );
const formattedStartDate = projectStartDate
? formatProjectsApiDatetimeLong( projectStartDate, i18n, { missing: null } )
: null;
const formattedEndDate = projectEndDate
? formatProjectsApiDatetimeLong( projectEndDate, i18n, { missing: null } )
: null;
const formattedObservedOnDate = observedOnDate
? formatProjectsApiDatetimeLong( observedOnDate, i18n, { missing: null } )
: null;
if ( projectStartDate && !projectEndDate ) {
projectDate = t( "project-start-time-datetime", {
datetime: formatProjectsApiDatetimeLong( projectStartDate, i18n ),
} );
if ( formattedStartDate && !formattedEndDate ) {
projectDate = formattedStartDate
? t( "project-start-time-datetime", {
datetime: formattedStartDate,
} )
: null;
}
if ( projectStartDate && projectEndDate ) {
if ( formattedStartDate && formattedEndDate ) {
projectDate = t( "date-to-date", {
d1: formatProjectsApiDatetimeLong( projectStartDate, i18n ),
d2: formatProjectsApiDatetimeLong( projectEndDate, i18n ),
d1: formattedStartDate,
d2: formattedEndDate,
} );
}
if ( observedOnDate ) {
projectDate = formatProjectsApiDatetimeLong( observedOnDate, i18n );
if ( formattedObservedOnDate ) {
projectDate = formattedObservedOnDate;
}
if ( months ) {
const monthList = months.split( "," );
projectDate = monthList.map( numberOfMonth => monthValues[numberOfMonth].label ).join( ", " );
const monthLabels = monthList
.map( numberOfMonth => monthValues[numberOfMonth] )
.filter( ( label ): label is string => Boolean( label ) );
projectDate = monthLabels.length > 0
? monthLabels.join( ", " )
: null;
}
return {
projectDate,
shouldDisplayDateRange: projectStartDate && projectEndDate
&& project?.project_type !== "traditional",
shouldDisplayDateRange: !!( formattedStartDate && formattedEndDate )
&& project?.project_type !== "", // "" means "traditional"
};
};

View File

@@ -13,6 +13,7 @@ import {
fromUnixTime,
getUnixTime,
getYear,
isValid as isValidDate,
parse,
parseISO,
} from "date-fns";
@@ -80,6 +81,9 @@ import {
} from "date-fns/locale";
import { formatInTimeZone } from "date-fns-tz";
import type { i18n as i18next } from "i18next";
import { log } from "sharedHelpers/logger";
const logger = log.extend( "dateAndTime" );
// Convert iNat locale to date-fns locale. Note that coverage is *not*
// complete, so some locales will see dates formatted in a nearby locale,
@@ -332,9 +336,23 @@ function formatDateString(
timeZone = Intl.DateTimeFormat( ).resolvedOptions( ).timeZone;
}
const parsedDate = parseISO( isoDateString );
if ( !isValidDate( parsedDate ) ) {
logger.warnWithExtra(
"invalid date string in formatDateString",
{
reason: "invalid_parse_iso",
isoDateString,
},
);
return options.missing === undefined
? i18n.t( "Missing-Date" )
: options.missing;
}
try {
return formatInTimeZone(
parseISO( isoDateString ),
parsedDate,
timeZone,
fmt,
{ locale: dateFnsLocale( i18n.language ) },
@@ -346,7 +364,7 @@ function formatDateString(
// Remove timezone (zzz) from format string
fmt = fmt.replace( / zzz/g, "" );
return format(
parseISO( isoDateString ),
parsedDate,
fmt,
{ locale: dateFnsLocale( i18n.language ) },
);
@@ -419,6 +437,12 @@ function formatProjectsApiDatetimeLong(
i18n: i18next,
options: FormatDateStringOptions = {},
) {
if ( !dateString || dateString === "" ) {
return options.missing === undefined
? i18n.t( "Missing-Date" )
: options.missing;
}
const hasTime = String( dateString ).includes( "T" );
if ( hasTime ) {
return formatDateString( dateString, i18n.t( "datetime-format-long" ), i18n, options );
@@ -440,6 +464,11 @@ function formatProjectsApiDatetimeLong(
const hasComma = String( dateString ).includes( "," );
if ( hasComma ) {
const parsedDate = parse( dateString, "MMMM d, yyyy", new Date( ) );
if ( !isValidDate( parsedDate ) ) {
return options.missing === undefined
? i18n.t( "Missing-Date" )
: options.missing;
}
return formatDateString(
formatISO( parsedDate ),

View File

@@ -116,7 +116,7 @@ describe( "ProjectDetails", ( ) => {
renderComponent( <ProjectDetails
project={{
...mockProjectWithDateRange,
project_type: "traditional",
project_type: "", // "" means "traditional"
}}
/> );
const projectTypeText = await screen.findByText( /Traditional Project/ );

View File

@@ -105,7 +105,7 @@ describe( "Projects", ( ) => {
useAuthenticatedInfiniteQuery.mockImplementation( ( ) => infiniteScrollResults(
[{
...mockProjectWithDateRange,
project_type: "traditional",
project_type: "", // "" means "traditional"
}],
) );
renderComponent( <ProjectsContainer /> );

View File

@@ -11,6 +11,7 @@ import {
formatApiDatetime,
formatDifferenceForHumans,
formatISONoSeconds,
formatProjectsApiDatetimeLong,
getNowISO,
} from "sharedHelpers/dateAndTime";
@@ -99,6 +100,24 @@ describe( "formatApiDatetime", ( ) => {
} );
} );
describe( "formatProjectsApiDatetimeLong", ( ) => {
beforeAll( async ( ) => {
await initI18next( { lng: "en" } );
} );
it( "returns null for malformed timezone project dates when missing is null", ( ) => {
expect(
formatProjectsApiDatetimeLong( "2023-06-080 0 +04:00", i18next, { missing: null } ),
).toBeNull( );
} );
it( "returns Missing Date for malformed timezone project dates by default", ( ) => {
expect(
formatProjectsApiDatetimeLong( "2023-06-080 0 +04:00", i18next ),
).toEqual( "Missing Date" );
} );
} );
describe( "getNowISO", ( ) => {
it( "should return a valid ISO8601 string", ( ) => {
const dateString = getNowISO( );