Reworked config

- Move config meta data into REST
- seperate views for config groups
This commit is contained in:
FinnStutzenstein 2019-05-27 18:38:43 +02:00 committed by GabrielMeyer
parent bcba878e18
commit cf7a5ce714
64 changed files with 1218 additions and 660 deletions

View File

@ -2,9 +2,9 @@ import { Injectable } from '@angular/core';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { BehaviorSubject, Observable } from 'rxjs'; import { BehaviorSubject, Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { CollectionStringMapperService } from 'app/core/core-services/collection-string-mapper.service'; import { CollectionStringMapperService } from 'app/core/core-services/collection-string-mapper.service';
import { ConstantsService } from 'app/core/core-services/constants.service';
import { DataSendService } from 'app/core/core-services/data-send.service'; import { DataSendService } from 'app/core/core-services/data-send.service';
import { DataStoreService } from 'app/core/core-services/data-store.service'; import { DataStoreService } from 'app/core/core-services/data-store.service';
import { HttpService } from 'app/core/core-services/http.service'; import { HttpService } from 'app/core/core-services/http.service';
@ -13,29 +13,9 @@ import { ViewModelStoreService } from 'app/core/core-services/view-model-store.s
import { BaseRepository } from 'app/core/repositories/base-repository'; import { BaseRepository } from 'app/core/repositories/base-repository';
import { Identifiable } from 'app/shared/models/base/identifiable'; import { Identifiable } from 'app/shared/models/base/identifiable';
import { Config } from 'app/shared/models/core/config'; import { Config } from 'app/shared/models/core/config';
import { ConfigItem } from 'app/site/config/components/config-list/config-list.component';
import { ConfigTitleInformation, ViewConfig } from 'app/site/config/models/view-config'; import { ConfigTitleInformation, ViewConfig } from 'app/site/config/models/view-config';
/**
* Holds a single config item.
*/
interface ConfigItem {
/**
* The key of this config variable.
*/
key: string;
/**
* The actual view config for this variable.
*/
config: ViewConfig;
/**
* The config variable data given in the constants. This is hold here, so the view
* config can be updated with this data.
*/
data: any;
}
/** /**
* Represents a config subgroup. It can only holds items and no further groups. * Represents a config subgroup. It can only holds items and no further groups.
*/ */
@ -48,7 +28,7 @@ interface ConfigSubgroup {
/** /**
* All items in this sub group. * All items in this sub group.
*/ */
items: ConfigItem[]; configs: ViewConfig[];
} }
/** /**
@ -64,11 +44,6 @@ export interface ConfigGroup {
* A list of subgroups. * A list of subgroups.
*/ */
subgroups: ConfigSubgroup[]; subgroups: ConfigSubgroup[];
/**
* A list of config items that are not in any subgroup.
*/
items: ConfigItem[];
} }
/** /**
@ -82,22 +57,27 @@ export class ConfigRepositoryService extends BaseRepository<ViewConfig, Config,
/** /**
* Own store for config groups. * Own store for config groups.
*/ */
private configs: ConfigGroup[] = null; private configs: ConfigGroup[] | null = null;
/** /**
* Own subject for config groups. * Own subject for config groups.
*/ */
protected configListSubject: BehaviorSubject<ConfigGroup[]> = new BehaviorSubject<ConfigGroup[]>(null); private readonly configsSubject: BehaviorSubject<ConfigGroup[]> = new BehaviorSubject<ConfigGroup[]>(null);
/** /**
* Saves, if we got config variables (the structure) from the server. * Custom observer for the config
*/ */
protected gotConfigsVariables = false; public get configsObservable(): Observable<ConfigGroup[]> {
return this.configsSubject.asObservable();
}
/** /**
* Saves, if we got first configs via autoupdate or cache. * Gets an observalble for all existing (main) config groups. Just the group names
* are given with this observable.
*/ */
protected gotFirstUpdate = false; public get availableGroupsOberservable(): Observable<string[]> {
return this.configsSubject.pipe(map((groups: ConfigGroup[]) => groups.map(group => group.name)));
}
/** /**
* Constructor for ConfigRepositoryService. Requests the constants from the server and creates the config group structure. * Constructor for ConfigRepositoryService. Requests the constants from the server and creates the config group structure.
@ -114,18 +94,15 @@ export class ConfigRepositoryService extends BaseRepository<ViewConfig, Config,
viewModelStoreService: ViewModelStoreService, viewModelStoreService: ViewModelStoreService,
translate: TranslateService, translate: TranslateService,
relationManager: RelationManagerService, relationManager: RelationManagerService,
private constantsService: ConstantsService,
private http: HttpService private http: HttpService
) { ) {
super(DS, dataSend, mapperService, viewModelStoreService, translate, relationManager, Config); super(DS, dataSend, mapperService, viewModelStoreService, translate, relationManager, Config);
this.constantsService.get('ConfigVariables').subscribe(constant => { this.setSortFunction((a, b) => a.weight - b.weight);
this.createConfigStructure(constant);
this.updateConfigStructure(false, ...Object.values(this.viewModelStore)); this.getViewModelListObservable().subscribe(configs =>
this.gotConfigsVariables = true; this.updateConfigStructure(configs.filter(config => !config.hidden))
this.checkConfigStructure(); );
this.updateConfigListObservable();
});
} }
public getVerboseName = (plural: boolean = false) => { public getVerboseName = (plural: boolean = false) => {
@ -154,96 +131,50 @@ export class ConfigRepositoryService extends BaseRepository<ViewConfig, Config,
throw new Error('Config variables cannot be created'); throw new Error('Config variables cannot be created');
} }
public changedModels(ids: number[]): void {
super.changedModels(ids);
ids.forEach(id => {
this.updateConfigStructure(false, this.viewModelStore[id]);
});
this.gotFirstUpdate = true;
this.checkConfigStructure();
this.updateConfigListObservable();
}
/**
* Custom observer for the config
*/
public getConfigListObservable(): Observable<ConfigGroup[]> {
return this.configListSubject.asObservable();
}
/** /**
* Custom notification for the observers. * Custom notification for the observers.
*/ */
protected updateConfigListObservable(): void { protected updateConfigListObservable(): void {
if (this.configs) { if (this.configs) {
this.configListSubject.next(this.configs); this.configsSubject.next(this.configs);
} }
} }
/** public getConfigGroupOberservable(name: string): Observable<ConfigGroup> {
* Getter for the config structure return this.configsSubject.pipe(
*/ map((groups: ConfigGroup[]) => groups.find(group => group.name.toLowerCase() === name))
public getConfigStructure(): ConfigGroup[] { );
return this.configs;
} }
/** protected updateConfigStructure(configs: ViewConfig[]): void {
* Checks the config structure, if we got configs (first data) and the const groups: ConfigGroup[] = [];
* structure (config variables)
*/
protected checkConfigStructure(): void {
if (this.gotConfigsVariables && this.gotFirstUpdate) {
this.updateConfigStructure(true, ...Object.values(this.viewModelStore));
}
}
/** configs.forEach(config => {
* With a given (and maybe partially filled) config structure, all given view configs are put into it. if (groups.length === 0 || groups[groups.length - 1].name !== config.group) {
* @param check Whether to check, if all given configs are there (according to the config structure). groups.push({
* If check is true and one viewConfig is missing, the user will get an error message. name: config.group,
* @param viewConfigs All view configs to put into the structure subgroups: []
*/ });
protected updateConfigStructure(check: boolean, ...viewConfigs: ViewConfig[]): void { }
if (!this.configs) {
return;
}
// Map the viewConfigs to their keys. const subgroupsLength = groups[groups.length - 1].subgroups.length;
const keyConfigMap: { [key: string]: ViewConfig } = {}; if (
viewConfigs.forEach(viewConfig => { subgroupsLength === 0 ||
keyConfigMap[viewConfig.key] = viewConfig; groups[groups.length - 1].subgroups[subgroupsLength - 1].name !== config.subgroup
) {
groups[groups.length - 1].subgroups.push({
name: config.subgroup,
configs: []
});
}
groups[groups.length - 1].subgroups[groups[groups.length - 1].subgroups.length - 1].configs.push(config);
}); });
// traverse through configs structure and replace all given viewConfigs this.configsSubject.next(groups);
for (const group of this.configs) {
for (const subgroup of group.subgroups) {
for (const item of subgroup.items) {
if (keyConfigMap[item.key]) {
keyConfigMap[item.key].setConstantsInfo(item.data);
item.config = keyConfigMap[item.key];
} else if (check) {
throw new Error(
`No config variable found for "${item.key}". Please migrate the database or rebuild the servercache.`
);
}
}
}
for (const item of group.items) {
if (keyConfigMap[item.key]) {
keyConfigMap[item.key].setConstantsInfo(item.data);
item.config = keyConfigMap[item.key];
} else if (check) {
throw new Error(
`No config variable found for "${item.key}". Please migrate the database or rebuild the servercache.`
);
}
}
}
} }
/** /**
* Saves a config value. * Saves a config value. The server needs the key instead of the id to fetch the config variable.
*/ */
public async update(config: Partial<Config>, viewConfig: ViewConfig): Promise<void> { public async update(config: Partial<Config>, viewConfig: ViewConfig): Promise<void> {
const updatedConfig = viewConfig.getUpdatedModel(config); const updatedConfig = viewConfig.getUpdatedModel(config);
@ -251,43 +182,24 @@ export class ConfigRepositoryService extends BaseRepository<ViewConfig, Config,
} }
/** /**
* initially create the config structure from the given constant. * Function to update multiple settings.
* @param constant *
* @param configItems An array of `ConfigItem` with the key of the changed setting and the value for that setting.
*
* @returns Either a promise containing errors or null, if there are no errors.
*/ */
private createConfigStructure(constant: any): void { public async bulkUpdate(configItems: ConfigItem[]): Promise<{ errors: { [key: string]: string } } | null> {
this.configs = []; return await this.http.post(`/rest/core/config/bulk_update/`, configItems);
for (const group of constant) { }
const _group: ConfigGroup = {
name: group.name, /**
subgroups: [], * Function to send a `reset`-poll for every group to the server.
items: [] *
}; * @param groups The names of the groups, that should be updated.
// The server always sends subgroups. But if it has an empty name, there is no subgroup.. *
for (const subgroup of group.subgroups) { * @returns The answer of the server.
if (subgroup.name) { */
const _subgroup: ConfigSubgroup = { public async resetGroups(groups: string[]): Promise<void> {
name: subgroup.name, return await this.http.post(`/rest/core/config/reset_groups/`, groups);
items: []
};
for (const item of subgroup.items) {
_subgroup.items.push({
key: item.key,
config: null,
data: item
});
}
_group.subgroups.push(_subgroup);
} else {
for (const item of subgroup.items) {
_group.items.push({
key: item.key,
config: null,
data: item
});
}
}
}
this.configs.push(_group);
}
} }
} }

View File

@ -1,57 +1,65 @@
<mat-card <mat-card
class="block-tile" class="block-tile"
[style.display]="orientation === 'horizontal' ? 'flex' : 'block'" [style.display]="orientation === 'horizontal' ? 'flex' : 'block'"
[ngClass]="{ 'is-square': isSquare, vertical: orientation === 'vertical' }"
(click)="onClick($event)" (click)="onClick($event)"
>
<div
[ngSwitch]="blockType"
class="block-node-container"
[ngClass]="{ 'no-padding': noPaddingBlock }"
*ngIf="showBlockNode"
> >
<div [ngSwitch]="blockType" class="block-node-container"> <div
<div *ngSwitchCase="'text'" class="tile-text stretch-to-fill-parent tile-color" [style.border-radius]="orientation === 'horizontal' ? '4px 0 0 4px' : '4px 4px 0 0'"> #blockNode
<table> class="tile-text stretch-to-fill-parent tile-color"
<tbody> [style.border-radius]="orientation === 'horizontal' ? '4px 0 0 4px' : '4px 4px 0 0'"
<tr> >
<td> <ng-container *ngSwitchCase="'text'">
{{ block }} {{ block }}
</td> </ng-container>
</tr> <ng-container *ngSwitchCase="'node'">
</tbody> <ng-container [ngTemplateOutlet]="blockNodeTemplate" [ngTemplateOutletContext]="data"></ng-container>
</table> </ng-container>
</div>
<div *ngSwitchCase="'image'">
<img mat-card-image [src]="block" alt="" />
</div>
<div *ngSwitchCase="'node'" class="tile-text stretch-to-fill-parent tile-color" [style.border-radius]="orientation === 'horizontal' ? '4px 0 0 4px' : '4px 4px 0 0'">
<ng-container
[ngTemplateOutlet]="blockNode"
[ngTemplateOutletContext]="data"></ng-container>
</div> </div>
</div> </div>
<div class="tile-content-node-container"> <div class="tile-content-node-container" *ngIf="showContentNode || showActions">
<mat-card-content class="tile-content"> <mat-card-content class="tile-content">
<mat-card-title class="tile-content-title stretch-to-fill-parent" *ngIf="!only || only === 'title'"> <ng-container *ngIf="showContentNode">
{{ title }} <ng-container *ngIf="!only || only === 'title'">
</mat-card-title> <mat-card-title class="tile-content-title">
<mat-card-subtitle class="tile-content-subtitle" *ngIf="subtitle"> {{ title }}
{{ subtitle }} </mat-card-title>
</mat-card-subtitle> <mat-card-subtitle class="tile-content-subtitle" *ngIf="subtitle">
<mat-divider *ngIf="!only"></mat-divider> {{ subtitle }}
<div *ngIf="!only || only === 'content'" class="tile-content-extra"> </mat-card-subtitle>
<ng-container </ng-container>
[ngTemplateOutlet]="contentNode" <mat-divider *ngIf="!only"></mat-divider>
[ngTemplateOutletContext]="data"></ng-container> <div
</div> *ngIf="!only || only === 'content'"
<mat-card-actions *ngIf="showActions"> class="tile-content-extra"
<ng-container [ngClass]="{ 'only-content': only === 'content' }"
[ngTemplateOutlet]="actionNode"></ng-container> #contentNode
>
<ng-container
[ngTemplateOutlet]="contentNodeTemplate"
[ngTemplateOutletContext]="data"
></ng-container>
</div>
</ng-container>
<mat-card-actions *ngIf="showActions" #actionNode>
<ng-container [ngTemplateOutlet]="actionNodeTemplate"></ng-container>
</mat-card-actions> </mat-card-actions>
</mat-card-content> </mat-card-content>
</div> </div>
</mat-card> </mat-card>
<ng-template #blockNode> <ng-template #blockNodeTemplate>
<ng-content select=".block-node"></ng-content> <ng-content select=".block-node"></ng-content>
</ng-template> </ng-template>
<ng-template #contentNode> <ng-template #contentNodeTemplate>
<ng-content select=".block-content-node"></ng-content> <ng-content select=".block-content-node"></ng-content>
</ng-template> </ng-template>
<ng-template #actionNode> <ng-template #actionNodeTemplate>
<ng-content select=".block-action-node"></ng-content> <ng-content select=".block-action-node"></ng-content>
</ng-template> </ng-template>

