How to Save Password Protected PDF Attachments from Gmail to Google Drive

Credit card companies and banks often send their financial statements in the form of password-protected PDF files. These PDF attachments may be encrypted with passwords derived from the last four digits of your Social Security number, your birthdate, or any unique combination.

Password protected PDF Attachment in Gmail

If you are to open these password-protected PDF files, you’ll have to enter the password every time. You can permanently remove passwords from PDF files using Google Chrome but that’s a manual and time-consuming process especially if you have a large number of password-protected PDF attachments in your Gmail inbox.

Important Note - This tutorial will only help you decrypt password-protected PDF files for which you know the password. You cannot use this method to unlock PDF files where you don’t know the password.

Imagine the convenience of having an automated method to download all your password-protected PDF attachments from Gmail directly to Google Drive as unencrypted PDF documents. This would completely eliminate the need to enter passwords to view your PDF files. The other advantage is that your PDF files will become searchable in Google Drive.

Save Password Protected PDF Files to Google Drive

We’ll use the Gmail to Google Drive add-on for Google Sheets to automatically download password-protected PDF attachments from Gmail to Google Drive.

1. Build the Gmail Search Query

After the addon is installed, go your sheet.new in the browser and choose Extensions > Save Emails and Attachments > Open App. Create a new workflow and provide the Gmail search query that will help you find all the password-protected PDF files in your Gmail mailbox.

Gmail Search for PDF attachments from Bank

The search query can be of the form filename:pdf has:attachment from:bank.com where you can replace bank with the name of your bank or credit card company.

2. Choose the Google Drive Folder

On the next screen, select the folder in your Google Drive where you wish to save the decrypted PDF files. You can also choose to save the PDF files in a sub-folder of your Google Drive or even Shared Drives.

In our example, we have set the sub-folder for downloading emails as {{Sender Domain}} / {{Year}} / {{Month}} / {{Day}} so the PDF files would be saved in a folder structure like bank.com/2024/01/15.

Gmail Save folder

3. Unencrypt PDF Attachments

On the next screen, enable the option that says Save Attachments and choose PDF for the Allow file extensions list. Thus, only PDF files will be saved to Google Drive and all other email attachments will be ignored.

Next, enable the option that says Save PDF Attachments without password and provide the password that you use to open the PDF files. This is the password that you would normally enter when opening the PDF files in Adobe Acrobat or Google Chrome.

Decrypt PDF files

That’s it. Click the Save button to create the workflow and the add-on will now run in the background and save all your password-protected PDF attachments from Gmail to Google Drive as decrypted PDF files that can be opened without entering the password.

Building The Real App With React Query

If you have ever built React applications that use asynchronous data you probably know how annoying it could be to handle different states (loading, error, and so on), share the state between components using the same API endpoint, and keep the synchronized state in your components.

In order to refresh data, we should do a lot of actions: define useState and useEffect hooks, fetch data from the API, put the updated data to the state, change the loading state, handle errors, and so on. Fortunately, we have React Query, i.e. a library that makes fetching, caching, and managing the data easier.

Benefits Of Using The New Approach

React Query has an impressive list of features:

  • caching;
  • deduping multiple requests for the same data into a single request;
  • updating “out of date” data in the background (on windows focus, reconnect, interval, and so on);
  • performance optimizations like pagination and lazy loading data;
  • memoizing query results;
  • prefetching the data;
  • mutations, which make it easy to implement optimistic changes.

To demonstrate these features I’ve implemented an example application, where I tried to cover all cases for those you would like to use React Query. The application is written in TypeScript and uses CRA, React query, Axios mock server and material UI for easier prototyping.

Demonstration The Example Application

Let’s say we would like to implement the car service system. It should be able to:

  • log in using email and password and indicate the logged user;
  • show the list of next appointments with load more feature;
  • show information about one particular appointment;
  • save and view changes history;
  • prefetch additional information;
  • add and amend required jobs.
Client-Side Interaction

As we don’t have a real backend server, we will use axios-mock-adapter. I prepared some kind of REST API with get/post/patch/delete endpoints. To store data, we will use fixtures. Nothing special — just variables which we will mutate.

Also, in order to be able to view state changes, I’ve set the delay time as 1 second per request.

Preparing React Query For Using

Now we are ready to set up React Query. It’s pretty straightforward.

