From c4e269805e117ef33aba754e6517aa699ba7fc02 Mon Sep 17 00:00:00 2001 From: VIAUD Nathan <nathan.viaud@inria.fr> Date: Wed, 23 Aug 2023 16:52:37 +0200 Subject: [PATCH] Functionnal badge system --- package-lock.json | 11 +- package.json | 2 +- .../components/ConditionElementSelector.vue | 2 +- .../badge/components/ConditionItem.vue | 129 ++++++++++----- .../badge/components/ConditionModal.vue | 42 ++++- .../badge/components/ConditionValue.vue | 91 +++++++++++ .../components/inputs/badges/BadgesInput.vue | 12 +- src/shared/classes/epoc-v1.ts | 6 +- src/shared/interfaces/badge.interface.ts | 30 +++- src/shared/services/badge.service.ts | 83 ++++++++++ src/shared/services/graph.service.ts | 30 ++-- src/shared/services/graph/content.service.ts | 14 +- .../services/graph/copyPaste.service.ts | 92 +++++++++++ src/shared/services/graph/element.service.ts | 95 +++++++++++ src/shared/services/graph/index.ts | 4 +- src/shared/services/graph/node.service.ts | 151 ++---------------- src/shared/services/import.service.ts | 2 +- src/shared/services/index.ts | 3 +- src/shared/stores/editorStore.ts | 7 +- 19 files changed, 575 insertions(+), 231 deletions(-) create mode 100644 src/features/badge/components/ConditionValue.vue create mode 100644 src/shared/services/badge.service.ts create mode 100644 src/shared/services/graph/copyPaste.service.ts create mode 100644 src/shared/services/graph/element.service.ts diff --git a/package-lock.json b/package-lock.json index aac9bbfe..f8d5b15b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.4-beta", "license": "CeCILL-B", "dependencies": { - "@epoc/epoc-types": "^2.0.0-beta.7", + "@epoc/epoc-types": "git+https://gitlab.inria.fr/learninglab/epoc/epoc-types.git", "@meforma/vue-toaster": "^1.3.0", "@tinymce/tinymce-vue": "^5.1.0", "@vue-flow/additional-components": "^1.3.3", @@ -287,8 +287,8 @@ }, "node_modules/@epoc/epoc-types": { "version": "2.0.0-beta.7", - "resolved": "https://registry.npmjs.org/@epoc/epoc-types/-/epoc-types-2.0.0-beta.7.tgz", - "integrity": "sha512-H9jlOWAkiHbuMQlWArWm4hsdKMhavsVBWngQUniv120JRJUjmHHj/CtGOu/crJ+gYV7R+4avjCDZI/L51Bv3sg==" + "resolved": "git+https://gitlab.inria.fr/learninglab/epoc/epoc-types.git#a5ba071f37736b019aa325fe88ad0f4e87eda22d", + "license": "CeCILL-B" }, "node_modules/@esbuild/android-arm": { "version": "0.17.19", @@ -13386,9 +13386,8 @@ } }, "@epoc/epoc-types": { - "version": "2.0.0-beta.7", - "resolved": "https://registry.npmjs.org/@epoc/epoc-types/-/epoc-types-2.0.0-beta.7.tgz", - "integrity": "sha512-H9jlOWAkiHbuMQlWArWm4hsdKMhavsVBWngQUniv120JRJUjmHHj/CtGOu/crJ+gYV7R+4avjCDZI/L51Bv3sg==" + "version": "git+https://gitlab.inria.fr/learninglab/epoc/epoc-types.git#a5ba071f37736b019aa325fe88ad0f4e87eda22d", + "from": "@epoc/epoc-types@git+https://gitlab.inria.fr/learninglab/epoc/epoc-types.git" }, "@esbuild/android-arm": { "version": "0.17.19", diff --git a/package.json b/package.json index a69f1dbf..27f8637a 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "debug-mac": "'/Applications/ePoc Editor.app/Contents/MacOS/ePoc Editor' --remote-debugging-port=8315" }, "dependencies": { - "@epoc/epoc-types": "^2.0.0-beta.7", + "@epoc/epoc-types": "git+https://gitlab.inria.fr/learninglab/epoc/epoc-types.git", "@meforma/vue-toaster": "^1.3.0", "@tinymce/tinymce-vue": "^5.1.0", "@vue-flow/additional-components": "^1.3.3", diff --git a/src/features/badge/components/ConditionElementSelector.vue b/src/features/badge/components/ConditionElementSelector.vue index 169e42f4..4aadce8f 100644 --- a/src/features/badge/components/ConditionElementSelector.vue +++ b/src/features/badge/components/ConditionElementSelector.vue @@ -17,7 +17,7 @@ function onClick() { <div class="select"> Activité <div class="select-input" @click="onClick"> - <p>{{ inputValue || 'Veuillez sélectionner un élément' }}</p> + <p>{{ inputValue || 'Veuillez sélectionner' }}</p> <i class="icon-cible"></i> </div> </div> diff --git a/src/features/badge/components/ConditionItem.vue b/src/features/badge/components/ConditionItem.vue index 70226204..0338b22f 100644 --- a/src/features/badge/components/ConditionItem.vue +++ b/src/features/badge/components/ConditionItem.vue @@ -1,35 +1,53 @@ <script setup lang="ts"> -import { Ref, ref } from 'vue'; -import ConditionElementSelector from '@/src/features/badge/components/ConditionElementSelector.vue'; - +import { computed, ComputedRef } from 'vue'; +import ConditionElementSelector from './ConditionElementSelector.vue'; +import ConditionValue from './ConditionValue.vue'; +import { getVerbs, getValueType } from '@/src/shared/services'; +import { Condition, ElementType } from '@/src/shared/interfaces'; +import { getElementType } from '@/src/shared/services/graph'; const props = defineProps<{ - inputValue: any; + inputValue: Condition; conditionIndex: number; }>(); const emit = defineEmits<{ (e: 'removeCondition'): void; + (e: 'updateCondition', values: { value: string, key: string }): void; }>(); -const conditions = [ - 'Vue', - 'Plus que', - 'Moins que', - 'Egal à', -]; -const logicChoice: Ref<'and' | 'or'> = ref('and'); +// const logicChoice: Ref<'and' | 'or'> = ref('and'); + +const currentCondition: ComputedRef<Condition> = computed(() => { + return { + element: props.inputValue.element || '', + verb: props.inputValue.verb || '', + value: props.inputValue.value || '' + }; +}); + + +const verbDisabled: ComputedRef<boolean> = computed(() => { + return !currentCondition.value.element; +}); + +const valueDisabled: ComputedRef<boolean> = computed(() => { + return !currentCondition.value.element || !currentCondition.value.verb; +}); + +const elementType: ComputedRef<ElementType> = computed(() => { + if(!currentCondition.value.element) return null; + return getElementType(currentCondition.value.element); +}); function removeCondition() { emit('removeCondition'); } -const currentCondition = ref({ - element: props.inputValue.element || '', - condition: props.inputValue.condition || '', - value: props.inputValue.value || '', -}); +function updateCondition(value: string, key: string) { + emit('updateCondition', { value, key }); +} </script> @@ -37,30 +55,46 @@ const currentCondition = ref({ <div v-if="conditionIndex !== 0" class="gap"></div> <article> <i class="icon-supprimer delete" @click.stop="removeCondition"></i> - <div v-if="conditionIndex !== 0" class="logic-condition"> + <div class="logic-condition"> + <button class="logic-choice active"> + ET + </button> + </div> + <!-- Condition switch --> + <!-- <div v-if="conditionIndex !== 0" class="logic-condition"> <button class="logic-choice" :class="{ 'active' : logicChoice === 'and'}" @click="logicChoice = 'and'"> ET </button> <button class="logic-choice" :class="{ 'active' : logicChoice === 'or'}" @click="logicChoice = 'or'"> OU </button> - </div> - <ConditionElementSelector - :input-value="currentCondition.element" - :index="conditionIndex" - /> - <div class="select"> - Condition - <select id="condition"> - <option v-for="(condition, index) of conditions" :key="index" :value="condition">{{ condition }}</option> - </select> - </div> - <div class="select"> - Valeur - <select id="value"> - <option value="true">True</option> - <option value="false">False</option> - </select> + </div> --> + <div class="grid-container"> + <ConditionElementSelector + :input-value="currentCondition.element" + :index="conditionIndex" + class="grid-item" + /> + <div class="select"> + Condition + <select + id="condition" + :disabled="verbDisabled" + class="grid-item" + :value="currentCondition.verb" + @change="updateCondition(($event.target as HTMLSelectElement).value, 'verb')" + > + <option default value="">Veuillez choisir</option> + <option v-for="(description, verb) in getVerbs(elementType)" :key="verb" :value="verb">{{ description.label }}</option> + </select> + </div> + <ConditionValue + :disabled="valueDisabled" + :input-value="currentCondition.value" + :value-type="valueDisabled ? null : getValueType(currentCondition.verb)" + class="grid-item" + @change="updateCondition($event, 'value')" + /> </div> </article> </template> @@ -90,13 +124,15 @@ const currentCondition = ref({ padding: .5rem .75rem; color: var(--inria-grey); - &:first-child { - border-right: none; - border-radius: 8px 0 0 8px; - } - &:last-child { - border-radius: 0 8px 8px 0; - } + border-radius: 8px; + + // &:first-child { + // border-right: none; + // border-radius: 8px 0 0 8px; + // } + // &:last-child { + // border-radius: 0 8px 8px 0; + // } &.active { background: var(--inria-grey); @@ -138,6 +174,17 @@ article { background-repeat: no-repeat; background-position: right 0.7rem top 50%; background-size: .8rem auto; + + &:disabled { + cursor: not-allowed; + } } } + +.grid-container { + display: grid; + width: 100%; + grid-template-columns: 1fr 1fr 1fr; + grid-gap: 1rem; +} </style> \ No newline at end of file diff --git a/src/features/badge/components/ConditionModal.vue b/src/features/badge/components/ConditionModal.vue index 8018cfa3..546d579a 100644 --- a/src/features/badge/components/ConditionModal.vue +++ b/src/features/badge/components/ConditionModal.vue @@ -1,23 +1,44 @@ <script setup lang="ts"> -import { ref } from 'vue'; import ConditionItem from './ConditionItem.vue'; import { useEditorStore } from '@/src/shared/stores'; +import { getConditions, createRule } from '@/src/shared/services'; +import { computed } from 'vue'; const editorStore = useEditorStore(); const currentBadge = editorStore.getEpocNode.data.formValues['badges'][editorStore.openedBadgeId]; -const localConditions = ref(currentBadge.conditions); + +const allConditionsValid = computed(() => { + return editorStore.tempConditions.every((condition) => { + return condition.element && condition.verb && condition.value; + }); +}); + +if(!editorStore.editingConditions) { + getConditions(currentBadge); + editorStore.editingConditions = true; +} + function addCondition() { - localConditions.value.push({}); + editorStore.tempConditions.push({}); } function close() { + editorStore.tempConditions = []; editorStore.conditionModal = false; + editorStore.editingConditions = false; +} + + +function updateCondition(values: { value: string, key: string }, index: number) { + const { value, key } = values; + editorStore.tempConditions[index][key] = value; } function save() { - currentBadge.conditions = localConditions.value; + currentBadge.rule = createRule(editorStore.tempConditions); + close(); } @@ -34,11 +55,12 @@ function save() { </header> <div class="content conditions"> <ConditionItem - v-for="(condition, index) in localConditions" + v-for="(condition, index) in editorStore.tempConditions" :key="index" :input-value="condition" :condition-index="index" - @remove-condition="localConditions.splice(index, 1)" + @remove-condition="editorStore.tempConditions.splice(index, 1)" + @update-condition="updateCondition($event, index)" /> <button class="add btn btn-form" @click="addCondition"> <i class="icon-plus"></i> @@ -49,7 +71,7 @@ function save() { <footer> <div class="content"> <button class="btn-choice cancel" @click="close">ANNULER</button> - <button class="btn-choice save" @click="save">ENREGISTRER</button> + <button :disabled="!allConditionsValid" class="btn-choice save" @click="save">ENREGISTRER</button> </div> </footer> </article> @@ -146,6 +168,12 @@ h3 { &.save{ background-color: #E93100; color: #fff; + + &:disabled { + cursor: not-allowed; + filter:opacity(40%); + } } + } </style> \ No newline at end of file diff --git a/src/features/badge/components/ConditionValue.vue b/src/features/badge/components/ConditionValue.vue new file mode 100644 index 00000000..1204a8b3 --- /dev/null +++ b/src/features/badge/components/ConditionValue.vue @@ -0,0 +1,91 @@ +<script setup lang="ts"> + +defineProps<{ + disabled: boolean; + valueType?: 'boolean' | 'number'; + inputValue: string | boolean | number; +}>(); + +const emit = defineEmits<{ + (e: 'change', value: string): void; +}>(); + +function onChange(event: Event) { + emit('change', (event.target as HTMLSelectElement).value); +} + +</script> + +<template> + <div class="select"> + Valeur + <select + v-if="valueType === 'boolean'" + id="value" + :value="inputValue" + :disabled="disabled" + @change="onChange" + > + <option default value="">Veuillez choisir</option> + <option value="true">True</option> + <option value="false">False</option> + </select> + <input + v-else-if="valueType === 'number'" + id="value" + :value="inputValue" + :disabled="disabled" + type="number" + class="number-input" + @change="onChange" + > + <input + v-else + type="text" + class="disabled-input" + disabled + > + </div> +</template> + +<style scoped lang="scss"> +.select { + display: flex; + flex-direction: column; + flex-grow: 1; + margin-top: .75rem; + + label { + margin-bottom: 0.5rem; + } + + select, .disabled-input, .number-input { + appearance: none; + padding: .5rem; + border: 1px solid var(--border); + border-radius: 4px; + background-color: var(--item-background); + cursor: pointer; + font-size: 1rem; + color: var(--text); + + &:disabled { + cursor: not-allowed; + } + } + + input::-webkit-outer-spin-button, + input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } + + select { + background-image: url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIHZlcnNpb249IjEuMSIgd2lkdGg9IjExcHgiIGhlaWdodD0iN3B4IiB2aWV3Qm94PSIwIDAgMTEuMCA3LjAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiPjxkZWZzPjxjbGlwUGF0aCBpZD0iaTAiPjxwYXRoIGQ9Ik0yNDE4LDAgTDI0MTgsMjQyNiBMMCwyNDI2IEwwLDAgTDI0MTgsMCBaIj48L3BhdGg+PC9jbGlwUGF0aD48Y2xpcFBhdGggaWQ9ImkxIj48cGF0aCBkPSJNOS4yMDE3MjIyNywwIEM5LjYxNjUwNDA2LDAgOS45OTA4OTcyMSwwLjI3MzU1MDk4NyAxMC4xNDk4ODU5LDAuNjk0MzM1OTM3IEMxMC4zMDg4NzQ2LDEuMTE1MTIwODkgMTAuMjQ5ODk0OSwxLjU5OTYwOTM3IDkuOTU0OTk2NjMsMS45MTk1MzE0NiBMNS44ODA5MDQ1NSw2LjQxOTUzMTQ2IEM1LjY1MzMxOTM0LDYuNjQxMDE1NDEgNS4zOTA0NzQ3Niw2Ljc1IDUuMTI3NjMwMTksNi43NSBDNC44NjQ3ODU2Miw2Ljc1IDQuNjAyNTgxNzgsNi42NDAxMzY3MiA0LjQwMjI0Mjg2LDYuNDIwNDEwMTYgTDAuMzI4MTUwMjkxLDEuOTIwNDEwMTYgQzAuMDA2MTQ2NTU1MTksMS41OTk2MDkzNyAtMC4wODE2OTQ5MjIzLDEuMTE0NDUzMDIgMC4wNzcxMDE1NTQ3LDAuNjk2MDkzODU3IEMwLjIzNTg5ODAzMiwwLjI3NzczNDY5NyAwLjYxMDIyNzEwNiwwIDEuMDI0Njg4NTMsMCBMOS4yMDE3MjIyNywwIFoiPjwvcGF0aD48L2NsaXBQYXRoPjwvZGVmcz48ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTMwNC4wIC0xMDYyLjApIj48ZyBjbGlwLXBhdGg9InVybCgjaTApIj48ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSg5ODAuMCA5MC4wKSI+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMjAuMCA3MjkuMCkiPjxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKDAuMCA1MS4wKSI+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMTUuMCAxNTAuMCkiPjxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKDAuMCAyNC4wKSI+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMjg5LjM3MjM2OTgwNjgwMSAxOC4wKSI+PGcgY2xpcC1wYXRoPSJ1cmwoI2kxKSI+PHBvbHlnb24gcG9pbnRzPSItMS4xMTAyMjMwMmUtMTYsMCAxMC4yMzYwMTA0LDAgMTAuMjM2MDEwNCw2Ljc1IC0xLjExMDIyMzAyZS0xNiw2Ljc1IC0xLjExMDIyMzAyZS0xNiwwIiBzdHJva2U9Im5vbmUiIGZpbGw9IiMzNTQyNTgiPjwvcG9seWdvbj48L2c+PC9nPjwvZz48L2c+PC9nPjwvZz48L2c+PC9nPjwvZz48L3N2Zz4="); + background-repeat: no-repeat; + background-position: right 0.7rem top 50%; + background-size: .8rem auto; + } +} + +</style> \ No newline at end of file diff --git a/src/features/forms/components/inputs/badges/BadgesInput.vue b/src/features/forms/components/inputs/badges/BadgesInput.vue index 0ae6e18b..9fd67a6e 100644 --- a/src/features/forms/components/inputs/badges/BadgesInput.vue +++ b/src/features/forms/components/inputs/badges/BadgesInput.vue @@ -2,9 +2,9 @@ import BadgeItem from '@/src/features/badge/components/BadgeItem.vue'; import AddBadge from './components/AddBadge.vue'; import { useEditorStore } from '@/src/shared/stores/editorStore'; -import { Badge } from '@/src/shared/interfaces'; import { computed, ComputedRef } from 'vue'; import { generateContentId } from '@/src/shared/services'; +import { Badge } from '@/src/shared/interfaces'; const editorStore = useEditorStore(); @@ -22,9 +22,9 @@ const badges: ComputedRef<Badge[]> = computed(() => { const newBadge: Badge = { id: value, title: props.inputValue[value]['title'], - icon: props.inputValue[value]['icon'], - conditions: props.inputValue[value]['conditions'], description: props.inputValue[value]['description'], + icon: props.inputValue[value]['icon'], + rule: props.inputValue[value]['rule'], }; res.push(newBadge); } @@ -40,8 +40,10 @@ function addNewBadge() { epocNode.data.formValues['badges'][id] = { title: '', icon: '', - conditions: [], description: '', + rule: { + 'and': [] + } }; openBadge(id); } @@ -58,7 +60,7 @@ function addNewBadge() { v-for="(badge, index) in badges" :key="index" :badge="badge" - :invalid="badge.conditions.length === 0" + :invalid="Object.keys(badge.rule.and).length === 0" @click="openBadge(badge.id)" /> </div> diff --git a/src/shared/classes/epoc-v1.ts b/src/shared/classes/epoc-v1.ts index 84e5976c..5f0d5b61 100644 --- a/src/shared/classes/epoc-v1.ts +++ b/src/shared/classes/epoc-v1.ts @@ -1,6 +1,6 @@ -import { Chapter, Content, Epoc, html, Parameters, uid } from '@epoc/epoc-types/dist/v1'; -import { Question } from '@epoc/epoc-types/dist/v1/question'; -import { Author } from '@epoc/epoc-types/dist/v1/author'; +import { Chapter, Content, Epoc, html, Parameters, uid } from '@epoc/epoc-types/src/v1'; +import { Question } from '@epoc/epoc-types/src/v1/question'; +import { Author } from '@epoc/epoc-types/src/v1/author'; export class EpocV1 implements Epoc { id: string; diff --git a/src/shared/interfaces/badge.interface.ts b/src/shared/interfaces/badge.interface.ts index a73a21ac..66b7173d 100644 --- a/src/shared/interfaces/badge.interface.ts +++ b/src/shared/interfaces/badge.interface.ts @@ -1,8 +1,26 @@ +import { Rule, uid } from '@epoc/epoc-types/src/v2'; export interface Badge { - id: string; - title: string; - description: string; - icon: string; - conditions: any; -} \ No newline at end of file + id?: uid; + title: string; + description: string; + icon: string; + rule: Rule +} + +export interface Condition { + element?: string; + verb?: string; + value?: string | number | boolean; + elementType?: 'contents' | 'chapters'; +} + +export interface VerbDescription { + label: string; + valueType: 'number' | 'boolean'; +} + +export type ElementType = 'chapter' | 'page' | 'text' | 'video' | 'audio' | 'activity' | 'question'; +export type VerbKey = 'started' | 'completed' | 'viewed' | 'read' | 'played' | 'watched' | 'listened' | 'attempted' | 'scored'; + +export type Verbs = { [key in VerbKey]?: VerbDescription; } \ No newline at end of file diff --git a/src/shared/services/badge.service.ts b/src/shared/services/badge.service.ts new file mode 100644 index 00000000..4cbb2ade --- /dev/null +++ b/src/shared/services/badge.service.ts @@ -0,0 +1,83 @@ +import { useEditorStore } from '@/src/shared/stores'; +import { Condition } from '@/src/shared/interfaces'; +import { Verbs, VerbKey, ElementType } from '@/src/shared/interfaces/badge.interface'; +import { Operand, Operands, Rule } from '@epoc/epoc-types/src/v2'; + +const verbs: Verbs = { + 'started': { label: 'Commencé', valueType: 'boolean' }, + 'completed': { label: 'Terminé', valueType: 'boolean' }, + 'viewed': { label: 'Vu', valueType: 'boolean' }, + 'read': { label: 'Lu', valueType: 'boolean' }, + 'played': { label: 'Joué', valueType: 'boolean' }, + 'watched': { label: 'Regardé', valueType: 'boolean' }, + 'listened': { label: 'Écouté', valueType: 'boolean' }, + 'attempted': { label: 'Tenté', valueType: 'boolean' }, + 'scored': { label: 'Obtenu un score de', valueType: 'number' } +}; + +const elementVerbs: Record<ElementType, VerbKey[]> = { + 'chapter': ['started', 'completed'], + 'page': ['viewed'], + 'text': ['read'], + 'video': ['played', 'watched'], + 'audio': ['played', 'listened'], + 'activity': ['started', 'completed', 'scored'], + 'question': ['attempted', 'completed', 'scored'] +}; + +export function getVerbs(type: ElementType): Verbs { + if(!type || !elementVerbs[type]) return; + + const verbsKeys = elementVerbs[type]; + const res: Verbs = {}; + + for (const key of verbsKeys) { + res[key] = verbs[key]; + } + + return res; +} + +export function getValueType(verbKey:string): 'number' | 'boolean' { + if(!verbs[verbKey]) return; + return verbs[verbKey].valueType; +} + + +export function getConditions(currentBadge): void { + const editorStore = useEditorStore(); + const rules = currentBadge.rule['and']; + + for(const key1 in rules) { + // key2 is the operator + for(const key2 in rules[key1]) { + const [verbs, value] = rules[key1][key2]; + + const [type, element,verb] = verbs['var'].split('.'); + + console.log('type detected'); + + const conditionObj: Condition = { + element, + verb, + value, + elementType: type + }; + + editorStore.tempConditions.push(conditionObj); + } + } +} + +export function createRule(entry: Condition[]): Rule { + const rules: Operands = entry.map((item) => { + const entryRule: Operand = { + '===': [ + { 'var': `${item.elementType}.${item.element}.${item.verb}` }, item.value + ] + }; + return entryRule; + }) as Operands; + + return { 'and': rules }; +} \ No newline at end of file diff --git a/src/shared/services/graph.service.ts b/src/shared/services/graph.service.ts index afdc9286..d6576411 100644 --- a/src/shared/services/graph.service.ts +++ b/src/shared/services/graph.service.ts @@ -1,15 +1,15 @@ import { ApiInterface } from '@/src/shared/interfaces/api.interface'; import { getConnectedEdges, GraphNode, useVueFlow } from '@vue-flow/core'; import { EpocV1 } from '@/src/shared/classes/epoc-v1'; -import {Assessment, ChoiceCondition, Content, Html, SimpleQuestion, uid, Video, Audio} from '@epoc/epoc-types/dist/v1'; -import { Question } from '@epoc/epoc-types/dist/v1/question'; +import { Assessment, ChoiceCondition, Content, Html, SimpleQuestion, uid, Video, Audio } from '@epoc/epoc-types/src/v1'; import {questions} from '@/src/shared/data'; import { useEditorStore } from '@/src/shared/stores'; -import { getContentIdFromId } from '@/src/shared/services/graph'; +import { changeChapterSelectability, getContentIdFromId } from '@/src/shared/services/graph'; +import { Question } from '@epoc/epoc-types/src/v2'; declare const api: ApiInterface; -const { toObject, nodes, edges } = useVueFlow({ id: 'main' }); +const { nodes, edges, findNode, toObject } = useVueFlow({ id: 'main' }); function writeProjectData(): void { debounceFunction(500, () => { @@ -37,8 +37,6 @@ const debounceFunction = function (delay, cb) { timerId = setTimeout(cb, delay); }; - - function createContentJSON() : EpocV1 { const ePocNode = nodes.value.find((node) => { return node.type === 'epoc'; }); @@ -253,28 +251,30 @@ let openedConditionIndex: number | null = null; export function enterSelectNodeMode(conditionIndex: number): void { const editorStore = useEditorStore(); editorStore.enterSelectNodeMode(); + changeChapterSelectability(true); disableGraph(); openedConditionIndex = conditionIndex; } +function getElementType(id: string): 'contents' | 'chapters' { + const node = findNode(id); + if(!node || node.type !== 'chapter' ) return 'contents'; + else return 'chapters'; +} + export function exitSelectNodeMode(selectedId?: string): void { const editorStore = useEditorStore(); editorStore.exitSelectNodeMode(); - - const epocNode = editorStore.getEpocNode; - const currentBadge = editorStore.openedBadgeId; - - const conditions = epocNode.data.formValues['badges'][currentBadge].conditions; - - conditions[openedConditionIndex].element = getContentIdFromId(selectedId); + changeChapterSelectability(false); + + editorStore.tempConditions[openedConditionIndex].elementType = getElementType(selectedId); + editorStore.tempConditions[openedConditionIndex].element = getContentIdFromId(selectedId); enableGraph(); - openedConditionIndex = null; } - function disableGraph(): void { nodes.value.forEach(node => node.draggable = false); edges.value.forEach(edge => { diff --git a/src/shared/services/graph/content.service.ts b/src/shared/services/graph/content.service.ts index 488236cc..c856f8c1 100644 --- a/src/shared/services/graph/content.service.ts +++ b/src/shared/services/graph/content.service.ts @@ -5,7 +5,7 @@ import { deleteNode } from './node.service'; import {generateContentId, generateId} from '../graph.service'; import * as forms from '@/src/shared/data/forms'; -const { findNode } = useVueFlow({ id: 'main' }); +const { nodes, findNode } = useVueFlow({ id: 'main' }); const editorStore = useEditorStore(); @@ -99,4 +99,16 @@ export function getContentDefaultValues(type) { }, {}); return {...acc, ...keyValues}; }, {}); +} + +export function getContentByContentId(contentId: string) { + for(const node of nodes.value) { + if(node.data.elements) { + for(const element of node.data.elements) { + if(element.contentId === contentId) { + return element; + } + } + } + } } \ No newline at end of file diff --git a/src/shared/services/graph/copyPaste.service.ts b/src/shared/services/graph/copyPaste.service.ts new file mode 100644 index 00000000..c7e7e150 --- /dev/null +++ b/src/shared/services/graph/copyPaste.service.ts @@ -0,0 +1,92 @@ +import { ApiInterface } from '@/src/shared/interfaces/api.interface'; +import { getSelectedNodes, alignNode } from '.'; +import { useVueFlow, Node } from '@vue-flow/core'; +import { generateId, generateContentId } from '../graph.service'; + +const { addNodes } = useVueFlow({ id: 'main' }); + +declare const api: ApiInterface; + +function setupCopyPaste(): void { + api.receive('graphPasted', (data: string) => { + const parsedData = JSON.parse(data); + const { selectedPages, position } = parsedData; + handleGraphPaste(selectedPages, position); + }); +} + +setupCopyPaste(); + +export function graphCopy(selection?: Node[]): void { + if(!selection) selection = getSelectedNodes(); + const selectedPages = selection.filter(node => node.type === 'page' || node.type === 'activity'); + + const data = JSON.stringify({ pages: selectedPages }); + + api.send('graphCopy', data); +} + +export function graphPaste(position?: { x: number, y: number }) { + const data = JSON.stringify({ position }); + api.send('graphPaste', data); +} + +function handleGraphPaste(selectedPages, position: { x: number, y: number }): void { + if(!selectedPages) return; + + let offsetX; + let offsetY; + + if(position) { + offsetX = position.x - selectedPages[0].position.x; + offsetY = position.y - selectedPages[0].position.y; + } else { + offsetX = 100; + offsetY = 100; + } + + const newPages = []; + for(const page of selectedPages) { + const pageId = generateId(); + + const elements = page.data.elements.map(element => { + const newElement = JSON.parse(JSON.stringify(element)); + newElement.id = generateId(); + newElement.parentId = pageId; + return newElement; + }); + + const newPage: Node = { + id: pageId, + type: page.type, + data: { + type: page.data.type, + elements, + contentId: generateContentId(), + formType: page.data.formType, + formValues: JSON.parse(JSON.stringify(page.data.formValues)), + }, + position: { x: page.position.x + offsetX, y: page.position.y + offsetY }, + deletable: true + }; + newPages.push(newPage); + } + + // deselect the old pages + for(const page of selectedPages) { + page.selected = false; + } + + // select the new pages + for(const page of newPages) { + page.selected = true; + } + // automatically copy the new pages so that they can be pasted again without superposition + // api.send('graphCopy', { selectedPages: newPages, selectionRectHeight: offsetY }); + + addNodes(newPages); + + for(const page of newPages) { + alignNode(page.id); + } +} \ No newline at end of file diff --git a/src/shared/services/graph/element.service.ts b/src/shared/services/graph/element.service.ts new file mode 100644 index 00000000..0bcb507d --- /dev/null +++ b/src/shared/services/graph/element.service.ts @@ -0,0 +1,95 @@ +import { useVueFlow, Node, MarkerType, Edge } from '@vue-flow/core'; +import { deleteContent, deleteNode, getContentByContentId } from './'; +import { useEditorStore } from '@/src/shared/stores'; +import { generateId } from '../graph.service'; +import { ElementType, NodeElement } from '../../interfaces'; + +const { nodes, findNode, addEdges } = useVueFlow({ id: 'main' }); +const editorStore = useEditorStore(); + +export function deleteSelection(selection: Node[]) { + selection.forEach(node => deleteElement(node.id)); +} + +export function deleteElement(id: string, pageId?: string): void { + const pageToDelete = findNode(id); + + if(pageId || !pageToDelete) deleteContent(pageId ?? editorStore.openedNodeId, id); + else deleteNode(id); + + editorStore.closeFormPanel(); +} + +export function createEdge(sourceId: string, targetId: string): void { + const newEdge: Edge = { + id: generateId(), + source: sourceId, + target: targetId, + type: 'default', + updatable: true, + style: {stroke: '#384257', strokeWidth: 2.5}, + markerEnd: {type: MarkerType.ArrowClosed, color: '#384257'} + }; + + addEdges([newEdge]); +} + +export function getContentIdFromId(id: string): string { + if(id === '1') return 'ePoc'; + const node = findNode(id); + let contentId; + if(!node) { + for(const node of nodes.value) { + if(node.data.elements) { + for(const element of node.data.elements) { + if(element.id === id) contentId = element.contentId; + } + } + } + } else { + contentId = node.data.contentId; + } + + return contentId; +} + +export function getElementByContentId(contentId: string): Node | NodeElement { + const node = nodes.value.find(node => node.data.contentId === contentId); + + if(!node) { + for(const node of nodes.value) { + if(node.data.elements) { + for(const element of node.data.elements) { + if(element.contentId === contentId) { + return element; + } + } + } + } + } + + return node; +} + + +const questions = ['choice', 'drag-and-drop', 'reorder', 'swipe', 'dropdown-list']; +function getContentType(contentId: string): ElementType { + const content = getContentByContentId(contentId); + + if(!content) return; + if(questions.includes(content.action.type)) return 'question'; + else return content.action.type; +} + + +export function getElementType(contentId: string): ElementType { + if(contentId === 'ePoc') return; + + const node = nodes.value.find(node => node.data.contentId === contentId); + + if(!node) { + return getContentType(contentId); + } + + return node.type as ElementType; +} \ No newline at end of file diff --git a/src/shared/services/graph/index.ts b/src/shared/services/graph/index.ts index e77bda7e..b735ddb1 100644 --- a/src/shared/services/graph/index.ts +++ b/src/shared/services/graph/index.ts @@ -1,2 +1,4 @@ export * from './content.service'; -export * from './node.service'; \ No newline at end of file +export * from './node.service'; +export * from './element.service'; +export * from './copyPaste.service'; \ No newline at end of file diff --git a/src/shared/services/graph/node.service.ts b/src/shared/services/graph/node.service.ts index a7d1d905..e63eed39 100644 --- a/src/shared/services/graph/node.service.ts +++ b/src/shared/services/graph/node.service.ts @@ -1,15 +1,15 @@ -import { Chapter } from '@epoc/epoc-types/dist/v1'; +import { Chapter } from '@epoc/epoc-types/src/v1'; import { EpocV1 } from '../../classes/epoc-v1'; import { useEditorStore, useGraphStore } from '../../stores'; -import { useVueFlow, Node, MarkerType, Edge, getConnectedEdges } from '@vue-flow/core'; +import { useVueFlow, Node, getConnectedEdges } from '@vue-flow/core'; import { NodeElement, SideAction } from '../../interfaces'; import { nextTick, toRaw, watch } from 'vue'; -import { addContentToPage, deleteContent } from './content.service'; +import { addContentToPage } from './content.service'; import { generateContentId, generateId, graphService } from '../graph.service'; -import { ApiInterface } from '../../interfaces/api.interface'; +import { deleteElement, deleteSelection, createEdge } from '.'; -const { nodes, edges, addNodes, addEdges, findNode, applyEdgeChanges, applyNodeChanges } = useVueFlow({ id: 'main' }); +const { nodes, edges, addNodes, findNode, applyEdgeChanges, applyNodeChanges } = useVueFlow({ id: 'main' }); const editorStore = useEditorStore(); @@ -212,13 +212,10 @@ export function insertAtEnd(chapterId: string, action: SideAction): void { nextNode = graphService.getNextNode(findNode(nextNode.id)); } - console.log('finalNode', savedId); - insertAfter(savedId, action); } export function insertAtStart(chapterId: string, action: SideAction): void { - console.log('insertAtStart'); const nextNode = graphService.getNextNode(findNode(chapterId)); if(nextNode) insertBefore(nextNode.id, action); @@ -255,20 +252,6 @@ export function createPageFromContent(position: { x: number, y: number }, elemen }); } -export function createEdge(sourceId: string, targetId: string): void { - const newEdge: Edge = { - id: generateId(), - source: sourceId, - target: targetId, - type: 'default', - updatable: true, - style: {stroke: '#384257', strokeWidth: 2.5}, - markerEnd: {type: MarkerType.ArrowClosed, color: '#384257'} - }; - - addEdges([newEdge]); -} - export function deleteSelectedNodes(): void { const isChild = Boolean(editorStore.openedNodeId); @@ -283,9 +266,6 @@ export function deleteSelectedNodes(): void { editorStore.closeValidationModal(); } -export function deleteSelection(selection: Node[]) { - selection.forEach(node => deleteElement(node.id)); -} export function deleteNode(nodeId: string): void { const nodeToDelete = findNode(nodeId); @@ -294,14 +274,6 @@ export function deleteNode(nodeId: string): void { if(nodeToDelete.type === 'chapter') updateNextChapter(nodeToDelete.id); } -export function deleteElement(id: string, pageId?: string): void { - const pageToDelete = findNode(id); - - if(pageId || !pageToDelete) deleteContent(pageId ?? editorStore.openedNodeId, id); - else deleteNode(id); - - editorStore.closeFormPanel(); -} export function duplicatePage(pageId?: string): void { const pageNode = findNode(pageId ?? editorStore.openedElementId); @@ -408,118 +380,17 @@ export function isFormButtonDisabled(isDisabledFunction: (node) => boolean): boo return isDisabledFunction(nodeData); } -export function unselectAllNodes(): void { - nodes.value.forEach(node => node.selected = false); -} - - -// Copy/Paste -declare const api: ApiInterface; - -function setupCopyPaste(): void { - api.receive('graphPasted', (data: string) => { - const parsedData = JSON.parse(data); - const { selectedPages, position } = parsedData; - handleGraphPaste(selectedPages, position); - }); -} - -setupCopyPaste(); - -export function graphCopy(selection?: Node[]): void { - if(!selection) selection = getSelectedNodes(); - const selectedPages = selection.filter(node => node.type === 'page' || node.type === 'activity'); - - const data = JSON.stringify({ pages: selectedPages }); - - api.send('graphCopy', data); -} - -export function graphPaste(position?: { x: number, y: number }) { - const data = JSON.stringify({ position }); - api.send('graphPaste', data); -} - -function handleGraphPaste(selectedPages, position: { x: number, y: number }): void { - if(!selectedPages) return; - - let offsetX; - let offsetY; - - if(position) { - offsetX = position.x - selectedPages[0].position.x; - offsetY = position.y - selectedPages[0].position.y; - } else { - offsetX = 100; - offsetY = 100; - } - - const newPages = []; - for(const page of selectedPages) { - const pageId = generateId(); - - const elements = page.data.elements.map(element => { - const newElement = JSON.parse(JSON.stringify(element)); - newElement.id = generateId(); - newElement.parentId = pageId; - return newElement; - }); - - const newPage: Node = { - id: pageId, - type: page.type, - data: { - type: page.data.type, - elements, - contentId: generateContentId(), - formType: page.data.formType, - formValues: JSON.parse(JSON.stringify(page.data.formValues)), - }, - position: { x: page.position.x + offsetX, y: page.position.y + offsetY }, - deletable: true - }; - newPages.push(newPage); - } - - // deselect the old pages - for(const page of selectedPages) { - page.selected = false; - } - - // select the new pages - for(const page of newPages) { - page.selected = true; +export function changeChapterSelectability(value: boolean): void { + for(const node of nodes.value) { + if(node.type === 'chapter') node.selectable = value; } - // automatically copy the new pages so that they can be pasted again without superposition - // api.send('graphCopy', { selectedPages: newPages, selectionRectHeight: offsetY }); +} - addNodes(newPages); - - for(const page of newPages) { - alignNode(page.id); - } +export function unselectAllNodes(): void { + nodes.value.forEach(node => node.selected = false); } export function deleteBadge(id: string) { const epocNode = findNode('1'); delete epocNode.data.formValues.badges[id]; -} - -export function getContentIdFromId(id: string): string { - if(id === '1') return 'ePoc'; - const node = findNode(id); - let contentId; - if(!node) { - for(const node of nodes.value) { - if(node.data.elements) { - for(const element of node.data.elements) { - if(element.id === id) contentId = element.contentId; - } - } - } - } else { - contentId = node.data.contentId; - } - - return contentId; } \ No newline at end of file diff --git a/src/shared/services/import.service.ts b/src/shared/services/import.service.ts index d5da0c09..830d218c 100644 --- a/src/shared/services/import.service.ts +++ b/src/shared/services/import.service.ts @@ -2,7 +2,7 @@ import { EpocV1 } from '@/src/shared/classes/epoc-v1'; import { addChapter, createLinkedPage, setEpocNodeData } from '@/src/shared/services/graph'; import { generateId } from '@/src/shared/services/graph.service'; import { questions, standardPages } from '@/src/shared/data'; -import { Assessment, ChoiceCondition, SimpleQuestion } from '@epoc/epoc-types/dist/v1'; +import { Assessment, ChoiceCondition, SimpleQuestion } from '@epoc/epoc-types/src/v1'; const mapType = { 'video': standardPages.find(s => s.type === 'video'), diff --git a/src/shared/services/index.ts b/src/shared/services/index.ts index 2731ec0d..c76024e2 100644 --- a/src/shared/services/index.ts +++ b/src/shared/services/index.ts @@ -1,2 +1,3 @@ export * from './editor.service'; -export * from './graph.service'; \ No newline at end of file +export * from './graph.service'; +export * from './badge.service'; \ No newline at end of file diff --git a/src/shared/stores/editorStore.ts b/src/shared/stores/editorStore.ts index 763190aa..8acdad17 100644 --- a/src/shared/stores/editorStore.ts +++ b/src/shared/stores/editorStore.ts @@ -1,7 +1,6 @@ import { defineStore } from 'pinia'; -import { ePocProject, Form, NodeElement, PageModel, SideAction} from '@/src/shared/interfaces'; +import { ePocProject, Form, NodeElement, PageModel, SideAction, Condition } from '@/src/shared/interfaces'; import { GraphNode, useVueFlow } from '@vue-flow/core'; - import { formsModel, questions, standardPages } from '@/src/shared/data'; const { nodes, findNode } = useVueFlow({ id: 'main' }); @@ -51,6 +50,8 @@ interface EditorState { // Mode selectNodeMode: boolean; + tempConditions: Condition[]; + editingConditions: boolean; } export const useEditorStore = defineStore('editor', { @@ -88,6 +89,8 @@ export const useEditorStore = defineStore('editor', { // Mode selectNodeMode: false, + tempConditions: [], + editingConditions: false, }), getters: { -- GitLab