mirror of
https://github.com/frappe/lms.git
synced 2026-05-02 13:39:31 +03:00
Merge pull request #2210 from raizasafeel/fix/ui-teardown
feat(control): better ux, inline create functionality for link
This commit is contained in:
@@ -9,7 +9,11 @@
|
||||
nullable
|
||||
v-slot="{ open: isComboboxOpen }"
|
||||
>
|
||||
<Popover class="w-full" v-model:show="showOptions">
|
||||
<Popover
|
||||
class="w-full"
|
||||
v-model:show="showOptions"
|
||||
:matchTargetWidth="true"
|
||||
>
|
||||
<template #target="{ open: openPopover, togglePopover }">
|
||||
<slot name="target" v-bind="{ open: openPopover, togglePopover }">
|
||||
<div class="w-full">
|
||||
@@ -92,7 +96,8 @@
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
'flex items-center rounded px-2.5 py-2 text-base',
|
||||
'flex items-center rounded px-2.5 text-base py-1.5',
|
||||
optionLines(option).secondary ? '' : 'h-7',
|
||||
{ 'bg-surface-gray-2': active },
|
||||
]"
|
||||
>
|
||||
@@ -104,16 +109,20 @@
|
||||
name="item-label"
|
||||
v-bind="{ active, selected, option }"
|
||||
>
|
||||
<div class="flex flex-col gap-1 p-1">
|
||||
<div
|
||||
class="flex flex-col px-1"
|
||||
:class="
|
||||
optionLines(option).secondary ? 'gap-0.5' : ''
|
||||
"
|
||||
>
|
||||
<div class="text-base font-medium text-ink-gray-8">
|
||||
{{
|
||||
option.value == option.label && option.description
|
||||
? option.description
|
||||
: option.label
|
||||
}}
|
||||
{{ optionLines(option).primary }}
|
||||
</div>
|
||||
<div class="text-sm text-ink-gray-5">
|
||||
{{ option.value }}
|
||||
<div
|
||||
v-if="optionLines(option).secondary"
|
||||
class="text-sm text-ink-gray-5"
|
||||
>
|
||||
{{ optionLines(option).secondary }}
|
||||
</div>
|
||||
</div>
|
||||
</slot>
|
||||
@@ -245,6 +254,17 @@ function filterOptions(options) {
|
||||
})
|
||||
}
|
||||
|
||||
function optionLines(option) {
|
||||
const primary = option.label
|
||||
let secondary = null
|
||||
if (option.description && option.description !== primary) {
|
||||
secondary = option.description
|
||||
} else if (option.value && option.value !== primary) {
|
||||
secondary = option.value
|
||||
}
|
||||
return { primary, secondary }
|
||||
}
|
||||
|
||||
function displayValue(option) {
|
||||
if (typeof option === 'string') {
|
||||
let allOptions = groups.value.flatMap((group) => group.items)
|
||||
|
||||
@@ -30,28 +30,48 @@
|
||||
</template>
|
||||
|
||||
<template #footer="{ value, close }">
|
||||
<div v-if="attrs.onCreate">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="w-full !justify-start"
|
||||
:label="__('Create New')"
|
||||
@click="attrs.onCreate(value, close)"
|
||||
<div v-if="creating" class="flex items-center gap-1">
|
||||
<button
|
||||
class="p-1 rounded hover:bg-surface-gray-3 text-ink-gray-5"
|
||||
@click="creating = false"
|
||||
:aria-label="__('Cancel')"
|
||||
>
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
<ArrowLeft class="size-4 stroke-1.5" />
|
||||
</button>
|
||||
<FormControl
|
||||
v-model="newItemName"
|
||||
class="flex-1 min-w-0"
|
||||
size="sm"
|
||||
:placeholder="__(props.inlineCreatePlaceholder)"
|
||||
/>
|
||||
<Button
|
||||
variant="solid"
|
||||
size="sm"
|
||||
:disabled="!newItemName.trim()"
|
||||
@click="submitCreate"
|
||||
:aria-label="__('Create')"
|
||||
>
|
||||
{{ __('Create') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<div v-else class="flex justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="w-full !justify-start"
|
||||
:label="__('Clear')"
|
||||
@click="() => clearValue(close)"
|
||||
:aria-label="__('Clear')"
|
||||
>
|
||||
{{ __('Clear') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="props.onCreate"
|
||||
variant="ghost"
|
||||
@click="handleCreate(close)"
|
||||
:aria-label="__('Create New')"
|
||||
>
|
||||
<template #prefix>
|
||||
<X class="h-4 w-4 stroke-1.5" />
|
||||
<Plus class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Create New') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -63,8 +83,8 @@
|
||||
<script setup>
|
||||
import Autocomplete from '@/components/Controls/Autocomplete.vue'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { createResource, Button } from 'frappe-ui'
|
||||
import { Plus, X } from 'lucide-vue-next'
|
||||
import { createResource, Button, FormControl } from 'frappe-ui'
|
||||
import { Plus, ArrowLeft } from 'lucide-vue-next'
|
||||
import { useAttrs, computed, ref } from 'vue'
|
||||
import { useSettings } from '@/stores/settings'
|
||||
|
||||
@@ -85,11 +105,25 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
inlineCreate: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
inlineCreatePlaceholder: {
|
||||
type: String,
|
||||
default: 'Enter...',
|
||||
},
|
||||
onCreate: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
const attrs = useAttrs()
|
||||
const valuePropPassed = computed(() => 'value' in attrs)
|
||||
const creating = ref(false)
|
||||
const newItemName = ref('')
|
||||
|
||||
const value = computed({
|
||||
get: () => (valuePropPassed.value ? attrs.value : props.modelValue),
|
||||
@@ -105,6 +139,26 @@ const autocomplete = ref(null)
|
||||
const text = ref('')
|
||||
const settingsStore = useSettings()
|
||||
|
||||
function handleCreate(close) {
|
||||
if (props.inlineCreate) {
|
||||
creating.value = true
|
||||
return
|
||||
}
|
||||
if (props.onCreate) {
|
||||
props.onCreate(null, close)
|
||||
}
|
||||
}
|
||||
|
||||
function submitCreate() {
|
||||
if (!newItemName.value.trim() || !props.onCreate) return
|
||||
const value = newItemName.value.trim()
|
||||
props.onCreate(value, () => {
|
||||
creating.value = false
|
||||
newItemName.value = ''
|
||||
reload()
|
||||
})
|
||||
}
|
||||
|
||||
watchDebounced(
|
||||
() => autocomplete.value?.query,
|
||||
(val) => {
|
||||
@@ -153,7 +207,7 @@ const options = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
const reload = (val) => {
|
||||
const reload = (val = '') => {
|
||||
options.update({
|
||||
params: {
|
||||
txt: val,
|
||||
@@ -178,4 +232,6 @@ const labelClasses = computed(() => {
|
||||
'text-ink-gray-5',
|
||||
]
|
||||
})
|
||||
|
||||
defineExpose({ reload })
|
||||
</script>
|
||||
|
||||
@@ -88,6 +88,7 @@ const props = defineProps({
|
||||
|
||||
const show = defineModel()
|
||||
const emailTemplates = defineModel('emailTemplates')
|
||||
const emit = defineEmits(['created'])
|
||||
const template = reactive({
|
||||
name: '',
|
||||
subject: '',
|
||||
@@ -113,6 +114,7 @@ const createNewTemplate = (close) => {
|
||||
{
|
||||
onSuccess() {
|
||||
emailTemplates.value.reload()
|
||||
emit('created', template.name)
|
||||
refreshForm(close)
|
||||
toast.success(__('Email Template created successfully'))
|
||||
},
|
||||
|
||||
@@ -72,10 +72,11 @@
|
||||
/>
|
||||
|
||||
<Link
|
||||
v-model="batchDetail.doc.category"
|
||||
doctype="LMS Category"
|
||||
:label="__('Category')"
|
||||
v-model="batchDetail.doc.category"
|
||||
:onCreate="(value, close) => openSettings('Categories', close)"
|
||||
:inlineCreate="true"
|
||||
:onCreate="createCategory"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -156,12 +157,14 @@
|
||||
class="mb-4"
|
||||
/>
|
||||
<Link
|
||||
ref="emailTemplateLinkRef"
|
||||
doctype="Email Template"
|
||||
:label="__('Enrollment Confirmation Email Template')"
|
||||
v-model="batchDetail.doc.confirmation_email_template"
|
||||
:onCreate="
|
||||
(value, close) => {
|
||||
openSettings('Email Templates', close)
|
||||
if (close) close()
|
||||
showEmailTemplateModal = true
|
||||
}
|
||||
"
|
||||
/>
|
||||
@@ -280,6 +283,12 @@
|
||||
:defaultRoles="['batch_evaluator']"
|
||||
@created="onInstructorCreated"
|
||||
/>
|
||||
<EmailTemplateModal
|
||||
v-model="showEmailTemplateModal"
|
||||
v-model:emailTemplates="emailTemplates"
|
||||
templateID="new"
|
||||
@created="onEmailTemplateCreated"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
@@ -300,8 +309,10 @@ import {
|
||||
createDocumentResource,
|
||||
toast,
|
||||
call,
|
||||
createListResource,
|
||||
} from 'frappe-ui'
|
||||
import {
|
||||
createLMSCategory,
|
||||
escapeHTML,
|
||||
getMetaInfo,
|
||||
openSettings,
|
||||
@@ -317,6 +328,7 @@ import Link from '@/components/Controls/Link.vue'
|
||||
import BatchCourses from '@/pages/Batches/components/BatchCourses.vue'
|
||||
import Assessments from '@/pages/Batches/components/Assessments.vue'
|
||||
import NewMemberModal from '@/components/Modals/NewMemberModal.vue'
|
||||
import EmailTemplateModal from '@/components/Modals/EmailTemplateModal.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const user = inject('$user')
|
||||
@@ -329,6 +341,29 @@ const { $dialog } = app.appContext.config.globalProperties
|
||||
const isDirty = ref(false)
|
||||
const originalDoc = ref(null)
|
||||
const showMemberModal = ref(false)
|
||||
const showEmailTemplateModal = ref(false)
|
||||
const emailTemplateLinkRef = ref(null)
|
||||
|
||||
const emailTemplates = createListResource({
|
||||
doctype: 'Email Template',
|
||||
fields: ['name', 'subject', 'use_html', 'response', 'response_html'],
|
||||
auto: true,
|
||||
orderBy: 'modified desc',
|
||||
cache: 'email-templates',
|
||||
})
|
||||
|
||||
const onEmailTemplateCreated = (name) => {
|
||||
batchDetail.doc.confirmation_email_template = name
|
||||
emailTemplateLinkRef.value?.reload()
|
||||
}
|
||||
|
||||
const createCategory = (name, done) => {
|
||||
createLMSCategory(name).then((categoryName) => {
|
||||
if (!categoryName) return
|
||||
batchDetail.doc.category = categoryName
|
||||
done()
|
||||
})
|
||||
}
|
||||
|
||||
const onInstructorCreated = (user) => {
|
||||
instructors.value = [...instructors.value, user.name]
|
||||
|
||||
@@ -46,16 +46,10 @@
|
||||
autocomplete="off"
|
||||
/>
|
||||
<Link
|
||||
doctype="LMS Category"
|
||||
v-model="batch.category"
|
||||
doctype="LMS Category"
|
||||
:label="__('Category')"
|
||||
:allowCreate="true"
|
||||
:onCreate="
|
||||
() => {
|
||||
openSettings('Categories')
|
||||
show = false
|
||||
}
|
||||
"
|
||||
:onCreate="createCategory"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="batch.seat_count"
|
||||
@@ -126,7 +120,7 @@ import { Button, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
|
||||
import { useOnboarding, useTelemetry } from 'frappe-ui/frappe'
|
||||
import { computed, inject, onMounted, onBeforeUnmount, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { cleanError, openSettings, sanitizeHTML, escapeHTML } from '@/utils'
|
||||
import { sanitizeHTML, escapeHTML, createLMSCategory } from '@/utils'
|
||||
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import NewMemberModal from '@/components/Modals/NewMemberModal.vue'
|
||||
@@ -172,6 +166,14 @@ const batch = ref<Batch>({
|
||||
medium: null,
|
||||
})
|
||||
|
||||
const createCategory = (name: string, done: () => void) => {
|
||||
createLMSCategory(name).then((categoryName: string) => {
|
||||
if (!categoryName) return
|
||||
batch.value.category = categoryName
|
||||
done()
|
||||
})
|
||||
}
|
||||
|
||||
const onInstructorCreated = (user: any) => {
|
||||
batch.value.instructors = [...batch.value.instructors, user.name]
|
||||
}
|
||||
|
||||
@@ -18,10 +18,11 @@
|
||||
@input="makeFormDirty()"
|
||||
/>
|
||||
<Link
|
||||
doctype="LMS Category"
|
||||
v-model="courseResource.doc.category"
|
||||
doctype="LMS Category"
|
||||
:label="__('Category')"
|
||||
:onCreate="(value, close) => openSettings('Categories', close)"
|
||||
:inlineCreate="true"
|
||||
:onCreate="createCategory"
|
||||
@update:modelValue="makeFormDirty()"
|
||||
/>
|
||||
</div>
|
||||
@@ -253,6 +254,7 @@
|
||||
</div>
|
||||
<div v-if="courseResource.doc.paid_certificate" class="space-y-5">
|
||||
<Link
|
||||
ref="evaluatorLinkRef"
|
||||
doctype="Course Evaluator"
|
||||
v-model="courseResource.doc.evaluator"
|
||||
:label="__('Evaluator')"
|
||||
@@ -336,9 +338,10 @@ import {
|
||||
import {
|
||||
escapeHTML,
|
||||
getMetaInfo,
|
||||
openSettings,
|
||||
sanitizeHTML,
|
||||
updateMetaInfo,
|
||||
createLMSCategory,
|
||||
cleanError,
|
||||
} from '@/utils'
|
||||
import { Trash2, X } from 'lucide-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
@@ -360,6 +363,7 @@ const app = getCurrentInstance()
|
||||
const { $dialog } = app.appContext.config.globalProperties
|
||||
const isDirty = ref(false)
|
||||
const showMemberModal = ref(false)
|
||||
const evaluatorLinkRef = ref(null)
|
||||
const memberModalRoles = ref(['course_creator'])
|
||||
|
||||
const props = defineProps({
|
||||
@@ -432,6 +436,7 @@ const submitCourse = () => {
|
||||
const onMemberCreated = (user) => {
|
||||
if (memberModalRoles.value.includes('batch_evaluator')) {
|
||||
courseResource.doc.evaluator = user.name
|
||||
evaluatorLinkRef.value?.reload()
|
||||
makeFormDirty()
|
||||
} else {
|
||||
instructors.value = [...instructors.value, user.name]
|
||||
@@ -555,6 +560,15 @@ const checkPermission = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const createCategory = (name, done) => {
|
||||
createLMSCategory(name).then((categoryName) => {
|
||||
if (!categoryName) return
|
||||
courseResource.doc.category = categoryName
|
||||
done()
|
||||
makeFormDirty()
|
||||
})
|
||||
}
|
||||
|
||||
const makeFormDirty = () => {
|
||||
isDirty.value = true
|
||||
}
|
||||
|
||||
@@ -16,15 +16,11 @@
|
||||
autocomplete="off"
|
||||
/>
|
||||
<Link
|
||||
doctype="LMS Category"
|
||||
v-model="course.category"
|
||||
doctype="LMS Category"
|
||||
:label="__('Category')"
|
||||
:onCreate="
|
||||
() => {
|
||||
openSettings('Categories')
|
||||
show = false
|
||||
}
|
||||
"
|
||||
:inlineCreate="true"
|
||||
:onCreate="createCategory"
|
||||
/>
|
||||
<MultiSelect
|
||||
v-model="course.instructors"
|
||||
@@ -86,8 +82,13 @@ import { Button, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
|
||||
import { useOnboarding, useTelemetry } from 'frappe-ui/frappe'
|
||||
import { inject, onMounted, onBeforeUnmount, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { cleanError, openSettings, sanitizeHTML, escapeHTML } from '@/utils'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import {
|
||||
cleanError,
|
||||
sanitizeHTML,
|
||||
escapeHTML,
|
||||
createLMSCategory,
|
||||
} from '@/utils'
|
||||
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||
import Uploader from '@/components/Controls/Uploader.vue'
|
||||
import NewMemberModal from '@/components/Modals/NewMemberModal.vue'
|
||||
@@ -122,6 +123,14 @@ const course = ref<Course>({
|
||||
image: null,
|
||||
})
|
||||
|
||||
const createCategory = (name: string, done: () => void) => {
|
||||
createLMSCategory(name).then((categoryName: string) => {
|
||||
if (!categoryName) return
|
||||
course.value.category = categoryName
|
||||
done()
|
||||
})
|
||||
}
|
||||
|
||||
const onInstructorCreated = (user: any) => {
|
||||
course.value.instructors = [...course.value.instructors, user.name]
|
||||
}
|
||||
|
||||
@@ -824,6 +824,24 @@ const extractYouTubeId = (url) => {
|
||||
}
|
||||
}
|
||||
|
||||
export const createLMSCategory = (name) => {
|
||||
return call('frappe.client.insert', {
|
||||
doc: {
|
||||
doctype: 'LMS Category',
|
||||
category: name,
|
||||
},
|
||||
})
|
||||
.then((data) => {
|
||||
toast.success(__('Category created successfully'))
|
||||
return data.name
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(
|
||||
cleanError(err.messages?.[0]) || __('Unable to create category')
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export const openSettings = (category, close = null) => {
|
||||
const settingsStore = useSettings()
|
||||
if (close) {
|
||||
|
||||
Reference in New Issue
Block a user