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. 54
      frontend/app/features/content/shared/list/content.component.html
  17. 94
      frontend/app/features/schemas/pages/schema/fields/field.component.html
  18. 48
      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.archiveFailed": "Failed to archive app. Please reload.",
"apps.create": "Create App",
"apps.createBlankApp": "New App.",
"apps.createBlankApp": "New App",
"apps.createBlankAppDescription": "Create a new blank app without content and schemas.",
"apps.createBlogApp": "New Blog Sample",
"apps.createBlogAppDescription": "Start with our ready to use blog.",
@ -35,6 +35,10 @@
"apps.generalSettingsDangerZone": "General",
"apps.image": "Image",
"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.loadFailed": "Failed to load apps. Please reload.",
"apps.removeImage": "Remove image",

4
backend/i18n/frontend_it.json

@ -35,6 +35,10 @@
"apps.generalSettingsDangerZone": "Generale",
"apps.image": "Immagine",
"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.loadFailed": "Non è stato possibile caricare le App. Per favore ricarica.",
"apps.removeImage": "Rimuovi l'immagine",

4
backend/i18n/frontend_nl.json

@ -35,6 +35,10 @@
"apps.generalSettingsDangerZone": "Algemeen",
"apps.image": "Afbeelding",
"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.loadFailed": "Laden van apps is mislukt. Laad opnieuw.",
"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.archiveFailed": "Failed to archive app. Please reload.",
"apps.create": "Create App",
"apps.createBlankApp": "New App.",
"apps.createBlankApp": "New App",
"apps.createBlankAppDescription": "Create a new blank app without content and schemas.",
"apps.createBlogApp": "New Blog Sample",
"apps.createBlogAppDescription": "Start with our ready to use blog.",
@ -35,6 +35,10 @@
"apps.generalSettingsDangerZone": "General",
"apps.image": "Image",
"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.loadFailed": "Failed to load apps. Please reload.",
"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.Invitation;
using Squidex.Domain.Apps.Entities.Apps.Plans;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Security;
using Squidex.Infrastructure.Translations;
using Squidex.Shared;
using Squidex.Shared.Users;
using Squidex.Web;
@ -86,6 +89,28 @@ namespace Squidex.Areas.Api.Controllers.Apps
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>
/// Remove contributor.
/// </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)
{
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
{
private static readonly JsonObject EmptyObject = JsonValue.Object();
/// <summary>
/// The name of the app.
/// </summary>
@ -69,7 +71,7 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
/// <summary>
/// The permission level of the user.
/// </summary>
public IEnumerable<string> Permissions { get; set; }
public IEnumerable<string> Permissions { get; set; } = Array.Empty<string>();
/// <summary>
/// Indicates if the user can access the api.
@ -96,26 +98,27 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
/// The properties from the role.
/// </summary>
[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)
{
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);
result.SetImage(app, resources);
var isContributor = false;
if (app.Contributors.TryGetValue(userId, out var roleName) && app.Roles.TryGet(app.Name, roleName, isFrontend, out var role))
{
isContributor = true;
result.RoleProperties = role.Properties;
}
else
{
result.RoleProperties = JsonValue.Object();
result.Permissions = permissions.ToIds();
permissions = role.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;
}
return result.CreateLinks(resources, 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))
if (resources.IsAllowed(P.AppPlansChange, app.Name, additional: permissions))
{
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))
{
PlanUpgrade = plans.GetPlanUpgradeForApp(app)?.Name;
}
var values = new { 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)
{
AddGetLink("image", resources.Url<AppsController>(x => nameof(x.GetImage), new { app = app.Name }));
}
}
private AppDto CreateLinks(Resources resources, PermissionSet permissions)
{
var values = new { app = Name };
AddGetLink("ping", resources.Url<PingController>(x => nameof(x.GetAppPing), values));
if (isContributor)
{
AddDeleteLink("leave", resources.Url<AppContributorsController>(x => nameof(x.DeleteMyself), values));
}
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));
}
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))
{
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));
}
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;
}
}

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

