Browse Source

Asset dropdown.

pull/751/head
Sebastian 4 years ago
parent
commit
6378818e6f
  1. 1
      backend/i18n/frontend_en.json
  2. 1
      backend/i18n/frontend_it.json
  3. 1
      backend/i18n/frontend_nl.json
  4. 1
      backend/i18n/frontend_zh.json
  5. 1
      backend/i18n/source/frontend_en.json
  6. 2
      frontend/app/framework/angular/forms/indeterminate-value.directive.ts
  7. 2
      frontend/app/framework/angular/forms/transform-input.directive.ts
  8. 2
      frontend/app/framework/angular/stateful.component.ts
  9. 2
      frontend/app/framework/angular/video-player.component.ts
  10. 2
      frontend/app/framework/services/message-bus.service.ts
  11. 27
      frontend/app/shared/components/assets/asset-folder-dropdown-item.component.html
  12. 16
      frontend/app/shared/components/assets/asset-folder-dropdown-item.component.scss
  13. 92
      frontend/app/shared/components/assets/asset-folder-dropdown-item.component.ts
  14. 28
      frontend/app/shared/components/assets/asset-folder-dropdown.component.html
  15. 3
      frontend/app/shared/components/assets/asset-folder-dropdown.component.scss
  16. 134
      frontend/app/shared/components/assets/asset-folder-dropdown.component.ts
  17. 31
      frontend/app/shared/components/assets/asset-folder-dropdown.state.ts
  18. 4
      frontend/app/shared/components/forms/geolocation-editor.component.ts
  19. 2
      frontend/app/shared/components/schema-category.component.html
  20. 1
      frontend/app/shared/declarations.ts
  21. 3
      frontend/app/shared/module.ts
  22. 12
      frontend/app/theme/_bootstrap.scss
  23. 4
      frontend/app/theme/_forms.scss

1
backend/i18n/frontend_en.json

@ -287,6 +287,7 @@
"common.httpConflict": "Failed to make the update. Another user has made a change. Please reload.",
"common.httpLimit": "You have exceeded the maximum limit of API calls.",
"common.id": "Identity",
"common.in": "in",
"common.label": "Label",
"common.language": "Language",
"common.languages": "Languages",

1
backend/i18n/frontend_it.json

@ -287,6 +287,7 @@
"common.httpConflict": "Non è stato possibile effettuare l'aggiornamento. Un altro utente ha fatto delle modifiche. Per favore ricarica.",
"common.httpLimit": "Hai superato il limite massimo di chiamate API.",
"common.id": "Identificativo",
"common.in": "in",
"common.label": "Etichetta",
"common.language": "Lingua",
"common.languages": "Lingue",

1
backend/i18n/frontend_nl.json

@ -287,6 +287,7 @@
"common.httpConflict": "De update is mislukt. Een andere gebruiker heeft een wijziging aangebracht. Laad opnieuw.",
"common.httpLimit": "Je hebt de maximale limiet van API-aanroepen overschreden.",
"common.id": "Identity",
"common.in": "in",
"common.label": "Label",
"common.language": "Language",
"common.languages": "Talen",

1
backend/i18n/frontend_zh.json

@ -287,6 +287,7 @@
"common.httpConflict": "更新失败。其他用户进行了更改。请重新加载。",
"common.httpLimit": "您已超出 API 调用的最大限制。",
"common.id": "身份",
"common.in": "in",
"common.label": "标签",
"common.language": "语言",
"common.languages": "语言",

1
backend/i18n/source/frontend_en.json

@ -287,6 +287,7 @@
"common.httpConflict": "Failed to make the update. Another user has made a change. Please reload.",
"common.httpLimit": "You have exceeded the maximum limit of API calls.",
"common.id": "Identity",
"common.in": "in",
"common.label": "Label",
"common.language": "Language",
"common.languages": "Languages",

2
frontend/app/framework/angular/forms/indeterminate-value.directive.ts

