@opencloning/ui 1.7.3 → 1.8.1

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.
Files changed (52) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/package.json +20 -14
  3. package/src/components/AppAlerts.jsx +7 -7
  4. package/src/components/CloningHistory.jsx +2 -0
  5. package/src/components/CloningSpeedDial.jsx +102 -0
  6. package/src/components/DownloadCloningStrategyDialog.jsx +2 -2
  7. package/src/components/LoadCloningHistoryWrapper.jsx +2 -2
  8. package/src/components/MainSequenceEditor.jsx +41 -34
  9. package/src/components/NetworkNode.jsx +35 -33
  10. package/src/components/OpenCloning.jsx +4 -3
  11. package/src/components/SequenceViewer.cy.jsx +27 -0
  12. package/src/components/SequenceViewer.jsx +211 -0
  13. package/src/components/annotation/AnnotationChangedAlert.jsx +26 -0
  14. package/src/components/annotation/useUpdateAnnotationInMainSequence.js +2 -2
  15. package/src/components/assembler/Assembler.jsx +3 -3
  16. package/src/components/domesticate/DomesticateDialog.jsx +274 -0
  17. package/src/components/form/GetRequestMultiSelect.jsx +0 -1
  18. package/src/components/form/PostRequestSelect.jsx +0 -1
  19. package/src/components/form/QuerySelect.jsx +128 -0
  20. package/src/components/form/QueryStatusWrapper.jsx +31 -0
  21. package/src/components/form/SubmitToDatabaseDialog.jsx +63 -58
  22. package/src/components/form/TextFieldQueryValidated.jsx +93 -0
  23. package/src/components/index.js +4 -0
  24. package/src/components/navigation/FeedbackDialog.jsx +1 -1
  25. package/src/components/navigation/MainAppBar.jsx +5 -5
  26. package/src/components/primers/import_primers/ImportPrimersButton.jsx +2 -2
  27. package/src/components/primers/import_primers/PrimerDatabaseImportForm.jsx +6 -3
  28. package/src/components/sources/FinishedSource.jsx +3 -0
  29. package/src/components/sources/MultipleOutputsSelector.cy.jsx +95 -0
  30. package/src/components/sources/MultipleOutputsSelector.jsx +32 -19
  31. package/src/components/sources/RecombinaseList.cy.jsx +1 -1
  32. package/src/components/sources/RecombinaseList.jsx +1 -1
  33. package/src/components/sources/SourceAssembly.jsx +36 -2
  34. package/src/components/sources/SourceRepositoryId.jsx +0 -1
  35. package/src/components/sources/SourceTypeSelector.jsx +1 -1
  36. package/src/components/verification/ImportSequencingFilesInput.jsx +17 -0
  37. package/src/components/verification/LoadFromDatabaseButton.jsx +17 -10
  38. package/src/components/verification/VerificationFileDialog.jsx +16 -22
  39. package/src/components/verification/index.js +1 -0
  40. package/src/config/defaultMainEditorProps.js +1 -0
  41. package/src/hooks/useAlerts.js +3 -4
  42. package/src/hooks/useBackendAPI.js +5 -2
  43. package/src/hooks/useCloningAlerts.js +7 -0
  44. package/src/hooks/useDebouncedSearchQuery.js +42 -0
  45. package/src/hooks/useLoadDatabaseFile.js +6 -2
  46. package/src/hooks/useSequencingAlignment.js +26 -0
  47. package/src/hooks/useSnapgeneHistoryEndpoint.js +3 -3
  48. package/src/hooks/useStoreEditor.js +10 -68
  49. package/src/hooks/useUrlParamsLoader.js +2 -2
  50. package/src/hooks/useValidateState.js +3 -3
  51. package/src/index.css +3 -11
  52. package/src/version.js +1 -1
