diff --git a/CHANGELOG.md b/CHANGELOG.md index 275e752..e7df353 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,14 +7,14 @@ version tags such as `v0.3.0`. ## Unreleased -- No unreleased changes. +- Add collection-level Import Review selection that selects or clears every eligible child in one collection without changing blocked, imported, system, conflicted, or unresolved-type children. ## 0.9.0 - Add one-fetch GitHub repository collection preview/apply with explicit child selection, stale ref/tree validation, and per-skill managed provenance. This - Phase C change targets v0.9.0 and remains unreleased pending v0.9.0 release - qualification; collection-level update/rollback remains planned. + Phase C change shipped in v0.9.0; collection-level update/rollback remains + planned. - Distinguish the shipped v0.8.0 local A+B work from the v0.9.0-targeted Phase C implementation in the public roadmap and implementation status. - Add one-fetch GitHub multi-skill collection preview/apply for repository and tree URLs. diff --git a/apps/desktop/src/App.import-candidates.test.js b/apps/desktop/src/App.import-candidates.test.js index 37902cb..95e9d4f 100644 --- a/apps/desktop/src/App.import-candidates.test.js +++ b/apps/desktop/src/App.import-candidates.test.js @@ -6,6 +6,8 @@ import { filterImportCandidatesByQuery, filterWorkspaceSkillCandidates, collectionChildTypeState, + collectionEligibleGroupIds, + collectionSelectionState, collectionSkillCountLabel, importCandidateGroupLocationCount, importCandidateGroupTabs, @@ -18,6 +20,7 @@ import { selectedImportCollectionRequests, selectImportCandidateVariant, toggleImportCandidateGroupSelection, + toggleImportCollectionSelection, updateImportCandidateGroupType, visibleImportCandidates, workspaceSkillTabs @@ -446,6 +449,138 @@ test('normalizes Git-backed collection children and submits one selected child r }]); }); +test('toggles every eligible child in one complete collection without changing blocked or unrelated groups', () => { + const groups = normalizeImportCandidateGroups([ + { + id: 'skill-alpha', + name: 'alpha', + selected_variant_id: 'variant-alpha', + variants: [{ + id: 'variant-alpha', + candidate: { name: 'alpha', import_status: 'importable', is_selected: false }, + selected_type: 'user' + }] + }, + { + id: 'skill-beta', + name: 'beta', + selected_variant_id: 'variant-beta', + variants: [{ + id: 'variant-beta', + candidate: { name: 'beta', import_status: 'importable', is_selected: true }, + selected_type: 'remote' + }] + }, + { + id: 'skill-needs-type', + name: 'needs-type', + selected_variant_id: 'variant-needs-type', + variants: [{ + id: 'variant-needs-type', + candidate: { name: 'needs-type', import_status: 'importable', is_selected: false }, + requires_type_review: true, + selected_type: null + }] + }, + { + id: 'skill-conflict', + name: 'conflict', + selected_variant_id: 'variant-conflict', + variants: [{ + id: 'variant-conflict', + candidate: { name: 'conflict', import_status: 'importable', conflict: 'managed target', is_selected: false }, + selected_type: 'user' + }] + }, + { + id: 'skill-imported', + name: 'imported', + selected_variant_id: 'variant-imported', + variants: [{ + id: 'variant-imported', + candidate: { name: 'imported', import_status: 'imported', is_selected: false }, + selected_type: 'remote' + }] + }, + { + id: 'skill-other-collection', + name: 'other', + selected_variant_id: 'variant-other', + variants: [{ + id: 'variant-other', + candidate: { name: 'other', import_status: 'importable', is_selected: false }, + selected_type: 'user' + }] + } + ]); + const collection = normalizeImportCollections([{ + id: 'collection-one', + source_kind: 'git_worktree', + children: [ + { group_id: 'skill-alpha', variant_id: 'variant-alpha', import_status: 'importable' }, + { group_id: 'skill-beta', variant_id: 'variant-beta', import_status: 'importable' }, + { group_id: 'skill-needs-type', variant_id: 'variant-needs-type', import_status: 'importable', requires_type_review: true }, + { group_id: 'skill-conflict', variant_id: 'variant-conflict', import_status: 'importable', conflict: 'managed target' }, + { group_id: 'skill-imported', variant_id: 'variant-imported', import_status: 'imported' } + ] + }])[0]; + + assert.deepEqual([...collectionEligibleGroupIds(groups, collection)], ['skill-alpha', 'skill-beta']); + assert.deepEqual(collectionSelectionState(groups, collection), { + eligibleGroupIds: new Set(['skill-alpha', 'skill-beta']), + eligibleCount: 2, + selectedCount: 1, + allSelected: false, + indeterminate: true + }); + + const selected = toggleImportCollectionSelection(groups, collection); + assert.deepEqual(selected.map((group) => group.isSelected), [true, true, false, false, false, false]); + assert.deepEqual(collectionSelectionState(selected, collection), { + eligibleGroupIds: new Set(['skill-alpha', 'skill-beta']), + eligibleCount: 2, + selectedCount: 2, + allSelected: true, + indeterminate: false + }); + + const unselected = toggleImportCollectionSelection(selected, collection); + assert.deepEqual(unselected.map((group) => group.isSelected), [false, false, false, false, false, false]); +}); + +test('collection selection uses the complete collection rather than a filtered child subset', () => { + const groups = normalizeImportCandidateGroups([ + { + id: 'skill-visible', + selected_variant_id: 'variant-visible', + variants: [{ id: 'variant-visible', candidate: { name: 'visible', import_status: 'importable', is_selected: false }, selected_type: 'user' }] + }, + { + id: 'skill-hidden-by-filter', + selected_variant_id: 'variant-hidden', + variants: [{ id: 'variant-hidden', candidate: { name: 'hidden', import_status: 'importable', is_selected: false }, selected_type: 'remote' }] + }, + { + id: 'skill-standalone', + selected_variant_id: 'variant-standalone', + variants: [{ id: 'variant-standalone', candidate: { name: 'standalone', import_status: 'importable', is_selected: false }, selected_type: 'user' }] + } + ]); + const completeCollection = normalizeImportCollections([{ + id: 'collection-filtered', + children: [ + { group_id: 'skill-visible', variant_id: 'variant-visible', import_status: 'importable' }, + { group_id: 'skill-hidden-by-filter', variant_id: 'variant-hidden', import_status: 'importable' } + ] + }])[0]; + const visibleSubset = { ...completeCollection, children: [completeCollection.children[0]] }; + + assert.equal(visibleSubset.children.length, 1); + const toggled = toggleImportCollectionSelection(groups, completeCollection); + assert.deepEqual(toggled.map((group) => group.isSelected), [true, true, false]); + assert.equal(collectionSelectionState(toggled, completeCollection).selectedCount, 2); +}); + test('normalizes a GitHub collection without inventing a local worktree root', () => { const collections = normalizeImportCollections([{ id: 'github-collection-demo', diff --git a/apps/desktop/src/App.jsx b/apps/desktop/src/App.jsx index 9f09db7..89cfe54 100644 --- a/apps/desktop/src/App.jsx +++ b/apps/desktop/src/App.jsx @@ -55,6 +55,7 @@ import { selectedImportCollectionRequests, selectedImportCandidates, selectImportCandidateVariant, + toggleImportCollectionSelection, toggleImportCandidateGroup, toggleImportCandidateGroupSelection, updateImportCandidateGroupType @@ -4530,6 +4531,12 @@ export default function App() { selectImportCandidateVariant(groups, group.id, variant.id) ) } + onToggleCollectionSelected={(collection) => + setImportReview((current) => ({ + ...current, + candidates: toggleImportCollectionSelection(current.candidates, collection) + })) + } onToggleSelected={(group) => updateImportCandidateGroup(group.id, (groups) => toggleImportCandidateGroup(groups, group.id)) } diff --git a/apps/desktop/src/cardLayout.test.js b/apps/desktop/src/cardLayout.test.js index 4a54252..6b29b09 100644 --- a/apps/desktop/src/cardLayout.test.js +++ b/apps/desktop/src/cardLayout.test.js @@ -548,11 +548,27 @@ test('collection review keeps child selection and type controls inside one expan const collectionSource = appSource.match( /function CollectionReviewCard\(\{(?
[\s\S]*?)\n\}\n\nfunction WorkspaceSkillTabs/ )?.groups.body || ''; + const collectionCheckboxSource = appSource.match( + /function CollectionSelectionCheckbox\(\{(?[\s\S]*?)\n\}\n\nfunction CollectionReviewCard/ + )?.groups.body || ''; + const candidateReviewListSource = appSource.match( + /function CandidateReviewList\(\{(?[\s\S]*?)\n\}\n\nfunction CollectionSelectionCheckbox/ + )?.groups.body || ''; const collectionRule = css.match(/\.collectionReviewCard\s*\{(?[^}]*)\}/s)?.groups.body || ''; const childRule = css.match(/\.collectionChildRow\s*\{(?[^}]*)\}/s)?.groups.body || ''; + const collectionActionsRule = css.match(/\.collectionReviewActions\s*\{(?[^}]*)\}/s)?.groups.body || ''; assert.match(collectionSource, /aria-controls={disclosureId}/); assert.match(collectionSource, /collection\.children\.map/); + assert.match(collectionSource, /collectionSelectionState\(groups, selectionCollection\)/); + assert.match(collectionSource, /