diff --git a/src/Elastic.Documentation.Site/Assets/markdown/applies-to.css b/src/Elastic.Documentation.Site/Assets/markdown/applies-to.css index a021fd476..312e4da4d 100644 --- a/src/Elastic.Documentation.Site/Assets/markdown/applies-to.css +++ b/src/Elastic.Documentation.Site/Assets/markdown/applies-to.css @@ -12,6 +12,10 @@ @apply border-grey-20 inline-grid cursor-default grid-cols-[auto_1fr_auto] rounded-full border-[1px] bg-white pt-1.5 pr-3 pb-1.5 pl-3; } + .applicable-meta { + @apply inline-flex gap-1.5; + } + .applicable-name, .applicable-meta { @apply text-xs text-nowrap; @@ -45,4 +49,29 @@ font-size: 0.65em; } } + + .applicable-ellipsis { + @apply bg-grey-20 inline-flex h-full items-center gap-0.5 rounded-md px-0.5 py-1; + } + + .applicable-ellipsis__dot { + @apply bg-grey-50 size-1 rounded-full; + } +} + +.tippy-box[data-theme~='applies-to'] { + .tippy-content { + white-space: normal; + + strong { + display: block; + margin-bottom: calc(var(--spacing) * 1); + } + } + + .tippy-content > div:not(:last-child) { + border-bottom: 1px dotted var(--color-grey-50); + padding-bottom: calc(var(--spacing) * 3); + margin-bottom: calc(var(--spacing) * 3); + } } diff --git a/src/Elastic.Documentation.Site/Assets/markdown/applies-to.ts b/src/Elastic.Documentation.Site/Assets/markdown/applies-to.ts index d222c8448..df5ad9d66 100644 --- a/src/Elastic.Documentation.Site/Assets/markdown/applies-to.ts +++ b/src/Elastic.Documentation.Site/Assets/markdown/applies-to.ts @@ -1,14 +1,23 @@ +import { $$ } from 'select-dom' import tippy from 'tippy.js' document.addEventListener('htmx:load', function () { - tippy( - [ - '.applies [data-tippy-content]:not([data-tippy-content=""])', - '.applies-inline [data-tippy-content]:not([data-tippy-content=""])', - ].join(', '), - { + const selector = [ + '.applies [data-tippy-content]:not([data-tippy-content=""])', + '.applies-inline [data-tippy-content]:not([data-tippy-content=""])', + ].join(', ') + + const appliesToBadgesWithTooltip = $$(selector) + appliesToBadgesWithTooltip.forEach((badge) => { + const content = badge.getAttribute('data-tippy-content') + if (!content) return + tippy(badge, { + content, + allowHTML: true, delay: [400, 100], hideOnClick: false, - } - ) + ignoreAttributes: true, + theme: 'applies-to', + }) + }) }) diff --git a/src/Elastic.Documentation/AppliesTo/ApplicabilitySelector.cs b/src/Elastic.Documentation/AppliesTo/ApplicabilitySelector.cs new file mode 100644 index 000000000..cb881fbf6 --- /dev/null +++ b/src/Elastic.Documentation/AppliesTo/ApplicabilitySelector.cs @@ -0,0 +1,58 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +namespace Elastic.Documentation.AppliesTo; + +/// +/// Utility class for selecting the most relevant applicability from a collection of applicabilities. +/// +public static class ApplicabilitySelector +{ + /// + /// Selects the most relevant applicability for display: available versions first (highest version), then closest future version + /// + /// The collection of applicabilities to select from + /// The current version to use for comparison + /// The most relevant applicability for display + public static Applicability GetPrimaryApplicability(IEnumerable applicabilities, SemVersion currentVersion) + { + var applicabilityList = applicabilities.ToList(); + var lifecycleOrder = new Dictionary + { + [ProductLifecycle.GenerallyAvailable] = 0, + [ProductLifecycle.Beta] = 1, + [ProductLifecycle.TechnicalPreview] = 2, + [ProductLifecycle.Planned] = 3, + [ProductLifecycle.Deprecated] = 4, + [ProductLifecycle.Removed] = 5, + [ProductLifecycle.Unavailable] = 6 + }; + + var availableApplicabilities = applicabilityList + .Where(a => a.Version is null || a.Version is AllVersions || a.Version <= currentVersion) + .ToList(); + + if (availableApplicabilities.Count != 0) + { + return availableApplicabilities + .OrderByDescending(a => a.Version ?? new SemVersion(0, 0, 0)) + .ThenBy(a => lifecycleOrder.GetValueOrDefault(a.Lifecycle, 999)) + .First(); + } + + var futureApplicabilities = applicabilityList + .Where(a => a.Version is not null && a.Version is not AllVersions && a.Version > currentVersion) + .ToList(); + + if (futureApplicabilities.Count != 0) + { + return futureApplicabilities + .OrderBy(a => a.Version!.CompareTo(currentVersion)) + .ThenBy(a => lifecycleOrder.GetValueOrDefault(a.Lifecycle, 999)) + .First(); + } + + return applicabilityList.First(); + } +} diff --git a/src/Elastic.Markdown/Myst/Components/ApplicabilityItem.cs b/src/Elastic.Markdown/Myst/Components/ApplicabilityItem.cs index d5a71f78b..348009ad3 100644 --- a/src/Elastic.Markdown/Myst/Components/ApplicabilityItem.cs +++ b/src/Elastic.Markdown/Myst/Components/ApplicabilityItem.cs @@ -8,6 +8,10 @@ namespace Elastic.Markdown.Myst.Components; public record ApplicabilityItem( string Key, - Applicability Applicability, - ApplicabilityRenderer.ApplicabilityRenderData RenderData -); + Applicability PrimaryApplicability, + ApplicabilityRenderer.ApplicabilityRenderData RenderData, + ApplicabilityMappings.ApplicabilityDefinition ApplicabilityDefinition +) +{ + public Applicability Applicability => PrimaryApplicability; +} diff --git a/src/Elastic.Markdown/Myst/Components/ApplicabilityMappings.cs b/src/Elastic.Markdown/Myst/Components/ApplicabilityMappings.cs index 138b04a0f..50aa3bbad 100644 --- a/src/Elastic.Markdown/Myst/Components/ApplicabilityMappings.cs +++ b/src/Elastic.Markdown/Myst/Components/ApplicabilityMappings.cs @@ -54,4 +54,5 @@ public record ApplicabilityDefinition(string Key, string DisplayName, Versioning // Generic product public static readonly ApplicabilityDefinition Product = new("", "", VersioningSystemId.All); + } diff --git a/src/Elastic.Markdown/Myst/Components/ApplicabilityRenderer.cs b/src/Elastic.Markdown/Myst/Components/ApplicabilityRenderer.cs index 87a683c89..8ffe34306 100644 --- a/src/Elastic.Markdown/Myst/Components/ApplicabilityRenderer.cs +++ b/src/Elastic.Markdown/Myst/Components/ApplicabilityRenderer.cs @@ -17,7 +17,8 @@ public record ApplicabilityRenderData( string TooltipText, string LifecycleClass, bool ShowLifecycleName, - bool ShowVersion + bool ShowVersion, + bool HasMultipleLifecycles = false ); public ApplicabilityRenderData RenderApplicability( @@ -46,6 +47,66 @@ public ApplicabilityRenderData RenderApplicability( ); } + public ApplicabilityRenderData RenderCombinedApplicability( + IEnumerable applicabilities, + ApplicabilityMappings.ApplicabilityDefinition applicabilityDefinition, + VersioningSystem versioningSystem, + AppliesCollection allApplications) + { + var applicabilityList = applicabilities.ToList(); + var primaryApplicability = ApplicabilitySelector.GetPrimaryApplicability(applicabilityList, versioningSystem.Current); + + var primaryRenderData = RenderApplicability(primaryApplicability, applicabilityDefinition, versioningSystem, allApplications); + var combinedTooltip = BuildCombinedTooltipText(applicabilityList, applicabilityDefinition, versioningSystem); + + // Check if there are multiple different lifecycles + var hasMultipleLifecycles = applicabilityList.Select(a => a.Lifecycle).Distinct().Count() > 1; + + return primaryRenderData with + { + TooltipText = combinedTooltip, + HasMultipleLifecycles = hasMultipleLifecycles, + ShowLifecycleName = primaryRenderData.ShowLifecycleName || (string.IsNullOrEmpty(primaryRenderData.BadgeLifecycleText) && hasMultipleLifecycles) + }; + } + + + private static string BuildCombinedTooltipText( + List applicabilities, + ApplicabilityMappings.ApplicabilityDefinition applicabilityDefinition, + VersioningSystem versioningSystem) + { + var tooltipParts = new List(); + + // Order by the same logic as primary selection: available first (by version desc), then future (by version asc) + var orderedApplicabilities = applicabilities + .OrderByDescending(a => a.Version is null || a.Version is AllVersions || a.Version <= versioningSystem.Current ? 1 : 0) + .ThenByDescending(a => a.Version ?? new SemVersion(0, 0, 0)) + .ThenBy(a => a.Version ?? new SemVersion(0, 0, 0)) + .ToList(); + + foreach (var applicability in orderedApplicabilities) + { + var realVersion = TryGetRealVersion(applicability, out var v) ? v : null; + var lifecycleFull = GetLifecycleFullText(applicability.Lifecycle); + var heading = CreateApplicabilityHeading(applicability, applicabilityDefinition, realVersion); + var tooltipText = BuildTooltipText(applicability, applicabilityDefinition, versioningSystem, realVersion, lifecycleFull); + // language=html + tooltipParts.Add($"
{heading}{tooltipText}
"); + } + + return string.Join("\n\n", tooltipParts); + } + + private static string CreateApplicabilityHeading(Applicability applicability, ApplicabilityMappings.ApplicabilityDefinition applicabilityDefinition, + SemVersion? realVersion) + { + var lifecycleName = applicability.GetLifeCycleName(); + var versionText = realVersion is not null ? $" {realVersion}" : ""; + // language=html + return $"""{applicabilityDefinition.DisplayName} {lifecycleName}{versionText}:"""; + } + private static string GetLifecycleFullText(ProductLifecycle lifecycle) => lifecycle switch { ProductLifecycle.GenerallyAvailable => "Available", @@ -76,8 +137,10 @@ or ProductLifecycle.Beta or ProductLifecycle.TechnicalPreview or ProductLifecycle.Planned => $"We plan to add this functionality in a future {applicabilityDefinition.DisplayName} update. Subject to change.", - ProductLifecycle.Deprecated => $"We plan to deprecate this functionality in a future {applicabilityDefinition.DisplayName} update. Subject to change.", - ProductLifecycle.Removed => $"We plan to remove this functionality in a future {applicabilityDefinition.DisplayName} update. Subject to change.", + ProductLifecycle.Deprecated => + $"We plan to deprecate this functionality in a future {applicabilityDefinition.DisplayName} update. Subject to change.", + ProductLifecycle.Removed => + $"We plan to remove this functionality in a future {applicabilityDefinition.DisplayName} update. Subject to change.", _ => tooltipText } : $"{lifecycleFull} on {applicabilityDefinition.DisplayName} unless otherwise specified."; @@ -91,8 +154,10 @@ or ProductLifecycle.TechnicalPreview private static string? GetDisclaimer(ProductLifecycle lifecycle, VersioningSystemId versioningSystemId) => lifecycle switch { - ProductLifecycle.Beta => "Beta features are subject to change. The design and code is less mature than official GA features and is being provided as-is with no warranties. Beta features are not subject to the support SLA of official GA features.", - ProductLifecycle.TechnicalPreview => "This functionality may be changed or removed in a future release. Elastic will work to fix any issues, but features in technical preview are not subject to the support SLA of official GA features.", + ProductLifecycle.Beta => + "Beta features are subject to change. The design and code is less mature than official GA features and is being provided as-is with no warranties. Beta features are not subject to the support SLA of official GA features.", + ProductLifecycle.TechnicalPreview => + "This functionality may be changed or removed in a future release. Elastic will work to fix any issues, but features in technical preview are not subject to the support SLA of official GA features.", ProductLifecycle.GenerallyAvailable => versioningSystemId is VersioningSystemId.Stack ? "If this functionality is unavailable or behaves differently when deployed on ECH, ECE, ECK, or a self-managed installation, it will be indicated on the page." : null, diff --git a/src/Elastic.Markdown/Myst/Components/ApplicableToComponent.cshtml b/src/Elastic.Markdown/Myst/Components/ApplicableToComponent.cshtml index f35d221e9..a4f7aa3d6 100644 --- a/src/Elastic.Markdown/Myst/Components/ApplicableToComponent.cshtml +++ b/src/Elastic.Markdown/Myst/Components/ApplicableToComponent.cshtml @@ -12,7 +12,7 @@ @if (item.RenderData.ShowLifecycleName) { - @item.Applicability.GetLifeCycleName() + @item.PrimaryApplicability.GetLifeCycleName() } @if (item.RenderData.ShowVersion) { @@ -24,6 +24,14 @@ { @item.RenderData.BadgeLifecycleText } + @if (item.RenderData.HasMultipleLifecycles) + { + + + + + + } } diff --git a/src/Elastic.Markdown/Myst/Components/ApplicableToViewModel.cs b/src/Elastic.Markdown/Myst/Components/ApplicableToViewModel.cs index 773bf03aa..11e50c37a 100644 --- a/src/Elastic.Markdown/Myst/Components/ApplicableToViewModel.cs +++ b/src/Elastic.Markdown/Myst/Components/ApplicableToViewModel.cs @@ -2,6 +2,7 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using Elastic.Documentation; using Elastic.Documentation.AppliesTo; using Elastic.Documentation.Configuration.Versions; @@ -15,7 +16,6 @@ public class ApplicableToViewModel public required ApplicableTo AppliesTo { get; init; } public required VersionsConfiguration VersionsConfig { get; init; } - // Dictionary mapping property selectors to their applicability definitions private static readonly Dictionary, ApplicabilityMappings.ApplicabilityDefinition> DeploymentMappings = new() { [d => d.Ess] = ApplicabilityMappings.Ech, @@ -56,15 +56,14 @@ public class ApplicableToViewModel [p => p.ApmAgentRum] = ApplicabilityMappings.ApmAgentRum }; + public IEnumerable GetApplicabilityItems() { var items = new List(); - // Process Stack if (AppliesTo.Stack is not null) items.AddRange(ProcessSingleCollection(AppliesTo.Stack, ApplicabilityMappings.Stack)); - // Process Serverless if (AppliesTo.Serverless is not null) { items.AddRange(AppliesTo.Serverless.AllProjects is not null @@ -72,24 +71,18 @@ public IEnumerable GetApplicabilityItems() : ProcessMappedCollections(AppliesTo.Serverless, ServerlessMappings)); } - // Process Deployment if (AppliesTo.Deployment is not null) items.AddRange(ProcessMappedCollections(AppliesTo.Deployment, DeploymentMappings)); - // Process Product Applicability if (AppliesTo.ProductApplicability is not null) items.AddRange(ProcessMappedCollections(AppliesTo.ProductApplicability, ProductMappings)); - // Process Generic Product if (AppliesTo.Product is not null) items.AddRange(ProcessSingleCollection(AppliesTo.Product, ApplicabilityMappings.Product)); - return items; + return CombineItemsByKey(items); } - /// - /// Processes a single collection with its corresponding applicability definition - /// private IEnumerable ProcessSingleCollection(AppliesCollection collection, ApplicabilityMappings.ApplicabilityDefinition applicabilityDefinition) { var versioningSystem = VersionsConfig.GetVersioningSystem(applicabilityDefinition.VersioningSystemId); @@ -97,7 +90,7 @@ private IEnumerable ProcessSingleCollection(AppliesCollection } /// - /// Processes multiple collections using a mapping dictionary to eliminate repetitive code + /// Uses mapping dictionary to eliminate repetitive code when processing multiple collections /// private IEnumerable ProcessMappedCollections(T source, Dictionary, ApplicabilityMappings.ApplicabilityDefinition> mappings) { @@ -127,10 +120,45 @@ private IEnumerable ProcessApplicabilityCollection( return new ApplicabilityItem( Key: applicabilityDefinition.Key, - Applicability: applicability, - RenderData: renderData + PrimaryApplicability: applicability, + RenderData: renderData, + ApplicabilityDefinition: applicabilityDefinition ); }); + /// + /// Combines multiple applicability items with the same key into a single item with combined tooltip + /// + private IEnumerable CombineItemsByKey(List items) => items + .GroupBy(item => item.Key) + .Select(group => + { + if (group.Count() == 1) + return group.First(); + + var firstItem = group.First(); + var allApplicabilities = group.Select(g => g.Applicability).ToList(); + var applicabilityDefinition = firstItem.ApplicabilityDefinition; + var versioningSystem = VersionsConfig.GetVersioningSystem(applicabilityDefinition.VersioningSystemId); + + var combinedRenderData = _applicabilityRenderer.RenderCombinedApplicability( + allApplicabilities, + applicabilityDefinition, + versioningSystem, + new AppliesCollection(allApplicabilities.ToArray())); + + // Select the closest version to current as the primary display + var primaryApplicability = ApplicabilitySelector.GetPrimaryApplicability(allApplicabilities, versioningSystem.Current); + + return new ApplicabilityItem( + Key: firstItem.Key, + PrimaryApplicability: primaryApplicability, + RenderData: combinedRenderData, + ApplicabilityDefinition: applicabilityDefinition + ); + }); + + + } diff --git a/tests/authoring/Applicability/ApplicableToComponent.fs b/tests/authoring/Applicability/ApplicableToComponent.fs index fc6c07223..515e632d2 100644 --- a/tests/authoring/Applicability/ApplicableToComponent.fs +++ b/tests/authoring/Applicability/ApplicableToComponent.fs @@ -396,7 +396,7 @@ This functionality may be changed or removed in a future release. Elastic will w type ``mixed lifecycles with ga planned`` () = static let markdown = Setup.Markdown """ ```{applies_to} -stack: ga 8.8.0, preview 9.0.0 +stack: ga 8.8.0, preview 8.1.0 ``` """ @@ -404,22 +404,22 @@ stack: ga 8.8.0, preview 9.0.0 let ``renders GA planned when preview exists alongside GA`` () = markdown |> convertsToHtml """

