Add folder scaffolding (#1404)

This commit is contained in:
Leendert de Borst
2025-12-28 15:53:02 +01:00
parent f7d0030fab
commit a6d3d7119c
12 changed files with 2246 additions and 385 deletions

View File

@@ -21,6 +21,14 @@ export default function ItemsLayout(): React.ReactNode {
...defaultHeaderOptions,
}}
/>
<Stack.Screen
name="folder/[id]"
options={{
title: t('items.folders.folder'),
headerBackTitle: t('items.title'),
...defaultHeaderOptions,
}}
/>
<Stack.Screen
name="add-edit"
options={{

View File

@@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next';
import { StyleSheet, View, Alert, Keyboard, Platform, ScrollView, KeyboardAvoidingView } from 'react-native';
import Toast from 'react-native-toast-message';
import type { Folder } from '@/utils/db/repositories/FolderRepository';
import { CreateIdentityGenerator, CreateUsernameEmailGenerator, Gender, Identity, IdentityHelperUtils, convertAgeRangeToBirthdateOptions } from '@/utils/dist/core/identity-generator';
import type { Attachment, Item, ItemField, TotpCode, ItemType, FieldType } from '@/utils/dist/core/models/vault';
import { ItemTypes, getSystemFieldsForItemType, getOptionalFieldsForItemType, isFieldShownByDefault, getSystemField, fieldAppliesToType, FieldCategories, FieldTypes } from '@/utils/dist/core/models/vault';
@@ -20,6 +21,7 @@ import { extractServiceNameFromUrl } from '@/utils/UrlUtility';
import { useColors } from '@/hooks/useColorScheme';
import { useVaultMutate } from '@/hooks/useVaultMutate';
import { FolderSelector } from '@/components/folders/FolderSelector';
import { AddFieldMenu, type OptionalSection } from '@/components/form/AddFieldMenu';
import { AdvancedPasswordField } from '@/components/form/AdvancedPasswordField';
import { EmailDomainField } from '@/components/form/EmailDomainField';
@@ -97,6 +99,9 @@ export default function AddEditItemScreen(): React.ReactNode {
const [show2FA, setShow2FA] = useState(false);
const [showAttachments, setShowAttachments] = useState(false);
// Folder state
const [folders, setFolders] = useState<Folder[]>([]);
// Track manually added optional fields
const [manuallyAddedFields, setManuallyAddedFields] = useState<Set<string>>(new Set());
@@ -488,6 +493,14 @@ export default function AddEditItemScreen(): React.ReactNode {
return;
}
// Load folders for folder selection
try {
const loadedFolders = await dbContext.sqliteClient!.getAllFolders();
setFolders(loadedFolders);
} catch (err) {
console.error('Error loading folders:', err);
}
if (isEditMode) {
loadExistingItem();
} else {
@@ -534,7 +547,7 @@ export default function AddEditItemScreen(): React.ReactNode {
};
initializeComponent();
}, [id, isEditMode, serviceUrl, itemTypeParam, loadExistingItem, authContext.isOffline, router, t]);
}, [id, isEditMode, serviceUrl, itemTypeParam, loadExistingItem, authContext.isOffline, router, t, dbContext.sqliteClient]);
/**
* Auto-generate alias when alias fields are shown by default in create mode.
@@ -1257,6 +1270,17 @@ export default function AddEditItemScreen(): React.ReactNode {
)}
</View>
))}
{/* Folder selection */}
{folders.length > 0 && (
<FolderSelector
folders={folders}
selectedFolderId={item.FolderId}
onFolderChange={(folderId) => {
setItem(prev => prev ? { ...prev, FolderId: folderId } : prev);
setHasUnsavedChanges(true);
}}
/>
)}
</FormSection>
{/* Passkey Section - only in edit mode for items with passkeys */}

View File

