diff --git a/client/src/app/core/services/app-load.service.ts b/client/src/app/core/services/app-load.service.ts index 652aa2b60..f0ef8a4c1 100644 --- a/client/src/app/core/services/app-load.service.ts +++ b/client/src/app/core/services/app-load.service.ts @@ -6,7 +6,7 @@ import { AppConfig } from '../../site/base/app-config'; import { CollectionStringModelMapperService } from './collectionStringModelMapper.service'; import { MediafileAppConfig } from '../../site/mediafiles/mediafile.config'; import { MotionsAppConfig } from '../../site/motions/motions.config'; -import { SettingsAppConfig } from '../../site/settings/settings.config'; +import { ConfigAppConfig } from '../../site/config/config.config'; import { AgendaAppConfig } from '../../site/agenda/agenda.config'; import { AssignmentsAppConfig } from '../../site/assignments/assignments.config'; import { UsersAppConfig } from '../../site/users/users.config'; @@ -17,7 +17,7 @@ import { MainMenuService } from './main-menu.service'; */ const appConfigs: AppConfig[] = [ CommonAppConfig, - SettingsAppConfig, + ConfigAppConfig, AgendaAppConfig, AssignmentsAppConfig, MotionsAppConfig, diff --git a/client/src/app/shared/date-adapter.ts b/client/src/app/shared/date-adapter.ts new file mode 100644 index 000000000..bcda23226 --- /dev/null +++ b/client/src/app/shared/date-adapter.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@angular/core'; +import { NativeDateAdapter } from '@angular/material'; + +/** + * A custom DateAdapter for the datetimepicker in the config. This is still not fully working and needs to be done later. + * See comments in PR #3895. + */ +@Injectable() +export class OpenSlidesDateAdapter extends NativeDateAdapter { + public format(date: Date, displayFormat: Object): string { + if (displayFormat === 'input') { + return this.toFullIso8601(date); + } else { + return date.toDateString(); + } + } + + private to2digit(n: number): string { + return ('00' + n).slice(-2); + } + + public toFullIso8601(date: Date): string { + return ( + [date.getUTCFullYear(), this.to2digit(date.getUTCMonth() + 1), this.to2digit(date.getUTCDate())].join('-') + + 'T' + + [this.to2digit(date.getUTCHours()), this.to2digit(date.getUTCMinutes())].join(':') + ); + } +} diff --git a/client/src/app/shared/parent-error-state-matcher.ts b/client/src/app/shared/parent-error-state-matcher.ts new file mode 100644 index 000000000..c1cd9e0eb --- /dev/null +++ b/client/src/app/shared/parent-error-state-matcher.ts @@ -0,0 +1,22 @@ +import { ErrorStateMatcher } from '@angular/material'; +import { FormControl, FormGroupDirective, NgForm } from '@angular/forms'; + +/** + * Custom state matcher for mat-errors. Enables the error for an input, if one has set the error + * with `setError` on the parent element. + */ +export class ParentErrorStateMatcher implements ErrorStateMatcher { + public isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean { + const isSubmitted = !!(form && form.submitted); + const controlTouched = !!(control && (control.dirty || control.touched)); + const controlInvalid = !!(control && control.invalid); + const parentInvalid = !!( + control && + control.parent && + control.parent.invalid && + (control.parent.dirty || control.parent.touched) + ); + + return (isSubmitted || controlTouched) && (controlInvalid || parentInvalid); + } +} diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts index f497e2260..74c0f4d98 100644 --- a/client/src/app/shared/shared.module.ts +++ b/client/src/app/shared/shared.module.ts @@ -16,7 +16,10 @@ import { MatTableModule, MatPaginatorModule, MatSortModule, - MatTooltipModule + MatTooltipModule, + MatDatepickerModule, + MatNativeDateModule, + DateAdapter } from '@angular/material'; import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { MatChipsModule } from '@angular/material'; @@ -46,6 +49,7 @@ import { FooterComponent } from './components/footer/footer.component'; import { LegalNoticeContentComponent } from './components/legal-notice-content/legal-notice-content.component'; import { PrivacyPolicyContentComponent } from './components/privacy-policy-content/privacy-policy-content.component'; import { SearchValueSelectorComponent } from './components/search-value-selector/search-value-selector.component'; +import { OpenSlidesDateAdapter } from './date-adapter'; library.add(fas); @@ -69,6 +73,8 @@ library.add(fas); MatButtonModule, MatCheckboxModule, MatToolbarModule, + MatDatepickerModule, + MatNativeDateModule, MatCardModule, MatInputModule, MatTableModule, @@ -98,6 +104,7 @@ library.add(fas); MatCheckboxModule, MatToolbarModule, MatCardModule, + MatDatepickerModule, MatInputModule, MatTableModule, MatSortModule, @@ -130,6 +137,7 @@ library.add(fas); LegalNoticeContentComponent, PrivacyPolicyContentComponent, SearchValueSelectorComponent - ] + ], + providers: [{ provide: DateAdapter, useClass: OpenSlidesDateAdapter }] }) export class SharedModule {} diff --git a/client/src/app/site/base/base-repository.ts b/client/src/app/site/base/base-repository.ts index 6ff8c2a6d..5120f4fe1 100644 --- a/client/src/app/site/base/base-repository.ts +++ b/client/src/app/site/base/base-repository.ts @@ -33,9 +33,12 @@ export abstract class BaseRepository[] ) { super(); + this.setup(); + } + protected setup(): void { // Populate the local viewModelStore with ViewModel Objects. - this.DS.getAll(baseModelCtor).forEach((model: M) => { + this.DS.getAll(this.baseModelCtor).forEach((model: M) => { this.viewModelStore[model.id] = this.createViewModel(model); this.updateViewModelObservable(model.id); }); @@ -63,7 +66,7 @@ export abstract class BaseRepository { - if (model.collection === CollectionStringModelMapperService.getCollectionString(baseModelCtor)) { + if (model.collection === CollectionStringModelMapperService.getCollectionString(this.baseModelCtor)) { delete this.viewModelStore[model.id]; this.updateAllObservables(model.id); } diff --git a/client/src/app/site/config/components/config-field/config-field.component.html b/client/src/app/site/config/components/config-field/config-field.component.html new file mode 100644 index 000000000..49bd6d918 --- /dev/null +++ b/client/src/app/site/config/components/config-field/config-field.component.html @@ -0,0 +1,66 @@ +
+ + + + + + + + + + + + + + + + + + + + {{ configItem.label }} + {{ configItem.helpText }} + + + + + {{ error }} + + + + + + + + + + + + + {{ choice.display_name | translate }} + + + + + + + + + + + + + +
+
+ + {{ configItem.label | translate }} + +
+ {{ configItem.helpText | translate }} +
+
+ {{ error }} +
+
diff --git a/client/src/app/site/config/components/config-field/config-field.component.scss b/client/src/app/site/config/components/config-field/config-field.component.scss new file mode 100644 index 000000000..4ebea5d50 --- /dev/null +++ b/client/src/app/site/config/components/config-field/config-field.component.scss @@ -0,0 +1,26 @@ +/* Full width form fields */ +.mat-form-field { + width: 100%; +} + +/* limit the color bar of color inputs */ +input[type='color'] { + max-width: 100px; +} + +/* Spacing between config entries */ +.config-form-group { + margin-bottom: 15px; +} + +/* Custom hint and error classes for the checkbox. Same values as .mat-hint and -mat-error */ +.hint, +.error { + font-size: 75%; +} +.hint { + color: rgba(0, 0, 0, 0.54); +} +.error { + color: #f44336; +} diff --git a/client/src/app/site/settings/settings-list/settings-list.component.css b/client/src/app/site/config/components/config-field/config-field.component.spec.ts similarity index 100% rename from client/src/app/site/settings/settings-list/settings-list.component.css rename to client/src/app/site/config/components/config-field/config-field.component.spec.ts diff --git a/client/src/app/site/config/components/config-field/config-field.component.ts b/client/src/app/site/config/components/config-field/config-field.component.ts new file mode 100644 index 000000000..964b8f471 --- /dev/null +++ b/client/src/app/site/config/components/config-field/config-field.component.ts @@ -0,0 +1,180 @@ +import { Component, OnInit, Input, ChangeDetectorRef, ChangeDetectionStrategy } from '@angular/core'; +import { Title } from '@angular/platform-browser'; +import { TranslateService } from '@ngx-translate/core'; +import { ViewConfig } from '../../models/view-config'; +import { BaseComponent } from '../../../../base.component'; +import { FormGroup, FormBuilder } from '@angular/forms'; +import { ConfigRepositoryService } from '../../services/config-repository.service'; +import { tap } from 'rxjs/operators'; +import { ParentErrorStateMatcher } from '../../../../shared/parent-error-state-matcher'; + +/** + * List view for the categories. + * + * TODO: Creation of new Categories + */ +@Component({ + selector: 'os-config-field', + templateUrl: './config-field.component.html', + styleUrls: ['./config-field.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ConfigFieldComponent extends BaseComponent implements OnInit { + public configItem: ViewConfig; + + /** + * Option to show a green check-icon. + */ + 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. + */ + public error: string | null = null; + + /** + * The config item for this component. Just accept components with already populated constants-info. + */ + @Input() + public set item(value: ViewConfig) { + if (value.hasConstantsInfo) { + this.configItem = value; + + if (this.form) { + this.form.patchValue( + { + value: this.configItem.value + }, + { emitEvent: false } + ); + } + } + } + + /** + * The form for this configItem. + */ + public form: FormGroup; + + /** + * The matcher for custom (request) errors. + */ + public matcher = new ParentErrorStateMatcher(); + + /** + * The usual component constructor + * @param titleService + * @param translate + */ + public constructor( + protected titleService: Title, + protected translate: TranslateService, + private formBuilder: FormBuilder, + private cdRef: ChangeDetectorRef, + public repo: ConfigRepositoryService + ) { + super(titleService, translate); + } + + /** + * Sets up the form for this config field. + */ + public ngOnInit(): void { + this.form = this.formBuilder.group({ + value: [''] + }); + this.form.patchValue({ + value: this.configItem.value + }); + this.form.valueChanges.subscribe(form => { + this.onChange(form.value); + }); + } + + /** + * Trigger an update of the data + */ + private onChange(value: any): void { + if (this.debounceTimeout !== null) { + clearTimeout(this.debounceTimeout); + } + this.debounceTimeout = setTimeout(() => { + this.update(value); + }, this.configItem.getDebouncingTimeout()); + this.cdRef.detectChanges(); + } + + /** + * Updates the this config field. + */ + private update(value: any): void { + // TODO: Fix the Datetimepicker parser and formatter. + if (this.configItem.inputType === 'datetimepicker') { + value = Date.parse(value); + } + this.debounceTimeout = null; + this.repo + .update({ value: value }, this.configItem) + .pipe( + tap( + response => { + this.error = null; + this.showSuccessIcon(); + }, + error => { + this.setError(error.error.detail); + } + ) + ) + .subscribe(); + } + + /** + * Show the green success icon on the component. The icon gets automatically cleared. + */ + private showSuccessIcon(): void { + if (this.updateSuccessIconTimeout !== null) { + clearTimeout(this.updateSuccessIconTimeout); + } + this.updateSuccessIconTimeout = setTimeout(() => { + this.updateSuccessIcon = false; + this.cdRef.detectChanges(); + }, 2000); + this.updateSuccessIcon = true; + this.cdRef.detectChanges(); + } + + /** + * Sets the error on this field. + */ + private setError(error: string): void { + this.error = error; + this.form.setErrors({ error: true }); + this.cdRef.detectChanges(); + } + + /** + * Uses the configItem to determine the kind of interation: + * input, textarea, choice or date + */ + public formType(type: string): string { + switch (type) { + case 'integer': + return 'number'; + case 'colorpicker': + return 'color'; + default: + return 'text'; + } + } +} diff --git a/client/src/app/site/config/components/config-list/config-list.component.html b/client/src/app/site/config/components/config-list/config-list.component.html new file mode 100644 index 000000000..64e250fed --- /dev/null +++ b/client/src/app/site/config/components/config-list/config-list.component.html @@ -0,0 +1,26 @@ + + + + + + + + {{ group.name | translate }} + + +
+ + {{ subgroup.name | translate }} + +
+ +
+
+
+
+
+ +
+
+
+
diff --git a/client/src/app/site/config/components/config-list/config-list.component.scss b/client/src/app/site/config/components/config-list/config-list.component.scss new file mode 100644 index 000000000..6571d4d16 --- /dev/null +++ b/client/src/app/site/config/components/config-list/config-list.component.scss @@ -0,0 +1,3 @@ +mat-card { + margin-bottom: 10px; +} diff --git a/client/src/app/site/config/components/config-list/config-list.component.spec.ts b/client/src/app/site/config/components/config-list/config-list.component.spec.ts new file mode 100644 index 000000000..14a13fee2 --- /dev/null +++ b/client/src/app/site/config/components/config-list/config-list.component.spec.ts @@ -0,0 +1,27 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from '../../../../../e2e-imports.module'; +import { ConfigListComponent } from './config-list.component'; +import { ConfigFieldComponent } from '../config-field/config-field.component'; + +describe('ConfigListComponent', () => { + let component: ConfigListComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + declarations: [ConfigListComponent, ConfigFieldComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ConfigListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/config/components/config-list/config-list.component.ts b/client/src/app/site/config/components/config-list/config-list.component.ts new file mode 100644 index 000000000..25175a68d --- /dev/null +++ b/client/src/app/site/config/components/config-list/config-list.component.ts @@ -0,0 +1,36 @@ +import { Component, OnInit } from '@angular/core'; +import { Title } from '@angular/platform-browser'; +import { TranslateService } from '@ngx-translate/core'; +import { ConfigRepositoryService, ConfigGroup } from '../../services/config-repository.service'; +import { BaseComponent } from '../../../../base.component'; + +/** + * List view for the global settings + */ +@Component({ + selector: 'os-config-list', + templateUrl: './config-list.component.html', + styleUrls: ['./config-list.component.scss'] +}) +export class ConfigListComponent extends BaseComponent implements OnInit { + public configs: ConfigGroup[]; + + public constructor( + protected titleService: Title, + protected translate: TranslateService, + private repo: ConfigRepositoryService + ) { + super(titleService, translate); + } + + /** + * Sets the title, inits the table and calls the repo + */ + public ngOnInit(): void { + super.setTitle('Settings'); + + this.repo.getConfigListObservable().subscribe(configs => { + this.configs = configs; + }); + } +} diff --git a/client/src/app/site/config/config-routing.module.ts b/client/src/app/site/config/config-routing.module.ts new file mode 100644 index 000000000..27d6cda20 --- /dev/null +++ b/client/src/app/site/config/config-routing.module.ts @@ -0,0 +1,11 @@ +import { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; +import { ConfigListComponent } from './components/config-list/config-list.component'; + +const routes: Routes = [{ path: '', component: ConfigListComponent }]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class ConfigRoutingModule {} diff --git a/client/src/app/site/settings/settings.config.ts b/client/src/app/site/config/config.config.ts similarity index 90% rename from client/src/app/site/settings/settings.config.ts rename to client/src/app/site/config/config.config.ts index 43164072c..ddf3d5b1f 100644 --- a/client/src/app/site/settings/settings.config.ts +++ b/client/src/app/site/config/config.config.ts @@ -1,7 +1,7 @@ import { AppConfig } from '../base/app-config'; import { Config } from '../../shared/models/core/config'; -export const SettingsAppConfig: AppConfig = { +export const ConfigAppConfig: AppConfig = { name: 'settings', models: [{ collectionString: 'core/config', model: Config }], mainMenuEntries: [ diff --git a/client/src/app/site/settings/settings.module.spec.ts b/client/src/app/site/config/config.module.spec.ts similarity index 55% rename from client/src/app/site/settings/settings.module.spec.ts rename to client/src/app/site/config/config.module.spec.ts index 70c27cf6d..a6b2dc697 100644 --- a/client/src/app/site/settings/settings.module.spec.ts +++ b/client/src/app/site/config/config.module.spec.ts @@ -1,10 +1,10 @@ -import { SettingsModule } from './settings.module'; +import { ConfigModule } from './config.module'; describe('SettingsModule', () => { - let settingsModule: SettingsModule; + let settingsModule: ConfigModule; beforeEach(() => { - settingsModule = new SettingsModule(); + settingsModule = new ConfigModule(); }); it('should create an instance', () => { diff --git a/client/src/app/site/config/config.module.ts b/client/src/app/site/config/config.module.ts new file mode 100644 index 000000000..2230cba92 --- /dev/null +++ b/client/src/app/site/config/config.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '../../shared/shared.module'; +import { ConfigRoutingModule } from './config-routing.module'; +import { ConfigListComponent } from './components/config-list/config-list.component'; +import { ConfigFieldComponent } from './components/config-field/config-field.component'; + +@NgModule({ + imports: [CommonModule, ConfigRoutingModule, SharedModule], + declarations: [ConfigListComponent, ConfigFieldComponent] +}) +export class ConfigModule {} diff --git a/client/src/app/site/config/models/view-config.ts b/client/src/app/site/config/models/view-config.ts new file mode 100644 index 000000000..271f12a77 --- /dev/null +++ b/client/src/app/site/config/models/view-config.ts @@ -0,0 +1,133 @@ +import { BaseViewModel } from '../../base/base-view-model'; +import { Config } from '../../../shared/models/core/config'; + +interface ConfigChoice { + value: string; + displayName: string; +} + +/** + * All valid input types for config variables. + */ +type ConfigInputType = + | 'text' + | 'string' + | 'boolean' + | 'markupText' + | 'integer' + | 'majorityMethod' + | '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[]; +} + +/** + * The view model for configs. + */ +export class ViewConfig extends BaseViewModel { + /** + * The underlying config. + */ + private _config: Config; + + /* This private members are set by setConstantsInfo. */ + private _helpText: string; + private _inputType: ConfigInputType; + private _label: string; + private _choices: ConfigChoice[]; + + /** + * Saves, if this config already got constants information. + */ + private _hasConstantsInfo = false; + + public get hasConstantsInfo(): boolean { + return this._hasConstantsInfo; + } + + public get config(): Config { + return this._config; + } + + public get id(): number { + return this.config ? this.config.id : null; + } + + public get key(): string { + return this.config ? this.config.key : null; + } + + public get value(): Object { + return this.config ? this.config.value : null; + } + + public get label(): string { + return this._label; + } + + public get inputType(): ConfigInputType { + return this._inputType; + } + + public get helpText(): string { + return this._helpText; + } + + public get choices(): Object { + return this._choices; + } + + public constructor(config: Config) { + super(); + this._config = config; + } + + public getTitle(): string { + return this.label; + } + + public updateValues(update: Config): void { + this._config = update; + } + + /** + * Returns the time this config field needs to debounce before sending a request to the server. + * A little debounce time for all inputs is given here and is usefull, if inputs sends multiple onChange-events, + * like the type="color" input... + */ + public getDebouncingTimeout(): number { + if (this.inputType === 'string' || this.inputType === 'text' || this.inputType === 'markupText') { + return 1000; + } else { + 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; + this._hasConstantsInfo = true; + } + + public copy(): ViewConfig { + return new ViewConfig(this._config); + } +} diff --git a/client/src/app/site/config/services/config-repository.service.spec.ts b/client/src/app/site/config/services/config-repository.service.spec.ts new file mode 100644 index 000000000..978ab6abd --- /dev/null +++ b/client/src/app/site/config/services/config-repository.service.spec.ts @@ -0,0 +1,17 @@ +import { TestBed, inject } from '@angular/core/testing'; + +import { ConfigRepositoryService } from './config-repository.service'; +import { E2EImportsModule } from 'e2e-imports.module'; + +describe('ConfigRepositoryService', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + providers: [ConfigRepositoryService] + }); + }); + + it('should be created', inject([ConfigRepositoryService], (service: ConfigRepositoryService) => { + expect(service).toBeTruthy(); + })); +}); diff --git a/client/src/app/site/config/services/config-repository.service.ts b/client/src/app/site/config/services/config-repository.service.ts new file mode 100644 index 000000000..4c2906e37 --- /dev/null +++ b/client/src/app/site/config/services/config-repository.service.ts @@ -0,0 +1,263 @@ +import { Injectable } from '@angular/core'; + +import { BaseRepository } from '../../base/base-repository'; +import { ViewConfig } from '../models/view-config'; +import { Config } from '../../../shared/models/core/config'; +import { Observable, BehaviorSubject } from 'rxjs'; +import { DataStoreService } from '../../../core/services/data-store.service'; +import { ConstantsService } from '../../../core/services/constants.service'; +import { HttpClient } from '@angular/common/http'; + +/** + * 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. + */ +interface ConfigSubgroup { + /** + * The name. + */ + name: string; + + /** + * All items in this sub group. + */ + items: ConfigItem[]; +} + +/** + * Represents a config group with its name, subgroups and direct items. + */ +export interface ConfigGroup { + /** + * The name. + */ + name: string; + + /** + * A list of subgroups. + */ + subgroups: ConfigSubgroup[]; + + /** + * A list of config items that are not in any subgroup. + */ + items: ConfigItem[]; +} + +/** + * Repository for Configs. It overrides some functions of the BaseRepository. So do not use the + * observables given by the base repository, but the {@method getConfigListObservable}. + */ +@Injectable({ + providedIn: 'root' +}) +export class ConfigRepositoryService extends BaseRepository { + /** + * Own store for config groups. + */ + private configs: ConfigGroup[] = null; + + /** + * Own subject for config groups. + */ + protected configListSubject: BehaviorSubject = new BehaviorSubject(null); + + /** + * Constructor for ConfigRepositoryService. Requests the constants from the server and creates the config group structure. + */ + public constructor(DS: DataStoreService, private constantsService: ConstantsService, private http: HttpClient) { + super(DS, Config); + + this.constantsService.get('OpenSlidesConfigVariables').subscribe(constant => { + this.createConfigStructure(constant); + this.updateConfigStructure(...Object.values(this.viewModelStore)); + this.updateConfigListObservable(); + }); + } + + /** + * Overwritten setup. Does only care about the custom list observable and inserts changed configs into the + * config group structure. + */ + protected setup(): void { + if (!this.configListSubject) { + this.configListSubject = new BehaviorSubject(null); + } + + this.DS.getAll(Config).forEach((config: Config) => { + this.viewModelStore[config.id] = this.createViewModel(config); + this.updateConfigStructure(this.viewModelStore[config.id]); + }); + this.updateConfigListObservable(); + + // Could be raise in error if the root injector is not known + this.DS.changeObservable.subscribe(model => { + if (model instanceof Config) { + this.viewModelStore[model.id] = this.createViewModel(model as Config); + this.updateConfigStructure(this.viewModelStore[model.id]); + this.updateConfigListObservable(); + } + }); + } + + /** + * Custom observer for the config + */ + public getConfigListObservable(): Observable { + return this.configListSubject.asObservable(); + } + + /** + * Custom notification for the observers. + */ + protected updateConfigListObservable(): void { + if (this.configs) { + this.configListSubject.next(this.configs); + } + } + + /** + * Getter for the config structure + */ + public getConfigStructure(): ConfigGroup[] { + return this.configs; + } + + /** + * With a given (and maybe partially filled) config structure, all given view configs are put into it. + * @param viewConfigs All view configs to put into the structure + */ + protected updateConfigStructure(...viewConfigs: ViewConfig[]): void { + if (!this.configs) { + return; + } + + // Map the viewConfigs to their keys. + const keyConfigMap: { [key: string]: ViewConfig } = {}; + viewConfigs.forEach(viewConfig => { + keyConfigMap[viewConfig.key] = viewConfig; + }); + + // 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]; + } + } + } + for (const item of group.items) { + if (keyConfigMap[item.key]) { + keyConfigMap[item.key].setConstantsInfo(item.data); + item.config = keyConfigMap[item.key]; + } + } + } + } + + /** + * Saves a config value. + */ + public update(config: Partial, viewConfig: ViewConfig): Observable { + const updatedConfig = new Config(); + updatedConfig.patchValues(viewConfig.config); + updatedConfig.patchValues(config); + // TODO: Use datasendService, if it can switch correctly between put, post and patch + return this.http.put( + 'rest/' + updatedConfig.collectionString + '/' + updatedConfig.key + '/', + updatedConfig + ); + } + + /** + * This particular function should never be necessary since the creation of config + * values is not planed. + * + * Function exists solely to correctly implement {@link BaseRepository} + */ + public delete(config: ViewConfig): Observable { + throw new Error('Config variables cannot be deleted'); + } + + /** + * This particular function should never be necessary since the creation of config + * values is not planed. + * + * Function exists solely to correctly implement {@link BaseRepository} + */ + public create(config: Config): Observable { + throw new Error('Config variables cannot be created'); + } + + /** + * Creates a new ViewConfig of a given Config object + * @param config + */ + public createViewModel(config: Config): ViewConfig { + const vm = new ViewConfig(config); + return vm; + } + + /** + * initially create the config structure from the given constant. + * @param constant + */ + 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); + } + } +} diff --git a/client/src/app/site/login/components/login-mask/login-mask.component.ts b/client/src/app/site/login/components/login-mask/login-mask.component.ts index a7cf9588d..d5d352cf1 100644 --- a/client/src/app/site/login/components/login-mask/login-mask.component.ts +++ b/client/src/app/site/login/components/login-mask/login-mask.component.ts @@ -4,32 +4,14 @@ import { Router } from '@angular/router'; import { BaseComponent } from 'app/base.component'; import { AuthService } from 'app/core/services/auth.service'; import { OperatorService } from 'app/core/services/operator.service'; -import { ErrorStateMatcher, MatSnackBar, MatSnackBarRef, SimpleSnackBar } from '@angular/material'; -import { FormControl, FormGroupDirective, NgForm, FormGroup, Validators, FormBuilder } from '@angular/forms'; +import { MatSnackBar, MatSnackBarRef, SimpleSnackBar } from '@angular/material'; +import { FormGroup, Validators, FormBuilder } from '@angular/forms'; import { TranslateService } from '@ngx-translate/core'; import { HttpErrorResponse, HttpClient } from '@angular/common/http'; import { environment } from 'environments/environment'; import { OpenSlidesService } from '../../../../core/services/openslides.service'; import { LoginDataService } from '../../../../core/services/login-data.service'; - -/** - * Custom error states. Might become part of the shared module later. - */ -export class ParentErrorStateMatcher implements ErrorStateMatcher { - public isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean { - const isSubmitted = !!(form && form.submitted); - const controlTouched = !!(control && (control.dirty || control.touched)); - const controlInvalid = !!(control && control.invalid); - const parentInvalid = !!( - control && - control.parent && - control.parent.invalid && - (control.parent.dirty || control.parent.touched) - ); - - return isSubmitted || (controlTouched && (controlInvalid || parentInvalid)); - } -} +import { ParentErrorStateMatcher } from '../../../../shared/parent-error-state-matcher'; /** * Login mask component. diff --git a/client/src/app/site/settings/models/view-config.ts b/client/src/app/site/settings/models/view-config.ts deleted file mode 100644 index 1e8714cf0..000000000 --- a/client/src/app/site/settings/models/view-config.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { BaseViewModel } from '../../base/base-view-model'; -import { Config } from '../../../shared/models/core/config'; - -export class ViewConfig extends BaseViewModel { - private _config: Config; - - public get config(): Config { - return this._config; - } - - public get id(): number { - return this.config ? this.config.id : null; - } - - public get key(): string { - return this.config ? this.config.key : null; - } - - public get value(): Object { - return this.config ? this.config.value : null; - } - - public constructor(config: Config) { - super(); - this._config = config; - } - - public getTitle(): string { - return this.key; - } - - public updateValues(update: Config): void { - this._config = update; - } -} diff --git a/client/src/app/site/settings/services/config-repository.service.spec.ts b/client/src/app/site/settings/services/config-repository.service.spec.ts deleted file mode 100644 index eeaaf6dc6..000000000 --- a/client/src/app/site/settings/services/config-repository.service.spec.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { TestBed } from '@angular/core/testing'; - -import { ConfigRepositoryService } from './config-repository.service'; - -describe('SettingsRepositoryService', () => { - beforeEach(() => TestBed.configureTestingModule({})); - - it('should be created', () => { - const service: ConfigRepositoryService = TestBed.get(ConfigRepositoryService); - expect(service).toBeTruthy(); - }); -}); diff --git a/client/src/app/site/settings/services/config-repository.service.ts b/client/src/app/site/settings/services/config-repository.service.ts deleted file mode 100644 index 7580b07ea..000000000 --- a/client/src/app/site/settings/services/config-repository.service.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { Injectable } from '@angular/core'; - -import { BaseRepository } from '../../base/base-repository'; -import { ViewConfig } from '../models/view-config'; -import { Config } from '../../../shared/models/core/config'; -import { Observable } from 'rxjs'; -import { DataStoreService } from '../../../core/services/data-store.service'; - -/** - * Repository for Configs. - * - * Documentation provided over {@link BaseRepository} - */ -@Injectable({ - providedIn: 'root' -}) -export class ConfigRepositoryService extends BaseRepository { - /** - * Constructor for ConfigRepositoryService - */ - public constructor(DS: DataStoreService) { - super(DS, Config); - } - - /** - * Saves a config value. - * - * TODO: used over not-yet-existing detail view - */ - public update(config: Partial, viewConfig: ViewConfig): Observable { - return null; - } - - /** - * This particular function should never be necessary since the creation of config - * values is not planed. - * - * Function exists solely to correctly implement {@link BaseRepository} - */ - public delete(config: ViewConfig): Observable { - return null; - } - - /** - * This particular function should never be necessary since the creation of config - * values is not planed. - * - * Function exists solely to correctly implement {@link BaseRepository} - */ - public create(config: Config): Observable { - return null; - } - - /** - * Creates a new ViewConfig of a given Config object - * @param config - */ - public createViewModel(config: Config): ViewConfig { - return new ViewConfig(config); - } -} diff --git a/client/src/app/site/settings/settings-list/settings-list.component.html b/client/src/app/site/settings/settings-list/settings-list.component.html deleted file mode 100644 index f395ee69d..000000000 --- a/client/src/app/site/settings/settings-list/settings-list.component.html +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - Key - {{config.key}} - - - - - Value - {{config.value}} - - - - - diff --git a/client/src/app/site/settings/settings-list/settings-list.component.spec.ts b/client/src/app/site/settings/settings-list/settings-list.component.spec.ts deleted file mode 100644 index b0700b3bd..000000000 --- a/client/src/app/site/settings/settings-list/settings-list.component.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { async, ComponentFixture, TestBed } from '@angular/core/testing'; - -import { SettingsListComponent } from './settings-list.component'; -import { E2EImportsModule } from '../../../../e2e-imports.module'; - -describe('SettingsListComponent', () => { - let component: SettingsListComponent; - let fixture: ComponentFixture; - - beforeEach(async(() => { - TestBed.configureTestingModule({ - imports: [E2EImportsModule], - declarations: [SettingsListComponent] - }).compileComponents(); - })); - - beforeEach(() => { - fixture = TestBed.createComponent(SettingsListComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/client/src/app/site/settings/settings-list/settings-list.component.ts b/client/src/app/site/settings/settings-list/settings-list.component.ts deleted file mode 100644 index 59c0ba329..000000000 --- a/client/src/app/site/settings/settings-list/settings-list.component.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { Component, OnInit } from '@angular/core'; -import { Title } from '@angular/platform-browser'; -import { TranslateService } from '@ngx-translate/core'; -import { ConstantsService } from '../../../core/services/constants.service'; -import { ListViewBaseComponent } from '../../base/list-view-base'; -import { ConfigRepositoryService } from '../services/config-repository.service'; -import { ViewConfig } from '../models/view-config'; - -/** - * List view for the global settings - * - */ -@Component({ - selector: 'os-settings-list', - templateUrl: './settings-list.component.html', - styleUrls: ['./settings-list.component.css'] -}) -export class SettingsListComponent extends ListViewBaseComponent implements OnInit { - /** - * The usual component constructor - * @param titleService - * @param translate - */ - public constructor( - protected titleService: Title, - protected translate: TranslateService, - private repo: ConfigRepositoryService, - private constantsService: ConstantsService, - ) { - super(titleService, translate); - } - - /** - * Init function. - * - * Sets the title, inits the table and calls the repo - * - * TODO: Needs the constants to be working - */ - public ngOnInit(): void { - super.setTitle('Settings'); - this.initTable(); - this.constantsService.get('OpenSlidesConfigVariables').subscribe(data => { - console.log(data); - }); - this.repo.getViewModelListObservable().subscribe(newConfig => { - this.dataSource.data = newConfig; - }); - } - - /** - * Triggers when user selects a row - * @param row - * - * TODO: This prints the clicked item in the log. - * Needs the constants to be working - */ - public selectConfig(row: ViewConfig): void { - console.log('change a config: ', row.value); - } -} diff --git a/client/src/app/site/settings/settings-routing.module.ts b/client/src/app/site/settings/settings-routing.module.ts deleted file mode 100644 index f90d7c927..000000000 --- a/client/src/app/site/settings/settings-routing.module.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { NgModule } from '@angular/core'; -import { Routes, RouterModule } from '@angular/router'; -import { SettingsListComponent } from './settings-list/settings-list.component'; - -const routes: Routes = [{ path: '', component: SettingsListComponent }]; - -@NgModule({ - imports: [RouterModule.forChild(routes)], - exports: [RouterModule] -}) -export class SettingsRoutingModule {} diff --git a/client/src/app/site/settings/settings.module.ts b/client/src/app/site/settings/settings.module.ts deleted file mode 100644 index 38027d5be..000000000 --- a/client/src/app/site/settings/settings.module.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { NgModule } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { SharedModule } from '../../shared/shared.module'; -import { SettingsRoutingModule } from './settings-routing.module'; -import { SettingsListComponent } from './settings-list/settings-list.component'; - -@NgModule({ - imports: [CommonModule, SettingsRoutingModule, SharedModule], - declarations: [SettingsListComponent] -}) -export class SettingsModule {} diff --git a/client/src/app/site/site-routing.module.ts b/client/src/app/site/site-routing.module.ts index c4e4ec20b..af4feb22b 100644 --- a/client/src/app/site/site-routing.module.ts +++ b/client/src/app/site/site-routing.module.ts @@ -37,7 +37,7 @@ const routes: Routes = [ }, { path: 'settings', - loadChildren: './settings/settings.module#SettingsModule' + loadChildren: './config/config.module#ConfigModule' }, { path: 'users', diff --git a/client/src/styles.scss b/client/src/styles.scss index b1443b8e1..e2baba56d 100644 --- a/client/src/styles.scss +++ b/client/src/styles.scss @@ -31,6 +31,10 @@ body { background-color: rgb(77, 243, 86) !important; } +.text-success { + color: rgb(77, 243, 86); +} + .red-warning-text { color: red; }