From c4f61d7dab278d38515d225310a02f95ca2d1f3a Mon Sep 17 00:00:00 2001 From: Tofandel Date: Sat, 24 Feb 2024 00:20:39 +0100 Subject: [PATCH] Fix: nested repeaters --- frontend/js/components/Repeater.vue | 4 +- frontend/js/components/blocks/BlocksList.js | 2 +- frontend/js/mixins/block.js | 2 +- frontend/js/mixins/blockEditor.js | 5 + frontend/js/utils/getFormData.js | 117 +++++------ jsconfig.json | 8 + src/Repositories/Behaviors/HandleBlocks.php | 37 ++-- .../Behaviors/HandleRepeaters.php | 191 +++++------------- 8 files changed, 139 insertions(+), 227 deletions(-) create mode 100644 jsconfig.json diff --git a/frontend/js/components/Repeater.vue b/frontend/js/components/Repeater.vue index 63355f90a8..4c50663282 100755 --- a/frontend/js/components/Repeater.vue +++ b/frontend/js/components/Repeater.vue @@ -123,6 +123,7 @@ handle: '.block__handle' // drag handle } }, + inject: {inContentEditor: {default: false}}, computed: { triggerVariant: function () { if (this.buttonAsLink) { @@ -136,9 +137,6 @@ blockSize: function () { return this.inContentEditor ? 'small' : '' }, - inContentEditor: function () { - return typeof this.$parent.repeaterName !== 'undefined' - }, hasRemainingBlocks: function () { let max = null if (this.max && this.max > 0) { diff --git a/frontend/js/components/blocks/BlocksList.js b/frontend/js/components/blocks/BlocksList.js index 193b849f25..9cbee6754a 100644 --- a/frontend/js/components/blocks/BlocksList.js +++ b/frontend/js/components/blocks/BlocksList.js @@ -20,7 +20,7 @@ export default { return this.blocks(this.editorName) }, allSavedBlocks () { - return this.used && Object.keys(this.used).reduce((acc, editorName) => acc.concat(this.used[editorName]), []) + return this.used && Object.values(this.used).flat() }, hasBlockActive () { return Object.keys(this.activeBlock).length > 0 diff --git a/frontend/js/mixins/block.js b/frontend/js/mixins/block.js index 24702df574..b1315049eb 100755 --- a/frontend/js/mixins/block.js +++ b/frontend/js/mixins/block.js @@ -22,7 +22,7 @@ export default { return this.name + '[' + id + ']' // output : nameOfBlock[UniqID][name] }, repeaterName: function (id) { - return this.name.replace('[', '-').replace(']', '') + '|' + id // nameOfBlock-UniqID|name + return this.nestedEditorName(id) }, nestedEditorName: function (id) { return this.name.replace('[', '-').replace(']', '') + '|' + id // nameOfBlock-UniqID|name diff --git a/frontend/js/mixins/blockEditor.js b/frontend/js/mixins/blockEditor.js index 0271c24d6c..f91e9a99b2 100644 --- a/frontend/js/mixins/blockEditor.js +++ b/frontend/js/mixins/blockEditor.js @@ -15,6 +15,11 @@ export default { default: 0 } }, + provide() { + return { + inContentEditor: true, + } + }, methods: { addAndEditBlock (add, edit, { block, index }) { window[process.env.VUE_APP_NAME].PREVSTATE = cloneDeep(this.$store.state) diff --git a/frontend/js/utils/getFormData.js b/frontend/js/utils/getFormData.js index fd358861a7..9050e2907b 100755 --- a/frontend/js/utils/getFormData.js +++ b/frontend/js/utils/getFormData.js @@ -31,52 +31,59 @@ export const stripOutBlockNamespace = (name, id) => { return nameWithoutBlock.match(/]/gi).length > 1 ? nameWithoutBlock.replace(']', '') : nameWithoutBlock.slice(0, -1) } -export const buildBlock = (block, rootState, isRepeater = false) => { - const repeaterIds = Object.keys(rootState.repeaters.repeaters); - const repeaters = Object.assign({}, ...repeaterIds.filter(repeaterKey => { - return repeaterKey.startsWith('blocks-' + block.id + '|') +export const buildBlock = (block, rootState, isRepeater = false, childKey) => { + const parentRepeaters = rootState.repeaters.repeaters; + const repeaterIds = Object.keys(parentRepeaters); + const prefix = 'blocks-' + block.id + '|'; + const repeaters = repeaterIds.filter(repeaterKey => { + return repeaterKey.startsWith(prefix) }) - .map(repeaterKey => { - return { - [repeaterKey.replace('blocks-' + block.id + '|', '')]: rootState.repeaters.repeaters[repeaterKey].map(repeaterItem => { - return buildBlock(repeaterItem, rootState, true) - }) - } - })) + .reduce((acc, repeaterKey) => { + acc[repeaterKey.replace(prefix, '')] = parentRepeaters[repeaterKey].map(repeaterItem => { + return buildBlock(repeaterItem, rootState, true) + }) + + return acc + }, {}) const blockIds = Object.keys(rootState.blocks.blocks); - const blocks = Object.assign({}, ...blockIds.filter(blockKey => { - return blockKey.startsWith('blocks-' + block.id) - }).map(blockKey => { + const blocks = blockIds.filter(blockKey => { + return blockKey.startsWith(prefix) + }).reduce((acc, blockKey) => { + acc.push(...rootState.blocks.blocks[blockKey].map(repeaterItem => { + if (isRepeater) { + repeaterItem = {...repeaterItem, name: repeaterItem.name.replace(prefix, '')} + } + return buildBlock(repeaterItem, rootState, false, blockKey.replace(prefix, '')) + })); + return acc; + }, []) + + // retrieve all fields for this block and clean up field names + const content = rootState.form.fields.filter((field) => { + return isBlockField(field.name, block.id) + }).map((field) => { return { - [blockKey.replace('blocks-' + block.id + '|', '')]: rootState.blocks.blocks[blockKey].map(repeaterItem => { - return buildBlock(repeaterItem, rootState) - }) + name: stripOutBlockNamespace(field.name, block.id), + value: field.value } - })) + }).reduce((content, field) => { + content[field.name] = field.value + return content + }, {}); - return { + const base = { id: block.id, - type: block.type, - is_repeater: isRepeater, editor_name: block.name, - // retrieve all fields for this block and clean up field names - content: rootState.form.fields.filter((field) => { - return isBlockField(field.name, block.id) - }).map((field) => { - return { - name: stripOutBlockNamespace(field.name, block.id), - value: field.value - } - }).reduce((content, field) => { - content[field.name] = field.value - return content - }, {}), medias: gatherSelected(rootState.mediaLibrary.selected, block), browsers: gatherSelected(rootState.browser.selected, block), // gather repeater blocks from the repeater store module - blocks: { ...repeaters, ...blocks } + blocks, + repeaters, } + return isRepeater + ? { ...content, ...base, is_repeater: true, repeater_target_id: block.repeater_target_id} + : { ...base, type: block.type, content, child_key: childKey } } export const isBlockEmpty = (blockData) => { @@ -84,30 +91,16 @@ export const isBlockEmpty = (blockData) => { } export const gatherRepeaters = (rootState) => { - return Object.assign({}, ...Object.keys(rootState.repeaters.repeaters).filter(repeaterKey => { + return Object.keys(rootState.repeaters.repeaters).filter(repeaterKey => { // we start by filtering out repeater blocks return !repeaterKey.startsWith('blocks-') - }).map(repeater => { - return { - [repeater]: rootState.repeaters.repeaters[repeater].map(repeaterItem => { - // and for each repeater we build a block for each item - const repeaterBlock = buildBlock(repeaterItem, rootState) - - // we want to inline fields in the repeater object - // and we don't need the type of component used - const fields = repeaterBlock.content - delete repeaterBlock.content - delete repeaterBlock.type - - // and lastly we want to keep the id to update existing items - fields.id = repeaterItem.id - // If the repeater has a target id we are referencing an existing item. - fields.repeater_target_id = repeaterItem.repeater_target_id ?? null - - return Object.assign(repeaterBlock, fields) - }) - } - })) + }).reduce((acc, repeater) => { + acc[repeater] = rootState.repeaters.repeaters[repeater].map(repeaterItem => { + // and for each repeater we build a block for each item + return buildBlock(repeaterItem, rootState, true) + }) + return acc; + }, {}) } export const gatherBlocks = (rootState) => { @@ -124,7 +117,7 @@ export const gatherBlocks = (rootState) => { } export const getFormFields = (rootState) => { - const fields = rootState.form.fields.filter((field) => { + return rootState.form.fields.filter((field) => { // we start by filtering out blocks related form fields return !field.name.startsWith('blocks[') && !field.name.startsWith('mediaMeta[') }).reduce((fields, field) => { @@ -133,12 +126,10 @@ export const getFormFields = (rootState) => { fields[field.name] = field.value return fields }, {}) - - return fields } export const getModalFormFields = (rootState) => { - const fields = rootState.form.modalFields.filter((field) => { + return rootState.form.modalFields.filter((field) => { // we start by filtering out blocks related form fields return !field.name.startsWith('blocks[') && !field.name.startsWith('mediaMeta[') }).reduce((fields, field) => { @@ -147,8 +138,6 @@ export const getModalFormFields = (rootState) => { fields[field.name] = field.value return fields }, {}) - - return fields } export const getFormData = (rootState) => { @@ -159,7 +148,7 @@ export const getFormData = (rootState) => { // - publication properties // - selected medias and browsers // - created blocks and repeaters - const data = Object.assign(fields, { + return Object.assign(fields, { cmsSaveType: rootState.form.type, published: rootState.publication.published, public: rootState.publication.visibility === 'public', @@ -172,6 +161,4 @@ export const getFormData = (rootState) => { blocks: gatherBlocks(rootState), repeaters: gatherRepeaters(rootState) }) - - return data } diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000000..9ab9f6f5d2 --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./frontend/js/*"] + } + } +} diff --git a/src/Repositories/Behaviors/HandleBlocks.php b/src/Repositories/Behaviors/HandleBlocks.php index d453bde924..d008c74c0b 100644 --- a/src/Repositories/Behaviors/HandleBlocks.php +++ b/src/Repositories/Behaviors/HandleBlocks.php @@ -13,6 +13,7 @@ use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Validator; +use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; use Symfony\Component\Routing\Exception\RouteNotFoundException; @@ -272,22 +273,32 @@ private function getChildBlocks($object, $parentBlockFields) { $childBlocksList = Collection::make(); - foreach ($parentBlockFields['blocks'] ?? [] as $childKey => $childBlocks) { - if (strpos($childKey, '|')) { - continue; - } - foreach ($childBlocks as $index => $childBlock) { - $childBlock = $this->buildBlock($childBlock, $object, $childBlock['is_repeater'] ?? true); - $this->validateBlockArray($childBlock, $childBlock['instance'], true); - $childBlock['child_key'] = $childKey; - $childBlock['position'] = $index + 1; - $childBlock['editor_name'] = $parentBlockFields['editor_name'] ?? 'default'; - $childBlock['blocks'] = $this->getChildBlocks($object, $childBlock); - - $childBlocksList->push($childBlock); + if (empty($parentBlockFields['blocks'])) { + return $childBlocksList; + } + + // Fallback if frontend or revision is still on the old schema + if (is_int(key(current($parentBlockFields['blocks'])))) { + foreach ($parentBlockFields['blocks'] as $childKey => $childBlocks) { + foreach ($childBlocks as $index => $childBlock) { + $childBlock['child_key'] = $childKey; + $parentBlockFields['blocks'][$index] = $childBlock; + } + unset($parentBlockFields['blocks'][$childKey]); } } + foreach ($parentBlockFields['blocks'] as $index => $childBlock) { + $childBlock = $this->buildBlock($childBlock, $object, $childBlock['is_repeater'] ?? false); + $this->validateBlockArray($childBlock, $childBlock['instance'], true); + $childBlock['child_key'] = $childBlock['child_key'] ?? Str::afterLast($childBlock['editor_name'], '|'); + $childBlock['position'] = $index + 1; + $childBlock['editor_name'] = $parentBlockFields['editor_name'] ?? 'default'; + $childBlock['blocks'] = $this->getChildBlocks($object, $childBlock); + + $childBlocksList->push($childBlock); + } + return $childBlocksList; } diff --git a/src/Repositories/Behaviors/HandleRepeaters.php b/src/Repositories/Behaviors/HandleRepeaters.php index 718ba09bb2..034016599c 100644 --- a/src/Repositories/Behaviors/HandleRepeaters.php +++ b/src/Repositories/Behaviors/HandleRepeaters.php @@ -9,6 +9,9 @@ use A17\Twill\Repositories\ModuleRepository; use Carbon\Carbon; use Exception; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasOneOrMany; +use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\Arr; use Illuminate\Support\Collection; use Illuminate\Support\Str; @@ -280,13 +283,22 @@ public function updateRepeater( $relationRepository = $this->getModelRepository($relation, $modelOrRepository); + if (method_exists($this->model, $relation)) { + /** @var Relation $relationInstance */ + $relationInstance = $this->model->$relation(); + if ($relationInstance instanceof BelongsTo || $relationInstance instanceof HasOneOrMany) { + $fk = $relationInstance->getForeignKeyName(); + } + } + $fk ??= $this->model->getForeignKey(); + // If no relation field submitted, soft deletes all associated rows. // We only do this when the model is already existing. if (! $relationFields && ! $object->wasRecentlyCreated) { $relationRepository->updateBasic(null, [ 'deleted_at' => Carbon::now(), ], [ - $this->model->getForeignKey() => $object->id, + $fk => $object->id, ]); } @@ -322,7 +334,7 @@ public function updateRepeater( $currentIdList[] = (int)$id; } else { // new row, let's attach to our object and create - $relationField[$this->model->getForeignKey()] = $object->id; + $relationField[$fk] = $object->id; $frontEndId = $relationField['id']; unset($relationField['id']); $newRelation = $relationRepository->create($relationField); @@ -358,9 +370,6 @@ private function encodePivotFields(array $fields): array return $fields; } - /** - * @todo: This is currently a massive duplication, once done, this needs to be cleaned up - */ public function getFormFieldForRepeaterWithPivot( TwillModelContract $object, array $fields, @@ -368,6 +377,17 @@ public function getFormFieldForRepeaterWithPivot( array $pivotFields, null|string|TwillModelContract|ModuleRepository $modelOrRepository = null, ?string $repeaterName = null + ): array { + return $this->getFormFieldsShared($object, $fields, $relation, $pivotFields, $modelOrRepository, $repeaterName); + } + + private function getFormFieldsShared( + TwillModelContract $object, + array $fields, + string $relation, + array $pivotFields, + null|string|TwillModelContract|ModuleRepository $modelOrRepository = null, + ?string $repeaterName = null ): array { if (! $repeaterName) { $repeaterName = $relation; @@ -382,11 +402,15 @@ public function getFormFieldForRepeaterWithPivot( $repeaterType = TwillBlocks::findRepeaterByName($repeaterName); - $pivotFields[] = 'id'; - $objects = $object->$relation()->withPivot($pivotFields)->get(); + if (!empty($pivotFields)) { + $pivotFields[] = 'id'; + $objects = $object->$relation()->withPivot($pivotFields)->get(); + } else { + $objects = $object->$relation; + } foreach ($objects as $relationItem) { - $pivotRowId = $relationItem->pivot->id; + $pivotRowId = !empty($pivotFields) ? $relationItem->pivot->id : $relationItem->id; $repeaters[] = [ 'id' => $relation . '-' . $pivotRowId, 'type' => $repeaterType->component, @@ -409,7 +433,6 @@ public function getFormFieldForRepeaterWithPivot( } } - // @todo: Can we make this work without custom pivot tables? if (isset($relatedItemFormFields['medias'])) { if (config('twill.media_library.translated_form_fields', false)) { Collection::make($relatedItemFormFields['medias'])->each( @@ -430,7 +453,6 @@ function ($medias, $role) use ($locale, $relation, $relationItem) { } } - // @todo: Can we make this work without custom pivot tables? if (isset($relatedItemFormFields['files'])) { Collection::make($relatedItemFormFields['files'])->each( function ($rolesWithFiles, $locale) use (&$repeatersFiles, $relation, $relationItem) { @@ -445,7 +467,6 @@ function ($files, $role) use ($locale, $relation, $relationItem) { ); } - // @todo: Can we make this work without custom pivot tables? if (isset($relatedItemFormFields['browsers'])) { foreach ($relatedItemFormFields['browsers'] as $key => $values) { $repeatersBrowsers["blocks[$relation-$relationItem->id][$key]"] = $values; @@ -471,25 +492,33 @@ function ($files, $role) use ($locale, $relation, $relationItem) { ]; } - // @todo: Can we make this work without custom pivot tables? + foreach ($relatedItemFormFields['blocks'] ?? [] as $key => $block) { + $fields['blocks'][str_contains($key, '|') ? $key : "blocks-$relation-{$relationItem->id}|$key"] = $block; + } + $fields['blocksFields'] = array_merge($fields['blocksFields'] ?? [], $relatedItemFormFields['blocksFields'] ?? []); + if (isset($relatedItemFormFields['repeaters'])) { foreach ($relatedItemFormFields['repeaters'] as $childRepeaterName => $childRepeaterItems) { - $fields['repeaters']["blocks-$relation-{$relationItem->id}_$childRepeaterName"] = $childRepeaterItems; + if (str_contains($childRepeaterName, '|')) { + $fields['repeaters']["$childRepeaterName"] = $childRepeaterItems; + continue; + } + $fields['repeaters']["blocks-$relation-{$relationItem->id}|$childRepeaterName"] = $childRepeaterItems; $repeatersFields = array_merge( $repeatersFields, - $relatedItemFormFields['repeaterFields'][$childRepeaterName] + $relatedItemFormFields['repeaterFields'][$childRepeaterName] ?? [] ); $repeatersMedias = array_merge( $repeatersMedias, - $relatedItemFormFields['repeaterMedias'][$childRepeaterName] + $relatedItemFormFields['repeaterMedias'][$childRepeaterName] ?? [] ); $repeatersFiles = array_merge( $repeatersFiles, - $relatedItemFormFields['repeaterFiles'][$childRepeaterName] + $relatedItemFormFields['repeaterFiles'][$childRepeaterName] ?? [] ); $repeatersBrowsers = array_merge( $repeatersBrowsers, - $relatedItemFormFields['repeaterBrowsers'][$childRepeaterName] + $relatedItemFormFields['repeaterBrowsers'][$childRepeaterName] ?? [] ); } } @@ -522,133 +551,7 @@ public function getFormFieldsForRepeater( null|string|TwillModelContract|ModuleRepository $modelOrRepository = null, ?string $repeaterName = null ): array { - if (! $repeaterName) { - $repeaterName = $relation; - } - - $repeaters = []; - $repeatersFields = []; - $repeatersBrowsers = []; - $repeatersMedias = []; - $repeatersFiles = []; - $relationRepository = $this->getModelRepository($relation, $modelOrRepository); - - $repeaterType = TwillBlocks::findRepeaterByName($repeaterName); - - $objects = $object->$relation; - - foreach ($objects as $relationItem) { - $repeaters[] = [ - 'id' => $relation . '-' . $relationItem->id, - 'type' => $repeaterType->component, - 'title' => $repeaterType->title, - 'titleField' => $repeaterType->titleField, - 'hideTitlePrefix' => $repeaterType->hideTitlePrefix, - ]; - - $relatedItemFormFields = $relationRepository->getFormFields($relationItem); - $translatedFields = []; - - if (isset($relatedItemFormFields['translations'])) { - foreach ($relatedItemFormFields['translations'] as $key => $values) { - $repeatersFields[] = [ - 'name' => "blocks[$relation-$relationItem->id][$key]", - 'value' => $values, - ]; - - $translatedFields[] = $key; - } - } - - if (isset($relatedItemFormFields['medias'])) { - if (config('twill.media_library.translated_form_fields', false)) { - Collection::make($relatedItemFormFields['medias'])->each( - function ($rolesWithMedias, $locale) use (&$repeatersMedias, $relation, $relationItem) { - $repeatersMedias[] = Collection::make($rolesWithMedias)->mapWithKeys( - function ($medias, $role) use ($locale, $relation, $relationItem) { - return [ - "blocks[$relation-$relationItem->id][$role][$locale]" => $medias, - ]; - } - )->toArray(); - } - ); - } else { - foreach ($relatedItemFormFields['medias'] as $key => $values) { - $repeatersMedias["blocks[$relation-$relationItem->id][$key]"] = $values; - } - } - } - - if (isset($relatedItemFormFields['files'])) { - Collection::make($relatedItemFormFields['files'])->each( - function ($rolesWithFiles, $locale) use (&$repeatersFiles, $relation, $relationItem) { - $repeatersFiles[] = Collection::make($rolesWithFiles)->mapWithKeys( - function ($files, $role) use ($locale, $relation, $relationItem) { - return [ - "blocks[$relation-$relationItem->id][$role][$locale]" => $files, - ]; - } - )->toArray(); - } - ); - } - - if (isset($relatedItemFormFields['browsers'])) { - foreach ($relatedItemFormFields['browsers'] as $key => $values) { - $repeatersBrowsers["blocks[$relation-$relationItem->id][$key]"] = $values; - } - } - - $itemFields = method_exists($relationItem, 'toRepeaterArray') ? - $relationItem->toRepeaterArray() : - Arr::except($relationItem->attributesToArray(), $translatedFields); - - foreach ($itemFields as $key => $value) { - $repeatersFields[] = [ - 'name' => "blocks[$relation-$relationItem->id][$key]", - 'value' => $value, - ]; - } - - if (isset($relatedItemFormFields['repeaters'])) { - foreach ($relatedItemFormFields['repeaters'] as $childRepeaterName => $childRepeaterItems) { - $fields['repeaters']["blocks-$relation-{$relationItem->id}_$childRepeaterName"] = $childRepeaterItems; - $repeatersFields = array_merge( - $repeatersFields, - $relatedItemFormFields['repeaterFields'][$childRepeaterName] - ); - $repeatersMedias = array_merge( - $repeatersMedias, - $relatedItemFormFields['repeaterMedias'][$childRepeaterName] - ); - $repeatersFiles = array_merge( - $repeatersFiles, - $relatedItemFormFields['repeaterFiles'][$childRepeaterName] - ); - $repeatersBrowsers = array_merge( - $repeatersBrowsers, - $relatedItemFormFields['repeaterBrowsers'][$childRepeaterName] - ); - } - } - } - - if (! empty($repeatersMedias) && config('twill.media_library.translated_form_fields', false)) { - $repeatersMedias = array_merge(...$repeatersMedias); - } - - if (! empty($repeatersFiles)) { - $repeatersFiles = array_merge(...$repeatersFiles); - } - - $fields['repeaters'][$repeaterName] = $repeaters; - $fields['repeaterFields'][$repeaterName] = $repeatersFields; - $fields['repeaterMedias'][$repeaterName] = $repeatersMedias; - $fields['repeaterFiles'][$repeaterName] = $repeatersFiles; - $fields['repeaterBrowsers'][$repeaterName] = $repeatersBrowsers; - - return $fields; + return $this->getFormFieldsShared($object, $fields, $relation, [], $modelOrRepository, $repeaterName); } private function decodePivotField(?string $data): null|array|string