Skip to content

Commit

Permalink
#9091 create personal deployment console (#9153)
Browse files Browse the repository at this point in the history
* wip

* wip

* create personal deploy on mod activation

* wip

* refactor to use appApi instead of custom hook to get packageVersionId

* combine getting the package version id and creating the user deployment

* fix test assertion

* error handling

* handle current personal deployment state

* Update src/activation/useActivateMod.ts comment

* remove useless check

---------

Co-authored-by: Graham Langford <[email protected]>
  • Loading branch information
fungairino and grahamlangford authored Sep 17, 2024
1 parent 56939fe commit f174363
Show file tree
Hide file tree
Showing 7 changed files with 363 additions and 18 deletions.
201 changes: 200 additions & 1 deletion src/activation/useActivateMod.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
import { type WizardValues } from "@/activation/wizardTypes";
import { renderHook } from "@/pageEditor/testHelpers";
import useActivateMod from "./useActivateMod";
import { validateRegistryId } from "@/types/helpers";
import { uuidv4, validateRegistryId } from "@/types/helpers";
import { type StarterBrickDefinitionLike } from "@/starterBricks/types";
import { type ContextMenuDefinition } from "@/starterBricks/contextMenu/contextMenuTypes";
import { deactivateMod } from "@/store/deactivateUtils";
Expand All @@ -41,8 +41,17 @@ import { appApiMock } from "@/testUtils/appApiMock";
import type MockAdapter from "axios-mock-adapter";
import { StarterBrickTypes } from "@/types/starterBrickTypes";
import { API_PATHS } from "@/data/service/urlPaths";
import { waitForEffect } from "@/testUtils/testHelpers";
import { editablePackageMetadataFactory } from "@/testUtils/factories/registryFactories";
import notify from "@/utils/notify";
import {
type Deployment,
type EditablePackageMetadata,
} from "@/types/contract";

jest.mock("@/contentScript/messenger/api");
jest.mock("@/utils/notify");
const mockedNotifyError = jest.mocked(notify.error);

