5 İşlemeler a02b4ad9e0 ... a098677dca

Yazar SHA1 Mesaj Tarih
  Tatiana Inama a098677dca Add README and env sample file 4 yıl önce
  Tatiana Inama 73d9cc203c UI touch-ups 4 yıl önce
  Tatiana Inama 16c20e9ab0 Avoid updating user when nothing changed 4 yıl önce
  Tatiana Inama 2a559d2a89 Introduce FormResponse component 4 yıl önce
  Tatiana Inama 3231780bd6 Better handle of API errors 4 yıl önce

+ 6 - 0
.env.example

@@ -0,0 +1,6 @@
+# More about this file in README
+# Used by custom proxy
+PRISMA_API=https://prisma-fe-dev-assignent.vercel.app/api
+
+# Add if not working with API locally
+# NEXT_PUBLIC_API=https://prisma-fe-dev-assignent.vercel.app/api

+ 38 - 19
README.md

@@ -1,34 +1,53 @@
-This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
+# Prisma Home Challenge
+
+This is my take on Prisma's Home Challenge.
+
+This is a [Next.js](https://nextjs.org/) project made in [Typescript](https://www.typescriptlang.org/) and bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
+
+## Architecture
+
+For an application like this, my candidates were
+
+- Create React App: Easy to set up, but really heavyweight in terms of dependencies.
+- Vite: Really fast CRA alternative, but also very minimalistic. I would had to spend time working on routing and other configurations.
+- Gatsby & Next.js: SSG and routing comes out of the box, very handy for an app like this.
+
+So based on this, my two top candidates were Gatsby & Next.js, I decided to go with Next.js because of personal preference :)
+
+as a side note the api doesn have cors configured correctly, so i couldnt query directly from fe, and for the challenge i set up a proxy to bypass this
+Aside from my reasons of choosing Next.js, I ended up creating a proxy for the API, since every request from a client application to `https://prisma-fe-dev-assignent.vercel.app/api/` was blocked by CORS policy (this was a workaround to get the API working).
+
+I added the option to choose between the original API or the proxy: adding the env variable `NEXT_PUBLIC_API=https://prisma-fe-dev-assignent.vercel.app/api` would make the client application to use the original backend instead of using proxy.
+
+I also used [TailwindCSS](https://tailwindcss.com) to speed up the development process.
 
 ## Getting Started
 
-First, run the development server:
+First, create a `.env.local` file with the contents of `.env.example`
+
+Then run the development server:
 
 ```bash
-npm run dev
-# or
 yarn dev
 ```
 
 Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
 
-You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file.
-
-[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`.
-
-The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
-
-## Learn More
-
-To learn more about Next.js, take a look at the following resources:
+## Feedback on the API
 
-- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
-- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
+There are a couple of things that I would do differently if I could change the API:
 
-You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
+1. Change CORS policy to allow fetch by any origin (only because this is a public API)
+2. Improve responses:
+   a. Server should return client error responses (40X) if the data was invalid, not 500 (Server error response).
+   b. It would be nice if `/login` endpoint returns user data instead of just a message
+   c. Double check message content (there was a tiny typo in the failed response for `/login`)
+   d. I would change the result of `/user/{id}` to return a user with an `id: int` instead of `id: string` to keep consistency with the other endpoints.
 
-## Deploy on Vercel
+## Improvements
 
-The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
+I would improve the UX of the application:
 
-Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
+- Better handling of form states (error message doesn't dissapear until new submission )
+- Do not resend data if the form inputs didn't change
+- Customize inputs to use personalised messages and validations.

+ 24 - 0
components/FormResponse.tsx

@@ -0,0 +1,24 @@
+import { FC } from "react";
+import { FormMessage, Success } from "../hooks/useForm";
+
+const FormResponse: FC<{
+  error?: FormMessage;
+  success?: Success;
+  fallbackSuccessMessage?: string;
+  loading: boolean;
+}> = ({ error, success, fallbackSuccessMessage, loading }) => {
+  if (loading) {
+    return null;
+  }
+  return error && !success ? (
+    <div role="alert" className="form-alert-msg">
+      {error.message}
+    </div>
+  ) : success ? (
+    <div role="alert" className="form-success-msg">
+      {success.message || fallbackSuccessMessage}
+    </div>
+  ) : null;
+};
+
+export default FormResponse;

+ 10 - 7
components/Nav.tsx

@@ -16,19 +16,22 @@ const Nav: FC = () => {
             </Link>
           </li>
         </ul>
-        <ul className="flex">
+        <ul className="flex min-w-0">
           {context.user ? (
-            <li>
-              <span className="py-7 px-4 block">
+            pathname === "/" ? null : (
+              <li
+                className="py-7 px-4 overflow-hidden whitespace-nowrap text-ellipsis"
+                title={`Hello ${context.user.name}`}
+              >
                 Hello <b>{context.user.name}</b>
-              </span>
-            </li>
+              </li>
+            )
           ) : (
             <>
               <li>
                 <Link href="/signup">
                   <a
-                    className={`py-7 px-4 block ${
+                    className={`py-7 px-2 sm:px-4 block ${
                       pathname === "/signup" ? "font-bold" : ""
                     }`}
                   >
@@ -39,7 +42,7 @@ const Nav: FC = () => {
               <li className="">
                 <Link href="/login">
                   <a
-                    className={`py-7 px-4 block ${
+                    className={`py-7 px-4 pl-2 sm:px-4 block ${
                       pathname === "/login" ? "font-bold" : ""
                     }`}
                   >

+ 1 - 3
components/PrivatePage.tsx

@@ -19,9 +19,7 @@ const PrivatePage: FC<{ protectedRoutes: string[] }> = ({
   }, [isAuthenticated, pathIsProtected, push]);
 
   if (!isAuthenticated && pathIsProtected) {
-    return (
-      <Spinner />
-    );
+    return <Spinner />;
   }
   return <>{children}</>;
 };

+ 15 - 8
context/UserContext.tsx

@@ -1,6 +1,6 @@
 import React, { useState, FC, useContext } from "react";
 
-interface UserContextInterface {
+export interface UserContextInterface {
   user?: User;
   isAuthenticated: boolean;
   login: (user: User) => void;
@@ -8,6 +8,11 @@ interface UserContextInterface {
   updateUser: (user: User) => void;
 }
 
+export interface UserContextAuthenticatedInterface
+  extends UserContextInterface {
+  user: User;
+}
+
 export const UserContext = React.createContext<UserContextInterface>({
   isAuthenticated: false,
   login: () => {},
@@ -15,6 +20,11 @@ export const UserContext = React.createContext<UserContextInterface>({
   updateUser: () => {},
 });
 
+const updateIfChanged = (user: User, newUser: Omit<User, "id">) =>
+  (Object.keys(newUser) as Array<keyof Omit<User, "id">>).some(
+    (key) => user[key] !== newUser[key]
+  );
+
 const UserContextProvider: FC = ({ children }) => {
   const [user, setUser] = useState<User>();
   const [isAuthenticated, setIsAuthenticated] = useState(false);
@@ -29,14 +39,11 @@ const UserContextProvider: FC = ({ children }) => {
     setUser(undefined);
   };
 
-  const updateUser = (newUserData: User) => {
-    if (user) {
+  const updateUser = ({ id, ...newData }: User) => {
+    if (user && updateIfChanged(user, newData)) {
       setUser({
-        ...newUserData,
-        id:
-          typeof newUserData.id === "string"
-            ? parseInt(newUserData.id)
-            : newUserData.id,
+        ...newData,
+        id: typeof id === "string" ? parseInt(id) : id,
       });
     }
   };

+ 11 - 3
hooks/useForm.ts

@@ -1,11 +1,11 @@
 import { ApiResulState, FetchApiInterface } from "./../utils/api";
 import { FormEventHandler, useState } from "react";
 
-type FormMessage = {
+export type FormMessage = {
   message: string;
 };
 
-type Success = FormMessage & {
+export type Success = FormMessage & {
   result: User;
 };
 
@@ -18,7 +18,10 @@ type UseForm = {
   success?: Success;
 };
 
-const useForm = <T>(fetchApi: FetchApiInterface<T>): UseForm => {
+const useForm = <T>(
+  fetchApi: FetchApiInterface<T>,
+  resetAfterSubmit?: boolean
+): UseForm => {
   const [error, setError] = useState<FormMessage>();
   const [loading, setLoading] = useState(false);
   const [success, setSuccess] = useState<Success>();
@@ -33,10 +36,15 @@ const useForm = <T>(fetchApi: FetchApiInterface<T>): UseForm => {
     switch (result.state) {
       case ApiResulState.SUCCESS: {
         setSuccess({ message: result.message, result: result.user });
+        setError(undefined);
+        if (resetAfterSubmit) {
+          (event.target as HTMLFormElement).reset();
+        }
         break;
       }
       case ApiResulState.ERROR: {
         setError({ message: result.error });
+        setSuccess(undefined);
         break;
       }
     }

+ 44 - 23
pages/index.tsx

@@ -1,7 +1,45 @@
 import type { NextPage } from "next";
 import Link from "next/link";
+import { FC } from "react";
 import Layout from "../components/Layout";
-import { useAuth } from "../context/UserContext";
+import {
+  useAuth,
+  UserContextAuthenticatedInterface,
+} from "../context/UserContext";
+
+const AuthenticatedView: FC<
+  Pick<UserContextAuthenticatedInterface, "user" | "logout">
+> = ({ user, logout }) => (
+  <div>
+    <h1 className="text-2xl font-bold mb-4 text-center">Welcome back!</h1>
+    <div className="rounded w-full mx-auto border p-4 flex flex-col gap-4 xs:max-w-md xs:gap-6 xs:py-6">
+      <header className="flex flex-col items-center text-center gap-2 xs:flex-row xs:gap-4 xs:text-left">
+        <div className="h-12 w-12 bg-gray-300 rounded-full"></div>
+        <div>
+          <h2 className="font-bold">{user.name}</h2>
+          <p className="text-sm">{user.email}</p>
+          <p>
+            <span className="bg-green-200 font-semibold font-mono rounded text-xs inline-block px-1 leading-5">
+              {user.team}
+            </span>
+          </p>
+        </div>
+      </header>
+      <div className="flex flex-col gap-2 xs:flex-row">
+        <Link href="/settings">
+          <a className="default-button">Edit profile</a>
+        </Link>
+        <button
+          type="button"
+          onClick={() => logout()}
+          className="outline-button"
+        >
+          Logout
+        </button>
+      </div>
+    </div>
+  </div>
+);
 
 const Home: NextPage = () => {
   const { user, logout } = useAuth();
@@ -9,29 +47,12 @@ const Home: NextPage = () => {
     <Layout>
       <div className="">
         {user ? (
-          <>
-            <h1>
-              Welcome back, <em>{user.name}</em>!
-            </h1>
-            <p>
-              You can change your settings in{" "}
-              <Link href="/settings">
-                <a>here</a>
-              </Link>
-            </p>
-            <button
-              type="button"
-              onClick={() => logout()}
-              className="form-button"
-            >
-              Logout
-            </button>
-          </>
+          <AuthenticatedView user={user} logout={logout} />
         ) : (
-          <>
-            <h1>Welcome!</h1>
-            <p>Log in to get started</p>
-          </>
+          <div>
+            <h1 className="text-2xl font-bold mb-4 text-center">Welcome!</h1>
+            <p className="text-center">Please, sign up or log in to continue</p>
+          </div>
         )}
       </div>
     </Layout>

+ 6 - 2
pages/login.tsx

@@ -22,7 +22,7 @@ const Login: NextPage = () => {
 
   return (
     <Layout>
-      <form className="form" onSubmit={submitHandler}>
+      <form className="form" onSubmit={submitHandler} autoComplete="true">
         <div className="form-group">
           <label htmlFor="email" className="form-label">
             Email
@@ -50,7 +50,11 @@ const Login: NextPage = () => {
             required
           />
         </div>
-        <button type="submit" className="form-button" disabled={loading}>
+        <button
+          type="submit"
+          className="default-button col-start-2 w-max"
+          disabled={loading}
+        >
           {loading ? <Spinner /> : "Log in"}
         </button>
         {error && !success && (

+ 13 - 12
pages/settings.tsx

@@ -1,5 +1,6 @@
 import { NextPage } from "next/types";
 import { useEffect } from "react";
+import FormResponse from "../components/FormResponse";
 import Layout from "../components/Layout";
 import Spinner from "../components/Spinner";
 import { useAuth } from "../context/UserContext";
@@ -65,7 +66,7 @@ const Settings: NextPage = () => {
             Team
           </label>
           <select
-            className="form-input"
+            className="form-input appearance-none bg-dropdown-arrow bg-no-repeat bg-[right_8px_center]"
             id="team"
             name="team"
             defaultValue={user.team}
@@ -77,19 +78,19 @@ const Settings: NextPage = () => {
           </select>
         </div>
         <input type="hidden" id="id" name="id" value={user.id} />
-        <button type="submit" className="form-button" disabled={loading}>
+        <button
+          type="submit"
+          className="default-button col-start-2 w-max"
+          disabled={loading}
+        >
           {loading ? <Spinner /> : "Save"}
         </button>
-        {error && !success && (
-          <div role="alert" className="form-alert-msg">
-            {error.message}
-          </div>
-        )}
-        {success && (
-          <div role="alert" className="form-success-msg">
-            Profile updated correctly
-          </div>
-        )}
+        <FormResponse
+          error={error}
+          success={success}
+          loading={loading}
+          fallbackSuccessMessage="Profile updated correctly"
+        />
       </form>
     </Layout>
   ) : null;

+ 16 - 12
pages/signup.tsx

@@ -1,11 +1,15 @@
 import { NextPage } from "next";
+import FormResponse from "../components/FormResponse";
 import Layout from "../components/Layout";
 import Spinner from "../components/Spinner";
 import useForm from "../hooks/useForm";
 import api from "../utils/api";
 
 const SignUp: NextPage = () => {
-  const { submitHandler, error, success, loading } = useForm(api.register);
+  const { submitHandler, error, success, loading } = useForm(
+    api.register,
+    true
+  );
 
   return (
     <Layout>
@@ -37,19 +41,19 @@ const SignUp: NextPage = () => {
             required
           />
         </div>
-        <button type="submit" className="form-button" disabled={loading}>
+        <button
+          type="submit"
+          className="default-button col-start-2 w-max"
+          disabled={loading}
+        >
           {loading ? <Spinner /> : "Sign up"}
         </button>
-        {error && !success && (
-          <div role="alert" className="form-alert-msg">
-            {error.message}
-          </div>
-        )}
-        {success && (
-          <div role="alert" className="form-success-msg">
-            User created correctly! Please, log in to continue
-          </div>
-        )}
+        <FormResponse
+          error={error}
+          success={success}
+          loading={loading}
+          fallbackSuccessMessage="User created correctly! Please, log in to continue"
+        />
       </form>
     </Layout>
   );

BIN
public/triangle.png


+ 9 - 5
styles/globals.css

@@ -22,23 +22,27 @@
 
 @layer components {
   .form {
-    @apply w-full max-w-screen-xs mx-auto flex flex-col xs:grid xs:grid-cols-form xs:items-center gap-4;
+    @apply w-full max-w-screen-xs mx-auto flex flex-col sm:grid sm:grid-cols-form sm:items-center gap-4;
   }
 
   .form-group {
-    @apply xs:contents;
+    @apply sm:contents;
   }
 
   .form-label {
-    @apply font-bold flex-1 xs:text-right;
+    @apply font-bold flex-1 sm:text-right;
   }
 
   .form-input {
     @apply bg-prisma-50 w-full rounded py-2 px-3 text-sm;
   }
 
-  .form-button {
-    @apply col-start-2 w-max py-2 px-4 bg-prisma-900 text-white font-bold rounded text-sm hover:bg-prisma-800 active:bg-prisma-700;
+  .default-button {
+    @apply py-2 px-4 bg-prisma-900 text-white font-bold rounded text-sm text-center hover:bg-prisma-800 active:bg-prisma-700 border border-prisma-800;
+  }
+
+  .outline-button {
+    @apply py-2 px-4 bg-white text-prisma-900 font-bold rounded text-sm text-center hover:bg-gray-100 active:bg-gray-200 border border-gray-100;
   }
 
   .form-alert-msg {

+ 4 - 0
tailwind.config.js

@@ -14,6 +14,7 @@ module.exports = {
       black: colors.black,
       red: colors.red,
       green: colors.green,
+      gray: colors.gray,
       prisma: {
         50: "#ebebeb",
         100: "#d2d2d2",
@@ -37,6 +38,9 @@ module.exports = {
       xs: "475px",
       ...defaultTheme.screens,
     },
+    backgroundImage: {
+      "dropdown-arrow": "url('/triangle.png')",
+    },
     extend: {},
   },
   plugins: [],

+ 19 - 11
utils/api.ts

@@ -29,19 +29,27 @@ type FetchApi = <T>(
 ) => Promise<FetchApiResult>;
 
 const fetchApi: FetchApi = async (path, method, body) => {
-  const response = await fetch(`${API}${path}`, {
-    method: method,
-    body: JSON.stringify(body),
-  });
-
-  const { message, ...result } = await response.json();
-
-  if (response.ok) {
-    return { state: ApiResulState.SUCCESS, message, user: result };
-  } else {
+  try {
+    const response = await fetch(`${API}${path}`, {
+      method: method,
+      body: JSON.stringify(body),
+      headers: { "Content-Type": "application/json" },
+    });
+
+    const { message, ...result } = await response.json();
+
+    if (response.ok) {
+      return { state: ApiResulState.SUCCESS, message, user: result };
+    } else {
+      return {
+        state: ApiResulState.ERROR,
+        error: message.replace("Incorect", "Incorrect"),
+      };
+    }
+  } catch (error) {
     return {
       state: ApiResulState.ERROR,
-      error: message.replace("Incorect", "Incorrect"),
+      error: `There was an error, please try again`,
     };
   }
 };

+ 0 - 32
utils/apiErrorHandler.ts

@@ -1,32 +0,0 @@
-import axios from "axios";
-
-enum HttpError {
-  Unauthorized = 401,
-  ServerError = 500,
-}
-
-type ErrorHandlerResponse = {
-  status: HttpError;
-  message: string;
-};
-
-export const errorHandler = (error: unknown): ErrorHandlerResponse => {
-  if (axios.isAxiosError(error) && error.response) {
-    const { status, data } = error.response;
-    console.log(status, data);
-    if (
-      status === HttpError.ServerError &&
-      data?.message.includes("Incorect")
-    ) {
-      return {
-        status: HttpError.Unauthorized,
-        message: "The email or password was incorrect",
-      };
-    }
-    return { status, message: data };
-  }
-  return {
-    status: HttpError.ServerError,
-    message: "There was an error, please try again",
-  };
-};