Browse Source

Feature/contributors in search (#600)

* Fix for rerun rules.

* Tests fixed

* Contributors in search.
pull/604/head
Sebastian Stehle 5 years ago
committed by GitHub
parent
commit
71c864e71f
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 8
      frontend/app/features/content/pages/contents/contents-page.component.ts
  2. 4
      frontend/app/features/settings/pages/contributors/contributor-add-form.component.html
  3. 2
      frontend/app/features/settings/pages/contributors/contributor-add-form.component.scss
  4. 20
      frontend/app/framework/angular/forms/editors/dropdown.component.html
  5. 19
      frontend/app/framework/angular/forms/editors/dropdown.component.scss
  6. 102
      frontend/app/framework/angular/forms/editors/dropdown.component.ts
  7. 36
      frontend/app/framework/utils/keys.ts
  8. 34
      frontend/app/shared/components/search/queries/filter-comparison.component.html
  9. 10
      frontend/app/shared/components/search/queries/filter-comparison.component.scss
  10. 16
      frontend/app/shared/components/search/queries/filter-comparison.component.ts
  11. 2
      frontend/app/shared/components/search/query-list.component.ts
  12. 15
      frontend/app/shared/components/search/search-form.component.ts
  13. 4
      frontend/app/shared/services/contributors.service.ts
  14. 7
      frontend/app/shared/state/contributors.state.spec.ts
  15. 10
      frontend/app/shared/state/contributors.state.ts
  16. 12
      frontend/app/shared/state/query.ts

8
frontend/app/features/content/pages/contents/contents-page.component.ts

@ -9,7 +9,7 @@
import { Component, OnInit, ViewChild } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { AppLanguageDto, ContentDto, ContentsState, fadeAnimation, LanguagesState, ModalModel, Queries, Query, QueryModel, queryModelFromSchema, ResourceOwner, Router2State, SchemaDetailsDto, SchemasState, TableFields, TempService, UIState } from '@app/shared';
import { AppLanguageDto, AppsState, ContentDto, ContentsState, ContributorsState, fadeAnimation, LanguagesState, ModalModel, Queries, Query, QueryModel, queryModelFromSchema, ResourceOwner, Router2State, SchemaDetailsDto, SchemasState, TableFields, TempService, UIState } from '@app/shared';
import { combineLatest } from 'rxjs';
import { distinctUntilChanged, onErrorResumeNext, switchMap, tap } from 'rxjs/operators';
import { DueTimeSelectorComponent } from './../../shared/due-time-selector.component';
@ -53,6 +53,8 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
constructor(
public readonly contentsRoute: Router2State,
public readonly contentsState: ContentsState,
private readonly appsState: AppsState,
private readonly contributorsState: ContributorsState,
private readonly route: ActivatedRoute,
private readonly router: Router,
private readonly languagesState: LanguagesState,
@ -64,6 +66,10 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
}
public ngOnInit() {
if (this.appsState.snapshot.selectedApp?.canReadContributors) {
this.contributorsState.loadIfNotLoaded();
}
this.own(
combineLatest([
this.schemasState.selectedSchema,

4
frontend/app/features/settings/pages/contributors/contributor-add-form.component.html

@ -5,9 +5,9 @@
<sqx-autocomplete [source]="usersDataSource" formControlName="user" inputName="contributor" placeholder="{{ 'contributors.emailPlaceholder' | sqxTranslate }}" displayProperty="displayName">
<ng-template let-user="$implicit">
<span class="autocomplete-user">
<img class="user-picture autocomplete-user-picture" [src]="user | sqxUserDtoPicture">
<img class="user-picture" [src]="user | sqxUserDtoPicture">
<span class="user-name autocomplete-user-name">{{user.displayName}}</span>
<span class="user-name">{{user.displayName}}</span>
</span>
</ng-template>
</sqx-autocomplete>

2
frontend/app/features/settings/pages/contributors/contributor-add-form.component.scss

@ -3,7 +3,7 @@
@include truncate;
}
&-name {
.user-name {
margin-left: .25rem;
}
}

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

@ -1,13 +1,14 @@
<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">
<input type="text" class="custom-select" [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="selectedItem">
<span class="truncate" *ngIf="!templateSelection">{{selectedItem}}</span>
<ng-template *ngIf="templateSelection" [sqxTemplateWrapper]="templateSelection" [item]="snapshot.selectedItem"></ng-template>
<ng-template *ngIf="templateSelection" [sqxTemplateWrapper]="templateSelection" [item]="selectedItem"></ng-template>
</div>
<i class="icon-caret-down"></i>
</div>
<div class="items-container">
@ -18,7 +19,12 @@
</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" [class.separated]="separated" (mousedown)="selectIndexAndClose(i)" [sqxScrollActive]="i === snapshot.selectedIndex" [sqxScrollContainer]="container">
<div *ngFor="let item of snapshot.suggestedItems; let i = index;" class="control-dropdown-item control-dropdown-item-selectable"
[class.active]="i === snapshot.selectedIndex"
[class.separated]="separated"
(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>

19
frontend/app/framework/angular/forms/editors/dropdown.component.scss

@ -1,16 +1,16 @@
$color-input-disabled: #eef1f4;
.form-control {
.custom-select {
& {
width: 100%;
cursor: default;
}
&[readonly] {
background: $color-input-background;
background-color: $color-input-background;
}
&:disabled {
background: $color-input-disabled;
background-color: $color-input-disabled;
}
}
@ -32,25 +32,18 @@ $color-input-disabled: #eef1f4;
.selection {
& {
overflow: hidden;
position: relative;
}
.control-dropdown-item {
@include absolute(0, 1rem, 0, 0);
@include absolute(0, 1.75rem, 0, 0);
line-height: 1.2rem;
overflow: hidden;
padding-bottom: 0;
pointer-events: none;
position: absolute;
}
.icon-caret-down {
@include absolute(30%, 5px, null, null);
font-size: .9rem;
font-weight: normal;
pointer-events: none;
}
.truncate {
min-height: 1.2rem;
}

102
frontend/app/framework/angular/forms/editors/dropdown.component.ts

@ -5,6 +5,8 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
// tslint:disable: prefer-for-of
import { AfterContentInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChildren, forwardRef, Input, OnChanges, OnInit, QueryList, SimpleChanges, TemplateRef } from '@angular/core';
import { ControlValueAccessor, FormControl, NG_VALUE_ACCESSOR } from '@angular/forms';
import { Keys, ModalModel, StatefulControlComponent, Types } from '@app/framework/internal';
@ -18,9 +20,6 @@ interface State {
// The suggested item.
suggestedItems: ReadonlyArray<any>;
// The selected suggested item.
selectedItem: any;
// The selected suggested index.
selectedIndex: number;
@ -40,12 +39,17 @@ const NO_EMIT = { emitEvent: false };
changeDetection: ChangeDetectionStrategy.OnPush
})
export class DropdownComponent extends StatefulControlComponent<State, ReadonlyArray<any>> implements AfterContentInit, ControlValueAccessor, OnChanges, OnInit {
private value: any;
@Input()
public items: ReadonlyArray<any> = [];
@Input()
public searchProperty = 'name';
@Input()
public valueProperty?: string;
@Input()
public canSearch = true;
@ -62,9 +66,12 @@ export class DropdownComponent extends StatefulControlComponent<State, ReadonlyA
public queryInput = new FormControl();
public get selectedItem() {
return this.items[this.snapshot.selectedIndex];
}
constructor(changeDetector: ChangeDetectorRef) {
super(changeDetector, {
selectedItem: undefined,
selectedIndex: -1,
suggestedItems: []
});
@ -102,11 +109,13 @@ export class DropdownComponent extends StatefulControlComponent<State, ReadonlyA
public ngOnChanges(changes: SimpleChanges) {
if (changes['items']) {
this.items = this.items || [];
this.resetSearch();
this.next(s => ({
...s,
suggestedIndex: 0,
suggestedIndex: this.getSelectedIndex(this.value),
suggestedItems: this.items || []
}));
}
@ -127,7 +136,9 @@ export class DropdownComponent extends StatefulControlComponent<State, ReadonlyA
}
public writeValue(obj: any) {
this.selectIndex(this.items && obj ? this.items.indexOf(obj) : -1, false);
this.value = obj;
this.selectIndex(this.getSelectedIndex(obj), false);
}
public setDisabledState(isDisabled: boolean): void {
@ -141,21 +152,18 @@ export class DropdownComponent extends StatefulControlComponent<State, ReadonlyA
}
public onKeyDown(event: KeyboardEvent) {
switch (event.keyCode) {
case Keys.UP:
this.selectPrevIndex();
return false;
case Keys.DOWN:
this.selectNextIndex();
return false;
case Keys.ENTER:
this.selectIndexAndClose(this.snapshot.selectedIndex);
return false;
case Keys.ESCAPE:
if (this.dropdown.isOpen) {
this.close();
return false;
}
if (Keys.isUp(event)) {
this.selectPrevIndex();
return false;
} else if (Keys.isDown(event)) {
this.selectNextIndex();
return false;
} else if (Keys.isEnter(event)) {
this.selectIndexAndClose(this.snapshot.selectedIndex);
return false;
} else if (Keys.isEscape(event) && this.dropdown.isOpen) {
this.close();
return false;
}
return true;
@ -194,25 +202,53 @@ export class DropdownComponent extends StatefulControlComponent<State, ReadonlyA
}
public selectIndex(selectedIndex: number, fromUserAction: boolean) {
if (selectedIndex < 0 && fromUserAction) {
selectedIndex = 0;
}
if (fromUserAction) {
const items = this.snapshot.suggestedItems || [];
const items = this.snapshot.suggestedItems || [];
if (selectedIndex < 0) {
selectedIndex = 0;
}
if (selectedIndex >= items.length && fromUserAction) {
selectedIndex = items.length - 1;
}
if (selectedIndex >= items.length) {
selectedIndex = items.length - 1;
}
const value = items[selectedIndex];
const selectedItem = items[selectedIndex];
if (value !== this.snapshot.selectedItem) {
if (fromUserAction) {
this.callChange(value);
let selectedValue = selectedItem;
if (this.valueProperty && this.valueProperty.length > 0 && selectedValue) {
selectedValue = selectedValue[this.valueProperty];
}
if (this.value !== selectedValue) {
this.value = selectedValue;
this.callChange(selectedValue);
this.callTouched();
}
}
this.next(s => ({ ...s, selectedIndex }));
}
this.next(s => ({ ...s, selectedIndex, selectedItem: value }));
private getSelectedIndex(value: any) {
if (!value) {
return -1;
}
if (this.valueProperty && this.valueProperty.length > 0) {
for (let i = 0; i < this.items.length; i++) {
const item = this.items[i];
if (item && item[this.valueProperty] === value) {
return i;
}
}
} else {
return this.items.indexOf(value);
}
return -1;
}
}

36
frontend/app/framework/utils/keys.ts

@ -12,4 +12,40 @@ export module Keys {
export const ESCAPE = 27;
export const DOWN = 40;
export const UP = 38;
export function isComma(event: KeyboardEvent) {
const key = event.key || event.keyCode;
return key === ',' || key === COMMA;
}
export function isDelete(event: KeyboardEvent) {
const key = event.key || event.keyCode;
return key === 'Delete' || key === DELETE;
}
export function isEnter(event: KeyboardEvent) {
const key = event.key || event.keyCode;
return key === 'ENTER' || key === ENTER;
}
export function isDown(event: KeyboardEvent) {
const key = event.key || event.keyCode;
return key === 'ArrowDown' || key === DOWN;
}
export function isUp(event: KeyboardEvent) {
const key = event.key || event.keyCode;
return key === 'ArrowUp' || key === UP;
}
export function isEscape(event: KeyboardEvent) {
const key = event.key || event.keyCode;
return key === 'Escape' || key === 'Esc' || key === UP;
}
}

34
frontend/app/shared/components/search/queries/filter-comparison.component.html

@ -49,14 +49,40 @@
</ng-container>
<ng-container *ngSwitchCase="'status'">
<sqx-dropdown [items]="fieldModel.extra"
[ngModel]="getStatus(fieldModel.extra)"
(ngModelChange)="changeStatus($event)"
valueProperty="status"
[ngModel]="filter.value"
(ngModelChange)="changeValue($event)"
[canSearch]="false">
<ng-template let-target="$implicit">
<i class="icon-circle" [style.color]="target.color"></i> {{target.status}}
<ng-template let-status="$implicit">
<i class="icon-circle" [style.color]="status.color"></i> {{status.status}}
</ng-template>
</sqx-dropdown>
</ng-container>
<ng-container *ngSwitchCase="'user'">
<ng-container *ngIf="contributorsState.isLoaded | async; else noPermission">
<sqx-dropdown [items]="contributorsState.contributors | async"
valueProperty="token"
[ngModel]="filter.value"
(ngModelChange)="changeValue($event)">
<ng-template let-user="$implicit">
<span class="dropdown-user">
<img class="user-picture" [src]="user | sqxUserDtoPicture">
<span class="user-name ">{{user.contributorName}}</span>
</span>
</ng-template>
<ng-template let-user="$implicit">
<span class="user-name">{{user.contributorName}}</span>
</ng-template>
</sqx-dropdown>
</ng-container>
<ng-template #noPermission>
<input type="text" class="form-control" *ngIf="!fieldModel.extra"
[ngModel]="filter.value"
(ngModelChange)="changeValue($event)"
/>
</ng-template>
</ng-container>
<ng-container *ngSwitchCase="'string'">
<input type="text" class="form-control" *ngIf="!fieldModel.extra"
[ngModel]="filter.value"

10
frontend/app/shared/components/search/queries/filter-comparison.component.scss

@ -4,4 +4,14 @@
.operator {
width: 10rem;
}
.dropdown-user {
& {
@include truncate;
}
.user-name {
margin-left: .25rem;
}
}

16
frontend/app/shared/components/search/queries/filter-comparison.component.ts

@ -6,7 +6,8 @@
*/
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
import { FilterComparison, LanguageDto, QueryFieldModel, QueryModel, StatusInfo } from '@app/shared/internal';
import { FilterComparison, LanguageDto, QueryFieldModel, QueryModel } from '@app/shared/internal';
import { ContributorsState } from '@app/shared/state/contributors.state';
@Component({
selector: 'sqx-filter-comparison',
@ -34,6 +35,11 @@ export class FilterComparisonComponent implements OnChanges {
public noValue = false;
constructor(
public readonly contributorsState: ContributorsState
) {
}
public ngOnChanges(changes: SimpleChanges) {
if (changes['filter']) {
this.updatePath(false);
@ -41,14 +47,6 @@ export class FilterComparisonComponent implements OnChanges {
}
}
public getStatus(statuses: ReadonlyArray<StatusInfo>) {
return statuses.find(x => x.status === this.filter.value);
}
public changeStatus(status: StatusInfo) {
this.changeValue(status.status);
}
public changeValue(value: any) {
this.filter.value = value;

2
frontend/app/shared/components/search/query-list.component.ts

@ -22,7 +22,7 @@ export class QueryListComponent {
public remove = new EventEmitter<SavedQuery>();
@Input()
public queryUsed: Query | undefined;
public queryUsed?: Query;
@Input()
public queries: ReadonlyArray<SavedQuery>;

15
frontend/app/shared/components/search/search-form.component.ts

@ -7,7 +7,7 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { DialogModel, hasFilter, LanguageDto, Queries, Query, QueryModel, SaveQueryForm } from '@app/shared/internal';
import { DialogModel, equalsQuery, hasFilter, LanguageDto, Queries, Query, QueryModel, SaveQueryForm, Types } from '@app/shared/internal';
import { Observable } from 'rxjs';
@Component({
@ -18,6 +18,7 @@ import { Observable } from 'rxjs';
})
export class SearchFormComponent implements OnChanges {
public readonly standalone = { standalone: true };
private previousQuery?: Query;
@Output()
public queryChange = new EventEmitter<Query>();
@ -32,7 +33,7 @@ export class SearchFormComponent implements OnChanges {
public queryModel: QueryModel;
@Input()
public query: Query | undefined;
public query?: Query;
@Input()
public queries: Queries;
@ -62,6 +63,8 @@ export class SearchFormComponent implements OnChanges {
}
if (changes['query']) {
this.previousQuery = Types.clone(this.query);
this.hasFilter = hasFilter(this.query);
}
}
@ -69,7 +72,13 @@ export class SearchFormComponent implements OnChanges {
public search(close = false) {
this.hasFilter = hasFilter(this.query);
this.queryChange.emit(this.query);
if (!equalsQuery(this.query, this.previousQuery)) {
const clone = Types.clone(this.query);
this.queryChange.emit(clone);
this.previousQuery = this.query;
}
if (close) {
this.searchDialog.hide();

4
frontend/app/shared/services/contributors.service.ts

@ -26,6 +26,10 @@ export class ContributorDto {
public readonly canUpdate: boolean;
public readonly canRevoke: boolean;
public get token() {
return `subject:${this.contributorId}`;
}
constructor(
links: ResourceLinks,
public readonly contributorId: string,

7
frontend/app/shared/state/contributors.state.spec.ts

@ -71,6 +71,13 @@ describe('ContributorsState', () => {
expect(contributorsState.snapshot.isLoading).toBeFalsy();
});
it('should not load if already loaded', () => {
contributorsState.load(true).subscribe();
contributorsState.loadIfNotLoaded().subscribe();
expect().nothing();
});
it('should only show current page of contributors', () => {
contributorsState.load().subscribe();

10
frontend/app/shared/state/contributors.state.ts

@ -7,7 +7,7 @@
import { Injectable } from '@angular/core';
import { DialogService, ErrorDto, Pager, shareMapSubscribed, shareSubscribed, State, StateSynchronizer, Types, Version } from '@app/framework';
import { Observable, throwError } from 'rxjs';
import { EMPTY, Observable, throwError } from 'rxjs';
import { catchError, finalize, tap } from 'rxjs/operators';
import { AssignContributorDto, ContributorDto, ContributorsPayload, ContributorsService } from './../services/contributors.service';
import { AppsState } from './apps.state';
@ -97,6 +97,14 @@ export class ContributorsState extends State<Snapshot> {
.build();
}
public loadIfNotLoaded(): Observable<any> {
if (this.snapshot.isLoaded) {
return EMPTY;
}
return this.loadInternal(false);
}
public load(isReload = false): Observable<any> {
if (!isReload) {
const contributorsPager = this.snapshot.contributorsPager.reset();

12
frontend/app/shared/state/query.ts

@ -21,7 +21,8 @@ export type QueryValueType =
'reference' |
'status' |
'string' |
'tags';
'tags' |
'user';
export interface FilterOperator {
// The optional display value.
@ -255,6 +256,11 @@ const TypeStatus: QueryFieldModel = {
operators: EqualOperators
};
const TypeUser: QueryFieldModel = {
type: 'user',
operators: EqualOperators
};
const TypeString: QueryFieldModel = {
type: 'string',
operators: [...EqualOperators, ...CompareOperator, ...StringOperators, ...ArrayOperators]
@ -272,7 +278,7 @@ const DEFAULT_FIELDS: QueryModelFields = {
description: 'i18n:contents.createFieldDescription'
},
createdBy: {
...TypeString,
...TypeUser,
displayName: 'meta.createdBy',
description: 'i18n:contents.createdByFieldDescription'
},
@ -282,7 +288,7 @@ const DEFAULT_FIELDS: QueryModelFields = {
description: 'i18n:contents.lastModifiedFieldDescription'
},
lastModifiedBy: {
...TypeString,
...TypeUser,
displayName: 'meta.lastModifiedBy',
description: 'i18n:contents.lastModifiedByFieldDescription'
},

Loading…
Cancel
Save