Skill: Start rewriting the skill wheel in Vue

pull/5964/head
Angel Fernando Quiroz Campos 12 months ago
parent 9b1ad1e632
commit c4b1f64463
No known key found for this signature in database
GPG Key ID: B284841AE3E562CD
  1. 3
      assets/vue/components/basecomponents/ChamiloIcons.js
  2. 84
      assets/vue/components/skill/SkillProfileDialog.vue
  3. 47
      assets/vue/components/skill/SkillProfileMatches.vue
  4. 30
      assets/vue/components/skill/SkillWheelGraph.vue
  5. 132
      assets/vue/components/skill/SkillWheelProfileList.vue
  6. 285
      assets/vue/composables/skill/skillWheel.js
  7. 2
      assets/vue/router/index.js
  8. 18
      assets/vue/router/skill.js
  9. 47
      assets/vue/services/skillProfileService.js
  10. 18
      assets/vue/services/skillService.js
  11. 180
      assets/vue/views/skill/SkillWheel.vue
  12. 1
      src/CoreBundle/Controller/IndexController.php

@ -125,4 +125,7 @@ export const chamiloIconToClass = {
"add-event-reminder": "mdi mdi-alarm-plus",
"session-star": "mdi mdi-star",
"next": "mdi mdi-arrow-right-bold-box",
"crosshairs": "mdi mdi-crosshairs",
"square": "mdi mdi-square",
"wheel": "mdi mdi-tire",
};

@ -0,0 +1,84 @@
<script setup>
import { computed, reactive, unref } from "vue"
import { useI18n } from "vue-i18n"
import BaseTextArea from "../basecomponents/BaseTextArea.vue"
import BaseButton from "../basecomponents/BaseButton.vue"
import BaseDialog from "../basecomponents/BaseDialog.vue"
import BaseInputText from "../basecomponents/BaseInputText.vue"
import { createProfile, updateProfile } from "../../services/skillProfileService"
const { t } = useI18n()
const profile = reactive({
title: "",
description: "",
})
const isVisible = defineModel("visible", {
required: true,
type: Boolean,
})
const skills = defineModel("skills", {
type: Array,
required: false,
})
const emit = defineEmits(["saved"])
async function saveProfile() {
if (profile["@id"]) {
await updateProfile({
iri: profile["@id"],
title: profile.title,
description: profile.description,
})
} else {
await createProfile({
title: profile.title,
description: profile.description,
skills: skills.value.map((skill) => ({ skill: skill["@id"] })),
})
}
isVisible.value = false
emit("saved", unref(profile))
}
</script>
<template>
<BaseDialog
:title="t('Skill profile')"
v-model:is-visible="isVisible"
>
<BaseInputText
id="name_profile"
v-model="profile.title"
:label="t('Title')"
/>
<BaseTextArea
v-model="profile.description"
:label="t('Description')"
/>
<template #footer>
<BaseButton
:label="t('Cancel')"
icon="close"
type="black"
@click="isVisible = false"
/>
<BaseButton
:label="t('Save')"
icon="save"
type="primary"
@click="saveProfile"
/>
<pre>{{ skillIdList }}</pre>
</template>
</BaseDialog>
</template>

@ -0,0 +1,47 @@
<script setup>
import { ref } from "vue"
import Skeleton from "primevue/skeleton"
import { useNotification } from "../../composables/notification"
import * as skillProfileService from "../../services/skillProfileService"
const { showErrorNotification } = useNotification()
const containerEl = ref()
const isLoading = ref(false)
/**
* @param {Array<Object>} skills
* @returns {Promise<void>}
*/
async function searchProfileMatches(skills) {
isLoading.value = true
const skillIdList = skills.map((skill) => skill.id)
try {
containerEl.value.innerHTML = await skillProfileService.matchProfiles(skillIdList)
} catch (e) {
showErrorNotification(e)
} finally {
isLoading.value = false
}
}
defineExpose({
searchProfileMatches,
})
</script>
<template>
<Skeleton
v-if="isLoading"
height="10rem"
/>
<div
v-show="!isLoading"
ref="containerEl"
/>
</template>

