Headless CMS and Content Managment Hub
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

464 lines
14 KiB

/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Injectable } from '@angular/core';
import { compareStrings, DialogService, MathHelper, Pager, shareSubscribed, State, StateSynchronizer } from '@app/framework';
import { empty, forkJoin, Observable, of, throwError } from 'rxjs';
import { catchError, finalize, tap } from 'rxjs/operators';
import { AnnotateAssetDto, AssetDto, AssetFolderDto, AssetsService, RenameAssetFolderDto } from './../services/assets.service';
import { AppsState } from './apps.state';
import { Query, QueryFullTextSynchronizer } from './query';
export type AssetPathItem = { id: string, folderName: string };
export type TagsAvailable = { [name: string]: number };
export type TagsSelected = { [name: string]: boolean };
export type Tag = { name: string, count: number; };
const EMPTY_FOLDERS: { canCreate: boolean, items: ReadonlyArray<AssetFolderDto>, path?: ReadonlyArray<AssetFolderDto> } = { canCreate: false, items: [] };
const ROOT_ITEM: AssetPathItem = { id: MathHelper.EMPTY_GUID, folderName: 'Assets' };
interface Snapshot {
// All assets tags.
tagsAvailable: TagsAvailable;
// The selected asset tags.
tagsSelected: TagsSelected;
// The current assets.
assets: ReadonlyArray<AssetDto>;
// The current asset folders.
assetFolders: ReadonlyArray<AssetFolderDto>;
// The pagination information.
assetsPager: Pager;
// The query to filter assets.
assetsQuery?: Query;
// The folder path.
path: ReadonlyArray<AssetPathItem>;
// The parent folder.
parentId: string;
// Indicates if the assets are loaded once.
isLoadedOnce?: boolean;
// Indicates if the assets are loaded.
isLoaded?: boolean;
// Indicates if the assets are loading.
isLoading?: boolean;
// Indicates if the user can create assets.
canCreate?: boolean;
// Indicates if the user can create asset folders.
canCreateFolders?: boolean;
}
@Injectable()
export class AssetsState extends State<Snapshot> {
public tagsUnsorted =
this.project(x => x.tagsAvailable);
public tagsSelected =
this.project(x => x.tagsSelected);
public tags =
this.projectFrom(this.tagsUnsorted, x => sort(x));
public tagsNames =
this.projectFrom(this.tagsUnsorted, x => Object.keys(x));
public selectedTagNames =
this.projectFrom(this.tagsSelected, x => Object.keys(x));
public assets =
this.project(x => x.assets);
public assetFolders =
this.project(x => x.assetFolders);
public assetsQuery =
this.project(x => x.assetsQuery);
public assetsPager =
this.project(x => x.assetsPager);
public isLoaded =
this.project(x => x.isLoaded === true);
public isLoading =
this.project(x => x.isLoading === true);
public path =
this.project(x => x.path);
public pathAvailable =
this.project(x => x.path.length > 0);
public parentFolder =
this.project(x => getParent(x.path));
public canCreate =
this.project(x => x.canCreate === true);
public canCreateFolders =
this.project(x => x.canCreateFolders === true);
constructor(
private readonly appsState: AppsState,
private readonly assetsService: AssetsService,
private readonly dialogs: DialogService
) {
super({
assetFolders: [],
assets: [],
assetsPager: new Pager(0, 0, 30),
parentId: ROOT_ITEM.id,
path: [ROOT_ITEM],
tagsAvailable: {},
tagsSelected: {}
});
}
public loadAndListen(synchronizer: StateSynchronizer) {
synchronizer.mapTo(this)
.withPager('assetsPager', 'assets', 20)
.withString('parentId', 'parent')
.withStrings('tagsSelected', 'tags')
.withSynchronizer('assetsQuery', new QueryFullTextSynchronizer())
.whenSynced(() => this.loadInternal(false))
.build();
}
public load(isReload = false): Observable<any> {
if (!isReload) {
this.resetState();
}
return this.loadInternal(isReload);
}
private loadInternal(isReload: boolean): Observable<any> {
this.next({ isLoading: true });
const query: any = {
take: this.snapshot.assetsPager.pageSize,
skip: this.snapshot.assetsPager.skip
};
const withQuery = hasQuery(this.snapshot);
if (withQuery) {
if (this.snapshot.assetsQuery) {
query.query = this.snapshot.assetsQuery;
}
const searchTags = Object.keys(this.snapshot.tagsSelected);
if (searchTags.length > 0) {
query.tags = searchTags;
}
} else {
query.parentId = this.snapshot.parentId;
}
const assets$ =
this.assetsService.getAssets(this.appName, query);
const assetFolders$ =
!withQuery ?
this.assetsService.getAssetFolders(this.appName, this.snapshot.parentId) :
of(EMPTY_FOLDERS);
const tags$ =
!withQuery || !this.snapshot.isLoadedOnce ?
this.assetsService.getTags(this.appName) :
of(this.snapshot.tagsAvailable);
return forkJoin(([assets$, assetFolders$, tags$])).pipe(
tap(([assets, assetFolders, tagsAvailable]) => {
if (isReload) {
this.dialogs.notifyInfo('i18n:assets.reloaded');
}
const path = assetFolders.path ?
[ROOT_ITEM, ...assetFolders.path] :
[];
this.next(s => ({
...s,
assetFolders: assetFolders.items,
assets: assets.items,
assetsPager: s.assetsPager.setCount(assets.total),
canCreate: assets.canCreate,
canCreateFolders: assetFolders.canCreate,
isLoaded: true,
isLoadedOnce: true,
isLoading: false,
path,
tagsAvailable
}));
}),
finalize(() => {
this.next({ isLoading: false });
}),
shareSubscribed(this.dialogs));
}
public addAsset(asset: AssetDto) {
if (asset.parentId !== this.snapshot.parentId || this.snapshot.assets.find(x => x.id === asset.id)) {
return;
}
this.next(s => {
const assets = [asset, ...s.assets];
const assetsPager = s.assetsPager.incrementCount();
const tags = updateTags(s, asset);
return { ...s, assets, assetsPager, ...tags };
});
}
public createAssetFolder(folderName: string) {
return this.assetsService.postAssetFolder(this.appName, { folderName, parentId: this.snapshot.parentId }).pipe(
tap(assetFolder => {
if (assetFolder.parentId !== this.snapshot.parentId) {
return;
}
this.next(s => {
const assetFolders = [...s.assetFolders, assetFolder].sortedByString(x => x.folderName);
return { ...s, assetFolders };
});
}),
shareSubscribed(this.dialogs));
}
public updateAsset(asset: AssetDto, request: AnnotateAssetDto) {
return this.assetsService.putAsset(this.appName, asset, request, asset.version).pipe(
tap(updated => {
this.next(s => {
const tags = updateTags(s, updated);
const assets = s.assets.replaceBy('id', updated);
return { ...s, assets, ...tags };
});
}),
shareSubscribed(this.dialogs, { silent: true }));
}
public updateAssetFolder(assetFolder: AssetFolderDto, request: RenameAssetFolderDto) {
return this.assetsService.putAssetFolder(this.appName, assetFolder, request, assetFolder.version).pipe(
tap(updated => {
this.next(s => {
const assetFolders = s.assetFolders.replaceBy('id', updated);
return { ...s, assetFolders };
});
}),
shareSubscribed(this.dialogs, { silent: true }));
}
public moveAsset(asset: AssetDto, parentId?: string) {
if (asset.parentId === parentId) {
return empty();
}
this.next(s => {
const assets = s.assets.filter(x => x.id !== asset.id);
return { ...s, assets };
});
return this.assetsService.putAssetItemParent(this.appName, asset, { parentId }, asset.version).pipe(
catchError(error => {
this.next(s => {
const assets = [asset, ...s.assets];
return { ...s, assets };
});
return throwError(error);
}),
shareSubscribed(this.dialogs));
}
public moveAssetFolder(assetFolder: AssetFolderDto, parentId?: string) {
if (assetFolder.id === parentId || assetFolder.parentId === parentId) {
return empty();
}
this.next(s => {
const assetFolders = s.assetFolders.filter(x => x.id !== assetFolder.id);
return { ...s, assetFolders };
});
return this.assetsService.putAssetItemParent(this.appName, assetFolder, { parentId }, assetFolder.version).pipe(
catchError(error => {
this.next(s => {
const assetFolders = [...s.assetFolders, assetFolder].sortedByString(x => x.folderName);
return { ...s, assetFolders };
});
return throwError(error);
}),
shareSubscribed(this.dialogs));
}
public deleteAsset(asset: AssetDto): Observable<any> {
return this.assetsService.deleteAssetItem(this.appName, asset, asset.version).pipe(
tap(() => {
this.next(s => {
const assets = s.assets.filter(x => x.id !== asset.id);
const assetsPager = s.assetsPager.decrementCount();
const tags = updateTags(s, undefined, asset);
return { ...s, assets, assetsPager, ...tags };
});
}),
shareSubscribed(this.dialogs));
}
public deleteAssetFolder(assetFolder: AssetFolderDto): Observable<any> {
return this.assetsService.deleteAssetItem(this.appName, assetFolder, assetFolder.version).pipe(
tap(() => {
this.next(s => {
const assetFolders = s.assetFolders.filter(x => x.id !== assetFolder.id);
return { ...s, assetFolders };
});
}),
shareSubscribed(this.dialogs));
}
public navigate(parentId: string) {
this.next({ parentId });
return this.loadInternal(false);
}
public setPager(assetsPager: Pager) {
this.next({ assetsPager });
return this.loadInternal(false);
}
public searchInternal(query?: Query | null, tags?: TagsSelected) {
this.next(s => {
const newState = { ...s, assetsPager: s.assetsPager.reset() };
if (query !== null) {
newState.assetsQuery = query;
}
if (tags) {
newState.tagsSelected = tags;
}
return newState;
});
return this.loadInternal(false);
}
public toggleTag(tag: string): Observable<any> {
const tagsSelected = { ...this.snapshot.tagsSelected };
if (tagsSelected[tag]) {
delete tagsSelected[tag];
} else {
tagsSelected[tag] = true;
}
return this.searchInternal(null, tagsSelected);
}
public selectTags(tags: ReadonlyArray<string>): Observable<any> {
const tagsSelected = {};
for (const tag of tags) {
tagsSelected[tag] = true;
}
return this.searchInternal(null, tagsSelected);
}
public resetTags(): Observable<any> {
return this.searchInternal(null, {});
}
public search(query?: Query): Observable<any> {
return this.searchInternal(query);
}
public get parentId() {
return this.snapshot.parentId;
}
private get appName() {
return this.appsState.appName;
}
}
function updateTags(snapshot: Snapshot, newAsset?: AssetDto, oldAsset?: AssetDto) {
if (!oldAsset && newAsset) {
oldAsset = snapshot.assets.find(x => x.id === newAsset.id);
}
const tagsAvailable = { ...snapshot.tagsAvailable };
const tagsSelected = { ...snapshot.tagsSelected };
if (oldAsset) {
for (const tag of oldAsset.tags) {
if (tagsAvailable[tag] === 1) {
delete tagsAvailable[tag];
delete tagsSelected[tag];
} else {
tagsAvailable[tag]--;
}
}
}
if (newAsset) {
for (const tag of newAsset.tags) {
if (tagsAvailable[tag]) {
tagsAvailable[tag]++;
} else {
tagsAvailable[tag] = 1;
}
}
}
return { tagsAvailable, tagsSelected };
}
function sort(tags: { [name: string]: number }) {
return Object.keys(tags).sort(compareStrings).map(name => ({ name, count: tags[name] }));
}
function hasQuery(state: Snapshot) {
return (state.assetsQuery && !!state.assetsQuery.fullText) || Object.keys(state.tagsSelected).length > 0;
}
function getParent(path: ReadonlyArray<AssetPathItem>) {
return path.length > 1 ? { folderName: '<Parent>', id: path[path.length - 2].id } : undefined;
}
@Injectable()
export class AssetsDialogState extends AssetsState { }