View File

@ -3,11 +3,34 @@
.block-tile { .block-tile {
padding: 0; padding: 0;
&.is-square:not(.vertical) {
.block-node-container {
padding-bottom: 100%;
}
}
&.vertical {
&.is-square {
padding-bottom: 100%;
height: 0;
}
.block-node-container {
padding-bottom: 50%;
}
.tile-content-node-container {
margin: 0;
}
}
.block-node-container { .block-node-container {
position: relative; position: relative;
padding-bottom: 50%;
min-width: 30%; min-width: 30%;
&:not(.no-padding) {
padding-bottom: 50%;
}
.tile-text { .tile-text {
table { table {
height: 100%; height: 100%;
@ -20,9 +43,8 @@
} }
.tile-content-node-container { .tile-content-node-container {
position: relative;
width: 100%; width: 100%;
margin: 8px 16px !important; margin: 8px 16px;
.tile-content { .tile-content {
margin-bottom: 0; margin-bottom: 0;
@ -47,7 +69,7 @@
} }
} }
.tile-content-extra { .tile-content-extra:not(.only-content) {
padding-top: 8px; padding-top: 8px;
} }
} }

View File

@ -1,39 +1,24 @@
import { Component, ContentChild, Input, TemplateRef } from '@angular/core'; import { AfterViewInit, ChangeDetectorRef, Component, ElementRef, Input, ViewChild } from '@angular/core';
import { TileComponent } from '../tile/tile.component'; import { TileComponent } from '../tile/tile.component';
/**
* Enumeration to define if the content is only text or a node.
*/
export enum ContentType {
text = 'text',
node = 'node'
}
/** /**
* Enumeration to define of which the big block is. * Enumeration to define of which the big block is.
*/ */
export enum BlockType { export enum BlockType {
text = 'text', text = 'text',
node = 'node', node = 'node'
picture = 'picture'
} }
/** /**
* Tells, whether to align the block and content next to each other or one below the other. * Tells, whether to align the block and content next to each other or one below the other.
*/ */
export enum Orientation { export type Orientation = 'horizontal' | 'vertical';
horizontal = 'horizontal',
vertical = 'vertical'
}
/** /**
* Tells, if the tile should only display the content or the title in the content part. * Tells, if the tile should only display the content or the title in the content part.
*/ */
export enum ShowOnly { export type ShowOnly = 'title' | 'content' | null;
title = 'title',
content = 'content'
}
/** /**
* Class, that extends the `tile.component`. * Class, that extends the `tile.component`.
@ -45,30 +30,42 @@ export enum ShowOnly {
templateUrl: './block-tile.component.html', templateUrl: './block-tile.component.html',
styleUrls: ['./block-tile.component.scss'] styleUrls: ['./block-tile.component.scss']
}) })
export class BlockTileComponent extends TileComponent { export class BlockTileComponent extends TileComponent implements AfterViewInit {
/** /**
* Reference to the content of the content part. * Reference to the content of the content part.
*/ */
@ContentChild(TemplateRef, { static: true }) @ViewChild('contentNode', { static: false })
public contentNode: TemplateRef<any>; public contentNode: ElementRef<HTMLElement>;
/** /**
* Reference to the block part, if it is a node. * Reference to the block part, if it is a node.
*/ */
@ContentChild(TemplateRef, { static: true }) @ViewChild('blockNode', { static: false })
public blockNode: TemplateRef<any>; public blockNode: ElementRef<HTMLElement>;
/** /**
* Reference to the action buttons in the content part, if used. * Reference to the action buttons in the content part, if used.
*/ */
@ContentChild(TemplateRef, { static: true }) @ViewChild('actionNode', { static: false })
public actionNode: TemplateRef<any>; public actionNode: ElementRef<HTMLElement>;
/** /**
* Defines the type of the primary block. * Defines the type of the primary block.
*/ */
@Input() @Input()
public blockType: BlockType; public blockType: BlockType = BlockType.node;
/**
* Manually remove the padding, the block is surrounded by.
*/
@Input()
public noPaddingBlock = false;
/**
* Renders the block-tile as a square.
*/
@Input()
public isSquare = false;
/** /**
* Input for the primary block content. * Input for the primary block content.
@ -77,12 +74,6 @@ export class BlockTileComponent extends TileComponent {
@Input() @Input()
public block: string; public block: string;
/**
* Defines the type of the content.
*/
@Input()
public contentType: ContentType;
/** /**
* The title in the content part. * The title in the content part.
*/ */
@ -100,17 +91,130 @@ export class BlockTileComponent extends TileComponent {
* whether the block part should be displayed above the content or next to it. * whether the block part should be displayed above the content or next to it.
*/ */
@Input() @Input()
public orientation: Orientation; public orientation: Orientation = 'horizontal';
/** /**
* Tells, whether the tile should display only one of `Title` or `Content` in the content part. * Tells, whether the tile should display only one of `Title` or `Content` in the content part.
*/ */
@Input() @Input()
public only: ShowOnly; public only: ShowOnly = null;
/**
* Boolean, if the block-part of the tile is shown or not.
*/
private _showBlockNode: boolean;
/**
* To decide, whether the block-node should always be shown.
* Otherwise this will decide automatically.
*
* @param show Whether the block-part should be shown or not.
*/
@Input()
public set showBlockNode(show: boolean) {
this._showBlockNode = show;
}
/**
* @returns A boolean whether the block-part of the tile should be shown.
* If this is not set manually, it will return `true` for the first time to check,
* if this part contains any nodes.
*/
public get showBlockNode(): boolean {
return typeof this._showBlockNode === 'undefined' ? true : this._showBlockNode;
}
/**
* Boolean, if the content-part of the tile is shown or not.
*/
private _showContentNode: boolean;
/**
* To decide, whether the content-node should always be shown.
* Otherwise this will decide automatically.
*
* @param show Whether the content-part should be shown or not.
*/
@Input()
public set showContentNode(show: boolean) {
this._showContentNode = show;
}
/**
* @returns A boolean whether the content-part of the tile should be shown.
* If this is not set manually, it will return `true` for the first time to check,
* if this part contains any nodes.
*/
public get showContentNode(): boolean {
return typeof this._showContentNode === 'undefined'
? true
: this._showContentNode || !!this.only || !!this.title;
}
/**
* Boolean, if the part with actions of the tile is shown or not.
*/
private _showActionNode: boolean;
/** /**
* Boolean, whether to show action buttons in the content part. * Boolean, whether to show action buttons in the content part.
*
* @param show Whether the action-part should be shown or not.
*/ */
@Input() @Input()
public showActions: boolean; public set showActions(show: boolean) {
this._showActionNode = show;
}
/**
* @returns A boolean whether the action-part of the tile should be shown.
* If this is not set manually, it will return `true` for the first time to check,
* if this part contains any nodes.
*/
public get showActions(): boolean {
return typeof this._showActionNode === 'undefined' ? true : this._showActionNode;
}
/**
* Default constructor.
*
* @param cd ChangeDetectorRef
*/
public constructor(private cd: ChangeDetectorRef) {
super();
}
/**
* AfterViewInit.
*
* Here it will check, if the visibility of the three parts of the tile is set manually.
* If not, it will check, if the parts contain nodes to display or not.
*/
public ngAfterViewInit(): void {
if (typeof this._showBlockNode === 'undefined') {
this.showBlockNode = this.checkForContent(this.blockNode);
}
if (typeof this._showContentNode === 'undefined') {
this.showContentNode = this.checkForContent(this.contentNode);
}
if (typeof this._showActionNode === 'undefined') {
this.showActions = this.checkForContent(this.actionNode);
}
this.cd.detectChanges();
}
/**
* Function to test, whether the child-nodes of the given parent-element
* are a comment or not. If not, then the parent-element contains content to display.
*
* @param parentElement The element whose child-nodes are tested.
*
* @returns `True`, if there is at least one node other than a comment.
*/
private checkForContent(parentElement: ElementRef<HTMLElement>): boolean {
if (!parentElement) {
return false;
}
return Array.from(parentElement.nativeElement.childNodes).some(item => item.nodeType !== 8);
}
} }

View File

@ -29,7 +29,7 @@
<div class="spacer"></div> <div class="spacer"></div>
<!-- Button to open the global search --> <!-- Button to open the global search -->
<button *ngIf="!editMode" mat-icon-button (click)="openSearch()"> <button *ngIf="!editMode && isSearchEnabled" mat-icon-button (click)="openSearch()">
<mat-icon>search</mat-icon> <mat-icon>search</mat-icon>
</button> </button>
@ -40,7 +40,8 @@
<!-- Main action button - desktop --> <!-- Main action button - desktop -->
<button <button
mat-icon-button mat-icon-button
*ngIf="mainButton && !editMode && !vp.isMobile && !multiSelectMode" *ngIf="hasMainButton && !editMode && !vp.isMobile && !multiSelectMode"
[disabled]="!isMainButtonEnabled"
(click)="sendMainEvent()" (click)="sendMainEvent()"
matTooltip="{{ mainActionTooltip | translate }}" matTooltip="{{ mainActionTooltip | translate }}"
> >
@ -62,7 +63,8 @@
<button <button
mat-fab mat-fab
class="head-button" class="head-button"
*ngIf="mainButton && !editMode && vp.isMobile && !multiSelectMode" *ngIf="hasMainButton && !editMode && vp.isMobile && !multiSelectMode"
[disabled]="!isMainButtonEnabled"
(click)="sendMainEvent()" (click)="sendMainEvent()"
matTooltip="{{ mainActionTooltip | translate }}" matTooltip="{{ mainActionTooltip | translate }}"
> >

View File

@ -21,7 +21,7 @@ import { ViewportService } from 'app/core/ui-services/viewport.service';
* saveText="Create" * saveText="Create"
* [nav]="false" * [nav]="false"
* [goBack]="true" * [goBack]="true"
* [mainButton]="opCanEdit()" * [hasMainButton]="opCanEdit()"
* [mainButtonIcon]="edit" * [mainButtonIcon]="edit"
* [backButtonIcon]="arrow_back" * [backButtonIcon]="arrow_back"
* [editMode]="editMotion" * [editMode]="editMotion"
@ -82,6 +82,12 @@ export class HeadBarComponent implements OnInit {
@Input() @Input()
public editMode = false; public editMode = false;
/**
* Determine, if the search should not be available.
*/
@Input()
public isSearchEnabled = true;
/** /**
* The save button can manually be disabled. * The save button can manually be disabled.
*/ */
@ -98,7 +104,13 @@ export class HeadBarComponent implements OnInit {
* Determine if there should be the main action button * Determine if there should be the main action button
*/ */
@Input() @Input()
public mainButton = false; public hasMainButton = false;
/**
* Determine if the main action button should be enabled or not.
*/
@Input()
public isMainButtonEnabled = true;
/** /**
* Set to true if the component should use location.back instead * Set to true if the component should use location.back instead

View File

@ -1,4 +1,5 @@
<ng-container <ng-container [ngTemplateOutlet]="tileContext" [ngTemplateOutletContext]="data"> </ng-container>
[ngTemplateOutlet]="tileContext"
[ngTemplateOutletContext]="data"> <ng-template #tileContext>
</ng-container> <ng-content></ng-content>
</ng-template>

View File

@ -149,7 +149,7 @@ export class TileComponent implements OnInit {
* *
* @param size how great the tile should be * @param size how great the tile should be
*/ */
private setTabletSize(size: number): void { private setTabletSize(size: number = 4): void {
if (size <= 8 && size >= 0) { if (size <= 8 && size >= 0) {
this.tabletSize = size; this.tabletSize = size;
} else { } else {
@ -162,7 +162,7 @@ export class TileComponent implements OnInit {
* *
* @param size how great the tile should be * @param size how great the tile should be
*/ */
private setMediumSize(size: number): void { private setMediumSize(size: number = 4): void {
if (size <= 12 && size >= 0) { if (size <= 12 && size >= 0) {
this.mediumSize = size; this.mediumSize = size;
} else { } else {
@ -175,7 +175,7 @@ export class TileComponent implements OnInit {
* *
* @param size how great the tile should be * @param size how great the tile should be
*/ */
private setLargeSize(size: number): void { private setLargeSize(size: number = 4): void {
if (size <= 16 && size >= 0) { if (size <= 16 && size >= 0) {
this.largeSize = size; this.largeSize = size;
} else { } else {

View File

@ -1,5 +1,35 @@
import { BaseModel } from '../base/base-model'; import { BaseModel } from '../base/base-model';
export interface ConfigChoice {
value: string;
displayName: string;
}
/**
* All valid input types for config variables.
*/
export type ConfigInputType =
| 'text'
| 'string'
| 'boolean'
| 'markupText'
| 'integer'
| 'choice'
| 'datetimepicker'
| 'colorpicker'
| 'translations';
export interface ConfigData {
defaultValue: any;
inputType: ConfigInputType;
label: string;
helpText?: string;
choices?: ConfigChoice[];
weight: number;
group: string;
subgroup?: string;
}
/** /**
* Representation of a config variable * Representation of a config variable
* @ignore * @ignore
@ -8,7 +38,8 @@ export class Config extends BaseModel {
public static COLLECTIONSTRING = 'core/config'; public static COLLECTIONSTRING = 'core/config';
public id: number; public id: number;
public key: string; public key: string;
public value: Object; public value: any;
public data?: ConfigData;
public constructor(input?: any) { public constructor(input?: any) {
super(Config.COLLECTIONSTRING, input); super(Config.COLLECTIONSTRING, input);

View File

@ -1,15 +1,15 @@
import { inject, TestBed } from '@angular/core/testing'; import { inject, TestBed } from '@angular/core/testing';
import { WatchSortingTreeGuard } from './watch-sorting-tree.guard'; import { WatchForChangesGuard } from './watch-for-changes.guard';
describe('WatchSortingTreeGuard', () => { describe('WatchSortingTreeGuard', () => {
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [WatchSortingTreeGuard] providers: [WatchForChangesGuard]
}); });
}); });
it('should ...', inject([WatchSortingTreeGuard], (guard: WatchSortingTreeGuard) => { it('should ...', inject([WatchForChangesGuard], (guard: WatchForChangesGuard) => {
expect(guard).toBeTruthy(); expect(guard).toBeTruthy();
})); }));
}); });

View File

@ -11,7 +11,7 @@ export interface CanComponentDeactivate {
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
export class WatchSortingTreeGuard implements CanDeactivate<CanComponentDeactivate> { export class WatchForChangesGuard implements CanDeactivate<CanComponentDeactivate> {
/** /**
* Function to determine whether the route will change or not. * Function to determine whether the route will change or not.
* *

View File

@ -4,7 +4,7 @@ import { RouterModule, Routes } from '@angular/router';
import { AgendaImportListComponent } from './components/agenda-import-list/agenda-import-list.component'; import { AgendaImportListComponent } from './components/agenda-import-list/agenda-import-list.component';
import { AgendaListComponent } from './components/agenda-list/agenda-list.component'; import { AgendaListComponent } from './components/agenda-list/agenda-list.component';
import { AgendaSortComponent } from './components/agenda-sort/agenda-sort.component'; import { AgendaSortComponent } from './components/agenda-sort/agenda-sort.component';
import { WatchSortingTreeGuard } from 'app/shared/utils/watch-sorting-tree.guard'; import { WatchForChangesGuard } from 'app/shared/utils/watch-for-changes.guard';
import { ListOfSpeakersComponent } from './components/list-of-speakers/list-of-speakers.component'; import { ListOfSpeakersComponent } from './components/list-of-speakers/list-of-speakers.component';
const routes: Routes = [ const routes: Routes = [
@ -13,7 +13,7 @@ const routes: Routes = [
{ {
path: 'sort-agenda', path: 'sort-agenda',
component: AgendaSortComponent, component: AgendaSortComponent,
canDeactivate: [WatchSortingTreeGuard], canDeactivate: [WatchForChangesGuard],
data: { basePerm: 'agenda.can_manage' } data: { basePerm: 'agenda.can_manage' }
}, },
{ path: 'speakers', component: ListOfSpeakersComponent, data: { basePerm: 'agenda.can_see_list_of_speakers' } }, { path: 'speakers', component: ListOfSpeakersComponent, data: { basePerm: 'agenda.can_see_list_of_speakers' } },

View File

@ -1,4 +1,4 @@
<os-head-bar [mainButton]="canManage" (mainEvent)="onPlusButton()" [multiSelectMode]="isMultiSelect"> <os-head-bar [hasMainButton]="canManage" (mainEvent)="onPlusButton()" [multiSelectMode]="isMultiSelect">
<!-- Title --> <!-- Title -->
<div class="title-slot"><h2 translate>Agenda</h2></div> <div class="title-slot"><h2 translate>Agenda</h2></div>
<!-- Menu --> <!-- Menu -->
@ -119,7 +119,13 @@
<!-- Import --> <!-- Import -->
<button mat-menu-item *osPerms="'agenda.can_manage'" routerLink="import"> <button mat-menu-item *osPerms="'agenda.can_manage'" routerLink="import">
<mat-icon>cloud_upload</mat-icon> <mat-icon>cloud_upload</mat-icon>
<span translate>Import</span><span>&nbsp;...</span> <span translate>Import</span>
</button>
<mat-divider></mat-divider>
<!-- Settings -->
<button mat-menu-item *osPerms="'core.can_manage_config'" routerLink="/settings/agenda">
<mat-icon>settings</mat-icon>
<span translate>Settings</span>
</button> </button>
</div> </div>

View File

@ -1,5 +1,5 @@
<os-head-bar <os-head-bar
[mainButton]="hasPerms('manage')" [hasMainButton]="hasPerms('manage')"
mainButtonIcon="edit" mainButtonIcon="edit"
[nav]="false" [nav]="false"
[editMode]="editAssignment" [editMode]="editAssignment"

View File

@ -1,5 +1,5 @@
<os-head-bar <os-head-bar
[mainButton]="operator.hasPerms('assignments.can_manage')" [hasMainButton]="operator.hasPerms('assignments.can_manage')"
(mainEvent)="onPlusButton()" (mainEvent)="onPlusButton()"
[multiSelectMode]="isMultiSelect" [multiSelectMode]="isMultiSelect"
> >
@ -91,6 +91,12 @@
<mat-icon>archive</mat-icon> <mat-icon>archive</mat-icon>
<span translate>Export ...</span> <span translate>Export ...</span>
</button> </button>
<mat-divider></mat-divider>
<!-- Settings -->
<button mat-menu-item *osPerms="'core.can_manage_config'" routerLink="/settings/elections">
<mat-icon>settings</mat-icon>
<span translate>Settings</span>
</button>
</div> </div>
<div *ngIf="isMultiSelect"> <div *ngIf="isMultiSelect">

View File

@ -8,7 +8,7 @@ import { SortDefinition } from 'app/core/ui-services/base-sort.service';
import { PromptService } from 'app/core/ui-services/prompt.service'; import { PromptService } from 'app/core/ui-services/prompt.service';
import { SortingTreeComponent } from 'app/shared/components/sorting-tree/sorting-tree.component'; import { SortingTreeComponent } from 'app/shared/components/sorting-tree/sorting-tree.component';
import { Identifiable } from 'app/shared/models/base/identifiable'; import { Identifiable } from 'app/shared/models/base/identifiable';
import { CanComponentDeactivate } from 'app/shared/utils/watch-sorting-tree.guard'; import { CanComponentDeactivate } from 'app/shared/utils/watch-for-changes.guard';
import { BaseViewComponent } from './base-view'; import { BaseViewComponent } from './base-view';
import { BaseViewModel } from './base-view-model'; import { BaseViewModel } from './base-view-model';

View File

@ -15,7 +15,7 @@
<!-- required for all kinds of input --> <!-- required for all kinds of input -->
<mat-label>{{ configItem.label | translate }}</mat-label> <mat-label>{{ configItem.label | translate }}</mat-label>
<span matSuffix> <span matSuffix>
<mat-icon pull="right" class="text-success" *ngIf="updateSuccessIcon">check_circle</mat-icon> <mat-icon pull="right" class="red-warning-text" *ngIf="error">error</mat-icon>
</span> </span>
<mat-error *ngIf="error"> {{ error }} </mat-error> <mat-error *ngIf="error"> {{ error }} </mat-error>
@ -57,7 +57,7 @@
[value]="translatedValue" [value]="translatedValue"
></textarea> ></textarea>
<span matSuffix> <span matSuffix>
<mat-icon pull="right" class="text-success" *ngIf="updateSuccessIcon"> <mat-icon pull="right" class="red-warning-text" *ngIf="updateSuccessIcon">
check_circle check_circle
</mat-icon> </mat-icon>
</span> </span>
@ -77,7 +77,7 @@
/> />
<mat-hint *ngIf="configItem.helpText">{{ configItem.helpText | translate }}</mat-hint> <mat-hint *ngIf="configItem.helpText">{{ configItem.helpText | translate }}</mat-hint>
<div class="suffix-wrapper" matSuffix> <div class="suffix-wrapper" matSuffix>
<mat-icon class="text-success" *ngIf="updateSuccessIcon">check_circle</mat-icon> <mat-icon class="red-warning-text" *ngIf="updateSuccessIcon">check_circle</mat-icon>
<mat-datepicker-toggle <mat-datepicker-toggle
[for]="datepicker" [for]="datepicker"
(click)="$event.preventDefault()" (click)="$event.preventDefault()"
@ -89,7 +89,7 @@
<mat-form-field> <mat-form-field>
<input matInput [format]="24" formControlName="time" [ngxTimepicker]="timepicker" /> <input matInput [format]="24" formControlName="time" [ngxTimepicker]="timepicker" />
<div class="suffix-wrapper" matSuffix> <div class="suffix-wrapper" matSuffix>
<mat-icon class="text-success" *ngIf="updateSuccessIcon">check_circle</mat-icon> <mat-icon class="red-warning-text" *ngIf="updateSuccessIcon">error</mat-icon>
<ngx-material-timepicker-toggle [for]="timepicker"></ngx-material-timepicker-toggle> <ngx-material-timepicker-toggle [for]="timepicker"></ngx-material-timepicker-toggle>
</div> </div>
<mat-error *ngIf="error"> {{ error }} </mat-error> <mat-error *ngIf="error"> {{ error }} </mat-error>
@ -103,16 +103,13 @@
<h4>{{ configItem.label | translate }}</h4> <h4>{{ configItem.label | translate }}</h4>
<editor formControlName="value" [init]="getTinyMceSettings()"></editor> <editor formControlName="value" [init]="getTinyMceSettings()"></editor>
<span matSuffix> <span matSuffix>
<mat-icon pull="right" class="text-success" *ngIf="updateSuccessIcon">check_circle</mat-icon> <mat-icon pull="right" class="red-warning-text" *ngIf="updateSuccessIcon">error</mat-icon>
</span> </span>
</div> </div>
<!-- Custom Translations --> <!-- Custom Translations -->
<div *ngIf="configItem.inputType === 'translations'"> <div *ngIf="configItem.inputType === 'translations'">
<os-custom-translation formControlName="value"></os-custom-translation> <os-custom-translation formControlName="value"></os-custom-translation>
<span matSuffix>
<mat-icon pull="right" class="text-success" *ngIf="updateSuccessIcon">check_circle</mat-icon>
</span>
</div> </div>
</div> </div>
</form> </form>
@ -122,9 +119,4 @@
<mat-icon>help_outline</mat-icon> <mat-icon>help_outline</mat-icon>
</button> </button>
</div> </div>
<div class="reset-button">
<button mat-icon-button *ngIf="hasDefault()" matTooltip="{{ 'Reset' | translate }}" (click)="onResetButton()">
<mat-icon>replay</mat-icon>
</button>
</div>
</div> </div>

View File

@ -2,9 +2,11 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
EventEmitter,
Input, Input,
OnDestroy, OnDestroy,
OnInit, OnInit,
Output,
ViewEncapsulation ViewEncapsulation
} from '@angular/core'; } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms'; import { FormBuilder, FormGroup } from '@angular/forms';
@ -18,6 +20,7 @@ import { distinctUntilChanged } from 'rxjs/operators';
import { BaseComponent } from 'app/base.component'; import { BaseComponent } from 'app/base.component';
import { ConfigRepositoryService } from 'app/core/repositories/config/config-repository.service'; import { ConfigRepositoryService } from 'app/core/repositories/config/config-repository.service';
import { ParentErrorStateMatcher } from 'app/shared/parent-error-state-matcher'; import { ParentErrorStateMatcher } from 'app/shared/parent-error-state-matcher';
import { ConfigItem } from '../config-list/config-list.component';
import { ViewConfig } from '../../models/view-config'; import { ViewConfig } from '../../models/view-config';
/** /**
@ -26,7 +29,7 @@ import { ViewConfig } from '../../models/view-config';
* *
* @example * @example
* ```ts * ```ts
* <os-config-field [item]="item.config"></os-config-field> * <os-config-field [config]="<ViewConfig>"></os-config-field>
* ``` * ```
*/ */
@Component({ @Component({
@ -44,16 +47,6 @@ export class ConfigFieldComponent extends BaseComponent implements OnInit, OnDes
*/ */
public updateSuccessIcon = false; public updateSuccessIcon = false;
/**
* The timeout for the success icon to hide.
*/
private updateSuccessIconTimeout: number | null = null;
/**
* The debounce timeout for inputs request delay.
*/
private debounceTimeout: number | null = null;
/** /**
* A possible error send by the server. * A possible error send by the server.
*/ */
@ -69,8 +62,8 @@ export class ConfigFieldComponent extends BaseComponent implements OnInit, OnDes
* populated constants-info. * populated constants-info.
*/ */
@Input() @Input()
public set item(value: ViewConfig) { public set config(value: ViewConfig) {
if (value && value.hasConstantsInfo) { if (value) {
this.configItem = value; this.configItem = value;
if (this.form) { if (this.form) {
@ -90,6 +83,25 @@ export class ConfigFieldComponent extends BaseComponent implements OnInit, OnDes
} }
} }
/**
* Passes the list of errors as object.
*
* The function looks, if the key of this config-item is contained in the list.
*
* @param errorList The object containing all errors.
*/
@Input()
public set errorList(errorList: { [key: string]: any }) {
const hasError = Object.keys(errorList).find(errorKey => errorKey === this.configItem.key);
if (hasError) {
this.error = errorList[hasError];
this.updateError(true);
} else {
this.error = null;
this.updateError(null);
}
}
/** /**
* The form for this configItem. * The form for this configItem.
*/ */
@ -100,6 +112,9 @@ export class ConfigFieldComponent extends BaseComponent implements OnInit, OnDes
*/ */
public matcher = new ParentErrorStateMatcher(); public matcher = new ParentErrorStateMatcher();
@Output()
public update = new EventEmitter<ConfigItem>();
/** /**
* The usual component constructor. datetime pickers will set their locale * The usual component constructor. datetime pickers will set their locale
* to the current language chosen * to the current language chosen
@ -172,7 +187,7 @@ export class ConfigFieldComponent extends BaseComponent implements OnInit, OnDes
private unixToDateAndTime(unix: number): { date: Moment; time: string } { private unixToDateAndTime(unix: number): { date: Moment; time: string } {
const date = moment.unix(unix); const date = moment.unix(unix);
const time = date.hours() + ':' + date.minutes(); const time = date.hours() + ':' + date.minutes();
return { date: date, time: time }; return { date, time };
} }
/** /**
@ -211,12 +226,7 @@ export class ConfigFieldComponent extends BaseComponent implements OnInit, OnDes
const time = this.form.get('time').value; const time = this.form.get('time').value;
value = this.dateAndTimeToUnix(date, time); value = this.dateAndTimeToUnix(date, time);
} }
if (this.debounceTimeout !== null) { this.sendUpdate(value);
clearTimeout(<any>this.debounceTimeout);
}
this.debounceTimeout = <any>setTimeout(() => {
this.update(value);
}, this.configItem.getDebouncingTimeout());
this.cd.detectChanges(); this.cd.detectChanges();
} }
@ -233,53 +243,22 @@ export class ConfigFieldComponent extends BaseComponent implements OnInit, OnDes
* Sends an update request for the config item to the server. * Sends an update request for the config item to the server.
* @param value The new value to set. * @param value The new value to set.
*/ */
private update(value: any): void { private sendUpdate(value: any): void {
this.debounceTimeout = null; this.update.emit({ key: this.configItem.key, value });
this.repo.update({ value: value }, this.configItem).then(() => {
this.error = null;
this.showSuccessIcon();
}, this.setError.bind(this));
} }
/** /**
* Show the green success icon on the component. The icon gets automatically cleared. * Function to update the form-control to display or hide an error.
*
* @param error `true | false`, if an error should be shown. `null`, if there is no error.
*/ */
private showSuccessIcon(): void { private updateError(error: boolean | null): void {
if (this.updateSuccessIconTimeout !== null) { if (this.form) {
clearTimeout(<any>this.updateSuccessIconTimeout); this.form.setErrors(error ? { error } : null);
}
this.updateSuccessIconTimeout = <any>setTimeout(() => {
this.updateSuccessIcon = false;
if (!this.wasViewDestroyed()) {
this.cd.detectChanges();
}
}, 2000);
this.updateSuccessIcon = true;
if (!this.wasViewDestroyed()) {
this.cd.detectChanges(); this.cd.detectChanges();
} }
} }
/**
* @returns true, if the view was destroyed. Note: This
* needs to access internal attributes from the change detection
* reference.
*/
private wasViewDestroyed(): boolean {
return (<any>this.cd).destroyed;
}
/**
* Sets the error on this field.
*
* @param error The error as string.
*/
private setError(error: string): void {
this.error = error;
this.form.setErrors({ error: true });
this.cd.detectChanges();
}
/** /**
* Uses the configItem to determine the kind of interation: * Uses the configItem to determine the kind of interation:
* input, textarea, choice or date * input, textarea, choice or date
@ -311,16 +290,6 @@ export class ConfigFieldComponent extends BaseComponent implements OnInit, OnDes
return excluded.includes(type); return excluded.includes(type);
} }
/**
* Determines if a reset buton should be offered.
* TODO: is 'null' a valid default in some cases?
*
* @returns true if any default exists
*/
public hasDefault(): boolean {
return this.configItem.defaultValue !== undefined && this.configItem.defaultValue !== null;
}
/** /**
* Amends the application-wide tinyMCE settings with update triggers that * Amends the application-wide tinyMCE settings with update triggers that
* send updated values only after leaving focus (Blur) or closing the editor (Remove) * send updated values only after leaving focus (Blur) or closing the editor (Remove)
@ -333,7 +302,7 @@ export class ConfigFieldComponent extends BaseComponent implements OnInit, OnDes
setup: editor => { setup: editor => {
editor.on('Blur', ev => { editor.on('Blur', ev => {
if (ev.target.getContent() !== this.translatedValue) { if (ev.target.getContent() !== this.translatedValue) {
this.update(ev.target.getContent()); this.sendUpdate(ev.target.getContent());
} }
}); });
editor.on('Remove', ev => { editor.on('Remove', ev => {
@ -341,7 +310,7 @@ export class ConfigFieldComponent extends BaseComponent implements OnInit, OnDes
// fast navigation, when the editor is not fully loaded. Then the content is empty // fast navigation, when the editor is not fully loaded. Then the content is empty
// and would trigger an update with empty data. // and would trigger an update with empty data.
if (ev.target.getContent() && ev.target.getContent() !== this.translatedValue) { if (ev.target.getContent() && ev.target.getContent() !== this.translatedValue) {
this.update(ev.target.getContent()); this.sendUpdate(ev.target.getContent());
} }
}); });
} }

View File

@ -1,28 +1,45 @@
<os-head-bar> <os-head-bar [nav]="false" [hasMainButton]="false" [isSearchEnabled]="false">
<!-- Title --> <!-- Title -->
<div class="title-slot"> <div class="title-slot">
<h2 translate>Settings</h2> <h2 *ngIf="configGroup">
{{ configGroup.name | translate }}
</h2>
</div>
<!-- Menu -->
<div class="menu-slot">
<button
mat-button
[disabled]="!hasChanges()"
(click)="saveAll()"
matTooltip="{{ 'Save all changes' | translate }}"
>
<strong class="upper">{{ 'Save' | translate }}</strong>
</button>
<button type="button" mat-icon-button [matMenuTriggerFor]="settingsMenu"><mat-icon>more_vert</mat-icon></button>
</div> </div>
</os-head-bar> </os-head-bar>
<div class="spacer-top-20"></div> <div class="spacer-top-20"></div>
<mat-accordion>
<ng-container *ngFor="let group of this.configs"> <mat-card class="os-card" *ngIf="configGroup">
<mat-expansion-panel displayMode="flat"> <div id="wrapper">
<mat-expansion-panel-header> <ng-container *ngFor="let subgroup of configGroup.subgroups; trackBy: trackByIndex">
<mat-panel-title> <h3 class="accent" *ngIf="configGroup.subgroups.length > 1">{{ subgroup.name | translate }}</h3>
{{ group.name | translate }} <ng-container *ngFor="let config of subgroup.configs">
</mat-panel-title> <os-config-field
</mat-expansion-panel-header> (update)="updateConfigGroup($event)"
<div *ngFor="let subgroup of group.subgroups"> [config]="config"
<h3 class="accent">{{ subgroup.name | translate }}</h3> [errorList]="errors"
<div *ngFor="let item of subgroup.items"> ></os-config-field>
<os-config-field [item]="item.config"></os-config-field> </ng-container>
</div> </ng-container>
</div> </div>
<div *ngFor="let item of group.items"> </mat-card>
<os-config-field [item]="item.config"></os-config-field>
</div> <mat-menu #settingsMenu="matMenu">
</mat-expansion-panel> <button mat-menu-item (click)="resetAll()">
</ng-container> <mat-icon>undo</mat-icon>
</mat-accordion> <span translate>Reset all to default</span>
</button>
</mat-menu>

View File

@ -1,8 +1,12 @@
mat-expansion-panel { #wrapper {
max-width: 770px; font-size: 14px;
margin: auto;
} }
h3.accent { h3.accent {
margin-top: 30px;
margin-bottom: 20px; margin-bottom: 20px;
&:first-child {
margin-top: 0;
}
&:not(:first-child) {
margin-top: 30px;
}
} }

View File

@ -1,10 +1,29 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnDestroy, OnInit } from '@angular/core';
import { Title } from '@angular/platform-browser'; import { Title } from '@angular/platform-browser';
import { ActivatedRoute } from '@angular/router';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { Subscription } from 'rxjs';
import { BaseComponent } from 'app/base.component'; import { BaseComponent } from 'app/base.component';
import { ConfigGroup, ConfigRepositoryService } from 'app/core/repositories/config/config-repository.service'; import { ConfigGroup, ConfigRepositoryService } from 'app/core/repositories/config/config-repository.service';
import { PromptService } from 'app/core/ui-services/prompt.service';
import { CanComponentDeactivate } from 'app/shared/utils/watch-for-changes.guard';
/**
* Key-value-pair to set a setting with its associated value.
*/
export interface ConfigItem {
/**
* The key has to be a string.
*/
key: string;
/**
* The value can be any.
*/
value: any;
}
/** /**
* List view for the global settings * List view for the global settings
@ -14,13 +33,27 @@ import { ConfigGroup, ConfigRepositoryService } from 'app/core/repositories/conf
templateUrl: './config-list.component.html', templateUrl: './config-list.component.html',
styleUrls: ['./config-list.component.scss'] styleUrls: ['./config-list.component.scss']
}) })
export class ConfigListComponent extends BaseComponent implements OnInit { export class ConfigListComponent extends BaseComponent implements CanComponentDeactivate, OnInit, OnDestroy {
public configs: ConfigGroup[]; public configGroup: ConfigGroup;
public configGroupSubscription: Subscription | null = null;
/**
* Object containing all errors.
*/
public errors = {};
/**
* Array of all changed settings.
*/
private configItems: ConfigItem[] = [];
public constructor( public constructor(
protected titleService: Title, protected titleService: Title,
protected translate: TranslateService, protected translate: TranslateService,
private repo: ConfigRepositoryService private repo: ConfigRepositoryService,
private route: ActivatedRoute,
private promptDialog: PromptService
) { ) {
super(titleService, translate); super(titleService, translate);
} }
@ -29,10 +62,85 @@ export class ConfigListComponent extends BaseComponent implements OnInit {
* Sets the title, inits the table and calls the repo * Sets the title, inits the table and calls the repo
*/ */
public ngOnInit(): void { public ngOnInit(): void {
super.setTitle('Settings'); const settings = this.translate.instant('Settings');
this.route.params.subscribe(params => {
this.repo.getConfigListObservable().subscribe(configs => { this.clearSubscription();
this.configs = configs; this.configGroupSubscription = this.repo.getConfigGroupOberservable(params.group).subscribe(configGroup => {
if (configGroup) {
const groupName = this.translate.instant(configGroup.name);
super.setTitle(`${settings} - ${groupName}`);
this.configGroup = configGroup;
}
});
}); });
} }
/**
* Updates the specified config-item indicated by the given key.
*
* @param key The key of the config-item.
* @param value The next value the config-item has.
*/
public updateConfigGroup(update: ConfigItem): void {
const { key, value }: ConfigItem = update;
const index = this.configItems.findIndex(item => item.key === key);
if (index === -1) {
this.configItems.push({ key, value });
} else {
this.configItems[index] = { key, value };
}
}
/**
* Saves every field in this config-group.
*/
public saveAll(): void {
this.repo.bulkUpdate(this.configItems).then(result => {
this.errors = result.errors;
if (Object.keys(result.errors).length === 0) {
this.configItems = [];
}
});
}
/**
* This resets all values to their defaults.
*/
public resetAll(): void {
this.repo.resetGroups([this.configGroup.name]);
}
/**
* Returns, if there are changes depending on the `configMap`.
*
* @returns True, if the array `configMap` has at least one member.
*/
public hasChanges(): boolean {
return !!this.configItems.length;
}
private clearSubscription(): void {
if (this.configGroupSubscription) {
this.configGroupSubscription.unsubscribe();
this.configGroupSubscription = null;
}
}
public ngOnDestroy(): void {
this.clearSubscription();
}
/**
* Lifecycle-hook to hook into, before the route changes.
*
* @returns The answer of the user, if he made changes, `true` otherwise.
*/
public async canDeactivate(): Promise<boolean> {
if (this.hasChanges()) {
const title = this.translate.instant('Do you really want to exit this page?');
const content = this.translate.instant('You made changes.');
return await this.promptDialog.open(title, content);
}
return true;
}
} }

View File

@ -0,0 +1,50 @@
<os-head-bar [isSearchEnabled]="false">
<!-- Title -->
<div class="title-slot">
<h2 translate>Settings</h2>
</div>
<!-- Menu -->
<div class="menu-slot">
<button type="button" mat-icon-button [matMenuTriggerFor]="settingsMenu"><mat-icon>more_vert</mat-icon></button>
</div>
</os-head-bar>
<os-grid-layout>
<os-block-tile
*ngFor="let group of this.groups"
orientation="vertical"
[only]="'content'"
[preferredSize]="2"
[isSquare]="true"
>
<ng-container class="block-node">
<table>
<tbody>
<tr>
<td [ngSwitch]="group">
<mat-icon *ngSwitchCase="'General'">home</mat-icon>
<mat-icon *ngSwitchCase="'Agenda'">today</mat-icon>
<mat-icon *ngSwitchCase="'Motions'">assignment</mat-icon>
<mat-icon *ngSwitchCase="'Elections'">how_to_vote</mat-icon>
<mat-icon *ngSwitchCase="'Participants'">groups</mat-icon>
<mat-icon *ngSwitchCase="'Custom translations'">language</mat-icon>
</td>
</tr>
</tbody>
</table>
</ng-container>
<ng-container class="block-content-node">
<a class="stretch-to-fill-parent" [routerLink]="group.toLowerCase()"></a>
<div class="link-wrapper">
{{ group | translate }}
</div>
</ng-container>
</os-block-tile>
</os-grid-layout>
<mat-menu #settingsMenu="matMenu">
<button mat-menu-item (click)="resetAll()">
<mat-icon>undo</mat-icon>
<span translate>Reset every group to default</span>
</button>
</mat-menu>

View File

@ -0,0 +1,14 @@
.parent-wrapper {
position: relative;
padding: 50%;
a {
text-decoration: none;
}
}
.link-wrapper {
text-decoration: none;
padding: 10% 0;
display: block;
text-align: center;
}

View File

@ -0,0 +1,26 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ConfigOverviewComponent } from './config-overview.component';
import { E2EImportsModule } from '../../../../../e2e-imports.module';
describe('ConfigOverviewComponent', () => {
let component: ConfigOverviewComponent;
let fixture: ComponentFixture<ConfigOverviewComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule],
declarations: [ConfigOverviewComponent]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ConfigOverviewComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,50 @@
import { Component, OnInit } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { TranslateService } from '@ngx-translate/core';
import { BaseComponent } from 'app/base.component';
import { ConfigRepositoryService } from 'app/core/repositories/config/config-repository.service';
import { PromptService } from 'app/core/ui-services/prompt.service';
/**
* List view for the global settings
*/
@Component({
selector: 'os-config-overview',
templateUrl: './config-overview.component.html',
styleUrls: ['./config-overview.component.scss']
})
export class ConfigOverviewComponent extends BaseComponent implements OnInit {
public groups: string[] = [];
public constructor(
protected titleService: Title,
protected translate: TranslateService,
public repo: ConfigRepositoryService,
private promptDialog: PromptService
) {
super(titleService, translate);
}
/**
* Sets the title, inits the table and calls the repo
*/
public ngOnInit(): void {
super.setTitle('Settings');
this.repo.availableGroupsOberservable.subscribe(groups => {
this.groups = groups;
});
}
/**
* Resets every config for all registered group.
*/
public async resetAll(): Promise<void> {
const title = this.translate.instant('Are you sure, you want to reset all groups?');
if (await this.promptDialog.open(title)) {
await this.repo.resetGroups(this.groups);
}
}
}

View File

@ -1,18 +1,32 @@
<!-- Add new translation button --> <form [formGroup]="translationForm">
<div *ngFor="let translation of translations; let i = index"> <div
<form> class="inline-form"
<mat-form-field> formArrayName="translationBoxes"
<input matInput [value]="translation.original" (input)="onChangeOriginal($event.target.value, i)" /> *ngFor="let translation of translationBoxes.controls; let i = index"
</mat-form-field> >
<mat-icon>arrow_forward</mat-icon> <ng-container [formGroupName]="i">
<mat-form-field> <mat-form-field>
<input matInput [value]="translation.translation" (input)="onChangeTranslation($event.target.value, i)" /> <input formControlName="original" matInput placeholder="{{ 'Original' | translate }}" />
</mat-form-field> <mat-error translate>You have to fill this field.</mat-error>
<button mat-icon-button> </mat-form-field>
<mat-icon (click)="onRemoveTranslation(i)">close</mat-icon> <mat-icon>arrow_forward</mat-icon>
</button> <mat-form-field>
</form> <input formControlName="translation" matInput placeholder="{{ 'Translation' | translate }}" />
</div> <mat-error translate>You have to fill this field.</mat-error>
</mat-form-field>
<button
mat-icon-button
type="button"
(click)="onRemoveTranslation(i)"
matTooltip="{{ 'Cancel' | translate }}"
>
<mat-icon>close</mat-icon>
</button>
</ng-container>
</div>
</form>
<!-- Add new translation button --> <!-- Add new translation button -->
<button mat-button (click)="onAddNewTranslation()">{{ 'Add new custom translation' | translate }}</button> <button mat-button (click)="addNewTranslation()">
<mat-icon>add</mat-icon>{{ 'Add new custom translation' | translate }}
</button>

View File

@ -0,0 +1,8 @@
.inline-form {
display: flex;
align-items: center;
mat-icon {
margin: 0 8px;
}
}

View File

@ -1,7 +1,5 @@
import { Component, forwardRef } from '@angular/core'; import { Component, forwardRef, OnInit } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { ControlValueAccessor, FormArray, FormBuilder, FormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms';
import { CustomTranslation, CustomTranslations } from 'app/core/translate/translation-parser';
/** /**
* Custom translations as custom form component * Custom translations as custom form component
@ -23,16 +21,39 @@ import { CustomTranslation, CustomTranslations } from 'app/core/translate/transl
} }
] ]
}) })
export class CustomTranslationComponent implements ControlValueAccessor { export class CustomTranslationComponent implements ControlValueAccessor, OnInit {
/** /**
* Holds the custom translations in a list * The parent form-group.
*/ */
public translations: CustomTranslations = []; public translationForm: FormGroup;
/** /**
* Empty constructor * Reference to the form-control within the `translationForm`.
*/ */
public constructor() {} public translationBoxes: FormArray;
/**
* Default constructor.
*
* @param fb FormBuilder
*/
public constructor(private fb: FormBuilder) {}
/**
* Initializes the form-controls.
*/
public ngOnInit(): void {
this.translationForm = this.fb.group({
translationBoxes: this.fb.array([])
});
this.translationBoxes = this.translationForm.get('translationBoxes') as FormArray;
this.translationBoxes.valueChanges.subscribe(value => {
if (this.translationBoxes.valid) {
this.propagateChange(value);
}
});
}
/** /**
* Helper function to determine which information to give to the parent form * Helper function to determine which information to give to the parent form
@ -46,7 +67,9 @@ export class CustomTranslationComponent implements ControlValueAccessor {
*/ */
public writeValue(obj: any): void { public writeValue(obj: any): void {
if (obj) { if (obj) {
this.translations = obj; for (const item of obj) {
this.addNewTranslation(item.original, item.translation);
}
} }
} }
@ -73,46 +96,28 @@ export class CustomTranslationComponent implements ControlValueAccessor {
*/ */
public setDisabledState?(isDisabled: boolean): void {} public setDisabledState?(isDisabled: boolean): void {}
/**
* Detects changes to the "original" word
*
* @param value the value that was typed
* @param index the index of the change
*/
public onChangeOriginal(value: string, index: number): void {
this.translations[index].original = value;
this.propagateChange(this.translations);
}
/**
* Detects changes to the translation
* @param value the value that was typed
* @param index the index of the change
*/
public onChangeTranslation(value: string, index: number): void {
this.translations[index].translation = value;
this.propagateChange(this.translations);
}
/** /**
* Removes a custom translation * Removes a custom translation
*
* @param index the translation to remove * @param index the translation to remove
*/ */
public onRemoveTranslation(index: number): void { public onRemoveTranslation(index: number): void {
this.translations.splice(index, 1); this.translationBoxes.removeAt(index);
this.propagateChange(this.translations);
} }
/** /**
* Adds a new custom translation to the list and to the server * Function to add a new translation-field to the form-array.
* If strings are passed, they are passed as the fields' value.
*
* @param original The original string to translate.
* @param translation The translation for the given string.
*/ */
public onAddNewTranslation(): void { public addNewTranslation(original: string = '', translation: string = ''): void {
const newCustomTranslation: CustomTranslation = { this.translationBoxes.push(
original: 'New', this.fb.group({
translation: 'New' original: [original, Validators.required],
}; translation: [translation, Validators.required]
})
this.translations.push(newCustomTranslation); );
this.propagateChange(this.translations);
} }
} }

View File

@ -1,9 +1,14 @@
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router'; import { RouterModule, Routes } from '@angular/router';
import { WatchForChangesGuard } from 'app/shared/utils/watch-for-changes.guard';
import { ConfigListComponent } from './components/config-list/config-list.component'; import { ConfigListComponent } from './components/config-list/config-list.component';
import { ConfigOverviewComponent } from './components/config-overview/config-overview.component';
const routes: Routes = [{ path: '', component: ConfigListComponent, pathMatch: 'full' }]; const routes: Routes = [
{ path: '', component: ConfigOverviewComponent, pathMatch: 'full' },
{ path: ':group', component: ConfigListComponent, canDeactivate: [WatchForChangesGuard] }
];
@NgModule({ @NgModule({
imports: [RouterModule.forChild(routes)], imports: [RouterModule.forChild(routes)],

View File

@ -3,13 +3,14 @@ import { NgModule } from '@angular/core';
import { ConfigFieldComponent } from './components/config-field/config-field.component'; import { ConfigFieldComponent } from './components/config-field/config-field.component';
import { ConfigListComponent } from './components/config-list/config-list.component'; import { ConfigListComponent } from './components/config-list/config-list.component';
import { ConfigOverviewComponent } from './components/config-overview/config-overview.component';
import { ConfigRoutingModule } from './config-routing.module'; import { ConfigRoutingModule } from './config-routing.module';
import { CustomTranslationComponent } from './components/custom-translation/custom-translation.component'; import { CustomTranslationComponent } from './components/custom-translation/custom-translation.component';
import { SharedModule } from '../../shared/shared.module'; import { SharedModule } from '../../shared/shared.module';
@NgModule({ @NgModule({
imports: [CommonModule, ConfigRoutingModule, SharedModule], imports: [CommonModule, ConfigRoutingModule, SharedModule],
declarations: [ConfigListComponent, ConfigFieldComponent, CustomTranslationComponent], declarations: [ConfigOverviewComponent, ConfigListComponent, ConfigFieldComponent, CustomTranslationComponent],
entryComponents: [CustomTranslationComponent] entryComponents: [CustomTranslationComponent]
}) })
export class ConfigModule {} export class ConfigModule {}

View File

@ -1,37 +1,6 @@
import { Config } from 'app/shared/models/core/config'; import { Config, ConfigChoice, ConfigData, ConfigInputType } from 'app/shared/models/core/config';
import { BaseViewModel } from '../../base/base-view-model'; import { BaseViewModel } from '../../base/base-view-model';
interface ConfigChoice {
value: string;
displayName: string;
}
/**
* All valid input types for config variables.
*/
type ConfigInputType =
| 'text'
| 'string'
| 'boolean'
| 'markupText'
| 'integer'
| 'choice'
| 'datetimepicker'
| 'colorpicker'
| 'translations';
/**
* Represents all information that is given in the constant.
*/
interface ConfigConstant {
default_value?: string;
help_text?: string;
input_type: ConfigInputType;
key: string;
label: string;
choices?: ConfigChoice[];
}
export interface ConfigTitleInformation { export interface ConfigTitleInformation {
key: string; key: string;
} }
@ -43,22 +12,6 @@ export class ViewConfig extends BaseViewModel<Config> implements ConfigTitleInfo
public static COLLECTIONSTRING = Config.COLLECTIONSTRING; public static COLLECTIONSTRING = Config.COLLECTIONSTRING;
protected _collectionString = Config.COLLECTIONSTRING; protected _collectionString = Config.COLLECTIONSTRING;
/* This private members are set by setConstantsInfo. */
private _helpText: string;
private _inputType: ConfigInputType;
private _label: string;
private _choices: ConfigChoice[];
private _defaultValue: any;
/**
* Saves, if this config already got constants information.
*/
private _hasConstantsInfo = false;
public get hasConstantsInfo(): boolean {
return this._hasConstantsInfo;
}
public get config(): Config { public get config(): Config {
return this._model; return this._model;
} }
@ -67,28 +20,48 @@ export class ViewConfig extends BaseViewModel<Config> implements ConfigTitleInfo
return this.config.key; return this.config.key;
} }
public get value(): Object { public get value(): any {
return this.config.value; return this.config.value;
} }
public get data(): ConfigData | null {
return this.config.data;
}
public get hidden(): boolean {
return !this.data;
}
public get label(): string { public get label(): string {
return this._label; return this.data.label;
} }
public get inputType(): ConfigInputType { public get inputType(): ConfigInputType | null {
return this._inputType; return this.data.inputType;
} }
public get helpText(): string { public get helpText(): string | null {
return this._helpText; return this.data.helpText;
} }
public get choices(): Object { public get choices(): ConfigChoice[] | null {
return this._choices; return this.data.choices;
} }
public get defaultValue(): any { public get defaultValue(): any {
return this._defaultValue; return this.data.defaultValue;
}
public get weight(): number {
return this.hidden ? 0 : this.data.weight;
}
public get group(): string {
return this.data.group;
}
public get subgroup(): string | null {
return this.data.subgroup;
} }
/** /**
@ -105,19 +78,4 @@ export class ViewConfig extends BaseViewModel<Config> implements ConfigTitleInfo
return 100; return 100;
} }
} }
/**
* This should be called, if the constants are loaded, so all extra info can be updated.
* @param constant The constant info
*/
public setConstantsInfo(constant: ConfigConstant): void {
this._label = constant.label;
this._helpText = constant.help_text;
this._inputType = constant.input_type;
this._choices = constant.choices;
if (constant.default_value !== undefined) {
this._defaultValue = constant.default_value;
}
this._hasConstantsInfo = true;
}
} }

View File

@ -1,4 +1,4 @@
<os-head-bar [mainButton]="canEdit" [multiSelectMode]="isMultiSelect" (mainEvent)="onMainEvent()"> <os-head-bar [hasMainButton]="canEdit" [multiSelectMode]="isMultiSelect" (mainEvent)="onMainEvent()">
<!-- Title --> <!-- Title -->
<div class="title-slot"> <div class="title-slot">
<h2 translate>Files</h2> <h2 translate>Files</h2>

View File

@ -1,11 +1,11 @@
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router'; import { RouterModule, Routes } from '@angular/router';
import { WatchSortingTreeGuard } from 'app/shared/utils/watch-sorting-tree.guard'; import { WatchForChangesGuard } from 'app/shared/utils/watch-for-changes.guard';
import { CallListComponent } from './call-list.component'; import { CallListComponent } from './call-list.component';
const routes: Routes = [ const routes: Routes = [
{ path: '', component: CallListComponent, pathMatch: 'full', canDeactivate: [WatchSortingTreeGuard] } { path: '', component: CallListComponent, pathMatch: 'full', canDeactivate: [WatchForChangesGuard] }
]; ];
@NgModule({ @NgModule({

View File

@ -1,7 +1,7 @@
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router'; import { RouterModule, Routes } from '@angular/router';
import { WatchSortingTreeGuard } from 'app/shared/utils/watch-sorting-tree.guard'; import { WatchForChangesGuard } from 'app/shared/utils/watch-for-changes.guard';
import { CategoriesSortComponent } from './components/categories-sort/categories-sort.component'; import { CategoriesSortComponent } from './components/categories-sort/categories-sort.component';
import { CategoryDetailComponent } from './components/category-detail/category-detail.component'; import { CategoryDetailComponent } from './components/category-detail/category-detail.component';
import { CategoryListComponent } from './components/category-list/category-list.component'; import { CategoryListComponent } from './components/category-list/category-list.component';
@ -9,8 +9,8 @@ import { CategoryMotionsSortComponent } from './components/category-motions-sort
const routes: Routes = [ const routes: Routes = [
{ path: '', component: CategoryListComponent, pathMatch: 'full' }, { path: '', component: CategoryListComponent, pathMatch: 'full' },
{ path: ':id/sort', component: CategoryMotionsSortComponent, canDeactivate: [WatchSortingTreeGuard] }, { path: ':id/sort', component: CategoryMotionsSortComponent, canDeactivate: [WatchForChangesGuard] },
{ path: 'sort', component: CategoriesSortComponent, canDeactivate: [WatchSortingTreeGuard] }, { path: 'sort', component: CategoriesSortComponent, canDeactivate: [WatchForChangesGuard] },
{ path: ':id', component: CategoryDetailComponent } { path: ':id', component: CategoryDetailComponent }
]; ];

View File

@ -8,7 +8,7 @@ import { Observable } from 'rxjs';
import { CategoryRepositoryService } from 'app/core/repositories/motions/category-repository.service'; import { CategoryRepositoryService } from 'app/core/repositories/motions/category-repository.service';
import { PromptService } from 'app/core/ui-services/prompt.service'; import { PromptService } from 'app/core/ui-services/prompt.service';
import { SortingTreeComponent } from 'app/shared/components/sorting-tree/sorting-tree.component'; import { SortingTreeComponent } from 'app/shared/components/sorting-tree/sorting-tree.component';
import { CanComponentDeactivate } from 'app/shared/utils/watch-sorting-tree.guard'; import { CanComponentDeactivate } from 'app/shared/utils/watch-for-changes.guard';
import { BaseViewComponent } from 'app/site/base/base-view'; import { BaseViewComponent } from 'app/site/base/base-view';
import { ViewCategory } from 'app/site/motions/models/view-category'; import { ViewCategory } from 'app/site/motions/models/view-category';

View File

@ -1,4 +1,4 @@
<os-head-bar prevUrl="../.." [nav]="false" [mainButton]="canEdit" (mainEvent)="onPlusButton()"> <os-head-bar prevUrl="../.." [nav]="false" [hasMainButton]="canEdit" (mainEvent)="onPlusButton()">
<!-- Title --> <!-- Title -->
<div class="title-slot"> <div class="title-slot">
<h2 translate>Categories</h2> <h2 translate>Categories</h2>

View File

@ -11,7 +11,7 @@ import { MotionRepositoryService } from 'app/core/repositories/motions/motion-re
import { ChoiceService } from 'app/core/ui-services/choice.service'; import { ChoiceService } from 'app/core/ui-services/choice.service';
import { PromptService } from 'app/core/ui-services/prompt.service'; import { PromptService } from 'app/core/ui-services/prompt.service';
import { SortingListComponent } from 'app/shared/components/sorting-list/sorting-list.component'; import { SortingListComponent } from 'app/shared/components/sorting-list/sorting-list.component';
import { CanComponentDeactivate } from 'app/shared/utils/watch-sorting-tree.guard'; import { CanComponentDeactivate } from 'app/shared/utils/watch-for-changes.guard';
import { BaseViewComponent } from 'app/site/base/base-view'; import { BaseViewComponent } from 'app/site/base/base-view';
import { ViewCategory } from 'app/site/motions/models/view-category'; import { ViewCategory } from 'app/site/motions/models/view-category';
import { ViewMotion } from 'app/site/motions/models/view-motion'; import { ViewMotion } from 'app/site/motions/models/view-motion';

View File

@ -1,4 +1,4 @@
<os-head-bar prevUrl="../.." [nav]="false" [mainButton]="canEdit" (mainEvent)="onPlusButton()"> <os-head-bar prevUrl="../.." [nav]="false" [hasMainButton]="canEdit" (mainEvent)="onPlusButton()">
<!-- Title --> <!-- Title -->
<div class="title-slot"><h2 translate>Motion blocks</h2></div> <div class="title-slot"><h2 translate>Motion blocks</h2></div>
</os-head-bar> </os-head-bar>

View File

@ -1,4 +1,4 @@
<os-head-bar prevUrl="../.." [nav]="false" [mainButton]="true" (mainEvent)="openDialog()"> <os-head-bar prevUrl="../.." [nav]="false" [hasMainButton]="true" (mainEvent)="openDialog()">
<!-- Title --> <!-- Title -->
<div class="title-slot"> <div class="title-slot">
<h2 translate>Comment fields</h2> <h2 translate>Comment fields</h2>

View File

@ -1,5 +1,5 @@
<os-head-bar <os-head-bar
[mainButton]="perms.isAllowed('can_create_amendments', motion)" [hasMainButton]="perms.isAllowed('can_create_amendments', motion)"
mainActionTooltip="New amendment" mainActionTooltip="New amendment"
[prevUrl]="getPrevUrl()" [prevUrl]="getPrevUrl()"
[nav]="false" [nav]="false"

View File

@ -1,4 +1,4 @@
<os-head-bar [mainButton]="perms.isAllowed('create')" (mainEvent)="onPlusButton()" [multiSelectMode]="isMultiSelect"> <os-head-bar [hasMainButton]="perms.isAllowed('create')" (mainEvent)="onPlusButton()" [multiSelectMode]="isMultiSelect">
<!-- Title --> <!-- Title -->
<div class="title-slot"><h2 translate>Motions</h2></div> <div class="title-slot"><h2 translate>Motions</h2></div>
@ -315,6 +315,14 @@
<span translate>Import</span> <span translate>Import</span>
</button> </button>
</div> </div>
<mat-divider></mat-divider>
<!-- Settings -->
<button mat-menu-item *osPerms="'core.can_manage_config'" routerLink="/settings/motions">
<mat-icon>settings</mat-icon>
<span translate>Settings</span>
</button>
</div> </div>
<div *ngIf="isMultiSelect"> <div *ngIf="isMultiSelect">
<button mat-menu-item (click)="selectAll()"> <button mat-menu-item (click)="selectAll()">

View File

@ -1,4 +1,4 @@
<os-head-bar [nav]="false" [mainButton]="true" (mainEvent)="onNewStateButton()"> <os-head-bar [nav]="false" [hasMainButton]="true" (mainEvent)="onNewStateButton()">
<!-- Title --> <!-- Title -->
<div class="title-slot"> <div class="title-slot">
<h2 *ngIf="workflow"> <h2 *ngIf="workflow">

View File

@ -1,4 +1,4 @@
<os-head-bar prevUrl="../.." [nav]="false" [mainButton]="true" (mainEvent)="onNewButton(newWorkflowDialog)"> <os-head-bar prevUrl="../.." [nav]="false" [hasMainButton]="true" (mainEvent)="onNewButton(newWorkflowDialog)">
<!-- Title --> <!-- Title -->
<div class="title-slot"><h2 translate>Workflows</h2></div> <div class="title-slot"><h2 translate>Workflows</h2></div>
</os-head-bar> </os-head-bar>

View File

@ -1,4 +1,4 @@
<os-head-bar prevUrl="../.." [nav]="false" [mainButton]="true" (mainEvent)="openDialog()"> <os-head-bar prevUrl="../.." [nav]="false" [hasMainButton]="true" (mainEvent)="openDialog()">
<!-- Title --> <!-- Title -->
<div class="title-slot"> <div class="title-slot">
<h2 translate>Statute</h2> <h2 translate>Statute</h2>
@ -54,7 +54,7 @@
</button> </button>
<button mat-menu-item *osPerms="'motions.can_manage'" routerLink="import"> <button mat-menu-item *osPerms="'motions.can_manage'" routerLink="import">
<mat-icon>cloud_upload</mat-icon> <mat-icon>cloud_upload</mat-icon>
<span translate>Import</span><span>&nbsp;...</span> <span translate>Import</span>
</button> </button>
</mat-menu> </mat-menu>

View File

@ -1,4 +1,4 @@
<os-head-bar [nav]="true" [mainButton]="canManage" (mainEvent)="onPlusButton()"> <os-head-bar [nav]="true" [hasMainButton]="canManage" (mainEvent)="onPlusButton()">
<!-- Title --> <!-- Title -->
<div class="title-slot"> <div class="title-slot">
<h2 translate>Projectors</h2> <h2 translate>Projectors</h2>

View File

@ -1,4 +1,4 @@
<os-head-bar [mainButton]="true" [nav]="true" (mainEvent)="openTagDialog()" [multiSelectMode]="isMultiSelect"> <os-head-bar [hasMainButton]="true" [nav]="true" (mainEvent)="openTagDialog()" [multiSelectMode]="isMultiSelect">
<div class="title-slot"><h2 translate>Tags</h2></div> <div class="title-slot"><h2 translate>Tags</h2></div>
</os-head-bar> </os-head-bar>

View File

@ -1,5 +1,5 @@
<os-head-bar <os-head-bar
[mainButton]="isAllowed('edit')" [hasMainButton]="isAllowed('edit')"
mainButtonIcon="edit" mainButtonIcon="edit"
[nav]="false" [nav]="false"
[goBack]="true" [goBack]="true"

View File

@ -1,4 +1,4 @@
<os-head-bar [mainButton]="true" [nav]="false" (mainEvent)="setEditMode(!editGroup)"> <os-head-bar [hasMainButton]="true" [nav]="false" (mainEvent)="setEditMode(!editGroup)">
<!-- Title --> <!-- Title -->
<div class="title-slot"> <div class="title-slot">
<h2 translate>Groups</h2> <h2 translate>Groups</h2>

View File

@ -1,4 +1,4 @@
<os-head-bar (mainEvent)="goBack()" [mainButton]="true" [nav]="false" [editMode]="true" (saveEvent)="save()">a <os-head-bar (mainEvent)="goBack()" [hasMainButton]="true" [nav]="false" [editMode]="true" (saveEvent)="save()">a
<!-- Title --> <!-- Title -->
<div class="title-slot"><h2 translate>Change password</h2></div> <div class="title-slot"><h2 translate>Change password</h2></div>
</os-head-bar> </os-head-bar>

View File

@ -1,4 +1,4 @@
<os-head-bar [mainButton]="false" [nav]="false"> <os-head-bar [hasMainButton]="false" [nav]="false">
<!-- Title --> <!-- Title -->
<div class="title-slot"><h2 translate>Presence</h2></div> <div class="title-slot"><h2 translate>Presence</h2></div>
</os-head-bar> </os-head-bar>

View File

@ -1,5 +1,5 @@
<os-head-bar <os-head-bar
[mainButton]="isAllowed('changePersonal')" [hasMainButton]="isAllowed('changePersonal')"
mainButtonIcon="edit" mainButtonIcon="edit"
[nav]="false" [nav]="false"
[goBack]="!isAllowed('seeOtherUsers')" [goBack]="!isAllowed('seeOtherUsers')"

View File

@ -1,4 +1,4 @@
<os-head-bar [mainButton]="canAddUser" (mainEvent)="onPlusButton()" [multiSelectMode]="isMultiSelect"> <os-head-bar [hasMainButton]="canAddUser" (mainEvent)="onPlusButton()" [multiSelectMode]="isMultiSelect">
<!-- Title --> <!-- Title -->
<div class="title-slot"><h2 translate>Participants</h2></div> <div class="title-slot"><h2 translate>Participants</h2></div>
@ -124,9 +124,9 @@
<!-- Presence --> <!-- Presence -->
<button mat-menu-item (click)="setPresent(user)"> <button mat-menu-item (click)="setPresent(user)">
<mat-icon color="accent"> {{ user.is_present ? 'check_box' : 'check_box_outline_blank' }} </mat-icon> <mat-icon color="accent"> {{ user.is_present ? 'check_box' : 'check_box_outline_blank' }} </mat-icon>
<span translate>Present</span> <span translate>Present</span>
</button> </button>
</ng-template> </ng-template>
</mat-menu> </mat-menu>
@ -166,7 +166,15 @@
<button mat-menu-item *osPerms="'users.can_manage'" routerLink="import"> <button mat-menu-item *osPerms="'users.can_manage'" routerLink="import">
<mat-icon>cloud_upload</mat-icon> <mat-icon>cloud_upload</mat-icon>
<span translate>Import</span><span>&nbsp;...</span> <span translate>Import</span>
</button>
<mat-divider></mat-divider>
<!-- Settings -->
<button mat-menu-item *osPerms="'core.can_manage_config'" routerLink="/settings/participants">
<mat-icon>settings</mat-icon>
<span translate>Settings</span>
</button> </button>
</div> </div>
<div *ngIf="isMultiSelect"> <div *ngIf="isMultiSelect">
@ -199,11 +207,6 @@
</button> </button>
</div> </div>
<button mat-menu-item *osPerms="'users.can_manage'" routerLink="import">
<mat-icon>save_alt</mat-icon>
<span translate>Import</span><span>&nbsp;...</span>
</button>
<div *osPerms="'users.can_manage'"> <div *osPerms="'users.can_manage'">
<mat-divider></mat-divider> <mat-divider></mat-divider>

View File

@ -34,7 +34,8 @@
} }
//custom table header for search button, filtering and more. Used in ListViews //custom table header for search button, filtering and more. Used in ListViews
.custom-table-header { .custom-table-header,
.background--default {
background: mat-color($background, background); background: mat-color($background, background);
} }

View File

@ -20,7 +20,6 @@ def get_config_variables():
help_text="Input format: DD.MM.YYYY HH:MM", help_text="Input format: DD.MM.YYYY HH:MM",
weight=200, weight=200,
group="Agenda", group="Agenda",
subgroup="General",
) )
yield ConfigVariable( yield ConfigVariable(
@ -30,7 +29,6 @@ def get_config_variables():
label="Show subtitles in the agenda", label="Show subtitles in the agenda",
weight=201, weight=201,
group="Agenda", group="Agenda",
subgroup="General",
) )
# Numbering # Numbering

View File

@ -1,8 +1,6 @@
import os import os
import sys import sys
from collections import OrderedDict from typing import Any, Dict
from operator import attrgetter
from typing import Any, Dict, List
from django.apps import AppConfig from django.apps import AppConfig
from django.conf import settings from django.conf import settings
@ -127,8 +125,6 @@ class CoreAppConfig(AppConfig):
yield self.get_model(model_name) yield self.get_model(model_name)
def get_angular_constants(self): def get_angular_constants(self):
from .config import config
constants: Dict[str, Any] = {} constants: Dict[str, Any] = {}
# Client settings # Client settings
@ -147,34 +143,7 @@ class CoreAppConfig(AppConfig):
pass pass
constants["Settings"] = client_settings_dict constants["Settings"] = client_settings_dict
# Config variables
config_groups: List[Any] = []
for config_variable in sorted(
config.config_variables.values(), key=attrgetter("weight")
):
if config_variable.is_hidden():
# Skip hidden config variables. Do not even check groups and subgroups.
continue
if not config_groups or config_groups[-1]["name"] != config_variable.group:
# Add new group.
config_groups.append(
OrderedDict(name=config_variable.group, subgroups=[])
)
if (
not config_groups[-1]["subgroups"]
or config_groups[-1]["subgroups"][-1]["name"]
!= config_variable.subgroup
):
# Add new subgroup.
config_groups[-1]["subgroups"].append(
OrderedDict(name=config_variable.subgroup, items=[])
)
# Add the config variable to the current group and subgroup.
config_groups[-1]["subgroups"][-1]["items"].append(config_variable.data)
constants["ConfigVariables"] = config_groups
constants["SchemaVersion"] = schema_version_handler.get() constants["SchemaVersion"] = schema_version_handler.get()
return constants return constants

View File

@ -24,6 +24,8 @@ INPUT_TYPE_MAPPING = {
"translations": list, "translations": list,
} }
ALLOWED_NONE = ("datetimepicker",)
build_key_to_id_lock = asyncio.Lock() build_key_to_id_lock = asyncio.Lock()
@ -119,12 +121,15 @@ class ConfigHandler:
expected_type = INPUT_TYPE_MAPPING[config_variable.input_type] expected_type = INPUT_TYPE_MAPPING[config_variable.input_type]
# Try to convert value into the expected datatype # Try to convert value into the expected datatype
try: if value is None and config_variable.input_type not in ALLOWED_NONE:
value = expected_type(value) raise ConfigError(f"Got None for {key}")
except ValueError: elif value is not None:
raise ConfigError( try:
f"Wrong datatype. Expected {expected_type}, got {type(value)}." value = expected_type(value)
) except (ValueError, TypeError):
raise ConfigError(
f"Wrong datatype. Expected {expected_type}, got {type(value)}."
)
if config_variable.input_type == "choice": if config_variable.input_type == "choice":
# Choices can be a callable. In this case call it at this place # Choices can be a callable. In this case call it at this place
@ -267,12 +272,14 @@ OnChangeType = Callable[[], None]
ConfigVariableDict = TypedDict( ConfigVariableDict = TypedDict(
"ConfigVariableDict", "ConfigVariableDict",
{ {
"key": str, "defaultValue": Any,
"default_value": Any, "inputType": str,
"input_type": str,
"label": str, "label": str,
"help_text": str, "helpText": str,
"choices": ChoiceType, "choices": ChoiceType,
"weight": int,
"group": str,
"subgroup": Optional[str],
}, },
) )
@ -314,8 +321,8 @@ class ConfigVariable:
choices: ChoiceCallableType = None, choices: ChoiceCallableType = None,
hidden: bool = False, hidden: bool = False,
weight: int = 0, weight: int = 0,
group: str = None, group: str = "General",
subgroup: str = None, subgroup: str = "General",
validators: ValidatorsType = None, validators: ValidatorsType = None,
on_change: OnChangeType = None, on_change: OnChangeType = None,
) -> None: ) -> None:
@ -339,28 +346,26 @@ class ConfigVariable:
self.choices = choices self.choices = choices
self.hidden = hidden self.hidden = hidden
self.weight = weight self.weight = weight
self.group = group or "General" self.group = group
self.subgroup = subgroup self.subgroup = subgroup
self.validators = validators or () self.validators = validators or ()
self.on_change = on_change self.on_change = on_change
@property @property
def data(self) -> ConfigVariableDict: def data(self) -> Optional[ConfigVariableDict]:
""" """
Property with all data for AngularJS variable on startup. Property with all data for Angular variable on startup.
""" """
return ConfigVariableDict( if self.hidden:
key=self.name, return None
default_value=self.default_value,
input_type=self.input_type,
label=self.label,
help_text=self.help_text,
choices=self.choices() if callable(self.choices) else self.choices,
)
def is_hidden(self) -> bool: return ConfigVariableDict(
""" defaultValue=self.default_value,
Returns True if the config variable is hidden so it can be removed inputType=self.input_type,
from response of OPTIONS request. label=self.label,
""" helpText=self.help_text,
return self.hidden choices=self.choices() if callable(self.choices) else self.choices,
weight=self.weight,
group=self.group,
subgroup=self.subgroup,
)

View File

@ -18,7 +18,6 @@ def get_config_variables():
default_value="OpenSlides", default_value="OpenSlides",
label="Event name", label="Event name",
weight=110, weight=110,
group="General",
subgroup="Event", subgroup="Event",
validators=(MaxLengthValidator(100),), validators=(MaxLengthValidator(100),),
) )
@ -28,7 +27,6 @@ def get_config_variables():
default_value="Presentation and assembly system", default_value="Presentation and assembly system",
label="Short description of event", label="Short description of event",
weight=115, weight=115,
group="General",
subgroup="Event", subgroup="Event",
validators=(MaxLengthValidator(100),), validators=(MaxLengthValidator(100),),
) )
@ -38,7 +36,6 @@ def get_config_variables():
default_value="", default_value="",
label="Event date", label="Event date",
weight=120, weight=120,
group="General",
subgroup="Event", subgroup="Event",
) )
@ -47,7 +44,6 @@ def get_config_variables():
default_value="", default_value="",
label="Event location", label="Event location",
weight=125, weight=125,
group="General",
subgroup="Event", subgroup="Event",
) )
@ -60,7 +56,6 @@ def get_config_variables():
input_type="markupText", input_type="markupText",
label="Legal notice", label="Legal notice",
weight=132, weight=132,
group="General",
subgroup="Event", subgroup="Event",
) )
@ -70,7 +65,6 @@ def get_config_variables():
input_type="markupText", input_type="markupText",
label="Privacy policy", label="Privacy policy",
weight=132, weight=132,
group="General",
subgroup="Event", subgroup="Event",
) )
@ -79,7 +73,6 @@ def get_config_variables():
default_value="Welcome to OpenSlides", default_value="Welcome to OpenSlides",
label="Front page title", label="Front page title",
weight=134, weight=134,
group="General",
subgroup="Event", subgroup="Event",
) )
@ -89,7 +82,6 @@ def get_config_variables():
input_type="markupText", input_type="markupText",
label="Front page text", label="Front page text",
weight=136, weight=136,
group="General",
subgroup="Event", subgroup="Event",
) )
@ -101,7 +93,6 @@ def get_config_variables():
input_type="boolean", input_type="boolean",
label="Allow access for anonymous guest users", label="Allow access for anonymous guest users",
weight=138, weight=138,
group="General",
subgroup="System", subgroup="System",
) )
@ -110,7 +101,6 @@ def get_config_variables():
default_value="", default_value="",
label="Show this text on the login page", label="Show this text on the login page",
weight=140, weight=140,
group="General",
subgroup="System", subgroup="System",
) )
@ -129,7 +119,6 @@ def get_config_variables():
}, },
), ),
weight=141, weight=141,
group="General",
subgroup="System", subgroup="System",
) )
@ -140,7 +129,6 @@ def get_config_variables():
default_value=",", default_value=",",
label="Separator used for all csv exports and examples", label="Separator used for all csv exports and examples",
weight=160, weight=160,
group="General",
subgroup="Export", subgroup="Export",
) )
@ -154,7 +142,6 @@ def get_config_variables():
{"value": "iso-8859-15", "display_name": "ISO-8859-15"}, {"value": "iso-8859-15", "display_name": "ISO-8859-15"},
), ),
weight=162, weight=162,
group="General",
subgroup="Export", subgroup="Export",
) )
@ -169,7 +156,6 @@ def get_config_variables():
{"value": "right", "display_name": "Right"}, {"value": "right", "display_name": "Right"},
), ),
weight=164, weight=164,
group="General",
subgroup="Export", subgroup="Export",
) )
@ -184,7 +170,6 @@ def get_config_variables():
{"value": "12", "display_name": "12"}, {"value": "12", "display_name": "12"},
), ),
weight=166, weight=166,
group="General",
subgroup="Export", subgroup="Export",
) )
@ -198,7 +183,6 @@ def get_config_variables():
{"value": "A5", "display_name": "DIN A5"}, {"value": "A5", "display_name": "DIN A5"},
), ),
weight=168, weight=168,
group="General",
subgroup="Export", subgroup="Export",
) )

