diff --git a/.env b/.env new file mode 100644 index 0000000..2907fa2 --- /dev/null +++ b/.env @@ -0,0 +1,2 @@ +NEXT_PUBLIC_GRAPHQL_ENDPOINT=http://localhost:8080/v1/graphql +NEXT_PUBLIC_GRAPHQL_SECRET=localadmin \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..ef14795 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,60 @@ +{ + "env": { + "browser": true, + "es2021": true + }, + "extends": [ + "plugin:react/recommended", + "airbnb", + "airbnb-typescript" + ], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaFeatures": { + "jsx": true + }, + "ecmaVersion": "latest", + "sourceType": "module", + "project": [ + "./tsconfig.json" + ] + }, + "plugins": [ + "react", + "@typescript-eslint" + ], + "rules": { + "import/extensions": [ + "error", + "ignorePackages", + { + "js": "never", + "jsx": "never", + "ts": "never", + "tsx": "never" + } + ], + "react/require-default-props": "off", + "no-console": "off", + "react/button-has-type": "off", + "import/prefer-default-export": "off", + "react/no-array-index-key": "off", + "jsx-a11y/anchor-is-valid": "off", + "react/jsx-props-no-spreading": "off", + "react/prop-types": "off", + // suppress errors for missing 'import React' in files + "react/react-in-jsx-scope": "off", + // allow jsx syntax in js files (for next.js project) + "react/jsx-filename-extension": [ + 1, + { + "extensions": [ + ".js", + ".jsx", + ".ts", + ".tsx" + ] + } + ] + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..737d872 --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..d1987c7 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,13 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations. + // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp + + // List of extensions which should be recommended for users of this workspace. + "recommendations": [ + + ], + // List of extensions recommended by VS Code that should not be recommended for users of this workspace. + "unwantedRecommendations": [ + + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..917da3e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "editor.formatOnSave": true, + "editor.defaultFormatter": "dbaeumer.vscode-eslint", + "reactSnippets.settings.prettierEnabled": true, + "[typescriptreact]": { + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, +} \ No newline at end of file diff --git a/README.md b/README.md index 0bbc7d5..1352675 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,35 @@ -## Front End Code Challenge +## How to setup -This is Limbic's React/React Native Coding Challenge which will allow us to get a glimpse into our candidates' overall developer skills. +This project uses postgres db for storage and [hasura](https://hasura.io/) as graphql engine. +There is a `docker-compose` file in `db` folder which will prepare backend. Please run the following in you r terminal -You are free to create a React web app, or a React Native mobile app, it's up to you. We'd rather you use something you are comfortable with so that time isn't wasted on project setup and we can get a real feel for your coding patterns and understanding of best practices with React and/or React Native, regardless of the end use case. + cd db + docker-compose up -d +2 new containers will be created for postgres and hasura. To verify every thing is working please visit http://localhost:8080/console -### Instructions +To create schema and seed tables run this command (make sure you are still in db folder) -1. **Submitting Code:** + hasura migrate apply + and then - Option A: - - Fork this repo - - Issue a Pull Request when you're ready to start. This will count as your starting date. - - Setup your development environment for React or React Native - - Implement your solution - - Commit your changes into the forked repo + hasura metadata apply - Option B: - - Setup your development environment for React or React Native - - Implement your solution - - Archive your solution into a zip file - - Send us the zip file. We should be able to extract the content and run it from there (w/o node_modules) +Now you have to be able to see `questions` and `answers` tables in hasura http://localhost:8080/console/data/default/schema/public -2. **Deadline:** +to run the project please navigate to root folder and - You have 1 week to complete as much tasks as you can from the challenge below. Countdown starts from the date you issued the PR or from the date you were invited to complete this challenge via email. + npm run dev -3. **Implementation:** +## Stack and tools +Some highlights about implementation - There is no correct way to do it, you are free to use whatever libraries you like. We want to see what you come up with on your own. + 1. This is a Next.js project. Pages are rendered client side. + 2. Tailwind css for styling. There are many great tools out there for styling a react project but I found tailwind quick and handy for this project. I'm experienced working with [styled components](https://styled-components.com/) as well + 3. TypeScript for type safety + 4. ESLint to maintain code style + 5. [Apollo client](https://www.apollographql.com/docs/react) for graphql client + 6. [react hook form](https://react-hook-form.com/) to simplify working with forms + 7. No usage of 3d party UI libraries to show case some simple ui component implementation -### The Challenge - -Jane is a clinical therapist and wants her clients to answer simple questionnaires in order to better understand them. She needs a way to add/delete/edit questions and also see the answers of each client. - -### Requirements - -Your app should be able to complete the following tasks: - -- See a list of questions -- Add a new question -- Edit a question -- Delete a question -- See a list of clients -- See a client's answers - -### Bonus Points - -The following tasks will **NOT** have a negative impact in how well you did, but you will get bonus points for completing any of them. - -- Ability to persist data locally -- Ability to select the type of answer a question will have. Types like free text, single choice from a predefined list, multiple choice from a predefined list, etc. -- Ability for the app to answer the questions. Basically using a fake login it could determine if the user is a client and display the questionnaire they need to answer. (fake login can be two buttons chosing the type of user - -Good Luck! +Here is a quick screen capture of project +https://drive.google.com/file/d/18cXAP4NBL7Q1BRlU8rZY2mNl9HUMLYIk/view?usp=sharing \ No newline at end of file diff --git a/apollo-client.ts b/apollo-client.ts new file mode 100644 index 0000000..4748701 --- /dev/null +++ b/apollo-client.ts @@ -0,0 +1,21 @@ +import { ApolloClient, InMemoryCache } from '@apollo/client'; + +const client = new ApolloClient({ + uri: process.env.NEXT_PUBLIC_GRAPHQL_ENDPOINT, + headers: { + 'x-hasura-admin-secret': process.env.NEXT_PUBLIC_GRAPHQL_SECRET, + }, + cache: new InMemoryCache({ + addTypename: false, + }), + defaultOptions: { + watchQuery: { + fetchPolicy: 'no-cache', + }, + query: { + fetchPolicy: 'no-cache', + }, + }, +}); + +export default client; diff --git a/components/answers/form/form.tsx b/components/answers/form/form.tsx new file mode 100644 index 0000000..74a33be --- /dev/null +++ b/components/answers/form/form.tsx @@ -0,0 +1,85 @@ +import { useMutation } from '@apollo/client'; +import { useForm } from 'react-hook-form'; +import { useState } from 'react'; +import FormLabel from '../../ui/FormLabel'; +import TextInput from '../../ui/textInput'; +import Button from '../../ui/button'; +import { INSERT_ANSWER } from './query'; +import { prepareDefaultValues } from './helpers'; +import { Answer } from '../../../types/answer'; +import { Question } from '../../../types/question'; + +interface AnswerFormProps { + answers?: Answer[]; + questions?: Question[]; +} + +export default function AnswerForm({ answers, questions }: AnswerFormProps) { + const [saveSuccess, setSaveSuccess] = useState(false); + const defaultValues = prepareDefaultValues(answers); + + const { + register, handleSubmit, formState: { errors }, + } = useForm({ defaultValues }); + + const [insertAnswer] = useMutation(INSERT_ANSWER); + + const onSubmit = async (data) => { + console.log('data', data); + + const preparedAnswers = []; + Object.keys(data).forEach((key) => { + const questionId = key.split('_')[1]; + preparedAnswers.push({ question_id: questionId, answer: data[key] }); + }); + + await insertAnswer({ variables: { answers: preparedAnswers } }); + setSaveSuccess(true); + }; + + return ( +
+ {questions.map((question) => ( +
+ + + {question.type === 'number' && ( + + )} + + {question.type === 'text' && ( + + )} + + {question.type === 'multiLine' && ( + + )} +

+ {errors[`q_${question.id}`] && {errors[`q_${question.id}`].message}} +

+
+ + ))} +
+
+
+ ); +} diff --git a/components/answers/form/helpers.ts b/components/answers/form/helpers.ts new file mode 100644 index 0000000..0493660 --- /dev/null +++ b/components/answers/form/helpers.ts @@ -0,0 +1,7 @@ +export const prepareDefaultValues = (answers) => { + const defaultValues = {}; + answers.forEach((answer) => { + defaultValues[`q_${answer.question_id}`] = answer.answer; + }); + return defaultValues; +}; diff --git a/components/answers/form/query.ts b/components/answers/form/query.ts new file mode 100644 index 0000000..fb7cb88 --- /dev/null +++ b/components/answers/form/query.ts @@ -0,0 +1,24 @@ +import { gql } from '@apollo/client'; + +export const GET_ANSWERS = gql` + query GetAnswers { + questions { + id + type + text + } + answers { + id + question_id + answer + } + } +`; + +export const INSERT_ANSWER = gql` +mutation InsertAnswer($answers: [answers_insert_input!]!) { + insert_answers(objects: $answers, on_conflict: {constraint: answers_question_id_key, update_columns: [answer]}) { + affected_rows + } +} +`; diff --git a/components/layout/footer.tsx b/components/layout/footer.tsx new file mode 100644 index 0000000..44059f0 --- /dev/null +++ b/components/layout/footer.tsx @@ -0,0 +1,7 @@ +export default function Footer() { + return ( +
+

Limbic code challenge

+
+ ); +} diff --git a/components/layout/layout.tsx b/components/layout/layout.tsx new file mode 100644 index 0000000..0bf6559 --- /dev/null +++ b/components/layout/layout.tsx @@ -0,0 +1,28 @@ +import { ReactNode } from 'react'; + +import Head from 'next/head'; + +import Navbar from './navbar'; +import Footer from './footer'; + +export default function Layout({ children } : ReactNode) { + return ( + <> +
+ + Limbic code challenge + + + + + + +
{children}
+ +
+
+ + + ); +} diff --git a/components/layout/navbar.tsx b/components/layout/navbar.tsx new file mode 100644 index 0000000..a989daf --- /dev/null +++ b/components/layout/navbar.tsx @@ -0,0 +1,25 @@ +import Link from 'next/link'; +import Image from 'next/image'; + +export default function Navbar() { + return ( +
+
+ + limbic logo + +
+ + + Questions + + + + Answers + +
+ ); +} diff --git a/components/questions/form/form.tsx b/components/questions/form/form.tsx new file mode 100644 index 0000000..8afe5c9 --- /dev/null +++ b/components/questions/form/form.tsx @@ -0,0 +1,72 @@ +import { useMutation } from '@apollo/client'; +import { useRouter } from 'next/router'; +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { questionTypes } from '../../../constants/questionTypes'; +import { Question } from '../../../types/question'; +import Button from '../../ui/button'; +import FormLabel from '../../ui/FormLabel'; +import SelectInput from '../../ui/SelectInput'; +import TextInput from '../../ui/textInput'; +import { INSERT_QUESTION, UPDATE_QUESTION } from './query'; + +interface QuestionsFormProps { + question?: Question; +} + +export default function QuestionsForm({ question }: QuestionsFormProps) { + const router = useRouter(); + const [saveSuccess, setSaveSuccess] = useState(false); + + const [updateQuestion] = useMutation(UPDATE_QUESTION); + const [insertQuestion] = useMutation(INSERT_QUESTION); + + const { + register, handleSubmit, formState: { errors }, + } = useForm({ defaultValues: question }); + + const onSubmit = async (data) => { + if (question) { + await updateQuestion({ variables: { id: question.id, question: data } }); + } else { + await insertQuestion({ variables: { question: data } }); + } + setSaveSuccess(true); + router.push('/questions'); + }; + + return ( +
+ + + + + + +

+ {errors.text && {errors.text.message}} +

+ +
+
+ {saveSuccess &&

Saved Successfully

} + + ); +} diff --git a/components/questions/form/query.ts b/components/questions/form/query.ts new file mode 100644 index 0000000..e556bf2 --- /dev/null +++ b/components/questions/form/query.ts @@ -0,0 +1,37 @@ +import { gql } from '@apollo/client'; + +export const GET_QUESTION = gql` +query GetQuestion($id: Int!) { + question: questions_by_pk(id:$id) { + id + type + text + } +} +`; + +export const UPDATE_QUESTION = gql` +mutation UpdateQuestion($id:Int!,$question: questions_set_input) { + update_questions_by_pk(_set: $question, pk_columns: {id:$id}){ + id + } +} +`; + +export const DELETE_QUESTION = gql` +mutation DeleteQuestion($id:Int!) { + delete_questions_by_pk(id:$id) { + id + } +} +`; + +export const INSERT_QUESTION = gql` +mutation InsertQuestion($question: questions_insert_input!) { + insert_questions(objects: [$question]) { + returning { + id + } + } +} +`; diff --git a/components/questions/list/list.tsx b/components/questions/list/list.tsx new file mode 100644 index 0000000..a5fd338 --- /dev/null +++ b/components/questions/list/list.tsx @@ -0,0 +1,53 @@ +import { useMutation } from '@apollo/client'; +import { useRouter } from 'next/router'; +import Table from '../../ui/table'; +import { DELETE_QUESTION } from '../form/query'; +import Button from '../../ui/button'; +import { Question } from '../../../types/question'; + +interface QuestionListProps { + questions: Question[]; + refetch: () => void; +} + +export default function QuestionsList({ questions, refetch }: QuestionListProps) { + const router = useRouter(); + + const [deleteQuestion] = useMutation(DELETE_QUESTION); + + const handleEdit = (row) => { + router.push(`/questions/${row.id}`); + }; + + const handleRemove = (row) => { + deleteQuestion({ variables: { id: row.id } }).then(() => { + refetch(); + }); + }; + + const prepareData = (rawQuestions) => rawQuestions.map((q, index) => ({ + number: index + 1, ...q, + })); + + const handleAdd = () => { + router.push('/questions/new'); + }; + + const preparedData = prepareData(questions); + const headers = ['#', 'Type', 'Text']; + const visibleKeys = ['number', 'type', 'text']; + + return ( + <> +
+ ); +} diff --git a/components/ui/textInput.tsx b/components/ui/textInput.tsx new file mode 100644 index 0000000..88fc66b --- /dev/null +++ b/components/ui/textInput.tsx @@ -0,0 +1,49 @@ +interface TextProps { + name: string; + placeholder: string; + register: (name: string, validations?: any) => any; + required?: boolean; + minLength?: number; + multiLine?: boolean; + isNumber?: boolean; +} +export default function TextInput({ + name, + placeHolder, + register, + required = false, + multiLine = false, + minLength = 0, + isNumber = false, +}: TextProps) { + const validations = {}; + if (required) { + validations.required = 'Required'; + } + if (minLength > 0) { + validations.minLength = { value: minLength, message: `At lease ${minLength} characters` }; + } + if (isNumber) { + validations.pattern = { value: /^\d+$/, message: 'Must be a number' }; + } + + return multiLine ? ( + +