From 49a011ce9592c9719137069267d654070c2d8211 Mon Sep 17 00:00:00 2001 From: Sunny Aggarwal Date: Thu, 16 Jul 2026 12:18:18 +0530 Subject: [PATCH] OpenConceptLab/ocl_issues#2188 | auto save --- src/components/map-projects/MapProject.jsx | 195 ++++++++++++++++---- src/components/map-projects/ProjectLogs.jsx | 4 + src/i18n/locales/en/translations.json | 1 + src/i18n/locales/es/translations.json | 1 + src/i18n/locales/zh/translations.json | 1 + 5 files changed, 168 insertions(+), 34 deletions(-) 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>Premium", "bridge_terminology_search_description": "Include mappings in the <0>CIEL Interface Terminology 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>Premium", "bridge_terminology_search_description": "Incluir mapeos en la <0>Terminología de Interfaz CIEL 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>高级版", "bridge_terminology_search_description": "包含 <0>CIEL 接口术语 中的映射,以识别更多高质量候选项。仅适用于兼容的目标仓库和匹配算法。",