@rivm-loculus-tools/common-frontend 0.0.0-stage → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/LICENSE +288 -0
- package/eslint.config.js +16 -0
- package/package.json +160 -3
- package/scripts/update-exports.js +9 -0
- package/src/.gitkeep +0 -0
- package/src/@types/vite-env.d.ts +3 -0
- package/src/classes/services/FastaDataEventBusService/FastaDataEventBusService.ts +22 -0
- package/src/classes/services/FastaDataEventBusService/index.ts +1 -0
- package/src/components/FastaDataImportDialog/FastaDataImportDialog.tsx +364 -0
- package/src/components/FastaDataImportDialog/index.ts +1 -0
- package/src/components/FastaDataImportDialogLoader/FastaDataImportDialogLoader.tsx +124 -0
- package/src/components/FastaDataImportDialogLoader/index.ts +1 -0
- package/src/components/FastaNotLoaded/FastaNotLoaded.tsx +62 -0
- package/src/components/FastaNotLoaded/index.ts +1 -0
- package/src/components/FastaPageHeaderCenterComponent/FastaPageHeaderCenterComponent.tsx +50 -0
- package/src/components/FastaPageHeaderCenterComponent/index.ts +1 -0
- package/src/components/FastaPageHeaderImportButton/FastaPageHeaderImportButton.tsx +28 -0
- package/src/components/FastaPageHeaderImportButton/index.ts +1 -0
- package/src/components/FastaPageHeaderResetButton/FastaPageHeaderResetButton.tsx +50 -0
- package/src/components/FastaPageHeaderResetButton/index.ts +1 -0
- package/src/components/I18nWrapper/I18nWrapper.tsx +39 -0
- package/src/components/I18nWrapper/index.ts +1 -0
- package/src/components/PageHeaderDocumentationButton/PageHeaderDocumentationButton.tsx +25 -0
- package/src/components/PageHeaderDocumentationButton/index.ts +1 -0
- package/src/components/SearchBar/SearchBar.tsx +207 -0
- package/src/components/SearchBar/SearchBarNodeCounter.tsx +23 -0
- package/src/components/SearchBar/index.ts +1 -0
- package/src/components/page/ErrorPageCommon.tsx +51 -0
- package/src/components/page/PageCommon.tsx +76 -0
- package/src/components/page/PageCommonContext.ts +15 -0
- package/src/components/page/PageCommonContextProvider.tsx +30 -0
- package/src/components/page/PageCommonDrawer.tsx +35 -0
- package/src/components/page/PageCommonHeader.tsx +99 -0
- package/src/components/page/index.ts +6 -0
- package/src/constants/page.ts +1 -0
- package/src/index.ts +21 -0
- package/src/models/fasta.ts +22 -0
- package/src/models/metadata.ts +10 -0
- package/src/stores/fastaDataStore/fastaDataStore.ts +103 -0
- package/src/stores/fastaDataStore/index.ts +1 -0
- package/src/theme/fonts/RobotoFlex.woff2 +0 -0
- package/src/theme/index.ts +1 -0
- package/src/theme/theme.ts +368 -0
- package/src/utils/DownloadUtil/DownloadUtil.ts +10 -0
- package/src/utils/DownloadUtil/index.ts +1 -0
- package/src/utils/ErrorUtil/ErrorUtil.ts +5 -0
- package/src/utils/ErrorUtil/index.ts +1 -0
- package/src/utils/FastaUtil/FastaUtil.ts +109 -0
- package/src/utils/FastaUtil/index.ts +1 -0
- package/src/utils/MetaDataUtil/MetaDataUtil.ts +292 -0
- package/src/utils/MetaDataUtil/index.ts +1 -0
- package/src/utils/UrlUtil/UrlUtil.ts +35 -0
- package/src/utils/UrlUtil/index.ts +1 -0
- package/tsconfig.build.json +14 -0
- package/tsconfig.json +32 -0
- package/vite.config.ts +137 -0
- package/README.md +0 -3
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './FastaPageHeaderResetButton';
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useEffect,
|
|
3
|
+
useState,
|
|
4
|
+
} from 'react';
|
|
5
|
+
import type { ReactNode } from 'react';
|
|
6
|
+
import i18n from 'i18next';
|
|
7
|
+
import { initReactI18next } from 'react-i18next';
|
|
8
|
+
|
|
9
|
+
export const I18nWrapper = ({ children }: { children: ReactNode }) => {
|
|
10
|
+
const [isInitialized, setIsInitialized] = useState(false);
|
|
11
|
+
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
const performInitialization = async () => {
|
|
14
|
+
await i18n
|
|
15
|
+
.use(initReactI18next) // passes i18n down to react-i18next
|
|
16
|
+
.init({
|
|
17
|
+
interpolation: {
|
|
18
|
+
escapeValue: false, // react already safes from xss
|
|
19
|
+
},
|
|
20
|
+
lng: 'en',
|
|
21
|
+
resources: {
|
|
22
|
+
en: {
|
|
23
|
+
translation: {
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
setIsInitialized(true);
|
|
29
|
+
};
|
|
30
|
+
void performInitialization();
|
|
31
|
+
}, []);
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<>
|
|
36
|
+
{isInitialized ? children : null}
|
|
37
|
+
</>
|
|
38
|
+
);
|
|
39
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './I18nWrapper';
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { useCallback } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
IconButton,
|
|
4
|
+
Tooltip,
|
|
5
|
+
} from '@mui/material';
|
|
6
|
+
import HelpOutlineIcon from '@mui/icons-material/HelpOutlineOutlined';
|
|
7
|
+
|
|
8
|
+
export const PageHeaderDocumentationButton = () => {
|
|
9
|
+
const onDocumentationButtonClick = useCallback(() => {
|
|
10
|
+
window.open('https://github.com/RIVM-bioinformatics/loculus-tools', '_blank');
|
|
11
|
+
}, []);
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<Tooltip title={'Documentation & Specifications'}>
|
|
15
|
+
<IconButton
|
|
16
|
+
color={'inherit'}
|
|
17
|
+
onClick={onDocumentationButtonClick}
|
|
18
|
+
size={'small'}
|
|
19
|
+
sx={{ opacity: 0.85 }}
|
|
20
|
+
>
|
|
21
|
+
<HelpOutlineIcon fontSize={'small'} />
|
|
22
|
+
</IconButton>
|
|
23
|
+
</Tooltip>
|
|
24
|
+
);
|
|
25
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './PageHeaderDocumentationButton';
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ChangeEvent,
|
|
3
|
+
KeyboardEvent,
|
|
4
|
+
} from 'react';
|
|
5
|
+
import {
|
|
6
|
+
useCallback,
|
|
7
|
+
useMemo,
|
|
8
|
+
useRef,
|
|
9
|
+
} from 'react';
|
|
10
|
+
import {
|
|
11
|
+
Box,
|
|
12
|
+
FilledInput,
|
|
13
|
+
IconButton,
|
|
14
|
+
InputAdornment,
|
|
15
|
+
useTheme,
|
|
16
|
+
} from '@mui/material';
|
|
17
|
+
import SearchIcon from '@mui/icons-material/Search';
|
|
18
|
+
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
|
19
|
+
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
|
20
|
+
import CloseIcon from '@mui/icons-material/Close';
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
export type SearchBarProps = {
|
|
24
|
+
allowNavigation?: boolean;
|
|
25
|
+
highlightedIndex?: null | number;
|
|
26
|
+
numResults?: number;
|
|
27
|
+
onHighlightedIndexChange?: (index: number) => void;
|
|
28
|
+
onSearchQueryChange: (query: string) => void;
|
|
29
|
+
searchQuery: string;
|
|
30
|
+
truncated?: boolean;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const SearchBar = ({ allowNavigation, highlightedIndex, numResults = 0, onHighlightedIndexChange, onSearchQueryChange, searchQuery, truncated }: SearchBarProps) => {
|
|
34
|
+
const theme = useTheme();
|
|
35
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
36
|
+
const arrowUpButtonRef = useRef<HTMLButtonElement>(null);
|
|
37
|
+
const arrowDownButtonRef = useRef<HTMLButtonElement>(null);
|
|
38
|
+
|
|
39
|
+
const onTextInputChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
|
40
|
+
const nextSearchText = event.target.value;
|
|
41
|
+
onSearchQueryChange(nextSearchText);
|
|
42
|
+
}, [onSearchQueryChange]);
|
|
43
|
+
|
|
44
|
+
const onClearButtonClick = useCallback(() => {
|
|
45
|
+
onSearchQueryChange('');
|
|
46
|
+
inputRef.current?.focus();
|
|
47
|
+
}, [onSearchQueryChange]);
|
|
48
|
+
|
|
49
|
+
const onTextInputKeyDown = useCallback((event: KeyboardEvent<HTMLInputElement>) => {
|
|
50
|
+
// on enter press, highlight the first result and focus the down arrow button
|
|
51
|
+
if (event.key === 'Enter') {
|
|
52
|
+
if (!numResults) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
event.preventDefault();
|
|
56
|
+
onHighlightedIndexChange?.(0);
|
|
57
|
+
arrowDownButtonRef.current?.focus();
|
|
58
|
+
}
|
|
59
|
+
}, [numResults, onHighlightedIndexChange]);
|
|
60
|
+
|
|
61
|
+
const onUpArrowButtonClick = useCallback(() => {
|
|
62
|
+
// goto previous result
|
|
63
|
+
if (!numResults) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const newHighlightedIndex = highlightedIndex === null || highlightedIndex === undefined || Number.isNaN(highlightedIndex) || highlightedIndex <= 0 ? numResults - 1 : highlightedIndex - 1;
|
|
67
|
+
|
|
68
|
+
onHighlightedIndexChange?.(newHighlightedIndex);
|
|
69
|
+
}, [numResults, highlightedIndex, onHighlightedIndexChange]);
|
|
70
|
+
|
|
71
|
+
const onDownArrowButtonClick = useCallback(() => {
|
|
72
|
+
// goto next result
|
|
73
|
+
if (!numResults) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const newHighlightedIndex = highlightedIndex === null || highlightedIndex === undefined || Number.isNaN(highlightedIndex) || highlightedIndex >= numResults - 1 ? 0 : highlightedIndex + 1;
|
|
77
|
+
onHighlightedIndexChange?.(newHighlightedIndex);
|
|
78
|
+
}, [numResults, highlightedIndex, onHighlightedIndexChange]);
|
|
79
|
+
|
|
80
|
+
const onGenericKeyDown = useCallback((event: KeyboardEvent<HTMLButtonElement>) => {
|
|
81
|
+
// arrow right, focus the next focusable element
|
|
82
|
+
if (event.key === 'ArrowRight') {
|
|
83
|
+
event.preventDefault();
|
|
84
|
+
const nextFocusableElement = event.currentTarget.nextElementSibling as HTMLElement;
|
|
85
|
+
if (nextFocusableElement) {
|
|
86
|
+
nextFocusableElement.focus();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// arrow left, focus the previous focusable element
|
|
90
|
+
if (event.key === 'ArrowLeft') {
|
|
91
|
+
event.preventDefault();
|
|
92
|
+
if (event.target === arrowUpButtonRef.current) {
|
|
93
|
+
inputRef.current?.focus();
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const previousFocusableElement = event.currentTarget.previousElementSibling as HTMLElement;
|
|
97
|
+
if (previousFocusableElement) {
|
|
98
|
+
previousFocusableElement.focus();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}, []);
|
|
102
|
+
|
|
103
|
+
const searchResultStatusText = useMemo(() => {
|
|
104
|
+
if (!searchQuery) {
|
|
105
|
+
return '';
|
|
106
|
+
}
|
|
107
|
+
if (searchQuery && (Number.isNaN(numResults) || numResults <= 0)) {
|
|
108
|
+
return 'No results';
|
|
109
|
+
} else if (highlightedIndex === null || highlightedIndex === undefined || Number.isNaN(highlightedIndex) || highlightedIndex < 0 || !allowNavigation) {
|
|
110
|
+
return `${numResults}${truncated ? '+' : ''} results`;
|
|
111
|
+
} else if (allowNavigation) {
|
|
112
|
+
return `${highlightedIndex + 1}/${numResults}${truncated ? '+' : ''} results`;
|
|
113
|
+
}
|
|
114
|
+
return '';
|
|
115
|
+
}, [searchQuery, numResults, highlightedIndex, allowNavigation, truncated]);
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<FilledInput
|
|
120
|
+
endAdornment={(
|
|
121
|
+
<InputAdornment position={'end'}>
|
|
122
|
+
{searchResultStatusText && (
|
|
123
|
+
<Box
|
|
124
|
+
aria-atomic={'true'}
|
|
125
|
+
aria-live={'polite'}
|
|
126
|
+
role={'status'}
|
|
127
|
+
sx={{
|
|
128
|
+
fontSize: '1rem',
|
|
129
|
+
}}
|
|
130
|
+
>
|
|
131
|
+
{searchResultStatusText}
|
|
132
|
+
</Box>
|
|
133
|
+
)}
|
|
134
|
+
{numResults > 0 && allowNavigation && (
|
|
135
|
+
<>
|
|
136
|
+
<IconButton
|
|
137
|
+
aria-label={'Goto previous search result'}
|
|
138
|
+
disabled={!numResults}
|
|
139
|
+
edge={'end'}
|
|
140
|
+
onClick={onUpArrowButtonClick}
|
|
141
|
+
onKeyDown={onGenericKeyDown}
|
|
142
|
+
ref={arrowUpButtonRef}
|
|
143
|
+
sx={{ color: theme.palette.primary.main }}
|
|
144
|
+
>
|
|
145
|
+
<KeyboardArrowUpIcon />
|
|
146
|
+
</IconButton>
|
|
147
|
+
<IconButton
|
|
148
|
+
aria-label={'Goto next search result'}
|
|
149
|
+
disabled={!numResults}
|
|
150
|
+
edge={'end'}
|
|
151
|
+
onClick={onDownArrowButtonClick}
|
|
152
|
+
onKeyDown={onGenericKeyDown}
|
|
153
|
+
ref={arrowDownButtonRef}
|
|
154
|
+
sx={{ color: theme.palette.primary.main }}
|
|
155
|
+
>
|
|
156
|
+
<KeyboardArrowDownIcon />
|
|
157
|
+
</IconButton>
|
|
158
|
+
</>
|
|
159
|
+
)}
|
|
160
|
+
{searchQuery && (
|
|
161
|
+
<IconButton
|
|
162
|
+
aria-label={'Clear'}
|
|
163
|
+
edge={'end'}
|
|
164
|
+
onClick={onClearButtonClick}
|
|
165
|
+
onKeyDown={onGenericKeyDown}
|
|
166
|
+
>
|
|
167
|
+
<CloseIcon />
|
|
168
|
+
</IconButton>
|
|
169
|
+
)}
|
|
170
|
+
</InputAdornment>
|
|
171
|
+
)}
|
|
172
|
+
fullWidth
|
|
173
|
+
inputRef={inputRef}
|
|
174
|
+
name={'Search'}
|
|
175
|
+
onChange={onTextInputChange}
|
|
176
|
+
onKeyDown={onTextInputKeyDown}
|
|
177
|
+
placeholder={'Search'}
|
|
178
|
+
size={'small'}
|
|
179
|
+
slotProps={{
|
|
180
|
+
input: {
|
|
181
|
+
'aria-label': 'Search',
|
|
182
|
+
sx: {
|
|
183
|
+
'&::-webkit-search-cancel-button': {
|
|
184
|
+
display: 'none',
|
|
185
|
+
},
|
|
186
|
+
fontSize: '1rem',
|
|
187
|
+
height: theme.spacing(4),
|
|
188
|
+
outline: 'none !important',
|
|
189
|
+
paddingBottom: '0 !important',
|
|
190
|
+
paddingTop: '0 !important',
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
}}
|
|
194
|
+
startAdornment={(
|
|
195
|
+
<InputAdornment position={'start'}>
|
|
196
|
+
<SearchIcon fontSize={'small'} />
|
|
197
|
+
</InputAdornment>
|
|
198
|
+
)}
|
|
199
|
+
sx={{
|
|
200
|
+
backgroundColor: theme.palette.background.paper,
|
|
201
|
+
height: theme.spacing(4),
|
|
202
|
+
}}
|
|
203
|
+
type={'search'}
|
|
204
|
+
value={searchQuery}
|
|
205
|
+
/>
|
|
206
|
+
);
|
|
207
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Chip } from '@mui/material';
|
|
2
|
+
|
|
3
|
+
export type SearchBarNodeCounterProps = {
|
|
4
|
+
numResults?: number;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export const SearchBarNodeCounter = ({ numResults }: SearchBarNodeCounterProps) => {
|
|
8
|
+
if (numResults === undefined || Number.isNaN(numResults)) {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<Chip
|
|
14
|
+
color={numResults > 0 ? 'primary' : 'default'}
|
|
15
|
+
label={`${numResults} node${numResults === 1 ? '' : 's'}`}
|
|
16
|
+
size={'small'}
|
|
17
|
+
sx={{
|
|
18
|
+
margin: 0,
|
|
19
|
+
padding: 0,
|
|
20
|
+
}}
|
|
21
|
+
/>
|
|
22
|
+
);
|
|
23
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './SearchBar';
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Box,
|
|
3
|
+
Typography,
|
|
4
|
+
} from '@mui/material';
|
|
5
|
+
import { t } from 'i18next';
|
|
6
|
+
|
|
7
|
+
export type ErrorPageCommonProps = {
|
|
8
|
+
error: unknown;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const ErrorPageCommon = ({ error }: ErrorPageCommonProps) => {
|
|
12
|
+
return (
|
|
13
|
+
<Box
|
|
14
|
+
sx={{
|
|
15
|
+
alignItems: 'center',
|
|
16
|
+
display: 'flex',
|
|
17
|
+
flexDirection: 'column',
|
|
18
|
+
justifyContent: 'center',
|
|
19
|
+
width: '100%',
|
|
20
|
+
}}
|
|
21
|
+
>
|
|
22
|
+
<Box sx={{ padding: 1 }}>
|
|
23
|
+
<Typography
|
|
24
|
+
variant={'h1'}
|
|
25
|
+
>
|
|
26
|
+
{t`Something went wrong`}
|
|
27
|
+
</Typography>
|
|
28
|
+
</Box>
|
|
29
|
+
<Box sx={{ padding: 1 }}>
|
|
30
|
+
<Typography
|
|
31
|
+
variant={'body2'}
|
|
32
|
+
>
|
|
33
|
+
{error instanceof Error ? error.message : t`An unexpected error occurred.`}
|
|
34
|
+
</Typography>
|
|
35
|
+
</Box>
|
|
36
|
+
<Box sx={{ padding: 1 }}>
|
|
37
|
+
<Typography variant={'h6'}>
|
|
38
|
+
{t`Details for our technicians`}
|
|
39
|
+
</Typography>
|
|
40
|
+
<Box
|
|
41
|
+
component={'pre'}
|
|
42
|
+
sx={{
|
|
43
|
+
fontSize: '0.8rem',
|
|
44
|
+
}}
|
|
45
|
+
>
|
|
46
|
+
{(error as Error)?.stack}
|
|
47
|
+
</Box>
|
|
48
|
+
</Box>
|
|
49
|
+
</Box>
|
|
50
|
+
);
|
|
51
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Box,
|
|
3
|
+
useTheme,
|
|
4
|
+
} from '@mui/material';
|
|
5
|
+
import {
|
|
6
|
+
use,
|
|
7
|
+
useEffect,
|
|
8
|
+
} from 'react';
|
|
9
|
+
import type { ReactNode } from 'react';
|
|
10
|
+
|
|
11
|
+
import { DRAWER_WIDTH } from '../../constants/page';
|
|
12
|
+
|
|
13
|
+
import { PageCommonContext } from './PageCommonContext';
|
|
14
|
+
|
|
15
|
+
export type PageCommonProps = {
|
|
16
|
+
readonly children: ReactNode;
|
|
17
|
+
readonly drawerComponent?: ReactNode;
|
|
18
|
+
readonly headerComponent?: ReactNode;
|
|
19
|
+
readonly isNoInteraction?: boolean;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
export const PageCommon = ({ children, drawerComponent, headerComponent, isNoInteraction }: PageCommonProps) => {
|
|
24
|
+
const theme = useTheme();
|
|
25
|
+
const { isDrawerOpen, setIsNoInteraction } = use(PageCommonContext);
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
setIsNoInteraction(!!isNoInteraction);
|
|
29
|
+
}, [isNoInteraction, setIsNoInteraction]);
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<Box
|
|
33
|
+
sx={{
|
|
34
|
+
display: 'flex',
|
|
35
|
+
flexDirection: 'column',
|
|
36
|
+
height: '100vh',
|
|
37
|
+
overflow: 'hidden',
|
|
38
|
+
position: 'relative',
|
|
39
|
+
width: '100vw',
|
|
40
|
+
}}
|
|
41
|
+
>
|
|
42
|
+
{headerComponent}
|
|
43
|
+
{/* Main Body */}
|
|
44
|
+
<Box
|
|
45
|
+
sx={{
|
|
46
|
+
display: 'flex',
|
|
47
|
+
flex: 1,
|
|
48
|
+
height: 'calc(100vh - 56px)',
|
|
49
|
+
overflow: 'hidden',
|
|
50
|
+
position: 'relative',
|
|
51
|
+
width: '100%',
|
|
52
|
+
}}
|
|
53
|
+
>
|
|
54
|
+
{drawerComponent}
|
|
55
|
+
<Box
|
|
56
|
+
component={'main'}
|
|
57
|
+
sx={{
|
|
58
|
+
flexGrow: 1,
|
|
59
|
+
height: '100%',
|
|
60
|
+
overflow: 'hidden',
|
|
61
|
+
position: 'relative',
|
|
62
|
+
transition: () =>
|
|
63
|
+
theme.transitions.create(['margin', 'width'], {
|
|
64
|
+
duration: theme.transitions.duration.leavingScreen,
|
|
65
|
+
easing: theme.transitions.easing.sharp,
|
|
66
|
+
}),
|
|
67
|
+
width: isDrawerOpen ? `calc(100% - ${DRAWER_WIDTH}px)` : '100%',
|
|
68
|
+
}}
|
|
69
|
+
>
|
|
70
|
+
{children}
|
|
71
|
+
</Box>
|
|
72
|
+
|
|
73
|
+
</Box>
|
|
74
|
+
</Box>
|
|
75
|
+
);
|
|
76
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createContext } from 'react';
|
|
2
|
+
|
|
3
|
+
export type PageCommonContextData = {
|
|
4
|
+
readonly isDrawerOpen: boolean;
|
|
5
|
+
readonly isNoInteraction: boolean;
|
|
6
|
+
readonly setIsDrawerOpen: (open: boolean) => void;
|
|
7
|
+
readonly setIsNoInteraction: (open: boolean) => void;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const PageCommonContext = createContext<PageCommonContextData>({
|
|
11
|
+
isDrawerOpen: false,
|
|
12
|
+
isNoInteraction: false,
|
|
13
|
+
setIsDrawerOpen: () => null,
|
|
14
|
+
setIsNoInteraction: () => null,
|
|
15
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useMemo,
|
|
3
|
+
useState,
|
|
4
|
+
} from 'react';
|
|
5
|
+
import type { PropsWithChildren } from 'react';
|
|
6
|
+
|
|
7
|
+
import type { PageCommonContextData } from './PageCommonContext';
|
|
8
|
+
import { PageCommonContext } from './PageCommonContext';
|
|
9
|
+
|
|
10
|
+
export type PageCommonContextProviderProps = PropsWithChildren;
|
|
11
|
+
|
|
12
|
+
export const PageCommonContextProvider = ({ children }: PageCommonContextProviderProps) => {
|
|
13
|
+
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
|
14
|
+
const [isNoInteraction, setIsNoInteraction] = useState(false);
|
|
15
|
+
|
|
16
|
+
const contextValue = useMemo<PageCommonContextData>(() => {
|
|
17
|
+
return {
|
|
18
|
+
isDrawerOpen,
|
|
19
|
+
isNoInteraction,
|
|
20
|
+
setIsDrawerOpen,
|
|
21
|
+
setIsNoInteraction,
|
|
22
|
+
};
|
|
23
|
+
}, [isDrawerOpen, isNoInteraction]);
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<PageCommonContext value={contextValue}>
|
|
27
|
+
{ children }
|
|
28
|
+
</PageCommonContext>
|
|
29
|
+
);
|
|
30
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Drawer } from '@mui/material';
|
|
2
|
+
import { use } from 'react';
|
|
3
|
+
import type { ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
import { DRAWER_WIDTH } from '../../constants/page';
|
|
6
|
+
|
|
7
|
+
import { PageCommonContext } from './PageCommonContext';
|
|
8
|
+
|
|
9
|
+
export type PageCommonDrawerProps = {
|
|
10
|
+
readonly children: ReactNode;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const PageCommonDrawer = ({ children }: PageCommonDrawerProps) => {
|
|
14
|
+
const { isDrawerOpen } = use(PageCommonContext);
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<Drawer
|
|
18
|
+
anchor={'left'}
|
|
19
|
+
open={isDrawerOpen}
|
|
20
|
+
sx={{
|
|
21
|
+
'& .MuiDrawer-paper': {
|
|
22
|
+
height: 'calc(100% - 56px)',
|
|
23
|
+
overflowY: 'auto',
|
|
24
|
+
top: '56px',
|
|
25
|
+
width: DRAWER_WIDTH,
|
|
26
|
+
},
|
|
27
|
+
flexShrink: 0,
|
|
28
|
+
width: isDrawerOpen ? DRAWER_WIDTH : 0,
|
|
29
|
+
}}
|
|
30
|
+
variant={'persistent'}
|
|
31
|
+
>
|
|
32
|
+
{children}
|
|
33
|
+
</Drawer>
|
|
34
|
+
);
|
|
35
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
use,
|
|
4
|
+
useCallback,
|
|
5
|
+
} from 'react';
|
|
6
|
+
import {
|
|
7
|
+
AppBar,
|
|
8
|
+
Box,
|
|
9
|
+
IconButton,
|
|
10
|
+
Toolbar,
|
|
11
|
+
Typography,
|
|
12
|
+
} from '@mui/material';
|
|
13
|
+
import MenuIcon from '@mui/icons-material/Menu';
|
|
14
|
+
|
|
15
|
+
import { PageCommonContext } from './PageCommonContext';
|
|
16
|
+
|
|
17
|
+
export type PageCommonHeaderProps = {
|
|
18
|
+
readonly centerComponent?: ReactNode;
|
|
19
|
+
readonly icon?: ReactNode;
|
|
20
|
+
readonly rightComponent?: ReactNode;
|
|
21
|
+
readonly title: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const PageCommonHeader = ({ centerComponent, icon, rightComponent, title }: PageCommonHeaderProps) => {
|
|
25
|
+
const { isDrawerOpen, isNoInteraction, setIsDrawerOpen } = use(PageCommonContext);
|
|
26
|
+
|
|
27
|
+
const onToggleDrawerButtonClick = useCallback(() => {
|
|
28
|
+
setIsDrawerOpen(!isDrawerOpen);
|
|
29
|
+
}, [isDrawerOpen, setIsDrawerOpen]);
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<AppBar
|
|
33
|
+
elevation={1}
|
|
34
|
+
position={'static'}
|
|
35
|
+
sx={{
|
|
36
|
+
backgroundColor: 'primary.main',
|
|
37
|
+
color: '#FFFFFF',
|
|
38
|
+
height: 56,
|
|
39
|
+
justifyContent: 'center',
|
|
40
|
+
zIndex: (theme) => theme.zIndex.drawer + 1,
|
|
41
|
+
}}
|
|
42
|
+
>
|
|
43
|
+
<Toolbar
|
|
44
|
+
sx={{ display: 'flex', justifyContent: 'space-between', px: 2 }}
|
|
45
|
+
variant={'dense'}
|
|
46
|
+
>
|
|
47
|
+
{/* Left: Menu toggle and App Branding */}
|
|
48
|
+
<Box sx={{ alignItems: 'center', display: 'flex', gap: 1.5 }}>
|
|
49
|
+
{!isNoInteraction && (
|
|
50
|
+
<IconButton
|
|
51
|
+
aria-label={'open drawer'}
|
|
52
|
+
color={'inherit'}
|
|
53
|
+
edge={'start'}
|
|
54
|
+
onClick={onToggleDrawerButtonClick}
|
|
55
|
+
size={'small'}
|
|
56
|
+
>
|
|
57
|
+
<MenuIcon />
|
|
58
|
+
</IconButton>
|
|
59
|
+
)}
|
|
60
|
+
{icon && (
|
|
61
|
+
<>
|
|
62
|
+
{icon}
|
|
63
|
+
</>
|
|
64
|
+
)}
|
|
65
|
+
<Typography
|
|
66
|
+
component={'div'}
|
|
67
|
+
noWrap
|
|
68
|
+
sx={{ color: 'inherit', fontSize: '1.05rem', fontWeight: 700, letterSpacing: '-0.01em' }}
|
|
69
|
+
variant={'h6'}
|
|
70
|
+
>
|
|
71
|
+
{title}
|
|
72
|
+
</Typography>
|
|
73
|
+
<Typography
|
|
74
|
+
sx={{
|
|
75
|
+
backgroundColor: 'rgba(255, 255, 255, 0.15)',
|
|
76
|
+
borderRadius: 1,
|
|
77
|
+
display: { sm: 'inline-block', xs: 'none' },
|
|
78
|
+
fontSize: '0.7rem',
|
|
79
|
+
fontWeight: 600,
|
|
80
|
+
px: 1,
|
|
81
|
+
py: 0.25,
|
|
82
|
+
}}
|
|
83
|
+
variant={'caption'}
|
|
84
|
+
>
|
|
85
|
+
{'Loculus Tool'}
|
|
86
|
+
</Typography>
|
|
87
|
+
</Box>
|
|
88
|
+
<Box sx={{ alignItems: 'center', display: 'flex', gap: 1 }}>
|
|
89
|
+
{centerComponent}
|
|
90
|
+
</Box>
|
|
91
|
+
{!isNoInteraction && (
|
|
92
|
+
<Box sx={{ alignItems: 'center', display: 'flex', gap: 1 }}>
|
|
93
|
+
{rightComponent}
|
|
94
|
+
</Box>
|
|
95
|
+
)}
|
|
96
|
+
</Toolbar>
|
|
97
|
+
</AppBar>
|
|
98
|
+
);
|
|
99
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const DRAWER_WIDTH = 340;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export * from './classes/services/FastaDataEventBusService';
|
|
2
|
+
export * from './components/FastaDataImportDialog';
|
|
3
|
+
export * from './components/FastaDataImportDialogLoader';
|
|
4
|
+
export * from './components/FastaNotLoaded';
|
|
5
|
+
export * from './components/FastaPageHeaderCenterComponent';
|
|
6
|
+
export * from './components/FastaPageHeaderImportButton';
|
|
7
|
+
export * from './components/FastaPageHeaderResetButton';
|
|
8
|
+
export * from './components/I18nWrapper';
|
|
9
|
+
export * from './components/page';
|
|
10
|
+
export * from './components/PageHeaderDocumentationButton';
|
|
11
|
+
export * from './components/SearchBar';
|
|
12
|
+
export * from './constants/page';
|
|
13
|
+
export * from './models/fasta';
|
|
14
|
+
export * from './models/metadata';
|
|
15
|
+
export * from './stores/fastaDataStore';
|
|
16
|
+
export * from './theme';
|
|
17
|
+
export * from './utils/DownloadUtil';
|
|
18
|
+
export * from './utils/ErrorUtil';
|
|
19
|
+
export * from './utils/FastaUtil';
|
|
20
|
+
export * from './utils/MetaDataUtil';
|
|
21
|
+
export * from './utils/UrlUtil';
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export enum LOADING_STATUS {
|
|
2
|
+
ERROR = 'ERROR',
|
|
3
|
+
IDLE = 'IDLE',
|
|
4
|
+
LOADING = 'LOADING',
|
|
5
|
+
SUCCESS = 'SUCCESS',
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type FastaParseResult = {
|
|
9
|
+
error?: string;
|
|
10
|
+
expectedLength: number;
|
|
11
|
+
invalidSequences?: { id: string; length: number }[];
|
|
12
|
+
isAligned: boolean;
|
|
13
|
+
sequences: FastaSequence[];
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type FastaSequence = {
|
|
17
|
+
accessionVersion: string;
|
|
18
|
+
header: string;
|
|
19
|
+
id: string;
|
|
20
|
+
length: number;
|
|
21
|
+
sequence: string;
|
|
22
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface MetadataParseResult {
|
|
2
|
+
columns: string[];
|
|
3
|
+
error?: string;
|
|
4
|
+
records: MetadataRecord[];
|
|
5
|
+
recordsByAccession: Record<string, MetadataRecord>;
|
|
6
|
+
recordsByColumn: Record<string, Record<string, MetadataRecord>>;
|
|
7
|
+
recordsBySampleId: Record<string, MetadataRecord>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type MetadataRecord = Record<string, string>;
|