mirror of https://github.com/Squidex/squidex.git
committed by
GitHub
77 changed files with 2946 additions and 1751 deletions
@ -0,0 +1,67 @@ |
|||
<div class="row"> |
|||
<div class="col"> |
|||
<div class="form-inline"> |
|||
<select class="form-control path mb-1 mr-2" [ngModel]="filter.path" (ngModelChange)="changePath($event)"> |
|||
<option *ngFor="let fieldName of model.fields | sqxKeys" [ngValue]="fieldName">{{fieldName}}</option> |
|||
</select> |
|||
|
|||
<ng-container *ngIf="fieldModel"> |
|||
<select class="form-control mb-1 mr-2" [ngModel]="filter.op" (ngModelChange)="changeOp($event)"> |
|||
<option *ngFor="let operator of fieldModel.operators" [ngValue]="operator.value">{{operator.name || operator.value}}</option> |
|||
</select> |
|||
|
|||
<div class="mb-1" *ngIf="!noValue" [ngSwitch]="fieldModel.type"> |
|||
<ng-container *ngSwitchCase="'boolean'"> |
|||
<input type="checkbox" class="form-control" |
|||
[ngModel]="filter.value" |
|||
(ngModelChange)="changeValue($event)" /> |
|||
</ng-container> |
|||
<ng-container *ngSwitchCase="'date'"> |
|||
<sqx-date-time-editor mode="Date" |
|||
[ngModel]="filter.value" |
|||
(ngModelChange)="changeValue($event)"> |
|||
</sqx-date-time-editor> |
|||
</ng-container> |
|||
<ng-container *ngSwitchCase="'datetime'"> |
|||
<sqx-date-time-editor mode="DateTime" |
|||
[ngModel]="filter.value" |
|||
(ngModelChange)="changeValue($event)"> |
|||
</sqx-date-time-editor> |
|||
</ng-container> |
|||
<ng-container *ngSwitchCase="'number'"> |
|||
<input type="number" class="form-control" |
|||
[ngModel]="filter.value" |
|||
(ngModelChange)="changeValue($event)" /> |
|||
</ng-container> |
|||
<ng-container *ngSwitchCase="'reference'"> |
|||
<sqx-references-dropdown [schemaId]="fieldModel.extra" |
|||
mode="Single" |
|||
[ngModel]="filter.value" |
|||
(ngModelChange)="changeValue($event)"> |
|||
</sqx-references-dropdown> |
|||
</ng-container> |
|||
<ng-container *ngSwitchCase="'status'"> |
|||
<sqx-dropdown [items]="fieldModel.extra" |
|||
[ngModel]="getStatus(fieldModel.extra)" |
|||
(ngModelChange)="changeStatus($event)" |
|||
[canSearch]="false"> |
|||
<ng-template let-target="$implicit"> |
|||
<i class="icon-circle" [style.color]="target.color"></i> {{target.status}} |
|||
</ng-template> |
|||
</sqx-dropdown> |
|||
</ng-container> |
|||
<ng-container *ngSwitchCase="'string'"> |
|||
<input type="text" class="form-control" *ngIf="!fieldModel.extra" |
|||
[ngModel]="filter.value" |
|||
(ngModelChange)="changeValue($event)" /> |
|||
</ng-container> |
|||
</div> |
|||
</ng-container> |
|||
</div> |
|||
</div> |
|||
<div class="col-auto pl-2"> |
|||
<button type="button" class="btn btn-text-danger" (click)="remove.emit()"> |
|||
<i class="icon-bin2"></i> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
@ -0,0 +1,6 @@ |
|||
@import '_vars'; |
|||
@import '_mixins'; |
|||
|
|||
.path { |
|||
max-width: 12rem; |
|||
} |
|||
@ -0,0 +1,106 @@ |
|||
/* |
|||
* Squidex Headless CMS |
|||
* |
|||
* @license |
|||
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
|||
*/ |
|||
|
|||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core'; |
|||
|
|||
import { |
|||
fadeAnimation, |
|||
FilterComparison, |
|||
QueryFieldModel, |
|||
QueryModel |
|||
} from '@app/shared/internal'; |
|||
import { StatusInfo } from '@app/shared/services/contents.service'; |
|||
|
|||
@Component({ |
|||
selector: 'sqx-filter-comparison', |
|||
styleUrls: ['./filter-comparison.component.scss'], |
|||
templateUrl: './filter-comparison.component.html', |
|||
animations: [ |
|||
fadeAnimation |
|||
], |
|||
changeDetection: ChangeDetectionStrategy.OnPush |
|||
}) |
|||
export class FilterComparisonComponent implements OnChanges { |
|||
public fieldModel: QueryFieldModel; |
|||
|
|||
public noValue = false; |
|||
|
|||
@Output() |
|||
public change = new EventEmitter(); |
|||
|
|||
@Output() |
|||
public remove = new EventEmitter(); |
|||
|
|||
@Input() |
|||
public model: QueryModel; |
|||
|
|||
@Input() |
|||
public filter: FilterComparison; |
|||
|
|||
public ngOnChanges(changes: SimpleChanges) { |
|||
if (changes['filter']) { |
|||
this.updatePath(false); |
|||
this.updateOperator(); |
|||
} |
|||
} |
|||
|
|||
public getStatus(statuses: 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; |
|||
|
|||
this.emitChange(); |
|||
} |
|||
|
|||
public changeOp(op: string) { |
|||
this.filter.op = op; |
|||
|
|||
this.updateOperator(); |
|||
|
|||
this.emitChange(); |
|||
} |
|||
|
|||
public changePath(path: string) { |
|||
this.filter.path = path; |
|||
|
|||
this.updatePath(true); |
|||
|
|||
this.emitChange(); |
|||
} |
|||
|
|||
private updateOperator() { |
|||
if (this.fieldModel) { |
|||
const operator = this.fieldModel.operators.find(x => x.value === this.filter.op); |
|||
|
|||
this.noValue = !!(operator && operator.noValue); |
|||
} |
|||
} |
|||
|
|||
private updatePath(refresh: boolean) { |
|||
const newModel = this.model.fields[this.filter.path]; |
|||
|
|||
if (newModel && refresh) { |
|||
if (!newModel.operators.find(x => x.value === this.filter.op)) { |
|||
this.filter.op = newModel.operators[0].value; |
|||
} |
|||
|
|||
this.filter.value = null; |
|||
} |
|||
|
|||
this.fieldModel = newModel; |
|||
} |
|||
|
|||
private emitChange() { |
|||
this.change.emit(); |
|||
} |
|||
} |
|||
@ -0,0 +1,45 @@ |
|||
<div class="group"> |
|||
<div class="row no-gutters"> |
|||
<div class="col"> |
|||
<div class="btn-group"> |
|||
<button type="button" class="btn btn-secondary btn-toggle" [class.btn-primary]="filter.and" [disabled]="filter.and" (click)="toggleType()"> |
|||
AND |
|||
</button> |
|||
<button type="button" class="btn btn-secondary btn-toggle" [class.btn-primary]="filter.or" [disabled]="filter.or" (click)="toggleType()"> |
|||
OR |
|||
</button> |
|||
</div> |
|||
</div> |
|||
|
|||
|
|||
<div class="col-auto pl-2" *ngIf="!isRoot"> |
|||
<button type="button" class="btn btn-text-danger" (click)="remove.emit()"> |
|||
<i class="icon-bin2"></i> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="filters"> |
|||
<span class="filter-line-v"></span> |
|||
|
|||
<div class="filter mt-3" *ngFor="let filter of filters"> |
|||
<span class="filter-line-h"></span> |
|||
|
|||
<sqx-filter-node [filter]="filter" [model]="model" [level]="level + 1" |
|||
(remove)="removeFilter(filter)" (change)="change.emit()"> |
|||
</sqx-filter-node> |
|||
</div> |
|||
|
|||
<div class="filter filter-add mt-3"> |
|||
<span class="filter-line-h"></span> |
|||
|
|||
<button class="btn btn-outline-success btn-sm mr-2" (click)="addComparison()"> |
|||
Add Filter |
|||
</button> |
|||
|
|||
<button class="btn btn-outline-success btn-sm" (click)="addLogical()" *ngIf="level < 1"> |
|||
Add Group |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
@ -0,0 +1,35 @@ |
|||
@import '_vars'; |
|||
@import '_mixins'; |
|||
|
|||
$field-line: #e1e8ef; |
|||
|
|||
.filters { |
|||
position: relative; |
|||
padding-left: 3rem; |
|||
} |
|||
|
|||
.filter { |
|||
& { |
|||
position: relative; |
|||
} |
|||
|
|||
&-line-v { |
|||
@include absolute(-.5rem, auto, 1.8rem, 1.5rem); |
|||
border: 0; |
|||
border-left: 2px dashed $field-line; |
|||
width: 2px; |
|||
} |
|||
|
|||
&-line-h { |
|||
@include absolute(1.2rem, auto, auto, -1.5rem); |
|||
border: 0; |
|||
border-bottom: 2px dashed $field-line; |
|||
width: 1.5rem; |
|||
} |
|||
|
|||
&-add { |
|||
background: none; |
|||
border: 2px dashed $field-line; |
|||
padding: .5rem; |
|||
} |
|||
} |
|||
@ -0,0 +1,100 @@ |
|||
/* |
|||
* Squidex Headless CMS |
|||
* |
|||
* @license |
|||
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
|||
*/ |
|||
|
|||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; |
|||
|
|||
import { |
|||
fadeAnimation, |
|||
FilterLogical, |
|||
FilterNode, |
|||
QueryModel |
|||
} from '@app/shared/internal'; |
|||
|
|||
@Component({ |
|||
selector: 'sqx-filter-logical', |
|||
styleUrls: ['./filter-logical.component.scss'], |
|||
templateUrl: './filter-logical.component.html', |
|||
animations: [ |
|||
fadeAnimation |
|||
], |
|||
changeDetection: ChangeDetectionStrategy.OnPush |
|||
}) |
|||
export class FilterLogicalComponent { |
|||
private filterValue: FilterLogical; |
|||
|
|||
public filters: FilterNode[] = []; |
|||
|
|||
@Output() |
|||
public change = new EventEmitter(); |
|||
|
|||
@Output() |
|||
public remove = new EventEmitter(); |
|||
|
|||
@Input() |
|||
public level = 0; |
|||
|
|||
@Input() |
|||
public isRoot = false; |
|||
|
|||
@Input() |
|||
public model: QueryModel; |
|||
|
|||
@Input() |
|||
public set filter(filter: FilterLogical) { |
|||
this.filterValue = filter; |
|||
|
|||
this.updateFilters(filter); |
|||
} |
|||
|
|||
public get filter() { |
|||
return this.filterValue; |
|||
} |
|||
|
|||
public addComparison() { |
|||
this.filters.push(<any>{ path: Object.keys(this.model.fields)[0] }); |
|||
|
|||
this.emitChange(); |
|||
} |
|||
|
|||
public addLogical() { |
|||
this.filters.push({ and: [] }); |
|||
|
|||
this.emitChange(); |
|||
} |
|||
|
|||
public removeFilter(node: FilterNode) { |
|||
this.filters.splice(this.filters.indexOf(node), 1); |
|||
|
|||
this.emitChange(); |
|||
} |
|||
|
|||
public toggleType() { |
|||
if (this.filterValue.and) { |
|||
this.filterValue.or = this.filterValue.and; |
|||
|
|||
delete this.filterValue.and; |
|||
} else { |
|||
this.filterValue.and = this.filterValue.or; |
|||
|
|||
delete this.filterValue.or; |
|||
} |
|||
|
|||
this.emitChange(); |
|||
} |
|||
|
|||
private updateFilters(filter: FilterLogical) { |
|||
if (filter) { |
|||
this.filters = filter.and || filter.or || []; |
|||
} else { |
|||
this.filters = []; |
|||
} |
|||
} |
|||
|
|||
private emitChange() { |
|||
this.change.emit(); |
|||
} |
|||
} |
|||
@ -0,0 +1,59 @@ |
|||
/* |
|||
* Squidex Headless CMS |
|||
* |
|||
* @license |
|||
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
|||
*/ |
|||
|
|||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; |
|||
|
|||
import { |
|||
FilterComparison, |
|||
FilterLogical, |
|||
FilterNode, |
|||
QueryModel |
|||
} from '@app/shared/internal'; |
|||
|
|||
@Component({ |
|||
selector: 'sqx-filter-node', |
|||
template: ` |
|||
<ng-container *ngIf="logical"> |
|||
<sqx-filter-logical [model]="model" [filter]="logical" [level]="level" |
|||
(remove)="remove.emit()" (change)="change.emit()"> |
|||
</sqx-filter-logical> |
|||
</ng-container> |
|||
|
|||
<ng-container *ngIf="comparison"> |
|||
<sqx-filter-comparison [model]="model" [filter]="comparison" |
|||
(remove)="remove.emit()" (change)="change.emit()"> |
|||
</sqx-filter-comparison> |
|||
</ng-container> |
|||
`,
|
|||
changeDetection: ChangeDetectionStrategy.OnPush |
|||
}) |
|||
export class FilterNodeComponent { |
|||
public comparison: FilterComparison; |
|||
|
|||
public logical: FilterLogical; |
|||
|
|||
@Output() |
|||
public change = new EventEmitter(); |
|||
|
|||
@Output() |
|||
public remove = new EventEmitter(); |
|||
|
|||
@Input() |
|||
public level: number; |
|||
|
|||
@Input() |
|||
public model: QueryModel; |
|||
|
|||
@Input() |
|||
public set filter(value: FilterNode) { |
|||
if (value['and'] || value['or']) { |
|||
this.logical = <FilterLogical>value; |
|||
} else { |
|||
this.comparison = <FilterComparison>value; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,83 @@ |
|||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; |
|||
|
|||
import { |
|||
Query, |
|||
QueryModel, |
|||
QuerySorting |
|||
} from '@app/shared/internal'; |
|||
|
|||
@Component({ |
|||
selector: 'sqx-query', |
|||
template: ` |
|||
<div> |
|||
<h4>Filter</h4> |
|||
|
|||
<sqx-filter-logical isRoot="true" [filter]="queryValue.filter" [model]="model" |
|||
(change)="emitQueryChange()"> |
|||
</sqx-filter-logical> |
|||
|
|||
<h4 class="mt-4">Sorting</h4> |
|||
|
|||
<div class="mb-2" *ngFor="let sorting of queryValue.sort"> |
|||
<sqx-sorting [sorting]="sorting" [model]="model" |
|||
(remove)="removeSorting(sorting)" (change)="emitQueryChange()"> |
|||
</sqx-sorting> |
|||
</div> |
|||
|
|||
<button class="btn btn-outline-success btn-sm mr-2" (click)="addSorting()"> |
|||
Add Sorting |
|||
</button> |
|||
</div> |
|||
`,
|
|||
changeDetection: ChangeDetectionStrategy.OnPush |
|||
}) |
|||
export class QueryComponent { |
|||
public queryValue: Query; |
|||
|
|||
@Output() |
|||
public queryChange = new EventEmitter<Query>(); |
|||
|
|||
@Input() |
|||
public model: QueryModel; |
|||
|
|||
@Input() |
|||
public set query(query: Query) { |
|||
if (!query) { |
|||
query = {}; |
|||
} |
|||
|
|||
if (query) { |
|||
if (!query.filter) { |
|||
query.filter = { |
|||
and: [] |
|||
}; |
|||
} |
|||
|
|||
if (!query.sort) { |
|||
query.sort = []; |
|||
} |
|||
|
|||
this.queryValue = query; |
|||
} |
|||
} |
|||
|
|||
constructor() { |
|||
this.query = {}; |
|||
} |
|||
|
|||
public addSorting() { |
|||
this.queryValue.sort!.push({ path: Object.keys(this.model.fields)[0], order: 'ascending' }); |
|||
|
|||
this.emitQueryChange(); |
|||
} |
|||
|
|||
public removeSorting(sorting: QuerySorting) { |
|||
this.queryValue.sort!.splice(this.queryValue.sort!.indexOf(sorting), 1); |
|||
|
|||
this.emitQueryChange(); |
|||
} |
|||
|
|||
public emitQueryChange() { |
|||
this.queryChange.emit(this.queryValue); |
|||
} |
|||
} |
|||
@ -0,0 +1,65 @@ |
|||
/* |
|||
* Squidex Headless CMS |
|||
* |
|||
* @license |
|||
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
|||
*/ |
|||
|
|||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; |
|||
|
|||
import { QueryModel, QuerySorting } from '@app/shared/internal'; |
|||
|
|||
@Component({ |
|||
selector: 'sqx-sorting', |
|||
template: ` |
|||
<div class="row"> |
|||
<div class="col"> |
|||
<div class="form-inline"> |
|||
<select class="form-control mr-2" [ngModel]="sorting.path" (ngModelChange)="changePath($event)"> |
|||
<option *ngFor="let fieldName of model.fields | sqxKeys" [ngValue]="fieldName">{{fieldName}}</option> |
|||
</select> |
|||
|
|||
<select class="form-control mr-2" [ngModel]="sorting.order" (ngModelChange)="changeOrder($event)"> |
|||
<option>ascending</option> |
|||
<option>descending</option> |
|||
</select> |
|||
</div> |
|||
</div> |
|||
<div class="col-auto pl-2"> |
|||
<button type="button" class="btn btn-text-danger" (click)="remove.emit()"> |
|||
<i class="icon-bin2"></i> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
`,
|
|||
changeDetection: ChangeDetectionStrategy.OnPush |
|||
}) |
|||
export class SortingComponent { |
|||
@Output() |
|||
public change = new EventEmitter(); |
|||
|
|||
@Output() |
|||
public remove = new EventEmitter(); |
|||
|
|||
@Input() |
|||
public model: QueryModel; |
|||
|
|||
@Input() |
|||
public sorting: QuerySorting; |
|||
|
|||
public changeOrder(order: any) { |
|||
this.sorting.order = order; |
|||
|
|||
this.emitChange(); |
|||
} |
|||
|
|||
public changePath(path: string) { |
|||
this.sorting.path = path; |
|||
|
|||
this.emitChange(); |
|||
} |
|||
|
|||
private emitChange() { |
|||
this.change.emit(); |
|||
} |
|||
} |
|||
@ -1,150 +0,0 @@ |
|||
/* |
|||
* Squidex Headless CMS |
|||
* |
|||
* @license |
|||
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
|||
*/ |
|||
|
|||
import { |
|||
FilterState, |
|||
LanguageDto, |
|||
Sorting |
|||
} from '@app/shared/internal'; |
|||
|
|||
describe('FilterState', () => { |
|||
let query: string | undefined; |
|||
let fullText: string | undefined; |
|||
let filterState: FilterState; |
|||
let filter: string | undefined; |
|||
let order: string | undefined; |
|||
|
|||
beforeEach(() => { |
|||
filterState = new FilterState(); |
|||
filterState.setLanguage(new LanguageDto('de', 'German')); |
|||
|
|||
filterState.query.subscribe(value => { |
|||
query = value; |
|||
}); |
|||
|
|||
filterState.filter.subscribe(value => { |
|||
filter = value; |
|||
}); |
|||
|
|||
filterState.fullText.subscribe(value => { |
|||
fullText = value; |
|||
}); |
|||
|
|||
filterState.order.subscribe(value => { |
|||
order = value; |
|||
}); |
|||
}); |
|||
|
|||
it('should parse elements from query', () => { |
|||
const newQuery = '$filter=MY_FILTER&$orderby=MY_FIELD desc&$search=MY_TEXT'; |
|||
|
|||
filterState.setQuery(newQuery); |
|||
|
|||
expect(order).toBe('MY_FIELD desc'); |
|||
expect(query).toBe(newQuery); |
|||
expect(filter).toBe('MY_FILTER'); |
|||
expect(fullText).toBe('MY_TEXT'); |
|||
}); |
|||
|
|||
it('should set full text and order and calculate query', () => { |
|||
filterState.setFullText('Hello World'); |
|||
filterState.setOrder('data/name/iv asc'); |
|||
|
|||
expect(query).toBe('$search=Hello World&$orderby=data/name/iv asc'); |
|||
expect(fullText).toBe('Hello World'); |
|||
}); |
|||
|
|||
it('should set full text only and calculate query', () => { |
|||
filterState.setFullText('Hello World'); |
|||
|
|||
expect(query).toBe('Hello World'); |
|||
expect(fullText).toBe('Hello World'); |
|||
}); |
|||
|
|||
it('should set filter and calculate query', () => { |
|||
filterState.setFilter('data/name/iv eq "Squidex"'); |
|||
|
|||
expect(query).toBe('$filter=data/name/iv eq "Squidex"'); |
|||
expect(filter).toBe('data/name/iv eq "Squidex"'); |
|||
}); |
|||
|
|||
it('should set order and calculate query', () => { |
|||
filterState.setOrder('data/name/iv asc'); |
|||
|
|||
expect(query).toBe('$orderby=data/name/iv asc'); |
|||
expect(order).toBe('data/name/iv asc'); |
|||
}); |
|||
|
|||
it('should set field name and calculate query', () => { |
|||
filterState.setOrderField('field', 'Descending'); |
|||
|
|||
expect(query).toBe('$orderby=field desc'); |
|||
expect(order).toBe('field desc'); |
|||
}); |
|||
|
|||
it('should set field and calculate query', () => { |
|||
filterState.setOrderField(<any>{ name: 'first-name', isLocalizable: false }, 'Ascending'); |
|||
|
|||
expect(query).toBe('$orderby=data/first_name/iv asc'); |
|||
expect(order).toBe('data/first_name/iv asc'); |
|||
}); |
|||
|
|||
it('should set localizable field and calculate query', () => { |
|||
filterState.setOrderField(<any>{ name: 'first-name', isLocalizable: true }, 'Ascending'); |
|||
|
|||
expect(query).toBe('$orderby=data/first_name/de asc'); |
|||
expect(order).toBe('data/first_name/de asc'); |
|||
}); |
|||
|
|||
it('should update field ordering for path', () => { |
|||
let sorting: Sorting; |
|||
|
|||
filterState.sortMode('field').subscribe(value => { |
|||
sorting = value; |
|||
}); |
|||
|
|||
filterState.setQuery('$orderby=field desc'); |
|||
|
|||
expect(sorting!).toBe('Descending'); |
|||
}); |
|||
|
|||
it('should update field ordering for field', () => { |
|||
let sorting: Sorting; |
|||
|
|||
filterState.sortMode(<any>{ name: 'first-name', fieldId: 1, isLocalizable: false }).subscribe(value => { |
|||
sorting = value; |
|||
}); |
|||
|
|||
filterState.setQuery('$orderby=data/first_name/iv asc'); |
|||
|
|||
expect(sorting!).toBe('Ascending'); |
|||
}); |
|||
|
|||
it('should update field ordering for localizable field', () => { |
|||
let sorting: Sorting; |
|||
|
|||
filterState.sortMode(<any>{ name: 'first-name', fieldId: 1, isLocalizable: true }).subscribe(value => { |
|||
sorting = value; |
|||
}); |
|||
|
|||
filterState.setQuery('$orderby=data/first_name/de asc'); |
|||
|
|||
expect(sorting!).toBe('Ascending'); |
|||
}); |
|||
|
|||
it('should update field ordering for localizable field and other language', () => { |
|||
let sorting: Sorting; |
|||
|
|||
filterState.sortMode(<any>{ name: 'first-name', fieldId: 1, isLocalizable: true }).subscribe(value => { |
|||
sorting = value; |
|||
}); |
|||
|
|||
filterState.setQuery('$orderby=data/first_name/en asc'); |
|||
|
|||
expect(sorting!).toBe('None'); |
|||
}); |
|||
}); |
|||
@ -1,215 +0,0 @@ |
|||
/* |
|||
* Squidex Headless CMS |
|||
* |
|||
* @license |
|||
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
|||
*/ |
|||
|
|||
import { Observable } from 'rxjs'; |
|||
import { distinctUntilChanged } from 'rxjs/internal/operators'; |
|||
|
|||
import { State, Types } from '@app/framework'; |
|||
|
|||
import { LanguageDto } from './../services/languages.service'; |
|||
|
|||
import { RootFieldDto } from './../services/schemas.service'; |
|||
|
|||
interface Snapshot { |
|||
// The current language.
|
|||
language?: LanguageDto; |
|||
|
|||
// The order statement.
|
|||
order?: string; |
|||
|
|||
// The order field.
|
|||
orderField?: string; |
|||
|
|||
// The order direction.
|
|||
orderDirection?: string; |
|||
|
|||
// The final query.
|
|||
query?: string; |
|||
|
|||
// The odata filter statement.
|
|||
filter?: string; |
|||
|
|||
// The odata full text statement.
|
|||
fullText?: string; |
|||
} |
|||
|
|||
export type Sorting = 'Ascending' | 'Descending' | 'None'; |
|||
|
|||
type Field = string | RootFieldDto; |
|||
|
|||
export class FilterState extends State<Snapshot> { |
|||
private readonly sortModes: { [key: string]: Observable<Sorting> } = {}; |
|||
|
|||
public query = |
|||
this.project(x => x.query); |
|||
|
|||
public order = |
|||
this.project(x => x.order); |
|||
|
|||
public filter = |
|||
this.project(x => x.filter); |
|||
|
|||
public fullText = |
|||
this.project(x => x.fullText); |
|||
|
|||
public get apiFilter() { |
|||
return this.snapshot.query; |
|||
} |
|||
|
|||
public constructor() { |
|||
super({}); |
|||
} |
|||
|
|||
public sortMode(field: Field) { |
|||
const key = Types.isString(field) ? field : field.fieldId.toString(); |
|||
|
|||
let result = this.sortModes[key]; |
|||
|
|||
if (!result) { |
|||
result = this.project(x => sortMode(x, field)), distinctUntilChanged(); |
|||
|
|||
this.sortModes[key] = result; |
|||
} |
|||
|
|||
return result; |
|||
} |
|||
|
|||
public setQuery(query?: string) { |
|||
this.next(s => fromQuery(s, query)); |
|||
} |
|||
|
|||
public setOrder(order?: string) { |
|||
this.next(s => fromProperty(s, { order })); |
|||
} |
|||
|
|||
public setFilter(filter?: string) { |
|||
this.next(s => fromProperty(s, { filter })); |
|||
} |
|||
|
|||
public setFullText(fullText?: string) { |
|||
this.next(s => fromProperty(s, { fullText })); |
|||
} |
|||
|
|||
public setLanguage(language: LanguageDto) { |
|||
this.next(s => ({ ...s, language })); |
|||
} |
|||
|
|||
public setOrderField(field: Field, sorting: Sorting) { |
|||
this.setOrder(getFieldSorting(this.snapshot, field, sorting)); |
|||
} |
|||
} |
|||
|
|||
function sortMode(snapshot: Snapshot, field: Field): Sorting { |
|||
let path = getFieldPath(snapshot, field); |
|||
|
|||
if (snapshot.orderField === path) { |
|||
if (snapshot.orderDirection === 'asc') { |
|||
return 'Ascending'; |
|||
} else if (snapshot.orderDirection === 'desc') { |
|||
return 'Descending'; |
|||
} |
|||
} |
|||
|
|||
return 'None'; |
|||
} |
|||
|
|||
function escapeField(value: string) { |
|||
return value.replace('-', '_'); |
|||
} |
|||
|
|||
function getFieldSorting(snapshot: Snapshot, field: Field, sorting: Sorting) { |
|||
if (sorting === 'Ascending') { |
|||
return `${getFieldPath(snapshot, field)} asc`; |
|||
} else { |
|||
return `${getFieldPath(snapshot, field)} desc`; |
|||
} |
|||
} |
|||
|
|||
function getFieldPath(snapshot: Snapshot, field?: Field) { |
|||
let path: string | undefined = undefined; |
|||
|
|||
if (field) { |
|||
if (Types.isString(field)) { |
|||
path = field; |
|||
} else if (field.isLocalizable && snapshot.language) { |
|||
path = `data/${escapeField(field.name)}/${snapshot.language.iso2Code.replace('-', '_')}`; |
|||
} else { |
|||
path = `data/${escapeField(field.name)}/iv`; |
|||
} |
|||
} |
|||
|
|||
return path; |
|||
} |
|||
|
|||
function fromQuery(previous: Snapshot, query?: string) { |
|||
const snapshot: Snapshot = { language: previous.language, query }; |
|||
|
|||
if (query) { |
|||
const parts = query.split('&'); |
|||
|
|||
if (parts.length === 1 && parts[0][0] !== '$') { |
|||
snapshot.fullText = parts[0]; |
|||
} else { |
|||
for (let part of parts) { |
|||
const kvp = part.split('='); |
|||
|
|||
if (kvp.length === 2) { |
|||
const key = kvp[0].toLowerCase(); |
|||
|
|||
if (key === '$filter') { |
|||
snapshot.filter = kvp[1]; |
|||
} else if (key === '$orderby') { |
|||
snapshot.order = kvp[1]; |
|||
} else if (key === '$search') { |
|||
snapshot.fullText = kvp[1]; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
return enrichOrderField(snapshot); |
|||
} |
|||
|
|||
function fromProperty(previous: Snapshot, update: Partial<Snapshot>) { |
|||
const snapshot = { ...previous, ...update }; |
|||
|
|||
if (snapshot.fullText && !snapshot.order && !snapshot.filter) { |
|||
snapshot.query = snapshot.fullText; |
|||
} else { |
|||
const parts: string[] = []; |
|||
|
|||
if (snapshot.fullText) { |
|||
parts.push(`$search=${snapshot.fullText}`); |
|||
} |
|||
|
|||
if (snapshot.filter) { |
|||
parts.push(`$filter=${snapshot.filter}`); |
|||
} |
|||
|
|||
if (snapshot.order) { |
|||
parts.push(`$orderby=${snapshot.order}`); |
|||
} |
|||
|
|||
snapshot.query = parts.join('&'); |
|||
} |
|||
|
|||
return enrichOrderField(snapshot); |
|||
} |
|||
|
|||
function enrichOrderField(snapshot: Snapshot) { |
|||
if (snapshot.order) { |
|||
const orderParts = snapshot.order.split(' '); |
|||
|
|||
if (orderParts.length === 2) { |
|||
snapshot.orderField = orderParts[0]; |
|||
snapshot.orderDirection = orderParts[1]; |
|||
} |
|||
} |
|||
|
|||
return snapshot; |
|||
} |
|||
@ -0,0 +1,218 @@ |
|||
/* |
|||
* Squidex Headless CMS |
|||
* |
|||
* @license |
|||
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
|||
*/ |
|||
|
|||
import { Types } from '@app/framework'; |
|||
|
|||
import { StatusInfo } from './../services/contents.service'; |
|||
import { LanguageDto } from './../services/languages.service'; |
|||
import { SchemaDetailsDto } from './../services/schemas.service'; |
|||
|
|||
export type QueryValueType = |
|||
'boolean' | |
|||
'date' | |
|||
'datetime' | |
|||
'number' | |
|||
'reference' | |
|||
'status' | |
|||
'string'; |
|||
|
|||
export interface FilterOperator { |
|||
// The optional display value.
|
|||
name?: string; |
|||
|
|||
// The operator value.
|
|||
value: string; |
|||
|
|||
// True, when the operator does not require an value.
|
|||
noValue?: boolean; |
|||
} |
|||
|
|||
export interface QueryFieldModel { |
|||
// The value type.
|
|||
type: QueryValueType; |
|||
|
|||
// The allowed operator.
|
|||
operators: FilterOperator[]; |
|||
|
|||
// Extra values.
|
|||
extra?: any; |
|||
} |
|||
|
|||
export interface QueryModel { |
|||
// All available fields.
|
|||
fields: { [name: string]: QueryFieldModel }; |
|||
} |
|||
|
|||
export type FilterNode = FilterComparison | FilterLogical; |
|||
|
|||
export interface FilterComparison { |
|||
// The full path to the property.
|
|||
path: string; |
|||
|
|||
// The operator.
|
|||
op: string; |
|||
|
|||
// The value.
|
|||
value: any; |
|||
} |
|||
|
|||
export interface FilterLogical { |
|||
// The child filters if the logical filter is a conjunction (AND).
|
|||
and?: FilterNode[]; |
|||
|
|||
// The child filters if the logical filter is a disjunction (OR).
|
|||
or?: FilterNode[]; |
|||
} |
|||
|
|||
export interface QuerySorting { |
|||
// The full path to the property.
|
|||
path: string; |
|||
|
|||
// The sort order.
|
|||
order: SortMode; |
|||
} |
|||
|
|||
export type SortMode = 'ascending' | 'descending'; |
|||
|
|||
export interface Query { |
|||
// The optional filter.
|
|||
filter?: FilterNode; |
|||
|
|||
// The full text search.
|
|||
fullText?: string; |
|||
|
|||
// The sorting.
|
|||
sort?: QuerySorting[]; |
|||
|
|||
// The number of items to take.
|
|||
take?: number; |
|||
|
|||
// The number of items to skip.
|
|||
skip?: number; |
|||
} |
|||
|
|||
export function encodeQuery(query?: Query) { |
|||
if (Types.isEmpty(query)) { |
|||
return ''; |
|||
} |
|||
|
|||
query = { ...query }; |
|||
|
|||
if (!query.sort) { |
|||
query.sort = []; |
|||
} |
|||
|
|||
if (!query.filter) { |
|||
query.filter = { and: [] }; |
|||
} |
|||
|
|||
return encodeURIComponent(JSON.stringify(query)); |
|||
} |
|||
|
|||
export function hasFilter(query?: Query) { |
|||
return !!query && !Types.isEmpty(query.filter); |
|||
} |
|||
|
|||
const EqualOperators: FilterOperator[] = [ |
|||
{ name: '==', value: 'eq' }, |
|||
{ name: '!=', value: 'ne' } |
|||
]; |
|||
|
|||
const CompareOperator: FilterOperator[] = [ |
|||
{ name: '<', value: 'lt' }, |
|||
{ name: '<=', value: 'le' }, |
|||
{ name: '>', value: 'gt' }, |
|||
{ name: '>=', value: 'ge' } |
|||
]; |
|||
|
|||
const StringOperators: FilterOperator[] = [ |
|||
{ name: 'T*', value: 'startsWith' }, |
|||
{ name: '*T', value: 'endsWith' }, |
|||
{ name: '*T*', value: 'contains' } |
|||
]; |
|||
|
|||
const ArrayOperators: FilterOperator[] = [ |
|||
{ value: 'empty', noValue: true } |
|||
]; |
|||
|
|||
const TypeBoolean: QueryFieldModel = { |
|||
type: 'boolean', |
|||
operators: EqualOperators |
|||
}; |
|||
|
|||
const TypeDateTime: QueryFieldModel = { |
|||
type: 'datetime', |
|||
operators: [...EqualOperators, ...CompareOperator] |
|||
}; |
|||
|
|||
const TypeNumber: QueryFieldModel = { |
|||
type: 'number', |
|||
operators: [...EqualOperators, ...CompareOperator] |
|||
}; |
|||
|
|||
const TypeReference: QueryFieldModel = { |
|||
type: 'reference', |
|||
operators: [...EqualOperators, ...ArrayOperators] |
|||
}; |
|||
|
|||
const TypeStatus: QueryFieldModel = { |
|||
type: 'status', |
|||
operators: EqualOperators |
|||
}; |
|||
|
|||
const TypeString: QueryFieldModel = { |
|||
type: 'string', |
|||
operators: [...EqualOperators, ...CompareOperator, ...StringOperators, ...ArrayOperators] |
|||
}; |
|||
|
|||
export function queryModelFromSchema(schema: SchemaDetailsDto, languages: LanguageDto[], statuses: StatusInfo[] | undefined) { |
|||
let languagesCodes = languages.map(x => x.iso2Code); |
|||
|
|||
let invariantCodes = ['iv']; |
|||
|
|||
let model: QueryModel = { |
|||
fields: {} |
|||
}; |
|||
|
|||
model.fields['created'] = TypeDateTime; |
|||
model.fields['createdBy'] = TypeString; |
|||
model.fields['lastModified'] = TypeDateTime; |
|||
model.fields['lastModifiedBy'] = TypeString; |
|||
model.fields['version'] = TypeNumber; |
|||
|
|||
if (statuses) { |
|||
model.fields['status'] = { ...TypeStatus, extra: statuses }; |
|||
} |
|||
|
|||
for (let field of schema.fields) { |
|||
let type: QueryFieldModel | null = null; |
|||
|
|||
if (field.properties.fieldType === 'Boolean') { |
|||
type = TypeBoolean; |
|||
} else if (field.properties.fieldType === 'Number') { |
|||
type = TypeNumber; |
|||
} else if (field.properties.fieldType === 'String') { |
|||
type = TypeString; |
|||
} else if (field.properties.fieldType === 'DateTime') { |
|||
type = TypeDateTime; |
|||
} else if (field.properties.fieldType === 'References') { |
|||
const extra = { schemaId: field.properties['schemaId'] }; |
|||
|
|||
type = { ...TypeReference, extra }; |
|||
} |
|||
|
|||
if (type) { |
|||
let codes = field.isLocalizable ? languagesCodes : invariantCodes; |
|||
|
|||
for (let code of codes) { |
|||
model.fields[`data.${field.name}.${code}`] = type; |
|||
} |
|||
} |
|||
} |
|||
|
|||
return model; |
|||
} |
|||
File diff suppressed because it is too large
Binary file not shown.
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
@ -0,0 +1,381 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System.Collections.Generic; |
|||
using System.Linq; |
|||
using NJsonSchema; |
|||
using Squidex.Infrastructure.Json.Objects; |
|||
using Squidex.Infrastructure.Queries.Json; |
|||
using Squidex.Infrastructure.TestHelpers; |
|||
using Xunit; |
|||
|
|||
namespace Squidex.Infrastructure.Queries |
|||
{ |
|||
public sealed class JsonQueryConversionTests |
|||
{ |
|||
private readonly List<string> errors = new List<string>(); |
|||
private readonly JsonSchema schema = new JsonSchema(); |
|||
|
|||
public JsonQueryConversionTests() |
|||
{ |
|||
var nested = new JsonSchemaProperty { Title = "nested" }; |
|||
|
|||
nested.Properties["property"] = new JsonSchemaProperty |
|||
{ |
|||
Type = JsonObjectType.String |
|||
}; |
|||
|
|||
schema.Properties["boolean"] = new JsonSchemaProperty |
|||
{ |
|||
Type = JsonObjectType.Boolean |
|||
}; |
|||
|
|||
schema.Properties["datetime"] = new JsonSchemaProperty |
|||
{ |
|||
Type = JsonObjectType.String, Format = JsonFormatStrings.DateTime |
|||
}; |
|||
|
|||
schema.Properties["guid"] = new JsonSchemaProperty |
|||
{ |
|||
Type = JsonObjectType.String, Format = JsonFormatStrings.Guid |
|||
}; |
|||
|
|||
schema.Properties["integer"] = new JsonSchemaProperty |
|||
{ |
|||
Type = JsonObjectType.Integer |
|||
}; |
|||
|
|||
schema.Properties["number"] = new JsonSchemaProperty |
|||
{ |
|||
Type = JsonObjectType.Number |
|||
}; |
|||
|
|||
schema.Properties["string"] = new JsonSchemaProperty |
|||
{ |
|||
Type = JsonObjectType.String |
|||
}; |
|||
|
|||
schema.Properties["stringArray"] = new JsonSchemaProperty |
|||
{ |
|||
Item = new JsonSchema |
|||
{ |
|||
Type = JsonObjectType.String |
|||
}, |
|||
Type = JsonObjectType.Array |
|||
}; |
|||
|
|||
schema.Properties["object"] = nested; |
|||
|
|||
schema.Properties["reference"] = new JsonSchemaProperty |
|||
{ |
|||
Reference = nested |
|||
}; |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_add_error_if_property_does_not_exist() |
|||
{ |
|||
var json = new { path = "notfound", op = "eq", value = 1 }; |
|||
|
|||
AssertErrors(json, "Path 'notfound' does not point to a valid property in the model."); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_add_error_if_nested_property_does_not_exist() |
|||
{ |
|||
var json = new { path = "object.notfound", op = "eq", value = 1 }; |
|||
|
|||
AssertErrors(json, "'notfound' is not a property of 'nested'."); |
|||
} |
|||
|
|||
[Theory] |
|||
[InlineData("contains", "contains(datetime, 2012-11-10T09:08:07Z)")] |
|||
[InlineData("empty", "empty(datetime)")] |
|||
[InlineData("endswith", "endsWith(datetime, 2012-11-10T09:08:07Z)")] |
|||
[InlineData("eq", "datetime == 2012-11-10T09:08:07Z")] |
|||
[InlineData("ge", "datetime >= 2012-11-10T09:08:07Z")] |
|||
[InlineData("gt", "datetime > 2012-11-10T09:08:07Z")] |
|||
[InlineData("le", "datetime <= 2012-11-10T09:08:07Z")] |
|||
[InlineData("lt", "datetime < 2012-11-10T09:08:07Z")] |
|||
[InlineData("ne", "datetime != 2012-11-10T09:08:07Z")] |
|||
[InlineData("startswith", "startsWith(datetime, 2012-11-10T09:08:07Z)")] |
|||
public void Should_parse_datetime_string_filter(string op, string expected) |
|||
{ |
|||
var json = new { path = "datetime", op, value = "2012-11-10T09:08:07Z" }; |
|||
|
|||
AssertFilter(json, expected); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_parse_date_string_filter() |
|||
{ |
|||
var json = new { path = "datetime", op = "eq", value = "2012-11-10" }; |
|||
|
|||
AssertFilter(json, "datetime == 2012-11-10T00:00:00Z"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_add_error_if_datetime_string_property_got_invalid_string_value() |
|||
{ |
|||
var json = new { path = "datetime", op = "eq", value = "invalid" }; |
|||
|
|||
AssertErrors(json, "Expected ISO8601 DateTime String for path 'datetime', but got invalid String."); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_add_error_if_datetime_string_property_got_invalid_value() |
|||
{ |
|||
var json = new { path = "datetime", op = "eq", value = 1 }; |
|||
|
|||
AssertErrors(json, "Expected ISO8601 DateTime String for path 'datetime', but got Number."); |
|||
} |
|||
|
|||
[Theory] |
|||
[InlineData("contains", "contains(guid, bf57d32c-d4dd-4217-8c16-6dcb16975cf3)")] |
|||
[InlineData("empty", "empty(guid)")] |
|||
[InlineData("endswith", "endsWith(guid, bf57d32c-d4dd-4217-8c16-6dcb16975cf3)")] |
|||
[InlineData("eq", "guid == bf57d32c-d4dd-4217-8c16-6dcb16975cf3")] |
|||
[InlineData("ge", "guid >= bf57d32c-d4dd-4217-8c16-6dcb16975cf3")] |
|||
[InlineData("gt", "guid > bf57d32c-d4dd-4217-8c16-6dcb16975cf3")] |
|||
[InlineData("le", "guid <= bf57d32c-d4dd-4217-8c16-6dcb16975cf3")] |
|||
[InlineData("lt", "guid < bf57d32c-d4dd-4217-8c16-6dcb16975cf3")] |
|||
[InlineData("ne", "guid != bf57d32c-d4dd-4217-8c16-6dcb16975cf3")] |
|||
[InlineData("startswith", "startsWith(guid, bf57d32c-d4dd-4217-8c16-6dcb16975cf3)")] |
|||
public void Should_parse_guid_string_filter(string op, string expected) |
|||
{ |
|||
var json = new { path = "guid", op, value = "bf57d32c-d4dd-4217-8c16-6dcb16975cf3" }; |
|||
|
|||
AssertFilter(json, expected); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_add_error_if_guid_string_property_got_invalid_string_value() |
|||
{ |
|||
var json = new { path = "guid", op = "eq", value = "invalid" }; |
|||
|
|||
AssertErrors(json, "Expected Guid String for path 'guid', but got invalid String."); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_add_error_if_guid_string_property_got_invalid_value() |
|||
{ |
|||
var json = new { path = "guid", op = "eq", value = 1 }; |
|||
|
|||
AssertErrors(json, "Expected Guid String for path 'guid', but got Number."); |
|||
} |
|||
|
|||
[Theory] |
|||
[InlineData("contains", "contains(string, 'Hello')")] |
|||
[InlineData("empty", "empty(string)")] |
|||
[InlineData("endswith", "endsWith(string, 'Hello')")] |
|||
[InlineData("eq", "string == 'Hello'")] |
|||
[InlineData("ge", "string >= 'Hello'")] |
|||
[InlineData("gt", "string > 'Hello'")] |
|||
[InlineData("le", "string <= 'Hello'")] |
|||
[InlineData("lt", "string < 'Hello'")] |
|||
[InlineData("ne", "string != 'Hello'")] |
|||
[InlineData("startswith", "startsWith(string, 'Hello')")] |
|||
public void Should_parse_string_filter(string op, string expected) |
|||
{ |
|||
var json = new { path = "string", op, value = "Hello" }; |
|||
|
|||
AssertFilter(json, expected); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_add_error_if_string_property_got_invalid_value() |
|||
{ |
|||
var json = new { path = "string", op = "eq", value = 1 }; |
|||
|
|||
AssertErrors(json, "Expected String for path 'string', but got Number."); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_parse_string_in_filter() |
|||
{ |
|||
var json = new { path = "string", op = "in", value = new[] { "Hello" } }; |
|||
|
|||
AssertFilter(json, "string in ['Hello']"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_parse_nested_string_filter() |
|||
{ |
|||
var json = new { path = "object.property", op = "in", value = new[] { "Hello" } }; |
|||
|
|||
AssertFilter(json, "object.property in ['Hello']"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_parse_referenced_string_filter() |
|||
{ |
|||
var json = new { path = "reference.property", op = "in", value = new[] { "Hello" } }; |
|||
|
|||
AssertFilter(json, "reference.property in ['Hello']"); |
|||
} |
|||
|
|||
[Theory] |
|||
[InlineData("eq", "number == 12")] |
|||
[InlineData("ge", "number >= 12")] |
|||
[InlineData("gt", "number > 12")] |
|||
[InlineData("le", "number <= 12")] |
|||
[InlineData("lt", "number < 12")] |
|||
[InlineData("ne", "number != 12")] |
|||
public void Should_parse_number_filter(string op, string expected) |
|||
{ |
|||
var json = new { path = "number", op, value = 12 }; |
|||
|
|||
AssertFilter(json, expected); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_add_error_if_number_property_got_invalid_value() |
|||
{ |
|||
var json = new { path = "number", op = "eq", value = true }; |
|||
|
|||
AssertErrors(json, "Expected Number for path 'number', but got Boolean."); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_parse_number_in_filter() |
|||
{ |
|||
var json = new { path = "number", op = "in", value = new[] { 12 } }; |
|||
|
|||
AssertFilter(json, "number in [12]"); |
|||
} |
|||
|
|||
[Theory] |
|||
[InlineData("eq", "boolean == True")] |
|||
[InlineData("ne", "boolean != True")] |
|||
public void Should_parse_boolean_filter(string op, string expected) |
|||
{ |
|||
var json = new { path = "boolean", op, value = true }; |
|||
|
|||
AssertFilter(json, expected); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_add_error_if_boolean_property_got_invalid_value() |
|||
{ |
|||
var json = new { path = "boolean", op = "eq", value = 1 }; |
|||
|
|||
AssertErrors(json, "Expected Boolean for path 'boolean', but got Number."); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_parse_boolean_in_filter() |
|||
{ |
|||
var json = new { path = "boolean", op = "in", value = new[] { true } }; |
|||
|
|||
AssertFilter(json, "boolean in [True]"); |
|||
} |
|||
|
|||
[Theory] |
|||
[InlineData("empty", "empty(stringArray)")] |
|||
[InlineData("eq", "stringArray == 'Hello'")] |
|||
[InlineData("ne", "stringArray != 'Hello'")] |
|||
public void Should_parse_array_filter(string op, string expected) |
|||
{ |
|||
var json = new { path = "stringArray", op, value = "Hello" }; |
|||
|
|||
AssertFilter(json, expected); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_parse_array_in_filter() |
|||
{ |
|||
var json = new { path = "stringArray", op = "in", value = new[] { "Hello" } }; |
|||
|
|||
AssertFilter(json, "stringArray in ['Hello']"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_add_error_when_using_array_value_for_non_allowed_operator() |
|||
{ |
|||
var json = new { path = "string", op = "eq", value = new[] { "Hello" } }; |
|||
|
|||
AssertErrors(json, "Array value is not allowed for 'Equals' operator and path 'string'."); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_parse_query() |
|||
{ |
|||
var json = new { skip = 10, take = 20, FullText = "Hello", Filter = new { path = "string", op = "eq", value = "Hello" } }; |
|||
|
|||
AssertQuery(json, "Filter: string == 'Hello'; FullText: 'Hello'; Skip: 10; Take: 20"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_parse_query_with_sorting() |
|||
{ |
|||
var json = new { sort = new[] { new { path = "string", order = "ascending" } } }; |
|||
|
|||
AssertQuery(json, "Sort: string Ascending"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_throw_exception_for_invalid_query() |
|||
{ |
|||
var json = new { sort = new[] { new { path = "invalid", order = "ascending" } } }; |
|||
|
|||
Assert.Throws<ValidationException>(() => AssertQuery(json, null)); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_throw_exception_when_parsing_invalid_json() |
|||
{ |
|||
var json = "invalid"; |
|||
|
|||
Assert.Throws<ValidationException>(() => AssertQuery(json, null)); |
|||
} |
|||
|
|||
private void AssertQuery(object json, string expectedFilter) |
|||
{ |
|||
var filter = ConvertQuery(json); |
|||
|
|||
Assert.Empty(errors); |
|||
|
|||
Assert.Equal(expectedFilter, filter); |
|||
} |
|||
|
|||
private void AssertFilter(object json, string expectedFilter) |
|||
{ |
|||
var filter = ConvertFilter(json); |
|||
|
|||
Assert.Empty(errors); |
|||
|
|||
Assert.Equal(expectedFilter, filter); |
|||
} |
|||
|
|||
private void AssertErrors(object json, params string[] expectedErrors) |
|||
{ |
|||
var filter = ConvertFilter(json); |
|||
|
|||
Assert.Equal(expectedErrors.ToList(), errors); |
|||
|
|||
Assert.Null(filter); |
|||
} |
|||
|
|||
private string ConvertFilter<T>(T value) |
|||
{ |
|||
var json = JsonHelper.DefaultSerializer.Serialize(value, true); |
|||
|
|||
var jsonFilter = JsonHelper.DefaultSerializer.Deserialize<FilterNode<IJsonValue>>(json); |
|||
|
|||
return JsonFilterVisitor.Parse(jsonFilter, schema, errors)?.ToString(); |
|||
} |
|||
|
|||
private string ConvertQuery<T>(T value) |
|||
{ |
|||
var json = JsonHelper.DefaultSerializer.Serialize(value, true); |
|||
|
|||
var jsonFilter = schema.Parse(json, JsonHelper.DefaultSerializer); |
|||
|
|||
return jsonFilter.ToString(); |
|||
} |
|||
} |
|||
} |
|||
Loading…
Reference in new issue