Browse Source

Add new buttons. (#1124)

* Add new buttons.

* Harden tests

* Make screenshots.

* Enable screenshots.

* Fix close?
pull/1127/head
Sebastian Stehle 2 years ago
committed by GitHub
parent
commit
a746d23487
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 14
      backend/i18n/frontend_en.json
  2. 8
      backend/i18n/frontend_fr.json
  3. 8
      backend/i18n/frontend_it.json
  4. 8
      backend/i18n/frontend_nl.json
  5. 8
      backend/i18n/frontend_pt.json
  6. 8
      backend/i18n/frontend_zh.json
  7. 14
      backend/i18n/source/frontend_en.json
  8. 6
      backend/src/Squidex.Domain.Apps.Core.Model/Contents/Json/ContentFieldDataConverter.cs
  9. 8
      backend/src/Squidex.Infrastructure/Json/System/PolymorphicConverter.cs
  10. 4
      backend/src/Squidex.Infrastructure/Json/System/StringConverter.cs
  11. 2
      backend/src/Squidex.Infrastructure/Queries/Json/JsonFilterSurrogate.cs
  12. 5
      backend/src/Squidex.Infrastructure/ThrowHelper.cs
  13. 1
      backend/src/Squidex.Web/ApiExceptionConverter.cs
  14. 9
      frontend/src/app/features/apps/pages/app.component.html
  15. 9
      frontend/src/app/features/apps/pages/team.component.html
  16. 68
      frontend/src/app/features/content/pages/content/content-page.component.html
  17. 88
      frontend/src/app/features/content/pages/content/content-page.component.ts
  18. 3
      frontend/src/app/features/content/shared/references/reference-item.component.html
  19. 2
      frontend/src/app/features/rules/pages/rules/rule.component.html
  20. 68
      frontend/src/app/features/schemas/pages/schema/fields/field-wizard.component.html
  21. 6
      frontend/src/app/features/schemas/pages/schema/fields/field-wizard.component.ts
  22. 2
      frontend/src/app/framework/angular/modals/dialog-renderer.component.html
  23. 20
      frontend/src/app/framework/angular/pager.component.html
  24. 13
      frontend/src/app/framework/services/localizer.service.ts
  25. 8
      frontend/src/app/shared/components/assets/asset-folder.component.html
  26. 1
      frontend/src/environments/environment.prod.ts
  27. 1
      frontend/src/environments/environment.ts
  28. 4
      frontend/src/main.ts
  29. 3
      tools/e2e/playwright.config.ts
  30. 3
      tools/e2e/tests/given-app/rules.spec.ts
  31. 2
      tools/e2e/tests/given-app/schemas.spec.ts
  32. 2
      tools/e2e/tests/given-schema/_setup.ts
  33. 104
      tools/e2e/tests/given-schema/contents.spec.ts

14
backend/i18n/frontend_en.json

@ -310,6 +310,7 @@
"common.more": "More", "common.more": "More",
"common.name": "Name", "common.name": "Name",
"common.next": "Continue", "common.next": "Continue",
"common.nextPage": "Next Page",
"common.no": "No", "common.no": "No",
"common.nothingChanged": "Nothing has been changed.", "common.nothingChanged": "Nothing has been changed.",
"common.notSupported": "Not Supported", "common.notSupported": "Not Supported",
@ -320,6 +321,7 @@
"common.pagerInfo": "{itemFirst}-{itemLast} of {numberOfItems}", "common.pagerInfo": "{itemFirst}-{itemLast} of {numberOfItems}",
"common.pagerInfoNoTotal": "{itemFirst}-{itemLast} of total?", "common.pagerInfoNoTotal": "{itemFirst}-{itemLast} of total?",
"common.pagerReload": "Click to reload view and get total number of items", "common.pagerReload": "Click to reload view and get total number of items",
"common.pageSize": "Page Size",
"common.password": "Password", "common.password": "Password",
"common.passwordConfirm": "Confirm Password", "common.passwordConfirm": "Confirm Password",
"common.pattern": "Pattern", "common.pattern": "Pattern",
@ -328,6 +330,7 @@
"common.permissions": "Permissions", "common.permissions": "Permissions",
"common.prev": "Back", "common.prev": "Back",
"common.preview": "Preview", "common.preview": "Preview",
"common.prevPage": "Previous Page",
"common.product": "Squidex Headless CMS", "common.product": "Squidex Headless CMS",
"common.project": "Project", "common.project": "Project",
"common.queryOperators.contains": "contains", "common.queryOperators.contains": "contains",
@ -359,6 +362,9 @@
"common.rules": "Rules", "common.rules": "Rules",
"common.sampleCodeLabel": "Sample Code at", "common.sampleCodeLabel": "Sample Code at",
"common.save": "Save", "common.save": "Save",
"common.saveAdd": "Save & add another",
"common.saveClose": "Save & close",
"common.saveEdit": "Save & editor",
"common.schema": "Schema", "common.schema": "Schema",
"common.schemas": "Schemas", "common.schemas": "Schemas",
"common.search": "Search", "common.search": "Search",
@ -480,6 +486,8 @@
"contents.removeConfirmText": "Do you really want to remove the content?", "contents.removeConfirmText": "Do you really want to remove the content?",
"contents.removeConfirmTitle": "Remove content", "contents.removeConfirmTitle": "Remove content",
"contents.saveAndPublish": "Save and Publish", "contents.saveAndPublish": "Save and Publish",
"contents.saveAndPublishAdd": "Save and Publish & add another",
"contents.saveAndPublishClose": "Save and Publish & close",
"contents.scheduledAt": "at", "contents.scheduledAt": "at",
"contents.scheduledBy": "by", "contents.scheduledBy": "by",
"contents.scheduledTo": "to", "contents.scheduledTo": "to",
@ -785,9 +793,9 @@
"rules.updateFailed": "Failed to update rule. Please reload.", "rules.updateFailed": "Failed to update rule. Please reload.",
"rules.when": "When", "rules.when": "When",
"schemas.addField": "Add Field", "schemas.addField": "Add Field",
"schemas.addFieldAndClose": "Create and close", "schemas.addFieldAndClose": "Create",
"schemas.addFieldAndCreate": "Create and add field", "schemas.addFieldAndCreate": "Create & add another",
"schemas.addFieldAndEdit": "Create and edit field", "schemas.addFieldAndEdit": "Create & edit properties",
"schemas.addFieldButton": "Add Field", "schemas.addFieldButton": "Add Field",
"schemas.addFieldFailed": "Failed to add field. Please reload.", "schemas.addFieldFailed": "Failed to add field. Please reload.",
"schemas.addNestedField": "Add Nested Field", "schemas.addNestedField": "Add Nested Field",

8
backend/i18n/frontend_fr.json

@ -310,6 +310,7 @@
"common.more": "Plus", "common.more": "Plus",
"common.name": "Nom", "common.name": "Nom",
"common.next": "Continue", "common.next": "Continue",
"common.nextPage": "Next Page",
"common.no": "Non", "common.no": "Non",
"common.nothingChanged": "Rien n'a été changé.", "common.nothingChanged": "Rien n'a été changé.",
"common.notSupported": "Non supporté", "common.notSupported": "Non supporté",
@ -320,6 +321,7 @@
"common.pagerInfo": "{itemFirst}-{itemLast} de {numberOfItems}", "common.pagerInfo": "{itemFirst}-{itemLast} de {numberOfItems}",
"common.pagerInfoNoTotal": "{itemFirst}-{itemLast} Du total?", "common.pagerInfoNoTotal": "{itemFirst}-{itemLast} Du total?",
"common.pagerReload": "Cliquez pour recharger la vue et obtenir le nombre total d'articles", "common.pagerReload": "Cliquez pour recharger la vue et obtenir le nombre total d'articles",
"common.pageSize": "Page Size",
"common.password": "Mot de passe", "common.password": "Mot de passe",
"common.passwordConfirm": "Confirmez le mot de passe", "common.passwordConfirm": "Confirmez le mot de passe",
"common.pattern": "Modèle", "common.pattern": "Modèle",
@ -328,6 +330,7 @@
"common.permissions": "Autorisations", "common.permissions": "Autorisations",
"common.prev": "Back", "common.prev": "Back",
"common.preview": "Aperçu", "common.preview": "Aperçu",
"common.prevPage": "Previous Page",
"common.product": "CMS sans tête Squidex", "common.product": "CMS sans tête Squidex",
"common.project": "Projet", "common.project": "Projet",
"common.queryOperators.contains": "contient", "common.queryOperators.contains": "contient",
@ -359,6 +362,9 @@
"common.rules": "Règles", "common.rules": "Règles",
"common.sampleCodeLabel": "Exemple de code à", "common.sampleCodeLabel": "Exemple de code à",
"common.save": "Sauvegarder", "common.save": "Sauvegarder",
"common.saveAdd": "Save & add another",
"common.saveClose": "Save & close",
"common.saveEdit": "Save & editor",
"common.schema": "Schéma", "common.schema": "Schéma",
"common.schemas": "Schémas", "common.schemas": "Schémas",
"common.search": "Recherche", "common.search": "Recherche",
@ -480,6 +486,8 @@
"contents.removeConfirmText": "Voulez-vous vraiment supprimer le contenu\u00A0?", "contents.removeConfirmText": "Voulez-vous vraiment supprimer le contenu\u00A0?",
"contents.removeConfirmTitle": "Supprimer le contenu", "contents.removeConfirmTitle": "Supprimer le contenu",
"contents.saveAndPublish": "Enregistrer et publier", "contents.saveAndPublish": "Enregistrer et publier",
"contents.saveAndPublishAdd": "Save and Publish & add another",
"contents.saveAndPublishClose": "Save and Publish & close",
"contents.scheduledAt": "à", "contents.scheduledAt": "à",
"contents.scheduledBy": "par", "contents.scheduledBy": "par",
"contents.scheduledTo": "pour", "contents.scheduledTo": "pour",

8
backend/i18n/frontend_it.json

@ -310,6 +310,7 @@
"common.more": "More", "common.more": "More",
"common.name": "Nome", "common.name": "Nome",
"common.next": "Continue", "common.next": "Continue",
"common.nextPage": "Next Page",
"common.no": "No", "common.no": "No",
"common.nothingChanged": "Non è stato cambiato niente.", "common.nothingChanged": "Non è stato cambiato niente.",
"common.notSupported": "Not Supported", "common.notSupported": "Not Supported",
@ -320,6 +321,7 @@
"common.pagerInfo": "{itemFirst}-{itemLast} of {numberOfItems}", "common.pagerInfo": "{itemFirst}-{itemLast} of {numberOfItems}",
"common.pagerInfoNoTotal": "{itemFirst}-{itemLast} of total?", "common.pagerInfoNoTotal": "{itemFirst}-{itemLast} of total?",
"common.pagerReload": "Click to reload view and get total number of items", "common.pagerReload": "Click to reload view and get total number of items",
"common.pageSize": "Page Size",
"common.password": "Password", "common.password": "Password",
"common.passwordConfirm": "Conferma Password", "common.passwordConfirm": "Conferma Password",
"common.pattern": "Pattern", "common.pattern": "Pattern",
@ -328,6 +330,7 @@
"common.permissions": "Permessi", "common.permissions": "Permessi",
"common.prev": "Back", "common.prev": "Back",
"common.preview": "Anteprima", "common.preview": "Anteprima",
"common.prevPage": "Previous Page",
"common.product": "Squidex Headless CMS", "common.product": "Squidex Headless CMS",
"common.project": "Progetto", "common.project": "Progetto",
"common.queryOperators.contains": "contiene", "common.queryOperators.contains": "contiene",
@ -359,6 +362,9 @@
"common.rules": "Regole", "common.rules": "Regole",
"common.sampleCodeLabel": "Esempio di codice per", "common.sampleCodeLabel": "Esempio di codice per",
"common.save": "Salva", "common.save": "Salva",
"common.saveAdd": "Save & add another",
"common.saveClose": "Save & close",
"common.saveEdit": "Save & editor",
"common.schema": "Schema", "common.schema": "Schema",
"common.schemas": "Schemi", "common.schemas": "Schemi",
"common.search": "Search", "common.search": "Search",
@ -480,6 +486,8 @@
"contents.removeConfirmText": "Sei sicuro di voler rimuovere il contenuto?", "contents.removeConfirmText": "Sei sicuro di voler rimuovere il contenuto?",
"contents.removeConfirmTitle": "Cancella il contenuto", "contents.removeConfirmTitle": "Cancella il contenuto",
"contents.saveAndPublish": "Salva e pubblica", "contents.saveAndPublish": "Salva e pubblica",
"contents.saveAndPublishAdd": "Save and Publish & add another",
"contents.saveAndPublishClose": "Save and Publish & close",
"contents.scheduledAt": "alle", "contents.scheduledAt": "alle",
"contents.scheduledBy": "by", "contents.scheduledBy": "by",
"contents.scheduledTo": "a", "contents.scheduledTo": "a",

8
backend/i18n/frontend_nl.json

@ -310,6 +310,7 @@
"common.more": "Meer", "common.more": "Meer",
"common.name": "Naam", "common.name": "Naam",
"common.next": "Continue", "common.next": "Continue",
"common.nextPage": "Next Page",
"common.no": "Nee", "common.no": "Nee",
"common.nothingChanged": "Er is niets veranderd.", "common.nothingChanged": "Er is niets veranderd.",
"common.notSupported": "Not Supported", "common.notSupported": "Not Supported",
@ -320,6 +321,7 @@
"common.pagerInfo": "{itemFirst} - {itemLast} van {numberOfItems}", "common.pagerInfo": "{itemFirst} - {itemLast} van {numberOfItems}",
"common.pagerInfoNoTotal": "{itemFirst}-{itemLast} of total?", "common.pagerInfoNoTotal": "{itemFirst}-{itemLast} of total?",
"common.pagerReload": "Click to reload view and get total number of items", "common.pagerReload": "Click to reload view and get total number of items",
"common.pageSize": "Page Size",
"common.password": "Wachtwoord", "common.password": "Wachtwoord",
"common.passwordConfirm": "Bevestig wachtwoord", "common.passwordConfirm": "Bevestig wachtwoord",
"common.pattern": "Patroon", "common.pattern": "Patroon",
@ -328,6 +330,7 @@
"common.permissions": "Rechten", "common.permissions": "Rechten",
"common.prev": "Back", "common.prev": "Back",
"common.preview": "Preview", "common.preview": "Preview",
"common.prevPage": "Previous Page",
"common.product": "Squidex Headless CMS", "common.product": "Squidex Headless CMS",
"common.project": "Project", "common.project": "Project",
"common.queryOperators.contains": "bevat", "common.queryOperators.contains": "bevat",
@ -359,6 +362,9 @@
"common.rules": "Regels", "common.rules": "Regels",
"common.sampleCodeLabel": "Voorbeeldcode bij", "common.sampleCodeLabel": "Voorbeeldcode bij",
"common.save": "Opslaan", "common.save": "Opslaan",
"common.saveAdd": "Save & add another",
"common.saveClose": "Save & close",
"common.saveEdit": "Save & editor",
"common.schema": "Schema", "common.schema": "Schema",
"common.schemas": "Schema's", "common.schemas": "Schema's",
"common.search": "Zoeken", "common.search": "Zoeken",
@ -480,6 +486,8 @@
"contents.removeConfirmText": "Wil je de inhoud echt verwijderen?", "contents.removeConfirmText": "Wil je de inhoud echt verwijderen?",
"contents.removeConfirmTitle": "Verwijder inhoud", "contents.removeConfirmTitle": "Verwijder inhoud",
"contents.saveAndPublish": "Opslaan en publiceren", "contents.saveAndPublish": "Opslaan en publiceren",
"contents.saveAndPublishAdd": "Save and Publish & add another",
"contents.saveAndPublishClose": "Save and Publish & close",
"contents.scheduledAt": "bij", "contents.scheduledAt": "bij",
"contents.scheduledBy": "door", "contents.scheduledBy": "door",
"contents.scheduledTo": "naar", "contents.scheduledTo": "naar",

8
backend/i18n/frontend_pt.json

@ -310,6 +310,7 @@
"common.more": "Mais", "common.more": "Mais",
"common.name": "Nome", "common.name": "Nome",
"common.next": "Continue", "common.next": "Continue",
"common.nextPage": "Next Page",
"common.no": "Não", "common.no": "Não",
"common.nothingChanged": "Nada foi mudado.", "common.nothingChanged": "Nada foi mudado.",
"common.notSupported": "Não suportado", "common.notSupported": "Não suportado",
@ -320,6 +321,7 @@
"common.pagerInfo": "{itemFirst}-{itemLast} de {numberOfItems}", "common.pagerInfo": "{itemFirst}-{itemLast} de {numberOfItems}",
"common.pagerInfoNoTotal": "{itemFirst}-{itemLast} do total?", "common.pagerInfoNoTotal": "{itemFirst}-{itemLast} do total?",
"common.pagerReload": "Clique para recarregar a vista e obter o número total de itens", "common.pagerReload": "Clique para recarregar a vista e obter o número total de itens",
"common.pageSize": "Page Size",
"common.password": "Password", "common.password": "Password",
"common.passwordConfirm": "Confirmar Password", "common.passwordConfirm": "Confirmar Password",
"common.pattern": "Padrão", "common.pattern": "Padrão",
@ -328,6 +330,7 @@
"common.permissions": "Permissões", "common.permissions": "Permissões",
"common.prev": "Back", "common.prev": "Back",
"common.preview": "Previsualizar", "common.preview": "Previsualizar",
"common.prevPage": "Previous Page",
"common.product": "CMS Headless Squidex", "common.product": "CMS Headless Squidex",
"common.project": "Projeto", "common.project": "Projeto",
"common.queryOperators.contains": "contém", "common.queryOperators.contains": "contém",
@ -359,6 +362,9 @@
"common.rules": "Regras", "common.rules": "Regras",
"common.sampleCodeLabel": "Código de amostra em", "common.sampleCodeLabel": "Código de amostra em",
"common.save": "Salvar", "common.save": "Salvar",
"common.saveAdd": "Save & add another",
"common.saveClose": "Save & close",
"common.saveEdit": "Save & editor",
"common.schema": "Esquema", "common.schema": "Esquema",
"common.schemas": "Esquemas", "common.schemas": "Esquemas",
"common.search": "Pesquisar", "common.search": "Pesquisar",
@ -480,6 +486,8 @@
"contents.removeConfirmText": "Quer mesmo remover o conteúdo?", "contents.removeConfirmText": "Quer mesmo remover o conteúdo?",
"contents.removeConfirmTitle": "Remover conteúdo", "contents.removeConfirmTitle": "Remover conteúdo",
"contents.saveAndPublish": "Salvar e Publicar", "contents.saveAndPublish": "Salvar e Publicar",
"contents.saveAndPublishAdd": "Save and Publish & add another",
"contents.saveAndPublishClose": "Save and Publish & close",
"contents.scheduledAt": "em", "contents.scheduledAt": "em",
"contents.scheduledBy": "por", "contents.scheduledBy": "por",
"contents.scheduledTo": "Para", "contents.scheduledTo": "Para",

8
backend/i18n/frontend_zh.json

@ -310,6 +310,7 @@
"common.more": "More", "common.more": "More",
"common.name": "名称", "common.name": "名称",
"common.next": "Continue", "common.next": "Continue",
"common.nextPage": "Next Page",
"common.no": "不", "common.no": "不",
"common.nothingChanged": "什么都没有改变。", "common.nothingChanged": "什么都没有改变。",
"common.notSupported": "Not Supported", "common.notSupported": "Not Supported",
@ -320,6 +321,7 @@
"common.pagerInfo": "{itemFirst}-{itemLast} 的 {numberOfItems}", "common.pagerInfo": "{itemFirst}-{itemLast} 的 {numberOfItems}",
"common.pagerInfoNoTotal": "{itemFirst}-{itemLast} of total?", "common.pagerInfoNoTotal": "{itemFirst}-{itemLast} of total?",
"common.pagerReload": "Click to reload view and get total number of items", "common.pagerReload": "Click to reload view and get total number of items",
"common.pageSize": "Page Size",
"common.password": "密码", "common.password": "密码",
"common.passwordConfirm": "确认密码", "common.passwordConfirm": "确认密码",
"common.pattern": "模式", "common.pattern": "模式",
@ -328,6 +330,7 @@
"common.permissions": "权限", "common.permissions": "权限",
"common.prev": "Back", "common.prev": "Back",
"common.preview": "预览", "common.preview": "预览",
"common.prevPage": "Previous Page",
"common.product": "Squidex Headless CMS", "common.product": "Squidex Headless CMS",
"common.project": "项目", "common.project": "项目",
"common.queryOperators.contains": "包含", "common.queryOperators.contains": "包含",
@ -359,6 +362,9 @@
"common.rules": "规则", "common.rules": "规则",
"common.sampleCodeLabel": "示例代码在", "common.sampleCodeLabel": "示例代码在",
"common.save": "保存", "common.save": "保存",
"common.saveAdd": "Save & add another",
"common.saveClose": "Save & close",
"common.saveEdit": "Save & editor",
"common.schema": "Schema", "common.schema": "Schema",
"common.schemas": "Schemas", "common.schemas": "Schemas",
"common.search": "搜索", "common.search": "搜索",
@ -480,6 +486,8 @@
"contents.removeConfirmText": "您真的要删除内容吗?", "contents.removeConfirmText": "您真的要删除内容吗?",
"contents.removeConfirmTitle": "删除内容", "contents.removeConfirmTitle": "删除内容",
"contents.saveAndPublish": "保存并发布", "contents.saveAndPublish": "保存并发布",
"contents.saveAndPublishAdd": "Save and Publish & add another",
"contents.saveAndPublishClose": "Save and Publish & close",
"contents.scheduledAt": "at", "contents.scheduledAt": "at",
"contents.scheduledBy": "by", "contents.scheduledBy": "by",
"contents.scheduledTo": "to", "contents.scheduledTo": "to",

14
backend/i18n/source/frontend_en.json

@ -310,6 +310,7 @@
"common.more": "More", "common.more": "More",
"common.name": "Name", "common.name": "Name",
"common.next": "Continue", "common.next": "Continue",
"common.nextPage": "Next Page",
"common.no": "No", "common.no": "No",
"common.nothingChanged": "Nothing has been changed.", "common.nothingChanged": "Nothing has been changed.",
"common.notSupported": "Not Supported", "common.notSupported": "Not Supported",
@ -320,6 +321,7 @@
"common.pagerInfo": "{itemFirst}-{itemLast} of {numberOfItems}", "common.pagerInfo": "{itemFirst}-{itemLast} of {numberOfItems}",
"common.pagerInfoNoTotal": "{itemFirst}-{itemLast} of total?", "common.pagerInfoNoTotal": "{itemFirst}-{itemLast} of total?",
"common.pagerReload": "Click to reload view and get total number of items", "common.pagerReload": "Click to reload view and get total number of items",
"common.pageSize": "Page Size",
"common.password": "Password", "common.password": "Password",
"common.passwordConfirm": "Confirm Password", "common.passwordConfirm": "Confirm Password",
"common.pattern": "Pattern", "common.pattern": "Pattern",
@ -328,6 +330,7 @@
"common.permissions": "Permissions", "common.permissions": "Permissions",
"common.prev": "Back", "common.prev": "Back",
"common.preview": "Preview", "common.preview": "Preview",
"common.prevPage": "Previous Page",
"common.product": "Squidex Headless CMS", "common.product": "Squidex Headless CMS",
"common.project": "Project", "common.project": "Project",
"common.queryOperators.contains": "contains", "common.queryOperators.contains": "contains",
@ -359,6 +362,9 @@
"common.rules": "Rules", "common.rules": "Rules",
"common.sampleCodeLabel": "Sample Code at", "common.sampleCodeLabel": "Sample Code at",
"common.save": "Save", "common.save": "Save",
"common.saveAdd": "Save & add another",
"common.saveClose": "Save & close",
"common.saveEdit": "Save & editor",
"common.schema": "Schema", "common.schema": "Schema",
"common.schemas": "Schemas", "common.schemas": "Schemas",
"common.search": "Search", "common.search": "Search",
@ -480,6 +486,8 @@
"contents.removeConfirmText": "Do you really want to remove the content?", "contents.removeConfirmText": "Do you really want to remove the content?",
"contents.removeConfirmTitle": "Remove content", "contents.removeConfirmTitle": "Remove content",
"contents.saveAndPublish": "Save and Publish", "contents.saveAndPublish": "Save and Publish",
"contents.saveAndPublishAdd": "Save and Publish & add another",
"contents.saveAndPublishClose": "Save and Publish & close",
"contents.scheduledAt": "at", "contents.scheduledAt": "at",
"contents.scheduledBy": "by", "contents.scheduledBy": "by",
"contents.scheduledTo": "to", "contents.scheduledTo": "to",
@ -785,9 +793,9 @@
"rules.updateFailed": "Failed to update rule. Please reload.", "rules.updateFailed": "Failed to update rule. Please reload.",
"rules.when": "When", "rules.when": "When",
"schemas.addField": "Add Field", "schemas.addField": "Add Field",
"schemas.addFieldAndClose": "Create and close", "schemas.addFieldAndClose": "Create",
"schemas.addFieldAndCreate": "Create and add field", "schemas.addFieldAndCreate": "Create & add another",
"schemas.addFieldAndEdit": "Create and edit field", "schemas.addFieldAndEdit": "Create & edit properties",
"schemas.addFieldButton": "Add Field", "schemas.addFieldButton": "Add Field",
"schemas.addFieldFailed": "Failed to add field. Please reload.", "schemas.addFieldFailed": "Failed to add field. Please reload.",
"schemas.addNestedField": "Add Nested Field", "schemas.addNestedField": "Add Nested Field",

6
backend/src/Squidex.Domain.Apps.Core.Model/Contents/Json/ContentFieldDataConverter.cs

@ -27,7 +27,8 @@ public sealed class ContentFieldDataConverter : JsonConverter<ContentFieldData>
if (!reader.Read()) if (!reader.Read())
{ {
throw new JsonException("Unexpected end when reading Object."); ThrowHelper.JsonSystemException("Unexpected end when reading Object.");
return result;
} }
var value = JsonSerializer.Deserialize<JsonValue>(ref reader, options)!; var value = JsonSerializer.Deserialize<JsonValue>(ref reader, options)!;
@ -48,7 +49,8 @@ public sealed class ContentFieldDataConverter : JsonConverter<ContentFieldData>
} }
} }
throw new JsonException("Unexpected end when reading Object."); ThrowHelper.JsonSystemException("Unexpected end when reading Object.");
return result;
} }
public override void Write(Utf8JsonWriter writer, ContentFieldData value, JsonSerializerOptions options) public override void Write(Utf8JsonWriter writer, ContentFieldData value, JsonSerializerOptions options)

