feat(media): show where media is used#2225
Conversation
🦋 Changeset detectedLatest commit: d053c6e The changes in this PR will be included in the next version bump. This PR includes changesets to release 17 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-playground | d053c6e | Jul 24 2026, 01:23 PM |
Scope checkThis PR changes 1,646 lines across 19 files. Large PRs are harder to review and more likely to be closed without review. If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs. See CONTRIBUTING.md for contribution guidelines. |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-demo-do | d053c6e | Jul 24 2026, 01:22 PM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-demo-cache | d053c6e | Jul 24 2026, 01:23 PM |
There was a problem hiding this comment.
This PR adds a useful, coverage-aware "Used in" panel to local media details and completes the back-compat story by canonicalizing legacy local media references off their storage key while seeding. I read the diff, the new UI/backend files, the media-usage handler/repository, and the API route changes, and checked against AGENTS.md conventions.
Approach: The change is the right one for the media-library project. It keeps usage reads admin-only, uses the existing projection tables, threads includeUsage only when requested, validates inputs through the shared schemas, and invalidates media-query caches on mutations. The only architecture concern is that the new canonicalization logic is a bit brittle: it fails an entire content source when one legacy storage key is missing, and it doesn't extend the storage-key repair to portable-text assets or repeater file sub-fields.
What I checked
- Localization: all new user-facing strings go through Lingui.
- RTL-safe Tailwind: the new JSX uses logical classes and
dir="auto"/dir="ltr"attributes; no physicalml-*/text-leftclasses were introduced. - SQL safety: dynamic table/column identifiers are validated with
validateIdentifier()and passed tosql.ref(); values are parameterized. - API envelope / auth: routes return
ApiResult, useunwrapResult, requiremedia:read,content:read_drafts, andadminscope for details, and redact counts for callers that can’t read drafts. - Locale filtering on content tables: snapshot/repair queries are by row
id, so the per-row locale is implicit; no missing-locale bug. - Logged-out query counts: usage endpoints are auth-locked; no hot-path regression.
- Changeset: present and user-facing.
Headline issues
- Repeater sub-field discovery and extraction only look for
imagesub-fields, sofilefields inside repeaters are silently invisible to usage even though top-level file fields are tracked. - The new storage-key canonicalizer aborts the whole content source on the first unresolvable storage key, dropping otherwise-valid references on the same entry.
- Portable-text image assets are not passed through the same
storageKeyrepair path as direct image values. - Tests assert Tailwind class names (
font-mono,text-[0.9em],h-10, etc.), which tests the implementation detail rather than observable behavior. - One new internal import omits the required
.jsextension.
I did not run the test suite or linters; the findings above are from static analysis only.
Findings
-
[needs fixing]
packages/core/src/media/usage/extractor.ts:88The repeater walker only extracts
imagesub-fields, so file fields inside repeaters are silently invisible to the usage index even though top-level file fields are tracked (theMediaUsageReferenceTypeincludesfile_field). If an editor puts a file in a repeater, the "Used in" panel will never show that reference.function extractRepeaterOccurrences( occurrences: ExtractedMediaUsageOccurrence[], seen: Set<string>, fieldSlug: string, value: unknown, subFields: readonly MediaUsageExtractionSubField[] | undefined, ): void { if (!Array.isArray(value) || !Array.isArray(subFields)) return; for (const [itemIndex, item] of value.entries()) { if (!isRecord(item)) continue; for (const subField of subFields) { if (subField.type !== "image" && subField.type !== "file") continue; addOccurrence(occurrences, seen, { fieldSlug, fieldPath: `${fieldSlug}[${itemIndex}].${subField.slug}`, referenceType: subField.type === "image" ? "image_field" : "file_field", value: item[subField.slug], fallbackKind: subField.type === "image" ? "image" : null, }); } } } -
[needs fixing]
packages/core/src/media/usage/content-fields.ts:92This mirrors the extractor gap: repeater sub-field discovery only indexes
imagechildren, so file sub-fields in repeaters are never discovered for usage indexing in the first place. Discovery and extraction should accept bothimageandfilesub-field types.const subFields: MediaUsageExtractionSubField[] = []; for (const subField of validation.subFields) { if (!isRecord(subField) || (subField.type !== "image" && subField.type !== "file")) continue; if (typeof subField.slug !== "string") continue; validateIdentifier(subField.slug, "media usage repeater sub-field slug"); subFields.push({ slug: subField.slug, type: subField.type as "image" | "file" }); } -
[needs fixing]
packages/core/src/media/usage/content-snapshots.ts:231canonicalizeLocalMediaOccurrencesfails the whole content source when any legacy storage key cannot be resolved. That drops every other valid reference on the same content row and marks the collection as failed/partial, instead of gracefully degrading. If one old reference points at a deleted file, entries that also reference still-existing media stop contributing to usage.Replace the hard failure with a partial mapping: keep resolvable occurrences and proceed with
sourceCompleteness: "partial"for unresolvable ones.const resolvedOccurrences = occurrences.map(({ storageKey, ...occurrence }) => { if (occurrence.provider !== "local" || !storageKey) return occurrence; const canonicalId = mediaIdByStorageKey.get(storageKey); if (!canonicalId) return occurrence; return { ...occurrence, mediaId: canonicalId, providerAssetId: canonicalId }; }); return { success: true, occurrences: resolvedOccurrences, };Then update the callers to set
sourceCompletenessto"partial"when anystorageKeywas present but not found, rather than returningLOCAL_MEDIA_NOT_FOUND. -
[suggestion]
packages/core/src/media/usage/extractor.ts:120-126The portable-text image path builds a
MediaRefwithout readingstorageKeyfromblock.asset.meta, even though the direct image-field path readsvalue.meta.storageKeyto repair legacy references. If a legacy rich-text block stored its local asset with a storage-key reference, this path won't canonicalize it.addRefOccurrence(occurrences, seen, { fieldSlug, fieldPath: `${fieldSlug}[${blockIndex}].asset.${ref.key}`, referenceType: "portable_text_image", ref: buildMediaRef({ id: ref.id, provider, mimeType: normalizeMimeValue(block.asset.mimeType), fallbackKind: "image", storageKey: provider === "local" ? readStorageKey(block.asset.meta) : null, }), }); -
[suggestion]
packages/admin/src/components/MediaDetailPanel.tsx:27This new sibling import omits the
.jsextension; AGENTS.md requires.json internal ESM imports and the file already uses it for./DialogError.js.import { MediaUsedIn } from "./MediaUsedIn.js"; -
[suggestion]
packages/admin/tests/components/MediaUsedIn.test.tsx:150-157These assertions check exact Tailwind class names (
font-mono,text-[0.9em],text-base). Per AGENTS.md, that tests the implementation detail straight back, not the behavior a user observes. Keep the visible-text,dir,href, and separator checks; drop the class-name assertions. -
[suggestion]
packages/admin/tests/components/MediaUsedIn.test.tsx:173-179Similar to the above test block, these lines assert that specific Tailwind utility classes (
w-full,-mx-2,border,border-kumo-line,bg-kumo-control,items-center,h-10,w-10) are present. Testing class strings pins styling rather than behavior. Prefer asserting that the link is visible, spans its container, and contains the expected accessible content.
Overlapping PRsThis PR modifies files that are also changed by other open PRs:
This may cause merge conflicts or duplicated work. A maintainer will coordinate. |
Fixed the Portable Text compatibility path, .js import, and tests; repeater file fields remain out of scope, and unresolved storage keys still fail safely so the UI never shows guessed usage as trustworthy. |
There was a problem hiding this comment.
Re-review of the media-library "Used in" feature.
Approach: The change is still the right one for the project. The new UI surfaces coverage-aware usage references, routes are guarded, counts are redacted for callers without draft/admin access, and the canonicalization path lets legacy storage-key references resolve after upgrade.
What changed since the last review
- The
MediaDetailPanel → MediaUsedInimport now includes the required.jsextension. - Portable Text image assets now pass
storageKeythrough the same repair path as direct image fields. - The
MediaUsedIntests no longer assert Tailwind class strings; they check visible text,dir, and link hrefs. - A new
MediaDetailPanel.test.tsxwas added with several class/style assertions.
What I checked
- Localization / RTL: all new UI strings go through Lingui; logical Tailwind classes and
dirattributes are used. - SQL safety: dynamic table/column identifiers are validated with
validateIdentifier(); values are parameterized via Kysely. - API envelope / auth: usage endpoints require
media:read,content:read_drafts, and theadminscope; summaries redact counts for unscoped callers. - Locale filtering on content tables: snapshot/repair queries read by row
idortranslation_group, so the per-row locale is implicit; no missing-locale bug. - Logged-out query counts: all new usage paths are auth-locked; no hot-path regression.
- Changeset: present and user-facing.
Headline conclusion
Three real gaps from the prior review are still open, plus one new test-quality issue in the MediaDetailPanel suite:
- Repeater sub-field discovery and extraction only handle
imagechildren, sofilefields inside repeaters are invisible to usage. - Legacy storage-key canonicalization aborts an entire content source when any key is missing, dropping otherwise-valid references on the same row.
MediaDetailPanel.test.tsxrepeats the pattern the last review removed fromMediaUsedIn.test.tsx: asserting exact Tailwind classes and inline styles.
The first two are completeness/correctness bugs; the hard-failure case is especially painful during upgrades because a single broken legacy reference blocks usage indexing for the whole content source. I did not run the test suite or linters; these findings are from static analysis only.
Findings
-
[needs fixing]
packages/core/src/media/usage/extractor.ts:88The repeater walker still skips every sub-field except
image, so file fields inside repeaters are invisible to usage indexing even though top-level file fields are tracked.MediaUsageReferenceTypealready includesfile_field, so the extraction path should accept both sub-field types and emit the correct reference type.for (const subField of subFields) { if (subField.type !== "image" && subField.type !== "file") continue; addOccurrence(occurrences, seen, { fieldSlug, fieldPath: `${fieldSlug}[${itemIndex}].${subField.slug}`, referenceType: subField.type === "image" ? "image_field" : "file_field", value: item[subField.slug], fallbackKind: subField.type === "image" ? "image" : null, }); } -
[needs fixing]
packages/core/src/media/usage/content-fields.ts:92Discovery mirrors the extractor gap: it only indexes
imagechildren from repeater validation, so file sub-fields in repeaters are never added to the extraction set in the first place. Discovery and extraction must both acceptimageandfilesub-field types.for (const subField of validation.subFields) { if (!isRecord(subField) || (subField.type !== "image" && subField.type !== "file")) continue; if (typeof subField.slug !== "string") continue; validateIdentifier(subField.slug, "media usage repeater sub-field slug"); subFields.push({ slug: subField.slug, type: subField.type as "image" | "file" }); } -
[needs fixing]
packages/core/src/media/usage/content-snapshots.ts:234canonicalizeLocalMediaOccurrencesfails the whole content source when any legacy storage key cannot be resolved. That drops every other valid reference on the same content row and marks the collection as failed/partial instead of degrading gracefully. A broken legacy reference should not block indexing of otherwise-valid ones.Replace the hard failure with a partial mapping: keep resolvable references, preserve unresolvable ones with a
nullmediaId, and expose ahasUnresolvedflag so callers can setsourceCompletenessto"partial".const missingStorageKeys = storageKeys.filter( (storageKey) => !mediaIdByStorageKey.has(storageKey), ); return { success: true, hasUnresolved: missingStorageKeys.length > 0, occurrences: occurrences.map(({ storageKey, ...occurrence }) => { if (occurrence.provider !== "local" || !storageKey) return occurrence; const canonicalId = mediaIdByStorageKey.get(storageKey); if (canonicalId) { return { ...occurrence, mediaId: canonicalId, providerAssetId: canonicalId }; } return { ...occurrence, mediaId: null }; }), };Then update the callers in
loadContentMediaUsageSnapshotsto setsource.sourceCompleteness = result.hasUnresolved ? "partial" : "complete"for each canonicalized source, and the existing"LOCAL_MEDIA_NOT_FOUND"return path can be removed. -
[suggestion]
packages/admin/tests/components/MediaDetailPanel.test.tsx:140This test asserts exact Tailwind classes and inline styles (
grid-cols-1,md:grid-cols-2,min(88dvh, 48rem),md:p-8, etc.). Per AGENTS.md, that tests the implementation detail straight back rather than behavior a user observes. Keep the DOM/structure checks (file facts live in the preview column, both columns are present) and drop the class/style assertions. There are similar class-name assertions later in the file (e.g., the filename input check around line 285); prefer deleting those too.it("renders the responsive two-column viewport-bounded dialog layout", async () => { const screen = await renderPanel(); const dialog = screen.getByRole("dialog", { name: "Media Details" }).element(); const header = screen.getByTestId("media-detail-dialog-header").element(); const body = screen.getByTestId("media-detail-dialog-body").element(); const previewColumn = screen.getByTestId("media-detail-dialog-preview-column").element(); const detailsColumn = screen.getByTestId("media-detail-dialog-details-column").element(); const fileFacts = screen.getByTestId("media-detail-dialog-file-facts").element(); const footer = screen.getByTestId("media-detail-dialog-footer").element(); expect(previewColumn.contains(fileFacts)).toBe(true); expect(detailsColumn.contains(fileFacts)).toBe(false); expect(dialog).toBeVisible(); expect(header).toBeVisible(); expect(footer).toBeVisible(); });
What does this PR do?
Adds a coverage-aware Used in section to the media detail dialog for local assets. Editors can see linked content titles, collections, identifiers, locales, and trash status; open active references without losing unsaved metadata; and load additional results when usage spans multiple pages.
The panel handles loading, empty, redacted, incomplete-index, and retry states. The supporting compatibility work canonicalizes legacy local media references by storage key and ensures seeded content stores canonical media IDs, so existing projects can populate accurate usage information after upgrading.
This is a completed child PR for the media library project and targets
feature/media-library. The umbrella PR remains draft at #2218 for Matt's final review.Closes: N/A
Type of change
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (or targeted tests for my change)pnpm formathas been runmessages.pochanges are included.AI-generated code disclosure
Screenshots / test output
Verified locally in the media detail dialog at desktop and mobile widths, in light and dark themes.
pnpm typecheckpnpm lintpnpm lint:json | jq '.diagnostics | length'→0Try this PR
Open a fresh playground →
A full working EmDash site, deployed from this branch. Each visit gets its own session-scoped sandbox: no login needed and no shared state. Try the admin, edit content, hit the public site.
Tracks
media-usage-admin-ui. Updated automatically when the playground redeploys.