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 public enum ReferencesFieldEditor
{ {
List, List,
Dropdown Dropdown,
Tags
} }
} }

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

@ -95,6 +95,13 @@
[schemaId]="field.rawProperties.singleId"> [schemaId]="field.rawProperties.singleId">
</sqx-references-dropdown> </sqx-references-dropdown>
</ng-container> </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> </ng-container>
<ng-container *ngSwitchCase="'String'"> <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 { import {
ArrayFieldPropertiesDto, ArrayFieldPropertiesDto,
FieldDto, FieldDto,
SchemaTagConverter SchemaTagSource
} from '@app/shared'; } from '@app/shared';
@Component({ @Component({
@ -30,7 +30,7 @@ export class ArrayValidationComponent implements OnInit {
public properties: ArrayFieldPropertiesDto; public properties: ArrayFieldPropertiesDto;
constructor( 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> <span class="radio-label">Dropdown</span>
</label> </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>
</div> </div>

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

@ -4,7 +4,7 @@
<div class="col-6"> <div class="col-6">
<sqx-tag-editor placeholder=", to add schema" formControlName="schemaIds" <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> </sqx-tag-editor>
</div> </div>
</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 { import {
FieldDto, FieldDto,
ReferencesFieldPropertiesDto, ReferencesFieldPropertiesDto,
SchemaTagConverter SchemaTagSource
} from '@app/shared'; } from '@app/shared';
@Component({ @Component({
@ -30,7 +30,7 @@ export class ReferencesValidationComponent implements OnInit {
public properties: ReferencesFieldPropertiesDto; public properties: ReferencesFieldPropertiesDto;
constructor( 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> <span class="workflow-name">{{workflow.displayName}}</span>
</div> </div>
<div class="col col-tags"> <div class="col col-tags">
<sqx-tag-editor [converter]="schemasSource" [ngModel]="workflow.schemaIds" <sqx-tag-editor [converter]="schemasSource.converter | async" [ngModel]="workflow.schemaIds"
placeholder="" placeholder=""
styleGray="true" styleGray="true"
styleBlank="true" styleBlank="true"
@ -62,10 +62,10 @@
<div class="col"> <div class="col">
<sqx-tag-editor placeholder=", to add schema" <sqx-tag-editor placeholder=", to add schema"
[converter]="schemasSource" [converter]="schemasSource.converter | async"
[ngModel]="workflow.schemaIds" [ngModel]="workflow.schemaIds"
(ngModelChange)="changeSchemaIds($event)" (ngModelChange)="changeSchemaIds($event)"
[suggestedValues]="schemasSource.suggestions"> [suggestedValues]="(schemasSource.converter | async)?.suggestions">
</sqx-tag-editor> </sqx-tag-editor>
<sqx-form-hint> <sqx-form-hint>

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

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

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

@ -12,7 +12,7 @@ import { Component, Input, OnChanges } from '@angular/core';
import { import {
ErrorDto, ErrorDto,
MathHelper, MathHelper,
SchemaTagConverter, SchemaTagSource,
WorkflowDto, WorkflowDto,
WorkflowsState, WorkflowsState,
WorkflowStep, WorkflowStep,
@ -36,7 +36,7 @@ export class WorkflowComponent implements OnChanges {
public roles: ReadonlyArray<string>; public roles: ReadonlyArray<string>;
@Input() @Input()
public schemasSource: SchemaTagConverter; public schemasSource: SchemaTagSource;
public error: string | null; 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 { import {
ResourceOwner, ResourceOwner,
RolesState, RolesState,
SchemaTagConverter, SchemaTagSource,
WorkflowDto, WorkflowDto,
WorkflowsState WorkflowsState
} from '@app/shared'; } from '@app/shared';
@ -25,7 +25,7 @@ export class WorkflowsPageComponent extends ResourceOwner implements OnInit {
constructor( constructor(
public readonly rolesState: RolesState, public readonly rolesState: RolesState,
public readonly schemasSource: SchemaTagConverter, public readonly schemasSource: SchemaTagSource,
public readonly workflowsState: WorkflowsState public readonly workflowsState: WorkflowsState
) { ) {
super(); super();

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

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

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

@ -47,7 +47,7 @@ $color-input-disabled: #eef1f4;
} }
.icon-caret-down { .icon-caret-down {
@include absolute(30%, .4rem, auto, auto); @include absolute(30%, 5px, null, null);
font-size: .9rem; font-size: .9rem;
font-weight: normal; font-weight: normal;
pointer-events: none; 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) { public onKeyDown(event: KeyboardEvent) {
switch (event.keyCode) { switch (event.keyCode) {
case Keys.UP: case Keys.UP:
this.up(); this.selectPrevIndex();
return false; return false;
case Keys.DOWN: case Keys.DOWN:
this.down(); this.selectNextIndex();
return false; return false;
case Keys.ENTER: case Keys.ENTER:
this.selectIndexAndClose(this.snapshot.selectedIndex); this.selectIndexAndClose(this.snapshot.selectedIndex);
@ -172,6 +172,14 @@ export class DropdownComponent extends StatefulControlComponent<State, any[]> im
this.queryInput.setValue(''); 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) { public selectIndex(selectedIndex: number, emitEvents: boolean) {
if (selectedIndex < 0) { if (selectedIndex < 0) {
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()" <div class="form-container">
[class.single-line]="singleLine" <div class="form-control tags" #form (click)="input.focus()"
[class.focus]="snapshot.hasFocus" [class.blank]="styleBlank"
[class.disabled]="addInput.disabled" [class.gray]="styleGray"
[class.dashed]="dashed && !(snapshot.items.length > 0)"> [class.singleline]="singleLine"
<span class="item" *ngFor="let item of snapshot.items; let i = index" [class.disabled]="addInput.disabled"> [class.multiline]="!singleLine"
{{item}} <i class="icon-close" (click)="remove(i)"></i> [class.focus]="snapshot.hasFocus"
</span> [class.disabled]="addInput.disabled"
[class.dashed]="dashed && !(snapshot.items.length > 0)">
<input type="text" class="blank" #input <span class="item" *ngFor="let item of snapshot.items; let i = index" [class.disabled]="addInput.disabled">
(blur)="markTouched()" {{item}} <i class="icon-close" (click)="remove(i)"></i>
(copy)="onCopy($event)" </span>
(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>
<ng-container *sqxModal="snapshot.suggestedItems.length > 0"> <input type="text" class="blank text-input" #input
<div class="control-dropdown" [sqxAnchoredTo]="form" position="bottom-left" #container @fade> (blur)="markTouched()"
<div *ngFor="let item of snapshot.suggestedItems; let i = index" class="control-dropdown-item control-dropdown-item-selectable" (copy)="onCopy($event)"
[class.active]="i === snapshot.suggestedIndex" (cut)="onCut($event)"
(mousedown)="selectValue(item)" (focus)="focus()"
(mouseover)="selectIndex(i)" (keydown)="onKeyDown($event)"
[sqxScrollActive]="i === snapshot.suggestedIndex" (paste)="onPaste($event)"
[sqxScrollContainer]="container"> [name]="inputName" [placeholder]="placeholder"
<ng-container>{{item}}</ng-container> autocomplete="off"
</div> 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> </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-color: #b3d3ff;
$focus-shadow: rgba(51, 137, 255, .25); $focus-shadow: rgba(51, 137, 255, .25);
$inner-height: 1.75rem;
:host { :host {
text-align: left; text-align: left;
} }
.form-container {
position: relative;
}
.form-control { .form-control {
& { & {
cursor: text; cursor: text;
padding-bottom: 0;
padding-left: .25rem;
padding-right: 2rem;
padding-top: .25rem;
position: relative;
text-align: left;
text-decoration: none;
} }
&.disabled { &.disabled {
@ -27,7 +40,7 @@ $focus-shadow: rgba(51, 137, 255, .25);
box-shadow: 0 0 0 .2rem $focus-shadow; box-shadow: 0 0 0 .2rem $focus-shadow;
} }
&.single-line { &.singleline {
overflow-x: hidden; overflow-x: hidden;
overflow-y: hidden; overflow-y: hidden;
white-space: nowrap; white-space: nowrap;
@ -38,12 +51,13 @@ $focus-shadow: rgba(51, 137, 255, .25);
} }
} }
.multiline {
height: auto;
}
div { div {
&.form-control { &.blank {
height: auto; height: auto;
position: relative;
text-align: left;
text-decoration: none;
} }
} }
@ -52,15 +66,12 @@ div {
@include placeholder-color($color-input-placeholder); @include placeholder-color($color-input-placeholder);
background: transparent; background: transparent;
border: 0; border: 0;
height: auto !important; border-radius: 0;
max-width: 100%;
min-width: 50px;
padding: 0; padding: 0;
} }
&:focus, &:focus,
&.focus { &.focus {
box-shadow: none;
outline: none; outline: none;
} }
@ -72,6 +83,25 @@ div {
&:hover { &:hover {
background: transparent; 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 { .gray {
@ -88,35 +118,46 @@ div {
.item { .item {
& { & {
@include border-radius(10px);
background: $color-theme-blue; background: $color-theme-blue;
border: 0; border: 0;
border-radius: 2px;
color: $color-dark-foreground; color: $color-dark-foreground;
cursor: default; cursor: default;
font-size: .8rem; display: inline-block;
font-weight: normal; height: $inner-height;
height: 1.25rem; margin-bottom: .25rem;
margin-right: 2px; margin-right: 2px;
padding: 1px .6rem; padding: 1px .5rem;
vertical-align: top;
white-space: nowrap; white-space: nowrap;
} width: auto;
&,
&-container {
display: inline-block;
}
&-container {
height: 24px;
padding: 2px;
padding-left: 0;
} }
&.disabled { &.disabled {
pointer-events: none; pointer-events: none;
i {
display: none;
}
} }
&:hover { &:hover {
background: $color-theme-blue-dark; 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 // 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 { FormControl, NG_VALUE_ACCESSOR } from '@angular/forms';
import { distinctUntilChanged, map, tap } from 'rxjs/operators'; import { distinctUntilChanged, map, tap } from 'rxjs/operators';
import { import {
fadeAnimation, fadeAnimation,
Keys, Keys,
ModalModel,
StatefulControlComponent, StatefulControlComponent,
Types Types
} from '@app/framework/internal'; } from '@app/framework/internal';
@ -45,7 +46,7 @@ export interface Converter {
export class IntConverter implements Converter { export class IntConverter implements Converter {
private static ZERO = new TagValue(0, '0', 0); private static ZERO = new TagValue(0, '0', 0);
public convertInput(input: string): TagValue<number> | null { public convertInput(input: string) {
if (input === '0') { if (input === '0') {
return IntConverter.ZERO; return IntConverter.ZERO;
} }
@ -59,7 +60,7 @@ export class IntConverter implements Converter {
return null; return null;
} }
public convertValue(value: any): TagValue<number> | null { public convertValue(value: any) {
if (Types.isNumber(value)) { if (Types.isNumber(value)) {
return new TagValue(value, `${value}`, value); return new TagValue(value, `${value}`, value);
} }
@ -71,7 +72,7 @@ export class IntConverter implements Converter {
export class FloatConverter implements Converter { export class FloatConverter implements Converter {
private static ZERO = new TagValue(0, '0', 0); private static ZERO = new TagValue(0, '0', 0);
public convertInput(input: string): TagValue<number> | null { public convertInput(input: string) {
if (input === '0') { if (input === '0') {
return FloatConverter.ZERO; return FloatConverter.ZERO;
} }
@ -85,7 +86,7 @@ export class FloatConverter implements Converter {
return null; return null;
} }
public convertValue(value: any): TagValue<number> | null { public convertValue(value: any) {
if (Types.isNumber(value)) { if (Types.isNumber(value)) {
return new TagValue(value, `${value}`, value); return new TagValue(value, `${value}`, value);
} }
@ -95,7 +96,7 @@ export class FloatConverter implements Converter {
} }
export class StringConverter implements Converter { export class StringConverter implements Converter {
public convertInput(input: string): TagValue<string> | null { public convertInput(input: string) {
if (input) { if (input) {
const trimmed = input.trim(); const trimmed = input.trim();
@ -107,7 +108,7 @@ export class StringConverter implements Converter {
return null; return null;
} }
public convertValue(value: any): TagValue<string> | null { public convertValue(value: any) {
if (Types.isString(value)) { if (Types.isString(value)) {
const trimmed = value.trim(); 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 provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => TagEditorComponent), multi: true
}; };
const CACHED_SIZES: { [key: string]: number } = {};
let CACHED_FONT: string; let CACHED_FONT: string;
interface State { interface State {
@ -140,22 +139,21 @@ interface State {
styleUrls: ['./tag-editor.component.scss'], styleUrls: ['./tag-editor.component.scss'],
templateUrl: './tag-editor.component.html', templateUrl: './tag-editor.component.html',
providers: [SQX_TAG_EDITOR_CONTROL_VALUE_ACCESSOR], providers: [SQX_TAG_EDITOR_CONTROL_VALUE_ACCESSOR],
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [ animations: [
fadeAnimation fadeAnimation
] ],
changeDetection: ChangeDetectionStrategy.OnPush
}) })
// tslint:disable-next-line: readonly-array // 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 }) @ViewChild('form', { static: false })
public formElement: ElementRef<HTMLElement>; public formElement: ElementRef<HTMLElement>;
@ViewChild('input', { static: false }) @ViewChild('input', { static: false })
public inputElement: ElementRef<HTMLInputElement>; public inputElement: ElementRef<HTMLInputElement>;
@Input()
public suggestedValues: ReadonlyArray<TagValue> = [];
@Input() @Input()
public converter: Converter = new StringConverter(); public converter: Converter = new StringConverter();
@ -186,12 +184,21 @@ export class TagEditorComponent extends StatefulControlComponent<State, any[]> i
@Input() @Input()
public inputName = 'tag-editor'; public inputName = 'tag-editor';
@Input()
public set suggestedValues(value: ReadonlyArray<TagValue>) {
if (value) {
this.suggestionsSorted = value.sortedByString(x => x.lowerCaseName);
} else {
this.suggestionsSorted = [];
}
}
@Input() @Input()
public set suggestions(value: ReadonlyArray<string>) { public set suggestions(value: ReadonlyArray<string>) {
if (value) { 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 { } else {
this.suggestedValues = []; this.suggestionsSorted = [];
} }
} }
@ -200,6 +207,9 @@ export class TagEditorComponent extends StatefulControlComponent<State, any[]> i
this.setDisabledState(value); this.setDisabledState(value);
} }
public suggestionsSorted: ReadonlyArray<TagValue> = [];
public suggestionsModal = new ModalModel();
public addInput = new FormControl(); public addInput = new FormControl();
constructor(changeDetector: ChangeDetectorRef) { constructor(changeDetector: ChangeDetectorRef) {
@ -212,13 +222,13 @@ export class TagEditorComponent extends StatefulControlComponent<State, any[]> i
} }
public ngAfterViewInit() { public ngAfterViewInit() {
if (!CACHED_FONT) { this.resetSize();
const style = window.getComputedStyle(this.inputElement.nativeElement); }
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() { public ngOnInit() {
@ -236,8 +246,8 @@ export class TagEditorComponent extends StatefulControlComponent<State, any[]> i
}), }),
distinctUntilChanged(), distinctUntilChanged(),
map(query => { map(query => {
if (Types.isArray(this.suggestedValues) && query && query.length > 0) { if (Types.isArray(this.suggestionsSorted) && query && query.length > 0) {
return this.suggestedValues.filter(s => s.lowerCaseName.indexOf(query) >= 0 && !this.snapshot.items.find(x => x.id === s.id)); return this.suggestionsSorted.filter(s => s.lowerCaseName.indexOf(query) >= 0 && !this.snapshot.items.find(x => x.id === s.id));
} else { } else {
return []; return [];
} }
@ -252,6 +262,8 @@ export class TagEditorComponent extends StatefulControlComponent<State, any[]> i
} }
public writeValue(obj: any) { public writeValue(obj: any) {
this.latestValue = obj;
this.resetForm(); this.resetForm();
this.resetSize(); this.resetSize();
@ -304,6 +316,8 @@ export class TagEditorComponent extends StatefulControlComponent<State, any[]> i
} }
public resetSize() { public resetSize() {
this.calculateStyle();
if (!CACHED_FONT || if (!CACHED_FONT ||
!this.inputElement || !this.inputElement ||
!this.inputElement.nativeElement) { !this.inputElement.nativeElement) {
@ -321,18 +335,11 @@ export class TagEditorComponent extends StatefulControlComponent<State, any[]> i
ctx.font = CACHED_FONT; ctx.font = CACHED_FONT;
const textValue = this.inputElement.nativeElement.value; 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'); 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) { public onKeyDown(event: KeyboardEvent) {
const key = event.keyCode; const key = event.keyCode;
@ -361,10 +387,10 @@ export class TagEditorComponent extends StatefulControlComponent<State, any[]> i
return false; return false;
} }
} else if (key === Keys.UP) { } else if (key === Keys.UP) {
this.up(); this.selectPrevIndex();
return false; return false;
} else if (key === Keys.DOWN) { } else if (key === Keys.DOWN) {
this.down(); this.selectNextIndex();
return false; return false;
} else if (key === Keys.ENTER) { } else if (key === Keys.ENTER) {
if (this.snapshot.suggestedIndex >= 0) { if (this.snapshot.suggestedIndex >= 0) {
@ -395,7 +421,7 @@ export class TagEditorComponent extends StatefulControlComponent<State, any[]> i
} }
if (tagValue) { 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]); this.updateItems([...this.snapshot.items, tagValue]);
} }
@ -407,12 +433,20 @@ export class TagEditorComponent extends StatefulControlComponent<State, any[]> i
return false; return false;
} }
private resetAutocompletion() { public toggleValue(isSelected: boolean, tagValue: TagValue) {
this.next(s => ({ if (isSelected) {
...s, this.updateItems([...this.snapshot.items, tagValue]);
suggestedItems: [], } else {
suggestedIndex: -1 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) { public selectIndex(suggestedIndex: number) {
@ -431,16 +465,16 @@ export class TagEditorComponent extends StatefulControlComponent<State, any[]> i
this.next(s => ({ ...s, hasFocus: false })); this.next(s => ({ ...s, hasFocus: false }));
} }
private resetForm() { private resetAutocompletion() {
this.addInput.reset(); this.next(s => ({ ...s, suggestedItems: [], suggestedIndex: -1 }));
} }
private up() { private resetForm() {
this.selectIndex(this.snapshot.suggestedIndex - 1); this.addInput.reset();
} }
private down() { public isSelected(tagValue: TagValue) {
this.selectIndex(this.snapshot.suggestedIndex + 1); return this.snapshot.items.find(x => x.id === tagValue.id);
} }
public onCut(event: ClipboardEvent) { 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]; 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/markdown-editor.component';
export * from './components/pipes'; export * from './components/pipes';
export * from './components/references-dropdown.component'; export * from './components/references-dropdown.component';
export * from './components/references-tags.component';
export * from './components/rich-editor.component'; export * from './components/rich-editor.component';
export * from './components/saved-queries.component'; export * from './components/saved-queries.component';
export * from './components/schema-category.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/roles.state';
export * from './state/rule-events.state'; export * from './state/rule-events.state';
export * from './state/rules.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.forms';
export * from './state/schemas.state'; export * from './state/schemas.state';
export * from './state/ui.state'; export * from './state/ui.state';

7
frontend/app/shared/module.ts

@ -75,6 +75,7 @@ import {
PlansState, PlansState,
QueryComponent, QueryComponent,
ReferencesDropdownComponent, ReferencesDropdownComponent,
ReferencesTagsComponent,
RichEditorComponent, RichEditorComponent,
RolesService, RolesService,
RolesState, RolesState,
@ -88,7 +89,7 @@ import {
SchemaMustNotBeSingletonGuard, SchemaMustNotBeSingletonGuard,
SchemasService, SchemasService,
SchemasState, SchemasState,
SchemaTagConverter, SchemaTagSource,
SearchFormComponent, SearchFormComponent,
SortingComponent, SortingComponent,
TableHeaderComponent, TableHeaderComponent,
@ -144,6 +145,7 @@ import {
MarkdownEditorComponent, MarkdownEditorComponent,
QueryComponent, QueryComponent,
ReferencesDropdownComponent, ReferencesDropdownComponent,
ReferencesTagsComponent,
RichEditorComponent, RichEditorComponent,
SavedQueriesComponent, SavedQueriesComponent,
SchemaCategoryComponent, SchemaCategoryComponent,
@ -182,6 +184,7 @@ import {
LanguageSelectorComponent, LanguageSelectorComponent,
MarkdownEditorComponent, MarkdownEditorComponent,
ReferencesDropdownComponent, ReferencesDropdownComponent,
ReferencesTagsComponent,
RichEditorComponent, RichEditorComponent,
RouterModule, RouterModule,
SavedQueriesComponent, SavedQueriesComponent,
@ -247,7 +250,7 @@ export class SqxSharedModule {
SchemaMustNotBeSingletonGuard, SchemaMustNotBeSingletonGuard,
SchemasService, SchemasService,
SchemasState, SchemasState,
SchemaTagConverter, SchemaTagSource,
TranslationsService, TranslationsService,
UIService, UIService,
UIState, 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. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { Injectable, OnDestroy } from '@angular/core'; import { Injectable } from '@angular/core';
import { Subscription } from 'rxjs'; import { map, shareReplay } from 'rxjs/operators';
import { Converter, TagValue } from '@app/framework'; import { Converter, TagValue } from '@app/framework';
import { SchemaDto } from './../services/schemas.service'; import { SchemaDto } from './../services/schemas.service';
import { SchemasState } from './schemas.state'; import { SchemasState } from './schemas.state';
@Injectable() class SchemaConverter implements Converter {
export class SchemaTagConverter implements Converter, OnDestroy { public suggestions: ReadonlyArray<TagValue>;
private schemasSubscription: Subscription;
private schemas: ReadonlyArray<SchemaDto> = [];
public suggestions: ReadonlyArray<TagValue> = [];
constructor( constructor(
readonly schemasState: SchemasState private readonly schemas: ReadonlyArray<SchemaDto>
) { ) {
this.schemasSubscription = this.suggestions = schemas.map(x => new TagValue(x.id, x.name, x.id));
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();
} }
public convertInput(input: string): TagValue<any> | null { public convertInput(input: string) {
const schema = this.schemas.find(x => x.name === input); const schema = this.schemas.find(x => x.name === input);
if (schema) { if (schema) {
@ -47,7 +32,7 @@ export class SchemaTagConverter implements Converter, OnDestroy {
return null; return null;
} }
public convertValue(value: any): TagValue<any> | null { public convertValue(value: any) {
const schema = this.schemas.find(x => x.id === value); const schema = this.schemas.find(x => x.id === value);
if (schema) { if (schema) {
@ -56,4 +41,17 @@ export class SchemaTagConverter implements Converter, OnDestroy {
return null; 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