├── .coveralls.yml ├── .gitignore ├── .jshintrc ├── .npmignore ├── .nvmrc ├── CONTRIBUTING.md ├── PR_TEMPLATE.md ├── README.md ├── build └── coverage ├── circle.yml ├── docs ├── menus.md ├── misc.md ├── mixins.md ├── tables.md └── utils.md ├── examples ├── example.css ├── index.html ├── menus.html ├── misc.html ├── tables.html └── template.html ├── gulpfile.js ├── package.json ├── roadmap.md ├── src ├── components │ ├── estimator.js │ ├── expander.js │ ├── selector_menu │ │ ├── index.js │ │ ├── label.js │ │ ├── list.js │ │ └── search.js │ ├── sortable_table │ │ ├── header.js │ │ ├── index.js │ │ └── row.js │ ├── status.js │ ├── tag_editor.js │ └── tags.js ├── index.js ├── less │ ├── base.less │ ├── estimator.less │ ├── expander.less │ ├── selector_menu.less │ ├── sortable_table.less │ ├── sprintly-ui.less │ ├── status.less │ ├── tag_editor.less │ └── tags.less ├── mixins │ └── .hidden └── utils │ └── group_and_sort.js ├── test ├── estimator_test.js ├── expander_test.js ├── group_and_sort_test.js ├── index.html ├── index.js ├── phantom_hooks.js ├── selector_menu_test.js ├── sortable_table_test.js ├── status_test.js ├── tag_editor_test.js └── tags_test.js └── upload-assets-to-s3.sh /.coveralls.yml: -------------------------------------------------------------------------------- 1 | # The COVERALLS_TOKEN is set in wercker as project environment variable. 2 | # All others are set in wercker.yml 3 | service_name: 4 | repo_token: 5 | git_commit: 6 | git_branch: 7 | service_job_id: -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | npm-debug.log 4 | test/build.js* 5 | test/coverage/ 6 | coverage/ 7 | dist/css/ 8 | dist/js/ -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "browser": true, 3 | "node": true, 4 | "undef": true, 5 | "unused": true, 6 | "globals": { 7 | "describe": true, 8 | "context": true, 9 | "it": true, 10 | "before": true, 11 | "beforeEach": true, 12 | "after": true, 13 | "afterEach": true 14 | } 15 | } -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .jshintrc 2 | .gitignore 3 | .npmignore 4 | .coveralls.yml 5 | wercker.yml 6 | /docs 7 | /examples 8 | /test 9 | upload-assets-to-s3.sh 10 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v8.2.1 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Welcome! 2 | 3 | We're excited that you're thinking about contributing to Sprintly-UI. 4 | In order to make contributing as friction-free as possible, please read through and consider the following guidelines. 5 | 6 | #### Roadmap and issues 7 | Please refer to [the roadmap](roadmap.md) before starting work on new components that you plan for inclusion in this repo. This will help us avoid duplicate efforts. If there's something that we're already planning via the roadmap that you would nevertheless like to work on, we'd be excited to collaborate with you on it. 8 | 9 | If there's a feature that you'd like to see as a component that _isn't_ on the roadmap, have at it! We'd prefer if you filed an issue before starting work so that we can check in and see if there's anything we can help with, but it's really up to you. 10 | 11 | #### Component structure 12 | Since we would like to be as non-deterministic as we can about how these components will be used, the assumption that we make is that users of this library will be plugging these componenets into apps that contain parent (or "controller") views that will manage data fetching and syncing themselves. 13 | 14 | Designing components for maximum reusability isn't always easy, especially when writing multiple components with the intention of combining them to form a composite component, like our [SortableTable](src/components/sortable_table). A good rule of thumb is that if you think there could be another use for one of the components within your composite component, see if you can reasonably break it out (ie, make it functional for your situation while making its imputs as generic as possible). That's not always possible for very domain-specific components, so just use your best judgement. 15 | 16 | #### Props vs state 17 | To support React's one-way data flow pattern, we ask that you try to avoid state wherever possible when developing components for inclusion in Sprintly-ui, and instead favor passing data and callback functions through as props. The React [getting started](http://facebook.github.io/react/docs/thinking-in-react.html) docs are great and go over one-way data flow and the difference between props and state in detail. 18 | 19 | #### Styles 20 | We're using LESS to generate styles and have a build task for compiling minified and unminified versions: ```$ npm run build-css```. Compiled styles will be saved to ```/dist/css```. 21 | 22 | #### Testing and coverage 23 | All PRs must include unit tests, and we ask for a minimum of 80% coverage. Tests are written in Mocha, Chai, and Sinon, and we use Istanbul for code coverage. We recommend using the [React Test Utilities](https://facebook.github.io/react/docs/test-utils.html), which make testing component lifecycle much easier. 24 | 25 | #### Submitting pull requests 26 | Please include documentation and an example file as part of any PRs for adding new components. 27 | 28 | You can find examples for existing components in ```/examples```. If an appropriate example file doesn't exist for the category of component you've developed, please add a new (appropriately named) file. There's a [template file](examples/template.html) that you can copy over to use as a base. Don't forget to add a link to your example file in [the examples index](examples/index.html)! 29 | 30 | All component documentation is located in the [docs folder](docs/). Please update the docs, adding a new doc file if one covering the category your new component belongs to isn't already represented. 31 | 32 | When you are ready to submit your request, please copy over the [PR template](PR_TEMPLATE.md) into the body of your pull request and fill it out. This gives us a common format for reading and reviewing pull requests. For more info on how PR templates have enhanced our workflow at Quick Left, see [this blog post by Justin Abrahms](https://quickleft.com/blog/pull-request-templates-make-code-review-easier/). 33 | 34 | #### Publishing to NPM & pushing out new CSS 35 | If you're on the Sprintly/Quick Left team and you're merging code, be sure to bump versions in ```package.json``` before publishing to npm via ```$ npm publish```. Also be sure to tag the commit ```$ git tag ``` (or just use the GH tagging interface in the 'releases' tab) before running the script to install the latest CSS in our S3 bucket via ```$ ./upload-assets-to-s3.sh```. 36 | -------------------------------------------------------------------------------- /PR_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | #### What's this PR do? 2 | 3 | #### Where should the reviewer start? 4 | 5 | #### How should this be manually tested? 6 | 7 | #### Any background context you want to provide? 8 | 9 | #### What ticket or issue # does this fix? 10 | 11 | #### Screenshots (if appropriate) 12 | 13 | #### What gif best describes this PR or how it makes you feel? 14 | 15 | #### Definition of Done (check if you've addressed item or if item doesn't apply): 16 | - [ ] Does this contain breaking changes? If so, did you update release-notes.md? 17 | - [ ] Did you bump the version number? 18 | - [ ] Major 19 | - [ ] Minor 20 | - [ ] Path 21 | - [ ] Is there appropriate unit test coverage? 22 | - [ ] Does this PR require a regression test? All fixes require a regression test. 23 | - [ ] Does this add new dependencies? 24 | - [ ] Will this feature require a new piece of infrastructure be implemented? 25 | - [ ] Did you update the documentation per these changes? 26 | - [ ] If you added a new component, did you add an example in /examples? -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sprintly-UI 2 | [![Circle CI](https://circleci.com/gh/sprintly/sprintly-ui/tree/master.svg?style=svg)](https://circleci.com/gh/sprintly/sprintly-ui/tree/master) 3 | [![Coverage Status](https://coveralls.io/repos/sprintly/sprintly-ui/badge.svg?branch=master&service=github)](https://coveralls.io/github/sprintly/sprintly-ui?branch=master) 4 | [![Dependency Status](https://gemnasium.com/sprintly/sprintly-ui.svg)](https://gemnasium.com/sprintly/sprintly-ui) 5 | 6 | A library of reusable React components for building Sprintly UIs. 7 | 8 | The goal of this repository is to make it easier for developers (those who work at Sprintly as well as those who use Sprintly) to build Sprintly interfaces that look and feel like the Sprintly product. 9 | 10 | 11 | ## Usage 12 | To use Sprintly-ui in your project: ```$ npm install sprintly-ui```. Or, generate a UMD bundle containing unminified and minified versions, run the prepublish script: ```$ npm run prepublish```. [Note: For unminified only, run the gulp ```build``` task.] 13 | 14 | Then add a component to your project by requiring it at the top of your file: 15 | ``` 16 | // for example, to use just the Estimator component: 17 | 18 | var SprintlyUI = require('sprintly-ui').Estimator; 19 | 20 | React.renderComponent( 21 | 27 | ); 28 | ``` 29 | ...or include ```dist/js/sprintly-ui.min.js``` in a script tag in the head tag of your html file. 30 | 31 | 32 | ## Styles 33 | To make it easier to modify component styles, we've decided against the React recommendation to inline styles and instead offer versioned stylesheets available via an Amazon S3 bucket that we've made public. To add Sprintly-ui styles into your project, include `````` in the head of your html file. Just make sure that the ```<.../v1.0.0...>``` part is up-to-date and reflects the version of Sprintly-UI that you're using. 34 | 35 | Alternatively, you may choose to store and serve the unminified or minified stylesheet in ```/dist/css```. You'll need to build those files via ```$ npm run build-css```. 36 | 37 | 38 | ## Working with components and fetching data 39 | Sprintly-ui is a component library that is built using Facebook's React library. If you aren't familiar with React, you can think about it as the "V" (for "view") in MVC, though you can also build components that have controller-like functionality. 40 | 41 | Since we want to be non-deterministic about how these components will be used, the assumption we make is that components in this library will be owned and managed by parent views that you, the user, will create. Just remember that you need to create a root node (typically an element with an 'id' property on it) in a template or html file to render your component into. 42 | 43 | These parent or "controller views" will need to do the work of fetching data and propagating changes on that data down to their child components. We're using [Backbone](http://backbonejs.org/) and [Flux](https://facebook.github.io/flux/) on projects here at Sprintly, but Sprintly-ui components should be pluggable into your framework of choice. We've open-sourced two distinct clients for pulling data from Sprintly: [Sprintly-search](https://github.com/sprintly/sprintly-search), and [Sprintly-data](https://github.com/sprintly/sprintly-data). 44 | 45 | 46 | ## Development 47 | Good-to-knows: there are a handful of npm convenience scripts available for your use in package.json, but you'll find more incremental tasks in the gulpfile. 48 | 49 | To run a dev server and Browserfy watch tasks, run ```$ npm run dev```. This will open the examples homepage where you'll find links to component examples. These files require CSS to be built via the ```$ npm run build-css``` command. 50 | 51 | If you are building new components to add to this repo, you may add them to the appropriate example file or create a new example file to help you develop. There's a [template file](examples/template.html) that you can copy over if creating a new example file. Please include your example file as part of any PRs for adding new components. 52 | 53 | Please read the [contributing doc](CONTRIBUTING.md) before starting work and make use of the [PR template](PR_TEMPLATE.md) when submitting pull requests. 54 | 55 | 56 | ## Utils and mixins 57 | We hope to provide utility classes and mixins wherever possible for controlling things like making item changes via component menus, sorting item data in tables, extending component functionality, etc. Please look for these in [utils](src/utils/) (docs [here](docs/utils.md)) and [mixins](src/mixins/) (docs [here](docs/mixins.md)). 58 | 59 | 60 | ## Tests and code coverage 61 | Running ```$ npm test``` will run tests in the cli and then in the browser (a test server will open localhost:8080/test/ automatically). To run tests in the terminal only run ```$ npm run test-cli```, or to run tests the browser only run ```$ npm run test-browser```. 62 | 63 | To see Istanbul coverage information, run ```$ npm test``` to build tests and start the server, 64 | and, in a new tab, run ```$ npm run coverage```. To view html coverage info, visit localhost:8080/test/coverage/lcov-report. If you are unfamiliar with lcov html reports, these allow you to drill down through code files to view per-file coverage data as well as line-by-line coverage. 65 | 66 | 67 | ## Examples 68 | You can view examples locally in the browser by running ```$ npm run build-css && npm run dev```, or take a look at some example code using the links below. 69 | * [Selector menu example code](examples/menus.html) 70 | * [SortableTable example code](examples/tables.html) 71 | * [Misc. components example code](examples/misc.html) 72 | -------------------------------------------------------------------------------- /build/coverage: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "service_name: CircleCI" >> .coveralls.yml 4 | echo "repo_token: $COVERALLS_TOKEN" >> .coveralls.yml 5 | echo "git_commit: $CIRCLE_SHA1" >> .coveralls.yml 6 | echo "service_job_id: $CIRCLE_BUILD_NUM" >> .coveralls.yml 7 | npm run coverage 8 | npm run coveralls 9 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | node: 3 | version: 5.1.0 4 | environment: 5 | COVERALLS_TOKEN: "y8CHg5kSoIhd6WJxVYAoKWNBY9PIIn3uE" 6 | COVERALLS_GIT_BRANCH: $CIRCLE_BRANCH 7 | test: 8 | post: 9 | - /bin/bash -x build/coverage 10 | -------------------------------------------------------------------------------- /docs/menus.md: -------------------------------------------------------------------------------- 1 | # Menu Components 2 | 3 | ### Selector Menu 4 | 5 | Renders dropdown that shows a currently selected option, and when clicked opens to show a list of options and fuzzy-search input for finding from among the options list. 6 | 7 | Matching user-entered text in search is case insensitive and partial-aware (ie, if a user enters "foo" and presses ENTER key, and there is at least one option containing "foo", that option will be selected. If there is more than one option containing "foo", the first option in lexical order will be selected.) 8 | 9 | 10 | ``` 11 | 15 | ``` 16 | 17 | #### props.optionsList (array of objects) 18 | An array of options (for example, myBackboneCollection.toJSON()). Each option in the list needs to have either a 'name' or a 'title' field to show in the menu. 19 | 20 | #### props.defaultSelection (string) 21 | If you'd like the default selection to be something other than 'All', specify an alternative string. 22 | 23 | #### props.onSelectionChange (function; required) 24 | Callback function that will be triggered when a user selects an item via the menu. You might use this, for example, to show the user different information via the parent view when selection changes. -------------------------------------------------------------------------------- /docs/misc.md: -------------------------------------------------------------------------------- 1 | # Misc Components 2 | 3 | 4 | ### Assigner 5 | WIP 6 | Can use SelectorMenu for simple assigner with typeahead. 7 | 8 | 9 | ### Estimator 10 | 11 | The Estimator component renders an item score that, when clicked, opens a score-editing menu. Score is editable via an edit menu that will open on score click unless props.readOnly is passed true. 12 | 13 | #### props.modelId (array of numbers) 14 | Representing product id and model id: [productId, modelId]. Supports usage in multi-product environments where we may need to identify and act on single items but where items from different products can have the same item number. 15 | 16 | #### props.readOnly (boolean) 17 | Read only items will render a score that is not editable and won't render a menu on click. 18 | 19 | #### props.itemType (string; required) 20 | Item type, ie: 'story', 'task', 'test', or 'defect'. 21 | 22 | #### props.score (string; required) 23 | Item score, ie: '~', S', 'M', 'L', 'XL'. The '~' signifies an unscored item. 24 | 25 | #### props.estimateChanger (object) 26 | ``` 27 | // Something like ... 28 | 29 | var estimateChanger = { 30 | changeScore: function(modelId, newScore) { 31 | var item = items.findById(modelId); 32 | 33 | if (newScore !== item.get('oldScore') { 34 | item.set('score', newScore); 35 | } 36 | } 37 | }; 38 | ``` 39 | 40 | If readOnly is false, then score is editable and this object can be used to provide a utility for changing item score externally. Must include a ```changeScore``` method. 41 | 42 | 43 | ### Expander 44 | 45 | Renders buttons for toggling between expanded and condensed modes in various views. Used in Sprintly in columns and tables. 46 | 47 | #### props.expanded (string) 48 | Set the default state, either 'expanded' or 'condensed'. 49 | 50 | #### props.onClick (function; required) 51 | Handler for triggering change in parent state based on whether 'expanded' or 'condensed' are currently active. 52 | 53 | 54 | ### Status 55 | WIP 56 | 57 | 58 | ### TagEditor 59 | 60 | Interface for adding and removing item tags. Renders an edit icon that, when clicked, opens a menu with an add item input and list of item's tags that may be deleted/removed. If an item has no tags, renders the add item input alone. 61 | 62 | #### props.modelId (array of numbers) 63 | An array representing item product's id and item number. Supports identification of single items in a multi-product environment where items from different products may share the same item number. 64 | 65 | #### props.readOnly (boolean) 66 | If readOnly is passed {true}, clicking the tag icon will not open a tag edit menu. 67 | 68 | #### props.tags (array of strings; required) 69 | Array of item tags, for example: ```['tag1', 'tag2', 'tag3']```. If an item has no tags, pass an empty array. 70 | 71 | #### props.tagChanger (object) 72 | Object for passing utility function through for the purposes of adding and removing tags from item models. Must contain an ```addOrRemove``` method. 73 | 74 | ``` 75 | // Something like... 76 | 77 | var tagChanger = { 78 | addOrRemove: function(modelId, tag, action) { 79 | var item = items.findItemById(modelId); 80 | action === 'add' ? item.add(tag) : item.remove(tag); 81 | } 82 | }; 83 | ``` 84 | 85 | 86 | ### Tags 87 | 88 | Renders either a textual list of tags ('tag1, tag2, tag3') or a tag count ('4 tags') button that, when clicked, opens a popup showing the item's tags. If passing a click handler through via navigatorUtility prop or altOnTagClick, clicking a tag will trigger the respective handler. 89 | 90 | #### props.tags (array of strings) 91 | List of tags, for example: ['tag1', 'tag2']. 92 | 93 | #### props.condensed (boolean) 94 | If you'd like tags represented as a clickable tag count instead of a textual list of tags, pass {true}. 95 | 96 | #### props.navigatorUtility (object) 97 | Object that wraps a ```setTagFilterAndRoute``` function. 98 | 99 | ``` 100 | // Something like... 101 | 102 | var navigator = { 103 | setTagFilterAndRoute: function (tag) { 104 | myFilters.set({tags: tag}); 105 | myView.navigate('/items/?tags=' + tag); 106 | } 107 | }; 108 | ``` 109 | 110 | #### props.altOnTagClick (function) 111 | If not using a navigator utility to route to tag-filtered views and you want to trigger an alternate behavior when a user clicks on a tag, use this prop. -------------------------------------------------------------------------------- /docs/mixins.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sprintly/sprintly-ui/de466c77f3a24a5cd00f5a719450065080e2ea98/docs/mixins.md -------------------------------------------------------------------------------- /docs/tables.md: -------------------------------------------------------------------------------- 1 | # Tables 2 | 3 | ### Sortable Table 4 | 5 | Renders a table for displaying Sprintly item data delivered as (nested) model json. Sortable table was built to be sortable via the use of an external sort utility (such as [this group-and-sort utility](../src/utils/group_and_sort.js)), and will be static if no sort utility is used. 6 | 7 | See the [example file](../examples/tables.html) for example implementation using the utils/group_and_sort.js sorter. 8 | 9 | If made sortable, clicking any label in the column header will sort the table data by that field in alternating directions (ie, in ascending/descending order). 10 | 11 | 12 | ``` 13 | 21 | ``` 22 | 23 | #### props.tableType (string) 24 | Identifier that becomes useful if you're creating multiple tables from one of several collections and want to track which table is triggering callbacks in your parent view. For example, we use this identifier to track which status-based table in Sprintly's Mine view should be sorted and rerendered on a user sort event. 25 | 26 | 27 | #### props.label (string) 28 | Table title header that will appear above the table. 29 | 30 | 31 | #### props.collection (array of objects; required) 32 | We recommend using [Backbone Supermodel](http://pathable.github.io/supermodel/) to generate nested collections, or use the [Sprintly-data client](https://github.com/sprintly/sprintly-data), which returns collections of supermodels. 33 | 34 | It is also possible to use non-nested item data (such as sprintly api data collected via the [Sprintly-search client](https://github.com/sprintly/sprintly-search)) with Sortable Table _as long as you're not passing "product" through as a column option_ (see below). 35 | 36 | Either way, you'll need to serialize models to json before passing them to Sortable Table. 37 | 38 | 39 | #### props.columnNames (array of strings; required) 40 | In addition to expecting an array of nested json data, Sortable Table expects an array of item properties you'd like represented in the table as columns/properties to sort on. 41 | 42 | Options include: 43 | 44 | * 'product' (the product the item belongs to; don't pass if using non-nested item data) 45 | * 'number' 46 | * 'size' (item estimate or score) 47 | * 'status' 48 | * 'title' 49 | * 'tags' 50 | * 'created by' 51 | * 'assigned to' 52 | * 'created' (date created) 53 | 54 | 55 | #### props.baseUrl (string) 56 | Base url for table row linkable elements, such as item titles, item numbers, and product names. 57 | 58 | 59 | #### props.onSortCollection (function) 60 | Function to call on parent view that will handle sorting. See the [example file](../examples/tables.html) for example using the utils/group_and_sort.js sorter. 61 | 62 | Use _.noop() or similar if rendering a static table. 63 | 64 | 65 | #### props.isBulkEditable (boolean) 66 | If true, the left-most column in the table will contain checkboxes for bulk-selection. Use with onBulkSelect handler (below). 67 | 68 | 69 | #### props.onBulkSelect (function) 70 | Bulk selection handler to trigger on parent view for operating on item data in aggregate. 71 | 72 | 73 | #### props.modelChangerUtilties (object) 74 | Container for passing through utility objects and/or functions for responding to item changes that are made via various Sortable Table child components (that may or may not be in play depending on columnNames passed in). 75 | 76 | These include: 77 | 78 | * Estimator (menu for changing item size/score) 79 | * Status (menu for changing item status) 80 | * TagEditor (menu for adding/removing tags) 81 | * Assigner (menu for changing item 'assigned to' status) 82 | 83 | ``` 84 | // Something like.... 85 | 86 | var modelChangers = { 87 | estimateChanger: { 88 | changeScore: function() {..change item score..} 89 | }, 90 | statusChanger: { 91 | changeStatus: function() {..change item status..} 92 | }, 93 | tagChanger: { 94 | addOrRemove: function() {..add or remove item tag..} 95 | }, 96 | assigneeChanger: { 97 | changeAssignee: function() {..change item assignee..} 98 | } 99 | }; 100 | ``` 101 | 102 | 103 | #### props.navigatorUtility (object) 104 | Object whose intended purpose is to provide a navigation utility for routing to a tag-filtered view when users click on a tag in the Tags component menu. 105 | 106 | ``` 107 | // Example: 108 | 109 | var navigator = { 110 | setTagFilterAndRoute: function() {..filter items by tag and load them in new view..} 111 | } 112 | ``` 113 | -------------------------------------------------------------------------------- /docs/utils.md: -------------------------------------------------------------------------------- 1 | # Utils 2 | 3 | ### [GroupSort utility] 4 | 5 | A [sorting utility](../src/utils/group_and_sort.js) that exposes methods for intelligently sorting Sprintly items by property in either ascending or descending order, while also maintaining groupings of parents with their subitems. 6 | 7 | #### Use 8 | ``` 9 | var GroupSort = require('sprintly-ui').GroupSort; 10 | 11 | var sorted = GroupSort.groupSort(myItemsArray); 12 | ``` 13 | 14 | Whether sort is ascending or descending, parents will _always_ be returned ahead of their subitems. 15 | 16 | ``` 17 | // For example, given an array of item objects: 18 | 19 | var items = [ 20 | {number: 1, product: {name: 'b-product'}, assigned_to: 'ann'}, 21 | {number: 5, product: {name: 'c-product'}, assigned_to: 'ben'}, 22 | {number: 2, product: {name: 'a-product'}, parent: 1, assigned_to: 'camille'} 23 | ]; 24 | 25 | // calling GroupSort.groupSort... 26 | 27 | // ...with arguments (items, 'number', 'descending') returns: 28 | 29 | {number: 5, product: {name: 'c-product'}, assigned_to: 'ben'}, 30 | {number: 1, product: {name: 'b-product'}, assigned_to: 'ann'}, 31 | {number: 2, product: {name: 'a-product'}, parent: 1, assigned_to: 'camille'} 32 | 33 | // where the parent item {number: 1} appears before its subitem {number: 2}. 34 | 35 | // ...or, with arguments (items, 'product', 'ascending') returns: 36 | 37 | {number: 1, product: {name: 'b-product'}, assigned_to: 'ann'}, 38 | {number: 2, product: {name: 'a-product'}, parent: 1, assigned_to: 'camille'}, 39 | {number: 5, product: {name: 'c-product'}, assigned_to: 'ben'} 40 | 41 | // where the parent {name: 'b-product'} appears before it's child {name: 'a-product'}. 42 | ``` 43 | -------------------------------------------------------------------------------- /examples/example.css: -------------------------------------------------------------------------------- 1 | /** Examples main page **/ 2 | /*.example__body { 3 | background-color: rgb(173, 216, 230); 4 | }*/ 5 | h1, 6 | a { 7 | color: rgb(95, 158, 160); 8 | } 9 | 10 | p { 11 | color: #444; 12 | } 13 | 14 | .example__wrapper { 15 | background-color: #fff; 16 | margin: 20px 40px; 17 | padding: 40px; 18 | border: 1px solid #000; 19 | border-radius: 10px; 20 | } 21 | 22 | .example__header { 23 | margin-bottom: 10px; 24 | } 25 | 26 | .example__title { 27 | margin-top: 60px; 28 | } 29 | 30 | .example__description { 31 | color: #444; 32 | font-size: 14px; 33 | line-height: 19px; 34 | margin-top: 0; 35 | } 36 | 37 | .example__list { 38 | list-style-type: square; 39 | margin-top: 20px; 40 | } 41 | 42 | .example__item a { 43 | font-size: 18px; 44 | line-height: 40px; 45 | } 46 | 47 | button:focus { 48 | outline: 0; 49 | } 50 | 51 | /** Menus **/ 52 | .message { 53 | color: rgb(95, 158, 160); 54 | margin-top: 80px; 55 | margin-left: 2px; 56 | } 57 | 58 | .selector__wrapper { 59 | position: relative; 60 | width: 280px; 61 | } 62 | 63 | .selector__searchbox { 64 | width: 268px; 65 | margin-left: 6px; 66 | } 67 | 68 | .selector__icon { 69 | left: 262px; 70 | } -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 16 | 17 |
18 | 19 |
20 |