- - Stack - - - Planned - - - +

Elastic Stack Preview 8.1.0:We plan to add this functionality in a future Elastic Stack update. Subject to change. + +This functionality may be changed or removed in a future release. Elastic will work to fix any issues, but features in technical preview are not subject to the support SLA of official GA features.
"> Stack GA planned + + + + +

@@ -799,3 +799,39 @@ If this functionality is unavailable or behaves differently when deployed on ECH

""" + +// Test multiple lifecycles for same applicability key +type ``multiple lifecycles same key`` () = + static let markdown = Setup.Markdown """ +```{applies_to} +stack: ga 8.0.0, beta 8.1.0 +``` +""" + + [] + let ``renders multiple lifecycles with ellipsis and shows GA lifecycle`` () = + markdown |> convertsToHtml """ +

+ + Stack + + + GA + + 8.0.0 + + + + + + + + +

+""" diff --git a/tests/authoring/Inline/AppliesToRole.fs b/tests/authoring/Inline/AppliesToRole.fs index 9ecfb8193..2450d5af7 100644 --- a/tests/authoring/Inline/AppliesToRole.fs +++ b/tests/authoring/Inline/AppliesToRole.fs @@ -137,26 +137,23 @@ This is an inline {applies_to}`stack: preview 9.0, ga 9.1` element. )) [] - let ``validate HTML: generates link and alt attr`` () = + let ``validate HTML: generates single combined badge`` () = markdown |> convertsToHtml """

This is an inline - - Stack - - - GA planned - - - Stack - - Planned + + GA planned