refactor: unified new/edit form for sync source

This commit is contained in:
Hussain Nagaria 2025-10-19 16:28:26 +05:30
parent 5fc0ca9bf0
commit 872f271eb1
12 changed files with 358 additions and 369 deletions

View File

@ -138,6 +138,14 @@ def create_facebook_lead_form_in_db(form: dict, page_id: str) -> None:
"questions": form["questions"],
}
)
frappe.errprint(form_doc.as_dict())
form_doc.insert(ignore_permissions=True)
@frappe.whitelist()
def get_pages_with_forms() -> list[dict]:
pages = frappe.db.get_all("Facebook Page", fields=["id", "name"])
for page in pages:
forms = frappe.db.get_all(
"Facebook Lead Form", filters={"page": page["id"]}, fields=["id", "name"]
)
page["forms"] = forms
return pages

View File

@ -41,7 +41,8 @@
"fieldname": "access_token",
"fieldtype": "Password",
"label": "Access Token",
"length": 500
"length": 500,
"reqd": 1
},
{
"fieldname": "facebook_page",
@ -76,13 +77,14 @@
"fieldname": "background_sync_frequency",
"fieldtype": "Select",
"label": "Background Sync Frequency",
"options": "Every 5 Minutes\nEvery 10 Minutes\nEvery 15 Minutes\nHourly\nDaily\nMonthly"
"options": "Every 5 Minutes\nEvery 10 Minutes\nEvery 15 Minutes\nHourly\nDaily\nMonthly",
"reqd": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-10-08 12:23:22.097933",
"modified": "2025-10-19 15:07:26.256720",
"modified_by": "Administrator",
"module": "Lead Syncing",
"name": "Lead Sync Source",

View File

@ -4,7 +4,10 @@
import frappe
from frappe.model.document import Document
from crm.lead_syncing.doctype.lead_sync_source.facebook import sync_leads_from_facebook
from crm.lead_syncing.doctype.lead_sync_source.facebook import (
fetch_and_store_pages_from_facebook,
sync_leads_from_facebook,
)
class LeadSyncSource(Document):
@ -16,10 +19,8 @@ class LeadSyncSource(Document):
if TYPE_CHECKING:
from frappe.types import DF
access_token: DF.Password | None
background_sync_frequency: DF.Literal[
"Every 5 Minutes", "Every 10 Minutes", "Every 15 Minutes", "Hourly", "Daily", "Monthly"
]
access_token: DF.Password
background_sync_frequency: DF.Literal["Every 5 Minutes", "Every 10 Minutes", "Every 15 Minutes", "Hourly", "Daily", "Monthly"]
enabled: DF.Check
facebook_lead_form: DF.Link | None
facebook_page: DF.Link | None
@ -45,10 +46,10 @@ class LeadSyncSource(Document):
if already_active:
frappe.throw(frappe._("A lead sync source is already enabled for this Facebook Lead Form!"))
def before_save(self):
def before_insert(self):
if self.type == "Facebook" and self.access_token:
# fetch_and_store_pages_from_facebook(self.access_token)
pass
fetch_and_store_pages_from_facebook(self.access_token)
# rest of the source types can be added here
@frappe.whitelist()
def sync_leads(self):

View File

@ -180,6 +180,7 @@ declare module 'vue' {
LeadsIcon: typeof import('./src/components/Icons/LeadsIcon.vue')['default']
LeadsListView: typeof import('./src/components/ListViews/LeadsListView.vue')['default']
LeadSyncSettings: typeof import('./src/components/Settings/LeadSyncing/LeadSyncSettings.vue')['default']
LeadSyncSourceForm: typeof import('./src/components/Settings/LeadSyncing/LeadSyncSourceForm.vue')['default']
LeadSyncSourcePage: typeof import('./src/components/Settings/LeadSyncing/LeadSyncSourcePage.vue')['default']
LeadSyncSources: typeof import('./src/components/Settings/LeadSyncing/LeadSyncSources.vue')['default']
LightningIcon: typeof import('./src/components/Icons/LightningIcon.vue')['default']

View File

@ -274,7 +274,8 @@
<Autocomplete
v-else-if="field.fieldtype === 'Autocomplete'"
class="text-sm text-ink-gray-8"
v-model="row[field.fieldname]"
:modelValue="row[field.fieldname]"
@update:modelValue="(v) => row[field.fieldname] = typeof v == 'object' ? v.value : v"
@change="(v) => fieldChange(typeof v == 'object' ? v.value : v, field, row)"
:options="field.options"
:placeholder="field.placeholder"

View File

@ -1,142 +0,0 @@
<template>
<div class="flex h-full flex-col gap-6 p-8 text-ink-gray-8">
<!-- Header -->
<div class="flex justify-between">
<div class="flex gap-1 -ml-4 w-9/12">
<Button variant="ghost" icon-left="chevron-left" :label="__(source.name)" size="md"
@click="() => emit('updateStep', 'source-list')"
class="cursor-pointer hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5 font-semibold text-xl hover:opacity-70 !pr-0 !max-w-96 !justify-start" />
<div class="w-fit ml-1">
<EmailProviderIcon :logo="sourceIcon[source.type]" />
</div>
</div>
<div class="flex item-center space-x-4 w-3/12 justify-end">
<div v-if="leadSyncSourceDoc.doc" class="flex items-center space-x-2">
<Switch size="sm" v-model="leadSyncSourceDoc.doc.enabled" />
<span class="text-sm text-ink-gray-7">{{ __('Enabled') }}</span>
</div>
<Button :label="__('Update')" icon-left="edit" variant="solid" :loading="sources.setValue.loading"
@click="updateSource" />
</div>
</div>
<!-- Form -->
<div v-if="leadSyncSourceDoc.doc">
<div class="grid grid-cols-1 gap-4">
<div v-for="field in fbSourceFields" :key="field.name" class="flex flex-col gap-1">
<FormControl v-model="leadSyncSourceDoc.doc[field.name]" :label="field.label" :name="field.name"
:type="field.type" :placeholder="field.placeholder" />
</div>
</div>
</div>
<div class="grid sm:grid-cols-2 gap-4">
<Link
label="Facebook Page"
doctype="Facebook Page"
v-model="leadSyncSourceDoc.doc.facebook_page"
/>
<Link
label="Facebook Lead Form"
doctype="Facebook Lead Form"
v-model="leadSyncSourceDoc.doc.facebook_lead_form"
:filters="{
page: leadSyncSourceDoc.doc.facebook_page
}"
/>
</div>
<!-- Mapping Table -->
<div v-if="formDoc.doc">
<Grid
v-model="formDoc.doc.questions"
v-model:parent="formDoc.doc"
doctype="Facebook Lead Form Question"
parentDoctype="Facebook Lead Form"
parentFieldname="questions"
:overrides="{
fields: [
{'fieldname': 'mapped_to_crm_field', 'options': getCRMLeadFields, 'placeholder': __('Not Synced')}
]
}"
/>
</div>
</div>
</template>
<script setup>
import { Switch, createResource } from "frappe-ui";
import { useDocument } from "@/data/document";
import { computed, inject, onMounted, ref } from "vue";
import Grid from "@/components/Controls/Grid.vue";
import { fbSourceFields } from "./leadSyncSourceConfig";
import { sourceIcon } from "./leadSyncSourceConfig";
import EmailProviderIcon from "../EmailProviderIcon.vue";
import Link from "@/components/Controls/Link.vue";
const emit = defineEmits();
const props = defineProps({
sourceData: {
type: Object,
required: true,
},
});
const sources = inject("sources");
const source = ref({});
onMounted(() => {
source.value = { ...props.sourceData };
});
const { document: leadSyncSourceDoc } = useDocument(
"Lead Sync Source",
props.sourceData.name,
);
const { document: formDoc } = useDocument(
"Facebook Lead Form",
props.sourceData.facebook_lead_form,
);
function updateSource() {
leadSyncSourceDoc.save.submit();
formDoc.save.submit();
}
const fields = createResource({
url: "crm.api.doc.get_fields_meta",
params: {
doctype: "CRM Lead",
as_array: true,
},
cache: ["fieldsMeta", "CRM Lead"],
auto: true,
transform: (data) => {
let restrictedFields = [
"name",
"owner",
"creation",
"modified",
"modified_by",
"docstatus",
"_comments",
"_user_tags",
"_assign",
"_liked_by",
];
console.log("data", data);
return data.filter((field) => !restrictedFields.includes(field.fieldname));
},
});
const getCRMLeadFields = computed(() => {
if (fields.data) {
return fields.data.map((field) => ({
label: field.label,
value: field.fieldname,
}));
}
return [];
});
</script>

