Skip to content

Commit

Permalink
Duplicate Budget (#3847)
Browse files Browse the repository at this point in the history
* Initial Commit

* Create 3847.md

* Removed un-needed comment

* Changed error log text

* Moved budget name validation from DuplicateFileModal to loot-core/server

* Added translation

* Fixed linting error

* Changed delete file hack

Changed from loading and closing the budget file to just opening and closing the database to be able to delete it.

* Removed hard coded english from loot-core server

* Updated wording and style of Duplicate File Modal

* Simpler wording for Duplication text and buttons
  • Loading branch information
tlesicka authored Dec 10, 2024
1 parent 2b908e9 commit 6ea7732
Show file tree
Hide file tree
Showing 10 changed files with 634 additions and 39 deletions.
11 changes: 11 additions & 0 deletions packages/desktop-client/src/components/Modals.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import { KeyboardShortcutModal } from './modals/KeyboardShortcutModal';
import { LoadBackupModal } from './modals/LoadBackupModal';
import { ConfirmChangeDocumentDirModal } from './modals/manager/ConfirmChangeDocumentDir';
import { DeleteFileModal } from './modals/manager/DeleteFileModal';
import { DuplicateFileModal } from './modals/manager/DuplicateFileModal';
import { FilesSettingsModal } from './modals/manager/FilesSettingsModal';
import { ImportActualModal } from './modals/manager/ImportActualModal';
import { ImportModal } from './modals/manager/ImportModal';
Expand Down Expand Up @@ -586,6 +587,16 @@ export function Modals() {
return <BudgetListModal key={name} />;
case 'delete-budget':
return <DeleteFileModal key={name} file={options.file} />;
case 'duplicate-budget':
return (
<DuplicateFileModal
key={name}
file={options.file}
managePage={options?.managePage}
loadBudget={options?.loadBudget}
onComplete={options?.onComplete}
/>
);
case 'import':
return <ImportModal key={name} />;
case 'files-settings':
Expand Down
48 changes: 43 additions & 5 deletions packages/desktop-client/src/components/manager/BudgetList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,11 @@ function getFileDescription(file: File, t: (key: string) => string) {
function FileMenu({
onDelete,
onClose,
onDuplicate,
}: {
onDelete: () => void;
onClose: () => void;
onDuplicate?: () => void;
}) {
function onMenuSelect(type: string) {
onClose();
Expand All @@ -75,18 +77,30 @@ function FileMenu({
case 'delete':
onDelete();
break;
case 'duplicate':
if (onDuplicate) onDuplicate();
break;
default:
}
}

const { t } = useTranslation();

const items = [{ name: 'delete', text: t('Delete') }];
const items = [
...(onDuplicate ? [{ name: 'duplicate', text: t('Duplicate') }] : []),
{ name: 'delete', text: t('Delete') },
];

return <Menu onMenuSelect={onMenuSelect} items={items} />;
}

function FileMenuButton({ onDelete }: { onDelete: () => void }) {
function FileMenuButton({
onDelete,
onDuplicate,
}: {
onDelete: () => void;
onDuplicate?: () => void;
}) {
const triggerRef = useRef(null);
const [menuOpen, setMenuOpen] = useState(false);

Expand All @@ -108,7 +122,11 @@ function FileMenuButton({ onDelete }: { onDelete: () => void }) {
isOpen={menuOpen}
onOpenChange={() => setMenuOpen(false)}
>
<FileMenu onDelete={onDelete} onClose={() => setMenuOpen(false)} />
<FileMenu
onDelete={onDelete}
onClose={() => setMenuOpen(false)}
onDuplicate={onDuplicate}
/>
</Popover>
</View>
);
Expand Down Expand Up @@ -169,11 +187,13 @@ function FileItem({
quickSwitchMode,
onSelect,
onDelete,
onDuplicate,
}: {
file: File;
quickSwitchMode: boolean;
onSelect: (file: File) => void;
onDelete: (file: File) => void;
onDuplicate: (file: File) => void;
}) {
const { t } = useTranslation();

Expand Down Expand Up @@ -239,7 +259,10 @@ function FileItem({
)}

{!quickSwitchMode && (
<FileMenuButton onDelete={() => onDelete(file)} />
<FileMenuButton
onDelete={() => onDelete(file)}
onDuplicate={'id' in file ? () => onDuplicate(file) : undefined}
/>
)}
</View>
</View>
Expand All @@ -252,11 +275,13 @@ function BudgetFiles({
quickSwitchMode,
onSelect,
onDelete,
onDuplicate,
}: {
files: File[];
quickSwitchMode: boolean;
onSelect: (file: File) => void;
onDelete: (file: File) => void;
onDuplicate: (file: File) => void;
}) {
function isLocalFile(file: File): file is LocalFile {
return file.state === 'local';
Expand Down Expand Up @@ -292,6 +317,7 @@ function BudgetFiles({
quickSwitchMode={quickSwitchMode}
onSelect={onSelect}
onDelete={onDelete}
onDuplicate={onDuplicate}
/>
))
)}
Expand Down Expand Up @@ -467,7 +493,19 @@ export function BudgetList({ showHeader = true, quickSwitchMode = false }) {
files={files}
quickSwitchMode={quickSwitchMode}
onSelect={onSelect}
onDelete={file => dispatch(pushModal('delete-budget', { file }))}
onDelete={(file: File) =>
dispatch(pushModal('delete-budget', { file }))
}
onDuplicate={(file: File) => {
if (file && 'id' in file) {
dispatch(pushModal('duplicate-budget', { file, managePage: true }));
} else {
console.error(
'Attempted to duplicate a cloud file - only local files are supported. Cloud file:',
file,
);
}
}}
/>
{!quickSwitchMode && (
<View
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
import React, { useEffect, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';

import {
addNotification,
duplicateBudget,
uniqueBudgetName,
validateBudgetName,
} from 'loot-core/client/actions';
import { type File } from 'loot-core/src/types/file';

import { theme } from '../../../style';
import { Button, ButtonWithLoading } from '../../common/Button2';
import { FormError } from '../../common/FormError';
import { InitialFocus } from '../../common/InitialFocus';
import { InlineField } from '../../common/InlineField';
import { Input } from '../../common/Input';
import {
Modal,
ModalButtons,
ModalCloseButton,
ModalHeader,
} from '../../common/Modal';
import { Text } from '../../common/Text';
import { View } from '../../common/View';

type DuplicateFileProps = {
file: File;
managePage?: boolean;
loadBudget?: 'none' | 'original' | 'copy';
onComplete?: (event: {
status: 'success' | 'failed' | 'canceled';
error?: object;
}) => void;
};

export function DuplicateFileModal({
file,
managePage,
loadBudget = 'none',
onComplete,
}: DuplicateFileProps) {
const { t } = useTranslation();
const fileEndingTranslation = t(' - copy');
const [newName, setNewName] = useState(file.name + fileEndingTranslation);
const [nameError, setNameError] = useState<string | null>(null);

// If the state is "broken" that means it was created by another user.
const isCloudFile = 'cloudFileId' in file && file.state !== 'broken';
const isLocalFile = 'id' in file;
const dispatch = useDispatch();

const [loadingState, setLoadingState] = useState<'cloud' | 'local' | null>(
null,
);

useEffect(() => {
(async () => {
setNewName(await uniqueBudgetName(file.name + fileEndingTranslation));
})();
}, [file.name, fileEndingTranslation]);

const validateAndSetName = async (name: string) => {
const trimmedName = name.trim();
const { valid, message } = await validateBudgetName(trimmedName);
if (valid) {
setNewName(trimmedName);
setNameError(null);
} else {
// The "Unknown error" should never happen, but this satifies type checking
setNameError(message ?? t('Unknown error with budget name'));
}
};

const handleDuplicate = async (sync: 'localOnly' | 'cloudSync') => {
const { valid, message } = await validateBudgetName(newName);
if (valid) {
setLoadingState(sync === 'cloudSync' ? 'cloud' : 'local');

try {
await dispatch(
duplicateBudget({
id: 'id' in file ? file.id : undefined,
cloudId:
sync === 'cloudSync' && 'cloudFileId' in file
? file.cloudFileId
: undefined,
oldName: file.name,
newName,
cloudSync: sync === 'cloudSync',
managePage,
loadBudget,
}),
);
dispatch(
addNotification({
type: 'message',
message: t('Duplicate file “{{newName}}” created.', { newName }),
}),
);
if (onComplete) onComplete({ status: 'success' });
} catch (e) {
const newError = new Error(t('Failed to duplicate budget'));
if (onComplete) onComplete({ status: 'failed', error: newError });
else console.error('Failed to duplicate budget:', e);
dispatch(
addNotification({
type: 'error',
message: t('Failed to duplicate budget file.'),
}),
);
} finally {
setLoadingState(null);
}
} else {
const failError = new Error(
message ?? t('Unknown error with budget name'),
);
if (onComplete) onComplete({ status: 'failed', error: failError });
}
};

return (
<Modal name="duplicate-budget">
{({ state: { close } }) => (
<View style={{ maxWidth: 700 }}>
<ModalHeader
title={t('Duplicate “{{fileName}}”', { fileName: file.name })}
rightContent={
<ModalCloseButton
onPress={() => {
close();
if (onComplete) onComplete({ status: 'canceled' });
}}
/>
}
/>

<View
style={{
padding: 15,
gap: 15,
paddingTop: 0,
paddingBottom: 25,
lineHeight: '1.5em',
}}
>
<InlineField
label={t('New Budget Name')}
width="100%"
labelWidth={150}
>
<InitialFocus>
<Input
name="name"
value={newName}
aria-label={t('New Budget Name')}
aria-invalid={nameError ? 'true' : 'false'}
onChange={event => setNewName(event.target.value)}
onBlur={event => validateAndSetName(event.target.value)}
style={{ flex: 1 }}
/>
</InitialFocus>
</InlineField>
{nameError && (
<FormError style={{ marginLeft: 150, color: theme.warningText }}>
{nameError}
</FormError>
)}

{isLocalFile ? (
isCloudFile && (
<Text>
<Trans>
Your budget is hosted on a server, making it accessible for
download on your devices.
<br />
Would you like to duplicate this budget for all your devices
or keep it stored locally on this device?
</Trans>
</Text>
)
) : (
<Text>
<Trans>
Unable to duplicate a budget that is not located on your
device.
<br />
Please download the budget from the server before duplicating.
</Trans>
</Text>
)}
<ModalButtons>
<Button
onPress={() => {
close();
if (onComplete) onComplete({ status: 'canceled' });
}}
>
<Trans>Cancel</Trans>
</Button>
{isLocalFile && isCloudFile && (
<ButtonWithLoading
variant={loadingState !== null ? 'bare' : 'primary'}
isLoading={loadingState === 'cloud'}
style={{
marginLeft: 10,
}}
onPress={() => handleDuplicate('cloudSync')}
>
<Trans>Duplicate for all devices</Trans>
</ButtonWithLoading>
)}
{isLocalFile && (
<ButtonWithLoading
variant={
loadingState !== null
? 'bare'
: isCloudFile
? 'normal'
: 'primary'
}
isLoading={loadingState === 'local'}
style={{
marginLeft: 10,
}}
onPress={() => handleDuplicate('localOnly')}
>
<Trans>Duplicate</Trans>
{isCloudFile && <Trans> locally</Trans>}
</ButtonWithLoading>
)}
</ModalButtons>
</View>
</View>
)}
</Modal>
);
}
Loading

0 comments on commit 6ea7732

Please sign in to comment.