8
backend/src/Squidex.Infrastructure/Json/System/PolymorphicConverter.cs

@ -63,7 +63,7 @@ public sealed class PolymorphicConverter<T> : JsonConverter<T> where T : class
if (typeReader.TokenType != JsonTokenType.StartObject) if (typeReader.TokenType != JsonTokenType.StartObject)
{ {
throw new JsonException(); ThrowHelper.JsonSystemException($"Expected Object, got '{reader.TokenType}'");
} }
while (typeReader.Read()) while (typeReader.Read())
@ -75,7 +75,7 @@ public sealed class PolymorphicConverter<T> : JsonConverter<T> where T : class
if (typeReader.TokenType != JsonTokenType.String) if (typeReader.TokenType != JsonTokenType.String)
{ {
ThrowHelper.JsonException($"Expected string discriminator value, got '{reader.TokenType}'"); ThrowHelper.JsonSystemException($"Expected string discriminator value, got '{reader.TokenType}'");
return default!; return default!;
} }
@ -94,7 +94,7 @@ public sealed class PolymorphicConverter<T> : JsonConverter<T> where T : class
} }
} }
ThrowHelper.JsonException($"Object has no discriminator '{discriminatorName}'."); ThrowHelper.JsonSystemException($"Object has no discriminator '{discriminatorName}'.");
return default!; return default!;
} }
@ -114,7 +114,7 @@ public sealed class PolymorphicConverter<T> : JsonConverter<T> where T : class
{ {
if (!typeRegistry.TryGetType<T>(name, out var type)) if (!typeRegistry.TryGetType<T>(name, out var type))
{ {
ThrowHelper.JsonException($"Object has invalid discriminator '{name}'."); ThrowHelper.JsonSystemException($"Object has invalid discriminator '{name}'.");
return default!; return default!;
} }

4
backend/src/Squidex.Infrastructure/Json/System/StringConverter.cs

@ -43,7 +43,7 @@ public sealed class StringConverter<T> : JsonConverter<T> where T : notnull
} }
catch (Exception ex) catch (Exception ex)
{ {
ThrowHelper.JsonException("Error while converting from string.", ex); ThrowHelper.JsonSystemException("Error while converting from string.", ex);
return default; return default;
} }
@ -53,7 +53,7 @@ public sealed class StringConverter<T> : JsonConverter<T> where T : notnull
return JsonSerializer.Deserialize<T>(ref reader, optionsWithoutSelf); return JsonSerializer.Deserialize<T>(ref reader, optionsWithoutSelf);
default: default:
ThrowHelper.JsonException($"Expected string or object, got {reader.TokenType}."); ThrowHelper.JsonSystemException($"Expected string or object, got {reader.TokenType}.");
return default; return default;
} }
} }

