Skip to content

Commit

Permalink
feat: add error message when project not aligned to spaceId (#7587)
Browse files Browse the repository at this point in the history
* feat: add error message when project not aligned to spaceId

* chore: add teamId scope

* fix: fix api path selection on error

* fix: add another test

* chore: add more tests to api path selection

* chore: add a test file for projectSelection

* fix: adjust logic of when the project env is validated

* chore: small copy changes

* fix: adjust api path filtering

* chore: change name of listProject
  • Loading branch information
lilbitner authored May 7, 2024
1 parent ab2160b commit 4dd4720
Show file tree
Hide file tree
Showing 18 changed files with 286 additions and 40 deletions.
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

0 comments on commit 4dd4720

Please sign in to comment.