Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
234 changes: 170 additions & 64 deletions assets/vue/components/coursemaintenance/ResourceSelector.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div class="flex items-center gap-2 text-sm">
<h3 class="text-sm font-semibold text-gray-90">{{ title }}</h3>
<span class="px-2 py-1 rounded-md bg-gray-15 text-gray-50">
{{ selectedTotal }} {{ $t('selected') }}
</span>
<span class="px-2 py-1 rounded-md bg-gray-15 text-gray-50"> {{ selectedTotal }} {{ $t("selected") }} </span>
</div>

<div class="flex flex-wrap gap-2">
<div class="relative" v-if="searchable">
<div
class="relative"
v-if="searchable"
>
<input
v-model.trim="query"
:placeholder="$t('Search by title or path…')"
Expand All @@ -20,34 +21,53 @@
<button
v-if="query"
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-50 hover:text-gray-90"
@click="query=''"
:aria-label="$t('Clear search')">
@click="query = ''"
:aria-label="$t('Clear search')"
>
<i class="mdi mdi-close"></i>
</button>
</div>

<button class="btn-secondary" @click="expandAll(true)">
<button
class="btn-secondary"
@click="expandAll(true)"
>
<i class="mdi mdi-arrow-expand-vertical"></i> {{ $t("Expand all") }}
</button>
<button class="btn-secondary" @click="expandAll(false)">
<button
class="btn-secondary"
@click="expandAll(false)"
>
<i class="mdi mdi-arrow-collapse-vertical"></i> {{ $t("Collapse all") }}
</button>
<button class="btn-secondary" @click="checkAll(true)">
<button
class="btn-secondary"
@click="checkAll(true)"
>
<i class="mdi mdi-check-all"></i> {{ $t("Select all") }}
</button>
<button class="btn-secondary" @click="checkAll(false)">
<button
class="btn-secondary"
@click="checkAll(false)"
>
<i class="mdi mdi-close-thick"></i> {{ $t("Select none") }}
</button>
</div>
</div>

<!-- Tree -->
<div class="rounded-lg border border-gray-25">
<div v-if="filteredGroups.length===0" class="p-6 text-center text-sm text-gray-50">
<div
v-if="filteredGroups.length === 0"
class="p-6 text-center text-sm text-gray-50"
>
{{ emptyText }}
</div>

<div v-else class="divide-y divide-gray-25">
<div
v-else
class="divide-y divide-gray-25"
>
<GroupBlock
v-for="g in filteredGroups"
:key="g.type"
Expand All @@ -57,7 +77,7 @@
:forceOpen="forceOpen"
:count-selected="countSelected"
:isNodeCheckable="isNodeCheckable"
@select-group="(val)=>toggleNode(g, val)"
@select-group="(val) => toggleNode(g, val)"
/>
</div>
</div>
Expand Down Expand Up @@ -85,43 +105,73 @@ const emit = defineEmits(["update:modelValue"])

// hook with shared logic
const sel = useResourceSelection()
const { tree, selections, query, forceOpen,
normalizeTreeForSelection, filteredGroups, selectedTotal,
countSelected, isNodeCheckable, isChecked, toggleNode, checkAll, expandAll } = sel
const {
tree,
selections,
query,
forceOpen,
normalizeTreeForSelection,
filteredGroups,
selectedTotal,
countSelected,
isNodeCheckable,
isChecked,
toggleNode,
checkAll,
expandAll,
} = sel

// sync in/out
const { groups, modelValue } = toRefs(props)
watch(groups, (arr) => {
const norm = normalizeTreeForSelection(Array.isArray(arr) ? JSON.parse(JSON.stringify(arr)) : [])
// ensure top-level children
tree.value = norm.map(g =>
Array.isArray(g.children) ? g : { ...g, children: Array.isArray(g.items) ? g.items : [] }
)
}, { immediate: true })
watch(
groups,
(arr) => {
const norm = normalizeTreeForSelection(Array.isArray(arr) ? JSON.parse(JSON.stringify(arr)) : [])
// ensure top-level children
tree.value = norm.map((g) =>
Array.isArray(g.children) ? g : { ...g, children: Array.isArray(g.items) ? g.items : [] },
)
},
{ immediate: true },
)

