Browse Source

Tag editor for references. (#458)

* Tag editor for references.

* Checkboxes for tag editor.
pull/459/head
Sebastian Stehle 6 years ago
committed by GitHub
parent
commit
182fb38316
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 3
      backend/src/Squidex.Domain.Apps.Core.Model/Schemas/ReferencesFieldEditor.cs
  2. 7
      frontend/app/features/content/shared/field-editor.component.html
  3. 4
      frontend/app/features/schemas/pages/schema/types/array-validation.component.ts
  4. 7
      frontend/app/features/schemas/pages/schema/types/references-ui.component.html
  5. 2
      frontend/app/features/schemas/pages/schema/types/references-validation.component.html
  6. 4
      frontend/app/features/schemas/pages/schema/types/references-validation.component.ts
  7. 6
      frontend/app/features/settings/pages/workflows/workflow.component.html
  8. 2
      frontend/app/features/settings/pages/workflows/workflow.component.scss
  9. 4
      frontend/app/features/settings/pages/workflows/workflow.component.ts
  10. 4
      frontend/app/features/settings/pages/workflows/workflows-page.component.ts
  11. 58
      frontend/app/framework/angular/forms/dropdown.component.html
  12. 2
      frontend/app/framework/angular/forms/dropdown.component.scss
  13. 20
      frontend/app/framework/angular/forms/dropdown.component.ts
  14. 95
      frontend/app/framework/angular/forms/tag-editor.component.html
  15. 91
      frontend/app/framework/angular/forms/tag-editor.component.scss
  16. 132
      frontend/app/framework/angular/forms/tag-editor.component.ts
  17. 4
      frontend/app/shared/components/references-dropdown.component.ts
  18. 173
      frontend/app/shared/components/references-tags.component.ts
  19. 1
      frontend/app/shared/declarations.ts
  20. 2
      frontend/app/shared/internal.ts
  21. 7
      frontend/app/shared/module.ts
  22. 44
      frontend/app/shared/state/schema-tag-source.ts

3
backend/src/Squidex.Domain.Apps.Core.Model/Schemas/ReferencesFieldEditor.cs

@ -10,6 +10,7 @@ namespace Squidex.Domain.Apps.Core.Schemas
public enum ReferencesFieldEditor
{
List,
Dropdown
Dropdown,
Tags
}
}

7
frontend/app/features/content/shared/field-editor.component.html

@ -95,6 +95,13 @@
[schemaId]="field.rawProperties.singleId">
</sqx-references-dropdown>
</ng-container>
<ng-container *ngSwitchCase="'Tags'">
<sqx-references-tags
[formControl]="editorControl"
[language]="language"
[schemaId]="field.rawProperties.singleId">
</sqx-references-tags>
</ng-container>
</ng-container>
</ng-container>
<ng-container *ngSwitchCase="'String'">

4
frontend/app/features/schemas/pages/schema/types/array-validation.component.ts

