Browse Source

Merge branch 'master' of github.com:Squidex/squidex

pull/470/head
Sebastian 6 years ago
parent
commit
59429c2a77
  1. 1
      backend/src/Squidex.Domain.Apps.Core.Model/Schemas/StringFieldEditor.cs
  2. 9
      frontend/app/features/content/declarations.ts
  3. 10
      frontend/app/features/content/module.ts
  4. 3
      frontend/app/features/content/shared/field-editor.component.html
  5. 42
      frontend/app/features/content/shared/stock-photo-editor.component.html
  6. 106
      frontend/app/features/content/shared/stock-photo-editor.component.scss
  7. 114
      frontend/app/features/content/shared/stock-photo-editor.component.ts
  8. 7
      frontend/app/features/schemas/pages/schema/types/string-ui.component.html
  9. 2
      frontend/app/framework/angular/forms/autocomplete.component.ts
  10. 1
      frontend/app/shared/internal.ts
  11. 2
      frontend/app/shared/module.ts
  12. 2
      frontend/app/shared/services/help.service.spec.ts
  13. 2
      frontend/app/shared/services/schemas.types.ts
  14. 79
      frontend/app/shared/services/stock-photo.service.spec.ts
  15. 45
      frontend/app/shared/services/stock-photo.service.ts
  16. 18
      frontend/app/shared/state/contents.forms.spec.ts
  17. 30
      frontend/app/shared/state/contents.forms.ts

1
backend/src/Squidex.Domain.Apps.Core.Model/Schemas/StringFieldEditor.cs

@ -17,6 +17,7 @@ namespace Squidex.Domain.Apps.Core.Schemas
Radio,
RichText,
Slug,
StockPhoto,
TextArea
}
}

9
frontend/app/features/content/declarations.ts

@ -20,14 +20,15 @@ export * from './shared/assets-editor.component';
export * from './shared/content-list-cell.directive';
export * from './shared/content-list-field.component';
export * from './shared/content-list-header.component';
export * from './shared/content.component';
export * from './shared/content-selector-item.component';
export * from './shared/content-status.component';
export * from './shared/content-value.component';
export * from './shared/content-value-editor.component';
export * from './shared/content-selector-item.component';
export * from './shared/content-value.component';
export * from './shared/content.component';
export * from './shared/contents-selector.component';
export * from './shared/due-time-selector.component';
export * from './shared/field-editor.component';
export * from './shared/preview-button.component';
export * from './shared/reference-item.component';
export * from './shared/references-editor.component';
export * from './shared/references-editor.component';
export * from './shared/stock-photo-editor.component';

10
frontend/app/features/content/module.ts

