208 | )
209 | }
210 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## Tuteria Fullstack Engineering Assessment
2 |
3 | First of all, let me thank you for taking this time to learn about us, we are honored that you’re considering joining our team.
4 |
5 | I’m Biola, Tuteria's CTO & Co-founder. Before we get into the assessment, I’ll like to give a bit more context:
6 |
7 | 1. This test was previously administered after an initial round of interviews, but we're making it public so that anyone can make an attempt in hopes of working with us.
8 | 2. We don't care about experience, but about **your ability to solve problems**. We'll be in touch with everyone who attempts this test and attempt to hire those who do well. If you're seeing this, it means we're still hiring ;)
9 | 3. Presently, we'll pay in the range of **N150,000 - N350,000 monthly,** depending on your performance on this test. This is in addition to other benefits like Learning Stipends, Housing Grant, Medical Insurance, Stock Options to exceptional teammates, etc. If you live outside Nigeria, we can only pay the dollar equivalent of the above amount using official rates.
10 | 4. We use TypeScript, Nodejs ([Expressjs](https://expressjs.com/), or [Featherjs](https://feathersjs.com/)) and Python ([Starlette](https://www.starlette.io/)) on the backend and Reactjs (Nextjs) on the frontend. We prefer Fullstack Javascript Engineers.
11 | 5. We are open to a fully remote engagement, as well as a partially remote one. If you would like to work from the office at any time, we're in Gbagada Phase 2, Lagos, Nigeria.
12 |
13 | If you'd like to work with us, please give this a shot. You don't have to do everything, we just want to see how you solve problems and where we may need to support you, should you work with us. After this test would be the rest of the interviews via video chat.
14 |
15 | All the very best!
16 |
17 | ### Taking and submitting the assessment
18 |
19 | 1. Clone the repository and provide your implementation to the tasks there.
20 | 2. When done, submit a pull request of your implementation to the base repository.
21 |
22 | **_Bonus Points:_** If you make use of the [changesets](https://github.com/atlassian/changesets) when creating the PR and also provide a live link of the hosted application.
23 |
24 | 3. These tasks require using [TypeScript](https://www.typescriptlang.org/).
25 | 4. To ensure consistent commit messages, visit [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) for commit conventions.
26 | 5. We recommend you complete this assessment **within 4 days** from when you fork the project
27 | 6. If you have any questions, please reach out to me on Telegram [@Beee_sama](https://t.me/Beee_sama) or send an email to [biola@tuteria.com](mailto:biola@tuteria.com)
28 |
29 | ### The assessment
30 |
31 | This is the repository to be used in solving the assessment. It is a mono-repository for a Note-taking app managed by [Lerna](https://github.com/lerna/lerna) in combination with [Yarn Workspace](https://github.com/Tuteria/Frontend-Assessment/blob/master).
32 |
33 | Frontend packages are located in [/packages](https://github.com/Tuteria/Frontend-Assessment/blob/master), while the backend packages are in [/services](https://github.com/Tuteria/Frontend-Assessment/blob/master). There's a sample backend service called `example` located in the `services` directory.
34 |
35 | To get the project up and running, you need to have [Node](https://nodejs.org/en/), [Lerna](https://github.com/lerna/lerna), and [npm](https://www.npmjs.com/get-npm) or [yarn](https://classic.yarnpkg.com/en/docs/install) installed globally. [Python3](https://www.python.org/downloads/) and [poetry](https://python-poetry.org/) which is the package manager used by [python](https://github.com/Tuteria/Frontend-Assessment/blob/master) also need to be installed globally.
36 |
37 | Once you've cloned the repo, simply run the below command to install all the dependencies
38 |
39 | `> yarn bootstrap` or `npm run bootstrap` (yarn preferably)
40 |
41 | ### First task
42 |
43 | The first task is to ensure that all tests pass when you run `yarn test` from the root package. In order for this to happen, the following steps need to occur:
44 |
45 | 1. Navigate to the `data-layer` directory and run `poetry install` *(You should have installed poetry and python)*
46 | 2. Run `poetry run alembic revision --autogenerate -m "Added notes table"` to create the database migration for the the table specified in `data-layer/data_layer/models/notes.py` (The ORM used is [SQLAlchemy](https://docs.sqlalchemy.org/en/13/orm/tutorial.html))
47 | 3. Run `poetry run alembic upgrade head` to apply the migration to the database.
48 | 4. Navigate back to the root of the project and run `yarn service:example db:update` to create [Prisma](https://www.prisma.io/docs/) model schema
49 | 5. Run `yarn service:example db:generate` to generate [Prisma](https://www.prisma.io/docs/) definitions that would be used in the project.
50 | 6. Finally run `yarn service:example test`. All the tests located in `services/example/tests` should pass.
51 |
52 | **You get bonus points if:**
53 |
54 | Instead of using `sqlite` as the database of choice, make use of [PostgreSQL](https://www.postgresql.org/). There is a [docker-compose.yml](https://github.com/Tuteria/Frontend-Assessment/blob/master) file provided to ease the creation of the database. Also, ensure that the connection string for PostgresSQL is read as an environmental variable in
55 |
56 | i. The `data-layer` python project
57 |
58 | ii. The `services/example/prisma` settings.
59 |
60 | You may need to go through the Prisma documentation to figure out how to switch from `sqlite` to `postgresql`.
61 |
62 | ### Second task
63 |
64 | Based on the work done in the first task, create a CRUD API that consists of the following endpoints
65 |
66 | 1. `POST /notes/create` *Anonymous note creation*
67 | 2. `GET /notes` *Fetching the list of anonymous notes created*
68 | 3. `PUT /notes/:note-id` *The ability to update a specific anonymous note*
69 | 4. `DELETE /notes/:note-id` *The ability to delete a specific anonymous note*
70 | 5. `POST /users/create` *Endpoint to create a user*
71 | 6. `GET /users/:username/notes` *Fetching the notes of a particular user*
72 |
73 | Create the corresponding tables using the convention already specified in the first task. Ensure to implement automated testing on each of these endpoints.
74 |
75 | **You get bonus points if:**
76 |
77 | 1. Instead of the `service/example` package, you create a different [NextJS](https://nextjs.org/docs) application `package` to house these endpoints.
78 | 2. You use a different database when running the tests since each test run will lead to deleting and recreating records.
79 | 3. You write a script that can pre-populate the database with sample records.
80 |
81 | ### Third task
82 |
83 | Building on the work done in the second task, create a [NextJS](https://nextjs.org/docs) application that consists of the following pages:
84 |
85 | 1. The Home page which consists of a list of notes already created that do not belong to any user with the ability to read the detail of each note when clicked.
86 | 2. A User page that displays the list of notes belonging to the user whose username is passed in the route, using [NextJS](https://nextjs.org/docs)' routing system to pull the relevant username from the URL. The ability to create notes, view notes, and delete notes should be provided.
87 | 3. A Protected page `/admin` that would be used to create users and to view the list of users and notes created by each of them. You're free to decide how you would handle access to this page. The only requirement is that it is protected, i.e. can't be accessed without some sort of credentials provided.
88 |
89 | **You get bonus points if you:**
90 |
91 | 1. Create [Storybook](https://storybook.js.org/) for the components used in creating the pages. *The project already has `storybook` setup.*
92 | 2. Use [Chakra-UI](https://chakra-ui.com/getting-started) as the component library of choice *This is already installed in the project.*
93 | 3. Use `packages/components` to house the components used in the project.
94 | 4. Add relevant tests where necessary for the frontend.
95 | 5. Host the whole application on a production URL e.g. Heroku, Vercel, Netlify, AWS, Digitalocean e.t.c
96 |
--------------------------------------------------------------------------------
/packages/components/src/utils/functions.ts:
--------------------------------------------------------------------------------
1 | import groupBy from "json-groupby";
2 | //This function removes duplicates from an array of objects
3 | //using the object key
4 | type StringType = string | number;
5 | export function getUnique(arr: Array<{ [K in StringType]: any }>, key: string) {
6 | const unique = arr
7 | .map((object: { [x: string]: any }) => object[key])
8 |
9 | // store the keys of the unique objects
10 | .map((e, i, final) => final.indexOf(e) === i && i)
11 |
12 | // eliminate the dead keys & store unique objects
13 | .filter((e) => typeof e == "number" && arr[e])
14 | .map((e) => typeof e == "number" && arr[e]);
15 |
16 | return unique;
17 | }
18 |
19 | export const isDefined = (elem: any) => typeof elem !== "undefined";
20 |
21 | /**
22 | * Breaks an array into
23 | * @param {Array[*]} array The array that is to be grouped into smaller arrays
24 | * @param {String} size The maximum number of elements in each chunked array
25 | *
26 | * @returns {Array[Arrays]} An array of smaller, chunked arrays
27 | *
28 | * @example
29 | * chunkArray(["a","b","c","d"], 2)
30 | * //=> [["a","b"],["c","d"]]
31 | */
32 | export function chunkArray(array: Array, size: number) {
33 | const results = [];
34 |
35 | while (array.length > 0) results.push(array.splice(0, size));
36 |
37 | return results;
38 | }
39 |
40 | export function shuffle(array: Array) {
41 | let currentIndex = array.length,
42 | temporaryValue,
43 | randomIndex;
44 |
45 | // While there remain elements to shuffle...
46 | while (0 !== currentIndex) {
47 | // Pick a remaining element...
48 | randomIndex = Math.floor(Math.random() * currentIndex);
49 | currentIndex -= 1;
50 |
51 | // And swap it with the current element.
52 | temporaryValue = array[currentIndex];
53 | array[currentIndex] = array[randomIndex];
54 | array[randomIndex] = temporaryValue;
55 | }
56 |
57 | return array;
58 | }
59 |
60 | /**
61 | * Returns the duplicate elements in an array
62 | * @param {Array} arr Array that has duplicate elements
63 | * @returns {Array} Duplicate elements
64 | */
65 | export function getArrayDuplicates(arr: Array) {
66 | return arr.filter((elem, index, array) => array.indexOf(elem) !== index);
67 | }
68 |
69 | /**
70 | * Checks if an array contains nested arrays or not.
71 | * @param {Array[]} arr
72 | * @returns {Boolean}
73 | * @example
74 | * isNestedArray([["a","b"],["c","d"]])
75 | * //=> true
76 | */
77 | export function isNestedArray(arr: Array) {
78 | const nestedArrays = arr.filter((item) => Array.isArray(item));
79 | return nestedArrays.length > 0;
80 | }
81 |
82 | export function scrollToTop(style: "auto" | "smooth" | undefined) {
83 | return window.scroll({
84 | top: 0,
85 | behavior: style ? style : "auto",
86 | });
87 | }
88 |
89 | export function capFirstLetter(string: string) {
90 | return string.charAt(0).toUpperCase() + string.slice(1);
91 | }
92 |
93 | // This component renders a title, description, icon, badge, and hide/show link
94 | // as well as a renderElement on the Right and Left
95 |
96 | export function removeClassPrefix(word: string) {
97 | return word
98 | .replace(/Primary /g, "")
99 | .replace(/JSS /g, "")
100 | .replace(/SSS /g, "");
101 | }
102 |
103 | export const toTitleCase = (string: string) =>
104 | string
105 | .split(" ")
106 | .map((w) => w.substring(0, 1).toUpperCase() + w.substring(1))
107 | .join(" ")
108 | .split(",")
109 | .map((w) => w.substring(0, 1).toUpperCase() + w.substring(1))
110 | .join(", ")
111 | .split(".")
112 | .map((w) => w.substring(0, 1).toUpperCase() + w.substring(1))
113 | .join(". ");
114 |
115 | /**
116 | * Sorts an array of objects in alphabetical order using the supplied `key`
117 | * @param {Array[{}]} array Array of objects to be ordered alphabetically
118 | * @param {String} key the object key to be used in the sorting
119 | * @returns A sorted Array
120 | * @example
121 | * orderAlphabetically([{food: "Yam", price: 500},{food: "Egg", price: 100}], "food")
122 | * //=> [{food: "Egg", price: 100}, {food: "Yam", price: 500}]
123 | */
124 | export const orderAlphabetically = (array: Array, key: string) => {
125 | return array.sort((a, b) => {
126 | if (a[key] < b[key]) return -1;
127 | if (a[key] > b[key]) return 1;
128 | return 0;
129 | });
130 | };
131 |
132 | /**
133 | * Checks if all the properties of an object have values
134 | * Applies to boolean, string, array or object values
135 | * @param {Object} obj - Object to be checked
136 | * @param {Object} deleteKey1 - A key to be disregarded
137 | * @param {Object} deleteKey2 - A key to be disregarded
138 | * @returns Boolean
139 | */
140 |
141 | export function isValidObject(
142 | obj: { [key: string]: any },
143 | deleteKey1 = "",
144 | deleteKey2 = ""
145 | ): boolean {
146 | const objKeyValue = Object.entries(obj)
147 | .filter((keyValue) => keyValue[0] !== deleteKey1)
148 | .filter((keyValue) => keyValue[0] !== deleteKey2)
149 | .map((keyValue) => keyValue[1]);
150 | return objKeyValue.length > 0
151 | ? objKeyValue.every((item) =>
152 | Array.isArray(item)
153 | ? item.length > 0
154 | : typeof item === "object"
155 | ? isValidObject(item)
156 | : Boolean(item) || typeof item === "boolean"
157 | )
158 | : false;
159 | }
160 | /**
161 | * Groups an array of objects using a key into two arrays
162 | * one containing objects that have the key, and the other containing
163 | * objects that do not have the key.
164 | * @param {Array} array An array of objects containing properties to be grouped
165 | * @param {String} key Name of the object key that will be used to group arrays
166 | * @example
167 | * groupArrayByKey([{food: "Yam", type: "Carbohydrate"},{food: "Beans", type: "Protein"},{food: "Rice", type: "Carbohydrate"},{food: "Vegetable", type: "vitamins"}],"Carbohydrate")
168 | * //=> {arrayWithKey: [{food: "Yam", type: "Carbohydrate"}, {food: "Rice", type: "Carbohydrate"}], arrayWithoutKey: [{food: "Beans", type: "Protein"}, {food: "Vegetable", type: "vitamins"}]}
169 | */
170 | export function groupArrayByKey(array: Array, key: string) {
171 | const firstKey = array[0][key];
172 | const arrayWithKey = array.filter((item) => item[key] === firstKey);
173 | const arrayWithoutKey = array.filter((item) => !arrayWithKey.includes(item));
174 | return { arrayWithKey, arrayWithoutKey };
175 | }
176 |
177 | /**
178 | * Chunks an array of objects into smaller arrays with similiar object keys
179 | * @param {Array} array
180 | * @param {String} key
181 | * @example
182 | * chunkArrayByKey([{food: "Yam", type: "Carbohydrate"},{food: "Beans", type: "Protein"},{food: "Rice", type: "Carbohydrate"},{food: "Vegetable", type: "vitamins"}],"Carbohydrate")
183 | * //=> [[{food: "Yam", type: "Carbohydrate"}, {food: "Rice", type: "Carbohydrate"}],[{food: "Beans", type: "Protein"}],[{food: "Vegetable", type: "vitamins"}]]
184 | */
185 | export function chunkArrayByKey(array: Array, key: string) {
186 | const result = [];
187 | let continueLoop = true;
188 | let startArr = array;
189 |
190 | const rr = groupBy(array, [key]);
191 | return Object.values(rr);
192 | console.log(rr);
193 | console.log(array);
194 | console.log(key);
195 | while (continueLoop) {
196 | const { arrayWithKey, arrayWithoutKey } = groupArrayByKey(startArr, key);
197 | if (arrayWithKey.length > 0) result.push(arrayWithKey);
198 | startArr = arrayWithoutKey;
199 | if (arrayWithoutKey.length === 0) continueLoop = false;
200 | }
201 | console.log(result);
202 | return result;
203 | }
204 |
205 | export function getRandomElemFromArray(array: Array) {
206 | return array[Math.floor(Math.random() * array.length)];
207 | }
208 |
209 | export function newChunkArrayByKey(array: Array, key: string) {
210 | const result = groupBy(array, [key]);
211 | return Object.entries(result);
212 | }
213 |
214 | /**
215 | * This combines an array of strings into a string output inserting
216 | * "," and "and" where appropriate
217 | * @param {Array} array
218 | *
219 | * @example
220 | * arrayToString(["a","b","c"])
221 | * //=>"a, b and c"
222 | */
223 | export function arrayToString(array: Array, separator = "and") {
224 | let string = "";
225 |
226 | array.forEach((x, i) => {
227 | if (i < array.length - 2) string = string.concat(x, ", ");
228 | if (i === array.length - 2) string = string.concat(x, ` ${separator} `);
229 | if (i > array.length - 2) string = string.concat(x);
230 | });
231 |
232 | return string;
233 | }
234 |
235 | /* Given a start date, end date and day name, return
236 | ** an array of dates between the two dates for the
237 | ** given day inclusive
238 | ** @param {Date} start - date to start from
239 | ** @param {Date} end - date to end on
240 | ** @param {string} dayName - name of day
241 | ** @returns {Array} array of Dates
242 | */
243 | export function getDaysBetweenDates(
244 | start: string | number | Date,
245 | end: number | string | Date,
246 | dayName: string
247 | ) {
248 | const result = [];
249 | const days: { [key: string]: number } = {
250 | sunday: 0,
251 | monday: 1,
252 | tuesday: 2,
253 | wednesday: 3,
254 | thursday: 4,
255 | friday: 5,
256 | saturday: 6,
257 | };
258 | const day = days[dayName.toLowerCase()];
259 | // Copy start date
260 | const current = new Date(start);
261 | // Shift to next of required days
262 | current.setDate(current.getDate() + ((day - current.getDay() + 7) % 7));
263 | // While less than end date, add dates to result array
264 | while (current < end) {
265 | result.push(+current);
266 | current.setDate(current.getDate() + 7);
267 | }
268 | return result;
269 | }
270 |
271 | export const scrollToId = (id: string, scrollProps = {}) => {
272 | const section = document.getElementById(id);
273 | const scrollObj: ScrollIntoViewOptions = {
274 | block: "start",
275 | inline: "start",
276 | behavior: "smooth",
277 | ...scrollProps,
278 | };
279 | if (section) {
280 | section.scrollIntoView(scrollObj);
281 | }
282 | };
283 |
--------------------------------------------------------------------------------