From 7469a7e5528b2113dded55500bace0bc196ec2b5 Mon Sep 17 00:00:00 2001 From: Leonardo Mendoza Date: Mon, 16 Feb 2026 14:36:02 -0600 Subject: [PATCH 01/10] ENGAGE-243 --- scripts/validate-branch-name.husky.js | 14 +++-- scripts/validate-commit-msg.husky.js | 14 +++-- .../search-link-wizard.component.html | 50 ++++++++++++++--- .../search-link-wizard.component.ts | 55 ++++++++++++++++++- .../record-peer-review-import.endpoint.ts | 10 ++++ 5 files changed, 122 insertions(+), 21 deletions(-) diff --git a/scripts/validate-branch-name.husky.js b/scripts/validate-branch-name.husky.js index 607c803509..595dafaa39 100644 --- a/scripts/validate-branch-name.husky.js +++ b/scripts/validate-branch-name.husky.js @@ -1,14 +1,16 @@ #!/usr/bin/env node -// Enforce branch naming: /AA-0000[anything] +// Enforce branch naming: /AAAA-000[0][anything] // - developer-name: lowercase letters, numbers, dot, underscore, hyphen (one or more) -// - ticket: exactly 2 uppercase letters, hyphen, exactly 4 digits (e.g., PD-0000) +// - ticket: 2+ uppercase letters, hyphen, 3 or 4 digits (e.g., EN-243, ENGAGE-243, or PD-0000) // - suffix: any optional characters after the ticket (e.g., "/feature-x", "-refactor", etc.) // Special allowed names: transifex // Examples: // yourname/PD-0000 // yourname/PD-0000-my-feature // your.name/AB-0123/quick-fix +// lmendoza/EN-243 +// lmendoza/ENGAGE-243 const { execSync } = require('child_process') @@ -67,15 +69,17 @@ function main() { process.exit(0) } - // developer-name / AA-0000 [anything] - const pattern = /^[a-z0-9._-]+\/[A-Z]{2}-\d{4}.*$/ + // developer-name / AAAA-000[0] [anything] + const pattern = /^[a-z0-9._-]+\/[A-Z]{2,}-\d{3,4}.*$/ if (!pattern.test(branch)) { console.error( - '\u001b[31mBranch name must follow "/AA-0000[anything]" or be a special allowed name (e.g., "transifex").\u001b[0m' + '\u001b[31mBranch name must follow "/AAAA-000[0][anything]" or be a special allowed name (e.g., "transifex").\u001b[0m' ) console.error('\nExamples:') console.error(' yourname/PD-0000') + console.error(' lmendoza/EN-243') + console.error(' lmendoza/ENGAGE-243') console.error(' yourname/PD-0000-my-feature') console.error(' your.name/AB-0123/quick-fix') console.error(' transifex') diff --git a/scripts/validate-commit-msg.husky.js b/scripts/validate-commit-msg.husky.js index debefe9a98..2fbada2250 100644 --- a/scripts/validate-commit-msg.husky.js +++ b/scripts/validate-commit-msg.husky.js @@ -1,10 +1,11 @@ #!/usr/bin/env node -// Enforce commit message format: AA-0000 [optional message] -// - AA: exactly 2 uppercase letters (A-Z) -// - 0000: exactly 4 digits +// Enforce commit message format: AAAA-000[0] [optional message] +// - AAAA: 2+ uppercase letters (A-Z) +// - 000[0]: 3 or 4 digits // - Example: PD-0000 Add feature // - Also valid: PD-0000 +// - Also valid: ENGAGE-243 const fs = require('fs') @@ -49,15 +50,16 @@ try { process.exit(0) } - // Pattern: start, 2 uppercase letters, hyphen, 4 digits, optional space and message - const pattern = /^[A-Z]{2}-\d{4}(?:\s.+)?$/ + // Pattern: start, 2+ uppercase letters, hyphen, 3-4 digits, optional space and message + const pattern = /^[A-Z]{2,}-\d{3,4}(?:\s.+)?$/ if (!pattern.test(firstLine)) { console.error( - '\u001b[31mCommit message must start with an issue key like "AA-0000" followed by an optional message.\u001b[0m' + '\u001b[31mCommit message must start with an issue key like "AAAA-000[0]" followed by an optional message.\u001b[0m' ) console.error('\nExamples:') console.error(' PD-0000') + console.error(' ENGAGE-243') console.error(' PD-0000 Fix broken tests') console.error('\nYour message was:') console.error(` ${firstLine || '(empty)'}`) diff --git a/src/app/record/components/search-link-wizard/search-link-wizard.component.html b/src/app/record/components/search-link-wizard/search-link-wizard.component.html index bcf0c38907..7fcc469ab1 100644 --- a/src/app/record/components/search-link-wizard/search-link-wizard.component.html +++ b/src/app/record/components/search-link-wizard/search-link-wizard.component.html @@ -1,5 +1,12 @@ -
-

+ +

+ > {{ recordImportWizard.name }} + + {{ recordImportWizard.redirectUriMetadata.type }} +

- + - {{ recordImportWizard.description.substring(0, 125) + '...' }} + {{ displayDescription(recordImportWizard).substring(0, 125) + '...' }} - {{ recordImportWizard.description }} + {{ displayDescription(recordImportWizard) }}
- - {{ recordImportWizard.description }} + + {{ displayDescription(recordImportWizard) }}

-

+ + +

Certified

+ + +

Featured

+ + +

Default

