Browse Source

Merge branch 'master' of github.com:Squidex/squidex

pull/814/head
Sebastian 4 years ago
parent
commit
3a17727f07
  1. 2
      backend/src/Squidex.Domain.Apps.Core.Model/Contents/Json/ContentFieldDataConverter.cs
  2. 8
      backend/src/Squidex.Infrastructure/Language.cs
  3. 30
      backend/tests/Squidex.Infrastructure.Tests/LanguageTests.cs
  4. 16
      backend/tools/TestSuite/TestSuite.ApiTests/AppLanguagesTests.cs
  5. 6
      frontend/app/features/settings/pages/contributors/contributor-add-form.component.ts
  6. 8
      frontend/app/features/settings/pages/languages/language-add-form.component.html
  7. 49
      frontend/app/features/settings/pages/languages/language-add-form.component.ts
  8. 7
      frontend/app/features/settings/pages/roles/roles-page.component.ts
  9. 17
      frontend/app/framework/angular/forms/editors/autocomplete.component.html
  10. 10
      frontend/app/framework/angular/forms/editors/autocomplete.component.scss
  11. 129
      frontend/app/framework/angular/forms/editors/autocomplete.component.ts
  12. 7
      frontend/app/framework/angular/forms/editors/dropdown.component.html
  13. 24
      frontend/app/framework/angular/forms/editors/tag-editor.component.html
  14. 1
      frontend/app/framework/angular/forms/editors/tag-editor.component.scss
  15. 9
      frontend/app/framework/angular/forms/editors/tag-editor.component.ts
  16. 2
      frontend/app/framework/angular/forms/error-validator.spec.ts
  17. 6
      frontend/app/framework/angular/modals/dialog-renderer.component.html
  18. 126
      frontend/app/framework/angular/modals/modal-placement.directive.ts
  19. 16
      frontend/app/framework/angular/modals/onboarding-tooltip.component.html
  20. 51
      frontend/app/framework/angular/modals/onboarding-tooltip.component.ts
  21. 2
      frontend/app/framework/services/dialog.service.ts
  22. 107
      frontend/app/framework/utils/modal-positioner.spec.ts
  23. 270
      frontend/app/framework/utils/modal-positioner.ts
  24. 3
      frontend/app/shared/state/languages.forms.ts
  25. 2
      frontend/app/shared/state/languages.state.spec.ts
  26. 4
      frontend/app/shared/state/languages.state.ts
  27. 2
      frontend/app/shell/pages/internal/notifications-menu.component.html
  28. 2
      frontend/app/shell/pages/internal/profile-menu.component.html

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

@ -46,7 +46,7 @@ namespace Squidex.Domain.Apps.Core.Contents.Json
var value = serializer.Deserialize<IJsonValue>(reader)!; var value = serializer.Deserialize<IJsonValue>(reader)!;
if (Language.IsValidLanguage(propertyName) || propertyName == InvariantPartitioning.Key) if (Language.IsDefault(propertyName) || propertyName == InvariantPartitioning.Key)
{ {
propertyName = string.Intern(propertyName); propertyName = string.Intern(propertyName);
} }

8
backend/src/Squidex.Infrastructure/Language.cs

@ -20,12 +20,12 @@ namespace Squidex.Infrastructure
{ {
Guard.NotNullOrEmpty(iso2Code, nameof(iso2Code)); Guard.NotNullOrEmpty(iso2Code, nameof(iso2Code));
if (!LanguageByCode.TryGetValue(iso2Code, out var result)) if (LanguageByCode.TryGetValue(iso2Code, out var result))
{ {
throw new NotSupportedException($"Language {iso2Code} is not supported"); return result;
} }
return result; return new Language(iso2Code.Trim());
} }
public static IReadOnlyCollection<Language> AllLanguages public static IReadOnlyCollection<Language> AllLanguages
@ -50,7 +50,7 @@ namespace Squidex.Infrastructure
Iso2Code = iso2Code; Iso2Code = iso2Code;
} }
public static bool IsValidLanguage(string iso2Code) public static bool IsDefault(string iso2Code)
{ {
Guard.NotNull(iso2Code, nameof(iso2Code)); Guard.NotNull(iso2Code, nameof(iso2Code));

30
backend/tests/Squidex.Infrastructure.Tests/LanguageTests.cs

@ -27,9 +27,27 @@ namespace Squidex.Infrastructure
} }
[Fact] [Fact]
public void Should_throw_exception_if_getting_by_unsupported_language() public void Should_provide_custom_language()
{ {
Assert.Throws<NotSupportedException>(() => Language.GetLanguage("xy")); var result = Language.GetLanguage("xy");
Assert.Equal("xy", result.Iso2Code);
}
[Fact]
public void Should_trim_custom_language()
{
var result = Language.GetLanguage("xy ");
Assert.Equal("xy", result.Iso2Code);
}
[Fact]
public void Should_provide_default_language()
{
var result = Language.GetLanguage("de");
Assert.Same(Language.DE, result);
} }
[Fact] [Fact]
@ -39,15 +57,15 @@ namespace Squidex.Infrastructure
} }
[Fact] [Fact]
public void Should_return_true_for_valid_language() public void Should_return_true_for_default_language()
{ {
Assert.True(Language.IsValidLanguage("de")); Assert.True(Language.IsDefault("de"));
} }
[Fact] [Fact]
public void Should_return_false_for_invalid_language() public void Should_return_false_for_custom_language()
{ {
Assert.False(Language.IsValidLanguage("xx")); Assert.False(Language.IsDefault("xx"));
} }
[Fact] [Fact]

16
backend/tools/TestSuite/TestSuite.ApiTests/AppLanguagesTests.cs

