diff --git a/packages/base/card-api.gts b/packages/base/card-api.gts index 30786ec08c6..9eda0fec401 100644 --- a/packages/base/card-api.gts +++ b/packages/base/card-api.gts @@ -2,7 +2,12 @@ import Modifier from 'ember-modifier'; import GlimmerComponent from '@glimmer/component'; import { isEqual } from 'lodash-es'; import { WatchedArray, rawArrayValues } from './watched-array'; -import { BoxelInput, CopyButton } from '@cardstack/boxel-ui/components'; +import { + BoxelInput, + BrokenLinkTemplate, + CopyButton, + type BrokenLinkFormat, +} from '@cardstack/boxel-ui/components'; import { markdownEscape, type MenuItemOptions, @@ -97,6 +102,7 @@ import { type RealmResourceIdentifier, type VirtualNetwork, isDirectIndexedFieldKey, + cardTypeName, } from '@cardstack/runtime-common'; import { captureQueryFieldSeedData, @@ -113,9 +119,6 @@ import DefaultCardDefTemplate from './default-templates/isolated-and-edit'; import DefaultAtomViewTemplate from './default-templates/atom'; import DefaultHeadTemplate from './default-templates/head'; import MissingTemplate from './default-templates/missing-template'; -import BrokenLinkTemplate, { - type BrokenLinkFormat, -} from './default-templates/broken-link-template'; import FieldDefEditTemplate from './default-templates/field-edit'; import MarkdownTemplate from './default-templates/markdown'; import DefaultMarkdownFallbackTemplate from './default-templates/markdown-fallback'; @@ -1611,6 +1614,7 @@ class LinksTo implements Field { @errorDoc={{broken.errorDoc}} @state={{broken.kind}} @format={{brokenLinkFormat @format defaultFormats.cardDef}} + @displayName={{cardTypeName broken.reference}} @viewCard={{cardCrudFunctions.viewCard}} ...attributes /> diff --git a/packages/base/codemirror-editor.gts b/packages/base/codemirror-editor.gts index 13f14100dd7..46afcde3bcc 100644 --- a/packages/base/codemirror-editor.gts +++ b/packages/base/codemirror-editor.gts @@ -657,7 +657,10 @@ export default class CodeMirrorEditor extends GlimmerComponent { @errorDoc={{@brokenLink.errorDoc}} @state={{@brokenLink.kind}} @format='embedded' + @displayName={{cardTypeName @brokenLink.reference}} @viewCard={{crud.viewCard}} /> diff --git a/packages/base/links-to-many-component.gts b/packages/base/links-to-many-component.gts index bf45995b034..f14b770ee77 100644 --- a/packages/base/links-to-many-component.gts +++ b/packages/base/links-to-many-component.gts @@ -16,7 +16,6 @@ import { isFileDef, brokenLinkFormat, } from './card-api'; -import BrokenLinkTemplate from './default-templates/broken-link-template'; import { getRelationshipMembershipState, type RelationshipState, @@ -29,7 +28,12 @@ import { PermissionsConsumer, getBoxComponent, } from './field-component'; -import { Button, IconButton, Pill } from '@cardstack/boxel-ui/components'; +import { + BrokenLinkTemplate, + Button, + IconButton, + Pill, +} from '@cardstack/boxel-ui/components'; import { restartableTask } from 'ember-concurrency'; import { chooseCard, @@ -46,6 +50,7 @@ import { uuidv4, CardCrudFunctionsContextName, CardErrorJSONAPI, + cardTypeName, } from '@cardstack/runtime-common'; import { IconMinusCircle, @@ -303,6 +308,7 @@ class LinksToManyStandardEditor extends GlimmerComponent @@ -458,6 +464,7 @@ class LinksToManyCompactEditor extends GlimmerComponent @@ -715,6 +722,7 @@ export function getLinksToManyComponent({ effectiveFormat effectiveFormat }} + @displayName={{cardTypeName broken.reference}} @viewCard={{crud.viewCard}} data-test-plural-view-item={{i}} /> diff --git a/packages/boxel-ui/addon/raw-icons/warning-triangle-filled.svg b/packages/boxel-ui/addon/raw-icons/warning-triangle-filled.svg new file mode 100644 index 00000000000..f7f933bc29e --- /dev/null +++ b/packages/boxel-ui/addon/raw-icons/warning-triangle-filled.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/boxel-ui/addon/src/components.ts b/packages/boxel-ui/addon/src/components.ts index 75a1914ede4..4068568f948 100644 --- a/packages/boxel-ui/addon/src/components.ts +++ b/packages/boxel-ui/addon/src/components.ts @@ -3,6 +3,14 @@ import AddButton from './components/add-button/index.gts'; import Alert from './components/alert/index.gts'; import Avatar from './components/avatar/index.gts'; import BasicFitted from './components/basic-fitted/index.gts'; +import BrokenLink, { + type BrokenLinkErrorDoc, + type BrokenLinkFormat, + type BrokenLinkItemType, + type BrokenLinkState, + type BrokenLinkTemplateArgs, + type BrokenLinkViewFn, +} from './components/broken-link/index.gts'; import Button from './components/button/index.gts'; import BoxelButton from './components/button/index.gts'; import CardContainer from './components/card-container/index.gts'; @@ -95,6 +103,12 @@ import ViewSelector, { } from './components/view-selector/index.gts'; export { + type BrokenLinkErrorDoc, + type BrokenLinkFormat, + type BrokenLinkItemType, + type BrokenLinkState, + type BrokenLinkTemplateArgs, + type BrokenLinkViewFn, type Filter, type FittedCardLayout, type FittedCardTitleTag, @@ -126,6 +140,7 @@ export { BoxelMultiSelectBasic, BoxelSelect, BoxelTag, + BrokenLink as BrokenLinkTemplate, Button, CardContainer, CardHeader, diff --git a/packages/base/default-templates/broken-link-template.gts b/packages/boxel-ui/addon/src/components/broken-link/index.gts similarity index 86% rename from packages/base/default-templates/broken-link-template.gts rename to packages/boxel-ui/addon/src/components/broken-link/index.gts index bf27bcb0ae6..d712035b710 100644 --- a/packages/base/default-templates/broken-link-template.gts +++ b/packages/boxel-ui/addon/src/components/broken-link/index.gts @@ -1,37 +1,74 @@ -import GlimmerComponent from '@glimmer/component'; -import { tracked } from '@glimmer/tracking'; +import LinkOffIcon from '@cardstack/boxel-icons/link-off'; import { on } from '@ember/modifier'; import { guidFor } from '@ember/object/internals'; import { htmlSafe } from '@ember/template'; +import GlimmerComponent from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; import { modifier } from 'ember-modifier'; -import LinkOffIcon from '@cardstack/boxel-icons/link-off'; -import { Button, CopyButton } from '@cardstack/boxel-ui/components'; -import { cardTypeName } from '@cardstack/runtime-common'; -import type { SerializedError } from '@cardstack/runtime-common'; -import type { ViewCardFn } from '../card-api'; + +import WarningTriangleFilled from '../../icons/warning-triangle-filled.gts'; +import Button from '../button/index.gts'; +import ContextButton from '../context-button/index.gts'; +import CopyButton from '../copy-button/index.gts'; type TipCorner = 'tl' | 'tr' | 'bl' | 'br'; export type BrokenLinkState = 'error' | 'not-found'; export type BrokenLinkFormat = 'isolated' | 'fitted' | 'embedded' | 'atom'; +// The kind of thing the broken reference points at. Card sites always pass +// 'card'; the BFM chooser passes 'file' for `:file[...]` refs. +export type BrokenLinkItemType = 'card' | 'file'; + +// The failure payload the overlay reads. Kept local so boxel-ui carries no +// dependency on runtime-common (which would invert the existing +// runtime-common → boxel-ui edge into a cycle). It lists only the fields the +// template renders; runtime-common's `SerializedError` is a structural +// superset, so base callers can pass one unchanged. +export interface BrokenLinkErrorDoc { + additionalErrors?: Array<{ + message?: string; + stack?: string; + status?: number; + title?: string; + }> | null; + message?: string; + stack?: string; + status?: number; + title?: string; +} + +// Navigates to the broken reference for "Open anyway". Local so boxel-ui needn't +// import base's `ViewCardFn`; base's wider `crud.viewCard` (its first param +// accepts `URL`) stays assignable to this. +export type BrokenLinkViewFn = (url: URL) => void; export interface BrokenLinkTemplateArgs { brokenUrl: string; - errorDoc: SerializedError; - state: BrokenLinkState; + // Human-readable label shown next to the link-off icon. Card sites pass the + // card type name; the BFM file chooser passes the filename. Falls back to + // the capitalized `itemType` ('Card' / 'File') when omitted. + displayName?: string; + errorDoc: BrokenLinkErrorDoc; format: BrokenLinkFormat; + // The kind of reference, used for the reveal-overlay headline ("Linked card + // not found" vs "Linked file not found") and as the fallback label when no + // `displayName` is given. `linksTo` field sites are always cards; the BFM + // chooser passes 'file' for `:file[...]` refs. Falls back to 'card' when + // omitted. + itemType?: BrokenLinkItemType; + state: BrokenLinkState; // Threaded from the field component's CardCrudFunctions. When present, the // overlay offers an "Open anyway" affordance that navigates to the broken // reference (a stack visit in interact mode, a code-editor jump in code // mode — whatever the host's viewCard does for the current submode). - viewCard?: ViewCardFn; + viewCard?: BrokenLinkViewFn; } interface NormalizedAdditionalError { message: string; + stack?: string; status?: number; title?: string; - stack?: string; } // Only http(s) references are navigable. The brokenUrl is a card reference @@ -50,62 +87,24 @@ function parseHttpUrl(url: string): URL | null { } } -// Solid amber warning triangle with a black "!" — a two-tone svg the -// single-colour boxel icon can't express. Shared by the reveal trigger and the -// overlay title; `@size` sets both svg dimensions so each caller can match its -// context. -class WarningIcon extends GlimmerComponent<{ - Element: SVGElement; - Args: { size: string }; -}> { - -} - export default class BrokenLinkTemplate extends GlimmerComponent<{ - Element: HTMLDivElement; Args: BrokenLinkTemplateArgs; + Element: HTMLDivElement; }> { + private get itemType(): BrokenLinkItemType { + return this.args.itemType ?? 'card'; + } + // The placeholder box is identical for every failure — what went wrong only - // surfaces inside the reveal overlay. `typeName` is the human-readable label - // shown next to the link-off icon, derived from the reference URL. - private get typeName(): string { - return cardTypeName(this.args.brokenUrl); + // surfaces inside the reveal overlay. `displayName` is the human-readable + // label shown next to the link-off icon; when the caller supplies none it + // falls back to the capitalized itemType ('Card' / 'File'). + private get displayName(): string { + let { displayName } = this.args; + if (displayName) { + return displayName; + } + return this.itemType.charAt(0).toUpperCase() + this.itemType.slice(1); } private get isNotFound() { @@ -114,8 +113,8 @@ export default class BrokenLinkTemplate extends GlimmerComponent<{ private get headline() { return this.isNotFound - ? 'Linked card not found' - : 'Linked card failed to load'; + ? `Linked ${this.itemType} not found` + : `Linked ${this.itemType} failed to load`; } private get statusLabel(): string { @@ -181,9 +180,10 @@ export default class BrokenLinkTemplate extends GlimmerComponent<{ return `${n} additional error${n === 1 ? '' : 's'}`; } - // The toggle checkbox and the trigger/close labels are wired by id; the - // overlay and tip anchor by dashed-ident. All must be unique per instance so - // multiple broken links on a page don't cross-trigger. + // The toggle checkbox and the trigger