Source

client/core/modules/app.js

/**
 * 
 * ## Kiss application manager
 * 
 * This module allows to:
 * - define and access **models**
 * - define and access **collections**
 * - define **views**
 * - define **view controllers**
 * - **init** the application using kiss.app.init()
 * 
 * Once the models and collections are defined, they are stored in the **app** object.
 * ```
 * // Getting models and collections
 * const appModels = kiss.app.models
 * const appCollections = kiss.app.collections
 * 
 * // Getting a model definition or a collection
 * const userModel = appModels.user
 * const userCollection = appCollections.user
 * 
 * // Using the model
 * const Bob = userModel.create({firstName: "Bob", lastName: "Wilson"})
 * await Bob.save()
 * 
 * // Using the collection if you're not sure it's loaded in memory (async method)
 * const John = await userCollection.findOne("123")
 * 
 * // Using the collection if it's already loaded in memory (sync method)
 * const Will = userCollection.getRecord("456")
 * ```
 * 
 * @namespace
 */
kiss.app = {

    // Reserved namespace for form templates
    formTemplates: {},

    // Reserved namespace for view templates
    viewTemplates: {},

    /**
     * Store the application models.
     * [More about models here.](kiss.data.Model.html)
     * 
     * @example
     * const userModel = kiss.app.models.user
     */
    models: {},

    /**
     * Store the application collections.
     * [More about collections here.](kiss.data.Collection.html)
     * 
     * @example
     * const userCollection = kiss.app.collections.user
     */
    collections: {},

    /**
     * Define all the application texts that can be translated.
     * Each text will be used as an English text if it doesn't have any english translation.
     * 
     * @param {object} texts
     * 
     * @example
     * kiss.app.defineTexts({
     *  "hello": {
     *      fr: "bonjour"
     *  },
     *  "#thank": {
     *      en: "thank you",
     *      fr: "merci"
     *  }
     * })
     */
    defineTexts(texts) {
        if (texts) {
            Object.assign(kiss.language.texts, texts)
            log.info(`kiss.language - Loaded ${Object.keys(texts).length} translated texts`)
        }
    },

    /**
     * Define a new model in the application
     * 
     * This automatically:
     * - references the model in **kiss.app.models**
     * - references a new collection for the model in **kiss.app.collections**
     * - references a new record class to instanciate the model
     * - generates the basic api for the record class (create, save, update, delete)
     * - generates getters/setters for record's computed fields (= virtual fields)
     * 
     * Check the class [Model documentation](kiss.data.Model.html) for more informations about models.
     * 
     * @param {object} model - The model configuration object
     * @returns {Model} The newly defined model
     */
    defineModel(model) {
        if (kiss.app.models[model.id]) return kiss.app.models[model.id]
        return new kiss.data.Model(model)
    },

    /**
     * Define the model relationships
     * 
     * This methods explores all the application models and finds automatically the relationships between the models.
     * When exploring the models, specific field types are generating the relationships:
     * - **link**: field used to link one or many foreign records
     * - **lookup**: field that looks up a field in a foreign record
     * - **summary**: field that looks up and summarizes data from multiple foreign records
     * 
     * @example
     * kiss.app.defineModelRelationships()
     */
    defineModelRelationships() {
        Object.values(kiss.app.models).forEach(model => model._defineRelationships())
    },

    /**
     * Get a Model by id or by name
     * 
     * Note: if the model is not found, the methods tries to find the model by its name
     * 
     * @param {string} modelId - id or name (case insensitive) of the model
     * @returns {object} Model
     */
    getModel(modelId) {
        const model = kiss.app.models[modelId]
        if (model) return model
        return kiss.app.getModelByName(modelId)
    },

    /**
     * Get a Model by name
     * 
     * Models are normally retrieved by id like this:
     * ```
     * const projectModel = kiss.app.models[modelId]
     * const projectRecord = projectModel.create({projectName: "foo"})
     * ```
     * 
     * In some situation, we just know the model name and have to use this method:
     * ```
     * const projectModel = kiss.app.getModelByName("Project")
     * const projectRecord = projectModel.create({projectName: "foo"})
     * ```
     * 
     * @param {string} modelName - The name of the model (case insensitive)
     * @returns {object} Model
     */
    getModelByName(modelName) {
        const model = Object.values(kiss.app.models).find(model => (model.name.toLowerCase() == modelName.toLowerCase()))
        return model
    },

    /**
     * Get a Collection by its model's name
     * 
     * Collections are normally retrieved by id like this:
     * ```
     * const projectCollection = kiss.app.collections[collectionId]
     * const projects = await projectCollection.find()
     * ```
     * 
     * In some situation, we just know the name of the collection's model, so we have to use this method:
     * ```
     * const projectCollection = kiss.app.getCollectionByModelName("Project")
     * const projects = await projectCollection.find()
     * ```
     * 
     * @param {string} modelName - The name of the collection's model (case insensitive)
     * @returns {object} Collection
     */
    getCollectionByModelName(modelName) {
        return Object.values(kiss.app.collections).find(collection => (collection.model.name.toLowerCase() == modelName.toLowerCase()))
    },

    /**
     * List all the application collections.
     * Give their name and number of records.
     */
    listCollections() {
        Object.values(kiss.app.collections).forEach(collection => {
            console.log(`id: ${collection.id}, name: ${collection.model.name}, records: ${collection.records.length}`)
        })
    },

    /**
     * Define a view by storing its renderer function into the list of view renderers.
     * It does NOT store a view, but instead stores a view 'renderer' function that will generate the view later, when needed.
     * 
     * @param {object} config
     * @param {string} config.id - The id of the view to add
     * @param {function} config.renderer - The function that will render the view when needed
     * @param {object} config.meta - Meta informations injected in the HTML header. Can be localized or not. See examples in kiss.views module.
     * 
     * @example
     * kiss.app.defineView({
     *  id: "home",
     *  renderer: function (id, target) {
     *      // ... build your view here
     *      return createPanel({
     * 
     *          id, // Very important. Can't work without it.
     *          target, // Optional insertion point in the DOM. You can omit if you don't need it.
     * 
     *          title: "My first panel",
     * 
     *          // A few panel properties
     *          draggable: true,
     *          closable: true,
     *          width: 300,
     *          height: 200,
     *          boxShadow: "5px 5px 10px #cccccc",
     *      
     *          // Panel content
     *          items: [
     *              {
     *                  type: "html",
     *                  html: "<center>Hello world?</center>",
     *              
     *                  // W3C events attached to the html element
     *                  // It works only if you've attached the "hello" viewController (see below)
     *                  events: {
     *                      onclick: function() {
     *                          $(id).hello()
     *                      }
     *                  }
     *              }
     *          ]
     *      })
     *  }
     * })
     */
    defineView({
        id,
        renderer,
        meta
    }) {
        kiss.views.addView({
            id,
            renderer,
            meta
        })
    },

    /**
     * Define a controller for a specific view
     * 
     * The view controller must have the same name as the controlled view.
     * They will be paired automatically.
     * 
     * @param {string} id 
     * @param {object} viewController - Object containing all the controller methods
     * 
     * @example
     * // This controller has 4 methods, hello(), world(), foo() and bar()
     * kiss.app.defineViewController("home", {
     *          
     *      hello: function() {
     *          createNotification({
     *              message: "Hello!",
     *              duration: 2000
     *          })
     *      },
     * 
     *      // ... or using an arrow function:
     *      world: () => console.log("World!"),
     * 
     *      // ... or class member notation:
     *      foo() {
     *          console.log("Foo!")
     *      },
     * 
     *      // Methods can be async, too:
     *      async bar() {
     *          return await 42
     *      }
     * })
     */
    defineViewController(id, viewController) {
        kiss.views.addViewController(id, viewController)
    },

    /**
     * Add a plugin definition to the application
     * 
     * @param {object} plugin 
     */
    definePlugin(plugin) {
        kiss.plugins.add(plugin)
    },

    /**
     * Init KissJS application
     * 
     * @async
     * @param {object} config - The application configuration object
     * @param {string} [config.name] - Optional application name (will be stored in kiss.app.name)
     * @param {string} [config.logo] - Optional application logo (will be stored in kiss.app.logo and use in login screens)
     * @param {string} [config.mode] - "online", "offline", "memory". Default is "online". Don't use "online" for local projects.
     * @param {string} [config.host] - The host for online requests. Can be localhost or "" in developement.
     * @param {boolean} [config.https] - Set to false if the application doesn't use https. Default is true. Ignored for "memory" or "offline" modes.
     * @param {string[]} [config.loginMethods] - The list of login methods to use. Default is ["internal", "google", "microsoft365"]
     * @param {string|object} [config.startRoute] - The route to start with. Can be a string (= viewId) or an object (check router documentation).
     * @param {string[]} [config.publicRoutes] - The list of public routes which doesn't require authentication
     * @param {object} [config.undoRedo] - The undo/redo configuration object
     * @param {function} [config.loader] - The async function used to load your custom resources at startup. Must *absolutely* return a boolean to indicate success.
     * @param {boolean} [config.useDirectory] - Set to true if your app uses KissJS directory to manage users, groups and apiClients. Default is false.
     * @param {boolean} [config.useDynamicModels] - Set to true if your app needs dynamic models. Default is false.
     * @param {boolean} [config.useFormPlugins] - Set to true if your app needs form plugins. Default is false.
     * @param {object} [config.theme] - The theme to use. Ex: {color: "light", geometry: "sharp"}
     * @param {string} [config.language] - "en", "fr" or "es". Default is "en" or the last language used by the user.
     * @param {boolean} [config.debug] - Enable debug mode if true (default is false)
     * 
     * @example
     * await kiss.app.init({
     *  debug: true,
     *  name: "pickaform",
     *  language: "fr",
     *  logo: "./resources/img/logo 256x128.png",
     *  mode: "online",
     *  https: true,
     *  useDirectory: true,
     *  useDynamicModels: true,
     *  useFormPlugins: true,
     *  startRoute: "home-start",
     *  publicRoutes: [
     *      "form-public"
     *  ],
     *  undoRedo: {
     *      async undo() {
     *          // Undo code here
     *      },
     *      async redo() {
     *          // Redo code here
     *      }
     *  },
     *  theme: {
     *      color: "light",
     *      geometry: "sharp"
     *  },
     *  loader: async function() {
     *    // Load your resources here
     *    return true // IMPORTANT: return true if everything is loaded correctly, false otherwise
     *  }
     * })
     */
    async init(config) {
        if (!config) return false

        kiss.app.name = config.name
        kiss.app.logo = config.logo
        kiss.app.useDirectory = !!config.useDirectory
        kiss.app.useDynamicModels = !!config.useDirectory
        kiss.app.useFormPlugins = !!config.useFormPlugins
        kiss.app.loader = config.loader

        // Init texts
        await kiss.language.init()
        
        // Init plugins texts once the language is set
        kiss.plugins.initTexts()

        // Init global mode and database mode:
        // - the mode automatically switch depending on the html file used to start the application
        // - by default, starting with **index.html** will work online
        // - demo.html is used to show application templates in memory (no server resource consumption)
        // - memory.html is to test the application locally without saving anything: a browser refresh will wipe data
        // - offline.html is to save the data inside the browser
        const location = window.location.pathname
        if (location.includes("demo")) {
            kiss.global.mode = "demo"
            kiss.db.setMode("memory")
        } else if (location.includes("memory")) {
            kiss.global.mode = "memory"
            kiss.db.setMode("memory")
        } else if (location.includes("offline")) {
            kiss.global.mode = "offline"
            kiss.db.setMode("offline")
        } else {
            kiss.global.mode = config.mode || "online"
            kiss.db.setMode(kiss.global.mode)
        }

        // Init KissJS logger
        let categories = ["😘"]
        if (config.debug) {
            categories = categories.concat([
                "*",
                // "kiss.ajax",
                // "kiss.session",
                // "kiss.websocket",
                // "kiss.pubsub",
                // "kiss.db",
                // "kiss.data.Model",
                // "kiss.data.Record",
                // "kiss.data.Collection",
                // "kiss.data.Transaction",
                // "kiss.data.trash",
                // "kiss.acl",
                // "kiss.ui",
                // "kiss.views",
                // "kiss.language",
                // "kiss.plugins"
            ])
        }

        kiss.logger.init({
            data: true,
            types: [0, 1, 2, 3, 4],
            categories
        })

        // Init undo/redo
        if (config.undoRedo) kiss.undoRedo.init(config.undoRedo)

        // Init the theme
        if (config.theme) kiss.theme.set(config.theme)
        kiss.theme.init()

        // Init screen size listener
        kiss.screen.init()

        // Init the application router
        kiss.router.init()

        if (config.publicRoutes) {
            if (Array.isArray(config.publicRoutes) && config.publicRoutes.length > 0) {
                kiss.router.addPublicRoutes(config.publicRoutes)
            }
        }

        if (config.routerGuards) {
            if (Array.isArray(config.routerGuards) && config.routerGuards.length > 0) {
                kiss.router.addRoutingGuards(config.routerGuards)
            }
        }

        if (config.routerActions) {
            if (Array.isArray(config.routerActions) && config.routerActions.length > 0) {
                kiss.router.addRoutingActions(config.routerActions)
            }
        }

        // Get the requested route
        if (config.startRoute) {
            let route = config.startRoute
            if (typeof route === "string") {
                route = {
                    ui: config.startRoute
                }
            }
            kiss.router.updateUrlHash(route, true)
        }
        const newRoute = kiss.router.getRoute()

        // Init host
        if (config.host) {
            let host = config.host
            // Session host
            if (typeof host === "string") {
                host = {
                    host: config.host
                }
            }
            kiss.session.setHost(host)

            // Ajax host
            kiss.session.secure = (config.https === false) ? false : true
            let ajaxHost = kiss.session.getHttpHost()
            kiss.ajax.setHost(ajaxHost)
        }

        if (config.loginMethods) {
            if (Array.isArray(config.loginMethods) && config.loginMethods.length > 0) {
                kiss.session.setLoginMethods(config.loginMethods)
            }
        }
        
        if (!kiss.router.isPublicRoute()) await kiss.session.restore()

        // Jump to the first route
        kiss.router.navigateTo(newRoute)

        // Remove the splash screen, if any
        if ($("splash")) $("splash").remove()

        // Welcome message
        console.log("😘 Powered with ❤ by KissJS, Keep It Simple Stupid Javascript (version " + kiss.version + ")")
    },

    /**
     * Load core application data:
     * - load the directory
     * - load dynamic models
     * - define model relationships
     * - load links between records
     * - load form plugins
     * 
     * @returns {boolean} - True if the core application data could be loaded properly, false otherwise
     */
    async load() {
        // Load the directory
        if (kiss.app.useDirectory) {
            let success = await kiss.app.loadDirectory()
            if (!success) return false
        }

        // Load dynamic models
        if (kiss.app.useDynamicModels) {
            let success = await kiss.app.loadDynamicModels()
            if (!success) return false
        }

        // Discover model relationships dynamically
        kiss.app.defineModelRelationships()

        // Load links between records
        await kiss.app.collections.link.find()

        // Load the form plugins
        if (kiss.app.useFormPlugins) {
            await kiss.plugins.init()
        }

        return true
    },

    /**
     * Load the application directory (users, groups, apiClients)
     * @returns {boolean} - True if the directory is loaded, false otherwise
     */
    async loadDirectory() {
        return await kiss.directory.init()
    },

    /**
     * Load the dynamic models.
     * Dynamic models are created by the users and have an unpredictable schema.
     * 
     * @returns {boolean} - True if the dynamic models are loaded, false otherwise
     */
    async loadDynamicModels() {
        if (!await kiss.app.collections.model) return true

        const models = await kiss.app.collections.model.find()

        // Exit if error (meaning the user is not properly logged in)
        if (!models) return false

        models.forEach(model => {
            if (model.items) kiss.app.defineModel(model)
        })

        // React to the creation of new models
        kiss.pubsub.subscribe("EVT_DB_INSERT:MODEL", msgData => {
            kiss.app.defineModel(msgData.data)
        })

        return true
    }
}


;