From 469f15ddf07ae39f094c5c49bba67f631db90ac8 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Mon, 13 Dec 2021 15:25:29 +0100 Subject: [PATCH] Custom language code. (#810) * Custom language code. * Bugfixes and UX improvements. * Fix imports. * API test for custom languages. --- .../Json/ContentFieldDataConverter.cs | 2 +- .../src/Squidex.Infrastructure/Language.cs | 8 +- .../LanguageTests.cs | 30 +- .../TestSuite.ApiTests/AppLanguagesTests.cs | 16 ++ .../contributor-add-form.component.ts | 6 +- .../language-add-form.component.html | 8 +- .../languages/language-add-form.component.ts | 49 +++- .../pages/roles/roles-page.component.ts | 7 +- .../forms/editors/autocomplete.component.html | 17 +- .../forms/editors/autocomplete.component.scss | 10 + .../forms/editors/autocomplete.component.ts | 129 +++++---- .../forms/editors/dropdown.component.html | 7 +- .../forms/editors/tag-editor.component.html | 24 +- .../forms/editors/tag-editor.component.scss | 1 + .../forms/editors/tag-editor.component.ts | 9 + .../angular/forms/error-validator.spec.ts | 2 +- .../modals/dialog-renderer.component.html | 6 +- .../modals/modal-placement.directive.ts | 126 +++++--- .../modals/onboarding-tooltip.component.html | 16 +- .../modals/onboarding-tooltip.component.ts | 51 ++-- .../app/framework/services/dialog.service.ts | 2 +- .../framework/utils/modal-positioner.spec.ts | 107 +++++-- .../app/framework/utils/modal-positioner.ts | 270 +++++++++++------- frontend/app/shared/state/languages.forms.ts | 3 +- .../app/shared/state/languages.state.spec.ts | 2 +- frontend/app/shared/state/languages.state.ts | 4 +- .../notifications-menu.component.html | 2 +- .../internal/profile-menu.component.html | 2 +- 28 files changed, 626 insertions(+), 290 deletions(-) diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Contents/Json/ContentFieldDataConverter.cs b/backend/src/Squidex.Domain.Apps.Core.Model/Contents/Json/ContentFieldDataConverter.cs index 8ffe53da1..57b07a060 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/Contents/Json/ContentFieldDataConverter.cs +++ b/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(reader)!; - if (Language.IsValidLanguage(propertyName) || propertyName == InvariantPartitioning.Key) + if (Language.IsDefault(propertyName) || propertyName == InvariantPartitioning.Key) { propertyName = string.Intern(propertyName); } diff --git a/backend/src/Squidex.Infrastructure/Language.cs b/backend/src/Squidex.Infrastructure/Language.cs index 65a24547d..04a0d5a66 100644 --- a/backend/src/Squidex.Infrastructure/Language.cs +++ b/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 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)); diff --git a/backend/tests/Squidex.Infrastructure.Tests/LanguageTests.cs b/backend/tests/Squidex.Infrastructure.Tests/LanguageTests.cs index 12187d976..9d9da1638 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/LanguageTests.cs +++ b/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(() => 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] diff --git a/backend/tools/TestSuite/TestSuite.ApiTests/AppLanguagesTests.cs b/backend/tools/TestSuite/TestSuite.ApiTests/AppLanguagesTests.cs index ec08172d8..2318c6e09 100644 --- a/backend/tools/TestSuite/TestSuite.ApiTests/AppLanguagesTests.cs +++ b/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() { diff --git a/frontend/app/features/settings/pages/contributors/contributor-add-form.component.ts b/frontend/app/features/settings/pages/contributors/contributor-add-form.component.ts index 92b4df2d6..334776cbc 100644 --- a/frontend/app/features/settings/pages/contributors/contributor-add-form.component.ts +++ b/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> { + if (!query) { + return of([]); + } + return this.usersService.getUsers(query).pipe( withLatestFrom(this.contributorsState.contributors, (users, contributors) => { const results: any[] = []; diff --git a/frontend/app/features/settings/pages/languages/language-add-form.component.html b/frontend/app/features/settings/pages/languages/language-add-form.component.html index 31bad0220..83d28f754 100644 --- a/frontend/app/features/settings/pages/languages/language-add-form.component.html +++ b/frontend/app/features/settings/pages/languages/language-add-form.component.html @@ -4,9 +4,11 @@
- + + + {{language.iso2Code}} ({{language.englishName}}) + +
- - +
+ +
+ + +
- + [sqxScrollContainer]="$any(container.nativeElement)"> {{item}} diff --git a/frontend/app/framework/angular/forms/editors/autocomplete.component.scss b/frontend/app/framework/angular/forms/editors/autocomplete.component.scss index 2d1a09e44..61a3668f7 100644 --- a/frontend/app/framework/angular/forms/editors/autocomplete.component.scss +++ b/frontend/app/framework/angular/forms/editors/autocomplete.component.scss @@ -11,4 +11,14 @@ color: $color-input; 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; } \ No newline at end of file diff --git a/frontend/app/framework/angular/forms/editors/autocomplete.component.ts b/frontend/app/framework/angular/forms/editors/autocomplete.component.ts index b27f58fd5..d103fdad8 100644 --- a/frontend/app/framework/angular/forms/editors/autocomplete.component.ts +++ b/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>; @@ -46,6 +45,7 @@ const NO_EMIT = { emitEvent: false }; changeDetection: ChangeDetectionStrategy.OnPush, }) export class AutocompleteComponent extends StatefulControlComponent> implements OnInit, OnDestroy { + private readonly modalStream = new Subject(); private timer: any; @Input() @@ -57,6 +57,9 @@ export class AutocompleteComponent extends StatefulControlComponent; + public suggestionsModal = new ModalModel(); + public queryInput = new FormControl(); constructor(changeDetector: ChangeDetectorRef) { @@ -103,41 +108,51 @@ export class AutocompleteComponent extends StatefulControlComponent { + if (state.suggestedItems.length > 0) { + this.suggestionsModal.show(); + } else { + this.suggestionsModal.hide(); + } + }); + + const inputStream = this.queryInput.valueChanges.pipe( - tap(query => { - this.callChange(query); - }), - map(query => { - if (Types.isString(query)) { - return query.trim(); - } else { - return ''; - } - }), - debounceTime(this.debounceTime), - distinctUntilChanged(), - switchMap(query => { - if (!query || !this.source) { - return of([]); - } else { - this.setLoading(true); - - return this.source.find(query).pipe( - finalize(() => { - this.setLoading(false); - }), - catchError(() => of([])), - ); - } - })) - .subscribe(items => { - this.next({ - suggestedIndex: -1, - suggestedItems: items || [], - isSearching: false, - }); - })); + tap(query => { + this.callChange(query); + }), + map(query => { + if (Types.isString(query)) { + return query.trim(); + } else { + return ''; + } + }), + debounceTime(this.debounceTime)); + + this.own( + merge(inputStream, this.modalStream).pipe( + switchMap(query => { + if (!this.source) { + return of([]); + } else { + this.setLoading(true); + + return this.source.find(query).pipe( + finalize(() => { + this.setLoading(false); + }), + catchError(() => of([])), + ); + } + })) + .subscribe(items => { + this.next({ + suggestedIndex: -1, + suggestedItems: items || [], + isSearching: false, + }); + })); } public onKeyDown(event: KeyboardEvent) { @@ -179,6 +194,10 @@ export class AutocompleteComponent extends StatefulControlComponent 0) { - this.queryInput.setValue(selection[this.displayProperty], NO_EMIT); - } else { - this.queryInput.setValue(selection.toString(), NO_EMIT); - } - - this.callChange(selection); - this.callTouched(); - } finally { - this.resetState(); + if (!selection) { + return false; + } + + try { + if (this.displayProperty && this.displayProperty.length > 0) { + this.queryInput.setValue(selection[this.displayProperty], NO_EMIT); + } else { + this.queryInput.setValue(selection.toString(), NO_EMIT); } - 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) { diff --git a/frontend/app/framework/angular/forms/editors/dropdown.component.html b/frontend/app/framework/angular/forms/editors/dropdown.component.html index 87a58107f..a48cfbeb8 100644 --- a/frontend/app/framework/angular/forms/editors/dropdown.component.html +++ b/frontend/app/framework/angular/forms/editors/dropdown.component.html @@ -15,7 +15,12 @@
- +
diff --git a/frontend/app/framework/angular/forms/editors/tag-editor.component.html b/frontend/app/framework/angular/forms/editors/tag-editor.component.html index 9bfb2ddc0..7b0fb3b90 100644 --- a/frontend/app/framework/angular/forms/editors/tag-editor.component.html +++ b/frontend/app/framework/angular/forms/editors/tag-editor.component.html @@ -1,5 +1,7 @@
-
- {{item}} @@ -24,12 +25,18 @@ [formControl]="addInput">
-
+
- +
- + - +
diff --git a/frontend/app/framework/angular/forms/editors/tag-editor.component.scss b/frontend/app/framework/angular/forms/editors/tag-editor.component.scss index 5ef28ff30..d880bc606 100644 --- a/frontend/app/framework/angular/forms/editors/tag-editor.component.scss +++ b/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; diff --git a/frontend/app/framework/angular/forms/editors/tag-editor.component.ts b/frontend/app/framework/angular/forms/editors/tag-editor.component.ts index b9de84231..3e13cda9f 100644 --- a/frontend/app/framework/angular/forms/editors/tag-editor.component.ts +++ b/frontend/app/framework/angular/forms/editors/tag-editor.component.ts @@ -94,6 +94,9 @@ export class TagEditorComponent extends StatefulControlComponent { diff --git a/frontend/app/framework/angular/modals/dialog-renderer.component.html b/frontend/app/framework/angular/modals/dialog-renderer.component.html index bfebfa993..ad1f1cfec 100644 --- a/frontend/app/framework/angular/modals/dialog-renderer.component.html +++ b/frontend/app/framework/angular/modals/dialog-renderer.component.html @@ -40,7 +40,11 @@
-
+
{{tooltip.text | sqxTranslate}} diff --git a/frontend/app/framework/angular/modals/modal-placement.directive.ts b/frontend/app/framework/angular/modals/modal-placement.directive.ts index c2d7fac3b..4fd068dea 100644 --- a/frontend/app/framework/angular/modals/modal-placement.directive.ts +++ b/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 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() 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, @@ -94,59 +120,69 @@ 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; - } - - if (this.scrollY) { - modalRect.height = modalRef.scrollHeight; - } + if (this.scrollX) { + modalRect.width = modalRef.scrollWidth; + } - const viewportHeight = document.documentElement!.clientHeight; - const viewportWidth = document.documentElement!.clientWidth; + if (this.scrollY) { + 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; - y = position.y; + if (this.scrollY) { + const maxHeight = position.maxHeight > 0 ? `${position.maxHeight - this.scrollMargin}px` : 'none'; - if (this.scrollX) { - const maxWidth = position.xMax > 0 ? `${position.xMax - 10}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, 'overflow-x', 'auto'); - this.renderer.setStyle(modalRef, 'max-width', maxWidth); - this.renderer.setStyle(modalRef, 'min-width', 0); - } + if (position.width) { + this.renderer.setStyle(modalRef, 'width', `${position.width}px`); + } - if (this.scrollY) { - const maxHeight = position.yMax > 0 ? `${position.yMax - 10}px` : 'none'; + if (position.height) { + this.renderer.setStyle(modalRef, 'height', `${position.height}px`); + } - this.renderer.setStyle(modalRef, 'overflow-y', 'auto'); - this.renderer.setStyle(modalRef, 'max-height', maxHeight); - this.renderer.setStyle(modalRef, 'min-height', 0); - } + if (position.x) { + this.renderer.setStyle(modalRef, 'left', `${position.x}px`); } - this.renderer.setStyle(modalRef, 'top', `${y}px`); - this.renderer.setStyle(modalRef, 'left', `${x}px`); + if (position.y) { + this.renderer.setStyle(modalRef, 'top', `${position.y}px`); + } } } diff --git a/frontend/app/framework/angular/modals/onboarding-tooltip.component.html b/frontend/app/framework/angular/modals/onboarding-tooltip.component.html index fb5a688e7..884503385 100644 --- a/frontend/app/framework/angular/modals/onboarding-tooltip.component.html +++ b/frontend/app/framework/angular/modals/onboarding-tooltip.component.html @@ -1,6 +1,18 @@ -
-
+
+ +
diff --git a/frontend/app/framework/angular/modals/onboarding-tooltip.component.ts b/frontend/app/framework/angular/modals/onboarding-tooltip.component.ts index d04e3e313..a90a8c84b 100644 --- a/frontend/app/framework/angular/modals/onboarding-tooltip.component.ts +++ b/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,37 +47,39 @@ export class OnboardingTooltipComponent extends StatefulComponent implements OnD } public ngOnInit() { - if (this.for && this.helpId && Types.isFunction(this.for.addEventListener)) { - this.own( - timer(this.after).subscribe(() => { - if (this.onboardingService.shouldShow(this.helpId)) { - const forRect = this.for.getBoundingClientRect(); + if (!this.helpId || !Types.isFunction(this.for?.addEventListener)) { + return; + } + + 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 y = forRect.top + 0.5 * forRect.height; + const x = forRect.left + 0.5 * forRect.width; + const y = forRect.top + 0.5 * forRect.height; - const fromPoint = document.elementFromPoint(x, y); + const fromPoint = document.elementFromPoint(x, y); - if (this.isSameOrParent(fromPoint)) { - this.tooltipModal.show(); + if (this.isSameOrParent(fromPoint)) { + this.tooltipModal.show(); - this.own( - timer(10000).subscribe(() => { - this.hideThis(); - })); + this.own( + timer(10000).subscribe(() => { + this.hideThis(); + })); - this.onboardingService.disable(this.helpId); - } + this.onboardingService.disable(this.helpId); } - })); + } + })); - this.own( - this.renderer.listen(this.for, 'mousedown', () => { - this.onboardingService.disable(this.helpId); + this.own( + this.renderer.listen(this.for, 'mousedown', () => { + this.onboardingService.disable(this.helpId); - this.hideThis(); - })); - } + this.hideThis(); + })); } private isSameOrParent(underCursor: Element | null): boolean { diff --git a/frontend/app/framework/services/dialog.service.ts b/frontend/app/framework/services/dialog.service.ts index 04839cbd3..d0793a607 100644 --- a/frontend/app/framework/services/dialog.service.ts +++ b/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, ) { diff --git a/frontend/app/framework/utils/modal-positioner.spec.ts b/frontend/app/framework/utils/modal-positioner.spec.ts index 29912fa4d..396a459a4 100644 --- a/frontend/app/framework/utils/modal-positioner.spec.ts +++ b/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); }); }); diff --git a/frontend/app/framework/utils/modal-positioner.ts b/frontend/app/framework/utils/modal-positioner.ts index e0e39fb00..0398f3a2c 100644 --- a/frontend/app/framework/utils/modal-positioner.ts +++ b/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; - - yMax = clientHeight - y; - // Unset yMax if we have enough space. - if (modalRect.height <= yMax) { - yMax = 0; - } else if (fix) { + case 'top-to-bottom': { + y = targetRect.bottom + actualOffsetY; + + 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; - - yMax = targetRect.top - offset; - // Unset yMax if we have enough space. - if (modalRect.height <= yMax) { - yMax = 0; - } else if (fix) { + 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; + + 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; - - xMax = clientWidth - x; - // Unset xMax if we have enough space. - if (modalRect.width <= xMax) { - xMax = 0; - } else if (fix) { + case 'left-to-right': { + x = targetRect.right + actualOffsetX; + + maxWidth = clientWidth - x; + + 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; - - xMax = targetRect.left - offset; - // Unset xMax if we have enough space. - if (modalRect.width <= xMax) { - xMax = 0; - } else if (fix) { + 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; + + 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 }; } diff --git a/frontend/app/shared/state/languages.forms.ts b/frontend/app/shared/state/languages.forms.ts index 9dece4e9b..9811fbab6 100644 --- a/frontend/app/shared/state/languages.forms.ts +++ b/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 { public get isMaster() { @@ -45,7 +44,7 @@ export class EditLanguageForm extends Form { constructor() { diff --git a/frontend/app/shared/state/languages.state.spec.ts b/frontend/app/shared/state/languages.state.spec.ts index b002a5444..253a44248 100644 --- a/frontend/app/shared/state/languages.state.spec.ts +++ b/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); }); diff --git a/frontend/app/shared/state/languages.state.ts b/frontend/app/shared/state/languages.state.ts index 05f4d50a5..040f6bf17 100644 --- a/frontend/app/shared/state/languages.state.ts +++ b/frontend/app/shared/state/languages.state.ts @@ -122,8 +122,8 @@ export class LanguagesState extends State { shareSubscribed(this.dialogs)); } - public add(language: LanguageDto): Observable { - return this.appLanguagesService.postLanguage(this.appName, { language: language.iso2Code }, this.version).pipe( + public add(language: string): Observable { + return this.appLanguagesService.postLanguage(this.appName, { language }, this.version).pipe( tap(({ version, payload }) => { this.replaceLanguages(payload, version); }), diff --git a/frontend/app/shell/pages/internal/notifications-menu.component.html b/frontend/app/shell/pages/internal/notifications-menu.component.html index 20893f279..3e57b95f3 100644 --- a/frontend/app/shell/pages/internal/notifications-menu.component.html +++ b/frontend/app/shell/pages/internal/notifications-menu.component.html @@ -11,7 +11,7 @@ - + {{ 'notifications.empty' | sqxTranslate}} diff --git a/frontend/app/shell/pages/internal/profile-menu.component.html b/frontend/app/shell/pages/internal/profile-menu.component.html index 969d05ef0..b4d346848 100644 --- a/frontend/app/shell/pages/internal/profile-menu.component.html +++ b/frontend/app/shell/pages/internal/profile-menu.component.html @@ -9,7 +9,7 @@ - +
{{ 'profile.userEmail' | sqxTranslate }}