diff --git a/app/react/azure/container-instances/ListView/ContainersDatatable.tsx b/app/react/azure/container-instances/ListView/ContainersDatatable.tsx index 889c18455..a58e99d6e 100644 --- a/app/react/azure/container-instances/ListView/ContainersDatatable.tsx +++ b/app/react/azure/container-instances/ListView/ContainersDatatable.tsx @@ -8,7 +8,7 @@ import { import { useRowSelectColumn } from '@lineup-lite/hooks'; 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 { Authorized } from '@/react/hooks/useUser'; import { confirmDeletionAsync } from '@/portainer/services/modal.service/confirm'; @@ -85,7 +85,7 @@ export function ContainersDatatable({ useRowSelectColumn ); - const debouncedSearchValue = useDebounce(searchBarValue); + const debouncedSearchValue = useDebouncedValue(searchBarValue); useEffect(() => { setGlobalFilter(debouncedSearchValue); diff --git a/app/react/components/datatables/SearchBar.tsx b/app/react/components/datatables/SearchBar.tsx index a885865e4..026013c52 100644 --- a/app/react/components/datatables/SearchBar.tsx +++ b/app/react/components/datatables/SearchBar.tsx @@ -1,9 +1,8 @@ import { Search } from 'react-feather'; -import { useEffect, useMemo, useState } from 'react'; -import _ from 'lodash'; import { useLocalStorage } from '@/react/hooks/useLocalStorage'; import { AutomationTestingProps } from '@/types'; +import { useDebounce } from '@/react/hooks/useDebounce'; interface Props extends AutomationTestingProps { 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( key: string ): [string, (value: string) => void] { diff --git a/app/react/edge/edge-devices/ListView/EdgeDevicesDatatable/EdgeDevicesDatatableContainer.tsx b/app/react/edge/edge-devices/ListView/EdgeDevicesDatatable/EdgeDevicesDatatableContainer.tsx index f9770a7c3..53fd42eb5 100644 --- a/app/react/edge/edge-devices/ListView/EdgeDevicesDatatable/EdgeDevicesDatatableContainer.tsx +++ b/app/react/edge/edge-devices/ListView/EdgeDevicesDatatable/EdgeDevicesDatatableContainer.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { useEnvironmentList } from '@/react/portainer/environments/queries/useEnvironmentList'; 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 { @@ -85,7 +85,7 @@ function Loader({ children, storageKey }: LoaderProps) { }); const [search, setSearch] = useSearchBarState(storageKey); - const debouncedSearchValue = useDebounce(search); + const debouncedSearchValue = useDebouncedValue(search); const { environments, isLoading, totalCount } = useEnvironmentList( { diff --git a/app/react/hooks/useDebounce.ts b/app/react/hooks/useDebounce.ts index f26df6d1a..350ccc839 100644 --- a/app/react/hooks/useDebounce.ts +++ b/app/react/hooks/useDebounce.ts @@ -1,15 +1,18 @@ -import { useEffect, useState } from 'react'; +import _ from 'lodash'; +import { useState, useRef } from 'react'; -export function useDebounce(value: T, delay = 500): T { - const [debouncedValue, setDebouncedValue] = useState(value); +export function useDebounce( + defaultValue: string, + onChange: (value: string) => void +) { + const [searchValue, setSearchValue] = useState(defaultValue); - useEffect(() => { - const timer = setTimeout(() => setDebouncedValue(value), delay); + const onChangeDebounces = useRef(_.debounce(onChange, 300)); - return () => { - clearTimeout(timer); - }; - }, [value, delay]); + return [searchValue, handleChange] as const; - return debouncedValue; + function handleChange(value: string) { + setSearchValue(value); + onChangeDebounces.current(value); + } } diff --git a/app/react/hooks/useDebouncedValue.ts b/app/react/hooks/useDebouncedValue.ts new file mode 100644 index 000000000..854ba3aff --- /dev/null +++ b/app/react/hooks/useDebouncedValue.ts @@ -0,0 +1,15 @@ +import { useEffect, useState } from 'react'; + +export function useDebouncedValue(value: T, delay = 500): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay); + + return () => { + clearTimeout(timer); + }; + }, [value, delay]); + + return debouncedValue; +} diff --git a/app/react/nomad/jobs/EventsView/EventsDatatable/EventsDatatable.tsx b/app/react/nomad/jobs/EventsView/EventsDatatable/EventsDatatable.tsx index a2f263317..e699f336c 100644 --- a/app/react/nomad/jobs/EventsView/EventsDatatable/EventsDatatable.tsx +++ b/app/react/nomad/jobs/EventsView/EventsDatatable/EventsDatatable.tsx @@ -8,7 +8,7 @@ import { } from 'react-table'; import { NomadEvent } from '@/react/nomad/types'; -import { useDebounce } from '@/react/hooks/useDebounce'; +import { useDebouncedValue } from '@/react/hooks/useDebouncedValue'; import { PaginationControls } from '@@/PaginationControls'; import { @@ -42,7 +42,7 @@ export function EventsDatatable({ data, isLoading }: EventsDatatableProps) { useTableSettings(); const [searchBarValue, setSearchBarValue] = useSearchBarState('events'); const columns = useColumns(); - const debouncedSearchValue = useDebounce(searchBarValue); + const debouncedSearchValue = useDebouncedValue(searchBarValue); const { getTableProps, diff --git a/app/react/nomad/jobs/JobsView/JobsDatatable/JobsDatatable.tsx b/app/react/nomad/jobs/JobsView/JobsDatatable/JobsDatatable.tsx index 8e15cbb37..ed946a1bb 100644 --- a/app/react/nomad/jobs/JobsView/JobsDatatable/JobsDatatable.tsx +++ b/app/react/nomad/jobs/JobsView/JobsDatatable/JobsDatatable.tsx @@ -10,7 +10,7 @@ import { import { useRowSelectColumn } from '@lineup-lite/hooks'; import { Job } from '@/react/nomad/types'; -import { useDebounce } from '@/react/hooks/useDebounce'; +import { useDebouncedValue } from '@/react/hooks/useDebouncedValue'; import { PaginationControls } from '@@/PaginationControls'; import { @@ -51,7 +51,7 @@ export function JobsDatatable({ const { settings, setTableSettings } = useTableSettings(); const [searchBarValue, setSearchBarValue] = useSearchBarState('jobs'); const columns = useColumns(); - const debouncedSearchValue = useDebounce(searchBarValue); + const debouncedSearchValue = useDebouncedValue(searchBarValue); useRepeater(settings.autoRefreshRate, refreshData); const { diff --git a/app/react/portainer/HomeView/EnvironmentList/EnvironmentList.tsx b/app/react/portainer/HomeView/EnvironmentList/EnvironmentList.tsx index 99169d0c2..8ac074e63 100644 --- a/app/react/portainer/HomeView/EnvironmentList/EnvironmentList.tsx +++ b/app/react/portainer/HomeView/EnvironmentList/EnvironmentList.tsx @@ -12,7 +12,7 @@ import { EdgeTypes, } from '@/react/portainer/environments/types'; import { EnvironmentGroupId } from '@/react/portainer/environments/environment-groups/types'; -import { useDebounce } from '@/react/hooks/useDebounce'; +import { useDebouncedValue } from '@/react/hooks/useDebouncedValue'; import { refetchIfAnyOffline, useEnvironmentList, @@ -75,7 +75,7 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) { const [searchBarValue, setSearchBarValue] = useSearchBarState(storageKey); const [pageLimit, setPageLimit] = usePaginationLimitState(storageKey); const [page, setPage] = useState(1); - const debouncedTextFilter = useDebounce(searchBarValue); + const debouncedTextFilter = useDebouncedValue(searchBarValue); const [connectionTypes, setConnectionTypes] = useHomePageFilter< Filter[] diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/NameField.tsx b/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/NameField.tsx index 5ad4c3741..23f171e98 100644 --- a/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/NameField.tsx +++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/NameField.tsx @@ -1,30 +1,45 @@ -import { Field, useField } from 'formik'; +import { useField } from 'formik'; import { string } from 'yup'; import { useRef } from 'react'; -import _ from 'lodash'; import { getEnvironments } from '@/react/portainer/environments/environment.service'; +import { useDebounce } from '@/react/hooks/useDebounce'; import { FormControl } from '@@/form-components/FormControl'; import { Input } from '@@/form-components/Input'; interface Props { readonly?: boolean; + tooltip?: string; + placeholder?: string; } -export function NameField({ readonly }: Props) { - const [, meta] = useField('name'); +export function NameField({ + readonly, + tooltip, + placeholder = 'e.g. docker-prod01 / kubernetes-cluster01', +}: Props) { + const [{ value }, meta, { setValue }] = useField('name'); const id = 'name-input'; + const [debouncedValue, setDebouncedValue] = useDebounce(value, setValue); + return ( - - + setDebouncedValue(e.target.value)} + value={debouncedValue} data-cy="endpointCreate-nameInput" - placeholder="e.g. docker-prod01 / kubernetes-cluster01" + placeholder={placeholder} readOnly={readonly} /> @@ -37,26 +52,30 @@ export async function isNameUnique(name = '') { } try { - const result = await getEnvironments({ limit: 1, query: { name } }); - if (result.totalCount > 0) { - return false; - } + const result = await getEnvironments({ + limit: 1, + query: { name, excludeSnapshots: true }, + }); + return ( + result.totalCount === 0 || result.value.every((e) => e.Name !== name) + ); } catch (e) { // if backend fails to respond, assume name is unique, name validation happens also in the backend + return true; } - return true; } function cacheTest( asyncValidate: (val?: string) => Promise | undefined ) { - let valid = false; + let valid = true; let value = ''; return async (newValue = '') => { if (newValue !== value) { - const response = await asyncValidate(newValue); value = newValue; + + const response = await asyncValidate(newValue); valid = !!response; } return valid; @@ -64,7 +83,7 @@ function cacheTest( } export function useNameValidation() { - const uniquenessTest = useRef(cacheTest(_.debounce(isNameUnique, 300))); + const uniquenessTest = useRef(cacheTest(isNameUnique)); return string() .required('Name is required')