diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..7eb1152235 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,25 @@ +# Orcid Angular — Agent Notes + +Monorepo: main app (`src/`, project `ng-orcid`), docs (`projects/orcid-ui-docs`), and libraries. Use this for fast orientation. + +> **Note for future agents:** This file is intended to be edited over time. Any section—relevant or not—may be updated, trimmed, or reorganized so the document stays lean and useful for future agents. Prefer keeping only what helps the next AI work effectively with this project. + +## Quick reference: libraries + +- **@orcid/ui** — `projects/orcid-ui` — Agnostic UI components, design tokens, modal shell. See `projects/orcid-ui/AGENTS.md`. +- **@orcid/registry-ui** — `projects/orcid-registry-ui` — Registry-specific components (e.g. import-works-dialog). See `projects/orcid-registry-ui/AGENTS.md`. + +Both are path-mapped from repo root (`tsconfig.json`). Main app and docs consume them from source. + +## When changing a library component’s API + +Update the main app and that component’s doc page (usage snippet, inputs list, examples). + +## If component styles differ between docs and main app + +If a library component (e.g. in orcid-registry-ui) looks correct in the docs app but wrong in the main app (e.g. missing margins or spacing), try **resetting the main project**: clean build artifacts, reinstall, or reset local overrides. Stale or inconsistent build/cache state in the main app can cause component styles to not apply as in docs; a fresh build often resolves it. + + +## Dialogs and async data + +When a dialog or view needs data from an HTTP (or other async) call, opening the dialog only after the request completes can feel slow. Consider opening the dialog **immediately** with skeleton or static data (e.g. labels, empty lists, or a `loading: true` flag), then assigning the full payload to the dialog’s component instance when the observable emits. The dialog can show placeholders or a loading state until then. diff --git a/projects/orcid-registry-ui/AGENTS.md b/projects/orcid-registry-ui/AGENTS.md new file mode 100644 index 0000000000..34ffa4bf41 --- /dev/null +++ b/projects/orcid-registry-ui/AGENTS.md @@ -0,0 +1,41 @@ +# Orcid Registry UI — Agent Notes (for future GPTs) + +This package (`projects/orcid-registry-ui`, published as `@orcid/registry-ui`) contains **feature-oriented UI components** for the main orcid-angular application only. It is not intended for reuse outside this project. + +> **Note for future agents:** This file is intended to be edited over time. Any section—relevant or not—may be updated, trimmed, or reorganized so the document stays lean and useful for future agents. Prefer keeping only what helps the next AI work effectively with this library. + +## Library scope: orcid-registry-ui vs orcid-ui + +- **orcid-ui** (`@orcid/ui`): Fully **agnostic** UI building blocks. No feature names or domain-specific wording. Open source–friendly and reusable across any ORCID or non-ORCID project. Components use Angular Material when possible and design tokens for styling. + +- **orcid-registry-ui** (`@orcid/registry-ui`): **Registry-specific** components for the orcid-angular app only. May use feature names (e.g. “Import your works”, “Permission notifications”). Builds on **orcid-ui** components, Angular Material, and **orcid-tokens**. More flexible and app-coupled. + +## Conventions + +- **Package entry**: `projects/orcid-registry-ui/src/public-api.ts` +- **Components**: `projects/orcid-registry-ui/src/lib/components/**` +- Prefer **standalone components**. Use `@orcid/ui` for base primitives (modals, action surfaces, etc.) and **orcid-tokens** CSS variables for layout/color/typography. +- Dialogs that use the shared modal chrome should use `OrcidModalComponent` and `ORCID_MODAL_DIALOG_PANEL_CLASS` from `@orcid/ui` when opening with `MatDialog`. +- **Dialog data (MAT_DIALOG_DATA):** If the host may open the dialog first and assign full data later (e.g. after an async request), the data interface can include an optional flag (e.g. `loading?: boolean`) so the dialog can show a skeleton or placeholder until the host sets the complete data. + +## Dependencies + +- Peer: `@angular/common`, `@angular/core`, `@angular/material`, `@orcid/ui`, `rxjs` +- The host app (orcid-angular) supplies `@orcid/ui` and design tokens (e.g. via `tokens.css`). + +## Building + +- From repo root: `ng build orcid-registry-ui` (or use the npm script if defined). + +## When changing this library’s API + +If you add or change **inputs**, **data types**, or **public behavior** in a registry-ui component, update **both** the main app usage (under `src/`) and the component’s doc page (usage snippet, inputs list, examples) so they stay in sync. + +## Docs site (`projects/orcid-ui-docs`) + +Registry-ui doc pages live under **`src/app/pages/orcid-registry-ui/`** (orcid-ui pages live under `pages/orcid-ui/`). When adding a new registry-ui component doc: + +- Add the page in `projects/orcid-ui-docs/src/app/pages/orcid-registry-ui/-page.component.{ts,html,scss}` +- Import `DocumentationPageComponent` from `'../../components/documentation-page/documentation-page.component'` +- Register a lazy route in `app.routes.ts` pointing to `./pages/orcid-registry-ui/-page.component` +- Add a sidebar link under the “Orcid Registry UI” section in `docs-shell.component.html` diff --git a/projects/orcid-registry-ui/src/lib/components/import-works-dialog/import-works-dialog.component.html b/projects/orcid-registry-ui/src/lib/components/import-works-dialog/import-works-dialog.component.html new file mode 100644 index 0000000000..14fed68850 --- /dev/null +++ b/projects/orcid-registry-ui/src/lib/components/import-works-dialog/import-works-dialog.component.html @@ -0,0 +1,154 @@ + +
+
+

{{ introText }}

+ + {{ supportLink.label }} + +
+ +
+

+ {{ certifiedSectionHeading }} +

+
+ + + + + + +
+
+ + {{ link.icon }} +
+
+

{{ link.name }}

+

+ {{ link.description }} +

+
+
+ + + + {{ connectedLabel }} + + + + + +
+
+
+
+
+ +
+
+ +
+ + + + + + +
+
+ + {{ link.icon }} +
+
+

{{ link.name }}

+

+ {{ link.description }} +

