diff --git a/README.md b/README.md index 7dd55b54..3a84bd47 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/rsconcept/frontend/package-lock.json b/rsconcept/frontend/package-lock.json index ac6b637c..0ae583a1 100644 --- a/rsconcept/frontend/package-lock.json +++ b/rsconcept/frontend/package-lock.json @@ -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", diff --git a/rsconcept/frontend/package.json b/rsconcept/frontend/package.json index 6e8d36a6..a3343052 100644 --- a/rsconcept/frontend/package.json +++ b/rsconcept/frontend/package.json @@ -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", diff --git a/rsconcept/frontend/src/app/ApplicationLayout.tsx b/rsconcept/frontend/src/app/ApplicationLayout.tsx index 29f67481..ea527ea1 100644 --- a/rsconcept/frontend/src/app/ApplicationLayout.tsx +++ b/rsconcept/frontend/src/app/ApplicationLayout.tsx @@ -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 (
diff --git a/rsconcept/frontend/src/app/GlobalProviders.tsx b/rsconcept/frontend/src/app/GlobalProviders.tsx index d3cf7043..e46b530b 100644 --- a/rsconcept/frontend/src/app/GlobalProviders.tsx +++ b/rsconcept/frontend/src/app/GlobalProviders.tsx @@ -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} > - + - + {children} - + ); } diff --git a/rsconcept/frontend/src/backend/queryClient.ts b/rsconcept/frontend/src/backend/queryClient.ts new file mode 100644 index 00000000..ca2b7909 --- /dev/null +++ b/rsconcept/frontend/src/backend/queryClient.ts @@ -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 + } + } +}); diff --git a/rsconcept/frontend/src/backend/users/api.ts b/rsconcept/frontend/src/backend/users/api.ts new file mode 100644 index 00000000..ffabbd30 --- /dev/null +++ b/rsconcept/frontend/src/backend/users/api.ts @@ -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(`/users/api/active-users`, { + signal: meta.signal + }) + }); + } +}; diff --git a/rsconcept/frontend/src/backend/users/useLabelUser.tsx b/rsconcept/frontend/src/backend/users/useLabelUser.tsx new file mode 100644 index 00000000..fc191bf0 --- /dev/null +++ b/rsconcept/frontend/src/backend/users/useLabelUser.tsx @@ -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; +} diff --git a/rsconcept/frontend/src/backend/users/useUsers.tsx b/rsconcept/frontend/src/backend/users/useUsers.tsx new file mode 100644 index 00000000..8312fe89 --- /dev/null +++ b/rsconcept/frontend/src/backend/users/useUsers.tsx @@ -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 }; +} diff --git a/rsconcept/frontend/src/components/info/InfoUsers.tsx b/rsconcept/frontend/src/components/info/InfoUsers.tsx index b7010fd3..2def1377 100644 --- a/rsconcept/frontend/src/components/info/InfoUsers.tsx +++ b/rsconcept/frontend/src/components/info/InfoUsers.tsx @@ -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 (
{header ?

{header}

: null} diff --git a/rsconcept/frontend/src/components/select/SelectUser.tsx b/rsconcept/frontend/src/components/select/SelectUser.tsx index 74b9d711..dfffb17b 100644 --- a/rsconcept/frontend/src/components/select/SelectUser.tsx +++ b/rsconcept/frontend/src/components/select/SelectUser.tsx @@ -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} /> diff --git a/rsconcept/frontend/src/context/AuthContext.tsx b/rsconcept/frontend/src/context/AuthContext.tsx index 6af73bcf..d8fad021 100644 --- a/rsconcept/frontend/src/context/AuthContext.tsx +++ b/rsconcept/frontend/src/context/AuthContext.tsx @@ -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(undefined); const [loading, setLoading] = useState(true); const [error, setError] = useState(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); }) }); diff --git a/rsconcept/frontend/src/context/UserProfileContext.tsx b/rsconcept/frontend/src/context/UserProfileContext.tsx index d9c3841a..392a9f2e 100644 --- a/rsconcept/frontend/src/context/UserProfileContext.tsx +++ b/rsconcept/frontend/src/context/UserProfileContext.tsx @@ -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(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(() => { diff --git a/rsconcept/frontend/src/context/UsersContext.tsx b/rsconcept/frontend/src/context/UsersContext.tsx deleted file mode 100644 index 41e1eee8..00000000 --- a/rsconcept/frontend/src/context/UsersContext.tsx +++ /dev/null @@ -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(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([]); - - 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 ( - - {children} - - ); -}; diff --git a/rsconcept/frontend/src/dialogs/DlgEditEditors/DlgEditEditors.tsx b/rsconcept/frontend/src/dialogs/DlgEditEditors/DlgEditEditors.tsx index 02c1fd47..613b9afb 100644 --- a/rsconcept/frontend/src/dialogs/DlgEditEditors/DlgEditEditors.tsx +++ b/rsconcept/frontend/src/dialogs/DlgEditEditors/DlgEditEditors.tsx @@ -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(editors); const { users } = useUsers(); - const filtered = users.filter(user => !selected.includes(user.id)); function handleSubmit() { setEditors(selected); @@ -61,7 +60,12 @@ function DlgEditEditors() {
); diff --git a/rsconcept/frontend/src/pages/LibraryPage/TableLibraryItems.tsx b/rsconcept/frontend/src/pages/LibraryPage/TableLibraryItems.tsx index 2dadb173..d11f0ea0 100644 --- a/rsconcept/frontend/src/pages/LibraryPage/TableLibraryItems.tsx +++ b/rsconcept/frontend/src/pages/LibraryPage/TableLibraryItems.tsx @@ -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(); 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); diff --git a/rsconcept/frontend/src/pages/LibraryPage/ToolbarSearch.tsx b/rsconcept/frontend/src/pages/LibraryPage/ToolbarSearch.tsx index a77ec4d5..13a632f5 100644 --- a/rsconcept/frontend/src/pages/LibraryPage/ToolbarSearch.tsx +++ b/rsconcept/frontend/src/pages/LibraryPage/ToolbarSearch.tsx @@ -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} /> diff --git a/rsconcept/frontend/src/pages/RSFormPage/EditorRSFormCard/EditorLibraryItem.tsx b/rsconcept/frontend/src/pages/RSFormPage/EditorRSFormCard/EditorLibraryItem.tsx index 98b38799..0fd38723 100644 --- a/rsconcept/frontend/src/pages/RSFormPage/EditorRSFormCard/EditorLibraryItem.tsx +++ b/rsconcept/frontend/src/pages/RSFormPage/EditorRSFormCard/EditorLibraryItem.tsx @@ -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 ? ( @@ -121,7 +121,9 @@ function EditorLibraryItem({ item, isModified, controller }: EditorLibraryItemPr disabled={isModified || controller.isProcessing || role < UserRole.OWNER} /> - + }> + +