/* * Squidex Headless CMS * * @license * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ // tslint:disable: readonly-array import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { AnalyticsService, ApiUrlConfig, DateTime, hasAnyLink, HTTP, pretifyError, Resource, ResourceLinks, StringHelper, Types, Version, Versioned } from '@app/framework'; import { Observable } from 'rxjs'; import { map, tap } from 'rxjs/operators'; import { createProperties, FieldPropertiesDto } from './schemas.types'; export type SchemasDto = { readonly items: ReadonlyArray; readonly canCreate: boolean; } & Resource; type FieldNames = ReadonlyArray; export const MetaFields = { id: 'meta.id', created: 'meta.created', createdByAvatar: 'meta.createdBy.avatar', createdByName: 'meta.createdBy.name', lastModified: 'meta.lastModified', lastModifiedByAvatar: 'meta.lastModifiedBy.avatar', lastModifiedByName: 'meta.lastModifiedBy.name', status: 'meta.status', statusColor: 'meta.status.color', statusNext: 'meta.status.next', version: 'meta.version' }; export class SchemaDto { public readonly _links: ResourceLinks; public readonly canAddField: boolean; public readonly canContentsRead: boolean; public readonly canContentsCreate: boolean; public readonly canContentsCreateAndPublish: boolean; public readonly canDelete: boolean; public readonly canOrderFields: boolean; public readonly canPublish: boolean; public readonly canReadContents: boolean; public readonly canSynchronize: boolean; public readonly canUnpublish: boolean; public readonly canUpdate: boolean; public readonly canUpdateCategory: boolean; public readonly canUpdateScripts: boolean; public readonly canUpdateUIFields: boolean; public readonly canUpdateUrls: boolean; public readonly canUpdateRules: boolean; public readonly displayName: string; constructor(links: ResourceLinks, public readonly id: string, public readonly name: string, public readonly category: string, public readonly properties: SchemaPropertiesDto, public readonly isSingleton: boolean, public readonly isPublished: boolean, public readonly created: DateTime, public readonly createdBy: string, public readonly lastModified: DateTime, public readonly lastModifiedBy: string, public readonly version: Version ) { this._links = links; this.canAddField = hasAnyLink(links, 'fields/add'); this.canContentsRead = hasAnyLink(links, 'contents'); this.canContentsCreate = hasAnyLink(links, 'contents/create'); this.canContentsCreateAndPublish = hasAnyLink(links, 'contents/create/publish'); this.canDelete = hasAnyLink(links, 'delete'); this.canOrderFields = hasAnyLink(links, 'fields/order'); this.canPublish = hasAnyLink(links, 'publish'); this.canReadContents = hasAnyLink(links, 'contents'); this.canSynchronize = hasAnyLink(this, 'update/sync'); this.canUnpublish = hasAnyLink(links, 'unpublish'); this.canUpdate = hasAnyLink(links, 'update'); this.canUpdateCategory = hasAnyLink(links, 'update/category'); this.canUpdateScripts = hasAnyLink(links, 'update/scripts'); this.canUpdateUIFields = hasAnyLink(links, 'fields/ui'); this.canUpdateUrls = hasAnyLink(links, 'update/urls'); this.canUpdateRules = hasAnyLink(links, 'update/rules'); this.displayName = StringHelper.firstNonEmpty(this.properties.label, this.name); } } export type TableField = RootFieldDto | string; export type FieldRuleAction = 'Disable' | 'Hide' | 'Require'; export type FieldRule = { field: string, action: FieldRuleAction, condition: string }; export const FIELD_RULE_ACTIONS: ReadonlyArray = [ 'Disable', 'Hide', 'Require' ]; export class SchemaDetailsDto extends SchemaDto { public readonly contentFields: ReadonlyArray; public readonly defaultListFields: ReadonlyArray; public readonly defaultReferenceFields: ReadonlyArray; constructor(links: ResourceLinks, id: string, name: string, category: string, properties: SchemaPropertiesDto, isSingleton: boolean, isPublished: boolean, created: DateTime, createdBy: string, lastModified: DateTime, lastModifiedBy: string, version: Version, public readonly fields: ReadonlyArray = [], public readonly fieldsInLists: FieldNames = [], public readonly fieldsInReferences: FieldNames = [], public readonly fieldRules: ReadonlyArray = [], public readonly scripts = {}, public readonly previewUrls = {} ) { super(links, id, name, category, properties, isSingleton, isPublished, created, createdBy, lastModified, lastModifiedBy, version); if (fields) { this.contentFields = fields.filter(x => x.properties.isContentField); const listFields = findFields(fieldsInLists, this.contentFields); if (listFields.length === 0) { listFields.push(MetaFields.lastModifiedByAvatar); if (fields.length > 0) { listFields.push(this.fields[0]); } else { listFields.push(''); } listFields.push(MetaFields.statusColor); listFields.push(MetaFields.lastModified); } this.defaultListFields = listFields; this.defaultReferenceFields = findFields(fieldsInReferences, this.contentFields); if (this.defaultReferenceFields.length === 0) { if (fields.length > 0) { this.defaultReferenceFields = [fields[0]]; } else { this.defaultReferenceFields = ['']; } } } } public export(): any { const fieldKeys = [ 'fieldId', 'parentId', 'parentFieldId', '_links' ]; const cleanup = (source: any, ...exclude: string[]): any => { const clone = {}; for (const key in source) { if (source.hasOwnProperty(key) && exclude.indexOf(key) < 0 && key.indexOf('can') !== 0) { const value = source[key]; if (value) { clone[key] = value; } } } return clone; }; const result: any = { properties: cleanup(this.properties), category: this.category, scripts: this.scripts, fieldsInLists: this.fieldsInLists, fieldsInReferences: this.fieldsInReferences, fields: this.fields.map(field => { const copy = cleanup(field, ...fieldKeys); copy.properties = cleanup(field.properties); if (Types.isArray(copy.nested)) { if (copy.nested.length === 0) { delete copy['nested']; } else { copy.nested = field.nested.map(nestedField => { const nestedCopy = cleanup(nestedField, ...fieldKeys); nestedCopy.properties = cleanup(nestedField.properties); return nestedCopy; }); } } return copy; }), isPublished: this.isPublished }; return result; } } function findFields(names: ReadonlyArray, fields: ReadonlyArray): TableField[] { const result: TableField[] = []; for (const name of names) { if (name.startsWith('meta.')) { result.push(name); } else { const field = fields.find(x => x.name === name); if (field) { result.push(field); } } } return result; } export class FieldDto { public readonly _links: ResourceLinks; public readonly canAddField: boolean; public readonly canDelete: boolean; public readonly canDisable: boolean; public readonly canEnable: boolean; public readonly canHide: boolean; public readonly canLock: boolean; public readonly canOrderFields: boolean; public readonly canShow: boolean; public readonly canUpdate: boolean; public get isInlineEditable(): boolean { return !this.isDisabled && this.rawProperties.inlineEditable === true; } public get displayName() { return StringHelper.firstNonEmpty(this.properties.label, this.name); } public get displayPlaceholder() { return this.properties.placeholder || ''; } public get rawProperties(): any { return this.properties; } constructor(links: ResourceLinks, public readonly fieldId: number, public readonly name: string, public readonly properties: FieldPropertiesDto, public readonly isLocked: boolean = false, public readonly isHidden: boolean = false, public readonly isDisabled: boolean = false ) { this._links = links; this.canAddField = hasAnyLink(links, 'fields/add'); this.canDelete = hasAnyLink(links, 'delete'); this.canDisable = hasAnyLink(links, 'disable'); this.canEnable = hasAnyLink(links, 'enable'); this.canOrderFields = hasAnyLink(links, 'fields/order'); this.canHide = hasAnyLink(links, 'hide'); this.canLock = hasAnyLink(links, 'lock'); this.canShow = hasAnyLink(links, 'show'); this.canUpdate = hasAnyLink(links, 'update'); } } export class RootFieldDto extends FieldDto { public get isLocalizable() { return this.partitioning === 'language'; } public get isArray() { return this.properties.fieldType === 'Array'; } constructor(links: ResourceLinks, fieldId: number, name: string, properties: FieldPropertiesDto, public readonly partitioning: string, isLocked: boolean = false, isHidden: boolean = false, isDisabled: boolean = false, public readonly nested: ReadonlyArray = [] ) { super(links, fieldId, name, properties, isLocked, isHidden, isDisabled); } } export class NestedFieldDto extends FieldDto { constructor(links: ResourceLinks, fieldId: number, name: string, properties: FieldPropertiesDto, public readonly parentId: number, isLocked: boolean = false, isHidden: boolean = false, isDisabled: boolean = false ) { super(links, fieldId, name, properties, isLocked, isHidden, isDisabled); } } export class SchemaPropertiesDto { constructor( public readonly label?: string, public readonly hints?: string, public readonly contentsSidebarUrl?: string, public readonly contentSidebarUrl?: string, public readonly validateOnPublish?: boolean, public readonly tags?: ReadonlyArray ) { } } export interface AddFieldDto { readonly name: string; readonly partitioning?: string; readonly properties: FieldPropertiesDto; } export interface UpdateUIFields { readonly fieldsInLists?: FieldNames; readonly fieldsInReferences?: FieldNames; } export interface CreateSchemaDto { readonly name: string; readonly fields?: ReadonlyArray; readonly properties?: SchemaPropertiesDto; readonly isSingleton?: boolean; } export interface UpdateSchemaCategoryDto { readonly name?: string; } export interface UpdateFieldDto { readonly properties: FieldPropertiesDto; } export interface SynchronizeSchemaDto { readonly noFieldDeletiong?: boolean; readonly noFieldRecreation?: boolean; } export interface UpdateSchemaDto { readonly label?: string; readonly hints?: string; readonly contentsSidebarUrl?: string; readonly contentSidebarUrl?: string; readonly validateOnPublish?: boolean; readonly tags?: ReadonlyArray; } @Injectable() export class SchemasService { constructor( private readonly http: HttpClient, private readonly apiUrl: ApiUrlConfig, private readonly analytics: AnalyticsService ) { } public getSchemas(appName: string): Observable { const url = this.apiUrl.buildUrl(`api/apps/${appName}/schemas`); return HTTP.getVersioned(this.http, url).pipe( map(({ payload }) => { return parseSchemas(payload.body); }), pretifyError('i18n:schemas.loadFailed')); } public getSchema(appName: string, name: string): Observable { const url = this.apiUrl.buildUrl(`api/apps/${appName}/schemas/${name}`); return HTTP.getVersioned(this.http, url).pipe( map(({ payload }) => { return parseSchemaWithDetails(payload.body); }), pretifyError('i18n:schemas.loadSchemaFailed')); } public postSchema(appName: string, dto: CreateSchemaDto): Observable { const url = this.apiUrl.buildUrl(`api/apps/${appName}/schemas`); return HTTP.postVersioned(this.http, url, dto).pipe( map(({ payload }) => { return parseSchemaWithDetails(payload.body); }), tap(() => { this.analytics.trackEvent('Schema', 'Created', appName); }), pretifyError('i18n:schemas.createFailed')); } public putScripts(appName: string, resource: Resource, dto: {}, version: Version): Observable { const link = resource._links['update/scripts']; const url = this.apiUrl.buildUrl(link.href); return HTTP.requestVersioned(this.http, link.method, url, version, dto).pipe( map(({ payload }) => { return parseSchemaWithDetails(payload.body); }), tap(() => { this.analytics.trackEvent('Schema', 'ScriptsConfigured', appName); }), pretifyError('i18n:schemas.updateScriptsFailed')); } public putFieldRules(appName: string, resource: Resource, dto: ReadonlyArray, version: Version): Observable { const link = resource._links['update/rules']; const url = this.apiUrl.buildUrl(link.href); return HTTP.requestVersioned(this.http, link.method, url, version, { fieldRules: dto }).pipe( map(({ payload }) => { return parseSchemaWithDetails(payload.body); }), tap(() => { this.analytics.trackEvent('Schema', 'RulesConfigured', appName); }), pretifyError('i18n:schemas.updateRulesFailed')); } public putSchemaSync(appName: string, resource: Resource, dto: SynchronizeSchemaDto & any, version: Version): Observable { const link = resource._links['update/sync']; const url = this.apiUrl.buildUrl(link.href); return HTTP.requestVersioned(this.http, link.method, url, version, dto).pipe( map(({ payload }) => { return parseSchemaWithDetails(payload.body); }), tap(() => { this.analytics.trackEvent('Schema', 'Updated', appName); }), pretifyError('i18n:schemas.synchronizeFailed')); } public putSchema(appName: string, resource: Resource, dto: UpdateSchemaDto, version: Version): Observable { const link = resource._links['update']; const url = this.apiUrl.buildUrl(link.href); return HTTP.requestVersioned(this.http, link.method, url, version, dto).pipe( map(({ payload }) => { return parseSchemaWithDetails(payload.body); }), tap(() => { this.analytics.trackEvent('Schema', 'Updated', appName); }), pretifyError('i18n:schemas.updateFailed')); } public putCategory(appName: string, resource: Resource, dto: UpdateSchemaCategoryDto, version: Version): Observable { const link = resource._links['update/category']; const url = this.apiUrl.buildUrl(link.href); return HTTP.requestVersioned(this.http, link.method, url, version, dto).pipe( map(({ payload }) => { return parseSchemaWithDetails(payload.body); }), tap(() => { this.analytics.trackEvent('Schema', 'CategoryChanged', appName); }), pretifyError('i18n:schemas.changeCategoryFailed')); } public putPreviewUrls(appName: string, resource: Resource, dto: {}, version: Version): Observable { const link = resource._links['update/urls']; const url = this.apiUrl.buildUrl(link.href); return HTTP.requestVersioned(this.http, link.method, url, version, dto).pipe( map(({ payload }) => { return parseSchemaWithDetails(payload.body); }), tap(() => { this.analytics.trackEvent('Schema', 'PreviewUrlsConfigured', appName); }), pretifyError('i18n:schemas.updatePreviewUrlsFailed')); } public publishSchema(appName: string, resource: Resource, version: Version): Observable { const link = resource._links['publish']; const url = this.apiUrl.buildUrl(link.href); return HTTP.requestVersioned(this.http, link.method, url, version, {}).pipe( map(({ payload }) => { return parseSchemaWithDetails(payload.body); }), tap(() => { this.analytics.trackEvent('Schema', 'Published', appName); }), pretifyError('i18n:schemas.publishFailed')); } public unpublishSchema(appName: string, resource: Resource, version: Version): Observable { const link = resource._links['unpublish']; const url = this.apiUrl.buildUrl(link.href); return HTTP.requestVersioned(this.http, link.method, url, version, {}).pipe( map(({ payload }) => { return parseSchemaWithDetails(payload.body); }), tap(() => { this.analytics.trackEvent('Schema', 'Unpublished', appName); }), pretifyError('i18n:schemas.unpublishFailed')); } public postField(appName: string, resource: Resource, dto: AddFieldDto, version: Version): Observable { const link = resource._links['fields/add']; const url = this.apiUrl.buildUrl(link.href); return HTTP.requestVersioned(this.http, link.method, url, version, dto).pipe( map(({ payload }) => { return parseSchemaWithDetails(payload.body); }), tap(() => { this.analytics.trackEvent('Schema', 'FieldCreated', appName); }), pretifyError('i18n:schemas.addFieldFailed')); } public putUIFields(appName: string, resource: Resource, dto: UpdateUIFields, version: Version): Observable { const link = resource._links['fields/ui']; const url = this.apiUrl.buildUrl(link.href); return HTTP.requestVersioned(this.http, link.method, url, version, dto).pipe( map(({ payload }) => { return parseSchemaWithDetails(payload.body); }), tap(() => { this.analytics.trackEvent('Schema', 'UIFieldsConfigured', appName); }), pretifyError('i18n:schemas.updateUIFieldsFailed')); } public putFieldOrdering(appName: string, resource: Resource, dto: ReadonlyArray, version: Version): Observable { const link = resource._links['fields/order']; const url = this.apiUrl.buildUrl(link.href); return HTTP.requestVersioned(this.http, link.method, url, version, { fieldIds: dto }).pipe( map(({ payload }) => { return parseSchemaWithDetails(payload.body); }), tap(() => { this.analytics.trackEvent('Schema', 'FieldsReordered', appName); }), pretifyError('i18n:schemas.reorderFieldsFailed')); } public putField(appName: string, resource: Resource, dto: UpdateFieldDto, version: Version): Observable { const link = resource._links['update']; const url = this.apiUrl.buildUrl(link.href); return HTTP.requestVersioned(this.http, link.method, url, version, dto).pipe( map(({ payload }) => { return parseSchemaWithDetails(payload.body); }), tap(() => { this.analytics.trackEvent('Schema', 'FieldUpdated', appName); }), pretifyError('i18n:schemas.updateFieldFailed')); } public lockField(appName: string, resource: Resource, version: Version): Observable { const link = resource._links['lock']; const url = this.apiUrl.buildUrl(link.href); return HTTP.requestVersioned(this.http, link.method, url, version, {}).pipe( map(({ payload }) => { return parseSchemaWithDetails(payload.body); }), tap(() => { this.analytics.trackEvent('Schema', 'FieldLocked', appName); }), pretifyError('i18n:schemas.lockFieldFailed')); } public enableField(appName: string, resource: Resource, version: Version): Observable { const link = resource._links['enable']; const url = this.apiUrl.buildUrl(link.href); return HTTP.requestVersioned(this.http, link.method, url, version, {}).pipe( map(({ payload }) => { return parseSchemaWithDetails(payload.body); }), tap(() => { this.analytics.trackEvent('Schema', 'FieldEnabled', appName); }), pretifyError('i18n:schemas.enableFieldFailed')); } public disableField(appName: string, resource: Resource, version: Version): Observable { const link = resource._links['disable']; const url = this.apiUrl.buildUrl(link.href); return HTTP.requestVersioned(this.http, link.method, url, version, {}).pipe( map(({ payload }) => { return parseSchemaWithDetails(payload.body); }), tap(() => { this.analytics.trackEvent('Schema', 'FieldDisabled', appName); }), pretifyError('i18n:schemas.disableFieldFailed')); } public showField(appName: string, resource: Resource, version: Version): Observable { const link = resource._links['show']; const url = this.apiUrl.buildUrl(link.href); return HTTP.requestVersioned(this.http, link.method, url, version, {}).pipe( map(({ payload }) => { return parseSchemaWithDetails(payload.body); }), tap(() => { this.analytics.trackEvent('Schema', 'FieldShown', appName); }), pretifyError('i18n:schemas.showFieldFailed')); } public hideField(appName: string, resource: Resource, version: Version): Observable { const link = resource._links['hide']; const url = this.apiUrl.buildUrl(link.href); return HTTP.requestVersioned(this.http, link.method, url, version, {}).pipe( map(({ payload }) => { return parseSchemaWithDetails(payload.body); }), tap(() => { this.analytics.trackEvent('Schema', 'FieldHidden', appName); }), pretifyError('i18n:schemas.hideFieldFailed')); } public deleteField(appName: string, resource: Resource, version: Version): Observable { const link = resource._links['delete']; const url = this.apiUrl.buildUrl(link.href); return HTTP.requestVersioned(this.http, link.method, url, version, {}).pipe( map(({ payload }) => { return parseSchemaWithDetails(payload.body); }), tap(() => { this.analytics.trackEvent('Schema', 'FieldDeleted', appName); }), pretifyError('i18n:schemas.deleteFieldFailed')); } public deleteSchema(appName: string, resource: Resource, version: Version): Observable> { const link = resource._links['delete']; const url = this.apiUrl.buildUrl(link.href); return HTTP.requestVersioned(this.http, link.method, url, version).pipe( tap(() => { this.analytics.trackEvent('Schema', 'Deleted', appName); }), pretifyError('i18n:schemas.deleteFailed')); } } function parseSchemas(response: any) { const raw: any[] = response.items; const items = raw.map(item => new SchemaDto(item._links, item.id, item.name, item.category, parseProperties(item.properties), item.isSingleton, item.isPublished, DateTime.parseISO(item.created), item.createdBy, DateTime.parseISO(item.lastModified), item.lastModifiedBy, new Version(item.version.toString()))); const _links = response._links; return { items, _links, canCreate: hasAnyLink(_links, 'create') }; } function parseSchemaWithDetails(response: any) { const fields = response.fields.map((item: any) => parseField(item)); return new SchemaDetailsDto(response._links, response.id, response.name, response.category, parseProperties(response.properties), response.isSingleton, response.isPublished, DateTime.parseISO(response.created), response.createdBy, DateTime.parseISO(response.lastModified), response.lastModifiedBy, new Version(response.version.toString()), fields, response.fieldsInLists, response.fieldsInReferences, response.fieldRules, response.scripts || {}, response.previewUrls || {}); } function parseProperties(response: any) { return new SchemaPropertiesDto( response.label, response.hints, response.contentsSidebarUrl, response.contentSidebarUrl, response.validateOnPublish, response.tags); } export function parseField(item: any) { const propertiesDto = createProperties( item.properties.fieldType, item.properties); let nested: NestedFieldDto[] | null = null; if (item.nested && item.nested.length > 0) { nested = item.nested.map((nestedItem: any) => { const nestedPropertiesDto = createProperties( nestedItem.properties.fieldType, nestedItem.properties); return new NestedFieldDto(nestedItem._links, nestedItem.fieldId, nestedItem.name, nestedPropertiesDto, item.fieldId, nestedItem.isLocked, nestedItem.isHidden, nestedItem.isDisabled); }); } return new RootFieldDto(item._links, item.fieldId, item.name, propertiesDto, item.partitioning, item.isLocked, item.isHidden, item.isDisabled, nested || []); }