Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions resources/js/components/blueprints/Section.vue
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,14 @@
<ui-field :label="__('Hidden')" v-if="showHideField">
<ui-switch v-model="editingSection.hide" />
</ui-field>
<ui-publish-container
v-if="editingSection && setConfig?.fields.length"
v-model="editingSection.extraConfig.values"
v-model:meta="editingSection.extraConfig.meta"
:blueprint="setConfigBlueprint"
:errors="setConfigErrors?.(section._id) || {}"
:track-dirty-state="false"
/>
<div class="py-6 space-x-2 -mx-6 px-6 border-t border-gray-200 dark:border-gray-700">
<ui-button :text="isSoloNarrowStack ? __('Save') : __('Confirm')" @click="handleSaveOrConfirm" variant="primary" />
<ui-button :text="__('Cancel')" @click="editCancelled" variant="ghost" />
Expand All @@ -144,6 +152,8 @@ export default {

inject: {
suggestableConditionFieldsProvider: { default: null },
setConfig: { default: null },
setConfigErrors: { default: null },
},

components: {
Expand Down Expand Up @@ -173,6 +183,12 @@ export default {
},

computed: {
setConfigBlueprint() {
return {
tabs: [{ handle: 'main', sections: [{ fields: this.setConfig?.fields || [] }] }],
};
},

suggestableConditionFields() {
return this.suggestableConditionFieldsProvider?.suggestableFields(this) || [];
},
Expand Down Expand Up @@ -271,6 +287,9 @@ export default {
hide: this.section.hide,
collapsible: this.section.collapsible,
collapsed: this.section.collapsed,
...(this.setConfig?.fields.length || this.section.extraConfig ? {
extraConfig: clone(this.section.extraConfig || this.setConfig.defaults),
} : {}),
};
},

Expand Down
23 changes: 21 additions & 2 deletions resources/js/components/fieldtypes/replicator/SetsFieldtype.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
</template>

<script>
import { computed } from 'vue';
import Fieldtype from '../Fieldtype.vue';
import SuggestsConditionalFields from '../../blueprints/SuggestsConditionalFields';
import Tabs from '../../blueprints/Tabs.vue';
Expand All @@ -36,11 +37,29 @@ export default {
};
},

provide: {
isInsideSet: true,
provide() {
return {
isInsideSet: true,
setConfig: computed(() => this.meta?.setConfig),
setConfigErrors: this.setConfigErrors,
};
},

methods: {
setConfigErrors(id) {
for (const [tabIndex, tab] of this.value.entries()) {
const sectionIndex = tab.sections.findIndex(section => section._id === id);
if (sectionIndex === -1) continue;

const prefix = [this.fieldPathPrefix, this.handle, tabIndex, 'sections', sectionIndex, 'extraConfig', 'values'].filter(part => part !== undefined && part !== '').join('.') + '.';
return Object.fromEntries(Object.entries(this.publishContainer.errors || {})
.filter(([key]) => key.startsWith(prefix))
.map(([key, value]) => [key.slice(prefix.length), value]));
}

return {};
},

tabsUpdated(tabs) {
this.update(tabs);
},
Expand Down
64 changes: 64 additions & 0 deletions resources/js/tests/components/blueprints/Section.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { shallowMount } from '@vue/test-utils';
import { beforeEach, expect, test, vi } from 'vitest';
import Section from '@/components/blueprints/Section.vue';

beforeEach(() => {
global.clone = (value) => JSON.parse(JSON.stringify(value));
global.snake_case = (value) => value.toLowerCase().replaceAll(' ', '_');
});

function mountSection(extraConfig = undefined, setConfig = null) {
return shallowMount(Section, {
props: {
canDefineLocalizable: false,
section: { _id: 'hero', handle: 'hero', display: 'Hero', fields: [], ...(extraConfig && { extraConfig }) },
},
global: {
provide: { setConfig },
mocks: {
$config: { get: () => null },
$stacks: { stacks: () => [] },
$keys: { bindGlobal: () => ({ destroy: vi.fn() }) },
},
stubs: { 'ui-stack': true, Fields: true },
},
});
}

test('cancelling nested config edits leaves the original set unchanged', async () => {
const config = { values: { addon: { note: 'Original' } }, meta: {} };
const wrapper = mountSection(config);
wrapper.vm.edit();
wrapper.vm.editingSection.extraConfig.values.addon.note = 'Changed';
await wrapper.vm.$nextTick();
wrapper.vm.editCancelled();
expect(wrapper.props('section').extraConfig.values.addon.note).toBe('Original');
wrapper.vm.edit();
expect(wrapper.vm.editingSection.extraConfig.values.addon.note).toBe('Original');
wrapper.unmount();
});

test('confirming includes edited config in the updated set', () => {
const wrapper = mountSection({ values: { addon: { note: 'Original' } }, meta: {} });
wrapper.vm.edit();
wrapper.vm.editingSection.extraConfig.values.addon.note = 'Changed';
wrapper.vm.editConfirmed();
expect(wrapper.emitted('updated').at(-1)[0].extraConfig.values.addon.note).toBe('Changed');
wrapper.unmount();
});

test('new sets get independent copies of registered defaults', () => {
const setConfig = { fields: [{ handle: 'addon' }], defaults: { values: { addon: { note: 'Default' } }, meta: {} } };
const wrapper = mountSection(undefined, setConfig);
wrapper.vm.edit();
wrapper.vm.editingSection.extraConfig.values.addon.note = 'Changed';
expect(setConfig.defaults.values.addon.note).toBe('Default');
wrapper.unmount();
});

test('ordinary blueprint sections do not acquire set configuration', () => {
const wrapper = mountSection();
wrapper.vm.edit();
expect(wrapper.vm.editingSection).not.toHaveProperty('extraConfig');
wrapper.unmount();
});
112 changes: 107 additions & 5 deletions src/Fieldtypes/Sets.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
use Statamic\Facades\Asset;
use Statamic\Facades\AssetContainer;
use Statamic\Facades\Icon;
use Statamic\Fields\Fieldset;
use Statamic\Fields\ConfigFields;
use Statamic\Fields\FieldTransformer;
use Statamic\Fields\Fieldtype;
use Statamic\Statamic;
Expand All @@ -18,6 +18,108 @@ class Sets extends Fieldtype
{
protected $selectable = false;

protected static $setConfigFields = [];

private const SET_KEYS = ['_id', 'handle', 'display', 'instructions', 'icon', 'image', 'hide', 'fields', 'extraConfig'];

public static function appendSetConfigFields(array $fields): void
{
foreach ($fields as $handle => $config) {
if (! is_string($handle) || in_array($handle, self::SET_KEYS)) {
throw new \InvalidArgumentException("Cannot append set config field [{$handle}]. Use a unique field handle.");
}
}

static::$setConfigFields = array_merge(static::$setConfigFields, $fields);
}

public static function appendSetConfigField(string $handle, array $config): void
{
static::appendSetConfigFields([$handle => $config]);
}

private function setConfigFields(): ConfigFields
{
return new ConfigFields(collect(static::$setConfigFields)->map(fn ($field, $handle) => compact('handle', 'field')));
}

private function preProcessSetConfig(array $set): array
{
$values = Arr::except($set, self::SET_KEYS);
$fields = $this->setConfigFields()->addValues($values)->preProcess();

if (! $values && $fields->all()->isEmpty()) {
return [];
}

return ['extraConfig' => [
'values' => array_merge($values, $fields->values()->all()),
'meta' => $fields->meta()->all(),
]];
}

private function processSetConfig(array $section): array
{
$values = Arr::except($section['extraConfig']['values'] ?? [], self::SET_KEYS);

return array_merge($values, $this->setConfigFields()->addValues($values)->process()->values()->all());
}

public function preload()
{
$fields = $this->setConfigFields()->preProcess();

return ['setConfig' => [
'fields' => $fields->toPublishArray(),
'defaults' => [
'values' => $fields->values()->all(),
'meta' => $fields->meta()->all(),
],
]];
}

public function extraRules(): array
{
return $this->setConfigValidation('rules');
}

public function extraValidationAttributes(): array
{
return $this->setConfigValidation('attributes');
}

private function setConfigValidation(string $method): array
{
return collect($this->field->value())->flatMap(function ($tab, $tabIndex) use ($method) {
return collect($tab['sections'] ?? [])->flatMap(function ($section, $sectionIndex) use ($tabIndex, $method) {
$prefix = "{$this->field->handle()}.{$tabIndex}.sections.{$sectionIndex}.extraConfig.values.";
$validator = $this->setConfigFields()
->addValues($section['extraConfig']['values'] ?? [])
->validator()
->withContext(['prefix' => $this->field->validationContext('prefix').$prefix]);

return collect($validator->{$method}())->mapWithKeys(fn ($value, $handle) => [$prefix.$handle => $value]);
});
})->all();
}

public function preProcessValidatable($value)
{
return collect($value)->map(function ($tab) {
$tab['sections'] = collect($tab['sections'] ?? [])->map(function ($section) {
if (static::$setConfigFields) {
$values = $section['extraConfig']['values'] ?? [];
$section['extraConfig']['values'] = array_merge($values, $this->setConfigFields()
->addValues($values)->preProcessValidatables()->values()->all());
}

return $section;
})->all();

return $tab;
})->all();
}

/**
* Converts the "sets" array of a Replicator (or Bard) field into what the
* <sets-fieldtype> Vue component is expecting, within either the Blueprint
Expand Down Expand Up @@ -50,7 +152,7 @@ public function preProcess($sets)
'instructions' => $group['instructions'] ?? null,
'icon' => $group['icon'] ?? null,
'sections' => collect($group['sets'] ?? [])->map(function ($set, $setHandle) use ($groupId) {
return [
return array_merge($this->preProcessSetConfig($set), [
'_id' => $setId = $groupId.'-section-'.$setHandle,
'handle' => $setHandle,
'display' => $set['display'] ?? null,
Expand All @@ -61,7 +163,7 @@ public function preProcess($sets)
'fields' => collect($set['fields'] ?? [])->map(function ($field, $i) use ($setId) {
return array_merge(FieldTransformer::toVue($field), ['_id' => $setId.'-'.$i]);
})->all(),
];
]);
})->values()->all(),
];
})->values()->all();
Expand Down Expand Up @@ -122,7 +224,7 @@ public function process($tabs)
'icon' => $tab['icon'] ?? null,
'sets' => collect($tab['sections'])->mapWithKeys(function ($section) {
return [
$section['handle'] => [
$section['handle'] => array_merge($this->processSetConfig($section), [
'display' => $section['display'],
'instructions' => $section['instructions'] ?? null,
'icon' => $section['icon'] ?? null,
Expand All @@ -131,7 +233,7 @@ public function process($tabs)
'fields' => collect($section['fields'])->map(function ($field) {
return FieldTransformer::fromVue($field);
})->all(),
],
]),
];
})->all(),
],
Expand Down
Loading
Loading