Refactor validation

Signed-off-by: Nikolaos Karaolidis <nick@karaolidis.com>
This commit is contained in:
2023-07-21 16:34:44 +03:00
parent b7dd1c77af
commit 3c303e0304
17 changed files with 256 additions and 151 deletions

View File

@@ -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<string, Tag>;
setMemeTags: (tags: Map<string, Tag>) => 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 <LoadingView />;
return (
@@ -57,13 +48,13 @@ const MemeEditor = ({
<TextInput
mode="outlined"
label="Title"
value={memeTitle}
onChangeText={handleMemeTitleChange}
error={!!memeTitleError}
value={memeTitle.raw}
onChangeText={title => setMemeTitle(validateMemeTitle(title))}
error={!memeTitle.valid}
selectTextOnFocus
/>
<HelperText type="error" visible={!!memeTitleError}>
{memeTitleError}
<HelperText type="error" visible={!memeTitle.valid}>
{memeTitle.error}
</HelperText>
<Image
source={{ uri: imageUri[0] }}
@@ -89,8 +80,10 @@ const MemeEditor = ({
style={{
marginBottom: responsive.verticalScale(15),
}}
value={memeDescription}
onChangeText={setMemeDescription}
value={memeDescription.raw}
onChangeText={description =>
setMemeDescription(validateMemeDescription(description))
}
/>
</>
);

View File

@@ -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<FlashList<Tag>>(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>(
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 = ({
<Chip
icon="plus"
mode="outlined"
onPress={() => 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'}
</Chip>
)}
/>

View File

@@ -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 (
<>
<TagPreview name={tagName} color={validatedTagColor} />
<TagPreview name={tagName.parsed} color={lastValidTagColor.current} />
<TextInput
mode="outlined"
label="Name"
value={tagName}
onChangeText={handleTagNameChange}
error={!!tagNameError}
value={tagName.raw}
onChangeText={name => setTagName(validateTagName(name))}
error={!tagName.valid}
autoCapitalize="none"
selectTextOnFocus
/>
<HelperText type="error" visible={!!tagNameError}>
{tagNameError}
<HelperText type="error" visible={!tagName.valid}>
{tagName.error}
</HelperText>
<TextInput
mode="outlined"
label="Color"
value={tagColor}
value={tagColor.raw}
onChangeText={handleTagColorChange}
error={!!tagColorError}
error={!tagColor.valid}
autoCorrect={false}
right={
<TextInput.Icon
@@ -80,8 +55,8 @@ const TagEditor = ({
/>
}
/>
<HelperText type="error" visible={!!tagColorError}>
{tagColorError}
<HelperText type="error" visible={!tagColor.valid}>
{tagColor.error}
</HelperText>
</>
);

View File

@@ -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<string, Tag>());
const [memeTitleError, setMemeTitleError] = useState<string | undefined>();
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}
/>
</View>
<View style={[styles.flex, styles.justifyEnd]}>
@@ -128,7 +130,7 @@ const AddMeme = ({
mode="contained"
icon="floppy"
onPress={handleSave}
disabled={!!memeTitleError || isSaving}
disabled={!memeTitle.valid || !memeDescription.valid || isSaving}
loading={isSaving}>
Save
</Button>

View File

@@ -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<string | undefined>();
const [tagColorError, setTagColorError] = useState<string | undefined>();
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}
/>
</View>
<View style={[styles.flex, styles.justifyEnd]}>
@@ -67,7 +63,7 @@ const AddTag = () => {
mode="contained"
icon="floppy"
onPress={handleSave}
disabled={!!tagNameError || !!tagColorError}>
disabled={!tagName.valid || !tagColor.valid}>
Save
</Button>
</View>

View File

@@ -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<string | undefined>();
const [tagColorError, setTagColorError] = useState<string | undefined>();
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}
/>
</View>
<View style={[styles.flex, styles.justifyEnd]}>
@@ -93,7 +84,7 @@ const EditTag = ({
mode="contained"
icon="floppy"
onPress={handleSave}
disabled={!!tagNameError || !!tagColorError}>
disabled={!tagName.valid || !tagColor.valid}>
Save
</Button>
</View>

View File

@@ -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>(Meme.schema.name);
const memes = useQuery<Meme>(
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 (
<View

View File

@@ -13,7 +13,11 @@ import { openDocumentTree } from 'react-native-scoped-storage';
import { useDispatch, useSelector } from 'react-redux';
import type {} from 'redux-thunk/extend-redux';
import styles from '../styles';
import { RootState, updateNoMedia, updateStorageUri } from '../state';
import {
RootState,
setNoMedia,
setStorageUri,
} from '../state';
import { useDimensions } from '../contexts';
const settingsScreenStyles = StyleSheet.create({
@@ -68,7 +72,7 @@ const SettingsScreen = () => {
}}
onPress={async () => {
const { uri } = await openDocumentTree(true);
void dispatch(updateStorageUri(uri));
void dispatch(setStorageUri(uri));
}}>
Change External Storage Path
</Button>
@@ -84,7 +88,7 @@ const SettingsScreen = () => {
<Switch
value={noMedia}
onValueChange={value => {
void dispatch(updateNoMedia(value));
void dispatch(setNoMedia(value));
}}
/>
</View>

View File

@@ -99,13 +99,20 @@ const Tags = () => {
const tags = useQuery<Tag>(
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],
);

View File

@@ -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();
};

View File

@@ -52,8 +52,8 @@ const persistor = persistStore(store);
export { type RootState, store, persistor };
export {
type SettingsState,
updateStorageUri,
updateNoMedia,
setStorageUri,
setNoMedia,
validateSettings,
} from './settings';
export {

View File

@@ -109,8 +109,8 @@ const validateSettings = createAsyncThunk(
export {
type SettingsState,
updateStorageUri,
updateNoMedia,
updateStorageUri as setStorageUri,
updateNoMedia as setNoMedia,
validateSettings,
};
export default settingsSlice.reducer;

View File

@@ -38,7 +38,7 @@ const styles = StyleSheet.create({
centerText: {
textAlign: 'center',
},
selfCenter: {
centerSelf: {
alignSelf: 'center',
},
flex: {

View File

@@ -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,
};

View File

@@ -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 };

View File

@@ -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';

View File

@@ -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,
};