@uxland/primary-shell 7.37.1 → 7.38.0

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,6 +1,7 @@
1
1
  import { ApiFactory, HarmonixApi } from '@uxland/harmonix';
2
2
  import { PrimariaBroker } from './broker/primaria-broker';
3
3
  import { EcapEventManager } from './ecap-event-manager/ecap-event-manager';
4
+ import { ExitGuardManager } from './exit-guard-manager/exit-guard-manager';
4
5
  import { PrimariaGlobalStateManager } from './global-state/global-state';
5
6
  import { HttpClient } from './http-client/http-client';
6
7
  import { PrimariaInteractionService } from './interaction-service';
@@ -25,6 +26,7 @@ export interface PrimariaApi extends HarmonixApi {
25
26
  userManager: UserManager;
26
27
  ecapEventManager: EcapEventManager;
27
28
  pluginBusyManager: PluginBusyManager;
29
+ exitGuardManager: ExitGuardManager;
28
30
  quickActionBusyManager: QuickActionBusyManager;
29
31
  pdfViewerManager: PdfViewerManager;
30
32
  importDataManager: PrimariaImportDataManager;
@@ -0,0 +1,12 @@
1
+ export type ExitGuardCanDispose = () => Promise<boolean>;
2
+ export declare abstract class ExitGuardManager {
3
+ abstract register(id: string, canDispose: ExitGuardCanDispose): void;
4
+ abstract unregister(id: string): void;
5
+ abstract canExit(): Promise<boolean>;
6
+ }
7
+ export declare class ExitGuardManagerImpl implements ExitGuardManager {
8
+ private guards;
9
+ register(id: string, canDispose: ExitGuardCanDispose): void;
10
+ unregister(id: string): void;
11
+ canExit(): Promise<boolean>;
12
+ }
@@ -2,6 +2,11 @@ export interface PluginTask {
2
2
  taskId: string;
3
3
  taskDescription: string;
4
4
  }
5
+ /**
6
+ * @deprecated Use the `canDispose(api)` plugin lifecycle hook instead. Plugins should
7
+ * decide whether they can be disposed (e.g. show their own confirmation modal) rather
8
+ * than relying on shell-level busy tasks. This API is kept for backwards compatibility.
9
+ */
5
10
  export declare abstract class PluginBusyManager {
6
11
  abstract addTask(task: PluginTask): void;
7
12
  abstract removeTask(taskId: string): void;
@@ -9,6 +14,7 @@ export declare abstract class PluginBusyManager {
9
14
  abstract isBusy(): boolean;
10
15
  abstract getTasks(): PluginTask[];
11
16
  }
17
+ /** @deprecated See {@link PluginBusyManager}. */
12
18
  export declare class PluginBusyManagerImpl implements PluginBusyManager {
13
19
  private tasks;
14
20
  constructor();
@@ -4,7 +4,6 @@ export declare class ExitShellHandler {
4
4
  private api;
5
5
  constructor(api: PrimariaApi);
6
6
  handle(exitEvent: ExitShell): Promise<void>;
7
- private askForClose;
8
7
  private timeout;
9
8
  private emitClose;
10
9
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uxland/primary-shell",
3
- "version": "7.37.1",
3
+ "version": "7.38.0",
4
4
  "description": "Primaria Shell",
5
5
  "author": "UXLand <dev@uxland.es>",
6
6
  "homepage": "https://github.com/uxland/harmonix/tree/app#readme",
package/src/api/api.ts CHANGED
@@ -1,15 +1,9 @@
1
- import {
2
- ApiFactory,
3
- HarmonixApi,
4
- PluginInfo,
5
- RegionManager,
6
- createRegionHost,
7
- createRegionManager,
8
- } from "@uxland/harmonix";
1
+ import { ApiFactory, HarmonixApi, PluginInfo, RegionManager, createRegionHost, createRegionManager } from "@uxland/harmonix";
9
2
  import { primariaShellId } from "../constants";
10
3
  import { createBroker } from "./broker/factory";
11
4
  import { PrimariaBroker } from "./broker/primaria-broker";
12
5
  import { EcapEventManager, createEcapEventManager } from "./ecap-event-manager/ecap-event-manager";
6
+ import { ExitGuardManager, ExitGuardManagerImpl } from "./exit-guard-manager/exit-guard-manager";
13
7
  import { PrimariaGlobalStateManager, createGlobalStateManager } from "./global-state/global-state";
14
8
  import { HttpClient, createHttpClient } from "./http-client/http-client";
15
9
  import { PrimariaInteractionService } from "./interaction-service";
@@ -18,14 +12,8 @@ import { createLocaleManager } from "./localization/localization";
18
12
  import { PrimariaNotificationService } from "./notification-service/notification-service";
19
13
  import { PrimariaNotificationServiceImpl } from "./notification-service/notification.service-impl";
20
14
  import { PdfViewerManager, createPdfViewerManager } from "./pdf-viewer-manager/pdf-viewer-manager";
21
- import {
22
- PluginBusyManager,
23
- PluginBusyManagerImpl,
24
- } from "./plugin-busy-manager/plugin-busy-manager";
25
- import {
26
- QuickActionBusyManager,
27
- QuickActionBusyManagerImpl,
28
- } from "./quick-action-busy-manager/quick-action-busy-manager";
15
+ import { PluginBusyManager, PluginBusyManagerImpl } from "./plugin-busy-manager/plugin-busy-manager";
16
+ import { QuickActionBusyManager, QuickActionBusyManagerImpl } from "./quick-action-busy-manager/quick-action-busy-manager";
29
17
  import { PrimariaRegionManager, createRegionManagerProxy } from "./region-manager/region-manager";
30
18
  import { TokenManager, createTokenManager } from "./token-manager/token-manager";
31
19
  import { UserManager, createUserManager } from "./user-manager/user-manager";
@@ -46,6 +34,7 @@ export interface PrimariaApi extends HarmonixApi {
46
34
  userManager: UserManager;
47
35
  ecapEventManager: EcapEventManager;
48
36
  pluginBusyManager: PluginBusyManager;
37
+ exitGuardManager: ExitGuardManager;
49
38
  quickActionBusyManager: QuickActionBusyManager;
50
39
  pdfViewerManager: PdfViewerManager;
51
40
  importDataManager: PrimariaImportDataManager;
@@ -58,6 +47,7 @@ const userManager = createUserManager(tokenManager);
58
47
  const globalStateManager: PrimariaGlobalStateManager = createGlobalStateManager(broker);
59
48
  const contextManager = createContextManager();
60
49
  const pluginBusyManager = new PluginBusyManagerImpl();
50
+ const exitGuardManager = new ExitGuardManagerImpl();
61
51
  const quickActionBusyManager = new QuickActionBusyManagerImpl(broker);
62
52
  const interactionService = new ParimariaInteractionServiceImpl();
63
53
  const notificationService = new PrimariaNotificationServiceImpl();
@@ -71,9 +61,7 @@ const importDataManager = new ImportDataManagerImpl(interactionService);
71
61
  * @param {PluginInfo} pluginInfo - Information about the plugin
72
62
  * @return {PrimariaApi} The created Primaria API instance
73
63
  */
74
- export const primariaApiFactory: ApiFactory<PrimariaApi> = (
75
- pluginInfo: PluginInfo,
76
- ): PrimariaApi => {
64
+ export const primariaApiFactory: ApiFactory<PrimariaApi> = (pluginInfo: PluginInfo): PrimariaApi => {
77
65
  const regionManagerProxy = createRegionManagerProxy(pluginInfo, regionManager, broker);
78
66
 
79
67
  return {
@@ -88,6 +76,7 @@ export const primariaApiFactory: ApiFactory<PrimariaApi> = (
88
76
  userManager,
89
77
  ecapEventManager,
90
78
  pluginBusyManager,
79
+ exitGuardManager,
91
80
  quickActionBusyManager,
92
81
  interactionService,
93
82
  notificationService,
@@ -0,0 +1,75 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { ExitGuardManagerImpl } from "./exit-guard-manager";
3
+
4
+ describe("ExitGuardManagerImpl", () => {
5
+ let manager: ExitGuardManagerImpl;
6
+
7
+ beforeEach(() => {
8
+ manager = new ExitGuardManagerImpl();
9
+ });
10
+
11
+ it("canExit resolves true when no guards are registered", async () => {
12
+ await expect(manager.canExit()).resolves.toBe(true);
13
+ });
14
+
15
+ it("canExit resolves true when every guard returns true", async () => {
16
+ manager.register("a", async () => true);
17
+ manager.register("b", async () => true);
18
+
19
+ await expect(manager.canExit()).resolves.toBe(true);
20
+ });
21
+
22
+ it("canExit resolves false when any guard returns false", async () => {
23
+ manager.register("a", async () => true);
24
+ manager.register("b", async () => false);
25
+
26
+ await expect(manager.canExit()).resolves.toBe(false);
27
+ });
28
+
29
+ it("canExit short-circuits and does not run guards after a false", async () => {
30
+ const guardA = vi.fn(async () => false);
31
+ const guardB = vi.fn(async () => true);
32
+
33
+ manager.register("a", guardA);
34
+ manager.register("b", guardB);
35
+
36
+ await manager.canExit();
37
+
38
+ expect(guardA).toHaveBeenCalled();
39
+ expect(guardB).not.toHaveBeenCalled();
40
+ });
41
+
42
+ it("unregister removes the guard", async () => {
43
+ const guard = vi.fn(async () => false);
44
+ manager.register("a", guard);
45
+ manager.unregister("a");
46
+
47
+ await expect(manager.canExit()).resolves.toBe(true);
48
+ expect(guard).not.toHaveBeenCalled();
49
+ });
50
+
51
+ it("register overwrites an existing guard with the same id", async () => {
52
+ manager.register("a", async () => false);
53
+ manager.register("a", async () => true);
54
+
55
+ await expect(manager.canExit()).resolves.toBe(true);
56
+ });
57
+
58
+ it("swallows errors in guards and continues with the remaining ones", async () => {
59
+ const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
60
+ const failing = vi.fn(async () => {
61
+ throw new Error("boom");
62
+ });
63
+ const allowing = vi.fn(async () => true);
64
+
65
+ manager.register("a", failing);
66
+ manager.register("b", allowing);
67
+
68
+ await expect(manager.canExit()).resolves.toBe(true);
69
+ expect(failing).toHaveBeenCalled();
70
+ expect(allowing).toHaveBeenCalled();
71
+ expect(consoleErrorSpy).toHaveBeenCalledWith("Exit guard failed:", expect.any(Error));
72
+
73
+ consoleErrorSpy.mockRestore();
74
+ });
75
+ });
@@ -0,0 +1,31 @@
1
+ export type ExitGuardCanDispose = () => Promise<boolean>;
2
+
3
+ export abstract class ExitGuardManager {
4
+ abstract register(id: string, canDispose: ExitGuardCanDispose): void;
5
+ abstract unregister(id: string): void;
6
+ abstract canExit(): Promise<boolean>;
7
+ }
8
+
9
+ export class ExitGuardManagerImpl implements ExitGuardManager {
10
+ private guards = new Map<string, ExitGuardCanDispose>();
11
+
12
+ register(id: string, canDispose: ExitGuardCanDispose): void {
13
+ this.guards.set(id, canDispose);
14
+ }
15
+
16
+ unregister(id: string): void {
17
+ this.guards.delete(id);
18
+ }
19
+
20
+ async canExit(): Promise<boolean> {
21
+ for (const guard of this.guards.values()) {
22
+ try {
23
+ const can = await guard();
24
+ if (!can) return false;
25
+ } catch (e) {
26
+ console.error("Exit guard failed:", e);
27
+ }
28
+ }
29
+ return true;
30
+ }
31
+ }
@@ -46,10 +46,12 @@ export class PdfVisor extends LitElement {
46
46
  }
47
47
  }
48
48
 
49
- private _getPdfSrc(pdf: IPdfDocument) {
50
- if (pdf.data.url) return pdf.data.url;
51
- if (pdf.data.b64) return createUrlFromBase64(pdf.data.b64);
52
- return "";
49
+ private _getPdfSrc(pdf: IPdfDocument): string {
50
+ const src = pdf.data.url || (pdf.data.b64 ? createUrlFromBase64(pdf.data.b64) : "") || "";
51
+ if (!src) return "";
52
+
53
+ const separator = src.includes("#") ? "&" : "#";
54
+ return `${src}${separator}navpanes=0`;
53
55
  }
54
56
 
55
57
  private _subscribeEvents() {
@@ -101,10 +103,7 @@ export class PdfVisor extends LitElement {
101
103
  <div class="pdf-container">
102
104
  ${
103
105
  this.activePdfs.length > 0
104
- ? this.activePdfs.map(
105
- (pdf) =>
106
- html`<iframe height="100%" src=${this._getPdfSrc(pdf)} frameborder="0"></iframe>`,
107
- )
106
+ ? this.activePdfs.map((pdf) => html`<iframe height="100%" src=${this._getPdfSrc(pdf)} frameborder="0"></iframe>`)
108
107
  : html`
109
108
  <div class="no-pdf">
110
109
  <p>${translate("pdfVisor.noPdfSelected")}</p>
@@ -6,6 +6,11 @@ export interface PluginTask {
6
6
  taskDescription: string;
7
7
  }
8
8
 
9
+ /**
10
+ * @deprecated Use the `canDispose(api)` plugin lifecycle hook instead. Plugins should
11
+ * decide whether they can be disposed (e.g. show their own confirmation modal) rather
12
+ * than relying on shell-level busy tasks. This API is kept for backwards compatibility.
13
+ */
9
14
  export abstract class PluginBusyManager {
10
15
  abstract addTask(task: PluginTask): void;
11
16
  abstract removeTask(taskId: string): void;
@@ -14,6 +19,7 @@ export abstract class PluginBusyManager {
14
19
  abstract getTasks(): PluginTask[];
15
20
  }
16
21
 
22
+ /** @deprecated See {@link PluginBusyManager}. */
17
23
  export class PluginBusyManagerImpl implements PluginBusyManager {
18
24
  private tasks: PluginTask[] = [];
19
25
 
@@ -3,9 +3,7 @@ import { ExitShellHandler } from "./handler";
3
3
  import { ExitShell } from "./request";
4
4
  import { disposePlugins } from "../../handle-plugins";
5
5
  import { disposeShell, raiseCloseEvent, raiseCustomCloseEvent } from "../../disposer";
6
- import { PluginBusyTask } from "../../api/plugin-busy-manager/plugin-busy-manager";
7
6
  import { PrimariaApi } from "../../api/api";
8
- import { PluginBusyList } from "../../api/plugin-busy-manager/plugin-busy-list/component";
9
7
 
10
8
  vi.mock("../../handle-plugins", () => ({
11
9
  disposePlugins: vi.fn(),
@@ -19,11 +17,8 @@ vi.mock("../../disposer", () => ({
19
17
 
20
18
  const createMockApi = (): PrimariaApi =>
21
19
  ({
22
- pluginBusyManager: {
23
- getTasks: vi.fn(),
24
- },
25
- interactionService: {
26
- confirm: vi.fn(),
20
+ exitGuardManager: {
21
+ canExit: vi.fn(),
27
22
  },
28
23
  notificationService: {
29
24
  error: vi.fn(),
@@ -40,21 +35,22 @@ describe("ExitShellHandler", () => {
40
35
  vi.clearAllMocks();
41
36
  });
42
37
 
43
- it("should dispose and raise custom close event if no busy tasks and message has ecapEvent", async () => {
38
+ it("disposes and raises custom close event when canExit resolves true and message has ecapEvent", async () => {
44
39
  const message = { ecapEvent: {} } as ExitShell;
45
- mockApi.pluginBusyManager.getTasks = vi.fn().mockReturnValue([]);
40
+ (mockApi.exitGuardManager.canExit as any).mockResolvedValue(true);
46
41
  (disposePlugins as any).mockResolvedValue(undefined);
47
42
 
48
43
  await handler.handle(message);
49
44
 
45
+ expect(mockApi.exitGuardManager.canExit).toHaveBeenCalled();
50
46
  expect(disposePlugins).toHaveBeenCalled();
51
47
  expect(disposeShell).toHaveBeenCalled();
52
48
  expect(raiseCustomCloseEvent).toHaveBeenCalledWith(message);
53
49
  });
54
50
 
55
- it("should dispose and raise default close event if no busy tasks and no ecapEvent in message", async () => {
51
+ it("raises default close event when no ecapEvent is present", async () => {
56
52
  const message = {} as ExitShell;
57
- mockApi.pluginBusyManager.getTasks = vi.fn().mockReturnValue([]);
53
+ (mockApi.exitGuardManager.canExit as any).mockResolvedValue(true);
58
54
  (disposePlugins as any).mockResolvedValue(undefined);
59
55
 
60
56
  await handler.handle(message);
@@ -65,50 +61,23 @@ describe("ExitShellHandler", () => {
65
61
  expect(raiseCustomCloseEvent).not.toHaveBeenCalled();
66
62
  });
67
63
 
68
- it("should ask for confirmation if there are busy tasks", async () => {
69
- const busyTasks: PluginBusyTask[] = [{ pluginId: "x" }] as any;
70
- const message = { ecapEvent: {} } as ExitShell;
71
- mockApi.pluginBusyManager.getTasks = vi.fn().mockReturnValue(busyTasks);
72
- mockApi.interactionService.confirm = vi.fn().mockResolvedValue({ confirmed: true });
73
- (disposePlugins as any).mockResolvedValue(undefined);
74
-
75
- await handler.handle(message);
76
-
77
- expect(mockApi.interactionService.confirm).toHaveBeenCalledWith(
78
- { busyTasks },
79
- { component: PluginBusyList },
80
- {
81
- title: "actions.askExit",
82
- state: "error",
83
- confirmButtonText: "Sí",
84
- cancelButtonText: "No",
85
- },
86
- );
87
- expect(disposePlugins).toHaveBeenCalled();
88
- expect(disposeShell).toHaveBeenCalled();
89
- expect(raiseCustomCloseEvent).toHaveBeenCalledWith(message);
90
- });
91
-
92
- it("should not continue if confirmation is declined", async () => {
93
- const busyTasks: PluginBusyTask[] = [{ pluginId: "x" }] as any;
64
+ it("aborts the exit when any guard returns false", async () => {
94
65
  const message = { ecapEvent: {} } as ExitShell;
95
- mockApi.pluginBusyManager.getTasks = vi.fn().mockReturnValue(busyTasks);
96
- mockApi.interactionService.confirm = vi.fn().mockResolvedValue({ confirmed: false });
66
+ (mockApi.exitGuardManager.canExit as any).mockResolvedValue(false);
97
67
 
98
68
  await handler.handle(message);
99
69
 
100
- expect(mockApi.interactionService.confirm).toHaveBeenCalled();
70
+ expect(mockApi.exitGuardManager.canExit).toHaveBeenCalled();
101
71
  expect(disposePlugins).not.toHaveBeenCalled();
102
72
  expect(disposeShell).not.toHaveBeenCalled();
103
73
  expect(raiseCustomCloseEvent).not.toHaveBeenCalled();
104
74
  expect(raiseCloseEvent).not.toHaveBeenCalled();
105
75
  });
106
76
 
107
- it("should handle errors and raise custom close event if message has ecapEvent", async () => {
108
- const error = new Error("Something went wrong");
77
+ it("notifies and raises custom close event when dispose throws and ecapEvent is present", async () => {
109
78
  const message = { ecapEvent: {} } as ExitShell;
110
- mockApi.pluginBusyManager.getTasks = vi.fn().mockReturnValue([]);
111
- (disposePlugins as any).mockRejectedValue(error);
79
+ (mockApi.exitGuardManager.canExit as any).mockResolvedValue(true);
80
+ (disposePlugins as any).mockRejectedValue(new Error("boom"));
112
81
 
113
82
  await handler.handle(message);
114
83
 
@@ -117,11 +86,10 @@ describe("ExitShellHandler", () => {
117
86
  expect(raiseCloseEvent).not.toHaveBeenCalled();
118
87
  });
119
88
 
120
- it("should handle errors and raise default close event if message has no ecapEvent", async () => {
121
- const error = new Error("Something went wrong");
89
+ it("notifies and raises default close event when dispose throws and no ecapEvent", async () => {
122
90
  const message = {} as ExitShell;
123
- mockApi.pluginBusyManager.getTasks = vi.fn().mockReturnValue([]);
124
- (disposePlugins as any).mockRejectedValue(error);
91
+ (mockApi.exitGuardManager.canExit as any).mockResolvedValue(true);
92
+ (disposePlugins as any).mockRejectedValue(new Error("boom"));
125
93
 
126
94
  await handler.handle(message);
127
95
 
@@ -130,9 +98,9 @@ describe("ExitShellHandler", () => {
130
98
  expect(raiseCustomCloseEvent).not.toHaveBeenCalled();
131
99
  });
132
100
 
133
- it("should proceed if disposePlugins takes longer than 10 seconds", async () => {
101
+ it("proceeds if disposePlugins exceeds the 10s timeout", async () => {
134
102
  const message = { ecapEvent: {} } as ExitShell;
135
- mockApi.pluginBusyManager.getTasks = vi.fn().mockReturnValue([]);
103
+ (mockApi.exitGuardManager.canExit as any).mockResolvedValue(true);
136
104
  (disposePlugins as any).mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 11000)));
137
105
 
138
106
  await handler.handle(message);
@@ -142,8 +110,8 @@ describe("ExitShellHandler", () => {
142
110
  expect(raiseCustomCloseEvent).toHaveBeenCalledWith(message);
143
111
  }, 15000);
144
112
 
145
- it("should raise raiseCloseEvent if no exitEvent is passed", async () => {
146
- mockApi.pluginBusyManager.getTasks = vi.fn().mockReturnValue([]);
113
+ it("raises raiseCloseEvent when no exitEvent is passed", async () => {
114
+ (mockApi.exitGuardManager.canExit as any).mockResolvedValue(true);
147
115
  (disposePlugins as any).mockResolvedValue(undefined);
148
116
 
149
117
  await handler.handle(undefined as any);
@@ -4,8 +4,6 @@ import { inject } from "inversify";
4
4
  import { ExitShell } from "./request";
5
5
  import { disposeShell, raiseCloseEvent, raiseCustomCloseEvent } from "../../disposer";
6
6
  import { disposePlugins } from "../../handle-plugins";
7
- import { PluginBusyTask } from "../../api/plugin-busy-manager/plugin-busy-manager";
8
- import { PluginBusyList } from "../../api/plugin-busy-manager/plugin-busy-list/component";
9
7
  import { translate } from "../../locales";
10
8
 
11
9
  export class ExitShellHandler {
@@ -14,11 +12,8 @@ export class ExitShellHandler {
14
12
  const evt = exitEvent && exitEvent.ecapEvent !== undefined ? exitEvent : undefined;
15
13
 
16
14
  try {
17
- const busyTasks = this.api.pluginBusyManager.getTasks();
18
- if (busyTasks.length > 0) {
19
- const { confirmed } = await this.askForClose(busyTasks);
20
- if (!confirmed) return;
21
- }
15
+ const canExit = await this.api.exitGuardManager.canExit();
16
+ if (!canExit) return;
22
17
 
23
18
  // Per si un plugin tarda molt en fer dispose, màxim deixarem 5 segons, per no interrompre el tancar infinitament
24
19
  await Promise.race([
@@ -33,15 +28,6 @@ export class ExitShellHandler {
33
28
  }
34
29
  }
35
30
 
36
- private askForClose(busyTasks: PluginBusyTask[]) {
37
- return this.api.interactionService.confirm({ busyTasks }, {component: PluginBusyList}, {
38
- title: translate("actions.askExit"),
39
- state: "error",
40
- confirmButtonText: "Sí",
41
- cancelButtonText: "No",
42
- });
43
- }
44
-
45
31
  private timeout(ms: number) {
46
32
  return new Promise<void>((resolve) => setTimeout(resolve, ms));
47
33
  }