Merge pull request #7153 from nocodb/zendesk-integeration

feat: zendesk integeration
This commit is contained in:
Pranav C
2025-10-29 18:58:32 +05:30
committed by GitHub
28 changed files with 1695 additions and 641 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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];

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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"
}
}

View File

@@ -0,0 +1,7 @@
/**
* Centralized configuration for Zendesk Auth Integration
*/
export const getTokenUri = (subdomain: string): string => {
return `https://${subdomain}.zendesk.com/oauth/tokens`;
};

View 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,
},
},
];

View File

@@ -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;

View File

@@ -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');
}
}

View File

@@ -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,
};

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"]
}

View File

@@ -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"
}
}

View 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;

View File

@@ -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;

View File

@@ -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 '';
}
}
}

View File

@@ -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;

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"]
}

View File

@@ -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==}

View File

@@ -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;
}

View File

@@ -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[];

View File

@@ -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,

View File

@@ -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(