F: Implement react-query pt1

This commit is contained in:
Ivan 2025-01-21 12:00:09 +03:00
parent bfae07c7b6
commit d899e17fcd
18 changed files with 184 additions and 135 deletions

View File

@ -46,6 +46,8 @@ This readme file is used mostly to document project dependencies and conventions
- html-to-image
- zustand
- @tanstack/react-table
- @tanstack/react-query
- @tanstack/react-query-devtools
- @uiw/react-codemirror
- @uiw/codemirror-themes
- @lezer/lr

View File

@ -10,6 +10,8 @@
"dependencies": {
"@dagrejs/dagre": "^1.1.4",
"@lezer/lr": "^1.4.2",
"@tanstack/react-query": "^5.64.1",
"@tanstack/react-query-devtools": "^5.64.1",
"@tanstack/react-table": "^8.20.6",
"@uiw/codemirror-themes": "^4.23.7",
"@uiw/react-codemirror": "^4.23.7",
@ -2994,6 +2996,59 @@
"@sinonjs/commons": "^3.0.0"
}
},
"node_modules/@tanstack/query-core": {
"version": "5.64.1",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.64.1.tgz",
"integrity": "sha512-978Wx4Wl4UJZbmvU/rkaM9cQtXXrbhK0lsz/UZhYIbyKYA8E4LdomTwyh2GHZ4oU0BKKoDH4YlKk2VscCUgNmg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/query-devtools": {
"version": "5.62.16",
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.62.16.tgz",
"integrity": "sha512-3ff6UBJr0H3nIhfLSl9911rvKqXf0u4B58jl0uYdDWLqPk9pCvYIbxC35cGxK2+8INl4IaFVUHb/IdgWrNkg3Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.64.1",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.64.1.tgz",
"integrity": "sha512-vW5ggHpIO2Yjj44b4sB+Fd3cdnlMJppXRBJkEHvld6FXh3j5dwWJoQo7mGtKI2RbSFyiyu/PhGAy0+Vv5ev9Eg==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.64.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-query-devtools": {
"version": "5.64.1",
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.64.1.tgz",
"integrity": "sha512-8ajcGE3wXYlb4KuJnkFYkJwJKc/qmPNTpQD7YTgLRMBPTGGp1xk7VMzxL87DoXuweO8luplUUblJJ3noVs/luQ==",
"license": "MIT",
"dependencies": {
"@tanstack/query-devtools": "5.62.16"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"@tanstack/react-query": "^5.64.1",
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-table": {
"version": "8.20.6",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.20.6.tgz",

View File

@ -14,6 +14,8 @@
"dependencies": {
"@dagrejs/dagre": "^1.1.4",
"@lezer/lr": "^1.4.2",
"@tanstack/react-query": "^5.64.1",
"@tanstack/react-query-devtools": "^5.64.1",
"@tanstack/react-table": "^8.20.6",
"@uiw/codemirror-themes": "^4.23.7",
"@uiw/react-codemirror": "^4.23.7",

View File

@ -19,6 +19,9 @@ function ApplicationLayout() {
const noNavigationAnimation = useAppLayoutStore(state => state.noNavigationAnimation);
const noNavigation = useAppLayoutStore(state => state.noNavigation);
const noFooter = useAppLayoutStore(state => state.noFooter);
// TODO: prefetch data
return (
<NavigationState>
<div className='min-w-[20rem] antialiased h-full max-w-[120rem] mx-auto'>

View File

@ -1,12 +1,14 @@
'use client';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { ErrorBoundary } from 'react-error-boundary';
import { IntlProvider } from 'react-intl';
import { queryClient } from '@/backend/queryClient';
import { AuthState } from '@/context/AuthContext';
import { GlobalOssState } from '@/context/GlobalOssContext';
import { LibraryState } from '@/context/LibraryContext';
import { UsersState } from '@/context/UsersContext';
import ErrorFallback from './ErrorFallback';
@ -30,18 +32,18 @@ function GlobalProviders({ children }: React.PropsWithChildren) {
onError={logError}
>
<IntlProvider locale='ru' defaultLocale='ru'>
<UsersState>
<QueryClientProvider client={queryClient}>
<AuthState>
<LibraryState>
<GlobalOssState>
<ReactQueryDevtools initialIsOpen={false} />
{children}
</GlobalOssState>
</LibraryState>
</AuthState>
</UsersState>
</QueryClientProvider>
</IntlProvider>
</ErrorBoundary>);
}

View File

@ -0,0 +1,21 @@
import { QueryClient } from '@tanstack/react-query';
import { AxiosError } from 'axios';
declare module '@tanstack/react-query' {
interface Register {
defaultError: AxiosError;
}
}
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
gcTime: 24 * 60 * 60 * 1000,
retry: 3,
refetchOnWindowFocus: true,
refetchOnMount: true,
refetchOnReconnect: true
}
}
});

View File

@ -0,0 +1,18 @@
import { queryOptions } from '@tanstack/react-query';
import { IUserInfo } from '@/models/user';
import { axiosInstance } from '../apiConfiguration';
export const usersApi = {
baseKey: 'users',
getUsersQueryOptions: () => {
return queryOptions({
queryKey: [usersApi.baseKey, 'list'],
queryFn: meta =>
axiosInstance.get<IUserInfo[]>(`/users/api/active-users`, {
signal: meta.signal
})
});
}
};

View File

@ -0,0 +1,25 @@
import { useUsers } from './useUsers';
export function useLabelUser() {
const { users } = useUsers();
function getUserLabel(userID: number | null): string {
const user = users.find(({ id }) => id === userID);
if (!user || userID === null) {
return userID ? `Аноним ${userID.toString()}` : 'Отсутствует';
}
const hasFirstName = user.first_name !== '';
const hasLastName = user.last_name !== '';
if (hasFirstName || hasLastName) {
if (!hasLastName) {
return user.first_name;
}
if (!hasFirstName) {
return user.last_name;
}
return user.last_name + ' ' + user.first_name;
}
return `Аноним ${userID.toString()}`;
}
return getUserLabel;
}

View File

@ -0,0 +1,19 @@
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
import { usersApi } from './api';
export function useUsersSuspense() {
const { data: users, refetch } = useSuspenseQuery({
...usersApi.getUsersQueryOptions()
});
return { users: users?.data ?? [], refetch };
}
export function useUsers() {
const { data: users, refetch } = useQuery({
...usersApi.getUsersQueryOptions()
});
return { users: users?.data ?? [], refetch };
}

View File

@ -1,6 +1,6 @@
import clsx from 'clsx';
import { useUsers } from '@/context/UsersContext';
import { useLabelUser } from '@/backend/users/useLabelUser';
import { UserID } from '@/models/user';
import { CProps } from '../props';
@ -12,8 +12,7 @@ interface InfoUsersProps extends CProps.Styling {
}
function InfoUsers({ items, className, prefix, header, ...restProps }: InfoUsersProps) {
const { getUserLabel } = useUsers();
const getUserLabel = useLabelUser();
return (
<div className={clsx('flex flex-col dense', className)} {...restProps}>
{header ? <h2>{header}</h2> : null}

View File

@ -2,17 +2,18 @@
import clsx from 'clsx';
import { useUsers } from '@/context/UsersContext';
import { IUserInfo, UserID } from '@/models/user';
import { useLabelUser } from '@/backend/users/useLabelUser';
import { useUsers } from '@/backend/users/useUsers';
import { UserID } from '@/models/user';
import { matchUser } from '@/models/userAPI';
import { CProps } from '../props';
import SelectSingle from '../ui/SelectSingle';
interface SelectUserProps extends CProps.Styling {
items?: IUserInfo[];
value?: UserID;
onSelectValue: (newValue: UserID) => void;
filter?: (userID: UserID) => boolean;
placeholder?: string;
noBorder?: boolean;
@ -20,20 +21,23 @@ interface SelectUserProps extends CProps.Styling {
function SelectUser({
className,
items,
filter,
value,
onSelectValue,
placeholder = 'Выберите пользователя',
...restProps
}: SelectUserProps) {
const { getUserLabel } = useUsers();
const { users } = useUsers();
const getUserLabel = useLabelUser();
const items = filter ? users.filter(user => filter(user.id)) : users;
const options =
items?.map(user => ({
value: user.id,
label: getUserLabel(user.id)
})) ?? [];
function filter(option: { value: UserID | undefined; label: string }, inputValue: string) {
function filterLabel(option: { value: UserID | undefined; label: string }, inputValue: string) {
const user = items?.find(item => item.id === option.value);
return !user ? false : matchUser(user, inputValue);
}
@ -47,7 +51,7 @@ function SelectUser({
if (data?.value !== undefined) onSelectValue(data.value);
}}
// @ts-expect-error: TODO: use type definitions from react-select in filter object
filterOption={filter}
filterOption={filterLabel}
placeholder={placeholder}
{...restProps}
/>

View File

@ -19,7 +19,6 @@ import {
IPasswordTokenData,
IRequestPasswordData,
IResetPasswordData,
IUserInfo,
IUserLoginData,
IUserProfile,
IUserSignupData,
@ -27,8 +26,6 @@ import {
} from '@/models/user';
import { contextOutsideScope } from '@/utils/labels';
import { useUsers } from './UsersContext';
interface IAuthContext {
user: ICurrentUser | undefined;
login: (data: IUserLoginData, callback?: DataCallback) => void;
@ -53,7 +50,6 @@ export const useAuth = () => {
};
export const AuthState = ({ children }: React.PropsWithChildren) => {
const { users } = useUsers();
const [user, setUser] = useState<ICurrentUser | undefined>(undefined);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<ErrorData>(undefined);
@ -110,7 +106,8 @@ export const AuthState = ({ children }: React.PropsWithChildren) => {
onError: setError,
onSuccess: newData =>
reload(() => {
users.push(newData as IUserInfo);
// TODO: reload users / optimistic update
// users.push(newData as IUserInfo);
callback?.(newData);
})
});

View File

@ -8,8 +8,6 @@ import { ErrorData } from '@/components/info/InfoError';
import { IUserProfile, IUserUpdateData } from '@/models/user';
import { contextOutsideScope } from '@/utils/labels';
import { useUsers } from './UsersContext';
interface IUserProfileContext {
user: IUserProfile | undefined;
loading: boolean;
@ -31,7 +29,6 @@ export const useUserProfile = () => {
};
export const UserProfileState = ({ children }: React.PropsWithChildren) => {
const { users } = useUsers();
const [user, setUser] = useState<IUserProfile | undefined>(undefined);
const [loading, setLoading] = useState(true);
const [processing, setProcessing] = useState(false);
@ -59,16 +56,12 @@ export const UserProfileState = ({ children }: React.PropsWithChildren) => {
onError: setErrorProcessing,
onSuccess: newData => {
setUser(newData);
const libraryUser = users.find(item => item.id === user?.id);
if (libraryUser) {
libraryUser.first_name = newData.first_name;
libraryUser.last_name = newData.last_name;
}
// TODO: reload users / optimistic update
callback?.(newData);
}
});
},
[setUser, users, user?.id]
[setUser]
);
useEffect(() => {

View File

@ -1,94 +0,0 @@
'use client';
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { getActiveUsers } from '@/backend/users';
import { IUserInfo } from '@/models/user';
import { contextOutsideScope } from '@/utils/labels';
interface IUsersContext {
users: IUserInfo[];
reload: (callback?: () => void) => void;
getUserLabel: (userID: number | null) => string;
}
const UsersContext = createContext<IUsersContext | null>(null);
export const useUsers = (): IUsersContext => {
const context = useContext(UsersContext);
if (context === null) {
throw new Error(contextOutsideScope('useUsers', 'UsersState'));
}
return context;
};
export const UsersState = ({ children }: React.PropsWithChildren) => {
const [users, setUsers] = useState<IUserInfo[]>([]);
function getUserLabel(userID: number | null) {
const user = users.find(({ id }) => id === userID);
if (!user) {
return userID ? userID.toString() : 'Отсутствует';
}
const hasFirstName = user.first_name !== '';
const hasLastName = user.last_name !== '';
if (hasFirstName || hasLastName) {
if (!hasLastName) {
return user.first_name;
}
if (!hasFirstName) {
return user.last_name;
}
return user.last_name + ' ' + user.first_name;
}
return `Аноним ${userID}`;
}
const reload = useCallback(
(callback?: () => void) => {
getActiveUsers({
showError: true,
onError: () => setUsers([]),
onSuccess: newData => {
newData.sort((a, b) => {
if (a.last_name === '') {
if (b.last_name === '') {
return a.id - b.id;
} else {
return 1;
}
} else if (b.last_name === '') {
if (a.last_name === '') {
return a.id - b.id;
} else {
return -1;
}
} else if (a.last_name !== b.last_name) {
return a.last_name.localeCompare(b.last_name);
} else {
return a.first_name.localeCompare(b.first_name);
}
});
setUsers(newData);
callback?.();
}
});
},
[setUsers]
);
useEffect(() => {
reload();
}, [reload]);
return (
<UsersContext
value={{
users,
reload,
getUserLabel
}}
>
{children}
</UsersContext>
);
};

View File

@ -3,12 +3,12 @@
import clsx from 'clsx';
import { useState } from 'react';
import { useUsers } from '@/backend/users/useUsers';
import { IconRemove } from '@/components/Icons';
import SelectUser from '@/components/select/SelectUser';
import Label from '@/components/ui/Label';
import MiniButton from '@/components/ui/MiniButton';
import Modal from '@/components/ui/Modal';
import { useUsers } from '@/context/UsersContext';
import { UserID } from '@/models/user';
import { useDialogsStore } from '@/stores/dialogs';
@ -23,7 +23,6 @@ function DlgEditEditors() {
const { editors, setEditors } = useDialogsStore(state => state.props as DlgEditEditorsProps);
const [selected, setSelected] = useState<UserID[]>(editors);
const { users } = useUsers();
const filtered = users.filter(user => !selected.includes(user.id));
function handleSubmit() {
setEditors(selected);
@ -61,7 +60,12 @@ function DlgEditEditors() {
<div className='flex items-center gap-3'>
<Label text='Добавить' />
<SelectUser items={filtered} value={undefined} onSelectValue={onAddEditor} className='w-[25rem]' />
<SelectUser
filter={id => !selected.includes(id)}
value={undefined}
onSelectValue={onAddEditor}
className='w-[25rem]'
/>
</div>
</Modal>
);

View File

@ -5,6 +5,7 @@ import { useLayoutEffect, useState } from 'react';
import { useIntl } from 'react-intl';
import { urls } from '@/app/urls';
import { useLabelUser } from '@/backend/users/useLabelUser';
import { IconFolderTree } from '@/components/Icons';
import BadgeLocation from '@/components/info/BadgeLocation';
import { CProps } from '@/components/props';
@ -13,7 +14,6 @@ import FlexColumn from '@/components/ui/FlexColumn';
import MiniButton from '@/components/ui/MiniButton';
import TextURL from '@/components/ui/TextURL';
import { useConceptNavigation } from '@/context/NavigationContext';
import { useUsers } from '@/context/UsersContext';
import useWindowSize from '@/hooks/useWindowSize';
import { ILibraryItem, LibraryItemType } from '@/models/library';
import { useFitHeight } from '@/stores/appLayout';
@ -30,7 +30,7 @@ const columnHelper = createColumnHelper<ILibraryItem>();
function TableLibraryItems({ items }: TableLibraryItemsProps) {
const router = useConceptNavigation();
const intl = useIntl();
const { getUserLabel } = useUsers();
const getUserLabel = useLabelUser();
const folderMode = useLibrarySearchStore(state => state.folderMode);
const toggleFolderMode = useLibrarySearchStore(state => state.toggleFolderMode);

View File

@ -19,7 +19,6 @@ import DropdownButton from '@/components/ui/DropdownButton';
import MiniButton from '@/components/ui/MiniButton';
import SearchBar from '@/components/ui/SearchBar';
import SelectorButton from '@/components/ui/SelectorButton';
import { useUsers } from '@/context/UsersContext';
import useDropdown from '@/hooks/useDropdown';
import { LocationHead } from '@/models/library';
import { useHasCustomFilter, useLibrarySearchStore } from '@/stores/librarySearch';
@ -35,7 +34,6 @@ interface ToolbarSearchProps {
function ToolbarSearch({ total, filtered }: ToolbarSearchProps) {
const headMenu = useDropdown();
const userMenu = useDropdown();
const { users } = useUsers();
const query = useLibrarySearchStore(state => state.query);
const setQuery = useLibrarySearchStore(state => state.setQuery);
@ -128,7 +126,6 @@ function ToolbarSearch({ total, filtered }: ToolbarSearchProps) {
noBorder
placeholder='Выберите владельца'
className='min-w-[15rem] text-sm mx-1 mb-1'
items={users}
value={filterUser}
onSelectValue={setFilterUser}
/>

View File

@ -1,7 +1,8 @@
import { useCallback } from 'react';
import { Suspense, useCallback } from 'react';
import { useIntl } from 'react-intl';
import { urls } from '@/app/urls';
import { useLabelUser } from '@/backend/users/useLabelUser';
import {
IconDateCreate,
IconDateUpdate,
@ -13,12 +14,12 @@ import {
import InfoUsers from '@/components/info/InfoUsers';
import { CProps } from '@/components/props';
import SelectUser from '@/components/select/SelectUser';
import Loader from '@/components/ui/Loader';
import MiniButton from '@/components/ui/MiniButton';
import Overlay from '@/components/ui/Overlay';
import Tooltip from '@/components/ui/Tooltip';
import ValueIcon from '@/components/ui/ValueIcon';
import { useConceptNavigation } from '@/context/NavigationContext';
import { useUsers } from '@/context/UsersContext';
import useDropdown from '@/hooks/useDropdown';
import { ILibraryItemData, ILibraryItemEditor } from '@/models/library';
import { UserID, UserRole } from '@/models/user';
@ -34,7 +35,7 @@ interface EditorLibraryItemProps {
}
function EditorLibraryItem({ item, isModified, controller }: EditorLibraryItemProps) {
const { getUserLabel, users } = useUsers();
const getUserLabel = useLabelUser();
const role = useRoleStore(state => state.role);
const intl = useIntl();
const router = useConceptNavigation();
@ -95,7 +96,6 @@ function EditorLibraryItem({ item, isModified, controller }: EditorLibraryItemPr
{ownerSelector.isOpen ? (
<SelectUser
className='w-[25rem] sm:w-[26rem] text-sm'
items={users}
value={item.owner ?? undefined}
onSelectValue={onSelectUser}
/>
@ -121,7 +121,9 @@ function EditorLibraryItem({ item, isModified, controller }: EditorLibraryItemPr
disabled={isModified || controller.isProcessing || role < UserRole.OWNER}
/>
<Tooltip anchorSelect='#editor_stats' layer='z-modalTooltip'>
<Suspense fallback={<Loader scale={2} />}>
<InfoUsers items={item?.editors ?? []} prefix={prefixes.user_editors} header='Редакторы' />
</Suspense>
</Tooltip>
<ValueIcon