Skip to content

Commit

Permalink
Merge pull request #468 from railmapgen/#40
Browse files Browse the repository at this point in the history
#40 Support switching between project selector view and app view
  • Loading branch information
wongchito authored Nov 8, 2022
2 parents 0539616 + 8c13a93 commit f7e9984
Show file tree
Hide file tree
Showing 14 changed files with 234 additions and 66 deletions.
3 changes: 2 additions & 1 deletion src/components/modal/template-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { useTranslation } from 'react-i18next';
import { translateText } from '../../i18n/config';
import { useRootDispatch } from '../../redux';
import { companyConfig, templateList } from '@railmapgen/rmg-templates-resources';
import { startLoading } from '../../redux/app/app-slice';
import { startLoading, stopLoading } from '../../redux/app/app-slice';
import { Events } from '../../constants/constants';
import rmgRuntime from '@railmapgen/rmg-runtime';
import { RmgEnrichedButton } from '@railmapgen/rmg-components';
Expand All @@ -41,6 +41,7 @@ export default function TemplateModal(props: TemplateModalProps) {
);
onOpenParam(module.default);
rmgRuntime.event(Events.OPEN_TEMPLATE, { company, filename });
dispatch(stopLoading());
};

return (
Expand Down
24 changes: 20 additions & 4 deletions src/components/page-header/header-actions.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,38 @@
import React from 'react';
import { HStack, Button } from '@chakra-ui/react';
import { Button, HStack } from '@chakra-ui/react';
import DownloadActions from './download-actions';
import { MdPalette } from 'react-icons/md';
import { MdFolder, MdPalette } from 'react-icons/md';
import { useDispatch } from 'react-redux';
import { SidePanelMode } from '../../constants/constants';
import { setSidePanelMode } from '../../redux/app/app-slice';
import { setParamConfig, setSidePanelMode } from '../../redux/app/app-slice';
import { useTranslation } from 'react-i18next';
import OpenActions from './open-actions';
import rmgRuntime, { RmgEnv } from '@railmapgen/rmg-runtime';
import { useSearchParams } from 'react-router-dom';

export default function HeaderActions() {
const { t } = useTranslation();
const dispatch = useDispatch();

const [, setSearchParams] = useSearchParams();

const handleGoToSelectorView = () => {
// reset param config to stop param update trigger
dispatch(setParamConfig(undefined));
setSearchParams({});
};

return (
<HStack ml="auto" w="fit-content">
{rmgRuntime.getEnv() !== RmgEnv.PRD && (
<Button variant="ghost" size="sm" leftIcon={<MdFolder />} onClick={handleGoToSelectorView}>
{t('All projects')}
</Button>
)}

<DownloadActions />

<OpenActions />
{rmgRuntime.getEnv() === RmgEnv.PRD && <OpenActions />}

<Button
variant="solid"
Expand Down
3 changes: 3 additions & 0 deletions src/components/page-header/open-actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import { initParam } from '../../redux/param/util';
import { useRootDispatch, useRootSelector } from '../../redux';
import { LanguageCode } from '@railmapgen/rmg-translate';

/**
* @deprecated
*/
export default function OpenActions() {
const { t, i18n } = useTranslation();
const dispatch = useRootDispatch();
Expand Down
28 changes: 25 additions & 3 deletions src/components/param-selector-view/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,17 @@ import { createMockAppStore, createParamInLocalStorage } from '../../setupTests'
import { fireEvent, screen } from '@testing-library/react';

const realStore = rootReducer.getState();
const mockStore = createMockAppStore({ ...realStore });

describe('ParamSelectorView', () => {
afterEach(() => {
window.localStorage.clear();
mockStore.clearActions();
});

it('Can disable open button if no project is selected', () => {
createParamInLocalStorage('test-1');
createParamInLocalStorage('test-2');
const mockStore = createMockAppStore({ ...realStore, app: { ...realStore.app } });

render(<ParamSelectorView />, { store: mockStore, route: '/' });

Expand All @@ -20,10 +25,27 @@ describe('ParamSelectorView', () => {
expect(screen.getByText(/test-2/)).toBeInTheDocument();

// open button is disabled
expect(screen.getByRole('button', { name: 'Open project' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Open selected' })).toBeDisabled();

// select test-2 and open
fireEvent.click(screen.getByText(/test-2/));
expect(screen.getByRole('button', { name: 'Open project' })).toBeEnabled();
expect(screen.getByRole('button', { name: 'Open selected' })).toBeEnabled();
});

it('Can disable new/template/upload buttons if max number reached', () => {
for (let i = 1; i <= 10; i++) {
createParamInLocalStorage('test-' + i);
}

render(<ParamSelectorView />, { store: mockStore, route: '/' });

// new/template/upload buttons are disabled
expect(screen.getByRole('button', { name: 'Blank project' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Open template' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Import project' })).toBeDisabled();
});

it('Can show error message if invalid type of file is uploaded', () => {
// TODO
});
});
78 changes: 71 additions & 7 deletions src/components/param-selector-view/index.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { ChangeEvent, useEffect, useRef, useState } from 'react';
import { RmgCard, RmgLoader, RmgPage } from '@railmapgen/rmg-components';
import { useSearchParams } from 'react-router-dom';
import { Button, Container, Heading, HStack, SystemStyleObject, useOutsideClick, VStack } from '@chakra-ui/react';
import { useTranslation } from 'react-i18next';
import { MdAdd, MdOpenInBrowser } from 'react-icons/md';
import { MdAdd, MdInsertDriveFile, MdOpenInBrowser, MdUpload } from 'react-icons/md';
import { nanoid } from 'nanoid';
import rmgRuntime from '@railmapgen/rmg-runtime';
import { Events, LocalStorageKey, ParamConfig } from '../../constants/constants';
import ParamSelector from '../param-selector-view/param-selector';
import { getParamRegistry } from '../../util/param-manager-utils';
import { getParamRegistry, importParam } from '../../util/param-manager-utils';
import TemplateModal from '../modal/template-modal';
import { readFileAsText } from '../../util/utils';

const paramSelectorCardStyle: SystemStyleObject = {
flexDirection: 'column',
Expand Down Expand Up @@ -40,7 +42,9 @@ export default function ParamSelectorView() {

const [paramRegistry, setParamRegistry] = useState<ParamConfig[]>([]);
const [selectedParam, setSelectedParam] = useState<string>();
const [isTemplateModalOpen, setIsTemplateModalOpen] = useState(false);
const selectorRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);

useEffect(() => {
// init paramRegistry state once
Expand All @@ -54,7 +58,36 @@ export default function ParamSelectorView() {
rmgRuntime.event(Events.NEW_PARAM, {});
};

const handleOpen = () => {
const handleOpenTemplate = (param: Record<string, any>) => {
const id = importParam(JSON.stringify(param));
setSearchParams({ project: id });
};

const handleImportProject = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
console.log('handleImportProject():: received file', file);

try {
if (file?.type !== 'application/json') {
// TODO
// dispatch(setGlobalAlert({ status: 'error', message: t('OpenActions.invalidType') }));
}

const paramStr = await readFileAsText(file!);
const id = importParam(paramStr);
setSearchParams({ project: id });
rmgRuntime.event(Events.UPLOAD_PARAM, {});
} catch (err) {
// TODO
// dispatch(setGlobalAlert({ status: 'error', message: t('OpenActions.unknownError') }));
console.error('handleImportProject():: Unknown error occurred while parsing the uploaded file', err);
}

// clear field for next upload
event.target.value = '';
};

const handleOpenSelected = () => {
if (selectedParam) {
setSearchParams({ project: selectedParam });
rmgRuntime.event(Events.OPEN_PARAM, {});
Expand All @@ -71,6 +104,8 @@ export default function ParamSelectorView() {
rmgRuntime.event(Events.REMOVE_PARAM, {});
};

const isProjectLimitReached = paramRegistry.length >= 10;

return (
<RmgPage justifyContent="center">
{urlParamId && <RmgLoader isIndeterminate />}
Expand All @@ -89,20 +124,49 @@ export default function ParamSelectorView() {
/>

<VStack>
<Button leftIcon={<MdAdd />} onClick={handleNew}>
<Button leftIcon={<MdAdd />} onClick={handleNew} isDisabled={isProjectLimitReached}>
{t('Blank project')}
</Button>
<Button
leftIcon={<MdInsertDriveFile />}
onClick={() => setIsTemplateModalOpen(true)}
isDisabled={isProjectLimitReached}
>
{t('Open template')}
</Button>
<Button
leftIcon={<MdUpload />}
onClick={() => fileInputRef.current?.click()}
isDisabled={isProjectLimitReached}
>
{t('Import project')}
</Button>
<Button
colorScheme="primary"
leftIcon={<MdOpenInBrowser />}
onClick={handleOpen}
onClick={handleOpenSelected}
isDisabled={selectedParam === undefined}
>
{t('Open project')}
{t('Open selected')}
</Button>
</VStack>
</HStack>
</RmgCard>
</Container>

<input
ref={fileInputRef}
type="file"
accept=".json"
hidden={true}
onChange={handleImportProject}
data-testid="file-upload"
/>
<TemplateModal
isOpen={isTemplateModalOpen}
onClose={() => setIsTemplateModalOpen(false)}
onOpenParam={handleOpenTemplate}
/>
</RmgPage>
);
}
90 changes: 51 additions & 39 deletions src/components/param-selector-view/param-selector.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import { ButtonGroup, Flex, IconButton, SystemStyleObject } from '@chakra-ui/react';
import { Box, ButtonGroup, Flex, IconButton, SystemStyleObject, Text } from '@chakra-ui/react';
import { getRelativeTime } from '../../util/utils';
import { MdDelete } from 'react-icons/md';
import { useTranslation } from 'react-i18next';
Expand All @@ -15,19 +15,22 @@ interface ParamSelectorProps {

const styles: SystemStyleObject = {
flex: 1,
flexDirection: 'column',
h: 200,
overflowX: 'hidden',
overflowY: 'auto',
borderRadius: 'md',
borderWidth: 2,

'& .chakra-button__group': {
flexShrink: 0,
h: 10,
'& > div': {
flexDirection: 'column',
h: 200,
overflowX: 'hidden',
overflowY: 'auto',
borderRadius: 'md',
borderWidth: 2,

'& button': {
h: '100%',
'& .chakra-button__group': {
flexShrink: 0,
h: 10,

'& button': {
h: '100%',
},
},
},
};
Expand All @@ -37,32 +40,41 @@ export default function ParamSelector(props: ParamSelectorProps) {
const { t } = useTranslation();

return (
<Flex sx={styles}>
{paramRegistry
.slice()
.sort((a, b) => {
return (b.lastModified ?? 0) - (a.lastModified ?? 0);
})
.map(({ id, lastModified }) => (
<ButtonGroup
key={id}
size="sm"
isAttached
colorScheme={selectedParam === id ? 'primary' : undefined}
variant={selectedParam === id ? 'solid' : 'ghost'}
>
<RmgEnrichedButton
primaryText={t('Project ID') + ': ' + id}
secondaryText={t('Last modified') + ': ' + getRelativeTime(lastModified)}
onClick={() => onParamSelect(id)}
/>
<IconButton
aria-label="Remove this project"
icon={<MdDelete />}
onClick={() => onParamRemove(id)}
/>
</ButtonGroup>
))}
</Flex>
<Box sx={styles}>
<Flex>
{paramRegistry
.slice()
.sort((a, b) => {
return (b.lastModified ?? 0) - (a.lastModified ?? 0);
})
.map(({ id, lastModified }) => (
<ButtonGroup
key={id}
size="sm"
isAttached
colorScheme={selectedParam === id ? 'primary' : undefined}
variant={selectedParam === id ? 'solid' : 'ghost'}
>
<RmgEnrichedButton
primaryText={t('Project ID') + ': ' + id}
secondaryText={
t('Last modified') + ': ' + getRelativeTime(lastModified).map(t).join(' ')
}
onClick={() => onParamSelect(id)}
/>
<IconButton
aria-label="Remove this project"
icon={<MdDelete />}
onClick={() => onParamRemove(id)}
/>
</ButtonGroup>
))}
</Flex>
{paramRegistry.length >= 10 && (
<Text as="em" fontSize="xs">
{t('You have reached the maximum number of projects.')}
</Text>
)}
</Box>
);
}
1 change: 1 addition & 0 deletions src/components/root/app-router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export default function AppRouter() {
setSearchParams({ project: Object.keys(paramMap)[0] ?? nanoid() });
} else {
console.log('AppRouter:: No URL param ID provided. Rendering param selector view...');
setIsLoaded(false);
}
}
}, [paramId]);
Expand Down
Loading

0 comments on commit f7e9984

Please sign in to comment.