mirror of
https://github.com/frappe/lms.git
synced 2026-04-19 22:52:29 +03:00
Merge pull request #2280 from frappe/main-hotfix
chore: merge 'main-hotfix' into 'main'
This commit is contained in:
16
.github/workflows/ui-tests.yml
vendored
16
.github/workflows/ui-tests.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: UI
|
||||
name: UI Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -16,13 +16,14 @@ permissions:
|
||||
jobs:
|
||||
|
||||
test:
|
||||
name: UI Tests (Cypress) - ${{ matrix.containers }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
name: UI Tests (Cypress)
|
||||
matrix:
|
||||
containers: [1, 2]
|
||||
|
||||
services:
|
||||
mariadb:
|
||||
@@ -114,6 +115,15 @@ jobs:
|
||||
env:
|
||||
CYPRESS_BASE_URL: http://lms.test:8000
|
||||
CYPRESS_RECORD_KEY: 095366ec-7b9f-41bd-aeec-03bb76d627fe
|
||||
SPLIT: ${{ strategy.job-total }}
|
||||
SPLIT_INDEX: ${{ strategy.job-index }}
|
||||
|
||||
- name: Upload Cypress screenshots if tests fail
|
||||
if: ${{ failure() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cypress-screenshots-${{ matrix.containers }}
|
||||
path: cypress/screenshots
|
||||
|
||||
- name: Stop server and wait for coverage file
|
||||
run: |
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { defineConfig } from "cypress";
|
||||
import cypressSplit from "cypress-split";
|
||||
|
||||
export default defineConfig({
|
||||
projectId: "vandxn",
|
||||
@@ -14,5 +15,12 @@ export default defineConfig({
|
||||
},
|
||||
e2e: {
|
||||
baseUrl: "http://pertest:8000",
|
||||
setupNodeEvents(on, config) {
|
||||
// Splitting tests only works when Cypress Cloud is not orchestrating parallel runs.
|
||||
if (process.env.CYPRESS_CLOUD_PARALLEL !== "1") {
|
||||
cypressSplit(on, config);
|
||||
}
|
||||
return config;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
:fileTypes="[fileType]"
|
||||
:validateFile="(file: File) => validateFile(file, true, type)"
|
||||
@success="(file: File) => saveFile(file)"
|
||||
@failure="onUploadFailure"
|
||||
>
|
||||
<template v-slot="{ file, progress, uploading, openFileSelector }">
|
||||
<div class="flex items-center">
|
||||
@@ -19,8 +20,8 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<Button @click="openFileSelector">
|
||||
{{ __('Upload') }}
|
||||
<Button @click="openFileSelector" :loading="uploading">
|
||||
{{ uploading ? `${__('Uploading')} ${progress}%` : __('Upload') }}
|
||||
</Button>
|
||||
<div class="mt-1 text-ink-gray-5 text-sm leading-5">
|
||||
{{ __(description) }}
|
||||
@@ -62,7 +63,7 @@
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { validateFile } from '@/utils'
|
||||
import { Button, FileUploader } from 'frappe-ui'
|
||||
import { Button, FileUploader, toast } from 'frappe-ui'
|
||||
import { Image, Video } from 'lucide-vue-next'
|
||||
import { computed } from 'vue'
|
||||
|
||||
@@ -100,4 +101,14 @@ const saveFile = (file: any) => {
|
||||
const removeImage = () => {
|
||||
emit('update:modelValue', '')
|
||||
}
|
||||
|
||||
const onUploadFailure = (error: any) => {
|
||||
let message = __('Error Uploading File')
|
||||
if (error?._server_messages) {
|
||||
message = JSON.parse(JSON.parse(error._server_messages)[0]).message
|
||||
} else if (error?.exc) {
|
||||
message = JSON.parse(error.exc)[0].split('\n').slice(-2, -1)[0]
|
||||
}
|
||||
toast.error(message)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
size: '4xl',
|
||||
size: '5xl',
|
||||
}"
|
||||
>
|
||||
<template #body>
|
||||
@@ -19,10 +19,17 @@
|
||||
rowHeight: 'h-16',
|
||||
selectable: false,
|
||||
}"
|
||||
class="border rounded-lg py-2 px-3"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
></ListHeader>
|
||||
class="mb-2 grid items-center rounded bg-surface-white border-b rounded-none !px-0"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in feedbackColumns">
|
||||
<template #prefix="{ item }">
|
||||
<FeatherIcon :name="item.icon?.toString()" class="h-4 w-4" />
|
||||
</template>
|
||||
</ListHeaderItem>
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
<ListRow
|
||||
:row="row"
|
||||
@@ -41,7 +48,7 @@
|
||||
class="flex"
|
||||
:image="row['member_image']"
|
||||
:label="item"
|
||||
size="sm"
|
||||
size="xl"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -63,9 +70,11 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Dialog,
|
||||
ListView,
|
||||
Avatar,
|
||||
FeatherIcon,
|
||||
ListView,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListRows,
|
||||
ListRow,
|
||||
ListRowItem,
|
||||
@@ -89,27 +98,43 @@ const feedbackColumns = computed(() => {
|
||||
label: 'Member',
|
||||
key: 'member_name',
|
||||
width: '10rem',
|
||||
align: 'left',
|
||||
icon: 'user',
|
||||
},
|
||||
{
|
||||
label: 'Feedback',
|
||||
key: 'feedback',
|
||||
width: '15rem',
|
||||
align: 'left',
|
||||
icon: 'message-square',
|
||||
},
|
||||
{
|
||||
label: 'Content',
|
||||
key: 'content',
|
||||
width: '9rem',
|
||||
width: '10rem',
|
||||
align: 'center',
|
||||
icon: 'book',
|
||||
},
|
||||
{
|
||||
label: 'Instructors',
|
||||
key: 'instructors',
|
||||
width: '9rem',
|
||||
width: '10rem',
|
||||
align: 'center',
|
||||
icon: 'users',
|
||||
},
|
||||
{
|
||||
label: 'Value',
|
||||
key: 'value',
|
||||
width: '9rem',
|
||||
width: '10rem',
|
||||
align: 'center',
|
||||
icon: 'dollar-sign',
|
||||
},
|
||||
]
|
||||
})
|
||||
</script>
|
||||
<style>
|
||||
.feedback-list > button > div {
|
||||
padding: 0.2rem 0;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -228,14 +228,6 @@
|
||||
:model-value="reviewQuestions.includes(activeQuestion) ? 1 : 0"
|
||||
@change="markForReview($event, activeQuestion)"
|
||||
/>
|
||||
<!-- <div class="text-sm text-ink-gray-5">
|
||||
{{
|
||||
__('Question {0} of {1}').format(
|
||||
activeQuestion,
|
||||
questions.length
|
||||
)
|
||||
}}
|
||||
</div> -->
|
||||
<div
|
||||
v-if="!quiz.data.show_answers"
|
||||
class="flex items-center space-x-2"
|
||||
@@ -257,11 +249,13 @@
|
||||
'cursor-pointer': item !== '...',
|
||||
'bg-surface-gray-4 border border-outline-gray-5 font-medium':
|
||||
activeQuestion == item,
|
||||
'bg-surface-gray-3 text-ink-gray-6':
|
||||
activeQuestion != item && item !== '...',
|
||||
'text-ink-gray-5': item === '...',
|
||||
'bg-surface-blue-3 text-ink-white':
|
||||
attemptedQuestions.includes(item) && activeQuestion != item,
|
||||
'bg-surface-gray-3 text-ink-gray-6':
|
||||
activeQuestion != item &&
|
||||
item !== '...' &&
|
||||
!attemptedQuestions.includes(item),
|
||||
}"
|
||||
@click="item !== '...' && switchQuestion(item)"
|
||||
>
|
||||
@@ -315,7 +309,7 @@
|
||||
<div class="flex items-center space-x-2 mt-2">
|
||||
<div
|
||||
v-for="index in reviewQuestions"
|
||||
@click="activeQuestion = index"
|
||||
@click="switchQuestion(index)"
|
||||
class="w-6 h-6 rounded-full flex items-center justify-center text-sm cursor-pointer bg-surface-gray-3"
|
||||
>
|
||||
{{ index }}
|
||||
|
||||
@@ -328,8 +328,7 @@ import {
|
||||
updateMetaInfo,
|
||||
} from '@/utils'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useOnboarding, useTelemetry } from 'frappe-ui/frappe'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { useTelemetry } from 'frappe-ui/frappe'
|
||||
import Uploader from '@/components/Controls/Uploader.vue'
|
||||
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
@@ -340,8 +339,6 @@ import EmailTemplateModal from '@/components/Modals/EmailTemplateModal.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const user = inject('$user')
|
||||
const { brand } = sessionStore()
|
||||
const { updateOnboardingStep } = useOnboarding('learning')
|
||||
const instructors = ref([])
|
||||
const app = getCurrentInstance()
|
||||
const { capture } = useTelemetry()
|
||||
|
||||
@@ -78,13 +78,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Switch
|
||||
size="sm"
|
||||
v-model="certification"
|
||||
:label="__('Certification')"
|
||||
:description="__('Only show batches that offer a certificate.')"
|
||||
@change="updateBatches()"
|
||||
/>
|
||||
<Tooltip :text="__('Only show batches that offer a certificate')">
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
v-model="certification"
|
||||
:label="__('Certification')"
|
||||
@change="updateBatches()"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -123,7 +124,7 @@ import {
|
||||
Dropdown,
|
||||
FormControl,
|
||||
Select,
|
||||
Switch,
|
||||
Tooltip,
|
||||
TabButtons,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
|
||||
@@ -183,9 +183,3 @@ const isAdmin = computed(() => {
|
||||
return user.data?.is_moderator || user.data?.is_evaluator
|
||||
})
|
||||
</script>
|
||||
<style>
|
||||
.feedback-list > button > div {
|
||||
align-items: start;
|
||||
padding: 0.15rem 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,100 +1,108 @@
|
||||
<template>
|
||||
<div v-if="batch.data" class="border-2 rounded-md p-5 lg:w-72">
|
||||
<Badge
|
||||
v-if="batch.data.seat_count && batch.data.seats_left > 0"
|
||||
variant="subtle"
|
||||
theme="green"
|
||||
size="md"
|
||||
:class="
|
||||
batch.data.amount || batch.data.courses.length
|
||||
? 'float-right'
|
||||
: 'w-fit mb-4'
|
||||
"
|
||||
:label="
|
||||
batch.data.seats_left +
|
||||
' ' +
|
||||
(batch.data.seats_left > 1 ? __('Seats Left') : __('Seat Left'))
|
||||
"
|
||||
<div v-if="batch.data" class="border-2 rounded-md lg:w-72">
|
||||
<video
|
||||
v-if="batch.data.video_link"
|
||||
:src="batch.data.video_link"
|
||||
controls
|
||||
class="rounded-t-md w-full"
|
||||
/>
|
||||
<Badge
|
||||
v-else-if="batch.data.seat_count && batch.data.seats_left <= 0"
|
||||
variant="subtle"
|
||||
theme="red"
|
||||
size="md"
|
||||
class="float-right"
|
||||
:label="__('Sold Out')"
|
||||
/>
|
||||
<div
|
||||
v-if="batch.data.amount"
|
||||
class="text-lg font-semibold mb-5 text-ink-gray-9"
|
||||
>
|
||||
{{ formatNumberIntoCurrency(batch.data.amount, batch.data.currency) }}
|
||||
</div>
|
||||
<div
|
||||
v-if="batch.data.courses.length"
|
||||
class="flex items-center mb-3 text-ink-gray-7"
|
||||
>
|
||||
<BookOpen class="h-4 w-4 stroke-1.5 mr-2" />
|
||||
<span> {{ batch.data.courses.length }} {{ __('Courses') }} </span>
|
||||
</div>
|
||||
<DateRange
|
||||
:startDate="batch.data.start_date"
|
||||
:endDate="batch.data.end_date"
|
||||
class="mb-3"
|
||||
/>
|
||||
<div class="flex items-center mb-3 text-ink-gray-7">
|
||||
<Clock class="h-4 w-4 stroke-1.5 mr-2" />
|
||||
<span>
|
||||
{{ formatTime(batch.data.start_time) }} -
|
||||
{{ formatTime(batch.data.end_time) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="batch.data.timezone" class="flex items-center text-ink-gray-7">
|
||||
<Globe class="h-4 w-4 stroke-1.5 mr-2" />
|
||||
<span>
|
||||
{{ batch.data.timezone }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<Badge
|
||||
v-if="batch.data.seat_count && batch.data.seats_left > 0"
|
||||
variant="subtle"
|
||||
theme="green"
|
||||
size="md"
|
||||
:class="
|
||||
batch.data.amount || batch.data.courses.length
|
||||
? 'float-right'
|
||||
: 'w-fit mb-4'
|
||||
"
|
||||
:label="
|
||||
batch.data.seats_left +
|
||||
' ' +
|
||||
(batch.data.seats_left > 1 ? __('Seats Left') : __('Seat Left'))
|
||||
"
|
||||
/>
|
||||
<Badge
|
||||
v-else-if="batch.data.seat_count && batch.data.seats_left <= 0"
|
||||
variant="subtle"
|
||||
theme="red"
|
||||
size="md"
|
||||
class="float-right"
|
||||
:label="__('Sold Out')"
|
||||
/>
|
||||
<div
|
||||
v-if="batch.data.amount"
|
||||
class="text-lg font-semibold mb-5 text-ink-gray-9"
|
||||
>
|
||||
{{ formatNumberIntoCurrency(batch.data.amount, batch.data.currency) }}
|
||||
</div>
|
||||
<div
|
||||
v-if="batch.data.courses.length"
|
||||
class="flex items-center mb-3 text-ink-gray-7"
|
||||
>
|
||||
<BookOpen class="h-4 w-4 stroke-1.5 mr-2" />
|
||||
<span> {{ batch.data.courses.length }} {{ __('Courses') }} </span>
|
||||
</div>
|
||||
<DateRange
|
||||
:startDate="batch.data.start_date"
|
||||
:endDate="batch.data.end_date"
|
||||
class="mb-3"
|
||||
/>
|
||||
<div class="flex items-center mb-3 text-ink-gray-7">
|
||||
<Clock class="h-4 w-4 stroke-1.5 mr-2" />
|
||||
<span>
|
||||
{{ formatTime(batch.data.start_time) }} -
|
||||
{{ formatTime(batch.data.end_time) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="batch.data.timezone" class="flex items-center text-ink-gray-7">
|
||||
<Globe class="h-4 w-4 stroke-1.5 mr-2" />
|
||||
<span>
|
||||
{{ batch.data.timezone }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="!readOnlyMode && !canAccessBatch">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Billing',
|
||||
params: {
|
||||
type: 'batch',
|
||||
name: batch.data.name,
|
||||
},
|
||||
}"
|
||||
v-if="
|
||||
batch.data.paid_batch &&
|
||||
batch.data.seats_left > 0 &&
|
||||
batch.data.accept_enrollments
|
||||
"
|
||||
>
|
||||
<Button class="w-full mt-4" variant="solid">
|
||||
<div v-if="!readOnlyMode && !canAccessBatch">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Billing',
|
||||
params: {
|
||||
type: 'batch',
|
||||
name: batch.data.name,
|
||||
},
|
||||
}"
|
||||
v-if="
|
||||
batch.data.paid_batch &&
|
||||
batch.data.seats_left > 0 &&
|
||||
batch.data.accept_enrollments
|
||||
"
|
||||
>
|
||||
<Button class="w-full mt-4" variant="solid">
|
||||
<template #prefix>
|
||||
<CreditCard class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
<span>
|
||||
{{ __('Register Now') }}
|
||||
</span>
|
||||
</Button>
|
||||
</router-link>
|
||||
<Button
|
||||
variant="solid"
|
||||
class="w-full mt-2"
|
||||
v-else-if="
|
||||
batch.data.allow_self_enrollment &&
|
||||
batch.data.seats_left &&
|
||||
batch.data.accept_enrollments
|
||||
"
|
||||
@click="enrollInBatch()"
|
||||
>
|
||||
<template #prefix>
|
||||
<CreditCard class="size-4 stroke-1.5" />
|
||||
<GraduationCap class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
<span>
|
||||
{{ __('Register Now') }}
|
||||
</span>
|
||||
{{ __('Enroll Now') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
<Button
|
||||
variant="solid"
|
||||
class="w-full mt-2"
|
||||
v-else-if="
|
||||
batch.data.allow_self_enrollment &&
|
||||
batch.data.seats_left &&
|
||||
batch.data.accept_enrollments
|
||||
"
|
||||
@click="enrollInBatch()"
|
||||
>
|
||||
<template #prefix>
|
||||
<GraduationCap class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Enroll Now') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -45,30 +45,35 @@
|
||||
@update:modelValue="makeFormDirty()"
|
||||
/>
|
||||
<div>
|
||||
<div class="text-xs text-ink-gray-5">
|
||||
<label class="block mb-1 text-xs text-ink-gray-5">
|
||||
{{ __('Tags') }}
|
||||
</div>
|
||||
<FormControl
|
||||
v-model="newTag"
|
||||
:placeholder="__('Add a keyword and then press enter')"
|
||||
:class="['w-full', 'flex-1', 'my-1']"
|
||||
@keyup.enter="updateTags()"
|
||||
id="tags"
|
||||
/>
|
||||
<div>
|
||||
<div class="flex items-center flex-wrap gap-2">
|
||||
<div
|
||||
v-if="courseResource.doc.tags"
|
||||
v-for="tag in courseResource.doc.tags?.split(', ')"
|
||||
class="flex items-center bg-surface-gray-2 text-ink-gray-7 p-2 rounded-md"
|
||||
>
|
||||
{{ tag }}
|
||||
<X
|
||||
class="stroke-1.5 w-3 h-3 ml-2 cursor-pointer"
|
||||
@click="removeTag(tag)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-1.5 w-full rounded-lg border border-[--surface-gray-2] bg-surface-gray-2 px-2 py-1.5 cursor-text transition-colors focus-within:bg-surface-white focus-within:border-outline-gray-4 focus-within:shadow-sm focus-within:ring-0 focus-within:ring-2 focus-within:ring-outline-gray-3"
|
||||
@click="$refs.tagInput?.focus()"
|
||||
>
|
||||
<button
|
||||
v-for="tag in parsedTags"
|
||||
:key="tag"
|
||||
class="inline-flex items-center gap-1 bg-surface-white border border-outline-gray-2 text-ink-gray-7 pl-2 pr-1.5 py-0.5 rounded text-base leading-5"
|
||||
@click.stop="removeTag(tag)"
|
||||
>
|
||||
<span>{{ tag }}</span>
|
||||
<X class="size-3.5 stroke-1.5 shrink-0" />
|
||||
</button>
|
||||
<input
|
||||
id="tags"
|
||||
ref="tagInput"
|
||||
v-model="newTag"
|
||||
type="text"
|
||||
:placeholder="
|
||||
!parsedTags.length
|
||||
? __('Add a keyword and press enter')
|
||||
: ''
|
||||
"
|
||||
class="flex-1 min-w-[4rem] border-none outline-none bg-transparent p-0 text-base focus:ring-0"
|
||||
@keyup.enter="updateTags()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -355,11 +360,9 @@ import {
|
||||
sanitizeHTML,
|
||||
updateMetaInfo,
|
||||
createLMSCategory,
|
||||
cleanError,
|
||||
} from '@/utils'
|
||||
import { Trash2, X } from 'lucide-vue-next'
|
||||
import { X } from 'lucide-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { sessionStore } from '../../stores/session'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import CourseOutline from '@/components/CourseOutline.vue'
|
||||
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||
@@ -369,7 +372,6 @@ import NewMemberModal from '@/components/Modals/NewMemberModal.vue'
|
||||
|
||||
const user = inject('$user')
|
||||
const newTag = ref('')
|
||||
const { brand } = sessionStore()
|
||||
const router = useRouter()
|
||||
const instructors = ref([])
|
||||
const related_courses = ref([])
|
||||
@@ -412,6 +414,11 @@ const courseResource = createDocumentResource({
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const parsedTags = computed(() => {
|
||||
const tags = courseResource.doc?.tags
|
||||
return tags ? tags.split(', ').filter(Boolean) : []
|
||||
})
|
||||
|
||||
watch(
|
||||
() => courseResource.doc,
|
||||
() => {
|
||||
@@ -595,13 +602,6 @@ const makeFormDirty = () => {
|
||||
isDirty.value = true
|
||||
}
|
||||
|
||||
usePageMeta(() => {
|
||||
return {
|
||||
title: courseResource.doc?.title,
|
||||
icon: brand.favicon,
|
||||
}
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
submitCourse,
|
||||
trashCourse,
|
||||
|
||||
@@ -157,7 +157,7 @@ import {
|
||||
createResource,
|
||||
usePageMeta,
|
||||
} from 'frappe-ui'
|
||||
import { inject, ref, computed } from 'vue'
|
||||
import { inject, ref, computed, watch, nextTick } from 'vue'
|
||||
import { sessionStore } from '../stores/session'
|
||||
import JobApplicationModal from '@/components/Modals/JobApplicationModal.vue'
|
||||
import {
|
||||
@@ -193,17 +193,11 @@ const job = createResource({
|
||||
},
|
||||
cache: ['job', props.job],
|
||||
auto: true,
|
||||
onSuccess: (data) => {
|
||||
if (user.data?.name) {
|
||||
jobApplication.submit()
|
||||
applicationCount.submit()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const jobApplication = createResource({
|
||||
url: 'frappe.client.get_list',
|
||||
makeParams(values) {
|
||||
makeParams() {
|
||||
return {
|
||||
doctype: 'LMS Job Application',
|
||||
filters: {
|
||||
@@ -216,7 +210,7 @@ const jobApplication = createResource({
|
||||
|
||||
const applicationCount = createResource({
|
||||
url: 'frappe.client.get_count',
|
||||
makeParams(values) {
|
||||
makeParams() {
|
||||
return {
|
||||
doctype: 'LMS Job Application',
|
||||
filters: {
|
||||
@@ -226,6 +220,18 @@ const applicationCount = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
const stopWatch = watch(
|
||||
() => [job.data?.name, user.data?.name],
|
||||
([jobName, userName]) => {
|
||||
if (jobName && userName) {
|
||||
jobApplication.submit()
|
||||
applicationCount.submit()
|
||||
nextTick(() => stopWatch())
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const openApplicationModal = () => {
|
||||
showApplicationModal.value = true
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import { createResource, FormControl, Button, Badge, toast } from 'frappe-ui'
|
||||
import { computed, reactive, ref, onMounted, inject } from 'vue'
|
||||
import { computed, reactive, ref, onMounted, inject, watch } from 'vue'
|
||||
import { convertToTitleCase } from '@/utils'
|
||||
import { Plus, X, Check, CircleAlert } from 'lucide-vue-next'
|
||||
|
||||
@@ -195,16 +195,30 @@ const evaluator = createResource({
|
||||
evaluator: props.profile.data?.name,
|
||||
},
|
||||
auto: true,
|
||||
onSuccess(data) {
|
||||
if (data.slots.unavailable_from) from.value = data.slots.unavailable_from
|
||||
if (data.slots.unavailable_to) to.value = data.slots.unavailable_to
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
console.error(err)
|
||||
},
|
||||
})
|
||||
|
||||
watch(evaluator, () => {
|
||||
if (evaluator.data?.slots?.unavailable_from)
|
||||
from.value = evaluator.data.slots.unavailable_from
|
||||
if (evaluator.data?.slots?.unavailable_to)
|
||||
to.value = evaluator.data.slots.unavailable_to
|
||||
|
||||
evaluator.data?.slots?.schedule.forEach((slot) => {
|
||||
slot.start_time = formatTime(slot.start_time)
|
||||
slot.end_time = formatTime(slot.end_time)
|
||||
})
|
||||
})
|
||||
|
||||
const formatTime = (time) => {
|
||||
if (!time) return ''
|
||||
const [hour, minute] = time.split(':')
|
||||
return `${hour.padStart(2, '0')}:${minute.padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const createSlot = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
makeParams(values) {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
size="sm"
|
||||
:label="__('Student')"
|
||||
:description="
|
||||
__('Can browse courses, enroll in batches, and view content.')
|
||||
__('Access courses, join batches, and track learning progress')
|
||||
"
|
||||
v-model="lms_student"
|
||||
@update:modelValue="saveRole('lms_student')"
|
||||
@@ -25,27 +25,21 @@
|
||||
<Switch
|
||||
size="sm"
|
||||
:label="__('Course Creator')"
|
||||
:description="
|
||||
__('Can create, edit, and manage courses, chapters, and lessons.')
|
||||
"
|
||||
:description="__('Build and manage courses, chapters, and lessons')"
|
||||
v-model="course_creator"
|
||||
@update:modelValue="saveRole('course_creator')"
|
||||
/>
|
||||
<Switch
|
||||
size="sm"
|
||||
:label="__('Evaluator')"
|
||||
:description="
|
||||
__('Can create batches/live classes and grade student assignments.')
|
||||
"
|
||||
:description="__('Manage batches, review and grade submissions')"
|
||||
v-model="batch_evaluator"
|
||||
@update:modelValue="saveRole('batch_evaluator')"
|
||||
/>
|
||||
<Switch
|
||||
size="sm"
|
||||
:label="__('Moderator')"
|
||||
:description="
|
||||
__('Full access to all content, users, and system-wide settings.')
|
||||
"
|
||||
:description="__('Oversee all users, content, and system settings')"
|
||||
v-model="moderator"
|
||||
@update:modelValue="saveRole('moderator')"
|
||||
/>
|
||||
|
||||
@@ -49,11 +49,14 @@
|
||||
"
|
||||
variant="solid"
|
||||
@click="submitCode"
|
||||
:loading="running"
|
||||
:disabled="running"
|
||||
class="text-ink-gray-9"
|
||||
>
|
||||
<template #prefix>
|
||||
<Play class="size-3" />
|
||||
</template>
|
||||
{{ __('Run') }}
|
||||
{{ running ? __('Running') : __('Run') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -172,8 +175,9 @@ const { brand } = sessionStore()
|
||||
const { settings } = useSettings()
|
||||
const router = useRouter()
|
||||
const fromLesson = ref(false)
|
||||
const falconURL = ref<string>('https://falcon.frappe.io/')
|
||||
const falconURL = ref<string>('https://falcon.frappe.io')
|
||||
const falconError = ref<string | null>(null)
|
||||
const running = ref<boolean>(false)
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -308,8 +312,10 @@ const loadFalcon = () => {
|
||||
}
|
||||
|
||||
const submitCode = async () => {
|
||||
running.value = true
|
||||
await runCode()
|
||||
createSubmission()
|
||||
running.value = false
|
||||
}
|
||||
|
||||
const runCode = async () => {
|
||||
@@ -406,6 +412,7 @@ const execute = (stdin = ''): Promise<string> => {
|
||||
|
||||
setTimeout(() => {
|
||||
if (!hasExited) {
|
||||
running.value = false
|
||||
error.value = true
|
||||
errorMessage.value = 'Execution timed out.'
|
||||
reject('Execution timed out.')
|
||||
|
||||
@@ -71,8 +71,13 @@
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
class="mb-2 grid items-center rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in columns">
|
||||
<template #prefix="{ item }">
|
||||
<FeatherIcon :name="item.icon?.toString()" class="h-4 w-4" />
|
||||
</template>
|
||||
</ListHeaderItem>
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
<ListRow
|
||||
@@ -134,13 +139,14 @@ import {
|
||||
call,
|
||||
createListResource,
|
||||
dayjs,
|
||||
FeatherIcon,
|
||||
FormControl,
|
||||
ListView,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListRows,
|
||||
ListRow,
|
||||
ListRowItem,
|
||||
FeatherIcon,
|
||||
ListSelectBanner,
|
||||
toast,
|
||||
usePageMeta,
|
||||
@@ -272,17 +278,20 @@ const columns = computed(() => {
|
||||
label: __('Title'),
|
||||
key: 'title',
|
||||
width: 3,
|
||||
icon: 'file-text',
|
||||
},
|
||||
{
|
||||
label: __('Language'),
|
||||
key: 'language',
|
||||
width: 2,
|
||||
align: 'left',
|
||||
icon: 'code',
|
||||
},
|
||||
{
|
||||
label: __('Updated On'),
|
||||
key: 'modified',
|
||||
width: 1,
|
||||
icon: 'clock',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
{{ __('Create') }}
|
||||
</Button>
|
||||
</header>
|
||||
<div class="py-5 mx-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="pt-5 mx-5">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<div class="text-lg font-semibold text-ink-gray-9">
|
||||
{{ __('{0} Quizzes').format(quizzes.data.length) }}
|
||||
{{ __('{0} Quizzes').format(quizzes.data?.length) }}
|
||||
</div>
|
||||
<FormControl v-model="search" type="text" placeholder="Search">
|
||||
<template #prefix>
|
||||
@@ -27,9 +27,10 @@
|
||||
:rows="quizzes.data"
|
||||
row-key="name"
|
||||
:options="{ showTooltip: false, selectable: true }"
|
||||
class="h-[79vh] border-b"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
class="mb-2 grid items-center rounded bg-surface-white border-b rounded-none p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in quizColumns">
|
||||
<template #prefix="{ item }">
|
||||
@@ -47,7 +48,7 @@
|
||||
},
|
||||
}"
|
||||
>
|
||||
<ListRow :row="row">
|
||||
<ListRow :row="row" class="hover:bg-surface-gray-2">
|
||||
<template #default="{ column, item }">
|
||||
<ListRowItem :item="row[column.key]" :align="column.align">
|
||||
<div v-if="column.key == 'show_answers'">
|
||||
@@ -59,7 +60,7 @@
|
||||
</div>
|
||||
<div
|
||||
v-else-if="column.key == 'modified'"
|
||||
class="text-xs text-ink-gray-5"
|
||||
class="text-sm text-ink-gray-5"
|
||||
>
|
||||
{{ row[column.key] }}
|
||||
</div>
|
||||
@@ -71,7 +72,7 @@
|
||||
</ListRow>
|
||||
</router-link>
|
||||
</ListRows>
|
||||
<ListSelectBanner>
|
||||
<ListSelectBanner class="bottom-50">
|
||||
<template #actions="{ unselectAll, selections }">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
@@ -85,10 +86,14 @@
|
||||
</ListSelectBanner>
|
||||
</ListView>
|
||||
<EmptyState v-else type="Quizzes" />
|
||||
<div v-if="quizzes.hasNextPage" class="flex justify-center my-5">
|
||||
<Button @click="quizzes.next()">
|
||||
<div class="flex items-center justify-end space-x-3 mt-3">
|
||||
<Button v-if="quizzes.hasNextPage" @click="quizzes.next()">
|
||||
{{ __('Load More') }}
|
||||
</Button>
|
||||
<div v-if="quizzes.hasNextPage" class="h-8 border-l"></div>
|
||||
<div class="text-ink-gray-5">
|
||||
{{ quizzes.data?.length }} {{ __('of') }} {{ totalQuizzes.data }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog
|
||||
@@ -123,6 +128,7 @@ import {
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
createListResource,
|
||||
createResource,
|
||||
Dialog,
|
||||
FeatherIcon,
|
||||
FormControl,
|
||||
@@ -157,10 +163,12 @@ const showForm = ref(false)
|
||||
const title = ref('')
|
||||
|
||||
onMounted(() => {
|
||||
if (!user.data?.is_moderator && !user.data?.is_instructor) {
|
||||
if (
|
||||
!user.data?.is_moderator &&
|
||||
!user.data?.is_instructor &&
|
||||
!user.data?.is_evaluator
|
||||
) {
|
||||
router.push({ name: 'Courses' })
|
||||
} else if (!user.data?.is_moderator) {
|
||||
quizFilters.value['owner'] = user.data?.name
|
||||
}
|
||||
if (route.query.new === 'true') {
|
||||
showForm.value = true
|
||||
@@ -173,6 +181,9 @@ watch(search, () => {
|
||||
filters: quizFilters.value,
|
||||
})
|
||||
quizzes.reload()
|
||||
totalQuizzes.update({
|
||||
filters: quizFilters.value,
|
||||
})
|
||||
})
|
||||
|
||||
const quizzes = createListResource({
|
||||
@@ -194,12 +205,26 @@ const quizzes = createListResource({
|
||||
return data.map((quiz) => {
|
||||
return {
|
||||
...quiz,
|
||||
modified: dayjs(quiz.modified).fromNow(),
|
||||
modified: dayjs(quiz.modified).fromNow(true),
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const totalQuizzes = createResource({
|
||||
url: 'frappe.client.get_count',
|
||||
params: {
|
||||
doctype: 'LMS Quiz',
|
||||
filters: quizFilters.value,
|
||||
},
|
||||
auto: true,
|
||||
cache: ['quizzes_count', user.data?.name],
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
console.error(err)
|
||||
},
|
||||
})
|
||||
|
||||
const validateTitle = () => {
|
||||
title.value = escapeHTML(title.value.trim())
|
||||
}
|
||||
@@ -249,7 +274,7 @@ const quizColumns = computed(() => {
|
||||
{
|
||||
label: __('Total Marks'),
|
||||
key: 'total_marks',
|
||||
width: 1,
|
||||
width: 0.5,
|
||||
align: 'center',
|
||||
icon: 'hash',
|
||||
},
|
||||
@@ -263,7 +288,7 @@ const quizColumns = computed(() => {
|
||||
{
|
||||
label: __('Max Attempts'),
|
||||
key: 'max_attempts',
|
||||
width: 1,
|
||||
width: 0.5,
|
||||
align: 'center',
|
||||
icon: 'repeat',
|
||||
},
|
||||
@@ -275,7 +300,7 @@ const quizColumns = computed(() => {
|
||||
icon: 'eye',
|
||||
},
|
||||
{
|
||||
label: __('Modified'),
|
||||
label: __('Updated On'),
|
||||
key: 'modified',
|
||||
width: 1,
|
||||
align: 'center',
|
||||
|
||||
@@ -86,12 +86,21 @@ def create_instructor():
|
||||
pluck="name",
|
||||
limit=1,
|
||||
)[0]
|
||||
return frappe.get_doc("User", user)
|
||||
instructor = frappe.get_doc("User", user)
|
||||
instructor.user_image = "/assets/lms/images/instructor.png"
|
||||
instructor.add_roles("Moderator")
|
||||
instructor.save()
|
||||
return instructor
|
||||
|
||||
return create_user("Jannat", "Patel", "jannat@example.com", "/assets/lms/images/instructor.png")
|
||||
return create_user(
|
||||
"Jannat", "Patel", "jannat@example.com", "/assets/lms/images/instructor.png", ["Moderator"]
|
||||
)
|
||||
|
||||
|
||||
def create_user(first_name, last_name, email, user_image):
|
||||
def create_user(first_name, last_name, email, user_image, roles=None):
|
||||
if roles is None:
|
||||
roles = ["LMS Student"]
|
||||
|
||||
filters = {"first_name": first_name, "last_name": last_name, "email": email}
|
||||
if frappe.db.exists("User", filters):
|
||||
return frappe.get_doc("User", filters)
|
||||
@@ -101,6 +110,8 @@ def create_user(first_name, last_name, email, user_image):
|
||||
user.last_name = last_name
|
||||
user.user_image = user_image
|
||||
user.email = email
|
||||
user.send_welcome_email = False
|
||||
user.add_roles(*roles)
|
||||
user.save()
|
||||
return user
|
||||
|
||||
|
||||
@@ -335,7 +335,8 @@ def get_evaluator_details(evaluator: str):
|
||||
doc = frappe.new_doc("Course Evaluator")
|
||||
doc.evaluator = evaluator
|
||||
doc.insert()
|
||||
|
||||
for slot in doc.schedule:
|
||||
print(slot.start_time, slot.end_time)
|
||||
return {
|
||||
"slots": doc.as_dict(),
|
||||
"calendar": calendar.name,
|
||||
|
||||
@@ -159,7 +159,7 @@
|
||||
"link_fieldname": "quiz"
|
||||
}
|
||||
],
|
||||
"modified": "2025-06-27 20:00:15.660323",
|
||||
"modified": "2026-03-25 20:22:22.124828",
|
||||
"modified_by": "sayali@frappe.io",
|
||||
"module": "LMS",
|
||||
"name": "LMS Quiz",
|
||||
@@ -209,6 +209,18 @@
|
||||
"report": 1,
|
||||
"role": "LMS Student",
|
||||
"share": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Batch Evaluator",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"default": "https://falcon.frappe.io/",
|
||||
"default": "https://falcon.frappe.io",
|
||||
"fieldname": "livecode_url",
|
||||
"fieldtype": "Data",
|
||||
"label": "LiveCode URL"
|
||||
@@ -512,8 +512,8 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2026-03-18 15:32:56.259783",
|
||||
"modified_by": "sayali@frappe.io",
|
||||
"modified": "2026-03-19 11:31:14.951880",
|
||||
"modified_by": "Administrator",
|
||||
"module": "LMS",
|
||||
"name": "LMS Settings",
|
||||
"owner": "Administrator",
|
||||
@@ -544,4 +544,4 @@
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
@@ -1087,7 +1087,7 @@ def get_neighbour_lesson(course: str, chapter: int, lesson: int) -> dict:
|
||||
}
|
||||
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
@frappe.whitelist(allow_guest=True) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method
|
||||
@rate_limit(limit=500, seconds=60 * 60)
|
||||
def get_batch_details(batch: str):
|
||||
if not guest_access_allowed():
|
||||
@@ -1129,6 +1129,7 @@ def get_batch_details(batch: str):
|
||||
"zoom_account",
|
||||
"conferencing_provider",
|
||||
"google_meet_account",
|
||||
"video_link",
|
||||
],
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
1011
lms/locale/ar.po
1011
lms/locale/ar.po
File diff suppressed because it is too large
Load Diff
1017
lms/locale/bs.po
1017
lms/locale/bs.po
File diff suppressed because it is too large
Load Diff
1011
lms/locale/cs.po
1011
lms/locale/cs.po
File diff suppressed because it is too large
Load Diff
1009
lms/locale/da.po
1009
lms/locale/da.po
File diff suppressed because it is too large
Load Diff
1011
lms/locale/de.po
1011
lms/locale/de.po
File diff suppressed because it is too large
Load Diff
1017
lms/locale/eo.po
1017
lms/locale/eo.po
File diff suppressed because it is too large
Load Diff
1011
lms/locale/es.po
1011
lms/locale/es.po
File diff suppressed because it is too large
Load Diff
1009
lms/locale/fa.po
1009
lms/locale/fa.po
File diff suppressed because it is too large
Load Diff
1009
lms/locale/fr.po
1009
lms/locale/fr.po
File diff suppressed because it is too large
Load Diff
1017
lms/locale/hr.po
1017
lms/locale/hr.po
File diff suppressed because it is too large
Load Diff
1011
lms/locale/hu.po
1011
lms/locale/hu.po
File diff suppressed because it is too large
Load Diff
1009
lms/locale/id.po
1009
lms/locale/id.po
File diff suppressed because it is too large
Load Diff
1009
lms/locale/it.po
1009
lms/locale/it.po
File diff suppressed because it is too large
Load Diff
1009
lms/locale/main.pot
1009
lms/locale/main.pot
File diff suppressed because it is too large
Load Diff
1009
lms/locale/my.po
1009
lms/locale/my.po
File diff suppressed because it is too large
Load Diff
1009
lms/locale/nb.po
1009
lms/locale/nb.po
File diff suppressed because it is too large
Load Diff
1015
lms/locale/nl.po
1015
lms/locale/nl.po
File diff suppressed because it is too large
Load Diff
1009
lms/locale/pl.po
1009
lms/locale/pl.po
File diff suppressed because it is too large
Load Diff
1009
lms/locale/pt.po
1009
lms/locale/pt.po
File diff suppressed because it is too large
Load Diff
1009
lms/locale/pt_BR.po
1009
lms/locale/pt_BR.po
File diff suppressed because it is too large
Load Diff
1015
lms/locale/ru.po
1015
lms/locale/ru.po
File diff suppressed because it is too large
Load Diff
1011
lms/locale/sl.po
1011
lms/locale/sl.po
File diff suppressed because it is too large
Load Diff
1017
lms/locale/sr.po
1017
lms/locale/sr.po
File diff suppressed because it is too large
Load Diff
1017
lms/locale/sr_CS.po
1017
lms/locale/sr_CS.po
File diff suppressed because it is too large
Load Diff
1017
lms/locale/sv.po
1017
lms/locale/sv.po
File diff suppressed because it is too large
Load Diff
1009
lms/locale/th.po
1009
lms/locale/th.po
File diff suppressed because it is too large
Load Diff
1011
lms/locale/tr.po
1011
lms/locale/tr.po
File diff suppressed because it is too large
Load Diff
1113
lms/locale/vi.po
1113
lms/locale/vi.po
File diff suppressed because it is too large
Load Diff
1015
lms/locale/zh.po
1015
lms/locale/zh.po
File diff suppressed because it is too large
Load Diff
@@ -122,4 +122,5 @@ lms.patches.v2_0.rename_badge_assignment_event
|
||||
lms.patches.v2_0.enable_allow_job_posting
|
||||
lms.patches.v2_0.set_conferencing_provider_for_zoom
|
||||
lms.patches.v2_0.sync_evaluator_roles
|
||||
lms.patches.v2_0.give_event_permission #10-03-2026
|
||||
lms.patches.v2_0.fix_livecode_url_default
|
||||
lms.patches.v2_0.give_event_permission #10-03-2026
|
||||
|
||||
7
lms/patches/v2_0/fix_livecode_url_default.py
Normal file
7
lms/patches/v2_0/fix_livecode_url_default.py
Normal file
@@ -0,0 +1,7 @@
|
||||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
livecode_url = frappe.db.get_single_value("LMS Settings", "livecode_url")
|
||||
if livecode_url == "https://falcon.frappe.io/":
|
||||
frappe.db.set_single_value("LMS Settings", "livecode_url", "https://falcon.frappe.io")
|
||||
@@ -29,7 +29,8 @@
|
||||
"@semantic-release/git": "^10.0.1",
|
||||
"cypress": "^14.5.4",
|
||||
"cypress-file-upload": "^5.0.8",
|
||||
"cypress-real-events": "^1.14.0"
|
||||
"cypress-real-events": "^1.14.0",
|
||||
"cypress-split": "^1.24.28"
|
||||
},
|
||||
"dependencies": {
|
||||
"pre-commit": "^1.2.2"
|
||||
|
||||
Reference in New Issue
Block a user