First, we have to wrap our app with the provider:

const queryClient = new QueryClient();

ReactDOM.render(
 <React.StrictMode>
   <Router>
     <QueryClientProvider client={queryClient}>
       <App />
       <ToastContainer />
     </QueryClientProvider>
   </Router>
 </React.StrictMode>,
 document.getElementById('root')
);

In QueryClient(), we could specify some global defaults.

For easier development, we will create our own abstractions for React Query hooks. To be able to subscribe to a query we have to pass a unique key. The easiest way to use strings, but it’s possible to use array-like keys.

In the official documentation, they use string keys, but I found it a bit redundant as we already have URLs for calling API requests. So, we could use the URL as a key, so that we don’t need to create new strings for keys.

However there are some restrictions: if you are going to use different URLs for GET/PATCH, for example, you have to use the same key, otherwise, React Query will not be able to match these queries.

Also, we should keep in mind that it’s important to include not only the URL but also all parameters which we are going to use to make requests to the backend. A combination of URL and params will create a solid key which the React Query will use for caching.

As a fetcher, we will use Axios where we pass a URL and params from queryKey.

export const useFetch = <T>(
 url: string | null,
 params?: object,
 config?: UseQueryOptions<T, Error, T, QueryKeyT>
) => {
 const context = useQuery<T, Error, T, QueryKeyT>(
   [url!, params],
   ({ queryKey }) => fetcher({ queryKey }),
   {
     enabled: !!url,
     ...config,
   }
 );

 return context;
};

export const fetcher = <T>({
 queryKey,
 pageParam,
}: QueryFunctionContext<QueryKeyT>): Promise<T> => {
 const [url, params] = queryKey;
 return api
   .get<T>(url, { params: { ...params, pageParam } })
   .then((res) => res.data);
};

Where [url!, params] is our key, setting enabled: !!url we use for pausing requests if there is no key (I’ll talk about that a bit later). For fetcher we could use anything — it doesn’t matter. For this case, I chose Axios.

For a smoother developer experience, it’s possible to use React Query Devtools by adding it to the root component.

import { ReactQueryDevtools } from 'react-query/devtools';

ReactDOM.render(
 <React.StrictMode>
   <Router>
     <QueryClientProvider client={queryClient}>
       <App />
       <ToastContainer />
       <ReactQueryDevtools initialIsOpen={false} />
     </QueryClientProvider>
   </Router>
 </React.StrictMode>,
 document.getElementById('root')
);

Nice one!

Authentication

To be able to use our app, we should log in by entering the email and password. The server returns the token and we store it in cookies (in the example app any combination of email/password works). When a user goes around our app we attach the token to each request.

Also, we fetch the user profile by the token. On the header, we show the user name or the loading if the request is still in progress. The interesting part is that we can handle a redirect to the login page in the root App component, but show the user name in the separate component.

This is where the React Query magic starts. By using hooks, we could easily share data about a user without passing it as props.

App.tsx:

const { error } = useGetProfile();

useEffect(() => {
 if (error) {
   history.replace(pageRoutes.auth);
 }
}, [error]);

UserProfile.tsx:

const UserProfile = ({}: Props) => {
 const { data: user, isLoading } = useGetProfile();

 if (isLoading) {
   return (
     <Box display="flex" justifyContent="flex-end">
       <CircularProgress color="inherit" size={24} />
     </Box>
   );
 }

 return (
   <Box display="flex" justifyContent="flex-end">
     {user ? `User: ${user.name}` : 'Unauthorized'}
   </Box>
 );
};

And the request to the API will be called just once (it is called deduping requests, and I’ll talk about it a bit more in the next section).

Hook to fetch the profile data:

export const useGetProfile = () => {
 const context = useFetch<{ user: ProfileInterface }>(
   apiRoutes.getProfile,
   undefined,
   { retry: false }
 );
 return { ...context, data: context.data?.user };
};

We use the retry: false setting here because we don’t want to retry this request. If it fails, we believe that the user is unauthorized and do the redirect.

When users enter their login and password we send a regular POST request. Theoretically, we could use React Query mutations here, but in this case, we don’t need to specify const [btnLoading, setBtnLoading] = useState(false); state and manage it, but I think it would be unclear and probably over complicated in this particular case.

If the request is successful, we invalidate all queries to get fresh data. In our app it would be just 1 query: user profile to update the name in the header, but just to be sure we invalidate everything.

