diff --git a/app/app.vue b/app/app.vue index 738d278529..085bb586c0 100644 --- a/app/app.vue +++ b/app/app.vue @@ -161,6 +161,7 @@ if (!isBlogPostRoute.value) { + diff --git a/app/components/Collection/AddButton.vue b/app/components/Collection/AddButton.vue new file mode 100644 index 0000000000..31ba695864 --- /dev/null +++ b/app/components/Collection/AddButton.vue @@ -0,0 +1,13 @@ + + + diff --git a/app/components/Collection/AddToModal.client.vue b/app/components/Collection/AddToModal.client.vue new file mode 100644 index 0000000000..9e34bed3b6 --- /dev/null +++ b/app/components/Collection/AddToModal.client.vue @@ -0,0 +1,24 @@ + + + diff --git a/app/components/Collection/Picker.vue b/app/components/Collection/Picker.vue new file mode 100644 index 0000000000..0ad670afb7 --- /dev/null +++ b/app/components/Collection/Picker.vue @@ -0,0 +1,165 @@ + + + diff --git a/app/components/Package/ActionBar.vue b/app/components/Package/ActionBar.vue index 15b447d100..a5bd742bbb 100644 --- a/app/components/Package/ActionBar.vue +++ b/app/components/Package/ActionBar.vue @@ -54,13 +54,16 @@ onKeyStroke( - - {{ $t('package.links.compare') }} - +
+ + {{ $t('package.links.compare') }} + + +
diff --git a/app/components/Package/Header.vue b/app/components/Package/Header.vue index 256c5d6f4f..0274e776c9 100644 --- a/app/components/Package/Header.vue +++ b/app/components/Package/Header.vue @@ -250,6 +250,9 @@ useShortcuts({
+ + {{ $t('collections.picker.title') }} + {{ $t('package.links.compare') }} +
diff --git a/app/composables/useAddToCollection.ts b/app/composables/useAddToCollection.ts new file mode 100644 index 0000000000..f582da054b --- /dev/null +++ b/app/composables/useAddToCollection.ts @@ -0,0 +1,23 @@ +export const ADD_TO_COLLECTION_MODAL_ID = 'add-to-collection-modal' + +/** Opens the add-to-collection picker for some packages, or sign-in when signed out. */ +export function useAddToCollection() { + const packages = useState('add-to-collection:packages', () => []) + // Remounts the picker on every open so it starts from fresh collections + const openCount = useState('add-to-collection:open-count', () => 0) + const { user } = useAtproto() + const authModal = useModal('auth-modal') + const modal = useModal(ADD_TO_COLLECTION_MODAL_ID) + + function open(packageNames: string[]) { + if (!user.value?.handle) { + authModal.open() + return + } + packages.value = packageNames + openCount.value++ + modal.open() + } + + return { packages, openCount, open } +} diff --git a/app/composables/useCommandPalettePackageCommands.ts b/app/composables/useCommandPalettePackageCommands.ts index 78ae3a62e0..df23d94566 100644 --- a/app/composables/useCommandPalettePackageCommands.ts +++ b/app/composables/useCommandPalettePackageCommands.ts @@ -16,6 +16,7 @@ export function useCommandPalettePackageCommands( const { t } = useI18n() const route = useRoute() const { announce, close } = useCommandPalette() + const { open: openAddToCollection } = useAddToCollection() function isCurrentPackageCompare(packageName: string) { const packages = route.query.packages @@ -143,6 +144,18 @@ export function useCommandPalettePackageCommands( }, }) + commands.push({ + id: 'package-add-to-collection', + group: 'package', + label: t('command_palette.package.add_to_collection'), + keywords: [resolvedContext.packageName, t('collections.title')], + iconClass: 'i-lucide:bookmark-plus', + action: () => { + close() + openAddToCollection([resolvedContext.packageName]) + }, + }) + if (resolvedContext.tarballUrl) { commands.push({ id: 'package-download', diff --git a/app/composables/useOwnCollections.ts b/app/composables/useOwnCollections.ts index d987159ea0..ae05760cf2 100644 --- a/app/composables/useOwnCollections.ts +++ b/app/composables/useOwnCollections.ts @@ -1,7 +1,11 @@ import type { OwnCollection } from '#shared/types/social' -import { fetchOwnCollections } from '~/utils/atproto/collections' +import { + changeCollectionItems, + createCollection, + fetchOwnCollections, +} from '~/utils/atproto/collections' -/** The signed-in user's collections, for finding them from the command palette. */ +/** The signed-in user's collections, shared by the add-to-collection picker and the palette. */ export function useOwnCollections() { const { user } = useAtproto() const state = useState<{ did: string; items: OwnCollection[] } | null>( @@ -27,5 +31,30 @@ export function useOwnCollections() { } } - return { collections, status, load } + async function changeItems( + collection: OwnCollection, + change: { add?: string[]; remove?: string[] }, + ) { + const { subjectRefs, cid } = await changeCollectionItems( + collection.did, + collection.rkey, + change, + user.value?.handle, + ) + // The server's answer is the record as written, so it can be applied as is + const items = state.value?.items.map(c => + c.uri === collection.uri ? { ...c, cid, subjectRefs, itemCount: subjectRefs.length } : c, + ) + if (state.value && items) state.value = { ...state.value, items } + } + + async function create(name: string, subjectRefs: string[]) { + await createCollection( + { name, items: subjectRefs.map(subjectRef => ({ subjectRef })) }, + user.value?.handle, + ) + await load({ force: true }) + } + + return { collections, status, load, changeItems, create } } diff --git a/app/utils/atproto/collections.ts b/app/utils/atproto/collections.ts index 70232ee41c..7534dbace2 100644 --- a/app/utils/atproto/collections.ts +++ b/app/utils/atproto/collections.ts @@ -105,6 +105,22 @@ export function fetchOwnCollections() { return $fetch('/api/social/collections/mine') } +export function changeCollectionItems( + identifier: string, + rkey: string, + change: { add?: string[]; remove?: string[] }, + userHandle?: string | null, +) { + return request( + () => + $fetch( + `${collectionPath(identifier, rkey)}/items`, + { method: 'POST', body: change }, + ), + userHandle, + ) +} + export function fetchUserCollections(identifier: string, cursor?: string | null, limit = 20) { return $fetch<{ cursor?: string; items: CollectionSummary[] }>( `/api/social/collections/${encodeURIComponent(identifier)}`, diff --git a/docs/content/2.guide/1.features.md b/docs/content/2.guide/1.features.md index 45712670a6..020a5a3764 100644 --- a/docs/content/2.guide/1.features.md +++ b/docs/content/2.guide/1.features.md @@ -105,6 +105,7 @@ npmx.dev fetches repository statistics (stars, forks) from these git hosting pla Collections are curated, ordered lists of npm packages, with an optional note on each one. Anyone with an Atmosphere account can make them, and they're stored in that account, not on npmx. See [Collections](/integrations/collections) for building with them from other apps. - **Create and edit** - Name a collection, describe it, and add, reorder, annotate, or remove up to 1,000 items +- **Add from anywhere** - Add a package to one or more collections, or start a new one, from its package page, from a selection of search results, or with **Add to collection** in the command palette - **Include other collections** - Add a collection's link to another collection to show it as a linked entry with its author and size - **Duplicate** - Start a new collection from a copy of any collection - **Like and follow** - Like a collection to recommend it, or follow it to keep up with it. Your own collections count as followed diff --git a/docs/content/2.guide/2.keyboard-shortcuts.md b/docs/content/2.guide/2.keyboard-shortcuts.md index ddc9e62571..349f7944f5 100644 --- a/docs/content/2.guide/2.keyboard-shortcuts.md +++ b/docs/content/2.guide/2.keyboard-shortcuts.md @@ -15,7 +15,7 @@ The command palette lets you: - Jump between major pages like home, compare, settings, and docs - Open package-specific surfaces like the main page, docs, code, and diff views -- Find your own collections by name, and like, follow, edit or duplicate the collection you're viewing +- Find your own collections by name, add the current package to a collection, and like, follow, edit or duplicate the collection you're viewing - Switch settings such as language, accent color, and background theme - Open external resources like docs, chat, source, and package links diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 58c871a649..5da0e6d2fb 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -167,7 +167,8 @@ "compare": "Compare this package", "download": "Download tarball", "changelog": "Changelog", - "stats": "Stats" + "stats": "Stats", + "add_to_collection": "Add to collection" }, "code": { "copy_file": "Copy file contents" @@ -492,6 +493,18 @@ "load_failed": "Couldn't load this collection", "load_failed_message": "The server that stores it didn't respond. Try again in a moment.", "sign_in_to_create": "Connect your Atmosphere account to create a collection.", + "picker": { + "title": "Add to collection", + "packages": "{count} package | {count} packages", + "none_yet": "You don't have any collections yet. Name one below to start.", + "full": "Full", + "full_reason": "A collection holds up to {max} items.", + "save_error": "Couldn't update this collection. Please try again.", + "create_error": "Couldn't create the collection. Please try again.", + "name_placeholder": "Name", + "create": "Create", + "created": "Created {name}" + }, "editor": { "public_notice": "Collections are saved to your Atmosphere account and are public. Anyone can see them, in npmx or any other app.", "name": "Name", diff --git a/i18n/locales/fr-FR.json b/i18n/locales/fr-FR.json index 16d8b40e89..acd6106674 100644 --- a/i18n/locales/fr-FR.json +++ b/i18n/locales/fr-FR.json @@ -155,7 +155,8 @@ "diff": "Diff", "compare": "Comparer ce paquet", "download": "Télécharger le tarball", - "changelog": "Changements" + "changelog": "Changements", + "add_to_collection": "Ajouter à une collection" }, "code": { "copy_file": "Copier le contenu du fichier" @@ -398,6 +399,18 @@ "load_failed": "Impossible de charger cette collection", "load_failed_message": "Le serveur qui la stocke n'a pas répondu. Réessayez dans un instant.", "sign_in_to_create": "Connectez votre compte Atmosphere pour créer une collection.", + "picker": { + "title": "Ajouter à une collection", + "packages": "{count} paquet | {count} paquets", + "none_yet": "Vous n'avez encore aucune collection. Donnez un nom ci-dessous pour en créer une.", + "full": "Pleine", + "full_reason": "Une collection peut contenir jusqu'à {max} éléments.", + "save_error": "Impossible de mettre à jour cette collection. Veuillez réessayer.", + "create_error": "Impossible de créer la collection. Veuillez réessayer.", + "name_placeholder": "Nom", + "create": "Créer", + "created": "{name} créée" + }, "editor": { "public_notice": "Les collections sont enregistrées dans votre compte Atmosphere et sont publiques. Tout le monde peut les voir, sur npmx ou dans n'importe quelle autre application.", "name": "Nom", diff --git a/i18n/schema.json b/i18n/schema.json index 9d7d991039..d45b167a3e 100644 --- a/i18n/schema.json +++ b/i18n/schema.json @@ -507,6 +507,9 @@ }, "stats": { "type": "string" + }, + "add_to_collection": { + "type": "string" } }, "additionalProperties": false @@ -1480,6 +1483,42 @@ "sign_in_to_create": { "type": "string" }, + "picker": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "packages": { + "type": "string" + }, + "none_yet": { + "type": "string" + }, + "full": { + "type": "string" + }, + "full_reason": { + "type": "string" + }, + "save_error": { + "type": "string" + }, + "create_error": { + "type": "string" + }, + "name_placeholder": { + "type": "string" + }, + "create": { + "type": "string" + }, + "created": { + "type": "string" + } + }, + "additionalProperties": false + }, "editor": { "type": "object", "properties": { diff --git a/server/api/social/collections/[identifier]/[rkey]/items.post.ts b/server/api/social/collections/[identifier]/[rkey]/items.post.ts new file mode 100644 index 0000000000..010463464e --- /dev/null +++ b/server/api/social/collections/[identifier]/[rkey]/items.post.ts @@ -0,0 +1,15 @@ +import * as v from 'valibot' +import { CollectionItemsChangeSchema } from '#shared/schemas/social' + +/** + * POST /api/social/collections/:identifier/:rkey/items + * + * Adds and removes items in a collection owned by the logged-in user. + */ +export default eventHandlerWithOAuthSession(async (event, session) => + changeCollectionItems( + session, + getCollectionRouteParams(event), + v.parse(CollectionItemsChangeSchema, await readBody(event)), + ), +) diff --git a/server/utils/atproto/utils/collection-writes.ts b/server/utils/atproto/utils/collection-writes.ts index 7d03b1f895..193d2f2bba 100644 --- a/server/utils/atproto/utils/collection-writes.ts +++ b/server/utils/atproto/utils/collection-writes.ts @@ -15,6 +15,7 @@ import { COLLECTIONS_SCOPE, FOLLOWS_SCOPE, LIKES_SCOPE, + MAX_COLLECTION_ITEMS, } from '#shared/utils/constants' import { throwOnMissingOAuthScope } from '#server/utils/atproto/oauth' import type { Reaction } from './collections' @@ -24,8 +25,12 @@ import type { Reaction } from './collections' type CollectionBody = InferOutput type CollectionRef = { identifier: string; rkey: string } type RawRecord = { cid: string; value: unknown } +type Item = { subjectRef: string; note?: string } export type CollectionWriteResult = { uri: string; cid: string; did: string; rkey: string } +// Quick toggles in the picker race each other; a lost swap is retried on the fresh record. +const ITEM_CHANGE_ATTEMPTS = 3 + export function requireOAuthSession(oAuthSession: OAuthSession | undefined) { const did = oAuthSession?.did.toString() if (!oAuthSession || !did) { @@ -89,6 +94,21 @@ export function buildCollectionRecord(body: CollectionBody, existing?: unknown) } type CollectionRecordValue = ReturnType +/** Drops `remove`, then appends whatever of `add` isn't already there. Existing items are kept as is. */ +export function applyItemChanges( + items: T[], + add: string[], + remove: string[], +): (T | Item)[] { + const removed = new Set(remove) + const kept = items.filter(item => !removed.has(item.subjectRef)) + const present = new Set(kept.map(item => item.subjectRef)) + const added = [...new Set(add)] + .filter(ref => !present.has(ref)) + .map(subjectRef => ({ subjectRef })) + return [...kept, ...added] +} + const isInvalidSwapError = (error: unknown) => (error as { error?: unknown } | null)?.error === 'InvalidSwap' @@ -109,25 +129,31 @@ async function afterWrite( /** * Read-modify-write against the PDS, with `swapRecord` so a concurrent edit is never clobbered. - * `build` may throw to reject the current record. + * `build` may throw to reject the current record; a lost swap re-runs it on the fresh one. */ async function editCollection( client: Client, owner: MiniDoc, rkey: string, build: (existing: RawRecord) => CollectionRecordValue, + maxAttempts = 1, ) { - const existing = await new CollectionsUtils().fetchRawRecord(owner, rkey) - if (!existing) throw createError({ status: 404, message: 'Collection not found' }) - const value = build(existing) - try { - const result = await client.put(dev.npmx.collection, value, { - rkey, - swapRecord: existing.cid, - }) - return await afterWrite(owner.did, rkey, result.cid, value) - } catch (error) { - throw isInvalidSwapError(error) ? conflict() : error + const utils = new CollectionsUtils() + for (let attempt = 1; ; attempt++) { + const existing = await utils.fetchRawRecord(owner, rkey) + if (!existing) throw createError({ status: 404, message: 'Collection not found' }) + const value = build(existing) + try { + const result = await client.put(dev.npmx.collection, value, { + rkey, + swapRecord: existing.cid, + }) + return { ...(await afterWrite(owner.did, rkey, result.cid, value)), value } + } catch (error) { + if (!isInvalidSwapError(error) || attempt >= maxAttempts) { + throw isInvalidSwapError(error) ? conflict() : error + } + } } } @@ -146,10 +172,45 @@ export async function updateCollection( ) { const { client, did } = await getWriter(session, COLLECTIONS_SCOPE) const owner = await requireOwnCollection(ref, did) - return await editCollection(client, owner, ref.rkey, existing => { + const { value: _, ...result } = await editCollection(client, owner, ref.rkey, existing => { if (existing.cid !== cid) throw conflict() return buildCollectionRecord(body, existing.value) }) + return result +} + +/** Adds and removes items without the caller sending the whole collection. */ +export async function changeCollectionItems( + session: OAuthSession | undefined, + ref: CollectionRef, + { add, remove }: { add: string[]; remove: string[] }, +) { + const { client, did } = await getWriter(session, COLLECTIONS_SCOPE) + const owner = await requireOwnCollection(ref, did) + const { value, ...result } = await editCollection( + client, + owner, + ref.rkey, + existing => { + const current = dev.npmx.collection.$safeValidate(existing.value) + if (!current.success) { + throw createError({ status: 422, message: 'Collection record is not valid' }) + } + // The raw items, not the validated copies, so fields other apps put on items survive + const rawItems = (existing.value as { items: dev.npmx.collection.Item[] }).items + const items = applyItemChanges(rawItems, add, remove) + if (items.length > MAX_COLLECTION_ITEMS) { + throw createError({ status: 400, message: 'Collection is full' }) + } + const { name, description } = current.value + return { + ...buildCollectionRecord({ name, description, items: [] }, existing.value), + items: items as dev.npmx.collection.Item[], + } + }, + ITEM_CHANGE_ATTEMPTS, + ) + return { ...result, subjectRefs: value.items.map(item => item.subjectRef) } } export async function deleteCollection(session: OAuthSession | undefined, ref: CollectionRef) { diff --git a/shared/schemas/social.ts b/shared/schemas/social.ts index e30742454f..06ddf13b18 100644 --- a/shared/schemas/social.ts +++ b/shared/schemas/social.ts @@ -49,6 +49,18 @@ export const CollectionUpdateBodySchema = v.object({ cid: v.pipe(v.string(), v.nonEmpty()), }) +/** Adds and removes items without sending the whole collection, as the picker does. */ +export const CollectionItemsChangeSchema = v.pipe( + v.object({ + add: v.optional( + v.pipe(v.array(CollectionItemSchema.entries.subjectRef), v.maxLength(MAX_COLLECTION_ITEMS)), + [], + ), + remove: v.optional(v.pipe(v.array(v.string()), v.maxLength(MAX_COLLECTION_ITEMS)), []), + }), + v.check(({ add, remove }) => !add.some(ref => remove.includes(ref)), 'Add and remove overlap'), +) + // TODO: add 'avatar' export const ProfileEditBodySchema = v.object({ displayName: v.pipe(v.string(), v.maxLength(640)), diff --git a/shared/types/social.ts b/shared/types/social.ts index 0f26d738df..9dcade25f4 100644 --- a/shared/types/social.ts +++ b/shared/types/social.ts @@ -66,7 +66,7 @@ export type CollectionItemView = note?: string } -/** One of the viewer's own collections, with what's in it. */ +/** One of the viewer's own collections, with what's in it, for the add-to-collection picker. */ export type OwnCollection = CollectionSummary & { subjectRefs: string[] } /** Likes or follows of a collection, counted once per account. */ diff --git a/test/nuxt/a11y.spec.ts b/test/nuxt/a11y.spec.ts index 701396b05a..7659ebcfa1 100644 --- a/test/nuxt/a11y.spec.ts +++ b/test/nuxt/a11y.spec.ts @@ -362,6 +362,8 @@ import PackageTimelineChart from '~/components/Package/TimelineChart.vue' import PackageLikeCard from '~/components/Package/LikeCard.vue' import CollectionCard from '~/components/Collection/Card.vue' import CollectionGrid from '~/components/Collection/Grid.vue' +import CollectionPicker from '~/components/Collection/Picker.vue' +import CollectionAddButton from '~/components/Collection/AddButton.vue' import CollectionEditor from '~/components/Collection/Editor.vue' import PackageTypeahead from '~/components/Package/Typeahead.vue' import CollectionItemList from '~/components/Collection/ItemList.vue' @@ -1338,6 +1340,32 @@ describe('component accessibility audits', () => { expect(results.violations).toEqual([]) }) + it('CollectionPicker should have no accessibility violations', async () => { + registerEndpoint('/api/auth/session', () => ({ did: summary.did, handle: summary.handle })) + registerEndpoint('/api/social/collections/mine', () => [ + { ...summary, subjectRefs: ['https://npmx.dev/package/zod'] }, + ]) + // The session is fetched once per app and was already loaded signed out + await refreshNuxtData() + try { + const component = await mountSuspended(CollectionPicker, { props: { packages: ['zod'] } }) + // Audit the loaded list, not just the form shown before it arrives + await vi.waitFor(() => expect(component.find('input[type="checkbox"]').exists()).toBe(true)) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + } finally { + // Later audits expect a signed-out visitor + registerEndpoint('/api/auth/session', () => null) + await refreshNuxtData() + } + }) + + it('CollectionAddButton should have no accessibility violations', async () => { + const component = await mountSuspended(CollectionAddButton, { props: { packages: ['zod'] } }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + it('CollectionItemList should have no accessibility violations', async () => { const component = await mountSuspended(CollectionItemList, { props: { items } }) const results = await runAxe(component) diff --git a/test/nuxt/components/Collection/Picker.spec.ts b/test/nuxt/components/Collection/Picker.spec.ts new file mode 100644 index 0000000000..3edbf09a17 --- /dev/null +++ b/test/nuxt/components/Collection/Picker.spec.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi } from 'vite-plus/test' +import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime' +import type { OwnCollection } from '#shared/types/social' +import { MAX_COLLECTION_ITEMS, PACKAGE_SUBJECT_REF } from '#shared/utils/constants' +import CollectionPicker from '~/components/Collection/Picker.vue' + +const DID = 'did:plc:alice' +const ZOD = PACKAGE_SUBJECT_REF('zod') + +const own = (rkey: string, name: string, subjectRefs: string[]): OwnCollection => ({ + uri: `at://${DID}/dev.npmx.collection/${rkey}`, + cid: `cid-${rkey}`, + did: DID, + handle: 'alice.test', + rkey, + name, + itemCount: subjectRefs.length, + createdAt: '2026-01-01T00:00:00.000Z', + subjectRefs, +}) + +let collections: OwnCollection[] = [] +const changeItems = vi.fn() +const create = vi.fn() + +registerEndpoint('/api/auth/session', () => ({ did: DID, handle: 'alice.test' })) +registerEndpoint('/api/social/collections/mine', () => collections) +registerEndpoint(`/api/social/collections/${DID}/b/items`, { + method: 'POST', + handler: changeItems, +}) +registerEndpoint('/api/social/collections', { method: 'POST', handler: create }) + +async function mountPicker(packages: string[]) { + const component = await mountSuspended(CollectionPicker, { props: { packages } }) + await vi.waitFor(() => { + for (const { name } of collections) expect(component.text()).toContain(name) + }) + return component +} + +const checkbox = (component: Awaited>, name: string) => { + const label = component.findAll('label').find(l => l.text().includes(name))! + return label.find('input[type="checkbox"]') +} + +describe('CollectionPicker', () => { + it('checks collections that already have the package and adds it to others', async () => { + collections = [own('a', 'Validation', [ZOD]), own('b', 'Favorites', [])] + changeItems.mockImplementation(() => ({ cid: 'cid-b2', subjectRefs: [ZOD] })) + const fetchSpy = vi.spyOn(globalThis, '$fetch') + const component = await mountPicker(['zod']) + + expect(checkbox(component, 'Validation').element.checked).toBe(true) + expect(checkbox(component, 'Favorites').element.checked).toBe(false) + + await checkbox(component, 'Favorites').setValue(true) + + // The item count comes from the response, so this proves the save went through + await vi.waitFor(() => { + const label = component.findAll('label').find(l => l.text().includes('Favorites'))! + expect(label.text()).toContain('1 item') + }) + expect(fetchSpy).toHaveBeenCalledWith( + `/api/social/collections/${encodeURIComponent(DID)}/b/items`, + { method: 'POST', body: { add: [ZOD] } }, + ) + fetchSpy.mockRestore() + expect(checkbox(component, 'Favorites').element.checked).toBe(true) + }) + + it('puts the checkbox back and says so when a save fails', async () => { + // No endpoint is registered for this collection's items, so the save fails + collections = [own('unsaveable', 'Broken', [])] + const component = await mountPicker(['zod']) + + await checkbox(component, 'Broken').setValue(true) + + await vi.waitFor(() => expect(component.find('[role="alert"]').exists()).toBe(true)) + expect(checkbox(component, 'Broken').element.checked).toBe(false) + }) + + it('shows a partial selection as indeterminate', async () => { + collections = [own('a', 'Validation', [ZOD])] + const component = await mountPicker(['zod', 'valibot']) + expect(checkbox(component, 'Validation').element.indeterminate).toBe(true) + }) + + it('disables collections with no room left', async () => { + collections = [ + own( + 'a', + 'Everything', + Array.from({ length: MAX_COLLECTION_ITEMS }, (_, i) => PACKAGE_SUBJECT_REF(`pkg-${i}`)), + ), + ] + const component = await mountPicker(['zod']) + expect(checkbox(component, 'Everything').element.disabled).toBe(true) + expect(component.text()).toContain('Full') + }) + + it('creates a new collection containing the packages', async () => { + collections = [own('a', 'Validation', [])] + create.mockImplementation(() => { + collections = [...collections, own('c', 'Schema libs', [ZOD])] + return { uri: collections[1]!.uri, cid: 'cid-c', did: DID, rkey: 'c' } + }) + const component = await mountPicker(['zod']) + + await component.find('input[placeholder="Name"]').setValue('Schema libs') + await component.find('form').trigger('submit') + + await vi.waitFor(() => expect(checkbox(component, 'Schema libs').element.checked).toBe(true)) + expect(create).toHaveBeenCalledOnce() + }) +}) diff --git a/test/nuxt/composables/use-command-palette-commands.spec.ts b/test/nuxt/composables/use-command-palette-commands.spec.ts index 718a70d529..e3b06330a3 100644 --- a/test/nuxt/composables/use-command-palette-commands.spec.ts +++ b/test/nuxt/composables/use-command-palette-commands.spec.ts @@ -236,6 +236,9 @@ describe('useCommandPaletteCommands', () => { ]) expect(flatCommands.value.find(command => command.id === 'package-diff')).toBeTruthy() expect(flatCommands.value.find(command => command.id === 'package-download')).toBeTruthy() + expect( + flatCommands.value.find(command => command.id === 'package-add-to-collection'), + ).toBeTruthy() expect(flatCommands.value.find(command => command.id === 'package-main')?.to).toBeTruthy() expect(flatCommands.value.find(command => command.id === 'package-timeline')?.to).toEqual({ name: 'timeline', diff --git a/test/unit/a11y-component-coverage.spec.ts b/test/unit/a11y-component-coverage.spec.ts index 6805d88f05..123b6ca08e 100644 --- a/test/unit/a11y-component-coverage.spec.ts +++ b/test/unit/a11y-component-coverage.spec.ts @@ -45,6 +45,7 @@ const SKIPPED_COMPONENTS: Record = { 'Header/OrgsDropdown.vue': 'Requires connector context and API calls', 'Header/PackagesDropdown.vue': 'Requires connector context and API calls', 'Header/MobileMenu.client.vue': 'Requires Teleport and full navigation context', + 'Collection/AddToModal.client.vue': 'Thin Modal wrapper; its content is CollectionPicker', 'Modal.client.vue': 'Base modal component - tested via specific modals like ChartModal, ConnectorModal', 'Package/SkillsModal.vue': 'Complex modal with tabs - requires modal context and state', diff --git a/test/unit/server/utils/collection-writes.spec.ts b/test/unit/server/utils/collection-writes.spec.ts index d4cf08b722..3f36d1126a 100644 --- a/test/unit/server/utils/collection-writes.spec.ts +++ b/test/unit/server/utils/collection-writes.spec.ts @@ -1,7 +1,9 @@ import { afterAll, beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import * as v from 'valibot' import { createError } from 'h3' import type * as AtprotoLex from '@atproto/lex' -import { PACKAGE_SUBJECT_REF } from '#shared/utils/constants' +import { CollectionItemsChangeSchema } from '#shared/schemas/social' +import { MAX_COLLECTION_ITEMS, PACKAGE_SUBJECT_REF } from '#shared/utils/constants' const OWNER = 'did:plc:owner' const VIEWER = 'did:plc:viewer' @@ -9,6 +11,7 @@ const RKEY = '3m5k5baifas2a' const URI = `at://${OWNER}/dev.npmx.collection/${RKEY}` const REF = { identifier: OWNER, rkey: RKEY } const ZOD = PACKAGE_SUBJECT_REF('zod') +const VALIBOT = PACKAGE_SUBJECT_REF('valibot') const client = { create: vi.fn(), put: vi.fn(), delete: vi.fn() } vi.mock('@atproto/lex', async importOriginal => ({ @@ -141,6 +144,71 @@ describe('updateCollection', () => { }) }) +const change = (add: string[], remove: string[] = []) => + writes.changeCollectionItems(sessionFor(OWNER), REF, { add, remove }) +const parseChange = (body: unknown) => v.safeParse(CollectionItemsChangeSchema, body).success + +describe('changeCollectionItems', () => { + it("rejects editing someone else's collection", async () => { + await expect( + writes.changeCollectionItems(sessionFor(VIEWER), REF, { add: [VALIBOT], remove: [] }), + ).rejects.toMatchObject({ statusCode: 403 }) + }) + + it('appends to the current record, keeping notes and unknown item fields', async () => { + const record = stored('cid-1', [ZOD]) + record.value.items[0] = { ...record.value.items[0]!, 'com.example.rating': 5 } as never + utils.fetchRawRecord.mockResolvedValue(record) + + expect(await change([VALIBOT])).toEqual({ + uri: URI, + cid: 'cid-2', + did: OWNER, + rkey: RKEY, + subjectRefs: [ZOD, VALIBOT], + }) + const [, value, options] = client.put.mock.calls[0]! + expect(options).toEqual({ rkey: RKEY, swapRecord: 'cid-1' }) + expect(value.items).toEqual([ + { 'subjectRef': ZOD, 'note': `why ${ZOD}`, 'com.example.rating': 5 }, + { subjectRef: VALIBOT }, + ]) + }) + + it('retries a lost swap against the fresh record, so concurrent toggles both land', async () => { + const ARKTYPE = PACKAGE_SUBJECT_REF('arktype') + utils.fetchRawRecord + .mockResolvedValueOnce(stored('cid-1', [ZOD])) + .mockResolvedValueOnce(stored('cid-1b', [ZOD, ARKTYPE])) + client.put.mockRejectedValueOnce(swapError()) + + const result = await change([VALIBOT]) + + expect(client.put.mock.calls[1]![2]).toEqual({ rkey: RKEY, swapRecord: 'cid-1b' }) + expect(result.subjectRefs).toEqual([ZOD, ARKTYPE, VALIBOT]) + }) + + it('gives up with 409 after repeated lost swaps', async () => { + client.put.mockRejectedValue(swapError()) + await expect(change([VALIBOT])).rejects.toMatchObject({ statusCode: 409 }) + expect(client.put).toHaveBeenCalledTimes(3) + }) + + it('refuses to overflow a full collection', async () => { + const full = Array.from({ length: MAX_COLLECTION_ITEMS }, (_, i) => + PACKAGE_SUBJECT_REF(`pkg-${i}`), + ) + utils.fetchRawRecord.mockResolvedValue(stored('cid-1', full)) + await expect(change([VALIBOT])).rejects.toMatchObject({ statusCode: 400 }) + expect(client.put).not.toHaveBeenCalled() + }) + + it('rejects changes npmx cannot render or that contradict themselves', () => { + expect(parseChange({ add: ['https://example.com'] })).toBe(false) + expect(parseChange({ add: [ZOD], remove: [ZOD] })).toBe(false) + }) +}) + describe('setReaction', () => { it('follows with a follow record pointing at the collection', async () => { client.create.mockResolvedValue({ uri: `at://${VIEWER}/dev.npmx.graph.follow/3follow` }) @@ -220,3 +288,13 @@ describe('buildCollectionRecord', () => { expect(record.createdAt).not.toBe('nope') }) }) + +describe('applyItemChanges', () => { + it('removes, then appends only what is missing', () => { + const items = [{ subjectRef: ZOD, note: 'n' }, { subjectRef: VALIBOT }] + expect(writes.applyItemChanges(items, [ZOD, 'x', 'x'], [VALIBOT])).toEqual([ + { subjectRef: ZOD, note: 'n' }, + { subjectRef: 'x' }, + ]) + }) +})