diff --git a/src/components/FilterByTags/index.tsx b/src/components/FilterByTags/index.tsx index 872ab96..3c30aec 100644 --- a/src/components/FilterByTags/index.tsx +++ b/src/components/FilterByTags/index.tsx @@ -14,7 +14,7 @@ interface Props { } export const FilterByTags = ({ value, onChangeValue }: Props) => { - const { data: tagList } = useGetTagList(); + const { data: tagList } = useGetTagList({ emptyTag: true }); const filterTagChoices = useMemo(() => { return convertTagListToChoices(tagList ?? []); diff --git a/src/pages/Dictionary/ExportWordsDialog/ExportWordsForm.tsx b/src/pages/Dictionary/ExportWordsDialog/ExportWordsForm.tsx new file mode 100644 index 0000000..2caf47a --- /dev/null +++ b/src/pages/Dictionary/ExportWordsDialog/ExportWordsForm.tsx @@ -0,0 +1,66 @@ +import { Button, Checkbox, FormControlLabel, Stack } from '@mui/material'; +import { useGetTagList } from '@/services/tags/hooks'; +import { useCallback, useMemo, useState } from 'react'; +import { orderTagsByAbc } from '@/services/tags/utils'; +import { useEffectOnce } from '@/hooks/useEffectOnce'; +import { exportWordsToFile } from '@/utils/file'; + +export const ExportWordsForm = () => { + const { data: tagList } = useGetTagList({ emptyTag: true }); + const allTagIds = useMemo(() => tagList?.map((it) => it.id) ?? [], [tagList]); + const orderedTagList = useMemo(() => (tagList ? orderTagsByAbc(tagList) : tagList), [tagList]); + const [tagIds, setTagIds] = useState([]); + const [isDisabled, setIsDisabled] = useState(false); + + const toggleTag = useCallback((tagId: string) => { + setTagIds((state) => { + if (state.includes(tagId)) { + return state.filter((val) => val !== tagId); + } else { + return [...state, tagId]; + } + }); + }, []); + + const exportWords = useCallback(async () => { + setIsDisabled(true); + try { + await exportWordsToFile(tagIds); + } finally { + setIsDisabled(false); + } + }, [tagIds]); + + useEffectOnce({ + effect: () => { + setTagIds(allTagIds); + }, + condition: () => allTagIds.length > 0, + deps: [allTagIds], + }); + + return ( + + setTagIds(allTagIds)} />} + disabled={isDisabled} + /> + {orderedTagList?.map((it) => { + return ( + toggleTag(it.id)} />} + sx={{ paddingLeft: 4 }} + disabled={isDisabled} + /> + ); + })} + + + + ); +}; diff --git a/src/pages/Dictionary/ExportWordsDialog/index.tsx b/src/pages/Dictionary/ExportWordsDialog/index.tsx new file mode 100644 index 0000000..70b02b3 --- /dev/null +++ b/src/pages/Dictionary/ExportWordsDialog/index.tsx @@ -0,0 +1,20 @@ +import { Dialog, DialogContent, DialogTitle, IconButton } from '@mui/material'; +import CloseIcon from '@mui/icons-material/Close'; +import { DialogProps } from '@toolpad/core/useDialogs'; +import { ExportWordsForm } from './ExportWordsForm'; + +export const ExportWordsDialog = ({ open, onClose }: DialogProps) => { + return ( + onClose()}> + + Экспортировать слова + onClose()}> + + + + + + + + ); +}; diff --git a/src/pages/Dictionary/index.tsx b/src/pages/Dictionary/index.tsx index 2126d99..aaeb257 100644 --- a/src/pages/Dictionary/index.tsx +++ b/src/pages/Dictionary/index.tsx @@ -1,5 +1,5 @@ import DashboardPagesLayout from '@/layouts/DashboardPagesLayout'; -import { Typography, Box, IconButton, Button, Input } from '@mui/material'; +import { Typography, Box, IconButton, Button, Input, Stack } from '@mui/material'; import { DataGrid, GridActionsCellItem, GridColDef } from '@mui/x-data-grid'; import { Word, WordType } from '@/services/words/types'; import { useCallback, useMemo, useState } from 'react'; @@ -11,6 +11,7 @@ import WordFormDialog from '@/components/WordFormDialog'; import StarRoundedIcon from '@mui/icons-material/StarRounded'; import StarBorderRoundedIcon from '@mui/icons-material/StarBorderRounded'; import GetAppRoundedIcon from '@mui/icons-material/GetAppRounded'; +import FileUploadRoundedIcon from '@mui/icons-material/FileUploadRounded'; import { ImportWordsDialog } from './ImportWordsDialog'; import SearchIcon from '@mui/icons-material/Search'; import { SimpleSpeakButton } from '@/components/SimpleSpeakButton'; @@ -25,6 +26,7 @@ import { orderWordsByFavorite, renderWordTypes, } from '@/services/words/utils'; +import { ExportWordsDialog } from './ExportWordsDialog'; const Dictionary = () => { const { data: wordList, isLoading } = useGetWordList(); @@ -129,6 +131,10 @@ const Dictionary = () => { dialogs.open(ImportWordsDialog); }, [dialogs]); + const handleClickExport = useCallback(() => { + dialogs.open(ExportWordsDialog); + }, [dialogs]); + const handleChangeSearch = useCallback((event: React.ChangeEvent) => { setSearchText(event.target.value); }, []); @@ -165,9 +171,14 @@ const Dictionary = () => { )} - + + + + => { +export interface GetTagListParams { + emptyTag?: boolean; +} + +export const getTagList = async ({ emptyTag }: GetTagListParams): Promise => { const tags = await db.getAll(StoreName.Tags); const result: TagWithCount[] = []; + let countWordsWithTags = 0; for (const tag of tags) { const count = await getCountWordByTag(tag.id); result.push({ ...tag, count }); + countWordsWithTags += count; + } + if (emptyTag) { + // IndexedDB isn't allowing filtering by an empty array + const countAllWords = await getCountAllWords(); + const countWordsWithoutTags = countAllWords - countWordsWithTags; + if (countWordsWithoutTags) { + result.push({ + id: EMPTY_TAG_ID, + name: EMPTY_TAG_NAME, + createdAt: '', + updatedAt: '', + count: countWordsWithoutTags, + }); + } } return result; }; diff --git a/src/services/tags/hooks.ts b/src/services/tags/hooks.ts index 1b5c046..2ffab81 100644 --- a/src/services/tags/hooks.ts +++ b/src/services/tags/hooks.ts @@ -1,11 +1,11 @@ import { useMutation, useQuery } from '@tanstack/react-query'; -import { createTag, deleteTag, getTagList, readTag, updateTag } from './api'; +import { createTag, deleteTag, getTagList, GetTagListParams, readTag, updateTag } from './api'; import { QueryKey } from '../constants'; -export const useGetTagList = () => +export const useGetTagList = (params: GetTagListParams = {}) => useQuery({ - queryKey: [QueryKey.GetTagList], - queryFn: getTagList, + queryKey: [QueryKey.GetTagList, params], + queryFn: () => getTagList(params), }); export const useCreateTag = () => diff --git a/src/services/tags/utils.ts b/src/services/tags/utils.ts index daad23d..9a49f30 100644 --- a/src/services/tags/utils.ts +++ b/src/services/tags/utils.ts @@ -1,5 +1,5 @@ import { DateTime } from 'luxon'; -import { Tag } from './types'; +import { Tag, TagWithCount } from './types'; export const getTagDefaultValues = (): Tag => { const today = DateTime.utc().toISO(); @@ -19,3 +19,7 @@ export const renderTags = (tagIds: string[], tagList: Tag[]) => { }) .join(', '); }; + +export const orderTagsByAbc = (list: TagWithCount[]): TagWithCount[] => { + return list.toSorted((a, b) => a.name.localeCompare(b.name)); +}; diff --git a/src/services/words/api.ts b/src/services/words/api.ts index 6effe37..2e5f49f 100644 --- a/src/services/words/api.ts +++ b/src/services/words/api.ts @@ -51,6 +51,10 @@ export const getCountWordByTag = async (tagId: string): Promise => { return db.countFromIndex(StoreName.Words, 'by-tags', IDBKeyRange.only(tagId)); }; +export const getCountAllWords = async (): Promise => { + return db.count(StoreName.Words); +}; + export const getWordsByTag = async (tagId: string): Promise => { return db.getAllFromIndex(StoreName.Words, 'by-tags', IDBKeyRange.only(tagId)); }; diff --git a/src/services/words/utils.ts b/src/services/words/utils.ts index 03163d3..48ad73d 100644 --- a/src/services/words/utils.ts +++ b/src/services/words/utils.ts @@ -2,6 +2,7 @@ import { DateTime } from 'luxon'; import { QuizItem, Word, WordType } from './types'; import { getRandomItemOfArray, shuffle } from '@/utils/random'; import { WORD_TYPE_TO_NAME } from '@/constants/word'; +import { EMPTY_TAG_ID } from '../constants'; export const getWordDefaultValues = (): Word => { const today = DateTime.utc().toISO(); @@ -25,7 +26,10 @@ export const getWordDefaultValues = (): Word => { export const filterWordsByTagIds = (list: Word[], tagIds: string[]): Word[] => { if (tagIds.length > 0) { const searchSet = new Set(tagIds); - return list.filter((it) => it.tags.length > 0 && !new Set(it.tags).isDisjointFrom(searchSet)); + return list.filter((it) => { + const wordTagSet = new Set(it.tags.length === 0 ? [EMPTY_TAG_ID] : it.tags); + return !wordTagSet.isDisjointFrom(searchSet); + }); } return list; }; diff --git a/src/utils/file.ts b/src/utils/file.ts index 4317c19..e60394b 100644 --- a/src/utils/file.ts +++ b/src/utils/file.ts @@ -1,13 +1,17 @@ -import { createTag, findByTag } from '@/services/tags/api'; +import { createTag, findByTag, getTagList } from '@/services/tags/api'; import { TagSchema } from '@/services/tags/schema'; -import { createWord, findByWord } from '@/services/words/api'; +import { createWord, findByWord, getWordList } from '@/services/words/api'; import { BaseWordSchema } from '@/services/words/schema'; import { z } from '@/zod'; import { mergeValues } from './form'; import { getTagDefaultValues } from '@/services/tags/utils'; -import { getWordDefaultValues } from '@/services/words/utils'; +import { filterWordsByTagIds, getWordDefaultValues } from '@/services/words/utils'; import { queryClient } from '@/services/queryClient'; import { QueryKey } from '@/services/constants'; +import { DateTime } from 'luxon'; +import FileSaver from 'file-saver'; + +const DEFAULT_FILE_VERSION = 1; const ImportTagSchema = TagSchema.pick({ id: true, name: true }); type ImportTag = z.infer; @@ -53,3 +57,16 @@ export const importDataFromFile = async (data: unknown): Promise => { } return isValidFile; }; + +export const exportWordsToFile = async (tagIds: string[]): Promise => { + const allTags = await getTagList({}); + const tags = allTags.filter((tag) => tagIds.includes(tag.id)); + + const allWords = await getWordList(); + const words = filterWordsByTagIds(allWords, tagIds); + + const filename = `dictionary_${DateTime.local().toFormat('yyyy_LL_dd_HH_mm')}.json`; + const jsonData = JSON.stringify({ version: DEFAULT_FILE_VERSION, tags, words } satisfies ImportFile, null, 2); + const blob = new Blob([jsonData], { type: 'application/json' }); + FileSaver.saveAs(blob, filename); +};