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 { 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>
@ -124,9 +124,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>
@ -166,7 +166,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">
@ -199,11 +207,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,8 +1,6 @@
import os
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
@ -127,8 +125,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
@ -147,34 +143,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()",
)