Merge pull request #4878 from FinnStutzenstein/moveConfigData

Reworked config
This commit is contained in:
Finn Stutzenstein 2019-10-21 14:13:02 +02:00 committed by GitHub
commit 62e5774c8d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
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 { BehaviorSubject, Observable } from 'rxjs';
import { map } from 'rxjs/operators';
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 { DataStoreService } from 'app/core/core-services/data-store.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 { Identifiable } from 'app/shared/models/base/identifiable';
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';
/**
* 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.
*/
@ -48,7 +28,7 @@ interface ConfigSubgroup {
/**
* All items in this sub group.
*/
items: ConfigItem[];
configs: ViewConfig[];
}
/**
@ -64,11 +44,6 @@ export interface ConfigGroup {
* A list of subgroups.
*/
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.
*/
private configs: ConfigGroup[] = null;
private configs: ConfigGroup[] | null = null;
/**
* 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.
@ -114,18 +94,15 @@ export class ConfigRepositoryService extends BaseRepository<ViewConfig, Config,
viewModelStoreService: ViewModelStoreService,
translate: TranslateService,
relationManager: RelationManagerService,
private constantsService: ConstantsService,
private http: HttpService
) {
super(DS, dataSend, mapperService, viewModelStoreService, translate, relationManager, Config);
this.constantsService.get('ConfigVariables').subscribe(constant => {
this.createConfigStructure(constant);
this.updateConfigStructure(false, ...Object.values(this.viewModelStore));
this.gotConfigsVariables = true;
this.checkConfigStructure();
this.updateConfigListObservable();
});
this.setSortFunction((a, b) => a.weight - b.weight);
this.getViewModelListObservable().subscribe(configs =>
this.updateConfigStructure(configs.filter(config => !config.hidden))
);
}
public getVerboseName = (plural: boolean = false) => {
@ -154,96 +131,50 @@ export class ConfigRepositoryService extends BaseRepository<ViewConfig, Config,
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.
*/
protected updateConfigListObservable(): void {
if (this.configs) {
this.configListSubject.next(this.configs);
this.configsSubject.next(this.configs);
}
}
/**
* Getter for the config structure
*/
public getConfigStructure(): ConfigGroup[] {
return this.configs;
public getConfigGroupOberservable(name: string): Observable<ConfigGroup> {
return this.configsSubject.pipe(
map((groups: ConfigGroup[]) => groups.find(group => group.name.toLowerCase() === name))
);
}
/**
* Checks the config structure, if we got configs (first data) and the
* structure (config variables)
*/
protected checkConfigStructure(): void {
if (this.gotConfigsVariables && this.gotFirstUpdate) {
this.updateConfigStructure(true, ...Object.values(this.viewModelStore));
}
}
protected updateConfigStructure(configs: ViewConfig[]): void {
const groups: ConfigGroup[] = [];
/**
* With a given (and maybe partially filled) config structure, all given view configs are put into it.
* @param check Whether to check, if all given configs are there (according to the config structure).
* If check is true and one viewConfig is missing, the user will get an error message.
* @param viewConfigs All view configs to put into the structure
*/
protected updateConfigStructure(check: boolean, ...viewConfigs: ViewConfig[]): void {
if (!this.configs) {
return;
}
configs.forEach(config => {
if (groups.length === 0 || groups[groups.length - 1].name !== config.group) {
groups.push({
name: config.group,
subgroups: []
});
}
// Map the viewConfigs to their keys.
const keyConfigMap: { [key: string]: ViewConfig } = {};
viewConfigs.forEach(viewConfig => {
keyConfigMap[viewConfig.key] = viewConfig;
const subgroupsLength = groups[groups.length - 1].subgroups.length;
if (
subgroupsLength === 0 ||
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
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.`
);
}
}
}
this.configsSubject.next(groups);
}
/**
* 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> {
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.
* @param constant
* Function to update multiple settings.
*
* @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 {
this.configs = [];
for (const group of constant) {
const _group: ConfigGroup = {
name: group.name,
subgroups: [],
items: []
};
// The server always sends subgroups. But if it has an empty name, there is no subgroup..
for (const subgroup of group.subgroups) {
if (subgroup.name) {
const _subgroup: ConfigSubgroup = {
name: subgroup.name,
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);
}
public async bulkUpdate(configItems: ConfigItem[]): Promise<{ errors: { [key: string]: string } } | null> {
return await this.http.post(`/rest/core/config/bulk_update/`, configItems);
}
/**
* Function to send a `reset`-poll for every group to the server.
*
* @param groups The names of the groups, that should be updated.
*
* @returns The answer of the server.
*/
public async resetGroups(groups: string[]): Promise<void> {
return await this.http.post(`/rest/core/config/reset_groups/`, groups);
}
}

View File

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

View File

@ -3,11 +3,34 @@
.block-tile {
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 {
position: relative;
padding-bottom: 50%;
min-width: 30%;
&:not(.no-padding) {
padding-bottom: 50%;
}
.tile-text {
table {
height: 100%;
@ -20,9 +43,8 @@
}
.tile-content-node-container {
position: relative;
width: 100%;
margin: 8px 16px !important;
margin: 8px 16px;
.tile-content {
margin-bottom: 0;
@ -47,7 +69,7 @@
}
}
.tile-content-extra {
.tile-content-extra:not(.only-content) {
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';
/**
* 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.
*/
export enum BlockType {
text = 'text',
node = 'node',
picture = 'picture'
node = 'node'
}
/**
* Tells, whether to align the block and content next to each other or one below the other.
*/
export enum Orientation {
horizontal = 'horizontal',
vertical = 'vertical'
}
export type Orientation = 'horizontal' | 'vertical';
/**
* Tells, if the tile should only display the content or the title in the content part.
*/
export enum ShowOnly {
title = 'title',
content = 'content'
}
export type ShowOnly = 'title' | 'content' | null;
/**
* Class, that extends the `tile.component`.
@ -45,30 +30,42 @@ export enum ShowOnly {
templateUrl: './block-tile.component.html',
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.
*/
@ContentChild(TemplateRef, { static: true })
public contentNode: TemplateRef<any>;
@ViewChild('contentNode', { static: false })
public contentNode: ElementRef<HTMLElement>;
/**
* Reference to the block part, if it is a node.
*/
@ContentChild(TemplateRef, { static: true })
public blockNode: TemplateRef<any>;
@ViewChild('blockNode', { static: false })
public blockNode: ElementRef<HTMLElement>;
/**
* Reference to the action buttons in the content part, if used.
*/
@ContentChild(TemplateRef, { static: true })
public actionNode: TemplateRef<any>;
@ViewChild('actionNode', { static: false })
public actionNode: ElementRef<HTMLElement>;
/**
* Defines the type of the primary block.
*/
@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.
@ -77,12 +74,6 @@ export class BlockTileComponent extends TileComponent {
@Input()
public block: string;
/**
* Defines the type of the content.
*/
@Input()
public contentType: ContentType;
/**
* 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.
*/
@Input()
public orientation: Orientation;
public orientation: Orientation = 'horizontal';
/**
* Tells, whether the tile should display only one of `Title` or `Content` in the content part.
*/
@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.
*
* @param show Whether the action-part should be shown or not.
*/
@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>
<!-- 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>
</button>
@ -40,7 +40,8 @@
<!-- Main action button - desktop -->
<button
mat-icon-button
*ngIf="mainButton && !editMode && !vp.isMobile && !multiSelectMode"
*ngIf="hasMainButton && !editMode && !vp.isMobile && !multiSelectMode"
[disabled]="!isMainButtonEnabled"
(click)="sendMainEvent()"
matTooltip="{{ mainActionTooltip | translate }}"
>
@ -62,7 +63,8 @@
<button
mat-fab
class="head-button"
*ngIf="mainButton && !editMode && vp.isMobile && !multiSelectMode"
*ngIf="hasMainButton && !editMode && vp.isMobile && !multiSelectMode"
[disabled]="!isMainButtonEnabled"
(click)="sendMainEvent()"
matTooltip="{{ mainActionTooltip | translate }}"
>

View File

@ -21,7 +21,7 @@ import { ViewportService } from 'app/core/ui-services/viewport.service';
* saveText="Create"
* [nav]="false"
* [goBack]="true"
* [mainButton]="opCanEdit()"
* [hasMainButton]="opCanEdit()"
* [mainButtonIcon]="edit"
* [backButtonIcon]="arrow_back"
* [editMode]="editMotion"
@ -82,6 +82,12 @@ export class HeadBarComponent implements OnInit {
@Input()
public editMode = false;
/**
* Determine, if the search should not be available.
*/
@Input()
public isSearchEnabled = true;
/**
* 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
*/
@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

View File

@ -1,4 +1,5 @@
<ng-container
[ngTemplateOutlet]="tileContext"
[ngTemplateOutletContext]="data">
</ng-container>
<ng-container [ngTemplateOutlet]="tileContext" [ngTemplateOutletContext]="data"> </ng-container>
<ng-template #tileContext>
<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
*/
private setTabletSize(size: number): void {
private setTabletSize(size: number = 4): void {
if (size <= 8 && size >= 0) {
this.tabletSize = size;
} else {
@ -162,7 +162,7 @@ export class TileComponent implements OnInit {
*
* @param size how great the tile should be
*/
private setMediumSize(size: number): void {
private setMediumSize(size: number = 4): void {
if (size <= 12 && size >= 0) {
this.mediumSize = size;
} else {
@ -175,7 +175,7 @@ export class TileComponent implements OnInit {
*
* @param size how great the tile should be
*/
private setLargeSize(size: number): void {
private setLargeSize(size: number = 4): void {
if (size <= 16 && size >= 0) {
this.largeSize = size;
} else {

View File

@ -1,5 +1,35 @@
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
* @ignore
@ -8,7 +38,8 @@ export class Config extends BaseModel {
public static COLLECTIONSTRING = 'core/config';
public id: number;
public key: string;
public value: Object;
public value: any;
public data?: ConfigData;
public constructor(input?: any) {
super(Config.COLLECTIONSTRING, input);

View File

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

View File

@ -11,7 +11,7 @@ export interface CanComponentDeactivate {
@Injectable({
providedIn: 'root'
})
export class WatchSortingTreeGuard implements CanDeactivate<CanComponentDeactivate> {
export class WatchForChangesGuard implements CanDeactivate<CanComponentDeactivate> {
/**
* 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 { AgendaListComponent } from './components/agenda-list/agenda-list.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';
const routes: Routes = [
@ -13,7 +13,7 @@ const routes: Routes = [
{
path: 'sort-agenda',
component: AgendaSortComponent,
canDeactivate: [WatchSortingTreeGuard],
canDeactivate: [WatchForChangesGuard],
data: { basePerm: 'agenda.can_manage' }
},
{ 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 -->
<div class="title-slot"><h2 translate>Agenda</h2></div>
<!-- Menu -->
@ -119,7 +119,13 @@
<!-- Import -->
<button mat-menu-item *osPerms="'agenda.can_manage'" routerLink="import">
<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>
</div>

View File

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

View File

@ -1,5 +1,5 @@
<os-head-bar
[mainButton]="operator.hasPerms('assignments.can_manage')"
[hasMainButton]="operator.hasPerms('assignments.can_manage')"
(mainEvent)="onPlusButton()"
[multiSelectMode]="isMultiSelect"
>
@ -91,6 +91,12 @@
<mat-icon>archive</mat-icon>
<span translate>Export ...</span>
</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 *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 { SortingTreeComponent } from 'app/shared/components/sorting-tree/sorting-tree.component';
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 { BaseViewModel } from './base-view-model';

View File

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

View File

@ -2,9 +2,11 @@ import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
EventEmitter,
Input,
OnDestroy,
OnInit,
Output,
ViewEncapsulation
} from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';
@ -18,6 +20,7 @@ import { distinctUntilChanged } from 'rxjs/operators';
import { BaseComponent } from 'app/base.component';
import { ConfigRepositoryService } from 'app/core/repositories/config/config-repository.service';
import { ParentErrorStateMatcher } from 'app/shared/parent-error-state-matcher';
import { ConfigItem } from '../config-list/config-list.component';
import { ViewConfig } from '../../models/view-config';
/**
@ -26,7 +29,7 @@ import { ViewConfig } from '../../models/view-config';
*
* @example
* ```ts
* <os-config-field [item]="item.config"></os-config-field>
* <os-config-field [config]="<ViewConfig>"></os-config-field>
* ```
*/
@Component({
@ -44,16 +47,6 @@ export class ConfigFieldComponent extends BaseComponent implements OnInit, OnDes
*/
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.
*/
@ -69,8 +62,8 @@ export class ConfigFieldComponent extends BaseComponent implements OnInit, OnDes
* populated constants-info.
*/
@Input()
public set item(value: ViewConfig) {
if (value && value.hasConstantsInfo) {
public set config(value: ViewConfig) {
if (value) {
this.configItem = value;
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.
*/
@ -100,6 +112,9 @@ export class ConfigFieldComponent extends BaseComponent implements OnInit, OnDes
*/
public matcher = new ParentErrorStateMatcher();
@Output()
public update = new EventEmitter<ConfigItem>();
/**
* The usual component constructor. datetime pickers will set their locale
* 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 } {
const date = moment.unix(unix);
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;
value = this.dateAndTimeToUnix(date, time);
}
if (this.debounceTimeout !== null) {
clearTimeout(<any>this.debounceTimeout);
}
this.debounceTimeout = <any>setTimeout(() => {
this.update(value);
}, this.configItem.getDebouncingTimeout());
this.sendUpdate(value);
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.
* @param value The new value to set.
*/
private update(value: any): void {
this.debounceTimeout = null;
this.repo.update({ value: value }, this.configItem).then(() => {
this.error = null;
this.showSuccessIcon();
}, this.setError.bind(this));
private sendUpdate(value: any): void {
this.update.emit({ key: this.configItem.key, value });
}
/**
* 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 {
if (this.updateSuccessIconTimeout !== null) {
clearTimeout(<any>this.updateSuccessIconTimeout);
}
this.updateSuccessIconTimeout = <any>setTimeout(() => {
this.updateSuccessIcon = false;
if (!this.wasViewDestroyed()) {
this.cd.detectChanges();
}
}, 2000);
this.updateSuccessIcon = true;
if (!this.wasViewDestroyed()) {
private updateError(error: boolean | null): void {
if (this.form) {
this.form.setErrors(error ? { error } : null);
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:
* input, textarea, choice or date
@ -311,16 +290,6 @@ export class ConfigFieldComponent extends BaseComponent implements OnInit, OnDes
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
* 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 => {
editor.on('Blur', ev => {
if (ev.target.getContent() !== this.translatedValue) {
this.update(ev.target.getContent());
this.sendUpdate(ev.target.getContent());
}
});
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
// and would trigger an update with empty data.
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 -->
<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>
</os-head-bar>
<div class="spacer-top-20"></div>
<mat-accordion>
<ng-container *ngFor="let group of this.configs">
<mat-expansion-panel displayMode="flat">
<mat-expansion-panel-header>
<mat-panel-title>
{{ group.name | translate }}
</mat-panel-title>
</mat-expansion-panel-header>
<div *ngFor="let subgroup of group.subgroups">
<h3 class="accent">{{ subgroup.name | translate }}</h3>
<div *ngFor="let item of subgroup.items">
<os-config-field [item]="item.config"></os-config-field>
</div>
</div>
<div *ngFor="let item of group.items">
<os-config-field [item]="item.config"></os-config-field>
</div>
</mat-expansion-panel>
</ng-container>
</mat-accordion>
<mat-card class="os-card" *ngIf="configGroup">
<div id="wrapper">
<ng-container *ngFor="let subgroup of configGroup.subgroups; trackBy: trackByIndex">
<h3 class="accent" *ngIf="configGroup.subgroups.length > 1">{{ subgroup.name | translate }}</h3>
<ng-container *ngFor="let config of subgroup.configs">
<os-config-field
(update)="updateConfigGroup($event)"
[config]="config"
[errorList]="errors"
></os-config-field>
</ng-container>
</ng-container>
</div>
</mat-card>
<mat-menu #settingsMenu="matMenu">
<button mat-menu-item (click)="resetAll()">
<mat-icon>undo</mat-icon>
<span translate>Reset all to default</span>
</button>
</mat-menu>

View File

@ -1,8 +1,12 @@
mat-expansion-panel {
max-width: 770px;
margin: auto;
#wrapper {
font-size: 14px;
}
h3.accent {
margin-top: 30px;
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 { ActivatedRoute } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { Subscription } from 'rxjs';
import { BaseComponent } from 'app/base.component';
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
@ -14,13 +33,27 @@ import { ConfigGroup, ConfigRepositoryService } from 'app/core/repositories/conf
templateUrl: './config-list.component.html',
styleUrls: ['./config-list.component.scss']
})
export class ConfigListComponent extends BaseComponent implements OnInit {
public configs: ConfigGroup[];
export class ConfigListComponent extends BaseComponent implements CanComponentDeactivate, OnInit, OnDestroy {
public configGroup: ConfigGroup;
public configGroupSubscription: Subscription | null = null;
/**
* Object containing all errors.
*/
public errors = {};
/**
* Array of all changed settings.
*/
private configItems: ConfigItem[] = [];
public constructor(
protected titleService: Title,
protected translate: TranslateService,
private repo: ConfigRepositoryService
private repo: ConfigRepositoryService,
private route: ActivatedRoute,
private promptDialog: PromptService
) {
super(titleService, translate);
}
@ -29,10 +62,85 @@ export class ConfigListComponent extends BaseComponent implements OnInit {
* Sets the title, inits the table and calls the repo
*/
public ngOnInit(): void {
super.setTitle('Settings');
this.repo.getConfigListObservable().subscribe(configs => {
this.configs = configs;
const settings = this.translate.instant('Settings');
this.route.params.subscribe(params => {
this.clearSubscription();
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 -->
<div *ngFor="let translation of translations; let i = index">
<form>
<mat-form-field>
<input matInput [value]="translation.original" (input)="onChangeOriginal($event.target.value, i)" />
</mat-form-field>
<mat-icon>arrow_forward</mat-icon>
<mat-form-field>
<input matInput [value]="translation.translation" (input)="onChangeTranslation($event.target.value, i)" />
</mat-form-field>
<button mat-icon-button>
<mat-icon (click)="onRemoveTranslation(i)">close</mat-icon>
</button>
</form>
</div>
<form [formGroup]="translationForm">
<div
class="inline-form"
formArrayName="translationBoxes"
*ngFor="let translation of translationBoxes.controls; let i = index"
>
<ng-container [formGroupName]="i">
<mat-form-field>
<input formControlName="original" matInput placeholder="{{ 'Original' | translate }}" />
<mat-error translate>You have to fill this field.</mat-error>
</mat-form-field>
<mat-icon>arrow_forward</mat-icon>
<mat-form-field>
<input formControlName="translation" matInput placeholder="{{ 'Translation' | translate }}" />
<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 -->
<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 { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { CustomTranslation, CustomTranslations } from 'app/core/translate/translation-parser';
import { Component, forwardRef, OnInit } from '@angular/core';
import { ControlValueAccessor, FormArray, FormBuilder, FormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms';
/**
* 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
@ -46,7 +67,9 @@ export class CustomTranslationComponent implements ControlValueAccessor {
*/
public writeValue(obj: any): void {
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 {}
/**
* 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
*
* @param index the translation to remove
*/
public onRemoveTranslation(index: number): void {
this.translations.splice(index, 1);
this.propagateChange(this.translations);
this.translationBoxes.removeAt(index);
}
/**
* 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 {
const newCustomTranslation: CustomTranslation = {
original: 'New',
translation: 'New'
};
this.translations.push(newCustomTranslation);
this.propagateChange(this.translations);
public addNewTranslation(original: string = '', translation: string = ''): void {
this.translationBoxes.push(
this.fb.group({
original: [original, Validators.required],
translation: [translation, Validators.required]
})
);
}
}

View File

@ -1,9 +1,14 @@
import { NgModule } from '@angular/core';
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 { 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({
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 { ConfigListComponent } from './components/config-list/config-list.component';
import { ConfigOverviewComponent } from './components/config-overview/config-overview.component';
import { ConfigRoutingModule } from './config-routing.module';
import { CustomTranslationComponent } from './components/custom-translation/custom-translation.component';
import { SharedModule } from '../../shared/shared.module';
@NgModule({
imports: [CommonModule, ConfigRoutingModule, SharedModule],
declarations: [ConfigListComponent, ConfigFieldComponent, CustomTranslationComponent],
declarations: [ConfigOverviewComponent, ConfigListComponent, ConfigFieldComponent, CustomTranslationComponent],
entryComponents: [CustomTranslationComponent]
})
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';
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 {
key: string;
}
@ -43,22 +12,6 @@ export class ViewConfig extends BaseViewModel<Config> implements ConfigTitleInfo
public static 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 {
return this._model;
}
@ -67,28 +20,48 @@ export class ViewConfig extends BaseViewModel<Config> implements ConfigTitleInfo
return this.config.key;
}
public get value(): Object {
public get value(): any {
return this.config.value;
}
public get data(): ConfigData | null {
return this.config.data;
}
public get hidden(): boolean {
return !this.data;
}
public get label(): string {
return this._label;
return this.data.label;
}
public get inputType(): ConfigInputType {
return this._inputType;
public get inputType(): ConfigInputType | null {
return this.data.inputType;
}
public get helpText(): string {
return this._helpText;
public get helpText(): string | null {
return this.data.helpText;
}
public get choices(): Object {
return this._choices;
public get choices(): ConfigChoice[] | null {
return this.data.choices;
}
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;
}
}
/**
* 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 -->
<div class="title-slot">
<h2 translate>Files</h2>

View File

@ -1,11 +1,11 @@
import { NgModule } from '@angular/core';
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';
const routes: Routes = [
{ path: '', component: CallListComponent, pathMatch: 'full', canDeactivate: [WatchSortingTreeGuard] }
{ path: '', component: CallListComponent, pathMatch: 'full', canDeactivate: [WatchForChangesGuard] }
];
@NgModule({

View File

@ -1,7 +1,7 @@
import { NgModule } from '@angular/core';
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 { CategoryDetailComponent } from './components/category-detail/category-detail.component';
import { CategoryListComponent } from './components/category-list/category-list.component';
@ -9,8 +9,8 @@ import { CategoryMotionsSortComponent } from './components/category-motions-sort
const routes: Routes = [
{ path: '', component: CategoryListComponent, pathMatch: 'full' },
{ path: ':id/sort', component: CategoryMotionsSortComponent, canDeactivate: [WatchSortingTreeGuard] },
{ path: 'sort', component: CategoriesSortComponent, canDeactivate: [WatchSortingTreeGuard] },
{ path: ':id/sort', component: CategoryMotionsSortComponent, canDeactivate: [WatchForChangesGuard] },
{ path: 'sort', component: CategoriesSortComponent, canDeactivate: [WatchForChangesGuard] },
{ 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 { PromptService } from 'app/core/ui-services/prompt.service';
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 { 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 -->
<div class="title-slot">
<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 { PromptService } from 'app/core/ui-services/prompt.service';
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 { ViewCategory } from 'app/site/motions/models/view-category';
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 -->
<div class="title-slot"><h2 translate>Motion blocks</h2></div>
</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 -->
<div class="title-slot">
<h2 translate>Comment fields</h2>

View File

@ -1,5 +1,5 @@
<os-head-bar
[mainButton]="perms.isAllowed('can_create_amendments', motion)"
[hasMainButton]="perms.isAllowed('can_create_amendments', motion)"
mainActionTooltip="New amendment"
[prevUrl]="getPrevUrl()"
[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 -->
<div class="title-slot"><h2 translate>Motions</h2></div>
@ -315,6 +315,14 @@
<span translate>Import</span>
</button>
</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 *ngIf="isMultiSelect">
<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 -->
<div class="title-slot">
<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 -->
<div class="title-slot"><h2 translate>Workflows</h2></div>
</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 -->
<div class="title-slot">
<h2 translate>Statute</h2>
@ -54,7 +54,7 @@
</button>
<button mat-menu-item *osPerms="'motions.can_manage'" routerLink="import">
<mat-icon>cloud_upload</mat-icon>
<span translate>Import</span><span>&nbsp;...</span>
<span translate>Import</span>
</button>
</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 -->
<div class="title-slot">
<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>
</os-head-bar>

View File

@ -1,5 +1,5 @@
<os-head-bar
[mainButton]="isAllowed('edit')"
[hasMainButton]="isAllowed('edit')"
mainButtonIcon="edit"
[nav]="false"
[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 -->
<div class="title-slot">
<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 -->
<div class="title-slot"><h2 translate>Change password</h2></div>
</os-head-bar>

View File

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

View File

@ -1,5 +1,5 @@
<os-head-bar
[mainButton]="isAllowed('changePersonal')"
[hasMainButton]="isAllowed('changePersonal')"
mainButtonIcon="edit"
[nav]="false"
[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 -->
<div class="title-slot"><h2 translate>Participants</h2></div>
@ -126,9 +126,9 @@
<!-- Presence -->
<button mat-menu-item (click)="setPresent(user)">
<mat-icon color="accent"> {{ user.is_present ? 'check_box' : 'check_box_outline_blank' }} </mat-icon>
<span translate>Present</span>
</button>
<mat-icon color="accent"> {{ user.is_present ? 'check_box' : 'check_box_outline_blank' }} </mat-icon>
<span translate>Present</span>
</button>
</ng-template>
</mat-menu>
@ -168,7 +168,15 @@
<button mat-menu-item *osPerms="'users.can_manage'" routerLink="import">
<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>
</div>
<div *ngIf="isMultiSelect">
@ -201,11 +209,6 @@
</button>
</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'">
<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 {
.custom-table-header,
.background--default {
background: mat-color($background, background);
}

View File

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

View File

@ -1,7 +1,5 @@
import sys
from collections import OrderedDict
from operator import attrgetter
from typing import Any, Dict, List
from typing import Any, Dict
from django.apps import AppConfig
from django.conf import settings
@ -139,8 +137,6 @@ class CoreAppConfig(AppConfig):
yield self.get_model(model_name)
def get_angular_constants(self):
from .config import config
constants: Dict[str, Any] = {}
# Client settings
@ -159,34 +155,7 @@ class CoreAppConfig(AppConfig):
pass
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()
return constants

View File

@ -24,6 +24,8 @@ INPUT_TYPE_MAPPING = {
"translations": list,
}
ALLOWED_NONE = ("datetimepicker",)
build_key_to_id_lock = asyncio.Lock()
@ -119,12 +121,15 @@ class ConfigHandler:
expected_type = INPUT_TYPE_MAPPING[config_variable.input_type]
# Try to convert value into the expected datatype
try:
value = expected_type(value)
except ValueError:
raise ConfigError(
f"Wrong datatype. Expected {expected_type}, got {type(value)}."
)
if value is None and config_variable.input_type not in ALLOWED_NONE:
raise ConfigError(f"Got None for {key}")
elif value is not None:
try:
value = expected_type(value)
except (ValueError, TypeError):
raise ConfigError(
f"Wrong datatype. Expected {expected_type}, got {type(value)}."
)
if config_variable.input_type == "choice":
# Choices can be a callable. In this case call it at this place
@ -267,12 +272,14 @@ OnChangeType = Callable[[], None]
ConfigVariableDict = TypedDict(
"ConfigVariableDict",
{
"key": str,
"default_value": Any,
"input_type": str,
"defaultValue": Any,
"inputType": str,
"label": str,
"help_text": str,
"helpText": str,
"choices": ChoiceType,
"weight": int,
"group": str,
"subgroup": Optional[str],
},
)
@ -314,8 +321,8 @@ class ConfigVariable:
choices: ChoiceCallableType = None,
hidden: bool = False,
weight: int = 0,
group: str = None,
subgroup: str = None,
group: str = "General",
subgroup: str = "General",
validators: ValidatorsType = None,
on_change: OnChangeType = None,
) -> None:
@ -339,28 +346,26 @@ class ConfigVariable:
self.choices = choices
self.hidden = hidden
self.weight = weight
self.group = group or "General"
self.group = group
self.subgroup = subgroup
self.validators = validators or ()
self.on_change = on_change
@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(
key=self.name,
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,
)
if self.hidden:
return None
def is_hidden(self) -> bool:
"""
Returns True if the config variable is hidden so it can be removed
from response of OPTIONS request.
"""
return self.hidden
return ConfigVariableDict(
defaultValue=self.default_value,
inputType=self.input_type,
label=self.label,
helpText=self.help_text,
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",
label="Event name",
weight=110,
group="General",
subgroup="Event",
validators=(MaxLengthValidator(100),),
)
@ -28,7 +27,6 @@ def get_config_variables():
default_value="Presentation and assembly system",
label="Short description of event",
weight=115,
group="General",
subgroup="Event",
validators=(MaxLengthValidator(100),),
)
@ -38,7 +36,6 @@ def get_config_variables():
default_value="",
label="Event date",
weight=120,
group="General",
subgroup="Event",
)
@ -47,7 +44,6 @@ def get_config_variables():
default_value="",
label="Event location",
weight=125,
group="General",
subgroup="Event",
)
@ -60,7 +56,6 @@ def get_config_variables():
input_type="markupText",
label="Legal notice",
weight=132,
group="General",
subgroup="Event",
)
@ -70,7 +65,6 @@ def get_config_variables():
input_type="markupText",
label="Privacy policy",
weight=132,
group="General",
subgroup="Event",
)
@ -79,7 +73,6 @@ def get_config_variables():
default_value="Welcome to OpenSlides",
label="Front page title",
weight=134,
group="General",
subgroup="Event",
)
@ -89,7 +82,6 @@ def get_config_variables():
input_type="markupText",
label="Front page text",
weight=136,
group="General",
subgroup="Event",
)
@ -101,7 +93,6 @@ def get_config_variables():
input_type="boolean",
label="Allow access for anonymous guest users",
weight=138,
group="General",
subgroup="System",
)
@ -110,7 +101,6 @@ def get_config_variables():
default_value="",
label="Show this text on the login page",
weight=140,
group="General",
subgroup="System",
)
@ -129,7 +119,6 @@ def get_config_variables():
},
),
weight=141,
group="General",
subgroup="System",
)
@ -140,7 +129,6 @@ def get_config_variables():
default_value=",",
label="Separator used for all csv exports and examples",
weight=160,
group="General",
subgroup="Export",
)
@ -154,7 +142,6 @@ def get_config_variables():
{"value": "iso-8859-15", "display_name": "ISO-8859-15"},
),
weight=162,
group="General",
subgroup="Export",
)
@ -169,7 +156,6 @@ def get_config_variables():
{"value": "right", "display_name": "Right"},
),
weight=164,
group="General",
subgroup="Export",
)
@ -184,7 +170,6 @@ def get_config_variables():
{"value": "12", "display_name": "12"},
),
weight=166,
group="General",
subgroup="Export",
)
@ -198,7 +183,6 @@ def get_config_variables():
{"value": "A5", "display_name": "DIN A5"},
),
weight=168,
group="General",
subgroup="Export",
)

View File

@ -1,11 +1,13 @@
from typing import Any
from ..core.config import config
from ..utils.projector import projector_slides
from ..utils.rest_api import (
Field,
IdPrimaryKeyRelatedField,
IntegerField,
ModelSerializer,
SerializerMethodField,
ValidationError,
)
from ..utils.validate import validate_html
@ -136,10 +138,14 @@ class ConfigSerializer(ModelSerializer):
"""
value = JSONSerializerField()
data = SerializerMethodField()
class Meta:
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):

View File

@ -37,6 +37,7 @@ from ..utils.rest_api import (
RetrieveModelMixin,
ValidationError,
detail_route,
list_route,
)
from .access_permissions import (
ConfigAccessPermissions,
@ -390,30 +391,46 @@ class ConfigViewSet(ModelViewSet):
access_permissions = ConfigAccessPermissions()
queryset = ConfigStore.objects.all()
can_manage_config = None
can_manage_logos_and_fonts = None
def check_view_permissions(self):
"""
Returns True if the user has required permissions.
"""
if self.action in ("list", "retrieve"):
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"):
# The user needs 'core.can_manage_logos_and_fonts' for all config values
# starting with 'logo' and 'font'. For all other config values th euser needs
# the default permissions 'core.can_manage_config'.
pk = self.kwargs["pk"]
if pk.startswith("logo") or pk.startswith("font"):
result = has_perm(self.request.user, "core.can_manage_logos_and_fonts")
else:
result = has_perm(self.request.user, "core.can_manage_config")
result = self.check_config_permission(self.kwargs["pk"])
elif self.action == "reset_groups":
result = has_perm(self.request.user, "core.can_manage_config")
elif self.action == "bulk_update":
result = True # will be checked in the view
else:
result = False
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):
"""
Updates a config variable. Only managers can do this.
@ -422,8 +439,6 @@ class ConfigViewSet(ModelViewSet):
"""
key = kwargs["pk"]
value = request.data.get("value")
if value is None:
raise ValidationError({"detail": "Invalid input. Config value is missing."})
# Validate and change value.
try:
@ -436,6 +451,60 @@ class ConfigViewSet(ModelViewSet):
# Return response.
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):
"""

View File

@ -35,7 +35,6 @@ def get_config_variables():
choices=get_workflow_choices,
weight=310,
group="Motions",
subgroup="General",
)
yield ConfigVariable(
@ -46,7 +45,6 @@ def get_config_variables():
choices=get_workflow_choices,
weight=312,
group="Motions",
subgroup="General",
)
yield ConfigVariable(
@ -55,7 +53,6 @@ def get_config_variables():
label="Motion preamble",
weight=320,
group="Motions",
subgroup="General",
)
yield ConfigVariable(
@ -70,7 +67,6 @@ def get_config_variables():
),
weight=322,
group="Motions",
subgroup="General",
)
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",
weight=323,
group="Motions",
subgroup="General",
validators=(MinValueValidator(40),),
)
@ -92,7 +87,6 @@ def get_config_variables():
label="Reason required for creating new motion",
weight=324,
group="Motions",
subgroup="General",
)
yield ConfigVariable(
@ -102,7 +96,6 @@ def get_config_variables():
label="Hide motion text on projector",
weight=325,
group="Motions",
subgroup="General",
)
yield ConfigVariable(
@ -112,7 +105,6 @@ def get_config_variables():
label="Hide reason on projector",
weight=326,
group="Motions",
subgroup="General",
)
yield ConfigVariable(
@ -122,7 +114,6 @@ def get_config_variables():
label="Hide meta information box on projector",
weight=327,
group="Motions",
subgroup="General",
)
yield ConfigVariable(
@ -132,7 +123,6 @@ def get_config_variables():
label="Hide recommendation on projector",
weight=328,
group="Motions",
subgroup="General",
)
yield ConfigVariable(
@ -142,7 +132,6 @@ def get_config_variables():
label="Hide referring motions",
weight=329,
group="Motions",
subgroup="General",
)
yield ConfigVariable(
@ -153,7 +142,6 @@ def get_config_variables():
help_text="In motion list, motion detail and PDF.",
weight=330,
group="Motions",
subgroup="General",
)
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.",
weight=332,
group="Motions",
subgroup="General",
)
yield ConfigVariable(
@ -173,7 +160,6 @@ def get_config_variables():
help_text="Will be displayed as label before selected recommendation in statute amendments.",
weight=333,
group="Motions",
subgroup="General",
)
yield ConfigVariable(
@ -189,7 +175,6 @@ def get_config_variables():
),
weight=334,
group="Motions",
subgroup="General",
)
yield ConfigVariable(
@ -203,7 +188,6 @@ def get_config_variables():
),
weight=335,
group="Motions",
subgroup="General",
)
# Numbering

View File

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

View File

@ -223,7 +223,7 @@ class ConfigViewSet(TestCase):
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
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
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.urls import reverse
from rest_framework import status
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.models import Projector, Tag
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.test import TestCase
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_preview, elements)
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.mock import patch
from openslides.core.config import ConfigVariable, config
from openslides.core.config import ConfigVariable, ConfigVariableDict, config
from openslides.core.exceptions import ConfigNotFound
@ -14,15 +15,15 @@ class TestConfigVariable(TestCase):
"""
config_variable = ConfigVariable("test_variable", "test_default_value")
self.assertIn(
"default_value",
config_variable.data,
"Config_varialbe.data should have a key 'default_value'",
self.assertTrue(
"defaultValue" in cast(ConfigVariableDict, config_variable.data)
)
data = config_variable.data
self.assertTrue(data)
self.assertEqual(
config_variable.data["default_value"],
cast(ConfigVariableDict, config_variable.data)["defaultValue"],
"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()",
)