fix(wizard): debounce name state [EE-4177] (#8042)

move debouncing to the component (from the validation).
debounce returns undefined when it's not calling the debounced function,
and undefined is considered a validation error.
pull/7609/head
Chaim Lev-Ari 2022-11-21 19:33:08 +02:00 committed by GitHub
parent 253a3a2b40
commit 7006c17ce4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 74 additions and 58 deletions

View File

@ -8,7 +8,7 @@ import {
import { useRowSelectColumn } from '@lineup-lite/hooks'; import { useRowSelectColumn } from '@lineup-lite/hooks';
import { Box, Plus, Trash2 } from 'react-feather'; import { Box, Plus, Trash2 } from 'react-feather';
import { useDebounce } from '@/react/hooks/useDebounce'; import { useDebouncedValue } from '@/react/hooks/useDebouncedValue';
import { ContainerGroup } from '@/react/azure/types'; import { ContainerGroup } from '@/react/azure/types';
import { Authorized } from '@/react/hooks/useUser'; import { Authorized } from '@/react/hooks/useUser';
import { confirmDeletionAsync } from '@/portainer/services/modal.service/confirm'; import { confirmDeletionAsync } from '@/portainer/services/modal.service/confirm';
@ -85,7 +85,7 @@ export function ContainersDatatable({
useRowSelectColumn useRowSelectColumn
); );
const debouncedSearchValue = useDebounce(searchBarValue); const debouncedSearchValue = useDebouncedValue(searchBarValue);
useEffect(() => { useEffect(() => {
setGlobalFilter(debouncedSearchValue); setGlobalFilter(debouncedSearchValue);

View File

@ -1,9 +1,8 @@
import { Search } from 'react-feather'; import { Search } from 'react-feather';
import { useEffect, useMemo, useState } from 'react';
import _ from 'lodash';
import { useLocalStorage } from '@/react/hooks/useLocalStorage'; import { useLocalStorage } from '@/react/hooks/useLocalStorage';
import { AutomationTestingProps } from '@/types'; import { AutomationTestingProps } from '@/types';
import { useDebounce } from '@/react/hooks/useDebounce';
interface Props extends AutomationTestingProps { interface Props extends AutomationTestingProps {
value: string; value: string;
@ -34,26 +33,6 @@ export function SearchBar({
); );
} }
function useDebounce(defaultValue: string, onChange: (value: string) => void) {
const [searchValue, setSearchValue] = useState(defaultValue);
useEffect(() => {
setSearchValue(defaultValue);
}, [defaultValue]);
const onChangeDebounces = useMemo(
() => _.debounce(onChange, 300),
[onChange]
);
return [searchValue, handleChange] as const;
function handleChange(value: string) {
setSearchValue(value);
onChangeDebounces(value);
}
}
export function useSearchBarState( export function useSearchBarState(
key: string key: string
): [string, (value: string) => void] { ): [string, (value: string) => void] {

View File

@ -2,7 +2,7 @@ import { useState } from 'react';
import { useEnvironmentList } from '@/react/portainer/environments/queries/useEnvironmentList'; import { useEnvironmentList } from '@/react/portainer/environments/queries/useEnvironmentList';
import { EdgeTypes, Environment } from '@/react/portainer/environments/types'; import { EdgeTypes, Environment } from '@/react/portainer/environments/types';
import { useDebounce } from '@/react/hooks/useDebounce'; import { useDebouncedValue } from '@/react/hooks/useDebouncedValue';
import { useSearchBarState } from '@@/datatables/SearchBar'; import { useSearchBarState } from '@@/datatables/SearchBar';
import { import {
@ -85,7 +85,7 @@ function Loader({ children, storageKey }: LoaderProps) {
}); });
const [search, setSearch] = useSearchBarState(storageKey); const [search, setSearch] = useSearchBarState(storageKey);
const debouncedSearchValue = useDebounce(search); const debouncedSearchValue = useDebouncedValue(search);
const { environments, isLoading, totalCount } = useEnvironmentList( const { environments, isLoading, totalCount } = useEnvironmentList(
{ {

View File

@ -1,15 +1,18 @@
import { useEffect, useState } from 'react'; import _ from 'lodash';
import { useState, useRef } from 'react';
export function useDebounce<T>(value: T, delay = 500): T { export function useDebounce(
const [debouncedValue, setDebouncedValue] = useState<T>(value); defaultValue: string,
onChange: (value: string) => void
) {
const [searchValue, setSearchValue] = useState(defaultValue);
useEffect(() => { const onChangeDebounces = useRef(_.debounce(onChange, 300));
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => { return [searchValue, handleChange] as const;
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue; function handleChange(value: string) {
setSearchValue(value);
onChangeDebounces.current(value);
}
} }

View File

@ -0,0 +1,15 @@
import { useEffect, useState } from 'react';
export function useDebouncedValue<T>(value: T, delay = 500): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
}

View File

@ -8,7 +8,7 @@ import {
} from 'react-table'; } from 'react-table';
import { NomadEvent } from '@/react/nomad/types'; import { NomadEvent } from '@/react/nomad/types';
import { useDebounce } from '@/react/hooks/useDebounce'; import { useDebouncedValue } from '@/react/hooks/useDebouncedValue';
import { PaginationControls } from '@@/PaginationControls'; import { PaginationControls } from '@@/PaginationControls';
import { import {
@ -42,7 +42,7 @@ export function EventsDatatable({ data, isLoading }: EventsDatatableProps) {
useTableSettings<EventsTableSettings>(); useTableSettings<EventsTableSettings>();
const [searchBarValue, setSearchBarValue] = useSearchBarState('events'); const [searchBarValue, setSearchBarValue] = useSearchBarState('events');
const columns = useColumns(); const columns = useColumns();
const debouncedSearchValue = useDebounce(searchBarValue); const debouncedSearchValue = useDebouncedValue(searchBarValue);
const { const {
getTableProps, getTableProps,

View File

@ -10,7 +10,7 @@ import {
import { useRowSelectColumn } from '@lineup-lite/hooks'; import { useRowSelectColumn } from '@lineup-lite/hooks';
import { Job } from '@/react/nomad/types'; import { Job } from '@/react/nomad/types';
import { useDebounce } from '@/react/hooks/useDebounce'; import { useDebouncedValue } from '@/react/hooks/useDebouncedValue';
import { PaginationControls } from '@@/PaginationControls'; import { PaginationControls } from '@@/PaginationControls';
import { import {
@ -51,7 +51,7 @@ export function JobsDatatable({
const { settings, setTableSettings } = useTableSettings<JobsTableSettings>(); const { settings, setTableSettings } = useTableSettings<JobsTableSettings>();
const [searchBarValue, setSearchBarValue] = useSearchBarState('jobs'); const [searchBarValue, setSearchBarValue] = useSearchBarState('jobs');
const columns = useColumns(); const columns = useColumns();
const debouncedSearchValue = useDebounce(searchBarValue); const debouncedSearchValue = useDebouncedValue(searchBarValue);
useRepeater(settings.autoRefreshRate, refreshData); useRepeater(settings.autoRefreshRate, refreshData);
const { const {

View File

@ -12,7 +12,7 @@ import {
EdgeTypes, EdgeTypes,
} from '@/react/portainer/environments/types'; } from '@/react/portainer/environments/types';
import { EnvironmentGroupId } from '@/react/portainer/environments/environment-groups/types'; import { EnvironmentGroupId } from '@/react/portainer/environments/environment-groups/types';
import { useDebounce } from '@/react/hooks/useDebounce'; import { useDebouncedValue } from '@/react/hooks/useDebouncedValue';
import { import {
refetchIfAnyOffline, refetchIfAnyOffline,
useEnvironmentList, useEnvironmentList,
@ -75,7 +75,7 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) {
const [searchBarValue, setSearchBarValue] = useSearchBarState(storageKey); const [searchBarValue, setSearchBarValue] = useSearchBarState(storageKey);
const [pageLimit, setPageLimit] = usePaginationLimitState(storageKey); const [pageLimit, setPageLimit] = usePaginationLimitState(storageKey);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const debouncedTextFilter = useDebounce(searchBarValue); const debouncedTextFilter = useDebouncedValue(searchBarValue);
const [connectionTypes, setConnectionTypes] = useHomePageFilter< const [connectionTypes, setConnectionTypes] = useHomePageFilter<
Filter<ConnectionType>[] Filter<ConnectionType>[]

View File

@ -1,30 +1,45 @@
import { Field, useField } from 'formik'; import { useField } from 'formik';
import { string } from 'yup'; import { string } from 'yup';
import { useRef } from 'react'; import { useRef } from 'react';
import _ from 'lodash';
import { getEnvironments } from '@/react/portainer/environments/environment.service'; import { getEnvironments } from '@/react/portainer/environments/environment.service';
import { useDebounce } from '@/react/hooks/useDebounce';
import { FormControl } from '@@/form-components/FormControl'; import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input'; import { Input } from '@@/form-components/Input';
interface Props { interface Props {
readonly?: boolean; readonly?: boolean;
tooltip?: string;
placeholder?: string;
} }
export function NameField({ readonly }: Props) { export function NameField({
const [, meta] = useField('name'); readonly,
tooltip,
placeholder = 'e.g. docker-prod01 / kubernetes-cluster01',
}: Props) {
const [{ value }, meta, { setValue }] = useField('name');
const id = 'name-input'; const id = 'name-input';
const [debouncedValue, setDebouncedValue] = useDebounce(value, setValue);
return ( return (
<FormControl label="Name" required errors={meta.error} inputId={id}> <FormControl
<Field label="Name"
required
errors={meta.error}
inputId={id}
tooltip={tooltip}
>
<Input
id={id} id={id}
name="name" name="name"
as={Input} onChange={(e) => setDebouncedValue(e.target.value)}
value={debouncedValue}
data-cy="endpointCreate-nameInput" data-cy="endpointCreate-nameInput"
placeholder="e.g. docker-prod01 / kubernetes-cluster01" placeholder={placeholder}
readOnly={readonly} readOnly={readonly}
/> />
</FormControl> </FormControl>
@ -37,26 +52,30 @@ export async function isNameUnique(name = '') {
} }
try { try {
const result = await getEnvironments({ limit: 1, query: { name } }); const result = await getEnvironments({
if (result.totalCount > 0) { limit: 1,
return false; query: { name, excludeSnapshots: true },
} });
return (
result.totalCount === 0 || result.value.every((e) => e.Name !== name)
);
} catch (e) { } catch (e) {
// if backend fails to respond, assume name is unique, name validation happens also in the backend // if backend fails to respond, assume name is unique, name validation happens also in the backend
}
return true; return true;
} }
}
function cacheTest( function cacheTest(
asyncValidate: (val?: string) => Promise<boolean> | undefined asyncValidate: (val?: string) => Promise<boolean> | undefined
) { ) {
let valid = false; let valid = true;
let value = ''; let value = '';
return async (newValue = '') => { return async (newValue = '') => {
if (newValue !== value) { if (newValue !== value) {
const response = await asyncValidate(newValue);
value = newValue; value = newValue;
const response = await asyncValidate(newValue);
valid = !!response; valid = !!response;
} }
return valid; return valid;
@ -64,7 +83,7 @@ function cacheTest(
} }
export function useNameValidation() { export function useNameValidation() {
const uniquenessTest = useRef(cacheTest(_.debounce(isNameUnique, 300))); const uniquenessTest = useRef(cacheTest(isNameUnique));
return string() return string()
.required('Name is required') .required('Name is required')