diff --git a/src/components/map-projects/MapProject.jsx b/src/components/map-projects/MapProject.jsx
index 0b77f48..e9f5f1c 100644
--- a/src/components/map-projects/MapProject.jsx
+++ b/src/components/map-projects/MapProject.jsx
@@ -141,6 +141,7 @@ const MapProject = () => {
const queryParams = new URLSearchParams(location.search)
return queryParams.get('templateFrom')
}, [location.search])
+ const AUTOSAVE_DELAY_MS = 5000
const bridgeRef = React.useRef()
const facetsRequestsRef = React.useRef({})
@@ -229,6 +230,11 @@ const MapProject = () => {
const [columnWidth, setColumnWidth] = React.useState({})
const [logs, setLogs] = React.useState({})
const [projectLogs, setProjectLogs] = React.useState([])
+ const logsRef = React.useRef({})
+ const projectLogsRef = React.useRef([])
+ const autosaveTimerRef = React.useRef(null)
+ const autosaveReasonsRef = React.useRef([])
+ const configSnapshotOnOpenRef = React.useRef(null)
const [filterModel, setFilterModel] = React.useState({ items: [] });
const [filterPanelAnchorEl, setFilterPanelAnchorEl] = React.useState(null)
const [retired, setRetired] = React.useState(false)
@@ -250,6 +256,19 @@ const MapProject = () => {
setAlert(false)
}, [])
+ React.useEffect(() => {
+ logsRef.current = logs
+ }, [logs])
+
+ React.useEffect(() => {
+ projectLogsRef.current = projectLogs
+ }, [projectLogs])
+
+ React.useEffect(() => () => {
+ if(autosaveTimerRef.current)
+ clearTimeout(autosaveTimerRef.current)
+ }, [])
+
// repo state
const [repo, setRepo] = React.useState(false)
const [repoVersion, setRepoVersion] = React.useState(false)
@@ -666,8 +685,12 @@ const MapProject = () => {
setFilters(response.data?.filters || {})
if(response.data?.url) {
APIService.new().overrideURL(response.data.url).appendToUrl('logs/').get().then(response => {
- setLogs(response.data.logs?.row_logs || [])
- setProjectLogs(response.data.logs?.project_logs || [])
+ const rowLogs = response.data.logs?.row_logs || []
+ const savedProjectLogs = response.data.logs?.project_logs || []
+ logsRef.current = rowLogs
+ projectLogsRef.current = savedProjectLogs
+ setLogs(rowLogs)
+ setProjectLogs(savedProjectLogs)
projectLog({action: 'Opened'})
})
}
@@ -1365,7 +1388,17 @@ const MapProject = () => {
})
}
- const onSave = () => {
+ const onSave = (options = {}) => {
+ const saveOptions = options?.preventDefault ? {} : options
+ const saveSource = saveOptions.source || 'manual'
+ const isAutoSave = saveSource === 'auto'
+ if(isAutoSave && !project?.id)
+ return
+ if(!isAutoSave && autosaveTimerRef.current) {
+ clearTimeout(autosaveTimerRef.current)
+ autosaveTimerRef.current = null
+ autosaveReasonsRef.current = []
+ }
if(!repoVersion?.version_url || !selectedTargetRepoVersion) {
setConfigure(true)
setAlert({
@@ -1378,6 +1411,8 @@ const MapProject = () => {
})
return
}
+ const rowLogsForSave = saveOptions.logs || logsRef.current
+ const projectLogsForSave = saveOptions.projectLogs || projectLogsRef.current
setIsSaving(true)
const f = getFileObjectFromRows()
const selected = map(mapSelected, (data, i) => {
@@ -1459,30 +1494,70 @@ const MapProject = () => {
service.then(response => {
setIsSaving(false)
if(response?.data?.id) {
- projectLog({action: isUpdate ? 'Updated' : 'Created', extras: isUpdate ? undefined : {project: response.data}})
- setConfigure(false)
+ const saveLog = {
+ action: isAutoSave ? 'Auto Saved' : (isUpdate ? 'Updated' : 'Created'),
+ description: isAutoSave ? t('map_project.auto_saved_changes') : undefined,
+ created_at: moment().toDate(),
+ user: user.username || user.id,
+ extras: isAutoSave ? {reasons: saveOptions.reasons || []} : (isUpdate ? undefined : {project: response.data})
+ }
+ const savedProjectLogs = [saveLog, ...projectLogsForSave]
+ projectLogsRef.current = savedProjectLogs
+ setProjectLogs(savedProjectLogs)
+ if(saveOptions.closeConfigure !== false) {
+ configSnapshotOnOpenRef.current = null
+ setConfigure(false)
+ }
setProjectPromptTemplateKey(response.data?.prompt_template_key || getProjectPromptTemplateKey())
setProject(response.data)
if(response.data.url)
history.push(response.data.url)
- baseSetAlert({severity: 'success', message: t('map_project.successfully_saved'), duration: 2000})
+ if(!isAutoSave)
+ baseSetAlert({severity: 'success', message: t('map_project.successfully_saved'), duration: 2000})
- APIService.new().overrideURL(response.data.url).appendToUrl('logs/').post({logs: {row_logs: logs, project_logs: projectLogs}}).then(() => {})
+ APIService.new().overrideURL(response.data.url).appendToUrl('logs/').post({logs: {row_logs: rowLogsForSave, project_logs: savedProjectLogs}}).then(() => {})
}
- })
+ }).finally(() => setIsSaving(false))
}
const log = (data, index) => {
let idx = index === undefined ? rowIndex : index
- setLogs(prev => ({...prev, [idx]: [{created_at: moment().toDate(), user: user.username || user.id, ...data}, ...(prev[idx] || [])]}))
+ const nextLogs = {...logsRef.current, [idx]: [{created_at: moment().toDate(), user: user.username || user.id, ...data}, ...(logsRef.current[idx] || [])]}
+ logsRef.current = nextLogs
+ setLogs(nextLogs)
}
const projectLog = data => {
const newLog = {...data, created_at: moment().toDate(), user: user.username || user.id}
- const newLogs = [newLog, ...projectLogs]
- setProjectLogs(prev => [newLog, ...prev])
+ const newLogs = [newLog, ...projectLogsRef.current]
+ projectLogsRef.current = newLogs
+ setProjectLogs(newLogs)
if(project?.url)
- APIService.new().overrideURL(project.url).appendToUrl('logs/').post({logs: {row_logs: logs, project_logs: newLogs}}).then(() => {})
+ APIService.new().overrideURL(project.url).appendToUrl('logs/').post({logs: {row_logs: logsRef.current, project_logs: newLogs}}).then(() => {})
+ }
+
+ const scheduleAutoSave = reason => {
+ if(!project?.id)
+ return
+
+ autosaveReasonsRef.current = uniq([...autosaveReasonsRef.current, reason])
+ if(autosaveTimerRef.current)
+ clearTimeout(autosaveTimerRef.current)
+
+ autosaveTimerRef.current = setTimeout(() => {
+ autosaveTimerRef.current = null
+ if(!project?.id) {
+ autosaveReasonsRef.current = []
+ return
+ }
+ if(isSaving) {
+ scheduleAutoSave(reason)
+ return
+ }
+ const reasons = autosaveReasonsRef.current
+ autosaveReasonsRef.current = []
+ onSave({source: 'auto', closeConfigure: false, reasons})
+ }, AUTOSAVE_DELAY_MS)
}
// ── ocl_online#105 Phase 5: AutomatchRun attribution ──────────────────────
@@ -1619,6 +1694,53 @@ const MapProject = () => {
return includeDefaultFilter ? allFilters : omit(allFilters, Object.keys(defaultFilters))
}
+ const getConfigurationSnapshot = () => JSON.stringify({
+ owner,
+ name,
+ description,
+ repo_url: repo?.url,
+ repo_version_url: repoVersion?.version_url,
+ algorithms: map(algosSelected, algo => omit(algo, ['__key'])),
+ score_configuration: candidatesScore,
+ lookup_config: lookupConfig,
+ namespace,
+ encoder_model: encoderModel || DEFAULT_ENCODER_MODEL,
+ include_retired: retired,
+ filters: getFilters(),
+ prompt_template_key: getProjectPromptTemplateKey(),
+ prompt_output_locale: promptOutputLocale || '',
+ input_locale: inputLocale || '',
+ use_lexical_variants: Boolean(useLexicalVariants),
+ columns: map(columns, col => ({
+ dataKey: col.dataKey,
+ mapped: col.mapped,
+ hidden: columnVisibilityModel[col.dataKey] === false,
+ width: columnWidth[col.dataKey] || undefined,
+ ai_assistant_hidden: AIAssistantColumns[col.dataKey] === false
+ }))
+ })
+
+ const closeConfiguration = () => {
+ const openedSnapshot = configSnapshotOnOpenRef.current
+ const changed = openedSnapshot && openedSnapshot !== getConfigurationSnapshot()
+ setConfigure(false)
+ configSnapshotOnOpenRef.current = null
+ if(changed)
+ scheduleAutoSave('configuration_change')
+ }
+
+ const setConfigureWithAutosave = nextConfigure => {
+ if(nextConfigure)
+ setConfigure(true)
+ else
+ closeConfiguration()
+ }
+
+ React.useEffect(() => {
+ if(configure && !configSnapshotOnOpenRef.current)
+ configSnapshotOnOpenRef.current = getConfigurationSnapshot()
+ }, [configure])
+
const getPayloadForMatching = (rows, _repo, _filters) => {
return {
rows: map(rows, row => prepareRow(row)),
@@ -1984,6 +2106,8 @@ const MapProject = () => {
} : {})
}
})
+ if(!abortRef.current)
+ scheduleAutoSave('auto_match')
} finally {
await completeAutomatchRun(_selectedAlgos, rowsToProcess)
}
@@ -2644,7 +2768,7 @@ const MapProject = () => {
conceptCacheRef.current = next
setConceptCache(next)
})
- setConfigure(false)
+ closeConfiguration()
setShowProjectLogs(false)
setRow(csvRow)
setSearchStr(getRowNameValue(csvRow) || '')
@@ -2721,6 +2845,7 @@ const MapProject = () => {
const newRowStatuses = {...rowStatuses, reviewed: uniq([...rowStatuses.reviewed, rowIndex]), readyForReview: without(rowStatuses.readyForReview, rowIndex), unmapped: without(rowStatuses.unmapped, rowIndex)}
setRowStatuses(newRowStatuses)
log({'action': 'approved'})
+ scheduleAutoSave('decision_change')
if(next){
const nextRow = data[selectedRowStatus === 'all' ? rowIndex + 1 : find(rowStatuses[selectedRowStatus], idx => idx > rowIndex)]
if(nextRow !== undefined)
@@ -2813,6 +2938,7 @@ const MapProject = () => {
})
if(newValue !== 'map' && !logged)
log({action: newValue || 'decision_changed', description: t('map_project.decision_changed_to_none'), extras: newValue ? {} : {decision: t('map_project.none')}})
+ scheduleAutoSave('decision_change')
}
const getBulkActionLabel = action => {
@@ -2828,25 +2954,24 @@ const MapProject = () => {
const addBulkLogs = (indexes, action, description, extras = {}) => {
const createdAt = moment().toDate()
- setLogs(prev => {
- const next = {...prev}
- indexes.forEach(index => {
- const resolvedDescription = typeof description === 'function' ? description(index) : description
- const resolvedExtras = typeof extras === 'function' ? extras(index) : extras
- next[index] = [{
- created_at: createdAt,
- user: user.username || user.id,
- action,
- description: resolvedDescription,
- extras: {
- bulk_origin: 'mapper-left-panel-selection',
- bulk_action: action,
- ...resolvedExtras
- }
- }, ...(next[index] || [])]
- })
- return next
+ const next = {...logsRef.current}
+ indexes.forEach(index => {
+ const resolvedDescription = typeof description === 'function' ? description(index) : description
+ const resolvedExtras = typeof extras === 'function' ? extras(index) : extras
+ next[index] = [{
+ created_at: createdAt,
+ user: user.username || user.id,
+ action,
+ description: resolvedDescription,
+ extras: {
+ bulk_origin: 'mapper-left-panel-selection',
+ bulk_action: action,
+ ...resolvedExtras
+ }
+ }, ...(next[index] || [])]
})
+ logsRef.current = next
+ setLogs(next)
}
const getSelectedRowIndexes = (_rows = data) => {
@@ -3006,6 +3131,8 @@ const MapProject = () => {
duration: 4,
message: t('map_project.bulk_action_summary', {changed: changed.length, skipped: skipped.length})
})
+ if(changed.length)
+ scheduleAutoSave('decision_change')
clearBulkSelection()
}
@@ -4688,7 +4815,7 @@ const MapProject = () => {
isValidColumnValue={isValidColumnValue}
updateColumn={updateColumn}
configure={configure}
- setConfigure={setConfigure}
+ setConfigure={setConfigureWithAutosave}
columnVisibilityModel={columnVisibilityModel}
setColumnVisibilityModel={setColumnVisibilityModel}
onSave={onSave}
@@ -4895,14 +5022,14 @@ const MapProject = () => {
onProjectLogsClick={() => {
const newValue = !showProjectLogs
if(newValue) {
- setConfigure(false)
+ closeConfiguration()
onCloseDecisions()
}
setShowProjectLogs(newValue)
}}
isProjectsLogOpen={showProjectLogs}
configure={configure}
- setConfigure={setConfigure}
+ setConfigure={setConfigureWithAutosave}
onCopyClick={onCopyClick}
/>
}
diff --git a/src/components/map-projects/ProjectLogs.jsx b/src/components/map-projects/ProjectLogs.jsx
index f21f4bf..93d4695 100644
--- a/src/components/map-projects/ProjectLogs.jsx
+++ b/src/components/map-projects/ProjectLogs.jsx
@@ -36,6 +36,8 @@ const ProjectLogs = ({onClose, logs, project}) => {
return [, 'primary']
if(action === 'updated')
return [, 'warning']
+ if(action === 'auto saved')
+ return [, 'primary']
if(action === 'saved_to_collection')
return [, 'primary']
if(action === 'opened')
@@ -101,6 +103,8 @@ const ProjectLogs = ({onClose, logs, project}) => {
/>
}
+ if(log?.action?.toLowerCase() === 'auto saved')
+ return log.description || t('map_project.auto_saved_changes')
if(log.description)
return log.description
return startCase(log.action)
diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json
index b3ca22c..9d031ea 100644
--- a/src/i18n/locales/en/translations.json
+++ b/src/i18n/locales/en/translations.json
@@ -595,6 +595,7 @@
"sort_by_unified_score": "Unified Score",
"project_logs": "Project History",
"project_logs_tooltip": "Project History",
+ "auto_saved_changes": "Auto saved changes",
"saved_to_collection": "Saved to collection",
"bridge_terminology_search": "Bridge terminology search<0>Premium0>",
"bridge_terminology_search_description": "Include mappings in the <0>CIEL Interface Terminology0> to identify additional high quality candidates. Only available for compatible target repositories and matching algorithms.",
diff --git a/src/i18n/locales/es/translations.json b/src/i18n/locales/es/translations.json
index ac4c1ea..9fa4ff2 100644
--- a/src/i18n/locales/es/translations.json
+++ b/src/i18n/locales/es/translations.json
@@ -565,6 +565,7 @@
"sort_by_unified_score": "Puntuación Unificada",
"project_logs": "Registros del proyecto",
"project_logs_tooltip": "Ver registros del proyecto",
+ "auto_saved_changes": "Cambios guardados automáticamente",
"saved_to_collection": "Guardado en la colección",
"bridge_terminology_search": "Búsqueda de terminología Bridge<0>Premium0>",
"bridge_terminology_search_description": "Incluir mapeos en la <0>Terminología de Interfaz CIEL0> para identificar candidatos adicionales de alta calidad. Disponible solo para repositorios de destino y algoritmos de coincidencia compatibles.",
diff --git a/src/i18n/locales/zh/translations.json b/src/i18n/locales/zh/translations.json
index 1c1c555..9d92c37 100644
--- a/src/i18n/locales/zh/translations.json
+++ b/src/i18n/locales/zh/translations.json
@@ -590,6 +590,7 @@
"sort_by_unified_score": "按统一分数排序",
"project_logs": "项目日志",
"project_logs_tooltip": "查看项目日志",
+ "auto_saved_changes": "已自动保存更改",
"saved_to_collection": "已保存到集合",
"bridge_terminology_search": "Bridge 术语搜索<0>高级版0>",
"bridge_terminology_search_description": "包含 <0>CIEL 接口术语0> 中的映射,以识别更多高质量候选项。仅适用于兼容的目标仓库和匹配算法。",