mirror of
https://github.com/nocodb/nocodb.git
synced 2026-02-02 02:47:29 +00:00
Merge pull request #7153 from nocodb/zendesk-integeration
feat: zendesk integeration
This commit is contained in:
@@ -147,14 +147,60 @@ const handleDeleteSync = async (syncId: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
const isSearchResultAvailable = () => {
|
||||
if (!searchQuery.value) return true
|
||||
return syncs.value.some(
|
||||
const filteredSyncs = computed(() => {
|
||||
if (!searchQuery.value) return syncs.value
|
||||
return syncs.value.filter(
|
||||
(sync) =>
|
||||
sync.title?.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
|
||||
sync.sync_type?.toLowerCase().includes(searchQuery.value.toLowerCase()),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'name',
|
||||
title: 'Name',
|
||||
name: 'Name',
|
||||
dataIndex: 'title',
|
||||
minWidth: 160,
|
||||
padding: '0px 24px',
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
title: 'Type',
|
||||
name: 'Type',
|
||||
dataIndex: 'sync_type',
|
||||
width: 150,
|
||||
minWidth: 150,
|
||||
padding: '0px 24px',
|
||||
},
|
||||
{
|
||||
key: 'frequency',
|
||||
title: 'Frequency',
|
||||
name: 'Frequency',
|
||||
dataIndex: 'frequency',
|
||||
width: 150,
|
||||
minWidth: 150,
|
||||
padding: '0px 24px',
|
||||
},
|
||||
{
|
||||
key: 'last_sync',
|
||||
title: 'Last Run',
|
||||
name: 'Last Run',
|
||||
dataIndex: 'last_sync_at',
|
||||
width: 240,
|
||||
minWidth: 240,
|
||||
padding: '0px 24px',
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
title: 'Actions',
|
||||
name: 'Actions',
|
||||
width: 100,
|
||||
minWidth: 100,
|
||||
padding: '0px 24px',
|
||||
},
|
||||
] as NcTableColumnProps[]
|
||||
|
||||
// Load syncs when component is mounted
|
||||
onMounted(() => {
|
||||
@@ -216,105 +262,67 @@ watch(
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-auto">
|
||||
<div class="ds-table overflow-y-auto nc-scrollbar-thin relative max-h-full mb-4">
|
||||
<div class="ds-table-head sticky top-0 bg-white z-10">
|
||||
<div class="ds-table-row !border-0">
|
||||
<div class="ds-table-col ds-table-name">Name</div>
|
||||
<div class="ds-table-col ds-table-type">Type</div>
|
||||
<div class="ds-table-col ds-table-frequency">Frequency</div>
|
||||
<div class="ds-table-col ds-table-last-sync">Last Run</div>
|
||||
<div class="ds-table-col ds-table-actions">Actions</div>
|
||||
<NcTable
|
||||
:columns="columns"
|
||||
:data="filteredSyncs"
|
||||
:is-data-loading="isLoading"
|
||||
row-height="54px"
|
||||
header-row-height="54px"
|
||||
class="h-full w-full"
|
||||
@row-click="(record) => handleEditSync(record.id)"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'name'">
|
||||
<div class="flex items-center gap-2">
|
||||
<GeneralIcon icon="ncZap" class="!text-green-700 !h-5 !w-5" />
|
||||
<span class="font-medium">{{ record.title || 'Untitled Sync' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'type'">
|
||||
<NcBadge rounded="lg" class="flex items-center gap-2 px-2 py-1 !h-7 truncate !border-transparent">
|
||||
{{ record.sync_type === SyncType.Full ? 'Full' : 'Incremental' }}
|
||||
</NcBadge>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'frequency'">
|
||||
{{ record.frequency }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'last_sync'">
|
||||
{{ formatDate(record.last_sync_at) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<div class="flex justify-end gap-2">
|
||||
<NcDropdown placement="bottomRight" @click.stop>
|
||||
<NcButton size="small" type="text" class="nc-action-btn !w-8 !px-1 !rounded-lg">
|
||||
<GeneralIcon icon="threeDotVertical" />
|
||||
</NcButton>
|
||||
<template #overlay>
|
||||
<NcMenu variant="small">
|
||||
<NcMenuItem @click="handleEditSync(record.id)">
|
||||
<GeneralIcon icon="edit" />
|
||||
<span>Edit</span>
|
||||
</NcMenuItem>
|
||||
<NcDivider />
|
||||
<NcMenuItem danger @click="handleDeleteSync(record.id)">
|
||||
<GeneralIcon icon="delete" />
|
||||
<span>Delete</span>
|
||||
</NcMenuItem>
|
||||
</NcMenu>
|
||||
</template>
|
||||
</NcDropdown>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<template #emptyText>
|
||||
<div class="px-2 py-6 text-gray-500 flex flex-col items-center gap-6 text-center">
|
||||
<img
|
||||
src="~assets/img/placeholder/no-search-result-found.png"
|
||||
class="!w-[164px] flex-none"
|
||||
:alt="syncs.length === 0 ? 'No syncs found' : 'No search results found'"
|
||||
/>
|
||||
{{ syncs.length === 0 ? 'No syncs found' : 'No results matched your search' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ds-table-body relative">
|
||||
<div
|
||||
v-for="sync in syncs"
|
||||
:key="sync.id"
|
||||
class="ds-table-row border-gray-200 cursor-pointer"
|
||||
:class="{
|
||||
'!hidden':
|
||||
searchQuery &&
|
||||
!sync.title?.toLowerCase().includes(searchQuery.toLowerCase()) &&
|
||||
!sync.sync_type?.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
}"
|
||||
@click="handleEditSync(sync.id)"
|
||||
>
|
||||
<div class="ds-table-col ds-table-name font-medium">
|
||||
<div class="flex items-center gap-1">
|
||||
<GeneralIcon icon="ncZap" class="!text-green-700 !h-5 !w-5" />
|
||||
{{ sync.title || 'Untitled Sync' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ds-table-col ds-table-type">
|
||||
<NcBadge rounded="lg" class="flex items-center gap-2 px-2 py-1 !h-7 truncate !border-transparent">
|
||||
{{ sync.sync_type === SyncType.Full ? 'Full' : 'Incremental' }}
|
||||
</NcBadge>
|
||||
</div>
|
||||
<div class="ds-table-col ds-table-frequency">
|
||||
{{ sync.frequency }}
|
||||
</div>
|
||||
<div class="ds-table-col ds-table-last-sync">
|
||||
{{ formatDate(sync.last_sync_at) }}
|
||||
</div>
|
||||
<div class="ds-table-col ds-table-actions">
|
||||
<div class="flex justify-end gap-2">
|
||||
<NcDropdown placement="bottomRight" @click.stop>
|
||||
<NcButton size="small" type="text" class="nc-action-btn !w-8 !px-1 !rounded-lg">
|
||||
<GeneralIcon icon="threeDotVertical" />
|
||||
</NcButton>
|
||||
<template #overlay>
|
||||
<NcMenu variant="small">
|
||||
<NcMenuItem @click="handleEditSync(sync.id)">
|
||||
<GeneralIcon icon="edit" />
|
||||
<span>Edit</span>
|
||||
</NcMenuItem>
|
||||
<NcDivider />
|
||||
<NcMenuItem danger @click="handleDeleteSync(sync.id)">
|
||||
<GeneralIcon icon="delete" />
|
||||
<span>Delete</span>
|
||||
</NcMenuItem>
|
||||
</NcMenu>
|
||||
</template>
|
||||
</NcDropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!isLoading && syncs.length === 0"
|
||||
class="flex-none integration-table-empty flex items-center justify-center py-8 px-6"
|
||||
>
|
||||
<div class="px-2 py-6 text-gray-500 flex flex-col items-center gap-6 text-center">
|
||||
<img src="~assets/img/placeholder/no-search-result-found.png" class="!w-[164px] flex-none" alt="No syncs found" />
|
||||
No syncs found
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!isLoading && syncs.length > 0 && !isSearchResultAvailable()"
|
||||
class="flex-none integration-table-empty flex items-center justify-center py-8 px-6"
|
||||
>
|
||||
<div class="px-2 py-6 text-gray-500 flex flex-col items-center gap-6 text-center">
|
||||
<img
|
||||
src="~assets/img/placeholder/no-search-result-found.png"
|
||||
class="!w-[164px] flex-none"
|
||||
alt="No search results found"
|
||||
/>
|
||||
No results matched your search
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-show="isLoading"
|
||||
class="flex items-center justify-center absolute left-0 top-0 w-full h-[calc(100%_-_45px)] z-10 pb-10 pointer-events-none"
|
||||
>
|
||||
<div class="flex flex-col justify-center items-center gap-2">
|
||||
<GeneralLoader size="xlarge" />
|
||||
<span class="text-center">Loading</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</NcTable>
|
||||
</div>
|
||||
|
||||
<!-- Create Sync Modal -->
|
||||
@@ -338,50 +346,5 @@ watch(
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ds-table {
|
||||
@apply border-1 border-gray-200 rounded-lg h-full;
|
||||
}
|
||||
.ds-table-head {
|
||||
@apply flex items-center border-b-1 text-gray-500 bg-gray-50 text-sm font-weight-500;
|
||||
}
|
||||
|
||||
.ds-table-body {
|
||||
@apply flex flex-col;
|
||||
}
|
||||
|
||||
.ds-table-row {
|
||||
@apply grid grid-cols-12 border-b border-gray-100 w-full h-full;
|
||||
}
|
||||
|
||||
.ds-table-col {
|
||||
@apply flex items-center justify-center py-3 mr-2;
|
||||
}
|
||||
|
||||
.ds-table-name {
|
||||
@apply col-span-4 items-center capitalize;
|
||||
}
|
||||
|
||||
.ds-table-type {
|
||||
@apply col-span-2 items-center;
|
||||
}
|
||||
|
||||
.ds-table-frequency {
|
||||
@apply col-span-2 items-center;
|
||||
}
|
||||
|
||||
.ds-table-last-sync {
|
||||
@apply col-span-2 items-center;
|
||||
}
|
||||
|
||||
.ds-table-actions {
|
||||
@apply col-span-2 flex w-full;
|
||||
}
|
||||
|
||||
.ds-table-col:last-child {
|
||||
@apply border-r-0;
|
||||
}
|
||||
|
||||
.ds-table-body .ds-table-row:hover {
|
||||
@apply bg-gray-50/60;
|
||||
}
|
||||
/* Styles are now handled by NcTable component */
|
||||
</style>
|
||||
|
||||
@@ -19,23 +19,57 @@ const selectCategory = (value: SyncCategory) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full flex flex-wrap gap-4 overflow-y-auto">
|
||||
<template v-for="category in categories" :key="category.value">
|
||||
<NcAlert
|
||||
type="info"
|
||||
:message="category.label"
|
||||
:description="category.description"
|
||||
show-icon
|
||||
class="cursor-pointer hover:!bg-gray-50"
|
||||
:class="{
|
||||
'!border-primary': vModel === category.value,
|
||||
}"
|
||||
@click="selectCategory(category.value)"
|
||||
>
|
||||
<template #icon>
|
||||
<GeneralIcon :icon="category.icon" class="text-primary mt-1" />
|
||||
</template>
|
||||
</NcAlert>
|
||||
</template>
|
||||
<div class="w-full flex flex-col gap-4">
|
||||
<div
|
||||
v-for="category in categories"
|
||||
:key="category.value"
|
||||
class="nc-category-card"
|
||||
:class="{
|
||||
'nc-category-selected': vModel === category.value,
|
||||
}"
|
||||
@click="selectCategory(category.value)"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="nc-category-icon">
|
||||
<GeneralIcon :icon="category.icon" class="w-5 h-5" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="nc-category-title">{{ category.label }}</div>
|
||||
<div class="nc-category-description">{{ category.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.nc-category-card {
|
||||
@apply p-4 rounded-lg border-2 border-gray-200 cursor-pointer transition-all duration-200;
|
||||
@apply hover:border-brand-500 hover:bg-brand-50/30;
|
||||
|
||||
&.nc-category-selected {
|
||||
@apply border-brand-500 bg-brand-50/50;
|
||||
|
||||
.nc-category-icon {
|
||||
@apply bg-brand-500 text-white;
|
||||
}
|
||||
|
||||
.nc-category-title {
|
||||
@apply text-brand-600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nc-category-icon {
|
||||
@apply w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center;
|
||||
@apply text-gray-600 transition-all duration-200;
|
||||
}
|
||||
|
||||
.nc-category-title {
|
||||
@apply text-sm font-semibold text-gray-800 mb-1;
|
||||
}
|
||||
|
||||
.nc-category-description {
|
||||
@apply text-xs text-gray-500 leading-relaxed;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -26,10 +26,16 @@ enum Step {
|
||||
}
|
||||
|
||||
const step = ref(Step.Category)
|
||||
const goToDashboard = ref(false)
|
||||
const goBack = ref(false)
|
||||
const progressRef = ref()
|
||||
const creatingSync = ref(false)
|
||||
const syncState = ref<{
|
||||
creating: boolean
|
||||
completed: boolean
|
||||
failed: boolean
|
||||
}>({
|
||||
creating: false,
|
||||
completed: false,
|
||||
failed: false,
|
||||
})
|
||||
|
||||
// Create a new integration configs store instance for this component
|
||||
const {
|
||||
@@ -46,7 +52,7 @@ const {
|
||||
|
||||
const handleSubmit = async () => {
|
||||
isLoading.value = true
|
||||
creatingSync.value = true
|
||||
syncState.value.creating = true
|
||||
|
||||
try {
|
||||
const syncData = await createSync()
|
||||
@@ -71,19 +77,14 @@ const handleSubmit = async () => {
|
||||
if (data.status !== 'close') {
|
||||
if (data.status === JobStatus.COMPLETED) {
|
||||
progressRef.value?.pushProgress('Done!', data.status)
|
||||
|
||||
await loadTables()
|
||||
|
||||
refreshCommandPalette()
|
||||
goToDashboard.value = true
|
||||
syncState.value.completed = true
|
||||
} else if (data.status === JobStatus.FAILED) {
|
||||
progressRef.value?.pushProgress(data.data?.error?.message ?? 'Sync failed', data.status)
|
||||
|
||||
await loadTables()
|
||||
|
||||
refreshCommandPalette()
|
||||
|
||||
goBack.value = true
|
||||
syncState.value.failed = true
|
||||
} else {
|
||||
progressRef.value?.pushProgress(data.data?.message ?? 'Syncing...', 'progress')
|
||||
}
|
||||
@@ -94,49 +95,45 @@ const handleSubmit = async () => {
|
||||
)
|
||||
} catch (e: any) {
|
||||
message.error(await extractSdkResponseErrorMsg(e))
|
||||
creatingSync.value = false
|
||||
syncState.value.creating = false
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const validateDestinationSchema = () => {
|
||||
if (!formState.value.config.custom_schema) return false
|
||||
|
||||
for (const table of Object.values(formState.value.config.custom_schema) as {
|
||||
systemFields: { primaryKey: string[] }
|
||||
}[]) {
|
||||
if (!table.systemFields.primaryKey || table.systemFields.primaryKey.length === 0) {
|
||||
message.error('Every table must have at least one unique identifier column')
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const nextStep = async () => {
|
||||
switch (step.value) {
|
||||
case Step.Category:
|
||||
step.value = Step.SyncSettings
|
||||
break
|
||||
case Step.Integration:
|
||||
if (await saveCurrentFormState()) {
|
||||
if (syncConfigForm.value.sync_category === 'custom') {
|
||||
step.value = Step.DestinationSchema
|
||||
} else {
|
||||
step.value = Step.Create
|
||||
}
|
||||
}
|
||||
break
|
||||
case Step.SyncSettings:
|
||||
try {
|
||||
await validateSyncConfig()
|
||||
step.value = Step.Integration
|
||||
} catch {}
|
||||
break
|
||||
case Step.Integration:
|
||||
if (await saveCurrentFormState()) {
|
||||
step.value = syncConfigForm.value.sync_category === 'custom' ? Step.DestinationSchema : Step.Create
|
||||
}
|
||||
break
|
||||
case Step.DestinationSchema:
|
||||
if (formState.value.config.custom_schema) {
|
||||
// make sure every table has a primary key
|
||||
for (const table of Object.values(formState.value.config.custom_schema) as {
|
||||
systemFields: {
|
||||
primaryKey: string[]
|
||||
}
|
||||
}[]) {
|
||||
if (!table.systemFields.primaryKey || table.systemFields.primaryKey.length === 0) {
|
||||
message.error('Every table must have at least one unique identifier column')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (await saveCurrentFormState()) {
|
||||
step.value = Step.Create
|
||||
}
|
||||
if (validateDestinationSchema() && (await saveCurrentFormState())) {
|
||||
step.value = Step.Create
|
||||
}
|
||||
break
|
||||
case Step.Create:
|
||||
@@ -145,27 +142,19 @@ const nextStep = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const stepFlow = {
|
||||
[Step.Category]: Step.Category,
|
||||
[Step.SyncSettings]: Step.Category,
|
||||
[Step.Integration]: Step.SyncSettings,
|
||||
[Step.DestinationSchema]: Step.Integration,
|
||||
[Step.Create]: Step.Integration,
|
||||
}
|
||||
|
||||
const previousStep = () => {
|
||||
switch (step.value) {
|
||||
case Step.Category:
|
||||
step.value = Step.Category
|
||||
break
|
||||
case Step.SyncSettings:
|
||||
step.value = Step.Category
|
||||
break
|
||||
case Step.Integration:
|
||||
step.value = Step.SyncSettings
|
||||
break
|
||||
case Step.DestinationSchema:
|
||||
step.value = Step.Integration
|
||||
break
|
||||
case Step.Create:
|
||||
if (syncConfigForm.value.sync_category === 'custom') {
|
||||
step.value = Step.DestinationSchema
|
||||
} else {
|
||||
step.value = Step.Integration
|
||||
}
|
||||
break
|
||||
if (step.value === Step.Create && syncConfigForm.value.sync_category === 'custom') {
|
||||
step.value = Step.DestinationSchema
|
||||
} else {
|
||||
step.value = stepFlow[step.value]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,25 +197,20 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
const refreshState = () => {
|
||||
goBack.value = false
|
||||
creatingSync.value = false
|
||||
goToDashboard.value = false
|
||||
}
|
||||
|
||||
function onDashboard() {
|
||||
refreshState()
|
||||
vOpen.value = false
|
||||
const resetSyncState = () => {
|
||||
syncState.value = {
|
||||
creating: false,
|
||||
completed: false,
|
||||
failed: false,
|
||||
}
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
refreshState()
|
||||
resetSyncState()
|
||||
vOpen.value = false
|
||||
}
|
||||
|
||||
const isModalClosable = computed(() => {
|
||||
return !creatingSync.value
|
||||
})
|
||||
const isModalClosable = computed(() => !syncState.value.creating)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -241,78 +225,78 @@ const isModalClosable = computed(() => {
|
||||
>
|
||||
<div class="flex-1 flex flex-col max-h-full create-source">
|
||||
<div class="px-4 py-3 w-full flex items-center gap-3 border-b-1 border-gray-200">
|
||||
<div class="flex items-center">
|
||||
<div class="flex items-center gap-2">
|
||||
<GeneralIcon icon="ncZap" class="!text-green-700 !h-5 !w-5" />
|
||||
<div class="text-base font-weight-700">Create Sync</div>
|
||||
</div>
|
||||
<div class="flex-1 text-base font-weight-700">Create Sync</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<NcButton :disabled="creatingSync" size="small" type="text" @click="onClose">
|
||||
<GeneralIcon icon="close" class="text-gray-600" />
|
||||
<div class="flex-1" />
|
||||
|
||||
<!-- Navigation Buttons in Header -->
|
||||
<div v-if="!syncState.creating" class="flex items-center gap-2">
|
||||
<NcButton type="ghost" size="small" :disabled="step === Step.Category" @click="previousStep"> Back </NcButton>
|
||||
<NcButton type="primary" size="small" :disabled="!continueEnabled" @click="nextStep">
|
||||
{{ step === Step.Create ? 'Create' : 'Continue' }}
|
||||
</NcButton>
|
||||
</div>
|
||||
|
||||
<NcButton :disabled="syncState.creating" size="small" type="text" @click="onClose">
|
||||
<GeneralIcon icon="close" class="text-gray-600" />
|
||||
</NcButton>
|
||||
</div>
|
||||
<div class="h-[calc(100%_-_58px)] flex justify-center p-5">
|
||||
<div class="flex flex-col gap-10 w-full items-center overflow-y-auto">
|
||||
<div class="w-5xl">
|
||||
<DashboardSettingsSyncSteps :current="step" />
|
||||
</div>
|
||||
<div class="flex rounded-lg p-6 border-1 border-nc-border-gray-medium">
|
||||
<a-form name="external-base-create-form" layout="vertical" no-style hide-required-mark class="flex flex-col w-full">
|
||||
<div class="nc-form-section">
|
||||
<div class="flex flex-col gap-5">
|
||||
<div v-if="step === Step.Category" class="w-3xl">
|
||||
<a-row :gutter="24">
|
||||
<a-col :span="24">
|
||||
<a-form-item label="Sync Category">
|
||||
<DashboardSettingsSyncCategorySelect
|
||||
:model-value="deepReference('sync_category')"
|
||||
@change="onCategoryChange($event)"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
<div v-if="step === Step.Integration" class="w-3xl">
|
||||
<div>
|
||||
<!-- Integration tabs and configuration -->
|
||||
<DashboardSettingsSyncIntegrationTabs />
|
||||
<DashboardSettingsSyncIntegrationConfig />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="step === Step.SyncSettings" class="w-3xl">
|
||||
<DashboardSettingsSyncSettings />
|
||||
</div>
|
||||
<div v-if="syncConfigForm.sync_category === 'custom' && step === Step.DestinationSchema">
|
||||
<DashboardSettingsSyncDestinationSchema />
|
||||
</div>
|
||||
<div v-if="step === Step.Create" class="w-3xl">
|
||||
<div v-if="creatingSync">
|
||||
<div class="mb-4 prose-xl font-bold">Creating sync schema and syncing initial data</div>
|
||||
|
||||
<GeneralProgressPanel ref="progressRef" class="w-full" />
|
||||
<div class="flex w-full max-w-5xl mx-auto">
|
||||
<a-form layout="vertical" no-style hide-required-mark class="flex flex-col w-full">
|
||||
<div class="flex flex-col gap-5">
|
||||
<!-- Step 1: Category -->
|
||||
<div v-if="step === Step.Category">
|
||||
<a-form-item label="Sync Category">
|
||||
<DashboardSettingsSyncCategorySelect
|
||||
:model-value="deepReference('sync_category')"
|
||||
@change="onCategoryChange($event)"
|
||||
/>
|
||||
</a-form-item>
|
||||
</div>
|
||||
|
||||
<div v-if="goToDashboard" class="flex justify-center items-center">
|
||||
<NcButton class="mt-6 mb-8" size="medium" @click="onDashboard"> 🚀 Go To Dashboard 🚀</NcButton>
|
||||
</div>
|
||||
<div v-else-if="goBack" class="flex justify-center items-center">
|
||||
<NcButton class="mt-6 mb-8" type="ghost" size="medium" @click="onDashboard">Go Dashboard</NcButton>
|
||||
</div>
|
||||
<!-- Step 2: Sync Settings -->
|
||||
<div v-else-if="step === Step.SyncSettings">
|
||||
<DashboardSettingsSyncSettings />
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Integration -->
|
||||
<div v-else-if="step === Step.Integration">
|
||||
<DashboardSettingsSyncIntegrationTabs />
|
||||
<DashboardSettingsSyncIntegrationConfig />
|
||||
</div>
|
||||
|
||||
<!-- Step 4: Destination Schema (custom only) -->
|
||||
<div v-else-if="step === Step.DestinationSchema">
|
||||
<DashboardSettingsSyncDestinationSchema />
|
||||
</div>
|
||||
|
||||
<!-- Step 5: Review & Create -->
|
||||
<div v-else-if="step === Step.Create">
|
||||
<div v-if="syncState.creating">
|
||||
<div class="mb-4 prose-xl font-bold">Creating sync schema and syncing initial data</div>
|
||||
<GeneralProgressPanel ref="progressRef" class="w-full" />
|
||||
|
||||
<div v-if="syncState.completed" class="flex justify-center items-center">
|
||||
<NcButton class="mt-6 mb-8" size="medium" @click="onClose"> 🚀 Go To Dashboard 🚀 </NcButton>
|
||||
</div>
|
||||
<div v-else>
|
||||
<DashboardSettingsSyncReview />
|
||||
<div v-else-if="syncState.failed" class="flex justify-center items-center">
|
||||
<NcButton class="mt-6 mb-8" type="ghost" size="medium" @click="onClose"> Go Dashboard </NcButton>
|
||||
</div>
|
||||
</div>
|
||||
<DashboardSettingsSyncReview v-else />
|
||||
</div>
|
||||
</div>
|
||||
</a-form>
|
||||
</div>
|
||||
<div v-if="!creatingSync" class="w-3xl flex justify-between">
|
||||
<NcButton type="ghost" :disabled="step === Step.Category" @click="previousStep"> Back </NcButton>
|
||||
<NcButton type="primary" :loading="creatingSync" :disabled="!continueEnabled" @click="nextStep">
|
||||
{{ step === Step.Create ? 'Create' : 'Continue' }}
|
||||
</NcButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -320,31 +304,6 @@ const isModalClosable = computed(() => {
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.ant-steps-item-finish .ant-steps-icon) {
|
||||
top: -3px;
|
||||
}
|
||||
|
||||
.nc-form-section > div > div {
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
|
||||
.nc-add-source-left-panel {
|
||||
@apply p-6 flex-1 flex justify-center;
|
||||
}
|
||||
.nc-add-source-right-panel {
|
||||
@apply p-4 w-[320px] border-l-1 border-gray-200 flex flex-col gap-4 bg-gray-50 rounded-br-2xl;
|
||||
}
|
||||
:deep(.ant-collapse-header) {
|
||||
@apply !-mt-4 !p-0 flex items-center !cursor-default children:first:flex;
|
||||
}
|
||||
:deep(.ant-collapse-icon-position-right > .ant-collapse-item > .ant-collapse-header .ant-collapse-arrow) {
|
||||
@apply !right-0;
|
||||
}
|
||||
|
||||
:deep(.ant-collapse-content-box) {
|
||||
@apply !px-0 !pb-0 !pt-3;
|
||||
}
|
||||
|
||||
:deep(.ant-form-item-explain-error) {
|
||||
@apply !text-xs;
|
||||
}
|
||||
|
||||
@@ -150,88 +150,98 @@ const isModalClosable = computed(() => {
|
||||
>
|
||||
<div class="flex-1 flex flex-col max-h-full create-source">
|
||||
<div class="px-4 py-3 w-full flex items-center gap-3 border-b-1 border-gray-200">
|
||||
<div class="flex items-center">
|
||||
<div class="flex items-center gap-2">
|
||||
<GeneralIcon icon="ncZap" class="!text-green-700 !h-5 !w-5" />
|
||||
<div class="text-base font-weight-700">Edit Sync Configuration</div>
|
||||
</div>
|
||||
<div class="flex-1 text-base font-weight-700">Edit Sync Configuration</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex-1" />
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<NcButton
|
||||
v-if="editTab === 'sync' && editModeSync && !triggeredSync && !completeSync"
|
||||
size="small"
|
||||
type="primary"
|
||||
:loading="triggeredSync"
|
||||
:disabled="isLoading || updatingSync"
|
||||
@click="onTrigger"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<GeneralIcon icon="refresh" class="w-4 h-4" />
|
||||
<span>Trigger Sync</span>
|
||||
</div>
|
||||
</NcButton>
|
||||
<NcButton :disabled="updatingSync || triggeredSync" size="small" type="text" @click="vOpen = false">
|
||||
<GeneralIcon icon="close" class="text-gray-600" />
|
||||
</NcButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-[calc(100%_-_58px)] flex justify-center p-5">
|
||||
<div class="flex flex-col gap-10 w-full items-center overflow-y-auto">
|
||||
<div>
|
||||
<div class="flex flex-col gap-6 w-full max-w-5xl mx-auto overflow-y-auto">
|
||||
<div class="flex justify-center">
|
||||
<NcSelectTab v-model="editTab" :items="tabs" @update:model-value="onTabChange" />
|
||||
</div>
|
||||
<div class="w-3xl flex rounded-lg p-6 w-full border-1 border-nc-border-gray-medium relative">
|
||||
<!-- <div
|
||||
v-if="editTab === 'integrations' && syncConfigEditForm.sync_category === 'custom'"
|
||||
class="absolute inset-0 bg-gray-500/10 z-10 rounded-lg cursor-not-allowed"
|
||||
></div> -->
|
||||
<div class="flex w-full relative">
|
||||
<template v-if="editTab === 'sync'">
|
||||
<div class="create-source bg-white relative flex flex-col gap-2 w-full max-w-[768px]">
|
||||
<div v-if="editModeSync" class="sync-info bg-gray-100 p-4 rounded-lg w-full">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="text-lg font-semibold">Sync Information</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm text-gray-500">
|
||||
Last Sync: {{ editModeSync.last_sync_at ? editModeSync.last_sync_at : 'Never' }}
|
||||
<div class="flex flex-col gap-4 w-full">
|
||||
<!-- Sync Overview -->
|
||||
<div v-if="editModeSync" class="nc-sync-overview">
|
||||
<div class="nc-overview-header">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="nc-sync-icon">
|
||||
<GeneralIcon icon="ncZap" class="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<div v-if="editModeSync.next_sync_at" class="text-sm text-gray-500">
|
||||
Next Sync: {{ editModeSync.next_sync_at }}
|
||||
<div>
|
||||
<div class="text-base font-semibold text-gray-900">{{ editModeSync.title }}</div>
|
||||
<div class="text-xs text-gray-500 mt-0.5">
|
||||
{{ editModeSync.sync_type === 'full' ? 'Full Sync' : 'Incremental Sync' }} •
|
||||
{{ editModeSync.sync_trigger === 'manual' ? 'Manual Trigger' : 'Scheduled' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nc-sync-stats">
|
||||
<div class="nc-stat-item">
|
||||
<div class="nc-stat-icon">
|
||||
<GeneralIcon icon="clock" class="w-4 h-4 text-brand-600" />
|
||||
</div>
|
||||
<div class="nc-stat-content">
|
||||
<div class="nc-stat-label">Last Sync</div>
|
||||
<div class="nc-stat-value">{{ editModeSync.last_sync_at || 'Never' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nc-stat-item">
|
||||
<div class="nc-stat-icon">
|
||||
<GeneralIcon icon="calendar" class="w-4 h-4 text-brand-600" />
|
||||
</div>
|
||||
<div class="nc-stat-content">
|
||||
<div class="nc-stat-label">Next Sync</div>
|
||||
<div class="nc-stat-value">{{ editModeSync.next_sync_at || 'Not scheduled' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full flex flex-col mt-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<NcButton
|
||||
v-if="!triggeredSync && !completeSync"
|
||||
size="small"
|
||||
type="primary"
|
||||
class="nc-extdb-btn-submit"
|
||||
:loading="triggeredSync"
|
||||
:disabled="isLoading || updatingSync"
|
||||
@click="onTrigger"
|
||||
>
|
||||
Trigger Sync
|
||||
</NcButton>
|
||||
<NcButton
|
||||
v-if="completeSync"
|
||||
size="small"
|
||||
type="primary"
|
||||
class="nc-extdb-btn-submit"
|
||||
@click="completeSync = false"
|
||||
>
|
||||
Minimize
|
||||
<div v-if="triggeredSync || completeSync" class="nc-progress-wrapper">
|
||||
<div class="flex items-center justify-between p-3 border-b border-gray-200 bg-gray-50">
|
||||
<div class="text-sm font-semibold text-gray-800">Sync Progress</div>
|
||||
<NcButton size="small" type="text" @click="completeSync = false">
|
||||
<GeneralIcon icon="close" class="w-4 h-4" />
|
||||
</NcButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex">
|
||||
<GeneralProgressPanel v-if="triggeredSync || completeSync" ref="progressRef" class="w-full h-[400px]" />
|
||||
<GeneralProgressPanel ref="progressRef" class="w-full h-[400px]" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="editTab === 'sync-settings'">
|
||||
<a-form name="external-base-create-form" layout="vertical" no-style hide-required-mark class="flex flex-col w-full">
|
||||
<div class="nc-form-section">
|
||||
<div class="flex flex-col gap-5">
|
||||
<DashboardSettingsSyncSettings :edit-mode="true" />
|
||||
</div>
|
||||
</div>
|
||||
</a-form>
|
||||
<div class="flex flex-col w-full">
|
||||
<DashboardSettingsSyncSettings :edit-mode="true" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="editTab === 'integrations'">
|
||||
<div class="create-source bg-white relative flex flex-col gap-2 w-full max-w-[768px]">
|
||||
<div class="nc-form-section w-full">
|
||||
<!-- Integration tabs and configuration -->
|
||||
<DashboardSettingsSyncIntegrationTabs />
|
||||
<DashboardSettingsSyncIntegrationConfig :edit-mode="true" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-4 w-full">
|
||||
<DashboardSettingsSyncIntegrationTabs />
|
||||
<DashboardSettingsSyncIntegrationConfig :edit-mode="true" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -242,21 +252,48 @@ const isModalClosable = computed(() => {
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.nc-add-source-left-panel {
|
||||
@apply p-6 flex-1 flex justify-center;
|
||||
}
|
||||
.nc-add-source-right-panel {
|
||||
@apply p-4 w-[320px] border-l-1 border-gray-200 flex flex-col gap-4 bg-gray-50 rounded-br-2xl;
|
||||
}
|
||||
:deep(.ant-collapse-header) {
|
||||
@apply !-mt-4 !p-0 flex items-center !cursor-default children:first:flex;
|
||||
}
|
||||
:deep(.ant-collapse-icon-position-right > .ant-collapse-item > .ant-collapse-header .ant-collapse-arrow) {
|
||||
@apply !right-0;
|
||||
.nc-sync-overview {
|
||||
@apply flex flex-col gap-4 p-5 rounded-lg border border-gray-200 bg-white;
|
||||
}
|
||||
|
||||
:deep(.ant-collapse-content-box) {
|
||||
@apply !px-0 !pb-0 !pt-3;
|
||||
.nc-overview-header {
|
||||
@apply pb-4 border-b border-gray-200;
|
||||
}
|
||||
|
||||
.nc-sync-icon {
|
||||
@apply w-10 h-10 rounded-lg bg-brand-50 flex items-center justify-center;
|
||||
}
|
||||
|
||||
.nc-sync-stats {
|
||||
@apply grid grid-cols-1 md:grid-cols-2 gap-3;
|
||||
}
|
||||
|
||||
.nc-quick-actions {
|
||||
@apply flex flex-col;
|
||||
}
|
||||
|
||||
.nc-stat-item {
|
||||
@apply flex items-start gap-3 p-3 rounded-lg bg-gray-50;
|
||||
}
|
||||
|
||||
.nc-stat-icon {
|
||||
@apply w-8 h-8 rounded-lg bg-brand-50 flex items-center justify-center flex-shrink-0;
|
||||
}
|
||||
|
||||
.nc-stat-content {
|
||||
@apply flex flex-col gap-0.5;
|
||||
}
|
||||
|
||||
.nc-stat-label {
|
||||
@apply text-xs font-medium text-gray-500;
|
||||
}
|
||||
|
||||
.nc-stat-value {
|
||||
@apply text-sm font-semibold text-gray-900;
|
||||
}
|
||||
|
||||
.nc-progress-wrapper {
|
||||
@apply rounded-lg border border-gray-200 overflow-hidden;
|
||||
}
|
||||
|
||||
:deep(.ant-form-item-explain-error) {
|
||||
@@ -279,28 +316,9 @@ const isModalClosable = computed(() => {
|
||||
@apply font-weight-400;
|
||||
}
|
||||
|
||||
.create-source {
|
||||
:deep(.ant-input-affix-wrapper),
|
||||
:deep(.ant-input),
|
||||
:deep(.ant-select) {
|
||||
@apply !appearance-none border-solid rounded-md;
|
||||
}
|
||||
|
||||
:deep(.ant-input-password) {
|
||||
input {
|
||||
@apply !border-none my-0;
|
||||
}
|
||||
}
|
||||
|
||||
.nc-form-section {
|
||||
@apply flex flex-col gap-3;
|
||||
}
|
||||
.nc-form-section-title {
|
||||
@apply text-sm font-bold text-gray-800;
|
||||
}
|
||||
.nc-form-section-body {
|
||||
@apply flex flex-col gap-3;
|
||||
}
|
||||
.nc-form-section-body {
|
||||
@apply flex flex-col gap-3;
|
||||
}
|
||||
|
||||
.nc-connection-json-editor {
|
||||
@apply min-h-[300px] max-h-[600px];
|
||||
|
||||
@@ -41,30 +41,32 @@ const onDeleteSync = async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<a-row v-if="!editMode || editModeAddIntegration" :gutter="24" :class="{ 'mb-4': editMode }">
|
||||
<a-col :span="24">
|
||||
<a-form-item label="Select Source" class="w-full">
|
||||
<DashboardSettingsSyncSelect
|
||||
:value="formState.sub_type"
|
||||
:category="syncConfigForm.sync_category"
|
||||
@change="changeIntegration"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<div class="nc-integration-config">
|
||||
<div v-if="!editMode || editModeAddIntegration" class="nc-config-section">
|
||||
<a-form-item label="Select Source" class="w-full">
|
||||
<DashboardSettingsSyncSelect
|
||||
:value="formState.sub_type"
|
||||
:category="syncConfigForm.sync_category"
|
||||
@change="changeIntegration"
|
||||
/>
|
||||
</a-form-item>
|
||||
</div>
|
||||
|
||||
<NcFormBuilder
|
||||
v-if="formState.sub_type"
|
||||
:key="`${selectedIntegrationIndex}-${formState.sub_type}`"
|
||||
class="pt-4"
|
||||
class="nc-config-section"
|
||||
@change="editModeModified = true"
|
||||
/>
|
||||
<div v-if="editMode" class="flex justify-between">
|
||||
<NcTooltip v-if="!formState.parentSyncConfigId" placement="top">
|
||||
<template #title> You can't delete first integration </template>
|
||||
<NcButton class="!px-4" type="danger" size="small" disabled> Delete Integration </NcButton>
|
||||
</NcTooltip>
|
||||
<NcButton v-else class="!px-4" type="danger" size="small" @click="onDeleteSync"> Delete Integration </NcButton>
|
||||
|
||||
<div v-if="editMode" class="nc-config-actions">
|
||||
<div>
|
||||
<NcTooltip v-if="!formState.parentSyncConfigId" placement="top">
|
||||
<template #title> You can't delete first integration </template>
|
||||
<NcButton class="!px-4" type="danger" size="small" disabled> Delete Integration </NcButton>
|
||||
</NcTooltip>
|
||||
<NcButton v-else class="!px-4" type="danger" size="small" @click="onDeleteSync"> Delete Integration </NcButton>
|
||||
</div>
|
||||
<NcButton
|
||||
v-if="syncConfigEditForm?.sync_category === 'custom'"
|
||||
class="!px-4"
|
||||
@@ -78,6 +80,7 @@ const onDeleteSync = async () => {
|
||||
Update Integration
|
||||
</NcButton>
|
||||
</div>
|
||||
|
||||
<GeneralModal
|
||||
v-if="destinationSchemaModalVisible"
|
||||
v-model:visible="destinationSchemaModalVisible"
|
||||
@@ -91,3 +94,17 @@ const onDeleteSync = async () => {
|
||||
</GeneralModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.nc-integration-config {
|
||||
@apply flex flex-col gap-6;
|
||||
}
|
||||
|
||||
.nc-config-section {
|
||||
@apply flex flex-col gap-4;
|
||||
}
|
||||
|
||||
.nc-config-actions {
|
||||
@apply flex justify-between items-center pt-4 border-t border-gray-200;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -23,42 +23,88 @@ const configs = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center mb-4 border-b border-gray-200 flex-wrap">
|
||||
<div
|
||||
v-for="(config, index) in configs"
|
||||
:key="index"
|
||||
class="px-4 py-2 cursor-pointer flex items-center gap-2"
|
||||
:class="{
|
||||
'border-b-2 border-primary text-primary': selectedIntegrationIndex === index,
|
||||
'text-gray-500': selectedIntegrationIndex !== index,
|
||||
}"
|
||||
@click="switchToIntegrationConfig(index)"
|
||||
>
|
||||
<GeneralIntegrationIcon v-if="config.sub_type" :type="config.sub_type" class="h-5 w-5" />
|
||||
<span v-else class="h-5 w-5 flex items-center justify-center bg-gray-100 rounded-full">
|
||||
{{ index + 1 }}
|
||||
</span>
|
||||
<span>
|
||||
{{ config?.title || config?.sub_type || 'New Source' }}
|
||||
</span>
|
||||
<a-button
|
||||
v-if="integrationConfigs.length > 1 && !editMode"
|
||||
<div class="nc-integration-tabs">
|
||||
<div class="nc-tabs-container">
|
||||
<div
|
||||
v-for="(config, index) in configs"
|
||||
:key="index"
|
||||
class="nc-tab"
|
||||
:class="{
|
||||
'nc-tab-active': selectedIntegrationIndex === index,
|
||||
}"
|
||||
@click="switchToIntegrationConfig(index)"
|
||||
>
|
||||
<div class="nc-tab-content">
|
||||
<GeneralIntegrationIcon v-if="config.sub_type" :type="config.sub_type" class="h-5 w-5" />
|
||||
<span class="nc-tab-label capitalize">
|
||||
{{ config?.title || config?.sub_type || 'New Source' }}
|
||||
</span>
|
||||
<NcButton
|
||||
v-if="integrationConfigs.length > 1 && !editMode"
|
||||
type="text"
|
||||
size="xxsmall"
|
||||
class="nc-tab-close"
|
||||
@click.stop="removeIntegrationConfig(index)"
|
||||
>
|
||||
<GeneralIcon icon="close" class="h-3 w-3" />
|
||||
</NcButton>
|
||||
</div>
|
||||
</div>
|
||||
<NcButton
|
||||
v-if="(!editMode || !editModeModified) && syncConfigEditForm?.sync_category !== 'custom'"
|
||||
type="text"
|
||||
size="small"
|
||||
class="!p-0 !min-w-0 !h-auto text-gray-400 hover:text-red-500"
|
||||
@click.stop="removeIntegrationConfig(index)"
|
||||
class="nc-add-source-btn"
|
||||
@click="addIntegrationConfig"
|
||||
>
|
||||
<GeneralIcon icon="close" class="h-3 w-3" />
|
||||
</a-button>
|
||||
<GeneralIcon icon="plus" class="h-4 w-4" />
|
||||
<span>Add Source</span>
|
||||
</NcButton>
|
||||
</div>
|
||||
<a-button
|
||||
v-if="(!editMode || !editModeModified) && syncConfigEditForm?.sync_category !== 'custom'"
|
||||
type="text"
|
||||
class="ml-2 flex items-center"
|
||||
@click="addIntegrationConfig"
|
||||
>
|
||||
<GeneralIcon icon="plus" class="h-3 w-3 mr-1" />
|
||||
Add Source
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.nc-integration-tabs {
|
||||
@apply mb-4;
|
||||
}
|
||||
|
||||
.nc-tabs-container {
|
||||
@apply flex items-center gap-1 border-b border-gray-200 flex-wrap;
|
||||
}
|
||||
|
||||
.nc-tab {
|
||||
@apply px-4 py-2.5 cursor-pointer transition-all duration-200 relative;
|
||||
@apply border-b-2 border-transparent;
|
||||
@apply hover:bg-gray-50;
|
||||
|
||||
&.nc-tab-active {
|
||||
@apply border-brand-500;
|
||||
|
||||
.nc-tab-content {
|
||||
@apply text-brand-600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nc-tab-content {
|
||||
@apply flex items-center gap-2 text-gray-600;
|
||||
}
|
||||
|
||||
.nc-tab-number {
|
||||
@apply h-5 w-5 flex items-center justify-center bg-gray-100 rounded-full text-xs font-medium;
|
||||
}
|
||||
|
||||
.nc-tab-label {
|
||||
@apply text-sm font-medium;
|
||||
}
|
||||
|
||||
.nc-tab-close {
|
||||
@apply !p-0 !min-w-0 !h-auto text-gray-400;
|
||||
@apply hover:!text-red-500 transition-colors;
|
||||
}
|
||||
|
||||
.nc-add-source-btn {
|
||||
@apply ml-2 !px-3 !py-1.5 flex items-center gap-1.5;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -41,137 +41,132 @@ const selectedModels = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="review-container">
|
||||
<!-- Basic configuration -->
|
||||
<div class="section">
|
||||
<div class="section-title">
|
||||
<GeneralIcon icon="settings" class="text-primary mr-2" />
|
||||
<h3>Sync Configuration</h3>
|
||||
</div>
|
||||
<a-card class="mb-4 !rounded-lg">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="info-item">
|
||||
<div class="info-label">Title</div>
|
||||
<div class="info-value">{{ syncConfigForm.title }}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">Sync Type</div>
|
||||
<div class="info-value">
|
||||
<GeneralIcon icon="refresh" class="text-primary mr-2" />
|
||||
{{ syncTypeLabel }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">Sync Trigger</div>
|
||||
<div class="info-value">
|
||||
<GeneralIcon icon="clock" class="text-primary mr-2" />
|
||||
{{ syncTriggerLabel }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">On Delete Action</div>
|
||||
<div class="info-value">
|
||||
<GeneralIcon icon="delete" class="text-primary mr-2" />
|
||||
{{ onDeleteActionLabel }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="nc-review-container">
|
||||
<!-- Title Section -->
|
||||
<div class="nc-review-hero">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="nc-hero-icon">
|
||||
<GeneralIcon icon="ncZap" class="w-6 h-6 text-brand-600" />
|
||||
</div>
|
||||
</a-card>
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-gray-900">{{ syncConfigForm.title || 'Untitled Sync' }}</div>
|
||||
<div class="text-xs text-gray-500 mt-0.5">Review your sync configuration before creating</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Models to sync -->
|
||||
<div class="section">
|
||||
<div class="section-title">
|
||||
<GeneralIcon icon="table" class="text-primary mr-2" />
|
||||
<h3>Models to Sync</h3>
|
||||
<div class="text-xs text-gray-500 ml-auto">
|
||||
{{
|
||||
syncAllModels ? 'All models will be synced' : `${selectedModels.length} of ${availableModels.length} models selected`
|
||||
}}
|
||||
<!-- Configuration Grid -->
|
||||
<div class="nc-config-grid">
|
||||
<div class="nc-config-item">
|
||||
<div class="nc-config-header">
|
||||
<div class="nc-config-icon-wrapper">
|
||||
<GeneralIcon icon="refresh" class="w-4 h-4 text-brand-600" />
|
||||
</div>
|
||||
<div class="nc-config-label">Sync Type</div>
|
||||
</div>
|
||||
<div class="nc-config-value">{{ syncTypeLabel }}</div>
|
||||
</div>
|
||||
<a-card class="mb-4 !rounded-lg">
|
||||
<div class="model-list">
|
||||
<div v-if="syncAllModels" class="flex items-center py-2">
|
||||
<GeneralIcon icon="check" class="text-green-600 mr-2" />
|
||||
<span class="text-sm">All available models will be synced</span>
|
||||
</div>
|
||||
<div v-else class="grid grid-cols-2 gap-2">
|
||||
<div v-for="model in selectedModels" :key="model.value" class="model-item">
|
||||
<GeneralIcon :icon="model.icon" class="text-primary mr-2" />
|
||||
<span>{{ model.label }}</span>
|
||||
</div>
|
||||
|
||||
<div class="nc-config-item">
|
||||
<div class="nc-config-header">
|
||||
<div class="nc-config-icon-wrapper">
|
||||
<GeneralIcon icon="clock" class="w-4 h-4 text-brand-600" />
|
||||
</div>
|
||||
<div class="nc-config-label">Sync Trigger</div>
|
||||
</div>
|
||||
</a-card>
|
||||
<div class="nc-config-value">{{ syncTriggerLabel }}</div>
|
||||
</div>
|
||||
|
||||
<div class="nc-config-item">
|
||||
<div class="nc-config-header">
|
||||
<div class="nc-config-icon-wrapper">
|
||||
<GeneralIcon icon="delete" class="w-4 h-4 text-brand-600" />
|
||||
</div>
|
||||
<div class="nc-config-label">On Delete</div>
|
||||
</div>
|
||||
<div class="nc-config-value">{{ onDeleteActionLabel }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Integrations
|
||||
<div class="section">
|
||||
<div class="section-title">
|
||||
<GeneralIcon icon="link" class="text-primary mr-2" />
|
||||
<h3>Integration</h3>
|
||||
<!-- Models Section -->
|
||||
<div v-if="syncConfigForm.sync_category !== 'custom'" class="nc-models-section">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<GeneralIcon icon="table" class="text-gray-600 w-4 h-4" />
|
||||
<span class="text-sm font-semibold text-gray-800">Models</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 font-medium">
|
||||
{{ syncAllModels ? 'All models' : `${selectedModels.length} selected` }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<a-card v-for="(integration, i) in integrationConfigs" :key="`${integration.sub_type}-${i}`" class="!rounded-lg">
|
||||
<div v-if="integration.sub_type" class="flex items-center">
|
||||
<div class="rounded-full p-3 mr-4">
|
||||
<GeneralIntegrationIcon :type="integration.sub_type" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-base font-medium">{{ integration.sub_type }}</div>
|
||||
<div class="text-gray-500 text-sm">Connected and ready to sync</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-gray-500 text-sm flex items-center">
|
||||
<GeneralIcon icon="warning" class="text-amber-500 mr-2" />
|
||||
No integration configured
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<div v-if="syncAllModels" class="nc-all-models-badge">
|
||||
<GeneralIcon icon="check" class="text-green-600 w-4 h-4" />
|
||||
<span>All available models will be synced</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="nc-models-grid">
|
||||
<div v-for="model in selectedModels" :key="model.value" class="nc-model-chip">
|
||||
<GeneralIcon :icon="model.icon" class="w-3.5 h-3.5 text-gray-600" />
|
||||
<span>{{ model.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
-->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.review-container {
|
||||
@apply w-full;
|
||||
.nc-review-container {
|
||||
@apply w-full flex flex-col gap-6;
|
||||
}
|
||||
|
||||
.section {
|
||||
@apply mb-6;
|
||||
.nc-review-hero {
|
||||
@apply pb-6 border-b border-gray-200;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
@apply flex items-center mb-3;
|
||||
|
||||
h3 {
|
||||
@apply text-base font-medium m-0 text-gray-700;
|
||||
}
|
||||
.nc-hero-icon {
|
||||
@apply w-12 h-12 rounded-xl bg-brand-50 flex items-center justify-center;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
@apply mb-2;
|
||||
.nc-config-grid {
|
||||
@apply grid grid-cols-1 md:grid-cols-3 gap-3;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
@apply text-xs text-gray-500 mb-1;
|
||||
.nc-config-item {
|
||||
@apply flex flex-col gap-3 p-4 rounded-lg border border-gray-200 bg-gray-50;
|
||||
@apply hover:border-brand-200 hover:bg-white transition-all duration-200;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
@apply text-sm font-medium flex items-center;
|
||||
.nc-config-header {
|
||||
@apply flex items-center gap-2;
|
||||
}
|
||||
|
||||
.model-item {
|
||||
@apply flex items-center py-1 px-2 text-sm bg-gray-50 rounded-md;
|
||||
.nc-config-icon-wrapper {
|
||||
@apply w-7 h-7 rounded-lg bg-brand-50 flex items-center justify-center;
|
||||
}
|
||||
|
||||
:deep(.ant-card) {
|
||||
@apply border border-gray-200 shadow-sm;
|
||||
.nc-config-label {
|
||||
@apply text-xs font-semibold text-gray-600;
|
||||
}
|
||||
|
||||
:deep(.ant-card-body) {
|
||||
@apply p-4;
|
||||
.nc-config-value {
|
||||
@apply text-base font-semibold text-gray-900;
|
||||
}
|
||||
|
||||
.nc-models-section {
|
||||
@apply flex flex-col;
|
||||
}
|
||||
|
||||
.nc-all-models-badge {
|
||||
@apply flex items-center gap-2 p-3 rounded-lg bg-green-50 text-sm font-medium text-green-700;
|
||||
}
|
||||
|
||||
.nc-models-grid {
|
||||
@apply flex flex-wrap gap-2;
|
||||
}
|
||||
|
||||
.nc-model-chip {
|
||||
@apply flex items-center gap-1.5 px-3 py-1.5 rounded-full;
|
||||
@apply bg-gray-100 text-xs font-medium text-gray-700;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -29,98 +29,193 @@ const onCheckboxChange = (model: string) => {
|
||||
const formModel = computed(() => {
|
||||
return editMode.value ? syncConfigEditForm.value : syncConfigForm.value
|
||||
})
|
||||
|
||||
const selectAllModels = () => {
|
||||
syncAllModels.value = true
|
||||
syncConfigEditFormChanged.value = true
|
||||
}
|
||||
|
||||
const selectSpecificModels = () => {
|
||||
syncAllModels.value = false
|
||||
syncConfigEditFormChanged.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="formModel">
|
||||
<a-row :gutter="24">
|
||||
<a-col :span="24">
|
||||
<a-form-item label="Sync Title" v-bind="validateInfosSyncConfig.title" hide-required-mark>
|
||||
<a-input v-model:value="formModel.title" @change="syncConfigEditFormChanged = true" />
|
||||
<div v-if="formModel" class="nc-sync-settings">
|
||||
<!-- Basic Settings -->
|
||||
<a-form layout="vertical" class="nc-settings-section">
|
||||
<div class="nc-section-title">Basic Settings</div>
|
||||
<a-form-item class="px-0.5" label="Sync Title" v-bind="validateInfosSyncConfig.title">
|
||||
<a-input
|
||||
v-model:value="formModel.title"
|
||||
class="nc-input-shadow !rounded-lg"
|
||||
placeholder="Enter sync title"
|
||||
@change="syncConfigEditFormChanged = true"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<!-- Sync Configuration -->
|
||||
<a-form layout="vertical" class="nc-settings-section">
|
||||
<div class="nc-section-title">Sync Configuration</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<a-form-item label="Sync Type" v-bind="validateInfosSyncConfig.sync_type">
|
||||
<NcSelect v-model:value="formModel.sync_type" :options="syncTypeOptions" @change="syncConfigEditFormChanged = true" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-row :gutter="24">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="Sync Type" v-bind="validateInfosSyncConfig.sync_type" hide-required-mark>
|
||||
<a-select v-model:value="formModel.sync_type" :options="syncTypeOptions" @change="syncConfigEditFormChanged = true" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="On Delete Action" v-bind="validateInfosSyncConfig.on_delete_action" hide-required-mark>
|
||||
<a-select
|
||||
<a-form-item label="On Delete Action" v-bind="validateInfosSyncConfig.on_delete_action">
|
||||
<NcSelect
|
||||
v-model:value="formModel.on_delete_action"
|
||||
:options="onDeleteActionOptions"
|
||||
@change="syncConfigEditFormChanged = true"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-row :gutter="24">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="Sync Trigger" v-bind="validateInfosSyncConfig.sync_trigger" hide-required-mark>
|
||||
<a-select
|
||||
</div>
|
||||
</a-form>
|
||||
|
||||
<a-form layout="vertical" class="nc-settings-section">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<a-form-item label="Sync Trigger" v-bind="validateInfosSyncConfig.sync_trigger">
|
||||
<NcSelect
|
||||
v-model:value="formModel.sync_trigger"
|
||||
:options="syncTriggerOptions"
|
||||
@change="syncConfigEditFormChanged = true"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col v-if="formModel.sync_trigger === 'schedule'" :span="12">
|
||||
<a-form-item label="Sync Schedule" v-bind="validateInfosSyncConfig.sync_trigger_cron" hide-required-mark>
|
||||
<a-form-item
|
||||
v-if="formModel.sync_trigger === 'schedule'"
|
||||
label="Sync Schedule"
|
||||
v-bind="validateInfosSyncConfig.sync_trigger_cron"
|
||||
>
|
||||
<DashboardSettingsSyncSchedule v-model="formModel.sync_trigger_cron" @change="syncConfigEditFormChanged = true" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
</a-form>
|
||||
|
||||
<div v-if="editMode" class="flex justify-end">
|
||||
<NcButton class="!px-4" type="primary" size="small" :disabled="!syncConfigEditFormChanged" @click="updateSync">
|
||||
Update Sync Settings
|
||||
</NcButton>
|
||||
<!-- Update Button (Edit Mode) -->
|
||||
<div v-if="editMode" class="nc-settings-section">
|
||||
<div class="flex justify-end">
|
||||
<NcButton class="!px-4" type="primary" size="small" :disabled="!syncConfigEditFormChanged" @click="updateSync">
|
||||
Update Sync Settings
|
||||
</NcButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="syncConfigForm.sync_category !== 'custom' && !editMode" class="mt-4">
|
||||
<a-radio-group v-model:value="syncAllModels" class="w-full" @change="syncConfigEditFormChanged = true">
|
||||
<div class="flex items-start mb-4">
|
||||
<a-radio :value="true" class="!mt-0.5">
|
||||
<div class="ml-2">
|
||||
<div class="text-base">Sync all available models</div>
|
||||
<div class="text-gray-500 text-xs mt-1">
|
||||
<!-- Model Selection -->
|
||||
<div v-if="syncConfigForm.sync_category !== 'custom' && !editMode" class="nc-settings-section">
|
||||
<div class="nc-section-title">Model Selection</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<!-- Option 1: Sync All -->
|
||||
<div class="nc-model-option" :class="{ 'nc-model-option-selected': syncAllModels }" @click="selectAllModels">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="nc-option-radio">
|
||||
<div v-if="syncAllModels" class="nc-radio-dot" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-semibold text-gray-800">Sync all available models</div>
|
||||
<div class="text-xs text-gray-500 mt-1">
|
||||
All models for the category will be synced. This may take longer and use more resources.
|
||||
</div>
|
||||
</div>
|
||||
</a-radio>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start">
|
||||
<a-radio :value="false" class="!mt-0.5">
|
||||
<div class="ml-2">
|
||||
<div class="text-base">Select specific models</div>
|
||||
<!-- Option 2: Select Specific -->
|
||||
<div class="nc-model-option" :class="{ 'nc-model-option-selected': !syncAllModels }" @click="selectSpecificModels">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="nc-option-radio">
|
||||
<div v-if="!syncAllModels" class="nc-radio-dot" />
|
||||
</div>
|
||||
</a-radio>
|
||||
</div>
|
||||
</a-radio-group>
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-semibold text-gray-800">Select specific models</div>
|
||||
<div class="text-xs text-gray-500 mt-1">Choose which models to sync</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!syncAllModels" class="border rounded-lg px-1 py-2">
|
||||
<div v-for="model in availableModels" :key="model.value" class="flex items-start px-3">
|
||||
<a-checkbox
|
||||
:value="model.value"
|
||||
:checked="!formModel.exclude_models?.includes(model.value)"
|
||||
:disabled="model.required"
|
||||
@change="onCheckboxChange(model.value)"
|
||||
>
|
||||
<div class="flex justify-center flex-col ml-2">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-none mr-2">
|
||||
<GeneralIcon :icon="model.icon" class="text-primary" />
|
||||
<!-- Model Selection Grid -->
|
||||
<div v-if="!syncAllModels" class="mt-4 grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
<div
|
||||
v-for="model in availableModels"
|
||||
:key="model.value"
|
||||
class="nc-model-card"
|
||||
:class="{
|
||||
'nc-model-card-selected': !formModel.exclude_models?.includes(model.value),
|
||||
'nc-model-card-disabled': model.required,
|
||||
}"
|
||||
@click="!model.required && onCheckboxChange(model.value)"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="nc-model-checkbox">
|
||||
<GeneralIcon v-if="!formModel.exclude_models?.includes(model.value)" icon="check" class="w-3 h-3 text-white" />
|
||||
</div>
|
||||
<GeneralIcon :icon="model.icon" class="w-4 h-4 text-gray-600" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium text-gray-800 truncate">{{ model.label }}</div>
|
||||
</div>
|
||||
<div class="!text-black">{{ model.label }}</div>
|
||||
</div>
|
||||
<div class="text-gray-500 text-xs">{{ model.description }}</div>
|
||||
</div>
|
||||
</a-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.nc-sync-settings {
|
||||
@apply flex flex-col gap-6;
|
||||
}
|
||||
|
||||
.nc-settings-section {
|
||||
@apply flex flex-col gap-3;
|
||||
}
|
||||
|
||||
.nc-section-title {
|
||||
@apply text-sm font-semibold text-gray-700 mb-1;
|
||||
}
|
||||
|
||||
.nc-model-option {
|
||||
@apply p-4 rounded-lg border-2 border-gray-200 cursor-pointer transition-all duration-200;
|
||||
@apply hover:border-brand-300 hover:bg-brand-50/30;
|
||||
|
||||
&.nc-model-option-selected {
|
||||
@apply border-brand-500 bg-brand-50/50;
|
||||
|
||||
.nc-option-radio {
|
||||
@apply border-brand-500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nc-option-radio {
|
||||
@apply w-5 h-5 rounded-full border-2 border-gray-300 flex items-center justify-center;
|
||||
@apply transition-all duration-200;
|
||||
}
|
||||
|
||||
.nc-radio-dot {
|
||||
@apply w-2.5 h-2.5 rounded-full bg-brand-500;
|
||||
}
|
||||
|
||||
.nc-model-card {
|
||||
@apply p-3 rounded-lg border border-gray-200 cursor-pointer transition-all duration-200;
|
||||
@apply hover:border-brand-300 hover:bg-gray-50;
|
||||
|
||||
&.nc-model-card-selected {
|
||||
@apply border-brand-500 bg-brand-50/30;
|
||||
|
||||
.nc-model-checkbox {
|
||||
@apply bg-brand-500 border-brand-500;
|
||||
}
|
||||
}
|
||||
|
||||
&.nc-model-card-disabled {
|
||||
@apply opacity-50 cursor-not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.nc-model-checkbox {
|
||||
@apply w-4 h-4 rounded border-2 border-gray-300 flex items-center justify-center;
|
||||
@apply transition-all duration-200;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,17 +1,102 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
current: number
|
||||
}>()
|
||||
|
||||
const { syncConfigForm } = useSyncStoreOrThrow()
|
||||
|
||||
const steps = computed(() => {
|
||||
const baseSteps = [
|
||||
{ title: 'Category', index: 0 },
|
||||
{ title: 'Settings', index: 1 },
|
||||
{ title: 'Sources', index: 2 },
|
||||
]
|
||||
|
||||
if (syncConfigForm.value.sync_category === 'custom') {
|
||||
baseSteps.push({ title: 'Schema', index: 3 })
|
||||
}
|
||||
|
||||
baseSteps.push({ title: 'Review', index: baseSteps.length })
|
||||
|
||||
return baseSteps
|
||||
})
|
||||
|
||||
const getStepStatus = (stepIndex: number) => {
|
||||
if (stepIndex < props.current) return 'completed'
|
||||
if (stepIndex === props.current) return 'active'
|
||||
return 'pending'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-steps class="pointer-events-none" :current="current" size="small">
|
||||
<a-step title="Sync Category" />
|
||||
<a-step title="Sync Settings" />
|
||||
<a-step title="Sources" />
|
||||
<a-step v-if="syncConfigForm.sync_category === 'custom'" title="Destination Schema" />
|
||||
<a-step title="Review" />
|
||||
</a-steps>
|
||||
<div class="nc-sync-steps">
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div v-for="(step, idx) in steps" :key="step.index" class="flex items-center" :class="{ 'flex-1': idx < steps.length - 1 }">
|
||||
<!-- Step Circle -->
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<div
|
||||
class="nc-step-circle"
|
||||
:class="{
|
||||
'nc-step-completed': getStepStatus(step.index) === 'completed',
|
||||
'nc-step-active': getStepStatus(step.index) === 'active',
|
||||
'nc-step-pending': getStepStatus(step.index) === 'pending',
|
||||
}"
|
||||
>
|
||||
<GeneralIcon v-if="getStepStatus(step.index) === 'completed'" icon="check" class="w-4 h-4 text-white" />
|
||||
<span v-else class="text-sm font-medium">{{ step.index + 1 }}</span>
|
||||
</div>
|
||||
<span
|
||||
class="text-xs font-medium whitespace-nowrap"
|
||||
:class="{
|
||||
'text-brand-500': getStepStatus(step.index) === 'active',
|
||||
'text-gray-800': getStepStatus(step.index) === 'completed',
|
||||
'text-gray-500': getStepStatus(step.index) === 'pending',
|
||||
}"
|
||||
>
|
||||
{{ step.title }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Connector Line -->
|
||||
<div
|
||||
v-if="idx < steps.length - 1"
|
||||
class="nc-step-connector"
|
||||
:class="{
|
||||
'nc-step-connector-completed': getStepStatus(step.index) === 'completed',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.nc-sync-steps {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.nc-step-circle {
|
||||
@apply w-8 h-8 rounded-full flex items-center justify-center transition-all duration-200;
|
||||
@apply border-2;
|
||||
}
|
||||
|
||||
.nc-step-pending {
|
||||
@apply bg-gray-50 border-gray-300 text-gray-500;
|
||||
}
|
||||
|
||||
.nc-step-active {
|
||||
@apply bg-brand-50 border-brand-500 text-brand-600;
|
||||
}
|
||||
|
||||
.nc-step-completed {
|
||||
@apply bg-brand-500 border-brand-500;
|
||||
}
|
||||
|
||||
.nc-step-connector {
|
||||
@apply flex-1 h-0.5 bg-gray-200 mx-3 transition-all duration-200;
|
||||
}
|
||||
|
||||
.nc-step-connector-completed {
|
||||
@apply bg-brand-500;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -135,9 +135,9 @@ watch(
|
||||
:required="false"
|
||||
:data-testid="`nc-form-input-${field.model}`"
|
||||
>
|
||||
<template #label>
|
||||
<template v-if="![FormBuilderInputType.Switch].includes(field.type)" #label>
|
||||
<div class="flex items-center gap-1">
|
||||
<span v-if="![FormBuilderInputType.Switch].includes(field.type)">{{ field.label }}</span>
|
||||
<span>{{ field.label }}</span>
|
||||
<span v-if="field.required" class="text-red-500">*</span>
|
||||
<NcTooltip v-if="field.helpText && field.showHintAsTooltip">
|
||||
<template #title>
|
||||
@@ -295,6 +295,7 @@ watch(
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.nc-form-item {
|
||||
@apply px-0.5;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@noco-integrations/zendesk-auth",
|
||||
"version": "0.1.0",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rimraf dist",
|
||||
"lint": "eslint src --ext .ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@noco-integrations/core": "workspace:*",
|
||||
"axios": "^1.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"rimraf": "^5.0.10",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Centralized configuration for Zendesk Auth Integration
|
||||
*/
|
||||
|
||||
export const getTokenUri = (subdomain: string): string => {
|
||||
return `https://${subdomain}.zendesk.com/oauth/tokens`;
|
||||
};
|
||||
94
packages/noco-integrations/packages/zendesk-auth/src/form.ts
Normal file
94
packages/noco-integrations/packages/zendesk-auth/src/form.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
FormBuilderInputType,
|
||||
FormBuilderValidatorType,
|
||||
} from '@noco-integrations/core';
|
||||
import { AuthType } from '@noco-integrations/core';
|
||||
import type { FormDefinition } from '@noco-integrations/core';
|
||||
|
||||
export const form: FormDefinition = [
|
||||
{
|
||||
type: FormBuilderInputType.Input,
|
||||
label: 'Integration name',
|
||||
width: 100,
|
||||
model: 'title',
|
||||
placeholder: 'Integration name',
|
||||
category: 'General',
|
||||
validators: [
|
||||
{
|
||||
type: FormBuilderValidatorType.Required,
|
||||
message: 'Integration name is required',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: FormBuilderInputType.Input,
|
||||
label: 'Zendesk Subdomain',
|
||||
width: 100,
|
||||
model: 'config.subdomain',
|
||||
category: 'General',
|
||||
placeholder: 'e.g., yourcompany (from yourcompany.zendesk.com)',
|
||||
validators: [
|
||||
{
|
||||
type: FormBuilderValidatorType.Required,
|
||||
message: 'Zendesk subdomain is required',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: FormBuilderInputType.Select,
|
||||
label: 'Auth Type',
|
||||
width: 48,
|
||||
model: 'config.type',
|
||||
category: 'Authentication',
|
||||
placeholder: 'Select auth type',
|
||||
defaultValue: AuthType.ApiKey,
|
||||
options: [
|
||||
{
|
||||
label: 'API Key',
|
||||
value: AuthType.ApiKey,
|
||||
},
|
||||
],
|
||||
validators: [
|
||||
{
|
||||
type: FormBuilderValidatorType.Required,
|
||||
message: 'Auth type is required',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: FormBuilderInputType.Input,
|
||||
label: 'Email Address',
|
||||
width: 100,
|
||||
model: 'config.email',
|
||||
category: 'Authentication',
|
||||
placeholder: 'Enter your Zendesk email address',
|
||||
validators: [
|
||||
{
|
||||
type: FormBuilderValidatorType.Required,
|
||||
message: 'Email is required',
|
||||
},
|
||||
],
|
||||
condition: {
|
||||
model: 'config.type',
|
||||
value: AuthType.ApiKey,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: FormBuilderInputType.Input,
|
||||
label: 'API Token',
|
||||
width: 100,
|
||||
model: 'config.token',
|
||||
category: 'Authentication',
|
||||
placeholder: 'Enter your API Token',
|
||||
validators: [
|
||||
{
|
||||
type: FormBuilderValidatorType.Required,
|
||||
message: 'API Token is required',
|
||||
},
|
||||
],
|
||||
condition: {
|
||||
model: 'config.type',
|
||||
value: AuthType.ApiKey,
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,17 @@
|
||||
import {
|
||||
type IntegrationEntry,
|
||||
IntegrationType,
|
||||
} from '@noco-integrations/core';
|
||||
import { ZendeskAuthIntegration } from './integration';
|
||||
import { form } from './form';
|
||||
import { manifest } from './manifest';
|
||||
|
||||
const integration: IntegrationEntry = {
|
||||
type: IntegrationType.Auth,
|
||||
sub_type: 'zendesk',
|
||||
wrapper: ZendeskAuthIntegration,
|
||||
form,
|
||||
manifest,
|
||||
};
|
||||
|
||||
export default integration;
|
||||
@@ -0,0 +1,89 @@
|
||||
import axios from 'axios';
|
||||
import { AuthIntegration, AuthType } from '@noco-integrations/core';
|
||||
import type {
|
||||
AuthResponse,
|
||||
TestConnectionResponse,
|
||||
} from '@noco-integrations/core';
|
||||
|
||||
interface ZendeskClient {
|
||||
subdomain: string;
|
||||
token: string;
|
||||
email?: string;
|
||||
apiVersion: string;
|
||||
}
|
||||
|
||||
export class ZendeskAuthIntegration extends AuthIntegration {
|
||||
public client: ZendeskClient | null = null;
|
||||
|
||||
public async authenticate(): Promise<AuthResponse<ZendeskClient>> {
|
||||
switch (this.config.type) {
|
||||
case AuthType.ApiKey:
|
||||
if (!this.config.subdomain || !this.config.email || !this.config.token) {
|
||||
throw new Error('Missing required Zendesk configuration');
|
||||
}
|
||||
|
||||
this.client = {
|
||||
subdomain: this.config.subdomain,
|
||||
email: this.config.email,
|
||||
token: this.config.token,
|
||||
apiVersion: 'v2',
|
||||
};
|
||||
|
||||
return this.client;
|
||||
default:
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
}
|
||||
|
||||
public async testConnection(): Promise<TestConnectionResponse> {
|
||||
try {
|
||||
const client = await this.authenticate();
|
||||
|
||||
if (!client) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Missing Zendesk client',
|
||||
};
|
||||
}
|
||||
|
||||
// Test connection by fetching current user information
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (this.config.type === AuthType.ApiKey) {
|
||||
const auth = Buffer.from(
|
||||
`${client.email}/token:${client.token}`,
|
||||
).toString('base64');
|
||||
headers['Authorization'] = `Basic ${auth}`;
|
||||
}
|
||||
|
||||
const response = await axios.get(
|
||||
`https://${client.subdomain}.zendesk.com/api/v2/users/me.json`,
|
||||
{ headers },
|
||||
);
|
||||
|
||||
if (response.data && response.data.user) {
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to verify Zendesk connection',
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async exchangeToken(payload: {
|
||||
code: string;
|
||||
}): Promise<{ oauth_token: string }> {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { IntegrationManifest } from '@noco-integrations/core';
|
||||
|
||||
export const manifest: IntegrationManifest = {
|
||||
title: 'Zendesk',
|
||||
icon: 'zendesk',
|
||||
description: 'Zendesk authentication integration for NocoDB',
|
||||
version: '0.1.0',
|
||||
author: 'NocoDB',
|
||||
website: 'https://www.zendesk.com',
|
||||
order: 100,
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@noco-integrations/zendesk-sync",
|
||||
"version": "0.1.0",
|
||||
"description": "Zendesk Sync integration for NocoDB",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@noco-integrations/core": "workspace:*",
|
||||
"axios": "^1.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
36
packages/noco-integrations/packages/zendesk-sync/src/form.ts
Normal file
36
packages/noco-integrations/packages/zendesk-sync/src/form.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
FormBuilderInputType,
|
||||
FormBuilderValidatorType,
|
||||
type FormDefinition,
|
||||
IntegrationType,
|
||||
} from '@noco-integrations/core';
|
||||
|
||||
const form: FormDefinition = [
|
||||
{
|
||||
type: FormBuilderInputType.SelectIntegration,
|
||||
label: 'Zendesk Connection',
|
||||
width: 100,
|
||||
model: 'config.authIntegrationId',
|
||||
category: 'Authentication',
|
||||
integrationFilter: {
|
||||
type: IntegrationType.Auth,
|
||||
sub_type: 'zendesk',
|
||||
},
|
||||
validators: [
|
||||
{
|
||||
type: FormBuilderValidatorType.Required,
|
||||
message: 'Zendesk connection is required',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: FormBuilderInputType.Switch,
|
||||
label: 'Include closed tickets',
|
||||
width: 48,
|
||||
model: 'config.includeClosed',
|
||||
category: 'Source',
|
||||
defaultValue: true,
|
||||
}
|
||||
];
|
||||
|
||||
export default form;
|
||||
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
type IntegrationEntry,
|
||||
IntegrationType,
|
||||
} from '@noco-integrations/core';
|
||||
import ZendeskSyncIntegration from './integration';
|
||||
import manifest from './manifest';
|
||||
import form from './form';
|
||||
|
||||
const integration: IntegrationEntry = {
|
||||
type: IntegrationType.Sync,
|
||||
sub_type: 'zendesk',
|
||||
wrapper: ZendeskSyncIntegration,
|
||||
form,
|
||||
manifest,
|
||||
};
|
||||
|
||||
export { manifest, form, ZendeskSyncIntegration };
|
||||
export default integration;
|
||||
@@ -0,0 +1,458 @@
|
||||
import axios from 'axios';
|
||||
import {
|
||||
DataObjectStream,
|
||||
SCHEMA_TICKETING,
|
||||
SyncIntegration,
|
||||
TARGET_TABLES,
|
||||
} from '@noco-integrations/core';
|
||||
import type {
|
||||
AuthResponse,
|
||||
SyncLinkValue,
|
||||
SyncRecord,
|
||||
TicketingCommentRecord,
|
||||
TicketingTeamRecord,
|
||||
TicketingTicketRecord,
|
||||
TicketingUserRecord,
|
||||
} from '@noco-integrations/core';
|
||||
|
||||
interface ZendeskClient {
|
||||
subdomain: string;
|
||||
token: string;
|
||||
email?: string;
|
||||
apiVersion: string;
|
||||
}
|
||||
|
||||
export interface ZendeskSyncPayload {
|
||||
includeClosed: boolean;
|
||||
}
|
||||
|
||||
export default class ZendeskSyncIntegration extends SyncIntegration<ZendeskSyncPayload> {
|
||||
public getTitle() {
|
||||
return 'Zendesk Tickets';
|
||||
}
|
||||
|
||||
public async getDestinationSchema(_auth: AuthResponse<ZendeskClient>) {
|
||||
return SCHEMA_TICKETING;
|
||||
}
|
||||
|
||||
private getAuthHeaders(client: ZendeskClient): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (client.email) {
|
||||
const auth = Buffer.from(`${client.email}/token:${client.token}`).toString(
|
||||
'base64',
|
||||
);
|
||||
headers['Authorization'] = `Basic ${auth}`;
|
||||
} else {
|
||||
headers['Authorization'] = `Bearer ${client.token}`;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
public async fetchData(
|
||||
auth: AuthResponse<ZendeskClient>,
|
||||
args: {
|
||||
targetTables?: TARGET_TABLES[];
|
||||
targetTableIncrementalValues?: Record<TARGET_TABLES, string>;
|
||||
},
|
||||
): Promise<
|
||||
DataObjectStream<
|
||||
| TicketingTicketRecord
|
||||
| TicketingUserRecord
|
||||
| TicketingCommentRecord
|
||||
| TicketingTeamRecord
|
||||
>
|
||||
> {
|
||||
const client = auth;
|
||||
const { includeClosed } = this.config;
|
||||
const { targetTableIncrementalValues } = args;
|
||||
|
||||
const stream = new DataObjectStream<
|
||||
| TicketingTicketRecord
|
||||
| TicketingUserRecord
|
||||
| TicketingCommentRecord
|
||||
| TicketingTeamRecord
|
||||
>();
|
||||
|
||||
const userMap = new Map<string, boolean>();
|
||||
const ticketMap = new Map<string, boolean>();
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const headers = this.getAuthHeaders(client);
|
||||
const baseUrl = `https://${client.subdomain}.zendesk.com/api/v2`;
|
||||
|
||||
const ticketIncrementalValue =
|
||||
targetTableIncrementalValues?.[TARGET_TABLES.TICKETING_TICKET];
|
||||
|
||||
// Build query parameters for regular tickets API
|
||||
const queryParams = new URLSearchParams({
|
||||
per_page: '100',
|
||||
});
|
||||
|
||||
if (ticketIncrementalValue) {
|
||||
queryParams.set('updated_after', ticketIncrementalValue);
|
||||
}
|
||||
|
||||
if (!includeClosed) {
|
||||
queryParams.set('status', 'open');
|
||||
}
|
||||
|
||||
// Fetch tickets using regular API (not incremental)
|
||||
this.log('[Zendesk Sync] Fetching tickets');
|
||||
|
||||
let page = 1;
|
||||
let totalTickets = 0;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
queryParams.set('page', page.toString());
|
||||
const url = `${baseUrl}/tickets.json?${queryParams.toString()}`;
|
||||
this.log(`[Zendesk Sync] Fetching page ${page}`);
|
||||
|
||||
const response: any = await axios.get(url, { headers });
|
||||
const data: any = response.data;
|
||||
|
||||
this.log(`[Zendesk Sync] Fetched ${data.tickets.length} tickets`);
|
||||
|
||||
// Break if no tickets returned
|
||||
if (!data.tickets || data.tickets.length === 0) {
|
||||
hasMore = false;
|
||||
break;
|
||||
}
|
||||
|
||||
totalTickets += data.tickets.length;
|
||||
|
||||
for (const ticket of data.tickets) {
|
||||
// Filter based on status
|
||||
if (!includeClosed && ['closed', 'solved'].includes(ticket.status)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ticketMap.set(ticket.id.toString(), true);
|
||||
|
||||
// Process ticket
|
||||
const ticketData = this.formatTicket(ticket);
|
||||
stream.push({
|
||||
recordId: ticket.id.toString(),
|
||||
targetTable: TARGET_TABLES.TICKETING_TICKET,
|
||||
data: ticketData.data as TicketingTicketRecord,
|
||||
links: ticketData.links,
|
||||
});
|
||||
|
||||
// Process users (requester, assignee, submitter)
|
||||
const userIds = [
|
||||
ticket.requester_id,
|
||||
ticket.assignee_id,
|
||||
ticket.submitter_id,
|
||||
].filter((id) => id);
|
||||
|
||||
for (const userId of userIds) {
|
||||
if (!userMap.has(userId.toString())) {
|
||||
userMap.set(userId.toString(), true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
page++;
|
||||
|
||||
// Respect rate limits
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
|
||||
this.log(`[Zendesk Sync] Total tickets fetched: ${totalTickets}`);
|
||||
|
||||
// Fetch users
|
||||
if (userMap.size > 0) {
|
||||
this.log(`[Zendesk Sync] Fetching ${userMap.size} users`);
|
||||
|
||||
const userIds = Array.from(userMap.keys());
|
||||
const batchSize = 100;
|
||||
|
||||
for (let i = 0; i < userIds.length; i += batchSize) {
|
||||
const batch = userIds.slice(i, i + batchSize);
|
||||
const userQueryParams = new URLSearchParams({
|
||||
ids: batch.join(','),
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`${baseUrl}/users/show_many.json?${userQueryParams.toString()}`,
|
||||
{ headers },
|
||||
);
|
||||
|
||||
for (const user of response.data.users) {
|
||||
const userData = this.formatUser(user);
|
||||
stream.push({
|
||||
recordId: user.id.toString(),
|
||||
targetTable: TARGET_TABLES.TICKETING_USER,
|
||||
data: userData.data as TicketingUserRecord,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
this.log(`[Zendesk Sync] Error fetching users batch: ${error}`);
|
||||
}
|
||||
|
||||
// Respect rate limits
|
||||
if (i + batchSize < userIds.length) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch comments if requested
|
||||
if (args.targetTables?.includes(TARGET_TABLES.TICKETING_COMMENT)) {
|
||||
this.log('[Zendesk Sync] Fetching comments');
|
||||
|
||||
for (const ticketId of ticketMap.keys()) {
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`${baseUrl}/tickets/${ticketId}/comments.json`,
|
||||
{ headers },
|
||||
);
|
||||
|
||||
if (!response.data.comments) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const comment of response.data.comments) {
|
||||
const commentData = this.formatComment({
|
||||
...comment,
|
||||
ticketId,
|
||||
});
|
||||
stream.push({
|
||||
recordId: comment.id.toString(),
|
||||
targetTable: TARGET_TABLES.TICKETING_COMMENT,
|
||||
data: commentData.data as TicketingCommentRecord,
|
||||
links: commentData.links,
|
||||
});
|
||||
|
||||
// Add comment author to users if not already added
|
||||
if (comment.author_id && !userMap.has(comment.author_id.toString())) {
|
||||
userMap.set(comment.author_id.toString(), true);
|
||||
|
||||
try {
|
||||
const userResponse = await axios.get(
|
||||
`${baseUrl}/users/${comment.author_id}.json`,
|
||||
{ headers },
|
||||
);
|
||||
|
||||
const userData = this.formatUser(userResponse.data.user);
|
||||
stream.push({
|
||||
recordId: comment.author_id.toString(),
|
||||
targetTable: TARGET_TABLES.TICKETING_USER,
|
||||
data: userData.data as TicketingUserRecord,
|
||||
});
|
||||
} catch (error) {
|
||||
this.log(
|
||||
`[Zendesk Sync] Error fetching comment author ${comment.author_id}: ${error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Respect rate limits
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
} catch (error: any) {
|
||||
// Log but don't fail - comments might not be accessible
|
||||
if (error.response?.status === 401) {
|
||||
this.log(
|
||||
`[Zendesk Sync] No permission to fetch comments for ticket ${ticketId}. Skipping comments.`,
|
||||
);
|
||||
// Stop trying to fetch more comments if we get 401
|
||||
break;
|
||||
} else {
|
||||
this.log(
|
||||
`[Zendesk Sync] Error fetching comments for ticket ${ticketId}: ${error.message || error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch organization (team) if requested
|
||||
if (args.targetTables?.includes(TARGET_TABLES.TICKETING_TEAM)) {
|
||||
this.log('[Zendesk Sync] Fetching organizations');
|
||||
|
||||
try {
|
||||
let orgNextPage: string | null = `${baseUrl}/organizations.json`;
|
||||
|
||||
while (orgNextPage) {
|
||||
const response: any = await axios.get(orgNextPage, { headers });
|
||||
const data: any = response.data;
|
||||
|
||||
for (const org of data.organizations) {
|
||||
const teamData = this.formatTeam(org);
|
||||
stream.push({
|
||||
recordId: org.id.toString(),
|
||||
targetTable: TARGET_TABLES.TICKETING_TEAM,
|
||||
data: teamData.data as TicketingTeamRecord,
|
||||
});
|
||||
}
|
||||
|
||||
orgNextPage = data.next_page;
|
||||
|
||||
if (orgNextPage) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.log(`[Zendesk Sync] Error fetching organizations: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
stream.push(null); // End the stream
|
||||
} catch (error) {
|
||||
this.log(`[Zendesk Sync] Error fetching data: ${error}`);
|
||||
stream.emit('error', error);
|
||||
}
|
||||
})();
|
||||
|
||||
return stream;
|
||||
}
|
||||
|
||||
public formatData(
|
||||
targetTable: TARGET_TABLES,
|
||||
data: any,
|
||||
): {
|
||||
data: SyncRecord;
|
||||
links?: Record<string, SyncLinkValue>;
|
||||
} {
|
||||
switch (targetTable) {
|
||||
case TARGET_TABLES.TICKETING_TICKET:
|
||||
return this.formatTicket(data);
|
||||
case TARGET_TABLES.TICKETING_USER:
|
||||
return this.formatUser(data);
|
||||
case TARGET_TABLES.TICKETING_COMMENT:
|
||||
return this.formatComment(data);
|
||||
case TARGET_TABLES.TICKETING_TEAM:
|
||||
return this.formatTeam(data);
|
||||
default: {
|
||||
return {
|
||||
data: {
|
||||
RemoteRaw: JSON.stringify(data),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private formatTicket(ticket: any): {
|
||||
data: TicketingTicketRecord;
|
||||
links?: Record<string, SyncLinkValue>;
|
||||
} {
|
||||
const ticketData: TicketingTicketRecord = {
|
||||
Name: ticket.subject || null,
|
||||
Description: ticket.description || null,
|
||||
'Due Date': ticket.due_at || null,
|
||||
Priority: ticket.priority || null,
|
||||
Status: ticket.status || null,
|
||||
Tags: ticket.tags?.join(', ') || null,
|
||||
'Ticket Type': ticket.type || null,
|
||||
Url: ticket.url || null,
|
||||
'Is Active': !['closed', 'solved'].includes(ticket.status),
|
||||
'Completed At': ticket.status === 'closed' || ticket.status === 'solved' ? ticket.updated_at : null,
|
||||
'Ticket Number': ticket.id?.toString() || null,
|
||||
RemoteCreatedAt: ticket.created_at || null,
|
||||
RemoteUpdatedAt: ticket.updated_at || null,
|
||||
RemoteRaw: JSON.stringify(ticket),
|
||||
};
|
||||
|
||||
const links: Record<string, string[]> = {};
|
||||
|
||||
if (ticket.assignee_id) {
|
||||
links.Assignees = [ticket.assignee_id.toString()];
|
||||
}
|
||||
|
||||
if (ticket.requester_id) {
|
||||
links.Creator = [ticket.requester_id.toString()];
|
||||
}
|
||||
|
||||
if (ticket.organization_id) {
|
||||
links.Team = [ticket.organization_id.toString()];
|
||||
}
|
||||
|
||||
return {
|
||||
data: ticketData,
|
||||
links,
|
||||
};
|
||||
}
|
||||
|
||||
private formatUser(user: any): {
|
||||
data: TicketingUserRecord;
|
||||
} {
|
||||
const userData: TicketingUserRecord = {
|
||||
Name: user.name || null,
|
||||
Email: user.email || null,
|
||||
Url: user.url || null,
|
||||
RemoteCreatedAt: user.created_at || null,
|
||||
RemoteUpdatedAt: user.updated_at || null,
|
||||
RemoteRaw: JSON.stringify(user),
|
||||
};
|
||||
|
||||
return {
|
||||
data: userData,
|
||||
};
|
||||
}
|
||||
|
||||
private formatComment(comment: any): {
|
||||
data: TicketingCommentRecord;
|
||||
links?: Record<string, SyncLinkValue>;
|
||||
} {
|
||||
const commentData: TicketingCommentRecord = {
|
||||
Title: `Comment on ticket #${comment.ticketId}`,
|
||||
Body: comment.body || comment.html_body || null,
|
||||
Url: null,
|
||||
RemoteCreatedAt: comment.created_at || null,
|
||||
RemoteUpdatedAt: null,
|
||||
RemoteRaw: JSON.stringify(comment),
|
||||
};
|
||||
|
||||
const links: Record<string, string[]> = {};
|
||||
|
||||
if (comment.ticketId) {
|
||||
links.Ticket = [comment.ticketId.toString()];
|
||||
}
|
||||
|
||||
if (comment.author_id) {
|
||||
links['Created By'] = [comment.author_id.toString()];
|
||||
}
|
||||
|
||||
return {
|
||||
data: commentData,
|
||||
links,
|
||||
};
|
||||
}
|
||||
|
||||
private formatTeam(team: any): {
|
||||
data: TicketingTeamRecord;
|
||||
} {
|
||||
const teamData: TicketingTeamRecord = {
|
||||
Name: team.name || null,
|
||||
Description: team.details || null,
|
||||
RemoteCreatedAt: team.created_at || null,
|
||||
RemoteUpdatedAt: team.updated_at || null,
|
||||
RemoteRaw: JSON.stringify(team),
|
||||
};
|
||||
|
||||
return {
|
||||
data: teamData,
|
||||
};
|
||||
}
|
||||
|
||||
public getIncrementalKey(targetTable: TARGET_TABLES): string {
|
||||
switch (targetTable) {
|
||||
case TARGET_TABLES.TICKETING_TICKET:
|
||||
return 'RemoteUpdatedAt';
|
||||
case TARGET_TABLES.TICKETING_COMMENT:
|
||||
return 'RemoteCreatedAt';
|
||||
case TARGET_TABLES.TICKETING_USER:
|
||||
case TARGET_TABLES.TICKETING_TEAM:
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import {
|
||||
type IntegrationManifest,
|
||||
SyncCategory,
|
||||
} from '@noco-integrations/core';
|
||||
|
||||
const manifest: IntegrationManifest = {
|
||||
title: 'Zendesk',
|
||||
icon: 'ncLogoZendesk',
|
||||
version: '0.1.0',
|
||||
description: 'Sync Zendesk tickets and users',
|
||||
sync_category: SyncCategory.TICKETING,
|
||||
};
|
||||
|
||||
export default manifest;
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
40
packages/noco-integrations/pnpm-lock.yaml
generated
40
packages/noco-integrations/pnpm-lock.yaml
generated
@@ -550,6 +550,35 @@ importers:
|
||||
specifier: ^5.1.6
|
||||
version: 5.8.3
|
||||
|
||||
packages/zendesk-auth:
|
||||
dependencies:
|
||||
'@noco-integrations/core':
|
||||
specifier: workspace:*
|
||||
version: link:../../core
|
||||
axios:
|
||||
specifier: ^1.9.0
|
||||
version: 1.9.0
|
||||
devDependencies:
|
||||
rimraf:
|
||||
specifier: ^5.0.10
|
||||
version: 5.0.10
|
||||
typescript:
|
||||
specifier: ^5.8.3
|
||||
version: 5.8.3
|
||||
|
||||
packages/zendesk-sync:
|
||||
dependencies:
|
||||
'@noco-integrations/core':
|
||||
specifier: workspace:*
|
||||
version: link:../../core
|
||||
axios:
|
||||
specifier: ^1.9.0
|
||||
version: 1.9.0
|
||||
devDependencies:
|
||||
typescript:
|
||||
specifier: ^5.8.3
|
||||
version: 5.8.3
|
||||
|
||||
packages:
|
||||
|
||||
'@ai-sdk/amazon-bedrock@2.2.9':
|
||||
@@ -1136,67 +1165,56 @@ packages:
|
||||
resolution: {integrity: sha512-de6TFZYIvJwRNjmW3+gaXiZ2DaWL5D5yGmSYzkdzjBDS3W+B9JQ48oZEsmMvemqjtAFzE16DIBLqd6IQQRuG9Q==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.40.2':
|
||||
resolution: {integrity: sha512-urjaEZubdIkacKc930hUDOfQPysezKla/O9qV+O89enqsqUmQm8Xj8O/vh0gHg4LYfv7Y7UsE3QjzLQzDYN1qg==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.40.2':
|
||||
resolution: {integrity: sha512-KlE8IC0HFOC33taNt1zR8qNlBYHj31qGT1UqWqtvR/+NuCVhfufAq9fxO8BMFC22Wu0rxOwGVWxtCMvZVLmhQg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.40.2':
|
||||
resolution: {integrity: sha512-j8CgxvfM0kbnhu4XgjnCWJQyyBOeBI1Zq91Z850aUddUmPeQvuAy6OiMdPS46gNFgy8gN1xkYyLgwLYZG3rBOg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loongarch64-gnu@4.40.2':
|
||||
resolution: {integrity: sha512-Ybc/1qUampKuRF4tQXc7G7QY9YRyeVSykfK36Y5Qc5dmrIxwFhrOzqaVTNoZygqZ1ZieSWTibfFhQ5qK8jpWxw==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-powerpc64le-gnu@4.40.2':
|
||||
resolution: {integrity: sha512-3FCIrnrt03CCsZqSYAOW/k9n625pjpuMzVfeI+ZBUSDT3MVIFDSPfSUgIl9FqUftxcUXInvFah79hE1c9abD+Q==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.40.2':
|
||||
resolution: {integrity: sha512-QNU7BFHEvHMp2ESSY3SozIkBPaPBDTsfVNGx3Xhv+TdvWXFGOSH2NJvhD1zKAT6AyuuErJgbdvaJhYVhVqrWTg==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.40.2':
|
||||
resolution: {integrity: sha512-5W6vNYkhgfh7URiXTO1E9a0cy4fSgfE4+Hl5agb/U1sa0kjOLMLC1wObxwKxecE17j0URxuTrYZZME4/VH57Hg==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.40.2':
|
||||
resolution: {integrity: sha512-B7LKIz+0+p348JoAL4X/YxGx9zOx3sR+o6Hj15Y3aaApNfAshK8+mWZEf759DXfRLeL2vg5LYJBB7DdcleYCoQ==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.40.2':
|
||||
resolution: {integrity: sha512-lG7Xa+BmBNwpjmVUbmyKxdQJ3Q6whHjMjzQplOs5Z+Gj7mxPtWakGHqzMqNER68G67kmCX9qX57aRsW5V0VOng==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.40.2':
|
||||
resolution: {integrity: sha512-tD46wKHd+KJvsmije4bUskNuvWKFcTOIM9tZ/RrmIvcXnbi0YK/cKS9FzFtAm7Oxi2EhV5N2OpfFB348vSQRXA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-win32-arm64-msvc@4.40.2':
|
||||
resolution: {integrity: sha512-Bjv/HG8RRWLNkXwQQemdsWw4Mg+IJ29LK+bJPW2SCzPKOUaMmPEppQlu/Fqk1d7+DX3V7JbFdbkh/NMmurT6Pg==}
|
||||
|
||||
@@ -4196,7 +4196,7 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 {
|
||||
await this.validateOptions(column, data);
|
||||
} catch (ex) {
|
||||
if (ex instanceof OptionsNotExistsError && typecast) {
|
||||
await Column.update(this.context, column.id, {
|
||||
const UpdatedColumn = await Column.update(this.context, column.id, {
|
||||
...column,
|
||||
colOptions: {
|
||||
options: [
|
||||
@@ -4212,6 +4212,21 @@ class BaseModelSqlv2 implements IBaseModelSqlV2 {
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const table = await Model.getWithInfo(this.context, {
|
||||
id: column.fk_model_id,
|
||||
});
|
||||
|
||||
NocoSocket.broadcastEvent(this.context, {
|
||||
event: EventType.META_EVENT,
|
||||
payload: {
|
||||
action: 'column_update',
|
||||
payload: {
|
||||
table,
|
||||
column: UpdatedColumn,
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
throw ex;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ import OpenaiAi from '@noco-local-integrations/openai-ai';
|
||||
import OpenaiCompatibleAi from '@noco-local-integrations/openai-compatible-ai';
|
||||
import PostgresAuth from '@noco-local-integrations/postgres-auth';
|
||||
import PostgresSync from '@noco-local-integrations/postgres-sync';
|
||||
import ZendeskAuth from '@noco-local-integrations/zendesk-auth';
|
||||
import ZendeskSync from '@noco-local-integrations/zendesk-sync';
|
||||
|
||||
import type { IntegrationEntry } from '@noco-local-integrations/core';
|
||||
|
||||
@@ -51,4 +53,6 @@ export default [
|
||||
OpenaiCompatibleAi,
|
||||
PostgresAuth,
|
||||
PostgresSync,
|
||||
ZendeskAuth,
|
||||
ZendeskSync,
|
||||
] as IntegrationEntry[];
|
||||
|
||||
@@ -905,6 +905,11 @@ export class SyncModuleSyncDataProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
req.query = {
|
||||
...req.query,
|
||||
typecast: 'true',
|
||||
};
|
||||
|
||||
if (dataToUpdate.length) {
|
||||
await this.dataTableService.dataUpdate(context, {
|
||||
baseId: model.base_id,
|
||||
|
||||
@@ -1903,6 +1903,8 @@ export default class Column<T = any> implements ColumnType {
|
||||
cleanBaseSchemaCacheForBase(context.base_id).catch(() => {
|
||||
logger.error('Failed to clean base schema cache');
|
||||
});
|
||||
|
||||
return this.get(context, { colId }, ncMeta);
|
||||
}
|
||||
|
||||
static async updateCustomIndexName(
|
||||
|
||||
Reference in New Issue
Block a user