@opencloning/ui 1.9.0 → 1.9.2

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 CHANGED
@@ -1,5 +1,27 @@
1
1
  # @opencloning/ui
2
2
 
3
+ ## 1.9.2
4
+
5
+ ### Patch Changes
6
+
7
+ - [#734](https://github.com/OpenCloning/OpenCloning_frontend/pull/734) [`06bb1a5`](https://github.com/OpenCloning/OpenCloning_frontend/commit/06bb1a513519f598e9ec3b5eec73544e2ed3958e) Thanks [@manulera](https://github.com/manulera)! - Add duplicate feature option to the database sequence viewer
8
+
9
+ - [#736](https://github.com/OpenCloning/OpenCloning_frontend/pull/736) [`bc045f9`](https://github.com/OpenCloning/OpenCloning_frontend/commit/bc045f9e74b4d5b84250dcb5a77b93de99e4d684) Thanks [@manulera](https://github.com/manulera)! - \* Unify load paths under single hook
10
+ - Remove warnings when adding db files to design tab
11
+ - Updated dependencies [[`bc045f9`](https://github.com/OpenCloning/OpenCloning_frontend/commit/bc045f9e74b4d5b84250dcb5a77b93de99e4d684)]:
12
+ - @opencloning/store@1.9.2
13
+ - @opencloning/utils@1.9.2
14
+
15
+ ## 1.9.1
16
+
17
+ ### Patch Changes
18
+
19
+ - [#715](https://github.com/OpenCloning/OpenCloning_frontend/pull/715) [`e4d9515`](https://github.com/OpenCloning/OpenCloning_frontend/commit/e4d9515a7f6fcad09e5c196f3cb090f70fe3a156) Thanks [@manulera](https://github.com/manulera)! - Allow to copy features when editing annotation
20
+
21
+ - Updated dependencies []:
22
+ - @opencloning/store@1.9.1
23
+ - @opencloning/utils@1.9.1
24
+
3
25
  ## 1.9.0
4
26
 
5
27
  ### Minor Changes
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "license": "MIT",
3
3
  "name": "@opencloning/ui",
4
- "version": "1.9.0",
4
+ "version": "1.9.2",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "scripts": {
@@ -25,6 +25,8 @@
25
25
  "./hooks/useAlerts": "./src/hooks/useAlerts.js",
26
26
  "./hooks/useCloningAlerts": "./src/hooks/useCloningAlerts.js",
27
27
  "./hooks/useSequencingAlignment": "./src/hooks/useSequencingAlignment.js",
28
+ "./hooks/useBackendRoute": "./src/hooks/useBackendRoute.js",
29
+ "./hooks/useHttpClient": "./src/hooks/useHttpClient.js",
28
30
  "./components/SequenceViewer": "./src/components/SequenceViewer.jsx",
29
31
  "./standalone": "./src/StandAloneOpenCloning.js"
30
32
  },
@@ -39,8 +41,8 @@
39
41
  "@mui/icons-material": "^5.18.0",
40
42
  "@mui/material": "^5.18.0",
41
43
  "@mui/x-data-grid": "^8.28.5",
42
- "@opencloning/store": "1.9.0",
43
- "@opencloning/utils": "1.9.0",
44
+ "@opencloning/store": "1.9.2",
45
+ "@opencloning/utils": "1.9.2",
44
46
  "@teselagen/bio-parsers": "^0.4.38",
45
47
  "@teselagen/ove": "^0.8.42",
46
48
  "@teselagen/range-utils": "^0.3.20",
@@ -2,17 +2,13 @@ import React from 'react';
2
2
  import { batch, useDispatch, useStore } from 'react-redux';
3
3
  import useCloningAlerts from '../hooks/useCloningAlerts';
4
4
  import useBackendRoute from '../hooks/useBackendRoute';
5
- import useValidateState from '../hooks/useValidateState';
5
+ import useCloningHistoryLoader from '../hooks/useCloningHistoryLoader';
6
+ import { getEmptyPlaceholderSource } from '@opencloning/store/cloning_utils';
6
7
  import { cloningActions } from '@opencloning/store/cloning';
7
- import { mergeStates } from '@opencloning/utils/network';
8
- import { loadFilesToSessionStorage, loadHistoryFile, updateVerificationFileNames } from '@opencloning/utils/readNwrite';
9
8
  import HistoryLoadedDialog from './HistoryLoadedDialog';
10
9
  import useHttpClient from '../hooks/useHttpClient';
11
- import { getVerificationFileName } from '@opencloning/utils/readNwrite';
12
- import { isEqual } from 'lodash-es';
13
- import useSnapgeneHistoryEndpoint from '../hooks/useSnapgeneHistoryEndpoint';
14
10
 
15
- const { setState: setCloningState, deleteSourceAndItsChildren, addSourceAndItsOutputSequence } = cloningActions;
11
+ const { deleteSourceAndItsChildren, addSourceAndItsOutputSequence } = cloningActions;
16
12
 
17
13
  async function processSequenceFiles(files, backendRoute, httpClient) {
18
14
  const allSources = [];
@@ -63,9 +59,8 @@ function LoadCloningHistoryWrapper({ fileList, clearFiles, children }) {
63
59
  const dispatch = useDispatch();
64
60
  const backendRoute = useBackendRoute();
65
61
  const store = useStore();
66
- const validateState = useValidateState();
67
62
  const httpClient = useHttpClient();
68
- const { loadSnapgeneHistory } = useSnapgeneHistoryEndpoint();
63
+ const { loadHistoryFromFile, loadSnapgeneHistory } = useCloningHistoryLoader();
69
64
  const [fileLoaderFunctions, setFileLoaderFunctions] = React.useState(null);
70
65
 
71
66
  React.useEffect(() => {
@@ -81,34 +76,11 @@ function LoadCloningHistoryWrapper({ fileList, clearFiles, children }) {
81
76
  const isJsonFile = files.length === 1 && files[0].name.endsWith('.json');
82
77
  // If the file is a zip or json file, load the history file
83
78
  if (isZipFile || isJsonFile) {
84
- let cloningStrategy;
85
- let verificationFiles;
86
- try {
87
- ({ cloningStrategy, verificationFiles } = await loadHistoryFile(files[0]));
88
- } catch (e) {
89
- console.error(e);
90
- addAlert({ message: e.message, severity: 'error' });
79
+ const prepared = await loadHistoryFromFile(files[0]);
80
+ if (!prepared) {
91
81
  return;
92
82
  }
93
83
 
94
- const validatedState = await validateState(cloningStrategy);
95
- // Update the verificationFiles names if needed
96
- const updatedVerificationFiles = updateVerificationFileNames(verificationFiles, cloningStrategy.files, validatedState.files);
97
-
98
- const updateState = async (newState, idShift) => {
99
- dispatch(setCloningState(newState));
100
- await loadFilesToSessionStorage(updatedVerificationFiles, idShift);
101
- };
102
-
103
- const replaceState = async () => {
104
- await updateState(validatedState, 0);
105
- };
106
-
107
- const addState = async () => {
108
- const { mergedState, idShift } = mergeStates(validatedState, cloningState);
109
- await updateState(mergedState, idShift);
110
- };
111
-
112
84
  const errorWrapper = (fn) => async () => {
113
85
  try {
114
86
  await fn();
@@ -117,11 +89,15 @@ function LoadCloningHistoryWrapper({ fileList, clearFiles, children }) {
117
89
  addAlert({ message: e.message, severity: 'error' });
118
90
  }
119
91
  };
120
- // If there are no sequences in the cloning state, load the history file
92
+ // If there are no sequences in the cloning state, load the history file
121
93
  if (cloningState.sequences.length === 0) {
122
- errorWrapper(replaceState)();
94
+ errorWrapper(prepared.replace)();
123
95
  } else {
124
- setFileLoaderFunctions({ addState: errorWrapper(addState), replaceState: errorWrapper(replaceState), clear: () => setFileLoaderFunctions(null) });
96
+ setFileLoaderFunctions({
97
+ addState: errorWrapper(prepared.merge),
98
+ replaceState: errorWrapper(prepared.replace),
99
+ clear: () => setFileLoaderFunctions(null),
100
+ });
125
101
  }
126
102
  // If there are sequences in the cloning state, give the option to merge or replace
127
103
 
@@ -144,8 +120,9 @@ function LoadCloningHistoryWrapper({ fileList, clearFiles, children }) {
144
120
  const { sources, sequences, warnings } = await processSequenceFiles(files, backendRoute, httpClient);
145
121
  batch(() => {
146
122
  // If there is only one source and it is empty, delete it
147
- if (cloningState.sources.length === 1 && cloningState.sources[0].type === null) {
148
- dispatch(deleteSourceAndItsChildren(cloningState.sources[0].id));
123
+ const emptyPlaceholder = getEmptyPlaceholderSource(cloningState.sources);
124
+ if (emptyPlaceholder) {
125
+ dispatch(deleteSourceAndItsChildren(emptyPlaceholder.id));
149
126
  }
150
127
  for (let i = 0; i < sources.length; i += 1) {
151
128
  dispatch(addSourceAndItsOutputSequence({ source: sources[i], sequence: sequences[i] }));
@@ -73,6 +73,18 @@ function MainSequenceEditor() {
73
73
  "--",
74
74
  "editFeature",
75
75
  "deleteFeature",
76
+ {
77
+ text: "Duplicate Feature",
78
+ disabled: props.readOnly,
79
+ onClick: () => {
80
+ const { id, ...rest } = annotation;
81
+ props.upsertFeature({
82
+ ...rest,
83
+ // omit id → upsertDeleteActionGenerator assigns a new shortid
84
+ name: annotation.name ? `${annotation.name} (copy)` : undefined,
85
+ });
86
+ },
87
+ },
76
88
  "showRemoveDuplicatesDialogFeatures",
77
89
  "--",
78
90
  "toggleCdsFeatureTranslations",
@@ -48,6 +48,18 @@ function featureRightClickedOverride(items, { annotation }, props) {
48
48
  "--",
49
49
  "editFeature",
50
50
  "deleteFeature",
51
+ {
52
+ text: "Duplicate Feature",
53
+ disabled: props.readOnly,
54
+ onClick: () => {
55
+ const { id, ...rest } = annotation;
56
+ props.upsertFeature({
57
+ ...rest,
58
+ // omit id → upsertDeleteActionGenerator assigns a new shortid
59
+ name: annotation.name ? `${annotation.name} (copy)` : undefined,
60
+ });
61
+ },
62
+ },
51
63
  "showRemoveDuplicatesDialogFeatures",
52
64
  "--",
53
65
  "toggleCdsFeatureTranslations",
@@ -10,6 +10,7 @@ import { TextField } from '@mui/material';
10
10
  * @param {string} placeholder - Placeholder text
11
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
12
  * @param {Function} onChange - Callback when validation succeeds; receives (queryData). Called with null when value is empty or validation fails.
13
+ * @param {Function} onValidationStateChange - Optional callback with ({ isChecking, isValid, hasValue }) when validation lifecycle changes.
13
14
  * @param {string} value - Controlled value (optional)
14
15
  * @param {number} debounceDelay - Delay in ms before running the query (default: 500)
15
16
  * @param {string} successMessage - Optional message to show when validation succeeds (default: ' ')
@@ -20,6 +21,7 @@ export default function TextFieldQueryValidated({
20
21
  placeholder,
21
22
  getQuery,
22
23
  onChange,
24
+ onValidationStateChange,
23
25
  debounceDelay = 500,
24
26
  successMessage = ' ',
25
27
  validatingMessage = 'Validating...',
@@ -44,24 +46,40 @@ export default function TextFieldQueryValidated({
44
46
  [getQuery, debouncedValue],
45
47
  );
46
48
 
47
- const { data: validationError, isLoading, error, isSuccess } = useQuery({
49
+ const { data: validationError, isLoading, isFetching, error, isSuccess } = useQuery({
48
50
  ...queryConfig,
49
51
  enabled: debouncedValue.length > 0 && (queryConfig.enabled !== false),
50
52
  });
51
53
 
54
+ const isDebouncing = localValue !== debouncedValue;
55
+ const isChecking =
56
+ localValue.length > 0 &&
57
+ (isDebouncing || (debouncedValue.length > 0 && (isLoading || isFetching)));
58
+ const isValid =
59
+ localValue.length > 0 &&
60
+ localValue === debouncedValue &&
61
+ isSuccess &&
62
+ !validationError &&
63
+ !error;
64
+
65
+ const hasValue = localValue.length > 0;
66
+
67
+ useEffect(() => {
68
+ onValidationStateChange?.({ isChecking, isValid, hasValue });
69
+ }, [isChecking, isValid, hasValue, onValidationStateChange]);
52
70
 
53
71
  useEffect(() => {
54
72
  if (debouncedValue.length === 0) {
55
73
  onChange('');
56
74
  return;
57
75
  }
58
- if (isSuccess && !validationError) {
76
+ if (localValue === debouncedValue && isSuccess && !validationError) {
59
77
  onChange(debouncedValue);
60
78
  }
61
79
  if (error || validationError) {
62
80
  onChange('');
63
81
  }
64
- }, [debouncedValue, isSuccess, validationError, error, onChange]);
82
+ }, [localValue, debouncedValue, isSuccess, validationError, error, onChange]);
65
83
 
66
84
  const handleChange = (e) => {
67
85
  onChange('');
@@ -69,7 +87,7 @@ export default function TextFieldQueryValidated({
69
87
  };
70
88
 
71
89
  let helperText = ' ';
72
- if (isLoading) {
90
+ if (isChecking) {
73
91
  helperText = validatingMessage;
74
92
  } else if (validationError) {
75
93
  helperText = validationError;
@@ -4,7 +4,7 @@ import { Menu as MenuIcon } from '@mui/icons-material';
4
4
  import './MainAppBar.css';
5
5
  import { useDispatch } from 'react-redux';
6
6
  import ButtonWithMenu from './ButtonWithMenu';
7
- import { downloadCloningStrategyAsSvg, formatTemplate, loadHistoryFile, loadFilesToSessionStorage } from '@opencloning/utils/readNwrite';
7
+ import { downloadCloningStrategyAsSvg, formatTemplate } from '@opencloning/utils/readNwrite';
8
8
  import SelectExampleDialog from './SelectExampleDialog';
9
9
  import SelectTemplateDialog from './SelectTemplateDialog';
10
10
  import FeedbackDialog from './FeedbackDialog';
@@ -13,11 +13,11 @@ import VersionDialog from './VersionDialog';
13
13
  import useCloningAlerts from '../../hooks/useCloningAlerts';
14
14
  import DownloadCloningStrategyDialog from '../DownloadCloningStrategyDialog';
15
15
  import LoadCloningHistoryWrapper from '../LoadCloningHistoryWrapper';
16
- import useValidateState from '../../hooks/useValidateState';
16
+ import useCloningHistoryLoader from '../../hooks/useCloningHistoryLoader';
17
17
  import GithubCornerRight from './GithubCornerRight';
18
18
  import useHttpClient from '../../hooks/useHttpClient';
19
19
 
20
- const { setCurrentTab, setState: setCloningState } = cloningActions;
20
+ const { setCurrentTab } = cloningActions;
21
21
 
22
22
  function MainAppBar() {
23
23
  const [openExampleDialog, setOpenExampleDialog] = React.useState(false);
@@ -32,7 +32,7 @@ function MainAppBar() {
32
32
 
33
33
  const dispatch = useDispatch();
34
34
  const { addAlert } = useCloningAlerts();
35
- const validateState = useValidateState();
35
+ const { applyCloningStrategy, loadHistoryFromFile } = useCloningHistoryLoader();
36
36
 
37
37
  // Hidden input field, used to load files.
38
38
  const fileInputRef = React.useRef(null);
@@ -52,26 +52,30 @@ function MainAppBar() {
52
52
  setOpenExampleDialog(false);
53
53
  setOpenTemplateDialog(false);
54
54
  if (url) {
55
- let data;
55
+ const fileName = url.split('/').pop();
56
+ let file;
56
57
  if (url.endsWith('.zip')) {
57
- // For zip files, get as blob and process with loadHistoryFile
58
58
  const { data: blob } = await httpClient.get(url, { responseType: 'blob' });
59
- const fileName = url.split('/').pop();
60
- // eslint-disable-next-line no-undef
61
- const file = new File([blob], fileName);
62
- const { cloningStrategy, verificationFiles } = await loadHistoryFile(file);
63
- data = await validateState(cloningStrategy);
64
- await loadFilesToSessionStorage(verificationFiles, 0);
59
+ file = new File([blob], fileName);
65
60
  } else {
66
- // For JSON files, get as JSON
67
- let { data: jsonData } = await httpClient.get(url);
68
- data = await validateState(jsonData);
61
+ const { data: jsonData } = await httpClient.get(url);
62
+ file = new File([JSON.stringify(jsonData)], fileName);
69
63
  }
64
+ const prepared = await loadHistoryFromFile(file, {
65
+ onError: (message) => addAlert({ message, severity: 'error' }),
66
+ });
67
+ if (!prepared) {
68
+ return;
69
+ }
70
+ let strategy = prepared.cloningStrategy;
70
71
  if (isTemplate) {
71
- data = formatTemplate(data, url);
72
+ strategy = formatTemplate(strategy, url);
72
73
  }
73
- dispatch(setCloningState(data));
74
- dispatch(setCurrentTab(0))
74
+ await applyCloningStrategy(strategy, {
75
+ verificationFiles: prepared.verificationFiles,
76
+ mode: 'replace',
77
+ });
78
+ dispatch(setCurrentTab(0));
75
79
  }
76
80
  };
77
81
 
@@ -1,15 +1,9 @@
1
1
  import React from 'react';
2
2
  import { FormHelperText, Alert, Checkbox, FormControl, FormControlLabel, InputLabel, MenuItem, Select, TextField } from '@mui/material';
3
- import { useDispatch, batch, useStore, useSelector } from 'react-redux';
3
+ import { useSelector } from 'react-redux';
4
4
  import SubmitButtonBackendAPI from '../form/SubmitButtonBackendAPI';
5
5
  import LabelWithTooltip from '../form/LabelWithTooltip';
6
- import { cloningActions } from '@opencloning/store/cloning';
7
- import { loadFilesToSessionStorage, loadHistoryFile, updateVerificationFileNames } from '@opencloning/utils/readNwrite';
8
- import useValidateState from '../../hooks/useValidateState';
9
- import { mergeStates, getGraftSequenceId, graftState } from '@opencloning/utils/network';
10
- import useSnapgeneHistoryEndpoint from '../../hooks/useSnapgeneHistoryEndpoint';
11
-
12
- const { deleteSourceAndItsChildren, restoreSource, setState: setCloningState } = cloningActions;
6
+ import useCloningHistoryLoader from '../../hooks/useCloningHistoryLoader';
13
7
 
14
8
  const fileFormatToExtension = {
15
9
  genbank: 'gb',
@@ -28,10 +22,7 @@ function SourceFile({ source, requestStatus, sendPostRequest }) {
28
22
  const hasOutput = useSelector((state) => state.cloning.sequences.some((s) => s.id === source.id));
29
23
  // Error message for json only
30
24
  const [alert, setAlert] = React.useState(null);
31
- const dispatch = useDispatch();
32
- const validateState = useValidateState();
33
- const store = useStore();
34
- const { loadSnapgeneHistory } = useSnapgeneHistoryEndpoint();
25
+ const { loadHistoryFromFile, loadSnapgeneHistory } = useCloningHistoryLoader();
35
26
  const onChange = async (event) => {
36
27
  setAlert(null);
37
28
  const files = Array.from(event.target.files);
@@ -39,6 +30,7 @@ function SourceFile({ source, requestStatus, sendPostRequest }) {
39
30
  return;
40
31
  }
41
32
  event.target.value = null;
33
+ const reportError = (message) => setAlert({ message, severity: 'error' });
42
34
  // If the file is a history file, we load it
43
35
  if (
44
36
  fileFormat === 'json' || fileFormat === 'zip'
@@ -50,54 +42,32 @@ function SourceFile({ source, requestStatus, sendPostRequest }) {
50
42
  type: fileFormat === 'json' ? 'application/json' : files[0].type,
51
43
  });
52
44
  }
53
- let cloningStrategy;
54
- let verificationFiles;
55
- try {
56
- ({ cloningStrategy, verificationFiles } = await loadHistoryFile(files[0]));
57
- } catch (e) {
58
- console.error(e);
59
- setAlert({ message: e.message, severity: 'error' });
45
+
46
+ const prepared = await loadHistoryFromFile(files[0], { onError: reportError });
47
+ if (!prepared) {
60
48
  return;
61
49
  }
62
50
 
63
- const validatedCloningStrategy = await validateState(cloningStrategy);
64
- // Update the verificationFiles names if needed
65
- const updatedVerificationFiles = updateVerificationFileNames(verificationFiles, cloningStrategy.files, validatedCloningStrategy.files);
66
-
67
- const canGraft = getGraftSequenceId(validatedCloningStrategy) !== null;
68
- const graft = hasOutput && canGraft;
69
-
70
- if (hasOutput && !canGraft) {
51
+ if (hasOutput && !prepared.canGraft) {
71
52
  setAlert({ message: 'Cannot graft cloning strategy as it does not converge on a single sequence, you can load it on a source without outputs', severity: 'error' });
72
53
  return;
73
54
  }
74
55
 
75
- batch(async () => {
76
- if (!graft) {
77
- // Replace the source with the new one
78
- dispatch(deleteSourceAndItsChildren(source.id));
79
- }
80
- try {
81
- const cloningState = store.getState().cloning;
82
- let mergedState;
83
- let idShift;
84
- if (graft) {
85
- ({ mergedState, idShift } = graftState(validatedCloningStrategy, cloningState, source.id));
86
- } else {
87
- ({ mergedState, idShift } = mergeStates(validatedCloningStrategy, cloningState));
88
- }
89
- dispatch(setCloningState(mergedState));
90
- await loadFilesToSessionStorage(updatedVerificationFiles, idShift);
91
- } catch (e) {
92
- setAlert({ message: e.message, severity: 'error' });
93
- dispatch(restoreSource({ ...source, type: 'UploadedFileSource' }));
56
+ const graft = hasOutput && prepared.canGraft;
57
+ try {
58
+ const applyOptions = { source, onError: reportError };
59
+ if (graft) {
60
+ await prepared.graft(applyOptions);
61
+ } else {
62
+ await prepared.merge(applyOptions);
94
63
  }
95
- });
64
+ } catch {
65
+ // Error already reported via onError; state restored in applyCloningStrategy
66
+ }
96
67
  return;
97
68
  }
98
69
  if (fileFormat === 'snapgene' || (fileFormat === '' && files[0].name.endsWith('.dna'))) {
99
- console.log('loading snapgene history');
100
- const success = await loadSnapgeneHistory(files[0], source.id);
70
+ const success = await loadSnapgeneHistory(files[0], { source });
101
71
  if (success) {
102
72
  return;
103
73
  }
@@ -1,2 +1,4 @@
1
1
  export { useConfig } from './useConfig.js';
2
2
  export { default as useServerStaticFiles, localFilesHttpClient } from './useServerStaticFiles.js';
3
+ export { default as useBackendRoute } from './useBackendRoute.js';
4
+ export { default as useHttpClient } from './useHttpClient.js';
@@ -0,0 +1,163 @@
1
+ import React from 'react';
2
+ import { batch, useDispatch, useStore } from 'react-redux';
3
+ import { cloningActions } from '@opencloning/store/cloning';
4
+ import { getEmptyPlaceholderSource } from '@opencloning/store/cloning_utils';
5
+ import { graftState, getGraftSequenceId, mergeStates } from '@opencloning/utils/network';
6
+ import {
7
+ loadFilesToSessionStorage,
8
+ parseHistoryFile,
9
+ updateVerificationFileNames,
10
+ } from '@opencloning/utils/readNwrite';
11
+ import useBackendRoute from './useBackendRoute';
12
+ import useCloningAlerts from './useCloningAlerts';
13
+ import useHttpClient from './useHttpClient';
14
+ import useValidateState from './useValidateState';
15
+
16
+ const {
17
+ setState: setCloningState,
18
+ deleteSourceAndItsChildren,
19
+ } = cloningActions;
20
+
21
+ function makeAlertHandler(addAlert, onError, severity = 'error') {
22
+ return onError || ((message) => addAlert({ message, severity }));
23
+ }
24
+
25
+ function shouldDeleteSourceBeforeApply(mode, source) {
26
+ return source != null && mode === 'merge';
27
+ }
28
+
29
+ export default function useCloningHistoryLoader() {
30
+ const dispatch = useDispatch();
31
+ const store = useStore();
32
+ const validateState = useValidateState();
33
+ const { addAlert } = useCloningAlerts();
34
+ const backendRoute = useBackendRoute();
35
+ const httpClient = useHttpClient();
36
+ const snapgeneUrl = backendRoute('read_snapgene_history');
37
+
38
+ const applyCloningStrategy = React.useCallback(async (cloningStrategy, options = {}) => {
39
+ const {
40
+ verificationFiles = [],
41
+ mode,
42
+ source = null,
43
+ onError,
44
+ } = options;
45
+
46
+ const reportError = makeAlertHandler(addAlert, onError);
47
+ const deleteSourceBeforeApply = shouldDeleteSourceBeforeApply(mode, source);
48
+ const sourceId = source?.id ?? null;
49
+
50
+ const prevState = store.getState().cloning;
51
+
52
+ try {
53
+ if (deleteSourceBeforeApply) {
54
+ dispatch(deleteSourceAndItsChildren(sourceId));
55
+ }
56
+
57
+ const cloningState = store.getState().cloning;
58
+ let newState;
59
+ let idShift = 0;
60
+
61
+ if (mode === 'replace') {
62
+ newState = cloningStrategy;
63
+ } else if (mode === 'merge') {
64
+ ({ mergedState: newState, idShift } = mergeStates(cloningStrategy, cloningState));
65
+ } else if (mode === 'graft') {
66
+ ({ mergedState: newState, idShift } = graftState(cloningStrategy, cloningState, sourceId));
67
+ } else {
68
+ throw new Error(`Unknown apply mode: ${mode}`);
69
+ }
70
+
71
+ batch(() => {
72
+ dispatch(setCloningState(newState));
73
+ });
74
+
75
+ await loadFilesToSessionStorage(verificationFiles, idShift);
76
+ } catch (e) {
77
+ console.error(e);
78
+ dispatch(setCloningState(prevState));
79
+ reportError(e.message);
80
+ throw e;
81
+ }
82
+ }, [addAlert, dispatch, store]);
83
+
84
+ const loadHistoryFromFile = React.useCallback(async (file, options = {}) => {
85
+ const { onError } = options;
86
+ const reportError = makeAlertHandler(addAlert, onError);
87
+
88
+ let cloningStrategy;
89
+ let verificationFiles;
90
+ try {
91
+ ({ cloningStrategy, verificationFiles } = await parseHistoryFile(file));
92
+ } catch (e) {
93
+ console.error(e);
94
+ reportError(e.message);
95
+ return null;
96
+ }
97
+
98
+ const validatedState = await validateState(cloningStrategy);
99
+ const updatedVerificationFiles = updateVerificationFileNames(
100
+ verificationFiles,
101
+ cloningStrategy.files,
102
+ validatedState.files,
103
+ );
104
+ const canGraft = getGraftSequenceId(validatedState) !== null;
105
+
106
+ const apply = (applyOptions) => applyCloningStrategy(validatedState, {
107
+ verificationFiles: updatedVerificationFiles,
108
+ validate: false,
109
+ ...applyOptions,
110
+ });
111
+
112
+ return {
113
+ cloningStrategy: validatedState,
114
+ verificationFiles: updatedVerificationFiles,
115
+ canGraft,
116
+ replace: (applyOptions = {}) => apply({ mode: 'replace', ...applyOptions }),
117
+ merge: (applyOptions = {}) => apply({ mode: 'merge', ...applyOptions }),
118
+ graft: (applyOptions = {}) => apply({ mode: 'graft', ...applyOptions }),
119
+ };
120
+ }, [addAlert, applyCloningStrategy, validateState]);
121
+
122
+ const loadSnapgeneHistory = React.useCallback(async (file, options = {}) => {
123
+ const { source = null, onError, onWarning } = options;
124
+ const reportWarning = makeAlertHandler(addAlert, onWarning, 'warning');
125
+
126
+ const cloningState = store.getState().cloning;
127
+ const formData = new FormData();
128
+ formData.append('file', file);
129
+ const config = {
130
+ headers: {
131
+ 'content-type': 'multipart/form-data',
132
+ },
133
+ };
134
+
135
+ try {
136
+ const resp = await httpClient.post(snapgeneUrl, formData, config);
137
+ const { data: cloningStrategy } = resp;
138
+
139
+ const isEmptyState = getEmptyPlaceholderSource(cloningState.sources) !== null;
140
+ const mode = isEmptyState ? 'replace' : 'merge';
141
+
142
+ if (resp.headers['x-warning']) {
143
+ reportWarning(`Not all SnapGene history was parsed: ${resp.headers['x-warning']}`);
144
+ }
145
+
146
+ await applyCloningStrategy(cloningStrategy, {
147
+ mode,
148
+ source,
149
+ onError,
150
+ });
151
+ return true;
152
+ } catch {
153
+ reportWarning('Failed to parse the history from the SnapGene file, will only read the sequence.');
154
+ return false;
155
+ }
156
+ }, [addAlert, applyCloningStrategy, httpClient, snapgeneUrl, store]);
157
+
158
+ return {
159
+ applyCloningStrategy,
160
+ loadHistoryFromFile,
161
+ loadSnapgeneHistory,
162
+ };
163
+ }
@@ -1,25 +1,22 @@
1
- import { batch, useDispatch, useStore } from 'react-redux';
2
1
  import { jsonToGenbank } from '@teselagen/bio-parsers';
3
2
  import useValidateState from './useValidateState';
4
- import { convertToTeselaJson, loadHistoryFile } from '@opencloning/utils/readNwrite';
3
+ import { convertToTeselaJson, parseHistoryFile } from '@opencloning/utils/readNwrite';
5
4
  import { getIdsOfSequencesWithoutChildSource } from '@opencloning/store/cloning_utils';
6
- import { mergeStates, graftState } from '@opencloning/utils/network';
7
- import { cloningActions } from '@opencloning/store/cloning';
5
+ import useCloningHistoryLoader from './useCloningHistoryLoader';
8
6
  import useDatabase from './useDatabase';
9
-
10
- const { deleteSourceAndItsChildren, setState: setCloningState } = cloningActions;
7
+ import { useStore } from 'react-redux';
11
8
 
12
9
  export default function useLoadDatabaseFile({ source, sendPostRequest, setHistoryFileError }) {
13
- const dispatch = useDispatch();
14
10
  const validateState = useValidateState();
15
11
  const store = useStore();
16
12
  const database = useDatabase();
13
+ const { applyCloningStrategy } = useCloningHistoryLoader();
17
14
 
18
15
  const loadDatabaseFile = async (file, databaseId, ancestors = false) => {
19
16
  if (file.name.endsWith('.json')) {
20
17
  let cloningStrategy;
21
18
  try {
22
- ({ cloningStrategy } = await loadHistoryFile(file));
19
+ ({ cloningStrategy } = await parseHistoryFile(file));
23
20
  // If the cloning strategy should end on a single sequence, set the databaseId for the right source
24
21
  const terminalSequences = getIdsOfSequencesWithoutChildSource(cloningStrategy.sources, cloningStrategy.sequences);
25
22
  if (terminalSequences.length === 1) {
@@ -29,7 +26,7 @@ export default function useLoadDatabaseFile({ source, sendPostRequest, setHistor
29
26
  // When importing sources that had inputs that we don't want to load, we turn them into database sources
30
27
  const allSequenceIds = [...cloningStrategy.sequences.map((e) => e.id), ...cloningStrategy.primers.map((e) => e.id)];
31
28
  cloningStrategy.sources = cloningStrategy.sources.map((s) => {
32
- if (s.input.some(({sequence}) => !allSequenceIds.includes(sequence))) {
29
+ if (s.input.some(({ sequence }) => !allSequenceIds.includes(sequence))) {
33
30
  return { id: s.id, type: 'DatabaseSource', input: [], database_id: s.database_id };
34
31
  }
35
32
  return s;
@@ -66,7 +63,6 @@ export default function useLoadDatabaseFile({ source, sendPostRequest, setHistor
66
63
  setHistoryFileError(e.message);
67
64
  return;
68
65
  }
69
- // This one won't have the source.id deleted
70
66
  const prevState = store.getState().cloning;
71
67
  const { backendVersion, schemaVersion, frontendVersion } = prevState.appInfo;
72
68
  cloningStrategy.backend_version = backendVersion;
@@ -74,28 +70,15 @@ export default function useLoadDatabaseFile({ source, sendPostRequest, setHistor
74
70
  cloningStrategy.frontend_version = frontendVersion;
75
71
  cloningStrategy = await validateState(cloningStrategy);
76
72
 
77
- batch(() => {
78
- // Replace the source with the new one if called from a source
79
- if (!ancestors && source) {
80
- dispatch(deleteSourceAndItsChildren(source.id));
81
- }
82
- const cloningState = store.getState().cloning;
83
- try {
84
- let mergedState;
85
- if (ancestors) {
86
- ({ mergedState } = graftState(cloningStrategy, cloningState, source.id));
87
- } else {
88
- ({ mergedState } = mergeStates(cloningStrategy, cloningState));
89
- }
90
- dispatch(setCloningState(mergedState));
91
- } catch (e) {
92
- setHistoryFileError(e.message);
93
- console.error(e);
94
- if (!ancestors) {
95
- dispatch(setCloningState(prevState));
96
- }
97
- }
98
- });
73
+ try {
74
+ await applyCloningStrategy(cloningStrategy, {
75
+ mode: ancestors ? 'graft' : 'merge',
76
+ source,
77
+ onError: setHistoryFileError,
78
+ });
79
+ } catch {
80
+ // Error already reported via onError; state restored in applyCloningStrategy when applicable
81
+ }
99
82
  } else {
100
83
  const requestData = new FormData();
101
84
  requestData.append('file', file);
@@ -6,10 +6,11 @@ import useLoadDatabaseFile from './useLoadDatabaseFile';
6
6
  import useCloningAlerts from './useCloningAlerts';
7
7
  import useHttpClient from './useHttpClient';
8
8
  import useValidateState from './useValidateState';
9
+ import useCloningHistoryLoader from './useCloningHistoryLoader';
9
10
  import { formatSequenceLocationString, getUrlParameters } from '@opencloning/utils/other';
10
- import { formatTemplate, loadHistoryFile, loadFilesToSessionStorage } from '@opencloning/utils/readNwrite';
11
+ import { formatTemplate } from '@opencloning/utils/readNwrite';
11
12
 
12
- const { setState: setCloningState, updateSource } = cloningActions;
13
+ const { updateSource } = cloningActions;
13
14
 
14
15
  /**
15
16
  * Hook to load sequences from URL parameters
@@ -23,6 +24,7 @@ export default function useUrlParamsLoader() {
23
24
  const { loadDatabaseFile } = useLoadDatabaseFile({ source: { id: 1 }, sendPostRequest: null, setHistoryFileError });
24
25
  const validateState = useValidateState();
25
26
  const httpClient = useHttpClient();
27
+ const { applyCloningStrategy, loadHistoryFromFile } = useCloningHistoryLoader();
26
28
 
27
29
  useEffect(() => {
28
30
  async function loadSequenceFromUrlParams() {
@@ -45,22 +47,24 @@ export default function useUrlParamsLoader() {
45
47
  } else if (urlParams.source === 'example' && urlParams.example) {
46
48
  try {
47
49
  const url = `${import.meta.env.BASE_URL}examples/${urlParams.example}`;
48
- let data;
49
- if (urlParams.example.endsWith('.zip')) {
50
- // For zip files, get as blob and process with loadHistoryFile
51
- const { data: blob } = await httpClient.get(url, { responseType: 'blob' });
52
- const fileName = urlParams.example;
53
- // eslint-disable-next-line no-undef
54
- const file = new File([blob], fileName);
55
- const { cloningStrategy, verificationFiles } = await loadHistoryFile(file);
56
- data = await validateState(cloningStrategy);
57
- await loadFilesToSessionStorage(verificationFiles, 0);
50
+ const fileName = urlParams.example;
51
+ if (fileName.endsWith('.zip')) {
52
+ const resp = await httpClient.get(url, { responseType: 'blob' });
53
+ if (resp.headers['content-type'] !== 'application/zip') {
54
+ throw new Error('Not found');
55
+ }
56
+ const file = new File([resp.data], fileName);
57
+ const prepared = await loadHistoryFromFile(file, { onError: setHistoryFileError });
58
+ await prepared?.replace();
58
59
  } else {
59
- // For JSON files, get as JSON
60
- const { data: jsonData } = await httpClient.get(url);
61
- data = await validateState(jsonData);
60
+ const resp = await httpClient.get(url);
61
+ if (resp.headers['content-type'] !== 'application/json') {
62
+ throw new Error('Not found');
63
+ }
64
+ const file = new File([JSON.stringify(resp.data)], fileName);
65
+ const prepared = await loadHistoryFromFile(file, { onError: setHistoryFileError });
66
+ await prepared?.replace();
62
67
  }
63
- dispatch(setCloningState(data));
64
68
  } catch (error) {
65
69
  addAlert({
66
70
  message: 'Error loading example',
@@ -76,7 +80,7 @@ export default function useUrlParamsLoader() {
76
80
  const validatedData = await validateState(data);
77
81
  const newState = formatTemplate(validatedData, url);
78
82
 
79
- dispatch(setCloningState(newState));
83
+ await applyCloningStrategy(newState, { mode: 'replace' });
80
84
  } catch (error) {
81
85
  addAlert({
82
86
  message: 'Error loading template',
@@ -146,4 +150,3 @@ export default function useUrlParamsLoader() {
146
150
  // eslint-disable-next-line react-hooks/exhaustive-deps
147
151
  }, []);
148
152
  }
149
-
package/src/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Version placeholder - replaced at publish time via prepack script
2
- export const version = "1.9.0";
2
+ export const version = "1.9.2";
@@ -1,72 +0,0 @@
1
- import React from 'react';
2
- import useBackendRoute from './useBackendRoute';
3
- import useHttpClient from './useHttpClient';
4
- import useCloningAlerts from './useCloningAlerts';
5
- import { batch, useDispatch, useStore } from 'react-redux';
6
- import { mergeStates } from '@opencloning/utils/network';
7
- import { cloningActions } from '@opencloning/store/cloning';
8
-
9
- const { setState: setCloningState, deleteSourceAndItsChildren } = cloningActions;
10
-
11
-
12
- /**
13
- * Custom React hook that provides a function to load and process a SnapGene .dna file's cloning history
14
- * via the backend endpoint. If successful, updates the Redux cloning state (merging or replacing as appropriate)
15
- * and handles warnings. On failure, triggers a user alert.
16
- *
17
- * @returns {Object} An object with a single method `loadSnapgeneHistory(file)`:
18
- * - {Promise<boolean>} loadSnapgeneHistory(file): Attempt to load and merge/replace SnapGene history.
19
- * Returns true on successfully parsing the SnapGene file, otherwise returns false and triggers a warning alert.
20
- */
21
- export default function useSnapgeneHistoryEndpoint() {
22
- const backendRoute = useBackendRoute();
23
- const httpClient = useHttpClient();
24
- const { addAlert } = useCloningAlerts();
25
- const dispatch = useDispatch();
26
- const store = useStore();
27
- const url = backendRoute('read_snapgene_history');
28
-
29
- const loadSnapgeneHistory = React.useCallback(async (file, sourceIdToDelete = null) => {
30
- const cloningState = store.getState().cloning;
31
- const formData = new FormData();
32
- formData.append('file', file);
33
- const config = {
34
- headers: {
35
- 'content-type': 'multipart/form-data',
36
- },
37
- };
38
- // If it fails to parse the history return false, true otherwise
39
- try {
40
- const resp = await httpClient.post(url, formData, config);
41
- const { data: cloningStrategy } = resp;
42
- let mergedState;
43
- if (!(cloningState.sources.length === 1 && cloningState.sources[0].type === null)) {
44
- ({ mergedState } = mergeStates(cloningStrategy, cloningState));
45
- } else {
46
- // If there is only one source and it is empty, replace the whole state
47
- mergedState = cloningStrategy;
48
- }
49
- if (resp.headers['x-warning']) {
50
- addAlert({
51
- message: `Not all SnapGene history was parsed: ${resp.headers['x-warning']}`,
52
- severity: 'warning',
53
- });
54
- }
55
- batch(() => {
56
- dispatch(setCloningState(mergedState));
57
- if (sourceIdToDelete !== null) {
58
- console.log('deleting source', sourceIdToDelete);
59
- dispatch(deleteSourceAndItsChildren(sourceIdToDelete));
60
- }
61
- });
62
- return true;
63
- } catch {
64
- addAlert({
65
- message: 'Failed to parse the history from the SnapGene file, will only read the sequence.',
66
- severity: 'warning',
67
- });
68
- return false;
69
- }
70
- }, [httpClient, addAlert, dispatch, store, url]);
71
- return { loadSnapgeneHistory };
72
- }