@ -11,7 +11,7 @@ import { FormControl, FormGroup } from '@angular/forms';
import {
ArrayFieldPropertiesDto,
FieldDto,
SchemaTagConverter
SchemaTagSource
} from '@app/shared';
@Component({
@ -30,7 +30,7 @@ export class ArrayValidationComponent implements OnInit {
public properties: ArrayFieldPropertiesDto;
constructor(
public readonly schemasSource: SchemaTagConverter
public readonly schemasSource: SchemaTagSource
) {
}

7
frontend/app/features/schemas/pages/schema/types/references-ui.component.html

@ -17,6 +17,13 @@
<span class="radio-label">Dropdown</span>
</label>
<label class="btn btn-radio" [class.active]="editForm.controls['editor'].value === 'Tags'">
<input type="radio" class="radio-input" name="editor" formControlName="editor" value="Tags" />
<i class="icon-control-Tags"></i>
<span class="radio-label">Tags</span>
</label>
</div>
</div>

2
frontend/app/features/schemas/pages/schema/types/references-validation.component.html

@ -4,7 +4,7 @@
<div class="col-6">
<sqx-tag-editor placeholder=", to add schema" formControlName="schemaIds"
[converter]="schemasSource" [suggestedValues]="schemasSource.suggestions">
[converter]="schemasSource.converter | async" [suggestedValues]="(schemasSource.converter | async)?.suggestions">
</sqx-tag-editor>
</div>
</div>

4
frontend/app/features/schemas/pages/schema/types/references-validation.component.ts

@ -11,7 +11,7 @@ import { FormControl, FormGroup } from '@angular/forms';
import {
FieldDto,
ReferencesFieldPropertiesDto,
SchemaTagConverter
SchemaTagSource
} from '@app/shared';
@Component({
@ -30,7 +30,7 @@ export class ReferencesValidationComponent implements OnInit {
public properties: ReferencesFieldPropertiesDto;
constructor(
public readonly schemasSource: SchemaTagConverter
public readonly schemasSource: SchemaTagSource
) {
}

6
frontend/app/features/settings/pages/workflows/workflow.component.html

@ -5,7 +5,7 @@
<span class="workflow-name">{{workflow.displayName}}</span>
</div>
<div class="col col-tags">
<sqx-tag-editor [converter]="schemasSource" [ngModel]="workflow.schemaIds"
<sqx-tag-editor [converter]="schemasSource.converter | async" [ngModel]="workflow.schemaIds"
placeholder=""
styleGray="true"
styleBlank="true"
@ -62,10 +62,10 @@
<div class="col">
<sqx-tag-editor placeholder=", to add schema"
[converter]="schemasSource"
[converter]="schemasSource.converter | async"
[ngModel]="workflow.schemaIds"
(ngModelChange)="changeSchemaIds($event)"
[suggestedValues]="schemasSource.suggestions">
[suggestedValues]="(schemasSource.converter | async)?.suggestions">
</sqx-tag-editor>
<sqx-form-hint>

2
frontend/app/features/settings/pages/workflows/workflow.component.scss

@ -18,7 +18,7 @@
}
.col-tags {
padding: .6rem 1rem 0;
padding: .375rem 1rem 0;
}
.form-group {

4
frontend/app/features/settings/pages/workflows/workflow.component.ts

@ -12,7 +12,7 @@ import { Component, Input, OnChanges } from '@angular/core';
import {
ErrorDto,
MathHelper,
SchemaTagConverter,
SchemaTagSource,
WorkflowDto,
WorkflowsState,
WorkflowStep,
@ -36,7 +36,7 @@ export class WorkflowComponent implements OnChanges {
public roles: ReadonlyArray<string>;
@Input()
public schemasSource: SchemaTagConverter;
public schemasSource: SchemaTagSource;
public error: string | null;

4
frontend/app/features/settings/pages/workflows/workflows-page.component.ts

@ -10,7 +10,7 @@ import { Component, OnInit } from '@angular/core';
import {
ResourceOwner,
RolesState,
SchemaTagConverter,
SchemaTagSource,
WorkflowDto,
WorkflowsState
} from '@app/shared';
@ -25,7 +25,7 @@ export class WorkflowsPageComponent extends ResourceOwner implements OnInit {
constructor(
public readonly rolesState: RolesState,
public readonly schemasSource: SchemaTagConverter,
public readonly schemasSource: SchemaTagSource,
public readonly workflowsState: WorkflowsState
) {
super();

58
frontend/app/framework/angular/forms/dropdown.component.html

@ -1,36 +1,34 @@
<span>
<div class="selection">
<input type="text" class="form-control" [disabled]="snapshot.isDisabled" (click)="open()" readonly (keydown)="onKeyDown($event)" #input
autocomplete="off"
autocorrect="off"
autocapitalize="off">
<div class="selection">
<input type="text" class="form-control" [disabled]="snapshot.isDisabled" (click)="open()" readonly (keydown)="onKeyDown($event)" #input
autocomplete="off"
autocorrect="off"
autocapitalize="off">
<div class="control-dropdown-item" *ngIf="snapshot.selectedItem">
<span class="truncate" *ngIf="!templateSelection">{{snapshot.selectedItem}}</span>
<div class="control-dropdown-item" *ngIf="snapshot.selectedItem">
<span class="truncate" *ngIf="!templateSelection">{{snapshot.selectedItem}}</span>
<ng-template *ngIf="templateSelection" [sqxTemplateWrapper]="templateSelection" [item]="snapshot.selectedItem"></ng-template>
</div>
<ng-template *ngIf="templateSelection" [sqxTemplateWrapper]="templateSelection" [item]="snapshot.selectedItem"></ng-template>
</div>
<i class="icon-caret-down"></i>
</div>
<i class="icon-caret-down"></i>
</div>
<div class="items-container">
<ng-container *sqxModal="dropdown">
<div class="control-dropdown" [sqxAnchoredTo]="input" position="bottom-left">
<div *ngIf="canSearch" class="search-form">
<input class="form-control search" [formControl]="queryInput" [disabled]="snapshot.isDisabled" placeholder="Search" (keydown)="onKeyDown($event)" sqxFocusOnInit />
</div>
<div class="items-container">
<ng-container *sqxModal="dropdown">
<div class="control-dropdown" [sqxAnchoredTo]="input" position="bottom-left">
<div *ngIf="canSearch" class="search-form">
<input class="form-control search" [formControl]="queryInput" [disabled]="snapshot.isDisabled" placeholder="Search" (keydown)="onKeyDown($event)" sqxFocusOnInit />
</div>
<div class="control-dropdown-items" #container>
<div *ngFor="let item of snapshot.suggestedItems; let i = index;" class="control-dropdown-item control-dropdown-item-selectable" [class.active]="i === snapshot.selectedIndex" (mousedown)="selectIndexAndClose(i)"
[sqxScrollActive]="i === snapshot.selectedIndex"
[sqxScrollContainer]="container">
<ng-container *ngIf="!templateItem">{{item}}</ng-container>
<ng-template *ngIf="templateItem" [sqxTemplateWrapper]="templateItem" [item]="item" [index]="i" [context]="snapshot.query"></ng-template>
</div>
<div class="control-dropdown-items" #container>
<div *ngFor="let item of snapshot.suggestedItems; let i = index;" class="control-dropdown-item control-dropdown-item-selectable" [class.active]="i === snapshot.selectedIndex" (mousedown)="selectIndexAndClose(i)"
[sqxScrollActive]="i === snapshot.selectedIndex"
[sqxScrollContainer]="container">
<ng-container *ngIf="!templateItem">{{item}}</ng-container>
<ng-template *ngIf="templateItem" [sqxTemplateWrapper]="templateItem" [item]="item" [index]="i" [context]="snapshot.query"></ng-template>
</div>
</div>
</ng-container>
</div>
</span>
</div>
</ng-container>
</div>

2
frontend/app/framework/angular/forms/dropdown.component.scss

@ -47,7 +47,7 @@ $color-input-disabled: #eef1f4;
}
.icon-caret-down {
@include absolute(30%, .4rem, auto, auto);
@include absolute(30%, 5px, null, null);
font-size: .9rem;
font-weight: normal;
pointer-events: none;

20
frontend/app/framework/angular/forms/dropdown.component.ts

@ -130,10 +130,10 @@ export class DropdownComponent extends StatefulControlComponent<State, any[]> im
public onKeyDown(event: KeyboardEvent) {
switch (event.keyCode) {
case Keys.UP:
this.up();
this.selectPrevIndex();
return false;
case Keys.DOWN:
this.down();
this.selectNextIndex();
return false;
case Keys.ENTER:
this.selectIndexAndClose(this.snapshot.selectedIndex);
@ -172,6 +172,14 @@ export class DropdownComponent extends StatefulControlComponent<State, any[]> im
this.queryInput.setValue('');
}
public selectPrevIndex() {
this.selectIndex(this.snapshot.selectedIndex - 1, true);
}
public selectNextIndex() {
this.selectIndex(this.snapshot.selectedIndex + 1, true);
}
public selectIndex(selectedIndex: number, emitEvents: boolean) {
if (selectedIndex < 0) {
selectedIndex = 0;
@ -195,12 +203,4 @@ export class DropdownComponent extends StatefulControlComponent<State, any[]> im
}
}
private up() {
this.selectIndex(this.snapshot.selectedIndex - 1, true);
}
private down() {
this.selectIndex(this.snapshot.selectedIndex + 1, true);
}
}

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

@ -1,36 +1,65 @@
<div class="form-control tags" [class.blank]="styleBlank" [class.gray]="styleGray" #form (click)="input.focus()"
[class.single-line]="singleLine"
[class.focus]="snapshot.hasFocus"
[class.disabled]="addInput.disabled"
[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>
<div class="form-container">
<div class="form-control tags" #form (click)="input.focus()"
[class.blank]="styleBlank"
[class.gray]="styleGray"
[class.singleline]="singleLine"
[class.multiline]="!singleLine"
[class.focus]="snapshot.hasFocus"
[class.disabled]="addInput.disabled"
[class.dashed]="dashed && !(snapshot.items.length > 0)">
<input type="text" class="blank" #input
(blur)="markTouched()"
(copy)="onCopy($event)"
(cut)="onCut($event)"
(focus)="focus()"
(keydown)="onKeyDown($event)"
(paste)="onPaste($event)"
[name]="inputName" [placeholder]="placeholder"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
[formControl]="addInput">
</div>
<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>
<ng-container *sqxModal="snapshot.suggestedItems.length > 0">
<div class="control-dropdown" [sqxAnchoredTo]="form" position="bottom-left" #container @fade>
<div *ngFor="let item of snapshot.suggestedItems; let i = index" class="control-dropdown-item control-dropdown-item-selectable"
[class.active]="i === snapshot.suggestedIndex"
(mousedown)="selectValue(item)"
(mouseover)="selectIndex(i)"
[sqxScrollActive]="i === snapshot.suggestedIndex"
[sqxScrollContainer]="container">
<ng-container>{{item}}</ng-container>
</div>
<input type="text" class="blank text-input" #input
(blur)="markTouched()"
(copy)="onCopy($event)"
(cut)="onCut($event)"
(focus)="focus()"
(keydown)="onKeyDown($event)"
(paste)="onPaste($event)"
[name]="inputName" [placeholder]="placeholder"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
[formControl]="addInput">
</div>
<div class="btn btn-sm" (click)="suggestionsModal.show()" sqxStopClick *ngIf="suggestionsSorted.length > 0">
<i class="icon-caret-down"></i>
</div>
</ng-container>
<ng-container *sqxModal="snapshot.suggestedItems.length > 0">
<div class="control-dropdown" [sqxAnchoredTo]="form" position="bottom-left" #container @fade>
<div *ngFor="let item of snapshot.suggestedItems; let i = index" class="control-dropdown-item control-dropdown-item-selectable"
[class.active]="i === snapshot.suggestedIndex"
(mousedown)="selectValue(item)"
(mouseover)="selectIndex(i)"
[sqxScrollActive]="i === snapshot.suggestedIndex"
[sqxScrollContainer]="container">
<ng-container>{{item}}</ng-container>
</div>
</div>
</ng-container>
<ng-container *ngIf="snapshot.suggestedItems.length === 0 && suggestionsSorted.length > 0">
<ng-container *sqxModal="suggestionsModal">
<div class="control-dropdown suggestions-dropdown" [sqxAnchoredTo]="form" position="bottom-left" @fade>
<div class="row">
<div class=" col-6" *ngFor="let item of suggestionsSorted; let i = index">
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="tag_{{i}}"
[ngModel]="isSelected(item)"
(ngModelChange)="toggleValue($event, item)" />
<label class="form-check-label truncate" for="tag_{{i}}">
{{item.name}}
</label>
</div>
</div>
</div>
</div>
</ng-container>
</ng-container>
</div>

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

@ -4,13 +4,26 @@
$focus-color: #b3d3ff;
$focus-shadow: rgba(51, 137, 255, .25);
$inner-height: 1.75rem;
:host {
text-align: left;
}
.form-container {
position: relative;
}
.form-control {
& {
cursor: text;
padding-bottom: 0;
padding-left: .25rem;
padding-right: 2rem;
padding-top: .25rem;
position: relative;
text-align: left;
text-decoration: none;
}
&.disabled {
@ -27,7 +40,7 @@ $focus-shadow: rgba(51, 137, 255, .25);
box-shadow: 0 0 0 .2rem $focus-shadow;
}
&.single-line {
&.singleline {
overflow-x: hidden;
overflow-y: hidden;
white-space: nowrap;
@ -38,12 +51,13 @@ $focus-shadow: rgba(51, 137, 255, .25);
}
}
.multiline {
height: auto;
}
div {
&.form-control {
&.blank {
height: auto;
position: relative;
text-align: left;
text-decoration: none;
}
}
@ -52,15 +66,12 @@ div {
@include placeholder-color($color-input-placeholder);
background: transparent;
border: 0;
height: auto !important;
max-width: 100%;
min-width: 50px;
border-radius: 0;
padding: 0;
}
&:focus,
&.focus {
box-shadow: none;
outline: none;
}
@ -72,6 +83,25 @@ div {
&:hover {
background: transparent;
}
&.singleline {
.item {
margin-bottom: 0;
}
.blank {
margin-bottom: 0;
}
}
}
.text-input {
height: $inner-height;
margin-bottom: .25rem;
margin-left: .25rem;
max-width: 100%;
min-width: 50px;
padding-left: .25rem;
}
.gray {
@ -88,35 +118,46 @@ div {
.item {
& {
@include border-radius(10px);
background: $color-theme-blue;
border: 0;
border-radius: 2px;
color: $color-dark-foreground;
cursor: default;
font-size: .8rem;
font-weight: normal;
height: 1.25rem;
display: inline-block;
height: $inner-height;
margin-bottom: .25rem;
margin-right: 2px;
padding: 1px .6rem;
padding: 1px .5rem;
vertical-align: top;
white-space: nowrap;
}
&,
&-container {
display: inline-block;
}
&-container {
height: 24px;
padding: 2px;
padding-left: 0;
width: auto;
}
&.disabled {
pointer-events: none;
i {
display: none;
}
}
&:hover {
background: $color-theme-blue-dark;
}
}
.btn {
@include absolute(.25rem, 0, null, null);
border: 0;
cursor: pointer;
font-size: .9rem;
font-weight: normal;
padding-left: 5px;
padding-right: 5px;
}
.suggestions-dropdown {
max-width: 300px;
min-width: 300px;
padding: 1rem;
}

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

@ -7,13 +7,14 @@
// tslint:disable:template-use-track-by-function
import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, forwardRef, Input, OnInit, ViewChild } from '@angular/core';
import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, forwardRef, Input, OnChanges, OnInit, SimpleChanges, ViewChild } from '@angular/core';
import { FormControl, NG_VALUE_ACCESSOR } from '@angular/forms';
import { distinctUntilChanged, map, tap } from 'rxjs/operators';
import {
fadeAnimation,
Keys,
ModalModel,
StatefulControlComponent,
Types
} from '@app/framework/internal';
@ -45,7 +46,7 @@ export interface Converter {
export class IntConverter implements Converter {
private static ZERO = new TagValue(0, '0', 0);
public convertInput(input: string): TagValue<number> | null {
public convertInput(input: string) {
if (input === '0') {
return IntConverter.ZERO;
}
@ -59,7 +60,7 @@ export class IntConverter implements Converter {
return null;
}
public convertValue(value: any): TagValue<number> | null {
public convertValue(value: any) {
if (Types.isNumber(value)) {
return new TagValue(value, `${value}`, value);
}
@ -71,7 +72,7 @@ export class IntConverter implements Converter {
export class FloatConverter implements Converter {
private static ZERO = new TagValue(0, '0', 0);
public convertInput(input: string): TagValue<number> | null {
public convertInput(input: string) {
if (input === '0') {
return FloatConverter.ZERO;
}
@ -85,7 +86,7 @@ export class FloatConverter implements Converter {
return null;
}
public convertValue(value: any): TagValue<number> | null {
public convertValue(value: any) {
if (Types.isNumber(value)) {
return new TagValue(value, `${value}`, value);
}
@ -95,7 +96,7 @@ export class FloatConverter implements Converter {
}
export class StringConverter implements Converter {
public convertInput(input: string): TagValue<string> | null {
public convertInput(input: string) {
if (input) {
const trimmed = input.trim();
@ -107,7 +108,7 @@ export class StringConverter implements Converter {
return null;
}
public convertValue(value: any): TagValue<string> | null {
public convertValue(value: any) {
if (Types.isString(value)) {
const trimmed = value.trim();
@ -122,8 +123,6 @@ export const SQX_TAG_EDITOR_CONTROL_VALUE_ACCESSOR: any = {
provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => TagEditorComponent), multi: true
};
const CACHED_SIZES: { [key: string]: number } = {};
let CACHED_FONT: string;
interface State {
@ -140,22 +139,21 @@ interface State {
styleUrls: ['./tag-editor.component.scss'],
templateUrl: './tag-editor.component.html',
providers: [SQX_TAG_EDITOR_CONTROL_VALUE_ACCESSOR],
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [
fadeAnimation
]
],
changeDetection: ChangeDetectionStrategy.OnPush
})
// tslint:disable-next-line: readonly-array
export class TagEditorComponent extends StatefulControlComponent<State, any[]> implements AfterViewInit, OnInit {
export class TagEditorComponent extends StatefulControlComponent<State, any[]> implements AfterViewInit, OnChanges, OnInit {
private latestValue: any;
@ViewChild('form', { static: false })
public formElement: ElementRef<HTMLElement>;
@ViewChild('input', { static: false })
public inputElement: ElementRef<HTMLInputElement>;
@Input()
public suggestedValues: ReadonlyArray<TagValue> = [];
@Input()
public converter: Converter = new StringConverter();
@ -186,12 +184,21 @@ export class TagEditorComponent extends StatefulControlComponent<State, any[]> i
@Input()
public inputName = 'tag-editor';
@Input()
public set suggestedValues(value: ReadonlyArray<TagValue>) {
if (value) {
this.suggestionsSorted = value.sortedByString(x => x.lowerCaseName);
} else {
this.suggestionsSorted = [];
}
}
@Input()
public set suggestions(value: ReadonlyArray<string>) {
if (value) {
this.suggestedValues = value.map(x => new TagValue(x, x, x));
this.suggestionsSorted = value.map(x => new TagValue(x, x, x)).sortedByString(x => x.lowerCaseName);
} else {
this.suggestedValues = [];
this.suggestionsSorted = [];
}
}
@ -200,6 +207,9 @@ export class TagEditorComponent extends StatefulControlComponent<State, any[]> i
this.setDisabledState(value);
}
public suggestionsSorted: ReadonlyArray<TagValue> = [];
public suggestionsModal = new ModalModel();
public addInput = new FormControl();
constructor(changeDetector: ChangeDetectorRef) {
@ -212,13 +222,13 @@ export class TagEditorComponent extends StatefulControlComponent<State, any[]> i
}
public ngAfterViewInit() {
if (!CACHED_FONT) {
const style = window.getComputedStyle(this.inputElement.nativeElement);
this.resetSize();
}
CACHED_FONT = `${style.getPropertyValue('font-size')} ${style.getPropertyValue('font-family')}`;
public ngOnChanges(changes: SimpleChanges) {
if (changes['converter']) {
this.writeValue(this.latestValue);
}
this.resetSize();
}
public ngOnInit() {
@ -236,8 +246,8 @@ export class TagEditorComponent extends StatefulControlComponent<State, any[]> i
}),
distinctUntilChanged(),
map(query => {
if (Types.isArray(this.suggestedValues) && query && query.length > 0) {
return this.suggestedValues.filter(s => s.lowerCaseName.indexOf(query) >= 0 && !this.snapshot.items.find(x => x.id === s.id));
if (Types.isArray(this.suggestionsSorted) && query && query.length > 0) {
return this.suggestionsSorted.filter(s => s.lowerCaseName.indexOf(query) >= 0 && !this.snapshot.items.find(x => x.id === s.id));
} else {
return [];
}
@ -252,6 +262,8 @@ export class TagEditorComponent extends StatefulControlComponent<State, any[]> i
}
public writeValue(obj: any) {
this.latestValue = obj;
this.resetForm();
this.resetSize();
@ -304,6 +316,8 @@ export class TagEditorComponent extends StatefulControlComponent<State, any[]> i
}
public resetSize() {
this.calculateStyle();
if (!CACHED_FONT ||
!this.inputElement ||
!this.inputElement.nativeElement) {
@ -321,18 +335,11 @@ export class TagEditorComponent extends StatefulControlComponent<State, any[]> i
ctx.font = CACHED_FONT;
const textValue = this.inputElement.nativeElement.value;
const textKey = `${textValue}§${this.placeholder}§${ctx.font}`;
let width = CACHED_SIZES[textKey];
if (!width) {
const widthText = ctx.measureText(textValue).width;
const widthPlaceholder = ctx.measureText(this.placeholder).width;
width = Math.max(widthText, widthPlaceholder);
const widthText = ctx.measureText(textValue).width;
const widthPlaceholder = ctx.measureText(this.placeholder).width;
CACHED_SIZES[textKey] = width;
}
const width = Math.max(widthText, widthPlaceholder);
this.inputElement.nativeElement.style.width = <any>((width + 5) + 'px');
}
@ -345,6 +352,25 @@ export class TagEditorComponent extends StatefulControlComponent<State, any[]> i
}
}
private calculateStyle() {
if (CACHED_FONT ||
!this.inputElement ||
!this.inputElement.nativeElement) {
return;
}
const style = window.getComputedStyle(this.inputElement.nativeElement);
const fontSize = style.getPropertyValue('font-size');
const fontFamily = style.getPropertyValue('font-family');
if (!fontSize || !fontFamily) {
return;
}
CACHED_FONT = `${fontSize} ${fontFamily}`;
}
public onKeyDown(event: KeyboardEvent) {
const key = event.keyCode;
@ -361,10 +387,10 @@ export class TagEditorComponent extends StatefulControlComponent<State, any[]> i
return false;
}
} else if (key === Keys.UP) {
this.up();
this.selectPrevIndex();
return false;
} else if (key === Keys.DOWN) {
this.down();
this.selectNextIndex();
return false;
} else if (key === Keys.ENTER) {
if (this.snapshot.suggestedIndex >= 0) {
@ -395,7 +421,7 @@ export class TagEditorComponent extends StatefulControlComponent<State, any[]> i
}
if (tagValue) {
if (this.allowDuplicates || !this.snapshot.items.find(x => x.id === tagValue!.id)) {
if (this.allowDuplicates || !this.isSelected(tagValue)) {
this.updateItems([...this.snapshot.items, tagValue]);
}
@ -407,12 +433,20 @@ export class TagEditorComponent extends StatefulControlComponent<State, any[]> i
return false;
}
private resetAutocompletion() {
this.next(s => ({
...s,
suggestedItems: [],
suggestedIndex: -1
}));
public toggleValue(isSelected: boolean, tagValue: TagValue) {
if (isSelected) {
this.updateItems([...this.snapshot.items, tagValue]);
} else {
this.updateItems(this.snapshot.items.filter(x => x.id !== tagValue.id));
}
}
public selectPrevIndex() {
this.selectIndex(this.snapshot.suggestedIndex - 1);
}
public selectNextIndex() {
this.selectIndex(this.snapshot.suggestedIndex + 1);
}
public selectIndex(suggestedIndex: number) {
@ -431,16 +465,16 @@ export class TagEditorComponent extends StatefulControlComponent<State, any[]> i
this.next(s => ({ ...s, hasFocus: false }));
}
private resetForm() {
this.addInput.reset();
private resetAutocompletion() {
this.next(s => ({ ...s, suggestedItems: [], suggestedIndex: -1 }));
}
private up() {
this.selectIndex(this.snapshot.suggestedIndex - 1);
private resetForm() {
this.addInput.reset();
}
private down() {
this.selectIndex(this.snapshot.suggestedIndex + 1);
public isSelected(tagValue: TagValue) {
return this.snapshot.items.find(x => x.id === tagValue.id);
}
public onCut(event: ClipboardEvent) {

4
frontend/app/shared/components/references-dropdown.component.ts

@ -180,8 +180,4 @@ export class ReferencesDropdownComponent extends StatefulControlComponent<State,
return [{ name: '- No Reference -' }, ...names];
}
public trackByContent(content: ContentDto) {
return content.id;
}
}

173
frontend/app/shared/components/references-tags.component.ts

@ -0,0 +1,173 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, forwardRef, Input, OnChanges, SimpleChanges } from '@angular/core';
import { FormControl, NG_VALUE_ACCESSOR } from '@angular/forms';
import {
AppsState,
ContentDto,
ContentsService,
Converter,
getContentValue,
LanguageDto,
StatefulControlComponent,
TagValue,
UIOptions
} from '@app/shared/internal';
export const SQX_REFERENCES_TAGS_CONTROL_VALUE_ACCESSOR: any = {
provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => ReferencesTagsComponent), multi: true
};
const NO_EMIT = { emitEvent: false };
class TagsConverter implements Converter {
public suggestions: ReadonlyArray<TagValue> = [];
constructor(language: LanguageDto, contents: ReadonlyArray<ContentDto>) {
this.suggestions = this.createTags(language, contents);
}
public convertInput(input: string) {
const result = this.suggestions.find(x => x.name === input);
return result || null;
}
public convertValue(value: any) {
const result = this.suggestions.find(x => x.id === value);
return result || null;
}
private createTags(language: LanguageDto, contents: ReadonlyArray<ContentDto>): ReadonlyArray<TagValue> {
if (contents.length === 0) {
return [];
}
const values = contents.map(content => {
const name =
content.referenceFields
.map(f => getContentValue(content, language, f, false))
.map(v => v.formatted || 'No value')
.filter(v => !!v)
.join(', ');
return new TagValue(content.id, name, content.id);
});
return values;
}
}
interface State {
converter: TagsConverter;
}
@Component({
selector: 'sqx-references-tags',
template: `
<sqx-tag-editor placeholder=", to add reference" [converter]="snapshot.converter" [formControl]="selectionControl"
[suggestedValues]="snapshot.converter.suggestions">
</sqx-tag-editor>`,
styles: [
'.truncate { min-height: 1.5rem; }'
],
providers: [SQX_REFERENCES_TAGS_CONTROL_VALUE_ACCESSOR],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ReferencesTagsComponent extends StatefulControlComponent<State, ReadonlyArray<string>> implements OnChanges {
private itemCount: number;
private contentItems: ReadonlyArray<ContentDto> | null = null;
@Input()
public schemaId: string;
@Input()
public language: LanguageDto;
public get isValid() {
return !!this.schemaId && !!this.language;
}
public selectionControl = new FormControl([]);
constructor(changeDetector: ChangeDetectorRef, uiOptions: UIOptions,
private readonly appsState: AppsState,
private readonly contentsService: ContentsService
) {
super(changeDetector, { converter: new TagsConverter(null!, []) });
this.itemCount = uiOptions.get('referencesDropdownItemCount');
this.own(
this.selectionControl.valueChanges
.subscribe((value: string[]) => {
if (value && value.length > 0) {
this.callTouched();
this.callChange(value);
} else {
this.callTouched();
this.callChange(null);
}
}));
}
public ngOnChanges(changes: SimpleChanges) {
if (changes['schemaId']) {
this.resetState();
if (this.isValid) {
this.contentsService.getContents(this.appsState.appName, this.schemaId, this.itemCount, 0)
.subscribe(contents => {
this.contentItems = contents.items;
this.resetConverterState();
}, () => {
this.contentItems = null;
this.resetConverterState();
});
} else {
this.contentItems = null;
this.resetConverterState();
}
}
}
public setDisabledState(isDisabled: boolean) {
if (isDisabled) {
this.selectionControl.disable();
} else if (this.isValid) {
this.selectionControl.enable();
}
super.setDisabledState(isDisabled);
}
public writeValue(obj: ReadonlyArray<string>) {
this.selectionControl.setValue(obj, NO_EMIT);
}
private resetConverterState() {
let converter: TagsConverter;
if (this.isValid && this.contentItems && this.contentItems.length > 0) {
converter = new TagsConverter(this.language, this.contentItems);
this.selectionControl.enable();
} else {
converter = new TagsConverter(null!, []);
this.selectionControl.disable();
}
this.next({ converter });
}
}

1
frontend/app/shared/declarations.ts

@ -25,6 +25,7 @@ export * from './components/language-selector.component';
export * from './components/markdown-editor.component';
export * from './components/pipes';
export * from './components/references-dropdown.component';
export * from './components/references-tags.component';
export * from './components/rich-editor.component';
export * from './components/saved-queries.component';
export * from './components/schema-category.component';

2
frontend/app/shared/internal.ts

@ -61,7 +61,7 @@ export * from './state/roles.forms';
export * from './state/roles.state';
export * from './state/rule-events.state';
export * from './state/rules.state';
export * from './state/schema-tag-converter';
export * from './state/schema-tag-source';
export * from './state/schemas.forms';
export * from './state/schemas.state';
export * from './state/ui.state';

7
frontend/app/shared/module.ts

@ -75,6 +75,7 @@ import {
PlansState,
QueryComponent,
ReferencesDropdownComponent,
ReferencesTagsComponent,
RichEditorComponent,
RolesService,
RolesState,
@ -88,7 +89,7 @@ import {
SchemaMustNotBeSingletonGuard,
SchemasService,
SchemasState,
SchemaTagConverter,
SchemaTagSource,
SearchFormComponent,
SortingComponent,
TableHeaderComponent,
@ -144,6 +145,7 @@ import {
MarkdownEditorComponent,
QueryComponent,
ReferencesDropdownComponent,
ReferencesTagsComponent,
RichEditorComponent,
SavedQueriesComponent,
SchemaCategoryComponent,
@ -182,6 +184,7 @@ import {
LanguageSelectorComponent,
MarkdownEditorComponent,
ReferencesDropdownComponent,
ReferencesTagsComponent,
RichEditorComponent,
RouterModule,
SavedQueriesComponent,
@ -247,7 +250,7 @@ export class SqxSharedModule {
SchemaMustNotBeSingletonGuard,
SchemasService,
SchemasState,
SchemaTagConverter,
SchemaTagSource,
TranslationsService,
UIService,
UIState,

44
frontend/app/shared/state/schema-tag-converter.ts → frontend/app/shared/state/schema-tag-source.ts

@ -5,39 +5,24 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Injectable, OnDestroy } from '@angular/core';
import { Subscription } from 'rxjs';
import { Injectable } from '@angular/core';
import { map, shareReplay } from 'rxjs/operators';
import { Converter, TagValue } from '@app/framework';
import { SchemaDto } from './../services/schemas.service';
import { SchemasState } from './schemas.state';
@Injectable()
export class SchemaTagConverter implements Converter, OnDestroy {
private schemasSubscription: Subscription;
private schemas: ReadonlyArray<SchemaDto> = [];
public suggestions: ReadonlyArray<TagValue> = [];
class SchemaConverter implements Converter {
public suggestions: ReadonlyArray<TagValue>;
constructor(
readonly schemasState: SchemasState
private readonly schemas: ReadonlyArray<SchemaDto>
) {
this.schemasSubscription =
schemasState.schemas.subscribe(schemas => {
this.schemas = schemas;
this.suggestions = this.schemas.map(x => new TagValue(x.id, x.name, x.id));
});
this.schemasState.loadIfNotLoaded();
}
public ngOnDestroy() {
this.schemasSubscription.unsubscribe();
this.suggestions = schemas.map(x => new TagValue(x.id, x.name, x.id));
}
public convertInput(input: string): TagValue<any> | null {
public convertInput(input: string) {
const schema = this.schemas.find(x => x.name === input);
if (schema) {
@ -47,7 +32,7 @@ export class SchemaTagConverter implements Converter, OnDestroy {
return null;
}
public convertValue(value: any): TagValue<any> | null {
public convertValue(value: any) {
const schema = this.schemas.find(x => x.id === value);
if (schema) {
@ -56,4 +41,17 @@ export class SchemaTagConverter implements Converter, OnDestroy {
return null;
}
}
@Injectable()
export class SchemaTagSource {
public converter =
this.schemasState.schemas.pipe(
map(x => new SchemaConverter(x), shareReplay(1)));
constructor(
readonly schemasState: SchemasState
) {
this.schemasState.loadIfNotLoaded();
}
}
Loading…
Cancel
Save