diff --git a/zero.Core/Api/ApplicationsApi.cs b/zero.Core/Api/ApplicationsApi.cs index 05c647a5..24bc2bcd 100644 --- a/zero.Core/Api/ApplicationsApi.cs +++ b/zero.Core/Api/ApplicationsApi.cs @@ -1,8 +1,12 @@ -using Raven.Client.Documents; +using FluentValidation.Results; +using Raven.Client.Documents; using Raven.Client.Documents.Session; +using System; using System.Collections.Generic; using System.Threading.Tasks; using zero.Core.Entities; +using zero.Core.Extensions; +using zero.Core.Validation; namespace zero.Core.Api { @@ -17,22 +21,117 @@ namespace zero.Core.Api } + /// + public async Task GetById(string id) + { + using (IAsyncDocumentSession session = Raven.OpenAsyncSession()) + { + return await session.LoadAsync(id); + } + } + + /// public async Task> GetAll() { using (IAsyncDocumentSession session = Raven.OpenAsyncSession()) { - return await session.Query().ToListAsync(); + return await session + .Query() + .OrderByDescending(x => x.CreatedDate) + .ToListAsync(); } } + + + /// + public async Task> GetByQuery(ListQuery query) + { + query.SearchFor(entity => entity.Name); + + using (IAsyncDocumentSession session = Raven.OpenAsyncSession()) + { + return await session.Query() + .ToQueriedListAsync(query); + } + } + + + /// + public async Task> Save(Application model) + { + ValidationResult validation = await new ApplicationValidator().ValidateAsync(model); + + if (!validation.IsValid) + { + return EntityResult.Fail(validation); + } + + if (model.Id.IsNullOrEmpty()) + { + model.AppId = Constants.Database.SharedAppId; + model.CreatedDate = DateTimeOffset.Now; + } + + model.Alias = Alias.Generate(model.Name); + + using (IAsyncDocumentSession session = Raven.OpenAsyncSession()) + { + await session.StoreAsync(model); + await session.SaveChangesAsync(); + } + + return EntityResult.Success(model); + } + + + /// + public async Task> Delete(string id) + { + using (IAsyncDocumentSession session = Raven.OpenAsyncSession()) + { + Application entity = await session.LoadAsync(id); + + if (entity == null) + { + return EntityResult.Fail("@errors.ondelete.idnotfound"); + } + + session.Delete(entity); + + await session.SaveChangesAsync(); + } + + return EntityResult.Success(); + } } public interface IApplicationsApi { + /// + /// Get application by Id + /// + Task GetById(string id); + /// /// Get all available zero applications /// Task> GetAll(); + + /// + /// Get all available applications (with query) + /// + Task> GetByQuery(ListQuery query); + + /// + /// Creates or updates a application + /// + Task> Save(Application model); + + /// + /// Deletes a application by Id + /// + Task> Delete(string id); } } diff --git a/zero.Core/Entities/Applications/Application.cs b/zero.Core/Entities/Applications/Application.cs index 1cc42350..9477a507 100644 --- a/zero.Core/Entities/Applications/Application.cs +++ b/zero.Core/Entities/Applications/Application.cs @@ -1,17 +1,29 @@ -namespace zero.Core.Entities +using System.Collections.Generic; + +namespace zero.Core.Entities { /// /// An application is a website. zero can host multiple websites at once which share common assets /// public class Application : DatabaseEntity { + /// + /// Full company or product name + /// + public string FullName { get; set; } + + /// + /// Generic contact email. Can be used in various locations + /// + public string Email { get; set; } + /// /// Image of the application /// public Media Image { get; set; } /// - /// Simple image of the application (used of favicon) + /// Simple image of the application (used as favicon) /// public Media Icon { get; set; } @@ -19,5 +31,11 @@ /// All assigned domains for this application /// public string[] Domains { get; set; } = new string[] { }; + + /// + /// Features which are enabled for this application. + /// Can be user-defined and affect both backoffice and frontend + /// + public List Features { get; set; } = new List(); } } \ No newline at end of file diff --git a/zero.Core/Entities/Features/Feature.cs b/zero.Core/Entities/Features/Feature.cs new file mode 100644 index 00000000..7e249bf2 --- /dev/null +++ b/zero.Core/Entities/Features/Feature.cs @@ -0,0 +1,15 @@ +namespace zero.Core.Entities +{ + /// + public class Feature : IFeature + { + /// + public string Alias { get; set; } + + /// + public string Name { get; set; } + + /// + public string Description { get; set; } + } +} diff --git a/zero.Core/Entities/Features/FeatureCollection.cs b/zero.Core/Entities/Features/FeatureCollection.cs new file mode 100644 index 00000000..77271b8d --- /dev/null +++ b/zero.Core/Entities/Features/FeatureCollection.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; + +namespace zero.Core.Entities +{ + public class FeatureCollection : List + { + public void Add() where T : IFeature, new() + { + Add(new T()); + } + + public void Add(string alias, string name, string description) + { + Add(new Feature() + { + Alias = alias, + Name = name, + Description = description + }); + } + } +} diff --git a/zero.Core/Entities/Features/IFeature.cs b/zero.Core/Entities/Features/IFeature.cs new file mode 100644 index 00000000..4c6f8ef2 --- /dev/null +++ b/zero.Core/Entities/Features/IFeature.cs @@ -0,0 +1,23 @@ +namespace zero.Core.Entities +{ + /// + /// A feature can affect both the backoffice and the frontend + /// + public interface IFeature + { + /// + /// The alias + /// + string Alias { get; } + + /// + /// The name of the feature + /// + string Name { get; } + + /// + /// Additional description + /// + string Description { get; } + } +} diff --git a/zero.Core/Validation/ApplicationValidator.cs b/zero.Core/Validation/ApplicationValidator.cs new file mode 100644 index 00000000..645c48fb --- /dev/null +++ b/zero.Core/Validation/ApplicationValidator.cs @@ -0,0 +1,16 @@ +using FluentValidation; +using zero.Core.Entities; +using zero.Core.Entities.Setup; +using zero.Core.Extensions; + +namespace zero.Core.Validation +{ + public class ApplicationValidator : AbstractValidator + { + public ApplicationValidator() + { + //RuleFor(x => x.Code).Length(2); + //RuleFor(x => x.Name).Length(2, 120); + } + } +} diff --git a/zero.Core/ZeroOptions.cs b/zero.Core/ZeroOptions.cs index 3a9b6c2d..d3a38748 100644 --- a/zero.Core/ZeroOptions.cs +++ b/zero.Core/ZeroOptions.cs @@ -17,6 +17,8 @@ namespace zero.Core public IList SettingsAreas { get; private set; } = new List(); public ZeroAuthorizationOptions Authorization { get; private set; } = new ZeroAuthorizationOptions(); + + public FeatureCollection Features { get; private set; } = new FeatureCollection(); } diff --git a/zero.TestData/Features.cs b/zero.TestData/Features.cs new file mode 100644 index 00000000..4e83c3f6 --- /dev/null +++ b/zero.TestData/Features.cs @@ -0,0 +1,9 @@ +namespace zero.TestData +{ + public static class Features + { + public const string Wishlist = "testdata.wishlist"; + + public const string SocialShopping = "testdata.socialshopping"; + } +} diff --git a/zero.Web/App/Components/Forms/input-list.vue b/zero.Web/App/Components/Forms/input-list.vue new file mode 100644 index 00000000..2e31e0fc --- /dev/null +++ b/zero.Web/App/Components/Forms/input-list.vue @@ -0,0 +1,114 @@ + + + + + + \ No newline at end of file diff --git a/zero.Web/App/Components/Forms/property.vue b/zero.Web/App/Components/Forms/property.vue index 8d13d2e0..0982f076 100644 --- a/zero.Web/App/Components/Forms/property.vue +++ b/zero.Web/App/Components/Forms/property.vue @@ -31,7 +31,7 @@ \ No newline at end of file diff --git a/zero.Web/App/Components/Forms/toggle.vue b/zero.Web/App/Components/Forms/toggle.vue index b76334d3..d6520714 100644 --- a/zero.Web/App/Components/Forms/toggle.vue +++ b/zero.Web/App/Components/Forms/toggle.vue @@ -90,18 +90,18 @@ transition: all 0.2s ease; } - &.is-active i + &.is-active { - background: var(--color-primary); - transform: translateX(18px); /*background: var(--color-primary); + transform: translateX(18px);*/ + background: var(--color-primary); border-color: transparent; i { background: var(--color-primary-fg); transform: translateX(18px); - }*/ + } } } diff --git a/zero.Web/App/Components/Tabs/tabs.vue b/zero.Web/App/Components/Tabs/tabs.vue index 2ced8f15..3a5a2858 100644 --- a/zero.Web/App/Components/Tabs/tabs.vue +++ b/zero.Web/App/Components/Tabs/tabs.vue @@ -4,6 +4,7 @@
@@ -97,22 +98,28 @@ .ui-tabs-list { - border-bottom: 1px solid var(--color-line); - padding: 0 10px; + /*border-bottom: 1px solid var(--color-line);*/ + padding: var(--padding) var(--padding) 0; + margin-bottom: calc(var(--padding) * -1); } .ui-tabs-list-item { display: inline-flex; align-items: center; - height: 60px; - padding: 0 20px; - margin: 0; + height: 58px; + padding: 0 var(--padding); font-size: var(--font-size); - color: var(--color-fg-light); + color: var(--color-fg); position: relative; - overflow: hidden; transition: color 0.2s ease; + border-radius: var(--radius) var(--radius) 0 0; + background: rgba(white, 0.5); // TODO + + & + .ui-tabs-list-item + { + margin-left: 4px; + } &:hover { @@ -121,7 +128,14 @@ &:before { - display: none; + content: ''; + width: 100%; + height: 3px; + position: absolute; + left: 0; + top: 0; + background: transparent; + border-radius: var(--radius) var(--radius) 0 0; } &[disabled] @@ -134,25 +148,48 @@ { content: ''; height: 4px; - border-radius: 4px 4px 0 0; - background: var(--color-line); + width: 4px; position: absolute; - left: 18px; - right: 18px; - bottom: 0; - transform: translateY(5px) scaleX(0.5); - transition: transform 0.2s ease; + left: 0; + bottom: -4px; } &.is-active { font-weight: 700; color: var(--color-fg); + background: var(--color-bg-light); + + .ui-tabs-list-item-count + { + background: var(--color-bg); + } + + &:before + { + background: var(--color-line); + } &:after { - transform: translateY(0) scaleX(1); + background: var(--color-bg-light); } } } + + .ui-tabs-list-item-count + { + font-style: normal; + font-size: 12px; + overflow: hidden; + float: right; + padding: 2px 6px; + background: var(--color-bg-light); + border-radius: 10px; + margin-left: 8px; + margin-right: -4px; + margin-top: -1px; + font-weight: bold; + color: var(--color-fg); + } \ No newline at end of file diff --git a/zero.Web/App/Pages/settings/application.vue b/zero.Web/App/Pages/settings/application.vue new file mode 100644 index 00000000..5b95479a --- /dev/null +++ b/zero.Web/App/Pages/settings/application.vue @@ -0,0 +1,175 @@ + + + + + + \ No newline at end of file diff --git a/zero.Web/App/Pages/settings/applications.vue b/zero.Web/App/Pages/settings/applications.vue new file mode 100644 index 00000000..7bb35f5e --- /dev/null +++ b/zero.Web/App/Pages/settings/applications.vue @@ -0,0 +1,95 @@ + + + + + + + \ No newline at end of file diff --git a/zero.Web/App/Resources/applications.js b/zero.Web/App/Resources/applications.js index 022310ac..da8787d8 100644 --- a/zero.Web/App/Resources/applications.js +++ b/zero.Web/App/Resources/applications.js @@ -1,10 +1,42 @@ import Axios from 'axios'; +const base = 'applications/'; + export default { - // get all applications - getAll() + // get application by id + getById(id) { - return Axios.get('applications/getAll').then(res => Promise.resolve(res.data)); + return Axios.get(base + 'getById', { params: { id } }).then(res => Promise.resolve(res.data)); + }, + + // get new application model + getEmpty() + { + return Axios.get(base + 'getEmpty').then(res => Promise.resolve(res.data)); + }, + + // get all applications + getAll(query) + { + return Axios.get(base + 'getAll', { params: query }).then(res => Promise.resolve(res.data)); + }, + + // get all application features + getAllFeatures() + { + return Axios.get(base + 'getAllFeatures').then(res => Promise.resolve(res.data)); + }, + + // save an application + save(model) + { + return Axios.post(base + 'save', model).then(res => Promise.resolve(res.data)); + }, + + // deletes an application + delete(id) + { + return Axios.delete(base + 'delete', { params: { id } }).then(res => Promise.resolve(res.data)); } }; \ No newline at end of file diff --git a/zero.Web/App/pages/settings/routes.js b/zero.Web/App/pages/settings/routes.js index 776eb6fb..e8652d23 100644 --- a/zero.Web/App/pages/settings/routes.js +++ b/zero.Web/App/pages/settings/routes.js @@ -12,6 +12,7 @@ detailPages[zero.alias.settings.users] = [ detailPages[zero.alias.settings.countries] = 'country'; detailPages[zero.alias.settings.translations] = 'translations'; detailPages[zero.alias.settings.languages] = 'language'; +detailPages[zero.alias.settings.applications] = 'application'; if (section) { diff --git a/zero.Web/Controllers/ApplicationsController.cs b/zero.Web/Controllers/ApplicationsController.cs index 93807dfe..411887de 100644 --- a/zero.Web/Controllers/ApplicationsController.cs +++ b/zero.Web/Controllers/ApplicationsController.cs @@ -1,24 +1,92 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using System.Globalization; +using System.Linq; using System.Threading.Tasks; using zero.Core; using zero.Core.Api; +using zero.Core.Entities; +using zero.Core.Extensions; +using zero.Core.Identity; +using zero.Web.Mapper; +using zero.Web.Models; namespace zero.Web.Controllers { + [ZeroAuthorize(Permissions.Settings.Applications, PermissionsValue.Read)] public class ApplicationsController : BackofficeController { private IApplicationsApi Api { get; set; } - public ApplicationsController(IZeroConfiguration config, IApplicationsApi api) : base(config) + private ZeroOptions Options { get; set; } + + + public ApplicationsController(IZeroConfiguration config, IApplicationsApi api, IMapper mapper, IToken token, IOptionsMonitor options) : base(config, mapper, token) { Api = api; + Options = options.CurrentValue; } - public async Task GetAll() + /// + /// Get translation by id + /// + public IActionResult GetEmpty() { - return Json(await Api.GetAll()); + return Json(new ApplicationEditModel()); + } + + + /// + /// Get translation by id + /// + public async Task GetById([FromQuery] string id) + { + return await As(await Api.GetById(id)); + } + + + /// + /// Get all translations + /// + public async Task GetAll([FromQuery] ListQuery query = null) + { + if (query == null) + { + return await As(await Api.GetAll()); + } + + return await As(await Api.GetByQuery(query)); + } + + + /// + /// Get all available features to select + /// + public IActionResult GetAllFeatures() + { + return Json(Options.Features); + } + + + /// + /// Save translation + /// + [ZeroAuthorize(Permissions.Settings.Applications, PermissionsValue.Write)] + public async Task Save([FromBody] ApplicationEditModel model) + { + Application entity = await Mapper.Map(model, await Api.GetById(model.Id)); + return await As(await Api.Save(entity)); + } + + + /// + /// Deletes a translation + /// + [ZeroAuthorize(Permissions.Settings.Applications, PermissionsValue.Write)] + public async Task Delete([FromQuery] string id) + { + return await As(await Api.Delete(id)); } } } diff --git a/zero.Web/Mapper/ApplicationMapperConfig.cs b/zero.Web/Mapper/ApplicationMapperConfig.cs new file mode 100644 index 00000000..b6d12b69 --- /dev/null +++ b/zero.Web/Mapper/ApplicationMapperConfig.cs @@ -0,0 +1,47 @@ +using zero.Core.Entities; +using zero.Web.Models; + +namespace zero.Web.Mapper +{ + public class ApplicationMapperConfig : IMapperConfig + { + /// + public void Configure(IMapper config) + { + config.CreateMap((source, target) => + { + target.Id = source.Id; + target.Name = source.Name; + target.FullName = source.FullName; + target.Email = source.Email; + target.IsActive = source.IsActive; + target.CreatedDate = source.CreatedDate; + target.Domains = source.Domains; + target.Image = source.Image; + target.Icon = source.Icon; + target.Features = source.Features; + }); + + config.CreateMap((source, target) => + { + target.Name = source.Name; + target.FullName = source.FullName; + target.Email = source.Email; + target.IsActive = source.IsActive; + target.Domains = source.Domains; + target.Image = source.Image; + target.Icon = source.Icon; + target.Features = source.Features; + }); + + config.CreateMap((source, target) => + { + target.Id = source.Id; + target.Name = source.Name; + target.IsActive = source.IsActive; + target.Domains = source.Domains; + target.Image = source.Image?.Source; + }); + } + } +} diff --git a/zero.Web/Models/ApplicationEditModel.cs b/zero.Web/Models/ApplicationEditModel.cs new file mode 100644 index 00000000..d92470e6 --- /dev/null +++ b/zero.Web/Models/ApplicationEditModel.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using zero.Core.Entities; + +namespace zero.Web.Models +{ + public class ApplicationEditModel : EditModel + { + public string Name { get; set; } + + public Media Image { get; set; } + + public Media Icon { get; set; } + + public string[] Domains { get; set; } = new string[] { }; + + public string FullName { get; set; } + + public string Email { get; set; } + + public List Features { get; set; } = new List(); + } +} diff --git a/zero.Web/Models/ApplicationListModel.cs b/zero.Web/Models/ApplicationListModel.cs new file mode 100644 index 00000000..0145d355 --- /dev/null +++ b/zero.Web/Models/ApplicationListModel.cs @@ -0,0 +1,13 @@ +namespace zero.Web.Models +{ + public class ApplicationListModel : ListModel + { + public string Name { get; set; } + + public string Image { get; set; } + + public string[] Domains { get; set; } + + public bool IsActive { get; set; } + } +} diff --git a/zero.Web/Resources/Localization/zero.en-us.json b/zero.Web/Resources/Localization/zero.en-us.json index cd0a2198..c0ccb50a 100644 --- a/zero.Web/Resources/Localization/zero.en-us.json +++ b/zero.Web/Resources/Localization/zero.en-us.json @@ -5,6 +5,7 @@ "ui": { "more": "More", + "add": "Add", "actions": "Actions", "back": "Go back", "close": "Close", @@ -260,6 +261,28 @@ "title": "Pick an icon" }, + "application": { + "list": "Applications", + "name": "Application", + "tab_features": "Features", + "tab_domains": "Domains", + "fields": { + "name_text": "Short human-readable name", + "fullName": "Full name", + "fullName_text": "Full company or product name", + "image": "Image", + "image_text": "Image of the app (with transparent background), used for all mails, PDFs, ...", + "icon": "Icon", + "icon_text": "Icon of the app. Simplified image for minor application parts", + "email": "Email", + "email_text": "Generic contact email", + "domains": "Domains", + "domains_text": "Select domains for this application", + "domains_help": "Valid domain names are: \"example.com\", \"www.example.com\", \"example.com:8080\", or \"https://www.example.com\".", + "domains_add": "Add domain" + } + }, + "_test": { "fields": { "name": "Name", diff --git a/zero.Web/Sass/Core/_forms.scss b/zero.Web/Sass/Core/_forms.scss index e837e1e4..444f3835 100644 --- a/zero.Web/Sass/Core/_forms.scss +++ b/zero.Web/Sass/Core/_forms.scss @@ -18,7 +18,7 @@ textarea, .ui-native-select padding: 6px 12px; line-height: 1.5; color: var(--color-fg); - border-radius: 3px; + border-radius: var(--radius); vertical-align: middle; box-sizing: border-box; width: 100%; @@ -55,7 +55,7 @@ textarea top: 0; bottom: 0; width: 100%; - padding-left: 12px; + padding-left: 9px; -webkit-appearance: none; border: none; } diff --git a/zero.Web/Sass/Modules/Buttons/_button.scss b/zero.Web/Sass/Modules/Buttons/_button.scss index 89a67ec6..7378f06c 100644 --- a/zero.Web/Sass/Modules/Buttons/_button.scss +++ b/zero.Web/Sass/Modules/Buttons/_button.scss @@ -287,7 +287,7 @@ button::-moz-focus-inner { width: 50px; height: 50px; - line-height: 50px; + line-height: 50px !important; border-radius: var(--radius); background: var(--color-bg-mid); color: var(--color-fg); diff --git a/zero.Web/Startup.cs b/zero.Web/Startup.cs index 368e0811..6117d968 100644 --- a/zero.Web/Startup.cs +++ b/zero.Web/Startup.cs @@ -96,6 +96,9 @@ namespace zero.Web opts.Spaces.AddSeparator(); opts.Spaces.AddEditor("social", "Social", "Links to social media", "fth-twitter"); + opts.Features.Add(Features.Wishlist, "Wishlist", "Frontend wishlist for logged-in users"); + opts.Features.Add(Features.SocialShopping, "Social shopping", "Integrate products into social media portals"); + opts.Renderers.Add(); opts.Renderers.Add(); diff --git a/zero.Web/ZeroBuilder.cs b/zero.Web/ZeroBuilder.cs index 23f16404..904df7b7 100644 --- a/zero.Web/ZeroBuilder.cs +++ b/zero.Web/ZeroBuilder.cs @@ -33,6 +33,7 @@ namespace zero.Web opts.Add(); opts.Add(); opts.Add(); + opts.Add(); }); Services.AddIdentity(opts =>