Skip to content

Commit

Permalink
Fix activation form validation
Browse files Browse the repository at this point in the history
  • Loading branch information
twschiller committed Sep 22, 2024
1 parent 6a90319 commit 5f53bcc
Show file tree
Hide file tree
Showing 5 changed files with 116 additions and 73 deletions.
2 changes: 1 addition & 1 deletion src/activation/modOptionsHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export async function autoCreateDatabaseOptionsArgsInPlace(
databaseFactory: (args: { name: string }) => Promise<UUID | undefined>,
): Promise<OptionsArgs> {
const optionsProperties = Object.entries(
modDefinition.options?.schema?.properties ?? {},
modDefinition.options?.schema.properties ?? {},
)
.filter(
([name, fieldSchema]) =>
Expand Down
5 changes: 3 additions & 2 deletions src/activation/useActivateMod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { type Deployment, type DeploymentPayload } from "@/types/contract";
import { PIXIEBRIX_INTEGRATION_ID } from "@/integrations/constants";
import notify from "@/utils/notify";
import { selectModInstanceMap } from "@/store/modComponents/modInstanceSelectors";
import { getIsPersonalDeployment } from "@/store/modComponents/modInstanceUtils";

export type ActivateResult = {
success: boolean;
Expand Down Expand Up @@ -160,8 +161,8 @@ function useActivateMod(
let createdUserDeployment: Deployment | undefined;
if (
formValues.personalDeployment &&
// Avoid creating a personal deployment if the mod already has one
!modInstance?.deploymentMetadata?.isPersonalDeployment
// Avoid creating a personal deployment if the mod is already associated with one
!getIsPersonalDeployment(modInstance)
) {
const data: DeploymentPayload = {
name: `Personal deployment for ${modDefinition.metadata.name}, version ${modDefinition.metadata.version}`,
Expand Down
160 changes: 92 additions & 68 deletions src/activation/useActivateModWizard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

import { type WizardStep, type WizardValues } from "@/activation/wizardTypes";
import type React from "react";
import { isEmpty, mapValues } from "lodash";
import { mapValues } from "lodash";
import OptionsBody from "@/extensionConsole/pages/activateMod/OptionsBody";
import IntegrationsBody from "@/extensionConsole/pages/activateMod/IntegrationsBody";
import PermissionsBody from "@/extensionConsole/pages/activateMod/PermissionsBody";
Expand All @@ -35,7 +35,6 @@ import useMergeAsyncState from "@/hooks/useMergeAsyncState";
import { type Option } from "@/components/form/widgets/SelectWidget";
import { type FetchableAsyncState } from "@/types/sliceTypes";
import { isPrimitive } from "@/utils/typeUtils";
import { inputProperties } from "@/utils/schemaUtils";
import { PIXIEBRIX_INTEGRATION_ID } from "@/integrations/constants";
import getUnconfiguredComponentIntegrations from "@/integrations/util/getUnconfiguredComponentIntegrations";
import { makeDatabasePreviewName } from "@/activation/modOptionsHelpers";
Expand All @@ -52,6 +51,11 @@ import { freeze } from "@/utils/objectUtils";
import { fallbackValue } from "@/utils/asyncStateUtils";
import type { ModInstance } from "@/types/modInstanceTypes";
import useFindModInstance from "@/mods/hooks/useFindModInstance";
import {
hasDefinedModOptions,
normalizeModOptionsDefinition,
} from "@/utils/modUtils";
import { getIsPersonalDeployment } from "@/store/modComponents/modInstanceUtils";

const STEPS: WizardStep[] = [
{ key: "services", label: "Integrations", Component: IntegrationsBody },
Expand All @@ -72,16 +76,85 @@ const STEPS: WizardStep[] = [
{ key: "activate", label: "Permissions & URLs", Component: PermissionsBody },
];

function forcePrimitive(value: unknown): Primitive | undefined {
return isPrimitive(value) ? value : undefined;
}

export type UseActivateModWizardResult = {
wizardSteps: WizardStep[];
initialValues: WizardValues;
validationSchema: Yup.AnyObjectSchema;
};

function getInitialIntegrationDependencies(
modDefinition: ModDefinition,
modInstance: ModInstance | undefined,
defaultAuthOptions: Record<RegistryId, AuthOption | null>,
) {
const definedIntegrationDependencies =
getUnconfiguredComponentIntegrations(modDefinition);

const currentIntegrationConfigurationLookup = Object.fromEntries(
// Exclude PixieBrix integration - will be returned as an unconfigured dependency
modInstance?.integrationsArgs
?.filter((x) => x.integrationId !== PIXIEBRIX_INTEGRATION_ID)
.map((dependency) => [dependency.integrationId, dependency.configId]) ??
[],
);

return definedIntegrationDependencies.map((unconfiguredDependency) => ({
...unconfiguredDependency,
configId:
// Prefer the activated dependency for reactivate cases, otherwise use the default
currentIntegrationConfigurationLookup[
unconfiguredDependency.integrationId
] ?? defaultAuthOptions[unconfiguredDependency.integrationId]?.value,
}));
}

function forcePrimitive(value: unknown): Primitive | undefined {
return isPrimitive(value) ? value : undefined;
}

function getInitialOptionsArgs(
modDefinition: ModDefinition,
modInstance: ModInstance | undefined,
databaseOptions: Option[],
initialModOptions: UnknownObject,
) {
const optionsDefinition = normalizeModOptionsDefinition(
modDefinition.options ?? null,
);

return mapValues(
optionsDefinition.schema.properties,
(optionSchema: Schema, name: string) => {
// eslint-disable-next-line security/detect-object-injection -- name from schema
const activatedValue = modInstance?.optionsArgs?.[name];
if (activatedValue) {
return forcePrimitive(activatedValue);
}

if (isDatabaseField(optionSchema) && optionSchema.format === "preview") {
const databaseName = makeDatabasePreviewName(
modDefinition,
optionSchema,
name,
);
const existingDatabaseOption = databaseOptions.find(
(option) => option.label === `${databaseName} - Private`,
);
return existingDatabaseOption?.value ?? databaseName;
}

// eslint-disable-next-line security/detect-object-injection -- name from schema
const initialValue = initialModOptions[name];

if (initialValue !== undefined) {
return forcePrimitive(initialValue);
}

return forcePrimitive(optionSchema.default);
},
);
}

export function wizardStateFactory({
flagOn,
modInstance,
Expand All @@ -101,41 +174,24 @@ export function wizardStateFactory({
optionsValidationSchema: AnyObjectSchema;
initialModOptions: UnknownObject;
}): UseActivateModWizardResult {
const modComponentDefinitions = modDefinition.extensionPoints ?? [];

const hasPersonalDeployment =
modInstance?.deploymentMetadata?.isPersonalDeployment;
const hasPersonalDeployment = getIsPersonalDeployment(modInstance);

const unconfiguredIntegrationDependencies =
getUnconfiguredComponentIntegrations(modDefinition);

const activatedDependencies = Object.fromEntries(
modInstance?.integrationsArgs?.map((dependency) => [
dependency.integrationId,
dependency.configId,
]) ?? [],
);

const integrationDependencies = unconfiguredIntegrationDependencies.map(
(unconfiguredDependency) => ({
...unconfiguredDependency,
configId:
// Prefer the activated dependency for reactivate cases, otherwise use the default
activatedDependencies[unconfiguredDependency.integrationId] ??
defaultAuthOptions[unconfiguredDependency.integrationId]?.value,
}),
const initialIntegrationDependencies = getInitialIntegrationDependencies(
modDefinition,
modInstance,
defaultAuthOptions,
);

const wizardSteps = STEPS.filter((step) => {
switch (step.key) {
case "services": {
return integrationDependencies.some(
return initialIntegrationDependencies.some(
({ integrationId }) => integrationId !== PIXIEBRIX_INTEGRATION_ID,
);
}

case "options": {
return !isEmpty(inputProperties(modDefinition.options?.schema ?? {}));
return hasDefinedModOptions(modDefinition);
}

case "synchronize": {
Expand All @@ -149,49 +205,17 @@ export function wizardStateFactory({
});

const initialValues: WizardValues = {
integrationDependencies,
optionsArgs: mapValues(
modDefinition.options?.schema?.properties ?? {},
(optionSchema: Schema, name: string) => {
const activatedValue = modInstance?.optionsArgs?.[name];
if (activatedValue) {
return forcePrimitive(activatedValue);
}

if (
isDatabaseField(optionSchema) &&
optionSchema.format === "preview"
) {
const databaseName = makeDatabasePreviewName(
modDefinition,
optionSchema,
name,
);
const existingDatabaseOption = databaseOptions.find(
(option) => option.label === `${databaseName} - Private`,
);
return existingDatabaseOption?.value ?? databaseName;
}

if (initialModOptions[name] !== undefined) {
return forcePrimitive(initialModOptions[name]);
}

return forcePrimitive(optionSchema.default);
},
integrationDependencies: initialIntegrationDependencies,
optionsArgs: getInitialOptionsArgs(
modDefinition,
modInstance,
databaseOptions,
initialModOptions,
),
personalDeployment: hasPersonalDeployment,
};

const validationSchema = Yup.object().shape({
modComponents: Yup.object().shape(
Object.fromEntries(
modComponentDefinitions.map((_, index) => [
index,
Yup.boolean().required(),
]),
),
),
integrationDependencies: Yup.array().of(
Yup.object().test(
"integrationConfigsRequired",
Expand Down
9 changes: 9 additions & 0 deletions src/store/modComponents/modInstanceUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,15 @@ type ModInstanceActivatedModComponent = SetRequired<
"_recipe" | "definitions" | "integrationDependencies" | "permissions"
>;

/**
* Returns true if mod instance is defined and is associated with a personal deployment.
*/
export function getIsPersonalDeployment(
modInstance: ModInstance | undefined,
): boolean {
return Boolean(modInstance?.deploymentMetadata?.isPersonalDeployment);
}

/**
* Returns the activated mod component for a given mod instance. Is side effect free -- only maps the shape, does
* not persist the mod components or modify the UI.
Expand Down
13 changes: 11 additions & 2 deletions src/utils/modUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import {
minimalUiSchemaFactory,
propertiesToSchema,
} from "@/utils/schemaUtils";
import { cloneDeep, mapValues, sortBy } from "lodash";
import { cloneDeep, isEmpty, mapValues, sortBy } from "lodash";
import { isNullOrBlank } from "@/utils/stringUtils";
import {
type Schema,
Expand Down Expand Up @@ -186,7 +186,7 @@ export function emptyModOptionsDefinitionFactory(): Required<ModOptionsDefinitio
*/
export function normalizeModOptionsDefinition(
optionsDefinition: ModDefinition["options"] | null,
): Required<ModDefinition["options"]> {
): NonNullable<Required<ModDefinition["options"]>> {
if (!optionsDefinition) {
return emptyModOptionsDefinitionFactory();
}
Expand Down Expand Up @@ -217,6 +217,15 @@ export function normalizeModOptionsDefinition(
};
}

/**
* Returns true if the mod definition any defined options.
*/
export function hasDefinedModOptions(modDefinition: ModDefinition): boolean {
return !isEmpty(
normalizeModOptionsDefinition(modDefinition.options).schema.properties,
);
}

/**
* Return the activation instructions for a mod as markdown, or null if there are none.
*/
Expand Down

0 comments on commit 5f53bcc

Please sign in to comment.