diff --git a/SOLUTION.md b/SOLUTION.md new file mode 100644 index 0000000..55f2209 --- /dev/null +++ b/SOLUTION.md @@ -0,0 +1,152 @@ +## Strategy & Trade-offs + +I focused on implementing the back-end first in order to start the front-end work with all endpoints working and their APIs defined, for an easy integration in the front-end. + +### back-end + +- I implemented the `loadReviews` logic first, processing and returning all the necessary data needed for any other flows, making the processing of this logic a bit longer but facilitating the implementation and usage of the ReviewsData object where needed. Even though it might not be needed in a controlled scenario like this, I implemented the logic to be resilient to malformed files or files that do not follow the expected schema, to avoid errors in case the source file is changed; +- I implemented the `handleAutocomplete` endpoint using a partial match strategy combined of a standardized return of the matched names. This strategy leads to a better UX - as all matching names are going to be displayed, either partially or fully - and eases the front-end implementation as all names are already standardized, but this makes the logic more inefficient as a trade-off. As the losses in speed are not noticeable, I decided to use the solution that leaves the client with a better experience; +- I implemented the `handleGetReviews` endpoint using a map with the college URL as a key and the reviews as a value, to facilitate the lookup and remove the need of multiple iterations through the college reviews data; +- I implemented unit tests for the shared functionalities and E2E tests for the exposed endpoint in order to leave the solution more reliable after changes. + +### front-end + +- I used a debounced-search strategy to fetch the matching college names after the user is done typing +- I used a virtual focus strategy to show focus on the elements of the college name list while the user still maintains focus on the input and can type at any moment, even while navigating the list +- I broke the pages into smaller components to make the code easier to read and separate responsibilities +- I used Niche's own design style to base the styles of the app + +## AI usage + +### back-end + +In the back-end implementation, I used AI to write the code after I had defined what needed to be changed and where, mostly because I'm not as used to writing GO code as I am to NodeJS, but also to accelerate implementation. Then I reviewed, corrected and refined the generated code as needed. + +Used prompts: + +1. "In @back-end/reviews.go, implement the loadReviews function with the following logic: + +- Open and read the data from the @back-end/data/niche_reviews.csv. In case the file does not exist or any errors happen when opening the file, log the error details in the console and raise the error; +- Read all the data from the file and create an array of colleges (using the College struct) with the read info (header not included). If any rows are missing an ID, name or URL skip it. If it is missing reviews, just save an empty array in the "reviews" property. In case any errors happen when reading the file, log the error details in the console and raise the error; +- If the file contains rows with different amount of columns an error should be returned +- If the file does not following the expected schema an error should be returned +- If the file has no rows besides the header nil should be returned +- Return an array of colleges data (using the College struct), an map of reviews (array) by college URL and by ID and an array of sorted college names, following the defined ReviewsData; +- Implement the code minimizing loops to avoid high complexity implementations and make the code clean and easy to read." + +2. "Now create unit tests for the loadReviews function to assert the following test cases: + +- Generic error to open the file -> error should be returned +- File does not exist -> error should be returned +- Generic error when reading the file -> error should be returned +- File containing rows with different amount of columns -> error should be returned +- File not following the expected schema -> error should be returned +- File has no rows besides the header -> nil should be returned, no error should be returned +- Valid file, containing only one row besides the header -> extracted ReviewsData should be returned containing the data of only one college +- Valid file, containing multiple row besides the header -> extracted ReviewsData should be returned containing the data of multiple colleges, no error should be returned + +The unit tests should not depend on external integrations, create fixtures for the csv file as needed and mock external dependencies in order to test the scenarios" + +3. "In @back-end/server.go, implement the handleGetReviews endpoint logic. The endpoint should: + +- receive a college url as a query param, search for the reviews related to that url in the reviewsByCollegeURLMap contained in the ReviewsData object and return them +- return a 400 HTTP status code if the url is NOT provided in the query params +- return a 200 HTTP status code and an array of college reviews (using the CollegeReview struct) if the url is existent in the map +- return a 404 HTTP status code if the url is NOT existent in the map +- return a 404 HTTP status code if ReviewsData is nil" + +4. "Now create e2e tests for the handleGetReviews endpoint with the following test cases: + +- the endpoint should return a 400 HTTP status code if the url is NOT provided in the query params +- the endpoint should return a 404 HTTP status code if the ReviewsData object is nil +- the endpoint should return a 400 HTTP status code if the url is NOT provided in the query params AND if the the ReviewsData object is nil +- the endpoint should return a 404 HTTP status code if the url provided is valid, the ReviewsData object is NOT nil but the URL data is NOT existent in the map +- the endpoint should return a 200 HTTP status code and an array of college reviews correctly parsed if the url provided is valid, the ReviewsData object is NOT nil and the URL data is existent in the map" + +5. "In @back-end/server.go, implement the handleAutocomplete endpoint logic. The endpoint should: + +- receive a college name as a query param and return matching college names from the ReviewsData object. The check should be case insensitive and should validate for partial matching (e.g.: the given name if given "var", "harvard" should be matched) +- return a 400 HTTP status code if the name is NOT provided in the query params +- return a 200 HTTP status code and an array of strings with the matching names, whereas if there are no matching names an empty array should be returned" + +6. "Now create e2e tests for the handleAutocomplete endpoint with the following test cases: + +- the endpoint should return a 400 HTTP status code if the name is NOT provided in the query params + +- the endpoint should return a 200 HTTP status code and an empty array if ReviewsData object is nil +- the endpoint should return a 200 HTTP status code and an array with one value for input in upper case and ReviewsData object with upper case names and full match +- the endpoint should return a 200 HTTP status code and an array with one value for input in lower case and ReviewsData object with lower case names and full match +- the endpoint should return a 200 HTTP status code and an array with one value for input with partial match +- the endpoint should return a 200 HTTP status code and an array with multiple values for input with partial match" + +### front-end + +I used AI to save time in the development, style the app following the Niche standards, and for some minor changes like the focus scroll functionality. + +Prompts: + +1. "Create separate files for the input component and the list component and import them in the page" +2. "Make the college list scroll so that the virtually focused college is always on display" +3. "Style this @front-end/src/app to use the same font, colors and styles used by https://www.niche.com/ and https://www.niche.com/colleges/search/best-colleges/ " + +## What I would do next + +If I were to improve this solution further I would: + +- Implement Docker in both apps to facilitate running and deploying +- Implement a better folder structure in the back-end project to facilitate maintenance as the project grows +- Stop depending on a local csv file to load the data, replacing it by a DB for a more robust solution, and also only loading the data from the DB on request, instead of when booting up the back-end +- Add TailwindCSS as an external lib in the front-end to facilitate stylization +- Add TanStack Query as an external lib in the front-end to facilitate deboucing queries +- Add Axios as an external lib in the front-end to facilitate HTTP calls +- Implement E2E automated test (e.g. Cypress or Playwright) + +## How to use + +### Running the apps + +In order to use the application from end-to-end, it is necessary to run both front-end and back-end at the same time. To do that, follow these steps: + +In order to run the back-end, run the following commands + +```bash +cd back-end +go run . +``` + +In order to run the front-end, run the following commands + +```bash +cd front-end +npm run dev +``` + +or, if you want to run the app in production mode, run + +```bash +cd front-end +npm run build +npm run start +``` + +With both applications running, you can open the front-end by accessing "localhost:3000" in your browser and then you are free to explore the solution! + +In case you want to try out the back-end endpoints directly, you can make direct HTTP requests to "localhost:8080". + +_Important_: In case port 8080 is already in use in your computer, you must change the port in both `back-end\main.go` and `front-end\src\app\page.tsx` to keep the integration from failing. + +### Testing the apps + +In order to run the automated tests in the back-end, run the following commands: + +```bash +cd back-end +go test ./... +``` + +or, add -v for verbose output (one line per test): + +```bash +cd back-end +go test -v ./... +``` diff --git a/back-end/reviews.go b/back-end/reviews.go index 6b64740..cbcb48d 100644 --- a/back-end/reviews.go +++ b/back-end/reviews.go @@ -1,13 +1,131 @@ package main -// ReviewsData holds the reviews data. You should modify this type. -type ReviewsData struct{} +import ( + "encoding/csv" + "fmt" + "io" + "log" + "os" + "sort" + "strings" +) + +var expectedCSVHeaders = []string{"COLLEGE_UUID", "COLLEGE_NAME", "COLLEGE_URL", "REVIEW_TEXT"} + +type Review struct { + Text string `json:"text"` +} + +type CollegeReview struct { + ID string `json:"id"` + Name string `json:"name"` + URL string `json:"url"` + Reviews []Review `json:"reviews"` +} + +type ReviewsData struct { + reviewsByCollegeURLMap map[string][]CollegeReview + reviewsByCollegeIdMap map[string][]CollegeReview + sortedCollegeNames []string + collegeURLByNameMap map[string]string +} func loadReviews() (*ReviewsData, error) { - // TODO: Implement this function to load reviews from the CSV file - // 1. Read the CSV file from data/niche_reviews.csv - // 2. Parse the CSV data to extract college names and reviews - // 3. Build and return a ReviewsData struct with the processed data + return loadReviewsFromFile("data/niche_reviews.csv", func(path string) (io.ReadCloser, error) { + return os.Open(path) + }) +} + +func loadReviewsFromFile(path string, openFn func(string) (io.ReadCloser, error)) (*ReviewsData, error) { + r, err := openFn(path) + if err != nil { + log.Printf("Error opening reviews CSV file: %v", err) + return nil, err + } + defer r.Close() + return parseReviewsCSV(r) +} + +func parseReviewsCSV(r io.Reader) (*ReviewsData, error) { + rows, err := csv.NewReader(r).ReadAll() + if err != nil { + log.Printf("Error reading reviews CSV file: %v", err) + return nil, err + } + + if len(rows) == 0 || !headersMatch(rows[0], expectedCSVHeaders) { + return nil, fmt.Errorf("CSV file does not follow the expected schema: expected headers %v", expectedCSVHeaders) + } + + dataRows := rows[1:] + if len(dataRows) == 0 { + return nil, nil + } + + reviewsByIdMap := make(map[string][]Review) + collegeByIdMap := make(map[string]CollegeReview) + + for _, row := range dataRows { + id, name, url, reviewText := row[0], row[1], row[2], row[3] + + if id == "" || name == "" || url == "" { + continue + } + + if reviewText != "" { + reviewsByIdMap[id] = append(reviewsByIdMap[id], Review{Text: reviewText}) + } + + if _, seen := collegeByIdMap[id]; !seen { + collegeByIdMap[id] = CollegeReview{ID: id, Name: toTitleCase(name), URL: url} + } + } + + reviewsByCollegeIdMap := make(map[string][]CollegeReview, len(collegeByIdMap)) + reviewsByCollegeURLMap := make(map[string][]CollegeReview, len(collegeByIdMap)) + sortedCollegeNames := make([]string, 0, len(collegeByIdMap)) + collegeURLByNameMap := make(map[string]string, len(collegeByIdMap)) + + for id, college := range collegeByIdMap { + reviews := reviewsByIdMap[id] + if reviews == nil { + reviews = []Review{} + } + college.Reviews = reviews + reviewsByCollegeIdMap[id] = []CollegeReview{college} + reviewsByCollegeURLMap[college.URL] = []CollegeReview{college} + sortedCollegeNames = append(sortedCollegeNames, college.Name) + collegeURLByNameMap[college.Name] = college.URL + } + + sort.Strings(sortedCollegeNames) + + return &ReviewsData{ + reviewsByCollegeURLMap: reviewsByCollegeURLMap, + reviewsByCollegeIdMap: reviewsByCollegeIdMap, + sortedCollegeNames: sortedCollegeNames, + collegeURLByNameMap: collegeURLByNameMap, + }, nil +} + +func toTitleCase(s string) string { + words := strings.Fields(s) + for i, word := range words { + if len(word) > 0 { + words[i] = strings.ToUpper(string(word[0])) + strings.ToLower(word[1:]) + } + } + return strings.Join(words, " ") +} - return nil, nil +func headersMatch(actual, expected []string) bool { + if len(actual) != len(expected) { + return false + } + for i, h := range expected { + if actual[i] != h { + return false + } + } + return true } diff --git a/back-end/reviews_test.go b/back-end/reviews_test.go new file mode 100644 index 0000000..a5ada22 --- /dev/null +++ b/back-end/reviews_test.go @@ -0,0 +1,206 @@ +package main + +import ( + "errors" + "io" + "os" + "strings" + "testing" +) + +const csvHeader = "COLLEGE_UUID,COLLEGE_NAME,COLLEGE_URL,REVIEW_TEXT\n" + +const singleRowCSV = csvHeader + + "uuid-1,Some College,some-college,A great review\n" + +const multipleRowsCSV = csvHeader + + "uuid-1,College B,college-b,Review B1\n" + + "uuid-2,College A,college-a,Review A1\n" + + "uuid-1,College B,college-b,Review B2\n" + + "uuid-2,College A,college-a,\n" + +const wrongSchemaCSV = "ID,NAME,URL,TEXT\n" + + "uuid-1,Some College,some-college,A review\n" + +const inconsistentColumnsCSV = csvHeader + + "uuid-1,Some College,some-college,A review,extra-column\n" + +type errorReader struct{ err error } + +func (e *errorReader) Read(_ []byte) (int, error) { return 0, e.err } + +type trackingReadCloser struct { + io.Reader + closed bool +} + +func (t *trackingReadCloser) Close() error { + t.closed = true + return nil +} + +func TestLoadReviews_GenericOpenError(t *testing.T) { + openErr := errors.New("generic open error") + + _, err := loadReviewsFromFile("any-path", func(_ string) (io.ReadCloser, error) { + return nil, openErr + }) + + if err == nil { + t.Fatal("expected an error, got nil") + } + if !errors.Is(err, openErr) { + t.Fatalf("expected wrapped error %v, got %v", openErr, err) + } +} + +func TestLoadReviews_FileNotExist(t *testing.T) { + _, err := loadReviewsFromFile("/nonexistent/path/niche_reviews.csv", func(path string) (io.ReadCloser, error) { + return os.Open(path) + }) + + if err == nil { + t.Fatal("expected an error, got nil") + } + if !os.IsNotExist(err) { + t.Fatalf("expected a file-not-exist error, got %v", err) + } +} + +func TestLoadReviews_GenericReadError(t *testing.T) { + readErr := errors.New("generic read error") + + _, err := parseReviewsCSV(&errorReader{err: readErr}) + + if err == nil { + t.Fatal("expected an error, got nil") + } +} + +func TestLoadReviews_InconsistentColumnCount(t *testing.T) { + _, err := parseReviewsCSV(strings.NewReader(inconsistentColumnsCSV)) + + if err == nil { + t.Fatal("expected an error, got nil") + } +} + +func TestLoadReviews_WrongSchema(t *testing.T) { + _, err := parseReviewsCSV(strings.NewReader(wrongSchemaCSV)) + + if err == nil { + t.Fatal("expected an error, got nil") + } +} + +func TestLoadReviews_HeaderOnly(t *testing.T) { + result, err := parseReviewsCSV(strings.NewReader(csvHeader)) + + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if result != nil { + t.Fatalf("expected nil result, got %+v", result) + } +} + +func TestLoadReviews_SingleRow(t *testing.T) { + result, err := parseReviewsCSV(strings.NewReader(singleRowCSV)) + + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if result == nil { + t.Fatal("expected a ReviewsData result, got nil") + } + + idResults := result.reviewsByCollegeIdMap["uuid-1"] + if len(idResults) != 1 { + t.Fatalf("reviewsByCollegeIdMap[uuid-1]: want 1 college, got %d", len(idResults)) + } + + college := idResults[0] + if college.ID != "uuid-1" { + t.Errorf("college.id: want %q, got %q", "uuid-1", college.ID) + } + if college.Name != "Some College" { + t.Errorf("college.name: want %q, got %q", "Some College", college.Name) + } + if college.URL != "some-college" { + t.Errorf("college.url: want %q, got %q", "some-college", college.URL) + } + if len(college.Reviews) != 1 { + t.Fatalf("college.reviews: want 1 review, got %d", len(college.Reviews)) + } + if college.Reviews[0].Text != "A great review" { + t.Errorf("review text: want %q, got %q", "A great review", college.Reviews[0].Text) + } + + if len(result.reviewsByCollegeURLMap["some-college"]) != 1 { + t.Errorf("reviewsByCollegeURLMap[some-college]: want 1 college, got %d", len(result.reviewsByCollegeURLMap["some-college"])) + } + + if len(result.sortedCollegeNames) != 1 || result.sortedCollegeNames[0] != "Some College" { + t.Errorf("sortedCollegeNames: want [Some College], got %v", result.sortedCollegeNames) + } +} + +func TestLoadReviews_MultipleRows(t *testing.T) { + result, err := parseReviewsCSV(strings.NewReader(multipleRowsCSV)) + + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if result == nil { + t.Fatal("expected a ReviewsData result, got nil") + } + + if len(result.reviewsByCollegeIdMap) != 2 { + t.Fatalf("reviewsByCollegeIdMap: want 2 colleges, got %d", len(result.reviewsByCollegeIdMap)) + } + + if len(result.reviewsByCollegeIdMap["uuid-1"][0].Reviews) != 2 { + t.Errorf("reviewsByCollegeIdMap[uuid-1] reviews: want 2, got %d", len(result.reviewsByCollegeIdMap["uuid-1"][0].Reviews)) + } + if len(result.reviewsByCollegeIdMap["uuid-2"][0].Reviews) != 1 { + t.Errorf("reviewsByCollegeIdMap[uuid-2] reviews: want 1, got %d", len(result.reviewsByCollegeIdMap["uuid-2"][0].Reviews)) + } + + if len(result.reviewsByCollegeURLMap["college-b"][0].Reviews) != 2 { + t.Errorf("reviewsByCollegeURLMap[college-b] reviews: want 2, got %d", len(result.reviewsByCollegeURLMap["college-b"][0].Reviews)) + } + if len(result.reviewsByCollegeURLMap["college-a"][0].Reviews) != 1 { + t.Errorf("reviewsByCollegeURLMap[college-a] reviews: want 1, got %d", len(result.reviewsByCollegeURLMap["college-a"][0].Reviews)) + } + + if len(result.sortedCollegeNames) != 2 { + t.Fatalf("sortedCollegeNames: want 2 names, got %d", len(result.sortedCollegeNames)) + } + if result.sortedCollegeNames[0] != "College A" || result.sortedCollegeNames[1] != "College B" { + t.Errorf("sortedCollegeNames: want [College A College B], got %v", result.sortedCollegeNames) + } +} + +func TestLoadReviews_ClosesFileAfterSuccess(t *testing.T) { + tracker := &trackingReadCloser{Reader: strings.NewReader(singleRowCSV)} + + loadReviewsFromFile("any-path", func(_ string) (io.ReadCloser, error) { + return tracker, nil + }) + + if !tracker.closed { + t.Error("expected Close to be called after a successful read, but it was not") + } +} + +func TestLoadReviews_ClosesFileAfterReadError(t *testing.T) { + tracker := &trackingReadCloser{Reader: &errorReader{err: errors.New("read error")}} + + loadReviewsFromFile("any-path", func(_ string) (io.ReadCloser, error) { + return tracker, nil + }) + + if !tracker.closed { + t.Error("expected Close to be called even after a read error, but it was not") + } +} diff --git a/back-end/server.go b/back-end/server.go index d45900a..cf140b2 100644 --- a/back-end/server.go +++ b/back-end/server.go @@ -1,7 +1,9 @@ package main import ( + "encoding/json" "net/http" + "strings" ) // Server represents the HTTP server for our application @@ -10,6 +12,15 @@ type Server struct { ReviewsData *ReviewsData } +type errorResponse struct { + Message string `json:"message"` +} + +type autocompleteResult struct { + Name string `json:"name"` + URL string `json:"url"` +} + // NewServer creates a new HTTP server with the given reviews data func NewServer(reviewsData *ReviewsData) *Server { server := &Server{ @@ -24,30 +35,67 @@ func NewServer(reviewsData *ReviewsData) *Server { return server } -// handleAutocomplete handles the autocomplete endpoint func (s *Server) handleAutocomplete(w http.ResponseWriter, r *http.Request) { - // Set response headers w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") - // TODO: Implement the autocomplete functionality - // This should search through the colleges in ReviewsData - // and return matches based on the query string + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + name := r.URL.Query().Get("name") + if name == "" { + writeError(w, http.StatusBadRequest, "name is required") + return + } + + matches := []autocompleteResult{} + if s.ReviewsData != nil { + nameLower := strings.ToLower(name) + for _, collegeName := range s.ReviewsData.sortedCollegeNames { + if strings.Contains(strings.ToLower(collegeName), nameLower) { + matches = append(matches, autocompleteResult{ + Name: collegeName, + URL: s.ReviewsData.collegeURLByNameMap[collegeName], + }) + } + } + } - // Write a simple 200 OK with empty JSON response for now - w.Write([]byte("{}")) + json.NewEncoder(w).Encode(matches) } -// handleGetReviews handles the reviews endpoint func (s *Server) handleGetReviews(w http.ResponseWriter, r *http.Request) { - // Set response headers w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") - // TODO: Implement the reviews endpoint - // This should retrieve reviews for a specific college - // and return them in the response + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + url := r.URL.Query().Get("url") + if url == "" { + writeError(w, http.StatusBadRequest, "url is required") + return + } + + if s.ReviewsData == nil { + writeError(w, http.StatusNotFound, "reviews not found") + return + } + + collegeReviews, exists := s.ReviewsData.reviewsByCollegeURLMap[url] + if !exists { + writeError(w, http.StatusNotFound, "reviews not found") + return + } + + json.NewEncoder(w).Encode(collegeReviews) +} - // Write a simple 200 OK with empty JSON response for now - w.Write([]byte("{}")) +func writeError(w http.ResponseWriter, status int, message string) { + w.WriteHeader(status) + json.NewEncoder(w).Encode(errorResponse{Message: message}) } diff --git a/back-end/server_test.go b/back-end/server_test.go new file mode 100644 index 0000000..ece090b --- /dev/null +++ b/back-end/server_test.go @@ -0,0 +1,248 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +const reviewsCSVHeader = "COLLEGE_UUID,COLLEGE_NAME,COLLEGE_URL,REVIEW_TEXT\n" + +func newServerFromCSV(t *testing.T, csv string) *Server { + t.Helper() + reviewsData, err := parseReviewsCSV(strings.NewReader(csv)) + if err != nil { + t.Fatalf("newServerFromCSV: failed to parse CSV: %v", err) + } + return NewServer(reviewsData) +} + +func assertErrorResponse(t *testing.T, rec *httptest.ResponseRecorder, wantStatus int, wantMessage string) { + t.Helper() + if rec.Code != wantStatus { + t.Errorf("expected status %d, got %d", wantStatus, rec.Code) + } + var body struct { + Message string `json:"message"` + } + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("failed to decode error response body: %v", err) + } + if body.Message != wantMessage { + t.Errorf("message: want %q, got %q", wantMessage, body.Message) + } +} + +type autocompleteItem struct { + Name string `json:"name"` + URL string `json:"url"` +} + +func assertAutocompleteResponse(t *testing.T, rec *httptest.ResponseRecorder, want []autocompleteItem) { + t.Helper() + if rec.Code != http.StatusOK { + t.Errorf("expected status %d, got %d", http.StatusOK, rec.Code) + } + var got []autocompleteItem + if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { + t.Fatalf("failed to decode autocomplete response body: %v", err) + } + if len(got) != len(want) { + t.Fatalf("expected %d results, got %d: %v", len(want), len(got), got) + } + for i, w := range want { + if got[i].Name != w.Name { + t.Errorf("results[%d].name: want %q, got %q", i, w.Name, got[i].Name) + } + if got[i].URL != w.URL { + t.Errorf("results[%d].url: want %q, got %q", i, w.URL, got[i].URL) + } + } +} + +func TestHandleAutocomplete_MethodNotAllowed(t *testing.T) { + server := newServerFromCSV(t, reviewsCSVHeader+"id-1,Some College,some-college,A review\n") + + for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch} { + req := httptest.NewRequest(method, "/autocomplete?name=Some", nil) + rec := httptest.NewRecorder() + server.Router.ServeHTTP(rec, req) + assertErrorResponse(t, rec, http.StatusMethodNotAllowed, "method not allowed") + } +} + +func TestHandleAutocomplete_MissingName(t *testing.T) { + server := newServerFromCSV(t, reviewsCSVHeader+"id-1,Some College,some-college,A review\n") + + req := httptest.NewRequest(http.MethodGet, "/autocomplete", nil) + rec := httptest.NewRecorder() + server.Router.ServeHTTP(rec, req) + + assertErrorResponse(t, rec, http.StatusBadRequest, "name is required") +} + +func TestHandleAutocomplete_NilReviewsData(t *testing.T) { + server := NewServer(nil) + + req := httptest.NewRequest(http.MethodGet, "/autocomplete?name=Harvard", nil) + rec := httptest.NewRecorder() + server.Router.ServeHTTP(rec, req) + + assertAutocompleteResponse(t, rec, []autocompleteItem{}) +} + +func TestHandleAutocomplete_UpperCaseInputFullMatch(t *testing.T) { + server := newServerFromCSV(t, reviewsCSVHeader+"id-1,HARVARD UNIVERSITY,harvard-university,A review\n") + + req := httptest.NewRequest(http.MethodGet, "/autocomplete?name=HARVARD+UNIVERSITY", nil) + rec := httptest.NewRecorder() + server.Router.ServeHTTP(rec, req) + + assertAutocompleteResponse(t, rec, []autocompleteItem{{Name: "Harvard University", URL: "harvard-university"}}) +} + +func TestHandleAutocomplete_LowerCaseInputFullMatch(t *testing.T) { + server := newServerFromCSV(t, reviewsCSVHeader+"id-1,harvard university,harvard-university,A review\n") + + req := httptest.NewRequest(http.MethodGet, "/autocomplete?name=harvard+university", nil) + rec := httptest.NewRecorder() + server.Router.ServeHTTP(rec, req) + + assertAutocompleteResponse(t, rec, []autocompleteItem{{Name: "Harvard University", URL: "harvard-university"}}) +} + +func TestHandleAutocomplete_PartialMatchSingleResult(t *testing.T) { + csv := reviewsCSVHeader + + "id-1,Harvard University,harvard-university,A review\n" + + "id-2,Yale University,yale-university,A review\n" + server := newServerFromCSV(t, csv) + + req := httptest.NewRequest(http.MethodGet, "/autocomplete?name=var", nil) + rec := httptest.NewRecorder() + server.Router.ServeHTTP(rec, req) + + assertAutocompleteResponse(t, rec, []autocompleteItem{{Name: "Harvard University", URL: "harvard-university"}}) +} + +func TestHandleAutocomplete_PartialMatchMultipleResults(t *testing.T) { + csv := reviewsCSVHeader + + "id-1,Harvard University,harvard-university,A review\n" + + "id-2,Stanford University,stanford-university,A review\n" + + "id-3,Yale College,yale-college,A review\n" + server := newServerFromCSV(t, csv) + + req := httptest.NewRequest(http.MethodGet, "/autocomplete?name=university", nil) + rec := httptest.NewRecorder() + server.Router.ServeHTTP(rec, req) + + assertAutocompleteResponse(t, rec, []autocompleteItem{ + {Name: "Harvard University", URL: "harvard-university"}, + {Name: "Stanford University", URL: "stanford-university"}, + }) +} + +func TestHandleGetReviews_MethodNotAllowed(t *testing.T) { + server := newServerFromCSV(t, reviewsCSVHeader+"id-1,Some College,some-college,A review\n") + + for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch} { + req := httptest.NewRequest(method, "/reviews?url=some-college", nil) + rec := httptest.NewRecorder() + server.Router.ServeHTTP(rec, req) + assertErrorResponse(t, rec, http.StatusMethodNotAllowed, "method not allowed") + } +} + +func TestHandleGetReviews_MissingURL(t *testing.T) { + server := newServerFromCSV(t, reviewsCSVHeader+"id-1,Some College,some-college,A review\n") + + req := httptest.NewRequest(http.MethodGet, "/reviews", nil) + rec := httptest.NewRecorder() + server.Router.ServeHTTP(rec, req) + + assertErrorResponse(t, rec, http.StatusBadRequest, "url is required") +} + +func TestHandleGetReviews_NilReviewsData(t *testing.T) { + server := newServerFromCSV(t, reviewsCSVHeader) + + req := httptest.NewRequest(http.MethodGet, "/reviews?url=some-college", nil) + rec := httptest.NewRecorder() + server.Router.ServeHTTP(rec, req) + + assertErrorResponse(t, rec, http.StatusNotFound, "reviews not found") +} + +func TestHandleGetReviews_MissingURLAndNilReviewsData(t *testing.T) { + server := newServerFromCSV(t, reviewsCSVHeader) + + req := httptest.NewRequest(http.MethodGet, "/reviews", nil) + rec := httptest.NewRecorder() + server.Router.ServeHTTP(rec, req) + + assertErrorResponse(t, rec, http.StatusBadRequest, "url is required") +} + +func TestHandleGetReviews_URLNotFoundInMap(t *testing.T) { + server := newServerFromCSV(t, reviewsCSVHeader+"id-1,Some College,some-college,A review\n") + + req := httptest.NewRequest(http.MethodGet, "/reviews?url=nonexistent-college", nil) + rec := httptest.NewRecorder() + server.Router.ServeHTTP(rec, req) + + assertErrorResponse(t, rec, http.StatusNotFound, "reviews not found") +} + +func TestHandleGetReviews_Success(t *testing.T) { + csv := reviewsCSVHeader + + "id-1,Some College,some-college,Great place\n" + + "id-1,Some College,some-college,Loved it\n" + server := newServerFromCSV(t, csv) + + req := httptest.NewRequest(http.MethodGet, "/reviews?url=some-college", nil) + rec := httptest.NewRecorder() + server.Router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("expected status %d, got %d", http.StatusOK, rec.Code) + } + if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { + t.Errorf("expected Content-Type application/json, got %q", ct) + } + + var result []struct { + ID string `json:"id"` + Name string `json:"name"` + URL string `json:"url"` + Reviews []struct { + Text string `json:"text"` + } `json:"reviews"` + } + if err := json.NewDecoder(rec.Body).Decode(&result); err != nil { + t.Fatalf("failed to decode response body: %v", err) + } + if len(result) != 1 { + t.Fatalf("expected 1 college in response, got %d", len(result)) + } + + college := result[0] + if college.ID != "id-1" { + t.Errorf("id: want %q, got %q", "id-1", college.ID) + } + if college.Name != "Some College" { + t.Errorf("name: want %q, got %q", "Some College", college.Name) + } + if college.URL != "some-college" { + t.Errorf("url: want %q, got %q", "some-college", college.URL) + } + if len(college.Reviews) != 2 { + t.Fatalf("reviews: want 2, got %d", len(college.Reviews)) + } + if college.Reviews[0].Text != "Great place" { + t.Errorf("reviews[0].text: want %q, got %q", "Great place", college.Reviews[0].Text) + } + if college.Reviews[1].Text != "Loved it" { + t.Errorf("reviews[1].text: want %q, got %q", "Loved it", college.Reviews[1].Text) + } +} diff --git a/front-end/src/app/components/AutocompleteList.tsx b/front-end/src/app/components/AutocompleteList.tsx new file mode 100644 index 0000000..4d61c36 --- /dev/null +++ b/front-end/src/app/components/AutocompleteList.tsx @@ -0,0 +1,95 @@ +import { useEffect, useRef } from "react"; +import { AutocompleteOption } from "../types"; + +interface AutocompleteListProps { + options: AutocompleteOption[]; + activeIndex: number; + onSelect: (option: AutocompleteOption) => void; + onActiveIndexChange: (index: number) => void; +} + +export function AutocompleteList({ + options, + activeIndex, + onSelect, + onActiveIndexChange, +}: AutocompleteListProps) { + const listRef = useRef(null); + + useEffect(() => { + if (activeIndex < 0 || !listRef.current) return; + const item = listRef.current.children[activeIndex] as + | HTMLElement + | undefined; + item?.scrollIntoView({ block: "nearest" }); + }, [activeIndex]); + + if (options.length === 0) return null; + + return ( + + ); +} diff --git a/front-end/src/app/components/CollegeInput.tsx b/front-end/src/app/components/CollegeInput.tsx new file mode 100644 index 0000000..3f84c1b --- /dev/null +++ b/front-end/src/app/components/CollegeInput.tsx @@ -0,0 +1,113 @@ +import { forwardRef } from "react"; +import { AutocompleteOption } from "../types"; + +interface CollegeInputProps { + value: string; + isOpen: boolean; + activeIndex: number; + options: AutocompleteOption[]; + onChange: (value: string) => void; + onSelect: (option: AutocompleteOption) => void; + onActiveIndexChange: (index: number) => void; + onDismiss: () => void; +} + +export const CollegeInput = forwardRef( + function CollegeInput( + { + value, + isOpen, + activeIndex, + options, + onChange, + onSelect, + onActiveIndexChange, + onDismiss, + }, + ref + ) { + function handleKeyDown(e: React.KeyboardEvent) { + if (!isOpen || options.length === 0) return; + + if (e.key === "ArrowDown") { + e.preventDefault(); + onActiveIndexChange((activeIndex + 1) % options.length); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + onActiveIndexChange( + activeIndex <= 0 ? options.length - 1 : activeIndex - 1 + ); + } else if (e.key === "Enter" && activeIndex >= 0) { + e.preventDefault(); + onSelect(options[activeIndex]); + } else if (e.key === "Escape") { + onDismiss(); + } + } + + return ( +
+ + onChange(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Search a college or university..." + aria-label="College name" + aria-autocomplete="list" + aria-expanded={isOpen} + aria-activedescendant={ + activeIndex >= 0 ? `option-${activeIndex}` : undefined + } + autoComplete="off" + autoCorrect="off" + autoCapitalize="off" + spellCheck={false} + style={{ + width: "100%", + padding: "12px 14px 12px 44px", + fontSize: 16, + fontFamily: "inherit", + color: "#464646", + border: "1.5px solid #d0d0d0", + borderBottom: isOpen ? "1.5px solid #d0d0d0" : "1.5px solid #d0d0d0", + borderRadius: isOpen ? "6px 6px 0 0" : 6, + boxSizing: "border-box", + outline: "none", + backgroundColor: "#fff", + transition: "border-color 0.15s ease", + boxShadow: isOpen + ? "0 2px 8px rgba(0,0,0,0.08)" + : "0 1px 3px rgba(0,0,0,0.06)", + }} + onFocus={(e) => { + e.currentTarget.style.borderColor = "#346dc2"; + }} + onBlur={(e) => { + e.currentTarget.style.borderColor = "#d0d0d0"; + }} + /> +
+ ); + } +); diff --git a/front-end/src/app/globals.css b/front-end/src/app/globals.css index 96ba8a3..a0503bc 100644 --- a/front-end/src/app/globals.css +++ b/front-end/src/app/globals.css @@ -1,5 +1,24 @@ +@import url('https://fonts.googleapis.com/css2?family=Source+Sans+Pro:ital,wght@0,400;0,600;0,700;1,400&display=swap'); + * { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: "Source Sans Pro", sans-serif; + background-color: #f5f7f9; + color: #464646; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + color: #346dc2; + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} diff --git a/front-end/src/app/page.tsx b/front-end/src/app/page.tsx index b42a1f5..a43f8b3 100644 --- a/front-end/src/app/page.tsx +++ b/front-end/src/app/page.tsx @@ -1,7 +1,321 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { CollegeInput } from "./components/CollegeInput"; +import { AutocompleteList } from "./components/AutocompleteList"; +import { AutocompleteOption, CollegeReview, Review } from "./types"; + +const API_BASE = "http://localhost:8080"; + +interface CollegeSearchProps { + query: string; + options: AutocompleteOption[] | null; + activeIndex: number; + inputRef: React.RefObject; + onQueryChange: (value: string) => void; + onSelect: (option: AutocompleteOption) => void; + onActiveIndexChange: (index: number) => void; + onDismiss: () => void; +} + +function CollegeSearch({ + query, + options, + activeIndex, + inputRef, + onQueryChange, + onSelect, + onActiveIndexChange, + onDismiss, +}: CollegeSearchProps) { + return ( +
+ + + {options !== null && ( + + )} +
+ ); +} + +function ReviewItem({ review }: { review: Review }) { + return ( +
  • +

    {review.text}

    +
  • + ); +} + +function CollegeReviews({ college }: { college: CollegeReview }) { + const count = college.reviews.length; + + return ( + <> +
    +

    + {college.name} +

    +

    + {count} student review{count !== 1 ? "s" : ""} +

    +
    + + {count === 0 ? ( +
    +

    + No reviews available for this college. +

    +
    + ) : ( +
      + {college.reviews.map((review, i) => ( + + ))} +
    + )} + + ); +} + +function ReviewsSection({ + college, + loading, +}: { + college: CollegeReview | null; + loading: boolean; +}) { + if (loading) { + return ( +
    + + + + + Loading reviews… + +
    + ); + } + + if (!college) { + return ( +
    + + + +

    + Search for a college above to see student reviews. +

    +
    + ); + } + + return ; +} + export default function Home() { + const [query, setQuery] = useState(""); + const [options, setOptions] = useState(null); + const [activeIndex, setActiveIndex] = useState(-1); + const [college, setCollege] = useState(null); + const [loadingReviews, setLoadingReviews] = useState(false); + const inputRef = useRef(null); + const debounceRef = useRef | null>(null); + + useEffect(() => { + if (query.trim() === "") { + setOptions(null); + setActiveIndex(-1); + return; + } + + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(async () => { + const res = await fetch( + `${API_BASE}/autocomplete?name=${encodeURIComponent(query)}` + ); + const data: AutocompleteOption[] = await res.json(); + setOptions(data); + setActiveIndex(-1); + }, 300); + + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [query]); + + async function selectCollege(option: AutocompleteOption) { + setOptions(null); + setActiveIndex(-1); + setQuery(""); + setLoadingReviews(true); + try { + const res = await fetch( + `${API_BASE}/reviews?url=${encodeURIComponent(option.url)}` + ); + const data: CollegeReview[] = await res.json(); + setCollege(data[0] ?? null); + } finally { + setLoadingReviews(false); + inputRef.current?.focus(); + } + } + + function dismissDropdown() { + setOptions(null); + setActiveIndex(-1); + } + return ( -
    -

    Hello World

    +
    +
    +
    +

    + College Reviews +

    +

    + Find student reviews for any college or university. +

    + +
    +
    + +
    + +
    ); } diff --git a/front-end/src/app/types.ts b/front-end/src/app/types.ts new file mode 100644 index 0000000..753e62c --- /dev/null +++ b/front-end/src/app/types.ts @@ -0,0 +1,15 @@ +export interface AutocompleteOption { + name: string; + url: string; +} + +export interface Review { + text: string; +} + +export interface CollegeReview { + id: string; + name: string; + url: string; + reviews: Review[]; +}