Browse Source

Feature/leave (#588)

* Leave app and code cleanup.

* Mini improvement to trigger build.
pull/590/head
Sebastian Stehle 5 years ago
committed by GitHub
parent
commit
135968261f
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 6
      backend/i18n/frontend_en.json
  2. 4
      backend/i18n/frontend_it.json
  3. 4
      backend/i18n/frontend_nl.json
  4. 6
      backend/i18n/source/frontend_en.json
  5. 37
      backend/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs
  6. 76
      backend/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs
  7. 1
      frontend/app/features/apps/declarations.ts
  8. 3
      frontend/app/features/apps/module.ts
  9. 43
      frontend/app/features/apps/pages/app.component.html
  10. 11
      frontend/app/features/apps/pages/app.component.scss
  11. 28
      frontend/app/features/apps/pages/app.component.ts
  12. 30
      frontend/app/features/apps/pages/apps-page.component.html
  13. 118
      frontend/app/features/apps/pages/apps-page.component.scss
  14. 4
      frontend/app/features/apps/pages/apps-page.component.ts
  15. 6
      frontend/app/features/content/pages/content/content-page.component.html
  16. 60
      frontend/app/features/content/shared/list/content.component.html
  17. 90
      frontend/app/features/schemas/pages/schema/fields/field.component.html
  18. 54
      frontend/app/features/schemas/pages/schema/schema-page.component.html
  19. 4
      frontend/app/features/settings/pages/clients/clients-page.component.html
  20. 4
      frontend/app/shared/components/assets/asset-folder.component.html
  21. 6
      frontend/app/shared/components/forms/language-selector.component.html
  22. 19
      frontend/app/shared/services/apps.service.spec.ts
  23. 12
      frontend/app/shared/services/apps.service.ts
  24. 9
      frontend/app/shared/state/apps.state.spec.ts
  25. 34
      frontend/app/shared/state/apps.state.ts
  26. 14
      frontend/app/theme/_common.scss

6
backend/i18n/frontend_en.json

@ -18,7 +18,7 @@
"apps.archieveWarning": "Once you archive an app, there is no going back. Please be certain.", "apps.archieveWarning": "Once you archive an app, there is no going back. Please be certain.",
"apps.archiveFailed": "Failed to archive app. Please reload.", "apps.archiveFailed": "Failed to archive app. Please reload.",
"apps.create": "Create App", "apps.create": "Create App",
"apps.createBlankApp": "New App.", "apps.createBlankApp": "New App",
"apps.createBlankAppDescription": "Create a new blank app without content and schemas.", "apps.createBlankAppDescription": "Create a new blank app without content and schemas.",
"apps.createBlogApp": "New Blog Sample", "apps.createBlogApp": "New Blog Sample",
"apps.createBlogAppDescription": "Start with our ready to use blog.", "apps.createBlogAppDescription": "Start with our ready to use blog.",
@ -35,6 +35,10 @@
"apps.generalSettingsDangerZone": "General", "apps.generalSettingsDangerZone": "General",
"apps.image": "Image", "apps.image": "Image",
"apps.imageDrop": "Drop to upload", "apps.imageDrop": "Drop to upload",
"apps.leave": "Leave app",
"apps.leaveConfirmText": "Leave app.",
"apps.leaveConfirmTitle": "Do you really want to leave this app?",
"apps.leaveFailed": "Failed to leave app. Please reload.",
"apps.listPageTitle": "Apps", "apps.listPageTitle": "Apps",
"apps.loadFailed": "Failed to load apps. Please reload.", "apps.loadFailed": "Failed to load apps. Please reload.",
"apps.removeImage": "Remove image", "apps.removeImage": "Remove image",

4
backend/i18n/frontend_it.json

@ -35,6 +35,10 @@
"apps.generalSettingsDangerZone": "Generale", "apps.generalSettingsDangerZone": "Generale",
"apps.image": "Immagine", "apps.image": "Immagine",
"apps.imageDrop": "Trascina il file per caricare", "apps.imageDrop": "Trascina il file per caricare",
"apps.leave": "Leave app",
"apps.leaveConfirmText": "Leave app.",
"apps.leaveConfirmTitle": "Do you really want to leave this app?",
"apps.leaveFailed": "Failed to leave app. Please reload.",
"apps.listPageTitle": "App", "apps.listPageTitle": "App",
"apps.loadFailed": "Non è stato possibile caricare le App. Per favore ricarica.", "apps.loadFailed": "Non è stato possibile caricare le App. Per favore ricarica.",
"apps.removeImage": "Rimuovi l'immagine", "apps.removeImage": "Rimuovi l'immagine",

4
backend/i18n/frontend_nl.json

@ -35,6 +35,10 @@
"apps.generalSettingsDangerZone": "Algemeen", "apps.generalSettingsDangerZone": "Algemeen",
"apps.image": "Afbeelding", "apps.image": "Afbeelding",
"apps.imageDrop": "Zet neer om te uploaden", "apps.imageDrop": "Zet neer om te uploaden",
"apps.leave": "Leave app",
"apps.leaveConfirmText": "Leave app.",
"apps.leaveConfirmTitle": "Do you really want to leave this app?",
"apps.leaveFailed": "Failed to leave app. Please reload.",
"apps.listPageTitle": "Apps", "apps.listPageTitle": "Apps",
"apps.loadFailed": "Laden van apps is mislukt. Laad opnieuw.", "apps.loadFailed": "Laden van apps is mislukt. Laad opnieuw.",
"apps.removeImage": "Afbeelding verwijderen", "apps.removeImage": "Afbeelding verwijderen",

6
backend/i18n/source/frontend_en.json

@ -18,7 +18,7 @@
"apps.archieveWarning": "Once you archive an app, there is no going back. Please be certain.", "apps.archieveWarning": "Once you archive an app, there is no going back. Please be certain.",
"apps.archiveFailed": "Failed to archive app. Please reload.", "apps.archiveFailed": "Failed to archive app. Please reload.",
"apps.create": "Create App", "apps.create": "Create App",
"apps.createBlankApp": "New App.", "apps.createBlankApp": "New App",
"apps.createBlankAppDescription": "Create a new blank app without content and schemas.", "apps.createBlankAppDescription": "Create a new blank app without content and schemas.",
"apps.createBlogApp": "New Blog Sample", "apps.createBlogApp": "New Blog Sample",
"apps.createBlogAppDescription": "Start with our ready to use blog.", "apps.createBlogAppDescription": "Start with our ready to use blog.",
@ -35,6 +35,10 @@
"apps.generalSettingsDangerZone": "General", "apps.generalSettingsDangerZone": "General",
"apps.image": "Image", "apps.image": "Image",
"apps.imageDrop": "Drop to upload", "apps.imageDrop": "Drop to upload",
"apps.leave": "Leave app",
"apps.leaveConfirmTitle": "Leave app.",
"apps.leaveConfirmText": "Do you really want to leave this app?",
"apps.leaveFailed": "Failed to leave app. Please reload.",
"apps.listPageTitle": "Apps", "apps.listPageTitle": "Apps",
"apps.loadFailed": "Failed to load apps. Please reload.", "apps.loadFailed": "Failed to load apps. Please reload.",
"apps.removeImage": "Remove image", "apps.removeImage": "Remove image",

37
backend/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs

@ -13,7 +13,10 @@ using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Domain.Apps.Entities.Apps.Commands;
using Squidex.Domain.Apps.Entities.Apps.Invitation; using Squidex.Domain.Apps.Entities.Apps.Invitation;
using Squidex.Domain.Apps.Entities.Apps.Plans; using Squidex.Domain.Apps.Entities.Apps.Plans;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Security;
using Squidex.Infrastructure.Translations;
using Squidex.Shared; using Squidex.Shared;
using Squidex.Shared.Users; using Squidex.Shared.Users;
using Squidex.Web; using Squidex.Web;
@ -86,6 +89,28 @@ namespace Squidex.Areas.Api.Controllers.Apps
return CreatedAtAction(nameof(GetContributors), new { app }, response); return CreatedAtAction(nameof(GetContributors), new { app }, response);
} }
/// <summary>
/// Remove yourself.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <returns>
/// 200 => User removed from app.
/// 404 => Contributor or app not found.
/// </returns>
[HttpDelete]
[Route("apps/{app}/contributors/me/")]
[ProducesResponseType(typeof(ContributorsDto), 200)]
[ApiPermission]
[ApiCosts(1)]
public async Task<IActionResult> DeleteMyself(string app)
{
var command = new RemoveContributor { ContributorId = UserId() };
var response = await InvokeCommandAsync(command);
return Ok(response);
}
/// <summary> /// <summary>
/// Remove contributor. /// Remove contributor.
/// </summary> /// </summary>
@ -123,6 +148,18 @@ namespace Squidex.Areas.Api.Controllers.Apps
} }
} }
private string UserId()
{
var subject = User.OpenIdSubject();
if (string.IsNullOrWhiteSpace(subject))
{
throw new DomainForbiddenException(T.Get("common.httpOnlyAsUser"));
}
return subject;
}
private Task<ContributorsDto> GetResponseAsync(IAppEntity app, bool invited) private Task<ContributorsDto> GetResponseAsync(IAppEntity app, bool invited)
{ {
return ContributorsDto.FromAppAsync(app, Resources, userResolver, appPlansProvider, invited); return ContributorsDto.FromAppAsync(app, Resources, userResolver, appPlansProvider, invited);

76
backend/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs

@ -29,6 +29,8 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
{ {
public sealed class AppDto : Resource public sealed class AppDto : Resource
{ {
private static readonly JsonObject EmptyObject = JsonValue.Object();
/// <summary> /// <summary>
/// The name of the app. /// The name of the app.
/// </summary> /// </summary>
@ -69,7 +71,7 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
/// <summary> /// <summary>
/// The permission level of the user. /// The permission level of the user.
/// </summary> /// </summary>
public IEnumerable<string> Permissions { get; set; } public IEnumerable<string> Permissions { get; set; } = Array.Empty<string>();
/// <summary> /// <summary>
/// Indicates if the user can access the api. /// Indicates if the user can access the api.
@ -96,26 +98,27 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
/// The properties from the role. /// The properties from the role.
/// </summary> /// </summary>
[LocalizedRequired] [LocalizedRequired]
public JsonObject RoleProperties { get; set; } public JsonObject RoleProperties { get; set; } = EmptyObject;
public static AppDto FromApp(IAppEntity app, string userId, bool isFrontend, IAppPlansProvider plans, Resources resources) public static AppDto FromApp(IAppEntity app, string userId, bool isFrontend, IAppPlansProvider plans, Resources resources)
{ {
var permissions = GetPermissions(app, userId, isFrontend); var result = SimpleMapper.Map(app, new AppDto
{
var result = SimpleMapper.Map(app, new AppDto()); PlanName = plans.GetPlanForApp(app).Plan.Name
});
result.Permissions = permissions.ToIds(); var permissions = PermissionSet.Empty;
result.SetPlan(app, plans, resources, permissions); var isContributor = false;
result.SetImage(app, resources);
if (app.Contributors.TryGetValue(userId, out var roleName) && app.Roles.TryGet(app.Name, roleName, isFrontend, out var role)) if (app.Contributors.TryGetValue(userId, out var roleName) && app.Roles.TryGet(app.Name, roleName, isFrontend, out var role))
{ {
isContributor = true;
result.RoleProperties = role.Properties; result.RoleProperties = role.Properties;
} result.Permissions = permissions.ToIds();
else
{ permissions = role.Permissions;
result.RoleProperties = JsonValue.Object();
} }
if (resources.Includes(P.ForApp(P.AppContents, app.Name), permissions)) if (resources.Includes(P.ForApp(P.AppContents, app.Name), permissions))
@ -123,44 +126,29 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
result.CanAccessContent = true; result.CanAccessContent = true;
} }
return result.CreateLinks(resources, permissions); if (resources.IsAllowed(P.AppPlansChange, app.Name, additional: permissions))
}
private static PermissionSet GetPermissions(IAppEntity app, string userId, bool isFrontend)
{
var permissions = new List<Permission>();
if (app.Contributors.TryGetValue(userId, out var roleName) && app.Roles.TryGet(app.Name, roleName, isFrontend, out var role))
{ {
permissions.AddRange(role.Permissions); result.PlanUpgrade = plans.GetPlanUpgradeForApp(app)?.Name;
} }
return new PermissionSet(permissions); return result.CreateLinks(app, resources, permissions, isContributor);
} }
private void SetPlan(IAppEntity app, IAppPlansProvider plans, Resources resources, PermissionSet permissions) private AppDto CreateLinks(IAppEntity app, Resources resources, PermissionSet permissions, bool isContributor)
{ {
if (resources.IsAllowed(P.AppPlansChange, app.Name, additional: permissions)) var values = new { app = Name };
{
PlanUpgrade = plans.GetPlanUpgradeForApp(app)?.Name;
}
PlanName = plans.GetPlanForApp(app).Plan.Name; AddGetLink("ping", resources.Url<PingController>(x => nameof(x.GetAppPing), values));
}
private void SetImage(IAppEntity app, Resources resources)
{
if (app.Image != null) if (app.Image != null)
{ {
AddGetLink("image", resources.Url<AppsController>(x => nameof(x.GetImage), new { app = app.Name })); AddGetLink("image", resources.Url<AppsController>(x => nameof(x.GetImage), new { app = app.Name }));
} }
}
private AppDto CreateLinks(Resources resources, PermissionSet permissions) if (isContributor)
{ {
var values = new { app = Name }; AddDeleteLink("leave", resources.Url<AppContributorsController>(x => nameof(x.DeleteMyself), values));
}
AddGetLink("ping", resources.Url<PingController>(x => nameof(x.GetAppPing), values));
if (resources.IsAllowed(P.AppDelete, Name, additional: permissions)) if (resources.IsAllowed(P.AppDelete, Name, additional: permissions))
{ {
@ -172,13 +160,6 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
AddPutLink("update", resources.Url<AppsController>(x => nameof(x.UpdateApp), values)); AddPutLink("update", resources.Url<AppsController>(x => nameof(x.UpdateApp), values));
} }
if (resources.IsAllowed(P.AppUpdateImage, Name, additional: permissions))
{
AddPostLink("image/upload", resources.Url<AppsController>(x => nameof(x.UploadImage), values));
AddDeleteLink("image/delete", resources.Url<AppsController>(x => nameof(x.DeleteImage), values));
}
if (resources.IsAllowed(P.AppAssetsRead, Name, additional: permissions)) if (resources.IsAllowed(P.AppAssetsRead, Name, additional: permissions))
{ {
AddGetLink("assets", resources.Url<AssetsController>(x => nameof(x.GetAssets), values)); AddGetLink("assets", resources.Url<AssetsController>(x => nameof(x.GetAssets), values));
@ -244,6 +225,13 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
AddPostLink("assets/create", resources.Url<SchemasController>(x => nameof(x.PostSchema), values)); AddPostLink("assets/create", resources.Url<SchemasController>(x => nameof(x.PostSchema), values));
} }
if (resources.IsAllowed(P.AppUpdateImage, Name, additional: permissions))
{
AddPostLink("image/upload", resources.Url<AppsController>(x => nameof(x.UploadImage), values));
AddDeleteLink("image/delete", resources.Url<AppsController>(x => nameof(x.DeleteImage), values));
}
return this; return this;
} }
} }

1
frontend/app/features/apps/declarations.ts

@ -5,6 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
export * from './pages/app.component';
export * from './pages/apps-page.component'; export * from './pages/apps-page.component';
export * from './pages/news-dialog.component'; export * from './pages/news-dialog.component';
export * from './pages/onboarding-dialog.component'; export * from './pages/onboarding-dialog.component';

3
frontend/app/features/apps/module.ts

@ -8,7 +8,7 @@
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router'; import { RouterModule, Routes } from '@angular/router';
import { SqxFrameworkModule, SqxSharedModule } from '@app/shared'; import { SqxFrameworkModule, SqxSharedModule } from '@app/shared';
import { AppsPageComponent, NewsDialogComponent, OnboardingDialogComponent } from './declarations'; import { AppComponent, AppsPageComponent, NewsDialogComponent, OnboardingDialogComponent } from './declarations';
const routes: Routes = [ const routes: Routes = [
{ {
@ -24,6 +24,7 @@ const routes: Routes = [
SqxSharedModule SqxSharedModule
], ],
declarations: [ declarations: [
AppComponent,
AppsPageComponent, AppsPageComponent,
NewsDialogComponent, NewsDialogComponent,
OnboardingDialogComponent OnboardingDialogComponent

43
frontend/app/features/apps/pages/app.component.html

@ -0,0 +1,43 @@
<div class="card card-href card-app" [routerLink]="['/app', app.name]">
<div class="card-body">
<div class="row no-gutters">
<div class="col-auto card-left">
<sqx-avatar [image]="app.image" [identifier]="app.name"></sqx-avatar>
</div>
<div class="col card-right">
<h3 class="card-title">{{app.displayName}}</h3>
<div class="card-text card-links truncate">
<a [routerLink]="['/app', app.name]" sqxStopClick>{{ 'common.edit' | sqxTranslate }}</a>
<span class="deeplinks">
&nbsp;|
<a [routerLink]="['/app', app.name, 'content']" sqxStopClick>{{ 'common.content' | sqxTranslate }}</a> &middot;
<a [routerLink]="['/app', app.name, 'assets']" sqxStopClick>{{ 'common.assets' | sqxTranslate }}</a> &middot;
<a [routerLink]="['/app', app.name, 'settings']" sqxStopClick>{{ 'common.settings' | sqxTranslate }}</a>
</span>
</div>
<div class="card-text" *ngIf="app.description">
{{app.description}}
</div>
</div>
</div>
<button type="button" class="btn btn-sm btn-text-secondary" (click)="dropdown.toggle()" sqxStopClick [class.active]="dropdown.isOpen | async" #buttonOptions>
<i class="icon-dots"></i>
</button>
<ng-container *sqxModal="dropdown;closeAlways:true">
<div class="dropdown-menu" [sqxAnchoredTo]="buttonOptions" @fade>
<a class="dropdown-item dropdown-item-delete"
(sqxConfirmClick)="leave.emit(app)"
confirmTitle="i18n:apps.leaveConfirmTitle"
confirmText="i18n:apps.leaveConfirmText"
confirmRememberKey="leaveApp">
{{ 'apps.leave' | sqxTranslate }}
</a>
</div>
</ng-container>
</div>
</div>

11
frontend/app/features/apps/pages/app.component.scss

@ -0,0 +1,11 @@
.btn {
@include absolute(1rem, 1rem);
}
.card-body {
position: relative;
}
.card-title {
padding-right: 2rem;
}

28
frontend/app/features/apps/pages/app.component.ts

@ -0,0 +1,28 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { AppDto, fadeAnimation, ModalModel } from '@app/shared';
@Component({
selector: 'sqx-app',
styleUrls: ['./app.component.scss'],
templateUrl: './app.component.html',
animations: [
fadeAnimation
],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class AppComponent {
@Input()
public app: AppDto;
@Output()
public leave = new EventEmitter<AppDto>();
public dropdown = new ModalModel();
}

30
frontend/app/features/apps/pages/apps-page.component.html

@ -15,33 +15,9 @@
<h3 class="empty-headline">{{ 'apps.empty' | sqxTranslate }}</h3> <h3 class="empty-headline">{{ 'apps.empty' | sqxTranslate }}</h3>
</div> </div>
<div class="card card-href card-app" *ngFor="let app of apps; trackBy: trackByApp" [routerLink]="['/app', app.name]"> <sqx-app *ngFor="let app of apps; trackBy: trackByApp"
<div class="card-body"> [app]="app" (leave)="leave($event)">
<div class="row no-gutters"> </sqx-app>
<div class="col-auto card-left">
<sqx-avatar [image]="app.image" [identifier]="app.name"></sqx-avatar>
</div>
<div class="col card-right">
<h3 class="card-title">{{app.displayName}}</h3>
<div class="card-text card-links truncate">
<a [routerLink]="['/app', app.name]" sqxStopClick>{{ 'common.edit' | sqxTranslate }}</a>
<span class="deeplinks">
&nbsp;|
<a [routerLink]="['/app', app.name, 'content']" sqxStopClick>{{ 'common.content' | sqxTranslate }}</a> &middot;
<a [routerLink]="['/app', app.name, 'assets']" sqxStopClick>{{ 'common.assets' | sqxTranslate }}</a> &middot;
<a [routerLink]="['/app', app.name, 'settings']" sqxStopClick>{{ 'common.settings' | sqxTranslate }}</a>
</span>
</div>
<div class="card-text" *ngIf="app.description">
{{app.description}}
</div>
</div>
</div>
</div>
</div>
</div> </div>
</ng-container> </ng-container>

118
frontend/app/features/apps/pages/apps-page.component.scss

@ -15,80 +15,80 @@
overflow-y: auto; overflow-y: auto;
} }
.card { :host ::ng-deep {
& { .card {
@include hover-visible('.deeplinks', inline); & {
display: inline-block; @include hover-visible('.deeplinks', inline);
margin-bottom: 1rem; display: inline-block;
margin-right: 1rem; margin-bottom: 1rem;
width: 20rem; margin-right: 1rem;
} width: 20rem;
}
&-links {
margin-top: .5rem;
}
&-left {
padding-right: .75rem;
}
&-right { &-links {
overflow: hidden; margin-top: .5rem;
} }
&-image { &-left {
text-align: center; padding-right: .75rem;
} }
&-text { &-right {
color: $color-text-decent; overflow: hidden;
font-size: .9rem; }
font-weight: normal;
margin-top: .5rem;
}
&-title { &-image {
@include truncate; text-align: center;
color: $color-title;
margin-bottom: 0;
margin-top: 0;
}
&-template { img {
.card-body { height: 6rem;
min-height: 15.5rem; }
} }
.card-title { &-text {
margin-bottom: .75rem; color: $color-text-decent;
margin-top: 1rem; font-size: .9rem;
font-weight: normal;
margin-top: .5rem;
} }
}
&-href { &-title {
& { @include truncate;
cursor: pointer; color: $color-title;
margin-bottom: 0;
margin-top: 0;
} }
&:hover { &-template {
@include box-shadow-outer(0, 3px, 16px, .2); .card-body {
} min-height: 15.5rem;
}
&:focus { .card-title {
outline: none; margin-bottom: .75rem;
margin-top: 1rem;
}
} }
&:hover, &-href {
&:focus, & {
&:active { cursor: pointer;
text-decoration: none; }
}
}
}
.card-image { &:hover {
img { @include box-shadow-outer(0, 3px, 16px, .2);
height: 6rem; }
&:focus {
outline: none;
}
&:hover,
&:focus,
&:active {
text-decoration: none;
}
}
} }
} }

4
frontend/app/features/apps/pages/apps-page.component.ts

@ -71,6 +71,10 @@ export class AppsPageComponent implements OnInit {
this.addAppDialog.show(); this.addAppDialog.show();
} }
public leave(app: AppDto) {
this.appsState.leave(app);
}
public trackByApp(_index: number, app: AppDto) { public trackByApp(_index: number, app: AppDto) {
return app.id; return app.id;
} }

6
frontend/app/features/content/pages/content/content-page.component.html

@ -26,9 +26,9 @@
<sqx-preview-button [schema]="schema" [content]="content"></sqx-preview-button> <sqx-preview-button [schema]="schema" [content]="content"></sqx-preview-button>
<div class="dropdown dropdown-options ml-1" *ngIf="content?.canDelete"> <ng-container *ngIf="content?.canDelete">
<button type="button" class="btn btn-outline-secondary" (click)="dropdown.toggle()" [class.active]="dropdown.isOpen | async" #buttonOptions> <button type="button" class="btn btn-outline-secondary" (click)="dropdown.toggle()" [class.active]="dropdown.isOpen | async" #buttonOptions>
<i class="icon-more"></i> <i class="icon-dots"></i>
</button> </button>
<ng-container *sqxModal="dropdown;closeAlways:true"> <ng-container *sqxModal="dropdown;closeAlways:true">
@ -42,7 +42,7 @@
</a> </a>
</div> </div>
</ng-container> </ng-container>
</div> </ng-container>
</ng-container> </ng-container>
<ng-container *ngIf="content?.canUpdate"> <ng-container *ngIf="content?.canUpdate">

60
frontend/app/features/content/shared/list/content.component.html

@ -22,38 +22,36 @@
</td> </td>
<td class="cell-actions cell-actions-left" sqxStopClick> <td class="cell-actions cell-actions-left" sqxStopClick>
<div class="dropdown dropdown-options inline-edit" *ngIf="content"> <button type="button" class="btn btn-text-secondary" (click)="dropdown.toggle()" [class.active]="dropdown.isOpen | async" #buttonOptions>
<button type="button" class="btn btn-text-secondary" (click)="dropdown.toggle()" [class.active]="dropdown.isOpen | async" #buttonOptions> <i class="icon-dots"></i>
<i class="icon-dots"></i> </button>
</button>
<ng-container *sqxModal="dropdown;closeAlways:true">
<div class="dropdown-menu" [sqxAnchoredTo]="buttonOptions" position="bottom-left" @fade>
<a class="dropdown-item" *ngFor="let info of content.statusUpdates" (click)="statusChange.emit(info.status)">
{{ 'common.statusChangeTo' | sqxTranslate }}
<sqx-content-status small="true" <ng-container *sqxModal="dropdown;closeAlways:true">
layout="text" <div class="dropdown-menu" [sqxAnchoredTo]="buttonOptions" position="bottom-left" @fade>
[status]="info.status" <a class="dropdown-item" *ngFor="let info of content.statusUpdates" (click)="statusChange.emit(info.status)">
[statusColor]="info.color"> {{ 'common.statusChangeTo' | sqxTranslate }}
</sqx-content-status>
</a> <sqx-content-status small="true"
<a class="dropdown-item" (click)="clone.emit(); dropdown.hide()" *ngIf="canClone"> layout="text"
{{ 'common.clone' | sqxTranslate }} [status]="info.status"
</a> [statusColor]="info.color">
</sqx-content-status>
<div class="dropdown-divider"></div> </a>
<a class="dropdown-item" (click)="clone.emit(); dropdown.hide()" *ngIf="canClone">
<a class="dropdown-item dropdown-item-delete" {{ 'common.clone' | sqxTranslate }}
(sqxConfirmClick)="delete.emit()" </a>
confirmTitle="i18n:contents.deleteConfirmTitle"
confirmText="i18n:contents.deleteConfirmText" <div class="dropdown-divider"></div>
confirmRememberKey="deleteContent">
{{ 'common.delete' | sqxTranslate }} <a class="dropdown-item dropdown-item-delete"
</a> (sqxConfirmClick)="delete.emit()"
</div> confirmTitle="i18n:contents.deleteConfirmTitle"
</ng-container> confirmText="i18n:contents.deleteConfirmText"
</div> confirmRememberKey="deleteContent">
{{ 'common.delete' | sqxTranslate }}
</a>
</div>
</ng-container>
</td> </td>
<td *ngFor="let field of listFields" [sqxContentListCell]="field" [sqxStopClick]="shouldStop(field)"> <td *ngFor="let field of listFields" [sqxContentListCell]="field" [sqxStopClick]="shouldStop(field)">

90
frontend/app/features/schemas/pages/schema/fields/field.component.html

@ -41,54 +41,52 @@
<i class="icon-settings"></i> <i class="icon-settings"></i>
</button> </button>
<div class="dropdown dropdown-options"> <button type="button" class="btn btn-text-secondary ml-1" (click)="dropdown.toggle()" [disabled]="!field.properties.isContentField && field.isLocked" [class.active]="dropdown.isOpen | async" #buttonOptions>
<button type="button" class="btn btn-text-secondary ml-1" (click)="dropdown.toggle()" [disabled]="!field.properties.isContentField && field.isLocked" [class.active]="dropdown.isOpen | async" #buttonOptions> <i class="icon-dots"></i>
<i class="icon-dots"></i> </button>
</button>
<ng-container *sqxModal="dropdown;closeAlways:true"> <ng-container *sqxModal="dropdown;closeAlways:true">
<div class="dropdown-menu" [sqxAnchoredTo]="buttonOptions" @fade> <div class="dropdown-menu" [sqxAnchoredTo]="buttonOptions" @fade>
<ng-container *ngIf="field.properties.isContentField"> <ng-container *ngIf="field.properties.isContentField">
<a class="dropdown-item" (click)="enableField()" *ngIf="field.canEnable"> <a class="dropdown-item" (click)="enableField()" *ngIf="field.canEnable">
{{ 'schemas.field.enable' | sqxTranslate }} {{ 'schemas.field.enable' | sqxTranslate }}
</a> </a>
<a class="dropdown-item" (click)="disableField()" *ngIf="field.canDisable"> <a class="dropdown-item" (click)="disableField()" *ngIf="field.canDisable">
{{ 'schemas.field.disable' | sqxTranslate }} {{ 'schemas.field.disable' | sqxTranslate }}
</a> </a>
<a class="dropdown-item" (click)="hideField()" *ngIf="field.canHide"> <a class="dropdown-item" (click)="hideField()" *ngIf="field.canHide">
{{ 'schemas.field.hide' | sqxTranslate }} {{ 'schemas.field.hide' | sqxTranslate }}
</a> </a>
<a class="dropdown-item" (click)="showField()" *ngIf="field.canShow"> <a class="dropdown-item" (click)="showField()" *ngIf="field.canShow">
{{ 'schemas.field.show' | sqxTranslate }} {{ 'schemas.field.show' | sqxTranslate }}
</a> </a>
</ng-container> </ng-container>
<ng-container *ngIf="field.canLock">
<div class="dropdown-divider"></div>
<a class="dropdown-item" <ng-container *ngIf="field.canLock">
(sqxConfirmClick)="lockField()" <div class="dropdown-divider"></div>
confirmTitle="i18n:schemas.field.lockConfirmText"
confirmText="i18n:schemas.field.lockConfirmText" <a class="dropdown-item"
confirmRememberKey="lockField"> (sqxConfirmClick)="lockField()"
{{ 'schemas.field.lock' | sqxTranslate }} confirmTitle="i18n:schemas.field.lockConfirmText"
</a> confirmText="i18n:schemas.field.lockConfirmText"
</ng-container> confirmRememberKey="lockField">
{{ 'schemas.field.lock' | sqxTranslate }}
<ng-container> </a>
<div class="dropdown-divider"></div> </ng-container>
<a class="dropdown-item dropdown-item-delete" [class.disabled]="!field.canDelete" <ng-container>
(sqxConfirmClick)="deleteField()" <div class="dropdown-divider"></div>
confirmTitle="i18n:schemas.field.deleteConfirmTitle"
confirmText="i18n:schemas.field.deleteConfirmText" <a class="dropdown-item dropdown-item-delete" [class.disabled]="!field.canDelete"
confirmRememberKey="deleteField"> (sqxConfirmClick)="deleteField()"
{{ 'common.delete' | sqxTranslate }} confirmTitle="i18n:schemas.field.deleteConfirmTitle"
</a> confirmText="i18n:schemas.field.deleteConfirmText"
</ng-container> confirmRememberKey="deleteField">
</div> {{ 'common.delete' | sqxTranslate }}
</ng-container> </a>
</div> </ng-container>
</div>
</ng-container>
</div> </div>
</div> </div>
</div> </div>

54
frontend/app/features/schemas/pages/schema/schema-page.component.html

@ -19,35 +19,33 @@
</button> </button>
</div> </div>
<div class="dropdown dropdown-options"> <button type="button" class="btn btn-text-secondary ml-1" (click)="editOptionsDropdown.toggle()" [class.active]="editOptionsDropdown.isOpen | async" #buttonOptions>
<button type="button" class="btn btn-text-secondary ml-1" (click)="editOptionsDropdown.toggle()" [class.active]="editOptionsDropdown.isOpen | async" #buttonOptions> <i class="icon-dots"></i>
<i class="icon-dots"></i> </button>
</button>
<ng-container *sqxModal="editOptionsDropdown;closeAlways:true"> <ng-container *sqxModal="editOptionsDropdown;closeAlways:true">
<div class="dropdown-menu" [sqxAnchoredTo]="buttonOptions" @fade> <div class="dropdown-menu" [sqxAnchoredTo]="buttonOptions" @fade>
<ng-container *ngIf="schemasState.canCreate"> <ng-container *ngIf="schemasState.canCreate">
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
<a class="dropdown-item" (click)="cloneSchema()"> <a class="dropdown-item" (click)="cloneSchema()">
{{ 'common.clone' | sqxTranslate }} {{ 'common.clone' | sqxTranslate }}
</a> </a>
</ng-container> </ng-container>
<ng-container> <ng-container>
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
<a class="dropdown-item dropdown-item-delete" [class.disabled]="!schema.canDelete" <a class="dropdown-item dropdown-item-delete" [class.disabled]="!schema.canDelete"
(sqxConfirmClick)="deleteSchema()" (sqxConfirmClick)="deleteSchema()"
confirmTitle="i18n:schemas.deleteConfirmTitle" confirmTitle="i18n:schemas.deleteConfirmTitle"
confirmText="i18n:schemas.deleteConfirmText" confirmText="i18n:schemas.deleteConfirmText"
confirmRememberKey="deleteSchema"> confirmRememberKey="deleteSchema">
{{ 'common.delete' | sqxTranslate }} {{ 'common.delete' | sqxTranslate }}
</a> </a>
</ng-container> </ng-container>
</div> </div>
</ng-container> </ng-container>
</div>
<sqx-onboarding-tooltip helpId="history" [for]="buttonOptions" position="bottom-right" after="60000"> <sqx-onboarding-tooltip helpId="history" [for]="buttonOptions" position="bottom-right" after="60000">
{{ 'schemas.contextMenuTour' | sqxTranslate }} {{ 'schemas.contextMenuTour' | sqxTranslate }}

4
frontend/app/features/settings/pages/clients/clients-page.component.html

@ -22,7 +22,9 @@
</div> </div>
<ng-container *ngIf="rolesState.roles | async; let roles"> <ng-container *ngIf="rolesState.roles | async; let roles">
<sqx-client *ngFor="let client of clients; trackBy: trackByClient" [client]="client" [clientRoles]="roles"> <sqx-client *ngFor="let client of clients; trackBy: trackByClient"
[client]="client"
[clientRoles]="roles">
</sqx-client> </sqx-client>
</ng-container> </ng-container>

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

@ -8,7 +8,7 @@
{{assetFolder.folderName | sqxTranslate}} {{assetFolder.folderName | sqxTranslate}}
</div> </div>
<div class="col-auto"> <div class="col-auto">
<div class="dropdown dropdown-options" *ngIf="canDelete || canUpdate"> <ng-container *ngIf="canDelete || canUpdate">
<button type="button" class="btn btn-sm btn-text-secondary ml-1" (click)="dropdown.toggle()" [class.active]="dropdown.isOpen | async" #buttonOptions> <button type="button" class="btn btn-sm btn-text-secondary ml-1" (click)="dropdown.toggle()" [class.active]="dropdown.isOpen | async" #buttonOptions>
<i class="icon-dots"></i> <i class="icon-dots"></i>
</button> </button>
@ -32,7 +32,7 @@
</a> </a>
</div> </div>
</ng-container> </ng-container>
</div> </ng-container>
</div> </div>
</div> </div>
</div> </div>

6
frontend/app/shared/components/forms/language-selector.component.html

@ -4,8 +4,8 @@
</button> </button>
</div> </div>
<div class="dropdown-options btn-group btn-group-{{size}}" *ngIf="isLargeMode"> <ng-container *ngIf="isLargeMode">
<button type="button" class="btn btn-secondary dropdown-toggle" title="{{selectedLanguage.englishName}}" (click)="dropdown.toggle()" #button tabindex="-1"> <button type="button" class="btn btn-secondary btn-{{size}} dropdown-toggle" title="{{selectedLanguage.englishName}}" (click)="dropdown.toggle()" #button tabindex="-1">
{{selectedLanguage.iso2Code}} {{selectedLanguage.iso2Code}}
</button> </button>
@ -16,4 +16,4 @@
</div> </div>
</div> </div>
</ng-container> </ng-container>
</div> </ng-container>

19
frontend/app/shared/services/apps.service.spec.ts

@ -195,6 +195,25 @@ describe('AppsService', () => {
expect(app!).toEqual(createApp(12)); expect(app!).toEqual(createApp(12));
})); }));
it('should make delete request to leave app',
inject([AppsService, HttpTestingController], (appsService: AppsService, httpMock: HttpTestingController) => {
const resource: Resource = {
_links: {
delete: { method: 'DELETE', href: '/api/apps/my-app/contributors/me' }
}
};
appsService.deleteApp(resource).subscribe();
const req = httpMock.expectOne('http://service/p/api/apps/my-app/contributors/me');
expect(req.request.method).toEqual('DELETE');
expect(req.request.headers.get('If-Match')).toBeNull();
req.flush({});
}));
it('should make delete request to archive app', it('should make delete request to archive app',
inject([AppsService, HttpTestingController], (appsService: AppsService, httpMock: HttpTestingController) => { inject([AppsService, HttpTestingController], (appsService: AppsService, httpMock: HttpTestingController) => {

12
frontend/app/shared/services/apps.service.ts

@ -195,6 +195,18 @@ export class AppsService {
pretifyError('i18n:apps.removeImageFailed')); pretifyError('i18n:apps.removeImageFailed'));
} }
public leaveApp(resource: Resource): Observable<any> {
const link = resource._links['leave'];
const url = this.apiUrl.buildUrl(link.href);
return this.http.request(link.method, url).pipe(
tap(() => {
this.analytics.trackEvent('App', 'Left');
}),
pretifyError('i18n:apps.leaveFailed'));
}
public deleteApp(resource: Resource): Observable<any> { public deleteApp(resource: Resource): Observable<any> {
const link = resource._links['delete']; const link = resource._links['delete'];

9
frontend/app/shared/state/apps.state.spec.ts

@ -163,6 +163,15 @@ describe('AppsState', () => {
expect(appsState.snapshot.apps).toEqual([app1, updated]); expect(appsState.snapshot.apps).toEqual([app1, updated]);
}); });
it('should remove app from snapshot when left', () => {
appsService.setup(x => x.leaveApp(app2))
.returns(() => of({})).verifiable();
appsState.leave(app2).subscribe();
expect(appsState.snapshot.apps).toEqual([app1]);
});
it('should remove app from snapshot when archived', () => { it('should remove app from snapshot when archived', () => {
appsService.setup(x => x.deleteApp(app2)) appsService.setup(x => x.deleteApp(app2))
.returns(() => of({})).verifiable(); .returns(() => of({})).verifiable();

34
frontend/app/shared/state/apps.state.ts

@ -131,24 +131,36 @@ export class AppsState extends State<Snapshot> {
shareSubscribed(this.dialogs)); shareSubscribed(this.dialogs));
} }
public leave(app: AppDto): Observable<any> {
return this.appsService.leaveApp(app).pipe(
tap(() => {
this.removeApp(app);
}),
shareSubscribed(this.dialogs));
}
public delete(app: AppDto): Observable<any> { public delete(app: AppDto): Observable<any> {
return this.appsService.deleteApp(app).pipe( return this.appsService.deleteApp(app).pipe(
tap(() => { tap(() => {
this.next(s => { this.removeApp(app);
const apps = s.apps.filter(x => x.name !== app.name);
const selectedApp =
s.selectedApp &&
s.selectedApp.id === app.id ?
null :
s.selectedApp;
return { ...s, apps, selectedApp };
});
}), }),
shareSubscribed(this.dialogs)); shareSubscribed(this.dialogs));
} }
private removeApp(app: AppDto) {
this.next(s => {
const apps = s.apps.filter(x => x.name !== app.name);
const selectedApp =
s.selectedApp &&
s.selectedApp.id === app.id ?
null :
s.selectedApp;
return { ...s, apps, selectedApp };
});
}
private replaceApp(updated: AppDto, app: AppDto) { private replaceApp(updated: AppDto, app: AppDto) {
this.next(s => { this.next(s => {
const apps = s.apps.replaceBy('id', updated); const apps = s.apps.replaceBy('id', updated);

14
frontend/app/theme/_common.scss

@ -135,20 +135,6 @@ body {
} }
} }
.dropdown-options {
& {
display: inline-block;
}
.dropdown-menu {
@include absolute(100%, 0, auto, auto);
}
.dropdown-item {
cursor: pointer;
}
}
.dropdown-item { .dropdown-item {
cursor: pointer; cursor: pointer;
} }

Loading…
Cancel
Save