View File

@ -1,11 +1,13 @@
from typing import Any from typing import Any
from ..core.config import config
from ..utils.projector import projector_slides from ..utils.projector import projector_slides
from ..utils.rest_api import ( from ..utils.rest_api import (
Field, Field,
IdPrimaryKeyRelatedField, IdPrimaryKeyRelatedField,
IntegerField, IntegerField,
ModelSerializer, ModelSerializer,
SerializerMethodField,
ValidationError, ValidationError,
) )
from ..utils.validate import validate_html from ..utils.validate import validate_html
@ -136,10 +138,14 @@ class ConfigSerializer(ModelSerializer):
""" """
value = JSONSerializerField() value = JSONSerializerField()
data = SerializerMethodField()
class Meta: class Meta:
model = ConfigStore model = ConfigStore
fields = ("id", "key", "value") fields = ("id", "key", "value", "data")
def get_data(self, db_config):
return config.config_variables[db_config.key].data
class ProjectorMessageSerializer(ModelSerializer): class ProjectorMessageSerializer(ModelSerializer):

View File

@ -37,6 +37,7 @@ from ..utils.rest_api import (
RetrieveModelMixin, RetrieveModelMixin,
ValidationError, ValidationError,
detail_route, detail_route,
list_route,
) )
from .access_permissions import ( from .access_permissions import (
ConfigAccessPermissions, ConfigAccessPermissions,
@ -390,30 +391,46 @@ class ConfigViewSet(ModelViewSet):
access_permissions = ConfigAccessPermissions() access_permissions = ConfigAccessPermissions()
queryset = ConfigStore.objects.all() queryset = ConfigStore.objects.all()
can_manage_config = None
can_manage_logos_and_fonts = None
def check_view_permissions(self): def check_view_permissions(self):
""" """
Returns True if the user has required permissions. Returns True if the user has required permissions.
""" """
if self.action in ("list", "retrieve"): if self.action in ("list", "retrieve"):
result = self.get_access_permissions().check_permissions(self.request.user) result = self.get_access_permissions().check_permissions(self.request.user)
elif self.action == "metadata":
# Every authenticated user can see the metadata and list or
# retrieve the config. Anonymous users can do so if they are
# enabled.
result = self.request.user.is_authenticated or anonymous_is_enabled()
elif self.action in ("partial_update", "update"): elif self.action in ("partial_update", "update"):
# The user needs 'core.can_manage_logos_and_fonts' for all config values result = self.check_config_permission(self.kwargs["pk"])
# starting with 'logo' and 'font'. For all other config values th euser needs elif self.action == "reset_groups":
# the default permissions 'core.can_manage_config'. result = has_perm(self.request.user, "core.can_manage_config")
pk = self.kwargs["pk"] elif self.action == "bulk_update":
if pk.startswith("logo") or pk.startswith("font"): result = True # will be checked in the view
result = has_perm(self.request.user, "core.can_manage_logos_and_fonts")
else:
result = has_perm(self.request.user, "core.can_manage_config")
else: else:
result = False result = False
return result return result
def check_config_permission(self, key):
"""
Checks the permissions for one config key.
Users needs 'core.can_manage_logos_and_fonts' for all config values starting
with 'logo' and 'font'. For all other config values the user needs the default
permissions 'core.can_manage_config'.
The result is cached for one request to reduce has_perm queries in e.g. bulk updates.
"""
if key.startswith("logo") or key.startswith("font"):
if self.can_manage_logos_and_fonts is None:
self.can_manage_logos_and_fonts = has_perm(
self.request.user, "core.can_manage_logos_and_fonts"
)
return self.can_manage_logos_and_fonts
else:
if self.can_manage_config is None:
self.can_manage_config = has_perm(
self.request.user, "core.can_manage_config"
)
return self.can_manage_config
def update(self, request, *args, **kwargs): def update(self, request, *args, **kwargs):
""" """
Updates a config variable. Only managers can do this. Updates a config variable. Only managers can do this.
@ -422,8 +439,6 @@ class ConfigViewSet(ModelViewSet):
""" """
key = kwargs["pk"] key = kwargs["pk"]
value = request.data.get("value") value = request.data.get("value")
if value is None:
raise ValidationError({"detail": "Invalid input. Config value is missing."})
# Validate and change value. # Validate and change value.
try: try:
@ -436,6 +451,60 @@ class ConfigViewSet(ModelViewSet):
# Return response. # Return response.
return Response({"key": key, "value": value}) return Response({"key": key, "value": value})
@list_route(methods=["post"])
def bulk_update(self, request):
"""
Updates many config variables:
[{key: <key>, value: <value>}, ...]
"""
if not isinstance(request.data, list):
raise ValidationError({"detail": "The data needs to be a list"})
for entry in request.data:
key = entry.get("key")
if not isinstance(key, str):
raise ValidationError({"detail": "The key must be a string."})
if not config.exists(key):
raise ValidationError(
{"detail": "The key {0} does not exist.", "args": [key]}
)
if not self.check_config_permission(key):
self.permission_denied(request, message=key)
if "value" not in entry:
raise ValidationError(
{"detail": "Invalid input. Config value is missing."}
)
errors = {}
for entry in request.data:
try:
config[entry["key"]] = entry["value"]
except ConfigError as err:
errors[entry["key"]] = str(err)
return Response({"errors": errors})
@list_route(methods=["post"])
def reset_groups(self, request):
"""
Resets multiple groups. The request data contains all
(main) group names: [<group1>, ...]
"""
if not isinstance(request.data, list):
raise ValidationError("The data must be a list")
for group in request.data:
if not isinstance(group, str):
raise ValidationError("Every group must be a string")
for key, config_variable in config.config_variables.items():
if (
config_variable.group in request.data
and config[key] != config_variable.default_value
):
config[key] = config_variable.default_value
return Response()
class ProjectorMessageViewSet(ModelViewSet): class ProjectorMessageViewSet(ModelViewSet):
""" """

View File

@ -35,7 +35,6 @@ def get_config_variables():
choices=get_workflow_choices, choices=get_workflow_choices,
weight=310, weight=310,
group="Motions", group="Motions",
subgroup="General",
) )
yield ConfigVariable( yield ConfigVariable(
@ -46,7 +45,6 @@ def get_config_variables():
choices=get_workflow_choices, choices=get_workflow_choices,
weight=312, weight=312,
group="Motions", group="Motions",
subgroup="General",
) )
yield ConfigVariable( yield ConfigVariable(
@ -55,7 +53,6 @@ def get_config_variables():
label="Motion preamble", label="Motion preamble",
weight=320, weight=320,
group="Motions", group="Motions",
subgroup="General",
) )
yield ConfigVariable( yield ConfigVariable(
@ -70,7 +67,6 @@ def get_config_variables():
), ),
weight=322, weight=322,
group="Motions", group="Motions",
subgroup="General",
) )
yield ConfigVariable( yield ConfigVariable(
@ -81,7 +77,6 @@ def get_config_variables():
help_text="The maximum number of characters per line. Relevant when line numbering is enabled. Min: 40", help_text="The maximum number of characters per line. Relevant when line numbering is enabled. Min: 40",
weight=323, weight=323,
group="Motions", group="Motions",
subgroup="General",
validators=(MinValueValidator(40),), validators=(MinValueValidator(40),),
) )
@ -92,7 +87,6 @@ def get_config_variables():
label="Reason required for creating new motion", label="Reason required for creating new motion",
weight=324, weight=324,
group="Motions", group="Motions",
subgroup="General",
) )
yield ConfigVariable( yield ConfigVariable(
@ -102,7 +96,6 @@ def get_config_variables():
label="Hide motion text on projector", label="Hide motion text on projector",
weight=325, weight=325,
group="Motions", group="Motions",
subgroup="General",
) )
yield ConfigVariable( yield ConfigVariable(
@ -112,7 +105,6 @@ def get_config_variables():
label="Hide reason on projector", label="Hide reason on projector",
weight=326, weight=326,
group="Motions", group="Motions",
subgroup="General",
) )
yield ConfigVariable( yield ConfigVariable(
@ -122,7 +114,6 @@ def get_config_variables():
label="Hide meta information box on projector", label="Hide meta information box on projector",
weight=327, weight=327,
group="Motions", group="Motions",
subgroup="General",
) )
yield ConfigVariable( yield ConfigVariable(
@ -132,7 +123,6 @@ def get_config_variables():
label="Hide recommendation on projector", label="Hide recommendation on projector",
weight=328, weight=328,
group="Motions", group="Motions",
subgroup="General",
) )
yield ConfigVariable( yield ConfigVariable(
@ -142,7 +132,6 @@ def get_config_variables():
label="Hide referring motions", label="Hide referring motions",
weight=329, weight=329,
group="Motions", group="Motions",
subgroup="General",
) )
yield ConfigVariable( yield ConfigVariable(
@ -153,7 +142,6 @@ def get_config_variables():
help_text="In motion list, motion detail and PDF.", help_text="In motion list, motion detail and PDF.",
weight=330, weight=330,
group="Motions", group="Motions",
subgroup="General",
) )
yield ConfigVariable( yield ConfigVariable(
@ -163,7 +151,6 @@ def get_config_variables():
help_text="Will be displayed as label before selected recommendation. Use an empty value to disable the recommendation system.", help_text="Will be displayed as label before selected recommendation. Use an empty value to disable the recommendation system.",
weight=332, weight=332,
group="Motions", group="Motions",
subgroup="General",
) )
yield ConfigVariable( yield ConfigVariable(
@ -173,7 +160,6 @@ def get_config_variables():
help_text="Will be displayed as label before selected recommendation in statute amendments.", help_text="Will be displayed as label before selected recommendation in statute amendments.",
weight=333, weight=333,
group="Motions", group="Motions",
subgroup="General",
) )
yield ConfigVariable( yield ConfigVariable(
@ -189,7 +175,6 @@ def get_config_variables():
), ),
weight=334, weight=334,
group="Motions", group="Motions",
subgroup="General",
) )
yield ConfigVariable( yield ConfigVariable(
@ -203,7 +188,6 @@ def get_config_variables():
), ),
weight=335, weight=335,
group="Motions", group="Motions",
subgroup="General",
) )
# Numbering # Numbering

