diff --git a/components/bl-cascade-select/README.md b/components/bl-cascade-select/README.md new file mode 100644 index 000000000..07a865ccf --- /dev/null +++ b/components/bl-cascade-select/README.md @@ -0,0 +1,41 @@ +# Cascade Select + +Cascade Select is a component of Backendless UI-Builder designer. This allows you to select a value from a nested structure of options. + +## Properties + +| Property | Type | Default Value | Logic | Data Binding | UI Setting | Description | +|-------------------|---------|---------------------|-----------------------------|--------------|------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Cascade | JSON | | Cascade Logic | YES | YES | Allows determinate an array of select items to display as the available options. Watch [Codeless Examples](#Examples). Signature of polygon: `[{name, code, ?children}]` | +| Placeholder | String | | Placeholder Logic | YES | YES | Allows determinate placeholder for input | + +## Events + +| Name | Triggers | Context Blocks | +|---------------|-------------------------------|--------------------------------------------------------------| +| On Click Item | when the user select the item | Item: `{name: String, code: String, levelOfNesting: Number}` | + +## Action + +| Action | Inputs | Return | +|---------------|------------------|-----------------------------| +| Get Select in | | Object: of a select item | +| Set Cascade | cascade: `Array` | | +| Get Cascade | | Array: of s cascade | +| Set Code | code: `String` | | +| Get Code | | String: code of select item | + +## Codeless Examples + +Addition of cascade data + +![](example-images/cascade_example.jpg) + +
+Try yourself + +``` +AustraliaAUNew South WalesAU-NSWSydneyAU-NSW-SYNewcastleAU-NSW-NCWollongongAU-NSW-WGQueenslandAU-QSBrisbaneAU-QS-BBTownsvilleAU-QS-TSCanadaCAQuebecCA-QBMontrealCA-QB-MRQuebec CityCA-QB-CBCOntarioCA-OTOttawaCA-OT-OWTorontoCA-OT-TR +``` +
+ diff --git a/components/bl-cascade-select/component.json b/components/bl-cascade-select/component.json new file mode 100644 index 000000000..4033118c4 --- /dev/null +++ b/components/bl-cascade-select/component.json @@ -0,0 +1,84 @@ +{ + "id": "c_4709a015328b307e652d915fc3f36fb7", + "name": "Cascade Select", + "description": "Cascade Select is a component to select a value from a nested structure of options.", + "showInToolbox": true, + "faIcon": "check-double", + "mainJS": "dist/index.js", + "type": "custom", + "category": "Custom Components", + "properties": [ + { + "type": "json", + "name": "cascade", + "label": "Cascade", + "showInSettings": true, + "hasLogicHandler": true, + "handlerId": "cascadeLogic", + "handlerLabel": "Cascade Logic", + "dataBinding": true, + "handlerDescription": "This is a handler for the logic to determine an array of select items to display as the available options." + }, + { + "type": "text", + "name": "placeholder", + "label": "Placeholder", + "showInSettings": true, + "hasLogicHandler": true, + "handlerId": "placeholderLogic", + "handlerLabel": "Placeholder Logic", + "dataBinding": true, + "handlerDescription": "This is a handler for the logic to determine the default text to display when no option is selected." + } + ], + "eventHandlers": [ + { + "name": "onClickItem", + "label": "On Click Item", + "contextBlocks": [ + { + "id": "item", + "label": "Item" + } + ], + "handlerDescription": "This event is triggered when user select item" + } + ], + "actions": [ + { + "id": "getSelected", + "label": "Get Selected in", + "hasReturn": true + }, + { + "id": "setCascade", + "label": "Set Cascade", + "inputs": [ + { + "id": "cascade", + "label": "Cascade" + } + ] + }, + { + "id": "getCascade", + "label": "Get Cascade", + "hasReturn": true + }, + { + "id": "setCode", + "label": "Set Code", + "inputs": [ + { + "id": "code", + "label": "Code" + } + ] + }, + { + "id": "getCode", + "label": "Get Code", + "hasReturn": true + } + ] +} diff --git a/components/bl-cascade-select/example-images/cascade_example.jpg b/components/bl-cascade-select/example-images/cascade_example.jpg new file mode 100644 index 000000000..1e751c587 Binary files /dev/null and b/components/bl-cascade-select/example-images/cascade_example.jpg differ diff --git a/components/bl-cascade-select/preview.html b/components/bl-cascade-select/preview.html new file mode 100644 index 000000000..4dd31a181 --- /dev/null +++ b/components/bl-cascade-select/preview.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/components/bl-cascade-select/src/helpers.js b/components/bl-cascade-select/src/helpers.js new file mode 100644 index 000000000..f2aee761a --- /dev/null +++ b/components/bl-cascade-select/src/helpers.js @@ -0,0 +1,116 @@ +export const validate = (cascade, setItemsCascade, setParentItems, setItems) => { + const { isCircular, cycleLocation } = analyzeCircularDependencies(cascade); + + if (isCircular) { + throw new Error('cascade have cycling object in ' + cycleLocation); + } + + if (cascade) { + setItemsCascade(prepareCascade(cascade, setParentItems, setItems)); + } +}; + +function analyzeCircularDependencies(obj) { + const keys = []; + const stack = []; + const stackSet = new Set(); + let isCircular = false; + let cycleLocation; + + function detectCircular(obj, key) { + if (obj && typeof obj != 'object') { + return; + } + + if (stackSet.has(obj)) { + cycleLocation = keys.join('.') + '.' + key; + isCircular = true; + + return; + } + + keys.push(key); + stack.push(obj); + stackSet.add(obj); + + for (const k in obj) { + if (Object.prototype.hasOwnProperty.call(obj, k)) { + detectCircular(obj[k], k); + } + } + + keys.pop(); + stack.pop(); + stackSet.delete(obj); + } + + detectCircular(obj, 'obj'); + + return { isCircular, cycleLocation }; +} + +const prepareCascade = (cascade, setParentItems, setItems) => { + let levelOfNesting = 0; + const parentItems = []; + const items = []; + + const prepare = cascade => { + const validCascade = cascade.map(item => { + let validItem = { ...item, levelOfNesting }; + + if (item.children) { + levelOfNesting++; + validItem = { + ...validItem, + children: prepare(item.children), + }; + + parentItems.push({ code: item.code, isOpen: false, levelOfNesting }); + } else { + items.push(validItem); + } + + return validItem; + }); + + levelOfNesting--; + + return validCascade; + }; + + const preparedCascade = prepare(cascade); + + setParentItems(getNestedItems(parentItems, levelOfNesting)); + setItems(items); + + return preparedCascade; +}; + +const getNestedItems = (items, levelOfNesting) => { + const groupParentItems = []; + + for (let i = 0; i <= -levelOfNesting; i++) { + groupParentItems.push(items.filter(({ levelOfNesting }) => levelOfNesting === i)); + } + + return groupParentItems; +}; + +export const openCascade = (state, item) => { + const currentParentItems = [...state]; + const { code, levelOfNesting } = item; + + for (let i = 0; i < currentParentItems[levelOfNesting].length; i++) { + const { code: parentItemCode, isOpen } = currentParentItems[levelOfNesting][i]; + + currentParentItems[levelOfNesting][i].isOpen = parentItemCode === code ? !isOpen : false; + } + + return currentParentItems; +}; + +export const findParentItem = (parentItems, item) => { + const { levelOfNesting, code } = item; + + return parentItems[levelOfNesting].find(item => item.code === code); +}; diff --git a/components/bl-cascade-select/src/index.js b/components/bl-cascade-select/src/index.js new file mode 100644 index 000000000..9db2638ab --- /dev/null +++ b/components/bl-cascade-select/src/index.js @@ -0,0 +1,65 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { openCascade, validate } from './helpers'; +import { Cascade, CollapseButtonIcon } from './subcomponent'; + +const { cn } = BackendlessUI.CSSUtils; + +export default function CascadeSelect({ component, eventHandlers, elRef }) { + const { display, classList, style, cascade, placeholder } = component; + const { onClickItem } = eventHandlers; + + const [itemsCascade, setItemsCascade] = useState(); + const [parentItems, setParentItems] = useState([]); + const [items, setItems] = useState([]); + const [selected, setSelected] = useState({ name: placeholder }); + const [isOpen, setIsOpen] = useState(false); + + useEffect(() => { + component.setCascade(cascade); + }, [cascade]); + + const openCascadeHandler = useCallback(item => { + setParentItems(state => openCascade(state, item)); + }, []); + + const openItemHandler = useCallback(item => { + setSelected(item); + setIsOpen(false); + + onClickItem({ item }); + }, []); + + const onClickInput = () => setIsOpen(state => !state); + + component.getSelected = () => selected; + + component.setCode = code => setSelected(state => items.find(item => item.code === code) || state); + component.getCode = () => selected.code || ''; + + component.getCascade = () => itemsCascade; + component.setCascade = cascade => validate(cascade, setItemsCascade, setParentItems, setItems); + + if (!display) { + return null; + } + + return ( +
+
+ { selected.name } + +
+ +
+ ); +} diff --git a/components/bl-cascade-select/src/subcomponent.js b/components/bl-cascade-select/src/subcomponent.js new file mode 100644 index 000000000..69bdb105a --- /dev/null +++ b/components/bl-cascade-select/src/subcomponent.js @@ -0,0 +1,86 @@ +import { findParentItem } from './helpers'; + +const { cn } = BackendlessUI.CSSUtils; + +export function Cascade(props) { + const { isOpen, itemsCascade, parentItems, levelOfNesting, openItemHandler, openCascadeHandler, selected } = props; + + if (!isOpen) { + return null; + } + + return ( + + ); +} + +export function CascadeItem({ item, parentItems, openItemHandler, openCascadeHandler, selected }) { + const { name, levelOfNesting, children, code } = item; + + if (children) { + const { isOpen } = findParentItem(parentItems, item); + + return ( +
  • +
    openCascadeHandler(item) }> + { name } + +
    + + { isOpen && ( + + ) } +
  • + ); + } + + return ( +
  • openItemHandler(item) }> + { name } +
  • + ); +} + +export function CollapseButtonIcon() { + return ( + + + + ); +} + +function CollapseParentIcon() { + return ( + + + + ); +} diff --git a/components/bl-cascade-select/styles/index.less b/components/bl-cascade-select/styles/index.less new file mode 100644 index 000000000..7ee8e4bf4 --- /dev/null +++ b/components/bl-cascade-select/styles/index.less @@ -0,0 +1,134 @@ +@bl-cascadeSelect-component-themePrimary: @themePrimary; +@bl-cascadeSelect-component-themeBackgroundColor: @appBackgroundColor; +@bl-cascadeSelect-component-themeTextColor: @appTextColor; + +@bl-cascadeSelect-component-collapse-icon-size: 15px; + +@bl-cascadeSelect-component-input-icon-size: 20px; +@bl-cascadeSelect-component-input-min-width: 200px; +@bl-cascadeSelect-component-input-border-color: if(@isLightTheme, rgba(0, 0, 0, 0.12), rgba(255, 255, 255, 0.12)); +@bl-cascadeSelect-component-input-color: #6c757d; + +@bl-cascadeSelect-component-list-z-index: 1; +@bl-cascadeSelect-component-list: if( + @isLightTheme, + @bl-cascadeSelect-component-themeBackgroundColor, + lighten(@bl-cascadeSelect-component-themeBackgroundColor, 5%) +); + +@bl-cascadeSelect-component-item-backround-color: if(@isLightTheme, rgba(0, 0, 0, 0.12), rgba(255, 255, 255, 0.12)); +@bl-cascadeSelect-component-item-color-open: lighten(@bl-cascadeSelect-component-themePrimary, 10%); +@bl-cascadeSelect-component-item-backround-color-open: if( + @isLightTheme, + lighten(@bl-cascadeSelect-component-themePrimary, 40%), + darken(@bl-cascadeSelect-component-themePrimary, 55%) +); + +@bl-cascadeSelect-component-item-border-focus: if( + @isLightTheme, + lighten(@bl-cascadeSelect-component-themePrimary, 30%), + darken(@bl-cascadeSelect-component-themePrimary, 40%) +); + +.bl-cascadeSelect-component { + position: relative; + user-select: none; + + .cascade-select { + &__input { + display: flex; + justify-content: space-between; + align-items: center; + + padding: 10px; + + cursor: pointer; + + border: 1px solid @bl-cascadeSelect-component-input-border-color; + border-radius: 6px; + + background-color: @bl-cascadeSelect-component-themeBackgroundColor; + color: @bl-cascadeSelect-component-input-color; + + transition: border-color 0.3s; + + &:hover { + border-color: @bl-cascadeSelect-component-themePrimary; + } + + &--selected { + color: @bl-cascadeSelect-component-themeTextColor; + } + } + + &__list { + position: absolute; + top: 100%; + z-index: @bl-cascadeSelect-component-list-z-index; + + min-width: 100%; + + margin: 0; + padding: 10px 0; + + list-style: none; + + background: @bl-cascadeSelect-component-list; + border-radius: 6px; + box-shadow: rgba(0, 0, 0, 0.1) 0px 4px 12px; + + animation: 0.1s linear list-open; + } + + @keyframes list-open { + from { + opacity: 0; + transform: scale(0.8); + } + + to { + opacity: 1; + transform: scale(1); + } + } + + &__item { + display: flex; + justify-content: space-between; + padding: 10px 17px; + + transition: background-color, color 0.2s; + + &:hover { + background-color: @bl-cascadeSelect-component-item-backround-color; + } + + &.open { + background-color: @bl-cascadeSelect-component-item-backround-color-open; + color: @bl-cascadeSelect-component-item-color-open; + } + + &:focus { + border: 2px solid @bl-cascadeSelect-component-item-border-focus; + } + } + + &__collapse-input-icon { + width: @bl-cascadeSelect-component-input-icon-size; + height: @bl-cascadeSelect-component-input-icon-size; + + fill: #aaa; + + transform: rotate(-90deg); + } + + &__collapse-icon { + height: @bl-cascadeSelect-component-collapse-icon-size; + width: @bl-cascadeSelect-component-collapse-icon-size; + + fill: #aaa; + + transform: rotate(-180deg); + } + } +}