@@ -0,0 +1,562 @@
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { useNavigation } from '@react-navigation/native';
import * as Haptics from 'expo-haptics';
import { useRouter, useLocalSearchParams } from 'expo-router';
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, StyleSheet, Platform, View, Text, TextInput, TouchableOpacity, RefreshControl, FlatList } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import Toast from 'react-native-toast-message';
import type { Folder } from '@/utils/db/repositories/FolderRepository';
import type { Item } from '@/utils/dist/core/models/vault';
import { getFieldValue, FieldKey } from '@/utils/dist/core/models/vault';
import emitter from '@/utils/EventEmitter';
import { VaultAuthenticationError } from '@/utils/types/errors/VaultAuthenticationError';
import { useColors } from '@/hooks/useColorScheme';
import { useMinDurationLoading } from '@/hooks/useMinDurationLoading';
import { useVaultMutate } from '@/hooks/useVaultMutate';
import { useVaultSync } from '@/hooks/useVaultSync';
import { DeleteFolderModal } from '@/components/folders/DeleteFolderModal';
import { FolderModal } from '@/components/folders/FolderModal';
import { ItemCard } from '@/components/items/ItemCard';
import LoadingOverlay from '@/components/LoadingOverlay';
import { ThemedContainer } from '@/components/themed/ThemedContainer';
import { ThemedText } from '@/components/themed/ThemedText';
import { ThemedView } from '@/components/themed/ThemedView';
import { RobustPressable } from '@/components/ui/RobustPressable';
import { SkeletonLoader } from '@/components/ui/SkeletonLoader';
import { useApp } from '@/context/AppContext';
import { useDb } from '@/context/DbContext';
/**
* Folder view screen - displays items within a specific folder.
* Simplified view with search scoped to this folder only.
*/
export default function FolderViewScreen(): React.ReactNode {
const { id: folderId } = useLocalSearchParams<{ id: string }>();
const { syncVault } = useVaultSync();
const colors = useColors();
const { t } = useTranslation();
const navigation = useNavigation();
const router = useRouter();
const insets = useSafeAreaInsets();
const flatListRef = useRef<FlatList<Item | null>>(null);
const [itemsList, setItemsList] = useState<Item[]>([]);
const [folder, setFolder] = useState<Folder | null>(null);
const [isLoadingItems, setIsLoadingItems] = useMinDurationLoading(false, 200);
const [refreshing, setRefreshing] = useMinDurationLoading(false, 200);
const { executeVaultMutation, isLoading, syncStatus } = useVaultMutate();
const [isSyncing, setIsSyncing] = useState(false);
// Search state (scoped to this folder)
const [searchQuery, setSearchQuery] = useState('');
// Folder modals
const [showEditFolderModal, setShowEditFolderModal] = useState(false);
const [showDeleteFolderModal, setShowDeleteFolderModal] = useState(false);
const authContext = useApp();
const dbContext = useDb();
const isAuthenticated = authContext.isLoggedIn;
const isDatabaseAvailable = dbContext.dbAvailable;
/**
* Filter items by search query (within this folder only).
*/
const filteredItems = useMemo(() => {
const searchLower = searchQuery.toLowerCase().trim();
if (!searchLower) {
return itemsList;
}
return itemsList.filter(item => {
const searchableFields = [
item.Name?.toLowerCase() || '',
getFieldValue(item, FieldKey.LoginUsername)?.toLowerCase() || '',
getFieldValue(item, FieldKey.LoginEmail)?.toLowerCase() || '',
getFieldValue(item, FieldKey.LoginUrl)?.toLowerCase() || '',
getFieldValue(item, FieldKey.NotesContent)?.toLowerCase() || '',
];
const searchWords = searchLower.split(/\s+/).filter(word => word.length > 0);
return searchWords.every(word =>
searchableFields.some(field => field.includes(word))
);
});
}, [itemsList, searchQuery]);
/**
* Load items in this folder and folder details.
*/
const loadItems = useCallback(async (): Promise<void> => {
if (!folderId) {
return;
}
try {
const [items, folders] = await Promise.all([
dbContext.sqliteClient!.getAllItems(),
dbContext.sqliteClient!.getAllFolders()
]);
// Filter to only items in this folder
const folderItems = items.filter((item: Item) => item.FolderId === folderId);
setItemsList(folderItems);
// Find this folder
const currentFolder = folders.find((f: Folder) => f.Id === folderId);
setFolder(currentFolder || null);
setIsLoadingItems(false);
} catch (err) {
Toast.show({
type: 'error',
text1: t('items.errorLoadingItems'),
text2: err instanceof Error ? err.message : 'Unknown error',
});
setIsLoadingItems(false);
}
}, [dbContext.sqliteClient, folderId, setIsLoadingItems, t]);
useEffect(() => {
// Add listener for item changes
const itemChangedSub = emitter.addListener('credentialChanged', async () => {
await loadItems();
});
return (): void => {
itemChangedSub.remove();
};
}, [loadItems]);
/**
* Handle pull-to-refresh.
*/
const onRefresh = useCallback(async () => {
if (Platform.OS === 'ios' || Platform.OS === 'android') {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
setRefreshing(true);
setIsLoadingItems(true);
if (authContext.isOffline) {
setRefreshing(false);
setIsLoadingItems(false);
return;
}
try {
await syncVault({
/**
* On success.
*/
onSuccess: async (hasNewVault) => {
await loadItems();
setIsLoadingItems(false);
setRefreshing(false);
setTimeout(() => {
Toast.show({
type: 'success',
text1: hasNewVault ? t('items.vaultSyncedSuccessfully') : t('items.vaultUpToDate'),
position: 'top',
visibilityTime: 1200,
});
}, 200);
},
/**
* On offline.
*/
onOffline: () => {
setRefreshing(false);
setIsLoadingItems(false);
authContext.setOfflineMode(true);
setTimeout(() => {
Toast.show({
type: 'error',
text1: t('items.offlineMessage'),
position: 'bottom',
});
}, 200);
},
/**
* On error.
*/
onError: async (error) => {
console.error('Error syncing vault:', error);
setRefreshing(false);
setIsLoadingItems(false);
Alert.alert(
t('common.error'),
error,
[{ text: t('common.ok'), style: 'default' }]
);
},
/**
* On upgrade required.
*/
onUpgradeRequired: (): void => {
router.replace('/upgrade');
},
});
} catch (err) {
console.error('Error refreshing items:', err);
setRefreshing(false);
setIsLoadingItems(false);
if (!(err instanceof VaultAuthenticationError)) {
Toast.show({
type: 'error',
text1: t('items.vaultSyncFailed'),
text2: err instanceof Error ? err.message : 'Unknown error',
});
}
}
}, [syncVault, loadItems, setIsLoadingItems, setRefreshing, authContext, router, t]);
useEffect(() => {
if (!isAuthenticated || !isDatabaseAvailable) {
return;
}
setIsLoadingItems(true);
loadItems();
}, [isAuthenticated, isDatabaseAvailable, loadItems, setIsLoadingItems]);
/**
* Set up header with folder name and edit/delete buttons.
*/
useEffect(() => {
navigation.setOptions({
title: folder?.Name || t('items.folders.folder'),
/**
* Header right buttons for edit and delete.
*/
headerRight: (): React.ReactNode => (
<View style={{ flexDirection: 'row', gap: 4 }}>
<RobustPressable
onPress={() => setShowEditFolderModal(true)}
style={{ padding: 8 }}
>
<MaterialIcons
name="edit"
size={Platform.OS === 'android' ? 24 : 22}
color={colors.primary}
/>
</RobustPressable>
<RobustPressable
onPress={() => setShowDeleteFolderModal(true)}
style={{ padding: 8 }}
>
<MaterialIcons
name="delete"
size={Platform.OS === 'android' ? 24 : 22}
color={colors.destructive}
/>
</RobustPressable>
</View>
),
});
}, [navigation, folder?.Name, colors.primary, colors.destructive, t]);
/**
* Delete an item (move to trash).
*/
const onItemDelete = useCallback(async (itemId: string): Promise<void> => {
setIsSyncing(true);
await executeVaultMutation(async () => {
await dbContext.sqliteClient!.trashItem(itemId);
setIsSyncing(false);
});
await new Promise(resolve => setTimeout(resolve, 250));
await loadItems();
}, [dbContext.sqliteClient, executeVaultMutation, loadItems]);
/**
* Rename the folder.
*/
const handleEditFolder = useCallback(async (newName: string) => {
if (!folderId) {
return;
}
await executeVaultMutation(async () => {
await dbContext.sqliteClient!.updateFolder(folderId, newName);
});
await loadItems();
setShowEditFolderModal(false);
}, [dbContext.sqliteClient, folderId, executeVaultMutation, loadItems]);
/**
* Delete the folder (keep items - move them to root).
*/
const handleDeleteFolderOnly = useCallback(async () => {
if (!folderId) {
return;
}
await executeVaultMutation(async () => {
await dbContext.sqliteClient!.deleteFolder(folderId);
});
router.back();
}, [dbContext.sqliteClient, folderId, executeVaultMutation, router]);
/**
* Delete the folder and all its contents.
*/
const handleDeleteFolderAndContents = useCallback(async () => {
if (!folderId) {
return;
}
await executeVaultMutation(async () => {
await dbContext.sqliteClient!.deleteFolderWithContents(folderId);
});
router.back();
}, [dbContext.sqliteClient, folderId, executeVaultMutation, router]);
/**
* Handle FAB press - navigate to add item screen with folder pre-selected.
*/
const handleAddItem = useCallback(() => {
router.push(`/(tabs)/items/add-edit?folderId=${folderId}` as '/(tabs)/items/add-edit');
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}, [folderId, router]);
// Header styles (stable, not dependent on colors) - prefixed with _ as styles are inlined in useEffect
const _headerStyles = StyleSheet.create({
headerButton: {
padding: 8,
},
headerRightContainer: {
flexDirection: 'row',
gap: 4,
},
});
const paddingTop = Platform.OS === 'ios' ? 16 : 16;
const paddingBottom = Platform.OS === 'ios' ? insets.bottom + 60 : 40;
const styles = StyleSheet.create({
container: {
paddingHorizontal: 0,
paddingTop: paddingTop + 100,
},
contentContainer: {
paddingBottom: paddingBottom,
paddingHorizontal: 14,
paddingTop: paddingTop,
},
// Search styles
searchContainer: {
position: 'relative',
},
searchIcon: {
left: 12,
position: 'absolute',
top: 11,
zIndex: 1,
},
searchInput: {
backgroundColor: colors.accentBackground,
borderRadius: 8,
color: colors.text,
fontSize: 16,
height: 40,
lineHeight: 20,
marginBottom: 16,
paddingLeft: 40,
paddingRight: Platform.OS === 'android' ? 40 : 12,
},
clearButton: {
padding: 4,
position: 'absolute',
right: 8,
top: 4,
},
clearButtonText: {
color: colors.textMuted,
fontSize: 20,
},
// Item count styles
itemCountContainer: {
marginBottom: 12,
},
itemCountText: {
color: colors.textMuted,
fontSize: 14,
},
// Empty state styles
emptyText: {
color: colors.textMuted,
fontSize: 16,
marginTop: 24,
opacity: 0.7,
textAlign: 'center',
},
emptyHint: {
color: colors.textMuted,
fontSize: 14,
marginTop: 8,
opacity: 0.6,
textAlign: 'center',
paddingHorizontal: 32,
},
// FAB styles
fab: {
alignItems: 'center',
backgroundColor: colors.primary,
borderRadius: 28,
bottom: Platform.OS === 'ios' ? insets.bottom + 60 : 16,
elevation: 4,
height: 56,
justifyContent: 'center',
position: 'absolute',
right: 16,
shadowColor: colors.black,
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.25,
shadowRadius: 3.84,
width: 56,
zIndex: 1000,
},
fabIcon: {
color: colors.primarySurfaceText,
fontSize: 24,
},
});
/**
* Render the list header with search.
*/
const renderListHeader = (): React.ReactNode => {
return (
<ThemedView>
{/* Search input */}
<ThemedView style={styles.searchContainer}>
<MaterialIcons
name="search"
size={20}
color={colors.textMuted}
style={styles.searchIcon}
/>
<TextInput
style={styles.searchInput}
placeholder={t('items.searchPlaceholder')}
placeholderTextColor={colors.textMuted}
value={searchQuery}
autoCorrect={false}
autoCapitalize="none"
multiline={false}
numberOfLines={1}
onChangeText={setSearchQuery}
clearButtonMode={Platform.OS === 'ios' ? 'while-editing' : 'never'}
/>
{Platform.OS === 'android' && searchQuery.length > 0 && (
<TouchableOpacity
style={styles.clearButton}
onPress={() => setSearchQuery('')}
>
<ThemedText style={styles.clearButtonText}>×</ThemedText>
</TouchableOpacity>
)}
</ThemedView>
</ThemedView>
);
};
/**
* Render empty state.
*/
const renderEmptyComponent = (): React.ReactNode => {
if (isLoadingItems) {
return null;
}
return (
<View>
<Text style={styles.emptyText}>
{searchQuery
? t('items.noMatchingItems')
: t('items.folders.emptyFolder')
}
</Text>
{!searchQuery && (
<Text style={styles.emptyHint}>
{t('items.folders.emptyFolderHint')}
</Text>
)}
</View>
);
};
return (
<ThemedContainer style={styles.container}>
{isSyncing && <LoadingOverlay status={syncStatus} />}
{/* FAB */}
<RobustPressable style={styles.fab} onPress={handleAddItem}>
<MaterialIcons name="add" style={styles.fabIcon} />
</RobustPressable>
{/* Item list */}
<FlatList
ref={flatListRef}
data={isLoadingItems ? Array(4).fill(null) : filteredItems}
keyExtractor={(itm, index) => itm?.Id ?? `skeleton-${index}`}
keyboardShouldPersistTaps='handled'
contentContainerStyle={styles.contentContainer}
scrollIndicatorInsets={{ bottom: 40 }}
initialNumToRender={14}
maxToRenderPerBatch={14}
windowSize={7}
removeClippedSubviews={false}
ListHeaderComponent={renderListHeader() as React.ReactElement}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
colors={[colors.primary]}
tintColor={colors.primary}
/>
}
renderItem={({ item: itm }) =>
isLoadingItems ? (
<SkeletonLoader count={1} height={60} parts={2} />
) : (
<ItemCard item={itm} onItemDelete={onItemDelete} />
)
}
ListEmptyComponent={renderEmptyComponent() as React.ReactElement}
/>
{isLoading && <LoadingOverlay status={syncStatus || t('items.deletingItem')} />}
{/* Folder modals */}
<FolderModal
isOpen={showEditFolderModal}
onClose={() => setShowEditFolderModal(false)}
onSave={handleEditFolder}
initialName={folder?.Name || ''}
mode="edit"
/>
<DeleteFolderModal
isOpen={showDeleteFolderModal}
onClose={() => setShowDeleteFolderModal(false)}
onDeleteFolderOnly={handleDeleteFolderOnly}
onDeleteFolderAndContents={handleDeleteFolderAndContents}
itemCount={itemsList.length}
/>
</ThemedContainer>
);
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,220 @@
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Modal,
StyleSheet,
Text,
TouchableOpacity,
View,
ActivityIndicator,
} from 'react-native';
import { useColors } from '@/hooks/useColorScheme';
interface IDeleteFolderModalProps {
isOpen: boolean;
onClose: () => void;
onDeleteFolderOnly: () => Promise<void>;
onDeleteFolderAndContents: () => Promise<void>;
itemCount: number;
}
/**
* Modal for deleting a folder with options to keep or delete contents.
*/
export const DeleteFolderModal: React.FC<IDeleteFolderModalProps> = ({
isOpen,
onClose,
onDeleteFolderOnly,
onDeleteFolderAndContents,
itemCount,
}) => {
const { t } = useTranslation();
const colors = useColors();
const [isSubmitting, setIsSubmitting] = useState(false);
/**
* Handle delete folder only (move items to root).
*/
const handleDeleteFolderOnly = async (): Promise<void> => {
setIsSubmitting(true);
try {
await onDeleteFolderOnly();
onClose();
} catch (err) {
console.error('Error deleting folder:', err);
} finally {
setIsSubmitting(false);
}
};
/**
* Handle delete folder and all contents.
*/
const handleDeleteFolderAndContents = async (): Promise<void> => {
setIsSubmitting(true);
try {
await onDeleteFolderAndContents();
onClose();
} catch (err) {
console.error('Error deleting folder with contents:', err);
} finally {
setIsSubmitting(false);
}
};
/**
* Handle close - only allow if not submitting.
*/
const handleClose = (): void => {
if (!isSubmitting) {
onClose();
}
};
const styles = StyleSheet.create({
backdrop: {
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
flex: 1,
justifyContent: 'center',
},
cancelButton: {
alignItems: 'center',
backgroundColor: colors.accentBackground,
borderColor: colors.accentBorder,
borderRadius: 8,
borderWidth: 1,
marginTop: 12,
paddingVertical: 12,
},
cancelButtonText: {
color: colors.text,
fontSize: 16,
fontWeight: '500',
},
container: {
backgroundColor: colors.background,
borderRadius: 12,
marginHorizontal: 20,
maxWidth: 400,
padding: 20,
width: '90%',
},
optionButton: {
borderColor: colors.accentBorder,
borderRadius: 10,
borderWidth: 1,
flexDirection: 'row',
gap: 12,
marginTop: 12,
padding: 14,
},
optionButtonDanger: {
borderColor: colors.destructive,
},
optionContent: {
flex: 1,
},
optionDescription: {
color: colors.textMuted,
fontSize: 13,
marginTop: 2,
},
optionDescriptionDanger: {
color: colors.textMuted,
},
optionIcon: {
marginTop: 2,
},
optionTitle: {
color: colors.text,
fontSize: 15,
fontWeight: '600',
},
optionTitleDanger: {
color: colors.destructive,
},
title: {
color: colors.text,
fontSize: 18,
fontWeight: '600',
marginBottom: 4,
},
});
return (
<Modal
visible={isOpen}
transparent
animationType="fade"
onRequestClose={handleClose}
>
<View style={styles.backdrop}>
<View style={styles.container}>
<Text style={styles.title}>{t('items.folders.deleteFolder')}</Text>
{/* Option 1: Delete folder only - move items to root */}
<TouchableOpacity
style={styles.optionButton}
onPress={handleDeleteFolderOnly}
disabled={isSubmitting}
>
<MaterialIcons
name="folder"
size={22}
color={colors.tint}
style={styles.optionIcon}
/>
<View style={styles.optionContent}>
<Text style={styles.optionTitle}>
{t('items.folders.deleteFolderKeepItems')}
</Text>
<Text style={styles.optionDescription}>
{t('items.folders.deleteFolderKeepItemsDescription')}
</Text>
</View>
{isSubmitting && <ActivityIndicator size="small" color={colors.primary} />}
</TouchableOpacity>
{/* Option 2: Delete folder and contents */}
{itemCount > 0 && (
<TouchableOpacity
style={[styles.optionButton, styles.optionButtonDanger]}
onPress={handleDeleteFolderAndContents}
disabled={isSubmitting}
>
<MaterialIcons
name="delete"
size={22}
color={colors.destructive}
style={styles.optionIcon}
/>
<View style={styles.optionContent}>
<Text style={[styles.optionTitle, styles.optionTitleDanger]}>
{t('items.folders.deleteFolderAndItems')}
</Text>
<Text style={[styles.optionDescription, styles.optionDescriptionDanger]}>
{t('items.folders.deleteFolderAndItemsDescription', { count: itemCount })}
</Text>
</View>
{isSubmitting && <ActivityIndicator size="small" color={colors.destructive} />}
</TouchableOpacity>
)}
{/* Cancel button */}
<TouchableOpacity
style={styles.cancelButton}
onPress={handleClose}
disabled={isSubmitting}
>
<Text style={styles.cancelButtonText}>{t('common.cancel')}</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
);
};
export default DeleteFolderModal;