View File

@ -23,7 +23,6 @@ def get_config_variables():
), ),
weight=510, weight=510,
group="Participants", group="Participants",
subgroup="General",
) )
yield ConfigVariable( yield ConfigVariable(
@ -33,7 +32,6 @@ def get_config_variables():
label="Enable participant presence view", label="Enable participant presence view",
weight=511, weight=511,
group="Participants", group="Participants",
subgroup="General",
) )
# PDF # PDF

View File

@ -223,7 +223,7 @@ class ConfigViewSet(TestCase):
) )
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual( self.assertEqual(
response.data, {"detail": "Invalid input. Config value is missing."} response.data, {"detail": "Got None for test_var_Xeiizi7ooH8Thuk5aida"}
) )

View File

@ -1,4 +1,9 @@
import random
import string
import pytest import pytest
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.urls import reverse from django.urls import reverse
from rest_framework import status from rest_framework import status
from rest_framework.test import APIClient from rest_framework.test import APIClient
@ -6,6 +11,7 @@ from rest_framework.test import APIClient
from openslides.core.config import config from openslides.core.config import config
from openslides.core.models import Projector, Tag from openslides.core.models import Projector, Tag
from openslides.users.models import User from openslides.users.models import User
from openslides.utils.auth import get_group_model
from openslides.utils.autoupdate import inform_changed_data from openslides.utils.autoupdate import inform_changed_data
from openslides.utils.test import TestCase from openslides.utils.test import TestCase
from tests.common_groups import GROUP_ADMIN_PK, GROUP_DELEGATE_PK from tests.common_groups import GROUP_ADMIN_PK, GROUP_DELEGATE_PK
@ -187,3 +193,204 @@ class Projection(TestCase):
self.assertEqual(self.projector.elements, []) self.assertEqual(self.projector.elements, [])
self.assertEqual(self.projector.elements_preview, elements) self.assertEqual(self.projector.elements_preview, elements)
self.assertEqual(self.projector.elements_history, []) self.assertEqual(self.projector.elements_history, [])
class ConfigViewSet(TestCase):
"""
Tests (currently just parts) of the ProjectorViewSet.
"""
string_config_key = "general_event_name"
"""
The config used for testing. It should accept string.
"""
logo_config_key = "logo_web_header"
def random_string(self):
return "".join(
random.choice(string.ascii_letters + string.digits) for i in range(20)
)
def get_static_config_value(self):
return {
"path": f"test_path_{self.random_string()}",
"display_name": f"test_display_name_{self.random_string()}",
}
def setUp(self):
self.client = APIClient()
self.client.login(username="admin", password="admin")
def test_create(self):
response = self.client.post(
reverse("config-list"), {"key": "test_key_fj3f2oqsjcqpsjclqwoO"}
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertFalse(config.exists("test_key_fj3f2oqsjcqpsjclqwoO"))
def test_delete(self):
response = self.client.delete(
reverse("config-detail", args=[self.string_config_key])
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertTrue(config.exists(self.string_config_key))
def test_update(self):
response = self.client.put(
reverse("config-detail", args=[self.string_config_key]),
{"value": "test_name_39gw4cishcvev2acoqnw"},
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
config[self.string_config_key], "test_name_39gw4cishcvev2acoqnw"
)
def test_set_none(self):
"""
The agenda_start_event_date_time is of type "datepicker" which
can be set to None
"""
response = self.client.put(
reverse("config-detail", args=["agenda_start_event_date_time"]),
{"value": None},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(config["agenda_start_event_date_time"], None)
def test_set_invalid_none(self):
"""
Try to set motions_identifier_min_digits to None, which should fail
"""
response = self.client.put(
reverse("config-detail", args=["motions_identifier_min_digits"]),
{"value": None},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def degrade_admin(self, can_manage_config=False, can_manage_logos_and_fonts=False):
admin = get_user_model().objects.get(username="admin")
admin.groups.remove(GROUP_ADMIN_PK)
admin.groups.add(GROUP_DELEGATE_PK)
if can_manage_config or can_manage_logos_and_fonts:
delegate_group = get_group_model().objects.get(pk=GROUP_DELEGATE_PK)
if can_manage_config:
delegate_group.permissions.add(
Permission.objects.get(
content_type__app_label="core", codename="can_manage_config"
)
)
if can_manage_logos_and_fonts:
delegate_group.permissions.add(
Permission.objects.get(
content_type__app_label="core",
codename="can_manage_logos_and_fonts",
)
)
inform_changed_data(delegate_group)
inform_changed_data(admin)
def test_update_no_permissions(self):
self.degrade_admin()
response = self.client.put(
reverse("config-detail", args=[self.string_config_key]),
{"value": "test_name_vp2sjjf29jswlvwaxwre"},
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(config[self.string_config_key], "OpenSlides")
def test_update_logo_no_config_permissions(self):
self.degrade_admin(can_manage_logos_and_fonts=True)
value = self.get_static_config_value()
response = self.client.put(
reverse("config-detail", args=[self.logo_config_key]),
{"value": value},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(config[self.logo_config_key], value)
def test_bulk_update(self):
string_value = "test_value_k2jqvjwrorepjadvpo2J"
logo_value = self.get_static_config_value()
response = self.client.post(
reverse("config-bulk-update"),
[
{"key": self.string_config_key, "value": string_value},
{"key": self.logo_config_key, "value": logo_value},
],
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["errors"], {})
self.assertEqual(config[self.string_config_key], string_value)
self.assertEqual(config[self.logo_config_key], logo_value)
def test_bulk_update_no_perm(self):
self.degrade_admin()
string_value = "test_value_gjscneuqoscmqf2qow91"
response = self.client.post(
reverse("config-bulk-update"),
[{"key": self.string_config_key, "value": string_value}],
format="json",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(config[self.string_config_key], "OpenSlides")
def test_bulk_update_no_list(self):
string_value = "test_value_fjewqpqayqfijnqm%cqi"
response = self.client.post(
reverse("config-bulk-update"),
{"key": self.string_config_key, "value": string_value},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(config[self.string_config_key], "OpenSlides")
def test_bulk_update_no_key(self):
string_value = "test_value_glwe32qc&Lml2lclmqmc"
response = self.client.post(
reverse("config-bulk-update"), [{"value": string_value}], format="json"
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(config[self.string_config_key], "OpenSlides")
def test_bulk_update_no_value(self):
response = self.client.post(
reverse("config-bulk-update"),
[{"key": self.string_config_key}],
format="json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(config[self.string_config_key], "OpenSlides")
def test_reset_group(self):
config["general_event_name"] = "test_name_of20w2fj20clqwcm2pij" # Group General
config["agenda_show_subtitle"] = True # Group Agenda
config[
"motions_preamble"
] = "test_preamble_2390jvwohjwo1oigefoq" # Group motions
response = self.client.post(
reverse("config-reset-groups"), ["General", "Agenda"], format="json"
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(config["general_event_name"], "OpenSlides")
self.assertEqual(config["agenda_show_subtitle"], False)
self.assertEqual(
config["motions_preamble"], "test_preamble_2390jvwohjwo1oigefoq"
)
def test_reset_group_wrong_format_1(self):
response = self.client.post(
reverse("config-reset-groups"), {"wrong": "format"}, format="json"
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test_reset_group_wrong_format_2(self):
response = self.client.post(
reverse("config-reset-groups"),
["some_string", {"wrong": "format"}],
format="json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)

View File

@ -1,7 +1,8 @@
from typing import cast
from unittest import TestCase from unittest import TestCase
from unittest.mock import patch from unittest.mock import patch
from openslides.core.config import ConfigVariable, config from openslides.core.config import ConfigVariable, ConfigVariableDict, config
from openslides.core.exceptions import ConfigNotFound from openslides.core.exceptions import ConfigNotFound
@ -14,15 +15,15 @@ class TestConfigVariable(TestCase):
""" """
config_variable = ConfigVariable("test_variable", "test_default_value") config_variable = ConfigVariable("test_variable", "test_default_value")
self.assertIn( self.assertTrue(
"default_value", "defaultValue" in cast(ConfigVariableDict, config_variable.data)
config_variable.data,
"Config_varialbe.data should have a key 'default_value'",
) )
data = config_variable.data
self.assertTrue(data)
self.assertEqual( self.assertEqual(
config_variable.data["default_value"], cast(ConfigVariableDict, config_variable.data)["defaultValue"],
"test_default_value", "test_default_value",
"The value of config_variable.data['default_value'] should be the same " "The value of config_variable.data['defaultValue'] should be the same "
"as set as second argument of ConfigVariable()", "as set as second argument of ConfigVariable()",
) )