// auto expand on first data
watch(tree, (v) => {
if (Array.isArray(v) && v.length) {
forceOpen.value = true
requestAnimationFrame(() => { forceOpen.value = null })
requestAnimationFrame(() => {
forceOpen.value = null
})
}
})

let syncing = false

watch(modelValue, (v) => {
if (syncing) return
syncing = true
selections.value = { ...(v || {}) }
queueMicrotask(() => { syncing = false })
}, { immediate: true })

watch(selections, (v) => {
if (syncing) return
syncing = true
emit("update:modelValue", { ...(v || {}) })
queueMicrotask(() => { syncing = false })
}, { deep: true })
watch(
modelValue,
(v) => {
if (syncing) return
syncing = true
selections.value = { ...(v || {}) }
queueMicrotask(() => {
syncing = false
})
},
{ immediate: true },
)

watch(
selections,
(v) => {
if (syncing) return
syncing = true
emit("update:modelValue", { ...(v || {}) })
queueMicrotask(() => {
syncing = false
})
},
{ deep: true },
)
</script>

<script>
Expand All @@ -130,40 +180,76 @@ export default {
components: {
GroupBlock: {
name: "GroupBlock",
props: { group: Object, isChecked: Function, toggleFn: Function, countSelected: Function, forceOpen: [Boolean, null], isNodeCheckable: Function },
props: {
group: Object,
isChecked: Function,
toggleFn: Function,
countSelected: Function,
forceOpen: [Boolean, null],
isNodeCheckable: Function,
},
emits: ["select-group"],
components: { /* <-- REGISTER TreeNode LOCALLY HERE */
components: {
/* <-- REGISTER TreeNode LOCALLY HERE */
TreeNode: {
name: "TreeNode",
props: { node: Object, checked: Boolean, isChecked: Function, toggleFn: Function, forceOpen: [Boolean, null], isNodeCheckable: Function },
props: {
node: Object,
checked: Boolean,
isChecked: Function,
toggleFn: Function,
forceOpen: [Boolean, null],
isNodeCheckable: Function,
},
emits: ["toggle"],
data(){ return { open: true } },
watch: { forceOpen: { immediate: true, handler(v){ if (v!==null) this.open = !!v } } },
data() {
return { open: true }
},
watch: {
forceOpen: {
immediate: true,
handler(v) {
if (v !== null) this.open = !!v
},
},
},
methods: {
toggleOpen(){ this.open = !this.open },
onCheck(e){ this.$emit("toggle", e.target.checked) },
badgeTone(){ return "bg-gray-10 text-gray-90 ring-gray-25" },
toggleOpen() {
this.open = !this.open
},
onCheck(e) {
this.$emit("toggle", e.target.checked)
},
badgeTone() {
return "bg-gray-10 text-gray-90 ring-gray-25"
},
},
template: `
<li class="p-3 rounded-lg hover:bg-gray-15 transition">
<div class="flex items-start gap-3">
<button v-if="node.children && node.children.length"
class="mt-0.5 text-gray-50 hover:text-gray-90" @click="toggleOpen" aria-label="toggle">
<i :class="open ? 'mdi mdi-chevron-down' : 'mdi mdi-chevron-right'"></i>
</button>

<div class="mt-0.5 w-5 flex items-center justify-center">
<button
class="text-gray-50 hover:text-gray-90"
:class="{'opacity-0 pointer-events-none': !(node.children && node.children.length)}"
@click="toggleOpen"
aria-label="toggle">
<i :class="open ? 'mdi mdi-chevron-down' : 'mdi mdi-chevron-right'"></i>
</button>
</div>

<template v-if="isNodeCheckable(node)">
<input type="checkbox" :checked="checked" @change="onCheck" class="mt-0.5 chk-success"/>
</template>
<template v-else>
<span class="mt-0.5 w-4"></span>
<span class="mt-0.5 w-5"></span>
</template>

<div class="flex-1">
<div class="flex items-center gap-2">
<span class="rounded px-2 py-0.5 text-xs font-semibold ring-1 ring-inset" :class="badgeTone()">
{{ (node.titleType || node.type || '').toUpperCase() }}
</span>
<span class="rounded px-2 py-0.5 text-xs font-semibold ring-1 ring-inset" :class="badgeTone()">
{{ (node.titleType || node.type || '').toUpperCase() }}
</span>
<span class="text-sm text-gray-90">{{ node.label || node.title || '—' }}</span>
<span v-if="node.meta" class="text-xs text-gray-50">· {{ node.meta }}</span>
</div>
Expand All @@ -187,19 +273,39 @@ export default {
`,
},
},
data(){ return { open: true } },
computed:{
nodes(){
return Array.isArray(this.group.children) ? this.group.children
: Array.isArray(this.group.items) ? this.group.items : []
data() {
return { open: true }
},
computed: {
nodes() {
return Array.isArray(this.group.children)
? this.group.children
: Array.isArray(this.group.items)
? this.group.items
: []
},
total() {
return this.nodes.length
},
},
watch: {
forceOpen: {
immediate: true,
handler(v) {
if (v !== null) this.open = !!v
},
},
total(){ return this.nodes.length },
},
watch: { forceOpen: { immediate: true, handler(v){ if (v!==null) this.open = !!v } } },
methods:{
toggleOpen(){ this.open = !this.open },
selectAll(){ this.$emit("select-group", true) },
selectNone(){ this.$emit("select-group", false) },
methods: {
toggleOpen() {
this.open = !this.open
},
selectAll() {
this.$emit("select-group", true)
},
selectNone() {
this.$emit("select-group", false)
},
},
template: `
<section class="bg-white">
Expand Down
10 changes: 7 additions & 3 deletions assets/vue/components/glossary/GlossaryTermList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,10 @@

<hr class="-mx-4 -mt-2 mb-4" />

<div>
{{ term.description }}
</div>
<div
class="prose max-w-none"
v-html="sanitize(term.description)"
></div>
</BaseCard>
</li>
<li v-if="!isLoading && glossaries.length === 0">
Expand All @@ -63,6 +64,7 @@ import { useRoute } from "vue-router"
import { computed, onMounted, ref } from "vue"
import { checkIsAllowedToEdit } from "../../composables/userPermissions"
import { useCidReq } from "../../composables/cidReq"
import DOMPurify from "dompurify"

const { t } = useI18n()
const securityStore = useSecurityStore()
Expand Down Expand Up @@ -97,6 +99,8 @@ const canEdit = (item) => {
return (isSessionDocument && isAllowedToEdit.value) || (isBaseCourse && !sid && isCurrentTeacher.value)
}

const sanitize = (html) => DOMPurify.sanitize(html ?? "", { ADD_ATTR: ["target", "rel"] })

onMounted(async () => {
isAllowedToEdit.value = await checkIsAllowedToEdit(true, true, true)
})
Expand Down
16 changes: 12 additions & 4 deletions assets/vue/components/glossary/GlossaryTermTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,16 @@
:header="t('Term')"
field="title"
/>
<Column
:header="t('Definition')"
field="description"
/>

<Column :header="t('Definition')">
<template #body="{ data }">
<div
class="prose max-w-none"
v-html="sanitize(data.description)"
></div>
</template>
</Column>

<Column :header="t('Actions')">
<template #body="{ data }">
<BaseButton
Expand Down Expand Up @@ -41,6 +47,7 @@ import { useI18n } from "vue-i18n"
import Column from "primevue/column"
import BaseButton from "../basecomponents/BaseButton.vue"
import BaseTable from "../basecomponents/BaseTable.vue"
import DOMPurify from "dompurify"

const { t } = useI18n()

Expand All @@ -56,4 +63,5 @@ defineProps({
})

const emit = defineEmits(["edit", "delete"])
const sanitize = (html) => DOMPurify.sanitize(html ?? "", { ADD_ATTR: ["target", "rel"] })
</script>
Loading
Loading