@mohamedatia/fly-design-system 2.7.0 → 2.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,12 @@
1
1
  import * as i0 from '@angular/core';
2
- import { InjectionToken, signal, computed, Injectable, inject, ErrorHandler, PLATFORM_ID, ChangeDetectionStrategy, Component, Pipe, DOCUMENT, ElementRef, input, output, HostListener, ViewChild, EventEmitter, DestroyRef, Output, Input, forwardRef, Injector, viewChild, effect, afterNextRender, ViewEncapsulation, model, Directive } from '@angular/core';
2
+ import { InjectionToken, signal, computed, Injectable, inject, ErrorHandler, PLATFORM_ID, DestroyRef, ChangeDetectionStrategy, Component, Pipe, DOCUMENT, ElementRef, input, output, HostListener, ViewChild, EventEmitter, Output, Input, forwardRef, Injector, viewChild, effect, afterNextRender, ViewEncapsulation, model, Directive } from '@angular/core';
3
3
  import * as i1$1 from '@angular/common';
4
4
  import { isPlatformBrowser, NgComponentOutlet, CommonModule, DOCUMENT as DOCUMENT$1 } from '@angular/common';
5
5
  import { Router, NavigationEnd } from '@angular/router';
6
+ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
6
7
  import { of, ReplaySubject, Subject } from 'rxjs';
7
8
  import * as i1 from '@angular/forms';
8
9
  import { FormsModule, NG_VALUE_ACCESSOR, NG_VALIDATORS } from '@angular/forms';
9
- import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
10
10
  import { switchMap, debounceTime, distinctUntilChanged, filter } from 'rxjs/operators';
11
11
  import { HttpClient, HttpEventType } from '@angular/common/http';
12
12
  import Cropper from 'cropperjs';
@@ -395,6 +395,40 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.5", ngImpor
395
395
  * routing.
396
396
  */
397
397
  const FLY_REMOTE_ROUTES = new InjectionToken('FLY_REMOTE_ROUTES');
398
+ /**
399
+ * Optional injection token a remote provides at its root to declare the shell
400
+ * mount path prefix (e.g. `'/desktop'`). When set, `FlyRemoteRouter.segments()`
401
+ * strips this prefix from `window.location.pathname` before matching routes.
402
+ *
403
+ * **Why this is needed:** In embedded mode the remote is loaded via
404
+ * `NgComponentOutlet` inside the shell SPA. The shell's own Angular route may
405
+ * leave `window.location.pathname` as `/desktop` (or any shell-level path).
406
+ * Without stripping that prefix, `segments()` returns `['desktop']` which
407
+ * matches none of the remote's Circles-internal routes (e.g. `''`, `'signals'`,
408
+ * `'signals/:id'`), so `<fly-remote-router-outlet>` renders nothing.
409
+ *
410
+ * **Default behaviour (token absent):** `FlyRemoteRouter` initialises `_url`
411
+ * to `'/'` in embedded mode, completely ignoring `window.location.pathname`.
412
+ * This is safe because the shell does not push the remote's logical URL into
413
+ * browser history — only `navigate()` / `navigateByUrl()` calls do.
414
+ *
415
+ * **Usage in a remote root component:**
416
+ * ```ts
417
+ * providers: [
418
+ * FlyRemoteRouter,
419
+ * { provide: FLY_REMOTE_ROUTES, useValue: MY_ROUTES },
420
+ * { provide: FLY_REMOTE_BASE_PATH, useValue: '/desktop' },
421
+ * ]
422
+ * ```
423
+ *
424
+ * With `basePath = '/desktop'` and `window.location.pathname = '/desktop'`,
425
+ * the stripped URL is `'/'` → `segments() = []` → matches the `''` default
426
+ * route. With pathname `'/desktop/signals/abc'`, segments become
427
+ * `['signals', 'abc']` → matches `'signals/:id'`.
428
+ *
429
+ * Available since design-system **2.7.2**.
430
+ */
431
+ const FLY_REMOTE_BASE_PATH = new InjectionToken('FLY_REMOTE_BASE_PATH');
398
432
  /**
399
433
  * Match a route's path pattern against URL segments. Returns the captured params
400
434
  * map on a successful match, or `null` if the pattern can't match. Empty path
@@ -489,6 +523,15 @@ class FlyRemoteRouter {
489
523
  * driving its own switch/template — `matchedRoute` stays `null`.
490
524
  */