+
+
+ +
+
+
+
+
+
+
+
diff --git a/projects/orcid-registry-ui/src/lib/components/import-works-dialog/import-works-dialog.component.scss b/projects/orcid-registry-ui/src/lib/components/import-works-dialog/import-works-dialog.component.scss new file mode 100644 index 0000000000..680853fec5 --- /dev/null +++ b/projects/orcid-registry-ui/src/lib/components/import-works-dialog/import-works-dialog.component.scss @@ -0,0 +1,350 @@ +// Typography and layout aligned with Figma (node 211-8545, 108-5385, 129-4920) +// Font sizes: intro/body 14px, section heading 18px, card title 18px, card description 14px, buttons 14px + +// So orcid-modal (height: 100%) has a defined parent and does not overflow +:host { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + overflow: hidden; +} + +.import-works-dialog__intro { + margin-bottom: var(--orcid-space-6, 32px); +} + +.import-works-dialog__intro-text { + margin: 0 0 var(--orcid-space-4, 16px); + font-family: var(--orcid-font-family-sans, 'Noto Sans', sans-serif); + font-size: var(--orcid-font-size-body-small, 14px); + font-weight: var(--orcid-font-weight-normal, 400); + line-height: 21px; + letter-spacing: 0.25px; + color: var(--orcid-color-text, #222222); +} + +.import-works-dialog__support-link { + font-family: var(--orcid-font-family-sans, 'Noto Sans', sans-serif); + font-size: var(--orcid-font-size-body-small, 14px); + font-weight: var(--orcid-font-weight-normal, 400); + line-height: 21px; + letter-spacing: 0.25px; + color: var(--orcid-color-brand-secondary-dark, #085c77); + text-decoration: underline; +} + +.import-works-dialog__section { + margin-bottom: var(--orcid-space-6, 32px); + + &:last-child { + margin-bottom: 0; + } +} + +.import-works-dialog__section-heading { + margin: 0 0 var(--orcid-space-4, 16px); + font-family: var(--orcid-font-family-sans, 'Noto Sans', sans-serif); + font-size: var(--orcid-font-size-body-large, 18px); + font-weight: var(--orcid-font-weight-bold, 700); + line-height: 24px; + color: var(--orcid-color-text-dark-high, #000000); +} + +// Certified: cards with no side borders (Figma Service/Certified) – top divider only, shadow +.import-works-dialog__card-list { + display: flex; + flex-direction: column; + gap: 0; +} + +.import-works-dialog__card-list:has(.import-works-dialog__card--certified) { + gap: var(--orcid-space-4, 16px); +} + +.import-works-dialog__card-list:has(.import-works-dialog__card--certified) .import-works-dialog__card--certified { + border-top: none; +} + +.import-works-dialog__card { + display: flex; + align-items: center; + gap: var(--orcid-space-4, 16px); + padding: var(--orcid-space-4, 16px); + background: var(--orcid-surface, #ffffff); + border-radius: var(--orcid-space-1, 4px); + box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.1); + + &--certified { + border: none; + } + + &--more { + border-radius: 0; + box-shadow: none; + border: none; + border-top: 1px solid var(--orcid-ui-background-light, #eeeeee); + border-left: none; + border-right: none; + padding: var(--orcid-space-4, 16px); + + &:first-child { + border-top: none; + } + } +} + +.import-works-dialog__card-icon { + flex-shrink: 0; + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + color: var(--orcid-color-text-muted, #555555); + + .import-works-dialog__card-img { + width: 48px; + height: 48px; + object-fit: contain; + } + + mat-icon { + font-size: 32px; + width: 32px; + height: 32px; + } +} + +.import-works-dialog__card-content { + flex: 1 1 0; + min-width: 0; +} + +.import-works-dialog__card-title { + margin: 0 0 2px; + font-family: var(--orcid-font-family-sans, 'Noto Sans', sans-serif); + font-size: var(--orcid-font-size-body-large, 18px); + font-weight: var(--orcid-font-weight-bold, 700); + line-height: 27px; + color: var(--orcid-color-text-dark-high, #000000); +} + +.import-works-dialog__card-description { + margin: 0; + font-family: var(--orcid-font-family-sans, 'Noto Sans', sans-serif); + font-size: var(--orcid-font-size-body-small, 14px); + font-weight: var(--orcid-font-weight-normal, 400); + line-height: 21px; + letter-spacing: 0.25px; + color: var(--orcid-color-text, #222222); +} + +.import-works-dialog__card-actions { + flex-shrink: 0; +} + +// Skeleton (shimmer) card placeholders +.import-works-dialog__card--skeleton { + pointer-events: none; + + .import-works-dialog__skeleton-title { + margin-bottom: 2px; + display: block; + } + + .import-works-dialog__skeleton-line { + margin-top: 4px; + display: block; + + &:first-of-type { + margin-top: 0; + } + } + + .import-works-dialog__skeleton-btn { + border-radius: 4px; + display: block; + } +} + + +.import-works-dialog__btn-connect { + font-size: var(--orcid-font-size-body-small, 14px) !important; + line-height: 21px !important; + letter-spacing: 0.25px !important; + background-color: var(--orcid-color-brand-secondary-dark, #085c77) !important; + color: var(--orcid-color-brand-white, #ffffff) !important; +} + +.import-works-dialog__btn-connect-more { + font-size: var(--orcid-font-size-body-small, 14px) !important; + line-height: 21px !important; + letter-spacing: 0.25px !important; + color: var(--orcid-color-brand-secondary-darkest, #003449) !important; + border-color: var(--orcid-ui-background-light, #eeeeee) !important; +} + +// Connected: border and button-like appearance (Figma 129-4920) +.import-works-dialog__connected { + display: inline-flex; + align-items: center; + gap: var(--orcid-space-2, 8px); + padding: var(--orcid-space-2, 8px) var(--orcid-space-3, 12px); + font-family: var(--orcid-font-family-sans, 'Noto Sans', sans-serif); + font-size: var(--orcid-font-size-body-small, 14px); + font-weight: var(--orcid-font-weight-normal, 400); + line-height: 21px; + letter-spacing: 0.25px; + color: var(--orcid-color-text-dark-high, #000000); + background: var(--orcid-surface, #ffffff); + border: 1px solid var(--orcid-ui-background-light, #eeeeee); + border-radius: var(--orcid-space-1, 4px); + box-sizing: border-box; +} + +.import-works-dialog__connected-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #28a745; +} + +// More Services: single panel (Figma Forms/Sections/More services) with collapsible list +.import-works-dialog__section--more { + margin-bottom: var(--orcid-space-6, 32px); +} + +.import-works-dialog__more-panel { + background: var(--orcid-surface, #ffffff); + border: none; + border-bottom: 1px solid var(--orcid-ui-background-light, #eeeeee); + border-radius: var(--orcid-space-1, 4px); + overflow: hidden; +} + +.import-works-dialog__more-header { + display: flex; + align-items: center; + gap: var(--orcid-space-2, 8px); + width: 100%; + min-height: 48px; + padding: 0 var(--orcid-space-4, 16px); + border: none; + background: transparent; + cursor: pointer; + text-align: left; + font-family: var(--orcid-font-family-sans, 'Noto Sans', sans-serif); + font-size: var(--orcid-font-size-body-large, 18px); + font-weight: var(--orcid-font-weight-bold, 700); + line-height: 24px; + color: var(--orcid-color-text-dark-high, #000000); + + &:hover { + background: var(--orcid-ui-background-lightest, #fafafa); + } + + &:focus-visible { + outline: 2px solid var(--orcid-color-brand-secondary-dark, #085c77); + outline-offset: 2px; + } +} + +.import-works-dialog__more-chevron { + flex-shrink: 0; + width: 24px; + height: 24px; + font-size: 24px; + color: var(--orcid-color-text, #222222); +} + +.import-works-dialog__more-heading-text { + flex: 1; +} + +// Count and parentheses: muted style so it's not null (Figma 129-4920) +.import-works-dialog__more-count { + font-weight: var(--orcid-font-weight-normal, 400); + color: var(--orcid-color-text-muted, #555555); +} + +.import-works-dialog__more-list { + border-top: 1px solid var(--orcid-ui-background-light, #eeeeee); + + &[hidden] { + display: none !important; + } +} + +// Mobile (Figma 129-4920): certified = icon next to title (vertical align); full-width actions; touch-friendly +@media (max-width: 600px) { + .import-works-dialog__card { + flex-wrap: wrap; + gap: var(--orcid-space-2, 8px); + + .import-works-dialog__card-icon { + order: 0; + } + + .import-works-dialog__card-content { + order: 1; + flex: 1 1 0; + min-width: 0; + } + + .import-works-dialog__card-actions { + order: 2; + width: 100%; + flex: 1 1 100%; + justify-content: flex-start; + } + } + + // Certified cards: icon next to title, vertically aligned (Figma 129-4920) + .import-works-dialog__card--certified { + display: grid; + grid-template-columns: auto 1fr; + grid-template-rows: auto auto auto; + gap: var(--orcid-space-2, 8px) var(--orcid-space-4, 16px); + + .import-works-dialog__card-icon { + grid-column: 1; + grid-row: 1; + align-self: center; + order: unset; + } + + .import-works-dialog__card-content { + display: contents; + order: unset; + } + + .import-works-dialog__card-title { + grid-column: 2; + grid-row: 1; + align-self: center; + margin: 0; + } + + .import-works-dialog__card-description { + grid-column: 1 / -1; + grid-row: 2; + } + + .import-works-dialog__card-actions { + grid-column: 1 / -1; + grid-row: 3; + order: unset; + } + } + + .import-works-dialog__btn-connect, + .import-works-dialog__btn-connect-more { + min-height: 40px; + } + + .import-works-dialog__more-header { + min-height: 48px; + padding: var(--orcid-space-2, 8px) var(--orcid-space-4, 16px); + } +} diff --git a/projects/orcid-registry-ui/src/lib/components/import-works-dialog/import-works-dialog.component.ts b/projects/orcid-registry-ui/src/lib/components/import-works-dialog/import-works-dialog.component.ts new file mode 100644 index 0000000000..a7bd7d910c --- /dev/null +++ b/projects/orcid-registry-ui/src/lib/components/import-works-dialog/import-works-dialog.component.ts @@ -0,0 +1,108 @@ +import { Component, Inject } from '@angular/core' +import { NgFor, NgIf } from '@angular/common' +import { MatButtonModule } from '@angular/material/button' +import { MatIconModule } from '@angular/material/icon' +import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog' +import { + OrcidModalComponent, + ORCID_MODAL_DIALOG_PANEL_CLASS, + SkeletonPlaceholderComponent, +} from '@orcid/ui' +import type { + ImportWorksDialogData, + ImportWorksCertifiedLink, + ImportWorksMoreLink, +} from './import-works-dialog.types' + +export { ORCID_MODAL_DIALOG_PANEL_CLASS } from '@orcid/ui' +export type { + ImportWorksDialogData, + ImportWorksCertifiedLink, + ImportWorksMoreLink, +} from './import-works-dialog.types' + +/** Number of skeleton cards to show in the certified section while loading. */ +/** Base on production data, we have 2 certified links */ +const CERTIFIED_SKELETON_COUNT = 2 +/** Number of skeleton cards to show in the more services section while loading. */ +/** Base on production data, we have 15 more services links */ +const MORE_SERVICES_SKELETON_COUNT = 15 + +@Component({ + selector: 'orcid-registry-import-works-dialog', + standalone: true, + imports: [ + NgFor, + NgIf, + MatButtonModule, + MatIconModule, + OrcidModalComponent, + SkeletonPlaceholderComponent, + ], + templateUrl: './import-works-dialog.component.html', + styleUrls: ['./import-works-dialog.component.scss'], +}) +export class ImportWorksDialogComponent { + /** Whether the "More Services" section is expanded. Default true. */ + moreServicesExpanded = true + + /** Array used to render N skeleton cards in the certified section. */ + certifiedSkeletonCount = Array(CERTIFIED_SKELETON_COUNT) + /** Array used to render N skeleton cards in the more services section. */ + moreServicesSkeletonCount = Array(MORE_SERVICES_SKELETON_COUNT) + + constructor( + private _dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: ImportWorksDialogData + ) {} + + toggleMoreServices(): void { + this.moreServicesExpanded = !this.moreServicesExpanded + } + + get title(): string { + return this.data?.title ?? 'Import your works' + } + + get introText(): string | undefined { + return this.data?.introText + } + + get supportLink(): { url: string; label: string } | undefined { + return this.data?.supportLink + } + + get certifiedSectionHeading(): string { + return this.data?.certifiedSectionHeading ?? 'ORCID Certified Services' + } + + get moreServicesHeading(): string { + return this.data?.moreServicesHeading ?? 'More Services' + } + + get connectNowLabel(): string { + return this.data?.connectNowLabel ?? 'Connect now' + } + + get connectedLabel(): string { + return this.data?.connectedLabel ?? 'Connected' + } + + get loading(): boolean { + return this.data?.loading === true + } + + get certifiedLinks(): ImportWorksCertifiedLink[] { + return this.data?.certifiedLinks ?? [] + } + + get moreServicesLinks(): ImportWorksMoreLink[] { + return this.data?.moreServicesLinks ?? [] + } + + openInNewTab(url: string): void { + if (url) { + window.open(url, '_blank', 'noopener,noreferrer') + } + } +} diff --git a/projects/orcid-registry-ui/src/lib/components/import-works-dialog/import-works-dialog.types.ts b/projects/orcid-registry-ui/src/lib/components/import-works-dialog/import-works-dialog.types.ts new file mode 100644 index 0000000000..eee7b5e353 --- /dev/null +++ b/projects/orcid-registry-ui/src/lib/components/import-works-dialog/import-works-dialog.types.ts @@ -0,0 +1,54 @@ +/** + * A certified import service (ORCID Certified Services section). + * When `connected` is true, the UI shows a "Connected" state instead of "Connect now". + */ +export interface ImportWorksCertifiedLink { + name: string + description: string + url: string + connected: boolean + /** Material icon name (e.g. "link"). Shown when imageUrl is not set. */ + icon?: string + /** Image URL for the service logo. When set, shown instead of icon. */ + imageUrl?: string +} + +/** + * A non-certified import service (More Services section). + */ +export interface ImportWorksMoreLink { + name: string + description: string + url: string + /** Material icon name. Shown when imageUrl is not set. */ + icon?: string + /** Image URL for the service logo. When set, shown instead of icon. */ + imageUrl?: string +} + +/** + * Data passed into the Import your works dialog via MAT_DIALOG_DATA. + * All user-facing strings (title, introText, labels) should be passed in so the host app can supply translatable text. + */ +export interface ImportWorksDialogData { + /** When true, shows shimmer skeleton placeholders for the link sections instead of content. */ + loading?: boolean + /** Header/title of the dialog. */ + title: string + /** Optional intro paragraph above the sections. */ + introText?: string + /** Optional support link shown below intro. */ + supportLink?: { url: string; label: string } + /** Section heading for certified services (e.g. "ORCID Certified Services"). */ + certifiedSectionHeading?: string + /** Section heading for non-certified services (e.g. "More Services"). */ + moreServicesHeading?: string + /** Button label for connecting to a service (e.g. "Connect now"). */ + connectNowLabel?: string + /** Label shown when a certified service is already connected (e.g. "Connected"). */ + connectedLabel?: string + /** Links shown under the certified section heading. */ + certifiedLinks: ImportWorksCertifiedLink[] + /** Ordered list of links shown under the more services heading. */ + moreServicesLinks: ImportWorksMoreLink[] +} diff --git a/projects/orcid-registry-ui/src/public-api.ts b/projects/orcid-registry-ui/src/public-api.ts index c03fff0082..46d71e4e89 100644 --- a/projects/orcid-registry-ui/src/public-api.ts +++ b/projects/orcid-registry-ui/src/public-api.ts @@ -4,3 +4,5 @@ export * from './lib/orcid-registry-ui' export * from './lib/components/permission-notifications/permission-notifications.component' +export * from './lib/components/import-works-dialog/import-works-dialog.component' +export * from './lib/components/import-works-dialog/import-works-dialog.types' diff --git a/projects/orcid-tokens/tokens.css b/projects/orcid-tokens/tokens.css index e8ea7c1198..ac51f93f96 100644 --- a/projects/orcid-tokens/tokens.css +++ b/projects/orcid-tokens/tokens.css @@ -12,6 +12,7 @@ --orcid-color-border-container: #333333; --orcid-ui-background-lightest: #fafafa; --orcid-ui-background-light: #eeeeee; + --orcid-ui-background-darkest: #212121; /* Brand secondary palette (blue) */ --orcid-color-brand-secondary-darkest: #003449; @@ -85,4 +86,7 @@ --orcid-letter-spacing-small-print: 0.5px; --orcid-letter-spacing-button: 0px; --orcid-letter-spacing-title-alt: 1.3px; + + /* Shadows */ + --orcid-shadow-modal: 4px 4px 20px 0px rgba(0, 0, 0, 0.2); } diff --git a/projects/orcid-tokens/tokens.json b/projects/orcid-tokens/tokens.json index b5bae95836..310be98d6d 100644 --- a/projects/orcid-tokens/tokens.json +++ b/projects/orcid-tokens/tokens.json @@ -7,7 +7,11 @@ }, "ui": { "backgroundLightest": "#fafafa", - "backgroundLight": "#eeeeee" + "backgroundLight": "#eeeeee", + "backgroundDarkest": "#212121" + }, + "shadow": { + "modal": "4px 4px 20px 0px rgba(0, 0, 0, 0.2)" }, "space": { "1": "4px", diff --git a/projects/orcid-tokens/tokens.scss b/projects/orcid-tokens/tokens.scss index 65bb98f8b1..55e44d7f65 100644 --- a/projects/orcid-tokens/tokens.scss +++ b/projects/orcid-tokens/tokens.scss @@ -12,6 +12,7 @@ $orcid-color-border-subtle: #dddddd; $orcid-color-border-container: #333333; $orcid-ui-background-lightest: #fafafa; $orcid-ui-background-light: #eeeeee; +$orcid-ui-background-darkest: #212121; // Brand secondary palette (blue) $orcid-color-brand-secondary-darkest: #003449; @@ -84,3 +85,6 @@ $orcid-letter-spacing-body-1: 0.1px; $orcid-letter-spacing-small-print: 0.5px; $orcid-letter-spacing-button: 0px; $orcid-letter-spacing-title-alt: 1.3px; + +// Shadows +$orcid-shadow-modal: 4px 4px 20px 0px rgba(0, 0, 0, 0.2); diff --git a/projects/orcid-ui-docs/src/app/app.routes.ts b/projects/orcid-ui-docs/src/app/app.routes.ts index b4a349c979..af32711ca4 100644 --- a/projects/orcid-ui-docs/src/app/app.routes.ts +++ b/projects/orcid-ui-docs/src/app/app.routes.ts @@ -5,90 +5,106 @@ export const routes: Routes = [ path: '', pathMatch: 'full', loadComponent: () => - import('./pages/overview-page.component').then( + import('./pages/orcid-ui/overview-page.component').then( (m) => m.OverviewPageComponent ), }, { path: 'colors', loadComponent: () => - import('./pages/colors-page.component').then( + import('./pages/orcid-ui/colors-page.component').then( (m) => m.ColorsPageComponent ), }, { path: 'typography', loadComponent: () => - import('./pages/typography-page.component').then( + import('./pages/orcid-ui/typography-page.component').then( (m) => m.TypographyPageComponent ), }, { path: 'record-header', loadComponent: () => - import('./pages/record-header-page.component').then( + import('./pages/orcid-ui/record-header-page.component').then( (m) => m.RecordHeaderPageComponent ), }, { path: 'text-with-tooltip', loadComponent: () => - import('./pages/text-with-tooltip-page.component').then( + import('./pages/orcid-ui/text-with-tooltip-page.component').then( (m) => m.TextWithTooltipPageComponent ), }, { path: 'material-buttons-directives', loadComponent: () => - import('./pages/material-buttons-directives-page.component').then( + import('./pages/orcid-ui/material-buttons-directives-page.component').then( (m) => m.MaterialButtonsDirectivesPageComponent ), }, { path: 'alert-message', loadComponent: () => - import('./pages/alert-message-page.component').then( + import('./pages/orcid-ui/alert-message-page.component').then( (m) => m.AlertMessagePageComponent ), }, { path: 'action-surface', loadComponent: () => - import('./pages/action-surface-page.component').then( + import('./pages/orcid-ui/action-surface-page.component').then( (m) => m.ActionSurfacePageComponent ), }, { path: 'action-surface-container', loadComponent: () => - import('./pages/action-surface-container-page.component').then( + import('./pages/orcid-ui/action-surface-container-page.component').then( (m) => m.ActionSurfaceContainerPageComponent ), }, { - path: 'registry-permission-notifications', + path: 'panel', loadComponent: () => - import('./pages/registry-permission-notifications-page.component').then( - (m) => m.RegistryPermissionNotificationsPageComponent + import('./pages/orcid-ui/panel-page.component').then( + (m) => m.PanelPageComponent ), }, { - path: 'panel', + path: 'modal', loadComponent: () => - import('./pages/panel-page.component').then((m) => m.PanelPageComponent), + import('./pages/orcid-ui/modal-page.component').then( + (m) => m.ModalPageComponent + ), }, { path: 'skeleton-placeholder', loadComponent: () => - import('./pages/skeleton-placeholder-page.component').then( + import('./pages/orcid-ui/skeleton-placeholder-page.component').then( (m) => m.SkeletonPlaceholderPageComponent ), }, { path: 'two-factor-auth-form', loadComponent: () => - import('./pages/two-factor-auth-form-page.component').then( + import('./pages/orcid-ui/two-factor-auth-form-page.component').then( (m) => m.TwoFactorAuthFormPageComponent ), }, + { + path: 'registry-permission-notifications', + loadComponent: () => + import( + './pages/orcid-registry-ui/registry-permission-notifications-page.component' + ).then((m) => m.RegistryPermissionNotificationsPageComponent), + }, + { + path: 'registry-import-works-dialog', + loadComponent: () => + import( + './pages/orcid-registry-ui/import-works-dialog-page.component' + ).then((m) => m.ImportWorksDialogPageComponent), + }, ] diff --git a/projects/orcid-ui-docs/src/app/docs-shell.component.html b/projects/orcid-ui-docs/src/app/docs-shell.component.html index 11abe41871..9d25b5de2f 100644 --- a/projects/orcid-ui-docs/src/app/docs-shell.component.html +++ b/projects/orcid-ui-docs/src/app/docs-shell.component.html @@ -35,6 +35,7 @@