2
backend/src/Squidex.Infrastructure/Queries/Json/JsonFilterSurrogate.cs

@ -50,7 +50,7 @@ public sealed class JsonFilterSurrogate : ISurrogate<FilterNode<JsonValue>>
return new CompareFilter<JsonValue>(Path, Op ?? CompareOperator.Equals, Value); return new CompareFilter<JsonValue>(Path, Op ?? CompareOperator.Equals, Value);
} }
ThrowHelper.JsonException(Errors.InvalidJsonStructure()); ThrowHelper.JsonSystemException(Errors.InvalidJsonStructure());
return default!; return default!;
} }
} }

5
backend/src/Squidex.Infrastructure/ThrowHelper.cs

@ -45,4 +45,9 @@ public static class ThrowHelper
{ {
throw new NotSupportedException(message); throw new NotSupportedException(message);
} }
public static void JsonSystemException(string? message = null, Exception? ex = null)
{
throw new System.Text.Json.JsonException(message, ex);
}
} }

1
backend/src/Squidex.Web/ApiExceptionConverter.cs

@ -8,6 +8,7 @@
using System.Diagnostics; using System.Diagnostics;
using System.Security; using System.Security;
using System.Text; using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Squidex.Infrastructure; using Squidex.Infrastructure;

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

@ -29,8 +29,13 @@
</div> </div>
@if (app.canLeave) { @if (app.canLeave) {
<button class="btn btn-sm btn-text-secondary" #buttonOptions (click)="dropdown.toggle()" sqxStopClick type="button"> <button
<span class="hidden">{{ "common.options" | sqxTranslate }}</span> class="btn btn-sm btn-text-secondary"
#buttonOptions
attr.aria-label="{{ 'common.options' | sqxTranslate }}"
(click)="dropdown.toggle()"
sqxStopClick
type="button">
<i class="icon-dots"></i> <i class="icon-dots"></i>
</button> </button>
<sqx-dropdown-menu scrollY="true" [sqxAnchoredTo]="buttonOptions" *sqxModal="dropdown; closeAlways: true"> <sqx-dropdown-menu scrollY="true" [sqxAnchoredTo]="buttonOptions" *sqxModal="dropdown; closeAlways: true">

9
frontend/src/app/features/apps/pages/team.component.html

@ -7,8 +7,13 @@
<a class="link" [routerLink]="['/app/teams', team.id]" sqxStopClick>{{ "common.edit" | sqxTranslate }}</a> <a class="link" [routerLink]="['/app/teams', team.id]" sqxStopClick>{{ "common.edit" | sqxTranslate }}</a>
</div> </div>
<div class="col-auto"> <div class="col-auto">
<button class="btn btn-sm btn-text-secondary" #buttonOptions (click)="dropdown.toggle()" sqxStopClick type="button"> <button
<span class="hidden">{{ "common.options" | sqxTranslate }}</span> class="btn btn-sm btn-text-secondary"
#buttonOptions
attr.aria-label="{{ 'common.options' | sqxTranslate }}"
(click)="dropdown.toggle()"
sqxStopClick
type="button">
<i class="icon-dots"></i> <i class="icon-dots"></i>
</button> </button>

68
frontend/src/app/features/content/pages/content/content-page.component.html

@ -1,6 +1,6 @@
<sqx-title [message]="schema.displayName" [url]="['..']"></sqx-title> <sqx-title [message]="schema.displayName" [url]="['..']"></sqx-title>
<form [formGroup]="contentForm.form" (ngSubmit)="saveAndPublish()"> <form [formGroup]="contentForm.form" (ngSubmit)="saveAndPublish('Edit')">
<sqx-layout [hideSidebar]="!content" layout="main"> <sqx-layout [hideSidebar]="!content" layout="main">
<ng-container title> <ng-container title>
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
@ -76,8 +76,12 @@
[languages]="languages" [languages]="languages"
[percents]="contentForm.translationStatus | async"></sqx-language-selector> [percents]="contentForm.translationStatus | async"></sqx-language-selector>
@if (content.canDelete) { @if (content.canDelete) {
<button class="btn btn-outline-secondary ms-2" #buttonOptions (click)="dropdown.toggle()" type="button"> <button
<span class="hidden">{{ "common.options" | sqxTranslate }}</span> class="btn btn-outline-secondary ms-2"
#buttonOptions
attr.aria-label="{{ 'common.options' | sqxTranslate }}"
(click)="dropdown.toggle()"
type="button">
<i class="icon-dots"></i> <i class="icon-dots"></i>
</button> </button>
<sqx-dropdown-menu scrollY="true" [sqxAnchoredTo]="buttonOptions" *sqxModal="dropdown; closeAlways: true"> <sqx-dropdown-menu scrollY="true" [sqxAnchoredTo]="buttonOptions" *sqxModal="dropdown; closeAlways: true">
@ -115,14 +119,60 @@
[percents]="contentForm.translationStatus | async"></sqx-language-selector> [percents]="contentForm.translationStatus | async"></sqx-language-selector>
<div sqxTourStep="saveContent"> <div sqxTourStep="saveContent">
@if (contentsState.canCreate | async) { @if (contentsState.canCreate | async) {
<button class="btn btn-primary ms-2" (click)="save()" type="button"> <div class="btn-group ms-2" attr.aria-label="{{ 'common.save' | sqxTranslate }}" role="group">
{{ "common.save" | sqxTranslate }} <button class="btn btn-primary" (click)="saveAsDraft('Edit')" type="button">
</button> {{ "common.save" | sqxTranslate }}
</button>
<button
class="btn btn-primary"
#buttonSave
attr.aria-label="{{ 'common.more' | sqxTranslate }}"
(click)="saveOnlyDropdown.toggle()"
type="button">
<i class="icon-angle-down"></i>
</button>
<sqx-dropdown-menu
position="bottom-end"
scrollY="true"
[sqxAnchoredTo]="buttonSave"
*sqxModal="saveOnlyDropdown; closeAlways: true">
<a class="dropdown-item" (click)="saveAsDraft('Add')">
{{ "common.saveAdd" | sqxTranslate }}
</a>
<a class="dropdown-item" (click)="saveAsDraft('Close')">
{{ "common.saveClose" | sqxTranslate }}
</a>
</sqx-dropdown-menu>
</div>
} }
@if (contentsState.canCreateAndPublish | async) { @if (contentsState.canCreateAndPublish | async) {
<button class="btn btn-success ms-2" shortcut="CTRL + SHIFT + S" type="submit"> <div class="btn-group ms-2" attr.aria-label="{{ 'contents.saveAndPublish' | sqxTranslate }}" role="group">
{{ "contents.saveAndPublish" | sqxTranslate }} <button class="btn btn-success" shortcut="CTRL + SHIFT + S" type="submit">
</button> {{ "contents.saveAndPublish" | sqxTranslate }}
</button>
<button
class="btn btn-success"
#buttonSave
attr.aria-label="{{ 'common.more' | sqxTranslate }}"
(click)="savePublishDropdown.toggle()"
type="button">
<i class="icon-angle-down"></i>
</button>
<sqx-dropdown-menu
position="bottom-end"
scrollY="true"
[sqxAnchoredTo]="buttonSave"
*sqxModal="savePublishDropdown; closeAlways: true">
<a class="dropdown-item" (click)="saveAndPublish('Add')">
{{ "contents.saveAndPublishAdd" | sqxTranslate }}
</a>
<a class="dropdown-item" (click)="saveAndPublish('Close')">
{{ "contents.saveAndPublishClose" | sqxTranslate }}
</a>
</sqx-dropdown-menu>
</div>
} }
</div> </div>
} }

88
frontend/src/app/features/content/pages/content/content-page.component.ts

@ -18,6 +18,8 @@ import { ContentEditorComponent } from './editor/content-editor.component';
import { ContentInspectionComponent } from './inspecting/content-inspection.component'; import { ContentInspectionComponent } from './inspecting/content-inspection.component';
import { ContentReferencesComponent } from './references/content-references.component'; import { ContentReferencesComponent } from './references/content-references.component';
type SaveNavigationMode = 'Close' | 'Add' | 'Edit';
@Component({ @Component({
standalone: true, standalone: true,
selector: 'sqx-content-page', selector: 'sqx-content-page',
@ -64,6 +66,7 @@ export class ContentPageComponent implements CanComponentDeactivate, OnInit {
private readonly subscriptions = new Subscriptions(); private readonly subscriptions = new Subscriptions();
private readonly mutableContext: Record<string, any>; private readonly mutableContext: Record<string, any>;
private autoSaveKey!: AutoSaveKey; private autoSaveKey!: AutoSaveKey;
private autoSaveIgnore = false;
public schema!: SchemaDto; public schema!: SchemaDto;
@ -75,6 +78,8 @@ export class ContentPageComponent implements CanComponentDeactivate, OnInit {
public contentVersion: Version | null = null; public contentVersion: Version | null = null;
public contentForm!: EditContentForm; public contentForm!: EditContentForm;
public contentFormCompare: EditContentForm | null = null; public contentFormCompare: EditContentForm | null = null;
public saveOnlyDropdown = new ModalModel();
public savePublishDropdown = new ModalModel();
public dropdown = new ModalModel(); public dropdown = new ModalModel();
@ -237,52 +242,61 @@ export class ContentPageComponent implements CanComponentDeactivate, OnInit {
); );
} }
public saveAndPublish() { public saveAndPublish(navigationMode: SaveNavigationMode) {
this.saveContent(true); this.saveContent(true, navigationMode);
} }
public save() { public saveAsDraft(navigationMode: SaveNavigationMode) {
this.saveContent(false); this.saveContent(false, navigationMode);
} }
private saveContent(publish: boolean) { private saveContent(publish: boolean, navigationMode: SaveNavigationMode) {
const value = this.contentForm.submit(); const value = this.contentForm.submit();
if (value) { if (!value) {
if (this.content) { this.contentForm.submitFailed('i18n:contents.contentNotValid', false);
if (!this.content.canUpdate) { return;
return; }
}
this.contentsState.update(this.content, value) if (this.content) {
.subscribe({ if (!this.content.canUpdate) {
next: () => { return;
this.contentForm.submitCompleted({ noReset: true }); }
},
error: error => {
this.contentForm.submitFailed(error);
},
});
} else {
if (!this.canCreate(publish)) {
return;
}
this.contentsState.create(value, publish, this.contentId) this.contentsState.update(this.content, value)
.subscribe({ .subscribe({
next: content => { next: () => {
this.contentForm.submitCompleted({ noReset: true });
},
error: error => {
this.contentForm.submitFailed(error);
},
});
} else {
if (!this.canCreate(publish)) {
return;
}
this.contentsState.create(value, publish, this.contentId)
.subscribe({
next: content => {
if (navigationMode == 'Edit') {
this.contentForm.submitCompleted({ noReset: true }); this.contentForm.submitCompleted({ noReset: true });
this.contentForm.load(content.data, true); this.contentForm.load(content.data, true);
this.router.navigate([content.id, 'history'], { relativeTo: this.route.parent! }); this.router.navigate([content.id, 'history'], { relativeTo: this.route.parent! });
}, } else if (navigationMode === 'Close') {
error: error => { this.autoSaveIgnore = true;
this.contentForm.submitFailed(error);
}, this.router.navigate(['./'], { relativeTo: this.route.parent! });
}); } else {
} this.contentForm = new EditContentForm(this.languages, this.schema, this.schemasState.schemaMap, this.formContext);
} else { }
this.contentForm.submitFailed('i18n:contents.contentNotValid', false); },
error: error => {
this.contentForm.submitFailed(error);
},
});
} }
} }
@ -314,9 +328,9 @@ export class ContentPageComponent implements CanComponentDeactivate, OnInit {
public changeLanguage(language: AppLanguageDto) { public changeLanguage(language: AppLanguageDto) {
this.language = language; this.language = language;
this.updateContext();
this.localStore.set(this.languageKey(), language.iso2Code); this.localStore.set(this.languageKey(), language.iso2Code);
this.updateContext();
} }
public checkPendingChangesBeforePreview() { public checkPendingChangesBeforePreview() {
@ -332,7 +346,7 @@ export class ContentPageComponent implements CanComponentDeactivate, OnInit {
} }
private checkPendingChanges(text: string) { private checkPendingChanges(text: string) {
if (this.content && !this.content.canUpdate) { if ((this.content && !this.content.canUpdate) || this.autoSaveIgnore) {
return of(true); return of(true);
} }

3
frontend/src/app/features/content/shared/references/reference-item.component.html

@ -48,8 +48,7 @@
<td class="cell-actions"> <td class="cell-actions">
<div class="reference-edit"> <div class="reference-edit">
<button class="btn btn-text-secondary" type="button"> <button class="btn btn-text-secondary" attr.aria-label="{{ 'common.options' | sqxTranslate }}" type="button">
<span class="hidden">{{ "common.options" | sqxTranslate }}</span>
<i class="icon-dots"></i> <i class="icon-dots"></i>
</button> </button>

2
frontend/src/app/features/rules/pages/rules/rule.component.html

@ -14,8 +14,8 @@
<button <button
class="btn btn-text-secondary" class="btn btn-text-secondary"
#buttonOptions #buttonOptions
attr.aria-label="{{ 'common.options' | sqxTranslate }}"
(click)="dropdown.toggle()" (click)="dropdown.toggle()"
attr.aria-label="{{ 'common.options' | sqxTranslate }}"
type="button"> type="button">
<i class="icon-dots"></i> <i class="icon-dots"></i>
</button> </button>

68
frontend/src/app/features/schemas/pages/schema/fields/field-wizard.component.html

@ -88,26 +88,64 @@
@if (!editForm) { @if (!editForm) {
<div> <div>
<button class="btn btn-outline-success" (click)="addField(false)" type="button"> <div class="btn-group ms-2" attr.aria-label="{{ 'schemas.addFieldAndClose' | sqxTranslate }}" role="group">
{{ "schemas.addFieldAndClose" | sqxTranslate }} <button class="btn btn-success" (click)="addField(false)" shortcut="CTRL + SHIFT + S">
</button> {{ "schemas.addFieldAndClose" | sqxTranslate }}
<button class="btn btn-success ms-2" (click)="addField(true)" type="button"> </button>
{{ "schemas.addFieldAndCreate" | sqxTranslate }} <button
</button> class="btn btn-success"
<button class="btn btn-success ms-2" (click)="addField(false, true)" type="button"> #buttonSave
{{ "schemas.addFieldAndEdit" | sqxTranslate }} attr.aria-label="{{ 'common.more' | sqxTranslate }}"
</button> (click)="addFieldModal.toggle()"
type="button">
<i class="icon-angle-down"></i>
</button>
<sqx-dropdown-menu
position="top-end"
scrollY="true"
[sqxAnchoredTo]="buttonSave"
*sqxModal="addFieldModal; closeAlways: true">
<a class="dropdown-item" (click)="addField(true)">
{{ "schemas.addFieldAndCreate" | sqxTranslate }}
</a>
<a class="dropdown-item" (click)="addField(false, true)">
{{ "schemas.addFieldAndEdit" | sqxTranslate }}
</a>
</sqx-dropdown-menu>
</div>
</div> </div>
} }
@if (editForm) { @if (editForm) {
<div> <div>
<button class="btn btn-success" (click)="save(true)" type="button"> <div
{{ "schemas.saveFieldAndNew" | sqxTranslate }} class="btn-group ms-2"
</button> attr.aria-label="{{ 'schemas.addFieldAndClose' | sqxTranslate }}"
<button class="btn btn-primary ms-2" (click)="save()" type="button"> position="top-end"
{{ "schemas.saveFieldAndClose" | sqxTranslate }} role="group">
</button> <button class="btn btn-primary" (click)="save(true)" shortcut="CTRL + SHIFT + S">
{{ "schemas.saveFieldAndNew" | sqxTranslate }}
</button>
<button
class="btn btn-primary"
#buttonSave
attr.aria-label="{{ 'common.more' | sqxTranslate }}"
(click)="addFieldModal.toggle()"
type="button">
<i class="icon-angle-down"></i>
</button>
<sqx-dropdown-menu
position="bottom-end"
scrollY="true"
[sqxAnchoredTo]="buttonSave"
*sqxModal="addFieldModal; closeAlways: true">
<a class="dropdown-item" (click)="save()">
{{ "schemas.saveFieldAndClose" | sqxTranslate }}
</a>
</sqx-dropdown-menu>
</div>
</div> </div>
} }
</ng-container> </ng-container>

6
frontend/src/app/features/schemas/pages/schema/fields/field-wizard.component.ts

@ -8,7 +8,7 @@
import { AsyncPipe } from '@angular/common'; import { AsyncPipe } from '@angular/common';
import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'; import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { AddFieldForm, AppSettingsDto, ControlErrorsComponent, createProperties, EditFieldForm, FieldDto, fieldTypes, FocusOnInitDirective, FormAlertComponent, FormErrorComponent, FormHintComponent, LanguagesState, ModalDialogComponent, RootFieldDto, SchemaDto, SchemasState, TooltipDirective, TranslatePipe, Types } from '@app/shared'; import { AddFieldForm, AppSettingsDto, ControlErrorsComponent, createProperties, DropdownMenuComponent, EditFieldForm, FieldDto, fieldTypes, FocusOnInitDirective, FormAlertComponent, FormErrorComponent, FormHintComponent, LanguagesState, ModalDialogComponent, ModalDirective, ModalModel, ModalPlacementDirective, RootFieldDto, SchemaDto, SchemasState, TooltipDirective, TranslatePipe, Types } from '@app/shared';
import { FieldFormComponent } from './forms/field-form.component'; import { FieldFormComponent } from './forms/field-form.component';
const DEFAULT_FIELD = { name: '', partitioning: 'invariant', properties: createProperties('String') }; const DEFAULT_FIELD = { name: '', partitioning: 'invariant', properties: createProperties('String') };
@ -21,6 +21,7 @@ const DEFAULT_FIELD = { name: '', partitioning: 'invariant', properties: createP
imports: [ imports: [
AsyncPipe, AsyncPipe,
ControlErrorsComponent, ControlErrorsComponent,
DropdownMenuComponent,
FieldFormComponent, FieldFormComponent,
FocusOnInitDirective, FocusOnInitDirective,
FormAlertComponent, FormAlertComponent,
@ -28,6 +29,8 @@ const DEFAULT_FIELD = { name: '', partitioning: 'invariant', properties: createP
FormHintComponent, FormHintComponent,
FormsModule, FormsModule,
ModalDialogComponent, ModalDialogComponent,
ModalDirective,
ModalPlacementDirective,
ReactiveFormsModule, ReactiveFormsModule,
TooltipDirective, TooltipDirective,
TranslatePipe, TranslatePipe,
@ -53,6 +56,7 @@ export class FieldWizardComponent implements OnInit {
public field!: FieldDto; public field!: FieldDto;
public addFieldForm = new AddFieldForm(); public addFieldForm = new AddFieldForm();
public addFieldModal = new ModalModel();
public editForm?: EditFieldForm; public editForm?: EditFieldForm;

2
frontend/src/app/framework/angular/modals/dialog-renderer.component.html

@ -31,7 +31,7 @@
<div class="notification-container notification-container-bottom-right"> <div class="notification-container notification-container-bottom-right">
@for (notification of snapshot.notifications; track notification) { @for (notification of snapshot.notifications; track notification) {
<div class="alert alert-dismissible alert-{{ notification.messageType }} shadowed" @fade (click)="close(notification)"> <div class="alert alert-dismissible alert-{{ notification.messageType }} shadowed" @fade (click)="close(notification)" role="alert">
<button class="btn-close" data-dismiss="alert" (dialogClose)="close(notification)" type="button"></button> <button class="btn-close" data-dismiss="alert" (dialogClose)="close(notification)" type="button"></button>
<div class="shadow"></div> <div class="shadow"></div>
<div class="shadowed" [innerHTML]="notification.message | sqxTranslate | sqxMarkdown | sqxSafeHtml"></div> <div class="shadowed" [innerHTML]="notification.message | sqxTranslate | sqxMarkdown | sqxSafeHtml"></div>

20
frontend/src/app/framework/angular/pager.component.html

@ -2,7 +2,11 @@
<div class="d-flex align-items-center justify-content-end"> <div class="d-flex align-items-center justify-content-end">
@if (paging) { @if (paging) {
<div class="float-end pagination"> <div class="float-end pagination">
<select class="form-select form-select-sm" [ngModel]="paging.pageSize" (ngModelChange)="setPageSize($event)"> <select
class="form-select form-select-sm"
attr.aria-label="{{ 'common.pageSize' | sqxTranslate }}"
[ngModel]="paging.pageSize"
(ngModelChange)="setPageSize($event)">
@for (pageSize of pageSizes; track pageSize) { @for (pageSize of pageSizes; track pageSize) {
<option [ngValue]="pageSize">{{ pageSize }}</option> <option [ngValue]="pageSize">{{ pageSize }}</option>
} }
@ -20,10 +24,20 @@
{{ "common.pagerInfoNoTotal" | sqxTranslate: translationInfo }} {{ "common.pagerInfoNoTotal" | sqxTranslate: translationInfo }}
</button> </button>
} }
<button class="btn btn-sm btn-text-secondary ms-2" (click)="goPrev()" [disabled]="!canGoPrev" type="button"> <button
class="btn btn-sm btn-text-secondary ms-2"
attr.aria-label="{{ 'common.prevPage' | sqxTranslate }}"
(click)="goPrev()"
[disabled]="!canGoPrev"
type="button">
<i class="icon-angle-left"></i> <i class="icon-angle-left"></i>
</button> </button>
<button class="btn btn-sm btn-text-secondary ms-2" (click)="goNext()" [disabled]="!canGoNext" type="button"> <button
class="btn btn-sm btn-text-secondary ms-2"
attr.aria-label="{{ 'common.nextPage' | sqxTranslate }}"
(click)="goNext()"
[disabled]="!canGoNext"
type="button">
<i class="icon-angle-right"></i> <i class="icon-angle-right"></i>
</button> </button>
</span> </span>

13
frontend/src/app/framework/services/localizer.service.ts

@ -10,19 +10,11 @@ import { compareStrings } from '../utils/array-helper';
@Injectable() @Injectable()
export class LocalizerService { export class LocalizerService {
private shouldLog = false;
constructor( constructor(
private readonly translations: Object, private readonly translations: Object,
) { ) {
} }
public logMissingKeys(shouldLog = true) {
this.shouldLog = shouldLog;
return this;
}
public getOrKey(key: string | undefined, args?: any): string { public getOrKey(key: string | undefined, args?: any): string {
return this.get(key, args) || key || ''; return this.get(key, args) || key || '';
} }
@ -39,11 +31,6 @@ export class LocalizerService {
let text = (this.translations as any)[key]; let text = (this.translations as any)[key];
if (!text) { if (!text) {
if (this.shouldLog && !key.includes(' ')) {
// eslint-disable-next-line no-console
console.warn(`Missing i18n key: ${key}`);
}
return null; return null;
} }

8
frontend/src/app/shared/components/assets/asset-folder.component.html

@ -9,8 +9,12 @@
</div> </div>
<div class="col-auto"> <div class="col-auto">
@if ((canDelete || canUpdate) && !isDisabled) { @if ((canDelete || canUpdate) && !isDisabled) {
<button class="btn btn-sm btn-text-secondary" #buttonOptions (click)="editDropdown.toggle()" type="button"> <button
<span class="hidden">{{ "common.options" | sqxTranslate }}</span> class="btn btn-sm btn-text-secondary"
#buttonOptions
attr.aria-label="{{ 'common.options' | sqxTranslate }}"
(click)="editDropdown.toggle()"
type="button">
<i class="icon-dots"></i> <i class="icon-dots"></i>
</button> </button>
<sqx-dropdown-menu scrollY="true" [sqxAnchoredTo]="buttonOptions" *sqxModal="editDropdown; closeAlways: true"> <sqx-dropdown-menu scrollY="true" [sqxAnchoredTo]="buttonOptions" *sqxModal="editDropdown; closeAlways: true">

1
frontend/src/environments/environment.prod.ts

@ -1,6 +1,5 @@
export const environment = { export const environment = {
production: true, production: true,
textLogger: false,
textResolver: () => { textResolver: () => {
return (window as any)['texts']; return (window as any)['texts'];
}, },

1
frontend/src/environments/environment.ts

@ -5,7 +5,6 @@ declare var require: any;
export const environment = { export const environment = {
production: false, production: false,
textLogger: true,
textResolver: () => { textResolver: () => {
const culture = (window as any)['options']?.culture || 'en'; const culture = (window as any)['options']?.culture || 'en';

4
frontend/src/main.ts

@ -11,12 +11,12 @@ import { enableProdMode, ErrorHandler } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser'; import { bootstrapApplication } from '@angular/platform-browser';
import { provideAnimations } from '@angular/platform-browser/animations'; import { provideAnimations } from '@angular/platform-browser/animations';
import { ActivatedRouteSnapshot, BaseRouteReuseStrategy, provideRouter, RouteReuseStrategy } from '@angular/router'; import { ActivatedRouteSnapshot, BaseRouteReuseStrategy, provideRouter, RouteReuseStrategy } from '@angular/router';
import { provideCharts, withDefaultRegisterables } from 'ng2-charts';
import { TourService as BaseTourService } from 'ngx-ui-tour-core'; import { TourService as BaseTourService } from 'ngx-ui-tour-core';
import { APP_ROUTES } from '@app/app.routes'; import { APP_ROUTES } from '@app/app.routes';
import { ApiUrlConfig, authInterceptor, buildTasks, cachingInterceptor, DateHelper, GlobalErrorHandler, loadingInterceptor, LocalizerService, TASK_CONFIGURATION, TitlesConfig, TourService, UIOptions } from '@app/shared'; import { ApiUrlConfig, authInterceptor, buildTasks, cachingInterceptor, DateHelper, GlobalErrorHandler, loadingInterceptor, LocalizerService, TASK_CONFIGURATION, TitlesConfig, TourService, UIOptions } from '@app/shared';
import { AppComponent } from './app/app.component'; import { AppComponent } from './app/app.component';
import { environment } from './environments/environment'; import { environment } from './environments/environment';
import { provideCharts, withDefaultRegisterables } from 'ng2-charts';
const options = (window as any)['options'] || {}; const options = (window as any)['options'] || {};
@ -77,7 +77,7 @@ function configTitles() {
} }
function configLocalizerService() { function configLocalizerService() {
return new LocalizerService(environment.textResolver()).logMissingKeys(environment.textLogger); return new LocalizerService(environment.textResolver());
} }
export class AppRouteReuseStrategy extends BaseRouteReuseStrategy { export class AppRouteReuseStrategy extends BaseRouteReuseStrategy {

3
tools/e2e/playwright.config.ts

@ -29,6 +29,9 @@ export default defineConfig({
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry', trace: 'on-first-retry',
/* Keep the test report small. See https://playwright.dev/docs/api/class-testoptions#test-options-screenshot */
screenshot: 'only-on-failure',
}, },
/* Configure projects for major browsers */ /* Configure projects for major browsers */

3
tools/e2e/tests/given-app/rules.spec.ts

@ -2,6 +2,9 @@ import { expect, Page } from '@playwright/test';
import { escapeRegex, getRandomId } from '../utils'; import { escapeRegex, getRandomId } from '../utils';
import { test } from './_fixture'; import { test } from './_fixture';
// We have no easy way to identity rules. Therefore run them sequentially.
test.describe.configure({ mode: 'serial' });
test.beforeEach(async ({ page, appName }) => { test.beforeEach(async ({ page, appName }) => {
await page.goto(`/app/${appName}/rules`); await page.goto(`/app/${appName}/rules`);
}); });

2
tools/e2e/tests/given-app/schemas.spec.ts

@ -68,7 +68,7 @@ async function createRandomField(page: Page) {
await page.getByPlaceholder('Enter field name').fill(fieldName); await page.getByPlaceholder('Enter field name').fill(fieldName);
// Save field. // Save field.
await page.getByRole('button', { name: 'Create and close' }).click(); await page.getByTestId('dialog').getByRole('button', { name: 'Create' }).click();
return fieldName; return fieldName;
} }

2
tools/e2e/tests/given-schema/_setup.ts

@ -32,7 +32,7 @@ setup('prepare schema', async ({ page, appName }) => {
await page.getByPlaceholder('Enter field name').fill(field.name); await page.getByPlaceholder('Enter field name').fill(field.name);
// Save field. // Save field.
await page.getByRole('button', { name: 'Create and close' }).click(); await page.getByTestId('dialog').getByRole('button', { name: 'Create' }).click();
} }
// Publish schema. // Publish schema.

104
tools/e2e/tests/given-schema/contents.spec.ts

@ -11,24 +11,45 @@ import { expect, test } from './_fixture';
test.beforeEach(async ({ page, appName, schemaName }) => { test.beforeEach(async ({ page, appName, schemaName }) => {
await page.goto(`/app/${appName}/content/${schemaName}`); await page.goto(`/app/${appName}/content/${schemaName}`);
await page.getByRole('combobox').selectOption('3: 50');
}); });
test('create content', async ({ page }) => { test('create content and close', async ({ page }) => {
const contentText = await createRandomContent(page); const contentText = await createRandomContent(page, 'SaveAndClose');
const contentRow = page.locator('tr', { hasText: escapeRegex(contentText) }); const contentRow = page.locator('tr', { hasText: escapeRegex(contentText) });
await expect(contentRow).toBeVisible(); await expect(contentRow).toBeVisible();
}); });
test('create content as published', async ({ page }) => { test('create content and edit', async ({ page }) => {
const contentText = await createRandomContent(page, true); await createRandomContent(page, 'SaveAndEdit');
await expect(page.getByRole('button', { name: /Draft/ })).toBeVisible();
await expect(page.getByLabel('Identity')).toBeVisible();
});
test('create content and add another', async ({ page }) => {
await createRandomContent(page, 'SaveAndAdd');
await expect(page.locator('sqx-field-editor').getByRole('textbox')).toBeEmpty();
});
test('create content as published and edit', async ({ page }) => {
await createRandomContent(page, 'SavePublishAndEdit');
await expect(page.getByRole('button', { name: /Published/ })).toBeVisible();
await expect(page.getByLabel('Identity')).toBeVisible();
});
test('create content as published and close', async ({ page }) => {
const contentText = await createRandomContent(page, 'SavePublishAndClose');
const contentRow = page.locator('tr', { hasText: escapeRegex(contentText) }); const contentRow = page.locator('tr', { hasText: escapeRegex(contentText) });
await expect(contentRow.getByLabel('Published')).toBeVisible(); await expect(contentRow.getByLabel('Published')).toBeVisible();
}); });
test('update content', async ({ page }) => { test('update content', async ({ page }) => {
const contentText = await createRandomContent(page); const contentText = await createRandomContent(page, 'SaveAndClose');
const contentRow = page.locator('tr', { hasText: escapeRegex(contentText) }); const contentRow = page.locator('tr', { hasText: escapeRegex(contentText) });
await contentRow.click(); await contentRow.click();
@ -37,7 +58,7 @@ test('update content', async ({ page }) => {
// Define content value. // Define content value.
await page.locator('sqx-field-editor').getByRole('textbox').fill(contentUpdate); await page.locator('sqx-field-editor').getByRole('textbox').fill(contentUpdate);
await saveContent(page, false); await saveContent(page, 'Save');
await page.getByRole('button', { name: 'Save', exact: true }).click(); await page.getByRole('button', { name: 'Save', exact: true }).click();
@ -67,8 +88,10 @@ const states = [{
}]; }];
states.forEach(({ state, currentState, initialPublished }) => { states.forEach(({ state, currentState, initialPublished }) => {
const mode: SaveMode = initialPublished ? 'SavePublishAndClose' : 'SaveAndClose';
test(`change content to ${state}`, async ({ dropdown, page }) => { test(`change content to ${state}`, async ({ dropdown, page }) => {
const contentText = await createRandomContent(page, initialPublished); const contentText = await createRandomContent(page, mode);
const contentRow = page.locator('tr', { hasText: escapeRegex(contentText) }); const contentRow = page.locator('tr', { hasText: escapeRegex(contentText) });
await contentRow.getByLabel('Options').click(); await contentRow.getByLabel('Options').click();
@ -79,7 +102,7 @@ states.forEach(({ state, currentState, initialPublished }) => {
}); });
test(`change content to ${state} by checkbox`, async ({ page }) => { test(`change content to ${state} by checkbox`, async ({ page }) => {
const contentText = await createRandomContent(page, initialPublished); const contentText = await createRandomContent(page, mode);
const contentRow = page.locator('tr', { hasText: escapeRegex(contentText) }); const contentRow = page.locator('tr', { hasText: escapeRegex(contentText) });
await contentRow.getByRole('checkbox').click(); await contentRow.getByRole('checkbox').click();
@ -90,7 +113,7 @@ states.forEach(({ state, currentState, initialPublished }) => {
}); });
test(`change content to ${state} by detail page`, async ({ page }) => { test(`change content to ${state} by detail page`, async ({ page }) => {
const contentText = await createRandomContent(page, initialPublished); const contentText = await createRandomContent(page, mode);
const contentRow = page.locator('tr', { hasText: escapeRegex(contentText) }); const contentRow = page.locator('tr', { hasText: escapeRegex(contentText) });
await contentRow.click(); await contentRow.click();
@ -103,7 +126,16 @@ states.forEach(({ state, currentState, initialPublished }) => {
}); });
test('delete content', async ({ dropdown, page }) => { test('delete content', async ({ dropdown, page }) => {
const contentText = await createRandomContent(page); await createRandomContent(page, 'SaveAndEdit');
await page.getByLabel('Options').click();
await dropdown.delete();
await expect(page.getByLabel('Identity')).not.toBeVisible();
});
test('delete content by options', async ({ dropdown, page }) => {
const contentText = await createRandomContent(page, 'SaveAndClose');
const contentRow = page.locator('tr', { hasText: escapeRegex(contentText) }); const contentRow = page.locator('tr', { hasText: escapeRegex(contentText) });
await contentRow.getByLabel('Options').click(); await contentRow.getByLabel('Options').click();
@ -113,7 +145,7 @@ test('delete content', async ({ dropdown, page }) => {
}); });
test('delete content by checkbox', async ({ page }) => { test('delete content by checkbox', async ({ page }) => {
const contentText = await createRandomContent(page); const contentText = await createRandomContent(page, 'SaveAndClose');
const contentRow = page.locator('tr', { hasText: escapeRegex(contentText) }); const contentRow = page.locator('tr', { hasText: escapeRegex(contentText) });
await contentRow.getByRole('checkbox').click(); await contentRow.getByRole('checkbox').click();
@ -123,28 +155,56 @@ test('delete content by checkbox', async ({ page }) => {
await expect(contentRow).not.toBeVisible(); await expect(contentRow).not.toBeVisible();
}); });
async function createRandomContent(page: Page, publish = false) { async function createRandomContent(page: Page, mode: SaveMode) {
const contentText = `content-${getRandomId()}`; const contentText = `content-${getRandomId()}`;
await page.getByRole('button', { name: /New/ }).click(); await page.getByRole('button', { name: /New/ }).click();
// Define content value. // Define content value.
await page.locator('sqx-field-editor').getByRole('textbox').fill(contentText); await page.locator('sqx-field-editor').getByRole('textbox').fill(contentText);
await saveContent(page, publish); await saveContent(page, mode);
// Wait for the success alert. // Wait for the success alert.
await page.getByLabel('Identity').waitFor({ state: 'visible' }); await page.getByRole('alert').getByText('Content created successfully.').waitFor({ state: 'visible' });
// Go back
await page.getByLabel('Back').click();
return contentText; return contentText;
} }
async function saveContent(page: Page, publish: boolean) { type SaveMode =
if (publish) { 'Save' |
await page.getByRole('button', { name: 'Save and Publish', exact: true }).click(); 'SaveAndAdd' |
} else { 'SaveAndClose' |
await page.getByRole('button', { name: 'Save', exact: true }).click(); 'SaveAndEdit' |
'SavePublishAndAdd' |
'SavePublishAndClose' |
'SavePublishAndEdit';
async function saveContent(page: Page, mode: SaveMode) {
switch (mode) {
case 'SaveAndAdd':
await page.getByLabel('Save', { exact: true }).getByLabel('More').click();
await page.getByText('Save & add another').click();
break;
case 'SaveAndClose':
await page.getByLabel('Save', { exact: true }).getByLabel('More').click();
await page.getByText('Save & close').click();
break;
case 'SaveAndEdit':
await page.getByRole('button', { name: 'Save', exact: true }).click();
break;
case 'SavePublishAndAdd':
await page.getByLabel('Save and Publish').getByLabel('More').click();
await page.getByText('Save and Publish & add another').click();
break;
case 'SavePublishAndClose':
await page.getByLabel('Save and Publish').getByLabel('More').click();
await page.getByText('Save and Publish & close').click();
break;
case 'SavePublishAndEdit':
await page.getByRole('button', { name: 'Save and Publish' }).click();
break;
case 'Save':
await page.getByRole('button', { name: 'Save', exact: true }).click();
break;
} }
} }

Loading…
Cancel
Save