Skip to content

Commit

Permalink
feat: add error message when project not aligned to spaceId
Browse files Browse the repository at this point in the history
  • Loading branch information
lilbitner committed May 6, 2024
1 parent d362c3c commit 555a24d
Show file tree
Hide file tree
Showing 15 changed files with 148 additions and 17 deletions.
58 changes: 58 additions & 0 deletions apps/vercel/frontend/src/clients/Vercel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,12 @@ import {
ServerlessFunction,
AccessToken,
Deployment,
ProjectEnv,
Project,
} from '@customTypes/configPage';

const CONTENTFUL_SPACE_ID = 'CONTENTFUL_SPACE_ID';

interface GetToken {
ok: boolean;
data: AccessToken;
Expand All @@ -18,6 +22,12 @@ interface VercelAPIClient {
getToken: () => Promise<Response>;
listProjects: (teamId?: string) => Promise<ListProjectsResponse>;
listApiPaths: (projectId: string, teamId?: string) => Promise<ApiPath[]>;
validateProjectContentfulSpaceId: (
currentSpaceId: string,
projectId: string,
teamId: string,
envs: ProjectEnv[]
) => Promise<boolean>;
}

export default class VercelClient implements VercelAPIClient {
Expand Down Expand Up @@ -146,6 +156,54 @@ export default class VercelClient implements VercelAPIClient {
return data.deployments[0];
}

async validateProjectContentfulSpaceId(
currentSpaceId: string,
projectId: string,
teamId: string
) {
try {
const data = await this.listProject(projectId, teamId);
const envs = data.env;
const contentfulSpaceIdEnv = envs.find((env) => env.key === CONTENTFUL_SPACE_ID);
if (contentfulSpaceIdEnv) {
const res = await fetch(
`${this.baseEndpoint}/v1/projects/${projectId}/env/${contentfulSpaceIdEnv.id}`,
{
headers: this.buildHeaders(),
method: 'GET',
}
);

const data = await res.json();
return data.value === currentSpaceId;
}
} catch (e) {
// let user continue if there is an validating env value
console.error(e);
}

return false;
}

private async listProject(projectId: string, teamId: string): Promise<Project> {
let projectData: Response;
try {
projectData = await fetch(
`${this.baseEndpoint}/v9/projects/${projectId}?${this.buildTeamIdQueryParam(teamId)}`,
{
headers: this.buildHeaders(),
method: 'GET',
}
);
} catch (e) {
console.error(e);
throw new Error('Cannot fetch project data.');
}

const data = await projectData.json();
return data;
}

private filterServerlessFunctions(data: ServerlessFunction[]) {
return data.filter(
(file: ServerlessFunction) => file.type === 'Page' && file.path.includes('api')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,11 @@ describe('SelectSection', () => {

it('renders error message when selected option no longer exists', () => {
const ID = singleSelectionSections.PROJECT_SELECTION_SECTION;
const projectSelectionError = { projectNotFound: true, cannotFetchProjects: false };
const projectSelectionError = {
projectNotFound: true,
cannotFetchProjects: false,
invalidSpaceId: false,
};
const mockHandleInvalidSelectionError = vi.fn(() => {});
const { unmount } = render(
<SelectSection
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export const SelectSection = ({
}: Props) => {
const { placeholder, label, emptyMessage, helpText: helpTextCopy } = copies.configPage[section];
const { isLoading } = useContext(ConfigPageContext);
const { isError, message } = useError({ error });
const { message } = useError({ error });

useEffect(() => {
if (!isLoading) {
Expand All @@ -51,7 +51,7 @@ export const SelectSection = ({
return (
<FormControl marginBottom="spacingS" id={id} isRequired={true}>
<Select
value={isError ? '' : selectedOption}
value={selectedOption}
onChange={handleChange}
placeholder={placeholder}
emptyMessage={emptyMessage}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export const ApiPathSelectionSection = ({ paths }: Props) => {
useContext(ConfigPageContext);
const { invalidDeploymentData, cannotFetchApiPaths } = errors.apiPathSelection;

const selectedOption = errors.apiPathSelection ? '' : parameters.selectedApiPath;

const handleChange = (event: ChangeEvent<HTMLSelectElement>) => {
dispatchParameters({
type: parametersActions.APPLY_API_PATH,
Expand All @@ -47,7 +49,7 @@ export const ApiPathSelectionSection = ({ paths }: Props) => {

return (
<SelectSection
selectedOption={parameters.selectedApiPath}
selectedOption={selectedOption}
options={paths}
handleInvalidSelectionError={handleInvalidSelectionError}
handleChange={handleChange}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,31 @@ import {
singleSelectionSections,
} from '@constants/enums';
import { ConfigPageContext } from '@contexts/ConfigPageProvider';
import { useFetchData } from '@hooks/useFetchData/useFetchData';

interface Props {
projects: Project[];
}

export const ProjectSelectionSection = ({ projects }: Props) => {
const sectionId = singleSelectionSections.PROJECT_SELECTION_SECTION;
const { parameters, errors, dispatchErrors, dispatchParameters, handleAppConfigurationChange } =
useContext(ConfigPageContext);
const {
parameters,
errors,
dispatchErrors,
dispatchParameters,
handleAppConfigurationChange,
sdk,
vercelClient,
} = useContext(ConfigPageContext);
const { validateProjectEnv } = useFetchData({
dispatchErrors,
dispatchParameters,
vercelClient,
teamId: parameters.teamId,
});

const handleChange = (event: ChangeEvent<HTMLSelectElement>) => {
const handleChange = async (event: ChangeEvent<HTMLSelectElement>) => {
// indicate app config change when project has been re-selected
handleAppConfigurationChange();

Expand All @@ -35,9 +49,8 @@ export const ProjectSelectionSection = ({ projects }: Props) => {
payload: event.target.value,
});

dispatchErrors({
type: errorsActions.RESET_PROJECT_SELECTION_ERRORS,
});
const currentSpaceId = sdk.ids.space;
await validateProjectEnv(currentSpaceId, event.target.value);
};

const handleInvalidSelectionError = () => {
Expand All @@ -47,10 +60,12 @@ export const ProjectSelectionSection = ({ projects }: Props) => {
});
};

const selectedOption = errors.projectSelection.projectNotFound ? '' : parameters.selectedProject;

return (
<SectionWrapper testId={sectionId}>
<SelectSection
selectedOption={parameters.selectedProject}
selectedOption={selectedOption}
options={projects}
handleInvalidSelectionError={handleInvalidSelectionError}
section={sectionId}
Expand Down
1 change: 1 addition & 0 deletions apps/vercel/frontend/src/constants/defaultParams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const initialErrors: Errors = {
projectSelection: {
projectNotFound: false,
cannotFetchProjects: false,
invalidSpaceId: false,
},
apiPathSelection: {
apiPathNotFound: false,
Expand Down
1 change: 1 addition & 0 deletions apps/vercel/frontend/src/constants/enums.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,5 @@ export enum errorTypes {
EMPTY_PREVIEW_PATH_INPUT = 'emptyPreviewPathInput',
API_PATHS_EMPTY = 'apiPathsEmpty',
INVALID_DEPLOYMENT_DATA = 'invalidDeploymentData',
INVALID_SPACE_ID = 'invalidSpaceId',
}
2 changes: 2 additions & 0 deletions apps/vercel/frontend/src/constants/errorMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,6 @@ export const errorMessages = {
invalidPreviewPathFormat: 'Path must start with a "/", and include a {token}.',
emptyPreviewPathInput: 'Field is empty.',
invalidDeploymentData: 'We had trouble fetching routes for this Vercel project.',
invalidSpaceId:
'It looks like you’ve configured an environment variable, CONTENTFUL_SPACE_ID, in the selected Vercel project that doesn’t match the id of the space where this app is installed.',
};
9 changes: 8 additions & 1 deletion apps/vercel/frontend/src/contexts/ConfigPageProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { createContext, Dispatch } from 'react';
import { ContentType } from '@contentful/app-sdk';
import { ConfigAppSDK, ContentType } from '@contentful/app-sdk';
import { ParameterAction } from '@reducers/parameterReducer';
import { AppInstallationParameters, Errors } from '@customTypes/configPage';
import { ErrorAction } from '@reducers/errorsReducer';
import VercelClient from '@clients/Vercel';

interface ConfigPageContextValue {
isAppConfigurationSaved: boolean;
Expand All @@ -13,6 +14,8 @@ interface ConfigPageContextValue {
dispatchErrors: Dispatch<ErrorAction>;
handleAppConfigurationChange: () => void;
isLoading: boolean;
sdk: ConfigAppSDK;
vercelClient: VercelClient | null;
}

export interface ChannelContextProviderProps extends ConfigPageContextValue {
Expand All @@ -32,6 +35,8 @@ export const ConfigPageProvider = (props: ChannelContextProviderProps) => {
errors,
handleAppConfigurationChange,
isLoading,
sdk,
vercelClient,
} = props;

return (
Expand All @@ -45,6 +50,8 @@ export const ConfigPageProvider = (props: ChannelContextProviderProps) => {
errors,
handleAppConfigurationChange,
isLoading,
sdk,
vercelClient,
}}>
{children}
</ConfigPageContext.Provider>
Expand Down
7 changes: 7 additions & 0 deletions apps/vercel/frontend/src/customTypes/configPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ export interface AppInstallationParameters {
teamId?: string;
}

export type ProjectEnv = {
key: string;
id: string;
value: string;
};
export interface Project {
id: string;
name: string;
Expand All @@ -25,6 +30,7 @@ export interface Project {
id: string;
};
};
env: ProjectEnv[];
}

export interface Deployment {
Expand Down Expand Up @@ -86,6 +92,7 @@ export type Errors = {
projectSelection: {
projectNotFound: boolean;
cannotFetchProjects: boolean;
invalidSpaceId: boolean;
};
apiPathSelection: {
apiPathNotFound: boolean;
Expand Down
27 changes: 26 additions & 1 deletion apps/vercel/frontend/src/hooks/useFetchData/useFetchData.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,5 +102,30 @@ export const useFetchData = ({
}
};

return { validateToken, fetchProjects, fetchApiPaths };
const validateProjectEnv = async (currentSpaceId: string, projectId: string) => {
if (vercelClient && teamId)
try {
const isProjectSelectionValid = await vercelClient.validateProjectContentfulSpaceId(
currentSpaceId,
projectId,
teamId
);
if (!isProjectSelectionValid) throw new Error(errorTypes.INVALID_SPACE_ID);
else {
dispatchErrors({
type: errorsActions.RESET_PROJECT_SELECTION_ERRORS,
});
}
} catch (e) {
const err = e as Error;
if (err.message === errorTypes.INVALID_SPACE_ID) {
dispatchErrors({
type: errorsActions.UPDATE_PROJECT_SELECTION_ERRORS,
payload: errorTypes.INVALID_SPACE_ID,
});
}
}
};

return { validateToken, fetchProjects, fetchApiPaths, validateProjectEnv };
};
1 change: 1 addition & 0 deletions apps/vercel/frontend/src/locations/ConfigScreen.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ describe('ConfigScreen', () => {
id: '',
},
},
env: [],
},
],
});
Expand Down
2 changes: 2 additions & 0 deletions apps/vercel/frontend/src/locations/ConfigScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,8 @@ const ConfigScreen = () => {
dispatchErrors={dispatchErrors}
isLoading={isLoading}
parameters={parameters}
sdk={sdk}
vercelClient={vercelClient}
errors={errors}>
<Box className={styles.body}>
<Box>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import React from 'react';
import { render } from '@testing-library/react';
import { mockContentTypes } from '../mocks/mockContentTypes';
import { mockContentTypePreviewPathSelections } from '../mocks/mockContentTypePreviewPathSelections';
import { ConfigPageContext } from '../../src/contexts/ConfigPageProvider';
import { AppInstallationParameters, Errors } from '../../src/customTypes/configPage';
import { ConfigPageContext } from '@contexts/ConfigPageProvider';
import { AppInstallationParameters, Errors } from '@customTypes/configPage';
import { initialErrors } from '@constants/defaultParams';
import { mockSdk } from '@test/mocks';

export const renderConfigPageComponent = (
children: React.ReactNode,
Expand All @@ -26,6 +27,8 @@ export const renderConfigPageComponent = (
isAppConfigurationSaved: false,
handleAppConfigurationChange: () => null,
isLoading: false,
sdk: mockSdk,
vercelClient: null,
...overrides,
}}>
{children}
Expand Down
7 changes: 5 additions & 2 deletions apps/vercel/frontend/test/helpers/renderConfigPageHook.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import React from 'react';
import { mockContentTypes } from '../mocks/mockContentTypes';
import { mockContentTypePreviewPathSelections } from '../mocks/mockContentTypePreviewPathSelections';
import { ConfigPageContext } from '../../src/contexts/ConfigPageProvider';
import { AppInstallationParameters, Errors } from '../../src/customTypes/configPage';
import { ConfigPageContext } from '@contexts/ConfigPageProvider';
import { AppInstallationParameters, Errors } from '@customTypes/configPage';
import { initialErrors } from '@constants/defaultParams';
import { mockSdk } from '@test/mocks';

export const renderConfigPageHook = ({ children }: { children: React.ReactNode }) => (
<ConfigPageContext.Provider
Expand All @@ -20,6 +21,8 @@ export const renderConfigPageHook = ({ children }: { children: React.ReactNode }
isAppConfigurationSaved: false,
handleAppConfigurationChange: () => null,
isLoading: false,
sdk: mockSdk,
vercelClient: null,
}}>
{children}
</ConfigPageContext.Provider>
Expand Down

0 comments on commit 555a24d

Please sign in to comment.