@@ -0,0 +1,128 @@
1
+ import React from 'react';
2
+ import { useQuery } from '@tanstack/react-query';
3
+ import { Autocomplete, TextField, FormControl, FormHelperText, InputLabel, MenuItem, Select, Button } from '@mui/material';
4
+ import QueryStatusWrapper from './QueryStatusWrapper';
5
+ import { AddCircle as AddCircleIcon } from '@mui/icons-material';
6
+
7
+ export default function QuerySelect({
8
+ query,
9
+ label,
10
+ loadingMessage = 'Loading...',
11
+ errorMessage = 'Could not load options',
12
+ onChange = () => {},
13
+ value,
14
+ multiple = true,
15
+ autoComplete = true,
16
+ getOptionLabel,
17
+ getOptionKey = getOptionLabel,
18
+ autocompleteProps = {},
19
+ inputProps = {},
20
+ onClear = () => {},
21
+ noOptionsAction = null, // { label: string, onClick: () => void }
22
+ ...rest
23
+ }) {
24
+ const queryResult = useQuery(query);
25
+ const { data: options = [] } = queryResult;
26
+ const [inputValue, setInputValue] = React.useState('');
27
+ const [internalValue, setInternalValue] = React.useState(() => (multiple ? [] : null));
28
+
29
+ const isControlled = value !== undefined;
30
+ const effectiveValue = isControlled ? value : internalValue;
31
+
32
+ const isOptionEqualToValue = React.useCallback((option, val) => getOptionKey(option) === getOptionKey(val), [getOptionKey]);
33
+
34
+ // Selected + query options so selected items stay visible when typing
35
+ const displayOptions = React.useMemo(() => {
36
+ const selected = multiple ? (effectiveValue || []) : effectiveValue ? [effectiveValue] : [];
37
+ const allOptions = [...selected, ...options];
38
+ return allOptions.filter((obj, index, self) =>
39
+ index === self.findIndex((t) => getOptionKey(t) === getOptionKey(obj))
40
+ );
41
+ }, [effectiveValue, multiple, options, getOptionKey]);
42
+
43
+ const handleAutocompleteChange = React.useCallback(
44
+ (event, newValue) => {
45
+ if (!isControlled) setInternalValue(newValue);
46
+ onChange(newValue);
47
+ setInputValue('');
48
+ onClear();
49
+ },
50
+ [onChange, isControlled, onClear],
51
+ );
52
+
53
+ const autocompleteValue = effectiveValue !== undefined ? effectiveValue : (multiple ? [] : null);
54
+
55
+ const noOptionsText = React.useMemo(() => {
56
+ if (noOptionsAction) {
57
+ return <MenuItem onClick={() => noOptionsAction.onClick(inputValue)} value="">
58
+ <AddCircleIcon color="success" />
59
+ <em style={{ marginLeft: 8 }}>{noOptionsAction.label}</em>
60
+ </MenuItem>
61
+ }
62
+ return undefined;
63
+ }, [noOptionsAction, inputValue]);
64
+
65
+ return (
66
+ <QueryStatusWrapper renderIfLoading={autoComplete} queryResult={queryResult} loadingMessage={loadingMessage} errorMessage={errorMessage}>
67
+ <FormControl {...rest}>
68
+ {autoComplete ? (
69
+ <Autocomplete
70
+ multiple={multiple}
71
+ onChange={handleAutocompleteChange}
72
+ options={displayOptions}
73
+ noOptionsText={noOptionsText}
74
+ getOptionLabel={getOptionLabel}
75
+ getOptionKey={getOptionKey}
76
+ value={autocompleteValue}
77
+ forcePopupIcon
78
+ isOptionEqualToValue={isOptionEqualToValue}
79
+ inputValue={inputValue}
80
+ onInputChange={(event, newInputValue, reason) => {
81
+ if (reason === 'reset') return;
82
+ setInputValue(newInputValue);
83
+ }}
84
+ {...autocompleteProps}
85
+ renderInput={(params) => (
86
+ <TextField
87
+ {...params}
88
+ label={label}
89
+ {...inputProps}
90
+ />
91
+ )}
92
+ />
93
+ ) : (
94
+ <>
95
+ <InputLabel id={`select-${label.replaceAll(' ', '-')}`}>{label}</InputLabel>
96
+ <Select
97
+ multiple={multiple}
98
+ onChange={(event) => {
99
+ const keys = event.target.value;
100
+ const keyList = multiple ? (Array.isArray(keys) ? keys : [keys]) : [keys];
101
+ const nextValue = multiple
102
+ ? keyList.map((k) => options.find((o) => getOptionKey(o) === k)).filter(Boolean)
103
+ : options.find((o) => getOptionKey(o) === keys) ?? null;
104
+ if (!isControlled) setInternalValue(nextValue);
105
+ onChange?.(nextValue);
106
+ }}
107
+ value={
108
+ multiple
109
+ ? (effectiveValue || []).map((o) => getOptionKey(o))
110
+ : effectiveValue ? getOptionKey(effectiveValue) : ''
111
+ }
112
+ label={label}
113
+ error={options.length === 0}
114
+ inputProps={inputProps}
115
+ >
116
+ {options.map((option) => (
117
+ <MenuItem key={getOptionKey(option)} value={getOptionKey(option)}>
118
+ {getOptionLabel(option)}
119
+ </MenuItem>
120
+ ))}
121
+ </Select>
122
+ <FormHelperText>{options.length === 0 ? autocompleteProps?.noOptionsText : ''}</FormHelperText>
123
+ </>
124
+ )}
125
+ </FormControl>
126
+ </QueryStatusWrapper>
127
+ );
128
+ }
@@ -0,0 +1,31 @@
1
+ import React from 'react';
2
+ import { Alert, CircularProgress } from '@mui/material';
3
+ import RetryAlert from './RetryAlert';
4
+
5
+ export default function QueryStatusWrapper({
6
+ children,
7
+ queryResult,
8
+ loadingMessage = 'Loading...',
9
+ errorMessage = 'Could not load options',
10
+ renderIfLoading = false,
11
+ }) {
12
+ const { isLoading, isError, refetch } = queryResult;
13
+
14
+ if (isError) {
15
+ return (
16
+ <RetryAlert onRetry={refetch} sx={{ alignItems: 'center' }}>
17
+ {errorMessage}
18
+ </RetryAlert>
19
+ );
20
+ }
21
+
22
+ if (isLoading && !renderIfLoading) {
23
+ return (
24
+ <Alert severity="info" icon={<CircularProgress color="inherit" size="1em" />}>
25
+ {loadingMessage}
26
+ </Alert>
27
+ );
28
+ }
29
+
30
+ return children;
31
+ }
@@ -1,4 +1,4 @@
1
- import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@mui/material';
1
+ import { Alert, Button, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle } from '@mui/material';
2
2
  import React from 'react';