if (resp.data.token) {
 Cookies.set('token', resp.data.token);
 history.replace(pageRoutes.main);
 queryClient.invalidateQueries();
}

If we wanted to invalidate just a single query we would use queryClient.invalidateQueries(apiRoutes.getProfile);.

For an appointment with id = 2 we have hasInsurance = false, and we don’t make requests for the insurance details.

Simple Mutation With Data Invalidation

To create/update/delete data in React Query we use mutations. It means we send a request to the server, receive a response, and based on a defined updater function we mutate our state and keep it fresh without making an additional request.

We have a genetic abstraction for these actions.

const useGenericMutation = <T, S>(
 func: (data: S) => Promise<AxiosResponse<S>>,
 url: string,
 params?: object,
 updater?: ((oldData: T, newData: S) => T) | undefined
) => {
 const queryClient = useQueryClient();

 return useMutation<AxiosResponse, AxiosError, S>(func, {
   onMutate: async (data) => {
     await queryClient.cancelQueries([url!, params]);

     const previousData = queryClient.getQueryData([url!, params]);

queryClient.setQueryData<T>([url!, params], (oldData) => {
 return updater ? (oldData!, data) : data;
});


     return previousData;
   },
   // If the mutation fails, use the context returned from onMutate to roll back
   onError: (err, _, context) => {
     queryClient.setQueryData([url!, params], context);
   },

   onSettled: () => {
     queryClient.invalidateQueries([url!, params]);
   },
 });
};

Let’s have a look in more detail. We have several callback methods:

onMutate (if the request is successful):

  1. Cancel any ongoing requests.
  2. Save the current data into a variable.
  3. If defined, we use an updater function to mutate our state by some specific logic, if not, just override the state with the new data. In most cases, it makes sense to define the updater function.
  4. Return the previous data.

onError (if the request is failed):

  1. Roll back the previous data.

onSettled (if the request is either successful or failed):

  1. Invalidate the query to keep the fresh state.

This abstraction we will use for all mutation actions.

export const useDelete = <T>(
 url: string,
 params?: object,
 updater?: (oldData: T, id: string | number) => T
) => {
 return useGenericMutation<T, string | number>(
   (id) => api.delete(`${url}/${id}`),
   url,
   params,
   updater
 );
};

export const usePost = <T, S>(
 url: string,
 params?: object,
 updater?: (oldData: T, newData: S) => T
) => {
 return useGenericMutation<T, S>(
   (data) => api.post<S>(url, data),
   url,
   params,
   updater
 );
};

export const useUpdate = <T, S>(
 url: string,
 params?: object,
 updater?: (oldData: T, newData: S) => T
) => {
 return useGenericMutation<T, S>(
   (data) => api.patch<S>(url, data),
   url,
   params,
   updater
 );
};

That’s why it’s very important to have the same set of [url!, params] (which we use as a key) in all hooks. Without that the library will not be able to invalidate the state and match the queries.

Let’s see how it works in our app: we have a History section, clicking by Save button we send a PATCH request and receive the whole updated appointment object.

First, we define a mutation. For now, we are not going to perform any complex logic, just returning the new state, that’s why we are not specifying the updater function.

const mutation = usePatchAppointment(+id);

export const usePatchAppointment = (id: number) =>
 useUpdate<AppointmentInterface, AppointmentInterface>(
   pathToUrl(apiRoutes.appointment, { id })
 );

Note: It uses our generic useUpdate hook.

Finally, we call the mutate method with the data we want to patch: mutation.mutate([data!]);.

Note: In this component, we use an isFetching flag to indicate updating data on window focus (check Background fetching section), so, we show the loading state each time when the request is in-flight. That’s why when we click Save, mutate the state and fetch the actual response we show the loading state as well. Ideally, it shouldn’t be shown in this case, but I haven’t found a way to indicate a background fetching, but don’t indicate fetching when loading the fresh data.

