Issues (20)

src/Block.php (1 issue)

Labels
Severity
1
<?php
2
3
4
namespace Riclep\Storyblok;
5
6
use Illuminate\Support\Arr;
7
use Illuminate\Support\Collection;
8
use Illuminate\Support\Str;
9
use Illuminate\View\View;
10
use Riclep\Storyblok\Exceptions\UnableToRenderException;
11
use Riclep\Storyblok\Fields\Asset;
12
use Riclep\Storyblok\Fields\Image;
13
use Riclep\Storyblok\Fields\MultiAsset;
14
use Riclep\Storyblok\Fields\RichText;
15
use Riclep\Storyblok\Fields\Table;
16
use Riclep\Storyblok\Traits\HasChildClasses;
17
use Riclep\Storyblok\Traits\HasMeta;
18
use Riclep\Storyblok\Traits\HasSettings;
19
use Storyblok\ApiException;
20
21
22
class Block implements \IteratorAggregate, \JsonSerializable
23
{
24
	use HasChildClasses;
25
	use HasMeta;
26
	use HasSettings;
27
28
	/**
29
	 * @var bool resolve UUID relations automatically
30
	 */
31
	public bool $_autoResolveRelations = false;
32
33
	/**
34
	 * @var array list of field names containing relations to resolve
35
	 */
36
	public array $_resolveRelations = [];
37
38
	/**
39
	 * @var bool Remove unresolved relations such as those that 404
40
	 */
41
	public bool $_filterRelations = true;
42
43
	/**
44
	 * @var array the path of nested components
45
	 */
46
	public array $_componentPath = [];
47
48
	/**
49
	 * @var array the path of nested components
50
	 */
51
	protected array $_casts = [];
52
53
	/**
54
	 * @var Collection all the fields for the Block
55
	 */
56
	private Collection $_fields;
57
58
	/**
59
	 * @var Page|Block reference to the parent Block or Page
60
	 */
61
	private mixed $_parent;
62
63
	/**
64
	 * @var array default values for fields
65
	 */
66
	protected array $_defaults = [];
67
68
	/**
69
	 * Takes the Block’s content and a reference to the parent
70
	 * @param $content
71
	 * @param $parent
72
	 */
73
	public function __construct($content, $parent = null)
74
	{
75
		$this->_parent = $parent;
76
		$this->preprocess($content);
77
78
		if ($parent) {
79
			$this->_componentPath = array_merge($parent->_componentPath, [Str::lower($this->meta()['component'])]);
80
		}
81
82
		$this->processFields();
83
84
		if (method_exists($this, 'fieldsReady')) {
85
			$this->fieldsReady();
86
		}
87
88
		// run automatic traits - methods matching initTraitClassName()
89
		foreach (class_uses_recursive($this) as $trait) {
90
			if (method_exists($this, $method = 'init' . class_basename($trait))) {
91
				$this->{$method}();
92
			}
93
		}
94
	}
95
96
	/**
97
	 * Returns the containing every field of content
98
	 *
99
	 * @return Collection
100
	 */
101
	public function content(): Collection
102
	{
103
		return $this->_fields;
104
	}
105
106
	/**
107
	 * Checks if this Block’s fields contain the specified key
108
	 *
109
	 * @param $key
110
	 * @return bool
111
	 */
112
	public function has($key): bool
113
	{
114
		return $this->_fields->has($key);
115
	}
116
117
	/**
118
	 * Checks if a ‘Blocks’ fieldtype contains a specific block component
119
	 * Pass the $field that contains the blocks and the component type to search for
120
	 *
121
	 * @param $field
122
	 * @param $component
123
	 * @return boolean
124
	 */
125
	public function hasChildBlock($field, $component): bool
126
	{
127
		return $this->content()[$field]->contains(function($item) use ($component) {
128
			return $item->meta('component') === $component;
129
		});
130
	}
131
132
	/**
133
	 * Returns the parent Block
134
	 *
135
	 * @return Block
136
	 */
137
	public function parent(): Block|Page|null
138
	{
139
		return $this->_parent;
140
	}
141
142
	/**
143
	 * Returns the page this Block belongs to
144
	 *
145
	 * @return Block
146
	 */
147
	public function page(): Block|Page|null
148
	{
149
		if ($this->parent() instanceof Page) {
150
			return $this->parent();
151
		}
152
153
		return $this->parent()->page();
154
	}
155
156
	/**
157
	 * Returns the first matching view, passing it the Block and optional data
158
	 *
159
	 * @param array $with
160
	 * @return View
161
	 * @throws UnableToRenderException
162
	 */
163
	public function render(array $with = []): View
164
	{
165
		return $this->renderUsing($this->views(), $with);
166
	}
167
168
	/**
169
	 * Pass an array of views rendering the first match, passing it the Block and optional data
170
	 *
171
	 * @param array|string $views
172
	 * @param array $with
173
	 * @return View
174
	 * @throws UnableToRenderException
175
	 */
176
	public function renderUsing(array|string $views, array $with = []): View
177
	{
178
		try {
179
			return view()->first(Arr::wrap($views), array_merge(['block' => $this], $with));
180
		} catch (\Exception $exception) {
181
			throw new UnableToRenderException('None of the views in the given array exist.', $this);
182
		}
183
	}
184
185
	/**
186
	 * Returns an array of possible views for the current Block based on
187
	 * it’s $componentPath match the component prefixed by each of it’s
188
	 * ancestors in turn, starting with the closest, for example:
189
	 *
190
	 * $componentPath = ['page', 'parent', 'child', 'this_block'];
191
	 *
192
	 * Becomes a list of possible views like so:
193
	 * ['child.this_block', 'parent.this_block', 'page.this_block'];
194
	 *
195
	 * Override this method with your custom implementation for
196
	 * ultimate control
197
	 *
198
	 * @return array
199
	 */
200
	public function views(): array
201
	{
202
		$componentPath = $this->_componentPath;
203
		array_pop($componentPath);
204
205
		$views = array_map(function($path) {
206
			return config('storyblok.view_path') . 'blocks.' . $path . '.' . $this->component();
207
		}, $componentPath);
208
209
		$views = array_reverse($views);
210
211
		$views[] = config('storyblok.view_path') . 'blocks.' . $this->component();
212
213
		return $views;
214
	}
215
216
	/**
217
	 * Returns a component X generations previous
218
	 *
219
	 * @param $generation int
220
	 * @return mixed
221
	 */
222
	public function ancestorComponentName(int $generation): mixed
223
	{
224
		return $this->_componentPath[count($this->_componentPath) - ($generation + 1)];
225
	}
226
227
	/**
228
	 * Checks if the current component is a child of another
229
	 *
230
	 * @param $parent string
231
	 * @return bool
232
	 */
233
	public function isChildOf(string $parent): bool
234
	{
235
		return $this->_componentPath[count($this->_componentPath) - 2] === $parent;
236
	}
237
238
	/**
239
	 * Checks if the component is an ancestor of another
240
	 *
241
	 * @param $parent string
242
	 * @return bool
243
	 */
244
	public function isAncestorOf(string $parent): bool
245
	{
246
		return in_array($parent, $this->parent()->_componentPath, true);
247
	}
248
249
	/**
250
	 * Returns the current Block’s component name from Storyblok
251
	 *
252
	 * @return string
253
	 */
254
	public function component(): string
255
	{
256
		return $this->_meta['component'];
257
	}
258
259
260
	/**
261
	 * Returns the HTML comment required for making this Block clickable in
262
	 * Storyblok’s visual editor. Don’t forget to set comments to true in
263
	 * your Vue.js app configuration.
264
	 *
265
	 * @return string
266
	 */
267
	public function editorLink(): string
268
	{
269
		if (array_key_exists('_editable', $this->_meta) && config('storyblok.edit_mode')) {
270
			return $this->_meta['_editable'];
271
		}
272
273
		return '';
274
	}
275
276
277
	/**
278
	 * Magic accessor to pull content from the _fields collection. Works just like
279
	 * Laravel’s model accessors. Matches public methods with the follow naming
280
	 * convention getSomeFieldAttribute() - called via $block->some_field
281
	 *
282
	 * @param $key
283
	 * @return null|string
284
	 */
285
	public function __get($key) {
286
		$accessor = 'get' . Str::studly($key) . 'Attribute';
287
288
		if (method_exists($this, $accessor)) {
289
			return $this->$accessor();
290
		}
291
292
		if ($this->has($key)) {
293
			return $this->_fields[$key];
294
		}
295
296
		if (array_key_exists($key, $this->_defaults)) {
297
			return $this->_defaults[$key];
298
		}
299
300
		return null;
301
	}
302
303
	/**
304
	 * Casts the Block as a string - json serializes the $_fields Collection
305
	 *
306
	 * @return string
307
	 */
308
	public function __toString(): string
309
	{
310
		return (string) $this->jsonSerialize();
311
	}
312
313
	/**
314
	 * Loops over every field to get the ball rolling
315
	 */
316
	private function processFields(): void
317
	{
318
		$this->_fields->transform(fn($field, $key) => $this->getFieldType($field, $key));
319
	}
320
321
	/**
322
	 * Converts fields into Field Classes based on various properties of their content
323
	 *
324
	 * @param $field
325
	 * @param $key
326
	 * @return array|Collection|mixed|Asset|Image|MultiAsset|RichText|Table
327
	 * @throws \Storyblok\ApiException
328
	 */
329
	private function getFieldType($field, $key): mixed
330
	{
331
		return (new FieldFactory())->build($this, $field, $key);
332
	}
333
334
	/**
335
	 * Storyblok returns fields and other meta content at the same level so
336
	 * let’s do a little tidying up first
337
	 *
338
	 * @param $content
339
	 */
340
	private function preprocess($content): void
341
	{
342
		// run pre-process traits - methods matching preprocessTraitClassName()
343
		foreach (class_uses_recursive($this) as $trait) {
344
			if (method_exists($this, $method = 'preprocess' . class_basename($trait))) {
345
				$content = $this->{$method}($content);
346
			}
347
		}
348
349
		$fields = ['_editable', '_uid', 'component'];
350
351
		$this->_fields = collect(array_diff_key($content, array_flip($fields)));
0 ignored issues
show
array_diff_key($content, array_flip($fields)) of type array is incompatible with the type Illuminate\Contracts\Support\Arrayable expected by parameter $value of collect(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

351
		$this->_fields = collect(/** @scrutinizer ignore-type */ array_diff_key($content, array_flip($fields)));
Loading history...
352
353
		// remove non-content keys
354
		$this->_meta = array_intersect_key($content, array_flip($fields));
355
	}
356
357
	/**
358
	 * Casting Block to JSON
359
	 *
360
	 * @return Collection|mixed
361
	 */
362
	public function jsonSerialize(): mixed
363
	{
364
		return $this->content();
365
	}
366
367
	/**
368
	 * Let’s up loop over the fields in Blade without needing to
369
	 * delve deep into the content collection
370
	 *
371
	 * @return \Traversable
372
	 */
373
	public function getIterator(): \Traversable {
374
		return $this->_fields;
375
	}
376
377
	/**
378
	 * @param RequestStory $requestStory
379
	 * @param $relation
380
	 * @param $className
381
	 * @return mixed|null
382
	 */
383
	public function getRelation(RequestStory $requestStory, $relation, $className = null): mixed
384
	{
385
		try {
386
			$response = $requestStory->get($relation);
387
388
			if (!$className) {
389
				$class = $this->getChildClassName('Block', $response['content']['component']);
390
			} else {
391
				$class = $className;
392
			}
393
394
			$relationClass = new $class($response['content'], $this);
395
396
			$relationClass->addMeta([
397
				'name' => $response['name'],
398
				'published_at' => $response['published_at'],
399
				'full_slug' => $response['full_slug'],
400
				'page_uuid' => $relation,
401
			]);
402
403
			return $relationClass;
404
		} catch (ApiException $e) {
405
			return null;
406
		}
407
	}
408
409
	/**
410
	 * Returns an inverse relationship to the current Block. For example if Service has a Multi-Option field
411
	 * relationship to People, on People you can request all the Services it has been related to
412
	 *
413
	 * @param string $foreignRelationshipField
414
	 * @param string $foreignRelationshipType
415
	 * @param array|null $components
416
	 * @param array|null $options
417
	 * @return array
418
	 */
419
	public function inverseRelation(string $foreignRelationshipField, string $foreignRelationshipType = 'multi', array $components = null, array $options = null): array
420
	{
421
		$storyblokClient = resolve('Storyblok\Client');
422
423
		$type = 'any_in_array';
424
425
		if ($foreignRelationshipType === 'single') {
426
			$type = 'in';
427
		}
428
429
		$query = [
430
			'filter_query' => [
431
				$foreignRelationshipField => [$type => $this->meta('page_uuid') ?? $this->page()->uuid()]
432
			],
433
		];
434
435
		if ($components) {
436
			$query = array_merge_recursive($query, [
437
				'filter_query' => [
438
					'component' => ['in' => $components],
439
				]
440
			]);
441
		}
442
443
		if ($options) {
444
			$query = array_merge_recursive($query, $options);
445
		}
446
447
		$storyblokClient->getStories($query);
448
449
		return [
450
			'headers' => $storyblokClient->getHeaders(),
451
			'stories' => $storyblokClient->getBody()['stories'],
452
		];
453
	}
454
455
	/**
456
	 * Returns the casts on this Block
457
	 *
458
	 * @return array
459
	 */
460
	public function getCasts(): array
461
	{
462
		return $this->_casts;
463
	}
464
}