3
3
  import { batch, useDispatch, useSelector, useStore } from 'react-redux';
4
4
  import useDatabase from '../../hooks/useDatabase';
@@ -32,7 +32,64 @@ function SubmitToDatabaseDialog({ id, dialogOpen, setDialogOpen, resourceType })
32
32
  setIsSubmitting(false);
33
33
  };
34
34
 
35
- if (hasUnsavedIntermediates && !disclaimerAccepted) {
35
+ const handleSubmit = async (event) => {
36
+ event.preventDefault();
37
+ if (isSubmitting) {
38
+ return;
39
+ }
40
+ // This should never happen
41
+ if (!database.isSubmissionDataValid(submissionData)) {
42
+ setErrorMessage('Submission data is invalid');
43
+ return;
44
+ }
45
+ try {
46
+ setIsSubmitting(true);
47
+ if (resourceType === 'primer') {
48
+ const oldPrimer = store.getState().cloning.primers.find((p) => p.id === id);
49
+ const primerDatabaseId = await database.submitPrimerToDatabase({ submissionData, primer: oldPrimer });
50
+ const newPrimer = { ...oldPrimer, database_id: primerDatabaseId };
51
+ batch(() => {
52
+ dispatch(cloningActions.editPrimer(newPrimer));
53
+ dispatch(cloningActions.addAlert({
54
+ message: 'Primer created successfully',
55
+ severity: 'success',
56
+ }));
57
+ });
58
+ } else if (resourceType === 'sequence') {
59
+ const substate = getSubState(store.getState(), id, true);
60
+ let databaseId;
61
+ let primerMappings;
62
+ let sequenceMappings;
63
+ try {
64
+ ({ databaseId, primerMappings = [], sequenceMappings = [] } = await database.submitSequenceToDatabase({ submissionData, substate, id }));
65
+ } catch (error) {
66
+ console.error(error);
67
+ setErrorMessage(error.message);
68
+ setIsSubmitting(false);
69
+ return;
70
+ }
71
+ batch(() => {
72
+ primerMappings.forEach((mapping) => dispatch(cloningActions.addDatabaseIdToPrimer(mapping)));
73
+ dispatch(cloningActions.addDatabaseIdToSequence({ databaseId, id }));
74
+ sequenceMappings.forEach(({databaseId, localId}) => dispatch(cloningActions.addDatabaseIdToSequence({ databaseId, id: localId })));
75
+ dispatch(cloningActions.addAlert({
76
+ message: 'Sequence created successfully',
77
+ severity: 'success',
78
+ }));
79
+ });
80
+ }
81
+ } catch (error) {
82
+ console.error(error);
83
+ setErrorMessage(error.message);
84
+ setIsSubmitting(false);
85
+ return;
86
+ }
87
+ setDialogOpen(false);
88
+ setIsSubmitting(false);
89
+ setErrorMessage('');
90
+ }
91
+
92
+ if (!database.omitUnsavedIntermediatesDisclaimer && hasUnsavedIntermediates && !disclaimerAccepted) {
36
93
  return (
37
94
  <Dialog open={dialogOpen} onClose={handleClose} sx={{ textAlign: 'center' }}>
38
95
  <DialogTitle>Unsaved Intermediates</DialogTitle>
@@ -59,71 +116,19 @@ function SubmitToDatabaseDialog({ id, dialogOpen, setDialogOpen, resourceType })
59
116
  PaperProps={{
60
117
  component: 'form',
61
118
  sx: { width: '40%' },
62
- onSubmit: async (event) => {
63
- event.preventDefault();
64
- if (isSubmitting) {
65
- return;
66
- }
67
- // This should never happen
68
- if (!database.isSubmissionDataValid(submissionData)) {
69
- setErrorMessage('Submission data is invalid');
70
- return;
71
- }
72
- try {
73
- setIsSubmitting(true);
74
- if (resourceType === 'primer') {
75
- const oldPrimer = store.getState().cloning.primers.find((p) => p.id === id);
76
- const primerDatabaseId = await database.submitPrimerToDatabase({ submissionData, primer: oldPrimer });
77
- const newPrimer = { ...oldPrimer, database_id: primerDatabaseId };
78
- batch(() => {
79
- dispatch(cloningActions.editPrimer(newPrimer));
80
- dispatch(cloningActions.addAlert({
81
- message: 'Primer created successfully',
82
- severity: 'success',
83
- }));
84
- });
85
- } else if (resourceType === 'sequence') {
86
- const substate = getSubState(store.getState(), id, true);
87
- let databaseId;
88
- let primerMappings;
89
- try {
90
- ({ databaseId, primerMappings } = await database.submitSequenceToDatabase({ submissionData, substate, id }));
91
- } catch (error) {
92
- console.error(error);
93
- setErrorMessage(error.message);
94
- setIsSubmitting(false);
95
- return;
96
- }
97
- batch(() => {
98
- primerMappings.forEach((mapping) => dispatch(cloningActions.addDatabaseIdToPrimer(mapping)));
99
- dispatch(cloningActions.addDatabaseIdToSequence({ databaseId, id }));
100
- dispatch(cloningActions.addAlert({
101
- message: 'Sequence created successfully',
102
- severity: 'success',
103
- }));
104
- });
105
- }
106
- } catch (error) {
107
- console.error(error);
108
- setErrorMessage(error.message);
109
- setIsSubmitting(false);
110
- return;
111
- }
112
- setIsSubmitting(false);
113
- setDialogOpen(false);
114
- setErrorMessage('');
115
- },
119
+ onSubmit: handleSubmit,
116
120
  }}
121
+ data-testid="submit-to-database-component"
117
122
  >
118
123
  <DialogTitle>{`Save ${resourceType} to ${database.name}`}</DialogTitle>
119
124
  <DialogContent>
120
125
  <database.SubmitToDatabaseComponent id={id} submissionData={submissionData} setSubmissionData={setSubmissionData} resourceType={resourceType} />
121
- {resourceType === 'sequence' && <database.PrimersNotInDatabaseComponent id={id} submissionData={submissionData} setSubmissionData={setSubmissionData} />}
126
+ {resourceType === 'sequence' && !isSubmitting && <database.PrimersNotInDatabaseComponent id={id} submissionData={submissionData} setSubmissionData={setSubmissionData} />}
122
127
  {errorMessage && <Alert sx={{ marginTop: 2 }} severity="error">{errorMessage}</Alert>}
123
128
  </DialogContent>
124
129
  <DialogActions>
125
130
  <Button onClick={handleClose}>Cancel</Button>
126
- <Button type="submit" disabled={isSubmitting || submissionData === null || !database.isSubmissionDataValid(submissionData)}>Submit</Button>
131
+ <Button type="submit" disabled={isSubmitting || submissionData === null || !database.isSubmissionDataValid(submissionData)}>{isSubmitting ? <CircularProgress /> : 'Submit'}</Button>
127
132
  </DialogActions>
128
133
  </Dialog>
129
134
 
@@ -0,0 +1,93 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import { useQuery } from '@tanstack/react-query';
3
+ import { TextField } from '@mui/material';
4
+
5
+ /**
6
+ * Text field with validation driven by a React Query. Error/loading state come from the query.
7
+ * Similar to TextFieldApiValidated but takes a query (like QuerySelect vs PostRequestSelect).
8
+ *
9
+ * @param {string} label - Label for the TextField
10
+ * @param {string} placeholder - Placeholder text
11
+ * @param {Function} getQuery - (value) => ({ queryKey, queryFn, ... }) — returns a query config for the given value (use value in queryKey so each input has its own cache).
12
+ * @param {Function} onChange - Callback when validation succeeds; receives (queryData). Called with null when value is empty or validation fails.
13
+ * @param {string} value - Controlled value (optional)
14
+ * @param {number} debounceDelay - Delay in ms before running the query (default: 500)
15
+ * @param {string} successMessage - Optional message to show when validation succeeds (default: ' ')
16
+ * @param {string} validatingMessage - Message while the query is loading (default: 'Validating...')
17
+ */
18
+ export default function TextFieldQueryValidated({
19
+ label,
20
+ placeholder,
21
+ getQuery,
22
+ onChange,
23
+ debounceDelay = 500,
24
+ successMessage = ' ',
25
+ validatingMessage = 'Validating...',
26
+ defaultValue = '',
27
+ ...rest
28
+ }) {
29
+ const [localValue, setLocalValue] = useState(defaultValue);
30
+ const [debouncedValue, setDebouncedValue] = useState(defaultValue);
31
+
32
+ useEffect(() => {
33
+ setLocalValue(defaultValue);
34
+ setDebouncedValue(defaultValue);
35
+ }, [defaultValue]);
36
+
37
+ useEffect(() => {
38
+ const id = setTimeout(() => setDebouncedValue(localValue), debounceDelay);
39
+ return () => clearTimeout(id);
40
+ }, [localValue, debounceDelay]);
41
+
42
+ const queryConfig = React.useMemo(
43
+ () => getQuery(debouncedValue),
44
+ [getQuery, debouncedValue],
45
+ );
46
+
47
+ const { data: validationError, isLoading, error, isSuccess } = useQuery({
48
+ ...queryConfig,
49
+ enabled: debouncedValue.length > 0 && (queryConfig.enabled !== false),
50
+ });
51
+
52
+
53
+ useEffect(() => {
54
+ if (debouncedValue.length === 0) {
55
+ onChange('');
56
+ return;
57
+ }
58
+ if (isSuccess && !validationError) {
59
+ onChange(debouncedValue);
60
+ }
61
+ if (error || validationError) {
62
+ onChange('');
63
+ }
64
+ }, [debouncedValue, isSuccess, validationError, error, onChange]);
65
+
66
+ const handleChange = (e) => {
67
+ onChange('');
68
+ setLocalValue(e.target.value);
69
+ };
70
+
71
+ let helperText = ' ';
72
+ if (isLoading) {
73
+ helperText = validatingMessage;
74
+ } else if (validationError) {
75
+ helperText = validationError;
76
+ } else if (error) {
77
+ helperText = error.message;
78
+ } else if (isSuccess && !validationError) {
79
+ helperText = successMessage;
80
+ }
81
+
82
+ return (
83
+ <TextField
84
+ label={label}
85
+ placeholder={placeholder}
86
+ value={localValue}
87
+ onChange={handleChange}
88
+ error={!!error || !!validationError}
89
+ helperText={helperText}
90
+ {...rest}
91
+ />
92
+ );
93
+ }
@@ -3,3 +3,7 @@ export { default as MainAppBar } from './navigation/MainAppBar';
3
3
  export { default as EditTextDialog } from './form/EditTextDialog';
4
4
  export { default as useUrlParamsLoader } from '../hooks/useUrlParamsLoader';
5
5
  export { default as useInitializeApp } from '../hooks/useInitializeApp';
6
+ export { default as QuerySelect } from './form/QuerySelect';
7
+ export { default as QueryStatusWrapper } from './form/QueryStatusWrapper';
8
+ export { default as TextFieldQueryValidated } from './form/TextFieldQueryValidated';
9
+ export { default as useDebouncedSearchQuery } from '../hooks/useDebouncedSearchQuery';
@@ -16,7 +16,7 @@ function FeedbackDialog({ open, setOpen }) {
16
16
  <li style={{ marginBottom: 10 }}>
17
17
  If you use GitHub,
18
18
  {' '}
19
- <a href="https://github.com/manulera/OpenCloning/issues/new" target="_blank" rel="noopener noreferrer">create an issue</a>
19
+ <a href="https://github.com/OpenCloning/OpenCloning/issues/new" target="_blank" rel="noopener noreferrer">create an issue</a>
20
20
  </li>
21
21
  <li>
22
22
  Otherwise, send an email to
@@ -10,7 +10,7 @@ import SelectTemplateDialog from './SelectTemplateDialog';
10
10
  import FeedbackDialog from './FeedbackDialog';
11
11
  import { cloningActions } from '@opencloning/store/cloning';
12
12
  import VersionDialog from './VersionDialog';
13
- import useAlerts from '../../hooks/useAlerts';
13
+ import useCloningAlerts from '../../hooks/useCloningAlerts';
14
14
  import DownloadCloningStrategyDialog from '../DownloadCloningStrategyDialog';
15
15
  import LoadCloningHistoryWrapper from '../LoadCloningHistoryWrapper';
16
16
  import useValidateState from '../../hooks/useValidateState';
@@ -31,7 +31,7 @@ function MainAppBar() {
31
31
  const httpClient = useHttpClient();
32
32
 
33
33
  const dispatch = useDispatch();
34
- const { addAlert } = useAlerts();
34
+ const { addAlert } = useCloningAlerts();
35
35
  const validateState = useValidateState();
36
36
 
37
37
  // Hidden input field, used to load files.
@@ -78,8 +78,8 @@ function MainAppBar() {
78
78
  // TODO: turn these into <a> elements.
79
79
  const helpMenu = [
80
80
  { display: 'Newsletter', onClick: () => window.open('https://eepurl.com/h9-n71') },
81
- { display: 'About the project', onClick: () => window.open('https://github.com/manulera/OpenCloning') },
82
- { display: 'GitHub repository', onClick: () => window.open('https://github.com/manulera/OpenCloning_frontend') },
81
+ { display: 'About the project', onClick: () => window.open('https://github.com/OpenCloning/OpenCloning') },
82
+ { display: 'GitHub repository', onClick: () => window.open('https://github.com/OpenCloning/OpenCloning_frontend') },
83
83
  { display: 'Demo videos', onClick: () => window.open('https://www.youtube.com/watch?v=n0hedzvpW88&list=PLpv3x-ensLZkJToD2E6ejefADmHcUPYSJ&index=1') },
84
84
  { display: 'App version', onClick: () => setOpenVersionDialog(true) },
85
85
  ];
@@ -126,7 +126,7 @@ function MainAppBar() {
126
126
  <Box sx={{ display: { xs: 'none', md: 'flex' } }}>
127
127
  <Button
128
128
  className="github-corner"
129
- onClick={() => window.open('https://github.com/manulera/OpenCloning')}
129
+ onClick={() => window.open('https://github.com/OpenCloning/OpenCloning')}
130
130
  aria-label="GitHub repository"
131
131
  >
132
132
  <GithubCornerRight />
@@ -1,14 +1,14 @@
1
1
  import React, { useState } from 'react';
2
2
  import { shallowEqual, useSelector } from 'react-redux';
3
3
  import { Button, Tooltip, Modal, Box } from '@mui/material';
4
- import useAlerts from '../../../hooks/useAlerts';
4
+ import useCloningAlerts from '../../../hooks/useCloningAlerts';
5
5
  import './styles.css';
6
6
 
7
7
  import { primersFromTextFile } from '@opencloning/utils/fileParsers';
8
8
  import PrimersImportTable from './ImportPrimersTable';
9
9
 
10
10
  function ImportPrimersButton({ addPrimer }) {
11
- const { addAlert } = useAlerts();
11
+ const { addAlert } = useCloningAlerts();
12
12
  const existingNames = useSelector((state) => state.cloning.primers.map((p) => p.name), shallowEqual);
13
13
 
14
14
  // Ref to the hidden file input element
@@ -1,12 +1,14 @@
1
1
  import React, { useState } from 'react';
2
- import { shallowEqual, useSelector } from 'react-redux';
2
+ import { useSelector } from 'react-redux';
3
3
  import { Alert, Button, TextField } from '@mui/material';
4
4
 
5
5
  import useDatabase from '../../../hooks/useDatabase';
6
6
  import { stringIsNotDNA } from '@opencloning/store/cloning_utils';
7
+ import { isEqual } from 'lodash-es';
7
8
 
8
9
  function PrimerDatabaseImportForm({ submitPrimer, cancelForm }) {
9
- const existingNames = useSelector((state) => state.cloning.primers.map((p) => p.name), shallowEqual);
10
+ const existingNames = useSelector((state) => state.cloning.primers.map((p) => p.name), isEqual);
11
+ const existingDatabaseIds = useSelector((state) => state.cloning.primers.map((p) => p.database_id).filter(Boolean), isEqual);
10
12
  const [primer, setPrimer] = useState(null);
11
13
  const [error, setError] = useState('');
12
14
  const database = useDatabase();
@@ -44,6 +46,7 @@ function PrimerDatabaseImportForm({ submitPrimer, cancelForm }) {
44
46
  primer={primer}
45
47
  setPrimer={setPrimer}
46
48
  setError={setError}
49
+ existingDatabaseIds={existingDatabaseIds}
47
50
  />
48
51
  </div>
49
52
  {primer && (
@@ -87,7 +90,7 @@ function PrimerDatabaseImportForm({ submitPrimer, cancelForm }) {
87
90
  variant="contained"
88
91
  color="primary"
89
92
  type="submit"
90
- disabled={!primer || error}
93
+ disabled={!primer || Boolean(error)}
91
94
  >
92
95
  Import Primer
93
96
  </Button>
@@ -17,6 +17,9 @@ function DatabaseMessage({ source }) {
17
17
  const handleClose = React.useCallback(() => setLoadingHistory(false), [setLoadingHistory]);
18
18
  const [historyFileError, setHistoryFileError] = React.useState(null);
19
19
  const { loadDatabaseFile } = useLoadDatabaseFile({ source, setHistoryFileError });
20
+ if (!database) {
21
+ return <div>Imported from unknown database</div>;
22
+ }
20
23
  const { LoadHistoryComponent } = database;
21
24
  return (
22
25
  <>
@@ -0,0 +1,95 @@
1
+ import React from 'react';
2
+ import MultipleOutputsSelector from './MultipleOutputsSelector';
3
+
4
+ const mockSources = [
5
+ { id: 1, label: 'source-1' },
6
+ { id: 2, label: 'source-2' },
7
+ { id: 3, label: 'source-3' },
8
+ ];
9
+
10
+ const mockSequences = [
11
+ { id: 'seq-1' },
12
+ { id: 'seq-2' },
13
+ { id: 'seq-3' },
14
+ ];
15
+
16
+ const mockable = {
17
+ SubSequenceDisplayerComponent: ({ source, sourceId }) => <div>{`subsequence:${source.id}:${sourceId}`}</div>,
18
+ AssemblyPlanDisplayerComponent: ({ source }) => <div>{`assembly:${source.id}`}</div>,
19
+ CircularOrLinearViewComponent: ({ sequenceData }) => <div>{`view:${sequenceData.id}`}</div>,
20
+ OverhangsDisplayComponent: ({ sequence }) => <div>{`overhangs:${sequence.id}`}</div>,
21
+ convertSequence: (sequence) => sequence,
22
+ };
23
+
24
+ function TestHarness({ onFragmentChosen }) {
25
+ const [outputs, setOutputs] = React.useState({
26
+ sources: mockSources,
27
+ sequences: mockSequences,
28
+ });
29
+
30
+ return (
31
+ <>
32
+ <button type="button" onClick={() => setOutputs({
33
+ sources: mockSources.slice(0, 2),
34
+ sequences: mockSequences.slice(0, 2),
35
+ })}
36
+ >
37
+ Shrink outputs
38
+ </button>
39
+ <MultipleOutputsSelector
40
+ sources={outputs.sources}
41
+ sequences={outputs.sequences}
42
+ sourceId={9}
43
+ onFragmentChosen={onFragmentChosen}
44
+ mockable={mockable}
45
+ convertSequence={(sequence) => sequence}
46
+ />
47
+ </>
48
+ );
49
+ }
50
+
51
+ describe('<MultipleOutputsSelector />', () => {
52
+ it('renders nothing when there are no outputs', () => {
53
+ cy.mount(
54
+ <MultipleOutputsSelector
55
+ sources={[]}
56
+ sequences={[]}
57
+ sourceId={9}
58
+ onFragmentChosen={cy.spy()}
59
+ mockable={mockable}
60
+ convertSequence={(sequence) => sequence}
61
+ />
62
+ );
63
+
64
+ cy.get('.multiple-output-selector').should('not.exist');
65
+ });
66
+
67
+ it('clamps the selected output when the outputs shrink', () => {
68
+ const onFragmentChosen = cy.spy().as('onFragmentChosen');
69
+ cy.mount(<TestHarness onFragmentChosen={onFragmentChosen} />);
70
+
71
+ cy.contains('1 / 3').should('exist');
72
+ cy.contains('subsequence:1:9').should('exist');
73
+ cy.contains('view:seq-1').should('exist');
74
+
75
+ cy.get('[data-testid="ArrowForwardIcon"]').closest('button').click();
76
+ cy.contains('2 / 3').should('exist');
77
+ cy.get('[data-testid="ArrowForwardIcon"]').closest('button').click();
78
+ cy.contains('3 / 3').should('exist');
79
+ cy.contains('subsequence:3:9').should('exist');
80
+ cy.contains('assembly:3').should('exist');
81
+ cy.contains('view:seq-3').should('exist');
82
+ cy.contains('overhangs:seq-3').should('exist');
83
+
84
+ cy.contains('Shrink outputs').click();
85
+
86
+ cy.contains('1 / 2').should('exist');
87
+ cy.contains('subsequence:1:9').should('exist');
88
+ cy.contains('assembly:1').should('exist');
89
+ cy.contains('view:seq-1').should('exist');
90
+ cy.contains('overhangs:seq-1').should('exist');
91
+
92
+ cy.get('button').contains('Choose product').click();
93
+ cy.get('@onFragmentChosen').should('have.been.calledOnceWith', 0);
94
+ });
95
+ });