feat: students and assessment tab in dashboard
This commit is contained in:
277
frontend/src/components/Controls/Autocomplete.vue
Normal file
277
frontend/src/components/Controls/Autocomplete.vue
Normal file
@@ -0,0 +1,277 @@
|
||||
<template>
|
||||
<Combobox v-model="selectedValue" nullable v-slot="{ open: isComboboxOpen }">
|
||||
<Popover class="w-full" v-model:show="showOptions">
|
||||
<template #target="{ open: openPopover, togglePopover }">
|
||||
<slot name="target" v-bind="{ open: openPopover, togglePopover }">
|
||||
<div class="w-full">
|
||||
<button
|
||||
class="flex w-full items-center justify-between focus:outline-none"
|
||||
:class="inputClasses"
|
||||
@click="() => togglePopover()"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<slot name="prefix" />
|
||||
<span
|
||||
class="overflow-hidden text-ellipsis whitespace-nowrap text-base leading-5"
|
||||
v-if="selectedValue"
|
||||
>
|
||||
{{ displayValue(selectedValue) }}
|
||||
</span>
|
||||
<span class="text-base leading-5 text-gray-500" v-else>
|
||||
{{ placeholder || '' }}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown class="h-4 w-4 stroke-1.5" />
|
||||
</button>
|
||||
</div>
|
||||
</slot>
|
||||
</template>
|
||||
<template #body="{ isOpen }">
|
||||
<div v-show="isOpen">
|
||||
<div class="mt-1 rounded-lg bg-white py-1 text-base shadow-2xl">
|
||||
<div class="relative px-1.5 pt-0.5">
|
||||
<ComboboxInput
|
||||
ref="search"
|
||||
class="form-input w-full"
|
||||
type="text"
|
||||
@change="
|
||||
(e) => {
|
||||
query = e.target.value
|
||||
}
|
||||
"
|
||||
:value="query"
|
||||
autocomplete="off"
|
||||
placeholder="Search"
|
||||
/>
|
||||
<button
|
||||
class="absolute right-1.5 inline-flex h-7 w-7 items-center justify-center"
|
||||
@click="selectedValue = null"
|
||||
>
|
||||
<X class="h-4 w-4 stroke-1.5" />
|
||||
</button>
|
||||
</div>
|
||||
<ComboboxOptions
|
||||
class="my-1 max-h-[12rem] overflow-y-auto px-1.5"
|
||||
static
|
||||
>
|
||||
<div
|
||||
class="mt-1.5"
|
||||
v-for="group in groups"
|
||||
:key="group.key"
|
||||
v-show="group.items.length > 0"
|
||||
>
|
||||
<div
|
||||
v-if="group.group && !group.hideLabel"
|
||||
class="px-2.5 py-1.5 text-sm font-medium text-gray-500"
|
||||
>
|
||||
{{ group.group }}
|
||||
</div>
|
||||
<ComboboxOption
|
||||
as="template"
|
||||
v-for="option in group.items"
|
||||
:key="option.value"
|
||||
:value="option"
|
||||
v-slot="{ active, selected }"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
'flex items-center rounded px-2.5 py-1.5 text-base',
|
||||
{ 'bg-gray-100': active },
|
||||
]"
|
||||
>
|
||||
<slot
|
||||
name="item-prefix"
|
||||
v-bind="{ active, selected, option }"
|
||||
/>
|
||||
<slot
|
||||
name="item-label"
|
||||
v-bind="{ active, selected, option }"
|
||||
>
|
||||
{{ option.label }}
|
||||
</slot>
|
||||
</li>
|
||||
</ComboboxOption>
|
||||
</div>
|
||||
<li
|
||||
v-if="groups.length == 0"
|
||||
class="mt-1.5 rounded-md px-2.5 py-1.5 text-base text-gray-600"
|
||||
>
|
||||
No results found
|
||||
</li>
|
||||
</ComboboxOptions>
|
||||
<div v-if="slots.footer" class="border-t p-1.5 pb-0.5">
|
||||
<slot
|
||||
name="footer"
|
||||
v-bind="{ value: search?.el._value, close }"
|
||||
></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</Combobox>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxOptions,
|
||||
ComboboxOption,
|
||||
} from '@headlessui/vue'
|
||||
import { Popover, Button } from 'frappe-ui'
|
||||
import { ChevronDown, X } from 'lucide-vue-next'
|
||||
import { ref, computed, useAttrs, useSlots, watch, nextTick } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'md',
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'subtle',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
filterable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue', 'update:query', 'change'])
|
||||
|
||||
const query = ref('')
|
||||
const showOptions = ref(false)
|
||||
const search = ref(null)
|
||||
|
||||
const attrs = useAttrs()
|
||||
const slots = useSlots()
|
||||
|
||||
const valuePropPassed = computed(() => 'value' in attrs)
|
||||
|
||||
const selectedValue = computed({
|
||||
get() {
|
||||
return valuePropPassed.value ? attrs.value : props.modelValue
|
||||
},
|
||||
set(val) {
|
||||
query.value = ''
|
||||
if (val) {
|
||||
showOptions.value = false
|
||||
}
|
||||
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val)
|
||||
},
|
||||
})
|
||||
|
||||
function close() {
|
||||
showOptions.value = false
|
||||
}
|
||||
|
||||
const groups = computed(() => {
|
||||
if (!props.options || props.options.length == 0) return []
|
||||
|
||||
let groups = props.options[0]?.group
|
||||
? props.options
|
||||
: [{ group: '', items: props.options }]
|
||||
|
||||
return groups
|
||||
.map((group, i) => {
|
||||
return {
|
||||
key: i,
|
||||
group: group.group,
|
||||
hideLabel: group.hideLabel || false,
|
||||
items: props.filterable ? filterOptions(group.items) : group.items,
|
||||
}
|
||||
})
|
||||
.filter((group) => group.items.length > 0)
|
||||
})
|
||||
|
||||
function filterOptions(options) {
|
||||
if (!query.value) {
|
||||
return options
|
||||
}
|
||||
return options.filter((option) => {
|
||||
let searchTexts = [option.label, option.value]
|
||||
return searchTexts.some((text) =>
|
||||
(text || '').toString().toLowerCase().includes(query.value.toLowerCase())
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function displayValue(option) {
|
||||
if (typeof option === 'string') {
|
||||
let allOptions = groups.value.flatMap((group) => group.items)
|
||||
let selectedOption = allOptions.find((o) => o.value === option)
|
||||
return selectedOption?.label || option
|
||||
}
|
||||
return option?.label
|
||||
}
|
||||
|
||||
watch(query, (q) => {
|
||||
emit('update:query', q)
|
||||
})
|
||||
|
||||
watch(showOptions, (val) => {
|
||||
if (val) {
|
||||
nextTick(() => {
|
||||
search.value.el.focus()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const textColor = computed(() => {
|
||||
return props.disabled ? 'text-gray-600' : 'text-gray-800'
|
||||
})
|
||||
|
||||
const inputClasses = computed(() => {
|
||||
let sizeClasses = {
|
||||
sm: 'text-base rounded h-7',
|
||||
md: 'text-base rounded h-8',
|
||||
lg: 'text-lg rounded-md h-10',
|
||||
xl: 'text-xl rounded-md h-10',
|
||||
}[props.size]
|
||||
|
||||
let paddingClasses = {
|
||||
sm: 'py-1.5 px-2',
|
||||
md: 'py-1.5 px-2.5',
|
||||
lg: 'py-1.5 px-3',
|
||||
xl: 'py-1.5 px-3',
|
||||
}[props.size]
|
||||
|
||||
let variant = props.disabled ? 'disabled' : props.variant
|
||||
let variantClasses = {
|
||||
subtle:
|
||||
'border border-gray-100 bg-gray-100 placeholder-gray-500 hover:border-gray-200 hover:bg-gray-200 focus:bg-white focus:border-gray-500 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-gray-400',
|
||||
outline:
|
||||
'border border-gray-300 bg-white placeholder-gray-500 hover:border-gray-400 hover:shadow-sm focus:bg-white focus:border-gray-500 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-gray-400',
|
||||
disabled: [
|
||||
'border bg-gray-50 placeholder-gray-400',
|
||||
props.variant === 'outline' ? 'border-gray-300' : 'border-transparent',
|
||||
],
|
||||
}[variant]
|
||||
|
||||
return [
|
||||
sizeClasses,
|
||||
paddingClasses,
|
||||
variantClasses,
|
||||
textColor.value,
|
||||
'transition-colors w-full',
|
||||
]
|
||||
})
|
||||
|
||||
defineExpose({ query })
|
||||
</script>
|
||||
146
frontend/src/components/Controls/Link.vue
Normal file
146
frontend/src/components/Controls/Link.vue
Normal file
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<div class="space-y-1.5">
|
||||
<label class="block" :class="labelClasses" v-if="attrs.label">
|
||||
{{ attrs.label }}
|
||||
</label>
|
||||
<Autocomplete
|
||||
ref="autocomplete"
|
||||
:options="options.data"
|
||||
v-model="value"
|
||||
:size="attrs.size || 'sm'"
|
||||
:variant="attrs.variant"
|
||||
:placeholder="attrs.placeholder"
|
||||
:filterable="false"
|
||||
>
|
||||
<template #target="{ open, togglePopover }">
|
||||
<slot name="target" v-bind="{ open, togglePopover }" />
|
||||
</template>
|
||||
|
||||
<template #prefix>
|
||||
<slot name="prefix" />
|
||||
</template>
|
||||
|
||||
<template #item-prefix="{ active, selected, option }">
|
||||
<slot name="item-prefix" v-bind="{ active, selected, option }" />
|
||||
</template>
|
||||
|
||||
<template #item-label="{ active, selected, option }">
|
||||
<slot name="item-label" v-bind="{ active, selected, option }" />
|
||||
</template>
|
||||
|
||||
<template v-if="attrs.onCreate" #footer="{ value, close }">
|
||||
<div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="w-full !justify-start"
|
||||
label="Create New"
|
||||
@click="attrs.onCreate(value, close)"
|
||||
>
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Autocomplete>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Autocomplete from '@/components/Controls/Autocomplete.vue'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { createResource, Button } from 'frappe-ui'
|
||||
import { Plus } from 'lucide-vue-next'
|
||||
import { useAttrs, computed, ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
doctype: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
filters: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
const attrs = useAttrs()
|
||||
|
||||
const valuePropPassed = computed(() => 'value' in attrs)
|
||||
|
||||
const value = computed({
|
||||
get: () => (valuePropPassed.value ? attrs.value : props.modelValue),
|
||||
set: (val) => {
|
||||
return (
|
||||
val?.value &&
|
||||
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val?.value)
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const autocomplete = ref(null)
|
||||
const text = ref('')
|
||||
|
||||
watchDebounced(
|
||||
() => autocomplete.value?.query,
|
||||
(val) => {
|
||||
val = val || ''
|
||||
if (text.value === val) return
|
||||
text.value = val
|
||||
reload(val)
|
||||
},
|
||||
{ debounce: 300, immediate: true }
|
||||
)
|
||||
|
||||
watchDebounced(
|
||||
() => props.doctype,
|
||||
() => reload(''),
|
||||
{ debounce: 300, immediate: true }
|
||||
)
|
||||
|
||||
const options = createResource({
|
||||
url: 'frappe.desk.search.search_link',
|
||||
cache: [props.doctype, text.value],
|
||||
method: 'POST',
|
||||
params: {
|
||||
txt: text.value,
|
||||
doctype: props.doctype,
|
||||
filters: props.filters,
|
||||
},
|
||||
transform: (data) => {
|
||||
return data.map((option) => {
|
||||
return {
|
||||
label: option.value,
|
||||
value: option.value,
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
function reload(val) {
|
||||
options.update({
|
||||
params: {
|
||||
txt: val,
|
||||
doctype: props.doctype,
|
||||
filters: props.filters,
|
||||
},
|
||||
})
|
||||
options.reload()
|
||||
}
|
||||
|
||||
const labelClasses = computed(() => {
|
||||
return [
|
||||
{
|
||||
sm: 'text-xs',
|
||||
md: 'text-base',
|
||||
}[attrs.size || 'sm'],
|
||||
'text-gray-600',
|
||||
]
|
||||
})
|
||||
</script>
|
||||
38
frontend/src/components/Controls/Rating.vue
Normal file
38
frontend/src/components/Controls/Rating.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<div class="flex text-center">
|
||||
<div v-for="index in 5">
|
||||
<Star
|
||||
:class="{ 'fill-orange-500': index <= rating }"
|
||||
class="h-5 w-5 fill-gray-400 text-gray-200 mr-1 cursor-pointer"
|
||||
@click="markRating(index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Star } from 'lucide-vue-next'
|
||||
import { ref } from 'vue'
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
modelValue: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
let rating = ref(props.modelValue)
|
||||
|
||||
let emitChange = (value) => {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
function markRating(index) {
|
||||
emitChange(index)
|
||||
rating.value = index
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user