├── .babelrc ├── .browserslistrc ├── .editorconfig ├── .eslintrc.js ├── .github └── workflows │ ├── build.yml │ ├── codeql-analysis.yml │ └── release.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── cypress.config.js ├── cypress ├── e2e │ └── examples │ │ ├── actions.spec.js │ │ ├── aliasing.spec.js │ │ ├── assertions.spec.js │ │ ├── connectors.spec.js │ │ ├── cookies.spec.js │ │ ├── cypress_api.spec.js │ │ ├── files.spec.js │ │ ├── local_storage.spec.js │ │ ├── location.spec.js │ │ ├── misc.spec.js │ │ ├── navigation.spec.js │ │ ├── network_requests.spec.js │ │ ├── querying.spec.js │ │ ├── spies_stubs_clocks.spec.js │ │ ├── traversal.spec.js │ │ ├── utilities.spec.js │ │ ├── viewport.spec.js │ │ ├── waiting.spec.js │ │ └── window.spec.js ├── fixtures │ └── example.json ├── plugins │ └── index.js └── support │ ├── commands.js │ └── index.js ├── docs ├── config.schema.md ├── development.md ├── images │ └── responsive.png ├── install.md └── system.md ├── jest.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── favicon.ico ├── img │ └── icons │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── apple-touch-icon-120x120.png │ │ ├── apple-touch-icon-152x152.png │ │ ├── apple-touch-icon-180x180.png │ │ ├── apple-touch-icon-60x60.png │ │ ├── apple-touch-icon-76x76.png │ │ ├── apple-touch-icon.png │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── msapplication-icon-144x144.png │ │ ├── mstile-150x150.png │ │ └── safari-pinned-tab.svg ├── index.html ├── manifest.json └── robots.txt ├── src ├── App.vue ├── assets │ ├── logo.png │ └── logo.svg ├── components │ ├── List.vue │ ├── ListColumnTypes.vue │ └── Share.vue ├── layouts │ └── Main.vue ├── main.js ├── plugins │ ├── toasted.js │ └── vuetify.js ├── registerServiceWorker.js ├── router.js ├── services │ ├── Config.js │ └── Database.js └── views │ ├── ChangeFeed.vue │ ├── Config.vue │ ├── Entry.vue │ ├── Home.vue │ ├── ImportExport.vue │ ├── List.vue │ ├── Login.vue │ ├── Search.vue │ └── Tree.vue ├── tests ├── e2e │ ├── .eslintrc.js │ ├── specs │ │ ├── itemEntry.cy.js │ │ ├── itemList.cy.js │ │ ├── login.cy.js │ │ └── search.cy.js │ └── support │ │ ├── commands.js │ │ └── index.js └── unit │ ├── .eslintrc.js │ └── ListColumnTypes.spec.js └── vue.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "transform-es2015-modules-commonjs" 4 | ] 5 | } -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,ts,tsx,vue}] 2 | indent_style = space 3 | indent_size = 2 4 | trim_trailing_whitespace = true 5 | insert_final_newline = true 6 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true 5 | }, 6 | 'extends': [ 7 | 'plugin:vue/essential', 8 | '@vue/standard' 9 | ], 10 | rules: { 11 | 'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off', 12 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', 13 | 'no-return-assign': 'off', 14 | 'vue/multi-word-component-names': 'off' 15 | }, 16 | parserOptions: { 17 | 'ecmaVersion': 2020 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | pull_request: 5 | workflow_call: 6 | 7 | jobs: 8 | build: 9 | runs-on: ${{ matrix.os }} 10 | 11 | strategy: 12 | matrix: 13 | os: [ubuntu-latest] 14 | node-version: [17.x] 15 | 16 | steps: 17 | - run: git config --global core.autocrlf false 18 | - uses: actions/checkout@v3 19 | 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v2 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | cache: 'npm' 25 | 26 | - name: Install StoreDown dependencies 27 | run: npm ci 28 | 29 | - name: Run linter 30 | run: npm run lint 31 | 32 | - name: Run e2e tests 33 | id: tests 34 | run: npx vue-cli-service test:e2e --headless 35 | 36 | - name: Get test screenshots 37 | if: failure() && steps.tests.outcome == 'failure' 38 | uses: actions/upload-artifact@v3 39 | with: 40 | name: testScreenshots 41 | path: | 42 | tests/e2e/screenshots 43 | 44 | - name: Get test videos 45 | if: failure() && steps.tests.outcome == 'failure' 46 | uses: actions/upload-artifact@v3 47 | with: 48 | name: testVideo 49 | path: | 50 | tests/e2e/videos 51 | 52 | - name: Build StoreDown 53 | run: npm run build 54 | 55 | - name: Build artifact 56 | uses: actions/upload-artifact@v3 57 | with: 58 | name: dist 59 | path: dist 60 | 61 | - name: Upload release files 62 | uses: actions/upload-artifact@v3 63 | with: 64 | name: release 65 | path: | 66 | package.json 67 | Dockerfile 68 | 69 | 70 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '32 17 * * 1' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v3 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | 52 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 53 | # queries: security-extended,security-and-quality 54 | 55 | 56 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 57 | # If this step fails, then you should remove it and run the build manually (see below) 58 | - name: Autobuild 59 | uses: github/codeql-action/autobuild@v2 60 | 61 | # ℹ️ Command-line programs to run using the OS shell. 62 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 63 | 64 | # If the Autobuild fails above, remove it and uncomment the following three lines. 65 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 66 | 67 | # - run: | 68 | # echo "Run, Build Application using script" 69 | # ./location_of_script_within_repo/buildscript.sh 70 | 71 | - name: Perform CodeQL Analysis 72 | uses: github/codeql-action/analyze@v2 73 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build: 10 | uses: ./.github/workflows/build.yml 11 | 12 | release: 13 | runs-on: ubuntu-latest 14 | needs: build 15 | steps: 16 | - name: Retrieve build 17 | uses: actions/download-artifact@v3 18 | with: 19 | name: dist 20 | path: ./dist 21 | - name: Retrieve release files 22 | uses: actions/download-artifact@v3 23 | with: 24 | name: release 25 | path: ./ 26 | - name: Install jq 27 | run: sudo apt-get install -y jq 28 | #Based on https://stackoverflow.com/questions/58177786/get-the-current-pushed-tag-in-github-actions 29 | - name: Get version 30 | run: echo "RELEASE_VERSION=$(jq -r .version ./package.json)" >> $GITHUB_ENV 31 | - name: Test 32 | run: | 33 | echo $RELEASE_VERSION 34 | ls -lt dist 35 | - name: Set up QEMU 36 | uses: docker/setup-qemu-action@v2 37 | - name: Set up Docker Buildx 38 | uses: docker/setup-buildx-action@v2 39 | - name: Login to DockerHub 40 | uses: docker/login-action@v2 41 | with: 42 | username: ${{ secrets.DOCKERHUB_USERNAME }} 43 | password: ${{ secrets.DOCKERHUB_TOKEN }} 44 | - name: Build and push 45 | uses: docker/build-push-action@v3 46 | with: 47 | context: . 48 | platforms: linux/amd64,linux/arm64 49 | push: true 50 | tags: foxusa/storedown:latest,foxusa/storedown:${{ env.RELEASE_VERSION }} 51 | 52 | - name: Zip up release 53 | run: cd dist && zip -r v${{ env.RELEASE_VERSION }}.zip . 54 | 55 | - name: Create GitHub Release 56 | uses: softprops/action-gh-release@v1 57 | with: 58 | name: StoreDown v${{ env.RELEASE_VERSION }} 59 | tag_name: v${{ env.RELEASE_VERSION }} 60 | files: | 61 | dist/v${{ env.RELEASE_VERSION }}.zip 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | /tests/e2e/reports/ 6 | selenium-debug.log 7 | screenshots 8 | videos 9 | 10 | # local env files 11 | .env.local 12 | .env.*.local 13 | 14 | # Log files 15 | npm-debug.log* 16 | yarn-debug.log* 17 | yarn-error.log* 18 | 19 | # Editor directories and files 20 | .idea 21 | .vscode 22 | *.suo 23 | *.ntvs* 24 | *.njsproj 25 | *.sln 26 | *.sw? 27 | home.deploy.md 28 | home.config.yml 29 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx 2 | ADD dist/ /usr/share/nginx/html/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # StoreDown 2 | 3 |  4 | > Do not store up for yourselves treasures on earth, where moths and vermin destroy, and where thieves break in and steal. 5 | 6 | I live by the mantra 7 | 8 | > If you cannot find something or cannot put it away, you don't need it. 9 | 10 | StoreDown is a Progressive Web App(PWA) designed to help you put stuff away and find it again. 11 | Due to the way StoreDown was built it can also be a forms application fronting CouchDB.(Like a very simple alternative of Microsoft Access) 12 | 13 | I personally use StoreDown every day to keep track of 1,359 items at time of writing. Those items range from: a 90 pack of dental floss picks, a Jig saw, a refrigerator, to red 15W USB A to USB C cable(of which I have 6). If having this capability appeals to you, come along and enjoy the fun! 14 | 15 | 16 | ## [Demo/Application](https://storedown.org/dist/) 17 | Click `RUN IN LOCAL MODE` in the bottom left to quickly demo/test. 18 | [Here is some test data you can import](https://gist.github.com/FoxUSA/80bc1b72b896a5d1db550ea7aaf4a167) 19 | 20 | > If you use this you should probably take regular backups exports of your data. 21 | 22 | > If you want to sync between multiple devices, you need to setup a [CouchDB server](https://hub.docker.com/_/couchdb). 23 | 24 | ## Features 25 | - BYOS(Bring Your Own Server) 26 | - Touch friendly and mouse friendly ui 27 | - Search 28 | - Can function offline 29 | - CouchDB Sync via PouchDB 30 | - Responsive 31 | - Tags 32 | - Customizable fields 33 | - Data Export(JSON, YML, and CSV) 34 | - Data Import(JSON, and YML) 35 | - TODO markdown 36 | 37 | 38 | 39 | ## Quick Links 40 | - [How to install](./docs/install.md) 41 | - [How to develop](./docs/development.md) 42 | - [Config/Custom field customization](./docs/config.schema.md) 43 | - [How I use StoreDown](./docs/system.md) 44 | - [Sub Reddit](https://www.reddit.com/r/storedown/) 45 | - [Like us on Alternative To][alternativeto] 46 | 47 | 48 | 49 | --- 50 | 51 | ## FAQ 52 | 53 | > Why not just use a spreadsheet? 54 | 55 | The first answer is mobile. Spreadsheets work terrible on mobile. 56 | The second answer is customization. The ability to search via UPC codes is the killer application for this system. 57 | 58 | > So you read "Don't store up" and built an inventory system? 59 | 60 | Keeping track of what you have and storing up are separate things. 61 | Keeping track of things lets you do cool things like see the last time something was used. 62 | Haven't use the rock band drums in 3 years? Might do good for someone else. 63 | This use case alone is why this system is called `StoreDown`. 64 | 65 | Also allows you to not over consume. 66 | Another killer application of this system is just label cables. 67 | - Do I have an unused HDMI cable somewhere? 68 | - I need a USB-A to micro-USB cable thats at least 7 feet long. 69 | 70 | Labeling items allows some unforeseen benefits like: 71 | - Sending someone not technical to retrieve a parallel port. 72 | - Answering questions like: 73 | - When did I buy xyz? 74 | - It's broken. Is it under warranty? 75 | 76 | > I don't like xyz about StoreDown, do you know of any alternatives? 77 | 78 | I would really like to be friends with the folks in orbit around [InvenTree](https://github.com/inventree/InvenTree) and [Grocy](https://github.com/grocy/grocy). 79 | So go check those out. More alternatives on [Alternative To][alternativeto]. 80 | 81 | [alternativeto]: https://alternativeto.net/software/storedown/ -------------------------------------------------------------------------------- /cypress.config.js: -------------------------------------------------------------------------------- 1 | const { defineConfig } = require('cypress') 2 | 3 | module.exports = defineConfig({ 4 | fixturesFolder: 'tests/e2e/fixtures', 5 | screenshotsFolder: 'tests/e2e/screenshots', 6 | videosFolder: 'tests/e2e/videos', 7 | e2e: { 8 | specPattern: 'tests/e2e/specs/**/*.cy.{js,jsx,ts,tsx}', 9 | supportFile: 'tests/e2e/support/index.js', 10 | }, 11 | }) 12 | -------------------------------------------------------------------------------- /cypress/e2e/examples/actions.spec.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | context('Actions', () => { 4 | beforeEach(() => { 5 | cy.visit('https://example.cypress.io/commands/actions') 6 | }) 7 | 8 | // https://on.cypress.io/interacting-with-elements 9 | 10 | it('.type() - type into a DOM element', () => { 11 | // https://on.cypress.io/type 12 | cy.get('.action-email') 13 | .type('fake@email.com').should('have.value', 'fake@email.com') 14 | 15 | // .type() with special character sequences 16 | .type('{leftarrow}{rightarrow}{uparrow}{downarrow}') 17 | .type('{del}{selectall}{backspace}') 18 | 19 | // .type() with key modifiers 20 | .type('{alt}{option}') //these are equivalent 21 | .type('{ctrl}{control}') //these are equivalent 22 | .type('{meta}{command}{cmd}') //these are equivalent 23 | .type('{shift}') 24 | 25 | // Delay each keypress by 0.1 sec 26 | .type('slow.typing@email.com', { delay: 100 }) 27 | .should('have.value', 'slow.typing@email.com') 28 | 29 | cy.get('.action-disabled') 30 | // Ignore error checking prior to type 31 | // like whether the input is visible or disabled 32 | .type('disabled error checking', { force: true }) 33 | .should('have.value', 'disabled error checking') 34 | }) 35 | 36 | it('.focus() - focus on a DOM element', () => { 37 | // https://on.cypress.io/focus 38 | cy.get('.action-focus').focus() 39 | .should('have.class', 'focus') 40 | .prev().should('have.attr', 'style', 'color: orange;') 41 | }) 42 | 43 | it('.blur() - blur off a DOM element', () => { 44 | // https://on.cypress.io/blur 45 | cy.get('.action-blur').type('About to blur').blur() 46 | .should('have.class', 'error') 47 | .prev().should('have.attr', 'style', 'color: red;') 48 | }) 49 | 50 | it('.clear() - clears an input or textarea element', () => { 51 | // https://on.cypress.io/clear 52 | cy.get('.action-clear').type('Clear this text') 53 | .should('have.value', 'Clear this text') 54 | .clear() 55 | .should('have.value', '') 56 | }) 57 | 58 | it('.submit() - submit a form', () => { 59 | // https://on.cypress.io/submit 60 | cy.get('.action-form') 61 | .find('[type="text"]').type('HALFOFF') 62 | cy.get('.action-form').submit() 63 | .next().should('contain', 'Your form has been submitted!') 64 | }) 65 | 66 | it('.click() - click on a DOM element', () => { 67 | // https://on.cypress.io/click 68 | cy.get('.action-btn').click() 69 | 70 | // You can click on 9 specific positions of an element: 71 | // ----------------------------------- 72 | // | topLeft top topRight | 73 | // | | 74 | // | | 75 | // | | 76 | // | left center right | 77 | // | | 78 | // | | 79 | // | | 80 | // | bottomLeft bottom bottomRight | 81 | // ----------------------------------- 82 | 83 | // clicking in the center of the element is the default 84 | cy.get('#action-canvas').click() 85 | 86 | cy.get('#action-canvas').click('topLeft') 87 | cy.get('#action-canvas').click('top') 88 | cy.get('#action-canvas').click('topRight') 89 | cy.get('#action-canvas').click('left') 90 | cy.get('#action-canvas').click('right') 91 | cy.get('#action-canvas').click('bottomLeft') 92 | cy.get('#action-canvas').click('bottom') 93 | cy.get('#action-canvas').click('bottomRight') 94 | 95 | // .click() accepts an x and y coordinate 96 | // that controls where the click occurs :) 97 | 98 | cy.get('#action-canvas') 99 | .click(80, 75) // click 80px on x coord and 75px on y coord 100 | .click(170, 75) 101 | .click(80, 165) 102 | .click(100, 185) 103 | .click(125, 190) 104 | .click(150, 185) 105 | .click(170, 165) 106 | 107 | // click multiple elements by passing multiple: true 108 | cy.get('.action-labels>.label').click({ multiple: true }) 109 | 110 | // Ignore error checking prior to clicking 111 | cy.get('.action-opacity>.btn').click({ force: true }) 112 | }) 113 | 114 | it('.dblclick() - double click on a DOM element', () => { 115 | // https://on.cypress.io/dblclick 116 | 117 | // Our app has a listener on 'dblclick' event in our 'scripts.js' 118 | // that hides the div and shows an input on double click 119 | cy.get('.action-div').dblclick().should('not.be.visible') 120 | cy.get('.action-input-hidden').should('be.visible') 121 | }) 122 | 123 | it('.check() - check a checkbox or radio element', () => { 124 | // https://on.cypress.io/check 125 | 126 | // By default, .check() will check all 127 | // matching checkbox or radio elements in succession, one after another 128 | cy.get('.action-checkboxes [type="checkbox"]').not('[disabled]') 129 | .check().should('be.checked') 130 | 131 | cy.get('.action-radios [type="radio"]').not('[disabled]') 132 | .check().should('be.checked') 133 | 134 | // .check() accepts a value argument 135 | cy.get('.action-radios [type="radio"]') 136 | .check('radio1').should('be.checked') 137 | 138 | // .check() accepts an array of values 139 | cy.get('.action-multiple-checkboxes [type="checkbox"]') 140 | .check(['checkbox1', 'checkbox2']).should('be.checked') 141 | 142 | // Ignore error checking prior to checking 143 | cy.get('.action-checkboxes [disabled]') 144 | .check({ force: true }).should('be.checked') 145 | 146 | cy.get('.action-radios [type="radio"]') 147 | .check('radio3', { force: true }).should('be.checked') 148 | }) 149 | 150 | it('.uncheck() - uncheck a checkbox element', () => { 151 | // https://on.cypress.io/uncheck 152 | 153 | // By default, .uncheck() will uncheck all matching 154 | // checkbox elements in succession, one after another 155 | cy.get('.action-check [type="checkbox"]') 156 | .not('[disabled]') 157 | .uncheck().should('not.be.checked') 158 | 159 | // .uncheck() accepts a value argument 160 | cy.get('.action-check [type="checkbox"]') 161 | .check('checkbox1') 162 | .uncheck('checkbox1').should('not.be.checked') 163 | 164 | // .uncheck() accepts an array of values 165 | cy.get('.action-check [type="checkbox"]') 166 | .check(['checkbox1', 'checkbox3']) 167 | .uncheck(['checkbox1', 'checkbox3']).should('not.be.checked') 168 | 169 | // Ignore error checking prior to unchecking 170 | cy.get('.action-check [disabled]') 171 | .uncheck({ force: true }).should('not.be.checked') 172 | }) 173 | 174 | it('.select() - select an option in a element', () => { 175 | // https://on.cypress.io/select 176 | 177 | // Select option(s) with matching text content 178 | cy.get('.action-select').select('apples') 179 | 180 | cy.get('.action-select-multiple') 181 | .select(['apples', 'oranges', 'bananas']) 182 | 183 | // Select option(s) with matching value 184 | cy.get('.action-select').select('fr-bananas') 185 | 186 | cy.get('.action-select-multiple') 187 | .select(['fr-apples', 'fr-oranges', 'fr-bananas']) 188 | }) 189 | 190 | it('.scrollIntoView() - scroll an element into view', () => { 191 | // https://on.cypress.io/scrollintoview 192 | 193 | // normally all of these buttons are hidden, 194 | // because they're not within 195 | // the viewable area of their parent 196 | // (we need to scroll to see them) 197 | cy.get('#scroll-horizontal button') 198 | .should('not.be.visible') 199 | 200 | // scroll the button into view, as if the user had scrolled 201 | cy.get('#scroll-horizontal button').scrollIntoView() 202 | .should('be.visible') 203 | 204 | cy.get('#scroll-vertical button') 205 | .should('not.be.visible') 206 | 207 | // Cypress handles the scroll direction needed 208 | cy.get('#scroll-vertical button').scrollIntoView() 209 | .should('be.visible') 210 | 211 | cy.get('#scroll-both button') 212 | .should('not.be.visible') 213 | 214 | // Cypress knows to scroll to the right and down 215 | cy.get('#scroll-both button').scrollIntoView() 216 | .should('be.visible') 217 | }) 218 | 219 | it('.trigger() - trigger an event on a DOM element', () => { 220 | // https://on.cypress.io/trigger 221 | 222 | // To interact with a range input (slider) 223 | // we need to set its value & trigger the 224 | // event to signal it changed 225 | 226 | // Here, we invoke jQuery's val() method to set 227 | // the value and trigger the 'change' event 228 | cy.get('.trigger-input-range') 229 | .invoke('val', 25) 230 | .trigger('change') 231 | .get('input[type=range]').siblings('p') 232 | .should('have.text', '25') 233 | }) 234 | 235 | it('cy.scrollTo() - scroll the window or element to a position', () => { 236 | 237 | // https://on.cypress.io/scrollTo 238 | 239 | // You can scroll to 9 specific positions of an element: 240 | // ----------------------------------- 241 | // | topLeft top topRight | 242 | // | | 243 | // | | 244 | // | | 245 | // | left center right | 246 | // | | 247 | // | | 248 | // | | 249 | // | bottomLeft bottom bottomRight | 250 | // ----------------------------------- 251 | 252 | // if you chain .scrollTo() off of cy, we will 253 | // scroll the entire window 254 | cy.scrollTo('bottom') 255 | 256 | cy.get('#scrollable-horizontal').scrollTo('right') 257 | 258 | // or you can scroll to a specific coordinate: 259 | // (x axis, y axis) in pixels 260 | cy.get('#scrollable-vertical').scrollTo(250, 250) 261 | 262 | // or you can scroll to a specific percentage 263 | // of the (width, height) of the element 264 | cy.get('#scrollable-both').scrollTo('75%', '25%') 265 | 266 | // control the easing of the scroll (default is 'swing') 267 | cy.get('#scrollable-vertical').scrollTo('center', { easing: 'linear' }) 268 | 269 | // control the duration of the scroll (in ms) 270 | cy.get('#scrollable-both').scrollTo('center', { duration: 2000 }) 271 | }) 272 | }) 273 | -------------------------------------------------------------------------------- /cypress/e2e/examples/aliasing.spec.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | context('Aliasing', () => { 4 | beforeEach(() => { 5 | cy.visit('https://example.cypress.io/commands/aliasing') 6 | }) 7 | 8 | it('.as() - alias a DOM element for later use', () => { 9 | // https://on.cypress.io/as 10 | 11 | // Alias a DOM element for use later 12 | // We don't have to traverse to the element 13 | // later in our code, we reference it with @ 14 | 15 | cy.get('.as-table').find('tbody>tr') 16 | .first().find('td').first() 17 | .find('button').as('firstBtn') 18 | 19 | // when we reference the alias, we place an 20 | // @ in front of its name 21 | cy.get('@firstBtn').click() 22 | 23 | cy.get('@firstBtn') 24 | .should('have.class', 'btn-success') 25 | .and('contain', 'Changed') 26 | }) 27 | 28 | it('.as() - alias a route for later use', () => { 29 | 30 | // Alias the route to wait for its response 31 | cy.server() 32 | cy.route('GET', 'comments/*').as('getComment') 33 | 34 | // we have code that gets a comment when 35 | // the button is clicked in scripts.js 36 | cy.get('.network-btn').click() 37 | 38 | // https://on.cypress.io/wait 39 | cy.wait('@getComment').its('status').should('eq', 200) 40 | 41 | }) 42 | }) 43 | -------------------------------------------------------------------------------- /cypress/e2e/examples/assertions.spec.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | context('Assertions', () => { 4 | beforeEach(() => { 5 | cy.visit('https://example.cypress.io/commands/assertions') 6 | }) 7 | 8 | describe('Implicit Assertions', () => { 9 | it('.should() - make an assertion about the current subject', () => { 10 | // https://on.cypress.io/should 11 | cy.get('.assertion-table') 12 | .find('tbody tr:last') 13 | .should('have.class', 'success') 14 | .find('td') 15 | .first() 16 | // checking the text of the element in various ways 17 | .should('have.text', 'Column content') 18 | .should('contain', 'Column content') 19 | .should('have.html', 'Column content') 20 | // chai-jquery uses "is()" to check if element matches selector 21 | .should('match', 'td') 22 | // to match text content against a regular expression 23 | // first need to invoke jQuery method text() 24 | // and then match using regular expression 25 | .invoke('text') 26 | .should('match', /column content/i) 27 | 28 | // a better way to check element's text content against a regular expression 29 | // is to use "cy.contains" 30 | // https://on.cypress.io/contains 31 | cy.get('.assertion-table') 32 | .find('tbody tr:last') 33 | // finds first element with text content matching regular expression 34 | .contains('td', /column content/i) 35 | .should('be.visible') 36 | 37 | // for more information about asserting element's text 38 | // see https://on.cypress.io/using-cypress-faq#How-do-I-get-an-element’s-text-contents 39 | }) 40 | 41 | it('.and() - chain multiple assertions together', () => { 42 | // https://on.cypress.io/and 43 | cy.get('.assertions-link') 44 | .should('have.class', 'active') 45 | .and('have.attr', 'href') 46 | .and('include', 'cypress.io') 47 | }) 48 | }) 49 | 50 | describe('Explicit Assertions', () => { 51 | // https://on.cypress.io/assertions 52 | it('expect - make an assertion about a specified subject', () => { 53 | // We can use Chai's BDD style assertions 54 | expect(true).to.be.true 55 | const o = { foo: 'bar' } 56 | 57 | expect(o).to.equal(o) 58 | expect(o).to.deep.equal({ foo: 'bar' }) 59 | // matching text using regular expression 60 | expect('FooBar').to.match(/bar$/i) 61 | }) 62 | 63 | it('pass your own callback function to should()', () => { 64 | // Pass a function to should that can have any number 65 | // of explicit assertions within it. 66 | // The ".should(cb)" function will be retried 67 | // automatically until it passes all your explicit assertions or times out. 68 | cy.get('.assertions-p') 69 | .find('p') 70 | .should(($p) => { 71 | // https://on.cypress.io/$ 72 | // return an array of texts from all of the p's 73 | // @ts-ignore TS6133 unused variable 74 | const texts = $p.map((i, el) => Cypress.$(el).text()) 75 | 76 | // jquery map returns jquery object 77 | // and .get() convert this to simple array 78 | const paragraphs = texts.get() 79 | 80 | // array should have length of 3 81 | expect(paragraphs, 'has 3 paragraphs').to.have.length(3) 82 | 83 | // use second argument to expect(...) to provide clear 84 | // message with each assertion 85 | expect(paragraphs, 'has expected text in each paragraph').to.deep.eq([ 86 | 'Some text from first p', 87 | 'More text from second p', 88 | 'And even more text from third p', 89 | ]) 90 | }) 91 | }) 92 | 93 | it('finds element by class name regex', () => { 94 | cy.get('.docs-header') 95 | .find('div') 96 | // .should(cb) callback function will be retried 97 | .should(($div) => { 98 | expect($div).to.have.length(1) 99 | 100 | const className = $div[0].className 101 | 102 | expect(className).to.match(/heading-/) 103 | }) 104 | // .then(cb) callback is not retried, 105 | // it either passes or fails 106 | .then(($div) => { 107 | expect($div, 'text content').to.have.text('Introduction') 108 | }) 109 | }) 110 | 111 | it('can throw any error', () => { 112 | cy.get('.docs-header') 113 | .find('div') 114 | .should(($div) => { 115 | if ($div.length !== 1) { 116 | // you can throw your own errors 117 | throw new Error('Did not find 1 element') 118 | } 119 | 120 | const className = $div[0].className 121 | 122 | if (!className.match(/heading-/)) { 123 | throw new Error(`Could not find class "heading-" in ${className}`) 124 | } 125 | }) 126 | }) 127 | 128 | it('matches unknown text between two elements', () => { 129 | /** 130 | * Text from the first element. 131 | * @type {string} 132 | */ 133 | let text 134 | 135 | /** 136 | * Normalizes passed text, 137 | * useful before comparing text with spaces and different capitalization. 138 | * @param {string} s Text to normalize 139 | */ 140 | const normalizeText = (s) => s.replace(/\s/g, '').toLowerCase() 141 | 142 | cy.get('.two-elements') 143 | .find('.first') 144 | .then(($first) => { 145 | // save text from the first element 146 | text = normalizeText($first.text()) 147 | }) 148 | 149 | cy.get('.two-elements') 150 | .find('.second') 151 | .should(($div) => { 152 | // we can massage text before comparing 153 | const secondText = normalizeText($div.text()) 154 | 155 | expect(secondText, 'second text').to.equal(text) 156 | }) 157 | }) 158 | 159 | it('assert - assert shape of an object', () => { 160 | const person = { 161 | name: 'Joe', 162 | age: 20, 163 | } 164 | 165 | assert.isObject(person, 'value is object') 166 | }) 167 | }) 168 | }) 169 | -------------------------------------------------------------------------------- /cypress/e2e/examples/connectors.spec.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | context('Connectors', () => { 4 | beforeEach(() => { 5 | cy.visit('https://example.cypress.io/commands/connectors') 6 | }) 7 | 8 | it('.each() - iterate over an array of elements', () => { 9 | // https://on.cypress.io/each 10 | cy.get('.connectors-each-ul>li') 11 | .each(($el, index, $list) => { 12 | console.log($el, index, $list) 13 | }) 14 | }) 15 | 16 | it('.its() - get properties on the current subject', () => { 17 | // https://on.cypress.io/its 18 | cy.get('.connectors-its-ul>li') 19 | // calls the 'length' property yielding that value 20 | .its('length') 21 | .should('be.gt', 2) 22 | }) 23 | 24 | it('.invoke() - invoke a function on the current subject', () => { 25 | // our div is hidden in our script.js 26 | // $('.connectors-div').hide() 27 | 28 | // https://on.cypress.io/invoke 29 | cy.get('.connectors-div').should('be.hidden') 30 | // call the jquery method 'show' on the 'div.container' 31 | .invoke('show') 32 | .should('be.visible') 33 | }) 34 | 35 | it('.spread() - spread an array as individual args to callback function', () => { 36 | // https://on.cypress.io/spread 37 | const arr = ['foo', 'bar', 'baz'] 38 | 39 | cy.wrap(arr).spread((foo, bar, baz) => { 40 | expect(foo).to.eq('foo') 41 | expect(bar).to.eq('bar') 42 | expect(baz).to.eq('baz') 43 | }) 44 | }) 45 | 46 | it('.then() - invoke a callback function with the current subject', () => { 47 | // https://on.cypress.io/then 48 | cy.get('.connectors-list > li') 49 | .then(($lis) => { 50 | expect($lis, '3 items').to.have.length(3) 51 | expect($lis.eq(0), 'first item').to.contain('Walk the dog') 52 | expect($lis.eq(1), 'second item').to.contain('Feed the cat') 53 | expect($lis.eq(2), 'third item').to.contain('Write JavaScript') 54 | }) 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /cypress/e2e/examples/cookies.spec.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | context('Cookies', () => { 4 | beforeEach(() => { 5 | Cypress.Cookies.debug(true) 6 | 7 | cy.visit('https://example.cypress.io/commands/cookies') 8 | 9 | // clear cookies again after visiting to remove 10 | // any 3rd party cookies picked up such as cloudflare 11 | cy.clearCookies() 12 | }) 13 | 14 | it('cy.getCookie() - get a browser cookie', () => { 15 | // https://on.cypress.io/getcookie 16 | cy.get('#getCookie .set-a-cookie').click() 17 | 18 | // cy.getCookie() yields a cookie object 19 | cy.getCookie('token').should('have.property', 'value', '123ABC') 20 | }) 21 | 22 | it('cy.getCookies() - get browser cookies', () => { 23 | // https://on.cypress.io/getcookies 24 | cy.getCookies().should('be.empty') 25 | 26 | cy.get('#getCookies .set-a-cookie').click() 27 | 28 | // cy.getCookies() yields an array of cookies 29 | cy.getCookies().should('have.length', 1).should((cookies) => { 30 | 31 | // each cookie has these properties 32 | expect(cookies[0]).to.have.property('name', 'token') 33 | expect(cookies[0]).to.have.property('value', '123ABC') 34 | expect(cookies[0]).to.have.property('httpOnly', false) 35 | expect(cookies[0]).to.have.property('secure', false) 36 | expect(cookies[0]).to.have.property('domain') 37 | expect(cookies[0]).to.have.property('path') 38 | }) 39 | }) 40 | 41 | it('cy.setCookie() - set a browser cookie', () => { 42 | // https://on.cypress.io/setcookie 43 | cy.getCookies().should('be.empty') 44 | 45 | cy.setCookie('foo', 'bar') 46 | 47 | // cy.getCookie() yields a cookie object 48 | cy.getCookie('foo').should('have.property', 'value', 'bar') 49 | }) 50 | 51 | it('cy.clearCookie() - clear a browser cookie', () => { 52 | // https://on.cypress.io/clearcookie 53 | cy.getCookie('token').should('be.null') 54 | 55 | cy.get('#clearCookie .set-a-cookie').click() 56 | 57 | cy.getCookie('token').should('have.property', 'value', '123ABC') 58 | 59 | // cy.clearCookies() yields null 60 | cy.clearCookie('token').should('be.null') 61 | 62 | cy.getCookie('token').should('be.null') 63 | }) 64 | 65 | it('cy.clearCookies() - clear browser cookies', () => { 66 | // https://on.cypress.io/clearcookies 67 | cy.getCookies().should('be.empty') 68 | 69 | cy.get('#clearCookies .set-a-cookie').click() 70 | 71 | cy.getCookies().should('have.length', 1) 72 | 73 | // cy.clearCookies() yields null 74 | cy.clearCookies() 75 | 76 | cy.getCookies().should('be.empty') 77 | }) 78 | }) 79 | -------------------------------------------------------------------------------- /cypress/e2e/examples/cypress_api.spec.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | context('Cypress.Commands', () => { 4 | beforeEach(() => { 5 | cy.visit('https://example.cypress.io/cypress-api') 6 | }) 7 | 8 | // https://on.cypress.io/custom-commands 9 | 10 | it('.add() - create a custom command', () => { 11 | Cypress.Commands.add('console', { 12 | prevSubject: true, 13 | }, (subject, method) => { 14 | // the previous subject is automatically received 15 | // and the commands arguments are shifted 16 | 17 | // allow us to change the console method used 18 | method = method || 'log' 19 | 20 | // log the subject to the console 21 | // @ts-ignore TS7017 22 | console[method]('The subject is', subject) 23 | 24 | // whatever we return becomes the new subject 25 | // we don't want to change the subject so 26 | // we return whatever was passed in 27 | return subject 28 | }) 29 | 30 | // @ts-ignore TS2339 31 | cy.get('button').console('info').then(($button) => { 32 | // subject is still $button 33 | }) 34 | }) 35 | }) 36 | 37 | 38 | context('Cypress.Cookies', () => { 39 | beforeEach(() => { 40 | cy.visit('https://example.cypress.io/cypress-api') 41 | }) 42 | 43 | // https://on.cypress.io/cookies 44 | it('.debug() - enable or disable debugging', () => { 45 | Cypress.Cookies.debug(true) 46 | 47 | // Cypress will now log in the console when 48 | // cookies are set or cleared 49 | cy.setCookie('fakeCookie', '123ABC') 50 | cy.clearCookie('fakeCookie') 51 | cy.setCookie('fakeCookie', '123ABC') 52 | cy.clearCookie('fakeCookie') 53 | cy.setCookie('fakeCookie', '123ABC') 54 | }) 55 | 56 | it('.preserveOnce() - preserve cookies by key', () => { 57 | // normally cookies are reset after each test 58 | cy.getCookie('fakeCookie').should('not.be.ok') 59 | 60 | // preserving a cookie will not clear it when 61 | // the next test starts 62 | cy.setCookie('lastCookie', '789XYZ') 63 | Cypress.Cookies.preserveOnce('lastCookie') 64 | }) 65 | 66 | it('.defaults() - set defaults for all cookies', () => { 67 | // now any cookie with the name 'session_id' will 68 | // not be cleared before each new test runs 69 | Cypress.Cookies.defaults({ 70 | whitelist: 'session_id', 71 | }) 72 | }) 73 | }) 74 | 75 | 76 | context('Cypress.Server', () => { 77 | beforeEach(() => { 78 | cy.visit('https://example.cypress.io/cypress-api') 79 | }) 80 | 81 | // Permanently override server options for 82 | // all instances of cy.server() 83 | 84 | // https://on.cypress.io/cypress-server 85 | it('.defaults() - change default config of server', () => { 86 | Cypress.Server.defaults({ 87 | delay: 0, 88 | force404: false, 89 | }) 90 | }) 91 | }) 92 | 93 | context('Cypress.arch', () => { 94 | beforeEach(() => { 95 | cy.visit('https://example.cypress.io/cypress-api') 96 | }) 97 | 98 | it('Get CPU architecture name of underlying OS', () => { 99 | // https://on.cypress.io/arch 100 | expect(Cypress.arch).to.exist 101 | }) 102 | }) 103 | 104 | context('Cypress.config()', () => { 105 | beforeEach(() => { 106 | cy.visit('https://example.cypress.io/cypress-api') 107 | }) 108 | 109 | it('Get and set configuration options', () => { 110 | // https://on.cypress.io/config 111 | let myConfig = Cypress.config() 112 | 113 | expect(myConfig).to.have.property('animationDistanceThreshold', 5) 114 | expect(myConfig).to.have.property('baseUrl', null) 115 | expect(myConfig).to.have.property('defaultCommandTimeout', 4000) 116 | expect(myConfig).to.have.property('requestTimeout', 5000) 117 | expect(myConfig).to.have.property('responseTimeout', 30000) 118 | expect(myConfig).to.have.property('viewportHeight', 660) 119 | expect(myConfig).to.have.property('viewportWidth', 1000) 120 | expect(myConfig).to.have.property('pageLoadTimeout', 60000) 121 | expect(myConfig).to.have.property('waitForAnimations', true) 122 | 123 | expect(Cypress.config('pageLoadTimeout')).to.eq(60000) 124 | 125 | // this will change the config for the rest of your tests! 126 | Cypress.config('pageLoadTimeout', 20000) 127 | 128 | expect(Cypress.config('pageLoadTimeout')).to.eq(20000) 129 | 130 | Cypress.config('pageLoadTimeout', 60000) 131 | }) 132 | }) 133 | 134 | context('Cypress.dom', () => { 135 | beforeEach(() => { 136 | cy.visit('https://example.cypress.io/cypress-api') 137 | }) 138 | 139 | // https://on.cypress.io/dom 140 | it('.isHidden() - determine if a DOM element is hidden', () => { 141 | let hiddenP = Cypress.$('.dom-p p.hidden').get(0) 142 | let visibleP = Cypress.$('.dom-p p.visible').get(0) 143 | 144 | // our first paragraph has css class 'hidden' 145 | expect(Cypress.dom.isHidden(hiddenP)).to.be.true 146 | expect(Cypress.dom.isHidden(visibleP)).to.be.false 147 | }) 148 | }) 149 | 150 | context('Cypress.env()', () => { 151 | beforeEach(() => { 152 | cy.visit('https://example.cypress.io/cypress-api') 153 | }) 154 | 155 | // We can set environment variables for highly dynamic values 156 | 157 | // https://on.cypress.io/environment-variables 158 | it('Get environment variables', () => { 159 | // https://on.cypress.io/env 160 | // set multiple environment variables 161 | Cypress.env({ 162 | host: 'veronica.dev.local', 163 | api_server: 'http://localhost:8888/v1/', 164 | }) 165 | 166 | // get environment variable 167 | expect(Cypress.env('host')).to.eq('veronica.dev.local') 168 | 169 | // set environment variable 170 | Cypress.env('api_server', 'http://localhost:8888/v2/') 171 | expect(Cypress.env('api_server')).to.eq('http://localhost:8888/v2/') 172 | 173 | // get all environment variable 174 | expect(Cypress.env()).to.have.property('host', 'veronica.dev.local') 175 | expect(Cypress.env()).to.have.property('api_server', 'http://localhost:8888/v2/') 176 | }) 177 | }) 178 | 179 | context('Cypress.log', () => { 180 | beforeEach(() => { 181 | cy.visit('https://example.cypress.io/cypress-api') 182 | }) 183 | 184 | it('Control what is printed to the Command Log', () => { 185 | // https://on.cypress.io/cypress-log 186 | }) 187 | }) 188 | 189 | 190 | context('Cypress.platform', () => { 191 | beforeEach(() => { 192 | cy.visit('https://example.cypress.io/cypress-api') 193 | }) 194 | 195 | it('Get underlying OS name', () => { 196 | // https://on.cypress.io/platform 197 | expect(Cypress.platform).to.be.exist 198 | }) 199 | }) 200 | 201 | context('Cypress.version', () => { 202 | beforeEach(() => { 203 | cy.visit('https://example.cypress.io/cypress-api') 204 | }) 205 | 206 | it('Get current version of Cypress being run', () => { 207 | // https://on.cypress.io/version 208 | expect(Cypress.version).to.be.exist 209 | }) 210 | }) 211 | 212 | context('Cypress.spec', () => { 213 | beforeEach(() => { 214 | cy.visit('https://example.cypress.io/cypress-api') 215 | }) 216 | 217 | it('Get current spec information', () => { 218 | // https://on.cypress.io/spec 219 | // wrap the object so we can inspect it easily by clicking in the command log 220 | cy.wrap(Cypress.spec).should('have.keys', ['name', 'relative', 'absolute']) 221 | }) 222 | }) 223 | -------------------------------------------------------------------------------- /cypress/e2e/examples/files.spec.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | /// JSON fixture file can be loaded directly using 4 | // the built-in JavaScript bundler 5 | // @ts-ignore 6 | const requiredExample = require('../../fixtures/example') 7 | 8 | context('Files', () => { 9 | beforeEach(() => { 10 | cy.visit('https://example.cypress.io/commands/files') 11 | }) 12 | 13 | beforeEach(() => { 14 | // load example.json fixture file and store 15 | // in the test context object 16 | cy.fixture('example.json').as('example') 17 | }) 18 | 19 | it('cy.fixture() - load a fixture', () => { 20 | // https://on.cypress.io/fixture 21 | 22 | // Instead of writing a response inline you can 23 | // use a fixture file's content. 24 | 25 | cy.server() 26 | cy.fixture('example.json').as('comment') 27 | // when application makes an Ajax request matching "GET comments/*" 28 | // Cypress will intercept it and reply with object 29 | // from the "comment" alias 30 | cy.route('GET', 'comments/*', '@comment').as('getComment') 31 | 32 | // we have code that gets a comment when 33 | // the button is clicked in scripts.js 34 | cy.get('.fixture-btn').click() 35 | 36 | cy.wait('@getComment').its('responseBody') 37 | .should('have.property', 'name') 38 | .and('include', 'Using fixtures to represent data') 39 | 40 | // you can also just write the fixture in the route 41 | cy.route('GET', 'comments/*', 'fixture:example.json').as('getComment') 42 | 43 | // we have code that gets a comment when 44 | // the button is clicked in scripts.js 45 | cy.get('.fixture-btn').click() 46 | 47 | cy.wait('@getComment').its('responseBody') 48 | .should('have.property', 'name') 49 | .and('include', 'Using fixtures to represent data') 50 | 51 | // or write fx to represent fixture 52 | // by default it assumes it's .json 53 | cy.route('GET', 'comments/*', 'fx:example').as('getComment') 54 | 55 | // we have code that gets a comment when 56 | // the button is clicked in scripts.js 57 | cy.get('.fixture-btn').click() 58 | 59 | cy.wait('@getComment').its('responseBody') 60 | .should('have.property', 'name') 61 | .and('include', 'Using fixtures to represent data') 62 | }) 63 | 64 | it('cy.fixture() or require - load a fixture', function () { 65 | // we are inside the "function () { ... }" 66 | // callback and can use test context object "this" 67 | // "this.example" was loaded in "beforeEach" function callback 68 | expect(this.example, 'fixture in the test context') 69 | .to.deep.equal(requiredExample) 70 | 71 | // or use "cy.wrap" and "should('deep.equal', ...)" assertion 72 | // @ts-ignore 73 | cy.wrap(this.example, 'fixture vs require') 74 | .should('deep.equal', requiredExample) 75 | }) 76 | 77 | it('cy.readFile() - read a files contents', () => { 78 | // https://on.cypress.io/readfile 79 | 80 | // You can read a file and yield its contents 81 | // The filePath is relative to your project's root. 82 | cy.readFile('cypress.json').then((json) => { 83 | expect(json).to.be.an('object') 84 | }) 85 | }) 86 | 87 | it('cy.writeFile() - write to a file', () => { 88 | // https://on.cypress.io/writefile 89 | 90 | // You can write to a file 91 | 92 | // Use a response from a request to automatically 93 | // generate a fixture file for use later 94 | cy.request('https://jsonplaceholder.cypress.io/users') 95 | .then((response) => { 96 | cy.writeFile('cypress/fixtures/users.json', response.body) 97 | }) 98 | cy.fixture('users').should((users) => { 99 | expect(users[0].name).to.exist 100 | }) 101 | 102 | // JavaScript arrays and objects are stringified 103 | // and formatted into text. 104 | cy.writeFile('cypress/fixtures/profile.json', { 105 | id: 8739, 106 | name: 'Jane', 107 | email: 'jane@example.com', 108 | }) 109 | 110 | cy.fixture('profile').should((profile) => { 111 | expect(profile.name).to.eq('Jane') 112 | }) 113 | }) 114 | }) 115 | -------------------------------------------------------------------------------- /cypress/e2e/examples/local_storage.spec.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | context('Local Storage', () => { 4 | beforeEach(() => { 5 | cy.visit('https://example.cypress.io/commands/local-storage') 6 | }) 7 | // Although local storage is automatically cleared 8 | // in between tests to maintain a clean state 9 | // sometimes we need to clear the local storage manually 10 | 11 | it('cy.clearLocalStorage() - clear all data in local storage', () => { 12 | // https://on.cypress.io/clearlocalstorage 13 | cy.get('.ls-btn').click().should(() => { 14 | expect(localStorage.getItem('prop1')).to.eq('red') 15 | expect(localStorage.getItem('prop2')).to.eq('blue') 16 | expect(localStorage.getItem('prop3')).to.eq('magenta') 17 | }) 18 | 19 | // clearLocalStorage() yields the localStorage object 20 | cy.clearLocalStorage().should((ls) => { 21 | expect(ls.getItem('prop1')).to.be.null 22 | expect(ls.getItem('prop2')).to.be.null 23 | expect(ls.getItem('prop3')).to.be.null 24 | }) 25 | 26 | // Clear key matching string in Local Storage 27 | cy.get('.ls-btn').click().should(() => { 28 | expect(localStorage.getItem('prop1')).to.eq('red') 29 | expect(localStorage.getItem('prop2')).to.eq('blue') 30 | expect(localStorage.getItem('prop3')).to.eq('magenta') 31 | }) 32 | 33 | cy.clearLocalStorage('prop1').should((ls) => { 34 | expect(ls.getItem('prop1')).to.be.null 35 | expect(ls.getItem('prop2')).to.eq('blue') 36 | expect(ls.getItem('prop3')).to.eq('magenta') 37 | }) 38 | 39 | // Clear keys matching regex in Local Storage 40 | cy.get('.ls-btn').click().should(() => { 41 | expect(localStorage.getItem('prop1')).to.eq('red') 42 | expect(localStorage.getItem('prop2')).to.eq('blue') 43 | expect(localStorage.getItem('prop3')).to.eq('magenta') 44 | }) 45 | 46 | cy.clearLocalStorage(/prop1|2/).should((ls) => { 47 | expect(ls.getItem('prop1')).to.be.null 48 | expect(ls.getItem('prop2')).to.be.null 49 | expect(ls.getItem('prop3')).to.eq('magenta') 50 | }) 51 | }) 52 | }) 53 | -------------------------------------------------------------------------------- /cypress/e2e/examples/location.spec.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | context('Location', () => { 4 | beforeEach(() => { 5 | cy.visit('https://example.cypress.io/commands/location') 6 | }) 7 | 8 | it('cy.hash() - get the current URL hash', () => { 9 | // https://on.cypress.io/hash 10 | cy.hash().should('be.empty') 11 | }) 12 | 13 | it('cy.location() - get window.location', () => { 14 | // https://on.cypress.io/location 15 | cy.location().should((location) => { 16 | expect(location.hash).to.be.empty 17 | expect(location.href).to.eq('https://example.cypress.io/commands/location') 18 | expect(location.host).to.eq('example.cypress.io') 19 | expect(location.hostname).to.eq('example.cypress.io') 20 | expect(location.origin).to.eq('https://example.cypress.io') 21 | expect(location.pathname).to.eq('/commands/location') 22 | expect(location.port).to.eq('') 23 | expect(location.protocol).to.eq('https:') 24 | expect(location.search).to.be.empty 25 | }) 26 | }) 27 | 28 | it('cy.url() - get the current URL', () => { 29 | // https://on.cypress.io/url 30 | cy.url().should('eq', 'https://example.cypress.io/commands/location') 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /cypress/e2e/examples/misc.spec.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | context('Misc', () => { 4 | beforeEach(() => { 5 | cy.visit('https://example.cypress.io/commands/misc') 6 | }) 7 | 8 | it('.end() - end the command chain', () => { 9 | // https://on.cypress.io/end 10 | 11 | // cy.end is useful when you want to end a chain of commands 12 | // and force Cypress to re-query from the root element 13 | cy.get('.misc-table').within(() => { 14 | // ends the current chain and yields null 15 | cy.contains('Cheryl').click().end() 16 | 17 | // queries the entire table again 18 | cy.contains('Charles').click() 19 | }) 20 | }) 21 | 22 | it('cy.exec() - execute a system command', () => { 23 | // https://on.cypress.io/exec 24 | 25 | // execute a system command. 26 | // so you can take actions necessary for 27 | // your test outside the scope of Cypress. 28 | cy.exec('echo Jane Lane') 29 | .its('stdout').should('contain', 'Jane Lane') 30 | 31 | // we can use Cypress.platform string to 32 | // select appropriate command 33 | // https://on.cypress/io/platform 34 | cy.log(`Platform ${Cypress.platform} architecture ${Cypress.arch}`) 35 | 36 | if (Cypress.platform === 'win32') { 37 | cy.exec('print cypress.json') 38 | .its('stderr').should('be.empty') 39 | } else { 40 | cy.exec('cat cypress.json') 41 | .its('stderr').should('be.empty') 42 | 43 | cy.exec('pwd') 44 | .its('code').should('eq', 0) 45 | } 46 | }) 47 | 48 | it('cy.focused() - get the DOM element that has focus', () => { 49 | // https://on.cypress.io/focused 50 | cy.get('.misc-form').find('#name').click() 51 | cy.focused().should('have.id', 'name') 52 | 53 | cy.get('.misc-form').find('#description').click() 54 | cy.focused().should('have.id', 'description') 55 | }) 56 | 57 | context('Cypress.Screenshot', function () { 58 | it('cy.screenshot() - take a screenshot', () => { 59 | // https://on.cypress.io/screenshot 60 | cy.screenshot('my-image') 61 | }) 62 | 63 | it('Cypress.Screenshot.defaults() - change default config of screenshots', function () { 64 | Cypress.Screenshot.defaults({ 65 | blackout: ['.foo'], 66 | capture: 'viewport', 67 | clip: { x: 0, y: 0, width: 200, height: 200 }, 68 | scale: false, 69 | disableTimersAndAnimations: true, 70 | screenshotOnRunFailure: true, 71 | beforeScreenshot () { }, 72 | afterScreenshot () { }, 73 | }) 74 | }) 75 | }) 76 | 77 | it('cy.wrap() - wrap an object', () => { 78 | // https://on.cypress.io/wrap 79 | cy.wrap({ foo: 'bar' }) 80 | .should('have.property', 'foo') 81 | .and('include', 'bar') 82 | }) 83 | }) 84 | -------------------------------------------------------------------------------- /cypress/e2e/examples/navigation.spec.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | context('Navigation', () => { 4 | beforeEach(() => { 5 | cy.visit('https://example.cypress.io') 6 | cy.get('.navbar-nav').contains('Commands').click() 7 | cy.get('.dropdown-menu').contains('Navigation').click() 8 | }) 9 | 10 | it('cy.go() - go back or forward in the browser\'s history', () => { 11 | // https://on.cypress.io/go 12 | 13 | cy.location('pathname').should('include', 'navigation') 14 | 15 | cy.go('back') 16 | cy.location('pathname').should('not.include', 'navigation') 17 | 18 | cy.go('forward') 19 | cy.location('pathname').should('include', 'navigation') 20 | 21 | // clicking back 22 | cy.go(-1) 23 | cy.location('pathname').should('not.include', 'navigation') 24 | 25 | // clicking forward 26 | cy.go(1) 27 | cy.location('pathname').should('include', 'navigation') 28 | }) 29 | 30 | it('cy.reload() - reload the page', () => { 31 | // https://on.cypress.io/reload 32 | cy.reload() 33 | 34 | // reload the page without using the cache 35 | cy.reload(true) 36 | }) 37 | 38 | it('cy.visit() - visit a remote url', () => { 39 | // https://on.cypress.io/visit 40 | 41 | // Visit any sub-domain of your current domain 42 | 43 | // Pass options to the visit 44 | cy.visit('https://example.cypress.io/commands/navigation', { 45 | timeout: 50000, // increase total time for the visit to resolve 46 | onBeforeLoad (contentWindow) { 47 | // contentWindow is the remote page's window object 48 | expect(typeof contentWindow === 'object').to.be.true 49 | }, 50 | onLoad (contentWindow) { 51 | // contentWindow is the remote page's window object 52 | expect(typeof contentWindow === 'object').to.be.true 53 | }, 54 | }) 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /cypress/e2e/examples/network_requests.spec.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | context('Network Requests', () => { 4 | beforeEach(() => { 5 | cy.visit('https://example.cypress.io/commands/network-requests') 6 | }) 7 | 8 | // Manage AJAX / XHR requests in your app 9 | 10 | it('cy.server() - control behavior of network requests and responses', () => { 11 | // https://on.cypress.io/server 12 | 13 | cy.server().should((server) => { 14 | // the default options on server 15 | // you can override any of these options 16 | expect(server.delay).to.eq(0) 17 | expect(server.method).to.eq('GET') 18 | expect(server.status).to.eq(200) 19 | expect(server.headers).to.be.null 20 | expect(server.response).to.be.null 21 | expect(server.onRequest).to.be.undefined 22 | expect(server.onResponse).to.be.undefined 23 | expect(server.onAbort).to.be.undefined 24 | 25 | // These options control the server behavior 26 | // affecting all requests 27 | 28 | // pass false to disable existing route stubs 29 | expect(server.enable).to.be.true 30 | // forces requests that don't match your routes to 404 31 | expect(server.force404).to.be.false 32 | // whitelists requests from ever being logged or stubbed 33 | expect(server.whitelist).to.be.a('function') 34 | }) 35 | 36 | cy.server({ 37 | method: 'POST', 38 | delay: 1000, 39 | status: 422, 40 | response: {}, 41 | }) 42 | 43 | // any route commands will now inherit the above options 44 | // from the server. anything we pass specifically 45 | // to route will override the defaults though. 46 | }) 47 | 48 | it('cy.request() - make an XHR request', () => { 49 | // https://on.cypress.io/request 50 | cy.request('https://jsonplaceholder.cypress.io/comments') 51 | .should((response) => { 52 | expect(response.status).to.eq(200) 53 | expect(response.body).to.have.length(500) 54 | expect(response).to.have.property('headers') 55 | expect(response).to.have.property('duration') 56 | }) 57 | }) 58 | 59 | 60 | it('cy.request() - verify response using BDD syntax', () => { 61 | cy.request('https://jsonplaceholder.cypress.io/comments') 62 | .then((response) => { 63 | // https://on.cypress.io/assertions 64 | expect(response).property('status').to.equal(200) 65 | expect(response).property('body').to.have.length(500) 66 | expect(response).to.include.keys('headers', 'duration') 67 | }) 68 | }) 69 | 70 | it('cy.request() with query parameters', () => { 71 | // will execute request 72 | // https://jsonplaceholder.cypress.io/comments?postId=1&id=3 73 | cy.request({ 74 | url: 'https://jsonplaceholder.cypress.io/comments', 75 | qs: { 76 | postId: 1, 77 | id: 3, 78 | }, 79 | }) 80 | .its('body') 81 | .should('be.an', 'array') 82 | .and('have.length', 1) 83 | .its('0') // yields first element of the array 84 | .should('contain', { 85 | postId: 1, 86 | id: 3, 87 | }) 88 | }) 89 | 90 | it('cy.request() - pass result to the second request', () => { 91 | // first, let's find out the userId of the first user we have 92 | cy.request('https://jsonplaceholder.cypress.io/users?_limit=1') 93 | .its('body.0') // yields the first element of the returned list 94 | .then((user) => { 95 | expect(user).property('id').to.be.a('number') 96 | // make a new post on behalf of the user 97 | cy.request('POST', 'https://jsonplaceholder.cypress.io/posts', { 98 | userId: user.id, 99 | title: 'Cypress Test Runner', 100 | body: 'Fast, easy and reliable testing for anything that runs in a browser.', 101 | }) 102 | }) 103 | // note that the value here is the returned value of the 2nd request 104 | // which is the new post object 105 | .then((response) => { 106 | expect(response).property('status').to.equal(201) // new entity created 107 | expect(response).property('body').to.contain({ 108 | id: 101, // there are already 100 posts, so new entity gets id 101 109 | title: 'Cypress Test Runner', 110 | }) 111 | // we don't know the user id here - since it was in above closure 112 | // so in this test just confirm that the property is there 113 | expect(response.body).property('userId').to.be.a('number') 114 | }) 115 | }) 116 | 117 | it('cy.request() - save response in the shared test context', () => { 118 | // https://on.cypress.io/variables-and-aliases 119 | cy.request('https://jsonplaceholder.cypress.io/users?_limit=1') 120 | .its('body.0') // yields the first element of the returned list 121 | .as('user') // saves the object in the test context 122 | .then(function () { 123 | // NOTE 👀 124 | // By the time this callback runs the "as('user')" command 125 | // has saved the user object in the test context. 126 | // To access the test context we need to use 127 | // the "function () { ... }" callback form, 128 | // otherwise "this" points at a wrong or undefined object! 129 | cy.request('POST', 'https://jsonplaceholder.cypress.io/posts', { 130 | userId: this.user.id, 131 | title: 'Cypress Test Runner', 132 | body: 'Fast, easy and reliable testing for anything that runs in a browser.', 133 | }) 134 | .its('body').as('post') // save the new post from the response 135 | }) 136 | .then(function () { 137 | // When this callback runs, both "cy.request" API commands have finished 138 | // and the test context has "user" and "post" objects set. 139 | // Let's verify them. 140 | expect(this.post, 'post has the right user id').property('userId').to.equal(this.user.id) 141 | }) 142 | }) 143 | 144 | it('cy.route() - route responses to matching requests', () => { 145 | // https://on.cypress.io/route 146 | 147 | let message = 'whoa, this comment does not exist' 148 | 149 | cy.server() 150 | 151 | // Listen to GET to comments/1 152 | cy.route('GET', 'comments/*').as('getComment') 153 | 154 | // we have code that gets a comment when 155 | // the button is clicked in scripts.js 156 | cy.get('.network-btn').click() 157 | 158 | // https://on.cypress.io/wait 159 | cy.wait('@getComment').its('status').should('eq', 200) 160 | 161 | // Listen to POST to comments 162 | cy.route('POST', '/comments').as('postComment') 163 | 164 | // we have code that posts a comment when 165 | // the button is clicked in scripts.js 166 | cy.get('.network-post').click() 167 | cy.wait('@postComment') 168 | 169 | // get the route 170 | cy.get('@postComment').should((xhr) => { 171 | expect(xhr.requestBody).to.include('email') 172 | expect(xhr.requestHeaders).to.have.property('Content-Type') 173 | expect(xhr.responseBody).to.have.property('name', 'Using POST in cy.route()') 174 | }) 175 | 176 | // Stub a response to PUT comments/ **** 177 | cy.route({ 178 | method: 'PUT', 179 | url: 'comments/*', 180 | status: 404, 181 | response: { error: message }, 182 | delay: 500, 183 | }).as('putComment') 184 | 185 | // we have code that puts a comment when 186 | // the button is clicked in scripts.js 187 | cy.get('.network-put').click() 188 | 189 | cy.wait('@putComment') 190 | 191 | // our 404 statusCode logic in scripts.js executed 192 | cy.get('.network-put-comment').should('contain', message) 193 | }) 194 | }) 195 | -------------------------------------------------------------------------------- /cypress/e2e/examples/querying.spec.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | context('Querying', () => { 4 | beforeEach(() => { 5 | cy.visit('https://example.cypress.io/commands/querying') 6 | }) 7 | 8 | // The most commonly used query is 'cy.get()', you can 9 | // think of this like the '$' in jQuery 10 | 11 | it('cy.get() - query DOM elements', () => { 12 | // https://on.cypress.io/get 13 | 14 | cy.get('#query-btn').should('contain', 'Button') 15 | 16 | cy.get('.query-btn').should('contain', 'Button') 17 | 18 | cy.get('#querying .well>button:first').should('contain', 'Button') 19 | // ↲ 20 | // Use CSS selectors just like jQuery 21 | 22 | cy.get('[data-test-id="test-example"]').should('have.class', 'example') 23 | 24 | // 'cy.get()' yields jQuery object, you can get its attribute 25 | // by invoking `.attr()` method 26 | cy.get('[data-test-id="test-example"]') 27 | .invoke('attr', 'data-test-id') 28 | .should('equal', 'test-example') 29 | 30 | // or you can get element's CSS property 31 | cy.get('[data-test-id="test-example"]') 32 | .invoke('css', 'position') 33 | .should('equal', 'static') 34 | 35 | // or use assertions directly during 'cy.get()' 36 | // https://on.cypress.io/assertions 37 | cy.get('[data-test-id="test-example"]') 38 | .should('have.attr', 'data-test-id', 'test-example') 39 | .and('have.css', 'position', 'static') 40 | }) 41 | 42 | it('cy.contains() - query DOM elements with matching content', () => { 43 | // https://on.cypress.io/contains 44 | cy.get('.query-list') 45 | .contains('bananas') 46 | .should('have.class', 'third') 47 | 48 | // we can pass a regexp to `.contains()` 49 | cy.get('.query-list') 50 | .contains(/^b\w+/) 51 | .should('have.class', 'third') 52 | 53 | cy.get('.query-list') 54 | .contains('apples') 55 | .should('have.class', 'first') 56 | 57 | // passing a selector to contains will 58 | // yield the selector containing the text 59 | cy.get('#querying') 60 | .contains('ul', 'oranges') 61 | .should('have.class', 'query-list') 62 | 63 | cy.get('.query-button') 64 | .contains('Save Form') 65 | .should('have.class', 'btn') 66 | }) 67 | 68 | it('.within() - query DOM elements within a specific element', () => { 69 | // https://on.cypress.io/within 70 | cy.get('.query-form').within(() => { 71 | cy.get('input:first').should('have.attr', 'placeholder', 'Email') 72 | cy.get('input:last').should('have.attr', 'placeholder', 'Password') 73 | }) 74 | }) 75 | 76 | it('cy.root() - query the root DOM element', () => { 77 | // https://on.cypress.io/root 78 | 79 | // By default, root is the document 80 | cy.root().should('match', 'html') 81 | 82 | cy.get('.query-ul').within(() => { 83 | // In this within, the root is now the ul DOM element 84 | cy.root().should('have.class', 'query-ul') 85 | }) 86 | }) 87 | }) 88 | -------------------------------------------------------------------------------- /cypress/e2e/examples/spies_stubs_clocks.spec.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | context('Spies, Stubs, and Clock', () => { 4 | it('cy.spy() - wrap a method in a spy', () => { 5 | // https://on.cypress.io/spy 6 | cy.visit('https://example.cypress.io/commands/spies-stubs-clocks') 7 | 8 | const obj = { 9 | foo () {}, 10 | } 11 | 12 | const spy = cy.spy(obj, 'foo').as('anyArgs') 13 | 14 | obj.foo() 15 | 16 | expect(spy).to.be.called 17 | }) 18 | 19 | it('cy.spy() retries until assertions pass', () => { 20 | cy.visit('https://example.cypress.io/commands/spies-stubs-clocks') 21 | 22 | const obj = { 23 | /** 24 | * Prints the argument passed 25 | * @param x {any} 26 | */ 27 | foo (x) { 28 | console.log('obj.foo called with', x) 29 | }, 30 | } 31 | 32 | cy.spy(obj, 'foo').as('foo') 33 | 34 | setTimeout(() => { 35 | obj.foo('first') 36 | }, 500) 37 | 38 | setTimeout(() => { 39 | obj.foo('second') 40 | }, 2500) 41 | 42 | cy.get('@foo').should('have.been.calledTwice') 43 | }) 44 | 45 | it('cy.stub() - create a stub and/or replace a function with stub', () => { 46 | // https://on.cypress.io/stub 47 | cy.visit('https://example.cypress.io/commands/spies-stubs-clocks') 48 | 49 | const obj = { 50 | /** 51 | * prints both arguments to the console 52 | * @param a {string} 53 | * @param b {string} 54 | */ 55 | foo (a, b) { 56 | console.log('a', a, 'b', b) 57 | }, 58 | } 59 | 60 | const stub = cy.stub(obj, 'foo').as('foo') 61 | 62 | obj.foo('foo', 'bar') 63 | 64 | expect(stub).to.be.called 65 | }) 66 | 67 | it('cy.clock() - control time in the browser', () => { 68 | // https://on.cypress.io/clock 69 | 70 | // create the date in UTC so its always the same 71 | // no matter what local timezone the browser is running in 72 | const now = new Date(Date.UTC(2017, 2, 14)).getTime() 73 | 74 | cy.clock(now) 75 | cy.visit('https://example.cypress.io/commands/spies-stubs-clocks') 76 | cy.get('#clock-div').click() 77 | .should('have.text', '1489449600') 78 | }) 79 | 80 | it('cy.tick() - move time in the browser', () => { 81 | // https://on.cypress.io/tick 82 | 83 | // create the date in UTC so its always the same 84 | // no matter what local timezone the browser is running in 85 | const now = new Date(Date.UTC(2017, 2, 14)).getTime() 86 | 87 | cy.clock(now) 88 | cy.visit('https://example.cypress.io/commands/spies-stubs-clocks') 89 | cy.get('#tick-div').click() 90 | .should('have.text', '1489449600') 91 | cy.tick(10000) // 10 seconds passed 92 | cy.get('#tick-div').click() 93 | .should('have.text', '1489449610') 94 | }) 95 | }) 96 | -------------------------------------------------------------------------------- /cypress/e2e/examples/traversal.spec.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | context('Traversal', () => { 4 | beforeEach(() => { 5 | cy.visit('https://example.cypress.io/commands/traversal') 6 | }) 7 | 8 | it('.children() - get child DOM elements', () => { 9 | // https://on.cypress.io/children 10 | cy.get('.traversal-breadcrumb') 11 | .children('.active') 12 | .should('contain', 'Data') 13 | }) 14 | 15 | it('.closest() - get closest ancestor DOM element', () => { 16 | // https://on.cypress.io/closest 17 | cy.get('.traversal-badge') 18 | .closest('ul') 19 | .should('have.class', 'list-group') 20 | }) 21 | 22 | it('.eq() - get a DOM element at a specific index', () => { 23 | // https://on.cypress.io/eq 24 | cy.get('.traversal-list>li') 25 | .eq(1).should('contain', 'siamese') 26 | }) 27 | 28 | it('.filter() - get DOM elements that match the selector', () => { 29 | // https://on.cypress.io/filter 30 | cy.get('.traversal-nav>li') 31 | .filter('.active').should('contain', 'About') 32 | }) 33 | 34 | it('.find() - get descendant DOM elements of the selector', () => { 35 | // https://on.cypress.io/find 36 | cy.get('.traversal-pagination') 37 | .find('li').find('a') 38 | .should('have.length', 7) 39 | }) 40 | 41 | it('.first() - get first DOM element', () => { 42 | // https://on.cypress.io/first 43 | cy.get('.traversal-table td') 44 | .first().should('contain', '1') 45 | }) 46 | 47 | it('.last() - get last DOM element', () => { 48 | // https://on.cypress.io/last 49 | cy.get('.traversal-buttons .btn') 50 | .last().should('contain', 'Submit') 51 | }) 52 | 53 | it('.next() - get next sibling DOM element', () => { 54 | // https://on.cypress.io/next 55 | cy.get('.traversal-ul') 56 | .contains('apples').next().should('contain', 'oranges') 57 | }) 58 | 59 | it('.nextAll() - get all next sibling DOM elements', () => { 60 | // https://on.cypress.io/nextall 61 | cy.get('.traversal-next-all') 62 | .contains('oranges') 63 | .nextAll().should('have.length', 3) 64 | }) 65 | 66 | it('.nextUntil() - get next sibling DOM elements until next el', () => { 67 | // https://on.cypress.io/nextuntil 68 | cy.get('#veggies') 69 | .nextUntil('#nuts').should('have.length', 3) 70 | }) 71 | 72 | it('.not() - remove DOM elements from set of DOM elements', () => { 73 | // https://on.cypress.io/not 74 | cy.get('.traversal-disabled .btn') 75 | .not('[disabled]').should('not.contain', 'Disabled') 76 | }) 77 | 78 | it('.parent() - get parent DOM element from DOM elements', () => { 79 | // https://on.cypress.io/parent 80 | cy.get('.traversal-mark') 81 | .parent().should('contain', 'Morbi leo risus') 82 | }) 83 | 84 | it('.parents() - get parent DOM elements from DOM elements', () => { 85 | // https://on.cypress.io/parents 86 | cy.get('.traversal-cite') 87 | .parents().should('match', 'blockquote') 88 | }) 89 | 90 | it('.parentsUntil() - get parent DOM elements from DOM elements until el', () => { 91 | // https://on.cypress.io/parentsuntil 92 | cy.get('.clothes-nav') 93 | .find('.active') 94 | .parentsUntil('.clothes-nav') 95 | .should('have.length', 2) 96 | }) 97 | 98 | it('.prev() - get previous sibling DOM element', () => { 99 | // https://on.cypress.io/prev 100 | cy.get('.birds').find('.active') 101 | .prev().should('contain', 'Lorikeets') 102 | }) 103 | 104 | it('.prevAll() - get all previous sibling DOM elements', () => { 105 | // https://on.cypress.io/prevAll 106 | cy.get('.fruits-list').find('.third') 107 | .prevAll().should('have.length', 2) 108 | }) 109 | 110 | it('.prevUntil() - get all previous sibling DOM elements until el', () => { 111 | // https://on.cypress.io/prevUntil 112 | cy.get('.foods-list').find('#nuts') 113 | .prevUntil('#veggies').should('have.length', 3) 114 | }) 115 | 116 | it('.siblings() - get all sibling DOM elements', () => { 117 | // https://on.cypress.io/siblings 118 | cy.get('.traversal-pills .active') 119 | .siblings().should('have.length', 2) 120 | }) 121 | }) 122 | -------------------------------------------------------------------------------- /cypress/e2e/examples/utilities.spec.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | context('Utilities', () => { 4 | beforeEach(() => { 5 | cy.visit('https://example.cypress.io/utilities') 6 | }) 7 | 8 | it('Cypress._ - call a lodash method', () => { 9 | // https://on.cypress.io/_ 10 | cy.request('https://jsonplaceholder.cypress.io/users') 11 | .then((response) => { 12 | let ids = Cypress._.chain(response.body).map('id').take(3).value() 13 | 14 | expect(ids).to.deep.eq([1, 2, 3]) 15 | }) 16 | }) 17 | 18 | it('Cypress.$ - call a jQuery method', () => { 19 | // https://on.cypress.io/$ 20 | let $li = Cypress.$('.utility-jquery li:first') 21 | 22 | cy.wrap($li) 23 | .should('not.have.class', 'active') 24 | .click() 25 | .should('have.class', 'active') 26 | }) 27 | 28 | it('Cypress.Blob - blob utilities and base64 string conversion', () => { 29 | // https://on.cypress.io/blob 30 | cy.get('.utility-blob').then(($div) => 31 | // https://github.com/nolanlawson/blob-util#imgSrcToDataURL 32 | // get the dataUrl string for the javascript-logo 33 | Cypress.Blob.imgSrcToDataURL('https://example.cypress.io/assets/img/javascript-logo.png', undefined, 'anonymous') 34 | .then((dataUrl) => { 35 | // create an element and set its src to the dataUrl 36 | let img = Cypress.$('', { src: dataUrl }) 37 | 38 | // need to explicitly return cy here since we are initially returning 39 | // the Cypress.Blob.imgSrcToDataURL promise to our test 40 | // append the image 41 | $div.append(img) 42 | 43 | cy.get('.utility-blob img').click() 44 | .should('have.attr', 'src', dataUrl) 45 | })) 46 | }) 47 | 48 | it('Cypress.minimatch - test out glob patterns against strings', () => { 49 | // https://on.cypress.io/minimatch 50 | let matching = Cypress.minimatch('/users/1/comments', '/users/*/comments', { 51 | matchBase: true, 52 | }) 53 | 54 | expect(matching, 'matching wildcard').to.be.true 55 | 56 | matching = Cypress.minimatch('/users/1/comments/2', '/users/*/comments', { 57 | matchBase: true, 58 | }) 59 | expect(matching, 'comments').to.be.false 60 | 61 | // ** matches against all downstream path segments 62 | matching = Cypress.minimatch('/foo/bar/baz/123/quux?a=b&c=2', '/foo/**', { 63 | matchBase: true, 64 | }) 65 | expect(matching, 'comments').to.be.true 66 | 67 | // whereas * matches only the next path segment 68 | 69 | matching = Cypress.minimatch('/foo/bar/baz/123/quux?a=b&c=2', '/foo/*', { 70 | matchBase: false, 71 | }) 72 | expect(matching, 'comments').to.be.false 73 | }) 74 | 75 | 76 | it('Cypress.moment() - format or parse dates using a moment method', () => { 77 | // https://on.cypress.io/moment 78 | const time = Cypress.moment().utc('2014-04-25T19:38:53.196Z').format('h:mm A') 79 | 80 | expect(time).to.be.a('string') 81 | 82 | cy.get('.utility-moment').contains('3:38 PM') 83 | .should('have.class', 'badge') 84 | 85 | // the time in the element should be between 3pm and 5pm 86 | const start = Cypress.moment('3:00 PM', 'LT') 87 | const end = Cypress.moment('5:00 PM', 'LT') 88 | 89 | cy.get('.utility-moment .badge') 90 | .should(($el) => { 91 | // parse American time like "3:38 PM" 92 | const m = Cypress.moment($el.text().trim(), 'LT') 93 | 94 | // display hours + minutes + AM|PM 95 | const f = 'h:mm A' 96 | 97 | expect(m.isBetween(start, end), 98 | `${m.format(f)} should be between ${start.format(f)} and ${end.format(f)}`).to.be.true 99 | }) 100 | }) 101 | 102 | 103 | it('Cypress.Promise - instantiate a bluebird promise', () => { 104 | // https://on.cypress.io/promise 105 | let waited = false 106 | 107 | /** 108 | * @return Bluebird 109 | */ 110 | function waitOneSecond () { 111 | // return a promise that resolves after 1 second 112 | // @ts-ignore TS2351 (new Cypress.Promise) 113 | return new Cypress.Promise((resolve, reject) => { 114 | setTimeout(() => { 115 | // set waited to true 116 | waited = true 117 | 118 | // resolve with 'foo' string 119 | resolve('foo') 120 | }, 1000) 121 | }) 122 | } 123 | 124 | cy.then(() => 125 | // return a promise to cy.then() that 126 | // is awaited until it resolves 127 | // @ts-ignore TS7006 128 | waitOneSecond().then((str) => { 129 | expect(str).to.eq('foo') 130 | expect(waited).to.be.true 131 | })) 132 | }) 133 | }) 134 | -------------------------------------------------------------------------------- /cypress/e2e/examples/viewport.spec.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | context('Viewport', () => { 4 | beforeEach(() => { 5 | cy.visit('https://example.cypress.io/commands/viewport') 6 | }) 7 | 8 | it('cy.viewport() - set the viewport size and dimension', () => { 9 | // https://on.cypress.io/viewport 10 | 11 | cy.get('#navbar').should('be.visible') 12 | cy.viewport(320, 480) 13 | 14 | // the navbar should have collapse since our screen is smaller 15 | cy.get('#navbar').should('not.be.visible') 16 | cy.get('.navbar-toggle').should('be.visible').click() 17 | cy.get('.nav').find('a').should('be.visible') 18 | 19 | // lets see what our app looks like on a super large screen 20 | cy.viewport(2999, 2999) 21 | 22 | // cy.viewport() accepts a set of preset sizes 23 | // to easily set the screen to a device's width and height 24 | 25 | // We added a cy.wait() between each viewport change so you can see 26 | // the change otherwise it is a little too fast to see :) 27 | 28 | cy.viewport('macbook-15') 29 | cy.wait(200) 30 | cy.viewport('macbook-13') 31 | cy.wait(200) 32 | cy.viewport('macbook-11') 33 | cy.wait(200) 34 | cy.viewport('ipad-2') 35 | cy.wait(200) 36 | cy.viewport('ipad-mini') 37 | cy.wait(200) 38 | cy.viewport('iphone-6+') 39 | cy.wait(200) 40 | cy.viewport('iphone-6') 41 | cy.wait(200) 42 | cy.viewport('iphone-5') 43 | cy.wait(200) 44 | cy.viewport('iphone-4') 45 | cy.wait(200) 46 | cy.viewport('iphone-3') 47 | cy.wait(200) 48 | 49 | // cy.viewport() accepts an orientation for all presets 50 | // the default orientation is 'portrait' 51 | cy.viewport('ipad-2', 'portrait') 52 | cy.wait(200) 53 | cy.viewport('iphone-4', 'landscape') 54 | cy.wait(200) 55 | 56 | // The viewport will be reset back to the default dimensions 57 | // in between tests (the default can be set in cypress.json) 58 | }) 59 | }) 60 | -------------------------------------------------------------------------------- /cypress/e2e/examples/waiting.spec.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | context('Waiting', () => { 4 | beforeEach(() => { 5 | cy.visit('https://example.cypress.io/commands/waiting') 6 | }) 7 | // BE CAREFUL of adding unnecessary wait times. 8 | // https://on.cypress.io/best-practices#Unnecessary-Waiting 9 | 10 | // https://on.cypress.io/wait 11 | it('cy.wait() - wait for a specific amount of time', () => { 12 | cy.get('.wait-input1').type('Wait 1000ms after typing') 13 | cy.wait(1000) 14 | cy.get('.wait-input2').type('Wait 1000ms after typing') 15 | cy.wait(1000) 16 | cy.get('.wait-input3').type('Wait 1000ms after typing') 17 | cy.wait(1000) 18 | }) 19 | 20 | it('cy.wait() - wait for a specific route', () => { 21 | cy.server() 22 | 23 | // Listen to GET to comments/1 24 | cy.route('GET', 'comments/*').as('getComment') 25 | 26 | // we have code that gets a comment when 27 | // the button is clicked in scripts.js 28 | cy.get('.network-btn').click() 29 | 30 | // wait for GET comments/1 31 | cy.wait('@getComment').its('status').should('eq', 200) 32 | }) 33 | 34 | }) 35 | -------------------------------------------------------------------------------- /cypress/e2e/examples/window.spec.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | context('Window', () => { 4 | beforeEach(() => { 5 | cy.visit('https://example.cypress.io/commands/window') 6 | }) 7 | 8 | it('cy.window() - get the global window object', () => { 9 | // https://on.cypress.io/window 10 | cy.window().should('have.property', 'top') 11 | }) 12 | 13 | it('cy.document() - get the document object', () => { 14 | // https://on.cypress.io/document 15 | cy.document().should('have.property', 'charset').and('eq', 'UTF-8') 16 | }) 17 | 18 | it('cy.title() - get the title', () => { 19 | // https://on.cypress.io/title 20 | cy.title().should('include', 'Kitchen Sink') 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } -------------------------------------------------------------------------------- /cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example plugins/index.js can be used to load plugins 3 | // 4 | // You can change the location of this file or turn off loading 5 | // the plugins file with the 'pluginsFile' configuration option. 6 | // 7 | // You can read more here: 8 | // https://on.cypress.io/plugins-guide 9 | // *********************************************************** 10 | 11 | // This function is called when a project is opened or re-opened (e.g. due to 12 | // the project's config changing) 13 | 14 | module.exports = (on, config) => { 15 | // `on` is used to hook into various events Cypress emits 16 | // `config` is the resolved Cypress config 17 | } 18 | -------------------------------------------------------------------------------- /cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add("login", (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This is will overwrite an existing command -- 25 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 26 | -------------------------------------------------------------------------------- /cypress/support/index.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 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /docs/config.schema.md: -------------------------------------------------------------------------------- 1 | # Config Schema 2 | 3 | TODO `_id` and `location` are special required fields 4 | 5 | ## Config structure 6 | 7 | # theme: #TODO 8 | # hooks: #TODO 9 | # savedSerches: #TODO 10 | dataDefinition: 11 | - displayName: Core info 12 | color: light-blue darken-2 #pick colors from https://v15.vuetifyjs.com/en/framework/colors 13 | fields: 14 | - name: _id 15 | displayName: ID #Trim option? So only a substring it shown? IE replace substr logic in list view 16 | type: string 17 | disabled: true 18 | showInTree: true 19 | size: 12 20 | - name: name 21 | displayName: Name 22 | type: string 23 | showInTree: true 24 | - name: location 25 | displayName: Location 26 | type: path 27 | size: 12 28 | #showInTree: true #Always shown 29 | 30 | ## Data Definition 31 | 32 | Data definition is a list of sections. These section group field in the new entry screen. 33 | 34 | ##### Example: 35 | 36 | - displayName: Core info 37 | color: light-blue darken-2 38 | size: 12 39 | fields: [] 40 | 41 | ##### Attributes: 42 | 43 | | Attribute name | Required | Default | Description | 44 | | -------------- | -------- | ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | 45 | | `displayName` | No | | Used to label the section on the new entry screen. | 46 | | `color` | No | | [Vuetify colors](https://v15.vuetifyjs.com/en/framework/colors) to set section background in new entry screen. | 47 | | `size` | No | `12` on small screens. `6` on large screens. `4` on extra large screens. | How wide to make section in new entry screen. Can be from 1-12. Why 12? It's divisible into halves and thirds. | 48 | | `fields` | No | | List of fields to to store. See below for field definitions | 49 | 50 | * * * 51 | 52 | ### Common fields Attributes 53 | 54 | ##### Example: 55 | 56 | name: _id 57 | displayName: ID 58 | type: string 59 | disabled: true 60 | showInTree: true 61 | size: 12 62 | color: red 63 | 64 | ##### Attributes: 65 | 66 | | Attribute name | Required | Default | Description | 67 | | -------------- | -------- | ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | 68 | | `name` | Yes | | Logical name for a field. Should be machine readable name. Case sensitive, no spaces or special characters. | 69 | | `displayName` | | | Human readable name for a field. Can contain spaces and special characters. | 70 | | `type` | Yes | | Type of the field. See options below. | 71 | | `showInTree` | No | `false` | Used in the tree view to show a field in results. | 72 | | `size` | No | `12` on small screens. `6` on large screens. `4` on extra large screens. | How wide to make section in new entry screen. Can be from 1-12. | 73 | | `color` | No | Various | [Vuetify colors](https://v15.vuetifyjs.com/en/framework/colors) to set section background in new entry screen. | 74 | 75 | * * * 76 | 77 | ### String field type 78 | 79 | A generic field type. 80 | 81 | ##### Example: 82 | 83 | - name: name 84 | displayName: Name 85 | type: string 86 | disabled: true 87 | showInTree: true 88 | size: 12 89 | color: red 90 | 91 | ##### Attributes: 92 | 93 | In addition to the common field attributes. 94 | 95 | | Attribute name | Required | Default | Description | 96 | | -------------- | -------- | ------- | -------------------------------------------------------------------- | 97 | | disabled | No | `false` | Determines if the field should be editable on the item entry screen. | 98 | 99 | * * * 100 | 101 | ### Path field type 102 | 103 | A special field type. This field expects a `/` delimited path(Like a file system) and is used to render the data in a tree view. 104 | 105 | ##### Example: 106 | 107 | - name: location 108 | displayName: Location 109 | prefixes: {} 110 | type: path 111 | size: 12 112 | 113 | ##### Prefix Example: 114 | 115 | - name: location 116 | displayName: Location 117 | prefixes: 118 | Box01: House/Room/ #Typing in `Box01` will act as if you entered in `House/Room/Box01` because of this configured prefix 119 | type: path 120 | size: 12 121 | 122 | ##### Attributes: 123 | 124 | In addition to the common field attributes. 125 | 126 | | Attribute name | Required | Default | Description | 127 | | -------------- | -------- | ------- | -------------------------------------------------------------------- | 128 | | `disabled` | No | `false` | Determines if the field should be editable on the item entry screen. | 129 | | `prefixes` | Yes | | Map of fixed prefixes | 130 | 131 | * * * 132 | 133 | ### Boolean field type 134 | 135 | A toggle field type with two states. `true` and `false`. Null is assumed to be `false` 136 | 137 | ##### Example: 138 | 139 | - name: idLabeled 140 | displayName: Item labeled with ID 141 | type: boolean 142 | color: red 143 | 144 | * * * 145 | 146 | ### List field type 147 | 148 | ##### Example: 149 | 150 | - name: tags 151 | displayName: Tags 152 | type: list 153 | size: 12 154 | 155 | ##### Attributes: 156 | 157 | | Attribute name | Required | Default | Description | | 158 | | -------------- | -------- | ------- | -------------------------------------------------------------------------------------- | --- | 159 | | `listType` | No | | If specified the list one of the special types below. Otherwise its just a string list | | 160 | 161 | #### Link list subtype 162 | 163 | ##### Example: 164 | 165 | - name: imageURLs 166 | displayName: Image URLs 167 | type: list 168 | listType: link 169 | hideInList: true 170 | 171 | #### UPC list code type 172 | 173 | ##### Example: 174 | 175 | - name: code 176 | displayName: Code 177 | type: list 178 | listType: upc 179 | size: 12 180 | hideInList: true 181 | 182 | * * * 183 | 184 | ### Last Date type 185 | 186 | Read only field that shows the last date from a list of fields. 187 | 188 | ##### Example: 189 | 190 | - name: lastTouchDate 191 | displayName: Last touch date 192 | type: lastDate 193 | dateFields: 194 | - name: status 195 | type: libraryStyleStatus 196 | - name: acquireDate 197 | 198 | ##### Attributes: 199 | 200 | In addition to the common field attributes. 201 | 202 | | Attribute name | Required | Default | Description | 203 | | -------------- | -------- | ------- | --------------------------------------------------------------- | 204 | | `dateFields` | True | | List of date fields. Display the latest date from those options | 205 | 206 | ##### Date Fields Attributes: 207 | 208 | | Attribute name | Required | Default | Description | 209 | | -------------- | -------- | ------- | -------------------------------------------------------------------------------------------------------------------- | 210 | | `name` | Yes | | Name of the field to include in latest date calculations | 211 | | `type` | Varies | | If value is a complex type like `libraryStyleStatus` it must be specified. Normal date fields this field is optional | 212 | 213 | * * * 214 | 215 | ### Last Modified type 216 | 217 | This field only updates when an entry is saved. 218 | 219 | ##### Example: 220 | 221 | - name: lastModified 222 | displayName: Last Modified 223 | type: lastModified 224 | 225 | * * * 226 | 227 | ### Library Style Status type 228 | 229 | A complex field type that lets you check in and check out. 230 | 231 | ##### Example: 232 | 233 | - name: status 234 | displayName: Status 235 | type: libraryStyleStatus #Checkin checkout 236 | size: 12 237 | showInTree: true 238 | -------------------------------------------------------------------------------- /docs/development.md: -------------------------------------------------------------------------------- 1 | # Development 2 | 3 | 4 | ## Project setup 5 | ``` 6 | npm install 7 | ``` 8 | 9 | ### Compiles and hot-reloads for development 10 | ``` 11 | npm run serve 12 | ``` 13 | 14 | ### Compiles and minifies for production 15 | ``` 16 | npm run build 17 | ``` 18 | 19 | ### Run your tests 20 | ``` 21 | npm run test 22 | ``` 23 | 24 | ### Lints and fixes files 25 | ``` 26 | npm run lint 27 | ``` 28 | 29 | ### Run your end-to-end tests 30 | ``` 31 | npm run test:e2e 32 | ``` 33 | 34 | ### Run end-to-end tests headless 35 | ``` 36 | npx vue-cli-service test:e2e --headless 37 | ``` 38 | 39 | ### Run your unit tests 40 | ``` 41 | npm run test:unit 42 | ``` 43 | 44 | ### Zip up the build folder 45 | ``` 46 | npm run dist 47 | ``` 48 | 49 | 50 | ### Customize configuration 51 | See [Configuration Reference](https://cli.vuejs.org/config/). 52 | 53 | ### Build the Docker container locally 54 | ``` 55 | docker build -t foxusa/storedown:$tag . 56 | # On M1 Macs 57 | docker build --platform linux/amd64 -t foxusa/storedown:latest -t foxusa/storedown:$version . 58 | ``` 59 | 60 | ### Push container 61 | > If you use the GitHub build all you need to do is merge to master and it will be done automatically 62 | ``` 63 | docker push --all-tags foxusa/storedown 64 | ``` -------------------------------------------------------------------------------- /docs/images/responsive.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JLiscom/StoreDown/cb27463307608f924736d675d1608f20d4ebc3d5/docs/images/responsive.png -------------------------------------------------------------------------------- /docs/install.md: -------------------------------------------------------------------------------- 1 | # How to Install StoreDown 2 | 3 | We are still in the early days. 4 | I hope to reduce the skills required to use StoreDown. 5 | 6 | > Remember you need to Deploy StoredDown and configure an Apache CouchDB instance. 7 | > StoreDown uses a CouchDB database to sync. 8 | > Instructions on how to setup CouchDB are at the bottom of this file. 9 | > TLDR: Pick an option __AND__ configure CouchDB 10 | 11 | ## Option 1: 12 | You can download a [zip of the most recent release](https://github.com/FoxUSA/StoreDown/releases) and extract it in your webserver of choice(S3, Apache, NGINX, etc ....) 13 | 14 | ## Option 2: Using `docker` 15 | `docker run -d -p 8080:80 foxusa/storedown` 16 | 17 | ## Option 3: Using `docker-compose` 18 | 19 | > If you want to use SSL/TLS skip this and go down to that section 20 | > 21 | Put the following text in a `docker-compose.yml`. 22 | Make sure to set all the items marked `#TODO`. 23 | Also make sure this file is in a secure place. 24 | Your credentials are stored in it. 25 | ``` 26 | version: "2" 27 | services: 28 | storedown: 29 | image: foxusa/storedown 30 | restart: always 31 | ports: 32 | - "80:80" 33 | couchdb: 34 | image: couchdb 35 | restart: always 36 | volumes: 37 | - :/opt/couchdb/data #TODO set this to prevent accidentally deleting your database data 38 | - :/opt/couchdb/etc/local.d # Prevents CORS setting from being removed 39 | ports: 40 | - "5984:5984" 41 | environment: 42 | COUCHDB_USER: user #TODO set this 43 | COUCHDB_PASSWORD: password #TODO set this 44 | ``` 45 | 46 | You can then run `docker-compose up -d` to start the services. 47 | 48 | ### SSL/TLS 49 | Create a folder with a SSL `private.key` and `public.crt` this gets mounted by nginx to encrypt connections. 50 | The `public.crt` file should have your servers cert and the whole cert chain appended to it. 51 | 52 | Create a nginx config file that proxies CouchDB traffic via SSL. 53 | [Here is an example you can use as is.](https://github.com/FoxUSA/OpenNote-Docker/blob/master/samples/nginx/default.conf) 54 | 55 | Mount these files using the following `docker-compose.yml` 56 | ``` 57 | version: "2" 58 | services: 59 | storedown: 60 | image: foxusa/storedown 61 | restart: always 62 | volumes: 63 | - $HOST_LOCATION/certs:/root/certs:ro #TODO set this 64 | - $HOST_LOCATION/storedown.default.conf:/etc/nginx/conf.d/default.conf:ro #TODO file created above 65 | ports: 66 | - 80:80 67 | - 443:443 68 | - 6984:6984 #CouchDB Proxy 69 | links: 70 | - couchdb:couchdb 71 | 72 | couchdb: 73 | restart: always 74 | image: couchdb 75 | volumes: 76 | - /opt/StoreDown/couchdb:/opt/couchdb/data 77 | ports: 78 | - 5984:5984 79 | environment: 80 | COUCHDB_USER: $user #TODO set this 81 | COUCHDB_PASSWORD: $password #TODO set this 82 | 83 | ``` 84 | 85 | You can then run `docker-compose up -d` to start the services. 86 | 87 | --- 88 | 89 | ### CouchDB config 90 | - [ ] Go to `http://$serverurl:5984/_utils/#_config/nonode@nohost/cors` and enable CORS for your domain. 91 | - [ ] Go to Go to `http://$serverurl:5984/_utils/#_config/nonode@nohost` and set in the `httpd` section, `WWW-Authenticate` to `Basic realm="administrator"` 92 | - [ ] Go to `http://$serverurl:5984/_utils/#_config/nonode@nohost` and set in the `chttpd` section `require_valid_user` to `true`. 93 | >If you are unable to get to the login screen after setting that, you can access it via `http://$serverurl:5984/_utils/#login` 94 | - [ ] Then open StoreDown and log in with the username, password, url, port, and database you configured. 95 | -------------------------------------------------------------------------------- /docs/system.md: -------------------------------------------------------------------------------- 1 | # My System 2 | 3 | ## Placement 4 | I optimize placement in [boxes](https://www.globalindustrial.com/product/itemKey/30016139) to use all the available space. 5 | 6 | > There seems to be some law of the universe that holiday decorations take one and a half boxes. 7 | StoreDown will let use the rest of that box. 8 | 9 | The second and maybe more important is to place stuff pseudo randomly. 10 | This creates a positive feedback loop where you have to use StoreDown to find stuff. This helps keep the data accurate. 11 | 12 | Putting items together logically makes it easier for you to pull items without using StoreDown. This causes a negative feedback loop where the data in StoreDown becomes less accurate and less useful hence causing you to use it less. 13 | 14 | Random placement also has an advantage that someone who is unfamiliar with an item is less likely to be confused. You are not very likely to be looking for a black USB cable in a box full of black cables if you placed them randomly. 15 | 16 | ### Intake 17 | I have an intake box that I keep out and check stuff into. When it gets full I put it in storage and get a new box to take its place. 18 | 19 | ### Placement Requirements 20 | Some items need to be stored in a climate controlled zone or away from other items. I use an @ tag to note these requirements. 21 | 22 | For example: `@Chemical` noted on a box notes thats where chemicals should be stored. 23 | That obviously is not a place you should store food. 24 | 25 | --- 26 | 27 | ## Retrieval 28 | StoreDown has two inbuilt retrieval systems you need to keep in mind. 29 | 30 | ### The Tree 31 | StoreDown can present inventory in a tree format. 32 | > Computer file systems are a good example of a tree. 33 | 34 | This is an excellent way to find items based on locations. 35 | 36 | ``` 37 | StoreDown 38 | ├── House 1 39 | │ ├── Room 40 | │ │ ├── Shelf 41 | │ │ │ └── Box 42 | │ │ ├── Shelf 43 | │ │ │ └── Box 44 | │ │ └── Shelf 45 | │ │ ├── Box 46 | │ │ └── Item 47 | │ └── Room 48 | │ └── Item 49 | ├── House 2 50 | │ └── Item 51 | └── Item 52 | ``` 53 | 54 | ### Search/List 55 | StoreDown has an inbuilt fuzzy search system. 56 | 57 | This acts a lot like Google. 58 | Search for what you want. 59 | Go and pick it up. 60 | 61 | --- 62 | 63 | ## My Prime Use Cases 64 | - Cords! 65 | Do we have a HDMI 2.1 cable? 66 | Do we have a USB cable that can reach around 4 ft? 67 | - Keeping receipts 68 | That lifetime warranty require a receipt? No problem! 69 | - Keeping track of manuals 70 | - Food storage 71 | 'xyz was on sale so I bought a bunch.' 72 | You can use an expiration field to use them in order. 73 | - Keep track of stuff you loaned out 74 | 'Am I going crazy or didn't we have a xyz?' 75 | - Keeping things off tables 76 | If you have something that has sat on your workbench for a year, you are storing it there. 77 | You probably are never going to get to it and it is keeping the work surface from being useful. 78 | StoreDown is a place where you can keep anything. 79 | 80 | --- 81 | 82 | ## Other stuff 83 | 84 | > I get nothing from these links. Maybe someday. But not today. 85 | 86 | - I use the [Scanner Keyboard](https://play.google.com/store/apps/details?id=com.tecit.android.barcodekbd.demo&hl=en_US&gl=US) on Android to scan barcodes. 87 | - On the desktop side, they sell [bluetooth scanners](https://smile.amazon.com/gp/product/B08GWSL4Y5) that act as keyboards and will try in a barcode. 88 | - I have experimented with [NFC readers](https://smile.amazon.com/gp/product/B019JM9R12) and [tags](https://smile.amazon.com/gp/product/B08F1Y8X3F). 89 | The problem is everything already has a barcode, its cheaper to [print an asset tag](https://smile.amazon.com/gp/product/B0146SDE0Y), or use a labeler. -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | moduleFileExtensions: [ 3 | 'js', 4 | 'jsx', 5 | 'json', 6 | 'vue' 7 | ], 8 | transform: { 9 | '^.+\\.vue$': 'vue-jest', 10 | '.+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$': 'jest-transform-stub', 11 | '^.+\\.jsx?$': 'babel-jest' 12 | }, 13 | transformIgnorePatterns: [ 14 | '/node_modules/' 15 | ], 16 | moduleNameMapper: { 17 | '^@/(.*)$': '/src/$1' 18 | }, 19 | snapshotSerializers: [ 20 | 'jest-serializer-vue' 21 | ], 22 | testMatch: [ 23 | '**/tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)' 24 | ], 25 | testURL: 'http://localhost/', 26 | watchPlugins: [ 27 | 'jest-watch-typeahead/filename', 28 | 'jest-watch-typeahead/testname' 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "storedown", 3 | "version": "22.04.04", 4 | "scripts": { 5 | "serve": "vue-cli-service serve", 6 | "build": "vue-cli-service build", 7 | "test:unit": "vue-cli-service test:unit", 8 | "test:e2e": "vue-cli-service test:e2e", 9 | "lint": "vue-cli-service lint", 10 | "serve:build": "vue-cli-service build && serve -s dist", 11 | "serve:prod": "vue-cli-service serve --mode production --https", 12 | "dist": "cd dist && zip -r v${npm_package_version}.zip ." 13 | }, 14 | "dependencies": { 15 | "file-saver": "^2.0.2", 16 | "fuse.js": "^3.4.5", 17 | "papaparse": "^5.3.0", 18 | "pouchdb-browser": "^7.1.1", 19 | "register-service-worker": "^1.6.2", 20 | "vue": "^2.6.10", 21 | "vue-router": "^3.1.2", 22 | "vue-toasted": "^1.1.27", 23 | "vuetify": "^1.5.5" 24 | }, 25 | "devDependencies": { 26 | "@vue/cli": "^5.0.4", 27 | "@vue/cli-plugin-e2e-cypress": "~5.0.8", 28 | "@vue/cli-plugin-eslint": "~5.0.8", 29 | "@vue/cli-plugin-pwa": "~5.0.8", 30 | "@vue/cli-plugin-unit-jest": "~5.0.8", 31 | "@vue/cli-service": "~5.0.8", 32 | "@vue/eslint-config-standard": "^4.0.0", 33 | "@vue/test-utils": "1.0.0-beta.29", 34 | "@vue/vue2-jest": "^27.0.0-alpha.3", 35 | "cypress": "^13.6.1", 36 | "eslint": "^8.15.0", 37 | "eslint-plugin-vue": "^8.7.1", 38 | "jest": "^27.1.0", 39 | "serve": "^11.3.2", 40 | "stylus": "^0.54.5", 41 | "stylus-loader": "^3.0.1", 42 | "vue-cli-plugin-vuetify": "~2.5.8", 43 | "vue-jest": "^3.0.7", 44 | "vue-template-compiler": "^2.6.10", 45 | "vuetify-loader": "^1.3.0" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | autoprefixer: {} 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JLiscom/StoreDown/cb27463307608f924736d675d1608f20d4ebc3d5/public/favicon.ico -------------------------------------------------------------------------------- /public/img/icons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JLiscom/StoreDown/cb27463307608f924736d675d1608f20d4ebc3d5/public/img/icons/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/img/icons/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JLiscom/StoreDown/cb27463307608f924736d675d1608f20d4ebc3d5/public/img/icons/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/img/icons/apple-touch-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JLiscom/StoreDown/cb27463307608f924736d675d1608f20d4ebc3d5/public/img/icons/apple-touch-icon-120x120.png -------------------------------------------------------------------------------- /public/img/icons/apple-touch-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JLiscom/StoreDown/cb27463307608f924736d675d1608f20d4ebc3d5/public/img/icons/apple-touch-icon-152x152.png -------------------------------------------------------------------------------- /public/img/icons/apple-touch-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JLiscom/StoreDown/cb27463307608f924736d675d1608f20d4ebc3d5/public/img/icons/apple-touch-icon-180x180.png -------------------------------------------------------------------------------- /public/img/icons/apple-touch-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JLiscom/StoreDown/cb27463307608f924736d675d1608f20d4ebc3d5/public/img/icons/apple-touch-icon-60x60.png -------------------------------------------------------------------------------- /public/img/icons/apple-touch-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JLiscom/StoreDown/cb27463307608f924736d675d1608f20d4ebc3d5/public/img/icons/apple-touch-icon-76x76.png -------------------------------------------------------------------------------- /public/img/icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JLiscom/StoreDown/cb27463307608f924736d675d1608f20d4ebc3d5/public/img/icons/apple-touch-icon.png -------------------------------------------------------------------------------- /public/img/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JLiscom/StoreDown/cb27463307608f924736d675d1608f20d4ebc3d5/public/img/icons/favicon-16x16.png -------------------------------------------------------------------------------- /public/img/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JLiscom/StoreDown/cb27463307608f924736d675d1608f20d4ebc3d5/public/img/icons/favicon-32x32.png -------------------------------------------------------------------------------- /public/img/icons/msapplication-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JLiscom/StoreDown/cb27463307608f924736d675d1608f20d4ebc3d5/public/img/icons/msapplication-icon-144x144.png -------------------------------------------------------------------------------- /public/img/icons/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JLiscom/StoreDown/cb27463307608f924736d675d1608f20d4ebc3d5/public/img/icons/mstile-150x150.png -------------------------------------------------------------------------------- /public/img/icons/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 148 | 149 | 150 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | StoreDown 16 | 21 | 22 | 23 | 24 | We're sorry but StoreDown doesn't work properly without JavaScript enabled. Please enable it to continue. 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "StoreDown", 3 | "short_name": "StoreDown", 4 | "icons": [ 5 | { 6 | "src": "./img/icons/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "./img/icons/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "start_url": "./index.html", 17 | "display": "standalone", 18 | "background_color": "#000000", 19 | "theme_color": "#4DBA87" 20 | } 21 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 15 | 17 | -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JLiscom/StoreDown/cb27463307608f924736d675d1608f20d4ebc3d5/src/assets/logo.png -------------------------------------------------------------------------------- /src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | Artboard 46 2 | -------------------------------------------------------------------------------- /src/components/List.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | No data was found. 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 72 | 73 | 76 | -------------------------------------------------------------------------------- /src/components/ListColumnTypes.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{props.item[header.value].substr(-6)}} 7 | 8 | 9 | {{props.item[header.value]}} 10 | 11 | 12 | 13 | 14 | {{header.item.prefixes[(props.item[header.value] || '').toLowerCase()]}}{{(props.item[header.value] || '').toLowerCase()}} 15 | 16 | 17 | 18 | 19 | {{subItem}} 20 | 21 | 22 | 23 | 24 | {{ props.item[header.value] && props.item[header.value].status ? "Checked in" : "Checked out"}} 25 | 26 | 27 | 28 | {{props.item[header.value]}} 29 | 30 | 31 | 32 | 33 | 34 | 43 | -------------------------------------------------------------------------------- /src/components/Share.vue: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/layouts/Main.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Discovery 9 | 10 | 11 | 12 | 13 | 14 | Search 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | Item tree 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | Item list 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | Change feed 39 | 40 | 41 | 42 | 43 | 44 | 45 | Modification 46 | 47 | 48 | 49 | 50 | 51 | New item 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | Settings 60 | 61 | 62 | 63 | 64 | 65 | Configuration 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | Connection 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | Import/export 82 | 83 | 84 | 85 | 86 | 87 | 88 | © 2022 Jacob Liscom Version: {{version}} 89 | 90 | 91 | 92 | 93 | 94 | 95 | StoreDown 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | Search 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 157 | 158 | 195 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import './plugins/vuetify' 3 | import './plugins/toasted' 4 | import App from './App.vue' 5 | import router from './router' 6 | import './registerServiceWorker' 7 | 8 | Vue.config.productionTip = false 9 | 10 | new Vue({ 11 | router, 12 | render (h) { return h(App) } 13 | }).$mount('#app') 14 | -------------------------------------------------------------------------------- /src/plugins/toasted.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Toasted from 'vue-toasted' 3 | Vue.use(Toasted, { 4 | duration: '2000', 5 | position: 'bottom-right', 6 | keepOnHover: true 7 | }) 8 | -------------------------------------------------------------------------------- /src/plugins/vuetify.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuetify from 'vuetify/lib' 3 | import 'vuetify/src/stylus/app.styl' 4 | 5 | Vue.use(Vuetify, { 6 | iconfont: 'md' 7 | }) 8 | -------------------------------------------------------------------------------- /src/registerServiceWorker.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | import { register } from 'register-service-worker' 4 | 5 | if (process.env.NODE_ENV === 'production') { 6 | register(`${process.env.BASE_URL}service-worker.js`, { 7 | ready () { 8 | console.log( 9 | 'App is being served from cache by a service worker.\n' + 10 | 'For more details, visit https://goo.gl/AFskqB' 11 | ) 12 | }, 13 | registered () { 14 | console.log('Service worker has been registered.') 15 | }, 16 | cached () { 17 | console.log('Content has been cached for offline use.') 18 | }, 19 | updatefound () { 20 | console.log('New content is downloading.') 21 | }, 22 | updated () { 23 | console.log('New content is available; please refresh.') 24 | }, 25 | offline () { 26 | console.log('No internet connection found. App is running in offline mode.') 27 | }, 28 | error (error) { 29 | console.error('Error during service worker registration:', error) 30 | } 31 | }) 32 | } 33 | -------------------------------------------------------------------------------- /src/router.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Router from 'vue-router' 3 | import { ConfigService } from './services/Config.js' 4 | 5 | Vue.use(Router) 6 | 7 | export default new Router({ 8 | mode: 'hash', 9 | base: process.env.BASE_URL, 10 | routes: [ 11 | { 12 | path: '/', 13 | beforeEnter: (to, from, next) => { 14 | let loginPath = '/login/' 15 | if (to.path === loginPath) { return next() }// If there already going to the login page dont bother them 16 | 17 | if (ConfigService.getLocalConfig('localMode')) { return next() } // If they have indicated they are ok with localMode, also dont bother them 18 | 19 | if (!ConfigService.getLocalConfig('user') || !ConfigService.getLocalConfig('database')) { return next(loginPath) }// Otherwise, it you dont have credentials, your going to login 20 | return next() 21 | }, 22 | component () { 23 | return import('./layouts/Main.vue') 24 | }, 25 | children: [ 26 | { 27 | name: 'list', 28 | path: '', 29 | component () { 30 | return import('./views/List.vue') 31 | } 32 | }, 33 | { 34 | name: 'tree', 35 | path: 'tree', 36 | component () { 37 | return import('./views/Tree.vue') 38 | } 39 | }, 40 | { 41 | name: 'search', 42 | path: 'search', 43 | component () { 44 | return import('./views/Search.vue') 45 | } 46 | }, 47 | { 48 | name: 'import-export', 49 | path: 'import-export', 50 | component () { 51 | return import('./views/ImportExport.vue') 52 | } 53 | }, 54 | { 55 | name: 'entry', 56 | path: 'entry/:id?', 57 | component () { 58 | return import('./views/Entry.vue') 59 | } 60 | }, 61 | { 62 | name: 'config', 63 | path: 'config', 64 | component () { 65 | return import('./views/Config.vue') 66 | } 67 | }, 68 | { 69 | name: 'changes', 70 | path: 'change-feed', 71 | component () { 72 | return import('./views/ChangeFeed.vue') 73 | } 74 | }, 75 | { 76 | name: 'connection', 77 | path: 'connection', 78 | component () { 79 | return import('./views/Login.vue') 80 | } 81 | } 82 | ] 83 | }, 84 | { 85 | name: 'login', 86 | path: '/login/', 87 | component () { 88 | return import('./views/Login.vue') 89 | } 90 | } 91 | ] 92 | }) 93 | -------------------------------------------------------------------------------- /src/services/Config.js: -------------------------------------------------------------------------------- 1 | import yaml from 'js-yaml' 2 | import DatabaseService from '../services/Database.js' 3 | 4 | const CONFIG_KEY = 'config' 5 | 6 | export class ConfigService { 7 | /** 8 | * [getConfigObject description] 9 | * @return {promise} [description] 10 | */ 11 | static getConfigObject () { 12 | return new Promise((resolve, reject) => { 13 | ConfigService.getConfig().then((config) => { 14 | return resolve(yaml.load(config.yml)) 15 | }).catch(reject) 16 | }) 17 | } 18 | 19 | /** 20 | * 21 | * @return {promise} - 22 | */ 23 | static getConfig () { 24 | return DatabaseService().getDatabase().get(CONFIG_KEY) 25 | } 26 | 27 | /** 28 | * [setConfig description] 29 | * @param {promise} config - 30 | */ 31 | static setConfig (config) { 32 | config.type = 'config' // Future proofing/give the ability to filter out app records 33 | config._id = CONFIG_KEY 34 | return DatabaseService().getDatabase().put(config) 35 | } 36 | 37 | /** 38 | * Wrapper for session storage and local storage 39 | * @param {boolean} remember - if true use local storage, if not use session storage 40 | * @param {string} key - Key to store 41 | * @param {object} value - Value to store 42 | */ 43 | static setLocalConfig (remember, key, value) { 44 | if (remember) { 45 | localStorage.setItem(key, JSON.stringify(value)) 46 | sessionStorage.removeItem(key) // Clean up if they switch types 47 | } else { 48 | sessionStorage.setItem(key, JSON.stringify(value)) 49 | localStorage.removeItem(key) // Clean up if they switch types 50 | } 51 | } 52 | 53 | /** 54 | * Wrapper for session storage and local storage 55 | * @param {string} key - key value was stored under 56 | * @return {object} - object retrieved. 57 | */ 58 | static getLocalConfig (key) { 59 | let value = localStorage.getItem(key) || sessionStorage.getItem(key) 60 | if (value) { return JSON.parse(value) } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/services/Database.js: -------------------------------------------------------------------------------- 1 | import { ConfigService } from '../services/Config.js' 2 | import PouchDB from 'pouchdb-browser' 3 | import Vue from 'vue' 4 | 5 | let Database = class { 6 | /** 7 | * Minimal PouchDB harness. For use in browser and cli 8 | * @param {[type]} databaseConfig - config object. Containers url, syncOptions, and syncCallback 9 | * @param {[type]} PouchDB [description] 10 | * @param {String} [dbPath='StoreDown'] [description] 11 | */ 12 | constructor (databaseConfig, PouchDB, dbPath = 'StoreDown') { // Dependency injector pattern 13 | // Create or find database 14 | this.localDatabase = new PouchDB(dbPath) 15 | 16 | // Re-init sync 17 | if (databaseConfig && databaseConfig.url) { 18 | this.remoteDatabase = new PouchDB(databaseConfig.url) 19 | databaseConfig.syncCallback(this.localDatabase.sync(this.remoteDatabase, databaseConfig.syncOptions)) 20 | } 21 | } 22 | 23 | /** 24 | * Get database object for crud operations. Doesn't really make sense to abstract out interface. 25 | * @return {PouchDB} - PouchDB local database object 26 | */ 27 | getDatabase () { 28 | return this.localDatabase 29 | } 30 | 31 | /** 32 | * [getAllItems description] 33 | * @return {promise} - when it resolves return array of item.doc's 34 | */ 35 | getAllItems (options = {}) { 36 | return new Promise((resolve, reject) => { 37 | let returnItems = [] 38 | this.localDatabase.allDocs(Object.assign(options, { // Merge options 39 | include_docs: true 40 | })).then((data) => { 41 | data.rows = data.rows.filter(row => row.doc.type === 'item').forEach(item => returnItems.push(item.doc)) 42 | data.rows = returnItems 43 | return resolve(data) 44 | }).catch(reject) 45 | }) 46 | } 47 | } 48 | 49 | let database = null 50 | let replicationTimeout = null 51 | const REPLICATION_TIMEOUT_DURATION = 5000 52 | // Factory pattern 53 | export default function (forceCreate, replicationCompleteCallback) { 54 | if (forceCreate || !database) { 55 | // Customized object for browser use 56 | let remoteDatabaseConfig = { 57 | syncOptions: { 58 | live: true, 59 | retry: true 60 | }, 61 | syncCallback: function (syncObject) { 62 | syncObject.on('error', function () { 63 | Vue.toasted.error('Replication error') 64 | }).on('paused', function () { 65 | clearTimeout(replicationTimeout) 66 | replicationTimeout = setTimeout(function () { 67 | Vue.toasted.info('Replication complete') 68 | replicationTimeout = null 69 | if (replicationCompleteCallback) { replicationCompleteCallback() }// Let folks know 70 | }, REPLICATION_TIMEOUT_DURATION) 71 | }) 72 | } 73 | } 74 | 75 | let userConfig = ConfigService.getLocalConfig('user') 76 | let databaseConfig = ConfigService.getLocalConfig('database') 77 | 78 | // Only give url if one is defined 79 | if (userConfig && databaseConfig) { 80 | remoteDatabaseConfig.url = `${databaseConfig.protocol}://${userConfig.username}:${userConfig.password}@${databaseConfig.host}:${databaseConfig.port}/${databaseConfig.database}` 81 | } 82 | 83 | database = new Database(remoteDatabaseConfig, PouchDB) 84 | } 85 | return database 86 | } 87 | -------------------------------------------------------------------------------- /src/views/ChangeFeed.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Change feed 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | No changes detected yet 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 100 | -------------------------------------------------------------------------------- /src/views/Config.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Configuration 6 | 7 | 8 | 9 | 10 | 11 | Save 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 68 | -------------------------------------------------------------------------------- /src/views/Entry.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Item Editor 6 | 7 | 8 | 9 | 10 | Duplicate 11 | 12 | 13 | Save 14 | 15 | 16 | 17 | Delete 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | {{group.displayName}} 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | Link 57 | 58 | 59 | 60 | {{ data.item }} 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | Check In 82 | 83 | 84 | Check Out 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | Delete 103 | 104 | Are you sure you want to delete this item? 105 | 106 | 107 | 108 | Cancel 109 | 110 | 111 | 112 | Delete 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 304 | 307 | -------------------------------------------------------------------------------- /src/views/Home.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 14 | -------------------------------------------------------------------------------- /src/views/ImportExport.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Import/Export 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | Database 14 | 15 | 16 | 17 | 18 | 19 | 20 | Empty database 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | Export 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | Export YML 42 | 43 | 44 | Export JSON 45 | 46 | 47 | Export CSV 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | Import 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | Import YML 74 | 75 | 76 | Import JSON 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | Delete 92 | 93 | Are you sure you want to delete all items in the database? 94 | This will also delete record on the database server. 95 | 96 | 97 | 98 | Cancel 99 | 100 | 101 | 102 | Delete 103 | 104 | 105 | 106 | 107 | 108 | 109 | Delete in progress 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | {{progressModalText}} 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 306 | -------------------------------------------------------------------------------- /src/views/List.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Item list 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 88 | -------------------------------------------------------------------------------- /src/views/Login.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | You are currently logged in. Click here to log out. 7 | 8 | 9 | 10 | 11 | 12 | 13 | Database login 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | Run in local/demo mode 34 | 35 | Login 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | Initial replication in progress... 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 250 | -------------------------------------------------------------------------------- /src/views/Search.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Search results 6 | 7 | 8 | 9 | 10 | 11 | 12 | 37 | 38 | 181 | 182 | 185 | -------------------------------------------------------------------------------- /src/views/Tree.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Item tree 6 | 7 | Item tree 8 | / 9 | 10 | {{p}} 11 | / 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 247 | -------------------------------------------------------------------------------- /tests/e2e/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | 'cypress' 4 | ], 5 | env: { 6 | mocha: true, 7 | 'cypress/globals': true 8 | }, 9 | rules: { 10 | strict: 'off' 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tests/e2e/specs/itemEntry.cy.js: -------------------------------------------------------------------------------- 1 | describe('Item Entry', () => { 2 | beforeEach(() => { 3 | cy.localMode() 4 | }) 5 | 6 | it('uses prefix', () => { 7 | cy.visit('/#/entry')// Go to the entry page 8 | cy.get('[aria-label="Location"]').type('box01') 9 | cy.get('div.v-text-field__prefix').should('have.text', 'House1/Room1/') 10 | 11 | // Test case insensitive 12 | cy.get('[aria-label="Location"]').clear().type('boX01') 13 | cy.get('div.v-text-field__prefix').should('have.text', 'House1/Room1/') 14 | 15 | // Test free form text, no prefix expected 16 | cy.get('[aria-label="Location"]').clear().type('box02') 17 | cy.get('div.v-text-field__prefix').should('not.exist')// test 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /tests/e2e/specs/itemList.cy.js: -------------------------------------------------------------------------------- 1 | describe('List View', () => { 2 | beforeEach(() => { 3 | cy.localMode() 4 | }) 5 | 6 | it('uses prefix', () => { 7 | cy.loadTestData() 8 | cy.visit('/#/')// Go to the entry page 9 | cy.contains('House1/Room1/box01') 10 | }) 11 | 12 | it('handles a blank location save', () => { 13 | // https://github.com/FoxUSA/StoreDown/issues/10 - There was an issue where if you saved an item with no location it would crash the list views 14 | cy.visit('/#/entry')// Go to the entry page 15 | cy.get('[aria-label="Name"]').type('Blank Location') 16 | cy.contains('Save').click() 17 | cy.hash().should('match', /#\/entry\/.+$/) // Wait for the save to be confirmed 18 | 19 | cy.visit('/#/')// Go to the list page 20 | cy.contains('Blank Location') 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /tests/e2e/specs/login.cy.js: -------------------------------------------------------------------------------- 1 | // https://docs.cypress.io/api/introduction/api.html 2 | 3 | describe('Login Experience', () => { 4 | it('visits the app root url', () => { 5 | cy.visit('/') 6 | cy.hash().should('eq', '#/login/') 7 | cy.contains('.v-toolbar__title', 'Database login') 8 | }) 9 | 10 | it('has a config when started in local mode', () => { 11 | cy.localMode() 12 | 13 | cy.visit('/#/config')// Go to the config page 14 | cy.get('textarea').should('not.be.empty') 15 | }) 16 | }) 17 | -------------------------------------------------------------------------------- /tests/e2e/specs/search.cy.js: -------------------------------------------------------------------------------- 1 | describe('List View', () => { 2 | before(() => { // Don't need to reset anything 3 | cy.localMode() 4 | cy.loadTestData() 5 | }) 6 | 7 | it('can find a uuid', () => { 8 | cy.visit('/#/')// Go to the entry page 9 | cy.get('input[placeholder="Search items"').type('8e71919a-4f03-4fa4-bdce-5767e38ed48e{enter}') 10 | cy.contains('USB A to USB C cable') 11 | 12 | // Partial uuid 13 | cy.get('input[placeholder="Search items"').invoke('val', '').type('1a76be8{enter}') 14 | cy.contains('Dental floss pick') 15 | 16 | // Click search with name 17 | cy.get('input[placeholder="Search items"').invoke('val', '').type('USB A to USB C cable') 18 | cy.get('.v-btn__content').contains('Search').click() 19 | cy.contains('8ed48e') 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /tests/e2e/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Call via cy.localMode() 14 | Cypress.Commands.add('localMode', () => { 15 | localStorage.clear() 16 | indexedDB.deleteDatabase('_pouch_StoreDown') 17 | 18 | cy.visit('/#/login/') 19 | cy.contains('Run in local/demo mode').click() 20 | cy.hash().should('eq', '#/')// Give the DB a moment after we click into local mode to redirect us. Or we could have used a time wait 21 | }) 22 | 23 | Cypress.Commands.add('loadTestData', () => { 24 | const testData = ` 25 | - status: 26 | checkInDate: '2022-04-23T19:31:22.030Z' 27 | checkInBy: Local 28 | status: true 29 | name: Refrigerator 30 | location: House 1/Kitchen 31 | tags: 32 | - '#Appliance' 33 | imageURLs: 34 | - >- 35 | https://www.homedepot.com/p/LG-Electronics-27-8-cu-ft-4-Door-French-Door-Smart-Refrigerator-with-2-Freezer-Drawers-and-Wi-Fi-Enabled-in-Stainless-Steel-LMXS28626S/302253240 36 | acquiredFrom: The Home Depot 37 | manufacturer: LG 38 | type: item 39 | manualPaths: 40 | - >- 41 | https://images.thdstatic.com/catalog/pdfImages/34/3425ea2f-97bf-4094-9cf3-7c2af2e8087f.pdf 42 | - >- 43 | https://images.thdstatic.com/catalog/pdfImages/2d/2d84e853-04e3-42b6-a6b0-2c125d060611.pdf 44 | receiptURLS: 45 | - >- 46 | https://image.shutterstock.com/image-vector/cash-receipt-receipts-financialcheck-isolated-260nw-1757944976.jpg 47 | acquireDate: '2022-04-23' 48 | warranty: 1 year 49 | pricePaid: '12345.1' 50 | weight: A lot 51 | expirationDate: '2023-04-24' 52 | holdReason: Put food in it. 53 | description: Stainless steel 54 | _id: 0f0fbfd9-8884-4160-8183-19bf39908f1e 55 | 56 | - status: {} 57 | location: House 1/Shed/Box4 58 | description: 90 flossers 59 | tags: 60 | - '#Dental' 61 | - '#Teeth' 62 | - '#Hygiene' 63 | code: 64 | - '842379146930' 65 | name: Dental floss pick 66 | acquiredFrom: Amazon 67 | pricePaid: '1.69' 68 | acquireDate: '2023-04-20' 69 | manufacturer: Solimo 70 | imageURLs: 71 | - >- 72 | https://smile.amazon.com/gp/product/B07CMR1BFM/ref=ppx_od_dt_b_asin_title_s00?ie=UTF8&th=1 73 | type: item 74 | _id: 45591d42-30e5-4131-800a-fe2241a76be8 75 | 76 | - status: {} 77 | name: USB A to USB C cable 78 | location: box01 79 | description: Red. 15W max. 80 | dimensions: 6ft 81 | imageURLs: 82 | - >- 83 | https://smile.amazon.com/gp/product/B07VYWJP71/ref=ppx_yo_dt_b_asin_title_o06_s00?ie=UTF8&psc=1&sa-no-redirect=1 84 | acquiredFrom: Amazon 85 | type: item 86 | lastModified: '2022-04-23T19:30:57.021Z' 87 | _id: 8e71919a-4f03-4fa4-bdce-5767e38ed48e 88 | 89 | - status: {} 90 | name: Backup Documents 91 | location: House 2/Attic/Behind picture of Grandma 92 | tags: 93 | - '#Backup' 94 | type: item 95 | _id: d51a2c0f-fe54-4ed5-8d18-19518af2cd9e` 96 | 97 | cy.visit('/#/import-export') 98 | 99 | cy.get('textarea').invoke('val', testData).trigger('input') 100 | cy.contains('Import YML').click().wait(500) // Let the ui process the data 101 | }) 102 | 103 | // 104 | // 105 | // -- This is a child command -- 106 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 107 | // 108 | // 109 | // -- This is a dual command -- 110 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 111 | // 112 | // 113 | // -- This is will overwrite an existing command -- 114 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 115 | -------------------------------------------------------------------------------- /tests/e2e/support/index.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 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /tests/unit/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | jest: true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /tests/unit/ListColumnTypes.spec.js: -------------------------------------------------------------------------------- 1 | // import { shallowMount } from '@vue/test-utils' 2 | // import ListColumnTypes from '@/components/ListColumnTypes.vue' 3 | 4 | describe('Placeholder', () => { 5 | it('Just checking to see if the logical fabric in the universe is still working', () => { 6 | expect(true).toBe(true) 7 | }) 8 | }) 9 | -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | process.env.VUE_APP_VERSION = require('./package.json').version// Include package.json version in proccess.env so app can render it. Must be prefixed with `VUE_APP_` per https://cli.vuejs.org/guide/mode-and-env.html#using-env-variables-in-client-side-code 2 | 3 | module.exports = { 4 | publicPath: './', 5 | configureWebpack: { 6 | 7 | } 8 | } 9 | --------------------------------------------------------------------------------