@ -71,7 +71,7 @@ export class IndeterminateValueDirective implements ControlValueAccessor {
this.isChecked = obj;
}
public setDisabledState(isDisabled: boolean): void {
public setDisabledState(isDisabled: boolean) {
this.renderer.setProperty(this.element.nativeElement, 'disabled', isDisabled);
}

2
frontend/app/framework/angular/forms/transform-input.directive.ts

@ -76,7 +76,7 @@ export class TransformInputDirective implements ControlValueAccessor {
this.renderer.setProperty(this.element.nativeElement, 'value', normalizedValue);
}
public setDisabledState(isDisabled: boolean): void {
public setDisabledState(isDisabled: boolean) {
this.renderer.setProperty(this.element.nativeElement, 'disabled', isDisabled);
}

2
frontend/app/framework/angular/stateful.component.ts

@ -115,7 +115,7 @@ export abstract class StatefulControlComponent<T extends {}, TValue> extends Sta
this.fnChanged(value);
}
public setDisabledState(isDisabled: boolean): void {
public setDisabledState(isDisabled: boolean) {
this.next({ isDisabled } as any);
this.onDisabled(this.snapshot.isDisabled);

2
frontend/app/framework/angular/video-player.component.ts

@ -48,7 +48,7 @@ export class VideoPlayerComponent implements AfterViewInit, OnDestroy, OnChanges
}
}
public ngAfterViewInit(): void {
public ngAfterViewInit() {
Promise.all([
this.resourceLoader.loadLocalScript('dependencies/videojs/video.min.js'),
this.resourceLoader.loadLocalStyle('dependencies/videojs/video-js.min.css'),

2
frontend/app/framework/services/message-bus.service.ts

@ -21,7 +21,7 @@ interface Message {
export class MessageBus {
private message$ = new Subject<Message>();
public emit<T>(data: T): void {
public emit<T>(data: T) {
const channel = ((<any>data)['constructor']).name;
this.message$.next({ channel, data });

27
frontend/app/shared/components/assets/asset-folder-dropdown-item.component.html

@ -0,0 +1,27 @@
<div class="control-dropdown-item d-flex align-items-center" [class.active]="node.isSelected" [style]="style" (click)="selectNode.emit(node)">
<ng-container *ngIf="node.isLoading; else notLoading" class="loader">
<button type="button" class="btn btn-sm btn-decent btn-text-secondary">
<i class="icon-spinner2 spin2"></i>
</button>
</ng-container>
<ng-template #notLoading>
<button type="button" class="btn btn-sm btn-decent btn-text-secondary" (click)="toggle()" sqxStopClick [class.invisible]="node.isLoaded && node.children.length === 0">
<i [class.icon-caret-right]="!node.isExpanded || !node.isLoaded" [class.icon-caret-down]="node.isExpanded && node.isLoaded"></i>
</button>
</ng-template>
<div class="name truncate">
{{node.item.folderName | sqxTranslate}}
</div>
</div>
<div class="tree-children" *ngIf="node.isExpanded || node.isSelected">
<sqx-asset-folder-dropdown-item *ngFor="let child of node.children; trackBy: trackByNode"
[appName]="appName"
[node]="child"
[nodeLevel]="nodeLevel + 1"
(selectNode)="selectNode.emit($event)">
</sqx-asset-folder-dropdown-item>
</div>

16
frontend/app/shared/components/assets/asset-folder-dropdown-item.component.scss

@ -0,0 +1,16 @@
.loader {
font-size: 60%;
flex-grow: 0;
flex-shrink: 0;
margin-right: .25rem;
}
.btn {
padding-bottom: 0;
padding-left: 0;
padding-right: 0;
flex-grow: 0;
flex-shrink: 0;
width: 1.5rem;
text-align: center;
}

92
frontend/app/shared/components/assets/asset-folder-dropdown-item.component.ts

@ -0,0 +1,92 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { AssetsService } from '@app/shared/internal';
import { AssetFolderDropdowNode } from './asset-folder-dropdown.state';
@Component({
selector: 'sqx-asset-folder-dropdown-item[appName][node]',
styleUrls: ['./asset-folder-dropdown-item.component.scss'],
templateUrl: './asset-folder-dropdown-item.component.html',
})
export class AssetFolderDropdownItemComponent {
@Input()
public appName: string;
@Input()
public node: AssetFolderDropdowNode;
@Input()
public nodeLevel = 0;
@Output()
public selectNode = new EventEmitter<AssetFolderDropdowNode>();
public get style() {
return { paddingLeft: `${this.nodeLevel}rem` };
}
constructor(
private readonly assetsService: AssetsService,
) {
}
public toggle() {
if (this.node.isExpanded && this.node.isLoaded) {
this.collapse();
} else {
this.expand();
}
}
public collapse() {
this.node.isExpanded = false;
}
public expand() {
this.node.isExpanded = true;
this.loadChildren();
}
public loadChildren() {
if (this.node.isLoading || this.node.isLoaded) {
return;
}
this.node.isLoading = true;
this.assetsService.getAssetFolders(this.appName, this.node.item.id)
.subscribe({
next: dto => {
if (dto.items.length > 0) {
const parent = this.node;
for (const item of dto.items) {
if (!parent.children.find(x => x.item.id === item.id)) {
parent.children.push({ item, children: [], parent });
}
}
parent.children.sortByString(x => x.item.folderName);
}
this.node.isLoaded = true;
},
complete: () => {
setTimeout(() => {
this.node.isLoading = false;
}, 250);
},
});
}
public trackByNode(_index: number, node: AssetFolderDropdowNode) {
return node.item.id;
}
}

28
frontend/app/shared/components/assets/asset-folder-dropdown.component.html

@ -1,5 +1,23 @@
<sqx-dropdown [formControl]="control" [items]="snapshot.assetFolders" valueProperty="id" searchProperty="folderName">
<ng-template let-assetFolder="$implicit" let-context="context">
<span class="truncate">{{assetFolder.folderName | sqxTranslate | sqxHighlight:context}}</span>
</ng-template>
</sqx-dropdown>
<div class="selection">
<div class="form-select" #input (click)="dropdown.show()">
<div class="truncate">{{selection.item.folderName | sqxTranslate}}
<span *ngIf="selectionPath">
{{ 'i18n:common.in' | sqxTranslate}}&nbsp;./{{selectionPath}}
</span>
</div>
</div>
</div>
<div class="items-container">
<ng-container *sqxModal="dropdown">
<div class="control-dropdown" [sqxAnchoredTo]="input" position="bottom-left">
<sqx-asset-folder-dropdown-item
[appName]="appName"
[node]="root"
[nodeLevel]="0"
(selectNode)="select($event)">
</sqx-asset-folder-dropdown-item>
</div>
</ng-container>
</div>

3
frontend/app/shared/components/assets/asset-folder-dropdown.component.scss

@ -0,0 +1,3 @@
.control-dropdown {
max-width: 30rem;
}

134
frontend/app/shared/components/assets/asset-folder-dropdown.component.ts

@ -5,22 +5,16 @@
* 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 { MathHelper, StatefulControlComponent, value$ } from '@app/framework';
import { AssetPathItem, AssetsService } from '@app/shared/internal';
import { AppsState } from '@app/shared/state/apps.state';
import { ROOT_ITEM } from '@app/shared/state/assets.state';
import { ChangeDetectorRef, Component, forwardRef, Input } from '@angular/core';
import { NG_VALUE_ACCESSOR } from '@angular/forms';
import { ModalModel, StatefulControlComponent, Types } from '@app/framework';
import { AppsState, AssetsService, ROOT_ITEM } from '@app/shared/internal';
import { AssetFolderDropdowNode } from './asset-folder-dropdown.state';
export const SQX_ASSETS_FOLDER_DROPDOWN_CONTROL_VALUE_ACCESSOR: any = {
provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => AssetFolderDropdownComponent), multi: true,
};
interface State {
// The asset folders.
assetFolders: ReadonlyArray<AssetPathItem>;
}
@Component({
selector: 'sqx-asset-folder-dropdown',
styleUrls: ['./asset-folder-dropdown.component.scss'],
@ -28,52 +22,118 @@ interface State {
providers: [
SQX_ASSETS_FOLDER_DROPDOWN_CONTROL_VALUE_ACCESSOR,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AssetFolderDropdownComponent extends StatefulControlComponent<State, any> implements OnInit {
export class AssetFolderDropdownComponent extends StatefulControlComponent<any, string> {
@Input()
public set disabled(value: boolean | undefined | null) {
this.setDisabledState(value === true);
}
public control = new FormControl();
public get appName() {
return this.appsState.appName;
}
public root: AssetFolderDropdowNode = { item: ROOT_ITEM, children: [], parent: null };
public selection = this.root;
public selectionPath: string;
public dropdown = new ModalModel();
constructor(changeDetector: ChangeDetectorRef,
private readonly appsState: AppsState,
private readonly assetsService: AssetsService,
) {
super(changeDetector, {
assetFolders: [],
});
this.own(
value$(this.control)
.subscribe((value: any) => {
if (this.control.enabled) {
this.callChange(value);
this.callTouched();
}
}));
super(changeDetector, {});
}
public ngOnInit() {
this.assetsService.getAssetFolders(this.appsState.appName, MathHelper.EMPTY_GUID)
public writeValue(obj: string) {
if (!Types.isString(obj)) {
obj = ROOT_ITEM.id;
}
const node = this.findNode(this.root, obj);
if (node?.isLoaded) {
this.select(node, false);
return;
}
this.assetsService.getAssetFolders(this.appName, obj)
.subscribe(dto => {
const assetFolders = [ROOT_ITEM, ...dto.items];
let parent = this.root;
for (const item of dto.path) {
let newParent = parent.children.find(x => x.item.id === item.id);
if (!newParent) {
newParent = { item, children: [], parent };
parent.children.push(newParent);
parent.children.sortByString(x => x.item.folderName);
}
parent = newParent;
}
if (dto.items.length > 0) {
for (const item of dto.items) {
if (!parent.children.find(x => x.item.id === item.id)) {
parent.children.push({ item, children: [], parent });
}
}
this.next({ assetFolders });
parent.children.sortByString(x => x.item.folderName);
}
this.select(parent, false);
});
}
public onDisabled(isDisabled: boolean) {
if (isDisabled) {
this.control.disable({ emitEvent: false });
} else {
this.control.enable({ emitEvent: false });
public select(selected: AssetFolderDropdowNode, emit = true) {
this.resetSelected(this.root);
const path: AssetFolderDropdowNode[] = [];
let current: AssetFolderDropdowNode | null = selected.parent;
while (current) {
path.push(current);
current.isExpanded = true;
current = current.parent;
}
this.selection = selected;
this.selection.isSelected = true;
this.selectionPath = path.filter(x => x.item !== ROOT_ITEM).map(x => x.item.folderName).join('/');
if (emit) {
this.callChange(selected.item.id);
this.callTouched();
this.dropdown.hide();
}
}
public writeValue(obj: any): void {
this.control.setValue(obj || ROOT_ITEM.id);
private resetSelected(node: AssetFolderDropdowNode) {
node.isSelected = false;
for (const child of node.children) {
this.resetSelected(child);
}
}
private findNode(node: AssetFolderDropdowNode, id: string) {
if (node.item.id === id) {
return node;
}
for (const child of node.children) {
if (this.findNode(child, id)) {
return child;
}
}
return undefined;
}
}

31
frontend/app/shared/components/assets/asset-folder-dropdown.state.ts

@ -0,0 +1,31 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { AssetPathItem } from '@app/shared/internal';
export interface AssetFolderDropdowNode {
// The child folders.
children: AssetFolderDropdowNode[];
// The parent folder.
parent: AssetFolderDropdowNode | null;
// True if selected.
isSelected?: boolean;
// True if loading
isLoading?: boolean;
// True if loaded
isLoaded?: boolean;
// True if expanded
isExpanded?: boolean;
// The folder.
item: AssetPathItem;
}

4
frontend/app/shared/components/forms/geolocation-editor.component.ts

@ -120,7 +120,7 @@ export class GeolocationEditorComponent extends StatefulControlComponent<State,
}
}
private updateOSMDisabled(isDisabled: boolean): void {
private updateOSMDisabled(isDisabled: boolean) {
const update: (t: any) => any =
isDisabled ?
x => x.disable() :
@ -139,7 +139,7 @@ export class GeolocationEditorComponent extends StatefulControlComponent<State,
}
}
private updateGoogleDisabled(isDisabled: boolean): void {
private updateGoogleDisabled(isDisabled: boolean) {
const enabled = !isDisabled;
if (this.map) {

2
frontend/app/shared/components/schema-category.component.html

@ -7,7 +7,7 @@
<li class="nav-item nav-heading">
<div class="row g-0 align-items-center mb-1">
<div class="col-auto">
<button type="button" class="btn btn-sm btn-text-secondary btn-toggle" (click)="toggle()">
<button type="button" class="btn btn-sm btn-decent btn-text-secondary btn-toggle" (click)="toggle()">
<i [class.icon-caret-right]="isCollapsed" [class.icon-caret-down]="!isCollapsed"></i>
</button>
</div>

1
frontend/app/shared/declarations.ts

@ -9,6 +9,7 @@ export * from './components/app-form.component';
export * from './components/assets/asset-dialog.component';
export * from './components/assets/asset-folder-dialog.component';
export * from './components/assets/asset-folder-dropdown.component';
export * from './components/assets/asset-folder-dropdown-item.component';
export * from './components/assets/asset-folder.component';
export * from './components/assets/asset-history.component';
export * from './components/assets/asset-path.component';

3
frontend/app/shared/module.ts

@ -12,7 +12,7 @@ import { RouterModule } from '@angular/router';
import { SqxFrameworkModule } from '@app/framework';
import { MentionModule } from 'angular-mentions';
import { NgxDocViewerModule } from 'ngx-doc-viewer';
import { AppFormComponent, AppLanguagesService, AppMustExistGuard, AppsService, AppsState, AssetComponent, AssetDialogComponent, AssetFolderComponent, AssetFolderDialogComponent, AssetFolderDropdownComponent, AssetHistoryComponent, AssetPathComponent, AssetPreviewUrlPipe, AssetsListComponent, AssetsSelectorComponent, AssetsService, AssetsState, AssetTextEditorComponent, AssetUploaderComponent, AssetUploaderState, AssetUrlPipe, AuthInterceptor, AuthService, AutoSaveService, BackupsService, BackupsState, ClientsService, ClientsState, CommentComponent, CommentsComponent, CommentsService, ContentListCellDirective, ContentListFieldComponent, ContentListHeaderComponent, ContentListWidthPipe, ContentMustExistGuard, ContentsColumnsPipe, ContentSelectorComponent, ContentSelectorItemComponent, ContentsService, ContentsState, ContentStatusComponent, ContentValueComponent, ContentValueEditorComponent, ContributorsService, ContributorsState, FileIconPipe, FilterComparisonComponent, FilterLogicalComponent, FilterNodeComponent, GeolocationEditorComponent, GraphQlService, HelpComponent, HelpMarkdownPipe, HelpService, HistoryComponent, HistoryListComponent, HistoryMessagePipe, HistoryService, ImageCropperComponent, ImageFocusPointComponent, LanguagesService, LanguagesState, LoadAppsGuard, LoadLanguagesGuard, LoadSchemasGuard, MarkdownEditorComponent, MustBeAuthenticatedGuard, MustBeNotAuthenticatedGuard, NewsService, NotifoComponent, PlansService, PlansState, PreviewableType, QueryComponent, QueryListComponent, QueryPathComponent, ReferenceDropdownComponent, ReferenceInputComponent, ReferencesCheckboxesComponent, ReferencesTagsComponent, RichEditorComponent, RolesService, RolesState, RuleEventsState, RuleMustExistGuard, RuleSimulatorState, RulesService, RulesState, SavedQueriesComponent, SchemaCategoryComponent, SchemaMustExistGuard, SchemaMustExistPublishedGuard, SchemaMustNotBeSingletonGuard, SchemasService, SchemasState, SchemaTagSource, SearchFormComponent, SearchService, SortingComponent, StockPhotoService, TableHeaderComponent, TranslationsService, UIService, UIState, UnsetAppGuard, UsagesService, UserDtoPicture, UserIdPicturePipe, UserNamePipe, UserNameRefPipe, UserPicturePipe, UserPictureRefPipe, UsersProviderService, UsersService, WorkflowsService, WorkflowsState } from './declarations';
import { AppFormComponent, AppLanguagesService, AppMustExistGuard, AppsService, AppsState, AssetComponent, AssetDialogComponent, AssetFolderComponent, AssetFolderDialogComponent, AssetFolderDropdownComponent, AssetFolderDropdownItemComponent, AssetHistoryComponent, AssetPathComponent, AssetPreviewUrlPipe, AssetsListComponent, AssetsSelectorComponent, AssetsService, AssetsState, AssetTextEditorComponent, AssetUploaderComponent, AssetUploaderState, AssetUrlPipe, AuthInterceptor, AuthService, AutoSaveService, BackupsService, BackupsState, ClientsService, ClientsState, CommentComponent, CommentsComponent, CommentsService, ContentListCellDirective, ContentListFieldComponent, ContentListHeaderComponent, ContentListWidthPipe, ContentMustExistGuard, ContentsColumnsPipe, ContentSelectorComponent, ContentSelectorItemComponent, ContentsService, ContentsState, ContentStatusComponent, ContentValueComponent, ContentValueEditorComponent, ContributorsService, ContributorsState, FileIconPipe, FilterComparisonComponent, FilterLogicalComponent, FilterNodeComponent, GeolocationEditorComponent, GraphQlService, HelpComponent, HelpMarkdownPipe, HelpService, HistoryComponent, HistoryListComponent, HistoryMessagePipe, HistoryService, ImageCropperComponent, ImageFocusPointComponent, LanguagesService, LanguagesState, LoadAppsGuard, LoadLanguagesGuard, LoadSchemasGuard, MarkdownEditorComponent, MustBeAuthenticatedGuard, MustBeNotAuthenticatedGuard, NewsService, NotifoComponent, PlansService, PlansState, PreviewableType, QueryComponent, QueryListComponent, QueryPathComponent, ReferenceDropdownComponent, ReferenceInputComponent, ReferencesCheckboxesComponent, ReferencesTagsComponent, RichEditorComponent, RolesService, RolesState, RuleEventsState, RuleMustExistGuard, RuleSimulatorState, RulesService, RulesState, SavedQueriesComponent, SchemaCategoryComponent, SchemaMustExistGuard, SchemaMustExistPublishedGuard, SchemaMustNotBeSingletonGuard, SchemasService, SchemasState, SchemaTagSource, SearchFormComponent, SearchService, SortingComponent, StockPhotoService, TableHeaderComponent, TranslationsService, UIService, UIState, UnsetAppGuard, UsagesService, UserDtoPicture, UserIdPicturePipe, UserNamePipe, UserNameRefPipe, UserPicturePipe, UserPictureRefPipe, UsersProviderService, UsersService, WorkflowsService, WorkflowsState } from './declarations';
@NgModule({
imports: [
@ -29,6 +29,7 @@ import { AppFormComponent, AppLanguagesService, AppMustExistGuard, AppsService,
AssetFolderComponent,
AssetFolderDialogComponent,
AssetFolderDropdownComponent,
AssetFolderDropdownItemComponent,
AssetHistoryComponent,
AssetPathComponent,
AssetPreviewUrlPipe,

12
frontend/app/theme/_bootstrap.scss

@ -318,7 +318,6 @@ a {
&:hover {
color: $color-text;
}
}
@ -469,6 +468,17 @@ a {
&-outline-secondary {
color: $color-text-decent;
}
&-decent {
&:focus {
outline: none !important;
}
&:active,
&:focus {
box-shadow: none !important;
}
}
}
$icon-size: 4.5rem;

4
frontend/app/theme/_forms.scss

@ -180,6 +180,10 @@
.text-muted {
color: $color-white !important;
}
i {
color: $color-white !important;
}
}
&:active,

Loading…
Cancel
Save