diff --git a/packages/oui-angular/src/index.js b/packages/oui-angular/src/index.js index c32f6756..745f69b4 100644 --- a/packages/oui-angular/src/index.js +++ b/packages/oui-angular/src/index.js @@ -10,6 +10,7 @@ import CriteriaAdder from "@ovh-ui/oui-criteria-adder"; import CriteriaContainer from "@ovh-ui/oui-criteria-container"; import Datagrid from "@ovh-ui/oui-datagrid"; import Dropdown from "@ovh-ui/oui-dropdown"; +import DualList from "@ovh-ui/oui-dual-list"; import Field from "@ovh-ui/oui-field"; import FormActions from "@ovh-ui/oui-form-actions"; import GuideMenu from "@ovh-ui/oui-guide-menu"; @@ -50,6 +51,7 @@ export default angular CriteriaContainer, Datagrid, Dropdown, + DualList, Field, FormActions, GuideMenu, diff --git a/packages/oui-angular/src/index.spec.js b/packages/oui-angular/src/index.spec.js index 67b46a72..42ff6b48 100644 --- a/packages/oui-angular/src/index.spec.js +++ b/packages/oui-angular/src/index.spec.js @@ -12,6 +12,7 @@ loadTests(require.context("../../oui-criteria-adder/src/", true, /.*((\.spec)|(i loadTests(require.context("../../oui-criteria-container/src/", true, /.*((\.spec)|(index))$/)); loadTests(require.context("../../oui-datagrid/src/", true, /.*((\.spec)|(index))$/)); loadTests(require.context("../../oui-dropdown/src/", true, /.*((\.spec)|(index))$/)); +loadTests(require.context("../../oui-dual-list/src/", true, /.*((\.spec)|(index))$/)); loadTests(require.context("../../oui-field/src/", true, /.*((\.spec)|(index))$/)); loadTests(require.context("../../oui-form-actions/src/", true, /.*((\.spec)|(index))$/)); loadTests(require.context("../../oui-guide-menu/src/", true, /.*((\.spec)|(index))$/)); diff --git a/packages/oui-dual-list/README.md b/packages/oui-dual-list/README.md new file mode 100644 index 00000000..ba2baf79 --- /dev/null +++ b/packages/oui-dual-list/README.md @@ -0,0 +1,123 @@ +# Dual List + + + +## Usage + +### Basic + +#### Array of strings + +```html:preview + + + + +``` + +#### Array of objects + +```html:preview + + + + +``` + +#### Array of objects (deep nested property) + +```html:preview + + + + +``` + +### Loading state + +**Note**: If `source` or `target` attribute are undefined, the loading will be automatically active. + +```html:preview +
+

+ + +

+
+ + + + +``` + +### Events + +**Note**: Bulk actions `Add all` and `Remove all` will trigger callbacks for each moved items. + +```html:preview + + + + +
+

onAdd ({{$ctrl.onAddCount}}): {{$ctrl.onAddItem | json}}

+

onRemove ({{$ctrl.onRemoveCount}}): {{$ctrl.onRemoveItem | json}}

+

onChange ({{$ctrl.onChangeCount}}): {{$ctrl.onChangeItem | json}}

+
+``` + +## API + +### oui-dual-list + +| Attribute | Type | Binding | One-time binding | Values | Default | Description +| ---- | ---- | ---- | ---- | ---- | ---- | ---- +| `source` | array | = | no | n/a | n/a | source model bound to component +| `target` | array | = | no | n/a | n/a | target model bound to component +| `property` | string | @? | no | n/a | n/a | property path used to get value from item +| `on-add` | function | & | no | n/a | n/a | handler triggered when an item is added +| `on-remove` | function | & | no | n/a | n/a | handler triggered when an item is removed +| `on-change` | function | & | no | n/a | n/a | handler triggered when items have changed + +### oui-dual-list-source + +| Attribute | Type | Binding | One-time binding | Values | Default | Description +| ---- | ---- | ---- | ---- | ---- | ---- | ---- +| `heading` | string | @? | yes | n/a | n/a | heading text +| `placeholder` | string | @? | yes | n/a | n/a | placeholder text +| `loading` | boolean | + this.$element.addClass("oui-dual-list") + ); + } + + getProperty (item) { + return get(item, this.property, item); + } + + moveToSource (item) { + if (angular.isArray(this.source)) { + remove(this.target, (source) => source === item); + this.source.push(item); + + // Callbacks + this.onChange({ item }); + this.onRemove({ item }); + } + } + + moveAllToSource (searchQuery) { + // Need to do a while for the callbacks + while (angular.isArray(this.source) && this.$filter("filter")(this.target, searchQuery).length) { + this.moveToSource(this.$filter("filter")(this.target, searchQuery)[0]); + } + } + + moveToTarget (item) { + if (angular.isArray(this.target)) { + remove(this.source, (source) => source === item); + this.target.push(item); + + // Callbacks + this.onChange({ item }); + this.onAdd({ item }); + } + } + + moveAllToTarget (searchQuery) { + // Need to do a while for the callbacks + while (angular.isArray(this.target) && this.$filter("filter")(this.source, searchQuery).length) { + this.moveToTarget(this.$filter("filter")(this.source, searchQuery)[0]); + } + } +} diff --git a/packages/oui-dual-list/src/dual-list.provider.js b/packages/oui-dual-list/src/dual-list.provider.js new file mode 100644 index 00000000..b80244b7 --- /dev/null +++ b/packages/oui-dual-list/src/dual-list.provider.js @@ -0,0 +1,38 @@ +import merge from "lodash/merge"; + +export default class { + constructor () { + this.translations = { + source: { + heading: "Items to select", + placeholder: "No item to select", + move: "Add", + moveAll: "Add all", + search: "Search in source content" + }, + target: { + heading: "Selected items", + placeholder: "No selected item", + move: "Remove", + moveAll: "Remove all", + search: "Search in target content" + } + }; + } + + /** + * Set the translations + * @param {Object} translations a map of translations + */ + setTranslations (translations) { + this.translations = merge(this.translations, translations); + return this; + } + + $get () { + return { + translations: this.translations + }; + } +} + diff --git a/packages/oui-dual-list/src/index.js b/packages/oui-dual-list/src/index.js new file mode 100644 index 00000000..09959fa3 --- /dev/null +++ b/packages/oui-dual-list/src/index.js @@ -0,0 +1,12 @@ +import DualList from "./dual-list.component"; +import DualListProvider from "./dual-list.provider"; +import DualListSource from "./source/dual-list-source.component"; +import DualListTarget from "./target/dual-list-target.component"; + +export default angular + .module("oui.dual-list", []) + .component("ouiDualList", DualList) + .component("ouiDualListSource", DualListSource) + .component("ouiDualListTarget", DualListTarget) + .provider("ouiDualListConfiguration", DualListProvider) + .name; diff --git a/packages/oui-dual-list/src/index.spec.data.json b/packages/oui-dual-list/src/index.spec.data.json new file mode 100644 index 00000000..8126782e --- /dev/null +++ b/packages/oui-dual-list/src/index.spec.data.json @@ -0,0 +1,65 @@ +{ + "string": { + "source": ["Lorem"], + "target": ["Ipsum"] + }, + "strings": { + "source": [ + "Andrew", + "Red", + "Lila", + "Marie", + "Robert", + "Hope", + "Alexis", + "Madison", + "Vanessa" + ], + "target": [ + "Kevin", + "Mark" + ] + }, + "object": { + "source": [{ "name": "Lorem", "age": 15 }], + "target": [{ "name": "Ipsum", "age": 25 }] + }, + "objects": { + "source" : [ + { "name": "Andrew", "age": 15 }, + { "name": "Red", "age": 25 }, + { "name": "Lila", "age": 34 }, + { "name": "Marie", "age": 12 }, + { "name": "Robert", "age": 64 }, + { "name": "Hope", "age": 15 }, + { "name": "Alexis", "age": 25 }, + { "name": "Madison", "age": 34 }, + { "name": "Vanessa", "age": 12 } + ], + "target" : [ + { "name": "Kevin", "age": 12 }, + { "name": "Mark", "age": 64 } + ] + }, + "nestedObject": { + "source": [{ "name": { "firstName": "Lorem" }, "age": 15 }], + "target": [{ "name": { "firstName": "Ipsum" }, "age": 25 }] + }, + "nestedObjects" : { + "source": [ + { "name": { "firstName": "Andrew" }, "age": 15 }, + { "name": { "firstName": "Red" }, "age": 25 }, + { "name": { "firstName": "Lila" }, "age": 34 }, + { "name": { "firstName": "Marie" }, "age": 12 }, + { "name": { "firstName": "Robert" }, "age": 64 }, + { "name": { "firstName": "Hope" }, "age": 15 }, + { "name": { "firstName": "Alexis" }, "age": 25 }, + { "name": { "firstName": "Madison" }, "age": 34 }, + { "name": { "firstName": "Vanessa" }, "age": 12 } + ], + "target" : [ + { "name": { "firstName": "Kevin" }, "age": 12 }, + { "name": { "firstName": "Mark" }, "age": 64 } + ] + } +} diff --git a/packages/oui-dual-list/src/index.spec.js b/packages/oui-dual-list/src/index.spec.js new file mode 100644 index 00000000..9c9a5e34 --- /dev/null +++ b/packages/oui-dual-list/src/index.spec.js @@ -0,0 +1,256 @@ +import data from "./index.spec.data.json"; +import get from "lodash/get"; + +describe("ouiDualList", () => { + let TestUtils; + let $timeout; + let configuration; + + const sourceString = data.string.source; + const sourceStrings = data.strings.source; + const sourceObject = data.object.source; + const sourceNestedObject = data.nestedObject.source; + + const targetString = data.string.target; + const targetStrings = data.strings.target; + const targetObject = data.object.target; + const targetNestedObject = data.nestedObject.target; + + const getDualList = (source, target) => TestUtils.compileTemplate(` + + + + `, { + source, + target + }); + + const getDualListWithProperty = (property, source, target) => TestUtils.compileTemplate(` + + + + `, { + property, + source, + target + }); + + const getDualListWithEvents = (onAddSpy, onChangeSpy, onRemoveSpy, source, target) => TestUtils.compileTemplate(` + + + + `, { + onAddSpy, + onChangeSpy, + onRemoveSpy, + source, + target + }); + + beforeEach(angular.mock.module("oui.button")); + beforeEach(angular.mock.module("oui.dual-list")); + beforeEach(angular.mock.module("oui.dual-list.configuration")); + beforeEach(angular.mock.module("oui.search")); + beforeEach(angular.mock.module("oui.spinner")); + beforeEach(angular.mock.module("oui.test-utils")); + + beforeEach(inject((_$timeout_, _TestUtils_) => { + $timeout = _$timeout_; + TestUtils = _TestUtils_; + })); + + describe("Provider", () => { + angular.module("oui.dual-list.configuration", [ + "oui.dual-list" + ]).config(ouiDualListConfigurationProvider => { + ouiDualListConfigurationProvider.setTranslations({ + foo: "bar" + }); + }); + + beforeEach(inject(_ouiDualListConfiguration_ => { + configuration = _ouiDualListConfiguration_; + })); + + it("should have custom options", () => { + expect(configuration.translations.foo).toEqual("bar"); + }); + }); + + describe("Component", () => { + describe("oui-inline-adder", () => { + it("should have a default classname", () => { + const element = getDualList(sourceStrings, targetStrings); + + $timeout.flush(); + + expect(element.hasClass("oui-dual-list")).toBeTruthy(); + }); + + it("should load the source and target data", () => { + const element = getDualList(sourceStrings, targetStrings); + + const sourceLength = sourceStrings.length; + const sourceItemsLength = element.find("oui-dual-list-source").find("li").length; + + expect(sourceItemsLength).toBe(sourceLength); + + const targetLength = targetStrings.length; + const targetItemsLength = element.find("oui-dual-list-target").find("li").length; + + expect(targetItemsLength).toBe(targetLength); + }); + + it("should display value from array of strings", () => { + const element = getDualList(sourceString, targetString); + + const source = element.find("oui-dual-list-source")[0]; + const sourceItem = angular.element(source.querySelector(".oui-dual-list-item:first-child .oui-dual-list-item__property")); + + expect(sourceItem.text()).toBe(sourceString[0]); + + const target = element.find("oui-dual-list-target")[0]; + const targetItem = angular.element(target.querySelector(".oui-dual-list-item:first-child .oui-dual-list-item__property")); + + expect(targetItem.text()).toBe(targetString[0]); + }); + + it("should display property value from array of objects", () => { + const property = "name"; + const element = getDualListWithProperty(property, sourceObject, targetObject); + + const source = element.find("oui-dual-list-source")[0]; + const sourceItem = angular.element(source.querySelector(".oui-dual-list-item:first-child .oui-dual-list-item__property")); + + expect(sourceItem.text()).toBe(get(sourceObject[0], property)); + + const target = element.find("oui-dual-list-target")[0]; + const targetItem = angular.element(target.querySelector(".oui-dual-list-item:first-child .oui-dual-list-item__property")); + + expect(targetItem.text()).toBe(get(targetObject[0], property)); + }); + + it("should display property value from array of nested objects", () => { + const property = "name.firstName"; + const element = getDualListWithProperty(property, sourceNestedObject, targetNestedObject); + + const source = element.find("oui-dual-list-source")[0]; + const sourceItem = angular.element(source.querySelector(".oui-dual-list-item:first-child .oui-dual-list-item__property")); + + expect(sourceItem.text()).toBe(get(sourceNestedObject[0], property)); + + const target = element.find("oui-dual-list-target")[0]; + const targetItem = angular.element(target.querySelector(".oui-dual-list-item:first-child .oui-dual-list-item__property")); + + expect(targetItem.text()).toBe(get(targetNestedObject[0], property)); + }); + + it("should move first source item to target", () => { + const element = getDualList(sourceStrings, targetStrings); + const controller = element.controller("ouiDualList"); + + const source = element.find("oui-dual-list-source")[0]; + const sourceItem = angular.element(source.querySelector(".oui-dual-list-item:first-child button")); + + sourceItem.triggerHandler("click"); + + const sourceLength = sourceStrings.length - 1; + const sourceItemsLength = controller.source.length; + + expect(sourceItemsLength).toBe(sourceLength); + + const targetLength = targetStrings.length + 1; + const targetItemsLength = controller.target.length; + + expect(targetItemsLength).toBe(targetLength); + }); + + it("should move all source items to target", () => { + const element = getDualList(sourceStrings, targetStrings); + const controller = element.controller("ouiDualList"); + + const source = element.find("oui-dual-list-source")[0]; + const sourceAction = angular.element(source.querySelector(".oui-dual-list__action button")); + + sourceAction.triggerHandler("click"); + + const sourceLength = 0; + const sourceItemsLength = controller.source.length; + + expect(sourceItemsLength).toBe(sourceLength); + + const targetLength = sourceStrings.length + targetStrings.length; + const targetItemsLength = controller.target.length; + + expect(targetItemsLength).toBe(targetLength); + }); + + it("should move first target item to source", () => { + const element = getDualList(sourceStrings, targetStrings); + const controller = element.controller("ouiDualList"); + + const target = element.find("oui-dual-list-target")[0]; + const targetItem = angular.element(target.querySelector(".oui-dual-list-item:first-child button")); + + targetItem.triggerHandler("click"); + + const sourceLength = sourceStrings.length + 1; + const sourceItemsLength = controller.source.length; + + expect(sourceItemsLength).toBe(sourceLength); + + const targetLength = targetStrings.length - 1; + const targetItemsLength = controller.target.length; + + expect(targetItemsLength).toBe(targetLength); + }); + + it("should move all target items to source", () => { + const element = getDualList(sourceStrings, targetStrings); + const controller = element.controller("ouiDualList"); + + const target = element.find("oui-dual-list-target")[0]; + const targetAction = angular.element(target.querySelector(".oui-dual-list__action button")); + + targetAction.triggerHandler("click"); + + const sourceLength = sourceStrings.length + targetStrings.length; + const sourceItemsLength = controller.source.length; + + expect(sourceItemsLength).toBe(sourceLength); + + const targetLength = 0; + const targetItemsLength = controller.target.length; + + expect(targetItemsLength).toBe(targetLength); + }); + + it("should call events callbacks", () => { + const onAddSpy = jasmine.createSpy("onAddSpy"); + const onChangeSpy = jasmine.createSpy("onChangeSpy"); + const onRemoveSpy = jasmine.createSpy("onRemoveSpy"); + + const element = getDualListWithEvents(onAddSpy, onChangeSpy, onRemoveSpy, sourceStrings, targetStrings); + + // Source click + const source = element.find("oui-dual-list-source")[0]; + const sourceItem = angular.element(source.querySelector(".oui-dual-list-item:first-child button")); + sourceItem.triggerHandler("click"); + + expect(onAddSpy).toHaveBeenCalled(); + expect(onChangeSpy).toHaveBeenCalled(); + + // Target click + const target = element.find("oui-dual-list-target")[0]; + const targetItem = angular.element(target.querySelector(".oui-dual-list-item:first-child button")); + targetItem.triggerHandler("click"); + + expect(onRemoveSpy).toHaveBeenCalled(); + expect(onChangeSpy).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/packages/oui-dual-list/src/source/dual-list-source.component.js b/packages/oui-dual-list/src/source/dual-list-source.component.js new file mode 100644 index 00000000..633e977f --- /dev/null +++ b/packages/oui-dual-list/src/source/dual-list-source.component.js @@ -0,0 +1,16 @@ +import controller from "./dual-list-source.controller"; +import template from "./dual-list-source.html"; + +export default { + require: { + dualList: "^ouiDualList" + }, + bindings: { + heading: "@?", + placeholder: "@?", + loading: " + this.$element.addClass("oui-dual-list-source") + ); + } + + isLoading () { + return this.loading || angular.isUndefined(this.dualList.source); + } + + toggle () { + this.expanded = !this.expanded; + } +} diff --git a/packages/oui-dual-list/src/source/dual-list-source.html b/packages/oui-dual-list/src/source/dual-list-source.html new file mode 100644 index 00000000..a51f6d53 --- /dev/null +++ b/packages/oui-dual-list/src/source/dual-list-source.html @@ -0,0 +1,82 @@ + +
+ + + ({{$ctrl.dualList.source.length}}) + +
+ + + +
+ + + + + + + +

+ + {{::$ctrl.translations.moveAll}} + +

+ + + +
+ +
+ + + +
+

+
+ + +
+ +
+
diff --git a/packages/oui-dual-list/src/target/dual-list-target.component.js b/packages/oui-dual-list/src/target/dual-list-target.component.js new file mode 100644 index 00000000..7dd5a255 --- /dev/null +++ b/packages/oui-dual-list/src/target/dual-list-target.component.js @@ -0,0 +1,16 @@ +import controller from "./dual-list-target.controller"; +import template from "./dual-list-target.html"; + +export default { + require: { + dualList: "^ouiDualList" + }, + bindings: { + heading: "@?", + placeholder: "@?", + loading: " + this.$element.addClass("oui-dual-list-target") + ); + } + + isLoading () { + return this.loading || angular.isUndefined(this.dualList.target); + } + + toggle () { + this.expanded = !this.expanded; + } +} diff --git a/packages/oui-dual-list/src/target/dual-list-target.html b/packages/oui-dual-list/src/target/dual-list-target.html new file mode 100644 index 00000000..b2c1f745 --- /dev/null +++ b/packages/oui-dual-list/src/target/dual-list-target.html @@ -0,0 +1,82 @@ + +
+ + + ({{$ctrl.dualList.target.length}}) + +
+ + + +
+ + + + + + + +

+ + {{::$ctrl.translations.moveAll}} + +

+ + + +
+ +
+ + + +
+

+
+ + +
+ +
+
diff --git a/packages/oui-inline-adder/package.json b/packages/oui-inline-adder/package.json new file mode 100644 index 00000000..dd84ec2a --- /dev/null +++ b/packages/oui-inline-adder/package.json @@ -0,0 +1,7 @@ +{ + "name": "@ovh-ui/oui-inline-adder", + "version": "1.0.0", + "main": "./src/index.js", + "license": "BSD-3-Clause", + "author": "OVH SAS" +} diff --git a/packages/oui-inline-adder/src/inline-adder.html b/packages/oui-inline-adder/src/inline-adder.html index 9502e156..8a7bb8eb 100644 --- a/packages/oui-inline-adder/src/inline-adder.html +++ b/packages/oui-inline-adder/src/inline-adder.html @@ -12,13 +12,13 @@ diff --git a/packages/oui-inline-adder/src/inline-adder.provider.js b/packages/oui-inline-adder/src/inline-adder.provider.js index c2810a0f..1441531d 100644 --- a/packages/oui-inline-adder/src/inline-adder.provider.js +++ b/packages/oui-inline-adder/src/inline-adder.provider.js @@ -1,4 +1,4 @@ -import { merge } from "lodash"; +import merge from "lodash/merge"; export default class { constructor () { diff --git a/packages/oui-navbar/src/navbar.provider.js b/packages/oui-navbar/src/navbar.provider.js index c9b4b4e9..108130f3 100644 --- a/packages/oui-navbar/src/navbar.provider.js +++ b/packages/oui-navbar/src/navbar.provider.js @@ -1,4 +1,4 @@ -import { merge } from "lodash"; +import merge from "lodash/merge"; export default class { constructor () { diff --git a/packages/oui-pagination/src/pagination.html b/packages/oui-pagination/src/pagination.html index db8204ed..225321bb 100644 --- a/packages/oui-pagination/src/pagination.html +++ b/packages/oui-pagination/src/pagination.html @@ -32,7 +32,7 @@ ng-attr-aria-label="{{ ::$ctrl.translations.previousPage }}" ng-disabled="$ctrl.currentPage === 1" ng-click="$ctrl.onPageChange($ctrl.getCurrentPage() - 1)"> -