mirror of https://github.com/Squidex/squidex.git
42 changed files with 757 additions and 466 deletions
@ -1,50 +1,2 @@ |
|||
@import '_vars'; |
|||
@import '_mixins'; |
|||
|
|||
.file-drop { |
|||
& { |
|||
@include transition(border-color .4s ease); |
|||
border: 2px dashed $color-border; |
|||
background: transparent; |
|||
padding: 1rem; |
|||
text-align: center; |
|||
margin-bottom: 1rem; |
|||
margin-right: 0; |
|||
} |
|||
|
|||
&.drag { |
|||
border-color: darken($color-border, 10%); |
|||
border-style: dashed; |
|||
cursor: copy; |
|||
} |
|||
|
|||
&-button-input { |
|||
@include hidden; |
|||
} |
|||
|
|||
&-button { |
|||
margin: .5rem 0; |
|||
} |
|||
|
|||
&-or { |
|||
font-size: .8rem; |
|||
} |
|||
|
|||
&-info { |
|||
color: darken($color-border, 30%); |
|||
} |
|||
} |
|||
|
|||
.btn { |
|||
cursor: default; |
|||
} |
|||
|
|||
.row { |
|||
margin-left: -8px; |
|||
margin-right: -8px; |
|||
} |
|||
|
|||
.col-3 { |
|||
padding-left: 8px; |
|||
padding-right: 8px; |
|||
} |
|||
@import '_mixins'; |
|||
@ -0,0 +1,34 @@ |
|||
<div class="file-drop" (sqxFileDrop)="addFiles($event)" *ngIf="!isDisabled"> |
|||
<h3 class="file-drop-header">Drop files here to upload</h3> |
|||
|
|||
<div class="file-drop-or">or</div> |
|||
|
|||
<div class="file-drop-button"> |
|||
<span class="btn btn-success" (click)="fileInput.click()"> |
|||
<span>Select File(s)</span> |
|||
|
|||
<input class="file-drop-button-input" type="file" (change)="addFiles($event.target.files)" #fileInput multiple /> |
|||
</span> |
|||
</div> |
|||
|
|||
<div class="file-drop-info">Drop file on existing item to replace the asset with a newer version.</div> |
|||
</div> |
|||
|
|||
<div class="row"> |
|||
<sqx-asset class="{{assetClass}}" *ngFor="let file of newFiles" [initFile]="file" |
|||
(failed)="onAssetFailed(file)" |
|||
(loaded)="onAssetLoaded(file, $event)"> |
|||
</sqx-asset> |
|||
|
|||
<ng-container *ngIf="state.assets | async; let assets"> |
|||
<sqx-asset class="{{assetClass}}" *ngFor="let asset of assets" [asset]="asset" |
|||
[isDisabled]="isDisabled" |
|||
[isSelectable]="selectedIds" |
|||
[isSelected]="isSelected(asset)" |
|||
(selected)="onAssetSelected($event)" |
|||
(deleting)="onAssetDeleting($event)"> |
|||
</sqx-asset> |
|||
</ng-container> |
|||
</div> |
|||
|
|||
<sqx-pager [hideWhenButtonsDisabled]="true" [pager]="state.assetsPager | async" (prev)="goPrev()" (next)="goNext()"></sqx-pager> |
|||
@ -0,0 +1,40 @@ |
|||
@import '_vars'; |
|||
@import '_mixins'; |
|||
|
|||
.file-drop { |
|||
& { |
|||
@include transition(border-color .4s ease); |
|||
border: 2px dashed $color-border; |
|||
background: transparent; |
|||
padding: 1rem; |
|||
text-align: center; |
|||
margin-bottom: 1rem; |
|||
margin-right: 0; |
|||
} |
|||
|
|||
&.drag { |
|||
border-color: darken($color-border, 10%); |
|||
border-style: dashed; |
|||
cursor: copy; |
|||
} |
|||
|
|||
&-button-input { |
|||
@include hidden; |
|||
} |
|||
|
|||
&-button { |
|||
margin: .5rem 0; |
|||
} |
|||
|
|||
&-or { |
|||
font-size: .8rem; |
|||
} |
|||
|
|||
&-info { |
|||
color: darken($color-border, 30%); |
|||
} |
|||
} |
|||
|
|||
.btn { |
|||
cursor: default; |
|||
} |
|||
@ -0,0 +1,81 @@ |
|||
/* |
|||
* Squidex Headless CMS |
|||
* |
|||
* @license |
|||
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
|||
*/ |
|||
|
|||
// tslint:disable:prefer-for-of
|
|||
|
|||
import { Component, EventEmitter, Input, Output } from '@angular/core'; |
|||
|
|||
import { |
|||
AssetsState, |
|||
AssetDto, |
|||
ImmutableArray |
|||
} from '@app/shared/internal'; |
|||
|
|||
@Component({ |
|||
selector: 'sqx-assets-list', |
|||
styleUrls: ['./assets-list.component.scss'], |
|||
templateUrl: './assets-list.component.html' |
|||
}) |
|||
export class AssetsListComponent { |
|||
public newFiles = ImmutableArray.empty<File>(); |
|||
|
|||
@Input() |
|||
public state: AssetsState; |
|||
|
|||
@Input() |
|||
public isDisabled: false; |
|||
|
|||
@Input() |
|||
public selectedIds: object; |
|||
|
|||
@Input() |
|||
public assetClass = ''; |
|||
|
|||
@Output() |
|||
public selected = new EventEmitter<AssetDto>(); |
|||
|
|||
public onAssetLoaded(file: File, asset: AssetDto) { |
|||
this.newFiles = this.newFiles.remove(file); |
|||
|
|||
this.state.addAsset(asset); |
|||
} |
|||
|
|||
public search() { |
|||
this.state.loadAssets().subscribe(); |
|||
} |
|||
|
|||
public onAssetDeleting(asset: AssetDto) { |
|||
this.state.delete(asset).subscribe(); |
|||
} |
|||
|
|||
public onAssetSelected(asset: AssetDto) { |
|||
this.selected.emit(asset); |
|||
} |
|||
|
|||
public onAssetFailed(file: File) { |
|||
this.newFiles = this.newFiles.remove(file); |
|||
} |
|||
|
|||
public goNext() { |
|||
this.state.goNext().subscribe(); |
|||
} |
|||
|
|||
public goPrev() { |
|||
this.state.goPrev().subscribe(); |
|||
} |
|||
|
|||
public isSelected(asset: AssetDto) { |
|||
return this.selectedIds && this.selectedIds[asset.id]; |
|||
} |
|||
|
|||
public addFiles(files: FileList) { |
|||
for (let i = 0; i < files.length; i++) { |
|||
this.newFiles = this.newFiles.pushFront(files[i]); |
|||
} |
|||
} |
|||
} |
|||
|
|||
@ -0,0 +1,24 @@ |
|||
<sqx-modal-dialog (close)="complete()" large="true" fullHeight="true"> |
|||
<ng-container title> |
|||
Select assets |
|||
</ng-container> |
|||
|
|||
<ng-container tabs> |
|||
<form class="form-inline" (ngSubmit)="search()"> |
|||
<input class="form-control" [formControl]="assetsFilter" placeholder="Search for assets" /> |
|||
</form> |
|||
</ng-container> |
|||
|
|||
<ng-container content> |
|||
<sqx-assets-list assetClass="asset-default" size="4" |
|||
(selected)="onAssetSelected($event)" |
|||
[selectedIds]="selectedAssets" |
|||
[state]="state" isDisabled="true"> |
|||
</sqx-assets-list> |
|||
</ng-container> |
|||
|
|||
<ng-container footer> |
|||
<button type="reset" class="float-left btn btn-secondary" (click)="complete()">Cancel</button> |
|||
<button type="submit" class="float-right btn btn-success" (click)="select()" [disabled]="selectionCount === 0">Link selected assets ({{selectionCount}})</button> |
|||
</ng-container> |
|||
</sqx-modal-dialog> |
|||
@ -0,0 +1,2 @@ |
|||
@import '_vars'; |
|||
@import '_mixins'; |
|||
@ -0,0 +1,69 @@ |
|||
/* |
|||
* Squidex Headless CMS |
|||
* |
|||
* @license |
|||
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
|||
*/ |
|||
|
|||
// tslint:disable:prefer-for-of
|
|||
|
|||
import { Component, EventEmitter, OnInit, Output } from '@angular/core'; |
|||
import { FormControl } from '@angular/forms'; |
|||
|
|||
import { |
|||
AssetDto, |
|||
AssetsDialogState, |
|||
fadeAnimation |
|||
} from '@app/shared/internal'; |
|||
|
|||
@Component({ |
|||
selector: 'sqx-assets-selector', |
|||
styleUrls: ['./assets-selector.component.scss'], |
|||
templateUrl: './assets-selector.component.html', |
|||
animations: [ |
|||
fadeAnimation |
|||
] |
|||
}) |
|||
export class AssetsSelectorComponent implements OnInit { |
|||
public selectedAssets: { [id: string]: AssetDto } = {}; |
|||
public selectionCount = 0; |
|||
|
|||
@Output() |
|||
public selected = new EventEmitter<AssetDto[]>(); |
|||
|
|||
public assetsFilter = new FormControl(''); |
|||
|
|||
constructor( |
|||
public readonly state: AssetsDialogState |
|||
) { |
|||
} |
|||
|
|||
public ngOnInit() { |
|||
this.state.loadAssets(false, true).subscribe(); |
|||
|
|||
this.assetsFilter.setValue(this.state.snapshot.assetsQuery); |
|||
} |
|||
|
|||
public search() { |
|||
this.state.search(this.assetsFilter.value).subscribe(); |
|||
} |
|||
|
|||
public complete() { |
|||
this.selected.emit([]); |
|||
} |
|||
|
|||
public select() { |
|||
this.selected.emit(Object.values(this.selectedAssets)); |
|||
} |
|||
|
|||
public onAssetSelected(asset: AssetDto) { |
|||
if (this.selectedAssets[asset.id]) { |
|||
delete this.selectedAssets[asset.id]; |
|||
} else { |
|||
this.selectedAssets[asset.id] = asset; |
|||
} |
|||
|
|||
this.selectionCount = Object.keys(this.selectedAssets).length; |
|||
} |
|||
} |
|||
|
|||
@ -0,0 +1,132 @@ |
|||
/* |
|||
* Squidex Headless CMS |
|||
* |
|||
* @license |
|||
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
|||
*/ |
|||
|
|||
import { Observable } from 'rxjs'; |
|||
import { IMock, It, Mock, Times } from 'typemoq'; |
|||
|
|||
import { |
|||
AppsState, |
|||
AssetDto, |
|||
AssetsDto, |
|||
AssetsService, |
|||
AssetsState, |
|||
DialogService, |
|||
DateTime, |
|||
Version, |
|||
Versioned |
|||
} from '@app/shared'; |
|||
|
|||
describe('AssetsState', () => { |
|||
const app = 'my-app'; |
|||
const creation = DateTime.today(); |
|||
const creator = 'not-me'; |
|||
const modified = DateTime.now(); |
|||
const modifier = 'me'; |
|||
const version = new Version('1'); |
|||
const newVersion = new Version('2'); |
|||
|
|||
const oldAssets = [ |
|||
new AssetDto('id1', creator, creator, creation, creation, 'name1', 'type1', 1, 1, 'mime1', false, null, null, 'url1', version), |
|||
new AssetDto('id2', creator, creator, creation, creation, 'name2', 'type2', 2, 2, 'mime2', false, null, null, 'url2', version) |
|||
]; |
|||
|
|||
let dialogs: IMock<DialogService>; |
|||
let appsState: IMock<AppsState>; |
|||
let assetsService: IMock<AssetsService>; |
|||
let assetsState: AssetsState; |
|||
|
|||
beforeEach(() => { |
|||
dialogs = Mock.ofType<DialogService>(); |
|||
|
|||
appsState = Mock.ofType<AppsState>(); |
|||
|
|||
appsState.setup(x => x.appName) |
|||
.returns(() => app); |
|||
|
|||
assetsService = Mock.ofType<AssetsService>(); |
|||
|
|||
assetsService.setup(x => x.getAssets(app, 30, 0, undefined)) |
|||
.returns(() => Observable.of(new AssetsDto(200, oldAssets))); |
|||
|
|||
assetsState = new AssetsState(appsState.object, assetsService.object, dialogs.object); |
|||
assetsState.loadAssets().subscribe(); |
|||
}); |
|||
|
|||
it('should load assets', () => { |
|||
assetsState.loadAssets().subscribe(); |
|||
|
|||
expect(assetsState.snapshot.assets.values).toEqual(oldAssets); |
|||
expect(assetsState.snapshot.assetsPager.numberOfItems).toEqual(200); |
|||
|
|||
assetsService.verify(x => x.getAssets(app, 30, 0, undefined), Times.exactly(2)); |
|||
}); |
|||
|
|||
it('should not reload when assets assets already loaded', () => { |
|||
assetsState.loadAssets(false, true).subscribe(); |
|||
|
|||
expect(assetsState.snapshot.assets.values).toEqual(oldAssets); |
|||
expect(assetsState.snapshot.assetsPager.numberOfItems).toEqual(200); |
|||
|
|||
assetsService.verify(x => x.getAssets(app, 30, 0, undefined), Times.once()); |
|||
}); |
|||
|
|||
it('should raise notification on load when notify is true', () => { |
|||
assetsState.loadAssets(true).subscribe(); |
|||
|
|||
dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.once()); |
|||
}); |
|||
|
|||
it('should add asset to snapshot', () => { |
|||
const newAsset = new AssetDto('id3', creator, creator, creation, creation, 'name3', 'type3', 3, 3, 'mime3', true, 0, 0, 'url3', version); |
|||
|
|||
assetsState.addAsset(newAsset); |
|||
|
|||
expect(assetsState.snapshot.assets.values).toEqual([newAsset, ...oldAssets]); |
|||
expect(assetsState.snapshot.assetsPager.numberOfItems).toBe(201); |
|||
}); |
|||
|
|||
it('should update asset in snapshot', () => { |
|||
const newAsset = new AssetDto('id1', modifier, modifier, modified, modified, 'name3', 'type3', 3, 3, 'mime3', true, 0, 0, 'url3', version); |
|||
assetsState.updateAsset(newAsset); |
|||
|
|||
const asset_1 = assetsState.snapshot.assets.at(0); |
|||
|
|||
expect(asset_1).toBe(newAsset); |
|||
}); |
|||
|
|||
it('should load next page and prev page when paging', () => { |
|||
assetsService.setup(x => x.getAssets(app, 30, 30, undefined)) |
|||
.returns(() => Observable.of(new AssetsDto(200, []))); |
|||
|
|||
assetsState.goNext().subscribe(); |
|||
assetsState.goPrev().subscribe(); |
|||
|
|||
assetsService.verify(x => x.getAssets(app, 30, 30, undefined), Times.once()); |
|||
assetsService.verify(x => x.getAssets(app, 30, 0, undefined), Times.exactly(2)); |
|||
}); |
|||
|
|||
it('should load with query when searching', () => { |
|||
assetsService.setup(x => x.getAssets(app, 30, 0, 'my-query')) |
|||
.returns(() => Observable.of(new AssetsDto(0, []))); |
|||
|
|||
assetsState.search('my-query').subscribe(); |
|||
|
|||
expect(assetsState.snapshot.assetsQuery).toEqual('my-query'); |
|||
|
|||
assetsService.verify(x => x.getAssets(app, 30, 0, 'my-query'), Times.once()); |
|||
}); |
|||
|
|||
it('should remove asset when deleted', () => { |
|||
assetsService.setup(x => x.deleteAsset(app, oldAssets[0].id, version)) |
|||
.returns(() => Observable.of(new Versioned<any>(newVersion, {}))); |
|||
|
|||
assetsState.delete(oldAssets[0]).subscribe(); |
|||
|
|||
expect(assetsState.snapshot.assets.values.length).toBe(1); |
|||
expect(assetsState.snapshot.assetsPager.numberOfItems).toBe(199); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,138 @@ |
|||
/* |
|||
* Squidex Headless CMS |
|||
* |
|||
* @license |
|||
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
|||
*/ |
|||
|
|||
import { Injectable } from '@angular/core'; |
|||
import { FormBuilder, Validators, FormGroup } from '@angular/forms'; |
|||
import { Observable } from 'rxjs'; |
|||
|
|||
import '@app/framework/utils/rxjs-extensions'; |
|||
|
|||
import { |
|||
DialogService, |
|||
ImmutableArray, |
|||
Pager, |
|||
Form, |
|||
State |
|||
} from '@app/framework'; |
|||
|
|||
import { AppsState } from './apps.state'; |
|||
import { AssetDto, AssetsService} from './../services/assets.service'; |
|||
|
|||
export class RenameAssetForm extends Form<FormGroup> { |
|||
constructor(formBuilder: FormBuilder) { |
|||
super(formBuilder.group({ |
|||
name: ['', |
|||
[ |
|||
Validators.required |
|||
] |
|||
] |
|||
})); |
|||
} |
|||
} |
|||
|
|||
interface Snapshot { |
|||
assets: ImmutableArray<AssetDto>; |
|||
assetsPager: Pager; |
|||
assetsQuery?: string; |
|||
|
|||
loaded: false; |
|||
} |
|||
|
|||
@Injectable() |
|||
export class AssetsState extends State<Snapshot> { |
|||
public assets = |
|||
this.changes.map(x => x.assets) |
|||
.distinctUntilChanged(); |
|||
|
|||
public assetsPager = |
|||
this.changes.map(x => x.assetsPager) |
|||
.distinctUntilChanged(); |
|||
|
|||
constructor( |
|||
private readonly appsState: AppsState, |
|||
private readonly assetsService: AssetsService, |
|||
private readonly dialogs: DialogService |
|||
) { |
|||
super({ assets: ImmutableArray.empty(), assetsPager: new Pager(0, 0, 30), loaded: false }); |
|||
} |
|||
|
|||
public loadAssets(notify = false, noReload = false): Observable<any> { |
|||
if (this.snapshot.loaded && noReload) { |
|||
return Observable.of({}); |
|||
} |
|||
|
|||
return this.assetsService.getAssets(this.appName, this.snapshot.assetsPager.pageSize, this.snapshot.assetsPager.skip, this.snapshot.assetsQuery) |
|||
.do(dtos => { |
|||
if (notify) { |
|||
this.dialogs.notifyInfo('Assets reloaded.'); |
|||
} |
|||
|
|||
this.next(s => { |
|||
const assets = ImmutableArray.of(dtos.items); |
|||
const assetsPager = s.assetsPager.setCount(dtos.total); |
|||
|
|||
return { ...s, assets, assetsPager, loaded: true }; |
|||
}); |
|||
}) |
|||
.notify(this.dialogs); |
|||
} |
|||
|
|||
public addAsset(asset: AssetDto) { |
|||
this.next(s => { |
|||
const assets = s.assets.pushFront(asset); |
|||
const assetsPager = s.assetsPager.incrementCount(); |
|||
|
|||
return { ...s, assets, assetsPager }; |
|||
}); |
|||
} |
|||
|
|||
public updateAsset(asset: AssetDto) { |
|||
this.next(s => { |
|||
const assets = s.assets.replaceBy('id', asset); |
|||
|
|||
return { ...s, assets }; |
|||
}); |
|||
} |
|||
|
|||
public delete(asset: AssetDto): Observable<any> { |
|||
return this.assetsService.deleteAsset(this.appName, asset.id, asset.version) |
|||
.do(dto => { |
|||
return this.next(s => { |
|||
const assets = s.assets.filter(x => x.id !== asset.id); |
|||
const assetsPager = s.assetsPager.decrementCount(); |
|||
|
|||
return { ...s, assets, assetsPager }; |
|||
}); |
|||
}) |
|||
.notify(this.dialogs); |
|||
} |
|||
|
|||
public search(query: string): Observable<any> { |
|||
this.next(s => ({ ...s, assetsPager: new Pager(0), assetsQuery: query })); |
|||
|
|||
return this.loadAssets(); |
|||
} |
|||
|
|||
public goNext(): Observable<any> { |
|||
this.next(s => ({ ...s, assetsPager: s.assetsPager.goNext() })); |
|||
|
|||
return this.loadAssets(); |
|||
} |
|||
|
|||
public goPrev(): Observable<any> { |
|||
this.next(s => ({ ...s, assetsPager: s.assetsPager.goPrev() })); |
|||
|
|||
return this.loadAssets(); |
|||
} |
|||
|
|||
private get appName() { |
|||
return this.appsState.appName; |
|||
} |
|||
} |
|||
|
|||
@Injectable() |
|||
export class AssetsDialogState extends AssetsState { } |
|||
Loading…
Reference in new issue