Passed
Push — master ( 7de972...bab707 )
by M. Mikkel
04:52
created

ReasonsService::getFieldUidById()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 10
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
eloc 6
c 1
b 0
f 0
nc 2
nop 1
dl 0
loc 10
rs 10
1
<?php
2
/**
3
 * Reasons plugin for Craft CMS 3.x
4
 *
5
 * Adds conditionals to field layouts.
6
 *
7
 * @link      https://vaersaagod.no
8
 * @copyright Copyright (c) 2020 Mats Mikkel Rummelhoff
9
 */
10
11
namespace mmikkel\reasons\services;
12
13
use Craft;
14
use craft\db\Query;
15
use craft\base\Component;
16
use craft\base\FieldInterface;
17
use craft\db\Table;
18
use craft\elements\User;
19
use craft\events\ConfigEvent;
20
use craft\events\RebuildConfigEvent;
21
use craft\fields\Assets;
22
use craft\fields\Categories;
23
use craft\fields\Checkboxes;
24
use craft\fields\Dropdown;
25
use craft\fields\Entries;
26
use craft\fields\Number;
27
use craft\fields\Lightswitch;
28
use craft\fields\MultiSelect;
29
use craft\fields\PlainText;
30
use craft\fields\RadioButtons;
31
use craft\fields\Tags;
32
use craft\fields\Users;
33
use craft\helpers\Db;
34
use craft\helpers\Json;
35
use craft\helpers\StringHelper;
36
use craft\models\FieldLayout;
37
use craft\records\EntryType;
38
39
use mmikkel\reasons\Reasons;
40
41
/**
42
 * @author    Mats Mikkel Rummelhoff
43
 * @package   Reasons
44
 * @since     2.0.0
45
 */