View File

@ -0,0 +1,325 @@
<template>
<div class="flex h-full flex-col gap-6 text-ink-gray-8">
<!-- Header -->
<div class="flex justify-between px-2 pt-2">
<div class="flex gap-1 -ml-4 w-9/12">
<Button
variant="ghost"
icon-left="chevron-left"
:label="isLocal ? __('New Lead Sync Source') : syncSource.name"
size="md"
@click="() => emit('updateStep', 'source-list')"
class="cursor-pointer hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5 font-semibold text-xl hover:opacity-70 !pr-0 !max-w-96 !justify-start"
/>
</div>
<div class="flex item-center space-x-4 w-3/12 justify-end">
<div class="flex items-center space-x-2">
<Switch size="sm" v-model="syncSource.enabled" />
<span class="text-sm text-ink-gray-7">{{ __('Enabled') }}</span>
</div>
<Button
:label="isLocal ? __('Create') : __('Update')"
icon-left="plus"
variant="solid"
:loading="sources.setValue.loading || sources.insert.loading || docResource?.loading"
@click="createOrUpdateSource"
/>
</div>
</div>
<!-- Form -->
<div class="grid grid-cols-2 gap-4">
<FormControl
type="text"
v-if="isLocal"
required="true"
v-model="syncSource.name"
:label="__('Source Name')"
:placeholder="__('Enter Source Name')"
/>
<FormControl
type="autocomplete"
required="true"
v-model="syncSource.type"
:options="supportedSourceTypes"
:label="__('Source Type')"
:placeholder="__('Select Source Type')"
>
<template v-if="syncSource.type" #prefix>
<Avatar
size="xs"
class="mr-2"
:image="syncSource.type.icon"
/>
</template>
</FormControl>
<FormControl
type="password"
required="true"
v-model="syncSource.access_token"
:label="__('Access Token')"
:placeholder="__('Enter Access Token')"
/>
<Link
v-if="!isLocal"
label="Facebook Page"
v-model="syncSource.facebook_page"
doctype="Facebook Page"
/>
<Link
v-if="!isLocal && syncSource.facebook_page"
label="Lead Form"
v-model="syncSource.facebook_lead_form"
doctype="Facebook Lead Form"
:filters="{
'page': syncSource.facebook_page
}"
/>
<FormControl
v-if="fieldsMap.background_sync_frequency"
type="select"
required="true"
:options="fieldsMap.background_sync_frequency.options"
v-model="syncSource.background_sync_frequency"
:label="__('Background Sync Frequency')"
/>
</div>
<!-- Mapping Grid -->
<div v-if="syncSource.facebook_lead_form && formDocResource && formDocResource.document?.doc">
<Grid
v-model="formDocResource.document.doc.questions"
v-model:parent="formDocResource.document.doc"
doctype="Facebook Lead Form Question"
parentDoctype="Facebook Lead Form"
parentFieldname="questions"
:overrides="{
fields: [
{'fieldname': 'mapped_to_crm_field', 'options': getCRMLeadFields, 'placeholder': __('Not Synced')}
]
}"
/>
</div>
</div>
</template>
<script setup>
import { useDocument } from "@/data/document";
import { onMounted, inject, ref, computed, watch } from "vue";
import { supportedSourceTypes } from "./leadSyncSourceConfig";
import {
Button,
FormControl,
Switch,
Avatar,
toast,
createResource,
} from "frappe-ui";
import { getMeta } from "@/stores/meta";
import Link from "@/components/Controls/Link.vue";
import Grid from "@/components/Controls/Grid.vue";
const props = defineProps({
sourceData: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(["updateStep"]);
const docResource = ref(null);
const formDocResource = ref(null);
const sourceDoc = computed(() => {
if (!docResource.value) return;
return docResource.value?.document?.doc;
});
const { meta, getFields } = getMeta("Lead Sync Source");
const fields = ref(getFields());
watch(
() => meta.data,
() => {
fields.value = getFields();
},
);
const fieldsMap = computed(() => {
if (!fields.value) return {};
const map = {};
for (const field of fields.value) {
map[field.fieldname] = field;
}
return map;
});
const sources = inject("sources");
const syncSource = ref({
name: "",
type: "",
access_token: "",
facebook_page: "",
facebook_lead_form: "",
enabled: true,
background_sync_frequency:
fieldsMap.value.background_sync_frequency?.default || "Hourly",
});
const isLocal = ref(true);
function updateSource(data) {
if (formDocResource.value ?? formDocResource.value.document.isDirty)
{
formDocResource.value.document.save.submit();
}
sources.setValue.submit(
{
name: syncSource.value.name,
...data,
},
{
onSuccess: () => {
if (docResource.value) {
docResource.value.document.reload();
}
toast.success(__("Lead Sync Source updated successfully"));
},
onError(e) {
toast.error(e.messages[0] || __("Error updating Lead Sync Source"));
},
},
);
}
function createSource() {
sources.insert.submit(
{
...syncSource.value,
type: syncSource.value.type.value,
},
{
onSuccess: (newDoc) => {
toast.success(__("Lead Sync Source created successfully"));
isLocal.value = false;
docResource.value = useDocument("Lead Sync Source", newDoc.name);
},
onError(error) {
toast.error(error.messages[0] || __("Error creating Lead Sync Source"));
},
},
);
}
function createOrUpdateSource() {
if (isLocal.value) {
createSource();
} else {
updateSource({
...syncSource.value,
type: syncSource.value.type.value,
});
}
}
onMounted(() => {
if (props.sourceData?.name) {
Object.assign(syncSource.value, props.sourceData);
isLocal.value = false; // edit form
docResource.value = useDocument("Lead Sync Source", props.sourceData.name);
}
if (syncSource.value.facebook_lead_form) {
formDocResource.value = useDocument(
"Facebook Lead Form",
syncSource.value.facebook_lead_form,
);
}
});
watch(
() => sourceDoc.value,
(newDoc) => {
if (newDoc) {
Object.assign(syncSource.value, {
...newDoc,
type:
supportedSourceTypes.find((type) => type.value === newDoc.type) ||
newDoc.type,
});
formDocResource.value = useDocument(
"Facebook Lead Form",
syncSource.value.facebook_lead_form,
);
}
},
);
watch(
() => syncSource.value.facebook_page,
(_, oldValue) => {
if (!oldValue) return; // on mount, the value changes from empty
syncSource.value.facebook_lead_form = "";
},
);
watch(
() => syncSource.value.facebook_lead_form,
(newVal) => {
if (newVal) {
formDocResource.value = useDocument(
"Facebook Lead Form",
newVal,
);
} else {
formDocResource.value = null;
}
},
);
const leadFields = createResource({
url: "crm.api.doc.get_fields_meta",
params: {
doctype: "CRM Lead",
as_array: true,
},
cache: ["fieldsMeta", "CRM Lead"],
auto: true,
transform: (data) => {
let restrictedFields = [
"name",
"owner",
"creation",
"modified",
"modified_by",
"docstatus",
"_comments",
"_user_tags",
"_assign",
"_liked_by",
];
console.log("data", data);
return data.filter((field) => !restrictedFields.includes(field.fieldname));
},
});
const getCRMLeadFields = computed(() => {
if (leadFields.data) {
return leadFields.data.map((field) => ({
label: field.label,
value: field.fieldname,
}));
}
return [];
});
</script>

View File

@ -1,6 +1,6 @@
<template>
<div class="flex-1 p-6">
<NewLeadSyncSource
<LeadSyncSourceForm
v-if="step === 'new-source'"
:sourceData="source"
@updateStep="updateStep"
@ -9,7 +9,7 @@
v-else-if="step === 'source-list'"
@updateStep="updateStep"
/>
<EditLeadSyncSource
<LeadSyncSourceForm
v-else-if="step === 'edit-source'"
:sourceData="source"
@updateStep="updateStep"
@ -18,9 +18,8 @@
</template>
<script setup>
import EditLeadSyncSource from "./EditLeadSyncSource.vue"
import LeadSyncSources from "./LeadSyncSources.vue"
import NewLeadSyncSource from "./NewLeadSyncSource.vue";
import LeadSyncSourceForm from "./LeadSyncSourceForm.vue";
import { createListResource } from 'frappe-ui'
import { provide, ref } from 'vue'

View File

@ -54,31 +54,6 @@
class="flex flex-col overflow-hidden"
v-if="!sources.loading && sources.data?.length"
>
<div
v-if="sources.data?.length > 10"
class="flex items-center justify-between mb-4 px-2 pt-0.5"
>
<TextInput
ref="searchRef"
v-model="search"
:placeholder="__('Search template')"
class="w-1/3"
:debounce="300"
>
<template #prefix>
<FeatherIcon name="search" class="h-4 w-4 text-ink-gray-6" />
</template>
</TextInput>
<FormControl
type="select"
v-model="currentDoctype"
:options="[
{ label: __('All'), value: 'All' },
{ label: __('Lead'), value: 'CRM Lead' },
{ label: __('Deal'), value: 'CRM Deal' },
]"
/>
</div>
<div class="flex items-center py-2 px-4 text-sm text-ink-gray-5">
<div class="w-4/6">{{ __('Name') }}</div>
<div class="w-1/6">{{ __('Source') }}</div>

View File

@ -1,182 +0,0 @@
<template>
<div class="flex flex-col h-full gap-4">
<div role="heading" aria-level="1" class="flex flex-col gap-1">
<h2 class="text-xl font-semibold text-ink-gray-8">
{{ __('Setup Lead Syncing Source') }}
</h2>
<p class="text-sm text-ink-gray-5">
{{ __('Choose the type of source you want to configure.') }}
</p>
</div>
<!-- supported sources -->
<div class="flex flex-wrap items-center">
<div v-for="s in supportedSourceTypes" :key="s.name" class="flex flex-col items-center gap-1 mt-4 w-[70px]"
@click="handleSelect(s)">
<EmailProviderIcon :label="s.name" :logo="s.icon" :selected="selectedSourceType?.name === s?.name" />
</div>
</div>
<div v-if="selectedSourceType" class="flex flex-col gap-4">
<!-- docs -->
<div class="flex items-center gap-2 p-2 rounded-md ring-1 ring-outline-gray-3 text-ink-gray-6">
<CircleAlert class="w-5 h-6 w-min-5 w-max-5 min-h-5 max-w-5" />
<div class="text-xs text-wrap">
{{ selectedSourceType.info }}
<a :href="selectedSourceType.link" target="_blank" class="underline">
{{ __('here') }}
</a>.
</div>
</div>
<!-- Form -->
<div v-if="selectedSourceType.name === 'Facebook'" class="flex flex-col gap-4">
<div class="grid grid-cols-1 gap-4">
<div v-for="field in fbSourceFields" :key="field.name" class="flex flex-col gap-1">
<FormControl v-model="syncSource[field.name]" :label="field.label" :name="field.name"
:type="field.type" :placeholder="field.placeholder" />
</div>
</div>
<div class="grid sm:grid-cols-2 gap-4">
<div v-if="state.fbAccountPages.length">
<FormControl type="autocomplete" :placeholder="__('Select an account page')"
:options="state.fbAccountPages" :label="__('Facebook Page')"
v-model="syncSource.facebook_page" />
</div>
<div v-if="syncSource.facebook_page">
<FormControl type="autocomplete" :placeholder="__('Select a lead gen form')"
:options="leadFormsForSelectedPage" :label="__('Lead Form')"
v-model="syncSource.facebook_lead_form" />
</div>
</div>
</div>
</div>
<!-- action button -->
<div v-if="selectedSourceType" class="flex justify-between mt-auto">
<Button :label="__('Back')" variant="outline" :disabled="sources.insert.loading"
@click="emit('updateStep', 'source-list')" />
<Button v-if="state.fbPagesFetched" :label="__('Create')" variant="solid" :loading="sources.insert.loading"
@click="createLeadSyncSource" />
<Button v-else="state.fbPagesFetched" :label="__('Fetch Account Pages')" variant="solid"
:loading="state.fbPagesFetching" @click="getAccountPages(syncSource.access_token)" />
</div>
</div>
</template>
<script setup>
import { ref, inject, onMounted, reactive, watch, computed } from "vue";
import { FormControl, toast, call } from "frappe-ui";
import CircleAlert from "~icons/lucide/circle-alert";
import { supportedSourceTypes, fbSourceFields } from "./leadSyncSourceConfig";
import EmailProviderIcon from "../EmailProviderIcon.vue";
const syncSource = ref({
name: "",
type: "",
access_token: "",
facebook_page: "",
facebook_lead_form: "",
});
const state = reactive({
fbPagesFetched: false,
fbPagesFetching: false,
fbAccountPages: [],
});
const emit = defineEmits();
const props = defineProps({
sourceData: {
type: Object,
default: () => ({}),
},
});
const selectedSourceType = ref(supportedSourceTypes[0]);
syncSource.value.type = selectedSourceType.value.name;
const sources = inject("sources");
function handleSelect(sourceType) {
selectedSourceType.value = sourceType;
syncSource.value.type = sourceType.name;
}
function createLeadSyncSource() {
sources.insert.submit(
{
...syncSource.value,
facebook_page: syncSource.value.facebook_page.id,
facebook_lead_form: syncSource.value.facebook_lead_form.id,
},
{
onSuccess: () => {
toast.success(__("New Lead Syncing Source created successfully"));
emit("updateStep", "edit-source", {
...syncSource.value,
facebook_page: syncSource.value.facebook_page.id,
facebook_lead_form: syncSource.value.facebook_lead_form.id,
});
},
onError: (error) => {
toast.error(error.messages[0] || __("Failed to create source"));
},
},
);
}
const getAccountPages = (access_token) => {
state.fbPagesFetching = true;
call(
"crm.lead_syncing.doctype.lead_sync_source.facebook.fetch_and_store_pages_from_facebook",
{ access_token },
)
.then((data) => {
state.fbPagesFetched = true;
state.fbAccountPages = data.map((page) => ({
label: page.name,
value: page.id,
...page,
}));
})
.catch((error) => {
toast.error(error.messages[0] || __("Failed to fetch pages"));
})
.finally(() => {
state.fbPagesFetching = false;
});
};
const leadFormsForSelectedPage = computed(() => {
if (!state.fbAccountPages || !syncSource.value.facebook_page) {
return [];
}
const selectedPage = state.fbAccountPages.find(
(page) => page.id === syncSource.value.facebook_page.id,
);
return selectedPage.forms.map((form) => ({
label: form.name,
value: form.id,
...form,
}));
});
watch(syncSource.value.facebook_page, () => {
syncSource.value.facebook_lead_form = null;
});
onMounted(() => {
if (props.sourceData?.name) {
Object.assign(syncSource.value, props.sourceData);
syncSource.value.name = `${syncSource.value.name} - Copy`;
syncSource.value.enabled = true; // Default to enabled
}
});
</script>

View File

@ -3,7 +3,8 @@ import LogoFacebook from '@/images/facebook.png'
export const supportedSourceTypes = [
{
name: 'Facebook',
label: 'Facebook',
value: 'Facebook',
icon: LogoFacebook,
info: __("You will need a Meta developer account and an access token to sync leads from Facebook. Read more "),
link: 'https://www.facebook.com/business/help/503306463479099?id=2190812977867143',

View File

@ -91,7 +91,7 @@ export function getMeta(doctype) {
}
})
if (f.options[0]?.value !== '') {
if (f.options[0]?.value !== '' && f.mandatory !== 1) {
f.options.unshift({
label: '',
value: '',