Skip to content

Commit 88dc106

Browse files
authored
Handle duplicate extension installs with a friendly already-installed message (#6805)
* Handle duplicate extension installs with a friendly already-installed message When appFetchManifest returns UNIQUE, resolve the existing app from Saleor's error payload and installed apps, then guide merchants to open it instead of showing a raw duplicate-install error. Pulse promotion links in What's New and the video announcement open Pulse directly when it is already installed. * Extract commit message * Fix Pulse CTA typing and App Store explore links
1 parent 0601672 commit 88dc106

32 files changed

Lines changed: 1399 additions & 108 deletions
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"saleor-dashboard": patch
3+
---
4+
5+
Re-installing an extension that is already on your store no longer shows a duplicate-install error. The install page now says the extension is already installed and links you to open it.
6+
7+
Pulse links in What's New and the homepage video announcement open Pulse directly when it is already installed, instead of sending you through the install flow again.

locale/defaultMessages.json

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@
3535
"context": "aria label for grid view button",
3636
"string": "Grid view"
3737
},
38+
"+CE3ZW": {
39+
"context": "ripple video announcement CTA when Saleor Pulse is already installed",
40+
"string": "Open Pulse"
41+
},
3842
"+G2N98": {
3943
"string": "Install from manifest"
4044
},
@@ -4927,6 +4931,10 @@
49274931
"Kxiige": {
49284932
"string": "Generated Token"
49294933
},
4934+
"KynPlY": {
4935+
"context": "link to open an already installed extension app UI",
4936+
"string": "Open {appName}"
4937+
},
49304938
"KzgcFV": {
49314939
"context": "category attribute entity type",
49324940
"string": "Categories"
@@ -9447,6 +9455,10 @@
94479455
"context": "payment method type other",
94489456
"string": "Other"
94499457
},
9458+
"g2P/kE": {
9459+
"context": "shown when user tries to install an extension that is already installed but inactive",
9460+
"string": "{appName} is installed but disabled."
9461+
},
94509462
"g3qjSf": {
94519463
"context": "button",
94529464
"string": "Assign categories"
@@ -9531,6 +9543,10 @@
95319543
"gSQ0Ge": {
95329544
"string": "Variant {name} has been set as default."
95339545
},
9546+
"gU10OX": {
9547+
"context": "shown when user tries to install an extension that is already installed",
9548+
"string": "{appName} is already installed."
9549+
},
95349550
"gaOXvo": {
95359551
"context": "notification, form submitted",
95369552
"string": "Refund grant for order #{orderNumber} was updated"
@@ -11992,6 +12008,10 @@
1199212008
"context": "number of included postal code ranges",
1199312009
"string": "{number, plural, one {# included postal code range} other {# included postal code ranges}}"
1199412010
},
12011+
"r0blTm": {
12012+
"context": "ripple action to open Saleor Pulse when already installed",
12013+
"string": "Open Pulse"
12014+
},
1199512015
"r1aQ2f": {
1199612016
"context": "dialog header",
1199712017
"string": "Unassign Attribute from Product Type"
@@ -14012,6 +14032,10 @@
1401214032
"zxs6G3": {
1401314033
"string": "Manage how you ship out orders"
1401414034
},
14035+
"zyWNgK": {
14036+
"context": "link to an already installed extension settings page from the install page",
14037+
"string": "Open settings"
14038+
},
1401514039
"zyXcBP": {
1401614040
"string": "Sorting by this column requires active filter: {filterName}"
1401714041
},

src/extensions/messages.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -538,6 +538,27 @@ export const appManifestErrorMessages = defineMessages({
538538
// TODO: Add docs link when we have docs page with explanation
539539
defaultMessage: "The extension identifier is already in use. ({errorCode})",
540540
},
541+
alreadyInstalled: {
542+
id: "gU10OX",
543+
defaultMessage: "{appName} is already installed.",
544+
description: "shown when user tries to install an extension that is already installed",
545+
},
546+
alreadyInstalledDisabled: {
547+
id: "g2P/kE",
548+
defaultMessage: "{appName} is installed but disabled.",
549+
description:
550+
"shown when user tries to install an extension that is already installed but inactive",
551+
},
552+
openInstalledApp: {
553+
id: "KynPlY",
554+
defaultMessage: "Open {appName}",
555+
description: "link to open an already installed extension app UI",
556+
},
557+
openInstalledExtensionSettings: {
558+
id: "zyWNgK",
559+
defaultMessage: "Open settings",
560+
description: "link to an already installed extension settings page from the install page",
561+
},
541562
forbidden: {
542563
// AppErrorCode.FORBIDDEN
543564
defaultMessage: "You are not allowed to perform this action. ({errorCode})",
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import {
2+
findAlreadyInstalledApp,
3+
findInstalledAppByIdentifier,
4+
} from "./findInstalledAppByIdentifier";
5+
6+
describe("findInstalledAppByIdentifier", () => {
7+
const installedApps = [
8+
{
9+
id: "app-1",
10+
identifier: "saleor.pulse",
11+
manifestUrl: "https://pulse.saleor.app/api/manifest",
12+
name: "Saleor Pulse",
13+
},
14+
{
15+
id: "app-2",
16+
identifier: "other.app",
17+
manifestUrl: "https://other.app/api/manifest",
18+
name: "Other App",
19+
},
20+
];
21+
22+
it("returns the installed app with a matching identifier", () => {
23+
// Arrange & Act
24+
const app = findInstalledAppByIdentifier(installedApps, "saleor.pulse");
25+
26+
// Assert
27+
expect(app?.id).toBe("app-1");
28+
});
29+
30+
it("returns undefined when no installed app matches", () => {
31+
// Arrange & Act
32+
const app = findInstalledAppByIdentifier(installedApps, "unknown.app");
33+
34+
// Assert
35+
expect(app).toBeUndefined();
36+
});
37+
});
38+
39+
describe("findAlreadyInstalledApp", () => {
40+
const installedApps = [
41+
{
42+
id: "app-1",
43+
identifier: "saleor.pulse",
44+
manifestUrl: "https://pulse.saleor.app/api/manifest",
45+
name: "Saleor Pulse",
46+
},
47+
{
48+
id: "app-2",
49+
identifier: "other.app",
50+
manifestUrl: "https://other.app/api/manifest",
51+
name: "Other App",
52+
},
53+
];
54+
55+
it("prefers identifier match over manifest URL", () => {
56+
// Arrange & Act
57+
const app = findAlreadyInstalledApp(installedApps, {
58+
identifier: "saleor.pulse",
59+
manifestUrl: "https://different-host.example/api/manifest",
60+
});
61+
62+
// Assert
63+
expect(app?.id).toBe("app-1");
64+
});
65+
66+
it("resolves the app from Saleor's UNIQUE error message", () => {
67+
// Arrange & Act
68+
const app = findAlreadyInstalledApp(installedApps, {
69+
manifestUrl: "https://staging.pulse.saleor.app/api/manifest",
70+
uniqueError: {
71+
field: "identifier",
72+
message: "App with the same identifier is already installed: Saleor Pulse",
73+
},
74+
});
75+
76+
// Assert
77+
expect(app?.id).toBe("app-1");
78+
});
79+
80+
it("falls back to manifest URL when identifier is missing", () => {
81+
// Arrange & Act
82+
const app = findAlreadyInstalledApp(installedApps, {
83+
manifestUrl: "https://other.app/api/manifest",
84+
});
85+
86+
// Assert
87+
expect(app?.id).toBe("app-2");
88+
});
89+
});
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { findInstalledAppByManifestUrl } from "./findInstalledAppByManifestUrl";
2+
import { findInstalledAppFromUniqueError } from "./findInstalledAppFromUniqueError";
3+
4+
export const findInstalledAppByIdentifier = <
5+
T extends {
6+
identifier: string | null;
7+
},
8+
>(
9+
installedApps: T[],
10+
identifier: string,
11+
): T | undefined => installedApps.find(app => app.identifier === identifier);
12+
13+
export const findAlreadyInstalledApp = <
14+
T extends {
15+
identifier: string | null;
16+
manifestUrl: string | null;
17+
name: string | null;
18+
},
19+
>(
20+
installedApps: T[],
21+
{
22+
identifier,
23+
manifestUrl,
24+
uniqueError,
25+
}: {
26+
identifier?: string | null;
27+
manifestUrl?: string | null;
28+
uniqueError?: {
29+
field?: string | null;
30+
message?: string | null;
31+
};
32+
},
33+
): T | undefined => {
34+
if (identifier) {
35+
const appByIdentifier = findInstalledAppByIdentifier(installedApps, identifier);
36+
37+
if (appByIdentifier) {
38+
return appByIdentifier;
39+
}
40+
}
41+
42+
if (uniqueError) {
43+
const appFromUniqueError = findInstalledAppFromUniqueError(installedApps, uniqueError);
44+
45+
if (appFromUniqueError) {
46+
return appFromUniqueError;
47+
}
48+
}
49+
50+
if (manifestUrl) {
51+
return findInstalledAppByManifestUrl(installedApps, manifestUrl);
52+
}
53+
54+
return undefined;
55+
};
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import {
2+
findInstalledAppByManifestUrl,
3+
normalizeManifestUrl,
4+
} from "./findInstalledAppByManifestUrl";
5+
6+
describe("normalizeManifestUrl", () => {
7+
it("normalizes URLs with trailing slashes", () => {
8+
// Arrange & Act & Assert
9+
expect(normalizeManifestUrl("https://example.com/manifest/")).toBe(
10+
"https://example.com/manifest/",
11+
);
12+
expect(normalizeManifestUrl("https://example.com/manifest")).toBe(
13+
"https://example.com/manifest",
14+
);
15+
});
16+
});
17+
18+
describe("findInstalledAppByManifestUrl", () => {
19+
const installedApps = [
20+
{ id: "app-1", manifestUrl: "https://pulse.saleor.app/api/manifest" },
21+
{ id: "app-2", manifestUrl: "https://other.app/api/manifest" },
22+
];
23+
24+
it("returns the installed app with a matching manifest URL", () => {
25+
// Arrange & Act
26+
const app = findInstalledAppByManifestUrl(
27+
installedApps,
28+
"https://pulse.saleor.app/api/manifest",
29+
);
30+
31+
// Assert
32+
expect(app?.id).toBe("app-1");
33+
});
34+
35+
it("returns undefined when no installed app matches", () => {
36+
// Arrange & Act
37+
const app = findInstalledAppByManifestUrl(installedApps, "https://unknown.app/api/manifest");
38+
39+
// Assert
40+
expect(app).toBeUndefined();
41+
});
42+
});
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
export const normalizeManifestUrl = (manifestUrl: string): string => {
2+
try {
3+
return new URL(manifestUrl).href;
4+
} catch {
5+
return manifestUrl;
6+
}
7+
};
8+
9+
export const findInstalledAppByManifestUrl = <
10+
T extends {
11+
manifestUrl: string | null;
12+
},
13+
>(
14+
installedApps: T[],
15+
manifestUrl: string,
16+
): T | undefined =>
17+
installedApps.find(
18+
app =>
19+
app.manifestUrl &&
20+
normalizeManifestUrl(app.manifestUrl) === normalizeManifestUrl(manifestUrl),
21+
);
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { findInstalledAppFromUniqueError } from "./findInstalledAppFromUniqueError";
2+
3+
describe("findInstalledAppFromUniqueError", () => {
4+
const installedApps = [
5+
{ id: "app-1", name: "Saleor Pulse" },
6+
{ id: "app-2", name: "Other App" },
7+
];
8+
9+
it("returns the installed app named in Saleor's UNIQUE error message", () => {
10+
// Arrange & Act
11+
const app = findInstalledAppFromUniqueError(installedApps, {
12+
field: "identifier",
13+
message: "App with the same identifier is already installed: Saleor Pulse",
14+
});
15+
16+
// Assert
17+
expect(app?.id).toBe("app-1");
18+
});
19+
20+
it("returns undefined when the error is not on the identifier field", () => {
21+
// Arrange & Act
22+
const app = findInstalledAppFromUniqueError(installedApps, {
23+
field: "manifestUrl",
24+
message: "App with the same identifier is already installed: Saleor Pulse",
25+
});
26+
27+
// Assert
28+
expect(app).toBeUndefined();
29+
});
30+
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// Saleor raises UNIQUE on identifier with:
2+
// "App with the same identifier is already installed: {app.name}"
3+
const SALEOR_UNIQUE_IDENTIFIER_PATTERN = /already installed:\s*(.+)$/i;
4+
5+
export const findInstalledAppFromUniqueError = <
6+
T extends {
7+
name: string | null;
8+
},
9+
>(
10+
installedApps: T[],
11+
error: {
12+
field?: string | null;
13+
message?: string | null;
14+
},
15+
): T | undefined => {
16+
if (error.field !== "identifier" || !error.message) {
17+
return undefined;
18+
}
19+
20+
const match = error.message.match(SALEOR_UNIQUE_IDENTIFIER_PATTERN);
21+
22+
if (!match) {
23+
return undefined;
24+
}
25+
26+
const appName = match[1].trim();
27+
28+
return installedApps.find(app => app.name === appName);
29+
};

0 commit comments

Comments
 (0)