const History = ({ id }: Props) => {
 const { data, isFetching } = useGetAppointment(+id);
 const mutation = usePatchAppointment(+id);

 if (isFetching) {
   return (
     <Box>
       <Box pt={2}>
         <Skeleton animation="wave" variant="rectangular" height={15} />
       </Box>
       <Box pt={2}>
         <Skeleton animation="wave" variant="rectangular" height={15} />
       </Box>
       <Box pt={2}>
         <Skeleton animation="wave" variant="rectangular" height={15} />
       </Box>
     </Box>
   );
 }

 const onSubmit = () => {
   mutation.mutate(data!);
 };

 return (
   <>
     {data?.history.map((item) => (
       <Typography variant="body1" key={item.date}>
         Date: {item.date} <br />
         Comment: {item.comment}
       </Typography>
     ))}

     {!data?.history.length && !isFetching && (
       <Box mt={2}>
         <span>Nothing found</span>
       </Box>
     )}
     <Box mt={3}>
       <Button
         variant="outlined"
         color="primary"
         size="large"
         onClick={onSubmit}
         disabled={!data || mutation.isLoading}
       >
         Save
       </Button>
     </Box>
   </>
 );
};
Mutation With Optimistic Changes

Now let’s have a look at the more complex example: in our app, we want to have a list, where we should be able to add and remove items. Also, we want to make the user experience as smooth as we can. We are going to implement optimistic changes for creating/deleting jobs.

Here are the actions:

  1. User inputs the job name and clicks Add button.
  2. We immediately add this item to our list and show the loader on the Add button.
  3. In parallel we send a request to the API.
  4. When the response is received we hide the loader, and if it succeeds we just keep the previous entry, update its id in the list, and clear the input field.
  5. If the response is failed we show the error notification, remove this item from the list, and keep the input field with the old value.
  6. In both cases we send GET request to the API to make sure we have the actual state.

All our logic is:

const { data, isLoading } = useGetJobs();

const mutationAdd = useAddJob((oldData, newData) => [...oldData, newData]);
const mutationDelete = useDeleteJob((oldData, id) =>
 oldData.filter((item) => item.id !== id)
);

const onAdd = async () => {
 try {
   await mutationAdd.mutateAsync({
     name: jobName,
     appointmentId,
   });
   setJobName('');
 } catch (e) {
   pushNotification(Cannot add the job: ${jobName});
 }
};

const onDelete = async (id: number) => {
 try {
   await mutationDelete.mutateAsync(id);
 } catch (e) {
   pushNotification(Cannot delete the job);
 }
};

In this example we define our own updater functions to mutate the state by custom logic: for us, it’s just creating an array with the new item and filtering by id if we want to delete the item. But the logic could be any, it depends on your tasks.

React Query takes care of changing states, making requests, and rolling back the previous state if something goes wrong.

In the console you could see which requests axios makes to our mock API. We could immediately see the updated list in the UI, then we call POST and finally we call GET. It works because we defined onSettled callback in useGenericMutation hook, so after success or error we always refetch the data:

onSettled: () => {
 queryClient.invalidateQueries([url!, params]);
},

Note: When I highlight the lines in the dev tools you could see a lot of made requests. This is because we change the window focus when we click on the Dev Tools window, and React Query invalidates the state.

If the backend returned the error, we would rollback the optimistic changes, and show the notification. It works because we defined onError callback in useGenericMutation hook, so we set previous data if an error happened:

onError: (err, _, context) => {
 queryClient.setQueryData([url!, params], context);
},
Prefetching

Prefetching could be useful if we want to have the data in advance and if there is a high possibility that a user will request this data in the near future.

In our example, we will prefetch the car details if the user moves the mouse cursor in the Additional section area.

When the user clicks the Show button we will render the data immediately, without calling the API (despite having a 1-second delay).

const prefetchCarDetails = usePrefetchCarDetails(+id);

onMouseEnter={() => {
 if (!prefetched.current) {
   prefetchCarDetails();
   prefetched.current = true;
 }
}}

export const usePrefetchCarDetails = (id: number | null) =>
 usePrefetch<InsuranceDetailsInterface>(
   id ? pathToUrl(apiRoutes.getCarDetail, { id }) : null
 );

We have our abstraction hook for the prefetching:

export const usePrefetch = <T>(url: string | null, params?: object) => {
 const queryClient = useQueryClient();

 return () => {
   if (!url) {
     return;
   }

   queryClient.prefetchQuery<T, Error, T, QueryKeyT>(
     [url!, params],
     ({ queryKey }) => fetcher({ queryKey })
   );
 };
};

To render the car details we use CarDetails component, where we define a hook to retrieve data.