@ -46,7 +46,8 @@ import {
PreviewButtonComponent,
ReferenceItemComponent,
ReferencesEditorComponent,
SchemasPageComponent
SchemasPageComponent,
StockPhotoEditorComponent
} from './declarations';
const routes: Routes = [
@ -106,9 +107,9 @@ const routes: Routes = [
@NgModule({
imports: [
RouterModule.forChild(routes),
SqxFrameworkModule,
SqxSharedModule,
RouterModule.forChild(routes)
SqxSharedModule
],
declarations: [
ArrayEditorComponent,
@ -137,7 +138,8 @@ const routes: Routes = [
PreviewButtonComponent,
ReferenceItemComponent,
ReferencesEditorComponent,
SchemasPageComponent
SchemasPageComponent,
StockPhotoEditorComponent
]
})
export class SqxFeatureContentModule {}

3
frontend/app/features/content/shared/field-editor.component.html

@ -124,6 +124,9 @@
<ng-container *ngSwitchCase="'Markdown'">
<sqx-markdown-editor [formControl]="editorControl"></sqx-markdown-editor>
</ng-container>
<ng-container *ngSwitchCase="'StockPhoto'">
<sqx-stock-photo-editor [formControl]="editorControl" [isCompact]="isCompact"></sqx-stock-photo-editor>
</ng-container>
<ng-container *ngSwitchCase="'Dropdown'">
<select class="form-control" [formControl]="editorControl">
<option [ngValue]="null"></option>

42
frontend/app/features/content/shared/stock-photo-editor.component.html

@ -0,0 +1,42 @@
<div class="row no-gutters">
<div class="col-auto col-image" [class.expand]="isCompact">
<input class="form-control value" [formControl]="valueControl" readonly />
<button type="button" class="btn btn-text-secondary value-clear" (click)="reset()">
<i class="icon-close"></i>
</button>
<div *ngIf="valueThumb | async; let thumbUrl; else noThumb" class="preview">
<img [src]="thumbUrl" />
</div>
<ng-template #noThumb>
<div class="preview preview-empty">
Nothing selected
</div>
</ng-template>
</div>
<div class="col pl-4" *ngIf="!isCompact">
<i class="icon-angle-left icon"></i>
<input class="form-control" [formControl]="stockPhotoSearch" placeholder="Search for Photos by Unsplash" />
<sqx-list-view [isLoading]="snapshot.isLoading" table="true">
<div content>
<div class="photos">
<ng-container *ngIf="stockPhotos | async; let photos">
<div *ngFor="let photo of photos" class="photo" [class.selected]="isSelected(photo)" (click)="selectPhoto(photo)">
<img [src]="photo.thumbUrl" />
<div class="photo-user">
<a class="photo-user-link" [href]="photo.userProfileUrl" sqxExternalLink sqxStopClick>
{{photo.user}}
</a>
</div>
</div>
</ng-container>
</div>
</div>
</sqx-list-view>
</div>
</div>

106
frontend/app/features/content/shared/stock-photo-editor.component.scss

@ -0,0 +1,106 @@
@import '_vars';
@import '_mixins';
$color-user-background: rgba(0, 0, 0, .5);
$color-background: #000;
$height: 300px;
.col-image {
@include force-width(400px);
&.expand {
@include force-width(100%);
}
img {
max-width: 100%;
}
}
.value {
& {
padding-right: 2.5rem;
}
&-clear {
@include absolute(0, 0, auto, auto);
}
}
.preview {
& {
@include force-height($height);
align-items: center;
background: $color-background;
border: 0;
border-radius: .25rem;
color: $color-dark-foreground;
display: flex;
justify-content: center;
margin: 0;
margin-top: .5rem;
}
&-empty {
padding: 1rem;
}
img {
max-height: $height;
}
}
sqx-list-view {
border: 1px solid $color-input;
border-radius: .25rem;
height: $height;
margin-top: .5rem;
}
.icon {
@include absolute($height * .5, auto, auto, 5px);
color: $color-border;
font-size: 30px;
font-weight: lighter;
}
.photos {
column-gap: 0;
column-width: 200px;
margin-left: -1rem;
margin-right: -1rem;
}
.photo {
& {
border: 2px solid $color-border;
border-radius: 0;
display: inline-block;
margin-bottom: .5rem;
margin-right: .5rem;
position: relative;
}
&:hover {
border-color: $color-theme-blue;
}
&.selected {
border-color: $color-theme-blue;
}
&-user {
@include absolute(auto, 0, 0, 0);
background: $color-user-background;
border: 0;
padding: .5rem .75rem;
}
&-user-link {
@include truncate;
color: $color-dark-foreground;
font-size: 90%;
font-weight: normal;
}
}

114
frontend/app/features/content/shared/stock-photo-editor.component.ts

@ -0,0 +1,114 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, forwardRef, Input, OnInit } from '@angular/core';
import { FormControl, NG_VALUE_ACCESSOR } from '@angular/forms';
import { of } from 'rxjs';
import { debounceTime, distinctUntilChanged, map, shareReplay, startWith, switchMap, tap } from 'rxjs/operators';
import {
StatefulControlComponent,
StockPhotoDto,
StockPhotoService,
thumbnail,
Types
} from '@app/shared';
interface State {
isLoading?: boolean;
}
export const SQX_STOCK_PHOTO_EDITOR_CONTROL_VALUE_ACCESSOR: any = {
provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => StockPhotoEditorComponent), multi: true
};
@Component({
selector: 'sqx-stock-photo-editor',
styleUrls: ['./stock-photo-editor.component.scss'],
templateUrl: './stock-photo-editor.component.html',
providers: [SQX_STOCK_PHOTO_EDITOR_CONTROL_VALUE_ACCESSOR],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class StockPhotoEditorComponent extends StatefulControlComponent<State, string> implements OnInit {
@Input()
public isCompact = false;
public valueControl = new FormControl('');
public valueThumb =
this.valueControl.valueChanges.pipe(
startWith(this.valueControl.value),
shareReplay(1),
map(value => thumbnail(value, 400) || value));
public stockPhotoSearch = new FormControl('');
public stockPhotos =
this.stockPhotoSearch.valueChanges.pipe(
startWith(this.stockPhotoSearch.value),
distinctUntilChanged(),
debounceTime(500),
tap(query => {
if (query && query.length > 0) {
this.next({ isLoading: true });
}
}),
switchMap(query => {
if (query && query.length > 0) {
return this.stockPhotoService.getImages(query);
} else {
return of([]);
}
}),
tap(() => {
this.next({ isLoading: false });
}));
constructor(changeDetector: ChangeDetectorRef,
private readonly stockPhotoService: StockPhotoService
) {
super(changeDetector, {});
}
public ngOnInit() {
this.own(this.valueThumb);
this.own(
this.valueControl.valueChanges
.subscribe(value => {
this.callChange(value);
}));
}
public writeValue(obj: string) {
if (Types.isString(obj)) {
this.valueControl.setValue(obj, { emitEvent: true });
} else {
this.valueControl.setValue('', { emitEvent: true });
}
}
public selectPhoto(photo: StockPhotoDto) {
if (!this.snapshot.isDisabled) {
this.valueControl.setValue(photo.url);
}
}
public reset() {
if (!this.snapshot.isDisabled) {
this.valueControl.setValue('');
}
}
public isSelected(photo: StockPhotoDto) {
return photo.url === this.valueControl.value;
}
public trackByPhoto(index: number, photo: StockPhotoDto) {
return photo.thumbUrl;
}
}

7
frontend/app/features/schemas/pages/schema/types/string-ui.component.html

@ -88,6 +88,13 @@
<span class="radio-label">HTML</span>
</label>
<label class="btn btn-radio" [class.active]="editForm.controls['editor'].value === 'StockPhoto'">
<input type="radio" class="radio-input" name="editor" formControlName="editor" value="StockPhoto" />
<i class="icon-media"></i>
<span class="radio-label">StockPhoto</span>
</label>
</div>
</div>
<div class="form-group row" [class.hidden]="hideAllowedValues | async">

2
frontend/app/framework/angular/forms/autocomplete.component.ts

@ -89,7 +89,7 @@ export class AutocompleteComponent extends StatefulControlComponent<State, any[]
this.reset();
}
}),
debounceTime(200),
debounceTime(500),
distinctUntilChanged(),
filter(query => !!query && !!this.source),
switchMap(query => this.source.find(query)), catchError(() => of([])))

