Skip to content
Snippets Groups Projects
Commit 4e4ab324 authored by Jon-Inge Heggstad's avatar Jon-Inge Heggstad
Browse files

Merge branch 'add-sorting-and-filtration' into 'master'

Add sorting and filtration

See merge request !9
parents d46f81b3 fa4f6569
No related branches found
No related tags found
1 merge request!9Add sorting and filtration
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
- To access DB run `npm start` on backend folder. - To access DB run `npm start` on backend folder.
- To launch React application run `npm start` on frontend 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 ### Back-end
...@@ -75,15 +75,15 @@ As Typescript was mandatory, the expo project was initialized with Typescript. A ...@@ -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. 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. - Redux, with React-Redux: state management.
- React Navigation: routing for the react native application - 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 #### 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 ### ESLint
......
import { Context } from 'koa'; import { Context } from "koa";
import { getManager, Repository, Like } from 'typeorm'; 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_MSG1 =
const ERROR_MSG2 = 'Conflicting movie name. Consider adding identifier if distinct movies have identical names.'; "Movie does not exist in the database, consider adding it for future users looking for the same movie.";
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_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. * 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 { export default class MovieController {
public static async getMovies(ctx: Context) { public static async getMovies(ctx: Context) {
const movieRepository: Repository<Movie> = getManager().getRepository(Movie); const movieRepository: Repository<Movie> = getManager().getRepository(
Movie
);
const movies: Movie[] = await movieRepository.find({ const movies: Movie[] = await movieRepository.find({
take: ctx.header.take, take: ctx.header.take,
skip: ctx.header.skip skip: ctx.header.skip,
}); });
ctx.status = 200; ctx.status = 200;
...@@ -25,15 +29,17 @@ export default class MovieController { ...@@ -25,15 +29,17 @@ export default class MovieController {
} }
public static async getTopMovies(ctx: Context) { public static async getTopMovies(ctx: Context) {
const movieRepository: Repository<Movie> = getManager().getRepository(Movie); const movieRepository: Repository<Movie> = getManager().getRepository(
Movie
);
const movies: Movie[] = await movieRepository.find({ const movies: Movie[] = await movieRepository.find({
order: { order: {
Metascore: "ASC" Metascore: "ASC",
}, },
take: ctx.header.take, take: ctx.header.take,
skip: ctx.header.skip skip: ctx.header.skip,
}) });
if (movies) { if (movies) {
ctx.status = 200; ctx.status = 200;
...@@ -45,7 +51,9 @@ export default class MovieController { ...@@ -45,7 +51,9 @@ export default class MovieController {
} }
public static async getMovieId(ctx: Context) { public static async getMovieId(ctx: Context) {
const movieRepository: Repository<Movie> = getManager().getRepository(Movie); const movieRepository: Repository<Movie> = getManager().getRepository(
Movie
);
const movie: Movie = await movieRepository.findOne(ctx.params.Column_1); const movie: Movie = await movieRepository.findOne(ctx.params.Column_1);
...@@ -64,14 +72,16 @@ export default class MovieController { ...@@ -64,14 +72,16 @@ export default class MovieController {
// e.g. take: ctx.request.body.name.take || 10 // e.g. take: ctx.request.body.name.take || 10
// skip: ctx.request.body.name.skip || 0 // skip: ctx.request.body.name.skip || 0
public static async searchMovie(ctx: Context) { public static async searchMovie(ctx: Context) {
const movieRepository: Repository<Movie> = getManager().getRepository(Movie); const movieRepository: Repository<Movie> = getManager().getRepository(
Movie
);
const [movies, count]: any = await movieRepository.findAndCount({ const [movies, count]: any = await movieRepository.findAndCount({
where: `"Title" ILIKE '%${ctx.request.body.Title}%'`, where: `"Title" ILIKE '%${ctx.request.body.Title}%'`,
// order has to be either DESC or ASC // order has to be either DESC or ASC
order: { Title: ctx.request.body.order || "ASC" }, order: { Title: ctx.request.body.order || "ASC" },
take: ctx.request.body.take || 10, take: ctx.request.body.take || 10,
skip: ctx.request.body.skip || 0 skip: ctx.request.body.skip || 0,
}); });
if (movies) { if (movies) {
...@@ -84,19 +94,47 @@ export default class MovieController { ...@@ -84,19 +94,47 @@ export default class MovieController {
} }
public static async getMovieByTitle(ctx: Context) { public static async getMovieByTitle(ctx: Context) {
const movieRepository: Repository<Movie> = getManager().getRepository(Movie); const movieRepository: Repository<Movie> = getManager().getRepository(
Movie
const movies: Movie[] = await movieRepository.find({ );
const [movies, count]: [
Movie[],
number
] = await movieRepository.findAndCount({
where: `"Title" ILIKE '%${ctx.params.title}%'`, where: `"Title" ILIKE '%${ctx.params.title}%'`,
// order has to be either DESC or ASC // order has to be either DESC or ASC
order: {Title: "ASC"}, order: { Title: ctx.params.order || "ASC" },
take: ctx.params.take, 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.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 { } else {
ctx.status = 400; ctx.status = 400;
ctx.body = ERROR_MSG1; ctx.body = ERROR_MSG1;
...@@ -107,17 +145,22 @@ export default class MovieController { ...@@ -107,17 +145,22 @@ export default class MovieController {
// Expected body has categories // Expected body has categories
// Take and skip for pagination // Take and skip for pagination
public static async searchMoviesByGenre(ctx: Context) { public static async searchMoviesByGenre(ctx: Context) {
const movieRepository: Repository<Movie> = getManager().getRepository(Movie); const movieRepository: Repository<Movie> = getManager().getRepository(
Movie
const [movies, count]: [Movie[], number] = await movieRepository.findAndCount({ );
const [movies, count]: [
Movie[],
number
] = await movieRepository.findAndCount({
where: `"Genre" ILIKE '%${ctx.request.body.Genre}%'`, where: `"Genre" ILIKE '%${ctx.request.body.Genre}%'`,
// order has to be either DESC or ASC // order has to be either DESC or ASC
order: { order: {
Title: ctx.request.body.order Title: ctx.request.body.order,
}, },
take: ctx.request.body.take || 10, 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.status = 200;
......
...@@ -12,7 +12,9 @@ restRouter.get("/movies/:id", controller.movie.getMovieId); ...@@ -12,7 +12,9 @@ restRouter.get("/movies/:id", controller.movie.getMovieId);
// Post request for finding movies based on search // Post request for finding movies based on search
restRouter.post("/movies/search", controller.movie.searchMovie); restRouter.post("/movies/search", controller.movie.searchMovie);
// Get request for finding movies based on search // 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 // Post request for finding movies based on category
restRouter.post("/movies/genre", controller.movie.searchMoviesByGenre); restRouter.post("/movies/genre", controller.movie.searchMoviesByGenre);
......
...@@ -2,5 +2,5 @@ import axios from "axios"; ...@@ -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. // 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({ export default axios.create({
baseURL: "http://192.168.0.62:3001", baseURL: "http://[THIS_IS_WHERE_YOUR_IP_GOES]:3001",
}); });
...@@ -20,7 +20,6 @@ const SearchHandler = (): JSX.Element => { ...@@ -20,7 +20,6 @@ const SearchHandler = (): JSX.Element => {
} }
}, [debouncedSearch]); }, [debouncedSearch]);
// TODO: this function should be exported to ensure modularity
const searchMovies = async () => { const searchMovies = async () => {
const body = { const body = {
Title: search, Title: search,
......
...@@ -40,8 +40,8 @@ export const movieSlice = createSlice({ ...@@ -40,8 +40,8 @@ export const movieSlice = createSlice({
searchTerm: state.searchTerm, searchTerm: state.searchTerm,
movies: [...state.movies, action.payload], movies: [...state.movies, action.payload],
}), }),
clearMovies: () => ({ clearMovies: (state: MovieState) => ({
searchTerm: "", searchTerm: state.searchTerm,
movies: [], movies: [],
}), }),
}, },
......
import React, { useState } from "react"; import React, { useEffect, useState } from "react";
import { View, FlatList, StyleSheet } from "react-native"; import { View, FlatList, StyleSheet, Picker, Text } from "react-native";
import axios from "axios"; import axios from "axios";
import { AppState, useAppDispatch } from "../store/redux/store"; import { AppState, useAppDispatch } from "../store/redux/store";
...@@ -8,6 +8,7 @@ import { useSelector } from "react-redux"; ...@@ -8,6 +8,7 @@ import { useSelector } from "react-redux";
import api from "../api"; import api from "../api";
import { Movie, movieSlice } from "../store/slices/movieSlice"; import { Movie, movieSlice } from "../store/slices/movieSlice";
import { MovieListItem } from "./MovieListItem.component"; import { MovieListItem } from "./MovieListItem.component";
import { systemSlice } from "../store/slices/systemSlice";
const Home: React.FC = (): JSX.Element => { const Home: React.FC = (): JSX.Element => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
...@@ -15,7 +16,8 @@ const Home: React.FC = (): JSX.Element => { ...@@ -15,7 +16,8 @@ const Home: React.FC = (): JSX.Element => {
const searchTerm = useSelector((state: AppState) => state.movies.searchTerm); const searchTerm = useSelector((state: AppState) => state.movies.searchTerm);
const count = useSelector((state: AppState) => state.system.count); const count = useSelector((state: AppState) => state.system.count);
const [refreshing, setRefreshing] = useState(false); 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. // 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 // The intended use of this function doesn't really make sense in a movie DB
...@@ -26,35 +28,85 @@ const Home: React.FC = (): JSX.Element => { ...@@ -26,35 +28,85 @@ const Home: React.FC = (): JSX.Element => {
}; };
const fetchMoreMovies = async () => { const fetchMoreMovies = async () => {
// Not fetch more movies than in DB
if (movies.length >= count) { if (movies.length >= count) {
return; 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 axios
.get(`${api.defaults.baseURL}/movies/${searchTerm}/${movies.length + 10}`) .get(endpoint)
.then((response) => { .then((response) => {
response.data.map((movie: Movie) => { response.data.movies.map((movie: Movie) => {
if (!movies.includes(movie)) { if (!movies.includes(movie)) {
dispatch(movieSlice.actions.addMovie(movie)); dispatch(movieSlice.actions.addMovie(movie));
} }
dispatch(systemSlice.actions.updateSystem(response.data.count));
}); });
}) })
.catch((e) => console.log(e)); .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 ( return (
<View style={styles.container}> <View style={styles.container}>
<SearchHandler /> <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> <View>
<FlatList <FlatList
data={movies} data={movies}
renderItem={({ item }) => <MovieListItem {...item} />} 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>} ItemSeparatorComponent={() => <View></View>}
refreshing={refreshing} refreshing={refreshing}
onRefresh={refreshMovies} onRefresh={refreshMovies}
onMomentumScrollEnd={() => fetchMoreMovies()} onMomentumScrollEnd={() => fetchMoreMovies()}
/> />
</View> </View>
<Text>You either haven't searched or there are no more movies.</Text>
</View> </View>
); );
}; };
...@@ -62,6 +114,7 @@ const Home: React.FC = (): JSX.Element => { ...@@ -62,6 +114,7 @@ const Home: React.FC = (): JSX.Element => {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
backgroundColor: "#eee", backgroundColor: "#eee",
flex: 1,
}, },
}); });
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment