From da9284ccbeb97c676572f44fe083751ba298db44 Mon Sep 17 00:00:00 2001 From: Thorsten Marx Date: Tue, 28 Jul 2026 15:37:14 +0200 Subject: [PATCH 01/16] support for simple variations --- .../com/condation/cms/api/db/ContentNode.java | 3 +- .../cms/content/ContentResolver.java | 7 +- .../cms/content/DefaultContentRenderer.java | 1 - .../cms/content/VariantResolver.java | 87 +++++++++++++++++++ .../content/.variants/about/summer/about.md | 21 +++++ 5 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 cms-content/src/main/java/com/condation/cms/content/VariantResolver.java create mode 100644 test-server/hosts/demo/content/.variants/about/summer/about.md diff --git a/cms-api/src/main/java/com/condation/cms/api/db/ContentNode.java b/cms-api/src/main/java/com/condation/cms/api/db/ContentNode.java index a1209d64e..958c41fe4 100644 --- a/cms-api/src/main/java/com/condation/cms/api/db/ContentNode.java +++ b/cms-api/src/main/java/com/condation/cms/api/db/ContentNode.java @@ -26,9 +26,8 @@ import com.condation.cms.api.request.RequestContext; import com.condation.cms.api.request.RequestContextScope; import com.condation.cms.api.utils.MapUtil; -import com.condation.cms.api.utils.PathUtil; import com.condation.cms.api.utils.SectionUtil; -import com.google.common.math.DoubleMath; +import com.condation.cms.api.workflow.WFStatusProvider; import java.io.Serializable; import java.time.LocalDate; import java.util.HashMap; diff --git a/cms-content/src/main/java/com/condation/cms/content/ContentResolver.java b/cms-content/src/main/java/com/condation/cms/content/ContentResolver.java index 850317009..1dfd82375 100644 --- a/cms-content/src/main/java/com/condation/cms/content/ContentResolver.java +++ b/cms-content/src/main/java/com/condation/cms/content/ContentResolver.java @@ -32,6 +32,7 @@ import com.condation.cms.api.request.RequestContext; import com.condation.cms.api.utils.HTTPUtil; import com.condation.cms.core.content.ContentResolvingStrategy; +import com.condation.cms.filesystem.FileSystem; import java.io.IOException; import java.util.List; import java.util.Map; @@ -49,7 +50,7 @@ public class ContentResolver { private final ContentRenderer contentRenderer; - private final DB db; + private final DB db; public Optional getContent (final RequestContext context) { return getContent(context, true); @@ -82,6 +83,10 @@ private Optional getContent(final RequestContext context, boole if (contentNode == null) { return Optional.empty(); } + + VariantResolver variantResolver = new VariantResolver(db); + + var variants = variantResolver.getVariants(contentNode); var contentFile = db.getFileSystem().contentBase().resolve(contentNode.path()); diff --git a/cms-content/src/main/java/com/condation/cms/content/DefaultContentRenderer.java b/cms-content/src/main/java/com/condation/cms/content/DefaultContentRenderer.java index 35bc3b4af..c82b346f4 100644 --- a/cms-content/src/main/java/com/condation/cms/content/DefaultContentRenderer.java +++ b/cms-content/src/main/java/com/condation/cms/content/DefaultContentRenderer.java @@ -54,7 +54,6 @@ import com.condation.cms.hooksystem.extensions.TemplateHooks; import com.condation.cms.content.template.functions.LinkFunction; import com.condation.cms.content.template.functions.MarkdownFunction; -import com.condation.cms.content.template.functions.hooks.HooksTemlateFunction; import com.condation.cms.content.template.functions.list.NodeListFunctionBuilder; import com.condation.cms.content.template.functions.navigation.NavigationFunction; import com.condation.cms.content.template.functions.query.QueryFunction; diff --git a/cms-content/src/main/java/com/condation/cms/content/VariantResolver.java b/cms-content/src/main/java/com/condation/cms/content/VariantResolver.java new file mode 100644 index 000000000..ae9aa84d1 --- /dev/null +++ b/cms-content/src/main/java/com/condation/cms/content/VariantResolver.java @@ -0,0 +1,87 @@ +package com.condation.cms.content; + +/*- + * #%L + * CMS Content + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import com.condation.cms.api.db.ContentNode; +import com.condation.cms.api.db.DB; +import com.condation.cms.api.db.DBFileSystem; +import com.condation.cms.api.db.cms.ReadOnlyFile; +import com.condation.cms.api.utils.PathUtil; +import com.condation.cms.filesystem.FileSystem; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * + * @author thorstenmarx + */ +@Slf4j +@RequiredArgsConstructor +public class VariantResolver { + + private final DB db; + + public Optional loadVariant (ContentNode node, String variantId) { + return getVariants(node).stream().filter(variant -> variant.id().equals(variantId)).findFirst(); + } + + public List getVariants (ContentNode node) { + + var folder = db.getFileSystem().contentBase().resolve(node.path()).getParent(); + var variantsFolder = folder.resolve(".variants/" + removeMarkdown(node.name())); + + if (variantsFolder.exists()) { + try { + List variants = new ArrayList<>(); + variantsFolder.children().stream().filter(ReadOnlyFile::isDirectory).forEach(variant -> { + var id = variant.getFileName(); + var contentFile = variant.resolve(node.name()); + var url = PathUtil.toURL(contentFile, db.getFileSystem().contentBase()); + var variantNode = db.getContent().byUrl(url); + if (variantNode.isPresent()) { + variants.add(new Variant(id, variantNode.get())); + } + }); + + return variants; + } catch (IOException ex) { + log.error("error loading variants", ex); + } + } + + return Collections.emptyList(); + } + + private String removeMarkdown (String filename) { + if (filename.endsWith(".md")) { + return filename.substring(0, filename.lastIndexOf(".md")); + } + return filename; + } + + public record Variant (String id, ContentNode node) {} +} diff --git a/test-server/hosts/demo/content/.variants/about/summer/about.md b/test-server/hosts/demo/content/.variants/about/summer/about.md new file mode 100644 index 000000000..d9079c956 --- /dev/null +++ b/test-server/hosts/demo/content/.variants/about/summer/about.md @@ -0,0 +1,21 @@ +--- +title: 'About summer campaign' +template: default.html +parent: + text: testsdf +count: 46 +description: desc +features: [ + ] +background_color: '#000000' +range_test: 0 +unpublish_date: null +status: published +publish_date: null +translations: + de: /ueber +--- + +# About summer campaign + +back to [index](/) From ee209374ba7e2d4fef614364e94eee53c6bff424 Mon Sep 17 00:00:00 2001 From: Thorsten Marx Date: Tue, 28 Jul 2026 21:24:20 +0200 Subject: [PATCH 02/16] select variant time based --- .../cms/content/ContentResolver.java | 51 +++- .../cms/content/DefaultVariantSelector.java | 136 ++++++++++ .../cms/content/VariantResolver.java | 2 - .../cms/content/VariantSelection.java | 53 ++++ .../cms/content/VariantSelector.java | 40 +++ .../content/DefaultVariantSelectorTest.java | 232 ++++++++++++++++++ .../cms/server/configs/SiteModule.java | 19 +- .../content/.variants/test/summer/test.md | 7 + .../cms/content/ContentResolverTest.java | 21 ++ .../content/.variants/about/summer/about.md | 2 +- test-server/hosts/demo/content/about.md | 2 - 11 files changed, 545 insertions(+), 20 deletions(-) create mode 100644 cms-content/src/main/java/com/condation/cms/content/DefaultVariantSelector.java create mode 100644 cms-content/src/main/java/com/condation/cms/content/VariantSelection.java create mode 100644 cms-content/src/main/java/com/condation/cms/content/VariantSelector.java create mode 100644 cms-content/src/test/java/com/condation/cms/content/DefaultVariantSelectorTest.java create mode 100644 integration-tests/hosts/test/content/.variants/test/summer/test.md diff --git a/cms-content/src/main/java/com/condation/cms/content/ContentResolver.java b/cms-content/src/main/java/com/condation/cms/content/ContentResolver.java index 1dfd82375..10d3ab430 100644 --- a/cms-content/src/main/java/com/condation/cms/content/ContentResolver.java +++ b/cms-content/src/main/java/com/condation/cms/content/ContentResolver.java @@ -27,30 +27,48 @@ import com.condation.cms.api.content.RedirectContentResponse; import com.condation.cms.api.db.ContentNode; import com.condation.cms.api.db.DB; +import com.condation.cms.api.db.NodeVisibility; import com.condation.cms.api.feature.features.CurrentNodeFeature; import com.condation.cms.api.feature.features.RequestFeature; import com.condation.cms.api.request.RequestContext; import com.condation.cms.api.utils.HTTPUtil; import com.condation.cms.core.content.ContentResolvingStrategy; -import com.condation.cms.filesystem.FileSystem; import java.io.IOException; import java.util.List; import java.util.Map; import java.util.Optional; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; /** * * @author t.marx */ -@RequiredArgsConstructor @Slf4j public class ContentResolver { private final ContentRenderer contentRenderer; - private final DB db; + private final DB db; + + private final VariantResolver variantResolver; + + private final VariantSelector variantSelector; + + public ContentResolver(ContentRenderer contentRenderer, DB db) { + this(contentRenderer, db, new VariantResolver(db), new DefaultVariantSelector()); + } + + public ContentResolver( + ContentRenderer contentRenderer, + DB db, + VariantResolver variantResolver, + VariantSelector variantSelector + ) { + this.contentRenderer = contentRenderer; + this.db = db; + this.variantResolver = variantResolver; + this.variantSelector = variantSelector; + } public Optional getContent (final RequestContext context) { return getContent(context, true); @@ -84,12 +102,6 @@ private Optional getContent(final RequestContext context, boole return Optional.empty(); } - VariantResolver variantResolver = new VariantResolver(db); - - var variants = variantResolver.getVariants(contentNode); - - var contentFile = db.getFileSystem().contentBase().resolve(contentNode.path()); - if (checkVisibility && !db.getContent().isVisible(contentNode)) { return Optional.empty(); } @@ -106,7 +118,20 @@ private Optional getContent(final RequestContext context, boole } else if (!Constants.NodeType.PAGE.equals(contentNode.nodeType())) { return Optional.empty(); } - context.add(CurrentNodeFeature.class, new CurrentNodeFeature(contentNode)); + + var selection = variantSelector.select( + contentNode, + variantResolver.getVariants(contentNode), + context + ); + var selectedNode = selection.variant() + .map(VariantResolver.Variant::node) + .filter(node -> !checkVisibility || NodeVisibility.isVisible(node)) + .orElse(contentNode); + + var contentFile = db.getFileSystem().contentBase().resolve(selectedNode.path()); + + context.add(CurrentNodeFeature.class, new CurrentNodeFeature(selectedNode)); try { @@ -116,9 +141,9 @@ private Optional getContent(final RequestContext context, boole var content = contentRenderer.render(contentFile, context, renderedSectionEntries); - var contentType = contentNode.contentType(); + var contentType = selectedNode.contentType(); - return Optional.of(new DefaultContentResponse(content, contentType, contentNode)); + return Optional.of(new DefaultContentResponse(content, contentType, selectedNode)); } catch (IOException ex) { log.error(null, ex); return Optional.empty(); diff --git a/cms-content/src/main/java/com/condation/cms/content/DefaultVariantSelector.java b/cms-content/src/main/java/com/condation/cms/content/DefaultVariantSelector.java new file mode 100644 index 000000000..d653da2c6 --- /dev/null +++ b/cms-content/src/main/java/com/condation/cms/content/DefaultVariantSelector.java @@ -0,0 +1,136 @@ +package com.condation.cms.content; + +/*- + * #%L + * CMS Content + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import com.condation.cms.api.db.ContentNode; +import com.condation.cms.api.feature.features.IsPreviewFeature; +import com.condation.cms.api.feature.features.RequestFeature; +import com.condation.cms.api.feature.features.WorkflowFeature; +import com.condation.cms.api.request.RequestContext; +import com.condation.cms.api.workflow.DefaultWFStatusProvider; +import com.condation.cms.api.workflow.WFStatusProvider; +import java.util.Comparator; +import java.util.List; +import lombok.extern.slf4j.Slf4j; + +/** + * Default variant selection with support for preview overrides. + * + * @author thorstenmarx + */ +@Slf4j +public class DefaultVariantSelector implements VariantSelector { + + public static final String VARIANT_QUERY_PARAMETER = "variant"; + public static final String CANONICAL_VARIANT_ID = "default"; + + @Override + public VariantSelection select( + ContentNode canonicalNode, + List variants, + RequestContext context + ) { + if (isManager(context)) { + return VariantSelection.canonical(); + } + + if (isPreview(context) && context.has(RequestFeature.class)) { + var request = context.get(RequestFeature.class); + if (request.hasQueryParameter(VARIANT_QUERY_PARAMETER)) { + return selectPreviewVariant(canonicalNode, variants, request); + } + } + + return selectAutomatically(canonicalNode, variants, context); + } + + private boolean isPreview (RequestContext context) { + return context.has(IsPreviewFeature.class) + && IsPreviewFeature.Mode.PREVIEW.equals(context.get(IsPreviewFeature.class).mode()); + } + + private boolean isManager(RequestContext context) { + return context.has(IsPreviewFeature.class) + && IsPreviewFeature.Mode.MANAGER.equals(context.get(IsPreviewFeature.class).mode()); + } + + protected VariantSelection selectAutomatically( + ContentNode canonicalNode, + List variants, + RequestContext context + ) { + var statusProvider = getStatusProvider(context); + + return variants.stream() + .map(variant -> new ScheduledVariant( + variant, + statusProvider.status(variant.node()) + )) + .filter(variant -> variant.status().published()) + .filter(variant -> variant.status().withinSchedule()) + .max(Comparator.comparing( + variant -> variant.status().publish_date() + )) + .map(ScheduledVariant::variant) + .map(VariantSelection::automatic) + .orElseGet(VariantSelection::canonical); + } + + private WFStatusProvider getStatusProvider(RequestContext context) { + if (context.has(WorkflowFeature.class)) { + return context.get(WorkflowFeature.class) + .workflow() + .getStatusProvider(); + } + return new DefaultWFStatusProvider(); + } + + private VariantSelection selectPreviewVariant( + ContentNode canonicalNode, + List variants, + RequestFeature request + ) { + var variantId = request.getQueryParameter(VARIANT_QUERY_PARAMETER, "").trim(); + if (variantId.isBlank() || CANONICAL_VARIANT_ID.equalsIgnoreCase(variantId)) { + return VariantSelection.canonical(); + } + + return variants.stream() + .filter(variant -> variant.id().equals(variantId)) + .findFirst() + .map(VariantSelection::preview) + .orElseGet(() -> { + log.warn( + "Requested preview variant '{}' does not exist for '{}'", + variantId, + canonicalNode.path() + ); + return VariantSelection.canonical(); + }); + } + + private record ScheduledVariant( + VariantResolver.Variant variant, + WFStatusProvider.Status status + ) { + } +} diff --git a/cms-content/src/main/java/com/condation/cms/content/VariantResolver.java b/cms-content/src/main/java/com/condation/cms/content/VariantResolver.java index ae9aa84d1..b2eb43609 100644 --- a/cms-content/src/main/java/com/condation/cms/content/VariantResolver.java +++ b/cms-content/src/main/java/com/condation/cms/content/VariantResolver.java @@ -23,10 +23,8 @@ import com.condation.cms.api.db.ContentNode; import com.condation.cms.api.db.DB; -import com.condation.cms.api.db.DBFileSystem; import com.condation.cms.api.db.cms.ReadOnlyFile; import com.condation.cms.api.utils.PathUtil; -import com.condation.cms.filesystem.FileSystem; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; diff --git a/cms-content/src/main/java/com/condation/cms/content/VariantSelection.java b/cms-content/src/main/java/com/condation/cms/content/VariantSelection.java new file mode 100644 index 000000000..b231aa427 --- /dev/null +++ b/cms-content/src/main/java/com/condation/cms/content/VariantSelection.java @@ -0,0 +1,53 @@ +package com.condation.cms.content; + +/*- + * #%L + * CMS Content + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import java.util.Optional; + +/** + * Describes the result and origin of a variant selection. + * + * @author thorstenmarx + */ +public record VariantSelection( + Optional variant, + Source source +) { + + public static VariantSelection canonical() { + return new VariantSelection(Optional.empty(), Source.CANONICAL); + } + + public static VariantSelection preview(VariantResolver.Variant variant) { + return new VariantSelection(Optional.of(variant), Source.PREVIEW); + } + + public static VariantSelection automatic(VariantResolver.Variant variant) { + return new VariantSelection(Optional.of(variant), Source.AUTOMATIC); + } + + public enum Source { + CANONICAL, + PREVIEW, + AUTOMATIC + } +} diff --git a/cms-content/src/main/java/com/condation/cms/content/VariantSelector.java b/cms-content/src/main/java/com/condation/cms/content/VariantSelector.java new file mode 100644 index 000000000..1caf1f124 --- /dev/null +++ b/cms-content/src/main/java/com/condation/cms/content/VariantSelector.java @@ -0,0 +1,40 @@ +package com.condation.cms.content; + +/*- + * #%L + * CMS Content + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import com.condation.cms.api.db.ContentNode; +import com.condation.cms.api.request.RequestContext; +import java.util.List; + +/** + * Selects the content variant to render for a request. + * + * @author thorstenmarx + */ +public interface VariantSelector { + + VariantSelection select( + ContentNode canonicalNode, + List variants, + RequestContext context + ); +} diff --git a/cms-content/src/test/java/com/condation/cms/content/DefaultVariantSelectorTest.java b/cms-content/src/test/java/com/condation/cms/content/DefaultVariantSelectorTest.java new file mode 100644 index 000000000..fa70b2efa --- /dev/null +++ b/cms-content/src/test/java/com/condation/cms/content/DefaultVariantSelectorTest.java @@ -0,0 +1,232 @@ +package com.condation.cms.content; + +/*- + * #%L + * CMS Content + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import com.condation.cms.api.Constants; +import com.condation.cms.api.db.ContentNode; +import com.condation.cms.api.feature.features.IsPreviewFeature; +import com.condation.cms.api.feature.features.RequestFeature; +import com.condation.cms.api.request.RequestContext; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; + +public class DefaultVariantSelectorTest { + + private final DefaultVariantSelector selector = new DefaultVariantSelector(); + private final ContentNode canonicalNode = node("about.md"); + private final VariantResolver.Variant summer = new VariantResolver.Variant( + "summer", + node(".variants/about/summer/about.md") + ); + + @Test + public void previewCanSelectVariant() { + var selection = selector.select( + canonicalNode, + List.of(summer), + context(true, "summer") + ); + + Assertions.assertThat(selection.source()).isEqualTo(VariantSelection.Source.PREVIEW); + Assertions.assertThat(selection.variant()).contains(summer); + } + + @Test + public void previewCanForceCanonicalVariant() { + var selection = selector.select( + canonicalNode, + List.of(summer), + context(true, DefaultVariantSelector.CANONICAL_VARIANT_ID) + ); + + Assertions.assertThat(selection.source()).isEqualTo(VariantSelection.Source.CANONICAL); + Assertions.assertThat(selection.variant()).isEmpty(); + } + + @Test + public void managerAlwaysUsesCanonicalVariant() { + var active = scheduledVariant( + "active", + Instant.now().minus(Duration.ofDays(1)), + Instant.now().plus(Duration.ofDays(1)), + "published" + ); + var context = context(false, "active"); + context.add( + IsPreviewFeature.class, + new IsPreviewFeature(IsPreviewFeature.Mode.MANAGER) + ); + + var selection = selector.select( + canonicalNode, + List.of(active), + context + ); + + Assertions.assertThat(selection.source()).isEqualTo(VariantSelection.Source.CANONICAL); + Assertions.assertThat(selection.variant()).isEmpty(); + } + + @Test + public void invalidPreviewVariantFallsBackToCanonicalVariant() { + var selection = selector.select( + canonicalNode, + List.of(summer), + context(true, "missing") + ); + + Assertions.assertThat(selection.source()).isEqualTo(VariantSelection.Source.CANONICAL); + Assertions.assertThat(selection.variant()).isEmpty(); + } + + @Test + public void publicRequestCannotOverrideVariant() { + var selection = selector.select( + canonicalNode, + List.of(summer), + context(false, "summer") + ); + + Assertions.assertThat(selection.source()).isEqualTo(VariantSelection.Source.CANONICAL); + Assertions.assertThat(selection.variant()).isEmpty(); + } + + @Test + public void automaticSelectionUsesPublishedVariantWithinSchedule() { + var active = scheduledVariant( + "active", + Instant.now().minus(Duration.ofDays(1)), + Instant.now().plus(Duration.ofDays(1)), + "published" + ); + + var selection = selector.select( + canonicalNode, + List.of(active), + context(false, null) + ); + + Assertions.assertThat(selection.source()).isEqualTo(VariantSelection.Source.AUTOMATIC); + Assertions.assertThat(selection.variant()).contains(active); + } + + @Test + public void automaticSelectionIgnoresDraftAndVariantsOutsideSchedule() { + var draft = scheduledVariant( + "draft", + Instant.now().minus(Duration.ofDays(1)), + Instant.now().plus(Duration.ofDays(1)), + "draft" + ); + var future = scheduledVariant( + "future", + Instant.now().plus(Duration.ofDays(1)), + Instant.now().plus(Duration.ofDays(2)), + "published" + ); + var expired = scheduledVariant( + "expired", + Instant.now().minus(Duration.ofDays(2)), + Instant.now().minus(Duration.ofDays(1)), + "published" + ); + + var selection = selector.select( + canonicalNode, + List.of(draft, future, expired), + context(false, null) + ); + + Assertions.assertThat(selection.source()).isEqualTo(VariantSelection.Source.CANONICAL); + Assertions.assertThat(selection.variant()).isEmpty(); + } + + @Test + public void newestActiveVariantWins() { + var older = scheduledVariant( + "older", + Instant.now().minus(Duration.ofDays(2)), + Instant.now().plus(Duration.ofDays(1)), + "published" + ); + var newer = scheduledVariant( + "newer", + Instant.now().minus(Duration.ofDays(1)), + Instant.now().plus(Duration.ofDays(1)), + "published" + ); + + var selection = selector.select( + canonicalNode, + List.of(newer, older), + context(false, null) + ); + + Assertions.assertThat(selection.variant()).contains(newer); + } + + private RequestContext context(boolean preview, String variantId) { + var context = new RequestContext(); + var queryParameters = variantId == null + ? Map.>of() + : Map.of("variant", List.of(variantId)); + context.add( + RequestFeature.class, + new RequestFeature("", "/", queryParameters, null) + ); + if (preview) { + context.add(IsPreviewFeature.class, new IsPreviewFeature()); + } + return context; + } + + private ContentNode node(String path) { + return new ContentNode(path, "/" + path, path, Map.of()); + } + + private VariantResolver.Variant scheduledVariant( + String id, + Instant publishDate, + Instant unpublishDate, + String status + ) { + Map data = new HashMap<>(); + data.put(Constants.MetaFields.STATUS, status); + data.put(Constants.MetaFields.PUBLISH_DATE, Date.from(publishDate)); + data.put(Constants.MetaFields.UNPUBLISH_DATE, Date.from(unpublishDate)); + return new VariantResolver.Variant( + id, + new ContentNode( + ".variants/about/" + id + "/about.md", + "/.variants/about/" + id + "/about", + "about.md", + data + ) + ); + } +} diff --git a/cms-server/src/main/java/com/condation/cms/server/configs/SiteModule.java b/cms-server/src/main/java/com/condation/cms/server/configs/SiteModule.java index f43d3752e..e6671f7a5 100644 --- a/cms-server/src/main/java/com/condation/cms/server/configs/SiteModule.java +++ b/cms-server/src/main/java/com/condation/cms/server/configs/SiteModule.java @@ -61,7 +61,10 @@ import com.condation.cms.content.ContentResolver; import com.condation.cms.content.DefaultContentParser; import com.condation.cms.content.DefaultContentRenderer; +import com.condation.cms.content.DefaultVariantSelector; import com.condation.cms.content.TaxonomyResolver; +import com.condation.cms.content.VariantResolver; +import com.condation.cms.content.VariantSelector; import com.condation.cms.content.ViewResolver; import com.condation.cms.content.shortcodes.ShortCodeParser; import com.condation.cms.content.template.functions.taxonomy.TaxonomyFunction; @@ -350,8 +353,20 @@ public ContentRenderer contentRenderer(ContentParser contentParser, Injector inj @Provides @Singleton public ContentResolver contentResolver(ContentRenderer contentRenderer, - FileDB db) { - return new ContentResolver(contentRenderer, db); + FileDB db, VariantResolver variantResolver, VariantSelector variantSelector) { + return new ContentResolver(contentRenderer, db, variantResolver, variantSelector); + } + + @Provides + @Singleton + public VariantResolver variantResolver(FileDB db) { + return new VariantResolver(db); + } + + @Provides + @Singleton + public VariantSelector variantSelector() { + return new DefaultVariantSelector(); } @Provides diff --git a/integration-tests/hosts/test/content/.variants/test/summer/test.md b/integration-tests/hosts/test/content/.variants/test/summer/test.md new file mode 100644 index 000000000..3b7382231 --- /dev/null +++ b/integration-tests/hosts/test/content/.variants/test/summer/test.md @@ -0,0 +1,7 @@ +--- +title: Summer variant +template: test.ftl +status: published +--- + +Summer variant content diff --git a/integration-tests/src/test/java/com/condation/cms/content/ContentResolverTest.java b/integration-tests/src/test/java/com/condation/cms/content/ContentResolverTest.java index 6cc399a20..611c7414f 100644 --- a/integration-tests/src/test/java/com/condation/cms/content/ContentResolverTest.java +++ b/integration-tests/src/test/java/com/condation/cms/content/ContentResolverTest.java @@ -32,6 +32,8 @@ import com.condation.cms.api.content.DefaultContentResponse; import com.condation.cms.api.content.RedirectContentResponse; import com.condation.cms.api.db.cms.ReadOnlyFile; +import com.condation.cms.api.feature.features.IsPreviewFeature; +import com.condation.cms.api.feature.features.RequestFeature; import com.condation.cms.api.markdown.MarkdownRenderer; import com.condation.cms.api.template.TemplateEngine; import static com.condation.cms.content.ContentRendererNGTest.contentRenderer; @@ -147,4 +149,23 @@ public void test_not_published() throws IOException { Assertions.assertThat(optional).isEmpty(); } + @Test + public void previewVariantOverride() throws IOException { + var context = TestHelper.requestContext("test"); + context.add( + RequestFeature.class, + new RequestFeature("test", Map.of("variant", java.util.List.of("summer"))) + ); + context.add(IsPreviewFeature.class, new IsPreviewFeature()); + + var optional = contentResolver.getContent(context); + + Assertions.assertThat(optional).isPresent(); + Assertions.assertThat(optional.get()).isInstanceOf(DefaultContentResponse.class); + var response = (DefaultContentResponse) optional.get(); + Assertions.assertThat(response.content()).contains("Summer variant content"); + Assertions.assertThat(response.node().path()) + .isEqualTo(".variants/test/summer/test.md"); + } + } diff --git a/test-server/hosts/demo/content/.variants/about/summer/about.md b/test-server/hosts/demo/content/.variants/about/summer/about.md index d9079c956..6ac2c3baf 100644 --- a/test-server/hosts/demo/content/.variants/about/summer/about.md +++ b/test-server/hosts/demo/content/.variants/about/summer/about.md @@ -1,5 +1,5 @@ --- -title: 'About summer campaign' +title: About summer campaign template: default.html parent: text: testsdf diff --git a/test-server/hosts/demo/content/about.md b/test-server/hosts/demo/content/about.md index 524fed68b..fcf5fad1e 100644 --- a/test-server/hosts/demo/content/about.md +++ b/test-server/hosts/demo/content/about.md @@ -9,9 +9,7 @@ features: [ ] background_color: '#000000' range_test: 0 -unpublish_date: null status: published -publish_date: null translations: de: /ueber --- From 3b77ee96ead0742ed2342284e80957a905f065aa Mon Sep 17 00:00:00 2001 From: Thorsten Marx Date: Tue, 28 Jul 2026 21:54:11 +0200 Subject: [PATCH 03/16] select dialog for variants --- .../ui/extensionpoints/MenuHookExtension.java | 1 - .../ui/extensionpoints/PageMenuExtension.java | 12 ++ .../AbstractRemoteMethodeExtension.java | 1 - .../remotemethods/RemoteVariantEndpoint.java | 86 +++++++++++ .../remotemethods/dto/VariantDto.java | 37 +++++ .../manager/actions/page/variants.d.ts | 21 +++ .../manager/actions/page/variants.js | 100 +++++++++++++ .../manager/js/modules/rpc/rpc-variant.d.ts | 36 +++++ .../manager/js/modules/rpc/rpc-variant.js | 29 ++++ .../src/main/ts/src/actions/page/variants.ts | 120 +++++++++++++++ .../main/ts/src/js/modules/rpc/rpc-variant.ts | 49 ++++++ .../RemoteVariantEndpointTest.java | 140 ++++++++++++++++++ 12 files changed, 630 insertions(+), 2 deletions(-) create mode 100644 modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpoint.java create mode 100644 modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/dto/VariantDto.java create mode 100644 modules/ui-module/src/main/resources/manager/actions/page/variants.d.ts create mode 100644 modules/ui-module/src/main/resources/manager/actions/page/variants.js create mode 100644 modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-variant.d.ts create mode 100644 modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-variant.js create mode 100644 modules/ui-module/src/main/ts/src/actions/page/variants.ts create mode 100644 modules/ui-module/src/main/ts/src/js/modules/rpc/rpc-variant.ts create mode 100644 modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpointTest.java diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/MenuHookExtension.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/MenuHookExtension.java index 66d476913..ea668206c 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/MenuHookExtension.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/MenuHookExtension.java @@ -22,7 +22,6 @@ */ import com.condation.cms.api.annotations.Action; import com.condation.cms.api.annotations.Filter; -import com.condation.cms.api.hooks.HookSystem; import com.condation.cms.api.extensions.HookSystemRegisterExtensionPoint; import com.condation.cms.api.ui.action.UIHookAction; import com.condation.cms.api.ui.action.UIScriptAction; diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/PageMenuExtension.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/PageMenuExtension.java index f3b94e3bf..beceeaa21 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/PageMenuExtension.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/PageMenuExtension.java @@ -116,6 +116,16 @@ public void manage_media() { scriptAction = @com.condation.cms.api.ui.annotations.ScriptAction(module = "/manager/actions/page/translations") ) public void manage_translations() {} + + @ShortCut( + id = "page-variants", + title = "Page variants", + permissions = {Permissions.CONTENT_EDIT}, + hotkey = "ctrl-6", + section = "Page", + scriptAction = @com.condation.cms.api.ui.annotations.ScriptAction(module = "/manager/actions/page/variants") + ) + public void page_variants() {} @Override @@ -126,6 +136,7 @@ public Map> getLocalizations() { "page-create", "Neue Seite erstellen", "page-edit-content", "Inhalt bearbeiten", "page-edit-meta", "Metadaten bearbeiten", + "page-variants", "Seitenvarianten", "language.de", "Deutsch", "language.en", "Englisch" ), @@ -134,6 +145,7 @@ public Map> getLocalizations() { "page-create", "Create new page", "page-edit-content", "Edit content", "page-edit-meta", "Edit metadata", + "page-variants", "Page variants", "language.de", "German", "language.en", "English" ) diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/AbstractRemoteMethodeExtension.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/AbstractRemoteMethodeExtension.java index a2a5143fc..4b53a4e7d 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/AbstractRemoteMethodeExtension.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/AbstractRemoteMethodeExtension.java @@ -32,7 +32,6 @@ import com.condation.cms.api.ui.extensions.UIRemoteMethodExtensionPoint; import com.condation.cms.core.serivce.ServiceRegistry; import com.condation.cms.core.serivce.impl.SiteDBService; -import com.condation.cms.filesystem.FileSystem; import com.condation.cms.modules.ui.utils.UIHooks; import java.nio.file.Path; import java.util.Map; diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpoint.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpoint.java new file mode 100644 index 000000000..c14ecf128 --- /dev/null +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpoint.java @@ -0,0 +1,86 @@ +package com.condation.cms.modules.ui.extensionpoints.remotemethods; + +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import com.condation.cms.api.auth.Permissions; +import com.condation.cms.api.db.ContentNode; +import com.condation.cms.api.db.DB; +import com.condation.cms.api.feature.features.InjectorFeature; +import com.condation.cms.api.ui.annotations.RemoteMethod; +import com.condation.cms.api.ui.extensions.UIRemoteMethodExtensionPoint; +import com.condation.cms.api.ui.rpc.RPCException; +import com.condation.cms.content.VariantResolver; +import com.condation.cms.modules.ui.extensionpoints.remotemethods.dto.VariantDto; +import com.condation.modules.api.annotation.Extension; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Remote methods for loading the variants of a content node. + * + * @author thorstenmarx + */ +@Extension(UIRemoteMethodExtensionPoint.class) +public class RemoteVariantEndpoint extends AbstractRemoteMethodeExtension { + + @RemoteMethod(name = "variants.get", permissions = {Permissions.CONTENT_EDIT}) + public Object get(Map parameters) throws RPCException { + var uri = (String) parameters.getOrDefault("uri", ""); + if (uri.isBlank()) { + throw new RPCException(400, "uri must not be blank"); + } + + var db = getDB(parameters); + var contentNode = findContentNode(db, uri); + var variants = getVariantResolver(db) + .getVariants(contentNode) + .stream() + .sorted(Comparator.comparing(VariantResolver.Variant::id)) + .map(variant -> new VariantDto( + variant.id(), + variant.node().uri(), + variant.node().url(), + variant.node().data() + )) + .toList(); + + Map result = new LinkedHashMap<>(); + result.put("uri", contentNode.uri()); + result.put("variants", variants); + return result; + } + + private ContentNode findContentNode(DB db, String uri) throws RPCException { + return db.getContent() + .byPath(uri) + .or(() -> db.getContent().byUrl(uri)) + .orElseThrow(() -> new RPCException( + 404, + "content node for uri %s not found".formatted(uri) + )); + } + + protected VariantResolver getVariantResolver(DB db) { + return getContext().get(InjectorFeature.class).injector().getInstance(VariantResolver.class); + } +} diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/dto/VariantDto.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/dto/VariantDto.java new file mode 100644 index 000000000..e082c37e5 --- /dev/null +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/dto/VariantDto.java @@ -0,0 +1,37 @@ +package com.condation.cms.modules.ui.extensionpoints.remotemethods.dto; + +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import java.util.Map; + +/** + * Variant data exposed to the manager UI. + * + * @author thorstenmarx + */ +public record VariantDto( + String id, + String uri, + String url, + Map meta +) { +} diff --git a/modules/ui-module/src/main/resources/manager/actions/page/variants.d.ts b/modules/ui-module/src/main/resources/manager/actions/page/variants.d.ts new file mode 100644 index 000000000..a83d0b4a2 --- /dev/null +++ b/modules/ui-module/src/main/resources/manager/actions/page/variants.d.ts @@ -0,0 +1,21 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ +export declare const runAction: () => Promise; diff --git a/modules/ui-module/src/main/resources/manager/actions/page/variants.js b/modules/ui-module/src/main/resources/manager/actions/page/variants.js new file mode 100644 index 000000000..50273a89b --- /dev/null +++ b/modules/ui-module/src/main/resources/manager/actions/page/variants.js @@ -0,0 +1,100 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ +import { i18n } from '@cms/modules/localization.js'; +import { openModal } from '@cms/modules/modal.js'; +import { getPreviewUrl, loadPreview } from '@cms/modules/preview.utils.js'; +import { getContentNode } from '@cms/modules/rpc/rpc-content.js'; +import { getVariants } from '@cms/modules/rpc/rpc-variant.js'; +import { showToast } from '@cms/modules/toast.js'; +const VARIANT_LIST_ID = 'cms-page-variants'; +const variantTitle = (variant) => { + const title = variant.meta?.title; + return typeof title === 'string' && title.trim() ? title : variant.id; +}; +const renderVariants = (container, variants, modal) => { + if (variants.length === 0) { + const emptyMessage = document.createElement('p'); + emptyMessage.className = 'text-body-secondary mb-0'; + emptyMessage.textContent = i18n.t('manager.actions.page.variants.empty', 'This page has no variants.'); + container.appendChild(emptyMessage); + return; + } + const list = document.createElement('div'); + list.className = 'list-group'; + variants.forEach((variant) => { + const link = document.createElement('a'); + link.href = variant.url; + link.className = 'list-group-item list-group-item-action'; + link.dataset.cmsVariantUrl = variant.url; + const heading = document.createElement('div'); + heading.className = 'd-flex w-100 justify-content-between align-items-center gap-3'; + const title = document.createElement('strong'); + title.textContent = variantTitle(variant); + heading.appendChild(title); + const id = document.createElement('span'); + id.className = 'badge text-bg-secondary'; + id.textContent = variant.id; + heading.appendChild(id); + link.appendChild(heading); + const uri = document.createElement('small'); + uri.className = 'text-body-secondary'; + uri.textContent = variant.uri; + link.appendChild(uri); + link.addEventListener('click', (event) => { + event.preventDefault(); + modal.hide(); + loadPreview(variant.url); + }); + list.appendChild(link); + }); + container.appendChild(list); +}; +export const runAction = async () => { + try { + const contentNode = await getContentNode({ + url: getPreviewUrl() + }); + const result = await getVariants({ + uri: contentNode.result.uri + }); + let modal; + modal = openModal({ + title: i18n.t('manager.actions.page.variants.title', 'Page variants'), + body: `
`, + fullscreen: false, + size: 'lg', + onCancel: () => { }, + onOk: () => { }, + onShow: (modalElement) => { + const container = modalElement.querySelector(`#${VARIANT_LIST_ID}`); + renderVariants(container, result.variants, modal); + } + }); + } + catch (error) { + showToast({ + title: i18n.t('manager.actions.page.variants.error.title', 'Could not load page variants'), + message: error instanceof Error ? error.message : String(error), + type: 'error', + timeout: 3000 + }); + } +}; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-variant.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-variant.d.ts new file mode 100644 index 000000000..d1eba69aa --- /dev/null +++ b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-variant.d.ts @@ -0,0 +1,36 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ +export interface VariantDto { + id: string; + uri: string; + url: string; + meta: Record; +} +export interface GetVariantsOptions { + uri: string; + siteId?: string; +} +export interface GetVariantsResult { + uri: string; + variants: VariantDto[]; +} +declare const getVariants: (options: GetVariantsOptions) => Promise; +export { getVariants }; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-variant.js b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-variant.js new file mode 100644 index 000000000..ed9ae2e13 --- /dev/null +++ b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-variant.js @@ -0,0 +1,29 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ +import { executeRemoteCall } from '@cms/modules/rpc/rpc.js'; +const getVariants = async (options) => { + const data = { + method: 'variants.get', + parameters: options + }; + return (await executeRemoteCall(data)).result; +}; +export { getVariants }; diff --git a/modules/ui-module/src/main/ts/src/actions/page/variants.ts b/modules/ui-module/src/main/ts/src/actions/page/variants.ts new file mode 100644 index 000000000..97cc7c9c2 --- /dev/null +++ b/modules/ui-module/src/main/ts/src/actions/page/variants.ts @@ -0,0 +1,120 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import { i18n } from '@cms/modules/localization.js' +import { openModal } from '@cms/modules/modal.js' +import { getPreviewUrl, loadPreview } from '@cms/modules/preview.utils.js' +import { getContentNode } from '@cms/modules/rpc/rpc-content.js' +import { getVariants, VariantDto } from '@cms/modules/rpc/rpc-variant.js' +import { showToast } from '@cms/modules/toast.js' + +const VARIANT_LIST_ID = 'cms-page-variants'; + +const variantTitle = (variant: VariantDto): string => { + const title = variant.meta?.title; + return typeof title === 'string' && title.trim() ? title : variant.id; +}; + +const renderVariants = (container: HTMLElement, variants: VariantDto[], modal: any) => { + if (variants.length === 0) { + const emptyMessage = document.createElement('p'); + emptyMessage.className = 'text-body-secondary mb-0'; + emptyMessage.textContent = i18n.t( + 'manager.actions.page.variants.empty', + 'This page has no variants.' + ); + container.appendChild(emptyMessage); + return; + } + + const list = document.createElement('div'); + list.className = 'list-group'; + + variants.forEach((variant) => { + const link = document.createElement('a'); + link.href = variant.url; + link.className = 'list-group-item list-group-item-action'; + link.dataset.cmsVariantUrl = variant.url; + + const heading = document.createElement('div'); + heading.className = 'd-flex w-100 justify-content-between align-items-center gap-3'; + + const title = document.createElement('strong'); + title.textContent = variantTitle(variant); + heading.appendChild(title); + + const id = document.createElement('span'); + id.className = 'badge text-bg-secondary'; + id.textContent = variant.id; + heading.appendChild(id); + link.appendChild(heading); + + const uri = document.createElement('small'); + uri.className = 'text-body-secondary'; + uri.textContent = variant.uri; + link.appendChild(uri); + + link.addEventListener('click', (event) => { + event.preventDefault(); + modal.hide(); + loadPreview(variant.url); + }); + + list.appendChild(link); + }); + + container.appendChild(list); +}; + +export const runAction = async () => { + try { + const contentNode = await getContentNode({ + url: getPreviewUrl() + }); + const result = await getVariants({ + uri: contentNode.result.uri + }); + + let modal: any; + modal = openModal({ + title: i18n.t('manager.actions.page.variants.title', 'Page variants'), + body: `
`, + fullscreen: false, + size: 'lg', + onCancel: () => { }, + onOk: () => { }, + onShow: (modalElement: HTMLElement) => { + const container = modalElement.querySelector(`#${VARIANT_LIST_ID}`) as HTMLElement; + renderVariants(container, result.variants, modal); + } + }); + } catch (error) { + showToast({ + title: i18n.t( + 'manager.actions.page.variants.error.title', + 'Could not load page variants' + ), + message: error instanceof Error ? error.message : String(error), + type: 'error', + timeout: 3000 + }); + } +}; diff --git a/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc-variant.ts b/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc-variant.ts new file mode 100644 index 000000000..fc4906ee0 --- /dev/null +++ b/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc-variant.ts @@ -0,0 +1,49 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import { executeRemoteCall } from '@cms/modules/rpc/rpc.js' + +export interface VariantDto { + id: string; + uri: string; + url: string; + meta: Record; +} + +export interface GetVariantsOptions { + uri: string; + siteId?: string; +} + +export interface GetVariantsResult { + uri: string; + variants: VariantDto[]; +} + +const getVariants = async (options: GetVariantsOptions): Promise => { + const data = { + method: 'variants.get', + parameters: options + }; + return (await executeRemoteCall(data)).result as GetVariantsResult; +}; + +export { getVariants }; diff --git a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpointTest.java b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpointTest.java new file mode 100644 index 000000000..0fd87a6ed --- /dev/null +++ b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpointTest.java @@ -0,0 +1,140 @@ +package com.condation.cms.modules.ui.extensionpoints.remotemethods; + +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import com.condation.cms.api.db.Content; +import com.condation.cms.api.db.ContentNode; +import com.condation.cms.api.db.DB; +import com.condation.cms.api.feature.features.DBFeature; +import com.condation.cms.api.module.SiteModuleContext; +import com.condation.cms.api.ui.rpc.RPCException; +import com.condation.cms.content.VariantResolver; +import com.condation.cms.modules.ui.extensionpoints.remotemethods.dto.VariantDto; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class RemoteVariantEndpointTest { + + @Mock + private SiteModuleContext moduleContext; + + @Mock + private DB db; + + @Mock + private Content content; + + @Mock + private VariantResolver variantResolver; + + private RemoteVariantEndpoint endpoint; + + @BeforeEach + void setUp() { + endpoint = new RemoteVariantEndpoint() { + @Override + protected VariantResolver getVariantResolver(DB db) { + return variantResolver; + } + }; + endpoint.setContext(moduleContext); + lenient().when(moduleContext.get(DBFeature.class)).thenReturn(new DBFeature(db)); + lenient().when(db.getContent()).thenReturn(content); + } + + @Test + void getReturnsVariantsSortedById() throws RPCException { + var node = node("about.md", "/about", Map.of("title", "About")); + var summerNode = node( + ".variants/about/summer/about.md", + "/.variants/about/summer/about", + Map.of("title", "Summer") + ); + var campaignNode = node( + ".variants/about/campaign/about.md", + "/.variants/about/campaign/about", + Map.of("title", "Campaign") + ); + when(content.byPath("about.md")).thenReturn(Optional.of(node)); + when(variantResolver.getVariants(node)).thenReturn(List.of( + new VariantResolver.Variant("summer", summerNode), + new VariantResolver.Variant("campaign", campaignNode) + )); + + @SuppressWarnings("unchecked") + var result = (Map) endpoint.get(Map.of("uri", "about.md")); + + assertThat(result).containsEntry("uri", "about.md"); + assertThat((List) result.get("variants")) + .extracting(VariantDto::id) + .containsExactly("campaign", "summer"); + } + + @Test + void getReturnsEmptyListWhenNodeHasNoVariants() throws RPCException { + var node = node("about.md", "/about", Map.of()); + when(content.byPath("about.md")).thenReturn(Optional.of(node)); + when(variantResolver.getVariants(node)).thenReturn(List.of()); + + @SuppressWarnings("unchecked") + var result = (Map) endpoint.get(Map.of("uri", "about.md")); + + assertThat(result).containsEntry("variants", List.of()); + } + + @Test + void getRejectsBlankUri() { + assertThatThrownBy(() -> endpoint.get(Map.of())) + .isInstanceOf(RPCException.class) + .satisfies(exception -> + assertThat(((RPCException) exception).getCode()).isEqualTo(400) + ); + } + + @Test + void getReturnsNotFoundForUnknownNode() { + when(content.byPath("missing.md")).thenReturn(Optional.empty()); + when(content.byUrl("missing.md")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> endpoint.get(Map.of("uri", "missing.md"))) + .isInstanceOf(RPCException.class) + .satisfies(exception -> + assertThat(((RPCException) exception).getCode()).isEqualTo(404) + ); + } + + private ContentNode node(String uri, String url, Map data) { + return new ContentNode(uri, url, "about.md", data); + } +} From 0facc90168d672dd1ba979fa35a1e43251d69829 Mon Sep 17 00:00:00 2001 From: Thorsten Marx Date: Wed, 29 Jul 2026 14:15:02 +0200 Subject: [PATCH 04/16] create new variant --- .../condation/cms/api/db/DBFileSystem.java | 6 + .../condation/cms/filesystem/FileSystem.java | 3 +- .../ui/extensionpoints/PageMenuExtension.java | 12 ++ .../remotemethods/RemoteVariantEndpoint.java | 137 ++++++++++++++++- .../manager/actions/page/create-variant.d.ts | 21 +++ .../manager/actions/page/create-variant.js | 129 ++++++++++++++++ .../manager/js/modules/rpc/rpc-variant.d.ts | 15 +- .../manager/js/modules/rpc/rpc-variant.js | 9 +- .../ts/src/actions/page/create-variant.ts | 143 ++++++++++++++++++ .../main/ts/src/js/modules/rpc/rpc-variant.ts | 24 ++- .../RemoteVariantEndpointTest.java | 6 + .../.variants/about/winter-campain/about.md | 11 ++ .../index/summer/index.asection.bla.md | 31 ++++ .../index/summer/index.asection.other.md | 20 +++ .../index/summer/index.asection.test.md | 11 ++ .../index/summer/index.asection.test1.md | 19 +++ .../content/.variants/index/summer/index.md | 43 ++++++ .../index/summer/index.vsection.bla.md | 30 ++++ .../index/summer/index.vsection.other.md | 20 +++ 19 files changed, 685 insertions(+), 5 deletions(-) create mode 100644 modules/ui-module/src/main/resources/manager/actions/page/create-variant.d.ts create mode 100644 modules/ui-module/src/main/resources/manager/actions/page/create-variant.js create mode 100644 modules/ui-module/src/main/ts/src/actions/page/create-variant.ts create mode 100644 test-server/hosts/demo/content/.variants/about/winter-campain/about.md create mode 100644 test-server/hosts/demo/content/.variants/index/summer/index.asection.bla.md create mode 100644 test-server/hosts/demo/content/.variants/index/summer/index.asection.other.md create mode 100644 test-server/hosts/demo/content/.variants/index/summer/index.asection.test.md create mode 100644 test-server/hosts/demo/content/.variants/index/summer/index.asection.test1.md create mode 100644 test-server/hosts/demo/content/.variants/index/summer/index.md create mode 100644 test-server/hosts/demo/content/.variants/index/summer/index.vsection.bla.md create mode 100644 test-server/hosts/demo/content/.variants/index/summer/index.vsection.other.md diff --git a/cms-api/src/main/java/com/condation/cms/api/db/DBFileSystem.java b/cms-api/src/main/java/com/condation/cms/api/db/DBFileSystem.java index a007e2002..ee970c893 100644 --- a/cms-api/src/main/java/com/condation/cms/api/db/DBFileSystem.java +++ b/cms-api/src/main/java/com/condation/cms/api/db/DBFileSystem.java @@ -48,4 +48,10 @@ public interface DBFileSystem { ReadOnlyFile contentBase(); ReadOnlyFile assetBase(); + + /** + * Processes all content changes that have already been queued for indexing + * and waits until the index has been updated. + */ + void flushContentChanges(); } diff --git a/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileSystem.java b/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileSystem.java index 7173b102b..f381df82e 100644 --- a/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileSystem.java +++ b/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileSystem.java @@ -312,7 +312,8 @@ void handleContentEvent(FileEvent event) { } } - void flushContentChanges() { + @Override + public void flushContentChanges() { contentChangeCoordinator.flushNow(); } diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/PageMenuExtension.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/PageMenuExtension.java index beceeaa21..c56fee407 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/PageMenuExtension.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/PageMenuExtension.java @@ -126,6 +126,16 @@ public void manage_translations() {} scriptAction = @com.condation.cms.api.ui.annotations.ScriptAction(module = "/manager/actions/page/variants") ) public void page_variants() {} + + @ShortCut( + id = "page-variant-create", + title = "Create page variant", + permissions = {Permissions.CONTENT_EDIT}, + hotkey = "ctrl-7", + section = "Page", + scriptAction = @com.condation.cms.api.ui.annotations.ScriptAction(module = "/manager/actions/page/create-variant") + ) + public void create_page_variant() {} @Override @@ -137,6 +147,7 @@ public Map> getLocalizations() { "page-edit-content", "Inhalt bearbeiten", "page-edit-meta", "Metadaten bearbeiten", "page-variants", "Seitenvarianten", + "page-variant-create", "Seitenvariante erstellen", "language.de", "Deutsch", "language.en", "Englisch" ), @@ -146,6 +157,7 @@ public Map> getLocalizations() { "page-edit-content", "Edit content", "page-edit-meta", "Edit metadata", "page-variants", "Page variants", + "page-variant-create", "Create page variant", "language.de", "German", "language.en", "English" ) diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpoint.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpoint.java index c14ecf128..67a21702a 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpoint.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpoint.java @@ -22,18 +22,33 @@ */ import com.condation.cms.api.auth.Permissions; +import com.condation.cms.api.Constants; import com.condation.cms.api.db.ContentNode; import com.condation.cms.api.db.DB; +import com.condation.cms.api.eventbus.events.ReIndexContentMetaDataEvent; +import com.condation.cms.api.feature.features.EventBusFeature; import com.condation.cms.api.feature.features.InjectorFeature; +import com.condation.cms.api.feature.features.WorkflowFeature; import com.condation.cms.api.ui.annotations.RemoteMethod; import com.condation.cms.api.ui.extensions.UIRemoteMethodExtensionPoint; import com.condation.cms.api.ui.rpc.RPCException; +import com.condation.cms.api.utils.PathUtil; import com.condation.cms.content.VariantResolver; +import com.condation.cms.core.content.io.ContentFileParser; +import com.condation.cms.core.content.io.YamlHeaderUpdater; import com.condation.cms.modules.ui.extensionpoints.remotemethods.dto.VariantDto; +import com.condation.cms.modules.ui.utils.UIPathUtil; import com.condation.modules.api.annotation.Extension; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.time.Instant; +import java.util.ArrayList; import java.util.Comparator; +import java.util.Date; +import java.util.HashMap; import java.util.LinkedHashMap; import java.util.Map; +import lombok.extern.slf4j.Slf4j; /** * Remote methods for loading the variants of a content node. @@ -41,8 +56,116 @@ * @author thorstenmarx */ @Extension(UIRemoteMethodExtensionPoint.class) +@Slf4j public class RemoteVariantEndpoint extends AbstractRemoteMethodeExtension { + @RemoteMethod(name = "variants.create", permissions = {Permissions.CONTENT_EDIT}) + public Object create(Map parameters) throws RPCException { + var uri = stringParameter(parameters, "uri"); + var id = stringParameter(parameters, "id"); + var title = stringParameter(parameters, "title"); + var template = stringParameter(parameters, "template"); + var copyContent = Boolean.TRUE.equals(parameters.get("copyContent")); + + if (uri.isBlank() || id.isBlank() || title.isBlank() || template.isBlank()) { + throw new RPCException(400, "uri, id, title and template must not be blank"); + } + if (uiHooks().contentTypes().getPageTemplates().stream() + .noneMatch(pageTemplate -> pageTemplate.template().equals(template))) { + throw new RPCException(400, "unknown page template"); + } + + var db = getDB(parameters); + var canonicalNode = findContentNode(db, uri); + var contentBase = db.getFileSystem().resolve(Constants.Folders.CONTENT); + var canonicalFile = contentBase.resolve(canonicalNode.path()); + var variantId = UIPathUtil.toValidFilename(id); + if (variantId.isBlank() || ".".equals(variantId) || "..".equals(variantId)) { + throw new RPCException(400, "invalid variant id"); + } + + var fileName = canonicalFile.getFileName().toString(); + var pageName = fileName.endsWith(".md") + ? fileName.substring(0, fileName.length() - 3) + : fileName; + var variantFile = canonicalFile.getParent() + .resolve(".variants") + .resolve(pageName) + .resolve(variantId) + .resolve(fileName); + + try { + if (!UIPathUtil.isChild(contentBase, variantFile)) { + throw new RPCException(400, "invalid variant path"); + } + if (Files.exists(variantFile)) { + throw new RPCException(409, "variant already exists"); + } + + var body = copyContent ? new ContentFileParser(canonicalFile.toString()).getContent() : ""; + var sectionCopies = copyContent + ? db.getContent().listSectionEntries(db.getFileSystem().contentBase().resolve(canonicalNode.path())) + .stream() + .map(section -> new SectionCopy( + contentBase.resolve(section.path()), + variantFile.getParent().resolve(section.name()) + )) + .toList() + : java.util.List.of(); + if (sectionCopies.stream().anyMatch(copy -> Files.exists(copy.target()))) { + throw new RPCException(409, "variant section already exists"); + } + + Map meta = new HashMap<>(); + meta.put(Constants.MetaFields.TITLE, title); + meta.put(Constants.MetaFields.TEMPLATE, template); + meta.put(Constants.MetaFields.STATUS, getContext().get(WorkflowFeature.class) + .workflow().getStatusProvider().newNodeStatus()); + meta.put("createdAt", Date.from(Instant.now())); + meta.put("createdBy", getUserName()); + + Files.createDirectories(variantFile.getParent()); + var createdFiles = new ArrayList(); + try { + YamlHeaderUpdater.saveMarkdownFileWithHeader(variantFile, meta, body); + createdFiles.add(variantFile); + for (var sectionCopy : sectionCopies) { + Files.copy( + sectionCopy.source(), + sectionCopy.target(), + StandardCopyOption.COPY_ATTRIBUTES + ); + createdFiles.add(sectionCopy.target()); + } + } catch (Exception exception) { + for (var createdFile : createdFiles.reversed()) { + Files.deleteIfExists(createdFile); + } + throw exception; + } + + var eventBus = getContext().get(EventBusFeature.class).eventBus(); + for (var createdFile : createdFiles) { + eventBus.syncPublish(new ReIndexContentMetaDataEvent( + PathUtil.toRelativeFile(createdFile, contentBase) + )); + } + db.getFileSystem().flushContentChanges(); + var newUri = PathUtil.toRelativeFile(variantFile, contentBase); + + return Map.of( + "id", variantId, + "uri", newUri, + "url", managerPreviewUrl(PathUtil.toURL(variantFile, contentBase)) + ); + } catch (RPCException exception) { + throw exception; + } catch (Exception exception) { + log.error("Could not create variant '{}' for '{}'", variantId, canonicalNode.path(), exception); + throw new RPCException(500, exception.getMessage()); + } + } + @RemoteMethod(name = "variants.get", permissions = {Permissions.CONTENT_EDIT}) public Object get(Map parameters) throws RPCException { var uri = (String) parameters.getOrDefault("uri", ""); @@ -59,7 +182,7 @@ public Object get(Map parameters) throws RPCException { .map(variant -> new VariantDto( variant.id(), variant.node().uri(), - variant.node().url(), + managerPreviewUrl(variant.node().url()), variant.node().data() )) .toList(); @@ -83,4 +206,16 @@ private ContentNode findContentNode(DB db, String uri) throws RPCException { protected VariantResolver getVariantResolver(DB db) { return getContext().get(InjectorFeature.class).injector().getInstance(VariantResolver.class); } + + private String stringParameter(Map parameters, String name) { + var value = parameters.get(name); + return value instanceof String stringValue ? stringValue.trim() : ""; + } + + private String managerPreviewUrl(String url) { + return url + (url.contains("?") ? "&" : "?") + "preview=manager"; + } + + private record SectionCopy(java.nio.file.Path source, java.nio.file.Path target) { + } } diff --git a/modules/ui-module/src/main/resources/manager/actions/page/create-variant.d.ts b/modules/ui-module/src/main/resources/manager/actions/page/create-variant.d.ts new file mode 100644 index 000000000..a83d0b4a2 --- /dev/null +++ b/modules/ui-module/src/main/resources/manager/actions/page/create-variant.d.ts @@ -0,0 +1,21 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ +export declare const runAction: () => Promise; diff --git a/modules/ui-module/src/main/resources/manager/actions/page/create-variant.js b/modules/ui-module/src/main/resources/manager/actions/page/create-variant.js new file mode 100644 index 000000000..df202a9ae --- /dev/null +++ b/modules/ui-module/src/main/resources/manager/actions/page/create-variant.js @@ -0,0 +1,129 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ +import { i18n } from '@cms/modules/localization.js'; +import { openModal } from '@cms/modules/modal.js'; +import { getPreviewUrl, loadPreview } from '@cms/modules/preview.utils.js'; +import { getContentNode } from '@cms/modules/rpc/rpc-content.js'; +import { getPageTemplates } from '@cms/modules/rpc/rpc-manager.js'; +import { createVariant } from '@cms/modules/rpc/rpc-variant.js'; +import { showToast } from '@cms/modules/toast.js'; +const value = (id) => document.getElementById(id)?.value.trim() ?? ''; +const validate = () => { + const missing = [ + ['cms-variant-id', 'Variant ID'], + ['cms-variant-title', 'Title'], + ['cms-variant-template', 'Template'] + ].find(([id]) => !value(id) || (id === 'cms-variant-template' && value(id) === '__none__')); + if (!missing) { + return true; + } + showToast({ + title: i18n.t('manager.actions.page.variant.create.validation.title', 'Create variant'), + message: i18n.t(`manager.actions.page.variant.create.validation.${missing[0]}`, `${missing[1]} is required.`), + type: 'error', + timeout: 3000 + }); + return false; +}; +export const runAction = async () => { + try { + const [contentNode, templatesResponse] = await Promise.all([ + getContentNode({ url: getPreviewUrl() }), + getPageTemplates({}) + ]); + const templates = Array.from(templatesResponse.result ?? []) + .sort((left, right) => String(left.name).localeCompare(String(right.name))); + const currentTemplate = contentNode.result.meta?.template ?? contentNode.result.data?.template ?? ''; + const options = templates.map((template) => { + const selected = template.template === currentTemplate ? ' selected' : ''; + return ``; + }).join(''); + openModal({ + title: i18n.t('manager.actions.page.variant.create.title', 'Create page variant'), + body: ` +
+ + +
+
+ + +
+
+ + +
+
+ Initial content +
+ + +
+
+ + +
+
`, + fullscreen: false, + validate, + onCancel: () => { }, + onOk: async () => { + try { + const copyContent = document.querySelector('input[name="cms-variant-content"]:checked')?.value === 'copy'; + const result = await createVariant({ + uri: contentNode.result.uri, + id: value('cms-variant-id'), + title: value('cms-variant-title'), + template: value('cms-variant-template'), + copyContent + }); + showToast({ + title: i18n.t('manager.actions.page.variant.create.success.title', 'Variant created'), + message: i18n.t('manager.actions.page.variant.create.success.message', 'The page variant was created.'), + type: 'success', + timeout: 3000 + }); + loadPreview(result.url); + } + catch (error) { + showError(error); + } + } + }); + } + catch (error) { + showError(error); + } +}; +const escapeHtml = (input) => { + const element = document.createElement('div'); + element.textContent = String(input ?? ''); + return element.innerHTML; +}; +const showError = (error) => showToast({ + title: i18n.t('manager.actions.page.variant.create.error.title', 'Could not create variant'), + message: error instanceof Error ? error.message : String(error), + type: 'error', + timeout: 3000 +}); diff --git a/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-variant.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-variant.d.ts index d1eba69aa..8207a527f 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-variant.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-variant.d.ts @@ -32,5 +32,18 @@ export interface GetVariantsResult { uri: string; variants: VariantDto[]; } +export interface CreateVariantOptions { + uri: string; + id: string; + title: string; + template: string; + copyContent: boolean; +} +export interface CreateVariantResult { + id: string; + uri: string; + url: string; +} declare const getVariants: (options: GetVariantsOptions) => Promise; -export { getVariants }; +declare const createVariant: (options: CreateVariantOptions) => Promise; +export { createVariant, getVariants }; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-variant.js b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-variant.js index ed9ae2e13..e41da2c54 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-variant.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-variant.js @@ -26,4 +26,11 @@ const getVariants = async (options) => { }; return (await executeRemoteCall(data)).result; }; -export { getVariants }; +const createVariant = async (options) => { + const data = { + method: 'variants.create', + parameters: options + }; + return (await executeRemoteCall(data)).result; +}; +export { createVariant, getVariants }; diff --git a/modules/ui-module/src/main/ts/src/actions/page/create-variant.ts b/modules/ui-module/src/main/ts/src/actions/page/create-variant.ts new file mode 100644 index 000000000..422e62754 --- /dev/null +++ b/modules/ui-module/src/main/ts/src/actions/page/create-variant.ts @@ -0,0 +1,143 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import { i18n } from '@cms/modules/localization.js' +import { openModal } from '@cms/modules/modal.js' +import { getPreviewUrl, loadPreview } from '@cms/modules/preview.utils.js' +import { getContentNode } from '@cms/modules/rpc/rpc-content.js' +import { getPageTemplates } from '@cms/modules/rpc/rpc-manager.js' +import { createVariant } from '@cms/modules/rpc/rpc-variant.js' +import { showToast } from '@cms/modules/toast.js' + +const value = (id: string): string => + (document.getElementById(id) as HTMLInputElement | HTMLSelectElement)?.value.trim() ?? ''; + +const validate = (): boolean => { + const missing = [ + ['cms-variant-id', 'Variant ID'], + ['cms-variant-title', 'Title'], + ['cms-variant-template', 'Template'] + ].find(([id]) => !value(id) || (id === 'cms-variant-template' && value(id) === '__none__')); + + if (!missing) { + return true; + } + + showToast({ + title: i18n.t('manager.actions.page.variant.create.validation.title', 'Create variant'), + message: i18n.t( + `manager.actions.page.variant.create.validation.${missing[0]}`, + `${missing[1]} is required.` + ), + type: 'error', + timeout: 3000 + }); + return false; +}; + +export const runAction = async () => { + try { + const [contentNode, templatesResponse] = await Promise.all([ + getContentNode({ url: getPreviewUrl() }), + getPageTemplates({}) + ]); + const templates = Array.from(templatesResponse.result ?? []) + .sort((left: any, right: any) => String(left.name).localeCompare(String(right.name))); + const currentTemplate = contentNode.result.meta?.template ?? contentNode.result.data?.template ?? ''; + + const options = templates.map((template: any) => { + const selected = template.template === currentTemplate ? ' selected' : ''; + return ``; + }).join(''); + + openModal({ + title: i18n.t('manager.actions.page.variant.create.title', 'Create page variant'), + body: ` +
+ + +
+
+ + +
+
+ + +
+
+ Initial content +
+ + +
+
+ + +
+
`, + fullscreen: false, + validate, + onCancel: () => {}, + onOk: async () => { + try { + const copyContent = (document.querySelector( + 'input[name="cms-variant-content"]:checked' + ) as HTMLInputElement)?.value === 'copy'; + const result = await createVariant({ + uri: contentNode.result.uri, + id: value('cms-variant-id'), + title: value('cms-variant-title'), + template: value('cms-variant-template'), + copyContent + }); + showToast({ + title: i18n.t('manager.actions.page.variant.create.success.title', 'Variant created'), + message: i18n.t('manager.actions.page.variant.create.success.message', 'The page variant was created.'), + type: 'success', + timeout: 3000 + }); + loadPreview(result.url); + } catch (error) { + showError(error); + } + } + }); + } catch (error) { + showError(error); + } +}; + +const escapeHtml = (input: unknown): string => { + const element = document.createElement('div'); + element.textContent = String(input ?? ''); + return element.innerHTML; +}; + +const showError = (error: unknown) => showToast({ + title: i18n.t('manager.actions.page.variant.create.error.title', 'Could not create variant'), + message: error instanceof Error ? error.message : String(error), + type: 'error', + timeout: 3000 +}); diff --git a/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc-variant.ts b/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc-variant.ts index fc4906ee0..e738e6944 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc-variant.ts +++ b/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc-variant.ts @@ -38,6 +38,20 @@ export interface GetVariantsResult { variants: VariantDto[]; } +export interface CreateVariantOptions { + uri: string; + id: string; + title: string; + template: string; + copyContent: boolean; +} + +export interface CreateVariantResult { + id: string; + uri: string; + url: string; +} + const getVariants = async (options: GetVariantsOptions): Promise => { const data = { method: 'variants.get', @@ -46,4 +60,12 @@ const getVariants = async (options: GetVariantsOptions): Promise => { + const data = { + method: 'variants.create', + parameters: options + }; + return (await executeRemoteCall(data)).result as CreateVariantResult; +}; + +export { createVariant, getVariants }; diff --git a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpointTest.java b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpointTest.java index 0fd87a6ed..5801d70e0 100644 --- a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpointTest.java +++ b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpointTest.java @@ -99,6 +99,12 @@ void getReturnsVariantsSortedById() throws RPCException { assertThat((List) result.get("variants")) .extracting(VariantDto::id) .containsExactly("campaign", "summer"); + assertThat((List) result.get("variants")) + .extracting(VariantDto::url) + .containsExactly( + "/.variants/about/campaign/about?preview=manager", + "/.variants/about/summer/about?preview=manager" + ); } @Test diff --git a/test-server/hosts/demo/content/.variants/about/winter-campain/about.md b/test-server/hosts/demo/content/.variants/about/winter-campain/about.md new file mode 100644 index 000000000..9d07d1c4d --- /dev/null +++ b/test-server/hosts/demo/content/.variants/about/winter-campain/about.md @@ -0,0 +1,11 @@ +--- +template: default.html +createdAt: 2026-07-29T11:53:35.309Z +createdBy: test +title: Winter Caompain +status: draft +--- + +# About + +back to [index](/) diff --git a/test-server/hosts/demo/content/.variants/index/summer/index.asection.bla.md b/test-server/hosts/demo/content/.variants/index/summer/index.asection.bla.md new file mode 100644 index 000000000..2a3f8cdc9 --- /dev/null +++ b/test-server/hosts/demo/content/.variants/index/summer/index.asection.bla.md @@ -0,0 +1,31 @@ +--- +title: Startseite +template: section.html +search: + index: false +description: my new descritpion for that awesome section-lol +parent: + text: text for a section +layout: + order: 0 +media_url: compass-7592444_1920.jpg +status: published +object: + values: + - title: section 1-1 + description: in section 1 + features: search + - title: New Item + description: asdads + features: export +about: its all about content +unpublish_date: null +publish_date: null +about1: more content more better +--- + +# This is a section: bla + +And no i can edit the content + +blabladdddsd asd diff --git a/test-server/hosts/demo/content/.variants/index/summer/index.asection.other.md b/test-server/hosts/demo/content/.variants/index/summer/index.asection.other.md new file mode 100644 index 000000000..c8f4ef63f --- /dev/null +++ b/test-server/hosts/demo/content/.variants/index/summer/index.asection.other.md @@ -0,0 +1,20 @@ +--- +template: section.html +layout: + order: 3 +parent: + text: another parent text +description: another description +status: draft +about: "thats the about text \nand we also support **multilines**\n\nhere" +unpublish_date: null +publish_date: null +object: + values: + - title: platz 1 + description: tolles ding + features: search +title: '' +--- + +another section diff --git a/test-server/hosts/demo/content/.variants/index/summer/index.asection.test.md b/test-server/hosts/demo/content/.variants/index/summer/index.asection.test.md new file mode 100644 index 000000000..022486910 --- /dev/null +++ b/test-server/hosts/demo/content/.variants/index/summer/index.asection.test.md @@ -0,0 +1,11 @@ +--- +template: section.html +description: sec descriptione +layout: + order: 1 +parent: + text: sec parent text +status: draft +--- + +test diff --git a/test-server/hosts/demo/content/.variants/index/summer/index.asection.test1.md b/test-server/hosts/demo/content/.variants/index/summer/index.asection.test1.md new file mode 100644 index 000000000..b8749083c --- /dev/null +++ b/test-server/hosts/demo/content/.variants/index/summer/index.asection.test1.md @@ -0,0 +1,19 @@ +--- +template: section.html +description: test23 +layout: + order: 2 +status: draft +parent: + text: '' +object: + values: [ + ] +about: about normal +unpublish_date: null +about1: about 1111 +title: '' +publish_date: null +--- + +das ist der inhalt2 diff --git a/test-server/hosts/demo/content/.variants/index/summer/index.md b/test-server/hosts/demo/content/.variants/index/summer/index.md new file mode 100644 index 000000000..0ea8fd31e --- /dev/null +++ b/test-server/hosts/demo/content/.variants/index/summer/index.md @@ -0,0 +1,43 @@ +--- +template: start.html +createdAt: 2026-07-29T12:06:01.530Z +createdBy: test +title: Summer time +status: draft +--- + +# Demo Project + +![TestBild!](/media/images/test.jpg?format=small) + +That's a demo page with some extra features to show the manager application! + +Hello world! + +Here some content! + +Hello: [[cms:username]][[/cms:username]] +Theme: [[ext:theme_name]][[/ext:theme_name]] + +[about](/about) + +[this is a new page](/this-is-a-new-page) + + +```java +// its a comment +System.out.println("Hello world!"); +``` + +### say hello +[[ext:say_hello name="CondationCMS" /]] + + +### test ShortCode with content +--- +[[ext:bold_content]]This content will be bold[[/ext:bold_content]] +--- + + +### example from module +[[ext:example /]] diff --git a/test-server/hosts/demo/content/.variants/index/summer/index.vsection.bla.md b/test-server/hosts/demo/content/.variants/index/summer/index.vsection.bla.md new file mode 100644 index 000000000..142b9cee3 --- /dev/null +++ b/test-server/hosts/demo/content/.variants/index/summer/index.vsection.bla.md @@ -0,0 +1,30 @@ +--- +title: Startseite +template: section_v.html +search: + index: false +description: my new descritpion for that awesome section-lol +parent: + text: text for a section +layout: + order: 0 +status: published +object: + values: + - title: section 1-1 + description: in section 1 + features: search + - title: New Item + description: asdads + features: export +about: its all about content +unpublish_date: null +publish_date: null +about1: more content more better +--- + +# This is a section: bla + +And no i can edit the content + +blabladdddsd asd diff --git a/test-server/hosts/demo/content/.variants/index/summer/index.vsection.other.md b/test-server/hosts/demo/content/.variants/index/summer/index.vsection.other.md new file mode 100644 index 000000000..12d03f08b --- /dev/null +++ b/test-server/hosts/demo/content/.variants/index/summer/index.vsection.other.md @@ -0,0 +1,20 @@ +--- +template: section_v.html +layout: + order: 1 +parent: + text: another parent text +description: another description +status: draft +about: "thats the about text \nand we also support **multilines**\n\nhere" +unpublish_date: null +publish_date: null +object: + values: + - title: platz 1 + description: tolles ding + features: search +title: '' +--- + +another section From dac53aa622df08d981643e959994e91957919b10 Mon Sep 17 00:00:00 2001 From: Thorsten Marx Date: Wed, 29 Jul 2026 15:10:58 +0200 Subject: [PATCH 05/16] variations test --- .../cms/content/VariantResolver.java | 59 +++++++++++- .../cms/content/VariantResolverTest.java | 74 +++++++++++++++ .../remotemethods/RemoteVariantEndpoint.java | 16 +++- .../manager/actions/page/variants.js | 60 ++++++------ .../manager/js/modules/rpc/rpc-variant.d.ts | 6 ++ .../src/main/ts/src/actions/page/variants.ts | 95 ++++++++++++------- .../main/ts/src/js/modules/rpc/rpc-variant.ts | 6 ++ .../RemoteVariantEndpointTest.java | 36 ++++++- .../.variants/about/black-friday/about.md | 9 ++ .../hosts/demo/content/index.asection.bla.md | 2 +- 10 files changed, 291 insertions(+), 72 deletions(-) create mode 100644 cms-content/src/test/java/com/condation/cms/content/VariantResolverTest.java create mode 100644 test-server/hosts/demo/content/.variants/about/black-friday/about.md diff --git a/cms-content/src/main/java/com/condation/cms/content/VariantResolver.java b/cms-content/src/main/java/com/condation/cms/content/VariantResolver.java index b2eb43609..d412076fb 100644 --- a/cms-content/src/main/java/com/condation/cms/content/VariantResolver.java +++ b/cms-content/src/main/java/com/condation/cms/content/VariantResolver.java @@ -30,6 +30,7 @@ import java.util.Collections; import java.util.List; import java.util.Optional; +import java.nio.file.Path; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -44,7 +45,25 @@ public class VariantResolver { private final DB db; public Optional loadVariant (ContentNode node, String variantId) { - return getVariants(node).stream().filter(variant -> variant.id().equals(variantId)).findFirst(); + return getVariants(resolveContext(node).canonical()).stream() + .filter(variant -> variant.id().equals(variantId)) + .findFirst(); + } + + /** + * Resolves the canonical page, active variant and sibling variants for + * either a canonical page or one of its variants. + */ + public VariantContext resolveContext(ContentNode node) { + var location = variantLocation(node); + var canonical = location + .flatMap(value -> db.getContent().byPath(value.canonicalPath())) + .orElse(node); + var activeVariantId = location + .filter(value -> canonical != node) + .map(VariantLocation::variantId); + + return new VariantContext(canonical, activeVariantId, getVariants(canonical)); } public List getVariants (ContentNode node) { @@ -80,6 +99,44 @@ private String removeMarkdown (String filename) { } return filename; } + + private Optional variantLocation(ContentNode node) { + var path = Path.of(node.path()); + for (int index = 0; index < path.getNameCount(); index++) { + if (!".variants".equals(path.getName(index).toString())) { + continue; + } + if (index + 3 != path.getNameCount() - 1) { + return Optional.empty(); + } + + var pageFolder = path.getName(index + 1).toString(); + var variantId = path.getName(index + 2).toString(); + var fileName = path.getName(index + 3).toString(); + if (!pageFolder.equals(removeMarkdown(fileName))) { + return Optional.empty(); + } + + Path canonicalPath = index == 0 + ? Path.of(fileName) + : path.subpath(0, index).resolve(fileName); + return Optional.of(new VariantLocation( + canonicalPath.toString().replace('\\', '/'), + variantId + )); + } + return Optional.empty(); + } public record Variant (String id, ContentNode node) {} + + public record VariantContext( + ContentNode canonical, + Optional activeVariantId, + List variants + ) { + } + + private record VariantLocation(String canonicalPath, String variantId) { + } } diff --git a/cms-content/src/test/java/com/condation/cms/content/VariantResolverTest.java b/cms-content/src/test/java/com/condation/cms/content/VariantResolverTest.java new file mode 100644 index 000000000..d4be8206b --- /dev/null +++ b/cms-content/src/test/java/com/condation/cms/content/VariantResolverTest.java @@ -0,0 +1,74 @@ +package com.condation.cms.content; + +/*- + * #%L + * CMS Content + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import com.condation.cms.api.db.Content; +import com.condation.cms.api.db.ContentNode; +import com.condation.cms.api.db.DB; +import com.condation.cms.api.db.DBFileSystem; +import com.condation.cms.api.db.cms.ReadOnlyFile; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class VariantResolverTest { + + @Test + void resolvesCanonicalPageAndActiveVariantFromVariantPath() { + var db = mock(DB.class); + var content = mock(Content.class); + var fileSystem = mock(DBFileSystem.class); + var contentBase = mock(ReadOnlyFile.class); + var canonicalFile = mock(ReadOnlyFile.class); + var canonicalFolder = mock(ReadOnlyFile.class); + var variantsFolder = mock(ReadOnlyFile.class); + var canonical = node("news/about.md", "/news/about", "about.md"); + var variant = node( + "news/.variants/about/summer/about.md", + "/news/.variants/about/summer/about", + "about.md" + ); + + when(db.getContent()).thenReturn(content); + when(db.getFileSystem()).thenReturn(fileSystem); + when(fileSystem.contentBase()).thenReturn(contentBase); + when(content.byPath("news/about.md")).thenReturn(Optional.of(canonical)); + when(contentBase.resolve("news/about.md")).thenReturn(canonicalFile); + when(canonicalFile.getParent()).thenReturn(canonicalFolder); + when(canonicalFolder.resolve(".variants/about")).thenReturn(variantsFolder); + when(variantsFolder.exists()).thenReturn(false); + + var context = new VariantResolver(db).resolveContext(variant); + + assertThat(context.canonical()).isEqualTo(canonical); + assertThat(context.activeVariantId()).contains("summer"); + assertThat(context.variants()).isEmpty(); + } + + private ContentNode node(String path, String url, String name) { + return new ContentNode(path, url, name, Map.of("title", name)); + } +} diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpoint.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpoint.java index 67a21702a..b6d19ce7b 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpoint.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpoint.java @@ -76,7 +76,8 @@ public Object create(Map parameters) throws RPCException { } var db = getDB(parameters); - var canonicalNode = findContentNode(db, uri); + var requestedNode = findContentNode(db, uri); + var canonicalNode = getVariantResolver(db).resolveContext(requestedNode).canonical(); var contentBase = db.getFileSystem().resolve(Constants.Folders.CONTENT); var canonicalFile = contentBase.resolve(canonicalNode.path()); var variantId = UIPathUtil.toValidFilename(id); @@ -175,8 +176,8 @@ public Object get(Map parameters) throws RPCException { var db = getDB(parameters); var contentNode = findContentNode(db, uri); - var variants = getVariantResolver(db) - .getVariants(contentNode) + var variantContext = getVariantResolver(db).resolveContext(contentNode); + var variants = variantContext.variants() .stream() .sorted(Comparator.comparing(VariantResolver.Variant::id)) .map(variant -> new VariantDto( @@ -188,7 +189,14 @@ public Object get(Map parameters) throws RPCException { .toList(); Map result = new LinkedHashMap<>(); - result.put("uri", contentNode.uri()); + result.put("uri", variantContext.canonical().uri()); + result.put("canonical", Map.of( + "uri", variantContext.canonical().uri(), + "url", managerPreviewUrl(variantContext.canonical().url()), + "title", variantContext.canonical() + .getMetaValue(Constants.MetaFields.TITLE, variantContext.canonical().name()) + )); + result.put("activeVariantId", variantContext.activeVariantId().orElse(null)); result.put("variants", variants); return result; } diff --git a/modules/ui-module/src/main/resources/manager/actions/page/variants.js b/modules/ui-module/src/main/resources/manager/actions/page/variants.js index 50273a89b..8f01719fb 100644 --- a/modules/ui-module/src/main/resources/manager/actions/page/variants.js +++ b/modules/ui-module/src/main/resources/manager/actions/page/variants.js @@ -29,8 +29,34 @@ const variantTitle = (variant) => { const title = variant.meta?.title; return typeof title === 'string' && title.trim() ? title : variant.id; }; -const renderVariants = (container, variants, modal) => { - if (variants.length === 0) { +const createVariantLink = (titleText, idText, uriText, url, active, modal) => { + const link = document.createElement('a'); + link.href = url; + link.className = `list-group-item list-group-item-action${active ? ' active' : ''}`; + link.setAttribute('aria-current', active ? 'true' : 'false'); + const heading = document.createElement('div'); + heading.className = 'd-flex w-100 justify-content-between align-items-center gap-3'; + const title = document.createElement('strong'); + title.textContent = titleText; + heading.appendChild(title); + const id = document.createElement('span'); + id.className = active ? 'badge text-bg-light' : 'badge text-bg-secondary'; + id.textContent = idText; + heading.appendChild(id); + link.appendChild(heading); + const uri = document.createElement('small'); + uri.className = active ? '' : 'text-body-secondary'; + uri.textContent = uriText; + link.appendChild(uri); + link.addEventListener('click', (event) => { + event.preventDefault(); + modal.hide(); + loadPreview(url); + }); + return link; +}; +const renderVariants = (container, result, modal) => { + if (result.variants.length === 0) { const emptyMessage = document.createElement('p'); emptyMessage.className = 'text-body-secondary mb-0'; emptyMessage.textContent = i18n.t('manager.actions.page.variants.empty', 'This page has no variants.'); @@ -39,31 +65,9 @@ const renderVariants = (container, variants, modal) => { } const list = document.createElement('div'); list.className = 'list-group'; - variants.forEach((variant) => { - const link = document.createElement('a'); - link.href = variant.url; - link.className = 'list-group-item list-group-item-action'; - link.dataset.cmsVariantUrl = variant.url; - const heading = document.createElement('div'); - heading.className = 'd-flex w-100 justify-content-between align-items-center gap-3'; - const title = document.createElement('strong'); - title.textContent = variantTitle(variant); - heading.appendChild(title); - const id = document.createElement('span'); - id.className = 'badge text-bg-secondary'; - id.textContent = variant.id; - heading.appendChild(id); - link.appendChild(heading); - const uri = document.createElement('small'); - uri.className = 'text-body-secondary'; - uri.textContent = variant.uri; - link.appendChild(uri); - link.addEventListener('click', (event) => { - event.preventDefault(); - modal.hide(); - loadPreview(variant.url); - }); - list.appendChild(link); + list.appendChild(createVariantLink(result.canonical.title, i18n.t('manager.actions.page.variants.canonical', 'Original'), result.canonical.uri, result.canonical.url, !result.activeVariantId, modal)); + result.variants.forEach((variant) => { + list.appendChild(createVariantLink(variantTitle(variant), variant.id, variant.uri, variant.url, result.activeVariantId === variant.id, modal)); }); container.appendChild(list); }; @@ -85,7 +89,7 @@ export const runAction = async () => { onOk: () => { }, onShow: (modalElement) => { const container = modalElement.querySelector(`#${VARIANT_LIST_ID}`); - renderVariants(container, result.variants, modal); + renderVariants(container, result, modal); } }); } diff --git a/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-variant.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-variant.d.ts index 8207a527f..d6b8fd598 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-variant.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-variant.d.ts @@ -30,6 +30,12 @@ export interface GetVariantsOptions { } export interface GetVariantsResult { uri: string; + canonical: { + uri: string; + url: string; + title: string; + }; + activeVariantId?: string | null; variants: VariantDto[]; } export interface CreateVariantOptions { diff --git a/modules/ui-module/src/main/ts/src/actions/page/variants.ts b/modules/ui-module/src/main/ts/src/actions/page/variants.ts index 97cc7c9c2..6e48d4cd7 100644 --- a/modules/ui-module/src/main/ts/src/actions/page/variants.ts +++ b/modules/ui-module/src/main/ts/src/actions/page/variants.ts @@ -23,7 +23,7 @@ import { i18n } from '@cms/modules/localization.js' import { openModal } from '@cms/modules/modal.js' import { getPreviewUrl, loadPreview } from '@cms/modules/preview.utils.js' import { getContentNode } from '@cms/modules/rpc/rpc-content.js' -import { getVariants, VariantDto } from '@cms/modules/rpc/rpc-variant.js' +import { getVariants, GetVariantsResult, VariantDto } from '@cms/modules/rpc/rpc-variant.js' import { showToast } from '@cms/modules/toast.js' const VARIANT_LIST_ID = 'cms-page-variants'; @@ -33,8 +33,47 @@ const variantTitle = (variant: VariantDto): string => { return typeof title === 'string' && title.trim() ? title : variant.id; }; -const renderVariants = (container: HTMLElement, variants: VariantDto[], modal: any) => { - if (variants.length === 0) { +const createVariantLink = ( + titleText: string, + idText: string, + uriText: string, + url: string, + active: boolean, + modal: any +): HTMLAnchorElement => { + const link = document.createElement('a'); + link.href = url; + link.className = `list-group-item list-group-item-action${active ? ' active' : ''}`; + link.setAttribute('aria-current', active ? 'true' : 'false'); + + const heading = document.createElement('div'); + heading.className = 'd-flex w-100 justify-content-between align-items-center gap-3'; + + const title = document.createElement('strong'); + title.textContent = titleText; + heading.appendChild(title); + + const id = document.createElement('span'); + id.className = active ? 'badge text-bg-light' : 'badge text-bg-secondary'; + id.textContent = idText; + heading.appendChild(id); + link.appendChild(heading); + + const uri = document.createElement('small'); + uri.className = active ? '' : 'text-body-secondary'; + uri.textContent = uriText; + link.appendChild(uri); + + link.addEventListener('click', (event) => { + event.preventDefault(); + modal.hide(); + loadPreview(url); + }); + return link; +}; + +const renderVariants = (container: HTMLElement, result: GetVariantsResult, modal: any) => { + if (result.variants.length === 0) { const emptyMessage = document.createElement('p'); emptyMessage.className = 'text-body-secondary mb-0'; emptyMessage.textContent = i18n.t( @@ -47,38 +86,24 @@ const renderVariants = (container: HTMLElement, variants: VariantDto[], modal: a const list = document.createElement('div'); list.className = 'list-group'; + list.appendChild(createVariantLink( + result.canonical.title, + i18n.t('manager.actions.page.variants.canonical', 'Original'), + result.canonical.uri, + result.canonical.url, + !result.activeVariantId, + modal + )); - variants.forEach((variant) => { - const link = document.createElement('a'); - link.href = variant.url; - link.className = 'list-group-item list-group-item-action'; - link.dataset.cmsVariantUrl = variant.url; - - const heading = document.createElement('div'); - heading.className = 'd-flex w-100 justify-content-between align-items-center gap-3'; - - const title = document.createElement('strong'); - title.textContent = variantTitle(variant); - heading.appendChild(title); - - const id = document.createElement('span'); - id.className = 'badge text-bg-secondary'; - id.textContent = variant.id; - heading.appendChild(id); - link.appendChild(heading); - - const uri = document.createElement('small'); - uri.className = 'text-body-secondary'; - uri.textContent = variant.uri; - link.appendChild(uri); - - link.addEventListener('click', (event) => { - event.preventDefault(); - modal.hide(); - loadPreview(variant.url); - }); - - list.appendChild(link); + result.variants.forEach((variant) => { + list.appendChild(createVariantLink( + variantTitle(variant), + variant.id, + variant.uri, + variant.url, + result.activeVariantId === variant.id, + modal + )); }); container.appendChild(list); @@ -103,7 +128,7 @@ export const runAction = async () => { onOk: () => { }, onShow: (modalElement: HTMLElement) => { const container = modalElement.querySelector(`#${VARIANT_LIST_ID}`) as HTMLElement; - renderVariants(container, result.variants, modal); + renderVariants(container, result, modal); } }); } catch (error) { diff --git a/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc-variant.ts b/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc-variant.ts index e738e6944..3a4660691 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc-variant.ts +++ b/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc-variant.ts @@ -35,6 +35,12 @@ export interface GetVariantsOptions { export interface GetVariantsResult { uri: string; + canonical: { + uri: string; + url: string; + title: string; + }; + activeVariantId?: string | null; variants: VariantDto[]; } diff --git a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpointTest.java b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpointTest.java index 5801d70e0..e24f89166 100644 --- a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpointTest.java +++ b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpointTest.java @@ -87,10 +87,13 @@ void getReturnsVariantsSortedById() throws RPCException { Map.of("title", "Campaign") ); when(content.byPath("about.md")).thenReturn(Optional.of(node)); - when(variantResolver.getVariants(node)).thenReturn(List.of( + var variants = List.of( new VariantResolver.Variant("summer", summerNode), new VariantResolver.Variant("campaign", campaignNode) - )); + ); + when(variantResolver.resolveContext(node)).thenReturn( + new VariantResolver.VariantContext(node, Optional.empty(), variants) + ); @SuppressWarnings("unchecked") var result = (Map) endpoint.get(Map.of("uri", "about.md")); @@ -105,13 +108,40 @@ void getReturnsVariantsSortedById() throws RPCException { "/.variants/about/campaign/about?preview=manager", "/.variants/about/summer/about?preview=manager" ); + assertThat(result).containsEntry("activeVariantId", null); + } + + @Test + void getFromVariantReturnsCanonicalAndActiveVariant() throws RPCException { + var canonical = node("about.md", "/about", Map.of("title", "About")); + var summer = node( + ".variants/about/summer/about.md", + "/.variants/about/summer/about", + Map.of("title", "Summer") + ); + var variants = List.of(new VariantResolver.Variant("summer", summer)); + when(content.byPath(summer.path())).thenReturn(Optional.of(summer)); + when(variantResolver.resolveContext(summer)).thenReturn( + new VariantResolver.VariantContext(canonical, Optional.of("summer"), variants) + ); + + @SuppressWarnings("unchecked") + var result = (Map) endpoint.get(Map.of("uri", summer.path())); + + assertThat(result) + .containsEntry("uri", "about.md") + .containsEntry("activeVariantId", "summer"); + assertThat((Map) result.get("canonical")) + .containsEntry("url", "/about?preview=manager"); } @Test void getReturnsEmptyListWhenNodeHasNoVariants() throws RPCException { var node = node("about.md", "/about", Map.of()); when(content.byPath("about.md")).thenReturn(Optional.of(node)); - when(variantResolver.getVariants(node)).thenReturn(List.of()); + when(variantResolver.resolveContext(node)).thenReturn( + new VariantResolver.VariantContext(node, Optional.empty(), List.of()) + ); @SuppressWarnings("unchecked") var result = (Map) endpoint.get(Map.of("uri", "about.md")); diff --git a/test-server/hosts/demo/content/.variants/about/black-friday/about.md b/test-server/hosts/demo/content/.variants/about/black-friday/about.md new file mode 100644 index 000000000..f54c8d2f3 --- /dev/null +++ b/test-server/hosts/demo/content/.variants/about/black-friday/about.md @@ -0,0 +1,9 @@ +--- +template: default.html +createdAt: 2026-07-29T12:59:22.845Z +createdBy: test +title: Back Friday sales party +status: draft +--- + +thats the friday black sale diff --git a/test-server/hosts/demo/content/index.asection.bla.md b/test-server/hosts/demo/content/index.asection.bla.md index 2a3f8cdc9..326cafdfd 100644 --- a/test-server/hosts/demo/content/index.asection.bla.md +++ b/test-server/hosts/demo/content/index.asection.bla.md @@ -24,7 +24,7 @@ publish_date: null about1: more content more better --- -# This is a section: bla +# This is a section: bla bla And no i can edit the content From cd631b06a9946f9245386a0595e839000f335926 Mon Sep 17 00:00:00 2001 From: Thorsten Marx Date: Wed, 29 Jul 2026 15:27:51 +0200 Subject: [PATCH 06/16] variations test --- .../api/workflow/DefaultWFStatusProvider.java | 3 +-- .../cms/api/workflow/WFStatusProvider.java | 5 +---- .../workflow/DefaultWFStatusProviderTest.java | 12 +++++++++++ .../cms/content/DefaultVariantSelector.java | 3 ++- .../content/DefaultVariantSelectorTest.java | 21 +++++++++++++++++++ .../RemoteWorkflowEndpointsExtension.java | 2 +- test-server/hosts/demo/content/about.md | 2 ++ 7 files changed, 40 insertions(+), 8 deletions(-) diff --git a/cms-api/src/main/java/com/condation/cms/api/workflow/DefaultWFStatusProvider.java b/cms-api/src/main/java/com/condation/cms/api/workflow/DefaultWFStatusProvider.java index 809faf34a..3e3f40ee9 100644 --- a/cms-api/src/main/java/com/condation/cms/api/workflow/DefaultWFStatusProvider.java +++ b/cms-api/src/main/java/com/condation/cms/api/workflow/DefaultWFStatusProvider.java @@ -24,7 +24,6 @@ import com.condation.cms.api.Constants; import com.condation.cms.api.db.ContentNode; import com.condation.cms.api.utils.DateRange; -import java.time.Instant; import java.util.Date; /** @@ -44,7 +43,7 @@ public boolean isPublished(ContentNode node) { @Override public Status status(ContentNode node) { var published = isPublished(node); - var publish_date = (Date) node.data().getOrDefault(Constants.MetaFields.PUBLISH_DATE, Date.from(Instant.now())); + var publish_date = (Date) node.data().getOrDefault(Constants.MetaFields.PUBLISH_DATE, null); var unpublish_date = (Date) node.data().getOrDefault(Constants.MetaFields.UNPUBLISH_DATE, null); return new Status(published, publish_date, unpublish_date, DateRange.isNowWithin(publish_date, unpublish_date), statusValue(node)); diff --git a/cms-api/src/main/java/com/condation/cms/api/workflow/WFStatusProvider.java b/cms-api/src/main/java/com/condation/cms/api/workflow/WFStatusProvider.java index 7c4c21042..fd74b87dd 100644 --- a/cms-api/src/main/java/com/condation/cms/api/workflow/WFStatusProvider.java +++ b/cms-api/src/main/java/com/condation/cms/api/workflow/WFStatusProvider.java @@ -2,10 +2,7 @@ import com.condation.cms.api.db.ContentNode; import com.condation.cms.api.utils.DateRange; -import java.time.Instant; -import java.time.LocalDate; import java.util.Date; -import org.jspecify.annotations.NonNull; /*- * #%L @@ -40,5 +37,5 @@ public interface WFStatusProvider { String newNodeStatus (); - public static record Status (boolean published, @NonNull Date publish_date, Date unpublish_date, boolean withinSchedule, String currentStage){}; + public static record Status (boolean published, Date publish_date, Date unpublish_date, boolean withinSchedule, String currentStage){}; } diff --git a/cms-api/src/test/java/com/condation/cms/api/workflow/DefaultWFStatusProviderTest.java b/cms-api/src/test/java/com/condation/cms/api/workflow/DefaultWFStatusProviderTest.java index c5c474005..df53f2023 100644 --- a/cms-api/src/test/java/com/condation/cms/api/workflow/DefaultWFStatusProviderTest.java +++ b/cms-api/src/test/java/com/condation/cms/api/workflow/DefaultWFStatusProviderTest.java @@ -37,6 +37,18 @@ * @author thorstenmarx */ public class DefaultWFStatusProviderTest { + + @Test + public void missingPublishDateRemainsUnsetAndHasNoStartLimit() { + var contentNode = new ContentNode("", "", "", Map.of( + Constants.MetaFields.STATUS, DefaultWFStatusProvider.STATUS_PUBLISHED + )); + + var status = new DefaultWFStatusProvider().status(contentNode); + + Assertions.assertThat(status.publish_date()).isNull(); + Assertions.assertThat(status.withinSchedule()).isTrue(); + } @Test public void test_publish_date_1_11_2023() { diff --git a/cms-content/src/main/java/com/condation/cms/content/DefaultVariantSelector.java b/cms-content/src/main/java/com/condation/cms/content/DefaultVariantSelector.java index d653da2c6..ac081815a 100644 --- a/cms-content/src/main/java/com/condation/cms/content/DefaultVariantSelector.java +++ b/cms-content/src/main/java/com/condation/cms/content/DefaultVariantSelector.java @@ -88,7 +88,8 @@ protected VariantSelection selectAutomatically( .filter(variant -> variant.status().published()) .filter(variant -> variant.status().withinSchedule()) .max(Comparator.comparing( - variant -> variant.status().publish_date() + variant -> variant.status().publish_date(), + Comparator.nullsFirst(Comparator.naturalOrder()) )) .map(ScheduledVariant::variant) .map(VariantSelection::automatic) diff --git a/cms-content/src/test/java/com/condation/cms/content/DefaultVariantSelectorTest.java b/cms-content/src/test/java/com/condation/cms/content/DefaultVariantSelectorTest.java index fa70b2efa..24dbb9e3f 100644 --- a/cms-content/src/test/java/com/condation/cms/content/DefaultVariantSelectorTest.java +++ b/cms-content/src/test/java/com/condation/cms/content/DefaultVariantSelectorTest.java @@ -135,6 +135,27 @@ public void automaticSelectionUsesPublishedVariantWithinSchedule() { Assertions.assertThat(selection.variant()).contains(active); } + @Test + public void automaticSelectionSupportsPublishedVariantWithoutSchedule() { + var unscheduled = new VariantResolver.Variant( + "unscheduled", + new ContentNode( + ".variants/about/unscheduled/about.md", + "/.variants/about/unscheduled/about", + "about.md", + Map.of(Constants.MetaFields.STATUS, "published") + ) + ); + + var selection = selector.select( + canonicalNode, + List.of(unscheduled), + context(false, null) + ); + + Assertions.assertThat(selection.variant()).contains(unscheduled); + } + @Test public void automaticSelectionIgnoresDraftAndVariantsOutsideSchedule() { var draft = scheduledVariant( diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteWorkflowEndpointsExtension.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteWorkflowEndpointsExtension.java index 6a282202a..896f38fe9 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteWorkflowEndpointsExtension.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteWorkflowEndpointsExtension.java @@ -62,7 +62,7 @@ private Optional getContentNode(String uri) { var node_uri = PathUtil.toRelativeFile(contentFile, contentBase); - var node = db.getContent().byUri(node_uri); + var node = db.getContent().byPath(node_uri); if (node.isEmpty()) { return Optional.empty(); } diff --git a/test-server/hosts/demo/content/about.md b/test-server/hosts/demo/content/about.md index fcf5fad1e..b144abe76 100644 --- a/test-server/hosts/demo/content/about.md +++ b/test-server/hosts/demo/content/about.md @@ -12,6 +12,8 @@ range_test: 0 status: published translations: de: /ueber +unpublish_date: null +publish_date: null --- # About From 41e8863d9a840de59d60f07e0e805c498fe77fae Mon Sep 17 00:00:00 2001 From: Thorsten Marx Date: Wed, 29 Jul 2026 16:11:16 +0200 Subject: [PATCH 07/16] variation selection config --- .../VariantSelectorExtensionPoint.java | 40 ++++ .../condation/cms/api/variants/Variant.java | 30 +++ .../cms/api/variants}/VariantSelection.java | 15 +- .../cms/api/variants}/VariantSelector.java | 8 +- .../content/ConfigurableVariantSelector.java | 171 ++++++++++++++++++ .../cms/content/ContentResolver.java | 4 +- .../cms/content/DefaultVariantSelector.java | 69 +------ .../cms/content/VariantResolver.java | 3 +- ...ariantSelectorConfigurationRepository.java | 97 ++++++++++ .../ConfigurableVariantSelectorTest.java | 133 ++++++++++++++ .../content/DefaultVariantSelectorTest.java | 110 ++--------- ...ntSelectorConfigurationRepositoryTest.java | 79 ++++++++ .../cms/server/configs/SiteModule.java | 30 ++- .../ui/extensionpoints/PageMenuExtension.java | 12 ++ .../remotemethods/RemoteVariantEndpoint.java | 62 ++++++- .../actions/page/variant-selector.d.ts | 21 +++ .../manager/actions/page/variant-selector.js | 80 ++++++++ .../manager/js/modules/rpc/rpc-variant.d.ts | 18 +- .../manager/js/modules/rpc/rpc-variant.js | 16 +- .../ts/src/actions/page/variant-selector.ts | 102 +++++++++++ .../main/ts/src/js/modules/rpc/rpc-variant.ts | 28 ++- .../RemoteVariantEndpointTest.java | 59 +++++- .../content/.variants/about/variants.yaml | 1 + .../.variants/about/winter-campain/about.md | 2 +- 24 files changed, 997 insertions(+), 193 deletions(-) create mode 100644 cms-api/src/main/java/com/condation/cms/api/extensions/VariantSelectorExtensionPoint.java create mode 100644 cms-api/src/main/java/com/condation/cms/api/variants/Variant.java rename {cms-content/src/main/java/com/condation/cms/content => cms-api/src/main/java/com/condation/cms/api/variants}/VariantSelection.java (78%) rename {cms-content/src/main/java/com/condation/cms/content => cms-api/src/main/java/com/condation/cms/api/variants}/VariantSelector.java (89%) create mode 100644 cms-content/src/main/java/com/condation/cms/content/ConfigurableVariantSelector.java create mode 100644 cms-content/src/main/java/com/condation/cms/content/VariantSelectorConfigurationRepository.java create mode 100644 cms-content/src/test/java/com/condation/cms/content/ConfigurableVariantSelectorTest.java create mode 100644 cms-content/src/test/java/com/condation/cms/content/VariantSelectorConfigurationRepositoryTest.java create mode 100644 modules/ui-module/src/main/resources/manager/actions/page/variant-selector.d.ts create mode 100644 modules/ui-module/src/main/resources/manager/actions/page/variant-selector.js create mode 100644 modules/ui-module/src/main/ts/src/actions/page/variant-selector.ts create mode 100644 test-server/hosts/demo/content/.variants/about/variants.yaml diff --git a/cms-api/src/main/java/com/condation/cms/api/extensions/VariantSelectorExtensionPoint.java b/cms-api/src/main/java/com/condation/cms/api/extensions/VariantSelectorExtensionPoint.java new file mode 100644 index 000000000..26913a2a7 --- /dev/null +++ b/cms-api/src/main/java/com/condation/cms/api/extensions/VariantSelectorExtensionPoint.java @@ -0,0 +1,40 @@ +package com.condation.cms.api.extensions; + +/*- + * #%L + * CMS Api + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import com.condation.cms.api.variants.VariantSelector; + +/** + * Extension point for automatic variant selection strategies. + * + *

Manager and explicit preview selection are handled by the CMS before an + * extension is invoked. Implementations therefore only select a variant for a + * normal public request.

+ */ +public abstract class VariantSelectorExtensionPoint + extends AbstractExtensionPoint + implements VariantSelector { + + public abstract String id(); + + public abstract String label(); +} diff --git a/cms-api/src/main/java/com/condation/cms/api/variants/Variant.java b/cms-api/src/main/java/com/condation/cms/api/variants/Variant.java new file mode 100644 index 000000000..5241a3dea --- /dev/null +++ b/cms-api/src/main/java/com/condation/cms/api/variants/Variant.java @@ -0,0 +1,30 @@ +package com.condation.cms.api.variants; + +/*- + * #%L + * CMS Api + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import com.condation.cms.api.db.ContentNode; + +/** + * A named variant of a canonical content node. + */ +public record Variant(String id, ContentNode node) { +} diff --git a/cms-content/src/main/java/com/condation/cms/content/VariantSelection.java b/cms-api/src/main/java/com/condation/cms/api/variants/VariantSelection.java similarity index 78% rename from cms-content/src/main/java/com/condation/cms/content/VariantSelection.java rename to cms-api/src/main/java/com/condation/cms/api/variants/VariantSelection.java index b231aa427..3fcc3a653 100644 --- a/cms-content/src/main/java/com/condation/cms/content/VariantSelection.java +++ b/cms-api/src/main/java/com/condation/cms/api/variants/VariantSelection.java @@ -1,8 +1,8 @@ -package com.condation.cms.content; +package com.condation.cms.api.variants; /*- * #%L - * CMS Content + * CMS Api * %% * Copyright (C) 2023 - 2026 CondationCMS * %% @@ -25,23 +25,18 @@ /** * Describes the result and origin of a variant selection. - * - * @author thorstenmarx */ -public record VariantSelection( - Optional variant, - Source source -) { +public record VariantSelection(Optional variant, Source source) { public static VariantSelection canonical() { return new VariantSelection(Optional.empty(), Source.CANONICAL); } - public static VariantSelection preview(VariantResolver.Variant variant) { + public static VariantSelection preview(Variant variant) { return new VariantSelection(Optional.of(variant), Source.PREVIEW); } - public static VariantSelection automatic(VariantResolver.Variant variant) { + public static VariantSelection automatic(Variant variant) { return new VariantSelection(Optional.of(variant), Source.AUTOMATIC); } diff --git a/cms-content/src/main/java/com/condation/cms/content/VariantSelector.java b/cms-api/src/main/java/com/condation/cms/api/variants/VariantSelector.java similarity index 89% rename from cms-content/src/main/java/com/condation/cms/content/VariantSelector.java rename to cms-api/src/main/java/com/condation/cms/api/variants/VariantSelector.java index 1caf1f124..585d97c71 100644 --- a/cms-content/src/main/java/com/condation/cms/content/VariantSelector.java +++ b/cms-api/src/main/java/com/condation/cms/api/variants/VariantSelector.java @@ -1,8 +1,8 @@ -package com.condation.cms.content; +package com.condation.cms.api.variants; /*- * #%L - * CMS Content + * CMS Api * %% * Copyright (C) 2023 - 2026 CondationCMS * %% @@ -27,14 +27,12 @@ /** * Selects the content variant to render for a request. - * - * @author thorstenmarx */ public interface VariantSelector { VariantSelection select( ContentNode canonicalNode, - List variants, + List variants, RequestContext context ); } diff --git a/cms-content/src/main/java/com/condation/cms/content/ConfigurableVariantSelector.java b/cms-content/src/main/java/com/condation/cms/content/ConfigurableVariantSelector.java new file mode 100644 index 000000000..8e0e14a43 --- /dev/null +++ b/cms-content/src/main/java/com/condation/cms/content/ConfigurableVariantSelector.java @@ -0,0 +1,171 @@ +package com.condation.cms.content; + +/*- + * #%L + * CMS Content + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import com.condation.cms.api.db.ContentNode; +import com.condation.cms.api.feature.features.IsPreviewFeature; +import com.condation.cms.api.feature.features.RequestFeature; +import com.condation.cms.api.request.RequestContext; +import com.condation.cms.api.extensions.VariantSelectorExtensionPoint; +import com.condation.cms.api.variants.Variant; +import com.condation.cms.api.variants.VariantSelection; +import com.condation.cms.api.variants.VariantSelector; +import com.condation.modules.api.ModuleManager; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; + +/** + * Applies CMS-owned manager and preview rules before delegating public + * requests to the selector configured for the canonical page. + */ +@Slf4j +public class ConfigurableVariantSelector implements VariantSelector { + + public static final String DATE_RANGE_LABEL = "Date range"; + public static final String VARIANT_QUERY_PARAMETER = "variant"; + public static final String CANONICAL_VARIANT_ID = "default"; + + private final DefaultVariantSelector dateRangeSelector; + private final VariantSelectorConfigurationRepository configurationRepository; + private final ModuleManager moduleManager; + + public ConfigurableVariantSelector( + DefaultVariantSelector dateRangeSelector, + VariantSelectorConfigurationRepository configurationRepository, + ModuleManager moduleManager + ) { + this.dateRangeSelector = dateRangeSelector; + this.configurationRepository = configurationRepository; + this.moduleManager = moduleManager; + } + + @Override + public VariantSelection select( + ContentNode canonicalNode, + List variants, + RequestContext context + ) { + if (isManager(context)) { + return VariantSelection.canonical(); + } + if (hasExplicitPreviewSelection(context)) { + return selectPreviewVariant(canonicalNode, variants, context.get(RequestFeature.class)); + } + + var selectorId = configurationRepository.getSelectorId(canonicalNode); + var selector = selectors().get(selectorId); + if (selector == null) { + log.warn( + "Configured variant selector '{}' is unavailable for '{}'; using '{}'", + selectorId, + canonicalNode.path(), + VariantSelectorConfigurationRepository.DEFAULT_SELECTOR_ID + ); + selector = dateRangeSelector; + } + + try { + return selector.select(canonicalNode, variants, context); + } catch (RuntimeException exception) { + log.error( + "Variant selector '{}' failed for '{}'; using date-range fallback", + selectorId, + canonicalNode.path(), + exception + ); + return dateRangeSelector.select(canonicalNode, variants, context); + } + } + + public Map availableSelectors() { + Map descriptors = new LinkedHashMap<>(); + descriptors.put( + VariantSelectorConfigurationRepository.DEFAULT_SELECTOR_ID, + new SelectorDescriptor( + VariantSelectorConfigurationRepository.DEFAULT_SELECTOR_ID, + DATE_RANGE_LABEL + ) + ); + moduleManager.extensions(VariantSelectorExtensionPoint.class).forEach(extension -> + descriptors.putIfAbsent( + extension.id(), + new SelectorDescriptor(extension.id(), extension.label()) + ) + ); + return Map.copyOf(descriptors); + } + + public boolean hasSelector(String selectorId) { + return availableSelectors().containsKey(selectorId); + } + + private Map selectors() { + Map selectors = new LinkedHashMap<>(); + selectors.put(VariantSelectorConfigurationRepository.DEFAULT_SELECTOR_ID, dateRangeSelector); + moduleManager.extensions(VariantSelectorExtensionPoint.class).forEach(extension -> + selectors.putIfAbsent(extension.id(), extension) + ); + return selectors; + } + + private boolean isManager(RequestContext context) { + return context.has(IsPreviewFeature.class) + && IsPreviewFeature.Mode.MANAGER.equals(context.get(IsPreviewFeature.class).mode()); + } + + private boolean hasExplicitPreviewSelection(RequestContext context) { + return context.has(IsPreviewFeature.class) + && IsPreviewFeature.Mode.PREVIEW.equals(context.get(IsPreviewFeature.class).mode()) + && context.has(RequestFeature.class) + && context.get(RequestFeature.class) + .hasQueryParameter(VARIANT_QUERY_PARAMETER); + } + + private VariantSelection selectPreviewVariant( + ContentNode canonicalNode, + List variants, + RequestFeature request + ) { + var variantId = request.getQueryParameter(VARIANT_QUERY_PARAMETER, "").trim(); + if (variantId.isBlank() || CANONICAL_VARIANT_ID.equalsIgnoreCase(variantId)) { + return VariantSelection.canonical(); + } + + return variants.stream() + .filter(variant -> variant.id().equals(variantId)) + .findFirst() + .map(VariantSelection::preview) + .orElseGet(() -> { + log.warn( + "Requested preview variant '{}' does not exist for '{}'", + variantId, + canonicalNode.path() + ); + return VariantSelection.canonical(); + }); + } + + public record SelectorDescriptor(String id, String label) { + } +} diff --git a/cms-content/src/main/java/com/condation/cms/content/ContentResolver.java b/cms-content/src/main/java/com/condation/cms/content/ContentResolver.java index 10d3ab430..1772594be 100644 --- a/cms-content/src/main/java/com/condation/cms/content/ContentResolver.java +++ b/cms-content/src/main/java/com/condation/cms/content/ContentResolver.java @@ -31,6 +31,8 @@ import com.condation.cms.api.feature.features.CurrentNodeFeature; import com.condation.cms.api.feature.features.RequestFeature; import com.condation.cms.api.request.RequestContext; +import com.condation.cms.api.variants.Variant; +import com.condation.cms.api.variants.VariantSelector; import com.condation.cms.api.utils.HTTPUtil; import com.condation.cms.core.content.ContentResolvingStrategy; import java.io.IOException; @@ -125,7 +127,7 @@ private Optional getContent(final RequestContext context, boole context ); var selectedNode = selection.variant() - .map(VariantResolver.Variant::node) + .map(Variant::node) .filter(node -> !checkVisibility || NodeVisibility.isVisible(node)) .orElse(contentNode); diff --git a/cms-content/src/main/java/com/condation/cms/content/DefaultVariantSelector.java b/cms-content/src/main/java/com/condation/cms/content/DefaultVariantSelector.java index ac081815a..a077a9209 100644 --- a/cms-content/src/main/java/com/condation/cms/content/DefaultVariantSelector.java +++ b/cms-content/src/main/java/com/condation/cms/content/DefaultVariantSelector.java @@ -22,60 +22,27 @@ */ import com.condation.cms.api.db.ContentNode; -import com.condation.cms.api.feature.features.IsPreviewFeature; -import com.condation.cms.api.feature.features.RequestFeature; import com.condation.cms.api.feature.features.WorkflowFeature; import com.condation.cms.api.request.RequestContext; +import com.condation.cms.api.variants.Variant; +import com.condation.cms.api.variants.VariantSelection; +import com.condation.cms.api.variants.VariantSelector; import com.condation.cms.api.workflow.DefaultWFStatusProvider; import com.condation.cms.api.workflow.WFStatusProvider; import java.util.Comparator; import java.util.List; -import lombok.extern.slf4j.Slf4j; /** - * Default variant selection with support for preview overrides. + * Selects the newest published variant within its configured date range. * * @author thorstenmarx */ -@Slf4j public class DefaultVariantSelector implements VariantSelector { - public static final String VARIANT_QUERY_PARAMETER = "variant"; - public static final String CANONICAL_VARIANT_ID = "default"; - @Override public VariantSelection select( ContentNode canonicalNode, - List variants, - RequestContext context - ) { - if (isManager(context)) { - return VariantSelection.canonical(); - } - - if (isPreview(context) && context.has(RequestFeature.class)) { - var request = context.get(RequestFeature.class); - if (request.hasQueryParameter(VARIANT_QUERY_PARAMETER)) { - return selectPreviewVariant(canonicalNode, variants, request); - } - } - - return selectAutomatically(canonicalNode, variants, context); - } - - private boolean isPreview (RequestContext context) { - return context.has(IsPreviewFeature.class) - && IsPreviewFeature.Mode.PREVIEW.equals(context.get(IsPreviewFeature.class).mode()); - } - - private boolean isManager(RequestContext context) { - return context.has(IsPreviewFeature.class) - && IsPreviewFeature.Mode.MANAGER.equals(context.get(IsPreviewFeature.class).mode()); - } - - protected VariantSelection selectAutomatically( - ContentNode canonicalNode, - List variants, + List variants, RequestContext context ) { var statusProvider = getStatusProvider(context); @@ -105,32 +72,8 @@ private WFStatusProvider getStatusProvider(RequestContext context) { return new DefaultWFStatusProvider(); } - private VariantSelection selectPreviewVariant( - ContentNode canonicalNode, - List variants, - RequestFeature request - ) { - var variantId = request.getQueryParameter(VARIANT_QUERY_PARAMETER, "").trim(); - if (variantId.isBlank() || CANONICAL_VARIANT_ID.equalsIgnoreCase(variantId)) { - return VariantSelection.canonical(); - } - - return variants.stream() - .filter(variant -> variant.id().equals(variantId)) - .findFirst() - .map(VariantSelection::preview) - .orElseGet(() -> { - log.warn( - "Requested preview variant '{}' does not exist for '{}'", - variantId, - canonicalNode.path() - ); - return VariantSelection.canonical(); - }); - } - private record ScheduledVariant( - VariantResolver.Variant variant, + Variant variant, WFStatusProvider.Status status ) { } diff --git a/cms-content/src/main/java/com/condation/cms/content/VariantResolver.java b/cms-content/src/main/java/com/condation/cms/content/VariantResolver.java index d412076fb..64b9fb94a 100644 --- a/cms-content/src/main/java/com/condation/cms/content/VariantResolver.java +++ b/cms-content/src/main/java/com/condation/cms/content/VariantResolver.java @@ -25,6 +25,7 @@ import com.condation.cms.api.db.DB; import com.condation.cms.api.db.cms.ReadOnlyFile; import com.condation.cms.api.utils.PathUtil; +import com.condation.cms.api.variants.Variant; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; @@ -128,8 +129,6 @@ private Optional variantLocation(ContentNode node) { return Optional.empty(); } - public record Variant (String id, ContentNode node) {} - public record VariantContext( ContentNode canonical, Optional activeVariantId, diff --git a/cms-content/src/main/java/com/condation/cms/content/VariantSelectorConfigurationRepository.java b/cms-content/src/main/java/com/condation/cms/content/VariantSelectorConfigurationRepository.java new file mode 100644 index 000000000..24747366b --- /dev/null +++ b/cms-content/src/main/java/com/condation/cms/content/VariantSelectorConfigurationRepository.java @@ -0,0 +1,97 @@ +package com.condation.cms.content; + +/*- + * #%L + * CMS Content + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import com.condation.cms.api.Constants; +import com.condation.cms.api.db.ContentNode; +import com.condation.cms.api.db.DB; +import com.condation.cms.core.content.io.YamlHeaderUpdater; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.constructor.SafeConstructor; + +/** + * Loads and stores the selector configured for a canonical page. + */ +@Slf4j +@RequiredArgsConstructor +public class VariantSelectorConfigurationRepository { + + public static final String DEFAULT_SELECTOR_ID = "date-range"; + public static final String CONFIG_FILE_NAME = "variants.yaml"; + private static final String SELECTOR_PROPERTY = "selector"; + + private final DB db; + private final VariantResolver variantResolver; + + public String getSelectorId(ContentNode node) { + var configurationFile = configurationFile(node); + if (!Files.exists(configurationFile)) { + return DEFAULT_SELECTOR_ID; + } + + try { + var yaml = new Yaml(new SafeConstructor(new LoaderOptions())); + var data = yaml.load(Files.readString(configurationFile)); + if (data instanceof Map map + && map.get(SELECTOR_PROPERTY) instanceof String selector + && !selector.isBlank()) { + return selector.trim(); + } + } catch (Exception exception) { + log.error("Could not read variant selector configuration '{}'", configurationFile, exception); + } + return DEFAULT_SELECTOR_ID; + } + + public void setSelectorId(ContentNode node, String selectorId) throws IOException { + if (selectorId == null || selectorId.isBlank()) { + throw new IllegalArgumentException("selectorId must not be blank"); + } + var configurationFile = configurationFile(node); + Files.createDirectories(configurationFile.getParent()); + YamlHeaderUpdater.saveMetaData( + configurationFile, + Map.of(SELECTOR_PROPERTY, selectorId.trim()) + ); + } + + public Path configurationFile(ContentNode node) { + var canonical = variantResolver.resolveContext(node).canonical(); + var contentBase = db.getFileSystem().resolve(Constants.Folders.CONTENT); + var canonicalFile = contentBase.resolve(canonical.path()); + var fileName = canonicalFile.getFileName().toString(); + var pageName = fileName.endsWith(".md") + ? fileName.substring(0, fileName.length() - 3) + : fileName; + return canonicalFile.getParent() + .resolve(".variants") + .resolve(pageName) + .resolve(CONFIG_FILE_NAME); + } +} diff --git a/cms-content/src/test/java/com/condation/cms/content/ConfigurableVariantSelectorTest.java b/cms-content/src/test/java/com/condation/cms/content/ConfigurableVariantSelectorTest.java new file mode 100644 index 000000000..2cb6088cf --- /dev/null +++ b/cms-content/src/test/java/com/condation/cms/content/ConfigurableVariantSelectorTest.java @@ -0,0 +1,133 @@ +package com.condation.cms.content; + +/*- + * #%L + * CMS Content + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.condation.cms.api.db.ContentNode; +import com.condation.cms.api.feature.features.IsPreviewFeature; +import com.condation.cms.api.feature.features.RequestFeature; +import com.condation.cms.api.request.RequestContext; +import com.condation.cms.api.extensions.VariantSelectorExtensionPoint; +import com.condation.cms.api.variants.Variant; +import com.condation.cms.api.variants.VariantSelection; +import com.condation.modules.api.ModuleManager; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class ConfigurableVariantSelectorTest { + + private final ContentNode canonical = new ContentNode("about.md", "/about", "about.md", Map.of()); + private final Variant summer = new Variant( + "summer", + new ContentNode(".variants/about/summer/about.md", "/summer", "about.md", Map.of()) + ); + private final List variants = List.of(summer); + private DefaultVariantSelector dateRange; + private VariantSelectorConfigurationRepository configuration; + private VariantSelectorExtensionPoint extension; + private ConfigurableVariantSelector selector; + + @BeforeEach + void setUp() { + dateRange = mock(DefaultVariantSelector.class); + configuration = mock(VariantSelectorConfigurationRepository.class); + extension = mock(VariantSelectorExtensionPoint.class); + var moduleManager = mock(ModuleManager.class); + when(extension.id()).thenReturn("audience"); + when(extension.label()).thenReturn("Audience"); + when(moduleManager.extensions(VariantSelectorExtensionPoint.class)).thenReturn(List.of(extension)); + selector = new ConfigurableVariantSelector(dateRange, configuration, moduleManager); + } + + @Test + void publicRequestsUseConfiguredModuleSelector() { + var context = mock(RequestContext.class); + var expected = VariantSelection.canonical(); + when(configuration.getSelectorId(canonical)).thenReturn("audience"); + when(extension.select(canonical, variants, context)).thenReturn(expected); + + assertThat(selector.select(canonical, variants, context)).isSameAs(expected); + verify(extension).select(canonical, variants, context); + } + + @Test + void managerRequestsAlwaysUseCentralSelectionLogic() { + var context = context(IsPreviewFeature.Mode.MANAGER, null); + + assertThat(selector.select(canonical, variants, context)) + .isEqualTo(VariantSelection.canonical()); + verify(configuration, never()).getSelectorId(canonical); + verify(dateRange, never()).select(canonical, variants, context); + verify(extension, never()).select(canonical, variants, context); + } + + @Test + void explicitPreviewSelectsVariantWithoutInvokingASelector() { + var context = context(IsPreviewFeature.Mode.PREVIEW, "summer"); + + assertThat(selector.select(canonical, variants, context)) + .isEqualTo(VariantSelection.preview(summer)); + verify(configuration, never()).getSelectorId(canonical); + verify(dateRange, never()).select(canonical, variants, context); + verify(extension, never()).select(canonical, variants, context); + } + + @Test + void explicitPreviewCanForceCanonicalContent() { + var context = context( + IsPreviewFeature.Mode.PREVIEW, + ConfigurableVariantSelector.CANONICAL_VARIANT_ID + ); + + assertThat(selector.select(canonical, variants, context)) + .isEqualTo(VariantSelection.canonical()); + verify(dateRange, never()).select(canonical, variants, context); + verify(extension, never()).select(canonical, variants, context); + } + + @Test + void exposesBuiltInAndModuleSelectors() { + assertThat(selector.availableSelectors()) + .containsKeys(VariantSelectorConfigurationRepository.DEFAULT_SELECTOR_ID, "audience"); + } + + private RequestContext context(IsPreviewFeature.Mode mode, String variantId) { + var context = mock(RequestContext.class); + var request = mock(RequestFeature.class); + when(context.has(IsPreviewFeature.class)).thenReturn(true); + when(context.get(IsPreviewFeature.class)).thenReturn(new IsPreviewFeature(mode)); + when(context.has(RequestFeature.class)).thenReturn(true); + when(context.get(RequestFeature.class)).thenReturn(request); + when(request.hasQueryParameter(ConfigurableVariantSelector.VARIANT_QUERY_PARAMETER)) + .thenReturn(variantId != null); + when(request.getQueryParameter(ConfigurableVariantSelector.VARIANT_QUERY_PARAMETER, "")) + .thenReturn(variantId == null ? "" : variantId); + return context; + } +} diff --git a/cms-content/src/test/java/com/condation/cms/content/DefaultVariantSelectorTest.java b/cms-content/src/test/java/com/condation/cms/content/DefaultVariantSelectorTest.java index 24dbb9e3f..f50629f62 100644 --- a/cms-content/src/test/java/com/condation/cms/content/DefaultVariantSelectorTest.java +++ b/cms-content/src/test/java/com/condation/cms/content/DefaultVariantSelectorTest.java @@ -23,9 +23,9 @@ import com.condation.cms.api.Constants; import com.condation.cms.api.db.ContentNode; -import com.condation.cms.api.feature.features.IsPreviewFeature; -import com.condation.cms.api.feature.features.RequestFeature; import com.condation.cms.api.request.RequestContext; +import com.condation.cms.api.variants.Variant; +import com.condation.cms.api.variants.VariantSelection; import java.time.Duration; import java.time.Instant; import java.util.Date; @@ -39,83 +39,6 @@ public class DefaultVariantSelectorTest { private final DefaultVariantSelector selector = new DefaultVariantSelector(); private final ContentNode canonicalNode = node("about.md"); - private final VariantResolver.Variant summer = new VariantResolver.Variant( - "summer", - node(".variants/about/summer/about.md") - ); - - @Test - public void previewCanSelectVariant() { - var selection = selector.select( - canonicalNode, - List.of(summer), - context(true, "summer") - ); - - Assertions.assertThat(selection.source()).isEqualTo(VariantSelection.Source.PREVIEW); - Assertions.assertThat(selection.variant()).contains(summer); - } - - @Test - public void previewCanForceCanonicalVariant() { - var selection = selector.select( - canonicalNode, - List.of(summer), - context(true, DefaultVariantSelector.CANONICAL_VARIANT_ID) - ); - - Assertions.assertThat(selection.source()).isEqualTo(VariantSelection.Source.CANONICAL); - Assertions.assertThat(selection.variant()).isEmpty(); - } - - @Test - public void managerAlwaysUsesCanonicalVariant() { - var active = scheduledVariant( - "active", - Instant.now().minus(Duration.ofDays(1)), - Instant.now().plus(Duration.ofDays(1)), - "published" - ); - var context = context(false, "active"); - context.add( - IsPreviewFeature.class, - new IsPreviewFeature(IsPreviewFeature.Mode.MANAGER) - ); - - var selection = selector.select( - canonicalNode, - List.of(active), - context - ); - - Assertions.assertThat(selection.source()).isEqualTo(VariantSelection.Source.CANONICAL); - Assertions.assertThat(selection.variant()).isEmpty(); - } - - @Test - public void invalidPreviewVariantFallsBackToCanonicalVariant() { - var selection = selector.select( - canonicalNode, - List.of(summer), - context(true, "missing") - ); - - Assertions.assertThat(selection.source()).isEqualTo(VariantSelection.Source.CANONICAL); - Assertions.assertThat(selection.variant()).isEmpty(); - } - - @Test - public void publicRequestCannotOverrideVariant() { - var selection = selector.select( - canonicalNode, - List.of(summer), - context(false, "summer") - ); - - Assertions.assertThat(selection.source()).isEqualTo(VariantSelection.Source.CANONICAL); - Assertions.assertThat(selection.variant()).isEmpty(); - } - @Test public void automaticSelectionUsesPublishedVariantWithinSchedule() { var active = scheduledVariant( @@ -128,7 +51,7 @@ public void automaticSelectionUsesPublishedVariantWithinSchedule() { var selection = selector.select( canonicalNode, List.of(active), - context(false, null) + context() ); Assertions.assertThat(selection.source()).isEqualTo(VariantSelection.Source.AUTOMATIC); @@ -137,7 +60,7 @@ public void automaticSelectionUsesPublishedVariantWithinSchedule() { @Test public void automaticSelectionSupportsPublishedVariantWithoutSchedule() { - var unscheduled = new VariantResolver.Variant( + var unscheduled = new Variant( "unscheduled", new ContentNode( ".variants/about/unscheduled/about.md", @@ -150,7 +73,7 @@ public void automaticSelectionSupportsPublishedVariantWithoutSchedule() { var selection = selector.select( canonicalNode, List.of(unscheduled), - context(false, null) + context() ); Assertions.assertThat(selection.variant()).contains(unscheduled); @@ -180,7 +103,7 @@ public void automaticSelectionIgnoresDraftAndVariantsOutsideSchedule() { var selection = selector.select( canonicalNode, List.of(draft, future, expired), - context(false, null) + context() ); Assertions.assertThat(selection.source()).isEqualTo(VariantSelection.Source.CANONICAL); @@ -205,32 +128,21 @@ public void newestActiveVariantWins() { var selection = selector.select( canonicalNode, List.of(newer, older), - context(false, null) + context() ); Assertions.assertThat(selection.variant()).contains(newer); } - private RequestContext context(boolean preview, String variantId) { - var context = new RequestContext(); - var queryParameters = variantId == null - ? Map.>of() - : Map.of("variant", List.of(variantId)); - context.add( - RequestFeature.class, - new RequestFeature("", "/", queryParameters, null) - ); - if (preview) { - context.add(IsPreviewFeature.class, new IsPreviewFeature()); - } - return context; + private RequestContext context() { + return new RequestContext(); } private ContentNode node(String path) { return new ContentNode(path, "/" + path, path, Map.of()); } - private VariantResolver.Variant scheduledVariant( + private Variant scheduledVariant( String id, Instant publishDate, Instant unpublishDate, @@ -240,7 +152,7 @@ private VariantResolver.Variant scheduledVariant( data.put(Constants.MetaFields.STATUS, status); data.put(Constants.MetaFields.PUBLISH_DATE, Date.from(publishDate)); data.put(Constants.MetaFields.UNPUBLISH_DATE, Date.from(unpublishDate)); - return new VariantResolver.Variant( + return new Variant( id, new ContentNode( ".variants/about/" + id + "/about.md", diff --git a/cms-content/src/test/java/com/condation/cms/content/VariantSelectorConfigurationRepositoryTest.java b/cms-content/src/test/java/com/condation/cms/content/VariantSelectorConfigurationRepositoryTest.java new file mode 100644 index 000000000..7efabd686 --- /dev/null +++ b/cms-content/src/test/java/com/condation/cms/content/VariantSelectorConfigurationRepositoryTest.java @@ -0,0 +1,79 @@ +package com.condation.cms.content; + +/*- + * #%L + * CMS Content + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.condation.cms.api.Constants; +import com.condation.cms.api.db.ContentNode; +import com.condation.cms.api.db.DB; +import com.condation.cms.api.db.DBFileSystem; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class VariantSelectorConfigurationRepositoryTest { + + @TempDir + Path contentBase; + + private final ContentNode canonical = new ContentNode( + "news/about.md", "/news/about", "about.md", Map.of() + ); + private VariantSelectorConfigurationRepository repository; + + @BeforeEach + void setUp() { + var db = mock(DB.class); + var fileSystem = mock(DBFileSystem.class); + var resolver = mock(VariantResolver.class); + when(db.getFileSystem()).thenReturn(fileSystem); + when(fileSystem.resolve(Constants.Folders.CONTENT)).thenReturn(contentBase); + when(resolver.resolveContext(canonical)).thenReturn( + new VariantResolver.VariantContext(canonical, Optional.empty(), List.of()) + ); + repository = new VariantSelectorConfigurationRepository(db, resolver); + } + + @Test + void missingConfigurationUsesDateRangeDefault() { + assertThat(repository.getSelectorId(canonical)) + .isEqualTo(VariantSelectorConfigurationRepository.DEFAULT_SELECTOR_ID); + } + + @Test + void storesOneConfigurationAtCanonicalVariantFolder() throws Exception { + repository.setSelectorId(canonical, "audience"); + + var file = contentBase.resolve("news/.variants/about/variants.yaml"); + assertThat(repository.configurationFile(canonical)).isEqualTo(file); + assertThat(Files.readString(file)).contains("selector: audience"); + assertThat(repository.getSelectorId(canonical)).isEqualTo("audience"); + } +} diff --git a/cms-server/src/main/java/com/condation/cms/server/configs/SiteModule.java b/cms-server/src/main/java/com/condation/cms/server/configs/SiteModule.java index e6671f7a5..6bea7212a 100644 --- a/cms-server/src/main/java/com/condation/cms/server/configs/SiteModule.java +++ b/cms-server/src/main/java/com/condation/cms/server/configs/SiteModule.java @@ -62,9 +62,11 @@ import com.condation.cms.content.DefaultContentParser; import com.condation.cms.content.DefaultContentRenderer; import com.condation.cms.content.DefaultVariantSelector; +import com.condation.cms.content.ConfigurableVariantSelector; import com.condation.cms.content.TaxonomyResolver; import com.condation.cms.content.VariantResolver; -import com.condation.cms.content.VariantSelector; +import com.condation.cms.api.variants.VariantSelector; +import com.condation.cms.content.VariantSelectorConfigurationRepository; import com.condation.cms.content.ViewResolver; import com.condation.cms.content.shortcodes.ShortCodeParser; import com.condation.cms.content.template.functions.taxonomy.TaxonomyFunction; @@ -365,8 +367,30 @@ public VariantResolver variantResolver(FileDB db) { @Provides @Singleton - public VariantSelector variantSelector() { - return new DefaultVariantSelector(); + public VariantSelectorConfigurationRepository variantSelectorConfigurationRepository( + FileDB db, + VariantResolver variantResolver + ) { + return new VariantSelectorConfigurationRepository(db, variantResolver); + } + + @Provides + @Singleton + public ConfigurableVariantSelector configurableVariantSelector( + VariantSelectorConfigurationRepository configurationRepository, + ModuleManager moduleManager + ) { + return new ConfigurableVariantSelector( + new DefaultVariantSelector(), + configurationRepository, + moduleManager + ); + } + + @Provides + @Singleton + public VariantSelector variantSelector(ConfigurableVariantSelector selector) { + return selector; } @Provides diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/PageMenuExtension.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/PageMenuExtension.java index c56fee407..2f3b59062 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/PageMenuExtension.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/PageMenuExtension.java @@ -136,6 +136,16 @@ public void page_variants() {} scriptAction = @com.condation.cms.api.ui.annotations.ScriptAction(module = "/manager/actions/page/create-variant") ) public void create_page_variant() {} + + @ShortCut( + id = "page-variant-selector", + title = "Configure variant selection", + permissions = {Permissions.CONTENT_EDIT}, + hotkey = "ctrl-8", + section = "Page", + scriptAction = @com.condation.cms.api.ui.annotations.ScriptAction(module = "/manager/actions/page/variant-selector") + ) + public void configure_variant_selector() {} @Override @@ -148,6 +158,7 @@ public Map> getLocalizations() { "page-edit-meta", "Metadaten bearbeiten", "page-variants", "Seitenvarianten", "page-variant-create", "Seitenvariante erstellen", + "page-variant-selector", "Variantenauswahl konfigurieren", "language.de", "Deutsch", "language.en", "Englisch" ), @@ -158,6 +169,7 @@ public Map> getLocalizations() { "page-edit-meta", "Edit metadata", "page-variants", "Page variants", "page-variant-create", "Create page variant", + "page-variant-selector", "Configure variant selection", "language.de", "German", "language.en", "English" ) diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpoint.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpoint.java index b6d19ce7b..7c1adc7b2 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpoint.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpoint.java @@ -32,8 +32,11 @@ import com.condation.cms.api.ui.annotations.RemoteMethod; import com.condation.cms.api.ui.extensions.UIRemoteMethodExtensionPoint; import com.condation.cms.api.ui.rpc.RPCException; +import com.condation.cms.api.variants.Variant; import com.condation.cms.api.utils.PathUtil; import com.condation.cms.content.VariantResolver; +import com.condation.cms.content.ConfigurableVariantSelector; +import com.condation.cms.content.VariantSelectorConfigurationRepository; import com.condation.cms.core.content.io.ContentFileParser; import com.condation.cms.core.content.io.YamlHeaderUpdater; import com.condation.cms.modules.ui.extensionpoints.remotemethods.dto.VariantDto; @@ -59,6 +62,53 @@ @Slf4j public class RemoteVariantEndpoint extends AbstractRemoteMethodeExtension { + @RemoteMethod(name = "variants.selectors.get", permissions = {Permissions.CONTENT_EDIT}) + public Object getSelectors(Map parameters) throws RPCException { + var uri = stringParameter(parameters, "uri"); + if (uri.isBlank()) { + throw new RPCException(400, "uri must not be blank"); + } + + var db = getDB(parameters); + var node = findContentNode(db, uri); + var configurableSelector = getConfigurableVariantSelector(); + var repository = getVariantSelectorConfigurationRepository(); + + return Map.of( + "selector", repository.getSelectorId(node), + "selectors", configurableSelector.availableSelectors().values() + .stream() + .sorted(Comparator.comparing( + ConfigurableVariantSelector.SelectorDescriptor::label + )) + .toList() + ); + } + + @RemoteMethod(name = "variants.selector.set", permissions = {Permissions.CONTENT_EDIT}) + public Object setSelector(Map parameters) throws RPCException { + var uri = stringParameter(parameters, "uri"); + var selectorId = stringParameter(parameters, "selector"); + if (uri.isBlank() || selectorId.isBlank()) { + throw new RPCException(400, "uri and selector must not be blank"); + } + + var configurableSelector = getConfigurableVariantSelector(); + if (!configurableSelector.hasSelector(selectorId)) { + throw new RPCException(400, "unknown variant selector"); + } + + var db = getDB(parameters); + var node = findContentNode(db, uri); + try { + getVariantSelectorConfigurationRepository().setSelectorId(node, selectorId); + return Map.of("selector", selectorId); + } catch (Exception exception) { + log.error("Could not save variant selector for '{}'", node.path(), exception); + throw new RPCException(500, exception.getMessage()); + } + } + @RemoteMethod(name = "variants.create", permissions = {Permissions.CONTENT_EDIT}) public Object create(Map parameters) throws RPCException { var uri = stringParameter(parameters, "uri"); @@ -179,7 +229,7 @@ public Object get(Map parameters) throws RPCException { var variantContext = getVariantResolver(db).resolveContext(contentNode); var variants = variantContext.variants() .stream() - .sorted(Comparator.comparing(VariantResolver.Variant::id)) + .sorted(Comparator.comparing(Variant::id)) .map(variant -> new VariantDto( variant.id(), variant.node().uri(), @@ -215,6 +265,16 @@ protected VariantResolver getVariantResolver(DB db) { return getContext().get(InjectorFeature.class).injector().getInstance(VariantResolver.class); } + protected ConfigurableVariantSelector getConfigurableVariantSelector() { + return getContext().get(InjectorFeature.class) + .injector().getInstance(ConfigurableVariantSelector.class); + } + + protected VariantSelectorConfigurationRepository getVariantSelectorConfigurationRepository() { + return getContext().get(InjectorFeature.class) + .injector().getInstance(VariantSelectorConfigurationRepository.class); + } + private String stringParameter(Map parameters, String name) { var value = parameters.get(name); return value instanceof String stringValue ? stringValue.trim() : ""; diff --git a/modules/ui-module/src/main/resources/manager/actions/page/variant-selector.d.ts b/modules/ui-module/src/main/resources/manager/actions/page/variant-selector.d.ts new file mode 100644 index 000000000..2fddde572 --- /dev/null +++ b/modules/ui-module/src/main/resources/manager/actions/page/variant-selector.d.ts @@ -0,0 +1,21 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ +export declare const runAction: () => Promise; diff --git a/modules/ui-module/src/main/resources/manager/actions/page/variant-selector.js b/modules/ui-module/src/main/resources/manager/actions/page/variant-selector.js new file mode 100644 index 000000000..8205c730d --- /dev/null +++ b/modules/ui-module/src/main/resources/manager/actions/page/variant-selector.js @@ -0,0 +1,80 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ +import { i18n } from '@cms/modules/localization.js'; +import { openModal } from '@cms/modules/modal.js'; +import { getPreviewUrl } from '@cms/modules/preview.utils.js'; +import { getContentNode } from '@cms/modules/rpc/rpc-content.js'; +import { getVariantSelectors, setVariantSelector } from '@cms/modules/rpc/rpc-variant.js'; +import { showToast } from '@cms/modules/toast.js'; +const SELECT_ID = 'cms-variant-selector'; +const escapeHtml = (input) => { + const element = document.createElement('div'); + element.textContent = String(input ?? ''); + return element.innerHTML; +}; +const option = (selector, selected) => ``; +const showError = (error) => showToast({ + title: i18n.t('manager.actions.page.variant-selector.error.title', 'Could not configure variant selection'), + message: error instanceof Error ? error.message : String(error), + type: 'error', + timeout: 3000 +}); +export const runAction = async () => { + try { + const contentNode = await getContentNode({ url: getPreviewUrl() }); + const result = await getVariantSelectors(contentNode.result.uri); + openModal({ + title: i18n.t('manager.actions.page.variant-selector.title', 'Configure variant selection'), + body: ` +
+ + +
+ The strategy is applied to public requests. Manager and explicit preview selection remain unchanged. +
+
`, + fullscreen: false, + onCancel: () => { }, + validate: () => Boolean(document.getElementById(SELECT_ID)?.value), + onOk: async () => { + try { + const selector = document.getElementById(SELECT_ID).value; + await setVariantSelector(contentNode.result.uri, selector); + showToast({ + title: i18n.t('manager.actions.page.variant-selector.success.title', 'Variant selection configured'), + message: i18n.t('manager.actions.page.variant-selector.success.message', 'The selection strategy was saved.'), + type: 'success', + timeout: 3000 + }); + } + catch (error) { + showError(error); + } + } + }); + } + catch (error) { + showError(error); + } +}; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-variant.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-variant.d.ts index d6b8fd598..db80c8034 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-variant.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-variant.d.ts @@ -8,12 +8,12 @@ * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. - * + * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. - * + * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . * #L% @@ -50,6 +50,18 @@ export interface CreateVariantResult { uri: string; url: string; } +export interface VariantSelectorDto { + id: string; + label: string; +} +export interface GetVariantSelectorsResult { + selector: string; + selectors: VariantSelectorDto[]; +} declare const getVariants: (options: GetVariantsOptions) => Promise; declare const createVariant: (options: CreateVariantOptions) => Promise; -export { createVariant, getVariants }; +declare const getVariantSelectors: (uri: string) => Promise; +declare const setVariantSelector: (uri: string, selector: string) => Promise<{ + selector: string; +}>; +export { createVariant, getVariants, getVariantSelectors, setVariantSelector }; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-variant.js b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-variant.js index e41da2c54..b2cc2841b 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-variant.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-variant.js @@ -33,4 +33,18 @@ const createVariant = async (options) => { }; return (await executeRemoteCall(data)).result; }; -export { createVariant, getVariants }; +const getVariantSelectors = async (uri) => { + const data = { + method: 'variants.selectors.get', + parameters: { uri } + }; + return (await executeRemoteCall(data)).result; +}; +const setVariantSelector = async (uri, selector) => { + const data = { + method: 'variants.selector.set', + parameters: { uri, selector } + }; + return (await executeRemoteCall(data)).result; +}; +export { createVariant, getVariants, getVariantSelectors, setVariantSelector }; diff --git a/modules/ui-module/src/main/ts/src/actions/page/variant-selector.ts b/modules/ui-module/src/main/ts/src/actions/page/variant-selector.ts new file mode 100644 index 000000000..b51570359 --- /dev/null +++ b/modules/ui-module/src/main/ts/src/actions/page/variant-selector.ts @@ -0,0 +1,102 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import { i18n } from '@cms/modules/localization.js' +import { openModal } from '@cms/modules/modal.js' +import { getPreviewUrl } from '@cms/modules/preview.utils.js' +import { getContentNode } from '@cms/modules/rpc/rpc-content.js' +import { + getVariantSelectors, + setVariantSelector, + VariantSelectorDto +} from '@cms/modules/rpc/rpc-variant.js' +import { showToast } from '@cms/modules/toast.js' + +const SELECT_ID = 'cms-variant-selector'; + +const escapeHtml = (input: unknown): string => { + const element = document.createElement('div'); + element.textContent = String(input ?? ''); + return element.innerHTML; +}; + +const option = (selector: VariantSelectorDto, selected: string): string => + ``; + +const showError = (error: unknown) => showToast({ + title: i18n.t( + 'manager.actions.page.variant-selector.error.title', + 'Could not configure variant selection' + ), + message: error instanceof Error ? error.message : String(error), + type: 'error', + timeout: 3000 +}); + +export const runAction = async () => { + try { + const contentNode = await getContentNode({ url: getPreviewUrl() }); + const result = await getVariantSelectors(contentNode.result.uri); + + openModal({ + title: i18n.t( + 'manager.actions.page.variant-selector.title', + 'Configure variant selection' + ), + body: ` +
+ + +
+ The strategy is applied to public requests. Manager and explicit preview selection remain unchanged. +
+
`, + fullscreen: false, + onCancel: () => {}, + validate: () => Boolean((document.getElementById(SELECT_ID) as HTMLSelectElement)?.value), + onOk: async () => { + try { + const selector = (document.getElementById(SELECT_ID) as HTMLSelectElement).value; + await setVariantSelector(contentNode.result.uri, selector); + showToast({ + title: i18n.t( + 'manager.actions.page.variant-selector.success.title', + 'Variant selection configured' + ), + message: i18n.t( + 'manager.actions.page.variant-selector.success.message', + 'The selection strategy was saved.' + ), + type: 'success', + timeout: 3000 + }); + } catch (error) { + showError(error); + } + } + }); + } catch (error) { + showError(error); + } +}; diff --git a/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc-variant.ts b/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc-variant.ts index 3a4660691..8e64ad69b 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc-variant.ts +++ b/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc-variant.ts @@ -58,6 +58,16 @@ export interface CreateVariantResult { url: string; } +export interface VariantSelectorDto { + id: string; + label: string; +} + +export interface GetVariantSelectorsResult { + selector: string; + selectors: VariantSelectorDto[]; +} + const getVariants = async (options: GetVariantsOptions): Promise => { const data = { method: 'variants.get', @@ -74,4 +84,20 @@ const createVariant = async (options: CreateVariantOptions): Promise => { + const data = { + method: 'variants.selectors.get', + parameters: { uri } + }; + return (await executeRemoteCall(data)).result as GetVariantSelectorsResult; +}; + +const setVariantSelector = async (uri: string, selector: string): Promise<{ selector: string }> => { + const data = { + method: 'variants.selector.set', + parameters: { uri, selector } + }; + return (await executeRemoteCall(data)).result as { selector: string }; +}; + +export { createVariant, getVariants, getVariantSelectors, setVariantSelector }; diff --git a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpointTest.java b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpointTest.java index e24f89166..d4ab76159 100644 --- a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpointTest.java +++ b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpointTest.java @@ -27,7 +27,10 @@ import com.condation.cms.api.feature.features.DBFeature; import com.condation.cms.api.module.SiteModuleContext; import com.condation.cms.api.ui.rpc.RPCException; +import com.condation.cms.api.variants.Variant; +import com.condation.cms.content.ConfigurableVariantSelector; import com.condation.cms.content.VariantResolver; +import com.condation.cms.content.VariantSelectorConfigurationRepository; import com.condation.cms.modules.ui.extensionpoints.remotemethods.dto.VariantDto; import java.util.List; import java.util.Map; @@ -41,6 +44,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -58,6 +62,12 @@ class RemoteVariantEndpointTest { @Mock private VariantResolver variantResolver; + @Mock + private ConfigurableVariantSelector configurableVariantSelector; + + @Mock + private VariantSelectorConfigurationRepository selectorConfigurationRepository; + private RemoteVariantEndpoint endpoint; @BeforeEach @@ -67,6 +77,16 @@ void setUp() { protected VariantResolver getVariantResolver(DB db) { return variantResolver; } + + @Override + protected ConfigurableVariantSelector getConfigurableVariantSelector() { + return configurableVariantSelector; + } + + @Override + protected VariantSelectorConfigurationRepository getVariantSelectorConfigurationRepository() { + return selectorConfigurationRepository; + } }; endpoint.setContext(moduleContext); lenient().when(moduleContext.get(DBFeature.class)).thenReturn(new DBFeature(db)); @@ -88,8 +108,8 @@ void getReturnsVariantsSortedById() throws RPCException { ); when(content.byPath("about.md")).thenReturn(Optional.of(node)); var variants = List.of( - new VariantResolver.Variant("summer", summerNode), - new VariantResolver.Variant("campaign", campaignNode) + new Variant("summer", summerNode), + new Variant("campaign", campaignNode) ); when(variantResolver.resolveContext(node)).thenReturn( new VariantResolver.VariantContext(node, Optional.empty(), variants) @@ -119,7 +139,7 @@ void getFromVariantReturnsCanonicalAndActiveVariant() throws RPCException { "/.variants/about/summer/about", Map.of("title", "Summer") ); - var variants = List.of(new VariantResolver.Variant("summer", summer)); + var variants = List.of(new Variant("summer", summer)); when(content.byPath(summer.path())).thenReturn(Optional.of(summer)); when(variantResolver.resolveContext(summer)).thenReturn( new VariantResolver.VariantContext(canonical, Optional.of("summer"), variants) @@ -170,6 +190,39 @@ void getReturnsNotFoundForUnknownNode() { ); } + @Test + void getSelectorsReturnsConfigurationAndAvailableStrategies() throws RPCException { + var node = node("about.md", "/about", Map.of()); + when(content.byPath("about.md")).thenReturn(Optional.of(node)); + when(selectorConfigurationRepository.getSelectorId(node)).thenReturn("audience"); + when(configurableVariantSelector.availableSelectors()).thenReturn(Map.of( + "date-range", + new ConfigurableVariantSelector.SelectorDescriptor("date-range", "Date range"), + "audience", + new ConfigurableVariantSelector.SelectorDescriptor("audience", "Audience") + )); + + @SuppressWarnings("unchecked") + var result = (Map) endpoint.getSelectors(Map.of("uri", "about.md")); + + assertThat(result).containsEntry("selector", "audience"); + assertThat((List) result.get("selectors")) + .extracting(ConfigurableVariantSelector.SelectorDescriptor::id) + .containsExactly("audience", "date-range"); + } + + @Test + void setSelectorPersistsConfigurationForPage() throws Exception { + var node = node("about.md", "/about", Map.of()); + when(content.byPath("about.md")).thenReturn(Optional.of(node)); + when(configurableVariantSelector.hasSelector("audience")).thenReturn(true); + + var result = endpoint.setSelector(Map.of("uri", "about.md", "selector", "audience")); + + assertThat(result).isEqualTo(Map.of("selector", "audience")); + verify(selectorConfigurationRepository).setSelectorId(node, "audience"); + } + private ContentNode node(String uri, String url, Map data) { return new ContentNode(uri, url, "about.md", data); } diff --git a/test-server/hosts/demo/content/.variants/about/variants.yaml b/test-server/hosts/demo/content/.variants/about/variants.yaml new file mode 100644 index 000000000..f2dd60758 --- /dev/null +++ b/test-server/hosts/demo/content/.variants/about/variants.yaml @@ -0,0 +1 @@ +selector: date-range diff --git a/test-server/hosts/demo/content/.variants/about/winter-campain/about.md b/test-server/hosts/demo/content/.variants/about/winter-campain/about.md index 9d07d1c4d..bd4118fe4 100644 --- a/test-server/hosts/demo/content/.variants/about/winter-campain/about.md +++ b/test-server/hosts/demo/content/.variants/about/winter-campain/about.md @@ -2,7 +2,7 @@ template: default.html createdAt: 2026-07-29T11:53:35.309Z createdBy: test -title: Winter Caompain +title: Winter Campaign status: draft --- From 8958d70a9378aee27fda94a62132a1eb0418358f Mon Sep 17 00:00:00 2001 From: Thorsten Marx Date: Thu, 30 Jul 2026 10:35:34 +0200 Subject: [PATCH 08/16] update manger for better variation handling --- .../content/ConfigurableVariantSelector.java | 9 +-- .../cms/content/ContentResolver.java | 5 +- .../ConfigurableVariantSelectorTest.java | 11 ++++ .../filter/PooledRequestContextFilter.java | 2 + .../RemoteContentEndpointsExtension.java | 55 +++++++++++++++++-- .../remotemethods/RemoteVariantEndpoint.java | 12 +++- .../RemoteWorkflowEndpointsExtension.java | 18 +++++- .../modules/ui/http/RemoteCallHandler.java | 23 ++++++++ .../manager/actions/page/add-section.js | 16 ++---- .../manager/actions/page/edit-content.js | 21 ++----- .../actions/page/edit-metaattribute-form.js | 21 ++----- .../actions/page/edit-metaattribute-list.js | 21 ++----- .../actions/page/edit-metaattribute.js | 21 ++----- .../actions/page/edit-page-settings.js | 12 +--- .../manager/actions/page/edit-schedule.js | 12 +--- .../manager/actions/page/variants.js | 8 +-- .../manager/actions/page/wf-run-transition.js | 1 - .../src/main/resources/manager/js/manager.js | 1 + .../manager/js/modules/manager-ui.js | 37 +++++-------- .../manager/manager.message.handlers.js | 4 ++ .../manager/js/modules/preview-context.d.ts | 28 ++++++++++ .../manager/js/modules/preview-context.js | 26 +++++++++ .../manager/js/modules/preview.utils.js | 7 ++- .../manager/js/modules/rpc/rpc-workflow.d.ts | 6 +- .../resources/manager/js/modules/rpc/rpc.js | 7 ++- .../main/ts/src/actions/page/add-section.js | 17 ++---- .../main/ts/src/actions/page/edit-content.js | 20 ++----- .../actions/page/edit-metaattribute-form.js | 20 ++----- .../actions/page/edit-metaattribute-list.js | 20 ++----- .../ts/src/actions/page/edit-metaattribute.js | 20 ++----- .../ts/src/actions/page/edit-page-settings.js | 13 +---- .../main/ts/src/actions/page/edit-schedule.js | 13 +---- .../src/main/ts/src/actions/page/variants.ts | 7 ++- .../ts/src/actions/page/wf-run-transition.ts | 1 - .../ui-module/src/main/ts/src/js/manager.js | 5 +- .../src/main/ts/src/js/modules/manager-ui.js | 40 +++++--------- .../manager/manager.message.handlers.ts | 4 ++ .../main/ts/src/js/modules/preview-context.ts | 36 ++++++++++++ .../main/ts/src/js/modules/preview.utils.js | 7 ++- .../ts/src/js/modules/rpc/rpc-workflow.ts | 6 +- .../src/main/ts/src/js/modules/rpc/rpc.ts | 7 ++- .../RemoteContentEndpointsExtensionTest.java | 23 ++++++++ .../RemoteVariantEndpointTest.java | 4 +- .../ui/http/RemoteCallHandlerTest.java | 36 ++++++++++++ .../index/summer/index.asection.bla.md | 2 +- .../content/.variants/index/summer/index.md | 18 +++++- 46 files changed, 431 insertions(+), 272 deletions(-) create mode 100644 modules/ui-module/src/main/resources/manager/js/modules/preview-context.d.ts create mode 100644 modules/ui-module/src/main/resources/manager/js/modules/preview-context.js create mode 100644 modules/ui-module/src/main/ts/src/js/modules/preview-context.ts diff --git a/cms-content/src/main/java/com/condation/cms/content/ConfigurableVariantSelector.java b/cms-content/src/main/java/com/condation/cms/content/ConfigurableVariantSelector.java index 8e0e14a43..b22d97f17 100644 --- a/cms-content/src/main/java/com/condation/cms/content/ConfigurableVariantSelector.java +++ b/cms-content/src/main/java/com/condation/cms/content/ConfigurableVariantSelector.java @@ -66,12 +66,12 @@ public VariantSelection select( List variants, RequestContext context ) { - if (isManager(context)) { - return VariantSelection.canonical(); - } if (hasExplicitPreviewSelection(context)) { return selectPreviewVariant(canonicalNode, variants, context.get(RequestFeature.class)); } + if (isManager(context)) { + return VariantSelection.canonical(); + } var selectorId = configurationRepository.getSelectorId(canonicalNode); var selector = selectors().get(selectorId); @@ -136,7 +136,8 @@ private boolean isManager(RequestContext context) { private boolean hasExplicitPreviewSelection(RequestContext context) { return context.has(IsPreviewFeature.class) - && IsPreviewFeature.Mode.PREVIEW.equals(context.get(IsPreviewFeature.class).mode()) + && (IsPreviewFeature.Mode.PREVIEW.equals(context.get(IsPreviewFeature.class).mode()) + || IsPreviewFeature.Mode.MANAGER.equals(context.get(IsPreviewFeature.class).mode())) && context.has(RequestFeature.class) && context.get(RequestFeature.class) .hasQueryParameter(VARIANT_QUERY_PARAMETER); diff --git a/cms-content/src/main/java/com/condation/cms/content/ContentResolver.java b/cms-content/src/main/java/com/condation/cms/content/ContentResolver.java index 1772594be..19a6e8da3 100644 --- a/cms-content/src/main/java/com/condation/cms/content/ContentResolver.java +++ b/cms-content/src/main/java/com/condation/cms/content/ContentResolver.java @@ -29,6 +29,7 @@ import com.condation.cms.api.db.DB; import com.condation.cms.api.db.NodeVisibility; import com.condation.cms.api.feature.features.CurrentNodeFeature; +import com.condation.cms.api.feature.features.IsPreviewFeature; import com.condation.cms.api.feature.features.RequestFeature; import com.condation.cms.api.request.RequestContext; import com.condation.cms.api.variants.Variant; @@ -128,7 +129,9 @@ private Optional getContent(final RequestContext context, boole ); var selectedNode = selection.variant() .map(Variant::node) - .filter(node -> !checkVisibility || NodeVisibility.isVisible(node)) + .filter(node -> !checkVisibility + || context.has(IsPreviewFeature.class) + || NodeVisibility.isVisible(node)) .orElse(contentNode); var contentFile = db.getFileSystem().contentBase().resolve(selectedNode.path()); diff --git a/cms-content/src/test/java/com/condation/cms/content/ConfigurableVariantSelectorTest.java b/cms-content/src/test/java/com/condation/cms/content/ConfigurableVariantSelectorTest.java index 2cb6088cf..53b6ff5ad 100644 --- a/cms-content/src/test/java/com/condation/cms/content/ConfigurableVariantSelectorTest.java +++ b/cms-content/src/test/java/com/condation/cms/content/ConfigurableVariantSelectorTest.java @@ -98,6 +98,17 @@ void explicitPreviewSelectsVariantWithoutInvokingASelector() { verify(extension, never()).select(canonical, variants, context); } + @Test + void explicitManagerPreviewSelectsVariantWithoutInvokingASelector() { + var context = context(IsPreviewFeature.Mode.MANAGER, "summer"); + + assertThat(selector.select(canonical, variants, context)) + .isEqualTo(VariantSelection.preview(summer)); + verify(configuration, never()).getSelectorId(canonical); + verify(dateRange, never()).select(canonical, variants, context); + verify(extension, never()).select(canonical, variants, context); + } + @Test void explicitPreviewCanForceCanonicalContent() { var context = context( diff --git a/cms-server/src/main/java/com/condation/cms/server/filter/PooledRequestContextFilter.java b/cms-server/src/main/java/com/condation/cms/server/filter/PooledRequestContextFilter.java index c87021cf1..9c931df07 100644 --- a/cms-server/src/main/java/com/condation/cms/server/filter/PooledRequestContextFilter.java +++ b/cms-server/src/main/java/com/condation/cms/server/filter/PooledRequestContextFilter.java @@ -23,6 +23,7 @@ import com.condation.cms.api.PerformanceProperties; import com.condation.cms.api.annotations.Experimental; import com.condation.cms.api.feature.features.IsPreviewFeature; +import com.condation.cms.api.feature.features.CurrentNodeFeature; import com.condation.cms.api.feature.features.RequestFeature; import com.condation.cms.api.request.RequestContext; import com.condation.cms.api.request.RequestContextScope; @@ -92,6 +93,7 @@ public boolean handle(Request httpRequest, Response rspns, Callback clbck) throw } finally { requestContext.features.remove(RequestFeature.class); requestContext.features.remove(IsPreviewFeature.class); + requestContext.features.remove(CurrentNodeFeature.class); requestContextPoolable.release(); } } diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteContentEndpointsExtension.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteContentEndpointsExtension.java index a38f3c08d..a0428a80a 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteContentEndpointsExtension.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteContentEndpointsExtension.java @@ -28,6 +28,7 @@ import com.condation.cms.api.eventbus.events.ReIndexContentMetaDataEvent; import com.condation.cms.api.extensions.AbstractExtensionPoint; import com.condation.cms.api.feature.features.DBFeature; +import com.condation.cms.api.feature.features.CurrentNodeFeature; import com.condation.cms.api.feature.features.EventBusFeature; import com.condation.cms.api.feature.features.RequestFeature; import com.condation.cms.api.feature.features.SitePropertiesFeature; @@ -47,6 +48,8 @@ import com.condation.cms.api.ui.rpc.RPCException; import com.condation.cms.api.utils.SectionUtil; import com.condation.cms.content.SectionEntry; +import com.condation.cms.content.ConfigurableVariantSelector; +import com.condation.cms.content.VariantResolver; import com.condation.cms.modules.ui.utils.FormHelper; import com.condation.cms.modules.ui.utils.MarkdownHelper; import com.condation.cms.modules.ui.utils.MetaConverter; @@ -69,7 +72,7 @@ public Object getContent(Map parameters) throws RPCException { final DB db = getContext().get(DBFeature.class).db(); var contentBase = db.getFileSystem().contentBase(); - var uri = (String) parameters.get("uri"); + var uri = contentUri(parameters); var contentFile = contentBase.resolve(uri); @@ -95,7 +98,7 @@ public Object setContent(Map parameters) throws RPCException { var contentBase = db.getFileSystem().contentBase(); var updatedContent = FormHelper.getContent(parameters.get("content")); - var uri = (String) parameters.get("uri"); + var uri = contentUri(parameters); var contentFile = contentBase.resolve(uri); @@ -128,7 +131,7 @@ public Object replaceContent(Map parameters) throws RPCException var replacement = (String)parameters.get("content"); int start = NumberUtils.toInt(parameters.getOrDefault("start", -1l)); int end = NumberUtils.toInt(parameters.getOrDefault("end", -1l)); - var uri = (String) parameters.get("uri"); + var uri = contentUri(parameters); Map result = new HashMap<>(); result.put("uri", uri); @@ -169,7 +172,7 @@ public Object setMeta(Map parameters) throws RPCException { var updateParam = (Map>) parameters.get("meta"); var update = MetaConverter.convertMeta(updateParam); - var uri = (String) parameters.get("uri"); + var uri = contentUri(parameters); var contentFile = contentBase.resolve(uri); @@ -279,7 +282,7 @@ public Object addSectionEntry(Map parameters) throws RPCExceptio var contentBase = db.getFileSystem().resolve(Constants.Folders.CONTENT); var content = (String) parameters.getOrDefault("content", ""); - var parentUri = (String) parameters.get("parentUri"); + var parentUri = contentUri(parameters, "parentUri"); var section = (String) parameters.get("section"); var sectionEntryName = (String) parameters.get("sectionEntryName"); var template = (String) parameters.get("template"); @@ -355,7 +358,32 @@ public Object getContentNode (Map parameters) { Map result = new HashMap<>(); result.put("url", url); if (contentFile != null) { - result.put("uri", PathUtil.toRelativeFile(contentFile, contentBase)); + var canonicalUri = PathUtil.toRelativeFile(contentFile, contentBase); + var selectedNode = db.getContent().byUri(canonicalUri).orElse(null); + var query = com.condation.cms.api.utils.HTTPUtil.queryParameters(URI.create(url).getQuery()); + var variantId = query.getOrDefault( + ConfigurableVariantSelector.VARIANT_QUERY_PARAMETER, + List.of() + ).stream().findFirst().orElse("").trim(); + if (selectedNode != null + && !variantId.isBlank() + && !ConfigurableVariantSelector.CANONICAL_VARIANT_ID.equalsIgnoreCase(variantId)) { + selectedNode = new VariantResolver(db) + .loadVariant(selectedNode, variantId) + .map(com.condation.cms.api.variants.Variant::node) + .orElse(selectedNode); + } + if (selectedNode != null) { + contentFile = contentBase.resolve(selectedNode.uri()); + result.put("uri", selectedNode.uri()); + } else { + result.put("uri", canonicalUri); + } + result.put("canonicalUri", canonicalUri); + result.put("variantId", variantId.isBlank() + || ConfigurableVariantSelector.CANONICAL_VARIANT_ID.equalsIgnoreCase(variantId) + ? null + : variantId); var sectionEntries = db.getContent().listSectionEntries(contentFile); Map> sectionMap = new HashMap<>(); @@ -372,4 +400,19 @@ public Object getContentNode (Map parameters) { return result; } + + private String contentUri(Map parameters) throws RPCException { + return contentUri(parameters, "uri"); + } + + private String contentUri(Map parameters, String parameterName) throws RPCException { + var value = parameters.get(parameterName); + if (value instanceof String uri && !uri.isBlank()) { + return uri; + } + if (getRequestContext().has(CurrentNodeFeature.class)) { + return getRequestContext().get(CurrentNodeFeature.class).node().uri(); + } + throw new RPCException(400, parameterName + " must not be blank"); + } } diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpoint.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpoint.java index 7c1adc7b2..5c2a1d992 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpoint.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpoint.java @@ -207,7 +207,7 @@ public Object create(Map parameters) throws RPCException { return Map.of( "id", variantId, "uri", newUri, - "url", managerPreviewUrl(PathUtil.toURL(variantFile, contentBase)) + "url", managerVariantPreviewUrl(canonicalNode.url(), variantId) ); } catch (RPCException exception) { throw exception; @@ -233,7 +233,7 @@ public Object get(Map parameters) throws RPCException { .map(variant -> new VariantDto( variant.id(), variant.node().uri(), - managerPreviewUrl(variant.node().url()), + managerVariantPreviewUrl(variantContext.canonical().url(), variant.id()), variant.node().data() )) .toList(); @@ -284,6 +284,14 @@ private String managerPreviewUrl(String url) { return url + (url.contains("?") ? "&" : "?") + "preview=manager"; } + private String managerVariantPreviewUrl(String canonicalUrl, String variantId) { + return managerPreviewUrl(canonicalUrl) + + "&" + + ConfigurableVariantSelector.VARIANT_QUERY_PARAMETER + + "=" + + java.net.URLEncoder.encode(variantId, java.nio.charset.StandardCharsets.UTF_8); + } + private record SectionCopy(java.nio.file.Path source, java.nio.file.Path target) { } } diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteWorkflowEndpointsExtension.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteWorkflowEndpointsExtension.java index 896f38fe9..506bd86f0 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteWorkflowEndpointsExtension.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteWorkflowEndpointsExtension.java @@ -27,6 +27,7 @@ import com.condation.cms.api.db.cms.ReadOnlyFile; import com.condation.cms.api.extensions.AbstractExtensionPoint; import com.condation.cms.api.feature.features.DBFeature; +import com.condation.cms.api.feature.features.CurrentNodeFeature; import com.condation.cms.api.feature.features.WorkflowFeature; import com.condation.cms.api.ui.extensions.UIRemoteMethodExtensionPoint; import com.condation.cms.api.ui.rpc.RPCException; @@ -88,7 +89,7 @@ private ReadOnlyFile getContentFile(String uri) { @RemoteMethod(name = "workflow.manager.node.status", permissions = {Permissions.CONTENT_EDIT}) public Object nodeStatus(Map parameters) throws RPCException { - var uri = (String) parameters.get("uri"); + var uri = contentUri(parameters); Map result = new HashMap<>(); var contentNodeOpt = getContentNode(uri); @@ -111,7 +112,7 @@ public Object nodeStatus(Map parameters) throws RPCException { @RemoteMethod(name = "workflow.transitions.get", permissions = {Permissions.CONTENT_EDIT}) public Object getTransitions(Map parameters) throws RPCException { - var uri = (String) parameters.get("uri"); + var uri = contentUri(parameters); Map result = new HashMap<>(); @@ -138,7 +139,7 @@ public Object getTransitions(Map parameters) throws RPCException public Object transit(Map parameters) throws RPCException { var result = new HashMap(); try { - var uri = (String) parameters.get("uri"); + var uri = contentUri(parameters); var transition = (String) parameters.get("transitionId"); final DB db = getContext().get(DBFeature.class).db(); @@ -167,4 +168,15 @@ public Object transit(Map parameters) throws RPCException { return result; } + private String contentUri(Map parameters) throws RPCException { + var value = parameters.get("uri"); + if (value instanceof String uri && !uri.isBlank()) { + return uri; + } + if (getRequestContext().has(CurrentNodeFeature.class)) { + return getRequestContext().get(CurrentNodeFeature.class).node().uri(); + } + throw new RPCException(400, "uri must not be blank"); + } + } diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/http/RemoteCallHandler.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/http/RemoteCallHandler.java index 1d1d6748d..2e952f46e 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/http/RemoteCallHandler.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/http/RemoteCallHandler.java @@ -21,7 +21,10 @@ * #L% */ import com.condation.cms.api.module.SiteModuleContext; +import com.condation.cms.api.feature.features.CurrentNodeFeature; +import com.condation.cms.api.feature.features.DBFeature; import com.condation.cms.api.request.RequestContext; +import com.condation.cms.api.request.RequestContextScope; import com.condation.cms.api.ui.rpc.RPCError; import com.condation.cms.api.ui.rpc.RPCException; import com.condation.cms.api.ui.rpc.RPCResult; @@ -47,6 +50,8 @@ @Slf4j public class RemoteCallHandler extends JettyHandler { + public static final String CONTENT_URI_HEADER = "X-CMS-Content-Uri"; + private final RemoteMethodService remoteCallService; private final SiteModuleContext moduleContext; private final RequestContext requestContext; @@ -68,6 +73,7 @@ public boolean handle(Request request, Response response, Callback callback) thr if (userOpt.isEmpty()) { rpcResult = new RPCResult(new RPCError("no user present")); } else { + setCurrentContentNode(request); Optional result = remoteCallService.execute(remoteCall.method(), remoteCall.parameters(), userOpt.get()); if (result.isPresent()) { rpcResult = new RPCResult(result.get()); @@ -87,6 +93,23 @@ public boolean handle(Request request, Response response, Callback callback) thr return true; } + private void setCurrentContentNode(Request request) { + var uri = request.getHeaders().get(CONTENT_URI_HEADER); + if (uri == null || uri.isBlank()) { + return; + } + if (!moduleContext.has(DBFeature.class)) { + return; + } + var content = moduleContext.get(DBFeature.class).db().getContent(); + content.byUri(uri.trim()) + .or(() -> content.byPath(uri.trim())) + .ifPresent(node -> requestContext.add( + CurrentNodeFeature.class, + new CurrentNodeFeature(node) + )); + } + private RPCResult buildErrorResult(Exception e, String method) { log.error("error executing endpoint {}", method, e); if (e instanceof RPCException rpcException) { diff --git a/modules/ui-module/src/main/resources/manager/actions/page/add-section.js b/modules/ui-module/src/main/resources/manager/actions/page/add-section.js index 83e799565..86bd5cb59 100644 --- a/modules/ui-module/src/main/resources/manager/actions/page/add-section.js +++ b/modules/ui-module/src/main/resources/manager/actions/page/add-section.js @@ -20,15 +20,12 @@ */ import { openModal } from '@cms/modules/modal.js'; import { showToast } from '@cms/modules/toast.js'; -import { addSection, getContentNode } from '@cms/modules/rpc/rpc-content.js'; -import { getPreviewUrl, reloadPreview } from '@cms/modules/preview.utils.js'; +import { addSection } from '@cms/modules/rpc/rpc-content.js'; +import { reloadPreview } from '@cms/modules/preview.utils.js'; import Handlebars from 'https://cdn.jsdelivr.net/npm/handlebars@4.7.8/+esm'; import { i18n } from '@cms/modules/localization.js'; import { getSectionEntryTemplates } from '@cms/modules/rpc/rpc-manager.js'; export async function runAction(params) { - const contentNode = await getContentNode({ - url: getPreviewUrl() - }); var template = Handlebars.compile(`
@@ -51,9 +48,9 @@ export async function runAction(params) { }), fullscreen: false, onCancel: (event) => { }, - validate: () => validate(contentNode, params.section), + validate: () => validate(params.section), onOk: async (event) => { - var result = await createSection(contentNode.result.uri, params.section); + var result = await createSection(params.section); if (result) { showToast({ title: i18n.t("manager.actions.addsection.titles.alert", "Create Entry"), @@ -78,7 +75,7 @@ export async function runAction(params) { const getSectionEntryName = () => { return document.getElementById("cms-section-name").value; }; -const validate = (contentNode, targetSection) => { +const validate = (targetSection) => { const template = document.getElementById("cms-section-template-selection").value; if (template === "000") { showToast({ @@ -114,14 +111,13 @@ function isUriInSection(data, sectionEntryKey, targetUri) { } return sectionArray.some(item => item.uri === targetUri); } -const createSection = async (parentUri, parentSectionName) => { +const createSection = async (parentSectionName) => { const template = document.getElementById("cms-section-template-selection").value; if (template === "000") { return false; } try { await addSection({ - parentUri: parentUri, sectionEntryName: getSectionEntryName(), section: parentSectionName, template: template diff --git a/modules/ui-module/src/main/resources/manager/actions/page/edit-content.js b/modules/ui-module/src/main/resources/manager/actions/page/edit-content.js index b14f04e7b..d06dbe8f7 100644 --- a/modules/ui-module/src/main/resources/manager/actions/page/edit-content.js +++ b/modules/ui-module/src/main/resources/manager/actions/page/edit-content.js @@ -20,26 +20,15 @@ */ import { openModal } from '@cms/modules/modal.js'; import { createForm } from '@cms/modules/form/forms.js'; -import { getPreviewUrl, reloadPreview } from '@cms/modules/preview.utils.js'; -import { getContentNode, getContent, setContent } from '@cms/modules/rpc/rpc-content.js'; +import { reloadPreview } from '@cms/modules/preview.utils.js'; +import { getContent, setContent } from '@cms/modules/rpc/rpc-content.js'; import { i18n } from '@cms/modules/localization.js'; import { showToast } from '@cms/modules/toast.js'; // hook.js export async function runAction(params) { - var uri = null; - if (params.uri) { - uri = params.uri; - } - else { - const contentNode = await getContentNode({ - url: getPreviewUrl() - }); - uri = contentNode.result.uri; - } + const uri = params.uri || null; try { - const nodeContent = await getContent({ - uri: uri - }); + const nodeContent = await getContent(uri ? { uri } : {}); const form = createForm({ fields: [ { @@ -63,7 +52,7 @@ export async function runAction(params) { var updateData = form.getData(); try { await setContent({ - uri: uri, + ...(uri && { uri }), content: updateData.content }); showToast({ diff --git a/modules/ui-module/src/main/resources/manager/actions/page/edit-metaattribute-form.js b/modules/ui-module/src/main/resources/manager/actions/page/edit-metaattribute-form.js index 5d2aae81e..476a9f22b 100644 --- a/modules/ui-module/src/main/resources/manager/actions/page/edit-metaattribute-form.js +++ b/modules/ui-module/src/main/resources/manager/actions/page/edit-metaattribute-form.js @@ -20,28 +20,17 @@ */ import { createForm } from '@cms/modules/form/forms.js'; import { showToast } from '@cms/modules/toast.js'; -import { getPreviewUrl, reloadPreview } from '@cms/modules/preview.utils.js'; +import { reloadPreview } from '@cms/modules/preview.utils.js'; import { buildValuesFromFields, getValueByPath } from '@cms/modules/node.js'; -import { getContentNode, getContent, setMeta } from '@cms/modules/rpc/rpc-content.js'; +import { getContent, setMeta } from '@cms/modules/rpc/rpc-content.js'; import { i18n } from '@cms/modules/localization.js'; import { openSidebar } from '@cms/modules/sidebar.js'; import { getPageTemplates, getSectionEntryTemplates } from '@cms/modules/rpc/rpc-manager'; // hook.js export async function runAction(params) { - var uri = null; - if (params.uri) { - uri = params.uri; - } - else { - const contentNode = await getContentNode({ - url: getPreviewUrl() - }); - uri = contentNode.result.uri; - } + const uri = params.uri || null; try { - const getContentResponse = await getContent({ - uri: uri - }); + const getContentResponse = await getContent(uri ? { uri } : {}); var templates = null; if (params.type === "sectionEntry") { templates = (await getSectionEntryTemplates()).result; @@ -75,7 +64,7 @@ export async function runAction(params) { var updateData = form.getData(); try { await setMeta({ - uri: uri, + ...(uri && { uri }), meta: updateData }); showToast({ diff --git a/modules/ui-module/src/main/resources/manager/actions/page/edit-metaattribute-list.js b/modules/ui-module/src/main/resources/manager/actions/page/edit-metaattribute-list.js index d7285ba9d..a69dc11da 100644 --- a/modules/ui-module/src/main/resources/manager/actions/page/edit-metaattribute-list.js +++ b/modules/ui-module/src/main/resources/manager/actions/page/edit-metaattribute-list.js @@ -20,27 +20,16 @@ */ import { createForm } from '@cms/modules/form/forms.js'; import { showToast } from '@cms/modules/toast.js'; -import { getPreviewUrl, reloadPreview } from '@cms/modules/preview.utils.js'; +import { reloadPreview } from '@cms/modules/preview.utils.js'; import { getValueByPath } from '@cms/modules/node.js'; -import { getContentNode, getContent, setMeta } from '@cms/modules/rpc/rpc-content.js'; +import { getContent, setMeta } from '@cms/modules/rpc/rpc-content.js'; import { i18n } from '@cms/modules/localization.js'; import { openSidebar } from '@cms/modules/sidebar.js'; // hook.js export async function runAction(params) { - var uri = null; - if (params.uri) { - uri = params.uri; - } - else { - const contentNode = await getContentNode({ - url: getPreviewUrl() - }); - uri = contentNode.result.uri; - } + const uri = params.uri || null; try { - const getContentResponse = await getContent({ - uri: uri - }); + const getContentResponse = await getContent(uri ? { uri } : {}); let formDefinition = { fields: [], values: {} @@ -65,7 +54,7 @@ export async function runAction(params) { var updateData = form.getData(); try { await setMeta({ - uri: uri, + ...(uri && { uri }), meta: updateData }); showToast({ diff --git a/modules/ui-module/src/main/resources/manager/actions/page/edit-metaattribute.js b/modules/ui-module/src/main/resources/manager/actions/page/edit-metaattribute.js index ceb2f2d4b..7f29e2419 100644 --- a/modules/ui-module/src/main/resources/manager/actions/page/edit-metaattribute.js +++ b/modules/ui-module/src/main/resources/manager/actions/page/edit-metaattribute.js @@ -20,27 +20,16 @@ */ import { createForm } from '@cms/modules/form/forms.js'; import { showToast } from '@cms/modules/toast.js'; -import { getPreviewUrl, reloadPreview } from '@cms/modules/preview.utils.js'; +import { reloadPreview } from '@cms/modules/preview.utils.js'; import { getValueByPath } from '@cms/modules/node.js'; -import { getContentNode, setMeta, getContent } from '@cms/modules/rpc/rpc-content.js'; +import { setMeta, getContent } from '@cms/modules/rpc/rpc-content.js'; import { i18n } from '@cms/modules/localization.js'; import { openSidebar } from '@cms/modules/sidebar.js'; // hook.js export async function runAction(params) { - var uri = null; - if (params.uri) { - uri = params.uri; - } - else { - const contentNode = await getContentNode({ - url: getPreviewUrl() - }); - uri = contentNode.result.uri; - } + const uri = params.uri || null; try { - const getContentResponse = await getContent({ - uri: uri - }); + const getContentResponse = await getContent(uri ? { uri } : {}); let formDefinition = { fields: [ { @@ -64,7 +53,7 @@ export async function runAction(params) { var updateData = form.getData(); try { await setMeta({ - uri: uri, + ...(uri && { uri }), meta: updateData }); showToast({ diff --git a/modules/ui-module/src/main/resources/manager/actions/page/edit-page-settings.js b/modules/ui-module/src/main/resources/manager/actions/page/edit-page-settings.js index f2a9171fd..1c16a0582 100644 --- a/modules/ui-module/src/main/resources/manager/actions/page/edit-page-settings.js +++ b/modules/ui-module/src/main/resources/manager/actions/page/edit-page-settings.js @@ -21,8 +21,8 @@ import { openSidebar } from '@cms/modules/sidebar.js'; import { createForm } from '@cms/modules/form/forms.js'; import { showToast } from '@cms/modules/toast.js'; -import { getContentNode, setMeta, getContent } from '@cms/modules/rpc/rpc-content.js'; -import { getPreviewUrl, reloadPreview } from '@cms/modules/preview.utils.js'; +import { setMeta, getContent } from '@cms/modules/rpc/rpc-content.js'; +import { reloadPreview } from '@cms/modules/preview.utils.js'; import { i18n } from '@cms/modules/localization.js'; import { getPageTemplates } from '@cms/modules/rpc/rpc-manager.js'; import { buildValuesFromFields } from '@cms/modules/node.js'; @@ -36,13 +36,8 @@ const DEFAULT_FIELDS = [ } ]; export async function runAction(params) { - const contentNode = await getContentNode({ - url: getPreviewUrl() - }); try { - const getContentResponse = await getContent({ - uri: contentNode.result.uri - }); + const getContentResponse = await getContent({}); var pageTemplates = (await getPageTemplates()).result; var selected = pageTemplates.filter(pageTemplate => pageTemplate.template === getContentResponse?.result?.meta?.template); var pageSettingsForm = []; @@ -75,7 +70,6 @@ export async function runAction(params) { var updateData = form.getData(); try { await setMeta({ - uri: contentNode.result.uri, meta: updateData }); showToast({ diff --git a/modules/ui-module/src/main/resources/manager/actions/page/edit-schedule.js b/modules/ui-module/src/main/resources/manager/actions/page/edit-schedule.js index a8b6e2a0c..3de471ef1 100644 --- a/modules/ui-module/src/main/resources/manager/actions/page/edit-schedule.js +++ b/modules/ui-module/src/main/resources/manager/actions/page/edit-schedule.js @@ -21,8 +21,8 @@ import { openModal } from '@cms/modules/modal.js'; import { createForm } from '@cms/modules/form/forms.js'; import { showToast } from '@cms/modules/toast.js'; -import { getContentNode, setMeta, getContent } from '@cms/modules/rpc/rpc-content.js'; -import { getPreviewUrl, reloadPreview } from '@cms/modules/preview.utils.js'; +import { setMeta, getContent } from '@cms/modules/rpc/rpc-content.js'; +import { reloadPreview } from '@cms/modules/preview.utils.js'; import { i18n } from '@cms/modules/localization.js'; import { getPageTemplates } from '@cms/modules/rpc/rpc-manager.js'; import { buildValuesFromFields } from '@cms/modules/node.js'; @@ -39,13 +39,8 @@ const DEFAULT_FIELDS = [ } ]; export async function runAction(params) { - const contentNode = await getContentNode({ - url: getPreviewUrl() - }); try { - const getContentResponse = await getContent({ - uri: contentNode.result.uri - }); + const getContentResponse = await getContent({}); //const previewMetaForm = getMetaForm() const fields = [ ...DEFAULT_FIELDS, @@ -68,7 +63,6 @@ export async function runAction(params) { var updateData = form.getData(); try { await setMeta({ - uri: contentNode.result.uri, meta: updateData }); showToast({ diff --git a/modules/ui-module/src/main/resources/manager/actions/page/variants.js b/modules/ui-module/src/main/resources/manager/actions/page/variants.js index 8f01719fb..914a22ddb 100644 --- a/modules/ui-module/src/main/resources/manager/actions/page/variants.js +++ b/modules/ui-module/src/main/resources/manager/actions/page/variants.js @@ -29,7 +29,7 @@ const variantTitle = (variant) => { const title = variant.meta?.title; return typeof title === 'string' && title.trim() ? title : variant.id; }; -const createVariantLink = (titleText, idText, uriText, url, active, modal) => { +const createVariantLink = (titleText, idText, uriText, url, variantId, active, modal) => { const link = document.createElement('a'); link.href = url; link.className = `list-group-item list-group-item-action${active ? ' active' : ''}`; @@ -51,7 +51,7 @@ const createVariantLink = (titleText, idText, uriText, url, active, modal) => { link.addEventListener('click', (event) => { event.preventDefault(); modal.hide(); - loadPreview(url); + loadPreview(url, variantId ? { variant: variantId } : {}); }); return link; }; @@ -65,9 +65,9 @@ const renderVariants = (container, result, modal) => { } const list = document.createElement('div'); list.className = 'list-group'; - list.appendChild(createVariantLink(result.canonical.title, i18n.t('manager.actions.page.variants.canonical', 'Original'), result.canonical.uri, result.canonical.url, !result.activeVariantId, modal)); + list.appendChild(createVariantLink(result.canonical.title, i18n.t('manager.actions.page.variants.canonical', 'Original'), result.canonical.uri, result.canonical.url, null, !result.activeVariantId, modal)); result.variants.forEach((variant) => { - list.appendChild(createVariantLink(variantTitle(variant), variant.id, variant.uri, variant.url, result.activeVariantId === variant.id, modal)); + list.appendChild(createVariantLink(variantTitle(variant), variant.id, variant.uri, result.canonical.url, variant.id, result.activeVariantId === variant.id, modal)); }); container.appendChild(list); }; diff --git a/modules/ui-module/src/main/resources/manager/actions/page/wf-run-transition.js b/modules/ui-module/src/main/resources/manager/actions/page/wf-run-transition.js index 5add8ba6d..3dae34895 100644 --- a/modules/ui-module/src/main/resources/manager/actions/page/wf-run-transition.js +++ b/modules/ui-module/src/main/resources/manager/actions/page/wf-run-transition.js @@ -24,7 +24,6 @@ import { wfTransit } from "@cms/modules/rpc/rpc-workflow"; import { showToast } from "@cms/modules/toast"; export async function runAction(params) { var request = { - uri: params.uri, transitionId: params.transitionId }; try { diff --git a/modules/ui-module/src/main/resources/manager/js/manager.js b/modules/ui-module/src/main/resources/manager/js/manager.js index 440c1d11e..bb2280f5e 100644 --- a/modules/ui-module/src/main/resources/manager/js/manager.js +++ b/modules/ui-module/src/main/resources/manager/js/manager.js @@ -105,6 +105,7 @@ const updateCurrentPage = (url) => { displayUrl.searchParams.delete("preview"); displayUrl.searchParams.delete("preview-token"); displayUrl.searchParams.delete("nocache"); + displayUrl.searchParams.delete("variant"); const displayPath = `${displayUrl.pathname}${displayUrl.search}${displayUrl.hash}`; pagePath.textContent = displayPath; pagePath.closest(".cms-current-page")?.setAttribute("title", displayUrl.href); diff --git a/modules/ui-module/src/main/resources/manager/js/modules/manager-ui.js b/modules/ui-module/src/main/resources/manager/js/modules/manager-ui.js index 5b99de065..3548c5aa9 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/manager-ui.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/manager-ui.js @@ -19,28 +19,20 @@ * #L% */ import { getPreviewUrl } from '@cms/modules/preview.utils.js'; -import { getContent, getContentNode } from '@cms/modules/rpc/rpc-content.js'; import { getWfManagerStatus } from './rpc/rpc-workflow'; import { executeScriptAction } from '../manager-globals'; +import { getActivePreviewContent } from './preview-context.js'; export function updateStateButton() { - var previewUrl = getPreviewUrl(); - ; - if (!previewUrl) { - document.querySelector('#cms-btn-status').classList.add('disabled'); - document.querySelector('#cms-btn-status').setAttribute('title', 'No preview URL available'); + const previewUrl = getPreviewUrl(); + const activePreviewContent = getActivePreviewContent(); + if (!previewUrl || !activePreviewContent?.uri) { + const statusButton = document.querySelector('#cms-btn-status'); + statusButton?.classList.add('disabled'); + statusButton?.setAttribute('title', 'No preview content available'); return; } - var previewUrl = getPreviewUrl(); - getContentNode({ - url: previewUrl - }).then((contentNode) => { - getWfManagerStatus({ - uri: contentNode.result.uri - }).then((getStatusResponse) => { - updateNodeStatus(getStatusResponse, contentNode.result.uri); - }).catch(() => { - hideStatusButton(); - }); + getWfManagerStatus({}).then((getStatusResponse) => { + updateNodeStatus(getStatusResponse); }).catch(() => { hideStatusButton(); }); @@ -51,7 +43,7 @@ function hideStatusButton() { statusBtn.classList.add('disabled'); } } -function updateNodeStatus(statusResponse, uri) { +function updateNodeStatus(statusResponse) { const statusBtn = document.querySelector('#cms-btn-status'); if (!statusBtn) return; @@ -97,9 +89,9 @@ function updateNodeStatus(statusResponse, uri) { statusBtn.classList.add(statusClass); iconEl.classList.add(statusIcon); statusBtn.querySelector('#cms-btn-status-text').textContent = statusText; - updateWorkflowStatus(statusResponse, uri); + updateWorkflowStatus(statusResponse); } -const updateWorkflowStatus = (statusResponse, uri) => { +const updateWorkflowStatus = (statusResponse) => { let visibilityStatus = document.querySelector('#cms-workflow-visibility'); Array.from(visibilityStatus.classList).forEach(className => { if (className.startsWith('bi-')) { @@ -146,16 +138,15 @@ const updateWorkflowStatus = (statusResponse, uri) => { wfTransitionsContainer.querySelectorAll('.workflow-transition').forEach((btn, index) => { btn.addEventListener('click', () => { const transition = transitions[index]; - executeTransition(uri, transition.id); + executeTransition(transition.id); }); }); }; -const executeTransition = async (uri, transitionId) => { +const executeTransition = async (transitionId) => { var cmd = { "module": window.manager.baseUrl + "/actions/page/wf-run-transition", "function": "runAction", "parameters": { - "uri": uri, "transitionId": transitionId } }; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/manager/manager.message.handlers.js b/modules/ui-module/src/main/resources/manager/js/modules/manager/manager.message.handlers.js index 55c9eef94..3894184d8 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/manager/manager.message.handlers.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/manager/manager.message.handlers.js @@ -22,6 +22,8 @@ import { executeScriptAction } from '@cms/js/manager-globals.js'; import frameMessenger from '@cms/modules/frameMessenger.js'; import { getPreviewFrame, getPreviewUrl } from '@cms/modules/preview.utils.js'; import { getContentNode, setMetaBatch } from '@cms/modules/rpc/rpc-content.js'; +import { setActivePreviewContent } from '@cms/modules/preview-context.js'; +import { updateStateButton } from '@cms/modules/manager-ui.js'; const executeImageForm = (payload) => { const cmd = { "module": window.manager.baseUrl + "/actions/media/edit-media-form", @@ -181,6 +183,8 @@ const initMessageHandlers = () => { const contentNode = await getContentNode({ url: getPreviewUrl() }); + setActivePreviewContent(contentNode.result); + updateStateButton(); var message = { "type": "getContentNodeResponse", "payload": { diff --git a/modules/ui-module/src/main/resources/manager/js/modules/preview-context.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/preview-context.d.ts new file mode 100644 index 000000000..9c4ecd254 --- /dev/null +++ b/modules/ui-module/src/main/resources/manager/js/modules/preview-context.d.ts @@ -0,0 +1,28 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ +export interface ActivePreviewContent { + uri: string; + canonicalUri?: string; + variantId?: string | null; +} +declare const setActivePreviewContent: (content: ActivePreviewContent | null) => void; +declare const getActivePreviewContent: () => ActivePreviewContent | null; +export { getActivePreviewContent, setActivePreviewContent }; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/preview-context.js b/modules/ui-module/src/main/resources/manager/js/modules/preview-context.js new file mode 100644 index 000000000..39b60843e --- /dev/null +++ b/modules/ui-module/src/main/resources/manager/js/modules/preview-context.js @@ -0,0 +1,26 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ +let activeContent = null; +const setActivePreviewContent = (content) => { + activeContent = content?.uri ? { ...content } : null; +}; +const getActivePreviewContent = () => activeContent; +export { getActivePreviewContent, setActivePreviewContent }; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/preview.utils.js b/modules/ui-module/src/main/resources/manager/js/modules/preview.utils.js index efc5be27a..b889392fd 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/preview.utils.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/preview.utils.js @@ -19,6 +19,7 @@ * #L% */ import { EventBus } from "@cms/modules/event-bus.js"; +import { setActivePreviewContent } from "@cms/modules/preview-context.js"; //PreviewHistory.init(); // close overlay on preview loaded EventBus.on("preview:loaded", (data) => { @@ -52,8 +53,9 @@ const reloadPreview = () => { activatePreviewOverlay(); getPreviewFrame().contentDocument.location.reload(true); }; -const loadPreview = (url) => { +const loadPreview = (url, options = {}) => { activatePreviewOverlay(); + setActivePreviewContent(null); try { // Fallback-Host für relative URLs, damit URL-Parsing funktioniert const dummyBase = window.location.origin; @@ -62,6 +64,9 @@ const loadPreview = (url) => { if (!parsedUrl.searchParams.has("preview")) { parsedUrl.searchParams.append("preview", "manager"); } + if (options.variant) { + parsedUrl.searchParams.set("variant", options.variant); + } parsedUrl.searchParams.delete("preview-token"); //parsedUrl.searchParams.append("preview-token", window.manager.previewToken); parsedUrl.searchParams.delete("nocache"); diff --git a/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-workflow.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-workflow.d.ts index b11822c10..569b3c2e6 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-workflow.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-workflow.d.ts @@ -19,7 +19,7 @@ * #L% */ export interface GetTransitionsRequest { - uri: string; + uri?: string; } export interface GetTransitionsDto { id: string; @@ -29,7 +29,7 @@ declare const getWfTransitions: (options: GetTransitionsRequest) => Promise<{ transitions: GetTransitionsDto[]; }>; export interface GetWFManagerRequest { - uri: string; + uri?: string; } export interface GetWFManagerStatusDto { published: boolean; @@ -49,7 +49,7 @@ export interface getWFManagerDto { } declare const getWfManagerStatus: (options: GetWFManagerRequest) => Promise; export interface WfTransitRequest { - uri: string; + uri?: string; transitionId: string; } export interface WFTransitDto { diff --git a/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc.js b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc.js index 985f77d3d..7c0ca3884 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc.js @@ -20,6 +20,7 @@ */ import { i18n } from "@cms/modules/localization.js"; import { getCSRFToken } from "../utils"; +import { getActivePreviewContent } from "../preview-context.js"; export class RPCClientError extends Error { constructor(code, message) { super(message); @@ -36,11 +37,15 @@ const executeRemoteMethodCall = async (method, parameters) => { parameters: parameters }; const csrfToken = getCSRFToken(); + const activePreviewContent = getActivePreviewContent(); var response = await fetch(window.manager.baseUrl + "/rpc", { method: "POST", headers: { 'Content-Type': 'application/json', - ...(csrfToken && { 'X-CSRF-Token': csrfToken }) + ...(csrfToken && { 'X-CSRF-Token': csrfToken }), + ...(activePreviewContent?.uri && { + 'X-CMS-Content-Uri': activePreviewContent.uri + }) }, body: JSON.stringify(data) }); diff --git a/modules/ui-module/src/main/ts/src/actions/page/add-section.js b/modules/ui-module/src/main/ts/src/actions/page/add-section.js index c84936380..c2bf1d210 100644 --- a/modules/ui-module/src/main/ts/src/actions/page/add-section.js +++ b/modules/ui-module/src/main/ts/src/actions/page/add-section.js @@ -20,18 +20,14 @@ */ import { openModal } from '@cms/modules/modal.js' import { showToast } from '@cms/modules/toast.js' -import { addSection, getContentNode } from '@cms/modules/rpc/rpc-content.js' -import { getPreviewUrl, reloadPreview } from '@cms/modules/preview.utils.js' +import { addSection } from '@cms/modules/rpc/rpc-content.js' +import { reloadPreview } from '@cms/modules/preview.utils.js' import Handlebars from 'https://cdn.jsdelivr.net/npm/handlebars@4.7.8/+esm' import { i18n } from '@cms/modules/localization.js' import { getSectionEntryTemplates } from '@cms/modules/rpc/rpc-manager.js'; export async function runAction(params) { - const contentNode = await getContentNode({ - url: getPreviewUrl() - }) - var template = Handlebars.compile(`
@@ -57,9 +53,9 @@ export async function runAction(params) { }), fullscreen: false, onCancel: (event) => {}, - validate: () => validate(contentNode, params.section), + validate: () => validate(params.section), onOk: async (event) => { - var result = await createSection(contentNode.result.uri, params.section); + var result = await createSection(params.section); if (result) { showToast({ title: i18n.t("manager.actions.addsection.titles.alert", "Create Entry"), @@ -86,7 +82,7 @@ const getSectionEntryName = () => { return document.getElementById("cms-section-name").value } -const validate = (contentNode, targetSection) => { +const validate = (targetSection) => { const template = document.getElementById("cms-section-template-selection").value if (template === "000") { showToast({ @@ -132,7 +128,7 @@ function isUriInSection(data, sectionEntryKey, targetUri) { return sectionArray.some(item => item.uri === targetUri); } -const createSection = async (parentUri, parentSectionName) => { +const createSection = async (parentSectionName) => { const template = document.getElementById("cms-section-template-selection").value if (template === "000") { @@ -140,7 +136,6 @@ const createSection = async (parentUri, parentSectionName) => { } try { await addSection({ - parentUri: parentUri, sectionEntryName: getSectionEntryName(), section: parentSectionName, template: template diff --git a/modules/ui-module/src/main/ts/src/actions/page/edit-content.js b/modules/ui-module/src/main/ts/src/actions/page/edit-content.js index f76833f0d..939f13071 100644 --- a/modules/ui-module/src/main/ts/src/actions/page/edit-content.js +++ b/modules/ui-module/src/main/ts/src/actions/page/edit-content.js @@ -20,27 +20,17 @@ */ import {openModal} from '@cms/modules/modal.js' import {createForm} from '@cms/modules/form/forms.js' -import {getPreviewUrl, reloadPreview} from '@cms/modules/preview.utils.js' -import {getContentNode, getContent, setContent} from '@cms/modules/rpc/rpc-content.js' +import {reloadPreview} from '@cms/modules/preview.utils.js' +import {getContent, setContent} from '@cms/modules/rpc/rpc-content.js' import { i18n } from '@cms/modules/localization.js' import { showToast } from '@cms/modules/toast.js' // hook.js export async function runAction(params) { - var uri = null - if (params.uri) { - uri = params.uri - } else { - const contentNode = await getContentNode({ - url: getPreviewUrl() - }) - uri = contentNode.result.uri - } + const uri = params.uri || null try { - const nodeContent = await getContent({ - uri: uri - }) + const nodeContent = await getContent(uri ? { uri } : {}) const form = createForm({ fields: [ @@ -66,7 +56,7 @@ export async function runAction(params) { var updateData = form.getData() try { await setContent({ - uri: uri, + ...(uri && { uri }), content: updateData.content }) showToast({ diff --git a/modules/ui-module/src/main/ts/src/actions/page/edit-metaattribute-form.js b/modules/ui-module/src/main/ts/src/actions/page/edit-metaattribute-form.js index fdca73529..4cfb00d6f 100644 --- a/modules/ui-module/src/main/ts/src/actions/page/edit-metaattribute-form.js +++ b/modules/ui-module/src/main/ts/src/actions/page/edit-metaattribute-form.js @@ -20,9 +20,9 @@ */ import {createForm} from '@cms/modules/form/forms.js' import {showToast} from '@cms/modules/toast.js' -import {getPreviewUrl, reloadPreview} from '@cms/modules/preview.utils.js' +import {reloadPreview} from '@cms/modules/preview.utils.js' import { buildValuesFromFields, getValueByPath } from '@cms/modules/node.js' -import {getContentNode, getContent, setMeta} from '@cms/modules/rpc/rpc-content.js' +import {getContent, setMeta} from '@cms/modules/rpc/rpc-content.js' import { i18n } from '@cms/modules/localization.js' import { openSidebar } from '@cms/modules/sidebar.js' import { getPageTemplates, getSectionEntryTemplates } from '@cms/modules/rpc/rpc-manager' @@ -30,20 +30,10 @@ import { getPageTemplates, getSectionEntryTemplates } from '@cms/modules/rpc/rpc export async function runAction(params) { - var uri = null - if (params.uri) { - uri = params.uri - } else { - const contentNode = await getContentNode({ - url: getPreviewUrl() - }) - uri = contentNode.result.uri - } + const uri = params.uri || null try { - const getContentResponse = await getContent({ - uri: uri - }) + const getContentResponse = await getContent(uri ? { uri } : {}) var templates = null @@ -86,7 +76,7 @@ export async function runAction(params) { var updateData = form.getData() try { await setMeta({ - uri: uri, + ...(uri && { uri }), meta: updateData }) showToast({ diff --git a/modules/ui-module/src/main/ts/src/actions/page/edit-metaattribute-list.js b/modules/ui-module/src/main/ts/src/actions/page/edit-metaattribute-list.js index 1c46a48e2..0f35c17c8 100644 --- a/modules/ui-module/src/main/ts/src/actions/page/edit-metaattribute-list.js +++ b/modules/ui-module/src/main/ts/src/actions/page/edit-metaattribute-list.js @@ -20,29 +20,19 @@ */ import {createForm} from '@cms/modules/form/forms.js' import {showToast} from '@cms/modules/toast.js' -import {getPreviewUrl, reloadPreview} from '@cms/modules/preview.utils.js' +import {reloadPreview} from '@cms/modules/preview.utils.js' import { getValueByPath } from '@cms/modules/node.js' -import {getContentNode, getContent, setMeta} from '@cms/modules/rpc/rpc-content.js' +import {getContent, setMeta} from '@cms/modules/rpc/rpc-content.js' import { i18n } from '@cms/modules/localization.js' import { openSidebar } from '@cms/modules/sidebar.js' // hook.js export async function runAction(params) { - var uri = null - if (params.uri) { - uri = params.uri - } else { - const contentNode = await getContentNode({ - url: getPreviewUrl() - }) - uri = contentNode.result.uri - } + const uri = params.uri || null try { - const getContentResponse = await getContent({ - uri: uri - }) + const getContentResponse = await getContent(uri ? { uri } : {}) let formDefinition = { fields: [], @@ -74,7 +64,7 @@ export async function runAction(params) { var updateData = form.getData() try { await setMeta({ - uri: uri, + ...(uri && { uri }), meta: updateData }) showToast({ diff --git a/modules/ui-module/src/main/ts/src/actions/page/edit-metaattribute.js b/modules/ui-module/src/main/ts/src/actions/page/edit-metaattribute.js index 533a7eaf2..503b71666 100644 --- a/modules/ui-module/src/main/ts/src/actions/page/edit-metaattribute.js +++ b/modules/ui-module/src/main/ts/src/actions/page/edit-metaattribute.js @@ -20,29 +20,19 @@ */ import {createForm} from '@cms/modules/form/forms.js' import {showToast} from '@cms/modules/toast.js' -import {getPreviewUrl, reloadPreview} from '@cms/modules/preview.utils.js' +import {reloadPreview} from '@cms/modules/preview.utils.js' import { getValueByPath } from '@cms/modules/node.js' -import { getContentNode, setMeta, getContent} from '@cms/modules/rpc/rpc-content.js' +import { setMeta, getContent} from '@cms/modules/rpc/rpc-content.js' import { i18n } from '@cms/modules/localization.js' import { openSidebar } from '@cms/modules/sidebar.js' // hook.js export async function runAction(params) { - var uri = null - if (params.uri) { - uri = params.uri - } else { - const contentNode = await getContentNode({ - url: getPreviewUrl() - }) - uri = contentNode.result.uri - } + const uri = params.uri || null try { - const getContentResponse = await getContent({ - uri: uri - }) + const getContentResponse = await getContent(uri ? { uri } : {}) let formDefinition = { fields: [ @@ -69,7 +59,7 @@ export async function runAction(params) { var updateData = form.getData() try { await setMeta({ - uri: uri, + ...(uri && { uri }), meta: updateData }) showToast({ diff --git a/modules/ui-module/src/main/ts/src/actions/page/edit-page-settings.js b/modules/ui-module/src/main/ts/src/actions/page/edit-page-settings.js index cebbddc40..d8da138ea 100644 --- a/modules/ui-module/src/main/ts/src/actions/page/edit-page-settings.js +++ b/modules/ui-module/src/main/ts/src/actions/page/edit-page-settings.js @@ -21,8 +21,8 @@ import { openSidebar } from '@cms/modules/sidebar.js' import { createForm } from '@cms/modules/form/forms.js' import { showToast } from '@cms/modules/toast.js' -import { getContentNode, setMeta, getContent } from '@cms/modules/rpc/rpc-content.js' -import { getPreviewUrl, reloadPreview } from '@cms/modules/preview.utils.js' +import { setMeta, getContent } from '@cms/modules/rpc/rpc-content.js' +import { reloadPreview } from '@cms/modules/preview.utils.js' import { i18n } from '@cms/modules/localization.js' import { getPageTemplates } from '@cms/modules/rpc/rpc-manager.js' import { buildValuesFromFields } from '@cms/modules/node.js' @@ -39,14 +39,8 @@ const DEFAULT_FIELDS = [ export async function runAction(params) { - const contentNode = await getContentNode({ - url: getPreviewUrl() - }) - try { - const getContentResponse = await getContent({ - uri: contentNode.result.uri - }) + const getContentResponse = await getContent({}) var pageTemplates = (await getPageTemplates()).result @@ -87,7 +81,6 @@ export async function runAction(params) { var updateData = form.getData() try { await setMeta({ - uri: contentNode.result.uri, meta: updateData }) showToast({ diff --git a/modules/ui-module/src/main/ts/src/actions/page/edit-schedule.js b/modules/ui-module/src/main/ts/src/actions/page/edit-schedule.js index 9cc9895de..584e92405 100644 --- a/modules/ui-module/src/main/ts/src/actions/page/edit-schedule.js +++ b/modules/ui-module/src/main/ts/src/actions/page/edit-schedule.js @@ -21,8 +21,8 @@ import { openModal } from '@cms/modules/modal.js' import { createForm } from '@cms/modules/form/forms.js' import { showToast } from '@cms/modules/toast.js' -import { getContentNode, setMeta, getContent } from '@cms/modules/rpc/rpc-content.js' -import { getPreviewUrl, reloadPreview } from '@cms/modules/preview.utils.js' +import { setMeta, getContent } from '@cms/modules/rpc/rpc-content.js' +import { reloadPreview } from '@cms/modules/preview.utils.js' import { i18n } from '@cms/modules/localization.js' import { getPageTemplates } from '@cms/modules/rpc/rpc-manager.js' import { buildValuesFromFields } from '@cms/modules/node.js' @@ -42,14 +42,8 @@ const DEFAULT_FIELDS = [ export async function runAction(params) { - const contentNode = await getContentNode({ - url: getPreviewUrl() - }) - try { - const getContentResponse = await getContent({ - uri: contentNode.result.uri - }) + const getContentResponse = await getContent({}) //const previewMetaForm = getMetaForm() const fields = [ @@ -77,7 +71,6 @@ export async function runAction(params) { var updateData = form.getData() try { await setMeta({ - uri: contentNode.result.uri, meta: updateData }) showToast({ diff --git a/modules/ui-module/src/main/ts/src/actions/page/variants.ts b/modules/ui-module/src/main/ts/src/actions/page/variants.ts index 6e48d4cd7..4115995a6 100644 --- a/modules/ui-module/src/main/ts/src/actions/page/variants.ts +++ b/modules/ui-module/src/main/ts/src/actions/page/variants.ts @@ -38,6 +38,7 @@ const createVariantLink = ( idText: string, uriText: string, url: string, + variantId: string | null, active: boolean, modal: any ): HTMLAnchorElement => { @@ -67,7 +68,7 @@ const createVariantLink = ( link.addEventListener('click', (event) => { event.preventDefault(); modal.hide(); - loadPreview(url); + loadPreview(url, variantId ? { variant: variantId } : {}); }); return link; }; @@ -91,6 +92,7 @@ const renderVariants = (container: HTMLElement, result: GetVariantsResult, modal i18n.t('manager.actions.page.variants.canonical', 'Original'), result.canonical.uri, result.canonical.url, + null, !result.activeVariantId, modal )); @@ -100,7 +102,8 @@ const renderVariants = (container: HTMLElement, result: GetVariantsResult, modal variantTitle(variant), variant.id, variant.uri, - variant.url, + result.canonical.url, + variant.id, result.activeVariantId === variant.id, modal )); diff --git a/modules/ui-module/src/main/ts/src/actions/page/wf-run-transition.ts b/modules/ui-module/src/main/ts/src/actions/page/wf-run-transition.ts index e80b3edc5..05bbb231b 100644 --- a/modules/ui-module/src/main/ts/src/actions/page/wf-run-transition.ts +++ b/modules/ui-module/src/main/ts/src/actions/page/wf-run-transition.ts @@ -27,7 +27,6 @@ import { showToast } from "@cms/modules/toast"; export async function runAction(params : any) { var request = { - uri : params.uri, transitionId: params.transitionId }; diff --git a/modules/ui-module/src/main/ts/src/js/manager.js b/modules/ui-module/src/main/ts/src/js/manager.js index 35edcd4bc..0bcfa3450 100644 --- a/modules/ui-module/src/main/ts/src/js/manager.js +++ b/modules/ui-module/src/main/ts/src/js/manager.js @@ -122,8 +122,9 @@ const updateCurrentPage = (url) => { const displayUrl = new URL(url); displayUrl.searchParams.delete("preview"); - displayUrl.searchParams.delete("preview-token"); - displayUrl.searchParams.delete("nocache"); + displayUrl.searchParams.delete("preview-token"); + displayUrl.searchParams.delete("nocache"); + displayUrl.searchParams.delete("variant"); const displayPath = `${displayUrl.pathname}${displayUrl.search}${displayUrl.hash}`; pagePath.textContent = displayPath; diff --git a/modules/ui-module/src/main/ts/src/js/modules/manager-ui.js b/modules/ui-module/src/main/ts/src/js/modules/manager-ui.js index 5cc029d34..cb06f2787 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/manager-ui.js +++ b/modules/ui-module/src/main/ts/src/js/modules/manager-ui.js @@ -20,31 +20,22 @@ */ import { getPreviewUrl } from '@cms/modules/preview.utils.js'; -import { getContent, getContentNode } from '@cms/modules/rpc/rpc-content.js'; import { getWfManagerStatus } from './rpc/rpc-workflow'; import { executeScriptAction } from '../manager-globals'; +import { getActivePreviewContent } from './preview-context.js'; export function updateStateButton() { - var previewUrl = getPreviewUrl();; - if (!previewUrl) { - document.querySelector('#cms-btn-status').classList.add('disabled'); - document.querySelector('#cms-btn-status').setAttribute('title', 'No preview URL available'); + const previewUrl = getPreviewUrl(); + const activePreviewContent = getActivePreviewContent(); + if (!previewUrl || !activePreviewContent?.uri) { + const statusButton = document.querySelector('#cms-btn-status'); + statusButton?.classList.add('disabled'); + statusButton?.setAttribute('title', 'No preview content available'); return; - } - var previewUrl = getPreviewUrl(); - - getContentNode({ - url: previewUrl - }).then((contentNode) => { - - getWfManagerStatus({ - uri: contentNode.result.uri - }).then((getStatusResponse) => { - updateNodeStatus(getStatusResponse, contentNode.result.uri); - }).catch(() => { - hideStatusButton(); - }); + + getWfManagerStatus({}).then((getStatusResponse) => { + updateNodeStatus(getStatusResponse); }).catch(() => { hideStatusButton(); }); @@ -57,7 +48,7 @@ function hideStatusButton() { } } -function updateNodeStatus(statusResponse, uri) { +function updateNodeStatus(statusResponse) { const statusBtn = document.querySelector('#cms-btn-status'); if (!statusBtn) return; const iconEl = statusBtn.querySelector('#cms-btn-status-icon'); @@ -106,10 +97,10 @@ function updateNodeStatus(statusResponse, uri) { iconEl.classList.add(statusIcon); statusBtn.querySelector('#cms-btn-status-text').textContent = statusText; - updateWorkflowStatus(statusResponse, uri); + updateWorkflowStatus(statusResponse); } -const updateWorkflowStatus = (statusResponse, uri) => { +const updateWorkflowStatus = (statusResponse) => { let visibilityStatus = document.querySelector('#cms-workflow-visibility'); Array.from(visibilityStatus.classList).forEach(className => { @@ -158,17 +149,16 @@ const updateWorkflowStatus = (statusResponse, uri) => { wfTransitionsContainer.querySelectorAll('.workflow-transition').forEach((btn, index) => { btn.addEventListener('click', () => { const transition = transitions[index]; - executeTransition(uri, transition.id); + executeTransition(transition.id); }); }); } -const executeTransition = async (uri, transitionId) => { +const executeTransition = async (transitionId) => { var cmd = { "module": window.manager.baseUrl + "/actions/page/wf-run-transition", "function": "runAction", "parameters": { - "uri": uri, "transitionId": transitionId } } diff --git a/modules/ui-module/src/main/ts/src/js/modules/manager/manager.message.handlers.ts b/modules/ui-module/src/main/ts/src/js/modules/manager/manager.message.handlers.ts index 40dfd791e..86bbd17d5 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/manager/manager.message.handlers.ts +++ b/modules/ui-module/src/main/ts/src/js/modules/manager/manager.message.handlers.ts @@ -22,6 +22,8 @@ import { executeScriptAction } from '@cms/js/manager-globals.js'; import frameMessenger from '@cms/modules/frameMessenger.js'; import { getPreviewFrame, getPreviewUrl } from '@cms/modules/preview.utils.js'; import { getContentNode, setMetaBatch } from '@cms/modules/rpc/rpc-content.js'; +import { setActivePreviewContent } from '@cms/modules/preview-context.js'; +import { updateStateButton } from '@cms/modules/manager-ui.js'; const executeImageForm = (payload: any) => { const cmd: any = { @@ -185,6 +187,8 @@ const initMessageHandlers = () => { const contentNode = await getContentNode({ url: getPreviewUrl() }) + setActivePreviewContent(contentNode.result); + updateStateButton(); var message : any = { "type": "getContentNodeResponse", "payload": { diff --git a/modules/ui-module/src/main/ts/src/js/modules/preview-context.ts b/modules/ui-module/src/main/ts/src/js/modules/preview-context.ts new file mode 100644 index 000000000..8900dbb94 --- /dev/null +++ b/modules/ui-module/src/main/ts/src/js/modules/preview-context.ts @@ -0,0 +1,36 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +export interface ActivePreviewContent { + uri: string; + canonicalUri?: string; + variantId?: string | null; +} + +let activeContent: ActivePreviewContent | null = null; + +const setActivePreviewContent = (content: ActivePreviewContent | null) => { + activeContent = content?.uri ? { ...content } : null; +}; + +const getActivePreviewContent = (): ActivePreviewContent | null => activeContent; + +export { getActivePreviewContent, setActivePreviewContent }; diff --git a/modules/ui-module/src/main/ts/src/js/modules/preview.utils.js b/modules/ui-module/src/main/ts/src/js/modules/preview.utils.js index 6877cb1d5..425eac3ba 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/preview.utils.js +++ b/modules/ui-module/src/main/ts/src/js/modules/preview.utils.js @@ -20,6 +20,7 @@ */ import { EventBus } from "@cms/modules/event-bus.js"; +import { setActivePreviewContent } from "@cms/modules/preview-context.js"; //PreviewHistory.init(); // close overlay on preview loaded @@ -58,8 +59,9 @@ const reloadPreview = () => { getPreviewFrame().contentDocument.location.reload(true); } -const loadPreview = (url) => { +const loadPreview = (url, options = {}) => { activatePreviewOverlay(); + setActivePreviewContent(null); try { // Fallback-Host für relative URLs, damit URL-Parsing funktioniert @@ -70,6 +72,9 @@ const loadPreview = (url) => { if (!parsedUrl.searchParams.has("preview")) { parsedUrl.searchParams.append("preview", "manager"); } + if (options.variant) { + parsedUrl.searchParams.set("variant", options.variant); + } parsedUrl.searchParams.delete("preview-token") //parsedUrl.searchParams.append("preview-token", window.manager.previewToken); parsedUrl.searchParams.delete("nocache"); diff --git a/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc-workflow.ts b/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc-workflow.ts index 41f487253..a8d00c1b0 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc-workflow.ts +++ b/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc-workflow.ts @@ -22,7 +22,7 @@ import { executeRemoteCall } from '@cms/modules/rpc/rpc.js' export interface GetTransitionsRequest { - uri: string; // The URI of the folder where the page should be created + uri?: string; } export interface GetTransitionsDto { id: string, @@ -37,7 +37,7 @@ const getWfTransitions = async (options: GetTransitionsRequest) => { }; export interface GetWFManagerRequest { - uri: string; // The URI of the folder where the page should be created + uri?: string; } export interface GetWFManagerStatusDto { published: boolean, @@ -64,7 +64,7 @@ const getWfManagerStatus = async (options: GetWFManagerRequest) => { }; export interface WfTransitRequest { - uri: string; // The URI of the folder where the page should be created + uri?: string; transitionId: string; // The URI of the folder where the page should be created } export interface WFTransitDto { diff --git a/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc.ts b/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc.ts index 1656f6c8a..a13c59a0e 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc.ts +++ b/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc.ts @@ -21,6 +21,7 @@ import { i18n } from "@cms/modules/localization.js"; import { getCSRFToken } from "../utils"; +import { getActivePreviewContent } from "../preview-context.js"; interface Options { method: string; @@ -50,11 +51,15 @@ const executeRemoteMethodCall = async (method : string, parameters : any) => { parameters: parameters } const csrfToken = getCSRFToken(); + const activePreviewContent = getActivePreviewContent(); var response = await fetch(window.manager.baseUrl + "/rpc", { method: "POST", headers: { 'Content-Type': 'application/json', - ...(csrfToken && { 'X-CSRF-Token': csrfToken }) + ...(csrfToken && { 'X-CSRF-Token': csrfToken }), + ...(activePreviewContent?.uri && { + 'X-CMS-Content-Uri': activePreviewContent.uri + }) }, body: JSON.stringify(data) }) diff --git a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteContentEndpointsExtensionTest.java b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteContentEndpointsExtensionTest.java index 35544bb8b..91deaef11 100644 --- a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteContentEndpointsExtensionTest.java +++ b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteContentEndpointsExtensionTest.java @@ -25,7 +25,11 @@ import com.condation.cms.api.db.DBFileSystem; import com.condation.cms.api.db.cms.ReadOnlyFile; import com.condation.cms.api.feature.features.DBFeature; +import com.condation.cms.api.feature.features.CurrentNodeFeature; import com.condation.cms.api.module.SiteModuleContext; +import com.condation.cms.api.db.ContentNode; +import com.condation.cms.api.request.RequestContext; +import com.condation.cms.api.request.RequestContextScope; import com.condation.cms.api.ui.rpc.RPCException; import java.io.IOException; import java.util.Map; @@ -90,4 +94,23 @@ void setContent_throwsRPCException_whenParsingFails() throws IOException { .isInstanceOf(RPCException.class) .hasMessage("disk error"); } + + @Test + void getContentUsesCurrentNodeWhenUriIsOmitted() throws Exception { + var uri = ".variants/about/summer/about.md"; + var requestContext = new RequestContext(); + requestContext.add( + CurrentNodeFeature.class, + new CurrentNodeFeature(new ContentNode(uri, "/about", "about.md", Map.of())) + ); + when(contentBase.resolve(uri)).thenReturn(contentFile); + when(contentFile.getContent()).thenThrow(new IOException("variant selected")); + + assertThatThrownBy(() -> ScopedValue.where( + RequestContextScope.REQUEST_CONTEXT, + requestContext + ).call(() -> endpoints.getContent(Map.of()))) + .isInstanceOf(RPCException.class) + .hasMessage("variant selected"); + } } diff --git a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpointTest.java b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpointTest.java index d4ab76159..4d5207c49 100644 --- a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpointTest.java +++ b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpointTest.java @@ -125,8 +125,8 @@ void getReturnsVariantsSortedById() throws RPCException { assertThat((List) result.get("variants")) .extracting(VariantDto::url) .containsExactly( - "/.variants/about/campaign/about?preview=manager", - "/.variants/about/summer/about?preview=manager" + "/about?preview=manager&variant=campaign", + "/about?preview=manager&variant=summer" ); assertThat(result).containsEntry("activeVariantId", null); } diff --git a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/http/RemoteCallHandlerTest.java b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/http/RemoteCallHandlerTest.java index cc4f8fc31..9bb4ab12b 100644 --- a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/http/RemoteCallHandlerTest.java +++ b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/http/RemoteCallHandlerTest.java @@ -22,6 +22,11 @@ */ import com.condation.cms.api.module.SiteModuleContext; +import com.condation.cms.api.db.Content; +import com.condation.cms.api.db.ContentNode; +import com.condation.cms.api.db.DB; +import com.condation.cms.api.feature.features.CurrentNodeFeature; +import com.condation.cms.api.feature.features.DBFeature; import com.condation.cms.api.request.RequestContext; import com.condation.cms.api.ui.rpc.RPCError; import com.condation.cms.api.ui.rpc.RPCException; @@ -33,10 +38,13 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; +import org.eclipse.jetty.http.HttpFields; +import org.eclipse.jetty.server.Request; import org.mockito.junit.jupiter.MockitoExtension; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.mock; @ExtendWith(MockitoExtension.class) class RemoteCallHandlerTest { @@ -78,4 +86,32 @@ void buildErrorResult_fallsBackToCodeMinusOne_forGenericException() throws Excep assertThat(result.error()).isEqualTo(new RPCError("boom")); assertThat(result.error().code()).isEqualTo(-1); } + + @Test + void contentUriHeaderAddsCurrentNodeFeature() throws Exception { + var context = new RequestContext(); + var db = mock(DB.class); + var content = mock(Content.class); + var request = mock(Request.class); + var headers = mock(HttpFields.class); + var node = new ContentNode( + ".variants/about/summer/about.md", + "/about", + "about.md", + Map.of() + ); + when(moduleContext.has(DBFeature.class)).thenReturn(true); + when(moduleContext.get(DBFeature.class)).thenReturn(new DBFeature(db)); + when(db.getContent()).thenReturn(content); + when(request.getHeaders()).thenReturn(headers); + when(headers.get(RemoteCallHandler.CONTENT_URI_HEADER)).thenReturn(node.uri()); + when(content.byUri(node.uri())).thenReturn(Optional.of(node)); + + var handler = new RemoteCallHandler(remoteMethodService, moduleContext, context); + var method = RemoteCallHandler.class.getDeclaredMethod("setCurrentContentNode", Request.class); + method.setAccessible(true); + method.invoke(handler, request); + + assertThat(context.get(CurrentNodeFeature.class).node()).isEqualTo(node); + } } diff --git a/test-server/hosts/demo/content/.variants/index/summer/index.asection.bla.md b/test-server/hosts/demo/content/.variants/index/summer/index.asection.bla.md index 2a3f8cdc9..076781d86 100644 --- a/test-server/hosts/demo/content/.variants/index/summer/index.asection.bla.md +++ b/test-server/hosts/demo/content/.variants/index/summer/index.asection.bla.md @@ -24,7 +24,7 @@ publish_date: null about1: more content more better --- -# This is a section: bla +# This is a section: summer And no i can edit the content diff --git a/test-server/hosts/demo/content/.variants/index/summer/index.md b/test-server/hosts/demo/content/.variants/index/summer/index.md index 0ea8fd31e..c0a291d67 100644 --- a/test-server/hosts/demo/content/.variants/index/summer/index.md +++ b/test-server/hosts/demo/content/.variants/index/summer/index.md @@ -2,11 +2,25 @@ template: start.html createdAt: 2026-07-29T12:06:01.530Z createdBy: test -title: Summer time +title: Summer time 2 status: draft +features: [ + ] +object: + values: [ + ] +taxonomy: + tags: [ + ] +background_color: '#000000' +range_test: 0 +seo: + description: '' +linked_page: '' +media_url: '' --- -# Demo Project +# Demo Project (summer) ![TestBild!](/media/images/test.jpg?format=small) From 9f7653680268fb85ab4ce244171f31c38641a612 Mon Sep 17 00:00:00 2001 From: Thorsten Marx Date: Thu, 30 Jul 2026 11:18:01 +0200 Subject: [PATCH 09/16] minor refactorings --- .../cms/content/DefaultVariantSelector.java | 4 +-- .../content/DefaultVariantSelectorTest.java | 22 +++++++++++++++ .../RemoteContentEndpointsExtension.java | 15 +++++------ .../modules/ui/http/RemoteCallHandler.java | 1 - .../manager/js/modules/manager-ui.js | 2 +- .../manager/js/modules/preview-context.d.ts | 3 ++- .../manager/js/modules/preview-context.js | 24 ++++++++++++++++- .../resources/manager/js/modules/rpc/rpc.js | 10 ++++++- .../src/main/ts/src/js/modules/manager-ui.js | 2 +- .../main/ts/src/js/modules/preview-context.ts | 27 ++++++++++++++++++- .../src/main/ts/src/js/modules/rpc/rpc.ts | 9 ++++++- .../ui/http/RemoteCallHandlerTest.java | 23 ++++++++++++++++ 12 files changed, 124 insertions(+), 18 deletions(-) diff --git a/cms-content/src/main/java/com/condation/cms/content/DefaultVariantSelector.java b/cms-content/src/main/java/com/condation/cms/content/DefaultVariantSelector.java index a077a9209..a9cfef20a 100644 --- a/cms-content/src/main/java/com/condation/cms/content/DefaultVariantSelector.java +++ b/cms-content/src/main/java/com/condation/cms/content/DefaultVariantSelector.java @@ -54,10 +54,10 @@ public VariantSelection select( )) .filter(variant -> variant.status().published()) .filter(variant -> variant.status().withinSchedule()) - .max(Comparator.comparing( + .max(Comparator.comparing( variant -> variant.status().publish_date(), Comparator.nullsFirst(Comparator.naturalOrder()) - )) + ).thenComparing(variant -> variant.variant().id())) .map(ScheduledVariant::variant) .map(VariantSelection::automatic) .orElseGet(VariantSelection::canonical); diff --git a/cms-content/src/test/java/com/condation/cms/content/DefaultVariantSelectorTest.java b/cms-content/src/test/java/com/condation/cms/content/DefaultVariantSelectorTest.java index f50629f62..530170f92 100644 --- a/cms-content/src/test/java/com/condation/cms/content/DefaultVariantSelectorTest.java +++ b/cms-content/src/test/java/com/condation/cms/content/DefaultVariantSelectorTest.java @@ -134,6 +134,28 @@ public void newestActiveVariantWins() { Assertions.assertThat(selection.variant()).contains(newer); } + @Test + public void variantsWithSameScheduleUseIdAsStableTieBreaker() { + var publishDate = Instant.now().minus(Duration.ofDays(1)); + var unpublishDate = Instant.now().plus(Duration.ofDays(1)); + var alpha = scheduledVariant("alpha", publishDate, unpublishDate, "published"); + var beta = scheduledVariant("beta", publishDate, unpublishDate, "published"); + + var firstOrder = selector.select( + canonicalNode, + List.of(beta, alpha), + context() + ); + var secondOrder = selector.select( + canonicalNode, + List.of(alpha, beta), + context() + ); + + Assertions.assertThat(firstOrder.variant()).contains(beta); + Assertions.assertThat(secondOrder.variant()).contains(beta); + } + private RequestContext context() { return new RequestContext(); } diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteContentEndpointsExtension.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteContentEndpointsExtension.java index a0428a80a..d17e85947 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteContentEndpointsExtension.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteContentEndpointsExtension.java @@ -365,13 +365,15 @@ public Object getContentNode (Map parameters) { ConfigurableVariantSelector.VARIANT_QUERY_PARAMETER, List.of() ).stream().findFirst().orElse("").trim(); + String activeVariantId = null; if (selectedNode != null && !variantId.isBlank() && !ConfigurableVariantSelector.CANONICAL_VARIANT_ID.equalsIgnoreCase(variantId)) { - selectedNode = new VariantResolver(db) - .loadVariant(selectedNode, variantId) - .map(com.condation.cms.api.variants.Variant::node) - .orElse(selectedNode); + var selectedVariant = new VariantResolver(db).loadVariant(selectedNode, variantId); + if (selectedVariant.isPresent()) { + selectedNode = selectedVariant.get().node(); + activeVariantId = selectedVariant.get().id(); + } } if (selectedNode != null) { contentFile = contentBase.resolve(selectedNode.uri()); @@ -380,10 +382,7 @@ public Object getContentNode (Map parameters) { result.put("uri", canonicalUri); } result.put("canonicalUri", canonicalUri); - result.put("variantId", variantId.isBlank() - || ConfigurableVariantSelector.CANONICAL_VARIANT_ID.equalsIgnoreCase(variantId) - ? null - : variantId); + result.put("variantId", activeVariantId); var sectionEntries = db.getContent().listSectionEntries(contentFile); Map> sectionMap = new HashMap<>(); diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/http/RemoteCallHandler.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/http/RemoteCallHandler.java index 2e952f46e..c10b212ee 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/http/RemoteCallHandler.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/http/RemoteCallHandler.java @@ -24,7 +24,6 @@ import com.condation.cms.api.feature.features.CurrentNodeFeature; import com.condation.cms.api.feature.features.DBFeature; import com.condation.cms.api.request.RequestContext; -import com.condation.cms.api.request.RequestContextScope; import com.condation.cms.api.ui.rpc.RPCError; import com.condation.cms.api.ui.rpc.RPCException; import com.condation.cms.api.ui.rpc.RPCResult; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/manager-ui.js b/modules/ui-module/src/main/resources/manager/js/modules/manager-ui.js index 3548c5aa9..3648f9115 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/manager-ui.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/manager-ui.js @@ -24,7 +24,7 @@ import { executeScriptAction } from '../manager-globals'; import { getActivePreviewContent } from './preview-context.js'; export function updateStateButton() { const previewUrl = getPreviewUrl(); - const activePreviewContent = getActivePreviewContent(); + const activePreviewContent = getActivePreviewContent(previewUrl); if (!previewUrl || !activePreviewContent?.uri) { const statusButton = document.querySelector('#cms-btn-status'); statusButton?.classList.add('disabled'); diff --git a/modules/ui-module/src/main/resources/manager/js/modules/preview-context.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/preview-context.d.ts index 9c4ecd254..c69c1c5c0 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/preview-context.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/preview-context.d.ts @@ -20,9 +20,10 @@ */ export interface ActivePreviewContent { uri: string; + url?: string; canonicalUri?: string; variantId?: string | null; } declare const setActivePreviewContent: (content: ActivePreviewContent | null) => void; -declare const getActivePreviewContent: () => ActivePreviewContent | null; +declare const getActivePreviewContent: (currentPreviewUrl?: string) => ActivePreviewContent | null; export { getActivePreviewContent, setActivePreviewContent }; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/preview-context.js b/modules/ui-module/src/main/resources/manager/js/modules/preview-context.js index 39b60843e..bcd28c879 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/preview-context.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/preview-context.js @@ -22,5 +22,27 @@ let activeContent = null; const setActivePreviewContent = (content) => { activeContent = content?.uri ? { ...content } : null; }; -const getActivePreviewContent = () => activeContent; +const comparableUrl = (url) => { + try { + const parsed = new URL(url, window.location.origin); + parsed.hash = ''; + return `${parsed.pathname}${parsed.search}`; + } + catch { + return null; + } +}; +const getActivePreviewContent = (currentPreviewUrl) => { + if (!activeContent) { + return null; + } + if (!currentPreviewUrl || !activeContent.url) { + return activeContent; + } + const activeUrl = comparableUrl(activeContent.url); + const currentUrl = comparableUrl(currentPreviewUrl); + return activeUrl !== null && activeUrl === currentUrl + ? activeContent + : null; +}; export { getActivePreviewContent, setActivePreviewContent }; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc.js b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc.js index 7c0ca3884..5724b2258 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc.js @@ -37,7 +37,15 @@ const executeRemoteMethodCall = async (method, parameters) => { parameters: parameters }; const csrfToken = getCSRFToken(); - const activePreviewContent = getActivePreviewContent(); + const previewFrame = document.getElementById("contentPreview"); + let currentPreviewUrl = ""; + try { + currentPreviewUrl = previewFrame?.contentWindow?.location.href ?? ""; + } + catch { + // A context header is optional when the preview URL cannot be read. + } + const activePreviewContent = getActivePreviewContent(currentPreviewUrl); var response = await fetch(window.manager.baseUrl + "/rpc", { method: "POST", headers: { diff --git a/modules/ui-module/src/main/ts/src/js/modules/manager-ui.js b/modules/ui-module/src/main/ts/src/js/modules/manager-ui.js index cb06f2787..459ce0801 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/manager-ui.js +++ b/modules/ui-module/src/main/ts/src/js/modules/manager-ui.js @@ -26,7 +26,7 @@ import { getActivePreviewContent } from './preview-context.js'; export function updateStateButton() { const previewUrl = getPreviewUrl(); - const activePreviewContent = getActivePreviewContent(); + const activePreviewContent = getActivePreviewContent(previewUrl); if (!previewUrl || !activePreviewContent?.uri) { const statusButton = document.querySelector('#cms-btn-status'); statusButton?.classList.add('disabled'); diff --git a/modules/ui-module/src/main/ts/src/js/modules/preview-context.ts b/modules/ui-module/src/main/ts/src/js/modules/preview-context.ts index 8900dbb94..a5b295fe5 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/preview-context.ts +++ b/modules/ui-module/src/main/ts/src/js/modules/preview-context.ts @@ -21,6 +21,7 @@ export interface ActivePreviewContent { uri: string; + url?: string; canonicalUri?: string; variantId?: string | null; } @@ -31,6 +32,30 @@ const setActivePreviewContent = (content: ActivePreviewContent | null) => { activeContent = content?.uri ? { ...content } : null; }; -const getActivePreviewContent = (): ActivePreviewContent | null => activeContent; +const comparableUrl = (url: string): string | null => { + try { + const parsed = new URL(url, window.location.origin); + parsed.hash = ''; + return `${parsed.pathname}${parsed.search}`; + } catch { + return null; + } +}; + +const getActivePreviewContent = ( + currentPreviewUrl?: string +): ActivePreviewContent | null => { + if (!activeContent) { + return null; + } + if (!currentPreviewUrl || !activeContent.url) { + return activeContent; + } + const activeUrl = comparableUrl(activeContent.url); + const currentUrl = comparableUrl(currentPreviewUrl); + return activeUrl !== null && activeUrl === currentUrl + ? activeContent + : null; +}; export { getActivePreviewContent, setActivePreviewContent }; diff --git a/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc.ts b/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc.ts index a13c59a0e..5ce381f25 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc.ts +++ b/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc.ts @@ -51,7 +51,14 @@ const executeRemoteMethodCall = async (method : string, parameters : any) => { parameters: parameters } const csrfToken = getCSRFToken(); - const activePreviewContent = getActivePreviewContent(); + const previewFrame = document.getElementById("contentPreview") as HTMLIFrameElement | null; + let currentPreviewUrl = ""; + try { + currentPreviewUrl = previewFrame?.contentWindow?.location.href ?? ""; + } catch { + // A context header is optional when the preview URL cannot be read. + } + const activePreviewContent = getActivePreviewContent(currentPreviewUrl); var response = await fetch(window.manager.baseUrl + "/rpc", { method: "POST", headers: { diff --git a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/http/RemoteCallHandlerTest.java b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/http/RemoteCallHandlerTest.java index 9bb4ab12b..8d5455083 100644 --- a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/http/RemoteCallHandlerTest.java +++ b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/http/RemoteCallHandlerTest.java @@ -114,4 +114,27 @@ void contentUriHeaderAddsCurrentNodeFeature() throws Exception { assertThat(context.get(CurrentNodeFeature.class).node()).isEqualTo(node); } + + @Test + void unknownContentUriHeaderDoesNotAddCurrentNodeFeature() throws Exception { + var context = new RequestContext(); + var db = mock(DB.class); + var content = mock(Content.class); + var request = mock(Request.class); + var headers = mock(HttpFields.class); + when(moduleContext.has(DBFeature.class)).thenReturn(true); + when(moduleContext.get(DBFeature.class)).thenReturn(new DBFeature(db)); + when(db.getContent()).thenReturn(content); + when(request.getHeaders()).thenReturn(headers); + when(headers.get(RemoteCallHandler.CONTENT_URI_HEADER)).thenReturn("unknown.md"); + when(content.byUri("unknown.md")).thenReturn(Optional.empty()); + when(content.byPath("unknown.md")).thenReturn(Optional.empty()); + + var handler = new RemoteCallHandler(remoteMethodService, moduleContext, context); + var method = RemoteCallHandler.class.getDeclaredMethod("setCurrentContentNode", Request.class); + method.setAccessible(true); + method.invoke(handler, request); + + assertThat(context.has(CurrentNodeFeature.class)).isFalse(); + } } From 3da27da9d7989ecdee3224248ab8f07a4346f0aa Mon Sep 17 00:00:00 2001 From: Thorsten Marx Date: Thu, 30 Jul 2026 11:58:54 +0200 Subject: [PATCH 10/16] display current variant --- .../main/resources/manager/css/manager.css | 24 +++++++++++++ .../src/main/resources/manager/index.html | 8 +++++ .../manager/js/modules/manager-ui.js | 27 +++++++++++++++ .../manager/js/modules/preview-context.js | 1 + .../src/main/ts/src/js/modules/manager-ui.js | 34 +++++++++++++++++++ .../main/ts/src/js/modules/preview-context.ts | 4 +++ 6 files changed, 98 insertions(+) diff --git a/modules/ui-module/src/main/resources/manager/css/manager.css b/modules/ui-module/src/main/resources/manager/css/manager.css index 50a10d23a..ec8f23374 100644 --- a/modules/ui-module/src/main/resources/manager/css/manager.css +++ b/modules/ui-module/src/main/resources/manager/css/manager.css @@ -96,6 +96,7 @@ i[data-cms-section-handle] { .cms-current-page-container { display: flex; align-items: center; + gap: 0.5rem; min-width: 0; margin-top: 0.5rem; } @@ -105,12 +106,35 @@ i[data-cms-section-handle] { } .cms-current-page__path { + flex: 1 1 auto; + min-width: 0; overflow: hidden; color: rgba(255, 255, 255, 0.9); font-family: var(--bs-font-monospace); text-overflow: ellipsis; } +.cms-current-variant { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: 0.3rem; + max-width: min(12rem, 35vw); + border: 0; + font-size: 0.75rem; + line-height: 1.25; + white-space: nowrap; +} + +.cms-current-variant:disabled { + opacity: 0.65; +} + +.cms-current-variant__label { + overflow: hidden; + text-overflow: ellipsis; +} + @media (min-width: 992px) { .cms-current-page-container { margin-top: 0; diff --git a/modules/ui-module/src/main/resources/manager/index.html b/modules/ui-module/src/main/resources/manager/index.html index 031dd4d53..40c367f85 100644 --- a/modules/ui-module/src/main/resources/manager/index.html +++ b/modules/ui-module/src/main/resources/manager/index.html @@ -180,6 +180,14 @@ Page /
+
diff --git a/modules/ui-module/src/main/resources/manager/js/modules/manager-ui.js b/modules/ui-module/src/main/resources/manager/js/modules/manager-ui.js index 3648f9115..4d5a0a9da 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/manager-ui.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/manager-ui.js @@ -22,6 +22,33 @@ import { getPreviewUrl } from '@cms/modules/preview.utils.js'; import { getWfManagerStatus } from './rpc/rpc-workflow'; import { executeScriptAction } from '../manager-globals'; import { getActivePreviewContent } from './preview-context.js'; +const updateVariantBadge = (content) => { + const badge = document.querySelector('#cms-current-variant'); + const label = document.querySelector('#cms-current-variant-label'); + if (!badge || !label) { + return; + } + const variantId = content?.variantId; + label.textContent = content ? (variantId || 'Original') : 'Loading…'; + badge.disabled = !content?.uri; + badge.classList.toggle('text-bg-warning', Boolean(variantId)); + badge.classList.toggle('text-bg-secondary', !variantId); + badge.setAttribute('title', variantId ? `Current variant: ${variantId}` : (content ? 'Original page' : 'Loading preview content')); +}; +window.addEventListener('cms:preview-context-changed', (event) => { + updateVariantBadge(event.detail); +}); +document.addEventListener('DOMContentLoaded', () => { + const badge = document.querySelector('#cms-current-variant'); + badge?.addEventListener('click', () => { + executeScriptAction({ + module: window.manager.baseUrl + '/actions/page/variants', + function: 'runAction', + parameters: {} + }); + }); + updateVariantBadge(getActivePreviewContent()); +}); export function updateStateButton() { const previewUrl = getPreviewUrl(); const activePreviewContent = getActivePreviewContent(previewUrl); diff --git a/modules/ui-module/src/main/resources/manager/js/modules/preview-context.js b/modules/ui-module/src/main/resources/manager/js/modules/preview-context.js index bcd28c879..487fc1d71 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/preview-context.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/preview-context.js @@ -21,6 +21,7 @@ let activeContent = null; const setActivePreviewContent = (content) => { activeContent = content?.uri ? { ...content } : null; + window.dispatchEvent(new CustomEvent("cms:preview-context-changed", { detail: activeContent })); }; const comparableUrl = (url) => { try { diff --git a/modules/ui-module/src/main/ts/src/js/modules/manager-ui.js b/modules/ui-module/src/main/ts/src/js/modules/manager-ui.js index 459ce0801..ca40f3df9 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/manager-ui.js +++ b/modules/ui-module/src/main/ts/src/js/modules/manager-ui.js @@ -24,6 +24,40 @@ import { getWfManagerStatus } from './rpc/rpc-workflow'; import { executeScriptAction } from '../manager-globals'; import { getActivePreviewContent } from './preview-context.js'; +const updateVariantBadge = (content) => { + const badge = document.querySelector('#cms-current-variant'); + const label = document.querySelector('#cms-current-variant-label'); + if (!badge || !label) { + return; + } + + const variantId = content?.variantId; + label.textContent = content ? (variantId || 'Original') : 'Loading…'; + badge.disabled = !content?.uri; + badge.classList.toggle('text-bg-warning', Boolean(variantId)); + badge.classList.toggle('text-bg-secondary', !variantId); + badge.setAttribute( + 'title', + variantId ? `Current variant: ${variantId}` : (content ? 'Original page' : 'Loading preview content') + ); +}; + +window.addEventListener('cms:preview-context-changed', (event) => { + updateVariantBadge(event.detail); +}); + +document.addEventListener('DOMContentLoaded', () => { + const badge = document.querySelector('#cms-current-variant'); + badge?.addEventListener('click', () => { + executeScriptAction({ + module: window.manager.baseUrl + '/actions/page/variants', + function: 'runAction', + parameters: {} + }); + }); + updateVariantBadge(getActivePreviewContent()); +}); + export function updateStateButton() { const previewUrl = getPreviewUrl(); const activePreviewContent = getActivePreviewContent(previewUrl); diff --git a/modules/ui-module/src/main/ts/src/js/modules/preview-context.ts b/modules/ui-module/src/main/ts/src/js/modules/preview-context.ts index a5b295fe5..c7bc68758 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/preview-context.ts +++ b/modules/ui-module/src/main/ts/src/js/modules/preview-context.ts @@ -30,6 +30,10 @@ let activeContent: ActivePreviewContent | null = null; const setActivePreviewContent = (content: ActivePreviewContent | null) => { activeContent = content?.uri ? { ...content } : null; + window.dispatchEvent(new CustomEvent( + "cms:preview-context-changed", + { detail: activeContent } + )); }; const comparableUrl = (url: string): string | null => { From 047898441181c25a02989cad4ed69e8916a34cca Mon Sep 17 00:00:00 2001 From: Thorsten Marx Date: Thu, 30 Jul 2026 15:33:32 +0200 Subject: [PATCH 11/16] ignore variants in content browser --- .../remotemethods/RemoteFileEnpoints.java | 71 +++++++++---- .../remotemethods/RemoteVariantEndpoint.java | 47 ++++++++ .../actions/page/variant-selector.d.ts | 4 +- .../manager/actions/page/variants.js | 61 ++++++++++- .../resources/manager/js/manager-inject.js | 1 - .../filebrowser/filebrowser.actions.d.ts | 4 +- .../filebrowser/filebrowser.actions.js | 20 ++-- .../js/modules/filebrowser/filebrowser.d.ts | 2 +- .../js/modules/filebrowser/filebrowser.js | 9 +- .../filebrowser/filebrowser.template.js | 5 +- .../manager/js/modules/form/utils.d.ts | 4 +- .../manager/js/modules/localization.d.ts | 2 +- .../js/modules/media/mediabrowser.d.ts | 6 +- .../manager/js/modules/preview.history.d.ts | 2 +- .../manager/js/modules/preview.utils.d.ts | 4 +- .../manager/js/modules/rpc/rpc-variant.d.ts | 11 +- .../manager/js/modules/rpc/rpc-variant.js | 9 +- .../resources/manager/js/modules/state.js | 1 - .../manager/js/modules/ui-state.d.ts | 4 +- .../resources/manager/public/manager-login.js | 1 - .../src/main/ts/src/actions/page/variants.ts | 100 ++++++++++++++++-- .../filebrowser/filebrowser.actions.js | 20 ++-- .../src/js/modules/filebrowser/filebrowser.js | 9 +- .../filebrowser/filebrowser.template.js | 5 +- .../main/ts/src/js/modules/rpc/rpc-variant.ts | 15 ++- .../remotemethods/RemoteFileEnpointsTest.java | 88 +++++++++++++++ .../RemoteVariantEndpointTest.java | 74 +++++++++++++ .../ui/utils/json/UIGsonProviderTest.java | 7 +- 28 files changed, 507 insertions(+), 79 deletions(-) diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteFileEnpoints.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteFileEnpoints.java index 4d2bea285..d033e0aa6 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteFileEnpoints.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteFileEnpoints.java @@ -21,8 +21,11 @@ * #L% */ import com.condation.cms.api.auth.Permissions; +import com.condation.cms.api.Constants; import com.condation.cms.api.db.DB; import com.condation.cms.api.db.cms.ReadOnlyFile; +import com.condation.cms.api.eventbus.events.ReIndexContentMetaDataEvent; +import com.condation.cms.api.feature.features.EventBusFeature; import com.condation.cms.api.ui.extensions.UIRemoteMethodExtensionPoint; import com.condation.cms.api.utils.FileUtils; import com.condation.cms.api.utils.SectionUtil; @@ -38,6 +41,8 @@ import com.condation.cms.api.ui.rpc.RPCException; import com.condation.cms.api.utils.PathUtil; import com.condation.cms.modules.ui.utils.UIPathUtil; +import com.condation.cms.core.content.io.ContentFileParser; +import com.condation.cms.core.content.io.YamlHeaderUpdater; import java.nio.file.Path; /** @@ -76,7 +81,8 @@ public Object list(Map parameters) throws RPCException { } contentFile.children().stream() .filter(child -> !SectionUtil.isSectionEntry(child.getFileName())) - .map(this::map) + .filter(child -> !".variants".equals(child.getFileName())) + .map(child -> map(db, type, child)) .forEach(files::add); } catch (IOException ex) { log.error("", ex); @@ -89,7 +95,7 @@ public Object list(Map parameters) throws RPCException { } else if (!f1.directory() && f2.directory()) { return 1; } else { - return f1.name().compareToIgnoreCase(f2.name()); + return f1.displayName().compareToIgnoreCase(f2.displayName()); } }); @@ -165,31 +171,37 @@ public Object renameFile(Map parameters) throws RPCException { var sourcePath = writableBase.resolve(uri).resolve(name); var targetPath = writableBase.resolve(uri).resolve(newName); - log.debug("renaming from {} to {}", sourcePath, targetPath); - if (!Files.exists(sourcePath)) { throw new RPCException("Source file not found: " + sourcePath); } + if (!"assets".equals(type) && name.endsWith(".md") && !Files.isDirectory(sourcePath)) { + var parser = new ContentFileParser(sourcePath.toString()); + var metadata = parser.getHeader() == null + ? new HashMap() + : new HashMap<>(parser.getHeader()); + metadata.put(Constants.MetaFields.TITLE, newName.trim()); + YamlHeaderUpdater.saveMarkdownFileWithHeader( + sourcePath, + metadata, + parser.getContent() + ); + getContext().get(EventBusFeature.class).eventBus().syncPublish( + new ReIndexContentMetaDataEvent(PathUtil.toRelativeFile(sourcePath, writableBase)) + ); + db.getFileSystem().flushContentChanges(); + result.put("success", true); + result.put("newName", name); + result.put("title", newName.trim()); + return result; + } + + log.debug("renaming from {} to {}", sourcePath, targetPath); if (Files.exists(targetPath)) { throw new RPCException("Target file already exists: " + targetPath); } Files.move(sourcePath, targetPath); - if (!"assets".equals(type) && !Files.isDirectory(targetPath)) { - var contentFile = contentBase.resolve(uri).resolve(name); - var sections = db.getContent().listSectionEntries(contentFile); - - for (var node : sections) { - var sourceSectionPath = writableBase.resolve(node.uri()); - var targetSectionPath = writableBase.resolve(node.uri().replace(name, newName)); - if (Files.exists(sourceSectionPath)) { - log.debug("renaming section {} to {}", sourceSectionPath, targetSectionPath); - Files.move(sourceSectionPath, targetSectionPath); - } - } - } - result.put("success", true); result.put("newName", newName); @@ -269,12 +281,13 @@ private boolean isMedia (String filename) { return name.endsWith(".jpg") || name.endsWith(".jpeg") || name.endsWith(".webp") + || name.endsWith(".avif") || name.endsWith(".png") || name.endsWith(".svg") || name.endsWith(".gif"); } - private File map (ReadOnlyFile readOnlyFile) { + private File map (DB db, String type, ReadOnlyFile readOnlyFile) { if (readOnlyFile.isDirectory()) { return new Directory( readOnlyFile.getFileName(), @@ -286,14 +299,27 @@ private File map (ReadOnlyFile readOnlyFile) { readOnlyFile.uri() ); } else { + var title = "assets".equals(type) + ? readOnlyFile.getFileName() + : db.getContent().byPath(readOnlyFile.relativePath()) + .map(node -> node.data().get(Constants.MetaFields.TITLE)) + .filter(String.class::isInstance) + .map(String.class::cast) + .filter(value -> !value.isBlank()) + .orElse(readOnlyFile.getFileName()); return new Content( readOnlyFile.getFileName(), - readOnlyFile.uri() + readOnlyFile.uri(), + title ); } } - public record Content(String name, String uri) implements File { + public record Content(String name, String uri, String title) implements File { + @Override + public String displayName() { + return title; + } } public record Media(String name, String uri) implements File { @@ -313,6 +339,9 @@ public boolean directory() { public static interface File { String name (); String uri (); + default String displayName () { + return name(); + } default boolean directory () { return false; } diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpoint.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpoint.java index 5c2a1d992..3d42943f3 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpoint.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpoint.java @@ -217,6 +217,53 @@ public Object create(Map parameters) throws RPCException { } } + @RemoteMethod(name = "variants.delete", permissions = {Permissions.CONTENT_EDIT}) + public Object delete(Map parameters) throws RPCException { + var uri = stringParameter(parameters, "uri"); + var variantId = stringParameter(parameters, "id"); + if (uri.isBlank() || variantId.isBlank()) { + throw new RPCException(400, "uri and id must not be blank"); + } + + var db = getDB(parameters); + var requestedNode = findContentNode(db, uri); + var variantContext = getVariantResolver(db).resolveContext(requestedNode); + var variant = variantContext.variants().stream() + .filter(candidate -> candidate.id().equals(variantId)) + .findFirst() + .orElseThrow(() -> new RPCException(404, "variant not found")); + var contentBase = db.getFileSystem().resolve(Constants.Folders.CONTENT); + var variantFolder = contentBase.resolve(variant.node().path()).getParent(); + + try { + if (!UIPathUtil.isChild(contentBase, variantFolder) + || !Files.exists(variantFolder) + || !Files.isDirectory(variantFolder)) { + throw new RPCException(404, "variant folder not found"); + } + var folderUri = PathUtil.toRelativeFile(variantFolder, contentBase); + try (var paths = Files.walk(variantFolder)) { + for (var path : paths.sorted(Comparator.reverseOrder()).toList()) { + Files.delete(path); + } + } + getContext().get(EventBusFeature.class).eventBus().syncPublish( + new ReIndexContentMetaDataEvent(folderUri) + ); + db.getFileSystem().flushContentChanges(); + + return Map.of( + "id", variantId, + "url", managerPreviewUrl(variantContext.canonical().url()) + ); + } catch (RPCException exception) { + throw exception; + } catch (Exception exception) { + log.error("Could not delete variant '{}' for '{}'", variantId, variantContext.canonical().path(), exception); + throw new RPCException(500, exception.getMessage()); + } + } + @RemoteMethod(name = "variants.get", permissions = {Permissions.CONTENT_EDIT}) public Object get(Map parameters) throws RPCException { var uri = (String) parameters.getOrDefault("uri", ""); diff --git a/modules/ui-module/src/main/resources/manager/actions/page/variant-selector.d.ts b/modules/ui-module/src/main/resources/manager/actions/page/variant-selector.d.ts index 2fddde572..a83d0b4a2 100644 --- a/modules/ui-module/src/main/resources/manager/actions/page/variant-selector.d.ts +++ b/modules/ui-module/src/main/resources/manager/actions/page/variant-selector.d.ts @@ -8,12 +8,12 @@ * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. - * + * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. - * + * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . * #L% diff --git a/modules/ui-module/src/main/resources/manager/actions/page/variants.js b/modules/ui-module/src/main/resources/manager/actions/page/variants.js index 914a22ddb..57bb584d1 100644 --- a/modules/ui-module/src/main/resources/manager/actions/page/variants.js +++ b/modules/ui-module/src/main/resources/manager/actions/page/variants.js @@ -19,10 +19,11 @@ * #L% */ import { i18n } from '@cms/modules/localization.js'; +import { alertConfirm } from '@cms/modules/alerts.js'; import { openModal } from '@cms/modules/modal.js'; import { getPreviewUrl, loadPreview } from '@cms/modules/preview.utils.js'; import { getContentNode } from '@cms/modules/rpc/rpc-content.js'; -import { getVariants } from '@cms/modules/rpc/rpc-variant.js'; +import { deleteVariant, getVariants } from '@cms/modules/rpc/rpc-variant.js'; import { showToast } from '@cms/modules/toast.js'; const VARIANT_LIST_ID = 'cms-page-variants'; const variantTitle = (variant) => { @@ -55,6 +56,57 @@ const createVariantLink = (titleText, idText, uriText, url, variantId, active, m }); return link; }; +const createVariantItem = (variant, result, modal, container) => { + const item = document.createElement('div'); + item.className = 'list-group-item d-flex align-items-center gap-2 p-0'; + const link = createVariantLink(variantTitle(variant), variant.id, variant.uri, result.canonical.url, variant.id, result.activeVariantId === variant.id, modal); + link.classList.remove('list-group-item'); + link.classList.add('flex-grow-1', 'border-0', 'rounded-0'); + item.appendChild(link); + const deleteButton = document.createElement('button'); + deleteButton.type = 'button'; + deleteButton.className = 'btn btn-sm btn-outline-danger flex-shrink-0 me-2'; + deleteButton.title = i18n.t('manager.actions.page.variants.delete', 'Delete variant'); + deleteButton.setAttribute('aria-label', `${deleteButton.title}: ${variant.id}`); + deleteButton.innerHTML = ''; + deleteButton.addEventListener('click', async () => { + const confirmed = await alertConfirm({ + title: i18n.t('manager.actions.page.variants.delete.confirm.title', 'Delete page variant?'), + message: i18n.t('manager.actions.page.variants.delete.confirm.message', `The variant “${escapeHtml(variantTitle(variant))}” and all its sections will be permanently deleted.`) + }); + if (!confirmed) { + return; + } + try { + const wasActive = result.activeVariantId === variant.id; + const deleted = await deleteVariant(result.canonical.uri, variant.id); + showToast({ + title: i18n.t('manager.actions.page.variants.delete.success.title', 'Variant deleted'), + message: i18n.t('manager.actions.page.variants.delete.success.message', `The variant “${variant.id}” was deleted.`), + type: 'success', + timeout: 3000 + }); + if (wasActive) { + modal.hide(); + loadPreview(deleted.url); + return; + } + result.variants = result.variants.filter(candidate => candidate.id !== variant.id); + container.replaceChildren(); + renderVariants(container, result, modal); + } + catch (error) { + showToast({ + title: i18n.t('manager.actions.page.variants.delete.error.title', 'Could not delete variant'), + message: error instanceof Error ? error.message : String(error), + type: 'error', + timeout: 3000 + }); + } + }); + item.appendChild(deleteButton); + return item; +}; const renderVariants = (container, result, modal) => { if (result.variants.length === 0) { const emptyMessage = document.createElement('p'); @@ -67,10 +119,15 @@ const renderVariants = (container, result, modal) => { list.className = 'list-group'; list.appendChild(createVariantLink(result.canonical.title, i18n.t('manager.actions.page.variants.canonical', 'Original'), result.canonical.uri, result.canonical.url, null, !result.activeVariantId, modal)); result.variants.forEach((variant) => { - list.appendChild(createVariantLink(variantTitle(variant), variant.id, variant.uri, result.canonical.url, variant.id, result.activeVariantId === variant.id, modal)); + list.appendChild(createVariantItem(variant, result, modal, container)); }); container.appendChild(list); }; +const escapeHtml = (input) => { + const element = document.createElement('div'); + element.textContent = String(input ?? ''); + return element.innerHTML; +}; export const runAction = async () => { try { const contentNode = await getContentNode({ diff --git a/modules/ui-module/src/main/resources/manager/js/manager-inject.js b/modules/ui-module/src/main/resources/manager/js/manager-inject.js index 1edf13ab0..987c7b634 100644 --- a/modules/ui-module/src/main/resources/manager/js/manager-inject.js +++ b/modules/ui-module/src/main/resources/manager/js/manager-inject.js @@ -1,4 +1,3 @@ -"use strict"; /*- * #%L * UI Module diff --git a/modules/ui-module/src/main/resources/manager/js/modules/filebrowser/filebrowser.actions.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/filebrowser/filebrowser.actions.d.ts index f8d509a6b..e1c7c16d4 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/filebrowser/filebrowser.actions.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/filebrowser/filebrowser.actions.d.ts @@ -18,10 +18,12 @@ * along with this program. If not, see . * #L% */ -export function renameFileAction({ state, getTargetFolder, filename }: { +export function renameFileAction({ state, getTargetFolder, filename, title, content }: { state: any; getTargetFolder: any; filename: any; + title: any; + content: any; }): Promise; export function deleteElementAction({ elementName, state, deleteFN, getTargetFolder }: { elementName: any; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/filebrowser/filebrowser.actions.js b/modules/ui-module/src/main/resources/manager/js/modules/filebrowser/filebrowser.actions.js index e40f04529..f5bdd7cee 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/filebrowser/filebrowser.actions.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/filebrowser/filebrowser.actions.js @@ -24,11 +24,15 @@ import { i18n } from '@cms/modules/localization.js'; import { alertSelect, alertConfirm, alertPrompt } from '@cms/modules/alerts.js'; import { showToast } from '@cms/modules/toast.js'; import { getPageTemplates } from '@cms/modules/rpc/rpc-manager.js'; -export async function renameFileAction({ state, getTargetFolder, filename }) { +export async function renameFileAction({ state, getTargetFolder, filename, title, content }) { const newName = await alertPrompt({ - title: i18n.t("filebrowser.rename.title", "Rename file"), - label: i18n.t("filebrowser.rename.label", "New name"), - placeholder: filename + title: content + ? i18n.t("filebrowser.renameContent.title", "Edit title") + : i18n.t("filebrowser.rename.title", "Rename"), + label: content + ? i18n.t("filebrowser.renameContent.label", "Title") + : i18n.t("filebrowser.rename.label", "New name"), + placeholder: content ? title : filename }); var extraOptions = {}; if (state.options.siteId) { @@ -44,8 +48,12 @@ export async function renameFileAction({ state, getTargetFolder, filename }) { ...extraOptions }); showToast({ - title: i18n.t("filebrowser.rename.success.title", 'File renamed'), - message: i18n.t("filebrowser.rename.success.message", "File renamed successfully"), + title: content + ? i18n.t("filebrowser.renameContent.success.title", 'Title updated') + : i18n.t("filebrowser.rename.success.title", 'Renamed'), + message: content + ? i18n.t("filebrowser.renameContent.success.message", "Title updated successfully") + : i18n.t("filebrowser.rename.success.message", "Renamed successfully"), type: 'info', timeout: 3000 }); diff --git a/modules/ui-module/src/main/resources/manager/js/modules/filebrowser/filebrowser.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/filebrowser/filebrowser.d.ts index 0869c773e..0c529bf7f 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/filebrowser/filebrowser.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/filebrowser/filebrowser.d.ts @@ -20,6 +20,6 @@ */ export function openFileBrowser(optionsParam: any): Promise; export namespace state { - let options: null; + let options: any; let currentFolder: string; } diff --git a/modules/ui-module/src/main/resources/manager/js/modules/filebrowser/filebrowser.js b/modules/ui-module/src/main/resources/manager/js/modules/filebrowser/filebrowser.js index 95188230f..cfa6c377e 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/filebrowser/filebrowser.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/filebrowser/filebrowser.js @@ -32,7 +32,7 @@ const defaultOptions = { uri: "", onSelect: null, fullscreen: true, - title: i18n.t("filebrowser.title", "Filesystem"), + title: i18n.t("filebrowser.title", "Content"), filter: (file) => { return true; // Default filter allows all files } @@ -81,7 +81,7 @@ const initFileBrowser = async (uri) => { if (fileBrowserElement) { fileBrowserElement.innerHTML = filebrowserTemplate({ files: files, - filenameHeader: i18n.t("filebrowser.filename", "Filename"), + filenameHeader: i18n.t("filebrowser.filename", "Name"), actionHeader: i18n.t("filebrowser.action", "Action"), actions: getActions(), asset: state.options.type === "assets", @@ -204,10 +204,13 @@ const fileActions = () => { }); } else if (action === "renameFile") { + const row = element.closest("[data-cms-file-name]"); renameFileAction({ state: state, getTargetFolder: getTargetFolder, - filename: filename + filename: filename, + title: row.querySelector('td')?.textContent?.trim() || filename, + content: row.hasAttribute("data-cms-file-content") }).then(async () => { await initFileBrowser(state.currentFolder); }); diff --git a/modules/ui-module/src/main/resources/manager/js/modules/filebrowser/filebrowser.template.js b/modules/ui-module/src/main/resources/manager/js/modules/filebrowser/filebrowser.template.js index f95f025c4..80ac1e52f 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/filebrowser/filebrowser.template.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/filebrowser/filebrowser.template.js @@ -71,6 +71,7 @@ const template = Handlebars.compile(` {{#if directory}} @@ -79,7 +80,7 @@ const template = Handlebars.compile(` {{/if}} - {{name}} + {{#if title}}{{title}}{{else}}{{name}}{{/if}} {{#if directory}} {{#ifNotEquals name ".."}} @@ -120,7 +121,7 @@ const template = Handlebars.compile(` {{#ifNotEquals name ".."}} diff --git a/modules/ui-module/src/main/resources/manager/js/modules/form/utils.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/form/utils.d.ts index 23eeb2258..7d3d89dcb 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/form/utils.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/form/utils.d.ts @@ -20,7 +20,7 @@ */ declare const createID: () => string; declare const utcToLocalDateTimeInputValue: (utcString: string) => string; -declare function getUTCDateTimeFromInput(inputElement: HTMLInputElement): string | null; +declare function getUTCDateTimeFromInput(inputElement: HTMLInputElement): string; declare function utcToLocalDateInputValue(utcString: string): string; -declare function getUTCDateFromInput(inputElement: HTMLInputElement): string | null; +declare function getUTCDateFromInput(inputElement: HTMLInputElement): string; export { createID, utcToLocalDateTimeInputValue, getUTCDateTimeFromInput, utcToLocalDateInputValue, getUTCDateFromInput }; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/localization.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/localization.d.ts index e91051759..b1e393873 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/localization.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/localization.d.ts @@ -21,7 +21,7 @@ export function localizeUi(): Promise; export namespace i18n { let _locale: any; - let _cache: null; + let _cache: any; /** * Loads and merges remote localizations with defaults. */ diff --git a/modules/ui-module/src/main/resources/manager/js/modules/media/mediabrowser.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/media/mediabrowser.d.ts index 125ee5274..d35ac75a0 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/media/mediabrowser.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/media/mediabrowser.d.ts @@ -20,8 +20,8 @@ */ export function openMediaBrowser(optionsParam: any): Promise; export namespace state { - let options: null; + let options: any; let currentFolder: string; - let metadataForm: null; - let metadataImage: null; + let metadataForm: any; + let metadataImage: any; } diff --git a/modules/ui-module/src/main/resources/manager/js/modules/preview.history.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/preview.history.d.ts index 6cfeb16ad..520ce2957 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/preview.history.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/preview.history.d.ts @@ -22,6 +22,6 @@ export namespace PreviewHistory { export { init }; export { navigatePreview }; } -declare function init(defaultUrl?: null): void; +declare function init(defaultUrl?: any): void; declare function navigatePreview(url: any, usePush?: boolean): void; export {}; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/preview.utils.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/preview.utils.d.ts index 20c850ee6..b529867d3 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/preview.utils.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/preview.utils.d.ts @@ -22,5 +22,5 @@ export function activatePreviewOverlay(): void; export function deActivatePreviewOverlay(): void; export function getPreviewUrl(): any; export function reloadPreview(): void; -export function loadPreview(url: any): void; -export function getPreviewFrame(): HTMLElement | null; +export function loadPreview(url: any, options?: {}): void; +export function getPreviewFrame(): HTMLElement; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-variant.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-variant.d.ts index db80c8034..0651ebce3 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-variant.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-variant.d.ts @@ -8,12 +8,12 @@ * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. - * + * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. - * + * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . * #L% @@ -50,6 +50,10 @@ export interface CreateVariantResult { uri: string; url: string; } +export interface DeleteVariantResult { + id: string; + url: string; +} export interface VariantSelectorDto { id: string; label: string; @@ -60,8 +64,9 @@ export interface GetVariantSelectorsResult { } declare const getVariants: (options: GetVariantsOptions) => Promise; declare const createVariant: (options: CreateVariantOptions) => Promise; +declare const deleteVariant: (uri: string, id: string) => Promise; declare const getVariantSelectors: (uri: string) => Promise; declare const setVariantSelector: (uri: string, selector: string) => Promise<{ selector: string; }>; -export { createVariant, getVariants, getVariantSelectors, setVariantSelector }; +export { createVariant, deleteVariant, getVariants, getVariantSelectors, setVariantSelector }; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-variant.js b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-variant.js index b2cc2841b..b8a486124 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-variant.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-variant.js @@ -33,6 +33,13 @@ const createVariant = async (options) => { }; return (await executeRemoteCall(data)).result; }; +const deleteVariant = async (uri, id) => { + const data = { + method: 'variants.delete', + parameters: { uri, id } + }; + return (await executeRemoteCall(data)).result; +}; const getVariantSelectors = async (uri) => { const data = { method: 'variants.selectors.get', @@ -47,4 +54,4 @@ const setVariantSelector = async (uri, selector) => { }; return (await executeRemoteCall(data)).result; }; -export { createVariant, getVariants, getVariantSelectors, setVariantSelector }; +export { createVariant, deleteVariant, getVariants, getVariantSelectors, setVariantSelector }; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/state.js b/modules/ui-module/src/main/resources/manager/js/modules/state.js index a0988236f..7274c0b66 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/state.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/state.js @@ -1,4 +1,3 @@ -"use strict"; /*- * #%L * UI Module diff --git a/modules/ui-module/src/main/resources/manager/js/modules/ui-state.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/ui-state.d.ts index 30e36cd4d..65018962d 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/ui-state.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/ui-state.d.ts @@ -20,11 +20,11 @@ */ export namespace UIStateManager { function setTabState(key: any, value: any): void; - function getTabState(key: any, defaultValue?: null): any; + function getTabState(key: any, defaultValue?: any): any; function setLocale(locale: any): void; function getLocale(): any; function removeTabState(key: any): void; function setAuthToken(token: any): void; - function getAuthToken(): string | null; + function getAuthToken(): string; function clearAuthToken(): void; } diff --git a/modules/ui-module/src/main/resources/manager/public/manager-login.js b/modules/ui-module/src/main/resources/manager/public/manager-login.js index 24aa077ae..6f21eee99 100644 --- a/modules/ui-module/src/main/resources/manager/public/manager-login.js +++ b/modules/ui-module/src/main/resources/manager/public/manager-login.js @@ -1,4 +1,3 @@ -"use strict"; /*- * #%L * UI Module diff --git a/modules/ui-module/src/main/ts/src/actions/page/variants.ts b/modules/ui-module/src/main/ts/src/actions/page/variants.ts index 4115995a6..f8d2018de 100644 --- a/modules/ui-module/src/main/ts/src/actions/page/variants.ts +++ b/modules/ui-module/src/main/ts/src/actions/page/variants.ts @@ -20,10 +20,16 @@ */ import { i18n } from '@cms/modules/localization.js' +import { alertConfirm } from '@cms/modules/alerts.js' import { openModal } from '@cms/modules/modal.js' import { getPreviewUrl, loadPreview } from '@cms/modules/preview.utils.js' import { getContentNode } from '@cms/modules/rpc/rpc-content.js' -import { getVariants, GetVariantsResult, VariantDto } from '@cms/modules/rpc/rpc-variant.js' +import { + deleteVariant, + getVariants, + GetVariantsResult, + VariantDto +} from '@cms/modules/rpc/rpc-variant.js' import { showToast } from '@cms/modules/toast.js' const VARIANT_LIST_ID = 'cms-page-variants'; @@ -73,6 +79,82 @@ const createVariantLink = ( return link; }; +const createVariantItem = ( + variant: VariantDto, + result: GetVariantsResult, + modal: any, + container: HTMLElement +): HTMLElement => { + const item = document.createElement('div'); + item.className = 'list-group-item d-flex align-items-center gap-2 p-0'; + + const link = createVariantLink( + variantTitle(variant), + variant.id, + variant.uri, + result.canonical.url, + variant.id, + result.activeVariantId === variant.id, + modal + ); + link.classList.remove('list-group-item'); + link.classList.add('flex-grow-1', 'border-0', 'rounded-0'); + item.appendChild(link); + + const deleteButton = document.createElement('button'); + deleteButton.type = 'button'; + deleteButton.className = 'btn btn-sm btn-outline-danger flex-shrink-0 me-2'; + deleteButton.title = i18n.t('manager.actions.page.variants.delete', 'Delete variant'); + deleteButton.setAttribute('aria-label', `${deleteButton.title}: ${variant.id}`); + deleteButton.innerHTML = ''; + deleteButton.addEventListener('click', async () => { + const confirmed = await alertConfirm({ + title: i18n.t('manager.actions.page.variants.delete.confirm.title', 'Delete page variant?'), + message: i18n.t( + 'manager.actions.page.variants.delete.confirm.message', + `The variant “${escapeHtml(variantTitle(variant))}” and all its sections will be permanently deleted.` + ) + }); + if (!confirmed) { + return; + } + + try { + const wasActive = result.activeVariantId === variant.id; + const deleted = await deleteVariant(result.canonical.uri, variant.id); + showToast({ + title: i18n.t('manager.actions.page.variants.delete.success.title', 'Variant deleted'), + message: i18n.t( + 'manager.actions.page.variants.delete.success.message', + `The variant “${variant.id}” was deleted.` + ), + type: 'success', + timeout: 3000 + }); + if (wasActive) { + modal.hide(); + loadPreview(deleted.url); + return; + } + result.variants = result.variants.filter(candidate => candidate.id !== variant.id); + container.replaceChildren(); + renderVariants(container, result, modal); + } catch (error) { + showToast({ + title: i18n.t( + 'manager.actions.page.variants.delete.error.title', + 'Could not delete variant' + ), + message: error instanceof Error ? error.message : String(error), + type: 'error', + timeout: 3000 + }); + } + }); + item.appendChild(deleteButton); + return item; +}; + const renderVariants = (container: HTMLElement, result: GetVariantsResult, modal: any) => { if (result.variants.length === 0) { const emptyMessage = document.createElement('p'); @@ -98,20 +180,18 @@ const renderVariants = (container: HTMLElement, result: GetVariantsResult, modal )); result.variants.forEach((variant) => { - list.appendChild(createVariantLink( - variantTitle(variant), - variant.id, - variant.uri, - result.canonical.url, - variant.id, - result.activeVariantId === variant.id, - modal - )); + list.appendChild(createVariantItem(variant, result, modal, container)); }); container.appendChild(list); }; +const escapeHtml = (input: unknown): string => { + const element = document.createElement('div'); + element.textContent = String(input ?? ''); + return element.innerHTML; +}; + export const runAction = async () => { try { const contentNode = await getContentNode({ diff --git a/modules/ui-module/src/main/ts/src/js/modules/filebrowser/filebrowser.actions.js b/modules/ui-module/src/main/ts/src/js/modules/filebrowser/filebrowser.actions.js index faae97637..c307df671 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/filebrowser/filebrowser.actions.js +++ b/modules/ui-module/src/main/ts/src/js/modules/filebrowser/filebrowser.actions.js @@ -27,11 +27,15 @@ import { alertSelect, alertConfirm, alertPrompt } from '@cms/modules/alerts.js' import { showToast } from '@cms/modules/toast.js' import { getPageTemplates } from '@cms/modules/rpc/rpc-manager.js'; -export async function renameFileAction({ state, getTargetFolder, filename }) { +export async function renameFileAction({ state, getTargetFolder, filename, title, content }) { const newName = await alertPrompt({ - title: i18n.t("filebrowser.rename.title", "Rename file"), - label: i18n.t("filebrowser.rename.label", "New name"), - placeholder: filename + title: content + ? i18n.t("filebrowser.renameContent.title", "Edit title") + : i18n.t("filebrowser.rename.title", "Rename"), + label: content + ? i18n.t("filebrowser.renameContent.label", "Title") + : i18n.t("filebrowser.rename.label", "New name"), + placeholder: content ? title : filename }); var extraOptions = {} if (state.options.siteId) { @@ -47,8 +51,12 @@ export async function renameFileAction({ state, getTargetFolder, filename }) { ...extraOptions }); showToast({ - title: i18n.t("filebrowser.rename.success.title", 'File renamed'), - message: i18n.t("filebrowser.rename.success.message", "File renamed successfully"), + title: content + ? i18n.t("filebrowser.renameContent.success.title", 'Title updated') + : i18n.t("filebrowser.rename.success.title", 'Renamed'), + message: content + ? i18n.t("filebrowser.renameContent.success.message", "Title updated successfully") + : i18n.t("filebrowser.rename.success.message", "Renamed successfully"), type: 'info', timeout: 3000 }); diff --git a/modules/ui-module/src/main/ts/src/js/modules/filebrowser/filebrowser.js b/modules/ui-module/src/main/ts/src/js/modules/filebrowser/filebrowser.js index 85ba465c3..add6003ab 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/filebrowser/filebrowser.js +++ b/modules/ui-module/src/main/ts/src/js/modules/filebrowser/filebrowser.js @@ -35,7 +35,7 @@ const defaultOptions = { uri: "", onSelect: null, fullscreen: true, - title: i18n.t("filebrowser.title", "Filesystem"), + title: i18n.t("filebrowser.title", "Content"), filter : (file) => { return true; // Default filter allows all files } @@ -92,7 +92,7 @@ const initFileBrowser = async (uri) => { if (fileBrowserElement) { fileBrowserElement.innerHTML = filebrowserTemplate({ files: files, - filenameHeader: i18n.t("filebrowser.filename", "Filename"), + filenameHeader: i18n.t("filebrowser.filename", "Name"), actionHeader: i18n.t("filebrowser.action", "Action"), actions: getActions(), asset: state.options.type === "assets", @@ -223,10 +223,13 @@ const fileActions = () => { await initFileBrowser(state.currentFolder); }); } else if (action === "renameFile") { + const row = element.closest("[data-cms-file-name]"); renameFileAction({ state: state, getTargetFolder: getTargetFolder, - filename: filename + filename: filename, + title: row.querySelector('td')?.textContent?.trim() || filename, + content: row.hasAttribute("data-cms-file-content") }).then(async () => { await initFileBrowser(state.currentFolder); }); diff --git a/modules/ui-module/src/main/ts/src/js/modules/filebrowser/filebrowser.template.js b/modules/ui-module/src/main/ts/src/js/modules/filebrowser/filebrowser.template.js index ea3be4afe..a58986d0c 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/filebrowser/filebrowser.template.js +++ b/modules/ui-module/src/main/ts/src/js/modules/filebrowser/filebrowser.template.js @@ -75,6 +75,7 @@ const template = Handlebars.compile(` {{#if directory}} @@ -83,7 +84,7 @@ const template = Handlebars.compile(` {{/if}} - {{name}} + {{#if title}}{{title}}{{else}}{{name}}{{/if}} {{#if directory}} {{#ifNotEquals name ".."}} @@ -124,7 +125,7 @@ const template = Handlebars.compile(` {{#ifNotEquals name ".."}} diff --git a/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc-variant.ts b/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc-variant.ts index 8e64ad69b..599edecd1 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc-variant.ts +++ b/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc-variant.ts @@ -58,6 +58,11 @@ export interface CreateVariantResult { url: string; } +export interface DeleteVariantResult { + id: string; + url: string; +} + export interface VariantSelectorDto { id: string; label: string; @@ -84,6 +89,14 @@ const createVariant = async (options: CreateVariantOptions): Promise => { + const data = { + method: 'variants.delete', + parameters: { uri, id } + }; + return (await executeRemoteCall(data)).result as DeleteVariantResult; +}; + const getVariantSelectors = async (uri: string): Promise => { const data = { method: 'variants.selectors.get', @@ -100,4 +113,4 @@ const setVariantSelector = async (uri: string, selector: string): Promise<{ sele return (await executeRemoteCall(data)).result as { selector: string }; }; -export { createVariant, getVariants, getVariantSelectors, setVariantSelector }; +export { createVariant, deleteVariant, getVariants, getVariantSelectors, setVariantSelector }; diff --git a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteFileEnpointsTest.java b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteFileEnpointsTest.java index fdfc372d8..099b54316 100644 --- a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteFileEnpointsTest.java +++ b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteFileEnpointsTest.java @@ -22,16 +22,26 @@ */ import com.condation.cms.api.Constants; +import com.condation.cms.api.db.Content; +import com.condation.cms.api.db.ContentNode; import com.condation.cms.api.db.DB; import com.condation.cms.api.db.DBFileSystem; +import com.condation.cms.api.db.cms.ReadOnlyFile; +import com.condation.cms.api.eventbus.EventBus; import com.condation.cms.api.feature.features.DBFeature; +import com.condation.cms.api.feature.features.EventBusFeature; import com.condation.cms.api.module.SiteModuleContext; import com.condation.cms.api.ui.rpc.RPCException; +import com.condation.cms.core.content.io.ContentFileParser; +import java.nio.file.Files; import java.nio.file.Path; +import java.util.List; import java.util.Map; +import java.util.Optional; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; @@ -54,6 +64,9 @@ public class RemoteFileEnpointsTest { @Mock private DBFileSystem dbFileSystem; + + @TempDir + private Path tempDir; public RemoteFileEnpointsTest() { } @@ -75,5 +88,80 @@ public void create_folder_with_absolut_path_throws_error() throws RPCException { ))).isInstanceOf(RPCException.class);//.hasMessage("invalid path"); } + + @Test + void listUsesContentTitleButKeepsTechnicalName() throws Exception { + var content = Mockito.mock(Content.class); + var contentBase = Mockito.mock(ReadOnlyFile.class); + var page = Mockito.mock(ReadOnlyFile.class); + var node = new ContentNode( + "about.md", + "/about", + "about.md", + Map.of(Constants.MetaFields.TITLE, "About us") + ); + Mockito.when(moduleContext.get(DBFeature.class)).thenReturn(new DBFeature(db)); + Mockito.when(db.getFileSystem()).thenReturn(dbFileSystem); + Mockito.when(db.getContent()).thenReturn(content); + Mockito.when(dbFileSystem.contentBase()).thenReturn(contentBase); + Mockito.when(contentBase.resolve("")).thenReturn(contentBase); + Mockito.when(contentBase.isDirectory()).thenReturn(true); + Mockito.when(contentBase.children()).thenReturn(List.of(page)); + Mockito.when(page.getFileName()).thenReturn("about.md"); + Mockito.when(page.uri()).thenReturn("/about"); + Mockito.when(page.relativePath()).thenReturn("about.md"); + Mockito.when(content.byPath("about.md")).thenReturn(Optional.of(node)); + + var endpoints = new RemoteFileEnpoints(); + endpoints.setContext(moduleContext); + + @SuppressWarnings("unchecked") + var result = (Map) endpoints.list(Map.of( + "type", "content", + "uri", "" + )); + var files = (List) result.get("files"); + + Assertions.assertThat(files).singleElement().satisfies(file -> { + Assertions.assertThat(file.name()).isEqualTo("about.md"); + Assertions.assertThat(file.displayName()).isEqualTo("About us"); + Assertions.assertThat(((RemoteFileEnpoints.Content) file).title()).isEqualTo("About us"); + }); + } + + @Test + void renameMarkdownContentUpdatesTitleWithoutChangingFileName() throws Exception { + var contentBase = Mockito.mock(ReadOnlyFile.class); + var directory = Mockito.mock(ReadOnlyFile.class); + var contentFile = Mockito.mock(ReadOnlyFile.class); + var eventBus = Mockito.mock(EventBus.class); + var page = tempDir.resolve("about.md"); + Files.writeString(page, "---\ntitle: Old title\ntemplate: page\n---\n\nBody"); + Mockito.when(moduleContext.get(DBFeature.class)).thenReturn(new DBFeature(db)); + Mockito.when(moduleContext.get(EventBusFeature.class)).thenReturn(new EventBusFeature(eventBus)); + Mockito.when(db.getFileSystem()).thenReturn(dbFileSystem); + Mockito.when(dbFileSystem.contentBase()).thenReturn(contentBase); + Mockito.when(dbFileSystem.resolve(Constants.Folders.CONTENT)).thenReturn(tempDir); + Mockito.when(contentBase.resolve("")).thenReturn(directory); + Mockito.when(directory.resolve("about.md")).thenReturn(contentFile); + + var endpoints = new RemoteFileEnpoints(); + endpoints.setContext(moduleContext); + endpoints.renameFile(Map.of( + "type", "content", + "uri", "", + "name", "about.md", + "newName", "New title" + )); + + Assertions.assertThat(page).exists(); + Assertions.assertThat(tempDir.resolve("New title")).doesNotExist(); + var parser = new ContentFileParser(page.toString()); + Assertions.assertThat(parser.getHeader()) + .containsEntry(Constants.MetaFields.TITLE, "New title") + .containsEntry(Constants.MetaFields.TEMPLATE, "page"); + Assertions.assertThat(parser.getContent()).isEqualTo("Body"); + Mockito.verify(dbFileSystem).flushContentChanges(); + } } diff --git a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpointTest.java b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpointTest.java index 4d5207c49..29b20a1af 100644 --- a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpointTest.java +++ b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpointTest.java @@ -21,10 +21,14 @@ * #L% */ +import com.condation.cms.api.Constants; import com.condation.cms.api.db.Content; import com.condation.cms.api.db.ContentNode; import com.condation.cms.api.db.DB; +import com.condation.cms.api.db.DBFileSystem; +import com.condation.cms.api.eventbus.EventBus; import com.condation.cms.api.feature.features.DBFeature; +import com.condation.cms.api.feature.features.EventBusFeature; import com.condation.cms.api.module.SiteModuleContext; import com.condation.cms.api.ui.rpc.RPCException; import com.condation.cms.api.variants.Variant; @@ -35,9 +39,12 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.nio.file.Files; +import java.nio.file.Path; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -59,6 +66,12 @@ class RemoteVariantEndpointTest { @Mock private Content content; + @Mock + private DBFileSystem fileSystem; + + @Mock + private EventBus eventBus; + @Mock private VariantResolver variantResolver; @@ -70,6 +83,9 @@ class RemoteVariantEndpointTest { private RemoteVariantEndpoint endpoint; + @TempDir + private Path tempDir; + @BeforeEach void setUp() { endpoint = new RemoteVariantEndpoint() { @@ -90,7 +106,10 @@ protected VariantSelectorConfigurationRepository getVariantSelectorConfiguration }; endpoint.setContext(moduleContext); lenient().when(moduleContext.get(DBFeature.class)).thenReturn(new DBFeature(db)); + lenient().when(moduleContext.get(EventBusFeature.class)).thenReturn(new EventBusFeature(eventBus)); lenient().when(db.getContent()).thenReturn(content); + lenient().when(db.getFileSystem()).thenReturn(fileSystem); + lenient().when(fileSystem.resolve(Constants.Folders.CONTENT)).thenReturn(tempDir); } @Test @@ -223,6 +242,61 @@ void setSelectorPersistsConfigurationForPage() throws Exception { verify(selectorConfigurationRepository).setSelectorId(node, "audience"); } + @Test + void deleteRemovesVariantAndSectionsButKeepsPageConfiguration() throws Exception { + var canonical = node("about.md", "/about", Map.of("title", "About")); + var summer = node( + ".variants/about/summer/about.md", + "/.variants/about/summer/about", + Map.of("title", "Summer") + ); + var variantFolder = tempDir.resolve(".variants/about/summer"); + Files.createDirectories(variantFolder); + Files.writeString(variantFolder.resolve("about.md"), "variant"); + Files.writeString(variantFolder.resolve("about.main.hero.md"), "section"); + var configuration = tempDir.resolve(".variants/about/variants.yaml"); + Files.writeString(configuration, "selector: date-range"); + when(content.byPath(canonical.path())).thenReturn(Optional.of(canonical)); + when(variantResolver.resolveContext(canonical)).thenReturn( + new VariantResolver.VariantContext( + canonical, + Optional.empty(), + List.of(new Variant("summer", summer)) + ) + ); + + @SuppressWarnings("unchecked") + var result = (Map) endpoint.delete(Map.of( + "uri", canonical.path(), + "id", "summer" + )); + + assertThat(result) + .containsEntry("id", "summer") + .containsEntry("url", "/about?preview=manager"); + assertThat(variantFolder).doesNotExist(); + assertThat(configuration).exists(); + verify(fileSystem).flushContentChanges(); + } + + @Test + void deleteRejectsUnknownVariant() { + var canonical = node("about.md", "/about", Map.of()); + when(content.byPath(canonical.path())).thenReturn(Optional.of(canonical)); + when(variantResolver.resolveContext(canonical)).thenReturn( + new VariantResolver.VariantContext(canonical, Optional.empty(), List.of()) + ); + + assertThatThrownBy(() -> endpoint.delete(Map.of( + "uri", canonical.path(), + "id", "missing" + ))) + .isInstanceOf(RPCException.class) + .satisfies(exception -> + assertThat(((RPCException) exception).getCode()).isEqualTo(404) + ); + } + private ContentNode node(String uri, String url, Map data) { return new ContentNode(uri, url, "about.md", data); } diff --git a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/utils/json/UIGsonProviderTest.java b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/utils/json/UIGsonProviderTest.java index eb6f2d562..bf58e173d 100644 --- a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/utils/json/UIGsonProviderTest.java +++ b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/utils/json/UIGsonProviderTest.java @@ -38,12 +38,17 @@ public UIGsonProviderTest() { @Test void testContentSerializationIncludesInterfaceProperties() { - var content = new RemoteFileEnpoints.Content("readme.md", "/docs/readme.md"); + var content = new RemoteFileEnpoints.Content( + "readme.md", + "/docs/readme.md", + "Read me" + ); String json = UIGsonProvider.INSTANCE.toJson(content); JsonObject obj = JsonParser.parseString(json).getAsJsonObject(); Assertions.assertThat(obj.get("name").getAsString()).isEqualTo("readme.md"); Assertions.assertThat(obj.get("uri").getAsString()).isEqualTo("/docs/readme.md"); + Assertions.assertThat(obj.get("title").getAsString()).isEqualTo("Read me"); Assertions.assertThat(obj.get("directory").getAsBoolean()).isFalse(); Assertions.assertThat(obj.get("media").getAsBoolean()).isFalse(); Assertions.assertThat(obj.get("content").getAsBoolean()).isTrue(); From 780ebc8620e85283e1ca02113fdb99aa055cede9 Mon Sep 17 00:00:00 2001 From: Thorsten Marx Date: Thu, 30 Jul 2026 20:44:37 +0200 Subject: [PATCH 12/16] add e2e tests for delivering variants in preview --- .../com/condation/cms/e2e/VariantTest.java | 57 +++++++++++++++++++ .../content/.variants/index/summer/index.md | 2 +- 2 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 integration-tests/src/test/java/com/condation/cms/e2e/VariantTest.java diff --git a/integration-tests/src/test/java/com/condation/cms/e2e/VariantTest.java b/integration-tests/src/test/java/com/condation/cms/e2e/VariantTest.java new file mode 100644 index 000000000..0ab6062ea --- /dev/null +++ b/integration-tests/src/test/java/com/condation/cms/e2e/VariantTest.java @@ -0,0 +1,57 @@ +package com.condation.cms.e2e; + +/*- + * #%L + * integration-tests + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ +import com.condation.cms.test.e2e.CMSServerExtension; +import com.condation.cms.cli.tools.CLIServerUtils; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.junit.UsePlaywright; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +/** + * + * @author thmar + */ +@UsePlaywright +public class VariantTest { + + @RegisterExtension + static CMSServerExtension serverExtensions = new CMSServerExtension(); + + @Test + void server_is_started() throws Exception { + Assertions.assertThat(CLIServerUtils.getCMSProcess()).isPresent(); + } + + @Test + void variant_is_selectable_in_preview(Page page) { + page.navigate("http://localhost:2020?preview=preview&variant=summer"); + Assertions.assertThat(page.locator("title").innerText()).isEqualTo("Summer time"); + } + + @Test + void variant_is_not_selectable_without_preview(Page page) { + page.navigate("http://localhost:2020?variant=summer"); + Assertions.assertThat(page.locator("title").innerText()).isEqualTo("Startpage"); + } +} diff --git a/test-server/hosts/demo/content/.variants/index/summer/index.md b/test-server/hosts/demo/content/.variants/index/summer/index.md index c0a291d67..b5cf3699f 100644 --- a/test-server/hosts/demo/content/.variants/index/summer/index.md +++ b/test-server/hosts/demo/content/.variants/index/summer/index.md @@ -2,7 +2,7 @@ template: start.html createdAt: 2026-07-29T12:06:01.530Z createdBy: test -title: Summer time 2 +title: Summer time status: draft features: [ ] From ba510537b8af364c42aadd9956fa1baf33ebd1ce Mon Sep 17 00:00:00 2001 From: Thorsten Marx Date: Thu, 30 Jul 2026 21:00:24 +0200 Subject: [PATCH 13/16] fix varaint selection dialog --- .../main/resources/manager/actions/page/variants.js | 12 ++++++++---- .../src/main/ts/src/actions/page/variants.ts | 12 ++++++++---- .../content/.variants/about/black-friday/about.md | 9 --------- .../content/.variants/about/winter-campain/about.md | 2 +- 4 files changed, 17 insertions(+), 18 deletions(-) delete mode 100644 test-server/hosts/demo/content/.variants/about/black-friday/about.md diff --git a/modules/ui-module/src/main/resources/manager/actions/page/variants.js b/modules/ui-module/src/main/resources/manager/actions/page/variants.js index 57bb584d1..e67bc84f6 100644 --- a/modules/ui-module/src/main/resources/manager/actions/page/variants.js +++ b/modules/ui-module/src/main/resources/manager/actions/page/variants.js @@ -58,10 +58,14 @@ const createVariantLink = (titleText, idText, uriText, url, variantId, active, m }; const createVariantItem = (variant, result, modal, container) => { const item = document.createElement('div'); - item.className = 'list-group-item d-flex align-items-center gap-2 p-0'; - const link = createVariantLink(variantTitle(variant), variant.id, variant.uri, result.canonical.url, variant.id, result.activeVariantId === variant.id, modal); - link.classList.remove('list-group-item'); - link.classList.add('flex-grow-1', 'border-0', 'rounded-0'); + const active = result.activeVariantId === variant.id; + item.className = `list-group-item d-flex align-items-center gap-2 p-0${active ? ' active' : ''}`; + const link = createVariantLink(variantTitle(variant), variant.id, variant.uri, result.canonical.url, variant.id, active, modal); + link.classList.remove('list-group-item', 'active'); + link.classList.add('flex-grow-1', 'border-0', 'rounded-0', 'px-3', 'py-2'); + if (active) { + link.classList.add('text-reset'); + } item.appendChild(link); const deleteButton = document.createElement('button'); deleteButton.type = 'button'; diff --git a/modules/ui-module/src/main/ts/src/actions/page/variants.ts b/modules/ui-module/src/main/ts/src/actions/page/variants.ts index f8d2018de..f9b2ad2d7 100644 --- a/modules/ui-module/src/main/ts/src/actions/page/variants.ts +++ b/modules/ui-module/src/main/ts/src/actions/page/variants.ts @@ -86,7 +86,8 @@ const createVariantItem = ( container: HTMLElement ): HTMLElement => { const item = document.createElement('div'); - item.className = 'list-group-item d-flex align-items-center gap-2 p-0'; + const active = result.activeVariantId === variant.id; + item.className = `list-group-item d-flex align-items-center gap-2 p-0${active ? ' active' : ''}`; const link = createVariantLink( variantTitle(variant), @@ -94,11 +95,14 @@ const createVariantItem = ( variant.uri, result.canonical.url, variant.id, - result.activeVariantId === variant.id, + active, modal ); - link.classList.remove('list-group-item'); - link.classList.add('flex-grow-1', 'border-0', 'rounded-0'); + link.classList.remove('list-group-item', 'active'); + link.classList.add('flex-grow-1', 'border-0', 'rounded-0', 'px-3', 'py-2'); + if (active) { + link.classList.add('text-reset'); + } item.appendChild(link); const deleteButton = document.createElement('button'); diff --git a/test-server/hosts/demo/content/.variants/about/black-friday/about.md b/test-server/hosts/demo/content/.variants/about/black-friday/about.md deleted file mode 100644 index f54c8d2f3..000000000 --- a/test-server/hosts/demo/content/.variants/about/black-friday/about.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -template: default.html -createdAt: 2026-07-29T12:59:22.845Z -createdBy: test -title: Back Friday sales party -status: draft ---- - -thats the friday black sale diff --git a/test-server/hosts/demo/content/.variants/about/winter-campain/about.md b/test-server/hosts/demo/content/.variants/about/winter-campain/about.md index bd4118fe4..3c3061c16 100644 --- a/test-server/hosts/demo/content/.variants/about/winter-campain/about.md +++ b/test-server/hosts/demo/content/.variants/about/winter-campain/about.md @@ -6,6 +6,6 @@ title: Winter Campaign status: draft --- -# About +# About winter back to [index](/) From c48ceace4e2d10854da0675e4ee97755f728988f Mon Sep 17 00:00:00 2001 From: Thorsten Marx Date: Thu, 30 Jul 2026 21:08:26 +0200 Subject: [PATCH 14/16] test --- test-server/hosts/demo/content/about.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test-server/hosts/demo/content/about.md b/test-server/hosts/demo/content/about.md index b144abe76..33f29b01d 100644 --- a/test-server/hosts/demo/content/about.md +++ b/test-server/hosts/demo/content/about.md @@ -1,19 +1,19 @@ --- -title: 'New of: start.html' template: default.html parent: text: testsdf -count: 46 -description: desc features: [ ] background_color: '#000000' range_test: 0 -status: published translations: de: /ueber +count: 46 +description: desc unpublish_date: null +title: About publish_date: null +status: published --- # About From aa48c08e5b6ae2f93d658c6c3053335c81d9cad1 Mon Sep 17 00:00:00 2001 From: Thorsten Marx Date: Fri, 31 Jul 2026 09:24:23 +0200 Subject: [PATCH 15/16] ignore variants in page search --- .../com/condation/cms/api/db/Content.java | 9 ++- .../com/condation/cms/api/db/ContentNode.java | 49 +++++++++++++++ .../cms/api/db/VariantSearchMode.java | 32 ++++++++++ .../condation/cms/api/db/ContentNodeTest.java | 28 +++++++++ .../condation/cms/filesystem/FileContent.java | 5 +- .../condation/cms/filesystem/MetaData.java | 7 ++- .../metadata/persistent/LuceneQuery.java | 2 +- .../persistent/PersistentMetaData.java | 10 ++- .../metadata/persistent/TitleQuery.java | 14 ++++- .../filesystem/PresistentFileSystemTest.java | 62 +++++++++++++++++++ .../remotemethods/RemotePageEnpoints.java | 3 +- .../remotemethods/RemotePageEnpointsTest.java | 6 +- 12 files changed, 215 insertions(+), 12 deletions(-) create mode 100644 cms-api/src/main/java/com/condation/cms/api/db/VariantSearchMode.java diff --git a/cms-api/src/main/java/com/condation/cms/api/db/Content.java b/cms-api/src/main/java/com/condation/cms/api/db/Content.java index 168038765..467a25a82 100644 --- a/cms-api/src/main/java/com/condation/cms/api/db/Content.java +++ b/cms-api/src/main/java/com/condation/cms/api/db/Content.java @@ -56,5 +56,12 @@ public interface Content { public ContentQuery query(final String startURI, final BiFunction nodeMapper); - public List searchByTitle (@NonNull String input); + default List searchByTitle (@NonNull String input) { + return searchByTitle(input, VariantSearchMode.ALL); + } + + List searchByTitle( + @NonNull String input, + @NonNull VariantSearchMode variantSearchMode + ); } diff --git a/cms-api/src/main/java/com/condation/cms/api/db/ContentNode.java b/cms-api/src/main/java/com/condation/cms/api/db/ContentNode.java index 958c41fe4..570625773 100644 --- a/cms-api/src/main/java/com/condation/cms/api/db/ContentNode.java +++ b/cms-api/src/main/java/com/condation/cms/api/db/ContentNode.java @@ -126,6 +126,55 @@ public boolean isSectionEntry() { return SectionUtil.isSectionEntry(name); } + public boolean isVariant() { + return variantPathSegment() >= 0; + } + + public Optional variantId() { + var pathParts = normalizedPathParts(); + var variantSegment = variantPathSegment(pathParts); + if (variantSegment < 0 || variantSegment + 2 >= pathParts.length) { + return Optional.empty(); + } + return Optional.of(pathParts[variantSegment + 2]); + } + + /** + * Returns the indexed path of the canonical page represented by this + * variant. + */ + public Optional originalUri() { + var pathParts = normalizedPathParts(); + var variantSegment = variantPathSegment(pathParts); + if (variantSegment < 0 || variantSegment + 1 >= pathParts.length) { + return Optional.empty(); + } + + var originalName = pathParts[variantSegment + 1] + ".md"; + if (variantSegment == 0) { + return Optional.of(originalName); + } + return Optional.of(String.join("/", java.util.Arrays.copyOf(pathParts, variantSegment)) + + "/" + originalName); + } + + private int variantPathSegment() { + return variantPathSegment(normalizedPathParts()); + } + + private int variantPathSegment(String[] pathParts) { + for (int index = 0; index < pathParts.length; index++) { + if (".variants".equals(pathParts[index])) { + return index; + } + } + return -1; + } + + private String[] normalizedPathParts() { + return uri.replace('\\', '/').split("/"); + } + public boolean isRedirect() { return MapUtil.getValue(data, Constants.MetaFields.REDIRECT_LOCATION) != null; } diff --git a/cms-api/src/main/java/com/condation/cms/api/db/VariantSearchMode.java b/cms-api/src/main/java/com/condation/cms/api/db/VariantSearchMode.java new file mode 100644 index 000000000..ca97d1aab --- /dev/null +++ b/cms-api/src/main/java/com/condation/cms/api/db/VariantSearchMode.java @@ -0,0 +1,32 @@ +package com.condation.cms.api.db; + +/*- + * #%L + * CMS Api + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +/** + * Controls whether a content search includes canonical pages, variants, or + * both. + */ +public enum VariantSearchMode { + ORIGINAL, + VARIANT, + ALL +} diff --git a/cms-api/src/test/java/com/condation/cms/api/db/ContentNodeTest.java b/cms-api/src/test/java/com/condation/cms/api/db/ContentNodeTest.java index 36da7211e..e5325d857 100644 --- a/cms-api/src/test/java/com/condation/cms/api/db/ContentNodeTest.java +++ b/cms-api/src/test/java/com/condation/cms/api/db/ContentNodeTest.java @@ -39,4 +39,32 @@ public void test_publish() { Assertions.assertThat(NodeVisibility.isVisible(contentNode)).isFalse(); Assertions.assertThat(contentNode.isVisible()).isFalse(); } + + @Test + void detectsVariantAndExposesVariantMetadata() { + var contentNode = new ContentNode( + "products/.variants/about/summer/about.md", + "/products/.variants/about/summer/about", + "about.md", + Map.of() + ); + + Assertions.assertThat(contentNode.isVariant()).isTrue(); + Assertions.assertThat(contentNode.variantId()).contains("summer"); + Assertions.assertThat(contentNode.originalUri()).contains("products/about.md"); + } + + @Test + void doesNotTreatSimilarFolderNameAsVariant() { + var contentNode = new ContentNode( + "products/my.variants/about.md", + "/products/my.variants/about", + "about.md", + Map.of() + ); + + Assertions.assertThat(contentNode.isVariant()).isFalse(); + Assertions.assertThat(contentNode.variantId()).isEmpty(); + Assertions.assertThat(contentNode.originalUri()).isEmpty(); + } } diff --git a/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileContent.java b/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileContent.java index b3206aece..d88c1ad77 100644 --- a/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileContent.java +++ b/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileContent.java @@ -24,6 +24,7 @@ import com.condation.cms.api.db.Content; import com.condation.cms.api.db.ContentNode; import com.condation.cms.api.db.ContentQuery; +import com.condation.cms.api.db.VariantSearchMode; import com.condation.cms.api.db.cms.ReadOnlyFile; import com.condation.cms.api.utils.PathUtil; import java.util.List; @@ -102,8 +103,8 @@ public Optional> getMeta(String uri) { } @Override - public List searchByTitle (String input) { - var titleQuery = fileSystem.getMetaData().searchByTitle(input); + public List searchByTitle (String input, VariantSearchMode variantSearchMode) { + var titleQuery = fileSystem.getMetaData().searchByTitle(input, variantSearchMode); return titleQuery.list(); } diff --git a/cms-filesystem/src/main/java/com/condation/cms/filesystem/MetaData.java b/cms-filesystem/src/main/java/com/condation/cms/filesystem/MetaData.java index 905106f5d..4ffbd5e8f 100644 --- a/cms-filesystem/src/main/java/com/condation/cms/filesystem/MetaData.java +++ b/cms-filesystem/src/main/java/com/condation/cms/filesystem/MetaData.java @@ -24,6 +24,7 @@ import com.condation.cms.api.db.ContentNode; import com.condation.cms.api.db.ContentQuery; +import com.condation.cms.api.db.VariantSearchMode; import com.condation.cms.filesystem.metadata.persistent.TitleQuery; import java.io.IOException; import java.time.LocalDate; @@ -64,7 +65,11 @@ public interface MetaData { List listSectionEntries(String pagePath); - TitleQuery searchByTitle(String path); + default TitleQuery searchByTitle(String input) { + return searchByTitle(input, VariantSearchMode.ALL); + } + + TitleQuery searchByTitle(String input, VariantSearchMode variantSearchMode); void clear (); diff --git a/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/LuceneQuery.java b/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/LuceneQuery.java index 2d337c071..0bb96beef 100644 --- a/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/LuceneQuery.java +++ b/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/LuceneQuery.java @@ -158,7 +158,7 @@ private List queryContentNodes() { var contentNodes = result.stream() .map(document -> document.get("_uri")) - .map(metaData::byUri) + .map(metaData::byPath) .filter(Optional::isPresent) .map(Optional::get) .filter(node -> !node.isDirectory()) diff --git a/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/PersistentMetaData.java b/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/PersistentMetaData.java index 54c8f5b30..2a791d8df 100644 --- a/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/PersistentMetaData.java +++ b/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/PersistentMetaData.java @@ -25,6 +25,7 @@ import com.condation.cms.api.db.ContentNode; import com.condation.cms.api.db.ContentQuery; import com.condation.cms.api.db.NodeVisibility; +import com.condation.cms.api.db.VariantSearchMode; import com.condation.cms.api.utils.PathUtil; import com.condation.cms.filesystem.metadata.AbstractMetaData; import com.condation.cms.filesystem.metadata.query.ExcerptMapperFunction; @@ -153,6 +154,11 @@ public synchronized void addFile(String uri, Map data, LocalDate Document document = new Document(); document.add(new StringField("_uri", uri, Field.Store.YES)); document.add(new StringField("_url", node.url(), Field.Store.YES)); + document.add(new StringField("_variant", Boolean.toString(node.isVariant()), Field.Store.YES)); + node.variantId().ifPresent(variantId -> + document.add(new StringField("_variant_id", variantId, Field.Store.YES))); + node.originalUri().ifPresent(originalUri -> + document.add(new StringField("_variant_original", originalUri, Field.Store.YES))); //document.add(new StringField("_source", GSON.toJson(node), Field.Store.NO)); DocumentHelper.addData(document, data); @@ -285,8 +291,8 @@ public ContentQuery query(final String startURI, final BiFunction queryContentNodes() { BooleanQuery.Builder queryBuilder = new BooleanQuery.Builder(); queryBuilder.add(new TermQuery(new Term("content.type", contentType)), BooleanClause.Occur.MUST); queryBuilder.add(titleQueryFactory.createQuery(input), BooleanClause.Occur.MUST); + if (variantSearchMode != VariantSearchMode.ALL) { + queryBuilder.add( + new TermQuery(new Term( + "_variant", + Boolean.toString(variantSearchMode == VariantSearchMode.VARIANT) + )), + BooleanClause.Occur.MUST + ); + } List result = index.query(queryBuilder.build()); var contentNodes = result.stream() .map(document -> document.get("_uri")) - .map(metaData::byUri) + .map(metaData::byPath) .filter(Optional::isPresent) .map(Optional::get) .filter(node -> !node.isDirectory()) diff --git a/cms-filesystem/src/test/java/com/condation/cms/filesystem/PresistentFileSystemTest.java b/cms-filesystem/src/test/java/com/condation/cms/filesystem/PresistentFileSystemTest.java index df6cd77e1..521cc90b2 100644 --- a/cms-filesystem/src/test/java/com/condation/cms/filesystem/PresistentFileSystemTest.java +++ b/cms-filesystem/src/test/java/com/condation/cms/filesystem/PresistentFileSystemTest.java @@ -23,14 +23,21 @@ import com.condation.cms.api.db.Content; import com.condation.cms.api.db.ContentNode; +import com.condation.cms.api.db.VariantSearchMode; import com.condation.cms.api.eventbus.EventBus; +import com.condation.cms.api.feature.features.IsPreviewFeature; +import com.condation.cms.api.request.RequestContext; +import com.condation.cms.api.request.RequestContextScope; import com.condation.cms.api.utils.FileUtils; import com.condation.cms.filesystem.metadata.query.ExtendableQuery; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.time.LocalDate; import java.util.List; +import java.util.Map; +import java.util.function.Supplier; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.AfterAll; @@ -62,6 +69,15 @@ static void setup() throws IOException { } }); fileSystem.init(); + fileSystem.getMetaData().addFile( + "test/.variants/test1/summer/test1.md", + Map.of( + "name", "test1-summer", + "title", "Superman Summer", + "status", "published" + ), + LocalDate.now() + ); content = new FileContent(fileSystem); } @@ -283,6 +299,52 @@ public void test_searchByTitle_blankInputMatchesAllPages() { Assertions.assertThat(nodes).hasSize(3); } + @Test + void test_searchByTitle_canSearchOnlyOriginals() { + List nodes = content.searchByTitle("Superman", VariantSearchMode.ORIGINAL); + + Assertions.assertThat(nodes) + .singleElement() + .satisfies(node -> { + Assertions.assertThat(node.isVariant()).isFalse(); + Assertions.assertThat(node.data().get("name")).isEqualTo("test1"); + }); + } + + @Test + void test_searchByTitle_canSearchOnlyVariants() { + List nodes = inManagerContext(() -> + content.searchByTitle("Superman", VariantSearchMode.VARIANT)); + + Assertions.assertThat(nodes) + .singleElement() + .satisfies(node -> { + Assertions.assertThat(node.isVariant()).isTrue(); + Assertions.assertThat(node.variantId()).contains("summer"); + Assertions.assertThat(node.originalUri()).contains("test/test1.md"); + }); + } + + @Test + void test_searchByTitle_canSearchOriginalsAndVariants() { + List nodes = inManagerContext(() -> + content.searchByTitle("Superman", VariantSearchMode.ALL)); + + Assertions.assertThat(nodes).hasSize(2); + } + + private List inManagerContext( + Supplier> search + ) { + var requestContext = new RequestContext(); + requestContext.add( + IsPreviewFeature.class, + new IsPreviewFeature(IsPreviewFeature.Mode.MANAGER) + ); + return ScopedValue.where(RequestContextScope.REQUEST_CONTEXT, requestContext) + .call(search::get); + } + @Test public void test_byUrl() throws IOException { var node = content.byUrl("/"); diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemotePageEnpoints.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemotePageEnpoints.java index 3aa35b73b..31efe9c08 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemotePageEnpoints.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemotePageEnpoints.java @@ -24,6 +24,7 @@ import com.condation.cms.api.auth.Permissions; import com.condation.cms.api.db.DB; import com.condation.cms.api.db.Page; +import com.condation.cms.api.db.VariantSearchMode; import com.condation.cms.api.eventbus.events.ReIndexContentMetaDataEvent; import com.condation.cms.api.feature.features.DBFeature; import com.condation.cms.api.feature.features.EventBusFeature; @@ -72,7 +73,7 @@ public Object searchPages (Map parameters) throws RPCException { final DB db = getContext().get(DBFeature.class).db(); var contentBase = db.getFileSystem().contentBase(); - var hits = db.getContent().searchByTitle(query).stream() + var hits = db.getContent().searchByTitle(query, VariantSearchMode.ORIGINAL).stream() .map(node -> { var contentFile = contentBase.resolve(node.uri()); var url = PathUtil.toURL(contentFile, contentBase); diff --git a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemotePageEnpointsTest.java b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemotePageEnpointsTest.java index 2404185c6..0f34afcf9 100644 --- a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemotePageEnpointsTest.java +++ b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemotePageEnpointsTest.java @@ -28,6 +28,7 @@ import com.condation.cms.api.db.ContentQuery; import com.condation.cms.api.db.DB; import com.condation.cms.api.db.Page; +import com.condation.cms.api.db.VariantSearchMode; import com.condation.cms.api.db.cms.ReadOnlyFile; import com.condation.cms.api.feature.features.DBFeature; import com.condation.cms.api.module.SiteModuleContext; @@ -174,7 +175,7 @@ public void testSearchPages_returnsRewrittenUriAndTitle() throws RPCException { meta.put(Constants.MetaFields.TITLE, "Superman Returns"); ContentNode node = new ContentNode("test/test1.md", "/test/test1", "test1.md", meta); - when(content.searchByTitle("superman")).thenReturn(List.of(node)); + when(content.searchByTitle("superman", VariantSearchMode.ORIGINAL)).thenReturn(List.of(node)); when(contentBase.resolve("test/test1.md")).thenReturn(contentFile); when(contentBase.relativize(contentFile)).thenReturn(contentFile); when(contentFile.toString()).thenReturn("test/test1.md"); @@ -195,6 +196,7 @@ public void testSearchPages_returnsRewrittenUriAndTitle() throws RPCException { assertThat(hits).hasSize(1); assertThat(hits.get(0).title()).isEqualTo("Superman Returns"); assertThat(hits.get(0).uri()).isNotNull(); + verify(content).searchByTitle("superman", VariantSearchMode.ORIGINAL); } @Test @@ -206,7 +208,7 @@ public void testSearchPages_missingTitle_fallsBackToEmptyString() throws RPCExce ContentNode node = new ContentNode("test/test2.md", "/test/test2", "test2.md", new HashMap<>()); - when(content.searchByTitle("")).thenReturn(List.of(node)); + when(content.searchByTitle("", VariantSearchMode.ORIGINAL)).thenReturn(List.of(node)); when(contentBase.resolve("test/test2.md")).thenReturn(contentFile); when(contentBase.relativize(contentFile)).thenReturn(contentFile); when(contentFile.toString()).thenReturn("test/test2.md"); From 235079be03990b5988c00c15683627ea2df7cc5c Mon Sep 17 00:00:00 2001 From: Thorsten Marx Date: Fri, 31 Jul 2026 10:00:03 +0200 Subject: [PATCH 16/16] fixes for variant ui --- .../condation/cms/filesystem/FileContent.java | 4 +- .../condation/cms/filesystem/MetaData.java | 6 +-- .../persistent/PersistentMetaData.java | 4 +- .../metadata/persistent/TitleQuery.java | 9 +++-- .../remotemethods/RemoteVariantEndpoint.java | 24 ++++++++---- .../manager/actions/page/create-variant.js | 27 +++++++++++--- .../manager/js/modules/rpc/rpc-variant.d.ts | 1 + .../ts/src/actions/page/create-variant.ts | 37 ++++++++++++++++--- .../main/ts/src/js/modules/rpc/rpc-variant.ts | 1 + .../RemoteVariantEndpointTest.java | 7 +++- 10 files changed, 87 insertions(+), 33 deletions(-) diff --git a/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileContent.java b/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileContent.java index d88c1ad77..2d81e0134 100644 --- a/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileContent.java +++ b/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileContent.java @@ -104,9 +104,9 @@ public Optional> getMeta(String uri) { @Override public List searchByTitle (String input, VariantSearchMode variantSearchMode) { - var titleQuery = fileSystem.getMetaData().searchByTitle(input, variantSearchMode); + var titleQuery = fileSystem.getMetaData().searchByTitle(input); - return titleQuery.list(); + return titleQuery.list(variantSearchMode); } } diff --git a/cms-filesystem/src/main/java/com/condation/cms/filesystem/MetaData.java b/cms-filesystem/src/main/java/com/condation/cms/filesystem/MetaData.java index 4ffbd5e8f..91df27df1 100644 --- a/cms-filesystem/src/main/java/com/condation/cms/filesystem/MetaData.java +++ b/cms-filesystem/src/main/java/com/condation/cms/filesystem/MetaData.java @@ -65,11 +65,7 @@ public interface MetaData { List listSectionEntries(String pagePath); - default TitleQuery searchByTitle(String input) { - return searchByTitle(input, VariantSearchMode.ALL); - } - - TitleQuery searchByTitle(String input, VariantSearchMode variantSearchMode); + TitleQuery searchByTitle(String input); void clear (); diff --git a/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/PersistentMetaData.java b/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/PersistentMetaData.java index 2a791d8df..140780a1a 100644 --- a/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/PersistentMetaData.java +++ b/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/PersistentMetaData.java @@ -291,8 +291,8 @@ public ContentQuery query(final String startURI, final BiFunction list(VariantSearchMode variantSearchMode) { + return queryContentNodes(variantSearchMode); + } + public List list() { - return queryContentNodes(); + return queryContentNodes(VariantSearchMode.ALL); } - private List queryContentNodes() { + private List queryContentNodes(VariantSearchMode variantSearchMode) { try { BooleanQuery.Builder queryBuilder = new BooleanQuery.Builder(); diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpoint.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpoint.java index 3d42943f3..b05d95bae 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpoint.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpoint.java @@ -117,17 +117,23 @@ public Object create(Map parameters) throws RPCException { var template = stringParameter(parameters, "template"); var copyContent = Boolean.TRUE.equals(parameters.get("copyContent")); - if (uri.isBlank() || id.isBlank() || title.isBlank() || template.isBlank()) { - throw new RPCException(400, "uri, id, title and template must not be blank"); - } - if (uiHooks().contentTypes().getPageTemplates().stream() - .noneMatch(pageTemplate -> pageTemplate.template().equals(template))) { - throw new RPCException(400, "unknown page template"); + if (uri.isBlank() || id.isBlank() || title.isBlank()) { + throw new RPCException(400, "uri, id and title must not be blank"); } var db = getDB(parameters); var requestedNode = findContentNode(db, uri); var canonicalNode = getVariantResolver(db).resolveContext(requestedNode).canonical(); + var selectedTemplate = copyContent + ? canonicalNode.getMetaValue(Constants.MetaFields.TEMPLATE, "") + : template; + if (selectedTemplate.isBlank()) { + throw new RPCException(400, "template must not be blank"); + } + if (uiHooks().contentTypes().getPageTemplates().stream() + .noneMatch(pageTemplate -> pageTemplate.template().equals(selectedTemplate))) { + throw new RPCException(400, "unknown page template"); + } var contentBase = db.getFileSystem().resolve(Constants.Folders.CONTENT); var canonicalFile = contentBase.resolve(canonicalNode.path()); var variantId = UIPathUtil.toValidFilename(id); @@ -169,7 +175,7 @@ public Object create(Map parameters) throws RPCException { Map meta = new HashMap<>(); meta.put(Constants.MetaFields.TITLE, title); - meta.put(Constants.MetaFields.TEMPLATE, template); + meta.put(Constants.MetaFields.TEMPLATE, selectedTemplate); meta.put(Constants.MetaFields.STATUS, getContext().get(WorkflowFeature.class) .workflow().getStatusProvider().newNodeStatus()); meta.put("createdAt", Date.from(Instant.now())); @@ -291,7 +297,9 @@ public Object get(Map parameters) throws RPCException { "uri", variantContext.canonical().uri(), "url", managerPreviewUrl(variantContext.canonical().url()), "title", variantContext.canonical() - .getMetaValue(Constants.MetaFields.TITLE, variantContext.canonical().name()) + .getMetaValue(Constants.MetaFields.TITLE, variantContext.canonical().name()), + "template", variantContext.canonical() + .getMetaValue(Constants.MetaFields.TEMPLATE, "") )); result.put("activeVariantId", variantContext.activeVariantId().orElse(null)); result.put("variants", variants); diff --git a/modules/ui-module/src/main/resources/manager/actions/page/create-variant.js b/modules/ui-module/src/main/resources/manager/actions/page/create-variant.js index df202a9ae..f1c0ece63 100644 --- a/modules/ui-module/src/main/resources/manager/actions/page/create-variant.js +++ b/modules/ui-module/src/main/resources/manager/actions/page/create-variant.js @@ -23,14 +23,15 @@ import { openModal } from '@cms/modules/modal.js'; import { getPreviewUrl, loadPreview } from '@cms/modules/preview.utils.js'; import { getContentNode } from '@cms/modules/rpc/rpc-content.js'; import { getPageTemplates } from '@cms/modules/rpc/rpc-manager.js'; -import { createVariant } from '@cms/modules/rpc/rpc-variant.js'; +import { createVariant, getVariants } from '@cms/modules/rpc/rpc-variant.js'; import { showToast } from '@cms/modules/toast.js'; const value = (id) => document.getElementById(id)?.value.trim() ?? ''; const validate = () => { + const copyContent = document.querySelector('input[name="cms-variant-content"]:checked')?.value === 'copy'; const missing = [ ['cms-variant-id', 'Variant ID'], ['cms-variant-title', 'Title'], - ['cms-variant-template', 'Template'] + ...(copyContent ? [] : [['cms-variant-template', 'Template']]) ].find(([id]) => !value(id) || (id === 'cms-variant-template' && value(id) === '__none__')); if (!missing) { return true; @@ -45,13 +46,14 @@ const validate = () => { }; export const runAction = async () => { try { - const [contentNode, templatesResponse] = await Promise.all([ + const [activeContentNode, templatesResponse] = await Promise.all([ getContentNode({ url: getPreviewUrl() }), getPageTemplates({}) ]); + const variantContext = await getVariants({ uri: activeContentNode.result.uri }); const templates = Array.from(templatesResponse.result ?? []) .sort((left, right) => String(left.name).localeCompare(String(right.name))); - const currentTemplate = contentNode.result.meta?.template ?? contentNode.result.data?.template ?? ''; + const currentTemplate = variantContext.canonical.template; const options = templates.map((template) => { const selected = template.template === currentTemplate ? ' selected' : ''; return ``; @@ -88,14 +90,27 @@ export const runAction = async () => { fullscreen: false, validate, onCancel: () => { }, + onShow: (modalElement) => { + const templateSelect = modalElement.querySelector('#cms-variant-template'); + const contentOptions = modalElement.querySelectorAll('input[name="cms-variant-content"]'); + const syncTemplateState = () => { + const copyContent = modalElement.querySelector('input[name="cms-variant-content"]:checked')?.value === 'copy'; + if (copyContent) { + templateSelect.value = currentTemplate; + } + templateSelect.disabled = copyContent; + }; + contentOptions.forEach(option => option.addEventListener('change', syncTemplateState)); + syncTemplateState(); + }, onOk: async () => { try { const copyContent = document.querySelector('input[name="cms-variant-content"]:checked')?.value === 'copy'; const result = await createVariant({ - uri: contentNode.result.uri, + uri: variantContext.canonical.uri, id: value('cms-variant-id'), title: value('cms-variant-title'), - template: value('cms-variant-template'), + template: copyContent ? currentTemplate : value('cms-variant-template'), copyContent }); showToast({ diff --git a/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-variant.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-variant.d.ts index 0651ebce3..6fde1add6 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-variant.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-variant.d.ts @@ -34,6 +34,7 @@ export interface GetVariantsResult { uri: string; url: string; title: string; + template: string; }; activeVariantId?: string | null; variants: VariantDto[]; diff --git a/modules/ui-module/src/main/ts/src/actions/page/create-variant.ts b/modules/ui-module/src/main/ts/src/actions/page/create-variant.ts index 422e62754..eb26bb54e 100644 --- a/modules/ui-module/src/main/ts/src/actions/page/create-variant.ts +++ b/modules/ui-module/src/main/ts/src/actions/page/create-variant.ts @@ -24,17 +24,20 @@ import { openModal } from '@cms/modules/modal.js' import { getPreviewUrl, loadPreview } from '@cms/modules/preview.utils.js' import { getContentNode } from '@cms/modules/rpc/rpc-content.js' import { getPageTemplates } from '@cms/modules/rpc/rpc-manager.js' -import { createVariant } from '@cms/modules/rpc/rpc-variant.js' +import { createVariant, getVariants } from '@cms/modules/rpc/rpc-variant.js' import { showToast } from '@cms/modules/toast.js' const value = (id: string): string => (document.getElementById(id) as HTMLInputElement | HTMLSelectElement)?.value.trim() ?? ''; const validate = (): boolean => { + const copyContent = (document.querySelector( + 'input[name="cms-variant-content"]:checked' + ) as HTMLInputElement)?.value === 'copy'; const missing = [ ['cms-variant-id', 'Variant ID'], ['cms-variant-title', 'Title'], - ['cms-variant-template', 'Template'] + ...(copyContent ? [] : [['cms-variant-template', 'Template']]) ].find(([id]) => !value(id) || (id === 'cms-variant-template' && value(id) === '__none__')); if (!missing) { @@ -55,13 +58,14 @@ const validate = (): boolean => { export const runAction = async () => { try { - const [contentNode, templatesResponse] = await Promise.all([ + const [activeContentNode, templatesResponse] = await Promise.all([ getContentNode({ url: getPreviewUrl() }), getPageTemplates({}) ]); + const variantContext = await getVariants({ uri: activeContentNode.result.uri }); const templates = Array.from(templatesResponse.result ?? []) .sort((left: any, right: any) => String(left.name).localeCompare(String(right.name))); - const currentTemplate = contentNode.result.meta?.template ?? contentNode.result.data?.template ?? ''; + const currentTemplate = variantContext.canonical.template; const options = templates.map((template: any) => { const selected = template.template === currentTemplate ? ' selected' : ''; @@ -100,16 +104,37 @@ export const runAction = async () => { fullscreen: false, validate, onCancel: () => {}, + onShow: (modalElement: HTMLElement) => { + const templateSelect = modalElement.querySelector( + '#cms-variant-template' + ) as HTMLSelectElement; + const contentOptions = modalElement.querySelectorAll( + 'input[name="cms-variant-content"]' + ); + const syncTemplateState = () => { + const copyContent = (modalElement.querySelector( + 'input[name="cms-variant-content"]:checked' + ) as HTMLInputElement)?.value === 'copy'; + if (copyContent) { + templateSelect.value = currentTemplate; + } + templateSelect.disabled = copyContent; + }; + + contentOptions.forEach(option => + option.addEventListener('change', syncTemplateState)); + syncTemplateState(); + }, onOk: async () => { try { const copyContent = (document.querySelector( 'input[name="cms-variant-content"]:checked' ) as HTMLInputElement)?.value === 'copy'; const result = await createVariant({ - uri: contentNode.result.uri, + uri: variantContext.canonical.uri, id: value('cms-variant-id'), title: value('cms-variant-title'), - template: value('cms-variant-template'), + template: copyContent ? currentTemplate : value('cms-variant-template'), copyContent }); showToast({ diff --git a/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc-variant.ts b/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc-variant.ts index 599edecd1..ba6a8322f 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc-variant.ts +++ b/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc-variant.ts @@ -39,6 +39,7 @@ export interface GetVariantsResult { uri: string; url: string; title: string; + template: string; }; activeVariantId?: string | null; variants: VariantDto[]; diff --git a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpointTest.java b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpointTest.java index 29b20a1af..a7dd319dd 100644 --- a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpointTest.java +++ b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpointTest.java @@ -114,7 +114,10 @@ protected VariantSelectorConfigurationRepository getVariantSelectorConfiguration @Test void getReturnsVariantsSortedById() throws RPCException { - var node = node("about.md", "/about", Map.of("title", "About")); + var node = node("about.md", "/about", Map.of( + "title", "About", + "template", "page.html" + )); var summerNode = node( ".variants/about/summer/about.md", "/.variants/about/summer/about", @@ -148,6 +151,8 @@ void getReturnsVariantsSortedById() throws RPCException { "/about?preview=manager&variant=summer" ); assertThat(result).containsEntry("activeVariantId", null); + assertThat((Map) result.get("canonical")) + .containsEntry("template", "page.html"); } @Test