/******************************************************************************** * Copyright (c) 2020 TypeFox and others * * This program and the accompanying materials are made available under the * terms of the Eclipse Public License v. 2.0 which is available at * http://www.eclipse.org/legal/epl-2.0. * * SPDX-License-Identifier: EPL-2.0 ********************************************************************************/ import { FunctionComponent, SyntheticEvent, createContext, useContext, useEffect, useMemo, useState } from 'react'; import { Alert, Autocomplete, Avatar, Box, Chip, IconButton, InputBase, MenuItem, Paper, Select, Stack, Typography } from '@mui/material'; import type { AutocompleteInputChangeReason, AutocompleteRenderInputParams } from '@mui/material'; import { useQueryClient } from '@tanstack/react-query'; import SearchIcon from '@mui/icons-material/Search'; import ClearIcon from '@mui/icons-material/Clear'; import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings'; import PersonIcon from '@mui/icons-material/Person'; import { useParams, useNavigate } from 'react-router'; import { UserRelationships } from '../../extension-registry-types'; import { ErrorResponse } from '../../server-request'; import { MainContext } from '../../context'; import { PublisherDetails } from './publisher-details'; import { SearchListContainer } from './search-list-container'; import { handleError as formatError } from '../../utils'; import { AdminDashboardRoutes } from './admin-dashboard-routes'; import { useDebouncedCallback } from '../../hooks/use-debounced-callback'; import { useInfinitePublishers } from './use-publisher-admin'; // eslint-disable-next-line react-refresh/only-export-components export const UpdateContext = createContext({ handleUpdate: () => {} }); const ROLE_FILTER_OPTIONS = [ { value: '', label: 'Any role' }, { value: 'admin', label: 'Admin' }, { value: 'privileged', label: 'Privileged' }, { value: 'none', label: 'No role' } ]; // How close to the bottom of the dropdown (in px) the user must scroll before the next page loads. const LOAD_MORE_THRESHOLD = 200; const roleIcon = (role: string | undefined) => role ? : ; export const PublisherAdmin: FunctionComponent = () => { const { publisher: publisherParam } = useParams<{ publisher?: string }>(); const navigate = useNavigate(); const queryClient = useQueryClient(); const { pageSettings } = useContext(MainContext); const [searchText, setSearchText] = useState(publisherParam ?? ''); const [inputValue, setInputValue] = useState(publisherParam ?? ''); const [roleFilter, setRoleFilter] = useState(''); const [selected, setSelected] = useState(null); const debouncedSetSearch = useDebouncedCallback(setSearchText); const { data, isFetching, isFetchingNextPage, error, hasNextPage, fetchNextPage } = useInfinitePublishers( searchText, roleFilter ); const publishers = useMemo(() => data?.pages.flatMap(page => page.content) ?? [], [data]); const listLoading = isFetching && !isFetchingNextPage; // Resolve a deep-linked publisher once the matching page has loaded. useEffect(() => { if (publisherParam && !selected) { const match = publishers.find(p => p.user.loginName === publisherParam); if (match) { setSelected(match); } } }, [publisherParam, selected, publishers]); const updateContextValue = useMemo( () => ({ handleUpdate: () => { queryClient.invalidateQueries({ queryKey: ['admin', 'publisher'] }); queryClient.invalidateQueries({ queryKey: ['admin', 'publishers'] }); } }), [queryClient] ); const clearSelection = () => { if (selected || publisherParam) { setSelected(null); navigate(AdminDashboardRoutes.PUBLISHER_ADMIN, { replace: true }); } }; const handleSelect = (_event: SyntheticEvent, value: UserRelationships | null) => { if (!value) { clearSelection(); return; } setSelected(value); setInputValue(value.user.loginName); navigate(`${AdminDashboardRoutes.PUBLISHER_ADMIN}/${encodeURIComponent(value.user.loginName)}`, { replace: true }); }; const handleInputChange = (_event: SyntheticEvent, value: string, reason: AutocompleteInputChangeReason) => { setInputValue(value); // 'reset' fires when the input syncs to the selected option's label — nothing else to do. if (reason === 'reset') { return; } clearSelection(); if (reason === 'clear') { setSearchText(''); } else { debouncedSetSearch(value); } }; const handleClear = () => { setInputValue(''); setSearchText(''); clearSelection(); }; const loadMoreOnScroll = (event: SyntheticEvent) => { const listbox = event.currentTarget as HTMLElement; const reachedBottom = listbox.scrollHeight - listbox.scrollTop - listbox.clientHeight < LOAD_MORE_THRESHOLD; if (reachedBottom && hasNextPage && !isFetchingNextPage) { void fetchNextPage(); } }; const searchIconColor = pageSettings?.themeType === 'dark' ? '#111111' : '#ffffff'; const renderInput = (params: AutocompleteRenderInputParams) => ( {inputValue && ( )} } /> ); return ( option.user.loginName} isOptionEqualToValue={(option, value) => option.user.loginName === value.user.loginName && option.user.provider === value.user.provider } filterOptions={x => x} autoHighlight clearOnBlur={false} handleHomeEndKeys forcePopupIcon={false} loading={listLoading} loadingText='Searching…' noOptionsText='No publishers matched the current filters.' ListboxProps={{ onScroll: loadMoreOnScroll }} renderInput={renderInput} renderOption={(props, option) => { const { user } = option; return ( {user.loginName} {user.fullName || '—'} {user.role ? ( ) : null} ); }} /> ]} listContainer={null} loading={listLoading} /> {error && {formatError(error as Error | Partial)}} {selected ? ( ) : publisherParam && !isFetching ? ( No publisher found for “{publisherParam}”. ) : ( Search for a user and select one to view their details. )} ); };