View File

@@ -0,0 +1,223 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
Modal,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
KeyboardAvoidingView,
Platform,
TouchableWithoutFeedback,
Keyboard,
ActivityIndicator,
} from 'react-native';
import { useColors } from '@/hooks/useColorScheme';
interface IFolderModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (folderName: string) => Promise<void>;
initialName?: string;
mode: 'create' | 'edit';
}
/**
* Modal for creating or editing a folder.
*/
export const FolderModal: React.FC<IFolderModalProps> = ({
isOpen,
onClose,
onSave,
initialName = '',
mode,
}) => {
const { t } = useTranslation();
const colors = useColors();
const [folderName, setFolderName] = useState(initialName);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (isOpen) {
setFolderName(initialName);
setError(null);
}
}, [isOpen, initialName]);
/**
* Handle the form submission.
*/
const handleSubmit = async (): Promise<void> => {
const trimmedName = folderName.trim();
if (!trimmedName) {
setError(t('items.folders.folderNameRequired'));
return;
}
setIsSubmitting(true);
setError(null);
try {
await onSave(trimmedName);
onClose();
} catch (err) {
setError(t('common.errors.unknownErrorTryAgain'));
console.error('Error saving folder:', err);
} finally {
setIsSubmitting(false);
}
};
/**
* Handle close - only allow if not submitting
*/
const handleClose = (): void => {
if (!isSubmitting) {
onClose();
}
};
const styles = StyleSheet.create({
backdrop: {
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
flex: 1,
justifyContent: 'center',
},
buttonRow: {
flexDirection: 'row',
gap: 12,
marginTop: 20,
},
cancelButton: {
alignItems: 'center',
borderColor: colors.accentBorder,
borderRadius: 8,
borderWidth: 1,
flex: 1,
paddingVertical: 12,
},
cancelButtonText: {
color: colors.text,
fontSize: 16,
fontWeight: '500',
},
container: {
backgroundColor: colors.background,
borderRadius: 12,
marginHorizontal: 20,
maxWidth: 400,
padding: 20,
width: '90%',
},
errorText: {
color: colors.destructive,
fontSize: 14,
marginTop: 8,
},
input: {
backgroundColor: colors.accentBackground,
borderColor: colors.accentBorder,
borderRadius: 8,
borderWidth: 1,
color: colors.text,
fontSize: 16,
marginTop: 8,
paddingHorizontal: 12,
paddingVertical: 10,
},
label: {
color: colors.textMuted,
fontSize: 14,
fontWeight: '500',
},
saveButton: {
alignItems: 'center',
backgroundColor: colors.tint,
borderRadius: 8,
flex: 1,
paddingVertical: 12,
},
saveButtonDisabled: {
opacity: 0.6,
},
saveButtonText: {
color: '#FFFFFF',
fontSize: 16,
fontWeight: '600',
},
title: {
color: colors.text,
fontSize: 18,
fontWeight: '600',
marginBottom: 16,
},
});
return (
<Modal
visible={isOpen}
transparent
animationType="fade"
onRequestClose={handleClose}
>
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.backdrop}
>
<TouchableWithoutFeedback>
<View style={styles.container}>
<Text style={styles.title}>
{mode === 'create' ? t('items.folders.createFolder') : t('items.folders.editFolder')}
</Text>
<Text style={styles.label}>{t('items.folders.folderName')}</Text>
<TextInput
style={styles.input}
value={folderName}
onChangeText={setFolderName}
placeholder={t('items.folders.folderNamePlaceholder')}
placeholderTextColor={colors.textMuted}
autoFocus
autoCapitalize="sentences"
editable={!isSubmitting}
/>
{error && <Text style={styles.errorText}>{error}</Text>}
<View style={styles.buttonRow}>
<TouchableOpacity
style={styles.cancelButton}
onPress={handleClose}
disabled={isSubmitting}
>
<Text style={styles.cancelButtonText}>{t('common.cancel')}</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.saveButton, isSubmitting && styles.saveButtonDisabled]}
onPress={handleSubmit}
disabled={isSubmitting}
>
{isSubmitting ? (
<ActivityIndicator color="#FFFFFF" size="small" />
) : (
<Text style={styles.saveButtonText}>
{mode === 'create' ? t('common.add') : t('common.save')}
</Text>
)}
</TouchableOpacity>
</View>
</View>
</TouchableWithoutFeedback>
</KeyboardAvoidingView>
</TouchableWithoutFeedback>
</Modal>
);
};
export default FolderModal;

