clean up plant search
This commit is contained in:
@@ -151,7 +151,7 @@ static IEnumerable<PlantInfoImportRecord> ReadGbifDwcaRecords(
|
||||
var rank = layout.Taxon.Value(fields, "taxonRank");
|
||||
var status = layout.Taxon.Value(fields, "taxonomicStatus");
|
||||
if (!EqualsIgnoreCase(kingdom, "Plantae") ||
|
||||
!EqualsIgnoreCase(rank, "species") ||
|
||||
!IsSpeciesOrBelowRank(rank) ||
|
||||
!EqualsIgnoreCase(status, "accepted"))
|
||||
{
|
||||
continue;
|
||||
@@ -296,6 +296,20 @@ static bool IsEnglishOrUnknown(string? language) =>
|
||||
EqualsIgnoreCase(language, "eng") ||
|
||||
EqualsIgnoreCase(language, "english");
|
||||
|
||||
static bool IsSpeciesOrBelowRank(string? rank)
|
||||
{
|
||||
var normalized = rank?.Trim().Replace(" ", "_").Replace("-", "_").ToLowerInvariant();
|
||||
return normalized is
|
||||
"species" or
|
||||
"subspecies" or
|
||||
"variety" or
|
||||
"subvariety" or
|
||||
"form" or
|
||||
"forma" or
|
||||
"subform" or
|
||||
"subforma";
|
||||
}
|
||||
|
||||
static async Task CreateSchemaAsync(SqliteConnection connection)
|
||||
{
|
||||
await ExecuteAsync(connection, """
|
||||
|
||||
@@ -30,13 +30,6 @@ type TaxaViewProps = {
|
||||
onSearchQueryChange: (value: string) => void;
|
||||
};
|
||||
|
||||
const plantInfoSearchExamples = [
|
||||
'ficus',
|
||||
'monstera',
|
||||
'alocasia',
|
||||
'croton',
|
||||
];
|
||||
|
||||
function getPlantInfoSubtitle(result: PlantInfoSearchResult) {
|
||||
if (result.commonName) {
|
||||
return result.commonName;
|
||||
@@ -116,19 +109,6 @@ export function TaxaView({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="query-chip-row" aria-label="Example plant info searches">
|
||||
{plantInfoSearchExamples.map((query) => (
|
||||
<button
|
||||
className="query-chip"
|
||||
type="button"
|
||||
key={query}
|
||||
onClick={() => onSearch(query)}
|
||||
>
|
||||
{query}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{plantInfoResults.length > 0 ? (
|
||||
<div className="plant-list">
|
||||
{plantInfoResults.map((result) => (
|
||||
@@ -137,6 +117,10 @@ export function TaxaView({
|
||||
<h3>{result.canonicalName ?? result.scientificName}</h3>
|
||||
<p>{getPlantInfoSubtitle(result)}</p>
|
||||
<dl className="plant-info-meta">
|
||||
<div>
|
||||
<dt>Common Names</dt>
|
||||
<dd>{result.commonNames.length > 0 ? result.commonNames.join(', ') : 'None'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Family</dt>
|
||||
<dd>{result.family ?? 'Unknown'}</dd>
|
||||
@@ -155,7 +139,19 @@ export function TaxaView({
|
||||
</div>
|
||||
<div>
|
||||
<dt>Source</dt>
|
||||
<dd>{`${result.source}:${result.externalId}`}</dd>
|
||||
<dd>
|
||||
{result.source === 'gbif' ? (
|
||||
<a
|
||||
href={`https://www.gbif.org/species/${result.externalId}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{`${result.source}:${result.externalId}`}
|
||||
</a>
|
||||
) : (
|
||||
`${result.source}:${result.externalId}`
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
@@ -66,6 +66,7 @@ export type PlantInfoSearchResult = {
|
||||
family: string | null;
|
||||
genus: string | null;
|
||||
species: string | null;
|
||||
commonNames: string[];
|
||||
};
|
||||
|
||||
export type PlantLocation = {
|
||||
|
||||
@@ -757,31 +757,6 @@ dd {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.query-chip-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin: 8px 0 12px;
|
||||
}
|
||||
|
||||
.query-chip {
|
||||
min-height: 30px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #cfc4b4;
|
||||
border-radius: 999px;
|
||||
background: var(--field);
|
||||
color: #33463b;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.query-chip:hover {
|
||||
border-color: var(--link);
|
||||
background: var(--sage);
|
||||
color: var(--link);
|
||||
}
|
||||
|
||||
.plant-info-result {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
assignPlantFlag,
|
||||
completeCareTasksBulk,
|
||||
@@ -56,6 +56,9 @@ import {
|
||||
import type { useAppEditors } from './use-app-editors';
|
||||
import type { useDashboardData } from './use-dashboard-data';
|
||||
|
||||
const plantInfoSearchDebounceMs = 150;
|
||||
const plantInfoSearchMinLength = 2;
|
||||
|
||||
type Editors = ReturnType<typeof useAppEditors>;
|
||||
type DashboardData = ReturnType<typeof useDashboardData>;
|
||||
|
||||
@@ -90,6 +93,39 @@ export function useAppActions({
|
||||
const [plantInfoQuery, setPlantInfoQuery] = useState('');
|
||||
const [plantInfoResults, setPlantInfoResults] = useState<PlantInfoSearchResult[]>([]);
|
||||
const [hasSearchedPlantInfo, setHasSearchedPlantInfo] = useState(false);
|
||||
const latestPlantInfoSearchId = useRef(0);
|
||||
const lastCompletedPlantInfoQuery = useRef('');
|
||||
|
||||
useEffect(() => {
|
||||
const query = plantInfoQuery.trim();
|
||||
if (query.length === 0) {
|
||||
latestPlantInfoSearchId.current += 1;
|
||||
lastCompletedPlantInfoQuery.current = '';
|
||||
setPlantInfoResults([]);
|
||||
setHasSearchedPlantInfo(false);
|
||||
setIsSearchingPlantInfo(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (query.length < plantInfoSearchMinLength) {
|
||||
latestPlantInfoSearchId.current += 1;
|
||||
lastCompletedPlantInfoQuery.current = '';
|
||||
setPlantInfoResults([]);
|
||||
setHasSearchedPlantInfo(false);
|
||||
setIsSearchingPlantInfo(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (query !== lastCompletedPlantInfoQuery.current) {
|
||||
setIsSearchingPlantInfo(true);
|
||||
}
|
||||
|
||||
const timeout = window.setTimeout(() => {
|
||||
void searchTaxonInfo(query, { updateQuery: false });
|
||||
}, plantInfoSearchDebounceMs);
|
||||
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [plantInfoQuery]);
|
||||
|
||||
async function savePlant() {
|
||||
if (!editors.form.nickname.trim()) {
|
||||
@@ -211,24 +247,44 @@ export function useAppActions({
|
||||
}
|
||||
}
|
||||
|
||||
async function searchTaxonInfo(queryOverride?: string) {
|
||||
async function searchTaxonInfo(
|
||||
queryOverride?: string,
|
||||
options: { updateQuery?: boolean } = {},
|
||||
) {
|
||||
const query = queryOverride ?? plantInfoQuery;
|
||||
setPlantInfoQuery(query);
|
||||
if (options.updateQuery ?? true) {
|
||||
setPlantInfoQuery(query);
|
||||
}
|
||||
|
||||
if (query.trim().length < 2) {
|
||||
const trimmedQuery = query.trim();
|
||||
if (trimmedQuery.length < plantInfoSearchMinLength) {
|
||||
setError('Search needs at least 2 characters.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmedQuery === lastCompletedPlantInfoQuery.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const searchId = latestPlantInfoSearchId.current + 1;
|
||||
latestPlantInfoSearchId.current = searchId;
|
||||
setIsSearchingPlantInfo(true);
|
||||
try {
|
||||
setError(null);
|
||||
setHasSearchedPlantInfo(true);
|
||||
setPlantInfoResults(await searchPlantInfo(query));
|
||||
const results = await searchPlantInfo(trimmedQuery);
|
||||
if (latestPlantInfoSearchId.current === searchId) {
|
||||
lastCompletedPlantInfoQuery.current = trimmedQuery;
|
||||
setPlantInfoResults(results);
|
||||
}
|
||||
} catch {
|
||||
setError('Could not search offline plant info.');
|
||||
if (latestPlantInfoSearchId.current === searchId) {
|
||||
setError('Could not search offline plant info.');
|
||||
}
|
||||
} finally {
|
||||
setIsSearchingPlantInfo(false);
|
||||
if (latestPlantInfoSearchId.current === searchId) {
|
||||
setIsSearchingPlantInfo(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ namespace plant_manager.Services
|
||||
r.Family,
|
||||
r.Genus,
|
||||
r.Species,
|
||||
r.AliasesText,
|
||||
CASE
|
||||
WHEN lower(coalesce(r.CommonName, '')) = $normalized THEN 0
|
||||
WHEN lower(coalesce(r.CanonicalName, '')) = $normalized THEN 1
|
||||
@@ -88,7 +89,8 @@ namespace plant_manager.Services
|
||||
ReadNullableString(reader, 6),
|
||||
ReadNullableString(reader, 7),
|
||||
ReadNullableString(reader, 8),
|
||||
ReadNullableString(reader, 9)));
|
||||
ReadNullableString(reader, 9),
|
||||
ToCommonNames(ReadNullableString(reader, 10))));
|
||||
}
|
||||
|
||||
return results;
|
||||
@@ -120,6 +122,20 @@ namespace plant_manager.Services
|
||||
|
||||
private static string? ReadNullableString(IDataRecord reader, int ordinal) =>
|
||||
reader.IsDBNull(ordinal) ? null : reader.GetString(ordinal);
|
||||
|
||||
private static IReadOnlyList<string> ToCommonNames(string? aliasesText)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(aliasesText))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return aliasesText
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Order(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public record PlantInfoSearchResultDto(
|
||||
@@ -132,5 +148,6 @@ namespace plant_manager.Services
|
||||
string? Status,
|
||||
string? Family,
|
||||
string? Genus,
|
||||
string? Species);
|
||||
string? Species,
|
||||
IReadOnlyList<string> CommonNames);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user