@ -0,0 +1,30 @@
<script setup>
import { onMounted } from "vue"
import Skeleton from "primevue/skeleton"
import { useSkillWheel } from "../../composables/skill/skillWheel"
const { wheelContainer, isLoading, loadSkills, showRoot, showSkill } = useSkillWheel()
defineExpose({
showRoot,
showSkill,
})
onMounted(() => {
loadSkills()
})
</script>
<template>
<div class="aspect-square">
<div ref="wheelContainer" />
<Skeleton
shape="circle"
v-if="isLoading"
size="100%"
/>
</div>
</template>

@ -0,0 +1,132 @@
<script setup>
import { onMounted, ref } from "vue"
import { useI18n } from "vue-i18n"
import BaseCard from "../basecomponents/BaseCard.vue"
import BaseButton from "../basecomponents/BaseButton.vue"
import Skeleton from "primevue/skeleton"
import * as skillProfileService from "../../services/skillProfileService"
import { useNotification } from "../../composables/notification"
import { useConfirm } from "primevue/useconfirm"
const { t } = useI18n()
const { showErrorNotification, showSuccessNotification } = useNotification()
const confirm = useConfirm()
const isLoading = ref(true)
const profileList = ref([])
async function loadProfiles() {
try {
isLoading.value = true
const { items } = await skillProfileService.findAll()
profileList.value = items
} catch (e) {
showErrorNotification(e)
} finally {
isLoading.value = false
}
}
defineExpose({
loadProfiles,
})
onMounted(() => {
loadProfiles()
})
async function onClickDeleteProfile(profile) {
confirm.require({
message: t('Are you sure you want to delete "%s"?', [profile.title]),
header: t("Delete skill profile"),
icon: "mdi mdi-alert",
async accept() {
if (!profile) {
return
}
try {
isLoading.value = true
await skillProfileService.deleteProfile(profile["@id"])
showSuccessNotification(t("Skill profile deleted"))
} catch (e) {
showErrorNotification(e)
} finally {
isLoading.value = false
}
await loadProfiles()
},
})
}
const emit = defineEmits(["searchProfile"])
</script>
<template>
<BaseCard>
<template #title>{{ t("Skill profiles") }}</template>
<div
v-if="isLoading"
class="space-y-2"
>
<div
v-for="v in 3"
:key="v"
class="flex flex-row gap-2 items-center"
>
<Skeleton
class="mr-auto"
width="10rem"
/>
<Skeleton size="2.5rem" />
<Skeleton size="2.5rem" />
</div>
</div>
<ul
v-else-if="profileList.length > 0"
class="space-y-2"
>
<li
v-for="(profile, i) in profileList"
:key="i"
class="flex flex-row gap-2 items-center"
>
<span
class="mr-auto"
v-text="profile.title"
/>
<BaseButton
:label="t('Search')"
icon="search"
only-icon
type="black"
@click="emit('searchProfile', profile)"
/>
<BaseButton
:label="t('Delete')"
icon="delete"
only-icon
type="danger"
@click="onClickDeleteProfile(profile)"
/>
</li>
</ul>
<p
v-else
v-t="'No skill profiles'"
/>
</BaseCard>
</template>

