From 7554dae341dec696c80130354e71f962479a4087 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Tue, 14 Jul 2026 08:02:47 +0100 Subject: [PATCH 01/13] Ability to register custom link types --- .../components/fieldtypes/LinkFieldtype.vue | 134 +++++----- .../fieldtypes/LinkIndexFieldtype.vue | 5 +- src/Fieldtypes/Link.php | 225 +++++++--------- src/Fieldtypes/Link/AssetLinkType.php | 52 ++++ src/Fieldtypes/Link/EntryLinkType.php | 85 ++++++ src/Fieldtypes/Link/LinkType.php | 55 ++++ src/Providers/ExtensionServiceProvider.php | 6 + src/Routing/ResolveRedirect.php | 39 +-- tests/Fieldtypes/LinkTest.php | 244 +++++++++++++++++- 9 files changed, 606 insertions(+), 239 deletions(-) create mode 100644 src/Fieldtypes/Link/AssetLinkType.php create mode 100644 src/Fieldtypes/Link/EntryLinkType.php create mode 100644 src/Fieldtypes/Link/LinkType.php diff --git a/resources/js/components/fieldtypes/LinkFieldtype.vue b/resources/js/components/fieldtypes/LinkFieldtype.vue index 0b48bf99c7b..fb1b827b933 100644 --- a/resources/js/components/fieldtypes/LinkFieldtype.vue +++ b/resources/js/components/fieldtypes/LinkFieldtype.vue @@ -2,36 +2,22 @@
-
- - - - - -
@@ -45,7 +31,7 @@ import { markRaw } from 'vue'; import { UPDATE_DEBOUNCE_MS } from './constants'; export default { - components: { Input, Text, Select }, + components: { Input, Select }, mixins: [Fieldtype], provide: { @@ -57,8 +43,7 @@ export default { option: this.meta.initialOption, options: this.initialOptions(), urlValue: this.meta.initialUrl, - selectedEntries: this.meta.initialSelectedEntries, - selectedAssets: this.meta.initialSelectedAssets, + selectedByType: this.initialSelectedByType(this.meta), metaChanging: false, }; }, @@ -71,26 +56,29 @@ export default { }, computed: { - entryValue() { - return this.selectedEntries.length ? `entry::${this.selectedEntries[0]}` : null; + matchedType() { + return this.meta.types[this.option] ?? null; + }, + + matchedTypeComponent() { + return `${this.matchedType.component}-fieldtype`; }, - assetValue() { - return this.selectedAssets.length ? `asset::${this.selectedAssets[0]}` : null; + typeValue() { + const selected = this.selectedByType[this.option]; + return selected && selected.length ? `${this.option}::${selected[0]}` : null; }, replicatorPreview() { if (!this.showFieldPreviews) return; - switch (this.option) { - case 'url': - return this.urlValue; - case 'first-child': - return __('First Child'); - case 'entry': - return data_get(this.meta, 'entry.meta.data.0.title', this.entryValue); - case 'asset': - return data_get(this.meta, 'asset.meta.data.0.basename', this.assetValue); + if (this.option === 'url') return this.urlValue; + if (this.option === 'first-child') return __('First Child'); + + if (this.matchedType) { + return data_get(this.meta, `types.${this.option}.meta.data.0.title`) + ?? data_get(this.meta, `types.${this.option}.meta.data.0.basename`) + ?? this.typeValue; } return this.value; @@ -107,18 +95,10 @@ export default { this.updateDebounced(this.urlValue); } else if (option === 'first-child') { this.update('@child'); - } else if (option === 'entry') { - if (this.entryValue) { - this.update(this.entryValue); - } else { - setTimeout(() => this.$refs.entries.linkExistingItem(), 0); - } - } else if (option === 'asset') { - if (this.assetValue) { - this.update(this.assetValue); - } else { - setTimeout(() => this.$refs.assets.openSelector(), 0); - } + } else if (this.matchedType) { + this.typeValue + ? this.update(this.typeValue) + : this.$nextTick(() => this.openTypeSelector()); } this.updateMeta({ ...this.meta, initialOption: option }); @@ -136,8 +116,7 @@ export default { this.metaChanging = true; this.urlValue = meta.initialUrl; this.option = meta.initialOption; - this.selectedEntries = meta.initialSelectedEntries; - this.selectedAssets = meta.initialSelectedAssets; + this.selectedByType = this.initialSelectedByType(meta); this.$nextTick(() => (this.metaChanging = false)); }, }, @@ -151,22 +130,45 @@ export default { this.meta.showFirstChildOption ? { label: __('First Child'), value: 'first-child' } : null, - { label: __('Entry'), value: 'entry' }, - - this.meta.showAssetOption ? { label: __('Asset'), value: 'asset', maxFiles: 1 } : null, + ...Object.entries(this.meta.types).map(([handle, type]) => ({ label: type.title, value: handle })), ].filter((option) => option); }, - entriesSelected(entries) { - this.selectedEntries = entries; - this.update(this.entryValue); - this.updateMeta({ ...this.meta, initialSelectedEntries: entries }); + initialSelectedByType(meta) { + return Object.fromEntries( + Object.entries(meta.types).map(([handle, type]) => [handle, type.selected]) + ); + }, + + openTypeSelector() { + const field = this.$refs.typeField; + + if (!field) return; + + if (typeof field.linkExistingItem === 'function') field.linkExistingItem(); + else if (typeof field.openSelector === 'function') field.openSelector(); + }, + + typeSelected(selected) { + this.selectedByType = { ...this.selectedByType, [this.option]: selected }; + this.update(this.typeValue); + this.updateMeta({ + ...this.meta, + types: { + ...this.meta.types, + [this.option]: { ...this.meta.types[this.option], selected }, + }, + }); }, - assetsSelected(assets) { - this.selectedAssets = assets; - this.update(this.assetValue); - this.updateMeta({ ...this.meta, initialSelectedAssets: assets }); + updateTypeMeta(typeMeta) { + this.updateMeta({ + ...this.meta, + types: { + ...this.meta.types, + [this.option]: { ...this.meta.types[this.option], meta: typeMeta }, + }, + }); }, }, }; diff --git a/resources/js/components/fieldtypes/LinkIndexFieldtype.vue b/resources/js/components/fieldtypes/LinkIndexFieldtype.vue index 11a461df9bc..fe76aaf1d1e 100644 --- a/resources/js/components/fieldtypes/LinkIndexFieldtype.vue +++ b/resources/js/components/fieldtypes/LinkIndexFieldtype.vue @@ -7,10 +7,7 @@ const props = defineProps(IndexFieldtype.props); diff --git a/src/Fieldtypes/Link.php b/src/Fieldtypes/Link.php index 3bacea22de8..a356192ccfe 100644 --- a/src/Fieldtypes/Link.php +++ b/src/Fieldtypes/Link.php @@ -5,13 +5,11 @@ use Facades\Statamic\Routing\ResolveRedirect; use Statamic\Contracts\Entries\Collection; use Statamic\Contracts\Entries\Entry; -use Statamic\Facades; -use Statamic\Facades\Blink; use Statamic\Facades\GraphQL; -use Statamic\Facades\Site; use Statamic\Fields\Field; use Statamic\Fields\Fieldtype; use Statamic\Fieldtypes\Link\ArrayableLink; +use Statamic\Fieldtypes\Link\LinkType; use Statamic\GraphQL\Types\LinkValueType; use Statamic\Support\Str; @@ -22,33 +20,36 @@ class Link extends Fieldtype use UpdatesReferences; protected $categories = ['relationship']; + protected static array $types = []; + + public static function extend(string $handle, string $type): void + { + static::$types[$handle] = $type; + + if ($fields = static::resolveType($handle)->configFieldItems()) { + static::appendConfigFields($fields); + } + } + + public static function resolveType(string $handle): LinkType + { + return app(static::$types[$handle])->setHandle($handle); + } + + public static function types(): array + { + return collect(static::$types) + ->keys() + ->mapWithKeys(fn (string $handle): array => [$handle => static::resolveType($handle)]) + ->all(); + } + protected function configFieldItems(): array { return [ [ 'display' => __('Input Behavior'), 'fields' => [ - 'collections' => [ - 'display' => __('Collections'), - 'instructions' => __('statamic::fieldtypes.link.config.collections'), - 'type' => 'collections', - 'mode' => 'select', - 'width' => '50', - ], - 'container' => [ - 'display' => __('Container'), - 'instructions' => __('statamic::fieldtypes.link.config.container'), - 'type' => 'asset_container', - 'mode' => 'select', - 'max_items' => 1, - 'width' => '50', - ], - 'select_across_sites' => [ - 'display' => __('Select Across Sites'), - 'instructions' => __('statamic::fieldtypes.entries.config.select_across_sites'), - 'type' => 'toggle', - 'width' => '50', - ], 'default_option' => [ 'display' => __('Default Option'), 'instructions' => __('statamic::fieldtypes.link.config.default_option'), @@ -57,12 +58,7 @@ protected function configFieldItems(): array 'width' => '50', 'clearable' => true, 'placeholder' => __('Default'), - 'options' => [ - 'asset' => __('Asset'), - 'entry' => __('Entry'), - 'first-child' => __('First Child'), - 'url' => __('URL'), - ], + 'options' => $this->defaultOptionOptions(), ], ], ], @@ -99,51 +95,78 @@ public function preProcessIndex($data) return null; } - $type = match (true) { - $data === '@child' => 'child', - Str::startsWith($data, 'asset::') => 'asset', - Str::startsWith($data, 'entry::') => 'entry', - default => 'url', - }; + $linkType = $this->linkTypeFor($data); - return ['type' => $type, 'url' => $url]; + return [ + 'type' => $data === '@child' ? 'child' : ($linkType ?? 'url'), + 'url' => $url, + 'icon' => match (true) { + $data === '@child' => 'page', + $linkType !== null => static::resolveType($linkType)->icon(), + default => 'external-link', + }, + ]; } public function preload() { $value = $this->field->value(); - $showAssetOption = $this->showAssetOption(); + $linkType = $this->linkTypeFor($value); - $selectedEntry = $value && Str::startsWith($value, 'entry::') ? Str::after($value, 'entry::') : null; + $url = ($value !== '@child' && ! $linkType) ? $value : null; - $selectedAsset = $value && Str::startsWith($value, 'asset::') ? Str::after($value, 'asset::') : null; + $types = []; - $url = ($value !== '@child' && ! $selectedEntry && ! $selectedAsset) ? $value : null; + foreach (static::types() as $handle => $type) { + if (! $type->visible($this->field)) { + continue; + } - $entryFieldtype = $this->nestedEntriesFieldtype($selectedEntry); + if (! $config = $type->fieldtype($this->field)) { + continue; + } - $assetFieldtype = $showAssetOption ? $this->nestedAssetsFieldtype($selectedAsset) : null; + $selected = $linkType === $handle ? Str::after($value, "{$handle}::") : null; + + $nestedField = new Field($handle, $config); + $nestedField->setValue($selected); + $nestedFieldtype = $nestedField->fieldtype(); + + $types[$handle] = [ + 'title' => $type->title(), + 'icon' => $type->icon(), + 'component' => $nestedFieldtype->component(), + 'config' => $nestedFieldtype->config(), + 'meta' => $nestedFieldtype->preload(), + 'selected' => $selected ? [$selected] : [], + ]; + } return [ 'initialUrl' => $url, - 'initialSelectedEntries' => $selectedEntry ? [$selectedEntry] : [], - 'initialSelectedAssets' => $selectedAsset ? [$selectedAsset] : [], - 'initialOption' => $this->initialOption($value, $selectedEntry, $selectedAsset), + 'initialOption' => $this->initialOption($value, $linkType), 'showFirstChildOption' => $this->showFirstChildOption(), - 'showAssetOption' => $showAssetOption, - 'entry' => [ - 'config' => $entryFieldtype->config(), - 'meta' => $entryFieldtype->preload(), - ], - 'asset' => $showAssetOption ? [ - 'config' => $assetFieldtype->config(), - 'meta' => $assetFieldtype->preload(), - ] : null, + 'types' => $types, ]; } - private function initialOption($value, $entry, $asset) + private function linkTypeFor($value): ?string + { + if (! $value) { + return null; + } + + foreach (static::types() as $type) { + if (Str::startsWith($value, "{$type->handle()}::")) { + return $type->handle(); + } + } + + return null; + } + + private function initialOption($value, ?string $matchedHandle) { if (! $value) { $fallback = $this->field->isRequired() && ! $this->field->hasSometimesRule() ? 'url' : null; @@ -153,7 +176,7 @@ private function initialOption($value, $entry, $asset) return $fallback; } - if ($option === 'asset' && ! $this->showAssetOption()) { + if ($option && $option !== 'first-child' && $option !== 'url' && ! $this->isLinkTypeVisible($option)) { return $fallback; } @@ -162,67 +185,30 @@ private function initialOption($value, $entry, $asset) if ($value === '@child') { return 'first-child'; - } elseif ($entry) { - return 'entry'; - } elseif ($asset) { - return 'asset'; } - return 'url'; + return $matchedHandle ?? 'url'; } - private function nestedEntriesFieldtype($value): Fieldtype + private function isLinkTypeVisible(string $handle): bool { - $entryField = (new Field('entry', [ - 'type' => 'entries', - 'max_items' => 1, - 'create' => false, - 'select_across_sites' => $this->canSelectAcrossSites(), - ])); - - $entryField->setValue($value); - - $entryField->setConfig(array_merge( - $entryField->config(), - ['collections' => $this->collections()] - )); - - return $entryField->fieldtype(); - } - - private function nestedAssetsFieldtype($value): Fieldtype - { - $assetField = (new Field('entry', [ - 'type' => 'assets', - 'max_files' => 1, - 'mode' => 'list', - ])); - - $assetField->setValue($value); - - $assetField->setConfig(array_merge( - $assetField->config(), - ['container' => $this->config('container')] - )); + $linkType = static::types()[$handle] ?? null; - return $assetField->fieldtype(); + return $linkType?->visible($this->field); } - private function collections() + private function defaultOptionOptions(): array { - $collections = $this->config('collections'); + $options = []; - if (empty($collections)) { - $site = Site::current()->handle(); - - $collections = Blink::once('routable-collection-handles-'.$site, function () use ($site) { - return Facades\Collection::all()->reject(function ($collection) use ($site) { - return is_null($collection->route($site)); - })->map->handle()->values()->all(); - }); + foreach (static::types() as $handle => $type) { + $options[$handle] = $type->title(); } - return $collections; + $options['first-child'] = __('First Child'); + $options['url'] = __('URL'); + + return $options; } private function showFirstChildOption() @@ -240,43 +226,16 @@ private function showFirstChildOption() return $collection->hasStructure() && $collection->structure()->maxDepth() !== 1; } - private function showAssetOption() - { - return $this->config('container') !== null; - } - public function toGqlType() { return GraphQL::type(LinkValueType::NAME); } - protected function getConfiguredCollections() - { - return empty($collections = $this->config('collections')) - ? \Statamic\Facades\Collection::handles()->all() - : $collections; - } - private function canSelectAcrossSites(): bool { return $this->config('select_across_sites', false); } - private function availableSites() - { - if (! Site::hasMultiple()) { - return []; - } - - $configuredSites = collect($this->getConfiguredCollections())->flatMap(fn ($collection) => \Statamic\Facades\Collection::find($collection)->sites()); - - return Site::authorized() - ->when(isset($configuredSites), fn ($sites) => $sites->filter(fn ($site) => $configuredSites->contains($site->handle()))) - ->map->handle() - ->values() - ->all(); - } - public function replaceAssetReferences($data, ?string $newValue, string $oldValue, string $container) { if ($this->config('container') !== $container) { diff --git a/src/Fieldtypes/Link/AssetLinkType.php b/src/Fieldtypes/Link/AssetLinkType.php new file mode 100644 index 00000000000..ec9423916e7 --- /dev/null +++ b/src/Fieldtypes/Link/AssetLinkType.php @@ -0,0 +1,52 @@ + [ + 'display' => __('Container'), + 'instructions' => __('statamic::fieldtypes.link.config.container'), + 'type' => 'asset_container', + 'mode' => 'select', + 'max_items' => 1, + 'width' => '50', + ], + ]; + } + + public function resolve(string $id, $parent = null, bool $localize = false): mixed + { + return Facades\Asset::find($id); + } + + public function fieldtype(Field $field): array + { + return [ + 'type' => 'assets', + 'max_files' => 1, + 'mode' => 'list', + 'container' => $field->get('container'), + ]; + } + + public function visible(Field $field): bool + { + return $field->get('container') !== null; + } +} diff --git a/src/Fieldtypes/Link/EntryLinkType.php b/src/Fieldtypes/Link/EntryLinkType.php new file mode 100644 index 00000000000..29c2b4a3399 --- /dev/null +++ b/src/Fieldtypes/Link/EntryLinkType.php @@ -0,0 +1,85 @@ + [ + 'display' => __('Collections'), + 'instructions' => __('statamic::fieldtypes.link.config.collections'), + 'type' => 'collections', + 'mode' => 'select', + 'width' => '50', + ], + 'select_across_sites' => [ + 'display' => __('Select Across Sites'), + 'instructions' => __('statamic::fieldtypes.entries.config.select_across_sites'), + 'type' => 'toggle', + 'width' => '50', + ], + ]; + } + + public function resolve(string $id, $parent = null, bool $localize = false): mixed + { + if (! $entry = Facades\Entry::find($id)) { + return null; + } + + if (! $localize) { + return $entry; + } + + $site = $parent instanceof Localization + ? $parent->locale() + : Site::current()->handle(); + + return $entry->in($site) ?? $entry; + } + + public function fieldtype(Field $field): array + { + return [ + 'type' => 'entries', + 'max_items' => 1, + 'create' => false, + 'select_across_sites' => $field->get('select_across_sites', false), + 'collections' => $this->collections($field), + ]; + } + + private function collections(Field $field): array + { + $collections = $field->get('collections'); + + if (empty($collections)) { + $site = Site::current()->handle(); + + $collections = Blink::once('routable-collection-handles-'.$site, function () use ($site) { + return Facades\Collection::all()->reject(function ($collection) use ($site) { + return is_null($collection->route($site)); + })->map->handle()->values()->all(); + }); + } + + return $collections; + } +} diff --git a/src/Fieldtypes/Link/LinkType.php b/src/Fieldtypes/Link/LinkType.php new file mode 100644 index 00000000000..3e130b4b6ca --- /dev/null +++ b/src/Fieldtypes/Link/LinkType.php @@ -0,0 +1,55 @@ +handle; + } + + public function setHandle(string $handle): static + { + $this->handle = $handle; + + return $this; + } + + public function title(): string + { + if (static::$title) { + return __(static::$title); + } + + return __(Str::title(Str::humanize(static::handle()))); + } + + public function icon(): string + { + return $this->icon ?? "fieldtype-{$this->handle()}"; + } + + public function configFieldItems(): array + { + return []; + } + + abstract public function resolve(string $id, $parent = null, bool $localize = false): mixed; + + abstract public function fieldtype(Field $field): ?array; + + public function visible(Field $field): bool + { + return true; + } +} diff --git a/src/Providers/ExtensionServiceProvider.php b/src/Providers/ExtensionServiceProvider.php index edace27a305..5371de78bcf 100644 --- a/src/Providers/ExtensionServiceProvider.php +++ b/src/Providers/ExtensionServiceProvider.php @@ -270,6 +270,12 @@ public function register() $this->app->instance('statamic.hooks', collect()); } + public function boot() + { + Fieldtypes\Link::extend('entry', Fieldtypes\Link\EntryLinkType::class); + Fieldtypes\Link::extend('asset', Fieldtypes\Link\AssetLinkType::class); + } + protected function registerAddonManifest() { $cachePath = $this->app->bootstrapPath().'/cache/addons.php'; diff --git a/src/Routing/ResolveRedirect.php b/src/Routing/ResolveRedirect.php index 7ac738ae24b..4f2e02f215a 100644 --- a/src/Routing/ResolveRedirect.php +++ b/src/Routing/ResolveRedirect.php @@ -2,11 +2,9 @@ namespace Statamic\Routing; -use Statamic\Contracts\Data\Localization; use Statamic\Contracts\Entries\Entry; -use Statamic\Facades; -use Statamic\Facades\Site; use Statamic\Fields\Values; +use Statamic\Fieldtypes\Link; use Statamic\Fieldtypes\Link\ArrayableLink; use Statamic\Structures\Page; use Statamic\Support\Str; @@ -54,38 +52,19 @@ public function item($redirect, $parent = null, $localize = false) return $redirect->url->value(); } - if (Str::startsWith($redirect, 'entry::')) { - $id = Str::after($redirect, 'entry::'); - - return $this->findEntry($id, $parent, $localize); - } - - if (Str::startsWith($redirect, 'asset::')) { - $id = Str::after($redirect, 'asset::'); - - return Facades\Asset::find($id); + foreach (Link::types() as $linkType) { + if (Str::startsWith($redirect, "{$linkType->handle()}::")) { + return $linkType->resolve( + id: Str::after($redirect, "{$linkType->handle()}::"), + parent: $parent, + localize: $localize + ); + } } return is_numeric($redirect) ? (int) $redirect : $redirect; } - private function findEntry($id, $parent, $localize) - { - if (! ($entry = Facades\Entry::find($id))) { - return null; - } - - if (! $localize) { - return $entry; - } - - $site = $parent instanceof Localization - ? $parent->locale() - : Site::current()->handle(); - - return $entry->in($site) ?? $entry; - } - private function firstChild($parent) { if (! $parent || ! $parent instanceof Entry) { diff --git a/tests/Fieldtypes/LinkTest.php b/tests/Fieldtypes/LinkTest.php index d194514389f..40b95ed4c41 100644 --- a/tests/Fieldtypes/LinkTest.php +++ b/tests/Fieldtypes/LinkTest.php @@ -11,6 +11,7 @@ use Statamic\Fields\ArrayableString; use Statamic\Fields\Field; use Statamic\Fieldtypes\Link; +use Statamic\Fieldtypes\Link\LinkType; use Tests\PreventSavingStacheItemsToDisk; use Tests\TestCase; @@ -100,7 +101,7 @@ public function it_pre_processes_url_for_index() $fieldtype = (new Link)->setField(new Field('test', ['type' => 'link'])); $this->assertEquals( - ['type' => 'url', 'url' => 'https://example.com'], + ['type' => 'url', 'url' => 'https://example.com', 'icon' => 'external-link'], $fieldtype->preProcessIndex('https://example.com') ); } @@ -111,7 +112,7 @@ public function it_pre_processes_numeric_value_for_index() $fieldtype = (new Link)->setField(new Field('test', ['type' => 'link'])); $this->assertEquals( - ['type' => 'url', 'url' => 404], + ['type' => 'url', 'url' => 404, 'icon' => 'external-link'], $fieldtype->preProcessIndex('404') ); } @@ -127,7 +128,7 @@ public function it_pre_processes_entry_reference_for_index() $fieldtype = (new Link)->setField(new Field('test', ['type' => 'link'])); $this->assertEquals( - ['type' => 'entry', 'url' => '/the-entry-url'], + ['type' => 'entry', 'url' => '/the-entry-url', 'icon' => 'collections'], $fieldtype->preProcessIndex('entry::entry-id') ); } @@ -143,7 +144,7 @@ public function it_pre_processes_asset_reference_for_index() $fieldtype = (new Link)->setField(new Field('test', ['type' => 'link'])); $this->assertEquals( - ['type' => 'asset', 'url' => '/assets/image.jpg'], + ['type' => 'asset', 'url' => '/assets/image.jpg', 'icon' => 'assets'], $fieldtype->preProcessIndex('asset::main::image.jpg') ); } @@ -202,7 +203,7 @@ public function it_pre_processes_first_child_for_index() $fieldtype = (new Link)->setField($field); $this->assertEquals( - ['type' => 'child', 'url' => '/parent/child'], + ['type' => 'child', 'url' => '/parent/child', 'icon' => 'page'], $fieldtype->preProcessIndex('@child') ); } @@ -231,7 +232,7 @@ public function it_pre_processes_first_child_for_index_when_parent_is_root() $fieldtype = (new Link)->setField($field); $this->assertEquals( - ['type' => 'child', 'url' => '/first-child'], + ['type' => 'child', 'url' => '/first-child', 'icon' => 'page'], $fieldtype->preProcessIndex('@child') ); } @@ -298,4 +299,235 @@ public static function initialOptionProvider(): array 'null when has sometimes rule even if required' => [['type' => 'link', 'required' => true, 'validate' => 'sometimes'], null, false, null], ]; } + + #[Test] + public function it_registers_a_custom_link_type() + { + Link::extend('link-extend-test-basic', TestBasicLinkType::class); + + $this->assertArrayHasKey('link-extend-test-basic', Link::types()); + + $type = Link::resolveType('link-extend-test-basic'); + + $this->assertInstanceOf(TestBasicLinkType::class, $type); + $this->assertEquals('link-extend-test-basic', $type->handle()); + $this->assertEquals('Basic Test Type', $type->title()); + } + + #[Test] + public function it_appends_config_field_items_into_links_config_fields() + { + Link::extend('link-extend-test-config', TestConfigFieldsLinkType::class); + + $fields = (new Link)->configFields(); + + $this->assertTrue($fields->has('link_extend_test_field')); + $this->assertEquals('text', $fields->get('link_extend_test_field')->type()); + } + + #[Test] + public function it_resolves_a_custom_type_through_resolve_redirect() + { + Link::extend('link-extend-test-resolve', TestResolvingLinkType::class); + + $resolver = new \Statamic\Routing\ResolveRedirect; + + $this->assertEquals('resolved:the-id', $resolver->item('link-extend-test-resolve::the-id')); + } + + #[Test] + public function it_passes_parent_and_localize_through_to_resolve() + { + Link::extend('link-extend-test-context', TestContextCapturingLinkType::class); + + $resolver = new \Statamic\Routing\ResolveRedirect; + + $resolver->item('link-extend-test-context::the-id', 'some-parent', true); + + $this->assertEquals(['the-id', 'some-parent', true], TestContextCapturingLinkType::$captured); + } + + #[Test] + public function it_includes_a_custom_type_in_preload_when_visible() + { + $this->setUpRoutableCollection(); + + Link::extend('link-extend-test-visible', TestAlwaysVisibleLinkType::class); + + $fieldtype = (new Link)->setField(new Field('test', ['type' => 'link'])); + + $types = $fieldtype->preload()['types']; + + $this->assertArrayHasKey('link-extend-test-visible', $types); + $this->assertEquals('Always Visible', $types['link-extend-test-visible']['title']); + } + + #[Test] + public function it_excludes_a_custom_type_from_preload_when_not_visible() + { + $this->setUpRoutableCollection(); + + Link::extend('link-extend-test-hidden', TestNeverVisibleLinkType::class); + + $fieldtype = (new Link)->setField(new Field('test', ['type' => 'link'])); + + $this->assertArrayNotHasKey('link-extend-test-hidden', $fieldtype->preload()['types']); + } + + #[Test] + public function it_excludes_a_custom_type_with_a_null_fieldtype_from_preload() + { + $this->setUpRoutableCollection(); + + Link::extend('link-extend-test-no-picker', TestNoPickerLinkType::class); + + $fieldtype = (new Link)->setField(new Field('test', ['type' => 'link'])); + + $this->assertArrayNotHasKey('link-extend-test-no-picker', $fieldtype->preload()['types']); + } + + #[Test] + public function it_includes_custom_type_and_icon_in_pre_process_index() + { + Link::extend('link-extend-test-index', TestIndexLinkType::class); + + $fieldtype = (new Link)->setField(new Field('test', ['type' => 'link'])); + + $this->assertEquals( + ['type' => 'link-extend-test-index', 'url' => '/resolved-url', 'icon' => 'custom-icon'], + $fieldtype->preProcessIndex('link-extend-test-index::the-id') + ); + } + + private function setUpRoutableCollection(): void + { + $this->actingAs(tap(Facades\User::make()->makeSuper())->save()); + tap(Facades\Collection::make('pages')->routes('{slug}'))->sites(['en'])->save(); + } +} + +class TestBasicLinkType extends LinkType +{ + protected static ?string $title = 'Basic Test Type'; + + public function resolve(string $id, $parent = null, bool $localize = false): mixed + { + return "resolved:{$id}"; + } + + public function fieldtype(Field $field): ?array + { + return ['type' => 'text']; + } +} + +class TestConfigFieldsLinkType extends LinkType +{ + public function resolve(string $id, $parent = null, bool $localize = false): mixed + { + return null; + } + + public function fieldtype(Field $field): ?array + { + return ['type' => 'text']; + } + + public function configFieldItems(): array + { + return [ + 'link_extend_test_field' => ['type' => 'text'], + ]; + } +} + +class TestResolvingLinkType extends LinkType +{ + public function resolve(string $id, $parent = null, bool $localize = false): mixed + { + return "resolved:{$id}"; + } + + public function fieldtype(Field $field): ?array + { + return null; + } +} + +class TestContextCapturingLinkType extends LinkType +{ + public static $captured; + + public function resolve(string $id, $parent = null, bool $localize = false): mixed + { + static::$captured = [$id, $parent, $localize]; + + return null; + } + + public function fieldtype(Field $field): ?array + { + return null; + } +} + +class TestAlwaysVisibleLinkType extends LinkType +{ + protected static ?string $title = 'Always Visible'; + + public function resolve(string $id, $parent = null, bool $localize = false): mixed + { + return null; + } + + public function fieldtype(Field $field): ?array + { + return ['type' => 'text']; + } +} + +class TestNeverVisibleLinkType extends LinkType +{ + public function resolve(string $id, $parent = null, bool $localize = false): mixed + { + return null; + } + + public function fieldtype(Field $field): ?array + { + return ['type' => 'text']; + } + + public function visible(Field $field): bool + { + return false; + } +} + +class TestNoPickerLinkType extends LinkType +{ + public function resolve(string $id, $parent = null, bool $localize = false): mixed + { + return null; + } + + public function fieldtype(Field $field): ?array + { + return null; + } +} + +class TestIndexLinkType extends LinkType +{ + protected ?string $icon = 'custom-icon'; + + public function resolve(string $id, $parent = null, bool $localize = false): mixed + { + return '/resolved-url'; + } + + public function fieldtype(Field $field): ?array + { + return null; + } } From 326d6b6eb80bfc9812c6acd1bc47b7120a00050c Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Tue, 14 Jul 2026 09:07:53 +0100 Subject: [PATCH 02/13] Support custom link types in bard links --- .../fieldtypes/bard/LinkToolbar.vue | 60 ++++---- src/Fieldtypes/Bard.php | 69 ++++++--- src/Fieldtypes/Bard/LinkMark.php | 24 ++- tests/Fieldtypes/BardTest.php | 142 +++++++++++++++++- 4 files changed, 225 insertions(+), 70 deletions(-) diff --git a/resources/js/components/fieldtypes/bard/LinkToolbar.vue b/resources/js/components/fieldtypes/bard/LinkToolbar.vue index 6edf6ad82ab..8adbed07cfa 100644 --- a/resources/js/components/fieldtypes/bard/LinkToolbar.vue +++ b/resources/js/components/fieldtypes/bard/LinkToolbar.vue @@ -3,7 +3,7 @@
{ - if (type.type === 'asset' && !this.config.container) { - return false; - } - return true; - }); + linkTypes() { + return [ + { type: 'url', title: __('URL') }, + ...Object.entries(this.bard.meta.linkTypes ?? {}).map(([handle, type]) => ({ + type: handle, + title: type.title, + })), + { type: 'mailto', title: __('Email') }, + { type: 'tel', title: __('Phone') }, + ]; + }, + + registeredLinkType() { + return this.bard.meta.linkTypes?.[this.linkType] ?? null; }, displayValue() { switch (this.linkType) { case 'url': return this.url.url; - case 'entry': - return this.itemData.entry ? this.itemData.entry.title : null; case 'asset': + // Assets use `basename` as their display name rather than `title`. return this.itemData.asset ? this.itemData.asset.basename : null; case 'mailto': return this.urlData.mailto ? this.urlData.mailto : null; case 'tel': return this.urlData.tel ? this.urlData.tel : null; + default: + return this.itemData[this.linkType] ? this.itemData[this.linkType].title : null; } }, @@ -263,22 +264,17 @@ export default { }, selectedEntryValue() { - const { type, id } = this.parseDataUrl(this.url.entry); + const { type, id } = this.parseDataUrl(this.url[this.linkType]); - return type === 'entry' && id ? [id] : []; + return type === this.linkType && id ? [id] : []; }, selectedEntryData() { - return this.itemData.entry ? [this.itemData.entry] : []; + return this.itemData[this.linkType] ? [this.itemData[this.linkType]] : []; }, relationshipConfig() { - return { - type: 'entries', - collections: this.collections, - max_items: 1, - select_across_sites: this.config.select_across_sites, - }; + return this.registeredLinkType?.config; }, itemDataUrl() { @@ -322,7 +318,7 @@ export default { }, canHaveTarget() { - return ['url', 'entry', 'asset'].includes(this.linkType); + return !['mailto', 'tel'].includes(this.linkType); }, selectedTextIsEmail() { @@ -441,14 +437,14 @@ export default { }, openSelector() { - if (this.linkType === 'entry') { - this.openEntrySelector(); - } else if (this.linkType === 'asset') { + if (this.linkType === 'asset') { this.openAssetSelector(); + } else if (this.registeredLinkType) { + this.openRelationshipSelector(); } }, - openEntrySelector() { + openRelationshipSelector() { this.$refs.relationshipInput.openSelector(); }, @@ -475,7 +471,7 @@ export default { entrySelected(data) { if (data.length) { - this.selectItem('entry', data[0]); + this.selectItem(this.linkType, data[0]); } }, diff --git a/src/Fieldtypes/Bard.php b/src/Fieldtypes/Bard.php index 1fc62114867..db37a564560 100644 --- a/src/Fieldtypes/Bard.php +++ b/src/Fieldtypes/Bard.php @@ -7,17 +7,16 @@ use Illuminate\Contracts\Validation\DataAwareRule; use Illuminate\Contracts\Validation\ValidationRule; use Statamic\Data\NestedFieldUpdater; -use Statamic\Facades\Asset; use Statamic\Facades\AssetContainer; use Statamic\Facades\Blink; use Statamic\Facades\Collection; -use Statamic\Facades\Entry; use Statamic\Facades\GraphQL; use Statamic\Facades\Site; use Statamic\Fields\Field; use Statamic\Fields\Fields; use Statamic\Fields\Value; use Statamic\Fieldtypes\Bard\Augmentor; +use Statamic\Fieldtypes\Link\LinkType; use Statamic\GraphQL\Types\BardSetsType; use Statamic\GraphQL\Types\BardTextType; use Statamic\GraphQL\Types\ReplicatorSetType; @@ -660,6 +659,7 @@ public function preload() '__collaboration' => ['existing'], 'linkCollections' => $linkCollections, 'linkData' => (object) $this->getLinkData($value), + 'linkTypes' => $this->linkTypesForToolbar(), ]; if ( @@ -791,30 +791,55 @@ private function extractLinkDataFromNode($node) private function getLinkDataForUrl($url) { $ref = str($url)->after('statamic://')->before('?')->before('#')->toString(); - [$type, $id] = explode('::', $ref, 2); + [$handle, $id] = explode('::', $ref, 2); - $data = null; + return [$ref => $this->linkDataForType($handle, $id)]; + } - switch ($type) { - case 'entry': - if ($entry = Entry::find($id)) { - $data = [ - 'title' => $entry->get('title'), - 'permalink' => $entry->absoluteUrl(), - ]; - } - break; - case 'asset': - if ($asset = Asset::find($id)) { - $data = [ - 'basename' => $asset->basename(), - 'thumbnail' => $asset->thumbnailUrl(), - ]; - } - break; + private function linkDataForType(string $handle, string $id): ?array + { + if (! $linkType = Link::types()[$handle] ?? null) { + return null; } - return [$ref => $data]; + if (! $config = $linkType->fieldtype($this->linkTypeField())) { + return null; + } + + $nestedField = new Field($handle, $config); + $nestedField->setValue([$id]); + + return $nestedField->fieldtype()->preload()['data'][0] ?? null; + } + + private function linkTypeField(): Field + { + return new Field('link', [ + 'collections' => $this->config('link_collections'), + 'container' => $this->config('container'), + 'select_across_sites' => $this->config('select_across_sites'), + ]); + } + + private function linkTypesForToolbar(): array + { + $field = $this->linkTypeField(); + + return collect(Link::types()) + ->filter(fn (LinkType $type): bool => $type->visible($field)) + ->map(function (LinkType $type, string $handle) use ($field): ?array { + if (! $config = $type->fieldtype($field)) { + return null; + } + + return [ + 'title' => $type->title(), + 'icon' => $type->icon(), + 'config' => (new Field($handle, $config))->fieldtype()->config(), + ]; + }) + ->filter() + ->all(); } private function wrapInlineValue($value) diff --git a/src/Fieldtypes/Bard/LinkMark.php b/src/Fieldtypes/Bard/LinkMark.php index 1277d60c8f1..5f837669427 100644 --- a/src/Fieldtypes/Bard/LinkMark.php +++ b/src/Fieldtypes/Bard/LinkMark.php @@ -2,9 +2,7 @@ namespace Statamic\Fieldtypes\Bard; -use Statamic\Contracts\Entries\Entry; -use Statamic\Facades\Data; -use Statamic\Facades\Site; +use Statamic\Fieldtypes\Link as LinkFieldtype; use Statamic\Support\Str; use Tiptap\Marks\Link; @@ -64,16 +62,26 @@ protected function convertHref($href) $ref = str($href)->after('statamic://')->before('?')->before('#')->toString(); - if (! $item = Data::find($ref)) { - return ''; + $selectAcrossSites = Augmentor::$currentBardConfig['select_across_sites'] ?? false; + $localize = ! $selectAcrossSites && ! $this->isApi(); + + $item = null; + + foreach (LinkFieldtype::types() as $type) { + if (Str::startsWith($ref, "{$type->handle()}::")) { + $item = $type->resolve(Str::after($ref, "{$type->handle()}::"), null, $localize); + break; + } } - $selectAcrossSites = Augmentor::$currentBardConfig['select_across_sites'] ?? false; + if (! $item) { + return ''; + } $extras = Str::after($href, $ref); - if (! $selectAcrossSites && ! $this->isApi() && $item instanceof Entry) { - return ($item->in(Site::current()->handle()) ?? $item)->url().$extras; + if (! is_object($item)) { + return $item.$extras; } return $selectAcrossSites ? $item->absoluteUrl().$extras : $item->url().$extras; diff --git a/tests/Fieldtypes/BardTest.php b/tests/Fieldtypes/BardTest.php index 5677d769e7c..9311b5c0355 100644 --- a/tests/Fieldtypes/BardTest.php +++ b/tests/Fieldtypes/BardTest.php @@ -14,6 +14,8 @@ use Statamic\Fields\Values; use Statamic\Fieldtypes\Bard; use Statamic\Fieldtypes\Bard\Augmentor; +use Statamic\Fieldtypes\Link; +use Statamic\Fieldtypes\Link\LinkType; use Statamic\Fieldtypes\RowId; use Tests\PreventSavingStacheItemsToDisk; use Tests\TestCase; @@ -782,6 +784,8 @@ public function it_preloads($areSetsGrouped) #[Test] public function it_gets_link_data() { + $this->actingAs(tap(Facades\User::make()->makeSuper())->save()); + tap(Facades\Collection::make('pages')->routes('/{slug}'))->save(); EntryFactory::collection('pages')->id('1')->slug('about')->data(['title' => 'About'])->create(); EntryFactory::collection('pages')->id('2')->slug('articles')->data(['title' => 'Articles'])->create(); @@ -805,11 +809,15 @@ public function it_gets_link_data() $prosemirror = (new Augmentor($this))->renderHtmlToProsemirror($html)['content']; - $this->assertEquals([ - 'entry::1' => ['title' => 'About', 'permalink' => 'http://localhost/about'], - 'entry::2' => ['title' => 'Articles', 'permalink' => 'http://localhost/articles'], - 'entry::3' => ['title' => 'Contact', 'permalink' => 'http://localhost/contact'], - ], $bard->getLinkData($prosemirror)); + // Link data comes from the same CP fieldtype resource used everywhere else entries are + // displayed, so we only assert the bits Bard's own toolbar actually cares about (which + // refs were extracted, and their title) rather than locking down that resource's full shape. + $linkData = $bard->getLinkData($prosemirror); + + $this->assertEquals(['entry::1', 'entry::2', 'entry::3'], array_keys($linkData)); + $this->assertEquals('About', $linkData['entry::1']['title']); + $this->assertEquals('Articles', $linkData['entry::2']['title']); + $this->assertEquals('Contact', $linkData['entry::3']['title']); } #[Test] @@ -1477,6 +1485,8 @@ public function it_preserves_appends_on_entry_links_with_select_across_sites() #[Test] public function it_gets_link_data_with_appends() { + $this->actingAs(tap(Facades\User::make()->makeSuper())->save()); + tap(Facades\Collection::make('pages')->routes('/{slug}'))->save(); EntryFactory::collection('pages')->id('1')->slug('about')->data(['title' => 'About'])->create(); @@ -1486,9 +1496,78 @@ public function it_gets_link_data_with_appends() $prosemirror = (new Augmentor($this))->renderHtmlToProsemirror($html)['content']; - $this->assertEquals([ - 'entry::1' => ['title' => 'About', 'permalink' => 'http://localhost/about'], - ], $bard->getLinkData($prosemirror)); + $linkData = $bard->getLinkData($prosemirror); + + $this->assertEquals(['entry::1'], array_keys($linkData)); + $this->assertEquals('About', $linkData['entry::1']['title']); + } + + #[Test] + public function it_resolves_a_custom_link_type() + { + Link::extend('bard-test-custom', TestCustomLinkType::class); + + $field = (new Bard)->setField(new Field('test', ['type' => 'bard'])); + + $augmented = $field->augment([ + ['type' => 'text', 'marks' => [['type' => 'link', 'attrs' => ['href' => 'statamic://bard-test-custom::valid']]], 'text' => 'Link'], + ]); + + $this->assertEquals('Link', $augmented); + } + + #[Test] + public function it_resolves_a_missing_custom_link_type_reference_to_an_empty_href() + { + Link::extend('bard-test-custom', TestCustomLinkType::class); + + $field = (new Bard)->setField(new Field('test', ['type' => 'bard'])); + + $augmented = $field->augment([ + ['type' => 'text', 'marks' => [['type' => 'link', 'attrs' => ['href' => 'statamic://bard-test-custom::missing']]], 'text' => 'Link'], + ]); + + $this->assertEquals('Link', $augmented); + } + + #[Test] + public function it_gets_link_data_for_a_custom_link_type() + { + TestCustomLinkFieldtype::register(); + Link::extend('bard-test-custom', TestCustomLinkType::class); + + $bard = $this->bard(['save_html' => true, 'sets' => null]); + + $html = '

Custom Link

'; + + $prosemirror = (new Augmentor($this))->renderHtmlToProsemirror($html)['content']; + + $linkData = $bard->getLinkData($prosemirror); + + $this->assertEquals(['bard-test-custom::valid'], array_keys($linkData)); + $this->assertEquals('Custom Title', $linkData['bard-test-custom::valid']['title']); + } + + #[Test] + public function it_includes_visible_custom_link_types_in_preload() + { + TestCustomLinkFieldtype::register(); + Link::extend('bard-test-custom', TestCustomLinkType::class); + + $linkTypes = $this->bard()->preload()['linkTypes']; + + $this->assertArrayHasKey('bard-test-custom', $linkTypes); + $this->assertEquals('Custom Type', $linkTypes['bard-test-custom']['title']); + $this->assertEquals('test-icon', $linkTypes['bard-test-custom']['icon']); + } + + #[Test] + public function it_excludes_non_visible_custom_link_types_from_preload() + { + TestCustomLinkFieldtype::register(); + Link::extend('bard-test-hidden', TestHiddenLinkType::class); + + $this->assertArrayNotHasKey('bard-test-hidden', $this->bard()->preload()['linkTypes']); } private function bard($config = []) @@ -1515,3 +1594,50 @@ private function groupSets($shouldGroup, $sets) ]; } } + +class TestCustomLinkType extends LinkType +{ + protected ?string $icon = 'test-icon'; + protected static ?string $title = 'Custom Type'; + + public function resolve(string $id, $parent = null, bool $localize = false): mixed + { + return $id === 'valid' ? 'https://example.com/custom/'.$id : null; + } + + public function fieldtype(Field $field): ?array + { + return ['type' => 'test_custom_link']; + } +} + +class TestHiddenLinkType extends LinkType +{ + protected ?string $icon = 'test-icon'; + protected static ?string $title = 'Hidden Type'; + + public function resolve(string $id, $parent = null, bool $localize = false): mixed + { + return null; + } + + public function fieldtype(Field $field): ?array + { + return ['type' => 'text']; + } + + public function visible(Field $field): bool + { + return false; + } +} + +class TestCustomLinkFieldtype extends Fieldtype +{ + public static $handle = 'test_custom_link'; + + public function preload() + { + return ['data' => [['title' => 'Custom Title']]]; + } +} From f7c13dfb4d65ee0917802c1513da67c5430ff9cf Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Tue, 14 Jul 2026 09:13:24 +0100 Subject: [PATCH 03/13] formatting --- src/Fieldtypes/Link/LinkType.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Fieldtypes/Link/LinkType.php b/src/Fieldtypes/Link/LinkType.php index 3e130b4b6ca..c2ca0be7902 100644 --- a/src/Fieldtypes/Link/LinkType.php +++ b/src/Fieldtypes/Link/LinkType.php @@ -4,6 +4,7 @@ use Statamic\Fields\Field; use Statamic\Support\Str; + use function Statamic\trans as __; abstract class LinkType From 107ffccf24fbd56ef487509ecc4521fde1ea22cb Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Tue, 14 Jul 2026 09:33:08 +0100 Subject: [PATCH 04/13] update phpstan baseline --- .phpstan/baseline.neon | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.phpstan/baseline.neon b/.phpstan/baseline.neon index 527438b53cd..2cefb95d7d5 100644 --- a/.phpstan/baseline.neon +++ b/.phpstan/baseline.neon @@ -67,7 +67,7 @@ parameters: path: ../src/Facades/Endpoint/Parse.php - - message: '#^Method Illuminate\\Contracts\\Validation\\DataAwareRule@anonymous/Fieldtypes/Bard\.php\:898\:\:setData\(\) should return \$this\(Illuminate\\Contracts\\Validation\\DataAwareRule@anonymous/Fieldtypes/Bard\.php\:898\) but return statement is missing\.$#' + message: '#^Method Illuminate\\Contracts\\Validation\\DataAwareRule@anonymous/Fieldtypes/Bard\.php\:923\:\:setData\(\) should return \$this\(Illuminate\\Contracts\\Validation\\DataAwareRule@anonymous/Fieldtypes/Bard\.php\:923\) but return statement is missing\.$#' identifier: return.missing count: 1 path: ../src/Fieldtypes/Bard.php From 02064ebc5ff5232386070686a083bdfc57c08fd3 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Tue, 14 Jul 2026 09:33:54 +0100 Subject: [PATCH 05/13] prevent link types leaking into other tests --- tests/Fieldtypes/BardTest.php | 2 ++ tests/Fieldtypes/LinkTest.php | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/tests/Fieldtypes/BardTest.php b/tests/Fieldtypes/BardTest.php index 9311b5c0355..4847f2a4eb8 100644 --- a/tests/Fieldtypes/BardTest.php +++ b/tests/Fieldtypes/BardTest.php @@ -32,6 +32,8 @@ public function tearDown(): void { parent::tearDown(); static::$functions = null; + + (new \ReflectionClass(Link::class))->setStaticPropertyValue('types', []); } #[Test] diff --git a/tests/Fieldtypes/LinkTest.php b/tests/Fieldtypes/LinkTest.php index 40b95ed4c41..a64dbb7437e 100644 --- a/tests/Fieldtypes/LinkTest.php +++ b/tests/Fieldtypes/LinkTest.php @@ -19,6 +19,13 @@ class LinkTest extends TestCase { use PreventSavingStacheItemsToDisk; + public function tearDown(): void + { + parent::tearDown(); + + (new \ReflectionClass(Link::class))->setStaticPropertyValue('types', []); + } + #[Test] public function it_augments_string_to_string() { From fb3a87056ba3c8cb4af9be7aaea9372141bd08fa Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Tue, 14 Jul 2026 09:43:12 +0100 Subject: [PATCH 06/13] translate link type labels --- resources/js/components/fieldtypes/LinkFieldtype.vue | 2 +- resources/js/components/fieldtypes/bard/LinkToolbar.vue | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/js/components/fieldtypes/LinkFieldtype.vue b/resources/js/components/fieldtypes/LinkFieldtype.vue index fb1b827b933..cb7cd5fc017 100644 --- a/resources/js/components/fieldtypes/LinkFieldtype.vue +++ b/resources/js/components/fieldtypes/LinkFieldtype.vue @@ -130,7 +130,7 @@ export default { this.meta.showFirstChildOption ? { label: __('First Child'), value: 'first-child' } : null, - ...Object.entries(this.meta.types).map(([handle, type]) => ({ label: type.title, value: handle })), + ...Object.entries(this.meta.types).map(([handle, type]) => ({ label: __(type.title), value: handle })), ].filter((option) => option); }, diff --git a/resources/js/components/fieldtypes/bard/LinkToolbar.vue b/resources/js/components/fieldtypes/bard/LinkToolbar.vue index 8adbed07cfa..c132a7c1a44 100644 --- a/resources/js/components/fieldtypes/bard/LinkToolbar.vue +++ b/resources/js/components/fieldtypes/bard/LinkToolbar.vue @@ -214,7 +214,7 @@ export default { { type: 'url', title: __('URL') }, ...Object.entries(this.bard.meta.linkTypes ?? {}).map(([handle, type]) => ({ type: handle, - title: type.title, + title: __(type.title), })), { type: 'mailto', title: __('Email') }, { type: 'tel', title: __('Phone') }, From ef280ba51bae3391603a2f66762591181abc363c Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Tue, 14 Jul 2026 10:15:04 +0100 Subject: [PATCH 07/13] actually avoid translating on the frontend. we do it in php --- resources/js/components/fieldtypes/LinkFieldtype.vue | 2 +- resources/js/components/fieldtypes/bard/LinkToolbar.vue | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/js/components/fieldtypes/LinkFieldtype.vue b/resources/js/components/fieldtypes/LinkFieldtype.vue index cb7cd5fc017..fb1b827b933 100644 --- a/resources/js/components/fieldtypes/LinkFieldtype.vue +++ b/resources/js/components/fieldtypes/LinkFieldtype.vue @@ -130,7 +130,7 @@ export default { this.meta.showFirstChildOption ? { label: __('First Child'), value: 'first-child' } : null, - ...Object.entries(this.meta.types).map(([handle, type]) => ({ label: __(type.title), value: handle })), + ...Object.entries(this.meta.types).map(([handle, type]) => ({ label: type.title, value: handle })), ].filter((option) => option); }, diff --git a/resources/js/components/fieldtypes/bard/LinkToolbar.vue b/resources/js/components/fieldtypes/bard/LinkToolbar.vue index c132a7c1a44..8adbed07cfa 100644 --- a/resources/js/components/fieldtypes/bard/LinkToolbar.vue +++ b/resources/js/components/fieldtypes/bard/LinkToolbar.vue @@ -214,7 +214,7 @@ export default { { type: 'url', title: __('URL') }, ...Object.entries(this.bard.meta.linkTypes ?? {}).map(([handle, type]) => ({ type: handle, - title: __(type.title), + title: type.title, })), { type: 'mailto', title: __('Email') }, { type: 'tel', title: __('Phone') }, From 2986fba61624c80881404b40d0c51ece717848e9 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Tue, 14 Jul 2026 10:15:57 +0100 Subject: [PATCH 08/13] make Bard's link button use the same picker components as the Link fieldtype Previously every registered link type in Bard was routed through a hardcoded relationship input (or a bespoke asset selector), so custom link types with a non-relationship picker never actually worked in Bard. Now Bard dynamically renders each type's own fieldtype component, same as the Link fieldtype already does. Guard against CollectionNotFoundException since linkTypesForToolbar now eagerly preloads every visible type on every Bard field render, which would otherwise crash on installs with no collections yet. --- .../fieldtypes/bard/LinkToolbar.vue | 212 ++++-------------- src/Fieldtypes/Bard.php | 14 +- 2 files changed, 58 insertions(+), 168 deletions(-) diff --git a/resources/js/components/fieldtypes/bard/LinkToolbar.vue b/resources/js/components/fieldtypes/bard/LinkToolbar.vue index 8adbed07cfa..0c0b2cbcf40 100644 --- a/resources/js/components/fieldtypes/bard/LinkToolbar.vue +++ b/resources/js/components/fieldtypes/bard/LinkToolbar.vue @@ -9,7 +9,7 @@ class="w-1/4 min-w-24" /> -
+
@@ -115,36 +92,6 @@ -