diff --git a/.gitignore b/.gitignore index 27d9177e..e4d5814c 100644 --- a/.gitignore +++ b/.gitignore @@ -101,3 +101,6 @@ venv.bak/ # mypy .mypy_cache/ + +# node_modules +node_modules \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 4c464fdf..d7f63bbb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,6 +34,9 @@ WORKDIR /app COPY ./requirements.txt /app/requirements.txt RUN pip install --no-cache-dir -r requirements.txt +# Install npm +RUN apt-get update && apt-get install -y npm + # Install Node requirements COPY ./package.json /app/package.json RUN npm install diff --git a/README.md b/README.md index a6b6a2a3..66517f54 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,14 @@ this assessment. To get started, fork this repo and follow the instructions below. +## Completed By + +Hi, my name is [Dan Grossberg](https://github.com/dwgrossberg), and I'm grateful that you're taking the time to review +my application code challenge. Changes have been committed throughout the dev process +and there are numerous comments explaining my thought process where applicable. +Please feel free to reach out with any questions or comments and I will be happy to +answer! + ## Installation Development requires a local installation of [Docker](https://docs.docker.com/install/) diff --git a/package-lock.json b/package-lock.json index 04492877..b5cd3533 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1043,4 +1043,4 @@ "dev": true } } -} +} \ No newline at end of file diff --git a/package.json b/package.json index 16980833..8cba86cc 100644 --- a/package.json +++ b/package.json @@ -4,5 +4,9 @@ "description": "JavaScript development setup for the DataMade Code Challenge", "devDependencies": { "eslint": "^7.23.0" + }, + "dependencies": { + "docker": "^1.0.0", + "docker-compose": "^0.24.8" } } diff --git a/parserator_web/static/css/custom.css b/parserator_web/static/css/custom.css index 79084665..d870a871 100644 --- a/parserator_web/static/css/custom.css +++ b/parserator_web/static/css/custom.css @@ -1 +1,21 @@ /* Add custom style overrides here. */ +#error-container { + background-color: #e34d3f; + width: 100%; + padding: 7px 13px; + margin-top: 10px; + border-radius: 5px; + gap: 5px; +} + +#close-button, #error-message { + font-family: inherit; + font-size: inherit; + color: white; + background-color: #e34d3f; + border: none; +} + +#error-message { + margin-top: 13px; +} \ No newline at end of file diff --git a/parserator_web/static/js/index.js b/parserator_web/static/js/index.js index 492674cc..ca0a70c7 100644 --- a/parserator_web/static/js/index.js +++ b/parserator_web/static/js/index.js @@ -1,2 +1,111 @@ -/* TODO: Flesh this out to connect the form to the API and render results - in the #address-results div. */ +// This function triggers an eslint Parsing error: The keyword 'const' is reserved, +// which I was unable to resolve in a satisfactory manner +const parseAddress = async (address) => { + /** + * Sends a user-inputted address to the api/parse API endpoint and calls a function to display + * the results or the error, if applicable. + * @param {string} address The address to parse, input by the user + */ + const url = "api/parse/?address=" + address + try { + const response = await fetch(url, { + method: "GET" + }); + // Check that the response is valid + if (!response.ok) { + throw new Error(`Response status: ${response.status}`); + } else { + const data = await response.json(); + // Check for input string API errors + if (data.Error) { + displayError(data.Error); + } else if (data.ParseError) { + displayError(data.ParseError); + } else if (data.RepeatedLabelError) { + displayError(data.RepeatedLabelError); + // Otherwise display the address parse results + } else { + displayAddress(data); + } + displayAddress(data); + } + // Catch any error messages -- 500 statusq + } catch (error) { + displayError(error.message); + } +}; + +const displayAddress = (addressData) => { + /** + * Displays the address response to the DOM if the parse was successful. + * @param {Object} addressData A dictionary object containing the input_string, + * address_components, and address_type. + */ + // Clear input value + document.getElementById("address").value = ""; + // Remove error if applicable + document.getElementById("error-container").style.display = "None"; + const addressResults = document.getElementById("address-results"); + // Show results table + addressResults.style.display = "block"; + // Display address type + document.getElementById("parse-type").innerText = addressData.address_type; + // Loop through address_components and add new rows to results table + const tableBody = document.getElementsByTagName('tbody')[0]; + // Clear previous search results + while (tableBody.firstChild) { + tableBody.removeChild(tableBody.lastChild); + } + for (let [tag, part] of Object.entries(addressData.address_components)) { + let resultsRow = document.createElement("tr"); + let partData = document.createElement("td"); + let tagData = document.createElement("td"); + partData.innerText = part; + tagData.innerText = tag; + resultsRow.appendChild(partData); + resultsRow.appendChild(tagData); + tableBody.appendChild(resultsRow); + } +} + +const displayError = (error) => { + /** + * Displays any errors encountered to the user via the DOM + * @param {Error} error The error data returned from the api/parse API endpoint + */ + // Clear address results if applicable + document.getElementById("address-results").style.display = "None"; + const errorContainer = document.getElementById("error-container"); + const errorText = document.getElementById("error-message"); + errorContainer.style.display = "flex"; + if (error === "Response status: 500") { + errorText.textContent = "Unable to parse this value due to repeated labels. Our team has been notified of the error."; + } else if (error === "no_value") { + errorText.textContent = "Please provide a value to parse." + } else { + errorText.textContent = error; + } +} + +// Handle form submission +const addressForm = document.getElementsByTagName("form")[0]; +console.log(addressForm); +addressForm.addEventListener("submit", (e) => { + e.preventDefault(); + const addressData = new FormData(e.target).get("address"); + // Check for blank string or string of whitespace + if (addressData) { + parseAddress(addressData); + } else { + // Otherwise display error message + displayError("no_value") + } +}) + +// Handle close error message event +const errorContainer = document.getElementById("error-container"); +const errorCloseButton = document.getElementById("close-button"); +errorCloseButton.addEventListener("mousedown", () => { + document.getElementById("address").value = ""; + errorContainer.style.display = "None"; +}) \ No newline at end of file diff --git a/parserator_web/templates/parserator_web/index.html b/parserator_web/templates/parserator_web/index.html index a72d9c80..073c155a 100644 --- a/parserator_web/templates/parserator_web/index.html +++ b/parserator_web/templates/parserator_web/index.html @@ -14,6 +14,11 @@

