Repository navigation
feat(tags): let people suggest a tag, and admins review suggestions - #648
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Claude finished @alukach's task in 26s —— View job ✅ No blocking issues — safe to merge. I read What I checked:
Non-blocking notes, all already marked as known limits in the PR:
DocsThe PR description covers all three:
Nothing to flag. Simplify (ponytail)
💰 Estimated review cost: $0.25 · 0m25s · 8 turns |
@tylere No, we don't really have a pattern for editing data on the product detail view. Instead, we do so in the settings, alongside other product details. This was added in #647 and is represented in the the ProductCreationForm story |
Suggested tags no longer appear publicly before an admin approves them (source-cooperative/source.coop#648). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This comment was marked as low quality.
This comment was marked as low quality.
The homepage is prerendered at build time and now reads the tags table to hide pending tags. A deployment whose table or grant isn't there yet failed its build on that read (the preview build of #648 did, with AccessDenied on sc-dev-tags). Hiding pending tags is a display matter, so hidePendingTags now leaves the products as they are when the read fails; before the table exists, nothing is pending anyway. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
It would be beneficial if admins would see an indicator when there are tags to review (similar to how users see an indicator when they are invited to join an organization). Otherwise I think that tags waiting for review will go unnoticed. |
Added in 2e02d23, see AccountDropdown for demonstration |
I think the core issue is that despite suggesting a tag, you're not saving the product after adding the tag. Tags are a property of the product and go through the same save workflow as any other field. Perhaps the tags should be a separate flow, I admit that I encountered this same situation myself before realizing I needed to save tags after suggestion. |
@tylere Agreed, however I think that may be best solved outside of this PR. Let's move that to #726 (which still needs some work) |
When no tag fits, the tag popover offers to suggest the filter text as a new tag. The suggestion is stored in the tags table as a pending row with who suggested it and when, and it goes on the product straight away: product saves accept pending tags, and its chip is marked pending. Suggestions are lowercased and limited to 40 letters, digits, spaces or hyphens, and each person may have at most five pending at once. A new admin page, /admin/tags, lists pending tags with the products carrying them. Approve adds a tag to the list everyone picks from. Merge replaces it with an approved tag on every product that carries it. Reject takes it off those products. Both merge and reject delete the pending row. The app now writes to the tags table, so the Vercel role's grant on it becomes read-write. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Until an admin approves it, a suggested tag no longer appears on the product page, the products list, profiles or the homepage. hidePendingTags takes pending tags off products before they are shown. The API's product reads apply it too, except for callers who may edit the product, so a tags list they send back doesn't drop pending tags unseen. The settings form reads products directly and still shows them, marked pending. reviewTag also now refuses to merge or reject a tag that is no longer pending. Without that, a second admin's stale form could delete an approved tag and strip it from every product. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The homepage is prerendered at build time and now reads the tags table to hide pending tags. A deployment whose table or grant isn't there yet failed its build on that read (the preview build of #648 did, with AccessDenied on sc-dev-tags). Hiding pending tags is a display matter, so hidePendingTags now leaves the products as they are when the read fails; before the table exists, nothing is pending anyway. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tagProps now takes the product's current tags and returns just the pending ones among them. Other people's suggestions aren't public, so the picker has no reason to receive them. Also moves editsTags above the doc comment that belongs to shown. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Admins had no way to notice suggested tags short of visiting /admin/tags, so suggestions could sit unreviewed. Like a pending invitation, a red dot now appears on the avatar, on Admin in the account menu (desktop and mobile), on Tags inside it, and on Tags in the admin sidebar. `anyPendingTags` reads the tags table, which the request cache already shares with `hidePendingTags`, and answers false when the table can't be read so the header never fails over it. Adds an AccountDropdown story with the invitation and tags-to-review states, which needs the credentials action mocked in Storybook. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Storybook's settings stories were regrouped into Profiles and Products; TagPicker was left under the old Details group, alone. It belongs beside ProductCreationForm, which renders it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…1/tags Suggesting and reviewing tags now go through operations in src/lib/operations/tags.ts, shared by the form actions and four new routes, so the UI and the API can't disagree about a rule: - GET /tags lists the approved tags to anyone, and with ?pending=true lists the tags awaiting review to admins, with who suggested each and when. - POST /tags suggests a tag. It answers 201 with the new pending tag, or 200 with the tag if it already exists, so retrying is safe without an Idempotency-Key (#708). API.md now names this convention. - PATCH /tags/{tag_id} with { "pending": false } approves a tag. - DELETE /tags/{tag_id} rejects a pending tag, taking it off every product, and with ?replace_with= merges it into an approved tag instead. It answers 409 for an approved tag, the stale-form protection the review page relies on. An empty replace_with is now refused rather than read as a rejection. Errors are the existing RFC 9457 problems from #701, and the routes are in the OpenAPI 3.1 reference. The admin review page reads tags through listTags, and public/llms.txt lists GET /tags. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review page's merge was a dropdown beside a separate Merge button, which could be pressed with nothing chosen. Each suggestion now offers the approved tags it looks most like (similarTags: the same tag without separators, one containing the other, shared words, or a near spelling), and "Other…" opens every approved tag with how many products carry it. Choosing a tag only stages the merge: the card spells out what saving will change, nothing happens until Save merge, and Keep goes back to the suggestion as it was. Approve and Reject act at once, as before. reviewTag now answers a refusal instead of throwing it, so the card shows why, rather than an error page whose message production hides. The page reads every tagged product in one scan (productsTable.listTagged) and groups it for both the suggestions and the approved tags' counts; listByTag filters the same scan. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Approving a tag used to remove who suggested it and recorded nothing about the approval. Now approval keeps suggested_by and suggested_at and adds approved_by and approved_at. Merging or rejecting still deletes the row, so deleteTag logs who decided, the replacement, the suggester and how many products changed. GET /tags answers admins with each tag's full record and everyone else with just the tags. /admin/tags gains an "All tags" table below the review queue: every approved tag, most used first, with its product count and where it came from (who suggested it and who approved it, or the original cleanup). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tags are lowercase with hyphens between words, and shown as stored. suggestTag kept spaces, so a suggested "Sea Ice" became "sea ice" beside "field-boundaries" and "saudi-arabia". It now spells a suggestion with canonTag, the same rule as clean-tags.ts and product saves (#719), so "Sea Ice" is suggested as "sea-ice", and its pattern no longer allows spaces. The picker filters and offers suggestions in that spelling too, so typing "sea ice" finds "sea-ice" and its button names the tag as it will be stored. src/lib/tags/canon.ts is the same file #719 adds, so the two merge cleanly. The stories, tests and API reference use hyphenated example tags. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
canon.ts is #719's updated file: it also strips `#`, joins words with single hyphens whatever mix of spaces and hyphens separated them, and trims hyphens from the ends. A suggested `#Sea - Ice` is therefore `sea-ice`. Inputs the old rule refused, such as `-ice` and `#seaice`, now respell to valid tags, so the tests for malformed suggestions use characters a tag can't hold instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The admin tag list said "From the original cleanup" for tags with no suggester or approver; it now says "Initial import", as do the API reference and code comments. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each tag in /admin/tags's All tags table now links to /products?tags=<tag>, so an admin can see the products carrying it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Suggesting a tag created it on the spot, but it only reached the product when the form was saved, so someone who suggested a tag and left without saving left a pending tag on no product. Now "Suggest" only adds a chip marked new, submitted as a `suggest_tags` form value, and the save creates the suggestion and adds it to the product together. - planSuggestions (src/lib/operations/tags.ts) checks suggestions without writing: their spelling, and that the new ones fit the suggester's cap. It answers a `write` the caller runs later. POST /tags uses it for one tag. - createProduct and updateProduct take `suggest_tags`, in the form and the API. They plan the suggestions, check the product's tags with them, finish every other check, and only then write the suggestions and the product, so a save that fails suggests nothing. Without `tags`, an update adds the suggestions to the tags the product has. - The form actions send `suggest_tags`, and show a refused suggestion under the form's Tags field. - The picker shows new tags as "(new)" with a reminder that they are suggested when the product is saved. The now-unused suggestTag server action is removed. - DynamicForm can ask before the page unloads while the form holds unsaved values (warnOnUnsavedChanges), and the product form does. It compares what the form would submit with what it loaded, since the tag picker changes hidden inputs that fire no input events. A link within the app doesn't unload the page, so it isn't caught. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The unsaved-changes warning was an opt-in prop that only the product form turned on. No form is better off losing what someone typed, so every DynamicForm now asks before the page unloads while it holds values it hasn't saved: organization creation, product, account flags, invite member, profile and onboarding. None of them unload the page after saving, so a successful save never triggers it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…there are some Every form now says "You have unsaved changes" beside its Save button while it holds any, and Save stays disabled until there is something to save. useFormChanges (src/hooks) keeps that state live: the form is unsaved while what it would submit differs from what it loaded or last saved, checked on every input and whenever the form's contents change, so hidden inputs like the tag picker's count. It also carries the ask before leaving, through useUnsavedChangesWarning. DynamicForm uses it, and so do the two forms that use FormActions directly, ServiceAccountForm and DataConnectionForm. FormActions takes `unsaved`: given, it disables the button until there are changes, and its status line shows a failed save's reason first, then the unsaved changes, then a success message, so an old "Saved" doesn't sit beside new edits. The tag picker's own reminder to save is gone, since the form now says so for every field; its "(new)" chip label stays. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After rebasing onto main, #719's checkTags tests mocked tagsTable.listAll as a list of strings; on this branch it answers tag rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"Other…" and "Merge into…" were ghost buttons, gray text with no edge, so they read as a label beside the suggested tags. They are now outlined, with a chevron for the list they open, and labelled "Other tag" and "Merge into a tag". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
similarTags dropped a trailing -ies, -es or -s to compare words, so boundaries became boundar while boundary stayed boundary, and the two never matched: neither contains the other, and they are three edits apart. Words now go to the singular: -ies becomes -y, -es comes off only after s, x, ch or sh, and a lone trailing s comes off except from -ss. Two tags that are the same words once singular score 0.95. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…merge
A suggestion like field-boundaries can now be merged into field and boundaries together.
- DELETE /tags/{tag_id} takes `replace_with` repeated, and puts every one of those approved tags in the suggestion's place on each product, once each. A single replace_with works as before. The route reads every value, which Object.fromEntries would have cut to the last.
- On the review card, likely matches and tags from the Other tag picker toggle in and out of the staged merge, which shows and saves them all.
- splitTag (src/lib/tags/similar.ts) finds the approved tags a suggestion splits into when each of its words is one, in the singular or the plural. The card offers that as one button, "field + boundaries", which stages them all.
- "Keep {tag}" is now Reset, on the right of the card where Reject sits, in orange: it undoes the staged merge, not the suggestion.
- A merge sent with no target is refused, not taken as a rejection.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A decided suggestion no longer disappears. Merging or rejecting it used to delete its row, and approving it kept no record of the decision beyond the approver. Every decision now stays on the tag: `outcome` (approved, merged or rejected), `reviewed_by`, `reviewed_at`, the `merged_into` targets, and for a merge or rejection `changed`, each product it rewrote with the tags it gained. Merged and rejected tags leave the list, the product picker and the save check (isListed, isUsable), but stay in the table.
- /admin/tags gains a Reviewed section, most recent first: the tag, the decision ("Merged into field + boundaries on 3 products"), who decided and when, and Undo.
- Undo is PATCH /tags/{tag_id} with `pending: true`. It puts the tag back awaiting review; for a merge or rejection it first puts the tag back on each product it was taken off and takes off the tags the merge added, leaving any the product had before. An approval changed no product, so undoing it only reopens the tag.
- GET /tags?reviewed=true lists decided tags, for admins.
- A merge or rejection is recorded before products change, and only while the tag is still pending, so two admins can't both decide one; the loser gets 409 and no product changes.
- Suggesting a tag that was merged or rejected puts it back in the queue.
- "Approve as a new tag" is a soft button on every card, so Save merge is the one solid button.
The approver fields `approved_by` and `approved_at` become `reviewed_by` and `reviewed_at` with `outcome: "approved"`, since no tag data has reached production yet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… one On /admin/tags, every account named (who suggested a tag, who approved, merged or rejected it) links to the account and shows its card on hover, through a new AccountMention (src/components/core): an id in running text, wrapped in the existing AccountInfoHoverCard, or plain text for an id the page couldn't look up. The page looks up every account it names in one batch and hands the components their publicView, so no private field reaches the browser. CLAUDE.md now makes this the rule for any account the UI names. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…g was on Both tag pickers, the product form's and the admin merge picker, now share TagMenu: a search field heading a list of plain option rows in titled groups. The rows were ghost Buttons, whose negative margins pushed them past the scrolling box, clipping their first letters and scrolling sideways, and whose full height made the hover highlight oversized; options stay inside the list's edges and highlight one 32 px row. Arrow keys move the highlight, which the mouse moves too, Enter chooses, and a footer shows the keys. The product form lists the product's tags first, checked, held in place while the menu is open so a toggled tag doesn't move under the cursor, and suggesting a new tag is the list's last row; the merge picker lists the suggestion's close matches first. Each row under Reviewed on /admin/tags now links the products the tag was on: those a merge or rejection rewrote, from its record of what changed, or those carrying an approved tag now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A raw <input> broke the lint rule that keeps form controls on the theme, which failed the preview build. TextField keeps the theme's focus ring and dark mode, with the search icon in its slot. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Filtering the public product list with ?tags= matched on stored tags, pending ones included, so filtering by a tag awaiting review returned the products carrying it, with the tag stripped, revealing both. listProducts now matches the filter again on the tags the caller can see. The tag picker no longer offers to suggest a tag the product already carries but the list lacks, a pending or retired tag, which made a second chip and sent the tag as both tags and suggest_tags. The admin tags page groups products by tag once instead of scanning every tagged product for each tag; likeness works out each tag's stems once. The tag routes' OpenAPI responses now list PATCH's 409 and describe what PATCH and DELETE return. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Applies the *Account names* rule that #648 adds to `CLAUDE.md` (wherever the UI names an account, show it with the account's card on hover) to the places that still named an account in plain text. Stacked on #648, which introduces `AccountMention` and the rule. ## What changes - **API keys** (`ApiKeyList`): who issued a key moves out of the row's tooltip, which can't hold a hover card, and into the row's meta line (`sck_…a07kT2 · issued by acoltrane`). "Revoked 3 days ago by acoltrane" names the person with their card; a key revoked by its holder or by GitHub secret scanning reads as before. - **Service accounts**: "Owned by miskatonic" and "Products miskatonic owns…" on the create form, and "miskatonic has no products yet" in `ProductAccessList`, name the owner with `AccountMention`. - **Product data connections**: the read-only callout ("Only owners and maintainers of miskatonic can…") names the owner with its card instead of in `<Code>`. - **Admin analytics**: a breakdown grouped by account gives each row its account's card. - **Memberships**: a membership whose account can't be loaded, such as a deleted one, shows its id. Before this, `MembershipsTable` passed `memberAccount!` and crashed on it. Each page looks up the accounts it names in one batch (`fetchManyByIds`) and hands the components their `publicView`, so no private field reaches the browser. The service-account detail page looks up the owner plus everyone who issued or revoked a key. `story-accounts.ts` moves from `features/admin` to `components/core`, since stories outside admin now use it. It gains `miskatonic` and `acoltrane`, the accounts the service-account stories name. ## Left alone - `DataConnectionsList`'s plain-id fallback is for an owner whose account can't be loaded; the found case already has the card. - `ProductSearchResult` names the owner without a card, but nothing renders it (it is only re-exported from `features/products`), so it isn't touched here. ## Stories On this branch's deploy: - ApiKeyList: [Default](https://source-coop-ui-git-feat-account-mentions-radiantearth.vercel.app/?path=/story/features-settings-service-accounts-detail-apikeylist--default) (issued by, revoked by) - ServiceAccountDetail: [Default](https://source-coop-ui-git-feat-account-mentions-radiantearth.vercel.app/?path=/story/features-settings-service-accounts-detail-serviceaccountdetail--default) - ServiceAccountForm: [Default](https://source-coop-ui-git-feat-account-mentions-radiantearth.vercel.app/?path=/story/features-settings-service-accounts-create-serviceaccountform--default) - ProductAccessList: [NoProducts](https://source-coop-ui-git-feat-account-mentions-radiantearth.vercel.app/?path=/story/features-settings-service-accounts-detail-productaccesslist--no-products) - ProductMirrorsManager: [ReadOnlyViewer](https://source-coop-ui-git-feat-account-mentions-radiantearth.vercel.app/?path=/story/features-settings-data-connections-productmirrorsmanager--read-only-viewer) - MembershipsTable: [MissingAccount](https://source-coop-ui-git-feat-account-mentions-radiantearth.vercel.app/?path=/story/features-settings-memberships-membershipstable--missing-account) (new) Screenshots to follow. ## Docs No user flow or platform decision moves, so docs.source.coop, the data-proxy ADRs and `public/llms.txt` still hold. The rule this applies is in #648's `CLAUDE.md`. ## Testing - `npm run type-check` passes. - The full jest suite passes locally (1251 tests). `stories.smoke.test.tsx` renders every story, including the new `MissingAccount`, which crashed the table before this change. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>






People can suggest a tag the list doesn't have, straight from the product form's tag picker, and admins review suggestions on a new admin page.
How it works
Suggest. The picker's popover lists the product's own tags first, checked, then every other tag, filtered as you type; the arrow keys move through it and Enter toggles a tag. When the filter text isn't an existing tag, its last row offers Suggest "sea-ice" as a new tag. The offer also appears when other tags only partly match, so someone after
ocean-acidificationisn't stuck withocean. Choosing it adds a chip marked (new). Nothing reaches the server yet.Saving the product suggests it. The form submits new tags as
suggest_tags.createProductandupdateProductcheck them withplanSuggestions(spelling, and the 5-suggestion cap), check the product's tags with them, finish every other check, and only then write the pending tags (pending: true,suggested_by,suggested_atinsc-{stage}-tags) and the product. A save that fails suggests nothing, and leaving without saving suggests nothing, so a suggestion can't be left on no product. A refused suggestion shows under the Tags field.An admin reviews it at
/admin/tags, which lists each pending tag, who suggested it, and the products carrying it. Each decision calls the same operation as the matching API route:splitTag), one button such as field + boundaries stages them all. The card then says what saving will change, nothing happens until Save merge, and Reset (on the right, in orange) goes back to the suggestion as it was. Saving puts every chosen tag in the suggestion's place on each product carrying it, once each, and records the decision.Approve and Reject act at once. A refused decision, such as a tag another admin already handled, is shown on its card.
Every decision stays on record, and can be undone. A suggestion no longer disappears once decided. Its row keeps
outcome(approved, merged or rejected),reviewed_by,reviewed_at, themerged_intotargets and, for a merge or rejection,changed: each product it rewrote and the tags each gained. Merged and rejected tags leave the list, the picker and the save check (isListed,isUsable), but stay in the table. The Reviewed section of/admin/tagslists them most recent first, with the decision, who made it and when, the products the tag was on (for a merge or rejection, those it rewrote; for an approval, those carrying it now), each linked, and Undo. Undo puts the tag back awaiting review; for a merge or rejection it first puts the tag back on each product it was taken off and takes off the tags the merge added, leaving any the product had before. A merge or rejection is recorded before products change, and only while the tag is still pending, so two admins can't both decide one. Suggesting a tag that was merged or rejected puts it back in the queue. Below that, an All tags table lists every approved tag, most used first, with its product count and where it came from. Each tag links to the product list filtered by it (/products?tags=boundaries). Tags seeded byclean-tags.tsshow as Initial import.API
Suggesting and reviewing go through operations in
src/lib/operations/tags.ts. The form actions and these routes both adapt them, so the UI and the API can't disagree about a rule:GET /api/v1/tagsGET /api/v1/tags?pending=trueGET /api/v1/tags?reviewed=truePOST /api/v1/tags{ "tag_id": "sea-ice" }POST/PATCHon products withsuggest_tagsPATCH /api/v1/tags/{tag_id}{ "pending": false }PATCH /api/v1/tags/{tag_id}{ "pending": true }DELETE /api/v1/tags/{tag_id}DELETE /api/v1/tags/{tag_id}?replace_with=field&replace_with=boundariesreplace_withfor eachHow this fits #702:
invalidfor a malformed tag or areplace_withthat isn't approved, 409conflictpast the five-suggestion cap or for deleting an approved tag, and 401 or 403 fromdeny. No new problem types.src/app/api/openapi/route.ts.POST /tagsanswers 200 and the existing tag rather than 409, so a retried suggestion is harmless without anIdempotency-Key. API.md now states this convention for creates named by their own content.If-Matchwill cover generally. Once API: conditional writes with ETag and If-Match (412, 428) #703 lands, the review page could send the ETag it read instead.WWW-Authenticate(API: WWW-Authenticate: Bearer on every 401 #704) will come to these routes' 401s throughtoResponse, with nothing to change here.Guardrails
canonTaginsrc/lib/tags/canon.ts, the same file fix(tags): spell tags with hyphens in clean-tags and product saves #719 adds), so typingSea Icesuggestssea-ice. They must then be at most 40 letters, digits or hyphens, starting and ending with a letter or digit. The picker filters and offers suggestions in that spelling too, so its button names the tag as it will be stored./adminlayout.deleteTagrefuses to merge or reject a tag that is no longer pending. Without that, a second admin's stale form could delete a tag another admin just approved, and strip it from every product.listProductsmatches?tags=again on the tags the caller can see, so filtering by a tag awaiting review doesn't list the products carrying it.Unsaved changes
The You have unsaved changes line by Save, the disabled Save, and the ask before leaving a form with unsaved changes moved to #722, now merged into
main. The tag picker relies on it: a (new) tag is only suggested once the product is saved, and #722 counts its hidden inputs as changes.Account names
Every account
/admin/tagsnames (who suggested a tag, who approved, merged or rejected it) links to the account and shows its card on hover. A newAccountMention(src/components/core) wraps an id in running text in the existingAccountInfoHoverCard, and shows an id the page couldn't look up, such as a deleted account's, as plain text. The page looks up every account it names in one batch and hands the components theirpublicView, so no private field reaches the browser. This followsCLAUDE.md's Account names rule, which is onmain(#728); this PR doesn't changeCLAUDE.md.Decisions worth a second look
Likely matches are a guess, so they are only offered.
similarTags(src/lib/tags/similar.ts) ranks approved tags by spelling: the same letters without separators, one containing the other, shared words, or a couple of letters off. It knows no synonyms, sosstfinds nothing likeocean; Other tag covers that. Because a guess can be wrong, a match only stages the merge, and the card says what saving will change before anything does.Pending tags appear only in the product's settings.
hidePendingTags(src/lib/clients/database/tags.ts) takes them off products shown on the product page, the products list, profiles and the homepage. The API's product reads apply it too, except for callers who may edit the product: they need to see every tag, or a tags list they send back would drop pending ones unseen. It costs one scan of the tags table per request, which the request cache shares. The?tags=search filter still matches a pending tag, so filtering by one lists its products, without showing the tag on them.One table, not a separate suggestions table. Approving a tag means the app writes to the tag list anyway, so a separate table wouldn't keep the list read-only. The Vercel role's grant on
sc-{stage}-tagsbecomes read-write.A dot, not a count or a message. While any tag is pending, admins see a red dot on their avatar, on Admin in the account menu, on Tags inside it and on Tags in the admin sidebar, the same way a pending invitation shows.
anyPendingTagsanswers it from the tags scanhidePendingTagsalready makes, which the request cache shares, and says no when the table can't be read so the header never fails over it. A count, or a Slack message, could follow if suggestions still sit unreviewed.ponytail:comment). That's fine while the queue is short and there are a few hundred products.clean-tags.tsafter this ships would drop pending tags from products, because they aren't in its corpus. It's meant to run once, at rollout, before any suggestions exist.The tag menus
Both pickers, the product form's and the admin merge picker, share one list,
TagMenu(src/components/features/products/TagMenu.tsx): a search field heading plain option rows in titled groups, with the active row tracked througharia-activedescendant. Their rows used to be ghostButtons, whose negative margins pushed them past the scrolling box, clipping their first letters and scrolling the list sideways, and whose full height made the hover highlight oversized. Options stay inside the list's edges and highlight one 32 px row. The product's tags are held in place while the menu is open, so a tag toggled off doesn't jump to the other group under the cursor. Enter never submits the form behind the popover.Stories
On this branch's Storybook deploy:
.storybook/preview.tsxmocks the newsrc/lib/actions/tags.tsmodule, andsrc/lib/actions/credentials.ts, which the AccountDropdown story reaches through the uploader providers.Docs
CLAUDE.mdis unchanged here: its Account names rule landed onmainin docs(claude): show named accounts with their card #728, and this PR is rebased on it.docs/using-source/create-a-data-product.mdtold people to email hello@source.coop for a missing tag. Updated in docs.source.coop#40, stacked on Repository settings #39.public/llms.txtgets a line forGET /api/v1/tags, next to product search, since?tags=filters on those tags. It needs no credentials, so the URL resolves in production once this ships. Tags have no bearing on the data-proxy ADRs (authorization, credentials, federation), and the proxy calls none of these endpoints, so its contract test is unaffected.API.mdgains the rule that a create named by its own content answers 200 with the existing resource.Testing
npm run type-check.npm run lintreports nothing in the changed files.src/components/core/AccountMention.test.tsxcovers the link to a looked-up account and the plain-text fallback. Undo is covered insrc/lib/operations/tags.test.ts: a merge restoring the tag and taking off only what it added (a product that had a target before keeps it, a deleted product is skipped), an approval only reopening, nothing to undo on a seeded tag, and re-suggesting a rejected tag. The same file covers recording each decision before products change, a race losing with 409 and no product changed, and the reviewed list.AdminReviewedTags.test.tsxcovers the decision wording, the products each tag was on, and Undo.src/components/features/admin/AdminTagSuggestions.test.tsxcovers choosing a match only staging the merge, Reset going back, Save merge sending the merge and its target, and a refusal being shown. Newsrc/lib/tags/similar.test.tscovers which approved tags a suggestion looks like.src/lib/operations/tags.test.tscovers the rules once: listing (pending only for admins), suggesting (normalizing, existing tags, sign-in, malformed tags, the cap), approving (admins only, recording the approver and keeping the suggester, harmless repeats, not found), admins seeing who suggested and approved each tag and deleting (merge without duplicates, areplace_withthat isn't approved or is empty, reject, refusing an approved tag).src/lib/actions/tags.test.tsnow checks only the mapping, andsrc/app/api/v1/tags/route.test.tschecks that a suggestion answers 201 or 200.src/lib/operations/products.test.tsadds a case where a product saves with a pending tag, and one where only callers who may edit a product see its pending tags. Newsrc/lib/clients/database/tags.test.tscovershidePendingTagstaking pending tags off, keeping them where asked, and leaving other products as they are.tags.test.tscoversreviewTagrefusing to merge or reject a tag that isn't pending.tags.test.tscoversanyPendingTagssaying yes when a tag is pending, no when none is, and no when the table can't be read. The new AccountDropdown stories were checked rendering in a local Storybook.TagMenu.test.tsxcovers empty groups left out, the arrow keys moving across groups to the action and wrapping, and Enter and a click choosing.src/lib/operations/products.test.tscovers filtering by a pending tag returning nothing to a caller who can't see it.TagPicker.test.tsxcovers no suggesting a tag the product already carries, the product's tags listed first and staying put when unchecked, Enter toggling without submitting the form, suggesting a tag (a (new) chip submitted assuggest_tags, with the save reminder, until removed), an existing tag not being offered for suggestion, and no suggesting withoutsuggestName.src/lib/operations/products.test.tscovers a save suggesting only the tags the list lacks, a failing save suggesting nothing, an update adding suggestions to the product's own tags, and the cap refusing a save.src/lib/actions/products.test.tscovers the form sendingsuggest_tagsand a refused suggestion showing under Tags.🤖 Generated with Claude Code