Example pages

21 | 26 |
27 |
28 | 29 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /examples/menus.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 16 | 17 |
18 |
19 |

Menu Components

20 |

Home

21 |

Selector Menu

22 |
23 |
24 |
25 | 26 | 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /examples/misc.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 16 | 17 |
18 | 19 |
20 |

Misc. Components

21 |

Home

22 |

Estimator

23 |
24 |

Expander

25 |
26 |

Tags

27 |
28 |

TagEditor

29 |
30 |
31 |
32 | 33 | 91 | 92 | 93 | -------------------------------------------------------------------------------- /examples/tables.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 17 | 18 |
19 |
20 |

Table Components

21 |

Home

22 |

Sortable Table

23 |

* sorting in this example is implemented via utils/group_and_sort.js.

24 |
25 |
26 |
27 | 28 | 78 | 79 | 80 | 81 | -------------------------------------------------------------------------------- /examples/template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 16 | 17 |
18 | 19 |
20 |

Example Category

21 |

Home

22 |

Component Example

23 |
div with id to render my example into
24 |
25 |
26 | 27 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _ = require('lodash'); 4 | var gulp = require('gulp'); 5 | var path = require('path'); 6 | var http = require('http'); 7 | var connect = require('connect'); 8 | var serveStatic = require('serve-static'); 9 | var source = require('vinyl-source-stream'); 10 | var openPage = require('open'); 11 | 12 | var browserify = require('browserify'); 13 | var istanbulify = require('browserify-istanbul'); 14 | var watchify = require('watchify'); 15 | var babelify = require('babelify'); 16 | var exorcist = require('exorcist'); 17 | 18 | var gutil = require('gulp-util'); 19 | var less = require('gulp-less'); 20 | var mochaPhantomJS = require('gulp-mocha-phantomjs'); 21 | var uglify = require('gulp-uglify'); 22 | var csso = require('gulp-csso'); 23 | var rename = require('gulp-rename'); 24 | var sourcemaps = require('gulp-sourcemaps'); 25 | 26 | /* 27 | * Dev 28 | */ 29 | var appArgs = { 30 | ignore: 'sinon', 31 | exclude: ['react'], 32 | standalone: 'SprintlyUI', 33 | debug: true, 34 | verbose: true 35 | }; 36 | 37 | var jsSrc = './src/'; 38 | var jsDest = './dist/js/'; 39 | var jsDist = 'sprintly-ui.js'; 40 | var jsMapDist = './dist/js/sprintly-ui.js.map'; 41 | 42 | function bundle(b) { 43 | return b.transform(babelify) 44 | .bundle() 45 | .pipe(exorcist(jsMapDist)) 46 | .pipe(source(jsDist)) 47 | .pipe(gulp.dest(jsDest)); 48 | } 49 | 50 | gulp.task('build', function() { 51 | var bundler = browserify(jsSrc, appArgs); 52 | return bundle(bundler); 53 | }); 54 | 55 | gulp.task('less', function() { 56 | gulp.src('./src/less/sprintly-ui.less') 57 | .pipe(sourcemaps.init()) 58 | .pipe(less()) 59 | .pipe(sourcemaps.write('.')) 60 | .pipe(gulp.dest('./dist/css/')); 61 | }); 62 | 63 | gulp.task('watch', function() { 64 | var bundler = watchify(browserify(jsSrc, _.extend({}, watchify.args, appArgs))); 65 | bundle(bundler); 66 | 67 | bundler.on('update', function() { 68 | return bundle(bundler); 69 | }); 70 | bundler.on('log', gutil.log); 71 | gulp.watch('src/less/**/*.less', ['less']); 72 | }); 73 | 74 | gulp.task('jsmin', ['build'], function() { 75 | gulp.src('./dist/js/sprintly-ui.js') 76 | .pipe(uglify()) 77 | .pipe(rename({extname: '.min.js'})) 78 | .pipe(gulp.dest('./dist/js')); 79 | }); 80 | 81 | gulp.task('cssmin', ['less'], function() { 82 | gulp.src('./dist/css/sprintly-ui.css') 83 | .pipe(csso()) 84 | .pipe(rename({extname: '.min.css'})) 85 | .pipe(gulp.dest('./dist/css')); 86 | }); 87 | 88 | gulp.task('dev-server', function() { 89 | var tests = connect(); 90 | tests.use(serveStatic('./')); 91 | http.createServer(tests).listen(8090); 92 | openPage('http://localhost:8090/examples/'); 93 | }); 94 | 95 | /* 96 | * Test 97 | */ 98 | var testArgs = { 99 | debug: true, 100 | verbose: true, 101 | ignore: 'sinon' 102 | }; 103 | 104 | var testSrc = './test/index.js'; 105 | var testDest = './test/'; 106 | var testDist = 'build.js'; 107 | var testMapDist = './test/build.js.map'; 108 | 109 | function bundleTests(b) { 110 | return b.transform(babelify) 111 | .transform(istanbulify({ignore: ["**/node_modules/**","**/test/**"]})) 112 | .bundle() 113 | .pipe(exorcist(testMapDist)) 114 | .pipe(source(testDist)) 115 | .pipe(gulp.dest(testDest)); 116 | } 117 | 118 | gulp.task('build-test', function() { 119 | var bundler = browserify(testSrc, testArgs); 120 | return bundleTests(bundler); 121 | }); 122 | 123 | gulp.task('watch-test', function() { 124 | var bundler = watchify(browserify(testSrc, _.extend({}, watchify.args, testArgs))); 125 | bundleTests(bundler); 126 | 127 | bundler.on('update', function() { 128 | return bundleTests(bundler); 129 | }); 130 | }); 131 | 132 | gulp.task('test-server', function() { 133 | var tests = connect(); 134 | tests.use(serveStatic('./')); 135 | http.createServer(tests).listen(8080); 136 | openPage('http://localhost:8080/test/'); 137 | }); 138 | 139 | gulp.task('test', ['build-test'], function() { 140 | gulp.src('./test/index.html') 141 | .pipe(mochaPhantomJS({ 142 | reporter: 'dot' 143 | })). 144 | on('error', function(err) { 145 | console.log("There was an error running tests: ", err.message); 146 | }); 147 | }); 148 | 149 | gulp.task('test-coverage', function() { 150 | var root = path.resolve('./test'); 151 | gulp.src('./test/index.html') 152 | .pipe(mochaPhantomJS({ 153 | path: root + 'index.html', 154 | phantomjs: { 155 | hooks: root + '/phantom_hooks' 156 | } 157 | })); 158 | }); 159 | 160 | 161 | gulp.task('default', ['test']); 162 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sprintly-ui", 3 | "title": "Sprintly UI", 4 | "description": "A React-based UI kit for building Sprintly interfaces", 5 | "version": "2.0.1", 6 | "main": "./src/index.js", 7 | "homepage": "https://github.com/sprintly/sprintly-ui", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/sprintly/sprintly-ui.git" 11 | }, 12 | "bugs": { 13 | "url": "https://github.com/sprintly/sprintly-ui/issues" 14 | }, 15 | "dependencies": { 16 | "browserify-istanbul": "^0.2.1", 17 | "fuzzy": "^0.1.0", 18 | "lodash": "^3.10.1", 19 | "object-path": "^0.9.0", 20 | "@sprintly/react-onclickoutside": "^1.0.0" 21 | }, 22 | "peerDependencies": { 23 | "moment": "^2.10.6", 24 | "react": "^0.13.3" 25 | }, 26 | "devDependencies": { 27 | "babelify": "^6.1.2", 28 | "browserify": "^11.1.0", 29 | "chai": "^3.2.0", 30 | "connect": "^3.4.0", 31 | "coveralls": "^2.11.2", 32 | "es5-shim": "^4.0.5", 33 | "exorcist": "^0.4.0", 34 | "gulp": "^3.9.0", 35 | "gulp-csso": "^1.0.0", 36 | "gulp-less": "^3.0.1", 37 | "gulp-mocha-phantomjs": "^0.10.1", 38 | "gulp-rename": "^1.2.2", 39 | "gulp-sourcemaps": "^1.5.0", 40 | "gulp-uglify": "^1.4.1", 41 | "gulp-util": "^3.0.6", 42 | "istanbul": "^0.3.20", 43 | "mocha": "^2.0.1", 44 | "moment": "^2.10.6", 45 | "open": "^0.0.5", 46 | "phantomjs": "1.9.7-15", 47 | "react": "^0.13.3", 48 | "serve-static": "^1.10.0", 49 | "sinon": "^1.12.1", 50 | "vinyl-source-stream": "^1.0.0", 51 | "watchify": "^3.4.0" 52 | }, 53 | "browserify": { 54 | "transform": [ 55 | "babelify" 56 | ] 57 | }, 58 | "scripts": { 59 | "dev": "gulp build && gulp less && gulp dev-server", 60 | "dev-test": "gulp test && gulp test-server", 61 | "test": "gulp test", 62 | "coverage": "gulp test-coverage && istanbul report --root coverage lcov && mv coverage/ test/", 63 | "coveralls": "cat test/coverage/lcov.info | coveralls", 64 | "prepublish": "gulp jsmin && gulp cssmin", 65 | "clean": "rm test/build.js && rm -rf test/coverage/" 66 | }, 67 | "directories": { 68 | "doc": "docs", 69 | "example": "examples", 70 | "test": "test" 71 | }, 72 | "keywords": [ 73 | "sprintly", 74 | "react", 75 | "ui kit", 76 | "ui-kit", 77 | "ui library", 78 | "components", 79 | "ui components", 80 | "component library", 81 | "quickleft", 82 | "quick left" 83 | ], 84 | "author": "Quick Left Team (http://quickleft.com/)", 85 | "license": "BSD-2-Clause" 86 | } 87 | -------------------------------------------------------------------------------- /roadmap.md: -------------------------------------------------------------------------------- 1 | ## Roadmap 2 | 3 | #### Work in Progress (Current) 4 | * Status menu 5 | * Item card component 6 | * Item column component 7 | 8 | #### Planned work (Backlog) 9 | * Assigner menu 10 | * Add-item component 11 | 12 | #### Someday 13 | * GH pages for displaying examples code -------------------------------------------------------------------------------- /src/components/estimator.js: -------------------------------------------------------------------------------- 1 | var React = window.React || require('react/addons'); 2 | var onClickOutside = require('@sprintly/react-onclickoutside'); 3 | /* 4 | * Estimator element displays item score that, when clicked, opens a menu 5 | * for editing the current score. Expects an estimate changer utility object 6 | * containing a changeScore method for handling score changing on the model 7 | * and syncing any changes with the backend. 8 | */ 9 | 10 | var Estimator = React.createClass({ 11 | ALL_ESTIMATES: [0, 1, 3, 5, 8], 12 | 13 | ESTIMATE_HASH: { 14 | 0: '?', 15 | 1: 'S', 16 | 3: 'M', 17 | 5: 'L', 18 | 8: 'XL' 19 | }, 20 | 21 | mixins: [ 22 | onClickOutside 23 | ], 24 | 25 | propTypes: { 26 | modelId: React.PropTypes.arrayOf(React.PropTypes.number), 27 | readOnly: React.PropTypes.bool, 28 | itemType: React.PropTypes.string.isRequired, 29 | score: React.PropTypes.string.isRequired, 30 | estimateChanger: React.PropTypes.object 31 | }, 32 | 33 | getDefaultProps: function() { 34 | return { 35 | modelId: null, 36 | readOnly: false 37 | }; 38 | }, 39 | 40 | getInitialState: function() { 41 | return { 42 | menuOpen: false 43 | }; 44 | }, 45 | 46 | handleClickOutside: function(ev) { 47 | ev.stopPropagation(); 48 | 49 | this.setState({ 50 | menuOpen: false 51 | }); 52 | }, 53 | 54 | onScoreClick: function() { 55 | if (this.props.readOnly || !this.props.estimateChanger) { 56 | return; 57 | } 58 | 59 | var openOrClose = this.state.menuOpen === false ? true : false; 60 | 61 | this.setState({ 62 | menuOpen: openOrClose 63 | }); 64 | }, 65 | 66 | onScoreChange: function(ev) { 67 | if (this.props.readOnly || !this.props.estimateChanger) { 68 | return; 69 | } 70 | 71 | var newScore = parseInt(ev.target.getAttribute('data-score'), 10); 72 | 73 | if (this.props.score === this.ESTIMATE_HASH[newScore].toLowerCase()) { 74 | return; 75 | } 76 | 77 | this.props.estimateChanger.changeScore(this.props.modelId, newScore); 78 | this.setState({ 79 | menuOpen: false 80 | }); 81 | }, 82 | 83 | render: function() { 84 | var currentScore = this.props.score === '~' ? '?' : this.props.score; 85 | var scoreMenu = null; 86 | 87 | if (this.state.menuOpen) { 88 | var scores = this.ALL_ESTIMATES.map(function(score) { 89 | return ( 90 |
  • 91 | 96 |
  • 97 | ); 98 | }.bind(this)); 99 | 100 | scoreMenu = ( 101 |
    102 |
      103 | {scores} 104 |
    105 |
    106 | ); 107 | } 108 | 109 | return ( 110 |
    111 | 115 | {scoreMenu} 116 |
    117 | ); 118 | } 119 | }); 120 | 121 | module.exports = Estimator; 122 | -------------------------------------------------------------------------------- /src/components/expander.js: -------------------------------------------------------------------------------- 1 | var React = window.React || require('react/addons'); 2 | 3 | /* 4 | * Buttons for toggling the expanded/condensed state of 5 | * column items and table rows. 6 | */ 7 | 8 | var Expander = React.createClass({ 9 | 10 | propTypes: { 11 | expanded: React.PropTypes.bool, 12 | onExpanderClick: React.PropTypes.func.isRequired 13 | }, 14 | 15 | getDefaultProps: function() { 16 | return { 17 | expanded: false 18 | }; 19 | }, 20 | 21 | render: function() { 22 | var expanded = this.props.expanded; 23 | var className = this.props.expanded ? 'expanded' : 'condensed'; 24 | 25 | var buttonClass = 'expander__button'; 26 | var iconClass = 'expander__icon'; 27 | 28 | return ( 29 |
    30 | 35 | 40 |
    41 | ); 42 | } 43 | }); 44 | 45 | module.exports = Expander; -------------------------------------------------------------------------------- /src/components/selector_menu/index.js: -------------------------------------------------------------------------------- 1 | var React = window.React || require('react/addons'); 2 | var _ = require('lodash'); 3 | var Label = require('./label'); 4 | var List = require('./list'); 5 | var Search = require('./search'); 6 | var fuzzy = require('fuzzy'); 7 | var onClickOutside = require('@sprintly/react-onclickoutside'); 8 | 9 | /* 10 | * Renders dropdown showing currently selected options, 11 | * list of items, and fuzzy-search input. 12 | * 13 | * Takes a list of options (for example, collection.toJSON()); 14 | * an optional default selection (or don't pass anything for default 'All'); 15 | * and an 'onSelectionChange' callback to trigger some action based on user selection. 16 | * 17 | * NOTE: 18 | * Options in optionsList must have either a 'title' or a 'name' property. 19 | * 20 | */ 21 | 22 | var SelectorMenu = React.createClass({ 23 | 24 | propTypes: { 25 | defaultSelection: React.PropTypes.string, 26 | optionsList: React.PropTypes.array, 27 | onSelectionChange: React.PropTypes.func.isRequired 28 | }, 29 | 30 | mixins: [ 31 | onClickOutside 32 | ], 33 | 34 | getDefaultProps: function() { 35 | return { 36 | defaultSelection: 'All', 37 | optionsList: [] 38 | }; 39 | }, 40 | 41 | getInitialState: function() { 42 | return { 43 | visible: [], 44 | expanded: false, 45 | clearInput: false 46 | }; 47 | }, 48 | 49 | componentWillReceiveProps: function(nextProps) { 50 | var nextOptions = _.compact( 51 | _.pluck(nextProps.optionsList, 'title').concat(_.pluck(nextProps.optionsList, 'name')) 52 | ); 53 | 54 | var currentOptions = _.compact( 55 | _.pluck(this.props.optionsList, 'title').concat(_.pluck(this.props.optionsList, 'name')) 56 | ); 57 | 58 | if (_.difference(nextOptions, currentOptions).length > 0) { 59 | this.setState({ 60 | visible: [], 61 | selected: '' 62 | }); 63 | } 64 | }, 65 | 66 | getOptionNames: function() { 67 | // Returns a list of option names, plus the default value. 68 | var options = [this.props.selection || this.state.selected || this.props.defaultSelection]; 69 | 70 | _.each(this.props.optionsList, function(option) { 71 | return option.title ? options.push(option.title) : options.push(option.name); 72 | }); 73 | 74 | return _.unique(options); 75 | }, 76 | 77 | handleClickOutside: function(ev) { 78 | ev.stopPropagation(); 79 | this.setState({ 80 | expanded: false 81 | }); 82 | }, 83 | 84 | onLabelClicked: function() { 85 | var expanded = this.state.expanded ? false : true; 86 | this.setState({ 87 | expanded: expanded, 88 | clearInput: true 89 | }); 90 | }, 91 | 92 | selectOption: function(optionName) { 93 | this.onLabelClicked(); 94 | 95 | this.setState({ 96 | selected: optionName, 97 | visible: [] 98 | }); 99 | 100 | this.props.onSelectionChange(optionName); 101 | }, 102 | 103 | processSearchInput: function(val) { 104 | // Matches partials against an alphabetically sorted list. 105 | var sortedNames = this.getOptionNames().sort(); 106 | 107 | var selection = _.find(sortedNames, function(name) { 108 | return name.toLowerCase().indexOf(val) > -1; 109 | }); 110 | 111 | if (!selection || selection === this.props.defaultSelection) { 112 | selection = this.props.defaultSelection; 113 | } 114 | 115 | this.selectOption(selection); 116 | }, 117 | 118 | filterList: function(filterBy) { 119 | // If user input, returns fuzzy search-delimited list of options; 120 | // else, shows all options. 121 | if (!filterBy) { 122 | this.setState({ 123 | visible: this.getOptionNames() 124 | }); 125 | return; 126 | } 127 | 128 | var visible = _.pluck(fuzzy.filter(filterBy, this.getOptionNames()), 'string'); 129 | 130 | this.setState({ 131 | visible: visible, 132 | clearInput: false 133 | }); 134 | }, 135 | 136 | render: function() { 137 | var wrapperClass = this.state.expanded ? 'selector__wrapper expanded' : 'selector__wrapper'; 138 | var innerClass = this.state.expanded ? 'inner-wrapper expanded' : 'inner-wrapper'; 139 | var visible = this.state.visible.length > 0 ? this.state.visible : this.getOptionNames(); 140 | var selected = this.props.selection || this.state.selected || this.props.defaultSelection; 141 | 142 | return ( 143 |
    144 |
    162 | ); 163 | } 164 | }); 165 | 166 | module.exports = SelectorMenu; 167 | -------------------------------------------------------------------------------- /src/components/selector_menu/label.js: -------------------------------------------------------------------------------- 1 | var React = window.React || require('react/addons'); 2 | 3 | 4 | var Label = React.createClass({ 5 | 6 | propTypes: { 7 | selected: React.PropTypes.string, 8 | onClick: React.PropTypes.func.isRequired 9 | }, 10 | 11 | getDefaultProps: function() { 12 | return { 13 | selected: 'All' 14 | }; 15 | }, 16 | 17 | render: function() { 18 | return ( 19 |
    20 | {this.props.selected} 21 | 22 |
    23 | ); 24 | } 25 | }); 26 | 27 | 28 | module.exports = Label; -------------------------------------------------------------------------------- /src/components/selector_menu/list.js: -------------------------------------------------------------------------------- 1 | var React = window.React || require('react/addons'); 2 | 3 | var List = React.createClass({ 4 | 5 | propTypes: { 6 | optionNames: React.PropTypes.array, 7 | onOptionSelect: React.PropTypes.func.isRequired 8 | }, 9 | 10 | getDefaultProps: function() { 11 | return { 12 | optionNames: [] 13 | }; 14 | }, 15 | 16 | render: function() { 17 | var options = this.props.optionNames.map(function(optionName) { 18 | return optionName.length ? ( 19 |
  • 20 | {optionName} 21 |
  • 22 | ) : null; 23 | }.bind(this)); 24 | 25 | return ( 26 |
      27 | {options} 28 |
    29 | ); 30 | } 31 | }); 32 | 33 | module.exports = List; 34 | -------------------------------------------------------------------------------- /src/components/selector_menu/search.js: -------------------------------------------------------------------------------- 1 | var React = window.React || require('react/addons'); 2 | 3 | 4 | var Search = React.createClass({ 5 | 6 | propTypes: { 7 | inputOverride: React.PropTypes.string, 8 | filterList: React.PropTypes.func.isRequired, 9 | processSearchInput: React.PropTypes.func.isRequired 10 | }, 11 | 12 | getInitialState: function() { 13 | return { 14 | value: '' 15 | }; 16 | }, 17 | 18 | componentWillReceiveProps: function(nextProps) { 19 | if (nextProps && nextProps.clearInput) { 20 | this.setState({ 21 | value: "" 22 | }); 23 | } 24 | }, 25 | 26 | componentDidUpdate: function() { 27 | React.findDOMNode(this).focus(); 28 | }, 29 | 30 | handleChange: function(ev) { 31 | var val = ev.target.value; 32 | 33 | this.setState({ 34 | value: val 35 | }); 36 | 37 | this.props.filterList(val); 38 | }, 39 | 40 | maybeSubmit: function(ev) { 41 | var val = this.state.value; 42 | 43 | if (ev.which === 13 && val.length) { 44 | this.setState({ 45 | value: '' 46 | }); 47 | this.props.processSearchInput(val.toLowerCase()); 48 | } 49 | }, 50 | 51 | render: function() { 52 | return ( 53 | 60 | ); 61 | } 62 | }); 63 | 64 | 65 | module.exports = Search; 66 | -------------------------------------------------------------------------------- /src/components/sortable_table/header.js: -------------------------------------------------------------------------------- 1 | var React = window.React || require('react/addons'); 2 | var _ = require('lodash'); 3 | var Expander = require('../expander'); 4 | 5 | /* 6 | * Renders header bar where cells are clickable elements that trigger a 7 | * sort on that column. Also responsible for proxying Expander events. 8 | * Property 'tableType' describes the data described by the table: ie, 9 | * might be 'backlog', if the table shows just backlog items. This property 10 | * is passed back through the label click callback so that you may take 11 | * action on that table's data alone (if rendering multiple tables in a view.) 12 | */ 13 | 14 | 15 | var TableHeader = React.createClass({ 16 | 17 | propTypes: { 18 | tableType: React.PropTypes.string, 19 | columns: React.PropTypes.arrayOf(React.PropTypes.string).isRequired, 20 | expanded: React.PropTypes.bool, 21 | isBulkEditable: React.PropTypes.bool, 22 | onExpanderClick: React.PropTypes.func, 23 | onLabelClick: React.PropTypes.func 24 | }, 25 | 26 | getInitialState: function() { 27 | /* 28 | * Keeps track of sort direction on each column. 29 | */ 30 | var directionHash = _.zipObject(this.props.columns, 31 | _.times(this.props.columns.length, function() { return 'ascending'; })); 32 | 33 | return { 34 | directionHash: directionHash 35 | }; 36 | }, 37 | 38 | onLabelClick: function(columnName, ev) { 39 | /* 40 | * Grabs table type and sort option and passes to sort callback. 41 | * Flips direction in direction hash. 42 | */ 43 | var direction = this.state.directionHash[columnName] === 'ascending' ? 'descending' : 'ascending'; 44 | var hashCopy = _.cloneDeep(this.state.directionHash); 45 | 46 | this.props.onLabelClick(this.props.tableType, columnName, direction); 47 | hashCopy[columnName] = direction; 48 | 49 | this.setState({ 50 | directionHash: hashCopy 51 | }); 52 | }, 53 | 54 | render: function() { 55 | /* 56 | * Render column labels and optionally render an expander element that proxies click events 57 | * to table (Note: we'll only render this if we have a control column to render it into). 58 | */ 59 | var hasProductColumn = _.contains(this.props.columns, 'product'); 60 | 61 | var control = this.props.isBulkEditable ? 62 | : null; 63 | 64 | var expander = hasProductColumn ? 65 | ( 66 | 67 | 71 | 72 | ) : null; 73 | 74 | return ( 75 | 76 | {control} 77 | {expander} 78 | {this.buildColumnLabels()} 79 | 80 | ); 81 | }, 82 | 83 | buildColumnLabels: function() { 84 | // We don't want to render a label for the 'Control' column, so pop it off the list. 85 | var columns = _.without(this.props.columns, 'product'); 86 | 87 | return _.map(columns, function(column) { 88 | return ( 89 | 90 | 94 | 95 | ); 96 | }, this); 97 | } 98 | }); 99 | 100 | module.exports = TableHeader; 101 | -------------------------------------------------------------------------------- /src/components/sortable_table/index.js: -------------------------------------------------------------------------------- 1 | var React = window.React || require('react/addons'); 2 | var _ = require('lodash'); 3 | var TableHeader = require('./header'); 4 | var TableRow = require('./row'); 5 | 6 | /* 7 | * Takes an array of json objects (for example, a Backbone.Collection.toJSON()) 8 | * and an array of attributes to serve as column header labels and creates a sortable table from the data. 9 | * Column header labels are clickable and will call an external sort method, passing 10 | * 'ascending' or 'descending' based on which option is currently active. 11 | * 12 | * Responsible for managing the expanded/condensed state of rows and for proxying sort 13 | * events up to parent view for processing. 14 | */ 15 | 16 | 17 | var SortableTable = React.createClass({ 18 | 19 | propTypes: { 20 | tableType: React.PropTypes.string, 21 | label: React.PropTypes.string, 22 | collection: React.PropTypes.arrayOf(React.PropTypes.object).isRequired, 23 | columnNames: React.PropTypes.arrayOf(React.PropTypes.string).isRequired, 24 | baseUrl: React.PropTypes.string, 25 | onSortCollection: React.PropTypes.func, 26 | isBulkEditable: React.PropTypes.bool, 27 | onBulkSelect: React.PropTypes.func, 28 | modelChangerUtilities: React.PropTypes.object, 29 | navigatorUtility: React.PropTypes.object 30 | }, 31 | 32 | getDefaultProps: function() { 33 | return { 34 | baseUrl: '', 35 | isBulkEditable: false, 36 | onBulkSelect: _.noop(), 37 | modelChangerUtilities: {} 38 | }; 39 | }, 40 | 41 | getInitialState: function() { 42 | return { 43 | expanded: false, 44 | sortBy: 'number' 45 | }; 46 | }, 47 | 48 | onExpandClicked: function(expanded) { 49 | if (expanded !== this.state.expanded) { 50 | this.setState({ 51 | expanded: expanded 52 | }); 53 | } 54 | }, 55 | 56 | render: function() { 57 | var rows = []; 58 | 59 | _.each(this.props.collection, function(model) { 60 | var modelId = [model.product.id, model.number]; 61 | var rowProps = { 62 | key: modelId, 63 | model: model, 64 | columns: this.props.columnNames, 65 | expanded: this.state.expanded, 66 | baseUrl: this.props.baseUrl, 67 | modelChangerUtilities: this.props.modelChangerUtilities, 68 | navigatorUtility: this.props.navigatorUtility, 69 | isBulkEditable: this.props.isBulkEditable, 70 | onBulkSelect: this.props.onBulkSelect 71 | }; 72 | 73 | if (model.isMatched && !model.parent) { 74 | // Add a spacer row above matched parents. 75 | rows.push( 76 | 77 | ); 78 | } 79 | rows.push( 80 | 81 | ); 82 | }, this); 83 | 84 | var headerProps = { 85 | tableType: this.props.tableType, 86 | columns: this.props.columnNames, 87 | sortedBy: this.state.sortBy, 88 | expanded: this.state.expanded, 89 | isBulkEditable: this.props.isBulkEditable, 90 | onExpanderClick: this.onExpandClicked, 91 | onLabelClick: this.props.onSortCollection 92 | }; 93 | 94 | return ( 95 |
    96 |

    {this.props.label}

    97 | 98 | 99 | 100 | 101 | 102 | {rows} 103 | 104 |
    105 |
    106 | ); 107 | } 108 | }); 109 | 110 | module.exports = SortableTable; -------------------------------------------------------------------------------- /src/components/sortable_table/row.js: -------------------------------------------------------------------------------- 1 | var React = window.React || require('react/addons'); 2 | var _ = require('lodash'); 3 | var Estimator = require('../estimator'); 4 | var Status = require('../status'); 5 | var Tags = require('../tags'); 6 | var TagEditor = require('../tag_editor'); 7 | var moment = require('moment'); 8 | 9 | /* 10 | * Renders a single table row for displaying item data. 11 | * Row accepts expanded prop for controlling whether the row appears 12 | * condensed (default) or optionally expanded (if using row with the sortable TableHeader. 13 | * Toggling between the two states happens via the Expander element in TableHeader.) 14 | * TODO(fw): reorg styles so don't have to calculate here. 15 | */ 16 | 17 | var abbreviateUsername = function (user) { 18 | return user.last_name ? user.first_name + ' ' + user.last_name[0] + '.' : 19 | user.first_name; 20 | }; 21 | 22 | var TableRow = React.createClass({ 23 | propTypes: { 24 | model: React.PropTypes.object.isRequired, 25 | columns: React.PropTypes.arrayOf(React.PropTypes.string).isRequired, 26 | expanded: React.PropTypes.bool, 27 | baseUrl: React.PropTypes.string, 28 | modelChangerUtilities: React.PropTypes.object, 29 | navigatorUtility: React.PropTypes.object, 30 | isBulkEditable: React.PropTypes.bool, 31 | onBulkSelect: React.PropTypes.func 32 | }, 33 | 34 | getCellBuilder: function(column, className, id) { 35 | var methodMap = { 36 | product: this.buildProductCell, 37 | number: this.buildNumberCell, 38 | size: this.buildEstimateCell, 39 | status: this.buildStatusCell, 40 | title: this.buildTitleCell, 41 | tags: this.buildTagsCell, 42 | 'created by': this.buildCreatedByCell, 43 | 'assigned to': this.buildAssigneeCell, 44 | created: this.buildCreatedAtCell 45 | }; 46 | return methodMap[column](className, id); 47 | }, 48 | 49 | render: function() { 50 | var modelId = [this.props.model.product.id, this.props.model.number]; 51 | 52 | var wrapperClass = 'wrapper ' + (this.props.expanded ? 'expanded' : 'condensed'); 53 | var rowClass = 'sortable__row ' + this.props.model.type; 54 | if (this.props.expanded) { 55 | rowClass += ' expanded'; 56 | } 57 | 58 | // "matched": item is grouped with parent/subitems. 59 | // "nonMatching": item doesn't fit collection filters, but is included to match w/subitems that do. 60 | if (this.props.model.isMatched) { 61 | rowClass += this.props.model.isNonMatching ? ' matched non-matching' : ' matched'; 62 | } 63 | 64 | var cells = []; 65 | if (this.props.isBulkEditable) { 66 | cells.push( 67 | 68 | {this.buildControlCell(wrapperClass)} 69 | 70 | ); 71 | } 72 | 73 | _.each(this.props.columns, function(column) { 74 | cells.push( 75 | 76 | {this.getCellBuilder(column, wrapperClass, modelId)} 77 | 78 | ); 79 | }, this); 80 | 81 | return ( 82 | 83 | {cells} 84 | 85 | ); 86 | }, 87 | 88 | buildControlCell: function(classes) { 89 | // Left border on matched rows causes padding weirdness in checkboxes, 90 | // so we add corrective styles on matched rows. 91 | classes += this.props.model.isMatched ? ' narrow matched' : ' narrow'; 92 | 93 | return ( 94 |
    95 | 96 |
    97 | ); 98 | }, 99 | 100 | buildProductCell: function(classes) { 101 | var linkProps = { 102 | href: this.props.baseUrl + '/product/' + this.props.model.product.id, 103 | className: 'js-item-link link product-cell', 104 | }; 105 | 106 | var subitemClass = this.props.expanded ? 'subitem expanded' : 'subitem'; 107 | var subitemArrow = this.props.model.parent ? : null; 108 | 109 | return ( 110 |
    111 | {subitemArrow} 112 | {this.props.model.product.name} 113 |
    114 | ); 115 | }, 116 | 117 | buildNumberCell: function(classes) { 118 | var props = { 119 | href: this.props.baseUrl + '/product/' + this.props.model.product.id + '/item/' + this.props.model.number, 120 | className: 'js-item-link link number-cell', 121 | 'data-item-number': this.props.model.number 122 | }; 123 | 124 | return ( 125 | 128 | ); 129 | }, 130 | 131 | buildEstimateCell: function(classes, mId) { 132 | var props = { 133 | modelId: mId, 134 | readOnly: !!this.props.model.isNonMatching, 135 | itemType: this.props.model.type, 136 | score: this.props.model.score, 137 | estimateChanger: this.props.modelChangerUtilities.estimateChanger 138 | }; 139 | 140 | return ( 141 |
    142 | 143 |
    144 | ); 145 | }, 146 | 147 | buildStatusCell: function(classes, mId) { 148 | var props = { 149 | modelId: mId, 150 | readOnly: !!this.props.model.isNonMatching, 151 | status: this.props.model.status, 152 | statusChanger: this.props.modelChangerUtilities.statusChanger 153 | }; 154 | 155 | return ( 156 |
    157 | 158 |
    159 | ); 160 | }, 161 | 162 | buildAssigneeCell: function(classes, mId) { 163 | // TODO(fw): implement 164 | return (
    ); 165 | }, 166 | 167 | buildCreatedByCell: function(classes) { 168 | return ( 169 |
    170 | {abbreviateUsername(this.props.model.created_by)} 171 |
    172 | ); 173 | }, 174 | 175 | buildTitleCell: function(classes) { 176 | var props = { 177 | href: this.props.baseUrl + '/product/' + this.props.model.product.id + '/item/' + this.props.model.number, 178 | className: 'js-item-link link title-cell', 179 | 'data-item-number': this.props.model.number 180 | }; 181 | 182 | return ( 183 | 188 | ); 189 | }, 190 | 191 | buildTagsCell: function(classes, mId) { 192 | var editorProps = { 193 | modelId: mId, 194 | readOnly: !!this.props.model.isNonMatching, 195 | tags: this.props.model.tags, 196 | tagChanger: this.props.modelChangerUtilities.tagChanger 197 | }; 198 | 199 | var tagsProps = { 200 | tags: this.props.model.tags, 201 | condensed: !this.props.expanded, 202 | navigatorUtility: this.props.navigatorUtility 203 | }; 204 | 205 | return ( 206 |
    207 | 208 | 209 |
    210 | ); 211 | }, 212 | 213 | buildCreatedAtCell: function(classes) { 214 | return ( 215 |
    216 | {moment(this.props.model.created_at).format('MM/DD/YY')} 217 |
    218 | ); 219 | } 220 | }); 221 | 222 | module.exports = TableRow; -------------------------------------------------------------------------------- /src/components/status.js: -------------------------------------------------------------------------------- 1 | var React = window.React || require('react/addons'); 2 | 3 | /* 4 | * (WIP) 5 | * TODO(fw) 6 | */ 7 | 8 | var Status = React.createClass({ 9 | propTypes: { 10 | modelId: React.PropTypes.arrayOf(React.PropTypes.number), 11 | readOnly: React.PropTypes.bool, 12 | status: React.PropTypes.number.isRequired, 13 | statusChanger: React.PropTypes.object 14 | }, 15 | 16 | mixins: [], 17 | 18 | getDefaultProps: function() { 19 | return { 20 | modelId: null, 21 | readOnly: false 22 | }; 23 | }, 24 | 25 | getInitialState: function() { 26 | return {}; 27 | }, 28 | 29 | render: function() { 30 | return ( 31 |
    32 | ); 33 | } 34 | }); 35 | 36 | module.exports = Status; 37 | -------------------------------------------------------------------------------- /src/components/tag_editor.js: -------------------------------------------------------------------------------- 1 | var React = window.React || require('react/addons'); 2 | var _ = require('lodash'); 3 | var onClickOutside = require('@sprintly/react-onclickoutside'); 4 | 5 | /* 6 | * TagEditor element provides interface for adding and removing item tags. 7 | * Used throughout app in tandem with Tags element. 8 | * Model id prop is optional, though if you're editing tags on an item, 9 | * you'll probably want to pass those in. 10 | */ 11 | 12 | var TagEditor = React.createClass({ 13 | 14 | propTypes: { 15 | modelId: React.PropTypes.arrayOf(React.PropTypes.number), 16 | readOnly: React.PropTypes.bool, 17 | tags: React.PropTypes.arrayOf(React.PropTypes.string).isRequired, 18 | tagChanger: React.PropTypes.object 19 | }, 20 | 21 | mixins: [ 22 | onClickOutside 23 | ], 24 | 25 | getDefaultProps: function() { 26 | return { 27 | modelId: null, 28 | readOnly: false 29 | }; 30 | }, 31 | 32 | getInitialState: function() { 33 | return { 34 | value: '', 35 | showMenu: false 36 | }; 37 | }, 38 | 39 | handleClickOutside: function(ev) { 40 | ev.stopPropagation(); 41 | this.setState({ 42 | showMenu: false 43 | }); 44 | }, 45 | 46 | handleEditClick: function(ev) { 47 | ev.stopPropagation(); 48 | if (this.props.readOnly || !this.props.tagChanger) { 49 | return; 50 | } 51 | 52 | var showOrHide = this.state.showMenu ? false : true; 53 | 54 | this.setState({ 55 | showMenu: showOrHide 56 | }); 57 | }, 58 | 59 | handleRemoveClick: function(tag, ev) { 60 | ev.stopPropagation(); 61 | if (this.props.readOnly || !this.props.tagChanger) { 62 | return; 63 | } 64 | 65 | this.props.tagChanger.addOrRemove(this.props.modelId, this.props.tags, tag, 'remove'); 66 | 67 | // Close popup if we've just removed our last tag 68 | if (this.props.tags.length === 1) { 69 | this.setState({ 70 | showMenu: false 71 | }); 72 | } 73 | }, 74 | 75 | handleChange: function(ev) { 76 | this.setState({ 77 | value: ev.target.value 78 | }); 79 | }, 80 | 81 | onFormSubmit: function(ev) { 82 | ev.preventDefault(); 83 | if (this.props.readOnly || !this.props.tagChanger) { 84 | return; 85 | } 86 | 87 | var tag = this.state.value; 88 | this.props.tagChanger.addOrRemove(this.props.modelId, this.props.tags, tag, 'add'); 89 | 90 | // Close popup if we've just added our first tag 91 | var newState = {value: ''}; 92 | if (this.props.tags.length < 1) { 93 | newState.showMenu = false; 94 | } 95 | 96 | this.setState(newState); 97 | }, 98 | 99 | render: function() { 100 | var tagsLength = this.props.tags.length; 101 | var addTagText = tagsLength ? null : 'Add a tag.'; 102 | var tagEditMenu = this.state.showMenu ? 103 | ( 104 |
    105 |
    106 |
    107 | 114 |
    115 |
      116 | {this.buildTagList()} 117 |
    118 |
    119 |
    120 | ) : null; 121 | 122 | return ( 123 |
    124 | 128 | {tagEditMenu} 129 |
    130 | ); 131 | }, 132 | 133 | buildTagList: function() { 134 | return this.props.tags.map(function(tag) { 135 | return ( 136 |
  • 137 | 140 | {tag} 141 |
  • 142 | ); 143 | }.bind(this)); 144 | } 145 | }); 146 | 147 | module.exports = TagEditor; 148 | -------------------------------------------------------------------------------- /src/components/tags.js: -------------------------------------------------------------------------------- 1 | var React = window.React || require('react/addons'); 2 | 3 | /* 4 | * Tags element displays either a textual list of tags ("one, two, three") 5 | * or a count ("3 tags") button that, when clicked, reveals a menu popup of those tags. 6 | * 7 | * Individual tags are clickable and, if passed a navigation utility, will set filters 8 | * and route to tag-filtered view (however defined). Alternatively, you may pass in a callback 9 | * that is called on tag click to produce some other behavior. 10 | * 11 | * Note: The Tags element merely displays tags; for tag editing functionality, use in 12 | * conjunction with the TagEdit element. 13 | */ 14 | 15 | var Tags = React.createClass({ 16 | 17 | propTypes: { 18 | tags: React.PropTypes.arrayOf(React.PropTypes.string), 19 | condensed: React.PropTypes.bool, 20 | navigatorUtility: React.PropTypes.object, 21 | altOnTagClick: React.PropTypes.func 22 | }, 23 | 24 | getDefaultProps: function() { 25 | return { 26 | tags: '', 27 | condensed: false 28 | }; 29 | }, 30 | 31 | getInitialState: function() { 32 | return {}; 33 | }, 34 | 35 | onTagClick: function(ev) { 36 | /* 37 | * If navigatorUtility prop passed in, will trigger navigation event to tag-filtered 38 | * view. Alternatively, pass in a different callback to trigger some other event. 39 | */ 40 | var tag = ev.target.textContent; 41 | 42 | if (this.props.navigatorUtility) { 43 | this.props.navigatorUtility.setTagFilterAndRoute(tag); 44 | } else if (this.props.altOnTagClick) { 45 | this.props.altOnTagClick(tag); 46 | } 47 | }, 48 | 49 | render: function() { 50 | /* 51 | * If the item has no tags, the TagEdit sibling element takes over, 52 | * adding the 'Add a tag.' button. 53 | * If we only have a single tag, print the tag. 54 | * If we have more than one tag and if condensed, 55 | * prints the number of tags: ie, "4 tags"; or, if expanded, 56 | * shows a full textual representation of tags: "tag1, tag2, tag3" 57 | */ 58 | var wrapped = null; 59 | var tagListItems = []; 60 | 61 | var len = this.props.tags.length; 62 | 63 | if (len === 1) { 64 | wrapped = ( 65 | 68 | ); 69 | } else if (len > 1) { 70 | wrapped = this.props.condensed ? 71 | ( 72 | 75 | ) : 76 | ( 77 |
      78 | {this.buildTagList()} 79 |
    80 | ); 81 | } 82 | 83 | return ( 84 |
    85 | {wrapped} 86 |
    87 | ); 88 | }, 89 | 90 | buildTagList: function() { 91 | return this.props.tags.map(function(tag, i, arr) { 92 | var maybeComma = i === (arr.length - 1) ? null : ','; 93 | return ( 94 |
  • 95 | {maybeComma} 98 |
  • 99 | ); 100 | }.bind(this)); 101 | } 102 | }); 103 | 104 | module.exports = Tags; 105 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | var Estimator = require('./components/estimator'); 2 | var Expander = require('./components/expander'); 3 | var SelectorMenu = require('./components/selector_menu'); 4 | var SortableTable = require('./components/sortable_table'); 5 | var Status = require('./components/status'); 6 | var TagEditor = require('./components/tag_editor'); 7 | var Tags = require('./components/tags'); 8 | var GroupSort = require('./utils/group_and_sort'); 9 | 10 | 11 | module.exports = { 12 | Estimator: Estimator, 13 | Expander: Expander, 14 | SelectorMenu: SelectorMenu, 15 | SortableTable: SortableTable, 16 | Status: Status, 17 | TagEditor: TagEditor, 18 | Tags: Tags, 19 | GroupSort: GroupSort 20 | }; -------------------------------------------------------------------------------- /src/less/base.less: -------------------------------------------------------------------------------- 1 | // mixins 2 | .truncate { 3 | text-overflow: ellipsis; 4 | white-space: nowrap; 5 | overflow: hidden; 6 | } 7 | 8 | // base styles 9 | 10 | .button__base { 11 | background: none repeat scroll 0 0 transparent; 12 | border: none; 13 | color: inherit; 14 | font: inherit; 15 | font-weight: normal; 16 | overflow: visible; 17 | text-transform: none; 18 | vertical-align: middle; 19 | margin: 0; 20 | padding: 0; 21 | -webkit-font-smoothing: antialiased; 22 | text-align: left; 23 | white-space: nowrap; 24 | cursor: pointer; 25 | } 26 | 27 | .icon__base { 28 | display: block; 29 | background-image: url("https://s3.amazonaws.com/sprintly-ui-icons/icons-sprite.png"); 30 | background-repeat: no-repeat; 31 | } 32 | 33 | .icon__base_small:extend(.icon__base) { 34 | height: 8px; 35 | width: 8px; 36 | } 37 | 38 | .icon__base_medium:extend(.icon__base) { 39 | height: 16px; 40 | width: 16px; 41 | } 42 | 43 | .popup__base { 44 | position: absolute; 45 | background-color: #fff; 46 | border: 1px solid #d3d3d3; 47 | border-radius: 4px; 48 | box-shadow: 1px 2px 7px rgba(0, 0, 0, 0.2); 49 | padding: 10px; 50 | } -------------------------------------------------------------------------------- /src/less/estimator.less: -------------------------------------------------------------------------------- 1 | @import './base'; 2 | 3 | .estimator__score { 4 | display: inline-block; 5 | margin-left: 4px; 6 | margin-bottom: 2px; 7 | } 8 | 9 | .estimator__button:extend(.button__base) { 10 | width: 22px; 11 | height: 22px; 12 | font-size: 11px; 13 | color: #fff; 14 | line-height: 19px; 15 | text-align: center; 16 | border: none; 17 | border-radius: 11px; 18 | 19 | &.story { 20 | background-color: #84b431; 21 | } 22 | 23 | &.task { 24 | background-color: #454545; 25 | } 26 | 27 | &.test { 28 | background-color: #5a96ab; 29 | } 30 | 31 | &.defect { 32 | background-color: #d94949; 33 | } 34 | } 35 | 36 | .estimator__menu:extend(.popup__base) { 37 | display: inline-block; 38 | line-height: 19px; 39 | padding: 6px 10px 0px 7px; 40 | margin-top: -6px; 41 | margin-left: 12px; 42 | 43 | &:before { 44 | content: ' '; 45 | position: absolute; 46 | border-right: 12px solid #fff; 47 | border-top: 12px solid transparent; 48 | border-bottom: 12px solid transparent; 49 | left: -8px; 50 | } 51 | } 52 | 53 | .estimator__list { 54 | list-style-type: none; 55 | width: 100%; 56 | height: 28px; 57 | padding: 0; 58 | margin: 0; 59 | } -------------------------------------------------------------------------------- /src/less/expander.less: -------------------------------------------------------------------------------- 1 | @import './base'; 2 | 3 | .expander__button:extend(.button__base) { 4 | display: inline-block; 5 | background-color: #fff; 6 | border: 1px solid #c7c7c7; 7 | padding: 2px 2px 1px 3px; 8 | 9 | &.expand { 10 | border-top-left-radius: 3px; 11 | border-bottom-left-radius: 3px; 12 | border-right: none; 13 | } 14 | 15 | &.condense { 16 | border-top-right-radius: 3px; 17 | border-bottom-right-radius: 3px; 18 | border-left: none; 19 | } 20 | 21 | &.active { 22 | background-color: #175574; 23 | border: 1px solid #175574; 24 | } 25 | } 26 | 27 | .expander__icon:extend(.icon__base_medium) { 28 | 29 | &.expand { 30 | background-position: 0 -272px; 31 | 32 | &.active { 33 | background-position: -16px -272px; 34 | } 35 | } 36 | 37 | &.condense { 38 | background-position: 0 -288px; 39 | 40 | &.active { 41 | background-position: -16px -288px; 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /src/less/selector_menu.less: -------------------------------------------------------------------------------- 1 | @import './base'; 2 | 3 | .selector__wrapper { 4 | position: absolute; 5 | min-width: 148px; 6 | color: #2e2e2e; 7 | font-size: inherit; 8 | font-family: inherit; 9 | background-color: #fff; 10 | border: 1px solid #d3d3d3; 11 | border-radius: 4px; 12 | overflow: hidden; 13 | 14 | &.expanded { 15 | border: 1px solid #5a96ab; 16 | box-shadow: 0 5px 11px -4px rgba(0,0,0,0.5); 17 | } 18 | 19 | .inner-wrapper { 20 | display: none; 21 | 22 | &.expanded { 23 | display: block; 24 | } 25 | } 26 | } 27 | 28 | .selector__searchbox { 29 | height: 30px; 30 | padding: 4px; 31 | margin: 6px; 32 | border: 1px solid #d3d3d3; 33 | border-radius: 4px; 34 | box-sizing: border-box; 35 | outline: none; 36 | } 37 | 38 | .selector__label { 39 | display: block; 40 | width: 100%; 41 | height: 34px; 42 | line-height: 34px; 43 | 44 | .inner { 45 | display: block; 46 | padding: 0 10px; 47 | } 48 | } 49 | 50 | .selector__options { 51 | width: 100%; 52 | list-style-type: none; 53 | padding-left: 0; 54 | margin: 0; 55 | 56 | .option { 57 | width: 100%; 58 | color: #2e2e2e; 59 | line-height: 40px; 60 | background-color: #fff; 61 | padding: 5px 0; 62 | 63 | &:hover { 64 | background-color: #175574; 65 | color: #fff; 66 | text-transform: none; 67 | } 68 | 69 | .inner { 70 | padding-left: 10px; 71 | } 72 | } 73 | } 74 | 75 | .selector__label .inner, 76 | .selector__options .option { 77 | .truncate; 78 | } 79 | 80 | .selector__icon:extend(.icon__base_small) { 81 | position: absolute; 82 | background-position: 0 -400px; 83 | width: 12px; 84 | height: 9px; 85 | top: 12px; 86 | left: 130px; 87 | } 88 | -------------------------------------------------------------------------------- /src/less/sortable_table.less: -------------------------------------------------------------------------------- 1 | @import './base'; 2 | 3 | .sortable__wrapper { 4 | width: 1000px; 5 | margin-bottom: 40px; 6 | overflow: hidden; 7 | } 8 | 9 | .sortable__title { 10 | font-size: 28px; 11 | font-weight: bold; 12 | color: #323232; 13 | padding-top: 30px; 14 | padding-bottom: 0; 15 | text-transform: capitalize; 16 | } 17 | 18 | .sortable__table { 19 | width: 100%; 20 | border: 1px solid #d3d3d3; 21 | border-left: none; 22 | border-collapse: collapse; 23 | border-spacing: 0; 24 | 25 | .head { 26 | background-color: #fff; 27 | border: 1px solid #c7c7c7; 28 | } 29 | } 30 | 31 | .sortable__label { 32 | -webkit-user-select: none; 33 | cursor: pointer; 34 | font-size: 13px; 35 | line-height: 35px; 36 | color: #323232; 37 | border-left: 1px solid #d3d3d3; 38 | overflow: hidden; 39 | white-space: nowrap; 40 | padding-left: 10px; 41 | padding-right: 10px; 42 | } 43 | 44 | .sortable__button:extend(.button__base) { 45 | text-transform: capitalize; 46 | } 47 | 48 | .sortable__row { 49 | border-top: 1px solid #d3d3d3; 50 | border-bottom: 1px solid #d3d3d3; 51 | box-sizing: border-box; 52 | 53 | &.story { 54 | background-color: #e4efcf; 55 | } 56 | &.task { 57 | background-color: #f0f0ec; 58 | } 59 | &.test { 60 | background-color: #ebf7fa; 61 | } 62 | &.defect { 63 | background-color: #f8e6e6; 64 | } 65 | 66 | &.spacer { 67 | width: 100%; 68 | height: 4px; 69 | } 70 | 71 | &.expanded { 72 | min-height: 30px; 73 | } 74 | 75 | &.matched { 76 | border-left: 6px solid #84b431; 77 | } 78 | 79 | &.non-matching { 80 | border-left: 6px solid #84b431; 81 | opacity: 0.6; 82 | } 83 | } 84 | 85 | .sortable__cell { 86 | border-left: 1px solid #d3d3d3; 87 | box-sizing: border-box; 88 | 89 | .wrapper { 90 | width: 60px; 91 | font-size: 12px; 92 | vertical-align: middle; 93 | padding: 0 10px; 94 | 95 | &.expanded { 96 | line-height: 19px; 97 | padding: 8px 10px; 98 | } 99 | 100 | &.condensed { 101 | line-height: 32px; 102 | .truncate; 103 | } 104 | 105 | &.narrow { 106 | width: 20px; 107 | 108 | &.matched { 109 | padding: 0 8px; 110 | } 111 | } 112 | 113 | &.wide { 114 | width: 50px; 115 | } 116 | 117 | &.wider { 118 | width: 90px; 119 | } 120 | 121 | &.widest { 122 | width: 375px; 123 | } 124 | } 125 | 126 | .link { 127 | color: #323232; 128 | text-decoration: none; 129 | 130 | &.title-cell:hover, 131 | &.number-cell:hover, 132 | &.product-cell:hover { 133 | text-decoration: underline; 134 | } 135 | } 136 | 137 | .subitem:extend(.icon__base_medium) { 138 | float: left; 139 | background-position: 0 -335px; 140 | margin-top: 6px; 141 | 142 | .expanded { 143 | margin-top: 0; 144 | } 145 | } 146 | } -------------------------------------------------------------------------------- /src/less/sprintly-ui.less: -------------------------------------------------------------------------------- 1 | @import './base'; 2 | @import './estimator'; 3 | @import './expander'; 4 | @import './tag_editor'; 5 | @import './tags'; 6 | @import './sortable_table'; 7 | @import './selector_menu'; -------------------------------------------------------------------------------- /src/less/status.less: -------------------------------------------------------------------------------- 1 | // WIP -------------------------------------------------------------------------------- /src/less/tag_editor.less: -------------------------------------------------------------------------------- 1 | @import './base'; 2 | 3 | .tag_editor__wrapper { 4 | display: inline-block; 5 | 6 | &.in-menu { 7 | display: block; 8 | } 9 | } 10 | 11 | .tag_editor__tag:extend(.button__base) { 12 | line-height: 19px; 13 | .truncate; 14 | } 15 | 16 | .tag_editor__list { 17 | width: 100%; 18 | list-style-type: none; 19 | padding: 0; 20 | margin: 0; 21 | 22 | .tag-wrapper { 23 | width: 100%; 24 | padding: 2px 10px 0 0; 25 | 26 | .tag { 27 | margin-right: 10px; 28 | } 29 | } 30 | } 31 | 32 | .tag_editor__edit_icon:extend(.icon__base_small) { 33 | display: inline-block; 34 | background-position: -4px -228px; 35 | margin-right: 6px; 36 | } 37 | 38 | .tag_editor__delete_icon:extend(.icon__base_medium) { 39 | display: inline-block; 40 | background-position: 0 -208px; 41 | vertical-align: middle; 42 | margin-right: 4px; 43 | margin-bottom: 2px; 44 | } 45 | 46 | .tag_editor__menu:extend(.popup__base) { 47 | margin-top: -44px; 48 | margin-left: 28px; 49 | 50 | &:before { 51 | content: ' '; 52 | position: absolute; 53 | border-right: 16px solid #fff; 54 | border-top: 16px solid transparent; 55 | border-bottom: 16px solid transparent; 56 | margin-left: -20px; 57 | } 58 | 59 | .add-tag { 60 | border: 1px solid #c7c7c7; 61 | border-radius: 4px; 62 | margin-bottom: 4px; 63 | padding: 6px; 64 | } 65 | } -------------------------------------------------------------------------------- /src/less/tags.less: -------------------------------------------------------------------------------- 1 | @import './base'; 2 | 3 | .tags__wrapper { 4 | display: inline-block; 5 | } 6 | 7 | .tags__tag:extend(.button__base) { 8 | .truncate; 9 | } 10 | 11 | .tags__list { 12 | width: 100%; 13 | list-style-type: none; 14 | padding: 0; 15 | margin: 0; 16 | 17 | &.expanded { 18 | display: inline-block; 19 | margin-right: 4px; 20 | } 21 | } -------------------------------------------------------------------------------- /src/mixins/.hidden: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sprintly/sprintly-ui/de466c77f3a24a5cd00f5a719450065080e2ea98/src/mixins/.hidden -------------------------------------------------------------------------------- /src/utils/group_and_sort.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | var lookup = require('object-path'); 3 | 4 | /* 5 | * Takes a array of items json data (such as a sprintly-data or sprintly-search-backed 6 | * Items collection.toJSON()) and sorts those items, weighting unmatched (top-level) 7 | * items and parent items against subitems, which are sorted based on parent and 8 | * grouped under that parent. If the parent is not a member of the passed in jsonArray, 9 | * the nested parent is exposed and added as a member of the array. 10 | * 11 | * It's possible to differentiate between matching and non-matching (added) member 12 | * parents b/c matching parents will come in with a nested "children" property. 13 | */ 14 | 15 | var propertyConversion = { 16 | /* 17 | * Normalizes property names, handling plain english as well as underscored names, 18 | * and converts for deep property lookups on nested models. 19 | */ 20 | 'product': 'product.name', 21 | 'number': 'number', 22 | 'size': 'score', 23 | 'assigned to': 'assigned_to.first_name', 24 | 'assigned_to': 'assigned_to.first_name', 25 | 'title': 'title', 26 | 'tags': 'tags', 27 | 'created by': 'created_by.first_name', 28 | 'created_by': 'created_by.first_name', 29 | 'created': 'created_at', 30 | 'created_at': 'created_at' 31 | }; 32 | 33 | var scoreConversion = { 34 | /* 35 | * Normalizes score strings to numerical values for sort comparison 36 | */ 37 | '~' : 0, 38 | 's' : 1, 39 | 'm' : 3, 40 | 'l' : 5, 41 | 'xl': 8 42 | }; 43 | 44 | var GroupSort = {}; 45 | 46 | GroupSort.groupSort = function(jsonArray, property, direction) { 47 | /* 48 | * Generally the method you want to call from external views. 49 | */ 50 | property = propertyConversion[property] || 'number'; 51 | direction = direction || 'descending'; 52 | 53 | var lookups = GroupSort.createParentLookups(jsonArray); 54 | var parents = lookups.parents; 55 | var matched = lookups.matched; 56 | 57 | var preparedItems = GroupSort.prepareArrayForSort(jsonArray, parents, matched); 58 | 59 | return direction === 'ascending' ? 60 | this.sort(preparedItems, property) : 61 | this.reverseSort(this.sort(preparedItems, property)); 62 | }; 63 | 64 | 65 | GroupSort.createParentLookups = function(arr) { 66 | /* 67 | * Makes a hash of unique parent ids ("productId:parentNumber") for each: 68 | * first, all the array's subitems' parents - whether the parent is a member of 69 | * the array or not; second, the parents that are members of the array. 70 | * We need this for inclusion checking below, and are making hashes for 71 | * inexpensive lookups. 72 | */ 73 | var parents = {}; 74 | var matched = {}; 75 | 76 | _.each(arr, function(item) { 77 | var parentId; 78 | if (item.parent) { 79 | parentId = item.product.id + ':' + item.parent.number; 80 | matched[parentId] = true; 81 | } else if (item.children) { 82 | parentId = item.product.id + ':' + item.number; 83 | parents[parentId] = true; 84 | } 85 | }); 86 | 87 | return {parents: parents, matched: matched}; 88 | }; 89 | 90 | 91 | GroupSort.prepareArrayForSort = function(arr, memberParents, matchingParents) { 92 | /* 93 | * Preprocesses items, adding any parents that aren't members to the array. 94 | * Also adds convenience attributes for styling. 95 | * Uses matchingParents to keep track of parent-subitem relationships. 96 | * Uses nonMatching to keep track of parents that may have been 97 | * added to the array already via other subitems. 98 | */ 99 | var preprocessed = []; 100 | var nonMatching = {}; 101 | 102 | _.each(arr, function(item) { 103 | var parentId = item.parent ? item.product.id + ':' + item.parent.number : null; 104 | 105 | // Check if item is parent of subitems in our array. 106 | if (item.children && matchingParents[item.product.id + ':' + item.number]) { 107 | item.isMatched = true; 108 | } 109 | 110 | // Check if item is a subitem. If its parent is not yet a member of our array, 111 | // add the parent. All subitems are matched here if not already. 112 | if (item.parent) { 113 | item.isMatched = true; 114 | 115 | if (!memberParents[parentId] && !nonMatching[parentId]) { 116 | preprocessed.push(_.extend({}, item.parent, { 117 | isMatched: true, 118 | isNonMatching: true 119 | })); 120 | nonMatching[parentId] = true; 121 | } 122 | } 123 | 124 | // Don't push any nonmatching parents in that are hanging around from previous sorts 125 | if (!item.isNonMatching) { 126 | preprocessed.push(item); 127 | } 128 | }); 129 | 130 | return preprocessed; 131 | }; 132 | 133 | 134 | GroupSort.parentPreferred = function (item, prop) { 135 | return lookup.get(item.parent ? item.parent : item, prop); 136 | }; 137 | 138 | 139 | GroupSort.sort = function(processedJson, property) { 140 | /* 141 | * Ensure that subitems are grouped to parent, while respecting product id. 142 | * True sorts later than false in this array-based sort, 143 | * so the fourth value causes subitems to show later in the list. 144 | */ 145 | 146 | return _.sortBy(processedJson, function(item) { 147 | var itemProperty = this.parentPreferred(item, property); 148 | 149 | if (typeof itemProperty === 'string') { 150 | itemProperty = property === 'score' ? 151 | scoreConversion[itemProperty.toLowerCase()] : itemProperty.toLowerCase(); 152 | } 153 | 154 | return [ 155 | itemProperty, 156 | this.parentPreferred(item, 'number'), 157 | item.product.id, 158 | !!item.parent, 159 | item.number 160 | ]; 161 | }, this); 162 | }; 163 | 164 | 165 | GroupSort.reverseSort = function(sortedArray) { 166 | /* 167 | * When reversing, we want to preserve the order of parent followed 168 | * by children, so we push parents and subitems into a temporary intermediate array 169 | * while reversing to maintain sorted chunks. The chunks are parent/child groupings. 170 | */ 171 | var reverseSorted = []; 172 | var temp = []; 173 | 174 | for (var i = sortedArray.length - 1; i >= 0; i--) { 175 | // if the current item's value isn't the same as the temp, 176 | // push the full temp onto the resorted list; otherwise, add it to temp. 177 | var item = sortedArray[i]; 178 | 179 | if (!temp.length) { 180 | temp = [item]; 181 | continue; 182 | } 183 | 184 | if ( 185 | (item.parent && temp[0].parent && temp[0].parent.number === item.parent.number) 186 | || (temp[0].parent && item.number === temp[0].parent.number) 187 | ) { // add to head of array to maintain original sort order 188 | temp.unshift(item); 189 | } else { 190 | reverseSorted = _.union(reverseSorted, temp); 191 | temp = [item]; 192 | } 193 | } 194 | return temp.length ? _.union(reverseSorted, temp) : reverseSorted; 195 | } 196 | 197 | module.exports = GroupSort; -------------------------------------------------------------------------------- /test/estimator_test.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | var React = window.React || require('react/addons'); 3 | var TestUtils = React.addons.TestUtils; 4 | var sinon = require('sinon'); 5 | var Estimator = require('../src/components/estimator'); 6 | 7 | var TestUtils = React.addons.TestUtils; 8 | /* 9 | * Estimator component tests. 10 | */ 11 | 12 | describe('Estimator', function() { 13 | beforeEach(function() { 14 | this.stub = { 15 | changeScore: sinon.stub() 16 | }; 17 | 18 | this.estimator = TestUtils.renderIntoDocument( 19 | 25 | ); 26 | }); 27 | 28 | it('should render a button with the correct score', function() { 29 | var score = React.findDOMNode(TestUtils.findRenderedDOMComponentWithTag(this.estimator, 'button')); 30 | assert.equal(score.textContent, 's'); 31 | }); 32 | 33 | it('should not render the score selector menu by default', function() { 34 | assert.notOk(TestUtils.scryRenderedDOMComponentsWithClass(this.estimator, 'estimator__menu').length); 35 | }); 36 | 37 | it('should change menu state to "open" and render the selector menu on click', function() { 38 | var score = TestUtils.findRenderedDOMComponentWithTag(this.estimator, 'button'); 39 | TestUtils.Simulate.click(score); 40 | assert.isTrue(this.estimator.state.menuOpen); 41 | assert.ok(TestUtils.findRenderedDOMComponentWithClass(this.estimator, 'estimator__menu')); 42 | }); 43 | 44 | it('should close the menu and reset state if clicked a second time after opening', function() { 45 | var score = TestUtils.findRenderedDOMComponentWithTag(this.estimator, 'button'); 46 | TestUtils.Simulate.click(score); 47 | TestUtils.Simulate.click(score); 48 | assert.isFalse(this.estimator.state.menuOpen); 49 | assert.notOk(TestUtils.scryRenderedDOMComponentsWithClass(this.estimator, 'estimator-menu').length); 50 | }); 51 | 52 | it('should trigger a changeScore method on estimateChanger utility with score number value', function() { 53 | this.estimator.setState({menuOpen:true}); 54 | var scores = TestUtils.scryRenderedDOMComponentsWithTag(this.estimator, 'button'); 55 | var newScore = scores[scores.length - 1]; // Last score would be 'XL' 56 | 57 | TestUtils.Simulate.click(newScore); 58 | sinon.assert.calledOnce(this.stub.changeScore); 59 | sinon.assert.calledWith(this.stub.changeScore, [1,1], parseInt(_.invert(this.estimator.ESTIMATE_HASH)['XL'], 10)); 60 | }); 61 | 62 | it('should not trigger the changeScore method if the same score is selected', function() { 63 | this.estimator.setState({menuOpen:true}); 64 | var scores = TestUtils.scryRenderedDOMComponentsWithTag(this.estimator, 'button'); 65 | var sameScore = scores[2]; 66 | assert.equal(React.findDOMNode(sameScore).textContent, 'S'); 67 | 68 | TestUtils.Simulate.click(sameScore); 69 | sinon.assert.notCalled(this.stub.changeScore); 70 | }); 71 | }); -------------------------------------------------------------------------------- /test/expander_test.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | var React = window.React || require('react/addons'); 3 | var TestUtils = React.addons.TestUtils; 4 | var sinon = require('sinon'); 5 | var Expander = require('../src/components/expander'); 6 | 7 | var TestUtils = React.addons.TestUtils; 8 | 9 | describe('Expander', function() { 10 | beforeEach(function() { 11 | this.stub = sinon.stub(); 12 | this.expander = TestUtils.renderIntoDocument( 13 | 16 | ); 17 | }); 18 | 19 | it('should render expander buttons element with condensed selected by default', function() { 20 | assert.ok(TestUtils.findRenderedDOMComponentWithClass(this.expander, 'expander condensed')); 21 | assert.isFalse(this.expander.props.expanded); 22 | }); 23 | 24 | it('should call onClick prop if button toggled', function() { 25 | var expandButton = TestUtils.scryRenderedDOMComponentsWithTag(this.expander, 'button')[0]; 26 | TestUtils.Simulate.click(expandButton); 27 | sinon.assert.calledOnce(this.stub); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /test/group_and_sort_test.js: -------------------------------------------------------------------------------- 1 | var GroupSort = require('../src/utils/group_and_sort'); 2 | var sinon = require('sinon'); 3 | 4 | var productMap = ['sprintly', 'mystery wagon', 'star wars', 'blues clues']; 5 | 6 | var genItem = function (num, product, parent, user, isParent) { 7 | var z = { 8 | number: num, 9 | product: { 10 | id: product, 11 | name: productMap[product-1] 12 | }, 13 | created_by: { 14 | first_name: user 15 | } 16 | }; 17 | 18 | if (parent) { 19 | z.parent = genItem(parent, product); 20 | } else if (isParent) { 21 | z.children = ['child']; // fake 22 | } 23 | return z; 24 | } 25 | 26 | describe("GroupSort", function() { 27 | 28 | describe('preparing items for sort', function() { 29 | 30 | it('should create a hash of parents with their subitems', function() { 31 | var items = [genItem(2,1,1), genItem(3,1,1, null, true), genItem(3,2,4), genItem(4,2, null, null, true)]; 32 | var result = GroupSort.createParentLookups(items); 33 | 34 | // Note: hash keys are 'productId:parentNumber' 35 | assert.deepEqual(result.parents, {'2:4':true}); 36 | assert.deepEqual(result.matched, {'1:1':true, '2:4':true}); 37 | }); 38 | 39 | it('should pull all subitem parents into top-level of array', function() { 40 | var items = [genItem(2,1,6), genItem(4,3,8)]; 41 | var lookups = GroupSort.createParentLookups(items); 42 | var result = _.pluck(GroupSort.prepareArrayForSort(items, lookups.parents, lookups.matched), 'number'); 43 | 44 | assert.isTrue(_.contains(result, 6)); 45 | assert.isTrue(_.contains(result, 8)); 46 | }); 47 | 48 | it('should not add nonMatching parents from previous', function() { 49 | var items = [genItem(2,1,6), genItem(6,1)]; 50 | items[1].isNonMatching = true; 51 | items[1].testing = true; 52 | 53 | var memberParents = {}; 54 | var matchingParents = {'1:6':true}; 55 | var result = GroupSort.prepareArrayForSort(items, memberParents, matchingParents); 56 | 57 | assert.deepEqual(_.pluck(result, 'number'), [6,2]); 58 | assert.isTrue(_.compact(_.pluck(result, 'testing')).length === 0); 59 | }); 60 | 61 | it('should convert passed-in properties to the correct item property lookup', function() { 62 | var actual = []; 63 | var stub = sinon.stub(GroupSort, 'sort', function(arr, prop, dir) { 64 | actual.push(prop); 65 | return; 66 | }); 67 | var items = [genItem(1,2)]; 68 | var expected = ['product.name', 'assigned_to.first_name', 69 | 'assigned_to.first_name', 'created_by.first_name', 'created_by.first_name', 70 | 'created_at', 'created_at']; 71 | 72 | GroupSort.groupSort(items, 'product', 'ascending'); 73 | GroupSort.groupSort(items, 'assigned to', 'ascending'); 74 | GroupSort.groupSort(items, 'assigned_to', 'ascending'); 75 | GroupSort.groupSort(items, 'created by', 'ascending'); 76 | GroupSort.groupSort(items, 'created_by', 'ascending'); 77 | GroupSort.groupSort(items, 'created', 'ascending'); 78 | GroupSort.groupSort(items, 'created_at', 'ascending'); 79 | 80 | 81 | assert.deepEqual(actual, expected); 82 | stub.restore(); 83 | }); 84 | }); 85 | 86 | describe('sorting', function() { 87 | 88 | it("should return top-level items in descending order by number by default", function() { 89 | var items = [genItem(2,1), genItem(1,2)]; 90 | var actual = GroupSort.groupSort(items); 91 | assert.deepEqual(_.pluck(actual, 'number'), [2,1]); 92 | }); 93 | 94 | it("should pull the parent of the subitem and add to array if the parent is not already member", function() { 95 | var items = [genItem(4,1), genItem(2,1), genItem(3,1,1)]; 96 | var actual = GroupSort.groupSort(items); 97 | assert.equal(items.length, 3); 98 | assert.equal(actual.length, 4); 99 | }); 100 | 101 | it("should use the parents' sort order as the basis for sort", function() { 102 | var items = [genItem(4,1), genItem(2,1), genItem(3,1,1)]; 103 | var actual = GroupSort.groupSort(items); // default is descending by number 104 | assert.deepEqual(_.pluck(actual, 'number'), [4, 2, 1, 3]); 105 | }); 106 | 107 | it("should handle change in sort direction appropriately", function() { 108 | var items = [genItem(3,2), genItem(1,2), genItem(1,1), genItem(2,1)]; 109 | var actual = GroupSort.groupSort(items, 'number', 'ascending'); 110 | assert.deepEqual(_.pluck(actual, 'number'), [1, 1, 2, 3]); 111 | }); 112 | 113 | it('should sort by properties other than default "number"', function () { 114 | var items = [genItem(1,1), genItem(2,2), genItem(1,4), genItem(4,1)]; 115 | var actual = GroupSort.groupSort(items, 'product', 'ascending'); 116 | var expected = ['blues clues', 'mystery wagon', 'sprintly', 'sprintly']; 117 | 118 | _.each(actual, function (obj, i) { 119 | assert.equal(obj.product.name, expected[i]); 120 | }); 121 | }); 122 | 123 | it('should handle sort on properties that are nested models', function() { 124 | var items = [genItem(1,1,null,'jennifer'), genItem(2,1,null,'amy'), genItem(3,2,null,'bill')]; 125 | var actual = GroupSort.groupSort(items, 'created by', 'descending'); 126 | var expected = ['jennifer', 'bill', 'amy']; 127 | _.each(actual, function(obj, i) { 128 | assert.equal(obj.created_by.first_name, expected[i]); 129 | }); 130 | }); 131 | 132 | it('should sort children after parent if ascending', function() { 133 | var items = [genItem(5,1,1), genItem(3,1,2), genItem(4,1,1)]; 134 | var actual = GroupSort.groupSort(items, 'number', 'ascending'); 135 | var expected = [1,4,5,2,3]; 136 | 137 | assert.deepEqual(_.pluck(actual, 'number'), expected); 138 | }); 139 | 140 | it('should also sort children after parent if decending', function() { 141 | var items = [genItem(5,1,11), genItem(6,1,11), genItem(8,1,11)]; 142 | var actual = GroupSort.groupSort(items, 'number', 'descending'); 143 | var expected = [11,5,6,8]; 144 | 145 | assert.deepEqual(_.pluck(actual, 'number'), expected); 146 | }); 147 | 148 | it('should respect product number when sorting and grouping subitems w/their parents', function() { 149 | var items = [genItem(1,15,5), genItem(1,14,5), genItem(2,15,5), genItem(2,14,5)]; 150 | var actual = GroupSort.groupSort(items, 'number', 'ascending'); 151 | var expected = [14,14,14,15,15,15]; // one each for parent and subitem 152 | 153 | _.each(actual, function(obj, i) { 154 | assert.equal(obj.product.id, expected[i]); 155 | }); 156 | }); 157 | 158 | it('should order by number (ascending) within parent-sorted sets', function() { 159 | var items = [genItem(1,15,5), genItem(1,14,5), genItem(3,14,5), genItem(2,15,5)]; 160 | var actual = GroupSort.groupSort(items, 'number', 'descending'); 161 | var expected = [[5,15], [1,15], [2,15], [5,14], [1,14], [3,14]]; // one each for parent and subitem 162 | 163 | _.each(actual, function(obj, i) { 164 | assert.deepEqual([obj.number,obj.product.id], [expected[i][0], expected[i][1]]); 165 | }); 166 | }); 167 | }); 168 | }); -------------------------------------------------------------------------------- /test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Sprintly UI Tests 5 | 6 | 7 | 8 |
    9 |
    10 | 11 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | window._ = require('lodash'); 2 | require('es5-shim'); 3 | 4 | var chai = require('chai'); 5 | window.assert = chai.assert; 6 | 7 | // Component tests 8 | require('./estimator_test.js'); 9 | require('./expander_test.js'); 10 | require('./selector_menu_test.js'); 11 | require('./sortable_table_test.js'); 12 | require('./status_test.js'); 13 | require('./tag_editor_test.js'); 14 | require('./tags_test.js'); 15 | require('./group_and_sort_test.js'); 16 | 17 | if (window.mochaPhantomJS) { 18 | window.mochaPhantomJS.run(); 19 | } else { 20 | window.mocha.run(); 21 | } -------------------------------------------------------------------------------- /test/phantom_hooks.js: -------------------------------------------------------------------------------- 1 | // Hook writes istanbul coverage data to coverage.json file 2 | module.exports = { 3 | afterEnd: function(runner) { 4 | var fs = require('fs'); 5 | var coverage = runner.page.evaluate(function() { 6 | return window.__coverage__; 7 | }); 8 | if (coverage) { 9 | console.log('Writing coverage to coverage/coverage.json'); 10 | fs.write('coverage/coverage.json', JSON.stringify(coverage), 'w'); 11 | } else { 12 | console.log('No coverage data generated'); 13 | } 14 | } 15 | }; -------------------------------------------------------------------------------- /test/selector_menu_test.js: -------------------------------------------------------------------------------- 1 | var React = window.React || require('react/addons'); 2 | var _ = require('lodash'); 3 | var TestUtils = React.addons.TestUtils; 4 | var sinon = require('sinon'); 5 | 6 | var SelectorMenu = require('../src/components/selector_menu/index'); 7 | var Label = require('../src/components/selector_menu/label'); 8 | var List = require('../src/components/selector_menu/list'); 9 | var Search = require('../src/components/selector_menu/search'); 10 | 11 | 12 | describe("SelectorMenu", function() { 13 | beforeEach(function() { 14 | this.optionsJSON = [ 15 | {title: "Option 1"}, 16 | {title: "Option 2"} 17 | ]; 18 | 19 | this.selector = TestUtils.renderIntoDocument( 20 | 25 | ); 26 | 27 | this.node = React.findDOMNode(this.selector); 28 | }); 29 | 30 | describe("rendering", function() { 31 | it("should render a label that displays current state", function() { 32 | assert.ok(TestUtils.findRenderedComponentWithType(this.selector, Label)); 33 | }); 34 | it("should render default label on first render", function() { 35 | var label = React.findDOMNode(TestUtils.findRenderedComponentWithType(this.selector, Label)); 36 | assert.strictEqual(label.textContent, "All"); 37 | }); 38 | it("should render a search input", function() { 39 | assert.ok(TestUtils.findRenderedComponentWithType(this.selector, Search)); 40 | }); 41 | it("should render a list of options", function() { 42 | assert.ok(TestUtils.findRenderedComponentWithType(this.selector, List)); 43 | }); 44 | it("should show all options plus default", function() { 45 | var optionsList = TestUtils.scryRenderedDOMComponentsWithClass(this.selector, "option"); 46 | assert.equal(optionsList.length, 3); 47 | }); 48 | }); 49 | describe("toggling menu open/close", function() { 50 | beforeEach(function() { 51 | this.optionLabel = TestUtils.findRenderedDOMComponentWithClass(this.selector, "selector__label"); 52 | TestUtils.Simulate.click(this.optionLabel); 53 | }); 54 | it("should open on click if closed", function() { 55 | var dropdown = TestUtils.findRenderedDOMComponentWithClass(this.selector, "inner-wrapper expanded"); 56 | assert.isTrue(this.selector.state.expanded); 57 | }); 58 | it("should close on click if open", function() { 59 | var dropdown = TestUtils.findRenderedDOMComponentWithClass(this.selector, "inner-wrapper expanded"); 60 | TestUtils.Simulate.click(this.optionLabel); 61 | 62 | assert.isFalse(this.selector.state.expanded); 63 | assert.equal(TestUtils.scryRenderedDOMComponentsWithClass(this.selector, "inner-wrapper expanded").length, 0); 64 | }); 65 | }); 66 | describe("search and selection", function() { 67 | beforeEach(function() { 68 | var label = TestUtils.findRenderedDOMComponentWithClass(this.selector, "selector__label"); 69 | TestUtils.Simulate.click(label); 70 | }); 71 | it("should render a list of options (including default)", function() { 72 | var optionsList = TestUtils.scryRenderedDOMComponentsWithClass(this.selector, "option"); 73 | assert.equal(optionsList.length, 3); 74 | }); 75 | it("should only show options relevant to user search, if search input entered", function() { 76 | var input = TestUtils.findRenderedDOMComponentWithTag(this.selector, "input"); 77 | 78 | React.findDOMNode(input).value = "option 1"; 79 | TestUtils.Simulate.change(input); 80 | assert.equal(TestUtils.scryRenderedDOMComponentsWithClass(this.selector, "option").length, 1); 81 | }); 82 | it("should trigger callback on ENTER pressed any valid option entered in search bar", function() { 83 | var input = TestUtils.findRenderedDOMComponentWithTag(this.selector, "input"); 84 | 85 | React.findDOMNode(input).value = "Option 1"; 86 | TestUtils.Simulate.change(input); 87 | TestUtils.Simulate.keyDown(input, {which: 13}); 88 | 89 | sinon.assert.calledWith(this.selector.props.onSelectionChange, "Option 1"); 90 | }); 91 | it("should be able to handle case insensitive input from user", function() { 92 | var spy = sinon.spy(this.selector, 'selectOption'); 93 | var input = TestUtils.findRenderedDOMComponentWithTag(this.selector, "input"); 94 | 95 | React.findDOMNode(input).value = "option 1"; // lowercased 96 | TestUtils.Simulate.change(input); 97 | TestUtils.Simulate.keyDown(input, {which: 13}); 98 | 99 | sinon.assert.calledWith(spy, "Option 1"); 100 | }); 101 | it("should be able to handle partial option input from user", function() { 102 | var spy = sinon.spy(this.selector, "selectOption"); 103 | var input = TestUtils.findRenderedDOMComponentWithTag(this.selector, "input"); 104 | 105 | React.findDOMNode(input).value = "2"; // partial 106 | TestUtils.Simulate.change(input); 107 | TestUtils.Simulate.keyDown(input, {which: 13}); 108 | 109 | sinon.assert.calledWith(spy, "Option 2"); 110 | }); 111 | it("should close menu and clear input on submit", function() { 112 | var spy = sinon.spy(this.selector, "onLabelClicked"); 113 | var input = TestUtils.findRenderedDOMComponentWithTag(this.selector, "input"); 114 | 115 | React.findDOMNode(input).value = "option 1"; 116 | TestUtils.Simulate.change(input); 117 | TestUtils.Simulate.keyDown(input, {which: 13}); 118 | 119 | sinon.assert.calledOnce(spy); 120 | assert.equal(React.findDOMNode(TestUtils.findRenderedDOMComponentWithTag(this.selector, "input")).value, ""); 121 | }); 122 | }); 123 | 124 | describe('controlled component with selection', function() { 125 | beforeEach(function() { 126 | this.selector.setProps({ 127 | selection: 'Sam B.', 128 | optionsList: [{ title: 'Sam B.' }, { title: 'Flora W.' }, { title: 'Justin A.' }, { title: 'Nick S.' }] 129 | }); 130 | }); 131 | it('renders the label correctly', function() { 132 | var label = React.findDOMNode(TestUtils.findRenderedDOMComponentWithClass(this.selector, 'selector__label')); 133 | assert.equal('Sam B.', label.textContent); 134 | }); 135 | it('renders the list correctly', function() { 136 | var list = React.findDOMNode(TestUtils.findRenderedDOMComponentWithClass(this.selector, 'selector__options')); 137 | assert.lengthOf(list.children, 4); 138 | }); 139 | it('adds an unexpected value to the rendered options list', function() { 140 | this.selector.setProps({ selection: 'Unassigned' }) 141 | var list = React.findDOMNode(TestUtils.findRenderedDOMComponentWithClass(this.selector, 'selector__options')); 142 | assert.lengthOf(list.children, 5); 143 | }); 144 | it('renders the default label when selection is empty', function() { 145 | this.selector.setProps({ selection: '', defaultSelection: 'Foo' }); 146 | var label = React.findDOMNode(TestUtils.findRenderedDOMComponentWithClass(this.selector, 'selector__label')); 147 | assert.equal('Foo', label.textContent); 148 | }); 149 | it('overrides selection when new options are passed in', function() { 150 | this.selector.setProps({ selection: '' }); 151 | 152 | // Mock the internal state as if the input changed 153 | this.selector.setState({ selected: 'Flora W.' }); 154 | var label = React.findDOMNode(TestUtils.findRenderedDOMComponentWithClass(this.selector, 'selector__label')); 155 | assert.equal('Flora W.', label.textContent, 'renders when state set'); 156 | 157 | // Update the optionsList, which should clear out previous state 158 | this.selector.setProps({ optionsList: [{ title: 'Foo B.' }] }); 159 | label = React.findDOMNode(TestUtils.findRenderedDOMComponentWithClass(this.selector, 'selector__label')); 160 | assert.equal('All', label.textContent, 'renders the default label'); 161 | 162 | var list = React.findDOMNode(TestUtils.findRenderedDOMComponentWithClass(this.selector, 'selector__options')); 163 | assert.lengthOf(list.children, 2, 'renders the new options list'); 164 | }); 165 | }); 166 | }); 167 | -------------------------------------------------------------------------------- /test/sortable_table_test.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | var React = window.React || require('react/addons'); 3 | var TestUtils = React.addons.TestUtils; 4 | var sinon = require('sinon'); 5 | 6 | var SortableTable = require('../src/components/sortable_table/index'); 7 | var Header = require('../src/components/sortable_table/header'); 8 | var Row = require('../src/components/sortable_table/row'); 9 | var Expander = require('../src/components/expander'); 10 | var Estimator = require('../src/components/estimator'); 11 | var Status = require('../src/components/status'); 12 | var Tags = require('../src/components/tags'); 13 | var TagEditor = require('../src/components/tag_editor'); 14 | 15 | 16 | var genItem = function(num, productNum, userName, parentNum) { 17 | return { 18 | number: num, 19 | product: {id: productNum, name: 'foo'}, 20 | type: 'defect', 21 | title: "Test item", 22 | status: 'backlog', 23 | score: 's', 24 | assigned_to: {first_name: userName}, 25 | created_by: {first_name: userName}, 26 | tags: [], 27 | parent: {number: parentNum} 28 | }; 29 | }; 30 | 31 | describe('SortableTable', function() { 32 | describe('rendering', function() { 33 | beforeEach(function() { 34 | this.items = [ 35 | genItem(1,1,'amy',5), 36 | genItem(2,2,'bob',5), 37 | genItem(5,2,'amy') 38 | ]; 39 | this.sortable = TestUtils.renderIntoDocument( 40 | 47 | ); 48 | 49 | this.node = React.findDOMNode(this.sortable); 50 | }); 51 | 52 | it('should render a table header', function() { 53 | assert.ok(TestUtils.findRenderedComponentWithType(this.sortable, Header)); 54 | }); 55 | 56 | it('should render a column for each in columnNames array prop', function() { 57 | // testing against number of and s in a single row. 58 | var headerCols = TestUtils.scryRenderedDOMComponentsWithTag(this.sortable, 'th'); 59 | var row = TestUtils.scryRenderedComponentsWithType(this.sortable, Row)[0]; 60 | var rowCols = TestUtils.scryRenderedDOMComponentsWithTag(row, 'td'); 61 | 62 | _.each([this.sortable.props.columnNames, headerCols, rowCols], function(cols) { 63 | assert.equal(cols.length, 8); 64 | }); 65 | }); 66 | 67 | it('should render the product name as a permalink', function() { 68 | var row = TestUtils.scryRenderedComponentsWithType(this.sortable, Row)[0]; 69 | var rowCols = TestUtils.scryRenderedDOMComponentsWithTag(row, 'td'); 70 | var anchors = TestUtils.scryRenderedDOMComponentsWithTag(rowCols[0], 'a') 71 | var item = this.items[0]; 72 | var node = React.findDOMNode(anchors[0]); 73 | 74 | assert.equal(node.text, item.product.name); 75 | assert.include(node.href, item.product.id); 76 | }); 77 | 78 | it('should render the item number be a permalink', function() { 79 | var row = TestUtils.scryRenderedComponentsWithType(this.sortable, Row)[0]; 80 | var rowCols = TestUtils.scryRenderedDOMComponentsWithTag(row, 'td'); 81 | var anchors = TestUtils.scryRenderedDOMComponentsWithTag(rowCols[1], 'a') 82 | var item = this.items[0]; 83 | var node = React.findDOMNode(anchors[0]); 84 | 85 | assert.equal(node.text, '#' + item.number); 86 | assert.include(node.href, item.product.id + '/item/' + item.number); 87 | }); 88 | 89 | it('should render a table row for each collection item', function() { 90 | var collectionLength = this.sortable.props.collection.length; 91 | var rows = TestUtils.scryRenderedComponentsWithType(this.sortable, Row); 92 | assert.equal(collectionLength, 3); 93 | assert.equal(rows.length, collectionLength); 94 | }); 95 | 96 | it('should render an expander element for toggling row height per content', function() { 97 | assert.ok(TestUtils.findRenderedComponentWithType(this.sortable, Expander)); 98 | }); 99 | 100 | it('should render condensed rows by default', function() { 101 | assert.isFalse(TestUtils.findRenderedComponentWithType(this.sortable, Expander).props.expanded); 102 | assert.isFalse(TestUtils.scryRenderedComponentsWithType(this.sortable, Row)[0].props.expanded); 103 | }); 104 | 105 | it('should update expander state and render an expanded row on expand button click', function() { 106 | var expandButton = TestUtils.findRenderedDOMComponentWithClass(this.sortable, 'expander__button expand'); 107 | TestUtils.Simulate.click(expandButton); 108 | assert.isTrue(TestUtils.findRenderedComponentWithType(this.sortable, Expander).props.expanded); 109 | assert.isTrue(TestUtils.scryRenderedComponentsWithType(this.sortable, Row)[0].props.expanded); 110 | }); 111 | 112 | it('should render an item component element for each applicable property in each row', function() { 113 | // incl. Estimator, Status, Tags, TagEditor item component elements 114 | var estimators = TestUtils.scryRenderedComponentsWithType(this.sortable, Estimator); 115 | var tags = TestUtils.scryRenderedComponentsWithType(this.sortable, Tags); 116 | var tagEditors = TestUtils.scryRenderedComponentsWithType(this.sortable, TagEditor); 117 | 118 | _.each([estimators, tags, tagEditors], function(comps) { 119 | assert.equal(comps.length, 3); 120 | }); 121 | }); 122 | 123 | it('shouldn\'t render an item component element for any property that there isn\'t a column for', function() { 124 | var statuses = TestUtils.scryRenderedComponentsWithType(this.sortable, Status); 125 | assert.equal(statuses.length, 0); 126 | }); 127 | 128 | it('should not be bulk editable by default', function() { 129 | assert.isFalse(this.sortable.props.isBulkEditable); 130 | }); 131 | }); 132 | 133 | describe('bulk edit mode', function() { 134 | beforeEach(function() { 135 | this.stub = sinon.stub(); 136 | this.sortable = TestUtils.renderIntoDocument( 137 | 146 | ); 147 | }); 148 | 149 | it('should enable bulk edit mode on children if isBulkEditable prop set to true', function() { 150 | var header = TestUtils.findRenderedComponentWithType(this.sortable, Header); 151 | var row = TestUtils.scryRenderedComponentsWithType(this.sortable, Row)[0]; 152 | assert.isTrue(header.props.isBulkEditable); 153 | assert.isTrue(row.props.isBulkEditable); 154 | }); 155 | 156 | it('should add a "control" column as first member of columns array', function() { 157 | var columnsLength = this.sortable.props.columnNames.length; 158 | var columns = TestUtils.scryRenderedDOMComponentsWithTag(this.sortable, 'th'); 159 | 160 | assert.equal(columns.length, columnsLength + 1); 161 | assert.equal(React.findDOMNode(columns[0]).className, 'sortable__label control'); 162 | }); 163 | 164 | it('should trigger the onBulkSelect callback on edit checkbox select', function() { 165 | var checkboxInput = TestUtils.findRenderedDOMComponentWithTag(this.sortable, 'input'); 166 | TestUtils.Simulate.click(checkboxInput); 167 | sinon.assert.calledOnce(this.stub); 168 | }); 169 | }); 170 | 171 | describe('table sorting', function() { 172 | beforeEach(function() { 173 | this.stub = sinon.stub(); 174 | this.sortable = TestUtils.renderIntoDocument( 175 | 182 | ); 183 | }); 184 | 185 | it('should trigger the onSortCollection callback on column label click', function() { 186 | var numberLabel = TestUtils.scryRenderedDOMComponentsWithClass(this.sortable, 'number')[0]; 187 | TestUtils.Simulate.click(numberLabel); 188 | sinon.assert.calledOnce(this.stub); 189 | }); 190 | 191 | it('should pass table type, column type, and direction to callback', function() { 192 | var createdByLabel = TestUtils.scryRenderedDOMComponentsWithClass(this.sortable, 'created-by')[0]; 193 | TestUtils.Simulate.click(createdByLabel); 194 | sinon.assert.calledWith(this.stub, 'someday', 'created by', 'descending'); 195 | }); 196 | 197 | it('should alternate passing descending and ascending as direction argument', function() { 198 | var titleLabel = TestUtils.scryRenderedDOMComponentsWithClass(this.sortable, 'title')[0]; 199 | 200 | TestUtils.Simulate.click(titleLabel); 201 | sinon.assert.calledWith(this.stub, 'someday', 'title', 'descending'); 202 | TestUtils.Simulate.click(titleLabel); 203 | sinon.assert.calledWith(this.stub, 'someday', 'title', 'ascending'); 204 | }); 205 | }); 206 | }); 207 | -------------------------------------------------------------------------------- /test/status_test.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | var React = window.React || require('react/addons'); 3 | var TestUtils = React.addons.TestUtils; 4 | var sinon = require('sinon'); 5 | var Status = require('../src/components/status'); 6 | 7 | /* 8 | * Status element tests.(WIP) 9 | */ 10 | 11 | describe('Status', function() { 12 | it('should render item status'); 13 | it('should not render status edit menu by default'); 14 | it('should render a status edit menu on status click'); 15 | it('should trigger a changeStatus method on status changer utility on other status clicked'); 16 | it('should close the menu on status change'); 17 | it('should close the menu if the item status icon is clicked a second time'); 18 | it('should close the menu on clicking out of menu'); 19 | }); -------------------------------------------------------------------------------- /test/tag_editor_test.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | var React = window.React || require('react/addons'); 3 | var TestUtils = React.addons.TestUtils; 4 | var sinon = require('sinon'); 5 | var TagEditor = require('../src/components/tag_editor'); 6 | 7 | /* 8 | * TagEditor element tests. 9 | */ 10 | 11 | describe('TagEditor', function() { 12 | it('should always render a tag edit icon', function() { 13 | var tagEditor = TestUtils.renderIntoDocument( 14 | 19 | ); 20 | assert.ok(TestUtils.findRenderedDOMComponentWithTag(tagEditor, 'i')); 21 | }); 22 | 23 | it('should also render "Add a tag." if the item has no tags', function() { 24 | var tagEditor = TestUtils.renderIntoDocument( 25 | 30 | ); 31 | var button = React.findDOMNode(TestUtils.findRenderedDOMComponentWithTag(tagEditor, 'button')); 32 | assert.equal(button.textContent, 'Add a tag.'); 33 | }); 34 | 35 | it('should not render the edit menu by default', function() { 36 | var tagEditor = TestUtils.renderIntoDocument( 37 | 42 | ); 43 | 44 | assert.isFalse(tagEditor.state.showMenu); 45 | assert.notOk(TestUtils.scryRenderedDOMComponentsWithClass(tagEditor, 'tag_editor__menu').length); 46 | }); 47 | 48 | it('should render the edit menu containing an input if "Add a tag." clicked', function() { 49 | var tagEditor = TestUtils.renderIntoDocument( 50 | 55 | ); 56 | var button = TestUtils.findRenderedDOMComponentWithTag(tagEditor, 'button'); 57 | TestUtils.Simulate.click(button); 58 | 59 | assert.ok(TestUtils.scryRenderedDOMComponentsWithClass(tagEditor, 'tag_editor__menu').length); 60 | assert.ok(TestUtils.findRenderedDOMComponentWithTag(tagEditor, 'input')); 61 | }); 62 | 63 | it('should trigger an add event on tag change utility if a new tag is added', function() { 64 | var stub = { 65 | addOrRemove: sinon.stub() 66 | }; 67 | 68 | var tagEditor = TestUtils.renderIntoDocument( 69 | 74 | ); 75 | var form; 76 | var button = TestUtils.findRenderedDOMComponentWithTag(tagEditor, 'button'); 77 | TestUtils.Simulate.click(button); 78 | 79 | form = React.findDOMNode(TestUtils.findRenderedDOMComponentWithTag(tagEditor, 'form')); 80 | form.children[0].value = 'new tag'; 81 | TestUtils.Simulate.change(form.children[0]); 82 | TestUtils.Simulate.submit(form); 83 | 84 | sinon.assert.calledOnce(stub.addOrRemove); 85 | sinon.assert.calledWith(stub.addOrRemove, [1,1], [], 'new tag', 'add'); 86 | }); 87 | 88 | it('should close the edit menu if adding the first tag on an item', function() { 89 | var stub = { 90 | addOrRemove: sinon.stub() 91 | }; 92 | 93 | var tagEditor = TestUtils.renderIntoDocument( 94 | 99 | ); 100 | var form; 101 | var button = TestUtils.findRenderedDOMComponentWithTag(tagEditor, 'button'); 102 | TestUtils.Simulate.click(button); 103 | 104 | form = React.findDOMNode(TestUtils.findRenderedDOMComponentWithTag(tagEditor, 'form')); 105 | form.children[0].value = 'new tag'; 106 | TestUtils.Simulate.change(form.children[0]); 107 | TestUtils.Simulate.submit(form); 108 | 109 | assert.isFalse(tagEditor.state.showMenu); 110 | assert.notOk(TestUtils.scryRenderedDOMComponentsWithClass(tagEditor, 'tag-editor__menu').length); 111 | }); 112 | 113 | it('should render the current item tags in the edit menu if item has tags', function() { 114 | var tagEditor = TestUtils.renderIntoDocument( 115 | 120 | ); 121 | var button = TestUtils.findRenderedDOMComponentWithTag(tagEditor, 'button'); 122 | TestUtils.Simulate.click(button); 123 | 124 | assert.equal(TestUtils.scryRenderedDOMComponentsWithTag(tagEditor, 'li').length, 2); 125 | }); 126 | 127 | it('should trigger a remove event on tag changer utility if tag is deleted from menu', function() { 128 | var stub = { 129 | addOrRemove: sinon.stub() 130 | }; 131 | var currentTags = ["test", "test2"]; 132 | 133 | var tagEditor = TestUtils.renderIntoDocument( 134 | 139 | ); 140 | var deleteButton; 141 | var button = TestUtils.findRenderedDOMComponentWithTag(tagEditor, 'button'); 142 | TestUtils.Simulate.click(button); 143 | 144 | deleteButton = TestUtils.scryRenderedDOMComponentsWithTag(tagEditor, 'i')[1]; 145 | TestUtils.Simulate.click(deleteButton); 146 | 147 | sinon.assert.calledOnce(stub.addOrRemove); 148 | sinon.assert.calledWith(stub.addOrRemove, [1,1], currentTags, 'test', 'remove'); 149 | }); 150 | 151 | it('should close the edit menu automatically if the last item tag is deleted', function() { 152 | var stub = { 153 | addOrRemove: sinon.stub() 154 | }; 155 | 156 | var tagEditor = TestUtils.renderIntoDocument( 157 | 162 | ); 163 | var deleteButton; 164 | var button = TestUtils.findRenderedDOMComponentWithTag(tagEditor, 'button'); 165 | TestUtils.Simulate.click(button); 166 | 167 | deleteButton = TestUtils.scryRenderedDOMComponentsWithTag(tagEditor, 'i')[1]; 168 | TestUtils.Simulate.click(deleteButton); 169 | 170 | assert.isFalse(tagEditor.state.showMenu); 171 | assert.notOk(TestUtils.scryRenderedDOMComponentsWithClass(tagEditor, 'tag-editor-menu').length); 172 | }); 173 | 174 | it('should not close the menu if there are still item tags left', function() { 175 | var stub = { 176 | addOrRemove: sinon.stub() 177 | }; 178 | 179 | var tagEditor = TestUtils.renderIntoDocument( 180 | 185 | ); 186 | var deleteButton; 187 | var button = TestUtils.findRenderedDOMComponentWithTag(tagEditor, 'button'); 188 | TestUtils.Simulate.click(button); 189 | 190 | deleteButton = TestUtils.scryRenderedDOMComponentsWithTag(tagEditor, 'i')[1]; 191 | TestUtils.Simulate.click(deleteButton); 192 | 193 | assert.isTrue(tagEditor.state.showMenu); 194 | assert.ok(TestUtils.scryRenderedDOMComponentsWithClass(tagEditor, 'tag_editor__menu').length); 195 | }); 196 | 197 | it('should close the menu if the tag edit button is clicked a second time', function() { 198 | var tagEditor = TestUtils.renderIntoDocument( 199 | 204 | ); 205 | var button = TestUtils.findRenderedDOMComponentWithTag(tagEditor, 'button'); 206 | TestUtils.Simulate.click(button); 207 | TestUtils.Simulate.click(button); 208 | 209 | assert.isFalse(tagEditor.state.showMenu); 210 | assert.notOk(TestUtils.scryRenderedDOMComponentsWithClass(tagEditor, 'tag-editor-menu').length); 211 | }); 212 | }); -------------------------------------------------------------------------------- /test/tags_test.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | var React = window.React || require('react/addons'); 3 | var TestUtils = React.addons.TestUtils; 4 | var sinon = require('sinon'); 5 | var Tags = require('../src/components/tags'); 6 | 7 | /* 8 | * Tags element tests. 9 | */ 10 | 11 | describe('Tags', function() { 12 | it('should render an empty wrapper div if there are no tags', function() { 13 | var tags = TestUtils.renderIntoDocument( 14 | 18 | ); 19 | assert.notOk(React.findDOMNode(tags).children.length); 20 | }); 21 | 22 | it('should render a the tag name if a single tag passed in as prop', function() { 23 | var tags = TestUtils.renderIntoDocument( 24 | 28 | ); 29 | var node = React.findDOMNode(tags); 30 | 31 | assert.equal(node.children.length, 1); 32 | assert.equal(node.textContent, 'test'); 33 | }); 34 | 35 | it('should not be in condensed mode by default', function() { 36 | var tags = TestUtils.renderIntoDocument( 37 | 41 | ); 42 | assert.isFalse(tags.props.condensed); 43 | }); 44 | 45 | it('should render a list of tag names if more than one tag and not condensed', function() { 46 | var itemTags = ["test", "test2", "test3", "4/4/15"]; 47 | var tags = TestUtils.renderIntoDocument( 48 | 52 | ); 53 | var renderedItemTags = TestUtils.scryRenderedDOMComponentsWithTag(tags, 'button'); 54 | 55 | assert.equal(renderedItemTags.length, 4); 56 | _.each(renderedItemTags, function(tag, i) { 57 | assert.equal(React.findDOMNode(tag).textContent, itemTags[i]); 58 | }); 59 | }); 60 | 61 | it('should render that list of tag names with appropriately placed commas', function() { 62 | var itemTags = ["test", "test2", "test3", "4/4/15"]; 63 | var tags = TestUtils.renderIntoDocument( 64 | 68 | ); 69 | var haveTrailingComma = _.map(TestUtils.scryRenderedDOMComponentsWithTag(tags, 'li'), function(li, i) { 70 | return React.findDOMNode(li).textContent.match(',') ? true : false; 71 | }); 72 | 73 | assert.deepEqual(haveTrailingComma, [true, true, true, false]); 74 | }); 75 | 76 | it('should render tags count as "_#_ tags" if more than one tag and condensed', function() { 77 | var tags = TestUtils.renderIntoDocument( 78 | 83 | ); 84 | assert.equal(TestUtils.scryRenderedDOMComponentsWithTag(tags, 'button').length, 1); 85 | assert.equal(React.findDOMNode(tags).textContent, '4 tags'); 86 | }); 87 | 88 | it('should trigger a navigation event on tag click if navigator utility prop provided', function() { 89 | var stub = { 90 | setTagFilterAndRoute: sinon.stub() 91 | }; 92 | var tags = TestUtils.renderIntoDocument( 93 | 98 | ); 99 | 100 | TestUtils.Simulate.click(TestUtils.scryRenderedDOMComponentsWithTag(tags, 'button')[0]); 101 | sinon.assert.calledOnce(stub.setTagFilterAndRoute); 102 | sinon.assert.calledWith(stub.setTagFilterAndRoute, 'test'); 103 | }); 104 | 105 | it('should call alternative callback if provided instead of a navigator', function() { 106 | var stub = sinon.stub(); 107 | var tags = TestUtils.renderIntoDocument( 108 | 113 | ); 114 | 115 | TestUtils.Simulate.click(TestUtils.scryRenderedDOMComponentsWithTag(tags, 'button')[0]); 116 | sinon.assert.calledOnce(stub); 117 | sinon.assert.calledWith(stub, 'test'); 118 | }); 119 | }); -------------------------------------------------------------------------------- /upload-assets-to-s3.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Uploads css to s3 for 3rd parties to include them. 3 | 4 | TAGNAME= 5 | 6 | # validate s3put exists 7 | which s3put 1>/dev/null 8 | if [ "$?" -ne "0" ]; then 9 | echo "You must install s3put via the boto python package." 10 | exit 1 11 | fi 12 | 13 | if [ "$AWS_ACCESS_KEY_ID" == "" ]; then 14 | echo "Please specify your AWS access key in the AWS_ACCESS_KEY_ID environment variable" 15 | exit 1; 16 | fi 17 | 18 | if [ "$AWS_ACCESS_KEY_SECRET" == "" ]; then 19 | echo "Please specify your AWS access key in the AWS_ACCESS_KEY_SECRET environment variable" 20 | exit 1; 21 | fi 22 | 23 | echo "Which tag do you want to upload assets for?" 24 | read TAGNAME 25 | 26 | # checkout git tag 27 | if [ `git tag | grep $TAGNAME | wc -l` -eq "0" ]; then 28 | echo "You must specify an existing git tag." 29 | exit 1 30 | fi 31 | 32 | git checkout $TAGNAME 33 | 34 | # build css 35 | gulp cssmin || exit 1 36 | 37 | # upload css to s3 in a tagged folder. 38 | s3put --access_key $AWS_ACCESS_KEY_ID --secret_key $AWS_ACCESS_KEY_SECRET --bucket sprintly-ui-build-artifacts --prefix $PWD/dist/css/ --key_prefix $TAGNAME dist/css 39 | --------------------------------------------------------------------------------