U.S. addres
{% csrf_token %} + +
diff --git a/parserator_web/views.py b/parserator_web/views.py index 0be3f4a9..c7d44b3f 100644 --- a/parserator_web/views.py +++ b/parserator_web/views.py @@ -1,4 +1,5 @@ import usaddress +from usaddress import RepeatedLabelError from django.views.generic import TemplateView from rest_framework.views import APIView from rest_framework.response import Response @@ -11,14 +12,55 @@ class Home(TemplateView): class AddressParse(APIView): + """ + A view class that represents a parsed US address. + """ renderer_classes = [JSONRenderer] def get(self, request): - # TODO: Flesh out this method to parse an address string using the - # parse() method and return the parsed components to the frontend. - return Response({}) + """ + Turns a request into an API response containing a parsed US address. + + Args: + request (HTTP request): Input string containing an address to be parsed. + + Raises: + ParseError: Checks that query_params are valid otherwise raises ParseError. + + Returns: + HTTP response: A response object containing input_string, address_components, + and address_type. + """ + # Address string passed in via request params + address = request.query_params.get('address') + # Check that request params have valid structure + if 'address' not in request.query_params: + raise ParseError + # Create the response values + try: + address_components, address_type = self.parse(address) + return Response({ + 'input_string': address, + 'address_components': address_components, + 'address_type': address_type + }) + # Handle exceptions and errors + except (RepeatedLabelError, TypeError): + return Response({'RepeatedLabelError': 'Unable to parse this value due to ' + 'repeated labels. Our team has been notified of the error.'}, + status=400) + except Exception as e: + return Response({'Error': 'Error ' + e}, status=400) def parse(self, address): - # TODO: Implement this method to return the parsed components of a - # given address using usaddress: https://github.com/datamade/usaddress + """ + Method for parsing US addresses via the usaddress module. + + Args: + address (string): The address to be parsed. + + Returns: + Object: parsed address_components and address_type. + """ + address_components, address_type = usaddress.tag(address) return address_components, address_type diff --git a/tests/test_views.py b/tests/test_views.py index bfd5d0b7..55b68295 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -1,15 +1,63 @@ -import pytest +import json +from django.urls import resolve + + +def test_api_address_resolves_to_view_name(): + # Validate that the named view matches url path + resolver = resolve('/api/parse/') + assert resolver.view_name == 'address-parse' def test_api_parse_succeeds(client): # TODO: Finish this test. Send a request to the API and confirm that the # data comes back in the appropriate format. address_string = '123 main st chicago il' - pytest.fail() + # Test that data returns in the correct format + test_url = '/api/parse/?address=' + address_string + data_format = { + 'input_string': '123 main st chicago il', + 'address_components': + { + 'AddressNumber': '123', + 'StreetName': 'main', + 'StreetNamePostType': 'st', + 'PlaceName': 'chicago', + 'StateName': 'il' + }, + 'address_type': 'Street Address' + } + response = client.get(test_url) + data = json.loads(response.content) + assert response.status_code == 200 + assert data == data_format def test_api_parse_raises_error(client): # TODO: Finish this test. The address_string below will raise a # RepeatedLabelError, so ParseAddress.parse() will not be able to parse it. address_string = '123 main st chicago il 123 main st' - pytest.fail() + test_url = '/api/parse/?address=' + address_string + # Check that RepeatedLabelError is raised + repeated_label_error = { + 'RepeatedLabelError': 'Unable to parse this value due to repeated labels. ' + 'Our team has been notified of the error.' + } + response = client.get(test_url) + response_data = json.loads(response.content) + assert response.status_code == 400 + assert response_data == repeated_label_error + + +def test_missing_address_query_term_raises_parse_error(client): + address_string = '123 main st chicago il' + test_url = '/api/parse/?=' + address_string + response = client.get(test_url) + assert response.status_code == 400 + + +def test_api_parse_empty_string_succeeds(client): + # Test that an empty string will generate a response status_code of 200 + address_string = '' + test_url = '/api/parse/?address=' + address_string + response = client.get(test_url) + assert response.status_code == 200