const CarDetails = ({ id }: Props) => {
 const { data, isLoading } = useGetCarDetail(id);

 if (isLoading) {
   return <CircularProgress />;
 }

 if (!data) {
   return <span>Nothing found</span>;
 }

 return (
   <Box>
     <Box mt={2}>
       <Typography>Model: {data.model}</Typography>
     </Box>

     <Box mt={2}>
       <Typography>Number: {data.number}</Typography>
     </Box>
   </Box>
 );
};

export const useGetCarDetail = (id: number | null) =>
 useFetch<CarDetailInterface>(
   pathToUrl(apiRoutes.getCarDetail, { id }),
   undefined,
   { staleTime: 2000 }
 );

Good point that we don’t have to pass additional props to this component, so in the Appointment component we prefetch the data and in the CarDetails component we use useGetCarDetail hook to retrieve the prefetched data.

By setting extended staleTime, we allow users to spend a bit more time before they click on the Show button. Without this setting, the request could be called twice if it takes too long between moving the cursor on the prefetching area and clicking the button.

Suspense

Suspense is an experimental React feature that makes it possible to wait for some code in a declarative way. In other words, we could call the Suspense component and define the fallback component, which we want to show while we are waiting for the data. We don't even need the isLoading flag from React Query. For more information please refer to the official documentation.

Let’s say we have a Service list, and we want to show the error, and Try again button if something went wrong.

To get the new developer experience let’s use Suspense, React Query and Error Boundaries together. For the last one, we will use react-error-boundary Library.

<QueryErrorResetBoundary>
 {({ reset }) => (
   <ErrorBoundary
     fallbackRender={({ error, resetErrorBoundary }) => (
       <Box width="100%" mt={2}>
         <Alert severity="error">
           <AlertTitle>
             <strong>Error!</strong>
           </AlertTitle>
           {error.message}
         </Alert>

         <Box mt={2}>
           <Button
             variant="contained"
             color="error"
             onClick={() => resetErrorBoundary()}
           >
             Try again
           </Button>
         </Box>
       </Box>
     )}
     onReset={reset}
   >
     <React.Suspense
       fallback={
         <Box width="100%">
           <Box mb={1}>
             <Skeleton variant="text" animation="wave" />
           </Box>
           <Box mb={1}>
             <Skeleton variant="text" animation="wave" />
           </Box>
           <Box mb={1}>
             <Skeleton variant="text" animation="wave" />
           </Box>
         </Box>
       }
     >
       <ServicesCheck checked={checked} onChange={onChange} />
     </React.Suspense>
   </ErrorBoundary>
 )}
</QueryErrorResetBoundary>

Within the Suspense component, we render our ServiceCheck component, where we call the API endpoint for the service list.

const { data } = useGetServices();

In the hook, we set suspense: true and retry: 0.

export const useGetServices = () =>
 useFetch<ServiceInterface[]>(apiRoutes.getServices, undefined, {
   suspense: true,
   retry: 0,
 });

On the mock server, we send a response of either 200 or 500 status codes randomly.

mock.onGet(apiRoutes.getServices).reply((config) => {
 if (!getUser(config)) {
   return [403];
 }

 const failed = !!Math.round(Math.random());

 if (failed) {
   return [500];
 }

 return [200, services];
});

So, if we receive some error from the API, and we don't handle it, we show the red notification with the message from the response. Clicking on the Try again button we call resetErrorBoundary() method, which tries to call the request again. In React Suspense fallback, we have our loading skeleton component, which renders when we are making the requests.

As we could see, this is a convenient and easy way to handle async data, but keep in mind that this is unstable, and probably shouldn’t be used in production right now.

Testing

Testing applications using React Query is almost the same as testing a regular application. We will use React Testing Library and Jest.

First, we create an abstraction for the rendering components.

export const renderComponent = (children: React.ReactElement, history: any) => {
 const queryClient = new QueryClient({
   defaultOptions: {
     queries: {
       retry: false,
     },
   },
 });
 const options = render(
   <Router history={history}>
     <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
   </Router>
 );

 return {
   ...options,
   debug: (
     el?: HTMLElement,
     maxLength = 300000,
     opt?: prettyFormat.OptionsReceived
   ) => options.debug(el, maxLength, opt),
 };
};

We set retry: false as a default setting in QueryClient and wrap a component with QueryClientProvider.

