fix: TB dry runs hide uneditable live templates, off-switch despite draft (rulings Q20, Q21)
- ThemeBuilderAccess::editableLiveTemplates() shared by tb-list and the tb-assign/tb-activate dry runs; uneditable live templates only as a count (hidden_overlaps, hidden_live) - tb-activate enabled:false works while the Theme Builder has a draft, with a warning - possible_overlaps skips identical conditions; reattach records the previous enabled state; exclude_from must be a list; tb-activate live from the read-back Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -24,12 +24,18 @@ defined('ABSPATH') || exit;
|
|||||||
* in the change log. Refused for the default template, a template the live Theme Builder does not list (reattach
|
* in the change log. Refused for the default template, a template the live Theme Builder does not list (reattach
|
||||||
* first) and, when enabling, one without conditions (it would apply nowhere) or with a layout that is no longer
|
* first) and, when enabling, one without conditions (it would apply nowhere) or with a layout that is no longer
|
||||||
* published (B10). Switching off is the fast way back and fail-safe: it also reaches a live template that is not
|
* published (B10). Switching off is the fast way back and fail-safe: it also reaches a live template that is not
|
||||||
* published, e.g. in the trash (ruling Q16), and never needs the layouts.
|
* published, e.g. in the trash (ruling Q16), never needs the layouts and works despite a Theme Builder draft (with
|
||||||
|
* DRAFT_WARNING, ruling Q20). Overlaps name only templates the user may edit, the others as a count (ruling Q21).
|
||||||
*/
|
*/
|
||||||
final class TbActivateAbility implements Ability
|
final class TbActivateAbility implements Ability
|
||||||
{
|
{
|
||||||
public const REASON = 'Die Vorlage konnte nicht umgeschaltet werden.';
|
public const REASON = 'Die Vorlage konnte nicht umgeschaltet werden.';
|
||||||
|
|
||||||
|
/** Ruling Q20: the off-switch works despite a Theme Builder draft – which may restore its own state. */
|
||||||
|
public const DRAFT_WARNING = 'Der Divi Theme Builder hat ungespeicherte Änderungen (Entwurf); wird ein dort '
|
||||||
|
. 'geöffnetes Fenster gespeichert, stellt Divi evtl. seinen eigenen Stand wieder her und die Vorlage ist '
|
||||||
|
. 'wieder aktiv – danach mit lit/tb-list prüfen oder die Änderungen im Theme Builder verwerfen.';
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private ThemeBuilderAccess $access,
|
private ThemeBuilderAccess $access,
|
||||||
private TemplateConditions $conditions,
|
private TemplateConditions $conditions,
|
||||||
@@ -171,15 +177,26 @@ final class TbActivateAbility implements Ability
|
|||||||
$template->status === 'publish' ? '' : sprintf(' (im Papierkorb oder Status „%s“)', $template->status),
|
$template->status === 'publish' ? '' : sprintf(' (im Papierkorb oder Status „%s“)', $template->status),
|
||||||
implode('; ', $labels)
|
implode('; ', $labels)
|
||||||
);
|
);
|
||||||
$live = $enabled ? $this->access->store()->liveTemplates() : [];
|
$overlaps = Template::overlapDetails(
|
||||||
|
$enabled ? $this->access->editableLiveTemplates() : ['templates' => [], 'hidden' => []],
|
||||||
|
$template->id,
|
||||||
|
$template->useOn
|
||||||
|
);
|
||||||
|
$message .= Template::hiddenOverlapsNote($overlaps['hidden_overlaps']);
|
||||||
|
if (! $enabled && $this->draftPending()) {
|
||||||
|
$message .= ' ' . self::DRAFT_WARNING;
|
||||||
|
}
|
||||||
|
|
||||||
return Errors::confirmRequired($message, [
|
return Errors::confirmRequired($message, [
|
||||||
'use_on' => $labels,
|
'use_on' => $labels,
|
||||||
'exclude_from' => array_map($this->conditions->label(...), $template->excludeFrom),
|
'exclude_from' => array_map($this->conditions->label(...), $template->excludeFrom),
|
||||||
'layouts' => $template->layouts,
|
'layouts' => $template->layouts,
|
||||||
'overlaps' => Template::overlaps($live, $template->id, $template->useOn),
|
] + $overlaps);
|
||||||
'possible_overlaps' => Template::possibleOverlaps($live, $template->id, $template->useOn),
|
}
|
||||||
]);
|
|
||||||
|
private function draftPending(): bool
|
||||||
|
{
|
||||||
|
return $this->access->store()->draftId() > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -203,6 +220,9 @@ final class TbActivateAbility implements Ability
|
|||||||
. 'erst nach „Statische CSS-Dateien leeren“ (Divi → Theme-Optionen → Builder).',
|
. 'erst nach „Statische CSS-Dateien leeren“ (Divi → Theme-Optionen → Builder).',
|
||||||
$warnings
|
$warnings
|
||||||
);
|
);
|
||||||
|
if (! $enabled && $this->draftPending()) {
|
||||||
|
$warnings[] = self::DRAFT_WARNING;
|
||||||
|
}
|
||||||
$logId = $this->log->record(
|
$logId = $this->log->record(
|
||||||
$this->name(),
|
$this->name(),
|
||||||
DiviThemeBuilder::TEMPLATE,
|
DiviThemeBuilder::TEMPLATE,
|
||||||
@@ -214,7 +234,7 @@ final class TbActivateAbility implements Ability
|
|||||||
$template->useOn === [] ? 'keine' : implode(', ', $template->useOn)
|
$template->useOn === [] ? 'keine' : implode(', ', $template->useOn)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
$after = $store->template($template->id, true) ?? $template;
|
$after = $store->template($template->id, true);
|
||||||
|
|
||||||
$summary = $enabled
|
$summary = $enabled
|
||||||
? sprintf(
|
? sprintf(
|
||||||
@@ -224,7 +244,7 @@ final class TbActivateAbility implements Ability
|
|||||||
)
|
)
|
||||||
: sprintf('Vorlage „%s“ deaktiviert – sie gilt nicht mehr für Besucher.', $template->title);
|
: sprintf('Vorlage „%s“ deaktiviert – sie gilt nicht mehr für Besucher.', $template->title);
|
||||||
|
|
||||||
return $this->result($after, $summary, $logId, $enabled, $warnings);
|
return $this->result($after ?? $template, $summary, $logId, $enabled, $after?->isLive() ?? $enabled, $warnings);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @throws WriteFailed Always. */
|
/** @throws WriteFailed Always. */
|
||||||
@@ -260,13 +280,14 @@ final class TbActivateAbility implements Ability
|
|||||||
string $summary,
|
string $summary,
|
||||||
?int $logId,
|
?int $logId,
|
||||||
?bool $enabled = null,
|
?bool $enabled = null,
|
||||||
|
?bool $live = null,
|
||||||
array $warnings = []
|
array $warnings = []
|
||||||
): array {
|
): array {
|
||||||
return ToolResult::ok($summary)
|
return ToolResult::ok($summary)
|
||||||
->with('template_id', $template->id)
|
->with('template_id', $template->id)
|
||||||
->with('template_rev', $template->rev())
|
->with('template_rev', $template->rev())
|
||||||
->with('enabled', $enabled ?? $template->enabled)
|
->with('enabled', $enabled ?? $template->enabled)
|
||||||
->with('live', $enabled ?? $template->isLive())
|
->with('live', $live ?? $template->isLive())
|
||||||
->warnings($warnings)
|
->warnings($warnings)
|
||||||
->logId($logId)
|
->logId($logId)
|
||||||
->urls(null, (string) admin_url('admin.php?page=et_theme_builder'))
|
->urls(null, (string) admin_url('admin.php?page=et_theme_builder'))
|
||||||
|
|||||||
@@ -118,9 +118,14 @@ final class TbAssignAbility implements Ability
|
|||||||
$useOn = is_array($input['use_on'] ?? null)
|
$useOn = is_array($input['use_on'] ?? null)
|
||||||
? $this->conditions->check($input['use_on'], 'use_on')
|
? $this->conditions->check($input['use_on'], 'use_on')
|
||||||
: ['ids' => [], 'errors' => ['use_on' => 'Liste von Bedingungs-IDs erwartet (leere Liste = keine)']];
|
: ['ids' => [], 'errors' => ['use_on' => 'Liste von Bedingungs-IDs erwartet (leere Liste = keine)']];
|
||||||
$excludeFrom = array_key_exists('exclude_from', $input)
|
$excludeFrom = match (true) {
|
||||||
? $this->conditions->check($input['exclude_from'] ?? [], 'exclude_from')
|
! array_key_exists('exclude_from', $input) => ['ids' => $template->excludeFrom, 'errors' => []],
|
||||||
: ['ids' => $template->excludeFrom, 'errors' => []];
|
is_array($input['exclude_from']) => $this->conditions->check($input['exclude_from'], 'exclude_from'),
|
||||||
|
default => [
|
||||||
|
'ids' => [],
|
||||||
|
'errors' => ['exclude_from' => 'Liste von Bedingungs-IDs erwartet ([] entfernt die Ausnahmen)'],
|
||||||
|
],
|
||||||
|
};
|
||||||
$errors = array_merge($useOn['errors'], $excludeFrom['errors']);
|
$errors = array_merge($useOn['errors'], $excludeFrom['errors']);
|
||||||
if ($errors !== []) {
|
if ($errors !== []) {
|
||||||
return Errors::invalidInput('Bedingungen sind ungültig; nichts wurde gespeichert.', $errors);
|
return Errors::invalidInput('Bedingungen sind ungültig; nichts wurde gespeichert.', $errors);
|
||||||
@@ -181,20 +186,19 @@ final class TbAssignAbility implements Ability
|
|||||||
*/
|
*/
|
||||||
private function dryRun(Template $template, array $useOn, array $excludeFrom): WP_Error
|
private function dryRun(Template $template, array $useOn, array $excludeFrom): WP_Error
|
||||||
{
|
{
|
||||||
$live = $this->access->store()->liveTemplates();
|
$overlaps = Template::overlapDetails($this->access->editableLiveTemplates(), $template->id, $useOn);
|
||||||
|
|
||||||
return Errors::confirmRequired(
|
return Errors::confirmRequired(
|
||||||
sprintf(
|
sprintf(
|
||||||
'Vorlage „%s“ ist aktiv; die neuen Bedingungen (use_on) und Ausnahmen (exclude_from) wirken sofort auf '
|
'Vorlage „%s“ ist aktiv; die neuen Bedingungen (use_on) und Ausnahmen (exclude_from) wirken sofort auf '
|
||||||
. 'der Website – vorher/nachher unter before/after. Nichts wurde gespeichert (Probelauf).',
|
. 'der Website – vorher/nachher unter before/after.%s Nichts wurde gespeichert (Probelauf).',
|
||||||
$template->title
|
$template->title,
|
||||||
|
Template::hiddenOverlapsNote($overlaps['hidden_overlaps'])
|
||||||
),
|
),
|
||||||
[
|
[
|
||||||
'before' => $this->labels($template->useOn, $template->excludeFrom),
|
'before' => $this->labels($template->useOn, $template->excludeFrom),
|
||||||
'after' => $this->labels($useOn, $excludeFrom),
|
'after' => $this->labels($useOn, $excludeFrom),
|
||||||
'overlaps' => Template::overlaps($live, $template->id, $useOn),
|
] + $overlaps
|
||||||
'possible_overlaps' => Template::possibleOverlaps($live, $template->id, $useOn),
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,7 +260,12 @@ final class TbAssignAbility implements Ability
|
|||||||
sprintf(
|
sprintf(
|
||||||
'Theme Builder: Bedingungen von „%s“ gesetzt%s (vorher: %s; Ausnahmen vorher: %s)',
|
'Theme Builder: Bedingungen von „%s“ gesetzt%s (vorher: %s; Ausnahmen vorher: %s)',
|
||||||
$template->title,
|
$template->title,
|
||||||
$reattach ? ', wieder zugeordnet (deaktiviert)' : '',
|
$reattach
|
||||||
|
? sprintf(
|
||||||
|
', wieder zugeordnet (deaktiviert, vorher %s)',
|
||||||
|
$template->enabled ? 'aktiviert' : 'deaktiviert'
|
||||||
|
)
|
||||||
|
: '',
|
||||||
self::listed($template->useOn),
|
self::listed($template->useOn),
|
||||||
self::listed($template->excludeFrom)
|
self::listed($template->excludeFrom)
|
||||||
)
|
)
|
||||||
@@ -276,7 +285,8 @@ final class TbAssignAbility implements Ability
|
|||||||
->with('template_id', $template->id)
|
->with('template_id', $template->id)
|
||||||
->with('template_rev', $after?->rev() ?? '')
|
->with('template_rev', $after?->rev() ?? '')
|
||||||
->with('live', $live)
|
->with('live', $live)
|
||||||
->with('previous', ['use_on' => $template->useOn, 'exclude_from' => $template->excludeFrom])
|
->with('previous', ['use_on' => $template->useOn, 'exclude_from' => $template->excludeFrom]
|
||||||
|
+ ($reattach ? ['enabled' => $template->enabled] : []))
|
||||||
->warnings($warnings)
|
->warnings($warnings)
|
||||||
->logId($logId)
|
->logId($logId)
|
||||||
->urls(null, (string) admin_url('admin.php?page=et_theme_builder'))
|
->urls(null, (string) admin_url('admin.php?page=et_theme_builder'))
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ defined('ABSPATH') || exit;
|
|||||||
* Divi's unused mark – Divi trashes marked posts 7 days later, F19, stage3b finding 10) so they can be
|
* Divi's unused mark – Divi trashes marked posts 7 days later, F19, stage3b finding 10) so they can be
|
||||||
* reattached; optionally Divi's condition catalog. Needs ThemeBuilderAccess::canUse(). Ruling Q8/B11: a
|
* reattached; optionally Divi's condition catalog. Needs ThemeBuilderAccess::canUse(). Ruling Q8/B11: a
|
||||||
* template the user may not edit does not appear at all (not listed, not in shared_with, asked for by ID it
|
* template the user may not edit does not appear at all (not listed, not in shared_with, asked for by ID it
|
||||||
* reads like a missing one – P12); layout titles only for layouts the user may edit; condition labels name
|
* reads like a missing one – P12) – active ones only as a count (hidden_live, ruling Q21); layout titles only for
|
||||||
* objects by the stored ID only, never by title.
|
* layouts the user may edit; condition labels name objects by the stored ID only, never by title.
|
||||||
*/
|
*/
|
||||||
final class TbListAbility implements Ability
|
final class TbListAbility implements Ability
|
||||||
{
|
{
|
||||||
@@ -71,12 +71,16 @@ final class TbListAbility implements Ability
|
|||||||
'properties' => [
|
'properties' => [
|
||||||
'theme_builder_id' => ['type' => 'integer'],
|
'theme_builder_id' => ['type' => 'integer'],
|
||||||
'draft_pending' => ['type' => 'boolean'],
|
'draft_pending' => ['type' => 'boolean'],
|
||||||
|
'hidden_live' => [
|
||||||
|
'type' => 'integer',
|
||||||
|
'description' => 'Aktive Vorlagen, die der Nutzer nicht bearbeiten darf (nur die Anzahl).',
|
||||||
|
],
|
||||||
'templates' => $objects,
|
'templates' => $objects,
|
||||||
'detached' => $objects,
|
'detached' => $objects,
|
||||||
'conditions' => $objects,
|
'conditions' => $objects,
|
||||||
'notes' => ['type' => 'array', 'items' => ['type' => 'string']],
|
'notes' => ['type' => 'array', 'items' => ['type' => 'string']],
|
||||||
],
|
],
|
||||||
'required' => ['theme_builder_id', 'draft_pending', 'templates', 'detached', 'notes'],
|
'required' => ['theme_builder_id', 'draft_pending', 'hidden_live', 'templates', 'detached', 'notes'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,12 +107,13 @@ final class TbListAbility implements Ability
|
|||||||
}
|
}
|
||||||
|
|
||||||
$store = $this->access->store();
|
$store = $this->access->store();
|
||||||
$editable = fn (Template $template): bool => $this->access->canEditTemplate($template->id);
|
$split = $this->access->editableLiveTemplates();
|
||||||
$live = array_values(array_filter($store->liveTemplates(), $editable));
|
$live = $split['templates'];
|
||||||
|
$hiddenLive = count(array_filter($split['hidden'], static fn (Template $t): bool => $t->isLive()));
|
||||||
$detached = [];
|
$detached = [];
|
||||||
foreach ($store->detachedIds(self::DETACHED_LIMIT) as $id) {
|
foreach ($store->detachedIds(self::DETACHED_LIMIT) as $id) {
|
||||||
$template = $store->template($id, false);
|
$template = $store->template($id, false);
|
||||||
if ($template !== null && $editable($template)) {
|
if ($template !== null && $this->access->canEditTemplate($template->id)) {
|
||||||
$detached[] = $template;
|
$detached[] = $template;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,7 +139,15 @@ final class TbListAbility implements Ability
|
|||||||
$notes = [];
|
$notes = [];
|
||||||
if ($draft) {
|
if ($draft) {
|
||||||
$notes[] = 'Der Theme Builder hat ungespeicherte Änderungen (Entwurf); bis sie dort gespeichert oder '
|
$notes[] = 'Der Theme Builder hat ungespeicherte Änderungen (Entwurf); bis sie dort gespeichert oder '
|
||||||
. 'verworfen sind, ändert LIT MCP keine Vorlagen.';
|
. 'verworfen sind, ändert LIT MCP keine Vorlagen – nur ausblenden (lit/tb-activate mit enabled: '
|
||||||
|
. 'false) geht.';
|
||||||
|
}
|
||||||
|
if ($hiddenLive > 0) {
|
||||||
|
$notes[] = sprintf(
|
||||||
|
'Weitere aktive Vorlagen ohne Berechtigung: %d (nicht aufgeführt; sie können sich mit den '
|
||||||
|
. 'aufgeführten überschneiden).',
|
||||||
|
$hiddenLive
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if ($detached !== []) {
|
if ($detached !== []) {
|
||||||
$notes[] = 'detached: Diese Vorlagen hat ein späteres Speichern im Theme Builder entfernt – oder jemand '
|
$notes[] = 'detached: Diese Vorlagen hat ein späteres Speichern im Theme Builder entfernt – oder jemand '
|
||||||
@@ -146,6 +159,7 @@ final class TbListAbility implements Ability
|
|||||||
$result = [
|
$result = [
|
||||||
'theme_builder_id' => $store->liveId(),
|
'theme_builder_id' => $store->liveId(),
|
||||||
'draft_pending' => $draft,
|
'draft_pending' => $draft,
|
||||||
|
'hidden_live' => $hiddenLive,
|
||||||
'templates' => array_map(fn (Template $t): array => $this->describe($t, $shared), $live),
|
'templates' => array_map(fn (Template $t): array => $this->describe($t, $shared), $live),
|
||||||
'detached' => array_map(fn (Template $t): array => $this->describe($t, $shared), $detached),
|
'detached' => array_map(fn (Template $t): array => $this->describe($t, $shared), $detached),
|
||||||
'notes' => $notes,
|
'notes' => $notes,
|
||||||
|
|||||||
@@ -164,8 +164,11 @@ final class Template
|
|||||||
}
|
}
|
||||||
$conditions = [];
|
$conditions = [];
|
||||||
foreach ($other->useOn as $theirs) {
|
foreach ($other->useOn as $theirs) {
|
||||||
|
if (in_array($theirs, $useOn, true)) {
|
||||||
|
continue; // identical: overlaps()
|
||||||
|
}
|
||||||
foreach ($useOn as $ours) {
|
foreach ($useOn as $ours) {
|
||||||
if ($theirs !== $ours && self::mayOverlap($theirs, $ours)) {
|
if (self::mayOverlap($theirs, $ours)) {
|
||||||
$conditions[] = $theirs;
|
$conditions[] = $theirs;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -179,6 +182,38 @@ final class Template
|
|||||||
return $overlaps;
|
return $overlaps;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The overlap details of a dry run (tb-assign, tb-activate) for template $exceptId with conditions $useOn:
|
||||||
|
* identical and possible overlaps with the live templates the user may edit, and only the number of
|
||||||
|
* overlapping live templates the user may not edit (ruling Q21, Q8/B11).
|
||||||
|
*
|
||||||
|
* @param array{templates: list<Template>, hidden: list<Template>} $live ThemeBuilderAccess::editableLiveTemplates()
|
||||||
|
* @param list<string> $useOn
|
||||||
|
* @return array{overlaps: list<array{id: int, title: string, conditions: list<string>}>,
|
||||||
|
* possible_overlaps: list<array{id: int, title: string, conditions: list<string>}>, hidden_overlaps: int}
|
||||||
|
*/
|
||||||
|
public static function overlapDetails(array $live, int $exceptId, array $useOn): array
|
||||||
|
{
|
||||||
|
$hidden = array_merge(
|
||||||
|
self::overlaps($live['hidden'], $exceptId, $useOn),
|
||||||
|
self::possibleOverlaps($live['hidden'], $exceptId, $useOn)
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'overlaps' => self::overlaps($live['templates'], $exceptId, $useOn),
|
||||||
|
'possible_overlaps' => self::possibleOverlaps($live['templates'], $exceptId, $useOn),
|
||||||
|
'hidden_overlaps' => count(array_unique(array_column($hidden, 'id'))),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The sentence for hidden_overlaps > 0 (never names them). */
|
||||||
|
public static function hiddenOverlapsNote(int $count): string
|
||||||
|
{
|
||||||
|
return $count > 0
|
||||||
|
? sprintf(' Weitere aktive Vorlagen ohne Berechtigung, die sich überschneiden können: %d.', $count)
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
private static function mayOverlap(string $a, string $b): bool
|
private static function mayOverlap(string $a, string $b): bool
|
||||||
{
|
{
|
||||||
foreach (self::scopesOf($a) as $x) {
|
foreach (self::scopesOf($a) as $x) {
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ class ThemeBuilderAccess
|
|||||||
* never the default template (E8), the current template_rev. $offSwitch (lit/tb-activate with enabled: false,
|
* never the default template (E8), the current template_rev. $offSwitch (lit/tb-activate with enabled: false,
|
||||||
* ruling Q16): also a template visitors see although it is not published (e.g. in the trash – Divi renders
|
* ruling Q16): also a template visitors see although it is not published (e.g. in the trash – Divi renders
|
||||||
* every attached, enabled template, B8), so it can always be switched off; any other unpublished template
|
* every attached, enabled template, B8), so it can always be switched off; any other unpublished template
|
||||||
* still reads as missing.
|
* still reads as missing. The off-switch also works while the Theme Builder has unsaved changes (ruling Q20).
|
||||||
*
|
*
|
||||||
* @param array<string, mixed> $input template_id, template_rev
|
* @param array<string, mixed> $input template_id, template_rev
|
||||||
*/
|
*/
|
||||||
@@ -177,10 +177,27 @@ class ThemeBuilderAccess
|
|||||||
|
|
||||||
$gate = $this->writableGate();
|
$gate = $this->writableGate();
|
||||||
|
|
||||||
return $gate ?? $this->checkedTemplate($id, $input);
|
return $gate ?? $this->checkedTemplate($id, $input, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->checkedTemplate((int) $post->ID, $input);
|
return $this->checkedTemplate((int) $post->ID, $input, $offSwitch);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The templates of the live Theme Builder post split by canEditTemplate() (ruling Q21 – one place for
|
||||||
|
* tb-list and the dry runs of tb-assign/tb-activate): only "templates" may be shown; "hidden" serves counts
|
||||||
|
* ("weitere aktive Vorlagen ohne Berechtigung"), never its IDs, titles or conditions (Q8/B11, P12).
|
||||||
|
*
|
||||||
|
* @return array{templates: list<Template>, hidden: list<Template>}
|
||||||
|
*/
|
||||||
|
public function editableLiveTemplates(): array
|
||||||
|
{
|
||||||
|
$split = ['templates' => [], 'hidden' => []];
|
||||||
|
foreach ($this->store->liveTemplates() as $template) {
|
||||||
|
$split[$this->canEditTemplate($template->id) ? 'templates' : 'hidden'][] = $template;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $split;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -297,12 +314,14 @@ class ThemeBuilderAccess
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The draft, default and template_rev checks of changeableTemplate() for template $id (rights already checked).
|
* The draft, default and template_rev checks of changeableTemplate() for template $id (rights already checked).
|
||||||
|
* The off-switch skips the draft refusal (ruling Q20): hiding a template must always be possible; the caller
|
||||||
|
* warns that saving an open Theme Builder editor may restore its own state.
|
||||||
*
|
*
|
||||||
* @param array<string, mixed> $input
|
* @param array<string, mixed> $input
|
||||||
*/
|
*/
|
||||||
private function checkedTemplate(int $id, array $input): Template|WP_Error
|
private function checkedTemplate(int $id, array $input, bool $offSwitch): Template|WP_Error
|
||||||
{
|
{
|
||||||
$refusal = $this->draftRefusal();
|
$refusal = $offSwitch ? null : $this->draftRefusal();
|
||||||
if ($refusal !== null) {
|
if ($refusal !== null) {
|
||||||
return $refusal;
|
return $refusal;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ final class ThemeBuilderFixtures
|
|||||||
$store->allows('draftId')->andReturn($draftId);
|
$store->allows('draftId')->andReturn($draftId);
|
||||||
$store->allows('liveTemplates')->andReturn($templates);
|
$store->allows('liveTemplates')->andReturn($templates);
|
||||||
$store->allows('templateIds')->andReturn(array_map(static fn (Template $t): int => $t->id, $templates));
|
$store->allows('templateIds')->andReturn(array_map(static fn (Template $t): int => $t->id, $templates));
|
||||||
$store->allows('template')->andReturnUsing(static fn (int $id): ?Template => $byId[$id] ?? null);
|
$store->allows('template')->andReturnUsing(static fn (int $id): ?Template => $byId[$id] ?? null)->byDefault();
|
||||||
$store->allows('detachedIds')->andReturn(array_map(static fn (Template $t): int => $t->id, $detached));
|
$store->allows('detachedIds')->andReturn(array_map(static fn (Template $t): int => $t->id, $detached));
|
||||||
$store->allows('flatConditions')->andReturn(self::conditions());
|
$store->allows('flatConditions')->andReturn(self::conditions());
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,26 @@ final class TbActivateAbilityTest extends TestCase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A store with $template on the live list whose read-back follows one setEnabled() call. */
|
||||||
|
private static function switchable(Template $template): DiviThemeBuilder
|
||||||
|
{
|
||||||
|
$current = $template;
|
||||||
|
$store = ThemeBuilderFixtures::store([$template]);
|
||||||
|
$store->shouldReceive('template')->andReturnUsing(static function () use (&$current): Template {
|
||||||
|
return $current;
|
||||||
|
});
|
||||||
|
$store->shouldReceive('setEnabled')->once()->with($template->id, ! $template->enabled)->andReturnUsing(
|
||||||
|
static function (int $id, bool $enabled) use (&$current, $template): void {
|
||||||
|
$current = ThemeBuilderFixtures::template($id, [
|
||||||
|
'enabled' => $enabled,
|
||||||
|
'use_on' => $template->useOn,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return $store;
|
||||||
|
}
|
||||||
|
|
||||||
/** @return array<string, mixed> */
|
/** @return array<string, mixed> */
|
||||||
private static function input(Template $template, bool $enabled, bool $confirm = false): array
|
private static function input(Template $template, bool $enabled, bool $confirm = false): array
|
||||||
{
|
{
|
||||||
@@ -91,8 +111,7 @@ final class TbActivateAbilityTest extends TestCase
|
|||||||
public function testConfirmedActivationSwitchesOnlyTheFlag(): void
|
public function testConfirmedActivationSwitchesOnlyTheFlag(): void
|
||||||
{
|
{
|
||||||
$template = ThemeBuilderFixtures::template(1066);
|
$template = ThemeBuilderFixtures::template(1066);
|
||||||
$store = ThemeBuilderFixtures::store([$template]);
|
$store = self::switchable($template);
|
||||||
$store->shouldReceive('setEnabled')->once()->with(1066, true);
|
|
||||||
$store->shouldReceive('clearCaches')->once();
|
$store->shouldReceive('clearCaches')->once();
|
||||||
$store->shouldReceive('setConditions')->never();
|
$store->shouldReceive('setConditions')->never();
|
||||||
$store->shouldReceive('attach')->never();
|
$store->shouldReceive('attach')->never();
|
||||||
@@ -112,8 +131,7 @@ final class TbActivateAbilityTest extends TestCase
|
|||||||
public function testDeactivationAlsoNeedsConfirm(): void
|
public function testDeactivationAlsoNeedsConfirm(): void
|
||||||
{
|
{
|
||||||
$template = ThemeBuilderFixtures::template(1066, ['enabled' => true]);
|
$template = ThemeBuilderFixtures::template(1066, ['enabled' => true]);
|
||||||
$store = ThemeBuilderFixtures::store([$template]);
|
$store = self::switchable($template);
|
||||||
$store->shouldReceive('setEnabled')->once()->with(1066, false);
|
|
||||||
$store->shouldReceive('clearCaches')->once();
|
$store->shouldReceive('clearCaches')->once();
|
||||||
|
|
||||||
$dry = self::ability($store)->execute(self::input($template, false));
|
$dry = self::ability($store)->execute(self::input($template, false));
|
||||||
@@ -243,4 +261,75 @@ final class TbActivateAbilityTest extends TestCase
|
|||||||
}
|
}
|
||||||
$log->assertPartials(['ability' => 'lit/tb-activate', 'object_type' => 'et_template', 'object_id' => '1066']);
|
$log->assertPartials(['ability' => 'lit/tb-activate', 'object_type' => 'et_template', 'object_id' => '1066']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fix round 1: live is what the read-back shows, not what was asked for. */
|
||||||
|
public function testLiveComesFromTheReadBack(): void
|
||||||
|
{
|
||||||
|
$template = ThemeBuilderFixtures::template(1066);
|
||||||
|
$store = ThemeBuilderFixtures::store([$template]);
|
||||||
|
$store->shouldReceive('setEnabled')->once()->with(1066, true);
|
||||||
|
$store->shouldReceive('clearCaches')->once();
|
||||||
|
|
||||||
|
$result = self::ability($store)->execute(self::input($template, true, true));
|
||||||
|
|
||||||
|
self::assertTrue($result['enabled']);
|
||||||
|
self::assertFalse($result['live'], 'the read-back still shows the template disabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ruling Q21: live templates the user may not edit appear in the dry run only as a number. */
|
||||||
|
public function testDryRunHidesLiveTemplatesTheUserMayNotEdit(): void
|
||||||
|
{
|
||||||
|
Functions\when('current_user_can')->alias(
|
||||||
|
static fn (string $cap, mixed ...$args): bool => ! ($cap === 'edit_post' && ($args[0] ?? 0) === 1071)
|
||||||
|
);
|
||||||
|
$template = ThemeBuilderFixtures::template(1066, ['use_on' => ['singular:post_type:page:all']]);
|
||||||
|
$mine = ThemeBuilderFixtures::template(1070, ['enabled' => true, 'title' => 'Meine', 'use_on' => ['homepage']]);
|
||||||
|
$secret = ThemeBuilderFixtures::template(1071, [
|
||||||
|
'enabled' => true,
|
||||||
|
'title' => 'Geheim',
|
||||||
|
'use_on' => ['singular:post_type:page:all'],
|
||||||
|
]);
|
||||||
|
$store = ThemeBuilderFixtures::store([$template, $mine, $secret]);
|
||||||
|
$store->shouldReceive('setEnabled')->never();
|
||||||
|
|
||||||
|
$dry = self::ability($store, FakeChangeLog::forbidden())->execute(self::input($template, true));
|
||||||
|
|
||||||
|
self::assertToolError($dry, 'confirm_required');
|
||||||
|
self::assertSame([], $dry->get_error_data()['overlaps']);
|
||||||
|
self::assertSame([1070], array_column($dry->get_error_data()['possible_overlaps'], 'id'));
|
||||||
|
self::assertSame(1, $dry->get_error_data()['hidden_overlaps']);
|
||||||
|
self::assertStringContainsString('ohne Berechtigung, die sich überschneiden können: 1', self::clientError($dry)['message']);
|
||||||
|
$all = (string) json_encode([$dry->get_error_data(), self::clientError($dry)], JSON_UNESCAPED_UNICODE);
|
||||||
|
self::assertStringNotContainsString('1071', $all);
|
||||||
|
self::assertStringNotContainsString('Geheim', $all);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ruling Q20: switching off works while the Theme Builder has unsaved changes – with a warning. */
|
||||||
|
public function testTheOffSwitchWorksWithADraftAndWarns(): void
|
||||||
|
{
|
||||||
|
$template = ThemeBuilderFixtures::template(1066, ['enabled' => true]);
|
||||||
|
$store = ThemeBuilderFixtures::store([$template], 999);
|
||||||
|
$store->shouldReceive('setEnabled')->once()->with(1066, false);
|
||||||
|
$store->shouldReceive('clearCaches')->once();
|
||||||
|
|
||||||
|
self::assertTrue(self::ability($store)->permission(self::input($template, false)));
|
||||||
|
$dry = self::ability($store)->execute(self::input($template, false));
|
||||||
|
self::assertToolError($dry, 'confirm_required');
|
||||||
|
self::assertStringContainsString('ungespeicherte Änderungen', self::clientError($dry)['message']);
|
||||||
|
|
||||||
|
$result = self::ability($store)->execute(self::input($template, false, true));
|
||||||
|
self::assertTrue($result['ok']);
|
||||||
|
self::assertStringContainsString('ungespeicherte Änderungen', implode(' ', $result['warnings']));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ruling Q20: the draft exemption is for switching off only. */
|
||||||
|
public function testTheOnSwitchIsRefusedWithADraft(): void
|
||||||
|
{
|
||||||
|
$template = ThemeBuilderFixtures::template(1066);
|
||||||
|
$store = ThemeBuilderFixtures::store([$template], 999);
|
||||||
|
$store->shouldReceive('setEnabled')->never();
|
||||||
|
|
||||||
|
self::assertToolError(self::ability($store)->permission(self::input($template, true)), 'unsupported');
|
||||||
|
self::assertToolError(self::ability($store)->execute(self::input($template, true, true)), 'unsupported');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,6 +176,8 @@ final class TbAssignAbilityTest extends TestCase
|
|||||||
self::assertTrue($result['ok']);
|
self::assertTrue($result['ok']);
|
||||||
self::assertSame([1066, 1061, 1062, 1063], $unmarked);
|
self::assertSame([1066, 1061, 1062, 1063], $unmarked);
|
||||||
self::assertStringContainsString('wieder zugeordnet', $log->summaries()[0]);
|
self::assertStringContainsString('wieder zugeordnet', $log->summaries()[0]);
|
||||||
|
self::assertStringContainsString('vorher aktiviert', $log->summaries()[0]);
|
||||||
|
self::assertTrue($result['previous']['enabled']);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ruling Q8/B10: a layout Divi trashed meanwhile blocks the reattach; nothing is written. */
|
/** Ruling Q8/B10: a layout Divi trashed meanwhile blocks the reattach; nothing is written. */
|
||||||
@@ -306,4 +308,50 @@ final class TbAssignAbilityTest extends TestCase
|
|||||||
self::assertToolError(self::ability($store)->permission(self::input($template, ['template_rev' => 'x'])), 'rev_mismatch');
|
self::assertToolError(self::ability($store)->permission(self::input($template, ['template_rev' => 'x'])), 'rev_mismatch');
|
||||||
self::assertToolError(self::ability($store)->permission(self::input($template, ['template_id' => 4711])), 'not_found');
|
self::assertToolError(self::ability($store)->permission(self::input($template, ['template_id' => 4711])), 'not_found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ruling Q21: live templates the user may not edit appear only as a number in the dry run. */
|
||||||
|
public function testDryRunHidesLiveTemplatesTheUserMayNotEdit(): void
|
||||||
|
{
|
||||||
|
Functions\when('current_user_can')->alias(
|
||||||
|
static fn (string $cap, mixed ...$args): bool => ! ($cap === 'edit_post' && in_array($args[0] ?? 0, [1071, 1072], true))
|
||||||
|
);
|
||||||
|
$template = ThemeBuilderFixtures::template(1066, ['enabled' => true]);
|
||||||
|
$mine = ThemeBuilderFixtures::template(1070, ['enabled' => true, 'title' => 'Meine', 'use_on' => [self::PERSONS]]);
|
||||||
|
$secret = ThemeBuilderFixtures::template(1071, ['enabled' => true, 'title' => 'Geheim', 'use_on' => [self::PERSONS]]);
|
||||||
|
$unrelated = ThemeBuilderFixtures::template(1072, ['enabled' => true, 'title' => 'Geheim 2', 'use_on' => ['404']]);
|
||||||
|
$store = ThemeBuilderFixtures::store([$template, $mine, $secret, $unrelated]);
|
||||||
|
$store->shouldReceive('setConditions')->never();
|
||||||
|
|
||||||
|
$dry = self::ability($store)->execute(self::input($template));
|
||||||
|
|
||||||
|
self::assertToolError($dry, 'confirm_required');
|
||||||
|
self::assertSame([1070], array_column($dry->get_error_data()['overlaps'], 'id'));
|
||||||
|
self::assertSame(1, $dry->get_error_data()['hidden_overlaps']);
|
||||||
|
self::assertStringContainsString('ohne Berechtigung, die sich überschneiden können: 1', self::clientError($dry)['message']);
|
||||||
|
$all = (string) json_encode([$dry->get_error_data(), self::clientError($dry)], JSON_UNESCAPED_UNICODE);
|
||||||
|
self::assertStringNotContainsString('1071', $all);
|
||||||
|
self::assertStringNotContainsString('Geheim', $all);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ruling Q20: the draft exemption is for the off-switch only – tb-assign stays refused. */
|
||||||
|
public function testRefusedWhileTheThemeBuilderHasADraft(): void
|
||||||
|
{
|
||||||
|
$template = ThemeBuilderFixtures::template(1066, ['enabled' => true]);
|
||||||
|
$store = ThemeBuilderFixtures::store([$template], 999);
|
||||||
|
$store->shouldReceive('setConditions')->never();
|
||||||
|
|
||||||
|
self::assertToolError(self::ability($store)->execute(self::input($template, ['confirm' => true])), 'unsupported');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExcludeFromMustBeAList(): void
|
||||||
|
{
|
||||||
|
$template = ThemeBuilderFixtures::template(1066, ['exclude_from' => ['homepage']]);
|
||||||
|
$store = ThemeBuilderFixtures::store([$template]);
|
||||||
|
$store->shouldReceive('setConditions')->never();
|
||||||
|
|
||||||
|
$refused = self::ability($store)->execute(self::input($template, ['exclude_from' => null]));
|
||||||
|
|
||||||
|
self::assertToolError($refused, 'validation_failed');
|
||||||
|
self::assertArrayHasKey('exclude_from', self::errorFields($refused));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,6 +144,26 @@ final class TbListAbilityTest extends TestCase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ruling Q21: active templates the user may not edit only as a number, never by ID or title. */
|
||||||
|
public function testCountsActiveTemplatesTheUserCannotEdit(): void
|
||||||
|
{
|
||||||
|
self::denyEditing([1070, 1071]);
|
||||||
|
$store = ThemeBuilderFixtures::store([
|
||||||
|
ThemeBuilderFixtures::template(1066),
|
||||||
|
ThemeBuilderFixtures::template(1070, ['enabled' => true, 'title' => 'Geheim']),
|
||||||
|
ThemeBuilderFixtures::template(1071, ['title' => 'Geheim aus']),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = self::ability($store)->execute([]);
|
||||||
|
|
||||||
|
self::assertSame([1066], array_column($result['templates'], 'id'));
|
||||||
|
self::assertSame(1, $result['hidden_live']);
|
||||||
|
self::assertStringContainsString('Weitere aktive Vorlagen ohne Berechtigung: 1', implode(' ', $result['notes']));
|
||||||
|
$encoded = (string) json_encode($result, JSON_UNESCAPED_UNICODE);
|
||||||
|
self::assertStringNotContainsString('1070', $encoded);
|
||||||
|
self::assertStringNotContainsString('Geheim', $encoded);
|
||||||
|
}
|
||||||
|
|
||||||
/** Ruling Q8/B11: layout titles only for layouts the user may edit. */
|
/** Ruling Q8/B11: layout titles only for layouts the user may edit. */
|
||||||
public function testLayoutTitlesOnlyForEditableLayouts(): void
|
public function testLayoutTitlesOnlyForEditableLayouts(): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -129,4 +129,39 @@ final class TemplateTest extends TestCase
|
|||||||
'a static front page is a page'
|
'a static front page is a page'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fix round 1: a condition the template shares exactly is in overlaps() only, never twice. */
|
||||||
|
public function testPossibleOverlapsSkipIdenticalConditions(): void
|
||||||
|
{
|
||||||
|
$other = ThemeBuilderFixtures::template(1070, [
|
||||||
|
'enabled' => true,
|
||||||
|
'title' => 'Seiten',
|
||||||
|
'use_on' => ['homepage', 'singular:post_type:page:all'],
|
||||||
|
]);
|
||||||
|
$useOn = ['homepage', 'singular:post_type:page:id:1053'];
|
||||||
|
|
||||||
|
self::assertSame(['homepage'], Template::overlaps([$other], 1066, $useOn)[0]['conditions']);
|
||||||
|
self::assertSame(['singular:post_type:page:all'], Template::possibleOverlaps([$other], 1066, $useOn)[0]['conditions']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ruling Q21: overlapping templates the user may not edit only as a number. */
|
||||||
|
public function testOverlapDetailsCountHiddenTemplatesOnly(): void
|
||||||
|
{
|
||||||
|
$mine = ThemeBuilderFixtures::template(1070, ['enabled' => true, 'title' => 'Meine', 'use_on' => ['search']]);
|
||||||
|
$hiddenExact = ThemeBuilderFixtures::template(1071, ['enabled' => true, 'use_on' => ['search', 'homepage']]);
|
||||||
|
$hiddenPossible = ThemeBuilderFixtures::template(1072, ['enabled' => true, 'use_on' => ['singular:post_type:page:all']]);
|
||||||
|
$hiddenOther = ThemeBuilderFixtures::template(1073, ['enabled' => true, 'use_on' => ['404']]);
|
||||||
|
|
||||||
|
$details = Template::overlapDetails(
|
||||||
|
['templates' => [$mine], 'hidden' => [$hiddenExact, $hiddenPossible, $hiddenOther]],
|
||||||
|
1066,
|
||||||
|
['search', 'homepage']
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame([['id' => 1070, 'title' => 'Meine', 'conditions' => ['search']]], $details['overlaps']);
|
||||||
|
self::assertSame([], $details['possible_overlaps']);
|
||||||
|
self::assertSame(2, $details['hidden_overlaps']);
|
||||||
|
self::assertStringContainsString('ohne Berechtigung', Template::hiddenOverlapsNote(2));
|
||||||
|
self::assertSame('', Template::hiddenOverlapsNote(0));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -241,4 +241,31 @@ final class ThemeBuilderAccessTest extends TestCase
|
|||||||
'rev_mismatch'
|
'rev_mismatch'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ruling Q21: one split of the live list by canEditTemplate(). */
|
||||||
|
public function testEditableLiveTemplatesSplitsByTheRightToEdit(): void
|
||||||
|
{
|
||||||
|
LibraryFixtures::given([]);
|
||||||
|
Functions\when('current_user_can')->alias(
|
||||||
|
static fn (string $cap, mixed ...$args): bool => ! ($cap === 'edit_post' && ($args[0] ?? 0) === 1070)
|
||||||
|
);
|
||||||
|
$mine = ThemeBuilderFixtures::template(1066);
|
||||||
|
$other = ThemeBuilderFixtures::template(1070);
|
||||||
|
$split = ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store([$mine, $other]))->editableLiveTemplates();
|
||||||
|
|
||||||
|
self::assertSame([$mine], $split['templates']);
|
||||||
|
self::assertSame([$other], $split['hidden']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ruling Q20: the off-switch works while the Theme Builder has unsaved changes; nothing else does. */
|
||||||
|
public function testTheOffSwitchIgnoresTheThemeBuilderDraft(): void
|
||||||
|
{
|
||||||
|
LibraryFixtures::given([ThemeBuilderFixtures::templatePost(1066)]);
|
||||||
|
$template = ThemeBuilderFixtures::template(1066, ['enabled' => true]);
|
||||||
|
$access = ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store([$template], 999));
|
||||||
|
$input = ['template_id' => 1066, 'template_rev' => $template->rev()];
|
||||||
|
|
||||||
|
self::assertSame($template, $access->changeableTemplate($input, true));
|
||||||
|
self::assertToolError($access->changeableTemplate($input), 'unsupported');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user