@ -0,0 +1,285 @@
import { ref, unref, watch } from "vue"
import * as d3 from "d3"
import { getSkillTree } from "../../services/skillService"
import { useNotification } from "../notification"
export function useSkillWheel() {
const isLoading = ref(true)
const skillList = ref([])
const wheelContainer = ref(null)
const { showErrorNotification } = useNotification()
const colorList = ["#deebf7", "#9ecae1", "#3182bd"]
let root
let centralCircle
let path
const width = 928
const height = width
const radius = width / 6
function transformSkillToWheelItem({
id,
title,
shortCode,
status,
children,
hasGradebook,
isSearched,
isAchievedByUser,
}) {
const item = {
id,
name: title,
shortCode,
status,
children: [],
hasGradebook,
isSearched,
isAchievedByUser,
}
if (children.length) {
for (const child of children) {
item.children.push(transformSkillToWheelItem(child))
}
} else {
item.value = 1
}
return item
}
function render() {
const data = {
name: "root",
children: unref(skillList).map(transformSkillToWheelItem),
}
// Compute the layout.
const hierarchy = d3
.hierarchy(data)
.sum((d) => d.value)
.sort((a, b) => b.id - a.id)
root = d3.partition().size([2 * Math.PI, hierarchy.height + 1])(hierarchy)
root.each((d) => (d.current = d))
// Create the arc generator.
const arc = d3
.arc()
.startAngle((d) => d.x0)
.endAngle((d) => d.x1)
.padAngle((d) => Math.min((d.x1 - d.x0) / 2, 0.005))
.padRadius(radius * 1.5)
.innerRadius((d) => d.y0 * radius)
.outerRadius((d) => Math.max(d.y0 * radius, d.y1 * radius - 1))
// Create the SVG container.
const svg = d3
.create("svg")
.attr("viewBox", [-width / 2, -height / 2, width, width])
.style("font", "10px sans-serif")
// Append the arcs.
path = svg
.append("g")
.selectAll("path")
.data(root.descendants().slice(1))
.join("path")
.attr("fill", setFillColor)
.attr("fill-opacity", (d) => (arcVisible(d.current) ? 1 : 0))
.attr("pointer-events", (d) => (arcVisible(d.current) ? "auto" : "none"))
.attr("d", (d) => arc(d.current))
.attr("id", (d) => "skill-" + d.data.id)
// Make them clickable if they have children.
path
.filter((d) => d.children)
.style("cursor", "pointer")
.on("click", clicked)
path.append("title").text(
(d) =>
`${d
.ancestors()
.filter((d) => d.depth > 0)
.map(setNodeText)
.reverse()
.join("/")}`,
)
const label = svg
.append("g")
.attr("pointer-events", "none")
.attr("text-anchor", "middle")
.style("user-select", "none")
.selectAll("text")
.data(root.descendants().slice(1))
.join("text")
.attr("dy", "0.35em")
.attr("fill-opacity", (d) => +labelVisible(d.current))
.attr("transform", (d) => labelTransform(d.current))
.text(setNodeText)
centralCircle = svg
.append("circle")
.datum(root)
.attr("r", radius)
.attr("fill", "none")
.attr("pointer-events", "all")
.on("click", clicked)
// Handle zoom on click.
function clicked(event, p) {
centralCircle.datum(p.parent || root)
root.each(
(d) =>
(d.target = {
x0: Math.max(0, Math.min(1, (d.x0 - p.x0) / (p.x1 - p.x0))) * 2 * Math.PI,
x1: Math.max(0, Math.min(1, (d.x1 - p.x0) / (p.x1 - p.x0))) * 2 * Math.PI,
y0: Math.max(0, d.y0 - p.depth),
y1: Math.max(0, d.y1 - p.depth),
}),
)
const t = svg.transition().duration(750)
// Transition the data on all arcs, even the ones that aren’t visible,
// so that if this transition is interrupted, entering arcs will start
// the next transition from the desired position.
path
.transition(t)
.tween("data", (d) => {
const i = d3.interpolate(d.current, d.target)
return (t) => (d.current = i(t))
})
.filter(function (d) {
return +this.getAttribute("fill-opacity") || arcVisible(d.target)
})
.attr("fill-opacity", (d) => (arcVisible(d.target) ? 1 : 0))
.attr("pointer-events", (d) => (arcVisible(d.target) ? "auto" : "none"))
.attrTween("d", (d) => () => arc(d.current))
label
.filter(function (d) {
return +this.getAttribute("fill-opacity") || labelVisible(d.target)
})
.transition(t)
.attr("fill-opacity", (d) => +labelVisible(d.target))
.attrTween("transform", (d) => () => labelTransform(d.current))
}
function arcVisible(d) {
return d.y1 <= 3 && d.y0 >= 1 && d.x1 > d.x0
}
function labelVisible(d) {
return d.y1 <= 3 && d.y0 >= 1 && (d.y1 - d.y0) * (d.x1 - d.x0) > 0.03
}
function labelTransform(d) {
const x = (((d.x0 + d.x1) / 2) * 180) / Math.PI
const y = ((d.y0 + d.y1) / 2) * radius
return `rotate(${x - 90}) translate(${y},0) rotate(${x < 180 ? 0 : 180})`
}
function setFillColor(d, i) {
if (d.data.hasGradebook) {
return "#F89406"
}
if (d.data.isSearched) {
return "#B94A48"
}
if (d.data.isAchievedByUser) {
return "#A1D99B"
}
if (!d.data.status) {
return "#48616C"
}
return colorList[i % colorList.length]
}
function setNodeText(d) {
if (d.data.shortCode) {
return d.data.shortCode
}
return d.data.name
}
return svg.node()
}
function showRoot() {
if (isLoading.value) {
return
}
centralCircle.datum(root).dispatch("click")
}
function showSkill(skillId) {
if (isLoading.value) {
return
}
const skillNode = root.descendants().find((d) => d.data.id === skillId)
if (!skillNode) {
return
}
if (skillNode.children && skillNode.children.length > 0) {
centralCircle.datum(skillNode).dispatch("click")
return
}
if (skillNode.parent) {
centralCircle.datum(skillNode.parent).dispatch("click")
return
}
showRoot()
}
async function loadSkills() {
isLoading.value = true
try {
skillList.value = await getSkillTree()
} catch (e) {
showErrorNotification(e)
} finally {
isLoading.value = false
}
}
watch(skillList, () => {
if (wheelContainer.value) {
wheelContainer.value.innerHTML = ""
wheelContainer.value.appendChild(render())
}
})
return {
wheelContainer,
isLoading,
loadSkills,
showRoot,
showSkill,
}
}

