refactor(ui): settings

This commit is contained in:
Jannat Patel
2026-01-02 14:41:17 +05:30
parent 1d19389fc0
commit e21f0e3a7f
5 changed files with 495 additions and 368 deletions

View File

@@ -17,7 +17,7 @@
</div>
</div>
<div class="overflow-y-auto">
<SettingFields :fields="fields" :data="branding.data" />
<SettingFields :sections="sections" :data="branding.data" />
</div>
<div class="flex flex-row-reverse mt-auto">
<Button variant="solid" :loading="saveSettings.loading" @click="update">
@@ -34,7 +34,7 @@ import { watch, ref } from 'vue'
const isDirty = ref(false)
const props = defineProps({
fields: {
sections: {
type: Array,
required: true,
},

View File

@@ -1,28 +1,28 @@
<template>
<div class="flex flex-col justify-between h-full text-base">
<div>
<div class="flex items-center justify-between">
<div class="text-xl font-semibold leading-none mb-2 text-ink-gray-9">
{{ __(label) }}
<div class="flex flex-col h-full text-base overflow-y-hidden">
<div class="">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center space-x-2">
<div class="text-xl font-semibold leading-none text-ink-gray-9">
{{ __(label) }}
</div>
<Badge
v-if="data.isDirty"
:label="__('Not Saved')"
variant="subtle"
theme="orange"
/>
</div>
<Badge
v-if="data.isDirty"
:label="__('Not Saved')"
variant="subtle"
theme="orange"
/>
<Button variant="solid" :loading="data.save.loading" @click="update">
{{ __('Update') }}
</Button>
</div>
<div class="text-ink-gray-6 leading-5">
{{ __(description) }}
</div>
</div>
<SettingFields :fields="fields" :data="data.doc" />
<div class="flex flex-row-reverse mt-auto">
<Button variant="solid" :loading="data.save.loading" @click="update">
{{ __('Update') }}
</Button>
</div>
<SettingFields :sections="sections" :data="data.doc" />
</div>
</template>
@@ -31,7 +31,7 @@ import { Button, Badge, toast } from 'frappe-ui'
import SettingFields from '@/components/Settings/SettingFields.vue'
const props = defineProps({
fields: {
sections: {
type: Array,
required: true,
},
@@ -49,13 +49,6 @@ const props = defineProps({
})
const update = () => {
props.fields.forEach((f) => {
if (f.type == 'Upload') {
props.data.doc[f.name] = f.value ? f.value.file_url : null
} else if (f.type != 'Column Break') {
props.data.doc[f.name] = f.value
}
})
props.data.save.submit(
{},
{

View File

@@ -1,115 +1,123 @@
<template>
<div
class="my-5"
:class="{ 'flex justify-between w-full': columns.length > 1 }"
>
<div v-for="(column, index) in columns" :key="index">
<div class="mb-5 divide-y overflow-y-auto">
<div v-for="(section, index) in sections" class="py-5">
<div v-if="section.label" class="font-semibold text-ink-gray-9 mb-4">
{{ section.label }}
</div>
<div
class="flex flex-col space-y-5"
:class="columns.length > 1 ? 'w-[21rem]' : 'w-full'"
:class="{
'flex justify-between space-x-8 w-full': section.columns.length > 1,
}"
>
<div v-for="field in column">
<Link
v-if="field.type == 'Link'"
v-model="data[field.name]"
:doctype="field.doctype"
:label="__(field.label)"
:description="__(field.description)"
/>
<div v-else-if="field.type == 'Code'">
<CodeEditor
:label="__(field.label)"
type="HTML"
description="The HTML you add here will be shown on your sign up page."
<div
v-for="(column, index) in section.columns"
class="w-full space-y-5"
>
<div v-for="field in column.fields">
<Link
v-if="field.type == 'Link'"
v-model="data[field.name]"
height="250px"
class="shrink-0"
:showLineNumbers="true"
>
</CodeEditor>
</div>
:doctype="field.doctype"
:label="__(field.label)"
:description="__(field.description)"
/>
<div v-else-if="field.type == 'Upload'">
<div class="space-y-1 mb-2">
<div class="text-sm text-ink-gray-5 font-medium">
{{ __(field.label) }}
</div>
<div class="text-sm text-ink-gray-5 leading-5">
{{ __(field.description) }}
</div>
</div>
<FileUploader
v-if="!data[field.name]"
:fileTypes="['image/*']"
:validateFile="validateFile"
@success="(file) => (data[field.name] = file)"
>
<template
v-slot="{ file, progress, uploading, openFileSelector }"
<div v-else-if="field.type == 'Code'">
<CodeEditor
:label="__(field.label)"
type="HTML"
description="The HTML you add here will be shown on your sign up page."
v-model="data[field.name]"
height="250px"
class="shrink-0"
:showLineNumbers="true"
>
<div class="">
<Button @click="openFileSelector" :loading="uploading">
{{
uploading ? `Uploading ${progress}%` : 'Upload an image'
}}
</Button>
</CodeEditor>
</div>
<div v-else-if="field.type == 'Upload'">
<div class="space-y-1 mb-2">
<div class="text-sm text-ink-gray-9 font-medium">
{{ __(field.label) }}
</div>
</template>
</FileUploader>
<div v-else>
<div class="flex items-center text-sm space-x-2">
<div
class="flex items-center justify-center rounded border border-outline-gray-1 bg-surface-gray-2"
:class="field.size == 'lg' ? 'px-5 py-5' : 'px-20 py-8'"
<div class="text-sm text-ink-gray-5 leading-5">
{{ __(field.description) }}
</div>
</div>
<FileUploader
v-if="!data[field.name]"
:fileTypes="['image/*']"
:validateFile="validateFile"
@success="(file) => (data[field.name] = file)"
>
<template
v-slot="{ file, progress, uploading, openFileSelector }"
>
<img
:src="data[field.name]?.file_url || data[field.name]"
class="rounded"
:class="field.size == 'lg' ? 'w-36' : 'size-6'"
<div class="">
<Button @click="openFileSelector" :loading="uploading">
{{
uploading ? `Uploading ${progress}%` : 'Upload an image'
}}
</Button>
</div>
</template>
</FileUploader>
<div v-else>
<div class="flex items-center text-sm space-x-2">
<div
class="flex items-center justify-center rounded border border-outline-gray-1 bg-surface-gray-2"
:class="field.size == 'lg' ? 'px-5 py-5' : 'px-20 py-8'"
>
<img
:src="data[field.name]?.file_url || data[field.name]"
class="rounded"
:class="field.size == 'lg' ? 'w-36' : 'size-6'"
/>
</div>
<div class="flex flex-col flex-wrap">
<span class="break-all text-ink-gray-9">
{{
data[field.name]?.file_name ||
data[field.name].split('/').pop()
}}
</span>
<span
v-if="data[field.name]?.file_size"
class="text-sm text-ink-gray-5 mt-1"
>
{{ getFileSize(data[field.name]?.file_size) }}
</span>
</div>
<X
@click="data[field.name] = null"
class="border text-ink-gray-7 border-outline-gray-3 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
/>
</div>
<div class="flex flex-col flex-wrap">
<span class="break-all text-ink-gray-9">
{{
data[field.name]?.file_name ||
data[field.name].split('/').pop()
}}
</span>
<span
v-if="data[field.name]?.file_size"
class="text-sm text-ink-gray-5 mt-1"
>
{{ getFileSize(data[field.name]?.file_size) }}
</span>
</div>
<X
@click="data[field.name] = null"
class="border text-ink-gray-7 border-outline-gray-3 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
/>
</div>
</div>
<Switch
v-else-if="field.type == 'checkbox'"
size="sm"
:label="__(field.label)"
:description="__(field.description)"
v-model="field.value"
/>
<!-- <div v-else>
{{ data[field.name] }}
</div> -->
<FormControl
v-else
:key="field.name"
v-model="data[field.name]"
:label="__(field.label)"
:type="field.type"
:rows="field.rows"
:options="field.options"
:description="field.description"
placeholder=""
/>
</div>
<Switch
v-else-if="field.type == 'checkbox'"
size="sm"
:label="__(field.label)"
:description="__(field.description)"
v-model="field.value"
/>
<FormControl
v-else
:key="field.name"
v-model="data[field.name]"
:label="__(field.label)"
:type="field.type"
:rows="field.rows"
:options="field.options"
:description="field.description"
:class="columns.length > 1 ? 'w-full' : 'w-1/2'"
/>
</div>
</div>
</div>
@@ -117,14 +125,14 @@
</template>
<script setup>
import { FormControl, FileUploader, Button, Switch } from 'frappe-ui'
import { computed } from 'vue'
import { computed, onMounted, watch } from 'vue'
import { getFileSize, validateFile } from '@/utils'
import { X } from 'lucide-vue-next'
import Link from '@/components/Controls/Link.vue'
import CodeEditor from '@/components/Controls/CodeEditor.vue'
const props = defineProps({
fields: {
sections: {
type: Array,
required: true,
},
@@ -134,30 +142,34 @@ const props = defineProps({
},
})
const columns = computed(() => {
const cols = []
let currentColumn = []
props.fields.forEach((field) => {
if (field.type === 'Column Break') {
if (currentColumn.length > 0) {
cols.push(currentColumn)
currentColumn = []
}
} else {
if (field.type == 'checkbox') {
field.value = props.data[field.name] ? true : false
} else {
field.value = props.data[field.name]
}
currentColumn.push(field)
}
onMounted(() => {
props.sections.forEach((section) => {
section.columns.forEach((column) => {
column.fields.forEach((field) => {
if (field.type == 'checkbox') {
field.value = props.data[field.name] ? true : false
} else {
field.value = props.data[field.name]
}
})
})
})
if (currentColumn.length > 0) {
cols.push(currentColumn)
}
return cols
})
watch(
props.sections,
(newSections) => {
// Makes the form dirty on change
newSections.forEach((section) => {
section.columns.forEach((column) => {
column.fields.forEach((field) => {
if (props.data[field.name] != field.value) {
props.data[field.name] = field.value
}
})
})
})
},
{ deep: true }
)
</script>

View File

@@ -11,7 +11,7 @@
<div v-for="tab in tabs" :key="tab.label">
<div
v-if="!tab.hideLabel"
class="mb-2 mt-3 flex cursor-pointer gap-1.5 px-1 text-base font-medium text-ink-gray-5 transition-all duration-300 ease-in-out"
class="mb-2 mt-3 flex cursor-pointer gap-1.5 px-1 text-base text-ink-gray-5 transition-all duration-300 ease-in-out"
>
<span>{{ __(tab.label) }}</span>
</div>
@@ -38,7 +38,7 @@
label: activeTab.label,
description: activeTab.description,
...(activeTab.label == 'Branding'
? { fields: activeTab.fields }
? { sections: activeTab.sections }
: {}),
...(activeTab.label == 'Evaluators' ||
activeTab.label == 'Members' ||
@@ -47,16 +47,9 @@
: {}),
}"
/>
<!-- <PaymentSettings
v-else-if="activeTab.label === 'Gateways'"
:label="activeTab.label"
:description="activeTab.description"
:data="data"
:fields="activeTab.fields"
/> -->
<SettingDetails
v-else
:fields="activeTab.fields"
:sections="activeTab.sections"
:label="activeTab.label"
:description="activeTab.description"
:data="data"
@@ -99,90 +92,158 @@ const data = createDocumentResource({
const tabsStructure = computed(() => {
return [
{
label: 'Settings',
label: 'Configuration',
hideLabel: true,
items: [
{
label: 'General',
icon: 'Wrench',
fields: [
sections: [
{
label: 'Allow Guest Access',
name: 'allow_guest_access',
description:
'If enabled, users can access the course and batch lists without logging in.',
type: 'checkbox',
label: 'System Configurations',
columns: [
{
fields: [
{
label: 'Allow Guest Access',
name: 'allow_guest_access',
description:
'If enabled, users can access the course and batch lists without logging in.',
type: 'checkbox',
},
{
label: 'Prevent Skipping Videos',
name: 'prevent_skipping_videos',
type: 'checkbox',
description:
'If enabled, users will no able to move forward in a video',
},
],
},
{
fields: [
{
label: 'Disable PWA',
name: 'disable_pwa',
type: 'checkbox',
description:
'If checked, users will not be able to install the application as a Progressive Web App.',
},
{
label: 'Send calendar invite for evaluations',
name: 'send_calendar_invite_for_evaluations',
description:
'If enabled, it sends google calendar invite to the student for evaluations.',
type: 'checkbox',
},
],
},
],
},
{
label: 'Prevent Skipping Videos',
name: 'prevent_skipping_videos',
type: 'checkbox',
description:
'If enabled, users will no able to move forward in a video',
label: 'Notifications',
columns: [
{
fields: [
{
label: 'Send Notification for Published Courses',
name: 'send_notification_for_published_courses',
type: 'select',
options: [' ', 'Email', 'In-app'],
},
],
},
{
fields: [
{
label: 'Send Notification for Published Batches',
name: 'send_notification_for_published_batches',
type: 'select',
options: [' ', 'Email', 'In-app'],
},
],
},
],
},
{
label: 'Disable PWA',
name: 'disable_pwa',
type: 'checkbox',
description:
'If checked, users will not be able to install the application as a Progressive Web App.',
label: 'Email Templates',
columns: [
{
fields: [
{
label: 'Batch Confirmation Email Template',
name: 'batch_confirmation_template',
doctype: 'Email Template',
type: 'Link',
},
],
},
{
fields: [
{
label: 'Certification Email Template',
name: 'certification_template',
doctype: 'Email Template',
type: 'Link',
},
],
},
],
},
{
label: 'Send calendar invite for evaluations',
name: 'send_calendar_invite_for_evaluations',
description:
'If enabled, it sends google calendar invite to the student for evaluations.',
type: 'checkbox',
label: 'Contact Information',
columns: [
{
fields: [
{
label: 'Email',
name: 'contact_us_email',
type: 'text',
description:
'Users can reach out to this email for support or inquiries.',
},
],
},
{
fields: [
{
label: 'URL',
name: 'contact_us_url',
type: 'text',
description:
'Users can reach out to this URL for support or inquiries.',
},
],
},
],
},
{
type: 'Column Break',
},
{
label: 'Livecode URL',
name: 'livecode_url',
doctype: 'Livecode URL',
type: 'text',
description:
'https://docs.frappe.io/learning/falcon-self-hosting-guide',
},
{
label: 'Batch Confirmation Email Template',
name: 'batch_confirmation_template',
doctype: 'Email Template',
type: 'Link',
},
{
label: 'Certification Email Template',
name: 'certification_template',
doctype: 'Email Template',
type: 'Link',
},
{
label: 'Unsplash Access Key',
name: 'unsplash_access_key',
description:
'Allows users to pick a profile cover image from Unsplash. https://unsplash.com/documentation#getting-started.',
type: 'password',
},
],
},
{
label: 'Contact Us',
icon: 'Phone',
fields: [
{
label: 'Email',
name: 'contact_us_email',
type: 'text',
description:
'Users can reach out to this email for support or inquiries.',
},
{
label: 'URL',
name: 'contact_us_url',
type: 'text',
description:
'Users can reach out to this URL for support or inquiries.',
label: '',
columns: [
{
fields: [
{
label: 'Livecode URL',
name: 'livecode_url',
doctype: 'Livecode URL',
type: 'text',
description:
'https://docs.frappe.io/learning/falcon-self-hosting-guide',
},
],
},
{
fields: [
{
label: 'Unsplash Access Key',
name: 'unsplash_access_key',
description:
'Allows users to pick a profile cover image from Unsplash. https://unsplash.com/documentation#getting-started.',
type: 'password',
},
],
},
],
},
],
},
@@ -243,36 +304,45 @@ const tabsStructure = computed(() => {
label: 'Configuration',
icon: 'CreditCard',
description: 'Manage all your payment related settings and defaults',
fields: [
sections: [
{
label: 'Default Currency',
name: 'default_currency',
type: 'Link',
doctype: 'Currency',
},
{
label: 'Payment Gateway',
name: 'payment_gateway',
type: 'Link',
doctype: 'Payment Gateway',
},
{
type: 'Column Break',
},
{
label: 'Apply GST for India',
name: 'apply_gst',
type: 'checkbox',
},
{
label: 'Show USD equivalent amount',
name: 'show_usd_equivalent',
type: 'checkbox',
},
{
label: 'Apply rounding on equivalent',
name: 'apply_rounding',
type: 'checkbox',
columns: [
{
fields: [
{
label: 'Default Currency',
name: 'default_currency',
type: 'Link',
doctype: 'Currency',
},
{
label: 'Payment Gateway',
name: 'payment_gateway',
type: 'Link',
doctype: 'Payment Gateway',
},
],
},
{
fields: [
{
label: 'Apply GST for India',
name: 'apply_gst',
type: 'checkbox',
},
{
label: 'Show USD equivalent amount',
name: 'show_usd_equivalent',
type: 'checkbox',
},
{
label: 'Apply rounding on equivalent',
name: 'apply_rounding',
type: 'checkbox',
},
],
},
],
},
],
},
@@ -304,25 +374,33 @@ const tabsStructure = computed(() => {
label: 'Branding',
icon: 'Blocks',
template: markRaw(BrandSettings),
fields: [
sections: [
{
label: 'Brand Name',
name: 'app_name',
type: 'text',
},
{
label: 'Logo',
name: 'banner_image',
type: 'Upload',
description:
'Appears in the top left corner of the application to represent your brand.',
},
{
label: 'Favicon',
name: 'favicon',
type: 'Upload',
description:
'Appears in the browser tab next to the page title to help users quickly identify the application.',
columns: [
{
fields: [
{
label: 'Brand Name',
name: 'app_name',
type: 'text',
},
{
label: 'Logo',
name: 'banner_image',
type: 'Upload',
description:
'Appears in the top left corner of the application to represent your brand.',
},
{
label: 'Favicon',
name: 'favicon',
type: 'Upload',
description:
'Appears in the browser tab next to the page title to help users quickly identify the application.',
},
],
},
],
},
],
},
@@ -330,105 +408,124 @@ const tabsStructure = computed(() => {
label: 'Sidebar',
icon: 'PanelLeftIcon',
description: 'Choose the items you want to show in the sidebar',
fields: [
sections: [
{
label: 'Courses',
name: 'courses',
type: 'checkbox',
},
{
label: 'Batches',
name: 'batches',
type: 'checkbox',
},
{
label: 'Programming Exercises',
name: 'programming_exercises',
type: 'checkbox',
},
{
label: 'Certifications',
name: 'certifications',
type: 'checkbox',
},
{
type: 'Column Break',
},
{
label: 'Jobs',
name: 'jobs',
type: 'checkbox',
},
{
label: 'Statistics',
name: 'statistics',
type: 'checkbox',
},
{
label: 'Notifications',
name: 'notifications',
type: 'checkbox',
columns: [
{
fields: [
{
label: 'Courses',
name: 'courses',
type: 'checkbox',
},
{
label: 'Batches',
name: 'batches',
type: 'checkbox',
},
{
label: 'Programming Exercises',
name: 'programming_exercises',
type: 'checkbox',
},
{
label: 'Certifications',
name: 'certifications',
type: 'checkbox',
},
],
},
{
fields: [
{
label: 'Jobs',
name: 'jobs',
type: 'checkbox',
},
{
label: 'Statistics',
name: 'statistics',
type: 'checkbox',
},
{
label: 'Notifications',
name: 'notifications',
type: 'checkbox',
},
],
},
],
},
],
},
{
label: 'Signup',
icon: 'LogIn',
fields: [
sections: [
{
label: 'Identify User Category',
name: 'user_category',
type: 'checkbox',
description:
'Enable this option to identify the user category during signup.',
},
{
label: 'Disable signup',
name: 'disable_signup',
type: 'checkbox',
description:
'New users will have to be manually registered by Admins.',
},
{
type: 'Column Break',
},
{
label: 'Signup Consent HTML',
name: 'custom_signup_content',
type: 'Code',
mode: 'htmlmixed',
rows: 10,
columns: [
{
fields: [
{
label: 'Identify User Category',
name: 'user_category',
type: 'checkbox',
description:
'Enable this option to identify the user category during signup.',
},
{
label: 'Disable signup',
name: 'disable_signup',
type: 'checkbox',
description:
'New users will have to be manually registered by Admins.',
},
{
label: 'Signup Consent HTML',
name: 'custom_signup_content',
type: 'Code',
mode: 'htmlmixed',
rows: 10,
},
],
},
],
},
],
},
{
label: 'SEO',
icon: 'Search',
fields: [
sections: [
{
label: 'Meta Description',
name: 'meta_description',
type: 'textarea',
rows: 4,
description:
"This description will be shown on lists and pages that don't have meta description",
},
{
label: 'Meta Keywords',
name: 'meta_keywords',
type: 'textarea',
rows: 4,
description:
'Comma separated keywords for search engines to find your website.',
},
{
type: 'Column Break',
},
{
label: 'Meta Image',
name: 'meta_image',
type: 'Upload',
size: 'lg',
columns: [
{
fields: [
{
label: 'Meta Description',
name: 'meta_description',
type: 'textarea',
rows: 4,
description:
"This description will be shown on lists and pages that don't have meta description",
},
{
label: 'Meta Keywords',
name: 'meta_keywords',
type: 'textarea',
rows: 4,
description:
'Comma separated keywords for search engines to find your website.',
},
{
label: 'Meta Image',
name: 'meta_image',
type: 'Upload',
size: 'lg',
},
],
},
],
},
],
},

View File

@@ -6,16 +6,20 @@
"engine": "InnoDB",
"field_order": [
"general_tab",
"default_home",
"send_calendar_invite_for_evaluations",
"persona_captured",
"column_break_zdel",
"allow_guest_access",
"prevent_skipping_videos",
"send_calendar_invite_for_evaluations",
"column_break_zdel",
"disable_pwa",
"persona_captured",
"default_home",
"column_break_bjis",
"unsplash_access_key",
"livecode_url",
"notifications_section",
"send_notification_for_published_courses",
"column_break_dtns",
"send_notification_for_published_batches",
"section_break_szgq",
"show_day_view",
"column_break_2",
@@ -446,13 +450,34 @@
"fieldname": "disable_pwa",
"fieldtype": "Check",
"label": "Disable PWA"
},
{
"fieldname": "send_notification_for_published_courses",
"fieldtype": "Select",
"label": "Send Notification for Published Courses",
"options": "\nEmail\nIn-app"
},
{
"fieldname": "send_notification_for_published_batches",
"fieldtype": "Select",
"label": "Send Notification for Published Batches",
"options": "\nEmail\nIn-app"
},
{
"fieldname": "notifications_section",
"fieldtype": "Section Break",
"label": "Notifications"
},
{
"fieldname": "column_break_dtns",
"fieldtype": "Column Break"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2025-12-22 11:30:13.868031",
"modified": "2026-01-01 19:36:54.443390",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Settings",