Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions src/components/page-header/confirm-overwrite-dialog.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { render } from '../../test-utils';
import { CURRENT_VERSION } from '../../util/save';
import ConfirmOverwriteDialog from './confirm-overwrite-dialog';

// Mock the translation hook
vi.mock('react-i18next', async () => {
const actual = await vi.importActual('react-i18next');
return {
...actual,
useTranslation: () => ({
t: (key: string, options?: any) => {
if (key === 'header.open.confirmOverwrite.title') return 'Confirm overwrite';
if (key === 'header.open.confirmOverwrite.body')
return 'This action will overwrite your current project. Any unsaved changes will be lost. Are you sure you want to continue?';
if (key === 'header.open.confirmOverwrite.overwrite') return 'Overwrite';
if (key === 'cancel') return 'Cancel';
if (key === 'header.open.confirmOverwrite.newerVersion')
return `Warning: This save file is from a newer version (${options?.saveVersion}) than the current version (${options?.currentVersion}). Loading it may cause errors or undefined behavior.`;
return key;
},
}),
};
});

describe('ConfirmOverwriteDialog', () => {
it('should not show version warning when saveVersion is undefined', () => {
render(<ConfirmOverwriteDialog isOpen={true} onClose={vi.fn()} onConfirm={vi.fn()} />);

expect(screen.getByText('Confirm overwrite')).toBeInTheDocument();
expect(
screen.getByText(
'This action will overwrite your current project. Any unsaved changes will be lost. Are you sure you want to continue?'
)
).toBeInTheDocument();
expect(screen.queryByText(/Warning:/)).not.toBeInTheDocument();
});

it('should not show version warning when saveVersion equals CURRENT_VERSION', () => {
render(
<ConfirmOverwriteDialog isOpen={true} onClose={vi.fn()} onConfirm={vi.fn()} saveVersion={CURRENT_VERSION} />
);

expect(screen.getByText('Confirm overwrite')).toBeInTheDocument();
expect(screen.queryByText(/Warning:/)).not.toBeInTheDocument();
});

it('should not show version warning when saveVersion is less than CURRENT_VERSION', () => {
render(
<ConfirmOverwriteDialog
isOpen={true}
onClose={vi.fn()}
onConfirm={vi.fn()}
saveVersion={CURRENT_VERSION - 10}
/>
);

expect(screen.getByText('Confirm overwrite')).toBeInTheDocument();
expect(screen.queryByText(/Warning:/)).not.toBeInTheDocument();
});

it('should show version warning when saveVersion is greater than CURRENT_VERSION', () => {
const newerVersion = CURRENT_VERSION + 10;
render(
<ConfirmOverwriteDialog isOpen={true} onClose={vi.fn()} onConfirm={vi.fn()} saveVersion={newerVersion} />
);

expect(screen.getByText('Confirm overwrite')).toBeInTheDocument();
expect(
screen.getByText(
`Warning: This save file is from a newer version (${newerVersion}) than the current version (${CURRENT_VERSION}). Loading it may cause errors or undefined behavior.`
)
).toBeInTheDocument();
});

it('should render buttons correctly', () => {
render(<ConfirmOverwriteDialog isOpen={true} onClose={vi.fn()} onConfirm={vi.fn()} />);

expect(screen.getByText('Cancel')).toBeInTheDocument();
expect(screen.getByText('Overwrite')).toBeInTheDocument();
});

it('should not render when isOpen is false', () => {
render(<ConfirmOverwriteDialog isOpen={false} onClose={vi.fn()} onConfirm={vi.fn()} saveVersion={100} />);

expect(screen.queryByText('Confirm overwrite')).not.toBeInTheDocument();
});
});
19 changes: 17 additions & 2 deletions src/components/page-header/confirm-overwrite-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,33 +6,48 @@ import {
AlertDialogHeader,
AlertDialogOverlay,
Button,
Text,
} from '@chakra-ui/react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { CURRENT_VERSION } from '../../util/save';

interface ConfirmOverwriteDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
saveVersion?: number;
}