1
frontend/app/shared/internal.ts

@ -28,6 +28,7 @@ export * from './services/roles.service';
export * from './services/rules.service';
export * from './services/schemas.service';
export * from './services/schemas.types';
export * from './services/stock-photo.service';
export * from './services/translations.service';
export * from './services/ui.service';
export * from './services/usages.service';

2
frontend/app/shared/module.ts

@ -93,6 +93,7 @@ import {
SchemaTagSource,
SearchFormComponent,
SortingComponent,
StockPhotoService,
TableHeaderComponent,
TranslationsService,
UIService,
@ -253,6 +254,7 @@ export class SqxSharedModule {
SchemasService,
SchemasState,
SchemaTagSource,
StockPhotoService,
TranslationsService,
UIService,
UIState,

2
frontend/app/shared/services/help.service.spec.ts

@ -10,7 +10,7 @@ import { inject, TestBed } from '@angular/core/testing';
import { HelpService } from '@app/shared/internal';
describe('ClientsService', () => {
describe('HelpService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [

2
frontend/app/shared/services/schemas.types.ts

@ -309,7 +309,7 @@ export class ReferencesFieldPropertiesDto extends FieldPropertiesDto {
}
}
export type StringEditor = 'Color' | 'Dropdown' | 'Html' | 'Input' | 'Markdown' | 'Radio' | 'RichText' | 'Slug' | 'TextArea';
export type StringEditor = 'Color' | 'Dropdown' | 'Html' | 'Input' | 'Markdown' | 'Radio' | 'RichText' | 'Slug' | 'StockPhoto' | 'TextArea';
export class StringFieldPropertiesDto extends FieldPropertiesDto {
public readonly fieldType = 'String';

79
frontend/app/shared/services/stock-photo.service.spec.ts

@ -0,0 +1,79 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { inject, TestBed } from '@angular/core/testing';
import { StockPhotoDto, StockPhotoService } from '@app/shared/internal';
describe('StockPhotoService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [
HttpClientTestingModule
],
providers: [
StockPhotoService
]
});
});
afterEach(inject([HttpTestingController], (httpMock: HttpTestingController) => {
httpMock.verify();
}));
it('should make get request to get stock photos',
inject([StockPhotoService, HttpTestingController], (stockPhotoService: StockPhotoService, httpMock: HttpTestingController) => {
let images: ReadonlyArray<StockPhotoDto>;
stockPhotoService.getImages('my-query').subscribe(result => {
images = result;
});
const req = httpMock.expectOne('https://stockphoto.squidex.io/?query=my-query&pageSize=100');
expect(req.request.method).toEqual('GET');
expect(req.request.headers.get('If-Match')).toBeNull();
req.flush([{
url: 'url1',
thumbUrl: 'thumb1',
user: 'user1',
userProfileUrl: 'user1-url'
}, {
url: 'url2',
thumbUrl: 'thumb2',
user: 'user2',
userProfileUrl: 'user2-url'
}]);
expect(images!).toEqual([
new StockPhotoDto('url1', 'thumb1', 'user1', 'user1-url'),
new StockPhotoDto('url2', 'thumb2', 'user2', 'user2-url')
]);
}));
it('should return empty stock photos if get request fails',
inject([StockPhotoService, HttpTestingController], (stockPhotoService: StockPhotoService, httpMock: HttpTestingController) => {
let images: ReadonlyArray<StockPhotoDto>;
stockPhotoService.getImages('my-query').subscribe(result => {
images = result;
});
const req = httpMock.expectOne('https://stockphoto.squidex.io/?query=my-query&pageSize=100');
expect(req.request.method).toEqual('GET');
expect(req.request.headers.get('If-Match')).toBeNull();
req.error(<any>{});
expect(images!).toEqual([]);
}));
});

