Browse Source

Apps page improvements. (#1235)

* Apps page improvements.

* Fix tests

* Fix e2e
pull/1236/head
Sebastian Stehle 11 months ago
committed by GitHub
parent
commit
864f7fa8a0
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 3
      backend/i18n/frontend_en.json
  2. 3
      backend/i18n/frontend_fr.json
  3. 3
      backend/i18n/frontend_it.json
  4. 3
      backend/i18n/frontend_nl.json
  5. 3
      backend/i18n/frontend_pt.json
  6. 3
      backend/i18n/frontend_zh.json
  7. 3
      backend/i18n/source/frontend_en.json
  8. 8
      backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Template.cs
  9. 115
      backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/TemplatesClient.cs
  10. 10
      backend/src/Squidex/Areas/Api/Controllers/Templates/Models/TemplateDto.cs
  11. 5
      backend/src/Squidex/Areas/Api/Controllers/Templates/TemplatesController.cs
  12. 38
      backend/src/Squidex/wwwroot/images/add-blog.svg
  13. 26
      backend/src/Squidex/wwwroot/images/add-profile.svg
  14. 2
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Templates/TemplatesClientTests.cs
  15. 4
      frontend/src/app/features/apps/pages/app.component.html
  16. 73
      frontend/src/app/features/apps/pages/app.component.scss
  17. 98
      frontend/src/app/features/apps/pages/apps-page.component.html
  18. 88
      frontend/src/app/features/apps/pages/apps-page.component.scss
  19. 11
      frontend/src/app/features/apps/pages/apps-page.component.ts
  20. 31
      frontend/src/app/features/settings/pages/templates/template.component.html
  21. 17
      frontend/src/app/features/settings/pages/templates/template.component.scss
  22. 65
      frontend/src/app/features/settings/pages/templates/template.component.ts
  23. 37
      frontend/src/app/features/settings/pages/templates/templates-page.component.html
  24. 0
      frontend/src/app/features/settings/pages/templates/templates-page.component.scss
  25. 52
      frontend/src/app/features/settings/pages/templates/templates-page.component.ts
  26. 14
      frontend/src/app/features/settings/routes.ts
  27. 4
      frontend/src/app/features/settings/settings-menu.component.html
  28. 80
      frontend/src/app/shared/components/app-form.component.html
  29. 31
      frontend/src/app/shared/components/app-form.component.scss
  30. 10
      frontend/src/app/shared/components/app-form.component.ts
  31. 12
      frontend/src/app/shared/model/generated.ts
  32. 4
      frontend/src/app/shared/services/templates.service.spec.ts
  33. 2
      frontend/src/app/shared/services/templates.service.ts
  34. 16
      frontend/src/app/shared/state/template.state.spec.ts
  35. 3
      frontend/src/app/shared/state/templates.state.ts
  36. 6
      frontend/src/app/shell/pages/internal/apps-menu.component.html
  37. 5
      frontend/src/app/shell/pages/internal/apps-menu.component.ts
  38. 2
      frontend/src/app/theme/_bootstrap-vars.scss
  39. 4
      frontend/src/app/theme/_common.scss
  40. 11
      frontend/src/app/theme/_forms.scss
  41. 13
      tools/e2e/tests/pages/apps.ts

3
backend/i18n/frontend_en.json

@ -10,6 +10,7 @@
"api.title": "API",
"apps.allApps": "All Apps",
"apps.allTeams": "All Teams",
"apps.appExplanation": "In Squidex, all your content and settings are organized within an app - a workspace similar to a project where your data lives",
"apps.appLoadFailed": "Failed to load app. Please reload.",
"apps.appNameHint": "You can only use letters, numbers and dashes and not more than 40 characters.",
"apps.appNameValidationMessage": "Name can contain lower case letters (a-z), numbers and dashes between.",
@ -42,6 +43,8 @@
"apps.loadSettingsFailed": "Failed to update UI settings. Please reload.",
"apps.removeImage": "Remove image",
"apps.removeImageFailed": "Failed to remove app image. Please reload.",
"apps.selectAppTemplate": "Select a Template",
"apps.template": "Template",
"apps.transfer": "Transfer",
"apps.transferFailed": "Failed to transfer the app. Please reload.",
"apps.transferTitle": "Transfer to team",

3
backend/i18n/frontend_fr.json

@ -10,6 +10,7 @@
"api.title": "API",
"apps.allApps": "Toutes les applications",
"apps.allTeams": "Toutes les équipes",
"apps.appExplanation": "In Squidex, all your content and settings are organized within an app - a workspace similar to a project where your data lives",
"apps.appLoadFailed": "Échec du chargement de l'application. Veuillez recharger.",
"apps.appNameHint": "Vous ne pouvez utiliser que des lettres, des chiffres et des tirets et pas plus de 40 caractères.",
"apps.appNameValidationMessage": "Le nom peut contenir des lettres minuscules (az), des chiffres et des tirets entre.",
@ -42,6 +43,8 @@
"apps.loadSettingsFailed": "Échec de la mise à jour des paramètres de l'interface utilisateur. Veuillez recharger.",
"apps.removeImage": "Supprimer l'image",
"apps.removeImageFailed": "Échec de la suppression de l'image de l'application. Veuillez recharger.",
"apps.selectAppTemplate": "Select a Template",
"apps.template": "Template",
"apps.transfer": "Transfert",
"apps.transferFailed": "Échec du transfert de l'application. Veuillez recharger.",
"apps.transferTitle": "Transférer à l'équipe",

3
backend/i18n/frontend_it.json

@ -10,6 +10,7 @@
"api.title": "API",
"apps.allApps": "Tutte le Apps",
"apps.allTeams": "All Teams",
"apps.appExplanation": "In Squidex, all your content and settings are organized within an app - a workspace similar to a project where your data lives",
"apps.appLoadFailed": "Non è stato possibile caricare l'App. Per favore ricarica.",
"apps.appNameHint": "Puoi utilizzare solo lettere, numeri e trattini e non più di 40 caratteri.",
"apps.appNameValidationMessage": "Il nome può contenere lettere minuscole (a-z), numeri e trattini all'interno.",
@ -42,6 +43,8 @@
"apps.loadSettingsFailed": "Failed to update UI settings. Please reload.",
"apps.removeImage": "Rimuovi l'immagine",
"apps.removeImageFailed": "Non è stato possibile rimuovere l'immagine dell'app. Per favore ricarica.",
"apps.selectAppTemplate": "Select a Template",
"apps.template": "Template",
"apps.transfer": "Transfer",
"apps.transferFailed": "Failed to transfer the app. Please reload.",
"apps.transferTitle": "Transfer to team",

3
backend/i18n/frontend_nl.json

@ -10,6 +10,7 @@
"api.title": "API",
"apps.allApps": "Alle apps",
"apps.allTeams": "All Teams",
"apps.appExplanation": "In Squidex, all your content and settings are organized within an app - a workspace similar to a project where your data lives",
"apps.appLoadFailed": "Kan app niet laden. Laad opnieuw.",
"apps.appNameHint": "Je kunt alleen letters, cijfers en streepjes gebruiken en niet meer dan 40 tekens.",
"apps.appNameValidationMessage": "Naam mag kleine letters (a-z), cijfers en streepjes tussen bevatten.",
@ -42,6 +43,8 @@
"apps.loadSettingsFailed": "Failed to update UI settings. Please reload.",
"apps.removeImage": "Afbeelding verwijderen",
"apps.removeImageFailed": "Verwijderen van app-afbeelding is mislukt. Laad opnieuw.",
"apps.selectAppTemplate": "Select a Template",
"apps.template": "Template",
"apps.transfer": "Transfer",
"apps.transferFailed": "Failed to transfer the app. Please reload.",
"apps.transferTitle": "Transfer to team",

3
backend/i18n/frontend_pt.json

@ -10,6 +10,7 @@
"api.title": "API",
"apps.allApps": "Todas as aplicações",
"apps.allTeams": "Todas as Equipas",
"apps.appExplanation": "In Squidex, all your content and settings are organized within an app - a workspace similar to a project where your data lives",
"apps.appLoadFailed": "Falha na aplicação. Por favor, recarregue.",
"apps.appNameHint": "Só pode utilizar letras, números e traços e não mais de 40 caracteres.",
"apps.appNameValidationMessage": "O nome pode conter letras minúsculas (a-z), números e traços entre.",
@ -42,6 +43,8 @@
"apps.loadSettingsFailed": "Falhou na atualização das definições de UI. Por favor, recarregue.",
"apps.removeImage": "Remover imagem",
"apps.removeImageFailed": "Falhou na remoção da imagem da aplicação. Por favor, recarregue.",
"apps.selectAppTemplate": "Select a Template",
"apps.template": "Template",
"apps.transfer": "Transferência",
"apps.transferFailed": "Falhou na transferência da aplicação. Por favor recarregue.",
"apps.transferTitle": "Transferência para a equipa",

3
backend/i18n/frontend_zh.json

@ -10,6 +10,7 @@
"api.title": "API",
"apps.allApps": "所有应用程序",
"apps.allTeams": "All Teams",
"apps.appExplanation": "In Squidex, all your content and settings are organized within an app - a workspace similar to a project where your data lives",
"apps.appLoadFailed": "加载应用失败。请重新加载。",
"apps.appNameHint": "您只能使用字母、数字和破折号,并且不能超过 40 个字符。",
"apps.appNameValidationMessage": "名称可以包含小写字母 (a-z)、数字和破折号。",
@ -42,6 +43,8 @@
"apps.loadSettingsFailed": "更新界面设置失败。请重新加载。",
"apps.removeImage": "删除图片",
"apps.removeImageFailed": "删除应用图片失败。请重新加载。",
"apps.selectAppTemplate": "Select a Template",
"apps.template": "Template",
"apps.transfer": "Transfer",
"apps.transferFailed": "Failed to transfer the app. Please reload.",
"apps.transferTitle": "Transfer to team",

3
backend/i18n/source/frontend_en.json

@ -10,6 +10,7 @@
"api.title": "API",
"apps.allApps": "All Apps",
"apps.allTeams": "All Teams",
"apps.appExplanation": "In Squidex, all your content and settings are organized within an app - a workspace similar to a project where your data lives",
"apps.appLoadFailed": "Failed to load app. Please reload.",
"apps.appNameHint": "You can only use letters, numbers and dashes and not more than 40 characters.",
"apps.appNameValidationMessage": "Name can contain lower case letters (a-z), numbers and dashes between.",
@ -42,6 +43,8 @@
"apps.loadSettingsFailed": "Failed to update UI settings. Please reload.",
"apps.removeImage": "Remove image",
"apps.removeImageFailed": "Failed to remove app image. Please reload.",
"apps.selectAppTemplate": "Select a Template",
"apps.template": "Template",
"apps.transfer": "Transfer",
"apps.transferFailed": "Failed to transfer the app. Please reload.",
"apps.transferTitle": "Transfer to team",

8
backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Template.cs

@ -9,4 +9,10 @@
namespace Squidex.Domain.Apps.Entities.Apps.Templates;
public sealed record Template(string Name, string Title, string Description, bool IsStarter);
public sealed record Template(
string Name,
string Title,
string Description,
string Details,
bool IsStarter,
string? Logo);

115
backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/TemplatesClient.cs

@ -5,8 +5,14 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.CodeDom;
using System.Text.RegularExpressions;
using Markdig;
using Markdig.Renderers.Normalize;
using Markdig.Syntax;
using Markdig.Syntax.Inlines;
using Microsoft.Extensions.Options;
using Squidex.ClientLibrary;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Apps.Templates;
@ -41,7 +47,7 @@ public sealed partial class TemplatesClient(IHttpClientFactory httpClientFactory
return null;
}
public async Task<List<Template>> GetTemplatesAsync(
public async Task<List<Template>> GetTemplatesAsync(bool includeDetails = false,
CancellationToken ct = default)
{
var httpClient = httpClientFactory.CreateClient();
@ -54,15 +60,27 @@ public sealed partial class TemplatesClient(IHttpClientFactory httpClientFactory
var text = await httpClient.GetStringAsync(url, ct);
foreach (Match match in RegexTemplate.Matches(text).OfType<Match>())
foreach (var match in RegexTemplate.Matches(text).OfType<Match>())
{
var title = match.Groups["Title"].Value;
var templateName = match.Groups["Name"].Value;
var templateTitle = match.Groups["Title"].Value;
const string StarterPrefix = "Starter ";
var isStarter = templateTitle.StartsWith(StarterPrefix, StringComparison.OrdinalIgnoreCase);
if (isStarter)
{
templateTitle = templateTitle[StarterPrefix.Length..].TrimStart(' ', ':');
}
var (details, logo) = await GetDetailCoreAsync(templateName, ct);
result.Add(new Template(
match.Groups["Name"].Value,
title,
templateName,
templateTitle,
match.Groups["Description"].Value,
title.StartsWith("Starter ", StringComparison.OrdinalIgnoreCase)));
GetSummary(details, includeDetails),
isStarter,
logo));
}
}
@ -74,23 +92,100 @@ public sealed partial class TemplatesClient(IHttpClientFactory httpClientFactory
{
Guard.NotNullOrEmpty(name);
var (text, _) = await GetDetailCoreAsync(name, ct);
return text;
}
private async Task<(string? Text, string? Logo)> GetDetailCoreAsync(string name,
CancellationToken ct = default)
{
var httpClient = httpClientFactory.CreateClient();
foreach (var repository in options.Repositories.OrEmpty())
{
var url = $"{repository.ContentUrl}/{name}/README.md";
var url = new Uri($"{repository.ContentUrl}/{name}/README.md", UriKind.Absolute);
var response = await httpClient.GetAsync(url, ct);
if (response.IsSuccessStatusCode)
{
return await response.Content.ReadAsStringAsync(ct);
var text = await response.Content.ReadAsStringAsync(ct);
string? logo = null;
text = BuildLogoRegex().Replace(text, match =>
{
var imageRelative = new Uri(match.Groups["Url"].Value, UriKind.Relative);
var imageAbsolute = new Uri(url, imageRelative);
logo = imageAbsolute.ToString();
return string.Empty;
});
return (text, logo);
}
}
return null;
return default;
}
private static string GetSummary(string? markdown, bool includeDetails)
{
if (string.IsNullOrWhiteSpace(markdown) || !includeDetails)
{
return string.Empty;
}
var document = Markdown.Parse(markdown);
var outputWriter = new StringWriter();
var outputRenderer = new NormalizeRenderer(outputWriter);
var headerCount = 0;
foreach (var block in document)
{
if (block is HeadingBlock heading || IsUsageBlock(block))
{
headerCount++;
if (headerCount == 2)
{
break;
}
}
else if (headerCount == 1)
{
outputRenderer.Render(block);
}
}
static bool IsUsageBlock(Block block)
{
return block is ParagraphBlock p && IsUsageLiteral(p.Inline);
}
static bool IsUsageLiteral(Inline? inline)
{
if (inline is LiteralInline literal)
{
return literal.Content.AsSpan().Trim().Equals("Usage", StringComparison.Ordinal);
}
if (inline is ContainerInline container)
{
foreach (var child in container)
{
if (IsUsageLiteral(child))
{
return true;
}
}
}
return false;
}
return outputWriter.ToString();
}
[GeneratedRegex("\\* \\[(?<Title>.*)\\]\\((?<Name>.*)\\/README\\.md\\): (?<Description>.*)", RegexOptions.ExplicitCapture | RegexOptions.Compiled)]
private static partial Regex BuildTemplateRegex();
[GeneratedRegex("Logo: \\[Logo\\]\\((?<Url>(.*))\\)", RegexOptions.ExplicitCapture | RegexOptions.Compiled)]
private static partial Regex BuildLogoRegex();
}

10
backend/src/Squidex/Areas/Api/Controllers/Templates/Models/TemplateDto.cs

@ -28,11 +28,21 @@ public sealed class TemplateDto : Resource
/// </summary>
public string Description { get; set; }
/// <summary>
/// The details of the template.
/// </summary>
public string Details { get; set; }
/// <summary>
/// True, if the template is a starter.
/// </summary>
public bool IsStarter { get; set; }
/// <summary>
/// The optional logo.
/// </summary>
public string? Logo { get; set; }
public static TemplateDto FromDomain(Template template, Resources resources)
{
var result = SimpleMapper.Map(template, new TemplateDto());

5
backend/src/Squidex/Areas/Api/Controllers/Templates/TemplatesController.cs

@ -22,14 +22,15 @@ public sealed class TemplatesController(ICommandBus commandBus, TemplatesClient
/// <summary>
/// Get all templates.
/// </summary>
/// <param name="includeDetails">Also include the details.</param>
/// <response code="200">Templates returned.</response>
[HttpGet]
[Route("templates/")]
[ProducesResponseType(typeof(TemplatesDto), StatusCodes.Status200OK)]
[ApiPermission]
public async Task<IActionResult> GetTemplates()
public async Task<IActionResult> GetTemplates([FromQuery] bool includeDetails)
{
var templates = await templatesClient.GetTemplatesAsync(HttpContext.RequestAborted);
var templates = await templatesClient.GetTemplatesAsync(includeDetails, HttpContext.RequestAborted);
var response = TemplatesDto.FromDomain(templates, Resources);

38
backend/src/Squidex/wwwroot/images/add-blog.svg

@ -0,0 +1,38 @@
<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" x="0" y="0" version="1.1" viewBox="0 0 64 64" xml:space="preserve">
<style id="style8137" type="text/css">
.st15{fill:#fff}
</style>
<style id="style8137-2" type="text/css">
.st15{fill:#fff}
</style>
<style id="style8137-0" type="text/css">
.st15{fill:#fff}
</style>
<g id="g4726" transform="translate(-67.875 31.5)">
<g id="g8143-8" transform="translate(67.875 -31.5)">
<path id="path8145-0" fill="#b4bcc1" d="M11.8 16c.2.4.2.9.2 1.3V19h1v-1.6c0-.7-.1-1.4-.4-2l-.1-3.1c-.4-1.2-.3-2.5.4-3.5.6-1 1.6-1.6 2.7-1.7H23.2c2.4 0 4.6.9 6.3 2.6l4.7 4.7c2.1 2.1 4.8 3.2 7.7 3.2H50v-2h-8.1c-2.4 0-4.6-.9-6.3-2.6l-4.7-4.7C28.8 6.1 26.1 5 23.2 5h-7.8c-1.7.2-3.2 1.1-4.2 2.6-1 1.6-1.2 3.5-.6 5.3l1.2 3.1z"/>
</g>
<g id="g8147-3" transform="translate(67.875 -31.5)">
<path id="path8149-6" fill="#d7d7f9" d="M3 24v-4.5C3 15.9 5.9 13 9.5 13h12.8c1.7 0 3.4.7 4.6 1.9l7.2 9.2c1.2 1.2 2.9 1.9 4.6 1.9h7.8C55.1 26 61 30.9 61 39.5v15c0 3.6-3.9 6.5-7.5 6.5h-44C5.9 61 3 58.1 3 54.5V24z"/>
<path id="path8151-3" fill="#2e3842" d="M53.5 62h-44C5.4 62 2 58.6 2 54.5v-35C2 15.4 5.4 12 9.5 12h12.8c2 0 3.9.8 5.3 2.2l.1.1 7.2 9.2c1 1 2.4 1.6 3.8 1.6h7.8C55.9 25 62 30.7 62 39.5v15c0 4.3-4.5 7.5-8.5 7.5zm-44-48c-3 0-5.5 2.5-5.5 5.5v35.1c0 3 2.5 5.5 5.5 5.5h44.1c2.9 0 6.5-2.4 6.5-5.5v-15C60 31.8 54.8 27 46.5 27h-7.8c-2 0-3.9-.8-5.3-2.2l-.1-.1-7.2-9.2c-1-1-2.4-1.6-3.8-1.6L9.633 14z"/>
</g>
<g id="g8153" transform="translate(67.875 -31.5)">
<path id="path8155" fill="#ededf9" d="M53 60H10c-3.8 0-6-2.2-6-6V20c0-3.8 2.2-6 6-6h12c2 0 3.6 1.2 4.7 2.3l.1.1 7 8.9c.6.5 2.1 1.7 4.5 1.5h7.3C54.7 26.9 60 31.3 60 39v15c0 3.6-2.8 6-7 6zM10 16c-2.7 0-4 1.3-4 4v34c0 2.7 1.3 4 4 4h43c2.3 0 5-1 5-4V39c0-8.8-7.9-10.1-12.5-10.1h-7.2c-2.2.1-4.4-.6-6-2.1l-.1-.1-7-8.9C24 16.5 23.1 16 22 16H10z"/>
</g>
<g id="g8197-0" transform="translate(67.875 -31.5)">
<circle id="circle8199-9" cx="51.9" cy="12.1" r="11.1" fill="#3389ff"/>
<path id="path8201-7" fill="#fff" d="M51.9 24.2c-6.7 0-12.1-5.4-12.1-12.1C39.8 5.4 45.2 0 51.9 0 58.6 0 64 5.4 64 12.1c0 6.7-5.4 12.1-12.1 12.1zm0-22.2c-5.6 0-10.1 4.5-10.1 10.1s4.5 10.1 10.1 10.1S62 17.7 62 12.1 57.5 2 51.9 2z" class="st15"/>
</g>
<g id="g8203-4" transform="translate(67.875 -31.5)">
<path id="path8205-6" fill="#fff" d="M57 13H47c-.6 0-1-.4-1-1s.4-1 1-1h10c.6 0 1 .4 1 1s-.4 1-1 1z" class="st15"/>
</g>
<g id="g8207-9" transform="translate(67.875 -31.5)">
<path id="path8209-3" fill="#fff" d="M52 18c-.6 0-1-.4-1-1V7c0-.6.4-1 1-1s1 .4 1 1v10c0 .6-.4 1-1 1z" class="st15"/>
</g>
<g id="g4685" stroke="#b5b5ea" stroke-opacity="1" transform="matrix(.77563 0 0 .7719 73.711 -21.893)">
<path id="path4174" fill="#b5b5ea" fill-opacity="1" stroke-width=".082" d="M12.888 47.387c-.96 0-1.775.34-2.447 1.018-.672.68-1.007 1.504-1.007 2.475 0 .97.335 1.795 1.007 2.474a3.313 3.313 0 002.447 1.019c.96 0 1.774-.34 2.446-1.019.672-.679 1.007-1.504 1.007-2.474 0-.97-.335-1.796-1.007-2.475a3.312 3.312 0 00-2.446-1.018z"/>
<path id="path4356" fill="none" fill-rule="evenodd" stroke-dasharray="none" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="4" stroke-width="3.877" d="M10.506 31.413C33.49 31.538 32.491 53.4 32.491 53.4"/>
<path id="path4356-4" fill="none" fill-rule="evenodd" stroke-dasharray="none" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="4" stroke-width="3.877" d="M10.344 39.427c14.616.08 13.982 13.986 13.982 13.986"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

26
backend/src/Squidex/wwwroot/images/add-profile.svg

@ -0,0 +1,26 @@
<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" x="0" y="0" version="1.1" viewBox="0 0 64 64" xml:space="preserve">
<style id="style2" type="text/css">
.st7{fill:#fff}
</style>
<g id="g8143">
<path id="path8145" fill="#b4bcc1" d="M11.8 16c.2.4.2.9.2 1.3V19h1v-1.6c0-.7-.1-1.4-.4-2l-.1-3.1c-.4-1.2-.3-2.5.4-3.5.6-1 1.6-1.6 2.7-1.7H23.2c2.4 0 4.6.9 6.3 2.6l4.7 4.7c2.1 2.1 4.8 3.2 7.7 3.2H50v-2h-8.1c-2.4 0-4.6-.9-6.3-2.6l-4.7-4.7C28.8 6.1 26.1 5 23.2 5h-7.8c-1.7.2-3.2 1.1-4.2 2.6-1 1.6-1.2 3.5-.6 5.3l1.2 3.1z"/>
</g>
<g id="g8147">
<path id="path8149" fill="#d7d7f9" d="M3 24v-4.5C3 15.9 5.9 13 9.5 13h12.8c1.7 0 3.4.7 4.6 1.9l7.2 9.2c1.2 1.2 2.9 1.9 4.6 1.9h7.8C55.1 26 61 30.9 61 39.5v15c0 3.6-3.9 6.5-7.5 6.5h-44C5.9 61 3 58.1 3 54.5V24z"/>
<path id="path8151" fill="#2e3842" d="M53.5 62h-44C5.4 62 2 58.6 2 54.5v-35C2 15.4 5.4 12 9.5 12h12.8c2 0 3.9.8 5.3 2.2l.1.1 7.2 9.2c1 1 2.4 1.6 3.8 1.6h7.8C55.9 25 62 30.7 62 39.5v15c0 4.3-4.5 7.5-8.5 7.5zm-44-48c-3 0-5.5 2.5-5.5 5.5v35.1c0 3 2.5 5.5 5.5 5.5h44.1c2.9 0 6.5-2.4 6.5-5.5v-15C60 31.8 54.8 27 46.5 27h-7.8c-2 0-3.9-.8-5.3-2.2l-.1-.1-7.2-9.2c-1-1-2.4-1.6-3.8-1.6H9.5v.1z"/>
</g>
<g id="g8153">
<path id="path8155" fill="#ededf9" d="M53 60H10c-3.8 0-6-2.2-6-6V20c0-3.8 2.2-6 6-6h12c2 0 3.6 1.2 4.7 2.3l.1.1 7 8.9c.6.5 2.1 1.7 4.5 1.5h7.3C54.7 26.9 60 31.3 60 39v15c0 3.6-2.8 6-7 6zM10 16c-2.7 0-4 1.3-4 4v34c0 2.7 1.3 4 4 4h43c2.3 0 5-1 5-4V39c0-8.8-7.9-10.1-12.5-10.1h-7.2c-2.2.1-4.4-.6-6-2.1l-.1-.1-7-8.9C24 16.5 23.1 16 22 16H10z"/>
</g>
<g id="g8197">
<circle id="circle8199" cx="51.9" cy="12.1" r="11.1" fill="#3389ff"/>
<path id="path8201" d="M51.9 24.2c-6.7 0-12.1-5.4-12.1-12.1S45.2 0 51.9 0 64 5.4 64 12.1s-5.4 12.1-12.1 12.1zm0-22.2c-5.6 0-10.1 4.5-10.1 10.1s4.5 10.1 10.1 10.1S62 17.7 62 12.1 57.5 2 51.9 2z" class="st7"/>
</g>
<g id="g8203">
<path id="path8205" d="M57 13H47c-.6 0-1-.4-1-1s.4-1 1-1h10c.6 0 1 .4 1 1s-.4 1-1 1z" class="st7"/>
</g>
<g id="g8207">
<path id="path8209" d="M52 18c-.6 0-1-.4-1-1V7c0-.6.4-1 1-1s1 .4 1 1v10c0 .6-.4 1-1 1z" class="st7"/>
</g>
<path id="path36" fill="#b5b5ea" fill-opacity="1" stroke="#b5b5ea" stroke-dasharray="none" stroke-miterlimit="4" stroke-opacity="1" stroke-width=".5" d="M21.695 32.767c-3.123 0-5.67 2.547-5.67 5.667 0 1.56.588 3.48 1.55 5.105.466.787 1.04 1.514 1.701 2.065-1.409.179-2.892.223-4.081.759-1.893.852-3.423 2.247-4.087 4.235-.036.226 0 0-.036.214v.012a3.199 3.199 0 003.186 3.19h14.87a3.2 3.2 0 003.19-3.19l-.036-.222c-.66-1.998-2.188-3.394-4.081-4.245-1.19-.534-2.677-.58-4.087-.756.659-.552 1.233-1.277 1.697-2.063.962-1.625 1.551-3.544 1.551-5.105a5.678 5.678 0 00-5.667-5.667zm0 1.423a4.235 4.235 0 014.245 4.245c0 1.175-.519 2.973-1.352 4.381-.833 1.408-1.931 2.342-2.893 2.342-.962 0-2.06-.934-2.893-2.342-.832-1.407-1.354-3.206-1.354-4.38a4.238 4.238 0 014.247-4.246zm0 12.39c2.195 0 4.288.341 5.922 1.076 1.583.712 2.695 1.772 3.236 3.267-.058.93-.78 1.668-1.725 1.668h-14.87c-.94 0-1.662-.736-1.722-1.666.544-1.487 1.659-2.55 3.242-3.263 1.635-.736 3.728-1.082 5.916-1.082z"/>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

2
backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Templates/TemplatesClientTests.cs

@ -45,7 +45,7 @@ public class TemplatesClientTests
var templates = await sut.GetTemplatesAsync();
Assert.NotEmpty(templates);
Assert.Contains(templates, x => x.IsStarter);
Assert.Contains(templates, x => x.IsStarter && !string.IsNullOrWhiteSpace(x.Logo));
}
[Fact]

4
frontend/src/app/features/apps/pages/app.component.html

@ -1,10 +1,10 @@
<div class="card card-href card-app" [routerLink]="['/app', app.name]">
<div class="card-body" sqxTourStep="app">
<div class="row g-0">
<div class="row g-4">
<div class="col-auto card-left"><sqx-avatar [identifier]="app.name" [image]="app.image" /></div>
<div class="col card-right">
<h3 class="card-title">{{ app.displayName }}</h3>
<h3 class="card-title mb-1">{{ app.displayName }}</h3>
<div class="card-text card-links truncate">
<a [routerLink]="['/app', app.name]" sqxStopClick>{{ "common.edit" | sqxTranslate }}</a>

73
frontend/src/app/features/apps/pages/app.component.scss

@ -5,10 +5,73 @@
@include absolute(1rem, 1rem);
}
.card-body {
position: relative;
}
.card {
@include hover-visible('.deeplinks', inline);
&-title {
padding-right: 2rem;
}
&-body {
position: relative;
}
&-right {
overflow: hidden;
}
&-image {
text-align: center;
img {
height: 6rem;
}
}
&-text {
font-size: $font-small;
a {
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
&-title {
@include truncate;
color: $color-title;
margin-bottom: 0;
margin-top: 0;
}
&-template {
.card-body {
min-height: 15.5rem;
}
.card-title {
margin-bottom: .75rem;
margin-top: 1rem;
}
}
&-href {
cursor: pointer;
&:active,
&:focus {
text-decoration: none;
}
&:focus {
outline: none;
}
.card-title {
padding-right: 2rem;
&:hover {
@include box-shadow-outer(0, 3px, 16px, .2);
}
}
}

98
frontend/src/app/features/apps/pages/apps-page.component.html

@ -1,67 +1,65 @@
<sqx-title message="i18n:apps.listPageTitle" />
@if (authState.userChanges | async; as user) {
<div class="panel-container page">
<div class="apps-section">
<h1 class="apps-title">{{ "apps.welcomeTitle" | sqxTranslate: { user: user.displayName } }}</h1>
<div class="subtext">{{ "apps.welcomeSubtitle" | sqxTranslate }}</div>
</div>
@if (groupedApps | async; as groups) {
<div class="apps-section" sqxTourStep="allApps">
@for (group of groups; track trackByGroup($index, group)) {
<div class="team">
@if (group.team) {
<div class="team-header"><sqx-team (leave)="leaveTeam($event)" [team]="group.team" /></div>
}
<div class="team-body" [class.padded]="group.team">
@for (app of group.apps; track app.id) {
<sqx-app [app]="app" (leave)="leaveApp($event)" />
} @empty {
<small class="team-empty"> {{ "teams.empty" | sqxTranslate }} </small>
}
</div>
</div>
} @empty {
<div class="empty">
<h3 class="empty-headline">{{ "apps.empty" | sqxTranslate }}</h3>
</div>
}
</div>
}
@if ((uiState.settings | async)?.canCreateApps) {
@if (authState.userChanges | async; as user) {
<div class="panel-container page flex-grow d-flex flex-grow flex-column justify-content-between" style="flex-grow: 1">
<div class="apps">
<div class="apps-section">
<div class="card card-template card-href" (click)="createNewApp()" data-testid="new-app" sqxTourStep="addApp">
<div class="card-body">
<div class="card-image"><img src="./images/add-app.svg" /></div>
<div class="row align-items-center">
<div class="col">
<h1 class="apps-title">{{ "apps.welcomeTitle" | sqxTranslate: { user: user.displayName } }}</h1>
<h3 class="card-title">{{ "apps.createBlankApp" | sqxTranslate }}</h3>
<sqx-form-hint> {{ "apps.createBlankAppDescription" | sqxTranslate }} </sqx-form-hint>
<div class="subtext">{{ "apps.welcomeSubtitle" | sqxTranslate }}</div>
</div>
@if ((uiState.settings | async)?.canCreateApps) {
<div class="col-auto">
<button class="btn btn-block btn-success" (click)="addAppDialog.show()" data-testid="new-app" sqxTourStep="addApp" type="button">
<i class="icon-plus"></i> {{ "apps.appsButtonCreate" | sqxTranslate }}
</button>
</div>
}
</div>
</div>
@for (template of templates | async; track template) {
<div class="card card-template card-href" (click)="createNewApp(template)">
<div class="card-body">
<div class="card-image"><img src="./images/add-template.svg" /></div>
@if (groupedApps | async; as groups) {
<div class="apps-section" sqxTourStep="allApps">
@for (group of groups; track trackByGroup($index, group)) {
<div class="team">
@if (group.team) {
<div class="team-header"><sqx-team (leave)="leaveTeam($event)" [team]="group.team" /></div>
}
<h3 class="card-title">{{ template.title }}</h3>
<sqx-form-hint> {{ template.description }} </sqx-form-hint>
<div class="team-body row g-2" [class.padded]="group.team">
@for (app of group.apps; track app.id) {
<div class="col-12 col-md-6 col-lg-4">
<sqx-app [app]="app" (leave)="leaveApp($event)" />
</div>
} @empty {
<small class="team-empty"> {{ "teams.empty" | sqxTranslate }} </small>
}
</div>
</div>
</div>
}
</div>
}
} @empty {
<div class="empty">
<h3 class="empty-headline">{{ "apps.empty" | sqxTranslate }}</h3>
</div>
}
</div>
}
</div>
@if (info) {
@if (generalInfo) {
<div class="apps-section">
<small class="info">{{ info }}</small>
<small class="info">{{ generalInfo }}</small>
</div>
}
</div>
}
<sqx-app-form (dialogClose)="addAppDialog.hide()" *sqxModal="addAppDialog" [template]="addAppTemplate" />
<sqx-onboarding-dialog (dialogClose)="onboardingDialog.hide()" *sqxModal="onboardingDialog" />
@if (starters | async; as templates) {
<sqx-app-form (dialogClose)="addAppDialog.hide()" *sqxModal="addAppDialog" [template]="addAppTemplate" [templates]="templates" />
}
<sqx-news-dialog (dialogClose)="newsDialog.hide()" [features]="newsFeatures!" *sqxModal="newsDialog" />

88
frontend/src/app/features/apps/pages/apps-page.component.scss

@ -2,7 +2,15 @@
@import 'vars';
.apps-section {
padding: 2rem 1.25rem 0 $size-sidebar-width + .25rem;
margin: 0 $size-sidebar-width;
max-width: 1080px;
padding-top: 2rem;
padding-left: 16px;
padding-right: 16px;
&:last-child {
margin-bottom: 1rem;
}
}
.page {
@ -11,84 +19,6 @@
overflow-y: auto;
}
:host ::ng-deep {
.card {
@include hover-visible('.deeplinks', inline);
display: inline-block;
margin-bottom: 1rem;
margin-right: 1rem;
vertical-align: top;
width: 20rem;
&-links {
margin-top: .5rem;
}
&-left {
padding-right: .75rem;
}
&-right {
overflow: hidden;
}
&-image {
text-align: center;
img {
height: 6rem;
}
}
&-text {
font-size: $font-small;
a {
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
&-title {
@include truncate;
color: $color-title;
margin-bottom: 0;
margin-top: 0;
}
&-template {
.card-body {
min-height: 15.5rem;
}
.card-title {
margin-bottom: .75rem;
margin-top: 1rem;
}
}
&-href {
cursor: pointer;
&:active,
&:focus {
text-decoration: none;
}
&:focus {
outline: none;
}
&:hover {
@include box-shadow-outer(0, 3px, 16px, .2);
}
}
}
}
.team {
margin-top: 1rem;

11
frontend/src/app/features/apps/pages/apps-page.component.ts

@ -9,7 +9,7 @@ import { AsyncPipe } from '@angular/common';
import { Component, OnInit } from '@angular/core';
import { combineLatest } from 'rxjs';
import { map, take } from 'rxjs/operators';
import { AppDto, AppFormComponent, AppsState, AuthService, DialogModel, FeatureDto, FormHintComponent, LocalStoreService, ModalDirective, NewsService, Settings, TeamDto, TeamsState, TemplateDto, TemplatesState, TitleComponent, TourState, TourStepDirective, TranslatePipe, UIOptions, UIState } from '@app/shared';
import { AppDto, AppFormComponent, AppsState, AuthService, DialogModel, FeatureDto, LocalStoreService, ModalDirective, NewsService, Settings, TeamDto, TeamsState, TemplateDto, TemplatesState, TitleComponent, TourState, TourStepDirective, TranslatePipe, UIOptions, UIState } from '@app/shared';
import { AppComponent } from './app.component';
import { NewsDialogComponent } from './news-dialog.component';
import { OnboardingDialogComponent } from './onboarding-dialog.component';
@ -25,7 +25,6 @@ type GroupedApps = { team?: TeamDto; apps: AppDto[] };
AppComponent,
AppFormComponent,
AsyncPipe,
FormHintComponent,
ModalDirective,
NewsDialogComponent,
OnboardingDialogComponent,
@ -44,11 +43,9 @@ export class AppsPageComponent implements OnInit {
public newsFeatures?: ReadonlyArray<FeatureDto>;
public newsDialog = new DialogModel();
public info = '';
public generalInfo = '';
public templates =
this.templatesState.templates.pipe(
map(x => x.filter(t => t.isStarter)));
public starters = this.templatesState.starters;
public groupedApps =
combineLatest([
@ -86,7 +83,7 @@ export class AppsPageComponent implements OnInit {
private readonly uiOptions: UIOptions,
) {
if (uiOptions.value.showInfo) {
this.info = uiOptions.value.info;
this.generalInfo = uiOptions.value.info;
}
}

31
frontend/src/app/features/settings/pages/templates/template.component.html

@ -1,31 +0,0 @@
<div class="table-items-row table-items-row-expandable">
<div class="table-items-row-summary row gx-2 align-items-center">
<div class="col">
{{ template.title }} <sqx-form-hint class="truncate">{{ template.description }}</sqx-form-hint>
</div>
<div class="col-auto">
<div class="float-end">
<button class="btn btn-outline-secondary btn-expand me-1" [class.expanded]="isExpanded" (click)="toggleExpanded()" type="button">
<i class="icon-download"></i>
</button>
</div>
</div>
</div>
@if (isExpanded) {
<div class="table-items-row-details">
<div class="table-items-row-details-tabs clearfix">
<h4>{{ "common.details" | sqxTranslate }}</h4>
</div>
<div class="table-items-row-details-tab">
@if (details | async; as loadedDetails) {
<div class="help" [sqxMarkdown]="loadedDetails"></div>
} @else {
<sqx-loader />
}
</div>
</div>
}
</div>

17
frontend/src/app/features/settings/pages/templates/template.component.scss

@ -1,17 +0,0 @@
@import 'mixins';
@import 'vars';
h4 {
font-size: 1rem;
font-weight: 500;
margin: 0;
margin-top: .5rem;
}
.help {
::ng-deep {
h1 {
display: none;
}
}
}

65
frontend/src/app/features/settings/pages/templates/template.component.ts

@ -1,65 +0,0 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { AsyncPipe } from '@angular/common';
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { map, Observable, shareReplay } from 'rxjs';
import { AppsState, ClientsState, FormHintComponent, LoaderComponent, MarkdownDirective, TemplateDetailsDto, TemplateDto, TemplatesService, TranslatePipe } from '@app/shared';
@Component({
selector: 'sqx-template',
styleUrls: ['./template.component.scss'],
templateUrl: './template.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
AsyncPipe,
FormHintComponent,
LoaderComponent,
MarkdownDirective,
TranslatePipe,
],
})
export class TemplateComponent {
@Input({ required: true })
public template!: TemplateDto;
public isExpanded = false;
public details?: Observable<string>;
constructor(
private readonly clientsState: ClientsState,
private readonly appsState: AppsState,
private readonly templatesService: TemplatesService,
) {
}
public ngOnChanges() {
this.details = this.templatesService.getTemplate(this.template).pipe(map(x => this.buildDetails(x)), shareReplay(1));
}
public toggleExpanded() {
this.isExpanded = !this.isExpanded;
}
private buildDetails(dto: TemplateDetailsDto) {
const app = this.appsState.appName;
let details = dto.details.replace(/<APP>/g, app);
const client = this.clientsState.snapshot.clients[0];
if (client) {
const clientId = `${app}:${client.id}`;
details = details.replace(/\<CLIENT_ID>/g, clientId);
details = details.replace(/\<CLIENT_SECRET>/g, client.secret);
}
return details;
}
}

37
frontend/src/app/features/settings/pages/templates/templates-page.component.html

@ -1,37 +0,0 @@
<sqx-title message="i18n:common.templates" />
<sqx-layout innerWidth="50" layout="main" titleIcon="download" titleText="i18n:common.templates">
<ng-container menu>
<button class="btn btn-text-secondary me-2" (click)="reload()" shortcut="CTRL + B" title="i18n:templates.refreshTooltip" type="button">
<i class="icon-reset"></i> {{ "common.refresh" | sqxTranslate }}
</button>
</ng-container>
<ng-container>
<sqx-list-view innerWidth="50rem" [isLoading]="templatesState.isLoading | async">
<sqx-form-alert light="true">
<div inline="true" [sqxMarkdown]="'templates.cliHint' | sqxTranslate"></div>
</sqx-form-alert>
@if ((templatesState.isLoaded | async) && (templatesState.templates | async); as templates) {
@for (template of templates; track template.name) {
<sqx-template [template]="template" />
}
}
</sqx-list-view>
</ng-container>
<ng-template sidebarMenu>
<div class="panel-nav">
<a
class="panel-link"
attr.aria-label="{{ 'common.help' | sqxTranslate }}"
queryParamsHandling="preserve"
replaceUrl="true"
routerLink="help"
routerLinkActive="active"
sqxTourStep="help"
title="i18n:common.help"
titlePosition="left">
<i class="icon-help2"></i>
</a>
</div>
</ng-template>
</sqx-layout>
<router-outlet />

0
frontend/src/app/features/settings/pages/templates/templates-page.component.scss

52
frontend/src/app/features/settings/pages/templates/templates-page.component.ts

@ -1,52 +0,0 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { AsyncPipe } from '@angular/common';
import { Component, OnInit } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { ClientsState, FormAlertComponent, LayoutComponent, ListViewComponent, MarkdownDirective, ShortcutDirective, SidebarMenuDirective, TemplatesState, TitleComponent, TooltipDirective, TourStepDirective, TranslatePipe } from '@app/shared';
import { TemplateComponent } from './template.component';
@Component({
selector: 'sqx-templates-page',
styleUrls: ['./templates-page.component.scss'],
templateUrl: './templates-page.component.html',
imports: [
AsyncPipe,
FormAlertComponent,
LayoutComponent,
ListViewComponent,
MarkdownDirective,
RouterLink,
RouterLinkActive,
RouterOutlet,
ShortcutDirective,
SidebarMenuDirective,
TemplateComponent,
TitleComponent,
TooltipDirective,
TourStepDirective,
TranslatePipe,
],
})
export class TemplatesPageComponent implements OnInit {
constructor(
public readonly clientsState: ClientsState,
public readonly templatesState: TemplatesState,
) {
}
public ngOnInit() {
this.clientsState.load();
this.templatesState.load();
}
public reload() {
this.templatesState.load(true);
}
}

14
frontend/src/app/features/settings/routes.ts

@ -16,7 +16,6 @@ import { MorePageComponent } from './pages/more/more-page.component';
import { PlansPageComponent } from './pages/plans/plans-page.component';
import { RolesPageComponent } from './pages/roles/roles-page.component';
import { SettingsPageComponent } from './pages/settings/settings-page.component';
import { TemplatesPageComponent } from './pages/templates/templates-page.component';
import { WorkflowsPageComponent } from './pages/workflows/workflows-page.component';
import { SettingsAreaComponent } from './settings-area.component';
@ -142,19 +141,6 @@ export const SETTINGS_ROUTES: Routes = [
},
],
},
{
path: 'templates',
component: TemplatesPageComponent,
children: [
{
path: 'help',
component: HelpComponent,
data: {
helpPage: '05-integrated/templates',
},
},
],
},
{
path: 'plans',
component: PlansPageComponent,

4
frontend/src/app/features/settings/settings-menu.component.html

@ -53,10 +53,6 @@
<li class="nav-item nav-heading">{{ "common.more" | sqxTranslate }}</li>
<li class="nav-item" sqxTourStep="templates">
<a class="nav-link" routerLink="templates" routerLinkActive="active"> <i class="icon-download"></i> {{ "common.templates" | sqxTranslate }} </a>
</li>
@if (app.canReadJobs) {
<li class="nav-item" sqxTourStep="jobs">
<a class="nav-link" routerLink="jobs" routerLinkActive="active"> <i class="icon-backups"></i> {{ "common.jobsBackups" | sqxTranslate }} </a>

80
frontend/src/app/shared/components/app-form.component.html

@ -1,5 +1,5 @@
<form [formGroup]="createForm.form" (ngSubmit)="createApp()">
<sqx-modal-dialog (dialogClose)="emitClose()" tourId="appForm">
<sqx-modal-dialog (dialogClose)="emitClose()" flexBody="true" [size]="templates.length > 0 ? 'lg' : 'md'" tourId="appForm">
<ng-container title>
@if (template) {
{{ "apps.createWithTemplate" | sqxTranslate: { template: template.title } }}
@ -8,18 +8,74 @@
}
</ng-container>
<ng-container content>
<sqx-form-error [error]="createForm.error | async" />
<div class="form-group mt-2">
<label for="appName">
{{ "common.name" | sqxTranslate }} <small class="hint">({{ "common.requiredHint" | sqxTranslate }})</small>
</label>
<sqx-control-errors for="name" />
<input class="form-control" id="name" autocomplete="off" formControlName="name" sqxFocusOnInit sqxTransformInput="LowerCase" />
<sqx-form-hint> {{ "apps.appNameHint" | sqxTranslate }} </sqx-form-hint>
</div>
<div class="row g-0">
<div class="col col-left">
<div class="card-body">
<sqx-form-error [error]="createForm.error | async" />
<div class="form-group mt-2">
<label for="appName">
{{ "common.name" | sqxTranslate }} <small class="hint">({{ "common.requiredHint" | sqxTranslate }})</small>
</label>
<sqx-control-errors for="name" />
<input class="form-control" id="name" autocomplete="off" formControlName="name" sqxFocusOnInit sqxTransformInput="LowerCase" />
<sqx-form-hint> {{ "apps.appNameHint" | sqxTranslate }} </sqx-form-hint>
</div>
<div class="form-group">
<sqx-form-alert marginBottom="0" marginTop="2"> {{ "apps.appNameWarning" | sqxTranslate }} </sqx-form-alert>
</div>
@if (templates.length > 0) {
<h4 class="mt-6">{{ "apps.selectAppTemplate" | sqxTranslate }}</h4>
<div class="grid" style="--bs-gap: 0.5rem 0.5rem">
<div
class="g-col-6 g-col-xl-4 card card-template card-href"
[class.border-primary]="!template"
(click)="selectTemplate()"
data-testid="new-app"
sqxTourStep="addApp">
<div class="card-body">
<div class="card-image"><img src="./images/add-app.svg" /></div>
<h5 class="card-title mt-3">{{ "apps.createBlankApp" | sqxTranslate }}</h5>
<sqx-form-hint> {{ "apps.createBlankAppDescription" | sqxTranslate }} </sqx-form-hint>
</div>
</div>
@for (availableTemplate of templates; track availableTemplate) {
<div
class="g-col-6 g-col-xl-4 card card-template card-href"
[class.border-primary]="availableTemplate === template"
(click)="selectTemplate(availableTemplate)">
<div class="card-body">
<div class="card-image">
@if (availableTemplate.logo) {
<img [src]="availableTemplate.logo" />
} @else {
<img src="./images/add-template.svg" />
}
</div>
<h5 class="card-title mt-3">{{ availableTemplate.title }}</h5>
<sqx-form-hint> {{ availableTemplate.description }} </sqx-form-hint>
</div>
</div>
}
</div>
}
</div>
</div>
@if (templates.length > 0) {
<div class="col-info d-none d-lg-block">
<sqx-form-hint> {{ "apps.appExplanation" | sqxTranslate }} </sqx-form-hint>
<div class="form-group">
<sqx-form-alert marginBottom="0" marginTop="2"> {{ "apps.appNameWarning" | sqxTranslate }} </sqx-form-alert>
@if (template) {
<h4 class="mt-4">{{ "apps.template" | sqxTranslate }}</h4>
<div class="help" inline="false" [sqxMarkdown]="template.details"></div>
}
</div>
}
</div>
</ng-container>
<ng-container footer>

31
frontend/src/app/shared/components/app-form.component.scss

@ -1,2 +1,31 @@
@use 'sass:color';
@import 'mixins';
@import 'vars';
@import 'vars';
.card-image {
width: 30%;
}
.card-title {
margin: .25rem 0;
}
.col-info {
background-color: color.adjust($color-background, $lightness: 2%);
border-radius: 0;
border-left: 1px solid $color-border;
padding: 1.5rem 2rem;
width: 18rem;
}
.col-left {
padding: 1.5rem 2rem;
}
.help {
::ng-deep {
h1 {
display: none;
}
}
}

10
frontend/src/app/shared/components/app-form.component.ts

@ -8,7 +8,7 @@
import { AsyncPipe } from '@angular/common';
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { ApiUrlConfig, ControlErrorsComponent, FocusOnInitDirective, FormAlertComponent, FormErrorComponent, FormHintComponent, ModalDialogComponent, TooltipDirective, TransformInputDirective, TranslatePipe } from '@app/framework';
import { ApiUrlConfig, ControlErrorsComponent, FocusOnInitDirective, FormAlertComponent, FormErrorComponent, FormHintComponent, MarkdownDirective, ModalDialogComponent, TooltipDirective, TransformInputDirective, TranslatePipe } from '@app/framework';
import { AppsState, CreateAppDto, CreateAppForm, TemplateDto } from '@app/shared/internal';
@Component({
@ -24,6 +24,7 @@ import { AppsState, CreateAppDto, CreateAppForm, TemplateDto } from '@app/shared
FormErrorComponent,
FormHintComponent,
FormsModule,
MarkdownDirective,
ModalDialogComponent,
ReactiveFormsModule,
TooltipDirective,
@ -38,6 +39,9 @@ export class AppFormComponent {
@Input()
public template?: TemplateDto;
@Input({ required: true })
public templates: TemplateDto[] = [];
public createForm = new CreateAppForm();
constructor(
@ -50,6 +54,10 @@ export class AppFormComponent {
this.dialogClose.emit();
}
public selectTemplate(template?: TemplateDto) {
this.template = template;
}
public createApp() {
const value = this.createForm.submit();
if (!value) {

12
frontend/src/app/shared/model/generated.ts

@ -1005,8 +1005,12 @@ export class TemplateDto extends ResourceDto implements ITemplateDto {
readonly title!: string;
/** The description of the template. */
readonly description!: string;
/** The details of the template. */
readonly details!: string;
/** True, if the template is a starter. */
readonly isStarter!: boolean;
/** The optional logo. */
readonly logo?: string | undefined;
constructor(data?: ITemplateDto) {
super(data);
@ -1017,7 +1021,9 @@ export class TemplateDto extends ResourceDto implements ITemplateDto {
(<any>this).name = _data["name"];
(<any>this).title = _data["title"];
(<any>this).description = _data["description"];
(<any>this).details = _data["details"];
(<any>this).isStarter = _data["isStarter"];
(<any>this).logo = _data["logo"];
this.cleanup(this);
return this;
}
@ -1033,7 +1039,9 @@ export class TemplateDto extends ResourceDto implements ITemplateDto {
data["name"] = this.name;
data["title"] = this.title;
data["description"] = this.description;
data["details"] = this.details;
data["isStarter"] = this.isStarter;
data["logo"] = this.logo;
super.toJSON(data);
this.cleanup(data);
return data;
@ -1047,8 +1055,12 @@ export interface ITemplateDto extends IResourceDto {
readonly title: string;
/** The description of the template. */
readonly description: string;
/** The details of the template. */
readonly details: string;
/** True, if the template is a starter. */
readonly isStarter: boolean;
/** The optional logo. */
readonly logo?: string | undefined;
}
export class TemplateDetailsDto extends ResourceDto implements ITemplateDetailsDto {

4
frontend/src/app/shared/services/templates.service.spec.ts

@ -36,7 +36,7 @@ describe('TemplatesService', () => {
templates = result;
});
const req = httpMock.expectOne('http://service/p/api/templates');
const req = httpMock.expectOne('http://service/p/api/templates?includeDetails=true');
expect(req.request.method).toEqual('GET');
expect(req.request.headers.get('If-Match')).toBeNull();
@ -88,6 +88,7 @@ describe('TemplatesService', () => {
return {
name: `name${key}`,
title: `Title ${key}`,
details: '',
description: `Description ${key}`,
isStarter: id % 2 === 0,
_links: {
@ -114,6 +115,7 @@ export function createTemplate(id: number, suffix = '') {
return new TemplateDto({
name: `name${key}`,
title: `Title ${key}`,
details: '',
description: `Description ${key}`,
isStarter: id % 2 === 0,
_links: {

2
frontend/src/app/shared/services/templates.service.ts

@ -23,7 +23,7 @@ export class TemplatesService {
}
public getTemplates(): Observable<TemplatesDto> {
const url = this.apiUrl.buildUrl('api/templates');
const url = this.apiUrl.buildUrl('api/templates?includeDetails=true');
return this.http.get<any>(url).pipe(
map(body => {

16
frontend/src/app/shared/state/template.state.spec.ts

@ -7,7 +7,7 @@
import { of, onErrorResumeNextWith, throwError } from 'rxjs';
import { IMock, It, Mock, Times } from 'typemoq';
import { DialogService, TemplatesDto, TemplatesService, TemplatesState } from '@app/shared/internal';
import { DialogService, TemplateDto, TemplatesDto, TemplatesService, TemplatesState } from '@app/shared/internal';
import { createTemplate } from '../services/templates.service.spec';
describe('TemplatesState', () => {
@ -43,6 +43,20 @@ describe('TemplatesState', () => {
dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never());
});
it('should provide starters', () => {
templatesService.setup(x => x.getTemplates())
.returns(() => of(new TemplatesDto({ items: [template1, template2], _links: {} }))).verifiable();
templatesState.load().subscribe();
let starters: TemplateDto[] = [];
templatesState.starters.subscribe(x => {
starters = x;
});
expect(starters).toEqual([template1]);
});
it('should reset loading state if loading failed', () => {
templatesService.setup(x => x.getTemplates())
.returns(() => throwError(() => 'Service Error'));

3
frontend/src/app/shared/state/templates.state.ts

@ -27,6 +27,9 @@ export class TemplatesState extends State<Snapshot> {
public templates =
this.project(x => x.templates);
public starters =
this.project(x => x.templates.filter(x => x.isStarter));
public isLoaded =
this.project(x => x.isLoaded === true);

6
frontend/src/app/shell/pages/internal/apps-menu.component.html

@ -92,5 +92,9 @@
</nav>
}
</ul>
<sqx-app-form (dialogClose)="addAppDialog.hide()" *sqxModal="addAppDialog" />
@if (starters | async; as templates) {
<sqx-app-form (dialogClose)="addAppDialog.hide()" *sqxModal="addAppDialog" [templates]="templates" />
}
<sqx-team-form (dialogClose)="addTeamDialog.hide()" *sqxModal="addTeamDialog" />

5
frontend/src/app/shell/pages/internal/apps-menu.component.ts

@ -10,7 +10,7 @@ import { ChangeDetectionStrategy, Component } from '@angular/core';
import { ActivatedRoute, RouterLink, RouterLinkActive } from '@angular/router';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { AppFormComponent, AppsState, DialogModel, DropdownMenuComponent, ModalDirective, ModalModel, ModalPlacementDirective, TeamFormComponent, TeamsState, Title, TitleService, TranslatePipe, UIState } from '@app/shared';
import { AppFormComponent, AppsState, DialogModel, DropdownMenuComponent, ModalDirective, ModalModel, ModalPlacementDirective, TeamFormComponent, TeamsState, TemplatesState, Title, TitleService, TranslatePipe, UIState } from '@app/shared';
@Component({
selector: 'sqx-apps-menu',
@ -36,11 +36,14 @@ export class AppsMenuComponent {
public appsMenu = new ModalModel();
public appPath: Observable<ReadonlyArray<Title>>;
public starters = this.templatesState.starters;
constructor(titleService: TitleService,
public readonly appsState: AppsState,
public readonly route: ActivatedRoute,
public readonly teamsState: TeamsState,
public readonly uiState: UIState,
private readonly templatesState: TemplatesState,
) {
this.appPath = titleService.pathChanges.pipe(map(x => x.slice(1)));
}

2
frontend/src/app/theme/_bootstrap-vars.scss

@ -3,6 +3,8 @@
/* stylelint-disable */
$enable-cssgrid: true;
//
// GENERAL
//

4
frontend/src/app/theme/_common.scss

@ -161,6 +161,10 @@ hr {
}
}
ul {
margin-top: .25rem;
}
sqx-form-hint {
small {
font-size: 100%;

11
frontend/src/app/theme/_forms.scss

@ -43,13 +43,13 @@
// Small triangle under the error tooltip with the border trick.
&::after {
@include absolute(null, null, -.75rem, .625rem);
@include caret-bottom($color-theme-error, .4rem);
@include absolute(null, null, -.5rem, .25rem);
@include caret-bottom($color-theme-error, .3rem);
}
// The tooltip rectangle itself.
& {
@include absolute(null, null, .4rem, 0);
@include absolute(null, null, .2rem, 0);
background: $color-theme-error;
border: 0;
border-radius: .5 * $border-radius;
@ -243,11 +243,16 @@
& ~ .hidden {
margin-bottom: 0;
}
& label {
margin-bottom: .125rem;;
}
}
.col-form-label {
font-size: 90%;
}
label {
& {
font-size: 90%;

13
tools/e2e/tests/pages/apps.ts

@ -5,7 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { expect, Page } from '@playwright/test';
import { expect, Locator, Page } from '@playwright/test';
export class AppsPage {
constructor(private readonly page: Page) {}
@ -21,7 +21,7 @@ export class AppsPage {
public async openAppDialog() {
await this.page.getByTestId('new-app').click();
return new AppDialog(this.page);
return new AppDialog(this.page, this.page.getByTestId('dialog'));
}
public async createNewApp(appName: string) {
@ -36,13 +36,16 @@ export class AppsPage {
}
class AppDialog {
constructor(private readonly page: Page) {}
constructor(private readonly page: Page,
public readonly root: Locator,
) {
}
public async enterName(name: string) {
await this.page.locator('#name').fill(name);
await this.root.locator('#name').fill(name);
}
public async save() {
await this.page.getByRole('button', { name: 'Create' }).click();
await this.root.getByRole('button', { name: 'Create' }).click();
}
}
Loading…
Cancel
Save