/**
*
* The Container derives from [Component](kiss.ui.Component.html).
*
* A Container is useful to embed other items.
* Containers can also embed other containers to build complex layouts.
*
* The Container class should not be instanciated directly: it's the base class for the 2 types of containers:
* - [Block](kiss.ui.Block.html), which is a simple "div" container
* - [Panel](kiss.ui.Panel.html), which has a header and is useful to build windows or modals, for example.
*
* It's also possible to bind a Record to a container:
* - in this case, the record will be bound to all the container's fields (text, number, date, checkbox, select...)
* - the fields will be automatically synchronized with the Record, **but only if their id matches a record's property**
* - it's a 2-way binding:
* - if a field of the container is updated, the database will be updated automatically
* - if the database is updated, the fields will react to the change as well
*
* @param {object} config
* @param {boolean} [multiview] - If true, only displays one item at a time
* @param {object[]} config.items - The array of contained items
* @param {Record} [config.record] - Record to bind to the contained fields
* @returns this
*
* @example
* let myRecord = myModel.create({firstName: "Bob", lastName: "Wilson"})
* await myRecord.save()
*
* let myForm = createPanel({
* title: "Binding example",
* width: 300,
* height: 300,
* align: "center",
* verticalAlign: "center",
*
* record: myRecord, // Bind the record to the container's fields
*
* items: [
* {
* id: "firstName", // Updating the field will update the database
* type: "text",
* value: myRecord.firstName
* },
* {
* id: "lastName",
* type: "text",
* value: myRecord.lastName
* }
* ]
* }).render()
*
* await myRecord.update({firstName: "Will"}) // Updating the database will update the field in the UI
*
*/
kiss.ui.Container = class Container extends kiss.ui.Component {
constructor() {
super()
}
/**
* Generates a Container and all its contained items, which are defined from a JSON config
*
* @ignore
* @param {object} config - JSON config
* @returns {HTMLElement}
*/
init(config) {
// Define a vertical or horizontal layout (= "layout" is a shortcut to define flex + flexFlow properties at the same time)
if (config.layout) {
config.display = "flex"
if (config.layout == "vertical") {
config.flexFlow = "column"
} else if (config.layout == "horizontal") {
config.flexFlow = "row"
}
}
// Init the base component
super.init(config)
if (this.type == "block") this.containerId = this.id
else this.containerId = "panel-body-" + this.id // panel and wizardPanel
// Bind a record to the contained fields
if (config.record) config.items.forEach(item => {
if (item.items || kiss.global.fieldTypes.map(type => type.value).includes(item.type)) {
item.record = config.record
}
})
// Insert items into the container (and filters out deleted items)
this.items = []
this.config.items = this.config.items || []
this._insertItems(this.config.items)
// Observe the window resize
this.subscriptions = this.subscriptions || []
// Adjust top and left property if the component is auto-centered
let _this = this
if (this.config.align == "center" && !this.config.left) this.config.left = () => (kiss.screen.current.width - $(_this.id).clientWidth) / 2
if (this.config.verticalAlign == "center" && !this.config.top) this.config.top = () => (kiss.screen.current.height - $(_this.id).clientHeight) / 2
// Add the container to the resize observer & flag the component as a container (containers are unobserved when destroyed)
this.isContainer = true
kiss.screen.getResizeObserver().observe(this)
return this
}
/**
* For multiview containers, show only a specific item of the container, given by index
*
* @param {number} itemIndex
* @param {object|string} [animation] - Optional animation when displaying the item
* @returns this
*/
showItem(itemIndex, animation) {
if (itemIndex > this.items.length) return
if (this.activeItemIndex == itemIndex) return
this.activeItemIndex = itemIndex
for (let i = 0; i < this.items.length; i++) this.items[i].hide()
this.items[itemIndex].show()
if (animation) this.items[itemIndex].setAnimation(animation)
return this
}
/**
* For multiview containers, show only a specific item of the container, given by id
*
* @param {string} id
* @param {object|string} [animation] - Optional animation when displaying the item
* @returns this
*/
showItemById(itemId, animation) {
let itemIndex = this.items.findIndex(item => item.id == itemId)
if (itemIndex != -1) this.showItem(itemIndex, animation)
return this
}
/**
* For multiview containers, show only a specific item of the container, given by CSS class
*
* @param {string} className
* @param {object|string} [animation] - Optional animation when displaying the item
* @returns this
*/
showItemByClass(className, animation) {
let itemIndex = this.items.findIndex(item => Array.from(item.classList).includes(className))
if (itemIndex != -1) this.showItem(itemIndex, animation)
return this
}
/**
* Returns the HTMLElement which is the real container of the component.
* It can differ depending on the component type.
* For example, for a panel, the container is the panel body.
*
* @returns {HTMLElement} The real component's container
*/
getContainer() {
return $(this.containerId)
}
/**
* Get the ids of all the contained items.
* Can be useful to check if a component is contained by this container.
*
* @returns {string[]}
*/
getComponentIds() {
let ids = []
ids.push(this.id)
this.items.forEach(function (item) {
if (item.items) ids.push(item.getComponentIds())
else if (item.id) ids.push(item.id)
})
return ids.flat()
}
/**
* Set container items.
*
* Overwrite existing items if the container is not empty.
*
* @param {object[]} newItems - Array of new items
* @returns this
*/
setItems(newItems) {
const containerElement = this.getContainer()
containerElement.deepDelete(false)
// Reset items and also items configuration
this.items = []
this.config.items = []
// Insert new items and re-render
this._insertItems(newItems)
this.render(this.target, false)
return this
}
/**
* Insert items and manage multiview.
* For multiview containers, only the first item is displayed, other items are hidden.
*
* @private
* @ignore
* @param {*} newItems - Items to insert: can be an HTMLElement, a Component, or a Component's JSON config, or all mixed
*/
_insertItems(newItems) {
if (this.config.multiview) {
this.activeItemIndex = 0
newItems.forEach((item, index) => {
// Insert the first element
if (index == 0) {
this._insertOrAddItem(item)
return
}
// Other items must be hidden.
// An item can be a component JSON config or an HTMLElement
if (item.tagName) {
// It's an HTMLElement, hide it
item.hide()
}
else {
// It's a JSON config: the component is not instanciated yet so we just edit its "hidden" property
item.hidden = true
}
this._insertOrAddItem(item)
})
} else {
newItems.forEach((item) => this._insertOrAddItem(item))
}
}
/**
* Insert or add a child item into the container
*
* @private
* @ignore
* @param {object} item - Item JSON configuration
* @param {number} position - Position at which to insert the new item, for insert operations
* @param {boolean} isNewItem - If true, the item is also added to the initial config object
* @returns Inserted item
*/
_insertOrAddItem(item, position, isNewItem) {
if (!item) return
// Set the DOM insertion node
item.target = this.containerId
// Apply container defaults to the item
const containerDefaults = this.config.defaultConfig
if (containerDefaults) {
for (let defaultProperty in containerDefaults) {
if (!item[defaultProperty]) item[defaultProperty] = containerDefaults[defaultProperty]
}
}
// Build the new item
let newItem = this._createNewItem(item)
if (position != null) {
// Insert
//log("kiss.ui.Container - Inserting...... " + item.id + " to " + this.id)
this.config.items.splice(position, 0, item)
const targetNode = this.items[position]
this.items.splice(position, 0, newItem)
this.insertBefore(newItem, targetNode)
} else {
// Add
//log("kiss.ui.Container - Adding...... " + item.id + " to " + this.id)
if (isNewItem) this.config.items.push(item)
this.items.push(newItem)
}
return newItem
}
/**
* Creates a new item into the container
*
* @private
* @ignore
* @param {object|HTMLElement} item - The item can be a JSON config (= the Component or View has to be built), or an HTMLElement (which can be directly inserted)
*/
_createNewItem(item) {
// If the item has no "render" method, it means it's a component config, and we have to generate the markup
if (!item.render) {
// Generate items according to their type
// If no type is specified, KissJS builds a basic "block" container
const type = item.type
if (type) {
if (["text", "textarea", "number", "date", "password", "lookup", "summary"].includes(type)) {
// Input fields and textarea
return document.createElement("a-field").init(item)
}
else if (type == "view") {
// Build a view
return kiss.views.buildView(item.id, this.containerId)
}
else {
// Other fields and elements
return document.createElement("a-" + type.toLowerCase()).init(item)
}
}
else {
// Block
return document.createElement("a-block").init(item)
}
} else {
// The item has a render method: it means it's already a Component and we inject it "as this" into the container
return item
}
}
/**
* Add a child item into the container
*
* @param {object} item - Item JSON configuration
* @returns this
*/
addItem(item) {
let insertedItem = this._insertOrAddItem(item, null, true)
insertedItem.render()
return this
}
/**
* Insert a child item into the container at a specified position
*
* @param {object} item - Item JSON configuration
* @param {number} position
* @returns this
*/
insertItem(item, position) {
let insertedItem = this._insertOrAddItem(item, position)
insertedItem.render()
return this
}
/**
* Delete an item from the container
*
* @param {string} itemId
* @returns this
*/
deleteItem(itemId) {
this.config.items = this.config.items.filter(item => item.id != itemId)
this.items = this.items.filter(item => item.id != itemId)
$(itemId).deepDelete()
return this
}
/**
* Find all the panels inside a container and expand them recursively
*
* @returns this
*/
expandAll() {
this.items.forEach(function (item) {
if (item.items) {
if (item.type == "panel") item.expand()
item.expandAll()
}
})
return this
}
/**
* Find all the panels inside a container and collapse them recursively
*
* @returns this
*/
collapseAll() {
this.items.forEach(function (item) {
if (item.items) {
if (item.type == "panel") item.collapse()
item.collapseAll()
}
})
return this
}
/**
* Get all the fields found in this container
*
* @returns {Object[]} An array of objects containing the fields
*/
getFields() {
let values = []
Array.from(this.getContainer().children).forEach(function (item) {
if (item.items) {
values.push(item.getFields())
} else {
if (kiss.global.fieldTypes.map(type => type.value).indexOf(item.type) != -1) {
values.push(item)
}
}
})
return values.flat()
}
/**
* Validate all the container's fields and return the result
*
* @returns {boolean} true if all fields have passed the validation
*/
validate() {
let isValid = true
const fields = this.getFields()
fields.forEach(field => {
let fieldElement = this.querySelector("#" + field.id.replaceAll(":", "\\:"))
isValid = isValid && fieldElement.validate()
})
if (!isValid) createNotification(txtTitleCase("#fields incorrect value"))
return isValid
}
/**
* Get fields data found in this container.
*
* This method:
* - finds all the contained items which are fields
* - also explores nested containers, if any
*
* @param {object} config
* @param {boolean} config.useLabels - If true, return data using field labels instead of field ids
* @returns {object}
*
* @example
* let formData = myForm.getData()
* console.log(formData) // {title: "Training ICT", amount: 1234.56, dueDate: "2020-02-20T20:19:15Z"}
*
* formData = myForm.getData({
* useLabels: true
* })
* console.log(formData) // {"Lead name": "Training ICT", "Lead amount": 1234.56, "Due date": "2020-02-20T20:19:15Z"}
*/
getData(config) {
let record = {}
this.getFields().forEach(function (field) {
if (config && config.useLabels) {
let label = field.getLabel()
if (!label) label = field.id
record[label] = field.getValue()
}
else {
record[field.id] = field.getValue()
}
})
return record
}
/**
* Set the value of the fields found in this container, given a data object.
*
* This method:
* - finds all the contained items which are fields
* - also explores nested containers, if any
* - set their value if the field id matches a property of the given data object
*
* @param {object} data
* @param {boolean} [rawUpdate] - If true, the field's value is updated without triggering the "change" event. Default is false.
* @returns this
*
* @example
* const data = {title: "Training ICT", amount: 1234.56, dueDate: "2020-02-20T20:19:15Z"}
* myForm.setData(data)
*/
setData(data, rawUpdate) {
this.getFields().forEach(function (field) {
if (data[field.id] != undefined) field.setValue(data[field.id], rawUpdate)
})
return this
}
/**
* Update layout of the component with its new config parameters.
* It affects:
* - the size properties
* - the position
*
* It can be useful to update the layout for example when:
* - the global window (screen) is resized
* - the parent container is resized
* - a parameter used in the function to compute a width or height has changed
*
* Note: the layout is updated only if the Element is connected to the DOM.
*
* @returns this
*
* @example
* myComponent.updateLayout()
*/
updateLayout() {
if (this.isConnected) {
// Width
this._setWidth()
this._setMinWidth()
this._setMaxWidth()
// Height
this._setHeight()
this._setMinHeight()
this._setMaxHeight()
// Position
this._setTop()
this._setLeft()
}
return this
}
/**
* Set the label position of all the container's fields
*
* @param {string} position - "left" (default) | "top" | "right" | "bottom"
* @returns this
*/
setLabelPosition(position = "left") {
const fields = this.getFields()
if (position == "top" || position == "bottom") {
fields.forEach(item => {
if (item.field) item.field.style.transition = "all 0.1s"
if (item.setFieldWidth) item.setFieldWidth("100%")
if (item.label) {
item.label.style.transition = "all 0.1s"
if (item.setLabelPosition) {
item.setLabelPosition(position)
}
if (item.setLabelWidth) {
item.setLabelWidth("100.00%")
}
}
})
} else if (position == "left" || position == "right") {
fields.forEach(item => {
if (item.field) item.field.style.transition = "all 0.1s"
if (item.setFieldWidth) item.setFieldWidth("50%")
if (item.label) {
item.label.style.transition = "all 0.1s"
if (item.setLabelPosition) {
item.setLabelPosition(position)
}
if (item.setLabelWidth) {
item.setLabelWidth("50.00%")
}
}
})
}
return this
}
/**
* Set the label size of all the container's fields
*
* @param {string} size - "1/4" | "1/3" (default) | "1/2" | "2/3" | "3/4"
* @returns this
*/
setLabelSize(size = "1/3") {
const fields = this.getFields()
let labelSize
let fieldSize
switch (size) {
case "1/4":
labelSize = "25.00%"
fieldSize = "75.00%"
break
case "1/3":
labelSize = "33.33%"
fieldSize = "66.66%"
break
case "1/2":
labelSize = "50.00%"
fieldSize = "50.00%"
break
case "2/3":
labelSize = "66.66%"
fieldSize = "33.33%"
break
case "3/4":
labelSize = "75.00%"
fieldSize = "25.00%"
break
}
fields.forEach(item => {
// Don't modify fields with labels at the top or bottom
if (item.style.flexFlow == "column") return
if (item.field) item.field.style.transition = "all 0.1s"
if (item.setFieldWidth) item.setFieldWidth(fieldSize)
if (item.label) {
item.label.style.transition = "all 0.1s"
if (item.setLabelWidth) item.setLabelWidth(labelSize)
}
})
return this
}
/**
* Set the label alignment of all the container's fields
*
* @param {string} position - "left" (default) | "right"
* @returns this
*/
setLabelAlign(position = "left") {
const fields = this.getFields()
fields.forEach(item => {
if (item.label) {
item.label.style.transition = "all 1s"
item.config.labelAlign = item.label.style.textAlign = position
}
})
return this
}
/**
* Dispatch container's content on multiple columns
*
* @param {number} numberOfColumns
* @returns this
*/
setColumns(numberOfColumns = 1) {
const fields = this.getFields()
const percent = (100 / numberOfColumns).toFixed(2) + "%"
if (numberOfColumns > 1) this.setDisplayMode("block")
else this.setDisplayMode("flex")
fields.forEach(item => {
if (!item.config.deleted) item.style.display = item.config.display = "inline-flex"
if (item.field) item.field.style.transition = "all 1s"
if (item.setWidth) item.setWidth(percent)
})
return this
}
/**
* Set the display mode
*
* @param {string} mode - "flex" | "inline-flex" | "block" | "inline-block"
*/
setDisplayMode(mode = "flex") {
this.config.display = this.container.style.display = mode
}
}
;
Source