Source

common/dataModel.js

/**
 * 
 * Represents a **Model**.
 * 
 * The models are the central elements to structure the data of your applications.
 * The jargon can be slightly different depending on the frameworks, therefore, let's ensure we're talking the same language when it comes to **Model**, **Field**, **Record**, and **Collection**.
 * 
 * **Model**:
 * - a model is a representation of a real-world entity: a user, a car, an invoice, a task, a project, a dream, a quark
 * - a model defines the properties of the entity, but **is not** that entity: a model defines what is a car, but is not a car
 * - a model **must** define the properties of the entity. Ex: firstName, lastName, email
 * - a model **can** have custom methods. Ex: invite(), sendEmail(), ...
 * 
 * **Field**:
 * - a field is a single property of a model. Ex: firstName
 * - a field has an id. Ex: firstName, lastName, bwxJF4yz
 * - a field has a label. Ex: First name, Last name
 * - a field has a type. Ex: text, number, date, select
 * 
 * **Record**:
 * - a record is an entity created thanks to the model definition
 * - a record has data. Ex: firstName: **Bob**, lastName: **Wilson**, email: **bob@wilson.com**
 * - a record inherits the model's methods. Ex: myUser.invite(), myUser.sendMail()
 * 
 * **Collection**:
 * - a collection is an array of multiple records
 * - a collection acts as a proxy for model's data, with specific projection, filtering, sorting, grouping, paging...
 * - it means multiple collections can be bound to the same model
 * - a collection is useful to cache a specific set of records and represent them into the UI
 * - in KissJS, each record of a collection is not just a JSON object, but it's a Record's instance, so you can directly use its methods
 * 
 * <img src="../../resources/doc/KissJS - Data model.png">
 * 
 * In KissJS:
 * - a model needs basic properties like **id**, **name**, **items**
 * - a model needs to know its **singular** and its **plural** name: man / men, child / children, box / boxes, spy / spies 
 * - a model **can** have custom **methods**: sendEmail(), archive(), processTask()
 * - to be able to represent the model visually in KissJS applications, it has an **icon** and a **color** property
 * - to classify the models semantically, KissJS **can** have meta-data like **tags** and **domains**
 * 
 * You define a model passing its names (singular and plural) and its items:
 * ```
 * let spyModel = new kiss.data.Model({
 *  name: "spy",
 *  namePlural: "spies",
 *  items: [
 *      {label: "Spy code number"},
 *      {label: "Spy real name"}
 *  ]
 * })
 * ```
 * 
 * Important note about KissJS conventions:
 *  - we define model **items** instead of **fields**
 *  - fields have a **label** instead of a **name**
 * 
 * This is because KissJS shares a single convention for both the **model** and its direct UI representation: the **form**:
 * - forms have **items** (which can be fields, panels, buttons, images...)
 * - panels contain **fields** or other items (like **buttons**)
 * - fields have a **label**
 * - panels have a **title**
 * 
 * It means you can also define a model like this:
 * ```
 * let spyModel = new kiss.data.Model({
 *  name: "spy",
 *  namePlural: "spies",
 *  items: [
 *      {
 *          type: "panel",
 *          title: "General informations",
 *          items: [
 *              {label: "Spy code number"},
 *              {label: "Spy fake name"}
 *           ]
 *      },
 *      {
 *          type: "panel",
 *          title: "Secret informations",
 *          items: [
 *              {label: "Spy real name"},
 *              {label: "Last mission date"}
 *          ]
 *      }
 *  ]
 * })
 * ```
 * This model will be automatically represented by a form with 2 sections.
 * 
 * If you just give a label to a field, its **id** will be randomly generated.
 * To keep control over your field ids, just add them to the field definition:
 * ```
 * let newModel = new kiss.data.Model({
 *  name: "spy",
 *  namePlural: "spies",
 *  items: [
 *      {id: "code", label: "Spy code number"},
 *      {id: "name", label: "Spy real name"}
 *  ]
 * })
 * ```
 * The default type is "text", but you can of course define the field's type and **validation** rules:
 * - using **validation** property and a regex
 * - using **validationType** property for pre-defined rules (alpha, alphanumeric, email, url, ip)
 * ```
 * let newModel = new kiss.data.Model({
 *  name: "spy",
 *  namePlural: "spies",
 *  items: [
 *      {id: "code", label: "Spy code number", type: "text", validation: /^\d{3}$/}, // Force 3 digits, like "007"
 *      {id: "name", label: "Spy real name", type: "text"},
 *      {id: "missions", label: "Number of missions", type: "number"},
 *      {id: "lastMission", label: "Last mission date", type: "date"},
 *      {id: "secretEmail", label: "Spy secret mailbox", type: "text", validationType: "email"}, // Predefined validation type
 *      {id: "isActive", label: "Is this spy still active?", type: "checkbox"}
 *  ]
 * })
 * ```
 * The field **type** is used to define how the field is rendered into the UI, and its **data type** is implicit.
 * In the previous example, the **checkbox** type has implicitly a **boolean** data type.
 * 
 * Once a model has been defined, you can **create** model instances.
 * In KissJS, a model instance is called a **Record**:
 * 
 * ```
 * let userModel = kiss.app.models.user
 * let userRecord = userModel.create({firstName: "Bob", lastName: "Wilson"})
 * ```
 * 
 * **Fields**
 * 
 * To define a field, the minimum is to have a field **label**:
 * ```
 * {label: "First name"}
 * ```
 * 
 * If you just set a label, KissJS will automatically:
 * - generate an id, using [kiss.tools.shortUid()](kiss.tools.html#.shortUid)
 * - set the type to **text**
 * 
 * If you prefer having control over your field ids, do:
 * ```
 * {id: "firstName", label: "First name"}
 * ```
 * 
 * **Field types**
 * 
 * Because KissJS is primarily a **UI library**, it is designed to make it easier to display the model in the user interface.
 * It's a very opiniated architecture choice.
 * The point here is to keep the focus on the UI and not the underlying structure.
 * Doing that way allows us to just throw a model definition to KissJS and it will automagically generate the UI as a form.
 * 
 * **Basic field types:**
 * 
 * Field type | Data type | Displayed as
 * --- | --- | ---
 * text | text | input field
 * textarea | text | textarea field
 * number | number | number field
 * date | ISO 8601 extended. Example: 2021-04-01T23:20:15Z | date picker
 * checkbox | boolean | checkbox
 * select | string[] | single or multi-value dropdown list
 * rating | number | a rating field with stars (or other icons)
 * slider | number | a slider field
 * icon | string | a single icon field
 * iconPicker | string | an icon picker (using Font Awesome icons at the moment)
 * color | string | a single color field
 * colorPicker | string | a color picker
 * attachment | object | file upload control
 * 
 * **Special field types (non mandatory extensions):**
 * 
 * Field type | Data type | Displayed as
 * --- | --- | ---
 * aiTextarea | text | textarea field with AI suggestions
 * aiImage | object | file upload control with AI image generation
 * directory | string[] | dropdown list to select users and/or groups
 * link | * | list to show one or multiple records and create relationships between tables
 * lookup | * | computed field that lookup a value from a single foreign linked record
 * summary | * | computed field that makes a summary of multiple foreign linked records (a sum, a percentage, a concatenation...)
 * 
 * **Roadmap for news field types:**
 * - automatic number
 * - address (with search / completion and map)
 * - checkbox group (= just another UI for Select field with "multiple: true" option)
 * - radio group (= just another UI for Select field with "multiple: false" option)
 * - image (= just another UI for the attachment field)
 * 
 * **Methods:**
 * 
 * By default, every instanciated record will have default methods:
 * - save
 * - read
 * - update
 * - delete
 * 
 * But you can also define custom methods as well (just ensure their name doesn't conflict with default CRUD methods):
 * ```
 * let contactModel = new kiss.data.Model({
 *  name: "contact",
 *  namePlural: "contacts",
 *  items: [
 *      {id: "name", label: "Contact name", type: "text"},
 *      {id: "email", label: "Email", type: "text", validationType: "email"}
 *  ],
 *  methods: {
 *      sendEmail: function (subject, message) {
 *          yourSmtpService.sendMessage(this.email, subject, message)
 *      }
 *  }
 * })
 * 
 * // Instanciate a new record
 * let newContact = contactModel.create({name: "Bob", email: "bob@wilson.com"})
 * 
 * // Using its custom method
 * newContact.sendEmail("Urgent", "Do that")
 * ```
 * 
 * @param {object} config - model configuration
 * @param {string} [config.mode] - "memory" | "offline" | "online"
 * @param {string} [config.id]
 * @param {string} [config.templateId] - id of the original template model (used to keep track of the source model)
 * @param {string} [config.name] - Name of the model: Lead
 * @param {string} [config.namePlural] - Plural name: Leads
 * @param {object} [config.nameTranslations] - Translations for the model name: {en: "Lead", fr: "Prospect"}
 * @param {object} [config.namePluralTranslations] - Translations for the model plural name: {en: "Leads", fr: "Prospects"}
 * @param {object[]} config.items - Array for field definitions
 * @param {object} config.acl - model's acl (Access Control List)
 * @param {object} config.methods - model's methods
 * @param {object} [config.features] - model's features (workflow, comments, ...)
 * @param {string} [config.icon] - The Font Awesome icon class. Example: "fas fa-check"
 * @param {string} [config.color] - Hexa color. Ex: "#00aaee"
 * @param {string[]} [config.tags] - Ex: ["Leads", "Sales", "CRM", "HRM"]
 * @param {string[]} [config.domains] - Ex: ["banking", "insurance"]
 * 
 * @example
 * // Register a new model
 * let leadModel = new kiss.data.Model({
 *  name: "lead",
 *  namePlural: "leads",
 * 
 *  icon: "fas fa-user",
 *  color: "#00aaee",
 * 
 *  // Define model fiels
 *  items: [
 *      {
 *          label: "Name",
 *          id: "name",
 *          type: "text"
 *      },
 *      {
 *          primary: true, // Primary key field
 *          label: "Email",
 *          id: "email",
 *          type: "text",
 *          validationType: "email"
 *      },
 *      {
 *          label: "Category",
 *          id: "category",
 *          type: "select",
 *          options: [
 *              {
 *                  label: "National"
 *                  value: "NAT",
 *                  color: "#00aaee"
 *              },
 *              {
 *                  label: "International",
 *                  value: "INT",
 *                  color: "#aa00ee"
 *              }
 *          ]
 *      }
 *  ],
 * 
 *  // Define model methods
 *  methods: {
 *      // Get all the pending deals for this lead
 *      getPendingDeals: async function() {
 *          return await kiss.app.collections["deal"].find({
 *              filter: {
 *                  $and: [
 *                      {leadId: this.id},
 *                      {status: "pending"}
 *                  ]
 *              }
 *          })
 *      }
 *  }
 * })
 * 
 * // Your can create a new instance like this
 * let myLead = leadModel.create({name: "Bob Wilson", email: "bob@wilson.com", category: "INT"})
 * 
 * // Creating a new instance happens in memory. You have to save it manually with
 * await myLead.save()
 * 
 * // Updating an instance using default CRUD methods
 * await myLead.update({name: "Bob Wilson Junior"})
 * 
 * // Calling a custom method
 * let pendingDeals = await myLead.getPendingDeals()
 * 
 * // Deleting an instance
 * await myLead.delete()
 * 
 */
