├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md └── workflows │ └── hub.yml ├── Dockerfile ├── LICENSE ├── README.md ├── Screenshots.md ├── docker-compose.yml ├── docs └── ubuntu-install.md ├── images ├── create_expense.jpg ├── create_fillup.jpg ├── screenshot.jpg ├── settings.jpg ├── users.jpg ├── vehicle_detail.jpg └── vehicles_add.jpg ├── server ├── .env ├── .gitignore ├── Dockerfile ├── common │ └── utils.go ├── controllers │ ├── auth.go │ ├── files.go │ ├── import.go │ ├── masters.go │ ├── middlewares.go │ ├── reports.go │ ├── setup.go │ ├── users.go │ └── vehicle.go ├── db │ ├── base.go │ ├── clarkson.go │ ├── db.go │ ├── dbModels.go │ ├── dbfunctions.go │ ├── enums.go │ └── migrations.go ├── go.mod ├── go.sum ├── internal │ └── sanitize │ │ ├── .gitignore │ │ ├── LICENSE │ │ ├── README.md │ │ └── sanitize.go ├── main.go ├── models │ ├── alert.go │ ├── auth.go │ ├── currency.go │ ├── errors.go │ ├── files.go │ ├── misc.go │ ├── report.go │ └── vehicle.go └── service │ ├── alertSevice.go │ ├── fileService.go │ ├── importService.go │ ├── miscService.go │ ├── reportService.go │ ├── userService.go │ └── vehicleService.go └── ui ├── .browserslistrc ├── .dockerignore ├── .env ├── .eslintignore ├── .eslintrc.js ├── .gitattributes ├── .gitignore ├── .markdownlint.yml ├── .postcssrc.js ├── .prettierignore ├── .prettierrc.js ├── .vscode ├── _components.code-snippets ├── _sfc-blocks.code-snippets ├── extensions.json └── settings.json ├── .vuepress └── config.js ├── Dockerfile ├── aliases.config.js ├── babel.config.js ├── cypress.json ├── docker-compose.yml ├── docker-dev.dockerfile ├── docs ├── architecture.md ├── development.md ├── editors.md ├── linting.md ├── production.md ├── routing.md ├── state.md ├── tech.md ├── tests.md └── troubleshooting.md ├── generators └── new │ ├── component │ ├── component.ejs.t │ ├── prompt.js │ └── unit.ejs.t │ ├── e2e │ ├── e2e.ejs.t │ └── prompt.js │ ├── layout │ ├── layout.ejs.t │ ├── prompt.js │ └── unit.ejs.t │ ├── module │ ├── module.ejs.t │ ├── prompt.js │ └── unit.ejs.t │ ├── util │ ├── prompt.js │ ├── unit.ejs.t │ └── util.ejs.t │ └── view │ ├── prompt.js │ ├── unit.ejs.t │ └── view.ejs.t ├── jest.config.js ├── jsconfig.template.js ├── lint-staged.config.js ├── package-lock.json ├── package.json ├── package.json.md ├── public ├── hammond.png ├── index.html └── touch-icon.png ├── src ├── app.config.json ├── app.vue ├── assets │ └── images │ │ └── logo.png ├── components │ ├── _base-button.unit.js │ ├── _base-button.vue │ ├── _base-icon.unit.js │ ├── _base-icon.vue │ ├── _base-input-text.unit.js │ ├── _base-input-text.vue │ ├── _base-link.unit.js │ ├── _base-link.vue │ ├── _globals.js │ ├── createQuickEntry.vue │ ├── mileageChart.vue │ ├── nav-bar-routes.unit.js │ ├── nav-bar-routes.vue │ ├── nav-bar.unit.js │ ├── nav-bar.vue │ ├── quickEntryDisplay.vue │ ├── shareVehicle.vue │ └── statsWidget.vue ├── design │ ├── _colors.scss │ ├── _durations.scss │ ├── _fonts.scss │ ├── _layers.scss │ ├── _sizes.scss │ ├── _typography.scss │ └── index.scss ├── main.js ├── router │ ├── index.js │ ├── layouts │ │ ├── main.unit.js │ │ └── main.vue │ ├── routes.js │ └── views │ │ ├── _404.unit.js │ │ ├── _404.vue │ │ ├── _loading.unit.js │ │ ├── _loading.vue │ │ ├── _timeout.unit.js │ │ ├── _timeout.vue │ │ ├── createExpense.vue │ │ ├── createFillup.vue │ │ ├── createVehicle.vue │ │ ├── home.unit.js │ │ ├── home.vue │ │ ├── import-fuelly.unit.js │ │ ├── import-fuelly.vue │ │ ├── import.unit.js │ │ ├── import.vue │ │ ├── initialize.vue │ │ ├── login.unit.js │ │ ├── login.vue │ │ ├── profile.unit.js │ │ ├── profile.vue │ │ ├── quickEntries.vue │ │ ├── settings.vue │ │ ├── siteSettings.vue │ │ ├── users.vue │ │ └── vehicle.vue ├── state │ ├── helpers.js │ ├── modules │ │ ├── auth.js │ │ ├── auth.unit.js │ │ ├── index.js │ │ ├── users.js │ │ ├── users.unit.js │ │ ├── utils.js │ │ └── vehicles.js │ └── store.js └── utils │ ├── dispatch-action-for-all-modules.js │ ├── dispatch-action-for-all-modules.unit.js │ ├── format-date-relative.js │ ├── format-date-relative.unit.js │ ├── format-date.js │ └── format-date.unit.js ├── stylelint.config.js ├── tests ├── e2e │ ├── .eslintrc.js │ ├── plugins │ │ └── index.js │ ├── specs │ │ ├── auth.e2e.js │ │ ├── home.e2e.js │ │ └── profile.e2e.js │ └── support │ │ ├── commands.js │ │ ├── setup.js │ │ └── utils.js ├── mock-api │ ├── index.js │ ├── resources │ │ └── users.js │ └── routes │ │ ├── auth.js │ │ └── users.js └── unit │ ├── __mocks__ │ └── .keep │ ├── global-setup.js │ ├── global-teardown.js │ ├── matchers.js │ └── setup.js ├── vue.config.js └── yarn.lock /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | *Before creating a bug report please make sure you are using the latest docker image / code base.* 11 | 12 | **Please complete the following information** 13 | - Installation Type: [Docker/Native] 14 | - Have you tried using the latest docker image / code base [yes/no] 15 | 16 | **Describe the bug** 17 | A clear and concise description of what the bug is. 18 | 19 | **To Reproduce** 20 | Steps to reproduce the behavior: 21 | 1. Go to '...' 22 | 2. Click on '....' 23 | 3. Scroll down to '....' 24 | 4. See error 25 | 26 | **Expected behavior** 27 | A clear and concise description of what you expected to happen. 28 | 29 | **Screenshots** 30 | If applicable, add screenshots to help explain your problem. 31 | 32 | 33 | **Additional context** 34 | Add any other context about the problem here. 35 | -------------------------------------------------------------------------------- /.github/workflows/hub.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: master 6 | 7 | jobs: 8 | multi: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v2 13 | - name: Set up QEMU 14 | id: qemu 15 | uses: docker/setup-qemu-action@v1 16 | with: 17 | platforms: linux/amd64,linux/arm64,linux/arm/v7 18 | - name: Available platforms 19 | run: echo ${{ steps.qemu.outputs.platforms }} 20 | - name: Set up Docker Buildx 21 | uses: docker/setup-buildx-action@v1 22 | - name: Set up build cache 23 | uses: actions/cache@v2 24 | with: 25 | path: /tmp/.buildx-cache 26 | key: ${{ runner.os }}-buildx-${{ github.sha }} 27 | restore-keys: | 28 | ${{ runner.os }}-buildx- 29 | - name: Login to DockerHub 30 | uses: docker/login-action@v1 31 | with: 32 | username: ${{ secrets.DOCKER_USERNAME }} 33 | password: ${{ secrets.DOCKERHUB_TOKEN }} 34 | - name: Login to GitHub 35 | uses: docker/login-action@v1 36 | with: 37 | registry: ghcr.io 38 | username: ${{ github.repository_owner }} 39 | password: ${{ secrets.CR_PAT }} 40 | - name: Build and push 41 | uses: docker/build-push-action@v2 42 | with: 43 | context: . 44 | file: ./Dockerfile 45 | #platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64 46 | #platforms: linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7 47 | platforms: linux/amd64,linux/arm64,linux/arm/v7 48 | push: true 49 | # cache-from: type=local,src=/tmp/.buildx-cache 50 | # cache-to: type=local,dest=/tmp/.buildx-cache 51 | tags: | 52 | akhilrex/hammond:latest 53 | akhilrex/hammond:1.0.0 54 | ghcr.io/akhilrex/hammond:latest 55 | ghcr.io/akhilrex/hammond:1.0.0 56 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG GO_VERSION=1.16.2 2 | FROM golang:${GO_VERSION}-alpine AS builder 3 | RUN apk update && apk add alpine-sdk git && rm -rf /var/cache/apk/* 4 | RUN mkdir -p /api 5 | WORKDIR /api 6 | COPY ./server/go.mod . 7 | COPY ./server/go.sum . 8 | RUN go mod download 9 | COPY ./server . 10 | RUN go build -o ./app ./main.go 11 | 12 | FROM node:14 as build-stage 13 | WORKDIR /app 14 | COPY ./ui/package*.json ./ 15 | RUN npm install 16 | COPY ./ui . 17 | RUN npm run build 18 | 19 | 20 | FROM alpine:latest 21 | LABEL org.opencontainers.image.source="https://github.com/akhilrex/hammond" 22 | ENV CONFIG=/config 23 | ENV DATA=/assets 24 | ENV UID=998 25 | ENV PID=100 26 | ENV GIN_MODE=release 27 | VOLUME ["/config", "/assets"] 28 | RUN apk update && apk add ca-certificates && rm -rf /var/cache/apk/* 29 | RUN mkdir -p /config; \ 30 | mkdir -p /assets; \ 31 | mkdir -p /api 32 | RUN chmod 777 /config; \ 33 | chmod 777 /assets 34 | WORKDIR /api 35 | COPY --from=builder /api/app . 36 | #COPY dist ./dist 37 | COPY --from=build-stage /app/dist ./dist 38 | EXPOSE 3000 39 | ENTRYPOINT ["./app"] 40 | -------------------------------------------------------------------------------- /Screenshots.md: -------------------------------------------------------------------------------- 1 | ## Home Page / Summary 2 | 3 | ![Product Name Screen Shot][product-screenshot] 4 | 5 | ## Create Vehicle 6 | 7 | ![Podcast Episodes](images/vehicles_add.jpg) 8 | 9 | ## Vehicle Detail 10 | 11 | ![All Episodes](images/vehicle_detail.jpg) 12 | 13 | ## Create Fillup 14 | 15 | ![Podcast Episodes](images/create_fillup.jpg) 16 | 17 | ## Create Expense 18 | 19 | ![Player](images/create_expense.jpg) 20 | 21 | ## User Management 22 | 23 | ![Player](images/users.jpg) 24 | 25 | ## Settings 26 | 27 | ![Podcast Episodes](images/settings.jpg) 28 | 29 | [product-screenshot]: images/screenshot.jpg 30 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "2.1" 2 | services: 3 | hammond: 4 | image: akhilrex/hammond 5 | container_name: hammond 6 | environment: 7 | - JWT_SECRET=somethingverystrong 8 | volumes: 9 | - /path/to/config:/config 10 | - /path/to/data:/assets 11 | ports: 12 | - 3000:3000 13 | restart: unless-stopped 14 | -------------------------------------------------------------------------------- /docs/ubuntu-install.md: -------------------------------------------------------------------------------- 1 | # Building from source / Ubuntu Installation Guide 2 | 3 | Although personally I feel that using the docker container is the best way of using and enjoying something like Hammond, a lot of people in the community are still not comfortable with using Docker and wanted to host it natively on their Linux servers. 4 | 5 | This guide has been written with Ubuntu in mind. If you are using any other flavour of Linux and are decently competent with using command line tools, it should be easy to figure out the steps for your specific distro. 6 | 7 | ## Install Go and Node 8 | 9 | Hammond is built using Go and VueJS which means GO and Node would be needed to compile and build the source code. Hammond is written with Go 1.15/ Node v14 so any version equal to or above this should be good to go. 10 | 11 | If you already have Go and Node installed on your machine, you can skip to the next step. 12 | 13 | Get precise Go installation process at the official link here - https://golang.org/doc/install 14 | 15 | Get precise Node installation process at the official link here - https://nodejs.org/en/ 16 | 17 | 18 | Following steps will only work if Go and Node are installed and configured properly. 19 | 20 | ## Install dependencies 21 | 22 | ``` bash 23 | sudo apt-get install -y git ca-certificates ufw gcc 24 | ``` 25 | 26 | ## Clone from Git 27 | 28 | ``` bash 29 | git clone --depth 1 https://github.com/akhilrex/hammond 30 | ``` 31 | 32 | ## Build and Copy dependencies 33 | 34 | ``` bash 35 | cd hammond/server 36 | mkdir -p ./dist 37 | cp .env ./dist 38 | go build -o ./dist/hammond ./main.go 39 | ``` 40 | 41 | ## Create final destination and copy executable 42 | ``` bash 43 | sudo mkdir -p /usr/local/bin/hammond 44 | mv -v dist/* /usr/local/bin/hammond 45 | mv -v dist/.* /usr/local/bin/hammond 46 | ``` 47 | 48 | 49 | ## Building the UI 50 | 51 | Go back to the root of the hammond folder. 52 | 53 | ``` bash 54 | cd ui 55 | npm install 56 | npm run build 57 | mv dist /usr/local/bin/hammond 58 | ``` 59 | 60 | At this point theoretically the installation is complete. You can make the relevant changes in the ```.env``` file present at ```/usr/local/bin/hammond``` path and run the following command 61 | 62 | ``` bash 63 | cd /usr/local/bin/hammond && ./hammond 64 | ``` 65 | 66 | Point your browser to http://localhost:3000 (if trying on the same machine) or http://server-ip:3000 from other machines. 67 | 68 | If you are using ufw or some other firewall, you might have to make an exception for this port on that. 69 | 70 | ## Setup as service (Optional) 71 | 72 | If you want to run Hammond in the background as a service or auto-start whenever the server starts, follow the next steps. 73 | 74 | Create new file named ```hammond.service``` at ```/etc/systemd/system``` and add the following content. You will have to modify the content accordingly if you changed the installation path in the previous steps. 75 | 76 | 77 | ``` unit 78 | [Unit] 79 | Description=Hammond 80 | 81 | [Service] 82 | ExecStart=/usr/local/bin/hammond/hammond 83 | WorkingDirectory=/usr/local/bin/hammond/ 84 | [Install] 85 | WantedBy=multi-user.target 86 | ``` 87 | 88 | Run the following commands 89 | ``` bash 90 | sudo systemctl daemon-reload 91 | sudo systemctl enable hammond.service 92 | sudo systemctl start hammond.service 93 | ``` 94 | 95 | Run the following command to check the service status. 96 | 97 | ``` bash 98 | sudo systemctl status hammond.service 99 | ``` 100 | 101 | # Update Hammond 102 | 103 | In case you have installed Hammond and want to update the latest version (another area where Docker really shines) you need to repeat the steps from cloning to building and copying. 104 | 105 | Stop the running service (if using) 106 | ``` bash 107 | sudo systemctl stop hammond.service 108 | ``` 109 | 110 | ## Clone from Git 111 | 112 | ``` bash 113 | git clone --depth 1 https://github.com/akhilrex/hammond 114 | ``` 115 | 116 | ## Build and Copy dependencies 117 | 118 | ``` bash 119 | cd hammond 120 | mkdir -p ./dist 121 | cp .env ./dist 122 | go build -o ./dist/hammond ./main.go 123 | ``` 124 | 125 | ## Create final destination and copy executable 126 | ``` bash 127 | sudo mkdir -p /usr/local/bin/hammond 128 | mv -v dist/* /usr/local/bin/hammond 129 | ``` 130 | 131 | Go back to the root of the hammond folder. 132 | 133 | ``` bash 134 | cd ui 135 | npm install 136 | npm run build 137 | mv dist /usr/local/bin/hammond 138 | ``` 139 | 140 | Restart the service (if using) 141 | ``` bash 142 | sudo systemctl start hammond.service 143 | ``` 144 | -------------------------------------------------------------------------------- /images/create_expense.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akhilrex/hammond/84cba2c7f26f6d3f81c49b132110b24ac97c7b49/images/create_expense.jpg -------------------------------------------------------------------------------- /images/create_fillup.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akhilrex/hammond/84cba2c7f26f6d3f81c49b132110b24ac97c7b49/images/create_fillup.jpg -------------------------------------------------------------------------------- /images/screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akhilrex/hammond/84cba2c7f26f6d3f81c49b132110b24ac97c7b49/images/screenshot.jpg -------------------------------------------------------------------------------- /images/settings.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akhilrex/hammond/84cba2c7f26f6d3f81c49b132110b24ac97c7b49/images/settings.jpg -------------------------------------------------------------------------------- /images/users.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akhilrex/hammond/84cba2c7f26f6d3f81c49b132110b24ac97c7b49/images/users.jpg -------------------------------------------------------------------------------- /images/vehicle_detail.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akhilrex/hammond/84cba2c7f26f6d3f81c49b132110b24ac97c7b49/images/vehicle_detail.jpg -------------------------------------------------------------------------------- /images/vehicles_add.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akhilrex/hammond/84cba2c7f26f6d3f81c49b132110b24ac97c7b49/images/vehicles_add.jpg -------------------------------------------------------------------------------- /server/.env: -------------------------------------------------------------------------------- 1 | CONFIG=. 2 | DATA=./assets 3 | JWT_SECRET="A super strong secret that needs to be changed" 4 | -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | *.db 14 | 15 | # Dependency directories (remove the comment below to include it) 16 | # vendor/ 17 | assets/* 18 | keys/* 19 | backups/* 20 | nodemon.json 21 | dist/* -------------------------------------------------------------------------------- /server/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG GO_VERSION=1.15.2 2 | 3 | FROM golang:${GO_VERSION}-alpine AS builder 4 | 5 | RUN apk update && apk add alpine-sdk git && rm -rf /var/cache/apk/* 6 | 7 | RUN mkdir -p /api 8 | WORKDIR /api 9 | 10 | COPY go.mod . 11 | COPY go.sum . 12 | RUN go mod download 13 | 14 | COPY . . 15 | RUN go build -o ./app ./main.go 16 | 17 | FROM alpine:latest 18 | 19 | LABEL org.opencontainers.image.source="https://github.com/akhilrex/hammond" 20 | 21 | ENV CONFIG=/config 22 | ENV DATA=/assets 23 | ENV UID=998 24 | ENV PID=100 25 | ENV GIN_MODE=release 26 | VOLUME ["/config", "/assets"] 27 | RUN apk update && apk add ca-certificates && rm -rf /var/cache/apk/* 28 | RUN mkdir -p /config; \ 29 | mkdir -p /assets; \ 30 | mkdir -p /api 31 | 32 | RUN chmod 777 /config; \ 33 | chmod 777 /assets 34 | 35 | WORKDIR /api 36 | COPY --from=builder /api/app . 37 | COPY dist ./dist 38 | 39 | EXPOSE 3000 40 | 41 | ENTRYPOINT ["./app"] -------------------------------------------------------------------------------- /server/common/utils.go: -------------------------------------------------------------------------------- 1 | // Common tools and helper functions 2 | package common 3 | 4 | import ( 5 | "fmt" 6 | "math/rand" 7 | "os" 8 | "time" 9 | 10 | "github.com/akhilrex/hammond/db" 11 | "github.com/dgrijalva/jwt-go" 12 | "github.com/gin-gonic/gin" 13 | "github.com/gin-gonic/gin/binding" 14 | "github.com/go-playground/validator/v10" 15 | ) 16 | 17 | var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") 18 | 19 | // A helper function to generate random string 20 | func RandString(n int) string { 21 | b := make([]rune, n) 22 | for i := range b { 23 | b[i] = letters[rand.Intn(len(letters))] 24 | } 25 | return string(b) 26 | } 27 | 28 | // A Util function to generate jwt_token which can be used in the request header 29 | func GenToken(id string, role db.Role) (string, string) { 30 | jwt_token := jwt.New(jwt.GetSigningMethod("HS256")) 31 | // Set some claims 32 | jwt_token.Claims = jwt.MapClaims{ 33 | "id": id, 34 | "role": role, 35 | "exp": time.Now().Add(time.Hour * 24 * 3).Unix(), 36 | } 37 | // Sign and get the complete encoded token as a string 38 | token, _ := jwt_token.SignedString([]byte(os.Getenv("JWT_SECRET"))) 39 | 40 | refreshToken := jwt.New(jwt.SigningMethodHS256) 41 | rtClaims := refreshToken.Claims.(jwt.MapClaims) 42 | rtClaims["id"] = id 43 | rtClaims["exp"] = time.Now().Add(time.Hour * 24 * 20).Unix() 44 | 45 | rt, _ := refreshToken.SignedString([]byte(os.Getenv("JWT_SECRET"))) 46 | 47 | return token, rt 48 | } 49 | 50 | // My own Error type that will help return my customized Error info 51 | // {"database": {"hello":"no such table", error: "not_exists"}} 52 | type CommonError struct { 53 | Errors map[string]interface{} `json:"errors"` 54 | } 55 | 56 | // To handle the error returned by c.Bind in gin framework 57 | // https://github.com/go-playground/validator/blob/v9/_examples/translations/main.go 58 | func NewValidatorError(err error) CommonError { 59 | res := CommonError{} 60 | res.Errors = make(map[string]interface{}) 61 | errs := err.(validator.ValidationErrors) 62 | for _, v := range errs { 63 | // can translate each error one at a time. 64 | //fmt.Println("gg",v.NameNamespace) 65 | if v.Param() != "" { 66 | res.Errors[v.Field()] = fmt.Sprintf("{%v: %v}", v.Tag(), v.Param()) 67 | } else { 68 | res.Errors[v.Field()] = fmt.Sprintf("{key: %v}", v.Tag()) 69 | } 70 | 71 | } 72 | return res 73 | } 74 | 75 | // Warp the error info in a object 76 | func NewError(key string, err error) CommonError { 77 | res := CommonError{} 78 | res.Errors = make(map[string]interface{}) 79 | res.Errors[key] = err.Error() 80 | return res 81 | } 82 | 83 | // Changed the c.MustBindWith() -> c.ShouldBindWith(). 84 | // I don't want to auto return 400 when error happened. 85 | // origin function is here: https://github.com/gin-gonic/gin/blob/master/context.go 86 | func Bind(c *gin.Context, obj interface{}) error { 87 | b := binding.Default(c.Request.Method, c.ContentType()) 88 | return c.ShouldBindWith(obj, b) 89 | } 90 | -------------------------------------------------------------------------------- /server/controllers/import.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/akhilrex/hammond/service" 7 | "github.com/gin-gonic/gin" 8 | ) 9 | 10 | func RegisteImportController(router *gin.RouterGroup) { 11 | router.POST("/import/fuelly", fuellyImport) 12 | } 13 | 14 | func fuellyImport(c *gin.Context) { 15 | bytes, err := getFileBytes(c, "file") 16 | if err != nil { 17 | c.JSON(http.StatusUnprocessableEntity, err) 18 | return 19 | } 20 | errors := service.FuellyImport(bytes, c.MustGet("userId").(string)) 21 | if len(errors) > 0 { 22 | c.JSON(http.StatusUnprocessableEntity, gin.H{"errors": errors}) 23 | return 24 | } 25 | c.JSON(http.StatusOK, gin.H{}) 26 | } 27 | -------------------------------------------------------------------------------- /server/controllers/masters.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/akhilrex/hammond/common" 7 | "github.com/akhilrex/hammond/db" 8 | "github.com/akhilrex/hammond/models" 9 | "github.com/akhilrex/hammond/service" 10 | "github.com/gin-gonic/gin" 11 | ) 12 | 13 | func RegisterAnonMasterConroller(router *gin.RouterGroup) { 14 | router.GET("/masters", func(c *gin.Context) { 15 | c.JSON(http.StatusOK, gin.H{ 16 | "fuelUnits": db.FuelUnitDetails, 17 | "fuelTypes": db.FuelTypeDetails, 18 | "distanceUnits": db.DistanceUnitDetails, 19 | "roles": db.RoleDetails, 20 | "currencies": models.GetCurrencyMasterList(), 21 | }) 22 | }) 23 | } 24 | func RegisterMastersController(router *gin.RouterGroup) { 25 | 26 | router.GET("/settings", getSettings) 27 | router.POST("/settings", udpateSettings) 28 | router.POST("/me/settings", udpateMySettings) 29 | 30 | } 31 | 32 | func getSettings(c *gin.Context) { 33 | 34 | c.JSON(http.StatusOK, service.GetSettings()) 35 | } 36 | func udpateSettings(c *gin.Context) { 37 | var model models.UpdateSettingModel 38 | if err := c.ShouldBind(&model); err == nil { 39 | err := service.UpdateSettings(model.Currency, *model.DistanceUnit) 40 | if err != nil { 41 | c.JSON(http.StatusUnprocessableEntity, common.NewError("udpateSettings", err)) 42 | return 43 | } 44 | c.JSON(http.StatusOK, gin.H{}) 45 | } else { 46 | c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err)) 47 | } 48 | 49 | } 50 | 51 | func udpateMySettings(c *gin.Context) { 52 | var model models.UpdateSettingModel 53 | if err := c.ShouldBind(&model); err == nil { 54 | err := service.UpdateUserSettings(c.MustGet("userId").(string), model.Currency, *model.DistanceUnit, model.DateFormat) 55 | if err != nil { 56 | c.JSON(http.StatusUnprocessableEntity, common.NewError("udpateMySettings", err)) 57 | return 58 | } 59 | c.JSON(http.StatusOK, gin.H{}) 60 | } else { 61 | c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err)) 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /server/controllers/middlewares.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "net/http" 5 | "os" 6 | "strings" 7 | 8 | "github.com/akhilrex/hammond/db" 9 | "github.com/dgrijalva/jwt-go" 10 | "github.com/dgrijalva/jwt-go/request" 11 | "github.com/gin-gonic/gin" 12 | ) 13 | 14 | // Strips 'BEARER ' prefix from token string 15 | func stripBearerPrefixFromTokenString(tok string) (string, error) { 16 | // Should be a bearer token 17 | if len(tok) > 6 && strings.ToUpper(tok[0:6]) == "BEARER " { 18 | return tok[7:], nil 19 | } 20 | return tok, nil 21 | } 22 | 23 | // Extract token from Authorization header 24 | // Uses PostExtractionFilter to strip "TOKEN " prefix from header 25 | var AuthorizationHeaderExtractor = &request.PostExtractionFilter{ 26 | Extractor: request.HeaderExtractor{"Authorization"}, 27 | Filter: stripBearerPrefixFromTokenString, 28 | } 29 | 30 | // Extractor for OAuth2 access tokens. Looks in 'Authorization' 31 | // header then 'access_token' argument for a token. 32 | var MyAuth2Extractor = &request.MultiExtractor{ 33 | AuthorizationHeaderExtractor, 34 | request.ArgumentExtractor{"access_token"}, 35 | } 36 | 37 | // A helper to write user_id and user_model to the context 38 | func UpdateContextUserModel(c *gin.Context, my_user_id string) { 39 | var myUserModel db.User 40 | if my_user_id != "" { 41 | 42 | db.DB.First(&myUserModel, map[string]string{ 43 | "ID": my_user_id, 44 | }) 45 | } 46 | c.Set("userId", my_user_id) 47 | c.Set("userModel", myUserModel) 48 | } 49 | 50 | // You can custom middlewares yourself as the doc: https://github.com/gin-gonic/gin#custom-middleware 51 | // r.Use(AuthMiddleware(true)) 52 | func AuthMiddleware(auto401 bool) gin.HandlerFunc { 53 | return func(c *gin.Context) { 54 | UpdateContextUserModel(c, "") 55 | token, err := request.ParseFromRequest(c.Request, MyAuth2Extractor, func(token *jwt.Token) (interface{}, error) { 56 | b := ([]byte(os.Getenv("JWT_SECRET"))) 57 | return b, nil 58 | }) 59 | if err != nil { 60 | if auto401 { 61 | c.AbortWithError(http.StatusUnauthorized, err) 62 | } 63 | return 64 | } 65 | if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { 66 | my_user_id := claims["id"].(string) 67 | //fmt.Println(my_user_id,claims["id"]) 68 | UpdateContextUserModel(c, my_user_id) 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /server/controllers/reports.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/akhilrex/hammond/common" 7 | "github.com/akhilrex/hammond/models" 8 | "github.com/akhilrex/hammond/service" 9 | "github.com/gin-gonic/gin" 10 | ) 11 | 12 | func RegisterReportsController(router *gin.RouterGroup) { 13 | router.GET("/vehicles/:id/mileage", getMileageForVehicle) 14 | } 15 | 16 | func getMileageForVehicle(c *gin.Context) { 17 | 18 | var searchByIdQuery models.SearchByIdQuery 19 | 20 | if err := c.ShouldBindUri(&searchByIdQuery); err == nil { 21 | var model models.MileageQueryModel 22 | err := c.BindQuery(&model) 23 | if err != nil { 24 | c.JSON(http.StatusUnprocessableEntity, common.NewError("getMileageForVehicle", err)) 25 | return 26 | } 27 | 28 | fillups, err := service.GetMileageByVehicleId(searchByIdQuery.Id, model.Since) 29 | if err != nil { 30 | c.JSON(http.StatusUnprocessableEntity, common.NewError("getMileageForVehicle", err)) 31 | return 32 | } 33 | c.JSON(http.StatusOK, fillups) 34 | } else { 35 | c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err)) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /server/controllers/setup.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/akhilrex/hammond/common" 8 | "github.com/akhilrex/hammond/db" 9 | "github.com/akhilrex/hammond/models" 10 | "github.com/akhilrex/hammond/service" 11 | "github.com/gin-gonic/gin" 12 | ) 13 | 14 | func RegisterSetupController(router *gin.RouterGroup) { 15 | router.POST("/clarkson/check", canMigrate) 16 | router.POST("/clarkson/migrate", migrate) 17 | router.GET("/system/status", appInitialized) 18 | } 19 | 20 | func appInitialized(c *gin.Context) { 21 | canInitialize, err := service.CanInitializeSystem() 22 | message := "" 23 | if err != nil { 24 | message = err.Error() 25 | } 26 | c.JSON(http.StatusOK, gin.H{"initialized": !canInitialize, "message": message}) 27 | } 28 | 29 | func canMigrate(c *gin.Context) { 30 | var request models.ClarksonMigrationModel 31 | if err := c.ShouldBind(&request); err == nil { 32 | canMigrate, data, errr := db.CanMigrate(request.Url) 33 | errorMessage := "" 34 | if errr != nil { 35 | errorMessage = errr.Error() 36 | } 37 | 38 | c.JSON(http.StatusOK, gin.H{ 39 | "canMigrate": canMigrate, 40 | "data": data, 41 | "message": errorMessage, 42 | }) 43 | } else { 44 | c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err)) 45 | } 46 | } 47 | 48 | func migrate(c *gin.Context) { 49 | var request models.ClarksonMigrationModel 50 | if err := c.ShouldBind(&request); err == nil { 51 | canMigrate, _, _ := db.CanMigrate(request.Url) 52 | 53 | if !canMigrate { 54 | c.JSON(http.StatusBadRequest, fmt.Errorf("cannot migrate database. please check connection string")) 55 | return 56 | } 57 | 58 | success, err := db.MigrateClarkson(request.Url) 59 | if !success { 60 | c.JSON(http.StatusBadRequest, err) 61 | return 62 | } 63 | 64 | c.JSON(http.StatusOK, gin.H{ 65 | "success": success, 66 | }) 67 | } else { 68 | c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err)) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /server/controllers/users.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/akhilrex/hammond/common" 7 | "github.com/akhilrex/hammond/db" 8 | "github.com/akhilrex/hammond/models" 9 | "github.com/akhilrex/hammond/service" 10 | "github.com/gin-gonic/gin" 11 | ) 12 | 13 | func RegisterUserController(router *gin.RouterGroup) { 14 | router.GET("/users", allUsers) 15 | router.POST("/users/:id/enable", ShouldBeAdmin(), enableUser) 16 | router.POST("/users/:id/disable", ShouldBeAdmin(), disableUser) 17 | } 18 | 19 | func allUsers(c *gin.Context) { 20 | users, err := db.GetAllUsers() 21 | if err != nil { 22 | c.JSON(http.StatusBadRequest, err) 23 | return 24 | } 25 | c.JSON(http.StatusOK, users) 26 | 27 | } 28 | func enableUser(c *gin.Context) { 29 | var searchByIdQuery models.SearchByIdQuery 30 | if err := c.ShouldBindUri(&searchByIdQuery); err == nil { 31 | err := service.SetDisabledStatusForUser(searchByIdQuery.Id, false) 32 | if err != nil { 33 | c.JSON(http.StatusBadRequest, err) 34 | return 35 | } 36 | c.JSON(http.StatusOK, gin.H{}) 37 | } else { 38 | c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err)) 39 | } 40 | 41 | } 42 | func disableUser(c *gin.Context) { 43 | var searchByIdQuery models.SearchByIdQuery 44 | if err := c.ShouldBindUri(&searchByIdQuery); err == nil { 45 | err := service.SetDisabledStatusForUser(searchByIdQuery.Id, true) 46 | if err != nil { 47 | c.JSON(http.StatusBadRequest, err) 48 | return 49 | } 50 | c.JSON(http.StatusOK, gin.H{}) 51 | } else { 52 | c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err)) 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /server/db/base.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "time" 5 | 6 | uuid "github.com/satori/go.uuid" 7 | "gorm.io/gorm" 8 | ) 9 | 10 | //Base is 11 | type Base struct { 12 | ID string `sql:"type:uuid;primary_key" json:"id"` 13 | CreatedAt time.Time `json:"createdAt"` 14 | UpdatedAt time.Time `json:"updatedAt"` 15 | DeletedAt *time.Time `gorm:"index" json:"deletedAt"` 16 | } 17 | 18 | //BeforeCreate 19 | func (base *Base) BeforeCreate(tx *gorm.DB) error { 20 | tx.Statement.SetColumn("ID", uuid.NewV4().String()) 21 | return nil 22 | } 23 | -------------------------------------------------------------------------------- /server/db/db.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "path" 8 | 9 | "gorm.io/driver/sqlite" 10 | 11 | "gorm.io/gorm" 12 | ) 13 | 14 | //DB is 15 | var DB *gorm.DB 16 | 17 | //Init is used to Initialize Database 18 | func Init() (*gorm.DB, error) { 19 | // github.com/mattn/go-sqlite3 20 | configPath := os.Getenv("CONFIG") 21 | dbPath := path.Join(configPath, "hammond.db") 22 | log.Println(dbPath) 23 | db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{ 24 | DisableForeignKeyConstraintWhenMigrating: true, 25 | }) 26 | if err != nil { 27 | fmt.Println("db err: ", err) 28 | return nil, err 29 | } 30 | 31 | localDB, _ := db.DB() 32 | localDB.SetMaxIdleConns(10) 33 | //db.LogMode(true) 34 | DB = db 35 | return DB, nil 36 | } 37 | 38 | //Migrate Database 39 | func Migrate() { 40 | err := DB.AutoMigrate(&Attachment{}, &QuickEntry{}, &User{}, &Vehicle{}, &UserVehicle{}, &VehicleAttachment{}, &Fillup{}, &Expense{}, &Setting{}, &JobLock{}, &Migration{}) 41 | if err != nil { 42 | fmt.Println("1 " + err.Error()) 43 | } 44 | err = DB.SetupJoinTable(&User{}, "Vehicles", &UserVehicle{}) 45 | if err != nil { 46 | fmt.Println(err.Error()) 47 | } 48 | err = DB.SetupJoinTable(&Vehicle{}, "Attachments", &VehicleAttachment{}) 49 | if err != nil { 50 | fmt.Println(err.Error()) 51 | } 52 | RunMigrations() 53 | } 54 | 55 | // Using this function to get a connection, you can create your connection pool here. 56 | func GetDB() *gorm.DB { 57 | return DB 58 | } 59 | -------------------------------------------------------------------------------- /server/db/enums.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | type FuelUnit int 4 | 5 | const ( 6 | LITRE FuelUnit = iota 7 | GALLON 8 | US_GALLON 9 | KILOGRAM 10 | KILOWATT_HOUR 11 | MINUTE 12 | ) 13 | 14 | type FuelType int 15 | 16 | const ( 17 | PETROL FuelType = iota 18 | DIESEL 19 | ETHANOL 20 | CNG 21 | ELECTRIC 22 | LPG 23 | ) 24 | 25 | type DistanceUnit int 26 | 27 | const ( 28 | MILES DistanceUnit = iota 29 | KILOMETERS 30 | ) 31 | 32 | type Role int 33 | 34 | const ( 35 | ADMIN Role = iota 36 | USER 37 | ) 38 | 39 | type AlertFrequency int 40 | 41 | const ( 42 | ONETIME AlertFrequency = iota 43 | RECURRING 44 | ) 45 | 46 | type AlertType int 47 | 48 | const ( 49 | DISTANCE AlertType = iota 50 | TIME 51 | BOTH 52 | ) 53 | 54 | type EnumDetail struct { 55 | Short string `json:"short"` 56 | Long string `json:"long"` 57 | } 58 | 59 | var FuelUnitDetails map[FuelUnit]EnumDetail = map[FuelUnit]EnumDetail{ 60 | LITRE: { 61 | Short: "Lt", 62 | Long: "Litre", 63 | }, 64 | GALLON: { 65 | Short: "Gal", 66 | Long: "Gallon", 67 | }, KILOGRAM: { 68 | Short: "Kg", 69 | Long: "Kilogram", 70 | }, KILOWATT_HOUR: { 71 | Short: "KwH", 72 | Long: "Kilowatt Hour", 73 | }, US_GALLON: { 74 | Short: "US Gal", 75 | Long: "US Gallon", 76 | }, 77 | MINUTE: { 78 | Short: "Mins", 79 | Long: "Minutes", 80 | }, 81 | } 82 | 83 | var FuelTypeDetails map[FuelType]EnumDetail = map[FuelType]EnumDetail{ 84 | PETROL: { 85 | Short: "Petrol", 86 | Long: "Petrol", 87 | }, 88 | DIESEL: { 89 | Short: "Diesel", 90 | Long: "Diesel", 91 | }, CNG: { 92 | Short: "CNG", 93 | Long: "CNG", 94 | }, LPG: { 95 | Short: "LPG", 96 | Long: "LPG", 97 | }, ELECTRIC: { 98 | Short: "Electric", 99 | Long: "Electric", 100 | }, ETHANOL: { 101 | Short: "Ethanol", 102 | Long: "Ethanol", 103 | }, 104 | } 105 | 106 | var DistanceUnitDetails map[DistanceUnit]EnumDetail = map[DistanceUnit]EnumDetail{ 107 | KILOMETERS: { 108 | Short: "Km", 109 | Long: "Kilometers", 110 | }, 111 | MILES: { 112 | Short: "Mi", 113 | Long: "Miles", 114 | }, 115 | } 116 | 117 | var RoleDetails map[Role]EnumDetail = map[Role]EnumDetail{ 118 | ADMIN: { 119 | Short: "Admin", 120 | Long: "ADMIN", 121 | }, 122 | USER: { 123 | Short: "User", 124 | Long: "USER", 125 | }, 126 | } 127 | -------------------------------------------------------------------------------- /server/db/migrations.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "time" 7 | 8 | "gorm.io/gorm" 9 | ) 10 | 11 | type localMigration struct { 12 | Name string 13 | Query string 14 | } 15 | 16 | var migrations = []localMigration{ 17 | { 18 | Name: "2021_06_24_04_42_SetUserDisabledFalse", 19 | Query: "update users set is_disabled=0", 20 | }, 21 | { 22 | Name: "2021_02_07_00_09_LowerCaseEmails", 23 | Query: "update users set email=lower(email)", 24 | }, 25 | } 26 | 27 | func RunMigrations() { 28 | for _, mig := range migrations { 29 | ExecuteAndSaveMigration(mig.Name, mig.Query) 30 | } 31 | } 32 | func ExecuteAndSaveMigration(name string, query string) error { 33 | var migration Migration 34 | result := DB.Where("name=?", name).First(&migration) 35 | if errors.Is(result.Error, gorm.ErrRecordNotFound) { 36 | fmt.Println(query) 37 | result = DB.Debug().Exec(query) 38 | if result.Error == nil { 39 | DB.Save(&Migration{ 40 | Date: time.Now(), 41 | Name: name, 42 | }) 43 | } 44 | return result.Error 45 | } 46 | return nil 47 | } 48 | -------------------------------------------------------------------------------- /server/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/akhilrex/hammond 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/dgrijalva/jwt-go v3.2.0+incompatible 7 | github.com/gin-contrib/location v0.0.2 8 | github.com/gin-gonic/contrib v0.0.0-20201101042839-6a891bf89f19 // indirect 9 | github.com/gin-gonic/gin v1.7.1 10 | github.com/go-playground/validator/v10 v10.4.1 11 | github.com/jasonlvhit/gocron v0.0.1 12 | github.com/joho/godotenv v1.3.0 13 | github.com/leekchan/accounting v1.0.0 // indirect 14 | github.com/satori/go.uuid v1.2.0 15 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 16 | golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1 17 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 18 | gorm.io/driver/mysql v1.0.5 19 | gorm.io/driver/sqlite v1.1.4 20 | gorm.io/gorm v1.21.3 21 | ) 22 | -------------------------------------------------------------------------------- /server/internal/sanitize/.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | -------------------------------------------------------------------------------- /server/internal/sanitize/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Mechanism Design. All rights reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are 5 | met: 6 | 7 | * Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above 10 | copyright notice, this list of conditions and the following disclaimer 11 | in the documentation and/or other materials provided with the 12 | distribution. 13 | * Neither the name of Google Inc. nor the names of its 14 | contributors may be used to endorse or promote products derived from 15 | this software without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 18 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 19 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 20 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 21 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 22 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 23 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 24 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 25 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /server/internal/sanitize/README.md: -------------------------------------------------------------------------------- 1 | sanitize [![GoDoc](https://godoc.org/github.com/kennygrant/sanitize?status.svg)](https://godoc.org/github.com/kennygrant/sanitize) [![Go Report Card](https://goreportcard.com/badge/github.com/kennygrant/sanitize)](https://goreportcard.com/report/github.com/kennygrant/sanitize) [![CircleCI](https://circleci.com/gh/kennygrant/sanitize.svg?style=svg)](https://circleci.com/gh/kennygrant/sanitize) 2 | ======== 3 | 4 | Package sanitize provides functions to sanitize html and paths with go (golang). 5 | 6 | FUNCTIONS 7 | 8 | 9 | ```go 10 | sanitize.Accents(s string) string 11 | ``` 12 | 13 | Accents replaces a set of accented characters with ascii equivalents. 14 | 15 | ```go 16 | sanitize.BaseName(s string) string 17 | ``` 18 | 19 | BaseName makes a string safe to use in a file name, producing a sanitized basename replacing . or / with -. Unlike Name no attempt is made to normalise text as a path. 20 | 21 | ```go 22 | sanitize.HTML(s string) string 23 | ``` 24 | 25 | HTML strips html tags with a very simple parser, replace common entities, and escape < and > in the result. The result is intended to be used as plain text. 26 | 27 | ```go 28 | sanitize.HTMLAllowing(s string, args...[]string) (string, error) 29 | ``` 30 | 31 | HTMLAllowing parses html and allow certain tags and attributes from the lists optionally specified by args - args[0] is a list of allowed tags, args[1] is a list of allowed attributes. If either is missing default sets are used. 32 | 33 | ```go 34 | sanitize.Name(s string) string 35 | ``` 36 | 37 | Name makes a string safe to use in a file name by first finding the path basename, then replacing non-ascii characters. 38 | 39 | ```go 40 | sanitize.Path(s string) string 41 | ``` 42 | 43 | Path makes a string safe to use as an url path. 44 | 45 | 46 | Changes 47 | ------- 48 | 49 | Version 1.2 50 | 51 | Adjusted HTML function to avoid linter warning 52 | Added more tests from https://githubengineering.com/githubs-post-csp-journey/ 53 | Chnaged name of license file 54 | Added badges and change log to readme 55 | 56 | Version 1.1 57 | Fixed type in comments. 58 | Merge pull request from Povilas Balzaravicius Pawka 59 | - replace br tags with newline even when they contain a space 60 | 61 | Version 1.0 62 | First release -------------------------------------------------------------------------------- /server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | 8 | "github.com/akhilrex/hammond/controllers" 9 | "github.com/akhilrex/hammond/db" 10 | "github.com/akhilrex/hammond/service" 11 | "github.com/gin-contrib/location" 12 | "github.com/gin-gonic/contrib/static" 13 | "github.com/gin-gonic/gin" 14 | "github.com/jasonlvhit/gocron" 15 | _ "github.com/joho/godotenv/autoload" 16 | ) 17 | 18 | func main() { 19 | var err error 20 | db.DB, err = db.Init() 21 | if err != nil { 22 | fmt.Println("status: ", err) 23 | } else { 24 | db.Migrate() 25 | } 26 | r := gin.Default() 27 | 28 | r.Use(setupSettings()) 29 | r.Use(gin.Recovery()) 30 | r.Use(location.Default()) 31 | r.Use(static.Serve("/", static.LocalFile("./dist", true))) 32 | r.NoRoute(func(c *gin.Context) { 33 | //fmt.'Println(c.Request.URL.Path) 34 | c.File("dist/index.html") 35 | }) 36 | router := r.Group("/api") 37 | 38 | dataPath := os.Getenv("DATA") 39 | 40 | router.Static("/assets/", dataPath) 41 | 42 | controllers.RegisterAnonController(router) 43 | controllers.RegisterAnonMasterConroller(router) 44 | controllers.RegisterSetupController(router) 45 | 46 | router.Use(controllers.AuthMiddleware(true)) 47 | controllers.RegisterUserController(router) 48 | controllers.RegisterMastersController(router) 49 | controllers.RegisterAuthController(router) 50 | controllers.RegisterVehicleController(router) 51 | controllers.RegisterFilesController(router) 52 | controllers.RegisteImportController(router) 53 | controllers.RegisterReportsController(router) 54 | 55 | go assetEnv() 56 | go intiCron() 57 | 58 | r.Run(":3000") // listen and serve on 0.0.0.0:8080 (for windows "localhost:8080") 59 | 60 | } 61 | func setupSettings() gin.HandlerFunc { 62 | return func(c *gin.Context) { 63 | 64 | setting := db.GetOrCreateSetting() 65 | c.Set("setting", setting) 66 | c.Writer.Header().Set("X-Clacks-Overhead", "GNU Terry Pratchett") 67 | 68 | c.Next() 69 | } 70 | } 71 | 72 | func intiCron() { 73 | 74 | //gocron.Every(uint64(checkFrequency)).Minutes().Do(service.DownloadMissingEpisodes) 75 | gocron.Every(2).Days().Do(service.CreateBackup) 76 | <-gocron.Start() 77 | } 78 | 79 | func assetEnv() { 80 | log.Println("Config Dir: ", os.Getenv("CONFIG")) 81 | log.Println("Assets Dir: ", os.Getenv("DATA")) 82 | } 83 | -------------------------------------------------------------------------------- /server/models/alert.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/akhilrex/hammond/db" 7 | ) 8 | 9 | type CreateAlertModel struct { 10 | Comments string `json:"comments"` 11 | Title string `json:"title"` 12 | StartDate time.Time `json:"date"` 13 | StartOdoReading int `json:"startOdoReading"` 14 | DistanceUnit *db.DistanceUnit `json:"distanceUnit"` 15 | AlertFrequency *db.AlertFrequency `json:"alertFrequency"` 16 | OdoFrequency int `json:"odoFrequency"` 17 | DayFrequency int `json:"dayFrequency"` 18 | AlertAllUsers bool `json:"alertAllUsers"` 19 | IsActive bool `json:"isActive"` 20 | AlertType *db.AlertType `json:"alertType"` 21 | } 22 | -------------------------------------------------------------------------------- /server/models/auth.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "github.com/akhilrex/hammond/db" 4 | 5 | type LoginResponse struct { 6 | Name string `json:"name"` 7 | Email string `json:"email"` 8 | Token string `json:"token"` 9 | RefreshToken string `json:"refreshToken"` 10 | Role string `json:"role"` 11 | } 12 | 13 | type LoginRequest struct { 14 | Email string `form:"email" json:"email" binding:"required,email"` 15 | Password string `form:"password" json:"password" binding:"required,max=255"` 16 | } 17 | 18 | type RegisterRequest struct { 19 | Name string `form:"name" json:"name"` 20 | Email string `form:"email" json:"email" binding:"required,email"` 21 | Password string `form:"password" json:"password" binding:"required,min=8,max=255"` 22 | Currency string `json:"currency" form:"currency" query:"currency"` 23 | DistanceUnit *db.DistanceUnit `json:"distanceUnit" form:"distanceUnit" query:"distanceUnit" ` 24 | Role *db.Role `json:"role" form:"role" query:"role" ` 25 | } 26 | 27 | type ChangePasswordRequest struct { 28 | OldPassword string `form:"oldPassword" json:"oldPassword" binding:"required,max=255"` 29 | NewPassword string `form:"newPassword" json:"newPassword" binding:"required,min=8,max=255"` 30 | } 31 | -------------------------------------------------------------------------------- /server/models/errors.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "fmt" 4 | 5 | type VehicleAlreadyExistsError struct { 6 | Registration string 7 | } 8 | 9 | func (e *VehicleAlreadyExistsError) Error() string { 10 | return fmt.Sprintf("Vehicle with this url already exists") 11 | } 12 | -------------------------------------------------------------------------------- /server/models/files.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type CreateQuickEntryModel struct { 4 | Comments string `json:"comments" form:"comments"` 5 | } 6 | -------------------------------------------------------------------------------- /server/models/misc.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "github.com/akhilrex/hammond/db" 4 | 5 | type UpdateSettingModel struct { 6 | Currency string `json:"currency" form:"currency" query:"currency"` 7 | DateFormat string `json:"dateFormat" form:"dateFormat" query:"dateFormat"` 8 | DistanceUnit *db.DistanceUnit `json:"distanceUnit" form:"distanceUnit" query:"distanceUnit" ` 9 | } 10 | 11 | type ClarksonMigrationModel struct { 12 | Url string `json:"url" form:"url" query:"url"` 13 | } 14 | -------------------------------------------------------------------------------- /server/models/report.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "encoding/json" 5 | "time" 6 | 7 | "github.com/akhilrex/hammond/db" 8 | ) 9 | 10 | type MileageModel struct { 11 | Date time.Time `form:"date" json:"date" binding:"required" time_format:"2006-01-02"` 12 | VehicleID string `form:"vehicleId" json:"vehicleId" binding:"required"` 13 | FuelUnit db.FuelUnit `form:"fuelUnit" json:"fuelUnit" binding:"required"` 14 | FuelQuantity float32 `form:"fuelQuantity" json:"fuelQuantity" binding:"required"` 15 | PerUnitPrice float32 `form:"perUnitPrice" json:"perUnitPrice" binding:"required"` 16 | Currency string `json:"currency"` 17 | 18 | Mileage float32 `form:"mileage" json:"mileage" binding:"mileage"` 19 | CostPerMile float32 `form:"costPerMile" json:"costPerMile" binding:"costPerMile"` 20 | OdoReading int `form:"odoReading" json:"odoReading" binding:"odoReading"` 21 | } 22 | 23 | func (v *MileageModel) FuelUnitDetail() db.EnumDetail { 24 | return db.FuelUnitDetails[v.FuelUnit] 25 | } 26 | func (b *MileageModel) MarshalJSON() ([]byte, error) { 27 | return json.Marshal(struct { 28 | MileageModel 29 | FuelUnitDetail db.EnumDetail `json:"fuelUnitDetail"` 30 | }{ 31 | MileageModel: *b, 32 | FuelUnitDetail: b.FuelUnitDetail(), 33 | }) 34 | } 35 | 36 | type MileageQueryModel struct { 37 | Since time.Time `json:"since" query:"since" form:"since"` 38 | } 39 | -------------------------------------------------------------------------------- /server/service/importService.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "bytes" 5 | "encoding/csv" 6 | "fmt" 7 | "strconv" 8 | "time" 9 | 10 | "github.com/akhilrex/hammond/db" 11 | "github.com/leekchan/accounting" 12 | ) 13 | 14 | func FuellyImport(content []byte, userId string) []string { 15 | stream := bytes.NewReader(content) 16 | reader := csv.NewReader(stream) 17 | records, err := reader.ReadAll() 18 | 19 | var errors []string 20 | if err != nil { 21 | errors = append(errors, err.Error()) 22 | return errors 23 | } 24 | 25 | vehicles, err := GetUserVehicles(userId) 26 | if err != nil { 27 | errors = append(errors, err.Error()) 28 | return errors 29 | } 30 | user, err := GetUserById(userId) 31 | 32 | if err != nil { 33 | errors = append(errors, err.Error()) 34 | return errors 35 | } 36 | 37 | var vehicleMap map[string]db.Vehicle = make(map[string]db.Vehicle) 38 | for _, vehicle := range *vehicles { 39 | vehicleMap[vehicle.Nickname] = vehicle 40 | } 41 | 42 | var fillups []db.Fillup 43 | var expenses []db.Expense 44 | layout := "2006-01-02 15:04" 45 | altLayout := "2006-01-02 3:04 PM" 46 | 47 | for index, record := range records { 48 | if index == 0 { 49 | continue 50 | } 51 | 52 | var vehicle db.Vehicle 53 | var ok bool 54 | if vehicle, ok = vehicleMap[record[4]]; !ok { 55 | errors = append(errors, "Found an unmapped vehicle entry at row "+strconv.Itoa(index+1)) 56 | } 57 | dateStr := record[2] + " " + record[3] 58 | date, err := time.Parse(layout, dateStr) 59 | if err != nil { 60 | date, err = time.Parse(altLayout, dateStr) 61 | } 62 | if err != nil { 63 | errors = append(errors, "Found an invalid date/time at row "+strconv.Itoa(index+1)) 64 | } 65 | 66 | totalCostStr := accounting.UnformatNumber(record[9], 3, user.Currency) 67 | totalCost64, err := strconv.ParseFloat(totalCostStr, 32) 68 | if err != nil { 69 | errors = append(errors, "Found an invalid total cost at row "+strconv.Itoa(index+1)) 70 | } 71 | 72 | totalCost := float32(totalCost64) 73 | odoStr := accounting.UnformatNumber(record[5], 0, user.Currency) 74 | odoreading, err := strconv.Atoi(odoStr) 75 | if err != nil { 76 | errors = append(errors, "Found an invalid odo reading at row "+strconv.Itoa(index+1)) 77 | } 78 | location := record[12] 79 | 80 | //Create Fillup 81 | if record[0] == "Gas" { 82 | rateStr := accounting.UnformatNumber(record[7], 3, user.Currency) 83 | ratet64, err := strconv.ParseFloat(rateStr, 32) 84 | if err != nil { 85 | errors = append(errors, "Found an invalid cost per gallon at row "+strconv.Itoa(index+1)) 86 | } 87 | rate := float32(ratet64) 88 | 89 | quantity64, err := strconv.ParseFloat(record[8], 32) 90 | if err != nil { 91 | errors = append(errors, "Found an invalid quantity at row "+strconv.Itoa(index+1)) 92 | } 93 | quantity := float32(quantity64) 94 | 95 | notes := fmt.Sprintf("Octane:%s\nGas Brand:%s\nLocation%s\nTags:%s\nPayment Type:%s\nTire Pressure:%s\nNotes:%s\nMPG:%s", 96 | record[10], record[11], record[12], record[13], record[14], record[15], record[16], record[1], 97 | ) 98 | 99 | isTankFull := record[6] == "Full" 100 | fal := false 101 | fillups = append(fillups, db.Fillup{ 102 | VehicleID: vehicle.ID, 103 | FuelUnit: vehicle.FuelUnit, 104 | FuelQuantity: quantity, 105 | PerUnitPrice: rate, 106 | TotalAmount: totalCost, 107 | OdoReading: odoreading, 108 | IsTankFull: &isTankFull, 109 | Comments: notes, 110 | FillingStation: location, 111 | HasMissedFillup: &fal, 112 | UserID: userId, 113 | Date: date, 114 | Currency: user.Currency, 115 | DistanceUnit: user.DistanceUnit, 116 | Source: "Fuelly", 117 | }) 118 | 119 | } 120 | if record[0] == "Service" { 121 | notes := fmt.Sprintf("Tags:%s\nPayment Type:%s\nNotes:%s", 122 | record[13], record[14], record[16], 123 | ) 124 | expenses = append(expenses, db.Expense{ 125 | VehicleID: vehicle.ID, 126 | Amount: totalCost, 127 | OdoReading: odoreading, 128 | Comments: notes, 129 | ExpenseType: record[17], 130 | UserID: userId, 131 | Currency: user.Currency, 132 | Date: date, 133 | DistanceUnit: user.DistanceUnit, 134 | Source: "Fuelly", 135 | }) 136 | } 137 | 138 | } 139 | if len(errors) != 0 { 140 | return errors 141 | } 142 | 143 | tx := db.DB.Begin() 144 | defer func() { 145 | if r := recover(); r != nil { 146 | tx.Rollback() 147 | } 148 | }() 149 | if err := tx.Error; err != nil { 150 | errors = append(errors, err.Error()) 151 | return errors 152 | } 153 | if err := tx.Create(&fillups).Error; err != nil { 154 | tx.Rollback() 155 | errors = append(errors, err.Error()) 156 | return errors 157 | } 158 | if err := tx.Create(&expenses).Error; err != nil { 159 | tx.Rollback() 160 | errors = append(errors, err.Error()) 161 | return errors 162 | } 163 | err = tx.Commit().Error 164 | if err != nil { 165 | errors = append(errors, err.Error()) 166 | } 167 | return errors 168 | } 169 | -------------------------------------------------------------------------------- /server/service/miscService.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "github.com/akhilrex/hammond/db" 5 | ) 6 | 7 | func CanInitializeSystem() (bool, error) { 8 | return db.CanInitializeSystem() 9 | } 10 | 11 | func UpdateSettings(currency string, distanceUnit db.DistanceUnit) error { 12 | setting := db.GetOrCreateSetting() 13 | setting.Currency = currency 14 | setting.DistanceUnit = distanceUnit 15 | return db.UpdateSettings(setting) 16 | } 17 | func UpdateUserSettings(userId, currency string, distanceUnit db.DistanceUnit, dateFormat string) error { 18 | user, err := db.GetUserById(userId) 19 | if err != nil { 20 | return err 21 | } 22 | 23 | user.Currency = currency 24 | user.DistanceUnit = distanceUnit 25 | user.DateFormat = dateFormat 26 | return db.UpdateUser(user) 27 | } 28 | 29 | func GetSettings() *db.Setting { 30 | return db.GetOrCreateSetting() 31 | } 32 | -------------------------------------------------------------------------------- /server/service/reportService.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "sort" 5 | "time" 6 | 7 | "github.com/akhilrex/hammond/db" 8 | "github.com/akhilrex/hammond/models" 9 | ) 10 | 11 | func GetMileageByVehicleId(vehicleId string, since time.Time) (mileage []models.MileageModel, err error) { 12 | data, err := db.GetFillupsByVehicleIdSince(vehicleId, since) 13 | if err != nil { 14 | return nil, err 15 | } 16 | 17 | fillups := make([]db.Fillup, len(*data)) 18 | copy(fillups, *data) 19 | sort.Slice(fillups, func(i, j int) bool { 20 | return fillups[i].OdoReading > fillups[j].OdoReading 21 | }) 22 | 23 | var mileages []models.MileageModel 24 | 25 | for i := 0; i < len(fillups)-1; i++ { 26 | last := i + 1 27 | 28 | currentFillup := fillups[i] 29 | lastFillup := fillups[last] 30 | 31 | mileage := models.MileageModel{ 32 | Date: currentFillup.Date, 33 | VehicleID: currentFillup.VehicleID, 34 | FuelUnit: currentFillup.FuelUnit, 35 | FuelQuantity: currentFillup.FuelQuantity, 36 | PerUnitPrice: currentFillup.PerUnitPrice, 37 | OdoReading: currentFillup.OdoReading, 38 | Currency: currentFillup.Currency, 39 | Mileage: 0, 40 | CostPerMile: 0, 41 | } 42 | 43 | if currentFillup.IsTankFull != nil && *currentFillup.IsTankFull && (currentFillup.HasMissedFillup == nil || !(*currentFillup.HasMissedFillup)) { 44 | distance := float32(currentFillup.OdoReading - lastFillup.OdoReading) 45 | mileage.Mileage = distance / currentFillup.FuelQuantity 46 | mileage.CostPerMile = distance / currentFillup.TotalAmount 47 | 48 | } 49 | 50 | mileages = append(mileages, mileage) 51 | } 52 | if mileages == nil { 53 | mileages = make([]models.MileageModel, 0) 54 | } 55 | return mileages, nil 56 | } 57 | -------------------------------------------------------------------------------- /server/service/userService.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/akhilrex/hammond/db" 7 | "github.com/akhilrex/hammond/models" 8 | ) 9 | 10 | func CreateUser(userModel *models.RegisterRequest, role db.Role) error { 11 | setting := db.GetOrCreateSetting() 12 | toCreate := db.User{ 13 | Email: strings.ToLower(userModel.Email), 14 | Name: userModel.Name, 15 | Role: role, 16 | Currency: setting.Currency, 17 | DistanceUnit: setting.DistanceUnit, 18 | DateFormat: "MM/dd/yyyy", 19 | } 20 | 21 | toCreate.SetPassword(userModel.Password) 22 | 23 | return db.CreateUser(&toCreate) 24 | 25 | } 26 | 27 | func GetUserById(id string) (*db.User, error) { 28 | var myUserModel db.User 29 | tx := db.DB.Debug().Preload("Vehicles").First(&myUserModel, map[string]string{ 30 | "ID": id, 31 | }) 32 | return &myUserModel, tx.Error 33 | } 34 | 35 | func GetAllUsers() (*[]db.User, error) { 36 | return db.GetAllUsers() 37 | } 38 | 39 | func UpdatePassword(id, password string) (bool, error) { 40 | user, err := GetUserById(id) 41 | if err != nil { 42 | return false, err 43 | } 44 | user.SetPassword(password) 45 | err = db.UpdateUser(user) 46 | if err != nil { 47 | return false, err 48 | } 49 | return true, nil 50 | } 51 | func SetDisabledStatusForUser(userId string, isDisabled bool) error { 52 | return db.SetDisabledStatusForUser(userId, isDisabled) 53 | } 54 | -------------------------------------------------------------------------------- /ui/.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | -------------------------------------------------------------------------------- /ui/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /ui/.env: -------------------------------------------------------------------------------- 1 | API_BASE_URL=http://localhost:3000 2 | -------------------------------------------------------------------------------- /ui/.eslintignore: -------------------------------------------------------------------------------- 1 | /dist/ 2 | /tests/unit/coverage/ 3 | -------------------------------------------------------------------------------- /ui/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parserOptions: { 4 | sourceType: 'script', 5 | }, 6 | extends: [ 7 | // https://github.com/vuejs/eslint-plugin-vue#bulb-rules 8 | 'plugin:vue/recommended', 9 | // https://github.com/standard/standard/blob/master/docs/RULES-en.md 10 | 'standard', 11 | // https://github.com/prettier/eslint-config-prettier 12 | 'prettier', 13 | 'prettier/standard', 14 | 'prettier/vue', 15 | ], 16 | rules: { 17 | // Only allow debugger in development 18 | 'no-debugger': process.env.PRE_COMMIT ? 'error' : 'off', 19 | // Only allow `console.log` in development 20 | 'no-console': process.env.PRE_COMMIT 21 | ? ['error', { allow: ['warn', 'error'] }] 22 | : 'off', 23 | 'import/no-relative-parent-imports': 'error', 24 | 'import/order': 'error', 25 | 'vue/array-bracket-spacing': 'error', 26 | 'vue/arrow-spacing': 'error', 27 | 'vue/block-spacing': 'error', 28 | 'vue/brace-style': 'error', 29 | 'vue/camelcase': 'error', 30 | 'vue/comma-dangle': ['error', 'always-multiline'], 31 | 'vue/component-name-in-template-casing': 'error', 32 | 'vue/dot-location': ['error', 'property'], 33 | 'vue/eqeqeq': 'error', 34 | 'vue/key-spacing': 'error', 35 | 'vue/keyword-spacing': 'error', 36 | 'vue/no-boolean-default': ['error', 'default-false'], 37 | 'vue/no-deprecated-scope-attribute': 'error', 38 | 'vue/no-empty-pattern': 'error', 39 | 'vue/object-curly-spacing': ['error', 'always'], 40 | 'vue/padding-line-between-blocks': 'error', 41 | 'vue/space-infix-ops': 'error', 42 | 'vue/space-unary-ops': 'error', 43 | 'vue/v-on-function-call': 'error', 44 | 'vue/v-slot-style': [ 45 | 'error', 46 | { 47 | atComponent: 'v-slot', 48 | default: 'v-slot', 49 | named: 'longform', 50 | }, 51 | ], 52 | 'vue/valid-v-slot': 'error', 53 | }, 54 | overrides: [ 55 | { 56 | files: ['src/**/*', 'tests/unit/**/*', 'tests/e2e/**/*'], 57 | parserOptions: { 58 | parser: 'babel-eslint', 59 | sourceType: 'module', 60 | }, 61 | env: { 62 | browser: true, 63 | }, 64 | }, 65 | { 66 | files: ['**/*.unit.js'], 67 | parserOptions: { 68 | parser: 'babel-eslint', 69 | sourceType: 'module', 70 | }, 71 | env: { jest: true }, 72 | globals: { 73 | mount: false, 74 | shallowMount: false, 75 | shallowMountView: false, 76 | createComponentMocks: false, 77 | createModuleStore: false, 78 | }, 79 | }, 80 | ], 81 | } 82 | -------------------------------------------------------------------------------- /ui/.gitattributes: -------------------------------------------------------------------------------- 1 | # Fix end-of-lines in Git versions older than 2.10 2 | # https://github.com/git/git/blob/master/Documentation/RelNotes/2.10.0.txt#L248 3 | * text=auto eol=lf 4 | 5 | # === 6 | # Binary Files (don't diff, don't fix line endings) 7 | # === 8 | 9 | # Images 10 | *.png binary 11 | *.jpg binary 12 | *.jpeg binary 13 | *.gif binary 14 | *.ico binary 15 | *.tiff binary 16 | 17 | # Fonts 18 | *.oft binary 19 | *.ttf binary 20 | *.eot binary 21 | *.woff binary 22 | *.woff2 binary 23 | 24 | # Videos 25 | *.mov binary 26 | *.mp4 binary 27 | *.webm binary 28 | *.ogg binary 29 | *.mpg binary 30 | *.3gp binary 31 | *.avi binary 32 | *.wmv binary 33 | *.flv binary 34 | *.asf binary 35 | 36 | # Audio 37 | *.mp3 binary 38 | *.wav binary 39 | *.flac binary 40 | 41 | # Compressed 42 | *.gz binary 43 | *.zip binary 44 | *.7z binary 45 | -------------------------------------------------------------------------------- /ui/.gitignore: -------------------------------------------------------------------------------- 1 | # OS Files 2 | .DS_Store 3 | Thumbs.db 4 | 5 | # Dependencies 6 | node_modules/ 7 | 8 | # Dev/Build Artifacts 9 | /dist/ 10 | /tests/e2e/videos/ 11 | /tests/e2e/screenshots/ 12 | /tests/unit/coverage/ 13 | jsconfig.json 14 | 15 | # Local Env Files 16 | .env.local 17 | .env.*.local 18 | 19 | # Log Files 20 | *.log 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | # Unconfigured Editors 26 | .idea 27 | *.suo 28 | *.ntvs* 29 | *.njsproj 30 | *.sln 31 | *.sw* 32 | -------------------------------------------------------------------------------- /ui/.markdownlint.yml: -------------------------------------------------------------------------------- 1 | default: true 2 | 3 | # === 4 | # Rule customizations for markdownlint go here 5 | # https://github.com/DavidAnson/markdownlint/blob/master/doc/Rules.md 6 | # === 7 | 8 | # Disable line length restrictions, because editor soft-wrapping is being 9 | # used instead. 10 | line-length: false 11 | 12 | # === 13 | # Prettier overrides 14 | # === 15 | 16 | no-multiple-blanks: false 17 | list-marker-space: false 18 | -------------------------------------------------------------------------------- /ui/.postcssrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | autoprefixer: {}, 4 | }, 5 | } 6 | -------------------------------------------------------------------------------- /ui/.prettierignore: -------------------------------------------------------------------------------- 1 | /node_modules/** 2 | /dist/** 3 | /tests/unit/coverage/** 4 | -------------------------------------------------------------------------------- /ui/.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | arrowParens: 'always', 3 | bracketSpacing: true, 4 | endOfLine: 'lf', 5 | htmlWhitespaceSensitivity: 'strict', 6 | jsxBracketSameLine: false, 7 | jsxSingleQuote: true, 8 | printWidth: 150, 9 | proseWrap: 'never', 10 | quoteProps: 'as-needed', 11 | semi: false, 12 | singleQuote: true, 13 | tabWidth: 2, 14 | trailingComma: 'es5', 15 | useTabs: false, 16 | vueIndentScriptAndStyle: false, 17 | } 18 | -------------------------------------------------------------------------------- /ui/.vscode/_components.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | "BaseButton": { 3 | "scope": "vue-html", 4 | "prefix": "BaseButton", 5 | "body": ["", "\t${3}", ""], 6 | "description": "" 7 | }, 8 | "BaseIcon": { 9 | "scope": "vue-html", 10 | "prefix": "BaseIcon", 11 | "body": ["", "\t${2}", ""], 12 | "description": "" 13 | }, 14 | "BaseInputText": { 15 | "scope": "vue-html", 16 | "prefix": "BaseInputText", 17 | "body": [""], 18 | "description": "" 19 | }, 20 | "BaseLink": { 21 | "scope": "vue-html", 22 | "prefix": "BaseLink", 23 | "body": [ 24 | "", 25 | "\t${3}", 26 | "" 27 | ], 28 | "description": "" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /ui/.vscode/_sfc-blocks.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | "script": { 3 | "scope": "vue", 4 | "prefix": "script", 5 | "body": [""], 6 | "description": " 14 | <% 15 | } 16 | 17 | if (blocks.indexOf('template') !== -1) { 18 | %> 19 | 22 | <% 23 | } 24 | 25 | if (blocks.indexOf('style') !== -1) { 26 | %> 27 | <% 30 | } 31 | %> 32 | -------------------------------------------------------------------------------- /ui/generators/new/component/prompt.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | 3 | module.exports = [ 4 | { 5 | type: 'input', 6 | name: 'name', 7 | message: 'Name:', 8 | validate(value) { 9 | if (!value.length) { 10 | return 'Components must have a name.' 11 | } 12 | const fileName = _.kebabCase(value) 13 | if (fileName.indexOf('-') === -1) { 14 | return 'Component names should contain at least two words to avoid conflicts with existing and future HTML elements.' 15 | } 16 | return true 17 | }, 18 | }, 19 | { 20 | type: 'multiselect', 21 | name: 'blocks', 22 | message: 'Blocks:', 23 | initial: ['script', 'template', 'style'], 24 | choices: [ 25 | { 26 | name: 'script', 27 | message: ' 19 | 20 | 25 | <% 26 | 27 | if (useStyles) { %> 28 | 31 | <% } %> 32 | -------------------------------------------------------------------------------- /ui/jest.config.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | // Use a random port number for the mock API by default, 3 | // to support multiple instances of Jest running 4 | // simultaneously, like during pre-commit lint. 5 | process.env.MOCK_API_PORT = process.env.MOCK_API_PORT || _.random(9000, 9999) 6 | 7 | module.exports = { 8 | setupFiles: ['/tests/unit/setup'], 9 | globalSetup: '/tests/unit/global-setup', 10 | globalTeardown: '/tests/unit/global-teardown', 11 | setupFilesAfterEnv: ['/tests/unit/matchers'], 12 | testMatch: ['**/(*.)unit.js'], 13 | moduleFileExtensions: ['js', 'json', 'vue'], 14 | transform: { 15 | '^.+\\.vue$': 'vue-jest', 16 | '^.+\\.js$': 'babel-jest', 17 | '.+\\.(css|scss|jpe?g|png|gif|webp|svg|mp4|webm|ogg|mp3|wav|flac|aac|woff2?|eot|ttf|otf)$': 18 | 'jest-transform-stub', 19 | }, 20 | moduleNameMapper: require('./aliases.config').jest, 21 | snapshotSerializers: ['jest-serializer-vue'], 22 | coverageDirectory: '/tests/unit/coverage', 23 | collectCoverageFrom: [ 24 | 'src/**/*.{js,vue}', 25 | '!**/node_modules/**', 26 | '!src/main.js', 27 | '!src/app.vue', 28 | '!src/router/index.js', 29 | '!src/router/routes.js', 30 | '!src/state/store.js', 31 | '!src/state/helpers.js', 32 | '!src/state/modules/index.js', 33 | '!src/components/_globals.js', 34 | ], 35 | // https://facebook.github.io/jest/docs/en/configuration.html#testurl-string 36 | // Set the `testURL` to a provided base URL if one exists, or the mock API base URL 37 | // Solves: https://stackoverflow.com/questions/42677387/jest-returns-network-error-when-doing-an-authenticated-request-with-axios 38 | testURL: 39 | process.env.API_BASE_URL || `http://localhost:${process.env.MOCK_API_PORT}`, 40 | // https://github.com/jest-community/jest-watch-typeahead 41 | watchPlugins: [ 42 | 'jest-watch-typeahead/filename', 43 | 'jest-watch-typeahead/testname', 44 | ], 45 | globals: { 46 | 'vue-jest': { 47 | // Compilation errors in the 123 | -------------------------------------------------------------------------------- /ui/src/components/mileageChart.vue: -------------------------------------------------------------------------------- 1 | 55 | -------------------------------------------------------------------------------- /ui/src/components/nav-bar-routes.unit.js: -------------------------------------------------------------------------------- 1 | import NavBarRoutes from './nav-bar-routes.vue' 2 | 3 | const mountRoutes = (options) => { 4 | return mount( 5 | { 6 | render(h) { 7 | return ( 8 |
    9 | 10 |