@ -41,6 +41,22 @@ namespace TestSuite.ApiTests
Assert.Equal(new string[] { "en", "de", "it" }, languages_1.Items.Select(x => x.Iso2Code).ToArray()); Assert.Equal(new string[] { "en", "de", "it" }, languages_1.Items.Select(x => x.Iso2Code).ToArray());
} }
[Fact]
public async Task Should_add_custom_language()
{
// STEP 0: Add app.
await CreateAppAsync();
// STEP 1: Add languages.
await AddLanguageAsync("abc");
await AddLanguageAsync("xyz");
var languages_1 = await _.Apps.GetLanguagesAsync(appName);
Assert.Equal(new string[] { "en", "abc", "xyz" }, languages_1.Items.Select(x => x.Iso2Code).ToArray());
}
[Fact] [Fact]
public async Task Should_update_language() public async Task Should_update_language()
{ {

6
frontend/app/features/settings/pages/contributors/contributor-add-form.component.ts

@ -7,7 +7,7 @@
import { Component, Injectable, Input, OnChanges } from '@angular/core'; import { Component, Injectable, Input, OnChanges } from '@angular/core';
import { AssignContributorForm, AutocompleteSource, ContributorsState, DialogModel, DialogService, RoleDto, UsersService } from '@app/shared'; import { AssignContributorForm, AutocompleteSource, ContributorsState, DialogModel, DialogService, RoleDto, UsersService } from '@app/shared';
import { Observable } from 'rxjs'; import { Observable, of } from 'rxjs';
import { withLatestFrom } from 'rxjs/operators'; import { withLatestFrom } from 'rxjs/operators';
@Injectable() @Injectable()
@ -19,6 +19,10 @@ export class UsersDataSource implements AutocompleteSource {
} }
public find(query: string): Observable<ReadonlyArray<any>> { public find(query: string): Observable<ReadonlyArray<any>> {
if (!query) {
return of([]);
}
return this.usersService.getUsers(query).pipe( return this.usersService.getUsers(query).pipe(
withLatestFrom(this.contributorsState.contributors, (users, contributors) => { withLatestFrom(this.contributorsState.contributors, (users, contributors) => {
const results: any[] = []; const results: any[] = [];

8
frontend/app/features/settings/pages/languages/language-add-form.component.html

@ -4,9 +4,11 @@
<form [formGroup]="addLanguageForm.form" (ngSubmit)="addLanguage()"> <form [formGroup]="addLanguageForm.form" (ngSubmit)="addLanguage()">
<div class="row gx-2"> <div class="row gx-2">
<div class="col"> <div class="col">
<select class="form-select" formControlName="language"> <sqx-autocomplete formControlName="language" displayProperty="iso2Code" [source]="addLanguagesSource">
<option *ngFor="let language of newLanguages" [ngValue]="language">{{language.englishName}}</option> <ng-template let-language="$implicit">
</select> {{language.iso2Code}} ({{language.englishName}})
</ng-template>
</sqx-autocomplete>
</div> </div>
<div class="col-auto"> <div class="col-auto">
<button type="submit" class="btn btn-success"> <button type="submit" class="btn btn-success">

49
frontend/app/features/settings/pages/languages/language-add-form.component.ts

@ -5,8 +5,38 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { ChangeDetectionStrategy, Component, Input, OnChanges } from '@angular/core'; import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { AddLanguageForm, LanguageDto, LanguagesState } from '@app/shared'; import { AddLanguageForm, AutocompleteSource, LanguageDto, LanguagesState } from '@app/shared';
import { Observable, of } from 'rxjs';
class LanguageSource implements AutocompleteSource {
constructor(
private readonly languages: ReadonlyArray<LanguageDto>,
) {
}
public find(query: string): Observable<ReadonlyArray<any>> {
if (!query) {
return of(this.languages);
}
const regex = new RegExp(query, 'i');
const results: LanguageDto[] = [];
const result = this.languages.find(x => x.iso2Code === query);
if (result) {
results.push(result);
}
results.push(...this.languages.filter(x =>
x.iso2Code !== query && (
regex.test(x.iso2Code) ||
regex.test(x.englishName))));
return of(results);
}
}
@Component({ @Component({
selector: 'sqx-language-add-form', selector: 'sqx-language-add-form',
@ -14,10 +44,13 @@ import { AddLanguageForm, LanguageDto, LanguagesState } from '@app/shared';
templateUrl: './language-add-form.component.html', templateUrl: './language-add-form.component.html',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class LanguageAddFormComponent implements OnChanges { export class LanguageAddFormComponent {
@Input() @Input()
public newLanguages: ReadonlyArray<LanguageDto>; public set newLanguages(value: ReadonlyArray<LanguageDto>) {
this.addLanguagesSource = new LanguageSource(value);
}
public addLanguagesSource = new LanguageSource([]);
public addLanguageForm = new AddLanguageForm(); public addLanguageForm = new AddLanguageForm();
constructor( constructor(
@ -25,14 +58,6 @@ export class LanguageAddFormComponent implements OnChanges {
) { ) {
} }
public ngOnChanges() {
if (this.newLanguages.length > 0) {
const language = this.newLanguages[0];
this.addLanguageForm.load({ language });
}
}
public addLanguage() { public addLanguage() {
const value = this.addLanguageForm.submit(); const value = this.addLanguageForm.submit();

7
frontend/app/features/settings/pages/roles/roles-page.component.ts

@ -9,15 +9,20 @@ import { Component, OnInit } from '@angular/core';
import { AppsState, AutocompleteSource, RoleDto, RolesService, RolesState, SchemasState } from '@app/shared'; import { AppsState, AutocompleteSource, RoleDto, RolesService, RolesState, SchemasState } from '@app/shared';
import { Observable, of } from 'rxjs'; import { Observable, of } from 'rxjs';
/* eslint-disable no-return-assign */
class PermissionsAutocomplete implements AutocompleteSource { class PermissionsAutocomplete implements AutocompleteSource {
private permissions: ReadonlyArray<string> = []; private permissions: ReadonlyArray<string> = [];
constructor(appsState: AppsState, rolesService: RolesService) { constructor(appsState: AppsState, rolesService: RolesService) {
// eslint-disable-next-line no-return-assign
rolesService.getPermissions(appsState.appName).subscribe(x => this.permissions = x); rolesService.getPermissions(appsState.appName).subscribe(x => this.permissions = x);
} }
public find(query: string): Observable<ReadonlyArray<any>> { public find(query: string): Observable<ReadonlyArray<any>> {
if (!query) {
return of(this.permissions);
}
return of(this.permissions.filter(y => y.indexOf(query) === 0)); return of(this.permissions.filter(y => y.indexOf(query) === 0));
} }
} }

17
frontend/app/framework/angular/forms/editors/autocomplete.component.html

@ -15,15 +15,24 @@
<i class="icon-{{icon}}" [class.icon-spinner2]="snapshot.isLoading" [class.spin2]="snapshot.isLoading"></i> <i class="icon-{{icon}}" [class.icon-spinner2]="snapshot.isLoading" [class.spin2]="snapshot.isLoading"></i>
</div> </div>
<ng-container *sqxModal="snapshot.suggestedItems.length > 0"> <div class="btn btn-sm" (click)="openModal()" sqxStopClick *ngIf="allowOpen">
<sqx-dropdown-menu class="control-dropdown" [sqxAnchoredTo]="input" [scrollY]="true" [style.width]="dropdownWidth" [position]="dropdownPosition" #container> <i class="icon-caret-down"></i>
</div>
<ng-container *sqxModal="suggestionsModal">
<sqx-dropdown-menu class="control-dropdown" #container
[sqxAnchoredTo]="input"
[adjustWidth]="true"
[adjustHeight]="false"
[scrollY]="true"
[style.minWidth]="dropdownWidth"
[position]="dropdownPosition">
<div *ngFor="let item of snapshot.suggestedItems; let i = index" class="control-dropdown-item control-dropdown-item-selectable" <div *ngFor="let item of snapshot.suggestedItems; let i = index" class="control-dropdown-item control-dropdown-item-selectable"
[class.active]="i === snapshot.suggestedIndex" [class.active]="i === snapshot.suggestedIndex"
(mousedown)="selectItem(item)" (mousedown)="selectItem(item)"
(mouseover)="selectIndex(i)" (mouseover)="selectIndex(i)"
[sqxScrollActive]="i === snapshot.suggestedIndex" [sqxScrollActive]="i === snapshot.suggestedIndex"
[sqxScrollContainer]="$any(container)"> [sqxScrollContainer]="$any(container.nativeElement)">
<ng-container *ngIf="!itemTemplate">{{item}}</ng-container> <ng-container *ngIf="!itemTemplate">{{item}}</ng-container>
<ng-template *ngIf="itemTemplate" [sqxTemplateWrapper]="itemTemplate" [item]="item" [index]="i"></ng-template> <ng-template *ngIf="itemTemplate" [sqxTemplateWrapper]="itemTemplate" [item]="item" [index]="i"></ng-template>

10
frontend/app/framework/angular/forms/editors/autocomplete.component.scss

@ -11,4 +11,14 @@
color: $color-input; color: $color-input;
font-size: 1.1rem; font-size: 1.1rem;
font-weight: normal; font-weight: normal;
}
.btn {
@include absolute(.25rem, 0, null, null);
border: 0;
cursor: pointer;
font-size: $font-small;
font-weight: normal;
padding-left: 5px;
padding-right: 5px;
} }

129
frontend/app/framework/angular/forms/editors/autocomplete.component.ts

@ -7,10 +7,9 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChild, ElementRef, forwardRef, Input, OnDestroy, OnInit, TemplateRef, ViewChild } from '@angular/core'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChild, ElementRef, forwardRef, Input, OnDestroy, OnInit, TemplateRef, ViewChild } from '@angular/core';
import { FormControl, NG_VALUE_ACCESSOR } from '@angular/forms'; import { FormControl, NG_VALUE_ACCESSOR } from '@angular/forms';
import { Keys, StatefulControlComponent, Types } from '@app/framework/internal'; import { Keys, ModalModel, RelativePosition, StatefulControlComponent, Types } from '@app/framework/internal';
import { RelativePosition } from '@app/shared'; import { merge, Observable, of, Subject } from 'rxjs';
import { Observable, of } from 'rxjs'; import { catchError, debounceTime, finalize, map, switchMap, tap } from 'rxjs/operators';
import { catchError, debounceTime, distinctUntilChanged, finalize, map, switchMap, tap } from 'rxjs/operators';
export interface AutocompleteSource { export interface AutocompleteSource {
find(query: string): Observable<ReadonlyArray<any>>; find(query: string): Observable<ReadonlyArray<any>>;
@ -46,6 +45,7 @@ const NO_EMIT = { emitEvent: false };
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class AutocompleteComponent extends StatefulControlComponent<State, ReadonlyArray<any>> implements OnInit, OnDestroy { export class AutocompleteComponent extends StatefulControlComponent<State, ReadonlyArray<any>> implements OnInit, OnDestroy {
private readonly modalStream = new Subject<string>();
private timer: any; private timer: any;
@Input() @Input()
@ -57,6 +57,9 @@ export class AutocompleteComponent extends StatefulControlComponent<State, Reado
@Input() @Input()
public inputStyle: 'underlined' | 'empty'; public inputStyle: 'underlined' | 'empty';
@Input()
public allowOpen?: boolean | null = true;
@Input() @Input()
public displayProperty: string; public displayProperty: string;
@ -89,6 +92,8 @@ export class AutocompleteComponent extends StatefulControlComponent<State, Reado
@ViewChild('input', { static: false }) @ViewChild('input', { static: false })
public inputControl: ElementRef<HTMLInputElement>; public inputControl: ElementRef<HTMLInputElement>;
public suggestionsModal = new ModalModel();
public queryInput = new FormControl(); public queryInput = new FormControl();
constructor(changeDetector: ChangeDetectorRef) { constructor(changeDetector: ChangeDetectorRef) {
@ -103,41 +108,51 @@ export class AutocompleteComponent extends StatefulControlComponent<State, Reado
} }
public ngOnInit() { public ngOnInit() {
this.own( this.changes.subscribe(state => {
if (state.suggestedItems.length > 0) {
this.suggestionsModal.show();
} else {
this.suggestionsModal.hide();
}
});
const inputStream =
this.queryInput.valueChanges.pipe( this.queryInput.valueChanges.pipe(
tap(query => { tap(query => {
this.callChange(query); this.callChange(query);
}), }),
map(query => { map(query => {
if (Types.isString(query)) { if (Types.isString(query)) {
return query.trim(); return query.trim();
} else { } else {
return ''; return '';
} }
}), }),
debounceTime(this.debounceTime), debounceTime(this.debounceTime));
distinctUntilChanged(),
switchMap(query => { this.own(
if (!query || !this.source) { merge(inputStream, this.modalStream).pipe(
return of([]); switchMap(query => {
} else { if (!this.source) {
this.setLoading(true); return of([]);
} else {
return this.source.find(query).pipe( this.setLoading(true);
finalize(() => {
this.setLoading(false); return this.source.find(query).pipe(
}), finalize(() => {
catchError(() => of([])), this.setLoading(false);
); }),
} catchError(() => of([])),
})) );
.subscribe(items => { }
this.next({ }))
suggestedIndex: -1, .subscribe(items => {
suggestedItems: items || [], this.next({
isSearching: false, suggestedIndex: -1,
}); suggestedItems: items || [],
})); isSearching: false,
});
}));
} }
public onKeyDown(event: KeyboardEvent) { public onKeyDown(event: KeyboardEvent) {
@ -179,6 +194,10 @@ export class AutocompleteComponent extends StatefulControlComponent<State, Reado
} }
} }
public openModal() {
this.modalStream.next('');
}
public reset() { public reset() {
this.resetState(); this.resetState();
@ -206,24 +225,30 @@ export class AutocompleteComponent extends StatefulControlComponent<State, Reado
selection = this.snapshot.suggestedItems[0]; selection = this.snapshot.suggestedItems[0];
} }
if (selection) { if (!selection) {
try { return false;
if (this.displayProperty && this.displayProperty.length > 0) { }
this.queryInput.setValue(selection[this.displayProperty], NO_EMIT);
} else { try {
this.queryInput.setValue(selection.toString(), NO_EMIT); if (this.displayProperty && this.displayProperty.length > 0) {
} this.queryInput.setValue(selection[this.displayProperty], NO_EMIT);
} else {
this.callChange(selection); this.queryInput.setValue(selection.toString(), NO_EMIT);
this.callTouched();
} finally {
this.resetState();
} }
return true; let value = selection;
if (this.displayProperty) {
value = selection[this.displayProperty];
}
this.callChange(value);
this.callTouched();
} finally {
this.resetState();
} }
return false; return true;
} }
private setLoading(value: boolean) { private setLoading(value: boolean) {

7
frontend/app/framework/angular/forms/editors/dropdown.component.html

@ -15,7 +15,12 @@
<div class="items-container"> <div class="items-container">
<ng-container *sqxModal="dropdown"> <ng-container *sqxModal="dropdown">
<sqx-dropdown-menu [sqxAnchoredTo]="input" [scrollY]="true" position="bottom-left"> <sqx-dropdown-menu
[sqxAnchoredTo]="input"
[adjustWidth]="true"
[adjustHeight]="false"
[scrollY]="true"
position="bottom-left">
<div *ngIf="canSearch" class="search-form"> <div *ngIf="canSearch" class="search-form">
<input class="form-control search" [formControl]="queryInput" placeholder="{{ 'contributors.search' | sqxTranslate }}" (keydown)="onKeyDown($event)" sqxFocusOnInit> <input class="form-control search" [formControl]="queryInput" placeholder="{{ 'contributors.search' | sqxTranslate }}" (keydown)="onKeyDown($event)" sqxFocusOnInit>
</div> </div>

24
frontend/app/framework/angular/forms/editors/tag-editor.component.html

@ -1,5 +1,7 @@
<div class="form-container"> <div class="form-container">
<div class="form-control tags" #form (click)="input.focus()" <div class="form-control tags" tabindex="0" #form
(mousedown)="focusInput($event)"
(focus)="focusInput($event)"
[class.blank]="styleBlank" [class.blank]="styleBlank"
[class.singleline]="singleLine" [class.singleline]="singleLine"
[class.readonly]="readonly" [class.readonly]="readonly"
@ -8,7 +10,6 @@
[class.focus]="snapshot.hasFocus" [class.focus]="snapshot.hasFocus"
[class.disabled]="snapshot.isDisabled" [class.disabled]="snapshot.isDisabled"
[class.dashed]="dashed && !(snapshot.items.length > 0)"> [class.dashed]="dashed && !(snapshot.items.length > 0)">
<span class="item" *ngFor="let item of snapshot.items; let i = index" [class.disabled]="addInput.disabled"> <span class="item" *ngFor="let item of snapshot.items; let i = index" [class.disabled]="addInput.disabled">
{{item}} <i class="icon-close" (click)="remove(i)"></i> {{item}} <i class="icon-close" (click)="remove(i)"></i>
</span> </span>
@ -24,12 +25,18 @@
[formControl]="addInput"> [formControl]="addInput">
</div> </div>
<div class="btn btn-sm" (click)="openModal()" sqxStopClick *ngIf="allowOpen || suggestionsSorted.length > 0"> <div class="btn btn-sm" (click)="openModal()" sqxStopClick *ngIf="!readonly && (allowOpen || suggestionsSorted.length > 0)">
<i class="icon-caret-down"></i> <i class="icon-caret-down"></i>
</div> </div>
<ng-container *sqxModal="snapshot.suggestedItems.length > 0"> <ng-container *sqxModal="snapshot.suggestedItems.length > 0">
<sqx-dropdown-menu class="control-dropdown" [sqxAnchoredTo]="form" [scrollY]="true" position="bottom-left" #container> <sqx-dropdown-menu class="control-dropdown"
[sqxAnchoredTo]="form"
[adjustWidth]="true"
[adjustHeight]="false"
[scrollY]="true"
[style.minWidth]="dropdownWidth"
position="bottom-left" #container>
<div *ngFor="let item of snapshot.suggestedItems; let i = index" class="control-dropdown-item control-dropdown-item-selectable" <div *ngFor="let item of snapshot.suggestedItems; let i = index" class="control-dropdown-item control-dropdown-item-selectable"
[class.active]="i === snapshot.suggestedIndex" [class.active]="i === snapshot.suggestedIndex"
[class.separated]="separated" [class.separated]="separated"
@ -42,9 +49,14 @@
</sqx-dropdown-menu> </sqx-dropdown-menu>
</ng-container> </ng-container>
<ng-container *ngIf="allowOpen || suggestionsSorted.length > 0"> <ng-container *ngIf="allowOpen && suggestionsSorted.length > 0">
<ng-container *sqxModal="suggestionsModal"> <ng-container *sqxModal="suggestionsModal">
<sqx-dropdown-menu class="control-dropdown suggestions-dropdown" [sqxAnchoredTo]="form" position="bottom-left"> <sqx-dropdown-menu class="control-dropdown suggestions-dropdown"
[sqxAnchoredTo]="form"
[adjustWidth]="false"
[adjustHeight]="false"
[scrollY]="true"
position="bottom-left">
<div class="row"> <div class="row">
<div class=" col-6" *ngFor="let item of suggestionsSorted; let i = index"> <div class=" col-6" *ngFor="let item of suggestionsSorted; let i = index">
<div class="form-check form-check"> <div class="form-check form-check">

1
frontend/app/framework/angular/forms/editors/tag-editor.component.scss

@ -24,6 +24,7 @@ $inner-height: 1.75rem;
position: relative; position: relative;
text-align: left; text-align: left;
text-decoration: none; text-decoration: none;
user-select: none;
&.suggested { &.suggested {
padding-right: 2rem; padding-right: 2rem;

9
frontend/app/framework/angular/forms/editors/tag-editor.component.ts

@ -94,6 +94,9 @@ export class TagEditorComponent extends StatefulControlComponent<State, Readonly
@Input() @Input()
public inputName = 'tag-editor'; public inputName = 'tag-editor';
@Input()
public dropdownWidth = '18rem';
@Input() @Input()
public set disabled(value: boolean | undefined | null) { public set disabled(value: boolean | undefined | null) {
this.setDisabledState(value === true); this.setDisabledState(value === true);
@ -416,6 +419,12 @@ export class TagEditorComponent extends StatefulControlComponent<State, Readonly
super.callTouched(); super.callTouched();
} }
public focusInput(event: Event) {
this.inputElement.nativeElement.focus();
event?.preventDefault();
}
public onCut(event: ClipboardEvent) { public onCut(event: ClipboardEvent) {
if (!this.hasSelection()) { if (!this.hasSelection()) {
this.onCopy(event); this.onCopy(event);

2
frontend/app/framework/angular/forms/error-validator.spec.ts

@ -6,7 +6,7 @@
*/ */
import { FormArray, FormControl, FormGroup } from '@angular/forms'; import { FormArray, FormControl, FormGroup } from '@angular/forms';
import { ErrorDto } from '@app/shared'; import { ErrorDto } from '@app/framework/internal';
import { ErrorValidator } from './error-validator'; import { ErrorValidator } from './error-validator';
describe('ErrorValidator', () => { describe('ErrorValidator', () => {

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

@ -40,7 +40,11 @@
</div> </div>
<ng-container *ngFor="let tooltip of snapshot.tooltips"> <ng-container *ngFor="let tooltip of snapshot.tooltips">
<div class="tooltip2 tooltip2-{{tooltip.position}}" [sqxAnchoredTo]="tooltip.target" [position]="tooltip.position" [offset]="6"> <div class="tooltip2 tooltip2-{{tooltip.textPosition}}"
[sqxAnchoredTo]="tooltip.target"
[position]="tooltip.textPosition"
[offsetY]="6"
[offsetX]="6">
{{tooltip.text | sqxTranslate}} {{tooltip.text | sqxTranslate}}
<ng-container *ngIf="tooltip.shortcut"> <ng-container *ngIf="tooltip.shortcut">

126
frontend/app/framework/angular/modals/modal-placement.directive.ts

@ -6,8 +6,7 @@
*/ */
import { AfterViewInit, Directive, ElementRef, Input, OnDestroy, Renderer2 } from '@angular/core'; import { AfterViewInit, Directive, ElementRef, Input, OnDestroy, Renderer2 } from '@angular/core';
import { positionModal, ResourceOwner } from '@app/framework/internal'; import { AnchorX, AnchorY, computeAnchors, positionModal, PositionRequest, RelativePosition, ResourceOwner } from '@app/framework/internal';
import { RelativePosition } from '@app/shared';
import { timer } from 'rxjs'; import { timer } from 'rxjs';
@Directive({ @Directive({
@ -25,16 +24,35 @@ export class ModalPlacementDirective extends ResourceOwner implements AfterViewI
if (element) { if (element) {
this.listenToElement(element); this.listenToElement(element);
this.updatePosition();
} }
this.updatePosition();
} }
} }
@Input() @Input()
public offset = 2; public offsetX = 0;
@Input() @Input()
public position: RelativePosition | 'full' = 'bottom-right'; public offsetY = 2;
@Input()
public spaceX = 0;
@Input()
public spaceY = 0;
@Input()
public anchorX: AnchorX = 'right-to-right';
@Input()
public anchorY: AnchorY = 'top-to-bottom';
@Input()
public adjustWidth = false;
@Input()
public adjustHeight = false;
@Input() @Input()
public scrollX = false; public scrollX = false;
@ -48,6 +66,14 @@ export class ModalPlacementDirective extends ResourceOwner implements AfterViewI
@Input() @Input()
public update = true; public update = true;
@Input()
public set position(value: RelativePosition) {
const [anchorX, anchorY] = computeAnchors(value);
this.anchorX = anchorX;
this.anchorY = anchorY;
}
constructor( constructor(
private readonly renderer: Renderer2, private readonly renderer: Renderer2,
private readonly element: ElementRef<HTMLElement>, private readonly element: ElementRef<HTMLElement>,
@ -94,59 +120,69 @@ export class ModalPlacementDirective extends ResourceOwner implements AfterViewI
const modalRef = this.element.nativeElement; const modalRef = this.element.nativeElement;
const modalRect = this.element.nativeElement.getBoundingClientRect(); const modalRect = this.element.nativeElement.getBoundingClientRect();
if ((modalRect.width === 0 || modalRect.height === 0) && this.position !== 'full') { if ((modalRect.width === 0 && !this.adjustWidth) || (modalRect.height === 0 && !this.adjustHeight)) {
return; return;
} }
const targetRect = this.targetElement.getBoundingClientRect(); const targetRect = this.targetElement.getBoundingClientRect();
let y: number; if (this.scrollX) {
let x: number; modalRect.width = modalRef.scrollWidth;
}
if (this.position === 'full') {
x = -this.offset + targetRect.left;
y = -this.offset + targetRect.top;
const w = 2 * this.offset + targetRect.width;
const h = 2 * this.offset + targetRect.height;
this.renderer.setStyle(modalRef, 'width', `${w}px`);
this.renderer.setStyle(modalRef, 'height', `${h}px`);
} else {
if (this.scrollX) {
modalRect.width = modalRef.scrollWidth;
}
if (this.scrollY) {
modalRect.height = modalRef.scrollHeight;
}
const viewportHeight = document.documentElement!.clientHeight; if (this.scrollY) {
const viewportWidth = document.documentElement!.clientWidth; modalRect.height = modalRef.scrollHeight;
}
const position = positionModal(targetRect, modalRect, this.position, this.offset, this.update, viewportWidth, viewportHeight); const clientHeight = document.documentElement!.clientHeight;
const clientWidth = document.documentElement!.clientWidth;
const request: PositionRequest = {
adjust: this.update,
anchorX: this.anchorX,
anchorY: this.anchorY,
clientHeight,
clientWidth,
computeHeight: this.adjustHeight,
computeWidth: this.adjustWidth,
modalRect,
offsetX: this.offsetX,
offsetY: this.offsetY,
targetRect,
};
const position = positionModal(request);
if (this.scrollX) {
const maxWidth = position.maxWidth > 0 ? `${position.maxWidth - this.scrollMargin}px` : 'none';
this.renderer.setStyle(modalRef, 'overflow-x', 'auto');
this.renderer.setStyle(modalRef, 'overflow-y', 'none');
this.renderer.setStyle(modalRef, 'max-width', maxWidth);
}
x = position.x; if (this.scrollY) {
y = position.y; const maxHeight = position.maxHeight > 0 ? `${position.maxHeight - this.scrollMargin}px` : 'none';
if (this.scrollX) { this.renderer.setStyle(modalRef, 'overflow-x', 'none');
const maxWidth = position.xMax > 0 ? `${position.xMax - 10}px` : 'none'; this.renderer.setStyle(modalRef, 'overflow-y', 'auto');
this.renderer.setStyle(modalRef, 'max-height', maxHeight);
}
this.renderer.setStyle(modalRef, 'overflow-x', 'auto'); if (position.width) {
this.renderer.setStyle(modalRef, 'max-width', maxWidth); this.renderer.setStyle(modalRef, 'width', `${position.width}px`);
this.renderer.setStyle(modalRef, 'min-width', 0); }
}
if (this.scrollY) { if (position.height) {
const maxHeight = position.yMax > 0 ? `${position.yMax - 10}px` : 'none'; this.renderer.setStyle(modalRef, 'height', `${position.height}px`);
}
this.renderer.setStyle(modalRef, 'overflow-y', 'auto'); if (position.x) {
this.renderer.setStyle(modalRef, 'max-height', maxHeight); this.renderer.setStyle(modalRef, 'left', `${position.x}px`);
this.renderer.setStyle(modalRef, 'min-height', 0);
}
} }
this.renderer.setStyle(modalRef, 'top', `${y}px`); if (position.y) {
this.renderer.setStyle(modalRef, 'left', `${x}px`); this.renderer.setStyle(modalRef, 'top', `${position.y}px`);
}
} }
} }

16
frontend/app/framework/angular/modals/onboarding-tooltip.component.html

@ -1,6 +1,18 @@
<ng-container *sqxModal="tooltipModal"> <ng-container *sqxModal="tooltipModal">
<div class="onboarding-rect" [sqxAnchoredTo]="for" [offset]="4" [position]="'full'"></div> <div class="onboarding-rect"
<div class="onboarding-help" [sqxAnchoredTo]="for" [offset]="4" [position]="position" @fade> [sqxAnchoredTo]="for"
[offsetX]="4"
[offsetY]="4"
[adjustWidth]="true"
[adjustHeight]="true"
anchorX="left-to-left"
anchorY="top-to-top"></div>
<div class="onboarding-help"
[sqxAnchoredTo]="for"
[offsetX]="4"
[offsetY]="4"
[position]="position" @fade>
<small class="onboarding-text"> <small class="onboarding-text">
<ng-content></ng-content> <ng-content></ng-content>
</small> </small>

51
frontend/app/framework/angular/modals/onboarding-tooltip.component.ts

@ -6,8 +6,7 @@
*/ */
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnDestroy, OnInit, Renderer2 } from '@angular/core'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnDestroy, OnInit, Renderer2 } from '@angular/core';
import { DialogModel, fadeAnimation, OnboardingService, StatefulComponent, Types } from '@app/framework/internal'; import { DialogModel, fadeAnimation, OnboardingService, RelativePosition, StatefulComponent, Types } from '@app/framework/internal';
import { RelativePosition } from '@app/shared';
import { timer } from 'rxjs'; import { timer } from 'rxjs';
@Component({ @Component({
@ -48,37 +47,39 @@ export class OnboardingTooltipComponent extends StatefulComponent implements OnD
} }
public ngOnInit() { public ngOnInit() {
if (this.for && this.helpId && Types.isFunction(this.for.addEventListener)) { if (!this.helpId || !Types.isFunction(this.for?.addEventListener)) {
this.own( return;
timer(this.after).subscribe(() => { }
if (this.onboardingService.shouldShow(this.helpId)) {
const forRect = this.for.getBoundingClientRect(); this.own(
timer(this.after).subscribe(() => {
if (this.onboardingService.shouldShow(this.helpId)) {
const forRect = this.for.getBoundingClientRect();
const x = forRect.left + 0.5 * forRect.width; const x = forRect.left + 0.5 * forRect.width;
const y = forRect.top + 0.5 * forRect.height; const y = forRect.top + 0.5 * forRect.height;
const fromPoint = document.elementFromPoint(x, y); const fromPoint = document.elementFromPoint(x, y);
if (this.isSameOrParent(fromPoint)) { if (this.isSameOrParent(fromPoint)) {
this.tooltipModal.show(); this.tooltipModal.show();
this.own( this.own(
timer(10000).subscribe(() => { timer(10000).subscribe(() => {
this.hideThis(); this.hideThis();
})); }));
this.onboardingService.disable(this.helpId); this.onboardingService.disable(this.helpId);
}
} }
})); }
}));
this.own( this.own(
this.renderer.listen(this.for, 'mousedown', () => { this.renderer.listen(this.for, 'mousedown', () => {
this.onboardingService.disable(this.helpId); this.onboardingService.disable(this.helpId);
this.hideThis(); this.hideThis();
})); }));
}
} }
private isSameOrParent(underCursor: Element | null): boolean { private isSameOrParent(underCursor: Element | null): boolean {

2
frontend/app/framework/services/dialog.service.ts

@ -61,7 +61,7 @@ export class Tooltip {
constructor( constructor(
public readonly target: any, public readonly target: any,
public readonly text: string | null | undefined, public readonly text: string | null | undefined,
public readonly position: RelativePosition, public readonly textPosition: RelativePosition,
public readonly multiple?: boolean, public readonly multiple?: boolean,
public readonly shortcut?: string, public readonly shortcut?: string,
) { ) {

107
frontend/app/framework/utils/modal-positioner.spec.ts

@ -5,7 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { positionModal } from './modal-positioner'; import { computeAnchors, positionModal, PositionRequest, SimplePosition } from './modal-positioner';
describe('position', () => { describe('position', () => {
function buildRect(x: number, y: number, w: number, h: number): any { function buildRect(x: number, y: number, w: number, h: number): any {
@ -21,26 +21,39 @@ describe('position', () => {
const targetRect = buildRect(200, 200, 100, 100); const targetRect = buildRect(200, 200, 100, 100);
const tests = [ const tests: { position: SimplePosition; x: number; y: number }[] = [
{ position: 'bottom-center', x: 235, y: 310 }, { position: 'bottom-center', x: 235, y: 310 },
{ position: 'bottom-left', x: 200, y: 310 }, { position: 'bottom-left', x: 210, y: 310 },
{ position: 'bottom-right', x: 270, y: 310 }, { position: 'bottom-right', x: 260, y: 310 },
{ position: 'left-bottom', x: 160, y: 270 }, { position: 'left-bottom', x: 160, y: 260 },
{ position: 'left-center', x: 160, y: 235 }, { position: 'left-center', x: 160, y: 235 },
{ position: 'left-top', x: 160, y: 200 }, { position: 'left-top', x: 160, y: 210 },
{ position: 'right-bottom', x: 310, y: 270 }, { position: 'right-bottom', x: 310, y: 260 },
{ position: 'right-center', x: 310, y: 235 }, { position: 'right-center', x: 310, y: 235 },
{ position: 'right-top', x: 310, y: 200 }, { position: 'right-top', x: 310, y: 210 },
{ position: 'top-center', x: 235, y: 160 }, { position: 'top-center', x: 235, y: 160 },
{ position: 'top-left', x: 200, y: 160 }, { position: 'top-left', x: 210, y: 160 },
{ position: 'top-right', x: 270, y: 160 }, { position: 'top-right', x: 260, y: 160 },
]; ];
tests.forEach(test => { tests.forEach(test => {
it(`should calculate modal position for ${test.position}`, () => { it(`should calculate modal position for ${test.position}`, () => {
const modalRect = buildRect(0, 0, 30, 30); const modalRect = buildRect(0, 0, 30, 30);
const result = positionModal(targetRect, modalRect, test.position as any, 10, false, 1000, 1000); const [anchorX, anchorY] = computeAnchors(test.position);
const request: PositionRequest = {
anchorX,
anchorY,
clientHeight: 1000,
clientWidth: 1000,
offsetX: 10,
offsetY: 10,
modalRect,
targetRect,
};
const result = positionModal(request);
expect(result.x).toBe(test.x); expect(result.x).toBe(test.x);
expect(result.y).toBe(test.y); expect(result.y).toBe(test.y);
@ -50,36 +63,92 @@ describe('position', () => {
it('should calculate modal position for vertical top fix', () => { it('should calculate modal position for vertical top fix', () => {
const modalRect = buildRect(0, 0, 30, 200); const modalRect = buildRect(0, 0, 30, 200);
const result = positionModal(targetRect, modalRect, 'top-left', 10, true, 600, 600); const [anchorX, anchorY] = computeAnchors('top-left');
const request: PositionRequest = {
adjust: true,
anchorX,
anchorY,
clientHeight: 600,
clientWidth: 600,
modalRect,
offsetX: 10,
offsetY: 10,
targetRect,
};
expect(result.x).toBe(200); const result = positionModal(request);
expect(result.x).toBe(210);
expect(result.y).toBe(310); expect(result.y).toBe(310);
}); });
it('should calculate modal position for vertical bottom fix', () => { it('should calculate modal position for vertical bottom fix', () => {
const modalRect = buildRect(0, 0, 30, 70); const modalRect = buildRect(0, 0, 30, 70);
const result = positionModal(targetRect, modalRect, 'bottom-left', 10, true, 350, 350); const [anchorX, anchorY] = computeAnchors('bottom-left');
const request: PositionRequest = {
adjust: true,
anchorX,
anchorY,
clientHeight: 350,
clientWidth: 350,
modalRect,
offsetX: 10,
offsetY: 10,
targetRect,
};
const result = positionModal(request);
expect(result.x).toBe(200); expect(result.x).toBe(210);
expect(result.y).toBe(120); expect(result.y).toBe(120);
}); });
it('should calculate modal position for horizontal left fix', () => { it('should calculate modal position for horizontal left fix', () => {
const modalRect = buildRect(0, 0, 200, 30); const modalRect = buildRect(0, 0, 200, 30);
const result = positionModal(targetRect, modalRect, 'left-top', 10, true, 600, 600); const [anchorX, anchorY] = computeAnchors('left-top');
const request: PositionRequest = {
adjust: true,
anchorX,
anchorY,
clientHeight: 600,
clientWidth: 600,
modalRect,
offsetX: 10,
offsetY: 10,
targetRect,
};
const result = positionModal(request);
expect(result.x).toBe(310); expect(result.x).toBe(310);
expect(result.y).toBe(200); expect(result.y).toBe(210);
}); });
it('should calculate modal position for horizontal right fix', () => { it('should calculate modal position for horizontal right fix', () => {
const modalRect = buildRect(0, 0, 70, 30); const modalRect = buildRect(0, 0, 70, 30);
const result = positionModal(targetRect, modalRect, 'right-top', 10, true, 350, 350); const [anchorX, anchorY] = computeAnchors('right-top');
const request: PositionRequest = {
adjust: true,
anchorX,
anchorY,
clientHeight: 350,
clientWidth: 350,
modalRect,
offsetX: 10,
offsetY: 10,
targetRect,
};
const result = positionModal(request);
expect(result.x).toBe(120); expect(result.x).toBe(120);
expect(result.y).toBe(200); expect(result.y).toBe(210);
}); });
}); });

270
frontend/app/framework/utils/modal-positioner.ts

@ -5,7 +5,24 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
export type RelativePosition = import { Types } from './types';
export type AnchorX =
'center' |
'left-to-right' |
'left-to-left' |
'right-to-left' |
'right-to-right';
export type AnchorY =
'bottom-to-bottom' |
'bottom-to-top' |
'center' |
'top-to-bottom' |
'top-to-top';
export type RelativePosition = SimplePosition | [AnchorX, AnchorY];
export type SimplePosition =
'bottom-center' | 'bottom-center' |
'bottom-left' | 'bottom-left' |
'bottom-right' | 'bottom-right' |
@ -19,151 +36,208 @@ export type RelativePosition =
'top-left' | 'top-left' |
'top-right'; 'top-right';
const POSITION_BOTTOM_CENTER = 'bottom-center'; export function computeAnchors(value: RelativePosition): [AnchorX, AnchorY] {
const POSITION_BOTTOM_LEFT = 'bottom-left'; if (Types.isArray(value)) {
const POSITION_BOTTOM_RIGHT = 'bottom-right'; return value;
const POSITION_LEFT_BOTTOM = 'left-bottom'; }
const POSITION_LEFT_CENTER = 'left-center';
const POSITION_LEFT_TOP = 'left-top'; switch (value) {
const POSITION_RIGHT_BOTTOM = 'right-bottom'; case 'bottom-center':
const POSITION_RIGHT_CENTER = 'right-center'; return ['center', 'top-to-bottom'];
const POSITION_RIGHT_TOP = 'right-top'; case 'bottom-left':
const POSITION_TOP_CENTER = 'top-center'; return ['left-to-left', 'top-to-bottom'];
const POSITION_TOP_LEFT = 'top-left'; case 'bottom-right':
const POSITION_TOP_RIGHT = 'top-right'; return ['right-to-right', 'top-to-bottom'];
case 'left-bottom':
return ['right-to-left', 'bottom-to-bottom'];
case 'left-center':
return ['right-to-left', 'center'];
case 'left-top':
return ['right-to-left', 'top-to-top'];
case 'right-bottom':
return ['left-to-right', 'bottom-to-bottom'];
case 'right-center':
return ['left-to-right', 'center'];
case 'right-top':
return ['left-to-right', 'top-to-top'];
case 'top-center':
return ['center', 'bottom-to-top'];
case 'top-left':
return ['left-to-left', 'bottom-to-top'];
case 'top-right':
return ['right-to-right', 'bottom-to-top'];
default:
return ['center', 'center'];
}
}
export type PositionResult = { export type PositionResult = {
height?: number;
maxHeight: number;
maxWidth: number;
width?: number;
x: number; x: number;
y: number; y: number;
xMax: number;
yMax: number;
}; };
export function positionModal(targetRect: DOMRect, modalRect: DOMRect, relativePosition: RelativePosition, offset: number, fix: boolean, clientWidth: number, clientHeight: number): PositionResult { export type PositionRequest = {
let y = 0; adjust?: boolean;
let x = 0; anchorX: AnchorX;
anchorY: AnchorY;
clientHeight: number;
clientWidth: number;
computeHeight?: boolean;
computeWidth?: boolean;
modalRect: DOMRect;
offsetX?: number;
offsetY?: number;
spaceX?: number;
spaceY?: number;
targetRect: DOMRect;
};
// Available space in x/y direction. export function positionModal(request: PositionRequest): PositionResult {
let xMax = 0; const {
let yMax = 0; adjust,
anchorX,
anchorY,
clientHeight,
clientWidth,
computeHeight,
computeWidth,
modalRect,
offsetX,
offsetY,
spaceX,
spaceY,
targetRect,
} = request;
const actualOffsetX = offsetX || 0;
const actualOffsetY = offsetY || 0;
let height = 0;
let maxHeight = 0;
let maxWidth = 0;
let width = 0;
let x = 0;
let y = 0;
switch (relativePosition) { switch (anchorY) {
case POSITION_LEFT_TOP: case 'center':
case POSITION_RIGHT_TOP: { y = targetRect.top + targetRect.height * 0.5 - modalRect.height * 0.5;
y = targetRect.top;
break; break;
} case 'top-to-top': {
case POSITION_LEFT_BOTTOM: y = targetRect.top + actualOffsetY;
case POSITION_RIGHT_BOTTOM: {
y = targetRect.bottom - modalRect.height;
break; break;
} }
case POSITION_BOTTOM_CENTER: case 'top-to-bottom': {
case POSITION_BOTTOM_LEFT: y = targetRect.bottom + actualOffsetY;
case POSITION_BOTTOM_RIGHT: {
y = targetRect.bottom + offset; maxHeight = clientHeight - y;
yMax = clientHeight - y; if (modalRect.height <= maxHeight) {
// Unset yMax if we have enough space. // Unset maxHeight if we have enough space.
if (modalRect.height <= yMax) { maxHeight = 0;
yMax = 0; } else if (adjust) {
} else if (fix) {
// Find a position at the other side of the rect (top). // Find a position at the other side of the rect (top).
const candidate = targetRect.top - modalRect.height - offset; const candidate = targetRect.top - modalRect.height - actualOffsetY;
if (candidate > 0) { if (candidate > 0) {
y = candidate; y = candidate;
// Reset space to zero (full space), becuase we fix only if we have the space. // Reset space to zero (full space), because we fix only if we have the space.
yMax = 0; maxHeight = 0;
} }
} }
break; break;
} }
case POSITION_TOP_CENTER: case 'bottom-to-bottom': {
case POSITION_TOP_LEFT: y = targetRect.bottom - modalRect.height - actualOffsetY;
case POSITION_TOP_RIGHT: { break;
y = targetRect.top - modalRect.height - offset; }
case 'bottom-to-top': {
yMax = targetRect.top - offset; y = targetRect.top - modalRect.height - actualOffsetY;
// Unset yMax if we have enough space.
if (modalRect.height <= yMax) { maxHeight = targetRect.top - actualOffsetY;
yMax = 0;
} else if (fix) { if (modalRect.height <= maxHeight) {
// Unset maxHeight if we have enough space.
maxHeight = 0;
} else if (adjust) {
// Find a position at the other side of the rect (bottom). // Find a position at the other side of the rect (bottom).
const candidate = targetRect.bottom + offset; const candidate = targetRect.bottom + actualOffsetY;
if (candidate + modalRect.height < clientHeight) { if (candidate + modalRect.height < clientHeight) {
y = candidate; y = candidate;
// Reset space to zero (full space), becuase we fix only if we have the space. // Reset space to zero (full space), because we fix only if we have the space.
yMax = 0; maxHeight = 0;
} }
} }
break; break;
} }
case POSITION_LEFT_CENTER:
case POSITION_RIGHT_CENTER:
y = targetRect.top + targetRect.height * 0.5 - modalRect.height * 0.5;
break;
} }
switch (relativePosition) { switch (anchorX) {
case POSITION_TOP_LEFT: case 'center':
case POSITION_BOTTOM_LEFT: { x = targetRect.left + targetRect.width * 0.5 - modalRect.width * 0.5;
x = targetRect.left;
break; break;
} case 'left-to-left': {
case POSITION_TOP_RIGHT: x = targetRect.left + actualOffsetX;
case POSITION_BOTTOM_RIGHT: {
x = targetRect.right - modalRect.width;
break; break;
} }
case POSITION_RIGHT_CENTER: case 'left-to-right': {
case POSITION_RIGHT_TOP: x = targetRect.right + actualOffsetX;
case POSITION_RIGHT_BOTTOM: {
x = targetRect.right + offset; maxWidth = clientWidth - x;
xMax = clientWidth - x; if (modalRect.width <= maxWidth) {
// Unset xMax if we have enough space. // Unset maxWidth if we have enough space.
if (modalRect.width <= xMax) { maxWidth = 0;
xMax = 0; } else if (adjust) {
} else if (fix) {
// Find a position at the other side of the rect (left). // Find a position at the other side of the rect (left).
const candidate = targetRect.left - modalRect.width - offset; const candidate = targetRect.left - modalRect.width - actualOffsetX;
if (candidate > 0) { if (candidate > 0) {
x = candidate; x = candidate;
// Reset space to zero (full space), becuase we fix only if we have the space. // Reset space to zero (full space), because we fix only if we have the space.
xMax = 0; maxWidth = 0;
} }
} }
break; break;
} }
case POSITION_LEFT_CENTER: case 'right-to-right': {
case POSITION_LEFT_TOP: x = targetRect.right - modalRect.width - actualOffsetX;
case POSITION_LEFT_BOTTOM: { break;
x = targetRect.left - modalRect.width - offset; }
case 'right-to-left': {
xMax = targetRect.left - offset; x = targetRect.left - modalRect.width - actualOffsetX;
// Unset xMax if we have enough space.
if (modalRect.width <= xMax) { maxWidth = targetRect.left - actualOffsetX;
xMax = 0;
} else if (fix) { if (modalRect.width <= maxWidth) {
// Unset maxWidth if we have enough space.
maxWidth = 0;
} else if (adjust) {
// Find a position at the other side of the rect (right). // Find a position at the other side of the rect (right).
const candidate = targetRect.right + offset; const candidate = targetRect.right + actualOffsetX;
if (candidate + modalRect.width < clientWidth) { if (candidate + modalRect.width < clientWidth) {
x = candidate; x = candidate;
// Reset space to zero (full space), becuase we fix only if we have the space. // Reset space to zero (full space), because we fix only if we have the space.
xMax = 0; maxWidth = 0;
} }
} }
break; break;
} }
case POSITION_TOP_CENTER:
case POSITION_BOTTOM_CENTER:
x = targetRect.left + targetRect.width * 0.5 - modalRect.width * 0.5;
break;
} }
return { x, y, xMax, yMax }; if (computeWidth) {
width = targetRect.width + 2 * (spaceX || 0);
}
if (computeHeight) {
height = targetRect.height + 2 * (spaceY || 0);
}
return { x, y, maxWidth, maxHeight, width, height };
} }

3
frontend/app/shared/state/languages.forms.ts

@ -8,7 +8,6 @@
import { FormControl, Validators } from '@angular/forms'; import { FormControl, Validators } from '@angular/forms';
import { Form, ExtendedFormGroup, value$ } from '@app/framework'; import { Form, ExtendedFormGroup, value$ } from '@app/framework';
import { AppLanguageDto, UpdateAppLanguageDto } from './../services/app-languages.service'; import { AppLanguageDto, UpdateAppLanguageDto } from './../services/app-languages.service';
import { LanguageDto } from './../services/languages.service';
export class EditLanguageForm extends Form<ExtendedFormGroup, UpdateAppLanguageDto, AppLanguageDto> { export class EditLanguageForm extends Form<ExtendedFormGroup, UpdateAppLanguageDto, AppLanguageDto> {
public get isMaster() { public get isMaster() {
@ -45,7 +44,7 @@ export class EditLanguageForm extends Form<ExtendedFormGroup, UpdateAppLanguageD
} }
} }
type AddLanguageFormType = { language: LanguageDto }; type AddLanguageFormType = { language: string };
export class AddLanguageForm extends Form<ExtendedFormGroup, AddLanguageFormType> { export class AddLanguageForm extends Form<ExtendedFormGroup, AddLanguageFormType> {
constructor() { constructor() {

2
frontend/app/shared/state/languages.state.spec.ts

@ -104,7 +104,7 @@ describe('LanguagesState', () => {
languagesService.setup(x => x.postLanguage(app, It.isAny(), version)) languagesService.setup(x => x.postLanguage(app, It.isAny(), version))
.returns(() => of(versioned(newVersion, updated))).verifiable(); .returns(() => of(versioned(newVersion, updated))).verifiable();
languagesState.add(languageIT).subscribe(); languagesState.add('it').subscribe();
expectNewLanguages(updated); expectNewLanguages(updated);
}); });

4
frontend/app/shared/state/languages.state.ts

@ -122,8 +122,8 @@ export class LanguagesState extends State<Snapshot> {
shareSubscribed(this.dialogs)); shareSubscribed(this.dialogs));
} }
public add(language: LanguageDto): Observable<any> { public add(language: string): Observable<any> {
return this.appLanguagesService.postLanguage(this.appName, { language: language.iso2Code }, this.version).pipe( return this.appLanguagesService.postLanguage(this.appName, { language }, this.version).pipe(
tap(({ version, payload }) => { tap(({ version, payload }) => {
this.replaceLanguages(payload, version); this.replaceLanguages(payload, version);
}), }),

2
frontend/app/shell/pages/internal/notifications-menu.component.html

@ -11,7 +11,7 @@
</li> </li>
<ng-container *sqxModal="modalMenu;onRoot:false"> <ng-container *sqxModal="modalMenu;onRoot:false">
<sqx-dropdown-menu [scrollTop]="scrollMe.nativeElement.scrollHeight" [sqxAnchoredTo]="button" [offset]="10" #scrollMe> <sqx-dropdown-menu [scrollTop]="scrollMe.nativeElement.scrollHeight" [sqxAnchoredTo]="button" [offsetY]="10" #scrollMe>
<ng-container *ngIf="commentsState.comments | async; let comments"> <ng-container *ngIf="commentsState.comments | async; let comments">
<small class="text-muted" *ngIf="comments.length === 0"> <small class="text-muted" *ngIf="comments.length === 0">
{{ 'notifications.empty' | sqxTranslate}} {{ 'notifications.empty' | sqxTranslate}}

2
frontend/app/shell/pages/internal/profile-menu.component.html

@ -9,7 +9,7 @@
</ul> </ul>
<ng-container *sqxModal="modalMenu;onRoot:false;closeAlways:true"> <ng-container *sqxModal="modalMenu;onRoot:false;closeAlways:true">
<sqx-dropdown-menu [sqxAnchoredTo]="button" [scrollY]="true" [offset]="10"> <sqx-dropdown-menu [sqxAnchoredTo]="button" [scrollY]="true" [offsetY]="10">
<a class="dropdown-item dropdown-info" [sqxPopupLink]="snapshot.profileUrl"> <a class="dropdown-item dropdown-info" [sqxPopupLink]="snapshot.profileUrl">
<div>{{ 'profile.userEmail' | sqxTranslate }}</div> <div>{{ 'profile.userEmail' | sqxTranslate }}</div>

Loading…
Cancel
Save