mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-20 18:30:15 +01:00
- Add useMarketplaceAgentsInfiniteQuery and useGetAgentCategoriesQuery to client/src/data-provider/Agents/
- Replace manual pagination in AgentGrid with infinite query pattern - Update imports to use local data provider instead of librechat-data-provider - Add proper permission handling with PERMISSION_BITS.VIEW/EDIT constants - Improve agent access control by adding requiredPermission validation in backend - Remove manual cursor/state management in favor of infinite query built-ins - Maintain existing search and category filtering functionality
This commit is contained in:
parent
83122f160f
commit
d549a64317
9 changed files with 143 additions and 128 deletions
|
|
@ -389,6 +389,16 @@ const deleteAgentHandler = async (req, res) => {
|
||||||
const getListAgentsHandler = async (req, res) => {
|
const getListAgentsHandler = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
|
if (!req.query.requiredPermission) {
|
||||||
|
req.query.requiredPermission = PermissionBits.VIEW;
|
||||||
|
} else if (typeof req.query.requiredPermission === 'string') {
|
||||||
|
req.query.requiredPermission = parseInt(req.query.requiredPermission, 10);
|
||||||
|
if (isNaN(req.query.requiredPermission)) {
|
||||||
|
req.query.requiredPermission = PermissionBits.VIEW;
|
||||||
|
}
|
||||||
|
} else if (typeof req.query.requiredPermission !== 'number') {
|
||||||
|
req.query.requiredPermission = PermissionBits.VIEW;
|
||||||
|
}
|
||||||
const requiredPermission = req.query.requiredPermission || PermissionBits.VIEW;
|
const requiredPermission = req.query.requiredPermission || PermissionBits.VIEW;
|
||||||
// Get agent IDs the user has VIEW access to via ACL
|
// Get agent IDs the user has VIEW access to via ACL
|
||||||
const accessibleIds = await findAccessibleResources({
|
const accessibleIds = await findAccessibleResources({
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
|
|
||||||
import type t from 'librechat-data-provider';
|
import type t from 'librechat-data-provider';
|
||||||
|
|
||||||
import { useGetMarketplaceAgentsQuery } from 'librechat-data-provider/react-query';
|
import { useMarketplaceAgentsInfiniteQuery } from '~/data-provider/Agents';
|
||||||
import { useAgentCategories } from '~/hooks/Agents';
|
import { useAgentCategories } from '~/hooks/Agents';
|
||||||
import useLocalize from '~/hooks/useLocalize';
|
import useLocalize from '~/hooks/useLocalize';
|
||||||
import { Button } from '~/components/ui';
|
import { Button } from '~/components/ui';
|
||||||
|
|
@ -11,7 +11,7 @@ import { SmartLoader, useHasData } from './SmartLoader';
|
||||||
import ErrorDisplay from './ErrorDisplay';
|
import ErrorDisplay from './ErrorDisplay';
|
||||||
import AgentCard from './AgentCard';
|
import AgentCard from './AgentCard';
|
||||||
import { cn } from '~/utils';
|
import { cn } from '~/utils';
|
||||||
|
import { PERMISSION_BITS } from 'librechat-data-provider';
|
||||||
interface AgentGridProps {
|
interface AgentGridProps {
|
||||||
category: string; // Currently selected category
|
category: string; // Currently selected category
|
||||||
searchQuery: string; // Current search query
|
searchQuery: string; // Current search query
|
||||||
|
|
@ -23,30 +23,23 @@ interface AgentGridProps {
|
||||||
*/
|
*/
|
||||||
const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAgent }) => {
|
const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAgent }) => {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const [cursor, setCursor] = useState<string | undefined>(undefined);
|
|
||||||
const [allAgents, setAllAgents] = useState<t.Agent[]>([]);
|
|
||||||
|
|
||||||
// Get category data from API
|
// Get category data from API
|
||||||
const { categories } = useAgentCategories();
|
const { categories } = useAgentCategories();
|
||||||
|
|
||||||
// Build query parameters based on current state
|
// Build query parameters based on current state
|
||||||
const queryParams = React.useMemo(() => {
|
const queryParams = useMemo(() => {
|
||||||
const params: {
|
const params: {
|
||||||
requiredPermission: number;
|
requiredPermission: number;
|
||||||
category?: string;
|
category?: string;
|
||||||
search?: string;
|
search?: string;
|
||||||
limit: number;
|
limit: number;
|
||||||
cursor?: string;
|
|
||||||
promoted?: 0 | 1;
|
promoted?: 0 | 1;
|
||||||
} = {
|
} = {
|
||||||
requiredPermission: 1, // Read permission for marketplace viewing
|
requiredPermission: PERMISSION_BITS.VIEW, // View permission for marketplace viewing
|
||||||
limit: 6,
|
limit: 6,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (cursor) {
|
|
||||||
params.cursor = cursor;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle search
|
// Handle search
|
||||||
if (searchQuery) {
|
if (searchQuery) {
|
||||||
params.search = searchQuery;
|
params.search = searchQuery;
|
||||||
|
|
@ -65,29 +58,28 @@ const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAg
|
||||||
}
|
}
|
||||||
|
|
||||||
return params;
|
return params;
|
||||||
}, [category, searchQuery, cursor]);
|
}, [category, searchQuery]);
|
||||||
|
|
||||||
// Single unified query that handles all cases
|
// Use infinite query for marketplace agents
|
||||||
const { data, isLoading, error, isFetching, refetch } = useGetMarketplaceAgentsQuery(queryParams);
|
const {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
isFetching,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
refetch,
|
||||||
|
isFetchingNextPage,
|
||||||
|
} = useMarketplaceAgentsInfiniteQuery(queryParams);
|
||||||
|
|
||||||
// Handle data accumulation for pagination
|
// Flatten all pages into a single array of agents
|
||||||
React.useEffect(() => {
|
const currentAgents = useMemo(() => {
|
||||||
if (data?.data) {
|
if (!data?.pages) return [];
|
||||||
if (cursor) {
|
return data.pages.flatMap((page) => page.data || []);
|
||||||
// Append new data for pagination
|
}, [data?.pages]);
|
||||||
setAllAgents((prev) => [...prev, ...data.data]);
|
|
||||||
} else {
|
|
||||||
// Replace data for new queries
|
|
||||||
setAllAgents(data.data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [data, cursor]);
|
|
||||||
|
|
||||||
// Get current agents to display
|
|
||||||
const currentAgents = cursor ? allAgents : data?.data || [];
|
|
||||||
|
|
||||||
// Check if we have meaningful data to prevent unnecessary loading states
|
// Check if we have meaningful data to prevent unnecessary loading states
|
||||||
const hasData = useHasData(data);
|
const hasData = useHasData(data?.pages?.[0]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get category display name from API data or use fallback
|
* Get category display name from API data or use fallback
|
||||||
|
|
@ -114,19 +106,11 @@ const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAg
|
||||||
* Load more agents when "See More" button is clicked
|
* Load more agents when "See More" button is clicked
|
||||||
*/
|
*/
|
||||||
const handleLoadMore = () => {
|
const handleLoadMore = () => {
|
||||||
if (data?.after) {
|
if (hasNextPage && !isFetching) {
|
||||||
setCursor(data.after);
|
fetchNextPage();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Reset cursor and agents when category or search changes
|
|
||||||
*/
|
|
||||||
React.useEffect(() => {
|
|
||||||
setCursor(undefined);
|
|
||||||
setAllAgents([]);
|
|
||||||
}, [category, searchQuery]);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the appropriate title for the agents grid based on current state
|
* Get the appropriate title for the agents grid based on current state
|
||||||
*/
|
*/
|
||||||
|
|
@ -257,7 +241,7 @@ const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAg
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Loading indicator when fetching more with accessibility */}
|
{/* Loading indicator when fetching more with accessibility */}
|
||||||
{isFetching && cursor && (
|
{isFetching && hasNextPage && (
|
||||||
<div
|
<div
|
||||||
className="flex justify-center py-4"
|
className="flex justify-center py-4"
|
||||||
role="status"
|
role="status"
|
||||||
|
|
@ -270,7 +254,7 @@ const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAg
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Load more button with enhanced accessibility */}
|
{/* Load more button with enhanced accessibility */}
|
||||||
{data?.has_more && !isFetching && (
|
{hasNextPage && !isFetching && (
|
||||||
<div className="mt-8 flex justify-center">
|
<div className="mt-8 flex justify-center">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -294,18 +278,13 @@ const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAg
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
console.log('isLoading', isLoading);
|
||||||
// Use SmartLoader to prevent unnecessary loading flashes
|
console.log('isFetching', isFetching);
|
||||||
return (
|
console.log('isFetchingNextPage', isFetchingNextPage);
|
||||||
<SmartLoader
|
if (isLoading || (isFetching && !isFetchingNextPage)) {
|
||||||
isLoading={isLoading}
|
return loadingSkeleton;
|
||||||
hasData={hasData}
|
}
|
||||||
delay={200} // Show loading only after 200ms delay
|
return mainContent;
|
||||||
loadingComponent={loadingSkeleton}
|
|
||||||
>
|
|
||||||
{mainContent}
|
|
||||||
</SmartLoader>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AgentGrid;
|
export default AgentGrid;
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,7 @@ import { useSetRecoilState, useRecoilValue } from 'recoil';
|
||||||
import type t from 'librechat-data-provider';
|
import type t from 'librechat-data-provider';
|
||||||
import type { ContextType } from '~/common';
|
import type { ContextType } from '~/common';
|
||||||
|
|
||||||
import { useGetEndpointsQuery } from '~/data-provider';
|
import { useGetEndpointsQuery, useGetAgentCategoriesQuery } from '~/data-provider';
|
||||||
import { useGetAgentCategoriesQuery } from 'librechat-data-provider/react-query';
|
|
||||||
import { useDocumentTitle } from '~/hooks';
|
import { useDocumentTitle } from '~/hooks';
|
||||||
import useLocalize from '~/hooks/useLocalize';
|
import useLocalize from '~/hooks/useLocalize';
|
||||||
import { TooltipAnchor, Button } from '~/components/ui';
|
import { TooltipAnchor, Button } from '~/components/ui';
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
import { EarthIcon } from 'lucide-react';
|
import { EarthIcon } from 'lucide-react';
|
||||||
import { useCallback, useEffect, useRef } from 'react';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
import { useFormContext, Controller } from 'react-hook-form';
|
import { useFormContext, Controller } from 'react-hook-form';
|
||||||
import { AgentCapabilities, defaultAgentFormValues } from 'librechat-data-provider';
|
import {
|
||||||
|
AgentCapabilities,
|
||||||
|
defaultAgentFormValues,
|
||||||
|
PERMISSION_BITS,
|
||||||
|
} from 'librechat-data-provider';
|
||||||
import type { UseMutationResult, QueryObserverResult } from '@tanstack/react-query';
|
import type { UseMutationResult, QueryObserverResult } from '@tanstack/react-query';
|
||||||
import type { Agent, AgentCreateParams } from 'librechat-data-provider';
|
import type { Agent, AgentCreateParams } from 'librechat-data-provider';
|
||||||
import type { TAgentCapabilities, AgentForm } from '~/common';
|
import type { TAgentCapabilities, AgentForm } from '~/common';
|
||||||
|
|
@ -28,18 +32,21 @@ export default function AgentSelect({
|
||||||
const { control, reset } = useFormContext();
|
const { control, reset } = useFormContext();
|
||||||
|
|
||||||
const { data: startupConfig } = useGetStartupConfig();
|
const { data: startupConfig } = useGetStartupConfig();
|
||||||
const { data: agents = null } = useListAgentsQuery(undefined, {
|
const { data: agents = null } = useListAgentsQuery(
|
||||||
select: (res) =>
|
{ requiredPermission: PERMISSION_BITS.EDIT },
|
||||||
res.data.map((agent) =>
|
{
|
||||||
processAgentOption({
|
select: (res) =>
|
||||||
agent: {
|
res.data.map((agent) =>
|
||||||
...agent,
|
processAgentOption({
|
||||||
name: agent.name || agent.id,
|
agent: {
|
||||||
},
|
...agent,
|
||||||
instanceProjectId: startupConfig?.instanceProjectId,
|
name: agent.name || agent.id,
|
||||||
}),
|
},
|
||||||
),
|
instanceProjectId: startupConfig?.instanceProjectId,
|
||||||
});
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const resetAgentForm = useCallback(
|
const resetAgentForm = useCallback(
|
||||||
(fullAgent: Agent) => {
|
(fullAgent: Agent) => {
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,5 @@
|
||||||
export * from './queries';
|
export * from './queries';
|
||||||
export * from './mutations';
|
export * from './mutations';
|
||||||
|
|
||||||
|
// Re-export specific marketplace queries for easier imports
|
||||||
|
export { useGetAgentCategoriesQuery, useMarketplaceAgentsInfiniteQuery } from './queries';
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
import { QueryKeys, dataService, EModelEndpoint, defaultOrderQuery } from 'librechat-data-provider';
|
import { QueryKeys, dataService, EModelEndpoint, defaultOrderQuery } from 'librechat-data-provider';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import type { QueryObserverResult, UseQueryOptions } from '@tanstack/react-query';
|
import type {
|
||||||
|
QueryObserverResult,
|
||||||
|
UseQueryOptions,
|
||||||
|
UseInfiniteQueryOptions,
|
||||||
|
InfiniteData,
|
||||||
|
} from '@tanstack/react-query';
|
||||||
import type t from 'librechat-data-provider';
|
import type t from 'librechat-data-provider';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -98,3 +103,60 @@ export const useGetExpandedAgentByIdQuery = (
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MARKETPLACE
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Hook for getting agent categories for marketplace tabs
|
||||||
|
*/
|
||||||
|
export const useGetAgentCategoriesQuery = (
|
||||||
|
config?: UseQueryOptions<t.TMarketplaceCategory[]>,
|
||||||
|
): QueryObserverResult<t.TMarketplaceCategory[]> => {
|
||||||
|
return useQuery<t.TMarketplaceCategory[]>(
|
||||||
|
[QueryKeys.agentCategories],
|
||||||
|
() => dataService.getAgentCategories(),
|
||||||
|
{
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
refetchOnMount: false,
|
||||||
|
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||||
|
...config,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for infinite loading of marketplace agents with cursor-based pagination
|
||||||
|
*/
|
||||||
|
export const useMarketplaceAgentsInfiniteQuery = (
|
||||||
|
params: {
|
||||||
|
requiredPermission: number;
|
||||||
|
category?: string;
|
||||||
|
search?: string;
|
||||||
|
limit?: number;
|
||||||
|
promoted?: 0 | 1;
|
||||||
|
cursor?: string; // For pagination
|
||||||
|
},
|
||||||
|
config?: UseInfiniteQueryOptions<t.AgentListResponse, unknown>,
|
||||||
|
) => {
|
||||||
|
return useInfiniteQuery<t.AgentListResponse>({
|
||||||
|
queryKey: [QueryKeys.marketplaceAgents, params],
|
||||||
|
queryFn: ({ pageParam }) => {
|
||||||
|
const queryParams = { ...params };
|
||||||
|
if (pageParam) {
|
||||||
|
queryParams.cursor = pageParam.toString();
|
||||||
|
}
|
||||||
|
return dataService.getMarketplaceAgents(queryParams);
|
||||||
|
},
|
||||||
|
getNextPageParam: (lastPage) => lastPage?.after ?? undefined,
|
||||||
|
enabled: !!params.requiredPermission,
|
||||||
|
keepPreviousData: true,
|
||||||
|
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||||
|
cacheTime: 10 * 60 * 1000, // 10 minutes
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
refetchOnMount: false,
|
||||||
|
...config,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import useLocalize from '~/hooks/useLocalize';
|
import useLocalize from '~/hooks/useLocalize';
|
||||||
import { useGetAgentCategoriesQuery } from 'librechat-data-provider/react-query';
|
import { useGetAgentCategoriesQuery } from '~/data-provider/Agents';
|
||||||
import { EMPTY_AGENT_CATEGORY } from '~/constants/agentCategories';
|
import { EMPTY_AGENT_CATEGORY } from '~/constants/agentCategories';
|
||||||
|
|
||||||
// This interface matches the structure used by the ControlCombobox component
|
// This interface matches the structure used by the ControlCombobox component
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { TAgentsMap } from 'librechat-data-provider';
|
import { PERMISSION_BITS, TAgentsMap } from 'librechat-data-provider';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useListAgentsQuery } from '~/data-provider';
|
import { useListAgentsQuery } from '~/data-provider';
|
||||||
import { mapAgents } from '~/utils';
|
import { mapAgents } from '~/utils';
|
||||||
|
|
@ -8,10 +8,15 @@ export default function useAgentsMap({
|
||||||
}: {
|
}: {
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
}): TAgentsMap | undefined {
|
}): TAgentsMap | undefined {
|
||||||
const { data: agentsList = null } = useListAgentsQuery(undefined, {
|
const { data: agentsList = null } = useListAgentsQuery(
|
||||||
select: (res) => mapAgents(res.data),
|
{
|
||||||
enabled: isAuthenticated,
|
requiredPermission: PERMISSION_BITS.EDIT,
|
||||||
});
|
},
|
||||||
|
{
|
||||||
|
select: (res) => mapAgents(res.data),
|
||||||
|
enabled: isAuthenticated,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const agents = useMemo<TAgentsMap | undefined>(() => {
|
const agents = useMemo<TAgentsMap | undefined>(() => {
|
||||||
return agentsList !== null ? agentsList : undefined;
|
return agentsList !== null ? agentsList : undefined;
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import * as q from '../types/queries';
|
||||||
import { QueryKeys } from '../keys';
|
import { QueryKeys } from '../keys';
|
||||||
import * as s from '../schemas';
|
import * as s from '../schemas';
|
||||||
import * as t from '../types';
|
import * as t from '../types';
|
||||||
import * as a from '../types/assistants';
|
|
||||||
import * as permissions from '../accessPermissions';
|
import * as permissions from '../accessPermissions';
|
||||||
|
|
||||||
export { hasPermissions } from '../accessPermissions';
|
export { hasPermissions } from '../accessPermissions';
|
||||||
|
|
@ -451,52 +450,3 @@ export const useGetEffectivePermissionsQuery = (
|
||||||
...config,
|
...config,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/* Marketplace */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get agent categories with counts for marketplace tabs
|
|
||||||
*/
|
|
||||||
export const useGetAgentCategoriesQuery = (
|
|
||||||
config?: UseQueryOptions<t.TMarketplaceCategory[]>,
|
|
||||||
): QueryObserverResult<t.TMarketplaceCategory[]> => {
|
|
||||||
return useQuery<t.TMarketplaceCategory[]>(
|
|
||||||
[QueryKeys.agentCategories],
|
|
||||||
() => dataService.getAgentCategories(),
|
|
||||||
{
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
refetchOnReconnect: false,
|
|
||||||
refetchOnMount: false,
|
|
||||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
|
||||||
...config,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unified marketplace agents query with query string controls
|
|
||||||
*/
|
|
||||||
export const useGetMarketplaceAgentsQuery = (
|
|
||||||
params: {
|
|
||||||
requiredPermission: number;
|
|
||||||
category?: string;
|
|
||||||
search?: string;
|
|
||||||
limit?: number;
|
|
||||||
cursor?: string;
|
|
||||||
promoted?: 0 | 1;
|
|
||||||
},
|
|
||||||
config?: UseQueryOptions<a.AgentListResponse>,
|
|
||||||
): QueryObserverResult<a.AgentListResponse> => {
|
|
||||||
return useQuery<a.AgentListResponse>(
|
|
||||||
[QueryKeys.marketplaceAgents, params],
|
|
||||||
() => dataService.getMarketplaceAgents(params),
|
|
||||||
{
|
|
||||||
enabled: !!params.requiredPermission,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
refetchOnReconnect: false,
|
|
||||||
refetchOnMount: false,
|
|
||||||
staleTime: 2 * 60 * 1000, // Cache for 2 minutes
|
|
||||||
...config,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue