Implements a mechanism for a fallback theme

- If the previously selected theme is not available the default OpenSlides theme will be displayed.
- Before the current selected theme was loaded, no theme is displayed.
This commit is contained in:
GabrielMeyer 2019-04-08 10:57:43 +02:00
parent c560f511b5
commit 376f4e2a31
25 changed files with 702 additions and 214 deletions

View File

@ -1,3 +1,4 @@
<div class="content"> <div class="content">
<router-outlet></router-outlet> <router-outlet></router-outlet>
<os-global-spinner></os-global-spinner>
</div> </div>

View File

@ -25,7 +25,7 @@ describe('AppComponent', () => {
const fixture = TestBed.createComponent(AppComponent); const fixture = TestBed.createComponent(AppComponent);
const app = fixture.debugElement.componentInstance; const app = fixture.debugElement.componentInstance;
expect(app).toBeTruthy(); expect(app).toBeTruthy();
tick(); tick(1000);
fixture.whenStable().then(() => { fixture.whenStable().then(() => {
expect(servertimeService.startScheduler).toHaveBeenCalled(); expect(servertimeService.startScheduler).toHaveBeenCalled();
}); });

View File

@ -1,7 +1,7 @@
import { Component, ApplicationRef } from '@angular/core'; import { Component, ApplicationRef } from '@angular/core';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { take, filter } from 'rxjs/operators'; import { take, filter, auditTime } from 'rxjs/operators';
import { ConfigService } from './core/ui-services/config.service'; import { ConfigService } from './core/ui-services/config.service';
import { ConstantsService } from './core/core-services/constants.service'; import { ConstantsService } from './core/core-services/constants.service';
@ -15,6 +15,8 @@ import { DataStoreUpgradeService } from './core/core-services/data-store-upgrade
import { UpdateService } from './core/ui-services/update.service'; import { UpdateService } from './core/ui-services/update.service';
import { PrioritizeService } from './core/core-services/prioritize.service'; import { PrioritizeService } from './core/core-services/prioritize.service';
import { PingService } from './core/core-services/ping.service'; import { PingService } from './core/core-services/ping.service';
import { SpinnerService } from './core/ui-services/spinner.service';
import { Router } from '@angular/router';
/** /**
* Angular's global App Component * Angular's global App Component
@ -48,10 +50,12 @@ export class AppComponent {
translate: TranslateService, translate: TranslateService,
appRef: ApplicationRef, appRef: ApplicationRef,
servertimeService: ServertimeService, servertimeService: ServertimeService,
router: Router,
operator: OperatorService, operator: OperatorService,
loginDataService: LoginDataService, loginDataService: LoginDataService,
constantsService: ConstantsService, // Needs to be started, so it can register itself to the WebsocketService constantsService: ConstantsService, // Needs to be started, so it can register itself to the WebsocketService
themeService: ThemeService, themeService: ThemeService,
spinnerService: SpinnerService,
countUsersService: CountUsersService, // Needed to register itself. countUsersService: CountUsersService, // Needed to register itself.
configService: ConfigService, configService: ConfigService,
loadFontService: LoadFontService, loadFontService: LoadFontService,
@ -70,13 +74,27 @@ export class AppComponent {
translate.use(translate.getLangs().includes(browserLang) ? browserLang : 'en'); translate.use(translate.getLangs().includes(browserLang) ? browserLang : 'en');
// change default JS functions // change default JS functions
this.overloadArrayToString(); this.overloadArrayToString();
// Show the spinner initial
spinnerService.setVisibility(true, translate.instant('Loading data. Please wait...'));
appRef.isStable appRef.isStable
.pipe( .pipe(
// take only the stable state
filter(s => s), filter(s => s),
take(1) take(1)
) )
.subscribe(() => servertimeService.startScheduler()); .subscribe(() => servertimeService.startScheduler());
// Subscribe to hide the spinner if the application has changed.
appRef.isStable
.pipe(
filter(s => s),
auditTime(1000)
)
.pipe(take(2))
.subscribe(() => {
spinnerService.setVisibility(false);
});
} }
/** /**

View File

@ -17,6 +17,7 @@ import { OpenSlidesTranslateModule } from './core/translate/openslides-translate
// PWA // PWA
import { ServiceWorkerModule } from '@angular/service-worker'; import { ServiceWorkerModule } from '@angular/service-worker';
import { environment } from '../environments/environment'; import { environment } from '../environments/environment';
import { GlobalSpinnerComponent } from './site/global-spinner/global-spinner.component';
/** /**
* Returns a function that returns a promis that will be resolved, if all apps are loaded. * Returns a function that returns a promis that will be resolved, if all apps are loaded.
@ -30,7 +31,7 @@ export function AppLoaderFactory(appLoadService: AppLoadService): () => Promise<
* Global App Module. Keep it as clean as possible. * Global App Module. Keep it as clean as possible.
*/ */
@NgModule({ @NgModule({
declarations: [AppComponent], declarations: [AppComponent, GlobalSpinnerComponent],
imports: [ imports: [
BrowserModule, BrowserModule,
HttpClientModule, HttpClientModule,

View File

@ -13,6 +13,10 @@ export interface LoginData {
privacy_policy: string; privacy_policy: string;
legal_notice: string; legal_notice: string;
theme: string; theme: string;
logo_web_header: {
path: string;
display_name: string;
};
} }
const LOGIN_DATA_STORAGE_KEY = 'LoginData'; const LOGIN_DATA_STORAGE_KEY = 'LoginData';
@ -61,6 +65,21 @@ export class LoginDataService {
return this._theme.asObservable(); return this._theme.asObservable();
} }
/**
* Holds the custom web header
*/
private readonly _logo_web_header = new BehaviorSubject<{ path: string; display_name: string }>({
path: '',
display_name: ''
});
/**
* Returns an observable for the web header
*/
public get logo_web_header(): Observable<{ path: string; display_name: string }> {
return this._logo_web_header.asObservable();
}
/** /**
* Constructs this service. The config service is needed to update the privacy * Constructs this service. The config service is needed to update the privacy
* policy and legal notice, when their config values change. * policy and legal notice, when their config values change.
@ -83,6 +102,10 @@ export class LoginDataService {
this._theme.next(value); this._theme.next(value);
this.storeLoginData(); this.storeLoginData();
}); });
configService.get<{ path: string; display_name: string }>('logo_web_header').subscribe(value => {
this._logo_web_header.next(value);
this.storeLoginData();
});
this.loadLoginData(); this.loadLoginData();
} }
@ -120,7 +143,8 @@ export class LoginDataService {
loginData = { loginData = {
privacy_policy: this._privacy_policy.getValue(), privacy_policy: this._privacy_policy.getValue(),
legal_notice: this._legal_notice.getValue(), legal_notice: this._legal_notice.getValue(),
theme: this._theme.getValue() theme: this._theme.getValue(),
logo_web_header: this._logo_web_header.getValue()
}; };
} }
if (!this.OSStatus.isInHistoryMode) { if (!this.OSStatus.isInHistoryMode) {

View File

@ -0,0 +1,12 @@
import { TestBed } from '@angular/core/testing';
import { SpinnerService } from './spinner.service';
describe('SpinnerService', () => {
beforeEach(() => TestBed.configureTestingModule({}));
it('should be created', () => {
const service: SpinnerService = TestBed.get(SpinnerService);
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,40 @@
// External imports
import { Injectable } from '@angular/core';
import { Observable, Subject } from 'rxjs';
/**
* Service for the `global-spinner.component`
*
* Handles the visibility of the global-spinner.
*/
@Injectable({
providedIn: 'root'
})
export class SpinnerService {
/**
* Subject, that holds the visibility and message. The component can observe this.
*/
private visibility: Subject<{ isVisible: boolean; text?: string }> = new Subject<{
isVisible: boolean;
text?: string;
}>();
/**
* Function to change the visibility of the `global-spinner.component`.
*
* @param isVisible flag, if the spinner should be shown.
* @param text optional. If the spinner should show a message.
*/
public setVisibility(isVisible: boolean, text?: string): void {
setTimeout(() => this.visibility.next({ isVisible, text }));
}
/**
* Function to get the visibility as observable.
*
* @returns class member `visibility`.
*/
public getVisibility(): Observable<{ isVisible: boolean; text?: string }> {
return this.visibility;
}
}

View File

@ -9,6 +9,26 @@ import { LoginDataService } from './login-data.service';
providedIn: 'root' providedIn: 'root'
}) })
export class ThemeService { export class ThemeService {
/**
* Constant, that describes the default theme class.
*/
public static DEFAULT_THEME = 'openslides-theme';
/**
* Constant path of the logo with dark colors for bright themes.
*/
public static STANDARD_LOGO = '/assets/img/openslides-logo-h.svg';
/**
* Constant path of the logo with white colors for dark themes.
*/
public static STANDARD_LOGO_DARK_THEME = '/assets/img/openslides-logo-h-dark-transparent.svg';
/**
* Holds the current theme as member.
*/
private currentTheme: string;
/** /**
* Here it will subscribe to the observer from login data service. The stheme is part of * Here it will subscribe to the observer from login data service. The stheme is part of
* the login data, so get it from there and not from the config. This service will * the login data, so get it from there and not from the config. This service will
@ -21,13 +41,31 @@ export class ThemeService {
if (!newTheme) { if (!newTheme) {
return; return;
} }
this.currentTheme = newTheme;
const classList = document.getElementsByTagName('body')[0].classList; // Get the classlist of the body. const classList = document.getElementsByTagName('body')[0].classList; // Get the classlist of the body.
const toRemove = Array.from(classList).filter((item: string) => item.includes('theme')); const toRemove = Array.from(classList).filter((item: string) => item.includes('theme'));
if (toRemove.length) { if (toRemove.length) {
classList.remove(...toRemove); // Remove all old themes. classList.remove(...toRemove); // Remove all old themes.
} }
classList.add(newTheme); // Add the new theme. classList.add(newTheme, ThemeService.DEFAULT_THEME); // Add the new theme.
}); });
} }
/**
* Returns the logo relative to the used theme.
*
* @param shouldDefault If this method should return the default logo.
*
* @returns the path to the logo.
*/
public getLogoRelativeToTheme(shouldDefault?: boolean): string {
if (this.currentTheme) {
return this.currentTheme.includes('dark') && !shouldDefault
? ThemeService.STANDARD_LOGO_DARK_THEME
: ThemeService.STANDARD_LOGO;
} else {
return null;
}
}
} }

View File

@ -1,3 +1,4 @@
<div *ngIf="getImage() != ''" class="logo-container"> <div *ngIf="getImage()" class="logo-container">
<img [src]="getImage()"> <img [src]="getImage('true')" class="default" />
<img [src]="getImage()" class="dark"/>
</div> </div>

View File

@ -2,6 +2,7 @@ img {
max-width: 100%; max-width: 100%;
height: auto; height: auto;
max-height: 100%; max-height: 100%;
margin: 0 auto;
} }
.logo-container { .logo-container {

View File

@ -1,63 +1,27 @@
import { Component, OnInit, Input } from '@angular/core'; import { Component, Input, OnInit, OnDestroy } from '@angular/core';
import { MediaManageService } from 'app/core/ui-services/media-manage.service'; import { ThemeService } from 'app/core/ui-services/theme.service';
import { ConfigService } from 'app/core/ui-services/config.service'; import { LoginDataService } from 'app/core/ui-services/login-data.service';
import { Subscription } from 'rxjs';
/** /**
* Reusable Logo component for Apps. * Component to hold the logo for the app.
*
* Following actions are possible:
* * "logo_projector_main"
* * "logo_projector_header"
* * "logo_web_header"
* * "logo_pdf_header_L"
* * "logo_pdf_header_R"
* * "logo_pdf_footer_L"
* * "logo_pdf_footer_R"
* * "logo_pdf_ballot_paper"
*
* ## Examples:
*
* ### Usage of the selector:
*
* ```html
* <os-logo
* inputAction="logo_projector_main"
* [footer]="false"
* [alignment]="right">
* </os-logo>
* ```
*
* Sidenote: The footer variable is optional. Only if you want
* alternating logos, i.E. in the sidenav. the Alignment is also
* optional.
*/ */
@Component({ @Component({
selector: 'os-logo', selector: 'os-logo',
templateUrl: './logo.component.html', templateUrl: './logo.component.html',
styleUrls: ['./logo.component.scss'] styleUrls: ['./logo.component.scss']
}) })
export class LogoComponent implements OnInit { export class LogoComponent implements OnInit, OnDestroy {
/** /**
* Constant path of the logo with dark colors for bright themes * Local variable to hold the path for a custom web header.
*/ */
public static STANDARD_LOGO = '/assets/img/openslides-logo-h.svg'; private logoPath: string;
/** /**
* Constant path of the logo with white colors for dark themes * Local variable to hold the subscription to unsubscribe if exists.
*/ */
public static STANDARD_LOGO_DARK_THEME = '/assets/img/openslides-logo-h-dark-transparent.svg'; private logoSubscription: Subscription;
/**
* Holds the actions for logos. Updated via an observable
*/
public logoActions: string[];
/**
* decides based on the actionString how to display the logo
*/
@Input()
public inputAction: string;
/** /**
* determines if the current picture is displayed in the footer. * determines if the current picture is displayed in the footer.
@ -66,117 +30,46 @@ export class LogoComponent implements OnInit {
@Input() @Input()
public footer = false; public footer = false;
/**
* influences text-alignment in the .logo-container css class
*/
@Input()
public alignment = 'center';
/** /**
* The constructor * The constructor
* *
* @param mmservice The Media Manage Service * @param loginDataService Reference to the `LoginDataService`
* @param configService The ConfigService to subscribe to theme-changes * @param themeService Reference to the `ThemeService`
*/ */
public constructor(private mmservice: MediaManageService, private configService: ConfigService) {} public constructor(private loginDataService: LoginDataService, private themeService: ThemeService) {}
/** /**
* Initialization function * On init method
*/ */
public ngOnInit(): void { public ngOnInit(): void {
this.mmservice.getLogoActions().subscribe(action => { this.logoSubscription = this.loginDataService.logo_web_header.subscribe(nextLogo => {
this.logoActions = action; if (nextLogo) {
this.logoPath = nextLogo.path;
}
}); });
} }
/** /**
* gets the image based on the inputAction and location. * On destroy method
* Possible inputActions are in the class description. */
public ngOnDestroy(): void {
if (this.logoSubscription) {
this.logoSubscription.unsubscribe();
this.logoSubscription = null;
}
}
/**
* Get the image based on custom images and footer.
* If a custom image is set and this component is displayed as footer or there is no custom image, then the OpenSlides logo is used.
* *
* @returns path to image * @returns path to image
*/ */
public getImage(): string { public getImage(shouldDefault?: boolean): string {
if (this.footer) { if ((!this.logoPath && !this.footer) || (!!this.logoPath && this.footer)) {
const path = this.getFooterImage(this.inputAction); return this.themeService.getLogoRelativeToTheme(shouldDefault);
return path;
} else { } else {
const path = this.getHeaderImage(this.inputAction, this.alignment); return this.logoPath;
return path;
}
}
/**
* Check if the user uses a dark theme or a 'bright' theme.
* In relation to the theme this will return the corresponding imagepath.
*
* @returns path of the image corresponding to the chosen theme.
*/
protected getImagePathRelatedToTheme(): string {
const theme = this.configService.instant<string>('openslides_theme');
if (theme) {
return theme.includes('dark') ? LogoComponent.STANDARD_LOGO_DARK_THEME : LogoComponent.STANDARD_LOGO;
} else {
return LogoComponent.STANDARD_LOGO;
}
}
/**
* gets the header image based on logo action
*
* @param logoAction the logo action to be used
* @param alignment the alignment of the logo (optional)
* @returns path to image
*/
protected getHeaderImage(logoAction: string, alignment: string = 'center'): string {
if (alignment !== 'center') {
this.setAlignment(alignment);
}
let path = '';
/* check if datastore is loaded and custom logo can be read */
if (this.logoActions === undefined) {
return '';
}
if (this.mmservice !== undefined) {
if (this.mmservice.isImageConfigObject(this.mmservice.getMediaConfig(logoAction))) {
const imageConfig = this.mmservice.getMediaConfig(logoAction);
path = imageConfig.path;
}
}
if (path === '') {
path = this.getImagePathRelatedToTheme();
}
return path;
}
/**
* Changes the alignment from center to either 'left' or 'right'
*
* @param alignment either 'right' or 'left'
*/
private setAlignment(alignment: string): void {
if (alignment === 'left' || alignment === 'right') {
const cssLogoContainer = document.getElementsByClassName('logo-container') as HTMLCollectionOf<HTMLElement>;
if (cssLogoContainer.length !== 0) {
cssLogoContainer[0].style.textAlign = alignment;
}
}
}
/**
* Returns the image-path for the footer
*
* @param logoAction the logo action to be used
* @returns '' if no logo is set and path to standard logo if a custom
* logo was set
*/
protected getFooterImage(logoAction: string): string {
if (
this.getHeaderImage(logoAction) === LogoComponent.STANDARD_LOGO ||
this.getHeaderImage(logoAction) === LogoComponent.STANDARD_LOGO_DARK_THEME ||
this.getHeaderImage(logoAction) === ''
) {
return '';
} else {
return this.getImagePathRelatedToTheme();
} }
} }
} }

View File

@ -0,0 +1,11 @@
<div
*ngIf="isVisible"
class="global-spinner-component">
<div class="spinner-container">
<div>
<div class="spinner"></div>
<div class="text">{{ text }}</div>
</div>
</div>
<div class="backdrop"></div>
</div>

View File

@ -0,0 +1,83 @@
@import '~@angular/material/theming';
@mixin os-global-spinner-theme($theme) {
$primary: map-get($theme, primary);
$accent: map-get($theme, accent);
$warn: map-get($theme, warn);
$background: map-get($theme, background);
$foreground: map-get($theme, foreground);
$contrast-primary: map-get($primary, contrast);
$contrast-accent: map-get($accent, contrast);
.global-spinner-component,
.backdrop,
.spinner-container {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 999;
}
.global-spinner-component {
position: fixed;
.spinner-container {
display: flex;
justify-content: center;
align-items: center;
.spinner {
position: absolute;
top: 50%;
left: 50%;
margin: -136px 0 0 -53px;
height: 100px;
width: 100px;
border: 6px solid #000;
border-radius: 100%;
opacity: 0.2;
animation: rotation 1s infinite linear;
&:before {
position: absolute;
top: -6px;
left: -6px;
content: '';
display: block;
height: 100%;
width: 100%;
border-radius: 100%;
border-style: solid;
border-width: 6px;
border-color: white transparent transparent;
}
@keyframes rotation {
from {
transform: rotate(0deg);
}
to {
transform: rotate(359deg);
}
}
}
.text {
text-align: center;
color: white;
font-size: 1.4rem;
}
}
.backdrop {
z-index: 899;
background-color: #303030;
opacity: 0.8;
}
}
}

View File

@ -0,0 +1,24 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { GlobalSpinnerComponent } from './global-spinner.component';
describe('GlobalSpinnerComponent', () => {
let component: GlobalSpinnerComponent;
let fixture: ComponentFixture<GlobalSpinnerComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [GlobalSpinnerComponent]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(GlobalSpinnerComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,74 @@
// External imports
import { Component, OnInit, OnDestroy, ChangeDetectorRef } from '@angular/core';
import { Subscription } from 'rxjs';
// Internal imports
import { SpinnerService } from 'app/core/ui-services/spinner.service';
/**
* Component for the global spinner.
*/
@Component({
selector: 'os-global-spinner',
templateUrl: './global-spinner.component.html',
styleUrls: ['./global-spinner.component.scss']
})
export class GlobalSpinnerComponent implements OnInit, OnDestroy {
/**
* Text, which will be shown if the spinner is shown.
*/
public text: string;
/**
* Flag, that defines when the spinner is shown.
*/
public isVisible = false;
/**
* Subscription for the service to handle the visibility and text for the spinner.
*/
private spinnerSubscription: Subscription;
/**
* Constant string as default message when the spinner is shown.
*/
private LOADING = 'Loading data. Please wait...';
/**
*
* @param spinnerService Reference to the service for this spinner.
* @param translate Service to get translations for the messages.
* @param detector Service to manual initiate a change of the UI.
*/
public constructor(private spinnerService: SpinnerService, private detector: ChangeDetectorRef) {}
/**
* Init method
*/
public ngOnInit(): void {
this.spinnerSubscription = this.spinnerService // subscribe to the service.
.getVisibility()
.subscribe((value: { isVisible: boolean; text: string }) => {
this.isVisible = value.isVisible;
this.text = value.text;
if (!this.text) {
this.text = this.LOADING;
}
this.detector.detectChanges();
});
}
/**
* Destroy method
*
* Deletes the subscription and marks the spinner as invisible.
*/
public ngOnDestroy(): void {
if (this.spinnerSubscription) {
this.spinnerSubscription.unsubscribe();
this.isVisible = false;
}
this.spinnerSubscription = null;
}
}

View File

@ -1,6 +1,4 @@
<div class="form-wrapper"> <div class="form-wrapper">
<!-- Spinner -->
<mat-spinner *ngIf="inProcess"></mat-spinner>
<!-- Install notice --> <!-- Install notice -->
<div class="login-container" *ngIf="installationNotice"> <div class="login-container" *ngIf="installationNotice">

View File

@ -13,6 +13,7 @@ import { environment } from 'environments/environment';
import { LoginDataService, LoginData } from 'app/core/ui-services/login-data.service'; import { LoginDataService, LoginData } from 'app/core/ui-services/login-data.service';
import { ParentErrorStateMatcher } from 'app/shared/parent-error-state-matcher'; import { ParentErrorStateMatcher } from 'app/shared/parent-error-state-matcher';
import { HttpService } from 'app/core/core-services/http.service'; import { HttpService } from 'app/core/core-services/http.service';
import { SpinnerService } from 'app/core/ui-services/spinner.service';
interface LoginDataWithInfoText extends LoginData { interface LoginDataWithInfoText extends LoginData {
info_text?: string; info_text?: string;
@ -54,11 +55,6 @@ export class LoginMaskComponent extends BaseComponent implements OnInit, OnDestr
*/ */
public parentErrorStateMatcher = new ParentErrorStateMatcher(); public parentErrorStateMatcher = new ParentErrorStateMatcher();
/**
* Show the Spinner if validation is in process
*/
public inProcess = false;
public operatorSubscription: Subscription | null; public operatorSubscription: Subscription | null;
/** /**
@ -71,6 +67,7 @@ export class LoginMaskComponent extends BaseComponent implements OnInit, OnDestr
* @param httpService used to get information before the login * @param httpService used to get information before the login
* @param OpenSlides The Service for OpenSlides * @param OpenSlides The Service for OpenSlides
* @param loginDataService provide information about the legal notice and privacy policy * @param loginDataService provide information about the legal notice and privacy policy
* @param spinnerService Service to show the spinner when the user is signing in
*/ */
public constructor( public constructor(
title: Title, title: Title,
@ -81,9 +78,12 @@ export class LoginMaskComponent extends BaseComponent implements OnInit, OnDestr
private route: ActivatedRoute, private route: ActivatedRoute,
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
private httpService: HttpService, private httpService: HttpService,
private loginDataService: LoginDataService private loginDataService: LoginDataService,
private spinnerService: SpinnerService
) { ) {
super(title, translate); super(title, translate);
// Hide the spinner if the user is at `login-mask`
spinnerService.setVisibility(false);
this.createForm(); this.createForm();
} }
@ -149,8 +149,8 @@ export class LoginMaskComponent extends BaseComponent implements OnInit, OnDestr
* Send username and password to the {@link AuthService} * Send username and password to the {@link AuthService}
*/ */
public async formLogin(): Promise<void> { public async formLogin(): Promise<void> {
this.spinnerService.setVisibility(true, this.translate.instant('Loading data. Please wait...'));
this.loginErrorMsg = ''; this.loginErrorMsg = '';
this.inProcess = true;
try { try {
await this.authService.login(this.loginForm.value.username, this.loginForm.value.password, () => { await this.authService.login(this.loginForm.value.username, this.loginForm.value.password, () => {
this.clearOperatorSubscription(); // We take control, not the subscription. this.clearOperatorSubscription(); // We take control, not the subscription.
@ -161,7 +161,7 @@ export class LoginMaskComponent extends BaseComponent implements OnInit, OnDestr
}); });
this.loginErrorMsg = e; this.loginErrorMsg = e;
} }
this.inProcess = false; this.spinnerService.setVisibility(false);
} }
/** /**

View File

@ -18,6 +18,7 @@ import { TreeService } from 'app/core/ui-services/tree.service';
import { UserRepositoryService } from 'app/core/repositories/users/user-repository.service'; import { UserRepositoryService } from 'app/core/repositories/users/user-repository.service';
import { ViewMotion } from '../models/view-motion'; import { ViewMotion } from '../models/view-motion';
import { WorkflowRepositoryService } from 'app/core/repositories/motions/workflow-repository.service'; import { WorkflowRepositoryService } from 'app/core/repositories/motions/workflow-repository.service';
import { SpinnerService } from 'app/core/ui-services/spinner.service';
/** /**
* Contains all multiselect actions for the motion list view. * Contains all multiselect actions for the motion list view.
@ -26,6 +27,8 @@ import { WorkflowRepositoryService } from 'app/core/repositories/motions/workflo
providedIn: 'root' providedIn: 'root'
}) })
export class MotionMultiselectService { export class MotionMultiselectService {
private messageForSpinner = 'Motions are in process. Please wait...';
/** /**
* Does nothing. * Does nothing.
* *
@ -42,6 +45,7 @@ export class MotionMultiselectService {
* @param httpService * @param httpService
* @param treeService * @param treeService
* @param personalNoteService * @param personalNoteService
* @param spinnerService to show a spinner when http-requests are made.
*/ */
public constructor( public constructor(
private repo: MotionRepositoryService, private repo: MotionRepositoryService,
@ -56,7 +60,8 @@ export class MotionMultiselectService {
private motionBlockRepo: MotionBlockRepositoryService, private motionBlockRepo: MotionBlockRepositoryService,
private httpService: HttpService, private httpService: HttpService,
private treeService: TreeService, private treeService: TreeService,
private personalNoteService: PersonalNoteService private personalNoteService: PersonalNoteService,
private spinnerService: SpinnerService
) {} ) {}
/** /**
@ -67,9 +72,19 @@ export class MotionMultiselectService {
public async delete(motions: ViewMotion[]): Promise<void> { public async delete(motions: ViewMotion[]): Promise<void> {
const title = this.translate.instant('Are you sure you want to delete all selected motions?'); const title = this.translate.instant('Are you sure you want to delete all selected motions?');
if (await this.promptService.open(title, null)) { if (await this.promptService.open(title, null)) {
let i = 0;
for (const motion of motions) { for (const motion of motions) {
++i;
const message =
this.translate.instant(this.messageForSpinner) +
`\n${i} ` +
this.translate.instant('of') +
` ${motions.length}`;
this.spinnerService.setVisibility(true, message);
await this.repo.delete(motion); await this.repo.delete(motion);
} }
this.spinnerService.setVisibility(false);
} }
} }
@ -102,7 +117,13 @@ export class MotionMultiselectService {
})); }));
const selectedChoice = await this.choiceService.open(title, choices); const selectedChoice = await this.choiceService.open(title, choices);
if (selectedChoice) { if (selectedChoice) {
await this.repo.setMultiState(motions, selectedChoice.items as number); const message = `${motions.length} ` + this.translate.instant(this.messageForSpinner);
this.spinnerService.setVisibility(true, message);
await this.repo.setMultiState(motions, selectedChoice.items as number).catch(error => {
this.spinnerService.setVisibility(false);
throw error;
});
this.spinnerService.setVisibility(false);
} }
} }
@ -127,9 +148,18 @@ export class MotionMultiselectService {
id: motion.id, id: motion.id,
recommendation: selectedChoice.action ? 0 : (selectedChoice.items as number) recommendation: selectedChoice.action ? 0 : (selectedChoice.items as number)
})); }));
await this.httpService.post('/rest/motions/motion/manage_multiple_recommendation/', {
const message = `${motions.length} ` + this.translate.instant(this.messageForSpinner);
this.spinnerService.setVisibility(true, message);
await this.httpService
.post('/rest/motions/motion/manage_multiple_recommendation/', {
motions: requestData motions: requestData
})
.catch(error => {
this.spinnerService.setVisibility(false);
throw error;
}); });
this.spinnerService.setVisibility(false);
} }
} }
@ -149,12 +179,21 @@ export class MotionMultiselectService {
clearChoice clearChoice
); );
if (selectedChoice) { if (selectedChoice) {
let i = 0;
for (const motion of motions) { for (const motion of motions) {
++i;
const message =
this.translate.instant(this.messageForSpinner) +
`\n${i} ` +
this.translate.instant('of') +
` ${motions.length}`;
this.spinnerService.setVisibility(true, message);
await this.repo.update( await this.repo.update(
{ category_id: selectedChoice.action ? null : (selectedChoice.items as number) }, { category_id: selectedChoice.action ? null : (selectedChoice.items as number) },
motion motion
); );
} }
this.spinnerService.setVisibility(false);
} }
} }
@ -174,8 +213,10 @@ export class MotionMultiselectService {
true, true,
choices choices
); );
if (selectedChoice && selectedChoice.action === choices[0]) { if (selectedChoice) {
const requestData = motions.map(motion => { let requestData = null;
if (selectedChoice.action === choices[0]) {
requestData = motions.map(motion => {
let submitterIds = [...motion.sorted_submitters_id, ...(selectedChoice.items as number[])]; let submitterIds = [...motion.sorted_submitters_id, ...(selectedChoice.items as number[])];
submitterIds = submitterIds.filter((id, index, self) => self.indexOf(id) === index); // remove duplicates submitterIds = submitterIds.filter((id, index, self) => self.indexOf(id) === index); // remove duplicates
return { return {
@ -183,9 +224,9 @@ export class MotionMultiselectService {
submitters: submitterIds submitters: submitterIds
}; };
}); });
await this.httpService.post('/rest/motions/motion/manage_multiple_submitters/', { motions: requestData }); // await this.httpService.post('/rest/motions/motion/manage_multiple_submitters/', { motions: requestData });
} else if (selectedChoice && selectedChoice.action === choices[1]) { } else if (selectedChoice.action === choices[1]) {
const requestData = motions.map(motion => { requestData = motions.map(motion => {
const submitterIdsToRemove = selectedChoice.items as number[]; const submitterIdsToRemove = selectedChoice.items as number[];
const submitterIds = motion.sorted_submitters_id.filter(id => !submitterIdsToRemove.includes(id)); const submitterIds = motion.sorted_submitters_id.filter(id => !submitterIdsToRemove.includes(id));
return { return {
@ -193,7 +234,12 @@ export class MotionMultiselectService {
submitters: submitterIds submitters: submitterIds
}; };
}); });
}
const message = `${motions.length} ` + this.translate.instant(this.messageForSpinner);
this.spinnerService.setVisibility(true, message);
await this.httpService.post('/rest/motions/motion/manage_multiple_submitters/', { motions: requestData }); await this.httpService.post('/rest/motions/motion/manage_multiple_submitters/', { motions: requestData });
this.spinnerService.setVisibility(false);
} }
} }
@ -215,8 +261,10 @@ export class MotionMultiselectService {
true, true,
choices choices
); );
if (selectedChoice && selectedChoice.action === choices[0]) { if (selectedChoice) {
const requestData = motions.map(motion => { let requestData = null;
if (selectedChoice.action === choices[0]) {
requestData = motions.map(motion => {
let tagIds = [...motion.tags_id, ...(selectedChoice.items as number[])]; let tagIds = [...motion.tags_id, ...(selectedChoice.items as number[])];
tagIds = tagIds.filter((id, index, self) => self.indexOf(id) === index); // remove duplicates tagIds = tagIds.filter((id, index, self) => self.indexOf(id) === index); // remove duplicates
return { return {
@ -224,9 +272,9 @@ export class MotionMultiselectService {
tags: tagIds tags: tagIds
}; };
}); });
await this.httpService.post('/rest/motions/motion/manage_multiple_tags/', { motions: requestData }); // await this.httpService.post('/rest/motions/motion/manage_multiple_tags/', { motions: requestData });
} else if (selectedChoice && selectedChoice.action === choices[1]) { } else if (selectedChoice.action === choices[1]) {
const requestData = motions.map(motion => { requestData = motions.map(motion => {
const tagIdsToRemove = selectedChoice.items as number[]; const tagIdsToRemove = selectedChoice.items as number[];
const tagIds = motion.tags_id.filter(id => !tagIdsToRemove.includes(id)); const tagIds = motion.tags_id.filter(id => !tagIdsToRemove.includes(id));
return { return {
@ -234,15 +282,20 @@ export class MotionMultiselectService {
tags: tagIds tags: tagIds
}; };
}); });
await this.httpService.post('/rest/motions/motion/manage_multiple_tags/', { motions: requestData }); // await this.httpService.post('/rest/motions/motion/manage_multiple_tags/', { motions: requestData });
} else if (selectedChoice && selectedChoice.action === choices[2]) { } else if (selectedChoice.action === choices[2]) {
const requestData = motions.map(motion => { requestData = motions.map(motion => {
return { return {
id: motion.id, id: motion.id,
tags: [] tags: []
}; };
}); });
}
const message = `${motions.length} ` + this.translate.instant(this.messageForSpinner);
this.spinnerService.setVisibility(true, message);
await this.httpService.post('/rest/motions/motion/manage_multiple_tags/', { motions: requestData }); await this.httpService.post('/rest/motions/motion/manage_multiple_tags/', { motions: requestData });
this.spinnerService.setVisibility(false);
} }
} }
@ -262,10 +315,19 @@ export class MotionMultiselectService {
clearChoice clearChoice
); );
if (selectedChoice) { if (selectedChoice) {
let i = 0;
for (const motion of motions) { for (const motion of motions) {
++i;
const message =
this.translate.instant(this.messageForSpinner) +
`\n${i} ` +
this.translate.instant('of') +
` ${motions.length}`;
this.spinnerService.setVisibility(true, message);
const blockId = selectedChoice.action ? null : (selectedChoice.items as number); const blockId = selectedChoice.action ? null : (selectedChoice.items as number);
await this.repo.update({ motion_block_id: blockId }, motion); await this.repo.update({ motion_block_id: blockId }, motion);
} }
this.spinnerService.setVisibility(false);
} }
} }
@ -317,8 +379,11 @@ export class MotionMultiselectService {
]; ];
const selectedChoice = await this.choiceService.open(title, choices); const selectedChoice = await this.choiceService.open(title, choices);
if (selectedChoice && motions.length) { if (selectedChoice && motions.length) {
const message = this.translate.instant(`I have ${motions.length} favorite motions. Please wait...`);
const star = (selectedChoice.items as number) === choices[0].id; const star = (selectedChoice.items as number) === choices[0].id;
this.spinnerService.setVisibility(true, message);
await this.personalNoteService.bulkSetStar(motions, star); await this.personalNoteService.bulkSetStar(motions, star);
this.spinnerService.setVisibility(false);
} }
} }
} }

View File

@ -20,7 +20,7 @@
<div class="nav-toolbar"> <div class="nav-toolbar">
<!-- logo --> <!-- logo -->
<a routerLink="/" (click)="toggleSideNav()"> <a routerLink="/" (click)="toggleSideNav()">
<os-logo class="os-logo-container" inputAction="logo_web_header" [footer]="false"></os-logo> <os-logo class="os-logo-container" [footer]="false"></os-logo>
</a> </a>
</div> </div>
@ -130,7 +130,9 @@
<small><os-copyright-sign></os-copyright-sign>&nbsp;Copyright by OpenSlides</small> <small><os-copyright-sign></os-copyright-sign>&nbsp;Copyright by OpenSlides</small>
</span> </span>
</a> </a>
<div class="os-footer-logo-container"><os-logo inputAction="logo_web_header" footer="true"> </os-logo></div> <div class="os-footer-logo-container">
<os-logo [footer]="true"></os-logo>
</div>
</mat-nav-list> </mat-nav-list>
</mat-sidenav> </mat-sidenav>
<mat-sidenav-content> <mat-sidenav-content>

View File

@ -0,0 +1,75 @@
$openslides-developer-primary: (
50 : #000000,
100 : #000000,
200 : #000000,
300 : #000000,
400 : #000000,
500 : #000000,
600 : #000000,
700 : #000000,
800 : #000000,
900 : #000000,
A100 : #000000,
A200 : #000000,
A400 : #000000,
A700 : #000000,
contrast: (
50 : #000000,
100 : #000000,
200 : #000000,
300 : #000000,
400 : #000000,
500 : #000000,
600 : #000000,
700 : #000000,
800 : #000000,
900 : #000000,
A100 : #000000,
A200 : #000000,
A400 : #000000,
A700 : #000000,
)
);
$openslides-developer-accent: (
50 : #f4f9e1,
100 : #e5f0b3,
200 : #d3e781,
300 : #c1dd4e,
400 : #b4d528,
500 : #a7ce02,
600 : #9fc902,
700 : #96c201,
800 : #8cbc01,
900 : #7cb001,
A100 : #f2ffda,
A200 : #e1ffa7,
A400 : #cfff74,
A700 : #c6ff5a,
contrast: (
50 : #000000,
100 : #000000,
200 : #000000,
300 : #000000,
400 : #000000,
500 : #000000,
600 : #000000,
700 : #000000,
800 : #000000,
900 : #000000,
A100 : #000000,
A200 : #000000,
A400 : #000000,
A700 : #000000,
)
);
$openslides-primary: mat-palette($openslides-developer-primary);
$openslides-accent: mat-palette($openslides-developer-accent);
$openslides-warn: mat-palette($mat-pink);
$openslides-developer-theme: mat-dark-theme(
$openslides-primary,
$openslides-accent,
$openslides-warn
);

View File

@ -0,0 +1,40 @@
$openslides-primary-dark: (
50 : #e0e0e0,
100 : #b3b3b3,
200 : #808080,
300 : #4d4d4d,
400 : #262626,
500 : #000000,
600 : #000000,
700 : #000000,
800 : #000000,
900 : #000000,
A100 : #a6a6a6,
A200 : #8c8c8c,
A400 : #737373,
A700 : #666666,
contrast: (
50 : #000000,
100 : #000000,
200 : #000000,
300 : #ffffff,
400 : #ffffff,
500 : #ffffff,
600 : #ffffff,
700 : #ffffff,
800 : #ffffff,
900 : #ffffff,
A100 : #000000,
A200 : #000000,
A400 : #ffffff,
A700 : #ffffff,
)
);
$openslides-primary: mat-palette($openslides-primary-dark, 500, 700, 900);
$openslides-general-theme: mat-light-theme(
$openslides-primary,
$openslides-primary,
$openslides-primary
);

View File

@ -13,7 +13,7 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
</head> </head>
<body class="openslides-theme"> <body class="general-theme">
<os-root></os-root> <os-root></os-root>
<noscript>Please enable JavaScript to continue using this application.</noscript> <noscript>Please enable JavaScript to continue using this application.</noscript>
</body> </body>

View File

@ -3,9 +3,11 @@
@include mat-core(); @include mat-core();
/** Import brand theme */ /** Import brand theme */
@import './assets/styles/openslides-general-theme.scss';
@import './assets/styles/openslides-theme.scss'; @import './assets/styles/openslides-theme.scss';
@import './assets/styles/openslides-dark-theme.scss'; @import './assets/styles/openslides-dark-theme.scss';
@import './assets/styles/openslides-green-theme.scss'; @import './assets/styles/openslides-green-theme.scss';
@import './assets/styles/openslides-developer-theme.scss';
/** Import the component-related style sheets here */ /** Import the component-related style sheets here */
@import './app/site/site.component.scss-theme.scss'; @import './app/site/site.component.scss-theme.scss';
@ -13,6 +15,7 @@
@import './app/shared/components/projector-button/projector-button.component.scss'; @import './app/shared/components/projector-button/projector-button.component.scss';
@import './app/site/agenda/components/list-of-speakers/list-of-speakers.component.scss-theme.scss'; @import './app/site/agenda/components/list-of-speakers/list-of-speakers.component.scss-theme.scss';
@import './app/shared/components/sorting-tree/sorting-tree.component.scss'; @import './app/shared/components/sorting-tree/sorting-tree.component.scss';
@import './app/site/global-spinner/global-spinner.component.scss';
/** fonts */ /** fonts */
@import './assets/styles/fonts.scss'; @import './assets/styles/fonts.scss';
@ -25,6 +28,7 @@
@include os-projector-button-style($theme); @include os-projector-button-style($theme);
@include os-list-of-speakers-style($theme); @include os-list-of-speakers-style($theme);
@include os-sorting-tree-style($theme); @include os-sorting-tree-style($theme);
@include os-global-spinner-theme($theme);
/** More components are added here */ /** More components are added here */
} }
@ -34,10 +38,34 @@
/** Load projector specific SCSS values */ /** Load projector specific SCSS values */
@import './assets/styles/projector.scss'; @import './assets/styles/projector.scss';
.general-theme {
@include os-global-spinner-theme($openslides-general-theme);
}
/** Define the classes to switch between themes */ /** Define the classes to switch between themes */
.openslides-theme { .openslides-theme {
@include angular-material-theme($openslides-theme); @include angular-material-theme($openslides-theme);
@include openslides-components-theme($openslides-theme); @include openslides-components-theme($openslides-theme);
.logo-container {
img.dark {
display: none;
}
img.default {
display: inherit;
}
}
}
.openslides-dark-theme, .openslides-developer-dark-theme {
.logo-container {
img.dark {
display: inherit;
}
img.default {
display: none;
}
}
} }
.openslides-dark-theme { .openslides-dark-theme {
@ -50,17 +78,71 @@
@include openslides-components-theme($openslides-green-theme); @include openslides-components-theme($openslides-green-theme);
} }
.openslides-developer-dark-theme {
@include angular-material-theme($openslides-developer-theme);
@include openslides-components-theme($openslides-developer-theme);
* {
font-family: monospace !important;
color: mat-color(map-get($openslides-developer-theme, accent));
}
os-site {
.active mat-icon, .active span {
color: mat-color(map-get($openslides-developer-theme, accent));
}
}
.custom-table-header {
background: #000;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
}
.mat-toolbar.sticky-toolbar {
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
}
.mat-drawer-inner-container {
background: #000;
}
.mat-drawer-inner-container > * {
background: #000 !important;
z-index: 3;
.os-site.nav-toolbar {
background: #000;
}
}
.mat-drawer-content > * {
background: #000;
}
mat-icon {
font-family: 'Material Icons' !important;
}
.mat-mini-fab.projector-active {
background-color: mat-color(map-get($openslides-developer-theme, warn));
}
.mce-ico {
font-family: 'tinymce', Arial !important;
}
}
/** Define the general style-rules */ /** Define the general style-rules */
* { * {
font-family: OSFont, Fira Sans, Roboto, Arial, Helvetica, sans-serif; font-family: OSFont, Fira Sans, Roboto, Arial, Helvetica, sans-serif;
} }
.mat-toolbar h2, .mat-toolbar h2,
.mat-dialog-title { .mat-dialog-title {
font-family: OSFont, Fira Sans, Roboto, Arial, Helvetica, sans-serif !important; font-family: OSFont, Fira Sans, Roboto, Arial, Helvetica, sans-serif;
} }
mat-icon { mat-icon {
font-family: 'Material Icons Baseline'; font-family: 'Material Icons' !important;
} }
body { body {

View File

@ -121,6 +121,10 @@ def get_config_variables():
{"value": "openslides-theme", "display_name": "OpenSlides Default"}, {"value": "openslides-theme", "display_name": "OpenSlides Default"},
{"value": "openslides-dark-theme", "display_name": "OpenSlides Dark"}, {"value": "openslides-dark-theme", "display_name": "OpenSlides Dark"},
{"value": "openslides-green-theme", "display_name": "OpenSlides Green"}, {"value": "openslides-green-theme", "display_name": "OpenSlides Green"},
{
"value": "openslides-developer-dark-theme",
"display_name": "OpenSlides Developer",
},
), ),
weight=141, weight=141,
group="General", group="General",

View File

@ -562,6 +562,7 @@ class UserLoginView(WhoAmIDataView):
context["legal_notice"] = config["general_event_legal_notice"] context["legal_notice"] = config["general_event_legal_notice"]
# Add the theme, so the loginpage is themed correctly # Add the theme, so the loginpage is themed correctly
context["theme"] = config["openslides_theme"] context["theme"] = config["openslides_theme"]
context["logo_web_header"] = config["logo_web_header"]
else: else:
# self.request.method == 'POST' # self.request.method == 'POST'
context.update(self.get_whoami_data()) context.update(self.get_whoami_data())