diff --git a/.github/workflows/p2-aus-appv3.yml b/.github/workflows/p2-aus-appv3.yml index 6dd162ba4..3e42cda29 100644 --- a/.github/workflows/p2-aus-appv3.yml +++ b/.github/workflows/p2-aus-appv3.yml @@ -180,6 +180,7 @@ jobs: export CUSTOM_HELPLINE=help@practera.com export CUSTOM_ENABLE_ASSESSMENT_PAGINATION=false export CUSTOM_STACK_NAME=${{ env.STACK_NAME }} + export CUSTOM_PROJECTHUB_URL= printf "Angular environment variable creation complete\n\n" printf "Executing env.sh script\n\n" diff --git a/.github/workflows/p2-euk-appv3.yml b/.github/workflows/p2-euk-appv3.yml index ea3ac7dcc..ee4aafef3 100644 --- a/.github/workflows/p2-euk-appv3.yml +++ b/.github/workflows/p2-euk-appv3.yml @@ -180,6 +180,7 @@ jobs: export CUSTOM_HELPLINE=help@practera.com export CUSTOM_ENABLE_ASSESSMENT_PAGINATION=false export CUSTOM_STACK_NAME=${{ env.STACK_NAME }} + export CUSTOM_PROJECTHUB_URL= printf "Angular environment variable creation complete\n\n" printf "Executing env.sh script\n\n" diff --git a/.github/workflows/p2-prerelease-appv3.yml b/.github/workflows/p2-prerelease-appv3.yml index 1419d21a9..5e96b65ad 100644 --- a/.github/workflows/p2-prerelease-appv3.yml +++ b/.github/workflows/p2-prerelease-appv3.yml @@ -183,6 +183,7 @@ jobs: export CUSTOM_HELPLINE=help@practera.com export CUSTOM_ENABLE_ASSESSMENT_PAGINATION=true export CUSTOM_STACK_NAME=${{ env.STACK_NAME }} + export CUSTOM_PROJECTHUB_URL=https://projecthub.p2-prerelease.practera.com/ printf "Angular environment variable creation complete\n\n" printf "Executing env.sh script\n\n" diff --git a/.github/workflows/p2-stage-appv3.yml b/.github/workflows/p2-stage-appv3.yml index fd85793c1..0fa1cae7a 100644 --- a/.github/workflows/p2-stage-appv3.yml +++ b/.github/workflows/p2-stage-appv3.yml @@ -183,6 +183,7 @@ jobs: export CUSTOM_HELPLINE=help@practera.com export CUSTOM_ENABLE_ASSESSMENT_PAGINATION=true export CUSTOM_STACK_NAME=${{ env.STACK_NAME }} + export CUSTOM_PROJECTHUB_URL=https://projecthub.p2-stage.practera.com/ printf "Angular environment variable creation complete\n\n" printf "Executing env.sh script\n\n" diff --git a/.github/workflows/p2-usa-appv3.yml b/.github/workflows/p2-usa-appv3.yml index 5baa65d66..6691ec7fc 100644 --- a/.github/workflows/p2-usa-appv3.yml +++ b/.github/workflows/p2-usa-appv3.yml @@ -180,6 +180,7 @@ jobs: export CUSTOM_HELPLINE=help@practera.com export CUSTOM_ENABLE_ASSESSMENT_PAGINATION=false export CUSTOM_STACK_NAME=${{ env.STACK_NAME }} + export CUSTOM_PROJECTHUB_URL= printf "Angular environment variable creation complete\n\n" printf "Executing env.sh script\n\n" diff --git a/docs/features/project-brief.md b/docs/features/project-brief.md new file mode 100644 index 000000000..e6fcbb16f --- /dev/null +++ b/docs/features/project-brief.md @@ -0,0 +1,255 @@ +# Project Brief Feature + +## Overview + +The Project Brief feature displays team project information to users on the home page. When a user's team has a project brief configured, a "Project Brief" button appears next to the experience name. Clicking this button opens a modal that displays structured project details. + +## Data Flow + +``` +GraphQL API (teams.projectBrief as stringified JSON) + ↓ +SharedService.getTeamInfo() + ↓ parseProjectBrief() - safely parses JSON +BrowserStorageService.setUser({ projectBrief: parsedObject }) + ↓ +HomePage.updateDashboard() + ↓ reads from storage +this.projectBrief = this.storageService.getUser().projectBrief + ↓ +Template: *ngIf="projectBrief" shows button + ↓ +showProjectBrief() → opens ProjectBriefModalComponent +``` + +## Components + +### ProjectBriefModalComponent + +**Location:** `projects/v3/src/app/components/project-brief-modal/` + +**Files:** +- `project-brief-modal.component.ts` - Component logic +- `project-brief-modal.component.html` - Template with sections for each field +- `project-brief-modal.component.scss` - Component-specific styles +- `project-brief-modal.component.spec.ts` - Unit tests + +**Input:** +```typescript +@Input() projectBrief: ProjectBrief = {}; +``` + +**Interface:** +```typescript +interface ProjectBrief { + id?: string; + title?: string; + description?: string; + industry?: string[]; + projectType?: string; + technicalSkills?: string[]; + professionalSkills?: string[]; + deliverables?: string; +} +``` + +**Display Sections:** +- Title (headline) +- Description +- Project Type +- Industry (as chips) +- Technical Skills (as chips) +- Professional Skills (as chips) +- Deliverables + +**Empty Field Handling:** +- All sections show "None specified" when the field is empty or undefined +- Uses `hasValue()` for string fields and `hasItems()` for array fields + +## Integration Points + +### SharedService + +**Method:** `parseProjectBrief(briefString: string): object | null` + +Safely parses the stringified JSON from the API: +- Returns `null` if input is falsy or not a string +- Uses try-catch to handle malformed JSON +- Logs errors to console for debugging + +**Usage in getTeamInfo():** +```typescript +this.storage.setUser({ + teamId: teams[0].id, + teamName: teams[0].name, + projectBrief: this.parseProjectBrief(teams[0].projectBrief), + teamUuid: teams[0].uuid +}); +``` + +### HomePage + +**Property:** +```typescript +projectBrief: ProjectBrief | null = null; +``` + +**Loading (in updateDashboard):** +```typescript +this.projectBrief = this.storageService.getUser().projectBrief || null; +``` + +**Modal Display:** +```typescript +async showProjectBrief(): Promise { + if (!this.projectBrief) { + return; + } + + const modal = await this.modalController.create({ + component: ProjectBriefModalComponent, + componentProps: { + projectBrief: this.projectBrief + }, + cssClass: 'project-brief-modal' + }); + + await modal.present(); +} +``` + +### Template (home.page.html) + +Button placement - next to experience name: +```html +
+

