diff --git a/src/components/map-projects/AutoMatchDialog.jsx b/src/components/map-projects/AutoMatchDialog.jsx index 3059513..49ac9bf 100644 --- a/src/components/map-projects/AutoMatchDialog.jsx +++ b/src/components/map-projects/AutoMatchDialog.jsx @@ -28,9 +28,10 @@ import AIAssistantSelectorPanel from './AIAssistantSelectorPanel' const AutoMatchDialog = ({ open, onClose, - autoMatchUnmappedOnly, - setAutoMatchUnmappedOnly, + autoMatchScope, + setAutoMatchScope, rowStatuses, + selectedRowCount, autoRunAIAnalysis, setAutoRunAIAnalysis, AIModels, @@ -47,29 +48,52 @@ const AutoMatchDialog = ({ }) => { const { t } = useTranslation() const [algos, setAlgos] = React.useState(true) - const selectedRows = autoMatchUnmappedOnly ? rowStatuses.unmapped.length : (rowStatuses.unmapped.length + rowStatuses.readyForReview.length) + const allRowsCount = rowStatuses.unmapped.length + rowStatuses.readyForReview.length + const rowsInSelectedScope = { + unmapped: rowStatuses.unmapped.length, + all: allRowsCount, + selected: selectedRowCount + } + const rowsToMatchCount = rowsInSelectedScope[autoMatchScope] || 0 const totalRows = rowStatuses.unmapped.length + rowStatuses.readyForReview.length + rowStatuses.reviewed.length + const hasSelectedRows = selectedRowCount > 0 + const hasUnmappedRows = rowStatuses.unmapped.length > 0 - const getHelperTextForAutoMatchUnmapped = () => { - if (autoMatchUnmappedOnly) { - const count = rowStatuses.unmapped.length; - if (count > 0) { - return t('map_project.auto_match_unmapped_only_note', {count: count.toLocaleString()}); - } - return t('map_project.auto_match_unmapped_only_note_no_count'); + React.useEffect(() => { + if (autoMatchScope === 'unmapped' && !hasUnmappedRows) { + setAutoMatchScope('all') } - const approvedCount = rowStatuses.reviewed.length; - const proposedCount = rowStatuses.readyForReview.length; - if (approvedCount > 0 || proposedCount > 0) { - return t('map_project.auto_match_note', { - approvedCount: approvedCount.toLocaleString(), - proposedCount: proposedCount.toLocaleString() - }); + }, [autoMatchScope, hasUnmappedRows, setAutoMatchScope]) + + const scopeOptions = [ + { + value: 'all', + disabled: false, + label: `${t('map_project.unmapped_and_proposed')} (${allRowsCount.toLocaleString()})`, + helperText: t('map_project.auto_match_note', { + approvedCount: rowStatuses.reviewed.length.toLocaleString(), + proposedCount: rowStatuses.readyForReview.length.toLocaleString() + }) + }, + { + value: 'unmapped', + disabled: !hasUnmappedRows, + label: `${t('map_project.unmapped_only')} (${rowStatuses.unmapped.length.toLocaleString()})`, + helperText: rowStatuses.unmapped.length > 0 ? + t('map_project.auto_match_unmapped_only_note', {count: rowStatuses.unmapped.length.toLocaleString()}) : + t('map_project.auto_match_unmapped_only_note_no_count') + }, + { + value: 'selected', + disabled: !hasSelectedRows, + label: `${t('map_project.selected_rows')} (${selectedRowCount.toLocaleString()})`, + helperText: hasSelectedRows ? + t('map_project.auto_match_selected_rows_note', {count: selectedRowCount.toLocaleString()}) : + t('map_project.auto_match_selected_rows_note_no_count') } - return t('map_project.auto_match_note_no_counts'); - }; + ] - const isDisabled = !repoVersion?.version_url || selectedRows === 0 || (!algos && !autoRunAIAnalysis) + const isDisabled = !repoVersion?.version_url || rowsToMatchCount === 0 || (!algos && !autoRunAIAnalysis) return ( - {`${t('map_project.selected_rows')}: ${selectedRows.toLocaleString()} ${t('map_project.out_of')} ${totalRows.toLocaleString()}` } + {`${t('map_project.rows_to_match')}: ${rowsToMatchCount.toLocaleString()} ${t('map_project.out_of')} ${totalRows.toLocaleString()}` } setAutoMatchUnmappedOnly(!autoMatchUnmappedOnly)} + value={autoMatchScope} + onChange={event => setAutoMatchScope(event.target.value)} > - } - label={t('map_project.unmapped_only')} - /> - } - label={t('map_project.all_rows')} - /> + { + scopeOptions.map(option => ( +
+ } + label={option.label} + sx={{marginRight: 0}} + /> + { + autoMatchScope === option.value && + + {option.helperText} + + } +
+ )) + }
- - { - getHelperTextForAutoMatchUnmapped() - } - setAlgos(!algos)} />} label={t('map_project.retrieve_candidates')} /> diff --git a/src/components/map-projects/MapProject.jsx b/src/components/map-projects/MapProject.jsx index 0fb22b7..243c92a 100644 --- a/src/components/map-projects/MapProject.jsx +++ b/src/components/map-projects/MapProject.jsx @@ -88,6 +88,7 @@ import { HEADERS, SEMANTIC_SEARCH_HEADERS, ROW_STATES, VIEWS, DECISION_TABS, ROW import MapProjectDeleteConfirmDialog from './MapProjectDeleteConfirmDialog'; import ConfigurationForm from './ConfigurationForm' import Controls from './Controls' +import { getRowsToProcess } from './autoMatchRows' import MatchSummaryCard from './MatchSummaryCard' import SearchField from './SearchField' import MappingDecisionResult from './MappingDecisionResult' @@ -224,7 +225,7 @@ const MapProject = () => { const [matchDialog, setMatchDialog] = React.useState(false) const [showItem, setShowItem] = React.useState(false) - const [autoMatchUnmappedOnly, setAutoMatchUnmappedOnly] = React.useState(true) + const [autoMatchScope, setAutoMatchScope] = React.useState('unmapped') const [autoRunAIAnalysis, setAutoRunAIAnalysis] = React.useState(false) const [alert, setAlert] = React.useState(false) const [columnVisibilityModel, setColumnVisibilityModel] = React.useState({}) @@ -1008,7 +1009,8 @@ const MapProject = () => { setDecisionTab('candidates') setSearchText('') setShowItem(false) - setAutoMatchUnmappedOnly(true) + setAutoMatchScope('unmapped') + setSelectedRowIds([]) setAlert(false) setSelectedCandidatesScoreBucket(false) setScoreBucketSortBy('desc') @@ -1591,6 +1593,14 @@ const MapProject = () => { const getRowsResults = async (rows, selectedAlgos) => { abortRef.current = false; + const selectedRowIndexes = getSelectedRowIndexes(rows) + const isAutoMatchUnmappedOnly = autoMatchScope === 'unmapped' + const isAutoMatchAllRows = autoMatchScope === 'all' + const isAutoMatchSelectedRows = autoMatchScope === 'selected' + const selectedRowsLogExtras = isAutoMatchSelectedRows ? { + selected_rows_count: selectedRowIndexes.length, + selected_row_indexes: selectedRowIndexes + } : {} // Function to process a single batch const processBatch = async (_repo, rowBatch, algo) => { @@ -1729,8 +1739,10 @@ const MapProject = () => { let _selectedAlgos = filter(algosSelected, algo => selectedAlgos.includes(algo.id)) let subActions = [...map(_selectedAlgos, algo => algo.name || algo.id)] subActions.push('reranker') - if(autoMatchUnmappedOnly) + if(isAutoMatchUnmappedOnly) subActions.push('unmatched_only') + if(isAutoMatchSelectedRows) + subActions.push('selected_rows') if(inAIAssistantGroup && autoRunAIAnalysis) subActions.push('with_ai_analysis') @@ -1738,6 +1750,7 @@ const MapProject = () => { action: 'auto_match_started', extras: { sub_actions: subActions, + ...selectedRowsLogExtras, ...(inAIAssistantGroup && autoRunAIAnalysis ? { ai_assistant: { model: getSelectedAIModel(), @@ -1747,13 +1760,17 @@ const MapProject = () => { } }) - if(!autoMatchUnmappedOnly) + if(isAutoMatchAllRows) setRowStatuses(prev => ({...prev, readyForReview: []})) + if(isAutoMatchSelectedRows) + setRowStatuses(prev => ({ + ...prev, + readyForReview: without(prev.readyForReview, ...selectedRowIndexes), + reviewed: without(prev.reviewed, ...selectedRowIndexes) + })) setTimeout(async () => { - const rowsToProcess = autoMatchUnmappedOnly - ? filter(rows, row => rowStatuses.unmapped.includes(row.__index)) - : filter(rows, row => !rowStatuses.reviewed.includes(row.__index)) + const rowsToProcess = getRowsToProcess(rows, rowStatuses, autoMatchScope, selectedRowIndexes) // ocl_online#105 Phase 5: open the run record, then guarantee it is // closed out (completed / partial / failed / cancelled) via the finally, @@ -1805,6 +1822,7 @@ const MapProject = () => { action: 'auto_match_finished', extras: { sub_actions: subActions, + ...selectedRowsLogExtras, ...(inAIAssistantGroup && autoRunAIAnalysis ? { ai_assistant: { model: getSelectedAIModel(), @@ -2043,6 +2061,7 @@ const MapProject = () => { const onGetCandidates = event => { event.stopPropagation() event.preventDefault() + setAutoMatchScope(getSelectedRowIndexes().length ? 'selected' : (rowStatuses.unmapped.length ? 'unmapped' : 'all')) setMatchDialog(true) } @@ -2677,7 +2696,11 @@ const MapProject = () => { }) } - const getSelectedRowIndexes = () => selectedRowIds.map(id => parseInt(id)).filter(Number.isFinite) + const getSelectedRowIndexes = (_rows = data) => { + if(!isArray(_rows)) return [] + const selectedIds = new Set(selectedRowIds.map(id => id?.toString())) + return _rows.filter(_row => selectedIds.has(_row.__index?.toString())).map(_row => _row.__index) + } const openBulkConfirm = action => { const indexes = getSelectedRowIndexes() @@ -3934,10 +3957,11 @@ const MapProject = () => { const rows = getRows() const visibleRowIds = rows.map(_row => _row.__index) const visibleRowIdKey = visibleRowIds.join(',') - const selectedRowsCount = selectedRowIds.length + const selectedRowsCount = getSelectedRowIndexes(rows).length React.useEffect(() => { setSelectedRowIds(prev => { - const next = prev.filter(id => visibleRowIds.includes(parseInt(id))) + const visibleRowIdSet = new Set(visibleRowIds.map(id => id?.toString())) + const next = prev.filter(id => visibleRowIdSet.has(id?.toString())) return next.length === prev.length ? prev : next }) }, [visibleRowIdKey]) @@ -4965,8 +4989,9 @@ const MapProject = () => { onSubmit={onGetCandidatesSubmit} {...{ rowStatuses, - autoMatchUnmappedOnly, - setAutoMatchUnmappedOnly, + autoMatchScope, + setAutoMatchScope, + selectedRowCount: selectedRowsCount, autoRunAIAnalysis, setAutoRunAIAnalysis, AIModels, diff --git a/src/components/map-projects/ProjectLogs.jsx b/src/components/map-projects/ProjectLogs.jsx index 4889153..a02ada1 100644 --- a/src/components/map-projects/ProjectLogs.jsx +++ b/src/components/map-projects/ProjectLogs.jsx @@ -70,14 +70,18 @@ const ProjectLogs = ({onClose, logs, project}) => { {log.extras.id} - + if(['auto_match_started', 'auto_match_finished', 'auto_matched'].includes(log.action)) { + const rawSubActions = (log.extras?.sub_actions || []).filter(subAction => log.extras?.selected_rows_count ? subAction !== 'selected_rows' : true) + const subActions = map(rawSubActions, formatSubAction) + if(log.extras?.selected_rows_count) + subActions.push(`${log.extras.selected_rows_count.toLocaleString()} Selected Rows`) return {startCase(log.action)} { - log.extras?.sub_actions?.length ? + subActions.length ? - {`(${map(log.extras.sub_actions, formatSubAction).join(', ')})`} + {`(${subActions.join(', ')})`} : null } diff --git a/src/components/map-projects/__tests__/autoMatchRows.test.js b/src/components/map-projects/__tests__/autoMatchRows.test.js new file mode 100644 index 0000000..d65ef60 --- /dev/null +++ b/src/components/map-projects/__tests__/autoMatchRows.test.js @@ -0,0 +1,62 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { getRowsToProcess } from '../autoMatchRows.js' + +const rows = [ + { __index: 0, label: 'zero' }, + { __index: 1, label: 'one' }, + { __index: 2, label: 'two' }, + { __index: 3, label: 'three' } +] + +test('getRowsToProcess: unmapped scope includes only unmapped rows', () => { + const result = getRowsToProcess( + rows, + { unmapped: [0, 2], readyForReview: [1], reviewed: [3] }, + 'unmapped', + [1, 3] + ) + + assert.deepEqual(result.map(row => row.__index), [0, 2]) +}) + +test('getRowsToProcess: selected scope includes only selected rows, even when reviewed', () => { + const result = getRowsToProcess( + rows, + { unmapped: [0], readyForReview: [1], reviewed: [2, 3] }, + 'selected', + [1, 3] + ) + + assert.deepEqual(result.map(row => row.__index), [1, 3]) +}) + +test('getRowsToProcess: all scope excludes reviewed rows and keeps unmapped plus proposed', () => { + const result = getRowsToProcess( + rows, + { unmapped: [0, 2], readyForReview: [1], reviewed: [3] }, + 'all', + [3] + ) + + assert.deepEqual(result.map(row => row.__index), [0, 1, 2]) +}) + +test('getRowsToProcess: selected scope preserves table row order, not selection order', () => { + const result = getRowsToProcess( + rows, + { unmapped: [0, 2], readyForReview: [1], reviewed: [3] }, + 'selected', + [3, 1] + ) + + assert.deepEqual(result.map(row => row.__index), [1, 3]) +}) + +test('getRowsToProcess: invalid rows input returns empty array', () => { + assert.deepEqual( + getRowsToProcess(false, { unmapped: [0], readyForReview: [], reviewed: [] }, 'unmapped', [0]), + [] + ) +}) diff --git a/src/components/map-projects/autoMatchRows.js b/src/components/map-projects/autoMatchRows.js new file mode 100644 index 0000000..33db236 --- /dev/null +++ b/src/components/map-projects/autoMatchRows.js @@ -0,0 +1,16 @@ +export const getRowsToProcess = (rows, rowStatuses, autoMatchScope, selectedRowIndexes = []) => { + if(!Array.isArray(rows)) + return [] + + const unmappedIndexes = new Set(rowStatuses?.unmapped || []) + const reviewedIndexes = new Set(rowStatuses?.reviewed || []) + const selectedIndexSet = new Set(selectedRowIndexes || []) + + if(autoMatchScope === 'unmapped') + return rows.filter(row => unmappedIndexes.has(row.__index)) + + if(autoMatchScope === 'selected') + return rows.filter(row => selectedIndexSet.has(row.__index)) + + return rows.filter(row => !reviewedIndexes.has(row.__index)) +} diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index 3c887d5..7ac8a29 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -518,6 +518,8 @@ "auto_match_unmapped_only_note_no_count": "Note: Skip input rows that are already proposed", "auto_match_note": "Note: This will not affect {{approvedCount}} approved input rows but will override {{proposedCount}} proposed input rows", "auto_match_note_no_counts": "Note: This will not affect approved input rows but will override proposed input rows", + "auto_match_selected_rows_note": "Note: This will run Auto Match for {{count}} selected input rows", + "auto_match_selected_rows_note_no_count": "Note: Select rows in the left panel to enable this option", "run_ai_analysis": "Run AI Analysis", "run_ai_analysis_note": "Note: Enabling this feature will run AI Analysis on results of each row. This has direct cost implications.", "decision": "Decision", @@ -631,7 +633,8 @@ "full_project_export": "Full Project Export", "select_an_algo": "Select an algorithm", "selected_rows": "Selected Rows", - "all_rows": "All Rows", + "unmapped_and_proposed": "Unmapped & Proposed", + "rows_to_match": "Rows to Match", "out_of": "out of", "retrieve_candidates": "Retrieve Candidates", "retrieve_candidates_helper_text": "Your project is configured to use the following match algorithms:", diff --git a/src/i18n/locales/es/translations.json b/src/i18n/locales/es/translations.json index deee65d..b82369e 100644 --- a/src/i18n/locales/es/translations.json +++ b/src/i18n/locales/es/translations.json @@ -485,6 +485,8 @@ "auto_match_unmapped_only_note_no_count": "Nota: Omitir filas de entrada que ya están propuestas", "auto_match_note": "Nota: Esto no afectará {{approvedCount}} filas de entrada aprobadas pero sobrescribirá {{proposedCount}} filas de entrada propuestas", "auto_match_note_no_counts": "Nota: Esto no afectará las filas de entrada aprobadas pero sobrescribirá las filas de entrada propuestas", + "auto_match_selected_rows_note": "Nota: Esto ejecutará Auto Match para {{count}} fila(s) seleccionada(s)", + "auto_match_selected_rows_note_no_count": "Nota: Seleccione filas en el panel izquierdo para habilitar esta opción", "run_ai_analysis": "Ejecutar Análisis de IA", "run_ai_analysis_note": "Nota: Habilitar esta función ejecutará Análisis de IA en los resultados de cada fila. Esto tiene implicaciones de costo directas.", "decision": "Decisión", @@ -608,7 +610,8 @@ "full_project_export": "Exportación completa del proyecto", "select_an_algo": "Seleccione un algoritmo", "selected_rows": "Filas seleccionadas", - "all_rows": "Todas las filas", + "unmapped_and_proposed": "No mapeadas y propuestas", + "rows_to_match": "Filas para coincidir", "out_of": "de", "retrieve_candidates": "Recuperar candidatos", "retrieve_candidates_helper_text": "Su proyecto está configurado para usar los siguientes algoritmos de coincidencia:", diff --git a/src/i18n/locales/zh/translations.json b/src/i18n/locales/zh/translations.json index 6aaafab..a9f1a42 100644 --- a/src/i18n/locales/zh/translations.json +++ b/src/i18n/locales/zh/translations.json @@ -510,6 +510,8 @@ "auto_match_unmapped_only_note_no_count": "注意:跳过已提议的输入行", "auto_match_note": "注意:这不会影响 {{approvedCount}} 个已批准的输入行,但会覆盖 {{proposedCount}} 个已提议的输入行", "auto_match_note_no_counts": "注意:这不会影响已批准的输入行,但会覆盖已提议的输入行", + "auto_match_selected_rows_note": "注意:这将对 {{count}} 个已选输入行运行自动匹配", + "auto_match_selected_rows_note_no_count": "注意:请在左侧面板选择行以启用此选项", "run_ai_analysis": "运行 AI 分析", "run_ai_analysis_note": "注意:启用此功能将对每行的结果运行 AI 分析。这会产生直接的成本影响。", "decision": "决策", @@ -633,7 +635,8 @@ "full_project_export": "完整项目导出", "select_an_algo": "选择算法", "selected_rows": "已选行", - "all_rows": "所有行", + "unmapped_and_proposed": "未映射和已提议", + "rows_to_match": "要匹配的行", "out_of": "共", "retrieve_candidates": "获取候选项", "retrieve_candidates_helper_text": "你的项目已配置为使用以下匹配算法:",