@ -14,6 +14,7 @@ import publicPageRoutes from "./publicPage"
import socialNetworkRoutes from "./social"
import termsRoutes from "./terms"
import fileManagerRoutes from "./filemanager"
import skillRoutes from "./skill"
//import courseCategoryRoutes from './coursecategory';
import documents from "./documents"
@ -251,6 +252,7 @@ const router = createRouter({
toolIntroRoutes,
pageRoutes,
publicPageRoutes,
skillRoutes,
],
})

@ -0,0 +1,18 @@
export default {
path: "/skill",
component: () => import("../components/layout/SimpleRouterViewLayout.vue"),
children: [
{
name: "SkillWheel",
path: "wheel",
meta: {
requiresAuth: true,
requiresAdmin: true,
requiresSessionAdmin: false,
requiresHR: true,
showBreadcrumb: false,
},
component: () => import("../views/skill/SkillWheel.vue"),
},
],
}

@ -0,0 +1,47 @@
import baseService from "./baseService"
/**
* @returns {Promise<{totalItems, items}>}
*/
export async function findAll() {
return baseService.getCollection("/api/skill_profiles")
}
/**
* @param {string} title
* @param {string} description
* @param {Array<{skill}>} skills
* @returns {Promise<Object>}
*/
export async function createProfile({ title, description, skills }) {
return baseService.post("/api/skill_profiles", { title, description, skills })
}
/**
* @param {string} iri
* @param {string} title
* @param {string} description
* @returns {Promise<Object>}
*/
export async function updateProfile({ iri, title, description }) {
return baseService.put(iri, { title, description })
}
/**
* @param {string} iri
* @returns {Promise<void>}
*/
export async function deleteProfile(iri) {
await baseService.delete(iri)
}
/**
* @param {Array<number>} idList
* @returns {Promise<string>}
*/
export async function matchProfiles(idList) {
return await baseService.get("/main/inc/ajax/skill.ajax.php", {
a: "profile_matches",
skill_id: idList,
})
}

@ -0,0 +1,18 @@
import baseService from "./baseService"
/**
* @returns {Promise<Array>}
*/
export async function getSkillTree() {
const { items } = await baseService.getCollection("/api/skills/tree")
return items
}
/**
* @param {Object} searchParams
* @returns {Promise<{totalItems, items}>}
*/
export async function findAll(searchParams) {
return await baseService.getCollection("api/skills", searchParams)
}

