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 {Promise<kiss.data.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)).init()
	},

	/**
	 * 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
	 * await kiss.app.defineModelRelationships()
	 */
	async defineModelRelationships() {
		for (const model of Object.values(kiss.app.models)) {
			await model._defineRelationships()
		}

		// Once the relationships between models are defined, we can initialize the renderers
		Object.values(kiss.app.models).forEach(model => model._initRenderers())
	},

	/**
	 * 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 {string} [config.routerMode] - "hash" (default) or "pathname"
	 * @param {boolean} [config.usePathRouting] - Shortcut to activate pathname routing
	 * @param {function} [config.pathnameToRoute] - Mapper: pathname => route object (used when hash is not exploitable)
	 * @param {function} [config.routeToPathname] - Mapper: route object => pathname
	 * @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.useLocalCache] - Set to true to enable local caching of data in the browser for online mode. 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)
	 * @returns {boolean} - True if the initialization process could go through all the steps
	 * 
	 * @example
	 * await kiss.app.init({
	 *  debug: true,
	 *  name: "airprocess",
	 *  language: "fr",
	 *  logo: "./resources/img/logo.png",
	 *  mode: "online",
	 *  https: true,
	 *  useDirectory: true,
	 *  useDynamicModels: true,
	 *  useFormPlugins: true,
	 *  startRoute: "home-start",
	 *  publicRoutes: [
	 *      "form-public"
	 *  ],
	 *  routerMode: "pathname",
	 *  pathnameToRoute(pathname) {
	 *      if (pathname == "/fr/landing") return {ui: "start", content: "landing", language: "fr"}
	 *      return {}
	 *  },
	 *  routeToPathname(route) {
	 *      if (route.ui == "start" && route.content == "landing" && route.language == "fr") return "/fr/landing"
	 *      return "/"
	 *  },
	 *  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.useDynamicModels
		kiss.app.useLocalCache = !!config.useLocalCache
		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({
			routerMode: config.routerMode,
			usePathRouting: config.usePathRouting,
			pathnameToRoute: config.pathnameToRoute,
			routeToPathname: config.routeToPathname
		})

		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 no default route is specified
		const currentRoute = kiss.router.getRoute()
		if (config.startRoute && !currentRoute.ui) {
			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 + ")")

		return true
	},

	/**
	 * 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
		await 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

		for (const model of models) {
			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
	},

	/**
	 * Disable local caching for collections
	 */
	disableLocalCache() {
		kiss.app.useLocalCache = false
		Object.values(kiss.app.models).forEach(model => {
			model.useLocalCache = false
			delete model.localCacheCollection
		})

		Object.values(kiss.app.collections).forEach(collection => {
			if (collection.isLocalCache) {
				delete kiss.app.collections[collection.id]
				kiss.db.memory.deleteCollection(collection.model.id)
			}
			else {
				if (collection.useLocalCache == true) {
					collection.useLocalCache = false
					const cacheCollectionId = collection.localCacheCollection.id
					delete kiss.app.collections[cacheCollectionId]
				}
			}
		})
	},

	/**
	 * Enable local caching for collections
	 */
	enableLocalCache() {
		kiss.app.useLocalCache = true
		Object.values(kiss.app.models).forEach(model => {
			model.useLocalCache = true
			model._initLocalCacheCollection()
		})
	}	
}