feat: DesignSystemWriter – Presets über Divis Sync-Ablauf schreiben (Mirror preset-sync)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
6ee8f9258a
commit
3fe83e39d3
@@ -0,0 +1,139 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Design;
|
||||||
|
|
||||||
|
use ET\Builder\Framework\UserRole\UserRole;
|
||||||
|
use ET\Builder\Packages\Conversion\Conversion;
|
||||||
|
use ET\Builder\Packages\GlobalData\GlobalPreset;
|
||||||
|
use LIT\MCP\Integrations\Divi\Release\Call;
|
||||||
|
use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
|
||||||
|
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
|
||||||
|
use LIT\MCP\Integrations\Divi\Release\Manifest;
|
||||||
|
use LIT\MCP\Integrations\Divi\Release\Mirror;
|
||||||
|
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
|
||||||
|
use RuntimeException;
|
||||||
|
use WP_Error;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adapter "Designsystem" (spec §3) for writing (Etappe 4): presets are saved the way Divi's own sync route does it
|
||||||
|
* (mirror preset-sync of GlobalPresetController::sync, formats "Nachtrag 5.15.1"): prepare_data() sanitizes the REST
|
||||||
|
* format, validate_preset_count() refuses a smaller inventory unless presets may be removed, removed presets leave
|
||||||
|
* Divi's Divi-4 legacy option, save_data() writes the option and clears the static CSS. Chunks and the route's nonce
|
||||||
|
* are not needed in-process; the tools check the rights themselves (spec §10). Also Divi's preset attribute mapping
|
||||||
|
* (which attributes a preset may carry) and the Visual Builder role right. Not final so abilities can be tested with
|
||||||
|
* a mock.
|
||||||
|
*/
|
||||||
|
class DesignSystemWriter implements DiviAdapter
|
||||||
|
{
|
||||||
|
public const ACTION_SAVE = 'ADD_NEW_MODULE_PRESET';
|
||||||
|
|
||||||
|
public const ACTION_DELETE_MODULE = 'DELETE_MODULE_PRESET';
|
||||||
|
|
||||||
|
public const ACTION_DELETE_GROUP = 'DELETE_OPTION_GROUP_PRESET';
|
||||||
|
|
||||||
|
/** Preset attribute types Divi copies into a preset (Conversion::get_preset_attrs). */
|
||||||
|
public const PRESET_TYPES = ['style', 'html', 'script'];
|
||||||
|
|
||||||
|
public static function manifest(): Manifest
|
||||||
|
{
|
||||||
|
return new Manifest(
|
||||||
|
Manifest::DESIGN,
|
||||||
|
calls: [
|
||||||
|
new Call(GlobalPreset::class . '::get_data', 0),
|
||||||
|
new Call(GlobalPreset::class . '::prepare_data', 1),
|
||||||
|
new Call(GlobalPreset::class . '::validate_preset_count', 3),
|
||||||
|
new Call(GlobalPreset::class . '::get_deleted_preset_ids', 3),
|
||||||
|
new Call(GlobalPreset::class . '::remove_preset_from_legacy_option', 2),
|
||||||
|
new Call(GlobalPreset::class . '::save_data', 1),
|
||||||
|
new Call(Conversion::class . '::get_preset_attrs_mapping', 1),
|
||||||
|
new Call(Conversion::class . '::get_preset_attrs', 3),
|
||||||
|
new Call(UserRole::class . '::can_current_user_use_visual_builder', 0),
|
||||||
|
],
|
||||||
|
mirrors: [
|
||||||
|
new Mirror(
|
||||||
|
'preset-sync',
|
||||||
|
'includes/builder-5/server/Packages/GlobalData/GlobalPresetController.php',
|
||||||
|
'sync',
|
||||||
|
'GlobalPresetController',
|
||||||
|
'Speicherablauf der Presets (prepare_data, validate_preset_count, Bereinigung der Divi-4-Option '
|
||||||
|
. 'beim Löschen, save_data)'
|
||||||
|
),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<array-key, mixed> The stored inventory (GlobalPreset::get_data(), formats §3). */
|
||||||
|
public function presets(): array
|
||||||
|
{
|
||||||
|
$data = GlobalPreset::get_data();
|
||||||
|
|
||||||
|
return is_array($data) ? $data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<array-key, mixed> $rest REST/sync format, see PresetBook::toRest().
|
||||||
|
* @param bool $mayRemove Deleting or restoring: fewer presets are allowed and removed ones
|
||||||
|
* leave Divi's legacy option (like a DELETE_* sync).
|
||||||
|
* @return array<array-key, mixed> The inventory Divi stored.
|
||||||
|
* @throws DiviLocked
|
||||||
|
* @throws RuntimeException When Divi refuses the inventory.
|
||||||
|
*/
|
||||||
|
public function savePresets(array $rest, bool $mayRemove): array
|
||||||
|
{
|
||||||
|
ReleaseGate::assertWritable();
|
||||||
|
$prepared = GlobalPreset::prepare_data($rest);
|
||||||
|
$current = GlobalPreset::get_data();
|
||||||
|
$refusal = GlobalPreset::validate_preset_count(
|
||||||
|
$current,
|
||||||
|
$prepared,
|
||||||
|
$mayRemove ? self::ACTION_DELETE_MODULE : self::ACTION_SAVE
|
||||||
|
);
|
||||||
|
if ($refusal instanceof WP_Error) {
|
||||||
|
throw new RuntimeException('preset sync refused: ' . $refusal->get_error_code() . ' '
|
||||||
|
. $refusal->get_error_message());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($mayRemove) {
|
||||||
|
foreach (['module', 'group'] as $kind) {
|
||||||
|
foreach (GlobalPreset::get_deleted_preset_ids($current, $prepared, $kind) as $deleted) {
|
||||||
|
GlobalPreset::remove_preset_from_legacy_option(
|
||||||
|
(string) ($deleted['id'] ?? ''),
|
||||||
|
(string) ($deleted['moduleName'] ?? '')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$saved = GlobalPreset::save_data($prepared);
|
||||||
|
|
||||||
|
return is_array($saved) ? $saved : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The parts of $attrs a preset of $moduleName carries (Divi's preset attribute mapping): all, style
|
||||||
|
* (styleAttrs) and render (renderAttrs: html + script).
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $attrs
|
||||||
|
* @return array{all: array<string, mixed>, style: array<string, mixed>, render: array<string, mixed>}
|
||||||
|
*/
|
||||||
|
public function presetAttrs(string $moduleName, array $attrs): array
|
||||||
|
{
|
||||||
|
$map = Conversion::get_preset_attrs_mapping($moduleName);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'all' => (array) Conversion::get_preset_attrs($attrs, self::PRESET_TYPES, $map),
|
||||||
|
'style' => (array) Conversion::get_preset_attrs($attrs, ['style'], $map),
|
||||||
|
'render' => (array) Conversion::get_preset_attrs($attrs, ['html', 'script'], $map),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Divi's role right "Visual Builder verwenden"; fail closed without Divi. */
|
||||||
|
public function canUseVisualBuilder(): bool
|
||||||
|
{
|
||||||
|
return class_exists(UserRole::class) && UserRole::can_current_user_use_visual_builder();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace LIT\MCP\Integrations\Divi\Release;
|
namespace LIT\MCP\Integrations\Divi\Release;
|
||||||
|
|
||||||
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
|
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
|
||||||
|
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
|
||||||
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
|
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
|
||||||
use LIT\MCP\Integrations\Divi\Library\DiviRoles;
|
use LIT\MCP\Integrations\Divi\Library\DiviRoles;
|
||||||
use LIT\MCP\Integrations\Divi\Pages\DiviPages;
|
use LIT\MCP\Integrations\Divi\Pages\DiviPages;
|
||||||
@@ -25,6 +26,7 @@ final class DiviAdapters
|
|||||||
DiviRoles::class,
|
DiviRoles::class,
|
||||||
DiviThemeBuilder::class,
|
DiviThemeBuilder::class,
|
||||||
DesignSystemReader::class,
|
DesignSystemReader::class,
|
||||||
|
DesignSystemWriter::class,
|
||||||
DiviModules::class,
|
DiviModules::class,
|
||||||
DynamicContentMap::class,
|
DynamicContentMap::class,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"library-submit-layout": "594b3843d4c42e1a91e5eda06f108c79583a0036e46c92dd749f4f541e362d02",
|
"library-submit-layout": "594b3843d4c42e1a91e5eda06f108c79583a0036e46c92dd749f4f541e362d02",
|
||||||
|
"preset-sync": "9586f8005b8065893c0350977a58ee9171bf715fff21ac0c84663cc512f45522",
|
||||||
"tb-api-save": "7ba452f4d73c9b676281ca6ddcfddd669502965db868bf478ef7804937e5bfc4",
|
"tb-api-save": "7ba452f4d73c9b676281ca6ddcfddd669502965db868bf478ef7804937e5bfc4",
|
||||||
"tb-load-setting-options": "2ea0a6925a1da5522489a417104b384b2bd77a16b25f4122b9503873a4c8a24e",
|
"tb-load-setting-options": "2ea0a6925a1da5522489a417104b384b2bd77a16b25f4122b9503873a4c8a24e",
|
||||||
"tb-store-template": "3bf4ac6028ed99ed8ac3523c659392a5fce7818ef8a80ea226e01f5404eb441d",
|
"tb-store-template": "3bf4ac6028ed99ed8ac3523c659392a5fce7818ef8a80ea226e01f5404eb441d",
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
function sync( WP_REST_Request $request ) {
|
||||||
|
$is_chunked = $request->get_param( 'isChunked' );
|
||||||
|
$chunk_index = $request->get_param( 'chunkIndex' );
|
||||||
|
$total_chunks = $request->get_param( 'totalChunks' );
|
||||||
|
$is_last_chunk = $request->get_param( 'isLastChunk' );
|
||||||
|
$request_id = $request->get_param( 'requestId' );
|
||||||
|
$action_type = $request->get_param( 'actionType' ) ?? '';
|
||||||
|
|
||||||
|
// Validate chunked request: if isChunked is true, all chunking parameters must be present.
|
||||||
|
if ( true === $is_chunked ) {
|
||||||
|
if ( null === $chunk_index || null === $total_chunks || null === $is_last_chunk || null === $request_id ) {
|
||||||
|
return RESTController::response_error(
|
||||||
|
'invalid_chunked_request',
|
||||||
|
esc_html__( 'Invalid chunked request: missing required chunking parameters.', 'et_builder_5' ),
|
||||||
|
[
|
||||||
|
'status' => 400,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate non-chunked request: if isChunked is false (or null/defaults to false), chunking parameters must not be present.
|
||||||
|
if ( false === $is_chunked || null === $is_chunked ) {
|
||||||
|
if ( null !== $chunk_index || null !== $total_chunks || null !== $is_last_chunk || null !== $request_id ) {
|
||||||
|
return RESTController::response_error(
|
||||||
|
'invalid_non_chunked_request',
|
||||||
|
esc_html__( 'Invalid non-chunked request: chunking parameters should not be present.', 'et_builder_5' ),
|
||||||
|
[
|
||||||
|
'status' => 400,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle chunked requests: accumulate chunks before processing.
|
||||||
|
// Check explicit isChunked flag first, then verify all required chunking parameters are present.
|
||||||
|
if ( true === $is_chunked ) {
|
||||||
|
// Get transient key for this request ID.
|
||||||
|
$transient_key = self::_get_chunk_transient_key( $request_id );
|
||||||
|
|
||||||
|
// Get existing accumulated chunks from transient.
|
||||||
|
$accumulated_data = get_transient( $transient_key );
|
||||||
|
|
||||||
|
// Initialize accumulation for this request ID if not exists.
|
||||||
|
if ( false === $accumulated_data ) {
|
||||||
|
$accumulated_data = [
|
||||||
|
'chunks' => [],
|
||||||
|
'actionType' => $action_type,
|
||||||
|
'totalChunks' => $total_chunks,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store this chunk.
|
||||||
|
$chunk_presets = $request->get_param( 'presets' );
|
||||||
|
$accumulated_data['chunks'][ $chunk_index ] = $chunk_presets;
|
||||||
|
|
||||||
|
// Update transient with new chunk data.
|
||||||
|
set_transient( $transient_key, $accumulated_data, self::CHUNK_TRANSIENT_EXPIRATION );
|
||||||
|
|
||||||
|
// If this is not the last chunk, return early with success (chunk received).
|
||||||
|
if ( ! $is_last_chunk ) {
|
||||||
|
return RESTController::response_success( (object) [] );
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is the last chunk - verify we received all expected chunks before merging.
|
||||||
|
$accumulated_chunks = $accumulated_data['chunks'];
|
||||||
|
$received_chunk_count = count( $accumulated_chunks );
|
||||||
|
|
||||||
|
// Verify we have all chunks (chunk indices are 0-based, so total should match).
|
||||||
|
if ( $received_chunk_count !== $total_chunks ) {
|
||||||
|
// Clean up transient on error.
|
||||||
|
delete_transient( $transient_key );
|
||||||
|
|
||||||
|
return RESTController::response_error(
|
||||||
|
'incomplete_chunks',
|
||||||
|
sprintf(
|
||||||
|
// translators: %1$d: received chunks, %2$d: expected chunks.
|
||||||
|
esc_html__( 'Incomplete chunk data received. Expected %1$d chunks, received %2$d chunks.', 'et_builder_5' ),
|
||||||
|
$total_chunks,
|
||||||
|
$received_chunk_count
|
||||||
|
),
|
||||||
|
[
|
||||||
|
'status' => 400,
|
||||||
|
'expectedChunks' => $total_chunks,
|
||||||
|
'receivedChunks' => $received_chunk_count,
|
||||||
|
'receivedIndices' => array_keys( $accumulated_chunks ),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge all chunks and process.
|
||||||
|
$merged_presets = self::_merge_chunks( $accumulated_chunks );
|
||||||
|
|
||||||
|
// Clean up transient after successful merge.
|
||||||
|
delete_transient( $transient_key );
|
||||||
|
|
||||||
|
// Use merged presets for processing.
|
||||||
|
$prepared_data = GlobalPreset::prepare_data( $merged_presets );
|
||||||
|
} else {
|
||||||
|
// Non-chunked request - process normally.
|
||||||
|
$prepared_data = GlobalPreset::prepare_data( $request->get_param( 'presets' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL SAFETY CHECK: Validate that we're not accidentally losing presets during sync.
|
||||||
|
// Only DELETE actions should reduce the preset count. All other operations (save, add, update)
|
||||||
|
// should preserve or increase the preset count.
|
||||||
|
// This prevents bugs where incomplete restore points or data corruption causes preset loss.
|
||||||
|
// We compare against the current database state (source of truth), not client-side state.
|
||||||
|
$current_presets = GlobalPreset::get_data();
|
||||||
|
$validation_error = GlobalPreset::validate_preset_count( $current_presets, $prepared_data, $action_type );
|
||||||
|
|
||||||
|
if ( is_wp_error( $validation_error ) ) {
|
||||||
|
// Clean up transient on validation error.
|
||||||
|
if ( null !== $request_id ) {
|
||||||
|
$transient_key = self::_get_chunk_transient_key( $request_id );
|
||||||
|
delete_transient( $transient_key );
|
||||||
|
}
|
||||||
|
|
||||||
|
return $validation_error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle deletion cleanup for D4 legacy presets.
|
||||||
|
// When a preset is deleted in D5, we need to also remove it from the D4 legacy option
|
||||||
|
// to prevent re-migration on page refresh.
|
||||||
|
if ( 'DELETE_MODULE_PRESET' === $action_type || 'DELETE_OPTION_GROUP_PRESET' === $action_type ) {
|
||||||
|
$preset_type = 'DELETE_MODULE_PRESET' === $action_type ? 'module' : 'group';
|
||||||
|
|
||||||
|
// Get deleted preset IDs by comparing current (before deletion) vs incoming (after deletion).
|
||||||
|
$deleted_presets = GlobalPreset::get_deleted_preset_ids( $current_presets, $prepared_data, $preset_type );
|
||||||
|
|
||||||
|
// Remove each deleted preset from the D4 legacy option.
|
||||||
|
foreach ( $deleted_presets as $deleted_preset ) {
|
||||||
|
GlobalPreset::remove_preset_from_legacy_option(
|
||||||
|
$deleted_preset['id'],
|
||||||
|
$deleted_preset['moduleName']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$saved_data = GlobalPreset::save_data( $prepared_data );
|
||||||
|
|
||||||
|
return RESTController::response_success( (object) $saved_data );
|
||||||
|
}
|
||||||
@@ -184,7 +184,10 @@ final class DiviCheckCommandTest extends TestCase
|
|||||||
|
|
||||||
self::assertSame(0, $code);
|
self::assertSame(0, $code);
|
||||||
self::assertSame(
|
self::assertSame(
|
||||||
['library-submit-layout', 'tb-api-save', 'tb-load-setting-options', 'tb-store-template', 'vb-sync-flags'],
|
[
|
||||||
|
'library-submit-layout', 'preset-sync', 'tb-api-save', 'tb-load-setting-options', 'tb-store-template',
|
||||||
|
'vb-sync-flags',
|
||||||
|
],
|
||||||
array_keys(DiviReference::expected($this->expectedFile))
|
array_keys(DiviReference::expected($this->expectedFile))
|
||||||
);
|
);
|
||||||
self::assertStringStartsWith(
|
self::assertStringStartsWith(
|
||||||
@@ -193,7 +196,7 @@ final class DiviCheckCommandTest extends TestCase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Minimal stand-ins for the five mirrored Divi functions at their real paths. */
|
/** Minimal stand-ins for the six mirrored Divi functions at their real paths. */
|
||||||
private function writeDivi(): void
|
private function writeDivi(): void
|
||||||
{
|
{
|
||||||
$files = [
|
$files = [
|
||||||
@@ -204,6 +207,8 @@ final class DiviCheckCommandTest extends TestCase
|
|||||||
. "function et_theme_builder_load_template_setting_options( \$ids ) {\n}",
|
. "function et_theme_builder_load_template_setting_options( \$ids ) {\n}",
|
||||||
'includes/builder-5/server/VisualBuilder/REST/SyncToServer/SyncToServerController.php' =>
|
'includes/builder-5/server/VisualBuilder/REST/SyncToServer/SyncToServerController.php' =>
|
||||||
"class SyncToServerController {\n\tpublic static function update( \$request ) {\n\t}\n}",
|
"class SyncToServerController {\n\tpublic static function update( \$request ) {\n\t}\n}",
|
||||||
|
'includes/builder-5/server/Packages/GlobalData/GlobalPresetController.php' =>
|
||||||
|
"class GlobalPresetController {\n\tpublic static function sync( \$request ) {\n\t}\n}",
|
||||||
];
|
];
|
||||||
foreach ($files as $path => $code) {
|
foreach ($files as $path => $code) {
|
||||||
$file = $this->themeDir . '/' . $path;
|
$file = $this->themeDir . '/' . $path;
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||||||
|
|
||||||
|
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
|
||||||
|
use LIT\MCP\Integrations\Divi\Release\Call;
|
||||||
|
use LIT\MCP\Integrations\Divi\Release\DiviAdapters;
|
||||||
|
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
|
||||||
|
use LIT\MCP\Integrations\Divi\Release\Manifest;
|
||||||
|
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
|
||||||
|
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
|
||||||
|
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
|
||||||
|
use LIT\MCP\Tests\Unit\TestCase;
|
||||||
|
|
||||||
|
final class DesignSystemWriterTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testIsARegisteredDesignAdapterWithTheSyncMirror(): void
|
||||||
|
{
|
||||||
|
$manifest = DesignSystemWriter::manifest();
|
||||||
|
|
||||||
|
self::assertContains(DesignSystemWriter::class, DiviAdapters::CLASSES);
|
||||||
|
self::assertSame(Manifest::DESIGN, $manifest->area);
|
||||||
|
self::assertSame(
|
||||||
|
[
|
||||||
|
'GlobalPreset::get_data()', 'GlobalPreset::prepare_data()', 'GlobalPreset::validate_preset_count()',
|
||||||
|
'GlobalPreset::get_deleted_preset_ids()', 'GlobalPreset::remove_preset_from_legacy_option()',
|
||||||
|
'GlobalPreset::save_data()', 'Conversion::get_preset_attrs_mapping()', 'Conversion::get_preset_attrs()',
|
||||||
|
'UserRole::can_current_user_use_visual_builder()',
|
||||||
|
],
|
||||||
|
array_map(static fn (Call $call): string => $call->label(), $manifest->calls)
|
||||||
|
);
|
||||||
|
self::assertSame(['preset-sync'], array_map(static fn ($mirror): string => $mirror->id, $manifest->mirrors));
|
||||||
|
self::assertSame('GlobalPresetController', $manifest->mirrors[0]->class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWritesNeedAReleasedDivi(): void
|
||||||
|
{
|
||||||
|
ReleaseGate::useResolver(static fn (): ReleaseStatus => new ReleaseStatus(ReleaseState::Untested, '5.16.0'));
|
||||||
|
|
||||||
|
$this->expectException(DiviLocked::class);
|
||||||
|
|
||||||
|
(new DesignSystemWriter())->savePresets(['module' => [], 'group' => []], false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWithoutDiviNobodyUsesTheVisualBuilder(): void
|
||||||
|
{
|
||||||
|
self::assertFalse((new DesignSystemWriter())->canUseVisualBuilder());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user