45
frontend/app/shared/services/stock-photo.service.ts

@ -0,0 +1,45 @@
/*
* 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 { Observable, of } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
export class StockPhotoDto {
constructor (
public readonly url: string,
public readonly thumbUrl: string,
public readonly user: string,
public readonly userProfileUrl: string
) {
}
}
@Injectable()
export class StockPhotoService {
constructor(
private readonly http: HttpClient
) {
}
public getImages(query: string): Observable<ReadonlyArray<StockPhotoDto>> {
const url = `https://stockphoto.squidex.io/?query=${query}&pageSize=100`;
return this.http.get<any[]>(url).pipe(
map(body => {
return body.map(x =>
new StockPhotoDto(
x.url,
x.thumbUrl,
x.user,
x.userProfileUrl
));
}),
catchError(() => of([])));
}
}

18
frontend/app/shared/state/contents.forms.spec.ts

@ -417,6 +417,24 @@ describe('StringField', () => {
expect(FieldFormatter.format(field, 'hello')).toBe('hello');
});
it('should format to preview image', () => {
const field2 = createField({ properties: createProperties('String', { editor: 'StockPhoto' }) });
expect(FieldFormatter.format(field2, 'https://images.unsplash.com/123?x', true)).toEqual(new HtmlValue('<img src="https://images.unsplash.com/123?x&q=80&fm=jpg&crop=entropy&cs=tinysrgb&h=50&fit=max" />'));
});
it('should not format to preview image when html not allowed', () => {
const field2 = createField({ properties: createProperties('String', { editor: 'StockPhoto' }) });
expect(FieldFormatter.format(field2, 'https://images.unsplash.com/123?x', false)).toBe('https://images.unsplash.com/123?x');
});
it('should not format to preview image when not unsplash image', () => {
const field2 = createField({ properties: createProperties('String', { editor: 'StockPhoto' }) });
expect(FieldFormatter.format(field2, 'https://images.com/123?x', true)).toBe('https://images.com/123?x');
});
it('should return default value for default properties', () => {
const field2 = createField({ properties: createProperties('String', { defaultValue: 'MyDefault' }) });

30
frontend/app/shared/state/contents.forms.ts

@ -206,10 +206,6 @@ export class FieldFormatter implements FieldPropertiesVisitor<FieldValue> {
}
}
public visitString(_: StringFieldPropertiesDto): any {
return this.value;
}
public visitTags(_: TagsFieldPropertiesDto): string {
if (this.value.length) {
return this.value.join(', ');
@ -218,11 +214,37 @@ export class FieldFormatter implements FieldPropertiesVisitor<FieldValue> {
}
}
public visitString(properties: StringFieldPropertiesDto): any {
if (properties.editor === 'StockPhoto' && this.allowHtml && this.value) {
const src = thumbnail(this.value, undefined, 50);
if (src) {
return new HtmlValue(`<img src="${src}" />`);
}
}
return this.value;
}
public visitUI(_: UIFieldPropertiesDto): any {
return '';
}
}
export function thumbnail(url: string, width?: number, height?: number) {
if (url && url.startsWith('https://images.unsplash.com')) {
if (width) {
return `${url}&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=${width}&fit=max`;
}
if (height) {
return `${url}&q=80&fm=jpg&crop=entropy&cs=tinysrgb&h=${height}&fit=max`;
}
}
return undefined;
}
export class FieldsValidators implements FieldPropertiesVisitor<ReadonlyArray<ValidatorFn>> {
private constructor(
private readonly isOptional: boolean

Loading…
Cancel
Save