Orcid UI

Action Surface Container Panel + Modal Skeleton Placeholder @@ -52,6 +53,12 @@

Orcid UI

> Permission Notifications + + Import your works dialog + diff --git a/projects/orcid-ui-docs/src/app/pages/orcid-registry-ui/import-works-dialog-page.component.html b/projects/orcid-ui-docs/src/app/pages/orcid-registry-ui/import-works-dialog-page.component.html new file mode 100644 index 0000000000..fe061e97f8 --- /dev/null +++ b/projects/orcid-ui-docs/src/app/pages/orcid-registry-ui/import-works-dialog-page.component.html @@ -0,0 +1,140 @@ + +
+

+ Configure the dialog data and open it to preview. This component is part of + @orcid/registry-ui. +

+
+ + Title + + + + + Intro text + + + + + Support link URL + + + + + Support link label + + +
+ +

Certified links (first connected = show "Connected")

+
+ + Certified {{ i + 1 }} name + + + + Certified {{ i + 1 }} URL + + + Connected + + Certified {{ i + 1 }} description + + +
+ +

More services links

+
+ + More {{ i + 1 }} name + + + + More {{ i + 1 }} URL + + + + More {{ i + 1 }} description + + +
+
+ +
+
+ + +
+
+ +
+
import { MatDialog } from '@angular/material/dialog'
+import {
+  ImportWorksDialogComponent,
+  ORCID_MODAL_DIALOG_PANEL_CLASS,
+  type ImportWorksDialogData,
+} from '@orcid/registry-ui'
+
+// ...
+
+// Open with loading state (shimmer), then update when data is ready:
+const dialogRef = this.dialog.open(ImportWorksDialogComponent, {
+  panelClass: ORCID_MODAL_DIALOG_PANEL_CLASS,
+  data: {
+    loading: true,
+    title: 'Import your works',
+    certifiedLinks: [],
+    moreServicesLinks: [],
+  } as ImportWorksDialogData,
+  width: '850px',
+  maxHeight: '90vh',
+})
+this.loadDialogData().subscribe((data) => {
+  dialogRef.componentInstance.data = { ...data, loading: false }
+})
+
+// Or open with data already loaded:
+this.dialog.open(ImportWorksDialogComponent, {
+  panelClass: ORCID_MODAL_DIALOG_PANEL_CLASS,
+  data: {
+    title: 'Import your works',
+    introText: '...',
+    supportLink: { url: '...', label: '...' },
+    certifiedLinks: [{ name, description, url, connected }],
+    moreServicesLinks: [{ name, description, url }],
+  } as ImportWorksDialogData,
+  width: '850px',
+  maxHeight: '90vh',
+})
+
+ +
+

+ Dialog content is driven by MAT_DIALOG_DATA of type + 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.
  • +
  • + certifiedLinks: Array of { name, description, url, connected?, icon? }. Optional icon is a Material icon name. + When connected is true, the card shows "Connected" instead of "Connect now". +
  • +
  • + moreServicesLinks: Ordered array of { name, description, url, icon? }. Optional icon is a Material icon name. +
  • +
+

+ "Connect now" buttons open the link url in a new tab. +