491
525
  routes = inject(FLY_REMOTE_ROUTES, { optional: true }) ?? [];
526
+ /**
527
+ * Shell mount path prefix to strip from `window.location.pathname` when
528
+ * deriving the initial URL in embedded mode. See `FLY_REMOTE_BASE_PATH` docs.
529
+ *
530
+ * When absent (the common case), the embedded router initialises `_url` to
531
+ * `'/'` and ignores `window.location.pathname` entirely — correct because the
532
+ * shell SPA never encodes the remote's logical route into the browser URL.
533
+ */
534
+ basePath = inject(FLY_REMOTE_BASE_PATH, { optional: true }) ?? null;
492
535
  /**
493
536
  * True when this remote is rendered inside the FlyOS desktop shell.
494
537
  *
@@ -550,17 +593,47 @@ class FlyRemoteRouter {
550
593
  * ```
551
594
  */
552
595
  params = computed(() => this.matchedRoute()?.params ?? {}, ...(ngDevMode ? [{ debugName: "params" }] : /* istanbul ignore next */ []));
596
+ destroyRef = inject(DestroyRef);
553
597
  constructor() {
554
598
  if (!this.isEmbedded && this.router) {
555
- // Seed with the current standalone URL so consumers can read `segments()`
556
- // synchronously during initial render, before any NavigationEnd fires.
599
+ // Standalone: seed from Angular Router and track NavigationEnd events so
600
+ // browser back/forward (which the host Router handles) keep segments() current.
557
601
  this._url.set(this.router.url);
558
- this.router.events.subscribe(event => {
602
+ this.router.events
603
+ .pipe(takeUntilDestroyed(this.destroyRef))
604
+ .subscribe(event => {
559
605
  if (event instanceof NavigationEnd) {
560
606
  this._url.set(event.urlAfterRedirects);
561
607
  }
562
608
  });
563
609
  }
610
+ else if (this.isEmbedded) {
611
+ // Embedded: the shell SPA keeps its own route in window.location.pathname
612
+ // (e.g. '/desktop') which is meaningless for the remote's route table.
613
+ // Default: initialise to '/' so the remote's empty-path default route
614
+ // matches on first render. When FLY_REMOTE_BASE_PATH is provided, strip
615
+ // it from pathname first — useful if the shell does push a remote-aware
616
+ // URL (e.g. '/desktop/signals/abc' with basePath '/desktop').
617
+ if (typeof window !== 'undefined') {
618
+ const rawPath = window.location.pathname || '/';
619
+ const initialUrl = this._resolveEmbeddedInitialUrl(rawPath);
620
+ this._url.set(initialUrl);
621
+ // Listen for browser back/forward so segments() stays in sync with
622
+ // history entries pushed by navigate() / navigateByUrl().
623
+ const onPopState = (event) => {
624
+ const state = event.state;
625
+ // Prefer the URL we stashed in the history state; fall back to pathname.
626
+ const url = state?.flyRemoteUrl ?? window.location.pathname ?? '/';
627
+ this._url.set(url.startsWith('/') ? url : '/' + url);
628
+ };
629
+ window.addEventListener('popstate', onPopState);
630
+ // Defensive cleanup — the service is providedIn: 'root' so this fires
631
+ // only when the whole Angular app is destroyed, but it keeps things tidy.
632
+ this.destroyRef.onDestroy(() => {
633
+ window.removeEventListener('popstate', onPopState);
634
+ });
635
+ }
636
+ }
564
637
  }
565
638
  /**
566
639
  * Navigate to a route, identified by an array of commands as you would pass
@@ -569,9 +642,11 @@ class FlyRemoteRouter {
569
642
  */
570
643
  navigate(commands, extras) {
571
644
  if (!this.isEmbedded && this.router) {
645
+ // Standalone: Angular Router owns history — pushState semantics are the default.
572
646
  return this.router.navigate(commands, extras);
573
647
  }
574
- this._url.set(this.buildUrl(commands));
648
+ const url = this.buildUrl(commands);
649
+ this._pushEmbedded(url);
575
650
  return Promise.resolve(true);
576
651
  }
577
652
  /**
@@ -579,9 +654,10 @@ class FlyRemoteRouter {
579
654
  */
580
655
  navigateByUrl(url, extras) {
581
656
  if (!this.isEmbedded && this.router) {
657
+ // Standalone: Angular Router owns history — pushState semantics are the default.
582
658
  return this.router.navigateByUrl(url, extras);
583
659
  }
584
- this._url.set(url.startsWith('/') ? url : '/' + url);
660
+ this._pushEmbedded(url.startsWith('/') ? url : '/' + url);
585
661
  return Promise.resolve(true);
586
662
  }
587
663
  /**
@@ -590,16 +666,54 @@ class FlyRemoteRouter {
590
666
  * so the browser's back stack is respected.
591
667
  */
592
668
  back() {
593
- if (!this.isEmbedded) {
594
- if (typeof history !== 'undefined')
595
- history.back();
596
- return;
669
+ // Both modes: delegate to the browser's history stack. In embedded mode we
670
+ // now push real history entries in navigate() / navigateByUrl(), so
671
+ // history.back() triggers popstate and the listener updates segments().
672
+ if (typeof history !== 'undefined') {
673
+ history.back();
597
674
  }
598
- const segs = [...this.segments()];
599
- if (segs.length === 0)
600
- return;
601
- segs.pop();
602
- this._url.set('/' + segs.join('/'));
675
+ }
676
+ /**
677
+ * Resolve the logical URL to seed `_url` from on embedded initial load.
678
+ *
679
+ * - No basePath: always return `'/'`. The shell's SPA path (e.g. `/desktop`)
680
+ * is irrelevant to the remote — `navigate()` is the only thing that should
681
+ * move the remote's URL.
682
+ * - basePath provided: strip it from `rawPath`. If rawPath starts with the
683
+ * basePath, the remainder becomes the seed URL (normalised to start with
684
+ * `'/'`). If it doesn't match (e.g. shell changed its own route), fall back
685
+ * to `'/'` rather than surfacing shell-specific segments to the remote's
686
+ * route table.
687
+ */
688
+ _resolveEmbeddedInitialUrl(rawPath) {
689
+ if (!this.basePath) {
690
+ // Default: ignore the shell's pathname, start at remote root.
691
+ return '/';
692
+ }
693
+ const base = this.basePath.endsWith('/') ? this.basePath.slice(0, -1) : this.basePath;
694
+ if (rawPath === base || rawPath === base + '/') {
695
+ return '/';
696
+ }
697
+ if (rawPath.startsWith(base + '/')) {
698
+ const remainder = rawPath.slice(base.length);
699
+ return remainder.startsWith('/') ? remainder : '/' + remainder;
700
+ }
701
+ // Shell path doesn't begin with our basePath — fall through to root.
702
+ return '/';
703
+ }
704
+ /**
705
+ * In embedded mode: push a real browser history entry (so back/forward work)
706
+ * and update the internal signal synchronously.
707
+ *
708
+ * The state object carries `flyRemoteUrl` so the popstate listener can
709
+ * restore the exact URL without relying on window.location.pathname (which
710
+ * could be the shell's route, not the remote's logical URL).
711
+ */
712
+ _pushEmbedded(url) {
713
+ if (typeof history !== 'undefined') {
714
+ history.pushState({ flyRemoteUrl: url }, '', url);
715
+ }
716
+ this._url.set(url);
603
717
  }
604
718
  buildUrl(commands) {
605
719
  const parts = commands
@@ -616,6 +730,161 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.5", ngImpor
616
730
  args: [{ providedIn: 'root' }]
617
731
  }], ctorParameters: () => [] });
