Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/integration-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,10 @@ jobs:
npm ci
npx playwright install --with-deps chromium

- name: Bulk tag reconciliation regression
if: ${{ matrix.shard == 1 }}
run: npm --prefix web run test:e2e -- --testPathPatterns=bulkTagsReconciliation

- name: Run Playwright UI tests
run: |
# Clean any existing playwright reports to ensure fresh state
Expand Down
59 changes: 40 additions & 19 deletions web/js/components/preact/recordings/TagsOverlay.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -186,28 +186,43 @@ export function BulkTagsOverlay({ recordings, selectedRecordings, onClose, onTag
.filter(([_, sel]) => sel)
.map(([id]) => parseInt(id, 10));

const selectedRecs = recordings.filter(r => selectedIds.includes(r.id));
const selectionKey = JSON.stringify(selectedIds);
const currentSelectionRef = useRef(selectionKey);
currentSelectionRef.current = selectionKey;

// Compute tag states from selected recordings
// The catalog is independent of the recording snapshot. Loading more names
// must not overwrite a successful local edit while the parent refetches rows.
useEffect(() => {
recordingsAPI.getAllRecordingTags().then(setAllTags);
}, []);

useEffect(() => {
const states = {};
// Gather all tags from selected recordings
const allUsedTags = new Set();
selectedRecs.forEach(r => (r.tags || []).forEach(t => allUsedTags.add(t)));
allTags.forEach(t => allUsedTags.add(t));
setTagStates(prev => {
const states = { ...prev };
allTags.forEach(tag => {
if (!Object.hasOwn(states, tag)) states[tag] = 'none';
});
return states;
});
}, [allTags]);

allUsedTags.forEach(tag => {
const count = selectedRecs.filter(r => (r.tags || []).includes(tag)).length;
if (count === 0) states[tag] = 'none';
else if (count === selectedRecs.length) states[tag] = 'all';
else states[tag] = 'some';
// A new recording snapshot is authoritative, including same-count refetches.
// Use selected IDs rather than their count so replacing a selection reconciles
// correctly without rerunning on each newly allocated selection object.
useEffect(() => {
const ids = JSON.parse(selectionKey);
const selectedRecs = recordings.filter(recording => ids.includes(recording.id));
setTagStates(prev => {
const states = {};
const knownTags = new Set(Object.keys(prev));
selectedRecs.forEach(recording => (recording.tags || []).forEach(tag => knownTags.add(tag)));
knownTags.forEach(tag => {
const count = selectedRecs.filter(recording => (recording.tags || []).includes(tag)).length;
states[tag] = count === 0 ? 'none' : count === selectedRecs.length ? 'all' : 'some';
});
return states;
});
setTagStates(states);
}, [allTags, selectedRecs.length]);
}, [recordings, selectionKey]);

// Close on outside click
useEffect(() => {
Expand All @@ -225,34 +240,40 @@ export function BulkTagsOverlay({ recordings, selectedRecordings, onClose, onTag
if (current === 'all') {
// Remove from all
await recordingsAPI.batchUpdateRecordingTags(selectedIds, [], [tag]);
setTagStates(prev => ({ ...prev, [tag]: 'none' }));
if (currentSelectionRef.current === selectionKey) {
setTagStates(prev => ({ ...prev, [tag]: 'none' }));
}
showStatusMessage(t('recordings.tagRemovedFromRecordings', { tag, count: selectedIds.length }));
} else {
// 'none' or 'some' -> add to all
await recordingsAPI.batchUpdateRecordingTags(selectedIds, [tag], []);
setTagStates(prev => ({ ...prev, [tag]: 'all' }));
if (currentSelectionRef.current === selectionKey) {
setTagStates(prev => ({ ...prev, [tag]: 'all' }));
}
showStatusMessage(t('recordings.tagAddedToRecordings', { tag, count: selectedIds.length }));
}
if (onTagsChanged) onTagsChanged();
} catch { /* error shown by API */ }
setSaving(false);
}, [tagStates, selectedIds, onTagsChanged]);
}, [tagStates, selectedIds, selectionKey, onTagsChanged]);

const addNewTag = useCallback(async (tag) => {
const trimmed = tag.trim();
if (!trimmed) return;
setSaving(true);
try {
await recordingsAPI.batchUpdateRecordingTags(selectedIds, [trimmed], []);
setTagStates(prev => ({ ...prev, [trimmed]: 'all' }));
if (currentSelectionRef.current === selectionKey) {
setTagStates(prev => ({ ...prev, [trimmed]: 'all' }));
}
if (!allTags.includes(trimmed)) setAllTags(prev => [...prev, trimmed].sort());
setInputValue('');
setShowSuggestions(false);
showStatusMessage(t('recordings.tagAddedToRecordings', { tag: trimmed, count: selectedIds.length }));
if (onTagsChanged) onTagsChanged();
} catch { /* error shown by API */ }
setSaving(false);
}, [selectedIds, allTags, onTagsChanged]);
}, [selectedIds, selectionKey, allTags, onTagsChanged]);

const handleKeyDown = (e) => {
if (e.key === 'Enter' && inputValue.trim()) { e.preventDefault(); addNewTag(inputValue); }
Expand Down
Loading
Loading