View File

@@ -0,0 +1,67 @@
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import React from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { useColors } from '@/hooks/useColorScheme';
/**
* Folder with item count for display.
*/
export type FolderWithCount = {
id: string;
name: string;
itemCount: number;
};
interface IFolderPillProps {
folder: FolderWithCount;
onPress: () => void;
}
/**
* FolderPill component
*
* Displays a folder as a compact pill/tag that can be clicked to navigate into.
* Designed to be displayed inline with other folder pills.
*/
export const FolderPill: React.FC<IFolderPillProps> = ({ folder, onPress }) => {
const colors = useColors();
const styles = StyleSheet.create({
container: {
alignItems: 'center',
backgroundColor: colors.accentBackground,
borderColor: colors.accentBorder,
borderRadius: 20,
borderWidth: 1,
flexDirection: 'row',
gap: 6,
paddingHorizontal: 12,
paddingVertical: 8,
},
folderName: {
color: colors.text,
fontSize: 14,
fontWeight: '500',
maxWidth: 120,
},
itemCount: {
color: colors.textMuted,
fontSize: 12,
},
});
return (
<TouchableOpacity style={styles.container} onPress={onPress} activeOpacity={0.7}>
<MaterialIcons name="folder" size={16} color={colors.tint} />
<Text style={styles.folderName} numberOfLines={1} ellipsizeMode="tail">
{folder.name}
</Text>
{folder.itemCount > 0 && (
<Text style={styles.itemCount}>{folder.itemCount}</Text>
)}
</TouchableOpacity>
);
};
export default FolderPill;

