/** * The Vue application instance. * https://vuejs.org/guide/essentials/application.html * This is the root component */ const app = Vue.createApp({ /** * to prevent conflict with jekyll we change the * delimiters from {{ }} to [[ ]] */ delimiters: ['[[', ']]'], /** * The data object of the root component. * These variables are available in the html template. */ data() { return { // The rule object rule: global.rule || { ruleID: "", timestamp: "", icon: "", name: "", lineage: "", summary: "", config: "", creator: { name: "", url: "", }, modules: [] }, legacy: false, rID: false, loading: false, publishing: false, view: (global.rule) ? true : false, preview: (global.rule) ? true : false, template: (global.rule) ? true : false, steinAPI: 'https://api.steinhq.com/v1/storages/5e8b937ab88d3d04ae0816a5', // TODO keep an array of past states for undo/redo history: [], // the data of the current module in the editor editor: { source: null, previousState: null, module: null, }, // the module that is currently being dragged holding: false, // object of icons for easy access icons: { plus: '/assets/tabler_icons/circle-plus.svg', info: '/assets/tabler_icons/info-circle.svg', chevron: '/assets/tabler_icons/chevrons-down.svg', blank: '/assets/tabler_icons/circle-dotted.svg', view: '/assets/tabler_icons/eye.svg', edit: '/assets/tabler_icons/tool.svg', plus: '/assets/tabler_icons/plus.svg', minus: '/assets/tabler_icons/minus.svg', publish: '/assets/tabler_icons/cloud-upload.svg', download: '/assets/tabler_icons/download.svg', export: '/assets/tabler_icons/file-download.svg', upload: '/assets/tabler_icons/file-upload.svg', fork: '/assets/tabler_icons/git-fork.svg', }, // icons available in the editor moduleIcons: { culture: '/assets/tabler_icons/palette.svg', decision: '/assets/tabler_icons/thumb-up.svg', process: '/assets/tabler_icons/rotate.svg', structure: '/assets/tabler_icons/building.svg', relationship: '/assets/tabler_icons/heart.svg', economic: '/assets/tabler_icons/coin.svg', legal: '/assets/tabler_icons/license.svg', map: '/assets/tabler_icons/map.svg', communications: '/assets/tabler_icons/microphone.svg', }, // icons available for rules ruleIcons: { atom: '/assets/tabler_icons/atom.svg', bandage: '/assets/tabler_icons/bandage.svg', book: '/assets/tabler_icons/book.svg', box: '/assets/tabler_icons/box.svg', church: '/assets/tabler_icons/building-church.svg', store: '/assets/tabler_icons/building-store.svg', brush: '/assets/tabler_icons/brush.svg', car: '/assets/tabler_icons/car.svg', clock: '/assets/tabler_icons/clock.svg', cloud: '/assets/tabler_icons/cloud.svg', compass: '/assets/tabler_icons/compass.svg', game: '/assets/tabler_icons/device-gamepad.svg', flask: '/assets/tabler_icons/flask.svg', location: '/assets/tabler_icons/location.svg', moon: '/assets/tabler_icons/moon.svg', settings: '/assets/tabler_icons/settings.svg', shield: '/assets/tabler_icons/shield.svg', star: '/assets/tabler_icons/star.svg', tool: '/assets/tabler_icons/tool.svg', world: '/assets/tabler_icons/world.svg', }, // the template for modules moduleTemplate: { moduleID: "", name: "", icon: "", summary: "", config: "", type: "", modules: [] }, // tracks the current module library tab that is open moduleLibrary: 'culture', moduleTypes: { // custom: { // question: 'Modules that you\'ve created.', // icon: '/assets/tabler_icons/circle-plus.svg', // open: true // }, culture: { question: 'What are the core missions, values, and norms?', icon: '/assets/tabler_icons/palette.svg', open: true }, decision: { question: 'Who can make decisions and how?', icon: '/assets/tabler_icons/thumb-up.svg', open: false }, process: { question: 'How are policies implemented, and how do they evolve?', icon: '/assets/tabler_icons/rotate.svg ', open: false }, structure: { question: 'What kinds of roles and internal entities are there?', icon: '/assets/tabler_icons/building.svg', open: false } }, // array of modules that have been created by the user // TODO: implement custom modules customModules: [], // library of existing modules modules: global.modules, exports: { markdown: null, json: null, } } }, /** * Vue provide passes data to other components. * https://vuejs.org/guide/components/provide-inject.html#provide-inject */ provide() { return { editor: this.editor, icons: this.icons, } }, created() { this.addToEditor(this.newModule()); var urlParams = new URLSearchParams(window.location.search); if (urlParams.has('r')) { this.rID = urlParams.get('r'); this.preview = true; this.view = true; this.fetchRule(this.rID); } }, computed: { /** * Exports the current rule into a normalized format * Cleans up all submodules so they are ready for export * @returns {Object} a Rule object */ ruleExport() { //TODO: test if icon is an absolute url and only add the global if it is not /** * Takes a module and recursively cleans it and all submodules up * @param {Object} module a module object * @returns */ function cleanModules(module) { const newModule = { moduleID: module.moduleID, name: module.name, icon: (module.icon && !module.icon.includes('http')) ? global.url + module.icon : module.icon, summary: module.summary, config: module.config, type: module.type, modules: (module.modules) ? module.modules.map(cleanModules) : [], } return newModule; } return { ruleID: (this.rule.ruleID) ? this.rule.ruleID : this.slugify(this.rule.name), timestamp: this.timesString(), icon: (this.rule.icon && !this.rule.icon.includes('http')) ? global.url + this.rule.icon : this.rule.icon, name: this.rule.name, lineage: this.rule.lineage, summary: this.rule.summary, config: this.rule.config, creator: { name: this.rule.creator.name, url: this.rule.creator.url, }, modules: this.rule.modules.map(cleanModules) } }, /** * @returns {String} the current rule as a JSON string */ json() { return JSON.stringify(this.ruleExport, null, 2); }, /** * Creates an array of all moduleIDs in use * @returns {Array} an array of all module's (in the library and custom modules) moduleID */ listModuleIds() { const modules = [...this.rule.modules, ...this.customModules]; return modules.map(module => module.moduleID) }, /** * @returns {Object} the current module in the editor */ moduleInEditor() { return this.moduleEditor[0] }, /** * Tests if the current module in the editor has been modified * against the editor.previousState object * @returns {Boolean} true if the module in the editor has been modified */ //TODO: find a more accurate solution than just turning the object into a string editorHasEdits() { return this.editor.module && Object.entries(this.editor.module).toString() !== Object.entries(this.editor.previousState).toString(); }, }, methods: { // module methods =========================================================== /** * @returns {Object} a new module object from the moduleTemplate */ newModule() { return { ...this.moduleTemplate } }, /** * spreads a source module into a new module from the moduleTemplate * @param {Object} sourceModule the module to copy * @param {Boolean} includeSubmodules whether to copy submodules or not * @returns */ cloneModule(sourceModule,includeSubmodules) { let output = { ...this.moduleTemplate, ...sourceModule, //TODO: implement lineage pattern, same as the rule does source: sourceModule, // keep track of where this module came from }; if (!includeSubmodules) output.modules = []; // delete unnecessary properties delete output.content; delete output.readonly; // TODO: give module a unique id // if (output.moduleID) output.moduleID = this.getUniqueId(output.moduleID); return output; }, /** * Handles the click event to copy a module * @param {Event} ev the click event */ handleClickCopyModule(ev) { const clickTarget = this.getClosestModule(ev.target); if (!clickTarget) return; this.copyModule(clickTarget.module); }, /** * Handles the click event to edit a module * @param {Event} ev the click event */ handleClickEditModule(ev) { const clickTarget = this.getClosestModule(ev.target); if (!clickTarget) return; this.editModule(clickTarget.module); }, /** * Copies a module to the editor * @param {Object} module the module to copy */ copyModule(module) { this.copyToEditor(module); }, /** * moves a module to the editor * @param {Object} module the module to edit */ editModule(module) { this.addToEditor(module); }, /** * add a module to another module (or to the rule by default) as a submodule * @param {Object} module to add * @param {Object} target a module or the rule Object where the module should be added as a submodule */ addModule(module,target = this.rule) { target.modules.push(module); }, /** * remove a module from another module (or from the rule) * recursively moves through all submodules in the target * removes ONLY the first instance of the module * @param {Object} module the module to remove from target * @param {Object} target the module or rule to remove the module from (defaults to rule) */ removeModule(module, target = this.rule) { if (! this.moduleContains(module, target)) return; // if the module is not in the target, do nothing // target.modules.forEach((m, idx) => { if (m === module) { target.modules.splice(idx, 1); return; } else { this.removeModule(module, m); } }); }, /** * Deletes custom module from the customModules array and clears the editor * @param {Object} module the module to be deleted */ deleteModule(module) { this.removeCustomModule(module); // TODO: only clear the editor if the module is present in the editor this.clearEditor(); }, /** * Handles the start drag event for a module * @param {Event} ev the drag event */ startDragModule(ev) { const targetModule = this.getClosestModule(ev.target); if (!targetModule) return; const module = targetModule.module; ev.dataTransfer.setDragImage(targetModule, ev.offsetX, ev.offsetY); this.holding = {module}; }, /** * Handles the start drag event for a module * when the module is being rearranged within the rule * @param {Event} ev the drag event */ rearrangeModule(ev) { const targetModule = this.getClosestModule(ev.target); if (!targetModule) return; const source = this.getClosestModule(targetModule.parentNode).module; const module = targetModule.module; ev.dataTransfer.setDragImage(targetModule, ev.offsetX, ev.offsetY); this.holding = { module, source, }; }, /** * Handles the dragend event for a module */ endDragModule() { this.holding = false; }, /** * Recursively searches modules and their submodules for a module * @param {Object} needle the module to search for * @param {Object} haystack the module to search in (defaults to rule) * @returns {Boolean} true if the module is in the haystack */ // TODO: return the module location in the haystack (Maybe?) moduleContains(needle, haystack = this.rule) { if (! haystack.modules ) return false; // does the haystack even have modules? if (haystack.modules.includes(needle)) return true; // is the needle in the haystack? return haystack.modules.some(submodule => this.moduleContains(needle, submodule)); // is the needle in any of the submodules? }, // rule methods =========================================================== /** * Handles the drop event for a module * adds the module to the closest submodule or the rule depending on what it is dropped onto * then adds the module to the editor * @param {Event} ev the drop event */ dropOnRule(ev) { //TODO browser drag objects that hover over drop zone are showing a 'add' icon const landingNode = this.getClosestModule(ev.target); if (!this.holding.module || !landingNode) return; // if there is no module to drop or no landing node, do nothing const landingModule = landingNode.module; // module is set with the v-bind prop binding const holdingModule = this.holding.module; if (holdingModule === landingModule) return; // if the module is the same, do nothing // if the module being dropped is readyonly clone it, otherwise use the original const readonly = holdingModule.readonly; const module = (readonly) ? this.cloneModule(holdingModule) : holdingModule; if (this.holding.source) { // if the module has a source, remove it from that source this.removeModule(holdingModule, this.holding.source); } this.addModule(module, landingModule); this.editModule(module); this.endDragModule(); }, fetchRule(id) { this.loading = true; // handle legacy links // TODO: handle this at a global level let redirect = { 'benevolent_dictator': 'benevolent-dictator', circles: 'circles', consensus: 'consensus', 'do-ocracy': 'do-ocracy', 'elected_board': 'elected-board', jury: 'jury', petition: 'petition', 'self-appointed_board': 'self-appointed-board', } // if the rule is a legacy link, redirect if (redirect[id]) { location.href = `/templates/${redirect[id]}`; return; } const store = new SteinStore( this.steinAPI ); (async () => { var rule = []; // read values from all sheets await store.read('rules', { search: { ruleID: id } }).then(data => { // test if there's anything in data if (data.length > 0) { rule = data[0]; } console.log(rule); }); // no rule found, exit // TODO: inform the user that the rule was not found if (!rule) { this.loading = false; return; } // if this is a legacy (pre-v3) set it as such if (rule.version < 3) { this.loading = false; this.legacy = true; this.rule = rule; Vue.nextTick(() => { if (rule.version == 2) displayBuilderHTML(); }); return; } this.rule = { ruleID: rule.ruleID, timestamp: rule.timestamp, icon: rule.icon, name: rule.name, lineage: rule.lineage, summary: rule.summary, config: rule.config, creator: { name: rule.creatorName, url: rule.creatorUrl, }, modules: (rule.modules) ? JSON.parse(rule.modules) : [] } /** Add name to