@ -5,6 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
export * from './pages/app.component';
export * from './pages/apps-page.component';
export * from './pages/news-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 { RouterModule, Routes } from '@angular/router';
import { SqxFrameworkModule, SqxSharedModule } from '@app/shared';
import { AppsPageComponent, NewsDialogComponent, OnboardingDialogComponent } from './declarations';
import { AppComponent, AppsPageComponent, NewsDialogComponent, OnboardingDialogComponent } from './declarations';
const routes: Routes = [
{
@ -24,6 +24,7 @@ const routes: Routes = [
SqxSharedModule
],
declarations: [
AppComponent,
AppsPageComponent,
NewsDialogComponent,
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>
</div>
<div class="card card-href card-app" *ngFor="let app of apps; trackBy: trackByApp" [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>
</div>
</div>
<sqx-app *ngFor="let app of apps; trackBy: trackByApp"
[app]="app" (leave)="leave($event)">
</sqx-app>
</div>
</ng-container>

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

@ -15,80 +15,80 @@
overflow-y: auto;
}
.card {
& {
@include hover-visible('.deeplinks', inline);
display: inline-block;
margin-bottom: 1rem;
margin-right: 1rem;
width: 20rem;
}
&-links {
margin-top: .5rem;
}
&-left {
padding-right: .75rem;
}
:host ::ng-deep {
.card {
& {
@include hover-visible('.deeplinks', inline);
display: inline-block;
margin-bottom: 1rem;
margin-right: 1rem;
width: 20rem;
}
&-right {
overflow: hidden;
}
&-links {
margin-top: .5rem;
}
&-image {
text-align: center;
}
&-left {
padding-right: .75rem;
}
&-text {
color: $color-text-decent;
font-size: .9rem;
font-weight: normal;
margin-top: .5rem;
}
&-right {
overflow: hidden;
}
&-title {
@include truncate;
color: $color-title;
margin-bottom: 0;
margin-top: 0;
}
&-image {
text-align: center;
&-template {
.card-body {
min-height: 15.5rem;
img {
height: 6rem;
}
}
.card-title {
margin-bottom: .75rem;
margin-top: 1rem;
&-text {
color: $color-text-decent;
font-size: .9rem;
font-weight: normal;
margin-top: .5rem;
}
}
&-href {
& {
cursor: pointer;
&-title {
@include truncate;
color: $color-title;
margin-bottom: 0;
margin-top: 0;
}
&:hover {
@include box-shadow-outer(0, 3px, 16px, .2);
}
&-template {
.card-body {
min-height: 15.5rem;
}
&:focus {
outline: none;
.card-title {
margin-bottom: .75rem;
margin-top: 1rem;
}
}
&:hover,
&:focus,
&:active {
text-decoration: none;
}
}
}
&-href {
& {
cursor: pointer;
}
.card-image {
img {
height: 6rem;
&:hover {
@include box-shadow-outer(0, 3px, 16px, .2);
}
&: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();
}
public leave(app: AppDto) {
this.appsState.leave(app);
}
public trackByApp(_index: number, app: AppDto) {
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>
<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>
<i class="icon-more"></i>
<i class="icon-dots"></i>
</button>
<ng-container *sqxModal="dropdown;closeAlways:true">
@ -42,7 +42,7 @@
</a>
</div>
</ng-container>
</div>
</ng-container>
</ng-container>
<ng-container *ngIf="content?.canUpdate">

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

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

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

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

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

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

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

@ -22,7 +22,9 @@
</div>
<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>
</ng-container>

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

@ -8,7 +8,7 @@
{{assetFolder.folderName | sqxTranslate}}
</div>
<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>
<i class="icon-dots"></i>
</button>
@ -32,7 +32,7 @@
</a>
</div>
</ng-container>
</div>
</ng-container>
</div>
</div>
</div>

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

@ -4,8 +4,8 @@
</button>
</div>
<div class="dropdown-options btn-group btn-group-{{size}}" *ngIf="isLargeMode">
<button type="button" class="btn btn-secondary dropdown-toggle" title="{{selectedLanguage.englishName}}" (click)="dropdown.toggle()" #button tabindex="-1">
<ng-container *ngIf="isLargeMode">
<button type="button" class="btn btn-secondary btn-{{size}} dropdown-toggle" title="{{selectedLanguage.englishName}}" (click)="dropdown.toggle()" #button tabindex="-1">
{{selectedLanguage.iso2Code}}
</button>
@ -16,4 +16,4 @@
</div>
</div>
</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));
}));
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',
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'));
}
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> {
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]);
});
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', () => {
appsService.setup(x => x.deleteApp(app2))
.returns(() => of({})).verifiable();

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

@ -131,24 +131,36 @@ export class AppsState extends State<Snapshot> {
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> {
return this.appsService.deleteApp(app).pipe(
tap(() => {
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 };
});
this.removeApp(app);
}),
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) {
this.next(s => {
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 {
cursor: pointer;
}

Loading…
Cancel
Save