mirror of https://github.com/Squidex/squidex.git
Browse Source
* Temp * Temp * Indexes UI. * UI for indexes finalized. * Revert text * More formatting.pull/1146/head
committed by
GitHub
102 changed files with 5090 additions and 4216 deletions
File diff suppressed because it is too large
@ -0,0 +1,73 @@ |
|||
<form (ngSubmit)="createSchema()"> |
|||
<sqx-modal-dialog (dialogClose)="dialogClose.emit()" size="md"> |
|||
<ng-container title> |
|||
{{ "schemas.indexes.addTitle" | sqxTranslate }} |
|||
</ng-container> |
|||
|
|||
<ng-container content> |
|||
<sqx-form-hint> |
|||
<span inline="true" [sqxMarkdown]="'schemas.indexes.hint' | sqxTranslate" trusted="true"></span> |
|||
</sqx-form-hint> |
|||
|
|||
@for (form of createForm.controls; track form; let i = $index) { |
|||
<div class="form-group row gx-2" attr.data-testid="pattern_{{ form.get('name')?.value }}" [formGroup]="form"> |
|||
<div class="col"> |
|||
<sqx-control-errors for="name"></sqx-control-errors> |
|||
<select class="form-select" formControlName="name"> |
|||
@for (fieldName of fieldNames; track fieldName) { |
|||
<option [ngValue]="fieldName">{{ fieldName }}</option> |
|||
} |
|||
</select> |
|||
</div> |
|||
<div class="col-4"> |
|||
<sqx-control-errors for="order"></sqx-control-errors> |
|||
<select class="form-select" formControlName="order"> |
|||
<option [ngValue]="'Ascending'">Ascending</option> |
|||
<option [ngValue]="'Descending'">Descending</option> |
|||
</select> |
|||
</div> |
|||
<div class="col-auto"> |
|||
<button |
|||
class="btn btn-text-danger" |
|||
attr.aria-label="{{ 'common.delete' | sqxTranslate }}" |
|||
confirmRememberKey="deleteIndexField" |
|||
confirmText="i18n:schemas.indexes.deleteFieldConfirmText" |
|||
confirmTitle="i18n:schemas.indexes.deleteFieldConfirmTitle" |
|||
(sqxConfirmClick)="createForm.form.removeAt(i)" |
|||
type="button"> |
|||
<i class="icon-bin2"></i> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
} |
|||
|
|||
<div class="form-group row gx-2"> |
|||
<div class="col"> |
|||
<div class="form-control preview">{{ "common.name" | sqxTranslate }}</div> |
|||
</div> |
|||
<div class="col-4"> |
|||
<div class="form-control preview">{{ "common.order" | sqxTranslate }}</div> |
|||
</div> |
|||
<div class="col-auto"> |
|||
<button |
|||
class="btn btn-success" |
|||
attr.aria-label="{{ 'common.add' | sqxTranslate }}" |
|||
(click)="createForm.form.add()" |
|||
type="button"> |
|||
<i class="icon-add"></i> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</ng-container> |
|||
|
|||
<ng-container footer> |
|||
<button class="btn btn-text-secondary" (click)="dialogClose.emit()" type="button"> |
|||
{{ "common.cancel" | sqxTranslate }} |
|||
</button> |
|||
|
|||
<button class="btn btn-success" type="submit"> |
|||
{{ "common.create" | sqxTranslate }} |
|||
</button> |
|||
</ng-container> |
|||
</sqx-modal-dialog> |
|||
</form> |
|||
@ -0,0 +1,2 @@ |
|||
@import 'mixins'; |
|||
@import 'vars'; |
|||
@ -0,0 +1,100 @@ |
|||
/* |
|||
* Squidex Headless CMS |
|||
* |
|||
* @license |
|||
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
|||
*/ |
|||
|
|||
import { Component, EventEmitter, Input, Output } from '@angular/core'; |
|||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; |
|||
import { AppLanguageDto, ConfirmClickDirective, ControlErrorsComponent, CreateIndexForm, FormHintComponent, IndexesState, MarkdownDirective, ModalDialogComponent, SchemaDto, TranslatePipe } from '@app/shared'; |
|||
|
|||
|
|||
@Component({ |
|||
standalone: true, |
|||
selector: 'sqx-index-form', |
|||
styleUrls: ['./index-form.component.scss'], |
|||
templateUrl: './index-form.component.html', |
|||
imports: [ |
|||
ConfirmClickDirective, |
|||
ControlErrorsComponent, |
|||
FormHintComponent, |
|||
FormsModule, |
|||
MarkdownDirective, |
|||
ModalDialogComponent, |
|||
ReactiveFormsModule, |
|||
TranslatePipe, |
|||
], |
|||
}) |
|||
export class IndexFormComponent { |
|||
@Output() |
|||
public create = new EventEmitter(); |
|||
|
|||
@Output() |
|||
public dialogClose = new EventEmitter(); |
|||
|
|||
@Input({ required: true }) |
|||
public schema!: SchemaDto; |
|||
|
|||
@Input({ required: true }) |
|||
public languages!: AppLanguageDto[]; |
|||
|
|||
public createForm = new CreateIndexForm(); |
|||
public fieldNames: string[] = []; |
|||
|
|||
constructor( |
|||
private readonly indexesState: IndexesState, |
|||
) { |
|||
} |
|||
|
|||
public ngOnInit() { |
|||
const metaFields: string[] = [ |
|||
'created', |
|||
'createdBy', |
|||
'lastModified', |
|||
'lastModifiedBy', |
|||
'newStatus', |
|||
'status', |
|||
'version', |
|||
]; |
|||
|
|||
const dataFields: string[] = []; |
|||
for (const field of this.schema.fields) { |
|||
if (field.properties.isContentField) { |
|||
if (field.isLocalizable) { |
|||
for (const language of this.languages) { |
|||
dataFields.push(`data.${field.name}.${language.iso2Code}`); |
|||
} |
|||
} else { |
|||
dataFields.push(`data.${field.name}.iv`); |
|||
} |
|||
} |
|||
} |
|||
|
|||
this.fieldNames = [...metaFields, ...dataFields.sort()]; |
|||
} |
|||
|
|||
public emitCreate() { |
|||
this.create.emit(); |
|||
} |
|||
|
|||
public emitClose() { |
|||
this.dialogClose.emit(); |
|||
} |
|||
|
|||
public createSchema() { |
|||
const fields = this.createForm.submit(); |
|||
|
|||
if (fields) { |
|||
this.indexesState.create({ fields }) |
|||
.subscribe({ |
|||
next: () => { |
|||
this.emitCreate(); |
|||
}, |
|||
error: error => { |
|||
this.createForm.submitFailed(error); |
|||
}, |
|||
}); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,57 @@ |
|||
<div class="table-items-row table-items-row-expandable"> |
|||
<div class="table-items-row-summary row gx-2 align-items-center"> |
|||
<div class="col"> |
|||
<span class="truncate">{{ index.name }}</span> |
|||
</div> |
|||
<div class="col-auto"> |
|||
<div class="float-end"> |
|||
<button |
|||
class="btn btn-outline-secondary btn-expand me-1" |
|||
attr.aria-label="{{ 'common.options' | sqxTranslate }}" |
|||
[class.expanded]="isExpanded" |
|||
(click)="toggleExpanded()" |
|||
type="button"> |
|||
<span class="hidden">{{ "common.settings" | sqxTranslate }}</span> |
|||
<i class="icon-settings"></i> |
|||
</button> |
|||
|
|||
<button |
|||
class="btn btn-text-danger" |
|||
attr.aria-label="{{ 'common.delete' | sqxTranslate }}" |
|||
confirmRememberKey="deleteIndex" |
|||
confirmText="i18n:schemas.indexes.deleteConfirmText" |
|||
confirmTitle="i18n:schemas.indexes.deleteConfirmTitle" |
|||
[disabled]="!index.canDelete" |
|||
(sqxConfirmClick)="delete()" |
|||
type="button"> |
|||
<i class="icon-bin2"></i> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
@if (isExpanded) { |
|||
<div class="table-items-row-details"> |
|||
<table class="table table-sm table-fixed mb-0"> |
|||
<colgroup> |
|||
<col /> |
|||
<col style="width: 300px" /> |
|||
</colgroup> |
|||
<thead> |
|||
<tr> |
|||
<th>{{ "common.field" | sqxTranslate }}</th> |
|||
<th>{{ "common.order" | sqxTranslate }}</th> |
|||
</tr> |
|||
</thead> |
|||
<tbody> |
|||
@for (field of index.fields; track field.name) { |
|||
<tr> |
|||
<td>{{ field.name }}</td> |
|||
<td>{{ field.order }}</td> |
|||
</tr> |
|||
} |
|||
</tbody> |
|||
</table> |
|||
</div> |
|||
} |
|||
</div> |
|||
@ -0,0 +1,12 @@ |
|||
@import 'mixins'; |
|||
@import 'vars'; |
|||
|
|||
th { |
|||
padding-left: 1rem !important; |
|||
padding-right: 1rem; |
|||
background: $color-border-light !important; |
|||
} |
|||
|
|||
td { |
|||
padding-left: 1rem; |
|||
} |
|||
@ -0,0 +1,39 @@ |
|||
/* |
|||
* Squidex Headless CMS |
|||
* |
|||
* @license |
|||
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
|||
*/ |
|||
|
|||
import { Component, Input } from '@angular/core'; |
|||
import { ConfirmClickDirective, IndexDto, IndexesState, TranslatePipe } from '@app/shared'; |
|||
|
|||
@Component({ |
|||
standalone: true, |
|||
selector: 'sqx-index', |
|||
styleUrls: ['./index.component.scss'], |
|||
templateUrl: './index.component.html', |
|||
imports: [ |
|||
ConfirmClickDirective, |
|||
TranslatePipe, |
|||
], |
|||
}) |
|||
export class IndexComponent { |
|||
@Input({ required: true }) |
|||
public index!: IndexDto; |
|||
|
|||
public isExpanded?: boolean | null; |
|||
|
|||
constructor( |
|||
private readonly indexesState: IndexesState, |
|||
) { |
|||
} |
|||
|
|||
public toggleExpanded() { |
|||
this.isExpanded = !this.isExpanded; |
|||
} |
|||
|
|||
public delete() { |
|||
this.indexesState.delete(this.index); |
|||
} |
|||
} |
|||
@ -0,0 +1,55 @@ |
|||
<sqx-list-view innerWidth="50rem"> |
|||
@if (canCreateIndexes) { |
|||
<div class="mt-2"> |
|||
@if ((indexesState.isLoaded | async) && (indexesState.indexes | async); as indexes) { |
|||
@if (indexes.length === 0) { |
|||
<div class="table-items-row table-items-row-summary table-items-row-empty"> |
|||
{{ "schemas.indexes.empty" | sqxTranslate }} |
|||
|
|||
<button class="btn btn-success btn-sm ms-2" (click)="addIndexDialog.show()" sqxTourStep="addField" type="button"> |
|||
<i class="icon icon-plus"></i> |
|||
{{ "schemas.indexes.addIndex" | sqxTranslate }} |
|||
</button> |
|||
</div> |
|||
} |
|||
@for (index of indexes; track index.name) { |
|||
<sqx-index [index]="index"></sqx-index> |
|||
} |
|||
} |
|||
</div> |
|||
} @else { |
|||
<div class="table-items-row table-items-row-summary table-items-row-empty text-sm"> |
|||
{{ "schemas.indexes.empty" | sqxTranslate }} |
|||
|
|||
<div class="section"> |
|||
<span [sqxMarkdown]="'schemas.indexes.notEnableHint1' | sqxTranslate" trusted="true"></span> |
|||
|
|||
<sqx-code>CONTENTS__OPTIMIZEFORSELFHOSTING=true</sqx-code> |
|||
</div> |
|||
|
|||
<div class="section"> |
|||
<span [sqxMarkdown]="'schemas.indexes.notEnableHint2' | sqxTranslate" trusted="true"></span> |
|||
|
|||
<sqx-code>REBUILD__CONTENTS=true</sqx-code> |
|||
</div> |
|||
</div> |
|||
} |
|||
</sqx-list-view> |
|||
|
|||
@if (canCreateIndexes) { |
|||
@if (indexesState.canCreate | async) { |
|||
<button class="btn btn-success index-button" (click)="addIndexDialog.show()" type="button"> |
|||
<i class="icon icon-plus index-button-icon"></i> |
|||
<div class="index-button-text">{{ "schemas.indexes.addIndexButton" | sqxTranslate }}</div> |
|||
</button> |
|||
} |
|||
|
|||
@if (languagesState.isoLanguages | async; as languages) { |
|||
<sqx-index-form |
|||
(create)="addIndexDialog.hide()" |
|||
(dialogClose)="addIndexDialog.hide()" |
|||
[languages]="languages" |
|||
[schema]="schema" |
|||
*sqxModal="addIndexDialog"></sqx-index-form> |
|||
} |
|||
} |
|||
@ -0,0 +1,22 @@ |
|||
@import 'mixins'; |
|||
@import 'vars'; |
|||
|
|||
.index-button { |
|||
@include circle(5.25rem); |
|||
@include box-shadow-outer(0, 8px, 16px, .3); |
|||
@include absolute(auto, 6rem, 1rem, auto); |
|||
|
|||
&-icon { |
|||
font-weight: bold; |
|||
} |
|||
|
|||
&-text { |
|||
font-size: $font-small; |
|||
} |
|||
} |
|||
|
|||
.section { |
|||
font-size: $font-small; |
|||
font-weight: normal; |
|||
margin-top: 1rem; |
|||
} |
|||
@ -0,0 +1,57 @@ |
|||
/* |
|||
* Squidex Headless CMS |
|||
* |
|||
* @license |
|||
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
|||
*/ |
|||
|
|||
|
|||
import { AsyncPipe } from '@angular/common'; |
|||
import { Component, inject, Input, OnInit } from '@angular/core'; |
|||
import { switchMap, timer } from 'rxjs'; |
|||
import { CodeComponent, DialogModel, IndexesState, LanguagesState, ListViewComponent, MarkdownDirective, ModalDirective, SchemaDto, Subscriptions, TranslatePipe, UIOptions } from '@app/shared'; |
|||
import { IndexFormComponent } from './index-form.component'; |
|||
import { IndexComponent } from './index.component'; |
|||
|
|||
@Component({ |
|||
standalone: true, |
|||
selector: 'sqx-schema-indexes', |
|||
styleUrls: ['./schema-indexes.component.scss'], |
|||
templateUrl: './schema-indexes.component.html', |
|||
imports: [ |
|||
AsyncPipe, |
|||
CodeComponent, |
|||
IndexFormComponent, |
|||
IndexComponent, |
|||
MarkdownDirective, |
|||
ModalDirective, |
|||
ListViewComponent, |
|||
TranslatePipe, |
|||
], |
|||
}) |
|||
export class SchemaIndexesComponent implements OnInit { |
|||
private readonly subscriptions = new Subscriptions(); |
|||
|
|||
public readonly canCreateIndexes = inject(UIOptions).value.canCreateIndexes; |
|||
|
|||
@Input({ required: true }) |
|||
public schema!: SchemaDto; |
|||
|
|||
public addIndexDialog = new DialogModel(); |
|||
|
|||
constructor( |
|||
public readonly indexesState: IndexesState, |
|||
public readonly languagesState: LanguagesState, |
|||
) { |
|||
} |
|||
|
|||
public ngOnInit() { |
|||
this.indexesState.load(); |
|||
|
|||
this.subscriptions.add( |
|||
timer(3000, 3000).pipe( |
|||
switchMap(() => this.indexesState.load(false, true)))); |
|||
|
|||
this.languagesState.load(); |
|||
} |
|||
} |
|||
@ -0,0 +1,116 @@ |
|||
/* |
|||
* Squidex Headless CMS |
|||
* |
|||
* @license |
|||
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
|||
*/ |
|||
|
|||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; |
|||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; |
|||
import { inject, TestBed } from '@angular/core/testing'; |
|||
import { ApiUrlConfig, IndexDto, IndexesDto, IndexesService, Resource, ResourceLinks } from '@app/shared/internal'; |
|||
|
|||
describe('IndexesService', () => { |
|||
beforeEach(() => { |
|||
TestBed.configureTestingModule({ |
|||
imports: [], |
|||
providers: [ |
|||
provideHttpClient(withInterceptorsFromDi()), |
|||
provideHttpClientTesting(), |
|||
IndexesService, |
|||
{ provide: ApiUrlConfig, useValue: new ApiUrlConfig('http://service/p/') }, |
|||
], |
|||
}); |
|||
}); |
|||
|
|||
afterEach(inject([HttpTestingController], (httpMock: HttpTestingController) => { |
|||
httpMock.verify(); |
|||
})); |
|||
|
|||
it('should make get request to get indexes', |
|||
inject([IndexesService, HttpTestingController], (indexesService: IndexesService, httpMock: HttpTestingController) => { |
|||
let indexes: IndexesDto; |
|||
|
|||
indexesService.getIndexes('my-app', 'my-schema').subscribe(result => { |
|||
indexes = result; |
|||
}); |
|||
|
|||
const req = httpMock.expectOne('http://service/p/api/apps/my-app/schemas/my-schema/indexes'); |
|||
|
|||
expect(req.request.method).toEqual('GET'); |
|||
expect(req.request.headers.get('If-Match')).toBeNull(); |
|||
|
|||
req.flush({ |
|||
items: [ |
|||
indexResponse(12), |
|||
indexResponse(13), |
|||
], |
|||
}); |
|||
|
|||
expect(indexes!).toEqual({ |
|||
items: [ |
|||
createIndex(12), |
|||
createIndex(13), |
|||
], |
|||
canCreate: false, |
|||
}); |
|||
})); |
|||
|
|||
it('should make post request to create index', |
|||
inject([IndexesService, HttpTestingController], (indexesService: IndexesService, httpMock: HttpTestingController) => { |
|||
const request = { fields: [] }; |
|||
|
|||
indexesService.postIndex('my-app', 'my-schema', request).subscribe(); |
|||
|
|||
const req = httpMock.expectOne('http://service/p/api/apps/my-app/schemas/my-schema/indexes'); |
|||
|
|||
expect(req.request.method).toEqual('POST'); |
|||
expect(req.request.headers.get('If-Match')).toBeNull(); |
|||
|
|||
req.flush({}); |
|||
})); |
|||
|
|||
it('should make delete request to remove index', |
|||
inject([IndexesService, HttpTestingController], (indexesService: IndexesService, httpMock: HttpTestingController) => { |
|||
const resource: Resource = { |
|||
_links: { |
|||
delete: { method: 'DELETE', href: '/api/apps/my-app/schemas/my-schema/indexes/my-index' }, |
|||
}, |
|||
}; |
|||
|
|||
indexesService.deleteIndex('my-app', resource).subscribe(); |
|||
|
|||
const req = httpMock.expectOne('http://service/p/api/apps/my-app/schemas/my-schema/indexes/my-index'); |
|||
|
|||
expect(req.request.method).toEqual('DELETE'); |
|||
expect(req.request.headers.get('If-Match')).toBeNull(); |
|||
|
|||
req.flush({}); |
|||
})); |
|||
|
|||
function indexResponse(id: number) { |
|||
return { |
|||
name: `index${id}`, |
|||
fields: [ |
|||
{ name: `field${id}_asc`, order: 'Ascending' }, |
|||
{ name: `field${id}_desc`, order: 'Descending' }, |
|||
], |
|||
_links: { |
|||
download: { method: 'GET', href: '/api/indexes/1' }, |
|||
}, |
|||
}; |
|||
} |
|||
}); |
|||
|
|||
export function createIndex(id: number) { |
|||
const links: ResourceLinks = { |
|||
download: { method: 'GET', href: '/api/indexes/1' }, |
|||
}; |
|||
|
|||
return new IndexDto(links, |
|||
`index${id}`, |
|||
[ |
|||
{ name: `field${id}_asc`, order: 'Ascending' }, |
|||
{ name: `field${id}_desc`, order: 'Descending' }, |
|||
]); |
|||
} |
|||
@ -0,0 +1,93 @@ |
|||
/* |
|||
* Squidex Headless CMS |
|||
* |
|||
* @license |
|||
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
|||
*/ |
|||
|
|||
import { HttpClient } from '@angular/common/http'; |
|||
import { Injectable } from '@angular/core'; |
|||
import { map, Observable } from 'rxjs'; |
|||
import { ApiUrlConfig, hasAnyLink, pretifyError, Resource, ResourceLinks } from '@app/framework'; |
|||
|
|||
export type IndexField = { name: string; order: 'Ascending' | 'Descending' }; |
|||
|
|||
export class IndexDto { |
|||
public readonly _links: ResourceLinks; |
|||
|
|||
public readonly canDelete: boolean; |
|||
|
|||
constructor(links: ResourceLinks, |
|||
public readonly name: string, |
|||
public readonly fields: ReadonlyArray<IndexField>, |
|||
) { |
|||
this._links = links; |
|||
|
|||
this.canDelete = hasAnyLink(links, 'delete'); |
|||
} |
|||
} |
|||
|
|||
export type IndexesDto = Readonly<{ |
|||
// The indexes.
|
|||
items: ReadonlyArray<IndexDto>; |
|||
|
|||
// The if the user can create a new index.
|
|||
canCreate?: boolean; |
|||
}>; |
|||
|
|||
export type CreateIndexDto = Readonly<{ |
|||
// The index fields.
|
|||
fields: IndexField[]; |
|||
}>; |
|||
|
|||
@Injectable({ |
|||
providedIn: 'root', |
|||
}) |
|||
export class IndexesService { |
|||
constructor( |
|||
private readonly http: HttpClient, |
|||
private readonly apiUrl: ApiUrlConfig, |
|||
) { |
|||
} |
|||
|
|||
public getIndexes(appName: string, schemaName: string): Observable<IndexesDto> { |
|||
const url = this.apiUrl.buildUrl(`api/apps/${appName}/schemas/${schemaName}/indexes`); |
|||
|
|||
return this.http.get(url).pipe( |
|||
map(body => { |
|||
return parseIndexes(body as any); |
|||
}), |
|||
pretifyError('i18n:schemas.indexes.loadFailed')); |
|||
} |
|||
|
|||
public postIndex(appName: string, schemaName: string, dto: CreateIndexDto): Observable<any> { |
|||
const url = this.apiUrl.buildUrl(`api/apps/${appName}/schemas/${schemaName}/indexes`); |
|||
|
|||
return this.http.post(url, dto).pipe( |
|||
pretifyError('i18n:schemas.indexes.createFailed')); |
|||
} |
|||
|
|||
public deleteIndex(appName: string, resource: Resource): Observable<any> { |
|||
const link = resource._links['delete']; |
|||
|
|||
const url = this.apiUrl.buildUrl(link.href); |
|||
|
|||
return this.http.request(link.method, url).pipe( |
|||
pretifyError('i18n:schemas.indexes.deleteFailed')); |
|||
} |
|||
} |
|||
|
|||
function parseIndexes(response: { items: any[] } & Resource): IndexesDto { |
|||
const { items: list, _links } = response; |
|||
const items = list.map(parseIndex); |
|||
|
|||
const canCreate = hasAnyLink(_links, 'create'); |
|||
|
|||
return { items, canCreate }; |
|||
} |
|||
|
|||
function parseIndex(response: any) { |
|||
return new IndexDto(response._links, |
|||
response.name, |
|||
response.fields); |
|||
} |
|||
@ -0,0 +1,36 @@ |
|||
/* |
|||
* Squidex Headless CMS |
|||
* |
|||
* @license |
|||
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
|||
*/ |
|||
|
|||
|
|||
import { UntypedFormControl, Validators } from '@angular/forms'; |
|||
import { ExtendedFormGroup, Form, TemplatedFormArray } from '@app/framework'; |
|||
import { IndexDto, IndexField } from '@app/shared/internal'; |
|||
|
|||
export class CreateIndexForm extends Form<TemplatedFormArray, IndexField[], IndexDto> { |
|||
public get controls(): ReadonlyArray<ExtendedFormGroup> { |
|||
return this.form.controls as any; |
|||
} |
|||
|
|||
constructor() { |
|||
super(new TemplatedFormArray(FieldTemplate.INSTANCE)); |
|||
} |
|||
} |
|||
|
|||
class FieldTemplate { |
|||
public static readonly INSTANCE = new FieldTemplate(); |
|||
|
|||
public createControl() { |
|||
return new ExtendedFormGroup({ |
|||
name: new UntypedFormControl('', |
|||
Validators.required, |
|||
), |
|||
order: new UntypedFormControl('', |
|||
Validators.required, |
|||
), |
|||
}); |
|||
} |
|||
} |
|||
@ -0,0 +1,132 @@ |
|||
/* |
|||
* Squidex Headless CMS |
|||
* |
|||
* @license |
|||
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
|||
*/ |
|||
|
|||
import { of, onErrorResumeNextWith, throwError } from 'rxjs'; |
|||
import { IMock, It, Mock, Times } from 'typemoq'; |
|||
import { CreateIndexDto, DialogService, IndexesService, IndexesState, SchemasState } from '@app/shared/internal'; |
|||
import { createIndex } from '../services/indexes.service.spec'; |
|||
import { TestValues } from './_test-helpers'; |
|||
|
|||
describe('IndexesState', () => { |
|||
const { |
|||
app, |
|||
appsState, |
|||
} = TestValues; |
|||
|
|||
const index1 = createIndex(12); |
|||
const index2 = createIndex(13); |
|||
const schema = 'my-schema'; |
|||
|
|||
let dialogs: IMock<DialogService>; |
|||
let schemasState: IMock<SchemasState>; |
|||
let indexesService: IMock<IndexesService>; |
|||
let indexesState: IndexesState; |
|||
|
|||
beforeEach(() => { |
|||
dialogs = Mock.ofType<DialogService>(); |
|||
|
|||
schemasState = Mock.ofType<SchemasState>(); |
|||
schemasState.setup(x => x.schemaName).returns(() => schema); |
|||
|
|||
indexesService = Mock.ofType<IndexesService>(); |
|||
indexesState = new IndexesState(appsState.object, schemasState.object, indexesService.object, dialogs.object); |
|||
}); |
|||
|
|||
afterEach(() => { |
|||
indexesService.verifyAll(); |
|||
}); |
|||
|
|||
describe('Loading', () => { |
|||
it('should load indexes', () => { |
|||
indexesService.setup(x => x.getIndexes(app, schema)) |
|||
.returns(() => of({ items: [index1, index2] } as any)).verifiable(); |
|||
|
|||
indexesState.load().subscribe(); |
|||
|
|||
expect(indexesState.snapshot.indexes).toEqual([index1, index2]); |
|||
expect(indexesState.snapshot.isLoaded).toBeTruthy(); |
|||
expect(indexesState.snapshot.isLoading).toBeFalsy(); |
|||
|
|||
dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never()); |
|||
}); |
|||
|
|||
it('should reset loading state if loading failed', () => { |
|||
indexesService.setup(x => x.getIndexes(app, schema)) |
|||
.returns(() => throwError(() => 'Service Error')); |
|||
|
|||
indexesState.load().pipe(onErrorResumeNextWith()).subscribe(); |
|||
|
|||
expect(indexesState.snapshot.isLoading).toBeFalsy(); |
|||
}); |
|||
|
|||
it('should show notification on load if reload is true', () => { |
|||
indexesService.setup(x => x.getIndexes(app, schema)) |
|||
.returns(() => of({ items: [index1, index2] } as any)).verifiable(); |
|||
|
|||
indexesState.load(true, false).subscribe(); |
|||
|
|||
expect().nothing(); |
|||
|
|||
dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.once()); |
|||
}); |
|||
|
|||
it('should show notification on load error if silent is false', () => { |
|||
indexesService.setup(x => x.getIndexes(app, schema)) |
|||
.returns(() => throwError(() => 'Service Error')); |
|||
|
|||
indexesState.load(true, false).pipe(onErrorResumeNextWith()).subscribe(); |
|||
|
|||
expect().nothing(); |
|||
|
|||
dialogs.verify(x => x.notifyError(It.isAny()), Times.once()); |
|||
}); |
|||
|
|||
it('should not show notification on load error if silent is true', () => { |
|||
indexesService.setup(x => x.getIndexes(app, schema)) |
|||
.returns(() => throwError(() => 'Service Error')); |
|||
|
|||
indexesState.load(true, true).pipe(onErrorResumeNextWith()).subscribe(); |
|||
|
|||
expect().nothing(); |
|||
|
|||
dialogs.verify(x => x.notifyError(It.isAny()), Times.never()); |
|||
}); |
|||
}); |
|||
|
|||
describe('Updates', () => { |
|||
beforeEach(() => { |
|||
indexesService.setup(x => x.getIndexes(app, schema)) |
|||
.returns(() => of({ items: [index1, index2] } as any)).verifiable(); |
|||
|
|||
indexesState.load().subscribe(); |
|||
}); |
|||
|
|||
it('should not add index to snapshot', () => { |
|||
const request: CreateIndexDto = { fields: [{ name: 'field1', order: 'Ascending' }] }; |
|||
|
|||
indexesService.setup(x => x.postIndex(app, schema, request)) |
|||
.returns(() => of({})).verifiable(); |
|||
|
|||
indexesState.create(request).subscribe(); |
|||
|
|||
expect(indexesState.snapshot.indexes.length).toBe(2); |
|||
|
|||
dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.once()); |
|||
}); |
|||
|
|||
it('should not remove index from snapshot', () => { |
|||
indexesService.setup(x => x.deleteIndex(app, index1)) |
|||
.returns(() => of({})).verifiable(); |
|||
|
|||
indexesState.delete(index1).subscribe(); |
|||
|
|||
expect(indexesState.snapshot.indexes.length).toBe(2); |
|||
|
|||
dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.once()); |
|||
}); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,114 @@ |
|||
/* |
|||
* Squidex Headless CMS |
|||
* |
|||
* @license |
|||
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
|||
*/ |
|||
|
|||
import { Injectable } from '@angular/core'; |
|||
import { Observable } from 'rxjs'; |
|||
import { finalize, tap } from 'rxjs/operators'; |
|||
import { debug, DialogService, LoadingState, shareSubscribed, State } from '@app/framework'; |
|||
import { CreateIndexDto, IndexDto, IndexesService } from '../services/indexes.service'; |
|||
import { AppsState } from './apps.state'; |
|||
import { SchemasState } from './schemas.state'; |
|||
|
|||
interface Snapshot extends LoadingState { |
|||
// The current indexes.
|
|||
indexes: ReadonlyArray<IndexDto>; |
|||
|
|||
// Indicates if the user can add an index.
|
|||
canCreate?: boolean; |
|||
} |
|||
|
|||
@Injectable({ |
|||
providedIn: 'root', |
|||
}) |
|||
export class IndexesState extends State<Snapshot> { |
|||
public indexes = |
|||
this.project(x => x.indexes); |
|||
|
|||
public isLoaded = |
|||
this.project(x => x.isLoaded === true); |
|||
|
|||
public isLoading = |
|||
this.project(x => x.isLoading === true); |
|||
|
|||
public canCreate = |
|||
this.project(x => x.canCreate === true); |
|||
|
|||
public get appId() { |
|||
return this.appsState.appId; |
|||
} |
|||
|
|||
public get appName() { |
|||
return this.appsState.appName; |
|||
} |
|||
|
|||
public get schemaId() { |
|||
return this.schemasState.schemaId; |
|||
} |
|||
|
|||
public get schemaName() { |
|||
return this.schemasState.schemaName; |
|||
} |
|||
|
|||
constructor( |
|||
private readonly appsState: AppsState, |
|||
private readonly schemasState: SchemasState, |
|||
private readonly indexesService: IndexesService, |
|||
private readonly dialogs: DialogService, |
|||
) { |
|||
super({ indexes: [] }); |
|||
|
|||
debug(this, 'indexes'); |
|||
} |
|||
|
|||
public load(isReload = false, silent = false): Observable<any> { |
|||
if (isReload && !silent) { |
|||
this.resetState('Loading Initial'); |
|||
} |
|||
|
|||
return this.loadInternal(isReload, silent); |
|||
} |
|||
|
|||
private loadInternal(isReload: boolean, silent: boolean): Observable<any> { |
|||
this.next({ isLoading: true }, 'Loading Success'); |
|||
|
|||
return this.indexesService.getIndexes(this.appName, this.schemasState.schemaName).pipe( |
|||
tap(payload => { |
|||
if (isReload && !silent) { |
|||
this.dialogs.notifyInfo('i18n:schemas.indexes.reloaded'); |
|||
} |
|||
|
|||
const { canCreate, items: indexes } = payload; |
|||
|
|||
this.next({ |
|||
canCreate, |
|||
isLoaded: true, |
|||
isLoading: false, |
|||
indexes, |
|||
}, 'Loading Success / Updated'); |
|||
}), |
|||
finalize(() => { |
|||
this.next({ isLoading: false }, 'Loading Done'); |
|||
}), |
|||
shareSubscribed(this.dialogs, { silent })); |
|||
} |
|||
|
|||
public create(request: CreateIndexDto): Observable<any> { |
|||
return this.indexesService.postIndex(this.appName, this.schemaName, request).pipe( |
|||
tap(() => { |
|||
this.dialogs.notifyInfo('i18n:schemas.indexes.created'); |
|||
}), |
|||
shareSubscribed(this.dialogs)); |
|||
} |
|||
|
|||
public delete(index: IndexDto): Observable<any> { |
|||
return this.indexesService.deleteIndex(this.appName, index).pipe( |
|||
tap(() => { |
|||
this.dialogs.notifyInfo('i18n:schemas.indexes.deleted'); |
|||
}), |
|||
shareSubscribed(this.dialogs)); |
|||
} |
|||
} |
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue