Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add error message when project not aligned to spaceId #7587

Merged
merged 10 commits into from
May 7, 2024
64 changes: 63 additions & 1 deletion 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,9 +156,61 @@ export default class VercelClient implements VercelAPIClient {
return data.deployments[0];
}

async validateProjectContentfulSpaceId(
currentSpaceId: string,
projectId: string,
teamId: string
) {
try {
const data = await this.getProject(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
}?${this.buildTeamIdQueryParam(teamId)}`,
{
headers: this.buildHeaders(),
method: 'GET',
}
);

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

return false;
}

private async getProject(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[]) {
const irrelevantServerlessFunctionType = 'ISR';
return data.filter(
(file: ServerlessFunction) => file.type === 'Page' && file.path.includes('api')
(file: ServerlessFunction) =>
file.type != irrelevantServerlessFunctionType && file.path.includes('api')
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ describe('SelectSection', () => {
options={paths}
section={ID}
id={ID}
handleInvalidSelectionError={vi.fn()}
handleNotFoundError={vi.fn()}
handleChange={vi.fn()}
selectedOption={parameters.selectedApiPath}
/>
Expand All @@ -51,7 +51,7 @@ describe('SelectSection', () => {
section={ID}
id={ID}
handleChange={mockHandleChange}
handleInvalidSelectionError={vi.fn()}
handleNotFoundError={vi.fn()}
selectedOption={parameters.selectedApiPath}
/>,
{ handleAppConfigurationChange: mockHandleAppConfigurationChange }
Expand All @@ -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 All @@ -81,7 +85,7 @@ describe('SelectSection', () => {
id={ID}
selectedOption={'non-existent-id'}
handleChange={vi.fn()}
handleInvalidSelectionError={mockHandleInvalidSelectionError}
handleNotFoundError={mockHandleInvalidSelectionError}
error={projectSelectionError}
/>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ interface Props {
options: Path[] | Project[];
section: CopySection;
id: string;
handleInvalidSelectionError: () => void;
handleNotFoundError: () => void;
handleChange: (event: ChangeEvent<HTMLSelectElement>) => void;
helpText?: string | React.ReactNode;
error?: Errors['projectSelection'] | Errors['apiPathSelection'];
Expand All @@ -29,12 +29,12 @@ export const SelectSection = ({
id,
helpText,
error,
handleInvalidSelectionError,
handleNotFoundError,
handleChange,
}: 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 @@ -43,15 +43,15 @@ export const SelectSection = ({
const areOptionsAvailable = options.length === 0;

if (!isValidSelection && !areOptionsAvailable) {
handleInvalidSelectionError();
handleNotFoundError();
}
}
}, [selectedOption, options, isLoading]);

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
@@ -1,9 +1,11 @@
import { describe, expect, it } from 'vitest';
import { screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';

import { ApiPathSelectionSection } from './ApiPathSelectionSection';
import { renderConfigPageComponent } from '@test/helpers/renderConfigPageComponent';
import { ApiPath } from '@customTypes/configPage';
import { ApiPath, AppInstallationParameters } from '@customTypes/configPage';
import { copies } from '@constants/copies';
import userEvent from '@testing-library/user-event';

describe('ApiPathSelectionSection', () => {
it('renders dropdown when paths are present and no errors are present', () => {
Expand Down Expand Up @@ -62,4 +64,38 @@ describe('ApiPathSelectionSection', () => {
expect(input).toBeTruthy();
unmount();
});

it('renders no selected value in dropdown when apiPathNotFound error', () => {
const paths = [{ id: 'path-1', name: 'Path/1' }];
const errors = { apiPathSelection: { apiPathNotFound: true }, selectedApiPath: 'path-1' };
const { unmount } = renderConfigPageComponent(
<ApiPathSelectionSection paths={paths} />,
errors
);

const emptyInput = screen.getByText(copies.configPage.pathSelectionSection.placeholder);
expect(emptyInput).toBeTruthy();
unmount();
});

it('handles path selection', async () => {
const user = userEvent.setup();

const paths = [{ id: 'path-1', name: 'Path/1' }];
const mockDispatchParameters = vi.fn();
const parameters = {
dispatchParameters: mockDispatchParameters,
} as unknown as AppInstallationParameters;
const { unmount } = renderConfigPageComponent(
<ApiPathSelectionSection paths={paths} />,
parameters
);

const selectDropdown = screen.getByTestId('optionsSelect');

user.selectOptions(selectDropdown, paths[0].name);

await waitFor(() => expect(mockDispatchParameters).toHaveBeenCalled());
unmount();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ export const ApiPathSelectionSection = ({ paths }: Props) => {
const sectionId = singleSelectionSections.API_PATH_SELECTION_SECTION;
const { parameters, isLoading, dispatchErrors, errors, dispatchParameters } =
useContext(ConfigPageContext);
const { invalidDeploymentData, cannotFetchApiPaths } = errors.apiPathSelection;
const { invalidDeploymentData, cannotFetchApiPaths, apiPathNotFound } = errors.apiPathSelection;

const selectedOption = apiPathNotFound ? '' : parameters.selectedApiPath;

const handleChange = (event: ChangeEvent<HTMLSelectElement>) => {
dispatchParameters({
Expand All @@ -33,7 +35,7 @@ export const ApiPathSelectionSection = ({ paths }: Props) => {
});
};

const handleInvalidSelectionError = () => {
const handlePathNotFoundError = () => {
dispatchErrors({
type: errorsActions.UPDATE_API_PATH_SELECTION_ERRORS,
payload: errorTypes.API_PATH_NOT_FOUND,
Expand All @@ -47,9 +49,9 @@ export const ApiPathSelectionSection = ({ paths }: Props) => {

return (
<SelectSection
selectedOption={parameters.selectedApiPath}
selectedOption={selectedOption}
options={paths}
handleInvalidSelectionError={handleInvalidSelectionError}
handleNotFoundError={handlePathNotFoundError}
handleChange={handleChange}
section={sectionId}
id={sectionId}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';

import { AppInstallationParameters } from '@customTypes/configPage';
import { ProjectSelectionSection } from './ProjectSelectionSection';
import { copies } from '@constants/copies';
import { renderConfigPageComponent } from '@test/helpers/renderConfigPageComponent';
import userEvent from '@testing-library/user-event';
import * as fetchData from '@hooks/useFetchData/useFetchData';

const projects = [
{ id: 'project-1', name: 'Project 1', targets: { production: { id: 'project-1' } }, env: [] },
];

describe('ProjectSelectionSection', () => {
it('renders dropdown when paths are present and no errors are present', () => {
const { unmount } = renderConfigPageComponent(<ProjectSelectionSection projects={projects} />);

const select = screen.getByTestId('optionsSelect');
expect(select).toBeTruthy();
unmount();
});

it('renders no selected value in dropdown when projectNotFound error', () => {
const mockDispatchParameters = vi.fn();
const parameters = {
dispatchParameters: mockDispatchParameters,
} as unknown as AppInstallationParameters;
const { unmount } = renderConfigPageComponent(
<ProjectSelectionSection projects={projects} />,
parameters
);

const emptyInput = screen.getByText(copies.configPage.projectSelectionSection.placeholder);
expect(emptyInput).toBeTruthy();
unmount();
});

it('handles project selection', async () => {
const user = userEvent.setup();
const mockValidation = vi.fn().mockImplementationOnce(() => Promise.resolve());
vi.spyOn(fetchData, 'useFetchData').mockReturnValue({
validateProjectEnv: mockValidation,
validateToken: vi.fn(),
fetchProjects: vi.fn(),
fetchApiPaths: vi.fn(),
});
const mockHandleAppConfigurationChange = vi.fn();
const mockDispatchParameters = vi.fn();
const overrides = {
dispatchParameters: mockDispatchParameters,
handleAppConfigurationChange: mockHandleAppConfigurationChange,
parameters: { teamId: '1234', selectedProject: projects[0].id },
} as unknown as AppInstallationParameters;

const { unmount } = renderConfigPageComponent(<ProjectSelectionSection projects={projects} />, {
...overrides,
});

const selectDropdown = screen.getByTestId('optionsSelect');

user.selectOptions(selectDropdown, projects[0].name);

await waitFor(() => expect(mockHandleAppConfigurationChange).toBeCalled());
await waitFor(() => expect(mockDispatchParameters).toBeCalled());
await waitFor(() => expect(mockValidation).toBeCalled());
unmount();
});
});
Loading
Loading