Skip to content

Commit

Permalink
Added export button
Browse files Browse the repository at this point in the history
  • Loading branch information
pkolt committed Dec 5, 2024
1 parent aef2d56 commit e3fe6e2
Show file tree
Hide file tree
Showing 11 changed files with 166 additions and 17 deletions.
2 changes: 1 addition & 1 deletion src/components/FilterByTags/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? []);
Expand Down
66 changes: 66 additions & 0 deletions src/pages/Dictionary/ExportWordsDialog/ExportWordsForm.tsx
Original file line number Diff line number Diff line change
@@ -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<string[]>([]);
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 (
<Stack spacing={2} alignItems="start" marginTop={1}>
<FormControlLabel
label="Все слова"
control={<Checkbox checked={tagIds.length === allTagIds.length} onChange={() => setTagIds(allTagIds)} />}
disabled={isDisabled}
/>
{orderedTagList?.map((it) => {
return (
<FormControlLabel
key={it.id}
label={`${it.name} (${it.count})`}
control={<Checkbox checked={tagIds.includes(it.id)} onChange={() => toggleTag(it.id)} />}
sx={{ paddingLeft: 4 }}
disabled={isDisabled}
/>
);
})}

<Button variant="contained" disabled={tagIds.length === 0 || isDisabled} onClick={exportWords}>
Экспорт
</Button>
</Stack>
);
};
20 changes: 20 additions & 0 deletions src/pages/Dictionary/ExportWordsDialog/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Dialog fullWidth open={open} onClose={() => onClose()}>
<DialogTitle sx={{ display: 'flex', alignItems: 'center' }}>
Экспортировать слова
<IconButton sx={{ ml: 'auto' }} onClick={() => onClose()}>
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent>
<ExportWordsForm />
</DialogContent>
</Dialog>
);
};
19 changes: 15 additions & 4 deletions src/pages/Dictionary/index.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand All @@ -25,6 +26,7 @@ import {
orderWordsByFavorite,
renderWordTypes,
} from '@/services/words/utils';
import { ExportWordsDialog } from './ExportWordsDialog';

const Dictionary = () => {
const { data: wordList, isLoading } = useGetWordList();
Expand Down Expand Up @@ -129,6 +131,10 @@ const Dictionary = () => {
dialogs.open(ImportWordsDialog);
}, [dialogs]);

const handleClickExport = useCallback(() => {
dialogs.open(ExportWordsDialog);
}, [dialogs]);

const handleChangeSearch = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
setSearchText(event.target.value);
}, []);
Expand Down Expand Up @@ -165,9 +171,14 @@ const Dictionary = () => {
</Button>
)}
</Box>
<Button variant="outlined" startIcon={<GetAppRoundedIcon />} onClick={handleClickImport}>
Импорт
</Button>
<Stack direction="row" gap={1}>
<Button variant="outlined" startIcon={<FileUploadRoundedIcon />} onClick={handleClickExport}>
Экспорт
</Button>
<Button variant="outlined" startIcon={<GetAppRoundedIcon />} onClick={handleClickImport}>
Импорт
</Button>
</Stack>
</Box>
<Box width="100%" minHeight="400px">
<DataGrid
Expand Down
3 changes: 3 additions & 0 deletions src/services/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,6 @@ export enum QueryKey {
GetTag = 'get_tag',
GetWordProgressList = 'get_word_progress_list',
}

export const EMPTY_TAG_ID = 'EMPTY_TAG_ID';
export const EMPTY_TAG_NAME = '⚠️ Без тегов';
26 changes: 23 additions & 3 deletions src/services/tags/api.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,35 @@
import { db } from '../db';
import { Tag, TagWithCount } from './types';
import { deleteWord, getWordsByTag, getCountWordByTag, updateWord } from '../words/api';
import { QueryKey, StoreName } from '../constants';
import { deleteWord, getWordsByTag, getCountWordByTag, updateWord, getCountAllWords } from '../words/api';
import { EMPTY_TAG_ID, EMPTY_TAG_NAME, QueryKey, StoreName } from '../constants';
import { queryClient } from '../queryClient';

export const getTagList = async (): Promise<TagWithCount[]> => {
export interface GetTagListParams {
emptyTag?: boolean;
}

export const getTagList = async ({ emptyTag }: GetTagListParams): Promise<TagWithCount[]> => {
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;
};
Expand Down
8 changes: 4 additions & 4 deletions src/services/tags/hooks.ts
Original file line number Diff line number Diff line change
@@ -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 = () =>
Expand Down
6 changes: 5 additions & 1 deletion src/services/tags/utils.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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));
};
4 changes: 4 additions & 0 deletions src/services/words/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ export const getCountWordByTag = async (tagId: string): Promise<number> => {
return db.countFromIndex(StoreName.Words, 'by-tags', IDBKeyRange.only(tagId));
};

export const getCountAllWords = async (): Promise<number> => {
return db.count(StoreName.Words);
};

export const getWordsByTag = async (tagId: string): Promise<Word[]> => {
return db.getAllFromIndex(StoreName.Words, 'by-tags', IDBKeyRange.only(tagId));
};
6 changes: 5 additions & 1 deletion src/services/words/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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;
};
Expand Down
23 changes: 20 additions & 3 deletions src/utils/file.ts
Original file line number Diff line number Diff line change
@@ -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<typeof ImportTagSchema>;
Expand Down Expand Up @@ -53,3 +57,16 @@ export const importDataFromFile = async (data: unknown): Promise<boolean> => {
}
return isValidFile;
};

export const exportWordsToFile = async (tagIds: string[]): Promise<void> => {
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);
};

0 comments on commit e3fe6e2

Please sign in to comment.