feat: badge list and form
This commit is contained in:
222
frontend/src/components/Settings/BadgeForm.vue
Normal file
222
frontend/src/components/Settings/BadgeForm.vue
Normal file
@@ -0,0 +1,222 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: badge ? __('Edit Badge') : __('Create a new Badge'),
|
||||
size: '3xl',
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="grid grid-cols-2 gap-x-5">
|
||||
<div class="space-y-4">
|
||||
<FormControl
|
||||
v-model="badge.enabled"
|
||||
:label="__('Enabled')"
|
||||
type="checkbox"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="badge.title"
|
||||
:label="__('Title')"
|
||||
type="text"
|
||||
:required="true"
|
||||
/>
|
||||
<Autocomplete
|
||||
@update:modelValue="(opt) => (badge.reference_doctype = opt.value)"
|
||||
:modelValue="badge.reference_doctype"
|
||||
:options="referenceDoctypeOptions"
|
||||
:required="true"
|
||||
:label="__('Assign For')"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="badge.description"
|
||||
:label="__('Description')"
|
||||
:required="true"
|
||||
type="textarea"
|
||||
/>
|
||||
<Uploader
|
||||
v-model="badge.image"
|
||||
label="Badge Image"
|
||||
description="An image that represents the badge."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<FormControl
|
||||
v-model="badge.grant_only_once"
|
||||
:label="__('Grant Only Once')"
|
||||
type="checkbox"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="badge.event"
|
||||
:label="__('Event')"
|
||||
type="select"
|
||||
:options="eventOptions"
|
||||
:required="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="badge.user_field"
|
||||
:label="__('Assign To')"
|
||||
type="select"
|
||||
:options="userFieldOptions"
|
||||
:required="true"
|
||||
/>
|
||||
<CodeEditor
|
||||
v-model="badge.condition"
|
||||
:label="__('Condition')"
|
||||
type="JavaScript"
|
||||
:required="true"
|
||||
:showBorder="true"
|
||||
height="82px"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-4"></div>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions="{ close }">
|
||||
<div class="pb-5 float-right">
|
||||
<Button variant="solid" @click="saveBadge(close)">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Button, call, Dialog, FormControl, toast } from 'frappe-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { cleanError } from '@/utils'
|
||||
import type { Badges, Badge } from '@/components/Settings/types'
|
||||
import Autocomplete from '@/components/Controls/Autocomplete.vue'
|
||||
import CodeEditor from '@/components/Controls/CodeEditor.vue'
|
||||
import Uploader from '@/components/Controls/Uploader.vue'
|
||||
|
||||
const defaultBadge = {
|
||||
name: '',
|
||||
title: '',
|
||||
enabled: true,
|
||||
description: '',
|
||||
image: '',
|
||||
grant_only_once: false,
|
||||
event: 'New',
|
||||
reference_doctype: 'LMS Course',
|
||||
condition: null,
|
||||
user_field: 'member',
|
||||
field_to_check: '',
|
||||
}
|
||||
const show = defineModel<boolean | undefined>()
|
||||
const badges = defineModel<Badges>('badges')
|
||||
const badge = ref<Badge>(defaultBadge)
|
||||
|
||||
const props = defineProps<{
|
||||
badgeName: string | null
|
||||
}>()
|
||||
|
||||
watch(
|
||||
() => props.badgeName,
|
||||
(val) => {
|
||||
if (val != 'new') {
|
||||
badges.value?.data.forEach((bdg: Badge) => {
|
||||
if (bdg.name === val) {
|
||||
badge.value = bdg
|
||||
}
|
||||
})
|
||||
} else {
|
||||
badge.value = { ...defaultBadge }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const saveBadge = (close: () => void) => {
|
||||
if (props.badgeName == 'new') {
|
||||
createBadge(close)
|
||||
} else {
|
||||
updateBadge(close)
|
||||
}
|
||||
}
|
||||
|
||||
const updateBadge = async (close: () => void) => {
|
||||
console.log(props.badgeName, badge.value?.title)
|
||||
if (props.badgeName != badge.value?.title) {
|
||||
await renameDoc()
|
||||
}
|
||||
setValue(close)
|
||||
}
|
||||
|
||||
const renameDoc = async () => {
|
||||
await call('frappe.client.rename_doc', {
|
||||
doctype: 'LMS Badge',
|
||||
old_name: props.badgeName,
|
||||
new_name: badge.value?.title,
|
||||
})
|
||||
}
|
||||
|
||||
const setValue = (close: () => void) => {
|
||||
badges.value?.setValue.submit(
|
||||
{
|
||||
...badge.value,
|
||||
name: badge.value.title,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
badges.value?.reload()
|
||||
close()
|
||||
toast.success(__('Zoom Account updated successfully'))
|
||||
},
|
||||
onError(err: any) {
|
||||
close()
|
||||
toast.error(
|
||||
cleanError(err.messages[0]) || __('Error updating Zoom Account')
|
||||
)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const createBadge = (close: () => void) => {
|
||||
badges.value?.insert.submit(
|
||||
{
|
||||
...badge.value,
|
||||
name: badge.value.name,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
badges.value?.reload()
|
||||
close()
|
||||
toast.success(__('Badge created successfully'))
|
||||
},
|
||||
onError(err) {
|
||||
close()
|
||||
toast.error(cleanError(err.messages[0]) || __('Error creating badge'))
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const referenceDoctypeOptions = computed(() => {
|
||||
return [
|
||||
{ label: __('Course'), value: 'LMS Course' },
|
||||
{ label: __('Batch'), value: 'LMS Batch' },
|
||||
{ label: __('User'), value: 'Member' },
|
||||
{ label: __('Quiz Submission'), value: 'LMS Quiz Submission' },
|
||||
{ label: __('Assignment Submission'), value: 'LMS Assignment Submission' },
|
||||
{
|
||||
label: __('Programming Exercise Submission'),
|
||||
value: 'LMS Programming Exercise Submission',
|
||||
},
|
||||
{ label: __('Course Enrollment'), value: 'LMS Enrollment' },
|
||||
{ label: __('Batch Enrollment'), value: 'LMS Batch Enrollment' },
|
||||
]
|
||||
})
|
||||
|
||||
const eventOptions = computed(() => {
|
||||
let options = ['New', 'Value Change', 'Auto Assign']
|
||||
return options.map((event) => ({ label: __(event), value: event }))
|
||||
})
|
||||
|
||||
const userFieldOptions = computed(() => {
|
||||
return [
|
||||
{ label: __('Member'), value: 'member' },
|
||||
{ label: __('Owner'), value: 'owner' },
|
||||
]
|
||||
})
|
||||
</script>
|
||||
229
frontend/src/components/Settings/Badges.vue
Normal file
229
frontend/src/components/Settings/Badges.vue
Normal file
@@ -0,0 +1,229 @@
|
||||
<template>
|
||||
<div class="flex flex-col min-h-0 text-base">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<div class="text-xl font-semibold text-ink-gray-9">
|
||||
{{ label }}
|
||||
</div>
|
||||
<div class="text-ink-gray-6 leading-5">
|
||||
{{ __(description) }}
|
||||
</div>
|
||||
</div>
|
||||
<Button @click="openForm('new')">
|
||||
<template #prefix>
|
||||
<Plus class="h-3 w-3 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('New') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="badges.data?.length" class="overflow-y-scroll">
|
||||
<ListView
|
||||
:columns="columns"
|
||||
:rows="badges.data"
|
||||
row-key="name"
|
||||
:options="{
|
||||
showTooltip: false,
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in columns">
|
||||
<template #prefix="{ item }">
|
||||
<FeatherIcon
|
||||
v-if="item.icon"
|
||||
:name="item.icon"
|
||||
class="h-4 w-4 stroke-1.5"
|
||||
/>
|
||||
</template>
|
||||
</ListHeaderItem>
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
<ListRow :row="row" v-for="row in badges.data">
|
||||
<template #default="{ column, item }">
|
||||
<ListRowItem :item="row[column.key]" :align="column.align">
|
||||
<div v-if="column.key == 'enabled'">
|
||||
<Badge v-if="row[column.key]" theme="green">
|
||||
{{ __('Enabled') }}
|
||||
</Badge>
|
||||
<Badge v-else theme="gray">
|
||||
{{ __('Disabled') }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div v-else-if="column.key == 'reference_doctype'">
|
||||
{{ doctypeLabel[row[column.key]] || row[column.key] }}
|
||||
</div>
|
||||
<FormControl
|
||||
v-else-if="column.key == 'grant_only_once'"
|
||||
v-model="row[column.key]"
|
||||
type="checkbox"
|
||||
:disabled="true"
|
||||
/>
|
||||
<div
|
||||
v-else-if="column.key != 'action'"
|
||||
class="leading-5 text-sm"
|
||||
>
|
||||
{{ row[column.key] }}
|
||||
</div>
|
||||
<!-- <Button v-else variant="ghost">
|
||||
<Ellipsis class="size-4 stroke-1.5 text-ink-gray-9" />
|
||||
</Button> -->
|
||||
<Dropdown
|
||||
v-else
|
||||
:options="getMoreOptions(row.name)"
|
||||
:button="{
|
||||
icon: 'more-horizontal',
|
||||
onblur: (e) => {
|
||||
e.stopPropagation()
|
||||
},
|
||||
}"
|
||||
placement="right"
|
||||
/>
|
||||
</ListRowItem>
|
||||
</template>
|
||||
</ListRow>
|
||||
</ListRows>
|
||||
|
||||
<ListSelectBanner>
|
||||
<template #actions="{ unselectAll, selections }">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="removeAccount(selections, unselectAll)"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</ListSelectBanner>
|
||||
</ListView>
|
||||
</div>
|
||||
</div>
|
||||
<BadgeForm
|
||||
v-model="showForm"
|
||||
:badgeName="selectedBadge"
|
||||
v-model:badges="badges"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
createListResource,
|
||||
Dropdown,
|
||||
FeatherIcon,
|
||||
FormControl,
|
||||
ListView,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListRows,
|
||||
ListRow,
|
||||
ListRowItem,
|
||||
ListSelectBanner,
|
||||
} from 'frappe-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||
import BadgeForm from '@/components/Settings/BadgeForm.vue'
|
||||
|
||||
const showForm = ref<boolean>(false)
|
||||
const selectedBadge = ref<string | null>(null)
|
||||
|
||||
const props = defineProps<{
|
||||
label: string
|
||||
description: string
|
||||
}>()
|
||||
|
||||
const badges = createListResource({
|
||||
doctype: 'LMS Badge',
|
||||
fields: [
|
||||
'name',
|
||||
'title',
|
||||
'enabled',
|
||||
'description',
|
||||
'image',
|
||||
'grant_only_once',
|
||||
'event',
|
||||
'reference_doctype',
|
||||
'condition',
|
||||
'user_field',
|
||||
'field_to_check',
|
||||
],
|
||||
order_by: 'creation desc',
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const getMoreOptions = (badgeName: string) => {
|
||||
return [
|
||||
{
|
||||
label: __('Edit'),
|
||||
icon: 'edit',
|
||||
onClick() {
|
||||
openForm(badgeName)
|
||||
},
|
||||
},
|
||||
{
|
||||
label: __('Assignments'),
|
||||
icon: 'download',
|
||||
onClick() {
|
||||
console.log('assignments')
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const openForm = (badgeName: string) => {
|
||||
selectedBadge.value = badgeName
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
const doctypeLabel = computed(() => {
|
||||
return {
|
||||
'LMS Course': __('Course'),
|
||||
'LMS Batch': __('Batch'),
|
||||
'LMS Enrollment': __('Course Enrollment'),
|
||||
'LMS Batch Enrollment': __('Batch Enrollment'),
|
||||
'LMS Quiz Submission': __('Quiz Submission'),
|
||||
'LMS Assignment Submission': __('Assignment Submission'),
|
||||
'LMS Programming Exercise Submission': __(
|
||||
'Programming Exercise Submission'
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
const columns = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: __('Badge'),
|
||||
key: 'title',
|
||||
icon: 'award',
|
||||
align: 'left',
|
||||
width: '25%',
|
||||
},
|
||||
{
|
||||
label: __('Assigned For'),
|
||||
key: 'reference_doctype',
|
||||
icon: 'info',
|
||||
align: 'left',
|
||||
width: '35%',
|
||||
},
|
||||
{
|
||||
label: __('Status'),
|
||||
key: 'enabled',
|
||||
icon: 'check-square',
|
||||
align: 'left',
|
||||
width: '15%',
|
||||
},
|
||||
{
|
||||
label: __('Grant Only Once'),
|
||||
key: 'grant_only_once',
|
||||
icon: 'check',
|
||||
align: 'center',
|
||||
width: '15%',
|
||||
},
|
||||
{
|
||||
key: 'action',
|
||||
align: 'right',
|
||||
},
|
||||
]
|
||||
})
|
||||
</script>
|
||||
@@ -17,7 +17,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-y-auto">
|
||||
<SettingFields :fields="fields" :data="data.data" />
|
||||
<SettingFields :fields="fields" :data="branding.data" />
|
||||
</div>
|
||||
<div class="flex flex-row-reverse mt-auto">
|
||||
<Button variant="solid" :loading="saveSettings.loading" @click="update">
|
||||
@@ -38,10 +38,6 @@ const props = defineProps({
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
data: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
@@ -51,6 +47,12 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const branding = createResource({
|
||||
url: 'lms.lms.api.get_branding',
|
||||
auto: true,
|
||||
cache: 'brand',
|
||||
})
|
||||
|
||||
const saveSettings = createResource({
|
||||
url: 'frappe.client.set_value',
|
||||
makeParams(values) {
|
||||
@@ -86,7 +88,7 @@ const update = () => {
|
||||
)
|
||||
}
|
||||
|
||||
watch(props.data, (newData) => {
|
||||
watch(branding, (newData) => {
|
||||
if (newData && !isDirty.value) {
|
||||
isDirty.value = true
|
||||
}
|
||||
|
||||
@@ -34,30 +34,9 @@
|
||||
:key="activeTab.label"
|
||||
class="flex flex-1 flex-col px-10 py-8 bg-surface-modal"
|
||||
>
|
||||
<Members
|
||||
v-if="activeTab.label === 'Members'"
|
||||
:label="activeTab.label"
|
||||
:description="activeTab.description"
|
||||
v-model:show="show"
|
||||
/>
|
||||
<Evaluators
|
||||
v-else-if="activeTab.label === 'Evaluators'"
|
||||
:label="activeTab.label"
|
||||
:description="activeTab.description"
|
||||
v-model:show="show"
|
||||
/>
|
||||
<Categories
|
||||
v-else-if="activeTab.label === 'Categories'"
|
||||
:label="activeTab.label"
|
||||
:description="activeTab.description"
|
||||
/>
|
||||
<EmailTemplates
|
||||
v-else-if="activeTab.label === 'Email Templates'"
|
||||
:label="activeTab.label"
|
||||
:description="activeTab.description"
|
||||
/>
|
||||
<ZoomSettings
|
||||
v-else-if="activeTab.label === 'Zoom Accounts'"
|
||||
<component
|
||||
v-if="activeTab.template"
|
||||
:is="activeTab.template"
|
||||
:label="activeTab.label"
|
||||
:description="activeTab.description"
|
||||
/>
|
||||
@@ -68,13 +47,6 @@
|
||||
:data="data"
|
||||
:fields="activeTab.fields"
|
||||
/>
|
||||
<BrandSettings
|
||||
v-else-if="activeTab.label === 'Branding'"
|
||||
:label="activeTab.label"
|
||||
:description="activeTab.description"
|
||||
:fields="activeTab.fields"
|
||||
:data="branding"
|
||||
/>
|
||||
<SettingDetails
|
||||
v-else
|
||||
:fields="activeTab.fields"
|
||||
@@ -88,8 +60,8 @@
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Dialog, createDocumentResource, createResource } from 'frappe-ui'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Dialog, createDocumentResource } from 'frappe-ui'
|
||||
import { computed, markRaw, ref, watch } from 'vue'
|
||||
import { useSettings } from '@/stores/settings'
|
||||
import SettingDetails from '@/components/Settings/SettingDetails.vue'
|
||||
import SidebarLink from '@/components/SidebarLink.vue'
|
||||
@@ -100,6 +72,7 @@ import EmailTemplates from '@/components/Settings/EmailTemplates.vue'
|
||||
import BrandSettings from '@/components/Settings/BrandSettings.vue'
|
||||
import PaymentSettings from '@/components/Settings/PaymentSettings.vue'
|
||||
import ZoomSettings from '@/components/Settings/ZoomSettings.vue'
|
||||
import Badges from '@/components/Settings/Badges.vue'
|
||||
|
||||
const show = defineModel()
|
||||
const doctype = ref('LMS Settings')
|
||||
@@ -114,12 +87,6 @@ const data = createDocumentResource({
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const branding = createResource({
|
||||
url: 'lms.lms.api.get_branding',
|
||||
auto: true,
|
||||
cache: 'brand',
|
||||
})
|
||||
|
||||
const tabsStructure = computed(() => {
|
||||
return [
|
||||
{
|
||||
@@ -245,6 +212,7 @@ const tabsStructure = computed(() => {
|
||||
description:
|
||||
'Add new members or manage roles and permissions of existing members',
|
||||
icon: 'UserRoundPlus',
|
||||
template: markRaw(Members),
|
||||
},
|
||||
{
|
||||
label: 'Evaluators',
|
||||
@@ -252,21 +220,32 @@ const tabsStructure = computed(() => {
|
||||
icon: 'UserCheck',
|
||||
description:
|
||||
'Add new evaluators or check the slots existing evaluators',
|
||||
},
|
||||
{
|
||||
label: 'Categories',
|
||||
description: 'Double click to edit the category',
|
||||
icon: 'Network',
|
||||
},
|
||||
{
|
||||
label: 'Email Templates',
|
||||
description: 'Manage the email templates for your learning system',
|
||||
icon: 'MailPlus',
|
||||
template: markRaw(Evaluators),
|
||||
},
|
||||
{
|
||||
label: 'Zoom Accounts',
|
||||
description: 'Manage the Zoom accounts for your learning system',
|
||||
icon: 'Video',
|
||||
template: markRaw(ZoomSettings),
|
||||
},
|
||||
{
|
||||
label: 'Badges',
|
||||
description:
|
||||
'Create badges and assign them to students to acknowledge their achievements',
|
||||
icon: 'Award',
|
||||
template: markRaw(Badges),
|
||||
},
|
||||
{
|
||||
label: 'Categories',
|
||||
description: 'Double click to edit the category',
|
||||
icon: 'Network',
|
||||
template: markRaw(Categories),
|
||||
},
|
||||
{
|
||||
label: 'Email Templates',
|
||||
description: 'Manage the email templates for your learning system',
|
||||
icon: 'MailPlus',
|
||||
template: markRaw(EmailTemplates),
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -277,6 +256,7 @@ const tabsStructure = computed(() => {
|
||||
{
|
||||
label: 'Branding',
|
||||
icon: 'Blocks',
|
||||
template: markRaw(BrandSettings),
|
||||
fields: [
|
||||
{
|
||||
label: 'Brand Name',
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
<template #default="{ column, item }">
|
||||
<ListRowItem :item="row[column.key]" :align="column.align">
|
||||
<div v-if="column.key == 'enabled'">
|
||||
<Badge v-if="row[column.key]" theme="blue">
|
||||
<Badge v-if="row[column.key]" theme="green">
|
||||
{{ __('Enabled') }}
|
||||
</Badge>
|
||||
<Badge v-else theme="gray">
|
||||
|
||||
@@ -13,4 +13,35 @@ export interface User {
|
||||
is_instructor: boolean
|
||||
is_fc_site: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export interface Badge {
|
||||
name: string;
|
||||
title: string;
|
||||
enabled: boolean;
|
||||
description: string;
|
||||
image: string;
|
||||
grant_only_once: boolean;
|
||||
event: string;
|
||||
reference_doctype: string;
|
||||
condition: string | null;
|
||||
user_field: string;
|
||||
field_to_check: string;
|
||||
};
|
||||
|
||||
export interface Badges {
|
||||
data: Badge[],
|
||||
reload: () => void
|
||||
insert: {
|
||||
submit: (
|
||||
data: Badge,
|
||||
options: { onSuccess: () => void; onError: (err: any) => void }
|
||||
) => void
|
||||
},
|
||||
setValue: {
|
||||
submit: (
|
||||
data: Badge,
|
||||
options: { onSuccess: () => void; onError: (err: any) => void }
|
||||
) => void
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user