mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-17 08:50:15 +01:00
feat: auto-scroll to the bottom of the conversation (#1049)
* added button for autoscroll
* fix(General) removed bold
* fix(General) typescript error with checked={autoScroll}
* added return condition for new conversations
* refactor(Message) limit nesting
* fix(settings) used effects
* fix(Message) disabled autoscroll when search
* test(AutoScrollSwitch)
* fix(AutoScrollSwitch) test
* fix(ci): attempt to debug workflow
* refactor: move AutoScrollSwitch from General file, don't use cache for npm
* fix(ci): add test config to avoid redirects and silentRefresh
* chore: add back workflow caching
* chore(AutoScrollSwitch): remove comments, fix type issues, clarify switch intent
* refactor(Message): remove unnecessary message prop form scrolling condition
* fix(AutoScrollSwitch.spec): do not get by text
---------
Co-authored-by: Danny Avila <messagedaniel@protonmail.com>
This commit is contained in:
parent
cff45df0ef
commit
b1a96ecedc
12 changed files with 158 additions and 37 deletions
3
.github/workflows/frontend-review.yml
vendored
3
.github/workflows/frontend-review.yml
vendored
|
|
@ -34,4 +34,5 @@ jobs:
|
||||||
run: npm run frontend:ci
|
run: npm run frontend:ci
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
run: cd client && npm run test:ci
|
run: npm run test:ci --verbose
|
||||||
|
working-directory: client
|
||||||
|
|
@ -8,8 +8,8 @@
|
||||||
"build:ci": "cross-env NODE_ENV=development vite build --mode ci",
|
"build:ci": "cross-env NODE_ENV=development vite build --mode ci",
|
||||||
"dev": "cross-env NODE_ENV=development vite",
|
"dev": "cross-env NODE_ENV=development vite",
|
||||||
"preview-prod": "cross-env NODE_ENV=development vite preview",
|
"preview-prod": "cross-env NODE_ENV=development vite preview",
|
||||||
"test": "cross-env NODE_ENV=test jest --watch",
|
"test": "cross-env NODE_ENV=development jest --watch",
|
||||||
"test:ci": "cross-env NODE_ENV=test jest --ci",
|
"test:ci": "cross-env NODE_ENV=development jest --ci",
|
||||||
"b:test": "NODE_ENV=test bunx jest --watch",
|
"b:test": "NODE_ENV=test bunx jest --watch",
|
||||||
"b:build": "NODE_ENV=production bun --bun vite build",
|
"b:build": "NODE_ENV=production bun --bun vite build",
|
||||||
"b:dev": "NODE_ENV=development bunx vite"
|
"b:dev": "NODE_ENV=development bunx vite"
|
||||||
|
|
|
||||||
|
|
@ -177,6 +177,7 @@ export type TUserContext = {
|
||||||
|
|
||||||
export type TAuthConfig = {
|
export type TAuthConfig = {
|
||||||
loginRedirect: string;
|
loginRedirect: string;
|
||||||
|
test?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type IconProps = Pick<TMessage, 'isCreatedByUser' | 'model' | 'error'> &
|
export type IconProps = Pick<TMessage, 'isCreatedByUser' | 'model' | 'error'> &
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
/* eslint-disable react-hooks/exhaustive-deps */
|
/* eslint-disable react-hooks/exhaustive-deps */
|
||||||
import { useGetConversationByIdQuery } from 'librechat-data-provider';
|
import { useGetConversationByIdQuery } from 'librechat-data-provider';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useSetRecoilState, useRecoilState } from 'recoil';
|
import { useSetRecoilState, useRecoilState, useRecoilValue } from 'recoil';
|
||||||
import copy from 'copy-to-clipboard';
|
import copy from 'copy-to-clipboard';
|
||||||
import { SubRow, Plugin, MessageContent } from './Content';
|
import { SubRow, Plugin, MessageContent } from './Content';
|
||||||
// eslint-disable-next-line import/no-cycle
|
// eslint-disable-next-line import/no-cycle
|
||||||
|
|
@ -13,8 +13,10 @@ import { useMessageHandler, useConversation } from '~/hooks';
|
||||||
import type { TMessageProps } from '~/common';
|
import type { TMessageProps } from '~/common';
|
||||||
import { cn } from '~/utils';
|
import { cn } from '~/utils';
|
||||||
import store from '~/store';
|
import store from '~/store';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
|
||||||
export default function Message({
|
export default function Message(props: TMessageProps) {
|
||||||
|
const {
|
||||||
conversation,
|
conversation,
|
||||||
message,
|
message,
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
|
|
@ -23,11 +25,15 @@ export default function Message({
|
||||||
siblingIdx,
|
siblingIdx,
|
||||||
siblingCount,
|
siblingCount,
|
||||||
setSiblingIdx,
|
setSiblingIdx,
|
||||||
}: TMessageProps) {
|
} = props;
|
||||||
|
|
||||||
const setLatestMessage = useSetRecoilState(store.latestMessage);
|
const setLatestMessage = useSetRecoilState(store.latestMessage);
|
||||||
const [abortScroll, setAbortScroll] = useRecoilState(store.abortScroll);
|
const [abortScroll, setAbortScroll] = useRecoilState(store.abortScroll);
|
||||||
const { isSubmitting, ask, regenerate, handleContinue } = useMessageHandler();
|
const { isSubmitting, ask, regenerate, handleContinue } = useMessageHandler();
|
||||||
const { switchToConversation } = useConversation();
|
const { switchToConversation } = useConversation();
|
||||||
|
const { conversationId } = useParams();
|
||||||
|
const isSearching = useRecoilValue(store.isSearching);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
text,
|
text,
|
||||||
children,
|
children,
|
||||||
|
|
@ -37,24 +43,26 @@ export default function Message({
|
||||||
error,
|
error,
|
||||||
unfinished,
|
unfinished,
|
||||||
} = message ?? {};
|
} = message ?? {};
|
||||||
|
|
||||||
const isLast = !children?.length;
|
const isLast = !children?.length;
|
||||||
const edit = messageId == currentEditId;
|
const edit = messageId === currentEditId;
|
||||||
const getConversationQuery = useGetConversationByIdQuery(message?.conversationId ?? '', {
|
const getConversationQuery = useGetConversationByIdQuery(message?.conversationId ?? '', {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
});
|
});
|
||||||
const blinker = message?.submitting && isSubmitting;
|
|
||||||
|
|
||||||
// debugging
|
const autoScroll = useRecoilValue(store.autoScroll);
|
||||||
// useEffect(() => {
|
|
||||||
// console.log('isSubmitting:', isSubmitting);
|
|
||||||
// console.log('unfinished:', unfinished);
|
|
||||||
// }, [isSubmitting, unfinished]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (blinker && scrollToBottom && !abortScroll) {
|
if (isSubmitting && scrollToBottom && !abortScroll) {
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}
|
}
|
||||||
}, [isSubmitting, blinker, text, scrollToBottom]);
|
}, [isSubmitting, text, scrollToBottom, abortScroll]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollToBottom && autoScroll && !isSearching && conversationId !== 'new') {
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
}, [autoScroll, conversationId, scrollToBottom, isSearching]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!message) {
|
if (!message) {
|
||||||
|
|
@ -62,7 +70,7 @@ export default function Message({
|
||||||
} else if (isLast) {
|
} else if (isLast) {
|
||||||
setLatestMessage({ ...message });
|
setLatestMessage({ ...message });
|
||||||
}
|
}
|
||||||
}, [isLast, message]);
|
}, [isLast, message, setLatestMessage]);
|
||||||
|
|
||||||
if (!message) {
|
if (!message) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -72,7 +80,7 @@ export default function Message({
|
||||||
setCurrentEditId && setCurrentEditId(cancel ? -1 : messageId);
|
setCurrentEditId && setCurrentEditId(cancel ? -1 : messageId);
|
||||||
|
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
if (blinker) {
|
if (isSubmitting) {
|
||||||
setAbortScroll(true);
|
setAbortScroll(true);
|
||||||
} else {
|
} else {
|
||||||
setAbortScroll(false);
|
setAbortScroll(false);
|
||||||
|
|
@ -85,7 +93,7 @@ export default function Message({
|
||||||
? 'bg-white dark:bg-gray-800 dark:text-gray-20'
|
? 'bg-white dark:bg-gray-800 dark:text-gray-20'
|
||||||
: 'bg-gray-50 dark:bg-gray-1000 dark:text-gray-70';
|
: 'bg-gray-50 dark:bg-gray-1000 dark:text-gray-70';
|
||||||
|
|
||||||
const props = {
|
const messageProps = {
|
||||||
className: cn(commonClasses, uniqueClasses),
|
className: cn(commonClasses, uniqueClasses),
|
||||||
titleclass: '',
|
titleclass: '',
|
||||||
};
|
};
|
||||||
|
|
@ -98,8 +106,8 @@ export default function Message({
|
||||||
});
|
});
|
||||||
|
|
||||||
if (message?.bg && searchResult) {
|
if (message?.bg && searchResult) {
|
||||||
props.className = message?.bg?.split('hover')[0];
|
messageProps.className = message?.bg?.split('hover')[0];
|
||||||
props.titleclass = message?.bg?.split(props.className)[1] + ' cursor-pointer';
|
messageProps.titleclass = message?.bg?.split(messageProps.className)[1] + ' cursor-pointer';
|
||||||
}
|
}
|
||||||
|
|
||||||
const regenerateMessage = () => {
|
const regenerateMessage = () => {
|
||||||
|
|
@ -124,17 +132,20 @@ export default function Message({
|
||||||
if (!message) {
|
if (!message) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
getConversationQuery.refetch({ queryKey: [message?.conversationId] }).then((response) => {
|
const response = await getConversationQuery.refetch({
|
||||||
|
queryKey: [message?.conversationId],
|
||||||
|
});
|
||||||
|
|
||||||
console.log('getConversationQuery response.data:', response.data);
|
console.log('getConversationQuery response.data:', response.data);
|
||||||
|
|
||||||
if (response.data) {
|
if (response.data) {
|
||||||
switchToConversation(response.data);
|
switchToConversation(response.data);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div {...props} onWheel={handleScroll} onTouchMove={handleScroll}>
|
<div {...messageProps} onWheel={handleScroll} onTouchMove={handleScroll}>
|
||||||
<div className="relative m-auto flex gap-4 p-4 text-base md:max-w-2xl md:gap-6 md:py-6 lg:max-w-2xl lg:px-0 xl:max-w-3xl">
|
<div className="relative m-auto flex gap-4 p-4 text-base md:max-w-2xl md:gap-6 md:py-6 lg:max-w-2xl lg:px-0 xl:max-w-3xl">
|
||||||
<div className="relative flex h-[40px] w-[40px] flex-col items-end text-right text-xs md:text-sm">
|
<div className="relative flex h-[40px] w-[40px] flex-col items-end text-right text-xs md:text-sm">
|
||||||
{typeof icon === 'string' && /[^\\x00-\\x7F]+/.test(icon as string) ? (
|
{typeof icon === 'string' && /[^\\x00-\\x7F]+/.test(icon as string) ? (
|
||||||
|
|
@ -153,7 +164,7 @@ export default function Message({
|
||||||
<div className="relative flex w-[calc(100%-50px)] flex-col gap-1 md:gap-3 lg:w-[calc(100%-115px)]">
|
<div className="relative flex w-[calc(100%-50px)] flex-col gap-1 md:gap-3 lg:w-[calc(100%-115px)]">
|
||||||
{searchResult && (
|
{searchResult && (
|
||||||
<SubRow
|
<SubRow
|
||||||
classes={props.titleclass + ' rounded'}
|
classes={messageProps.titleclass + ' rounded'}
|
||||||
subclasses="switch-result pl-2 pb-2"
|
subclasses="switch-result pl-2 pb-2"
|
||||||
onClick={clickSearchResult}
|
onClick={clickSearchResult}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
import React from 'react';
|
||||||
|
import '@testing-library/jest-dom/extend-expect';
|
||||||
|
import { render, fireEvent } from 'test/layout-test-utils';
|
||||||
|
import AutoScrollSwitch from './AutoScrollSwitch';
|
||||||
|
import { RecoilRoot } from 'recoil';
|
||||||
|
|
||||||
|
describe('AutoScrollSwitch', () => {
|
||||||
|
/**
|
||||||
|
* Mock function to set the auto-scroll state.
|
||||||
|
*/
|
||||||
|
let mockSetAutoScroll: jest.Mock<void, [boolean]> | ((value: boolean) => void) | undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockSetAutoScroll = jest.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders correctly', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<RecoilRoot>
|
||||||
|
<AutoScrollSwitch />
|
||||||
|
</RecoilRoot>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getByTestId('autoScroll')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onCheckedChange when the switch is toggled', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<RecoilRoot>
|
||||||
|
<AutoScrollSwitch onCheckedChange={mockSetAutoScroll} />
|
||||||
|
</RecoilRoot>,
|
||||||
|
);
|
||||||
|
const switchElement = getByTestId('autoScroll');
|
||||||
|
fireEvent.click(switchElement);
|
||||||
|
|
||||||
|
expect(mockSetAutoScroll).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
33
client/src/components/Nav/SettingsTabs/AutoScrollSwitch.tsx
Normal file
33
client/src/components/Nav/SettingsTabs/AutoScrollSwitch.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { useRecoilState } from 'recoil';
|
||||||
|
import { Switch } from '~/components/ui';
|
||||||
|
import { useLocalize } from '~/hooks';
|
||||||
|
import store from '~/store';
|
||||||
|
|
||||||
|
export default function AutoScrollSwitch({
|
||||||
|
onCheckedChange,
|
||||||
|
}: {
|
||||||
|
onCheckedChange?: (value: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const [autoScroll, setAutoScroll] = useRecoilState<boolean>(store.autoScroll);
|
||||||
|
const localize = useLocalize();
|
||||||
|
|
||||||
|
const handleCheckedChange = (value: boolean) => {
|
||||||
|
setAutoScroll(value);
|
||||||
|
if (onCheckedChange) {
|
||||||
|
onCheckedChange(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>{localize('com_nav_auto_scroll')}</div>
|
||||||
|
<Switch
|
||||||
|
id="autoScroll"
|
||||||
|
checked={autoScroll}
|
||||||
|
onCheckedChange={handleCheckedChange}
|
||||||
|
className="ml-4 mt-2"
|
||||||
|
data-testid="autoScroll"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -8,11 +8,12 @@ import {
|
||||||
useOnClickOutside,
|
useOnClickOutside,
|
||||||
useConversation,
|
useConversation,
|
||||||
useConversations,
|
useConversations,
|
||||||
|
useLocalStorage,
|
||||||
} from '~/hooks';
|
} from '~/hooks';
|
||||||
import type { TDangerButtonProps } from '~/common';
|
import type { TDangerButtonProps } from '~/common';
|
||||||
|
import AutoScrollSwitch from './AutoScrollSwitch';
|
||||||
import DangerButton from './DangerButton';
|
import DangerButton from './DangerButton';
|
||||||
import store from '~/store';
|
import store from '~/store';
|
||||||
import useLocalStorage from '~/hooks/useLocalStorage';
|
|
||||||
|
|
||||||
export const ThemeSelector = ({
|
export const ThemeSelector = ({
|
||||||
theme,
|
theme,
|
||||||
|
|
@ -175,6 +176,9 @@ function General() {
|
||||||
mutation={clearConvosMutation}
|
mutation={clearConvosMutation}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
|
||||||
|
<AutoScrollSwitch />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ import useTimeout from './useTimeout';
|
||||||
const AuthContext = createContext<TAuthContext | undefined>(undefined);
|
const AuthContext = createContext<TAuthContext | undefined>(undefined);
|
||||||
|
|
||||||
const AuthContextProvider = ({
|
const AuthContextProvider = ({
|
||||||
// authConfig,
|
authConfig,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
authConfig?: TAuthConfig;
|
authConfig?: TAuthConfig;
|
||||||
|
|
@ -98,6 +98,10 @@ const AuthContextProvider = ({
|
||||||
}, [setUserContext, doSetError, logoutUser]);
|
}, [setUserContext, doSetError, logoutUser]);
|
||||||
|
|
||||||
const silentRefresh = useCallback(() => {
|
const silentRefresh = useCallback(() => {
|
||||||
|
if (authConfig?.test) {
|
||||||
|
console.log('Test mode. Skipping silent refresh.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
refreshToken.mutate(undefined, {
|
refreshToken.mutate(undefined, {
|
||||||
onSuccess: (data: TLoginResponse) => {
|
onSuccess: (data: TLoginResponse) => {
|
||||||
const { user, token } = data;
|
const { user, token } = data;
|
||||||
|
|
@ -105,11 +109,17 @@ const AuthContextProvider = ({
|
||||||
setUserContext({ token, isAuthenticated: true, user });
|
setUserContext({ token, isAuthenticated: true, user });
|
||||||
} else {
|
} else {
|
||||||
console.log('Token is not present. User is not authenticated.');
|
console.log('Token is not present. User is not authenticated.');
|
||||||
|
if (authConfig?.test) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.log('refreshToken mutation error:', error);
|
console.log('refreshToken mutation error:', error);
|
||||||
|
if (authConfig?.test) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -226,6 +226,7 @@ export default {
|
||||||
com_endpoint_config_key_google_service_account: 'Create a Service Account',
|
com_endpoint_config_key_google_service_account: 'Create a Service Account',
|
||||||
com_endpoint_config_key_google_vertex_api_role:
|
com_endpoint_config_key_google_vertex_api_role:
|
||||||
'Make sure to click \'Create and Continue\' to give at least the \'Vertex AI User\' role. Lastly, create a JSON key to import here.',
|
'Make sure to click \'Create and Continue\' to give at least the \'Vertex AI User\' role. Lastly, create a JSON key to import here.',
|
||||||
|
com_nav_auto_scroll: 'Auto-scroll to Newest on Open',
|
||||||
com_nav_plugin_store: 'Plugin store',
|
com_nav_plugin_store: 'Plugin store',
|
||||||
com_nav_plugin_search: 'Search plugins',
|
com_nav_plugin_search: 'Search plugins',
|
||||||
com_nav_plugin_auth_error:
|
com_nav_plugin_auth_error:
|
||||||
|
|
|
||||||
|
|
@ -226,6 +226,7 @@ export default {
|
||||||
com_endpoint_config_key_google_service_account: 'Crea un account di servizio',
|
com_endpoint_config_key_google_service_account: 'Crea un account di servizio',
|
||||||
com_endpoint_config_key_google_vertex_api_role:
|
com_endpoint_config_key_google_vertex_api_role:
|
||||||
'Assicurati di fare clic su \'Crea e continua\' per dare almeno il ruolo \'Utente Vertex AI\'. Infine, crea una chiave JSON da importare qui.',
|
'Assicurati di fare clic su \'Crea e continua\' per dare almeno il ruolo \'Utente Vertex AI\'. Infine, crea una chiave JSON da importare qui.',
|
||||||
|
com_nav_auto_scroll: 'Scorrimento automatico',
|
||||||
com_nav_plugin_store: 'Negozio dei plugin',
|
com_nav_plugin_store: 'Negozio dei plugin',
|
||||||
com_nav_plugin_search: 'Cerca plugin',
|
com_nav_plugin_search: 'Cerca plugin',
|
||||||
com_nav_plugin_auth_error:
|
com_nav_plugin_auth_error:
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,25 @@ const showPopover = atom<boolean>({
|
||||||
default: false,
|
default: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const autoScroll = atom<boolean>({
|
||||||
|
key: 'autoScroll',
|
||||||
|
default: localStorage.getItem('autoScroll') === 'true',
|
||||||
|
effects: [
|
||||||
|
({ setSelf, onSet }) => {
|
||||||
|
const savedValue = localStorage.getItem('autoScroll');
|
||||||
|
if (savedValue != null) {
|
||||||
|
setSelf(savedValue === 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
onSet((newValue: unknown) => {
|
||||||
|
if (typeof newValue === 'boolean') {
|
||||||
|
localStorage.setItem('autoScroll', newValue.toString());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
] as const,
|
||||||
|
});
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
abortScroll,
|
abortScroll,
|
||||||
optionSettings,
|
optionSettings,
|
||||||
|
|
@ -42,4 +61,5 @@ export default {
|
||||||
showAgentSettings,
|
showAgentSettings,
|
||||||
showBingToneSetting,
|
showBingToneSetting,
|
||||||
showPopover,
|
showPopover,
|
||||||
|
autoScroll,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ function renderWithProvidersWrapper(ui, { ...options } = {}) {
|
||||||
<AuthContextProvider
|
<AuthContextProvider
|
||||||
authConfig={{
|
authConfig={{
|
||||||
loginRedirect: '',
|
loginRedirect: '',
|
||||||
|
test: true,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue