diff --git a/frontend/src/app/components/objective/objective.component.html b/frontend/src/app/components/objective/objective.component.html
index 0b6b5b0f35..48fd01a194 100644
--- a/frontend/src/app/components/objective/objective.component.html
+++ b/frontend/src/app/components/objective/objective.component.html
@@ -29,7 +29,7 @@
{{ objective.title }}
(keydown.enter)="$event.stopPropagation()"
[attr.data-testId]="'three-dot-menu'"
>
-
+
diff --git a/frontend/src/app/components/objective/objective.component.scss b/frontend/src/app/components/objective/objective.component.scss
index b2c08d3c1e..4bf3ed8826 100644
--- a/frontend/src/app/components/objective/objective.component.scss
+++ b/frontend/src/app/components/objective/objective.component.scss
@@ -44,3 +44,7 @@
.objective-title {
width: 90%;
}
+
+.menu-scale {
+ transform: scale(1.1);
+}
diff --git a/frontend/src/app/config.service.ts b/frontend/src/app/config.service.ts
index b311b6b04e..27faea76e1 100644
--- a/frontend/src/app/config.service.ts
+++ b/frontend/src/app/config.service.ts
@@ -1,14 +1,15 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, shareReplay } from 'rxjs';
+import { ClientConfig } from './shared/types/model/ClientConfig';
@Injectable({
providedIn: 'root',
})
export class ConfigService {
- public config$: Observable
;
+ public config$: Observable;
constructor(private httpClient: HttpClient) {
- this.config$ = this.httpClient.get('/config').pipe(shareReplay());
+ this.config$ = this.httpClient.get('/config').pipe(shareReplay());
}
}
diff --git a/frontend/src/app/objective-filter/objective-filter.component.scss b/frontend/src/app/objective-filter/objective-filter.component.scss
index e69de29bb2..0f3d371327 100644
--- a/frontend/src/app/objective-filter/objective-filter.component.scss
+++ b/frontend/src/app/objective-filter/objective-filter.component.scss
@@ -0,0 +1,12 @@
+.search-button {
+ border-radius: 0 4px 4px 0;
+ padding-left: 15px;
+}
+
+#objective-form-field {
+ width: 300px;
+}
+
+.search-scale {
+ transform: scale(1.2);
+}
diff --git a/frontend/src/app/services/customization.service.spec.ts b/frontend/src/app/services/customization.service.spec.ts
new file mode 100644
index 0000000000..9419bb8de8
--- /dev/null
+++ b/frontend/src/app/services/customization.service.spec.ts
@@ -0,0 +1,129 @@
+import { CustomizationService } from './customization.service';
+import { ConfigService } from '../../config.service';
+import { BehaviorSubject } from 'rxjs';
+import { ClientConfig } from '../types/model/ClientConfig';
+
+class CallRecorder {
+ private calls: { [key: string]: any[] } = {};
+
+ public add(key: string, value: any): void {
+ if (!this.calls[key]) {
+ this.calls[key] = [];
+ }
+ this.calls[key].push(value);
+ }
+
+ public getCallByIdx(key: string, index = 0): any[] {
+ return this.calls[key][index];
+ }
+
+ public getCallCount(key: string): number {
+ return this.calls[key]?.length ?? 0;
+ }
+
+ public clear(): void {
+ this.calls = {};
+ }
+}
+
+describe('CustomizationService', () => {
+ const body: ClientConfig = {
+ activeProfile: 'test',
+ issuer: 'some-issuer.com',
+ clientId: 'my-client-id',
+ title: 'title',
+ favicon: 'favicon',
+ logo: 'logo',
+ customStyles: { cssVar1: 'foo' },
+ };
+
+ let service: CustomizationService;
+ let configServiceMock: ConfigService;
+ let documentMock: Document;
+ let callRecorder = new CallRecorder();
+ let configSubject: BehaviorSubject;
+
+ beforeEach(() => {
+ configSubject = new BehaviorSubject(body);
+ configServiceMock = { config$: configSubject.asObservable() } as ConfigService;
+ callRecorder.clear();
+
+ documentMock = {
+ getElementById: (id: string) => {
+ return {
+ setAttribute: function () {
+ callRecorder.add(`${id}-setAttribute`, arguments);
+ },
+ } as unknown as HTMLElement;
+ },
+ querySelector: (selector: string) => {
+ return {
+ set innerHTML(value: string) {
+ callRecorder.add(`${selector}.innerHTML`, arguments);
+ },
+ get style() {
+ return {
+ setProperty: function () {
+ callRecorder.add(`${selector}.style.setProperty`, arguments);
+ },
+ removeProperty: function () {
+ callRecorder.add(`${selector}.style.removeProperty`, arguments);
+ },
+ };
+ },
+ };
+ },
+ } as unknown as Document;
+ service = new CustomizationService(configServiceMock, documentMock);
+ });
+
+ it('should call correct apis when config is ready', () => {
+ const currentConfig = service.getCurrentConfig();
+ expect(currentConfig?.title).toBe(body.title);
+ expect(currentConfig?.logo).toBe(body.logo);
+ expect(currentConfig?.favicon).toBe(body.favicon);
+ expect(currentConfig?.customStyles['cssVar1']).toBe(body.customStyles['cssVar1']);
+
+ expect(callRecorder.getCallCount('title.innerHTML')).toBe(1);
+ expect(callRecorder.getCallCount('favicon-setAttribute')).toBe(1);
+ expect(callRecorder.getCallCount('html.style.setProperty')).toBe(1);
+ expect(callRecorder.getCallCount('html.style.removeProperty')).toBe(0);
+
+ expect(callRecorder.getCallByIdx('title.innerHTML', 0)[0]).toBe('title');
+ expect(callRecorder.getCallByIdx('favicon-setAttribute', 0)[0]).toBe('href');
+ expect(callRecorder.getCallByIdx('favicon-setAttribute', 0)[1]).toBe('favicon');
+ expect(callRecorder.getCallByIdx('html.style.setProperty', 0)[0]).toBe('--cssVar1');
+ expect(callRecorder.getCallByIdx('html.style.setProperty', 0)[1]).toBe('foo');
+ });
+
+ it('should update if config changed afterwards', () => {
+ const bodySecond = {
+ activeProfile: 'test-second',
+ issuer: 'some-issuer.com-second',
+ clientId: 'my-client-id-second',
+ title: 'title-second',
+ favicon: 'favicon-second',
+ logo: 'logo-second',
+ customStyles: { cssVarNew: 'bar' },
+ };
+ configSubject.next(bodySecond);
+
+ const currentConfig = service.getCurrentConfig();
+ expect(currentConfig?.title).toBe(bodySecond.title);
+ expect(currentConfig?.logo).toBe(bodySecond.logo);
+ expect(currentConfig?.favicon).toBe(bodySecond.favicon);
+ expect(currentConfig?.customStyles['cssVarNew']).toBe(bodySecond.customStyles['cssVarNew']);
+ expect(currentConfig?.customStyles['cssVar1']).toBe(undefined);
+
+ expect(callRecorder.getCallCount('title.innerHTML')).toBe(2);
+ expect(callRecorder.getCallCount('favicon-setAttribute')).toBe(2);
+ expect(callRecorder.getCallCount('html.style.setProperty')).toBe(2);
+ expect(callRecorder.getCallCount('html.style.removeProperty')).toBe(1);
+
+ expect(callRecorder.getCallByIdx('title.innerHTML', 1)[0]).toBe('title-second');
+ expect(callRecorder.getCallByIdx('favicon-setAttribute', 1)[0]).toBe('href');
+ expect(callRecorder.getCallByIdx('favicon-setAttribute', 1)[1]).toBe('favicon-second');
+ expect(callRecorder.getCallByIdx('html.style.setProperty', 1)[0]).toBe('--cssVarNew');
+ expect(callRecorder.getCallByIdx('html.style.setProperty', 1)[1]).toBe('bar');
+ });
+});
diff --git a/frontend/src/app/services/customization.service.ts b/frontend/src/app/services/customization.service.ts
new file mode 100644
index 0000000000..e96215cc12
--- /dev/null
+++ b/frontend/src/app/services/customization.service.ts
@@ -0,0 +1,102 @@
+import { Inject, Injectable } from '@angular/core';
+import { ConfigService } from '../../config.service';
+import { CustomizationConfig, CustomStyles } from '../types/model/ClientConfig';
+import { DOCUMENT } from '@angular/common';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class CustomizationService {
+ private currentConfig?: CustomizationConfig;
+
+ constructor(
+ configService: ConfigService,
+ @Inject(DOCUMENT) private document: Document,
+ ) {
+ configService.config$.subscribe((config) => {
+ this.updateCustomizations(config);
+ });
+ }
+
+ public getCurrentConfig() {
+ return this.currentConfig;
+ }
+
+ private updateCustomizations(config: CustomizationConfig) {
+ this.setTitle(config.title);
+ this.setFavicon(config.favicon);
+ this.setStyleCustomizations(config.customStyles);
+
+ this.currentConfig = config;
+ }
+
+ private setFavicon(favicon: string) {
+ if (!favicon || this.currentConfig?.favicon === favicon) {
+ return;
+ }
+
+ if (!this.document) {
+ return;
+ }
+
+ this.document.getElementById('favicon')?.setAttribute('href', favicon);
+ }
+
+ private setTitle(title: string) {
+ if (!title || this.currentConfig?.title === title) {
+ return;
+ }
+
+ if (!this.document) {
+ return;
+ }
+
+ this.document.querySelector('title')!.innerHTML = title;
+ }
+
+ private setStyleCustomizations(customStylesMap: CustomStyles) {
+ if (!customStylesMap || this.areStylesTheSame(customStylesMap)) {
+ return;
+ }
+
+ this.removeStyles(this.currentConfig?.customStyles);
+ this.setStyles(customStylesMap);
+ }
+
+ private areStylesTheSame(customStylesMap: CustomStyles) {
+ return JSON.stringify(this.currentConfig?.customStyles) === JSON.stringify(customStylesMap);
+ }
+
+ private setStyles(customStylesMap: CustomStyles | undefined) {
+ if (!customStylesMap) {
+ return;
+ }
+
+ if (!this.document) {
+ return;
+ }
+
+ const styles = this.document.querySelector('html')!.style;
+ if (!styles) {
+ return;
+ }
+
+ Object.entries(customStylesMap).forEach(([varName, varValue]) => {
+ styles.setProperty(`--${varName}`, varValue);
+ });
+ }
+
+ private removeStyles(customStylesMap: CustomStyles | undefined) {
+ if (!customStylesMap) {
+ return;
+ }
+
+ const styles = this.document.querySelector('html')!.style;
+ if (!styles) {
+ return;
+ }
+ Object.keys(customStylesMap).forEach((varName) => {
+ styles.removeProperty(`--${varName}`);
+ });
+ }
+}
diff --git a/frontend/src/app/shared/custom/scoring/scoring.component.scss b/frontend/src/app/shared/custom/scoring/scoring.component.scss
index 7e088b41a9..323c8e33cb 100644
--- a/frontend/src/app/shared/custom/scoring/scoring.component.scss
+++ b/frontend/src/app/shared/custom/scoring/scoring.component.scss
@@ -6,7 +6,7 @@
.scoring-container {
display: flex;
- align-items: start;
+ justify-content: flex-start;
}
.okr-score-label {
diff --git a/frontend/src/app/shared/types/model/ClientConfig.ts b/frontend/src/app/shared/types/model/ClientConfig.ts
new file mode 100644
index 0000000000..031e4bb55a
--- /dev/null
+++ b/frontend/src/app/shared/types/model/ClientConfig.ts
@@ -0,0 +1,17 @@
+export interface AuthConfig {
+ activeProfile: string;
+ issuer: string;
+ clientId: string;
+}
+
+export interface CustomStyles {
+ [key: string]: string;
+}
+
+export interface CustomizationConfig {
+ title: string;
+ favicon: string;
+ logo: string;
+ customStyles: CustomStyles;
+}
+export interface ClientConfig extends AuthConfig, CustomizationConfig {}
diff --git a/frontend/src/assets/icons/threedot.svg b/frontend/src/assets/icons/threedot.svg
deleted file mode 100644
index 6e79363f35..0000000000
--- a/frontend/src/assets/icons/threedot.svg
+++ /dev/null
@@ -1,5 +0,0 @@
-
diff --git a/frontend/src/assets/images/empty.svg b/frontend/src/assets/images/empty.svg
new file mode 100644
index 0000000000..2a63845fcf
--- /dev/null
+++ b/frontend/src/assets/images/empty.svg
@@ -0,0 +1,3 @@
+
+
diff --git a/frontend/src/index.html b/frontend/src/index.html
index 2ef665adae..69fb91e0c2 100644
--- a/frontend/src/index.html
+++ b/frontend/src/index.html
@@ -2,10 +2,10 @@
- Puzzle OKR
+ OKR Tool
-
+
diff --git a/frontend/src/style/_variables.scss b/frontend/src/style/_variables.scss
index c027f26cdd..24b4eaf669 100644
--- a/frontend/src/style/_variables.scss
+++ b/frontend/src/style/_variables.scss
@@ -48,4 +48,6 @@ $pz-dark-blue-palette: (
--mdc-text-button-label-text-tracking: normal;
--mdc-filled-button-label-text-tracking: normal;
--mdc-outlined-button-label-text-tracking: normal;
+
+ --okr-topbar-background-color: #1e5a96;
}
diff --git a/frontend/src/style/custom_angular.scss b/frontend/src/style/custom_angular.scss
index 74f079b1dc..5657b28c28 100644
--- a/frontend/src/style/custom_angular.scss
+++ b/frontend/src/style/custom_angular.scss
@@ -19,6 +19,6 @@ $pz-theme: mat.define-light-theme(
@include mat.all-component-themes($pz-theme);
.header-form-field {
- @include mat.private-form-field-density(-4);
+ @include mat.form-field-density(-4);
border-radius: 5px;
}
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
index 2a3b5bd381..c4e3ae25fd 100644
--- a/frontend/tsconfig.json
+++ b/frontend/tsconfig.json
@@ -22,13 +22,13 @@
"resolveJsonModule": true,
"esModuleInterop": true,
"useDefineForClassFields": false,
- "allowSyntheticDefaultImports": true,
+ "allowSyntheticDefaultImports": true
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
- "strictTemplates": true,
+ "strictTemplates": true
},
- "exclude": ["**/*.spec.ts"],
+ "exclude": ["**/*.spec.ts"]
}
diff --git a/pom.xml b/pom.xml
index be0582eca6..a2b52facc2 100644
--- a/pom.xml
+++ b/pom.xml
@@ -8,12 +8,12 @@
ch.puzzle.okr
parent
- 2.0.39-SNAPSHOT
+ 2.0.95-SNAPSHOT
org.springframework.boot
spring-boot-starter-parent
- 3.2.2
+ 3.2.3