/**
* A shared dialog component for confirming overwrite of current project data.
* Used when importing data from various sources that would replace the current graph.
*/
export default function ConfirmOverwriteDialog(props: ConfirmOverwriteDialogProps) {
const { isOpen, onClose, onConfirm } = props;
const { isOpen, onClose, onConfirm, saveVersion } = props;
const { t } = useTranslation();
const cancelRef = React.useRef<HTMLButtonElement | null>(null);

const isNewerVersion = saveVersion !== undefined && saveVersion > CURRENT_VERSION;

return (
<AlertDialog isOpen={isOpen} leastDestructiveRef={cancelRef} onClose={onClose}>
<AlertDialogOverlay>
<AlertDialogContent>
<AlertDialogHeader fontSize="lg" fontWeight="bold">
{t('header.open.confirmOverwrite.title')}
</AlertDialogHeader>
<AlertDialogBody>{t('header.open.confirmOverwrite.body')}</AlertDialogBody>
<AlertDialogBody>
{t('header.open.confirmOverwrite.body')}
{isNewerVersion && (
<Text mt={4} color="orange.500" fontWeight="semibold">
{t('header.open.confirmOverwrite.newerVersion', {
saveVersion,
currentVersion: CURRENT_VERSION,
})}
</Text>
)}
</AlertDialogBody>
<AlertDialogFooter>
<Button ref={cancelRef} onClick={onClose}>
{t('cancel')}
Expand Down
9 changes: 7 additions & 2 deletions src/components/page-header/open-actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { getCanvasSize } from '../../util/helpers';
import { useWindowSize } from '../../util/hooks';
import { pullServerImages, saveImagesFromParam } from '../../util/image';
import { saveManagerChannel, SaveManagerEvent, SaveManagerEventType } from '../../util/rmt-save';
import { getInitialParam, RMPSave, upgrade } from '../../util/save';
import { getInitialParam, parseVersionFromSave, RMPSave, upgrade } from '../../util/save';
import ConfirmOverwriteDialog from './confirm-overwrite-dialog';
import RmgParamAppClip from './rmg-param-app-clip';
import RmpGalleryAppClip from './rmp-gallery-app-clip';
Expand Down Expand Up @@ -185,7 +185,12 @@ export default function OpenActions() {
<RmpGalleryAppClip isOpen={isOpenGallery} onClose={() => setIsOpenGallery(false)} />
</Menu>

<ConfirmOverwriteDialog isOpen={isConfirmOpen} onClose={onConfirmClose} onConfirm={handleConfirmLoad} />
<ConfirmOverwriteDialog
isOpen={isConfirmOpen}
onClose={onConfirmClose}
onConfirm={handleConfirmLoad}
saveVersion={paramToLoad ? parseVersionFromSave(paramToLoad) : undefined}
/>
</>
);
}
Expand Down
7 changes: 6 additions & 1 deletion src/components/page-header/rmp-gallery-app-clip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,12 @@ export default function RmpGalleryAppClip(props: RmpGalleryAppClipProps) {
<iframe src="/rmp-gallery/" loading="lazy" />
<CloseButton onClick={onClose} position="fixed" top="5px" right="15px" />
</RmgAppClip>
<ConfirmOverwriteDialog isOpen={isConfirmOpen} onClose={onConfirmClose} onConfirm={handleConfirmOpen} />
<ConfirmOverwriteDialog
isOpen={isConfirmOpen}
onClose={onConfirmClose}
onConfirm={handleConfirmOpen}
saveVersion={workToLoad?.version}
/>
</>
);
}
3 changes: 2 additions & 1 deletion src/i18n/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -691,7 +691,8 @@
"confirmOverwrite": {
"title": "Confirm overwrite",
"body": "This action will overwrite your current project. Any unsaved changes will be lost. Are you sure you want to continue?",
"overwrite": "Overwrite"
"overwrite": "Overwrite",
"newerVersion": "Warning: This save file is from a newer version ({{saveVersion}}) than the current version ({{currentVersion}}). Loading it may cause errors or undefined behavior."
}
},
"download": {
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/translations/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -693,7 +693,8 @@
"confirmOverwrite": {
"title": "上書きの確認",
"body": "この操作により現在のプロジェクトが上書きされます。未保存の変更はすべて失われます。続行してもよろしいですか?",
"overwrite": "上書き"
"overwrite": "上書き",
"newerVersion": "警告:このセーブファイルは新しいバージョン({{saveVersion}})からのもので、現在のバージョン({{currentVersion}})より新しいです。読み込むとエラーや未定義の動作が発生する可能性があります。"
}
},
"download": {
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/translations/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -690,7 +690,8 @@
"confirmOverwrite": {
"title": "확인 덮어쓰기",
"body": "이 작업은 현재 프로젝트를 덮어씁니다. 저장하지 않은 모든 변경 사항이 손실됩니다. 계속하시겠습니까?",
"overwrite": "덮어쓰기"
"overwrite": "덮어쓰기",
"newerVersion": "경고: 이 저장 파일은 현재 버전({{currentVersion}})보다 최신 버전({{saveVersion}})에서 가져온 것입니다. 로드하면 오류나 정의되지 않은 동작이 발생할 수 있습니다."
}
},
"download": {
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/translations/zh-Hans.json
Original file line number Diff line number Diff line change
Expand Up @@ -690,7 +690,8 @@
"confirmOverwrite": {
"title": "确认覆盖",
"body": "此操作将覆盖当前项目。所有未保存的更改将丢失。确定要继续吗?",
"overwrite": "覆盖"
"overwrite": "覆盖",
"newerVersion": "警告:此存档文件来自更新的版本({{saveVersion}}),高于当前版本({{currentVersion}})。加载它可能会导致错误或未定义的行为。"
}
},
"download": {
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/translations/zh-Hant.json
Original file line number Diff line number Diff line change
Expand Up @@ -690,7 +690,8 @@
"confirmOverwrite": {
"title": "確認覆蓋",
"body": "此操作將覆蓋當前項目。所有未保存的更改將丟失。確定要繼續嗎?",
"overwrite": "覆蓋"
"overwrite": "覆蓋",
"newerVersion": "警告:此存檔檔案來自更新的版本({{saveVersion}}),高於當前版本({{currentVersion}})。載入它可能會導致錯誤或未定義的行為。"
}
},
"download": {
Expand Down
63 changes: 63 additions & 0 deletions src/util/save-version.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest';
import { CURRENT_VERSION, parseVersionFromSave } from './save';

describe('parseVersionFromSave', () => {
it('should parse valid version from save string', () => {
const saveStr = JSON.stringify({ version: 66, graph: {}, svgViewBoxZoom: 100, svgViewBoxMin: { x: 0, y: 0 } });
expect(parseVersionFromSave(saveStr)).toBe(66);
});

it('should parse newer version from save string', () => {
const saveStr = JSON.stringify({ version: 100, graph: {}, svgViewBoxZoom: 100, svgViewBoxMin: { x: 0, y: 0 } });
expect(parseVersionFromSave(saveStr)).toBe(100);
});

it('should return undefined for invalid JSON', () => {
const saveStr = 'invalid json';
expect(parseVersionFromSave(saveStr)).toBeUndefined();
});

it('should return undefined for missing version field', () => {
const saveStr = JSON.stringify({ graph: {}, svgViewBoxZoom: 100, svgViewBoxMin: { x: 0, y: 0 } });
expect(parseVersionFromSave(saveStr)).toBeUndefined();
});

it('should return undefined for non-integer version', () => {
const saveStr = JSON.stringify({
version: '66',
graph: {},
svgViewBoxZoom: 100,
svgViewBoxMin: { x: 0, y: 0 },
});
expect(parseVersionFromSave(saveStr)).toBeUndefined();
});

it('should return undefined for float version', () => {
const saveStr = JSON.stringify({
version: 66.5,
graph: {},
svgViewBoxZoom: 100,
svgViewBoxMin: { x: 0, y: 0 },
});
expect(parseVersionFromSave(saveStr)).toBeUndefined();
});

it('should correctly identify version newer than CURRENT_VERSION', () => {
const saveStr = JSON.stringify({
version: CURRENT_VERSION + 10,
graph: {},
svgViewBoxZoom: 100,
svgViewBoxMin: { x: 0, y: 0 },
});
const version = parseVersionFromSave(saveStr);
expect(version).toBe(CURRENT_VERSION + 10);
expect(version).toBeGreaterThan(CURRENT_VERSION);
});

it('should correctly identify version older than CURRENT_VERSION', () => {
const saveStr = JSON.stringify({ version: 1, graph: {}, svgViewBoxZoom: 100, svgViewBoxMin: { x: 0, y: 0 } });
const version = parseVersionFromSave(saveStr);
expect(version).toBe(1);
expect(version).toBeLessThan(CURRENT_VERSION);
});
});
16 changes: 16 additions & 0 deletions src/util/save.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,22 @@ export interface RMPSave {

export const CURRENT_VERSION = 66;

/**
* Parse the version from a save string without fully validating the save.
* Returns undefined if the version cannot be parsed.
*/
export const parseVersionFromSave = (saveStr: string): number | undefined => {
try {
const save = JSON.parse(saveStr);
if ('version' in save && Number.isInteger(save.version)) {
return save.version;
}
} catch {
// Invalid JSON or missing version field
}
return undefined;
};

/**
* Load the tutorial.
*/
Expand Down