From 351d1a6dbd371a141b4ee02487105fd91bda9456 Mon Sep 17 00:00:00 2001 From: Santos Date: Wed, 19 Aug 2026 22:47:31 +0800 Subject: [PATCH] feat(import): add collection select all --- CHANGELOG.md | 6 +- .../desktop/src/App.import-candidates.test.js | 135 ++++++++++++++++++ apps/desktop/src/App.jsx | 7 + apps/desktop/src/cardLayout.test.js | 18 +++ apps/desktop/src/components/importReview.jsx | 78 ++++++++-- apps/desktop/src/importCandidates.js | 44 ++++++ apps/desktop/src/styles.css | 29 ++++ docs/workflows.md | 2 + 8 files changed, 308 insertions(+), 11 deletions(-) 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, / ) : null} @@ -316,7 +320,15 @@ function ImportScanProgress({ progress }) { ); } -function CandidateReviewList({ collections, groups, onSelectVariant, onToggleSelected, onTypeChange }) { +function CandidateReviewList({ + collections, + groups, + onSelectVariant, + onToggleCollectionSelected, + onToggleSelected, + onTypeChange, + selectionDisabled +}) { const [activeTab, setActiveTab] = useState('all'); const [searchQuery, setSearchQuery] = useState(''); const searchedGroups = filterImportCandidateGroupsByQuery(groups, searchQuery); @@ -366,8 +378,11 @@ function CandidateReviewList({ collections, groups, onSelectVariant, onToggleSel groups={groups} key={collection.id} onSelectVariant={onSelectVariant} + onToggleCollectionSelected={onToggleCollectionSelected} onToggleSelected={onToggleSelected} onTypeChange={onTypeChange} + selectionCollection={collections.find((candidate) => candidate.id === collection.id) || collection} + selectionDisabled={selectionDisabled} /> ))} {standaloneGroups.length > 0 ? ( @@ -390,21 +405,54 @@ function CandidateReviewList({ collections, groups, onSelectVariant, onToggleSel ); } +function CollectionSelectionCheckbox({ + allSelected, + ariaDescribedBy, + collectionName, + disabled, + indeterminate, + onChange +}) { + const checkboxRef = useRef(null); + + useEffect(() => { + if (checkboxRef.current) { + checkboxRef.current.indeterminate = indeterminate; + } + }, [indeterminate]); + + return ( + + ); +} + function CollectionReviewCard({ collection, groups, onSelectVariant, + onToggleCollectionSelected, onToggleSelected, - onTypeChange + onTypeChange, + selectionCollection, + selectionDisabled }) { const [expanded, setExpanded] = useState(false); const isInstalledSource = collection.sourceKind === 'installed_source'; const isGithubRemote = collection.sourceKind === 'github_remote'; const disclosureId = `${collection.id}-children`; - const selectedCount = collection.children.filter((child) => { - const group = groups.find((candidateGroup) => candidateGroup.id === child.groupId); - return group?.isSelected && group.selectedVariantId === child.variantId; - }).length; + const selectionSummaryId = `${collection.id}-selection-summary`; + const selectionState = collectionSelectionState(groups, selectionCollection); const shortSha = collection.reviewedHeadSha ? collection.reviewedHeadSha.slice(0, 8) : 'uncommitted'; return ( @@ -437,7 +485,21 @@ function CollectionReviewCard({ )} - {selectedCount} selected +
+ onToggleCollectionSelected(selectionCollection)} + /> + + {selectionState.eligibleCount === 0 + ? 'No eligible skills' + : `${selectionState.selectedCount} of ${selectionState.eligibleCount} eligible selected`} + +