From cd30ff551dd67bc8007f722d11f86a88eaa264e3 Mon Sep 17 00:00:00 2001 From: Julia Gogonova Date: Sun, 21 Dec 2025 14:27:24 +0300 Subject: [PATCH 01/10] YH-1564: Add AnalyticsPageSkeleton component - Rebase branch onto latest develop - Fix merge conflict in DifficultQuestionsPage - Add skeleton loading to all analytics pages --- .../DifficultQuestionsPage.tsx | 25 +++- .../PopularQuestionsPage.tsx | 24 +++- .../PopularSkillsPage/PopularSkillsPage.tsx | 27 +++- .../ProgressSpecializationsPage.tsx | 24 +++- .../SkillsProficiencyPage.tsx | 24 +++- .../AnalyticsMobileListSkeleton.tsx | 34 +++++ .../AnalyticsPageSkeleton.module.css | 46 +++++++ .../AnalyticsPageSkeleton.tsx | 122 ++++++++++++++++++ .../AnalyticsTableSkeleton.tsx | 53 ++++++++ src/shared/ui/AnalyticsPageSkeleton/index.ts | 2 + 10 files changed, 372 insertions(+), 9 deletions(-) create mode 100644 src/shared/ui/AnalyticsPageSkeleton/AnalyticsMobileListSkeleton.tsx create mode 100644 src/shared/ui/AnalyticsPageSkeleton/AnalyticsPageSkeleton.module.css create mode 100644 src/shared/ui/AnalyticsPageSkeleton/AnalyticsPageSkeleton.tsx create mode 100644 src/shared/ui/AnalyticsPageSkeleton/AnalyticsTableSkeleton.tsx create mode 100644 src/shared/ui/AnalyticsPageSkeleton/index.ts diff --git a/src/pages/analytics/difficultQuestions/ui/DifficultQuestionsPage/DifficultQuestionsPage.tsx b/src/pages/analytics/difficultQuestions/ui/DifficultQuestionsPage/DifficultQuestionsPage.tsx index 7941fa3cf..aa0f36a27 100644 --- a/src/pages/analytics/difficultQuestions/ui/DifficultQuestionsPage/DifficultQuestionsPage.tsx +++ b/src/pages/analytics/difficultQuestions/ui/DifficultQuestionsPage/DifficultQuestionsPage.tsx @@ -1,7 +1,8 @@ import { useTranslation } from 'react-i18next'; import { i18Namespace, Analytics } from '@/shared/config'; -import { useAppSelector } from '@/shared/libs'; +import { useAppSelector, useScreenSize } from '@/shared/libs'; +import { AnalyticsPageSkeleton } from '@/shared/ui/AnalyticsPageSkeleton'; import { getSpecializationId } from '@/entities/profile'; import { useGetMostDifficultQuestionsBySpecializationIdQuery } from '@/entities/question'; @@ -13,6 +14,7 @@ import { DifficultQuestionsTable } from '../DifficultQuestionsTable/DifficultQue export const DifficultQuestionsPage = () => { const { t } = useTranslation(i18Namespace.analytics); + const { isMobile } = useScreenSize(); const specializationId = useAppSelector(getSpecializationId); const { filters, onChangeSpecialization, onChangePage } = useAnalyticFilters({ @@ -20,11 +22,30 @@ export const DifficultQuestionsPage = () => { page: 1, }); - const { data: response } = useGetMostDifficultQuestionsBySpecializationIdQuery({ + const { + data: response, + isLoading, + isFetching, + } = useGetMostDifficultQuestionsBySpecializationIdQuery({ specId: filters.specialization || specializationId, page: filters.page || 1, }); + if (isLoading || isFetching) { + return ( + + ); + } + const difficultQuestions = response?.data.topStat ?? []; return ( diff --git a/src/pages/analytics/popularQuestions/ui/PopularQuestionsPage/PopularQuestionsPage.tsx b/src/pages/analytics/popularQuestions/ui/PopularQuestionsPage/PopularQuestionsPage.tsx index 3282c8dda..e606f972c 100644 --- a/src/pages/analytics/popularQuestions/ui/PopularQuestionsPage/PopularQuestionsPage.tsx +++ b/src/pages/analytics/popularQuestions/ui/PopularQuestionsPage/PopularQuestionsPage.tsx @@ -1,6 +1,8 @@ import { useTranslation } from 'react-i18next'; import { i18Namespace, Analytics } from '@/shared/config'; +import { useScreenSize } from '@/shared/libs'; +import { AnalyticsPageSkeleton } from '@/shared/ui/AnalyticsPageSkeleton'; import { PopularQuestionStat, useGetPopularQuestionsQuery } from '@/entities/question'; @@ -10,6 +12,9 @@ import { PopularQuestionsList } from '../PopularQuestionsList/PopularQuestionsLi import { PopularQuestionsPageTable } from '../PopularQuestionsPageTable/PopularQuestionsPageTable'; export const PopularQuestionsPage = () => { + const { t } = useTranslation(i18Namespace.analytics); + const { isMobile } = useScreenSize(); + const { filters, hasFilters, onChangePage, onResetFilters, onChangeSpecialization } = useAnalyticFilters({ page: 1, @@ -17,8 +22,23 @@ export const PopularQuestionsPage = () => { const DATA_LIMIT_IN_PAGE = 10; const page = filters?.page || 1; - const { t } = useTranslation(i18Namespace.analytics); - const { data } = useGetPopularQuestionsQuery(); + + const { data, isLoading, isFetching } = useGetPopularQuestionsQuery(); + + if (isLoading || isFetching) { + return ( + + ); + } const popularQuestionsByAllSpecializations = data?.reduce( (accum, item) => [...accum, ...item.topStat], diff --git a/src/pages/analytics/popularSkills/ui/PopularSkillsPage/PopularSkillsPage.tsx b/src/pages/analytics/popularSkills/ui/PopularSkillsPage/PopularSkillsPage.tsx index 7cc7d0357..eb0af49cc 100644 --- a/src/pages/analytics/popularSkills/ui/PopularSkillsPage/PopularSkillsPage.tsx +++ b/src/pages/analytics/popularSkills/ui/PopularSkillsPage/PopularSkillsPage.tsx @@ -1,6 +1,8 @@ import { useTranslation } from 'react-i18next'; import { i18Namespace, Analytics } from '@/shared/config'; +import { useScreenSize } from '@/shared/libs'; +import { AnalyticsPageSkeleton } from '@/shared/ui/AnalyticsPageSkeleton'; import { useGetPopularSkillsQuery } from '@/entities/skill'; @@ -10,18 +12,39 @@ import { PopularSkillsList } from '../PopularSkillsList/PopularSkillsList'; import { PopularSkillsPageTable } from '../PopularSkillsPageTable/PopularSkillsPageTable'; export const PopularSkillsPage = () => { + const { t } = useTranslation(i18Namespace.analytics); + const { isMobile } = useScreenSize(); + const { filters, hasFilters, onChangePage, onResetFilters, onChangeSpecialization } = useAnalyticFilters({ page: 1, }); - const { t } = useTranslation(i18Namespace.analytics); - const { data: popularSkills } = useGetPopularSkillsQuery({ + const { + data: popularSkills, + isLoading, + isFetching, + } = useGetPopularSkillsQuery({ limit: 10, page: filters.page, specializationId: filters.specialization, }); + if (isLoading || isFetching) { + return ( + + ); + } + const specializationTitle = filters.specialization ? popularSkills?.data[0].specialization.title : ''; diff --git a/src/pages/analytics/progressSpecializations/ui/ProgressSpecializationsPage/ProgressSpecializationsPage.tsx b/src/pages/analytics/progressSpecializations/ui/ProgressSpecializationsPage/ProgressSpecializationsPage.tsx index 91fd0eb06..95a5102c6 100644 --- a/src/pages/analytics/progressSpecializations/ui/ProgressSpecializationsPage/ProgressSpecializationsPage.tsx +++ b/src/pages/analytics/progressSpecializations/ui/ProgressSpecializationsPage/ProgressSpecializationsPage.tsx @@ -1,6 +1,8 @@ import { useTranslation } from 'react-i18next'; import { i18Namespace, Analytics } from '@/shared/config'; +import { useScreenSize } from '@/shared/libs'; +import { AnalyticsPageSkeleton } from '@/shared/ui/AnalyticsPageSkeleton'; import { SpecializationProgressTable, @@ -12,17 +14,35 @@ import { AnalyticPageTemplate, useAnalyticFilters } from '@/widgets/analytics/An import { ProgressSpecializationsList } from '../ProgressSpecializationsList/ProgressSpecializationsList'; export const ProgressSpecializationsPage = () => { + const { t } = useTranslation(i18Namespace.analytics); + const { isMobile } = useScreenSize(); + const { filters, hasFilters, onChangePage, onResetFilters, onChangeSpecialization } = useAnalyticFilters({ page: 1, }); - const { data: response } = useGetSpecializationsGeneralProgressQuery({ + const { + data: response, + isLoading, + isFetching, + } = useGetSpecializationsGeneralProgressQuery({ page: filters.page, specializationId: filters.specialization, }); - const { t } = useTranslation(i18Namespace.analytics); + if (isLoading || isFetching) { + return ( + + ); + } const specializationsProgress = response?.data ?? []; diff --git a/src/pages/analytics/skillsProficiency/ui/SkillsProficiencyPage/SkillsProficiencyPage.tsx b/src/pages/analytics/skillsProficiency/ui/SkillsProficiencyPage/SkillsProficiencyPage.tsx index 45a2cc783..343ea2dac 100644 --- a/src/pages/analytics/skillsProficiency/ui/SkillsProficiencyPage/SkillsProficiencyPage.tsx +++ b/src/pages/analytics/skillsProficiency/ui/SkillsProficiencyPage/SkillsProficiencyPage.tsx @@ -1,6 +1,8 @@ import { useTranslation } from 'react-i18next'; import { i18Namespace, Analytics } from '@/shared/config'; +import { useScreenSize } from '@/shared/libs'; +import { AnalyticsPageSkeleton } from '@/shared/ui/AnalyticsPageSkeleton'; import { useGetLearnedQuestionsQuery } from '@/entities/question'; @@ -11,17 +13,37 @@ import { SkillsProficiencyPageTable } from '../SkillsProficiencyPageTable/Skills export const SkillsProficiencyPage = () => { const { t } = useTranslation(i18Namespace.analytics); + const { isMobile } = useScreenSize(); const filters = useAnalyticFilters({ page: 1, }); - const { data: response } = useGetLearnedQuestionsQuery({ + const { + data: response, + isLoading, + isFetching, + } = useGetLearnedQuestionsQuery({ page: filters.filters.page, specializationId: filters.filters.specialization, skillId: filters.filters.skill, }); + if (isLoading || isFetching) { + return ( + + ); + } + const learnedQuestions = response?.data ?? []; return ( diff --git a/src/shared/ui/AnalyticsPageSkeleton/AnalyticsMobileListSkeleton.tsx b/src/shared/ui/AnalyticsPageSkeleton/AnalyticsMobileListSkeleton.tsx new file mode 100644 index 000000000..179d62f22 --- /dev/null +++ b/src/shared/ui/AnalyticsPageSkeleton/AnalyticsMobileListSkeleton.tsx @@ -0,0 +1,34 @@ +import { Flex } from '@/shared/ui/Flex'; +import { Skeleton } from '@/shared/ui/Skeleton'; + +import styles from './AnalyticsPageSkeleton.module.css'; + +interface AnalyticsMobileListSkeletonProps { + itemsCount?: number; +} + +export const AnalyticsMobileListSkeleton = ({ + itemsCount = 3, +}: AnalyticsMobileListSkeletonProps) => { + return ( +
+ {Array.from({ length: itemsCount }).map((_, index) => ( +
+ + + + + + + + {index < itemsCount - 1 &&
} +
+ ))} +
+ ); +}; diff --git a/src/shared/ui/AnalyticsPageSkeleton/AnalyticsPageSkeleton.module.css b/src/shared/ui/AnalyticsPageSkeleton/AnalyticsPageSkeleton.module.css new file mode 100644 index 000000000..fd12c4857 --- /dev/null +++ b/src/shared/ui/AnalyticsPageSkeleton/AnalyticsPageSkeleton.module.css @@ -0,0 +1,46 @@ +.header { + margin-bottom: 24px; +} + +.filtersContainer { + margin-bottom: 24px; +} + +.content { + min-height: 300px; +} + +.tableBody { + border-radius: 8px; + overflow: hidden; +} + +.tableRow { + display: flex; + align-items: center; + padding: 16px 12px; +} + +.tableRow:last-child { + border-bottom: none; +} + +.tableCell { + padding: 0 4px; +} + +.mobileList { + border: 1px solid var(--border-color); + border-radius: 8px; + overflow: hidden; +} + +.mobileListItem { + padding: 16px; +} + +.divider { + height: 1px; + background-color: var(--border-color); + margin: 0 16px; +} diff --git a/src/shared/ui/AnalyticsPageSkeleton/AnalyticsPageSkeleton.tsx b/src/shared/ui/AnalyticsPageSkeleton/AnalyticsPageSkeleton.tsx new file mode 100644 index 000000000..9edf4de09 --- /dev/null +++ b/src/shared/ui/AnalyticsPageSkeleton/AnalyticsPageSkeleton.tsx @@ -0,0 +1,122 @@ +import { Card } from '@/shared/ui/Card'; +import { Flex } from '@/shared/ui/Flex'; +import { Skeleton } from '@/shared/ui/Skeleton'; +import { TablePaginationSkeleton } from '@/shared/ui/TablePagination'; + +import { AnalyticsMobileListSkeleton } from './AnalyticsMobileListSkeleton'; +import styles from './AnalyticsPageSkeleton.module.css'; +import { AnalyticsTableSkeleton } from './AnalyticsTableSkeleton'; + +export interface AnalyticsPageSkeletonProps { + showTitle?: boolean; + showTooltip?: boolean; + showFilters?: boolean; + showSkillFilter?: boolean; + showPagination?: boolean; + displayMode?: 'table' | 'mobile'; + tableRowsCount?: number; + mobileItemsCount?: number; +} + +export const AnalyticsPageSkeleton = ({ + showTitle = true, + showTooltip = true, + showFilters = true, + showSkillFilter = true, + showPagination = true, + displayMode = 'table', + tableRowsCount = 10, + mobileItemsCount = 3, +}: AnalyticsPageSkeletonProps) => { + const shouldRenderHeader = showTitle || showTooltip; + + return ( + + {shouldRenderHeader && ( + + )} + + {showFilters && ( + + )} + + + + {showPagination && } + + ); +}; + +interface HeaderSectionProps { + showTitle: boolean; + showTooltip: boolean; + displayMode: 'table' | 'mobile'; +} + +const HeaderSection = ({ showTitle, showTooltip, displayMode }: HeaderSectionProps) => { + const isMobile = displayMode === 'mobile'; + + return ( + + {showTitle && ( + + )} + {showTooltip && ( + + )} + + ); +}; + +interface FiltersSectionProps { + displayMode: 'table' | 'mobile'; + showSkillFilter: boolean; +} + +const FiltersSection = ({ displayMode, showSkillFilter }: FiltersSectionProps) => { + const isMobile = displayMode === 'mobile'; + + return ( + + + {showSkillFilter && ( + + )} + + ); +}; + +interface ContentSectionProps { + displayMode: 'table' | 'mobile'; + tableRowsCount: number; + mobileItemsCount: number; +} + +const ContentSection = ({ displayMode, tableRowsCount, mobileItemsCount }: ContentSectionProps) => { + return ( +
+ {displayMode === 'table' ? ( + + ) : ( + + )} +
+ ); +}; diff --git a/src/shared/ui/AnalyticsPageSkeleton/AnalyticsTableSkeleton.tsx b/src/shared/ui/AnalyticsPageSkeleton/AnalyticsTableSkeleton.tsx new file mode 100644 index 000000000..f2afde086 --- /dev/null +++ b/src/shared/ui/AnalyticsPageSkeleton/AnalyticsTableSkeleton.tsx @@ -0,0 +1,53 @@ +import { Flex } from '@/shared/ui/Flex'; +import { Skeleton } from '@/shared/ui/Skeleton'; + +import styles from './AnalyticsPageSkeleton.module.css'; + +interface AnalyticsTableSkeletonProps { + rowsCount?: number; +} + +export const AnalyticsTableSkeleton = ({ rowsCount = 10 }: AnalyticsTableSkeletonProps) => { + return ( +
+ + + + + + + + +
+ {Array.from({ length: rowsCount }).map((_, rowIndex) => ( +
+
+ +
+ +
+ + + + +
+ +
+ +
+
+ ))} +
+
+ ); +}; diff --git a/src/shared/ui/AnalyticsPageSkeleton/index.ts b/src/shared/ui/AnalyticsPageSkeleton/index.ts new file mode 100644 index 000000000..d57cc83ca --- /dev/null +++ b/src/shared/ui/AnalyticsPageSkeleton/index.ts @@ -0,0 +1,2 @@ +export { AnalyticsPageSkeleton } from './AnalyticsPageSkeleton'; +export type { AnalyticsPageSkeletonProps } from './AnalyticsPageSkeleton'; From 66ab94fcbc5494cd293a6187fbf6fad9f859713e Mon Sep 17 00:00:00 2001 From: Julia Gogonova Date: Sun, 21 Dec 2025 14:33:30 +0300 Subject: [PATCH 02/10] YH-1564: Sync types with develop - Update question and quiz types from latest develop branch --- src/entities/question/model/types/question.ts | 6 ++---- src/entities/quiz/model/types/quiz.ts | 6 ++---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/entities/question/model/types/question.ts b/src/entities/question/model/types/question.ts index 30a8d0bbd..cc9e6ba7f 100644 --- a/src/entities/question/model/types/question.ts +++ b/src/entities/question/model/types/question.ts @@ -84,10 +84,8 @@ export type GetQuestionByIdParamsRequest = { }; export type GetQuestionByIdResponse = Question; -export interface GetQuestionsForLearnParamsRequest extends Omit< - GetQuestionsListParamsRequest, - 'order' | 'orderBy' | 'random' -> { +export interface GetQuestionsForLearnParamsRequest + extends Omit { profileId: string; isLearned?: boolean; areFavorites?: boolean; diff --git a/src/entities/quiz/model/types/quiz.ts b/src/entities/quiz/model/types/quiz.ts index ea49ebf8f..ff4c5846e 100644 --- a/src/entities/quiz/model/types/quiz.ts +++ b/src/entities/quiz/model/types/quiz.ts @@ -87,10 +87,8 @@ export type ActiveQuiz = Omit; export type CreateNewQuizResponse = ActiveQuiz; -export interface CreateNewMockQuizParamsRequest extends Omit< - CreateNewQuizParamsRequest, - 'profileId' -> { +export interface CreateNewMockQuizParamsRequest + extends Omit { specialization?: number[] | number; } From a9afb9194487c56b6faab0ed13d607e6911c152c Mon Sep 17 00:00:00 2001 From: Anushavan <53114844+AnushavanHarutyunyan@users.noreply.github.com> Date: Mon, 22 Dec 2025 16:55:35 +0400 Subject: [PATCH 03/10] Feature/YH-1538 (#1054) * YH-1594: Redesign tabs * YH-1594: Deleted Tabs_Line all files * YH-1538: Add deleteButtons in Topics page and functional * YH-1538: Fix delete funcional and conflicts --- public/locales/en/translation.json | 20 +++-- public/locales/ru/translation.json | 20 +++-- src/entities/question/model/types/question.ts | 6 +- src/entities/quiz/model/types/quiz.ts | 6 +- src/entities/topic/model/types/topic.ts | 1 + .../api/deleteCollectionApi.ts | 2 +- .../topics/deleteTopic/api/deleteTopicApi.ts | 20 +++++ src/features/topics/deleteTopic/index.ts | 1 + .../deleteTopic/model/deleteTopicConstants.ts | 3 + .../DeleteTopicButton/DeleteTopicButton.tsx | 90 +++++++++++++++++++ .../deleteTopics/api/deleteTopicsApi.ts | 15 ++++ src/features/topics/deleteTopics/index.ts | 2 + .../model/constants/deleteTopicsConstants.ts | 3 + .../model/thunks/deleteMultipleTopicsThunk.ts | 55 ++++++++++++ .../DeleteTopicsButton/DeleteTopicsButton.tsx | 29 ++++++ .../ui/ResourcesPage/ResourcesPage.tsx | 2 +- .../topics/model/slices/topicsPageSlice.ts | 3 + .../topic/topics/ui/TopicsPage/TopicsPage.tsx | 18 +++- src/shared/config/i18n/i18nTranslations.ts | 6 ++ src/shared/config/query/apiTags.ts | 3 +- src/shared/ui/Dropdown/Dropdown/Dropdown.tsx | 6 +- src/shared/ui/Table/Table.tsx | 2 +- src/shared/ui/Tabs/Tabs.module.css | 25 +++--- src/shared/ui/Tabs/Tabs.skeleton.tsx | 1 - src/shared/ui/Tabs/Tabs.test.tsx | 18 +--- src/shared/ui/Tabs/Tabs.tsx | 29 +----- .../ui/AdditionalInfo/AdditionalInfo.tsx | 9 +- .../ui/CollectionHeader/CollectionHeader.tsx | 6 +- .../PopularSkillsWidget.tsx | 18 ++-- .../topic/TopicsTable/ui/TopicsTable.tsx | 41 +++++++-- 30 files changed, 356 insertions(+), 104 deletions(-) create mode 100644 src/features/topics/deleteTopic/api/deleteTopicApi.ts create mode 100644 src/features/topics/deleteTopic/index.ts create mode 100644 src/features/topics/deleteTopic/model/deleteTopicConstants.ts create mode 100644 src/features/topics/deleteTopic/ui/DeleteTopicButton/DeleteTopicButton.tsx create mode 100644 src/features/topics/deleteTopics/api/deleteTopicsApi.ts create mode 100644 src/features/topics/deleteTopics/index.ts create mode 100644 src/features/topics/deleteTopics/model/constants/deleteTopicsConstants.ts create mode 100644 src/features/topics/deleteTopics/model/thunks/deleteMultipleTopicsThunk.ts create mode 100644 src/features/topics/deleteTopics/ui/DeleteTopicsButton/DeleteTopicsButton.tsx diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 2f719b039..88b5e92f3 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -305,12 +305,20 @@ "create": { "success": "Topic successfully created", "failed": "Topic could not be created" - } - }, - "topics": { - "create": { - "success": "Topic successfully created", - "failed": "Topic could not be created" + }, + "edit": { + "success": "Topic successfully changed", + "failed": "Topic could not be changed" + }, + "delete": { + "single": { + "success": "Topic successfully deleted", + "failed": "Topic could not be deleted" + }, + "multiple": { + "success": "Successfully deleted topics:", + "failed": "Topic could not be deleted:" + } } }, "collections": { diff --git a/public/locales/ru/translation.json b/public/locales/ru/translation.json index e0454da2f..2fb2adf65 100644 --- a/public/locales/ru/translation.json +++ b/public/locales/ru/translation.json @@ -309,12 +309,20 @@ "create": { "success": "Тема успешно создана", "failed": "Не удалось создать тему" - } - }, - "topics": { - "create": { - "success": "Тема успешно создана", - "failed": "Не удалось создать тему" + }, + "edit": { + "success": "Тема успешно изменена", + "failed": "Не удалось изменить тему" + }, + "delete": { + "single": { + "success": "Тема успешно удалена", + "failed": "Тему не удалось удалить" + }, + "multiple": { + "success": "Успешно удалено тем:", + "failed": "Не удалось удалить тему:" + } } }, "collections": { diff --git a/src/entities/question/model/types/question.ts b/src/entities/question/model/types/question.ts index 30a8d0bbd..cc9e6ba7f 100644 --- a/src/entities/question/model/types/question.ts +++ b/src/entities/question/model/types/question.ts @@ -84,10 +84,8 @@ export type GetQuestionByIdParamsRequest = { }; export type GetQuestionByIdResponse = Question; -export interface GetQuestionsForLearnParamsRequest extends Omit< - GetQuestionsListParamsRequest, - 'order' | 'orderBy' | 'random' -> { +export interface GetQuestionsForLearnParamsRequest + extends Omit { profileId: string; isLearned?: boolean; areFavorites?: boolean; diff --git a/src/entities/quiz/model/types/quiz.ts b/src/entities/quiz/model/types/quiz.ts index ea49ebf8f..ff4c5846e 100644 --- a/src/entities/quiz/model/types/quiz.ts +++ b/src/entities/quiz/model/types/quiz.ts @@ -87,10 +87,8 @@ export type ActiveQuiz = Omit; export type CreateNewQuizResponse = ActiveQuiz; -export interface CreateNewMockQuizParamsRequest extends Omit< - CreateNewQuizParamsRequest, - 'profileId' -> { +export interface CreateNewMockQuizParamsRequest + extends Omit { specialization?: number[] | number; } diff --git a/src/entities/topic/model/types/topic.ts b/src/entities/topic/model/types/topic.ts index 28874d1ff..4303dd7f0 100644 --- a/src/entities/topic/model/types/topic.ts +++ b/src/entities/topic/model/types/topic.ts @@ -10,6 +10,7 @@ export interface Topic { skill: Skill; createdAt?: string; updatedAt?: string; + disabled?: boolean; } export interface GetTopicsListParamsRequest { diff --git a/src/features/collections/deleteCollection/api/deleteCollectionApi.ts b/src/features/collections/deleteCollection/api/deleteCollectionApi.ts index be46abd12..69ec2bbba 100644 --- a/src/features/collections/deleteCollection/api/deleteCollectionApi.ts +++ b/src/features/collections/deleteCollection/api/deleteCollectionApi.ts @@ -19,7 +19,7 @@ const deleteCollectionApi = baseApi.injectEndpoints({ await queryFulfilled; const typedExtra = extra as ExtraArgument; toast.success(i18n.t(Translation.TOAST_COLLECTION_DELETE_SUCCESS)); - typedExtra.navigate(ROUTES.admin.questions.page); + typedExtra.navigate(ROUTES.admin.collections.page); } catch (error) { toast.error(i18n.t(Translation.TOAST_COLLECTION_DELETE_FAILED)); // eslint-disable-next-line no-console diff --git a/src/features/topics/deleteTopic/api/deleteTopicApi.ts b/src/features/topics/deleteTopic/api/deleteTopicApi.ts new file mode 100644 index 000000000..af571f92f --- /dev/null +++ b/src/features/topics/deleteTopic/api/deleteTopicApi.ts @@ -0,0 +1,20 @@ +import { ApiTags, baseApi } from '@/shared/config'; +import { route } from '@/shared/libs'; + +import { Topic } from '@/entities/topic'; + +import { deleteTopicApiUrls } from '../model/deleteTopicConstants'; + +const deleteTopicMutation = baseApi.injectEndpoints({ + endpoints: (build) => ({ + deleteTopic: build.mutation({ + query: (topicId) => ({ + url: route(deleteTopicApiUrls.deleteTopic, topicId), + method: 'DELETE', + }), + invalidatesTags: [ApiTags.TOPICS, ApiTags.TOPICS_DETAIL], + }), + }), +}); + +export const { useDeleteTopicMutation } = deleteTopicMutation; diff --git a/src/features/topics/deleteTopic/index.ts b/src/features/topics/deleteTopic/index.ts new file mode 100644 index 000000000..5c7208a46 --- /dev/null +++ b/src/features/topics/deleteTopic/index.ts @@ -0,0 +1 @@ +export { DeleteTopicButton } from './ui/DeleteTopicButton/DeleteTopicButton'; diff --git a/src/features/topics/deleteTopic/model/deleteTopicConstants.ts b/src/features/topics/deleteTopic/model/deleteTopicConstants.ts new file mode 100644 index 000000000..3e8f6d51c --- /dev/null +++ b/src/features/topics/deleteTopic/model/deleteTopicConstants.ts @@ -0,0 +1,3 @@ +export const deleteTopicApiUrls = { + deleteTopic: 'topics/:topicId', +}; diff --git a/src/features/topics/deleteTopic/ui/DeleteTopicButton/DeleteTopicButton.tsx b/src/features/topics/deleteTopic/ui/DeleteTopicButton/DeleteTopicButton.tsx new file mode 100644 index 000000000..8aacd677b --- /dev/null +++ b/src/features/topics/deleteTopic/ui/DeleteTopicButton/DeleteTopicButton.tsx @@ -0,0 +1,90 @@ +import { Placement } from '@floating-ui/react'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { i18Namespace, Translation } from '@/shared/config'; +import { BlockerDialog } from '@/shared/ui/BlockerDialogModal'; +import { Button } from '@/shared/ui/Button'; +import { Icon } from '@/shared/ui/Icon'; +import { Tooltip } from '@/shared/ui/Tooltip'; + +import { Topic } from '@/entities/topic'; + +import { useDeleteTopicMutation } from '../../api/deleteTopicApi'; + +interface DeleteCollectionButtonProps { + topicId: Topic['id']; + isDetailPage?: boolean; + disabled?: boolean; + placementTooltip?: Placement; + offsetTooltip?: number; + onSuccess?: () => void; +} + +export const DeleteTopicButton = ({ + topicId, + isDetailPage = false, + disabled = false, + onSuccess, +}: DeleteCollectionButtonProps) => { + const [deleteTopicMutation] = useDeleteTopicMutation(); + + const { t } = useTranslation(i18Namespace.translation); + const [isDeleteModalOpen, setIsModalOpen] = useState(false); + + const handleOpenModal = () => { + setIsModalOpen(true); + }; + + const handleCloseModal = () => { + setIsModalOpen(false); + }; + + const onDeleteCollection = async () => { + try { + await deleteTopicMutation(topicId).unwrap(); + + onSuccess?.(); + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + } + }; + + return ( + <> + + + + + {isDeleteModalOpen && ( + setIsModalOpen(false)} + message={Translation.MODAL_DELETE_TITLE} + /> + )} + + ); +}; diff --git a/src/features/topics/deleteTopics/api/deleteTopicsApi.ts b/src/features/topics/deleteTopics/api/deleteTopicsApi.ts new file mode 100644 index 000000000..9b13fcfa4 --- /dev/null +++ b/src/features/topics/deleteTopics/api/deleteTopicsApi.ts @@ -0,0 +1,15 @@ +import { baseApi } from '@/shared/config'; +import { route } from '@/shared/libs'; + +import { deleteTopicsApiUrls } from '../model/constants/deleteTopicsConstants'; + +export const deleteTopicsApi = baseApi.injectEndpoints({ + endpoints: (build) => ({ + deleteTopicOfMultiply: build.mutation({ + query: (topicId) => ({ + url: route(deleteTopicsApiUrls.deleteTopic, topicId), + method: 'DELETE', + }), + }), + }), +}); diff --git a/src/features/topics/deleteTopics/index.ts b/src/features/topics/deleteTopics/index.ts new file mode 100644 index 000000000..cbaa70937 --- /dev/null +++ b/src/features/topics/deleteTopics/index.ts @@ -0,0 +1,2 @@ +export { deleteMultipleTopicsThunk } from './model/thunks/deleteMultipleTopicsThunk'; +export { DeleteTopicsButton } from './ui/DeleteTopicsButton/DeleteTopicsButton'; diff --git a/src/features/topics/deleteTopics/model/constants/deleteTopicsConstants.ts b/src/features/topics/deleteTopics/model/constants/deleteTopicsConstants.ts new file mode 100644 index 000000000..cda8df077 --- /dev/null +++ b/src/features/topics/deleteTopics/model/constants/deleteTopicsConstants.ts @@ -0,0 +1,3 @@ +export const deleteTopicsApiUrls = { + deleteTopic: 'topics/:topicId', +}; diff --git a/src/features/topics/deleteTopics/model/thunks/deleteMultipleTopicsThunk.ts b/src/features/topics/deleteTopics/model/thunks/deleteMultipleTopicsThunk.ts new file mode 100644 index 000000000..87f20e0c5 --- /dev/null +++ b/src/features/topics/deleteTopics/model/thunks/deleteMultipleTopicsThunk.ts @@ -0,0 +1,55 @@ +import { createAsyncThunk } from '@reduxjs/toolkit'; + +import { i18n, Translation, ApiTags, baseApi } from '@/shared/config'; +import { SelectedAdminEntities } from '@/shared/libs'; +import { toast } from '@/shared/ui/Toast'; + +import { deleteTopicsApi } from '../../api/deleteTopicsApi'; + +export const deleteMultipleTopicsThunk = createAsyncThunk( + 'topics/deleteMultiple', + async (topics, { rejectWithValue, dispatch }) => { + try { + const responses = await Promise.allSettled( + topics.map( + async (topic) => + await dispatch(deleteTopicsApi.endpoints.deleteTopicOfMultiply.initiate(topic.id)), + ), + ); + + dispatch(baseApi.util.invalidateTags([ApiTags.TOPICS, ApiTags.TOPICS_DETAIL])); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const successfulDeletions = responses.filter((response: any) => !response.value.error); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const failedDeletions = responses.filter((response: any) => !!response.value.error); + + if (failedDeletions.length === 1 && successfulDeletions.length === 0) { + toast.error(i18n.t(Translation.TOAST_TOPIC_DELETE_SINGLE_FAILED)); + return; + } + + if (successfulDeletions.length === 1 && failedDeletions.length === 0) { + toast.success(`${i18n.t(Translation.TOAST_TOPIC_DELETE_SINGLE_SUCCESS)}`); + return; + } + + if (successfulDeletions.length >= 1) { + toast.success( + `${i18n.t(Translation.TOAST_TOPIC_DELETE_MULTIPLE_SUCCESS)} ${successfulDeletions.length}`, + ); + } + + if (failedDeletions.length > 0) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + failedDeletions.forEach((_: any, index: number) => { + toast.error( + `${i18n.t(Translation.TOAST_TOPIC_DELETE_MULTIPLE_FAILED)} ${topics[index].title}`, + ); + }); + } + } catch (error) { + return rejectWithValue(error instanceof Error ? error.message : 'Unknown error'); + } + }, +); diff --git a/src/features/topics/deleteTopics/ui/DeleteTopicsButton/DeleteTopicsButton.tsx b/src/features/topics/deleteTopics/ui/DeleteTopicsButton/DeleteTopicsButton.tsx new file mode 100644 index 000000000..65752d24b --- /dev/null +++ b/src/features/topics/deleteTopics/ui/DeleteTopicsButton/DeleteTopicsButton.tsx @@ -0,0 +1,29 @@ +import { useTranslation } from 'react-i18next'; + +import { i18Namespace, Translation } from '@/shared/config'; +import { useAppDispatch, SelectedAdminEntities } from '@/shared/libs'; +import { Button } from '@/shared/ui/Button'; + +import { deleteMultipleTopicsThunk } from '../../model/thunks/deleteMultipleTopicsThunk'; + +interface DeleteTopicsButtonProps { + topicsToRemove: SelectedAdminEntities; + onSuccess?: () => void; +} + +export const DeleteTopicsButton = ({ topicsToRemove, onSuccess }: DeleteTopicsButtonProps) => { + const dispatch = useAppDispatch(); + const { t } = useTranslation(i18Namespace.translation); + + const onRemoveTopics = async () => { + await dispatch(deleteMultipleTopicsThunk(topicsToRemove)).unwrap(); + + onSuccess?.(); + }; + + return ( + + ); +}; diff --git a/src/pages/admin/resource/resources/ui/ResourcesPage/ResourcesPage.tsx b/src/pages/admin/resource/resources/ui/ResourcesPage/ResourcesPage.tsx index 568cf75ba..6848a133d 100644 --- a/src/pages/admin/resource/resources/ui/ResourcesPage/ResourcesPage.tsx +++ b/src/pages/admin/resource/resources/ui/ResourcesPage/ResourcesPage.tsx @@ -10,7 +10,7 @@ const ResourcesPage = () => { const ActiveComponent = activeTab.Component; return ( - + diff --git a/src/pages/admin/topic/topics/model/slices/topicsPageSlice.ts b/src/pages/admin/topic/topics/model/slices/topicsPageSlice.ts index 8622b3bd4..7e9b4bcf6 100644 --- a/src/pages/admin/topic/topics/model/slices/topicsPageSlice.ts +++ b/src/pages/admin/topic/topics/model/slices/topicsPageSlice.ts @@ -15,6 +15,9 @@ const TopicsPageSlice = createSlice({ setSelectedTopics: (state, action: PayloadAction) => { state.selectedTopics = action.payload; }, + clearSelectedTopics: (state) => { + state.selectedTopics = []; + }, }, }); diff --git a/src/pages/admin/topic/topics/ui/TopicsPage/TopicsPage.tsx b/src/pages/admin/topic/topics/ui/TopicsPage/TopicsPage.tsx index 4f438da8d..a8fc3f216 100644 --- a/src/pages/admin/topic/topics/ui/TopicsPage/TopicsPage.tsx +++ b/src/pages/admin/topic/topics/ui/TopicsPage/TopicsPage.tsx @@ -4,6 +4,8 @@ import { Flex } from '@/shared/ui/Flex'; import { useGetTopicsListQuery } from '@/entities/topic'; +import { DeleteTopicsButton } from '@/features/topics/deleteTopics'; + import { SearchSection } from '@/widgets/SearchSection'; import { TopicsTable } from '@/widgets/topic/TopicsTable'; @@ -20,14 +22,28 @@ const TopicsPage = () => { dispatch(topicsPageActions.setSelectedTopics(ids)); }; + const clearSelectedTopics = () => { + dispatch(topicsPageActions.setSelectedTopics([])); + }; + return ( - + 0} + renderRemoveButton={() => ( + clearSelectedTopics()} + /> + )} + /> clearSelectedTopics()} /> diff --git a/src/shared/config/i18n/i18nTranslations.ts b/src/shared/config/i18n/i18nTranslations.ts index 8e64b8596..cd9137bf4 100644 --- a/src/shared/config/i18n/i18nTranslations.ts +++ b/src/shared/config/i18n/i18nTranslations.ts @@ -237,6 +237,12 @@ export enum Translation { TOAST_AUTH_TELEGRAM_VERIFICATION_LINK_ERROR = 'toast.auth.telegram.verification.link.error.default', TOAST_TOPIC_CREATE_SUCCESS = 'toast.topics.create.success', TOAST_TOPIC_CREATE_FAILED = 'toast.topics.create.failed', + TOAST_TOPIC_DELETE_SINGLE_SUCCESS = 'toast.topics.delete.single.success', + TOAST_TOPIC_DELETE_SINGLE_FAILED = 'toast.topics.delete.single.failed', + TOAST_TOPIC_DELETE_MULTIPLE_SUCCESS = 'toast.topics.delete.multiple.success', + TOAST_TOPIC_DELETE_MULTIPLE_FAILED = 'toast.topics.delete.multiple.failed', + TOAST_TOPIC_EDIT_SUCCESS = 'toast.topics.edit.success', + TOAST_TOPIC_EDIT_FAILED = 'toast.topics.edit.failed', TOAST_AUTH_TELEGRAM_UNAUTHORIZED = 'toast.auth.telegram.verification.link.error.unauthorized', TOAST_AUTH_TELEGRAM_INVALID_DATA = 'toast.auth.telegram.verification.link.error.invalid.data', TOAST_AUTH_TELEGRAM_DATA_OUTDATED = 'toast.auth.telegram.verification.link.error.data.outdated', diff --git a/src/shared/config/query/apiTags.ts b/src/shared/config/query/apiTags.ts index 87daa9fd1..db9678d8d 100644 --- a/src/shared/config/query/apiTags.ts +++ b/src/shared/config/query/apiTags.ts @@ -7,11 +7,11 @@ export enum ApiTags { QUESTIONS = 'questions', QUESTION_STATISTICS = 'question_statistic', COLLECTIONS = 'collections', + COLLECTION_DETAIL = 'collection_detail', RESOURCES = 'resources', RESOURCES_MY_REQUESTS = 'resources_my_requests', RESOURCES_DETAIL = 'resources_detail', RESOURCES_TYPES = 'resources_types', - COLLECTION_DETAIL = 'collection_detail', RESOURCE_REQUESTS = 'resource_requests', RESOURCE_REQUEST = 'resource_request', SKILLS = 'skills', @@ -34,5 +34,6 @@ export enum ApiTags { POPULAR_QUESTIONS = 'questions_popular', SPECIALIZATIONS_GENERAL_PROGRESS = 'general_progress', TOPICS = 'topics', + TOPICS_DETAIL = 'topics_detail', TOPIC = 'topic', } diff --git a/src/shared/ui/Dropdown/Dropdown/Dropdown.tsx b/src/shared/ui/Dropdown/Dropdown/Dropdown.tsx index 3dce06929..8de221c71 100644 --- a/src/shared/ui/Dropdown/Dropdown/Dropdown.tsx +++ b/src/shared/ui/Dropdown/Dropdown/Dropdown.tsx @@ -12,10 +12,8 @@ import { Select } from '../Select/Select'; import styles from './Dropdown.module.css'; -export interface DropdownProps extends Omit< - React.HTMLProps, - 'prefix' | 'size' | 'onSelect' | 'value' -> { +export interface DropdownProps + extends Omit, 'prefix' | 'size' | 'onSelect' | 'value'> { prefix?: React.ReactNode; suffix?: React.ReactNode; size?: DropdownSize; diff --git a/src/shared/ui/Table/Table.tsx b/src/shared/ui/Table/Table.tsx index e5f51cf45..290a08bf2 100644 --- a/src/shared/ui/Table/Table.tsx +++ b/src/shared/ui/Table/Table.tsx @@ -57,7 +57,7 @@ export const Table = >( }: TableProps) => { const hasActions = !!renderActions; - const isAllSelected = selectedItems?.length === items.length; + const isAllSelected = selectedItems?.length === items.length && items.length > 0; const selectedItemsIds = selectedItems?.map(({ id }) => id) || []; const onSelectAllItems = () => { diff --git a/src/shared/ui/Tabs/Tabs.module.css b/src/shared/ui/Tabs/Tabs.module.css index 9fd401bc5..ebeef993f 100644 --- a/src/shared/ui/Tabs/Tabs.module.css +++ b/src/shared/ui/Tabs/Tabs.module.css @@ -1,31 +1,36 @@ .tab-container { position: relative; + padding-bottom: 20px; } .tab-list { + align-self: flex-start; + padding: 10px; overflow-x: auto; + border-radius: 18px; + background-color: var(--background-block); list-style-type: none; scrollbar-width: none; } .tab-item { flex-shrink: 0; - padding-bottom: 8px; + padding: 10px; + border-radius: 8px; cursor: pointer; } .tab-item.active { position: relative; + background-color:var(--background-app); } -.tab-item.active::after { - position: absolute; - bottom: 0; - left: 0; - width: 100%; - height: 2px; - border-radius: 18px; - background: var(--background-primary); +.tab-item:hover{ + background-color: var(--background-button); + color:var(--border-image-hover); transition: all .3s ease-in-out; - content: ''; +} + +.tab-item:hover > p { + color:var(--border-image-hover); } \ No newline at end of file diff --git a/src/shared/ui/Tabs/Tabs.skeleton.tsx b/src/shared/ui/Tabs/Tabs.skeleton.tsx index f0b687022..949357ddd 100644 --- a/src/shared/ui/Tabs/Tabs.skeleton.tsx +++ b/src/shared/ui/Tabs/Tabs.skeleton.tsx @@ -19,7 +19,6 @@ export const TabsSkeleton = ({ tabs }: TabsSkeleton) => { ))} -
); }; diff --git a/src/shared/ui/Tabs/Tabs.test.tsx b/src/shared/ui/Tabs/Tabs.test.tsx index 12f2976e8..432155280 100644 --- a/src/shared/ui/Tabs/Tabs.test.tsx +++ b/src/shared/ui/Tabs/Tabs.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { Tab, Tabs, TabsProps } from './Tabs'; @@ -32,29 +32,13 @@ const renderComponent = (props = {}) => { }; describe('Tabs Component', () => { - test('sets the active tab and line position correctly on initial render', () => { - renderComponent(); - - const tab1 = screen.getByTestId('Tabs_Item_tab-1'); - const lineRef = screen.getByTestId('Tabs_Line'); - - expect(lineRef).toHaveStyle(`left: ${tab1.offsetLeft}px`); - expect(lineRef).toHaveStyle(`width: ${tab1.offsetWidth}px`); - }); - test('updates line position and active tab on tab click', async () => { renderComponent(); const tab2 = screen.getByTestId('Tabs_Item_tab-2'); - const lineRef = screen.getByTestId('Tabs_Line'); fireEvent.click(tab2); - await waitFor(() => { - expect(lineRef).toHaveStyle(`left: ${tab2.offsetLeft}px`); - expect(lineRef).toHaveStyle(`width: ${tab2.offsetWidth}px`); - }); - expect(setActiveTab).toHaveBeenCalledWith(mockTabs[1]); }); diff --git a/src/shared/ui/Tabs/Tabs.tsx b/src/shared/ui/Tabs/Tabs.tsx index 03ccc402e..918a2b187 100644 --- a/src/shared/ui/Tabs/Tabs.tsx +++ b/src/shared/ui/Tabs/Tabs.tsx @@ -1,5 +1,5 @@ import classNames from 'classnames'; -import { Dispatch, Key, SetStateAction, useEffect, useRef } from 'react'; +import { Dispatch, Key, SetStateAction } from 'react'; import { useNavigate } from 'react-router-dom'; import { Flex } from '@/shared/ui/Flex'; @@ -21,33 +21,13 @@ export interface TabsProps { } export const Tabs = ({ tabs, activeTab, setActiveTab }: TabsProps) => { - const lineRef = useRef(null); const navigate = useNavigate(); - const onTabToggle = (e: React.MouseEvent, tab: Tab) => { - const tabElement = e.target as HTMLLIElement; - const tabRect = tabElement.offsetLeft; - + const onTabToggle = (tab: Tab) => { setActiveTab(tab); navigate(`#${tab.id}`, { replace: true }); - - if (lineRef.current) { - lineRef.current.style.width = tabElement.offsetWidth + 'px'; - lineRef.current.style.left = `${tabRect}px`; - } }; - useEffect(() => { - const tabElement = document.querySelector( - `.${styles['tab-item']}.${styles.active}`, - ) as HTMLLIElement | null; - if (tabElement && lineRef.current) { - const tabRect = tabElement.offsetLeft; - lineRef.current.style.width = `${tabElement.offsetWidth}px`; - lineRef.current.style.left = `${tabRect}px`; - } - }, [activeTab]); - return ( ({ tabs, activeTab, setActiveTab }: TabsProps) => {
  • onTabToggle(e, tab)} + onClick={() => onTabToggle(tab)} role="tab" tabIndex={0} data-testid={`Tabs_Item_${tab.id}`} > - + {tab.label} {(tab.count ?? 0) > 0 && `(${tab.count})`}
  • ))}
    -
    ); }; diff --git a/src/widgets/Collection/ui/AdditionalInfo/AdditionalInfo.tsx b/src/widgets/Collection/ui/AdditionalInfo/AdditionalInfo.tsx index 215fe2cfb..3ae3d8782 100644 --- a/src/widgets/Collection/ui/AdditionalInfo/AdditionalInfo.tsx +++ b/src/widgets/Collection/ui/AdditionalInfo/AdditionalInfo.tsx @@ -20,10 +20,11 @@ import { SpecializationsList } from '@/entities/specialization'; import styles from './AdditionalInfo.module.css'; -interface AdditionalInfoProps extends Pick< - Collection, - 'specializations' | 'isFree' | 'company' | 'questionsCount' | 'createdBy' | 'keywords' -> { +interface AdditionalInfoProps + extends Pick< + Collection, + 'specializations' | 'isFree' | 'company' | 'questionsCount' | 'createdBy' | 'keywords' + > { showAuthor?: boolean; className?: string; media?: Media | undefined; diff --git a/src/widgets/Collection/ui/CollectionHeader/CollectionHeader.tsx b/src/widgets/Collection/ui/CollectionHeader/CollectionHeader.tsx index cd9c2b54f..4eca4e37c 100644 --- a/src/widgets/Collection/ui/CollectionHeader/CollectionHeader.tsx +++ b/src/widgets/Collection/ui/CollectionHeader/CollectionHeader.tsx @@ -11,10 +11,8 @@ import { Collection } from '@/entities/collection'; import styles from './CollectionHeader.module.css'; -interface CollectionHeaderProps extends Pick< - Collection, - 'title' | 'description' | 'imageSrc' | 'company' -> { +interface CollectionHeaderProps + extends Pick { renderDrawer: () => ReactNode; } diff --git a/src/widgets/analytics/PopularSkillsWidget/ui/PopularSkillsWidget/PopularSkillsWidget.tsx b/src/widgets/analytics/PopularSkillsWidget/ui/PopularSkillsWidget/PopularSkillsWidget.tsx index adab4f37d..19f58ee6b 100644 --- a/src/widgets/analytics/PopularSkillsWidget/ui/PopularSkillsWidget/PopularSkillsWidget.tsx +++ b/src/widgets/analytics/PopularSkillsWidget/ui/PopularSkillsWidget/PopularSkillsWidget.tsx @@ -36,14 +36,16 @@ const PopularSkillsWidget = () => { isActionPositionBottom > - {data?.data.slice(0, 3).map((item) => ( - - ))} + {data?.data + .slice(0, 3) + .map((item) => ( + + ))} ); diff --git a/src/widgets/topic/TopicsTable/ui/TopicsTable.tsx b/src/widgets/topic/TopicsTable/ui/TopicsTable.tsx index 505cb1f6b..8ad2426c8 100644 --- a/src/widgets/topic/TopicsTable/ui/TopicsTable.tsx +++ b/src/widgets/topic/TopicsTable/ui/TopicsTable.tsx @@ -1,6 +1,5 @@ import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router-dom'; -import { Link } from 'react-router-dom'; +import { Link, useNavigate } from 'react-router-dom'; import { i18Namespace, Topics, Translation, ROUTES } from '@/shared/config'; import { SelectedAdminEntities, formatDate, route } from '@/shared/libs'; @@ -14,17 +13,25 @@ import { Text } from '@/shared/ui/Text'; import { Topic } from '@/entities/topic'; +import { DeleteTopicButton } from '@/features/topics/deleteTopic'; + import styles from './TopicsTable.module.css'; interface TopicsTableProps { topics?: Topic[]; selectedTopics?: SelectedAdminEntities; onSelectTopics?: (ids: SelectedAdminEntities) => void; + onDeleteSuccess?: () => void; } -export const TopicsTable = ({ topics, selectedTopics, onSelectTopics }: TopicsTableProps) => { - const navigate = useNavigate(); +export const TopicsTable = ({ + topics, + selectedTopics, + onSelectTopics, + onDeleteSuccess, +}: TopicsTableProps) => { const { t } = useTranslation([i18Namespace.topic, i18Namespace.translation]); + const navigate = useNavigate(); const renderTableColumnWidth = () => { const columnWidths = { @@ -95,8 +102,29 @@ export const TopicsTable = ({ topics, selectedTopics, onSelectTopics }: TopicsTa navigate(route(ROUTES.admin.topics.details.page, topic.id)); }, }, - // Добавить редактировать и удалить если нужно + { + icon: , + title: t(Translation.EDIT, { ns: i18Namespace.translation }), + tooltip: { + color: 'red', + text: t(Translation.TOOLTIP_COLLECTION_DISABLED_INFO, { ns: i18Namespace.translation }), + }, + disabled: topic.disabled, + onClick: () => { + navigate(route(ROUTES.admin.topics.details.page, topic.id)); + }, + }, + { + renderComponent: () => ( + + ), + }, ]; + return ( @@ -124,10 +152,11 @@ export const TopicsTable = ({ topics, selectedTopics, onSelectTopics }: TopicsTa items={topics} renderTableHeader={renderTableHeader} renderTableBody={renderTableBody} + renderActions={renderActions} renderTableColumnWidths={renderTableColumnWidth} selectedItems={selectedTopics} onSelectItems={onSelectTopics} - renderActions={renderActions} + hasCopyButton /> ); }; From 38c511d5dbaa20eab20c8dad34db48efad9b24e7 Mon Sep 17 00:00:00 2001 From: Emin <122212022+Emin14@users.noreply.github.com> Date: Mon, 22 Dec 2025 15:59:06 +0300 Subject: [PATCH 04/10] YH-1494: Added campaign filtering to collection pages (#1041) * YH-1494: Added campaign filtering to collection pages * YH-1494: Fixed it so that the component is only shown on the landing page and platform * YH-1494: fix fsd error * YH-1494: remove page * YH-1494: add page * YH-1494: fix companies select --------- Co-authored-by: Emin14 Co-authored-by: Denis Pereloma --- public/locales/en/collection.json | 6 -- public/locales/en/companies.json | 7 +- public/locales/en/translation.json | 6 ++ public/locales/ru/collection.json | 6 -- public/locales/ru/companies.json | 7 +- public/locales/ru/translation.json | 9 ++ src/entities/collection/index.ts | 1 - .../collection/model/types/collection.ts | 1 + .../collection/model/types/filters.ts | 1 + src/entities/company/api/companyApi.ts | 10 +- src/entities/company/index.ts | 7 +- .../model/constants/companyConstants.ts | 1 + .../PublicCompanySelect.tsx | 99 +++++++++++++++++++ src/entities/guru/model/constants/gurus.ts | 15 --- .../model/hooks/useCollectionsFilters.ts | 11 ++- .../hooks/useGetCollectionsFilterParams.ts | 1 + .../CollectionsFilters/CollectionsFilters.tsx | 20 +++- .../{TopicDetail => topicDetail}/index.ts | 0 .../TopicDetailPage/TopicDetailPage.lazy.tsx | 0 .../ui/TopicDetailPage/TopicDetailPage.tsx | 0 .../PublicCollectionsPage.tsx | 4 + .../ui/CollectionsPage/CollectionsPage.tsx | 4 + src/shared/config/i18n/i18nTranslations.ts | 11 ++- src/shared/config/query/apiTags.ts | 1 + .../ui/KeywordSelect/KeywordSelect.tsx | 48 ++++++--- src/shared/ui/KeywordSelect/index.ts | 1 + .../Avos/ui/AvosPromo/AvosPromo.skeleton.tsx | 2 +- 27 files changed, 223 insertions(+), 56 deletions(-) create mode 100644 src/entities/company/ui/PublicCompanySelect/PublicCompanySelect.tsx rename src/pages/admin/topic/{TopicDetail => topicDetail}/index.ts (100%) rename src/pages/admin/topic/{TopicDetail => topicDetail}/ui/TopicDetailPage/TopicDetailPage.lazy.tsx (100%) rename src/pages/admin/topic/{TopicDetail => topicDetail}/ui/TopicDetailPage/TopicDetailPage.tsx (100%) rename src/{entities/collection => shared}/ui/KeywordSelect/KeywordSelect.tsx (66%) create mode 100644 src/shared/ui/KeywordSelect/index.ts diff --git a/public/locales/en/collection.json b/public/locales/en/collection.json index 28c92ded3..373e318cc 100644 --- a/public/locales/en/collection.json +++ b/public/locales/en/collection.json @@ -35,12 +35,6 @@ "aria.label": "Warning: You cannot create a new interview because there is already an active one." }, "keywords": "Keywords:", - "keyword": { - "label": "Keyword", - "placeholder": "Select keyword", - "not.found": "Keyword not found", - "not.exist": "Keywords are not exist" - }, "tags": { "title": "Tags" }, diff --git a/public/locales/en/companies.json b/public/locales/en/companies.json index b5a779fc4..68ac3d26d 100644 --- a/public/locales/en/companies.json +++ b/public/locales/en/companies.json @@ -14,6 +14,11 @@ "select": { "choose": "Choose company name", "empty": "No companies available", - "selected": "Selected company" + "selected": "Selected company", + "filter": { + "title": "Company", + "choose": "Select a company", + "not.found": "Company is not found" + } } } diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 88b5e92f3..d3bc9c111 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -61,6 +61,12 @@ "topics": "Topics" } }, + "keyword": { + "label": "Keyword", + "placeholder": "Select keyword", + "not.found": "Keyword not found", + "not.exist": "Keywords are not exist" + }, "stub": { "filter": { "title": "Unfortunately, nothing was found for the query.", diff --git a/public/locales/ru/collection.json b/public/locales/ru/collection.json index 722029e96..794134261 100644 --- a/public/locales/ru/collection.json +++ b/public/locales/ru/collection.json @@ -29,12 +29,6 @@ "title": "Ключевые слова", "label": "Добавьте ключевые слова в текст" }, - "keyword": { - "label": "Ключевое слово", - "placeholder": "Выберите ключевое слово", - "not.found": "Ключевое слово не найдено", - "not.exist": "Ключевых слов нет" - }, "tags": { "title": "Теги" }, diff --git a/public/locales/ru/companies.json b/public/locales/ru/companies.json index 022abd318..e358cf784 100644 --- a/public/locales/ru/companies.json +++ b/public/locales/ru/companies.json @@ -14,6 +14,11 @@ "select": { "choose": "Выберите название компании", "empty": "Нет доступных компаний", - "selected": "Выбранная компания" + "selected": "Выбранная компания", + "filter": { + "title": "Компания", + "choose": "Выберите компанию", + "not.found": "Компания не найдена" + } } } diff --git a/public/locales/ru/translation.json b/public/locales/ru/translation.json index 2fb2adf65..e4152ad81 100644 --- a/public/locales/ru/translation.json +++ b/public/locales/ru/translation.json @@ -62,6 +62,12 @@ "topics": "Темы" } }, + "keyword": { + "label": "Ключевое слово", + "placeholder": "Выберите ключевое слово", + "not.found": "Ключевое слово не найдено", + "not.exist": "Ключевых слов нет" + }, "stub": { "filter": { "title": "К сожалению, по запросу ничего не найдено.", @@ -491,6 +497,9 @@ "create": { "success": "Компания успешно создана", "failed": "Не удалось создать компанию" + }, + "filter": { + "not.found": "Компания не найдена" } }, "subscriptions": { diff --git a/src/entities/collection/index.ts b/src/entities/collection/index.ts index e2ab2ea82..6c16c57a9 100644 --- a/src/entities/collection/index.ts +++ b/src/entities/collection/index.ts @@ -12,7 +12,6 @@ export { CollectionForm } from './ui/CollectionForm/CollectionForm'; export * from './api/collectionApi'; export { ChooseCollectionAccess } from './ui/ChooseCollectionAccess/ChooseCollectionAccess'; -export { KeywordSelect } from './ui/KeywordSelect/KeywordSelect'; export { CollectionPreview } from './ui/CollectionPreview/CollectionPreview'; export { PreviewCollectionsItemSkeleton } from './ui/PreviewCollectionItem/PreviewCollectionsItem.skeleton'; export { CollectionsPreviewSkeleton } from './ui/CollectionPreview/CollectionPreview.sekeleton'; diff --git a/src/entities/collection/model/types/collection.ts b/src/entities/collection/model/types/collection.ts index 964ada770..187418728 100644 --- a/src/entities/collection/model/types/collection.ts +++ b/src/entities/collection/model/types/collection.ts @@ -42,6 +42,7 @@ export interface GetCollectionsListParamsRequest { limit?: number; isFree?: boolean; specializations?: number | number[]; + companies?: string | string[]; keywords?: string[]; titleOrDescriptionSearch?: string; authorId?: string; diff --git a/src/entities/collection/model/types/filters.ts b/src/entities/collection/model/types/filters.ts index 69ff4baa5..849cbf914 100644 --- a/src/entities/collection/model/types/filters.ts +++ b/src/entities/collection/model/types/filters.ts @@ -3,6 +3,7 @@ export interface CollectionsFilterParams { specialization?: number; isFree?: boolean; page?: number; + company?: string; authorId?: string; isMy?: boolean; keyword?: string; diff --git a/src/entities/company/api/companyApi.ts b/src/entities/company/api/companyApi.ts index 3a25290b7..272d71673 100644 --- a/src/entities/company/api/companyApi.ts +++ b/src/entities/company/api/companyApi.ts @@ -24,7 +24,15 @@ const companyApi = baseApi.injectEndpoints({ }), providesTags: [ApiTags.COMPANIES], }), + getPublicCompaniesList: build.query({ + query: (params) => ({ + url: companyApiUrls.getPublicCompaniesList, + params, + }), + providesTags: [ApiTags.PUBLIC_COMPANIES], + }), }), }); -export const { useGetCompanyByIdQuery, useGetCompaniesListQuery } = companyApi; +export const { useGetCompanyByIdQuery, useGetCompaniesListQuery, useGetPublicCompaniesListQuery } = + companyApi; diff --git a/src/entities/company/index.ts b/src/entities/company/index.ts index 0e542034c..beca92e08 100644 --- a/src/entities/company/index.ts +++ b/src/entities/company/index.ts @@ -1,6 +1,11 @@ export { CompanyCard } from './ui/CompanyCard/CompanyCard'; export { CompanySelect } from './ui/CompanySelect/CompanySelect'; -export { useGetCompanyByIdQuery, useGetCompaniesListQuery } from './api/companyApi'; +export { PublicCompanySelect } from './ui/PublicCompanySelect/PublicCompanySelect'; +export { + useGetCompanyByIdQuery, + useGetCompaniesListQuery, + useGetPublicCompaniesListQuery, +} from './api/companyApi'; export type { Company, CreateOrEditCompanyFormValues } from './model/types/companyTypes'; export { CompanyForm } from './ui/CompanyForm/CompanyForm'; diff --git a/src/entities/company/model/constants/companyConstants.ts b/src/entities/company/model/constants/companyConstants.ts index a4b79b4b7..c082e3343 100644 --- a/src/entities/company/model/constants/companyConstants.ts +++ b/src/entities/company/model/constants/companyConstants.ts @@ -1,4 +1,5 @@ export const companyApiUrls = { getCompanyById: 'company/:companyId', getCompaniesList: 'company', + getPublicCompaniesList: 'company/public-companies', }; diff --git a/src/entities/company/ui/PublicCompanySelect/PublicCompanySelect.tsx b/src/entities/company/ui/PublicCompanySelect/PublicCompanySelect.tsx new file mode 100644 index 000000000..fbe712911 --- /dev/null +++ b/src/entities/company/ui/PublicCompanySelect/PublicCompanySelect.tsx @@ -0,0 +1,99 @@ +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { i18Namespace, Companies } from '@/shared/config'; +import { useDebounce } from '@/shared/libs'; +import { Dropdown, Option } from '@/shared/ui/Dropdown'; +import { Flex } from '@/shared/ui/Flex'; +import { Text } from '@/shared/ui/Text'; + +import { useGetPublicCompaniesListQuery } from '../../api/companyApi'; + +export type PublicCompanySelectProps = Omit< + React.ComponentProps, + 'options' | 'type' | 'value' | 'onChange' | 'children' +> & { + value?: string; + onChange?: (company?: string) => void; + disabled?: boolean; +}; + +export const PublicCompanySelect = ({ value, onChange, disabled }: PublicCompanySelectProps) => { + const { t } = useTranslation([i18Namespace.companies]); + + const [searchValue, setSearchValue] = useState(''); + const [debouncedValue, setDebouncedValue] = useState(''); + + const debouncedSetValue = useDebounce((value: string) => { + setDebouncedValue(value); + }, 500); + + const { data, isFetching } = useGetPublicCompaniesListQuery({ + titleOrLegalNameOrDescriptionSearch: debouncedValue, + page: 1, + limit: 10, + }); + + const companies = data?.data.filter((company) => company.title) || []; + + const handleChange = (newValue?: string) => { + if (disabled) return; + onChange?.(newValue); + }; + + const emptyCompany = { + value: '', + label: t(Companies.SELECT_FILTER_CHOOSE), + }; + + const handleSearchChange = (val: string) => { + setSearchValue(val); + debouncedSetValue(val); + }; + + const options = useMemo(() => { + return companies.map((company) => ({ + value: company.id.toString(), + label: company.title as string, + })); + }, [companies]); + const selectCompany = options.find((option) => option.value === value) || emptyCompany; + const notFoundText = t(Companies.SELECT_FILTER_NOT_FOUND); + const displayValue = options.length === 0 ? notFoundText : searchValue || selectCompany.label; + + return ( + + + {t(Companies.SELECT_FILTER_TITLE)} + + { + const selected = options.find((opt) => opt.value === val); + handleChange(!val ? undefined : String(val)); + !selected?.label ? handleSearchChange('') : ''; + setSearchValue(selected?.label ?? ''); + }} + > + {options.length === 0 ? ( + + + ); +}; diff --git a/src/entities/guru/model/constants/gurus.ts b/src/entities/guru/model/constants/gurus.ts index 27b646117..631e624b8 100644 --- a/src/entities/guru/model/constants/gurus.ts +++ b/src/entities/guru/model/constants/gurus.ts @@ -124,21 +124,6 @@ export const gurus: Guru[] = [ landing: 'https://notsystemanalysis.ru/', }, }, - { - title: '1C Developer Guru', - name: 'Nadvorny Vladimir', - specializations: [41, 43], - hasPractice: false, - description: - 'Отвечает за направление 1C: следит за актуальностью материалов, помогает формировать контент и добавляет вопросы для практики и интервью.', - image: - 'https://e5e684b1-4a6a-4be5-b7ee-b2b678239d61.selstorage.ru/gurus/%D0%B3%D1%83%D1%80%D1%831%D1%81.jpg', - socials: { - telegram: 'https://t.me/Mentor1CProfi', - profileId: '73771794-59d6-4c5b-8449-07e1887ea0d2', - landing: 'https://t.me/Mentor1CProfi', - }, - }, { title: 'Unity Guru', name: 'Oleg Miroshkin', diff --git a/src/features/collections/filterCollections/model/hooks/useCollectionsFilters.ts b/src/features/collections/filterCollections/model/hooks/useCollectionsFilters.ts index 863dcf754..acec21ec9 100644 --- a/src/features/collections/filterCollections/model/hooks/useCollectionsFilters.ts +++ b/src/features/collections/filterCollections/model/hooks/useCollectionsFilters.ts @@ -17,7 +17,8 @@ export const useCollectionsFilters = (initialParams: CollectionsFilterParams) => Boolean(filters.specialization) || Boolean(filters.authorId) || Boolean(filters.isMy) || - Boolean(filters.keyword); + Boolean(filters.keyword) || + Boolean(filters.company); const onChangeTitle = (title: CollectionsFilterParams['title']) => { onFilterChange({ title, page: 1 }); @@ -30,6 +31,13 @@ export const useCollectionsFilters = (initialParams: CollectionsFilterParams) => }); }; + const onChangeCompany = (company: CollectionsFilterParams['company']) => { + onFilterChange({ + company, + page: 1, + }); + }; + const onChangeIsFree = (isFree: CollectionsFilterParams['isFree']) => { onFilterChange({ isFree, @@ -63,6 +71,7 @@ export const useCollectionsFilters = (initialParams: CollectionsFilterParams) => onResetFilters, onChangeTitle, onChangeSpecialization, + onChangeCompany, onChangeIsFree, onChangePage, onChangeAuthor, diff --git a/src/features/collections/filterCollections/model/hooks/useGetCollectionsFilterParams.ts b/src/features/collections/filterCollections/model/hooks/useGetCollectionsFilterParams.ts index 81dc8e34f..4d58a0f83 100644 --- a/src/features/collections/filterCollections/model/hooks/useGetCollectionsFilterParams.ts +++ b/src/features/collections/filterCollections/model/hooks/useGetCollectionsFilterParams.ts @@ -9,6 +9,7 @@ export const useGetCollectionsFilterParams = (initialParams: CollectionsFilterPa specialization: parsedParams.specialization ? Number(parsedParams.specialization) : initialParams.specialization, + company: parsedParams.company || initialParams.company, isFree: parsedParams.isFree ? Boolean(parsedParams.isFree) : initialParams.isFree, title: parsedParams.title || initialParams.title, authorId: parsedParams.authorId || initialParams.authorId, diff --git a/src/features/collections/filterCollections/ui/CollectionsFilters/CollectionsFilters.tsx b/src/features/collections/filterCollections/ui/CollectionsFilters/CollectionsFilters.tsx index f6e0b9576..a81f4387a 100644 --- a/src/features/collections/filterCollections/ui/CollectionsFilters/CollectionsFilters.tsx +++ b/src/features/collections/filterCollections/ui/CollectionsFilters/CollectionsFilters.tsx @@ -3,14 +3,16 @@ import { useTranslation } from 'react-i18next'; import { i18Namespace, Collections } from '@/shared/config'; import { useCurrentProject } from '@/shared/libs'; import { Flex } from '@/shared/ui/Flex'; +import { KeywordSelect } from '@/shared/ui/KeywordSelect'; import { SearchInput } from '@/shared/ui/SearchInput'; import { Switch } from '@/shared/ui/Switch'; import { ChooseCollectionAccess, CollectionsFilterParams, - KeywordSelect, + useGetCollectionKeywordsQuery, } from '@/entities/collection'; +import { PublicCompanySelect } from '@/entities/company'; import { getChannelsForSpecialization, MediaLinksBanner } from '@/entities/socialMedia'; import { DEFAULT_SPECIALIZATION_ID, SpecializationsListField } from '@/entities/specialization'; import { UserSelect } from '@/entities/user'; @@ -21,6 +23,7 @@ interface CollectionsFiltersProps { filter: CollectionsFilterParams; onChangeTitle?: (value: CollectionsFilterParams['title']) => void; onChangeSpecialization?: (specialization: CollectionsFilterParams['specialization']) => void; + onChangeCompany?: (company: CollectionsFilterParams['company']) => void; onChangeIsFree: (isFree: CollectionsFilterParams['isFree']) => void; onChangeKeyword?: (keyword: CollectionsFilterParams['keyword']) => void; onChangeAuthor?: (authorId?: CollectionsFilterParams['authorId']) => void; @@ -31,11 +34,12 @@ export const CollectionsFilters = ({ filter, onChangeTitle, onChangeSpecialization, + onChangeCompany, onChangeIsFree, onChangeKeyword, onChangeIsMy, }: CollectionsFiltersProps) => { - const { title, specialization, isFree, keyword, isMy } = filter; + const { title, specialization, isFree, keyword, isMy, company } = filter; const { t } = useTranslation(i18Namespace.collection); const project = useCurrentProject(); @@ -82,10 +86,18 @@ export const CollectionsFilters = ({ onChangeSpecialization={handleSpecializationChange} /> )} - {onChangeKeyword && ( - + )} + {(project === 'landing' || project === 'platform') && ( + + )} + {media && } ); diff --git a/src/pages/admin/topic/TopicDetail/index.ts b/src/pages/admin/topic/topicDetail/index.ts similarity index 100% rename from src/pages/admin/topic/TopicDetail/index.ts rename to src/pages/admin/topic/topicDetail/index.ts diff --git a/src/pages/admin/topic/TopicDetail/ui/TopicDetailPage/TopicDetailPage.lazy.tsx b/src/pages/admin/topic/topicDetail/ui/TopicDetailPage/TopicDetailPage.lazy.tsx similarity index 100% rename from src/pages/admin/topic/TopicDetail/ui/TopicDetailPage/TopicDetailPage.lazy.tsx rename to src/pages/admin/topic/topicDetail/ui/TopicDetailPage/TopicDetailPage.lazy.tsx diff --git a/src/pages/admin/topic/TopicDetail/ui/TopicDetailPage/TopicDetailPage.tsx b/src/pages/admin/topic/topicDetail/ui/TopicDetailPage/TopicDetailPage.tsx similarity index 100% rename from src/pages/admin/topic/TopicDetail/ui/TopicDetailPage/TopicDetailPage.tsx rename to src/pages/admin/topic/topicDetail/ui/TopicDetailPage/TopicDetailPage.tsx diff --git a/src/pages/landing/publicCollections/ui/PublicCollectionsPage/PublicCollectionsPage.tsx b/src/pages/landing/publicCollections/ui/PublicCollectionsPage/PublicCollectionsPage.tsx index 088e3ffe5..1f1ad10ba 100644 --- a/src/pages/landing/publicCollections/ui/PublicCollectionsPage/PublicCollectionsPage.tsx +++ b/src/pages/landing/publicCollections/ui/PublicCollectionsPage/PublicCollectionsPage.tsx @@ -24,6 +24,7 @@ const PublicCollectionsPage = () => { onResetFilters, onChangePage, onChangeSpecialization, + onChangeCompany, onChangeIsFree, onChangeTitle, onChangeKeyword, @@ -32,6 +33,7 @@ const PublicCollectionsPage = () => { const { data: collections, isLoading: isLoadingCollections } = useGetPublicCollectionsListQuery({ titleOrDescriptionSearch: filters.title, specializations: filters.specialization, + companies: filters.company, isFree: filters.isFree, page: filters.page, keywords: filters.keyword ? [filters.keyword] : undefined, @@ -41,6 +43,7 @@ const PublicCollectionsPage = () => { { specialization: filters.specialization, isFree: filters.isFree, keyword: filters.keyword, + company: filters.company, }} /> ); diff --git a/src/pages/wiki/collection/collections/ui/CollectionsPage/CollectionsPage.tsx b/src/pages/wiki/collection/collections/ui/CollectionsPage/CollectionsPage.tsx index 4e3dd5a3f..169278a9b 100644 --- a/src/pages/wiki/collection/collections/ui/CollectionsPage/CollectionsPage.tsx +++ b/src/pages/wiki/collection/collections/ui/CollectionsPage/CollectionsPage.tsx @@ -27,6 +27,7 @@ const CollectionsPage = () => { onChangeTitle, onChangeIsFree, onChangeKeyword, + onChangeCompany, } = useCollectionsFilters({ page: 1, }); @@ -41,6 +42,7 @@ const CollectionsPage = () => { const { data: allCollections, isLoading: isLoadingAllCollections } = useGetCollectionsListQuery({ titleOrDescriptionSearch: filters.title, specializations: specializationId, + companies: filters.company, isFree: filters.isFree, page: filters.page, keywords: filters.keyword ? [filters.keyword] : undefined, @@ -52,10 +54,12 @@ const CollectionsPage = () => { diff --git a/src/shared/config/i18n/i18nTranslations.ts b/src/shared/config/i18n/i18nTranslations.ts index cd9137bf4..8b39c54da 100644 --- a/src/shared/config/i18n/i18nTranslations.ts +++ b/src/shared/config/i18n/i18nTranslations.ts @@ -51,6 +51,10 @@ export enum Translation { SIDEBAR_MENU_ANALYTICS = 'sidebar.menu.analytics', SIDEBAR_MENU_TOPICS = 'sidebar.menu.topics', + KEYWORD_LABEL = 'keyword.label', + KEYWORD_PLACEHOLDER = 'keyword.placeholder', + KEYWORD_NOT_FOUND = 'keyword.not.found', + KEYWORD_NOT_EXIST = 'keyword.not.exist', /* Stub */ STUB_FILTER_TITLE = 'stub.filter.title', STUB_FILTER_SUBTITLE = 'stub.filter.subtitle', @@ -835,10 +839,6 @@ export enum Collections { SPECIALIZATION_LABEL = 'specialization.label', KEYWORDS_TITLE = 'keywords.title', KEYWORDS_LABEL = 'keywords.label', - KEYWORD_LABEL = 'keyword.label', - KEYWORD_PLACEHOLDER = 'keyword.placeholder', - KEYWORD_NOT_FOUND = 'keyword.not.found', - KEYWORD_NOT_EXIST = 'keyword.not.exist', TAGS_TITLE = 'tags.title', TOOLTIP_TITLE = 'tooltip.title', TOOLTIP_ARIA_LABEL = 'tooltip.aria.label', @@ -869,6 +869,9 @@ export enum Companies { SELECT_CHOOSE = 'select.choose', SELECT_EMPTY = 'select.empty', SELECT_SELECTED = 'select.selected', + SELECT_FILTER_TITLE = 'select.filter.title', + SELECT_FILTER_CHOOSE = 'select.filter.choose', + SELECT_FILTER_NOT_FOUND = 'select.filter.not.found', } export enum ResourceRequests { diff --git a/src/shared/config/query/apiTags.ts b/src/shared/config/query/apiTags.ts index db9678d8d..b903bc707 100644 --- a/src/shared/config/query/apiTags.ts +++ b/src/shared/config/query/apiTags.ts @@ -28,6 +28,7 @@ export enum ApiTags { INTERVIEW_STATISTICS = 'interview_statistics', PUBLIC_QUESTION_DETAIL = 'public_question_detail', COMPANIES = 'companies', + PUBLIC_COMPANIES = 'public_companies', COMPANY_DETAIL = 'company_detail', PAYMENTS = 'payments', POPULAR_SKILLS = 'skills_popular', diff --git a/src/entities/collection/ui/KeywordSelect/KeywordSelect.tsx b/src/shared/ui/KeywordSelect/KeywordSelect.tsx similarity index 66% rename from src/entities/collection/ui/KeywordSelect/KeywordSelect.tsx rename to src/shared/ui/KeywordSelect/KeywordSelect.tsx index cfa065a01..1b8e9a4a9 100644 --- a/src/entities/collection/ui/KeywordSelect/KeywordSelect.tsx +++ b/src/shared/ui/KeywordSelect/KeywordSelect.tsx @@ -1,29 +1,48 @@ -import { useMemo, useState } from 'react'; +import { BaseQueryFn, TypedUseQuery } from '@reduxjs/toolkit/query/react'; +import { ComponentProps, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { i18Namespace, Collections } from '@/shared/config'; +import { i18Namespace, Translation } from '@/shared/config'; +import { Response, useDebounce } from '@/shared/libs'; import { Dropdown, Option } from '@/shared/ui/Dropdown'; import { Flex } from '@/shared/ui/Flex'; import { Text } from '@/shared/ui/Text'; -import { useGetCollectionKeywordsQuery } from '../../api/collectionApi'; - export type KeywordSelectProps = Omit< - React.ComponentProps, + ComponentProps, 'options' | 'type' | 'value' | 'onChange' | 'children' > & { value?: string; onChange: (value?: string) => void; disabled?: boolean; + getKeywordsQuery: TypedUseQuery< + Response, + { + page?: number; + limit?: number; + title?: string; + }, + BaseQueryFn + >; }; -export const KeywordSelect = ({ value, onChange, disabled }: KeywordSelectProps) => { - const { t } = useTranslation(i18Namespace.collection); +export const KeywordSelect = ({ + value, + onChange, + disabled, + getKeywordsQuery, +}: KeywordSelectProps) => { + const { t } = useTranslation(i18Namespace.translation); const [searchValue, setSearchValue] = useState(value || ''); + const [debouncedValue, setDebouncedValue] = useState(''); + + const debouncedSetValue = useDebounce((value: string) => { + setDebouncedValue(value); + }, 500); - const { data: keywordsResponse } = useGetCollectionKeywordsQuery({ - limit: 100, - title: searchValue, + const { data: keywordsResponse } = getKeywordsQuery({ + limit: 10, + title: debouncedValue, }); const handleChange = (newValue?: string) => { @@ -33,11 +52,12 @@ export const KeywordSelect = ({ value, onChange, disabled }: KeywordSelectProps) const emptyKeyword = { value: '', - label: t(Collections.KEYWORD_PLACEHOLDER), + label: t(Translation.KEYWORD_PLACEHOLDER), }; const handleSearchChange = (val: string) => { setSearchValue(val); + debouncedSetValue(val); }; const onClearFilterValue = () => { @@ -58,13 +78,13 @@ export const KeywordSelect = ({ value, onChange, disabled }: KeywordSelectProps) }, [searchValue, keywordsResponse]); const selectedKeyword = options.find((option) => option.value === value) || emptyKeyword; - const notFoundText = t(Collections.KEYWORD_NOT_FOUND); + const notFoundText = t(Translation.KEYWORD_NOT_FOUND); const displayValue = options.length === 0 ? notFoundText : searchValue || selectedKeyword.label; return ( - {t(Collections.KEYWORD_LABEL)} + {t(Translation.KEYWORD_LABEL)} ) : ( diff --git a/src/shared/ui/KeywordSelect/index.ts b/src/shared/ui/KeywordSelect/index.ts new file mode 100644 index 000000000..0639c79d3 --- /dev/null +++ b/src/shared/ui/KeywordSelect/index.ts @@ -0,0 +1 @@ +export { KeywordSelect } from './KeywordSelect'; diff --git a/src/widgets/Landing/Avos/ui/AvosPromo/AvosPromo.skeleton.tsx b/src/widgets/Landing/Avos/ui/AvosPromo/AvosPromo.skeleton.tsx index 5322c4a00..aff54807e 100644 --- a/src/widgets/Landing/Avos/ui/AvosPromo/AvosPromo.skeleton.tsx +++ b/src/widgets/Landing/Avos/ui/AvosPromo/AvosPromo.skeleton.tsx @@ -39,7 +39,7 @@ export const AvosPromoSkeleton = () => { /> - + ); From 03380bbb851a1f961a9024f1fa98a5f0de8d93cd Mon Sep 17 00:00:00 2001 From: Anton Kovalev Date: Mon, 22 Dec 2025 18:01:31 +0500 Subject: [PATCH 05/10] YH-1453: Add users rating widget with mock API (#1043) * YH-1453: Add users rating widget with mock API * YH-1453: refactor(analytics): update users rating API response and relocate users rating to user entity * YH-1453: fix(user): add cross imports from specialization to user entity * YH-1453: hide widget --------- Co-authored-by: Denis Pereloma Co-authored-by: PerelomaDenis <88330372+PerelomaDenis@users.noreply.github.com> --- public/locales/en/analytics.json | 8 +++ public/locales/ru/analytics.json | 8 +++ src/app/msw/handlers.ts | 2 + src/entities/specialization/@x/user.ts | 2 + .../user/__mocks__/data/usersRatingMock.ts | 34 ++++++++++ src/entities/user/__mocks__/index.ts | 3 + .../usersRatingBySpecializationMock.ts | 20 ++++++ src/entities/user/api/usersRatingApi.ts | 18 +++++ src/entities/user/index.ts | 3 + .../model/constants/usersRatingConstants.ts | 3 + src/entities/user/model/types/usersRating.ts | 16 +++++ .../AnalyticsPage/AnalyticsPage.skeleton.tsx | 10 ++- .../ui/AnalyticsPage/AnalyticsPage.tsx | 10 ++- src/shared/assets/images/firstPlace.png | Bin 0 -> 2582 bytes src/shared/assets/images/index.ts | 3 + src/shared/assets/images/secondPlace.png | Bin 0 -> 2448 bytes src/shared/assets/images/thirdPlace.png | Bin 0 -> 2740 bytes src/shared/config/i18n/i18nTranslations.ts | 2 + src/shared/config/query/apiTags.ts | 1 + src/shared/ui/Avatar/Avatar.skeleton.tsx | 28 ++++++++ src/shared/ui/Avatar/index.ts | 1 + .../analytics/UsersRatingWidget/index.ts | 2 + .../model/constants/index.ts | 9 +++ .../AvatarWithRating.module.css | 27 ++++++++ .../AvatarWithRating.skeleton.tsx | 23 +++++++ .../ui/AvatarWithRating/AvatarWithRating.tsx | 62 ++++++++++++++++++ .../UserRatingItem/UserRatingItem.module.css | 14 ++++ .../UserRatingItem.skeleton.tsx | 33 ++++++++++ .../ui/UserRatingItem/UserRatingItem.tsx | 60 +++++++++++++++++ .../UsersRatingWidget.module.css | 23 +++++++ .../UsersRatingWidget.skeleton.tsx | 23 +++++++ .../UsersRatingWidget/UsersRatingWidget.tsx | 52 +++++++++++++++ 32 files changed, 494 insertions(+), 6 deletions(-) create mode 100644 src/entities/specialization/@x/user.ts create mode 100644 src/entities/user/__mocks__/data/usersRatingMock.ts create mode 100644 src/entities/user/__mocks__/index.ts create mode 100644 src/entities/user/__mocks__/usersRatingBySpecializationMock.ts create mode 100644 src/entities/user/api/usersRatingApi.ts create mode 100644 src/entities/user/model/constants/usersRatingConstants.ts create mode 100644 src/entities/user/model/types/usersRating.ts create mode 100644 src/shared/assets/images/firstPlace.png create mode 100644 src/shared/assets/images/secondPlace.png create mode 100644 src/shared/assets/images/thirdPlace.png create mode 100644 src/shared/ui/Avatar/Avatar.skeleton.tsx create mode 100644 src/widgets/analytics/UsersRatingWidget/index.ts create mode 100644 src/widgets/analytics/UsersRatingWidget/model/constants/index.ts create mode 100644 src/widgets/analytics/UsersRatingWidget/ui/AvatarWithRating/AvatarWithRating.module.css create mode 100644 src/widgets/analytics/UsersRatingWidget/ui/AvatarWithRating/AvatarWithRating.skeleton.tsx create mode 100644 src/widgets/analytics/UsersRatingWidget/ui/AvatarWithRating/AvatarWithRating.tsx create mode 100644 src/widgets/analytics/UsersRatingWidget/ui/UserRatingItem/UserRatingItem.module.css create mode 100644 src/widgets/analytics/UsersRatingWidget/ui/UserRatingItem/UserRatingItem.skeleton.tsx create mode 100644 src/widgets/analytics/UsersRatingWidget/ui/UserRatingItem/UserRatingItem.tsx create mode 100644 src/widgets/analytics/UsersRatingWidget/ui/UsersRatingWidget/UsersRatingWidget.module.css create mode 100644 src/widgets/analytics/UsersRatingWidget/ui/UsersRatingWidget/UsersRatingWidget.skeleton.tsx create mode 100644 src/widgets/analytics/UsersRatingWidget/ui/UsersRatingWidget/UsersRatingWidget.tsx diff --git a/public/locales/en/analytics.json b/public/locales/en/analytics.json index 3a914637c..19471f395 100644 --- a/public/locales/en/analytics.json +++ b/public/locales/en/analytics.json @@ -69,5 +69,13 @@ "keywords": "Keywords", "count": "Count" } + }, + "top.users": { + "title": { + "widget": "Top 3 users by speciality" + }, + "no.data": { + "widget": "No top users found for this speciality" + } } } diff --git a/public/locales/ru/analytics.json b/public/locales/ru/analytics.json index b40b2d10a..5add29827 100644 --- a/public/locales/ru/analytics.json +++ b/public/locales/ru/analytics.json @@ -70,5 +70,13 @@ "keywords": "Слова", "count": "Количество" } + }, + "top.users": { + "title": { + "widget": "Топ 3 участника по специальности" + }, + "no.data": { + "widget": "В топе по этой специальности пока никого нет" + } } } diff --git a/src/app/msw/handlers.ts b/src/app/msw/handlers.ts index b12b9d5e8..1e5f4b48b 100644 --- a/src/app/msw/handlers.ts +++ b/src/app/msw/handlers.ts @@ -10,6 +10,7 @@ import { resourceHandlers } from '@/entities/resource'; import { skillHandlers } from '@/entities/skill'; import { specializationHandlers } from '@/entities/specialization'; import { specializationsProgressHandlers } from '@/entities/specialization'; +import { usersRatingHandlers } from '@/entities/user'; import { questionCreateHandlers } from '@/features/question/createQuestion'; import { questionDeleteHandlers } from '@/features/question/deleteQuestion'; @@ -48,5 +49,6 @@ export const handlers = [ ...difficultQuestionsHandler, ...learnedQuestionHandlers, ...specializationsProgressHandlers, + ...usersRatingHandlers, ...resourceHandlers, ]; diff --git a/src/entities/specialization/@x/user.ts b/src/entities/specialization/@x/user.ts new file mode 100644 index 000000000..accbe2076 --- /dev/null +++ b/src/entities/specialization/@x/user.ts @@ -0,0 +1,2 @@ +export type { Specialization } from '../model/types/specialization'; +export { specializationsMock } from '../api/__mocks__/data/specializationsMock'; diff --git a/src/entities/user/__mocks__/data/usersRatingMock.ts b/src/entities/user/__mocks__/data/usersRatingMock.ts new file mode 100644 index 000000000..89bcba7bf --- /dev/null +++ b/src/entities/user/__mocks__/data/usersRatingMock.ts @@ -0,0 +1,34 @@ +import { specializationsMock, type Specialization } from '@/entities/specialization/@x/user'; + +import type { UserRating, UsersRatingBySpecialization } from '../../model/types/usersRating'; + +const specializations: Specialization[] = specializationsMock.data; + +const usersRating: UserRating[] = [ + { + userId: '1', + username: 'Christopher', + avatarUrl: 'https://mockmind-api.uifaces.co/content/human/222.jpg', + ratingScore: 1100, + }, + { + userId: '2', + username: 'Anna', + avatarUrl: 'http://mockmind-api.uifaces.co/content/human/219.jpg', + ratingScore: 900, + }, + { + userId: '3', + username: 'Alexander', + avatarUrl: 'https://mockmind-api.uifaces.co/content/human/80.jpg', + ratingScore: 700, + }, +]; + +export const usersRatingMock: UsersRatingBySpecialization[] = specializations.map( + (specialization) => ({ + specialization, + questionsCount: 1200, + users: usersRating, + }), +); diff --git a/src/entities/user/__mocks__/index.ts b/src/entities/user/__mocks__/index.ts new file mode 100644 index 000000000..66c4479e7 --- /dev/null +++ b/src/entities/user/__mocks__/index.ts @@ -0,0 +1,3 @@ +import { usersRatingBySpecializationMock } from './usersRatingBySpecializationMock'; + +export const usersRatingHandlers = [usersRatingBySpecializationMock]; diff --git a/src/entities/user/__mocks__/usersRatingBySpecializationMock.ts b/src/entities/user/__mocks__/usersRatingBySpecializationMock.ts new file mode 100644 index 000000000..ae881e1bd --- /dev/null +++ b/src/entities/user/__mocks__/usersRatingBySpecializationMock.ts @@ -0,0 +1,20 @@ +import { DefaultBodyType, http, HttpResponse } from 'msw'; + +import { usersRatingApiUrls } from '../model/constants/usersRatingConstants'; +import type { UsersRatingBySpecialization } from '../model/types/usersRating'; + +import { usersRatingMock } from './data/usersRatingMock'; + +export const usersRatingBySpecializationMock = http.get< + Record, + DefaultBodyType, + UsersRatingBySpecialization +>(`${process.env.API_URL}${usersRatingApiUrls.getUsersRatingBySpecialization}`, ({ params }) => { + const { specializationId } = params; + + const data = usersRatingMock.find( + ({ specialization }) => specialization.id === Number(specializationId), + ); + + return HttpResponse.json(data); +}); diff --git a/src/entities/user/api/usersRatingApi.ts b/src/entities/user/api/usersRatingApi.ts new file mode 100644 index 000000000..3bf6e20e9 --- /dev/null +++ b/src/entities/user/api/usersRatingApi.ts @@ -0,0 +1,18 @@ +import { ApiTags, baseApi } from '@/shared/config'; +import { route } from '@/shared/libs'; + +import { usersRatingApiUrls } from '../model/constants/usersRatingConstants'; +import type { GetUsersRatingBySpecializationResponse } from '../model/types/usersRating'; + +const usersRatingApi = baseApi.injectEndpoints({ + endpoints: (build) => ({ + getUsersRatingBySpecialization: build.query({ + query: (specializationId) => ({ + url: route(usersRatingApiUrls.getUsersRatingBySpecialization, specializationId), + }), + providesTags: [ApiTags.USERS_RATING], + }), + }), +}); + +export const { useGetUsersRatingBySpecializationQuery } = usersRatingApi; diff --git a/src/entities/user/index.ts b/src/entities/user/index.ts index 8854ea77d..9c55a5793 100644 --- a/src/entities/user/index.ts +++ b/src/entities/user/index.ts @@ -20,3 +20,6 @@ export { UserRolesList } from './ui/UserRolesList/UserRolesList'; export { UserSelect } from './ui/UserSelect/UserSelect'; export { UserSelectSkeleton } from './ui/UserSelect/UserSelect.skeleton'; export { convertRoleNameToEnumKey } from './model/utils/convertRoleNameToEnumKey/convertRoleNameToEnumKey'; +export { useGetUsersRatingBySpecializationQuery } from './api/usersRatingApi'; +export type { UserRating } from './model/types/usersRating'; +export { usersRatingHandlers } from './__mocks__'; diff --git a/src/entities/user/model/constants/usersRatingConstants.ts b/src/entities/user/model/constants/usersRatingConstants.ts new file mode 100644 index 000000000..f93a88394 --- /dev/null +++ b/src/entities/user/model/constants/usersRatingConstants.ts @@ -0,0 +1,3 @@ +export const usersRatingApiUrls = { + getUsersRatingBySpecialization: 'questions-stats/users-rating/:specializationId', +}; diff --git a/src/entities/user/model/types/usersRating.ts b/src/entities/user/model/types/usersRating.ts new file mode 100644 index 000000000..8facec97f --- /dev/null +++ b/src/entities/user/model/types/usersRating.ts @@ -0,0 +1,16 @@ +import type { Specialization } from '@/entities/specialization/@x/user'; + +export interface UserRating { + userId: string; + username: string; + avatarUrl: string; + ratingScore: number; +} + +export type UsersRatingBySpecialization = { + specialization: Specialization; + questionsCount: number; + users: UserRating[]; +}; + +export type GetUsersRatingBySpecializationResponse = UsersRatingBySpecialization; diff --git a/src/pages/analytics/analytics/ui/AnalyticsPage/AnalyticsPage.skeleton.tsx b/src/pages/analytics/analytics/ui/AnalyticsPage/AnalyticsPage.skeleton.tsx index 86e74ee9b..58548c574 100644 --- a/src/pages/analytics/analytics/ui/AnalyticsPage/AnalyticsPage.skeleton.tsx +++ b/src/pages/analytics/analytics/ui/AnalyticsPage/AnalyticsPage.skeleton.tsx @@ -6,15 +6,19 @@ import { PopularQuestionsWidgetSkeleton } from '@/widgets/analytics/PopularQuest import { PopularSkillsWidgetSkeleton } from '@/widgets/analytics/PopularSkillsWidget'; import { SkillsProficiencyWidgetSkeleton } from '@/widgets/analytics/SkillsProficiencyWidget'; import { SpecializationProgressWidgetSkeleton } from '@/widgets/analytics/SpecializationProgressWidget'; +import { UsersRatingWidgetSkeleton } from '@/widgets/analytics/UsersRatingWidget'; export const AnalyticsPageSkeleton = () => { const { isSmallScreen, isLaptop, isTablet } = useScreenSize(); return ( - - - + + + + + + diff --git a/src/pages/analytics/analytics/ui/AnalyticsPage/AnalyticsPage.tsx b/src/pages/analytics/analytics/ui/AnalyticsPage/AnalyticsPage.tsx index 29438f72b..36895444a 100644 --- a/src/pages/analytics/analytics/ui/AnalyticsPage/AnalyticsPage.tsx +++ b/src/pages/analytics/analytics/ui/AnalyticsPage/AnalyticsPage.tsx @@ -12,6 +12,7 @@ import { PopularQuestionsWidget } from '@/widgets/analytics/PopularQuestionsWidg import { PopularSkillsWidget } from '@/widgets/analytics/PopularSkillsWidget'; import { SkillsProficiencyWidget } from '@/widgets/analytics/SkillsProficiencyWidget'; import { SpecializationProgressWidget } from '@/widgets/analytics/SpecializationProgressWidget'; +// import { UsersRatingWidget } from '@/widgets/analytics/UsersRatingWidget'; export const AnalyticsPage = () => { const { isSmallScreen, isLaptop, isTablet } = useScreenSize(); @@ -26,9 +27,12 @@ export const AnalyticsPage = () => { return ( - - - + + {/**/} + + + + diff --git a/src/shared/assets/images/firstPlace.png b/src/shared/assets/images/firstPlace.png new file mode 100644 index 0000000000000000000000000000000000000000..fb422dbf04be559b4518f6760c1ad2e0aedf318b GIT binary patch literal 2582 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 literal 0 HcmV?d00001 diff --git a/src/shared/assets/images/index.ts b/src/shared/assets/images/index.ts index 1458bdbe1..f2a071983 100644 --- a/src/shared/assets/images/index.ts +++ b/src/shared/assets/images/index.ts @@ -15,3 +15,6 @@ export { default as notFound } from './notFound.avif'; export { default as questionImg } from './questionImg.avif'; export { default as quizCardPreview } from './quizCardPreview.avif'; export { default as searchPage } from './searchPage.png'; +export { default as firstPlaceIcon } from './firstPlace.png'; +export { default as secondPlaceIcon } from './secondPlace.png'; +export { default as thirdPlaceIcon } from './thirdPlace.png'; diff --git a/src/shared/assets/images/secondPlace.png b/src/shared/assets/images/secondPlace.png new file mode 100644 index 0000000000000000000000000000000000000000..72a4eeb50cc89517dae800e03cae1063123a0399 GIT binary patch literal 2448 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 literal 0 HcmV?d00001 diff --git a/src/shared/config/i18n/i18nTranslations.ts b/src/shared/config/i18n/i18nTranslations.ts index 8b39c54da..b18186c0c 100644 --- a/src/shared/config/i18n/i18nTranslations.ts +++ b/src/shared/config/i18n/i18nTranslations.ts @@ -470,6 +470,8 @@ export enum Analytics { HH_ANALYTICS_TABLE_SKILLS = 'hhAnalytics.table.skills', HH_ANALYTICS_TABLE_KEYWORDS = 'hhAnalytics.table.keywords', HH_ANALYTICS_TABLE_COUNT = 'hhAnalytics.table.count', + TOP_USERS_TITLE_WIDGET = 'top.users.title.widget', + TOP_USERS_NO_DATA_WIDGET = 'top.users.no.data.widget', } export enum InterviewQuiz { diff --git a/src/shared/config/query/apiTags.ts b/src/shared/config/query/apiTags.ts index b903bc707..b0966559d 100644 --- a/src/shared/config/query/apiTags.ts +++ b/src/shared/config/query/apiTags.ts @@ -35,6 +35,7 @@ export enum ApiTags { POPULAR_QUESTIONS = 'questions_popular', SPECIALIZATIONS_GENERAL_PROGRESS = 'general_progress', TOPICS = 'topics', + USERS_RATING = 'users_rating', TOPICS_DETAIL = 'topics_detail', TOPIC = 'topic', } diff --git a/src/shared/ui/Avatar/Avatar.skeleton.tsx b/src/shared/ui/Avatar/Avatar.skeleton.tsx new file mode 100644 index 000000000..3574a5723 --- /dev/null +++ b/src/shared/ui/Avatar/Avatar.skeleton.tsx @@ -0,0 +1,28 @@ +import classNames from 'classnames'; + +import { Skeleton } from '../Skeleton'; + +import styles from './Avatar.module.css'; + +interface AvatarSkeletonProps { + size?: number; + className?: string; + borderRadius?: number; +} + +export const AvatarSkeleton = ({ + size = 50, + borderRadius = 25, + className, + ...props +}: AvatarSkeletonProps) => { + return ( +
    + +
    + ); +}; diff --git a/src/shared/ui/Avatar/index.ts b/src/shared/ui/Avatar/index.ts index d3fb6dfa7..f43aeaef4 100644 --- a/src/shared/ui/Avatar/index.ts +++ b/src/shared/ui/Avatar/index.ts @@ -1 +1,2 @@ export { Avatar } from './Avatar'; +export { AvatarSkeleton } from './Avatar.skeleton'; diff --git a/src/widgets/analytics/UsersRatingWidget/index.ts b/src/widgets/analytics/UsersRatingWidget/index.ts new file mode 100644 index 000000000..ba4b173a8 --- /dev/null +++ b/src/widgets/analytics/UsersRatingWidget/index.ts @@ -0,0 +1,2 @@ +export { UsersRatingWidget } from './ui/UsersRatingWidget/UsersRatingWidget'; +export { UsersRatingWidgetSkeleton } from './ui/UsersRatingWidget/UsersRatingWidget.skeleton'; diff --git a/src/widgets/analytics/UsersRatingWidget/model/constants/index.ts b/src/widgets/analytics/UsersRatingWidget/model/constants/index.ts new file mode 100644 index 000000000..973babb72 --- /dev/null +++ b/src/widgets/analytics/UsersRatingWidget/model/constants/index.ts @@ -0,0 +1,9 @@ +import { firstPlaceIcon, secondPlaceIcon, thirdPlaceIcon } from '@/shared/assets'; + +export const AVATAR_RADII = { 1: 56, 2: 44.5, 3: 39 }; +export const PLACE_ICONS = { + 1: firstPlaceIcon, + 2: secondPlaceIcon, + 3: thirdPlaceIcon, +}; +export const TOP_PLACES_COUNT = 3; diff --git a/src/widgets/analytics/UsersRatingWidget/ui/AvatarWithRating/AvatarWithRating.module.css b/src/widgets/analytics/UsersRatingWidget/ui/AvatarWithRating/AvatarWithRating.module.css new file mode 100644 index 000000000..03b362517 --- /dev/null +++ b/src/widgets/analytics/UsersRatingWidget/ui/AvatarWithRating/AvatarWithRating.module.css @@ -0,0 +1,27 @@ +.avatar-with-rating { + position: relative; + display: inline-block; +} + +.avatar-image { + position: absolute; + top: 50%; + left: 50%; + border-style: solid; + border-color: var(--color-white-900); + border-radius: 50%; + transform: translate(-50%, -50%); + object-fit: cover; +} + +.empty-circle { + stroke: var(--color-purple-100); + fill: transparent; +} + +.filled-circle { + stroke: var(--color-purple-700); + stroke-linecap: round; + fill: transparent; + transition: 'stroke-dashoffset 0.3s' +} diff --git a/src/widgets/analytics/UsersRatingWidget/ui/AvatarWithRating/AvatarWithRating.skeleton.tsx b/src/widgets/analytics/UsersRatingWidget/ui/AvatarWithRating/AvatarWithRating.skeleton.tsx new file mode 100644 index 000000000..0697c032d --- /dev/null +++ b/src/widgets/analytics/UsersRatingWidget/ui/AvatarWithRating/AvatarWithRating.skeleton.tsx @@ -0,0 +1,23 @@ +import classNames from 'classnames'; + +import { AvatarSkeleton } from '@/shared/ui/Avatar'; + +import styles from './AvatarWithRating.module.css'; + +interface AvatarWithRatingProps { + radius: number; + className?: string; +} + +export const AvatarWithRatingSkeleton = ({ radius, className }: AvatarWithRatingProps) => { + const size = radius * 2; + + return ( +
    + +
    + ); +}; diff --git a/src/widgets/analytics/UsersRatingWidget/ui/AvatarWithRating/AvatarWithRating.tsx b/src/widgets/analytics/UsersRatingWidget/ui/AvatarWithRating/AvatarWithRating.tsx new file mode 100644 index 000000000..0d46ef3ab --- /dev/null +++ b/src/widgets/analytics/UsersRatingWidget/ui/AvatarWithRating/AvatarWithRating.tsx @@ -0,0 +1,62 @@ +import classNames from 'classnames'; + +import { Avatar } from '@/shared/ui/Avatar'; + +import styles from './AvatarWithRating.module.css'; + +interface AvatarWithRatingProps { + avatarUrl: string; + score: number; + radius: number; + maxRating: number; + className?: string; +} + +export const AvatarWithRating = ({ + avatarUrl, + score, + maxRating, + radius, + className, +}: AvatarWithRatingProps) => { + const size = radius * 2; + const strokeWidth = radius * 0.12; + const circleRadius = radius - strokeWidth / 2; + + const circumference = 2 * Math.PI * circleRadius; + const progress = Math.min(1, Math.max(0, score / maxRating)); + const offset = circumference * (1 - progress); + + return ( +
    + + + + + +
    + ); +}; diff --git a/src/widgets/analytics/UsersRatingWidget/ui/UserRatingItem/UserRatingItem.module.css b/src/widgets/analytics/UsersRatingWidget/ui/UserRatingItem/UserRatingItem.module.css new file mode 100644 index 000000000..acbda93d8 --- /dev/null +++ b/src/widgets/analytics/UsersRatingWidget/ui/UserRatingItem/UserRatingItem.module.css @@ -0,0 +1,14 @@ +.user-rating { + padding: 12px 4px; + width: 100%; +} + +.user-name { + max-width: 87px; +} + +@media (width < 480px) { + .user-name { + max-width: 58px; + } +} diff --git a/src/widgets/analytics/UsersRatingWidget/ui/UserRatingItem/UserRatingItem.skeleton.tsx b/src/widgets/analytics/UsersRatingWidget/ui/UserRatingItem/UserRatingItem.skeleton.tsx new file mode 100644 index 000000000..e630b70f4 --- /dev/null +++ b/src/widgets/analytics/UsersRatingWidget/ui/UserRatingItem/UserRatingItem.skeleton.tsx @@ -0,0 +1,33 @@ +import { useScreenSize } from '@/shared/libs'; +import { CardSkeleton } from '@/shared/ui/Card'; +import { Flex } from '@/shared/ui/Flex'; +import { TextSkeleton } from '@/shared/ui/Text'; + +import { AVATAR_RADII } from '../../model/constants'; +import { AvatarWithRatingSkeleton } from '../AvatarWithRating/AvatarWithRating.skeleton'; + +import styles from './UserRatingItem.module.css'; + +interface UserRatingItemPropsSkeleton { + place: 1 | 2 | 3; +} + +export const UserRatingItemSkeleton = ({ place }: UserRatingItemPropsSkeleton) => { + const { isMobileS } = useScreenSize(); + const itemWidth = isMobileS ? AVATAR_RADII[1] * 1.5 : AVATAR_RADII[1] * 2; + return ( + + + + + + + + + + + + ); +}; diff --git a/src/widgets/analytics/UsersRatingWidget/ui/UserRatingItem/UserRatingItem.tsx b/src/widgets/analytics/UsersRatingWidget/ui/UserRatingItem/UserRatingItem.tsx new file mode 100644 index 000000000..0a1366a0b --- /dev/null +++ b/src/widgets/analytics/UsersRatingWidget/ui/UserRatingItem/UserRatingItem.tsx @@ -0,0 +1,60 @@ +import { useRef } from 'react'; + +import { useScreenSize, useTruncation } from '@/shared/libs'; +import { Card } from '@/shared/ui/Card'; +import { Flex } from '@/shared/ui/Flex'; +import { Text } from '@/shared/ui/Text'; +import { Tooltip } from '@/shared/ui/Tooltip'; + +import type { UserRating } from '@/entities/user'; + +import { AVATAR_RADII, PLACE_ICONS } from '../../model/constants'; +import { AvatarWithRating } from '../AvatarWithRating/AvatarWithRating'; + +import styles from './UserRatingItem.module.css'; + +interface UserRatingItemProps { + userRating: UserRating; + place: 1 | 2 | 3; + questionsCount: number; +} + +export const UserRatingItem = ({ userRating, place, questionsCount }: UserRatingItemProps) => { + const { isMobileS } = useScreenSize(); + const itemWidth = isMobileS ? AVATAR_RADII[1] * 1.5 : AVATAR_RADII[1] * 2; + const nameRef = useRef(null); + const isTruncated = useTruncation(nameRef, 'row'); + return ( + + + + + + + + + place + + + {userRating.username} + + + + + {userRating.ratingScore}/{questionsCount} + + + + + ); +}; diff --git a/src/widgets/analytics/UsersRatingWidget/ui/UsersRatingWidget/UsersRatingWidget.module.css b/src/widgets/analytics/UsersRatingWidget/ui/UsersRatingWidget/UsersRatingWidget.module.css new file mode 100644 index 000000000..cfc49d39f --- /dev/null +++ b/src/widgets/analytics/UsersRatingWidget/ui/UsersRatingWidget/UsersRatingWidget.module.css @@ -0,0 +1,23 @@ +.card { + gap: 20px; + width: 100%; + max-width: 455px; +} + +.wrapper { + width: 112px; +} + +@media (width < 1280px) { + .card { + padding: 16px; + max-width: 100%; + } +} + +@media (width < 768px) { + .card { + padding: 20px 10px; + max-width: 100%; + } +} diff --git a/src/widgets/analytics/UsersRatingWidget/ui/UsersRatingWidget/UsersRatingWidget.skeleton.tsx b/src/widgets/analytics/UsersRatingWidget/ui/UsersRatingWidget/UsersRatingWidget.skeleton.tsx new file mode 100644 index 000000000..66b86bc30 --- /dev/null +++ b/src/widgets/analytics/UsersRatingWidget/ui/UsersRatingWidget/UsersRatingWidget.skeleton.tsx @@ -0,0 +1,23 @@ +import { CardSkeleton } from '@/shared/ui/Card'; +import { Flex } from '@/shared/ui/Flex'; + +import { UserRatingItemSkeleton } from '../UserRatingItem/UserRatingItem.skeleton'; + +import styles from './UsersRatingWidget.module.css'; + +export const UsersRatingWidgetSkeleton = () => { + return ( + + + {[...Array(3)].map((_, i) => ( + + ))} + + + ); +}; diff --git a/src/widgets/analytics/UsersRatingWidget/ui/UsersRatingWidget/UsersRatingWidget.tsx b/src/widgets/analytics/UsersRatingWidget/ui/UsersRatingWidget/UsersRatingWidget.tsx new file mode 100644 index 000000000..b661f7481 --- /dev/null +++ b/src/widgets/analytics/UsersRatingWidget/ui/UsersRatingWidget/UsersRatingWidget.tsx @@ -0,0 +1,52 @@ +import { useTranslation } from 'react-i18next'; + +import { i18Namespace, Analytics } from '@/shared/config'; +import { useAppSelector } from '@/shared/libs'; +import { Card } from '@/shared/ui/Card'; +import { Flex } from '@/shared/ui/Flex'; +import { Text } from '@/shared/ui/Text'; + +import { getSpecializationId } from '@/entities/profile'; +import { useGetUsersRatingBySpecializationQuery } from '@/entities/user'; + +import { TOP_PLACES_COUNT } from '../../model/constants'; +import { UserRatingItem } from '../UserRatingItem/UserRatingItem'; + +import styles from './UsersRatingWidget.module.css'; +import { UsersRatingWidgetSkeleton } from './UsersRatingWidget.skeleton'; + +export const UsersRatingWidget = () => { + const { t } = useTranslation(i18Namespace.analytics); + const specializationId = String(useAppSelector(getSpecializationId)); + const { data, isLoading } = useGetUsersRatingBySpecializationQuery(specializationId); + const topUsers = data?.users.slice(0, TOP_PLACES_COUNT) || []; + const topUsersIsEmpty = topUsers.length === 0; + const specialization = data?.specialization; + const questionsCount = data?.questionsCount ?? 0; + + if (isLoading) return ; + + return ( + + {!topUsersIsEmpty ? ( + + {topUsers.map((data, i) => ( + + ))} + + ) : ( + {t(Analytics.TOP_USERS_NO_DATA_WIDGET)} + )} + + ); +}; From ba3b5eb95ac367c6d0461d2120cfb24fe2bda9b3 Mon Sep 17 00:00:00 2001 From: crouch365 Date: Mon, 22 Dec 2025 18:33:26 +0500 Subject: [PATCH 06/10] YH-1549: added a new display of the general checkbox (#1056) --- src/shared/ui/Checkbox/Checkbox.module.css | 12 ++++++++++++ src/shared/ui/Checkbox/Checkbox.tsx | 21 ++++++++++++++++++--- src/shared/ui/Checkbox/types.ts | 1 + src/shared/ui/Table/Table.tsx | 9 ++++++++- 4 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/shared/ui/Checkbox/Checkbox.module.css b/src/shared/ui/Checkbox/Checkbox.module.css index beeed8a26..6c050d9d4 100644 --- a/src/shared/ui/Checkbox/Checkbox.module.css +++ b/src/shared/ui/Checkbox/Checkbox.module.css @@ -38,6 +38,18 @@ transform: translate(-50%, -50%); content: "\2714" } + +.checkbox:indeterminate::after { + content: ""; + position: absolute; + top: 50%; + left: 2px; + width: 14px; + height: 1.5px; + border-radius: 1.5px; + background-color: var(--color-white-900); + transform: translateY(-50%); +} .checkbox:disabled { border-color: var(--color-black-500); diff --git a/src/shared/ui/Checkbox/Checkbox.tsx b/src/shared/ui/Checkbox/Checkbox.tsx index ffacbbb96..a148e122d 100644 --- a/src/shared/ui/Checkbox/Checkbox.tsx +++ b/src/shared/ui/Checkbox/Checkbox.tsx @@ -1,5 +1,5 @@ import classnames from 'classnames'; -import { forwardRef } from 'react'; +import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'; import styles from './Checkbox.module.css'; import { CheckboxProps } from './types'; @@ -13,14 +13,29 @@ import { CheckboxProps } from './types'; */ export const Checkbox = forwardRef( - ({ label, className, ...props }, ref) => { + ({ label, className, isIntermediate = false, ...props }, ref) => { + const internalRef = useRef(null); + + useImperativeHandle(ref, () => internalRef.current!); + + useEffect(() => { + if (internalRef.current) { + internalRef.current.indeterminate = !!isIntermediate; + } + }, [isIntermediate]); + return ( ); diff --git a/src/shared/ui/Checkbox/types.ts b/src/shared/ui/Checkbox/types.ts index c4662634b..f6d0e5144 100644 --- a/src/shared/ui/Checkbox/types.ts +++ b/src/shared/ui/Checkbox/types.ts @@ -14,4 +14,5 @@ export interface CheckboxProps extends HTMLAttributes { className?: string; label?: ReactNode; checked?: boolean; + isIntermediate?: boolean; } diff --git a/src/shared/ui/Table/Table.tsx b/src/shared/ui/Table/Table.tsx index 290a08bf2..23ba7ea36 100644 --- a/src/shared/ui/Table/Table.tsx +++ b/src/shared/ui/Table/Table.tsx @@ -60,6 +60,9 @@ export const Table = >( const isAllSelected = selectedItems?.length === items.length && items.length > 0; const selectedItemsIds = selectedItems?.map(({ id }) => id) || []; + const allSelected = selectedItems?.length ?? 0; + const isIntermediate = allSelected > 0 && allSelected < items.length; + const onSelectAllItems = () => { onSelectItems?.(isAllSelected ? [] : items.map((item) => ({ id: item.id, title: item.title }))); }; @@ -86,7 +89,11 @@ export const Table = >( {selectedItems && ( - + )} {renderTableHeader()} From b4c4a1ae7da77bf13fd1fadad8f9ac3872f92ff8 Mon Sep 17 00:00:00 2001 From: Anushavan <53114844+AnushavanHarutyunyan@users.noreply.github.com> Date: Tue, 23 Dec 2025 11:42:06 +0400 Subject: [PATCH 07/10] YH-1594: Redesign tabs (#1051) * YH-1594: Redesign tabs * YH-1594: Deleted Tabs_Line all files * YH-1594: fixed colors Tabs * YH-1594: fix styles --------- Co-authored-by: Denis Pereloma --- .../ui/EditProfileForm/EditProfileForm.tsx | 2 +- src/shared/config/router/routes.ts | 4 +++ src/shared/ui/Flex/Flex.module.css | 2 +- src/shared/ui/Tabs/Tabs.module.css | 30 +++++++++++++------ src/shared/ui/Tabs/Tabs.skeleton.tsx | 2 +- src/shared/ui/Tabs/Tabs.tsx | 13 +++++--- 6 files changed, 37 insertions(+), 16 deletions(-) diff --git a/src/features/profile/editProfileForm/ui/EditProfileForm/EditProfileForm.tsx b/src/features/profile/editProfileForm/ui/EditProfileForm/EditProfileForm.tsx index 2bceb5bea..fdd40612b 100644 --- a/src/features/profile/editProfileForm/ui/EditProfileForm/EditProfileForm.tsx +++ b/src/features/profile/editProfileForm/ui/EditProfileForm/EditProfileForm.tsx @@ -53,7 +53,7 @@ export const EditProfileForm = () => { {t(Profile.EDIT_PAGE_TITLE)} - +
    diff --git a/src/shared/config/router/routes.ts b/src/shared/config/router/routes.ts index 1a61e5885..626aa51a0 100644 --- a/src/shared/config/router/routes.ts +++ b/src/shared/config/router/routes.ts @@ -130,6 +130,10 @@ export const ROUTES = { route: 'create', page: '/admin/topics/create', }, + edit: { + route: ':topicId/edit', + page: '/admin/topics/:topicId/edit', + }, details: { route: ':topicId', page: '/admin/topics/:topicId', diff --git a/src/shared/ui/Flex/Flex.module.css b/src/shared/ui/Flex/Flex.module.css index 41e061bfb..748f742da 100644 --- a/src/shared/ui/Flex/Flex.module.css +++ b/src/shared/ui/Flex/Flex.module.css @@ -144,4 +144,4 @@ .max-height { height: 100%; -} \ No newline at end of file +} diff --git a/src/shared/ui/Tabs/Tabs.module.css b/src/shared/ui/Tabs/Tabs.module.css index ebeef993f..d539ead1a 100644 --- a/src/shared/ui/Tabs/Tabs.module.css +++ b/src/shared/ui/Tabs/Tabs.module.css @@ -8,7 +8,6 @@ padding: 10px; overflow-x: auto; border-radius: 18px; - background-color: var(--background-block); list-style-type: none; scrollbar-width: none; } @@ -20,17 +19,30 @@ cursor: pointer; } -.tab-item.active { - position: relative; - background-color:var(--background-app); -} - .tab-item:hover{ - background-color: var(--background-button); - color:var(--border-image-hover); + color: var(--border-image-hover); transition: all .3s ease-in-out; + cursor: pointer; +} + +.tab-item.default:hover, +.tab-item.default.active { + background-color: var(--background-app); +} + +.tab-item.gray:hover, +.tab-item.gray.active { + background-color: var(--background-block); } .tab-item:hover > p { color:var(--border-image-hover); -} \ No newline at end of file +} + +.default { + background-color: var(--background-block); +} + +.gray { + background-color: var(--background-light-hover); +} diff --git a/src/shared/ui/Tabs/Tabs.skeleton.tsx b/src/shared/ui/Tabs/Tabs.skeleton.tsx index 949357ddd..26084eb58 100644 --- a/src/shared/ui/Tabs/Tabs.skeleton.tsx +++ b/src/shared/ui/Tabs/Tabs.skeleton.tsx @@ -12,7 +12,7 @@ interface TabsSkeleton { export const TabsSkeleton = ({ tabs }: TabsSkeleton) => { return ( - + {tabs?.map((tab) => (
  • diff --git a/src/shared/ui/Tabs/Tabs.tsx b/src/shared/ui/Tabs/Tabs.tsx index 918a2b187..ab5aeea52 100644 --- a/src/shared/ui/Tabs/Tabs.tsx +++ b/src/shared/ui/Tabs/Tabs.tsx @@ -14,13 +14,16 @@ export interface Tab { Component: () => JSX.Element; } +type TabColor = 'default' | 'gray'; + export interface TabsProps { tabs: Tab[]; activeTab: Tab; + color?: TabColor; setActiveTab: Dispatch>>; } -export const Tabs = ({ tabs, activeTab, setActiveTab }: TabsProps) => { +export const Tabs = ({ tabs, activeTab, setActiveTab, color = 'default' }: TabsProps) => { const navigate = useNavigate(); const onTabToggle = (tab: Tab) => { @@ -32,8 +35,8 @@ export const Tabs = ({ tabs, activeTab, setActiveTab }: TabsProps) => { @@ -41,7 +44,9 @@ export const Tabs = ({ tabs, activeTab, setActiveTab }: TabsProps) => { // eslint-disable-next-line jsx-a11y/click-events-have-key-events
  • onTabToggle(tab)} role="tab" tabIndex={0} From 34989bd1e59b3162f0ef8c61f15dcbeadb21ae52 Mon Sep 17 00:00:00 2001 From: Daniil Leonov <142981918+Beyondofsoul@users.noreply.github.com> Date: Fri, 26 Dec 2025 00:54:32 +0300 Subject: [PATCH 08/10] YH-1539: Added filter button and Pagination (#1053) * YH-1539: Added filter button and Pagination * YH-1539: Fixed comments --- .../ui/SkillsListField/SkillsListField.tsx | 2 +- src/features/topic/filterTopics/index.ts | 2 + .../model/hooks/useGetTopicsFilterParams.ts | 14 ++++++ .../model/hooks/useTopicsFilters.ts | 37 ++++++++++++++ .../topic/filterTopics/model/types/filters.ts | 5 ++ .../ui/TopicsFilters/TopicsFilters.tsx | 23 +++++++++ .../ui/TopicsPage/TopicsPage.module.css | 18 +++++++ .../topic/topics/ui/TopicsPage/TopicsPage.tsx | 48 ++++++++++++++----- 8 files changed, 136 insertions(+), 13 deletions(-) create mode 100644 src/features/topic/filterTopics/index.ts create mode 100644 src/features/topic/filterTopics/model/hooks/useGetTopicsFilterParams.ts create mode 100644 src/features/topic/filterTopics/model/hooks/useTopicsFilters.ts create mode 100644 src/features/topic/filterTopics/model/types/filters.ts create mode 100644 src/features/topic/filterTopics/ui/TopicsFilters/TopicsFilters.tsx create mode 100644 src/pages/admin/topic/topics/ui/TopicsPage/TopicsPage.module.css diff --git a/src/entities/skill/ui/SkillsListField/SkillsListField.tsx b/src/entities/skill/ui/SkillsListField/SkillsListField.tsx index 47cda5fad..0a4332795 100644 --- a/src/entities/skill/ui/SkillsListField/SkillsListField.tsx +++ b/src/entities/skill/ui/SkillsListField/SkillsListField.tsx @@ -13,7 +13,7 @@ import { MAX_SHOW_LIMIT_SKILLS } from '../../model/constants/skillConstants'; interface SkillsListFieldProps { selectedSkills?: number[]; onChangeSkills: (skills: number[] | undefined) => void; - selectedSpecialization: number; + selectedSpecialization?: number; showAllLabel?: boolean; } diff --git a/src/features/topic/filterTopics/index.ts b/src/features/topic/filterTopics/index.ts new file mode 100644 index 000000000..f3cb4a59e --- /dev/null +++ b/src/features/topic/filterTopics/index.ts @@ -0,0 +1,2 @@ +export { TopicsFilters } from './ui/TopicsFilters/TopicsFilters'; +export { useTopicsFilters } from './model/hooks/useTopicsFilters'; diff --git a/src/features/topic/filterTopics/model/hooks/useGetTopicsFilterParams.ts b/src/features/topic/filterTopics/model/hooks/useGetTopicsFilterParams.ts new file mode 100644 index 000000000..9f5f8daf9 --- /dev/null +++ b/src/features/topic/filterTopics/model/hooks/useGetTopicsFilterParams.ts @@ -0,0 +1,14 @@ +import { TopicsFilterParams } from '../types/filters'; + +export const useGetTopicsFilterParams = (initialParams: TopicsFilterParams) => { + const params = new URLSearchParams(location.search); + const parsedParams = Object.fromEntries(params.entries()); + + const currentParams: TopicsFilterParams = { + page: parsedParams.page ? Number(parsedParams.page) : initialParams.page, + title: parsedParams.title || initialParams.title, + skillIds: parsedParams.skillIds ? parsedParams.skillIds.split(',').map(Number) : undefined, + }; + + return currentParams; +}; diff --git a/src/features/topic/filterTopics/model/hooks/useTopicsFilters.ts b/src/features/topic/filterTopics/model/hooks/useTopicsFilters.ts new file mode 100644 index 000000000..e198a6afe --- /dev/null +++ b/src/features/topic/filterTopics/model/hooks/useTopicsFilters.ts @@ -0,0 +1,37 @@ +import { useQueryFilterParams } from '@/shared/libs'; + +import { TopicsFilterParams } from '../types/filters'; + +import { useGetTopicsFilterParams } from './useGetTopicsFilterParams'; + +export const useTopicsFilters = (initialParams: TopicsFilterParams) => { + const currentParams = useGetTopicsFilterParams(initialParams); + const { filters, onFilterChange, onResetFilters } = useQueryFilterParams( + initialParams, + currentParams, + ); + + const hasFilters = + (filters.page || 1) > 1 || Boolean(filters.title) || (filters.skillIds || []).length > 0; + + const onChangeTitle = (title: TopicsFilterParams['title']) => { + onFilterChange({ title, page: 1 }); + }; + + const onChangeSkillIds = (skillIds: TopicsFilterParams['skillIds']) => { + onFilterChange({ skillIds, page: 1 }); + }; + + const onChangePage = (page: TopicsFilterParams['page']) => { + onFilterChange({ page }); + }; + + return { + filters, + hasFilters, + onResetFilters, + onChangeTitle, + onChangeSkillIds, + onChangePage, + }; +}; diff --git a/src/features/topic/filterTopics/model/types/filters.ts b/src/features/topic/filterTopics/model/types/filters.ts new file mode 100644 index 000000000..60ca30466 --- /dev/null +++ b/src/features/topic/filterTopics/model/types/filters.ts @@ -0,0 +1,5 @@ +export interface TopicsFilterParams { + title?: string; + page?: number; + skillIds?: number[]; +} diff --git a/src/features/topic/filterTopics/ui/TopicsFilters/TopicsFilters.tsx b/src/features/topic/filterTopics/ui/TopicsFilters/TopicsFilters.tsx new file mode 100644 index 000000000..7924b9738 --- /dev/null +++ b/src/features/topic/filterTopics/ui/TopicsFilters/TopicsFilters.tsx @@ -0,0 +1,23 @@ +import { Flex } from '@/shared/ui/Flex'; + +import { SkillsListField } from '@/entities/skill'; + +import { TopicsFilterParams } from '../../model/types/filters'; + +interface TopicsFiltersProps { + filters: TopicsFilterParams; + onChangeSkillIds: (skillIds?: TopicsFilterParams['skillIds']) => void; +} + +export const TopicsFilters = ({ filters, onChangeSkillIds }: TopicsFiltersProps) => { + const { skillIds } = filters; + + return ( + + onChangeSkillIds(skills)} + /> + + ); +}; diff --git a/src/pages/admin/topic/topics/ui/TopicsPage/TopicsPage.module.css b/src/pages/admin/topic/topics/ui/TopicsPage/TopicsPage.module.css new file mode 100644 index 000000000..43934b747 --- /dev/null +++ b/src/pages/admin/topic/topics/ui/TopicsPage/TopicsPage.module.css @@ -0,0 +1,18 @@ +.main { + flex-grow: 1; + height: auto; +} + +.filters { + position: sticky; + top: 0; + min-width: 360px; + max-width: 360px; + height: auto; +} + +@media (width < 1280px) { + .filters { + display: none; + } +} diff --git a/src/pages/admin/topic/topics/ui/TopicsPage/TopicsPage.tsx b/src/pages/admin/topic/topics/ui/TopicsPage/TopicsPage.tsx index a8fc3f216..6eb6f4283 100644 --- a/src/pages/admin/topic/topics/ui/TopicsPage/TopicsPage.tsx +++ b/src/pages/admin/topic/topics/ui/TopicsPage/TopicsPage.tsx @@ -1,9 +1,12 @@ import { SelectedAdminEntities, useAppDispatch, useAppSelector } from '@/shared/libs'; import { Card } from '@/shared/ui/Card'; import { Flex } from '@/shared/ui/Flex'; +import { TablePagination } from '@/shared/ui/TablePagination'; import { useGetTopicsListQuery } from '@/entities/topic'; + +import { TopicsFilters, useTopicsFilters } from '@/features/topic/filterTopics'; import { DeleteTopicsButton } from '@/features/topics/deleteTopics'; import { SearchSection } from '@/widgets/SearchSection'; @@ -11,11 +14,18 @@ import { TopicsTable } from '@/widgets/topic/TopicsTable'; import { getSelectedTopics } from '../../model/selectors/topicsPageSelectors'; import { topicsPageActions } from '../../model/slices/topicsPageSlice'; - const TopicsPage = () => { const dispatch = useAppDispatch(); - const { data: topics } = useGetTopicsListQuery({ page: 1 }); + const { filters, hasFilters, onChangeTitle, onChangeSkillIds, onChangePage, onResetFilters } = + useTopicsFilters({ page: 1 }); + + const { data: topicsWithTitle } = useGetTopicsListQuery({ + page: filters.page, + limit: 10, + title: filters.title, + skillIds: filters.skillIds, + }); const selectedTopics = useAppSelector(getSelectedTopics); const onSelectTopics = (ids: SelectedAdminEntities) => { @@ -28,23 +38,37 @@ const TopicsPage = () => { return ( - 0} - renderRemoveButton={() => ( - clearSelectedTopics()} - /> - )} + } + onResetFilters={onResetFilters} + showResetFilterButton={hasFilters} + hasFilters={Boolean((filters.skillIds || []).length)} + showRemoveButton={selectedTopics.length > 0} + renderRemoveButton={() => ( + clearSelectedTopics()} + /> + )} +/> + /> clearSelectedTopics()} /> + ); From fc3c9e659af4bc96e1e1759875e5cba7c76eb612 Mon Sep 17 00:00:00 2001 From: Julia Gogonova Date: Sun, 21 Dec 2025 14:27:24 +0300 Subject: [PATCH 09/10] YH-1564: Add AnalyticsPageSkeleton component - Rebase branch onto latest develop - Fix merge conflict in DifficultQuestionsPage - Add skeleton loading to all analytics pages --- .../DifficultQuestionsPage.tsx | 25 +++- .../PopularQuestionsPage.tsx | 24 +++- .../PopularSkillsPage/PopularSkillsPage.tsx | 27 +++- .../ProgressSpecializationsPage.tsx | 24 +++- .../SkillsProficiencyPage.tsx | 24 +++- .../AnalyticsMobileListSkeleton.tsx | 34 +++++ .../AnalyticsPageSkeleton.module.css | 46 +++++++ .../AnalyticsPageSkeleton.tsx | 122 ++++++++++++++++++ .../AnalyticsTableSkeleton.tsx | 53 ++++++++ src/shared/ui/AnalyticsPageSkeleton/index.ts | 2 + 10 files changed, 372 insertions(+), 9 deletions(-) create mode 100644 src/shared/ui/AnalyticsPageSkeleton/AnalyticsMobileListSkeleton.tsx create mode 100644 src/shared/ui/AnalyticsPageSkeleton/AnalyticsPageSkeleton.module.css create mode 100644 src/shared/ui/AnalyticsPageSkeleton/AnalyticsPageSkeleton.tsx create mode 100644 src/shared/ui/AnalyticsPageSkeleton/AnalyticsTableSkeleton.tsx create mode 100644 src/shared/ui/AnalyticsPageSkeleton/index.ts diff --git a/src/pages/analytics/difficultQuestions/ui/DifficultQuestionsPage/DifficultQuestionsPage.tsx b/src/pages/analytics/difficultQuestions/ui/DifficultQuestionsPage/DifficultQuestionsPage.tsx index 7941fa3cf..aa0f36a27 100644 --- a/src/pages/analytics/difficultQuestions/ui/DifficultQuestionsPage/DifficultQuestionsPage.tsx +++ b/src/pages/analytics/difficultQuestions/ui/DifficultQuestionsPage/DifficultQuestionsPage.tsx @@ -1,7 +1,8 @@ import { useTranslation } from 'react-i18next'; import { i18Namespace, Analytics } from '@/shared/config'; -import { useAppSelector } from '@/shared/libs'; +import { useAppSelector, useScreenSize } from '@/shared/libs'; +import { AnalyticsPageSkeleton } from '@/shared/ui/AnalyticsPageSkeleton'; import { getSpecializationId } from '@/entities/profile'; import { useGetMostDifficultQuestionsBySpecializationIdQuery } from '@/entities/question'; @@ -13,6 +14,7 @@ import { DifficultQuestionsTable } from '../DifficultQuestionsTable/DifficultQue export const DifficultQuestionsPage = () => { const { t } = useTranslation(i18Namespace.analytics); + const { isMobile } = useScreenSize(); const specializationId = useAppSelector(getSpecializationId); const { filters, onChangeSpecialization, onChangePage } = useAnalyticFilters({ @@ -20,11 +22,30 @@ export const DifficultQuestionsPage = () => { page: 1, }); - const { data: response } = useGetMostDifficultQuestionsBySpecializationIdQuery({ + const { + data: response, + isLoading, + isFetching, + } = useGetMostDifficultQuestionsBySpecializationIdQuery({ specId: filters.specialization || specializationId, page: filters.page || 1, }); + if (isLoading || isFetching) { + return ( + + ); + } + const difficultQuestions = response?.data.topStat ?? []; return ( diff --git a/src/pages/analytics/popularQuestions/ui/PopularQuestionsPage/PopularQuestionsPage.tsx b/src/pages/analytics/popularQuestions/ui/PopularQuestionsPage/PopularQuestionsPage.tsx index 3282c8dda..e606f972c 100644 --- a/src/pages/analytics/popularQuestions/ui/PopularQuestionsPage/PopularQuestionsPage.tsx +++ b/src/pages/analytics/popularQuestions/ui/PopularQuestionsPage/PopularQuestionsPage.tsx @@ -1,6 +1,8 @@ import { useTranslation } from 'react-i18next'; import { i18Namespace, Analytics } from '@/shared/config'; +import { useScreenSize } from '@/shared/libs'; +import { AnalyticsPageSkeleton } from '@/shared/ui/AnalyticsPageSkeleton'; import { PopularQuestionStat, useGetPopularQuestionsQuery } from '@/entities/question'; @@ -10,6 +12,9 @@ import { PopularQuestionsList } from '../PopularQuestionsList/PopularQuestionsLi import { PopularQuestionsPageTable } from '../PopularQuestionsPageTable/PopularQuestionsPageTable'; export const PopularQuestionsPage = () => { + const { t } = useTranslation(i18Namespace.analytics); + const { isMobile } = useScreenSize(); + const { filters, hasFilters, onChangePage, onResetFilters, onChangeSpecialization } = useAnalyticFilters({ page: 1, @@ -17,8 +22,23 @@ export const PopularQuestionsPage = () => { const DATA_LIMIT_IN_PAGE = 10; const page = filters?.page || 1; - const { t } = useTranslation(i18Namespace.analytics); - const { data } = useGetPopularQuestionsQuery(); + + const { data, isLoading, isFetching } = useGetPopularQuestionsQuery(); + + if (isLoading || isFetching) { + return ( + + ); + } const popularQuestionsByAllSpecializations = data?.reduce( (accum, item) => [...accum, ...item.topStat], diff --git a/src/pages/analytics/popularSkills/ui/PopularSkillsPage/PopularSkillsPage.tsx b/src/pages/analytics/popularSkills/ui/PopularSkillsPage/PopularSkillsPage.tsx index 7cc7d0357..eb0af49cc 100644 --- a/src/pages/analytics/popularSkills/ui/PopularSkillsPage/PopularSkillsPage.tsx +++ b/src/pages/analytics/popularSkills/ui/PopularSkillsPage/PopularSkillsPage.tsx @@ -1,6 +1,8 @@ import { useTranslation } from 'react-i18next'; import { i18Namespace, Analytics } from '@/shared/config'; +import { useScreenSize } from '@/shared/libs'; +import { AnalyticsPageSkeleton } from '@/shared/ui/AnalyticsPageSkeleton'; import { useGetPopularSkillsQuery } from '@/entities/skill'; @@ -10,18 +12,39 @@ import { PopularSkillsList } from '../PopularSkillsList/PopularSkillsList'; import { PopularSkillsPageTable } from '../PopularSkillsPageTable/PopularSkillsPageTable'; export const PopularSkillsPage = () => { + const { t } = useTranslation(i18Namespace.analytics); + const { isMobile } = useScreenSize(); + const { filters, hasFilters, onChangePage, onResetFilters, onChangeSpecialization } = useAnalyticFilters({ page: 1, }); - const { t } = useTranslation(i18Namespace.analytics); - const { data: popularSkills } = useGetPopularSkillsQuery({ + const { + data: popularSkills, + isLoading, + isFetching, + } = useGetPopularSkillsQuery({ limit: 10, page: filters.page, specializationId: filters.specialization, }); + if (isLoading || isFetching) { + return ( + + ); + } + const specializationTitle = filters.specialization ? popularSkills?.data[0].specialization.title : ''; diff --git a/src/pages/analytics/progressSpecializations/ui/ProgressSpecializationsPage/ProgressSpecializationsPage.tsx b/src/pages/analytics/progressSpecializations/ui/ProgressSpecializationsPage/ProgressSpecializationsPage.tsx index 91fd0eb06..95a5102c6 100644 --- a/src/pages/analytics/progressSpecializations/ui/ProgressSpecializationsPage/ProgressSpecializationsPage.tsx +++ b/src/pages/analytics/progressSpecializations/ui/ProgressSpecializationsPage/ProgressSpecializationsPage.tsx @@ -1,6 +1,8 @@ import { useTranslation } from 'react-i18next'; import { i18Namespace, Analytics } from '@/shared/config'; +import { useScreenSize } from '@/shared/libs'; +import { AnalyticsPageSkeleton } from '@/shared/ui/AnalyticsPageSkeleton'; import { SpecializationProgressTable, @@ -12,17 +14,35 @@ import { AnalyticPageTemplate, useAnalyticFilters } from '@/widgets/analytics/An import { ProgressSpecializationsList } from '../ProgressSpecializationsList/ProgressSpecializationsList'; export const ProgressSpecializationsPage = () => { + const { t } = useTranslation(i18Namespace.analytics); + const { isMobile } = useScreenSize(); + const { filters, hasFilters, onChangePage, onResetFilters, onChangeSpecialization } = useAnalyticFilters({ page: 1, }); - const { data: response } = useGetSpecializationsGeneralProgressQuery({ + const { + data: response, + isLoading, + isFetching, + } = useGetSpecializationsGeneralProgressQuery({ page: filters.page, specializationId: filters.specialization, }); - const { t } = useTranslation(i18Namespace.analytics); + if (isLoading || isFetching) { + return ( + + ); + } const specializationsProgress = response?.data ?? []; diff --git a/src/pages/analytics/skillsProficiency/ui/SkillsProficiencyPage/SkillsProficiencyPage.tsx b/src/pages/analytics/skillsProficiency/ui/SkillsProficiencyPage/SkillsProficiencyPage.tsx index 45a2cc783..343ea2dac 100644 --- a/src/pages/analytics/skillsProficiency/ui/SkillsProficiencyPage/SkillsProficiencyPage.tsx +++ b/src/pages/analytics/skillsProficiency/ui/SkillsProficiencyPage/SkillsProficiencyPage.tsx @@ -1,6 +1,8 @@ import { useTranslation } from 'react-i18next'; import { i18Namespace, Analytics } from '@/shared/config'; +import { useScreenSize } from '@/shared/libs'; +import { AnalyticsPageSkeleton } from '@/shared/ui/AnalyticsPageSkeleton'; import { useGetLearnedQuestionsQuery } from '@/entities/question'; @@ -11,17 +13,37 @@ import { SkillsProficiencyPageTable } from '../SkillsProficiencyPageTable/Skills export const SkillsProficiencyPage = () => { const { t } = useTranslation(i18Namespace.analytics); + const { isMobile } = useScreenSize(); const filters = useAnalyticFilters({ page: 1, }); - const { data: response } = useGetLearnedQuestionsQuery({ + const { + data: response, + isLoading, + isFetching, + } = useGetLearnedQuestionsQuery({ page: filters.filters.page, specializationId: filters.filters.specialization, skillId: filters.filters.skill, }); + if (isLoading || isFetching) { + return ( + + ); + } + const learnedQuestions = response?.data ?? []; return ( diff --git a/src/shared/ui/AnalyticsPageSkeleton/AnalyticsMobileListSkeleton.tsx b/src/shared/ui/AnalyticsPageSkeleton/AnalyticsMobileListSkeleton.tsx new file mode 100644 index 000000000..179d62f22 --- /dev/null +++ b/src/shared/ui/AnalyticsPageSkeleton/AnalyticsMobileListSkeleton.tsx @@ -0,0 +1,34 @@ +import { Flex } from '@/shared/ui/Flex'; +import { Skeleton } from '@/shared/ui/Skeleton'; + +import styles from './AnalyticsPageSkeleton.module.css'; + +interface AnalyticsMobileListSkeletonProps { + itemsCount?: number; +} + +export const AnalyticsMobileListSkeleton = ({ + itemsCount = 3, +}: AnalyticsMobileListSkeletonProps) => { + return ( +
    + {Array.from({ length: itemsCount }).map((_, index) => ( +
    + + + + + + + + {index < itemsCount - 1 &&
    } +
    + ))} +
    + ); +}; diff --git a/src/shared/ui/AnalyticsPageSkeleton/AnalyticsPageSkeleton.module.css b/src/shared/ui/AnalyticsPageSkeleton/AnalyticsPageSkeleton.module.css new file mode 100644 index 000000000..fd12c4857 --- /dev/null +++ b/src/shared/ui/AnalyticsPageSkeleton/AnalyticsPageSkeleton.module.css @@ -0,0 +1,46 @@ +.header { + margin-bottom: 24px; +} + +.filtersContainer { + margin-bottom: 24px; +} + +.content { + min-height: 300px; +} + +.tableBody { + border-radius: 8px; + overflow: hidden; +} + +.tableRow { + display: flex; + align-items: center; + padding: 16px 12px; +} + +.tableRow:last-child { + border-bottom: none; +} + +.tableCell { + padding: 0 4px; +} + +.mobileList { + border: 1px solid var(--border-color); + border-radius: 8px; + overflow: hidden; +} + +.mobileListItem { + padding: 16px; +} + +.divider { + height: 1px; + background-color: var(--border-color); + margin: 0 16px; +} diff --git a/src/shared/ui/AnalyticsPageSkeleton/AnalyticsPageSkeleton.tsx b/src/shared/ui/AnalyticsPageSkeleton/AnalyticsPageSkeleton.tsx new file mode 100644 index 000000000..9edf4de09 --- /dev/null +++ b/src/shared/ui/AnalyticsPageSkeleton/AnalyticsPageSkeleton.tsx @@ -0,0 +1,122 @@ +import { Card } from '@/shared/ui/Card'; +import { Flex } from '@/shared/ui/Flex'; +import { Skeleton } from '@/shared/ui/Skeleton'; +import { TablePaginationSkeleton } from '@/shared/ui/TablePagination'; + +import { AnalyticsMobileListSkeleton } from './AnalyticsMobileListSkeleton'; +import styles from './AnalyticsPageSkeleton.module.css'; +import { AnalyticsTableSkeleton } from './AnalyticsTableSkeleton'; + +export interface AnalyticsPageSkeletonProps { + showTitle?: boolean; + showTooltip?: boolean; + showFilters?: boolean; + showSkillFilter?: boolean; + showPagination?: boolean; + displayMode?: 'table' | 'mobile'; + tableRowsCount?: number; + mobileItemsCount?: number; +} + +export const AnalyticsPageSkeleton = ({ + showTitle = true, + showTooltip = true, + showFilters = true, + showSkillFilter = true, + showPagination = true, + displayMode = 'table', + tableRowsCount = 10, + mobileItemsCount = 3, +}: AnalyticsPageSkeletonProps) => { + const shouldRenderHeader = showTitle || showTooltip; + + return ( + + {shouldRenderHeader && ( + + )} + + {showFilters && ( + + )} + + + + {showPagination && } + + ); +}; + +interface HeaderSectionProps { + showTitle: boolean; + showTooltip: boolean; + displayMode: 'table' | 'mobile'; +} + +const HeaderSection = ({ showTitle, showTooltip, displayMode }: HeaderSectionProps) => { + const isMobile = displayMode === 'mobile'; + + return ( + + {showTitle && ( + + )} + {showTooltip && ( + + )} + + ); +}; + +interface FiltersSectionProps { + displayMode: 'table' | 'mobile'; + showSkillFilter: boolean; +} + +const FiltersSection = ({ displayMode, showSkillFilter }: FiltersSectionProps) => { + const isMobile = displayMode === 'mobile'; + + return ( + + + {showSkillFilter && ( + + )} + + ); +}; + +interface ContentSectionProps { + displayMode: 'table' | 'mobile'; + tableRowsCount: number; + mobileItemsCount: number; +} + +const ContentSection = ({ displayMode, tableRowsCount, mobileItemsCount }: ContentSectionProps) => { + return ( +
    + {displayMode === 'table' ? ( + + ) : ( + + )} +
    + ); +}; diff --git a/src/shared/ui/AnalyticsPageSkeleton/AnalyticsTableSkeleton.tsx b/src/shared/ui/AnalyticsPageSkeleton/AnalyticsTableSkeleton.tsx new file mode 100644 index 000000000..f2afde086 --- /dev/null +++ b/src/shared/ui/AnalyticsPageSkeleton/AnalyticsTableSkeleton.tsx @@ -0,0 +1,53 @@ +import { Flex } from '@/shared/ui/Flex'; +import { Skeleton } from '@/shared/ui/Skeleton'; + +import styles from './AnalyticsPageSkeleton.module.css'; + +interface AnalyticsTableSkeletonProps { + rowsCount?: number; +} + +export const AnalyticsTableSkeleton = ({ rowsCount = 10 }: AnalyticsTableSkeletonProps) => { + return ( +
    + + + + + + + + +
    + {Array.from({ length: rowsCount }).map((_, rowIndex) => ( +
    +
    + +
    + +
    + + + + +
    + +
    + +
    +
    + ))} +
    +
    + ); +}; diff --git a/src/shared/ui/AnalyticsPageSkeleton/index.ts b/src/shared/ui/AnalyticsPageSkeleton/index.ts new file mode 100644 index 000000000..d57cc83ca --- /dev/null +++ b/src/shared/ui/AnalyticsPageSkeleton/index.ts @@ -0,0 +1,2 @@ +export { AnalyticsPageSkeleton } from './AnalyticsPageSkeleton'; +export type { AnalyticsPageSkeletonProps } from './AnalyticsPageSkeleton'; From 98baa89a9c6aa075c5a3fc8ed3477c99ad4cfd7f Mon Sep 17 00:00:00 2001 From: Julia Gogonova Date: Sat, 27 Dec 2025 21:36:43 +0300 Subject: [PATCH 10/10] YH-1564: added skeletons for 5 analytics pages --- .../SkeletonGenerator/SkeletonGenerator.tsx | 15 +++++ .../analytics/difficultQuestions/index.ts | 1 + .../DifficultQuestionsPage.skeleton.tsx | 40 ++++++++++++++ .../DifficultQuestionsPage.tsx | 19 ++----- src/pages/analytics/popularQuestions/index.ts | 1 + .../PopularQuestionsPage.skeleton.tsx | 41 ++++++++++++++ .../PopularQuestionsPage.tsx | 18 +----- src/pages/analytics/popularSkills/index.ts | 1 + .../PopularSkillsPage.skeleton.tsx | 40 ++++++++++++++ .../PopularSkillsPage/PopularSkillsPage.tsx | 18 +----- .../progressSpecializations/index.ts | 1 + .../ProgressSpecializationsPage.skeleton.tsx | 40 ++++++++++++++ .../ProgressSpecializationsPage.tsx | 16 +----- .../analytics/skillsProficiency/index.ts | 1 + .../SkillsProficiencyPage.skeleton.tsx | 40 ++++++++++++++ .../SkillsProficiencyPage.tsx | 18 +----- src/shared/ui/Table/Table.skeleton.tsx | 28 ++++++---- .../analytics/AnalyticPageTemplate/index.ts | 1 + .../AnalyticPageTemplate.skeleton.tsx | 55 +++++++++++++++++++ .../AnalyticPageTemplate.tsx | 2 +- ...nalyticPageTemplateMobileList.skeleton.tsx | 30 ++++++++++ 21 files changed, 342 insertions(+), 84 deletions(-) create mode 100644 src/pages/analytics/difficultQuestions/ui/DifficultQuestionsPage/DifficultQuestionsPage.skeleton.tsx create mode 100644 src/pages/analytics/popularQuestions/ui/PopularQuestionsPage/PopularQuestionsPage.skeleton.tsx create mode 100644 src/pages/analytics/popularSkills/ui/PopularSkillsPage/PopularSkillsPage.skeleton.tsx create mode 100644 src/pages/analytics/progressSpecializations/ui/ProgressSpecializationsPage/ProgressSpecializationsPage.skeleton.tsx create mode 100644 src/pages/analytics/skillsProficiency/ui/SkillsProficiencyPage/SkillsProficiencyPage.skeleton.tsx create mode 100644 src/widgets/analytics/AnalyticPageTemplate/ui/AnalyticPageTemplate/AnalyticPageTemplate.skeleton.tsx create mode 100644 src/widgets/analytics/AnalyticPageTemplate/ui/AnalyticPageTemplateMobileList/AnalyticPageTemplateMobileList.skeleton.tsx diff --git a/src/app/layouts/MainLayout/SkeletonGenerator/SkeletonGenerator.tsx b/src/app/layouts/MainLayout/SkeletonGenerator/SkeletonGenerator.tsx index 7e3db4415..2711c26ac 100644 --- a/src/app/layouts/MainLayout/SkeletonGenerator/SkeletonGenerator.tsx +++ b/src/app/layouts/MainLayout/SkeletonGenerator/SkeletonGenerator.tsx @@ -13,6 +13,11 @@ import { SkillsPageSkeleton } from '@/pages/admin/skill/skills'; import { SpecializationsPageSkeleton } from '@/pages/admin/specialization/specializations'; import { UsersTablePageSkeleton } from '@/pages/admin/user/users'; import { AnalyticsPageSkeleton } from '@/pages/analytics/analytics'; +import { DifficultQuestionsPageSkeleton } from '@/pages/analytics/difficultQuestions'; +import { PopularQuestionsPageSkeleton } from '@/pages/analytics/popularQuestions'; +import { PopularSkillsPageSkeleton } from '@/pages/analytics/popularSkills'; +import { ProgressSpecializationsPageSkeleton } from '@/pages/analytics/progressSpecializations'; +import { SkillsProficiencyPageSkeleton } from '@/pages/analytics/skillsProficiency'; import { CreateQuizPageSkeleton } from '@/pages/interview/createQuiz'; import { InterviewPageSkeleton } from '@/pages/interview/interview'; import { InterviewHistoryPageSkeleton } from '@/pages/interview/interviewHistory'; @@ -74,6 +79,16 @@ const SkeletonGenerator = () => { return ; case ROUTES.media.page: return ; + case ROUTES.analytics.progressSpecializations.route: + return ; + case ROUTES.analytics['skills-proficiency'].route: + return ; + case ROUTES.analytics['popular-skills'].route: + return ; + case ROUTES.analytics['popular-questions'].route: + return ; + case ROUTES.analytics['difficult-questions'].route: + return ; case ROUTES.analytics.page: return ; default: diff --git a/src/pages/analytics/difficultQuestions/index.ts b/src/pages/analytics/difficultQuestions/index.ts index d547c9c92..f99737d71 100644 --- a/src/pages/analytics/difficultQuestions/index.ts +++ b/src/pages/analytics/difficultQuestions/index.ts @@ -1 +1,2 @@ export { DifficultQuestionsPage } from './ui/DifficultQuestionsPage/DifficultQuestionsPage'; +export { DifficultQuestionsPageSkeleton } from './ui/DifficultQuestionsPage/DifficultQuestionsPage.skeleton'; diff --git a/src/pages/analytics/difficultQuestions/ui/DifficultQuestionsPage/DifficultQuestionsPage.skeleton.tsx b/src/pages/analytics/difficultQuestions/ui/DifficultQuestionsPage/DifficultQuestionsPage.skeleton.tsx new file mode 100644 index 000000000..7210dfa97 --- /dev/null +++ b/src/pages/analytics/difficultQuestions/ui/DifficultQuestionsPage/DifficultQuestionsPage.skeleton.tsx @@ -0,0 +1,40 @@ +import { useTranslation } from 'react-i18next'; + +import { i18Namespace, Analytics } from '@/shared/config'; +import { TableSkeleton } from '@/shared/ui/Table'; + +import { AnalyticPageTemplateSkeleton } from '@/widgets/analytics/AnalyticPageTemplate'; +import { AnalyticPageTemplateMobileListSkeleton } from '@/widgets/analytics/AnalyticPageTemplate/ui/AnalyticPageTemplateMobileList/AnalyticPageTemplateMobileList.skeleton'; + +export const DifficultQuestionsPageSkeleton = () => { + const { t } = useTranslation(i18Namespace.analytics); + + return ( + } + table={ + + } + filters={{ + specialization: undefined, + skill: undefined, + page: 1, + total: 100, + limit: 10, + hasFilters: false, + onChangeSpecialization: () => {}, + onChangeSkill: () => {}, + onChangePage: () => {}, + onResetFilters: () => {}, + }} + showSkillFilter={false} + /> + ); +}; diff --git a/src/pages/analytics/difficultQuestions/ui/DifficultQuestionsPage/DifficultQuestionsPage.tsx b/src/pages/analytics/difficultQuestions/ui/DifficultQuestionsPage/DifficultQuestionsPage.tsx index aa0f36a27..222df3852 100644 --- a/src/pages/analytics/difficultQuestions/ui/DifficultQuestionsPage/DifficultQuestionsPage.tsx +++ b/src/pages/analytics/difficultQuestions/ui/DifficultQuestionsPage/DifficultQuestionsPage.tsx @@ -1,8 +1,7 @@ import { useTranslation } from 'react-i18next'; import { i18Namespace, Analytics } from '@/shared/config'; -import { useAppSelector, useScreenSize } from '@/shared/libs'; -import { AnalyticsPageSkeleton } from '@/shared/ui/AnalyticsPageSkeleton'; +import { useAppSelector } from '@/shared/libs'; import { getSpecializationId } from '@/entities/profile'; import { useGetMostDifficultQuestionsBySpecializationIdQuery } from '@/entities/question'; @@ -12,9 +11,10 @@ import { AnalyticPageTemplate, useAnalyticFilters } from '@/widgets/analytics/An import { DifficultQuestionsList } from '../DifficultQuestionsList/DifficultQuestionsList'; import { DifficultQuestionsTable } from '../DifficultQuestionsTable/DifficultQuestionsTable'; +import { DifficultQuestionsPageSkeleton } from './DifficultQuestionsPage.skeleton'; + export const DifficultQuestionsPage = () => { const { t } = useTranslation(i18Namespace.analytics); - const { isMobile } = useScreenSize(); const specializationId = useAppSelector(getSpecializationId); const { filters, onChangeSpecialization, onChangePage } = useAnalyticFilters({ @@ -32,18 +32,7 @@ export const DifficultQuestionsPage = () => { }); if (isLoading || isFetching) { - return ( - - ); + return ; } const difficultQuestions = response?.data.topStat ?? []; diff --git a/src/pages/analytics/popularQuestions/index.ts b/src/pages/analytics/popularQuestions/index.ts index 9ecb7ae52..48e8fb646 100644 --- a/src/pages/analytics/popularQuestions/index.ts +++ b/src/pages/analytics/popularQuestions/index.ts @@ -1 +1,2 @@ export { PopularQuestionsPage } from './ui/PopularQuestionsPage/PopularQuestionsPage'; +export { PopularQuestionsPageSkeleton } from './ui/PopularQuestionsPage/PopularQuestionsPage.skeleton'; diff --git a/src/pages/analytics/popularQuestions/ui/PopularQuestionsPage/PopularQuestionsPage.skeleton.tsx b/src/pages/analytics/popularQuestions/ui/PopularQuestionsPage/PopularQuestionsPage.skeleton.tsx new file mode 100644 index 000000000..1fd342bb6 --- /dev/null +++ b/src/pages/analytics/popularQuestions/ui/PopularQuestionsPage/PopularQuestionsPage.skeleton.tsx @@ -0,0 +1,41 @@ +import { useTranslation } from 'react-i18next'; + +import { i18Namespace, Analytics } from '@/shared/config'; +import { TableSkeleton } from '@/shared/ui/Table'; + +import { AnalyticPageTemplateSkeleton } from '@/widgets/analytics/AnalyticPageTemplate'; +import { AnalyticPageTemplateMobileListSkeleton } from '@/widgets/analytics/AnalyticPageTemplate/ui/AnalyticPageTemplateMobileList/AnalyticPageTemplateMobileList.skeleton'; + +export const PopularQuestionsPageSkeleton = () => { + const { t } = useTranslation(i18Namespace.analytics); + + return ( + } + table={ + + } + filters={{ + specialization: undefined, + skill: undefined, + page: 1, + total: 100, + limit: 10, + hasFilters: false, + + onChangeSpecialization: () => {}, + onChangeSkill: () => {}, + onChangePage: () => {}, + onResetFilters: () => {}, + }} + showSkillFilter={false} + /> + ); +}; diff --git a/src/pages/analytics/popularQuestions/ui/PopularQuestionsPage/PopularQuestionsPage.tsx b/src/pages/analytics/popularQuestions/ui/PopularQuestionsPage/PopularQuestionsPage.tsx index e606f972c..4b47aef20 100644 --- a/src/pages/analytics/popularQuestions/ui/PopularQuestionsPage/PopularQuestionsPage.tsx +++ b/src/pages/analytics/popularQuestions/ui/PopularQuestionsPage/PopularQuestionsPage.tsx @@ -1,8 +1,6 @@ import { useTranslation } from 'react-i18next'; import { i18Namespace, Analytics } from '@/shared/config'; -import { useScreenSize } from '@/shared/libs'; -import { AnalyticsPageSkeleton } from '@/shared/ui/AnalyticsPageSkeleton'; import { PopularQuestionStat, useGetPopularQuestionsQuery } from '@/entities/question'; @@ -11,9 +9,10 @@ import { AnalyticPageTemplate, useAnalyticFilters } from '@/widgets/analytics/An import { PopularQuestionsList } from '../PopularQuestionsList/PopularQuestionsList'; import { PopularQuestionsPageTable } from '../PopularQuestionsPageTable/PopularQuestionsPageTable'; +import { PopularQuestionsPageSkeleton } from './PopularQuestionsPage.skeleton'; + export const PopularQuestionsPage = () => { const { t } = useTranslation(i18Namespace.analytics); - const { isMobile } = useScreenSize(); const { filters, hasFilters, onChangePage, onResetFilters, onChangeSpecialization } = useAnalyticFilters({ @@ -26,18 +25,7 @@ export const PopularQuestionsPage = () => { const { data, isLoading, isFetching } = useGetPopularQuestionsQuery(); if (isLoading || isFetching) { - return ( - - ); + return ; } const popularQuestionsByAllSpecializations = data?.reduce( diff --git a/src/pages/analytics/popularSkills/index.ts b/src/pages/analytics/popularSkills/index.ts index ccdf2c666..e95ff9fc6 100644 --- a/src/pages/analytics/popularSkills/index.ts +++ b/src/pages/analytics/popularSkills/index.ts @@ -1 +1,2 @@ export { PopularSkillsPage } from './ui/PopularSkillsPage/PopularSkillsPage'; +export { PopularSkillsPageSkeleton } from './ui/PopularSkillsPage/PopularSkillsPage.skeleton'; diff --git a/src/pages/analytics/popularSkills/ui/PopularSkillsPage/PopularSkillsPage.skeleton.tsx b/src/pages/analytics/popularSkills/ui/PopularSkillsPage/PopularSkillsPage.skeleton.tsx new file mode 100644 index 000000000..ab17a8b89 --- /dev/null +++ b/src/pages/analytics/popularSkills/ui/PopularSkillsPage/PopularSkillsPage.skeleton.tsx @@ -0,0 +1,40 @@ +import { useTranslation } from 'react-i18next'; + +import { i18Namespace, Analytics } from '@/shared/config'; +import { TableSkeleton } from '@/shared/ui/Table'; + +import { AnalyticPageTemplateSkeleton } from '@/widgets/analytics/AnalyticPageTemplate'; +import { AnalyticPageTemplateMobileListSkeleton } from '@/widgets/analytics/AnalyticPageTemplate/ui/AnalyticPageTemplateMobileList/AnalyticPageTemplateMobileList.skeleton'; + +export const PopularSkillsPageSkeleton = () => { + const { t } = useTranslation(i18Namespace.analytics); + + return ( + } + table={ + + } + filters={{ + specialization: undefined, + skill: undefined, + page: 1, + total: 100, + limit: 10, + hasFilters: false, + onChangeSpecialization: () => {}, + onChangeSkill: () => {}, + onChangePage: () => {}, + onResetFilters: () => {}, + }} + showSkillFilter={false} + /> + ); +}; diff --git a/src/pages/analytics/popularSkills/ui/PopularSkillsPage/PopularSkillsPage.tsx b/src/pages/analytics/popularSkills/ui/PopularSkillsPage/PopularSkillsPage.tsx index eb0af49cc..2cad6edf9 100644 --- a/src/pages/analytics/popularSkills/ui/PopularSkillsPage/PopularSkillsPage.tsx +++ b/src/pages/analytics/popularSkills/ui/PopularSkillsPage/PopularSkillsPage.tsx @@ -1,8 +1,6 @@ import { useTranslation } from 'react-i18next'; import { i18Namespace, Analytics } from '@/shared/config'; -import { useScreenSize } from '@/shared/libs'; -import { AnalyticsPageSkeleton } from '@/shared/ui/AnalyticsPageSkeleton'; import { useGetPopularSkillsQuery } from '@/entities/skill'; @@ -11,9 +9,10 @@ import { AnalyticPageTemplate, useAnalyticFilters } from '@/widgets/analytics/An import { PopularSkillsList } from '../PopularSkillsList/PopularSkillsList'; import { PopularSkillsPageTable } from '../PopularSkillsPageTable/PopularSkillsPageTable'; +import { PopularSkillsPageSkeleton } from './PopularSkillsPage.skeleton'; + export const PopularSkillsPage = () => { const { t } = useTranslation(i18Namespace.analytics); - const { isMobile } = useScreenSize(); const { filters, hasFilters, onChangePage, onResetFilters, onChangeSpecialization } = useAnalyticFilters({ @@ -31,18 +30,7 @@ export const PopularSkillsPage = () => { }); if (isLoading || isFetching) { - return ( - - ); + return ; } const specializationTitle = filters.specialization diff --git a/src/pages/analytics/progressSpecializations/index.ts b/src/pages/analytics/progressSpecializations/index.ts index b2d833e08..9ee55f660 100644 --- a/src/pages/analytics/progressSpecializations/index.ts +++ b/src/pages/analytics/progressSpecializations/index.ts @@ -1 +1,2 @@ export { ProgressSpecializationsPage } from './ui/ProgressSpecializationsPage/ProgressSpecializationsPage'; +export { ProgressSpecializationsPageSkeleton } from './ui/ProgressSpecializationsPage/ProgressSpecializationsPage.skeleton'; diff --git a/src/pages/analytics/progressSpecializations/ui/ProgressSpecializationsPage/ProgressSpecializationsPage.skeleton.tsx b/src/pages/analytics/progressSpecializations/ui/ProgressSpecializationsPage/ProgressSpecializationsPage.skeleton.tsx new file mode 100644 index 000000000..a89ceed2c --- /dev/null +++ b/src/pages/analytics/progressSpecializations/ui/ProgressSpecializationsPage/ProgressSpecializationsPage.skeleton.tsx @@ -0,0 +1,40 @@ +import { useTranslation } from 'react-i18next'; + +import { i18Namespace, Analytics } from '@/shared/config'; +import { TableSkeleton } from '@/shared/ui/Table'; + +import { AnalyticPageTemplateSkeleton } from '@/widgets/analytics/AnalyticPageTemplate'; +import { AnalyticPageTemplateMobileListSkeleton } from '@/widgets/analytics/AnalyticPageTemplate/ui/AnalyticPageTemplateMobileList/AnalyticPageTemplateMobileList.skeleton'; + +export const ProgressSpecializationsPageSkeleton = () => { + const { t } = useTranslation(i18Namespace.analytics); + + return ( + } + table={ + + } + filters={{ + specialization: undefined, + skill: undefined, + page: 1, + total: 0, + limit: 10, + hasFilters: false, + onChangeSpecialization: () => {}, + onChangeSkill: () => {}, + onChangePage: () => {}, + onResetFilters: () => {}, + }} + showSkillFilter={false} + /> + ); +}; diff --git a/src/pages/analytics/progressSpecializations/ui/ProgressSpecializationsPage/ProgressSpecializationsPage.tsx b/src/pages/analytics/progressSpecializations/ui/ProgressSpecializationsPage/ProgressSpecializationsPage.tsx index 95a5102c6..43b7b318b 100644 --- a/src/pages/analytics/progressSpecializations/ui/ProgressSpecializationsPage/ProgressSpecializationsPage.tsx +++ b/src/pages/analytics/progressSpecializations/ui/ProgressSpecializationsPage/ProgressSpecializationsPage.tsx @@ -1,8 +1,6 @@ import { useTranslation } from 'react-i18next'; import { i18Namespace, Analytics } from '@/shared/config'; -import { useScreenSize } from '@/shared/libs'; -import { AnalyticsPageSkeleton } from '@/shared/ui/AnalyticsPageSkeleton'; import { SpecializationProgressTable, @@ -13,9 +11,10 @@ import { AnalyticPageTemplate, useAnalyticFilters } from '@/widgets/analytics/An import { ProgressSpecializationsList } from '../ProgressSpecializationsList/ProgressSpecializationsList'; +import { ProgressSpecializationsPageSkeleton } from './ProgressSpecializationsPage.skeleton'; + export const ProgressSpecializationsPage = () => { const { t } = useTranslation(i18Namespace.analytics); - const { isMobile } = useScreenSize(); const { filters, hasFilters, onChangePage, onResetFilters, onChangeSpecialization } = useAnalyticFilters({ @@ -32,16 +31,7 @@ export const ProgressSpecializationsPage = () => { }); if (isLoading || isFetching) { - return ( - - ); + return ; } const specializationsProgress = response?.data ?? []; diff --git a/src/pages/analytics/skillsProficiency/index.ts b/src/pages/analytics/skillsProficiency/index.ts index 56be329ec..668f41193 100644 --- a/src/pages/analytics/skillsProficiency/index.ts +++ b/src/pages/analytics/skillsProficiency/index.ts @@ -1 +1,2 @@ export { SkillsProficiencyPage } from './ui/SkillsProficiencyPage/SkillsProficiencyPage'; +export { SkillsProficiencyPageSkeleton } from './ui/SkillsProficiencyPage/SkillsProficiencyPage.skeleton'; diff --git a/src/pages/analytics/skillsProficiency/ui/SkillsProficiencyPage/SkillsProficiencyPage.skeleton.tsx b/src/pages/analytics/skillsProficiency/ui/SkillsProficiencyPage/SkillsProficiencyPage.skeleton.tsx new file mode 100644 index 000000000..07c10de4d --- /dev/null +++ b/src/pages/analytics/skillsProficiency/ui/SkillsProficiencyPage/SkillsProficiencyPage.skeleton.tsx @@ -0,0 +1,40 @@ +import { useTranslation } from 'react-i18next'; + +import { i18Namespace, Analytics } from '@/shared/config'; +import { TableSkeleton } from '@/shared/ui/Table'; + +import { AnalyticPageTemplateSkeleton } from '@/widgets/analytics/AnalyticPageTemplate'; +import { AnalyticPageTemplateMobileListSkeleton } from '@/widgets/analytics/AnalyticPageTemplate/ui/AnalyticPageTemplateMobileList/AnalyticPageTemplateMobileList.skeleton'; + +export const SkillsProficiencyPageSkeleton = () => { + const { t } = useTranslation(i18Namespace.analytics); + + return ( + } + table={ + + } + filters={{ + specialization: undefined, + skill: undefined, + page: 1, + total: 100, + limit: 10, + hasFilters: false, + onChangeSpecialization: () => {}, + onChangeSkill: () => {}, + onChangePage: () => {}, + onResetFilters: () => {}, + }} + showSkillFilter={true} + /> + ); +}; diff --git a/src/pages/analytics/skillsProficiency/ui/SkillsProficiencyPage/SkillsProficiencyPage.tsx b/src/pages/analytics/skillsProficiency/ui/SkillsProficiencyPage/SkillsProficiencyPage.tsx index 343ea2dac..e69d315f5 100644 --- a/src/pages/analytics/skillsProficiency/ui/SkillsProficiencyPage/SkillsProficiencyPage.tsx +++ b/src/pages/analytics/skillsProficiency/ui/SkillsProficiencyPage/SkillsProficiencyPage.tsx @@ -1,8 +1,6 @@ import { useTranslation } from 'react-i18next'; import { i18Namespace, Analytics } from '@/shared/config'; -import { useScreenSize } from '@/shared/libs'; -import { AnalyticsPageSkeleton } from '@/shared/ui/AnalyticsPageSkeleton'; import { useGetLearnedQuestionsQuery } from '@/entities/question'; @@ -11,9 +9,10 @@ import { AnalyticPageTemplate, useAnalyticFilters } from '@/widgets/analytics/An import { SkillsProficiencyList } from '../SkillsProficiencyList/SkillsProficiencyList'; import { SkillsProficiencyPageTable } from '../SkillsProficiencyPageTable/SkillsProficiencyPageTable'; +import { SkillsProficiencyPageSkeleton } from './SkillsProficiencyPage.skeleton'; + export const SkillsProficiencyPage = () => { const { t } = useTranslation(i18Namespace.analytics); - const { isMobile } = useScreenSize(); const filters = useAnalyticFilters({ page: 1, @@ -30,18 +29,7 @@ export const SkillsProficiencyPage = () => { }); if (isLoading || isFetching) { - return ( - - ); + return ; } const learnedQuestions = response?.data ?? []; diff --git a/src/shared/ui/Table/Table.skeleton.tsx b/src/shared/ui/Table/Table.skeleton.tsx index 36a1ebe0c..bbf14f605 100644 --- a/src/shared/ui/Table/Table.skeleton.tsx +++ b/src/shared/ui/Table/Table.skeleton.tsx @@ -9,6 +9,7 @@ interface TableSkeletonProps { rowCount?: number; columnCount?: number; hasCopyButton?: boolean; + hasIcon?: boolean; } export const TableSkeleton = ({ @@ -16,9 +17,12 @@ export const TableSkeleton = ({ rowCount = 10, columnCount = 3, hasCopyButton = false, + hasIcon = true, }: TableSkeletonProps) => { + const hasAnyActions = hasIcon || hasCopyButton; + return ( - +
    {hasSelectors ? ( @@ -32,7 +36,8 @@ export const TableSkeleton = ({ ))} - + + {hasAnyActions && } {hasCopyButton && } @@ -49,14 +54,17 @@ export const TableSkeleton = ({ ))} - + {hasIcon && ( + + )} + {hasCopyButton && (
    - - + + { + const { isMobile } = useScreenSize(); + + const { page, total, limit } = filters; + + const showPagination = page && total && limit && total > limit; + + return ( + + + + + + + + + + + {showSkillFilter && } + + + {isMobile ? list : table} + + {showPagination && ( + + + + )} + + + ); +}; diff --git a/src/widgets/analytics/AnalyticPageTemplate/ui/AnalyticPageTemplate/AnalyticPageTemplate.tsx b/src/widgets/analytics/AnalyticPageTemplate/ui/AnalyticPageTemplate/AnalyticPageTemplate.tsx index 15d77f292..cc917d9e2 100644 --- a/src/widgets/analytics/AnalyticPageTemplate/ui/AnalyticPageTemplate/AnalyticPageTemplate.tsx +++ b/src/widgets/analytics/AnalyticPageTemplate/ui/AnalyticPageTemplate/AnalyticPageTemplate.tsx @@ -29,7 +29,7 @@ interface AnalyticPageTemplateFilters { onResetFilters?: () => void; } -interface AnalyticPageTemplateProps { +export interface AnalyticPageTemplateProps { title: string; tooltip?: string; list: ReactNode; diff --git a/src/widgets/analytics/AnalyticPageTemplate/ui/AnalyticPageTemplateMobileList/AnalyticPageTemplateMobileList.skeleton.tsx b/src/widgets/analytics/AnalyticPageTemplate/ui/AnalyticPageTemplateMobileList/AnalyticPageTemplateMobileList.skeleton.tsx new file mode 100644 index 000000000..426013fad --- /dev/null +++ b/src/widgets/analytics/AnalyticPageTemplate/ui/AnalyticPageTemplateMobileList/AnalyticPageTemplateMobileList.skeleton.tsx @@ -0,0 +1,30 @@ +import { Flex } from '@/shared/ui/Flex'; +import { TextSkeleton } from '@/shared/ui/Text'; + +import styles from './AnalyticPageTemplateMobileList.module.css'; + +export const AnalyticPageTemplateMobileListSkeleton = () => { + return ( + + {[...Array(10)].map((_, index) => ( +
  • +
    + + + + + + + + + + + + + +
    +
  • + ))} +
    + ); +};