View File

@@ -0,0 +1,233 @@
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import React, { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import {
Modal,
StyleSheet,
Text,
TouchableOpacity,
View,
ScrollView,
} from 'react-native';
import { useColors } from '@/hooks/useColorScheme';
type Folder = {
Id: string;
Name: string;
};
interface IFolderSelectorProps {
folders: Folder[];
selectedFolderId: string | null | undefined;
onFolderChange: (folderId: string | null) => void;
}
/**
* FolderSelector component
*
* A button that opens a modal to select a folder for an item.
* Can be placed anywhere in the form.
*/
export const FolderSelector: React.FC<IFolderSelectorProps> = ({
folders,
selectedFolderId,
onFolderChange,
}) => {
const { t } = useTranslation();
const colors = useColors();
const [showModal, setShowModal] = useState(false);
const selectedFolder = folders.find(f => f.Id === selectedFolderId);
/**
* Handle folder selection.
*/
const handleSelectFolder = useCallback((folderId: string | null): void => {
onFolderChange(folderId);
setShowModal(false);
}, [onFolderChange]);
const styles = StyleSheet.create({
backdrop: {
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
flex: 1,
justifyContent: 'center',
},
button: {
alignItems: 'center',
backgroundColor: selectedFolderId ? colors.tint + '20' : colors.accentBackground,
borderColor: selectedFolderId ? colors.tint : colors.accentBorder,
borderRadius: 8,
borderWidth: 1,
flexDirection: 'row',
gap: 8,
paddingHorizontal: 12,
paddingVertical: 10,
},
buttonText: {
color: selectedFolderId ? colors.tint : colors.textMuted,
flex: 1,
fontSize: 15,
},
closeButton: {
padding: 4,
position: 'absolute',
right: 16,
top: 16,
},
container: {
backgroundColor: colors.background,
borderRadius: 12,
marginHorizontal: 20,
maxHeight: '70%',
maxWidth: 400,
padding: 20,
width: '90%',
},
folderOption: {
alignItems: 'center',
borderRadius: 8,
flexDirection: 'row',
gap: 12,
paddingHorizontal: 12,
paddingVertical: 12,
},
folderOptionActive: {
backgroundColor: colors.tint + '15',
},
folderOptionText: {
color: colors.text,
flex: 1,
fontSize: 16,
},
folderOptionTextActive: {
color: colors.tint,
fontWeight: '600',
},
label: {
color: colors.textMuted,
fontSize: 14,
fontWeight: '500',
marginBottom: 6,
},
optionsList: {
marginTop: 16,
},
title: {
color: colors.text,
fontSize: 18,
fontWeight: '600',
marginBottom: 4,
},
wrapper: {
marginBottom: 16,
},
});
return (
<View style={styles.wrapper}>
<Text style={styles.label}>{t('items.folders.folder')}</Text>
<TouchableOpacity
style={styles.button}
onPress={() => setShowModal(true)}
activeOpacity={0.7}
>
<MaterialIcons
name="folder"
size={20}
color={selectedFolderId ? colors.tint : colors.textMuted}
/>
<Text style={styles.buttonText} numberOfLines={1}>
{selectedFolder ? selectedFolder.Name : t('items.folders.noFolder')}
</Text>
<MaterialIcons
name="keyboard-arrow-down"
size={20}
color={colors.textMuted}
/>
</TouchableOpacity>
<Modal
visible={showModal}
transparent
animationType="fade"
onRequestClose={() => setShowModal(false)}
>
<View style={styles.backdrop}>
<View style={styles.container}>
<Text style={styles.title}>{t('items.folders.selectFolder')}</Text>
<TouchableOpacity
style={styles.closeButton}
onPress={() => setShowModal(false)}
>
<MaterialIcons name="close" size={24} color={colors.textMuted} />
</TouchableOpacity>
<ScrollView style={styles.optionsList}>
{/* No folder option */}
<TouchableOpacity
style={[
styles.folderOption,
!selectedFolderId && styles.folderOptionActive,
]}
onPress={() => handleSelectFolder(null)}
>
<MaterialIcons
name="folder-open"
size={22}
color={!selectedFolderId ? colors.tint : colors.textMuted}
/>
<Text
style={[
styles.folderOptionText,
!selectedFolderId && styles.folderOptionTextActive,
]}
>
{t('items.folders.noFolder')}
</Text>
{!selectedFolderId && (
<MaterialIcons name="check" size={20} color={colors.tint} />
)}
</TouchableOpacity>
{/* Folder options */}
{folders.map(folder => (
<TouchableOpacity
key={folder.Id}
style={[
styles.folderOption,
selectedFolderId === folder.Id && styles.folderOptionActive,
]}
onPress={() => handleSelectFolder(folder.Id)}
>
<MaterialIcons
name="folder"
size={22}
color={selectedFolderId === folder.Id ? colors.tint : colors.textMuted}
/>
<Text
style={[
styles.folderOptionText,
selectedFolderId === folder.Id && styles.folderOptionTextActive,
]}
numberOfLines={1}
>
{folder.Name}
</Text>
{selectedFolderId === folder.Id && (
<MaterialIcons name="check" size={20} color={colors.tint} />
)}
</TouchableOpacity>
))}
</ScrollView>
</View>
</View>
</Modal>
</View>
);
};
export default FolderSelector;

View File

@@ -533,6 +533,7 @@
"noItemsFound": "No items found. Create one to get started. Tip: you can also login to the AliasVault web app to import credentials from other password managers.",
"noPasskeysFound": "No passkeys have been created yet. Passkeys are created by visiting a website that offers passkeys as an authentication method.",
"noAttachmentsFound": "No items with attachments found",
"noItemsOfTypeFound": "No {{type}} items found",
"recentEmails": "Recent emails",
"loadingEmails": "Loading emails...",
"noEmailsYet": "No emails received yet.",
@@ -595,7 +596,24 @@
"copyEmail": "Copy Email",
"copyPassword": "Copy Password"
},
"deleteConfirm": "Are you sure you want to delete this item? This action cannot be undone."
"deleteConfirm": "Are you sure you want to delete this item? This action cannot be undone.",
"folders": {
"folder": "Folder",
"newFolder": "New Folder",
"createFolder": "Create Folder",
"editFolder": "Edit Folder",
"folderName": "Folder Name",
"folderNamePlaceholder": "e.g., Work, Personal",
"folderNameRequired": "Folder name is required",
"deleteFolder": "Delete Folder",
"deleteFolderKeepItems": "Delete folder only",
"deleteFolderKeepItemsDescription": "Items will be moved back to the main list.",
"deleteFolderAndItems": "Delete folder and all items",
"deleteFolderAndItemsDescription": "{{count}} item(s) will be moved to Recently Deleted.",
"emptyFolderHint": "To move items to this folder, edit the item and select this folder.",
"noFolder": "No Folder",
"selectFolder": "Select Folder"
}
},
"emails": {
"title": "Emails",

View File

@@ -10,6 +10,7 @@ import * as dateFormatter from '@/utils/dateFormatter';
import { ItemRepository } from '@/utils/db/repositories/ItemRepository';
import { SettingsRepository } from '@/utils/db/repositories/SettingsRepository';
import { LogoRepository } from '@/utils/db/repositories/LogoRepository';
import { FolderRepository, type Folder } from '@/utils/db/repositories/FolderRepository';
import type { IDatabaseClient, SqliteBindValue } from '@/utils/db/BaseRepository';
import type { ItemWithDeletedAt } from '@/utils/db/mappers/ItemMapper';
@@ -24,6 +25,7 @@ class SqliteClient implements IDatabaseClient {
private _itemRepository: ItemRepository | null = null;
private _settingsRepository: SettingsRepository | null = null;
private _logoRepository: LogoRepository | null = null;
private _folderRepository: FolderRepository | null = null;
/**
* Get the ItemRepository instance (lazy initialization).
@@ -84,6 +86,25 @@ class SqliteClient implements IDatabaseClient {
return this._logoRepository;
}
/**
* Get the FolderRepository instance (lazy initialization).
*/
public get folderRepository(): FolderRepository {
if (!this._folderRepository) {
this._folderRepository = Object.setPrototypeOf(
{ client: this as IDatabaseClient },
FolderRepository.prototype
) as FolderRepository;
Object.getOwnPropertyNames(FolderRepository.prototype).forEach(name => {
const method = FolderRepository.prototype[name as keyof typeof FolderRepository.prototype];
if (typeof method === 'function' && name !== 'constructor') {
(this._folderRepository as unknown as Record<string, unknown>)[name] = method.bind(this._folderRepository);
}
});
}
return this._folderRepository;
}
/**
* Store the encrypted database via the native code implementation.
*/
@@ -404,6 +425,75 @@ class SqliteClient implements IDatabaseClient {
return this.settingsRepository.getAttachmentsForItem(itemId);
}
// ============================================================================
// NEW: Folder-based methods using repository pattern
// ============================================================================
/**
* Get all folders.
* @returns Array of Folder objects
*/
public async getAllFolders(): Promise<Folder[]> {
return this.folderRepository.getAll();
}
/**
* Get a folder by ID.
* @param folderId - The ID of the folder
* @returns Folder object or null if not found
*/
public async getFolderById(folderId: string): Promise<Omit<Folder, 'Weight'> | null> {
return this.folderRepository.getById(folderId);
}
/**
* Create a new folder.
* @param name - The name of the folder
* @param parentFolderId - Optional parent folder ID for nested folders
* @returns The ID of the created folder
*/
public async createFolder(name: string, parentFolderId?: string | null): Promise<string> {
return this.folderRepository.create(name, parentFolderId);
}
/**
* Update a folder's name.
* @param folderId - The ID of the folder to update
* @param name - The new name for the folder
* @returns The number of rows updated
*/
public async updateFolder(folderId: string, name: string): Promise<number> {
return this.folderRepository.update(folderId, name);
}
/**
* Delete a folder (soft delete). Items in the folder will be moved to root.
* @param folderId - The ID of the folder to delete
* @returns The number of rows updated
*/
public async deleteFolder(folderId: string): Promise<number> {
return this.folderRepository.delete(folderId);
}
/**
* Delete a folder and all its contents. Items will be moved to trash.
* @param folderId - The ID of the folder to delete
* @returns The number of items trashed
*/
public async deleteFolderWithContents(folderId: string): Promise<number> {
return this.folderRepository.deleteWithContents(folderId);
}
/**
* Move an item to a folder.
* @param itemId - The ID of the item to move
* @param folderId - The ID of the destination folder (null to remove from folder)
* @returns The number of rows updated
*/
public async moveItemToFolder(itemId: string, folderId: string | null): Promise<number> {
return this.folderRepository.moveItem(itemId, folderId);
}
// ============================================================================
// LEGACY: Credential-based methods (kept for backward compatibility)
// ============================================================================

View File

@@ -6,7 +6,7 @@
export class ItemQueries {
/**
* Base SELECT for items with common fields.
* Includes LEFT JOIN to Logos, and subqueries for HasPasskey/HasAttachment/HasTotp.
* Includes LEFT JOIN to Logos and Folders, and subqueries for HasPasskey/HasAttachment/HasTotp.
*/
public static readonly BASE_SELECT = `
SELECT DISTINCT
@@ -14,6 +14,7 @@ export class ItemQueries {
i.Name,
i.ItemType,
i.FolderId,
f.Name as FolderPath,
l.FileData as Logo,
CASE WHEN EXISTS (SELECT 1 FROM Passkeys pk WHERE pk.ItemId = i.Id AND pk.IsDeleted = 0) THEN 1 ELSE 0 END as HasPasskey,
CASE WHEN EXISTS (SELECT 1 FROM Attachments att WHERE att.ItemId = i.Id AND att.IsDeleted = 0) THEN 1 ELSE 0 END as HasAttachment,
@@ -21,7 +22,8 @@ export class ItemQueries {
i.CreatedAt,
i.UpdatedAt
FROM Items i
LEFT JOIN Logos l ON i.LogoId = l.Id`;
LEFT JOIN Logos l ON i.LogoId = l.Id
LEFT JOIN Folders f ON i.FolderId = f.Id`;
/**
* Get all active items (not deleted, not in trash).
@@ -40,6 +42,7 @@ export class ItemQueries {
i.Name,
i.ItemType,
i.FolderId,
f.Name as FolderPath,
l.FileData as Logo,
CASE WHEN EXISTS (SELECT 1 FROM Passkeys pk WHERE pk.ItemId = i.Id AND pk.IsDeleted = 0) THEN 1 ELSE 0 END as HasPasskey,
CASE WHEN EXISTS (SELECT 1 FROM Attachments att WHERE att.ItemId = i.Id AND att.IsDeleted = 0) THEN 1 ELSE 0 END as HasAttachment,
@@ -48,6 +51,7 @@ export class ItemQueries {
i.UpdatedAt
FROM Items i
LEFT JOIN Logos l ON i.LogoId = l.Id
LEFT JOIN Folders f ON i.FolderId = f.Id
WHERE i.Id = ? AND i.IsDeleted = 0`;
/**

View File

@@ -0,0 +1,232 @@
import { BaseRepository } from '../BaseRepository';
/**
* Folder entity type.
*/
export type Folder = {
Id: string;
Name: string;
ParentFolderId: string | null;
Weight: number;
}
/**
* SQL query constants for Folder operations.
*/
const FolderQueries = {
/**
* Get all active folders.
*/
GET_ALL: `
SELECT Id, Name, ParentFolderId, Weight
FROM Folders
WHERE IsDeleted = 0
ORDER BY Weight, Name`,
/**
* Get folder by ID.
*/
GET_BY_ID: `
SELECT Id, Name, ParentFolderId
FROM Folders
WHERE Id = ? AND IsDeleted = 0`,
/**
* Insert a new folder.
*/
INSERT: `
INSERT INTO Folders (Id, Name, ParentFolderId, Weight, IsDeleted, CreatedAt, UpdatedAt)
VALUES (?, ?, ?, 0, 0, ?, ?)`,
/**
* Update folder name.
*/
UPDATE_NAME: `
UPDATE Folders
SET Name = ?,
UpdatedAt = ?
WHERE Id = ?`,
/**
* Soft delete folder.
*/
SOFT_DELETE: `
UPDATE Folders
SET IsDeleted = 1,
UpdatedAt = ?
WHERE Id = ?`,
/**
* Clear folder reference from items.
*/
CLEAR_ITEMS_FOLDER: `
UPDATE Items
SET FolderId = NULL,
UpdatedAt = ?
WHERE FolderId = ?`,
/**
* Trash items in folder.
*/
TRASH_ITEMS_IN_FOLDER: `
UPDATE Items
SET DeletedAt = ?,
UpdatedAt = ?,
FolderId = NULL
WHERE FolderId = ? AND IsDeleted = 0 AND DeletedAt IS NULL`,
/**
* Move item to folder.
*/
MOVE_ITEM: `
UPDATE Items
SET FolderId = ?,
UpdatedAt = ?
WHERE Id = ?`
};
/**
* Repository for Folder CRUD operations.
*/
export class FolderRepository extends BaseRepository {
/**
* Create a new folder.
* @param name - The name of the folder
* @param parentFolderId - Optional parent folder ID for nested folders
* @returns The ID of the created folder
*/
public async create(name: string, parentFolderId?: string | null): Promise<string> {
return this.withTransaction(async () => {
const folderId = this.generateId();
const currentDateTime = this.now();
await this.client.executeUpdate(FolderQueries.INSERT, [
folderId,
name,
parentFolderId || null,
currentDateTime,
currentDateTime
]);
return folderId;
});
}
/**
* Get all folders.
* @returns Array of folder objects (empty array if Folders table doesn't exist yet)
*/
public async getAll(): Promise<Folder[]> {
try {
// Check if table exists first
if (!await this.tableExists('Folders')) {
return [];
}
return this.client.executeQuery<Folder>(FolderQueries.GET_ALL);
} catch (error) {
// Table may not exist in older vault versions - return empty array
if (error instanceof Error && error.message.includes('no such table')) {
return [];
}
throw error;
}
}
/**
* Get a folder by ID.
* @param folderId - The ID of the folder
* @returns Folder object or null if not found
*/
public async getById(folderId: string): Promise<Omit<Folder, 'Weight'> | null> {
const results = await this.client.executeQuery<Omit<Folder, 'Weight'>>(
FolderQueries.GET_BY_ID,
[folderId]
);
return results.length > 0 ? results[0] : null;
}
/**
* Update a folder's name.
* @param folderId - The ID of the folder to update
* @param name - The new name for the folder
* @returns The number of rows updated
*/
public async update(folderId: string, name: string): Promise<number> {
return this.withTransaction(async () => {
const currentDateTime = this.now();
return this.client.executeUpdate(FolderQueries.UPDATE_NAME, [
name,
currentDateTime,
folderId
]);
});
}
/**
* Delete a folder (soft delete).
* Note: Items in the folder will have their FolderId set to NULL.
* @param folderId - The ID of the folder to delete
* @returns The number of rows updated
*/
public async delete(folderId: string): Promise<number> {
return this.withTransaction(async () => {
const currentDateTime = this.now();
// Remove folder reference from all items in this folder
await this.client.executeUpdate(FolderQueries.CLEAR_ITEMS_FOLDER, [
currentDateTime,
folderId
]);
// Soft delete the folder
return this.client.executeUpdate(FolderQueries.SOFT_DELETE, [
currentDateTime,
folderId
]);
});
}
/**
* Delete a folder and all items within it (soft delete both folder and items).
* Items are moved to "Recently Deleted" (trash).
* @param folderId - The ID of the folder to delete
* @returns The number of items trashed
*/
public async deleteWithContents(folderId: string): Promise<number> {
return this.withTransaction(async () => {
const currentDateTime = this.now();
// Move all items in this folder to trash and clear FolderId
const itemsDeleted = await this.client.executeUpdate(FolderQueries.TRASH_ITEMS_IN_FOLDER, [
currentDateTime,
currentDateTime,
folderId
]);
// Soft delete the folder
await this.client.executeUpdate(FolderQueries.SOFT_DELETE, [
currentDateTime,
folderId
]);
return itemsDeleted;
});
}
/**
* Move an item to a folder.
* @param itemId - The ID of the item to move
* @param folderId - The ID of the destination folder (null to remove from folder)
* @returns The number of rows updated
*/
public async moveItem(itemId: string, folderId: string | null): Promise<number> {
return this.withTransaction(async () => {
const currentDateTime = this.now();
return this.client.executeUpdate(FolderQueries.MOVE_ITEM, [
folderId,
currentDateTime,
itemId
]);
});
}
}