const checkPermissionsMock = jest.mocked(checkModDefinitionPermissions);
const deactivateModMock = jest.mocked(deactivateMod);
Expand Down Expand Up @@ -109,6 +118,7 @@ function setUserAcceptedPermissions(accepted: boolean) {
describe("useActivateMod", () => {
beforeEach(() => {
reactivateEveryTabMock.mockClear();
appApiMock.reset();
});

it("returns error if permissions are not granted", async () => {
Expand Down Expand Up @@ -332,4 +342,193 @@ describe("useActivateMod", () => {
expect(success).toBe(false);
expect(error).toBe(errorMessage);
});

describe("personal deployment functionality", () => {
const packageVersionId = "package-version-id";
const testDeployment = {
id: uuidv4(),
name: "test-user-deployment",
} as Deployment;
let formValues: WizardValues;
let editablePackage: EditablePackageMetadata;
let modDefinition: ModDefinition;

beforeEach(() => {
({ formValues, modDefinition } = setupInputs());

setModHasPermissions(true);
setUserAcceptedPermissions(true);

editablePackage = editablePackageMetadataFactory({
name: modDefinition.metadata.id,
});
});

it("handles personal deployment creation successfully", async () => {
appApiMock.onGet(API_PATHS.BRICKS).reply(200, [editablePackage]);
appApiMock
.onGet(API_PATHS.BRICK_VERSIONS(editablePackage.id))
.reply(200, [
{ id: packageVersionId, version: modDefinition.metadata.version },
]);
appApiMock.onPost(API_PATHS.USER_DEPLOYMENTS).reply(201, testDeployment);

const { result, getReduxStore } = renderHook(
() => useActivateMod("marketplace"),
{
setupRedux(_dispatch, { store }) {
jest.spyOn(store, "dispatch");
},
},
);

const { success, error } = await result.current(
{ ...formValues, personalDeployment: true },
modDefinition,
);

expect(success).toBe(true);
expect(error).toBeUndefined();

const { dispatch } = getReduxStore();

expect(dispatch).toHaveBeenCalledWith(
modComponentSlice.actions.activateMod({
modDefinition,
configuredDependencies: [],
optionsArgs: formValues.optionsArgs,
screen: "marketplace",
isReactivate: false,
deployment: testDeployment,
}),
);

expect(
JSON.parse(
appApiMock.history.post!.find(
(request) => request.url === API_PATHS.USER_DEPLOYMENTS,
)!.data,
),
).toEqual({
package_version: packageVersionId,
name: `Personal deployment for ${modDefinition.metadata.name}, version ${modDefinition.metadata.version}`,
services: [],
options_config: formValues.optionsArgs,
});
});

it("notifies error when personal deployment was not created due to missing package", async () => {
appApiMock.onGet(API_PATHS.BRICKS).reply(200, []);

const { result } = renderHook(() => useActivateMod("marketplace"));
await waitForEffect();

const { success, error } = await result.current(
{ ...formValues, personalDeployment: true },
modDefinition,
);

expect(success).toBe(true);
expect(error).toBeUndefined();

expect(mockedNotifyError).toHaveBeenCalledWith({
message: `Error setting up device synchronization for ${modDefinition.metadata.name}. Please try reactivating.`,
error: new Error(
`Failed to find editable package for mod: ${modDefinition.metadata.id}`,
),
});
});

it("notifies error when personal deployment was not created due to failed package call", async () => {
appApiMock.onGet(API_PATHS.BRICKS).reply(500);

const { result } = renderHook(() => useActivateMod("marketplace"));

const { success, error } = await result.current(
{ ...formValues, personalDeployment: true },
modDefinition,
);

expect(success).toBe(true);
expect(error).toBeUndefined();

expect(mockedNotifyError).toHaveBeenCalledWith({
message: `Error setting up device synchronization for ${modDefinition.metadata.name}. Please try reactivating.`,
error: expect.objectContaining({
message: "Request failed with status code 500",
}),
});
});

it("notifies error when personal deployment was not created due to missing package version", async () => {
appApiMock.onGet(API_PATHS.BRICKS).reply(200, [editablePackage]);
appApiMock
.onGet(API_PATHS.BRICK_VERSIONS(editablePackage.id))
.reply(200, []);

const { result } = renderHook(() => useActivateMod("marketplace"));

const { success, error } = await result.current(
{ ...formValues, personalDeployment: true },
modDefinition,
);

expect(success).toBe(true);
expect(error).toBeUndefined();

expect(mockedNotifyError).toHaveBeenCalledWith({
message: `Error setting up device synchronization for ${modDefinition.metadata.name}. Please try reactivating.`,
error: new Error("Failed to find package version: 1.0.0"),
});
});

it("notifies error when personal deployment was not created due to failed package versions call", async () => {
appApiMock.onGet(API_PATHS.BRICKS).reply(200, [editablePackage]);
appApiMock.onGet(API_PATHS.BRICK_VERSIONS(editablePackage.id)).reply(500);

const { result } = renderHook(() => useActivateMod("marketplace"));

const { success, error } = await result.current(
{ ...formValues, personalDeployment: true },
modDefinition,
);

expect(success).toBe(true);
expect(error).toBeUndefined();

expect(mockedNotifyError).toHaveBeenCalledWith({
message: `Error setting up device synchronization for ${modDefinition.metadata.name}. Please try reactivating.`,
error: expect.objectContaining({
message: "Request failed with status code 500",
}),
});
});

it("notifies error when personal deployment was not created due to failed deployment call", async () => {
appApiMock.onGet(API_PATHS.BRICKS).reply(200, [editablePackage]);
appApiMock
.onGet(API_PATHS.BRICK_VERSIONS(editablePackage.id))
.reply(200, [
{ id: packageVersionId, version: modDefinition.metadata.version },
]);
appApiMock.onPost(API_PATHS.USER_DEPLOYMENTS).reply(500);

const { result } = renderHook(() => useActivateMod("marketplace"));

const { success, error } = await result.current(
{ ...formValues, personalDeployment: true },
modDefinition,
);

expect(success).toBe(true);
expect(error).toBeUndefined();

expect(mockedNotifyError).toHaveBeenCalledWith({
message: `Error setting up device synchronization for ${modDefinition.metadata.name}. Please try reactivating.`,
error: expect.objectContaining({
message: "Request failed with status code 500",
}),
});
});
});
});
64 changes: 54 additions & 10 deletions src/activation/useActivateMod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,17 @@ import { deactivateMod } from "@/store/deactivateUtils";
import { selectActivatedModComponents } from "@/store/modComponents/modComponentSelectors";
import { ensurePermissionsFromUserGesture } from "@/permissions/permissionsUtils";
import { checkModDefinitionPermissions } from "@/modDefinitions/modDefinitionPermissionsHelpers";
import { useCreateDatabaseMutation } from "@/data/service/api";
import {
useCreateDatabaseMutation,
useCreateUserDeploymentMutation,
} from "@/data/service/api";
import { Events } from "@/telemetry/events";
import { reloadModsEveryTab } from "@/contentScript/messenger/api";
import { autoCreateDatabaseOptionsArgsInPlace } from "@/activation/modOptionsHelpers";
import { type ReportEventData } from "@/telemetry/telemetryTypes";
import { type Deployment, type DeploymentPayload } from "@/types/contract";
import { PIXIEBRIX_INTEGRATION_ID } from "@/integrations/constants";
import notify from "@/utils/notify";

export type ActivateResult = {
success: boolean;
Expand Down Expand Up @@ -78,12 +84,14 @@ function useActivateMod(
const activatedModComponents = useSelector(selectActivatedModComponents);

const [createDatabase] = useCreateDatabaseMutation();
const [createUserDeployment] = useCreateUserDeploymentMutation();

return useCallback(
async (formValues: WizardValues, modDefinition: ModDefinition) => {
const isReactivate = activatedModComponents.some(
const activeModComponent = activatedModComponents.find(
(x) => x._recipe?.id === modDefinition.metadata.id,
);
const isReactivate = Boolean(activeModComponent);

if (source === "extensionConsole") {
// Note: The prefix "Marketplace" on the telemetry event name
Expand Down Expand Up @@ -152,13 +160,50 @@ function useActivateMod(
dispatch,
);

// TODO: handle updating a deployment from a previous version to the new version and
// handle deleting a deployment if the user turns off personal deployment
// https://github.com/pixiebrix/pixiebrix-extension/issues/9092
let createdUserDeployment: Deployment | undefined;
if (
formValues.personalDeployment &&
// Avoid creating a personal deployment if the mod already has one
!activeModComponent?._deployment?.isPersonalDeployment
) {
const data: DeploymentPayload = {
name: `Personal deployment for ${modDefinition.metadata.name}, version ${modDefinition.metadata.version}`,
services: integrationDependencies.flatMap(
(integrationDependency) =>
integrationDependency.integrationId ===
PIXIEBRIX_INTEGRATION_ID ||
integrationDependency.configId == null
? []
: [{ auth: integrationDependency.configId }],
),
options_config: optionsArgs,
};
const result = await createUserDeployment({
modDefinition,
data,
});

if ("error" in result) {
notify.error({
message: `Error setting up device synchronization for ${modDefinition.metadata.name}. Please try reactivating.`,
error: result.error,
});
} else {
createdUserDeployment = result.data;
}
}

dispatch(
modComponentSlice.actions.activateMod({
modDefinition,
configuredDependencies: integrationDependencies,
optionsArgs,
screen: source,
isReactivate: existingModComponents.length > 0,
deployment: createdUserDeployment,
}),
);

Expand All @@ -170,24 +215,23 @@ function useActivateMod(
error,
});

if (typeof errorMessage === "string") {
return {
success: false,
error: errorMessage,
};
}
return {
success: false,
error: errorMessage,
};
}

return {
success: true,
};
},
[
createDatabase,
dispatch,
activatedModComponents,
source,
checkPermissions,
dispatch,
createDatabase,
createUserDeployment,
],
);
}
Expand Down
6 changes: 5 additions & 1 deletion src/activation/useActivateModWizard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,10 @@ export function wizardStateFactory({
({ integrationId, configId }) => [integrationId, configId],
),
);
const hasPersonalDeployment = activatedModComponentsForMod?.some(
(x) => x._deployment?.isPersonalDeployment,
);

const unconfiguredIntegrationDependencies =
getUnconfiguredComponentIntegrations(modDefinition);
const integrationDependencies = unconfiguredIntegrationDependencies.map(
Expand Down Expand Up @@ -188,7 +192,7 @@ export function wizardStateFactory({
return forcePrimitive(optionSchema.default);
},
),
personalDeployment: false,
personalDeployment: hasPersonalDeployment,
};

const validationSchema = Yup.object().shape({
Expand Down
Loading

0 comments on commit f174363

Please sign in to comment.