Skip to content

Commit

Permalink
fix bugs with commune input
Browse files Browse the repository at this point in the history
  • Loading branch information
MaGOs92 committed May 17, 2024
1 parent 423a21b commit 291d8ad
Show file tree
Hide file tree
Showing 5 changed files with 216 additions and 151 deletions.
122 changes: 77 additions & 45 deletions components/commune-input.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import React, {useEffect, useState} from 'react'
import {SearchBar} from '@codegouvfr/react-dsfr/SearchBar'
import allCommunes from '@etalab/decoupage-administratif/data/communes.json'
import SearchInput from '@/components/search-input'
import React, { useState } from "react";
import type Fuse from "fuse.js";
import allCommunes from "@etalab/decoupage-administratif/data/communes.json";
import SearchInput from "@/components/search-input";
import { useFuse } from "@/hooks/use-fuse";
import styled from "styled-components";
import Button from "@codegouvfr/react-dsfr/Button";

const allOptions = (allCommunes as CommuneType[])
.filter(c => ['commune-actuelle', 'arrondissement-municipal'].includes(c.type))
.map(c => ({label: `${c.nom} (${c.code})`, value: c}))
const allOptions = (allCommunes as CommuneType[]).filter((c) =>
["commune-actuelle", "arrondissement-municipal"].includes(c.type)
);

export type CommuneType = {
code: string;
Expand All @@ -20,50 +23,79 @@ export type CommuneType = {
siren: string;
codesPostaux: string[];
population: number;
}
};

type CommuneInputProps = {
label?: string;
onChange: (value?: CommuneType) => void;
}
};

export const CommuneInput = ({label, onChange}: CommuneInputProps) => {
const [inputValue, setInputValue] = useState('')
const [options, setOptions] = useState([])
const StyledSelectedCommune = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
`;

useEffect(() => {
if (inputValue.length <= 2) {
setOptions([])
} else if (inputValue.length > 2) {
const newOptions = allOptions.filter(c => c.label.toLowerCase().includes(inputValue.toLowerCase()))
setOptions(newOptions.slice(0, 10))
}
}, [inputValue])
export const CommuneInput = ({ label, onChange }: CommuneInputProps) => {
const [value, setValue] = useState<CommuneType>();
const [fuseOptions] = useState({
keys: ["nom", "code"],
threshold: 0.4,
});

return (
<div className='fr-select-group'>
{label && <label className='fr-label' style={{marginBottom: 8}} htmlFor={`select-${label}`}>
{label}
</label>}
<SearchBar
renderInput={({className, id, placeholder, type}) => (
<SearchInput
options={options}
className={className}
id={id}
placeholder={placeholder}
type={type}
onChange={event => {
onChange(event?.value)
}}
onInputChange={newValue => {
setInputValue(newValue)
}}
/>
)}
/>
</div>
const fuzzySearch = useFuse(allOptions, fuseOptions);

const handleChange = (value: CommuneType) => {
setValue(value);
onChange(value);
};

)
}
const handleClear = () => {
setValue(undefined);
onChange(undefined);
};

return (
<div className="fr-select-group" style={{ padding: 5 }}>
{label && (
<label
className="fr-label"
style={{ marginBottom: 8 }}
htmlFor={`select-${label}`}
>
{label}
</label>
)}
{value ? (
<StyledSelectedCommune className="fr-input">
<span>
{value.nom} ({value.code})
</span>
<button
type="button"
onClick={handleClear}
title="Réinitialiser la commune"
>
X
</button>
</StyledSelectedCommune>
) : (
<SearchInput
inputProps={{ placeholder: "Rechercher une commune" }}
fetchResults={fuzzySearch}
ResultCmp={(result: Fuse.FuseResult<CommuneType>) => (
<div>
<button
type="button"
className="autocomplete-btn"
onClick={() => handleChange(result.item)}
>
{result.item.nom} ({result.item.code})
</button>
</div>
)}
/>
)}
</div>
);
};
40 changes: 0 additions & 40 deletions components/search-input.js

This file was deleted.

125 changes: 125 additions & 0 deletions components/search-input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { useEffect, useRef, useState } from "react";
import styled from "styled-components";

interface SearchInputProps<T> {
fetchResults: (search: string) => any;
ResultCmp: React.FC<T>;
inputProps?: React.InputHTMLAttributes<HTMLInputElement>;
}

const StyledSearchInput = styled.div`
position: relative;
.fr-input {
outline: none;
}
.results {
position: absolute;
background-color: #fff;
width: 100%;
border: 1px solid #ccc;
border-top: none;
z-index: 100;
max-height: 200px;
overflow-y: auto;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
border-radius: 0 0 4px 4px;
> div {
> button {
width: 100%;
text-align: left;
padding: 10px;
&:hover {
background-color: #eee;
}
}
}
> p {
padding: 5px 10px;
margin: 0;
}
}
`;

const SearchInput = <T extends { item: any }>({
ResultCmp,
inputProps,
fetchResults,
}: SearchInputProps<T>) => {
const searchTimeoutRef = useRef({} as NodeJS.Timeout);
const [hasFocus, setHasFocus] = useState(false);
const [search, setSearch] = useState("");
const [results, setResults] = useState<T[]>([]);

useEffect(() => {
async function updateResults() {
if (search.length >= 3) {
const results = await fetchResults(search);
setResults(results);
} else {
setResults([]);
}
}

updateResults();
}, [search, fetchResults]);

const onSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.value.length <= 3) {
setSearch(e.target.value);
} else if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
searchTimeoutRef.current = setTimeout(() => {
setSearch(e.target.value);
}, 500);
};

return (
<StyledSearchInput>
<div className="fr-search-bar" id="header-search" role="search">
<input
className="fr-input"
onChange={onSearch}
onFocus={() => setHasFocus(true)}
onBlur={(e) => {
if (
e.relatedTarget instanceof Element &&
e.relatedTarget.className === "autocomplete-btn"
) {
return;
}
setHasFocus(false);
}}
type="search"
id="autocomplete-search"
name="autocomplete-search"
{...inputProps}
/>
<button
type="button"
className="fr-btn"
title="Rechercher"
disabled={inputProps?.disabled}
>
Rechercher
</button>
</div>
{hasFocus && (
<div className="results">
{results.length > 0 &&
results.map((result: T) => (
<ResultCmp key={result.item.code} {...result} />
))}
{results.length === 0 && search.length >= 3 && <p>Aucun résultat</p>}
{results.length === 0 && search.length > 0 && search.length < 3 && (
<p>Votre recherche doit comporter au moins 3 caractères</p>
)}
</div>
)}
</StyledSearchInput>
);
};

export default SearchInput;
23 changes: 5 additions & 18 deletions hooks/use-fuse.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,16 @@
import Fuse from "fuse.js";
import { useCallback } from "react";

export const useFuse = (
data: any[],
keys: string[],
setter: (values: { label: string; value: string }[]) => void
) => {
export const useFuse = <T>(data: T[], options: Fuse.IFuseOptions<T>) => {
return useCallback(
(search) => {
const fuse = new Fuse(data, {
keys,
threshold: 0.4,
});
const fuse = new Fuse(data, options);
if (search.length <= 2) {
setter([]);
return [];
} else if (search.length > 2) {
const results = fuse.search(search).slice(0, 10);
setter(
results.map(({ item }) => ({
label: `${item.nom} (${item.code})`,
value: item.code,
}))
);
return fuse.search(search).slice(0, 10);
}
},
[data, keys, setter]
[data, options]
);
};
Loading

0 comments on commit 291d8ad

Please sign in to comment.