+ diff --git a/src/app/record/components/search-link-wizard/search-link-wizard.component.ts b/src/app/record/components/search-link-wizard/search-link-wizard.component.ts index 4751781c8c..a9281c4a3f 100644 --- a/src/app/record/components/search-link-wizard/search-link-wizard.component.ts +++ b/src/app/record/components/search-link-wizard/search-link-wizard.component.ts @@ -1,4 +1,4 @@ -import { Component, Input, OnInit } from '@angular/core' +import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core' import { RecordImportWizard } from '../../../types/record-peer-review-import.endpoint' @Component({ @@ -7,13 +7,64 @@ import { RecordImportWizard } from '../../../types/record-peer-review-import.end styleUrls: ['./search-link-wizard.component.scss'], standalone: false, }) -export class SearchLinkWizardComponent implements OnInit { +export class SearchLinkWizardComponent implements OnInit, OnChanges { @Input() recordImportWizards: RecordImportWizard[] + certifiedWizards: RecordImportWizard[] = [] + featuredWizards: RecordImportWizard[] = [] + defaultWizards: RecordImportWizard[] = [] + constructor() {} ngOnInit(): void {} + ngOnChanges(changes: SimpleChanges): void { + if (changes.recordImportWizards) { + this.groupWizards() + } + } + + displayDescription(recordImportWizard: RecordImportWizard): string { + return ( + recordImportWizard?.redirectUriMetadata?.defaultDescription || + recordImportWizard?.description || + '' + ) + } + + private groupWizards(): void { + const wizards = this.recordImportWizards || [] + + const certified: RecordImportWizard[] = [] + const featured: RecordImportWizard[] = [] + const defaults: RecordImportWizard[] = [] + + for (const w of wizards) { + const type = w?.redirectUriMetadata?.type || 'Default' + if (type === 'Certified') { + certified.push(w) + } else if (type === 'Featured') { + featured.push(w) + } else { + defaults.push(w) + } + } + + const sortByIndexThenName = (a: RecordImportWizard, b: RecordImportWizard) => { + const ai = a?.redirectUriMetadata?.index ?? Number.POSITIVE_INFINITY + const bi = b?.redirectUriMetadata?.index ?? Number.POSITIVE_INFINITY + if (ai !== bi) return ai - bi + return (a?.name || '').localeCompare(b?.name || '') + } + + const sortByName = (a: RecordImportWizard, b: RecordImportWizard) => + (a?.name || '').localeCompare(b?.name || '') + + this.certifiedWizards = certified.sort(sortByIndexThenName) + this.featuredWizards = featured.sort(sortByIndexThenName) + this.defaultWizards = defaults.sort(sortByName) + } + openImportWizardUrlFilter(client: RecordImportWizard): string { if (client.status === 'RETIRED') { return client.clientWebsite diff --git a/src/app/types/record-peer-review-import.endpoint.ts b/src/app/types/record-peer-review-import.endpoint.ts index 6731a4ec8b..2231a72f22 100644 --- a/src/app/types/record-peer-review-import.endpoint.ts +++ b/src/app/types/record-peer-review-import.endpoint.ts @@ -1,3 +1,12 @@ +export type RecordImportWizardMetadataType = 'Featured' | 'Default' | 'Certified' + +export interface RecordImportWizardMetadata { + type?: RecordImportWizardMetadataType + index?: number + defaultDescription?: string + logoUrl?: string +} + export interface RecordImportWizard { actTypes: string[] clientWebsite: string @@ -6,6 +15,7 @@ export interface RecordImportWizard { id: string name: string redirectUri: string + redirectUriMetadata?: RecordImportWizardMetadata scopes: string status: string show: boolean From 30b91acf863116b6161084583ba78225f1adcac4 Mon Sep 17 00:00:00 2001 From: Leonardo Mendoza Date: Tue, 17 Feb 2026 15:12:40 -0600 Subject: [PATCH 02/10] PD-0000 Fix CSRF token issues --- src/app/app.module.ts | 8 ++ .../core/xsrf/xsrf-fallback.interceptor.ts | 80 +++++++++++++++++++ src/app/types/config.endpoint.ts | 1 + 3 files changed, 89 insertions(+) create mode 100644 src/app/core/xsrf/xsrf-fallback.interceptor.ts diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 0d66804947..d3024439ce 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -11,6 +11,7 @@ import { BidiModule } from '@angular/cdk/bidi' import { PseudoModule } from 'src/locale/i18n.pseudo.component' import { TitleService } from './core/title-service/title.service' import { HttpContentTypeHeaderInterceptor } from './core/http-content-type-header-interceptor/http-content-type-header-interceptor' +import { XsrfFallbackInterceptor } from './core/xsrf/xsrf-fallback.interceptor' import { FirefoxXsrfPreloadInterceptor } from './core/lang-preload/firefox-xsrf-preload.interceptor' import { HTTP_INTERCEPTORS, @@ -56,6 +57,13 @@ import { FormsModule } from '@angular/forms' useClass: HttpContentTypeHeaderInterceptor, multi: true, }, + // Fallback XSRF interceptor to ensure x-xsrf-token is present + // when using local proxy / same-origin dev setups. + { + provide: HTTP_INTERCEPTORS, + useClass: XsrfFallbackInterceptor, + multi: true, + }, provideHttpClient( withInterceptorsFromDi(), withXsrfConfiguration({ diff --git a/src/app/core/xsrf/xsrf-fallback.interceptor.ts b/src/app/core/xsrf/xsrf-fallback.interceptor.ts new file mode 100644 index 0000000000..60c6d3811d --- /dev/null +++ b/src/app/core/xsrf/xsrf-fallback.interceptor.ts @@ -0,0 +1,80 @@ +import { + HttpEvent, + HttpHandler, + HttpInterceptor, + HttpRequest, +} from '@angular/common/http' +import { Injectable } from '@angular/core' +import { Observable } from 'rxjs' +import { CookieService } from 'ngx-cookie-service' + +declare const runtimeEnvironment: any + +/** + * XsrfFallbackInterceptor + * + * Fallback XSRF interceptor to cover cases where Angular's built-in XSRF + * support (configured via withXsrfConfiguration) does not attach the header, + * especially when using the local proxy setup. + * + * Behaviour: + * - For mutating backend calls (POST/PUT/PATCH/DELETE) to ORCID web APIs: + * - If an XSRF header is already present, do nothing. + * - Otherwise, read the appropriate cookie and set `x-xsrf-token`: + * - For requests to AUTH_SERVER origin → `AUTH-XSRF-TOKEN` + * - For API_WEB / BASE_URL / relative (e.g. /signin/auth.json) → `XSRF-TOKEN` + */ +@Injectable() +export class XsrfFallbackInterceptor implements HttpInterceptor { + constructor(private _cookie: CookieService) {} + + intercept( + req: HttpRequest, + next: HttpHandler + ): Observable> { + const method = req.method.toUpperCase() + + // Only care about mutating requests + if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) { + return next.handle(req) + } + + // If header already present (either manually or by Angular), leave as-is + if (req.headers.has('x-xsrf-token')) { + return next.handle(req) + } + + const apiBase = runtimeEnvironment.API_WEB + const baseUrl = runtimeEnvironment.BASE_URL + const authBase = runtimeEnvironment.AUTH_SERVER + + const isBackendHost = + req.url.startsWith(apiBase) || + req.url.startsWith(baseUrl) || + req.url.startsWith('/') || + req.url.startsWith(authBase) + + if (!isBackendHost) { + return next.handle(req) + } + + // Decide which cookie to use based on target *host*, not path. + // Only the auth server (AUTH_SERVER origin) sets/expects AUTH-XSRF-TOKEN. + // API_WEB / BASE_URL / relative URLs (e.g. /signin/auth.json) use XSRF-TOKEN. + const isAuthServerCall = req.url.startsWith(authBase) + + const cookieName = isAuthServerCall ? 'AUTH-XSRF-TOKEN' : 'XSRF-TOKEN' + const token = this._cookie.get(cookieName) + + if (!token) { + return next.handle(req) + } + + const cloned = req.clone({ + withCredentials: true, + headers: req.headers.set('x-xsrf-token', token), + }) + + return next.handle(cloned) + } +} diff --git a/src/app/types/config.endpoint.ts b/src/app/types/config.endpoint.ts index 445e418312..0d06c247a6 100644 --- a/src/app/types/config.endpoint.ts +++ b/src/app/types/config.endpoint.ts @@ -14,6 +14,7 @@ export const TogglzFlag = { MAINTENANCE_MESSAGE: 'MAINTENANCE_MESSAGE', FEATURED_AFFILIATIONS: 'FEATURED_AFFILIATIONS', PERMISSION_NOTIFICATIONS: 'PERMISSION_NOTIFICATIONS', + SEARCH_AND_LINK_WIZARD_WITH_CERTIFIED_AND_FEATURED_LINKS: 'SEARCH_AND_LINK_WIZARD_WITH_CERTIFIED_AND_FEATURED_LINKS', } as const export type TogglzFlag = (typeof TogglzFlag)[keyof typeof TogglzFlag] From 76348b366a1eb7b66cf0fbc5a4936c264ae383ce Mon Sep 17 00:00:00 2001 From: Leonardo Mendoza Date: Tue, 17 Feb 2026 15:12:47 -0600 Subject: [PATCH 03/10] Revert "ENGAGE-243" This reverts commit 7469a7e5528b2113dded55500bace0bc196ec2b5. --- scripts/validate-branch-name.husky.js | 14 ++--- scripts/validate-commit-msg.husky.js | 14 ++--- .../search-link-wizard.component.html | 50 +++-------------- .../search-link-wizard.component.ts | 55 +------------------ .../record-peer-review-import.endpoint.ts | 10 ---- 5 files changed, 21 insertions(+), 122 deletions(-) diff --git a/scripts/validate-branch-name.husky.js b/scripts/validate-branch-name.husky.js index 595dafaa39..607c803509 100644 --- a/scripts/validate-branch-name.husky.js +++ b/scripts/validate-branch-name.husky.js @@ -1,16 +1,14 @@ #!/usr/bin/env node -// Enforce branch naming: /AAAA-000[0][anything] +// Enforce branch naming: /AA-0000[anything] // - developer-name: lowercase letters, numbers, dot, underscore, hyphen (one or more) -// - ticket: 2+ uppercase letters, hyphen, 3 or 4 digits (e.g., EN-243, ENGAGE-243, or PD-0000) +// - ticket: exactly 2 uppercase letters, hyphen, exactly 4 digits (e.g., PD-0000) // - suffix: any optional characters after the ticket (e.g., "/feature-x", "-refactor", etc.) // Special allowed names: transifex // Examples: // yourname/PD-0000 // yourname/PD-0000-my-feature // your.name/AB-0123/quick-fix -// lmendoza/EN-243 -// lmendoza/ENGAGE-243 const { execSync } = require('child_process') @@ -69,17 +67,15 @@ function main() { process.exit(0) } - // developer-name / AAAA-000[0] [anything] - const pattern = /^[a-z0-9._-]+\/[A-Z]{2,}-\d{3,4}.*$/ + // developer-name / AA-0000 [anything] + const pattern = /^[a-z0-9._-]+\/[A-Z]{2}-\d{4}.*$/ if (!pattern.test(branch)) { console.error( - '\u001b[31mBranch name must follow "/AAAA-000[0][anything]" or be a special allowed name (e.g., "transifex").\u001b[0m' + '\u001b[31mBranch name must follow "/AA-0000[anything]" or be a special allowed name (e.g., "transifex").\u001b[0m' ) console.error('\nExamples:') console.error(' yourname/PD-0000') - console.error(' lmendoza/EN-243') - console.error(' lmendoza/ENGAGE-243') console.error(' yourname/PD-0000-my-feature') console.error(' your.name/AB-0123/quick-fix') console.error(' transifex') diff --git a/scripts/validate-commit-msg.husky.js b/scripts/validate-commit-msg.husky.js index 2fbada2250..debefe9a98 100644 --- a/scripts/validate-commit-msg.husky.js +++ b/scripts/validate-commit-msg.husky.js @@ -1,11 +1,10 @@ #!/usr/bin/env node -// Enforce commit message format: AAAA-000[0] [optional message] -// - AAAA: 2+ uppercase letters (A-Z) -// - 000[0]: 3 or 4 digits +// Enforce commit message format: AA-0000 [optional message] +// - AA: exactly 2 uppercase letters (A-Z) +// - 0000: exactly 4 digits // - Example: PD-0000 Add feature // - Also valid: PD-0000 -// - Also valid: ENGAGE-243 const fs = require('fs') @@ -50,16 +49,15 @@ try { process.exit(0) } - // Pattern: start, 2+ uppercase letters, hyphen, 3-4 digits, optional space and message - const pattern = /^[A-Z]{2,}-\d{3,4}(?:\s.+)?$/ + // Pattern: start, 2 uppercase letters, hyphen, 4 digits, optional space and message + const pattern = /^[A-Z]{2}-\d{4}(?:\s.+)?$/ if (!pattern.test(firstLine)) { console.error( - '\u001b[31mCommit message must start with an issue key like "AAAA-000[0]" followed by an optional message.\u001b[0m' + '\u001b[31mCommit message must start with an issue key like "AA-0000" followed by an optional message.\u001b[0m' ) console.error('\nExamples:') console.error(' PD-0000') - console.error(' ENGAGE-243') console.error(' PD-0000 Fix broken tests') console.error('\nYour message was:') console.error(` ${firstLine || '(empty)'}`) diff --git a/src/app/record/components/search-link-wizard/search-link-wizard.component.html b/src/app/record/components/search-link-wizard/search-link-wizard.component.html index 7fcc469ab1..bcf0c38907 100644 --- a/src/app/record/components/search-link-wizard/search-link-wizard.component.html +++ b/src/app/record/components/search-link-wizard/search-link-wizard.component.html @@ -1,12 +1,5 @@ - -

- +
+

> {{ recordImportWizard.name }} - - {{ recordImportWizard.redirectUriMetadata.type }} -

- + - {{ displayDescription(recordImportWizard).substring(0, 125) + '...' }} + {{ recordImportWizard.description.substring(0, 125) + '...' }} - {{ displayDescription(recordImportWizard) }} + {{ recordImportWizard.description }}
- - {{ displayDescription(recordImportWizard) }} + + {{ recordImportWizard.description }}

- - -

Certified

- - -

Featured

- - -

Default

- +
diff --git a/src/app/record/components/search-link-wizard/search-link-wizard.component.ts b/src/app/record/components/search-link-wizard/search-link-wizard.component.ts index a9281c4a3f..4751781c8c 100644 --- a/src/app/record/components/search-link-wizard/search-link-wizard.component.ts +++ b/src/app/record/components/search-link-wizard/search-link-wizard.component.ts @@ -1,4 +1,4 @@ -import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core' +import { Component, Input, OnInit } from '@angular/core' import { RecordImportWizard } from '../../../types/record-peer-review-import.endpoint' @Component({ @@ -7,64 +7,13 @@ import { RecordImportWizard } from '../../../types/record-peer-review-import.end styleUrls: ['./search-link-wizard.component.scss'], standalone: false, }) -export class SearchLinkWizardComponent implements OnInit, OnChanges { +export class SearchLinkWizardComponent implements OnInit { @Input() recordImportWizards: RecordImportWizard[] - certifiedWizards: RecordImportWizard[] = [] - featuredWizards: RecordImportWizard[] = [] - defaultWizards: RecordImportWizard[] = [] - constructor() {} ngOnInit(): void {} - ngOnChanges(changes: SimpleChanges): void { - if (changes.recordImportWizards) { - this.groupWizards() - } - } - - displayDescription(recordImportWizard: RecordImportWizard): string { - return ( - recordImportWizard?.redirectUriMetadata?.defaultDescription || - recordImportWizard?.description || - '' - ) - } - - private groupWizards(): void { - const wizards = this.recordImportWizards || [] - - const certified: RecordImportWizard[] = [] - const featured: RecordImportWizard[] = [] - const defaults: RecordImportWizard[] = [] - - for (const w of wizards) { - const type = w?.redirectUriMetadata?.type || 'Default' - if (type === 'Certified') { - certified.push(w) - } else if (type === 'Featured') { - featured.push(w) - } else { - defaults.push(w) - } - } - - const sortByIndexThenName = (a: RecordImportWizard, b: RecordImportWizard) => { - const ai = a?.redirectUriMetadata?.index ?? Number.POSITIVE_INFINITY - const bi = b?.redirectUriMetadata?.index ?? Number.POSITIVE_INFINITY - if (ai !== bi) return ai - bi - return (a?.name || '').localeCompare(b?.name || '') - } - - const sortByName = (a: RecordImportWizard, b: RecordImportWizard) => - (a?.name || '').localeCompare(b?.name || '') - - this.certifiedWizards = certified.sort(sortByIndexThenName) - this.featuredWizards = featured.sort(sortByIndexThenName) - this.defaultWizards = defaults.sort(sortByName) - } - openImportWizardUrlFilter(client: RecordImportWizard): string { if (client.status === 'RETIRED') { return client.clientWebsite diff --git a/src/app/types/record-peer-review-import.endpoint.ts b/src/app/types/record-peer-review-import.endpoint.ts index 2231a72f22..6731a4ec8b 100644 --- a/src/app/types/record-peer-review-import.endpoint.ts +++ b/src/app/types/record-peer-review-import.endpoint.ts @@ -1,12 +1,3 @@ -export type RecordImportWizardMetadataType = 'Featured' | 'Default' | 'Certified' - -export interface RecordImportWizardMetadata { - type?: RecordImportWizardMetadataType - index?: number - defaultDescription?: string - logoUrl?: string -} - export interface RecordImportWizard { actTypes: string[] clientWebsite: string @@ -15,7 +6,6 @@ export interface RecordImportWizard { id: string name: string redirectUri: string - redirectUriMetadata?: RecordImportWizardMetadata scopes: string status: string show: boolean From b3d533157d3b22ea4aab1146e348ffd8979a9145 Mon Sep 17 00:00:00 2001 From: Leonardo Mendoza Date: Tue, 17 Feb 2026 15:33:56 -0600 Subject: [PATCH 04/10] ENGAGE-243 --- .../core/record-works/record-works.service.ts | 16 +++- .../search-link-wizard.component.html | 17 ++-- .../search-link-wizard.component.ts | 25 +++--- .../modal-works-search-link.component.html | 2 +- .../modal-works-search-link.component.ts | 78 +++++++++++-------- .../record-peer-review-import.endpoint.ts | 23 ++++-- 6 files changed, 99 insertions(+), 62 deletions(-) diff --git a/src/app/core/record-works/record-works.service.ts b/src/app/core/record-works/record-works.service.ts index 84e1f0b843..81f285e958 100644 --- a/src/app/core/record-works/record-works.service.ts +++ b/src/app/core/record-works/record-works.service.ts @@ -556,9 +556,19 @@ export class RecordWorksService { } loadWorkImportWizardList(): Observable { - return this._http.get( - runtimeEnvironment.API_WEB + 'workspace/retrieve-work-import-wizards.json' - ) + return this._togglz + .getStateOf(TogglzFlag.SEARCH_AND_LINK_WIZARD_WITH_CERTIFIED_AND_FEATURED_LINKS) + .pipe( + take(1), + switchMap((useNewEndpoint) => + this._http.get( + runtimeEnvironment.API_WEB + + (useNewEndpoint + ? 'workspace/retrieve-search-and-link-wizard.json' + : 'workspace/retrieve-work-import-wizards.json') + ) + ) + ) } loadExternalId( diff --git a/src/app/record/components/search-link-wizard/search-link-wizard.component.html b/src/app/record/components/search-link-wizard/search-link-wizard.component.html index bcf0c38907..1ad03adeae 100644 --- a/src/app/record/components/search-link-wizard/search-link-wizard.component.html +++ b/src/app/record/components/search-link-wizard/search-link-wizard.component.html @@ -7,11 +7,18 @@

> {{ recordImportWizard.name }} + + Connected +

- + - {{ recordImportWizard.description.substring(0, 125) + '...' }} + {{ (recordImportWizard.description || recordImportWizard.redirectUriMetadata?.defaultDescription || '').substring(0, 125) + '...' }} - {{ recordImportWizard.description }} + {{ recordImportWizard.description || recordImportWizard.redirectUriMetadata?.defaultDescription || '' }}
- - {{ recordImportWizard.description }} + + {{ recordImportWizard.description || recordImportWizard.redirectUriMetadata?.defaultDescription || '' }}

diff --git a/src/app/record/components/search-link-wizard/search-link-wizard.component.ts b/src/app/record/components/search-link-wizard/search-link-wizard.component.ts index 4751781c8c..c5a0dadede 100644 --- a/src/app/record/components/search-link-wizard/search-link-wizard.component.ts +++ b/src/app/record/components/search-link-wizard/search-link-wizard.component.ts @@ -15,20 +15,19 @@ export class SearchLinkWizardComponent implements OnInit { ngOnInit(): void {} openImportWizardUrlFilter(client: RecordImportWizard): string { - if (client.status === 'RETIRED') { - return client.clientWebsite - } else { - return ( - runtimeEnvironment.BASE_URL + - 'oauth/authorize' + - '?client_id=' + - client.id + - '&response_type=code&scope=' + - client.scopes + - '&redirect_uri=' + - client.redirectUri - ) + if (client.isConnected || client.status === 'RETIRED') { + return client.clientWebsite || '#' } + return ( + runtimeEnvironment.BASE_URL + + 'oauth/authorize' + + '?client_id=' + + client.id + + '&response_type=code&scope=' + + encodeURIComponent(client.scopes) + + '&redirect_uri=' + + encodeURIComponent(client.redirectUri) + ) } toggle(recordImportWizard: RecordImportWizard) { diff --git a/src/app/record/components/work-stack-group/modals/work-search-link-modal/modal-works-search-link.component.html b/src/app/record/components/work-stack-group/modals/work-search-link-modal/modal-works-search-link.component.html index a906e3b562..65be19af80 100644 --- a/src/app/record/components/work-stack-group/modals/work-search-link-modal/modal-works-search-link.component.html +++ b/src/app/record/components/work-stack-group/modals/work-search-link-modal/modal-works-search-link.component.html @@ -32,7 +32,7 @@


- @@ -99,6 +121,7 @@

More services links

ImportWorksDialogData.

    +
  • loading: Optional. When true, the dialog shows shimmer skeleton placeholders for both sections (certified and more services) instead of link content. Use when fetching data asynchronously; set to false when data is ready (e.g. dialogRef.componentInstance.data = { ...data, loading: false }). Uses orcid-skeleton-placeholder from @orcid/ui.
  • title: Header text.
  • introText: Optional intro paragraph.
  • supportLink: Optional { url, label } link below intro.
  • diff --git a/projects/orcid-ui-docs/src/app/pages/orcid-registry-ui/import-works-dialog-page.component.ts b/projects/orcid-ui-docs/src/app/pages/orcid-registry-ui/import-works-dialog-page.component.ts index a5afe77501..617a6fa7dd 100644 --- a/projects/orcid-ui-docs/src/app/pages/orcid-registry-ui/import-works-dialog-page.component.ts +++ b/projects/orcid-ui-docs/src/app/pages/orcid-registry-ui/import-works-dialog-page.component.ts @@ -1,6 +1,8 @@ import { Component } from '@angular/core' import { CommonModule } from '@angular/common' import { FormsModule } from '@angular/forms' +import { of } from 'rxjs' +import { delay } from 'rxjs/operators' import { MatButtonModule } from '@angular/material/button' import { MatDialog } from '@angular/material/dialog' import { MatFormFieldModule } from '@angular/material/form-field' @@ -88,6 +90,39 @@ export class ImportWorksDialogPageComponent { this._dialog.open(ImportWorksDialogComponent, { panelClass: ORCID_MODAL_DIALOG_PANEL_CLASS, data, + width: '850px', + maxHeight: '90vh', }) } + + /** Opens the dialog with loading (shimmer) state, then fills in data after a short delay to demo the flow. */ + openDialogWithLoading(): void { + const dialogRef = this._dialog.open(ImportWorksDialogComponent, { + panelClass: ORCID_MODAL_DIALOG_PANEL_CLASS, + data: { + loading: true, + title: this.title, + certifiedLinks: [], + moreServicesLinks: [], + } as ImportWorksDialogData, + width: '850px', + maxHeight: '90vh', + }) + const data: ImportWorksDialogData = { + loading: false, + title: this.title, + introText: this.introText, + supportLink: + this.supportLinkUrl && this.supportLinkLabel + ? { url: this.supportLinkUrl, label: this.supportLinkLabel } + : undefined, + certifiedLinks: [...this.certifiedLinks], + moreServicesLinks: [...this.moreServicesLinks], + } + of(data) + .pipe(delay(5000)) + .subscribe((d) => { + dialogRef.componentInstance.data = d + }) + } } diff --git a/projects/orcid-ui-docs/src/app/pages/orcid-ui/skeleton-placeholder-page.component.html b/projects/orcid-ui-docs/src/app/pages/orcid-ui/skeleton-placeholder-page.component.html index 210f44ce45..cbefb9f854 100644 --- a/projects/orcid-ui-docs/src/app/pages/orcid-ui/skeleton-placeholder-page.component.html +++ b/projects/orcid-ui-docs/src/app/pages/orcid-ui/skeleton-placeholder-page.component.html @@ -1,8 +1,12 @@
    +

    + Use the controls below to try different shapes, sizes, and background modes. Toggle + Use on accent background to switch between light shimmer (for dark areas) and dark shimmer (for white/light surfaces). +

    Shape @@ -33,32 +37,83 @@ /> % + + + Use on accent background +

    Playground

    -
    +

    + Background switches with the checkbox: dark when accent, white when surface. +

    +
    + +
    +

    Both variants

    +

    + Same placeholder on accent (left) and on surface (right). +

    +
    +
    + +
    +
    + +
    +
    +
    +
    + +
    +
    <!-- On accent/dark (e.g. record header) — default -->
    +<orcid-skeleton-placeholder
    +  shape="circle"
    +  width="36px"
    +  height="36px"
    +></orcid-skeleton-placeholder>
    +
    +<!-- On light/white (e.g. cards, dialogs) -->
    +<orcid-skeleton-placeholder
    +  shape="square"
    +  width="48px"
    +  height="48px"
    +  [accentBackground]="false"
    +></orcid-skeleton-placeholder>
    +

    + All inputs are optional. Use accentBackground to match the component’s background so the shimmer is visible. +

      -
    • shape: 'square' | 'circle' (default: 'square')
    • -
    • width: string (default: '100%')
    • -
    • height: string (default: '100%')
    • -
    • - shimmerPercentage: number (default: 100) - Percentage of - container size that the shimmer effect will cover (centered) -
    • +
    • shape: 'square' | 'circle' (default: 'square')
    • +
    • width: string (default: '100%') — any valid CSS width
    • +
    • height: string (default: '100%') — any valid CSS height
    • +
    • shimmerPercentage: number (default: 100) — percentage of the element the shimmer band covers, centered
    • +
    • accentBackground: boolean (default: true) — true for accent/dark backgrounds (light shimmer); false for light/white backgrounds (dark shimmer)
    diff --git a/projects/orcid-ui-docs/src/app/pages/orcid-ui/skeleton-placeholder-page.component.ts b/projects/orcid-ui-docs/src/app/pages/orcid-ui/skeleton-placeholder-page.component.ts index b5817cc3a1..282b7a37ef 100644 --- a/projects/orcid-ui-docs/src/app/pages/orcid-ui/skeleton-placeholder-page.component.ts +++ b/projects/orcid-ui-docs/src/app/pages/orcid-ui/skeleton-placeholder-page.component.ts @@ -4,6 +4,7 @@ import { FormsModule } from '@angular/forms' import { MatFormFieldModule } from '@angular/material/form-field' import { MatInputModule } from '@angular/material/input' import { MatSelectModule } from '@angular/material/select' +import { MatCheckboxModule } from '@angular/material/checkbox' import { SkeletonPlaceholderComponent } from '@orcid/ui' import { DocumentationPageComponent } from '../../components/documentation-page/documentation-page.component' @@ -16,6 +17,7 @@ import { DocumentationPageComponent } from '../../components/documentation-page/ MatFormFieldModule, MatInputModule, MatSelectModule, + MatCheckboxModule, SkeletonPlaceholderComponent, DocumentationPageComponent, ], @@ -24,9 +26,28 @@ import { DocumentationPageComponent } from '../../components/documentation-page/ ` .example-container { padding: 24px; - background: #003449; /* Dark background to see the placeholder */ border-radius: 4px; } + .example-container.accent { + background: #003449; + } + .example-container.surface { + background: #fff; + border: 1px solid #eee; + } + .example-caption { + margin: 0 0 8px; + font-size: 14px; + color: #555; + } + .examples-row { + display: flex; + gap: 16px; + flex-wrap: wrap; + } + .examples-row .example-container { + flex: 0 0 auto; + } `, ], }) @@ -36,5 +57,6 @@ export class SkeletonPlaceholderPageComponent { width: '100px', height: '100px', shimmerPercentage: 100, + accentBackground: true, } } diff --git a/projects/orcid-ui/src/lib/components/modal/modal.component.scss b/projects/orcid-ui/src/lib/components/modal/modal.component.scss index be025ac0ee..44aaf53978 100644 --- a/projects/orcid-ui/src/lib/components/modal/modal.component.scss +++ b/projects/orcid-ui/src/lib/components/modal/modal.component.scss @@ -1,3 +1,15 @@ +/* So orcid-modal host participates in flex when used inside a dialog (e.g. import-works-dialog) and does not overflow */ +:host { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + min-width: 0; + overflow: hidden; + max-width: 100%; + height: 100%; +} + /* Constrain overlay and inner container so height is bounded and body scrolls inside .orcid-modal__container */ .orcid-modal-dialog-panel { max-height: 90vh; @@ -161,4 +173,3 @@ overflow: hidden; } } - diff --git a/projects/orcid-ui/src/lib/components/modal/modal.component.ts b/projects/orcid-ui/src/lib/components/modal/modal.component.ts index d276c4c858..c00a839667 100644 --- a/projects/orcid-ui/src/lib/components/modal/modal.component.ts +++ b/projects/orcid-ui/src/lib/components/modal/modal.component.ts @@ -12,7 +12,6 @@ export const ORCID_MODAL_DIALOG_PANEL_CLASS = 'orcid-modal-dialog-panel' imports: [NgIf, MatButtonModule, MatIconModule], templateUrl: './modal.component.html', styleUrls: ['./modal.component.scss'], - encapsulation: ViewEncapsulation.None, }) export class OrcidModalComponent { /** Optional title string for the modal header. */ diff --git a/projects/orcid-ui/src/lib/components/skeleton-placeholder/skeleton-placeholder.component.ts b/projects/orcid-ui/src/lib/components/skeleton-placeholder/skeleton-placeholder.component.ts index c9bc42a78c..d67d6b071e 100644 --- a/projects/orcid-ui/src/lib/components/skeleton-placeholder/skeleton-placeholder.component.ts +++ b/projects/orcid-ui/src/lib/components/skeleton-placeholder/skeleton-placeholder.component.ts @@ -15,6 +15,7 @@ import { CommonModule } from '@angular/common' overflow: hidden; } + /* Default: accent/dark background — light shimmer (production behavior) */ :host::before { content: ''; position: absolute; @@ -34,6 +35,24 @@ import { CommonModule } from '@angular/common' animation: shimmer 2s infinite ease-in-out; } + /* Light/white background — dark shimmer so it’s visible on surface */ + :host.skeleton-placeholder--surface { + background-color: rgba(0, 0, 0, 0.04); + } + + :host.skeleton-placeholder--surface::before { + background: linear-gradient( + 90deg, + transparent 0%, + rgba(0, 0, 0, 0.05) 25%, + rgba(0, 0, 0, 0.10) 50%, + rgba(0, 0, 0, 0.05) 75%, + transparent 100% + ); + background-size: 200% 100%; + animation: shimmer 2s infinite ease-in-out; + } + :host.circle { border-radius: 50%; } @@ -66,9 +85,17 @@ export class SkeletonPlaceholderComponent { @Input() width = '100%' @Input() height = '100%' @Input() shimmerPercentage = 100 + /** + * When true (default), shimmer is tuned for accent/dark backgrounds (light shimmer). + * When false, shimmer is tuned for light/white backgrounds (dark shimmer). + */ + @Input() accentBackground = true - @HostBinding('class') get hostClasses() { - return this.shape + @HostBinding('class') get hostClasses(): string { + const shapeClass = this.shape + const surfaceClass = + !this.accentBackground ? 'skeleton-placeholder--surface' : '' + return [shapeClass, surfaceClass].filter(Boolean).join(' ') } @HostBinding('style.width') get hostWidth() { diff --git a/src/app/cdk/panel/panels/panels.component.ts b/src/app/cdk/panel/panels/panels.component.ts index e456bea1ce..d98abddd96 100644 --- a/src/app/cdk/panel/panels/panels.component.ts +++ b/src/app/cdk/panel/panels/panels.component.ts @@ -89,6 +89,8 @@ export class PanelsComponent implements OnInit { const menuOption = this.addMenuOptions.find((x) => x.action === action) if (menuOption && menuOption.modal) { this.openModal(menuOption.modal, { ...menuOption, type: menuOption.type }) + } else if (menuOption && type === 'works') { + this.addEvent.emit(action) } else { switch (type) { case 'employment': diff --git a/src/app/core/record-works/record-works.service.ts b/src/app/core/record-works/record-works.service.ts index 81f285e958..aabb295496 100644 --- a/src/app/core/record-works/record-works.service.ts +++ b/src/app/core/record-works/record-works.service.ts @@ -1,6 +1,12 @@ import { HttpClient } from '@angular/common/http' import { Injectable } from '@angular/core' -import { BehaviorSubject, Observable, of, ReplaySubject } from 'rxjs' +import { + BehaviorSubject, + forkJoin, + Observable, + of, + ReplaySubject, +} from 'rxjs' import { catchError, first, @@ -36,7 +42,15 @@ import { VisibilityStrings, } from '../../types/common.endpoint' import { DEFAULT_PAGE_SIZE, EXTERNAL_ID_TYPE_WORK } from 'src/app/constants' -import { RecordImportWizard } from '../../types/record-peer-review-import.endpoint' +import { + RecordImportWizard, + SearchAndLinkWizardFormSummaryResponse, +} from '../../types/record-peer-review-import.endpoint' +import type { + ImportWorksDialogData, + ImportWorksCertifiedLink, + ImportWorksMoreLink, +} from '@orcid/registry-ui' import { SortOrderType } from '../../types/sort' import { TogglzService } from 'src/app/core/togglz/togglz.service' import { TogglzFlag } from 'src/app/types/config.endpoint' @@ -564,13 +578,164 @@ export class RecordWorksService { this._http.get( runtimeEnvironment.API_WEB + (useNewEndpoint - ? 'workspace/retrieve-search-and-link-wizard.json' + ? 'workspace/retrieve-works-search-and-link-wizard.json' : 'workspace/retrieve-work-import-wizards.json') ) ) ) } + /** + * Returns the static part of the Import Works dialog data (title, intro, labels, empty link lists). + * Sets loading: true so the dialog shows skeleton placeholders until full data is assigned. + * Use this to open the dialog immediately; then pass full data from loadSearchAndLinkWizardDialogData when it loads. + */ + getImportWorksDialogDataSkeleton(): ImportWorksDialogData { + return this._getImportWorksDialogDataStatic([], [], true) + } + + private _getImportWorksDialogDataStatic( + certifiedLinks: ImportWorksCertifiedLink[], + moreServicesLinks: ImportWorksMoreLink[], + loading?: boolean + ): ImportWorksDialogData { + return { + ...(loading !== undefined && { loading }), + title: $localize`:@@works.importYourWorks:Import your works`, + introText: $localize`:@@works.importIntroText:These services can help you update your ORCID record quickly by searching for your research outputs from various databases. Connect to a service to grant permission and add selected research outputs to your ORCID record.`, + supportLink: { + url: 'https://support.orcid.org/hc/en-us/articles/360006973653-Add-works-by-direct-import-from-other-systems', + label: $localize`:@@works.importSupportLinkLabel:Find out more about importing works into your ORCID record`, + }, + certifiedSectionHeading: $localize`:@@works.certifiedSectionHeading:ORCID Certified Services`, + moreServicesHeading: $localize`:@@works.moreServicesHeading:More Services`, + connectNowLabel: $localize`:@@works.connectNow:Connect now`, + connectedLabel: $localize`:@@works.connected:Connected`, + certifiedLinks, + moreServicesLinks, + } + } + + /** + * Loads data for the new Import Works dialog (certified + more services). + * Use when SEARCH_AND_LINK_WIZARD_WITH_CERTIFIED_AND_FEATURED_LINKS is enabled. + * Certified/Featured: description from S3 localize.properties by client id, else redirectUriMetadata.defaultDescription. + * More Services: description from top-level `description` only. + */ + loadSearchAndLinkWizardDialogData(locale: string): Observable { + const list$ = this._http.get( + runtimeEnvironment.API_WEB + 'workspace/retrieve-works-search-and-link-wizard.json' + ) + const baseUrl = (runtimeEnvironment as { CERTIFIED_LINKS_LOCALIZE_BASE_URL?: string }) + .CERTIFIED_LINKS_LOCALIZE_BASE_URL?.replace(/\/$/, '') + const localizeUrl = baseUrl ? `${baseUrl}/works-search-and-link.${locale}.properties` : null + const localize$ = localizeUrl + ? this._http.get(localizeUrl, { responseType: 'text' }).pipe( + catchError(() => of('')) + ) + : of('') + + return forkJoin({ list: list$, localize: localize$ }).pipe( + map(({ list, localize }) => { + const localizeByClientId = this._parsePropertiesFile(localize) + const certifiedLinks: ImportWorksCertifiedLink[] = [] + const featuredForMore: Array<{ link: ImportWorksMoreLink; index: number }> = [] + const defaultForMore: ImportWorksMoreLink[] = [] + + for (const item of list) { + const type = item.redirectUriMetadata?.type + const connected = item.connected ?? false + const name = item.name ?? '' + const redirectUri = item.redirectUri ?? '' + const scopes = item.scopes ?? '' + const oauthUrl = this._buildOAuthAuthorizeUrl(item.id, scopes, redirectUri) + const imageUrl = item.redirectUriMetadata?.logoUrl + + if (type === 'Certified') { + const description = + localizeByClientId[`${item.id}-client-description`] ?? + localizeByClientId[item.id] ?? + item.redirectUriMetadata?.defaultDescription ?? + '' + certifiedLinks.push({ + name, + description, + url: oauthUrl, + connected, + imageUrl, + }) + } else if (type === 'Featured') { + const description = + localizeByClientId[`${item.id}-client-description`] ?? + localizeByClientId[item.id] ?? + item.redirectUriMetadata?.defaultDescription ?? + '' + const index = item.redirectUriMetadata?.index ?? 999 + featuredForMore.push({ + link: { name, description, url: oauthUrl, imageUrl }, + index, + }) + } else { + const description = item.description ?? '' + defaultForMore.push({ name, description, url: oauthUrl, imageUrl }) + } + } + + featuredForMore.sort((a, b) => a.index - b.index) + const moreServicesLinks: ImportWorksMoreLink[] = [ + ...featuredForMore.map((x) => x.link), + ...defaultForMore, + ] + + return this._getImportWorksDialogDataStatic(certifiedLinks, moreServicesLinks, false) + }) + ) + } + + /** + * Builds the OAuth authorize URL used when the user clicks "Connect now". + * Matches the format used by the legacy search-link-wizard. + */ + private _buildOAuthAuthorizeUrl( + clientId: string, + scopes: string, + redirectUri: string + ): string { + const base = (runtimeEnvironment as { BASE_URL?: string }).BASE_URL ?? '' + return ( + base + + 'oauth/authorize' + + '?client_id=' + + encodeURIComponent(clientId) + + '&response_type=code' + + '&scope=' + + encodeURIComponent(scopes) + + '&redirect_uri=' + + encodeURIComponent(redirectUri) + ) + } + + /** + * Parses a Java .properties file into a key-value map. + * Handles key=value lines and skips comments/empty lines. + */ + private _parsePropertiesFile(text: string): Record { + const out: Record = {} + if (!text || typeof text !== 'string') return out + const lines = text.split(/\r?\n/) + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) continue + const eq = trimmed.indexOf('=') + if (eq < 0) continue + const key = trimmed.slice(0, eq).trim() + let value = trimmed.slice(eq + 1).trim() + value = value.replace(/\\n/g, '\n').replace(/\\t/g, '\t').replace(/\\\\/g, '\\') + out[key] = value + } + return out + } + loadExternalId( externalId: string, type: EXTERNAL_ID_TYPE_WORK diff --git a/src/app/record/components/work-stack-group/modals/work-search-link-modal/modal-works-search-link.component.html b/src/app/record/components/work-stack-group/modals/work-search-link-modal/modal-works-search-link.component.html index 65be19af80..a906e3b562 100644 --- a/src/app/record/components/work-stack-group/modals/work-search-link-modal/modal-works-search-link.component.html +++ b/src/app/record/components/work-stack-group/modals/work-search-link-modal/modal-works-search-link.component.html @@ -32,7 +32,7 @@


    -