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. 51
      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. 96
      frontend/app/framework/angular/modals/modal-placement.directive.ts
  19. 16
      frontend/app/framework/angular/modals/onboarding-tooltip.component.html
  20. 9
      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. 262
      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)!;
if (Language.IsValidLanguage(propertyName) || propertyName == InvariantPartitioning.Key)
if (Language.IsDefault(propertyName) || propertyName == InvariantPartitioning.Key)
{
propertyName = string.Intern(propertyName);
}

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

@ -20,12 +20,12 @@ namespace Squidex.Infrastructure
{
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
@ -50,7 +50,7 @@ namespace Squidex.Infrastructure
Iso2Code = iso2Code;
}
public static bool IsValidLanguage(string iso2Code)
public static bool IsDefault(string iso2Code)
{
Guard.NotNull(iso2Code, nameof(iso2Code));

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

@ -27,9 +27,27 @@ namespace Squidex.Infrastructure
}
[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]
@ -39,15 +57,15 @@ namespace Squidex.Infrastructure
}
[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]
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]

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());
}
[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]
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 { AssignContributorForm, AutocompleteSource, ContributorsState, DialogModel, DialogService, RoleDto, UsersService } from '@app/shared';
import { Observable } from 'rxjs';
import { Observable, of } from 'rxjs';
import { withLatestFrom } from 'rxjs/operators';
@Injectable()
@ -19,6 +19,10 @@ export class UsersDataSource implements AutocompleteSource {
}
public find(query: string): Observable<ReadonlyArray<any>> {
if (!query) {
return of([]);
}
return this.usersService.getUsers(query).pipe(
withLatestFrom(this.contributorsState.contributors, (users, contributors) => {
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()">
<div class="row gx-2">
<div class="col">
<select class="form-select" formControlName="language">
<option *ngFor="let language of newLanguages" [ngValue]="language">{{language.englishName}}</option>
</select>
<sqx-autocomplete formControlName="language" displayProperty="iso2Code" [source]="addLanguagesSource">
<ng-template let-language="$implicit">
{{language.iso2Code}} ({{language.englishName}})
</ng-template>
</sqx-autocomplete>
</div>
<div class="col-auto">
<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.
*/
import { ChangeDetectionStrategy, Component, Input, OnChanges } from '@angular/core';
import { AddLanguageForm, LanguageDto, LanguagesState } from '@app/shared';
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
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({
selector: 'sqx-language-add-form',
@ -14,10 +44,13 @@ import { AddLanguageForm, LanguageDto, LanguagesState } from '@app/shared';
templateUrl: './language-add-form.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class LanguageAddFormComponent implements OnChanges {
export class LanguageAddFormComponent {
@Input()
public newLanguages: ReadonlyArray<LanguageDto>;
public set newLanguages(value: ReadonlyArray<LanguageDto>) {
this.addLanguagesSource = new LanguageSource(value);
}
public addLanguagesSource = new LanguageSource([]);
public addLanguageForm = new AddLanguageForm();
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() {
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 { Observable, of } from 'rxjs';
/* eslint-disable no-return-assign */
class PermissionsAutocomplete implements AutocompleteSource {
private permissions: ReadonlyArray<string> = [];
constructor(appsState: AppsState, rolesService: RolesService) {
// eslint-disable-next-line no-return-assign
rolesService.getPermissions(appsState.appName).subscribe(x => this.permissions = x);
}
public find(query: string): Observable<ReadonlyArray<any>> {
if (!query) {
return of(this.permissions);
}
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>
</div>
<ng-container *sqxModal="snapshot.suggestedItems.length > 0">
<sqx-dropdown-menu class="control-dropdown" [sqxAnchoredTo]="input" [scrollY]="true" [style.width]="dropdownWidth" [position]="dropdownPosition" #container>
<div class="btn btn-sm" (click)="openModal()" sqxStopClick *ngIf="allowOpen">
<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"
[class.active]="i === snapshot.suggestedIndex"
(mousedown)="selectItem(item)"
(mouseover)="selectIndex(i)"
[sqxScrollActive]="i === snapshot.suggestedIndex"
[sqxScrollContainer]="$any(container)">
[sqxScrollContainer]="$any(container.nativeElement)">
<ng-container *ngIf="!itemTemplate">{{item}}</ng-container>
<ng-template *ngIf="itemTemplate" [sqxTemplateWrapper]="itemTemplate" [item]="item" [index]="i"></ng-template>

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

@ -12,3 +12,13 @@
font-size: 1.1rem;
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;
}

51
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 { FormControl, NG_VALUE_ACCESSOR } from '@angular/forms';
import { Keys, StatefulControlComponent, Types } from '@app/framework/internal';
import { RelativePosition } from '@app/shared';
import { Observable, of } from 'rxjs';
import { catchError, debounceTime, distinctUntilChanged, finalize, map, switchMap, tap } from 'rxjs/operators';
import { Keys, ModalModel, RelativePosition, StatefulControlComponent, Types } from '@app/framework/internal';
import { merge, Observable, of, Subject } from 'rxjs';
import { catchError, debounceTime, finalize, map, switchMap, tap } from 'rxjs/operators';
export interface AutocompleteSource {
find(query: string): Observable<ReadonlyArray<any>>;
@ -46,6 +45,7 @@ const NO_EMIT = { emitEvent: false };
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AutocompleteComponent extends StatefulControlComponent<State, ReadonlyArray<any>> implements OnInit, OnDestroy {
private readonly modalStream = new Subject<string>();
private timer: any;
@Input()
@ -57,6 +57,9 @@ export class AutocompleteComponent extends StatefulControlComponent<State, Reado
@Input()
public inputStyle: 'underlined' | 'empty';
@Input()
public allowOpen?: boolean | null = true;
@Input()
public displayProperty: string;
@ -89,6 +92,8 @@ export class AutocompleteComponent extends StatefulControlComponent<State, Reado
@ViewChild('input', { static: false })
public inputControl: ElementRef<HTMLInputElement>;
public suggestionsModal = new ModalModel();
public queryInput = new FormControl();
constructor(changeDetector: ChangeDetectorRef) {
@ -103,7 +108,15 @@ export class AutocompleteComponent extends StatefulControlComponent<State, Reado
}
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(
tap(query => {
this.callChange(query);
@ -115,10 +128,12 @@ export class AutocompleteComponent extends StatefulControlComponent<State, Reado
return '';
}
}),
debounceTime(this.debounceTime),
distinctUntilChanged(),
debounceTime(this.debounceTime));
this.own(
merge(inputStream, this.modalStream).pipe(
switchMap(query => {
if (!query || !this.source) {
if (!this.source) {
return of([]);
} else {
this.setLoading(true);
@ -179,6 +194,10 @@ export class AutocompleteComponent extends StatefulControlComponent<State, Reado
}
}
public openModal() {
this.modalStream.next('');
}
public reset() {
this.resetState();
@ -206,7 +225,10 @@ export class AutocompleteComponent extends StatefulControlComponent<State, Reado
selection = this.snapshot.suggestedItems[0];
}
if (selection) {
if (!selection) {
return false;
}
try {
if (this.displayProperty && this.displayProperty.length > 0) {
this.queryInput.setValue(selection[this.displayProperty], NO_EMIT);
@ -214,7 +236,13 @@ export class AutocompleteComponent extends StatefulControlComponent<State, Reado
this.queryInput.setValue(selection.toString(), NO_EMIT);
}
this.callChange(selection);
let value = selection;
if (this.displayProperty) {
value = selection[this.displayProperty];
}
this.callChange(value);
this.callTouched();
} finally {
this.resetState();
@ -223,9 +251,6 @@ export class AutocompleteComponent extends StatefulControlComponent<State, Reado
return true;
}
return false;
}
private setLoading(value: boolean) {
clearTimeout(this.timer);

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

@ -15,7 +15,12 @@
<div class="items-container">
<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">
<input class="form-control search" [formControl]="queryInput" placeholder="{{ 'contributors.search' | sqxTranslate }}" (keydown)="onKeyDown($event)" sqxFocusOnInit>
</div>

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

@ -1,5 +1,7 @@
<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.singleline]="singleLine"
[class.readonly]="readonly"
@ -8,7 +10,6 @@
[class.focus]="snapshot.hasFocus"
[class.disabled]="snapshot.isDisabled"
[class.dashed]="dashed && !(snapshot.items.length > 0)">
<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>
</span>
@ -24,12 +25,18 @@
[formControl]="addInput">
</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>
</div>
<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"
[class.active]="i === snapshot.suggestedIndex"
[class.separated]="separated"
@ -42,9 +49,14 @@
</sqx-dropdown-menu>
</ng-container>
<ng-container *ngIf="allowOpen || suggestionsSorted.length > 0">
<ng-container *ngIf="allowOpen && suggestionsSorted.length > 0">
<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=" col-6" *ngFor="let item of suggestionsSorted; let i = index">
<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;
text-align: left;
text-decoration: none;
user-select: none;
&.suggested {
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()
public inputName = 'tag-editor';
@Input()
public dropdownWidth = '18rem';
@Input()
public set disabled(value: boolean | undefined | null) {
this.setDisabledState(value === true);
@ -416,6 +419,12 @@ export class TagEditorComponent extends StatefulControlComponent<State, Readonly
super.callTouched();
}
public focusInput(event: Event) {
this.inputElement.nativeElement.focus();
event?.preventDefault();
}
public onCut(event: ClipboardEvent) {
if (!this.hasSelection()) {
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 { ErrorDto } from '@app/shared';
import { ErrorDto } from '@app/framework/internal';
import { ErrorValidator } from './error-validator';
describe('ErrorValidator', () => {

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

@ -40,7 +40,11 @@
</div>
<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}}
<ng-container *ngIf="tooltip.shortcut">

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

@ -6,8 +6,7 @@
*/
import { AfterViewInit, Directive, ElementRef, Input, OnDestroy, Renderer2 } from '@angular/core';
import { positionModal, ResourceOwner } from '@app/framework/internal';
import { RelativePosition } from '@app/shared';
import { AnchorX, AnchorY, computeAnchors, positionModal, PositionRequest, RelativePosition, ResourceOwner } from '@app/framework/internal';
import { timer } from 'rxjs';
@Directive({
@ -25,16 +24,35 @@ export class ModalPlacementDirective extends ResourceOwner implements AfterViewI
if (element) {
this.listenToElement(element);
this.updatePosition();
}
this.updatePosition();
}
}
@Input()
public offset = 2;
public offsetX = 0;
@Input()
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 position: RelativePosition | 'full' = 'bottom-right';
public adjustHeight = false;
@Input()
public scrollX = false;
@ -48,6 +66,14 @@ export class ModalPlacementDirective extends ResourceOwner implements AfterViewI
@Input()
public update = true;
@Input()
public set position(value: RelativePosition) {
const [anchorX, anchorY] = computeAnchors(value);
this.anchorX = anchorX;
this.anchorY = anchorY;
}
constructor(
private readonly renderer: Renderer2,
private readonly element: ElementRef<HTMLElement>,
@ -94,25 +120,12 @@ export class ModalPlacementDirective extends ResourceOwner implements AfterViewI
const modalRef = this.element.nativeElement;
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;
}
const targetRect = this.targetElement.getBoundingClientRect();
let y: number;
let x: number;
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;
}
@ -121,32 +134,55 @@ export class ModalPlacementDirective extends ResourceOwner implements AfterViewI
modalRect.height = modalRef.scrollHeight;
}
const viewportHeight = document.documentElement!.clientHeight;
const viewportWidth = document.documentElement!.clientWidth;
const clientHeight = document.documentElement!.clientHeight;
const clientWidth = document.documentElement!.clientWidth;
const position = positionModal(targetRect, modalRect, this.position, this.offset, this.update, viewportWidth, viewportHeight);
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,
};
x = position.x;
y = position.y;
const position = positionModal(request);
if (this.scrollX) {
const maxWidth = position.xMax > 0 ? `${position.xMax - 10}px` : 'none';
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);
this.renderer.setStyle(modalRef, 'min-width', 0);
}
if (this.scrollY) {
const maxHeight = position.yMax > 0 ? `${position.yMax - 10}px` : 'none';
const maxHeight = position.maxHeight > 0 ? `${position.maxHeight - this.scrollMargin}px` : 'none';
this.renderer.setStyle(modalRef, 'overflow-x', 'none');
this.renderer.setStyle(modalRef, 'overflow-y', 'auto');
this.renderer.setStyle(modalRef, 'max-height', maxHeight);
this.renderer.setStyle(modalRef, 'min-height', 0);
}
if (position.width) {
this.renderer.setStyle(modalRef, 'width', `${position.width}px`);
}
if (position.height) {
this.renderer.setStyle(modalRef, 'height', `${position.height}px`);
}
this.renderer.setStyle(modalRef, 'top', `${y}px`);
this.renderer.setStyle(modalRef, 'left', `${x}px`);
if (position.x) {
this.renderer.setStyle(modalRef, 'left', `${position.x}px`);
}
if (position.y) {
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">
<div class="onboarding-rect" [sqxAnchoredTo]="for" [offset]="4" [position]="'full'"></div>
<div class="onboarding-help" [sqxAnchoredTo]="for" [offset]="4" [position]="position" @fade>
<div class="onboarding-rect"
[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">
<ng-content></ng-content>
</small>

9
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 { DialogModel, fadeAnimation, OnboardingService, StatefulComponent, Types } from '@app/framework/internal';
import { RelativePosition } from '@app/shared';
import { DialogModel, fadeAnimation, OnboardingService, RelativePosition, StatefulComponent, Types } from '@app/framework/internal';
import { timer } from 'rxjs';
@Component({
@ -48,7 +47,10 @@ export class OnboardingTooltipComponent extends StatefulComponent implements OnD
}
public ngOnInit() {
if (this.for && this.helpId && Types.isFunction(this.for.addEventListener)) {
if (!this.helpId || !Types.isFunction(this.for?.addEventListener)) {
return;
}
this.own(
timer(this.after).subscribe(() => {
if (this.onboardingService.shouldShow(this.helpId)) {
@ -79,7 +81,6 @@ export class OnboardingTooltipComponent extends StatefulComponent implements OnD
this.hideThis();
}));
}
}
private isSameOrParent(underCursor: Element | null): boolean {
if (!underCursor) {

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

@ -61,7 +61,7 @@ export class Tooltip {
constructor(
public readonly target: any,
public readonly text: string | null | undefined,
public readonly position: RelativePosition,
public readonly textPosition: RelativePosition,
public readonly multiple?: boolean,
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.
*/
import { positionModal } from './modal-positioner';
import { computeAnchors, positionModal, PositionRequest, SimplePosition } from './modal-positioner';
describe('position', () => {
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 tests = [
const tests: { position: SimplePosition; x: number; y: number }[] = [
{ position: 'bottom-center', x: 235, y: 310 },
{ position: 'bottom-left', x: 200, y: 310 },
{ position: 'bottom-right', x: 270, y: 310 },
{ position: 'left-bottom', x: 160, y: 270 },
{ position: 'bottom-left', x: 210, y: 310 },
{ position: 'bottom-right', x: 260, y: 310 },
{ position: 'left-bottom', x: 160, y: 260 },
{ position: 'left-center', x: 160, y: 235 },
{ position: 'left-top', x: 160, y: 200 },
{ position: 'right-bottom', x: 310, y: 270 },
{ position: 'left-top', x: 160, y: 210 },
{ position: 'right-bottom', x: 310, y: 260 },
{ 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-left', x: 200, y: 160 },
{ position: 'top-right', x: 270, y: 160 },
{ position: 'top-left', x: 210, y: 160 },
{ position: 'top-right', x: 260, y: 160 },
];
tests.forEach(test => {
it(`should calculate modal position for ${test.position}`, () => {
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.y).toBe(test.y);
@ -50,36 +63,92 @@ describe('position', () => {
it('should calculate modal position for vertical top fix', () => {
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);
});
it('should calculate modal position for vertical bottom fix', () => {
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);
});
it('should calculate modal position for horizontal left fix', () => {
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.y).toBe(200);
expect(result.y).toBe(210);
});
it('should calculate modal position for horizontal right fix', () => {
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.y).toBe(200);
expect(result.y).toBe(210);
});
});

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

@ -5,7 +5,24 @@
* 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-left' |
'bottom-right' |
@ -19,151 +36,208 @@ export type RelativePosition =
'top-left' |
'top-right';
const POSITION_BOTTOM_CENTER = 'bottom-center';
const POSITION_BOTTOM_LEFT = 'bottom-left';
const POSITION_BOTTOM_RIGHT = 'bottom-right';
const POSITION_LEFT_BOTTOM = 'left-bottom';
const POSITION_LEFT_CENTER = 'left-center';
const POSITION_LEFT_TOP = 'left-top';
const POSITION_RIGHT_BOTTOM = 'right-bottom';
const POSITION_RIGHT_CENTER = 'right-center';
const POSITION_RIGHT_TOP = 'right-top';
const POSITION_TOP_CENTER = 'top-center';
const POSITION_TOP_LEFT = 'top-left';
const POSITION_TOP_RIGHT = 'top-right';
export function computeAnchors(value: RelativePosition): [AnchorX, AnchorY] {
if (Types.isArray(value)) {
return value;
}
switch (value) {
case 'bottom-center':
return ['center', 'top-to-bottom'];
case 'bottom-left':
return ['left-to-left', 'top-to-bottom'];
case 'bottom-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 = {
height?: number;
maxHeight: number;
maxWidth: number;
width?: number;
x: 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 {
let y = 0;
let x = 0;
export type PositionRequest = {
adjust?: boolean;
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.
let xMax = 0;
let yMax = 0;
export function positionModal(request: PositionRequest): PositionResult {
const {
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) {
case POSITION_LEFT_TOP:
case POSITION_RIGHT_TOP: {
y = targetRect.top;
switch (anchorY) {
case 'center':
y = targetRect.top + targetRect.height * 0.5 - modalRect.height * 0.5;
break;
}
case POSITION_LEFT_BOTTOM:
case POSITION_RIGHT_BOTTOM: {
y = targetRect.bottom - modalRect.height;
case 'top-to-top': {
y = targetRect.top + actualOffsetY;
break;
}
case POSITION_BOTTOM_CENTER:
case POSITION_BOTTOM_LEFT:
case POSITION_BOTTOM_RIGHT: {
y = targetRect.bottom + offset;
case 'top-to-bottom': {
y = targetRect.bottom + actualOffsetY;
yMax = clientHeight - y;
// Unset yMax if we have enough space.
if (modalRect.height <= yMax) {
yMax = 0;
} else if (fix) {
maxHeight = clientHeight - y;
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 (top).
const candidate = targetRect.top - modalRect.height - offset;
const candidate = targetRect.top - modalRect.height - actualOffsetY;
if (candidate > 0) {
y = candidate;
// Reset space to zero (full space), becuase we fix only if we have the space.
yMax = 0;
// Reset space to zero (full space), because we fix only if we have the space.
maxHeight = 0;
}
}
break;
}
case POSITION_TOP_CENTER:
case POSITION_TOP_LEFT:
case POSITION_TOP_RIGHT: {
y = targetRect.top - modalRect.height - offset;
case 'bottom-to-bottom': {
y = targetRect.bottom - modalRect.height - actualOffsetY;
break;
}
case 'bottom-to-top': {
y = targetRect.top - modalRect.height - actualOffsetY;
maxHeight = targetRect.top - actualOffsetY;
yMax = targetRect.top - offset;
// Unset yMax if we have enough space.
if (modalRect.height <= yMax) {
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).
const candidate = targetRect.bottom + offset;
const candidate = targetRect.bottom + actualOffsetY;
if (candidate + modalRect.height < clientHeight) {
y = candidate;
// Reset space to zero (full space), becuase we fix only if we have the space.
yMax = 0;
// Reset space to zero (full space), because we fix only if we have the space.
maxHeight = 0;
}
}
break;
}
case POSITION_LEFT_CENTER:
case POSITION_RIGHT_CENTER:
y = targetRect.top + targetRect.height * 0.5 - modalRect.height * 0.5;
break;
}
switch (relativePosition) {
case POSITION_TOP_LEFT:
case POSITION_BOTTOM_LEFT: {
x = targetRect.left;
switch (anchorX) {
case 'center':
x = targetRect.left + targetRect.width * 0.5 - modalRect.width * 0.5;
break;
}
case POSITION_TOP_RIGHT:
case POSITION_BOTTOM_RIGHT: {
x = targetRect.right - modalRect.width;
case 'left-to-left': {
x = targetRect.left + actualOffsetX;
break;
}
case POSITION_RIGHT_CENTER:
case POSITION_RIGHT_TOP:
case POSITION_RIGHT_BOTTOM: {
x = targetRect.right + offset;
case 'left-to-right': {
x = targetRect.right + actualOffsetX;
maxWidth = clientWidth - x;
xMax = clientWidth - x;
// Unset xMax if we have enough space.
if (modalRect.width <= xMax) {
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 (left).
const candidate = targetRect.left - modalRect.width - offset;
const candidate = targetRect.left - modalRect.width - actualOffsetX;
if (candidate > 0) {
x = candidate;
// Reset space to zero (full space), becuase we fix only if we have the space.
xMax = 0;
// Reset space to zero (full space), because we fix only if we have the space.
maxWidth = 0;
}
}
break;
}
case POSITION_LEFT_CENTER:
case POSITION_LEFT_TOP:
case POSITION_LEFT_BOTTOM: {
x = targetRect.left - modalRect.width - offset;
case 'right-to-right': {
x = targetRect.right - modalRect.width - actualOffsetX;
break;
}
case 'right-to-left': {
x = targetRect.left - modalRect.width - actualOffsetX;
maxWidth = targetRect.left - actualOffsetX;
xMax = targetRect.left - offset;
// Unset xMax if we have enough space.
if (modalRect.width <= xMax) {
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).
const candidate = targetRect.right + offset;
const candidate = targetRect.right + actualOffsetX;
if (candidate + modalRect.width < clientWidth) {
x = candidate;
// Reset space to zero (full space), becuase we fix only if we have the space.
xMax = 0;
// Reset space to zero (full space), because we fix only if we have the space.
maxWidth = 0;
}
}
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 { Form, ExtendedFormGroup, value$ } from '@app/framework';
import { AppLanguageDto, UpdateAppLanguageDto } from './../services/app-languages.service';
import { LanguageDto } from './../services/languages.service';
export class EditLanguageForm extends Form<ExtendedFormGroup, UpdateAppLanguageDto, AppLanguageDto> {
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> {
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))
.returns(() => of(versioned(newVersion, updated))).verifiable();
languagesState.add(languageIT).subscribe();
languagesState.add('it').subscribe();
expectNewLanguages(updated);
});

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

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

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

@ -11,7 +11,7 @@
</li>
<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">
<small class="text-muted" *ngIf="comments.length === 0">
{{ 'notifications.empty' | sqxTranslate}}

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

@ -9,7 +9,7 @@
</ul>
<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">
<div>{{ 'profile.userEmail' | sqxTranslate }}</div>

Loading…
Cancel
Save