diff --git a/README.md b/README.md index 703fb1c..3cd7dc2 100644 --- a/README.md +++ b/README.md @@ -18,27 +18,26 @@ Usage Contributing ------------------------------------------------------------------------------ +This project uses docker-compose to ensure a consistant testing environment. See alternative instructions below for local testing. + ### Installation * `git clone https://github.com/limit-zero/ember-common-uikit` * `cd ember-common-uikit` -* `yarn install` +* `yarn build` ### Linting -* `yarn lint:js` -* `yarn lint:js --fix` +* `yarn lint` ### Running tests -* `ember test` – Runs the test suite on the current Ember version -* `ember test --server` – Runs the test suite in "watch mode" -* `ember try:each` – Runs the test suite against multiple Ember versions +* `yarn test` – Runs the test suite on the current Ember version ### Running the dummy application -* `ember serve` -* Visit the dummy application at [http://localhost:4200](http://localhost:4200). +* `yarn start` +* Visit the dummy application at [http://localhost:9905](http://localhost:9905). For more information on using ember-cli, visit [https://ember-cli.com/](https://ember-cli.com/). diff --git a/addon/.gitkeep b/addon/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/addon/components/bs-modal.js b/addon/components/bs-modal.js new file mode 100644 index 0000000..55035ff --- /dev/null +++ b/addon/components/bs-modal.js @@ -0,0 +1,15 @@ +import Component from '@ember/component'; +import layout from '../templates/components/bs-modal'; + +export default Component.extend({ + layout, + to: 'bootstrap-modals', + backdrop: true, + fade: true, + show: false, + keyboard: true, + focus: true, + size: null, + contentClass: null, + dislogClass: null, +}); diff --git a/addon/components/bs-modal/body.js b/addon/components/bs-modal/body.js new file mode 100644 index 0000000..8436d64 --- /dev/null +++ b/addon/components/bs-modal/body.js @@ -0,0 +1,7 @@ +import Component from '@ember/component'; +import layout from '../../templates/components/bs-modal/body'; + +export default Component.extend({ + layout, + classNames: ['modal-body'], +}); diff --git a/addon/components/bs-modal/dialog.js b/addon/components/bs-modal/dialog.js new file mode 100644 index 0000000..af37528 --- /dev/null +++ b/addon/components/bs-modal/dialog.js @@ -0,0 +1,24 @@ +import Component from '@ember/component'; +import layout from '../../templates/components/bs-modal/dialog'; + +export default Component.extend({ + layout, + classNames: ['modal-dialog'], + classNameBindings: ['_sizeClass'], + attributeBindings: ['role'], + + role: 'document', + + size: null, + + _sizeClass: computed('size', function() { + switch (this.get('size')) { + case 'small': + return 'modal-sm'; + case 'large': + return 'modal-lg'; + default: + return null; + } + }), +}); diff --git a/addon/components/bs-modal/footer.js b/addon/components/bs-modal/footer.js new file mode 100644 index 0000000..67efbd8 --- /dev/null +++ b/addon/components/bs-modal/footer.js @@ -0,0 +1,7 @@ +import Component from '@ember/component'; +import layout from '../../templates/components/bs-modal/footer'; + +export default Component.extend({ + layout, + classNames: ['modal-footer'], +}); diff --git a/addon/components/bs-modal/header.js b/addon/components/bs-modal/header.js new file mode 100644 index 0000000..2abad8a --- /dev/null +++ b/addon/components/bs-modal/header.js @@ -0,0 +1,12 @@ +import Component from '@ember/component'; +import layout from '../../templates/components/bs-modal/header'; + +export default Component.extend({ + layout, + classNames: ['modal-header'], + title: null, + showClose: true, + + titleComponent: 'bs-modal/title', + closeComponent: 'bs-modal/close-icon' +}); diff --git a/addon/components/bs-modal/wrapper.js b/addon/components/bs-modal/wrapper.js new file mode 100644 index 0000000..5ceb4c2 --- /dev/null +++ b/addon/components/bs-modal/wrapper.js @@ -0,0 +1,137 @@ +import Component from '@ember/component'; +import layout from '../../templates/components/bs-modal/wrapper'; + +export default Component.extend({ + layout, + classNames: ['modal'], + classNameBindings: ['fade'], + attributeBindings: ['role'], + + fade: true, + show: false, + role: 'dialog', + size: null, + + isShowing: false, + isShown: false, + isHiding: false, + isHidden: true, + isTransitioning: false, + + didInsertElement() { + const $obj = this.$(); + // Set the modal options. + this.setModalOptions($obj); + // Replace Bootstraps native dismiss with the internal action. + this.replaceDismiss($obj); + // Set modal event hooks + this.setModalHooks($obj); + // Show the modal, if directed to. + if (this.get('show')) this.send('show'); + }, + + willDestroyElement() { + const $obj = this.$(); + if (this.get('isShown')) { + // The modal was closed by sending `show=false` and it's still open. + // Remove internal events and then natively hide the modal and disposed once hidden. + $obj.off('hidden.bs.modal') + $obj.on('hidden.bs.modal', () => { + this.sendEvent('onHidden'); + $obj.modal('dispose'); + }); + $obj.modal('hide'); + } else { + $obj.modal('dispose'); + } + }, + + actions: { + show() { + if (this.get('isTransitioning')) return; + this.$().modal('show'); + }, + + hide() { + if (this.get('isTransitioning')) return; + this.$().modal('hide'); + }, + }, + + setModalOptions($obj) { + const keys = ['backdrop', 'keyboard', 'focus']; + const options = keys.reduce((opts, key) => { + const value = this.get(key); + if (isPresent(value)) opts[key] = value; + return opts; + }, { show: false }); + $obj.modal(options); + }, + + replaceDismiss($obj) { + // Turn off Bootstrap's native dismissing of the modal (via a click from a`[data-dismiss="modal"]` element or by clicking the backdrop) + $obj.off('click.dismiss.bs.modal'); + // Replace with the Ember hide() action. + $obj.on('click.dismiss.bs.modal', (event) => { + if ($(event.currentTarget).is(event.target) && true === this.get('backdrop')) { + this.send('hide'); + } + }); + }, + + resetShowing() { + this.set('isShowing', false); + this.set('isShown', false); + }, + + resetHiding() { + this.set('isHiding', false); + this.set('isHidden', false); + }, + + setModalHooks($obj) { + // This event fires immediately when the show instance method is called. + // If caused by a click, the clicked element is available as the relatedTarget property of the event. + $obj.on('show.bs.modal', () => { + this.resetHiding(); + this.set('isTransitioning', true); + this.set('isShowing', true); + this.set('isShown', false); + this.sendEvent('onShow'); + + }); + + // This event is fired when the modal has been made visible to the user (will wait for CSS transitions to complete). + // If caused by a click, the clicked element is available as the relatedTarget property of the event. + $obj.on('shown.bs.modal', () => { + this.set('isShown', true); + this.set('isShowing', false); + this.set('isTransitioning', false); + this.sendEvent('onShown'); + }); + + // This event is fired immediately when the hide instance method has been called. + $obj.on('hide.bs.modal', () => { + this.resetShowing(); + this.set('isTransitioning', true); + this.set('isHiding', true); + this.set('isHidden', false); + this.sendEvent('onHide'); + }); + + // This event is fired when the modal has finished being hidden from the user (will wait for CSS transitions to complete). + $obj.on('hidden.bs.modal', () => { + this.set('isHidden', true); + this.set('isHiding', false); + this.set('isTransitioning', false); + this.sendEvent('onHidden'); + if (!this.get('isDestroyed')) this.set('show', false); + }); + }, + + sendEvent(name) { + const fn = this.get(name); + if (fn && typeof fn === 'function') return fn(); + }, + +}); diff --git a/addon/components/confirm-button.js b/addon/components/confirm-button.js new file mode 100644 index 0000000..5460dd8 --- /dev/null +++ b/addon/components/confirm-button.js @@ -0,0 +1,31 @@ +import Component from '@ember/component'; +import layout from '../templates/components/confirm-button'; + +export default Component.extend({ + layout, + tagName: 'button', + classNames: ['btn', 'clickable'], + attributeBindings: ['disabled', 'type'], + + disabled: false, + type: 'button', + + icon: '', + label: 'Action', + confirmLabel: 'You Sure?', + onConfirm: null, + + hasConfirmed: false, + + click() { + if (this.get('hasConfirmed')) { + this.get('onConfirm')(); + } else { + this.set('hasConfirmed', true); + } + }, + + focusOut() { + this.set('hasConfirmed', false); + }, +}); diff --git a/addon/components/fetch-more.js b/addon/components/fetch-more.js new file mode 100644 index 0000000..f090637 --- /dev/null +++ b/addon/components/fetch-more.js @@ -0,0 +1,82 @@ +import Component from '@ember/component'; +import { computed } from '@ember/object'; +import { assign } from '@ember/polyfills'; +import { isArray } from '@ember/array'; +import layout from '../templates/components/fetch-more'; + +export default Component.extend({ + layout, + + /** + * The Apollo client query observable. + * @type {Observable} + */ + query: null, + + hasNextPage: false, + endCursor: null, + resultKey: null, + + isFetching: false, + + nodes: computed('edges.@each.node', function() { + const edges = this.get('edges'); + if (!isArray(edges)) return []; + return edges.map(edge => edge.node); + }), + + hasEvent(name) { + const fn = this.get(name); + return fn && typeof fn === 'function'; + }, + + sendEvent(name, ...args) { + if (this.hasEvent(name)) this.get(name)(...args, this); + }, + + actions: { + /** + * Fetches more results using the observable from the original query. + * @see https://www.apollographql.com/docs/react/features/pagination.html + */ + async fetchMore() { + this.set('isFetching', true); + this.sendEvent('on-fetch-start'); + const observable = this.get('query'); + const endCursor = this.get('endCursor'); + const resultKey = this.get('resultKey'); + + const updateQuery = (previous, { fetchMoreResult }) => { + const { edges, pageInfo, totalCount } = fetchMoreResult[resultKey]; + if (edges.length) { + return { + [resultKey]: { + __typename: previous[resultKey].__typename, + totalCount, + edges: [...previous[resultKey].edges, ...edges], + pageInfo, + }, + }; + } + return previous; + }; + const pagination = assign({}, observable.variables.pagination, { after: endCursor }); + const variables = { pagination }; + try { + const result = await observable.fetchMore({ updateQuery, variables }); + this.sendEvent('on-fetch-success', result); + return result; + } catch (e) { + const evt = 'on-fetch-error'; + if (this.hasEvent(evt)) { + this.sendEvent(evt, e); + } else { + throw e; + } + } finally { + this.set('isFetching', false); + this.sendEvent('on-fetch-end'); + } + }, + }, +}); diff --git a/addon/components/list-controls/button.js b/addon/components/list-controls/button.js new file mode 100644 index 0000000..9c4bba0 --- /dev/null +++ b/addon/components/list-controls/button.js @@ -0,0 +1,13 @@ +import Component from '@ember/component'; +import layout from '../../templates/components/list-controls/button'; + +export default Component.extend({ + layout, + + tagName: 'button', + classNames: ['btn'], + attributeBindings: ['type', 'disabled', 'data-toggle', 'aria-haspopup', 'aria-expanded'], + + type: 'button', + disabled: false, +}); diff --git a/addon/components/list-controls/limit.js b/addon/components/list-controls/limit.js new file mode 100644 index 0000000..3e7ce8d --- /dev/null +++ b/addon/components/list-controls/limit.js @@ -0,0 +1,52 @@ +import Component from '@ember/component'; +import layout from '../../templates/components/list-controls/limit'; + +export default Component.extend({ + layout, + classNames: ['btn-group'], + attributeBindings: ['role', 'aria-label'], + + role: 'group', + 'aria-label': 'Display Limit Filter', + + /** + * The limit number, e.g. `25`. + * @public + * @type {number} + */ + limit: null, + + /** + * The label to display before the limit number. + * @public + * @type {string} + */ + label: 'Show:', + + /** + * Whether the limit dropdown control is completely disabled. + * @public + * @type {boolean} + */ + disabled: false, + + /** + * The class to apply to buttons within this group + * @public + * @type {string} + */ + buttonClass: 'btn-primary', + + /** + * Displays filtered limit options by removing the currently selected `limit` value. + */ + filteredOptions: computed('options', 'limit', function() { + return this.get('options').reject(item => item === this.get('limit')); + }), + + actions: { + setLimit(value) { + this.set('limit', value); + }, + }, +}); diff --git a/addon/components/list-controls/menu-mixin.js b/addon/components/list-controls/menu-mixin.js new file mode 100644 index 0000000..54d2e53 --- /dev/null +++ b/addon/components/list-controls/menu-mixin.js @@ -0,0 +1,47 @@ +import Component from '@ember/component'; +import layout from '../../templates/components/list-controls/menu-mixin'; + +export default Component.extend({ + layout, + /** + * The dropdown menu direction. + * An empty value will use the default `down` direction. + * Can be one of `up`, `right` or `left`. + * @public + * @type {string} + */ + direction: null, + + /** + * Aligns the menu. + * An empty value will use the default `left` alignment. + * A value of `right` will right-align the menu. + * @public + * @type {string} + */ + alignment: null, + + /** + * Determines the menu direction class based off the `direction` property. + */ + directionClass: computed('direction', function() { + switch (this.get('direction')) { + case 'up': + return 'dropup'; + case 'left': + return 'dropleft'; + case 'right': + return 'dropright'; + default: + return ''; + } + }), + + /** + * Determines the menu alignment class based off the `alignment` property. + */ + alignmentClass: computed('alignment', function() { + if (this.get('alignment') === 'right') return 'dropdown-menu-right'; + return ''; + }), +}); diff --git a/addon/components/list-controls/search.js b/addon/components/list-controls/search.js new file mode 100644 index 0000000..285787b --- /dev/null +++ b/addon/components/list-controls/search.js @@ -0,0 +1,86 @@ +import Component from '@ember/component'; +import layout from '../../templates/components/list-controls/search'; + +export default Component.extend({ + layout, + tagName: 'form', + + notify: inject(), + + submit(event) { + event.preventDefault(); + event.stopPropagation(); + // @todo Eventually enable this. + this.get('notify').warning('Search is not enabled... yet. Stay tuned.'); + + // const onSubmit = this.get('onSubmit'); + // if (onSubmit) onSubmit(this.get('value')); + }, + + /** + * The initial search phrase input value. + * This component will *not* directly manipulate this value. + * Instead, the new value can be retrieve when the search control is submitted. + * @public + * @type {string} + */ + phrase: null, + + /** + * The icon to display in the search button. + * @public + * @type {string} + */ + icon: 'magnifying-glass', + + /** + * The label to display in the search button. + * @public + * @type {string} + */ + label: null, + + /** + * The search field placeholder value. + * @public + * @type {string} + */ + placeholder: 'Search...', + + /** + * Whether to select all of the text in the search box when focusd. + * @public + * @type {boolean} + */ + selectAllOnFocus: true, + + /** + * Whether the search control is completely disabled. + * @public + * @type {boolean} + */ + disabled: false, + + /** + * The class to apply to buttons within this control. + * @public + * @type {string} + */ + buttonClass: 'btn-primary', + + init() { + this._super(...arguments); + this.set('value', this.get('phrase')); + }, + + actions: { + selectAll(event) { + if (this.get('selectAllOnFocus')) event.target.select(); + }, + clear() { + this.set('value', ''); + const onSubmit = this.get('onSubmit'); + if (onSubmit) onSubmit(''); + }, + }, +}); diff --git a/addon/components/list-controls/sort.js b/addon/components/list-controls/sort.js new file mode 100644 index 0000000..5ccc784 --- /dev/null +++ b/addon/components/list-controls/sort.js @@ -0,0 +1,77 @@ +import Component from '@ember/component'; +import layout from '../../templates/components/list-controls/sort'; +import MenuMixin from './menu-mixin'; + +export default Component.extend({ + layout, + classNames: ['btn-group'], + attributeBindings: ['role', 'aria-label'], + + role: 'group', + 'aria-label': 'Sort filter', + + /** + * The sortBy field value, e.g. `createdAt` or `name`. + * @public + * @type {string} + */ + sortBy: null, + + /** + * Whether the sort is ascending. A false value signifies descending. + * @public + * @type {boolean} + */ + ascending: true, + + /** + * Whether the sort dropdown control is completely disabled. + * @public + * @type {boolean} + */ + disabled: false, + + /** + * The class to apply to buttons within this group + * @public + * @type {string} + */ + buttonClass: 'btn-primary', + + /** + * Based on the `sortBy` value, computes the selected sort object. + * For example, if the `sortBy` value equals `createdAt`, this would return + * something like `{ key: 'createdAt', label: 'Created' }`. + */ + selected: computed('options.[]', 'sortBy', function() { + return this.get('options').findBy('key', this.get('sortBy')); + }), + + /** + * Displays filtered sort options by removing the currently selected `sortBy` value. + * Returns an array of sort option objects. + */ + filteredOptions: computed('options.[]', 'sortBy', function() { + return this.get('options').rejectBy('key', this.get('sortBy')); + }), + + /** + * Initializes the component. + * If the `options` property is not an array, it will set it as an empty array. + */ + init() { + this._super(...arguments); + if (!isArray(this.get('options'))) { + this.set('options', []); + } + }, + + actions: { + toggleAscending() { + this.toggleProperty('ascending'); + }, + sortBy(key) { + this.set('sortBy', key); + }, + }, +}); diff --git a/addon/templates/components/bs-modal.hbs b/addon/templates/components/bs-modal.hbs new file mode 100644 index 0000000..bf5e80e --- /dev/null +++ b/addon/templates/components/bs-modal.hbs @@ -0,0 +1,19 @@ +{{#if show}} + {{#ember-wormhole to=to}} + {{#bs-modal/wrapper + fade=fade + backdrop=backdrop + keyboard=keyboard + focus=focus + show=show + size=size + contentClass=contentClass + dialogClass=dialogClass + onHidden=onHidden + as |wrapper|}} + {{yield wrapper}} + {{/bs-modal/wrapper}} + {{/ember-wormhole}} +{{/if}} +{{!-- onHide=onHide +onHidden=onHidden --}} diff --git a/addon/templates/components/bs-modal/body.hbs b/addon/templates/components/bs-modal/body.hbs new file mode 100644 index 0000000..fb5c4b1 --- /dev/null +++ b/addon/templates/components/bs-modal/body.hbs @@ -0,0 +1 @@ +{{yield}} \ No newline at end of file diff --git a/addon/templates/components/bs-modal/dialog.hbs b/addon/templates/components/bs-modal/dialog.hbs new file mode 100644 index 0000000..fb5c4b1 --- /dev/null +++ b/addon/templates/components/bs-modal/dialog.hbs @@ -0,0 +1 @@ +{{yield}} \ No newline at end of file diff --git a/addon/templates/components/bs-modal/footer.hbs b/addon/templates/components/bs-modal/footer.hbs new file mode 100644 index 0000000..fb5c4b1 --- /dev/null +++ b/addon/templates/components/bs-modal/footer.hbs @@ -0,0 +1 @@ +{{yield}} \ No newline at end of file diff --git a/addon/templates/components/bs-modal/header.hbs b/addon/templates/components/bs-modal/header.hbs new file mode 100644 index 0000000..fb5c4b1 --- /dev/null +++ b/addon/templates/components/bs-modal/header.hbs @@ -0,0 +1 @@ +{{yield}} \ No newline at end of file diff --git a/addon/templates/components/bs-modal/wrapper.hbs b/addon/templates/components/bs-modal/wrapper.hbs new file mode 100644 index 0000000..7b94edf --- /dev/null +++ b/addon/templates/components/bs-modal/wrapper.hbs @@ -0,0 +1,13 @@ +{{#bs-modal/dialog size=size class=dialogClass}} +
+{{/bs-modal/dialog}} diff --git a/addon/templates/components/confirm-button.hbs b/addon/templates/components/confirm-button.hbs new file mode 100644 index 0000000..fa331d5 --- /dev/null +++ b/addon/templates/components/confirm-button.hbs @@ -0,0 +1 @@ +{{#if icon}}{{entypo-icon icon}} {{/if}}{{#unless hasConfirmed}}{{ label }}{{else}}{{ confirmLabel }}{{/unless}} diff --git a/addon/templates/components/fetch-more.hbs b/addon/templates/components/fetch-more.hbs new file mode 100644 index 0000000..40b0807 --- /dev/null +++ b/addon/templates/components/fetch-more.hbs @@ -0,0 +1,8 @@ +{{yield (hash + nodes=nodes + hasNextPage=hasNextPage + isFetching=isFetching + actions=(hash + loadMore=(action "fetchMore") + ) +)}} diff --git a/addon/templates/components/list-controls/button.hbs b/addon/templates/components/list-controls/button.hbs new file mode 100644 index 0000000..fb5c4b1 --- /dev/null +++ b/addon/templates/components/list-controls/button.hbs @@ -0,0 +1 @@ +{{yield}} \ No newline at end of file diff --git a/addon/templates/components/list-controls/limit.hbs b/addon/templates/components/list-controls/limit.hbs new file mode 100644 index 0000000..8c83a0b --- /dev/null +++ b/addon/templates/components/list-controls/limit.hbs @@ -0,0 +1,18 @@ +{{#if options.length}} + {{#list-controls/button + id="dropdown-display-limit" + disabled=disabled + class=(concat "dropdown-toggle" " " buttonClass) + data-toggle="dropdown" + aria-haspopup="true" + aria-expanded="false" + }} + {{label}} {{limit}} + {{/list-controls/button}} + + +{{/if}} diff --git a/addon/templates/components/list-controls/menu-mixin.hbs b/addon/templates/components/list-controls/menu-mixin.hbs new file mode 100644 index 0000000..fb5c4b1 --- /dev/null +++ b/addon/templates/components/list-controls/menu-mixin.hbs @@ -0,0 +1 @@ +{{yield}} \ No newline at end of file diff --git a/addon/templates/components/list-controls/search.hbs b/addon/templates/components/list-controls/search.hbs new file mode 100644 index 0000000..fd322b3 --- /dev/null +++ b/addon/templates/components/list-controls/search.hbs @@ -0,0 +1,18 @@ +