@ -0,0 +1,180 @@
<script setup>
import { ref } from "vue"
import { useI18n } from "vue-i18n"
import SectionHeader from "../../components/layout/SectionHeader.vue"
import BaseAutocomplete from "../../components/basecomponents/BaseAutocomplete.vue"
import BaseIcon from "../../components/basecomponents/BaseIcon.vue"
import BaseButton from "../../components/basecomponents/BaseButton.vue"
import BaseCard from "../../components/basecomponents/BaseCard.vue"
import SkillWheelProfileList from "../../components/skill/SkillWheelProfileList.vue"
import SkillProfileDialog from "../../components/skill/SkillProfileDialog.vue"
import SkillWheelGraph from "../../components/skill/SkillWheelGraph.vue"
import { useNotification } from "../../composables/notification"
import * as skillService from "../../services/skillService"
import SkillProfileMatches from "../../components/skill/SkillProfileMatches.vue"
const { t } = useI18n()
const { showErrorNotification } = useNotification()
const profileListEL = ref()
const wheelEl = ref()
const foundSkills = ref([])
/**
* @param {string} query
* @returns {Promise<Object[]>}
*/
async function findSkills(query) {
try {
const { items } = await skillService.findAll({ title: query })
return items.map((item) => ({ name: item.title, value: item["@id"], ...item }))
} catch (e) {
showErrorNotification(e)
return []
}
}
const showSkilProfileForm = ref(false)
const profileMatchesEl = ref()
const showProfileMatches = ref(false)
async function onClickSearchProfileMatches() {
showProfileMatches.value = true
await profileMatchesEl.value.searchProfileMatches(foundSkills.value)
}
async function onClickViewSkillWheel() {
showProfileMatches.value = false
wheelEl.value.showRoot()
}
async function onSearchProfile(profile) {
const profileSkills = profile.skills.map((skillRelProfile) => skillRelProfile.skill)
showProfileMatches.value = true
await profileMatchesEl.value.searchProfileMatches(profileSkills)
}
</script>
<template>
<SectionHeader :title="t('Skill wheel')" />
<div class="grid grid-cols-1 xl:grid-cols-3 gap-4">
<div class="xl:col-span-1 skill-options flex flex-col gap-4">
<SkillWheelProfileList
ref="profileListEL"
@search-profile="onSearchProfile"
/>
<BaseCard>
<template #title>{{ t("What skills are you looking for?") }}</template>
<template #footer>
<BaseButton
:label="t('View skills wheel')"
icon="wheel"
type="secondary"
@click="onClickViewSkillWheel()"
/>
</template>
<BaseAutocomplete
id="skill_id"
v-model="foundSkills"
:label="t('Enter the skill name to search')"
:search="findSkills"
is-multiple
>
<template #chip="{ value }">
{{ value.name }}
<span
class="p-autocomplete-token-icon"
@click="wheelEl.showSkill(value.id)"
>
<BaseIcon
icon="crosshairs"
size="small"
/>
</span>
</template>
</BaseAutocomplete>
<BaseButton
:disabled="!foundSkills.length"
:label="t('Search profile matches')"
icon="search"
type="black"
@click="onClickSearchProfileMatches"
/>
<p v-t="'Is this what you were looking for?'" />
<BaseButton
:disabled="!foundSkills.length"
:label="t('Save this search')"
icon="search"
type="black"
@click="showSkilProfileForm = true"
/>
</BaseCard>
<BaseCard>
<template #title>{{ t("Legend") }}</template>
<ul class="fa-ul">
<li>
<BaseIcon
class="skill-legend-basic"
icon="square"
/>
{{ t("Basic skills") }}
</li>
<li>
<BaseIcon
class="skill-legend-add"
icon="square"
/>
{{ t("Skills you can learn") }}
</li>
<li>
<BaseIcon
class="skill-legend-search"
icon="square"
/>
{{ t("Skills searched for") }}
</li>
</ul>
</BaseCard>
</div>
<div class="xl:col-span-2">
<SkillWheelGraph
v-show="!showProfileMatches"
ref="wheelEl"
/>
<SkillProfileMatches
v-show="showProfileMatches"
ref="profileMatchesEl"
/>
</div>
</div>
<SkillProfileDialog
v-model:skills="foundSkills"
v-model:visible="showSkilProfileForm"
@saved="profileListEL.loadProfiles()"
/>
</template>

@ -31,6 +31,7 @@ class IndexController extends BaseController
#[Route('/social', name: 'chamilo_core_socialnetwork', options: ['expose' => true])]
#[Route('/admin', name: 'admin', options: ['expose' => true])]
#[Route('/p/{slug}', name: 'public_page')]
#[Route('/skill/wheel', name: 'skill')]
public function index(): Response
{
return $this->render('@ChamiloCore/Layout/no_layout.html.twig', ['content' => '']);

Loading…
Cancel
Save