46
class ReasonsService extends Component
47
{
48
49
    /** @var int */
50
    const CACHE_TTL = 1800;
51
52
    /** @var FieldInterface[] */
53
    protected $allFields;
54
55
    /** @var array */
56
    protected $sources;
57
58
    /** @var int[] */
59
    protected $fieldIdsByUid;
60
61
    /** @var string[] */
62
    protected $fieldUidsById;
63
64
    // Public Methods
65
    // =========================================================================
66
67
    /**
68
     * Saves a field layout's conditionals, via the Project Config
69
     *
70
     * @param FieldLayout $layout
71
     * @param string|array $conditionals
72
     * @return bool
73
     * @throws \yii\base\ErrorException
74
     * @throws \yii\base\Exception
75
     * @throws \yii\base\NotSupportedException
76
     * @throws \yii\web\ServerErrorHttpException
77
     */
78
    public function saveFieldLayoutConditionals(FieldLayout $layout, $conditionals): bool
79
    {
80
81
        $uid = (new Query())
82
            ->select(['uid'])
83
            ->from('{{%reasons}}')
84
            ->where(['fieldLayoutId' => $layout->id])
85
            ->scalar();
86
87
        $isNew = !$uid;
88
        if ($isNew) {
89
            $uid = StringHelper::UUID();
90
        }
91
92
        $conditionals = $this->prepConditionalsForProjectConfig($conditionals);
93
94
        // Save it to the project config
95
        $path = "reasons_conditionals.{$uid}";
96
        Craft::$app->projectConfig->set($path, [
0 ignored issues
show
Bug introduced by
The method set() does not exist on null. ( Ignorable by Annotation )

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

96
        Craft::$app->projectConfig->/** @scrutinizer ignore-call */ 
97
                                    set($path, [

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
97
            'fieldLayoutUid' => $layout->uid,
98
            'conditionals' => $conditionals,
99
        ]);
100
101
        return true;
102
    }
103
104
    /**
105
     * Deletes a field layout's conditionals, via the Project Config
106
     *
107
     * @param FieldLayout $layout
108
     * @return bool
109
     */
110
    public function deleteFieldLayoutConditionals(FieldLayout $layout): bool
111
    {
112
113
        $uid = (new Query())
114
            ->select(['uid'])
115
            ->from('{{%reasons}}')
116
            ->where(['fieldLayoutId' => $layout->id])
117
            ->scalar();
118
119
        if (!$uid) {
120
            return false;
121
        }
122
123
        // Remove it from the project config
124
        $path = "reasons_conditionals.{$uid}";
125
        Craft::$app->projectConfig->remove($path);
126
127
        return true;
128
    }
129
130
    /**
131
     * @param ConfigEvent $event
132
     * @throws \yii\db\Exception
133
     */
134
    public function onProjectConfigChange(ConfigEvent $event)
135
    {
136
137
        $this->clearCache();
138
139
        $uid = $event->tokenMatches[0];
140
        $data = $event->newValue;
141
142
        $id = (new Query())
143
            ->select(['id'])
144
            ->from('{{%reasons}}')
145
            ->where(['uid' => $uid])
146
            ->scalar();
147
148
        $isNew = empty($id);
149
150
        if ($isNew) {
151
152
            // Save new conditionals
153
            $fieldLayoutId = Db::idByUid(Table::FIELDLAYOUTS, $data['fieldLayoutUid']);
154
155
            if ($fieldLayoutId === null) {
156
                // The field layout might not've synced yet. Defer to Project Config
157
                Craft::$app->getProjectConfig()->defer($event, [$this, __FUNCTION__]);
158
                return;
159
            }
160
161
            $transaction = Craft::$app->getDb()->beginTransaction();
162
163
            try {
164
                Craft::$app->db->createCommand()
165
                    ->insert('{{%reasons}}', [
166
                        'fieldLayoutId' => $fieldLayoutId,
167
                        'conditionals' => $data['conditionals'],
168
                        'uid' => $uid,
169
                    ])
170
                    ->execute();
171
                $transaction->commit();
172
            } catch (\Throwable $e) {
173
                $transaction->rollBack();
174
                throw $e;
175
            }
176
177
        } else {
178
179
            // Update existing conditionals
180
            $transaction = Craft::$app->getDb()->beginTransaction();
181
182
            try {
183
                Craft::$app->db->createCommand()
184
                    ->update('{{%reasons}}', [
185
                        'conditionals' => $data['conditionals'],
186
                    ], ['id' => $id])
187
                    ->execute();
188
                $transaction->commit();
189
            } catch (\Throwable $e) {
190
                $transaction->rollBack();
191
                throw $e;
192
            }
193
        }
194
195
    }
196
197
    /**
198
     * @param ConfigEvent $event
199
     * @throws \yii\db\Exception
200
     */
201
    public function onProjectConfigDelete(ConfigEvent $event)
202
    {
203
204
        $this->clearCache();
205
206
        $uid = $event->tokenMatches[0];
207
208
        $id = (new Query())
209
            ->select(['id'])
210
            ->from('{{%reasons}}')
211
            ->where(['uid' => $uid])
212
            ->scalar();
213
214
        if ($id) {
215
216
            $transaction = Craft::$app->getDb()->beginTransaction();
217
218
            try {
219
                Craft::$app->db->createCommand()
220
                    ->delete('{{%reasons}}', ['id' => $id])
221
                    ->execute();
222
                $transaction->commit();
223
            } catch (\Throwable $e) {
224
                $transaction->rollBack();
225
                throw $e;
226
            }
227
        }
228
229
    }
230
231
    /**
232
     * @param RebuildConfigEvent $event
233
     * @return void
234
     */
235
    public function onProjectConfigRebuild(RebuildConfigEvent $event)
236
    {
237
238
        Craft::$app->getProjectConfig()->remove('reasons_conditionals');
239
240
        $event->config['reasons_conditionals'] = [];
241
242
        $rows = (new Query())
243
            ->select(['reasons.uid', 'reasons.conditionals', 'fieldlayouts.uid AS fieldLayoutUid'])
244
            ->from('{{%reasons}} AS reasons')
245
            ->innerJoin(['fieldlayouts' => Table::FIELDLAYOUTS], '[[fieldlayouts.id]] = [[reasons.fieldLayoutId]]')
246
            ->all();
247
248
        foreach ($rows as $row) {
249
            $uid = $row['uid'] ?? null;
250
            $conditionals = $row['conditionals'] ?? null;
251
            $fieldLayoutUid = $row['fieldLayoutUid'] ?? null;
252
            if (!$uid || !$conditionals || !$fieldLayoutUid) {
253
                continue;
254
            }
255
            $event->config['reasons_conditionals'][$uid] = [
256
                'conditionals' => $conditionals,
257
                'fieldLayoutUid' => $fieldLayoutUid,
258
            ];
259
        }
260
261
        $this->clearCache();
262
    }
263
264
    /**
265
     * Clears Reasons' data caches
266
     *
267
     * @return void
268
     */
269
    public function clearCache()
270
    {
271
        Craft::$app->getCache()->delete($this->getCacheKey());
272
    }
273
274
    /**
275
     * @return array|mixed
276
     */
277
    public function getData()
278
    {
279
        $doCacheData = !Craft::$app->getConfig()->getGeneral()->devMode;
280
        $cacheKey = $this->getCacheKey();
281
282
        if ($doCacheData && $data = Craft::$app->getCache()->get($cacheKey)) {
283
            return $data;
284
        }
285
286
        $data = [
287
            'conditionals' => $this->getConditionals(),
288
            'toggleFieldTypes' => $this->getToggleFieldTypes(),
289
            'toggleFields' => $this->getToggleFields(),
290
            'fieldIds' => $this->getFieldIdsByHandle(),
291
        ];
292
293
        if ($doCacheData) {
294
            Craft::$app->getCache()->set($cacheKey, $data, self::CACHE_TTL);
295
        }
296
297
        return $data;
298
    }
299
300
    /**
301
     * @param string|array $conditionals
302
     * @return string|null
303
     */
304
    protected function prepConditionalsForProjectConfig($conditionals)
305
    {
306
        if (!$conditionals) {
307
            return null;
308
        }
309
        $return = [];
310
        $conditionals = Json::decodeIfJson($conditionals);
311
        foreach ($conditionals as $targetFieldId => $statements) {
312
            $targetFieldUid = $this->getFieldUidById((int)$targetFieldId);
313
            $return[$targetFieldUid] = \array_map(function (array $rules) {
314
                return \array_map(function (array $rule) {
315
                    $fieldId = $rule['fieldId'];
316
                    return [
317
                        'field' => $this->getFieldUidById("$fieldId"),
0 ignored issues
show
Bug introduced by
$fieldId of type string is incompatible with the type integer expected by parameter $fieldId of mmikkel\reasons\services...vice::getFieldUidById(). ( Ignorable by Annotation )

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

317
                        'field' => $this->getFieldUidById(/** @scrutinizer ignore-type */ "$fieldId"),
Loading history...
318
                        'compare' => $rule['compare'],
319
                        'value' => $rule['value'],
320
                    ];
321
                }, $rules);
322
            }, $statements);
323
        }
324
        return Json::encode($return);
325
    }
326
327
    /**
328
     * @param string|array $conditionals
329
     * @return array|null
330
     */
331
    protected function normalizeConditionalsFromProjectConfig($conditionals)
332
    {
333
        if (!$conditionals) {
334
            return null;
335
        }
336
        $return = [];
337
        try {
338
            $conditionals = Json::decodeIfJson($conditionals);
339
            foreach ($conditionals as $targetFieldUid => $statements) {
340
                $targetFieldId = $this->getFieldIdByUid($targetFieldUid);
341
                $return[$targetFieldId] = \array_map(function (array $rules) {
342
                    return \array_map(function (array $rule) {
343
                        return [
344
                            'fieldId' => $this->getFieldIdByUid($rule['field']),
345
                            'compare' => $rule['compare'],
346
                            'value' => $rule['value'],
347
                        ];
348
                    }, $rules);
349
                }, $statements);
350
            }
351
        } catch (\Throwable $e) {
352
            Craft::error($e->getMessage(), __METHOD__);
353
        }
354
355
        return $return;
356
    }
357
358
    /**
359
     * Returns all conditionals, mapped by source key
360
     *
361
     * @return array
362
     */
363
    protected function getConditionals(): array
364
    {
365
366
        // Get all conditionals from database
367
        $rows = (new Query())
368
            ->select(['reasons.id', 'reasons.fieldLayoutId', 'reasons.conditionals'])
369
            ->from('{{%reasons}} AS reasons')
370
            ->innerJoin(['fieldlayouts' => Table::FIELDLAYOUTS], '[[fieldlayouts.id]] = [[reasons.fieldLayoutId]]')
371
            ->all();
372
373
        // Map conditionals to field layouts, and convert field uids to ids
374
        $conditionals = [];
375
        foreach ($rows as $row) {
376
            $conditionals["fieldLayout:{$row['fieldLayoutId']}"] = $this->normalizeConditionalsFromProjectConfig($row['conditionals']);
377
        }
378
379
        // Map conditionals to sources
380
        $conditionalsBySources = [];
381
        $sources = $this->getSources();
382
        foreach ($sources as $sourceId => $fieldLayoutId) {
383
            if (!isset($conditionals["fieldLayout:{$fieldLayoutId}"])) {
384
                continue;
385
            }
386
            $conditionalsBySources[$sourceId] = $conditionals["fieldLayout:{$fieldLayoutId}"];
387
        }
388
389
        return $conditionalsBySources;
390
    }
391
392
    /**
393
     * @return array
394
     */
395
    protected function getSources(): array
396
    {
397
398
        if (!isset($this->sources)) {
399
400
            $sources = [];
401
402
            $entryTypeRecords = EntryType::find()->all();
403
            /** @var EntryType $entryTypeRecord */
404
            foreach ($entryTypeRecords as $entryTypeRecord) {
405
                $sources["entryType:{$entryTypeRecord->id}"] = (int)$entryTypeRecord->fieldLayoutId;
406
                $sources["section:{$entryTypeRecord->sectionId}"] = (int)$entryTypeRecord->fieldLayoutId;
407
            }
408
409
            $categoryGroups = Craft::$app->getCategories()->getAllGroups();
410
            foreach ($categoryGroups as $categoryGroup) {
411
                $sources["categoryGroup:{$categoryGroup->id}"] = (int)$categoryGroup->fieldLayoutId;
412
            }
413
414
            $tagGroups = Craft::$app->getTags()->getAllTagGroups();
415
            foreach ($tagGroups as $tagGroup) {
416
                $sources["tagGroup:{$tagGroup->id}"] = (int)$tagGroup->fieldLayoutId;
417
            }
418
419
            $volumes = Craft::$app->getVolumes()->getAllVolumes();
420
            foreach ($volumes as $volume) {
421
                $sources["assetSource:{$volume->id}"] = (int)$volume->fieldLayoutId;
422
            }
423
424
            $globalSets = Craft::$app->getGlobals()->getAllSets();
425
            foreach ($globalSets as $globalSet) {
426
                $sources["globalSet:{$globalSet->id}"] = (int)$globalSet->fieldLayoutId;
427
            }
428
429
            $usersFieldLayout = Craft::$app->getFields()->getLayoutByType(User::class);
430
            $sources['users'] = $usersFieldLayout->id;
431
432
            $this->sources = $sources;
433
434
        }
435
436
        return $this->sources;
437
    }
438
439
    /**
440
     * Returns all toggleable fields
441
     *
442
     * @return array
443
     */
444
    protected function getToggleFields(): array
445
    {
446
        $toggleFieldTypes = $this->getToggleFieldTypes();
447
        $toggleFields = [];
448
        $fields = $this->getAllFields();
449
        /** @var FieldInterface $field */
450
        foreach ($fields as $field) {
451
            $fieldType = \get_class($field);
452
            if (!\in_array($fieldType, $toggleFieldTypes)) {
453
                continue;
454
            }
455
            $toggleFields[] = [
456
                'id' => (int)$field->id,
0 ignored issues
show
Bug introduced by
Accessing id on the interface craft\base\FieldInterface suggest that you code against a concrete implementation. How about adding an instanceof check?
Loading history...
457
                'handle' => $field->handle,
0 ignored issues
show
Bug introduced by
Accessing handle on the interface craft\base\FieldInterface suggest that you code against a concrete implementation. How about adding an instanceof check?
Loading history...
458
                'name' => $field->name,
0 ignored issues
show
Bug introduced by
Accessing name on the interface craft\base\FieldInterface suggest that you code against a concrete implementation. How about adding an instanceof check?
Loading history...
459
                'type' => $fieldType,
460
                'settings' => $field->getSettings(),
461
            ];
462
        }
463
        return $toggleFields;
464
    }
465
466
    /**
467
     * Returns all toggleable fieldtype classnames
468
     *
469
     * @return string[]
470
     */
471
    protected function getToggleFieldTypes(): array
472
    {
473
        return [
474
            Lightswitch::class,
475
            Dropdown::class,
476
            Checkboxes::class,
477
            MultiSelect::class,
478
            RadioButtons::class,
479
            Number::class,
480
            PlainText::class,
481
            Entries::class,
482
            Categories::class,
483
            Tags::class,
484
            Assets::class,
485
            Users::class,
486
        ];
487
    }
488
489
    /**
490
     * Returns all global field IDs, indexed by handle
491
     *
492
     * @return array
493
     */
494
    protected function getFieldIdsByHandle(): array
495
    {
496
        $handles = [];
497
        $fields = $this->getAllFields();
498
        foreach ($fields as $field) {
499
            $handles[$field->handle] = (int)$field->id;
0 ignored issues
show
Bug introduced by
Accessing id on the interface craft\base\FieldInterface suggest that you code against a concrete implementation. How about adding an instanceof check?
Loading history...
Bug introduced by
Accessing handle on the interface craft\base\FieldInterface suggest that you code against a concrete implementation. How about adding an instanceof check?
Loading history...
500
        }
501
        return $handles;
502
    }
503
504
    /**
505
     * @return FieldInterface[]
506
     */
507
    protected function getAllFields(): array
508
    {
509
        if (!isset($this->allFields)) {
510
            $this->allFields = Craft::$app->getFields()->getAllFields('global');
511
        }
512
        return $this->allFields;
513
    }
514
515
    /**
516
     * Return the UID for a field, based on its database ID
517
     *
518
     * @param int $fieldId
519
     * @return string
520
     */
521
    protected function getFieldUidById(int $fieldId): string
522
    {
523
        if (!isset($this->fieldUidsById)) {
524
            $allFields = $this->getAllFields();
525
            $this->fieldUidsById = \array_reduce($allFields, function (array $carry, FieldInterface $field) {
526
                $carry["{$field->id}"] = $field->uid;
0 ignored issues
show
Bug introduced by
Accessing uid on the interface craft\base\FieldInterface suggest that you code against a concrete implementation. How about adding an instanceof check?
Loading history...
Bug introduced by
Accessing id on the interface craft\base\FieldInterface suggest that you code against a concrete implementation. How about adding an instanceof check?
Loading history...
527
                return $carry;
528
            }, []);
529
        }
530
        return $this->fieldUidsById["$fieldId"];
531
    }
532
533
    /**
534
     * Return the database ID for a field, based on its UID
535
     *
536
     * @param string $fieldUid
537
     * @return int
538
     */
539
    protected function getFieldIdByUid(string $fieldUid): int
540
    {
541
        if (!isset($this->fieldIdsByUid)) {
542
            $allFields = $this->getAllFields();
543
            $this->fieldIdsByUid = \array_reduce($allFields, function (array $carry, FieldInterface $field) {
544
                $carry[$field->uid] = (int)$field->id;
0 ignored issues
show
Bug introduced by
Accessing uid on the interface craft\base\FieldInterface suggest that you code against a concrete implementation. How about adding an instanceof check?
Loading history...
Bug introduced by
Accessing id on the interface craft\base\FieldInterface suggest that you code against a concrete implementation. How about adding an instanceof check?
Loading history...
545
                return $carry;
546
            }, []);
547
        }
548
        return $this->fieldIdsByUid[$fieldUid];
549
    }
550
551
    /**
552
     * @return string
553
     */
554
    protected function getCacheKey(): string
555
    {
556
        $reasons = Reasons::getInstance();
557
        return \implode('-', [
558
            $reasons->getHandle(),
559
            $reasons->getVersion(),
560
            $reasons->schemaVersion
561
        ]);
562
    }
563
}
564