├── .babelrc ├── .browserlistrc ├── .circleci └── config.yml ├── .eslintrc.json ├── .flowconfig ├── .github └── workflows │ └── combine-prs.yml ├── .gitignore ├── .npmignore ├── .prettierrc ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── Makefile ├── README.md ├── example ├── .gitignore ├── example.tsx ├── public │ ├── images │ │ ├── alistar.jpg │ │ ├── brand.jpg │ │ ├── galio.jpg │ │ ├── jarvan.jpg │ │ └── riven.jpg │ └── template.html ├── server │ ├── index.js │ └── photos.json ├── style.scss └── webpack.config.js ├── jest.config.js ├── package.json ├── screenshots ├── listview.png └── two-up.png ├── src ├── __tests__ │ └── index-test.js ├── components │ ├── __tests__ │ │ ├── ars-test.js │ │ ├── error-message-test.js │ │ ├── figure-test.js │ │ ├── multiselection-item-test.js │ │ ├── multiselection-test.js │ │ ├── picker-test.js │ │ ├── search-test.js │ │ ├── selection-figure-test.js │ │ ├── selection-test.js │ │ ├── selection-text-test.js │ │ └── truncated-test.js │ ├── animation.ts │ ├── ars.tsx │ ├── datalist.tsx │ ├── empty.tsx │ ├── error-message.tsx │ ├── figure.tsx │ ├── gallery-button.tsx │ ├── gallery-panel.tsx │ ├── gallery.tsx │ ├── multiselection-item.tsx │ ├── multiselection.tsx │ ├── picker.tsx │ ├── scroll-monitor.tsx │ ├── search.tsx │ ├── selection-figure.tsx │ ├── selection-text.tsx │ ├── selection.tsx │ ├── table-button.tsx │ ├── table-view │ │ ├── checker.tsx │ │ ├── index.tsx │ │ └── table-heading.tsx │ ├── tag-list.tsx │ ├── tag.tsx │ ├── truncated.tsx │ └── ui │ │ ├── __tests__ │ │ └── image-test.js │ │ ├── button.tsx │ │ ├── image.tsx │ │ └── selection-not-found.tsx ├── containers │ ├── __tests__ │ │ └── load-record.test.js │ ├── load-collection.tsx │ └── load-record.tsx ├── contexts │ └── options.ts ├── icons │ ├── icon-frame.tsx │ └── index.tsx ├── index.ts ├── logger.ts ├── options.ts ├── record.ts ├── request.ts ├── style │ ├── animation │ │ └── spin.scss │ ├── ars-arsenal.scss │ ├── components │ │ ├── animation.scss │ │ ├── ars.scss │ │ ├── button.scss │ │ ├── dialog.scss │ │ ├── empty.scss │ │ ├── error.scss │ │ ├── figure.scss │ │ ├── gallery.scss │ │ ├── image.scss │ │ ├── multiselection.scss │ │ ├── search.scss │ │ ├── selection.scss │ │ ├── table-view.scss │ │ ├── tag.scss │ │ └── truncated.scss │ ├── generators │ │ ├── extends.scss │ │ └── functions.scss │ └── utils │ │ └── hidden.scss └── utils │ ├── __tests__ │ ├── article-for-test.js │ ├── collection-test.js │ └── pluralize-test.js │ ├── article-for.js │ ├── article-for.ts │ ├── collection.ts │ ├── pluralize.js │ └── pluralize.ts ├── test ├── __mocks__ │ └── xhr.js ├── data │ └── 1.json ├── setup.js ├── test.jpg └── test.json ├── tsconfig.json ├── types └── modules.d.ts └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "modules": "commonjs", 7 | "loose": true 8 | } 9 | ], 10 | "@babel/preset-react", 11 | "@babel/preset-typescript" 12 | ], 13 | "plugins": [ 14 | "@babel/plugin-proposal-class-properties" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /.browserlistrc: -------------------------------------------------------------------------------- 1 | # Browsers that we support 2 | 3 | > 1% 4 | last 2 versions 5 | Firefox ESR 6 | not ie < 10 7 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: circleci/node:8-browsers 6 | working_directory: ~/repo 7 | steps: 8 | - checkout 9 | - restore_cache: 10 | keys: 11 | - v1-dependencies-{{ checksum "package.json" }} 12 | - v1-dependencies- 13 | - run: yarn install 14 | - save_cache: 15 | paths: 16 | - node_modules 17 | key: v1-dependencies-{{ checksum "package.json" }} 18 | # This is annoying, but node-sass needs to be rebuilt 19 | - run: npm rebuild node-sass --force 20 | - run: yarn lint 21 | - run: yarn test 22 | - run: yarn build 23 | - run: yarn build:example 24 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "globals": { 3 | "Promise": true, 4 | "jest": true, 5 | "expect": true, 6 | "sinon": true 7 | }, 8 | "env": { 9 | "browser": true, 10 | "commonjs": true, 11 | "es6": true, 12 | "node": true, 13 | "jest": true 14 | }, 15 | "parser": "babel-eslint", 16 | "parserOptions": { 17 | "ecmaVersion": 7, 18 | "sourceType": "module", 19 | "ecmaFeatures": { 20 | "experimentalObjectRestSpread": true, 21 | "jsx": true 22 | } 23 | }, 24 | "extends": [ 25 | "eslint:recommended" 26 | ], 27 | "plugins": [ 28 | "react" 29 | ], 30 | "rules": { 31 | "semi": [ 32 | 0, 33 | "never" 34 | ], 35 | "sort-vars": 2, 36 | "comma-dangle": [ 37 | 2, 38 | "never" 39 | ], 40 | "no-console": 0, 41 | "quotes": [ 42 | 2, 43 | "single", 44 | { 45 | "avoidEscape": true 46 | } 47 | ], 48 | "no-unused-vars": [ 49 | 1, 50 | { 51 | "args": "none", 52 | "ignoreRestSiblings": true, 53 | "varsIgnorePattern": "^_" 54 | } 55 | ], 56 | "react/jsx-equals-spacing": [ 57 | "warn", 58 | "never" 59 | ], 60 | "react/jsx-no-duplicate-props": [ 61 | "warn", 62 | { 63 | "ignoreCase": true 64 | } 65 | ], 66 | "react/jsx-no-undef": "error", 67 | "react/jsx-pascal-case": [ 68 | "warn", 69 | { 70 | "allowAllCaps": true, 71 | "ignore": [] 72 | } 73 | ], 74 | "react/jsx-uses-react": "warn", 75 | "react/jsx-uses-vars": "warn", 76 | "react/no-danger-with-children": "warn", 77 | "react/no-deprecated": "warn", 78 | "react/no-direct-mutation-state": "warn", 79 | "react/no-is-mounted": "warn", 80 | "react/react-in-jsx-scope": "error", 81 | "react/require-render-return": "warn", 82 | "react/style-prop-object": "warn" 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | 3 | [include] 4 | 5 | [libs] 6 | 7 | [lints] 8 | 9 | [options] 10 | 11 | [strict] 12 | -------------------------------------------------------------------------------- /.github/workflows/combine-prs.yml: -------------------------------------------------------------------------------- 1 | name: 'Combine PRs' 2 | 3 | # Controls when the action will run - in this case triggered manually 4 | on: 5 | workflow_dispatch: 6 | inputs: 7 | branchPrefix: 8 | description: 'Branch prefix to find combinable PRs based on' 9 | required: true 10 | default: 'dependabot' 11 | mustBeGreen: 12 | description: 'Only combine PRs that are green (status is success)' 13 | required: true 14 | default: true 15 | combineBranchName: 16 | description: 'Name of the branch to combine PRs into' 17 | required: true 18 | default: 'combine-prs-branch' 19 | ignoreLabel: 20 | description: 'Exclude PRs with this label' 21 | required: true 22 | default: 'nocombine' 23 | 24 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 25 | jobs: 26 | # This workflow contains a single job called "combine-prs" 27 | combine-prs: 28 | # The type of runner that the job will run on 29 | runs-on: ubuntu-latest 30 | 31 | # Steps represent a sequence of tasks that will be executed as part of the job 32 | steps: 33 | - uses: actions/github-script@v3 34 | id: fetch-branch-names 35 | name: Fetch branch names 36 | with: 37 | github-token: ${{secrets.GITHUB_TOKEN}} 38 | script: | 39 | const pulls = await github.paginate('GET /repos/:owner/:repo/pulls', { 40 | owner: context.repo.owner, 41 | repo: context.repo.repo 42 | }); 43 | branches = []; 44 | prs = []; 45 | base_branch = null; 46 | for (const pull of pulls) { 47 | const branch = pull['head']['ref']; 48 | console.log('Pull for branch: ' + branch); 49 | if (branch.startsWith('${{ github.event.inputs.branchPrefix }}')) { 50 | console.log('Branch matched: ' + branch); 51 | statusOK = true; 52 | if(${{ github.event.inputs.mustBeGreen }}) { 53 | console.log('Checking green status: ' + branch); 54 | const statuses = await github.paginate('GET /repos/{owner}/{repo}/commits/{ref}/status', { 55 | owner: context.repo.owner, 56 | repo: context.repo.repo, 57 | ref: branch 58 | }); 59 | if(statuses.length > 0) { 60 | const latest_status = statuses[0]['state']; 61 | console.log('Validating status: ' + latest_status); 62 | if(latest_status != 'success') { 63 | console.log('Discarding ' + branch + ' with status ' + latest_status); 64 | statusOK = false; 65 | } 66 | } 67 | } 68 | console.log('Checking labels: ' + branch); 69 | const labels = pull['labels']; 70 | for(const label of labels) { 71 | const labelName = label['name']; 72 | console.log('Checking label: ' + labelName); 73 | if(labelName == '${{ github.event.inputs.ignoreLabel }}') { 74 | console.log('Discarding ' + branch + ' with label ' + labelName); 75 | statusOK = false; 76 | } 77 | } 78 | if (statusOK) { 79 | console.log('Adding branch to array: ' + branch); 80 | branches.push(branch); 81 | prs.push('#' + pull['number'] + ' ' + pull['title']); 82 | base_branch = pull['base']['ref']; 83 | } 84 | } 85 | } 86 | 87 | if (branches.length == 0) { 88 | core.setFailed('No PRs/branches matched criteria'); 89 | return; 90 | } 91 | 92 | core.setOutput('base-branch', base_branch); 93 | core.setOutput('prs-string', prs.join('\n')); 94 | 95 | combined = branches.join(' ') 96 | console.log('Combined: ' + combined); 97 | return combined 98 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 99 | - uses: actions/checkout@v2.3.3 100 | with: 101 | fetch-depth: 0 102 | # Creates a branch with other PR branches merged together 103 | - name: Created combined branch 104 | env: 105 | BASE_BRANCH: ${{ steps.fetch-branch-names.outputs.base-branch }} 106 | BRANCHES_TO_COMBINE: ${{ steps.fetch-branch-names.outputs.result }} 107 | COMBINE_BRANCH_NAME: ${{ github.event.inputs.combineBranchName }} 108 | run: | 109 | echo "$BRANCHES_TO_COMBINE" 110 | sourcebranches="${BRANCHES_TO_COMBINE%\"}" 111 | sourcebranches="${sourcebranches#\"}" 112 | 113 | basebranch="${BASE_BRANCH%\"}" 114 | basebranch="${basebranch#\"}" 115 | 116 | git config pull.rebase false 117 | git config user.name github-actions 118 | git config user.email github-actions@github.com 119 | 120 | git branch $COMBINE_BRANCH_NAME $basebranch 121 | git checkout $COMBINE_BRANCH_NAME 122 | git pull origin $sourcebranches --no-edit 123 | git push origin $COMBINE_BRANCH_NAME 124 | # Creates a PR with the new combined branch 125 | - uses: actions/github-script@v3 126 | name: Create Combined Pull Request 127 | env: 128 | PRS_STRING: ${{ steps.fetch-branch-names.outputs.prs-string }} 129 | with: 130 | github-token: ${{secrets.GITHUB_TOKEN}} 131 | script: | 132 | const prString = process.env.PRS_STRING; 133 | const body = 'This PR was created by the Combine PRs action by combining the following PRs:\n' + prString; 134 | await github.pulls.create({ 135 | owner: context.repo.owner, 136 | repo: context.repo.repo, 137 | title: 'Combined PR', 138 | head: '${{ github.event.inputs.combineBranchName }}', 139 | base: '${{ steps.fetch-branch-names.outputs.base-branch }}', 140 | body: body 141 | }); 142 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | coverage 3 | node_modules 4 | npm-debug.log 5 | dist 6 | build 7 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | __tests__ 2 | webpack.config.js 3 | webpack.example.config.js 4 | karma.conf.js 5 | example 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "printWidth": 100 5 | } 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## 3.9.0 4 | 5 | - Make `index.js` the main entry point 6 | 7 | ## 3.8.0 8 | 9 | - Merge security updates from dependabot 10 | 11 | ## 3.7.0 12 | 13 | - Disable browser autocomplete when autocomplete feature is disabled 14 | 15 | ## 3.6.0 16 | 17 | - Add autocomplete setting to control autocomplete 18 | 19 | ## 3.5.1 20 | 21 | - Fix bug where yarn output reported to stylesheet (#76) 22 | 23 | ## 3.5.0 24 | 25 | - Update picked state when receiving a new picked value. This allows ArsArsenal 26 | to be controlled. 27 | 28 | ## 3.4.0 29 | 30 | - Add failure state when data for an image can not be loaded 31 | - Add clear button to selected state 32 | - Updates to UI to support more compact usage 33 | - Improved gallery focus styles 34 | - Inlined icons 35 | 36 | ## 3.3.1 37 | 38 | - Remove exit animation from gallery, which was resulting in an 39 | undesired visual effect where gallery items flickered into new 40 | results 41 | - Fix case where searching broke pagination. 42 | - Scroll resets upon search 43 | 44 | ## 3.3.0 45 | 46 | - Safely handle duplicate record entries returned from API responses 47 | - Add `logger` option to customize errors and warnings emitted from 48 | ArsArsenal 49 | - Add CSS flexbox fix to prevent a single row of gallery items from 50 | stretching to bottom of container. 51 | 52 | ## 3.2.4 53 | 54 | - Fix an overflow bug in Chrome 72 where gallery items extended past container 55 | - Remove incorrect border on search box in Safari 56 | 57 | ## 3.2.3 58 | 59 | - Do not show ellipsis for nully text values 60 | 61 | ## 3.2.2 62 | 63 | - Fix cases where truncated text broke operating on nully values 64 | 65 | ## 3.2.1 66 | 67 | - New buttons are now typed as "button" to prevent form submission 68 | 69 | ## 3.2.0 70 | 71 | In this release, we've made some updates to improve animations and have introduced the concept of tags. 72 | 73 | Tags allow a user to quickly search by a particular term. They can click a tag to pre-populate search 74 | with the given tag. 75 | 76 | Additionally, we've added a panel to the gallery view that allows a user to see more information about 77 | a picture without needing to go to the table view. 78 | 79 | ## 3.1.0 80 | 81 | - Update dependencies 82 | - Fixed React key issue where stale content could load along-side 83 | fresh content, resulting in a key error 84 | 85 | ## 3.0.0 86 | 87 | This release adds pagination to ArsArsenal. In the process of doing this, we've made some breaking changes to the way URLs are constructed. For most users, this upgrade process should be minimal: 88 | 89 | ### `makeURL` is now `listUrl` and `showUrl` 90 | 91 | The old `makeURL` option relied on a null check to determine if the requested url is for a list of items or a single record. To avoid that ambiguity, there are now two endpoints for URL construction: 92 | 93 | Instead of: 94 | 95 | ```javascript 96 | function makeURL(url, id) { 97 | if (id == null) { 98 | return url 99 | } else { 100 | return `${url}/${id}` 101 | } 102 | } 103 | 104 | ArsArsenal.render({ makeURL }) 105 | ``` 106 | 107 | Change this to: 108 | 109 | ```javascript 110 | let listUrl = url => url 111 | let showUrl = (url, id) => `${url}/${id}` 112 | 113 | ArsArsenal.render({ listUrl, showUrl }) 114 | ``` 115 | 116 | **These are the default implementations of each option.** We anticipate 117 | that this change affects very few users. 118 | 119 | ### `makeQuery` is now `listQuery` and returns an object 120 | 121 | With pagination, ArsArsenal must now manage multiple query 122 | parameters. For improved ergonomics, ArsArsenal now builds the query 123 | string on behalf of the user. Instead of returning a string, return an object of key/value pairs: 124 | 125 | Instead of: 126 | 127 | ```javascript 128 | function makeQuery(term) { 129 | return `q=${term}` 130 | } 131 | 132 | ArsArsenal.render({ makeQuery }) 133 | ``` 134 | 135 | Return an object: 136 | 137 | ```javascript 138 | const PAGE_SIZE = 10 139 | 140 | function listQuery({ page, search, sort }) { 141 | // Return your pagination/search query implementation: 142 | let offset = page * PAGE_SIZE 143 | let limit = offset + PAGE_SIZE 144 | 145 | return { q: search, offset, limit, sort } 146 | } 147 | ``` 148 | 149 | ### Sorting 150 | 151 | Adding pagination required us to remove client-side 152 | sorting. ArsArsenal can't know all of the records on your server, so 153 | sorting would cause a frustrating reordering of items as new data 154 | loads. 155 | 156 | To enable sorting, take advantage of the `sort` field in the 157 | `listQuery` method: 158 | 159 | ```javascript 160 | function listQuery({ page, search, sort }) { 161 | // Assuming your API requires a call like: 162 | // /photos?page=1&q=Dogs&sortKey=breed 163 | return { 164 | page: page, 165 | q: search, 166 | sortKey: sort 167 | } 168 | } 169 | ``` 170 | 171 | ## 2.5.1 172 | 173 | - Handle `null` in captions and titles 174 | 175 | ## 2.5.0 176 | 177 | - Export project as CommonJS module for better support 178 | 179 | ## 2.4.5 180 | 181 | - Visual updates based on testing in a few apps 182 | 183 | ## 2.4.4 184 | 185 | - Fix bad method reference 186 | 187 | ## 2.4.3 188 | 189 | - Fix another case where indexOf check failed on null selection in the 190 | TableView 191 | 192 | ## 2.4.2 193 | 194 | - Fix case where indexOf check failed on null selection 195 | 196 | ## 2.4.1 197 | 198 | - Fix a style issue with selection clearing on mobile 199 | 200 | ## 2.4.0 201 | 202 | - Add the ability to what table columns display 203 | 204 | ## 2.3.0 205 | 206 | - Added a table view 207 | - Significant animation and aesthetic improvements 208 | 209 | ## 2.2.0 210 | 211 | - Do not fetch when given a `NaN` slug 212 | 213 | ## 2.1.1 214 | 215 | - Remove PropType to avoid unexpected warning in 216 | 217 | ## 2.1.0 218 | 219 | - Upgrade react-focus-trap dependency 220 | 221 | ## 2.0.0 222 | 223 | - Upgrade dependencies 224 | - Remove peer dependency on React 225 | - Remove deprecation warnings in React 15.x 226 | 227 | ## 1.0.0 228 | 229 | - **Important Update**: This update makes breaking changes to support 230 | React 0.14. ars-arsenal now takes advantage of 231 | `react-addons-css-transition-group` and utilizes `react-dom` for rendering. 232 | 233 | ## 0.4.2 234 | 235 | - Adds the ability to clear existing image selections. 236 | - Adds a `resource` option for customizing file type language. For changing the "Photos" reference in "Pick a photo" selection text: 237 | 238 | - Setting `resource` to "File" renders "Pick a file" 239 | - Setting `resource` to "Image" renders "Pick an image" 240 | 241 | - Updates basic selection styles 242 | 243 | - Centers the selection text, re-positions icons to reflect the loading state within the selection button. 244 | - Adds "Loading" text while a selected image is fetching. 245 | - Applies the "loaded" class to the image shortly after onload for image-to-image transitions. 246 | - Adds an explicit `-webkit-transition` to workaround autoprefixr not generating `-webkit-filter` as a transition property. 247 | 248 | - Resets `isLoaded` state when the image re-renders with a different src. 249 | 250 | ## 0.4.1 251 | 252 | ### Noticeable Changes 253 | 254 | - Updated style 255 | - Added multiselection option, see https://github.com/vigetlabs/ars-arsenal/pull/14 256 | - Fixed an issue with Picker `onExit` where "Cancel" clicks could bubble and immediately re-open the Picker dialog 257 | 258 | ### Upgrading 259 | 260 | - The style folder for ars-arsenal is now placed within `ars-arsenal/style`. For those on 0.3.0, you will need to change this path. 261 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution Guidelines 2 | 3 | Thanks you for considering a contribution to ArsArsenal! 4 | 5 | ArsArsenal is built using tools written for 6 | [nodejs](http://nodejs.org). We recommend installing Node with 7 | [nvm](https://github.com/creationix/nvm). Dependencies are managed 8 | through `package.json`. 9 | 10 | You can install dependencies with: 11 | 12 | ```bash 13 | npm install 14 | ``` 15 | 16 | ## Running 17 | 18 | A production build can be built by running: 19 | 20 | ```bash 21 | make build 22 | ``` 23 | 24 | However most of the time developing with ArsArsenal, you will want 25 | to reference the example app: 26 | 27 | ```bash 28 | npm start 29 | ``` 30 | 31 | This will host a web server with all examples at `http://localhost:8080`. 32 | 33 | ## Testing 34 | 35 | ```bash 36 | npm test 37 | ``` 38 | 39 | Be sure to check the `./coverage` folder to verify all code paths are 40 | touched. 41 | 42 | ## Deployment 43 | 44 | The following steps are required to push a new release: 45 | 46 | 1. Update changelog 47 | 2. `npm version ` 48 | 3. `git push --tags` 49 | 4. `make release` 50 | 51 | ArsArsenal must first be compiled down to ES5 using Babel. The 52 | following command will perform that task and deploy to NPM: 53 | 54 | ```bash 55 | make release 56 | ``` 57 | 58 | ## Conventions 59 | 60 | **Consider master unsafe**, use [`npm`](https://www.npmjs.com/package/ars-arsenal) for the latest stable version. 61 | 62 | ### Javascript 63 | 64 | ArsArsenal uses ES6 Javascript (compiled using [Babel](babeljs.io)). As 65 | for style, shoot for: 66 | 67 | - No semicolons 68 | - Commas last, 69 | - 2 spaces for indentation (no tabs) 70 | - Prefer ' over ", use string interpolation 71 | - 80 character line length 72 | 73 | ### Reviews 74 | 75 | All changes should be submitted through pull request. Ideally, at 76 | least two :+1:s should be given before a pull request is merge. 77 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Viget Labs 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean build package.json docs release example sass css 2 | 3 | build: clean javascript typescript css package.json documentation 4 | 5 | javascript: $(subst src,dist,$(shell find src -name '*.js' ! -name '*-test.js')) 6 | 7 | typescript: $(subst src,dist,$(shell find src -name '*.ts*')) 8 | 9 | dist/%.js: src/%.js 10 | @mkdir -p $(@D) 11 | @yarn babel -o dist/$*.js $< 12 | @echo "[+] dist/$*.js" 13 | 14 | dist/%.ts: src/%.ts 15 | @mkdir -p $(@D) 16 | @yarn babel -o dist/$*.js $< 17 | @echo "[+] dist/$*.js" 18 | 19 | dist/%.tsx: src/%.tsx 20 | @mkdir -p $(@D) 21 | @yarn babel -o dist/$*.js $< 22 | @echo "[+] dist/$*.js" 23 | 24 | css: sass 25 | node_modules/.bin/node-sass ./dist/style/ars-arsenal.scss --stdout > dist/style.css 26 | 27 | sass: 28 | @mkdir -p dist 29 | cp -r src/style dist/style 30 | 31 | package.json: 32 | @node -p 'p=require("./package");p.private=undefined;p.scripts=p.devDependencies=undefined;JSON.stringify(p,null,2)' > dist/package.json 33 | 34 | documentation: README.md LICENSE.md 35 | @mkdir -p dist 36 | cp -r $^ dist 37 | 38 | release: clean build 39 | yarn test 40 | npm publish dist 41 | 42 | prerelease: clean build 43 | yarn test 44 | npm publish dist --tag beta 45 | 46 | clean: 47 | rm -rf dist 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ars Arsenal 2 | 3 | [![CircleCI](https://circleci.com/gh/vigetlabs/ars-arsenal.svg?style=svg)](https://circleci.com/gh/vigetlabs/ars-arsenal) 4 | 5 | A gallery picker. ArsArsenal makes it easy to quickly select photos and other resources for content management purposes. Additionally, it supports features such as: 6 | 7 | - Table/Gallery view 8 | - Pagination 9 | - Sorting 10 | - Search 11 | 12 | **Heads up!** we recently made some breaking changes to configuration in version 3.0.0. See the [CHANGELOG](CHANGELOG.md) for more information. 13 | 14 | ![Example](./screenshots/two-up.png) 15 | 16 | ## Installation 17 | 18 | ```shell 19 | npm install --save ars-arsenal 20 | # or use yarn 21 | yarn add ars-arsenal 22 | ``` 23 | 24 | ### Styles 25 | 26 | Ars Arsenal ships with a stylesheet. The easiest way to include it is by importing it from the node_modules folder: 27 | 28 | ```scss 29 | /* Sass stylesheet: */ 30 | @import './node_modules/ars-arsenal/style/ars-arsenal.scss'; /* or CSS: */ 31 | @import './node_modules/ars-arsenal/style.css'; 32 | ``` 33 | 34 | ## Usage 35 | 36 | ArsArsenal can be rendered either as a stand-alone instance or as a React component: 37 | 38 | ### Stand Alone 39 | 40 | ```javascript 41 | import ArsArsenal from 'ars-arsenal' 42 | 43 | let app = document.getElementById('app') 44 | 45 | ArsArsenal.render(app, { 46 | autoComplete: true, // Show or hide autocomplete results 47 | 48 | resource: 'photo', // the noun used for selection, i.e. "Pick a photo" 49 | 50 | // Configure the root element's HTML attributes. default = {} 51 | rootAttributes: { 52 | className: 'my-custom-class another-custom-class', 53 | 'data-test': 'my-integration-selector-helper' 54 | }, 55 | 56 | // The base URL for API interaction 57 | url: 'photo/resource/endpoint', 58 | 59 | // How to display the items. Can be "table" or "gallery" 60 | mode: 'gallery', 61 | 62 | // What table columns to display, and in what order 63 | columns: ['id', 'name', 'caption', 'attribution', 'preview'], 64 | 65 | multiselect: false, 66 | 67 | listUrl: function(url) { 68 | // Used to build the URL that fetches lists of records. 69 | return url 70 | }, 71 | 72 | listQuery: function({ search, page, sort }) { 73 | // Use this function to rename query parameters before building 74 | // the listUrl URL 75 | // 76 | // Any data returned from this function will be stringified into 77 | // query parameters 78 | return { search, page, sort } 79 | }, 80 | 81 | showUrl: function(url, id: ID) { 82 | // Used to build the URL that fetches a single record 83 | return `${url}/${id}` 84 | }, 85 | 86 | onError: function(response) { 87 | // format errors before they are sent as a "string" value 88 | // to the component 89 | return response.code + ': ' + response.message 90 | }, 91 | 92 | onFetch: function(response) { 93 | // format the response, useful if you do not control the JSON 94 | // response from your endpoint 95 | return response.data 96 | }, 97 | 98 | onChange: function(id) { 99 | // Whenever a new item is picked, this event is triggered 100 | console.log('The value was changed to %s', id) 101 | }, 102 | 103 | request: function(url, callback) { 104 | // Behavior to configure networking. Return an XMLHTTPRequest 105 | return xhr(url, callback) 106 | }, 107 | 108 | logger: function(level, message) { 109 | // Override this method to handle usage warnings and issues 110 | // ArsArsenal considers errors with API interaction. Useful 111 | // for monitoring. 112 | switch (level) { 113 | case 'warning': 114 | console.warn(message) 115 | break 116 | case 'error': 117 | console.error(message) 118 | break 119 | default: 120 | console.log(message) 121 | break 122 | } 123 | } 124 | }) 125 | ``` 126 | 127 | ### React 128 | 129 | ```jsx 130 | import React from 'react' 131 | import ReactDOM from 'react-dom' 132 | import { Ars } from 'ars-arsenal' 133 | 134 | let app = document.getElementById('app') 135 | 136 | let options = { 137 | /* same options as above */ 138 | } 139 | 140 | ReactDOM.render(, app) 141 | ``` 142 | 143 | ## Response format 144 | 145 | APIs return different shapes of data. To account for this, ArsArsenal exposes the `onFetch` option. This option is called whenever data is fetched from your API: 146 | 147 | ```javascript 148 | let options = { 149 | onFetch: function(response) { 150 | // format the response, useful if you do not control the JSON 151 | // response from your endpoint 152 | return response.data 153 | } 154 | } 155 | ``` 156 | 157 | ArsArsenal expects the following data format: 158 | 159 | ```json 160 | [ 161 | { 162 | "id": 1, 163 | "attribution": "League of Legends", 164 | "name": "Alistar", 165 | "caption": "Lorem ipsum dolor sit amet", 166 | "url": "images/alistar.jpg", 167 | "tags": ["blue", "cunning"] 168 | } 169 | //... 170 | ] 171 | ``` 172 | 173 | To transpose data, map over it in `onFetch` like so: 174 | 175 | ```javascript 176 | let options = { 177 | onFetch: function(response) { 178 | return response.data.map(function(record) { 179 | return { 180 | id: record.id, 181 | attribution: record.credit, 182 | name: record.title, 183 | caption: record.caption, 184 | url: record.imageSrc, 185 | tags: record.tags 186 | } 187 | }) 188 | } 189 | } 190 | ``` 191 | 192 | ## Sorting 193 | 194 | To enable sorting, take advantage of the `sort` field passed into the `listQuery` option. `listQuery` will automatically stringify the returned object: 195 | 196 | ```javascript 197 | function listQuery({ page, search, sort }) { 198 | // Assuming your API requires a call like: 199 | // /photos?page=1&q=Dogs&sortKey=breed 200 | return { 201 | page: page, 202 | q: search, 203 | sortKey: sort 204 | } 205 | } 206 | ``` 207 | 208 | ## Contributing 209 | 210 | Take a look at our [contributing guide](./CONTRIBUTING.md), but the gist of it is: 211 | 212 | ```shell 213 | # Install dependencies 214 | yarn install 215 | # Spin up the example server with: 216 | yarn start 217 | ``` 218 | 219 | --- 220 | 221 | 222 | Code At Viget 223 | 224 | 225 | Visit [code.viget.com](http://code.viget.com) to see more projects from [Viget.](https://viget.com) 226 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | public/index.html 2 | public/main.*.js 3 | public/main.*.css 4 | -------------------------------------------------------------------------------- /example/example.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as DOM from 'react-dom' 3 | import { Ars, render } from '../src' 4 | import server from './server' 5 | 6 | import './style' 7 | 8 | const options = { 9 | url: '/api/photos', 10 | 11 | listQuery(options) { 12 | return { 13 | term: options.search, 14 | sort: options.sort, 15 | limit: 10, 16 | offset: options.page * 10 17 | } 18 | }, 19 | 20 | onError(response) { 21 | return `${response.code}: ${response.message}` 22 | }, 23 | 24 | onChange(value) { 25 | console.log('Value changed to %s', value) 26 | }, 27 | 28 | request: server 29 | } 30 | 31 | function Example({ title, options }) { 32 | return ( 33 |
34 |
35 |

{title}

36 | 37 |
38 |
{JSON.stringify(options, null, 2)}
39 |
40 | ) 41 | } 42 | 43 | DOM.render( 44 |
45 | 46 | 47 | 48 | 49 | 53 | 62 |
, 63 | document.getElementById('app') 64 | ) 65 | -------------------------------------------------------------------------------- /example/public/images/alistar.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vigetlabs/ars-arsenal/42c3ab3b85744155147923e3742923a2d953a241/example/public/images/alistar.jpg -------------------------------------------------------------------------------- /example/public/images/brand.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vigetlabs/ars-arsenal/42c3ab3b85744155147923e3742923a2d953a241/example/public/images/brand.jpg -------------------------------------------------------------------------------- /example/public/images/galio.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vigetlabs/ars-arsenal/42c3ab3b85744155147923e3742923a2d953a241/example/public/images/galio.jpg -------------------------------------------------------------------------------- /example/public/images/jarvan.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vigetlabs/ars-arsenal/42c3ab3b85744155147923e3742923a2d953a241/example/public/images/jarvan.jpg -------------------------------------------------------------------------------- /example/public/images/riven.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vigetlabs/ars-arsenal/42c3ab3b85744155147923e3742923a2d953a241/example/public/images/riven.jpg -------------------------------------------------------------------------------- /example/public/template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Ars Arsenal 8 | 9 | 10 |
11 |