11 | ) 12 | }, 13 | }, 14 | { 15 | stubs: { 16 | BaseLink: { 17 | functional: true, 18 | render(h, { slots }) { 19 | return {slots().default} 20 | }, 21 | }, 22 | ...options.stubs, 23 | }, 24 | ...options, 25 | } 26 | ) 27 | } 28 | 29 | describe('@components/nav-bar-routes', () => { 30 | it('correctly renders routes with text titles', () => { 31 | const { element } = mountRoutes({ 32 | propsData: { 33 | routes: [ 34 | { 35 | name: 'aaa', 36 | title: 'bbb', 37 | }, 38 | ], 39 | }, 40 | }) 41 | expect(element.textContent).toEqual('bbb') 42 | }) 43 | 44 | it('correctly renders routes with function titles', () => { 45 | const { element } = mountRoutes({ 46 | propsData: { 47 | routes: [ 48 | { 49 | name: 'aaa', 50 | title: () => 'bbb', 51 | }, 52 | ], 53 | }, 54 | }) 55 | expect(element.textContent).toEqual('bbb') 56 | }) 57 | }) 58 | -------------------------------------------------------------------------------- /ui/src/components/nav-bar-routes.vue: -------------------------------------------------------------------------------- 1 | 63 | -------------------------------------------------------------------------------- /ui/src/components/nav-bar.unit.js: -------------------------------------------------------------------------------- 1 | import NavBar from './nav-bar.vue' 2 | 3 | describe('@components/nav-bar', () => { 4 | it(`displays the user's name in the profile link`, () => { 5 | const { vm } = shallowMount( 6 | NavBar, 7 | createComponentMocks({ 8 | store: { 9 | auth: { 10 | state: { 11 | currentUser: { 12 | name: 'My Name', 13 | }, 14 | }, 15 | getters: { 16 | loggedIn: () => true, 17 | }, 18 | }, 19 | }, 20 | }) 21 | ) 22 | 23 | const profileRoute = vm.loggedInNavRoutes.find( 24 | (route) => route.name === 'profile' 25 | ) 26 | expect(profileRoute.title()).toEqual('Logged in as My Name') 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /ui/src/components/nav-bar.vue: -------------------------------------------------------------------------------- 1 | 62 | 63 | 82 | -------------------------------------------------------------------------------- /ui/src/components/quickEntryDisplay.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 69 | -------------------------------------------------------------------------------- /ui/src/components/shareVehicle.vue: -------------------------------------------------------------------------------- 1 | 90 | 91 | 111 | -------------------------------------------------------------------------------- /ui/src/components/statsWidget.vue: -------------------------------------------------------------------------------- 1 | 128 | 129 | 151 | -------------------------------------------------------------------------------- /ui/src/design/_colors.scss: -------------------------------------------------------------------------------- 1 | // CONTENT 2 | $color-body-bg: #f9f7f5; 3 | $color-text: #444; 4 | $color-heading-text: #35495e; 5 | 6 | // LINKS 7 | $color-link-text: #39a275; 8 | $color-link-text-active: $color-text; 9 | 10 | // INPUTS 11 | $color-input-border: lighten($color-heading-text, 50%); 12 | 13 | // BUTTONS 14 | $color-button-bg: $color-link-text; 15 | $color-button-disabled-bg: darken(desaturate($color-button-bg, 20%), 10%); 16 | $color-button-text: white; 17 | -------------------------------------------------------------------------------- /ui/src/design/_durations.scss: -------------------------------------------------------------------------------- 1 | $duration-animation-base: 300ms; 2 | -------------------------------------------------------------------------------- /ui/src/design/_fonts.scss: -------------------------------------------------------------------------------- 1 | $system-default-font-family: -apple-system, 'BlinkMacSystemFont', 'Segoe UI', 2 | 'Helvetica', 'Arial', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 3 | 'Segoe UI Symbol'; 4 | 5 | $heading-font-family: $system-default-font-family; 6 | $heading-font-weight: 600; 7 | 8 | $content-font-family: $system-default-font-family; 9 | $content-font-weight: 400; 10 | 11 | %font-heading { 12 | font-family: $heading-font-family; 13 | font-weight: $heading-font-weight; 14 | color: $color-heading-text; 15 | } 16 | 17 | %font-content { 18 | font-family: $content-font-family; 19 | font-weight: $content-font-weight; 20 | color: $color-text; 21 | } 22 | -------------------------------------------------------------------------------- /ui/src/design/_layers.scss: -------------------------------------------------------------------------------- 1 | $layer-negative-z-index: -1; 2 | $layer-page-z-index: 1; 3 | $layer-dropdown-z-index: 2; 4 | $layer-modal-z-index: 3; 5 | $layer-popover-z-index: 4; 6 | $layer-tooltip-z-index: 5; 7 | -------------------------------------------------------------------------------- /ui/src/design/_sizes.scss: -------------------------------------------------------------------------------- 1 | // GRID 2 | $size-grid-padding: 1.3rem; 3 | 4 | // CONTENT 5 | $size-content-width-max: 50rem; 6 | $size-content-width-min: 25rem; 7 | 8 | // INPUTS 9 | $size-input-padding-vertical: 0.75em; 10 | $size-input-padding-horizontal: 1em; 11 | $size-input-padding: $size-input-padding-vertical $size-input-padding-horizontal; 12 | $size-input-border: 1px; 13 | $size-input-border-radius: (1em + $size-input-padding-vertical * 2) / 10; 14 | 15 | // BUTTONS 16 | $size-button-padding-vertical: $size-grid-padding / 2; 17 | $size-button-padding-horizontal: $size-grid-padding / 1.5; 18 | $size-button-padding: $size-button-padding-vertical 19 | $size-button-padding-horizontal; 20 | -------------------------------------------------------------------------------- /ui/src/design/index.scss: -------------------------------------------------------------------------------- 1 | @import 'colors'; 2 | @import 'durations'; 3 | @import 'fonts'; 4 | @import 'layers'; 5 | @import 'sizes'; 6 | @import 'typography'; 7 | 8 | :export { 9 | // Any values that need to be accessible from JavaScript 10 | // outside of a Vue component can be defined here, prefixed 11 | // with `global-` to avoid conflicts with classes. For 12 | // example: 13 | // 14 | // global-grid-padding: $size-grid-padding; 15 | // 16 | // Then in a JavaScript file, you can import this object 17 | // as you would normally with: 18 | // 19 | // import design from '@design' 20 | // 21 | // console.log(design['global-grid-padding']) 22 | } 23 | -------------------------------------------------------------------------------- /ui/src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Buefy from 'buefy' 3 | import router from '@router' 4 | import store from '@state/store' 5 | import { library } from '@fortawesome/fontawesome-svg-core' 6 | import { 7 | faCheck, 8 | faTimes, 9 | faArrowUp, 10 | faAngleLeft, 11 | faAngleRight, 12 | faCalendar, 13 | faEdit, 14 | faAngleDown, 15 | faAngleUp, 16 | faUpload, 17 | faExclamationCircle, 18 | faDownload, 19 | faEye, 20 | faEyeSlash, 21 | faTrash, 22 | faShare, 23 | faUserFriends, 24 | faTimesCircle, 25 | } from '@fortawesome/free-solid-svg-icons' 26 | import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' 27 | 28 | import App from './app.vue' 29 | 30 | // Globally register all `_base`-prefixed components 31 | import '@components/_globals' 32 | 33 | import 'buefy/dist/buefy.css' 34 | import 'nprogress/nprogress.css' 35 | 36 | Vue.component('vue-fontawesome', FontAwesomeIcon) 37 | library.add( 38 | faCheck, 39 | faTimes, 40 | faArrowUp, 41 | faAngleLeft, 42 | faAngleRight, 43 | faCalendar, 44 | faEdit, 45 | faAngleDown, 46 | faAngleUp, 47 | faUpload, 48 | faExclamationCircle, 49 | faDownload, 50 | faEye, 51 | faEyeSlash, 52 | faTrash, 53 | faShare, 54 | faUserFriends, 55 | faTimesCircle 56 | ) 57 | Vue.use(Buefy, { 58 | defaultIconComponent: 'vue-fontawesome', 59 | defaultIconPack: 'fas', 60 | }) 61 | 62 | // Don't warn about using the dev version of Vue in development. 63 | Vue.config.productionTip = process.env.NODE_ENV === 'production' 64 | 65 | // If running inside Cypress... 66 | if (process.env.VUE_APP_TEST === 'e2e') { 67 | // Ensure tests fail when Vue emits an error. 68 | Vue.config.errorHandler = window.Cypress.cy.onUncaughtException 69 | } 70 | 71 | const app = new Vue({ 72 | router, 73 | store, 74 | 75 | render: (h) => h(App), 76 | }).$mount('#app') 77 | 78 | // If running e2e tests... 79 | if (process.env.VUE_APP_TEST === 'e2e') { 80 | // Attach the app to the window, which can be useful 81 | // for manually setting state in Cypress commands 82 | // such as `cy.logIn()`. 83 | window.__app__ = app 84 | } 85 | -------------------------------------------------------------------------------- /ui/src/router/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import VueRouter from 'vue-router' 3 | // https://github.com/declandewet/vue-meta 4 | import VueMeta from 'vue-meta' 5 | // Adds a loading bar at the top during page loads. 6 | import NProgress from 'nprogress/nprogress' 7 | import store from '@state/store' 8 | import routes from './routes' 9 | 10 | Vue.use(VueRouter) 11 | Vue.use(VueMeta, { 12 | // The component option name that vue-meta looks for meta info on. 13 | keyName: 'page', 14 | }) 15 | 16 | const router = new VueRouter({ 17 | routes, 18 | // Use the HTML5 history API (i.e. normal-looking routes) 19 | // instead of routes with hashes (e.g. example.com/#/about). 20 | // This may require some server configuration in production: 21 | // https://router.vuejs.org/en/essentials/history-mode.html#example-server-configurations 22 | mode: 'history', 23 | // Simulate native-like scroll behavior when navigating to a new 24 | // route and using back/forward buttons. 25 | scrollBehavior(to, from, savedPosition) { 26 | if (savedPosition) { 27 | return savedPosition 28 | } else { 29 | return { x: 0, y: 0 } 30 | } 31 | }, 32 | }) 33 | 34 | // Before each route evaluates... 35 | router.beforeEach((routeTo, routeFrom, next) => { 36 | // If this isn't an initial page load... 37 | if (routeFrom.name !== null) { 38 | // Start the route progress bar. 39 | NProgress.start() 40 | } 41 | 42 | // Check if auth is required on this route 43 | // (including nested routes). 44 | const authRequired = routeTo.matched.some((route) => route.meta.authRequired) 45 | 46 | // If auth isn't required for the route, just continue. 47 | if (!authRequired) return next() 48 | 49 | // If auth is required and the user is logged in... 50 | if (store.getters['auth/loggedIn']) { 51 | // Validate the local user token... 52 | return store.dispatch('auth/validate').then((validUser) => { 53 | // Then continue if the token still represents a valid user, 54 | // otherwise redirect to login. 55 | 56 | if (!validUser) { 57 | redirectToLogin() 58 | } 59 | const rolesRequired = routeTo.matched.some((route) => route.meta.roles) 60 | 61 | if (!rolesRequired) { 62 | return next() 63 | } 64 | 65 | const roles = routeTo.matched.find((route) => route.meta.roles).meta.roles 66 | 67 | roles.some((x) => x === validUser.role) ? next() : redirectToHome() 68 | }) 69 | } 70 | 71 | // If auth is required and the user is NOT currently logged in, 72 | // redirect to login. 73 | redirectToLogin() 74 | 75 | function redirectToLogin() { 76 | // Pass the original route to the login component 77 | next({ name: 'login', query: { redirectFrom: routeTo.fullPath } }) 78 | } 79 | function redirectToHome() { 80 | // Pass the original route to the login component 81 | next({ name: 'home', query: { redirectFrom: routeTo.fullPath } }) 82 | } 83 | }) 84 | 85 | router.beforeResolve(async (routeTo, routeFrom, next) => { 86 | // Create a `beforeResolve` hook, which fires whenever 87 | // `beforeRouteEnter` and `beforeRouteUpdate` would. This 88 | // allows us to ensure data is fetched even when params change, 89 | // but the resolved route does not. We put it in `meta` to 90 | // indicate that it's a hook we created, rather than part of 91 | // Vue Router (yet?). 92 | try { 93 | // For each matched route... 94 | for (const route of routeTo.matched) { 95 | await new Promise((resolve, reject) => { 96 | // If a `beforeResolve` hook is defined, call it with 97 | // the same arguments as the `beforeEnter` hook. 98 | if (route.meta && route.meta.beforeResolve) { 99 | route.meta.beforeResolve(routeTo, routeFrom, (...args) => { 100 | // If the user chose to redirect... 101 | if (args.length) { 102 | // If redirecting to the same route we're coming from... 103 | if (routeFrom.name === args[0].name) { 104 | // Complete the animation of the route progress bar. 105 | NProgress.done() 106 | } 107 | // Complete the redirect. 108 | next(...args) 109 | reject(new Error('Redirected')) 110 | } else { 111 | resolve() 112 | } 113 | }) 114 | } else { 115 | // Otherwise, continue resolving the route. 116 | resolve() 117 | } 118 | }) 119 | } 120 | // If a `beforeResolve` hook chose to redirect, just return. 121 | } catch (error) { 122 | return 123 | } 124 | 125 | // If we reach this point, continue resolving the route. 126 | next() 127 | }) 128 | 129 | // When each route is finished evaluating... 130 | router.afterEach((routeTo, routeFrom) => { 131 | // Complete the animation of the route progress bar. 132 | NProgress.done() 133 | }) 134 | 135 | export default router 136 | -------------------------------------------------------------------------------- /ui/src/router/layouts/main.unit.js: -------------------------------------------------------------------------------- 1 | import MainLayout from './main.vue' 2 | 3 | describe('@layouts/main.vue', () => { 4 | it('renders its content', () => { 5 | const slotContent = '

Hello!

' 6 | const { element } = shallowMount(MainLayout, { 7 | slots: { 8 | default: slotContent, 9 | }, 10 | }) 11 | expect(element.innerHTML).toContain(slotContent) 12 | }) 13 | }) 14 | -------------------------------------------------------------------------------- /ui/src/router/layouts/main.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 15 | -------------------------------------------------------------------------------- /ui/src/router/views/_404.unit.js: -------------------------------------------------------------------------------- 1 | import View404 from './_404.vue' 2 | 3 | describe('@views/404', () => { 4 | it('is a valid view', () => { 5 | expect(View404).toBeAViewComponent() 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /ui/src/router/views/_404.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 30 | 31 | 36 | -------------------------------------------------------------------------------- /ui/src/router/views/_loading.unit.js: -------------------------------------------------------------------------------- 1 | import Loading from './_loading.vue' 2 | 3 | describe('@views/loading', () => { 4 | it('is a valid view', () => { 5 | expect(Loading).toBeAViewComponent() 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /ui/src/router/views/_loading.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 20 | 21 | 40 | -------------------------------------------------------------------------------- /ui/src/router/views/_timeout.unit.js: -------------------------------------------------------------------------------- 1 | import Timeout from './_timeout.vue' 2 | 3 | describe('@views/timeout', () => { 4 | it('is a valid view', () => { 5 | expect(Timeout).toBeAViewComponent() 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /ui/src/router/views/_timeout.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 41 | 42 | 47 | -------------------------------------------------------------------------------- /ui/src/router/views/home.unit.js: -------------------------------------------------------------------------------- 1 | import Home from './home.vue' 2 | 3 | describe('@views/home', () => { 4 | it('is a valid view', () => { 5 | expect(Home).toBeAViewComponent() 6 | }) 7 | 8 | it('renders an element', () => { 9 | const { element } = shallowMountView(Home) 10 | expect(element.textContent).toContain('Home Page') 11 | }) 12 | }) 13 | -------------------------------------------------------------------------------- /ui/src/router/views/import-fuelly.unit.js: -------------------------------------------------------------------------------- 1 | import ImportFuelly from './import-fuelly' 2 | 3 | describe('@views/import-fuelly', () => { 4 | it('is a valid view', () => { 5 | expect(ImportFuelly).toBeAViewComponent() 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /ui/src/router/views/import.unit.js: -------------------------------------------------------------------------------- 1 | import Import from './import' 2 | 3 | describe('@views/import', () => { 4 | it('is a valid view', () => { 5 | expect(Import).toBeAViewComponent() 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /ui/src/router/views/import.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 38 | -------------------------------------------------------------------------------- /ui/src/router/views/login.unit.js: -------------------------------------------------------------------------------- 1 | import Login from './login.vue' 2 | 3 | describe('@views/login', () => { 4 | it('is a valid view', () => { 5 | expect(Login).toBeAViewComponent() 6 | }) 7 | 8 | it('redirects to home after successful login', () => { 9 | const { vm } = mountLogin() 10 | 11 | vm.username = 'correctUsername' 12 | vm.password = 'correctPassword' 13 | 14 | const routerPush = jest.fn() 15 | vm.$router = { push: routerPush } 16 | vm.$route = { query: {} } 17 | 18 | expect.assertions(2) 19 | return vm.tryToLogIn().then(() => { 20 | expect(vm.authError).toEqual(null) 21 | expect(routerPush).toHaveBeenCalledWith({ name: 'home' }) 22 | }) 23 | }) 24 | 25 | it('redirects to redirectFrom query, if it exists, after successful login', () => { 26 | const { vm } = mountLogin() 27 | 28 | vm.username = 'correctUsername' 29 | vm.password = 'correctPassword' 30 | 31 | const routerPush = jest.fn() 32 | vm.$router = { push: routerPush } 33 | 34 | const redirectFrom = '/profile?someQuery' 35 | vm.$route = { query: { redirectFrom } } 36 | 37 | expect.assertions(2) 38 | return vm.tryToLogIn().then(() => { 39 | expect(vm.authError).toEqual(null) 40 | expect(routerPush).toHaveBeenCalledWith(redirectFrom) 41 | }) 42 | }) 43 | 44 | it('displays an error after failed login', () => { 45 | const { vm } = mountLogin() 46 | 47 | const routerPush = jest.fn() 48 | vm.$router = { push: routerPush } 49 | 50 | expect.assertions(2) 51 | return vm.tryToLogIn().then(() => { 52 | expect(vm.authError).toBeTruthy() 53 | expect(vm.$el.textContent).toContain('error') 54 | }) 55 | }) 56 | }) 57 | 58 | function mountLogin() { 59 | return shallowMountView(Login, { 60 | ...createComponentMocks({ 61 | store: { 62 | auth: { 63 | actions: { 64 | logIn(_, { username, password }) { 65 | if ( 66 | username === 'correctUsername' && 67 | password === 'correctPassword' 68 | ) { 69 | return Promise.resolve('testToken') 70 | } else { 71 | return Promise.reject(new Error('testError')) 72 | } 73 | }, 74 | }, 75 | }, 76 | }, 77 | }), 78 | }) 79 | } 80 | -------------------------------------------------------------------------------- /ui/src/router/views/login.vue: -------------------------------------------------------------------------------- 1 | 66 | 67 | 84 | -------------------------------------------------------------------------------- /ui/src/router/views/profile.unit.js: -------------------------------------------------------------------------------- 1 | import Profile from './profile.vue' 2 | 3 | describe('@views/profile', () => { 4 | it('is a valid view', () => { 5 | expect(Profile).toBeAViewComponentUsing({ user: { name: '' } }) 6 | }) 7 | 8 | it(`includes the provided user's name`, () => { 9 | const { element } = shallowMountView(Profile, { 10 | propsData: { 11 | user: { name: 'My Name' }, 12 | }, 13 | }) 14 | 15 | expect(element.textContent).toMatch(/My Name\s+Profile/) 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /ui/src/router/views/profile.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 36 | -------------------------------------------------------------------------------- /ui/src/router/views/quickEntries.vue: -------------------------------------------------------------------------------- 1 | 59 | 60 | 109 | 110 | 120 | -------------------------------------------------------------------------------- /ui/src/router/views/siteSettings.vue: -------------------------------------------------------------------------------- 1 | 60 | 61 | 96 | -------------------------------------------------------------------------------- /ui/src/state/helpers.js: -------------------------------------------------------------------------------- 1 | import { mapState, mapGetters, mapActions } from 'vuex' 2 | 3 | export const authComputed = { 4 | ...mapState('auth', { 5 | currentUser: (state) => state.currentUser, 6 | }), 7 | ...mapGetters('auth', ['loggedIn']), 8 | } 9 | 10 | export const authMethods = mapActions('auth', ['logIn', 'logOut']) 11 | -------------------------------------------------------------------------------- /ui/src/state/modules/auth.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | export const state = { 4 | currentUser: getSavedState('auth.currentUser'), 5 | initialized: getSavedState('system.initialized'), 6 | } 7 | 8 | export const mutations = { 9 | SET_CURRENT_USER(state, newValue) { 10 | state.currentUser = newValue 11 | saveState('auth.currentUser', newValue) 12 | setDefaultAuthHeaders(state) 13 | }, 14 | SET_INITIALIZATION_STATUS(state, newValue) { 15 | state.initialized = newValue 16 | saveState('system.initialized', newValue) 17 | }, 18 | } 19 | 20 | export const getters = { 21 | // Whether the user is currently logged in. 22 | loggedIn(state) { 23 | return !!state.currentUser 24 | }, 25 | isInitialized(state) { 26 | return state.initialized == null || state.initialized.initialized 27 | }, 28 | } 29 | 30 | export const actions = { 31 | // This is automatically run in `src/state/store.js` when the app 32 | // starts, along with any other actions named `init` in other modules. 33 | init({ state, dispatch }) { 34 | dispatch('systemInitialized') 35 | setDefaultAuthHeaders(state) 36 | dispatch('validate') 37 | }, 38 | 39 | logIn({ commit, dispatch, getters }, { username, password } = {}) { 40 | if (getters.loggedIn) return dispatch('validate') 41 | 42 | return axios.post('/api/login', { email: username, password }).then((response) => { 43 | const user = response.data 44 | commit('SET_CURRENT_USER', user) 45 | dispatch('vehicles/fetchMasters', null, { root: true }) 46 | return user 47 | }) 48 | }, 49 | 50 | // Logs out the current user. 51 | logOut({ commit }) { 52 | commit('SET_CURRENT_USER', null) 53 | }, 54 | 55 | // Validates the current user's token and refreshes it 56 | // with new data from the API. 57 | validate({ commit, state }) { 58 | if (!state.currentUser) return Promise.resolve(null) 59 | 60 | return axios 61 | .post('/api/refresh', { refreshToken: state.currentUser.refreshToken }) 62 | .then((response) => { 63 | const user = response.data 64 | commit('SET_CURRENT_USER', user) 65 | return user 66 | }) 67 | .catch((ex) => { 68 | commit('SET_CURRENT_USER', null) 69 | }) 70 | }, 71 | 72 | systemInitialized({ commit, state }) { 73 | return axios.get('/api/system/status').then((response) => { 74 | const data = response.data 75 | commit('SET_INITIALIZATION_STATUS', data) 76 | return data 77 | }) 78 | }, 79 | } 80 | 81 | // === 82 | // Private helpers 83 | // === 84 | 85 | function getSavedState(key) { 86 | return JSON.parse(window.localStorage.getItem(key)) 87 | } 88 | 89 | function saveState(key, state) { 90 | window.localStorage.setItem(key, JSON.stringify(state)) 91 | } 92 | 93 | function setDefaultAuthHeaders(state) { 94 | axios.defaults.headers.common.Authorization = state.currentUser ? state.currentUser.token : '' 95 | } 96 | -------------------------------------------------------------------------------- /ui/src/state/modules/auth.unit.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import * as authModule from './auth' 3 | 4 | describe('@state/modules/auth', () => { 5 | it('exports a valid Vuex module', () => { 6 | expect(authModule).toBeAVuexModule() 7 | }) 8 | 9 | describe('in a store', () => { 10 | let store 11 | beforeEach(() => { 12 | store = createModuleStore(authModule) 13 | window.localStorage.clear() 14 | }) 15 | 16 | it('mutations.SET_CURRENT_USER correctly sets axios default authorization header', () => { 17 | axios.defaults.headers.common.Authorization = '' 18 | 19 | store.commit('SET_CURRENT_USER', { token: 'some-token' }) 20 | expect(axios.defaults.headers.common.Authorization).toEqual('some-token') 21 | 22 | store.commit('SET_CURRENT_USER', null) 23 | expect(axios.defaults.headers.common.Authorization).toEqual('') 24 | }) 25 | 26 | it('mutations.SET_CURRENT_USER correctly saves currentUser in localStorage', () => { 27 | let savedCurrentUser = JSON.parse( 28 | window.localStorage.getItem('auth.currentUser') 29 | ) 30 | expect(savedCurrentUser).toEqual(null) 31 | 32 | const expectedCurrentUser = { token: 'some-token' } 33 | store.commit('SET_CURRENT_USER', expectedCurrentUser) 34 | 35 | savedCurrentUser = JSON.parse( 36 | window.localStorage.getItem('auth.currentUser') 37 | ) 38 | expect(savedCurrentUser).toEqual(expectedCurrentUser) 39 | }) 40 | 41 | it('getters.loggedIn returns true when currentUser is an object', () => { 42 | store.commit('SET_CURRENT_USER', {}) 43 | expect(store.getters.loggedIn).toEqual(true) 44 | }) 45 | 46 | it('getters.loggedIn returns false when currentUser is null', () => { 47 | store.commit('SET_CURRENT_USER', null) 48 | expect(store.getters.loggedIn).toEqual(false) 49 | }) 50 | 51 | it('actions.logIn resolves to a refreshed currentUser when already logged in', () => { 52 | expect.assertions(2) 53 | 54 | store.commit('SET_CURRENT_USER', { token: validUserExample.token }) 55 | return store.dispatch('logIn').then((user) => { 56 | expect(user).toEqual(validUserExample) 57 | expect(store.state.currentUser).toEqual(validUserExample) 58 | }) 59 | }) 60 | 61 | it('actions.logIn commits the currentUser and resolves to the user when NOT already logged in and provided a correct username and password', () => { 62 | expect.assertions(2) 63 | 64 | return store 65 | .dispatch('logIn', { username: 'admin', password: 'password' }) 66 | .then((user) => { 67 | expect(user).toEqual(validUserExample) 68 | expect(store.state.currentUser).toEqual(validUserExample) 69 | }) 70 | }) 71 | 72 | it('actions.logIn rejects with 401 when NOT already logged in and provided an incorrect username and password', () => { 73 | expect.assertions(1) 74 | 75 | return store 76 | .dispatch('logIn', { 77 | username: 'bad username', 78 | password: 'bad password', 79 | }) 80 | .catch((error) => { 81 | expect(error.message).toEqual('Request failed with status code 401') 82 | }) 83 | }) 84 | 85 | it('actions.validate resolves to null when currentUser is null', () => { 86 | expect.assertions(1) 87 | 88 | store.commit('SET_CURRENT_USER', null) 89 | return store.dispatch('validate').then((user) => { 90 | expect(user).toEqual(null) 91 | }) 92 | }) 93 | 94 | it('actions.validate resolves to null when currentUser contains an invalid token', () => { 95 | expect.assertions(2) 96 | 97 | store.commit('SET_CURRENT_USER', { token: 'invalid-token' }) 98 | return store.dispatch('validate').then((user) => { 99 | expect(user).toEqual(null) 100 | expect(store.state.currentUser).toEqual(null) 101 | }) 102 | }) 103 | 104 | it('actions.validate resolves to a user when currentUser contains a valid token', () => { 105 | expect.assertions(2) 106 | 107 | store.commit('SET_CURRENT_USER', { token: validUserExample.token }) 108 | return store.dispatch('validate').then((user) => { 109 | expect(user).toEqual(validUserExample) 110 | expect(store.state.currentUser).toEqual(validUserExample) 111 | }) 112 | }) 113 | }) 114 | }) 115 | 116 | const validUserExample = { 117 | id: 1, 118 | username: 'admin', 119 | name: 'Vue Master', 120 | token: 'valid-token-for-admin', 121 | } 122 | -------------------------------------------------------------------------------- /ui/src/state/modules/index.js: -------------------------------------------------------------------------------- 1 | // Register each file as a corresponding Vuex module. Module nesting 2 | // will mirror [sub-]directory hierarchy and modules are namespaced 3 | // as the camelCase equivalent of their file name. 4 | 5 | import camelCase from 'lodash/camelCase' 6 | 7 | const modulesCache = {} 8 | const storeData = { modules: {} } 9 | 10 | ;(function updateModules() { 11 | // Allow us to dynamically require all Vuex module files. 12 | // https://webpack.js.org/guides/dependency-management/#require-context 13 | const requireModule = require.context( 14 | // Search for files in the current directory. 15 | '.', 16 | // Search for files in subdirectories. 17 | true, 18 | // Include any .js files that are not this file or a unit test. 19 | /^((?!index|\.unit\.).)*\.js$/ 20 | ) 21 | 22 | // For every Vuex module... 23 | requireModule.keys().forEach((fileName) => { 24 | const moduleDefinition = 25 | requireModule(fileName).default || requireModule(fileName) 26 | 27 | // Skip the module during hot reload if it refers to the 28 | // same module definition as the one we have cached. 29 | if (modulesCache[fileName] === moduleDefinition) return 30 | 31 | // Update the module cache, for efficient hot reloading. 32 | modulesCache[fileName] = moduleDefinition 33 | 34 | // Get the module path as an array. 35 | const modulePath = fileName 36 | // Remove the "./" from the beginning. 37 | .replace(/^\.\//, '') 38 | // Remove the file extension from the end. 39 | .replace(/\.\w+$/, '') 40 | // Split nested modules into an array path. 41 | .split(/\//) 42 | // camelCase all module namespaces and names. 43 | .map(camelCase) 44 | 45 | // Get the modules object for the current path. 46 | const { modules } = getNamespace(storeData, modulePath) 47 | 48 | // Add the module to our modules object. 49 | modules[modulePath.pop()] = { 50 | // Modules are namespaced by default. 51 | namespaced: true, 52 | ...moduleDefinition, 53 | } 54 | }) 55 | 56 | // If the environment supports hot reloading... 57 | if (module.hot) { 58 | // Whenever any Vuex module is updated... 59 | module.hot.accept(requireModule.id, () => { 60 | // Update `storeData.modules` with the latest definitions. 61 | updateModules() 62 | // Trigger a hot update in the store. 63 | require('../store').default.hotUpdate({ modules: storeData.modules }) 64 | }) 65 | } 66 | })() 67 | 68 | // Recursively get the namespace of a Vuex module, even if nested. 69 | function getNamespace(subtree, path) { 70 | if (path.length === 1) return subtree 71 | 72 | const namespace = path.shift() 73 | subtree.modules[namespace] = { 74 | modules: {}, 75 | namespaced: true, 76 | ...subtree.modules[namespace], 77 | } 78 | return getNamespace(subtree.modules[namespace], path) 79 | } 80 | 81 | export default storeData.modules 82 | -------------------------------------------------------------------------------- /ui/src/state/modules/users.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | export const state = { 4 | cached: [], 5 | me: null, 6 | } 7 | 8 | export const getters = {} 9 | 10 | export const mutations = { 11 | CACHE_USER(state, newUser) { 12 | state.cached.push(newUser) 13 | }, 14 | CACHE_MY_USER(state, newUser) { 15 | state.me = newUser 16 | }, 17 | } 18 | 19 | export const actions = { 20 | init({ dispatch, rootState }) { 21 | const { currentUser } = rootState.auth 22 | if (currentUser != null) { 23 | dispatch('me') 24 | } 25 | }, 26 | forceMe({ commit, state }) { 27 | return axios 28 | .get('/api/me') 29 | .then((response) => { 30 | commit('CACHE_MY_USER', response.data) 31 | return response.data 32 | }) 33 | .catch((error) => { 34 | if (error.response && error.response.status === 401) { 35 | commit('CACHE_MY_USER', null) 36 | } else { 37 | console.warn(error) 38 | } 39 | return null 40 | }) 41 | }, 42 | users() { 43 | return axios 44 | .get('/api/users') 45 | .then((response) => { 46 | return response.data 47 | }) 48 | .catch((error) => { 49 | if (error.response && error.response.status === 401) { 50 | } else { 51 | console.warn(error) 52 | } 53 | return null 54 | }) 55 | }, 56 | me({ commit, state }) { 57 | if (state.me) { 58 | return Promise.resolve(state.me) 59 | } 60 | return axios 61 | .get('/api/me') 62 | .then((response) => { 63 | commit('CACHE_MY_USER', response.data) 64 | return response.data 65 | }) 66 | .catch((error) => { 67 | if (error.response && error.response.status === 401) { 68 | commit('CACHE_MY_USER', null) 69 | } else { 70 | console.warn(error) 71 | } 72 | return null 73 | }) 74 | }, 75 | fetchUser({ commit, state, rootState }, { username }) { 76 | // 1. Check if we already have the user as a current user. 77 | const { currentUser } = rootState.auth 78 | if (currentUser && currentUser.username === username) { 79 | return Promise.resolve(currentUser) 80 | } 81 | 82 | // 2. Check if we've already fetched and cached the user. 83 | const matchedUser = state.cached.find((user) => user.username === username) 84 | if (matchedUser) { 85 | return Promise.resolve(matchedUser) 86 | } 87 | }, 88 | } 89 | -------------------------------------------------------------------------------- /ui/src/state/modules/users.unit.js: -------------------------------------------------------------------------------- 1 | import * as usersModule from './users' 2 | 3 | describe('@state/modules/users', () => { 4 | it('exports a valid Vuex module', () => { 5 | expect(usersModule).toBeAVuexModule() 6 | }) 7 | 8 | describe('in a store when logged in', () => { 9 | let store 10 | beforeEach(() => { 11 | store = createModuleStore(usersModule, { 12 | currentUser: validUserExample, 13 | }) 14 | }) 15 | 16 | it('actions.fetchUser returns the current user without fetching it again', () => { 17 | expect.assertions(2) 18 | 19 | const axios = require('axios') 20 | const originalAxiosGet = axios.get 21 | axios.get = jest.fn() 22 | 23 | return store.dispatch('fetchUser', { username: 'admin' }).then((user) => { 24 | expect(user).toEqual(validUserExample) 25 | expect(axios.get).not.toHaveBeenCalled() 26 | axios.get = originalAxiosGet 27 | }) 28 | }) 29 | 30 | it('actions.fetchUser rejects with 400 when provided a bad username', () => { 31 | expect.assertions(1) 32 | 33 | return store 34 | .dispatch('fetchUser', { username: 'bad-username' }) 35 | .catch((error) => { 36 | expect(error.response.status).toEqual(400) 37 | }) 38 | }) 39 | }) 40 | 41 | describe('in a store when logged out', () => { 42 | let store 43 | beforeEach(() => { 44 | store = createModuleStore(usersModule) 45 | }) 46 | 47 | it('actions.fetchUser rejects with 401', () => { 48 | expect.assertions(1) 49 | 50 | return store 51 | .dispatch('fetchUser', { username: 'admin' }) 52 | .catch((error) => { 53 | expect(error.response.status).toEqual(401) 54 | }) 55 | }) 56 | }) 57 | }) 58 | 59 | const validUserExample = { 60 | id: 1, 61 | username: 'admin', 62 | name: 'Vue Master', 63 | token: 'valid-token-for-admin', 64 | } 65 | -------------------------------------------------------------------------------- /ui/src/state/modules/utils.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | export const state = { 4 | isMobile: false, 5 | settings: null, 6 | } 7 | export const mutations = { 8 | CACHE_ISMOBILE(state, isMobile) { 9 | state.isMobile = isMobile 10 | }, 11 | CACHE_SETTINGS(state, settings) { 12 | state.settings = settings 13 | }, 14 | } 15 | export const getters = {} 16 | export const actions = { 17 | init({ dispatch, rootState }) { 18 | dispatch('checkSize') 19 | const { currentUser } = rootState.auth 20 | if (currentUser) { 21 | dispatch('getSettings') 22 | } 23 | }, 24 | checkSize({ commit }) { 25 | commit('CACHE_ISMOBILE', window.innerWidth < 600) 26 | return window.innerWidth < 600 27 | }, 28 | getSettings({ commit }) { 29 | return axios.get(`/api/settings`).then((response) => { 30 | const data = response.data 31 | commit('CACHE_SETTINGS', data) 32 | return data 33 | }) 34 | }, 35 | saveSettings({ commit, dispatch }, { settings }) { 36 | return axios.post(`/api/settings`, { ...settings }).then((response) => { 37 | const data = response.data 38 | dispatch('getSettings') 39 | return data 40 | }) 41 | }, 42 | saveUserSettings({ commit, dispatch }, { settings }) { 43 | return axios.post(`/api/me/settings`, { ...settings }).then((response) => { 44 | const data = response.data 45 | dispatch('users/forceMe', {}, { root: true }).then((data) => {}) 46 | return data 47 | }) 48 | }, 49 | } 50 | -------------------------------------------------------------------------------- /ui/src/state/store.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuex from 'vuex' 3 | import dispatchActionForAllModules from '@utils/dispatch-action-for-all-modules' 4 | 5 | import modules from './modules' 6 | 7 | Vue.use(Vuex) 8 | 9 | const store = new Vuex.Store({ 10 | modules, 11 | // Enable strict mode in development to get a warning 12 | // when mutating state outside of a mutation. 13 | // https://vuex.vuejs.org/guide/strict.html 14 | strict: process.env.NODE_ENV !== 'production', 15 | }) 16 | 17 | export default store 18 | 19 | // Automatically run the `init` action for every module, 20 | // if one exists. 21 | dispatchActionForAllModules('init') 22 | -------------------------------------------------------------------------------- /ui/src/utils/dispatch-action-for-all-modules.js: -------------------------------------------------------------------------------- 1 | import allModules from '@state/modules' 2 | import store from '@state/store' 3 | 4 | export default function dispatchActionForAllModules( 5 | actionName, 6 | { modules = allModules, modulePrefix = '', flags = {} } = {} 7 | ) { 8 | // For every module... 9 | for (const moduleName in modules) { 10 | const moduleDefinition = modules[moduleName] 11 | 12 | // If the action is defined on the module... 13 | if (moduleDefinition.actions && moduleDefinition.actions[actionName]) { 14 | // Dispatch the action if the module is namespaced. Otherwise, 15 | // set a flag to dispatch the action globally at the end. 16 | if (moduleDefinition.namespaced) { 17 | store.dispatch(`${modulePrefix}${moduleName}/${actionName}`) 18 | } else { 19 | flags.dispatchGlobal = true 20 | } 21 | } 22 | 23 | // If there are any nested sub-modules... 24 | if (moduleDefinition.modules) { 25 | // Also dispatch the action for these sub-modules. 26 | dispatchActionForAllModules(actionName, { 27 | modules: moduleDefinition.modules, 28 | modulePrefix: modulePrefix + moduleName + '/', 29 | flags, 30 | }) 31 | } 32 | } 33 | 34 | // If this is the root and at least one non-namespaced module 35 | // was found with the action... 36 | if (!modulePrefix && flags.dispatchGlobal) { 37 | // Dispatch the action globally. 38 | store.dispatch(actionName) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /ui/src/utils/dispatch-action-for-all-modules.unit.js: -------------------------------------------------------------------------------- 1 | describe('@utils/dispatch-action-for-all-modules', () => { 2 | beforeEach(() => { 3 | jest.resetModules() 4 | }) 5 | 6 | it('dispatches actions from NOT namespaced modules', () => { 7 | jest.doMock('@state/modules', () => ({ 8 | moduleA: { 9 | actions: { 10 | someAction: jest.fn(), 11 | otherAction: jest.fn(), 12 | }, 13 | }, 14 | moduleB: { 15 | actions: { 16 | someAction: jest.fn(), 17 | otherAction: jest.fn(), 18 | }, 19 | }, 20 | })) 21 | 22 | require('./dispatch-action-for-all-modules').default('someAction') 23 | 24 | const { moduleA, moduleB } = require('@state/modules') 25 | expect(moduleA.actions.someAction).toHaveBeenCalledTimes(1) 26 | expect(moduleB.actions.someAction).toHaveBeenCalledTimes(1) 27 | expect(moduleA.actions.otherAction).not.toHaveBeenCalled() 28 | expect(moduleB.actions.otherAction).not.toHaveBeenCalled() 29 | }) 30 | 31 | it('dispatches actions from namespaced modules', () => { 32 | jest.doMock('@state/modules', () => ({ 33 | moduleA: { 34 | namespaced: true, 35 | actions: { 36 | someAction: jest.fn(), 37 | otherAction: jest.fn(), 38 | }, 39 | }, 40 | moduleB: { 41 | namespaced: true, 42 | actions: { 43 | someAction: jest.fn(), 44 | otherAction: jest.fn(), 45 | }, 46 | }, 47 | })) 48 | 49 | require('./dispatch-action-for-all-modules').default('someAction') 50 | 51 | const { moduleA, moduleB } = require('@state/modules') 52 | expect(moduleA.actions.someAction).toHaveBeenCalledTimes(1) 53 | expect(moduleB.actions.someAction).toHaveBeenCalledTimes(1) 54 | expect(moduleA.actions.otherAction).not.toHaveBeenCalled() 55 | expect(moduleB.actions.otherAction).not.toHaveBeenCalled() 56 | }) 57 | 58 | it('dispatches actions from deeply nested NOT namespaced modules', () => { 59 | jest.doMock('@state/modules', () => ({ 60 | moduleA: { 61 | actions: { 62 | someAction: jest.fn(), 63 | otherAction: jest.fn(), 64 | }, 65 | modules: { 66 | moduleB: { 67 | actions: { 68 | someAction: jest.fn(), 69 | otherAction: jest.fn(), 70 | }, 71 | modules: { 72 | moduleC: { 73 | actions: { 74 | someAction: jest.fn(), 75 | otherAction: jest.fn(), 76 | }, 77 | }, 78 | }, 79 | }, 80 | }, 81 | }, 82 | })) 83 | 84 | require('./dispatch-action-for-all-modules').default('someAction') 85 | 86 | const { moduleA } = require('@state/modules') 87 | const { moduleB } = moduleA.modules 88 | const { moduleC } = moduleB.modules 89 | expect(moduleA.actions.someAction).toHaveBeenCalledTimes(1) 90 | expect(moduleB.actions.someAction).toHaveBeenCalledTimes(1) 91 | expect(moduleC.actions.someAction).toHaveBeenCalledTimes(1) 92 | expect(moduleA.actions.otherAction).not.toHaveBeenCalled() 93 | expect(moduleB.actions.otherAction).not.toHaveBeenCalled() 94 | expect(moduleC.actions.otherAction).not.toHaveBeenCalled() 95 | }) 96 | 97 | it('dispatches actions from deeply nested namespaced modules', () => { 98 | jest.doMock('@state/modules', () => ({ 99 | moduleA: { 100 | namespaced: true, 101 | actions: { 102 | someAction: jest.fn(), 103 | otherAction: jest.fn(), 104 | }, 105 | modules: { 106 | moduleB: { 107 | namespaced: true, 108 | actions: { 109 | someAction: jest.fn(), 110 | otherAction: jest.fn(), 111 | }, 112 | modules: { 113 | moduleC: { 114 | namespaced: true, 115 | actions: { 116 | someAction: jest.fn(), 117 | otherAction: jest.fn(), 118 | }, 119 | }, 120 | }, 121 | }, 122 | }, 123 | }, 124 | })) 125 | 126 | require('./dispatch-action-for-all-modules').default('someAction') 127 | 128 | const { moduleA } = require('@state/modules') 129 | const { moduleB } = moduleA.modules 130 | const { moduleC } = moduleB.modules 131 | expect(moduleA.actions.someAction).toHaveBeenCalledTimes(1) 132 | expect(moduleB.actions.someAction).toHaveBeenCalledTimes(1) 133 | expect(moduleC.actions.someAction).toHaveBeenCalledTimes(1) 134 | expect(moduleA.actions.otherAction).not.toHaveBeenCalled() 135 | expect(moduleB.actions.otherAction).not.toHaveBeenCalled() 136 | expect(moduleC.actions.otherAction).not.toHaveBeenCalled() 137 | }) 138 | }) 139 | -------------------------------------------------------------------------------- /ui/src/utils/format-date-relative.js: -------------------------------------------------------------------------------- 1 | // https://date-fns.org/docs/formatDistance 2 | import formatDistance from 'date-fns/formatDistance' 3 | // https://date-fns.org/docs/isToday 4 | import isToday from 'date-fns/isToday' 5 | 6 | export default function formatDateRelative(fromDate, toDate = new Date()) { 7 | return formatDistance(fromDate, toDate) + (isToday(toDate) ? ' ago' : '') 8 | } 9 | -------------------------------------------------------------------------------- /ui/src/utils/format-date-relative.unit.js: -------------------------------------------------------------------------------- 1 | import formatDateRelative from './format-date-relative' 2 | 3 | describe('@utils/format-date-relative', () => { 4 | it('correctly compares dates years apart', () => { 5 | const fromDate = new Date(2002, 5, 1) 6 | const toDate = new Date(2017, 4, 10) 7 | const timeAgoInWords = formatDateRelative(fromDate, toDate) 8 | expect(timeAgoInWords).toEqual('almost 15 years') 9 | }) 10 | 11 | it('correctly compares dates months apart', () => { 12 | const fromDate = new Date(2017, 8, 1) 13 | const toDate = new Date(2017, 11, 10) 14 | const timeAgoInWords = formatDateRelative(fromDate, toDate) 15 | expect(timeAgoInWords).toEqual('3 months') 16 | }) 17 | 18 | it('correctly compares dates days apart', () => { 19 | const fromDate = new Date(2017, 11, 1) 20 | const toDate = new Date(2017, 11, 10) 21 | const timeAgoInWords = formatDateRelative(fromDate, toDate) 22 | expect(timeAgoInWords).toEqual('9 days') 23 | }) 24 | 25 | it('compares to now when passed only one date', () => { 26 | const fromDate = new Date(2010, 11, 1) 27 | const timeAgoInWords = formatDateRelative(fromDate) 28 | expect(timeAgoInWords).toContain('years ago') 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /ui/src/utils/format-date.js: -------------------------------------------------------------------------------- 1 | // https://date-fns.org/docs/format 2 | import format from 'date-fns/format' 3 | import parseISO from 'date-fns/parseISO' 4 | 5 | export default function formatDate(date) { 6 | return format(date, 'MMM do, yyyy') 7 | } 8 | 9 | export function parseAndFormatDate(date) { 10 | return format(parseISO(date), 'MMM dd, yyyy') 11 | } 12 | 13 | export function parseAndFormatDateTime(date) { 14 | return format(parseISO(date), 'MMM dd, yyyy hh:mm aa') 15 | } 16 | -------------------------------------------------------------------------------- /ui/src/utils/format-date.unit.js: -------------------------------------------------------------------------------- 1 | import formatDate from './format-date' 2 | 3 | describe('@utils/format-date', () => { 4 | it('correctly compares dates years apart', () => { 5 | const date = new Date(2002, 5, 1) 6 | const formattedDate = formatDate(date) 7 | expect(formattedDate).toEqual('Jun 1st, 2002') 8 | }) 9 | 10 | it('correctly compares dates months apart', () => { 11 | const date = new Date(2017, 8, 1) 12 | const formattedDate = formatDate(date) 13 | expect(formattedDate).toEqual('Sep 1st, 2017') 14 | }) 15 | 16 | it('correctly compares dates days apart', () => { 17 | const date = new Date(2017, 11, 11) 18 | const formattedDate = formatDate(date) 19 | expect(formattedDate).toEqual('Dec 11th, 2017') 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /ui/stylelint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | // Use the Standard config as the base 4 | // https://github.com/stylelint/stylelint-config-standard 5 | 'stylelint-config-standard', 6 | // Enforce a standard order for CSS properties 7 | // https://github.com/stormwarning/stylelint-config-recess-order 8 | 'stylelint-config-recess-order', 9 | // Override rules that would interfere with Prettier 10 | // https://github.com/shannonmoeller/stylelint-config-prettier 11 | 'stylelint-config-prettier', 12 | // Override rules to allow linting of CSS modules 13 | // https://github.com/pascalduez/stylelint-config-css-modules 14 | 'stylelint-config-css-modules', 15 | ], 16 | plugins: [ 17 | // Bring in some extra rules for SCSS 18 | 'stylelint-scss', 19 | ], 20 | // Rule lists: 21 | // - https://stylelint.io/user-guide/rules/ 22 | // - https://github.com/kristerkari/stylelint-scss#list-of-rules 23 | rules: { 24 | // Allow newlines inside class attribute values 25 | 'string-no-newline': null, 26 | // Enforce camelCase for classes and ids, to work better 27 | // with CSS modules 28 | 'selector-class-pattern': /^[a-z][a-zA-Z]*(-(enter|leave)(-(active|to))?)?$/, 29 | 'selector-id-pattern': /^[a-z][a-zA-Z]*$/, 30 | // Limit the number of universal selectors in a selector, 31 | // to avoid very slow selectors 32 | 'selector-max-universal': 1, 33 | // Disallow allow global element/type selectors in scoped modules 34 | 'selector-max-type': [0, { ignore: ['child', 'descendant', 'compounded'] }], 35 | // === 36 | // SCSS 37 | // === 38 | 'scss/dollar-variable-colon-space-after': 'always', 39 | 'scss/dollar-variable-colon-space-before': 'never', 40 | 'scss/dollar-variable-no-missing-interpolation': true, 41 | 'scss/dollar-variable-pattern': /^[a-z-]+$/, 42 | 'scss/double-slash-comment-whitespace-inside': 'always', 43 | 'scss/operator-no-newline-before': true, 44 | 'scss/operator-no-unspaced': true, 45 | 'scss/selector-no-redundant-nesting-selector': true, 46 | // Allow SCSS and CSS module keywords beginning with `@` 47 | 'at-rule-no-unknown': null, 48 | 'scss/at-rule-no-unknown': true, 49 | }, 50 | } 51 | -------------------------------------------------------------------------------- /ui/tests/e2e/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: ['cypress'], 3 | env: { 4 | 'cypress/globals': true, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /ui/tests/e2e/plugins/index.js: -------------------------------------------------------------------------------- 1 | // https://docs.cypress.io/guides/guides/plugins-guide.html 2 | module.exports = (on, config) => { 3 | // Dynamic configuration 4 | // https://docs.cypress.io/guides/references/configuration.html 5 | return Object.assign({}, config, { 6 | // === 7 | // General 8 | // https://docs.cypress.io/guides/references/configuration.html#Global 9 | // === 10 | watchForFileChanges: true, 11 | // === 12 | // Environment variables 13 | // https://docs.cypress.io/guides/guides/environment-variables.html#Option-1-cypress-json 14 | // === 15 | env: { 16 | CI: process.env.CI, 17 | }, 18 | // === 19 | // Viewport 20 | // https://docs.cypress.io/guides/references/configuration.html#Viewport 21 | // === 22 | viewportWidth: 1280, 23 | viewportHeight: 720, 24 | // === 25 | // Animations 26 | // https://docs.cypress.io/guides/references/configuration.html#Animations 27 | // === 28 | waitForAnimations: true, 29 | animationDistanceThreshold: 4, 30 | // === 31 | // Timeouts 32 | // https://docs.cypress.io/guides/references/configuration.html#Timeouts 33 | // === 34 | defaultCommandTimeout: 4000, 35 | execTimeout: 60000, 36 | pageLoadTimeout: 60000, 37 | requestTimeout: 5000, 38 | responseTimeout: 30000, 39 | // === 40 | // Main Directories 41 | // https://docs.cypress.io/guides/references/configuration.html#Folders-Files 42 | // === 43 | supportFile: 'tests/e2e/support/setup.js', 44 | integrationFolder: 'tests/e2e/specs', 45 | fixturesFolder: 'tests/e2e/fixtures', 46 | // === 47 | // Videos & Screenshots 48 | // https://docs.cypress.io/guides/core-concepts/screenshots-and-videos.html 49 | // === 50 | videoUploadOnPasses: true, 51 | videoCompression: 32, 52 | videosFolder: 'tests/e2e/videos', 53 | screenshotsFolder: 'tests/e2e/screenshots', 54 | }) 55 | } 56 | -------------------------------------------------------------------------------- /ui/tests/e2e/specs/auth.e2e.js: -------------------------------------------------------------------------------- 1 | describe('Authentication', () => { 2 | it('login link exists on the home page when logged out', () => { 3 | cy.visit('/') 4 | cy.contains('a', 'Log in').should('have.attr', 'href', '/login') 5 | }) 6 | 7 | it('login form shows an error on failure', () => { 8 | cy.visit('/login') 9 | 10 | // Enter bad login info 11 | cy.get('input[name="username"]').type('badUsername') 12 | cy.get('input[name="password"]').type('badPassword') 13 | 14 | // Submit the login form 15 | cy.contains('button', 'Log in').click() 16 | 17 | // Ensure that an error displays 18 | cy.contains('error logging in') 19 | }) 20 | 21 | it('successful login works redirects to the home page and logging out works', () => { 22 | cy.visit('/login') 23 | 24 | // Enter the user-supplied username and password 25 | cy.get('input[name="username"]').type('admin') 26 | cy.get('input[name="password"]').type('password') 27 | 28 | // Submit the login form 29 | cy.contains('button', 'Log in').click() 30 | 31 | // Confirm redirection to the homepage 32 | cy.location('pathname').should('equal', '/') 33 | 34 | // Confirm a logout link exists 35 | cy.contains('a', 'Log out') 36 | }) 37 | 38 | it('login after attempting to visit authenticated route redirects to that route after login', () => { 39 | cy.visit('/profile?someQuery') 40 | 41 | // Confirm redirection to the login page 42 | cy.location('pathname').should('equal', '/login') 43 | 44 | // Enter the user-supplied username and password 45 | cy.get('input[name="username"]').type('admin') 46 | cy.get('input[name="password"]').type('password') 47 | 48 | // Submit the login form 49 | cy.contains('button', 'Log in').click() 50 | 51 | // Confirm redirection to the homepage 52 | cy.location('pathname').should('equal', '/profile') 53 | cy.location('search').should('equal', '?someQuery') 54 | 55 | // Confirm a logout link exists 56 | cy.contains('a', 'Log out') 57 | }) 58 | 59 | it('logout link logs the user out when logged in', () => { 60 | cy.logIn() 61 | 62 | // Click the logout link 63 | cy.contains('a', 'Log out').click() 64 | 65 | // Confirm that the user is logged out 66 | cy.contains('a', 'Log in') 67 | }) 68 | 69 | it('logout from an authenticated route redirects to home', () => { 70 | cy.logIn() 71 | cy.visit('/profile') 72 | 73 | // Click the logout link 74 | cy.contains('a', 'Log out').click() 75 | 76 | // Confirm we're on the correct page 77 | cy.location('pathname').should('equal', '/') 78 | 79 | // Confirm that the user is logged out 80 | cy.contains('a', 'Log in') 81 | }) 82 | }) 83 | -------------------------------------------------------------------------------- /ui/tests/e2e/specs/home.e2e.js: -------------------------------------------------------------------------------- 1 | describe('Home Page', () => { 2 | it('has the correct title and heading', () => { 3 | cy.visit('/') 4 | cy.title().should('equal', 'Home | Hammond') 5 | cy.contains('h1', 'Home Page') 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /ui/tests/e2e/specs/profile.e2e.js: -------------------------------------------------------------------------------- 1 | describe('Profile Page', () => { 2 | it('redirects to login when logged out', () => { 3 | cy.visit('/profile') 4 | cy.location('pathname').should('equal', '/login') 5 | }) 6 | 7 | it('nav link exists when logged in', () => { 8 | cy.logIn() 9 | cy.contains('a', 'Logged in as Vue Master').should( 10 | 'have.attr', 11 | 'href', 12 | '/profile' 13 | ) 14 | }) 15 | 16 | it('shows the current user profile when logged in', () => { 17 | cy.logIn() 18 | cy.visit('/profile') 19 | cy.contains('h1', 'Vue Master') 20 | }) 21 | 22 | it('shows non-current users at username routes when logged in', () => { 23 | cy.logIn() 24 | cy.visit('/profile/user1') 25 | cy.contains('h1', 'User One') 26 | }) 27 | 28 | it('shows a user 404 page when looking for a user that does not exist', () => { 29 | cy.logIn() 30 | cy.visit('/profile/non-existant-user') 31 | cy.contains('h1', /User\s+Not\s+Found/) 32 | }) 33 | }) 34 | -------------------------------------------------------------------------------- /ui/tests/e2e/support/commands.js: -------------------------------------------------------------------------------- 1 | // Create custom Cypress commands and overwrite existing ones. 2 | // https://on.cypress.io/custom-commands 3 | 4 | import { getStore } from './utils' 5 | 6 | Cypress.Commands.add( 7 | 'logIn', 8 | ({ username = 'admin', password = 'password' } = {}) => { 9 | // Manually log the user in 10 | cy.location('pathname').then((pathname) => { 11 | if (pathname === 'blank') { 12 | cy.visit('/') 13 | } 14 | }) 15 | getStore().then((store) => 16 | store.dispatch('auth/logIn', { username, password }) 17 | ) 18 | } 19 | ) 20 | -------------------------------------------------------------------------------- /ui/tests/e2e/support/setup.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | -------------------------------------------------------------------------------- /ui/tests/e2e/support/utils.js: -------------------------------------------------------------------------------- 1 | // Returns the Vuex store. 2 | export const getStore = () => cy.window().its('__app__.$store') 3 | -------------------------------------------------------------------------------- /ui/tests/mock-api/index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const bodyParser = require('body-parser') 4 | 5 | module.exports = (app) => { 6 | app.use(bodyParser.json()) 7 | // Register all routes inside tests/mock-api/routes. 8 | fs.readdirSync(path.join(__dirname, 'routes')).forEach((routeFileName) => { 9 | if (/\.js$/.test(routeFileName)) { 10 | require(`./routes/${routeFileName}`)(app) 11 | } 12 | }) 13 | } 14 | -------------------------------------------------------------------------------- /ui/tests/mock-api/resources/users.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | 3 | module.exports = { 4 | all: [ 5 | { 6 | id: 1, 7 | username: 'admin', 8 | password: 'password', 9 | name: 'Vue Master', 10 | }, 11 | { 12 | id: 2, 13 | username: 'user1', 14 | password: 'password', 15 | name: 'User One', 16 | }, 17 | ].map((user) => { 18 | return { 19 | ...user, 20 | token: `valid-token-for-${user.username}`, 21 | } 22 | }), 23 | authenticate({ username, password }) { 24 | return new Promise((resolve, reject) => { 25 | const matchedUser = this.all.find( 26 | (user) => user.username === username && user.password === password 27 | ) 28 | if (matchedUser) { 29 | resolve(this.json(matchedUser)) 30 | } else { 31 | reject(new Error('Invalid user credentials.')) 32 | } 33 | }) 34 | }, 35 | findBy(propertyName, value) { 36 | const matchedUser = this.all.find((user) => user[propertyName] === value) 37 | return this.json(matchedUser) 38 | }, 39 | json(user) { 40 | return user && _.omit(user, ['password']) 41 | }, 42 | } 43 | -------------------------------------------------------------------------------- /ui/tests/mock-api/routes/auth.js: -------------------------------------------------------------------------------- 1 | const Users = require('../resources/users') 2 | 3 | module.exports = (app) => { 4 | // Log in a user with a username and password 5 | app.post('/api/session', (request, response) => { 6 | Users.authenticate(request.body) 7 | .then((user) => { 8 | response.json(user) 9 | }) 10 | .catch((error) => { 11 | response.status(401).json({ message: error.message }) 12 | }) 13 | }) 14 | 15 | // Get the user of a provided token, if valid 16 | app.get('/api/session', (request, response) => { 17 | const currentUser = Users.findBy('token', request.headers.authorization) 18 | 19 | if (!currentUser) { 20 | return response.status(401).json({ 21 | message: 22 | 'The token is either invalid or has expired. Please log in again.', 23 | }) 24 | } 25 | 26 | response.json(currentUser) 27 | }) 28 | 29 | // A simple ping for checking online status 30 | app.get('/api/ping', (request, response) => { 31 | response.send('OK') 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /ui/tests/mock-api/routes/users.js: -------------------------------------------------------------------------------- 1 | const Users = require('../resources/users') 2 | 3 | module.exports = (app) => { 4 | app.get('/api/users/:username', (request, response) => { 5 | const currentUser = Users.findBy('token', request.headers.authorization) 6 | 7 | if (!currentUser) { 8 | return response.status(401).json({ 9 | message: 10 | 'The token is either invalid or has expired. Please log in again.', 11 | }) 12 | } 13 | 14 | const matchedUser = Users.findBy('username', request.params.username) 15 | 16 | if (!matchedUser) { 17 | return response.status(400).json({ 18 | message: 'No user with this name was found.', 19 | }) 20 | } 21 | 22 | response.json(matchedUser) 23 | }) 24 | } 25 | -------------------------------------------------------------------------------- /ui/tests/unit/__mocks__/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akhilrex/hammond/84cba2c7f26f6d3f81c49b132110b24ac97c7b49/ui/tests/unit/__mocks__/.keep -------------------------------------------------------------------------------- /ui/tests/unit/global-setup.js: -------------------------------------------------------------------------------- 1 | const app = require('express')() 2 | 3 | app.use((request, response, next) => { 4 | response.header('Access-Control-Allow-Origin', '*') 5 | next() 6 | }) 7 | 8 | require('../mock-api')(app) 9 | 10 | module.exports = () => { 11 | return new Promise((resolve, reject) => { 12 | global.mockApiServer = app.listen(process.env.MOCK_API_PORT, resolve) 13 | }) 14 | } 15 | -------------------------------------------------------------------------------- /ui/tests/unit/global-teardown.js: -------------------------------------------------------------------------------- 1 | // Shut down the mock API once all the tests are complete. 2 | 3 | module.exports = () => { 4 | return new Promise((resolve, reject) => { 5 | global.mockApiServer.close(resolve) 6 | }) 7 | } 8 | -------------------------------------------------------------------------------- /ui/tests/unit/matchers.js: -------------------------------------------------------------------------------- 1 | // See these docs for details on Jest's matcher utils: 2 | // https://facebook.github.io/jest/docs/en/expect.html#thisutils 3 | 4 | const _ = require('lodash') 5 | const customMatchers = {} 6 | 7 | customMatchers.toBeAComponent = function(options) { 8 | if (isAComponent()) { 9 | return { 10 | message: () => 11 | `expected ${this.utils.printReceived( 12 | options 13 | )} not to be a Vue component`, 14 | pass: true, 15 | } 16 | } else { 17 | return { 18 | message: () => 19 | `expected ${this.utils.printReceived( 20 | options 21 | )} to be a valid Vue component, exported from a .vue file`, 22 | pass: false, 23 | } 24 | } 25 | 26 | function isAComponent() { 27 | return _.isPlainObject(options) && typeof options.render === 'function' 28 | } 29 | } 30 | 31 | customMatchers.toBeAViewComponent = function(options, mockInstance = {}) { 32 | if (usesALayout() && definesAPageTitleAndDescription()) { 33 | return { 34 | message: () => 35 | `expected ${this.utils.printReceived( 36 | options 37 | )} not to register a local Layout component nor define a page title and meta description`, 38 | pass: true, 39 | } 40 | } else { 41 | return { 42 | message: () => 43 | `expected ${this.utils.printReceived( 44 | options 45 | )} to register a local Layout component and define a page title and meta description`, 46 | pass: false, 47 | } 48 | } 49 | 50 | function usesALayout() { 51 | return options.components && options.components.Layout 52 | } 53 | 54 | function definesAPageTitleAndDescription() { 55 | if (!options.page) return false 56 | const pageObject = 57 | typeof options.page === 'function' 58 | ? options.page.apply(mockInstance) 59 | : options.page 60 | if (!Object.prototype.hasOwnProperty.call(pageObject, 'title')) return false 61 | if (!pageObject.meta) return false 62 | const hasMetaDescription = pageObject.meta.some( 63 | (metaObject) => 64 | metaObject.name === 'description' && 65 | Object.prototype.hasOwnProperty.call(metaObject, 'content') 66 | ) 67 | if (!hasMetaDescription) return false 68 | return true 69 | } 70 | } 71 | 72 | customMatchers.toBeAViewComponentUsing = function(options, mockInstance) { 73 | return customMatchers.toBeAViewComponent.apply(this, [options, mockInstance]) 74 | } 75 | 76 | customMatchers.toBeAVuexModule = function(options) { 77 | if (isAVuexModule()) { 78 | return { 79 | message: () => 80 | `expected ${this.utils.printReceived(options)} not to be a Vuex module`, 81 | pass: true, 82 | } 83 | } else { 84 | return { 85 | message: () => 86 | `expected ${this.utils.printReceived( 87 | options 88 | )} to be a valid Vuex module, include state, getters, mutations, and actions`, 89 | pass: false, 90 | } 91 | } 92 | 93 | function isAVuexModule() { 94 | return ( 95 | _.isPlainObject(options) && 96 | _.isPlainObject(options.state) && 97 | _.isPlainObject(options.getters) && 98 | _.isPlainObject(options.mutations) && 99 | _.isPlainObject(options.actions) 100 | ) 101 | } 102 | } 103 | 104 | // https://facebook.github.io/jest/docs/en/expect.html#expectextendmatchers 105 | global.expect.extend(customMatchers) 106 | -------------------------------------------------------------------------------- /ui/vue.config.js: -------------------------------------------------------------------------------- 1 | const appConfig = require('./src/app.config') 2 | 3 | /** @type import('@vue/cli-service').ProjectOptions */ 4 | module.exports = { 5 | // https://github.com/neutrinojs/webpack-chain/tree/v4#getting-started 6 | chainWebpack(config) { 7 | // We provide the app's title in Webpack's name field, so that 8 | // it can be accessed in index.html to inject the correct title. 9 | config.set('name', appConfig.title) 10 | 11 | // Set up all the aliases we use in our app. 12 | config.resolve.alias.clear().merge(require('./aliases.config').webpack) 13 | 14 | // Don't allow importing .vue files without the extension, as 15 | // it's necessary for some Vetur autocompletions. 16 | config.resolve.extensions.delete('.vue') 17 | 18 | // Only enable performance hints for production builds, 19 | // outside of tests. 20 | config.performance.hints(process.env.NODE_ENV === 'production' && !process.env.VUE_APP_TEST && 'warning') 21 | }, 22 | css: { 23 | // Enable CSS source maps. 24 | sourceMap: true, 25 | }, 26 | // Configure Webpack's dev server. 27 | // https://cli.vuejs.org/guide/cli-service.html 28 | devServer: { 29 | ...(process.env.API_BASE_URL 30 | ? // Proxy API endpoints to the production base URL. 31 | { 32 | proxy: { 33 | '/api': { 34 | target: process.env.API_BASE_URL, 35 | // pathRewrite: { '^/api': '/' }, 36 | }, 37 | }, 38 | } 39 | : // Proxy API endpoints a local mock API. 40 | { before: require('./tests/mock-api') }), 41 | }, 42 | } 43 | --------------------------------------------------------------------------------