mirror of
https://github.com/frappe/lms.git
synced 2026-05-02 13:39:31 +03:00
feat: add member modal, evaluator add refactor
This commit is contained in:
Vendored
+2
@@ -8,6 +8,7 @@ export {}
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
AddEvaluatorModal: typeof import('./src/components/Modals/AddEvaluatorModal.vue')['default']
|
||||
Apps: typeof import('./src/components/Sidebar/Apps.vue')['default']
|
||||
AppSidebar: typeof import('./src/components/Sidebar/AppSidebar.vue')['default']
|
||||
AssessmentModal: typeof import('./src/components/Modals/AssessmentModal.vue')['default']
|
||||
@@ -78,6 +79,7 @@ declare module 'vue' {
|
||||
Members: typeof import('./src/components/Settings/Members.vue')['default']
|
||||
MobileLayout: typeof import('./src/components/MobileLayout.vue')['default']
|
||||
MultiSelect: typeof import('./src/components/Controls/MultiSelect.vue')['default']
|
||||
NewMemberModal: typeof import('./src/components/Modals/NewMemberModal.vue')['default']
|
||||
NoPermission: typeof import('./src/components/NoPermission.vue')['default']
|
||||
NoSidebarLayout: typeof import('./src/components/NoSidebarLayout.vue')['default']
|
||||
Notes: typeof import('./src/components/Notes/Notes.vue')['default']
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Add Existing User as Evaluator'),
|
||||
size: 'md',
|
||||
actions: [
|
||||
{
|
||||
label: __('Add'),
|
||||
variant: 'solid',
|
||||
loading: submitting,
|
||||
onClick: ({ close }: any) => addEvaluator(close),
|
||||
},
|
||||
],
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<Link doctype="User" v-model="selectedUser" :label="__('Select User')" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { call, Dialog, toast } from 'frappe-ui'
|
||||
import { ref, watch } from 'vue'
|
||||
import { cleanError } from '@/utils'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
|
||||
const show = defineModel<boolean>({ default: false })
|
||||
const selectedUser = ref('')
|
||||
const submitting = ref(false)
|
||||
|
||||
const emit = defineEmits<{
|
||||
added: []
|
||||
}>()
|
||||
|
||||
watch(show, (isOpen) => {
|
||||
if (isOpen) {
|
||||
selectedUser.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
const addEvaluator = async (close?: () => void) => {
|
||||
if (!selectedUser.value?.trim()) {
|
||||
toast.error(__('Please select a user'))
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await call('lms.lms.api.save_role', {
|
||||
user: selectedUser.value,
|
||||
role: 'Batch Evaluator',
|
||||
value: 1,
|
||||
})
|
||||
toast.success(__('Evaluator added successfully'))
|
||||
emit('added')
|
||||
close?.()
|
||||
} catch (err: any) {
|
||||
toast.error(cleanError(err.messages?.[0]) || __('Unable to add evaluator'))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Add New Member'),
|
||||
size: 'lg',
|
||||
actions: [
|
||||
{
|
||||
label: __('Add'),
|
||||
variant: 'solid',
|
||||
loading: submitting,
|
||||
onClick: ({ close }: any) => addMember(close),
|
||||
},
|
||||
],
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="space-y-4">
|
||||
<FormControl
|
||||
v-model="member.email"
|
||||
:label="__('Email')"
|
||||
placeholder="jane@doe.com"
|
||||
type="email"
|
||||
:required="true"
|
||||
@keyup.enter="addMember()"
|
||||
/>
|
||||
<div class="flex items-center gap-3">
|
||||
<FormControl
|
||||
v-model="member.first_name"
|
||||
:label="__('First Name')"
|
||||
placeholder="Jane"
|
||||
type="text"
|
||||
class="w-full"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="member.last_name"
|
||||
:label="__('Last Name')"
|
||||
placeholder="Doe"
|
||||
type="text"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm text-ink-gray-5">
|
||||
{{ __('Roles') }}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-6">
|
||||
<FormControl
|
||||
:label="__('Student')"
|
||||
v-model="roles.lms_student"
|
||||
type="checkbox"
|
||||
/>
|
||||
<FormControl
|
||||
:label="__('Course Creator')"
|
||||
v-model="roles.course_creator"
|
||||
type="checkbox"
|
||||
/>
|
||||
<FormControl
|
||||
:label="__('Evaluator')"
|
||||
v-model="roles.batch_evaluator"
|
||||
type="checkbox"
|
||||
/>
|
||||
<FormControl
|
||||
:label="__('Moderator')"
|
||||
v-model="roles.moderator"
|
||||
type="checkbox"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { call, Dialog, FormControl, toast } from 'frappe-ui'
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { cleanError } from '@/utils'
|
||||
|
||||
const show = defineModel<boolean>({ default: false })
|
||||
const submitting = ref(false)
|
||||
|
||||
const props = defineProps<{
|
||||
defaultRoles?: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
created: [user: any]
|
||||
}>()
|
||||
|
||||
const ROLE_MAP: Record<string, string> = {
|
||||
moderator: 'Moderator',
|
||||
course_creator: 'Course Creator',
|
||||
batch_evaluator: 'Batch Evaluator',
|
||||
lms_student: 'LMS Student',
|
||||
}
|
||||
|
||||
const member = reactive({
|
||||
email: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
})
|
||||
|
||||
const roles = reactive({
|
||||
moderator: false,
|
||||
course_creator: false,
|
||||
batch_evaluator: false,
|
||||
lms_student: false,
|
||||
})
|
||||
|
||||
const resetForm = () => {
|
||||
member.email = ''
|
||||
member.first_name = ''
|
||||
member.last_name = ''
|
||||
applyDefaultRoles()
|
||||
}
|
||||
|
||||
const applyDefaultRoles = () => {
|
||||
roles.moderator = props.defaultRoles?.includes('moderator') ?? false
|
||||
roles.course_creator = props.defaultRoles?.includes('course_creator') ?? false
|
||||
roles.batch_evaluator =
|
||||
props.defaultRoles?.includes('batch_evaluator') ?? false
|
||||
roles.lms_student = props.defaultRoles?.includes('lms_student') ?? false
|
||||
}
|
||||
|
||||
watch(show, (isOpen) => {
|
||||
if (isOpen) {
|
||||
resetForm()
|
||||
}
|
||||
})
|
||||
|
||||
const assignRoles = async (userEmail: string) => {
|
||||
const selectedRoles = Object.entries(roles).filter(([_, checked]) => checked)
|
||||
|
||||
for (const [key, _] of selectedRoles) {
|
||||
await call('lms.lms.api.save_role', {
|
||||
user: userEmail,
|
||||
role: ROLE_MAP[key],
|
||||
value: 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const addMember = async (close?: () => void) => {
|
||||
if (!member.email?.trim()) {
|
||||
toast.error(__('Email is required'))
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const user = await call('frappe.client.insert', {
|
||||
doc: {
|
||||
doctype: 'User',
|
||||
email: member.email.trim(),
|
||||
first_name: member.first_name.trim() || undefined,
|
||||
last_name: member.last_name.trim() || undefined,
|
||||
},
|
||||
})
|
||||
|
||||
await assignRoles(user.name)
|
||||
|
||||
toast.success(__('Member added successfully'))
|
||||
emit('created', user)
|
||||
resetForm()
|
||||
close?.()
|
||||
} catch (err: any) {
|
||||
toast.error(cleanError(err.messages?.[0]) || __('Unable to add member'))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -10,12 +10,43 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex item-center space-x-2">
|
||||
<Button @click="() => (showForm = !showForm)">
|
||||
<template #prefix>
|
||||
<Plus class="size-4 stroke-1.5" />
|
||||
<Dropdown
|
||||
placement="right"
|
||||
side="bottom"
|
||||
:options="[
|
||||
{
|
||||
label: __('New Evaluator'),
|
||||
icon: 'user-plus',
|
||||
onClick() {
|
||||
showNewEvaluator = true
|
||||
},
|
||||
},
|
||||
{
|
||||
label: __('Existing User'),
|
||||
icon: 'user-check',
|
||||
onClick() {
|
||||
showExistingUser = true
|
||||
},
|
||||
},
|
||||
]"
|
||||
>
|
||||
<template v-slot="{ open }">
|
||||
<Button variant="solid">
|
||||
<template #prefix>
|
||||
<Plus class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('New') }}
|
||||
<template #suffix>
|
||||
<ChevronDown
|
||||
:class="[
|
||||
'w-4 h-4 stroke-1.5 ml-1 transform transition-transform',
|
||||
open ? 'rotate-180' : '',
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
{{ __('New') }}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -71,11 +102,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="evaluators.length && hasNextPage"
|
||||
class="flex justify-center mt-4"
|
||||
>
|
||||
<Button @click="evaluators.reload()">
|
||||
<div v-if="evaluators.hasNextPage" class="flex justify-center mt-4">
|
||||
<Button @click="evaluators.next()">
|
||||
<template #prefix>
|
||||
<RefreshCw class="h-3 w-3 stroke-1.5" />
|
||||
</template>
|
||||
@@ -85,33 +113,12 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog
|
||||
v-model="showForm"
|
||||
:options="{
|
||||
size: 'xl',
|
||||
title: __('Add Evaluator'),
|
||||
actions: [{
|
||||
label: __('Add'),
|
||||
variant: 'solid',
|
||||
onClick({ close }: any) {
|
||||
addEvaluator(close)
|
||||
},
|
||||
}]
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div v-if="showForm" class="flex items-center">
|
||||
<FormControl
|
||||
v-model="email"
|
||||
:label="__('Email')"
|
||||
placeholder="jane@doe.com"
|
||||
type="email"
|
||||
class="w-full"
|
||||
@keydown.enter="addEvaluator"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
<AddEvaluatorModal v-model="showExistingUser" @added="evaluators.reload()" />
|
||||
<NewMemberModal
|
||||
v-model="showNewEvaluator"
|
||||
:defaultRoles="['batch_evaluator']"
|
||||
@created="onMemberCreated"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
@@ -119,18 +126,19 @@ import {
|
||||
Button,
|
||||
call,
|
||||
createListResource,
|
||||
Dialog,
|
||||
Dropdown,
|
||||
FormControl,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { ref, watch } from 'vue'
|
||||
import { Plus, Search, Trash2, RefreshCw } from 'lucide-vue-next'
|
||||
import { Plus, Search, Trash2, RefreshCw, ChevronDown } from 'lucide-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
import NewMemberModal from '@/components/Modals/NewMemberModal.vue'
|
||||
import AddEvaluatorModal from '@/components/Modals/AddEvaluatorModal.vue'
|
||||
|
||||
const show = defineModel('show')
|
||||
const search = ref('')
|
||||
const showForm = ref(false)
|
||||
const email = ref('')
|
||||
const showExistingUser = ref(false)
|
||||
const showNewEvaluator = ref(false)
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps({
|
||||
@@ -151,20 +159,8 @@ const evaluators = createListResource({
|
||||
orderBy: 'creation desc',
|
||||
})
|
||||
|
||||
const addEvaluator = (close: () => void) => {
|
||||
call('lms.lms.api.add_an_evaluator', {
|
||||
email: email.value,
|
||||
})
|
||||
.then(() => {
|
||||
email.value = ''
|
||||
evaluators.reload()
|
||||
toast.success(__('Evaluator added successfully'))
|
||||
close()
|
||||
})
|
||||
.catch((error: any) => {
|
||||
toast.error(__(error.messages[0] || error.messages))
|
||||
console.error('Error adding evaluator:', error)
|
||||
})
|
||||
const onMemberCreated = () => {
|
||||
evaluators.reload()
|
||||
}
|
||||
|
||||
watch(search, () => {
|
||||
@@ -177,7 +173,6 @@ watch(search, () => {
|
||||
})
|
||||
|
||||
const openProfile = (username: string) => {
|
||||
show.value = false
|
||||
router.push({
|
||||
name: 'Profile',
|
||||
params: {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex item-center space-x-2">
|
||||
<Button @click="() => (showForm = !showForm)">
|
||||
<Button @click="showNewMember = true">
|
||||
<template #prefix>
|
||||
<Plus class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
@@ -82,56 +82,16 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog
|
||||
v-model="showForm"
|
||||
:options="{
|
||||
title: __('Add a new member'),
|
||||
size: 'lg',
|
||||
actions: [{
|
||||
label: __('Add'),
|
||||
variant: 'solid',
|
||||
onClick({ close }: any) {
|
||||
addMember(close)
|
||||
}
|
||||
}]
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="flex items-center space-x-2">
|
||||
<FormControl
|
||||
v-model="member.email"
|
||||
:label="__('Email')"
|
||||
placeholder="jane@doe.com"
|
||||
type="email"
|
||||
class="w-full"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="member.first_name"
|
||||
:label="__('First Name')"
|
||||
placeholder="Jane"
|
||||
type="text"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
<NewMemberModal v-model="showNewMember" @created="onMemberCreated" />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
call,
|
||||
createResource,
|
||||
Dialog,
|
||||
FormControl,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { Avatar, Button, createResource, FormControl } from 'frappe-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ref, watch, reactive, inject } from 'vue'
|
||||
import { ref, watch, inject } from 'vue'
|
||||
import { RefreshCw, Plus, Search, Shield } from 'lucide-vue-next'
|
||||
import { useOnboarding } from 'frappe-ui/frappe'
|
||||
import { useOnboarding, useTelemetry } from 'frappe-ui/frappe'
|
||||
import type { User } from '@/components/Settings/types'
|
||||
import { useTelemetry } from 'frappe-ui/frappe'
|
||||
import NewMemberModal from '@/components/Modals/NewMemberModal.vue'
|
||||
|
||||
type Member = {
|
||||
username: string
|
||||
@@ -147,16 +107,11 @@ const search = ref('')
|
||||
const start = ref(0)
|
||||
const memberList = ref<Member[]>([])
|
||||
const hasNextPage = ref(false)
|
||||
const showForm = ref(false)
|
||||
const showNewMember = ref(false)
|
||||
const user = inject<User | null>('$user')
|
||||
const { updateOnboardingStep } = useOnboarding('learning')
|
||||
const { capture } = useTelemetry()
|
||||
|
||||
const member = reactive({
|
||||
email: '',
|
||||
first_name: '',
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
@@ -194,30 +149,12 @@ const openProfile = (username: string) => {
|
||||
})
|
||||
}
|
||||
|
||||
const addMember = (close: () => void) => {
|
||||
call('frappe.client.insert', {
|
||||
doc: {
|
||||
doctype: 'User',
|
||||
first_name: member.first_name,
|
||||
email: member.email,
|
||||
},
|
||||
})
|
||||
.then((data: Member) => {
|
||||
if (user?.data?.is_system_manager) updateOnboardingStep('invite_students')
|
||||
capture('user_added')
|
||||
show.value = false
|
||||
router.push({
|
||||
name: 'ProfileRoles',
|
||||
params: {
|
||||
username: data.username,
|
||||
},
|
||||
})
|
||||
close()
|
||||
})
|
||||
.catch((err: any) => {
|
||||
console.error(err)
|
||||
toast.error(__(err.messages?.[0] || err))
|
||||
})
|
||||
const onMemberCreated = (data: any) => {
|
||||
if (user?.data?.is_system_manager) updateOnboardingStep('invite_students')
|
||||
capture('user_added')
|
||||
memberList.value = []
|
||||
start.value = 0
|
||||
members.reload()
|
||||
}
|
||||
|
||||
watch(search, () => {
|
||||
|
||||
@@ -42,8 +42,7 @@
|
||||
...(activeTab.label == 'Branding'
|
||||
? { sections: activeTab.sections }
|
||||
: {}),
|
||||
...(activeTab.label == 'Evaluators' ||
|
||||
activeTab.label == 'Members' ||
|
||||
...(activeTab.label == 'Members' ||
|
||||
activeTab.label == 'Transactions'
|
||||
? { 'onUpdate:show': (val) => (show = val), show }
|
||||
: {}),
|
||||
|
||||
+46
-22
@@ -1468,28 +1468,6 @@ def save_evaluator_role(user: str, value: int):
|
||||
return True
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def add_an_evaluator(email: str):
|
||||
frappe.only_for("Moderator")
|
||||
if not frappe.db.exists("User", email):
|
||||
user = frappe.new_doc("User")
|
||||
user.update(
|
||||
{
|
||||
"email": email,
|
||||
"first_name": email.split("@")[0].capitalize(),
|
||||
"enabled": 1,
|
||||
}
|
||||
)
|
||||
user.insert()
|
||||
user.add_roles("Batch Evaluator")
|
||||
|
||||
evaluator = frappe.new_doc("Course Evaluator")
|
||||
evaluator.evaluator = email
|
||||
evaluator.insert()
|
||||
|
||||
return evaluator
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def capture_user_persona(responses: str):
|
||||
frappe.only_for("System Manager")
|
||||
@@ -2329,3 +2307,49 @@ def clear_demo_data():
|
||||
frappe.delete_doc("User", user, ignore_permissions=True)
|
||||
|
||||
frappe.db.set_single_value("LMS Settings", "demo_data_present", False)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def search_users_by_role(txt: str = "", roles: str | list | None = None, page_length: int = 10):
|
||||
"""Returns users with `roles` in search_link format"""
|
||||
frappe.only_for(["Moderator", "Course Creator", "Batch Evaluator"])
|
||||
if not roles:
|
||||
return []
|
||||
|
||||
if isinstance(roles, str):
|
||||
roles = json.loads(roles)
|
||||
|
||||
invalid_roles = set(roles) - set(LMS_ROLES)
|
||||
if invalid_roles:
|
||||
frappe.throw(_("Cannot search for roles: {0}").format(", ".join(invalid_roles)))
|
||||
|
||||
users_with_roles = frappe.get_all(
|
||||
"Has Role",
|
||||
filters={"role": ["in", roles], "parenttype": "User"},
|
||||
pluck="parent",
|
||||
distinct=True,
|
||||
)
|
||||
|
||||
if not users_with_roles:
|
||||
return []
|
||||
|
||||
results = frappe.get_all(
|
||||
"User",
|
||||
filters=[
|
||||
["name", "in", users_with_roles],
|
||||
["name", "not in", ["Administrator", "Guest"]],
|
||||
["enabled", "=", 1],
|
||||
],
|
||||
or_filters=[
|
||||
["full_name", "like", f"%{txt}%"],
|
||||
["name", "like", f"%{txt}%"],
|
||||
],
|
||||
fields=["name", "full_name"],
|
||||
limit_page_length=cint(page_length),
|
||||
order_by="full_name asc",
|
||||
)
|
||||
|
||||
return [
|
||||
{"value": r.name, "description": r.full_name or r.name, "label": r.full_name or r.name}
|
||||
for r in results
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user