Now, let’s test our Appointment component.

We start with the easiest one: just checking that the component renders correctly.

test('should render the main page', async () => {
 const mocked = mockAxiosGetRequests({
   '/api/appointment/1': {
     id: 1,
     name: 'Hector Mckeown',
     appointment_date: '2021-08-25T17:52:48.132Z',
     services: [1, 2],
     address: 'London',
     vehicle: 'FR14ERF',
     comment: 'Car does not work correctly',
     history: [],
     hasInsurance: true,
   },
   '/api/job': [],
   '/api/getServices': [
     {
       id: 1,
       name: 'Replace a cambelt',
     },
     {
       id: 2,
       name: 'Replace oil and filter',
     },
     {
       id: 3,
       name: 'Replace front brake pads and discs',
     },
     {
       id: 4,
       name: 'Replace rare brake pads and discs',
     },
   ],
   '/api/getInsurance/1': {
     allCovered: true,
   },
 });
 const history = createMemoryHistory();
 const { getByText, queryByTestId } = renderComponent(
   <Appointment />,
   history
 );

 expect(queryByTestId('appointment-skeleton')).toBeInTheDocument();

 await waitFor(() => {
   expect(queryByTestId('appointment-skeleton')).not.toBeInTheDocument();
 });

 expect(getByText('Hector Mckeown')).toBeInTheDocument();
 expect(getByText('Replace a cambelt')).toBeInTheDocument();
 expect(getByText('Replace oil and filter')).toBeInTheDocument();
 expect(getByText('Replace front brake pads and discs')).toBeInTheDocument();
 expect(queryByTestId('DoneAllIcon')).toBeInTheDocument();
 expect(
   mocked.mock.calls.some((item) => item[0] === '/api/getInsurance/1')
 ).toBeTruthy();
});

We have prepared helpers to mock Axios requests. In the tests, we could specify URL and mock data.

const getMockedData = (
 originalUrl: string,
 mockData: { [url: string]: any },
 type: string
) => {
 const foundUrl = Object.keys(mockData).find((url) =>
   originalUrl.match(new RegExp(`${url}$`))
 );

 if (!foundUrl) {
   return Promise.reject(
     new Error(`Called unmocked api ${type} ${originalUrl}`)
   );
 }

 if (mockData[foundUrl] instanceof Error) {
   return Promise.reject(mockData[foundUrl]);
 }

 return Promise.resolve({ data: mockData[foundUrl] });
};

export const mockAxiosGetRequests = <T extends any>(mockData: {
 [url: string]: T;
}): MockedFunction<AxiosInstance> => {
 // @ts-ignore
 return axios.get.mockImplementation((originalUrl) =>
   getMockedData(originalUrl, mockData, 'GET')
 );
};

Then, we check there is a loading state and next, wait for the unmounting of the loading component.

expect(queryByTestId('appointment-skeleton')).toBeInTheDocument();

 await waitFor(() => {
   expect(queryByTestId('appointment-skeleton')).not.toBeInTheDocument();
 });

Next, we check that there are necessary texts in the rendered component, and finally check that the API request for the insurance details has been called.

expect(
   mocked.mock.calls.some((item) => item[0] === '/api/getInsurance/1')
 ).toBeTruthy();

It checks that loading flags, fetching data and calling endpoints work correctly.

In the next text, we check that we do not call request for the insurance details if we don’t need it (remember in the component we have a condition, that if in the response from appointment endpoint there is a flag hasInsurance: true we should call the insurance endpoint, otherwise we shouldn’t).

test('should not call and render Insurance flag', async () => {
 const mocked = mockAxiosGetRequests({
   '/api/appointment/1': {
     id: 1,
     name: 'Hector Mckeown',
     appointment_date: '2021-08-25T17:52:48.132Z',
     services: [1, 2],
     address: 'London',
     vehicle: 'FR14ERF',
     comment: 'Car does not work correctly',
     history: [],
     hasInsurance: false,
   },
   '/api/getServices': [],
   '/api/job': [],
 });
 const history = createMemoryHistory();
 const { queryByTestId } = renderComponent(<Appointment />, history);

 await waitFor(() => {
   expect(queryByTestId('appointment-skeleton')).not.toBeInTheDocument();
 });

 expect(queryByTestId('DoneAllIcon')).not.toBeInTheDocument();

 expect(
   mocked.mock.calls.some((item) => item[0] === '/api/getInsurance/1')
 ).toBeFalsy();
});