ArsArsenal

12 | vigetlabs/ars-arsenal 13 |
14 |
15 | 16 | 17 | -------------------------------------------------------------------------------- /example/server/index.js: -------------------------------------------------------------------------------- 1 | import Url from 'url' 2 | import data from './photos.json' 3 | import { sortBy } from 'lodash' 4 | 5 | const photos = [] 6 | 7 | var id = 0 8 | for (var i = 0; i < 20; i++) { 9 | data.forEach(item => { 10 | photos.push({ ...item, id: id++ }) 11 | }) 12 | } 13 | 14 | export default function(url, success, error) { 15 | const { pathname, query } = Url.parse(url, true) 16 | 17 | const timeout = setTimeout(function() { 18 | let [_base, id] = pathname.match(/api\/photos\/(.+?)/) || [] 19 | 20 | if (id) { 21 | show(id, query, success, error) 22 | } else { 23 | index(query, success, error) 24 | } 25 | }, 150) 26 | 27 | return { 28 | abort() { 29 | clearTimeout(timeout) 30 | } 31 | } 32 | } 33 | 34 | function index(query, success, error) { 35 | let payload = photos 36 | 37 | if ('sort' in query) { 38 | payload = sortBy(payload, [query.sort, 'id'], -1) 39 | } 40 | 41 | if ('term' in query) { 42 | let term = new RegExp(escape(query.term), 'i') 43 | 44 | payload = payload.filter(function(photo) { 45 | return term.test(photo.name) 46 | }) 47 | } 48 | 49 | if ('offset' in query || 'limit' in query) { 50 | let offset = parseInt(query.offset || 0) 51 | let limit = parseInt(query.limit || 10) 52 | 53 | payload = payload.slice(offset, offset + limit) 54 | } 55 | 56 | success(payload) 57 | } 58 | 59 | function show(id, query, success, error) { 60 | let payload = photos.find(photo => `${photo.id}` === `${id}`) 61 | payload ? success(payload) : error({ code: 404, message: 'Not found' }) 62 | } 63 | -------------------------------------------------------------------------------- /example/server/photos.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "attribution": "League of Legends", 4 | "name": "Brand", 5 | "caption": "Lorem ipsum dolor sit amet, in nec nulla dolor, ea oratio prompta invenire mel, has eu hinc semper. ", 6 | "url": "images/brand.jpg", 7 | "tags": ["league", "tag-1"] 8 | }, 9 | { 10 | "attribution": "League of Legends", 11 | "name": "Galio has a very long title of which gets truncated", 12 | "caption": "Lorem ipsum dolor sit amet, in nec nulla dolor, ea oratio prompta invenire mel, has eu hinc semper. ", 13 | "url": "images/galio.jpg", 14 | "tags": ["league", "tag-2", "lots-of-chars-what-even", "more-keys", "even-more-tags"] 15 | }, 16 | { 17 | "attribution": "League of Legends", 18 | "name": "Alistar", 19 | "caption": "Lorem ipsum dolor sit amet, in nec nulla dolor, ea oratio prompta invenire mel, has eu hinc semper. ", 20 | "url": "images/alistar.jpg", 21 | "tags": ["league", "tag-3"] 22 | }, 23 | { 24 | "attribution": "League of Legends", 25 | "name": "Jarvan", 26 | "caption": "Lorem ipsum dolor sit amet, in nec nulla dolor, ea oratio prompta invenire mel, has eu hinc semper. ", 27 | "url": "images/jarvan.jpg", 28 | "tags": [] 29 | }, 30 | { 31 | "attribution": "League of Legends", 32 | "name": "Riven vs Shyvana", 33 | "caption": "Lorem ipsum dolor sit amet, in nec nulla dolor, ea oratio prompta invenire mel, has eu hinc semper. ", 34 | "url": "images/riven.jpg", 35 | "tags": ["conflict"] 36 | } 37 | ] 38 | -------------------------------------------------------------------------------- /example/style.scss: -------------------------------------------------------------------------------- 1 | @import '../src/style/ars-arsenal.scss'; 2 | 3 | *, 4 | *:after, 5 | *:before { 6 | box-sizing: border-box; 7 | } 8 | 9 | body { 10 | background: #eee; 11 | margin: 0; 12 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, 13 | Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; 14 | padding-bottom: 48px; 15 | } 16 | 17 | h1 { 18 | color: rgba(#000, 0.54); 19 | font-size: 14px; 20 | font-weight: 400; 21 | letter-spacing: 0.01em; 22 | text-transform: uppercase; 23 | } 24 | 25 | .header { 26 | background: $ars-primary; 27 | padding: 12px 20px 64px; 28 | box-shadow: inset 0 -1px 0.5px rgba(#000, 0.12), 29 | inset 0 -1px 0 rgba(#000, 0.12); 30 | } 31 | 32 | .header a:link, 33 | .header a:visited { 34 | color: rgba(white, 0.54); 35 | text-decoration: none; 36 | 37 | &:hover { 38 | color: $ars-accent; 39 | text-decoration: underline; 40 | } 41 | } 42 | 43 | .type-heading { 44 | color: rgba(255, 255, 255, 0.88); 45 | font-size: 14px; 46 | font-weight: 400; 47 | letter-spacing: 0.05em; 48 | text-transform: uppercase; 49 | margin: 4px 0 8px; 50 | } 51 | 52 | .type-subheading { 53 | color: rgba(#000, 0.54); 54 | font-size: 14px; 55 | font-weight: 500; 56 | letter-spacing: 0.01em; 57 | text-transform: uppercase; 58 | margin: 4px 0 24px; 59 | } 60 | 61 | .main { 62 | margin: -32px auto 0; 63 | max-width: 768px; 64 | padding: 0 20px; 65 | } 66 | 67 | .example { 68 | background: #fff; 69 | border-radius: 2px; 70 | box-shadow: 0 1px 2px rgba(#000, 0.20), 0 0 1px rgba(#000, 0.12); 71 | margin-bottom: 24px; 72 | display: table; 73 | width: 100%; 74 | 75 | > * { 76 | display: table-cell; 77 | width: 100% 78 | } 79 | } 80 | 81 | .example-content { 82 | padding: 20px; 83 | } 84 | 85 | .code { 86 | background: #224; 87 | box-shadow: inset 1px 0 2px rgba(#000, 0.54); 88 | color: white; 89 | height: 100%; 90 | margin: 0; 91 | padding: 20px; 92 | text-shadow: 0 1px 1px rgba(#000, 0.88); 93 | width: 33%; 94 | min-width: 250px; 95 | vertical-align: middle; 96 | } 97 | -------------------------------------------------------------------------------- /example/webpack.config.js: -------------------------------------------------------------------------------- 1 | const MiniCssExtractPlugin = require('mini-css-extract-plugin') 2 | const HtmlWebpackPlugin = require('html-webpack-plugin') 3 | const path = require('path') 4 | 5 | module.exports = { 6 | devtool: 'sourcemap', 7 | 8 | context: __dirname, 9 | 10 | entry: ['./example.tsx'], 11 | 12 | output: { 13 | filename: '[name].[hash].js', 14 | path: path.resolve(__dirname, 'public'), 15 | publicPath: '/' 16 | }, 17 | 18 | resolve: { 19 | extensions: ['.js', '.scss', '.css', '.ts', '.tsx'], 20 | alias: { 21 | 'ars-arsenal': '../src/index.js' 22 | } 23 | }, 24 | 25 | plugins: [ 26 | new HtmlWebpackPlugin({ 27 | template: 'public/template.html' 28 | }), 29 | new MiniCssExtractPlugin({ 30 | filename: '[name].[hash].css' 31 | }) 32 | ], 33 | 34 | module: { 35 | strictExportPresence: true, 36 | rules: [ 37 | { 38 | test: /\.(js|ts|tsx)$/, 39 | exclude: /node_modules/, 40 | use: ['babel-loader'] 41 | }, 42 | { 43 | test: /\.s*(c|a)ss$/, 44 | exclude: /node_modules/, 45 | use: [ 46 | MiniCssExtractPlugin.loader, 47 | 'css-loader', 48 | { 49 | loader: 'postcss-loader', 50 | options: { 51 | plugins: loader => [ 52 | require('postcss-import')({ 53 | root: loader.resourcePath 54 | }), 55 | require('autoprefixer')() 56 | ] 57 | } 58 | }, 59 | 'sass-loader' 60 | ] 61 | } 62 | ] 63 | }, 64 | 65 | devServer: { 66 | contentBase: path.resolve(__dirname, 'public'), 67 | publicPath: '/', 68 | port: process.env.PORT || 3000 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Jest configuration 3 | * https://facebook.github.io/jest/docs/en/configuration.html 4 | */ 5 | 6 | module.exports = { 7 | setupFiles: ['./test/setup.js'], 8 | moduleFileExtensions: ['ts', 'tsx', 'js'], 9 | transform: { 10 | '^.+\\.(ts|tsx)$': 'babel-jest', 11 | '^.+\\.(js|jsx)$': 'babel-jest' 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ars-arsenal", 3 | "version": "3.9.0", 4 | "private": true, 5 | "description": "A gallery picker", 6 | "main": "index.js", 7 | "scripts": { 8 | "start": "webpack-dev-server --config example/webpack.config.js --mode=development", 9 | "build": "make build", 10 | "build:example": "webpack --config=example/webpack.config.js --mode=production", 11 | "test": "jest", 12 | "lint": "eslint . --cache --cache-location=node_modules/ --ignore-path=.gitignore", 13 | "format": "prettier --write './{src,lib,test,config,example}/**/*.{js,ts,tsx}' './*.{js,ts,jsx}'" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git://github.com/vigetlabs/ars-arsenal.git" 18 | }, 19 | "keywords": [ 20 | "gallery", 21 | "react" 22 | ], 23 | "author": "Viget Labs ", 24 | "license": "MIT", 25 | "bugs": "https://github.com/vigetlabs/ars-arsenal/issues", 26 | "homepage": "https://github.com/vigetlabs/ars-arsenal", 27 | "devDependencies": { 28 | "@babel/cli": "^7.2.0", 29 | "@babel/core": "^7.2.0", 30 | "@babel/plugin-proposal-class-properties": "^7.2.1", 31 | "@babel/preset-env": "^7.2.0", 32 | "@babel/preset-react": "^7.0.0", 33 | "@babel/preset-typescript": "^7.0.0", 34 | "@types/classnames": "^2.2.6", 35 | "@types/jest": "^23.3.10", 36 | "@types/lodash": "^4.14.118", 37 | "@types/query-string": "^6.1.1", 38 | "@types/react": "^16.7.13", 39 | "@types/react-dom": "^16.0.11", 40 | "@types/react-transition-group": "^2.0.14", 41 | "autoprefixer": "^9.4.2", 42 | "babel-core": "^7.0.0-bridge.0", 43 | "babel-eslint": "^10.0.1", 44 | "babel-jest": "^23.0.1", 45 | "babel-loader": "^8.0.2", 46 | "css-loader": "^2.0.0", 47 | "enzyme": "^3.6.0", 48 | "enzyme-adapter-react-16": "^1.7.1", 49 | "eslint": "^5.10.0", 50 | "eslint-plugin-react": "^7.11.1", 51 | "html-webpack-plugin": "^3.2.0", 52 | "jest": "^23.5.0", 53 | "lodash": "^4.17.11", 54 | "mini-css-extract-plugin": "^0.5.0", 55 | "node-sass": "^4.13.1", 56 | "postcss-import": "^12.0.1", 57 | "postcss-loader": "^3.0.0", 58 | "prettier": "^1.15.3", 59 | "react": "^16.6.3", 60 | "react-dom": "^16.6.3", 61 | "sass-loader": "^7.1.0", 62 | "style-loader": "^0.23.0", 63 | "typescript": "^3.2.2", 64 | "webpack": "^4.27.1", 65 | "webpack-cli": "^3.1.0", 66 | "webpack-dev-server": "^3.1.11" 67 | }, 68 | "dependencies": { 69 | "classnames": "~2.2.6", 70 | "query-string": "^6.2.0", 71 | "react-focus-trap": "^2.7.0", 72 | "react-ink": "^6.4.0", 73 | "react-transition-group": "^2.5.1", 74 | "xhr": "~2.5.0" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /screenshots/listview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vigetlabs/ars-arsenal/42c3ab3b85744155147923e3742923a2d953a241/screenshots/listview.png -------------------------------------------------------------------------------- /screenshots/two-up.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vigetlabs/ars-arsenal/42c3ab3b85744155147923e3742923a2d953a241/screenshots/two-up.png -------------------------------------------------------------------------------- /src/__tests__/index-test.js: -------------------------------------------------------------------------------- 1 | import TestUtils from 'react-dom/test-utils' 2 | import ArsArsenal from '../index' 3 | import Ars from '../components/ars' 4 | 5 | describe('ArsArsenal', () => { 6 | test('exposes a component definition', () => { 7 | expect(ArsArsenal).toHaveProperty('component', Ars) 8 | }) 9 | 10 | test('exposes a render method', () => { 11 | let component = ArsArsenal.render(document.createElement('div'), { 12 | url: '/test.json' 13 | }) 14 | 15 | expect(TestUtils.isElementOfType(component, Ars)).toBe(true) 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /src/components/__tests__/ars-test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Ars from '../ars' 3 | import { mount } from 'enzyme' 4 | 5 | describe('Ars', () => { 6 | describe('when the component renders', () => { 7 | let component = null 8 | 9 | beforeEach(() => { 10 | component = mount() 11 | }) 12 | 13 | test('has a selection component', () => { 14 | expect(component.find('Selection')).toHaveLength(1) 15 | }) 16 | 17 | test('migrates a single `picked` value to an array', () => { 18 | expect(component).toHaveState('picked', [9]) 19 | }) 20 | }) 21 | 22 | describe('when rootAttributes is provided', () => { 23 | test('can be given a root attribute', () => { 24 | const rootAttributes = { 25 | 'data-test': 'ars-resource-photo', 26 | className: 'my-custom-class' 27 | } 28 | let component = mount() 29 | let htmlNode = component.find('.my-custom-class') 30 | 31 | expect(htmlNode.prop('data-test')).toBe('ars-resource-photo') 32 | }) 33 | }) 34 | 35 | describe('when the component renders with the multiselect option', () => { 36 | let component, onChange 37 | 38 | beforeEach(function() { 39 | onChange = jest.fn() 40 | component = mount() 41 | }) 42 | 43 | test('has a multiselection component', () => { 44 | expect(component.find('MultiSelection').exists()).toBe(true) 45 | }) 46 | }) 47 | 48 | describe("when the component's selection button is clicked", () => { 49 | test('should set the dialogOpen state to true', () => { 50 | let component = mount() 51 | 52 | component.find('Selection button').simulate('click') 53 | 54 | expect(component).toHaveState('dialogOpen', true) 55 | }) 56 | }) 57 | 58 | describe("when the component's multiselection button is clicked", () => { 59 | let component 60 | 61 | beforeEach(() => { 62 | component = mount() 63 | component.find('MultiSelection button').simulate('click') 64 | }) 65 | 66 | test('should set the dialogOpen state to true', () => { 67 | expect(component).toHaveState('dialogOpen', true) 68 | }) 69 | }) 70 | 71 | describe("when the component's dialogOpen state is true", () => { 72 | let component = null 73 | 74 | beforeEach(() => { 75 | component = mount() 76 | component.setState({ dialogOpen: true }) 77 | }) 78 | 79 | test('renders a picker component', () => { 80 | expect(component.find('Picker').exists()).toBe(true) 81 | }) 82 | }) 83 | 84 | test('warns about duplicate API responses', () => { 85 | let logger = jest.fn() 86 | 87 | let request = (_url, success, _error) => { 88 | success([{ id: 1 }, { id: 1 }]) 89 | return { abort() {} } 90 | } 91 | 92 | let component = mount() 93 | 94 | component.setState({ dialogOpen: true }) 95 | 96 | expect(logger).toHaveBeenCalledWith( 97 | 'error', 98 | expect.stringContaining('Duplicate records were returned from /test.json') 99 | ) 100 | }) 101 | 102 | test('updates picked state when receiving new props', () => { 103 | let component = mount() 104 | 105 | expect(component.state('picked')).toEqual([9]) 106 | 107 | component.setProps({ picked: 10 }) 108 | 109 | expect(component.state('picked')).toEqual([10]) 110 | }) 111 | }) 112 | -------------------------------------------------------------------------------- /src/components/__tests__/error-message-test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ErrorMessage from '../error-message' 3 | import { mount } from 'enzyme' 4 | 5 | describe('ErrorMessage', () => { 6 | describe('when given an error string', () => { 7 | test('renders', () => { 8 | let component = mount() 9 | expect(component.text()).toBe('test') 10 | }) 11 | }) 12 | 13 | describe('when given an error instance', () => { 14 | test('renders', () => { 15 | let component = mount() 16 | expect(component.text()).toBe('test') 17 | }) 18 | }) 19 | 20 | describe('when not given an error', () => { 21 | test('renders nothing', () => { 22 | let component = mount() 23 | expect(component.html()).toBe(null) 24 | }) 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /src/components/__tests__/figure-test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { mount } from 'enzyme' 3 | import Figure from '../figure' 4 | 5 | describe('Figure Component', () => { 6 | let record = { id: 0, url: '/test.jpg' } 7 | 8 | test('executes a callback that passes the record id when clicked', () => { 9 | let callback = jest.fn() 10 | let component = mount(
) 11 | 12 | component.simulate('click') 13 | 14 | expect(callback).toHaveBeenCalled() 15 | }) 16 | }) 17 | -------------------------------------------------------------------------------- /src/components/__tests__/multiselection-item-test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import MultiSelectionItem from '../multiselection-item' 3 | import Options from '../../contexts/options' 4 | import { mount } from 'enzyme' 5 | 6 | jest.useFakeTimers() 7 | 8 | describe('MultiSelectionItem', () => { 9 | test('renders a photo', () => { 10 | let component = mount( 11 | 12 | 13 | 14 | ) 15 | 16 | jest.runAllTimers() 17 | 18 | expect(component.render().find('img')).toHaveLength(1) 19 | }) 20 | 21 | test('renders empty', () => { 22 | let component = mount( 23 | 24 | 25 | 26 | ) 27 | 28 | jest.runAllTimers() 29 | 30 | expect(component.render().find('img')).toHaveLength(0) 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /src/components/__tests__/multiselection-test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import MultiSelection from '../multiselection' 3 | import Options from '../../contexts/options' 4 | import { mount } from 'enzyme' 5 | 6 | describe('MultiSelection', () => { 7 | test('renders multiple selections', () => { 8 | let component = mount( 9 | 10 | 11 | 12 | ) 13 | 14 | expect(component.find('.ars-multiselection-grid').exists()).toBe(true) 15 | }) 16 | 17 | test('renders an empty state', () => { 18 | let component = mount( 19 | 20 | 21 | 22 | ) 23 | 24 | expect(component.find('.ars-multiselection-grid').exists()).toBe(false) 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /src/components/__tests__/picker-test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Picker from '../picker' 3 | import Options from '../../contexts/options' 4 | import { mount } from 'enzyme' 5 | 6 | jest.useFakeTimers() 7 | 8 | describe('Picker', () => { 9 | let component = null 10 | 11 | function clickGalleryItem(index) { 12 | component 13 | .find('Gallery .ars-gallery-item Figure') 14 | .at(index) 15 | .simulate('click') 16 | } 17 | 18 | describe("when a picker's search input is changed", () => { 19 | test('updates its search state', () => { 20 | let component = mount( 21 | 22 | 23 | 24 | ) 25 | 26 | component.find('Search input').simulate('change', { 27 | target: { value: 'test' } 28 | }) 29 | 30 | jest.runAllTimers() 31 | 32 | expect(component).toHaveState('currentSearch', 'test') 33 | }) 34 | }) 35 | 36 | describe("when a picker's gallery has a selection", () => { 37 | beforeEach(() => { 38 | component = mount( 39 | 40 | 41 | 42 | ) 43 | jest.runAllTimers() 44 | component.update() 45 | }) 46 | 47 | test('updates its picked state', () => { 48 | clickGalleryItem(0) 49 | expect(component).toHaveState('picked', [0]) 50 | }) 51 | }) 52 | 53 | describe("when a multiselect picker's gallery has a selection", () => { 54 | beforeEach(() => { 55 | component = mount( 56 | 57 | 58 | 59 | ) 60 | jest.runAllTimers() 61 | component.update() 62 | }) 63 | 64 | test('updates its picked state', () => { 65 | clickGalleryItem(0) 66 | expect(component).toHaveState('picked', [0]) 67 | }) 68 | 69 | test('adds to its picked state', () => { 70 | clickGalleryItem(0) 71 | clickGalleryItem(1) 72 | expect(component).toHaveState('picked', [0, 1]) 73 | }) 74 | 75 | test('removes its picked state', () => { 76 | clickGalleryItem(0) 77 | clickGalleryItem(1) 78 | clickGalleryItem(1) 79 | expect(component).toHaveState('picked', [0]) 80 | }) 81 | }) 82 | 83 | describe("when a picker's clear selection button is clicked", () => { 84 | beforeEach(() => { 85 | component = mount( 86 | 87 | 88 | 89 | ) 90 | component.setState({ picked: [0] }) 91 | jest.runAllTimers() 92 | component.update() 93 | }) 94 | 95 | test('clears its picked state', () => { 96 | component.find('Button.ars-dialog-clear').simulate('click') 97 | expect(component).toHaveState('picked', []) 98 | }) 99 | }) 100 | 101 | describe("when a picker's confirm button is clicked", () => { 102 | beforeEach(() => { 103 | component = mount( 104 | 105 | 106 | 107 | ) 108 | 109 | component 110 | .find('.ars-dialog-confirm') 111 | .last() 112 | .simulate('click') 113 | }) 114 | 115 | test('triggers the exit callback', () => { 116 | expect(component.prop('onExit')).toHaveBeenCalled() 117 | }) 118 | 119 | test('triggers the onChange callback', () => { 120 | expect(component.prop('onChange')).toHaveBeenCalled() 121 | }) 122 | }) 123 | 124 | describe("when a picker's cancel button is clicked", () => { 125 | let component, onChange, onExit 126 | 127 | beforeEach(() => { 128 | onExit = jest.fn() 129 | onChange = jest.fn() 130 | component = mount( 131 | 132 | 133 | 134 | ) 135 | component 136 | .find('.ars-dialog-cancel') 137 | .last() 138 | .simulate('click') 139 | }) 140 | 141 | test('triggers the exit callback', () => { 142 | expect(onExit).toHaveBeenCalled() 143 | }) 144 | 145 | test('does not trigger the onChange callback', () => { 146 | expect(onChange).not.toHaveBeenCalled() 147 | }) 148 | }) 149 | 150 | describe('when a user pushes a key sequence in the gallery', () => { 151 | describe('and it is cmd+enter', () => { 152 | let component = null 153 | 154 | beforeEach(() => { 155 | component = mount( 156 | 157 | 158 | 159 | ) 160 | 161 | jest.runAllTimers() 162 | component.update() 163 | 164 | component.find('Gallery').simulate('keyDown', { 165 | key: 'Enter', 166 | metaKey: true 167 | }) 168 | }) 169 | 170 | test('triggers the exit callback', () => { 171 | expect(component.prop('onExit')).toHaveBeenCalled() 172 | }) 173 | 174 | test('trigger the onChange callback', () => { 175 | expect(component.prop('onChange')).toHaveBeenCalled() 176 | }) 177 | }) 178 | 179 | describe('and it is ctrl+enter', () => { 180 | let component = null 181 | 182 | beforeEach(() => { 183 | component = mount( 184 | 185 | 186 | 187 | ) 188 | 189 | jest.runAllTimers() 190 | component.update() 191 | 192 | component.find('Gallery').simulate('keydown', { 193 | key: 'Enter', 194 | ctrlKey: true 195 | }) 196 | }) 197 | 198 | test('triggers the exit callback', () => { 199 | expect(component.prop('onExit')).toHaveBeenCalled() 200 | }) 201 | 202 | test('trigger the onChange callback', () => { 203 | expect(component.prop('onChange')).toHaveBeenCalled() 204 | }) 205 | }) 206 | 207 | describe('and it does not include an option key', () => { 208 | let component = null 209 | 210 | beforeEach(() => { 211 | component = mount( 212 | 213 | 214 | 215 | ) 216 | 217 | jest.runAllTimers() 218 | component.update() 219 | 220 | component.find('Gallery').simulate('keydown', { 221 | key: 'Enter' 222 | }) 223 | }) 224 | 225 | test('does not trigger the exit callback', () => { 226 | expect(component.prop('onExit')).not.toHaveBeenCalled() 227 | }) 228 | 229 | test('does not trigger the onChange callback', () => { 230 | expect(component.prop('onChange')).not.toHaveBeenCalled() 231 | }) 232 | }) 233 | 234 | describe('when given an error', () => { 235 | let component = null 236 | 237 | beforeAll(() => { 238 | component = mount( 239 | 240 | 241 | 242 | ) 243 | jest.runAllTimers() 244 | component.update() 245 | }) 246 | 247 | test('displays the error', () => { 248 | expect(component.find('.ars-error').text()).toContain('Unable to load URL') 249 | }) 250 | }) 251 | }) 252 | 253 | describe('Display Modes', () => { 254 | let component = null 255 | 256 | beforeEach(() => { 257 | component = mount( 258 | 259 | 260 | 261 | ) 262 | jest.runAllTimers() 263 | component.update() 264 | }) 265 | 266 | it('can switch to table mode', () => { 267 | component.find('TableButton').simulate('click') 268 | 269 | expect(component.find('TableButton').prop('disabled')).toBe(true) 270 | expect(component.exists('TableView')).toBe(true) 271 | }) 272 | 273 | it('can switch to gallery mode', () => { 274 | component.find('TableButton').simulate('click') 275 | component.find('GalleryButton').simulate('click') 276 | 277 | expect(component.find('GalleryButton').prop('disabled')).toBe(true) 278 | expect(component.exists('Gallery')).toBe(true) 279 | }) 280 | }) 281 | 282 | test('disable autocomplete through options', () => { 283 | let component = mount( 284 | 285 | 286 | 287 | ) 288 | 289 | expect(component.exists('datalist')).toEqual(false) 290 | }) 291 | }) 292 | -------------------------------------------------------------------------------- /src/components/__tests__/search-test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Search from '../search' 3 | import { mount } from 'enzyme' 4 | 5 | jest.useFakeTimers() 6 | 7 | describe('Search', () => { 8 | test('triggers a blank search below 2 characters', () => { 9 | let callback = jest.fn() 10 | let component = mount() 11 | 12 | component.find('input').simulate('change') 13 | 14 | jest.runAllTimers() 15 | 16 | expect(callback).toHaveBeenCalledTimes(1) 17 | expect(callback).toHaveBeenCalledWith('') 18 | }) 19 | 20 | test('triggers the full term above 2 characters', () => { 21 | let callback = jest.fn() 22 | let component = mount() 23 | 24 | component.find('input').simulate('change', { target: { value: 'Large Enough' } }) 25 | 26 | jest.runAllTimers() 27 | 28 | expect(callback).toHaveBeenCalledTimes(1) 29 | expect(callback).toHaveBeenCalledWith('Large Enough') 30 | }) 31 | 32 | test('traps submit events and calls onQuery', () => { 33 | let callback = jest.fn() 34 | let component = mount() 35 | 36 | component.find('input').simulate('change', { target: { value: 'Large Enough' } }) 37 | 38 | component.simulate('submit') 39 | 40 | jest.runAllTimers() 41 | 42 | expect(callback).toHaveBeenCalledTimes(1) 43 | expect(callback).toHaveBeenCalledWith('Large Enough') 44 | }) 45 | 46 | test('clears search on escape', () => { 47 | let callback = jest.fn() 48 | let component = mount() 49 | 50 | component.find('input').simulate('keyup', { key: 'Escape' }) 51 | 52 | jest.runAllTimers() 53 | 54 | expect(callback).toHaveBeenCalledTimes(1) 55 | expect(callback).toHaveBeenCalledWith('') 56 | }) 57 | 58 | test('when created with autoComplete false', () => { 59 | let component = mount() 60 | expect(component.exists('datalist')).toEqual(false) 61 | }) 62 | }) 63 | -------------------------------------------------------------------------------- /src/components/__tests__/selection-figure-test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import SelectionFigure from '../selection-figure' 3 | import { mount } from 'enzyme' 4 | 5 | describe('SelectionFigure', () => { 6 | describe('when given a name', () => { 7 | test('renders a title', () => { 8 | let component = mount() 9 | 10 | expect(component.find('.ars-selection-title').text()).toContain('Ars') 11 | }) 12 | }) 13 | 14 | describe('when not given a name', () => { 15 | test('handles null', () => { 16 | let component = mount() 17 | 18 | expect(component.find('.ars-selection-title').exists()).toBe(false) 19 | }) 20 | 21 | test('handles undefined', () => { 22 | let component = mount() 23 | 24 | expect(component.find('.ars-selection-title').exists()).toBe(false) 25 | }) 26 | }) 27 | 28 | describe('when given a caption', () => { 29 | test('renders a caption', () => { 30 | let component = mount() 31 | 32 | expect(component.find('.ars-selection-caption').text()).toContain('Ars') 33 | }) 34 | }) 35 | 36 | describe('when not given a caption', () => { 37 | test('handles null', () => { 38 | let component = mount() 39 | 40 | expect(component.find('.ars-selection-caption').exists()).toBe(false) 41 | }) 42 | 43 | test('handles undefined', () => { 44 | let component = mount() 45 | 46 | expect(component.find('.ars-selection-caption').exists()).toBe(false) 47 | }) 48 | }) 49 | }) 50 | -------------------------------------------------------------------------------- /src/components/__tests__/selection-test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Selection from '../selection' 3 | import SelectionFigure from '../selection-figure' 4 | import { mount } from 'enzyme' 5 | 6 | jest.useFakeTimers() 7 | 8 | describe('Selection', () => { 9 | test('renders a photo given a valid record id', () => { 10 | let component = mount() 11 | 12 | jest.runAllTimers() 13 | component.update() 14 | 15 | expect(component.find(SelectionFigure).exists()).toBe(true) 16 | }) 17 | 18 | test('does not render a photo when props.id is falsey', () => { 19 | let component = mount() 20 | 21 | jest.runAllTimers() 22 | component.update() 23 | 24 | expect(component.find(SelectionFigure).exists()).toBe(true) 25 | 26 | component.setProps({ id: null }) 27 | 28 | expect(component.find(SelectionFigure).exists()).toBe(false) 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /src/components/__tests__/selection-text-test.js: -------------------------------------------------------------------------------- 1 | import selectionText from '../selection-text' 2 | 3 | let item = {} 4 | let fetching = true 5 | let isPlural = true 6 | 7 | describe('selectionText', () => { 8 | describe('when the selection is empty', () => { 9 | let text = selectionText({}) 10 | 11 | test('has the correct text', () => { 12 | expect(text).toBe('Pick a photo') 13 | }) 14 | }) 15 | 16 | describe('when the selection is not empty', () => { 17 | let text = selectionText({ item }) 18 | 19 | test('has the correct text', () => { 20 | expect(text).toBe('Pick a different photo') 21 | }) 22 | }) 23 | 24 | describe('when the selection is loading', () => { 25 | let text = selectionText({ fetching }) 26 | 27 | test('has the correct text', () => { 28 | expect(text).toBe('Loading photo') 29 | }) 30 | }) 31 | 32 | describe('when the selection is empty and the resource is plural', () => { 33 | let text = selectionText({ isPlural }) 34 | 35 | test('has the correct text', () => { 36 | expect(text).toBe('Pick photos') 37 | }) 38 | }) 39 | 40 | describe('when the selection is not empty and the resource is plural', () => { 41 | let text = selectionText({ isPlural, item }) 42 | 43 | test('has the correct text', () => { 44 | expect(text).toBe('Pick different photos') 45 | }) 46 | }) 47 | }) 48 | -------------------------------------------------------------------------------- /src/components/__tests__/truncated-test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Truncated from '../truncated' 3 | import { mount } from 'enzyme' 4 | 5 | describe('Truncated', () => { 6 | test('handles undefined values', () => { 7 | let component = mount() 8 | expect(component.text()).toBe('') 9 | }) 10 | 11 | test('handles numbers', () => { 12 | let component = mount() 13 | expect(component.text()).toBe('1') 14 | }) 15 | 16 | test('limits text', () => { 17 | let component = mount() 18 | expect(component.text()).toBe('This will…') 19 | }) 20 | 21 | test('does not add ellipsis to short text', () => { 22 | let component = mount() 23 | expect(component.text()).toBe('Short') 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /src/components/animation.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * For the gallery and table view, calculate the delay 3 | * with which to present items to a user 4 | */ 5 | 6 | export function itemAnimationDelay(index: number): number { 7 | return 100 + index * 45 8 | } 9 | -------------------------------------------------------------------------------- /src/components/ars.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Ars 3 | * The main element for Ars Arsenal 4 | */ 5 | 6 | import * as React from 'react' 7 | import cx from 'classnames' 8 | import Picker from './picker' 9 | import Selection from './selection' 10 | import MultiSelection from './multiselection' 11 | import OptionsContext from '../contexts/options' 12 | import { ID } from '../record' 13 | import { DEFAULT_OPTIONS, ArsOptions } from '../options' 14 | 15 | interface State { 16 | dialogOpen: boolean 17 | picked: ID[] 18 | } 19 | 20 | export default class Ars extends React.Component { 21 | static defaultProps = DEFAULT_OPTIONS 22 | 23 | constructor(props: ArsOptions) { 24 | super(props) 25 | 26 | this.state = { 27 | dialogOpen: false, 28 | picked: [].concat(props.picked || []) 29 | } 30 | } 31 | 32 | componentDidUpdate(lastProps: ArsOptions) { 33 | if (lastProps.picked != this.props.picked) { 34 | this.setState({ 35 | picked: [].concat(this.props.picked || []) 36 | }) 37 | } 38 | } 39 | 40 | getPicker() { 41 | let { picked } = this.state 42 | let { columns, multiselect, mode } = this.props 43 | 44 | return ( 45 | 53 | ) 54 | } 55 | 56 | renderSelection() { 57 | let { multiselect, resource } = this.props 58 | let { picked } = this.state 59 | 60 | if (multiselect) { 61 | return ( 62 | 68 | ) 69 | } 70 | 71 | return ( 72 | 78 | ) 79 | } 80 | 81 | render() { 82 | let { rootAttributes } = this.props 83 | let { dialogOpen } = this.state 84 | 85 | let rootClass = cx('ars', rootAttributes.className) 86 | 87 | return ( 88 | 89 |
90 | {this.renderSelection()} 91 | {dialogOpen && this.getPicker()} 92 |
93 |
94 | ) 95 | } 96 | 97 | private triggerChange = () => { 98 | let { picked } = this.state 99 | this.props.onChange(this.props.multiselect ? picked : picked[0]) 100 | } 101 | 102 | private onOpenClick = () => { 103 | this.setState({ dialogOpen: true }) 104 | } 105 | 106 | private onGalleryPicked = (picked: ID[]) => { 107 | this.setState({ picked }, this.triggerChange) 108 | } 109 | 110 | private onClear = () => { 111 | this.setState({ picked: [] }, this.triggerChange) 112 | } 113 | 114 | private onExit = (event?: React.SyntheticEvent) => { 115 | if (event) { 116 | event.preventDefault() 117 | } 118 | 119 | this.setState({ dialogOpen: false }) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/components/datalist.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Datalist 3 | */ 4 | 5 | import * as React from 'react' 6 | import { Record } from '../record' 7 | 8 | interface Props { 9 | id: string 10 | items: Record[] 11 | } 12 | 13 | function getOption(record: Record) { 14 | return 15 | } 16 | 17 | export const DataList: React.SFC = ({ id, items = [] }) => { 18 | return {items.map(getOption)} 19 | } 20 | -------------------------------------------------------------------------------- /src/components/empty.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | interface Props { 4 | fetching: boolean 5 | search: string 6 | } 7 | 8 | const Empty: React.SFC = ({ fetching, search }) => { 9 | if (fetching) { 10 | return

Awaiting data...

11 | } 12 | return

No items exist {search ? `for “${search}”.` : ''}

13 | } 14 | 15 | export default Empty 16 | -------------------------------------------------------------------------------- /src/components/error-message.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * ErrorMessage 3 | * Displays error information should an endpoint fail to respond 4 | */ 5 | 6 | import * as React from 'react' 7 | 8 | interface Props { 9 | error?: Error | string 10 | } 11 | 12 | const ErrorMessage: React.SFC = ({ error }) => { 13 | if (!error) { 14 | return null 15 | } 16 | 17 | return
{error instanceof Error ? error.message : error}
18 | } 19 | 20 | export default ErrorMessage 21 | -------------------------------------------------------------------------------- /src/components/figure.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Figure 3 | * An individual gallery tile 4 | */ 5 | 6 | import * as React from 'react' 7 | import Ink from 'react-ink' 8 | import cx from 'classnames' 9 | import Image from './ui/image' 10 | import { Record, ID } from '../record' 11 | 12 | interface Props { 13 | record: Record 14 | onClick: (id: ID, picked: boolean) => void 15 | picked: boolean 16 | } 17 | 18 | const Figure: React.SFC = ({ record, picked, onClick }) => { 19 | const className = cx('ars-fig', { 20 | 'ars-fig-picked': picked 21 | }) 22 | 23 | const clickHandler = (event: React.MouseEvent) => { 24 | event.preventDefault() 25 | onClick(record.id, !picked) 26 | } 27 | 28 | return ( 29 | 34 | ) 35 | } 36 | 37 | export default Figure 38 | -------------------------------------------------------------------------------- /src/components/gallery-button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import Button from './ui/button' 3 | import { GalleryIcon } from '../icons' 4 | 5 | interface Props { 6 | onClick: (type: string) => void 7 | disabled: boolean 8 | } 9 | 10 | const GalleryButton: React.SFC = ({ disabled, onClick }) => { 11 | return ( 12 | 20 | ) 21 | } 22 | 23 | export default GalleryButton 24 | -------------------------------------------------------------------------------- /src/components/gallery-panel.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Record } from '../record' 3 | import FocusTrap from 'react-focus-trap' 4 | import TagList from './tag-list' 5 | 6 | interface GalleryPanelProps { 7 | record?: Record 8 | onTagClick: (tag: string) => void 9 | onExit: () => void 10 | } 11 | 12 | const GalleryPanel: React.SFC = ({ onTagClick, onExit, record }) => { 13 | if (!record) { 14 | return null 15 | } 16 | 17 | return ( 18 | 44 | ) 45 | } 46 | 47 | const autoFocus = (el: HTMLElement) => el && el.focus() 48 | 49 | const exitIfEscaped = (callback: () => void) => { 50 | return (event: React.KeyboardEvent) => { 51 | if (event.key === 'Escape') { 52 | event.stopPropagation() 53 | callback() 54 | } 55 | } 56 | } 57 | 58 | export default GalleryPanel 59 | -------------------------------------------------------------------------------- /src/components/gallery.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Gallery 3 | * Displays tiles of photos 4 | */ 5 | 6 | import * as React from 'react' 7 | import { CSSTransition, TransitionGroup } from 'react-transition-group' 8 | import Figure from './figure' 9 | import GalleryPanel from './gallery-panel' 10 | import { Record, ID } from '../record' 11 | import { itemAnimationDelay } from './animation' 12 | 13 | interface Props { 14 | items: Record[] 15 | picked: ID[] 16 | onPicked: (id: ID) => void 17 | onKeyDown: (event: React.KeyboardEvent) => void 18 | onTagClick: (tag: String) => void 19 | } 20 | 21 | interface State { 22 | focus: Record | null 23 | } 24 | 25 | export default class Gallery extends React.PureComponent { 26 | offset: number = 0 27 | 28 | state: State = { 29 | focus: null 30 | } 31 | 32 | isPicked(id: ID) { 33 | const { picked } = this.props 34 | 35 | return picked.indexOf(id) >= 0 36 | } 37 | 38 | trackMount = (index: number) => { 39 | this.offset = Math.max(this.offset, itemAnimationDelay(index)) 40 | } 41 | 42 | getItem(record: Record, index: number, list: Record[]) { 43 | const { onPicked } = this.props 44 | 45 | let isPicked = this.isPicked(record.id) 46 | let delay = Math.max(0, itemAnimationDelay(index) - this.offset) 47 | 48 | return ( 49 | 58 |
63 |
64 | 71 |
72 |
73 | ) 74 | } 75 | 76 | render() { 77 | let { items, onKeyDown, onTagClick } = this.props 78 | let { focus } = this.state 79 | 80 | return ( 81 |
82 | 83 | {items.map(this.getItem, this)} 84 | 85 | 86 |
87 | ) 88 | } 89 | 90 | setFocus = (focus: Record) => { 91 | this.setState({ focus }) 92 | } 93 | 94 | clearFocus = () => { 95 | this.setState({ focus: null }) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/components/multiselection-item.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * MultiSelectionItem 3 | */ 4 | 5 | import * as React from 'react' 6 | import cx from 'classnames' 7 | import Image from './ui/image' 8 | import SelectionNotFound from './ui/selection-not-found' 9 | import LoadRecord, { RecordResult } from '../containers/load-record' 10 | import { RefreshIcon } from '../icons' 11 | import { ID, Record } from '../record' 12 | 13 | interface Props { 14 | id: ID | null 15 | resource: string 16 | } 17 | 18 | export default class MultiSelectionItem extends React.Component { 19 | getPhoto(photo: Record | null) { 20 | return photo ? {photo.name} : null 21 | } 22 | 23 | renderContent({ data, fetching, initialized }: RecordResult) { 24 | if (initialized && !fetching && data == null) { 25 | return 26 | } 27 | 28 | let className = cx('ars-multiselection-cell', { 29 | 'ars-is-loading': fetching, 30 | 'ars-has-photo': data 31 | }) 32 | 33 | return ( 34 |
35 | {fetching ? : null} 36 | {this.getPhoto(data)} 37 |
38 | ) 39 | } 40 | 41 | render() { 42 | return 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/components/multiselection.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * MultiSelection 3 | */ 4 | 5 | import * as React from 'react' 6 | import cx from 'classnames' 7 | import Button from './ui/button' 8 | import MultiSelectionItem from './multiselection-item' 9 | import selectionText from './selection-text' 10 | import { ID } from '../record' 11 | 12 | interface Props { 13 | resource?: string 14 | ids: ID[] 15 | onEdit: (event: React.MouseEvent) => void 16 | onClear: () => void 17 | } 18 | 19 | export default class MultiSelection extends React.Component { 20 | static defaultProps: Props = { 21 | ids: [], 22 | onClick: event => {} 23 | } 24 | 25 | getItems() { 26 | let { ids, resource } = this.props 27 | 28 | if (!ids.length) { 29 | return null 30 | } 31 | 32 | return ( 33 |
34 | {ids.map(id => ( 35 | 36 | ))} 37 |
38 | ) 39 | } 40 | 41 | render() { 42 | const { resource, ids, onEdit, onClear } = this.props 43 | 44 | let hasPicked = ids.length 45 | 46 | let title = selectionText({ 47 | resource: resource, 48 | item: hasPicked, 49 | isPlural: true 50 | }) 51 | 52 | let className = cx('ars-multiselection', { 53 | 'ars-has-photo': hasPicked 54 | }) 55 | 56 | return ( 57 |
58 | {this.getItems()} 59 | 60 |
61 | 64 | 65 | 73 |
74 |
75 | ) 76 | } 77 | 78 | onClick = (event: React.MouseEvent) => { 79 | event.preventDefault() 80 | this.props.onClick(event) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/components/picker.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Picker 3 | * The a modal that appears to select a gallery image 4 | */ 5 | 6 | import * as React from 'react' 7 | import Button from './ui/button' 8 | import ErrorMessage from './error-message' 9 | import FocusTrap from 'react-focus-trap' 10 | import Gallery from './gallery' 11 | import Search from './search' 12 | import TableView from './table-view' 13 | import LoadCollection, { CollectionResult } from '../containers/load-collection' 14 | import Empty from './empty' 15 | import GalleryButton from './gallery-button' 16 | import TableButton from './table-button' 17 | import { ID, Record } from '../record' 18 | import { ArsColumn, SortableColumn, ArsMode } from '../options' 19 | import OptionsContext from '../contexts/options' 20 | 21 | type Mode = 'gallery' | 'table' 22 | 23 | interface Props { 24 | columns?: ArsColumn[] 25 | mode: ArsMode 26 | multiselect: boolean 27 | onChange: (selection: ID[]) => void 28 | onExit: () => void 29 | picked: Array 30 | } 31 | 32 | interface State { 33 | mode: Mode 34 | picked: ID[] 35 | currentSearch: string 36 | queriedSearch: string 37 | sort: SortableColumn 38 | } 39 | 40 | export default class Picker extends React.PureComponent { 41 | static contextType = OptionsContext 42 | static defaultProps: Props = { 43 | mode: 'gallery', 44 | multiselect: false, 45 | onChange: () => {}, 46 | onExit: () => {}, 47 | picked: [] 48 | } 49 | 50 | constructor(props: Props) { 51 | super(props) 52 | 53 | this.state = { 54 | mode: props.mode, 55 | picked: props.picked, 56 | currentSearch: '', 57 | queriedSearch: '', 58 | sort: 'id' 59 | } 60 | } 61 | 62 | confirm() { 63 | this.props.onChange(this.state.picked) 64 | this.props.onExit() 65 | } 66 | 67 | renderItems(data: Record[], fetching: boolean) { 68 | const { columns, multiselect } = this.props 69 | const { mode, picked, queriedSearch, sort } = this.state 70 | 71 | if (data.length === 0) { 72 | return 73 | } 74 | 75 | if (mode === 'table') { 76 | return ( 77 | 88 | ) 89 | } 90 | 91 | return ( 92 | 99 | ) 100 | } 101 | 102 | renderContent = ({ data, fetching, error }: CollectionResult) => { 103 | const { onExit } = this.props 104 | const { mode, currentSearch } = this.state 105 | 106 | return ( 107 | 108 |
109 | 116 | 117 | 118 | 119 |
120 | 121 | 122 | 123 | {this.renderItems(data, fetching)} 124 | 125 |
126 | 133 | 134 |
135 | 142 | 150 |
151 |
152 |
153 | ) 154 | } 155 | 156 | render() { 157 | let { sort, queriedSearch } = this.state 158 | 159 | return 160 | } 161 | 162 | onTagClick = (tag: string) => { 163 | this.setState({ currentSearch: tag, queriedSearch: tag }) 164 | } 165 | 166 | onSort = (sort: SortableColumn) => { 167 | this.setState({ sort }) 168 | } 169 | 170 | setMode = mode => { 171 | this.setState({ mode }) 172 | } 173 | 174 | onClear = () => { 175 | this.setState({ picked: [] }) 176 | } 177 | 178 | onSearchChange = (currentSearch: string) => { 179 | this.setState({ currentSearch }) 180 | } 181 | 182 | onQueryChange = (queriedSearch: string) => { 183 | this.setState({ queriedSearch }) 184 | } 185 | 186 | onPicked = (picked: ID, shouldAdd?: Boolean) => { 187 | let next = this.props.multiselect ? this.onMultiPicked([].concat(picked), shouldAdd) : [picked] 188 | 189 | this.setState({ picked: next }) 190 | } 191 | 192 | onMultiPicked(picked: ID[], shouldAdd: Boolean): ID[] { 193 | let pool = new Set(this.state.picked || []) 194 | 195 | picked.forEach(function(item) { 196 | if (shouldAdd) { 197 | pool.add(item) 198 | } else { 199 | pool.delete(item) 200 | } 201 | }) 202 | 203 | let next: ID[] = [] 204 | pool.forEach((item: ID) => next.push(item)) 205 | 206 | return next 207 | } 208 | 209 | onConfirm = (event: React.SyntheticEvent) => { 210 | event.preventDefault() 211 | this.confirm() 212 | } 213 | 214 | onKeyDown = (event: React.KeyboardEvent) => { 215 | if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) { 216 | event.preventDefault() 217 | event.stopPropagation() 218 | 219 | this.confirm() 220 | } 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /src/components/scroll-monitor.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { findDOMNode } from 'react-dom' 3 | 4 | interface Props { 5 | page: number 6 | refresh: string 7 | onPage: (number: number) => void 8 | } 9 | 10 | class ScrollMonitor extends React.Component { 11 | teardown = () => {} 12 | lastChild: HTMLElement = null 13 | container: HTMLElement = null 14 | 15 | getElement(): HTMLElement { 16 | return findDOMNode(this) as HTMLElement 17 | } 18 | 19 | getScrollContainer(): HTMLElement { 20 | return this.getElement().querySelector('[data-scroll-container]') 21 | } 22 | 23 | getScrollTrigger(): HTMLElement | null { 24 | return this.getElement().querySelector('[data-scroll="true"]') 25 | } 26 | 27 | subscribe() { 28 | let element = this.getScrollContainer() 29 | 30 | if (element != null && element !== this.container) { 31 | this.container = element 32 | element.addEventListener('scroll', this.check, { passive: true }) 33 | this.teardown = () => element.removeEventListener('scroll', this.check) 34 | this.check() 35 | } 36 | } 37 | 38 | componentDidMount() { 39 | this.subscribe() 40 | } 41 | 42 | componentDidUpdate(lastProps: Props) { 43 | if (this.props.page < lastProps.page) { 44 | this.lastChild = null 45 | } 46 | 47 | if (this.props.refresh != lastProps.refresh) { 48 | this.resetScroll() 49 | this.check() 50 | } 51 | 52 | this.subscribe() 53 | } 54 | 55 | componentWillUnmount() { 56 | this.teardown() 57 | } 58 | 59 | resetScroll() { 60 | if (this.container) { 61 | this.container.scrollTop = 0 62 | } 63 | } 64 | 65 | check = () => { 66 | let endChild = this.getScrollTrigger() 67 | 68 | if (!endChild || endChild === this.lastChild) { 69 | return 70 | } 71 | 72 | // This value represents the lower fence a child element a child must 73 | // cross through to trigger a new page 74 | let lowerFence = this.container.scrollTop 75 | 76 | // Start by pushing the fence to the bottom of the container element 77 | // Then add the look ahead value; how far below the viewable window to 78 | // proactively fetch a new page. 79 | lowerFence += this.container.offsetHeight * 2 80 | 81 | // If the end child's offset in the container is less than the lower 82 | // fence, trigger pagination 83 | if (lowerFence > endChild.offsetTop) { 84 | this.lastChild = endChild 85 | this.props.onPage(this.props.page + 1) 86 | } 87 | } 88 | 89 | render() { 90 | return this.props.children 91 | } 92 | } 93 | 94 | export default ScrollMonitor 95 | -------------------------------------------------------------------------------- /src/components/search.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Search 3 | */ 4 | 5 | import * as React from 'react' 6 | import { DataList } from './datalist' 7 | import { SearchIcon } from '../icons' 8 | import { Record } from '../record' 9 | 10 | interface Props { 11 | data: Record[] 12 | search: string 13 | autoComplete: boolean 14 | onChange(search: string): void 15 | onQuery(search: string): void 16 | } 17 | 18 | let uid = 0 19 | 20 | // The minimum number of characters before searching 21 | const THRESHOLD = 2 22 | 23 | // The minimum time between change events 24 | const INTERVAL = 150 25 | 26 | export default class Search extends React.Component { 27 | id: number = uid++ 28 | timer: number = null 29 | 30 | componentWillUnmount() { 31 | window.clearTimeout(this.timer) 32 | } 33 | 34 | render() { 35 | let { search, autoComplete, data } = this.props 36 | let inputId = `ars_search_${this.id}` 37 | let listId = `ars_search_list_${this.id}` 38 | 39 | let autoCompleteOpts = {} 40 | if (!autoComplete) { 41 | autoCompleteOpts['autoComplete'] = 'off' 42 | } 43 | 44 | return ( 45 |
46 | 49 | 60 | {autoComplete && } 61 | 62 | 63 | ) 64 | } 65 | 66 | private update() { 67 | clearTimeout(this.timer) 68 | 69 | this.timer = window.setTimeout(() => { 70 | const { search } = this.props 71 | this.props.onQuery(search.length >= THRESHOLD ? search : '') 72 | }, INTERVAL) 73 | } 74 | 75 | private onChange(event: React.ChangeEvent) { 76 | this.props.onChange(event.target.value) 77 | this.update() 78 | } 79 | 80 | private onSubmit(event: React.FormEvent) { 81 | event.preventDefault() 82 | this.update() 83 | } 84 | 85 | private onKeyUp(event: React.KeyboardEvent) { 86 | if (event.key === 'Escape') { 87 | event.stopPropagation() 88 | this.setState({ search: '' }, this.update.bind(this)) 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/components/selection-figure.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Selection Figure 3 | */ 4 | 5 | import * as React from 'react' 6 | import Image from './ui/image' 7 | import { Record, EmptyRecord } from '../record' 8 | 9 | interface Props { 10 | item: Record 11 | } 12 | 13 | export default class SelectionFigure extends React.Component { 14 | getTitle(title: string) { 15 | let trimmed = title ? title.trim() : '' 16 | 17 | return trimmed.length ?

{trimmed}

: null 18 | } 19 | 20 | getCaption(caption: string) { 21 | let trimmed = caption ? caption.trim() : '' 22 | 23 | return trimmed.length ?

{trimmed}

: null 24 | } 25 | 26 | render() { 27 | let { caption = '', name = '', url } = this.props.item 28 | 29 | return ( 30 |
31 | {caption} 32 |
33 | {this.getTitle(name)} 34 | {this.getCaption(caption)} 35 |
36 |
37 | ) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/components/selection-text.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * SelectionText 3 | */ 4 | 5 | import articleFor from '../utils/article-for' 6 | import pluralize from '../utils/pluralize' 7 | 8 | interface Options { 9 | resource: string 10 | fetching: boolean 11 | item: boolean 12 | isPlural: boolean 13 | } 14 | 15 | export default function selectionText(options: Options) { 16 | let resource = options.resource || 'Photo' 17 | let fetching = options.fetching 18 | let isPlural = options.isPlural 19 | let item = options.item 20 | 21 | let noun = getNoun(resource, isPlural) 22 | 23 | if (fetching) { 24 | return getLoadingText(noun) 25 | } 26 | 27 | if (item) { 28 | return getSelectedText(noun, isPlural) 29 | } 30 | 31 | return getEmptyText(articleFor(noun), noun, isPlural) 32 | } 33 | 34 | function getNoun(resource: string, isPlural: boolean) { 35 | let noun = resource.toLowerCase() 36 | 37 | if (isPlural) { 38 | noun = pluralize(noun) 39 | } 40 | 41 | return noun 42 | } 43 | 44 | function getEmptyText(article: string, noun: string, isPlural: boolean) { 45 | let a = isPlural ? ' ' : ` ${article} ` 46 | return `Pick${a}${noun}` 47 | } 48 | 49 | function getSelectedText(noun: string, isPlural: boolean) { 50 | let a = isPlural ? ' ' : ' a ' 51 | return `Pick${a}different ${noun}` 52 | } 53 | 54 | function getLoadingText(noun: string) { 55 | return `Loading ${noun}` 56 | } 57 | -------------------------------------------------------------------------------- /src/components/selection.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Selection 3 | */ 4 | 5 | import * as React from 'react' 6 | import cx from 'classnames' 7 | import Button from './ui/button' 8 | import SelectionFigure from './selection-figure' 9 | import SelectionNotFound from './ui/selection-not-found' 10 | import selectionText from './selection-text' 11 | import { EditIcon, ClearIcon } from '../icons' 12 | import LoadRecord, { RecordResult } from '../containers/load-record' 13 | import { Record, ID } from '../record' 14 | 15 | interface Props { 16 | resource: string 17 | id: ID | null 18 | onEdit: (event: React.SyntheticEvent) => void 19 | onClear: () => void 20 | } 21 | 22 | export default class Selection extends React.Component { 23 | getPhoto(data: Record | null, fetching: Boolean, initialized: Boolean) { 24 | if (initialized && !fetching && data == null) { 25 | return 26 | } 27 | 28 | let showPhoto = data != null && this.props.id != null 29 | 30 | return showPhoto ? : null 31 | } 32 | 33 | renderContent({ data, fetching, initialized }: RecordResult) { 34 | let { resource, onEdit, onClear } = this.props 35 | 36 | let hasPicked = this.props.id != null 37 | 38 | let className = cx('ars-selection', { 39 | 'ars-is-loading': fetching, 40 | 'ars-has-photo': hasPicked 41 | }) 42 | 43 | let title = selectionText({ item: hasPicked, fetching, resource }) 44 | 45 | return ( 46 |
47 | {this.getPhoto(data, fetching, initialized)} 48 | 49 |
50 | 53 | 61 |
62 |
63 | ) 64 | } 65 | 66 | render() { 67 | return 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/components/table-button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import Button from './ui/button' 3 | import { TableIcon } from '../icons' 4 | 5 | interface Props { 6 | onClick: (type: String) => void 7 | disabled: boolean 8 | } 9 | 10 | const TableButton: React.SFC = ({ disabled, onClick }) => { 11 | return ( 12 | 20 | ) 21 | } 22 | 23 | export default TableButton 24 | -------------------------------------------------------------------------------- /src/components/table-view/checker.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { ID } from '../../record' 3 | 4 | interface Props { 5 | checked: boolean 6 | disabled?: boolean 7 | id: ID | ID[] 8 | multiselect: boolean 9 | name: string 10 | onChange: (id: ID | ID[]) => void 11 | } 12 | 13 | const Checker: React.SFC = props => { 14 | const { multiselect, checked, disabled, id, name, onChange } = props 15 | 16 | if (disabled) { 17 | return null 18 | } 19 | 20 | return ( 21 | 33 | ) 34 | } 35 | 36 | Checker.defaultProps = { 37 | disabled: false 38 | } 39 | 40 | export default Checker 41 | -------------------------------------------------------------------------------- /src/components/table-view/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import TableHeading from './table-heading' 3 | import Checker from './checker' 4 | import Tag from '../tag' 5 | import Truncated from '../truncated' 6 | import cx from 'classnames' 7 | import { ArsColumn, SortableColumn, DEFAULT_OPTIONS } from '../../options' 8 | import { Record, ID } from '../../record' 9 | import { itemAnimationDelay } from '../animation' 10 | 11 | interface Props { 12 | columns: ArsColumn[] 13 | items: Record[] 14 | multiselect: boolean 15 | picked: ID[] 16 | sort: SortableColumn 17 | onKeyDown: (event: React.SyntheticEvent) => void 18 | onPicked: (ids: ID) => void 19 | onSort: (field: SortableColumn) => void 20 | onTagClick: (tag: String) => void 21 | } 22 | 23 | class TableView extends React.PureComponent { 24 | mounted?: boolean = false 25 | timer?: NodeJS.Timeout = null 26 | 27 | static defaultProps: Props = { 28 | picked: [], 29 | items: [], 30 | columns: DEFAULT_OPTIONS.columns, 31 | multiselect: false, 32 | onKeyDown: event => {}, 33 | onPicked: ids => {}, 34 | onSort: field => {}, 35 | onTagClick: tag => {}, 36 | sort: null 37 | } 38 | 39 | componentDidUpdate() { 40 | if (this.mounted) { 41 | return true 42 | } 43 | 44 | clearTimeout(this.timer) 45 | 46 | this.timer = setTimeout(() => { 47 | this.mounted = true 48 | }, itemAnimationDelay(this.props.items.length)) 49 | } 50 | 51 | isPicked(id: ID) { 52 | return this.props.picked.indexOf(id) >= 0 53 | } 54 | 55 | renderRow(item: Record, index: number, list: Record[]) { 56 | const { id, name, attribution, caption, url, tags } = item 57 | const { multiselect, onPicked, onTagClick } = this.props 58 | 59 | let animationDelay = itemAnimationDelay(index) + 'ms' 60 | let checked = this.isPicked(id) 61 | 62 | let className = cx('ars-table-row', { 63 | 'ars-table-animate': !this.mounted, 64 | 'ars-table-selected': checked 65 | }) 66 | 67 | return ( 68 | 74 | 75 | 82 | 83 | 84 | {id} 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | {this.renderTagList(item)} 97 | 98 | 99 |
100 | 101 |
102 | 103 | 104 | ) 105 | } 106 | renderTagList(record: Record) { 107 | if (Array.isArray(record.tags) === false) { 108 | return null 109 | } 110 | 111 | return record.tags.map((tag, i) => ) 112 | } 113 | changeSort = (field: SortableColumn) => { 114 | this.props.onSort(field) 115 | } 116 | 117 | canRender(field: ArsColumn): boolean { 118 | return this.props.columns.indexOf(field) >= 0 119 | } 120 | 121 | render() { 122 | const { items, multiselect, sort, onKeyDown, onPicked } = this.props 123 | 124 | let ids = items.map((record: Record) => record.id) 125 | let selected = ids.filter(this.isPicked, this) 126 | let allPicked = selected.length === ids.length 127 | 128 | return ( 129 |
130 | 131 | 132 | 133 | 145 | 151 | ID 152 | 153 | 159 | Name 160 | 161 | 167 | Caption 168 | 169 | 175 | Attribution 176 | 177 | 178 | Tags 179 | 180 | 181 | Preview 182 | 183 | 184 | 185 | {items.map(this.renderRow, this)} 186 |
134 | Use this column to select items 135 | 136 | 144 |
187 |
188 | ) 189 | } 190 | } 191 | 192 | export default TableView 193 | -------------------------------------------------------------------------------- /src/components/table-view/table-heading.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import cx from 'classnames' 3 | import { ArsColumn } from '../../options' 4 | 5 | interface Props { 6 | active: boolean 7 | children: React.ReactNode 8 | field: string 9 | onSort?: (string: ArsColumn) => void 10 | show: boolean 11 | } 12 | 13 | const TableHeading: React.SFC = props => { 14 | const { active, children, field, onSort, show } = props 15 | 16 | const className = cx(`ars-table-heading ars-table-${field}`, { 17 | 'ars-active': active, 18 | 'ars-sortable': !active && !!onSort 19 | }) 20 | 21 | return ( 22 | 23 | {children} 24 | 25 | ) 26 | } 27 | 28 | export default TableHeading 29 | -------------------------------------------------------------------------------- /src/components/tag-list.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Record } from '../record' 3 | import Tag from './tag' 4 | 5 | interface TagListProps { 6 | className?: string 7 | record: Record 8 | onTagClick: (tag: string) => void 9 | } 10 | 11 | const TagList: React.SFC = ({ className, record, onTagClick }) => { 12 | if (Array.isArray(record.tags) === false) { 13 | return null 14 | } 15 | 16 | return ( 17 |
18 | {record.tags.map((tag, i) => ( 19 | 20 | ))} 21 |
22 | ) 23 | } 24 | 25 | export default TagList 26 | -------------------------------------------------------------------------------- /src/components/tag.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | interface TagProps { 4 | tag: string 5 | onClick: (tag: string) => void 6 | } 7 | 8 | const Tag: React.SFC = props => { 9 | const onClick = (event: React.SyntheticEvent) => { 10 | event.preventDefault() 11 | props.onClick(props.tag) 12 | } 13 | 14 | const title = `Search by ${props.tag}` 15 | 16 | return ( 17 | 20 | ) 21 | } 22 | 23 | export default Tag 24 | -------------------------------------------------------------------------------- /src/components/truncated.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | interface TruncatedProps { 4 | text: string 5 | limit?: number 6 | } 7 | 8 | const Truncated: React.SFC = ({ text, limit }) => { 9 | let fullText = `${text || ''}`.trim() 10 | let shortText = fullText.slice(0, limit) 11 | 12 | if (shortText === fullText) { 13 | return {fullText} 14 | } 15 | 16 | return ( 17 | 18 | {shortText.trim()}… 19 | 20 | ) 21 | } 22 | 23 | Truncated.defaultProps = { 24 | limit: 40 25 | } 26 | 27 | export default Truncated 28 | -------------------------------------------------------------------------------- /src/components/ui/__tests__/image-test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Image from '../image' 3 | import { mount } from 'enzyme' 4 | 5 | describe('Image Component', () => { 6 | test('sets its state to loaded when it finishes loading', () => { 7 | let component = mount() 8 | 9 | component.simulate('load') 10 | 11 | expect(component).toHaveState('isLoaded', true) 12 | }) 13 | 14 | test('adds an error class on failed images', () => { 15 | let component = mount() 16 | 17 | component.simulate('error') 18 | 19 | expect(component).toHaveState('didFail', true) 20 | }) 21 | 22 | test('resets its loaded state when a new src is received', () => { 23 | let component = mount() 24 | 25 | component.setProps({ src: 'bar.jpg' }) 26 | 27 | expect(component).toHaveState('isLoaded', false) 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Button 3 | */ 4 | 5 | import * as React from 'react' 6 | import Ink from 'react-ink' 7 | import cx from 'classnames' 8 | 9 | interface Props extends React.HTMLProps { 10 | raised?: boolean 11 | onClick: (event: React.SyntheticEvent) => void 12 | } 13 | 14 | const Button: React.SFC = ({ children, raised, hidden, ...attrs }) => { 15 | let className = cx(attrs.className, 'ars-button', { 16 | 'ars-button-raised': raised 17 | }) 18 | 19 | if (hidden) { 20 | return null 21 | } 22 | 23 | return ( 24 | 28 | ) 29 | } 30 | 31 | export default Button 32 | -------------------------------------------------------------------------------- /src/components/ui/image.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Image 3 | * A wrapper around image elements to handle loading states and 4 | * transitions 5 | */ 6 | 7 | import * as React from 'react' 8 | import cx from 'classnames' 9 | 10 | interface Props { 11 | className: string 12 | src: string | null | undefined 13 | alt?: string 14 | } 15 | 16 | interface State { 17 | didFail: boolean 18 | isLoaded: boolean 19 | prevSrc: string | null | undefined 20 | } 21 | 22 | export default class Image extends React.Component { 23 | static getDerivedStateFromProps(next: Props, last: State) { 24 | return { 25 | prevSrc: next.src, 26 | isLoaded: next.src === last.prevSrc 27 | } 28 | } 29 | 30 | state: State = { 31 | didFail: false, 32 | isLoaded: false, 33 | prevSrc: null 34 | } 35 | 36 | render() { 37 | let css = cx(this.props.className, { 38 | 'ars-img': true, 39 | 'ars-img-loaded': this.state.isLoaded, 40 | 'ars-img-failed': this.state.didFail 41 | }) 42 | 43 | return ( 44 | 50 | ) 51 | } 52 | 53 | private onLoad() { 54 | this.setState({ didFail: false, isLoaded: true }) 55 | } 56 | 57 | private onError() { 58 | this.setState({ didFail: true, isLoaded: true }) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/components/ui/selection-not-found.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | interface Props { 4 | resource: String 5 | } 6 | 7 | const SelectionNotFound: React.SFC = ({ resource }) => ( 8 |
9 |
10 |

Unable to find {resource.toLowerCase()}

11 |

It may have been deleted.

12 |
13 |
14 | ) 15 | 16 | export default SelectionNotFound 17 | -------------------------------------------------------------------------------- /src/containers/__tests__/load-record.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import xhr from 'xhr' 3 | import LoadRecord from '../load-record' 4 | import { mount } from 'enzyme' 5 | 6 | jest.useFakeTimers() 7 | 8 | describe('LoadRecord', () => { 9 | beforeEach(() => xhr.mockClear()) 10 | 11 | describe('componentWillMount', () => { 12 | test('fetches on mount if given a id', () => { 13 | mount() 14 | expect(xhr).toHaveBeenCalled() 15 | }) 16 | 17 | test('does not fetch on mount if no id is provided', () => { 18 | mount() 19 | expect(xhr).not.toHaveBeenCalled() 20 | }) 21 | 22 | test('fetches when the id is 0', () => { 23 | mount() 24 | expect(xhr).toHaveBeenCalled() 25 | }) 26 | 27 | test('does not fetch on NaN', () => { 28 | mount() 29 | expect(xhr).not.toHaveBeenCalled() 30 | }) 31 | }) 32 | 33 | describe('componentWillReceiveProps', () => { 34 | test('fetches when given a new id', () => { 35 | let component = mount() 36 | 37 | expect(xhr).toHaveBeenCalledTimes(1) 38 | component.setProps({ id: 'second' }) 39 | expect(xhr).toHaveBeenCalledTimes(2) 40 | }) 41 | 42 | test('does not fetch when given the same id', () => { 43 | let component = mount() 44 | 45 | expect(xhr).toHaveBeenCalledTimes(1) 46 | component.setProps({ id: 'first' }) 47 | expect(xhr).toHaveBeenCalledTimes(1) 48 | }) 49 | }) 50 | 51 | describe('responseDidSucceed', () => { 52 | test('calls onFetch when a response succeeds', () => { 53 | let onFetch = jest.fn() 54 | 55 | mount() 56 | 57 | jest.runAllTimers() 58 | 59 | expect(onFetch).toHaveBeenCalledWith({ 60 | url: '/base/test/test.jpg', 61 | caption: 'This is a test', 62 | id: 1 63 | }) 64 | }) 65 | 66 | test('sets the error state to false', () => { 67 | let component = mount() 68 | 69 | jest.runAllTimers() 70 | 71 | expect(component).toHaveState('error', null) 72 | }) 73 | 74 | test('sets the item state to the returned value of onFetch', () => { 75 | let onFetch = () => 'fetched' 76 | let component = mount() 77 | 78 | jest.runAllTimers() 79 | 80 | expect(component).toHaveState('data', 'fetched') 81 | }) 82 | }) 83 | 84 | describe('responseDidFail', () => { 85 | test('sets the error state to the returned value of onError, and item to false', () => { 86 | let onError = () => 'terrible error!' 87 | let component = mount() 88 | 89 | jest.runAllTimers() 90 | 91 | expect(component).toHaveState('error', 'terrible error!') 92 | expect(component).toHaveState('data', null) 93 | }) 94 | }) 95 | }) 96 | -------------------------------------------------------------------------------- /src/containers/load-collection.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * LoadCollection 3 | * Fetch and present a list of items 4 | */ 5 | 6 | import * as React from 'react' 7 | import OptionsContext from '../contexts/options' 8 | import { Record } from '../record' 9 | import { DEFAULT_OPTIONS, SortableColumn, ArsOptionsWithDeprecations } from '../options' 10 | import { stringify } from 'query-string' 11 | import ScrollMonitor from '../components/scroll-monitor' 12 | import { dedupe } from '../utils/collection' 13 | import { LogLevel } from '../logger' 14 | 15 | export interface CollectionResult { 16 | data: Record[] 17 | fetching: boolean 18 | error: string | null 19 | } 20 | 21 | interface Props extends ArsOptionsWithDeprecations { 22 | sort: SortableColumn 23 | search: string 24 | render: (result: CollectionResult) => React.ReactNode | null 25 | } 26 | 27 | interface State { 28 | data: Record[] 29 | error: string | null 30 | fetching: boolean 31 | targetUrl: string 32 | search: string 33 | sort: SortableColumn 34 | page: number 35 | } 36 | 37 | type Request = { 38 | error: string | null 39 | fetching: boolean 40 | data: Record[] 41 | xhr?: XMLHttpRequest 42 | valid: boolean 43 | } 44 | 45 | function nextPage(nextProps: Props, lastState: State) { 46 | if (nextProps.search !== lastState.search || nextProps.sort !== lastState.sort) { 47 | return 0 48 | } 49 | 50 | return lastState.page 51 | } 52 | 53 | class CollectionFetcher extends React.Component { 54 | static defaultProps: Props = { 55 | ...DEFAULT_OPTIONS, 56 | sort: 'id', 57 | search: '', 58 | render: () => null 59 | } 60 | 61 | static getDerivedStateFromProps(nextProps: Props, lastState: State) { 62 | let { listUrl, listQuery, url, sort, search, logger } = nextProps 63 | 64 | let page = nextPage(nextProps, lastState) 65 | let baseUrl = listUrl(url) 66 | let query = { search, page, sort } 67 | let queryString = stringify(listQuery(query)) 68 | 69 | if ('makeURL' in nextProps) { 70 | baseUrl = nextProps.makeURL(url) 71 | 72 | logger(LogLevel.Warning, 'ArsArsenal option makeURL is deprecated. Use listUrl instead.') 73 | } 74 | 75 | if ('makeQuery' in nextProps) { 76 | queryString = nextProps.makeQuery(search) 77 | 78 | logger(LogLevel.Warning, 'ArsArsenal option makeURL is deprecated. Use listUrl instead.') 79 | } 80 | 81 | return { page, sort, search, targetUrl: baseUrl + '?' + queryString } 82 | } 83 | 84 | requests: Request[] = [] 85 | 86 | state: State = { 87 | data: [], 88 | page: 0, 89 | error: null, 90 | fetching: false, 91 | sort: 'id', 92 | search: '', 93 | targetUrl: '' 94 | } 95 | 96 | componentDidMount() { 97 | this.fetch() 98 | } 99 | 100 | componentDidUpdate(lastProps: Props, lastState: State) { 101 | if (lastState.search !== this.state.search || lastState.sort !== this.state.sort) { 102 | this.abort() 103 | } 104 | 105 | if (lastState.targetUrl !== this.state.targetUrl) { 106 | this.fetch() 107 | } 108 | } 109 | 110 | componentWillUnmount() { 111 | this.abort() 112 | } 113 | 114 | fetch() { 115 | let request: Request = { 116 | fetching: true, 117 | error: null, 118 | valid: true, 119 | data: [] 120 | } 121 | 122 | request.xhr = this.props.request( 123 | this.state.targetUrl, 124 | this.onSuccess.bind(this, request), 125 | this.onFailure.bind(this, request) 126 | ) 127 | 128 | this.requests.push(request) 129 | 130 | this.commit() 131 | } 132 | 133 | accumulate(requests: Request[]): Record[] { 134 | let data = [] 135 | let newContent = requests.some(request => request.valid && !request.fetching) 136 | 137 | for (var i = 0; i < requests.length; i += 1) { 138 | // Should we hit a case where new content is mixed with old, invalid content, 139 | // Filter out invalid requests. But only do this if there is new content, 140 | // otherwise the collection will flash empty while new results load in 141 | if (newContent && !requests[i].valid) { 142 | continue 143 | } 144 | 145 | if (requests[i].fetching) { 146 | break 147 | } 148 | 149 | data.push(...requests[i].data) 150 | } 151 | 152 | let unique = dedupe(data, 'id') 153 | 154 | if (unique.length < data.length) { 155 | this.props.logger( 156 | LogLevel.Error, 157 | `Duplicate records were returned from ${this.state.targetUrl}. ` + 158 | 'ArsArsenal has deduplicated them, however check that your API response is ' + 159 | 'returning unique results.' 160 | ) 161 | } 162 | 163 | return unique 164 | } 165 | 166 | onSuccess(request: Request, body: Object) { 167 | request.data = this.props.onFetch(body) as Record[] 168 | request.fetching = false 169 | 170 | // Filter invalid requests on success. This allows data to 171 | // remain in a reasonable state while other data loads 172 | this.requests = this.requests.filter(r => r.valid) 173 | 174 | this.commit() 175 | } 176 | 177 | onFailure(request: Request, error: Error) { 178 | request.data = [] 179 | request.error = this.props.onError(error) 180 | request.fetching = false 181 | 182 | this.commit() 183 | } 184 | 185 | lastError() { 186 | let errors = this.requests.map(request => request.error).filter(Boolean) 187 | return errors ? errors.pop() : null 188 | } 189 | 190 | commit() { 191 | this.setState({ 192 | data: this.accumulate(this.requests), 193 | error: this.lastError(), 194 | fetching: this.requests.some(item => item.fetching === true) 195 | }) 196 | } 197 | 198 | abort() { 199 | this.requests.forEach(request => { 200 | if (request.xhr) { 201 | request.xhr.abort() 202 | } 203 | 204 | request.valid = false 205 | }) 206 | } 207 | 208 | render() { 209 | // Refresh scrolling when these fields change. This causes the monitor to 210 | // start from a clean slate whenever new results come in. 211 | let token = [this.state.search, this.state.sort].join(':') 212 | 213 | return ( 214 | 215 | {this.props.render(this.state)} 216 | 217 | ) 218 | } 219 | 220 | private onPage = (page: number) => this.setState({ page }) 221 | } 222 | 223 | type LoadCollectionProps = { 224 | search: string 225 | sort: SortableColumn 226 | render: (result: CollectionResult) => React.ReactNode | null 227 | } 228 | 229 | export default function LoadCollection(props: LoadCollectionProps) { 230 | return ( 231 | 232 | {options => } 233 | 234 | ) 235 | } 236 | -------------------------------------------------------------------------------- /src/containers/load-record.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * LoadRecord 3 | * Fetch and present a single item 4 | */ 5 | 6 | import * as React from 'react' 7 | import OptionsContext from '../contexts/options' 8 | import { ID, Record } from '../record' 9 | import { DEFAULT_OPTIONS, ArsOptionsWithDeprecations } from '../options' 10 | 11 | export interface RecordResult { 12 | data: Record | null 13 | error: string | null 14 | fetching: boolean 15 | initialized: boolean 16 | } 17 | 18 | interface Props extends ArsOptionsWithDeprecations { 19 | id: ID | null 20 | render: (result: RecordResult) => React.ReactNode | null 21 | } 22 | 23 | interface State { 24 | data: Record | null 25 | error: string | null 26 | fetching: boolean 27 | targetURL: string 28 | initialized: false 29 | } 30 | 31 | function isValidId(id: ID): boolean { 32 | return !!id || id === 0 33 | } 34 | 35 | class RecordFetcher extends React.Component { 36 | static defaultProps: Props = { 37 | ...DEFAULT_OPTIONS, 38 | id: null, 39 | render: result => null 40 | } 41 | 42 | static getDerivedStateFromProps(props: Props, lastState: State) { 43 | let { url, showUrl, id } = props 44 | 45 | let targetURL = showUrl(url, id) 46 | 47 | if ('makeURL' in props) { 48 | targetURL = props.makeURL(url, id) 49 | 50 | console.warn('ArsArsenal option makeURL is deprecated. Use showUrl instead.') 51 | } 52 | 53 | return { targetURL } 54 | } 55 | 56 | request: XMLHttpRequest | null = null 57 | 58 | state: State = { 59 | data: null, 60 | error: null, 61 | fetching: false, 62 | targetURL: '', 63 | initialized: false 64 | } 65 | 66 | componentDidMount() { 67 | if (this.shouldFetch(this.state.targetURL, null, this.props)) { 68 | this.fetch() 69 | } 70 | } 71 | 72 | componentDidUpdate(lastProps: Props, lastState: State) { 73 | if (this.shouldFetch(this.state.targetURL, lastState.targetURL, this.props)) { 74 | this.fetch() 75 | } 76 | } 77 | 78 | shouldFetch(nextURL: string, lastURL: string | null, props: Props) { 79 | return nextURL !== lastURL && isValidId(props.id) 80 | } 81 | 82 | fetch() { 83 | this.abort() 84 | 85 | this.request = this.props.request( 86 | this.state.targetURL, 87 | this.onSuccess.bind(this), 88 | this.onFailure.bind(this) 89 | ) 90 | } 91 | 92 | onSuccess(data: Object) { 93 | this.setState({ 94 | data: this.props.onFetch(data) as Record, 95 | error: null, 96 | fetching: false, 97 | initialized: true 98 | }) 99 | } 100 | 101 | onFailure(error: Error) { 102 | this.setState({ 103 | data: null, 104 | error: this.props.onError(error), 105 | fetching: false, 106 | initialized: true 107 | }) 108 | } 109 | 110 | abort() { 111 | if (this.request != null) { 112 | this.request.abort() 113 | } 114 | } 115 | 116 | componentWillUnmount() { 117 | this.abort() 118 | } 119 | 120 | render() { 121 | return this.props.render ? this.props.render(this.state) : null 122 | } 123 | } 124 | 125 | type LoadRecordProps = { 126 | id: ID | null 127 | render: (result: RecordResult) => React.ReactNode | null 128 | } 129 | 130 | export default function LoadRecord(props: LoadRecordProps) { 131 | return ( 132 | 133 | {options => } 134 | 135 | ) 136 | } 137 | -------------------------------------------------------------------------------- /src/contexts/options.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react' 2 | import { DEFAULT_OPTIONS } from '../options' 3 | 4 | const OptionsContext = createContext(DEFAULT_OPTIONS) 5 | 6 | export default OptionsContext 7 | -------------------------------------------------------------------------------- /src/icons/icon-frame.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | interface Props { 4 | fill?: string 5 | height?: number 6 | width?: number 7 | } 8 | 9 | const IconFrame: React.SFC = ({ children, ...props }) => { 10 | const viewBox = `0 0 ${props.width} ${props.height}` 11 | 12 | return ( 13 | 14 | {children} 15 | 16 | ) 17 | } 18 | 19 | IconFrame.defaultProps = { 20 | fill: '#000000', 21 | height: 24, 22 | width: 24 23 | } 24 | 25 | export function generateIcon( 26 | callback: (props: IconProps) => React.ReactNode 27 | ): React.SFC { 28 | return (props: IconProps) => React.createElement(IconFrame, props, callback(props)) 29 | } 30 | 31 | export default IconFrame 32 | -------------------------------------------------------------------------------- /src/icons/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { generateIcon } from './icon-frame' 3 | 4 | export const ClearIcon = generateIcon(() => ( 5 | <> 6 | Clear 7 | 8 | 9 | 10 | )) 11 | 12 | export const EditIcon = generateIcon(() => ( 13 | 17 | )) 18 | 19 | export const GalleryIcon = generateIcon(() => ( 20 | <> 21 | 22 | 23 | 24 | )) 25 | 26 | export const RefreshIcon = generateIcon(() => ( 27 | 28 | )) 29 | 30 | export const SearchIcon = generateIcon(() => ( 31 | <> 32 | Enter Search Term 33 | 34 | 35 | 36 | )) 37 | 38 | export const TableIcon = generateIcon(() => ( 39 | <> 40 | 41 | 42 | 43 | )) 44 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Ars Arsenal 3 | * A gallery picker 4 | */ 5 | 6 | import * as React from 'react' 7 | import * as DOM from 'react-dom' 8 | import Ars from './components/ars' 9 | import { ArsOptions } from './options' 10 | 11 | /** 12 | * Render an ArsArsenal component for a given element. 13 | */ 14 | function render(el: HTMLElement, options: ArsOptions) { 15 | let component = React.createElement(Ars, options) 16 | 17 | DOM.render(component, el) 18 | 19 | return component 20 | } 21 | 22 | export { Ars, Ars as component, render } 23 | 24 | export default { component: Ars, render } 25 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | export enum LogLevel { 2 | Warning = 'warning', 3 | Error = 'error' 4 | } 5 | 6 | export function logger(level: LogLevel, message: String) { 7 | switch (level) { 8 | case LogLevel.Warning: 9 | console.warn(message) 10 | break 11 | case LogLevel.Error: 12 | console.error(message) 13 | break 14 | default: 15 | console.log(message) 16 | break 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/options.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * These are all options available to Ars Arsenal 3 | */ 4 | 5 | import { logger } from './logger' 6 | import { request } from './request' 7 | import { ID, Record } from './record' 8 | 9 | export type ArsMode = 'gallery' | 'table' 10 | 11 | export type SortableColumn = keyof Record 12 | 13 | export type ArsColumn = SortableColumn | 'preview' 14 | 15 | export type SearchQuery = { 16 | page: number 17 | search: string 18 | sort: SortableColumn 19 | } 20 | 21 | export interface ArsOptions { 22 | // Show or hide autocomplete results 23 | autoComplete: boolean 24 | // The base URL for API interaction 25 | url: string 26 | // Used to build the URL that fetches lists of records. 27 | listUrl: (url: string) => string 28 | // Used to rename query parameters before building a list endpoint URL 29 | listQuery: (query: SearchQuery) => Object 30 | // Used to build the URL that fetches a single record. 31 | showUrl: (url: string, id: ID) => string 32 | // Configure the root element's HTML attributes 33 | rootAttributes: { [key: string]: number | string | boolean } 34 | // Format errors before they are sent as a "string" value 35 | // to the component 36 | onError: (error: Error) => string 37 | // Format the response, useful if you do not control the 38 | // JSON response from your endpoint 39 | onFetch: (response: Object) => Object 40 | // Whenever a new item is picked, this event is triggered 41 | // When using multiselect: true, this is an array of values 42 | onChange: (id: ID | ID[]) => void 43 | // Are multiple selections possible? 44 | multiselect: boolean 45 | // The noun used for selection, i.e. "photo" or "file" 46 | // This shows up in the UI as "Pick a photo" 47 | resource: string 48 | // How to display the items. Can be "table" or "gallery" 49 | mode: ArsMode 50 | // In mode: 'table', sets the displayed columns, and the order 51 | columns: ArsColumn[] 52 | // Existing selections 53 | picked?: ID | ID[] 54 | // What utility should Ars use for network requests? 55 | request: typeof request 56 | // Method to report issues with Ars Arsenal. Use this method to 57 | // provide custom error reporting. 58 | logger: (level: String, message: String) => void 59 | } 60 | 61 | export interface ArsOptionsWithDeprecations extends ArsOptions { 62 | makeURL?: (url: string, id?: ID) => string 63 | makeQuery?: (term: string) => string 64 | } 65 | 66 | export const DEFAULT_OPTIONS: ArsOptions = { 67 | autoComplete: true, 68 | url: '', 69 | listUrl: (url: string) => url, 70 | listQuery: query => ({ q: query.search }), 71 | showUrl: (url: string, id: ID) => `${url}/${id}`, 72 | rootAttributes: { className: '' }, 73 | onError: error => error.message, 74 | onFetch: data => data, 75 | onChange: picked => {}, 76 | multiselect: false, 77 | resource: 'Photo', 78 | mode: 'gallery', 79 | columns: ['id', 'name', 'caption', 'attribution', 'tags', 'preview'], 80 | request: request, 81 | logger: logger 82 | } 83 | -------------------------------------------------------------------------------- /src/record.ts: -------------------------------------------------------------------------------- 1 | export type ID = string | number 2 | 3 | export interface Record { 4 | id: ID 5 | caption: string 6 | name: string 7 | attribution: string 8 | url: string 9 | tags: string[] 10 | } 11 | 12 | export const EmptyRecord: Record = { 13 | id: '__ars-arsenal-empty-record', 14 | caption: '', 15 | name: '', 16 | attribution: '', 17 | url: '', 18 | tags: [] 19 | } 20 | -------------------------------------------------------------------------------- /src/request.ts: -------------------------------------------------------------------------------- 1 | import xhr from 'xhr' 2 | 3 | type OnSuccess = (body: Object) => void 4 | type OnError = (error: Error) => void 5 | 6 | export function request(url: string, success: OnSuccess, error: OnError) { 7 | return xhr({ url: url, json: true }, (err, response, body) => { 8 | if (err) { 9 | error(err) 10 | } else if (response.statusCode >= 400) { 11 | error(new Error(response.body as string)) 12 | } else { 13 | success(body) 14 | } 15 | }) 16 | } 17 | -------------------------------------------------------------------------------- /src/style/animation/spin.scss: -------------------------------------------------------------------------------- 1 | @keyframes ars-spin { 2 | 0% { 3 | transform: rotateZ(0); 4 | } 5 | 6 | 100% { 7 | transform: rotateZ(360deg); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/style/ars-arsenal.scss: -------------------------------------------------------------------------------- 1 | $ars-font-family: Roboto, sans-serif; 2 | $ars-primary: #009688 !default; 3 | $ars-accent: #cddc39 !default; 4 | $ars-danger: #f44336 !default; 5 | $ars-focus: #0088ff !default; 6 | $ars-selected: gold !default; 7 | $ars-text-light: rgba(#fff, 0.875) !default; 8 | $ars-text-dark: rgba(#000, 0.875) !default; 9 | $ars-muted-light: rgba(#fff, 0.4) !default; 10 | $ars-muted-dark: rgba(#000, 0.54) !default; 11 | $ars-grey-200: #eeeeee !default; 12 | $ars-bounce: cubic-bezier(.05, .65, .24, 1.14) !default; 13 | $ars-dialog-width: 768px; 14 | 15 | @import 'generators/functions'; 16 | @import 'generators/extends'; 17 | 18 | @import "components/animation"; 19 | @import "components/ars"; 20 | 21 | @import "utils/hidden"; 22 | @import "animation/spin.scss"; 23 | 24 | .ars { 25 | @import "components/dialog"; 26 | @import "components/gallery"; 27 | @import "components/table-view"; 28 | @import "components/figure"; 29 | @import "components/image"; 30 | @import "components/search"; 31 | @import "components/button"; 32 | @import "components/selection"; 33 | @import "components/multiselection"; 34 | @import "components/error"; 35 | @import "components/empty"; 36 | @import "components/tag"; 37 | @import "components/truncated"; 38 | } 39 | -------------------------------------------------------------------------------- /src/style/components/animation.scss: -------------------------------------------------------------------------------- 1 | @keyframes ars-fade-in { 2 | 0% { opacity: 0; } 3 | 100% { opacity: 1; } 4 | } 5 | 6 | @keyframes ars-swipe-in { 7 | 0% { 8 | opacity: 0; 9 | transform: translateY(10px); 10 | } 11 | 12 | 100% { 13 | opacity: 1; 14 | transform: translateY(0); 15 | } 16 | } -------------------------------------------------------------------------------- /src/style/components/ars.scss: -------------------------------------------------------------------------------- 1 | .ars { 2 | box-sizing: border-box; 3 | font-family: $ars-font-family; 4 | height: 100%; 5 | text-align: left; 6 | width: 100%; 7 | -webkit-font-smoothing: antialiased; 8 | -moz-osx-font-smoothing: grayscale; 9 | 10 | *, 11 | *:after, 12 | *:before { 13 | box-sizing: inherit; 14 | } 15 | 16 | button { 17 | &::-moz-focus-inner { 18 | border: 0; 19 | padding: 0; 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/style/components/button.scss: -------------------------------------------------------------------------------- 1 | .ars-button { 2 | background-color: #fff; 3 | border-radius: 3px; 4 | border: 0; 5 | color: $ars-primary; 6 | cursor: pointer; 7 | display: inline-block; 8 | font: bold 14px/16px $ars-font-family; 9 | letter-spacing: 0.07em; 10 | line-height: 16px; 11 | margin: 6px 0; 12 | padding: 10px 8px; 13 | position: relative; 14 | text-align: center; 15 | text-shadow: none; 16 | transition: 0.3s all; 17 | text-transform: uppercase; 18 | width: auto; 19 | 20 | &:hover, 21 | &:focus { 22 | background-color: #fff; 23 | } 24 | 25 | .ink { 26 | z-index: 4; 27 | } 28 | 29 | &[disabled] { 30 | opacity: 0.18 !important; 31 | cursor: cancel !important; 32 | } 33 | 34 | @media screen and (max-width: 400px) { 35 | margin: 4px; 36 | } 37 | } 38 | 39 | .ars-button-raised { 40 | background-color: $ars-primary; 41 | box-shadow: 0 1px 2px rgba(#000, 0.12), 0 0 1px rgba(#000, 0.2); 42 | color: #fff; 43 | 44 | &:hover, 45 | &:focus { 46 | background-color: $ars-primary; 47 | } 48 | } 49 | 50 | .ars-button + .ars-button-raised { 51 | margin-left: 12px; 52 | } 53 | 54 | .ars-button-muted { 55 | color: gray; 56 | font-weight: normal; 57 | } 58 | 59 | .ars-button-icon { 60 | height: 40px; 61 | width: 40px; 62 | opacity: 0.64; 63 | border-radius: 50%; 64 | margin: 2px; 65 | padding: 0; 66 | 67 | .ars-icon { 68 | display: block; 69 | margin: 0 auto; 70 | } 71 | 72 | &:hover, 73 | &:focus { 74 | background-color: transparent; 75 | box-shadow: 0 0 0 1px rgba(#000, 0.12); 76 | opacity: 1; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/style/components/dialog.scss: -------------------------------------------------------------------------------- 1 | .ars-dialog-wrapper { 2 | animation: 0.4s ars-fade-in; 3 | display: flex; 4 | flex-direction: column; 5 | flex-grow: 0; 6 | font-family: $ars-font-family; 7 | height: 100%; 8 | justify-content: center; 9 | left: 0; 10 | position: fixed; 11 | top: 0; 12 | width: 100%; 13 | z-index: 10000; 14 | } 15 | 16 | .ars-dialog-backdrop { 17 | background: rgba(#000, 0.54); 18 | height: 100%; 19 | left: 0; 20 | position: absolute; 21 | top: 0; 22 | width: 100%; 23 | z-index: 0; 24 | } 25 | 26 | .ars-dialog { 27 | background: #fafafa; 28 | border-radius: 2px; 29 | box-shadow: 0 1px 3px rgba(#000, 0.24); 30 | display: flex; 31 | flex-direction: column; 32 | flex-grow: 1; 33 | margin: 0 auto; 34 | max-height: 85vh; 35 | max-width: $ars-dialog-width; 36 | min-height: 200px; 37 | position: relative; 38 | width: 100%; 39 | z-index: 1; 40 | 41 | @media screen and (max-width: $ars-dialog-width) { 42 | background: $ars-primary; 43 | color: #fff; 44 | height: 100%; 45 | max-height: 100%; 46 | } 47 | } 48 | 49 | .ars-dialog-header { 50 | align-items: center; 51 | display: flex; 52 | flex-shrink: 0; 53 | margin: 8px 8px 0; 54 | padding-right: 4px; 55 | } 56 | 57 | .ars-dialog-title { 58 | color: $ars-text-dark; 59 | flex-grow: 1; 60 | font: 500 20px/24px $ars-font-family; 61 | margin: 0; 62 | padding: 16px; 63 | } 64 | 65 | .ars-dialog-footer { 66 | align-items: center; 67 | background: #fff; 68 | box-shadow: 0 -1px 2px rgba(#000, 0.12); 69 | display: flex; 70 | flex-shrink: 0; 71 | justify-content: space-between; 72 | padding: 4px 12px 4px 12px; 73 | position: relative; 74 | white-space: nowrap; 75 | } 76 | -------------------------------------------------------------------------------- /src/style/components/empty.scss: -------------------------------------------------------------------------------- 1 | .ars-empty { 2 | margin: 0; 3 | padding: 24px 12px; 4 | flex-grow: 1; 5 | } 6 | 7 | .ars-empty.ars-lag { 8 | animation: 0.3s ars-fade-in 200ms; 9 | animation-fill-mode: both; 10 | opacity: 0; 11 | } -------------------------------------------------------------------------------- /src/style/components/error.scss: -------------------------------------------------------------------------------- 1 | .ars-error { 2 | background: $ars-danger; 3 | border-radius: 2px; 4 | color: $ars-text-light; 5 | font-size: 14px; 6 | margin: 16px 12px 0; 7 | padding: 8px; 8 | } 9 | -------------------------------------------------------------------------------- /src/style/components/figure.scss: -------------------------------------------------------------------------------- 1 | .ars-fig { 2 | background: #fff; 3 | border-radius: 2px; 4 | border: 0; 5 | box-shadow: 0 1px 2px rgba(#000, 0.35); 6 | cursor: pointer; 7 | color: $ars-accent; 8 | margin: 0; 9 | padding: 100% 0 0; 10 | position: relative; 11 | transition: 0.2s box-shadow; 12 | width: 100%; 13 | 14 | &:before { 15 | border: 0 solid transparent; 16 | bottom: -4px; 17 | border-radius: 2px; 18 | content: ''; 19 | left: -4px; 20 | pointer-events: none; 21 | position: absolute; 22 | right: -4px; 23 | top: -4px; 24 | transition: 0.28s all; 25 | z-index: 3; 26 | } 27 | 28 | &:focus { 29 | outline: none; 30 | 31 | &:before { 32 | border: 2px solid $ars-focus; 33 | background: rgba($ars-focus, 0.12); 34 | } 35 | } 36 | 37 | &:active:before { 38 | border-width: 4px; 39 | } 40 | 41 | &.ars-fig-picked:before { 42 | border: 4px solid $ars-selected; 43 | background: rgba($ars-selected, 0.12); 44 | box-shadow: 0 1px 2px 0px rgba(0, 0, 0, 0.45); 45 | top: -3px; 46 | bottom: -3px; 47 | left: -3px; 48 | right: -3px; 49 | } 50 | } 51 | 52 | .ars-fig-img { 53 | border-radius: 2px; 54 | bottom: 0; 55 | position: absolute; 56 | right: 0; 57 | } 58 | 59 | .ars-fig-caption { 60 | background: rgba(#000, 0.54); 61 | bottom: 0; 62 | border-bottom-left-radius: 2px; 63 | border-bottom-right-radius: 2px; 64 | color: $ars-text-light; 65 | font-family: $ars-font-family; 66 | font-size: 14px; 67 | font-weight: 500; 68 | left: 0; 69 | line-height: 32px; 70 | overflow: hidden; 71 | padding: 0 12px; 72 | position: absolute; 73 | text-align: center; 74 | text-overflow: ellipsis; 75 | white-space: nowrap; 76 | width: 100%; 77 | } 78 | 79 | // Transitions 80 | // -------------------------------------------------- // 81 | 82 | .ars-figure-enter { 83 | opacity: 0.01; 84 | transform: scale(0.98) translateY(-5px); 85 | transition: 0.48s all; 86 | } 87 | 88 | .ars-figure-enter.ars-figure-enter-active { 89 | opacity: 1; 90 | transform: none; 91 | } 92 | 93 | .ars-figure-leave { 94 | opacity: 1; 95 | transition: 0.48s all; 96 | } 97 | 98 | .ars-figure-leave.ars-figure-leave-active { 99 | opacity: 0.01; 100 | transform: scale(0.95); 101 | } 102 | -------------------------------------------------------------------------------- /src/style/components/gallery.scss: -------------------------------------------------------------------------------- 1 | .ars-gallery-wrapper { 2 | display: flex; 3 | flex-grow: 1; 4 | flex-direction: column; 5 | // Note: this is important to avoid an overflow bug in Chrome 72 where gallery 6 | // items incorrectly extend further than their container. 7 | overflow: hidden; 8 | } 9 | 10 | .ars-gallery { 11 | align-content: flex-start; 12 | box-shadow: 0 -1px 0 rgba(#000, 0.12); 13 | display: flex; 14 | flex: 1 1; 15 | flex-wrap: wrap; 16 | margin-top: 9px; 17 | max-width: 100%; 18 | overflow-x: hidden; 19 | overflow-y: auto; 20 | padding: 6px 4px 2px; 21 | transform: translateZ(0); // Prevents a strange box-shadow animation in Chrome 22 | width: 100%; 23 | } 24 | 25 | .ars-gallery-item { 26 | align-self: flex-start; 27 | padding: 2px 4px; 28 | position: relative; 29 | width: 33%; 30 | 31 | @media screen and (min-width: 568px) { 32 | width: 25%; 33 | } 34 | } 35 | 36 | .ars-gallery-info { 37 | background: rgba(0, 0, 0, 0.88); 38 | border: 0; 39 | border-radius: 50%; 40 | color: white; 41 | cursor: pointer; 42 | font-family: serif; 43 | font-style: italic; 44 | font-size: 16px; 45 | height: 24px; 46 | padding: 0; 47 | width: 24px; 48 | position: absolute; 49 | bottom: 48px; 50 | right: 12px; 51 | box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.23); 52 | opacity: 0.88; 53 | transition: 0.2s all; 54 | } 55 | 56 | .ars-gallery-info:focus, 57 | .ars-gallery-info:hover { 58 | opacity: 1; 59 | } 60 | 61 | .ars-gallery-panel { 62 | animation: 0.3s ars-swipe-in; 63 | align-items: center; 64 | background: white; 65 | box-shadow: 0 -1px 3px rgba(0, 0, 0, 0.27); 66 | display: flex; 67 | flex: 0 0; 68 | position: relative; 69 | width: 100%; 70 | } 71 | 72 | .ars-gallery-panel-imagebox { 73 | border-radius: 2px; 74 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.28); 75 | height: 100px; 76 | overflow: hidden; 77 | position: relative; 78 | width: 100px; 79 | margin: 16px 0 16px 16px; 80 | 81 | img { 82 | left: 50%; 83 | position: absolute; 84 | top: 50%; 85 | transform: translate(-50%, -50%); 86 | max-height: 100%; 87 | min-width: 100%; 88 | } 89 | } 90 | 91 | .ars-gallery-panel-fields { 92 | flex: 1 1; 93 | } 94 | 95 | .ars-gallery-tags { 96 | padding: 0 14px 16px; 97 | } 98 | 99 | .ars-gallery-panel-close { 100 | background: none; 101 | border: 0; 102 | font-size: 32px; 103 | position: absolute; 104 | top: 5px; 105 | right: 5px; 106 | opacity: 0.3; 107 | font-weight: bold; 108 | font-family: auto; 109 | transition: 0.2s; 110 | cursor: pointer; 111 | } 112 | 113 | .ars-gallery-panel-close:focus, 114 | .ars-gallery-panel-close:hover { 115 | opacity: 1; 116 | } 117 | 118 | .ars-gallery-panel-close:active { 119 | transform: scale(0.99); 120 | } 121 | 122 | // Transitions 123 | // -------------------------------------------------- // 124 | 125 | .ars-gallery-enter, 126 | .ars-gallery-appear { 127 | opacity: 0; 128 | transform: translateZ(0) translate(8px, 4px); 129 | transition: 0.4s all; 130 | } 131 | 132 | .ars-gallery-appear.ars-gallery-appear-active, 133 | .ars-gallery-enter.ars-gallery-enter-active { 134 | opacity: 1; 135 | transform: none; 136 | } 137 | 138 | .ars-gallery-leave { 139 | opacity: 1; 140 | transition: 0.4s all; 141 | } 142 | 143 | .ars-gallery-leave.ars-gallery-leave-active { 144 | opacity: 0; 145 | transform: translateZ(0) translate(8px, 4px); 146 | } 147 | -------------------------------------------------------------------------------- /src/style/components/image.scss: -------------------------------------------------------------------------------- 1 | $ars-img-easing : cubic-bezier(.01,.3,.12,.93); 2 | $ars-failed-size : 25px; 3 | 4 | .ars-img { 5 | filter: brightness(1.25) saturate(0.2); 6 | object-fit: cover; 7 | opacity: 0.1; 8 | height: 100%; 9 | transition: 2.5s filter $ars-img-easing, 2.5s opacity $ars-img-easing; 10 | -webkit-transition: 2.5s -webkit-filter $ars-img-easing, 2.5s opacity $ars-img-easing; 11 | user-select: none; 12 | width: 100%; 13 | } 14 | 15 | .ars-img-loaded { 16 | filter: none; 17 | opacity: 1; 18 | } 19 | 20 | .ars-is-loading .ars-img-loaded { 21 | filter: brightness(1.25) saturate(0.2); 22 | opacity: 0.1; 23 | } 24 | 25 | .ars-img-failed { 26 | height: $ars-failed-size; 27 | left: 50%; 28 | margin: -$ars-failed-size 0 0 ($ars-failed-size * -0.5); 29 | position: absolute; 30 | top: 50%; 31 | width: $ars-failed-size; 32 | } 33 | -------------------------------------------------------------------------------- /src/style/components/multiselection.scss: -------------------------------------------------------------------------------- 1 | .ars-multiselection { 2 | border-radius: 3px; 3 | border: 1px solid #dfdfdf; 4 | box-shadow: 0 1px 1px rgba(#000, 0.12); 5 | display: inline-block; 6 | max-width: 400px; 7 | } 8 | 9 | .ars-multiselection .ars-selection-failed { 10 | padding: 4px; 11 | height: 100px; 12 | width: 50%; 13 | } 14 | 15 | .ars-multiselection-grid { 16 | display: flex; 17 | flex-wrap: wrap; 18 | padding: 4px; 19 | 20 | + .ars-selection-edit { 21 | border-top: 1px solid #dfdfdf; 22 | } 23 | } 24 | 25 | .ars-multiselection-cell { 26 | padding: 4px; 27 | position: relative; 28 | width: 50%; 29 | 30 | &.ars-is-loading { 31 | min-height: 64px; 32 | 33 | .ars-icon { 34 | animation-timing-function: linear; 35 | animation: 0.7s ars-spin infinite; 36 | border-radius: 50%; 37 | box-shadow: 0 0 1px rgba(#000, 0.12); 38 | content: ''; 39 | height: 48px; 40 | left: 50%; 41 | margin-left: -24px; 42 | margin-top: -24px; 43 | overflow: hidden; 44 | position: absolute; 45 | top: 50%; 46 | transition: 0.3s border-radius; 47 | width: 48px; 48 | } 49 | 50 | .ars-img { 51 | visibility: hidden; 52 | } 53 | } 54 | 55 | .ars-img { 56 | box-shadow: 0 1px 2px rgba(0, 0, 0, 0.35); 57 | } 58 | 59 | .ink { 60 | color: $ars-primary; 61 | z-index: 4; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/style/components/search.scss: -------------------------------------------------------------------------------- 1 | .ars-search { 2 | border-right: 1px solid rgba(#000, 0.12); 3 | box-shadow: 1px 0 1px rgba(#000, 0.04); 4 | flex-grow: 1; 5 | padding: 8px 0; 6 | margin-right: 8px; 7 | display: flex; 8 | } 9 | 10 | .ars-search-label { 11 | padding: 4px 12px; 12 | 13 | .ars-icon { 14 | display: inline-block; 15 | vertical-align: middle; 16 | } 17 | } 18 | 19 | .ars-search-input { 20 | background: transparent; 21 | border: 0; 22 | font-size: 16px; 23 | line-height: 32px; 24 | padding: 0 12px 0 0; 25 | width: 100%; 26 | -webkit-appearance: none; 27 | 28 | &:focus { 29 | outline: none; 30 | } 31 | 32 | &::-webkit-calendar-picker-indicator { 33 | display: none; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/style/components/selection.scss: -------------------------------------------------------------------------------- 1 | .ars-selection { 2 | border-radius: 3px; 3 | border: 1px solid #dfdfdf; 4 | box-shadow: 0 1px 1px rgba(#000, 0.12); 5 | display: inline-block; 6 | margin: 0; 7 | padding: 0; 8 | position: relative; 9 | 10 | .ink { 11 | color: $ars-primary; 12 | z-index: 4; 13 | } 14 | 15 | &.ars-has-photo { 16 | max-width: 400px; 17 | width: 100%; 18 | } 19 | 20 | &.ars-is-loading { 21 | .ars-selection-desc { 22 | opacity: 0.2; 23 | } 24 | 25 | .ars-selection-button-icon { 26 | animation: 0.7s ars-spin infinite; 27 | animation-timing-function: linear; 28 | background-image: url('/icons/refresh.svg'); 29 | } 30 | } 31 | } 32 | 33 | .ars-selection-photo { 34 | display: block; 35 | max-height: 225px; 36 | } 37 | 38 | .ars-selection-failed-banner { 39 | align-items: center; 40 | box-shadow: 0 0 0 1px $ars-danger; 41 | border-radius: 1px; 42 | background: rgba($ars-danger, 0.3); 43 | color: darken($ars-danger, 40%); 44 | padding: 16px; 45 | display: flex; 46 | flex-direction: column; 47 | justify-content: center; 48 | height: 100%; 49 | } 50 | 51 | .ars-selection-failed-title { 52 | font-size: 14px; 53 | margin: 0 0 8px; 54 | } 55 | 56 | .ars-selection-failed-body { 57 | font-size: 10px; 58 | margin: 0; 59 | } 60 | 61 | .ars-selection-desc { 62 | transition: 0.55s opacity; 63 | } 64 | 65 | .ars-selection-title { 66 | font-weight: 500; 67 | line-height: 48px; 68 | margin: 0; 69 | overflow: hidden; 70 | padding: 0 16px; 71 | white-space: nowrap; 72 | text-overflow: ellipsis; 73 | } 74 | 75 | .ars-selection-caption { 76 | color: rgba(#000, 0.54); 77 | font-size: 13px; 78 | line-height: 1.5; 79 | margin: 0; 80 | padding: 16px; 81 | } 82 | 83 | .ars-selection-title + .ars-selection-caption { 84 | margin-top: -8px; 85 | padding-top: 0; 86 | } 87 | 88 | .ars-selection-figure { 89 | margin: 0; 90 | padding: 0; 91 | } 92 | 93 | .ars-selection-actions { 94 | text-align: left; 95 | padding: 0 12px; 96 | } 97 | 98 | .ars-has-photo .ars-selection-actions { 99 | border-top: 1px solid rgba(#000, 0.12); 100 | } 101 | 102 | .ars-selection-actions .ars-button { 103 | display: inline-block; 104 | } 105 | -------------------------------------------------------------------------------- /src/style/components/table-view.scss: -------------------------------------------------------------------------------- 1 | .ars-table-wrapper { 2 | box-shadow: 0 -1px 0 rgba(#000, 0.12); 3 | flex-grow: 1; 4 | margin-top: 9px; 5 | overflow: auto; 6 | } 7 | 8 | .ars-table { 9 | background: white; 10 | border-collapse: collapse; 11 | color: $ars-text-dark; 12 | font-size: 13px; 13 | width: 100%; 14 | } 15 | 16 | .ars-table-row { 17 | box-shadow: 0 -1px 0 rgba(#000, 0.12); 18 | transition: 0.2s all; 19 | position: relative; 20 | will-change: all; 21 | 22 | &:hover { 23 | background: $ars-grey-200; 24 | box-shadow: inset 0 1px 0 rgba(#000, 0.12); 25 | z-index: 1; 26 | } 27 | } 28 | 29 | .ars-table-selected, 30 | .ars-table-selected:hover { 31 | background: rgba($ars-primary, 0.2); 32 | box-shadow: inset 0 1px 0 rgba(darken($ars-primary, 20%), 0.2); 33 | z-index: 1; 34 | } 35 | 36 | .ars-table td, 37 | .ars-table th { 38 | text-align: left; 39 | padding: 8px 16px 8px 0; 40 | min-height: 48px; 41 | width: 150px; 42 | 43 | &[hidden] { 44 | display: none; 45 | } 46 | } 47 | 48 | .ars-table td:last-child { 49 | padding-right: 16px; 50 | } 51 | 52 | .ars-table thead th { 53 | color: $ars-muted-dark; 54 | font-size: 12px; 55 | padding-top: 16px; 56 | padding-bottom: 16px; 57 | text-align: left; 58 | } 59 | 60 | .ars-table-stretch { 61 | width: 100%; 62 | } 63 | 64 | .ars-table .ars-table-id { 65 | min-width: 0; 66 | width: 48px; 67 | text-align: center; 68 | } 69 | 70 | .ars-table .ars-table-preview, 71 | .ars-table .ars-table-selection { 72 | width: 48px; 73 | } 74 | 75 | .ars-table .ars-table-selection { 76 | min-width: 0; 77 | padding: 16px 20px; 78 | width: 24px; 79 | } 80 | 81 | .ars-table-imagebox { 82 | border-radius: 2px; 83 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.28); 84 | height: 50px; 85 | overflow: hidden; 86 | position: relative; 87 | width: 50px; 88 | 89 | &.ars-large { 90 | border-radius: 0; 91 | box-shadow: none; 92 | height: 200px; 93 | width: 100%; 94 | } 95 | 96 | img { 97 | left: 50%; 98 | position: absolute; 99 | top: 50%; 100 | transform: translate(-50%, -50%); 101 | max-height: 100%; 102 | min-width: 100%; 103 | } 104 | } 105 | 106 | .ars-table-checker { 107 | cursor: pointer; 108 | position: relative; 109 | 110 | &:after { 111 | bottom: -8px; 112 | content: ''; 113 | cursor: pointer; 114 | left: -8px; 115 | position: absolute; 116 | right: -8px; 117 | top: -8px; 118 | z-index: 0; 119 | } 120 | 121 | input { 122 | cursor: pointer; 123 | display: block; 124 | margin: 0; 125 | position: relative; 126 | z-index: 1; 127 | } 128 | } 129 | 130 | /** 131 | * Active States 132 | */ 133 | 134 | .ars-table-heading.ars-sortable { 135 | cursor: pointer; 136 | transition: 0.4s all; 137 | 138 | &:hover { 139 | color: $ars-accent; 140 | transition: 0.15s all; 141 | } 142 | } 143 | 144 | .ars-table-heading.ars-active { 145 | color: $ars-primary; 146 | font-weight: bold; 147 | } 148 | 149 | // Transitions 150 | // -------------------------------------------------- // 151 | 152 | @keyframes ars-table-in { 153 | 0% { 154 | opacity: 0; 155 | transform: translateZ(0) scale(0.94); 156 | } 157 | 158 | 100% { 159 | opacity: 1; 160 | transform: translateZ(0); 161 | } 162 | } 163 | 164 | .ars-table-animate { 165 | animation: ars-table-in 0.6s $ars-bounce; 166 | animation-fill-mode: both; 167 | } 168 | 169 | .ars-table-enter { 170 | opacity: 0.01; 171 | transform: scale(1.02); 172 | transition: 0.48s all; 173 | } 174 | 175 | .ars-table-enter.ars-table-enter-active { 176 | opacity: 1; 177 | transform: none; 178 | } 179 | 180 | .ars-table-leave { 181 | opacity: 1; 182 | transition: 0.32s all; 183 | } 184 | 185 | .ars-table-leave.ars-table-leave-active { 186 | opacity: 0.01; 187 | transform: scale(1.02); 188 | } 189 | -------------------------------------------------------------------------------- /src/style/components/tag.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * Tags appear on the table view. They make it easy to search by a particular category. 3 | * Follows https://material.io/design/components/chips.html 4 | */ 5 | 6 | .ars-tag { 7 | background: #e3e3e3; 8 | border-radius: 24px; 9 | border: 1px solid #dddddd; 10 | cursor: pointer; 11 | font-size: 12px; 12 | color: $ars-text-dark; 13 | margin: 0 4px 4px 0; 14 | padding: 4px 8px; 15 | transition: 0.2s all; 16 | } 17 | 18 | .ars-tag:hover, 19 | .ars-tag:focus { 20 | background: $ars-primary; 21 | border-color: darken($ars-primary, 5%); 22 | color: $ars-text-light; 23 | } -------------------------------------------------------------------------------- /src/style/components/truncated.scss: -------------------------------------------------------------------------------- 1 | .ars-truncated { 2 | text-decoration: none; 3 | } -------------------------------------------------------------------------------- /src/style/generators/extends.scss: -------------------------------------------------------------------------------- 1 | .ars-paper { 2 | background: #fff; 3 | border-radius: 2px; 4 | box-shadow: 0 1px 2px rgba(#000, 0.24), 0 0 0 1px rgba(#000, 0.04); 5 | } 6 | -------------------------------------------------------------------------------- /src/style/generators/functions.scss: -------------------------------------------------------------------------------- 1 | @function tracking($target) { 2 | // 1 unit = 1/1000 em 3 | @return ($target / 1000) * 1em; 4 | } 5 | 6 | @function strip-units($number) { 7 | @return $number / ($number * 0 + 1); 8 | } 9 | 10 | @function sp($value, $baseline:16) { 11 | @return (strip-units($value) / $baseline) + rem; 12 | } 13 | 14 | @function unit($number, $value: $unit) { 15 | @return $number * $value; 16 | } 17 | -------------------------------------------------------------------------------- /src/style/utils/hidden.scss: -------------------------------------------------------------------------------- 1 | .ars-hidden { 2 | position: absolute !important; 3 | overflow: hidden !important; 4 | clip: rect(0 0 0 0) !important; 5 | height: 1px !important; 6 | width: 1px !important; 7 | margin: -1px !important; 8 | padding: 0 !important; 9 | border: 0 !important; 10 | } 11 | -------------------------------------------------------------------------------- /src/utils/__tests__/article-for-test.js: -------------------------------------------------------------------------------- 1 | import articleFor from '../article-for' 2 | 3 | describe('articleFor', () => { 4 | test('returns the article preceeding the given noun', () => { 5 | expect(articleFor('photo')).toBe('a') 6 | expect(articleFor('image')).toBe('an') 7 | }) 8 | }) 9 | -------------------------------------------------------------------------------- /src/utils/__tests__/collection-test.js: -------------------------------------------------------------------------------- 1 | import { dedupe } from '../collection' 2 | 3 | describe('dedupe', () => { 4 | test('eliminates duplicates given a key', () => { 5 | let result = dedupe([{ id: 1 }, { id: 2 }, { id: 1 }], 'id') 6 | 7 | expect(result).toHaveLength(2) 8 | expect(result.map(r => r.id)).toEqual([1, 2]) 9 | }) 10 | 11 | test('handles cases when all items are unique', () => { 12 | let result = dedupe([{ id: 1 }, { id: 2 }], 'id') 13 | 14 | expect(result).toHaveLength(2) 15 | expect(result.map(r => r.id)).toEqual([1, 2]) 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /src/utils/__tests__/pluralize-test.js: -------------------------------------------------------------------------------- 1 | import pluralize from '../pluralize' 2 | 3 | describe('pluralize', () => { 4 | test('pluralizes a string', () => { 5 | expect(pluralize('photo')).toBe('photos') 6 | expect(pluralize('photos')).toBe('photos') 7 | }) 8 | }) 9 | -------------------------------------------------------------------------------- /src/utils/article-for.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | exports.__esModule = true 3 | /* 4 | * A simplified take on which article to use before a noun. 5 | * "A" goes before words that begin with consonants. 6 | * "An" goes before words that begin with vowels. 7 | */ 8 | function articleFor(noun) { 9 | var article = 'a' 10 | var vowels = ['a', 'e', 'i', 'o', 'u'] 11 | if (vowels.indexOf(noun[0].toLowerCase()) !== -1) { 12 | article = 'an' 13 | } 14 | return article 15 | } 16 | exports['default'] = articleFor 17 | -------------------------------------------------------------------------------- /src/utils/article-for.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * A simplified take on which article to use before a noun. 3 | * "A" goes before words that begin with consonants. 4 | * "An" goes before words that begin with vowels. 5 | */ 6 | export default function articleFor(noun: string): string { 7 | let article = 'a' 8 | const vowels = ['a', 'e', 'i', 'o', 'u'] 9 | 10 | if (vowels.indexOf(noun[0].toLowerCase()) !== -1) { 11 | article = 'an' 12 | } 13 | 14 | return article 15 | } 16 | -------------------------------------------------------------------------------- /src/utils/collection.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Removes duplicate records from a list, given a field to compare 3 | * against. This is used to control for duplicate entries returned 4 | * from API endpoints. 5 | * 6 | * Ultimately, we can't control this. However it is important to 7 | * deduplicate search results for React indexing and user experience. 8 | */ 9 | export function dedupe(list: Item[], field: keyof Item): Item[] { 10 | let bank = new Set() 11 | let next = [] 12 | 13 | for (let i = 0, len = list.length; i < len; i++) { 14 | let item = list[i] 15 | let index = item[field] 16 | 17 | if (bank.has(index) === false) { 18 | next.push(item) 19 | } 20 | 21 | bank.add(index) 22 | } 23 | 24 | return next 25 | } 26 | -------------------------------------------------------------------------------- /src/utils/pluralize.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | exports.__esModule = true 3 | function pluralize(word) { 4 | return word.replace(/s?$/i, 's') 5 | } 6 | exports['default'] = pluralize 7 | -------------------------------------------------------------------------------- /src/utils/pluralize.ts: -------------------------------------------------------------------------------- 1 | export default function pluralize(word: string): string { 2 | return word.replace(/s?$/i, 's') 3 | } 4 | -------------------------------------------------------------------------------- /test/__mocks__/xhr.js: -------------------------------------------------------------------------------- 1 | const url = require('url') 2 | 3 | module.exports = jest.fn((options, callback) => { 4 | let body = null 5 | let path = '../' + url.parse(options.url).pathname 6 | let statusCode = 200 7 | 8 | try { 9 | body = require(path) 10 | } catch (x) { 11 | body = 'Unable to load URL' 12 | statusCode = 404 13 | } 14 | 15 | let timeout = setTimeout(() => { 16 | callback(null, { body, url: options.url, statusCode }, body) 17 | }) 18 | 19 | return { 20 | abort() { 21 | clearTimeout(timeout) 22 | } 23 | } 24 | }) 25 | -------------------------------------------------------------------------------- /test/data/1.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 1, 3 | "caption": "This is a test", 4 | "url": "/base/test/test.jpg" 5 | } 6 | -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | import Enzyme from 'enzyme' 2 | import Adapter from 'enzyme-adapter-react-16' 3 | import expect from 'expect' 4 | 5 | Enzyme.configure({ adapter: new Adapter() }) 6 | 7 | jest.mock('xhr') 8 | 9 | expect.extend({ 10 | toHaveState(wrapper, key, expected) { 11 | while (wrapper) { 12 | if (wrapper.instance() == null) { 13 | wrapper = wrapper.children().at(0) 14 | } else { 15 | break 16 | } 17 | } 18 | 19 | if (wrapper == null) { 20 | throw new Error('Enzyme wrapper has no backing instance.') 21 | } 22 | 23 | let actual = wrapper.instance().state[key] 24 | 25 | return { 26 | pass: this.equals(expected, actual), 27 | message: () => { 28 | return `Expected ${wrapper.name()} to have state ${this.utils.printReceived( 29 | expected 30 | )} for ${key}, instead got {}${this.utils.printReceived(actual)}` 31 | } 32 | } 33 | } 34 | }) 35 | -------------------------------------------------------------------------------- /test/test.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vigetlabs/ars-arsenal/42c3ab3b85744155147923e3742923a2d953a241/test/test.jpg -------------------------------------------------------------------------------- /test/test.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 0, 4 | "caption": "This is a test", 5 | "url": "/base/test/test.jpg" 6 | }, 7 | { 8 | "id": 1, 9 | "caption": "This is a test", 10 | "url": "/base/test/test.jpg" 11 | } 12 | ] 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noImplicitAny": true, 4 | "removeComments": true, 5 | "preserveConstEnums": true, 6 | "sourceMap": true, 7 | "outDir": "./dist", 8 | "jsx": "react", 9 | "lib": [ 10 | "es2015", 11 | "dom" 12 | ] 13 | }, 14 | "include": [ 15 | "./src/**/*" 16 | ], 17 | "exclude": [ 18 | "node_modules", 19 | "example", 20 | "build" 21 | ], 22 | "types": [ 23 | "types", 24 | "node_modules", 25 | "node_modules/@types" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /types/modules.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'xhr' { 2 | interface Options { 3 | url: string 4 | json: boolean 5 | } 6 | 7 | interface Response { 8 | statusCode: number 9 | body: string 10 | } 11 | 12 | export default function xhr( 13 | options: Options, 14 | callback: (error: Error | null, response: Response, body: Object) => void 15 | ): XMLHttpRequest 16 | } 17 | --------------------------------------------------------------------------------