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