Skip to content

Commit

Permalink
feat(front): use React19
Browse files Browse the repository at this point in the history
  • Loading branch information
SARDONYX-sard committed Dec 6, 2024
1 parent cd85557 commit b496a0a
Show file tree
Hide file tree
Showing 18 changed files with 737 additions and 678 deletions.
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,12 @@ be helped)
- version: 1.9.3
- VS Code extension: 2.2.3
- eslint: ^8

- mui/x-data-grid, when changing from `7.22.2` to `7.23.1`, the `setState` in
`handleRowSelectionModelChange` is now

`Cannot update a component () while rendering a different component ()` and
therefore do not use "7.23.1".

- `React19` is a new ver. stabilized on 2024/12/5, so `notistack`,
`@monaco-editor/react` warns. In that case, use `npm i --force`.
1 change: 1 addition & 0 deletions gui/frontend/src/components/hooks/useDebounce/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { useDebounce } from './useDebounce';
64 changes: 64 additions & 0 deletions gui/frontend/src/components/hooks/useDebounce/useDebounce.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';

import { useDebounce } from './useDebounce';

describe('useDebounce', () => {
vi.useFakeTimers();

it('should return the initial value immediately', () => {
const { result } = renderHook(() => useDebounce('test', 500));
expect(result.current).toBe('test');
});

it('should debounce the value', () => {
vi.useFakeTimers(); // Setup functions required to use `advanceTimersByTime`.
const { result, rerender } = renderHook(({ value, delay }) => useDebounce(value, delay), {
initialProps: { value: 'initial', delay: 500 },
});

expect(result.current).toBe('initial');

rerender({ value: 'updated', delay: 500 });

// The value should not change immediately
expect(result.current).toBe('initial');

act(() => {
vi.advanceTimersByTime(500);
});

expect(result.current).toBe('updated');
});

it('should reset the timer when the value changes quickly', () => {
const { result, rerender } = renderHook(({ value, delay }) => useDebounce(value, delay), {
initialProps: { value: 'initial', delay: 500 },
});

expect(result.current).toBe('initial');

// Update the value multiple times
rerender({ value: 'first update', delay: 500 });
act(() => {
vi.advanceTimersByTime(200);
});
rerender({ value: 'second update', delay: 500 });
act(() => {
vi.advanceTimersByTime(200);
});

// The value should still be the initial value
expect(result.current).toBe('initial');

// Fast-forward time by 500ms
act(() => {
vi.advanceTimersByTime(500);
});

// Now the value should update to the latest one
expect(result.current).toBe('second update');
});

vi.useRealTimers();
});
33 changes: 33 additions & 0 deletions gui/frontend/src/components/hooks/useDebounce/useDebounce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { useState, useEffect } from 'react';

/**
* Custom React hook to debounce a value. The value updates after the specified delay time.
*
* @template T The type of the value to debounce.
* @param value The value to debounce.
* @param delay The debounce delay in milliseconds.
* @returns The debounced value.
*
* @example
* const [search, setSearch] = useState('');
* const debouncedSearch = useDebounce(search, 500);
*
* useEffect(() => {
* // Perform a search API call with debouncedSearch.
* }, [debouncedSearch]);
*/
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);

useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);

return () => {
clearTimeout(handler);
};
}, [value, delay]);

return debouncedValue;
}
22 changes: 9 additions & 13 deletions gui/frontend/src/components/molecules/Button/Button.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,21 @@
import FolderOpenIcon from '@mui/icons-material/FolderOpen';
import { type ButtonProps, Button as Button_ } from '@mui/material';
import { type ButtonProps, Button as Button_, type SxProps, type Theme } from '@mui/material';

import { useTranslation } from '@/components/hooks/useTranslation';

type Props = ButtonProps;

export function Button({ ...props }: Props) {
const defaultStyle = {
marginTop: '9px',
width: '150px',
height: '55px',
} as const satisfies SxProps<Theme>;

export function Button({ sx, ...props }: Props) {
const { t } = useTranslation();

return (
<Button_
startIcon={<FolderOpenIcon />}
sx={{
marginTop: '9px',
width: '150px',
height: '55px',
}}
type='button'
variant='outlined'
{...props}
>
<Button_ startIcon={<FolderOpenIcon />} sx={{ ...defaultStyle, ...sx }} type='button' variant='outlined' {...props}>
{t('select-btn')}
</Button_>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export function InputField({ label, icon, path, setPath, placeholder, ...props }
value={path}
variant='standard'
/>
<Button {...props} />
<Button {...props} sx={{ height: '50px', width: '125px' }} />
</Box>
</Box>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { FormControl, InputLabel, MenuItem, Select, type SelectChangeEvent } from '@mui/material';
import { type ComponentPropsWithRef, type JSX, type Ref, forwardRef, useId } from 'react';
import { type ComponentPropsWithRef, type Ref, useId } from 'react';

import type { SxProps, Theme } from '@mui/system';

Expand All @@ -15,22 +15,19 @@ type SelectWithLabelProps<V extends string, T extends MenuItems<V>> = {
sx?: SxProps<Theme>;
value: T['value'];
variant?: ComponentPropsWithRef<typeof FormControl>['variant'];
ref?: Ref<HTMLDivElement>;
};

// NOTE: 1. `forwardRef` to get the ref and let it inherit the props so it will finally work when we want to use a `ToolTip`.
// TODO: However, `forwardRef` will be deprecated in React19 as it can be passed as component props.
export const SelectWithLabel = forwardRef(function SelectWithLabel<V extends string, T extends MenuItems<V>>(
{
label,
menuItems,
onChange,
sx = { m: 1, minWidth: 110 },
value,
variant = 'filled',
...props // HACK: 2. We need to let props inherit in order for it to work when wrapped in `ToolTip`.
}: SelectWithLabelProps<V, T>,
ref: Ref<HTMLDivElement>,
) {
export const SelectWithLabel = <V extends string, T extends MenuItems<V>>({
label,
menuItems,
onChange,
sx = { m: 1, minWidth: 110 },
value,
variant = 'filled',
ref,
...props // HACK: 2. We need to let props inherit in order for it to work when wrapped in `ToolTip`.
}: SelectWithLabelProps<V, T>) => {
const id = useId();
const labelId = `${id}-label`;
const selectId = `${id}-select`;
Expand All @@ -47,9 +44,4 @@ export const SelectWithLabel = forwardRef(function SelectWithLabel<V extends str
</Select>
</FormControl>
);
}) as SelectLevelFc; // HACK: Hacks for code completion with generics. The pattern of passing customRef was treated as an error, so we had to cast it.

/** For code completion with generics. The pattern of passing customRef was treated as an error, so we had to cast it. */
type SelectLevelFc = <V extends string, T extends MenuItems<V>>(
props: SelectWithLabelProps<V, T> & { ref?: Ref<HTMLDivElement> },
) => JSX.Element;
};
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { openPath } from '@/services/api/dialog';

import { CONVERT_TREE_INIT_VALUES, useConvertContext } from './ConvertProvider';
import { PathSelector } from './PathSelector';
import { PathSelectorButtons } from './PathSelectorButtons';

import type { ComponentPropsWithRef } from 'react';

Expand Down Expand Up @@ -49,6 +50,8 @@ export const ConvertForm = () => {
{t('all-clear-btn')}
</Button>

<PathSelectorButtons />

{inputFieldsProps.map((inputProps) => {
return <InputField key={inputProps.label} {...inputProps} />;
})}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,10 @@ import { stringArraySchema, stringSchema } from '@/lib/zod/schema-utils';
import type { OutFormat } from '@/services/api/serde_hkx';

import { outFormatSchema } from './schemas/out_format';
import { SelectionType, selectionTypeSchema } from './schemas/selection_type';
import { type SelectionType, selectionTypeSchema } from './schemas/selection_type';

import type { TreeViewBaseItem } from '@mui/x-tree-view';


export type ConvertStatusPayload = {
/** Djb2 hash algorism */
pathId: number;
Expand Down
110 changes: 17 additions & 93 deletions gui/frontend/src/components/organisms/ConvertForm/PathSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,113 +1,37 @@
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import ErrorIcon from '@mui/icons-material/Error';
import { Box, Chip, Grid2 as Grid } from '@mui/material';
import CircularProgress from '@mui/material/CircularProgress';
import { open } from '@tauri-apps/plugin-dialog';
import { Box, Chip } from '@mui/material';

import { Button } from '@/components/molecules/Button';
import { hashDjb2 } from '@/lib/hash-djb2';
import { loadDirNode } from '@/services/api/serde_hkx';

import { useConvertContext } from './ConvertProvider';
import { OutFormatList } from './OutFormatList';
import { PathTreeSelector } from './PathTreeSelector';
import { SelectionTypeRadios } from './SelectionTypeRadios';
import { renderStatusIcon } from './renderStatusIcon';

import type { ComponentPropsWithRef } from 'react';

export const PathSelector = () => {
const {
selectionType,
selectedFiles,
setSelectedFiles,
selectedDirs,
setSelectedDirs,
selectedTree,
setSelectedTree,
convertStatuses,
setConvertStatuses,
} = useConvertContext();
const { selectionType, selectedFiles, setSelectedFiles, selectedDirs, setSelectedDirs, convertStatuses } =
useConvertContext();
const isDirMode = selectionType === 'dir';
const selectedPaths = isDirMode ? selectedDirs : selectedFiles;
const setSelectedPaths = isDirMode ? setSelectedDirs : setSelectedFiles;

const handleDelete: ComponentPropsWithRef<typeof Chip>['onDelete'] = (fileToDelete: string) =>
setSelectedPaths(selectedPaths.filter((file) => file !== fileToDelete));

const handlePathSelect = async () => {
const newSelectedPaths = await open({
title: isDirMode ? 'Select directory' : 'Select files',
filters: [{ name: '', extensions: ['hkx', 'xml', 'json', 'yaml'] }],
multiple: true,
directory: ['dir', 'tree'].includes(selectionType),
defaultPath: selectedPaths.at(0),
});

if (selectionType === 'tree') {
const roots = (() => {
if (Array.isArray(newSelectedPaths)) {
return newSelectedPaths;
}
if (newSelectedPaths !== null) {
return [newSelectedPaths];
}
})();

if (roots) {
setSelectedTree({ ...selectedTree, roots, tree: await loadDirNode(roots) });
}
return;
}

if (Array.isArray(newSelectedPaths)) {
setSelectedPaths(newSelectedPaths);
setConvertStatuses(new Map()); // Clear the conversion status when a new selection is made.
} else if (newSelectedPaths !== null) {
setSelectedPaths([newSelectedPaths]);
setConvertStatuses(new Map()); // Clear the conversion status when a new selection is made.
}
};

return (
<Box>
<Grid container={true} spacing={2} sx={{ justifyContent: 'space-between' }}>
<Grid>
<SelectionTypeRadios />
<Button onClick={handlePathSelect} />
</Grid>

<Grid>
<OutFormatList />
</Grid>
</Grid>

<Box mt={2}>
{selectionType === 'tree' ? (
<PathTreeSelector />
) : (
selectedPaths.map((path) => {
const pathId = hashDjb2(path);
const statusId = convertStatuses.get(pathId) ?? 0;

return (
<Chip icon={renderStatusIcon(statusId)} key={pathId} label={path} onDelete={() => handleDelete(path)} />
);
})
)}
</Box>
<Box mt={2}>
{selectionType === 'tree' ? (
<PathTreeSelector />
) : (
selectedPaths.map((path) => {
const pathId = hashDjb2(path);
const statusId = convertStatuses.get(pathId) ?? 0;

return (
<Chip icon={renderStatusIcon(statusId)} key={pathId} label={path} onDelete={() => handleDelete(path)} />
);
})
)}
</Box>
);
};

export const renderStatusIcon = (status: number) => {
switch (status) {
case 1:
return <CircularProgress size={20} />;
case 2:
return <CheckCircleIcon color='success' />;
case 3:
return <ErrorIcon color='error' />;
default:
return undefined;
}
};
Loading

0 comments on commit b496a0a

Please sign in to comment.