+
+
diff --git a/projects/orcid-ui-docs/src/app/pages/orcid-registry-ui/import-works-dialog-page.component.scss b/projects/orcid-ui-docs/src/app/pages/orcid-registry-ui/import-works-dialog-page.component.scss new file mode 100644 index 0000000000..c9ee915da6 --- /dev/null +++ b/projects/orcid-ui-docs/src/app/pages/orcid-registry-ui/import-works-dialog-page.component.scss @@ -0,0 +1,16 @@ +.controls-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 16px; + margin-bottom: 24px; + + .full-width { + grid-column: 1 / -1; + } +} + +.example-container { + padding: 16px; + border-radius: 8px; + background: var(--orcid-surface, #ffffff); +} 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 new file mode 100644 index 0000000000..617a6fa7dd --- /dev/null +++ b/projects/orcid-ui-docs/src/app/pages/orcid-registry-ui/import-works-dialog-page.component.ts @@ -0,0 +1,128 @@ +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' +import { MatInputModule } from '@angular/material/input' +import { MatCheckboxModule } from '@angular/material/checkbox' +import { + ImportWorksDialogComponent, + ORCID_MODAL_DIALOG_PANEL_CLASS, + type ImportWorksDialogData, + type ImportWorksCertifiedLink, + type ImportWorksMoreLink, +} from '@orcid/registry-ui' +import { DocumentationPageComponent } from '../../components/documentation-page/documentation-page.component' + +@Component({ + selector: 'orcid-registry-import-works-dialog-page', + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatButtonModule, + MatFormFieldModule, + MatInputModule, + MatCheckboxModule, + DocumentationPageComponent, + ], + templateUrl: './import-works-dialog-page.component.html', + styleUrls: ['./import-works-dialog-page.component.scss'], +}) +export class ImportWorksDialogPageComponent { + title = 'Import your works' + introText = + '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.' + supportLinkUrl = 'https://support.orcid.org/hc/en-us/articles/360006973653' + supportLinkLabel = + 'Find out more about importing works into your ORCID record' + + certifiedLinks: ImportWorksCertifiedLink[] = [ + { + name: 'The Lens', + description: + 'Create or connect your Lens Profile to add patents and other scholarly works and automatically update your ORCID record over time.', + url: 'https://www.lens.org/', + connected: false, + imageUrl: 'https://placehold.co/48x48/e8f4f8/085c77?text=Lens', + }, + { + name: 'Web of Science', + description: + 'Connect your Web of Science profile to import publications and keep your ORCID record in sync.', + url: 'https://www.webofscience.com/', + connected: true, + imageUrl: 'https://placehold.co/48x48/e8f4f8/085c77?text=WoS', + }, + ] + + moreServicesLinks: ImportWorksMoreLink[] = [ + { + name: 'Scopus', + description: + 'Import your Scopus publications and citation metrics into your ORCID record.', + url: 'https://www.scopus.com/', + }, + { + name: 'Europe PMC', + description: + 'Link your Europe PMC publications to your ORCID record.', + url: 'https://europepmc.org/', + }, + ] + + constructor(private _dialog: MatDialog) {} + + openDialog(): void { + const data: ImportWorksDialogData = { + title: this.title, + introText: this.introText, + supportLink: + this.supportLinkUrl && this.supportLinkLabel + ? { url: this.supportLinkUrl, label: this.supportLinkLabel } + : undefined, + certifiedLinks: [...this.certifiedLinks], + moreServicesLinks: [...this.moreServicesLinks], + } + 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/registry-permission-notifications-page.component.html b/projects/orcid-ui-docs/src/app/pages/orcid-registry-ui/registry-permission-notifications-page.component.html similarity index 100% rename from projects/orcid-ui-docs/src/app/pages/registry-permission-notifications-page.component.html rename to projects/orcid-ui-docs/src/app/pages/orcid-registry-ui/registry-permission-notifications-page.component.html diff --git a/projects/orcid-ui-docs/src/app/pages/registry-permission-notifications-page.component.scss b/projects/orcid-ui-docs/src/app/pages/orcid-registry-ui/registry-permission-notifications-page.component.scss similarity index 100% rename from projects/orcid-ui-docs/src/app/pages/registry-permission-notifications-page.component.scss rename to projects/orcid-ui-docs/src/app/pages/orcid-registry-ui/registry-permission-notifications-page.component.scss diff --git a/projects/orcid-ui-docs/src/app/pages/registry-permission-notifications-page.component.ts b/projects/orcid-ui-docs/src/app/pages/orcid-registry-ui/registry-permission-notifications-page.component.ts similarity index 96% rename from projects/orcid-ui-docs/src/app/pages/registry-permission-notifications-page.component.ts rename to projects/orcid-ui-docs/src/app/pages/orcid-registry-ui/registry-permission-notifications-page.component.ts index 49be2ee605..126d17f62f 100644 --- a/projects/orcid-ui-docs/src/app/pages/registry-permission-notifications-page.component.ts +++ b/projects/orcid-ui-docs/src/app/pages/orcid-registry-ui/registry-permission-notifications-page.component.ts @@ -10,7 +10,7 @@ import { RegistryNotificationActionEvent, RegistryPermissionNotification, } from '@orcid/registry-ui' -import { DocumentationPageComponent } from '../components/documentation-page/documentation-page.component' +import { DocumentationPageComponent } from '../../components/documentation-page/documentation-page.component' @Component({ selector: 'orcid-registry-permission-notifications-page', diff --git a/projects/orcid-ui-docs/src/app/pages/action-surface-container-page.component.html b/projects/orcid-ui-docs/src/app/pages/orcid-ui/action-surface-container-page.component.html similarity index 100% rename from projects/orcid-ui-docs/src/app/pages/action-surface-container-page.component.html rename to projects/orcid-ui-docs/src/app/pages/orcid-ui/action-surface-container-page.component.html diff --git a/projects/orcid-ui-docs/src/app/pages/action-surface-container-page.component.scss b/projects/orcid-ui-docs/src/app/pages/orcid-ui/action-surface-container-page.component.scss similarity index 100% rename from projects/orcid-ui-docs/src/app/pages/action-surface-container-page.component.scss rename to projects/orcid-ui-docs/src/app/pages/orcid-ui/action-surface-container-page.component.scss diff --git a/projects/orcid-ui-docs/src/app/pages/action-surface-container-page.component.ts b/projects/orcid-ui-docs/src/app/pages/orcid-ui/action-surface-container-page.component.ts similarity index 91% rename from projects/orcid-ui-docs/src/app/pages/action-surface-container-page.component.ts rename to projects/orcid-ui-docs/src/app/pages/orcid-ui/action-surface-container-page.component.ts index 852a76a3d7..cb7c426f3d 100644 --- a/projects/orcid-ui-docs/src/app/pages/action-surface-container-page.component.ts +++ b/projects/orcid-ui-docs/src/app/pages/orcid-ui/action-surface-container-page.component.ts @@ -10,7 +10,7 @@ import { BrandSecondaryDarkButtonDirective, UnderlineButtonDirective, } from '@orcid/ui' -import { DocumentationPageComponent } from '../components/documentation-page/documentation-page.component' +import { DocumentationPageComponent } from '../../components/documentation-page/documentation-page.component' @Component({ selector: 'orcid-action-surface-container-page', diff --git a/projects/orcid-ui-docs/src/app/pages/action-surface-page.component.html b/projects/orcid-ui-docs/src/app/pages/orcid-ui/action-surface-page.component.html similarity index 100% rename from projects/orcid-ui-docs/src/app/pages/action-surface-page.component.html rename to projects/orcid-ui-docs/src/app/pages/orcid-ui/action-surface-page.component.html diff --git a/projects/orcid-ui-docs/src/app/pages/action-surface-page.component.scss b/projects/orcid-ui-docs/src/app/pages/orcid-ui/action-surface-page.component.scss similarity index 100% rename from projects/orcid-ui-docs/src/app/pages/action-surface-page.component.scss rename to projects/orcid-ui-docs/src/app/pages/orcid-ui/action-surface-page.component.scss diff --git a/projects/orcid-ui-docs/src/app/pages/action-surface-page.component.ts b/projects/orcid-ui-docs/src/app/pages/orcid-ui/action-surface-page.component.ts similarity index 86% rename from projects/orcid-ui-docs/src/app/pages/action-surface-page.component.ts rename to projects/orcid-ui-docs/src/app/pages/orcid-ui/action-surface-page.component.ts index 6d3359cec9..eab57c2c97 100644 --- a/projects/orcid-ui-docs/src/app/pages/action-surface-page.component.ts +++ b/projects/orcid-ui-docs/src/app/pages/orcid-ui/action-surface-page.component.ts @@ -6,7 +6,7 @@ import { BrandSecondaryDarkButtonDirective, UnderlineButtonDirective, } from '@orcid/ui' -import { DocumentationPageComponent } from '../components/documentation-page/documentation-page.component' +import { DocumentationPageComponent } from '../../components/documentation-page/documentation-page.component' @Component({ selector: 'orcid-action-surface-page', diff --git a/projects/orcid-ui-docs/src/app/pages/alert-message-page.component.html b/projects/orcid-ui-docs/src/app/pages/orcid-ui/alert-message-page.component.html similarity index 100% rename from projects/orcid-ui-docs/src/app/pages/alert-message-page.component.html rename to projects/orcid-ui-docs/src/app/pages/orcid-ui/alert-message-page.component.html diff --git a/projects/orcid-ui-docs/src/app/pages/alert-message-page.component.scss b/projects/orcid-ui-docs/src/app/pages/orcid-ui/alert-message-page.component.scss similarity index 100% rename from projects/orcid-ui-docs/src/app/pages/alert-message-page.component.scss rename to projects/orcid-ui-docs/src/app/pages/orcid-ui/alert-message-page.component.scss diff --git a/projects/orcid-ui-docs/src/app/pages/alert-message-page.component.ts b/projects/orcid-ui-docs/src/app/pages/orcid-ui/alert-message-page.component.ts similarity index 91% rename from projects/orcid-ui-docs/src/app/pages/alert-message-page.component.ts rename to projects/orcid-ui-docs/src/app/pages/orcid-ui/alert-message-page.component.ts index 4b5bd98bcf..751a585793 100644 --- a/projects/orcid-ui-docs/src/app/pages/alert-message-page.component.ts +++ b/projects/orcid-ui-docs/src/app/pages/orcid-ui/alert-message-page.component.ts @@ -7,7 +7,7 @@ import { MatInputModule } from '@angular/material/input' import { MatOptionModule } from '@angular/material/core' import { MatIconModule } from '@angular/material/icon' import { AlertMessageComponent } from '@orcid/ui' -import { DocumentationPageComponent } from '../components/documentation-page/documentation-page.component' +import { DocumentationPageComponent } from '../../components/documentation-page/documentation-page.component' @Component({ selector: 'orcid-alert-message-page', diff --git a/projects/orcid-ui-docs/src/app/pages/colors-page.component.ts b/projects/orcid-ui-docs/src/app/pages/orcid-ui/colors-page.component.ts similarity index 100% rename from projects/orcid-ui-docs/src/app/pages/colors-page.component.ts rename to projects/orcid-ui-docs/src/app/pages/orcid-ui/colors-page.component.ts diff --git a/projects/orcid-ui-docs/src/app/pages/material-buttons-directives-page.component.ts b/projects/orcid-ui-docs/src/app/pages/orcid-ui/material-buttons-directives-page.component.ts similarity index 100% rename from projects/orcid-ui-docs/src/app/pages/material-buttons-directives-page.component.ts rename to projects/orcid-ui-docs/src/app/pages/orcid-ui/material-buttons-directives-page.component.ts diff --git a/projects/orcid-ui-docs/src/app/pages/orcid-ui/modal-page.component.html b/projects/orcid-ui-docs/src/app/pages/orcid-ui/modal-page.component.html new file mode 100644 index 0000000000..0a43182cc5 --- /dev/null +++ b/projects/orcid-ui-docs/src/app/pages/orcid-ui/modal-page.component.html @@ -0,0 +1,79 @@ + +
+

Modify these values and open the modal to preview:

+
+ + Title + + + + + Close aria-label + + + + Show close icon + + + Body text + + +
+
+ +
+
+

Dialog preview

+
+ + +

+ Last close result: {{ lastResult }} +

+
+
+
+ +
+
import { MatDialog } from '@angular/material/dialog'
+import { ORCID_MODAL_DIALOG_PANEL_CLASS } from '@orcid/ui'
+
+// ...
+this.dialog.open(YourDialogComponent, {
+  panelClass: ORCID_MODAL_DIALOG_PANEL_CLASS,
+})
+ +
<orcid-modal title="Import your works">
+  <div orcidModalBody>
+    Modal content
+  </div>
+
+  <div orcidModalFooter>
+    <button mat-button mat-dialog-close="cancel">Cancel</button>
+    <button mat-flat-button mat-dialog-close="confirm">Confirm</button>
+  </div>
+</orcid-modal>
+
+ +
+

Inputs

+
    +
  • title: Header title text (optional).
  • +
  • showClose: Show/hide the close icon button.
  • +
  • closeAriaLabel: Accessible label for the close button.
  • +
+ +

Slots

+
    +
  • [orcidModalTitle]: Project a custom title area.
  • +
  • [orcidModalBody]: Scrollable container content.
  • +
  • [orcidModalFooter]: Footer actions/content.
  • +
+
+
+ diff --git a/projects/orcid-ui-docs/src/app/pages/orcid-ui/modal-page.component.scss b/projects/orcid-ui-docs/src/app/pages/orcid-ui/modal-page.component.scss new file mode 100644 index 0000000000..81816e98cf --- /dev/null +++ b/projects/orcid-ui-docs/src/app/pages/orcid-ui/modal-page.component.scss @@ -0,0 +1,13 @@ +.example-container { + display: grid; + gap: 12px; + padding: 16px; + border-radius: 8px; + background: var(--orcid-surface, #ffffff); +} + +.result { + margin: 0; + color: var(--orcid-color-text-muted, #555555); +} + diff --git a/projects/orcid-ui-docs/src/app/pages/orcid-ui/modal-page.component.ts b/projects/orcid-ui-docs/src/app/pages/orcid-ui/modal-page.component.ts new file mode 100644 index 0000000000..d02d56060a --- /dev/null +++ b/projects/orcid-ui-docs/src/app/pages/orcid-ui/modal-page.component.ts @@ -0,0 +1,94 @@ +import { Component } from '@angular/core' +import { CommonModule } from '@angular/common' +import { FormsModule } from '@angular/forms' +import { MatButtonModule } from '@angular/material/button' +import { MatCheckboxModule } from '@angular/material/checkbox' +import { MatDialog, MatDialogModule } from '@angular/material/dialog' +import { MatFormFieldModule } from '@angular/material/form-field' +import { MatInputModule } from '@angular/material/input' +import { + OrcidModalComponent, + ORCID_MODAL_DIALOG_PANEL_CLASS, +} from '@orcid/ui' +import { DocumentationPageComponent } from '../../components/documentation-page/documentation-page.component' + +type ModalDocsConfig = { + title: string + closeAriaLabel: string + showClose: boolean + body: string +} + +@Component({ + selector: 'orcid-modal-demo-dialog', + standalone: true, + imports: [CommonModule, OrcidModalComponent, MatButtonModule], + template: ` + +
+

+ {{ config.body }} +

+

+ This is placeholder content for the modal container. +

+
+ +
+ + +
+
+ `, +}) +class ModalDemoDialogComponent { + config!: ModalDocsConfig +} + +@Component({ + selector: 'orcid-modal-page', + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatDialogModule, + MatButtonModule, + MatCheckboxModule, + MatFormFieldModule, + MatInputModule, + DocumentationPageComponent, + ], + templateUrl: './modal-page.component.html', + styleUrls: ['./modal-page.component.scss'], +}) +export class ModalPageComponent { + config: ModalDocsConfig = { + title: 'Import your works', + closeAriaLabel: 'Close dialog', + showClose: true, + body: 'These services can help you update your ORCID record quickly by searching for your research outputs from various databases.', + } + + lastResult = '' + + constructor(private dialog: MatDialog) {} + + openModal(): void { + const ref = this.dialog.open(ModalDemoDialogComponent, { + panelClass: ORCID_MODAL_DIALOG_PANEL_CLASS, + }) + + ref.componentInstance.config = { ...this.config } + + ref.afterClosed().subscribe((result) => { + this.lastResult = result ? String(result) : '' + }) + } +} + diff --git a/projects/orcid-ui-docs/src/app/pages/overview-page.component.ts b/projects/orcid-ui-docs/src/app/pages/orcid-ui/overview-page.component.ts similarity index 100% rename from projects/orcid-ui-docs/src/app/pages/overview-page.component.ts rename to projects/orcid-ui-docs/src/app/pages/orcid-ui/overview-page.component.ts diff --git a/projects/orcid-ui-docs/src/app/pages/panel-page.component.html b/projects/orcid-ui-docs/src/app/pages/orcid-ui/panel-page.component.html similarity index 100% rename from projects/orcid-ui-docs/src/app/pages/panel-page.component.html rename to projects/orcid-ui-docs/src/app/pages/orcid-ui/panel-page.component.html diff --git a/projects/orcid-ui-docs/src/app/pages/panel-page.component.scss b/projects/orcid-ui-docs/src/app/pages/orcid-ui/panel-page.component.scss similarity index 100% rename from projects/orcid-ui-docs/src/app/pages/panel-page.component.scss rename to projects/orcid-ui-docs/src/app/pages/orcid-ui/panel-page.component.scss diff --git a/projects/orcid-ui-docs/src/app/pages/panel-page.component.ts b/projects/orcid-ui-docs/src/app/pages/orcid-ui/panel-page.component.ts similarity index 94% rename from projects/orcid-ui-docs/src/app/pages/panel-page.component.ts rename to projects/orcid-ui-docs/src/app/pages/orcid-ui/panel-page.component.ts index 73ea9e8601..ea1a970f84 100644 --- a/projects/orcid-ui-docs/src/app/pages/panel-page.component.ts +++ b/projects/orcid-ui-docs/src/app/pages/orcid-ui/panel-page.component.ts @@ -9,7 +9,7 @@ import { MatInputModule } from '@angular/material/input' import { MatCheckboxModule } from '@angular/material/checkbox' import { MatSelectModule } from '@angular/material/select' import { MatTooltipModule } from '@angular/material/tooltip' -import { DocumentationPageComponent } from '../components/documentation-page/documentation-page.component' +import { DocumentationPageComponent } from '../../components/documentation-page/documentation-page.component' @Component({ selector: 'orcid-panel-page', diff --git a/projects/orcid-ui-docs/src/app/pages/record-header-loading-page/record-header-loading-page.component.html b/projects/orcid-ui-docs/src/app/pages/orcid-ui/record-header-loading-page/record-header-loading-page.component.html similarity index 100% rename from projects/orcid-ui-docs/src/app/pages/record-header-loading-page/record-header-loading-page.component.html rename to projects/orcid-ui-docs/src/app/pages/orcid-ui/record-header-loading-page/record-header-loading-page.component.html diff --git a/projects/orcid-ui-docs/src/app/pages/record-header-loading-page/record-header-loading-page.component.scss b/projects/orcid-ui-docs/src/app/pages/orcid-ui/record-header-loading-page/record-header-loading-page.component.scss similarity index 100% rename from projects/orcid-ui-docs/src/app/pages/record-header-loading-page/record-header-loading-page.component.scss rename to projects/orcid-ui-docs/src/app/pages/orcid-ui/record-header-loading-page/record-header-loading-page.component.scss diff --git a/projects/orcid-ui-docs/src/app/pages/record-header-loading-page/record-header-loading-page.component.spec.ts b/projects/orcid-ui-docs/src/app/pages/orcid-ui/record-header-loading-page/record-header-loading-page.component.spec.ts similarity index 100% rename from projects/orcid-ui-docs/src/app/pages/record-header-loading-page/record-header-loading-page.component.spec.ts rename to projects/orcid-ui-docs/src/app/pages/orcid-ui/record-header-loading-page/record-header-loading-page.component.spec.ts diff --git a/projects/orcid-ui-docs/src/app/pages/record-header-loading-page/record-header-loading-page.component.ts b/projects/orcid-ui-docs/src/app/pages/orcid-ui/record-header-loading-page/record-header-loading-page.component.ts similarity index 100% rename from projects/orcid-ui-docs/src/app/pages/record-header-loading-page/record-header-loading-page.component.ts rename to projects/orcid-ui-docs/src/app/pages/orcid-ui/record-header-loading-page/record-header-loading-page.component.ts diff --git a/projects/orcid-ui-docs/src/app/pages/record-header-page.component.html b/projects/orcid-ui-docs/src/app/pages/orcid-ui/record-header-page.component.html similarity index 100% rename from projects/orcid-ui-docs/src/app/pages/record-header-page.component.html rename to projects/orcid-ui-docs/src/app/pages/orcid-ui/record-header-page.component.html diff --git a/projects/orcid-ui-docs/src/app/pages/record-header-page.component.scss b/projects/orcid-ui-docs/src/app/pages/orcid-ui/record-header-page.component.scss similarity index 100% rename from projects/orcid-ui-docs/src/app/pages/record-header-page.component.scss rename to projects/orcid-ui-docs/src/app/pages/orcid-ui/record-header-page.component.scss diff --git a/projects/orcid-ui-docs/src/app/pages/record-header-page.component.ts b/projects/orcid-ui-docs/src/app/pages/orcid-ui/record-header-page.component.ts similarity index 93% rename from projects/orcid-ui-docs/src/app/pages/record-header-page.component.ts rename to projects/orcid-ui-docs/src/app/pages/orcid-ui/record-header-page.component.ts index 4c5cc3c1e4..c509baeab1 100644 --- a/projects/orcid-ui-docs/src/app/pages/record-header-page.component.ts +++ b/projects/orcid-ui-docs/src/app/pages/orcid-ui/record-header-page.component.ts @@ -9,7 +9,7 @@ import { MatCheckboxModule } from '@angular/material/checkbox' import { MatSelectModule } from '@angular/material/select' import { MatTooltipModule } from '@angular/material/tooltip' import { AccentButtonDirective, HeaderBannerComponent } from '@orcid/ui' -import { DocumentationPageComponent } from '../components/documentation-page/documentation-page.component' +import { DocumentationPageComponent } from '../../components/documentation-page/documentation-page.component' @Component({ selector: 'orcid-record-header-page', 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 new file mode 100644 index 0000000000..cbefb9f854 --- /dev/null +++ b/projects/orcid-ui-docs/src/app/pages/orcid-ui/skeleton-placeholder-page.component.html @@ -0,0 +1,119 @@ + +
+

+ 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 + + Square + Circle + + + + + Width + + + + + Height + + + + + Shimmer Percentage + + % + + + + 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%') — 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/skeleton-placeholder-page.component.ts b/projects/orcid-ui-docs/src/app/pages/orcid-ui/skeleton-placeholder-page.component.ts similarity index 59% rename from projects/orcid-ui-docs/src/app/pages/skeleton-placeholder-page.component.ts rename to projects/orcid-ui-docs/src/app/pages/orcid-ui/skeleton-placeholder-page.component.ts index c02331bd6d..282b7a37ef 100644 --- a/projects/orcid-ui-docs/src/app/pages/skeleton-placeholder-page.component.ts +++ b/projects/orcid-ui-docs/src/app/pages/orcid-ui/skeleton-placeholder-page.component.ts @@ -4,8 +4,9 @@ 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' +import { DocumentationPageComponent } from '../../components/documentation-page/documentation-page.component' @Component({ selector: 'orcid-skeleton-placeholder-page', @@ -16,6 +17,7 @@ import { DocumentationPageComponent } from '../components/documentation-page/doc MatFormFieldModule, MatInputModule, MatSelectModule, + MatCheckboxModule, SkeletonPlaceholderComponent, DocumentationPageComponent, ], @@ -24,9 +26,28 @@ import { DocumentationPageComponent } from '../components/documentation-page/doc ` .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-docs/src/app/pages/text-with-tooltip-page.component.html b/projects/orcid-ui-docs/src/app/pages/orcid-ui/text-with-tooltip-page.component.html similarity index 100% rename from projects/orcid-ui-docs/src/app/pages/text-with-tooltip-page.component.html rename to projects/orcid-ui-docs/src/app/pages/orcid-ui/text-with-tooltip-page.component.html diff --git a/projects/orcid-ui-docs/src/app/pages/text-with-tooltip-page.component.scss b/projects/orcid-ui-docs/src/app/pages/orcid-ui/text-with-tooltip-page.component.scss similarity index 100% rename from projects/orcid-ui-docs/src/app/pages/text-with-tooltip-page.component.scss rename to projects/orcid-ui-docs/src/app/pages/orcid-ui/text-with-tooltip-page.component.scss diff --git a/projects/orcid-ui-docs/src/app/pages/text-with-tooltip-page.component.ts b/projects/orcid-ui-docs/src/app/pages/orcid-ui/text-with-tooltip-page.component.ts similarity index 89% rename from projects/orcid-ui-docs/src/app/pages/text-with-tooltip-page.component.ts rename to projects/orcid-ui-docs/src/app/pages/orcid-ui/text-with-tooltip-page.component.ts index 303524128d..7b8d2b08eb 100644 --- a/projects/orcid-ui-docs/src/app/pages/text-with-tooltip-page.component.ts +++ b/projects/orcid-ui-docs/src/app/pages/orcid-ui/text-with-tooltip-page.component.ts @@ -5,7 +5,7 @@ import { MatFormFieldModule } from '@angular/material/form-field' import { MatInputModule } from '@angular/material/input' import { MatButtonModule } from '@angular/material/button' import { TextWithTooltipComponent } from '@orcid/ui' -import { DocumentationPageComponent } from '../components/documentation-page/documentation-page.component' +import { DocumentationPageComponent } from '../../components/documentation-page/documentation-page.component' @Component({ selector: 'orcid-text-with-tooltip-page', diff --git a/projects/orcid-ui-docs/src/app/pages/two-factor-auth-form-page.component.html b/projects/orcid-ui-docs/src/app/pages/orcid-ui/two-factor-auth-form-page.component.html similarity index 100% rename from projects/orcid-ui-docs/src/app/pages/two-factor-auth-form-page.component.html rename to projects/orcid-ui-docs/src/app/pages/orcid-ui/two-factor-auth-form-page.component.html diff --git a/projects/orcid-ui-docs/src/app/pages/two-factor-auth-form-page.component.scss b/projects/orcid-ui-docs/src/app/pages/orcid-ui/two-factor-auth-form-page.component.scss similarity index 100% rename from projects/orcid-ui-docs/src/app/pages/two-factor-auth-form-page.component.scss rename to projects/orcid-ui-docs/src/app/pages/orcid-ui/two-factor-auth-form-page.component.scss diff --git a/projects/orcid-ui-docs/src/app/pages/two-factor-auth-form-page.component.ts b/projects/orcid-ui-docs/src/app/pages/orcid-ui/two-factor-auth-form-page.component.ts similarity index 93% rename from projects/orcid-ui-docs/src/app/pages/two-factor-auth-form-page.component.ts rename to projects/orcid-ui-docs/src/app/pages/orcid-ui/two-factor-auth-form-page.component.ts index 8c4ae20ffb..fd11057ca2 100644 --- a/projects/orcid-ui-docs/src/app/pages/two-factor-auth-form-page.component.ts +++ b/projects/orcid-ui-docs/src/app/pages/orcid-ui/two-factor-auth-form-page.component.ts @@ -12,7 +12,7 @@ import { MatFormFieldModule } from '@angular/material/form-field' import { MatInputModule } from '@angular/material/input' import { MatIconModule } from '@angular/material/icon' import { TwoFactorAuthFormComponent } from '@orcid/ui' -import { DocumentationPageComponent } from '../components/documentation-page/documentation-page.component' +import { DocumentationPageComponent } from '../../components/documentation-page/documentation-page.component' import '@angular/localize/init' import { MatCheckboxModule } from '@angular/material/checkbox' diff --git a/projects/orcid-ui-docs/src/app/pages/typography-page.component.ts b/projects/orcid-ui-docs/src/app/pages/orcid-ui/typography-page.component.ts similarity index 100% rename from projects/orcid-ui-docs/src/app/pages/typography-page.component.ts rename to projects/orcid-ui-docs/src/app/pages/orcid-ui/typography-page.component.ts diff --git a/projects/orcid-ui-docs/src/app/pages/skeleton-placeholder-page.component.html b/projects/orcid-ui-docs/src/app/pages/skeleton-placeholder-page.component.html deleted file mode 100644 index 210f44ce45..0000000000 --- a/projects/orcid-ui-docs/src/app/pages/skeleton-placeholder-page.component.html +++ /dev/null @@ -1,64 +0,0 @@ - -
-
- - Shape - - Square - Circle - - - - - Width - - - - - Height - - - - - Shimmer Percentage - - % - -
-
- -
-
-

Playground

-
- -
-
-
- -
-
    -
  • 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) -
  • -
-
-
diff --git a/projects/orcid-ui/AGENTS.md b/projects/orcid-ui/AGENTS.md new file mode 100644 index 0000000000..886795c354 --- /dev/null +++ b/projects/orcid-ui/AGENTS.md @@ -0,0 +1,85 @@ +# Orcid UI — Agent Notes +This repo contains an Angular library at `projects/orcid-ui` published as `@orcid/ui`. +Use this document as a fast orientation and a checklist for making safe, consistent changes. + +> **Note for future agents:** This file is intended to be edited over time. Any section—relevant or not—may be updated, trimmed, or reorganized so the document stays lean and useful for future agents. Prefer keeping only what helps the next AI work effectively with this library. + +## Library scope: orcid-ui vs orcid-registry-ui + +- **orcid-ui** (`@orcid/ui`): Only **agnostic** UI elements. No references to specific features or domain copy. This is an open-source–friendly library reusable in any ORCID or non-ORCID project. Components should use **Angular Material** when possible and design tokens for sizing, color, and typography. + +- **orcid-registry-ui** (`@orcid/registry-ui`): **Registry-specific** components for the main orcid-angular app only. May use feature names and app-specific wording. Those components consume **orcid-ui**, Angular Material, and **orcid-tokens**. + +## No i18n — strings from content project via inputs + +- **Do not use i18n translations** in this library (no `i18n` attributes, no translation pipes, no runtime translation services). +- **All user-facing strings** must be supplied by the **content project** (the app that uses the library) via **Angular inputs** (e.g. `@Input() title`, `@Input() label`). Components should not hard-code copy; they receive it from the host. + +## TL;DR (how to ship changes here) + +- Prefer **standalone components** (`standalone: true`) and export them from `projects/orcid-ui/src/public-api.ts`. +- Style using **ORCID design tokens** (CSS variables like `--orcid-space-4`, `--orcid-color-text`, etc). +- If you add/modify tokens, update **all three**: + - `projects/orcid-tokens/tokens.scss` (SCSS source-of-truth) + - `projects/orcid-tokens/tokens.css` (CSS variables used by apps/docs) + - `projects/orcid-tokens/tokens.json` (structured token map) +- Keep `projects/orcid-ui-docs` updated with a docs page + route + sidebar link. When you **change a component’s API** (inputs, data types, public behavior), update the main app (or other consumers) and that component’s doc page (usage snippet, inputs list, examples). +- Verify builds: + - `npm run build:tokens` + - `npm run build:ui` + - `npm run build:ui-docs` + +## Library structure and conventions + +- **Package entry**: `projects/orcid-ui/src/public-api.ts` +- **Components**: `projects/orcid-ui/src/lib/components/**` +- **Directives**: `projects/orcid-ui/src/lib/directives/**` +- **Themes/typography**: `projects/orcid-ui/src/lib/themes/**` +- **Tokens** live in a sibling library `projects/orcid-tokens` (published as `@orcid/tokens`). + +### Styling approach + +- Components generally rely on **CSS variables** in `tokens.css` (e.g. `--orcid-space-*`, `--orcid-font-*`, `--orcid-color-*`). +- Some SCSS (typography/theme helpers) imports token SCSS. +- Prefer token variables over hard-coded colors/sizes. + +### Loading and placeholder states + +- Prefer existing **skeleton/placeholder components** from this library (e.g. `SkeletonPlaceholderComponent`) for loading or empty states so loading UIs stay consistent. Check `public-api.ts` and the docs before adding new spinners or custom placeholders. + +## MCP Figma-to-code workflow used in this repo + +When the user provides a Figma URL: + +- Parse `fileKey` and `nodeId` from the URL query `node-id=...` (convert `-` to `:`). +- Use the Figma MCP in this order: + - `get_design_context(fileKey, nodeId)` to extract layout/typography details + - `get_screenshot(fileKey, nodeId)` for visual confirmation +- The MCP returns React/Tailwind examples; **do not** add Tailwind dependencies. Convert to Angular + existing token system. + +## Docs site (`projects/orcid-ui-docs`) structure + +Docs pages are split by library: + +- **`src/app/pages/orcid-ui/`** — Pages for **@orcid/ui** components (overview, colors, typography, record-header, modal, panel, etc.). Add new orcid-ui doc pages here. +- **`src/app/pages/orcid-registry-ui/`** — Pages for **@orcid/registry-ui** components (e.g. permission-notifications, import-works-dialog). Add new registry-ui doc pages here. + +When adding a doc page: + +- Place the page in the correct folder: `pages/orcid-ui/-page.component.*` or `pages/orcid-registry-ui/-page.component.*` +- Import `DocumentationPageComponent` from `'../../components/documentation-page/documentation-page.component'` +- Register a lazy route in `app.routes.ts` (e.g. `import('./pages/orcid-ui/modal-page.component').then(...)`) +- Add a sidebar link in `docs-shell.component.html` +- Use `DocumentationPageComponent` with content-projected sections: `[controls]`, `[examples]`, `[usage]`, `[inputs]` + +### Gotcha: braces in HTML docs templates + +Angular templates may treat raw `{ ... }` inside text as ICU syntax. If you render TypeScript snippets like: + +```ts +import { MatDialog } from '@angular/material/dialog' +``` + +escape braces in the HTML (e.g. `{` and `}`) to avoid template parse errors. + + diff --git a/projects/orcid-ui/src/lib/components/modal/modal.component.html b/projects/orcid-ui/src/lib/components/modal/modal.component.html new file mode 100644 index 0000000000..d692b2b9fe --- /dev/null +++ b/projects/orcid-ui/src/lib/components/modal/modal.component.html @@ -0,0 +1,32 @@ +
+
+
+ + {{ title }} + + + + +
+ + +
+ +
+ +
+ +
+ +
+
+ diff --git a/projects/orcid-ui/src/lib/components/modal/modal.component.scss b/projects/orcid-ui/src/lib/components/modal/modal.component.scss new file mode 100644 index 0000000000..44aaf53978 --- /dev/null +++ b/projects/orcid-ui/src/lib/components/modal/modal.component.scss @@ -0,0 +1,175 @@ +/* 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; + max-width: min(850px, calc(100vw - 2 * var(--orcid-space-4, 16px))); + width: 100%; + display: flex; + flex-direction: column; + min-height: 0; + min-width: 0; + overflow: hidden; + box-sizing: border-box; +} + +.orcid-modal-dialog-panel .mat-mdc-dialog-inner-container { + max-height: 90vh; + max-width: 100%; + min-height: 0; + min-width: 0; + flex-direction: column; + align-items: stretch; + justify-content: flex-start; + overflow: hidden; + box-sizing: border-box; +} + +.orcid-modal-dialog-panel .mat-mdc-dialog-surface { + padding: 0; + border-radius: var(--orcid-space-1, 4px); + border: 2px solid var(--orcid-ui-background-darkest, #212121); + background: var(--orcid-surface, #ffffff); + box-shadow: var( + --orcid-shadow-modal, + 4px 4px 20px 0px rgba(0, 0, 0, 0.2) + ); + overflow: hidden; + display: flex; + flex-direction: column; + height: 90vh; + max-height: 90vh; + min-height: 0; + min-width: 0; + flex: 1 1 auto; + width: 100%; + max-width: 100%; + box-sizing: border-box; +} + +/* So the dialog content host participates in flex and constrains the scroll area */ +.orcid-modal-dialog-panel .mat-mdc-dialog-surface > * { + flex: 1 1 auto; + min-height: 0; + min-width: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +@media (max-width: 600px) { + .orcid-modal-dialog-panel { + width: calc(100vw - 2 * var(--orcid-space-2, 8px)); + max-width: calc(100vw - 2 * var(--orcid-space-2, 8px)); + } + + .orcid-modal-dialog-panel .mat-mdc-dialog-container { + max-width: 100%; + overflow: hidden; + } + + .orcid-modal-dialog-panel .mat-mdc-dialog-inner-container { + max-width: 100%; + } + + .orcid-modal-dialog-panel .mat-mdc-dialog-surface { + min-width: 0; + width: 100%; + max-width: 100%; + } +} + +.orcid-modal { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + min-width: 0; + height: 100%; + overflow: hidden; + max-width: 100%; +} + +.orcid-modal__header { + background: var(--orcid-ui-background-darkest, #212121); + color: var(--orcid-color-brand-white, #ffffff); + height: 56px; + display: flex; + align-items: center; + justify-content: space-between; + padding-left: var(--orcid-space-4, 16px); + padding-right: var(--orcid-space-2, 8px); + box-sizing: border-box; +} + +.orcid-modal__title { + flex: 1 1 auto; + min-width: 0; + margin: 0; + padding: 0; + font-family: var( + --orcid-font-family-sans, + 'Noto Sans', + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif + ); + font-size: var(--orcid-font-size-body-large, 18px); + font-weight: var(--orcid-font-weight-bold, 700); + line-height: 27px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.orcid-modal__close { + flex: 0 0 auto; +} + +.orcid-modal__container { + flex: 1 1 auto; + min-height: 0; + min-width: 0; + overflow-x: hidden; + overflow-y: auto; + padding: var(--orcid-space-4, 16px); + box-sizing: border-box; + max-width: 100%; + overflow-wrap: break-word; +} + +.orcid-modal__footer { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--orcid-space-2, 8px); + padding: var(--orcid-space-4, 16px); + border-top: 1px solid var(--orcid-ui-background-light, #eeeeee); + box-sizing: border-box; + + /* Completely hide when no content is projected or only empty elements are projected */ + &:empty, + &:not(:has(*)), + &:not(:has(*:not(:empty))) { + display: none !important; + visibility: hidden; + height: 0; + min-height: 0; + padding: 0; + margin: 0; + border: none; + 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 new file mode 100644 index 0000000000..c00a839667 --- /dev/null +++ b/projects/orcid-ui/src/lib/components/modal/modal.component.ts @@ -0,0 +1,32 @@ +import { Component, Input, Optional, ViewEncapsulation } from '@angular/core' +import { NgIf } from '@angular/common' +import { MatButtonModule } from '@angular/material/button' +import { MatIconModule } from '@angular/material/icon' +import { MatDialogRef } from '@angular/material/dialog' + +export const ORCID_MODAL_DIALOG_PANEL_CLASS = 'orcid-modal-dialog-panel' + +@Component({ + selector: 'orcid-modal', + standalone: true, + imports: [NgIf, MatButtonModule, MatIconModule], + templateUrl: './modal.component.html', + styleUrls: ['./modal.component.scss'], +}) +export class OrcidModalComponent { + /** Optional title string for the modal header. */ + @Input() title = '' + + /** When true, show the close icon button in the header. */ + @Input() showClose = true + + /** Accessible label for the close icon button. */ + @Input() closeAriaLabel = 'Close dialog' + + constructor(@Optional() private dialogRef?: MatDialogRef) {} + + close(): void { + this.dialogRef?.close() + } +} + 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/projects/orcid-ui/src/public-api.ts b/projects/orcid-ui/src/public-api.ts index 30db1aeca7..1bf9077622 100644 --- a/projects/orcid-ui/src/public-api.ts +++ b/projects/orcid-ui/src/public-api.ts @@ -14,3 +14,4 @@ export * from './lib/components/action-surface-container/action-surface-containe export * from './lib/components/two-factor-form-auth/two-factor-auth-form.component' export * from './lib/components/panel/panel.component' export * from './lib/components/skeleton-placeholder/skeleton-placeholder.component' +export * from './lib/components/modal/modal.component' diff --git a/scripts/validate-branch-name.husky.js b/scripts/validate-branch-name.husky.js index 607c803509..96585bbc48 100644 --- a/scripts/validate-branch-name.husky.js +++ b/scripts/validate-branch-name.husky.js @@ -1,12 +1,13 @@ #!/usr/bin/env node -// Enforce branch naming: /AA-0000[anything] +// Enforce branch naming: /PROJECT-NNN[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: one or more uppercase letters, hyphen, one or more digits (e.g., PD-0000, ENGAGE-243) // - suffix: any optional characters after the ticket (e.g., "/feature-x", "-refactor", etc.) // Special allowed names: transifex // Examples: // yourname/PD-0000 +// lmendoza/ENGAGE-243 // yourname/PD-0000-my-feature // your.name/AB-0123/quick-fix @@ -67,15 +68,16 @@ function main() { process.exit(0) } - // developer-name / AA-0000 [anything] - const pattern = /^[a-z0-9._-]+\/[A-Z]{2}-\d{4}.*$/ + // developer-name / PROJECT-NNN [anything] (e.g. PD-0000, ENGAGE-243) + const pattern = /^[a-z0-9._-]+\/[A-Z]+-\d+.*$/ 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 "/PROJECT-NNN[anything]" or be a special allowed name (e.g., "transifex").\u001b[0m' ) console.error('\nExamples:') console.error(' yourname/PD-0000') + 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..6bf26aa60a 100644 --- a/scripts/validate-commit-msg.husky.js +++ b/scripts/validate-commit-msg.husky.js @@ -1,10 +1,9 @@ #!/usr/bin/env node -// 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 +// Enforce commit message format: PROJECT-NNN [optional message] +// - PROJECT: one or more uppercase letters (A-Z) +// - NNN: one or more digits +// - Examples: PD-0000, ENGAGE-243, ENGAGE-243 Add feature const fs = require('fs') @@ -49,16 +48,17 @@ 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: PROJECT-NNN (e.g. PD-0000, ENGAGE-243), optional space and message + const pattern = /^[A-Z]+-\d+(?:\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 "PROJECT-NNN" (e.g. PD-0000, ENGAGE-243) followed by an optional message.\u001b[0m' ) console.error('\nExamples:') console.error(' PD-0000') - console.error(' PD-0000 Fix broken tests') + console.error(' ENGAGE-243') + console.error(' ENGAGE-243 Fix broken tests') console.error('\nYour message was:') console.error(` ${firstLine || '(empty)'}`) process.exit(1) 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/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.spec.ts b/src/app/core/record-works/record-works.service.spec.ts index 1380fdda37..631f0dc4bc 100644 --- a/src/app/core/record-works/record-works.service.spec.ts +++ b/src/app/core/record-works/record-works.service.spec.ts @@ -25,6 +25,8 @@ import { Config } from 'src/app/types/config.endpoint' import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core' +declare const runtimeEnvironment: { API_WEB: string } + describe('RecordWorksService', () => { let service: RecordWorksService let togglzService: TogglzService @@ -47,6 +49,7 @@ describe('RecordWorksService', () => { MatSnackBar, MatDialog, Overlay, + { provide: TogglzService, useValue: fakeTogglzService }, ], }) service = TestBed.inject(RecordWorksService) @@ -97,6 +100,76 @@ describe('RecordWorksService', () => { expect(requestGroupingSuggestions.length).toEqual(1) requestGroupingSuggestions.forEach((grouping) => grouping.flush({})) }) + + describe('getImportWorksDialogDataSkeleton', () => { + it('returns dialog data with loading true and empty link lists', () => { + const skeleton = service.getImportWorksDialogDataSkeleton() + expect(skeleton.loading).toBe(true) + expect(skeleton.certifiedLinks).toEqual([]) + expect(skeleton.moreServicesLinks).toEqual([]) + expect(skeleton.title).toBeDefined() + expect(skeleton.introText).toBeDefined() + expect(skeleton.supportLink).toBeDefined() + expect(skeleton.certifiedSectionHeading).toBeDefined() + expect(skeleton.moreServicesHeading).toBeDefined() + expect(skeleton.connectNowLabel).toBeDefined() + expect(skeleton.connectedLabel).toBeDefined() + }) + }) + + describe('loadSearchAndLinkWizardDialogData', () => { + it('requests retrieve-works-search-and-link-wizard.json and returns mapped dialog data', (done) => { + const listPayload = [ + { + id: 'cert-1', + name: 'Certified Service', + redirectUri: 'https://app.example/cb', + scopes: '/read-limited', + redirectUriMetadata: { type: 'Certified', defaultDescription: 'Cert desc' }, + }, + { + id: 'more-1', + name: 'More Service', + description: 'More desc', + redirectUri: 'https://app.example/cb2', + scopes: '/activities/update', + }, + ] + + service.loadSearchAndLinkWizardDialogData('en').subscribe((data) => { + expect(data.loading).toBe(false) + expect(data.certifiedLinks.length).toBe(1) + expect(data.certifiedLinks[0].name).toBe('Certified Service') + expect(data.certifiedLinks[0].description).toBe('Cert desc') + expect(data.moreServicesLinks.length).toBe(1) + expect(data.moreServicesLinks[0].name).toBe('More Service') + expect(data.moreServicesLinks[0].description).toBe('More desc') + done() + }) + + const req = httpTestingController.expectOne( + runtimeEnvironment.API_WEB + + 'workspace/retrieve-works-search-and-link-wizard.json' + ) + expect(req.request.method).toBe('GET') + req.flush(listPayload) + }) + + it('returns empty certified and more lists when API returns empty array', (done) => { + service.loadSearchAndLinkWizardDialogData('en').subscribe((data) => { + expect(data.certifiedLinks).toEqual([]) + expect(data.moreServicesLinks).toEqual([]) + expect(data.loading).toBe(false) + done() + }) + + const req = httpTestingController.expectOne( + runtimeEnvironment.API_WEB + + 'workspace/retrieve-works-search-and-link-wizard.json' + ) + req.flush([]) + }) + }) }) function getNumberOfWorks(numberOfContributors: number): Work[] { diff --git a/src/app/core/record-works/record-works.service.ts b/src/app/core/record-works/record-works.service.ts index 84e1f0b843..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' @@ -556,9 +570,170 @@ 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-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( diff --git a/src/app/core/xsrf/xsrf-fallback.interceptor.spec.ts b/src/app/core/xsrf/xsrf-fallback.interceptor.spec.ts new file mode 100644 index 0000000000..fa5a35935e --- /dev/null +++ b/src/app/core/xsrf/xsrf-fallback.interceptor.spec.ts @@ -0,0 +1,154 @@ +import { HttpClient, HTTP_INTERCEPTORS } from '@angular/common/http' +import { + HttpClientTestingModule, + HttpTestingController, +} from '@angular/common/http/testing' +import { TestBed } from '@angular/core/testing' +import { CookieService } from 'ngx-cookie-service' +import { XsrfFallbackInterceptor } from './xsrf-fallback.interceptor' + +describe('XsrfFallbackInterceptor', () => { + let http: HttpClient + let httpMock: HttpTestingController + let cookieGetSpy: jasmine.Spy + + const apiBase = 'http://api.example/' + const baseUrl = 'http://orcid.example/' + const authBase = 'http://auth.example/' + + beforeEach(() => { + ;(window as any).runtimeEnvironment = { + production: false, + API_WEB: apiBase, + BASE_URL: baseUrl, + AUTH_SERVER: authBase, + } + cookieGetSpy = jasmine.createSpy('get').and.returnValue('') + + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + { + provide: HTTP_INTERCEPTORS, + useClass: XsrfFallbackInterceptor, + multi: true, + }, + { provide: CookieService, useValue: { get: cookieGetSpy } }, + ], + }) + + http = TestBed.inject(HttpClient) + httpMock = TestBed.inject(HttpTestingController) + }) + + afterEach(() => { + httpMock.verify() + }) + + it('passes through without adding header when production is true', () => { + const env = (window as any).runtimeEnvironment + env.production = true + cookieGetSpy.and.returnValue('xsrf-token-123') + + http.post(apiBase + 'works/work.json', {}).subscribe() + + const req = httpMock.expectOne(apiBase + 'works/work.json') + expect(req.request.headers.has('x-xsrf-token')).toBe(false) + req.flush({}) + env.production = false + }) + + it('passes through GET requests without adding header', () => { + http.get(apiBase + 'works/works.json').subscribe() + + const req = httpMock.expectOne(apiBase + 'works/works.json') + expect(req.request.headers.has('x-xsrf-token')).toBe(false) + req.flush({}) + }) + + it('passes through when x-xsrf-token header is already present', () => { + cookieGetSpy.and.returnValue('cookie-token') + http + .post(apiBase + 'works/work.json', {}, { + headers: { 'x-xsrf-token': 'existing-token' }, + }) + .subscribe() + + const req = httpMock.expectOne(apiBase + 'works/work.json') + expect(req.request.headers.get('x-xsrf-token')).toBe('existing-token') + req.flush({}) + }) + + it('passes through when request URL is not to backend host', () => { + cookieGetSpy.and.returnValue('token') + http.post('https://other-origin.com/api', {}).subscribe() + + const req = httpMock.expectOne('https://other-origin.com/api') + expect(req.request.headers.has('x-xsrf-token')).toBe(false) + req.flush({}) + }) + + it('passes through when cookie is missing', () => { + cookieGetSpy.and.returnValue('') + + http.post(apiBase + 'works/work.json', {}).subscribe() + + const req = httpMock.expectOne(apiBase + 'works/work.json') + expect(req.request.headers.has('x-xsrf-token')).toBe(false) + req.flush({}) + }) + + it('adds XSRF-TOKEN for POST to API_WEB when cookie is present', () => { + cookieGetSpy.and.callFake((name: string) => + name === 'XSRF-TOKEN' ? 'api-xsrf-token' : '' + ) + + http.post(apiBase + 'works/work.json', {}).subscribe() + + const req = httpMock.expectOne(apiBase + 'works/work.json') + expect(req.request.headers.get('x-xsrf-token')).toBe('api-xsrf-token') + expect(req.request.withCredentials).toBe(true) + req.flush({}) + }) + + it('adds AUTH-XSRF-TOKEN for POST to AUTH_SERVER when cookie is present', () => { + cookieGetSpy.and.callFake((name: string) => + name === 'AUTH-XSRF-TOKEN' ? 'auth-xsrf-token' : '' + ) + + http.post(authBase + 'signin/auth.json', {}).subscribe() + + const req = httpMock.expectOne(authBase + 'signin/auth.json') + expect(req.request.headers.get('x-xsrf-token')).toBe('auth-xsrf-token') + req.flush({}) + }) + + it('adds XSRF-TOKEN for relative URL when cookie is present', () => { + cookieGetSpy.and.returnValue('rel-xsrf-token') + + http.post('/signin/auth.json', {}).subscribe() + + const req = httpMock.expectOne('/signin/auth.json') + expect(req.request.headers.get('x-xsrf-token')).toBe('rel-xsrf-token') + req.flush({}) + }) + + it('passes through PUT and PATCH and DELETE when not production', () => { + cookieGetSpy.and.returnValue('token') + + http.put(apiBase + 'works/1.json', {}).subscribe() + let req = httpMock.expectOne(apiBase + 'works/1.json') + expect(req.request.headers.get('x-xsrf-token')).toBe('token') + req.flush({}) + + http.patch(apiBase + 'works/1.json', {}).subscribe() + req = httpMock.expectOne(apiBase + 'works/1.json') + expect(req.request.headers.get('x-xsrf-token')).toBe('token') + req.flush({}) + + http.delete(apiBase + 'works/1.json').subscribe() + req = httpMock.expectOne(apiBase + 'works/1.json') + expect(req.request.headers.get('x-xsrf-token')).toBe('token') + req.flush({}) + }) +}) 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..35290fe7b2 --- /dev/null +++ b/src/app/core/xsrf/xsrf-fallback.interceptor.ts @@ -0,0 +1,87 @@ +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. + * + * Only active when not in production (local development runs). + * + * 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> { + // Only apply fallback in local development (e.g. proxy / same-origin dev) + if (runtimeEnvironment.production) { + return next.handle(req) + } + + 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/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..c32559626f 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 || '').substring(0, 125) + '...' }} - {{ recordImportWizard.description }} + {{ recordImportWizard.description || '' }}
- - {{ recordImportWizard.description }} + + {{ recordImportWizard.description || '' }}

diff --git a/src/app/record/components/search-link-wizard/search-link-wizard.component.spec.ts b/src/app/record/components/search-link-wizard/search-link-wizard.component.spec.ts index 6cb27bb042..d2eb0d4aa6 100644 --- a/src/app/record/components/search-link-wizard/search-link-wizard.component.spec.ts +++ b/src/app/record/components/search-link-wizard/search-link-wizard.component.spec.ts @@ -1,14 +1,18 @@ import { ComponentFixture, TestBed } from '@angular/core/testing' import { SearchLinkWizardComponent } from './search-link-wizard.component' +import { RecordImportWizard } from '../../../types/record-peer-review-import.endpoint' import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core' +declare const runtimeEnvironment: { BASE_URL: string } + describe('SearchLinkWizardComponent', () => { let component: SearchLinkWizardComponent let fixture: ComponentFixture beforeEach(async () => { + ;(window as any).runtimeEnvironment = { BASE_URL: 'https://example.org/' } await TestBed.configureTestingModule({ declarations: [SearchLinkWizardComponent], schemas: [CUSTOM_ELEMENTS_SCHEMA], @@ -18,10 +22,85 @@ describe('SearchLinkWizardComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(SearchLinkWizardComponent) component = fixture.componentInstance + component.recordImportWizards = [] fixture.detectChanges() }) it('should create', () => { expect(component).toBeTruthy() }) + + describe('openImportWizardUrlFilter', () => { + it('returns clientWebsite when client is connected', () => { + const client: RecordImportWizard = { + id: 'client-1', + name: 'Test', + redirectUri: 'https://app.example/cb', + scopes: 'scope1', + isConnected: true, + clientWebsite: 'https://connected.example', + } + expect(component.openImportWizardUrlFilter(client)).toBe( + 'https://connected.example' + ) + }) + + it('returns clientWebsite when status is RETIRED', () => { + const client: RecordImportWizard = { + id: 'client-2', + name: 'Retired', + redirectUri: 'https://app.example/cb', + scopes: 'scope1', + status: 'RETIRED', + clientWebsite: 'https://retired.example', + } + expect(component.openImportWizardUrlFilter(client)).toBe( + 'https://retired.example' + ) + }) + + + it('builds OAuth authorize URL when not connected and not retired', () => { + const client: RecordImportWizard = { + id: 'my-client', + name: 'OAuth App', + redirectUri: 'https://app.example/callback', + scopes: '/read-limited', + } + const url = component.openImportWizardUrlFilter(client) + expect(url).toContain('https://example.org/oauth/authorize') + expect(url).toContain('client_id=my-client') + expect(url).toContain('response_type=code') + expect(url).toContain('scope=' + encodeURIComponent('/read-limited')) + expect(url).toContain( + 'redirect_uri=' + encodeURIComponent('https://app.example/callback') + ) + }) + }) + + describe('toggle', () => { + it('flips show from false to true', () => { + const wizard: RecordImportWizard = { + id: 'w', + name: 'W', + redirectUri: 'https://u', + scopes: 's', + show: false, + } + component.toggle(wizard) + expect(wizard.show).toBe(true) + }) + + it('flips show from true to false', () => { + const wizard: RecordImportWizard = { + id: 'w', + name: 'W', + redirectUri: 'https://u', + scopes: 's', + show: true, + } + component.toggle(wizard) + expect(wizard.show).toBe(false) + }) + }) }) 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.ts b/src/app/record/components/work-stack-group/modals/work-search-link-modal/modal-works-search-link.component.ts index ff9fcc3089..c82eb90940 100644 --- a/src/app/record/components/work-stack-group/modals/work-search-link-modal/modal-works-search-link.component.ts +++ b/src/app/record/components/work-stack-group/modals/work-search-link-modal/modal-works-search-link.component.ts @@ -19,8 +19,8 @@ export class ModalWorksSearchLinkComponent implements OnInit, OnDestroy { loadingWorks = true recordImportWizardsOriginal: RecordImportWizard[] recordImportWizards: RecordImportWizard[] - workTypes = [] - geographicalAreas = [] + workTypes: string[] = [] + geographicalAreas: string[] = [] workTypeSelected = 'All' geographicalAreaSelected = 'All' total = 0 @@ -39,52 +39,51 @@ export class ModalWorksSearchLinkComponent implements OnInit, OnDestroy { .loadWorkImportWizardList() .pipe(takeUntil(this.$destroy)) .subscribe((recordImportWizards) => { + recordImportWizards.forEach((w) => (w.show = w.show ?? false)) this.recordImportWizardsOriginal = sortBy(recordImportWizards, 'name') - this.recordImportWizards = this.recordImportWizardsOriginal + this.recordImportWizards = [...this.recordImportWizardsOriginal] recordImportWizards.forEach((recordImportWizard) => { - recordImportWizard.actTypes.forEach((actType) => { + recordImportWizard.actTypes?.forEach((actType) => { if (!this.workTypes.includes(actType)) { this.workTypes.push(actType) } }) - - recordImportWizard.geoAreas.forEach((geoArea) => { + recordImportWizard.geoAreas?.forEach((geoArea) => { if (!this.geographicalAreas.includes(geoArea)) { this.geographicalAreas.push(geoArea) } }) }) this.loadingWorks = false - this.total = this.recordImportWizardsOriginal.length }) } searchAndLink() { - this.recordImportWizards = [] - this.recordImportWizardsOriginal.forEach((recordImportWizard) => { - if ( - this.workTypeSelected === 'All' && - this.geographicalAreaSelected === 'All' - ) { - this.recordImportWizards = this.recordImportWizardsOriginal - } else if ( - this.workTypeSelected === 'All' && - recordImportWizard.geoAreas.includes(this.geographicalAreaSelected) - ) { - this.recordImportWizards.push(recordImportWizard) - } else if ( - this.geographicalAreaSelected === 'All' && - recordImportWizard.actTypes.includes(this.workTypeSelected) - ) { - this.recordImportWizards.push(recordImportWizard) - } else if ( - recordImportWizard.actTypes.includes(this.workTypeSelected) && - recordImportWizard.geoAreas.includes(this.geographicalAreaSelected) - ) { - this.recordImportWizards.push(recordImportWizard) - } - }) + if ( + this.workTypeSelected === 'All' && + this.geographicalAreaSelected === 'All' + ) { + this.recordImportWizards = [...this.recordImportWizardsOriginal] + } else { + this.recordImportWizards = this.recordImportWizardsOriginal.filter( + (recordImportWizard) => { + const matchWorkType = + this.workTypeSelected === 'All' || + (recordImportWizard.actTypes?.length + ? recordImportWizard.actTypes.includes(this.workTypeSelected) + : true) + const matchGeo = + this.geographicalAreaSelected === 'All' || + (recordImportWizard.geoAreas?.length + ? recordImportWizard.geoAreas.includes( + this.geographicalAreaSelected + ) + : true) + return matchWorkType && matchGeo + } + ) + } this.total = this.recordImportWizards.length } diff --git a/src/app/record/components/work-stack-group/work-stack-group.component.html b/src/app/record/components/work-stack-group/work-stack-group.component.html index e1dfd4abaa..53206f2b3e 100644 --- a/src/app/record/components/work-stack-group/work-stack-group.component.html +++ b/src/app/record/components/work-stack-group/work-stack-group.component.html @@ -18,6 +18,7 @@ [sortTypes]="sortTypes" [sortType]="'date'" [addMenuOptions]="addMenuOptions" + (addEvent)="onAddEvent($event)" (sort)="sortEvent($event)" [type]="'works'" id="cy-works-panels" diff --git a/src/app/record/components/work-stack-group/work-stack-group.component.spec.ts b/src/app/record/components/work-stack-group/work-stack-group.component.spec.ts index ae6b96b423..3d202a1f2c 100644 --- a/src/app/record/components/work-stack-group/work-stack-group.component.spec.ts +++ b/src/app/record/components/work-stack-group/work-stack-group.component.spec.ts @@ -12,14 +12,25 @@ import { RecordService } from '../../../core/record/record.service' import { RecordWorksService } from '../../../core/record-works/record-works.service' import { HttpClientTestingModule } from '@angular/common/http/testing' import { RouterTestingModule } from '@angular/router/testing' +import { TogglzService } from '../../../core/togglz/togglz.service' +import { ADD_EVENT_ACTION } from '../../../constants' +import { of } from 'rxjs' import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core' describe('WorkStackGroupComponent', () => { let component: WorkStackGroupComponent let fixture: ComponentFixture + let mockTogglzService: jasmine.SpyObj + let recordWorksService: RecordWorksService + let matDialog: MatDialog beforeEach(async () => { + mockTogglzService = jasmine.createSpyObj('TogglzService', [ + 'getStateOf', + ]) + mockTogglzService.getStateOf.and.returnValue(of(true)) + await TestBed.configureTestingModule({ imports: [HttpClientTestingModule, MatDialogModule, RouterTestingModule], declarations: [WorkStackGroupComponent], @@ -33,6 +44,7 @@ describe('WorkStackGroupComponent', () => { MatSnackBar, MatDialog, Overlay, + { provide: TogglzService, useValue: mockTogglzService }, ], schemas: [CUSTOM_ELEMENTS_SCHEMA], }).compileComponents() @@ -41,10 +53,57 @@ describe('WorkStackGroupComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(WorkStackGroupComponent) component = fixture.componentInstance + component.isPublicRecord = 'orcid-id' + recordWorksService = TestBed.inject(RecordWorksService) + matDialog = TestBed.inject(MatDialog) fixture.detectChanges() }) it('should create', () => { expect(component).toBeTruthy() }) + + it('when togglz certified/featured is true, searchAndLink add menu option has no modal', () => { + const searchAndLinkOption = component.addMenuOptions.find( + (opt) => opt.action === ADD_EVENT_ACTION.searchAndLink + ) + expect(searchAndLinkOption).toBeDefined() + expect(searchAndLinkOption!.modal).toBeUndefined() + }) + + it('onAddEvent(searchAndLink) opens ImportWorksDialogComponent with skeleton data and loads dialog data', () => { + const openSpy = spyOn(matDialog, 'open').and.returnValue({ + componentInstance: { data: null }, + afterClosed: () => of(undefined), + } as any) + const skeleton = { loading: true, certifiedLinks: [], moreServicesLinks: [] } + spyOn(recordWorksService, 'getImportWorksDialogDataSkeleton').and.returnValue( + skeleton as any + ) + spyOn( + recordWorksService, + 'loadSearchAndLinkWizardDialogData' + ).and.returnValue(of({ loading: false, certifiedLinks: [], moreServicesLinks: [] } as any)) + + component.onAddEvent(ADD_EVENT_ACTION.searchAndLink) + + expect(recordWorksService.getImportWorksDialogDataSkeleton).toHaveBeenCalled() + expect(matDialog.open).toHaveBeenCalledWith( + jasmine.anything(), + jasmine.objectContaining({ + data: skeleton, + width: '850px', + maxHeight: '90vh', + }) + ) + expect(recordWorksService.loadSearchAndLinkWizardDialogData).toHaveBeenCalled() + }) + + it('onAddEvent(non-searchAndLink) does not open dialog', () => { + const openSpy = spyOn(matDialog, 'open') + + component.onAddEvent(ADD_EVENT_ACTION.addManually) + + expect(openSpy).not.toHaveBeenCalled() + }) }) diff --git a/src/app/record/components/work-stack-group/work-stack-group.component.ts b/src/app/record/components/work-stack-group/work-stack-group.component.ts index ae94cdfd55..6ff6c06263 100644 --- a/src/app/record/components/work-stack-group/work-stack-group.component.ts +++ b/src/app/record/components/work-stack-group/work-stack-group.component.ts @@ -3,7 +3,10 @@ import { Component, ElementRef, EventEmitter, + Inject, Input, + LOCALE_ID, + OnDestroy, OnInit, Output, QueryList, @@ -15,7 +18,7 @@ import { MatDialog } from '@angular/material/dialog' import { MatPaginatorIntl, PageEvent } from '@angular/material/paginator' import { isEmpty } from 'lodash' import { Observable, Subject } from 'rxjs' -import { first, take } from 'rxjs/operators' +import { first, take, takeUntil } from 'rxjs/operators' import { PlatformInfo, PlatformInfoService } from 'src/app/cdk/platform-info' import { ADD_EVENT_ACTION, @@ -50,6 +53,11 @@ import { ModalCombineWorksWithSelectorComponent } from '../work/modals/modal-com import { GroupingSuggestions } from 'src/app/types/works.endpoint' import { AnnouncerService } from 'src/app/core/announcer/announcer.service' import { TogglzService } from '../../../core/togglz/togglz.service' +import { TogglzFlag } from '../../../types/config.endpoint' +import { + ImportWorksDialogComponent, + ORCID_MODAL_DIALOG_PANEL_CLASS, +} from '@orcid/registry-ui' @Component({ selector: 'app-work-stack-group', @@ -60,7 +68,7 @@ import { TogglzService } from '../../../core/togglz/togglz.service' ], standalone: false, }) -export class WorkStackGroupComponent implements OnInit { +export class WorkStackGroupComponent implements OnInit, OnDestroy { paginatorLabel showManageSimilarWorks = false defaultPageSize = DEFAULT_PAGE_SIZE @@ -78,11 +86,10 @@ export class WorkStackGroupComponent implements OnInit { userRecordContext: UserRecordOptions = {} - addMenuOptions = [ + private _baseAddMenuOptions = [ { label: $localize`:@@shared.searchLink:Search & Link`, action: ADD_EVENT_ACTION.searchAndLink, - modal: ModalWorksSearchLinkComponent, id: 'cy-add-work-search-link', }, { @@ -105,7 +112,6 @@ export class WorkStackGroupComponent implements OnInit { modal: WorkBibtexModalComponent, id: 'cy-add-work-bibtext', }, - { label: $localize`:@@shared.addManually:Add manually`, action: ADD_EVENT_ACTION.addManually, @@ -114,6 +120,14 @@ export class WorkStackGroupComponent implements OnInit { }, ] + addMenuOptions: { + label: string + action: ADD_EVENT_ACTION + modal?: ComponentType + type?: EXTERNAL_ID_TYPE_WORK + id?: string + }[] = this._buildAddMenuOptions(false) + $destroy: Subject = new Subject() $loading: Observable @@ -143,7 +157,8 @@ export class WorkStackGroupComponent implements OnInit { private _works: RecordWorksService, private _matPaginatorIntl: MatPaginatorIntl, private _announce: AnnouncerService, - private _togglz: TogglzService + private _togglz: TogglzService, + @Inject(LOCALE_ID) private _locale: string ) {} ngOnInit(): void { @@ -168,6 +183,57 @@ export class WorkStackGroupComponent implements OnInit { this._platform.get().subscribe((platform) => { this.platform = platform }) + + this._togglz + .getStateOf(TogglzFlag.SEARCH_AND_LINK_WIZARD_WITH_CERTIFIED_AND_FEATURED_LINKS) + .pipe(take(1), takeUntil(this.$destroy)) + .subscribe((useCertifiedAndFeatured) => { + this.addMenuOptions = this._buildAddMenuOptions(useCertifiedAndFeatured) + }) + } + + private _buildAddMenuOptions(useCertifiedAndFeatured: boolean) { + return this._baseAddMenuOptions.map((opt) => { + if (opt.action === ADD_EVENT_ACTION.searchAndLink) { + return { + ...opt, + modal: useCertifiedAndFeatured + ? undefined + : ModalWorksSearchLinkComponent, + } + } + return { ...opt } + }) + } + + ngOnDestroy(): void { + this.$destroy.next(true) + this.$destroy.unsubscribe() + } + + /** Called when user chooses Search & Link and the certified/featured dialog is used (no modal in addMenuOptions). */ + onAddEvent(action: ADD_EVENT_ACTION): void { + if (action !== ADD_EVENT_ACTION.searchAndLink) { + return + } + const dialogRef = this._dialog.open(ImportWorksDialogComponent, { + panelClass: ORCID_MODAL_DIALOG_PANEL_CLASS, + data: this._works.getImportWorksDialogDataSkeleton(), + width: '850px', + maxHeight: '90vh', + }) + this._works + .loadSearchAndLinkWizardDialogData(this._normalizeLocale()) + .pipe(takeUntil(this.$destroy)) + .subscribe({ + next: (data) => { + dialogRef.componentInstance.data = { ...data, loading: false } + }, + }) + } + + private _normalizeLocale(): string { + return this._locale === 'en-US' ? 'en' : this._locale } private getGroupingSuggestions() { 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] diff --git a/src/app/types/record-peer-review-import.endpoint.ts b/src/app/types/record-peer-review-import.endpoint.ts index 6731a4ec8b..9d2b7212f0 100644 --- a/src/app/types/record-peer-review-import.endpoint.ts +++ b/src/app/types/record-peer-review-import.endpoint.ts @@ -1,12 +1,36 @@ +export type RecordImportWizardMetadataType = 'Featured' | 'Default' | 'Certified' + +export interface RecordImportWizardMetadata { + type?: RecordImportWizardMetadataType + index?: number + defaultDescription?: string + logoUrl?: string +} + export interface RecordImportWizard { - actTypes: string[] - clientWebsite: string - description: string - geoAreas: string[] + actTypes?: string[] + clientWebsite?: string + description?: string + geoAreas?: string[] + id: string + isConnected?: boolean + name: string + redirectUri: string + redirectUriMetadata?: RecordImportWizardMetadata + scopes: string + status?: string + show?: boolean +} + +/** + * Response shape from GET workspace/retrieve-works-search-and-link-wizard.json. + */ +export interface SearchAndLinkWizardFormSummaryResponse { id: string name: string + description?: string redirectUri: string scopes: string - status: string - show: boolean + redirectUriMetadata?: RecordImportWizardMetadata + connected?: boolean } diff --git a/src/environments/environment.int.ts b/src/environments/environment.int.ts index 3221524ed9..3752d48163 100644 --- a/src/environments/environment.int.ts +++ b/src/environments/environment.int.ts @@ -19,6 +19,8 @@ export const environment: EnvironmentInterface = { VERBOSE_SNACKBAR_ERRORS_REPORTS: true, WORDPRESS_S3: 'https://homepage-prod.orcid.org', WORDPRESS_S3_FALLBACK: 'https://homepage-fallback.orcid.org', + CERTIFIED_LINKS_LOCALIZE_BASE_URL: + 'https://s3.us-east-2.amazonaws.com/orcid-search-and-link.qa.orcid.org', ONE_TRUST: '5a6d60d3-b085-4e48-8afa-d707c7afc419-test', LANGUAGE_MENU_OPTIONS: { ar: 'العربية', diff --git a/src/environments/environment.local.4200.ts b/src/environments/environment.local.4200.ts index efc54d309e..4a6ab88238 100644 --- a/src/environments/environment.local.4200.ts +++ b/src/environments/environment.local.4200.ts @@ -20,6 +20,8 @@ export const environment: EnvironmentInterface = { VERBOSE_SNACKBAR_ERRORS_REPORTS: true, WORDPRESS_S3: 'https://homepage-qa.orcid.org', WORDPRESS_S3_FALLBACK: 'https://homepage-fallback.orcid.org', + CERTIFIED_LINKS_LOCALIZE_BASE_URL: + 'https://s3.us-east-2.amazonaws.com/orcid-search-and-link.qa.orcid.org', ONE_TRUST: '5a6d60d3-b085-4e48-8afa-d707c7afc419-test', NEW_RELIC_APP: '772335827', LANGUAGE_MENU_OPTIONS: { diff --git a/src/environments/environment.local.dev.orcid.org.ts b/src/environments/environment.local.dev.orcid.org.ts index bd1a37f792..1f9b374ff3 100644 --- a/src/environments/environment.local.dev.orcid.org.ts +++ b/src/environments/environment.local.dev.orcid.org.ts @@ -20,6 +20,8 @@ export const environment: EnvironmentInterface = { VERBOSE_SNACKBAR_ERRORS_REPORTS: true, WORDPRESS_S3: 'https://homepage-qa.orcid.org', WORDPRESS_S3_FALLBACK: 'https://homepage-fallback.orcid.org', + CERTIFIED_LINKS_LOCALIZE_BASE_URL: + 'https://s3.us-east-2.amazonaws.com/orcid-search-and-link.qa.orcid.org', ONE_TRUST: '5a6d60d3-b085-4e48-8afa-d707c7afc419-test', NEW_RELIC_APP: '772335827', LANGUAGE_MENU_OPTIONS: { diff --git a/src/environments/environment.qa.ts b/src/environments/environment.qa.ts index c7f2c986d2..5942eed644 100644 --- a/src/environments/environment.qa.ts +++ b/src/environments/environment.qa.ts @@ -20,6 +20,8 @@ export const environment: EnvironmentInterface = { VERBOSE_SNACKBAR_ERRORS_REPORTS: true, WORDPRESS_S3: 'https://homepage-qa.orcid.org', WORDPRESS_S3_FALLBACK: 'https://homepage-fallback.orcid.org', + CERTIFIED_LINKS_LOCALIZE_BASE_URL: + 'https://s3.us-east-2.amazonaws.com/orcid-search-and-link.qa.orcid.org', ONE_TRUST: '5a6d60d3-b085-4e48-8afa-d707c7afc419-test', NEW_RELIC_APP: '772335827', LANGUAGE_MENU_OPTIONS: { diff --git a/src/environments/environment.sandbox.ts b/src/environments/environment.sandbox.ts index 91dc90d37a..d61bb28619 100644 --- a/src/environments/environment.sandbox.ts +++ b/src/environments/environment.sandbox.ts @@ -20,6 +20,8 @@ export const environment: EnvironmentInterface = { VERBOSE_SNACKBAR_ERRORS_REPORTS: false, WORDPRESS_S3: 'https://homepage-prod.orcid.org', WORDPRESS_S3_FALLBACK: 'https://homepage-fallback.orcid.org', + CERTIFIED_LINKS_LOCALIZE_BASE_URL: + 'https://s3.us-east-2.amazonaws.com/orcid-search-and-link.qa.orcid.org', ONE_TRUST: '5a6d60d3-b085-4e48-8afa-d707c7afc419-test', NEW_RELIC_APP: '772335828', LANGUAGE_MENU_OPTIONS: { diff --git a/src/environments/interface.d.ts b/src/environments/interface.d.ts index 53908736d6..cb097c9cf5 100644 --- a/src/environments/interface.d.ts +++ b/src/environments/interface.d.ts @@ -18,6 +18,8 @@ export interface EnvironmentInterface { VERBOSE_SNACKBAR_ERRORS_REPORTS: boolean WORDPRESS_S3: string WORDPRESS_S3_FALLBACK: string + /** Base URL for certified links (e.g. S3 bucket). Fetched as {CERTIFIED_LINKS_LOCALIZE_BASE_URL}/works-search-and-link.{lang}.properties */ + CERTIFIED_LINKS_LOCALIZE_BASE_URL?: string NEW_RELIC_APP: string LANGUAGE_MENU_OPTIONS: { [key: string]: string