+ + + Project Brief + +
+``` + +## Styling + +### Button (home.page.scss) + +```scss +.exp-header { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.project-brief-btn { + --padding-start: 8px; + --padding-end: 8px; + font-size: 0.875rem; + text-transform: none; + letter-spacing: normal; +} +``` + +### Modal (styles.scss) + +```scss +.project-brief-modal { + --width: 90%; + --max-width: 500px; + --height: auto; + --max-height: 80vh; + --border-radius: 12px; + + @media (min-width: 768px) { + --width: 500px; + } +} +``` + +## Accessibility + +- Button includes `aria-label="View project brief"` with i18n support +- Keyboard navigation with `(keydown.enter)` and `(keydown.space)` handlers +- Modal has proper semantic structure with `
`, `
`, and heading hierarchy +- Close button includes `aria-label="Close project brief"` +- Ion-chips for industry/skills are visually distinct with color coding + +## Sample Data + +API returns stringified JSON: +```json +"{\"id\":\"fdcdf0d1-2148-4bab-a02a-62a2ae535fbe\",\"title\":\"Project Title\",\"description\":\"Project description text\",\"industry\":[\"Health & Medical Science\",\"Communications, Media, Digital & Creative\"],\"projectType\":\"Growth Strategy\",\"technicalSkills\":[],\"professionalSkills\":[],\"deliverables\":\"Deliverables description\",\"timeline\":12}" +``` + +After parsing: +```typescript +{ + id: "fdcdf0d1-2148-4bab-a02a-62a2ae535fbe", + title: "Project Title", + description: "Project description text", + industry: ["Health & Medical Science", "Communications, Media, Digital & Creative"], + projectType: "Growth Strategy", + technicalSkills: [], + professionalSkills: [], + deliverables: "Deliverables description", + timeline: 12 +} +``` + +**Note:** The `timeline` field is not displayed in the UI as per requirements. + +## Testing + +### Unit Tests + +**ProjectBriefModalComponent tests:** +- Component creation +- `close()` method dismisses modal +- `hasItems()` correctly identifies empty/populated arrays +- `hasValue()` correctly identifies empty/populated strings +- Template renders title when provided +- Template shows "None specified" for empty fields +- Template renders chips for industry and skills + +**HomePage tests (additions needed):** +- Button visible when `projectBrief` is set +- Button hidden when `projectBrief` is null +- `showProjectBrief()` creates and presents modal + +**SharedService tests (additions needed):** +- `parseProjectBrief()` returns parsed object for valid JSON +- `parseProjectBrief()` returns null for invalid JSON +- `parseProjectBrief()` returns null for empty string +- `parseProjectBrief()` returns null for null/undefined input + +## Module Registration + +The component is registered in `ComponentsModule`: + +```typescript +// Import +import { ProjectBriefModalComponent } from './project-brief-modal/project-brief-modal.component'; + +// Declarations +declarations: [ + // ... + ProjectBriefModalComponent, + // ... +], + +// Exports +exports: [ + // ... + ProjectBriefModalComponent, + // ... +], +``` diff --git a/package-lock.json b/package-lock.json index 6f6554846..986967c4a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -706,9 +706,9 @@ } }, "node_modules/@angular-devkit/build-angular/node_modules/@types/node": { - "version": "25.0.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", - "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", + "version": "25.0.8", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.8.tgz", + "integrity": "sha512-powIePYMmC3ibL0UJ2i2s0WIbq6cg6UyVFQxSCpaPxxzAaziRfimGivjdF943sSGV6RADVbk0Nvlm5P/FB44Zg==", "dev": true, "license": "MIT", "optional": true, diff --git a/projects/v3/src/app/components/assessment/assessment.component.html b/projects/v3/src/app/components/assessment/assessment.component.html index f4a8ceeac..f8a358846 100644 --- a/projects/v3/src/app/components/assessment/assessment.component.html +++ b/projects/v3/src/app/components/assessment/assessment.component.html @@ -89,6 +89,24 @@ + + + + + + + + Project Brief + + + + + diff --git a/projects/v3/src/app/components/assessment/assessment.component.spec.ts b/projects/v3/src/app/components/assessment/assessment.component.spec.ts index 3be4a76f1..869a5dbec 100644 --- a/projects/v3/src/app/components/assessment/assessment.component.spec.ts +++ b/projects/v3/src/app/components/assessment/assessment.component.spec.ts @@ -17,6 +17,7 @@ import { BehaviorSubject, of, Subject } from 'rxjs'; import { MockRouter } from '@testingv3/mocked.service'; import { TestUtils } from '@testingv3/utils'; import { ApolloService } from '@v3/app/services/apollo.service'; +import { ModalController } from '@ionic/angular'; class Page { get savingMessage() { @@ -101,6 +102,7 @@ describe('AssessmentComponent', () => { let shared: SharedService; let utils: UtilsService; let apolloSpy: jasmine.SpyObj; + let modalSpy: jasmine.SpyObj; const mockQuestions = [ { @@ -237,6 +239,10 @@ describe('AssessmentComponent', () => { provide: Router, useClass: MockRouter, }, + { + provide: ModalController, + useValue: jasmine.createSpyObj('ModalController', ['create', 'dismiss']), + }, ] }).compileComponents(); @@ -257,6 +263,7 @@ describe('AssessmentComponent', () => { apolloSpy = TestBed.inject(ApolloService) as jasmine.SpyObj; shared = TestBed.inject(SharedService); utils = TestBed.inject(UtilsService); + modalSpy = TestBed.inject(ModalController) as jasmine.SpyObj; // initialise service calls /* assessmentSpy.getAssessment.and.returnValue(of({ @@ -274,6 +281,47 @@ describe('AssessmentComponent', () => { expect(component).toBeTruthy(); }); + describe('showProjectBrief()', () => { + it('should open project brief modal when review has projectBrief', async () => { + const mockProjectBrief = { + id: 'brief-1', + title: 'Test Brief', + description: 'Test Description', + }; + component.review = { + id: 1, + answers: {}, + status: 'pending review', + modified: '2024-01-01', + projectBrief: mockProjectBrief, + }; + const mockModal = { present: jasmine.createSpy('present') }; + modalSpy.create.and.returnValue(Promise.resolve(mockModal as any)); + + await component.showProjectBrief(); + + expect(modalSpy.create).toHaveBeenCalledWith({ + component: jasmine.any(Function), + componentProps: { projectBrief: mockProjectBrief }, + cssClass: 'project-brief-modal', + }); + expect(mockModal.present).toHaveBeenCalled(); + }); + + it('should not open modal when review has no projectBrief', async () => { + component.review = { + id: 1, + answers: {}, + status: 'pending review', + modified: '2024-01-01', + }; + + await component.showProjectBrief(); + + expect(modalSpy.create).not.toHaveBeenCalled(); + }); + }); + describe('ngOnChanges()', () => { it('should straightaway return when assessment not loaded', () => { expect(component.ngOnChanges({})).toBeFalsy(); diff --git a/projects/v3/src/app/components/assessment/assessment.component.ts b/projects/v3/src/app/components/assessment/assessment.component.ts index cdb05abee..0dc8bfb40 100644 --- a/projects/v3/src/app/components/assessment/assessment.component.ts +++ b/projects/v3/src/app/components/assessment/assessment.component.ts @@ -19,6 +19,8 @@ import { Task } from '@v3/app/services/activity.service'; import { ActivityService } from '@v3/app/services/activity.service'; import { FileInput, Question, SubmitActions } from '../types/assessment'; import { FileUploadComponent } from '../file-upload/file-upload.component'; +import { ProjectBriefModalComponent, ProjectBrief } from '../project-brief-modal/project-brief-modal.component'; +import { ModalController } from '@ionic/angular'; const MIN_SCROLLING_PAGES = 8; // minimum number of pages to show pagination scrolling const MAX_QUESTIONS_PER_PAGE = 8; // maximum number of questions to display per paginated view (controls pagination granularity) @@ -151,6 +153,7 @@ export class AssessmentComponent implements OnInit, OnChanges, OnDestroy { private sharedService: SharedService, private assessmentService: AssessmentService, private activityService: ActivityService, + private modalController: ModalController, private cdr: ChangeDetectorRef, ) { this.resubscribe$.pipe( @@ -1029,6 +1032,21 @@ Best regards`; } } + /** + * open the project brief modal for the submitter's team + */ + async showProjectBrief(): Promise { + if (!this.review?.projectBrief) { + return; + } + const modal = await this.modalController.create({ + component: ProjectBriefModalComponent, + componentProps: { projectBrief: this.review.projectBrief }, + cssClass: 'project-brief-modal', + }); + await modal.present(); + } + /** * Breaks original groups into pages, each containing ≤ pageSize questions. * If a single group has more questions than pageSize, it gets sliced. diff --git a/projects/v3/src/app/components/components.module.ts b/projects/v3/src/app/components/components.module.ts index b494ce8c1..427e3be85 100644 --- a/projects/v3/src/app/components/components.module.ts +++ b/projects/v3/src/app/components/components.module.ts @@ -46,6 +46,7 @@ import { UppyUploaderService } from './uppy-uploader/uppy-uploader.service'; import { FilePopupComponent } from './file-popup/file-popup.component'; import { SliderComponent } from './slider/slider.component'; import { LanguageDetectionPipe } from '../pipes/language.pipe'; +import { ProjectBriefModalComponent } from './project-brief-modal/project-brief-modal.component'; const largeCircleDefaultConfig = { backgroundColor: 'var(--ion-color-light)', @@ -99,6 +100,7 @@ const largeCircleDefaultConfig = { MultipleComponent, OneofComponent, PopUpComponent, + ProjectBriefModalComponent, ReviewListComponent, ReviewRatingComponent, SliderComponent, @@ -146,6 +148,7 @@ const largeCircleDefaultConfig = { MultipleComponent, OneofComponent, PopUpComponent, + ProjectBriefModalComponent, ReviewListComponent, ReviewRatingComponent, SliderComponent, diff --git a/projects/v3/src/app/components/project-brief-modal/project-brief-modal.component.html b/projects/v3/src/app/components/project-brief-modal/project-brief-modal.component.html new file mode 100644 index 000000000..aea21a7a6 --- /dev/null +++ b/projects/v3/src/app/components/project-brief-modal/project-brief-modal.component.html @@ -0,0 +1,125 @@ + + + Project Brief + + + + + + + + + +
+

{{ projectBrief.title }}

+
+ + {{ projectBrief.projectType }} +
+
+ + + + + + + Description + +
+

+ {{ projectBrief.description }} +

+ +

None specified

+
+
+
+ + + + + Industry + +
+
+ + {{ item }} + +
+ +

None specified

+
+
+
+ + + + + Technical Skills + +
+
+ + {{ skill }} + +
+ +

None specified

+
+
+
+ + + + + Professional Skills + +
+
+ + {{ skill }} + +
+ +

None specified

+
+
+
+ + + + + Deliverables + +
+

+ {{ projectBrief.deliverables }} +

+ +

None specified

+
+
+
+ +
+
+ + + + Close + + diff --git a/projects/v3/src/app/components/project-brief-modal/project-brief-modal.component.scss b/projects/v3/src/app/components/project-brief-modal/project-brief-modal.component.scss new file mode 100644 index 000000000..8fd4cd0c0 --- /dev/null +++ b/projects/v3/src/app/components/project-brief-modal/project-brief-modal.component.scss @@ -0,0 +1,80 @@ +.title-section { + margin-bottom: 24px; + padding-bottom: 20px; + border-bottom: 2px solid var(--ion-color-medium-tint); + + h1 { + margin-bottom: 12px; + } + + .info-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: var(--ion-color-primary-tint); + border-radius: 16px; + font-size: 0.875rem; + color: var(--ion-color-primary-shade); + + ion-icon { + font-size: 1rem; + } + } +} + +.brief-accordion { + ion-accordion { + margin-bottom: 8px; + border: 1px solid var(--ion-color-light-shade); + border-radius: 8px; + overflow: hidden; + + &:last-child { + margin-bottom: 0; + } + } + + .accordion-header { + font-weight: 600; + font-size: 0.95rem; + color: var(--ion-color-dark); + } + + ion-item { + --padding-start: 12px; + --inner-padding-end: 12px; + --min-height: 48px; + } + + .accordion-content { + padding: 16px; + background: var(--ion-color-light); + } +} + +.section-value { + color: var(--ion-color-dark); + font-size: 1rem; + line-height: 1.5; + margin: 0; +} + +.chip-container { + display: flex; + flex-wrap: wrap; + gap: 8px; + + ion-chip { + margin: 0; + } +} + +.none-specified { + color: var(--ion-color-medium); + font-style: italic; +} + +ion-header ion-toolbar { + --background: var(--ion-color-light); +} diff --git a/projects/v3/src/app/components/project-brief-modal/project-brief-modal.component.spec.ts b/projects/v3/src/app/components/project-brief-modal/project-brief-modal.component.spec.ts new file mode 100644 index 000000000..8fc024d6a --- /dev/null +++ b/projects/v3/src/app/components/project-brief-modal/project-brief-modal.component.spec.ts @@ -0,0 +1,121 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { IonicModule, ModalController } from '@ionic/angular'; +import { ProjectBriefModalComponent, ProjectBrief } from './project-brief-modal.component'; + +describe('ProjectBriefModalComponent', () => { + let component: ProjectBriefModalComponent; + let fixture: ComponentFixture; + let modalControllerSpy: jasmine.SpyObj; + + beforeEach(waitForAsync(() => { + modalControllerSpy = jasmine.createSpyObj('ModalController', ['dismiss']); + + TestBed.configureTestingModule({ + declarations: [ProjectBriefModalComponent], + imports: [IonicModule.forRoot()], + providers: [ + { provide: ModalController, useValue: modalControllerSpy } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(ProjectBriefModalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + })); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('close()', () => { + it('should dismiss the modal', () => { + component.close(); + expect(modalControllerSpy.dismiss).toHaveBeenCalled(); + }); + }); + + describe('hasItems()', () => { + it('should return true for non-empty array', () => { + expect(component.hasItems(['item1', 'item2'])).toBe(true); + }); + + it('should return false for empty array', () => { + expect(component.hasItems([])).toBe(false); + }); + + it('should return false for undefined', () => { + expect(component.hasItems(undefined)).toBe(false); + }); + + it('should return false for null', () => { + expect(component.hasItems(null as any)).toBe(false); + }); + }); + + describe('hasValue()', () => { + it('should return true for non-empty string', () => { + expect(component.hasValue('test value')).toBe(true); + }); + + it('should return false for empty string', () => { + expect(component.hasValue('')).toBe(false); + }); + + it('should return false for whitespace only string', () => { + expect(component.hasValue(' ')).toBe(false); + }); + + it('should return false for undefined', () => { + expect(component.hasValue(undefined)).toBe(false); + }); + + it('should return false for null', () => { + expect(component.hasValue(null as any)).toBe(false); + }); + }); + + describe('template rendering', () => { + it('should display project brief title when provided', () => { + const testBrief: ProjectBrief = { + title: 'Test Project Title', + description: 'Test description' + }; + component.projectBrief = testBrief; + fixture.detectChanges(); + + const titleElement = fixture.nativeElement.querySelector('#project-brief-title'); + expect(titleElement.textContent).toContain('Test Project Title'); + }); + + it('should display "none specified" for empty fields', () => { + component.projectBrief = {}; + fixture.detectChanges(); + + const noneSpecifiedElements = fixture.nativeElement.querySelectorAll('.none-specified'); + expect(noneSpecifiedElements.length).toBeGreaterThan(0); + }); + + it('should display industry chips when provided', () => { + const testBrief: ProjectBrief = { + industry: ['Health', 'Technology'] + }; + component.projectBrief = testBrief; + fixture.detectChanges(); + + const chips = fixture.nativeElement.querySelectorAll('ion-chip'); + expect(chips.length).toBe(2); + }); + + it('should display skills chips when provided', () => { + const testBrief: ProjectBrief = { + technicalSkills: ['Python', 'JavaScript'], + professionalSkills: ['Leadership', 'Communication'] + }; + component.projectBrief = testBrief; + fixture.detectChanges(); + + const chips = fixture.nativeElement.querySelectorAll('ion-chip'); + expect(chips.length).toBe(4); + }); + }); +}); diff --git a/projects/v3/src/app/components/project-brief-modal/project-brief-modal.component.ts b/projects/v3/src/app/components/project-brief-modal/project-brief-modal.component.ts new file mode 100644 index 000000000..a7c0e1907 --- /dev/null +++ b/projects/v3/src/app/components/project-brief-modal/project-brief-modal.component.ts @@ -0,0 +1,55 @@ +import { Component } from '@angular/core'; +import { ModalController } from '@ionic/angular'; + +/** + * interface for project brief data structure + */ +export interface ProjectBrief { + id?: string; + title?: string; + description?: string; + industry?: string[]; + projectType?: string; + technicalSkills?: string[]; + professionalSkills?: string[]; + deliverables?: string; +} + +/** + * modal component to display project brief details + * displays title, description, industry, project type, skills, and deliverables + * empty fields show "none specified" + */ +@Component({ + selector: 'app-project-brief-modal', + templateUrl: './project-brief-modal.component.html', + styleUrls: ['./project-brief-modal.component.scss'] +}) +export class ProjectBriefModalComponent { + projectBrief: ProjectBrief = {}; + + constructor( + private modalController: ModalController + ) {} + + /** + * dismiss the modal + */ + close(): void { + this.modalController.dismiss(); + } + + /** + * check if an array has items + */ + hasItems(arr: string[] | undefined): boolean { + return Array.isArray(arr) && arr.length > 0; + } + + /** + * check if a string value exists and is not empty + */ + hasValue(val: string | undefined): boolean { + return typeof val === 'string' && val.trim().length > 0; + } +} diff --git a/projects/v3/src/app/pages/home/home.page.html b/projects/v3/src/app/pages/home/home.page.html index 73d71d88a..464ccee4e 100644 --- a/projects/v3/src/app/pages/home/home.page.html +++ b/projects/v3/src/app/pages/home/home.page.html @@ -41,8 +41,40 @@ aria-hidden="true">
-

+
+

+
+ + + Project Brief + + + + Project-Hub + +
+
{ beforeEach(() => { sharedService.refreshJWT.and.returnValue(Promise.resolve()); storageService.get.and.returnValue({ name: 'Test Experience', cardUrl: 'test-url' }); + storageService.getFeature.and.returnValue(true); achievementService.getIsPointsConfigured.and.returnValue(true); achievementService.getEarnedPoints.and.returnValue(100); homeService.getPulseCheckStatuses.and.returnValue(of({ @@ -165,6 +166,18 @@ describe('HomePage', () => { expect(component.experience).toEqual({ name: 'Test Experience', cardUrl: 'test-url' }); }); + it('should set project hub visibility from feature toggle', async () => { + await component.updateDashboard(); + expect(storageService.getFeature).toHaveBeenCalledWith('showProjectHub'); + expect(component.showProjectHub).toBe(true); + }); + + it('should hide project hub when feature toggle is disabled', async () => { + storageService.getFeature.and.returnValue(false); + await component.updateDashboard(); + expect(component.showProjectHub).toBe(false); + }); + it('should call service methods to fetch data', async () => { await component.updateDashboard(); expect(homeService.getMilestones).toHaveBeenCalled(); diff --git a/projects/v3/src/app/pages/home/home.page.ts b/projects/v3/src/app/pages/home/home.page.ts index c11e5708a..dc5d1a76a 100644 --- a/projects/v3/src/app/pages/home/home.page.ts +++ b/projects/v3/src/app/pages/home/home.page.ts @@ -1,5 +1,6 @@ import { Component, OnInit, OnDestroy, ViewChild, AfterViewChecked, ElementRef, ChangeDetectorRef, isDevMode } from '@angular/core'; import { NavigationEnd, Router } from '@angular/router'; +import { environment } from '@v3/environments/environment'; import { TrafficLightGroupComponent } from '@v3/app/components/traffic-light-group/traffic-light-group.component'; import { Achievement, @@ -14,9 +15,10 @@ import { UtilsService } from '@v3/services/utils.service'; import { Observable, Subject, of } from 'rxjs'; import { distinctUntilChanged, filter, first, takeUntil, catchError } from 'rxjs/operators'; import { FastFeedbackService } from '@v3/app/services/fast-feedback.service'; -import { AlertController } from '@ionic/angular'; +import { AlertController, ModalController } from '@ionic/angular'; import { Activity } from '@v3/app/services/activity.service'; import { PulsecheckService } from '@v3/app/services/pulsecheck.service'; +import { ProjectBriefModalComponent, ProjectBrief } from '@v3/app/components/project-brief-modal/project-brief-modal.component'; @Component({ selector: "app-home", @@ -58,6 +60,10 @@ export class HomePage implements OnInit, OnDestroy, AfterViewChecked { @ViewChild('activities', { static: false }) activities!: ElementRef; pulseCheckSkills: PulseCheckSkill[] = []; + // project brief data from team storage + projectBrief: ProjectBrief | null = null; + showProjectHub = false; + // Expose Math to template Math = Math; @@ -73,6 +79,7 @@ export class HomePage implements OnInit, OnDestroy, AfterViewChecked { private cdr: ChangeDetectorRef, private fastFeedbackService: FastFeedbackService, private alertController: AlertController, + private modalController: ModalController, private pulsecheckService: PulsecheckService, ) { this.activityCount$ = homeService.activityCount$; @@ -218,10 +225,16 @@ export class HomePage implements OnInit, OnDestroy, AfterViewChecked { this.isExpertWithoutTeam = role === 'mentor' && !teamId; this.experience = this.storageService.get("experience"); + this.showProjectHub = this.storageService.getFeature('showProjectHub'); this.homeService.getMilestones({ forceRefresh: true }); this.achievementService.getAchievements(); this.homeService.getProjectProgress(); + const user = this.storageService.getUser(); + + // load project brief from user storage + this.projectBrief = user.projectBrief || null; + this.getIsPointsConfigured = this.achievementService.getIsPointsConfigured(); this.getEarnedPoints = this.achievementService.getEarnedPoints(); @@ -407,6 +420,40 @@ export class HomePage implements OnInit, OnDestroy, AfterViewChecked { await alert.present(); } + /** + * @name showProjectBrief + * @description opens modal to display project brief details + */ + async showProjectBrief(): Promise { + if (!this.projectBrief) { + return; + } + + const cssClass = this.isMobile + ? ['project-brief-modal', 'modal-fullscreen'] + : 'project-brief-modal'; + + const modal = await this.modalController.create({ + component: ProjectBriefModalComponent, + componentProps: { + projectBrief: this.projectBrief + }, + cssClass + }); + + await modal.present(); + } + + /** + * @name openProjectBriefExternal + * @description opens project brief in external projecthub application with authentication token + */ + openProjectBriefExternal(): void { + const apikey = this.storageService.getUser().apikey; + const url = `${environment.projecthub}login?token=${apikey}`; + window.open(url, '_blank'); + } + achievePopup(achievement: Achievement, keyboardEvent?: KeyboardEvent): void { if ( keyboardEvent && diff --git a/projects/v3/src/app/services/assessment.service.spec.ts b/projects/v3/src/app/services/assessment.service.spec.ts index e43b5ece2..564ae7cba 100644 --- a/projects/v3/src/app/services/assessment.service.spec.ts +++ b/projects/v3/src/app/services/assessment.service.spec.ts @@ -695,7 +695,15 @@ describe('AssessmentService', () => { submitter: { name: 'John Doe', image: 'profile.jpg', - team: { name: 'Team Alpha' } + team: { + id: 10, + name: 'Team Alpha', + projectBrief: JSON.stringify({ + id: 'brief-1', + title: 'Team Alpha Brief', + description: 'Brief description', + }), + } }, answers: [ { @@ -799,6 +807,11 @@ describe('AssessmentService', () => { expect(result.review.id).toBe(201); expect(result.review.status).toBe('done'); expect(result.review.teamName).toBe('Team Alpha'); + expect(result.review.projectBrief).toEqual({ + id: 'brief-1', + title: 'Team Alpha Brief', + description: 'Brief description', + }); // Verify review answers normalization expect(result.review.answers[1].answer).toBeNull(); diff --git a/projects/v3/src/app/services/assessment.service.ts b/projects/v3/src/app/services/assessment.service.ts index a3f67c553..ae14b23b1 100644 --- a/projects/v3/src/app/services/assessment.service.ts +++ b/projects/v3/src/app/services/assessment.service.ts @@ -12,6 +12,7 @@ import { FastFeedbackService } from './fast-feedback.service'; import { RequestService } from 'request'; import { FileInput, FileResponse } from '../components/types/assessment'; import { Choice, Question } from '@v3/components/types/assessment'; +import { ProjectBrief } from '@v3/app/components/project-brief-modal/project-brief-modal.component'; /** * @name api @@ -89,6 +90,7 @@ export interface AssessmentReview { status: string; modified: string; teamName?: string; + projectBrief?: ProjectBrief; } @Injectable({ @@ -161,7 +163,7 @@ export class AssessmentService { submitter { name image team { - name + id name projectBrief } } answers { @@ -437,6 +439,7 @@ export class AssessmentService { status: firstSubmissionReview.status, modified: firstSubmissionReview.modified, teamName: firstSubmission.submitter.team?.name, + projectBrief: this._parseProjectBrief(firstSubmission.submitter.team?.projectBrief), answers: {}, }; @@ -459,6 +462,27 @@ export class AssessmentService { return review; } + /** + * parse project brief from raw string or object + */ + private _parseProjectBrief(brief: string | object | null): ProjectBrief | null { + if (!brief) { + return null; + } + if (typeof brief === 'object') { + return brief as ProjectBrief; + } + if (typeof brief === 'string') { + try { + return JSON.parse(brief); + } catch (e) { + console.error('failed to parse project brief:', e); + return null; + } + } + return null; + } + /** * For each question that has choice (oneof & multiple), show the choice explanation in the submission if it is not empty */ diff --git a/projects/v3/src/app/services/auth.service.ts b/projects/v3/src/app/services/auth.service.ts index 874326ae9..99cd7f24c 100644 --- a/projects/v3/src/app/services/auth.service.ts +++ b/projects/v3/src/app/services/auth.service.ts @@ -118,6 +118,7 @@ interface AuthEndpointExperience { }; featureToggle: { pulseCheckIndicator: boolean; + showProjectHub: boolean; }; } @@ -246,6 +247,7 @@ export class AuthService { } featureToggle { pulseCheckIndicator + showProjectHub } } email diff --git a/projects/v3/src/app/services/demo.service.ts b/projects/v3/src/app/services/demo.service.ts index 472e290d9..2b7dc4f76 100644 --- a/projects/v3/src/app/services/demo.service.ts +++ b/projects/v3/src/app/services/demo.service.ts @@ -1714,6 +1714,7 @@ export class DemoService { "progress": 0, "featureToggle": { "pulseCheckIndicator": true, + "showProjectHub": true, }, "projectId": 1, }; diff --git a/projects/v3/src/app/services/experience.service.ts b/projects/v3/src/app/services/experience.service.ts index a3d7dbeda..5b36e02d8 100644 --- a/projects/v3/src/app/services/experience.service.ts +++ b/projects/v3/src/app/services/experience.service.ts @@ -90,6 +90,7 @@ export interface Experience { truncateDescription: boolean; featureToggle: { pulseCheckIndicator: boolean; + showProjectHub: boolean; }; progress: number; config: { @@ -191,6 +192,7 @@ export class ExperienceService { truncateDescription featureToggle { pulseCheckIndicator + showProjectHub } } }` diff --git a/projects/v3/src/app/services/notifications.service.ts b/projects/v3/src/app/services/notifications.service.ts index 5d3334bff..d7d181fce 100644 --- a/projects/v3/src/app/services/notifications.service.ts +++ b/projects/v3/src/app/services/notifications.service.ts @@ -467,7 +467,12 @@ export class NotificationsService { modalOnly: false, } ): Promise { + const cssClass = this.utils.isMobile() + ? 'modal-fullscreen' + : ''; + const modalConfig = { + cssClass, backdropDismiss: options?.closable === true, showBackdrop: false, ...options diff --git a/projects/v3/src/app/services/shared.service.ts b/projects/v3/src/app/services/shared.service.ts index 3e1256f55..e7d0245f4 100644 --- a/projects/v3/src/app/services/shared.service.ts +++ b/projects/v3/src/app/services/shared.service.ts @@ -8,10 +8,25 @@ import { BehaviorSubject, Observable, of, first, firstValueFrom } from 'rxjs'; import { TopicService } from '@v3/services/topic.service'; import { ApolloService } from '@v3/services/apollo.service'; import { PusherService } from '@v3/services/pusher.service'; -import { map } from 'rxjs/operators'; +import { map, switchMap } from 'rxjs/operators'; import { AchievementService } from './achievement.service'; import { environment } from '../../environments/environment'; +interface Team { + id: number; + name: string; + uuid: string; + projectBrief: string; +} + +interface UserTeamsResponse { + data: { + user: { + teams: Team[]; + }; + }; +} + @Injectable({ providedIn: 'root' }) @@ -84,64 +99,69 @@ export class SharedService { * @description pull team information which belongs to current user * (determined by header data in the api request) * - * @return {Observable} non-strict return value, we won't use - * this return value anywhere. + * @return {Observable} graphql response containing user teams data */ - getTeamInfo(): Observable { + getTeamInfo(): Observable { return this.apolloService.graphQLFetch( `query user { user { teams { id name + uuid + projectBrief } } }` - ).pipe(map(async response => { - if (response?.data?.user) { - const thisUser = response.data.user; - const newTeamId = thisUser.teams.length > 0 ? thisUser.teams[0].id : null; - - // get latest JWT if teamId changed - if (this.storage.getUser().teamId !== newTeamId) { - await this.refreshJWT(); - } + ).pipe( + switchMap(async (response: UserTeamsResponse) => { + if (response?.data?.user) { + const thisUser = response.data.user; + const teams: Team[] = thisUser.teams || []; + const newTeamId: number | null = teams.length > 0 ? teams[0].id : null; + const currentTeamId: number = this.storage.getUser().teamId; - if (!this.utils.has(thisUser, 'teams') || - !Array.isArray(thisUser.teams) || - !this.utils.has(thisUser.teams[0], 'id') - ) { - this.storage.setUser({ - teamId: null - }); - } + // get latest jwt if teamid changed + if (currentTeamId !== newTeamId) { + await this.refreshJWT(); + } - if (thisUser.teams.length > 0) { - this.storage.setUser({ - teamId: thisUser.teams[0].id, - teamName: thisUser.teams[0].name - }); + // update storage with team information + if (teams.length > 0) { + this.storage.setUser({ + teamId: teams[0].id, + teamName: teams[0].name, + projectBrief: this.parseProjectBrief(teams[0].projectBrief), + teamUuid: teams[0].uuid + }); + } else { + this.storage.setUser({ + teamId: null + }); + } } - } - return response; - })); + return response; + }) + ); } /** * This method get all iframe and videos from documents and stop playing videos. */ stopPlayingVideos() { - const iframes = Array.from(document.querySelectorAll('iframe')); - const videos = Array.from(document.querySelectorAll('video')); - if (iframes) { - iframes.forEach(frame => { - frame.src = null; - }); - } - if (videos) { - videos.forEach(video => { - video.pause(); - }); + if (typeof window !== 'undefined' && typeof document !== 'undefined') { + const iframes = Array.from(document.querySelectorAll('iframe')); + const videos = Array.from(document.querySelectorAll('video')); + if (iframes) { + iframes.forEach(frame => { + frame.src = null; + }); + } + if (videos) { + videos.forEach(video => { + video.pause(); + }); + } } } @@ -198,6 +218,37 @@ export class SharedService { this.utils.checkIsPracteraSupportEmail(); } + /** + * @name parseProjectBrief + * @description safely parse project brief into object + * handles both stringified json and already-parsed objects from api + * + * @param {string|object} brief project brief from api (string or object) + * @return {object|null} parsed project brief object or null if invalid + */ + private parseProjectBrief(brief: string | object): object | null { + if (!brief) { + return null; + } + + // if already an object, return as-is + if (typeof brief === 'object') { + return brief; + } + + // if string, try to parse as json + if (typeof brief === 'string') { + try { + return JSON.parse(brief); + } catch (e) { + console.error('failed to parse project brief:', e); + return null; + } + } + + return null; + } + /** * @name refreshJWT * @description refresh JWT token, update teamId in storage, broadcast teamId diff --git a/projects/v3/src/app/services/storage.service.ts b/projects/v3/src/app/services/storage.service.ts index 6141143eb..48074123b 100644 --- a/projects/v3/src/app/services/storage.service.ts +++ b/projects/v3/src/app/services/storage.service.ts @@ -55,6 +55,20 @@ export interface User { // error handling saveAssessmentErrors?: [], + + // project brief - parsed json object containing team project details + projectBrief?: { + id?: string; + title?: string; + description?: string; + industry?: string[]; + projectType?: string; + technicalSkills?: string[]; + professionalSkills?: string[]; + deliverables?: string; + timeline?: number; + }; + teamUuid?: string; } export interface Referrer { @@ -140,10 +154,10 @@ export class BrowserStorageService { /** * Retrieves the status of a specified feature toggle. (controlled by the backend) * - * @param name - The name of the feature toggle to check. Currently supports 'pulseCheckIndicator'. + * @param name - The name of the feature toggle to check. Currently supports 'pulseCheckIndicator' and 'showProjectHub'. * @returns A boolean indicating whether the specified feature toggle is enabled. */ - getFeature(name: 'pulseCheckIndicator'): boolean { + getFeature(name: 'pulseCheckIndicator' | 'showProjectHub'): boolean { return this.get('experience')?.featureToggle?.[name] || false; } diff --git a/projects/v3/src/environments/environment.custom.ts b/projects/v3/src/environments/environment.custom.ts index f62a43158..cb62cd1bb 100644 --- a/projects/v3/src/environments/environment.custom.ts +++ b/projects/v3/src/environments/environment.custom.ts @@ -58,8 +58,13 @@ export const environment = { intercom: false, newrelic: '', goMobile: false, + projecthub: '', helpline: '', featureToggles: { assessmentPagination: , }, + snowAnimation: { + enabled: false, + snowflakeCount: 30, + }, }; diff --git a/projects/v3/src/environments/environment.local.ts b/projects/v3/src/environments/environment.local.ts index 6d1fdbcad..52373fa57 100644 --- a/projects/v3/src/environments/environment.local.ts +++ b/projects/v3/src/environments/environment.local.ts @@ -61,6 +61,7 @@ export const environment = { intercom: false, newrelic: false, goMobile: false, + projecthub: 'http://localhost:3000/', helpline: 'help@practera.com', featureToggles: { assessmentPagination: true, diff --git a/projects/v3/src/styles.scss b/projects/v3/src/styles.scss index 46f956027..67c85d214 100644 --- a/projects/v3/src/styles.scss +++ b/projects/v3/src/styles.scss @@ -249,11 +249,11 @@ ion-chip.label { text-decoration: none; z-index: 10000; border-radius: 0 0 4px 0; - + // Calculate darker shade if primary color is too light // Falls back to a WCAG-compliant dark green (#2a6d3f = white:dark green = ~7.5:1) background-color: color-mix(in srgb, var(--ion-color-primary) 60%, #1a4d2a); - + // Fallback for browsers without color-mix support @supports not (color-mix(in srgb, white, black)) { background-color: var(--ion-color-primary-shade, #2a6d3f); @@ -515,6 +515,25 @@ quill-editor .ql-toolbar.ql-snow { --height: 500px; } +// project brief modal +.project-brief-modal { + --width: 90%; + --max-width: 700px; + --height: 85vh; + --max-height: 800px; + + @media (min-width: 768px) { + --width: 700px; + } +} + +// fullscreen modal for mobile (used by project brief, fast feedback, etc) +.modal-fullscreen { + --width: 100%; + --height: 100%; + --border-radius: 0; +} + // Alert styles .wide-alert { .alert-wrapper {