kiss.data.Model = class {

    constructor(config) {
        // log(`kiss.data.Model - Defining model <${config.name}>`, 0, config)

        // Define collection's database (memory, offline, online)
        this.mode = config.mode || kiss.db.mode
        this.db = kiss.db[this.mode]

        // Basic model properties
        this.id = config.id || this.namePlural || this.name || uid()
        this.accountId = config.accountId
        this.name = (config.name || this.id).toTitleCase()
        this.namePlural = (config.namePlural || this.id).toTitleCase()
        this.nameTranslations = config.nameTranslations || {}
        this.namePluralTranslations = config.namePluralTranslations || {}
        this.icon = config.icon || "fas fa-th"
        this.color = config.color || "#00aaee"
        this.backgroundColor = config.backgroundColor || "#ffffff"
        this.fullscreen = !!config.fullscreen
        this.align = config.align || "center"
        this.tags = config.tags || []
        this.domains = config.domains || []
        this.methods = config.methods || {}
        this.features = config.features
        this.splitBy = config.splitBy

        // Dynamic models get their acl rules from the generic "dynamicModel" definition
        this.acl = (kiss.tools.isUid(this.id) && kiss.app.models.dynamicModel) ? kiss.app.models.dynamicModel.acl : config.acl
        this.acl = this.acl || {}

        // Model access fields
        this.authenticatedCanCreate = config.authenticatedCanCreate
        this.authenticatedCanRead = config.authenticatedCanRead
        this.authenticatedCanUpdate = config.authenticatedCanUpdate
        this.authenticatedCanDelete = config.authenticatedCanDelete
        this.ownerCanManage = config.ownerCanManage
        this.accessCreate = config.accessCreate
        this.accessRead = config.accessRead
        this.accessUpdate = config.accessUpdate
        this.accessDelete = config.accessDelete
        this.accessManage = config.accessManage

        // Public forms
        this.public = !!config.public
        this.publicFormWidth = config.publicFormWidth
        this.publicFormMargin = config.publicFormMargin
        this.publicFormHeader = config.publicFormHeader
        this.publicEmailTo = config.publicEmailTo
        this.publicFormActionId = config.publicFormActionId

        // Keep the id of the original application it was created in
        if (config.applicationId) this.applicationId = config.applicationId

        // Keep the id of the original template used to generate this model
        if (config.templateId) this.templateId = config.templateId

        // Self-register the Model into the kiss.app object
        kiss.app.models[this.id] = this

        // Init items, fields, computed fields
        this._initItems(config.items)
            ._initFields()
            ._initElements()
            ._initACLFields()
            ._initComputedFields()

        // Init the Record factory
        this._initRecordFactory()

        // Init client methods: master collection, subscriptions
        if (kiss.isClient) {
            this._initMasterCollection()
                ._initSubscriptions()
        }

        // Init server methods: set accepted fields
        if (kiss.isServer) {
            this._initAcceptedFields()
        }

        return this
    }

    // #region CRUD methods

    /**
     * Create a new Record from this model
     * 
     * **This does not save the record automatically**: to save the record into the database, use the **save()** method of the created record.
     * 
     * @param {object} [recordData] - The new record's data
     * @param {boolean} [inherit] - If true, create a blank record then assign recordData to it
     * @returns {object} The new Record object
     * 
     * @example
     * userModel = kiss.app.models["user"]
     * let Bob = userModel.create({firstName: "Bob", lastName: "Wilson"})
     * await Bob.save()
     */
    create(recordData, inherit) {
        return new this.recordFactory(recordData, inherit)
    }

    /**
     * Create a record using field labels as keys
     * 
     * @param {object} record
     * @returns The record
     * 
     * @example
     * userModel = kiss.app.models["user"]
     * let Bob = userModel.createFromLabels({"First name": "Bob", "Last name": "Wilson"})
     * await Bob.save()
     */
    createFromLabels(record) {
        let newRecord = {}

        Object.keys(record).forEach(fieldLabel => {
            const field = this.getFieldByLabel(fieldLabel)
            const value = record[fieldLabel]
            newRecord[field.id] = value
        })
        return this.create(newRecord)
    }

    /**
     * Check the permission (client-side) to perform an action on the model.
     * 
     * @param {string} action - "update" | "delete"
     * @returns {boolean} true if the permission is granted
     */
    async checkPermission(action) {
        const record = kiss.app.collections.model.records.get(this.id)
        const hasPermission = await kiss.acl.check({
            action,
            record
        })

        if (!hasPermission) {
            createNotification(txtTitleCase("#not authorized"))
            return false
        }

        return true
    }    

    // #endregion

    // #region Initialization

    /**
     * Initialize the model's items
     * 
     * - set an id
     * - cast <select> field options to objects if they're given as string
     * 
     * Important:
     * Items contains all the UI components required to render a record as a form.
     * This includes "non-field" informations like panels, buttons, images, html...
     * 
     * @private
     * @ignore
     * @param {object[]} items
     * @returns this
     */
    _initItems(items) {
        if (kiss.isClient) {
            this._initClientItems(items)
        } else {
            this._initServerItems(items)
        }
        return this
    }

    /**
     * Initialize the model's fields
     * 
     * Fields is a subset of items, containing only the fields.
     * 
     * The model fields are deduced from:
     * - the model's items
     * - the model's plugins, which can add custom fields to the model
     * 
     * @private
     * @ignore
     * @returns this
     */
    _initFields() {
        const modelFields = this.getFields()
        const featureFields = this.getFeatureFields()
        const systemFields = this.getSystemFields()
        this.fields = modelFields.concat(featureFields).concat(systemFields)
        
        if (kiss.isClient) {
            this.fields.forEach(field => {
                // Translate system field labels for the client UI
                if (field.label && field.label.startsWith("#")) {
                    field.label = txtTitleCase(field.label)
                }

                // Set the field's renderer for data views (like datatable, calendar, kanban...)
                kiss.fields.setRenderer(this, field)
            })
        }

        return this
    }

    /**
     * Initialize the model's elements
     * 
     * Elements are the non-field items of the model, like:
     * - html
     * - image
     * - button
     * 
     * @private
     * @ignore
     * @param {object[]} items 
     * @returns this
     */
    _initElements(items) {
        this.elements = this.getElements(items)
        return this
    }    

    /**
     * Init the model's ACL fields
     * 
     * This is only used server-side to define the fields that holds ACL entries (users and/or groups).
     * When a user or group is deleted, the ACL entries in the model's fields are updated accordingly.
     * This is done by kiss.directory.cleanupAllUserReferences
     * 
     * @returns this
     */
    _initACLFields() {
        if (kiss.isClient) return this

        this.aclFields = this.fields.filter(field => field.isACL)
        if (this.aclFields.length > 0) kiss.acl.addFields(this, this.aclFields)
        return this
    }
    
    /**
     * - Transform the fields "semantic" formulae into some formulae ready to be evaluated.
     * - Init dependencies between fields
     * - Check if the model has cyclic dependencies
     * 
     * @private
     * @ignore
     * @returns {boolean|string} false if no cyclic dependencies, or the field id that is causing the cyclic dependency
     */
    _initComputedFields() {
        this.computedFields = []
        const fields = this.getActiveFields()

        // Reset all props
        fields.forEach(field => {
            field.dependencies = []
            field.deepDependencies = []

            if (field.computed) {
                field.formulaSourceFields = []
                field.formulaSourceFieldIds = []
            }
        })

        // Init dependencies and formula source fields
        for (let i = 0; i < fields.length; i++) {
            let field = fields[i]

            if (field.computed) {
                // Add this field to the list of computed fields
                this.computedFields.push(field.id)

                // Keep in cache the field dependencies of the formula:
                // - field names
                field.formulaSourceFields = kiss.tools.findTags(field.formula)

                // - field ids
                field.formulaSourceFieldIds = field.formulaSourceFields.map(sourceFieldName => {
                    let sourceField
                    const fieldIndex = Number(sourceFieldName)
                    const isFieldIndex = Number.isInteger(fieldIndex)

                    if (isFieldIndex) {
                        sourceField = fields[fieldIndex]
                    } else {
                        sourceField = this.getFieldByLabel(sourceFieldName)
                    }

                    if (sourceField) {
                        sourceField.dependencies.push(field.id)
                        sourceField.dependencies = sourceField.dependencies.unique()
                        return sourceField.id
                    }
                    else {
                        // log.err("Field used in formula but not found:", sourceFieldName)
                        return sourceFieldName
                    }
                })
                .unique()
            }
        }

        // Define the fields execution order of the formulas, to be able to compute everything in a single pass
        this._initFieldsFormulaExecutionOrder()

        // Init the chain of deep dependencies between fields
        this._initFieldsDeepDependencies()

        // Check if the model has cyclic dependencies
        return this._checkFieldsCyclicDependencies()
    }

    /**
     * Define the fields execution order of the formulas using Khan algorithm
     * 
     * @private
     * @ignore
     * @returns this
     */
    _initFieldsFormulaExecutionOrder() {
        let dependencies = {}
        let inDegree = {}

        for (let field of this.fields) {
            if (field.computed) {
                dependencies[field.id] = new Set(field.dependencies || [])
                inDegree[field.id] = 0
            }
        }

        // Compute in-degree dependencies
        for (let fieldId in dependencies) { 
            for (let dep of dependencies[fieldId]) {
                if (!(dep in inDegree)) {
                    inDegree[dep] = 0
                }
                inDegree[dep]++
            }
        }

        // Topological sort with Kahn algorithm
        let queue = Object.keys(inDegree).filter(fieldId => inDegree[fieldId] === 0)
        let orderedFields = []

        while (queue.length > 0) {
            let fieldId = queue.shift()
            orderedFields.push(fieldId)
            if (dependencies[fieldId]) {
                dependencies[fieldId].forEach(dep => {
                    inDegree[dep]--
                    if (inDegree[dep] === 0) queue.push(dep)
                })
            }
        }
        
        this.orderedComputedFields = orderedFields.filter(fieldId => {
            let field = this.getField(fieldId)
            return field.computed
        })

        return this
    }

    /**
     * Initialize the deep dependencies between the fields,
     * and detect cyclic dependencies, which are forbidden
     * 
     * @private
     * @ignore
     * @returns this
     */
    _initFieldsDeepDependencies() {
        const getDeepDependencies = (fieldId, visited = new Set()) => {
            if (visited.has(fieldId)) return []
            visited.add(fieldId)
            
            let field = this.getField(fieldId)
            if (!field || !field.dependencies) return []
            
            let deepDeps = new Set(field.dependencies)
            
            for (let dep of field.dependencies) {
                let subDeps = getDeepDependencies(dep, visited)
                subDeps.forEach(d => deepDeps.add(d))
            }
            
            return Array.from(deepDeps)
        }
        
        this.fields.forEach(field => field.deepDependencies = getDeepDependencies(field.id))

        return this
    }

    /**
     * Check if the model has computed fields with cyclic dependencies
     * 
     * @returns {boolean|string} false if no cyclic dependencies, or the field id that is causing the cyclic dependency
     */
    _checkFieldsCyclicDependencies() {
        let computedFields = this.fields.filter(field =>
            !field.deleted
            && field.computed
            && field.deepDependencies
            && Array.isArray(field.deepDependencies)
            && field.deepDependencies.length > 0
        )
        
        for (let field of computedFields) {
            if (field.deepDependencies.includes(field.id)) {
                this.hasCyclicDependencies = true

                log.warn(`kiss.data.Model - Cyclic dependency detected in model <${this.name}> (${this.id}) on field <${field.label}> (${field.id})`)
                // log.warn(`kiss.data.Model - Dependencies:`, field.dependencies)
                // log.warn(`kiss.data.Model - Deep dependencies:`, field.deepDependencies)
                // log.warn(`kiss.data.Model - Field formula:`, field.formula)
                // log.warn(`kiss.data.Model - Field formula source fields:`, field.formulaSourceFieldIds)
                // log.err('Label', field.formulaSourceFieldIds.map(id => this.getField(id)?.label))
                return field.id
            }
        }
        
        this.hasCyclicDependencies = false
        return false
    }

    /**
     * Get the accepted fields of the model.
     * This includes model's fields, plus default system fields:
     * - id
     * - createdAt
     * - createdBy
     * - updatedAt
     * - updatedBy
     * - deletedAt
     * - deletedBy
     * - accessRead
     * - accessUpdate
     * - accessDelete
     * 
     * @private
     * @ignore
     * @returns this
     */
    _initAcceptedFields() {
        const defaultAcceptedFields = ["id", "createdAt", "createdBy", "updatedAt", "updatedBy", "deletedAt", "deletedBy", "accessRead", "accessUpdate", "accessDelete", "accessManage"]
        const acceptedFields = this.fields.map(field => field.id)
        this.acceptedFields = defaultAcceptedFields.concat(acceptedFields)
        return this
    }    

    /**
     * Initialize the model's items for the CLIENT
     * - set an id
     * - cast <select> field options to objects if they're given as string
     * 
     * Important:
     * Items contains all the UI components required to render a record as a form.
     * This includes "non-field" informations like panels, buttons, images, html...
     * 
     * @private
     * @ignore
     * @param {object[]} items
     * @returns this
     */
    _initClientItems(items, read, update) {
        if (!items) return []

        const userACL = kiss.session.getACL()
        items = items.filter(item => item != null)

        items.forEach(item => {
            if (!item.id) item.id = kiss.tools.shortUid()

            if (item.items) {
                // Section
                const canRead = kiss.tools.intersects(item.accessRead, userACL) || !item.accessRead
                const canUpdate = kiss.tools.intersects(item.accessUpdate, userACL) || !item.accessUpdate

                item.acl = item.acl || {}

                // item.acl.read = item.acl.hasOwnProperty("read") ? item.acl.read : !!canRead
                // item.acl.update = item.acl.hasOwnProperty("update") ? item.acl.update : !!canUpdate

                item.acl.read = !!canRead
                item.acl.update = !!canUpdate

                this._initClientItems(item.items, canRead, canUpdate)
            } else {
                // Fields or elements
                item.acl = item.acl || {}

                // item.acl.read = item.acl.hasOwnProperty("read") ? item.acl.read : read
                // item.acl.update = item.acl.hasOwnProperty("update") ? item.acl.update : update

                item.acl.read = read
                item.acl.update = update

                if (item.type == "select") {
                    if (!item.options) return
                    item.options = item.options.map(option => {
                        if (typeof option == "object") return option
                        return {
                            value: option
                        }
                    })
                }
            }
        })

        this.items = items
        return this
    }

    /**
     * Initialize the model's items for the SERVER
     * 
     * @private
     * @ignore
     * @param {object[]} items
     * @returns this
     */
    _initServerItems(items) {
        if (!items) return []

        items = items.filter(item => item != null)
        items.forEach(item => {
            if (!item.id) item.id = kiss.tools.shortUid()

            if (item.items) {
                // Section
                this._initServerItems(item.items)
            } else {
                // Fields or widgets
                if (item.type == "select") {
                    if (!item.options) return
                    item.options = item.options.map(option => {
                        if (typeof option == "object") return option
                        return {
                            value: option
                        }
                    })
                }
            }
        })

        this.items = items
        return this
    }

    /**
     * Create a Record class specific to this Model
     * 
     * @private
     * @ignore
     * @returns this
     */
    _initRecordFactory() {
        this.recordFactory = kiss.data.RecordFactory(this.id)
        return this
    }

    /**
     * Create and register a default collection for the model
     * 
     * @private
     * @ignore
     * @returns this
     */
    _initMasterCollection() {
        this.collection = new kiss.data.Collection({
            id: this.id,
            mode: this.mode,
            model: this,
            isMaster: true, // The default model's collection is flagged as the "master" collection
            sort: [{
                [this.getPrimaryKeyField().id]: "asc" // Sort on the primary key field by default
            }]
        })
        return this
    }

    /**
     * Subscribe the model to react to changes
     * 
     * @private
     * @ignore
     * @returns this
     */
    _initSubscriptions() {
        const modelId = this.id.toUpperCase()

        this.subscriptions = [
            // React to model updates
            subscribe("EVT_DB_UPDATE:MODEL", (msgData) => {
                if (this.id == msgData.id) {
                    Object.assign(this, msgData.data)

                    if (msgData.data.hasOwnProperty("items")) {
                        this._initItems(this.items)
                        this._initFields()
                    }
                }
            }),

            subscribe("EVT_DB_UPDATE_BULK", (msgData) => {
                if (msgData.data && msgData.data[0] && msgData.data[0].modelId == "model" && msgData.data[0].recordId == this.id) {
                    Object.assign(this, msgData.data[0].updates)
                }
            }),

            // React to database mutations on records built from this model
            subscribe("EVT_DB_INSERT:" + modelId, this._notifyUser),
            subscribe("EVT_DB_UPDATE:" + modelId, this._notifyUser),
            subscribe("EVT_DB_DELETE:" + modelId, this._notifyUser),
            subscribe("EVT_DB_UPDATE_BULK", (msgData) => {
                let isUpdated = false
                for (let operation of msgData.data) {
                    if (operation.modelId == this.id) {
                        isUpdated = true
                        break
                    }
                }
                if (isUpdated) this._notifyUser(msgData)
            }),

            // Hardly ever happens, so we put this in standby at the moment to limit the number of subscriptions:
            // subscribe("EVT_DB_INSERT_MANY:" + modelId, this._notifyUser),
            // subscribe("EVT_DB_UPDATE_MANY:" + modelId, this._notifyUser),
            // subscribe("EVT_DB_DELETE_MANY:" + modelId, this._notifyUser)
        ]
        return this
    }

    // #endregion

    // #region Fields managements

    /**
     * Create a new field configuration.
     * This method also updates the views that are connected to the model.
     * 
     * @async
     * @param {object} config - New field config
     * @param {string} [sectionId] - Optional section id. If provided, adds the field at the end this section
     * @returns {object} The new field config
     */
    async addField(config, sectionId) {

        // const hasPermission = await this.checkPermission("update")
        // if (!hasPermission) return false

        // Enforce field id
        if (!config.id) config.id = kiss.tools.shortUid()

        // If the model doesn't have any section, adds the field at the end
        if (!this.hasSections()) {
            this.items.push(config)
        } else {
            if (!sectionId) {
                // No specific section is given to add the field: it is added at the end of the last section
                const lastSection = this.items[this.items.length - 1]
                lastSection.items.push(config)
            } else {
                // A specific section is given: we append the field to it
                this.items
                    .find(section => section.id == sectionId)
                    .items
                    .push(config)
            }
        }
        
        
        this._initFields()
        this._initElements()
        this._initComputedFields()
        await this._saveItems()
        
        // For offline apps, re-compute relationships locally
        if (kiss.session.isOffline()) {
            this._defineRelationships()
        }

        // Computed fields need to be computed on every record
        if (config.computed) {
            await this.updateFieldFormula(config.id)
        }
        
        // Get all the views that display this model and update them
        await this.syncViewsWithModelFields()

        // Reset the context
        kiss.context.addFieldToSectionId = null

        return config
    }  

    /**
     * Get a field by id
     * 
     * Note: if the field is not found, the method tries to find the field by its label
     * 
     * @param {string} fieldId
     * @returns {object} The field definition
     * 
     * @example
     * let myField = myModel.getField("xD12z4ml00z")
     * 
     * // Returns...
     * {
     *      id: "yearlyIncome",
     *      label: "Yearly income",
     *      type: "number",
     *      precision: 2,
     *      formula: "{{Monthly income}} * 12",
     * }
    */
    getField(fieldId) {
        if (!fieldId) return null
        const field = this.fields.find(field => field.id == fieldId)
        if (field) return field
        return this.getFieldByLabel(fieldId)
    }

    /**
     * Get a field id by its label
     * 
     * @param {string} fieldLabel
     * @returns {*} The field id or false if not found
     * 
     * @example
     * let fieldId = myModel.getFieldId("Project name") // "xD12z4ml00z"
     */
    getFieldId(fieldLabel) {
        const field = this.getFieldByLabel(fieldLabel)
        return field ? field.id : null
    }

    /**
     * Get the first field matching a label.
     * 
     * Note:
     * - if the field label is not found, it defaults to searching the field id
     * - deleted field are not taken into consideration
     * 
     * @param {string} fieldLabel
     * @returns {object} The field definition
     * 
     * @example
     * let myField = myModel.getFieldByLabel("Project name")
     */
    getFieldByLabel(fieldLabel) {
        const fields = this.fields.filter(field => !field.deleted)

        let field = fields.find(field => field.label && field.label.toLowerCase() == fieldLabel.toLowerCase())
        if (field) return field

        field = fields.find(field => field.id.toLowerCase() == fieldLabel.toLowerCase())
        return field
    }
    
    /**
     * Get a field type
     * 
     * Specific field types like "lookup" and "summary" have to be converted to the type of the fields they point to.
     * For example, if a "lookup" field is getting the value of a "number" field, the "real" field type is "number"
     * 
     * Warning:
     * - this method doesn't return the field data type.
     * - field type and field data type are 2 different things: a field which type is "checkbox" has a "boolean" data type.
     * 
     * @private
     * @ignore
     * @param {object} field - The field definition
     * @returns {string} The field type: "text", "number", "date", "checkbox", "select"...
     */
    getFieldType(field) {
        if (field.type == "lookup") {
            return field.lookup.type
        } else if (field.type == "summary") {
            return field.summary.type
        } else {
            return field.type || "text"
        }
    }

    /**
     * Get the label of a field
     * 
     * A field label can be defined in 4 different ways:
     * - field.label is not defined: we return the field id instead
     * - field.label is defined and starts with "#": the label must be translated using the localized **static** texts
     * - field.labelTranslations is defined: the label must be translated using the localized **dynamic** texts
     * - field.label is defined: we return the field label as is
     * 
     * @param {object} field - The field definition
     * @returns {string} The field label
     */
    getFieldLabel(field) {
        let fieldLabel
        if (!field.label) {
            fieldLabel = txtTitleCase(field.id)
        }
        else if (field.label.startsWith("#")) {
            fieldLabel = txtTitleCase(field.label)
        }
        else if(field.label && field.labelTranslations) {
            fieldLabel = field.labelTranslations[kiss.language.currentDynamic]
            fieldLabel = (fieldLabel) ? fieldLabel.toTitleCase() : field.label.toTitleCase()
        }
        else {
            fieldLabel = field.label.toTitleCase()
        }

        return fieldLabel  
    }    

    /**
     * Get the primary field of this model
     * 
     * @returns {object} The primary field, or the model's 1st field if it wasn't found
     */
    getPrimaryKeyField() {
        const fields = this.fields
        const primaryKeyField = fields.find(field => field.primary == true)
        if (primaryKeyField) return primaryKeyField
        return fields[0]
    }

    /**
     * Search inside a model which field links to a foreign model
     * 
     * @param {string} foreignModelId - Foreign model id
     * @returns {object} The <link> field that links to the foreign model
     */
    getLinkField(foreignModelId) {
        const fields = this.fields
        for (let field of fields) {
            if (field.type == "link") {
                if (field.link.modelId == foreignModelId) return field
            }
        }
        return null
    }

    /**
     * Update a field configuration.
     * This method also updates the views that are connected to the model.
     * 
     * @async
     * @param {string} fieldId 
     * @param {object} config - New field config
     * @param {boolean} shouldUpdateFormula - If true, re-compute the field value on every record of the collection
     * @returns {boolean} true in case of success
     */
    async updateField(fieldId, config, shouldUpdateFormula) {

        // const hasPermission = await this.checkPermission("update")
        // if (!hasPermission) return false

        // Update the model's field
        this._updateItemInTree(this, fieldId, config)
        
        this._initFields()
        this._initElements()
        this._initComputedFields()
        await this._saveItems()
        
        // For offline apps, re-compute relationships locally
        if (kiss.session.isOffline()) {
            this._defineRelationships()
        }
        
        // Computed field need to update their values (100% server-side process)
        if (shouldUpdateFormula) {
            await this.updateFieldFormula(fieldId)
        }
        
        // Get all the views that display this model and update them
        await this.syncViewsWithModelFields()

        return true
    }

    /**
     * Recompute the computed field value on every record of the collection.
     * 
     * @async
     */
    async updateFieldFormula() {
        if (kiss.session.isOffline()) {
            await kiss.data.relations.updateAllDeep(this.id)
        } else {
            await kiss.ajax.request({
                showLoading: true,
                url: "/updateAllDeep",
                method: "post",
                body: JSON.stringify({
                    modelId: this.id
                })
            })
        }
    }

    /**
     * Check if the field labels used in a formula are still valid.
     * If not, returns the list of invalid field labels.
     * 
     * @private
     * @ignore
     * @param {string} fieldId 
     * @returns {string[]} Array with the wrong field labels (empty if OK)
     */
    _checkFormula(formula) {
        const tags = kiss.tools.findTags(formula)
        let errorFields = []

        tags.forEach(fieldLabel => {
            const field = this.getFieldByLabel(fieldLabel)
            if (kiss.tools.isNumber(fieldLabel)) return
            if (!field) errorFields.push(fieldLabel)
        })

        return errorFields
    }

    /**
     * Check if a field formula creates cyclic dependencies
     * 
     * @private
     * @ignore
     * @param {string} label - Field label
     * @param {string} formula - Field formula
     * @returns {string[]} Array with the field labels causing cyclic dependencies (empty if OK)
     */
    _checkFormulaCyclicDependencies(label, formula) {
        let errorFields = []
        const tags = kiss.tools.findTags(formula)

        tags.forEach(tag => {
            const tagField = this.getFieldByLabel(tag)
            if (!tagField) return
            if (!tagField.formula) return

            let fieldFormula = tagField.formula
            if (tag == label) fieldFormula = formula

            const tagFieldTags = kiss.tools.findTags(fieldFormula)
            if (tagFieldTags.includes(label)) errorFields.push(tagField.label)
        })
        
        return errorFields
    }    

    /**
     * Delete a field configuration and update all the views that are connected to this model.
     * A primary field can't (and must not) be deleted.
     * 
     * @async
     * @param {string} fieldId
     * @returns {boolean} false if the field couldn't be deleted (primary field)
     */
    async deleteField(fieldId) {
        let field = this.getField(fieldId)
        if (!field) field = this.getElement(fieldId)
        
        if (!field) {
            log("kiss.data.Model - deleteField: could not find the item to delete", 3)
            return false
        }

        if (field.primary == true) {
            log("kiss.data.Model - deleteField: could not delete primary field", 3)
            return false
        }

        if (field.type == "html" || field.type == "image" || field.type == "button") {
            return this.deleteElement(fieldId)
        }

        log(`kiss.data.Model - deleteField: ${fieldId} / ${field.label}`)

        field.deleted = true
        await this.updateField(fieldId, field)
        this._initFields()
        return true
    }

    /**
     * Get the model's fields
     * 
     * In KissJS, the model can be directly defined by a complex form with multiple sections and sub items.
     * This method explores the tree and returns only the items which are fields.
     * 
     * Important: deleted fields are also returned, with a flag deleted = true
     * 
     * @returns {object[]} Array of field definitions
     */
    getFields(containerItems) {
        const fieldTypes = kiss.global.fieldTypes.map(type => type.value)
        let fields = []
        let items = containerItems || this.items || []

        items = items.filter(item => item != null)
        items.forEach(item => {
            if ((fieldTypes.indexOf(item.type) != -1) || (item.dataType != null)) {
                fields.push(item)
            } else {
                if (item.items) {
                    fields.push(this.getFields(item.items))
                }
            }
        })

        return fields.flat()
    }

    /**
     * Initialize the system fields
     * 
     * @returns {object[]} Array of system fields
     */
    getSystemFields() {
        return [{
                id: "createdAt",
                label: "#createdAt",
                type: "date",
                dataType: Date,
                isSystem: true,
                readOnly: true,
                hidden: true,
                acl: {
                    update: false
                }
            },
            {
                id: "createdBy",
                label: "#createdBy",
                type: "directory",
                dataType: Date,
                isSystem: true,
                readOnly: true,
                hidden: true,
                acl: {
                    update: false
                }
            },
            {
                id: "updatedAt",
                label: "#updatedAt",
                type: "date",
                dataType: Date,
                isSystem: true,
                readOnly: true,
                hidden: true,
                acl: {
                    update: false
                }
            },
            {
                id: "updatedBy",
                label: "#updatedBy",
                type: "directory",
                dataType: Date,
                isSystem: true,
                readOnly: true,
                hidden: true,
                acl: {
                    update: false
                }
            },
            {
                id: "deletedAt",
                label: "#deletedAt",
                type: "date",
                dataType: Date,
                isSystem: true,
                readOnly: true,
                hidden: true,
                acl: {
                    update: false
                }
            },
            {
                id: "deletedBy",
                label: "#deletedBy",
                type: "directory",
                dataType: Date,
                isSystem: true,
                readOnly: true,
                hidden: true,
                acl: {
                    update: false
                }
            }
        ]
    }

    /**
     * Get the fields brought by the model's active plugins
     * 
     * @returns {object[]} Array of field definitions
     */
    getFeatureFields() {
        if (kiss.isServer) return []

        let featureFields = []
        if (this.features) {
            Object.keys(this.features)
                .filter(featureId => this.features[featureId].active)
                .forEach(featureId => {
                    const plugin = kiss.plugins.get(featureId)
                    if (!plugin) return

                    const texts = plugin.texts || {}

                    if (plugin && plugin.fields) {
                        plugin.fields.forEach(field => {
                            field.isFromPlugin = true
                            field.pluginId = featureId
                            field.label = txtTitleCase(field.label, texts)
                            featureFields.push(field)
                        })
                    }
                })
        }
        return featureFields
    }

    /**
     * Returns the model's fields using the datatable format
     * 
     * @ignore
     * @returns {object[]} Array of columns
     */
    getFieldsAsColumns() {
        let columns = this.fields
            .filter(field => field.label)
            .map(field => {
                if (field.deleted) return null

                let columnConfig = {
                    id: field.id,
                    type: this.getFieldType(field),
                    title: this.getFieldLabel(field)
                }

                // Flag columns coming from plugins
                if (field.isFromPlugin) {
                    columnConfig.isFromPlugin = true
                    columnConfig.pluginId = field.pluginId
                    columnConfig.title = txtTitleCase(field.label)
                }

                // Flag system columns
                if (field.isSystem) {
                    columnConfig.isSystem = true
                    columnConfig.title = txtTitleCase(field.label)
                    columnConfig.hidden = (field.hidden !== false)
                }

                return columnConfig
            })
            .filter(column => column != null)

        return columns
    }

    /**
     * Get fields as a list of options for a Select field
     * 
     * @param {string[]} types - Types of fields to return
     * @returns {object[]} Array of options
     * 
     * @example
     * let options = myModel.getFieldsAsOptions(["text", "number"])
     */
    getFieldsAsOptions(types) {
        const isDynamicModel = kiss.tools.isUid(this.id)
        if (!Array.isArray(types)) types = [types]
        if (types.length == 0) return []

        const fields = this.getFieldsByType(types)
        if (fields.length == 0) return []

        return fields.filter(field => !field.deleted).map(field => {
            return {
                value: field.id,
                label: (isDynamicModel && !field.isSystem) ? field.label.toTitleCase() : txtTitleCase(field.label),
                isFromPlugin: !!field.isFromPlugin
            }
        })
    }

    /**
     * Get visible fields (= non deleted)
     * 
     * @returns {object[]} Array of field definitions
     */
    getActiveFields() {
        return this.fields.filter(field => !field.deleted)
    }

    /**
     * Get only the fields of specific types
     * 
     * @param {string|string[]} types -Types of fields to return. When a field is a lookup, its type is the lookup type.
     * @returns {object[]} The fields of the required type, or []
     */
    getFieldsByType(types) {
        const fields = this.fields
        if (!Array.isArray(types)) types = [types]
        
        if (types.length > 0) {
            return fields.filter(field => {
                if (field.type == "lookup") {
                    return types.includes(field.lookup.type)
                }
                else if (field.type == "summary") {
                    return types.includes(field.summary.type)
                }
                else {
                    return types.includes(field.type)
                }
            })
        }
    }

    /**
     * Get the fields which can be used for sorting
     * 
     * @returns {object[]} The list of sortable fields
     */
    getSortableFields() {
        return this.fields.filter(field => field.type != "password" && field.type != "link" && field.type != "attachment" && field.label && field.deleted != true && field.multiple != true)
    }

    /**
     * Get the fields which can be used for grouping
     * 
     * @returns {object[]} The list of groupable fields
     */
    getGroupableFields() {
        return this.fields.filter(field => field.type != "password" && field.type != "link" && field.type != "attachment" && field.label && field.deleted != true && field.multiple != true && !field.isSystem)
        // return this.fields.filter(field => field.type != "password" && field.type != "link" && field.type != "attachment" && field.label && field.deleted != true)
    }

    /**
     * Get the fields which can be used for filtering
     * 
     * @returns {object[]} The list of filterable fields
     */
    getFilterableFields() {
        return this.fields.filter(field => field.type != "link" && field.label && field.deleted != true)
    }

    /**
     * Get the fields which can be used inside a formula
     * @returns {object[]} The list of formula fields
     */
    getFormulaFields() {
        return this.fields.filter(field => {
            return field.type != "link" &&
                field.type != "attachment" &&
                field.label &&
                field.deleted != true &&
                field.isSystem != true &&
                field.isFromPlugin != true
        })
    }

    /**
     * Get the fields which can be updated by a script.
     * 
     * @returns {object[]} The list of fields
     */
    getUpdatableFields() {
        return this.fields.filter(field => {
            return field.deleted != true &&
                field.isSystem != true &&
                // field.isFromPlugin != true &&
                field.computed != true &&
                !field.sourceFor &&
                field.type != "lookup" &&
                field.type != "summary" &&
                field.type != "attachment" &&
                field.type != "password" &&
                field.type != "selectViewColumn" &&
                field.type != "selectViewColumns" &&
                field.type != "link" &&
                field.type != "aiImage"
        })
    }

    // #endregion

    // #region Elements management

    /**
     * Get an element by id
     * 
     * Elements are the non-field items of the model, like:
     * - html
     * - image
     * - button
     * 
     * @param {string} elementId
     * @returns {object} The element definition
     * 
     * @example
     * let myHtmlElement = myModel.getElement("xD12z4ml00z")
     * 
     * // Returns...
     * {
     *      id: "yearlyIncome",
     *      type: "html",
     *      html: "<p>Yearly income is calculated by multiplying the monthly income by 12</p>",
     * }
     */
    getElement(elementId) {
        return this.elements.find(element => element.id == elementId)
    }

    /**
     * Get the model's elements
     * 
     * In KissJS, the model can be directly defined by a complex form with multiple sections and sub items.
     * This method explores the tree and returns only the items which are "elements", like:
     * - html
     * - button
     * - image
     * 
     * @returns {object[]} Array of element definitions
     */
    getElements(containerItems) {
        const elementTypes = ["html", "button", "image"]
        let elements = []
        let items = containerItems || this.items || []

        items = items.filter(item => item != null)
        items.forEach(item => {
            if ((elementTypes.indexOf(item.type) != -1) || (item.dataType != null)) {
                elements.push(item)
            } else {
                if (item.items) {
                    elements.push(this.getElements(item.items))
                }
            }
        })

        return elements.flat()
    }

    /**
     * Delete an element configuration
     * 
     * @param {string} elementId 
     */
    async deleteElement(elementId) {
        let element = this.getElement(elementId)
        if (!element) return false

        // log(`kiss.data.Model - deleteElement: ${elementId} / ${element.type}`)

        this._deleteItemFromTree(this, elementId, element)
        
        await this._saveItems()

        this._initElements()
        return true
    }

    // #endregion

    // #region Form and Sections management

    /**
     * Get a section by id
     * 
     * Note: if the section is not found, the method tries to find the section by its title
     * 
     * @param {string} fieldId
     * @returns {object} The section definition
     * 
     * @example
     * let mySection = myModel.getSection("General informations")
     * 
     * // Returns...
     * {
     *      id: "aE7x450",
     *      title: "General informations",
     *      items: [
     *          // ... Section items
     *      ]
     * }
     */
    getSection(sectionId) {
        let section = this.items.find(section => section.id == sectionId)
        if (section) return section
        return this.getSectionByTitle(sectionId)
    }

    /**
     * Get the first section matching a title.
     * 
     * Note: if the section title is not found, it defaults to searching the section id
     * 
     * @param {string} sectionTitle
     * @returns {object} The section definition
     * 
     * @example
     * let mySection = myModel.getSectionByTitle("General informations")
     */
    getSectionByTitle(sectionTitle) {
        let section = this.items.find(section => section.title && section.title.toLowerCase() == sectionTitle.toLowerCase())
        if (section) return section

        section = this.items.find(section => section.id.toLowerCase() == sectionTitle.toLowerCase())
        return section
    }

    /**
     * Check if the model has sections.
     * 
     * @returns {boolean}
     */
    hasSections() {
        const modelSections = this.items.filter(item => item.type == "panel")
        return (modelSections.length > 0)
    }

    /**
     * Get the model's sections
     * 
     * In KissJS, the model can be directly defined by a complex form with multiple sections and sub items.
     * This method explores the tree and returns only the items which are sections.
     * 
     * @returns {object[]} Array of sections definitions
     */
    getSections() {
        let sections = []
        let items = this.items || []

        items = items.filter(item => item != null)
        items.forEach(item => {
            if (item.items) sections.push(item)
        })

        return sections
    }    

    /**
     * Creates the first form section, at the top of the form
     * 
     * @ignore
     * @param {object} config - Section configuration
     * @returns {string} The new section id
     */
    async createFirstSection(config) {
        const newSectionId = kiss.tools.shortUid()
        const newSection = {
            id: newSectionId,
            type: "panel",
            icon: config.icon || "far fa-file-alt",
            title: config.title || "Section",
            collapsible: true,
            collapsed: config.collapsed,
            colored: config.colored,
            acl: config.acl,
            items: this.items
        }
        this.items = [newSection]
        await this._saveItems()

        // Update items & fields & elements
        this._initItems(this.items)
        this._initFields()
        this._initElements()

        return newSectionId
    }

    /**
     * Creates a new section just before the item passed as a parameter 
     * 
     * @ignore
     * @param {object} config - Section configuration
     * @param {string} breakItemId - Item id where to break the form
     * @returns {string|boolean} The new section id, or false if it did not succeed
     */
    async createSection(config, breakItemId) {
        const sections = this.items

        // Explore the model to find the section and break index
        let breakIndex
        let sectionIndex

        sections.forEach((section, index) => {
            const itemIndex = section.items.findIndex(item => item.id == breakItemId)
            if (itemIndex != -1) {
                breakIndex = itemIndex
                sectionIndex = index
            }
        })

        // Can't insert a section immediately after an existing section!
        if (breakIndex == 0) return false

        // Rebuild a new section after the break index
        const fieldsInPreviousSection = sections[sectionIndex].items.slice(0, breakIndex)
        const fieldsInNewSection = sections[sectionIndex].items.slice(breakIndex)
        sections[sectionIndex].items = fieldsInPreviousSection

        const newSectionId = kiss.tools.shortUid()
        const newSection = {
            id: newSectionId,
            type: "panel",
            icon: config.icon || "far fa-file-alt",
            title: config.title || "Section",
            collapsible: true,
            collapsed: config.collapsed,
            colored: config.colored,
            accessRead: config.accessRead,
            accessUpdate: config.accessUpdate,
            acl: config.acl,
            items: fieldsInNewSection
        }

        sections.splice(sectionIndex + 1, 0, newSection)

        this.items = sections
        await this._saveItems()

        // Update items & fields & elements
        this._initItems(this.items)
        this._initFields()
        this._initElements()

        return newSectionId
    }

    /**
     * Check if an item is the first in its section.
     * (used to perform checks in the form builder)
     * 
     * @param {string} itemId
     * @returns {boolean}
     */
    isFirstItemInSection(itemId) {
        if (!this.hasSections()) return false

        let breakIndex
        const sections = this.items

        sections.forEach((section, index) => {
            const itemIndex = section.items.findIndex(item => item.id == itemId)
            if (itemIndex != -1) {
                breakIndex = itemIndex
            }
        })

        // Can't insert a section immediately after an existing section!
        if (breakIndex === 0) return true
        return false
    }

    /**
     * Update a section configuration
     * 
     * @async
     * @param {string} sectionId
     * @param {object} newSectionConfig 
     */
    async updateSection(sectionId, newSectionConfig) {
        // Update the section in the model
        this._updateItemInTree(this, sectionId, newSectionConfig)

        // Update the model's record which is stored in db
        await this._saveItems()

        // Update items & fields & elements
        this._initItems(this.items)
        this._initFields()
        this._initElements()
    }

    /**
     * Delete a section, and move its items into the previous section.
     * The method doesn't allow to delete the 1st section.
     * 
     * @ignore
     * @param {string} sectionId 
     * @returns {boolean} false if the section id could not be found
     */
    async deleteSection(sectionId) {
        for (let i = 0; i < this.items.length; i++) {
            const section = this.items[i]

            if (section.id == sectionId) {
                const fieldsToMove = section.items
                let previousSection = this.items[i - 1]
                let nextSection = this.items[i + 1]

                if (i == 0) {
                    if (nextSection) {
                        // Move items to next section, if there is one
                        nextSection.items.splice(0, 0, ...fieldsToMove)
                        this.items.splice(i, 1)
                    } else {
                        // Otherwise, there are no sections anymore
                        this.items = fieldsToMove
                    }
                } else {
                    // Move items to previous section
                    previousSection.items.splice(previousSection.items.length, 0, ...fieldsToMove)
                    this.items.splice(i, 1)
                }

                await this._saveItems()

                // Update items & fields & elements
                this._initItems(this.items)
                this._initFields()
                this._initElements()

                return true
            }
        }
    }

    /**
     * Move a section from a position to another (move "up" or "down")
     * 
     * @ignore
     * @param {string} sectionId 
     * @param {string} direction - "up" | "down"
     * @returns {boolean} false if the section id could not be found
     */
    async moveSection(sectionId, direction) {
        const fromIndex = this.items.findIndex(section => section.id == sectionId)
        if (fromIndex == -1) return false

        const toIndex = (direction == "down") ? fromIndex + 1 : fromIndex - 1

        // Switch section positions
        const tempSection = this.items[fromIndex]
        this.items[fromIndex] = this.items[toIndex]
        this.items[toIndex] = tempSection

        await this._saveItems()
        return true
    }

    /**
     * Save the model's items into the database.
     * 
     * @private
     * @ignore
     */
    async _saveItems() {
        // Prevent from saving an empty form
        if (!Array.isArray(this.items)) return
        if (this.items.length == 0) return

        const modelRecord = kiss.app.collections.model.getRecord(this.id)
        await modelRecord.update({
            items: this.items
        })
    }

    /**
     * Export the model definition as JSON.
     * 
     * This is used to import/export application templates.
     */
    exportAsJSON() {
        let items
        const hasSections = (this.items[0].items) ? true : false

        if (hasSections) {
            items = this.items.map(section => {
                section.items = section.items.filter(item => !item.deleted).map(this._sanitizeFieldProperties)

                // Neutralize section ACL
                section.accessRead = ["*"]
                section.accessUpdate = ["*"]
                return section
            })
        }
        else {
            items = this.items.filter(item => !item.deleted).map(this._sanitizeFieldProperties)
        }

        return {
            id: this.id,
            name: this.name,
            namePlural: this.namePlural,
            language: kiss.language.current,
            icon: this.icon,
            color: this.color,
            fullscreen: !!this.fullscreen,
            items,
            // items: this.items.map(section => {
            //     section.items = section.items.filter(item => !item.deleted).map(this._sanitizeFieldProperties)

            //     // Neutralize ACL
            //     section.accessRead = ["*"]
            //     section.accessUpdate = ["*"]
            //     return section
            // }),
            features: this.features,

            // Neutralize ACL
            authenticatedCanCreate: true,
            authenticatedCanRead: true,
            authenticatedCanUpdate: true,
            authenticatedCanDelete: true,
            ownerCanManage: true,
            accessCreate: [],
            accessRead: [],
            accessUpdate: [],
            accessDelete: [],
            accessManage: []
        }
    }

    /**
     * Sanitize field properties before exporting Model (as JSON)
     * 
     * @private
     * @ignore
     * @param {object} field - Field JSON definition
     * @returns {object} The sanitized field definition
     */
    _sanitizeFieldProperties(field) {
        delete field.acl

        // Reset field formulas
        delete field.formulaSourceFields
        delete field.formulaSourceFieldIds

        if ((field.type == "lookup") || (field.type == "summary")) delete field.formula

        // Reset relations
        delete field.sourceFor
        if (field.type == "link") delete field.link.model

        // Reset DOM specific properties
        delete field.target

        // Sort props alphabetically
        let exportedField = {}
        Object.keys(field)
            .sortAlpha()
            .forEach(property => exportedField[property] = field[property])

        return exportedField
    }

    /**
     * Update an item in the nested model's config
     * 
     * @private
     * @ignore
     * @param {object} node - Root node to explore
     * @param {string} itemId - Id of the field to update
     * @param {object} config - New field config
     */
    _updateItemInTree(node, itemId, config) {
        if (node.id == itemId) {
            Object.assign(node, config)
        } else if (node.items) {
            for (let i = 0; i < node.items.length; i++) {
                this._updateItemInTree(node.items[i], itemId, config)
            }
        }
    }

    /**
     * Delete an item in the nested model's config
     * 
     * @private
     * @ignore
     * @param {object} node - Root node to explore
     * @param {string} itemId - Id of the field to delete
     * @returns {boolean} - Returns true if the item was deleted, otherwise false
     */
    _deleteItemFromTree(node, itemId) {
        if (node.items) {
            for (let i = 0; i < node.items.length; i++) {
                if (node.items[i].id === itemId) {
                    node.items.splice(i, 1)
                    return true
                } else if (this._deleteItemFromTree(node.items[i], itemId)) {
                    return true
                }
            }
        }
        return false
    }    

    // #endregion

    // #region Manage the model's views

    /**
     * Get all the views that are connected to this model
     * 
     * @returns {Record[]} Array of records containing the view configurations
     */
    getViews() {
        const viewCollection = kiss.app.collections.view
        if (!viewCollection) return

        const viewRecords = viewCollection.records
        const modelViews = viewRecords.filter(view => view.modelId == this.id)
        return modelViews
    }

    /**
     * Get the views a user is allowed to access.
     * 
     * A user can see a view if:
     * - he is the account owner
     * - he is one of the account managers
     * - he is the view creator
     * - the view read access is allowed to all authenticated users
     * - he is mentionned in the field "accessRead"
     * 
     * @param {string} userId 
     * @returns {object[]} The list of authorized views
     */
    getViewsByUser(userId) {
        const views = this.getViews()
        const userACL = kiss.directory.getUserACL(userId)

        // Account owner and account managers always sees all the views
        if (kiss.session.isOwner) return views
        if (kiss.session.isAccountManager()) return views

        return views.filter(view => {
            return !!view.authenticatedCanRead == true || kiss.tools.intersects(view.accessRead, userACL) || view.createdBy == userId
        })
    }

    /**
     * Get all the views that display this model and sync their fields
     * 
     * @ignore
     */
    async syncViewsWithModelFields() {
        const viewsToUpdate = this.getViews()
        for (let viewRecord of viewsToUpdate) {
            await viewRecord.syncWithModelFields()
        }
    }

    // #endregion

    // #region Relationships management

    /**
     * Generate links between two models (= tables)
     * 
     * This is used to automatically create links between records when they have a matching value.
     * Example:
     * - you have a flat list of companies with a field "name"
     * - you have a flat list of contacts with a field "company name"
     * - you want to link the contacts to the companies which name matches the contact's company name
     * 
     * Note: it does **not** save the links into the database.
     * It's up to the caller function to decide what to do with the links (create them or cancel)
     * 
     * @param {object} config
     * @param {object} config.foreignModelId
     * @param {object} config.sourceLinkFieldId
     * @param {object} config.sourceFieldId
     * @param {object} config.foreignLinkFieldId
     * @param {object} config.foreignFieldId
     * @returns {object[]} link records
     */
    async generateLinksToModel({
        foreignModelId,
        sourceLinkFieldId,
        sourceFieldId,
        foreignLinkFieldId,
        foreignFieldId
    }) {
        const localCollection = this.collection
        const foreignCollection = kiss.app.collections[foreignModelId]
        const localRecords = await localCollection.find({}, true)
        const foreignRecords = await foreignCollection.find({}, true)
        let links = []

        localRecords.forEach(localRecord => {
            foreignRecords.forEach(foreignRecord => {
                let localValue = localRecord[sourceFieldId]
                if (localValue == foreignRecord[foreignFieldId] && localValue !== "" && localValue !== undefined) {
                    links.push({
                        id: kiss.tools.uid(),
                        mX: this.id,
                        rX: localRecord.id,
                        fX: sourceLinkFieldId,
                        mY: foreignModelId,
                        rY: foreignRecord.id,
                        fY: foreignLinkFieldId,
                        auto: true // Tell it was created automatically. Can be used to rollback the process.
                    })
                }
            })
        })

        return links
    }

    /**
     * Delete all the links that were auto-generated for a given model.
     * 
     * @param {string} foreignModelId
     * @returns {integer} The number of deleted links
     */
    async deleteLinksToModel(foreignModelId) {
        const query = {
            $or: [{
                    mX: this.id,
                    mY: foreignModelId,
                    auto: true
                },
                {
                    mY: this.id,
                    mX: foreignModelId,
                    auto: true
                },
            ]
        }
        await kiss.app.collections.link.deleteMany(query)
    }

    /**
     * Connect the model to a foreign model using a <link> field.
     * 
     * To connect the 2 models, a symmetric <link> field is created in the foreign model.
     * 
     * @param {string} foreignModelId  - id of the foreign model to connect
     * @param {object} fieldSetup - Setup of the <link> field in the local model
     * @returns {object} The generated foreign <link> field
     */
    async connectToModel(foreignModelId, fieldSetup) {
        const foreignModel = kiss.app.models[foreignModelId]
        const foreignLinkFields = foreignModel.getFieldsByType("link")

        log("kiss.data.Model - Connecting model " + this.name + " to model " + foreignModel.name)

        let foreignLinkFieldId = null
        let existingLinkField = null
        let foreignLinkFieldConfig = {}

        // First, check if a deleted link field already points to the same model.
        // If yes, just restore the field instead of creating a new one.
        foreignLinkFields.forEach(linkField => {
            if (linkField.link.modelId == this.id) existingLinkField = linkField
        })

        if (existingLinkField != null) {
            // A link field already exists: we update it
            log("kiss.data.Model - connectToModel: updating existing link in the foreign model " + foreignModel.name, 2)

            delete existingLinkField.deleted
            existingLinkField.link.field = fieldSetup.label
            existingLinkField.link.fieldId = fieldSetup.id

            // Restore the field in case it was deleted
            existingLinkField.deleted = false
            existingLinkField.hidden = false

            await foreignModel.updateField(existingLinkField.id, existingLinkField)
            foreignLinkFieldId = existingLinkField.id

        } else {
            // There is no link field: we create it
            log("kiss.data.Model - connectToModel: adding a new link field in the foreign model " + foreignModel.name, 2)

            foreignLinkFieldConfig = {
                id: kiss.tools.shortUid(),
                label: this.namePlural,
                type: "link",

                // Layout parameters
                display: "inline-flex",
                width: "100%",
                fieldWidth: "100%",
                labelWidth: "100%",
                labelPosition: "top",
                labelAlign: "left",

                link: {
                    model: this.name,
                    modelId: this.id,
                    field: fieldSetup.label,
                    fieldId: fieldSetup.id
                }
            }

            await foreignModel.addField(foreignLinkFieldConfig)
            foreignLinkFieldId = foreignLinkFieldConfig.id

            log("kiss.data.Model - Foreign link field config:", 2, foreignLinkFieldConfig)
        }

        // Update the local link field
        fieldSetup.deleted = fieldSetup.hidden = false
        fieldSetup.link.fieldId = foreignLinkFieldId
        await this.updateField(fieldSetup.id, fieldSetup)

        // Update model relationships
        this._defineRelationships()
        foreignModel._defineRelationships()

        return foreignLinkFieldConfig
    }

    /**
     * Discover dynamically the relationships with foreign models.
     * 
     * Call this method once your models are loaded and available into kiss.app.models.
     * 
     * @private
     * @ignore
     * @returns {this}
     */
    _defineRelationships() {
        const modelProblems = []
        this.sourceFor = this.sourceFor || []
        const fields = this.fields.filter(field => !field.deleted)

        // Parse connections established by "link" fields
        fields.filter(field => field.type == "link").forEach(field => {
            try {
                let targetLinkModel = kiss.app.getModel(field.link.modelId || field.link.model)
                if (!targetLinkModel) {
                    // console.log("kiss.data.Model - Foreign model not found:", field.link.modelId || field.link.model)
                    throw new Error("Model not found")
                }
                
                // Link model => foreign model, in case the connection was made with the foreign model name instead of its id
                if (!field.link.modelId) {
                    field.link.modelId = targetLinkModel.id
                    delete field.link.model
                }

                // Get the field to link in the foreign model
                let targetLinkField = targetLinkModel.getField(field.link.fieldId || field.link.field)
                if (!targetLinkField) {
                    // console.log("kiss.data.Model - Foreign link field not found:", field.link.fieldId || field.link.field)
                    throw new Error("Symmetric link field not found in the foreign model")
                }

                // Enforce the field id in case the connection was made with the field name instead of its id
                field.link.fieldId = targetLinkField.id

                // Show the relationships in the console
                let hasMany = field.multiple
                let toModel = (hasMany) ? targetLinkModel.namePlural : targetLinkModel.name
                // log(`kiss.data.Model - ${this.name.padEnd(40, " ")} -> ${(hasMany) ? "N" : "1"} ${toModel.padEnd(40, " ")}` + " (link field: " + field.label + ")")

            } catch (err) {
                // Problem, the foreign model does not exist
                // log(err)
                field.type = "text"
                modelProblems.push(`kiss.data.Model - The link field <${this.name + " / " + field.label}> points to a foreign model or a foreign link field that can't be found`)
            }
        })

        // Parse connections established by "lookup" fields
        fields.filter(field => field.type == "lookup").forEach(field => {
            try {
                // Get the field to lookup in the foreign model
                let lookupLinkField = this.getField(field.lookup.linkId || field.lookup.link)
                let lookupLinkedModel = kiss.app.models[lookupLinkField.link.modelId]
                if (!lookupLinkedModel) throw new Error("Model not found")
                let lookupSourceField = lookupLinkedModel.getField(field.lookup.fieldId || field.lookup.field)
                
                // The foreign model is a source for this one
                lookupLinkedModel.sourceFor = (lookupLinkedModel.sourceFor || []).concat(this.id).unique()

                // Link model => foreign model
                field.computed = true
                if (!field.lookup.linkId) {
                    field.lookup.linkId = lookupLinkField.id
                    delete field.lookup.link
                }
                
                if (!field.lookup.fieldId) {
                    field.lookup.fieldId = lookupSourceField.id
                    delete field.lookup.field
                }

                field.lookup.type = lookupSourceField.type
                if (field.lookup.type == "number") field.precision = lookupSourceField.precision

                // Link foreign model => model
                lookupSourceField.sourceFor = lookupSourceField.sourceFor || []
                let newSource = {
                    modelId: this.id,
                    modelName: this.name,
                    fieldId: field.id,
                    fieldName: field.label,
                    type: "lookup"
                }

                if (!lookupSourceField.sourceFor.includesObject(newSource)) lookupSourceField.sourceFor.push(newSource)
                lookupSourceField.sourceFor = lookupSourceField.sourceFor.uniqueObject("fieldId")

            } catch (err) {
                // Problem, the foreign model does not exist
                // log(err)
                field.type = "text"
                modelProblems.push(`kiss.data.Model - The lookup field <${this.name + " / " + field.label}> points to a model that can't be found`)
            }
        })

        // Parse connections established by "summary" fields
        fields.filter(field => field.type == "summary").forEach(field => {
            try {
                // Get the field to summarize in the foreign model
                let summaryLinkField = this.getField(field.summary.linkId || field.summary.link)
                let summaryLinkModel = kiss.app.models[summaryLinkField.link.modelId]
                if (!summaryLinkModel) throw new Error("Model not found")
                let summaryField = summaryLinkModel.getField(field.summary.field || field.summary.fieldId)

                // The foreign model is a source for this one
                summaryLinkModel.sourceFor = (summaryLinkModel.sourceFor || []).concat(this.id).unique()

                // Link model => foreign model
                field.computed = true
                
                if (!field.summary.linkId) {
                    field.summary.linkId = summaryLinkField.id
                    delete field.summary.link
                }
                
                if (!field.summary.fieldId) {
                    field.summary.fieldId = summaryField.id
                    delete field.summary.field
                }
                
                field.summary.type = summaryField.type
                if (field.summary.type == "number") field.precision = summaryField.precision

                // Link foreign model => model
                summaryField.sourceFor = summaryField.sourceFor || []
                let newSource = {
                    modelId: this.id,
                    modelName: this.name,
                    fieldId: field.id,
                    fieldName: field.label,
                    type: "summary"
                }

                if (!summaryField.sourceFor.includesObject(newSource)) summaryField.sourceFor.push(newSource)
                summaryField.sourceFor = summaryField.sourceFor.uniqueObject("fieldId")

            } catch (err) {
                // Problem, the foreign model does not exist
                // log(err)
                field.type = "text"
                modelProblems.push(`kiss.data.Model - The summary field <${this.name + " / " + field.label}> points to a model that can't be found`)
            }
        })

        // Clean the list of foreign models that depends on this one for computed fields
        this.sourceFor = this.sourceFor.unique()

        modelProblems.forEach(warning => log(warning))
        return this
    }

    // #endregion

    /**
     * Notify the user about a change that has been made by someone else
     * 
     * @private
     * @ignore
     * @param {object} msgData - The original pubsub message
     */
    _notifyUser(msgData) {
        if (msgData.userId == kiss.session.getUserId()) return

        let msgEvent
        let object

        if (msgData.channel == "EVT_DB_UPDATE_BULK") {
            msgEvent = txtTitleCase("#msg update")
            object = "#some data"
        } else {
            const event = msgData.channel.split(":")[0]
            const modelId = msgData.channel.split(":")[1]

            if (event.includes("INSERT")) msgEvent = txtTitleCase("#msg insert")
            else if (event.includes("UPDATE")) msgEvent = txtTitleCase("#msg update")
            else if (event.includes("DELETE")) msgEvent = txtTitleCase("#msg delete")

            if (kiss.tools.isUid(modelId.toLowerCase())) object = "#a record"
            else if (modelId == "VIEW") object = "#a view"
            else if (modelId == "MODEL") object = "#a model"
            else object = "#some data"
        }

        createNotification({
            message: txtTitleCase(
                msgEvent, null, {
                    user: kiss.directory.getEntryName(msgData.userId),
                    object: txt(object)
                }
            ),
            top: () => kiss.screen.current.height - 50,
            left: 10,
            height: "40px",
            padding: "0px",
            animation: "slideInUp",
            duration: 4000
        })
    }
 }

;