mirror of
https://github.com/nocodb/nocodb.git
synced 2026-05-01 18:07:18 +00:00
399 lines
13 KiB
Vue
399 lines
13 KiB
Vue
<script setup lang="ts">
|
|
import tinycolor from 'tinycolor2'
|
|
import { type BaseType, WorkspaceUserRoles } from 'nocodb-sdk'
|
|
|
|
const props = defineProps<{
|
|
modelValue: boolean
|
|
base: BaseType
|
|
}>()
|
|
|
|
const emit = defineEmits(['update:modelValue'])
|
|
|
|
const dialogShow = useVModel(props, 'modelValue', emit)
|
|
|
|
const { navigateToProject } = useGlobal()
|
|
|
|
const { refreshCommandPalette } = useCommandPalette()
|
|
|
|
const { isFeatureEnabled } = useBetaFeatureToggle()
|
|
|
|
const isScriptsEnabled = computed(() => isFeatureEnabled(FEATURE_FLAG.NOCODB_SCRIPTS))
|
|
|
|
const { api } = useApi()
|
|
|
|
const { $e, $poller } = useNuxtApp()
|
|
|
|
const basesStore = useBases()
|
|
|
|
const { workspacesList, activeWorkspace } = useWorkspace()
|
|
|
|
const { loadProjects, createProject: _createProject } = basesStore
|
|
|
|
const options = ref({
|
|
includeData: true,
|
|
includeViews: true,
|
|
includeHooks: true,
|
|
includeComments: true,
|
|
includeScripts: true,
|
|
})
|
|
const targetWorkspace = ref(activeWorkspace)
|
|
|
|
const errorMessage = ref()
|
|
|
|
// Used to handle different Action in different states in Modal
|
|
// pending -> Initial state
|
|
// loading -> Set when duplicate is triggered
|
|
const status = ref<'pending' | 'success' | 'error' | 'loading'>('pending')
|
|
|
|
const isEaster = ref(false)
|
|
|
|
const dropdownOpen = ref(false)
|
|
|
|
const optionsToExclude = computed(() => {
|
|
const { includeData, includeViews, includeHooks, includeComments, includeScripts } = options.value
|
|
return {
|
|
excludeData: !includeData,
|
|
excludeViews: !includeViews,
|
|
excludeHooks: !includeHooks,
|
|
excludeComments: !includeComments,
|
|
excludeScripts: !includeScripts,
|
|
}
|
|
})
|
|
|
|
const workspaceOptions = computed(() => {
|
|
if (!isEeUI) return []
|
|
return workspacesList.filter((ws) =>
|
|
[WorkspaceUserRoles.CREATOR, WorkspaceUserRoles.OWNER].includes(ws.roles as WorkspaceUserRoles),
|
|
)
|
|
})
|
|
|
|
const isLoading = computed(() => status.value === 'loading')
|
|
|
|
const targetBase = ref()
|
|
|
|
const _duplicate = async () => {
|
|
try {
|
|
status.value = 'loading'
|
|
// pick a random color from array and assign to base
|
|
const color = baseThemeColors[Math.floor(Math.random() * 1000) % baseThemeColors.length]
|
|
const tcolor = tinycolor(color)
|
|
|
|
const complement = tcolor.complement()
|
|
|
|
const jobData = await api.base.duplicate(props.base.id as string, {
|
|
options: optionsToExclude.value,
|
|
base: {
|
|
fk_workspace_id: isEeUI ? (targetWorkspace.value?.id ? targetWorkspace.value.id : props.base.fk_workspace_id) : null,
|
|
type: props.base.type,
|
|
color,
|
|
meta: JSON.stringify({
|
|
theme: {
|
|
primaryColor: color,
|
|
accentColor: complement.toHex8String(),
|
|
},
|
|
iconColor: parseProp(props.base.meta).iconColor,
|
|
}),
|
|
},
|
|
})
|
|
|
|
$poller.subscribe(
|
|
{ id: jobData.id },
|
|
async (data: {
|
|
id: string
|
|
status?: string
|
|
data?: {
|
|
error?: {
|
|
message: string
|
|
}
|
|
message?: string
|
|
result?: any
|
|
}
|
|
}) => {
|
|
if (data.status !== 'close') {
|
|
if (data.status === JobStatus.COMPLETED) {
|
|
const resBases = await loadProjects('workspace', targetWorkspace?.value?.id)
|
|
targetBase.value = resBases.find((b) => b.id === jobData.base_id)
|
|
status.value = 'success'
|
|
|
|
refreshCommandPalette()
|
|
} else if (data.status === JobStatus.FAILED) {
|
|
status.value = 'error'
|
|
errorMessage.value = data?.data?.error?.message || 'Some error occurred'
|
|
await loadProjects('workspace')
|
|
refreshCommandPalette()
|
|
}
|
|
}
|
|
},
|
|
)
|
|
|
|
$e('a:base:duplicate')
|
|
} catch (e: any) {
|
|
message.error(await extractSdkResponseErrorMsg(e))
|
|
errorMessage.value = await extractSdkResponseErrorMsg(e)
|
|
status.value = 'error'
|
|
dialogShow.value = false
|
|
}
|
|
}
|
|
|
|
const selectOption = (option: WorkspaceType) => {
|
|
targetWorkspace.value = option
|
|
dropdownOpen.value = false
|
|
}
|
|
|
|
const handleActionClick = () => {
|
|
switch (status.value) {
|
|
case 'pending': {
|
|
_duplicate()
|
|
break
|
|
}
|
|
case 'error': {
|
|
targetBase.value = null
|
|
errorMessage.value = null
|
|
status.value = 'pending'
|
|
break
|
|
}
|
|
case 'success': {
|
|
const base = targetBase.value
|
|
navigateToProject({
|
|
workspaceId: isEeUI ? base.fk_workspace_id : undefined,
|
|
baseId: base.id,
|
|
type: base.type,
|
|
})
|
|
dialogShow.value = false
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
watch(dialogShow, (newVal) => {
|
|
if (!newVal) {
|
|
status.value = 'pending'
|
|
}
|
|
})
|
|
|
|
onKeyStroke('Enter', () => {
|
|
// should only trigger this when our modal is open
|
|
if (dialogShow.value) {
|
|
_duplicate()
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<GeneralModal
|
|
v-if="base"
|
|
v-model:visible="dialogShow"
|
|
:mask-style="{
|
|
'background-color': 'rgba(0, 0, 0, 0.08)',
|
|
}"
|
|
:mask-closable="!isLoading"
|
|
:keyboard="!isLoading"
|
|
class="!w-[30rem]"
|
|
wrap-class-name="nc-modal-base-duplicate"
|
|
>
|
|
<div>
|
|
<div class="text-base text-nc-content-gray-emphasis leading-6 font-bold self-center" @dblclick="isEaster = !isEaster">
|
|
<template v-if="['pending', 'loading'].includes(status)">
|
|
{{ $t('labels.duplicateBaseBaseTitle', { baseTitle: base.title }) }}
|
|
</template>
|
|
|
|
<template v-else-if="status === 'success'">
|
|
<div class="flex items-center gap-2">
|
|
<GeneralIcon class="text-white w-6 h-6" icon="checkFill" />
|
|
<div class="text-nc-content-gray-emphasis font-semibold">
|
|
{{ $t('labels.duplicateBaseSuccessfull') }}
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<template v-else-if="status === 'error'">
|
|
<div class="flex items-center gap-2">
|
|
<GeneralIcon icon="ncInfoSolid" class="flex-none !text-nc-content-red-dark w-6 h-6" />
|
|
<div class="text-nc-content-gray-emphasis font-semibold">
|
|
{{ $t('labels.duplicateBaseFailed') }}
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<template v-if="['pending', 'loading'].includes(status)">
|
|
<div class="mt-5 flex gap-3 flex-col">
|
|
<div
|
|
class="flex gap-3 cursor-pointer leading-5 text-nc-content-gray font-medium items-center"
|
|
@click="options.includeData = !options.includeData"
|
|
>
|
|
<NcSwitch :checked="options.includeData" />
|
|
{{ $t('labels.includeRecords') }}
|
|
</div>
|
|
<template v-if="isEaster">
|
|
<div
|
|
class="flex gap-3 cursor-pointer leading-5 text-nc-content-gray font-medium items-center"
|
|
@click="options.includeViews = !options.includeViews"
|
|
>
|
|
<NcSwitch :checked="options.includeViews" />
|
|
{{ $t('labels.includeView') }}
|
|
</div>
|
|
|
|
<div
|
|
class="flex gap-3 cursor-pointer leading-5 text-nc-content-gray font-medium items-center"
|
|
@click="options.includeHooks = !options.includeHooks"
|
|
>
|
|
<NcSwitch :checked="options.includeHooks" />
|
|
{{ $t('labels.includeWebhook') }}
|
|
</div>
|
|
</template>
|
|
|
|
<div
|
|
class="flex gap-3 cursor-pointer leading-5 text-nc-content-gray font-medium items-center"
|
|
@click="options.includeComments = !options.includeComments"
|
|
>
|
|
<NcSwitch :checked="options.includeComments" />
|
|
{{ $t('labels.includeComments') }}
|
|
</div>
|
|
|
|
<div
|
|
v-if="isScriptsEnabled"
|
|
class="flex gap-3 cursor-pointer leading-5 text-nc-content-gray font-medium items-center"
|
|
@click="options.includeScripts = !options.includeScripts"
|
|
>
|
|
<NcSwitch :checked="options.includeScripts" />
|
|
{{ $t('labels.includeScripts') }}
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
:class="{
|
|
'mb-5': !isEeUI,
|
|
}"
|
|
class="mt-5 text-nc-content-gray-subtle2 font-medium"
|
|
>
|
|
{{ $t('labels.baseDuplicateMessage') }}
|
|
</div>
|
|
|
|
<div v-if="isEeUI" class="mb-5">
|
|
<NcDivider divider-class="!my-5" />
|
|
|
|
<div class="text-nc-content-gray font-medium leading-5">
|
|
{{ $t('labels.workspace') }}
|
|
|
|
<NcDropdown v-model:visible="dropdownOpen" class="mt-2">
|
|
<div
|
|
class="rounded-lg border-1 transition-all cursor-pointer flex items-center border-nc-border-grey-medium h-8 py-1 gap-2 px-3"
|
|
style="box-shadow: 0px 0px 4px 0px rgba(0, 0, 0, 0.08)"
|
|
:class="{
|
|
'!border-brand-500 !shadow-selected': dropdownOpen,
|
|
}"
|
|
>
|
|
<GeneralWorkspaceIcon size="small" :workspace="targetWorkspace" />
|
|
|
|
<div class="flex-1 capitalize truncate">
|
|
{{ targetWorkspace?.title }}
|
|
</div>
|
|
|
|
<div class="flex gap-2 items-center">
|
|
<div v-if="activeWorkspace?.id === targetWorkspace?.id" class="text-nc-content-gray-muted leading-4.5 text-xs">
|
|
{{ $t('labels.currentWorkspace') }}
|
|
</div>
|
|
<GeneralIcon
|
|
:class="{
|
|
'transform rotate-180': dropdownOpen,
|
|
}"
|
|
class="text-nc-content-gray transition-all w-4 h-4"
|
|
icon="ncChevronDown"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<template #overlay>
|
|
<NcList
|
|
:value="targetWorkspace"
|
|
:item-height="28"
|
|
close-on-select
|
|
class="nc-base-workspace-selection"
|
|
:min-items-for-search="6"
|
|
container-class-name="w-full"
|
|
:list="workspaceOptions"
|
|
option-label-key="title"
|
|
>
|
|
<template #listHeader>
|
|
<div class="text-nc-content-gray-muted text-[13px] px-3 pt-2.5 pb-1.5 font-medium leading-5">
|
|
{{ $t('labels.duplicateBaseMessage') }}
|
|
</div>
|
|
|
|
<NcDivider />
|
|
</template>
|
|
|
|
<template #listItem="{ option }">
|
|
<div class="flex gap-2 w-full items-center" @click="selectOption(option)">
|
|
<GeneralWorkspaceIcon :workspace="option" size="small" />
|
|
|
|
<div class="flex-1 text-[13px] truncate font-semibold leading-5 capitalize w-full">
|
|
{{ option.title }}
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2">
|
|
<div v-if="activeWorkspace?.id === option.id" class="text-nc-content-gray-muted leading-4.5 text-xs">
|
|
{{ $t('labels.currentWorkspace') }}
|
|
</div>
|
|
<GeneralIcon v-if="option.id === targetWorkspace?.id" class="text-brand-500 w-4 h-4" icon="ncCheck" />
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</NcList>
|
|
</template>
|
|
</NcDropdown>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template v-else-if="status === 'success'">
|
|
<div class="text-nc-content-gray-emphasis my-5 font-medium">
|
|
Base <span class="font-bold leading-5">"{{ base.title }}"</span> has finished duplication.
|
|
</div>
|
|
</template>
|
|
|
|
<template v-else-if="status === 'error'">
|
|
<div class="text-nc-content-gray-emphasis my-5 font-medium">{{ $t('labels.errorMessage') }} {{ errorMessage }}</div>
|
|
</template>
|
|
</div>
|
|
<div class="flex flex-row gap-x-2 justify-end">
|
|
<NcButton v-if="!isLoading" key="back" type="secondary" size="small" @click="dialogShow = false">
|
|
{{ $t('general.cancel') }}
|
|
</NcButton>
|
|
<NcButton
|
|
key="submit"
|
|
v-e="['a:base:duplicate']"
|
|
size="small"
|
|
:loading="isLoading"
|
|
:disabled="isLoading"
|
|
@click="handleActionClick"
|
|
>
|
|
<template v-if="status === 'pending'"> {{ $t('general.duplicate') }} {{ $t('objects.project') }} </template>
|
|
<template v-else-if="status === 'loading'"> Duplicating {{ $t('objects.project') }} </template>
|
|
<template v-else-if="status === 'success'"> {{ $t('labels.goToBase') }} </template>
|
|
<template v-else-if="status === 'error'"> {{ $t('labels.tryAgain') }} </template>
|
|
</NcButton>
|
|
</div>
|
|
</GeneralModal>
|
|
</template>
|
|
|
|
<style scoped lang="scss">
|
|
:deep(.ant-modal-mask) {
|
|
@apply !bg-black !bg-opacity-[8%];
|
|
}
|
|
|
|
.nc-list-root {
|
|
@apply !w-[432px] !pt-0;
|
|
}
|
|
</style>
|
|
|
|
<style lang="scss">
|
|
.nc-base-workspace-selection {
|
|
.nc-list {
|
|
@apply !px-1;
|
|
.nc-list-item {
|
|
@apply !py-1;
|
|
}
|
|
}
|
|
}
|
|
</style>
|