Skip to content

Commit f9b962d

Browse files
duncanmccleanclaudejasonvarga
authored
[6.x] Improve Link fieldtype in listings (#14535)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Jason Varga <jason@pixelfear.com>
1 parent 2e0b577 commit f9b962d

4 files changed

Lines changed: 219 additions & 0 deletions

File tree

resources/js/bootstrap/fieldtypes.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import HtmlFieldtype from '../components/fieldtypes/HtmlFieldtype.vue';
3535
import IconFieldtype from '../components/fieldtypes/IconFieldtype.vue';
3636
import IntegerFieldtype from '../components/fieldtypes/IntegerFieldtype.vue';
3737
import LinkFieldtype from '../components/fieldtypes/LinkFieldtype.vue';
38+
import LinkIndexFieldtype from '../components/fieldtypes/LinkIndexFieldtype.vue';
3839
import ListFieldtype from '../components/fieldtypes/ListFieldtype.vue';
3940
import ListIndexFieldtype from '../components/fieldtypes/ListIndexFieldtype.vue';
4041
import MarkdownButtonsSettingFieldtype from '../components/fieldtypes/markdown/MarkdownButtonsSettingFieldtype.vue';
@@ -107,6 +108,7 @@ export default function registerFieldtypes(app) {
107108
app.component('icon-fieldtype', IconFieldtype);
108109
app.component('integer-fieldtype', IntegerFieldtype);
109110
app.component('link-fieldtype', LinkFieldtype);
111+
app.component('link-fieldtype-index', LinkIndexFieldtype);
110112
app.component('list-fieldtype', ListFieldtype);
111113
app.component('list-fieldtype-index', ListIndexFieldtype);
112114
app.component(
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<script setup>
2+
import IndexFieldtype from '@/components/fieldtypes/index-fieldtype.js';
3+
import { Icon } from '@ui';
4+
5+
const props = defineProps(IndexFieldtype.props);
6+
</script>
7+
8+
<template>
9+
<a v-if="value" :key="value.url" :href="value.url" target="_blank" rel="noopener noreferrer" class="flex items-center space-x-2 text-ellipsis">
10+
<Icon v-if="value.type === 'asset'" name="assets" class="size-3 flex-shrink-0" />
11+
<Icon v-else-if="value.type === 'entry'" name="collections" class="size-3 flex-shrink-0" />
12+
<Icon v-else-if="value.type === 'child'" name="page" class="size-3 flex-shrink-0" />
13+
<Icon v-else name="external-link" class="size-3 flex-shrink-0" />
14+
<span v-text="value.url" />
15+
</a>
16+
</template>

src/Fieldtypes/Link.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,34 @@ public function augment($value)
6464
);
6565
}
6666

67+
public function preProcessIndex($data)
68+
{
69+
if (! $data) {
70+
return null;
71+
}
72+
73+
if ($data === '@child' && ! $this->field->parent() instanceof Entry) {
74+
return null;
75+
}
76+
77+
if (! $item = ResolveRedirect::item($data, $this->field->parent())) {
78+
return null;
79+
}
80+
81+
if (! ($url = is_object($item) ? $item->url() : $item)) {
82+
return null;
83+
}
84+
85+
$type = match (true) {
86+
$data === '@child' => 'child',
87+
Str::startsWith($data, 'asset::') => 'asset',
88+
Str::startsWith($data, 'entry::') => 'entry',
89+
default => 'url',
90+
};
91+
92+
return ['type' => $type, 'url' => $url];
93+
}
94+
6795
public function preload()
6896
{
6997
$value = $this->field->value();

tests/Fieldtypes/LinkTest.php

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use Mockery;
77
use PHPUnit\Framework\Attributes\Test;
88
use Statamic\Entries\Entry;
9+
use Statamic\Facades;
910
use Statamic\Fields\ArrayableString;
1011
use Statamic\Fields\Field;
1112
use Statamic\Fieldtypes\Link;
@@ -88,4 +89,176 @@ public function it_augments_null_to_null()
8889
$this->assertNull($augmented->value());
8990
$this->assertEquals(['url' => null], $augmented->toArray());
9091
}
92+
93+
#[Test]
94+
public function it_pre_processes_url_for_index()
95+
{
96+
$fieldtype = (new Link)->setField(new Field('test', ['type' => 'link']));
97+
98+
$this->assertEquals(
99+
['type' => 'url', 'url' => 'https://example.com'],
100+
$fieldtype->preProcessIndex('https://example.com')
101+
);
102+
}
103+
104+
#[Test]
105+
public function it_pre_processes_numeric_value_for_index()
106+
{
107+
$fieldtype = (new Link)->setField(new Field('test', ['type' => 'link']));
108+
109+
$this->assertEquals(
110+
['type' => 'url', 'url' => 404],
111+
$fieldtype->preProcessIndex('404')
112+
);
113+
}
114+
115+
#[Test]
116+
public function it_pre_processes_entry_reference_for_index()
117+
{
118+
$entry = Mockery::mock(\Statamic\Contracts\Entries\Entry::class);
119+
$entry->shouldReceive('url')->once()->andReturn('/the-entry-url');
120+
121+
Facades\Entry::shouldReceive('find')->with('entry-id')->once()->andReturn($entry);
122+
123+
$fieldtype = (new Link)->setField(new Field('test', ['type' => 'link']));
124+
125+
$this->assertEquals(
126+
['type' => 'entry', 'url' => '/the-entry-url'],
127+
$fieldtype->preProcessIndex('entry::entry-id')
128+
);
129+
}
130+
131+
#[Test]
132+
public function it_pre_processes_asset_reference_for_index()
133+
{
134+
$asset = Mockery::mock(\Statamic\Contracts\Assets\Asset::class);
135+
$asset->shouldReceive('url')->once()->andReturn('/assets/image.jpg');
136+
137+
Facades\Asset::shouldReceive('find')->with('main::image.jpg')->once()->andReturn($asset);
138+
139+
$fieldtype = (new Link)->setField(new Field('test', ['type' => 'link']));
140+
141+
$this->assertEquals(
142+
['type' => 'asset', 'url' => '/assets/image.jpg'],
143+
$fieldtype->preProcessIndex('asset::main::image.jpg')
144+
);
145+
}
146+
147+
#[Test]
148+
public function it_pre_processes_entry_with_null_url_for_index()
149+
{
150+
$entry = Mockery::mock(\Statamic\Contracts\Entries\Entry::class);
151+
$entry->shouldReceive('url')->once()->andReturnNull();
152+
153+
Facades\Entry::shouldReceive('find')->with('entry-id')->once()->andReturn($entry);
154+
155+
$fieldtype = (new Link)->setField(new Field('test', ['type' => 'link']));
156+
157+
$this->assertNull($fieldtype->preProcessIndex('entry::entry-id'));
158+
}
159+
160+
#[Test]
161+
public function it_pre_processes_missing_entry_reference_for_index()
162+
{
163+
Facades\Entry::shouldReceive('find')->with('missing-id')->once()->andReturnNull();
164+
165+
$fieldtype = (new Link)->setField(new Field('test', ['type' => 'link']));
166+
167+
$this->assertNull($fieldtype->preProcessIndex('entry::missing-id'));
168+
}
169+
170+
#[Test]
171+
public function it_pre_processes_missing_asset_reference_for_index()
172+
{
173+
Facades\Asset::shouldReceive('find')->with('main::missing.jpg')->once()->andReturnNull();
174+
175+
$fieldtype = (new Link)->setField(new Field('test', ['type' => 'link']));
176+
177+
$this->assertNull($fieldtype->preProcessIndex('asset::main::missing.jpg'));
178+
}
179+
180+
#[Test]
181+
public function it_pre_processes_first_child_for_index()
182+
{
183+
$child = Mockery::mock();
184+
$child->shouldReceive('url')->once()->andReturn('/parent/child');
185+
186+
$pages = Mockery::mock();
187+
$pages->shouldReceive('all')->once()->andReturn(collect([$child]));
188+
189+
$parent = Mockery::mock();
190+
$parent->shouldReceive('isRoot')->once()->andReturn(false);
191+
$parent->shouldReceive('pages')->once()->andReturn($pages);
192+
193+
$entry = Mockery::mock(\Statamic\Contracts\Entries\Entry::class);
194+
$entry->shouldReceive('page')->once()->andReturn($parent);
195+
196+
$field = new Field('test', ['type' => 'link']);
197+
$field->setParent($entry);
198+
$fieldtype = (new Link)->setField($field);
199+
200+
$this->assertEquals(
201+
['type' => 'child', 'url' => '/parent/child'],
202+
$fieldtype->preProcessIndex('@child')
203+
);
204+
}
205+
206+
#[Test]
207+
public function it_pre_processes_first_child_for_index_when_parent_is_root()
208+
{
209+
$child = Mockery::mock();
210+
$child->shouldReceive('url')->once()->andReturn('/first-child');
211+
212+
$tree = Mockery::mock();
213+
$tree->shouldReceive('pages')->once()->andReturn($tree);
214+
$tree->shouldReceive('all')->once()->andReturn(collect(['root-page', $child])->slice(0));
215+
216+
$parent = Mockery::mock();
217+
$parent->shouldReceive('isRoot')->once()->andReturn(true);
218+
$parent->shouldReceive('locale')->once()->andReturn('en');
219+
$parent->shouldReceive('structure')->once()->andReturn($structure = Mockery::mock());
220+
$structure->shouldReceive('in')->with('en')->once()->andReturn($tree);
221+
222+
$entry = Mockery::mock(\Statamic\Contracts\Entries\Entry::class);
223+
$entry->shouldReceive('page')->once()->andReturn($parent);
224+
225+
$field = new Field('test', ['type' => 'link']);
226+
$field->setParent($entry);
227+
$fieldtype = (new Link)->setField($field);
228+
229+
$this->assertEquals(
230+
['type' => 'child', 'url' => '/first-child'],
231+
$fieldtype->preProcessIndex('@child')
232+
);
233+
}
234+
235+
#[Test]
236+
public function it_pre_processes_first_child_for_index_when_parent_is_not_an_entry()
237+
{
238+
$field = new Field('test', ['type' => 'link']);
239+
$field->setParent(Mockery::mock());
240+
$fieldtype = (new Link)->setField($field);
241+
242+
$this->assertNull($fieldtype->preProcessIndex('@child'));
243+
}
244+
245+
#[Test]
246+
public function it_pre_processes_first_child_for_index_when_no_children()
247+
{
248+
$pages = Mockery::mock();
249+
$pages->shouldReceive('all')->once()->andReturn(collect());
250+
251+
$parent = Mockery::mock();
252+
$parent->shouldReceive('isRoot')->once()->andReturn(false);
253+
$parent->shouldReceive('pages')->once()->andReturn($pages);
254+
255+
$entry = Mockery::mock(\Statamic\Contracts\Entries\Entry::class);
256+
$entry->shouldReceive('page')->once()->andReturn($parent);
257+
258+
$field = new Field('test', ['type' => 'link']);
259+
$field->setParent($entry);
260+
$fieldtype = (new Link)->setField($field);
261+
262+
$this->assertNull($fieldtype->preProcessIndex('@child'));
263+
}
91264
}

0 commit comments

Comments
 (0)