mirror of https://github.com/portainer/portainer
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
parent
253a3a2b40
commit
7006c17ce4
|
@ -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);
|
||||||
|
|
|
@ -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] {
|
||||||
|
|
|
@ -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(
|
||||||
{
|
{
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
|
@ -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,
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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>[]
|
||||||
|
|
|
@ -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')
|
||||||
|
|
Loading…
Reference in New Issue