diff --git a/README.md b/README.md index b7def2b4217794c179af56ec7e56fd55d38b5096..53b080dbb848edeefecc64c8bfb86750df215345 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ - To access DB run `npm start` on backend folder. - To launch React application run `npm start` on frontend folder. -IMPORTANT! The data is fetched from the back-end, if you're gonna test this solution on an android or iOS unit using expo. You have to make sure that the backend is fetched from your computer's IP and not the default localhost. Head into `frontend/app/api/index.ts` and configure the `baseURL` to be your own. If you don't know how to find your IP, type `ipconfig` (windows), `ifconfig | grep 'inet'` (mac) or `hostname -i` (debian). Due to time constraints minimal effort went into improving the backend in this assignment as this was not specified in the requirements. +IMPORTANT! The data is fetched from the back-end, if you're gonna test this solution on an android or iOS unit using expo. You have to make sure that the backend is fetched from your computer's IP and not the default localhost. Head into `frontend/app/api/index.ts` and configure the `baseURL` to be your own. If you don't know how to find your IP, type `ipconfig` (windows), `ifconfig | grep 'inet'` (mac) or `hostname -i` (debian). Example in Windows, the adapter called `Wireless LAN Adapter Wi-Fi` is most likely going to contain your correct IPv4 or IPv6 address. If you use an IPv6 address, you might have to wrap it with `[]`. Due to time constraints minimal effort went into improving the backend in this assignment as this was not specified in the requirements. ### Back-end @@ -75,15 +75,15 @@ As Typescript was mandatory, the expo project was initialized with Typescript. A The React Native project was initialized using the Expo CLI tools. -Some of the third party libraries I have used: +Some of the third party libraries we have used: - Redux, with React-Redux: state management. - React Navigation: routing for the react native application -- React Native Elements: a cross platform UI toolkit. This has mainly been used for the searchbar as I wanted a guarantee that it would work properly on both Androis and iOS. I could have used more components, but considering that most of the logic I have used are based on components in the React-Native library without heavy styling rules and advanced logic, I assumed cross-platform support would not be an issue. +- React Native Elements: a cross platform UI toolkit. This has mainly been used for the searchbar as we wanted a guarantee that it would work properly on both Androis and iOS. We could have used more components, but considering that most of the logic we have used are based on components in the React-Native library without heavy styling rules and advanced logic, we assumed cross-platform support would not be an issue. #### End-to-end testing -The application has been heavily tested during the development. This has been done using the Expo application for Android and connecting it to the Expo service running on my computer. I can only ensure that the application will run properly on a unit rurnning Android OS as I do not own an iphone or any Apple devices to test this on. +The application has been heavily tested during the development. This has been done using the Expo application for Android and connecting it to the Expo service running on my computer. we can only ensure that the application will run properly on a unit rurnning Android OS as we do not own an iphone or any Apple devices to test this on. ### ESLint diff --git a/backend/src/controllers/movie.ts b/backend/src/controllers/movie.ts index ec63b6fc1b728ead4a59901fa7877d833ee6c517..7cf083b4f6dfdbacccce8e83e401b83e70ee8f5b 100644 --- a/backend/src/controllers/movie.ts +++ b/backend/src/controllers/movie.ts @@ -1,41 +1,47 @@ -import { Context } from 'koa'; -import { getManager, Repository, Like } from 'typeorm'; +import { Context } from "koa"; +import { getManager, Repository, Like } from "typeorm"; -import { Movie } from '../models/movies'; +import { Movie } from "../models/movies"; -const ERROR_MSG1 = 'Movie does not exist in the database, consider adding it for future users looking for the same movie.'; -const ERROR_MSG2 = 'Conflicting movie name. Consider adding identifier if distinct movies have identical names.'; -const ERROR_MSG3 = 'Somethink awful has happened because the database must have gone down as it is supposed to contain movies and this code works...'; +const ERROR_MSG1 = + "Movie does not exist in the database, consider adding it for future users looking for the same movie."; +const ERROR_MSG2 = + "Conflicting movie name. Consider adding identifier if distinct movies have identical names."; +const ERROR_MSG3 = + "Somethink awful has happened because the database must have gone down as it is supposed to contain movies and this code works..."; /** * MovieController contains all the logic surrounding querying the database and performing operations on the results from the database regarding the Movie model. */ export default class MovieController { + public static async getMovies(ctx: Context) { + const movieRepository: Repository<Movie> = getManager().getRepository( + Movie + ); - public static async getMovies (ctx: Context) { - const movieRepository: Repository<Movie> = getManager().getRepository(Movie); - const movies: Movie[] = await movieRepository.find({ take: ctx.header.take, - skip: ctx.header.skip + skip: ctx.header.skip, }); ctx.status = 200; ctx.body = movies; } - public static async getTopMovies (ctx: Context) { - const movieRepository: Repository<Movie> = getManager().getRepository(Movie); + public static async getTopMovies(ctx: Context) { + const movieRepository: Repository<Movie> = getManager().getRepository( + Movie + ); const movies: Movie[] = await movieRepository.find({ order: { - Metascore: "ASC" + Metascore: "ASC", }, take: ctx.header.take, - skip: ctx.header.skip - }) + skip: ctx.header.skip, + }); - if(movies) { + if (movies) { ctx.status = 200; ctx.body = movies; } else { @@ -44,11 +50,13 @@ export default class MovieController { } } - public static async getMovieId (ctx: Context) { - const movieRepository: Repository<Movie> = getManager().getRepository(Movie); + public static async getMovieId(ctx: Context) { + const movieRepository: Repository<Movie> = getManager().getRepository( + Movie + ); const movie: Movie = await movieRepository.findOne(ctx.params.Column_1); - + if (movie) { ctx.status = 200; ctx.body = movie; @@ -63,40 +71,70 @@ export default class MovieController { // pagination can be ensured by sending in a paremeter for take and skip value // e.g. take: ctx.request.body.name.take || 10 // skip: ctx.request.body.name.skip || 0 - public static async searchMovie (ctx: Context) { - const movieRepository: Repository<Movie> = getManager().getRepository(Movie); + public static async searchMovie(ctx: Context) { + const movieRepository: Repository<Movie> = getManager().getRepository( + Movie + ); const [movies, count]: any = await movieRepository.findAndCount({ where: `"Title" ILIKE '%${ctx.request.body.Title}%'`, // order has to be either DESC or ASC order: { Title: ctx.request.body.order || "ASC" }, take: ctx.request.body.take || 10, - skip: ctx.request.body.skip || 0 + skip: ctx.request.body.skip || 0, }); - if(movies) { + if (movies) { ctx.status = 200; - ctx.body = {movies: movies, count: count}; + ctx.body = { movies: movies, count: count }; } else { ctx.status = 400; ctx.body = ERROR_MSG1; } } - public static async getMovieByTitle (ctx: Context) { - const movieRepository: Repository<Movie> = getManager().getRepository(Movie); - - const movies: Movie[] = await movieRepository.find({ + public static async getMovieByTitle(ctx: Context) { + const movieRepository: Repository<Movie> = getManager().getRepository( + Movie + ); + const [movies, count]: [ + Movie[], + number + ] = await movieRepository.findAndCount({ where: `"Title" ILIKE '%${ctx.params.title}%'`, // order has to be either DESC or ASC - order: {Title: "ASC"}, + order: { Title: ctx.params.order || "ASC" }, take: ctx.params.take, - skip: Math.max(ctx.params.take-10, 0) + skip: Math.max(ctx.params.take - 10, 0), }); - if(movies) { + if (movies) { ctx.status = 200; - ctx.body = movies; + ctx.body = { movies: movies, count: count }; + } else { + ctx.status = 400; + ctx.body = ERROR_MSG1; + } + } + + public static async getMovieByTitleAndGenre(ctx: Context) { + const movieRepository: Repository<Movie> = getManager().getRepository( + Movie + ); + const [movies, count]: [ + Movie[], + number + ] = await movieRepository.findAndCount({ + where: `"Title" ILIKE '%${ctx.params.title}%' AND "Genre" ILIKE '%${ctx.params.genre}%'`, + // order has to be either DESC or ASC + order: { Title: ctx.params.order || "ASC" }, + take: ctx.params.take, + skip: Math.max(ctx.params.take - 10, 0), + }); + + if (movies) { + ctx.status = 200; + ctx.body = { movies: movies, count: count }; } else { ctx.status = 400; ctx.body = ERROR_MSG1; @@ -106,20 +144,25 @@ export default class MovieController { // This is supposed to be a post // Expected body has categories // Take and skip for pagination - public static async searchMoviesByGenre (ctx: Context) { - const movieRepository: Repository<Movie> = getManager().getRepository(Movie); - - const [movies, count]: [Movie[], number] = await movieRepository.findAndCount({ + public static async searchMoviesByGenre(ctx: Context) { + const movieRepository: Repository<Movie> = getManager().getRepository( + Movie + ); + + const [movies, count]: [ + Movie[], + number + ] = await movieRepository.findAndCount({ where: `"Genre" ILIKE '%${ctx.request.body.Genre}%'`, // order has to be either DESC or ASC order: { - Title: ctx.request.body.order + Title: ctx.request.body.order, }, take: ctx.request.body.take || 10, - skip: ctx.request.body.skip || 0 - }) + skip: ctx.request.body.skip || 0, + }); - if(movies) { + if (movies) { ctx.status = 200; ctx.body = movies; } else { @@ -128,7 +171,7 @@ export default class MovieController { } } -/* + /* public static async updateMovie (ctx: Context) { const movieRepository: Repository<Movie> = getManager().getRepository(Movie); diff --git a/backend/src/routes/rest-routes.ts b/backend/src/routes/rest-routes.ts index 4aaa37ef326550d812fed0634069b385271c2a0f..f0fe73b8671b6f3462dcc62e5c53d6dfc08b4c43 100644 --- a/backend/src/routes/rest-routes.ts +++ b/backend/src/routes/rest-routes.ts @@ -12,7 +12,9 @@ restRouter.get("/movies/:id", controller.movie.getMovieId); // Post request for finding movies based on search restRouter.post("/movies/search", controller.movie.searchMovie); // Get request for finding movies based on search -restRouter.get("/movies/:title/:take", controller.movie.getMovieByTitle); +restRouter.get("/movies/:title/:take/:order", controller.movie.getMovieByTitle) +// Get request for finding movies based on search and genre +restRouter.get("/movies/:title/:take/:order/:genre", controller.movie.getMovieByTitleAndGenre); // Post request for finding movies based on category restRouter.post("/movies/genre", controller.movie.searchMoviesByGenre); diff --git a/frontend/app/api/index.ts b/frontend/app/api/index.ts index 6d38f4a9b03b328273be9f6aacab57744630ec51..739768b3a382dd102370e39351c3656ddd8bb1b8 100644 --- a/frontend/app/api/index.ts +++ b/frontend/app/api/index.ts @@ -2,5 +2,5 @@ import axios from "axios"; // You will need to set this to your ip address when running. This is the only variable that has to be changed. export default axios.create({ - baseURL: "http://192.168.0.62:3001", + baseURL: "http://[THIS_IS_WHERE_YOUR_IP_GOES]:3001", }); diff --git a/frontend/app/components/SearchHandler.component.tsx b/frontend/app/components/SearchHandler.component.tsx index 46a40c62e1fab854f46204b19deb7ad42367cf48..cf6a0582ad46aeb240e4a068088363d77604496f 100644 --- a/frontend/app/components/SearchHandler.component.tsx +++ b/frontend/app/components/SearchHandler.component.tsx @@ -20,7 +20,6 @@ const SearchHandler = (): JSX.Element => { } }, [debouncedSearch]); - // TODO: this function should be exported to ensure modularity const searchMovies = async () => { const body = { Title: search, diff --git a/frontend/app/store/slices/movieSlice.ts b/frontend/app/store/slices/movieSlice.ts index e568131bec1c0d482b90918492be4319a4fb53a4..febffc7a23de08afac40d03fa3548a99ae781b5b 100644 --- a/frontend/app/store/slices/movieSlice.ts +++ b/frontend/app/store/slices/movieSlice.ts @@ -40,8 +40,8 @@ export const movieSlice = createSlice({ searchTerm: state.searchTerm, movies: [...state.movies, action.payload], }), - clearMovies: () => ({ - searchTerm: "", + clearMovies: (state: MovieState) => ({ + searchTerm: state.searchTerm, movies: [], }), }, diff --git a/frontend/app/views/Home.component.tsx b/frontend/app/views/Home.component.tsx index 4701e4b8859b07a11b0bd5958f5acbc92da37b86..fb340028d1208cd9231b2bff844e60a0224e1eb0 100644 --- a/frontend/app/views/Home.component.tsx +++ b/frontend/app/views/Home.component.tsx @@ -1,5 +1,5 @@ -import React, { useState } from "react"; -import { View, FlatList, StyleSheet } from "react-native"; +import React, { useEffect, useState } from "react"; +import { View, FlatList, StyleSheet, Picker, Text } from "react-native"; import axios from "axios"; import { AppState, useAppDispatch } from "../store/redux/store"; @@ -8,6 +8,7 @@ import { useSelector } from "react-redux"; import api from "../api"; import { Movie, movieSlice } from "../store/slices/movieSlice"; import { MovieListItem } from "./MovieListItem.component"; +import { systemSlice } from "../store/slices/systemSlice"; const Home: React.FC = (): JSX.Element => { const dispatch = useAppDispatch(); @@ -15,10 +16,11 @@ const Home: React.FC = (): JSX.Element => { const searchTerm = useSelector((state: AppState) => state.movies.searchTerm); const count = useSelector((state: AppState) => state.system.count); const [refreshing, setRefreshing] = useState(false); + const [sortValue, setSortValue] = useState("ASC"); + const [filterValue, setFilterValue] = useState(""); - - // As it stands, this simply clear the movie page, implemented in case anything gets bugged. - // The intended use of this function doesn't really make sense in a movie DB + // As it stands, this simply clear the movie page, implemented in case anything gets bugged. + // The intended use of this function doesn't really make sense in a movie DB const refreshMovies = () => { setRefreshing(true); dispatch(movieSlice.actions.clearMovies()); @@ -26,35 +28,85 @@ const Home: React.FC = (): JSX.Element => { }; const fetchMoreMovies = async () => { + // Not fetch more movies than in DB if (movies.length >= count) { return; } + + // We create the endpoint based on whether a genre has been set or not, these are seperate in the back-end + const endpoint = + filterValue == "" + ? `${api.defaults.baseURL}/movies/${searchTerm}/${ + movies.length + 10 + }/${sortValue}` + : `${api.defaults.baseURL}/movies/${searchTerm}/${ + movies.length + 10 + }/${sortValue}/${filterValue}`; + axios - .get(`${api.defaults.baseURL}/movies/${searchTerm}/${movies.length + 10}`) + .get(endpoint) .then((response) => { - response.data.map((movie: Movie) => { + response.data.movies.map((movie: Movie) => { if (!movies.includes(movie)) { dispatch(movieSlice.actions.addMovie(movie)); } + + dispatch(systemSlice.actions.updateSystem(response.data.count)); }); }) .catch((e) => console.log(e)); }; + // We need to clear the list and fetch new ones when genre or sorting has been changed + useEffect(() => { + dispatch(movieSlice.actions.clearMovies()); + fetchMoreMovies(); + }, [filterValue, sortValue]); + return ( <View style={styles.container}> <SearchHandler /> + <View style={{ flexDirection: "row" }}> + <Picker + selectedValue={sortValue} + style={{ height: 50, width: "50%" }} + onValueChange={(itemValue, itemIndex) => setSortValue(itemValue)} + > + <Picker.Item label="Ascending A-Z" value="ASC" /> + <Picker.Item label="Descending Z-A" value="DESC" /> + </Picker> + <Picker + selectedValue={filterValue} + style={{ height: 50, width: "50%" }} + onValueChange={(itemValue, itemIndex) => setFilterValue(itemValue)} + > + <Picker.Item label="All" value="" /> + <Picker.Item label="Action" value="Action" /> + <Picker.Item label="Adventure" value="Adventure" /> + <Picker.Item label="Biography" value="Biography" /> + <Picker.Item label="Comedy" value="Comedy" /> + <Picker.Item label="Crime" value="Crime" /> + <Picker.Item label="Drama" value="Drama" /> + <Picker.Item label="Fantasy" value="Fantasy" /> + <Picker.Item label="History" value="History" /> + <Picker.Item label="Thriller" value="Thriller" /> + </Picker> + </View> <View> <FlatList data={movies} renderItem={({ item }) => <MovieListItem {...item} />} - keyExtractor={(item) => item.Column_1.toString()} + keyExtractor={(item, index) => + Math.floor(Math.random() * 100).toString() + + item.Column_1.toString() + } ItemSeparatorComponent={() => <View></View>} refreshing={refreshing} onRefresh={refreshMovies} onMomentumScrollEnd={() => fetchMoreMovies()} /> </View> + <Text>You either haven't searched or there are no more movies.</Text> </View> ); }; @@ -62,6 +114,7 @@ const Home: React.FC = (): JSX.Element => { const styles = StyleSheet.create({ container: { backgroundColor: "#eee", + flex: 1, }, });