618
732
 
733
+ /**
734
+ * fly-remote-styles — Shell-layer CSS loader for Native Federation remotes.
735
+ *
736
+ * Problem
737
+ * -------
738
+ * Federated remotes only ship JS chunks via Native Federation — their global
739
+ * `styles.css` (or its hashed equivalent) never loads in the shell, so any
740
+ * global CSS selector (e.g. `.circles-overlay`) silently breaks in embedded
741
+ * mode.
742
+ *
743
+ * Solution
744
+ * --------
745
+ * On first mount of each remote, the shell calls `loadRemoteStyles(appId,
746
+ * remoteBaseUrl)`. This function:
747
+ * 1. Derives the remote's `index.html` URL from `remoteBaseUrl`.
748
+ * 2. Fetches it (one network round-trip per remote, browser-cached thereafter).
749
+ * 3. Parses the HTML with DOMParser to extract the first
750
+ * `<link rel="stylesheet">` — the hashed production stylesheet
751
+ * (e.g. `styles-ABC123.css`). This is Strategy (b): index.html discovery.
752
+ * 4. Injects `<link rel="stylesheet" data-fly-app="<appId>">` into
753
+ * `document.head`. Idempotent: if an identical href is already present,
754
+ * the call is a no-op. If the href differs (hot upgrade), it is replaced.
755
+ *
756
+ * Why strategy (b)?
757
+ * -----------------
758
+ * (a) Unhashed `styles.css` — requires remote build config changes (out of scope).
759
+ * (b) Parse remote `index.html` — one tiny fetch per remote; browser caches it;
760
+ * works today without any remote changes.
761
+ * (c) `stylesUrl` in manifest/remoteEntry.json — cleanest long-term; requires
762
+ * protocol change to every remote's CI pipeline (out of scope for Wave A1).
763
+ *
764
+ * Unload decision
765
+ * ---------------
766
+ * `unloadRemoteStyles` is exported for symmetry but should NOT be called on
767
+ * normal window close. Keeping styles loaded across remounts avoids FOUC
768
+ * flicker when the user reopens the same app. The stylesheet is a few KB; the
769
+ * memory cost is negligible. Only call `unloadRemoteStyles` if you are certain
770
+ * the remote will never be opened again in this session (e.g. licence revoke).
771
+ *
772
+ * Caveats
773
+ * -------
774
+ * - CORS: the remote's dev/prod server must serve `index.html` with a
775
+ * permissive `Access-Control-Allow-Origin` header (or be same-origin via
776
+ * the YARP gateway). The fetch uses `credentials: 'omit'` to avoid
777
+ * credential-carrying preflights.
778
+ * - Race on rapid mount/unmount: if `loadRemoteStyles` is called a second time
779
+ * for the same appId while the first fetch is still in-flight, the second
780
+ * call joins the same in-flight Promise (idempotency guard at fetch level).
781
+ * - Angular hashing: Angular's production build hashes the stylesheet filename.
782
+ * DOMParser picks the first `<link rel="stylesheet">` in `<head>`, which is
783
+ * the single global stylesheet Angular emits. If a remote emits multiple
784
+ * stylesheets, only the first is loaded — acceptable for Wave A1.
785
+ */
786
+ /** Per-appId cache of the resolved stylesheet href (or `null` if not found). */
787
+ const _resolvedHref = new Map();
788
+ /** In-flight fetch promises keyed by appId — prevents duplicate fetches. */
789
+ const _inFlight = new Map();
790
+ /**
791
+ * Discovers the hashed stylesheet href from the remote's `index.html`.
792
+ * Returns `null` if no stylesheet `<link>` is found or the fetch fails.
793
+ */
794
+ async function _discoverStylesheetHref(remoteBaseUrl) {
795
+ const indexUrl = remoteBaseUrl.replace(/\/$/, '') + '/index.html';
796
+ try {
797
+ const res = await fetch(indexUrl, {
798
+ credentials: 'omit',
799
+ cache: 'default',
800
+ });
801
+ if (!res.ok)
802
+ return null;
803
+ const html = await res.text();
804
+ const doc = new DOMParser().parseFromString(html, 'text/html');
805
+ // Find the first stylesheet link in <head> — Angular emits exactly one.
806
+ const link = doc.head.querySelector('link[rel="stylesheet"]');
807
+ if (!link?.href)
808
+ return null;
809
+ // `link.href` from DOMParser is resolved relative to the parser's base,
810
+ // which defaults to `about:blank`. We get the raw `href` attribute instead.
811
+ const rawHref = link.getAttribute('href') ?? '';
812
+ if (!rawHref)
813
+ return null;
814
+ // If the remote emits an absolute URL, use it as-is; otherwise resolve
815
+ // against remoteBaseUrl.
816
+ if (rawHref.startsWith('http://') || rawHref.startsWith('https://') || rawHref.startsWith('//')) {
817
+ return rawHref;
818
+ }
819
+ return remoteBaseUrl.replace(/\/$/, '') + '/' + rawHref.replace(/^\//, '');
820
+ }
821
+ catch {
822
+ return null;
823
+ }
824
+ }
825
+ /**
826
+ * Injects the remote's stylesheet into `document.head`.
827
+ *
828
+ * @param appId - Stable identifier for the remote app (matches `DesktopApp.id`).
829
+ * @param remoteBaseUrl - Base URL of the remote, e.g. `https://circles.example.com`
830
+ * or `http://localhost:7202`. Must NOT include `/remoteEntry.json`.
831
+ *
832
+ * The call is idempotent:
833
+ * - If a `<link data-fly-app="appId">` with the same href already exists → no-op.
834
+ * - If the href differs (hot upgrade) → existing link is replaced.
835
+ * - If no stylesheet is found in `index.html` → no-op (logged as a warning).
836
+ */
837
+ async function loadRemoteStyles(appId, remoteBaseUrl) {
838
+ if (typeof document === 'undefined')
839
+ return; // SSR guard
840
+ // Kick off or join an in-flight fetch for this appId.
841
+ let fetchPromise = _inFlight.get(appId);
842
+ if (!fetchPromise) {
843
+ if (_resolvedHref.has(appId)) {
844
+ // Already resolved in a previous call — skip the fetch.
845
+ fetchPromise = Promise.resolve(_resolvedHref.get(appId) ?? null);
846
+ }
847
+ else {
848
+ fetchPromise = _discoverStylesheetHref(remoteBaseUrl);
849
+ _inFlight.set(appId, fetchPromise);
850
+ }
851
+ }
852
+ const href = await fetchPromise;
853
+ _inFlight.delete(appId);
854
+ _resolvedHref.set(appId, href);
855
+ if (!href) {
856
+ console.warn(`[FlyOS] loadRemoteStyles: no stylesheet found in ${remoteBaseUrl}/index.html for appId="${appId}"`);
857
+ return;
858
+ }
859
+ const existing = document.head.querySelector(`link[data-fly-app="${CSS.escape(appId)}"]`);
860
+ if (existing) {
861
+ if (existing.getAttribute('href') === href)
862
+ return; // identical — no-op
863
+ // Hot upgrade: replace href.
864
+ existing.setAttribute('href', href);
865
+ return;
866
+ }
867
+ const link = document.createElement('link');
868
+ link.rel = 'stylesheet';
869
+ link.setAttribute('data-fly-app', appId);
870
+ link.href = href;
871
+ document.head.appendChild(link);
872
+ }
873
+ /**
874
+ * Removes the injected stylesheet for `appId` from `document.head`.
875
+ *
876
+ * NOTE: Prefer NOT calling this on normal window close — keeping styles loaded
877
+ * prevents FOUC flicker when the user reopens the same app. Call only if you
878
+ * are certain the remote will not be reopened in this session.
879
+ */
880
+ function unloadRemoteStyles(appId) {
881
+ if (typeof document === 'undefined')
882
+ return; // SSR guard
883
+ const link = document.head.querySelector(`link[data-fly-app="${CSS.escape(appId)}"]`);
884
+ link?.parentNode?.removeChild(link);
885
+ _resolvedHref.delete(appId);
886
+ }
887
+
619
888
  /**
620
889
  * Outlet for FlyOS-embedded routing. Renders the component associated with the
621
890
  * first matching route in the consumer's `FLY_REMOTE_ROUTES` table, reacting to
@@ -4028,5 +4297,5 @@ const AUDIENCE_ERROR_CODES = {
4028
4297
  * Generated bundle index. Do not edit.
4029
4298
  */
4030
4299
 
4031
- export { AGENT_DRAG_MIME, AGENT_PAYLOAD_VERSION, AUDIENCE_ERROR_CODES, AUDIENCE_LIMITS, AUDIENCE_PRESETS, AUDIENCE_TERM_KINDS, AgentCommandRegistry, AgentDropRegistry, AgentPayloadOversizeError, AudienceBuilderComponent, AuthService, ContextMenuComponent, DEFAULT_AGENT_PAYLOAD_LIMITS, DEFAULT_FLY_THEME_MODE, DialogResult, FLY_LOCALE_CATALOG, FLY_REMOTE_ROUTES, FLY_THEME_MODE_IDS, FlyAgentDraggableDirective, FlyBlockUiComponent, FlyFileUploadComponent, FlyImageUploadComponent, FlyRemoteRouter, FlyRemoteRouterOutletComponent, FlyThemeService, I18nService, LAUNCH_CONTEXT, MessageBoxButtons, MessageBoxComponent, MessageBoxIcon, MessageBoxService, MockAuthService, RTL_LOCALE_SET, SHARE_ORG_CHART_SYSTEM_KEY_APPS, SHARE_ORG_CHART_SYSTEM_KEY_DEFAULT, SHARE_PANEL_DEFAULT_FILE_LEVELS, SharePanelComponent, StandaloneWindowManagerService, TranslatePipe, WINDOW_DATA, WindowManagerService, findLocaleByDialect, findLocaleByPrefix, isRtlLocale, isRtlLocaleEntry, matchFlyRoutePattern, normalizeFlyTheme, trimAgentPayload, trimAgentString, utf8ByteLength, validateAgentPayload };
4300
+ export { AGENT_DRAG_MIME, AGENT_PAYLOAD_VERSION, AUDIENCE_ERROR_CODES, AUDIENCE_LIMITS, AUDIENCE_PRESETS, AUDIENCE_TERM_KINDS, AgentCommandRegistry, AgentDropRegistry, AgentPayloadOversizeError, AudienceBuilderComponent, AuthService, ContextMenuComponent, DEFAULT_AGENT_PAYLOAD_LIMITS, DEFAULT_FLY_THEME_MODE, DialogResult, FLY_LOCALE_CATALOG, FLY_REMOTE_BASE_PATH, FLY_REMOTE_ROUTES, FLY_THEME_MODE_IDS, FlyAgentDraggableDirective, FlyBlockUiComponent, FlyFileUploadComponent, FlyImageUploadComponent, FlyRemoteRouter, FlyRemoteRouterOutletComponent, FlyThemeService, I18nService, LAUNCH_CONTEXT, MessageBoxButtons, MessageBoxComponent, MessageBoxIcon, MessageBoxService, MockAuthService, RTL_LOCALE_SET, SHARE_ORG_CHART_SYSTEM_KEY_APPS, SHARE_ORG_CHART_SYSTEM_KEY_DEFAULT, SHARE_PANEL_DEFAULT_FILE_LEVELS, SharePanelComponent, StandaloneWindowManagerService, TranslatePipe, WINDOW_DATA, WindowManagerService, findLocaleByDialect, findLocaleByPrefix, isRtlLocale, isRtlLocaleEntry, loadRemoteStyles, matchFlyRoutePattern, normalizeFlyTheme, trimAgentPayload, trimAgentString, unloadRemoteStyles, utf8ByteLength, validateAgentPayload };
4032
4301
  //# sourceMappingURL=mohamedatia-fly-design-system.mjs.map