This test checks that if we have hasInsurance: false in the response, we will not call the insurance endpoint and render the icon.

Last, we are going to test mutations in our Jobs component. The whole test case:

test('should be able to add and remove elements', async () => {
 const mockedPost = mockAxiosPostRequests({
   '/api/job': {
     name: 'First item',
     appointmentId: 1,
   },
 });

 const mockedDelete = mockAxiosDeleteRequests({
   '/api/job/1': {},
 });

 const history = createMemoryHistory();
 const { queryByTestId, queryByText } = renderComponent(
   <Jobs appointmentId={1} />,
   history
 );

 await waitFor(() => {
   expect(queryByTestId('loading-skeleton')).not.toBeInTheDocument();
 });

 await changeTextFieldByTestId('input', 'First item');

 await clickByTestId('add');

 mockAxiosGetRequests({
   '/api/job': [
     {
       id: 1,
       name: 'First item',
       appointmentId: 1,
     },
   ],
 });

 await waitFor(() => {
   expect(queryByText('First item')).toBeInTheDocument();
 });

 expect(
   mockedPost.mock.calls.some((item) => item[0] === '/api/job')
 ).toBeTruthy();

 await clickByTestId('delete-1');

 mockAxiosGetRequests({
   '/api/job': [],
 });

 await waitFor(() => {
   expect(queryByText('First item')).not.toBeInTheDocument();
 });

 expect(
   mockedDelete.mock.calls.some((item) => item[0] === '/api/job/1')
 ).toBeTruthy();
});

Let’s see what is happening here.

  1. We mock requests for POST and DELETE.
  2. Input some text in the field and press the button.
  3. Mock GET endpoint again, because we assume that POST request has been made, and the real server should send us the updated data; in our case, it’s a list with 1 item.
  4. Wait for the updated text in the rendered component.
  5. Check that the POST request to api/job has been called.
  6. Click the Delete button.
  7. Mock GET endpoint again with an empty list (like in the previous case we assume the server sent us the updated data after deleting).
  8. Check that deleted item doesn’t exist in the document.
  9. Check that the DELETE request to api/job/1 has been called.

Important Note: We need to clear all mocks after each test to avoid mixing them up.

afterEach(() => {
 jest.clearAllMocks();
});
Conclusion

With the help of this real-life application, we went through all of the most common React Query features: how to fetch data, manage states, share between components, make it easier to implement optimistic changes and infinite lists, and learned how to make the app stable with tests.

I hope I could interest you in trying out this new approach in your current or upcoming projects.

Resources

Better Collaboration With Pull Requests

This article is part of our “Advanced Git” series. Be sure to follow us on Twitter or sign up for our newsletter to hear about the next articles!

In this third installment of our “Advanced Git” series, we’ll look at pull requests — a great feature which helps both small and larger teams of developers. Pull requests not only improve the review and the feedback process, but they also help tracking and discussing code changes. Last, but not least, pull requests are the ideal way to contribute to other repositories you don’t have write access to.

Advanced Git series:

  • Part 1: Creating the Perfect Commit in Git
  • Part 2: Branching Strategies in Git
  • Part 3: Better Collaboration With Pull Requests
    You are here!
  • Part 4: Merge Conflicts
    Coming soon!
  • Part 5: Rebase vs. Merge
  • Part 6: Interactive Rebase
  • Part 7: Cherry-Picking Commits in Git
  • Part 8: Using the Reflog to Restore Lost Commits

What are pull requests?

First of all, it’s important to understand that pull requests are not a core Git feature. Instead, they are provided by the Git hosting platform you’re using: GitHub, GitLab, Bitbucket, AzureDevops and others all have such a functionality built into their platforms.

Why should I create a pull request?

Before we get into the details of how to create the perfect pull request, let’s talk about why you would want to use this feature at all.

Imagine you’ve just finished a new feature for your software. Maybe you’ve been working in a feature branch, so your next step would be merging it into the mainline branch (master or main). This is totally fine in some cases, for example, if you’re the only developer on the project or if you’re experienced enough and know for certain your team members will be happy about it.

By the way: If you want to know more about branches and typical branching workflows, have a look at our second article in our “Advanced Git” series: “Branching Strategies in Git.”

