From 3c303e0304f36d2803d70f5b1fb884f4f9a63f4c Mon Sep 17 00:00:00 2001 From: Nikolaos Karaolidis Date: Fri, 21 Jul 2023 16:34:44 +0300 Subject: [PATCH] Refactor validation Signed-off-by: Nikolaos Karaolidis --- src/components/memes/memeEditor.tsx | 43 +++++------ src/components/memes/memeTagSearchModal.tsx | 32 ++++++-- src/components/tags/tagEditor.tsx | 73 ++++++------------ src/screens/addMeme.tsx | 22 +++--- src/screens/addTag.tsx | 28 +++---- src/screens/editTag.tsx | 21 ++--- src/screens/memes.tsx | 24 +++++- src/screens/settings.tsx | 10 ++- src/screens/tags.tsx | 21 +++-- src/screens/welcome.tsx | 4 +- src/state/index.ts | 4 +- src/state/settings.ts | 4 +- src/styles.tsx | 2 +- src/utilities/color.ts | 24 ++++-- src/utilities/database.ts | 2 +- src/utilities/index.ts | 8 +- src/utilities/validation.ts | 85 +++++++++++++++++++++ 17 files changed, 256 insertions(+), 151 deletions(-) create mode 100644 src/utilities/validation.ts diff --git a/src/components/memes/memeEditor.tsx b/src/components/memes/memeEditor.tsx index 9069dc9..e833f95 100644 --- a/src/components/memes/memeEditor.tsx +++ b/src/components/memes/memeEditor.tsx @@ -5,6 +5,11 @@ import { useDimensions } from '../../contexts'; import LoadingView from '../loadingView'; import { MemeTagSelector } from '.'; import { Tag } from '../../database'; +import { + StringValidationResult, + validateMemeDescription, + validateMemeTitle, +} from '../../utilities'; const MemeEditor = ({ imageUri, @@ -14,18 +19,14 @@ const MemeEditor = ({ setMemeDescription, memeTags, setMemeTags, - memeTitleError, - setMemeTitleError, }: { imageUri: string[]; - memeTitle: string; - setMemeTitle: (name: string) => void; - memeDescription: string; - setMemeDescription: (description: string) => void; + memeTitle: StringValidationResult; + setMemeTitle: (name: StringValidationResult) => void; + memeDescription: StringValidationResult; + setMemeDescription: (description: StringValidationResult) => void; memeTags: Map; setMemeTags: (tags: Map) => void; - memeTitleError: string | undefined; - setMemeTitleError: (error: string | undefined) => void; }) => { const { dimensions, fixed, responsive } = useDimensions(); @@ -40,16 +41,6 @@ const MemeEditor = ({ }); }, [dimensions.width, imageUri]); - const handleMemeTitleChange = (name: string) => { - setMemeTitle(name); - if (name.length === 0) { - setMemeTitleError('Meme title cannot be empty'); - } else { - // eslint-disable-next-line unicorn/no-useless-undefined - setMemeTitleError(undefined); - } - }; - if (!imageWidth || !imageHeight) return ; return ( @@ -57,13 +48,13 @@ const MemeEditor = ({ setMemeTitle(validateMemeTitle(title))} + error={!memeTitle.valid} selectTextOnFocus /> - - {memeTitleError} + + {memeTitle.error} + setMemeDescription(validateMemeDescription(description)) + } /> ); diff --git a/src/components/memes/memeTagSearchModal.tsx b/src/components/memes/memeTagSearchModal.tsx index 6d9d380..68d3894 100644 --- a/src/components/memes/memeTagSearchModal.tsx +++ b/src/components/memes/memeTagSearchModal.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { TagChip } from '../tags'; import { Tag } from '../../database'; import { useQuery, useRealm } from '@realm/react'; @@ -8,6 +8,7 @@ import { StyleSheet } from 'react-native'; import { useDimensions } from '../../contexts'; import styles from '../../styles'; import { FlashList } from '@shopify/flash-list'; +import { validateTagName } from '../../utilities'; const memeTagSearchModalStyles = StyleSheet.create({ modal: { @@ -34,6 +35,11 @@ const MemeTagSearchModal = ({ const flashListRef = useRef>(null); const [search, setSearch] = useState(''); + const [tagName, setTagName] = useState(validateTagName(search)); + + useEffect(() => { + setTagName(validateTagName(search)); + }, [search]); const handleSearch = (newSearch: string) => { flashListRef.current?.scrollToOffset({ offset: 0 }); @@ -42,10 +48,20 @@ const MemeTagSearchModal = ({ const tags = useQuery( Tag.schema.name, - collection => - collection - .filtered(`name CONTAINS[c] "${search}"`) - .sorted(tagSortQuery(TAG_SORT.DATE_MODIFIED), true), + collectionIn => { + let collection = collectionIn; + + if (search) { + collection = collection.filtered('name CONTAINS[c] $0', search); + } + + collection = collection.sorted( + tagSortQuery(TAG_SORT.DATE_MODIFIED), + true, + ); + + return collection; + }, [search], ); @@ -114,8 +130,10 @@ const MemeTagSearchModal = ({ handleCreateTag(search.replaceAll(/\s+/g, ''))}> - Create Tag #{search.replaceAll(/\s+/g, '')} + onPress={() => + handleCreateTag(tagName.valid ? tagName.parsed : 'newTag') + }> + Create Tag #{tagName.valid ? tagName.parsed : 'newTag'} )} /> diff --git a/src/components/tags/tagEditor.tsx b/src/components/tags/tagEditor.tsx index ea5717c..940195c 100644 --- a/src/components/tags/tagEditor.tsx +++ b/src/components/tags/tagEditor.tsx @@ -1,77 +1,52 @@ -import React from 'react'; +import React, { useRef } from 'react'; import { HelperText, TextInput } from 'react-native-paper'; import TagPreview from './tagPreview'; -import { generateRandomColor, isValidColor } from '../../utilities'; +import { + StringValidationResult, + generateRandomColor, + validateColor, + validateTagName, +} from '../../utilities'; const TagEditor = ({ tagName, setTagName, tagColor, setTagColor, - validatedTagColor, - setValidatedTagColor, - tagNameError, - setTagNameError, - tagColorError, - setTagColorError, }: { - tagName: string; - setTagName: (name: string) => void; - tagColor: string; - setTagColor: (color: string) => void; - validatedTagColor: string; - setValidatedTagColor: (color: string) => void; - tagNameError: string | undefined; - setTagNameError: (error: string | undefined) => void; - tagColorError: string | undefined; - setTagColorError: (error: string | undefined) => void; + tagName: StringValidationResult; + setTagName: (name: StringValidationResult) => void; + tagColor: StringValidationResult; + setTagColor: (color: StringValidationResult) => void; }) => { - const handleTagNameChange = (name: string) => { - setTagName(name); - - if (name.length === 0) { - setTagNameError('Tag name cannot be empty'); - } else if (name.includes(' ')) { - setTagNameError('Tag name cannot contain spaces'); - } else { - // eslint-disable-next-line unicorn/no-useless-undefined - setTagNameError(undefined); - } - }; + const lastValidTagColor = useRef(tagColor.parsed); const handleTagColorChange = (color: string) => { - setTagColor(color); - - if (isValidColor(color)) { - setValidatedTagColor(color); - // eslint-disable-next-line unicorn/no-useless-undefined - setTagColorError(undefined); - } else { - setTagColorError('Color must be a valid hex or rgb value'); - } + setTagColor(validateColor(color)); + if (tagColor.valid) lastValidTagColor.current = tagColor.parsed; }; return ( <> - + setTagName(validateTagName(name))} + error={!tagName.valid} autoCapitalize="none" selectTextOnFocus /> - - {tagNameError} + + {tagName.error} } /> - - {tagColorError} + + {tagColor.error} ); diff --git a/src/screens/addMeme.tsx b/src/screens/addMeme.tsx index ec12651..03b4121 100644 --- a/src/screens/addMeme.tsx +++ b/src/screens/addMeme.tsx @@ -13,7 +13,11 @@ import styles from '../styles'; import { ROUTE, RootStackParamList } from '../types'; import { MEME_TYPE, Meme, Tag } from '../database'; import { RootState } from '../state'; -import { getMemeType } from '../utilities'; +import { + getMemeType, + validateMemeDescription, + validateMemeTitle, +} from '../utilities'; import { MemeEditor } from '../components'; const AddMeme = ({ @@ -34,13 +38,13 @@ const AddMeme = ({ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion uri.length > 1 ? MEME_TYPE.ALBUM : getMemeType(uri[0].type!); - const [memeTitle, setMemeTitle] = useState('New Meme'); - const [memeDescription, setMemeDescription] = useState(''); + const [memeTitle, setMemeTitle] = useState(validateMemeTitle('New Meme')); + const [memeDescription, setMemeDescription] = useState( + validateMemeDescription(''), + ); const [memeIsFavorite, setMemeIsFavorite] = useState(false); const [memeTags, setMemeTags] = useState(new Map()); - const [memeTitleError, setMemeTitleError] = useState(); - const [isSaving, setIsSaving] = useState(false); const handleSave = async () => { @@ -72,8 +76,8 @@ const AddMeme = ({ uri: savedUri, size, hash, - title: memeTitle, - description: memeDescription, + title: memeTitle.parsed, + description: memeDescription.parsed, isFavorite: memeIsFavorite, tags: [...memeTags.values()], tagsLength: memeTags.size, @@ -119,8 +123,6 @@ const AddMeme = ({ setMemeDescription={setMemeDescription} memeTags={memeTags} setMemeTags={setMemeTags} - memeTitleError={memeTitleError} - setMemeTitleError={setMemeTitleError} /> @@ -128,7 +130,7 @@ const AddMeme = ({ mode="contained" icon="floppy" onPress={handleSave} - disabled={!!memeTitleError || isSaving} + disabled={!memeTitle.valid || !memeDescription.valid || isSaving} loading={isSaving}> Save diff --git a/src/screens/addTag.tsx b/src/screens/addTag.tsx index bef0222..c6f5410 100644 --- a/src/screens/addTag.tsx +++ b/src/screens/addTag.tsx @@ -4,7 +4,11 @@ import { Appbar, Button, useTheme } from 'react-native-paper'; import { useNavigation } from '@react-navigation/native'; import { useRealm } from '@realm/react'; import styles from '../styles'; -import { generateRandomColor } from '../utilities'; +import { + generateRandomColor, + validateColor, + validateTagName, +} from '../utilities'; import { useDimensions } from '../contexts'; import { Tag } from '../database'; import { TagEditor } from '../components'; @@ -15,18 +19,16 @@ const AddTag = () => { const { orientation } = useDimensions(); const realm = useRealm(); - const [tagName, setTagName] = useState('newTag'); - const [tagColor, setTagColor] = useState(generateRandomColor()); - const [validatedTagColor, setValidatedTagColor] = useState(tagColor); - - const [tagNameError, setTagNameError] = useState(); - const [tagColorError, setTagColorError] = useState(); + const [tagName, setTagName] = useState(validateTagName('newTag')); + const [tagColor, setTagColor] = useState( + validateColor(generateRandomColor()), + ); const handleSave = () => { realm.write(() => { realm.create(Tag.schema.name, { - name: tagName, - color: tagColor, + name: tagName.parsed, + color: tagColor.parsed, }); }); @@ -54,12 +56,6 @@ const AddTag = () => { setTagName={setTagName} tagColor={tagColor} setTagColor={setTagColor} - validatedTagColor={validatedTagColor} - setValidatedTagColor={setValidatedTagColor} - tagNameError={tagNameError} - setTagNameError={setTagNameError} - tagColorError={tagColorError} - setTagColorError={setTagColorError} /> @@ -67,7 +63,7 @@ const AddTag = () => { mode="contained" icon="floppy" onPress={handleSave} - disabled={!!tagNameError || !!tagColorError}> + disabled={!tagName.valid || !tagColor.valid}> Save diff --git a/src/screens/editTag.tsx b/src/screens/editTag.tsx index 74efc7f..932eaf3 100644 --- a/src/screens/editTag.tsx +++ b/src/screens/editTag.tsx @@ -10,6 +10,7 @@ import styles from '../styles'; import { useDimensions } from '../contexts'; import { ROUTE, RootStackParamList } from '../types'; import { Tag } from '../database'; +import { validateColor, validateTagName } from '../utilities'; const EditTag = ({ route, @@ -25,17 +26,13 @@ const EditTag = ({ BSON.UUID.createFromHexString(route.params.id), )!; - const [tagName, setTagName] = useState(tag.name); - const [tagColor, setTagColor] = useState(tag.color); - const [validatedTagColor, setValidatedTagColor] = useState(tagColor); - - const [tagNameError, setTagNameError] = useState(); - const [tagColorError, setTagColorError] = useState(); + const [tagName, setTagName] = useState(validateTagName(tag.name)); + const [tagColor, setTagColor] = useState(validateColor(tag.color)); const handleSave = () => { realm.write(() => { - tag.name = tagName; - tag.color = tagColor; + tag.name = tagName.parsed; + tag.color = tagColor.parsed; tag.dateModified = new Date(); }); @@ -80,12 +77,6 @@ const EditTag = ({ setTagName={setTagName} tagColor={tagColor} setTagColor={setTagColor} - validatedTagColor={validatedTagColor} - setValidatedTagColor={setValidatedTagColor} - tagNameError={tagNameError} - setTagNameError={setTagNameError} - tagColorError={tagColorError} - setTagColorError={setTagColorError} /> @@ -93,7 +84,7 @@ const EditTag = ({ mode="contained" icon="floppy" onPress={handleSave} - disabled={!!tagNameError || !!tagColorError}> + disabled={!tagName.valid || !tagColor.valid}> Save diff --git a/src/screens/memes.tsx b/src/screens/memes.tsx index 7282fe2..7e94cdc 100644 --- a/src/screens/memes.tsx +++ b/src/screens/memes.tsx @@ -12,7 +12,7 @@ import { } from 'react-native-paper'; import { useDispatch, useSelector } from 'react-redux'; import styles from '../styles'; -import { MEME_SORT, SORT_DIRECTION } from '../types'; +import { MEME_SORT, SORT_DIRECTION, memesSortQuery } from '../types'; import { getSortIcon, getViewIcon } from '../utilities'; import { RootState, @@ -72,7 +72,27 @@ const Memes = () => { }; const [search, setSearch] = useState(''); - const memes = useQuery(Meme.schema.name); + + const memes = useQuery( + Meme.schema.name, + collectionIn => { + let collection = collectionIn; + + if (favoritesOnly) collection = collection.filtered('isFavorite == true'); + if (filter) collection = collection.filtered('type == $0', filter); + if (search) { + collection = collection.filtered('title CONTAINS[c] $0', search); + } + + collection = collection.sorted( + memesSortQuery(sort), + sortDirection === SORT_DIRECTION.DESCENDING, + ); + + return collection; + }, + [sort, sortDirection, favoritesOnly, filter, search], + ); return ( { }} onPress={async () => { const { uri } = await openDocumentTree(true); - void dispatch(updateStorageUri(uri)); + void dispatch(setStorageUri(uri)); }}> Change External Storage Path @@ -84,7 +88,7 @@ const SettingsScreen = () => { { - void dispatch(updateNoMedia(value)); + void dispatch(setNoMedia(value)); }} /> diff --git a/src/screens/tags.tsx b/src/screens/tags.tsx index 0eaf3af..9b30166 100644 --- a/src/screens/tags.tsx +++ b/src/screens/tags.tsx @@ -99,13 +99,20 @@ const Tags = () => { const tags = useQuery( Tag.schema.name, - collection => - collection - .filtered(`name CONTAINS[c] "${search}"`) - .sorted( - tagSortQuery(sort), - sortDirection === SORT_DIRECTION.DESCENDING, - ), + collectionIn => { + let collection = collectionIn; + + if (search) { + collection = collection.filtered('name CONTAINS[c] $0', search); + } + + collection = collection.sorted( + tagSortQuery(sort), + sortDirection === SORT_DIRECTION.DESCENDING, + ); + + return collection; + }, [search, sort, sortDirection], ); diff --git a/src/screens/welcome.tsx b/src/screens/welcome.tsx index 37a1b3f..b843814 100644 --- a/src/screens/welcome.tsx +++ b/src/screens/welcome.tsx @@ -5,7 +5,7 @@ import { useDispatch } from 'react-redux'; import { openDocumentTree } from 'react-native-scoped-storage'; import styles from '../styles'; import { noOp } from '../utilities'; -import { updateStorageUri } from '../state'; +import { setStorageUri } from '../state'; import { useDimensions } from '../contexts'; const Welcome = ({ onWelcomeComplete }: { onWelcomeComplete: () => void }) => { @@ -16,7 +16,7 @@ const Welcome = ({ onWelcomeComplete }: { onWelcomeComplete: () => void }) => { const selectStorageLocation = async () => { const uri = await openDocumentTree(true).catch(noOp); if (!uri) return; - await dispatch(updateStorageUri(uri.uri)); + await dispatch(setStorageUri(uri.uri)); onWelcomeComplete(); }; diff --git a/src/state/index.ts b/src/state/index.ts index 66ae9f6..896a8c7 100644 --- a/src/state/index.ts +++ b/src/state/index.ts @@ -52,8 +52,8 @@ const persistor = persistStore(store); export { type RootState, store, persistor }; export { type SettingsState, - updateStorageUri, - updateNoMedia, + setStorageUri, + setNoMedia, validateSettings, } from './settings'; export { diff --git a/src/state/settings.ts b/src/state/settings.ts index f5afae1..e4d34ec 100644 --- a/src/state/settings.ts +++ b/src/state/settings.ts @@ -109,8 +109,8 @@ const validateSettings = createAsyncThunk( export { type SettingsState, - updateStorageUri, - updateNoMedia, + updateStorageUri as setStorageUri, + updateNoMedia as setNoMedia, validateSettings, }; export default settingsSlice.reducer; diff --git a/src/styles.tsx b/src/styles.tsx index 37cc5e2..06ec455 100644 --- a/src/styles.tsx +++ b/src/styles.tsx @@ -38,7 +38,7 @@ const styles = StyleSheet.create({ centerText: { textAlign: 'center', }, - selfCenter: { + centerSelf: { alignSelf: 'center', }, flex: { diff --git a/src/utilities/color.ts b/src/utilities/color.ts index e97cf5d..76ce8e4 100644 --- a/src/utilities/color.ts +++ b/src/utilities/color.ts @@ -23,10 +23,6 @@ const isRgbColor = (color: string) => { return /^rgb\((\d{1,3}), ?(\d{1,3}), ?(\d{1,3})\)$/i.test(color); }; -const isValidColor = (color: string) => { - return isHexColor(color) || isRgbColor(color); -}; - const rgbToHex = (rgb: string) => { const [r, g, b] = rgb .replaceAll(/[^\d,]/g, '') @@ -37,10 +33,22 @@ const rgbToHex = (rgb: string) => { }; const generateRandomColor = () => { - const r = Math.floor(Math.random() * 256).toString(16).padStart(2, '0'); - const g = Math.floor(Math.random() * 256).toString(16).padStart(2, '0'); - const b = Math.floor(Math.random() * 256).toString(16).padStart(2, '0'); + const r = Math.floor(Math.random() * 256) + .toString(16) + .padStart(2, '0'); + const g = Math.floor(Math.random() * 256) + .toString(16) + .padStart(2, '0'); + const b = Math.floor(Math.random() * 256) + .toString(16) + .padStart(2, '0'); return `#${r}${g}${b}`; }; -export { getContrastColor, isHexColor, isRgbColor, isValidColor, rgbToHex, generateRandomColor }; +export { + getContrastColor, + isHexColor, + isRgbColor, + rgbToHex, + generateRandomColor, +}; diff --git a/src/utilities/database.ts b/src/utilities/database.ts index 11b1abf..2e06fd2 100644 --- a/src/utilities/database.ts +++ b/src/utilities/database.ts @@ -1,5 +1,5 @@ const multipleIdQuery = (ids: string[]) => { - return `id in {${ids.map(id => `uuid(${id})`).join(',')}}`; + return `id IN {${ids.map(id => `uuid(${id})`).join(',')}}`; }; export { multipleIdQuery }; diff --git a/src/utilities/index.ts b/src/utilities/index.ts index 3f57914..442c013 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -2,7 +2,6 @@ export { getContrastColor, isHexColor, isRgbColor, - isValidColor, rgbToHex, generateRandomColor, } from './color'; @@ -16,3 +15,10 @@ export { } from './filesystem'; export { isPermissionForPath, clearPermissions } from './permissions'; export { getSortIcon, getViewIcon } from './icon'; +export { + type StringValidationResult, + validateMemeTitle, + validateMemeDescription, + validateTagName, + validateColor, +} from './validation'; diff --git a/src/utilities/validation.ts b/src/utilities/validation.ts new file mode 100644 index 0000000..ddd3ecf --- /dev/null +++ b/src/utilities/validation.ts @@ -0,0 +1,85 @@ +import { isHexColor, isRgbColor } from './color'; + +interface StringValidationResult { + valid: boolean; + raw: string; + parsed: string; + error?: string; +} + +const validateMemeTitle = (title: string): StringValidationResult => { + const parsedTitle = title.trim(); + + if (parsedTitle.length === 0) { + return { + valid: false, + raw: title, + parsed: parsedTitle, + error: 'Title cannot be empty', + }; + } + + return { + valid: true, + raw: title, + parsed: parsedTitle, + }; +}; + +const validateMemeDescription = ( + description: string, +): StringValidationResult => { + const parsedDescription = description.trim(); + + return { + valid: true, + raw: description, + parsed: parsedDescription, + }; +}; + +const validateTagName = (name: string): StringValidationResult => { + const parsedName = name.trim(); + + if (parsedName.length === 0) { + return { + valid: false, + raw: name, + parsed: parsedName, + error: 'Name cannot be empty', + }; + } + + return { + valid: true, + raw: name, + parsed: parsedName, + }; +}; + +const validateColor = (color: string): StringValidationResult => { + const parsedColor = color.trim().toLowerCase(); + + if (!isHexColor(parsedColor) && !isRgbColor(parsedColor)) { + return { + valid: false, + raw: color, + parsed: parsedColor, + error: 'Color must be a valid hex or rgb value', + }; + } + + return { + valid: true, + raw: color, + parsed: parsedColor, + }; +}; + +export { + type StringValidationResult, + validateMemeTitle, + validateMemeDescription, + validateTagName, + validateColor, +};