diff --git a/.devcontainer/setup.sh b/.devcontainer/setup.sh new file mode 100755 index 000000000..f7bc939f8 --- /dev/null +++ b/.devcontainer/setup.sh @@ -0,0 +1,24 @@ +WILCO_ID="`cat .wilco`" +CODESPACE_BACKEND_HOST=$(curl -s "${ENGINE_BASE_URL}/api/v1/codespace/backendHost?codespaceName=${CODESPACE_NAME}&portForwarding=${GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN}" | jq -r '.codespaceBackendHost') +CODESPACE_BACKEND_URL="https://${CODESPACE_BACKEND_HOST}" +export ENGINE_EVENT_ENDPOINT="${ENGINE_BASE_URL}/users/${WILCO_ID}/event" + +# Update engine that codespace started for user +curl -L -X POST "${ENGINE_EVENT_ENDPOINT}" -H "Content-Type: application/json" --data-raw "{ \"event\": \"github_codespace_started\" }" + +# Export backend envs when in codespaces +echo "export CODESPACE_BACKEND_HOST=\"${CODESPACE_BACKEND_HOST}\"" >> ~/.bashrc +echo "export CODESPACE_BACKEND_URL=\"${CODESPACE_BACKEND_URL}\"" >> ~/.bashrc +echo "export CODESPACE_WDS_SOCKET_PORT=443" >> ~/.bashrc + +# Export welcome prompt in bash: +echo "printf \"\n\n☁️☁️☁️️ Anythink: Develop in the Cloud ☁️☁️☁️\n\"" >> ~/.bashrc +echo "printf \"\n\x1b[31m \x1b[1m👉 Type: \\\`docker compose up\\\` to run the project. 👈\n\n\"" >> ~/.bashrc + +nohup bash -c "cd /wilco-agent && node agent.js &" >> /tmp/agent.log 2>&1 + +# Check if docker is installed +if command -v docker &> /dev/null +then + docker compose pull +fi diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index bffb357a7..000000000 --- a/.eslintrc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "extends": "next/core-web-vitals" -} diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..8ebf54968 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,3 @@ +# Description + +Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. diff --git a/.github/workflows/k8s.yml b/.github/workflows/k8s.yml new file mode 100644 index 000000000..9a56d6aa2 --- /dev/null +++ b/.github/workflows/k8s.yml @@ -0,0 +1,157 @@ +name: Build and deploy to Kubernetes +on: + push: + branches: + - main + +concurrency: + group: k8s + cancel-in-progress: true + +jobs: + check-kubernetes-enabled: + runs-on: ubuntu-20.04 + outputs: + kubernetes-enabled: ${{ steps.kubernetes-flag-defined.outputs.DEFINED }} + steps: + - id: kubernetes-flag-defined + if: "${{ env.ENABLE_KUBERNETES != '' }}" + run: echo "DEFINED=true" >> $GITHUB_OUTPUT + env: + ENABLE_KUBERNETES: ${{ secrets.ENABLE_KUBERNETES }} + + check-secret: + runs-on: ubuntu-20.04 + needs: [check-kubernetes-enabled] + outputs: + aws-creds-defined: ${{ steps.aws-creds-defined.outputs.DEFINED }} + kubeconfig-defined: ${{ steps.kubeconfig-defined.outputs.DEFINED }} + if: needs.check-kubernetes-enabled.outputs.kubernetes-enabled == 'true' + steps: + - id: aws-creds-defined + if: "${{ env.AWS_ACCESS_KEY_ID != '' && env.AWS_SECRET_ACCESS_KEY != '' }}" + run: echo "DEFINED=true" >> $GITHUB_OUTPUT + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + + - id: kubeconfig-defined + if: "${{ env.KUBECONFIG != '' }}" + run: echo "DEFINED=true" >> $GITHUB_OUTPUT + env: + KUBECONFIG: ${{ secrets.KUBECONFIG }} + + build-backend: + name: Build backend image + runs-on: ubuntu-20.04 + needs: [check-secret] + if: needs.check-secret.outputs.aws-creds-defined == 'true' + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v1-node16 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: us-east-2 + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v1 + + - name: Set the image tag + run: echo IMAGE_TAG=${GITHUB_REPOSITORY/\//-}-latest >> $GITHUB_ENV + + - name: Set repository name + run: | + if [ ${{ secrets.CLUSTER_ENV }} == 'staging' ]; then + echo "REPO_NAME=staging-anythink-backend" >> $GITHUB_ENV + else + echo "REPO_NAME=anythink-backend" >> $GITHUB_ENV + fi + + - name: Build, tag, and push backend image to Amazon ECR + id: build-image-backend + run: | + docker build \ + -t ${{ steps.login-ecr.outputs.registry }}/${{ env.REPO_NAME }}:${{ env.IMAGE_TAG }} \ + -f backend/Dockerfile.aws \ + . + docker push ${{ steps.login-ecr.outputs.registry }}/${{ env.REPO_NAME }}:${{ env.IMAGE_TAG }} + + build-frontend: + name: Build frontend images + runs-on: ubuntu-20.04 + needs: [check-secret] + if: needs.check-secret.outputs.aws-creds-defined == 'true' + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v1-node16 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: us-east-2 + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v1 + + - name: Set the image tag + run: echo IMAGE_TAG=${GITHUB_REPOSITORY/\//-}-latest >> $GITHUB_ENV + + - name: Set repository name + run: | + if [ ${{ secrets.CLUSTER_ENV }} == 'staging' ]; then + echo "REPO_NAME=staging-anythink-frontend" >> $GITHUB_ENV + else + echo "REPO_NAME=anythink-frontend" >> $GITHUB_ENV + fi + + - name: Build, tag, and push frontend image to Amazon ECR + id: build-image-frontend + run: | + docker build \ + -t ${{ steps.login-ecr.outputs.registry }}/${{ env.REPO_NAME }}:${{ env.IMAGE_TAG }} \ + -f frontend/Dockerfile.aws \ + . + docker push ${{ steps.login-ecr.outputs.registry }}/${{ env.REPO_NAME }}:${{ env.IMAGE_TAG }} + + deploy: + name: Deploy latest tag using helm + runs-on: ubuntu-20.04 + if: needs.check-secret.outputs.kubeconfig-defined == 'true' + needs: + - build-frontend + - build-backend + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Create kube config + run: | + mkdir -p $HOME/.kube/ + echo "${{ secrets.KUBECONFIG }}" > $HOME/.kube/config + chmod 600 $HOME/.kube/config + - name: Install helm + run: | + curl -LO https://get.helm.sh/helm-v3.8.0-linux-amd64.tar.gz + tar -zxvf helm-v3.8.0-linux-amd64.tar.gz + mv linux-amd64/helm /usr/local/bin/helm + helm version + - name: Lint helm charts + run: helm lint ./charts/ + + - name: Set the image tag + run: echo IMAGE_TAG=${GITHUB_REPOSITORY/\//-}-latest >> $GITHUB_ENV + + - name: Deploy + run: | + helm upgrade --install --timeout 10m anythink-market ./charts/ \ + --set clusterEnv=${{ secrets.CLUSTER_ENV }} \ + --set frontend.image.tag=${{ env.IMAGE_TAG }} \ + --set backend.image.tag=${{ env.IMAGE_TAG }} diff --git a/.github/workflows/wilco-actions.yml b/.github/workflows/wilco-actions.yml deleted file mode 100644 index b732c8681..000000000 --- a/.github/workflows/wilco-actions.yml +++ /dev/null @@ -1,24 +0,0 @@ -on: - pull_request: - branches: - - main - -jobs: - wilco: - runs-on: ubuntu-20.04 - timeout-minutes: 10 - name: Pr checks - - steps: - - name: Check out project - uses: actions/checkout@v2 - - - uses: oNaiPs/secrets-to-env-action@v1 - with: - secrets: ${{ toJSON(secrets) }} - - - name: Wilco checks - id: Wilco - uses: trywilco/actions@main - with: - engine: ${{ secrets.WILCO_ENGINE_URL }} diff --git a/.gitignore b/.gitignore index fd3dbb571..10d146d23 100644 --- a/.gitignore +++ b/.gitignore @@ -2,35 +2,36 @@ # dependencies /node_modules +/backend/node_modules +/frontend/node_modules +/.wilco-helpers/node_modules +/tests/e2e/node_modules +/tests/frontend/node_modules/ +/tests/frontend/test-results/ +/tests/frontend/playwright-report/ +/tests/frontend/playwright/.cache/ + /.pnp .pnp.js -.yarn/install-state.gz # testing /coverage -# next.js -/.next/ -/out/ - # production -/build +/backend/build +/frontend/build # misc .DS_Store -*.pem +.env +.env.local +.env.development.local +.env.test.local +.env.production.local -# debug npm-debug.log* yarn-debug.log* yarn-error.log* -# local env files -.env*.local - -# vercel -.vercel - -# typescript -*.tsbuildinfo -next-env.d.ts +#IDEs +/.idea/ diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..0c878f127 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "workbench.startupEditor": "none" +} diff --git a/README.md b/README.md deleted file mode 100644 index 4ae4281ae..000000000 --- a/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# AI Voice Translator in Next.js - -Demo for tutorial [How to Build an AI Voice Translator in Next.js with Web Speech API & OpenAI](https://www.youtube.com/watch?v=JFfCDvKiJqU) - -📝 Article: https://spacejelly.dev/posts/how-to-build-an-ai-voice-translator-in-next-js-with-web-speech-api-openai - -📺 YouTube: https://www.youtube.com/watch?v=JFfCDvKiJqU - -🚀 Demo: https://my-universal-translator.vercel.app/ - -## More tutorials and walkthroughs - -🐦 [Follow me on Twitter](https://twitter.com/colbyfayock) - -📺 [Subscribe on YouTube](https://www.youtube.com/colbyfayock) - -✉️ [Sign Up for My Newsletter](https://colbyfayock.com/newsletter) diff --git a/frontend/.eslintignore b/frontend/.eslintignore new file mode 100644 index 000000000..dd87e2d73 --- /dev/null +++ b/frontend/.eslintignore @@ -0,0 +1,2 @@ +node_modules +build diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 000000000..edfc11914 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,16 @@ +# See http://help.github.com/ignore-files/ for more about ignoring files. + +# dependencies +node_modules + +# testing +coverage + +# production +build + +# misc +.DS_Store +.env +npm-debug.log +.idea \ No newline at end of file diff --git a/frontend/Dockerfile.aws b/frontend/Dockerfile.aws new file mode 100644 index 000000000..9485ac1bc --- /dev/null +++ b/frontend/Dockerfile.aws @@ -0,0 +1,9 @@ +FROM node:16 +WORKDIR /usr/src + +COPY frontend ./frontend +COPY .wilco ./.wilco + +# Pre-install npm packages +WORKDIR /usr/src/frontend +RUN yarn install diff --git a/frontend/jest.config.js b/frontend/jest.config.js new file mode 100644 index 000000000..582fe825c --- /dev/null +++ b/frontend/jest.config.js @@ -0,0 +1,8 @@ +const config = { + verbose: true, + jest: { + setupFilesAfterEnv: ["src/setupTests.js"], + }, +}; + +module.exports = config; diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 000000000..45a643d60 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,74 @@ +{ + "name": "anythink-market-front", + "version": "0.1.0", + "engines": { + "node": "^16" + }, + "private": true, + "devDependencies": { + "@wojtekmaj/enzyme-adapter-react-17": "^0.6.7", + "core-js": "^3.25.1", + "enzyme": "^3.11.0", + "eslint-config-prettier": "^8.3.0", + "eslint-plugin-react": "^7.26.1", + "prettier": "2.4.1", + "react-test-renderer": "^17.0.2", + "redux-mock-store": "^1.5.4" + }, + "dependencies": { + "@babel/core": "^7.18.13", + "@babel/plugin-proposal-private-property-in-object": "^7.18.6", + "@babel/plugin-syntax-flow": "^7.18.6", + "@babel/plugin-transform-react-jsx": "^7.18.10", + "bootstrap": "^4.6.0", + "bootstrap-icons": "^1.7.1", + "history": "^4.6.3", + "jquery": "^3.6.1", + "marked": "^0.3.6", + "postcss": "^8.4.16", + "prop-types": "^15.5.10", + "react": "^17.0.2", + "react-dom": "^17.0.2", + "react-redux": "^5.0.7", + "react-router-dom": "^6.9.0", + "react-scripts": "^5.0.1", + "redux": "^3.6.0", + "redux-devtools-extension": "^2.13.2", + "sass": "^1.45.0", + "superagent": "^3.8.2", + "superagent-promise": "^1.1.0", + "typescript": "^4.8.2" + }, + "scripts": { + "start": "REACT_APP_WILCO_ID=${WILCO_ID:-\"$(cat ../.wilco)\"} react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --env=jsdom", + "eject": "react-scripts eject", + "format": "yarn prettier --write .", + "lint": "yarn eslint . && yarn prettier --check ." + }, + "eslintConfig": { + "extends": [ + "react-app", + "eslint:recommended" + ], + "rules": { + "no-var": "error" + } + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "resolutions": { + "autoprefixer": "10.4.5" + } +} diff --git a/frontend/public/50precentoff.png b/frontend/public/50precentoff.png new file mode 100644 index 000000000..4c835a18d Binary files /dev/null and b/frontend/public/50precentoff.png differ diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 000000000..201ae78cc Binary files /dev/null and b/frontend/public/favicon.ico differ diff --git a/frontend/public/index.html b/frontend/public/index.html new file mode 100644 index 000000000..f0441fcfc --- /dev/null +++ b/frontend/public/index.html @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + Anythink Market + + +
+
+ + diff --git a/frontend/public/placeholder.png b/frontend/public/placeholder.png new file mode 100644 index 000000000..bf1d31046 Binary files /dev/null and b/frontend/public/placeholder.png differ diff --git a/frontend/public/style.css b/frontend/public/style.css new file mode 100644 index 000000000..2bb7fe292 --- /dev/null +++ b/frontend/public/style.css @@ -0,0 +1,54 @@ +* { + -webkit-font-smoothing: antialiased; +} +body { + font-family: "Inter", sans-serif; + -ms-text-size-adjust: 100%; + -webkit-text-size-adjust: 100%; + text-rendering: optimizeLegibility; + -moz-osx-font-smoothing: grayscale; + -webkit-font-feature-settings: "kern" 1, "liga" 1; + font-feature-settings: "kern" 1, "liga" 1; + scroll-behavior: smooth; +} +.top-announcement { + background-color: #59ca00; + padding: 15px; + font-size: 18px; + color: white; +} +.logo-text { + color: #59ca00 !important; + font-weight: 600; +} +.minegeek-navbar { + background-color: #393939; +} +.sunray { + background-image: url("sunray.jpeg"); + background-size: cover; +} +.text-white { + color: white; +} +.row-eq-height { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; +} +.minegear-btn { + background-color: #59ca00; + border: 0px; + border-radius: 0px; +} +.dramaticPerson { + opacity: 0; + position: fixed; + right: -500px; + bottom: 0px; + height: 590px; + width: 490px; + z-index: 1041; + background-size: cover; +} diff --git a/frontend/public/sunray.jpeg b/frontend/public/sunray.jpeg new file mode 100644 index 000000000..f1334964a Binary files /dev/null and b/frontend/public/sunray.jpeg differ diff --git a/frontend/public/verified_seller.svg b/frontend/public/verified_seller.svg new file mode 100644 index 000000000..2e1b35306 --- /dev/null +++ b/frontend/public/verified_seller.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + diff --git a/frontend/readme.md b/frontend/readme.md new file mode 100644 index 000000000..2f5bc174f --- /dev/null +++ b/frontend/readme.md @@ -0,0 +1,26 @@ +# Anythink Frontend + +The Anythink Frontend is an SPA written with [React](https://reactjs.org/) and [Redux](https://redux.js.org/) + +## Getting started + +Make sure your server is up and running to serve requests. + +## Pages overview + +- Home page (URL: /#/ ) + - List of tags + - List of items pulled from either Feed, Global, or by Tag + - Pagination for list of items +- Sign in/Sign up pages (URL: /#/login, /#/register ) + - Use JWT (store the token in localStorage) +- Settings page (URL: /#/settings ) +- Editor page to create/edit articles (URL: /#/editor, /#/editor/slug ) +- Item page (URL: /#/item/slug ) + - Delete item button (only shown to item's author) + - Render markdown from server client side + - Comments section at bottom of page + - Delete comment button (only shown to comment's author) +- Profile page (URL: /#/@username, /#/@username/favorites ) + - Show basic user info + - List of items populated from seller's items or user favorite items diff --git a/frontend/src/agent.js b/frontend/src/agent.js new file mode 100644 index 000000000..972f3e3fe --- /dev/null +++ b/frontend/src/agent.js @@ -0,0 +1,98 @@ +import superagentPromise from "superagent-promise"; +import _superagent from "superagent"; + +const superagent = superagentPromise(_superagent, global.Promise); + +const BACKEND_URL = + process.env.NODE_ENV !== "production" + ? process.env.REACT_APP_BACKEND_URL + : "https://api.anythink.market"; + +const API_ROOT = `${BACKEND_URL}/api`; + +const encode = encodeURIComponent; +const responseBody = (res) => res.body; + +let token = null; +const tokenPlugin = (req) => { + if (token) { + req.set("authorization", `Token ${token}`); + } +}; + +const requests = { + del: (url) => + superagent.del(`${API_ROOT}${url}`).use(tokenPlugin).then(responseBody), + get: (url) => + superagent.get(`${API_ROOT}${url}`).use(tokenPlugin).then(responseBody), + put: (url, body) => + superagent + .put(`${API_ROOT}${url}`, body) + .use(tokenPlugin) + .then(responseBody), + post: (url, body) => + superagent + .post(`${API_ROOT}${url}`, body) + .use(tokenPlugin) + .then(responseBody), +}; + +const Auth = { + current: () => requests.get("/user"), + login: (email, password) => + requests.post("/users/login", { user: { email, password } }), + register: (username, email, password) => + requests.post("/users", { user: { username, email, password } }), + save: (user) => requests.put("/user", { user }), +}; + +const Tags = { + getAll: () => requests.get("/tags"), +}; + +const limit = (count, p) => `limit=${count}&offset=${p ? p * count : 0}`; +const omitSlug = (item) => Object.assign({}, item, { slug: undefined }); +const Items = { + all: (page) => requests.get(`/items?${limit(1000, page)}`), + bySeller: (seller, page) => + requests.get(`/items?seller=${encode(seller)}&${limit(500, page)}`), + byTag: (tag, page) => + requests.get(`/items?tag=${encode(tag)}&${limit(1000, page)}`), + del: (slug) => requests.del(`/items/${slug}`), + favorite: (slug) => requests.post(`/items/${slug}/favorite`), + favoritedBy: (seller, page) => + requests.get(`/items?favorited=${encode(seller)}&${limit(500, page)}`), + feed: () => requests.get("/items/feed?limit=10&offset=0"), + get: (slug) => requests.get(`/items/${slug}`), + unfavorite: (slug) => requests.del(`/items/${slug}/favorite`), + update: (item) => + requests.put(`/items/${item.slug}`, { item: omitSlug(item) }), + create: (item) => requests.post("/items", { item }), +}; + +const Comments = { + create: (slug, comment) => + requests.post(`/items/${slug}/comments`, { comment }), + delete: (slug, commentId) => + requests.del(`/items/${slug}/comments/${commentId}`), + forItem: (slug) => requests.get(`/items/${slug}/comments`), +}; + +const Profile = { + follow: (username) => requests.post(`/profiles/${username}/follow`), + get: (username) => requests.get(`/profiles/${username}`), + unfollow: (username) => requests.del(`/profiles/${username}/follow`), +}; + +const agentObj = { + Items, + Auth, + Comments, + Profile, + Tags, + setToken: (_token) => { + token = _token; + }, +}; + +export default agentObj; diff --git a/frontend/src/components/App.js b/frontend/src/components/App.js new file mode 100644 index 000000000..8b23812e3 --- /dev/null +++ b/frontend/src/components/App.js @@ -0,0 +1,81 @@ +import agent from "../agent"; +import Header from "./Header"; +import React, { useEffect } from "react"; +import { connect } from "react-redux"; +import { APP_LOAD, REDIRECT } from "../constants/actionTypes"; +import Item from "./Item"; +import Editor from "./Editor"; +import Home from "./Home"; +import Login from "./Login"; +import Profile from "./Profile"; +import ProfileFavorites from "./ProfileFavorites"; +import Register from "./Register"; +import Settings from "./Settings"; +import { Route, Routes, useNavigate } from "react-router-dom"; + +const mapStateToProps = (state) => { + return { + appLoaded: state.common.appLoaded, + appName: state.common.appName, + currentUser: state.common.currentUser, + redirectTo: state.common.redirectTo, + }; +}; + +const mapDispatchToProps = (dispatch) => ({ + onLoad: (payload, token) => + dispatch({ type: APP_LOAD, payload, token, skipTracking: true }), + onRedirect: () => dispatch({ type: REDIRECT }), +}); + +const App = (props) => { + const { redirectTo, onRedirect, onLoad } = props; + const navigate = useNavigate(); + + useEffect(() => { + if (redirectTo) { + navigate(redirectTo); + onRedirect(); + } + }, [redirectTo, onRedirect, navigate]); + + useEffect(() => { + const token = window.localStorage.getItem("jwt"); + if (token) { + agent.setToken(token); + } + onLoad(token ? agent.Auth.current() : null, token); + }, [onLoad]); + + if (props.appLoaded) { + return ( +
+
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +
+ ); + } + return ( +
+
+
+ ); +} + +export default connect(mapStateToProps, mapDispatchToProps)(App); \ No newline at end of file diff --git a/frontend/src/components/Editor.js b/frontend/src/components/Editor.js new file mode 100644 index 000000000..700a401cc --- /dev/null +++ b/frontend/src/components/Editor.js @@ -0,0 +1,176 @@ +import ListErrors from "./ListErrors"; +import React from "react"; +import agent from "../agent"; +import { connect } from "react-redux"; +import { + ADD_TAG, + EDITOR_PAGE_LOADED, + REMOVE_TAG, + ITEM_SUBMITTED, + EDITOR_PAGE_UNLOADED, + UPDATE_FIELD_EDITOR, +} from "../constants/actionTypes"; +import { withRouterParams } from "./commons"; + +const mapStateToProps = (state) => ({ + ...state.editor, +}); + +const mapDispatchToProps = (dispatch) => ({ + onAddTag: () => dispatch({ type: ADD_TAG }), + onLoad: (payload) => dispatch({ type: EDITOR_PAGE_LOADED, payload }), + onRemoveTag: (tag) => dispatch({ type: REMOVE_TAG, tag }), + onSubmit: (payload) => dispatch({ type: ITEM_SUBMITTED, payload }), + onUnload: (payload) => dispatch({ type: EDITOR_PAGE_UNLOADED }), + onUpdateField: (key, value) => + dispatch({ type: UPDATE_FIELD_EDITOR, key, value }), +}); + +class Editor extends React.Component { + constructor() { + super(); + + const updateFieldEvent = (key) => (ev) => + this.props.onUpdateField(key, ev.target.value); + this.changeTitle = updateFieldEvent("title"); + this.changeDescription = updateFieldEvent("description"); + this.changeImage = updateFieldEvent("image"); + this.changeTagInput = updateFieldEvent("tagInput"); + + this.watchForEnter = (ev) => { + if (ev.keyCode === 13) { + ev.preventDefault(); + this.props.onAddTag(); + } + }; + + this.removeTagHandler = (tag) => () => { + this.props.onRemoveTag(tag); + }; + + this.submitForm = (ev) => { + ev.preventDefault(); + const item = { + title: this.props.title, + description: this.props.description, + image: this.props.image, + tagList: this.props.tagList, + }; + + const slug = { slug: this.props.itemSlug }; + const promise = this.props.itemSlug + ? agent.Items.update(Object.assign(item, slug)) + : agent.Items.create(item); + + this.props.onSubmit(promise); + }; + } + + componentDidUpdate(prevProps) { + if (this.props.params.slug !== prevProps.params.slug) { + if (this.props.params.slug) { + this.props.onUnload(); + return this.props.onLoad(agent.Items.get(this.props.params.slug)); + } + this.props.onLoad(null); + } + } + + componentDidMount() { + if (this.props.params.slug) { + return this.props.onLoad(agent.Items.get(this.props.params.slug)); + } + this.props.onLoad(null); + } + + componentWillUnmount() { + this.props.onUnload(); + } + + render() { + return ( +
+
+
+
+ + +
+
+
+ +
+ +
+ +
+ +
+ +
+ +
+ + +
+ {(this.props.tagList || []).map((tag) => { + return ( + + + {tag} + + ); + })} +
+
+ + +
+
+
+
+
+
+ ); + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(withRouterParams(Editor)); diff --git a/frontend/src/components/Header.js b/frontend/src/components/Header.js new file mode 100644 index 000000000..be37dfcd7 --- /dev/null +++ b/frontend/src/components/Header.js @@ -0,0 +1,73 @@ +import React from "react"; +import { Link } from "react-router-dom"; +import logo from "../imgs/topbar_logo.png"; + +const LoggedOutView = () => { + return ( + + ); +}; + +const LoggedInView = (props) => { + return ( + + ); +}; + +class Header extends React.Component { + render() { + return ( + + ); + } +} + +export default Header; diff --git a/frontend/src/components/Home/Banner.js b/frontend/src/components/Home/Banner.js new file mode 100644 index 000000000..60eed2042 --- /dev/null +++ b/frontend/src/components/Home/Banner.js @@ -0,0 +1,19 @@ +import React from "react"; +import logo from "../../imgs/logo.png"; + +const Banner = () => { + return ( +
+
+ +
+ A place to + get + the cool stuff. +
+
+
+ ); +}; + +export default Banner; diff --git a/frontend/src/components/Home/MainView.js b/frontend/src/components/Home/MainView.js new file mode 100644 index 000000000..bf92549d7 --- /dev/null +++ b/frontend/src/components/Home/MainView.js @@ -0,0 +1,100 @@ +import ItemList from "../ItemList"; +import React from "react"; +import agent from "../../agent"; +import { connect } from "react-redux"; +import { CHANGE_TAB } from "../../constants/actionTypes"; + +const YourFeedTab = (props) => { + if (props.token) { + const clickHandler = (ev) => { + ev.preventDefault(); + props.onTabClick("feed", agent.Items.feed, agent.Items.feed()); + }; + + return ( +
  • + +
  • + ); + } + return null; +}; + +const GlobalFeedTab = (props) => { + const clickHandler = (ev) => { + ev.preventDefault(); + props.onTabClick("all", agent.Items.all, agent.Items.all()); + }; + return ( +
  • + +
  • + ); +}; + +const TagFilterTab = (props) => { + if (!props.tag) { + return null; + } + + return ( +
  • + +
  • + ); +}; + +const mapStateToProps = (state) => ({ + ...state.itemList, + tags: state.home.tags, + token: state.common.token, +}); + +const mapDispatchToProps = (dispatch) => ({ + onTabClick: (tab, pager, payload) => + dispatch({ type: CHANGE_TAB, tab, pager, payload }), +}); + +const MainView = (props) => { + return ( +
    +
    + +
    + + +
    + ); +}; + +export default connect(mapStateToProps, mapDispatchToProps)(MainView); diff --git a/frontend/src/components/Home/Tags.js b/frontend/src/components/Home/Tags.js new file mode 100644 index 000000000..01cba5dfd --- /dev/null +++ b/frontend/src/components/Home/Tags.js @@ -0,0 +1,40 @@ +import React from "react"; +import agent from "../../agent"; + +const Tags = (props) => { + const tags = props.tags; + if (tags) { + return ( +
    + Popular tags: + + {tags.map((tag) => { + const handleClick = (ev) => { + ev.preventDefault(); + props.onClickTag( + tag, + (page) => agent.Items.byTag(tag, page), + agent.Items.byTag(tag) + ); + }; + + return ( + + ); + })} + +
    + ); + } else { + return
    Loading Tags...
    ; + } +}; + +export default Tags; diff --git a/frontend/src/components/Home/index.js b/frontend/src/components/Home/index.js new file mode 100644 index 000000000..34e09ae6a --- /dev/null +++ b/frontend/src/components/Home/index.js @@ -0,0 +1,54 @@ +import Banner from "./Banner"; +import MainView from "./MainView"; +import React, { useEffect } from "react"; +import Tags from "./Tags"; +import agent from "../../agent"; +import { connect } from "react-redux"; +import { + HOME_PAGE_LOADED, + HOME_PAGE_UNLOADED, + APPLY_TAG_FILTER, +} from "../../constants/actionTypes"; + +const Promise = global.Promise; + +const mapStateToProps = (state) => ({ + ...state.home, + appName: state.common.appName, + token: state.common.token, +}); + +const mapDispatchToProps = (dispatch) => ({ + onClickTag: (tag, pager, payload) => + dispatch({ type: APPLY_TAG_FILTER, tag, pager, payload }), + onLoad: (tab, pager, payload) => + dispatch({ type: HOME_PAGE_LOADED, tab, pager, payload }), + onUnload: () => dispatch({ type: HOME_PAGE_UNLOADED }), +}); + +const Home = ({onLoad, onUnload, tags, onClickTag}) => { + const tab = "all"; + const itemsPromise = agent.Items.all; + + useEffect(() => { + onLoad( + tab, + itemsPromise, + Promise.all([agent.Tags.getAll(), itemsPromise()]) + ); + return onUnload; + }, [onLoad, onUnload, tab, itemsPromise]); + + return ( +
    + + +
    + + +
    +
    + ); +} + +export default connect(mapStateToProps, mapDispatchToProps)(Home); \ No newline at end of file diff --git a/frontend/src/components/Item/Comment.js b/frontend/src/components/Item/Comment.js new file mode 100644 index 000000000..f852408ef --- /dev/null +++ b/frontend/src/components/Item/Comment.js @@ -0,0 +1,42 @@ +import DeleteButton from "./DeleteButton"; +import { Link } from "react-router-dom"; +import React from "react"; + +const Comment = (props) => { + const comment = props.comment; + const show = + props.currentUser && props.currentUser.username === comment.seller.username; + return ( +
    +
    +
    +

    {comment.body}

    +
    + + {comment.seller.username} + +   + + {comment.seller.username} + + | + + {new Date(comment.createdAt).toDateString()} + + +
    +
    +
    +
    + ); +}; + +export default Comment; diff --git a/frontend/src/components/Item/CommentContainer.js b/frontend/src/components/Item/CommentContainer.js new file mode 100644 index 000000000..88562b475 --- /dev/null +++ b/frontend/src/components/Item/CommentContainer.js @@ -0,0 +1,46 @@ +import CommentInput from "./CommentInput"; +import CommentList from "./CommentList"; +import { Link } from "react-router-dom"; +import React from "react"; + +const CommentContainer = (props) => { + if (props.currentUser) { + return ( +
    + +
    +
    + + +
    +
    +
    + ); + } else { + return ( +
    + +

    + + Sign in + +  or  + + sign up + +  to add comments on this item. +

    +
    + ); + } +}; + +export default CommentContainer; diff --git a/frontend/src/components/Item/CommentInput.js b/frontend/src/components/Item/CommentInput.js new file mode 100644 index 000000000..250a241d6 --- /dev/null +++ b/frontend/src/components/Item/CommentInput.js @@ -0,0 +1,59 @@ +import React from "react"; +import agent from "../../agent"; +import { connect } from "react-redux"; +import { ADD_COMMENT } from "../../constants/actionTypes"; + +const mapDispatchToProps = (dispatch) => ({ + onSubmit: (payload) => dispatch({ type: ADD_COMMENT, payload }), +}); + +class CommentInput extends React.Component { + constructor() { + super(); + this.state = { + body: "", + }; + + this.setBody = (ev) => { + this.setState({ body: ev.target.value }); + }; + + this.createComment = async (ev) => { + ev.preventDefault(); + agent.Comments.create(this.props.slug, { + body: this.state.body, + }).then((payload) => { + this.props.onSubmit(payload); + }); + this.setState({ body: "" }); + }; + } + + render() { + return ( +
    +
    + +
    +
    + {this.props.currentUser.username} + +
    +
    + ); + } +} + +export default connect(() => ({}), mapDispatchToProps)(CommentInput); diff --git a/frontend/src/components/Item/CommentList.js b/frontend/src/components/Item/CommentList.js new file mode 100644 index 000000000..b1bcb35fa --- /dev/null +++ b/frontend/src/components/Item/CommentList.js @@ -0,0 +1,21 @@ +import Comment from "./Comment"; +import React from "react"; + +const CommentList = (props) => { + return ( +
    + {props.comments.map((comment) => { + return ( + + ); + })} +
    + ); +}; + +export default CommentList; diff --git a/frontend/src/components/Item/DeleteButton.js b/frontend/src/components/Item/DeleteButton.js new file mode 100644 index 000000000..b78b1b2d6 --- /dev/null +++ b/frontend/src/components/Item/DeleteButton.js @@ -0,0 +1,27 @@ +import React from "react"; +import agent from "../../agent"; +import { connect } from "react-redux"; +import { DELETE_COMMENT } from "../../constants/actionTypes"; + +const mapDispatchToProps = (dispatch) => ({ + onClick: (payload, commentId) => + dispatch({ type: DELETE_COMMENT, payload, commentId }), +}); + +const DeleteButton = (props) => { + const del = () => { + const payload = agent.Comments.delete(props.slug, props.commentId); + props.onClick(payload, props.commentId); + }; + + if (props.show) { + return ( + + + + ); + } + return null; +}; + +export default connect(() => ({}), mapDispatchToProps)(DeleteButton); diff --git a/frontend/src/components/Item/ItemActions.js b/frontend/src/components/Item/ItemActions.js new file mode 100644 index 000000000..b6d86157c --- /dev/null +++ b/frontend/src/components/Item/ItemActions.js @@ -0,0 +1,36 @@ +import { Link } from "react-router-dom"; +import React from "react"; +import agent from "../../agent"; +import { connect } from "react-redux"; +import { DELETE_ITEM } from "../../constants/actionTypes"; + +const mapDispatchToProps = (dispatch) => ({ + onClickDelete: (payload) => dispatch({ type: DELETE_ITEM, payload }), +}); + +const ItemActions = (props) => { + const item = props.item; + const del = () => { + props.onClickDelete(agent.Items.del(item.slug)); + }; + if (props.canModify) { + return ( + + + Edit Item + + + + + ); + } + + return ; +}; + +export default connect(() => ({}), mapDispatchToProps)(ItemActions); diff --git a/frontend/src/components/Item/ItemMeta.js b/frontend/src/components/Item/ItemMeta.js new file mode 100644 index 000000000..c3bdb6e75 --- /dev/null +++ b/frontend/src/components/Item/ItemMeta.js @@ -0,0 +1,30 @@ +import ItemActions from "./ItemActions"; +import { Link } from "react-router-dom"; +import React from "react"; + +const ItemMeta = (props) => { + const item = props.item; + return ( +
    + + {item.seller.username} + + +
    + + {item.seller.username} + + {new Date(item.createdAt).toDateString()} +
    + + +
    + ); +}; + +export default ItemMeta; diff --git a/frontend/src/components/Item/index.js b/frontend/src/components/Item/index.js new file mode 100644 index 000000000..7d1bf583a --- /dev/null +++ b/frontend/src/components/Item/index.js @@ -0,0 +1,85 @@ +import ItemMeta from "./ItemMeta"; +import CommentContainer from "./CommentContainer"; +import React, { useEffect } from "react"; +import { connect } from "react-redux"; +import marked from "marked"; +import { + ITEM_PAGE_LOADED, + ITEM_PAGE_UNLOADED, +} from "../../constants/actionTypes"; +import { getItemAndComments } from "./utils/ItemFetcher"; +import { useParams } from "react-router-dom"; + +const mapStateToProps = (state) => ({ + ...state.item, + currentUser: state.common.currentUser, +}); + +const mapDispatchToProps = (dispatch) => ({ + onLoad: (payload) => dispatch({ type: ITEM_PAGE_LOADED, payload }), + onUnload: () => dispatch({ type: ITEM_PAGE_UNLOADED }), +}); + +const Item = (props) => { + const params = useParams(); + const {onLoad, onUnload} = props; + useEffect(() => { + getItemAndComments( + params.id + ).then(([item, comments]) => { + onLoad([item, comments]); + }); + return onUnload; + }, [onLoad, onUnload, params]); + + if (!props.item) { + return null; + } + + const markup = { + __html: marked(props.item.description, { sanitize: true }), + }; + const canModify = + props.currentUser && + props.currentUser.username === props.item.seller.username; + return ( +
    +
    +
    +
    + {props.item.title} +
    + +
    +

    {props.item.title}

    + +
    + {props.item.tagList.map((tag) => { + return ( + + {tag} + + ); + })} +
    +
    + +
    + +
    +
    +
    + ); +} + +export default connect(mapStateToProps, mapDispatchToProps)(Item); diff --git a/frontend/src/components/Item/utils/ItemFetcher.js b/frontend/src/components/Item/utils/ItemFetcher.js new file mode 100644 index 000000000..6ebd6c8f6 --- /dev/null +++ b/frontend/src/components/Item/utils/ItemFetcher.js @@ -0,0 +1,8 @@ +import agent from "../../../agent"; + +export async function getItemAndComments(id) { + const item = await agent.Items.get(id); + const comments = await agent.Comments.forItem(id); + + return [item, comments]; +} diff --git a/frontend/src/components/ItemList.js b/frontend/src/components/ItemList.js new file mode 100644 index 000000000..268714e0b --- /dev/null +++ b/frontend/src/components/ItemList.js @@ -0,0 +1,35 @@ +import ItemPreview from "./ItemPreview"; +import ListPagination from "./ListPagination"; +import React from "react"; + +const ItemList = (props) => { + if (!props.items) { + return
    Loading...
    ; + } + + if (props.items.length === 0) { + return
    No items are here... yet.
    ; + } + + return ( +
    +
    + {props.items.map((item) => { + return ( +
    + +
    + ); + })} +
    + + +
    + ); +}; + +export default ItemList; diff --git a/frontend/src/components/ItemPreview.js b/frontend/src/components/ItemPreview.js new file mode 100644 index 000000000..ce89b0062 --- /dev/null +++ b/frontend/src/components/ItemPreview.js @@ -0,0 +1,66 @@ +import React from "react"; +import { Link } from "react-router-dom"; +import agent from "../agent"; +import { connect } from "react-redux"; +import { ITEM_FAVORITED, ITEM_UNFAVORITED } from "../constants/actionTypes"; + +const mapDispatchToProps = (dispatch) => ({ + favorite: (slug) => + dispatch({ + type: ITEM_FAVORITED, + payload: agent.Items.favorite(slug), + }), + unfavorite: (slug) => + dispatch({ + type: ITEM_UNFAVORITED, + payload: agent.Items.unfavorite(slug), + }), +}); + +const ItemPreview = (props) => { + const item = props.item; + + const handleClick = (ev) => { + ev.preventDefault(); + if (item.favorited) { + props.unfavorite(item.slug); + } else { + props.favorite(item.slug); + } + }; + + return ( +
    + item +
    + +

    {item.title}

    +

    {item.description}

    + +
    + + {item.seller.username} + + +
    +
    +
    + ); +}; + +export default connect(() => ({}), mapDispatchToProps)(ItemPreview); diff --git a/frontend/src/components/ListErrors.js b/frontend/src/components/ListErrors.js new file mode 100644 index 000000000..33c97e83b --- /dev/null +++ b/frontend/src/components/ListErrors.js @@ -0,0 +1,24 @@ +import React from "react"; + +class ListErrors extends React.Component { + render() { + const errors = this.props.errors; + if (errors) { + return ( + + ); + } else { + return null; + } + } +} + +export default ListErrors; diff --git a/frontend/src/components/ListPagination.js b/frontend/src/components/ListPagination.js new file mode 100644 index 000000000..fcefbcce3 --- /dev/null +++ b/frontend/src/components/ListPagination.js @@ -0,0 +1,52 @@ +import React from "react"; +import agent from "../agent"; +import { connect } from "react-redux"; +import { SET_PAGE } from "../constants/actionTypes"; + +const mapDispatchToProps = (dispatch) => ({ + onSetPage: (page, payload) => dispatch({ type: SET_PAGE, page, payload }), +}); + +const ListPagination = (props) => { + if (props.itemsCount <= 10) { + return null; + } + + const range = []; + for (let i = 0; i < Math.ceil(props.itemsCount / 10); ++i) { + range.push(i); + } + + const setPage = (page) => { + if (props.pager) { + props.onSetPage(page, props.pager(page)); + } else { + props.onSetPage(page, agent.Items.all(page)); + } + }; + + return ( + + ); +}; + +export default connect(() => ({}), mapDispatchToProps)(ListPagination); diff --git a/frontend/src/components/Login.js b/frontend/src/components/Login.js new file mode 100644 index 000000000..af4f12da1 --- /dev/null +++ b/frontend/src/components/Login.js @@ -0,0 +1,121 @@ +import { Link } from "react-router-dom"; +import ListErrors from "./ListErrors"; +import React from "react"; +import agent from "../agent"; +import { connect } from "react-redux"; +import { + UPDATE_FIELD_AUTH, + LOGIN, + LOGIN_PAGE_UNLOADED, +} from "../constants/actionTypes"; + +const mapStateToProps = (state) => ({ ...state.auth }); + +const mapDispatchToProps = (dispatch) => ({ + onChangeEmail: (value) => + dispatch({ type: UPDATE_FIELD_AUTH, key: "email", value }), + onChangePassword: (value) => + dispatch({ type: UPDATE_FIELD_AUTH, key: "password", value }), + onSubmit: (email, password) => + dispatch({ type: LOGIN, payload: agent.Auth.login(email, password) }), + onUnload: () => dispatch({ type: LOGIN_PAGE_UNLOADED }), +}); + +class Login extends React.Component { + constructor() { + super(); + this.changeEmail = (ev) => this.props.onChangeEmail(ev.target.value); + this.changePassword = (ev) => this.props.onChangePassword(ev.target.value); + this.submitForm = (email, password) => (ev) => { + ev.preventDefault(); + this.props.onSubmit(email, password); + }; + } + + componentWillUnmount() { + this.props.onUnload(); + } + + render() { + const email = this.props.email; + const password = this.props.password; + return ( +
    +
    +
    +
    +

    Sign In

    + + + +
    +
    +
    +
    +
    + + + +
    + +
    +
    + +
    +
    +
    + + + +
    + +
    +
    + + +
    +
    +

    + + Need an account? + +

    +
    +
    +
    +
    + ); + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(Login); diff --git a/frontend/src/components/Profile.js b/frontend/src/components/Profile.js new file mode 100644 index 000000000..b2e0f0b0e --- /dev/null +++ b/frontend/src/components/Profile.js @@ -0,0 +1,172 @@ +import ItemList from "./ItemList"; +import React from "react"; +import { Link } from "react-router-dom"; +import agent from "../agent"; +import { connect } from "react-redux"; +import { + FOLLOW_USER, + UNFOLLOW_USER, + PROFILE_PAGE_LOADED, + PROFILE_PAGE_UNLOADED, +} from "../constants/actionTypes"; +import { withRouterParams } from "./commons"; + +const EditProfileSettings = (props) => { + if (props.isUser) { + return ( + + Edit Profile Settings + + ); + } + return null; +}; + +const FollowUserButton = (props) => { + if (props.isUser) { + return null; + } + + let classes = "btn btn-sm action-btn"; + if (props.user.following) { + classes += " btn-secondary"; + } else { + classes += " btn-outline-secondary"; + } + + const handleClick = (ev) => { + ev.preventDefault(); + if (props.user.following) { + props.unfollow(props.user.username); + } else { + props.follow(props.user.username); + } + }; + + return ( + + ); +}; + +const mapStateToProps = (state) => ({ + ...state.itemList, + currentUser: state.common.currentUser, + profile: state.profile, +}); + +const mapDispatchToProps = (dispatch) => ({ + onFollow: (username) => + dispatch({ + type: FOLLOW_USER, + payload: agent.Profile.follow(username), + }), + onLoad: (payload) => dispatch({ type: PROFILE_PAGE_LOADED, payload }), + onUnfollow: (username) => + dispatch({ + type: UNFOLLOW_USER, + payload: agent.Profile.unfollow(username), + }), + onUnload: () => dispatch({ type: PROFILE_PAGE_UNLOADED }), +}); + +class Profile extends React.Component { + componentDidMount() { + const username = this.props.params.username?.substring(1); + this.props.onLoad( + Promise.all([ + agent.Profile.get(username), + agent.Items.bySeller(username), + ]) + ); + } + + componentWillUnmount() { + this.props.onUnload(); + } + + renderTabs() { + return ( + + ); + } + + render() { + const profile = this.props.profile; + if (!profile) { + return null; + } + + const isUser = + this.props.currentUser && + this.props.profile.username === this.props.currentUser.username; + + return ( +
    +
    +
    +
    + {profile.username} +

    {profile.username}

    +

    {profile.bio}

    + + + +
    +
    +
    + +
    +
    +
    +
    {this.renderTabs()}
    + + +
    +
    +
    +
    + ); + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(withRouterParams(Profile)); +export { Profile, mapStateToProps }; diff --git a/frontend/src/components/ProfileFavorites.js b/frontend/src/components/ProfileFavorites.js new file mode 100644 index 000000000..5fd3fba2e --- /dev/null +++ b/frontend/src/components/ProfileFavorites.js @@ -0,0 +1,56 @@ +import { Profile, mapStateToProps } from "./Profile"; +import React from "react"; +import { Link } from "react-router-dom"; +import agent from "../agent"; +import { connect } from "react-redux"; +import { + PROFILE_PAGE_LOADED, + PROFILE_PAGE_UNLOADED, +} from "../constants/actionTypes"; +import { withRouterParams } from "./commons"; + +const mapDispatchToProps = (dispatch) => ({ + onLoad: (pager, payload) => + dispatch({ type: PROFILE_PAGE_LOADED, pager, payload }), + onUnload: () => dispatch({ type: PROFILE_PAGE_UNLOADED }), +}); + +class ProfileFavorites extends Profile { + componentDidMount() { + const username = this.props.params.username?.substring(1); + this.props.onLoad( + (page) => agent.Items.favoritedBy(username, page), + Promise.all([ + agent.Profile.get(username), + agent.Items.favoritedBy(username), + ]) + ); + } + + componentWillUnmount() { + this.props.onUnload(); + } + + renderTabs() { + return ( + + ); + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(withRouterParams(ProfileFavorites)); diff --git a/frontend/src/components/Register.js b/frontend/src/components/Register.js new file mode 100644 index 000000000..195a25af2 --- /dev/null +++ b/frontend/src/components/Register.js @@ -0,0 +1,148 @@ +import { Link } from "react-router-dom"; +import ListErrors from "./ListErrors"; +import React from "react"; +import agent from "../agent"; +import { connect } from "react-redux"; +import { + UPDATE_FIELD_AUTH, + REGISTER, + REGISTER_PAGE_UNLOADED, +} from "../constants/actionTypes"; + +const mapStateToProps = (state) => ({ ...state.auth }); + +const mapDispatchToProps = (dispatch) => ({ + onChangeEmail: (value) => + dispatch({ type: UPDATE_FIELD_AUTH, key: "email", value }), + onChangePassword: (value) => + dispatch({ type: UPDATE_FIELD_AUTH, key: "password", value }), + onChangeUsername: (value) => + dispatch({ type: UPDATE_FIELD_AUTH, key: "username", value }), + onSubmit: (username, email, password) => { + const payload = agent.Auth.register(username, email, password); + dispatch({ type: REGISTER, payload }); + }, + onUnload: () => dispatch({ type: REGISTER_PAGE_UNLOADED }), +}); + +class Register extends React.Component { + constructor() { + super(); + this.changeEmail = (ev) => this.props.onChangeEmail(ev.target.value); + this.changePassword = (ev) => this.props.onChangePassword(ev.target.value); + this.changeUsername = (ev) => this.props.onChangeUsername(ev.target.value); + this.submitForm = (username, email, password) => (ev) => { + ev.preventDefault(); + this.props.onSubmit(username, email, password); + }; + } + + componentWillUnmount() { + this.props.onUnload(); + } + + render() { + const email = this.props.email; + const password = this.props.password; + const username = this.props.username; + + return ( +
    +
    +
    +
    +

    Sign Up

    + + + +
    +
    +
    +
    +
    + + + +
    + +
    +
    + +
    +
    +
    + + + +
    + +
    +
    + +
    +
    +
    + + + +
    + +
    +
    + + +
    +
    +

    + + Have an account? + +

    +
    +
    +
    +
    + ); + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(Register); diff --git a/frontend/src/components/Settings.js b/frontend/src/components/Settings.js new file mode 100644 index 000000000..e4791bc1f --- /dev/null +++ b/frontend/src/components/Settings.js @@ -0,0 +1,142 @@ +import ListErrors from "./ListErrors"; +import React, { useCallback, useEffect, useState } from "react"; +import agent from "../agent"; +import { connect } from "react-redux"; +import { + SETTINGS_SAVED, + SETTINGS_PAGE_UNLOADED, + LOGOUT, +} from "../constants/actionTypes"; + +const SettingsForm = ({ currentUser, onSubmitForm }) => { + const [user, setUser] = useState({}); + + useEffect(() => { + if (currentUser) { + setUser(currentUser); + } + }, [currentUser]); + + const updateState = useCallback((field) => (ev) => { + const newState = Object.assign({}, user, { [field]: ev.target.value }); + setUser(newState); + }, [user]); + + const submitForm = useCallback((ev) => { + ev.preventDefault(); + const userToSubmit = { ...user }; + if (!userToSubmit.password) { + delete userToSubmit.password; + } + onSubmitForm(userToSubmit); + }, [user, onSubmitForm]); + + return ( +
    +
    +
    + +
    + +
    + +
    + +
    + +
    + +
    + +
    + +
    + +
    + + +
    +
    + ); +} + +const mapStateToProps = (state) => ({ + ...state.settings, + currentUser: state.common.currentUser, +}); + +const mapDispatchToProps = (dispatch) => ({ + onClickLogout: () => dispatch({ type: LOGOUT }), + onSubmitForm: (user) => + dispatch({ type: SETTINGS_SAVED, payload: agent.Auth.save(user) }), + onUnload: () => dispatch({ type: SETTINGS_PAGE_UNLOADED }), +}); + +class Settings extends React.Component { + render() { + return ( +
    +
    +
    +
    +

    Your Settings

    + + + + + +
    + + +
    +
    +
    +
    + ); + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(Settings); diff --git a/frontend/src/components/commons.js b/frontend/src/components/commons.js new file mode 100644 index 000000000..8f1fd1c4b --- /dev/null +++ b/frontend/src/components/commons.js @@ -0,0 +1,5 @@ +import { useParams } from "react-router-dom"; + +export function withRouterParams(Component) { + return props => ; +} diff --git a/frontend/src/constants/actionTypes.js b/frontend/src/constants/actionTypes.js new file mode 100644 index 000000000..bb5380b86 --- /dev/null +++ b/frontend/src/constants/actionTypes.js @@ -0,0 +1,37 @@ +export const APP_LOAD = "APP_LOAD"; +export const REDIRECT = "REDIRECT"; +export const ITEM_SUBMITTED = "ITEM_SUBMITTED"; +export const SETTINGS_SAVED = "SETTINGS_SAVED"; +export const DELETE_ITEM = "DELETE_ITEM"; +export const SETTINGS_PAGE_UNLOADED = "SETTINGS_PAGE_UNLOADED"; +export const HOME_PAGE_LOADED = "HOME_PAGE_LOADED"; +export const HOME_PAGE_UNLOADED = "HOME_PAGE_UNLOADED"; +export const ITEM_PAGE_LOADED = "ITEM_PAGE_LOADED"; +export const ITEM_PAGE_UNLOADED = "ITEM_PAGE_UNLOADED"; +export const ADD_COMMENT = "ADD_COMMENT"; +export const DELETE_COMMENT = "DELETE_COMMENT"; +export const ITEM_FAVORITED = "ITEM_FAVORITED"; +export const ITEM_UNFAVORITED = "ITEM_UNFAVORITED"; +export const SET_PAGE = "SET_PAGE"; +export const APPLY_TAG_FILTER = "APPLY_TAG_FILTER"; +export const CHANGE_TAB = "CHANGE_TAB"; +export const PROFILE_PAGE_LOADED = "PROFILE_PAGE_LOADED"; +export const PROFILE_PAGE_UNLOADED = "PROFILE_PAGE_UNLOADED"; +export const LOGIN = "LOGIN"; +export const LOGOUT = "LOGOUT"; +export const REGISTER = "REGISTER"; +export const LOGIN_PAGE_UNLOADED = "LOGIN_PAGE_UNLOADED"; +export const REGISTER_PAGE_UNLOADED = "REGISTER_PAGE_UNLOADED"; +export const ASYNC_START = "ASYNC_START"; +export const ASYNC_END = "ASYNC_END"; +export const EDITOR_PAGE_LOADED = "EDITOR_PAGE_LOADED"; +export const EDITOR_PAGE_UNLOADED = "EDITOR_PAGE_UNLOADED"; +export const ADD_TAG = "ADD_TAG"; +export const REMOVE_TAG = "REMOVE_TAG"; +export const UPDATE_FIELD_AUTH = "UPDATE_FIELD_AUTH"; +export const UPDATE_FIELD_EDITOR = "UPDATE_FIELD_EDITOR"; +export const FOLLOW_USER = "FOLLOW_USER"; +export const UNFOLLOW_USER = "UNFOLLOW_USER"; +export const PROFILE_FAVORITES_PAGE_UNLOADED = + "PROFILE_FAVORITES_PAGE_UNLOADED"; +export const PROFILE_FAVORITES_PAGE_LOADED = "PROFILE_FAVORITES_PAGE_LOADED"; diff --git a/frontend/src/custom.scss b/frontend/src/custom.scss new file mode 100644 index 000000000..0b4d7579b --- /dev/null +++ b/frontend/src/custom.scss @@ -0,0 +1,61 @@ +@import url("https://fonts.googleapis.com/css?family=Poppins:300,400,500,600,700,800"); + +// Override default variables before the import +$primary: #2b1456; +$secondary: #ff2b98; + +$body-color: white; //this is the text color +$body-bg: $primary; + +$dark: #170539; +$light: #af93f2; + +$input-border-color: #d0d0d0; + +$font-family-base: "Poppins", sans-serif !important; + +$theme-colors: ( + "light-gray": #f2f2f2, +); + +// Import Bootstrap and its default variables +@import "~bootstrap/scss/bootstrap.scss"; +@import "~bootstrap-icons/font/bootstrap-icons.css"; + +body { + background-image: url("./imgs/background.png"); + background-position: top; + background-repeat: no-repeat; +} +.page { + margin-top: 2 * $spacer; + margin-bottom: 2 * $spacer; +} + +.user-pic { + height: 40px; + width: 40px; +} + +.user-img { + width: 100px; + height: 100px; + border-radius: 100px; +} + +.item-img { + height: 150px; + object-fit: cover; +} + +.crop-text-3 { + -webkit-line-clamp: 3; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-box-orient: vertical; +} + +.user-info { + min-width: 800px; +} diff --git a/frontend/src/imgs/background.png b/frontend/src/imgs/background.png new file mode 100644 index 000000000..6c2e0f978 Binary files /dev/null and b/frontend/src/imgs/background.png differ diff --git a/frontend/src/imgs/logo.png b/frontend/src/imgs/logo.png new file mode 100644 index 000000000..89757c251 Binary files /dev/null and b/frontend/src/imgs/logo.png differ diff --git a/frontend/src/imgs/topbar_logo.png b/frontend/src/imgs/topbar_logo.png new file mode 100644 index 000000000..e7fefd3b9 Binary files /dev/null and b/frontend/src/imgs/topbar_logo.png differ diff --git a/frontend/src/index.js b/frontend/src/index.js new file mode 100644 index 000000000..a6ecd9bc7 --- /dev/null +++ b/frontend/src/index.js @@ -0,0 +1,18 @@ +import "./custom.scss"; +import ReactDOM from "react-dom"; +import { Provider } from "react-redux"; +import React from "react"; +import { store } from "./store"; + +import App from "./components/App"; +import { BrowserRouter } from "react-router-dom"; + +ReactDOM.render( + + + + + , + + document.getElementById("root") +); diff --git a/frontend/src/middleware.js b/frontend/src/middleware.js new file mode 100644 index 000000000..4f82efb69 --- /dev/null +++ b/frontend/src/middleware.js @@ -0,0 +1,65 @@ +import agent from "./agent"; +import { + ASYNC_START, + ASYNC_END, + LOGIN, + LOGOUT, + REGISTER, +} from "./constants/actionTypes"; + +const promiseMiddleware = (store) => (next) => (action) => { + if (isPromise(action.payload)) { + store.dispatch({ type: ASYNC_START, subtype: action.type }); + + const currentView = store.getState().viewChangeCounter; + const skipTracking = action.skipTracking; + + action.payload.then( + (res) => { + const currentState = store.getState(); + if (!skipTracking && currentState.viewChangeCounter !== currentView) { + return; + } + action.payload = res; + store.dispatch({ type: ASYNC_END, promise: action.payload }); + store.dispatch(action); + }, + (error) => { + const currentState = store.getState(); + if (!skipTracking && currentState.viewChangeCounter !== currentView) { + return; + } + action.error = true; + action.payload = error.response.body; + if (!action.skipTracking) { + store.dispatch({ type: ASYNC_END, promise: action.payload }); + } + store.dispatch(action); + } + ); + + return; + } + + next(action); +}; + +const localStorageMiddleware = (store) => (next) => (action) => { + if (action.type === REGISTER || action.type === LOGIN) { + if (!action.error) { + window.localStorage.setItem("jwt", action.payload.user.token); + agent.setToken(action.payload.user.token); + } + } else if (action.type === LOGOUT) { + window.localStorage.setItem("jwt", ""); + agent.setToken(null); + } + + next(action); +}; + +function isPromise(v) { + return v && typeof v.then === "function"; +} + +export { promiseMiddleware, localStorageMiddleware }; diff --git a/frontend/src/reducer.js b/frontend/src/reducer.js new file mode 100644 index 000000000..65173caa2 --- /dev/null +++ b/frontend/src/reducer.js @@ -0,0 +1,20 @@ +import item from "./reducers/item"; +import itemList from "./reducers/itemList"; +import auth from "./reducers/auth"; +import { combineReducers } from "redux"; +import common from "./reducers/common"; +import editor from "./reducers/editor"; +import home from "./reducers/home"; +import profile from "./reducers/profile"; +import settings from "./reducers/settings"; + +export default combineReducers({ + item, + itemList, + auth, + common, + editor, + home, + profile, + settings, +}); diff --git a/frontend/src/reducers/auth.js b/frontend/src/reducers/auth.js new file mode 100644 index 000000000..6128b1159 --- /dev/null +++ b/frontend/src/reducers/auth.js @@ -0,0 +1,36 @@ +import { + LOGIN, + REGISTER, + LOGIN_PAGE_UNLOADED, + REGISTER_PAGE_UNLOADED, + ASYNC_START, + UPDATE_FIELD_AUTH, +} from "../constants/actionTypes"; + +const reducer = (state = {}, action) => { + switch (action.type) { + case LOGIN: + case REGISTER: + return { + ...state, + inProgress: false, + errors: action.error ? action.payload.errors : null, + }; + case LOGIN_PAGE_UNLOADED: + case REGISTER_PAGE_UNLOADED: + return {}; + case ASYNC_START: + if (action.subtype === LOGIN || action.subtype === REGISTER) { + return { ...state, inProgress: true }; + } + break; + case UPDATE_FIELD_AUTH: + return { ...state, [action.key]: action.value }; + default: + return state; + } + + return state; +}; + +export default reducer; diff --git a/frontend/src/reducers/common.js b/frontend/src/reducers/common.js new file mode 100644 index 000000000..3ef54b4b3 --- /dev/null +++ b/frontend/src/reducers/common.js @@ -0,0 +1,79 @@ +import { + APP_LOAD, + REDIRECT, + LOGOUT, + ITEM_SUBMITTED, + SETTINGS_SAVED, + LOGIN, + REGISTER, + DELETE_ITEM, + ITEM_PAGE_UNLOADED, + EDITOR_PAGE_UNLOADED, + HOME_PAGE_UNLOADED, + PROFILE_PAGE_UNLOADED, + PROFILE_FAVORITES_PAGE_UNLOADED, + SETTINGS_PAGE_UNLOADED, + LOGIN_PAGE_UNLOADED, + REGISTER_PAGE_UNLOADED, +} from "../constants/actionTypes"; + +const defaultState = { + appName: "Anythink Market", + token: null, + viewChangeCounter: 0, +}; + +const reducer = (state = defaultState, action) => { + switch (action.type) { + case APP_LOAD: + return { + ...state, + token: action.token || null, + appLoaded: true, + currentUser: action.payload ? action.payload.user : null, + }; + case REDIRECT: + return { ...state, redirectTo: null }; + case LOGOUT: + return { ...state, redirectTo: "/", token: null, currentUser: null }; + case ITEM_SUBMITTED: { + const redirectUrl = `/item/${action.payload.item.slug}`; + return { ...state, redirectTo: redirectUrl }; + } + case SETTINGS_SAVED: + return { + ...state, + redirectTo: action.error ? null : "/", + currentUser: action.error ? null : action.payload.user, + }; + case LOGIN: + return { + ...state, + redirectTo: action.error ? null : "/", + token: action.error ? null : action.payload.user.token, + currentUser: action.error ? null : action.payload.user, + }; + case REGISTER: + return { + ...state, + redirectTo: action.error ? null : `/@${action.payload.user.username}`, + token: action.error ? null : action.payload.user.token, + currentUser: action.error ? null : action.payload.user, + }; + case DELETE_ITEM: + return { ...state, redirectTo: "/" }; + case ITEM_PAGE_UNLOADED: + case EDITOR_PAGE_UNLOADED: + case HOME_PAGE_UNLOADED: + case PROFILE_PAGE_UNLOADED: + case PROFILE_FAVORITES_PAGE_UNLOADED: + case SETTINGS_PAGE_UNLOADED: + case LOGIN_PAGE_UNLOADED: + case REGISTER_PAGE_UNLOADED: + return { ...state, viewChangeCounter: state.viewChangeCounter + 1 }; + default: + return state; + } +}; + +export default reducer; diff --git a/frontend/src/reducers/editor.js b/frontend/src/reducers/editor.js new file mode 100644 index 000000000..4320e7071 --- /dev/null +++ b/frontend/src/reducers/editor.js @@ -0,0 +1,56 @@ +import { + EDITOR_PAGE_LOADED, + EDITOR_PAGE_UNLOADED, + ITEM_SUBMITTED, + ASYNC_START, + ADD_TAG, + REMOVE_TAG, + UPDATE_FIELD_EDITOR, +} from "../constants/actionTypes"; + +const reducer = (state = {}, action) => { + switch (action.type) { + case EDITOR_PAGE_LOADED: + return { + ...state, + itemSlug: action.payload ? action.payload.item.slug : "", + title: action.payload ? action.payload.item.title : "", + description: action.payload ? action.payload.item.description : "", + image: action.payload ? action.payload.item.image : "", + tagInput: "", + tagList: action.payload ? action.payload.item.tagList : [], + }; + case EDITOR_PAGE_UNLOADED: + return {}; + case ITEM_SUBMITTED: + return { + ...state, + inProgress: null, + errors: action.error ? action.payload.errors : null, + }; + case ASYNC_START: + if (action.subtype === ITEM_SUBMITTED) { + return { ...state, inProgress: true }; + } + break; + case ADD_TAG: + return { + ...state, + tagList: state.tagList.concat([state.tagInput]), + tagInput: "", + }; + case REMOVE_TAG: + return { + ...state, + tagList: state.tagList.filter((tag) => tag !== action.tag), + }; + case UPDATE_FIELD_EDITOR: + return { ...state, [action.key]: action.value }; + default: + return state; + } + + return state; +}; + +export default reducer; diff --git a/frontend/src/reducers/home.js b/frontend/src/reducers/home.js new file mode 100644 index 000000000..b9bc097ab --- /dev/null +++ b/frontend/src/reducers/home.js @@ -0,0 +1,17 @@ +import { HOME_PAGE_LOADED, HOME_PAGE_UNLOADED } from "../constants/actionTypes"; + +const reducer = (state = {}, action) => { + switch (action.type) { + case HOME_PAGE_LOADED: + return { + ...state, + tags: action.payload[0].tags, + }; + case HOME_PAGE_UNLOADED: + return {}; + default: + return state; + } +}; + +export default reducer; diff --git a/frontend/src/reducers/item.js b/frontend/src/reducers/item.js new file mode 100644 index 000000000..918201c03 --- /dev/null +++ b/frontend/src/reducers/item.js @@ -0,0 +1,38 @@ +import { + ITEM_PAGE_LOADED, + ITEM_PAGE_UNLOADED, + ADD_COMMENT, + DELETE_COMMENT, +} from "../constants/actionTypes"; + +const reducer = (state = {}, action) => { + switch (action.type) { + case ITEM_PAGE_LOADED: + return { + ...state, + item: action.payload[0].item, + comments: action.payload[1].comments, + }; + case ITEM_PAGE_UNLOADED: + return {}; + case ADD_COMMENT: + return { + ...state, + commentErrors: action.error ? action.payload.errors : null, + comments: action.error + ? null + : (state.comments || []).concat([action.payload.comment]), + }; + case DELETE_COMMENT: { + const commentId = action.commentId; + return { + ...state, + comments: state.comments.filter((comment) => comment.id !== commentId), + }; + } + default: + return state; + } +}; + +export default reducer; diff --git a/frontend/src/reducers/itemList.js b/frontend/src/reducers/itemList.js new file mode 100644 index 000000000..016a996cd --- /dev/null +++ b/frontend/src/reducers/itemList.js @@ -0,0 +1,88 @@ +import { + ITEM_FAVORITED, + ITEM_UNFAVORITED, + SET_PAGE, + APPLY_TAG_FILTER, + HOME_PAGE_LOADED, + HOME_PAGE_UNLOADED, + CHANGE_TAB, + PROFILE_PAGE_LOADED, + PROFILE_PAGE_UNLOADED, + PROFILE_FAVORITES_PAGE_LOADED, + PROFILE_FAVORITES_PAGE_UNLOADED, +} from "../constants/actionTypes"; + +const reducer = (state = {}, action) => { + switch (action.type) { + case ITEM_FAVORITED: + case ITEM_UNFAVORITED: + return { + ...state, + items: state.items.map((item) => { + if (item.slug === action.payload.item.slug) { + return { + ...item, + favorited: action.payload.item.favorited, + favoritesCount: action.payload.item.favoritesCount, + }; + } + return item; + }), + }; + case SET_PAGE: + return { + ...state, + items: action.payload.items, + itemsCount: action.payload.itemsCount, + currentPage: action.page, + }; + case APPLY_TAG_FILTER: + return { + ...state, + pager: action.pager, + items: action.payload.items, + itemsCount: action.payload.itemsCount, + tab: null, + tag: action.tag, + currentPage: 0, + }; + case HOME_PAGE_LOADED: + return { + ...state, + pager: action.pager, + tags: action.payload[0].tags, + items: action.payload[1].items, + itemsCount: action.payload[1].itemsCount, + currentPage: 0, + tab: action.tab, + }; + case HOME_PAGE_UNLOADED: + return {}; + case CHANGE_TAB: + return { + ...state, + pager: action.pager, + items: action.payload.items, + itemsCount: action.payload.itemsCount, + tab: action.tab, + currentPage: 0, + tag: null, + }; + case PROFILE_PAGE_LOADED: + case PROFILE_FAVORITES_PAGE_LOADED: + return { + ...state, + pager: action.pager, + items: action.payload?.[1]?.items, + itemsCount: action.payload?.[1]?.itemsCount, + currentPage: 0, + }; + case PROFILE_PAGE_UNLOADED: + case PROFILE_FAVORITES_PAGE_UNLOADED: + return {}; + default: + return state; + } +}; + +export default reducer; diff --git a/frontend/src/reducers/profile.js b/frontend/src/reducers/profile.js new file mode 100644 index 000000000..5d4fe85d4 --- /dev/null +++ b/frontend/src/reducers/profile.js @@ -0,0 +1,26 @@ +import { + PROFILE_PAGE_LOADED, + PROFILE_PAGE_UNLOADED, + FOLLOW_USER, + UNFOLLOW_USER, +} from "../constants/actionTypes"; + +const reducer = (state = {}, action) => { + switch (action.type) { + case PROFILE_PAGE_LOADED: + return { + ...action.payload?.[0]?.profile, + }; + case PROFILE_PAGE_UNLOADED: + return {}; + case FOLLOW_USER: + case UNFOLLOW_USER: + return { + ...action.payload.profile, + }; + default: + return state; + } +}; + +export default reducer; diff --git a/frontend/src/reducers/settings.js b/frontend/src/reducers/settings.js new file mode 100644 index 000000000..2cf4da0d3 --- /dev/null +++ b/frontend/src/reducers/settings.js @@ -0,0 +1,27 @@ +import { + SETTINGS_SAVED, + SETTINGS_PAGE_UNLOADED, + ASYNC_START, +} from "../constants/actionTypes"; + +const reducer = (state = {}, action) => { + switch (action.type) { + case SETTINGS_SAVED: + return { + ...state, + inProgress: false, + errors: action.error ? action.payload.errors : null, + }; + case SETTINGS_PAGE_UNLOADED: + return {}; + case ASYNC_START: + return { + ...state, + inProgress: true, + }; + default: + return state; + } +}; + +export default reducer; diff --git a/frontend/src/setupTests.js b/frontend/src/setupTests.js new file mode 100644 index 000000000..0772595f4 --- /dev/null +++ b/frontend/src/setupTests.js @@ -0,0 +1,5 @@ +import "core-js"; +import { configure } from "enzyme"; +import Adapter from "@wojtekmaj/enzyme-adapter-react-17"; + +configure({ adapter: new Adapter() }); diff --git a/frontend/src/store.js b/frontend/src/store.js new file mode 100644 index 000000000..5ac25dc2e --- /dev/null +++ b/frontend/src/store.js @@ -0,0 +1,25 @@ +import { applyMiddleware, createStore } from "redux"; +import { composeWithDevTools } from "redux-devtools-extension/developmentOnly"; +import { promiseMiddleware, localStorageMiddleware } from "./middleware"; +import reducer from "./reducer"; + +import { createBrowserHistory } from "history"; + +export const history = createBrowserHistory(); + +const getMiddleware = () => { + if (process.env.NODE_ENV === "production") { + return applyMiddleware( + promiseMiddleware, + localStorageMiddleware + ); + } else { + // Enable additional logging in non-production environments. + return applyMiddleware( + promiseMiddleware, + localStorageMiddleware, + ); + } +}; + +export const store = createStore(reducer, composeWithDevTools(getMiddleware())); diff --git a/frontend/src/tests/components/Header.test.js b/frontend/src/tests/components/Header.test.js new file mode 100644 index 000000000..592b74291 --- /dev/null +++ b/frontend/src/tests/components/Header.test.js @@ -0,0 +1,54 @@ +import { create } from "react-test-renderer"; +import { mount } from "enzyme"; +import { BrowserRouter as Router } from "react-router-dom"; +import Header from "../../components/Header"; + +describe("Header component", () => { + it("Snapshot testing with no user", () => { + const component = create( + +
    + + ); + let tree = component.toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it("Snapshot testing with user", () => { + const component = create( + +
    + + ); + let tree = component.toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it("Check link to main page", () => { + const header = mount( + +
    + + ); + expect(header.find("Link").first().prop("to")).toEqual("/"); + }); + + it("Render register button when there's no user", () => { + const header = mount( + +
    + + ); + expect(header.find("li > Link").first().text()).toEqual("Sign in"); + }); + + it("Render user name when there's a user", () => { + const user = { username: "user name", image: "image.png" }; + const header = mount( + +
    + + ); + expect(header.find("li > Link").last().text()).toEqual(user.username); + }); +}); diff --git a/frontend/src/tests/components/__snapshots__/Header.test.js.snap b/frontend/src/tests/components/__snapshots__/Header.test.js.snap new file mode 100644 index 000000000..e2509e5f6 --- /dev/null +++ b/frontend/src/tests/components/__snapshots__/Header.test.js.snap @@ -0,0 +1,119 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Header component Snapshot testing with no user 1`] = ` + +`; + +exports[`Header component Snapshot testing with user 1`] = ` + +`; diff --git a/frontend/src/tests/item/CommentInput.test.js b/frontend/src/tests/item/CommentInput.test.js new file mode 100644 index 000000000..ab7fcf5e2 --- /dev/null +++ b/frontend/src/tests/item/CommentInput.test.js @@ -0,0 +1,64 @@ +import { create } from "react-test-renderer"; +import { mount } from "enzyme"; +import configureMockStore from "redux-mock-store"; +import CommentInput from "../../components/Item/CommentInput"; +import agent from "../../agent"; +import { ADD_COMMENT } from "../../constants/actionTypes"; + +const mockStore = configureMockStore(); +agent.Comments.create = jest.fn(); + +describe("CommentInput component", () => { + let store; + + beforeEach(() => { + store = mockStore({}); + }); + + it("Snapshot testing with no user", () => { + const component = create( + + ); + let tree = component.toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it("Submit text", () => { + const user = { username: "name", image: "" }; + const component = mount( + + ); + const comment = { id: 1 }; + agent.Comments.create.mockResolvedValue(comment); + + const event = { target: { value: "sometext" } }; + component.find("textarea").simulate("change", event); + component.find("form").simulate("submit"); + + setImmediate(async () => { + expect(store.getActions()).toHaveLength(1); + expect(store.getActions()[0].type).toEqual(ADD_COMMENT); + expect(await store.getActions()[0].payload).toEqual(comment); + }); + }); + + it("Clear text after submit", async () => { + const user = { username: "name", image: "" }; + + const component = mount( + + ); + + const comment = { id: 1 }; + agent.Comments.create.mockResolvedValue(comment); + + const event = { target: { value: "sometext" } }; + component.find("textarea").simulate("change", event); + component.find("form").simulate("submit"); + expect(component.find("textarea").text()).toHaveLength(0); + }); +}); diff --git a/frontend/src/tests/item/__snapshots__/CommentInput.test.js.snap b/frontend/src/tests/item/__snapshots__/CommentInput.test.js.snap new file mode 100644 index 000000000..f739b2cb4 --- /dev/null +++ b/frontend/src/tests/item/__snapshots__/CommentInput.test.js.snap @@ -0,0 +1,35 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CommentInput component Snapshot testing with no user 1`] = ` +
    +
    +