From 9ab42652fb0a935b17e20b696f25ddb4fbec2b84 Mon Sep 17 00:00:00 2001 From: trtshen Date: Wed, 25 Mar 2026 14:48:43 +0800 Subject: [PATCH 1/4] [CORE-8147] notification service circular dependency --- projects/v3/src/app/services/fast-feedback.service.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/projects/v3/src/app/services/fast-feedback.service.ts b/projects/v3/src/app/services/fast-feedback.service.ts index 068b4e056..44ccbf09f 100644 --- a/projects/v3/src/app/services/fast-feedback.service.ts +++ b/projects/v3/src/app/services/fast-feedback.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable, forwardRef } from '@angular/core'; +import { Injectable, Injector } from '@angular/core'; import { NotificationsService } from './notifications.service'; import { BrowserStorageService } from '@v3/services/storage.service'; import { UtilsService } from '@v3/services/utils.service'; @@ -17,9 +17,14 @@ export class FastFeedbackService { private currentPulseCheckId: string = null; // temporary store active pulse check ID + // lazily resolved to break circular dependency with NotificationsService + private _notificationsService: NotificationsService; + private get notificationsService(): NotificationsService { + return (this._notificationsService ??= this.injector.get(NotificationsService)); + } + constructor( - // type is 'any' to prevent design:paramtypes metadata from accessing NotificationsService during module evaluation (circular dependency) - @Inject(forwardRef(() => NotificationsService)) private notificationsService: any, + private injector: Injector, private storage: BrowserStorageService, private utils: UtilsService, private demo: DemoService, From b276960d4f5d7d392de53819b3048c62e7fc20c9 Mon Sep 17 00:00:00 2001 From: trtshen Date: Mon, 30 Mar 2026 14:10:42 +0800 Subject: [PATCH 2/4] [CORE-8147] deprecated filestack --- .../angular-18-to-19-security-fixes.md | 35 ++ lambda/README.md | 74 +++- lambda/forwarder/index.js | 15 +- lambda/forwarder/template.yml | 4 +- lambda/versioner/template.yml | 4 +- package.json | 1 - .../activity/activity.component.scss | 4 +- .../src/app/components/components.module.ts | 9 +- .../file-display.component.spec.ts | 11 - .../file-display/file-display.component.ts | 6 +- .../file-preview.component.html} | 0 .../file-preview.component.scss} | 4 +- .../file-preview.component.spec.ts} | 12 +- .../file-preview.component.ts} | 8 +- .../filestack/filestack.component.html | 94 ----- .../filestack/filestack.component.scss | 68 ---- .../filestack/filestack.component.spec.ts | 75 ---- .../filestack/filestack.component.ts | 161 -------- .../support-popup.component.html | 4 +- .../support-popup.component.spec.ts | 53 ++- .../support-popup/support-popup.component.ts | 41 +-- .../todo-card/todo-card.component.scss | 4 +- .../components/topic/topic.component.spec.ts | 12 +- .../app/components/topic/topic.component.ts | 8 +- .../uppy-uploader/uppy-uploader.service.ts | 2 +- .../video-conversion.component.html | 4 +- .../video-conversion.component.spec.ts | 53 +-- .../video-conversion.component.ts | 46 +-- .../attachment-popover.component.html | 4 - .../attachment-popover.component.spec.ts | 6 +- .../attachment-popover.component.ts | 36 +- .../chat/chat-list/chat-list.component.scss | 4 +- .../chat/chat-room/chat-room.component.html | 18 +- .../chat-room/chat-room.component.spec.ts | 7 - .../chat/chat-room/chat-room.component.ts | 18 +- .../event-list/event-list.component.scss | 4 +- projects/v3/src/app/pages/home/home.page.scss | 4 +- .../src/app/services/file-preview.service.ts | 77 ++++ .../app/services/filestack.service.spec.ts | 348 ------------------ .../v3/src/app/services/filestack.service.ts | 297 --------------- .../v3/src/environments/environment.custom.ts | 23 -- .../src/environments/environment.interface.ts | 21 -- .../v3/src/environments/environment.local.ts | 23 -- 43 files changed, 317 insertions(+), 1385 deletions(-) rename projects/v3/src/app/components/{filestack-preview/filestack-preview.component.html => file-preview/file-preview.component.html} (100%) rename projects/v3/src/app/components/{filestack-preview/filestack-preview.component.scss => file-preview/file-preview.component.scss} (58%) rename projects/v3/src/app/components/{filestack-preview/filestack-preview.component.spec.ts => file-preview/file-preview.component.spec.ts} (86%) rename projects/v3/src/app/components/{filestack-preview/filestack-preview.component.ts => file-preview/file-preview.component.ts} (71%) delete mode 100644 projects/v3/src/app/components/filestack/filestack.component.html delete mode 100644 projects/v3/src/app/components/filestack/filestack.component.scss delete mode 100644 projects/v3/src/app/components/filestack/filestack.component.spec.ts delete mode 100644 projects/v3/src/app/components/filestack/filestack.component.ts create mode 100644 projects/v3/src/app/services/file-preview.service.ts delete mode 100644 projects/v3/src/app/services/filestack.service.spec.ts delete mode 100644 projects/v3/src/app/services/filestack.service.ts diff --git a/docs/upgrades/angular-18-to-19-security-fixes.md b/docs/upgrades/angular-18-to-19-security-fixes.md index ff259319f..7ffe9a07a 100644 --- a/docs/upgrades/angular-18-to-19-security-fixes.md +++ b/docs/upgrades/angular-18-to-19-security-fixes.md @@ -341,6 +341,37 @@ review and update as needed: - `Apollo.use()`, `Apollo.watchQuery()`, `Apollo.mutate()`, `Apollo.query()` signatures - check for changes in `ApolloModule.forRoot()` vs standalone provider pattern +### 5.5. Lambda@Edge forwarder — S3 path prefix (COMPLETED March 2026) + +**Root cause:** The Angular 19 `application` builder (`@angular-devkit/build-angular ^19`) changed the build output structure by introducing a `browser/` subdirectory: + +| Builder | Angular version | Output path | S3 key prefix | +|---------|-----------------|-------------|---------------| +| `browser` (webpack) | 17 / 18 | `dist/v3/{locale}/` | `/{locale}/` | +| `application` (esbuild) | **19+** | `dist/v3/browser/{locale}/` | `/browser/{locale}/` | + +This mismatch caused `lambda/forwarder/index.js` to rewrite all CloudFront URIs to non-existent S3 keys (e.g. `/en-US/index.html` instead of `/browser/en-US/index.html`). S3 returned `403 AccessDenied` — surfaced as raw XML to every user loading the app. + +**Fix applied to `lambda/forwarder/index.js`:** + +```javascript +// before (Angular 17/18) +request.uri = "/en-US/index.html"; // fallback +request.uri = `/${locale}/index.html`; // spa route +// (no static-asset rewrite needed — assets were at root) + +// after (Angular 19+) +request.uri = "/browser/en-US/index.html"; // fallback +request.uri = `/browser/${locale}/index.html`; // spa route +} else if (!request.uri.startsWith('/browser/')) { + request.uri = `/browser${request.uri}`; // static assets +} +``` + +**Validation:** Confirmed working on `p2-stage` after deployment (March 2026). + +**Future Angular upgrades:** check `ls dist/v3/` after building to verify the output structure before merging. If thenstructure changes again, update `forwarder/index.js` in the same PR. See `lambda/README.md` for the full dependency documentation. + --- ## Phase 6 — Testing & Validation @@ -446,6 +477,7 @@ npm audit > ./output/angular-19-upgrade-audit.log 2>&1 | TypeScript 5.6 stricter checks | incremental fix of any new type errors | | ngx-quill v27 regressions | minimal risk — usually just peer dep bump | | CI build failures | run full CI pipeline before merging | +| **Lambda@Edge S3 path mismatch** (**occurred** March 2026) | Angular 19 `application` builder adds `browser/` subdirectory; `lambda/forwarder/index.js` must be updated in the same PR — see §5.5 | **Rollback plan:** revert to the pre-upgrade commit on `angular-eos-upgrades-prerelease` branch. all changes are confined to `package.json`, `package-lock.json`, and targeted source fixes. @@ -480,6 +512,7 @@ npm audit > ./output/angular-19-upgrade-audit.log 2>&1 - [ ] update uppy service/component if API changed - [ ] update apollo-angular usage if API changed - [ ] fix any Angular 19 deprecation warnings + - [x] update `lambda/forwarder/index.js` for `browser/` path prefix *(completed March 2026)* - [ ] **Phase 6:** Validation - [ ] `npm run prebuildv3` succeeds - [ ] `ng build v3` succeeds @@ -487,3 +520,5 @@ npm audit > ./output/angular-19-upgrade-audit.log 2>&1 - [ ] `npm run lint` passes - [ ] `npm audit` shows reduced vulnerability count - [ ] manual testing on staging + - [ ] verify app loads at `https://app..practera.com/en-US` without S3 AccessDenied + - [ ] verify deep-links with query params (magic-link login) resolve to correct locale diff --git a/lambda/README.md b/lambda/README.md index ede09877e..f6debddf5 100644 --- a/lambda/README.md +++ b/lambda/README.md @@ -1,10 +1,76 @@ ### Description -This directory will hold `lambda@edge` functions. +This directory holds `lambda@edge` functions deployed to CloudFront as `origin-request` handlers. -`forwarder` - lambda function that sits infront of the CDN, handles `globalization` redirection. -`versioner` - function to create lambda function version. +`forwarder` - rewrites incoming CloudFront URIs to the correct S3 object keys for the Angular i18n locale builds. +`versioner` - creates a numbered Lambda version ARN that CloudFront requires for Lambda@Edge associations. + +--- + +### i18n / Angular Build Output Dependency + +The `forwarder` function is **tightly coupled** to the Angular build output directory structure. Breaking this coupling causes S3 `AccessDenied` (403) errors surfaced as raw XML to end users. + +#### How Angular i18n builds are deployed + +`projects/v3` is compiled with `"localize": true` in `angular.json`, producing one subfolder per locale: + +| Angular version | Build output path | S3 object key prefix | +|-----------------|-------------------|----------------------| +| 17 / 18 (webpack builder) | `dist/v3/{locale}/` | `/{locale}/` | +| **19+ (application builder)** | `dist/v3/browser/{locale}/` | `/browser/{locale}/` | + +The `aws s3 sync dist/v3/ s3://$BUCKET --delete` step (CI/CD step 22) mirrors this structure directly into S3, so the S3 key prefix always matches the build output. + +#### What the forwarder does + +CloudFront does **not** forward query strings to S3 (`QueryString: false`). When a user visits a deep-link such as `/en-US?auth_token=…`, CloudFront calls the forwarder with `request.uri = "/en-US"`. The forwarder: + +1. Extracts the first path segment as the locale (`en-US`, `ja`, `ms`, `es`). +2. For unknown locales or bare `/`, falls back to the default locale. +3. For SPA routes (no file extension), rewrites to `/{prefix}/{locale}/index.html`. +4. For static assets (with file extension), rewrites to `/{prefix}{original_uri}`. +5. Passes `version.json` requests through unchanged (whitelist). + +#### Supported locales + +The locale whitelist in `forwarder/index.js` must stay in sync with the locales configured in `angular.json`: + +```javascript +const locales = ["en-US", "ja", "ms", "es"]; +``` + +To add a new locale: update both `angular.json` (`i18n.locales`) **and** the `locales` array in `forwarder/index.js`. + +#### ⚠️ What to check on every Angular major version upgrade + +When upgrading Angular major versions, verify the build output structure has not changed before deploying: + +```bash +# after building, check what subdirectories are produced +ls dist/v3/ +# Angular 19+: should show browser/ +# Angular 17/18: should show en-US/ ja/ ms/ es/ +``` + +If the output structure changes, update the path prefix in `forwarder/index.js` **before** merging to trunk so both changes deploy together in the same CI/CD run. + +#### Incident history + +| Date | Trigger | Symptom | Fix | +|------|---------|---------|-----| +| March 2026 | Angular 18 → 19 upgrade | S3 `AccessDenied` XML shown to all users on `app.p2-stage.practera.com` | Added `/browser/` prefix to all rewritten URIs in `forwarder/index.js` | + +--- ### Deployment -Once `AWS` credentials is ready, just run `deploy.sh`. Make sure you installed [sam](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html) on your machine. \ No newline at end of file +Once `AWS` credentials are ready, run `deploy.sh`. Requires [AWS SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html). + +In CI/CD (`p2-stage-appv3.yml`) the deploy sequence is: +1. Build Angular app (`ng build v3`) +2. Deploy Lambda@Edge (`bash lambda/deploy.sh`) → exports `HandlerVersionArn` +3. Deploy CloudFormation/Serverless stack (picks up new `HandlerVersionArn`) +4. Sync `dist/v3/` to S3 + +Both the Lambda function and the S3 content must reflect the same output path structure. \ No newline at end of file diff --git a/lambda/forwarder/index.js b/lambda/forwarder/index.js index d279a2eee..1bc60c914 100644 --- a/lambda/forwarder/index.js +++ b/lambda/forwarder/index.js @@ -1,6 +1,6 @@ const path = require('path'); -exports.handler = async (evt, context, cb) => { +exports.handler = async (evt) => { const { request } = evt.Records[0].cf; console.log(`Original Uri: ${request.uri}`); @@ -11,26 +11,27 @@ exports.handler = async (evt, context, cb) => { const locales = ["en-US", "ja", "ms", "es"]; const lastPartUrl = uriParts[uriParts.length - 1]; - // whitelisted version.json request + // whitelisted version.json request — note: query strings are in request.querystring, + // not in request.uri, so only the path filename is tested here console.log("trailingURL::", lastPartUrl); - if (lastPartUrl.match(/^version\.json(?:\?t=\d+)?$/) !== null) { - return cb(null, request); + if (lastPartUrl.match(/^version\.json$/) !== null) { + return request; } if (locale === "" || locale === "index.html" || !locales.includes(locale)) { request.uri = "/browser/en-US/index.html"; console.log("Go to default page and locale."); - return cb(null, request); + return request; } const fileExt = path.extname(lastPartUrl); if (!fileExt) { request.uri = `/browser/${locale}/index.html`; } else if (!request.uri.startsWith('/browser/')) { - // rewrite static asset paths to match Angular 17+ application builder output + // rewrite static asset paths to match Angular 19+ application builder output request.uri = `/browser${request.uri}`; } console.log(`New Uri: ${request.uri}`); - return cb(null, request); + return request; }; diff --git a/lambda/forwarder/template.yml b/lambda/forwarder/template.yml index 27d047891..f4946fa60 100644 --- a/lambda/forwarder/template.yml +++ b/lambda/forwarder/template.yml @@ -37,7 +37,7 @@ Resources: Properties: Handler: index.handler CodeUri: ./bin/handler.zip - Runtime: nodejs18.x + Runtime: nodejs22.x Timeout: 10 Role: !GetAtt HandlerRole.Arn HandlerVersion: @@ -53,4 +53,4 @@ Outputs: Description: "Arn Version for Lambda function to associate unto CDN" Value: !GetAtt HandlerVersion.FunctionArn Export: - Name: !Sub "${StackName}-HandlerVersion-${Env}" \ No newline at end of file + Name: !Sub "${StackName}-HandlerVersion-${Env}" diff --git a/lambda/versioner/template.yml b/lambda/versioner/template.yml index cd5bc793e..fc33233b6 100644 --- a/lambda/versioner/template.yml +++ b/lambda/versioner/template.yml @@ -19,7 +19,7 @@ Resources: Properties: Handler: index.handler CodeUri: ./bin/handler.zip - Runtime: nodejs18.x + Runtime: nodejs22.x Timeout: 10 Role: !GetAtt LambdaVersionHelperRole.Arn LambdaVersionHelperRole: @@ -51,4 +51,4 @@ Outputs: Description: "Lambda Function Versioner ARN" Value: !GetAtt Versioner.Arn Export: - Name: !Sub "${StackName}-LambdaVersionerArn-${Env}" \ No newline at end of file + Name: !Sub "${StackName}-LambdaVersionerArn-${Env}" diff --git a/package.json b/package.json index 2117ebc0a..35bc57d9b 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,6 @@ "core-js": "^3.21.1", "dayjs": "^1.11.10", "exif-js": "^2.3.0", - "filestack-js": "^3.30.0", "franc-min": "^6.2.0", "graphql": "^16.8.1", "ics": "^3.7.2", diff --git a/projects/v3/src/app/components/activity/activity.component.scss b/projects/v3/src/app/components/activity/activity.component.scss index e78b2ea36..b3514919a 100644 --- a/projects/v3/src/app/components/activity/activity.component.scss +++ b/projects/v3/src/app/components/activity/activity.component.scss @@ -45,6 +45,6 @@ } .focusable:focus { - border: 1px solid var(--ion-color-primary); - display: flex; + outline: 2px solid var(--ion-color-primary); + outline-offset: -2px; } diff --git a/projects/v3/src/app/components/components.module.ts b/projects/v3/src/app/components/components.module.ts index 884ff1ffe..713e6b8fe 100644 --- a/projects/v3/src/app/components/components.module.ts +++ b/projects/v3/src/app/components/components.module.ts @@ -18,8 +18,7 @@ import { FastFeedbackComponent } from './fast-feedback/fast-feedback.component'; import { ReviewRatingComponent } from './review-rating/review-rating.component'; import { CircleProgressComponent } from './circle-progress/circle-progress.component'; import { NgCircleProgressModule } from 'ng-circle-progress'; -import { FilestackComponent } from './filestack/filestack.component'; -import { FilestackPreviewComponent } from './filestack-preview/filestack-preview.component'; +import { FilePreviewComponent } from './file-preview/file-preview.component'; import { ContactNumberFormComponent } from './contact-number-form/contact-number-form.component'; import { ClickableItemComponent } from './clickable-item/clickable-item.component'; import { AssessmentComponent } from './assessment/assessment.component'; @@ -91,8 +90,7 @@ const largeCircleDefaultConfig = { FilePopupComponent, FileDisplayComponent, VideoConversionComponent, - FilestackComponent, - FilestackPreviewComponent, + FilePreviewComponent, ImgComponent, ListItemComponent, LockTeamAssessmentPopUpComponent, @@ -138,8 +136,7 @@ const largeCircleDefaultConfig = { FilePopupComponent, FileDisplayComponent, VideoConversionComponent, - FilestackComponent, - FilestackPreviewComponent, + FilePreviewComponent, ImgComponent, IonicModule, ListItemComponent, diff --git a/projects/v3/src/app/components/file-display/file-display.component.spec.ts b/projects/v3/src/app/components/file-display/file-display.component.spec.ts index 611fd6ebb..6b41b7e9d 100644 --- a/projects/v3/src/app/components/file-display/file-display.component.spec.ts +++ b/projects/v3/src/app/components/file-display/file-display.component.spec.ts @@ -2,7 +2,6 @@ import { CUSTOM_ELEMENTS_SCHEMA, SimpleChange, DebugElement } from '@angular/core'; import { ComponentFixture, TestBed, fakeAsync, flushMicrotasks, waitForAsync, tick } from '@angular/core/testing'; import { FileDisplayComponent } from './file-display.component'; -import { FilestackService } from '@v3/services/filestack.service'; import { ReactiveFormsModule, FormControl } from '@angular/forms'; import { UtilsService } from '@v3/services/utils.service'; import { TestUtils } from '@testingv3/utils'; @@ -19,7 +18,6 @@ class OnChangedValues extends SimpleChange { describe('FileDisplayComponent', () => { let component: FileDisplayComponent; let fixture: ComponentFixture; - let filestackSpy: jasmine.SpyObj; let utilsSpy: jasmine.SpyObj; beforeEach(waitForAsync(() => { @@ -32,14 +30,6 @@ describe('FileDisplayComponent', () => { provide: UtilsService, useClass: TestUtils, }, - { - provide: FilestackService, - useValue: jasmine.createSpyObj('FilestackService', [ - 'previewFile', - 'getWorkflowStatus', - 'metadata' - ]) - }, { provide: ModalController, useValue: jasmine.createSpyObj('ModalController', { @@ -55,7 +45,6 @@ describe('FileDisplayComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(FileDisplayComponent); component = fixture.debugElement.componentInstance; - filestackSpy = TestBed.inject(FilestackService) as jasmine.SpyObj; utilsSpy = TestBed.inject(UtilsService) as jasmine.SpyObj; }); diff --git a/projects/v3/src/app/components/file-display/file-display.component.ts b/projects/v3/src/app/components/file-display/file-display.component.ts index 79f9a9155..4d3a91372 100644 --- a/projects/v3/src/app/components/file-display/file-display.component.ts +++ b/projects/v3/src/app/components/file-display/file-display.component.ts @@ -11,8 +11,8 @@ import { FileInput, TusFileResponse } from '../types/assessment'; import { FilePopupComponent } from '../file-popup/file-popup.component'; import { ModalController } from '@ionic/angular'; -// @TODO: make compatible with FileStack format (remove when no longer needed) -interface FileStackCompatible extends TusFileResponse { +// backward-compatible file type that includes legacy property names +interface DisplayableFile extends TusFileResponse { filename: string; mimetype: string; url: string; @@ -26,7 +26,7 @@ interface FileStackCompatible extends TusFileResponse { }) export class FileDisplayComponent { @Input() fileType: string = 'any'; - @Input() file?: FileStackCompatible; + @Input() file?: DisplayableFile; @Input() isFileComponent: boolean = false; // flag parent component is FileComponent @ViewChild('videoEle') videoEle?: ElementRef = new ElementRef(null); @Output() removeFile: EventEmitter = new EventEmitter(); diff --git a/projects/v3/src/app/components/filestack-preview/filestack-preview.component.html b/projects/v3/src/app/components/file-preview/file-preview.component.html similarity index 100% rename from projects/v3/src/app/components/filestack-preview/filestack-preview.component.html rename to projects/v3/src/app/components/file-preview/file-preview.component.html diff --git a/projects/v3/src/app/components/filestack-preview/filestack-preview.component.scss b/projects/v3/src/app/components/file-preview/file-preview.component.scss similarity index 58% rename from projects/v3/src/app/components/filestack-preview/filestack-preview.component.scss rename to projects/v3/src/app/components/file-preview/file-preview.component.scss index 8989bbe97..7d784e672 100644 --- a/projects/v3/src/app/components/filestack-preview/filestack-preview.component.scss +++ b/projects/v3/src/app/components/file-preview/file-preview.component.scss @@ -7,6 +7,6 @@ ion-header { } .focusable:focus { - border: 1px solid var(--ion-color-primary); - display: flex; + outline: 2px solid var(--ion-color-primary); + outline-offset: -2px; } diff --git a/projects/v3/src/app/components/filestack-preview/filestack-preview.component.spec.ts b/projects/v3/src/app/components/file-preview/file-preview.component.spec.ts similarity index 86% rename from projects/v3/src/app/components/filestack-preview/filestack-preview.component.spec.ts rename to projects/v3/src/app/components/file-preview/file-preview.component.spec.ts index 5fd60f4e2..11ad06e4d 100644 --- a/projects/v3/src/app/components/filestack-preview/filestack-preview.component.spec.ts +++ b/projects/v3/src/app/components/file-preview/file-preview.component.spec.ts @@ -1,5 +1,5 @@ import { TestBed, ComponentFixture } from '@angular/core/testing'; -import { FilestackPreviewComponent } from './filestack-preview.component'; +import { FilePreviewComponent } from './file-preview.component'; import { IonicModule, ModalController } from '@ionic/angular'; import { DomSanitizer } from '@angular/platform-browser'; import { CommonModule } from '@angular/common'; @@ -8,17 +8,17 @@ import { HttpClientTestingModule } from '@angular/common/http/testing'; -describe('FilestackPreviewComponent', () => { +describe('FilePreviewComponent', () => { const TEST_URL = 'https://www.practera.com'; - let component: FilestackPreviewComponent; - let fixture: ComponentFixture; + let component: FilePreviewComponent; + let fixture: ComponentFixture; let modalSpy: ModalController; let domSanitizerSpy: DomSanitizer; beforeEach(async () => { TestBed.configureTestingModule({ imports: [ IonicModule, CommonModule, HttpClientTestingModule ], - declarations: [ FilestackPreviewComponent ], + declarations: [ FilePreviewComponent ], providers: [ ModalController, { @@ -32,7 +32,7 @@ describe('FilestackPreviewComponent', () => { ], }); - fixture = TestBed.createComponent(FilestackPreviewComponent); + fixture = TestBed.createComponent(FilePreviewComponent); component = fixture.componentInstance; modalSpy = TestBed.inject(ModalController); domSanitizerSpy = TestBed.inject(DomSanitizer); diff --git a/projects/v3/src/app/components/filestack-preview/filestack-preview.component.ts b/projects/v3/src/app/components/file-preview/file-preview.component.ts similarity index 71% rename from projects/v3/src/app/components/filestack-preview/filestack-preview.component.ts rename to projects/v3/src/app/components/file-preview/file-preview.component.ts index b4bdfd6bf..d8681a790 100644 --- a/projects/v3/src/app/components/filestack-preview/filestack-preview.component.ts +++ b/projects/v3/src/app/components/file-preview/file-preview.component.ts @@ -4,11 +4,11 @@ import { DomSanitizer } from '@angular/platform-browser'; @Component({ standalone: false, - selector: 'app-filestack-preview', - templateUrl: './filestack-preview.component.html', - styleUrls: ['filestack-preview.component.scss'] + selector: 'app-file-preview', + templateUrl: './file-preview.component.html', + styleUrls: ['file-preview.component.scss'] }) -export class FilestackPreviewComponent { +export class FilePreviewComponent { url = ''; file: any = {}; diff --git a/projects/v3/src/app/components/filestack/filestack.component.html b/projects/v3/src/app/components/filestack/filestack.component.html deleted file mode 100644 index e8f31fa86..000000000 --- a/projects/v3/src/app/components/filestack/filestack.component.html +++ /dev/null @@ -1,94 +0,0 @@ - - - - Upload Profile Image - - - - - -
- Upload file(s) - UPLOAD -
-
- -
-

-   - Drag and drop file here or - UPLOAD FILE -

-
-
- - - - - - - - - - - - - - - - - - - -

{{ uploadingFile.fileName }}

-
- - - - - -
- - - - - - - - -

{{ uploadingFile.uploadSize }} of {{ uploadingFile.fileSize }} -

-
- -

{{ uploadingFile.uploadProgress }}%

-
-
-
-
-
-
-
-
-
-
diff --git a/projects/v3/src/app/components/filestack/filestack.component.scss b/projects/v3/src/app/components/filestack/filestack.component.scss deleted file mode 100644 index 07b2270f4..000000000 --- a/projects/v3/src/app/components/filestack/filestack.component.scss +++ /dev/null @@ -1,68 +0,0 @@ - .upload-button { - max-width: 200px; - // margin: auto; - } - - .drop-zone { - height: 6em; - display: flex; - align-items: center; - justify-content: center; - text-align: center; - border: dashed 1px var(--practera-30-percent-gray); - position: relative; - margin: 0 auto; - vertical-align: -webkit-baseline-middle; - border-radius: 12px; - p { - ion-icon { - vertical-align: middle; - } - ion-button { - vertical-align: middle; - } - } - } - - .fileover { - border: 2px solid var(--ion-color-primary); - } - - .upload-progress { - --ion-grid-column-padding: 0; - .fileTypeIcon { - text-align: center; - ion-icon { - padding: 10px; - } - } - .file-size-container, .file-name-container { - text-align: left; - } - .file-name-container { - p { - margin: 0; - height: 29px; - padding-bottom: 4px; - padding-top: 4px; - } - } - .file-progress-container, .cancel-btn-container { - text-align: right; - } - .size-and-progress { - margin-top: 10px; - } - .cancel-btn { - --padding-start: 0; - --padding-end: 0; - } - } - - ion-icon { - font-size: 24px; - } - - ion-button.ion-focused { - border: 1px solid var(--ion-color-primary); - } diff --git a/projects/v3/src/app/components/filestack/filestack.component.spec.ts b/projects/v3/src/app/components/filestack/filestack.component.spec.ts deleted file mode 100644 index 449e49208..000000000 --- a/projects/v3/src/app/components/filestack/filestack.component.spec.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { CUSTOM_ELEMENTS_SCHEMA, DebugElement } from '@angular/core'; -import { IonicModule } from '@ionic/angular'; -import { TestBed, ComponentFixture, fakeAsync, flushMicrotasks } from '@angular/core/testing'; -import { FilestackComponent } from './filestack.component'; -import { FilestackService } from '@v3/services/filestack.service'; -import { UtilsService } from '@v3/app/services/utils.service'; -import { TestUtils } from '@testingv3/utils'; - -describe('FilestackComponent', () => { - let component: FilestackComponent; - let fixture: ComponentFixture; - let filestackSpy: FilestackService; - let utilsSpy: jasmine.SpyObj; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [IonicModule], - declarations: [FilestackComponent], - schemas: [CUSTOM_ELEMENTS_SCHEMA], - providers: [ - { - provide: UtilsService, - useClass: TestUtils, - }, - { - provide: FilestackService, - useValue: jasmine.createSpyObj(['open', 'getS3Config']) - } - ], - }).compileComponents(); - fixture = TestBed.createComponent(FilestackComponent); - component = fixture.debugElement.componentInstance; - filestackSpy = TestBed.inject(FilestackService); - utilsSpy = TestBed.inject(UtilsService) as jasmine.SpyObj; - }); - - it('should create the filestack component', () => { - expect(component).toBeTruthy(); - }); - - describe('uploadFile()', () => { - it('should display "Upload File" by default (and not "profileImage")', () => { - component.type = 'anything'; - fixture.detectChanges(); - const button = fixture.nativeElement.querySelector('ion-button'); - - expect(button.textContent).toEqual('UPLOAD FILE'); - }); - - it('should allow upload profile picture', () => { - utilsSpy.isMobile.and.returnValue(false); - component.type = 'profileImage'; - fixture.detectChanges(); - - const button: HTMLElement = fixture.nativeElement.querySelector('ion-button'); - - expect(button.classList).toContain('upload'); - }); - - it('should open filestack fileupload window', fakeAsync(() => { - const respond = { fileupload: true }; - let result; - - filestackSpy.open = jasmine.createSpy('open').and.returnValue(Promise.resolve(respond)); - - component.uploadFile().then(data => { - result = data; - }); - flushMicrotasks(); - - expect(filestackSpy.getS3Config).toHaveBeenCalled(); - expect(filestackSpy.open).toHaveBeenCalled(); - })); - }); -}); diff --git a/projects/v3/src/app/components/filestack/filestack.component.ts b/projects/v3/src/app/components/filestack/filestack.component.ts deleted file mode 100644 index b5f78b76b..000000000 --- a/projects/v3/src/app/components/filestack/filestack.component.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { Component, EventEmitter, Output, Input } from '@angular/core'; -import { FilestackService } from '@v3/services/filestack.service'; -import { UtilsService } from '@v3/services/utils.service'; -import { PickerOptions } from 'filestack-js/build/main/lib/picker'; - -export interface FilestackUploaded { - handle: string; - url: string; - filename: string; - size: number; - mimetype: string; - key: string; - container: string; - status: string; - workflow?: object; - - // flag to indicate detected virus (true: threat found, false: safe from virus/malware) - infected?: boolean; - - // list of infected files - infections_list?: string[]; -} - -@Component({ - standalone: false, - selector: 'app-file-stack', - templateUrl: 'filestack.component.html', - styleUrls: ['filestack.component.scss'] -}) -export class FilestackComponent { - @Input() accept: any; - @Input() fileType: string; - @Input() type?: string; - @Input() disabled: boolean; - - // upon fileupload success - @Output() uploadCompleted: EventEmitter = new EventEmitter(); - - uploadingFile = { - uploadProgress: 0, - fileName: '', - fileSize: '', - uploadSize: '' - }; - isDroped: boolean; - uploadToken: any; - - constructor( - private filestackService: FilestackService, - private readonly utils: UtilsService - ) { } - - async uploadFile(keyboardEvent?: KeyboardEvent) { - if (keyboardEvent && (keyboardEvent?.code === 'Space' || keyboardEvent?.code === 'Enter')) { - keyboardEvent.preventDefault(); - } else if (keyboardEvent) { - return; - } - - const s3Config = this.filestackService.getS3Config(this.fileType); - const pickerOptions: PickerOptions = { - storeTo: s3Config, - onFileUploadFailed: data => { - this.uploadCompleted.emit({ - success: false, - data: data - }); - }, - onFileUploadFinished: data => { - this.uploadCompleted.emit({ - success: true, - data: data - }); - }, - onOpen: () => { - setTimeout(() => { - const eles = document.getElementsByClassName('fsp-picker__close-button'); - if (eles.length > 0) { - (eles[0] as HTMLElement).focus(); - } - }, 850); - }, - }; - - if (this.accept) { - pickerOptions['accept'] = this.accept; - } - - try { - const res = await this.filestackService.open(pickerOptions); - return res; - } catch (err) { - throw new Error(err); - } - } - - async dragAndDropUpload(dropData) { - if (dropData.success) { - this.isDroped = true; - this.uploadingFile.fileName = dropData.file.name; - this.uploadingFile.fileSize = this._bytesToSize(dropData.file.size); - const s3Config = this.filestackService.getS3Config(this.fileType); - this.uploadToken = {}; - const uploadOptions = { - onProgress: progressData => { - this.uploadingFile.uploadProgress = progressData.totalPercent; - this.uploadingFile.uploadSize = this._bytesToSize(progressData.totalBytes); - }, - onFileUploadFinished: fileData => { - this.uploadCompleted.emit({ - success: true, - data: fileData - }); - }, - onFileUploadFailed: err => { - this.uploadCompleted.emit({ - success: false, - data: err - }); - } - }; - - await this.filestackService.upload(dropData.file, uploadOptions, s3Config, this.uploadToken); - } else { - this.isDroped = false; - this.uploadCompleted.emit({ - success: false, - data: { - message: dropData.message, - isDragAndDropError: true - } - }); - } - } - - cancelFileUpload() { - if (this.uploadToken) { - this.uploadToken.cancel(); - this.isDroped = false; - this.uploadingFile = { - uploadProgress: 0, - fileName: '', - fileSize: '', - uploadSize: '' - }; - } - } - - private _bytesToSize(bytes) { - if (bytes) { - const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; - const i = Math.min(parseInt(Math.floor(Math.log(bytes) / Math.log(1024)).toString(), 10), sizes.length - 1); - return `${(bytes / (1024 ** i)).toFixed(i ? 1 : 0)} ${sizes[i]}`; - } - return '0'; - } - - get isMobile(): boolean { - return this.utils.isMobile(); - } -} diff --git a/projects/v3/src/app/components/support-popup/support-popup.component.html b/projects/v3/src/app/components/support-popup/support-popup.component.html index b1c1887b0..de554cbe4 100644 --- a/projects/v3/src/app/components/support-popup/support-popup.component.html +++ b/projects/v3/src/app/components/support-popup/support-popup.component.html @@ -63,7 +63,7 @@ (click)="uploadFile()" >Choose file -

{{ selectedFile ? selectedFile?.filename : 'No file chosen' }}

+

{{ selectedFile ? selectedFile?.name : 'No file chosen' }}

diff --git a/projects/v3/src/app/components/support-popup/support-popup.component.spec.ts b/projects/v3/src/app/components/support-popup/support-popup.component.spec.ts index f97813dbd..3e029d564 100644 --- a/projects/v3/src/app/components/support-popup/support-popup.component.spec.ts +++ b/projects/v3/src/app/components/support-popup/support-popup.component.spec.ts @@ -4,7 +4,7 @@ import { of, throwError } from 'rxjs'; import { SupportPopupComponent } from './support-popup.component'; import { HubspotService } from '@v3/services/hubspot.service'; -import { FilestackService } from '@v3/app/services/filestack.service'; +import { UppyUploaderService } from '@v3/services/uppy-uploader.service'; import { UtilsService } from '@v3/services/utils.service'; import { NotificationsService } from '@v3/app/services/notifications.service'; @@ -26,7 +26,7 @@ describe('SupportPopupComponent', () => { let fixture: ComponentFixture; let modalSpy: jasmine.SpyObj; let notificationSpy: jasmine.SpyObj; - let filestackSpy: jasmine.SpyObj; + let uppyUploaderSpy: jasmine.SpyObj; let hubspotSpy: jasmine.SpyObj; beforeEach(async () => { @@ -35,7 +35,7 @@ describe('SupportPopupComponent', () => { imports: [IonicModule.forRoot()], providers: [ { provide: HubspotService, useValue: jasmine.createSpyObj('HubspotService', ['submitDataToHubspot']) }, - { provide: FilestackService, useValue: jasmine.createSpyObj('FilestackService', ['deleteFile', 'open', 'getS3Config']) }, + { provide: UppyUploaderService, useValue: jasmine.createSpyObj('UppyUploaderService', ['open']) }, { provide: UtilsService, useClass: UtilsServiceMock }, { provide: NotificationsService, useClass: NotificationsServiceMock }, { provide: ModalController, useValue: jasmine.createSpyObj('ModalController', ['dismiss', 'getTop']) }, @@ -46,7 +46,7 @@ describe('SupportPopupComponent', () => { component = fixture.componentInstance; modalSpy = TestBed.inject(ModalController) as jasmine.SpyObj; notificationSpy = TestBed.inject(NotificationsService) as jasmine.SpyObj; - filestackSpy = TestBed.inject(FilestackService) as jasmine.SpyObj; + uppyUploaderSpy = TestBed.inject(UppyUploaderService) as jasmine.SpyObj; hubspotSpy = TestBed.inject(HubspotService) as jasmine.SpyObj; fixture.detectChanges(); }); @@ -191,9 +191,7 @@ describe('SupportPopupComponent', () => { }); describe('removeSelectedFile', () => { - it('should remove the selected file and call deleteFile with the file handle', fakeAsync(() => { - filestackSpy.deleteFile = jasmine.createSpy().and.returnValue(of({})); - + it('should clear the selected file', fakeAsync(() => { component.selectedFile = { bucket: 'test-bucket', path: 'test-path', @@ -202,19 +200,17 @@ describe('SupportPopupComponent', () => { extension: 'jpg', type: 'image/jpeg', size: 1000, - handle: 'abc123' }; component.removeSelectedFile(); flushMicrotasks(); - expect(filestackSpy.deleteFile).toHaveBeenCalledWith('abc123'); expect(component.selectedFile).toBeUndefined(); })); }); describe('uploadFile', () => { - it('should call FilestackService open method and set the selectedFile on upload finished', fakeAsync(() => { - const mockResponse = { + it('should open uppy uploader and set selectedFile on dismiss with data', fakeAsync(() => { + const mockFile = { bucket: 'test-bucket', path: 'test-path', name: 'test.jpg', @@ -222,53 +218,54 @@ describe('SupportPopupComponent', () => { extension: 'jpg', type: 'image/jpeg', size: 1000, - handle: 'abc123', - filename: 'test.jpg' }; - filestackSpy.open = jasmine.createSpy().and.callFake(options => { - return options.onFileUploadFinished(mockResponse); - }); + uppyUploaderSpy.open.and.returnValue(Promise.resolve({ + onDidDismiss: () => Promise.resolve({ data: mockFile, role: 'confirm' }), + } as any)); component.uploadFile(); flushMicrotasks(); - expect(filestackSpy.open).toHaveBeenCalled(); - expect(component.selectedFile).toEqual(mockResponse); + expect(uppyUploaderSpy.open).toHaveBeenCalledWith('any'); + expect(component.selectedFile).toEqual(mockFile); })); - it('should handle file upload failure and not set selectedFile', fakeAsync(() => { - filestackSpy.open = jasmine.createSpy().and.callFake(options => { - return options.onFileUploadFailed('Error'); - }); + it('should not set selectedFile when uppy modal is dismissed without data', fakeAsync(() => { + uppyUploaderSpy.open.and.returnValue(Promise.resolve({ + onDidDismiss: () => Promise.resolve({ data: undefined, role: 'cancel' }), + } as any)); component.uploadFile(); flushMicrotasks(); - expect(filestackSpy.open).toHaveBeenCalled(); + expect(uppyUploaderSpy.open).toHaveBeenCalledWith('any'); expect(component.selectedFile).toBeUndefined(); })); - it('should call FilestackService open method when keyboard event is Enter or Space', fakeAsync(() => { - filestackSpy.open = jasmine.createSpy().and.callThrough(); + it('should open uppy uploader when keyboard event is Enter or Space', fakeAsync(() => { + uppyUploaderSpy.open.and.returnValue(Promise.resolve({ + onDidDismiss: () => Promise.resolve({ data: undefined, role: 'cancel' }), + } as any)); const enterEvent = new KeyboardEvent('keydown', { code: 'Enter' }); const spaceEvent = new KeyboardEvent('keydown', { code: 'Space' }); component.uploadFile(enterEvent); + flushMicrotasks(); component.uploadFile(spaceEvent); flushMicrotasks(); - expect(filestackSpy.open).toHaveBeenCalledTimes(2); + expect(uppyUploaderSpy.open).toHaveBeenCalledTimes(2); })); - it('should not call FilestackService open method when keyboard event is not Enter or Space', fakeAsync(() => { + it('should not open uppy uploader when keyboard event is not Enter or Space', fakeAsync(() => { const escapeEvent = new KeyboardEvent('keydown', { code: 'Escape' }); component.uploadFile(escapeEvent); flushMicrotasks(); - expect(filestackSpy.open).not.toHaveBeenCalled(); + expect(uppyUploaderSpy.open).not.toHaveBeenCalled(); })); }); diff --git a/projects/v3/src/app/components/support-popup/support-popup.component.ts b/projects/v3/src/app/components/support-popup/support-popup.component.ts index 71238ef00..2e364e9be 100644 --- a/projects/v3/src/app/components/support-popup/support-popup.component.ts +++ b/projects/v3/src/app/components/support-popup/support-popup.component.ts @@ -1,9 +1,8 @@ -import * as filestack from 'filestack-js'; import { Component, Inject, Input, OnInit, forwardRef } from '@angular/core'; import { supportQuestionList } from './support-questions'; import { ModalController } from '@ionic/angular'; import { HubspotService, HubspotFormParams } from '@v3/services/hubspot.service'; -import { FilestackService } from '@v3/app/services/filestack.service'; +import { UppyUploaderService, UppyFileData } from '../uppy-uploader/uppy-uploader.service'; import { UtilsService } from '@v3/services/utils.service'; import { NotificationsService } from '@v3/app/services/notifications.service'; @@ -14,13 +13,12 @@ import { NotificationsService } from '@v3/app/services/notifications.service'; styleUrls: ['./support-popup.component.scss'], }) export class SupportPopupComponent implements OnInit { - protected filestack = filestack.Client; isShowForm: boolean = false; isShowSuccess: boolean = false; isShowError: boolean = false; isShowRequiredError: boolean = false; questionList = supportQuestionList; - selectedFile: any; + selectedFile: UppyFileData; problemSubject: string; problemContent: string; @Input() isShowFormOnly?: boolean; @@ -29,7 +27,7 @@ export class SupportPopupComponent implements OnInit { constructor( private modalController: ModalController, @Inject(forwardRef(() => HubspotService)) private hubspotService: HubspotService, - private filestackService: FilestackService, + private uppyUploaderService: UppyUploaderService, private utilService: UtilsService, private notificationsService: NotificationsService, ) { } @@ -68,11 +66,6 @@ export class SupportPopupComponent implements OnInit { { text: 'Leave', handler: () => { - // if fileupload has been initiated earlier, delete the file from filestack - if (this.selectedFile) { - this.filestackService.deleteFile(this.selectedFile.handle).toPromise(); - } - this.modalController.dismiss({ isPristine: this.isPristine() }); @@ -92,7 +85,6 @@ export class SupportPopupComponent implements OnInit { } async removeSelectedFile() { - await this.filestackService.deleteFile(this.selectedFile.handle).toPromise(); this.selectedFile = undefined; } @@ -103,28 +95,13 @@ export class SupportPopupComponent implements OnInit { return; } - const pickerOptions: filestack.PickerOptions = { - storeTo: this.filestackService.getS3Config('any'), - onFileUploadFailed: data => { - this.selectedFile = undefined; - }, - onFileUploadFinished: data => { - this.selectedFile = data; - }, - onOpen: () => { // for accessibility - setTimeout(() => { - const eles = document.getElementsByClassName('fsp-picker__close-button'); - if (eles.length > 0) { - (eles[0] as HTMLElement).focus(); - } - }, 850); - }, - }; - try { - - const res = await this.filestackService.open(pickerOptions); - return res; + const modal = await this.uppyUploaderService.open('any'); + const res = await modal.onDidDismiss(); + const data: UppyFileData = res.data; + if (data) { + this.selectedFile = data; + } } catch (err) { throw new Error(err); } diff --git a/projects/v3/src/app/components/todo-card/todo-card.component.scss b/projects/v3/src/app/components/todo-card/todo-card.component.scss index 0a0d98c50..1b5aa51eb 100644 --- a/projects/v3/src/app/components/todo-card/todo-card.component.scss +++ b/projects/v3/src/app/components/todo-card/todo-card.component.scss @@ -10,7 +10,7 @@ } .focusable:focus { - display: block; - border: 1px solid; + outline: 2px solid var(--ion-color-primary); + outline-offset: -2px; } diff --git a/projects/v3/src/app/components/topic/topic.component.spec.ts b/projects/v3/src/app/components/topic/topic.component.spec.ts index 88df682f3..ea4b6118b 100644 --- a/projects/v3/src/app/components/topic/topic.component.spec.ts +++ b/projects/v3/src/app/components/topic/topic.component.spec.ts @@ -4,7 +4,7 @@ import { ComponentFixture, TestBed, fakeAsync, tick, flushMicrotasks } from '@an import { Router } from '@angular/router'; import { TopicComponent } from './topic.component'; import { TopicService } from '@v3/services/topic.service'; -import { FilestackService } from '@v3/services/filestack.service'; +import { FilePreviewService } from '@v3/services/file-preview.service'; import { ActivatedRouteStub } from '@testingv3/activated-route-stub'; import { NotificationsService } from '@v3/services/notifications.service'; import { BrowserStorageService } from '@v3/services/storage.service'; @@ -21,7 +21,7 @@ describe('TopicComponent', () => { let component: TopicComponent; let fixture: ComponentFixture; let topicSpy: jasmine.SpyObj; - let filestackSpy: jasmine.SpyObj; + let filePreviewSpy: jasmine.SpyObj; let embedSpy: jasmine.SpyObj; let sharedSpy: jasmine.SpyObj; let routerSpy: jasmine.SpyObj; @@ -32,7 +32,7 @@ describe('TopicComponent', () => { beforeEach(async () => { topicSpy = jasmine.createSpyObj('TopicService', ['getTopic', 'getTopicProgress', 'updateTopicProgress', 'clearTopic']); - filestackSpy = jasmine.createSpyObj('FilestackService', ['previewFile']); + filePreviewSpy = jasmine.createSpyObj('FilePreviewService', ['preview']); embedSpy = jasmine.createSpyObj('EmbedVideoService', ['embed']); embedSpy.embed.and.returnValue(''); // return valid embed html sharedSpy = jasmine.createSpyObj('SharedService', ['stopPlayingVideos']); @@ -47,7 +47,7 @@ describe('TopicComponent', () => { schemas: [CUSTOM_ELEMENTS_SCHEMA], providers: [ { provide: TopicService, useValue: topicSpy }, - { provide: FilestackService, useValue: filestackSpy }, + { provide: FilePreviewService, useValue: filePreviewSpy }, { provide: EmbedVideoService, useValue: embedSpy }, { provide: Router, useValue: routerSpy }, { provide: NotificationsService, useValue: notificationSpy }, @@ -163,7 +163,7 @@ describe('TopicComponent', () => { it('should load file successfully', fakeAsync(() => { const SAMPLE_RESULT = 'SAMPLE'; let result: any; - filestackSpy.previewFile.and.returnValue(Promise.resolve(SAMPLE_RESULT)); + filePreviewSpy.preview.and.returnValue(Promise.resolve(SAMPLE_RESULT)); component.isLoadingPreview = false; component.previewFile('').then(res => result = res); @@ -178,7 +178,7 @@ describe('TopicComponent', () => { const SAMPLE_RESULT = 'FAILED_SAMPLE'; let result: any; notificationSpy.alert.and.returnValue(Promise.resolve(SAMPLE_RESULT as any)); - filestackSpy.previewFile.and.rejectWith(new Error('File preview test error')); + filePreviewSpy.preview.and.rejectWith(new Error('File preview test error')); component.isLoadingPreview = false; component.previewFile('').then(res => result = res); diff --git a/projects/v3/src/app/components/topic/topic.component.ts b/projects/v3/src/app/components/topic/topic.component.ts index 7c70b67e5..0ffc235c3 100644 --- a/projects/v3/src/app/components/topic/topic.component.ts +++ b/projects/v3/src/app/components/topic/topic.component.ts @@ -6,7 +6,7 @@ import { SharedService } from '@v3/services/shared.service'; import Plyr from 'plyr'; import { EmbedVideoService } from '@v3/services/ngx-embed-video.service'; import { SafeHtml, DomSanitizer } from '@angular/platform-browser'; -import { FilestackService } from '@v3/app/services/filestack.service'; +import { FilePreviewService } from '@v3/app/services/file-preview.service'; import { NotificationsService } from '@v3/app/services/notifications.service'; import { BehaviorSubject, exhaustMap, filter, finalize, Subject, Subscription, takeUntil } from 'rxjs'; import { Task } from '@v3/app/services/activity.service'; @@ -50,7 +50,7 @@ export class TopicComponent implements OnInit, OnChanges, AfterViewChecked, OnDe private notification: NotificationsService, private utils: UtilsService, private sharedService: SharedService, - private filestack: FilestackService, + private filePreviewService: FilePreviewService, private topicService: TopicService, private sanitizer: DomSanitizer, private cleanupService: ComponentCleanupService, @@ -288,9 +288,9 @@ export class TopicComponent implements OnInit, OnChanges, AfterViewChecked, OnDe this.isLoadingPreview = true; try { - const filestack = await this.filestack.previewFile(file); + const result = await this.filePreviewService.preview(file); this.isLoadingPreview = false; - return filestack; + return result; } catch (err) { const toasted = await this.notification.alert({ header: 'Error Previewing file', diff --git a/projects/v3/src/app/components/uppy-uploader/uppy-uploader.service.ts b/projects/v3/src/app/components/uppy-uploader/uppy-uploader.service.ts index 6fdcea158..c687c915b 100644 --- a/projects/v3/src/app/components/uppy-uploader/uppy-uploader.service.ts +++ b/projects/v3/src/app/components/uppy-uploader/uppy-uploader.service.ts @@ -199,7 +199,7 @@ export class UppyUploaderService { * @param {string} source * @return {Promise} */ - async open(source: 'chat' | 'user-profile' | 'assessment' | 'media-manager' | 'static' | null): Promise { + async open(source: 'chat' | 'user-profile' | 'assessment' | 'media-manager' | 'static' | 'any' | 'image' | 'video' | null): Promise { // dynamic import to break circular dependency with UppyUploaderComponent const { UppyUploaderComponent } = await import('./uppy-uploader.component'); const modal = await this.modalController.create({ diff --git a/projects/v3/src/app/components/video-conversion/video-conversion.component.html b/projects/v3/src/app/components/video-conversion/video-conversion.component.html index 303a475b8..3fb20b042 100644 --- a/projects/v3/src/app/components/video-conversion/video-conversion.component.html +++ b/projects/v3/src/app/components/video-conversion/video-conversion.component.html @@ -1,6 +1,6 @@
diff --git a/projects/v3/src/app/components/video-conversion/video-conversion.component.spec.ts b/projects/v3/src/app/components/video-conversion/video-conversion.component.spec.ts index 4e1221291..1da48ee5a 100644 --- a/projects/v3/src/app/components/video-conversion/video-conversion.component.spec.ts +++ b/projects/v3/src/app/components/video-conversion/video-conversion.component.spec.ts @@ -1,13 +1,13 @@ import { CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from "@angular/core/testing"; -import { FilestackService } from "@v3/app/services/filestack.service"; +import { FilePreviewService } from "@v3/app/services/file-preview.service"; import { of, Subject } from "rxjs"; import { VideoConversionComponent } from "./video-conversion.component"; describe('VideoConversionComponent', () => { let component: VideoConversionComponent; let fixture: ComponentFixture; - let filestackSpy: FilestackService; + let filePreviewSpy: jasmine.SpyObj; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ @@ -16,10 +16,9 @@ describe('VideoConversionComponent', () => { schemas: [CUSTOM_ELEMENTS_SCHEMA], providers: [ { - provide: FilestackService, - useValue: jasmine.createSpyObj('FilestackService', { - 'videoConversion': of({ status: 'completed' }), - 'previewModal': of(), + provide: FilePreviewService, + useValue: jasmine.createSpyObj('FilePreviewService', { + 'openModal': Promise.resolve(), }), }, ], @@ -27,7 +26,7 @@ describe('VideoConversionComponent', () => { fixture = TestBed.createComponent(VideoConversionComponent); component = fixture.componentInstance; - filestackSpy = TestBed.inject(FilestackService); + filePreviewSpy = TestBed.inject(FilePreviewService) as jasmine.SpyObj; })); it('should created', () => { @@ -35,46 +34,26 @@ describe('VideoConversionComponent', () => { }); describe('ngOnInit()', () => { - it('should start with countdown for timeout', fakeAsync(() => { + it('should be a no-op after filestack removal', () => { component.ngOnInit(); expect(component.waitedTooLong).toBeFalse(); - tick(10000); - expect(component.waitedTooLong).toBeTrue(); - })); + }); }); describe('ngOnChange()', () => { - it('should act on video file which isn\'t an mp4', () => { - const spy = spyOn(component, 'convertVideo'); + it('should show download fallback for non-mp4 video', () => { component.video = { fileObject: { - mimetype: 'video/abc', // not mp4 + mimetype: 'video/abc', }, }; component.ngOnChanges({} as any); - expect(spy).toHaveBeenCalled(); + expect(component.waitedTooLong).toBeTrue(); }); }); - describe('convertVideo()', () => { - it('should perform filestack video conversion and wait', fakeAsync(() => { - component.stop$ = new Subject(); - component.convertVideo({ - bucket: 'test-bucket', - path: 'test-path', - name: 'test-video', - url: 'http://test.com/video.mp4', - extension: 'mp4', - type: 'video/mp4', - size: 1000 - }); - tick(10000); - expect(component.result).toEqual({ status: 'completed' }); - })); - }); - - describe('showInFilestackPreview()', () => { + describe('showPreview()', () => { it('should show video in streaming URL', () => { const file = { data: { @@ -87,8 +66,8 @@ describe('VideoConversionComponent', () => { url: 'http://streaming.com', }, }; - component.showInFilestackPreview(file as any); - expect(filestackSpy.previewModal).toHaveBeenCalledWith('http://practera.com', { url: 'http://streaming.com' }); + component.showPreview(file as any); + expect(filePreviewSpy.openModal).toHaveBeenCalledWith('http://practera.com', { url: 'http://streaming.com' }); }); it('should allow keyboard event', () => { @@ -109,7 +88,7 @@ describe('VideoConversionComponent', () => { key: 'Enter', }); const spyKb = spyOn(kbEvent, 'preventDefault'); - component.showInFilestackPreview(file as any, kbEvent); + component.showPreview(file as any, kbEvent); expect(spyKb).toHaveBeenCalled(); }); @@ -131,7 +110,7 @@ describe('VideoConversionComponent', () => { key: 'Tab', }); const spyKb = spyOn(kbEvent, 'preventDefault'); - component.showInFilestackPreview(file as any, kbEvent); + component.showPreview(file as any, kbEvent); expect(spyKb).not.toHaveBeenCalled(); }); }); diff --git a/projects/v3/src/app/components/video-conversion/video-conversion.component.ts b/projects/v3/src/app/components/video-conversion/video-conversion.component.ts index 7fdf80614..ab68d4c7c 100644 --- a/projects/v3/src/app/components/video-conversion/video-conversion.component.ts +++ b/projects/v3/src/app/components/video-conversion/video-conversion.component.ts @@ -1,14 +1,6 @@ import { Component, Input, Output, OnChanges, SimpleChanges, EventEmitter, ViewEncapsulation, OnDestroy, OnInit } from '@angular/core'; -import { FilestackService } from '@v3/app/services/filestack.service'; +import { FilePreviewService } from '@v3/app/services/file-preview.service'; import { Subject, Subscription } from 'rxjs'; -import { delay, repeat, takeUntil } from 'rxjs/operators'; - -interface FilestackConversionResponse { - status: string; - data: { - url: string; - }; -} @Component({ standalone: false, @@ -25,23 +17,16 @@ export class VideoConversionComponent implements OnInit, OnChanges, OnDestroy { subscriptions: Subscription[] = []; waitedTooLong: boolean = false; - constructor(private filestackService: FilestackService) {} + constructor(private filePreviewService: FilePreviewService) {} ngOnInit(): void { - const stillWaiting = setTimeout(() => { - this.waitedTooLong = true; - }, 10000); - - this.subscriptions.push(this.stop$.subscribe(res => { - if (res === true) { - clearTimeout(stillWaiting); - } - })); + // no-op: conversion polling removed (filestack deprecated) } ngOnChanges(_changes: SimpleChanges): void { if (this.video?.fileObject?.mimetype !== 'video/mp4') { - this.convertVideo(this.video.fileObject); + // filestack video conversion no longer available — show download fallback immediately + this.waitedTooLong = true; } } @@ -52,29 +37,16 @@ export class VideoConversionComponent implements OnInit, OnChanges, OnDestroy { } } - convertVideo(file) { - this.subscriptions.push(this.filestackService.videoConversion(file.handle).pipe( - delay(2000), - repeat(10), - takeUntil(this.stop$), - ).subscribe((res: FilestackConversionResponse) => { - this.result = res; - if (res?.status === 'completed') { - this.stop$.next(true); - } - })); - } - - showInFilestackPreview(file: FilestackConversionResponse, keyboardEvent?: KeyboardEvent) { + showPreview(file: { data?: { url: string } }, keyboardEvent?: KeyboardEvent) { if (keyboardEvent && (keyboardEvent?.code === 'Space' || keyboardEvent?.code === 'Enter')) { keyboardEvent.preventDefault(); } else if (keyboardEvent) { return; } - const downloadURL = file.data.url; - const streamURL = this.video.fileObject.url; - return this.filestackService.previewModal(downloadURL, { + const downloadURL = file.data?.url; + const streamURL = this.video?.fileObject?.url; + return this.filePreviewService.openModal(downloadURL, { url: streamURL }); } diff --git a/projects/v3/src/app/pages/chat/attachment-popover/attachment-popover.component.html b/projects/v3/src/app/pages/chat/attachment-popover/attachment-popover.component.html index 1c1eb52e6..e638659eb 100644 --- a/projects/v3/src/app/pages/chat/attachment-popover/attachment-popover.component.html +++ b/projects/v3/src/app/pages/chat/attachment-popover/attachment-popover.component.html @@ -1,9 +1,5 @@ Select a type - - - Uppy - File diff --git a/projects/v3/src/app/pages/chat/attachment-popover/attachment-popover.component.spec.ts b/projects/v3/src/app/pages/chat/attachment-popover/attachment-popover.component.spec.ts index f514b6eed..cd089390d 100644 --- a/projects/v3/src/app/pages/chat/attachment-popover/attachment-popover.component.spec.ts +++ b/projects/v3/src/app/pages/chat/attachment-popover/attachment-popover.component.spec.ts @@ -4,7 +4,7 @@ import { PopoverController } from '@ionic/angular'; import { of } from 'rxjs'; import { AttachmentPopoverComponent } from './attachment-popover.component'; -import { FilestackService } from '@v3/services/filestack.service'; +import { UppyUploaderService } from '@v3/app/components/uppy-uploader/uppy-uploader.service'; import { NotificationsService } from '@v3/services/notifications.service'; import { ModalService } from '@v3/services/modal.service'; @@ -22,8 +22,8 @@ describe('AttachmentPopoverComponent', () => { useValue: jasmine.createSpyObj('PopoverController', ['dismiss', 'create']) }, { - provide: FilestackService, - useValue: jasmine.createSpyObj('FilestackService', ['getFileTypes', 'getS3Config', 'open']) + provide: UppyUploaderService, + useValue: jasmine.createSpyObj('UppyUploaderService', ['open']) }, { provide: NotificationsService, diff --git a/projects/v3/src/app/pages/chat/attachment-popover/attachment-popover.component.ts b/projects/v3/src/app/pages/chat/attachment-popover/attachment-popover.component.ts index 51448b729..442d11455 100644 --- a/projects/v3/src/app/pages/chat/attachment-popover/attachment-popover.component.ts +++ b/projects/v3/src/app/pages/chat/attachment-popover/attachment-popover.component.ts @@ -1,8 +1,7 @@ import { UppyUploaderService } from './../../../components/uppy-uploader/uppy-uploader.service'; -import { Component, OnInit } from '@angular/core'; +import { Component } from '@angular/core'; import { PopoverController } from '@ionic/angular'; -import { FilestackService } from '@v3/services/filestack.service'; import { NotificationsService } from '../../../services/notifications.service'; @Component({ @@ -15,7 +14,6 @@ export class AttachmentPopoverComponent{ constructor( private popoverController: PopoverController, - private filestackService: FilestackService, private uppyUploaderService: UppyUploaderService, private notificationsService: NotificationsService, ) { } @@ -38,34 +36,12 @@ export class AttachmentPopoverComponent{ async openAttachPopup(selectedType) { try { - if (selectedType === 'uppy') { - const modal = await this.uppyUploaderService.open('chat'); - modal.onDidDismiss().then(async (res) => { - if (res.data) { - const success = res.data.successful.length > 0 ? res.data.successful[0] : {}; - this.close(success); - } - }); - return; - } - - const options: any = {}; - if (this.filestackService.getFileTypes(selectedType)) { - options.accept = this.filestackService.getFileTypes(selectedType); - options.storeTo = this.filestackService.getS3Config(selectedType); - } - - await this.filestackService.open( - options, - res => { - this.close(res); - return; - }, - err => { - // eslint-disable-next-line no-console - console.log(err); + const modal = await this.uppyUploaderService.open(selectedType); + modal.onDidDismiss().then(async (res) => { + if (res.data) { + this.close(res.data); } - ); + }); } catch (error) { // eslint-disable-next-line no-console diff --git a/projects/v3/src/app/pages/chat/chat-list/chat-list.component.scss b/projects/v3/src/app/pages/chat/chat-list/chat-list.component.scss index 9a2111edc..3aeaf2bec 100644 --- a/projects/v3/src/app/pages/chat/chat-list/chat-list.component.scss +++ b/projects/v3/src/app/pages/chat/chat-list/chat-list.component.scss @@ -104,8 +104,8 @@ clickable-item { } .focusable:focus { - border: 1px solid var(--ion-color-primary); - display: block; + outline: 2px solid var(--ion-color-primary); + outline-offset: -2px; } .indicator-container { diff --git a/projects/v3/src/app/pages/chat/chat-room/chat-room.component.html b/projects/v3/src/app/pages/chat/chat-room/chat-room.component.html index d64d14e6e..c43526f03 100644 --- a/projects/v3/src/app/pages/chat/chat-room/chat-room.component.html +++ b/projects/v3/src/app/pages/chat/chat-room/chat-room.component.html @@ -210,12 +210,12 @@ [attr.aria-label]="'Preview ' + (attachment?.name || 'attachment')" (keydown.enter)="preview(attachment)" (keydown.space)="preview(attachment); $event.preventDefault()"> - + (keydown.enter)="removeSelectAttachment(attachment, i)" + (keydown.space)="removeSelectAttachment(attachment, i); $event.preventDefault()">
@@ -231,12 +231,12 @@ (keydown.space)="preview(attachment); $event.preventDefault()"> - + (keydown.enter)="removeSelectAttachment(attachment, i)" + (keydown.space)="removeSelectAttachment(attachment, i); $event.preventDefault()"> @@ -253,12 +253,12 @@
- + (keydown.enter)="removeSelectAttachment(attachment, i)" + (keydown.space)="removeSelectAttachment(attachment, i); $event.preventDefault()"> diff --git a/projects/v3/src/app/pages/chat/chat-room/chat-room.component.spec.ts b/projects/v3/src/app/pages/chat/chat-room/chat-room.component.spec.ts index 78c497064..cc069ab1c 100644 --- a/projects/v3/src/app/pages/chat/chat-room/chat-room.component.spec.ts +++ b/projects/v3/src/app/pages/chat/chat-room/chat-room.component.spec.ts @@ -8,7 +8,6 @@ import { of, Subject } from 'rxjs'; import { BrowserStorageService } from '@v3/services/storage.service'; import { UtilsService } from '@v3/services/utils.service'; import { PusherService } from '@v3/services/pusher.service'; -import { FilestackService } from '@v3/services/filestack.service'; import { NotificationsService } from '@v3/services/notifications.service'; import { ModalService } from '@v3/services/modal.service'; import { MockRouter } from '@testingv3/mocked.service'; @@ -29,7 +28,6 @@ describe('ChatRoomComponent', () => { let utils: UtilsService; let storageSpy: jasmine.SpyObj; let pusherSpy: jasmine.SpyObj; - let filestackSpy: jasmine.SpyObj; let routerSpy: jasmine.SpyObj; let routeStub: Partial; let MockIoncontent: IonContent; @@ -78,10 +76,6 @@ describe('ChatRoomComponent', () => { provide: PusherService, useValue: jasmine.createSpyObj('PusherService', ['triggerSendMessage', 'triggerTyping', 'triggerDeleteMessage', 'triggerEditMessage']) }, - { - provide: FilestackService, - useValue: jasmine.createSpyObj('FilestackService', ['getFileTypes', 'getS3Config', 'open', 'previewFile']) - }, { provide: NotificationsService, useValue: jasmine.createSpyObj('NotificationsService', ['alert', 'presentToast', 'loading', 'dismiss']) @@ -135,7 +129,6 @@ describe('ChatRoomComponent', () => { utils = TestBed.inject(UtilsService) as jasmine.SpyObj; storageSpy = TestBed.inject(BrowserStorageService) as jasmine.SpyObj; pusherSpy = TestBed.inject(PusherService) as jasmine.SpyObj; - filestackSpy = TestBed.inject(FilestackService) as jasmine.SpyObj; MockIoncontent = TestBed.inject(IonContent) as jasmine.SpyObj; modalCtrlSpy = TestBed.inject(ModalController); modalCtrlSpy.create.and.returnValue(Promise.resolve(modalSpy)); diff --git a/projects/v3/src/app/pages/chat/chat-room/chat-room.component.ts b/projects/v3/src/app/pages/chat/chat-room/chat-room.component.ts index 3d4bf1acd..8d2704d34 100644 --- a/projects/v3/src/app/pages/chat/chat-room/chat-room.component.ts +++ b/projects/v3/src/app/pages/chat/chat-room/chat-room.component.ts @@ -6,7 +6,6 @@ import { DOCUMENT } from '@angular/common'; import { BrowserStorageService } from '@v3/services/storage.service'; import { UtilsService } from '@v3/services/utils.service'; import { PusherService, SendMessageParam } from '@v3/services/pusher.service'; -import { FilestackService } from '@v3/services/filestack.service'; import { ChatService, ChatChannel, Message, MessageListResult, ChannelMembers, FileResponse } from '@v3/services/chat.service'; import { ChatPreviewComponent } from '../chat-preview/chat-preview.component'; import { ChatInfoComponent } from '../chat-info/chat-info.component'; @@ -154,7 +153,6 @@ export class ChatRoomComponent implements OnInit, OnDestroy, AfterViewInit { private storage: BrowserStorageService, public utils: UtilsService, private pusherService: PusherService, - private filestackService: FilestackService, private modalController: ModalController, private ngZone: NgZone, public element: ElementRef, @@ -337,7 +335,7 @@ export class ChatRoomComponent implements OnInit, OnDestroy, AfterViewInit { .subscribe((_event) => { this._checkScrollPosition(); }); - + // Set aria-labels for Quill toolbar elements (WCAG 4.1.2) setTimeout(() => { this._setQuillToolbarAriaLabels(); @@ -350,7 +348,7 @@ export class ChatRoomComponent implements OnInit, OnDestroy, AfterViewInit { private _setQuillToolbarAriaLabels(): void { // Scope queries to the component's native element to avoid conflicts const componentElement = this.element.nativeElement; - + const previewLink = componentElement.querySelector('.ql-preview') as HTMLElement; if (previewLink && !previewLink.getAttribute('aria-label')) { previewLink.setAttribute('aria-label', 'Preview link'); @@ -1076,11 +1074,7 @@ export class ChatRoomComponent implements OnInit, OnDestroy, AfterViewInit { } } - getResizedImageUrl(fileStackObject, dimension) { - return `https://cdn.filestackcontent.com/quality=value:70/resize=w:${dimension},h:${dimension},fit:crop/${fileStackObject.handle}`; - } - - removeSelectAttachment(attachment, index?: number, isDelete = false) { + removeSelectAttachment(attachment, index?: number) { if (!attachment) { return; } @@ -1089,12 +1083,6 @@ export class ChatRoomComponent implements OnInit, OnDestroy, AfterViewInit { attachIndex = index; } this.selectedAttachments.splice(attachIndex, 1); - if (isDelete) { - this.filestackService - .deleteFile(attachment.handle) - // eslint-disable-next-line no-console - .subscribe(console.log); - } } download(file: FileResponse): void { diff --git a/projects/v3/src/app/pages/events/event-list/event-list.component.scss b/projects/v3/src/app/pages/events/event-list/event-list.component.scss index 0fdfa107f..46f178162 100644 --- a/projects/v3/src/app/pages/events/event-list/event-list.component.scss +++ b/projects/v3/src/app/pages/events/event-list/event-list.component.scss @@ -18,6 +18,6 @@ ion-segment { } .focusable:focus { - display: block; - border: 1px solid var(--ion-color-primary); + outline: 2px solid var(--ion-color-primary); + outline-offset: -2px; } diff --git a/projects/v3/src/app/pages/home/home.page.scss b/projects/v3/src/app/pages/home/home.page.scss index 1a033f80b..ac92c8a65 100644 --- a/projects/v3/src/app/pages/home/home.page.scss +++ b/projects/v3/src/app/pages/home/home.page.scss @@ -108,8 +108,8 @@ ion-content.scrollable-desktop { } .focusable:focus { - border: 1px solid var(--ion-color-primary); - display: block; + outline: 2px solid var(--ion-color-primary); + outline-offset: -2px; } .activity-search-container { diff --git a/projects/v3/src/app/services/file-preview.service.ts b/projects/v3/src/app/services/file-preview.service.ts new file mode 100644 index 000000000..056b15221 --- /dev/null +++ b/projects/v3/src/app/services/file-preview.service.ts @@ -0,0 +1,77 @@ +import { Injectable } from '@angular/core'; +import { ModalController } from '@ionic/angular'; +import { NotificationsService } from '@v3/services/notifications.service'; +import { UtilsService } from '@v3/services/utils.service'; +import { FilePreviewComponent } from '../components/file-preview/file-preview.component'; + +@Injectable({ + providedIn: 'root' +}) +export class FilePreviewService { + + constructor( + private modalController: ModalController, + private notificationsService: NotificationsService, + private utils: UtilsService, + ) {} + + // open a file preview modal for given file object + async preview(file: { url?: string; handle?: string; name?: string; size?: number; mimetype?: string }): Promise { + let fileUrl = file.url; + if (fileUrl) { + if (fileUrl.indexOf('www.filepicker.io/api/file') !== -1) { + fileUrl = fileUrl.replace('www.filepicker.io/api/file', 'cdn.filestackcontent.com/preview'); + } else if (fileUrl.indexOf('filestackcontent.com') !== -1) { + fileUrl = fileUrl.replace('filestackcontent.com', 'filestackcontent.com/preview'); + } + } else if (file.handle) { + fileUrl = 'https://cdn.filestackcontent.com/preview/' + file.handle; + } + + if (!fileUrl) { + return this.notificationsService.alert({ + subHeader: $localize`Inaccessible file`, + message: $localize`The file URL is not available.`, + }); + } + + // large application file warning using local size info + if (file.mimetype?.includes('application/') && file.size) { + const megabyte = file.size / 1000 / 1000; + if (megabyte > 10) { + return this.notificationsService.alert({ + subHeader: $localize`File size too large`, + message: $localize`Attachment size has exceeded the size of ${Math.floor(megabyte)}mb please consider downloading the file for better reading experience.`, + buttons: [ + { + text: $localize`Download`, + handler: () => { + return this.utils.openUrl(file.url, { target: '_blank' }); + } + }, + { + text: $localize`Cancel`, + role: 'cancel', + handler: () => { return; } + }, + ] + }); + } + } + + return this.openModal(fileUrl, file); + } + + // open preview modal with given url and optional file reference + async openModal(url: string, file?: any): Promise { + const modal = await this.modalController.create({ + component: FilePreviewComponent, + componentProps: { + url, + file: file || {}, + }, + cssClass: 'filestack-preview-modal', + }); + return await modal.present(); + } +} diff --git a/projects/v3/src/app/services/filestack.service.spec.ts b/projects/v3/src/app/services/filestack.service.spec.ts deleted file mode 100644 index 136d1dd9b..000000000 --- a/projects/v3/src/app/services/filestack.service.spec.ts +++ /dev/null @@ -1,348 +0,0 @@ -import { TestBed, fakeAsync, flushMicrotasks } from '@angular/core/testing'; - -import { - HttpTestingController, - HttpClientTestingModule -} from '@angular/common/http/testing'; -import { FilestackService } from './filestack.service'; -import { NotificationsService } from '@v3/services/notifications.service'; -import { BrowserStorageService } from '@v3/services/storage.service'; -import { UtilsService } from '@v3/services/utils.service'; -import { BrowserStorageServiceMock } from '@testingv3/mocked.service'; -import { environment } from '@v3/environments/environment'; -import { ModalController, IonicModule } from '@ionic/angular'; -import * as filestack from 'filestack-js'; -import { TestUtils } from '@testingv3/utils'; - -describe('FilestackService', () => { - let service: FilestackService; - let notificationSpy: jasmine.SpyObj; - let storageSpy: jasmine.SpyObj; - let utils: UtilsService; - let mockBackend: HttpTestingController; - let modalctrlSpy: jasmine.SpyObj; - const MODAL_SAMPLE = 'test'; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [HttpClientTestingModule, IonicModule], - providers: [ - FilestackService, - { - provide: ModalController, - useValue: jasmine.createSpyObj('ModalController', { - create: Promise.resolve({ - present: () => new Promise(res => { - res(MODAL_SAMPLE); - }), - dismiss: () => new Promise(res => res(true)), - }) - }) - }, - { - provide: UtilsService, - useClass: TestUtils, - }, - { - provide: BrowserStorageService, - useClass: BrowserStorageServiceMock - }, - { - provide: NotificationsService, - useValue: jasmine.createSpyObj('NotificationsService', [ - 'modal', 'alert' - ]) - }, - ] - }); - service = TestBed.inject(FilestackService); - utils = TestBed.inject(UtilsService); - notificationSpy = TestBed.inject(NotificationsService) as jasmine.SpyObj; - storageSpy = TestBed.inject(BrowserStorageService) as jasmine.SpyObj; - mockBackend = TestBed.inject(HttpTestingController); - modalctrlSpy = TestBed.inject(ModalController) as jasmine.SpyObj; - }); - - it('should be created', () => { - expect(service).toBeTruthy(); - }); - - describe('getFileTypes', () => { - it('should return mimetype wildcard based on provided type', () => { - const anyType = service.getFileTypes(); - const imageType = service.getFileTypes('image'); - const videoType = service.getFileTypes('video'); - - expect(anyType).toEqual(''); - expect(imageType).toEqual('image/*'); - expect(videoType).toEqual('video/*'); - }); - }); - - describe('getS3Config()', () => { - it('should get config value from environment variable', () => { - const userHash = 'testUserHash'; - storageSpy.getUser.and.returnValue({ - userHash - }); - - const location = 'test'; - const container = 'test'; - const region = 'test'; - const workflows = ['test', 'test2']; - const paths = { - test: 'test-type', - any: 'test' - }; - - const testConfig = { - location, - container, - region, - workflows, - paths, - }; - - environment.filestack.s3Config = Object.assign(environment.filestack.s3Config, testConfig); - - const result = service.getS3Config('test'); - - expect(storageSpy.getUser).toHaveBeenCalled(); - expect(result).toEqual({ - location, - container, - region, - path: `${paths.test}${userHash}/`, - workflows, - }); - }); - }); - - describe('previewFile()', () => { - beforeEach(() => { - spyOn(service, 'previewModal').and.returnValue(Promise.resolve()); - }); - - afterEach(() => { - expect(service.metadata).toHaveBeenCalled(); - }); - - it('should popup file preview', fakeAsync(() => { - spyOn(service, 'metadata').and.returnValue(Promise.resolve({ mimetype: 'testing/format' })); - service.previewFile({ - bucket: 'test-bucket', - path: 'test-path', - name: 'test-file', - url: 'https://example.com/test.jpg', - extension: 'jpg', - type: 'image/jpeg', - size: 1000, - handle: 'testingHandleValue' - }).then(); - flushMicrotasks(); - expect(service.previewModal).toHaveBeenCalled(); - })); - - it('should popup file preview (support older URL format)', fakeAsync(() => { - spyOn(service, 'metadata').and.returnValue(Promise.resolve({ mimetype: 'testing/format' })); - service.previewFile({ - bucket: 'test-bucket', - path: 'test-path', - name: 'test-file', - url: 'www.filepicker.io/api/file', - extension: 'jpg', - type: 'image/jpeg', - size: 1000, - handle: 'testingHandleValue' - }).then(); - flushMicrotasks(); - expect(service.previewModal).toHaveBeenCalled(); - })); - - it('should popup file preview (support older URL format 2)', fakeAsync(() => { - spyOn(service, 'metadata').and.returnValue(Promise.resolve({ mimetype: 'testing/format' })); - service.previewFile({ - bucket: 'test-bucket', - path: 'test-path', - name: 'test-file', - url: 'filestackcontent.com', - extension: 'jpg', - type: 'image/jpeg', - size: 1000, - handle: 'testingHandleValue' - }).then(); - flushMicrotasks(); - expect(service.previewModal).toHaveBeenCalled(); - })); - - it('should alert instead of popup preview when file size too large', fakeAsync(() => { - spyOn(service, 'metadata').and.returnValue(Promise.resolve({ - mimetype: 'application/testType', - size: 11 * 1000 * 1000 // 11mb - })); - - service.previewFile({ - bucket: 'test-bucket', - path: 'test-path', - name: 'test-file', - url: 'filestackcontent.com', - extension: 'pdf', - type: 'application/pdf', - size: 11 * 1000 * 1000, // 11mb - handle: 'testingHandleValue' - }).then(); - flushMicrotasks(); - - expect(notificationSpy.alert).toHaveBeenCalled(); - expect(service.previewModal).not.toHaveBeenCalled(); - })); - }); - - describe('metadata()', () => { - it('should get metadata from filestack', fakeAsync(() => { - const handle = 'testingFilestackHandle'; - let result; - service.metadata({ - url: `http://testing.com/${handle}`, - }).then(res => result = res); - - const req = mockBackend.expectOne({ - url: `https://www.filestackapi.com/api/file/${handle}/metadata`, - method: 'GET', - }); - req.flush({ body: true }); - - mockBackend.verify(); - })); - }); - - describe('open()', () => { - beforeEach(() => { - spyOn(service['filestack'], 'picker').and.returnValue({ - open: () => Promise.resolve(null) - }); - spyOn(service, 'getFileTypes'); - spyOn(service, 'getS3Config'); - }); - - it('should instantiate filestack and trigger open fileupload popup', fakeAsync(() => { - let result; - - service.open().then(res => { - result = res; - }); - flushMicrotasks(); - expect(service.getFileTypes).toHaveBeenCalled(); - expect(service.getS3Config).toHaveBeenCalled(); - expect(result).toBeNull(); - })); - - it('should initiate picker with correct settings', fakeAsync(() => { - let result; - let onSuccessRes; - let onErrorRes; - - const onSuccess = res => { - onSuccessRes = res; - }; - - const onError = res => { - onErrorRes = res; - }; - - service.open({ - testOnly: true, - }, - res => res, - res => res - ).then(res => { - result = res; - }); - flushMicrotasks(); - - expect(service['filestack'].picker).toHaveBeenCalledWith(jasmine.objectContaining({ testOnly: true })); - })); - }); - - describe('previewModal()', () => { - it('should pop up modal for provided filestack link', fakeAsync(() => { - service.previewModal('test.com'); - flushMicrotasks(); - - expect(modalctrlSpy.create).toHaveBeenCalled(); - })); - }); - - describe('getWorkflowStatus()', () => { - const workflowId = 'test_workflow_id'; - const policy = 'test_policy'; - const signature = 'test_signature'; - const workflows = { virusDetection: workflowId }; - - beforeEach(() => { - environment.filestack = Object.assign(environment.filestack, { - policy, - signature, - workflows - }); - }); - - it('should get status of provided workflow info', fakeAsync(() => { - // spyOn(utils, 'each'); - let result = [{ body: true }]; - service.getWorkflowStatus({ - test_workflow_id: [workflows.virusDetection] - }).then(res => { - result = res; - }); - - flushMicrotasks(); - const req = mockBackend.expectOne({ method: 'GET' }); - req.flush(result); - - - expect(req.request.url).toEqual(`https://cdn.filestackcontent.com/${environment.filestack.key}/security=p:${policy},s:${signature}/workflow_status=job_id:${workflowId}`); - - mockBackend.verify(); - })); - - it('should return empty if processedJobs is 0', fakeAsync(() => { - let result; - service.getWorkflowStatus().then(res => { - result = res; - }); - - flushMicrotasks(); - expect(result).toEqual([]); - })); - }); - - describe('onFileSelectedRename()', () => { - it('should rename file with spacing', fakeAsync(() => { - const currentFile = { - bucket: 'test-bucket', - path: 'test-path', - name: 'a b c', - url: 'http://example.com/a-b-c', - extension: 'jpg', - type: 'image/jpeg', - size: 1000, - filename: 'a b c', - handle: 'a-b-c', - mimetype: 'mimetype', - originalPath: 'here', - source: 'earth', - uploadId: '12345', - alt: '' - }; - - // Since onFileSelectedRename is always returning a Promise now - let result; - service['onFileSelectedRename'](currentFile).then(res => { - result = res; - expect(result.filename).toEqual('a_b_c'); - }); - flushMicrotasks(); - })); - }); -}); diff --git a/projects/v3/src/app/services/filestack.service.ts b/projects/v3/src/app/services/filestack.service.ts deleted file mode 100644 index 8371b4f01..000000000 --- a/projects/v3/src/app/services/filestack.service.ts +++ /dev/null @@ -1,297 +0,0 @@ -import * as filestack from 'filestack-js'; -import { Injectable } from '@angular/core'; -import { ModalController } from '@ionic/angular'; -// import { PreviewComponent } from './preview/preview.component'; -import { environment } from '@v3/environments/environment'; -import { BrowserStorageService } from '@v3/services/storage.service'; -import { HttpClient } from '@angular/common/http'; // added to make one and only API call to filestack server -import { forkJoin } from 'rxjs'; -import { NotificationsService } from '@v3/services/notifications.service'; -import { UtilsService } from '@v3/services/utils.service'; -import { FilestackPreviewComponent } from '../components/filestack-preview/filestack-preview.component'; - -export interface Metadata { - mimetype?: string; - uploaded?: number; - container?: string; - writeable?: boolean; - filename?: string; - location?: string; - key?: string; - path?: string; - size?: number; -} - -// https://www.filestack.com/docs/api/file/#md-request -const api = { - metadata: `https://www.filestackapi.com/api/file/HANDLE/metadata` -}; - -const FS_INTELLIGENT = false; -const FS_MULTIPART_CONCURRENCY = 5; - -@Injectable({ - providedIn: 'root' -}) -export class FilestackService { - private filestack: filestack.Client; - readonly chunksConcurrency = FS_MULTIPART_CONCURRENCY; - readonly intelligent: boolean = FS_INTELLIGENT; - - // file types that allowed to upload - public fileTypes = { - any: '', - image: 'image/*', - video: 'video/*' - }; - - constructor( - private modalController: ModalController, - private storage: BrowserStorageService, - private httpClient: HttpClient, - private notificationsService: NotificationsService, - private utils: UtilsService, - ) { - const { policy, signature } = environment.filestack; - this.filestack = filestack.init(this.getFilestackConfig(), { - policy, - signature, - }); - - // avoid silent error - this.filestack.on('upload.error', (error) => { - if (error.type === 'request') { - return this.notificationsService.alert({ - header: $localize`Upload failed`, - message: $localize`Maybe the file is too large or the network is unstable. Please try again later. If problem persists, please contact support.`, - }); - } - }); - - if (!this.filestack) { - throw new Error('Filestack module not found.'); - } - } - - get client() { - if (!this.filestack) { - throw new Error('Filestack module not found.'); - } - - return this.filestack; - } - - // get filestack config - getFilestackConfig() { - return environment.filestack.key; - } - - // get file types - getFileTypes(type = 'any') { - return this.fileTypes[type]; - } - - // get s3 config - getS3Config(fileType) { - let { container, region } = environment.filestack.s3Config; - const { - location, - workflows, - paths - } = environment.filestack.s3Config; - - let path = paths.any; - // get s3 path based on file type - if (paths[fileType]) { - path = paths[fileType]; - } - // add user hash to the path - path = path + this.storage.getUser().userHash + '/'; - if (this.storage.getCountry() === 'China') { - container = environment.filestack.s3Config.containerChina; - region = environment.filestack.s3Config.regionChina; - } - return { - location, - container, - region, - path, - workflows - }; - } - - async previewFile(file): Promise { - let fileUrl = file.url; - if (fileUrl) { - if (fileUrl.indexOf('www.filepicker.io/api/file') !== -1) { - // old format - fileUrl = fileUrl.replace('www.filepicker.io/api/file', 'cdn.filestackcontent.com/preview'); - } else if (fileUrl.indexOf('filestackcontent.com') !== -1) { - // new format - fileUrl = fileUrl.replace('filestackcontent.com', 'filestackcontent.com/preview'); - } - } else if (file.handle) { - fileUrl = 'https://cdn.filestackcontent.com/preview/' + file.handle; - } - - let metadata; - try { - metadata = await this.metadata(file); - } catch (e) { - if (e.status === 0) { - return this.notificationsService.alert({ - subHeader: $localize`No Filestack responses`, - message: e.message, - }); - } - return this.notificationsService.alert({ - subHeader: $localize`Inaccessible file`, - message: $localize`The uploaded file is suspicious and being scanned for potential risk. Please try again later.`, - }); - } - - if (metadata.mimetype && metadata.mimetype.includes('application/')) { - const megabyte = (metadata && metadata.size) ? metadata.size / 1000 / 1000 : 0; - if (megabyte > 10) { - return this.notificationsService.alert({ - subHeader: $localize`File size too large`, - message: $localize`Attachment size has exceeded the size of ${Math.floor(megabyte)}mb please consider downloading the file for better reading experience.`, - buttons: [ - { - text: $localize`Download`, - handler: () => { - return this.utils.openUrl(file.url, { - target: '_blank', - }); - } - }, - { - text: $localize`Cancel`, - role: 'cancel', - handler: () => { - return; - } - }, - ] - }); - } - } - - return new Promise(resolve => resolve(this.previewModal(fileUrl, file))); - } - - async metadata(file): Promise { - const handle = file.url.match(/([A-Za-z0-9]){20,}/); - return this.httpClient.get(api.metadata.replace('HANDLE', handle[0])).toPromise(); - } - - private onFileSelectedRename(file: filestack.PickerFileMetadata): Promise { - // replace space with underscore '_' in file name - // replace space with underscore '_' in file name - const filename = file.filename.replace(/ /g, '_'); - return Promise.resolve({ ...file, filename }); - } - - async open(options = {}, onSuccess = res => res, onError = err => err): Promise { - const currentLocale = this.utils.getCurrentLocale(); - const pickerOptions: filestack.PickerOptions = { - dropPane: {}, - fromSources: [ - 'local_file_system', - 'googledrive', - 'dropbox', - 'onedrive', - 'gmail', - 'video' - ], - uploadConfig: { - intelligent: this.intelligent, - partSize: 1024 * 1024 * 5, // 5MB - concurrency: this.chunksConcurrency, - retry: 2, // retry for 3 times - timeout: 60000, // allow chunked size upload happen for 30 seconds max - }, - storeTo: this.getS3Config(this.getFileTypes()), - onFileSelected: this.onFileSelectedRename, - onFileUploadFailed: onError, - onFileUploadFinished: (res) => { - return onSuccess(res); - }, - onUploadDone: (res) => res, - supportEmail: 'help@practera.com', - lang: currentLocale !== 'en-US' ? currentLocale : 'en', - }; - - return await this.filestack.picker(Object.assign(pickerOptions, options)).open(); - } - - // Note: added similar functionality as this.open() to support drag and drop feature, please check FilestackComponent for how this is being used. - async upload(file, uploadOptions, path, uploadToken): Promise { - const option: filestack.UploadOptions = { - onProgress: uploadOptions.onProgress, - concurrency: this.chunksConcurrency, - intelligent: this.intelligent, // multipart upload - }; - - if (!path) { - path = this.getS3Config(this.getFileTypes()); - } - - await this.filestack.upload(file, option, path, uploadToken) - .then(res => { - const missingAttribute = { - container: res.container, - key: res.key, - filename: res.filename, - mimetype: res.mimetype - }; - return uploadOptions.onFileUploadFinished(Object.assign(res.toJSON(), missingAttribute)); - }) - .catch(err => { - return uploadOptions.onFileUploadFailed(err); - }); - } - - async previewModal(url, filestackUploadedResponse?): Promise { - const modal = await this.modalController.create({ - component: FilestackPreviewComponent, - componentProps: { - url: url, - file: filestackUploadedResponse, // for whole object reference - }, - cssClass: 'filestack-preview-modal', - }); - return await modal.present(); - } - - async getWorkflowStatus(processedJobs = {}): Promise { - const { policy, signature, workflows } = environment.filestack; - let jobs = {}; - - // currently we only accept virusDetection workflow - if (processedJobs && processedJobs[workflows.virusDetection]) { - jobs = processedJobs[workflows.virusDetection]; - } - - const request = []; - this.utils.each(jobs, job => { - request.push(this.httpClient.get(`https://cdn.filestackcontent.com/${environment.filestack.key}/security=p:${policy},s:${signature}/workflow_status=job_id:${job}`)); - }); - if (request.length > 0) { - return forkJoin(request).toPromise(); - } - - return []; - } - - videoConversion(handle) { - return this.httpClient.get(`https://cdn.filestackcontent.com/video_convert/${handle}`); - } - - // securely delete a file from filestack - deleteFile(handle) { - const { policy, signature, key } = environment.filestack; - return this.httpClient.delete(`https://www.filestackapi.com/api/file/${handle}?key=${key}&policy=${policy}&signature=${signature}`); - } -} - diff --git a/projects/v3/src/environments/environment.custom.ts b/projects/v3/src/environments/environment.custom.ts index cb62cd1bb..841f85e19 100644 --- a/projects/v3/src/environments/environment.custom.ts +++ b/projects/v3/src/environments/environment.custom.ts @@ -26,29 +26,6 @@ export const environment = { requiredMetaFields: [] // No required metadata fields } }, - filestack: { - key: '', - s3Config: { - location: 's3', - container: '', - containerChina: '', - region: '', - regionChina: '', - paths: { - any: '', - image: '', - video: '' - }, - workflows: [ - '', - ], - }, - policy: '', - signature: '', - workflows: { - virusDetection: '', - }, - }, hubspot: { liveServerRegion: '', supportFormPortalId: '', diff --git a/projects/v3/src/environments/environment.interface.ts b/projects/v3/src/environments/environment.interface.ts index 29bc3a053..caa7be288 100644 --- a/projects/v3/src/environments/environment.interface.ts +++ b/projects/v3/src/environments/environment.interface.ts @@ -32,27 +32,6 @@ export interface Environment { requiredMetaFields: string[]; }; }; - filestack: { - key: string; - s3Config: { - location: string; - container: string; - containerChina: string; - region: string; - regionChina: string; - paths: { - any: string; - image: string; - video: string; - }; - workflows: string[]; - }; - policy: string; - signature: string; - workflows: { - virusDetection: string; - }; - }; hubspot: { liveServerRegion: string; supportFormPortalId: string; diff --git a/projects/v3/src/environments/environment.local.ts b/projects/v3/src/environments/environment.local.ts index b5e8be26e..072428eae 100644 --- a/projects/v3/src/environments/environment.local.ts +++ b/projects/v3/src/environments/environment.local.ts @@ -30,29 +30,6 @@ export const environment = { requiredMetaFields: [], // No required metadata fields } }, - filestack: { - key: 'AO6F4C72uTPGRywaEijdLz', - s3Config: { - location: 's3', - container: 'practera-aus', - containerChina: 'practera-kr', - region: 'ap-southeast-2', - regionChina: 'ap-northeast-2', - paths: { - any: '/appv2/local/uploads/', - image: '/appv2/local/uploads/', - video: '/appv2/local/video/upload/' - }, - workflows: [ - '3c38ef53-a9d0-4aa4-9234-617d9f03c0de', - ], - }, - policy: '', - signature: '', - workflows: { - virusDetection: '3c38ef53-a9d0-4aa4-9234-617d9f03c0de', - }, - }, hubspot: { liveServerRegion: '', supportFormPortalId: '', From f73c7c22901453aee46c97d08fcfae0fba6afbcc Mon Sep 17 00:00:00 2001 From: trtshen Date: Mon, 30 Mar 2026 14:10:42 +0800 Subject: [PATCH 3/4] [CORE-8147] deprecated filestack --- .../angular-18-to-19-security-fixes.md | 35 ++ lambda/README.md | 74 +++- lambda/forwarder/index.js | 20 +- lambda/forwarder/template.yml | 4 +- lambda/versioner/template.yml | 4 +- package.json | 1 - .../activity/activity.component.scss | 4 +- .../src/app/components/components.module.ts | 9 +- .../file-display.component.spec.ts | 11 - .../file-display/file-display.component.ts | 6 +- .../file-preview.component.html} | 0 .../file-preview.component.scss} | 4 +- .../file-preview.component.spec.ts} | 12 +- .../file-preview.component.ts} | 8 +- .../filestack/filestack.component.html | 94 ----- .../filestack/filestack.component.scss | 68 ---- .../filestack/filestack.component.spec.ts | 75 ---- .../filestack/filestack.component.ts | 161 -------- .../support-popup.component.html | 4 +- .../support-popup.component.spec.ts | 53 ++- .../support-popup/support-popup.component.ts | 41 +-- .../todo-card/todo-card.component.scss | 4 +- .../components/topic/topic.component.spec.ts | 12 +- .../app/components/topic/topic.component.ts | 8 +- .../uppy-uploader/uppy-uploader.service.ts | 2 +- .../video-conversion.component.html | 4 +- .../video-conversion.component.spec.ts | 53 +-- .../video-conversion.component.ts | 46 +-- .../attachment-popover.component.html | 4 - .../attachment-popover.component.spec.ts | 6 +- .../attachment-popover.component.ts | 36 +- .../chat/chat-list/chat-list.component.scss | 4 +- .../chat/chat-room/chat-room.component.html | 18 +- .../chat-room/chat-room.component.spec.ts | 7 - .../chat/chat-room/chat-room.component.ts | 18 +- .../event-list/event-list.component.scss | 4 +- projects/v3/src/app/pages/home/home.page.scss | 4 +- .../src/app/services/file-preview.service.ts | 77 ++++ .../app/services/filestack.service.spec.ts | 348 ------------------ .../v3/src/app/services/filestack.service.ts | 297 --------------- .../v3/src/environments/environment.custom.ts | 23 -- .../src/environments/environment.interface.ts | 21 -- .../v3/src/environments/environment.local.ts | 23 -- 43 files changed, 322 insertions(+), 1385 deletions(-) rename projects/v3/src/app/components/{filestack-preview/filestack-preview.component.html => file-preview/file-preview.component.html} (100%) rename projects/v3/src/app/components/{filestack-preview/filestack-preview.component.scss => file-preview/file-preview.component.scss} (58%) rename projects/v3/src/app/components/{filestack-preview/filestack-preview.component.spec.ts => file-preview/file-preview.component.spec.ts} (86%) rename projects/v3/src/app/components/{filestack-preview/filestack-preview.component.ts => file-preview/file-preview.component.ts} (71%) delete mode 100644 projects/v3/src/app/components/filestack/filestack.component.html delete mode 100644 projects/v3/src/app/components/filestack/filestack.component.scss delete mode 100644 projects/v3/src/app/components/filestack/filestack.component.spec.ts delete mode 100644 projects/v3/src/app/components/filestack/filestack.component.ts create mode 100644 projects/v3/src/app/services/file-preview.service.ts delete mode 100644 projects/v3/src/app/services/filestack.service.spec.ts delete mode 100644 projects/v3/src/app/services/filestack.service.ts diff --git a/docs/upgrades/angular-18-to-19-security-fixes.md b/docs/upgrades/angular-18-to-19-security-fixes.md index ff259319f..7ffe9a07a 100644 --- a/docs/upgrades/angular-18-to-19-security-fixes.md +++ b/docs/upgrades/angular-18-to-19-security-fixes.md @@ -341,6 +341,37 @@ review and update as needed: - `Apollo.use()`, `Apollo.watchQuery()`, `Apollo.mutate()`, `Apollo.query()` signatures - check for changes in `ApolloModule.forRoot()` vs standalone provider pattern +### 5.5. Lambda@Edge forwarder — S3 path prefix (COMPLETED March 2026) + +**Root cause:** The Angular 19 `application` builder (`@angular-devkit/build-angular ^19`) changed the build output structure by introducing a `browser/` subdirectory: + +| Builder | Angular version | Output path | S3 key prefix | +|---------|-----------------|-------------|---------------| +| `browser` (webpack) | 17 / 18 | `dist/v3/{locale}/` | `/{locale}/` | +| `application` (esbuild) | **19+** | `dist/v3/browser/{locale}/` | `/browser/{locale}/` | + +This mismatch caused `lambda/forwarder/index.js` to rewrite all CloudFront URIs to non-existent S3 keys (e.g. `/en-US/index.html` instead of `/browser/en-US/index.html`). S3 returned `403 AccessDenied` — surfaced as raw XML to every user loading the app. + +**Fix applied to `lambda/forwarder/index.js`:** + +```javascript +// before (Angular 17/18) +request.uri = "/en-US/index.html"; // fallback +request.uri = `/${locale}/index.html`; // spa route +// (no static-asset rewrite needed — assets were at root) + +// after (Angular 19+) +request.uri = "/browser/en-US/index.html"; // fallback +request.uri = `/browser/${locale}/index.html`; // spa route +} else if (!request.uri.startsWith('/browser/')) { + request.uri = `/browser${request.uri}`; // static assets +} +``` + +**Validation:** Confirmed working on `p2-stage` after deployment (March 2026). + +**Future Angular upgrades:** check `ls dist/v3/` after building to verify the output structure before merging. If thenstructure changes again, update `forwarder/index.js` in the same PR. See `lambda/README.md` for the full dependency documentation. + --- ## Phase 6 — Testing & Validation @@ -446,6 +477,7 @@ npm audit > ./output/angular-19-upgrade-audit.log 2>&1 | TypeScript 5.6 stricter checks | incremental fix of any new type errors | | ngx-quill v27 regressions | minimal risk — usually just peer dep bump | | CI build failures | run full CI pipeline before merging | +| **Lambda@Edge S3 path mismatch** (**occurred** March 2026) | Angular 19 `application` builder adds `browser/` subdirectory; `lambda/forwarder/index.js` must be updated in the same PR — see §5.5 | **Rollback plan:** revert to the pre-upgrade commit on `angular-eos-upgrades-prerelease` branch. all changes are confined to `package.json`, `package-lock.json`, and targeted source fixes. @@ -480,6 +512,7 @@ npm audit > ./output/angular-19-upgrade-audit.log 2>&1 - [ ] update uppy service/component if API changed - [ ] update apollo-angular usage if API changed - [ ] fix any Angular 19 deprecation warnings + - [x] update `lambda/forwarder/index.js` for `browser/` path prefix *(completed March 2026)* - [ ] **Phase 6:** Validation - [ ] `npm run prebuildv3` succeeds - [ ] `ng build v3` succeeds @@ -487,3 +520,5 @@ npm audit > ./output/angular-19-upgrade-audit.log 2>&1 - [ ] `npm run lint` passes - [ ] `npm audit` shows reduced vulnerability count - [ ] manual testing on staging + - [ ] verify app loads at `https://app..practera.com/en-US` without S3 AccessDenied + - [ ] verify deep-links with query params (magic-link login) resolve to correct locale diff --git a/lambda/README.md b/lambda/README.md index ede09877e..f6debddf5 100644 --- a/lambda/README.md +++ b/lambda/README.md @@ -1,10 +1,76 @@ ### Description -This directory will hold `lambda@edge` functions. +This directory holds `lambda@edge` functions deployed to CloudFront as `origin-request` handlers. -`forwarder` - lambda function that sits infront of the CDN, handles `globalization` redirection. -`versioner` - function to create lambda function version. +`forwarder` - rewrites incoming CloudFront URIs to the correct S3 object keys for the Angular i18n locale builds. +`versioner` - creates a numbered Lambda version ARN that CloudFront requires for Lambda@Edge associations. + +--- + +### i18n / Angular Build Output Dependency + +The `forwarder` function is **tightly coupled** to the Angular build output directory structure. Breaking this coupling causes S3 `AccessDenied` (403) errors surfaced as raw XML to end users. + +#### How Angular i18n builds are deployed + +`projects/v3` is compiled with `"localize": true` in `angular.json`, producing one subfolder per locale: + +| Angular version | Build output path | S3 object key prefix | +|-----------------|-------------------|----------------------| +| 17 / 18 (webpack builder) | `dist/v3/{locale}/` | `/{locale}/` | +| **19+ (application builder)** | `dist/v3/browser/{locale}/` | `/browser/{locale}/` | + +The `aws s3 sync dist/v3/ s3://$BUCKET --delete` step (CI/CD step 22) mirrors this structure directly into S3, so the S3 key prefix always matches the build output. + +#### What the forwarder does + +CloudFront does **not** forward query strings to S3 (`QueryString: false`). When a user visits a deep-link such as `/en-US?auth_token=…`, CloudFront calls the forwarder with `request.uri = "/en-US"`. The forwarder: + +1. Extracts the first path segment as the locale (`en-US`, `ja`, `ms`, `es`). +2. For unknown locales or bare `/`, falls back to the default locale. +3. For SPA routes (no file extension), rewrites to `/{prefix}/{locale}/index.html`. +4. For static assets (with file extension), rewrites to `/{prefix}{original_uri}`. +5. Passes `version.json` requests through unchanged (whitelist). + +#### Supported locales + +The locale whitelist in `forwarder/index.js` must stay in sync with the locales configured in `angular.json`: + +```javascript +const locales = ["en-US", "ja", "ms", "es"]; +``` + +To add a new locale: update both `angular.json` (`i18n.locales`) **and** the `locales` array in `forwarder/index.js`. + +#### ⚠️ What to check on every Angular major version upgrade + +When upgrading Angular major versions, verify the build output structure has not changed before deploying: + +```bash +# after building, check what subdirectories are produced +ls dist/v3/ +# Angular 19+: should show browser/ +# Angular 17/18: should show en-US/ ja/ ms/ es/ +``` + +If the output structure changes, update the path prefix in `forwarder/index.js` **before** merging to trunk so both changes deploy together in the same CI/CD run. + +#### Incident history + +| Date | Trigger | Symptom | Fix | +|------|---------|---------|-----| +| March 2026 | Angular 18 → 19 upgrade | S3 `AccessDenied` XML shown to all users on `app.p2-stage.practera.com` | Added `/browser/` prefix to all rewritten URIs in `forwarder/index.js` | + +--- ### Deployment -Once `AWS` credentials is ready, just run `deploy.sh`. Make sure you installed [sam](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html) on your machine. \ No newline at end of file +Once `AWS` credentials are ready, run `deploy.sh`. Requires [AWS SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html). + +In CI/CD (`p2-stage-appv3.yml`) the deploy sequence is: +1. Build Angular app (`ng build v3`) +2. Deploy Lambda@Edge (`bash lambda/deploy.sh`) → exports `HandlerVersionArn` +3. Deploy CloudFormation/Serverless stack (picks up new `HandlerVersionArn`) +4. Sync `dist/v3/` to S3 + +Both the Lambda function and the S3 content must reflect the same output path structure. \ No newline at end of file diff --git a/lambda/forwarder/index.js b/lambda/forwarder/index.js index cfec74fcc..5450957c5 100644 --- a/lambda/forwarder/index.js +++ b/lambda/forwarder/index.js @@ -1,6 +1,6 @@ const path = require('path'); -exports.handler = async (evt, context, cb) => { +exports.handler = async (evt) => { const { request } = evt.Records[0].cf; console.log(`Original Uri: ${request.uri}`); @@ -11,21 +11,27 @@ exports.handler = async (evt, context, cb) => { const locales = ["en-US", "ja", "ms", "es"]; const lastPartUrl = uriParts[uriParts.length - 1]; - // whitelisted version.json request + // whitelisted version.json request — note: query strings are in request.querystring, + // not in request.uri, so only the path filename is tested here console.log("trailingURL::", lastPartUrl); - if (lastPartUrl.match(/^version\.json(?:\?t=\d+)?$/) !== null) { - return cb(null, request); + if (lastPartUrl.match(/^version\.json$/) !== null) { + return request; } if (locale === "" || locale === "index.html" || !locales.includes(locale)) { request.uri = "/en-US/index.html"; console.log("Go to default page and locale."); - return cb(null, request); + return request; } const fileExt = path.extname(lastPartUrl); - if (!fileExt) request.uri = `/${locale}/index.html`; + if (!fileExt) { + request.uri = `/browser/${locale}/index.html`; + } else if (!request.uri.startsWith('/browser/')) { + // rewrite static asset paths to match Angular 19+ application builder output + request.uri = `/browser${request.uri}`; + } console.log(`New Uri: ${request.uri}`); - return cb(null, request); + return request; }; diff --git a/lambda/forwarder/template.yml b/lambda/forwarder/template.yml index 27d047891..f4946fa60 100644 --- a/lambda/forwarder/template.yml +++ b/lambda/forwarder/template.yml @@ -37,7 +37,7 @@ Resources: Properties: Handler: index.handler CodeUri: ./bin/handler.zip - Runtime: nodejs18.x + Runtime: nodejs22.x Timeout: 10 Role: !GetAtt HandlerRole.Arn HandlerVersion: @@ -53,4 +53,4 @@ Outputs: Description: "Arn Version for Lambda function to associate unto CDN" Value: !GetAtt HandlerVersion.FunctionArn Export: - Name: !Sub "${StackName}-HandlerVersion-${Env}" \ No newline at end of file + Name: !Sub "${StackName}-HandlerVersion-${Env}" diff --git a/lambda/versioner/template.yml b/lambda/versioner/template.yml index cd5bc793e..fc33233b6 100644 --- a/lambda/versioner/template.yml +++ b/lambda/versioner/template.yml @@ -19,7 +19,7 @@ Resources: Properties: Handler: index.handler CodeUri: ./bin/handler.zip - Runtime: nodejs18.x + Runtime: nodejs22.x Timeout: 10 Role: !GetAtt LambdaVersionHelperRole.Arn LambdaVersionHelperRole: @@ -51,4 +51,4 @@ Outputs: Description: "Lambda Function Versioner ARN" Value: !GetAtt Versioner.Arn Export: - Name: !Sub "${StackName}-LambdaVersionerArn-${Env}" \ No newline at end of file + Name: !Sub "${StackName}-LambdaVersionerArn-${Env}" diff --git a/package.json b/package.json index 2117ebc0a..35bc57d9b 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,6 @@ "core-js": "^3.21.1", "dayjs": "^1.11.10", "exif-js": "^2.3.0", - "filestack-js": "^3.30.0", "franc-min": "^6.2.0", "graphql": "^16.8.1", "ics": "^3.7.2", diff --git a/projects/v3/src/app/components/activity/activity.component.scss b/projects/v3/src/app/components/activity/activity.component.scss index e78b2ea36..b3514919a 100644 --- a/projects/v3/src/app/components/activity/activity.component.scss +++ b/projects/v3/src/app/components/activity/activity.component.scss @@ -45,6 +45,6 @@ } .focusable:focus { - border: 1px solid var(--ion-color-primary); - display: flex; + outline: 2px solid var(--ion-color-primary); + outline-offset: -2px; } diff --git a/projects/v3/src/app/components/components.module.ts b/projects/v3/src/app/components/components.module.ts index 85a35cfa9..23997d3fc 100644 --- a/projects/v3/src/app/components/components.module.ts +++ b/projects/v3/src/app/components/components.module.ts @@ -18,8 +18,7 @@ import { FastFeedbackComponent } from './fast-feedback/fast-feedback.component'; import { ReviewRatingComponent } from './review-rating/review-rating.component'; import { CircleProgressComponent } from './circle-progress/circle-progress.component'; import { NgCircleProgressModule } from 'ng-circle-progress'; -import { FilestackComponent } from './filestack/filestack.component'; -import { FilestackPreviewComponent } from './filestack-preview/filestack-preview.component'; +import { FilePreviewComponent } from './file-preview/file-preview.component'; import { ContactNumberFormComponent } from './contact-number-form/contact-number-form.component'; import { ClickableItemComponent } from './clickable-item/clickable-item.component'; import { AssessmentComponent } from './assessment/assessment.component'; @@ -90,8 +89,7 @@ const largeCircleDefaultConfig = { FilePopupComponent, FileDisplayComponent, VideoConversionComponent, - FilestackComponent, - FilestackPreviewComponent, + FilePreviewComponent, ImgComponent, ListItemComponent, LockTeamAssessmentPopUpComponent, @@ -136,8 +134,7 @@ const largeCircleDefaultConfig = { FilePopupComponent, FileDisplayComponent, VideoConversionComponent, - FilestackComponent, - FilestackPreviewComponent, + FilePreviewComponent, ImgComponent, IonicModule, ListItemComponent, diff --git a/projects/v3/src/app/components/file-display/file-display.component.spec.ts b/projects/v3/src/app/components/file-display/file-display.component.spec.ts index 611fd6ebb..6b41b7e9d 100644 --- a/projects/v3/src/app/components/file-display/file-display.component.spec.ts +++ b/projects/v3/src/app/components/file-display/file-display.component.spec.ts @@ -2,7 +2,6 @@ import { CUSTOM_ELEMENTS_SCHEMA, SimpleChange, DebugElement } from '@angular/core'; import { ComponentFixture, TestBed, fakeAsync, flushMicrotasks, waitForAsync, tick } from '@angular/core/testing'; import { FileDisplayComponent } from './file-display.component'; -import { FilestackService } from '@v3/services/filestack.service'; import { ReactiveFormsModule, FormControl } from '@angular/forms'; import { UtilsService } from '@v3/services/utils.service'; import { TestUtils } from '@testingv3/utils'; @@ -19,7 +18,6 @@ class OnChangedValues extends SimpleChange { describe('FileDisplayComponent', () => { let component: FileDisplayComponent; let fixture: ComponentFixture; - let filestackSpy: jasmine.SpyObj; let utilsSpy: jasmine.SpyObj; beforeEach(waitForAsync(() => { @@ -32,14 +30,6 @@ describe('FileDisplayComponent', () => { provide: UtilsService, useClass: TestUtils, }, - { - provide: FilestackService, - useValue: jasmine.createSpyObj('FilestackService', [ - 'previewFile', - 'getWorkflowStatus', - 'metadata' - ]) - }, { provide: ModalController, useValue: jasmine.createSpyObj('ModalController', { @@ -55,7 +45,6 @@ describe('FileDisplayComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(FileDisplayComponent); component = fixture.debugElement.componentInstance; - filestackSpy = TestBed.inject(FilestackService) as jasmine.SpyObj; utilsSpy = TestBed.inject(UtilsService) as jasmine.SpyObj; }); diff --git a/projects/v3/src/app/components/file-display/file-display.component.ts b/projects/v3/src/app/components/file-display/file-display.component.ts index 79f9a9155..4d3a91372 100644 --- a/projects/v3/src/app/components/file-display/file-display.component.ts +++ b/projects/v3/src/app/components/file-display/file-display.component.ts @@ -11,8 +11,8 @@ import { FileInput, TusFileResponse } from '../types/assessment'; import { FilePopupComponent } from '../file-popup/file-popup.component'; import { ModalController } from '@ionic/angular'; -// @TODO: make compatible with FileStack format (remove when no longer needed) -interface FileStackCompatible extends TusFileResponse { +// backward-compatible file type that includes legacy property names +interface DisplayableFile extends TusFileResponse { filename: string; mimetype: string; url: string; @@ -26,7 +26,7 @@ interface FileStackCompatible extends TusFileResponse { }) export class FileDisplayComponent { @Input() fileType: string = 'any'; - @Input() file?: FileStackCompatible; + @Input() file?: DisplayableFile; @Input() isFileComponent: boolean = false; // flag parent component is FileComponent @ViewChild('videoEle') videoEle?: ElementRef = new ElementRef(null); @Output() removeFile: EventEmitter = new EventEmitter(); diff --git a/projects/v3/src/app/components/filestack-preview/filestack-preview.component.html b/projects/v3/src/app/components/file-preview/file-preview.component.html similarity index 100% rename from projects/v3/src/app/components/filestack-preview/filestack-preview.component.html rename to projects/v3/src/app/components/file-preview/file-preview.component.html diff --git a/projects/v3/src/app/components/filestack-preview/filestack-preview.component.scss b/projects/v3/src/app/components/file-preview/file-preview.component.scss similarity index 58% rename from projects/v3/src/app/components/filestack-preview/filestack-preview.component.scss rename to projects/v3/src/app/components/file-preview/file-preview.component.scss index 8989bbe97..7d784e672 100644 --- a/projects/v3/src/app/components/filestack-preview/filestack-preview.component.scss +++ b/projects/v3/src/app/components/file-preview/file-preview.component.scss @@ -7,6 +7,6 @@ ion-header { } .focusable:focus { - border: 1px solid var(--ion-color-primary); - display: flex; + outline: 2px solid var(--ion-color-primary); + outline-offset: -2px; } diff --git a/projects/v3/src/app/components/filestack-preview/filestack-preview.component.spec.ts b/projects/v3/src/app/components/file-preview/file-preview.component.spec.ts similarity index 86% rename from projects/v3/src/app/components/filestack-preview/filestack-preview.component.spec.ts rename to projects/v3/src/app/components/file-preview/file-preview.component.spec.ts index 5fd60f4e2..11ad06e4d 100644 --- a/projects/v3/src/app/components/filestack-preview/filestack-preview.component.spec.ts +++ b/projects/v3/src/app/components/file-preview/file-preview.component.spec.ts @@ -1,5 +1,5 @@ import { TestBed, ComponentFixture } from '@angular/core/testing'; -import { FilestackPreviewComponent } from './filestack-preview.component'; +import { FilePreviewComponent } from './file-preview.component'; import { IonicModule, ModalController } from '@ionic/angular'; import { DomSanitizer } from '@angular/platform-browser'; import { CommonModule } from '@angular/common'; @@ -8,17 +8,17 @@ import { HttpClientTestingModule } from '@angular/common/http/testing'; -describe('FilestackPreviewComponent', () => { +describe('FilePreviewComponent', () => { const TEST_URL = 'https://www.practera.com'; - let component: FilestackPreviewComponent; - let fixture: ComponentFixture; + let component: FilePreviewComponent; + let fixture: ComponentFixture; let modalSpy: ModalController; let domSanitizerSpy: DomSanitizer; beforeEach(async () => { TestBed.configureTestingModule({ imports: [ IonicModule, CommonModule, HttpClientTestingModule ], - declarations: [ FilestackPreviewComponent ], + declarations: [ FilePreviewComponent ], providers: [ ModalController, { @@ -32,7 +32,7 @@ describe('FilestackPreviewComponent', () => { ], }); - fixture = TestBed.createComponent(FilestackPreviewComponent); + fixture = TestBed.createComponent(FilePreviewComponent); component = fixture.componentInstance; modalSpy = TestBed.inject(ModalController); domSanitizerSpy = TestBed.inject(DomSanitizer); diff --git a/projects/v3/src/app/components/filestack-preview/filestack-preview.component.ts b/projects/v3/src/app/components/file-preview/file-preview.component.ts similarity index 71% rename from projects/v3/src/app/components/filestack-preview/filestack-preview.component.ts rename to projects/v3/src/app/components/file-preview/file-preview.component.ts index b4bdfd6bf..d8681a790 100644 --- a/projects/v3/src/app/components/filestack-preview/filestack-preview.component.ts +++ b/projects/v3/src/app/components/file-preview/file-preview.component.ts @@ -4,11 +4,11 @@ import { DomSanitizer } from '@angular/platform-browser'; @Component({ standalone: false, - selector: 'app-filestack-preview', - templateUrl: './filestack-preview.component.html', - styleUrls: ['filestack-preview.component.scss'] + selector: 'app-file-preview', + templateUrl: './file-preview.component.html', + styleUrls: ['file-preview.component.scss'] }) -export class FilestackPreviewComponent { +export class FilePreviewComponent { url = ''; file: any = {}; diff --git a/projects/v3/src/app/components/filestack/filestack.component.html b/projects/v3/src/app/components/filestack/filestack.component.html deleted file mode 100644 index e8f31fa86..000000000 --- a/projects/v3/src/app/components/filestack/filestack.component.html +++ /dev/null @@ -1,94 +0,0 @@ - - - - Upload Profile Image - - - - - -
- Upload file(s) - UPLOAD -
-
- -
-

-   - Drag and drop file here or - UPLOAD FILE -

-
-
- - - - - - - - - - - - - - - - - - - -

{{ uploadingFile.fileName }}

-
- - - - - -
- - - - - - - - -

{{ uploadingFile.uploadSize }} of {{ uploadingFile.fileSize }} -

-
- -

{{ uploadingFile.uploadProgress }}%

-
-
-
-
-
-
-
-
-
-
diff --git a/projects/v3/src/app/components/filestack/filestack.component.scss b/projects/v3/src/app/components/filestack/filestack.component.scss deleted file mode 100644 index 07b2270f4..000000000 --- a/projects/v3/src/app/components/filestack/filestack.component.scss +++ /dev/null @@ -1,68 +0,0 @@ - .upload-button { - max-width: 200px; - // margin: auto; - } - - .drop-zone { - height: 6em; - display: flex; - align-items: center; - justify-content: center; - text-align: center; - border: dashed 1px var(--practera-30-percent-gray); - position: relative; - margin: 0 auto; - vertical-align: -webkit-baseline-middle; - border-radius: 12px; - p { - ion-icon { - vertical-align: middle; - } - ion-button { - vertical-align: middle; - } - } - } - - .fileover { - border: 2px solid var(--ion-color-primary); - } - - .upload-progress { - --ion-grid-column-padding: 0; - .fileTypeIcon { - text-align: center; - ion-icon { - padding: 10px; - } - } - .file-size-container, .file-name-container { - text-align: left; - } - .file-name-container { - p { - margin: 0; - height: 29px; - padding-bottom: 4px; - padding-top: 4px; - } - } - .file-progress-container, .cancel-btn-container { - text-align: right; - } - .size-and-progress { - margin-top: 10px; - } - .cancel-btn { - --padding-start: 0; - --padding-end: 0; - } - } - - ion-icon { - font-size: 24px; - } - - ion-button.ion-focused { - border: 1px solid var(--ion-color-primary); - } diff --git a/projects/v3/src/app/components/filestack/filestack.component.spec.ts b/projects/v3/src/app/components/filestack/filestack.component.spec.ts deleted file mode 100644 index 449e49208..000000000 --- a/projects/v3/src/app/components/filestack/filestack.component.spec.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { CUSTOM_ELEMENTS_SCHEMA, DebugElement } from '@angular/core'; -import { IonicModule } from '@ionic/angular'; -import { TestBed, ComponentFixture, fakeAsync, flushMicrotasks } from '@angular/core/testing'; -import { FilestackComponent } from './filestack.component'; -import { FilestackService } from '@v3/services/filestack.service'; -import { UtilsService } from '@v3/app/services/utils.service'; -import { TestUtils } from '@testingv3/utils'; - -describe('FilestackComponent', () => { - let component: FilestackComponent; - let fixture: ComponentFixture; - let filestackSpy: FilestackService; - let utilsSpy: jasmine.SpyObj; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [IonicModule], - declarations: [FilestackComponent], - schemas: [CUSTOM_ELEMENTS_SCHEMA], - providers: [ - { - provide: UtilsService, - useClass: TestUtils, - }, - { - provide: FilestackService, - useValue: jasmine.createSpyObj(['open', 'getS3Config']) - } - ], - }).compileComponents(); - fixture = TestBed.createComponent(FilestackComponent); - component = fixture.debugElement.componentInstance; - filestackSpy = TestBed.inject(FilestackService); - utilsSpy = TestBed.inject(UtilsService) as jasmine.SpyObj; - }); - - it('should create the filestack component', () => { - expect(component).toBeTruthy(); - }); - - describe('uploadFile()', () => { - it('should display "Upload File" by default (and not "profileImage")', () => { - component.type = 'anything'; - fixture.detectChanges(); - const button = fixture.nativeElement.querySelector('ion-button'); - - expect(button.textContent).toEqual('UPLOAD FILE'); - }); - - it('should allow upload profile picture', () => { - utilsSpy.isMobile.and.returnValue(false); - component.type = 'profileImage'; - fixture.detectChanges(); - - const button: HTMLElement = fixture.nativeElement.querySelector('ion-button'); - - expect(button.classList).toContain('upload'); - }); - - it('should open filestack fileupload window', fakeAsync(() => { - const respond = { fileupload: true }; - let result; - - filestackSpy.open = jasmine.createSpy('open').and.returnValue(Promise.resolve(respond)); - - component.uploadFile().then(data => { - result = data; - }); - flushMicrotasks(); - - expect(filestackSpy.getS3Config).toHaveBeenCalled(); - expect(filestackSpy.open).toHaveBeenCalled(); - })); - }); -}); diff --git a/projects/v3/src/app/components/filestack/filestack.component.ts b/projects/v3/src/app/components/filestack/filestack.component.ts deleted file mode 100644 index b5f78b76b..000000000 --- a/projects/v3/src/app/components/filestack/filestack.component.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { Component, EventEmitter, Output, Input } from '@angular/core'; -import { FilestackService } from '@v3/services/filestack.service'; -import { UtilsService } from '@v3/services/utils.service'; -import { PickerOptions } from 'filestack-js/build/main/lib/picker'; - -export interface FilestackUploaded { - handle: string; - url: string; - filename: string; - size: number; - mimetype: string; - key: string; - container: string; - status: string; - workflow?: object; - - // flag to indicate detected virus (true: threat found, false: safe from virus/malware) - infected?: boolean; - - // list of infected files - infections_list?: string[]; -} - -@Component({ - standalone: false, - selector: 'app-file-stack', - templateUrl: 'filestack.component.html', - styleUrls: ['filestack.component.scss'] -}) -export class FilestackComponent { - @Input() accept: any; - @Input() fileType: string; - @Input() type?: string; - @Input() disabled: boolean; - - // upon fileupload success - @Output() uploadCompleted: EventEmitter = new EventEmitter(); - - uploadingFile = { - uploadProgress: 0, - fileName: '', - fileSize: '', - uploadSize: '' - }; - isDroped: boolean; - uploadToken: any; - - constructor( - private filestackService: FilestackService, - private readonly utils: UtilsService - ) { } - - async uploadFile(keyboardEvent?: KeyboardEvent) { - if (keyboardEvent && (keyboardEvent?.code === 'Space' || keyboardEvent?.code === 'Enter')) { - keyboardEvent.preventDefault(); - } else if (keyboardEvent) { - return; - } - - const s3Config = this.filestackService.getS3Config(this.fileType); - const pickerOptions: PickerOptions = { - storeTo: s3Config, - onFileUploadFailed: data => { - this.uploadCompleted.emit({ - success: false, - data: data - }); - }, - onFileUploadFinished: data => { - this.uploadCompleted.emit({ - success: true, - data: data - }); - }, - onOpen: () => { - setTimeout(() => { - const eles = document.getElementsByClassName('fsp-picker__close-button'); - if (eles.length > 0) { - (eles[0] as HTMLElement).focus(); - } - }, 850); - }, - }; - - if (this.accept) { - pickerOptions['accept'] = this.accept; - } - - try { - const res = await this.filestackService.open(pickerOptions); - return res; - } catch (err) { - throw new Error(err); - } - } - - async dragAndDropUpload(dropData) { - if (dropData.success) { - this.isDroped = true; - this.uploadingFile.fileName = dropData.file.name; - this.uploadingFile.fileSize = this._bytesToSize(dropData.file.size); - const s3Config = this.filestackService.getS3Config(this.fileType); - this.uploadToken = {}; - const uploadOptions = { - onProgress: progressData => { - this.uploadingFile.uploadProgress = progressData.totalPercent; - this.uploadingFile.uploadSize = this._bytesToSize(progressData.totalBytes); - }, - onFileUploadFinished: fileData => { - this.uploadCompleted.emit({ - success: true, - data: fileData - }); - }, - onFileUploadFailed: err => { - this.uploadCompleted.emit({ - success: false, - data: err - }); - } - }; - - await this.filestackService.upload(dropData.file, uploadOptions, s3Config, this.uploadToken); - } else { - this.isDroped = false; - this.uploadCompleted.emit({ - success: false, - data: { - message: dropData.message, - isDragAndDropError: true - } - }); - } - } - - cancelFileUpload() { - if (this.uploadToken) { - this.uploadToken.cancel(); - this.isDroped = false; - this.uploadingFile = { - uploadProgress: 0, - fileName: '', - fileSize: '', - uploadSize: '' - }; - } - } - - private _bytesToSize(bytes) { - if (bytes) { - const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; - const i = Math.min(parseInt(Math.floor(Math.log(bytes) / Math.log(1024)).toString(), 10), sizes.length - 1); - return `${(bytes / (1024 ** i)).toFixed(i ? 1 : 0)} ${sizes[i]}`; - } - return '0'; - } - - get isMobile(): boolean { - return this.utils.isMobile(); - } -} diff --git a/projects/v3/src/app/components/support-popup/support-popup.component.html b/projects/v3/src/app/components/support-popup/support-popup.component.html index e6df1448d..16eb284dd 100644 --- a/projects/v3/src/app/components/support-popup/support-popup.component.html +++ b/projects/v3/src/app/components/support-popup/support-popup.component.html @@ -63,7 +63,7 @@ (click)="uploadFile()" >Choose file -

{{ selectedFile ? selectedFile?.filename : 'No file chosen' }}

+

{{ selectedFile ? selectedFile?.name : 'No file chosen' }}

diff --git a/projects/v3/src/app/components/support-popup/support-popup.component.spec.ts b/projects/v3/src/app/components/support-popup/support-popup.component.spec.ts index f97813dbd..3e029d564 100644 --- a/projects/v3/src/app/components/support-popup/support-popup.component.spec.ts +++ b/projects/v3/src/app/components/support-popup/support-popup.component.spec.ts @@ -4,7 +4,7 @@ import { of, throwError } from 'rxjs'; import { SupportPopupComponent } from './support-popup.component'; import { HubspotService } from '@v3/services/hubspot.service'; -import { FilestackService } from '@v3/app/services/filestack.service'; +import { UppyUploaderService } from '@v3/services/uppy-uploader.service'; import { UtilsService } from '@v3/services/utils.service'; import { NotificationsService } from '@v3/app/services/notifications.service'; @@ -26,7 +26,7 @@ describe('SupportPopupComponent', () => { let fixture: ComponentFixture; let modalSpy: jasmine.SpyObj; let notificationSpy: jasmine.SpyObj; - let filestackSpy: jasmine.SpyObj; + let uppyUploaderSpy: jasmine.SpyObj; let hubspotSpy: jasmine.SpyObj; beforeEach(async () => { @@ -35,7 +35,7 @@ describe('SupportPopupComponent', () => { imports: [IonicModule.forRoot()], providers: [ { provide: HubspotService, useValue: jasmine.createSpyObj('HubspotService', ['submitDataToHubspot']) }, - { provide: FilestackService, useValue: jasmine.createSpyObj('FilestackService', ['deleteFile', 'open', 'getS3Config']) }, + { provide: UppyUploaderService, useValue: jasmine.createSpyObj('UppyUploaderService', ['open']) }, { provide: UtilsService, useClass: UtilsServiceMock }, { provide: NotificationsService, useClass: NotificationsServiceMock }, { provide: ModalController, useValue: jasmine.createSpyObj('ModalController', ['dismiss', 'getTop']) }, @@ -46,7 +46,7 @@ describe('SupportPopupComponent', () => { component = fixture.componentInstance; modalSpy = TestBed.inject(ModalController) as jasmine.SpyObj; notificationSpy = TestBed.inject(NotificationsService) as jasmine.SpyObj; - filestackSpy = TestBed.inject(FilestackService) as jasmine.SpyObj; + uppyUploaderSpy = TestBed.inject(UppyUploaderService) as jasmine.SpyObj; hubspotSpy = TestBed.inject(HubspotService) as jasmine.SpyObj; fixture.detectChanges(); }); @@ -191,9 +191,7 @@ describe('SupportPopupComponent', () => { }); describe('removeSelectedFile', () => { - it('should remove the selected file and call deleteFile with the file handle', fakeAsync(() => { - filestackSpy.deleteFile = jasmine.createSpy().and.returnValue(of({})); - + it('should clear the selected file', fakeAsync(() => { component.selectedFile = { bucket: 'test-bucket', path: 'test-path', @@ -202,19 +200,17 @@ describe('SupportPopupComponent', () => { extension: 'jpg', type: 'image/jpeg', size: 1000, - handle: 'abc123' }; component.removeSelectedFile(); flushMicrotasks(); - expect(filestackSpy.deleteFile).toHaveBeenCalledWith('abc123'); expect(component.selectedFile).toBeUndefined(); })); }); describe('uploadFile', () => { - it('should call FilestackService open method and set the selectedFile on upload finished', fakeAsync(() => { - const mockResponse = { + it('should open uppy uploader and set selectedFile on dismiss with data', fakeAsync(() => { + const mockFile = { bucket: 'test-bucket', path: 'test-path', name: 'test.jpg', @@ -222,53 +218,54 @@ describe('SupportPopupComponent', () => { extension: 'jpg', type: 'image/jpeg', size: 1000, - handle: 'abc123', - filename: 'test.jpg' }; - filestackSpy.open = jasmine.createSpy().and.callFake(options => { - return options.onFileUploadFinished(mockResponse); - }); + uppyUploaderSpy.open.and.returnValue(Promise.resolve({ + onDidDismiss: () => Promise.resolve({ data: mockFile, role: 'confirm' }), + } as any)); component.uploadFile(); flushMicrotasks(); - expect(filestackSpy.open).toHaveBeenCalled(); - expect(component.selectedFile).toEqual(mockResponse); + expect(uppyUploaderSpy.open).toHaveBeenCalledWith('any'); + expect(component.selectedFile).toEqual(mockFile); })); - it('should handle file upload failure and not set selectedFile', fakeAsync(() => { - filestackSpy.open = jasmine.createSpy().and.callFake(options => { - return options.onFileUploadFailed('Error'); - }); + it('should not set selectedFile when uppy modal is dismissed without data', fakeAsync(() => { + uppyUploaderSpy.open.and.returnValue(Promise.resolve({ + onDidDismiss: () => Promise.resolve({ data: undefined, role: 'cancel' }), + } as any)); component.uploadFile(); flushMicrotasks(); - expect(filestackSpy.open).toHaveBeenCalled(); + expect(uppyUploaderSpy.open).toHaveBeenCalledWith('any'); expect(component.selectedFile).toBeUndefined(); })); - it('should call FilestackService open method when keyboard event is Enter or Space', fakeAsync(() => { - filestackSpy.open = jasmine.createSpy().and.callThrough(); + it('should open uppy uploader when keyboard event is Enter or Space', fakeAsync(() => { + uppyUploaderSpy.open.and.returnValue(Promise.resolve({ + onDidDismiss: () => Promise.resolve({ data: undefined, role: 'cancel' }), + } as any)); const enterEvent = new KeyboardEvent('keydown', { code: 'Enter' }); const spaceEvent = new KeyboardEvent('keydown', { code: 'Space' }); component.uploadFile(enterEvent); + flushMicrotasks(); component.uploadFile(spaceEvent); flushMicrotasks(); - expect(filestackSpy.open).toHaveBeenCalledTimes(2); + expect(uppyUploaderSpy.open).toHaveBeenCalledTimes(2); })); - it('should not call FilestackService open method when keyboard event is not Enter or Space', fakeAsync(() => { + it('should not open uppy uploader when keyboard event is not Enter or Space', fakeAsync(() => { const escapeEvent = new KeyboardEvent('keydown', { code: 'Escape' }); component.uploadFile(escapeEvent); flushMicrotasks(); - expect(filestackSpy.open).not.toHaveBeenCalled(); + expect(uppyUploaderSpy.open).not.toHaveBeenCalled(); })); }); diff --git a/projects/v3/src/app/components/support-popup/support-popup.component.ts b/projects/v3/src/app/components/support-popup/support-popup.component.ts index 71238ef00..2e364e9be 100644 --- a/projects/v3/src/app/components/support-popup/support-popup.component.ts +++ b/projects/v3/src/app/components/support-popup/support-popup.component.ts @@ -1,9 +1,8 @@ -import * as filestack from 'filestack-js'; import { Component, Inject, Input, OnInit, forwardRef } from '@angular/core'; import { supportQuestionList } from './support-questions'; import { ModalController } from '@ionic/angular'; import { HubspotService, HubspotFormParams } from '@v3/services/hubspot.service'; -import { FilestackService } from '@v3/app/services/filestack.service'; +import { UppyUploaderService, UppyFileData } from '../uppy-uploader/uppy-uploader.service'; import { UtilsService } from '@v3/services/utils.service'; import { NotificationsService } from '@v3/app/services/notifications.service'; @@ -14,13 +13,12 @@ import { NotificationsService } from '@v3/app/services/notifications.service'; styleUrls: ['./support-popup.component.scss'], }) export class SupportPopupComponent implements OnInit { - protected filestack = filestack.Client; isShowForm: boolean = false; isShowSuccess: boolean = false; isShowError: boolean = false; isShowRequiredError: boolean = false; questionList = supportQuestionList; - selectedFile: any; + selectedFile: UppyFileData; problemSubject: string; problemContent: string; @Input() isShowFormOnly?: boolean; @@ -29,7 +27,7 @@ export class SupportPopupComponent implements OnInit { constructor( private modalController: ModalController, @Inject(forwardRef(() => HubspotService)) private hubspotService: HubspotService, - private filestackService: FilestackService, + private uppyUploaderService: UppyUploaderService, private utilService: UtilsService, private notificationsService: NotificationsService, ) { } @@ -68,11 +66,6 @@ export class SupportPopupComponent implements OnInit { { text: 'Leave', handler: () => { - // if fileupload has been initiated earlier, delete the file from filestack - if (this.selectedFile) { - this.filestackService.deleteFile(this.selectedFile.handle).toPromise(); - } - this.modalController.dismiss({ isPristine: this.isPristine() }); @@ -92,7 +85,6 @@ export class SupportPopupComponent implements OnInit { } async removeSelectedFile() { - await this.filestackService.deleteFile(this.selectedFile.handle).toPromise(); this.selectedFile = undefined; } @@ -103,28 +95,13 @@ export class SupportPopupComponent implements OnInit { return; } - const pickerOptions: filestack.PickerOptions = { - storeTo: this.filestackService.getS3Config('any'), - onFileUploadFailed: data => { - this.selectedFile = undefined; - }, - onFileUploadFinished: data => { - this.selectedFile = data; - }, - onOpen: () => { // for accessibility - setTimeout(() => { - const eles = document.getElementsByClassName('fsp-picker__close-button'); - if (eles.length > 0) { - (eles[0] as HTMLElement).focus(); - } - }, 850); - }, - }; - try { - - const res = await this.filestackService.open(pickerOptions); - return res; + const modal = await this.uppyUploaderService.open('any'); + const res = await modal.onDidDismiss(); + const data: UppyFileData = res.data; + if (data) { + this.selectedFile = data; + } } catch (err) { throw new Error(err); } diff --git a/projects/v3/src/app/components/todo-card/todo-card.component.scss b/projects/v3/src/app/components/todo-card/todo-card.component.scss index 0a0d98c50..1b5aa51eb 100644 --- a/projects/v3/src/app/components/todo-card/todo-card.component.scss +++ b/projects/v3/src/app/components/todo-card/todo-card.component.scss @@ -10,7 +10,7 @@ } .focusable:focus { - display: block; - border: 1px solid; + outline: 2px solid var(--ion-color-primary); + outline-offset: -2px; } diff --git a/projects/v3/src/app/components/topic/topic.component.spec.ts b/projects/v3/src/app/components/topic/topic.component.spec.ts index 88df682f3..ea4b6118b 100644 --- a/projects/v3/src/app/components/topic/topic.component.spec.ts +++ b/projects/v3/src/app/components/topic/topic.component.spec.ts @@ -4,7 +4,7 @@ import { ComponentFixture, TestBed, fakeAsync, tick, flushMicrotasks } from '@an import { Router } from '@angular/router'; import { TopicComponent } from './topic.component'; import { TopicService } from '@v3/services/topic.service'; -import { FilestackService } from '@v3/services/filestack.service'; +import { FilePreviewService } from '@v3/services/file-preview.service'; import { ActivatedRouteStub } from '@testingv3/activated-route-stub'; import { NotificationsService } from '@v3/services/notifications.service'; import { BrowserStorageService } from '@v3/services/storage.service'; @@ -21,7 +21,7 @@ describe('TopicComponent', () => { let component: TopicComponent; let fixture: ComponentFixture; let topicSpy: jasmine.SpyObj; - let filestackSpy: jasmine.SpyObj; + let filePreviewSpy: jasmine.SpyObj; let embedSpy: jasmine.SpyObj; let sharedSpy: jasmine.SpyObj; let routerSpy: jasmine.SpyObj; @@ -32,7 +32,7 @@ describe('TopicComponent', () => { beforeEach(async () => { topicSpy = jasmine.createSpyObj('TopicService', ['getTopic', 'getTopicProgress', 'updateTopicProgress', 'clearTopic']); - filestackSpy = jasmine.createSpyObj('FilestackService', ['previewFile']); + filePreviewSpy = jasmine.createSpyObj('FilePreviewService', ['preview']); embedSpy = jasmine.createSpyObj('EmbedVideoService', ['embed']); embedSpy.embed.and.returnValue(''); // return valid embed html sharedSpy = jasmine.createSpyObj('SharedService', ['stopPlayingVideos']); @@ -47,7 +47,7 @@ describe('TopicComponent', () => { schemas: [CUSTOM_ELEMENTS_SCHEMA], providers: [ { provide: TopicService, useValue: topicSpy }, - { provide: FilestackService, useValue: filestackSpy }, + { provide: FilePreviewService, useValue: filePreviewSpy }, { provide: EmbedVideoService, useValue: embedSpy }, { provide: Router, useValue: routerSpy }, { provide: NotificationsService, useValue: notificationSpy }, @@ -163,7 +163,7 @@ describe('TopicComponent', () => { it('should load file successfully', fakeAsync(() => { const SAMPLE_RESULT = 'SAMPLE'; let result: any; - filestackSpy.previewFile.and.returnValue(Promise.resolve(SAMPLE_RESULT)); + filePreviewSpy.preview.and.returnValue(Promise.resolve(SAMPLE_RESULT)); component.isLoadingPreview = false; component.previewFile('').then(res => result = res); @@ -178,7 +178,7 @@ describe('TopicComponent', () => { const SAMPLE_RESULT = 'FAILED_SAMPLE'; let result: any; notificationSpy.alert.and.returnValue(Promise.resolve(SAMPLE_RESULT as any)); - filestackSpy.previewFile.and.rejectWith(new Error('File preview test error')); + filePreviewSpy.preview.and.rejectWith(new Error('File preview test error')); component.isLoadingPreview = false; component.previewFile('').then(res => result = res); diff --git a/projects/v3/src/app/components/topic/topic.component.ts b/projects/v3/src/app/components/topic/topic.component.ts index 914050731..afc4d8ce8 100644 --- a/projects/v3/src/app/components/topic/topic.component.ts +++ b/projects/v3/src/app/components/topic/topic.component.ts @@ -6,7 +6,7 @@ import { SharedService } from '@v3/services/shared.service'; import Plyr from 'plyr'; import { EmbedVideoService } from '@v3/services/ngx-embed-video.service'; import { SafeHtml, DomSanitizer } from '@angular/platform-browser'; -import { FilestackService } from '@v3/app/services/filestack.service'; +import { FilePreviewService } from '@v3/app/services/file-preview.service'; import { NotificationsService } from '@v3/app/services/notifications.service'; import { BehaviorSubject, exhaustMap, filter, finalize, Subject, Subscription, takeUntil } from 'rxjs'; import { Task } from '@v3/app/services/activity.service'; @@ -50,7 +50,7 @@ export class TopicComponent implements OnInit, OnChanges, AfterViewChecked, OnDe private notification: NotificationsService, private utils: UtilsService, private sharedService: SharedService, - private filestack: FilestackService, + private filePreviewService: FilePreviewService, private topicService: TopicService, private sanitizer: DomSanitizer, private cleanupService: ComponentCleanupService, @@ -285,9 +285,9 @@ export class TopicComponent implements OnInit, OnChanges, AfterViewChecked, OnDe this.isLoadingPreview = true; try { - const filestack = await this.filestack.previewFile(file); + const result = await this.filePreviewService.preview(file); this.isLoadingPreview = false; - return filestack; + return result; } catch (err) { const toasted = await this.notification.alert({ header: 'Error Previewing file', diff --git a/projects/v3/src/app/components/uppy-uploader/uppy-uploader.service.ts b/projects/v3/src/app/components/uppy-uploader/uppy-uploader.service.ts index 649529ace..199dd79a0 100644 --- a/projects/v3/src/app/components/uppy-uploader/uppy-uploader.service.ts +++ b/projects/v3/src/app/components/uppy-uploader/uppy-uploader.service.ts @@ -199,7 +199,7 @@ export class UppyUploaderService { * @param {string} source * @return {Promise} */ - async open(source: 'chat' | 'user-profile' | 'assessment' | 'media-manager' | 'static' | null): Promise { + async open(source: 'chat' | 'user-profile' | 'assessment' | 'media-manager' | 'static' | 'any' | 'image' | 'video' | null): Promise { // dynamic import to break circular dependency with UppyUploaderComponent const { UppyUploaderComponent } = await import('./uppy-uploader.component'); const modal = await this.modalController.create({ diff --git a/projects/v3/src/app/components/video-conversion/video-conversion.component.html b/projects/v3/src/app/components/video-conversion/video-conversion.component.html index e9cd10abe..9149073ca 100644 --- a/projects/v3/src/app/components/video-conversion/video-conversion.component.html +++ b/projects/v3/src/app/components/video-conversion/video-conversion.component.html @@ -1,6 +1,6 @@
diff --git a/projects/v3/src/app/components/video-conversion/video-conversion.component.spec.ts b/projects/v3/src/app/components/video-conversion/video-conversion.component.spec.ts index 4e1221291..1da48ee5a 100644 --- a/projects/v3/src/app/components/video-conversion/video-conversion.component.spec.ts +++ b/projects/v3/src/app/components/video-conversion/video-conversion.component.spec.ts @@ -1,13 +1,13 @@ import { CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from "@angular/core/testing"; -import { FilestackService } from "@v3/app/services/filestack.service"; +import { FilePreviewService } from "@v3/app/services/file-preview.service"; import { of, Subject } from "rxjs"; import { VideoConversionComponent } from "./video-conversion.component"; describe('VideoConversionComponent', () => { let component: VideoConversionComponent; let fixture: ComponentFixture; - let filestackSpy: FilestackService; + let filePreviewSpy: jasmine.SpyObj; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ @@ -16,10 +16,9 @@ describe('VideoConversionComponent', () => { schemas: [CUSTOM_ELEMENTS_SCHEMA], providers: [ { - provide: FilestackService, - useValue: jasmine.createSpyObj('FilestackService', { - 'videoConversion': of({ status: 'completed' }), - 'previewModal': of(), + provide: FilePreviewService, + useValue: jasmine.createSpyObj('FilePreviewService', { + 'openModal': Promise.resolve(), }), }, ], @@ -27,7 +26,7 @@ describe('VideoConversionComponent', () => { fixture = TestBed.createComponent(VideoConversionComponent); component = fixture.componentInstance; - filestackSpy = TestBed.inject(FilestackService); + filePreviewSpy = TestBed.inject(FilePreviewService) as jasmine.SpyObj; })); it('should created', () => { @@ -35,46 +34,26 @@ describe('VideoConversionComponent', () => { }); describe('ngOnInit()', () => { - it('should start with countdown for timeout', fakeAsync(() => { + it('should be a no-op after filestack removal', () => { component.ngOnInit(); expect(component.waitedTooLong).toBeFalse(); - tick(10000); - expect(component.waitedTooLong).toBeTrue(); - })); + }); }); describe('ngOnChange()', () => { - it('should act on video file which isn\'t an mp4', () => { - const spy = spyOn(component, 'convertVideo'); + it('should show download fallback for non-mp4 video', () => { component.video = { fileObject: { - mimetype: 'video/abc', // not mp4 + mimetype: 'video/abc', }, }; component.ngOnChanges({} as any); - expect(spy).toHaveBeenCalled(); + expect(component.waitedTooLong).toBeTrue(); }); }); - describe('convertVideo()', () => { - it('should perform filestack video conversion and wait', fakeAsync(() => { - component.stop$ = new Subject(); - component.convertVideo({ - bucket: 'test-bucket', - path: 'test-path', - name: 'test-video', - url: 'http://test.com/video.mp4', - extension: 'mp4', - type: 'video/mp4', - size: 1000 - }); - tick(10000); - expect(component.result).toEqual({ status: 'completed' }); - })); - }); - - describe('showInFilestackPreview()', () => { + describe('showPreview()', () => { it('should show video in streaming URL', () => { const file = { data: { @@ -87,8 +66,8 @@ describe('VideoConversionComponent', () => { url: 'http://streaming.com', }, }; - component.showInFilestackPreview(file as any); - expect(filestackSpy.previewModal).toHaveBeenCalledWith('http://practera.com', { url: 'http://streaming.com' }); + component.showPreview(file as any); + expect(filePreviewSpy.openModal).toHaveBeenCalledWith('http://practera.com', { url: 'http://streaming.com' }); }); it('should allow keyboard event', () => { @@ -109,7 +88,7 @@ describe('VideoConversionComponent', () => { key: 'Enter', }); const spyKb = spyOn(kbEvent, 'preventDefault'); - component.showInFilestackPreview(file as any, kbEvent); + component.showPreview(file as any, kbEvent); expect(spyKb).toHaveBeenCalled(); }); @@ -131,7 +110,7 @@ describe('VideoConversionComponent', () => { key: 'Tab', }); const spyKb = spyOn(kbEvent, 'preventDefault'); - component.showInFilestackPreview(file as any, kbEvent); + component.showPreview(file as any, kbEvent); expect(spyKb).not.toHaveBeenCalled(); }); }); diff --git a/projects/v3/src/app/components/video-conversion/video-conversion.component.ts b/projects/v3/src/app/components/video-conversion/video-conversion.component.ts index 7fdf80614..ab68d4c7c 100644 --- a/projects/v3/src/app/components/video-conversion/video-conversion.component.ts +++ b/projects/v3/src/app/components/video-conversion/video-conversion.component.ts @@ -1,14 +1,6 @@ import { Component, Input, Output, OnChanges, SimpleChanges, EventEmitter, ViewEncapsulation, OnDestroy, OnInit } from '@angular/core'; -import { FilestackService } from '@v3/app/services/filestack.service'; +import { FilePreviewService } from '@v3/app/services/file-preview.service'; import { Subject, Subscription } from 'rxjs'; -import { delay, repeat, takeUntil } from 'rxjs/operators'; - -interface FilestackConversionResponse { - status: string; - data: { - url: string; - }; -} @Component({ standalone: false, @@ -25,23 +17,16 @@ export class VideoConversionComponent implements OnInit, OnChanges, OnDestroy { subscriptions: Subscription[] = []; waitedTooLong: boolean = false; - constructor(private filestackService: FilestackService) {} + constructor(private filePreviewService: FilePreviewService) {} ngOnInit(): void { - const stillWaiting = setTimeout(() => { - this.waitedTooLong = true; - }, 10000); - - this.subscriptions.push(this.stop$.subscribe(res => { - if (res === true) { - clearTimeout(stillWaiting); - } - })); + // no-op: conversion polling removed (filestack deprecated) } ngOnChanges(_changes: SimpleChanges): void { if (this.video?.fileObject?.mimetype !== 'video/mp4') { - this.convertVideo(this.video.fileObject); + // filestack video conversion no longer available — show download fallback immediately + this.waitedTooLong = true; } } @@ -52,29 +37,16 @@ export class VideoConversionComponent implements OnInit, OnChanges, OnDestroy { } } - convertVideo(file) { - this.subscriptions.push(this.filestackService.videoConversion(file.handle).pipe( - delay(2000), - repeat(10), - takeUntil(this.stop$), - ).subscribe((res: FilestackConversionResponse) => { - this.result = res; - if (res?.status === 'completed') { - this.stop$.next(true); - } - })); - } - - showInFilestackPreview(file: FilestackConversionResponse, keyboardEvent?: KeyboardEvent) { + showPreview(file: { data?: { url: string } }, keyboardEvent?: KeyboardEvent) { if (keyboardEvent && (keyboardEvent?.code === 'Space' || keyboardEvent?.code === 'Enter')) { keyboardEvent.preventDefault(); } else if (keyboardEvent) { return; } - const downloadURL = file.data.url; - const streamURL = this.video.fileObject.url; - return this.filestackService.previewModal(downloadURL, { + const downloadURL = file.data?.url; + const streamURL = this.video?.fileObject?.url; + return this.filePreviewService.openModal(downloadURL, { url: streamURL }); } diff --git a/projects/v3/src/app/pages/chat/attachment-popover/attachment-popover.component.html b/projects/v3/src/app/pages/chat/attachment-popover/attachment-popover.component.html index 1c1eb52e6..e638659eb 100644 --- a/projects/v3/src/app/pages/chat/attachment-popover/attachment-popover.component.html +++ b/projects/v3/src/app/pages/chat/attachment-popover/attachment-popover.component.html @@ -1,9 +1,5 @@ Select a type - - - Uppy - File diff --git a/projects/v3/src/app/pages/chat/attachment-popover/attachment-popover.component.spec.ts b/projects/v3/src/app/pages/chat/attachment-popover/attachment-popover.component.spec.ts index f514b6eed..cd089390d 100644 --- a/projects/v3/src/app/pages/chat/attachment-popover/attachment-popover.component.spec.ts +++ b/projects/v3/src/app/pages/chat/attachment-popover/attachment-popover.component.spec.ts @@ -4,7 +4,7 @@ import { PopoverController } from '@ionic/angular'; import { of } from 'rxjs'; import { AttachmentPopoverComponent } from './attachment-popover.component'; -import { FilestackService } from '@v3/services/filestack.service'; +import { UppyUploaderService } from '@v3/app/components/uppy-uploader/uppy-uploader.service'; import { NotificationsService } from '@v3/services/notifications.service'; import { ModalService } from '@v3/services/modal.service'; @@ -22,8 +22,8 @@ describe('AttachmentPopoverComponent', () => { useValue: jasmine.createSpyObj('PopoverController', ['dismiss', 'create']) }, { - provide: FilestackService, - useValue: jasmine.createSpyObj('FilestackService', ['getFileTypes', 'getS3Config', 'open']) + provide: UppyUploaderService, + useValue: jasmine.createSpyObj('UppyUploaderService', ['open']) }, { provide: NotificationsService, diff --git a/projects/v3/src/app/pages/chat/attachment-popover/attachment-popover.component.ts b/projects/v3/src/app/pages/chat/attachment-popover/attachment-popover.component.ts index 51448b729..442d11455 100644 --- a/projects/v3/src/app/pages/chat/attachment-popover/attachment-popover.component.ts +++ b/projects/v3/src/app/pages/chat/attachment-popover/attachment-popover.component.ts @@ -1,8 +1,7 @@ import { UppyUploaderService } from './../../../components/uppy-uploader/uppy-uploader.service'; -import { Component, OnInit } from '@angular/core'; +import { Component } from '@angular/core'; import { PopoverController } from '@ionic/angular'; -import { FilestackService } from '@v3/services/filestack.service'; import { NotificationsService } from '../../../services/notifications.service'; @Component({ @@ -15,7 +14,6 @@ export class AttachmentPopoverComponent{ constructor( private popoverController: PopoverController, - private filestackService: FilestackService, private uppyUploaderService: UppyUploaderService, private notificationsService: NotificationsService, ) { } @@ -38,34 +36,12 @@ export class AttachmentPopoverComponent{ async openAttachPopup(selectedType) { try { - if (selectedType === 'uppy') { - const modal = await this.uppyUploaderService.open('chat'); - modal.onDidDismiss().then(async (res) => { - if (res.data) { - const success = res.data.successful.length > 0 ? res.data.successful[0] : {}; - this.close(success); - } - }); - return; - } - - const options: any = {}; - if (this.filestackService.getFileTypes(selectedType)) { - options.accept = this.filestackService.getFileTypes(selectedType); - options.storeTo = this.filestackService.getS3Config(selectedType); - } - - await this.filestackService.open( - options, - res => { - this.close(res); - return; - }, - err => { - // eslint-disable-next-line no-console - console.log(err); + const modal = await this.uppyUploaderService.open(selectedType); + modal.onDidDismiss().then(async (res) => { + if (res.data) { + this.close(res.data); } - ); + }); } catch (error) { // eslint-disable-next-line no-console diff --git a/projects/v3/src/app/pages/chat/chat-list/chat-list.component.scss b/projects/v3/src/app/pages/chat/chat-list/chat-list.component.scss index 9a2111edc..3aeaf2bec 100644 --- a/projects/v3/src/app/pages/chat/chat-list/chat-list.component.scss +++ b/projects/v3/src/app/pages/chat/chat-list/chat-list.component.scss @@ -104,8 +104,8 @@ clickable-item { } .focusable:focus { - border: 1px solid var(--ion-color-primary); - display: block; + outline: 2px solid var(--ion-color-primary); + outline-offset: -2px; } .indicator-container { diff --git a/projects/v3/src/app/pages/chat/chat-room/chat-room.component.html b/projects/v3/src/app/pages/chat/chat-room/chat-room.component.html index a1e1b7215..2f71d5ec6 100644 --- a/projects/v3/src/app/pages/chat/chat-room/chat-room.component.html +++ b/projects/v3/src/app/pages/chat/chat-room/chat-room.component.html @@ -189,12 +189,12 @@ [attr.aria-label]="'Preview ' + (attachment?.name || 'attachment')" (keydown.enter)="preview(attachment)" (keydown.space)="preview(attachment); $event.preventDefault()"> - + (keydown.enter)="removeSelectAttachment(attachment, i)" + (keydown.space)="removeSelectAttachment(attachment, i); $event.preventDefault()">
@@ -210,12 +210,12 @@ (keydown.space)="preview(attachment); $event.preventDefault()"> - + (keydown.enter)="removeSelectAttachment(attachment, i)" + (keydown.space)="removeSelectAttachment(attachment, i); $event.preventDefault()"> @@ -232,12 +232,12 @@
- + (keydown.enter)="removeSelectAttachment(attachment, i)" + (keydown.space)="removeSelectAttachment(attachment, i); $event.preventDefault()"> diff --git a/projects/v3/src/app/pages/chat/chat-room/chat-room.component.spec.ts b/projects/v3/src/app/pages/chat/chat-room/chat-room.component.spec.ts index 859376b90..fcafb2c82 100644 --- a/projects/v3/src/app/pages/chat/chat-room/chat-room.component.spec.ts +++ b/projects/v3/src/app/pages/chat/chat-room/chat-room.component.spec.ts @@ -8,7 +8,6 @@ import { of, Subject } from 'rxjs'; import { BrowserStorageService } from '@v3/services/storage.service'; import { UtilsService } from '@v3/services/utils.service'; import { PusherService } from '@v3/services/pusher.service'; -import { FilestackService } from '@v3/services/filestack.service'; import { NotificationsService } from '@v3/services/notifications.service'; import { ModalService } from '@v3/services/modal.service'; import { MockRouter } from '@testingv3/mocked.service'; @@ -29,7 +28,6 @@ describe('ChatRoomComponent', () => { let utils: UtilsService; let storageSpy: jasmine.SpyObj; let pusherSpy: jasmine.SpyObj; - let filestackSpy: jasmine.SpyObj; let routerSpy: jasmine.SpyObj; let routeStub: Partial; let MockIoncontent: IonContent; @@ -78,10 +76,6 @@ describe('ChatRoomComponent', () => { provide: PusherService, useValue: jasmine.createSpyObj('PusherService', ['triggerSendMessage', 'triggerTyping', 'triggerDeleteMessage', 'triggerEditMessage']) }, - { - provide: FilestackService, - useValue: jasmine.createSpyObj('FilestackService', ['getFileTypes', 'getS3Config', 'open', 'previewFile']) - }, { provide: NotificationsService, useValue: jasmine.createSpyObj('NotificationsService', ['alert', 'presentToast', 'loading', 'dismiss']) @@ -135,7 +129,6 @@ describe('ChatRoomComponent', () => { utils = TestBed.inject(UtilsService) as jasmine.SpyObj; storageSpy = TestBed.inject(BrowserStorageService) as jasmine.SpyObj; pusherSpy = TestBed.inject(PusherService) as jasmine.SpyObj; - filestackSpy = TestBed.inject(FilestackService) as jasmine.SpyObj; MockIoncontent = TestBed.inject(IonContent) as jasmine.SpyObj; modalCtrlSpy = TestBed.inject(ModalController); modalCtrlSpy.create.and.returnValue(Promise.resolve(modalSpy)); diff --git a/projects/v3/src/app/pages/chat/chat-room/chat-room.component.ts b/projects/v3/src/app/pages/chat/chat-room/chat-room.component.ts index e406fc5ba..2006f61df 100644 --- a/projects/v3/src/app/pages/chat/chat-room/chat-room.component.ts +++ b/projects/v3/src/app/pages/chat/chat-room/chat-room.component.ts @@ -6,7 +6,6 @@ import { DOCUMENT } from '@angular/common'; import { BrowserStorageService } from '@v3/services/storage.service'; import { UtilsService } from '@v3/services/utils.service'; import { PusherService, SendMessageParam } from '@v3/services/pusher.service'; -import { FilestackService } from '@v3/services/filestack.service'; import { ChatService, ChatChannel, Message, MessageListResult, ChannelMembers, FileResponse } from '@v3/services/chat.service'; import { ChatPreviewComponent } from '../chat-preview/chat-preview.component'; import { ChatInfoComponent } from '../chat-info/chat-info.component'; @@ -153,7 +152,6 @@ export class ChatRoomComponent implements OnInit, OnDestroy, AfterViewInit { private storage: BrowserStorageService, public utils: UtilsService, private pusherService: PusherService, - private filestackService: FilestackService, private modalController: ModalController, private ngZone: NgZone, public element: ElementRef, @@ -336,7 +334,7 @@ export class ChatRoomComponent implements OnInit, OnDestroy, AfterViewInit { .subscribe((_event) => { this._checkScrollPosition(); }); - + // Set aria-labels for Quill toolbar elements (WCAG 4.1.2) setTimeout(() => { this._setQuillToolbarAriaLabels(); @@ -349,7 +347,7 @@ export class ChatRoomComponent implements OnInit, OnDestroy, AfterViewInit { private _setQuillToolbarAriaLabels(): void { // Scope queries to the component's native element to avoid conflicts const componentElement = this.element.nativeElement; - + const previewLink = componentElement.querySelector('.ql-preview') as HTMLElement; if (previewLink && !previewLink.getAttribute('aria-label')) { previewLink.setAttribute('aria-label', 'Preview link'); @@ -1075,11 +1073,7 @@ export class ChatRoomComponent implements OnInit, OnDestroy, AfterViewInit { } } - getResizedImageUrl(fileStackObject, dimension) { - return `https://cdn.filestackcontent.com/quality=value:70/resize=w:${dimension},h:${dimension},fit:crop/${fileStackObject.handle}`; - } - - removeSelectAttachment(attachment, index?: number, isDelete = false) { + removeSelectAttachment(attachment, index?: number) { if (!attachment) { return; } @@ -1088,12 +1082,6 @@ export class ChatRoomComponent implements OnInit, OnDestroy, AfterViewInit { attachIndex = index; } this.selectedAttachments.splice(attachIndex, 1); - if (isDelete) { - this.filestackService - .deleteFile(attachment.handle) - // eslint-disable-next-line no-console - .subscribe(console.log); - } } download(file: FileResponse): void { diff --git a/projects/v3/src/app/pages/events/event-list/event-list.component.scss b/projects/v3/src/app/pages/events/event-list/event-list.component.scss index 0fdfa107f..46f178162 100644 --- a/projects/v3/src/app/pages/events/event-list/event-list.component.scss +++ b/projects/v3/src/app/pages/events/event-list/event-list.component.scss @@ -18,6 +18,6 @@ ion-segment { } .focusable:focus { - display: block; - border: 1px solid var(--ion-color-primary); + outline: 2px solid var(--ion-color-primary); + outline-offset: -2px; } diff --git a/projects/v3/src/app/pages/home/home.page.scss b/projects/v3/src/app/pages/home/home.page.scss index 6ad37eaab..9c2b52b0a 100644 --- a/projects/v3/src/app/pages/home/home.page.scss +++ b/projects/v3/src/app/pages/home/home.page.scss @@ -72,8 +72,8 @@ ion-content.scrollable-desktop { } .focusable:focus { - border: 1px solid var(--ion-color-primary); - display: block; + outline: 2px solid var(--ion-color-primary); + outline-offset: -2px; } diff --git a/projects/v3/src/app/services/file-preview.service.ts b/projects/v3/src/app/services/file-preview.service.ts new file mode 100644 index 000000000..056b15221 --- /dev/null +++ b/projects/v3/src/app/services/file-preview.service.ts @@ -0,0 +1,77 @@ +import { Injectable } from '@angular/core'; +import { ModalController } from '@ionic/angular'; +import { NotificationsService } from '@v3/services/notifications.service'; +import { UtilsService } from '@v3/services/utils.service'; +import { FilePreviewComponent } from '../components/file-preview/file-preview.component'; + +@Injectable({ + providedIn: 'root' +}) +export class FilePreviewService { + + constructor( + private modalController: ModalController, + private notificationsService: NotificationsService, + private utils: UtilsService, + ) {} + + // open a file preview modal for given file object + async preview(file: { url?: string; handle?: string; name?: string; size?: number; mimetype?: string }): Promise { + let fileUrl = file.url; + if (fileUrl) { + if (fileUrl.indexOf('www.filepicker.io/api/file') !== -1) { + fileUrl = fileUrl.replace('www.filepicker.io/api/file', 'cdn.filestackcontent.com/preview'); + } else if (fileUrl.indexOf('filestackcontent.com') !== -1) { + fileUrl = fileUrl.replace('filestackcontent.com', 'filestackcontent.com/preview'); + } + } else if (file.handle) { + fileUrl = 'https://cdn.filestackcontent.com/preview/' + file.handle; + } + + if (!fileUrl) { + return this.notificationsService.alert({ + subHeader: $localize`Inaccessible file`, + message: $localize`The file URL is not available.`, + }); + } + + // large application file warning using local size info + if (file.mimetype?.includes('application/') && file.size) { + const megabyte = file.size / 1000 / 1000; + if (megabyte > 10) { + return this.notificationsService.alert({ + subHeader: $localize`File size too large`, + message: $localize`Attachment size has exceeded the size of ${Math.floor(megabyte)}mb please consider downloading the file for better reading experience.`, + buttons: [ + { + text: $localize`Download`, + handler: () => { + return this.utils.openUrl(file.url, { target: '_blank' }); + } + }, + { + text: $localize`Cancel`, + role: 'cancel', + handler: () => { return; } + }, + ] + }); + } + } + + return this.openModal(fileUrl, file); + } + + // open preview modal with given url and optional file reference + async openModal(url: string, file?: any): Promise { + const modal = await this.modalController.create({ + component: FilePreviewComponent, + componentProps: { + url, + file: file || {}, + }, + cssClass: 'filestack-preview-modal', + }); + return await modal.present(); + } +} diff --git a/projects/v3/src/app/services/filestack.service.spec.ts b/projects/v3/src/app/services/filestack.service.spec.ts deleted file mode 100644 index 136d1dd9b..000000000 --- a/projects/v3/src/app/services/filestack.service.spec.ts +++ /dev/null @@ -1,348 +0,0 @@ -import { TestBed, fakeAsync, flushMicrotasks } from '@angular/core/testing'; - -import { - HttpTestingController, - HttpClientTestingModule -} from '@angular/common/http/testing'; -import { FilestackService } from './filestack.service'; -import { NotificationsService } from '@v3/services/notifications.service'; -import { BrowserStorageService } from '@v3/services/storage.service'; -import { UtilsService } from '@v3/services/utils.service'; -import { BrowserStorageServiceMock } from '@testingv3/mocked.service'; -import { environment } from '@v3/environments/environment'; -import { ModalController, IonicModule } from '@ionic/angular'; -import * as filestack from 'filestack-js'; -import { TestUtils } from '@testingv3/utils'; - -describe('FilestackService', () => { - let service: FilestackService; - let notificationSpy: jasmine.SpyObj; - let storageSpy: jasmine.SpyObj; - let utils: UtilsService; - let mockBackend: HttpTestingController; - let modalctrlSpy: jasmine.SpyObj; - const MODAL_SAMPLE = 'test'; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [HttpClientTestingModule, IonicModule], - providers: [ - FilestackService, - { - provide: ModalController, - useValue: jasmine.createSpyObj('ModalController', { - create: Promise.resolve({ - present: () => new Promise(res => { - res(MODAL_SAMPLE); - }), - dismiss: () => new Promise(res => res(true)), - }) - }) - }, - { - provide: UtilsService, - useClass: TestUtils, - }, - { - provide: BrowserStorageService, - useClass: BrowserStorageServiceMock - }, - { - provide: NotificationsService, - useValue: jasmine.createSpyObj('NotificationsService', [ - 'modal', 'alert' - ]) - }, - ] - }); - service = TestBed.inject(FilestackService); - utils = TestBed.inject(UtilsService); - notificationSpy = TestBed.inject(NotificationsService) as jasmine.SpyObj; - storageSpy = TestBed.inject(BrowserStorageService) as jasmine.SpyObj; - mockBackend = TestBed.inject(HttpTestingController); - modalctrlSpy = TestBed.inject(ModalController) as jasmine.SpyObj; - }); - - it('should be created', () => { - expect(service).toBeTruthy(); - }); - - describe('getFileTypes', () => { - it('should return mimetype wildcard based on provided type', () => { - const anyType = service.getFileTypes(); - const imageType = service.getFileTypes('image'); - const videoType = service.getFileTypes('video'); - - expect(anyType).toEqual(''); - expect(imageType).toEqual('image/*'); - expect(videoType).toEqual('video/*'); - }); - }); - - describe('getS3Config()', () => { - it('should get config value from environment variable', () => { - const userHash = 'testUserHash'; - storageSpy.getUser.and.returnValue({ - userHash - }); - - const location = 'test'; - const container = 'test'; - const region = 'test'; - const workflows = ['test', 'test2']; - const paths = { - test: 'test-type', - any: 'test' - }; - - const testConfig = { - location, - container, - region, - workflows, - paths, - }; - - environment.filestack.s3Config = Object.assign(environment.filestack.s3Config, testConfig); - - const result = service.getS3Config('test'); - - expect(storageSpy.getUser).toHaveBeenCalled(); - expect(result).toEqual({ - location, - container, - region, - path: `${paths.test}${userHash}/`, - workflows, - }); - }); - }); - - describe('previewFile()', () => { - beforeEach(() => { - spyOn(service, 'previewModal').and.returnValue(Promise.resolve()); - }); - - afterEach(() => { - expect(service.metadata).toHaveBeenCalled(); - }); - - it('should popup file preview', fakeAsync(() => { - spyOn(service, 'metadata').and.returnValue(Promise.resolve({ mimetype: 'testing/format' })); - service.previewFile({ - bucket: 'test-bucket', - path: 'test-path', - name: 'test-file', - url: 'https://example.com/test.jpg', - extension: 'jpg', - type: 'image/jpeg', - size: 1000, - handle: 'testingHandleValue' - }).then(); - flushMicrotasks(); - expect(service.previewModal).toHaveBeenCalled(); - })); - - it('should popup file preview (support older URL format)', fakeAsync(() => { - spyOn(service, 'metadata').and.returnValue(Promise.resolve({ mimetype: 'testing/format' })); - service.previewFile({ - bucket: 'test-bucket', - path: 'test-path', - name: 'test-file', - url: 'www.filepicker.io/api/file', - extension: 'jpg', - type: 'image/jpeg', - size: 1000, - handle: 'testingHandleValue' - }).then(); - flushMicrotasks(); - expect(service.previewModal).toHaveBeenCalled(); - })); - - it('should popup file preview (support older URL format 2)', fakeAsync(() => { - spyOn(service, 'metadata').and.returnValue(Promise.resolve({ mimetype: 'testing/format' })); - service.previewFile({ - bucket: 'test-bucket', - path: 'test-path', - name: 'test-file', - url: 'filestackcontent.com', - extension: 'jpg', - type: 'image/jpeg', - size: 1000, - handle: 'testingHandleValue' - }).then(); - flushMicrotasks(); - expect(service.previewModal).toHaveBeenCalled(); - })); - - it('should alert instead of popup preview when file size too large', fakeAsync(() => { - spyOn(service, 'metadata').and.returnValue(Promise.resolve({ - mimetype: 'application/testType', - size: 11 * 1000 * 1000 // 11mb - })); - - service.previewFile({ - bucket: 'test-bucket', - path: 'test-path', - name: 'test-file', - url: 'filestackcontent.com', - extension: 'pdf', - type: 'application/pdf', - size: 11 * 1000 * 1000, // 11mb - handle: 'testingHandleValue' - }).then(); - flushMicrotasks(); - - expect(notificationSpy.alert).toHaveBeenCalled(); - expect(service.previewModal).not.toHaveBeenCalled(); - })); - }); - - describe('metadata()', () => { - it('should get metadata from filestack', fakeAsync(() => { - const handle = 'testingFilestackHandle'; - let result; - service.metadata({ - url: `http://testing.com/${handle}`, - }).then(res => result = res); - - const req = mockBackend.expectOne({ - url: `https://www.filestackapi.com/api/file/${handle}/metadata`, - method: 'GET', - }); - req.flush({ body: true }); - - mockBackend.verify(); - })); - }); - - describe('open()', () => { - beforeEach(() => { - spyOn(service['filestack'], 'picker').and.returnValue({ - open: () => Promise.resolve(null) - }); - spyOn(service, 'getFileTypes'); - spyOn(service, 'getS3Config'); - }); - - it('should instantiate filestack and trigger open fileupload popup', fakeAsync(() => { - let result; - - service.open().then(res => { - result = res; - }); - flushMicrotasks(); - expect(service.getFileTypes).toHaveBeenCalled(); - expect(service.getS3Config).toHaveBeenCalled(); - expect(result).toBeNull(); - })); - - it('should initiate picker with correct settings', fakeAsync(() => { - let result; - let onSuccessRes; - let onErrorRes; - - const onSuccess = res => { - onSuccessRes = res; - }; - - const onError = res => { - onErrorRes = res; - }; - - service.open({ - testOnly: true, - }, - res => res, - res => res - ).then(res => { - result = res; - }); - flushMicrotasks(); - - expect(service['filestack'].picker).toHaveBeenCalledWith(jasmine.objectContaining({ testOnly: true })); - })); - }); - - describe('previewModal()', () => { - it('should pop up modal for provided filestack link', fakeAsync(() => { - service.previewModal('test.com'); - flushMicrotasks(); - - expect(modalctrlSpy.create).toHaveBeenCalled(); - })); - }); - - describe('getWorkflowStatus()', () => { - const workflowId = 'test_workflow_id'; - const policy = 'test_policy'; - const signature = 'test_signature'; - const workflows = { virusDetection: workflowId }; - - beforeEach(() => { - environment.filestack = Object.assign(environment.filestack, { - policy, - signature, - workflows - }); - }); - - it('should get status of provided workflow info', fakeAsync(() => { - // spyOn(utils, 'each'); - let result = [{ body: true }]; - service.getWorkflowStatus({ - test_workflow_id: [workflows.virusDetection] - }).then(res => { - result = res; - }); - - flushMicrotasks(); - const req = mockBackend.expectOne({ method: 'GET' }); - req.flush(result); - - - expect(req.request.url).toEqual(`https://cdn.filestackcontent.com/${environment.filestack.key}/security=p:${policy},s:${signature}/workflow_status=job_id:${workflowId}`); - - mockBackend.verify(); - })); - - it('should return empty if processedJobs is 0', fakeAsync(() => { - let result; - service.getWorkflowStatus().then(res => { - result = res; - }); - - flushMicrotasks(); - expect(result).toEqual([]); - })); - }); - - describe('onFileSelectedRename()', () => { - it('should rename file with spacing', fakeAsync(() => { - const currentFile = { - bucket: 'test-bucket', - path: 'test-path', - name: 'a b c', - url: 'http://example.com/a-b-c', - extension: 'jpg', - type: 'image/jpeg', - size: 1000, - filename: 'a b c', - handle: 'a-b-c', - mimetype: 'mimetype', - originalPath: 'here', - source: 'earth', - uploadId: '12345', - alt: '' - }; - - // Since onFileSelectedRename is always returning a Promise now - let result; - service['onFileSelectedRename'](currentFile).then(res => { - result = res; - expect(result.filename).toEqual('a_b_c'); - }); - flushMicrotasks(); - })); - }); -}); diff --git a/projects/v3/src/app/services/filestack.service.ts b/projects/v3/src/app/services/filestack.service.ts deleted file mode 100644 index 8371b4f01..000000000 --- a/projects/v3/src/app/services/filestack.service.ts +++ /dev/null @@ -1,297 +0,0 @@ -import * as filestack from 'filestack-js'; -import { Injectable } from '@angular/core'; -import { ModalController } from '@ionic/angular'; -// import { PreviewComponent } from './preview/preview.component'; -import { environment } from '@v3/environments/environment'; -import { BrowserStorageService } from '@v3/services/storage.service'; -import { HttpClient } from '@angular/common/http'; // added to make one and only API call to filestack server -import { forkJoin } from 'rxjs'; -import { NotificationsService } from '@v3/services/notifications.service'; -import { UtilsService } from '@v3/services/utils.service'; -import { FilestackPreviewComponent } from '../components/filestack-preview/filestack-preview.component'; - -export interface Metadata { - mimetype?: string; - uploaded?: number; - container?: string; - writeable?: boolean; - filename?: string; - location?: string; - key?: string; - path?: string; - size?: number; -} - -// https://www.filestack.com/docs/api/file/#md-request -const api = { - metadata: `https://www.filestackapi.com/api/file/HANDLE/metadata` -}; - -const FS_INTELLIGENT = false; -const FS_MULTIPART_CONCURRENCY = 5; - -@Injectable({ - providedIn: 'root' -}) -export class FilestackService { - private filestack: filestack.Client; - readonly chunksConcurrency = FS_MULTIPART_CONCURRENCY; - readonly intelligent: boolean = FS_INTELLIGENT; - - // file types that allowed to upload - public fileTypes = { - any: '', - image: 'image/*', - video: 'video/*' - }; - - constructor( - private modalController: ModalController, - private storage: BrowserStorageService, - private httpClient: HttpClient, - private notificationsService: NotificationsService, - private utils: UtilsService, - ) { - const { policy, signature } = environment.filestack; - this.filestack = filestack.init(this.getFilestackConfig(), { - policy, - signature, - }); - - // avoid silent error - this.filestack.on('upload.error', (error) => { - if (error.type === 'request') { - return this.notificationsService.alert({ - header: $localize`Upload failed`, - message: $localize`Maybe the file is too large or the network is unstable. Please try again later. If problem persists, please contact support.`, - }); - } - }); - - if (!this.filestack) { - throw new Error('Filestack module not found.'); - } - } - - get client() { - if (!this.filestack) { - throw new Error('Filestack module not found.'); - } - - return this.filestack; - } - - // get filestack config - getFilestackConfig() { - return environment.filestack.key; - } - - // get file types - getFileTypes(type = 'any') { - return this.fileTypes[type]; - } - - // get s3 config - getS3Config(fileType) { - let { container, region } = environment.filestack.s3Config; - const { - location, - workflows, - paths - } = environment.filestack.s3Config; - - let path = paths.any; - // get s3 path based on file type - if (paths[fileType]) { - path = paths[fileType]; - } - // add user hash to the path - path = path + this.storage.getUser().userHash + '/'; - if (this.storage.getCountry() === 'China') { - container = environment.filestack.s3Config.containerChina; - region = environment.filestack.s3Config.regionChina; - } - return { - location, - container, - region, - path, - workflows - }; - } - - async previewFile(file): Promise { - let fileUrl = file.url; - if (fileUrl) { - if (fileUrl.indexOf('www.filepicker.io/api/file') !== -1) { - // old format - fileUrl = fileUrl.replace('www.filepicker.io/api/file', 'cdn.filestackcontent.com/preview'); - } else if (fileUrl.indexOf('filestackcontent.com') !== -1) { - // new format - fileUrl = fileUrl.replace('filestackcontent.com', 'filestackcontent.com/preview'); - } - } else if (file.handle) { - fileUrl = 'https://cdn.filestackcontent.com/preview/' + file.handle; - } - - let metadata; - try { - metadata = await this.metadata(file); - } catch (e) { - if (e.status === 0) { - return this.notificationsService.alert({ - subHeader: $localize`No Filestack responses`, - message: e.message, - }); - } - return this.notificationsService.alert({ - subHeader: $localize`Inaccessible file`, - message: $localize`The uploaded file is suspicious and being scanned for potential risk. Please try again later.`, - }); - } - - if (metadata.mimetype && metadata.mimetype.includes('application/')) { - const megabyte = (metadata && metadata.size) ? metadata.size / 1000 / 1000 : 0; - if (megabyte > 10) { - return this.notificationsService.alert({ - subHeader: $localize`File size too large`, - message: $localize`Attachment size has exceeded the size of ${Math.floor(megabyte)}mb please consider downloading the file for better reading experience.`, - buttons: [ - { - text: $localize`Download`, - handler: () => { - return this.utils.openUrl(file.url, { - target: '_blank', - }); - } - }, - { - text: $localize`Cancel`, - role: 'cancel', - handler: () => { - return; - } - }, - ] - }); - } - } - - return new Promise(resolve => resolve(this.previewModal(fileUrl, file))); - } - - async metadata(file): Promise { - const handle = file.url.match(/([A-Za-z0-9]){20,}/); - return this.httpClient.get(api.metadata.replace('HANDLE', handle[0])).toPromise(); - } - - private onFileSelectedRename(file: filestack.PickerFileMetadata): Promise { - // replace space with underscore '_' in file name - // replace space with underscore '_' in file name - const filename = file.filename.replace(/ /g, '_'); - return Promise.resolve({ ...file, filename }); - } - - async open(options = {}, onSuccess = res => res, onError = err => err): Promise { - const currentLocale = this.utils.getCurrentLocale(); - const pickerOptions: filestack.PickerOptions = { - dropPane: {}, - fromSources: [ - 'local_file_system', - 'googledrive', - 'dropbox', - 'onedrive', - 'gmail', - 'video' - ], - uploadConfig: { - intelligent: this.intelligent, - partSize: 1024 * 1024 * 5, // 5MB - concurrency: this.chunksConcurrency, - retry: 2, // retry for 3 times - timeout: 60000, // allow chunked size upload happen for 30 seconds max - }, - storeTo: this.getS3Config(this.getFileTypes()), - onFileSelected: this.onFileSelectedRename, - onFileUploadFailed: onError, - onFileUploadFinished: (res) => { - return onSuccess(res); - }, - onUploadDone: (res) => res, - supportEmail: 'help@practera.com', - lang: currentLocale !== 'en-US' ? currentLocale : 'en', - }; - - return await this.filestack.picker(Object.assign(pickerOptions, options)).open(); - } - - // Note: added similar functionality as this.open() to support drag and drop feature, please check FilestackComponent for how this is being used. - async upload(file, uploadOptions, path, uploadToken): Promise { - const option: filestack.UploadOptions = { - onProgress: uploadOptions.onProgress, - concurrency: this.chunksConcurrency, - intelligent: this.intelligent, // multipart upload - }; - - if (!path) { - path = this.getS3Config(this.getFileTypes()); - } - - await this.filestack.upload(file, option, path, uploadToken) - .then(res => { - const missingAttribute = { - container: res.container, - key: res.key, - filename: res.filename, - mimetype: res.mimetype - }; - return uploadOptions.onFileUploadFinished(Object.assign(res.toJSON(), missingAttribute)); - }) - .catch(err => { - return uploadOptions.onFileUploadFailed(err); - }); - } - - async previewModal(url, filestackUploadedResponse?): Promise { - const modal = await this.modalController.create({ - component: FilestackPreviewComponent, - componentProps: { - url: url, - file: filestackUploadedResponse, // for whole object reference - }, - cssClass: 'filestack-preview-modal', - }); - return await modal.present(); - } - - async getWorkflowStatus(processedJobs = {}): Promise { - const { policy, signature, workflows } = environment.filestack; - let jobs = {}; - - // currently we only accept virusDetection workflow - if (processedJobs && processedJobs[workflows.virusDetection]) { - jobs = processedJobs[workflows.virusDetection]; - } - - const request = []; - this.utils.each(jobs, job => { - request.push(this.httpClient.get(`https://cdn.filestackcontent.com/${environment.filestack.key}/security=p:${policy},s:${signature}/workflow_status=job_id:${job}`)); - }); - if (request.length > 0) { - return forkJoin(request).toPromise(); - } - - return []; - } - - videoConversion(handle) { - return this.httpClient.get(`https://cdn.filestackcontent.com/video_convert/${handle}`); - } - - // securely delete a file from filestack - deleteFile(handle) { - const { policy, signature, key } = environment.filestack; - return this.httpClient.delete(`https://www.filestackapi.com/api/file/${handle}?key=${key}&policy=${policy}&signature=${signature}`); - } -} - diff --git a/projects/v3/src/environments/environment.custom.ts b/projects/v3/src/environments/environment.custom.ts index f62a43158..b6d49dee8 100644 --- a/projects/v3/src/environments/environment.custom.ts +++ b/projects/v3/src/environments/environment.custom.ts @@ -26,29 +26,6 @@ export const environment = { requiredMetaFields: [] // No required metadata fields } }, - filestack: { - key: '', - s3Config: { - location: 's3', - container: '', - containerChina: '', - region: '', - regionChina: '', - paths: { - any: '', - image: '', - video: '' - }, - workflows: [ - '', - ], - }, - policy: '', - signature: '', - workflows: { - virusDetection: '', - }, - }, hubspot: { liveServerRegion: '', supportFormPortalId: '', diff --git a/projects/v3/src/environments/environment.interface.ts b/projects/v3/src/environments/environment.interface.ts index 29bc3a053..caa7be288 100644 --- a/projects/v3/src/environments/environment.interface.ts +++ b/projects/v3/src/environments/environment.interface.ts @@ -32,27 +32,6 @@ export interface Environment { requiredMetaFields: string[]; }; }; - filestack: { - key: string; - s3Config: { - location: string; - container: string; - containerChina: string; - region: string; - regionChina: string; - paths: { - any: string; - image: string; - video: string; - }; - workflows: string[]; - }; - policy: string; - signature: string; - workflows: { - virusDetection: string; - }; - }; hubspot: { liveServerRegion: string; supportFormPortalId: string; diff --git a/projects/v3/src/environments/environment.local.ts b/projects/v3/src/environments/environment.local.ts index 6d1fdbcad..d68de3d75 100644 --- a/projects/v3/src/environments/environment.local.ts +++ b/projects/v3/src/environments/environment.local.ts @@ -29,29 +29,6 @@ export const environment = { requiredMetaFields: [], // No required metadata fields } }, - filestack: { - key: 'AO6F4C72uTPGRywaEijdLz', - s3Config: { - location: 's3', - container: 'practera-aus', - containerChina: 'practera-kr', - region: 'ap-southeast-2', - regionChina: 'ap-northeast-2', - paths: { - any: '/appv2/local/uploads/', - image: '/appv2/local/uploads/', - video: '/appv2/local/video/upload/' - }, - workflows: [ - '3c38ef53-a9d0-4aa4-9234-617d9f03c0de', - ], - }, - policy: '', - signature: '', - workflows: { - virusDetection: '3c38ef53-a9d0-4aa4-9234-617d9f03c0de', - }, - }, hubspot: { liveServerRegion: '', supportFormPortalId: '', From ff90a9b676ecc7e6e08b2981493885fa4fd75b89 Mon Sep 17 00:00:00 2001 From: trtshen Date: Tue, 31 Mar 2026 13:00:44 +0800 Subject: [PATCH 4/4] [CORE-8147] request module to v19 --- angular.json | 2 ++ projects/request/karma.conf.js | 13 +++++---- projects/request/ng-package.json | 5 +--- projects/request/package.json | 5 ++-- .../request/src/lib/request.module.spec.ts | 6 ++++ projects/request/src/lib/request.module.ts | 7 ----- .../request/src/lib/request.service.spec.ts | 28 ++++++++++--------- projects/request/src/lib/request.service.ts | 27 +++++++++--------- projects/request/src/test.ts | 18 +++--------- projects/request/tsconfig.lib.json | 2 +- projects/request/tsconfig.lib.prod.json | 2 +- projects/request/tsconfig.spec.json | 2 +- projects/v3/src/app/app.component.ts | 1 - projects/v3/src/app/app.module.ts | 3 +- 14 files changed, 55 insertions(+), 66 deletions(-) diff --git a/angular.json b/angular.json index 365e45131..a82fddcaa 100644 --- a/angular.json +++ b/angular.json @@ -274,9 +274,11 @@ "test": { "builder": "@angular-devkit/build-angular:karma", "options": { + "builderMode": "application", "main": "projects/request/src/test.ts", "karmaConfig": "projects/request/karma.conf.js", "tsConfig": "projects/request/tsconfig.spec.json", + "include": ["projects/request/src/**/*.spec.ts"], "scripts": [] } } diff --git a/projects/request/karma.conf.js b/projects/request/karma.conf.js index 71b6bfb0e..9de7858e0 100644 --- a/projects/request/karma.conf.js +++ b/projects/request/karma.conf.js @@ -14,10 +14,8 @@ module.exports = function (config) { ], client: { jasmine: { - // you can add configuration options for Jasmine here - // the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html - // for example, you can disable the random execution with `random: false` - // or set a specific seed with `seed: 4321` + random: false, + timeoutInterval: 10000, }, clearContext: false // leave Jasmine Spec Runner output visible in browser }, @@ -37,8 +35,11 @@ module.exports = function (config) { colors: true, logLevel: config.LOG_INFO, autoWatch: true, - browsers: ['Chrome'], + browsers: ['ChromeHeadless'], singleRun: false, - restartOnFileChange: true + restartOnFileChange: true, + browserNoActivityTimeout: 120000, + browserDisconnectTimeout: 30000, + browserDisconnectTolerance: 3, }); }; diff --git a/projects/request/ng-package.json b/projects/request/ng-package.json index 2d2b6250d..3bfaed007 100644 --- a/projects/request/ng-package.json +++ b/projects/request/ng-package.json @@ -3,8 +3,5 @@ "dest": "../../dist/request", "lib": { "entryFile": "src/public-api.ts" - }, - "allowedNonPeerDependencies": [ - "lodash" - ] + } } diff --git a/projects/request/package.json b/projects/request/package.json index 24f21f136..e80bc2534 100644 --- a/projects/request/package.json +++ b/projects/request/package.json @@ -2,11 +2,10 @@ "name": "request", "version": "0.0.1", "peerDependencies": { - "@angular/common": "^17.0.0 || ^18.0.0 || ^19.0.0", - "@angular/core": "^17.0.0 || ^18.0.0 || ^19.0.0" + "@angular/common": "^18.0.0 || ^19.0.0", + "@angular/core": "^18.0.0 || ^19.0.0" }, "dependencies": { - "lodash-es": "^4.17.21", "tslib": "^2.3.0" } } diff --git a/projects/request/src/lib/request.module.spec.ts b/projects/request/src/lib/request.module.spec.ts index b0aac90e5..15acd35eb 100644 --- a/projects/request/src/lib/request.module.spec.ts +++ b/projects/request/src/lib/request.module.spec.ts @@ -1,4 +1,6 @@ import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; import { RequestModule } from './request.module'; describe('RequestModule', () => { @@ -12,6 +14,10 @@ describe('RequestModule', () => { prefixUrl: 'TEST', }), ], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + ], }); requestModule = TestBed.inject(RequestModule); diff --git a/projects/request/src/lib/request.module.ts b/projects/request/src/lib/request.module.ts index 753104aee..85f14b732 100644 --- a/projects/request/src/lib/request.module.ts +++ b/projects/request/src/lib/request.module.ts @@ -1,17 +1,10 @@ import { ModuleWithProviders, NgModule, Optional, SkipSelf } from '@angular/core'; import { RequestConfig, RequestService } from './request.service'; -import { HttpClientModule } from '@angular/common/http'; @NgModule({ - imports: [ - HttpClientModule, - ], providers: [ RequestService, ], - exports: [ - HttpClientModule, - ], }) export class RequestModule { constructor(@Optional() @SkipSelf() parentModule: RequestModule) { diff --git a/projects/request/src/lib/request.service.spec.ts b/projects/request/src/lib/request.service.spec.ts index ad95829a3..a1fee3a49 100644 --- a/projects/request/src/lib/request.service.spec.ts +++ b/projects/request/src/lib/request.service.spec.ts @@ -6,8 +6,9 @@ import { import { HttpTestingController, - HttpClientTestingModule, + provideHttpClientTesting, } from '@angular/common/http/testing'; +import { provideHttpClient } from '@angular/common/http'; import { RequestService, RequestConfig, DevModeService, QueryEncoder } from './request.service'; import { Router } from '@angular/router'; @@ -68,12 +69,12 @@ describe('RequestService', () => { let mockBackend: HttpTestingController; let requestConfigSpy: RequestConfig; let devModeServiceSpy: DevModeService; - let storageSpy: BrowserStorageService; beforeEach(async () => { TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], providers: [ + provideHttpClient(), + provideHttpClientTesting(), RequestService, DevModeService, { @@ -102,7 +103,6 @@ describe('RequestService', () => { mockBackend = TestBed.inject(HttpTestingController); requestConfigSpy = TestBed.inject(RequestConfig); devModeServiceSpy = TestBed.inject(DevModeService); - storageSpy = TestBed.inject(BrowserStorageService); }); it('should be created', () => { @@ -130,15 +130,16 @@ describe('RequestService', () => { mockBackend.verify(); })); - it('should update apikey if new apikey exist', () => { - let res = { body: true, apikey: 'testapikey' }; + it('should pass through response with custom headers', () => { + let res: any; service.get(testURL, { headers: { some: 'keys' } }).subscribe(_res => { res = _res; }); const req = mockBackend.expectOne({ method: 'GET' }); - req.flush(res); + req.flush({ body: true, apikey: 'testapikey' }); - expect(storageSpy.setUser).toHaveBeenCalledWith({ apikey: res.apikey }); + expect(res).toEqual({ body: true, apikey: 'testapikey' }); + expect(req.request.headers.get('Content-Type')).toBe('application/json'); mockBackend.verify(); }); @@ -160,7 +161,7 @@ describe('RequestService', () => { const req = mockBackend.expectOne({ url: testURL, method: 'GET' }).flush(ERR_MESSAGE, err); expect(res).toBeUndefined(); - expect(errRes).toEqual(ERR_MESSAGE); + expect(errRes.error).toEqual(ERR_MESSAGE); mockBackend.verify(); })); @@ -242,6 +243,7 @@ describe('RequestService', () => { const req = mockBackend.expectOne({ url: testURL, method: 'POST' }).flush(ERR_MESSAGE, err); expect(res).toBeUndefined(); + expect(errRes.error).toEqual(ERR_MESSAGE); mockBackend.verify(); })); }); @@ -308,7 +310,7 @@ describe('RequestService', () => { const req = mockBackend.expectOne({ url: `https://${PREFIX_URL}/${testURL}`, method: 'PUT' }).flush(ERR_MESSAGE, err); expect(res).toBeUndefined(); - expect(errRes).toEqual(ERR_MESSAGE); + expect(errRes.error).toEqual(ERR_MESSAGE); mockBackend.verify(); })); }); @@ -354,7 +356,7 @@ describe('RequestService', () => { expect(res).toBeUndefined(); expect(console.error).not.toHaveBeenCalled(); - expect(errRes).toEqual(ERR_MESSAGE); + expect(errRes.error).toEqual(ERR_MESSAGE); mockBackend.verify(); })); }); @@ -400,7 +402,7 @@ describe('RequestService', () => { }, _err => { errRes = _err; - expect(errRes).toEqual(ERR_MESSAGE); + expect(errRes.error).toEqual(ERR_MESSAGE); } ); @@ -415,7 +417,7 @@ describe('RequestService', () => { _res => _res, _err => { errRes = _err; - expect(errRes.message).toEqual(badKey); + expect(errRes.error.message).toEqual(badKey); } ); diff --git a/projects/request/src/lib/request.service.ts b/projects/request/src/lib/request.service.ts index 84350c544..5b879d6b3 100644 --- a/projects/request/src/lib/request.service.ts +++ b/projects/request/src/lib/request.service.ts @@ -9,7 +9,6 @@ import { import { Router } from '@angular/router'; import { Observable, of, throwError } from 'rxjs'; import { catchError, concatMap } from 'rxjs/operators'; -import { has, isEmpty, each } from 'lodash-es'; interface RequestOptions { headers?: any; @@ -92,9 +91,9 @@ export class RequestService { */ setParams(options: {[key:string]: any}) { let params: any; - if (!isEmpty(options)) { + if (options && Object.keys(options).length > 0) { params = new HttpParams(); - each(options, (value, key) => { + Object.entries(options).forEach(([key, value]) => { params = params.append(key, value); }); } @@ -122,13 +121,13 @@ export class RequestService { httpOptions = {}; } - if (!has(httpOptions, 'headers')) { + if (!('headers' in httpOptions)) { httpOptions.headers = ''; } - if (!has(httpOptions, 'params')) { + if (!('params' in httpOptions)) { httpOptions.params = ''; } - if (!has(httpOptions, 'observe')) { + if (!('observe' in httpOptions)) { httpOptions.observe = 'body'; } @@ -149,10 +148,10 @@ export class RequestService { params.httpOptions = {}; } - if (!has(params.httpOptions, 'headers')) { + if (!('headers' in params.httpOptions)) { params.httpOptions.headers = ''; } - if (!has(params.httpOptions, 'params')) { + if (!('params' in params.httpOptions)) { params.httpOptions.params = ''; } @@ -179,10 +178,10 @@ export class RequestService { httpOptions = {}; } - if (!has(httpOptions, 'headers')) { + if (!('headers' in httpOptions)) { httpOptions.headers = ''; } - if (!has(httpOptions, 'params')) { + if (!('params' in httpOptions)) { httpOptions.params = ''; } @@ -203,10 +202,10 @@ export class RequestService { * */ delete(endPoint: string, httpOptions: RequestOptions = {}): Observable { - if (!has(httpOptions, 'headers')) { + if (!('headers' in httpOptions)) { httpOptions.headers = ''; } - if (!has(httpOptions, 'params')) { + if (!('params' in httpOptions)) { httpOptions.params = ''; } @@ -245,11 +244,11 @@ export class RequestService { } // log the user out if jwt expired - if (has(error, 'error.message') && [ + if (error?.error?.message && [ 'Request must contain an apikey', 'Expired apikey', 'Invalid apikey' - ].includes(error?.error?.message) && !this.loggedOut) { + ].includes(error.error.message) && !this.loggedOut) { // in case lots of api returns the same apikey invalid at the same time this.loggedOut = true; setTimeout( diff --git a/projects/request/src/test.ts b/projects/request/src/test.ts index bcca659d3..9a3d94ca9 100644 --- a/projects/request/src/test.ts +++ b/projects/request/src/test.ts @@ -1,5 +1,6 @@ -// This file is required by karma.conf.js and loads recursively all the .spec and framework files +// this file is required by karma.conf.js and loads recursively all the .spec and framework files +import '@angular/localize/init'; import 'zone.js'; import 'zone.js/testing'; import { getTestBed } from '@angular/core/testing'; @@ -8,20 +9,9 @@ import { platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing'; -declare const require: { - context(path: string, deep?: boolean, filter?: RegExp): { - (id: string): T; - keys(): string[]; - }; -}; - -// First, initialize the Angular testing environment. +// first, initialize the angular testing environment. getTestBed().initTestEnvironment( BrowserDynamicTestingModule, platformBrowserDynamicTesting(), + { teardown: { destroyAfterEach: true } } ); - -// Then we find all the tests. -const context = require.context('./', true, /\.spec\.ts$/); -// And load the modules. -context.keys().map(context); diff --git a/projects/request/tsconfig.lib.json b/projects/request/tsconfig.lib.json index c8c3e7e33..4d5c90353 100644 --- a/projects/request/tsconfig.lib.json +++ b/projects/request/tsconfig.lib.json @@ -1,4 +1,4 @@ -/* To learn more about this file see: https://angular.io/config/tsconfig. */ +/* To learn more about this file see: https://angular.dev/reference/configs/workspace-config#tsconfig. */ { "extends": "../../tsconfig.json", "compilerOptions": { diff --git a/projects/request/tsconfig.lib.prod.json b/projects/request/tsconfig.lib.prod.json index 06de549e1..2f660aa63 100644 --- a/projects/request/tsconfig.lib.prod.json +++ b/projects/request/tsconfig.lib.prod.json @@ -1,4 +1,4 @@ -/* To learn more about this file see: https://angular.io/config/tsconfig. */ +/* To learn more about this file see: https://angular.dev/reference/configs/workspace-config#tsconfig. */ { "extends": "./tsconfig.lib.json", "compilerOptions": { diff --git a/projects/request/tsconfig.spec.json b/projects/request/tsconfig.spec.json index 715dd0a5d..74fabeb7e 100644 --- a/projects/request/tsconfig.spec.json +++ b/projects/request/tsconfig.spec.json @@ -1,4 +1,4 @@ -/* To learn more about this file see: https://angular.io/config/tsconfig. */ +/* To learn more about this file see: https://angular.dev/reference/configs/workspace-config#tsconfig. */ { "extends": "../../tsconfig.json", "compilerOptions": { diff --git a/projects/v3/src/app/app.component.ts b/projects/v3/src/app/app.component.ts index 972ceea14..cc546bf58 100644 --- a/projects/v3/src/app/app.component.ts +++ b/projects/v3/src/app/app.component.ts @@ -19,7 +19,6 @@ import { takeUntil } from "rxjs/operators"; import { ComponentCleanupService } from "./services/component-cleanup.service"; @Component({ - standalone: false, selector: "app-root", templateUrl: "./app.component.html", styleUrls: ["./app.component.scss"], diff --git a/projects/v3/src/app/app.module.ts b/projects/v3/src/app/app.module.ts index cf1e55c9e..ecf7cb6c1 100644 --- a/projects/v3/src/app/app.module.ts +++ b/projects/v3/src/app/app.module.ts @@ -1,4 +1,4 @@ -import { HTTP_INTERCEPTORS } from '@angular/common/http'; +import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; import { NgModule } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; import { RequestInterceptor } from '@v3/services/request.interceptor'; @@ -30,6 +30,7 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; ApolloModule, ], providers: [ + provideHttpClient(withInterceptorsFromDi()), { provide: HTTP_INTERCEPTORS, useClass: RequestInterceptor,