Without a pull request, you would jump right to merging your code.

However, what if your changes are a bit more complex and you’d like someone else to look at your work? This is what pull requests were made for. With pull requests you can invite other people to review your work and give you feedback. 

A pull request invites reviewers to provide feedback before merging.

Once a pull request is open, you can discuss your code with other developers. Most Git hosting platforms allow other users to add comments and suggest changes during that process. After your reviewers have approved your work, it might be merged into another branch.

A pull request invites reviewers to provide feedback before merging.

Having a reviewing workflow is not the only reason for pull requests, though. They come in handy if you want to contribute to other repositories you don’t have write access to. Think of all the open source projects out there: if you have an idea for a new feature, or if you want to submit a patch, pull requests are a great way to present your ideas without having to join the project and become a main contributor.

This brings us to a topic that’s tightly connected to pull requests: forks.

Working with forks

A fork is your personal copy of an existing Git repository. Going back to our Open Source example: your first step is to create a fork of the original repository. After that, you can change code in your own, personal copy.

Creating a fork of the original respository is where you make changes.

After you’re done, you open a pull request to ask the owners of the original repository to include your changes. The owner or one of the other main contributors can review your code and then decide to include it (or not).

Two red database icons with gold arrows pointing at opposite directions between the database. The database on the left as a lock icon next to it that is circled in gold.

Important Note: Pull requests are always based on branches and not on individual commits! When you create a pull request, you base it on a certain branch and request that it gets included.

Making a reviewer’s life easier: How to create a great pull request

As mentioned earlier, pull requests are not a core Git feature. Instead, every Git platform has its own design and its own idea about how a pull request should work. They look different on GitLab, GitHub, Bitbucket, etc. Every platform has a slightly different workflow for tracking, discussing, and reviewing changes.

A layered collage of Git-based websites. Bitbucket is on top, followed by GitHub, then GitLab.

Desktop GUIs like the Tower Git client, for example, can make this easier: they provide a consistent user interface, no matter what code hosting service you’re using.

Animated screenshot of a pull request in the Tower application. A pull requests panel is open showing a pull request by the author that, when clicked, reveals information about that pull request on the right. The app has a dark interface.

Having said that, the general workflow is always the same and includes the following steps:

  1. If you don’t have write access to the repository in question, the first step is to create a fork, i.e. your personal version of the repository.
  2. Create a new local branch in your forked repository. (Reminder: pull requests are based on branches, not on commits!)
  3. Make some changes in your local branch and commit them.
  4. Push the changes to your own remote repository.
  5. Create a pull request with your changes and start the discussion with others.

Let’s look at the pull request itself and how to create one which makes another developer’s life easier. First of all, it should be short so it can be reviewed quickly. It’s harder to understand code when looking at 3,000 lines instead of 30 lines. 

Also, make sure to add a good and self-explanatory title and a meaningful description. Try to describe what you changed, why you opened the pull request, and how your changes affect the project. Most platforms allow you to add a screenshot which can help to demonstrate the changes.

Approve, merge, or decline?

Once your changes have been approved, you (or someone with write access) can merge the forked branch into the main branch. But what if the reviewer doesn’t want to merge the pull request in its current state? Well, you can always add new commits, and after pushing that branch, the existing pull request is updated.

Alternatively, the owner or someone else with write access can decline the pull request when they don’t want to merge the changes.

Safety net for developers

As you can see, pull requests are a great way to communicate and collaborate with your fellow developers. By asking others to review your work, you make sure that only high-quality code enters your codebase. 

If you want to dive deeper into advanced Git tools, feel free to check out my (free!) “Advanced Git Kit”: it’s a collection of short videos about topics like branching strategies, Interactive Rebase, Reflog, Submodules and much more.

Advanced Git series:

  • Part 1: Creating the Perfect Commit in Git
  • Part 2: Branching Strategies in Git
  • Part 3: Better Collaboration With Pull Requests
    You are here!
  • Part 4: Merge Conflicts
    Coming soon!
  • Part 5: Rebase vs. Merge
  • Part 6: Interactive Rebase
  • Part 7: Cherry-Picking Commits in Git
  • Part 8: Using the Reflog to Restore Lost Commits

The post Better Collaboration With Pull Requests appeared first on CSS-Tricks. You can support CSS-Tricks by being an MVP Supporter.