Browse Source

Merge pull request #9351 from nocodb/nc-revamp/csv-import-extension

Nc revamp/csv import extension
pull/9421/head
Ramesh Mane 3 months ago committed by GitHub
parent
commit
673d055830
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 7
      packages/nc-gui/assets/nc-icons/file-big.svg
  2. 5
      packages/nc-gui/assets/nc-icons/info-solid.svg
  3. 3
      packages/nc-gui/assets/nc-icons/placeholder-icon.svg
  4. 8
      packages/nc-gui/assets/nc-icons/script.svg
  5. 3
      packages/nc-gui/assets/nc-icons/spanner.svg
  6. 92
      packages/nc-gui/components/extensions/Details.vue
  7. 240
      packages/nc-gui/components/extensions/Extension.vue
  8. 185
      packages/nc-gui/components/extensions/Extension/Header.vue
  9. 12
      packages/nc-gui/components/extensions/Extension/HeaderMenu.vue
  10. 40
      packages/nc-gui/components/extensions/Extension/Wrapper.vue
  11. 256
      packages/nc-gui/components/extensions/Market.vue
  12. 239
      packages/nc-gui/components/extensions/Pane.vue
  13. 18
      packages/nc-gui/components/nc/Modal.vue
  14. 4
      packages/nc-gui/components/smartsheet/Topbar.vue
  15. 22
      packages/nc-gui/components/tabs/Smartsheet.vue
  16. 4
      packages/nc-gui/components/workspace/integrations/IntegrationsTab.vue
  17. 356
      packages/nc-gui/composables/useExtensionHelper.ts
  18. 32
      packages/nc-gui/composables/useExtensions.ts
  19. BIN
      packages/nc-gui/extensions/data-exporter/assets/fullscreen-modal-screenshot.png
  20. BIN
      packages/nc-gui/extensions/data-exporter/assets/icon.png
  21. 14
      packages/nc-gui/extensions/data-exporter/assets/publisher-icon.svg
  22. BIN
      packages/nc-gui/extensions/data-exporter/assets/recent-exports-modal.png
  23. BIN
      packages/nc-gui/extensions/data-exporter/assets/recent-exports.png
  24. 24
      packages/nc-gui/extensions/data-exporter/description.md
  25. 358
      packages/nc-gui/extensions/data-exporter/index.vue
  26. 16
      packages/nc-gui/extensions/data-exporter/manifest.json
  27. 14
      packages/nc-gui/extensions/json-exporter/assets/publisher-icon.svg
  28. 15
      packages/nc-gui/extensions/json-exporter/description.md
  29. 12
      packages/nc-gui/extensions/json-exporter/index.vue
  30. 14
      packages/nc-gui/extensions/json-exporter/manifest.json
  31. 4
      packages/nc-gui/lang/en.json
  32. 2
      packages/nc-gui/store/bases.ts
  33. 18
      packages/nc-gui/utils/commonUtils.ts
  34. 11
      packages/nc-gui/utils/iconUtils.ts
  35. 5
      packages/nc-gui/windi.config.ts

7
packages/nc-gui/assets/nc-icons/file-big.svg

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none">
<path d="M18.6667 2.6665H8C7.29276 2.6665 6.61448 2.94746 6.11438 3.44755C5.61428 3.94765 5.33333 4.62593 5.33333 5.33317V26.6665C5.33333 27.3737 5.61428 28.052 6.11438 28.5521C6.61448 29.0522 7.29276 29.3332 8 29.3332H24C24.7072 29.3332 25.3855 29.0522 25.8856 28.5521C26.3857 28.052 26.6667 27.3737 26.6667 26.6665V10.6665L18.6667 2.6665Z" fill="white" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M21.3333 22.6665H10.6667" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M21.3333 17.3335H10.6667" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13.3333 12H12H10.6667" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M18.6667 2.6665V10.6665H26.6667" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

5
packages/nc-gui/assets/nc-icons/info-solid.svg

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 16V12" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 8H12.01" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 544 B

3
packages/nc-gui/assets/nc-icons/placeholder-icon.svg

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="17" height="16" viewBox="0 0 17 16" fill="none">
<path d="M13.1667 2H3.83333C3.09695 2 2.5 2.59695 2.5 3.33333V12.6667C2.5 13.403 3.09695 14 3.83333 14H13.1667C13.903 14 14.5 13.403 14.5 12.6667V3.33333C14.5 2.59695 13.903 2 13.1667 2Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 386 B

8
packages/nc-gui/assets/nc-icons/script.svg

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M2.5 14C1.67157 14 1 13.3284 1 12.5M4 11.5V3.5" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10.5 14C11.3284 14 12 13.3284 12 12.5V8.5V4.5H13.5H15V3.5M10.5 14C9.67157 14 9 13.3284 9 12.5V11.5H1V12.5M10.5 14H2.5M5.5 2H13.5" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 3.5C4 2.67157 4.67157 2 5.5 2" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15 3.5C15 2.67157 14.3284 2 13.5 2C12.6716 2 12 2.67157 12 3.5V4.5" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.00011 5.97858L6.00011 6.97858L7.00011 7.97858" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.99989 8.02142L9.99989 7.02142L8.99989 6.02142" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

3
packages/nc-gui/assets/nc-icons/spanner.svg

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="17" height="16" viewBox="0 0 17 16" fill="none">
<path d="M9.83427 4.73872C9.71212 4.86334 9.64369 5.03089 9.64369 5.20539C9.64369 5.37989 9.71212 5.54744 9.83427 5.67206L10.9009 6.73872C11.0256 6.86088 11.1931 6.9293 11.3676 6.9293C11.5421 6.9293 11.7096 6.86088 11.8343 6.73872L14.3476 4.22539C14.6828 4.96618 14.7843 5.79155 14.6386 6.59149C14.4928 7.39143 14.1067 8.12795 13.5318 8.70291C12.9568 9.27787 12.2203 9.66394 11.4204 9.8097C10.6204 9.95545 9.79506 9.85395 9.05427 9.51872L4.4476 14.1254C4.18238 14.3906 3.82267 14.5396 3.4476 14.5396C3.07253 14.5396 2.71282 14.3906 2.4476 14.1254C2.18238 13.8602 2.03339 13.5005 2.03339 13.1254C2.03339 12.7503 2.18238 12.3906 2.4476 12.1254L7.05427 7.51872C6.71904 6.77793 6.61754 5.95257 6.76329 5.15263C6.90905 4.35269 7.29512 3.61616 7.87008 3.0412C8.44504 2.46625 9.18156 2.08017 9.9815 1.93442C10.7814 1.78867 11.6068 1.89017 12.3476 2.22539L9.84093 4.73206L9.83427 4.73872Z" stroke="currentColor" stroke-width="1.33" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

92
packages/nc-gui/components/extensions/Details.vue

@ -67,8 +67,7 @@ const detailsBody = computed(() => {
v-model:visible="vModel"
:class="{ active: vModel }"
:footer="null"
:width="1154"
size="medium"
size="lg"
wrap-class-name="nc-modal-extension-details"
>
<div v-if="activeExtension" class="flex flex-col w-full h-full">
@ -85,7 +84,7 @@ const detailsBody = computed(() => {
<div class="self-start flex items-center gap-2.5">
<NcButton size="small" class="w-full" @click="onAddExtension(activeExtension)">
<div class="flex items-center justify-center gap-1 -ml-3px">
<GeneralIcon icon="plus" /> {{ $t('general.install') }}
<GeneralIcon icon="plus" /> {{ $t('general.add') }} {{ $t('general.extension') }}
</div>
</NcButton>
<NcButton size="small" type="text" @click="vModel = false">
@ -103,30 +102,64 @@ const detailsBody = computed(() => {
<div class="extension-details-right-title">Version</div>
<div class="extension-details-right-subtitle">{{ activeExtension.version }}</div>
</div>
<NcDivider />
<div class="extension-details-right-section">
<div v-if="activeExtension.publisherName" class="extension-details-right-title">Publisher</div>
<div class="extension-details-right-subtitle">{{ activeExtension.publisherName }}</div>
</div>
<template v-if="activeExtension.publisherEmail">
<NcDivider />
<div class="extension-details-right-section">
<div class="extension-details-right-title">Publisher Email</div>
<div class="extension-details-right-subtitle">
<a :href="`mailto:${activeExtension.publisherEmail}`" target="_blank" rel="noopener noreferrer">
{{ activeExtension.publisherEmail }}
<div v-if="activeExtension.publisher" class="extension-details-right-section">
<div class="extension-details-right-title">Publisher</div>
<div class="flex items-center gap-2">
<img
v-if="activeExtension.publisher?.icon?.src"
:src="getExtensionAssetsUrl(activeExtension.publisher.icon.src)"
alt="Publisher icon"
class="object-contain flex-none"
:style="{
width: activeExtension.publisher?.icon?.width ? `${activeExtension.publisher?.icon?.width}px` : '24px',
height: activeExtension.publisher?.icon?.height ? `${activeExtension.publisher?.icon?.height}px` : '24px',
}"
/>
<div class="extension-details-right-subtitle">{{ activeExtension.publisher.name }}</div>
</div>
<div class="flex items-center gap-3 text-sm font-semibold text-nc-content-brand">
<a
v-if="activeExtension.publisher?.url"
:href="activeExtension.publisher.url"
target="_blank"
rel="noopener noreferrer"
class="!no-underline !hover:underline"
>
Website
</a>
<template v-if="activeExtension.publisher?.email">
<div class="border-l-1 border-nc-border-gray-medium h-5"></div>
<a
:href="`mailto:${activeExtension.publisher.email}`"
target="_blank"
rel="noopener noreferrer"
class="!no-underline !hover:underline"
>
Contact
</a>
</div>
</template>
</div>
</template>
<template v-if="activeExtension.publisherUrl">
</div>
<template v-if="activeExtension.links && activeExtension.links.length">
<NcDivider />
<div class="extension-details-right-section">
<div class="extension-details-right-title">Publisher Website</div>
<div class="extension-details-right-subtitle">
<a :href="activeExtension.publisherUrl" target="_blank" rel="noopener noreferrer">
{{ activeExtension.publisherUrl }}
</a>
<div class="extension-details-right-title">Links</div>
<div>
<div v-for="(doc, idx) of activeExtension.links" :key="idx" class="flex items-center gap-1">
<div class="h-7 w-7 flex items-center justify-center">
<GeneralIcon icon="bookOpen" class="flex-none w-4 h-4 text-gray-600" />
</div>
<a
:href="doc.href"
target="_blank"
rel="noopener noreferrer"
class="!text-gray-700 text-sm !no-underline !hover:underline"
>
{{ doc.title }}
</a>
</div>
</div>
</div>
</template>
@ -138,7 +171,7 @@ const detailsBody = computed(() => {
<style lang="scss" scoped>
.extension-details {
@apply flex w-full h-[calc(100%_-_65px)];
@apply flex w-full h-[calc(100%_-_83px)];
.extension-details-left {
@apply p-6 flex-1 flex flex-col gap-6 nc-scrollbar-thin;
@ -148,7 +181,7 @@ const detailsBody = computed(() => {
@apply p-5 w-[320px] flex flex-col space-y-4 border-l-1 border-gray-200 bg-gray-50 nc-scrollbar-thin;
.extension-details-right-section {
@apply flex flex-col gap-2;
@apply flex flex-col gap-3;
}
.extension-details-right-title {
@ -168,18 +201,11 @@ const detailsBody = computed(() => {
}
.nc-modal {
@apply !p-0;
height: min(calc(100vh - 100px), 1024px);
max-height: min(calc(100vh - 100px), 1024px) !important;
.nc-edit-or-add-integration-left-panel {
@apply w-full p-6 flex-1 flex justify-center;
}
.nc-edit-or-add-integration-right-panel {
@apply p-5 w-[320px] border-l-1 border-gray-200 flex flex-col gap-4 bg-gray-50 rounded-br-2xl;
}
}
.nc-extension-details-body {
@apply max-w-[768px] mx-auto;
p {
@apply !m-0 !leading-5;
}

240
packages/nc-gui/components/extensions/Extension.vue

@ -6,15 +6,9 @@ interface Prop {
const { extensionId, error } = defineProps<Prop>()
const {
extensionList,
extensionsLoaded,
availableExtensions,
eventBus,
getExtensionAssetsUrl,
duplicateExtension,
showExtensionDetails,
} = useExtensions()
const { extensionList, extensionsLoaded, availableExtensions, eventBus } = useExtensions()
const isLoadedExtension = ref<boolean>(true)
const activeError = ref(error)
@ -32,63 +26,50 @@ const extension = computed(() => {
return ext
})
const titleInput = ref<HTMLInputElement | null>(null)
const titleEditMode = ref<boolean>(false)
const tempTitle = ref<string>(extension.value.title)
const enableEditMode = () => {
titleEditMode.value = true
tempTitle.value = extension.value.title
nextTick(() => {
titleInput.value?.focus()
titleInput.value?.select()
})
}
const extensionManifest = computed<ExtensionManifest | undefined>(() => {
return availableExtensions.value.find((ext) => ext.id === extension.value?.extensionId)
})
const updateExtensionTitle = async () => {
await extension.value.setTitle(tempTitle.value)
titleEditMode.value = false
}
const {
fullscreen,
fullscreenModalSize: currentExtensionModalSize,
collapsed,
} = useProvideExtensionHelper(extension, extensionManifest, activeError)
const { fullscreen, collapsed } = useProvideExtensionHelper(extension)
const { height } = useElementSize(extensionRef)
const component = ref<any>(null)
const extensionManifest = ref<ExtensionManifest | undefined>()
const extensionHeight = computed(() => {
const heigthInInt = parseInt(extensionManifest.value?.config?.contentMinHeight || '') || undefined
const fullscreenModalMaxWidth = computed(() => {
const modalMaxWidth = {
xs: 'min(calc(100vw - 32px), 448px)',
sm: 'min(calc(100vw - 32px), 640px)',
md: 'min(calc(100vw - 48px), 900px)',
lg: 'min(calc(100vw - 48px), 1280px)',
}
if (!heigthInInt || height.value > heigthInInt) return `${height.value}px`
return extensionManifest.value?.config?.modalMaxWith
? modalMaxWidth[extensionManifest.value?.config?.modalMaxWith] || modalMaxWidth.lg
: modalMaxWidth.lg
return extensionManifest.value?.config?.contentMinHeight
})
const expandExtension = () => {
if (!collapsed.value) return
const fullscreenModalSize = computed(() => {
return currentExtensionModalSize.value ? modalSizes[currentExtensionModalSize.value] || modalSizes.lg : modalSizes.lg
})
collapsed.value = false
// close fullscreen on clicking extensionModalRef directly
const closeFullscreen = (e: MouseEvent) => {
if (e.target === extensionModalRef.value) {
fullscreen.value = false
}
}
onMounted(() => {
until(extensionsLoaded)
.toMatch((v) => v)
.then(() => {
extensionManifest.value = availableExtensions.value.find((ext) => ext.id === extension.value.extensionId)
if (!extensionManifest.value) {
return
}
import(`../../extensions/${extensionManifest.value.entry}/index.vue`).then((mod) => {
component.value = markRaw(mod.default)
isLoadedExtension.value = false
})
})
.catch((err) => {
@ -97,9 +78,12 @@ onMounted(() => {
return
}
activeError.value = err
isLoadedExtension.value = false
})
})
// #Listeners
// close fullscreen on escape key press
useEventListener('keydown', (e) => {
// Check if the event target or its closest parent is an input, select, or textarea
@ -111,23 +95,6 @@ useEventListener('keydown', (e) => {
}
})
// close fullscreen on clicking extensionModalRef directly
const closeFullscreen = (e: MouseEvent) => {
if (e.target === extensionModalRef.value) {
fullscreen.value = false
}
}
const handleDuplicateExtension = async (id: string, open: boolean = false) => {
const duplicatedExt = await duplicateExtension(id)
if (duplicatedExt?.id && open) {
fullscreen.value = false
eventBus.emit(ExtensionsEvents.DUPLICATE, duplicatedExt.id)
}
}
// #Listeners
eventBus.on((event, payload) => {
if (event === ExtensionsEvents.DUPLICATE && extension.value.id === payload) {
setTimeout(() => {
@ -140,7 +107,7 @@ eventBus.on((event, payload) => {
</script>
<template>
<div ref="extensionRef" class="w-full px-4" :data-testid="extension.id">
<div ref="extensionRef" class="w-full px-4" :class="`nc-${extensionManifest?.id}`" :data-testid="extension.id">
<div
class="extension-wrapper"
:class="[
@ -153,7 +120,7 @@ eventBus.on((event, payload) => {
:style="
!collapsed
? {
height: extensionManifest?.config?.contentMinHeight,
height: extensionHeight,
minHeight: extensionManifest?.config?.contentMinHeight,
}
: {}
@ -161,67 +128,7 @@ eventBus.on((event, payload) => {
@mousedown="isMouseDown = true"
@mouseup="isMouseDown = false"
>
<div
class="extension-header px-3 py-2"
:class="{
'border-b-1 border-gray-200 h-[49px]': !collapsed,
'collapsed border-transparent h-[48px]': collapsed,
}"
@click="expandExtension"
>
<div class="extension-header-left max-w-[calc(100%_-_100px)]">
<!-- Todo: enable later when we support extension reordering -->
<!-- eslint-disable vue/no-constant-condition -->
<NcButton size="xs" type="text" class="nc-extension-drag-handler !px-1" @click.stop>
<GeneralIcon icon="ncDrag" class="flex-none text-gray-500" />
</NcButton>
<img
v-if="extensionManifest"
:src="getExtensionAssetsUrl(extensionManifest.iconUrl)"
alt="icon"
class="h-8 w-8 object-contain"
/>
<a-input
v-if="titleEditMode && !fullscreen"
ref="titleInput"
v-model:value="tempTitle"
type="text"
class="flex-grow !h-8 !px-1 !py-1 !-ml-1 !rounded-lg w-4/5 extension-title"
@click.stop
@keyup.enter="updateExtensionTitle"
@keyup.esc="updateExtensionTitle"
@blur="updateExtensionTitle"
>
</a-input>
<NcTooltip v-else show-on-truncate-only class="truncate">
<template #title>
{{ extension.title }}
</template>
<span class="extension-title cursor-pointer" @dblclick.stop="enableEditMode" @click.stop>
{{ extension.title }}
</span>
</NcTooltip>
</div>
<div class="extension-header-right" @click.stop>
<ExtensionsExtensionMenu
:active-error="activeError"
class="nc-extension-menu"
@rename="enableEditMode"
@duplicate="handleDuplicateExtension(extension.id, true)"
@show-details="showExtensionDetails(extension.extensionId, 'extension')"
@clear-data="extension.clear()"
@delete="extension.delete()"
/>
<NcButton v-if="!activeError" type="text" size="xs" class="nc-extension-expand-btn !px-1" @click="fullscreen = true">
<GeneralIcon icon="ncMaximize2" class="h-3.5 w-3.5" />
</NcButton>
<NcButton size="xs" type="text" class="!px-1" @click="collapsed = !collapsed">
<GeneralIcon :icon="collapsed ? 'arrowDown' : 'arrowUp'" class="flex-none" />
</NcButton>
</div>
</div>
<ExtensionsExtensionHeader :is-fullscreen="false" />
<template v-if="activeError">
<div
@ -262,60 +169,16 @@ eventBus.on((event, payload) => {
:style="
fullscreen
? {
maxWidth: fullscreenModalMaxWidth,
maxWidth: fullscreenModalSize.width,
maxHeight: fullscreenModalSize.height,
}
: {}
"
>
<div v-if="fullscreen" class="flex items-center justify-between cursor-default">
<div class="flex-1 max-w-[calc(100%_-_96px)] flex items-center gap-2 text-gray-800 font-semibold">
<img
v-if="extensionManifest"
:src="getExtensionAssetsUrl(extensionManifest.iconUrl)"
alt="icon"
class="flex-none w-8 h-8"
/>
<a-input
v-if="titleEditMode"
ref="titleInput"
v-model:value="tempTitle"
type="text"
class="flex-grow !h-8 !px-1 !py-1 !-ml-1 !rounded-lg !text-lg font-semibold extension-title max-w-[420px]"
@click.stop
@keyup.enter.stop="updateExtensionTitle"
@keyup.esc.stop="updateExtensionTitle"
@blur="updateExtensionTitle"
>
</a-input>
<NcTooltip v-else show-on-truncate-only class="extension-title truncate text-lg">
<template #title>
{{ extension.title }}
</template>
<span class="cursor-pointer" @dblclick="enableEditMode">
{{ extension.title }}
</span>
</NcTooltip>
</div>
<div class="flex items-center gap-4">
<ExtensionsExtensionMenu
:active-error="activeError"
:fullscreen="fullscreen"
@rename="enableEditMode"
@duplicate="handleDuplicateExtension(extension.id, true)"
@show-details="showExtensionDetails(extension.extensionId, 'extension')"
@clear-data="extension.clear()"
@delete="extension.delete()"
/>
<NcButton size="small" type="text" class="flex-none" @click="fullscreen = false">
<GeneralIcon icon="close" />
</NcButton>
</div>
</div>
<div
v-show="fullscreen || !collapsed"
class="extension-content"
:class="{ 'fullscreen h-[calc(100%-40px)]': fullscreen, 'h-full': !fullscreen }"
class="extension-content h-full"
:class="{ 'fullscreen': fullscreen, 'h-full nc-scrollbar-thin': !fullscreen }"
>
<component :is="component" :key="extension.uiKey" class="h-full" />
</div>
@ -323,6 +186,12 @@ eventBus.on((event, payload) => {
</div>
</Teleport>
</template>
<general-overlay :model-value="isLoadedExtension" inline transition class="!bg-opacity-15 rounded-xl overflow-hidden">
<div class="flex flex-col items-center justify-center h-full w-full !bg-white !bg-opacity-80">
<a-spin size="large" />
</div>
</general-overlay>
</div>
</div>
</template>
@ -341,29 +210,6 @@ eventBus.on((event, payload) => {
}
}
.extension-header {
@apply flex justify-between;
&.collapsed:not(:hover) {
.nc-extension-expand-btn,
.nc-extension-menu {
@apply hidden;
}
}
.extension-header-left {
@apply flex-1 flex items-center gap-2;
}
.extension-header-right {
@apply flex items-center gap-1;
}
.extension-title {
@apply font-weight-600;
}
}
.extension-content {
@apply rounded-lg;
@ -373,10 +219,10 @@ eventBus.on((event, payload) => {
}
.extension-modal {
@apply absolute top-0 left-0 z-1000 w-full h-full bg-black bg-opacity-50;
@apply absolute top-0 left-0 z-1000 w-full h-full bg-black bg-opacity-50 flex items-center justify-center;
.extension-modal-content {
@apply bg-white rounded-2xl w-[90%] h-[90vh] mt-[5vh] mx-auto p-6 flex flex-col gap-3;
@apply bg-white rounded-2xl w-[90%] h-[90vh] mx-auto flex flex-col;
}
}

185
packages/nc-gui/components/extensions/Extension/Header.vue

@ -0,0 +1,185 @@
<script lang="ts" setup>
/**
* ExtensionHeader component.
*
* @slot prefix - Slot for custom content to be displayed at the start of the header when in fullscreen mode.
* @slot extra - Slot for additional custom content to be displayed before the options and close button in fullscreen mode.
*/
interface Props {
isFullscreen?: boolean
}
withDefaults(defineProps<Props>(), {
isFullscreen: true,
})
const { eventBus, getExtensionAssetsUrl, duplicateExtension, showExtensionDetails } = useExtensions()
const { fullscreen, collapsed, extension, extensionManifest, activeError, showExpandBtn } = useExtensionHelperOrThrow()
const titleInput = ref<HTMLInputElement | null>(null)
const titleEditMode = ref<boolean>(false)
const tempTitle = ref<string>(extension.value.title)
const showExpandButton = computed(() => {
return showExpandBtn.value && !activeError.value
})
const enableEditMode = () => {
titleEditMode.value = true
tempTitle.value = extension.value.title
nextTick(() => {
titleInput.value?.focus()
titleInput.value?.select()
})
}
const updateExtensionTitle = async () => {
await extension.value.setTitle(tempTitle.value)
titleEditMode.value = false
}
const expandExtension = () => {
if (!collapsed.value) return
collapsed.value = false
}
/**
* Handles the duplication of an extension.
*
* @param id - The ID of the extension to duplicate.
* @param open - Optional. If true, the duplicated extension will be opened.
*/
const handleDuplicateExtension = async (id: string, open: boolean = false) => {
const duplicatedExt = await duplicateExtension(id)
if (duplicatedExt?.id && open) {
fullscreen.value = false
eventBus.emit(ExtensionsEvents.DUPLICATE, duplicatedExt.id)
}
}
</script>
<template>
<div
v-if="(isFullscreen && fullscreen) || !isFullscreen"
class="extension-header flex items-center"
:class="{
'border-b-1 border-nc-border-gray-medium h-[49px]': !collapsed && !isFullscreen,
'collapsed border-transparent h-[48px]': collapsed && !isFullscreen,
'px-3 py-2 gap-1': !isFullscreen,
'gap-3 px-4 pt-4 pb-[15px] border-b-1 border-nc-border-gray-medium': isFullscreen,
}"
@click="expandExtension"
>
<slot v-if="isFullscreen" name="prefix"></slot>
<NcButton v-if="!isFullscreen" size="xs" type="text" class="nc-extension-drag-handler !px-1" @click.stop>
<GeneralIcon icon="ncDrag" class="flex-none text-gray-500" />
</NcButton>
<img
v-if="extensionManifest"
:src="getExtensionAssetsUrl(extensionManifest.iconUrl)"
alt="icon"
class="h-8 w-8 object-contain flex-none"
:class="{
'mx-1': !isFullscreen,
}"
/>
<div
v-if="titleEditMode"
class="flex-1"
:class="{
'mr-1': !isFullscreen,
}"
>
<a-input
ref="titleInput"
v-model:value="tempTitle"
type="text"
class="!h-8 !px-1 !py-1 !-ml-1 !rounded-lg extension-title"
:class="{
'w-4/5': !isFullscreen,
'!text-lg !font-semibold max-w-[420px]': isFullscreen,
}"
@click.stop
@keyup.enter="updateExtensionTitle"
@keyup.esc="updateExtensionTitle"
@blur="updateExtensionTitle"
>
</a-input>
</div>
<NcTooltip v-else show-on-truncate-only class="truncate flex-1">
<template #title>
{{ extension.title }}
</template>
<span
class="extension-title cursor-pointer"
:class="{
'text-lg font-semibold ': isFullscreen,
'mr-1': !isFullscreen,
}"
@dblclick.stop="enableEditMode"
@click.stop
>
{{ extension.title }}
</span>
</NcTooltip>
<slot v-if="isFullscreen" name="extra"></slot>
<ExtensionsExtensionHeaderMenu
:is-fullscreen="isFullscreen"
class="nc-extension-menu"
@rename="enableEditMode"
@duplicate="handleDuplicateExtension(extension.id, true)"
@show-details="showExtensionDetails(extension.extensionId, 'extension')"
@clear-data="extension.clear()"
@delete="extension.delete()"
/>
<template v-if="!isFullscreen">
<NcButton
v-if="showExpandButton"
size="xs"
type="text"
class="nc-extension-expand-btn !px-1"
@click.stop="fullscreen = true"
>
<GeneralIcon icon="ncMaximize2" class="h-3.5 w-3.5" />
</NcButton>
<NcButton size="xs" type="text" class="!px-1" @click.stop="collapsed = !collapsed">
<GeneralIcon :icon="collapsed ? 'arrowDown' : 'arrowUp'" class="flex-none" />
</NcButton>
</template>
<NcButton v-else :size="isFullscreen ? 'small' : 'xs'" type="text" class="flex-none !px-1" @click="fullscreen = false">
<GeneralIcon icon="close" />
</NcButton>
</div>
</template>
<style lang="scss" scoped>
.extension-header {
&.collapsed:not(:hover) {
.nc-extension-expand-btn,
.nc-extension-menu {
@apply hidden;
}
}
.extension-header-left {
@apply flex-1 flex items-center gap-2;
}
.extension-header-right {
@apply flex items-center gap-1;
}
.extension-title {
@apply font-weight-600;
}
}
</style>

12
packages/nc-gui/components/extensions/ExtensionMenu.vue → packages/nc-gui/components/extensions/Extension/HeaderMenu.vue

@ -1,17 +1,19 @@
<script setup lang="ts">
interface Props {
fullscreen?: boolean
activeError?: boolean
isFullscreen?: boolean
}
const { fullscreen, activeError } = defineProps<Props>()
defineProps<Props>()
const emits = defineEmits(['rename', 'duplicate', 'showDetails', 'clearData', 'delete'])
const { activeError } = useExtensionHelperOrThrow()
</script>
<template>
<div class="flex items-center">
<div class="flex items-center" @click.stop>
<NcDropdown :trigger="['click']" placement="bottomRight">
<NcButton type="text" :size="fullscreen ? 'small' : 'xs'" class="!px-1">
<NcButton type="text" :size="isFullscreen ? 'small' : 'xs'" class="!px-1">
<GeneralIcon icon="threeDotVertical" />
</NcButton>

40
packages/nc-gui/components/extensions/Extension/Wrapper.vue

@ -0,0 +1,40 @@
<script lang="ts" setup>
/**
* ExtensionHeaderWrapper component.
*
* @slot headerPrefix - Slot for custom content to be displayed at the start of the header when in fullscreen mode.
* @slot headerExtra - Slot for additional custom content to be displayed in the header when in fullscreen mode.
*/
const { fullscreen } = useExtensionHelperOrThrow()
const headerRef = ref<HTMLDivElement>()
const { height } = useElementSize(headerRef)
</script>
<template>
<div class="h-full">
<div ref="headerRef" class="extension-header-wrapper">
<ExtensionsExtensionHeader>
<template #prefix>
<slot name="headerPrefix"></slot>
</template>
<template #extra>
<slot name="headerExtra"></slot>
</template>
</ExtensionsExtensionHeader>
</div>
<div
class="extension-content-container"
:class="{
'fullscreen p-6 nc-scrollbar-thin': fullscreen,
'h-full': !fullscreen,
}"
:style="fullscreen ? { height: height ? `calc(100% - ${height}px)` : 'calc(100% - 64px)' } : {}"
>
<slot />
</div>
</div>
</template>
<style lang="scss" scoped></style>

256
packages/nc-gui/components/extensions/Market.vue

@ -1,23 +1,60 @@
<script lang="ts" setup>
import type { VNodeRef } from '@vue/runtime-core'
interface Prop {
modelValue?: boolean
}
type TabKeysType = 'extensions' | 'scripts' | 'build-an-extension'
interface TabItem {
title: string
tabKey: TabKeysType
icon: keyof typeof iconMap
isDisabled?: boolean
}
const props = defineProps<Prop>()
const emit = defineEmits(['update:modelValue'])
const tabs = [
{
title: 'Extensions',
tabKey: 'extensions',
icon: 'ncPuzzleOutline',
},
{
title: 'Scripts',
tabKey: 'scripts',
icon: 'ncScript',
isDisabled: true,
},
{
title: 'Build an extension',
tabKey: 'build-an-extension',
icon: 'ncSpanner',
isDisabled: true,
},
] as TabItem[]
const vModel = useVModel(props, 'modelValue', emit)
const { availableExtensions, addExtension, getExtensionAssetsUrl, showExtensionDetails } = useExtensions()
const searchRef: VNodeRef = (el) => {
return el && (el as HTMLInputElement)?.focus()
}
const searchQuery = ref<string>('')
const activeTab = ref<TabKeysType>('extensions')
const filteredAvailableExtensions = computed(() =>
(availableExtensions.value || []).filter(
(ext) =>
ext.title.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
ext.subTitle.toLowerCase().includes(searchQuery.value.toLowerCase()),
ext.title.toLowerCase().includes(searchQuery.value.toLowerCase()?.trim()) ||
ext.subTitle.toLowerCase().includes(searchQuery.value.toLowerCase()?.trim()),
),
)
@ -30,6 +67,13 @@ const onAddExtension = (ext: any) => {
addExtension(ext)
vModel.value = false
}
const handleSetActiveTab = (tab: TabItem) => {
if (tab.isDisabled) return
searchQuery.value = ''
activeTab.value = tab.tabKey
}
</script>
<template>
@ -37,27 +81,56 @@ const onAddExtension = (ext: any) => {
v-model:visible="vModel"
:class="{ active: vModel }"
:footer="null"
:width="1154"
size="medium"
size="lg"
wrap-class-name="nc-modal-extension-market"
>
<div class="h-full">
<div class="flex items-center gap-3 p-4 border-b-1 border-gray-200">
<GeneralIcon icon="ncPuzzleSolid" class="h-6 w-6 flex-none text-gray-700" />
<div class="flex-1 font-semibold text-xl">Extensions Marketplace</div>
<NcButton size="small" type="text" @click="vModel = false">
<GeneralIcon icon="close" class="text-gray-600" />
</NcButton>
</div>
<div class="flex flex-col h-[calc(100%_-_65px)] px-6 py-4">
<div class="h-full flex flex-col gap-6 flex-1 pt-2">
<div class="flex flex max-w-[470px]">
<div class="nc-extension-market-header flex items-center gap-3 p-4 border-b-1 border-gray-200">
<div
class="flex items-center gap-3 flex-none"
:style="{
width: 'calc(\(100% - 358px - 24px\) / 2)',
}"
>
<GeneralIcon icon="ncPuzzleSolid" class="h-6 w-6 flex-none text-gray-700" />
<div class="flex-1 font-semibold text-xl">Marketplace</div>
</div>
<div class="flex bg-nc-bg-gray-medium rounded-lg p-1">
<div class="flex items-center">
<NcTooltip
v-for="(tab, idx) of tabs"
:key="idx"
:disabled="tab.tabKey === 'extensions'"
class="nc-extension-market-header-tab-item"
:class="{
'selected ': activeTab === tab.tabKey,
}"
>
<template #title> {{ $t('msg.toast.futureRelease') }}</template>
<div
class="px-3 py-1 flex items-center gap-2 text-xs rounded-md select-none"
:class="{
'bg-white text-nc-content-gray-emphasis': activeTab === tab.tabKey,
'text-nc-content-gray-subtle2': activeTab !== tab.tabKey,
'cursor-not-allowed opacity-60': tab.isDisabled,
'cursor-pointer': !tab.isDisabled,
}"
@click="handleSetActiveTab(tab)"
>
<GeneralIcon :icon="tab.icon" class="h-4 w-4 flex-none !stroke-transparent opacity-75" />
{{ tab.title }}
</div>
</NcTooltip>
</div>
</div>
<div class="flex-1 flex gap-3 justify-end">
<div class="flex-1 flex max-w-[290px]">
<a-input
:ref="searchRef"
v-model:value="searchQuery"
type="text"
class="nc-input-border-on-value !h-8 !px-3 !py-1 !rounded-lg"
placeholder="Search for an extension..."
placeholder="Search for an extension or script"
allow-clear
>
<template #prefix>
@ -65,50 +138,69 @@ const onAddExtension = (ext: any) => {
</template>
</a-input>
</div>
<div
class="max-h-[calc(100%_-_40px)] flex flex-wrap gap-3 nc-scrollbar-thin pb-2"
:class="{
'h-full': searchQuery && !filteredAvailableExtensions.length && availableExtensions.length,
}"
>
<template v-for="ext of filteredAvailableExtensions" :key="ext.id">
<NcButton size="small" type="text" @click="vModel = false">
<GeneralIcon icon="close" class="text-gray-600" />
</NcButton>
</div>
</div>
<div class="flex flex-col h-[calc(100%_-_65px)]">
<div v-if="activeTab === 'extensions'" class="h-full py-4">
<div class="h-full flex flex-col gap-6 flex-1 pt-2 px-6 max-w-[900px] w-full mx-auto">
<div v-if="searchQuery" class="text-base text-nc-content-gray-subtle">Search result for {{ searchQuery }}</div>
<div
class="flex flex-wrap gap-6 overflow-auto nc-scrollbar-thin pb-2"
:class="{
'h-full': searchQuery && !filteredAvailableExtensions.length && availableExtensions.length,
}"
>
<template v-for="ext of filteredAvailableExtensions" :key="ext.id">
<div
class="nc-market-extension-item w-full md:w-[calc(50%_-_12px)] flex items-center gap-3 border-1 rounded-xl p-3 cursor-pointer hover:bg-gray-50 transition-all"
@click="onExtensionClick(ext.id)"
>
<div class="h-[60px] w-[60px] overflow-hidden m-auto flex-none">
<img :src="getExtensionAssetsUrl(ext.iconUrl)" alt="icon" class="w-full h-full object-contain" />
</div>
<div class="flex-1 flex flex-grow flex-col gap-1">
<div class="font-weight-600 text-base line-clamp-1">
{{ ext.title }}
</div>
<div class="max-h-[32px] text-xs text-gray-500 line-clamp-2">{{ ext.subTitle }}</div>
</div>
<NcButton size="small" type="secondary" class="flex-none !px-7px" @click.stop="onAddExtension(ext)">
<div class="flex items-center gap-1 -ml-3px text-small">
<GeneralIcon icon="plus" />
{{ $t('general.add') }}
</div>
</NcButton>
</div>
</template>
<div
class="nc-market-extension-item flex border-1 rounded-xl p-3 w-[360px] cursor-pointer hover:bg-gray-50 transition-all"
@click="onExtensionClick(ext.id)"
v-if="searchQuery && !filteredAvailableExtensions.length && availableExtensions.length"
class="w-full h-full flex items-center justify-center"
>
<div class="h-[60px] w-[60px] overflow-hidden m-auto">
<img :src="getExtensionAssetsUrl(ext.iconUrl)" alt="icon" class="w-full h-full object-contain" />
</div>
<div class="flex flex-grow flex-col gap-1 ml-3">
<div class="flex justify-between gap-1">
<div class="font-weight-600 text-base">{{ ext.title }}</div>
<NcButton size="xsmall" type="secondary" class="!px-7px" @click.stop="onAddExtension(ext)">
<div class="flex items-center gap-1 -ml-3px text-small">
<GeneralIcon icon="plus" />
{{ $t('general.install') }}
</div>
</NcButton>
</div>
<div class="w-[250px] h-[32px] text-xs text-gray-500 line-clamp-2">{{ ext.subTitle }}</div>
<div class="pb-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"
/>
{{ $t('title.noResultsMatchedYourSearch') }}
</div>
</div>
</template>
<div
v-if="searchQuery && !filteredAvailableExtensions.length && availableExtensions.length"
class="w-full h-full flex items-center justify-center"
>
<div class="pb-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"
/>
{{ $t('title.noResultsMatchedYourSearch') }}
</div>
</div>
</div>
</div>
<template v-else-if="activeTab === 'scripts'">
<!-- Coming soon -->
</template>
<template v-else-if="activeTab === 'build-an-extension'">
<!-- Coming soon -->
</template>
</div>
</div>
</NcModal>
@ -120,20 +212,64 @@ const onAddExtension = (ext: any) => {
box-shadow: 0px 4px 8px -2px rgba(0, 0, 0, 0.08), 0px 2px 4px -2px rgba(0, 0, 0, 0.04);
}
}
.tab {
@apply flex flex-row items-center gap-x-2;
}
:deep(.ant-tabs-nav) {
@apply !pl-0;
}
:deep(.ant-tabs-tab) {
@apply pt-2 pb-3;
}
:deep(.ant-tabs-content) {
@apply nc-content-max-w;
}
:deep(.ant-tabs-content-top) {
@apply !h-full;
}
.tab-info {
@apply flex pl-1.25 px-1.5 py-0.75 rounded-md text-xs;
}
.tab-title {
@apply flex flex-row items-center gap-x-2 py-[1px];
}
</style>
<style lang="scss">
.nc-modal-extension-market {
.nc-modal {
@apply !p-0;
height: min(calc(100vh - 100px), 1024px);
max-height: min(calc(100vh - 100px), 1024px) !important;
}
.nc-edit-or-add-integration-left-panel {
@apply w-full p-6 flex-1 flex justify-center;
}
.nc-edit-or-add-integration-right-panel {
@apply p-5 w-[320px] border-l-1 border-gray-200 flex flex-col gap-4 bg-gray-50 rounded-br-2xl;
.nc-extension-market-header {
.nc-extension-market-header-tab-item {
@apply relative;
// Add vertical line to all items except the last one
&:not(:last-child)::after {
@apply absolute right-0 top-[4px] h-[16px] w-[1px] bg-nc-bg-gray-dark; // Use WindiCSS utilities for line
content: '';
transform: scaleY(0); // Hide by default
transition: transform 0.18s;
}
// Handle lines visibility based on selection
&.selected {
box-shadow: 0px 3px 1px -2px rgba(0, 0, 0, 0.06), 0px 5px 3px -2px rgba(0, 0, 0, 0.02);
}
// Ensure lines are shown between non-selected items
&:not(.selected)::after {
transform: scaleY(1);
}
// If supported, this will hide the line to the right of the selected item
&:has(+ .selected)::after {
transform: scaleY(0); // Hide the line on the right of the selected item
}
}
}
}

239
packages/nc-gui/components/extensions/Pane.vue

@ -27,6 +27,13 @@ const searchQuery = ref<string>('')
const showSearchBox = ref(false)
const panelSize = computed(() => {
if (isPanelExpanded.value) {
return extensionPanelSize.value
}
return 0
})
const { width } = useElementSize(extensionHeaderRef)
const isOpenSearchBox = computed(() => {
@ -54,14 +61,6 @@ const toggleMarket = () => {
isMarketVisible.value = !isMarketVisible.value
}
const normalizePaneMaxWidth = computed(() => {
if (isReady.value) {
return 60
} else {
return extensionPanelSize.value
}
})
const onMove = async (_event: { moved: { newIndex: number; oldIndex: number; element: ExtensionType } }) => {
let {
moved: { newIndex = 0, oldIndex = 0, element },
@ -131,124 +130,130 @@ onMounted(() => {
<template>
<Pane
v-if="isPanelExpanded"
:size="extensionPanelSize"
min-size="10%"
v-show="isPanelExpanded || isReady"
:size="panelSize"
max-size="60%"
class="nc-extension-pane"
:style="{
minWidth: isReady ? '300px' : `${normalizePaneMaxWidth}%`,
maxWidth: `${normalizePaneMaxWidth}%`,
}"
:style="
!isReady
? {
maxWidth: `${extensionPanelSize}%`,
}
: {}
"
>
<div
ref="extensionHeaderRef"
class="h-[var(--toolbar-height)] flex items-center gap-3 px-4 py-2 border-b-1 border-gray-200 bg-white"
>
<div
class="flex items-center gap-3 font-weight-700 text-gray-700 text-base"
:class="{
'flex-1': !isOpenSearchBox,
}"
>
<GeneralIcon icon="ncPuzzleSolid" class="h-5 w-5 text-gray-700 opacity-85" />
<span v-if="!isOpenSearchBox || width >= 507">Extensions</span>
</div>
<div
class="flex justify-end"
:class="{
'flex-1': isOpenSearchBox,
}"
>
<NcButton v-if="!isOpenSearchBox" size="xs" type="text" class="!px-1" @click="handleShowSearchInput">
<GeneralIcon icon="search" class="flex-none !text-gray-500" />
</NcButton>
<div v-else class="flex flex-grow items-center justify-end !max-w-[300px]">
<a-input
ref="searchExtensionRef"
v-model:value="searchQuery"
type="text"
class="nc-input-border-on-value !h-7 !px-3 !py-1 !rounded-lg"
placeholder="Search Extension"
allow-clear
@keydown.esc="handleCloseSearchbox"
<Transition name="layout" :duration="150">
<div v-if="isPanelExpanded" class="flex flex-col h-full">
<div
ref="extensionHeaderRef"
class="h-[var(--toolbar-height)] flex items-center gap-3 px-4 py-2 border-b-1 border-gray-200 bg-white"
>
<div
class="flex items-center gap-3 font-weight-700 text-gray-700 text-base"
:class="{
'flex-1': !isOpenSearchBox,
}"
>
<template #prefix>
<GeneralIcon icon="search" class="mr-2 h-4 w-4 text-gray-500 group-hover:text-black" />
</template>
</a-input>
</div>
</div>
<NcButton type="secondary" size="xs" @click="toggleMarket">
<div class="flex items-center gap-1 text-xs max-w-full -ml-3px">
<GeneralIcon icon="plus" />
{{ $t('general.install') }}
</div>
</NcButton>
</div>
<template v-if="extensionList.length === 0">
<div class="flex items-center flex-col gap-4 w-full nc-scrollbar-md text-center p-4">
<GeneralIcon icon="ncPuzzleSolid" class="h-12 w-12 flex-none mt-[120px] text-gray-500 !stroke-transparent" />
<div class="font-weight-700 text-base">No extensions added</div>
<div class="text-sm text-gray-700">Add Extensions from the community extensions marketplace</div>
<NcButton size="small" @click="toggleMarket">
<div class="flex items-center gap-1 -ml-3px">
<GeneralIcon icon="plus" />
{{ $t('general.install') }}
</div>
</NcButton>
<!-- Todo: add docs link -->
<NcButton size="small" type="secondary">
<div class="flex items-center gap-1.5">
<GeneralIcon icon="externalLink" />
{{ $t('activity.goToDocs') }}
<GeneralIcon icon="ncPuzzleSolid" class="h-5 w-5 text-gray-700 opacity-85" />
<span v-if="!isOpenSearchBox || width >= 507">{{ $t('general.extensions') }}</span>
</div>
</NcButton>
</div>
</template>
<template v-else>
<Draggable
:model-value="filteredExtensionList"
draggable=".nc-extension-item"
item-key="id"
handle=".nc-extension-drag-handler"
ghost-class="ghost"
class="nc-extension-list-wrapper flex items-center flex-col gap-3 w-full nc-scrollbar-md py-4"
:class="{
'h-full': searchQuery && !filteredExtensionList.length && extensionList.length,
}"
@start="(e) => e.target.classList.add('grabbing')"
@end="(e) => e.target.classList.remove('grabbing')"
@change="onMove($event)"
>
<template #item="{ element: ext }">
<div class="nc-extension-item w-full">
<ExtensionsWrapper :extension-id="ext.id" />
<div
class="flex justify-end"
:class="{
'flex-1': isOpenSearchBox,
}"
>
<NcButton v-if="!isOpenSearchBox" size="xs" type="text" class="!px-1" @click="handleShowSearchInput">
<GeneralIcon icon="search" class="flex-none !text-gray-500" />
</NcButton>
<div v-else class="flex flex-grow items-center justify-end !max-w-[300px]">
<a-input
ref="searchExtensionRef"
v-model:value="searchQuery"
type="text"
class="nc-input-border-on-value !h-7 !px-3 !py-1 !rounded-lg"
placeholder="Search Extension"
allow-clear
@keydown.esc="handleCloseSearchbox"
>
<template #prefix>
<GeneralIcon icon="search" class="mr-2 h-4 w-4 text-gray-500 group-hover:text-black" />
</template>
</a-input>
</div>
</div>
</template>
<template v-if="searchQuery && !filteredExtensionList.length && extensionList.length" #header>
<div class="w-full h-full flex-1 flex items-center justify-center">
<div class="pb-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"
/>
{{ $t('title.noResultsMatchedYourSearch') }}
<NcButton type="secondary" size="xs" @click="toggleMarket">
<div class="flex items-center gap-1 text-xs max-w-full -ml-3px">
<GeneralIcon icon="plus" />
{{ $t('general.add') }}
</div>
</NcButton>
</div>
<template v-if="extensionList.length === 0">
<div class="flex items-center flex-col gap-4 w-full nc-scrollbar-md text-center p-4">
<GeneralIcon icon="ncPuzzleSolid" class="h-12 w-12 flex-none mt-[120px] text-gray-500 !stroke-transparent" />
<div class="font-weight-700 text-base">No extensions added</div>
<div class="text-sm text-gray-700">Add Extensions from the community extensions marketplace</div>
<NcButton size="small" @click="toggleMarket">
<div class="flex items-center gap-1 -ml-3px">
<GeneralIcon icon="plus" />
{{ $t('general.add') }} {{ $t('general.extension') }}
</div>
</NcButton>
<!-- Todo: add docs link -->
<NcButton size="small" type="secondary">
<div class="flex items-center gap-1.5">
<GeneralIcon icon="externalLink" />
{{ $t('activity.goToDocs') }}
</div>
</NcButton>
</div>
</template>
</Draggable>
</template>
<ExtensionsMarket v-if="isMarketVisible" v-model="isMarketVisible" />
<ExtensionsDetails
v-if="isDetailsVisible && detailsExtensionId"
v-model="isDetailsVisible"
:extension-id="detailsExtensionId"
:from="detailsFrom"
/>
<template v-else>
<Draggable
:model-value="filteredExtensionList"
draggable=".nc-extension-item"
item-key="id"
handle=".nc-extension-drag-handler"
ghost-class="ghost"
class="nc-extension-list-wrapper flex items-center flex-col gap-3 w-full nc-scrollbar-md py-4"
:class="{
'h-full': searchQuery && !filteredExtensionList.length && extensionList.length,
}"
@start="(e) => e.target.classList.add('grabbing')"
@end="(e) => e.target.classList.remove('grabbing')"
@change="onMove($event)"
>
<template #item="{ element: ext }">
<div class="nc-extension-item w-full">
<ExtensionsWrapper :extension-id="ext.id" />
</div>
</template>
<template v-if="searchQuery && !filteredExtensionList.length && extensionList.length" #header>
<div class="w-full h-full flex-1 flex items-center justify-center">
<div class="pb-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"
/>
{{ $t('title.noResultsMatchedYourSearch') }}
</div>
</div>
</template>
</Draggable>
</template>
<ExtensionsMarket v-if="isMarketVisible" v-model="isMarketVisible" />
<ExtensionsDetails
v-if="isDetailsVisible && detailsExtensionId"
v-model="isDetailsVisible"
:extension-id="detailsExtensionId"
:from="detailsFrom"
/>
</div>
</Transition>
</Pane>
</template>

18
packages/nc-gui/components/nc/Modal.vue

@ -3,7 +3,8 @@ const props = withDefaults(
defineProps<{
visible: boolean
width?: string | number
size?: 'small' | 'medium' | 'large'
height?: string | number
size?: 'small' | 'medium' | 'large' | keyof typeof modalSizes
destroyOnClose?: boolean
maskClosable?: boolean
showSeparator?: boolean
@ -22,7 +23,7 @@ const props = withDefaults(
const emits = defineEmits(['update:visible'])
const { width: propWidth, destroyOnClose, wrapClassName: _wrapClassName, showSeparator } = props
const { width: propWidth, height: propHeight, destroyOnClose, wrapClassName: _wrapClassName, showSeparator } = props
const { maskClosable } = toRefs(props)
@ -49,6 +50,10 @@ const width = computed(() => {
return '80rem'
}
if (modalSizes[props.size]) {
return modalSizes[props.size].width
}
return 'max(30vw, 600px)'
})
@ -57,6 +62,10 @@ const height = computed(() => {
return '95vh'
}
if (propHeight) {
return propHeight
}
if (props.size === 'small') {
return 'auto'
}
@ -69,6 +78,10 @@ const height = computed(() => {
return '80vh'
}
if (modalSizes[props.size]) {
return modalSizes[props.size].height
}
return 'auto'
})
@ -102,6 +115,7 @@ const slots = useSlots()
class="flex flex-col nc-modal p-6 h-full"
:style="{
maxHeight: height,
...(modalSizes[size] ? { height } : {}),
}"
>
<div

4
packages/nc-gui/components/smartsheet/Topbar.vue

@ -50,7 +50,7 @@ const topbarBreadcrumbItemWidth = computed(() => {
<SmartsheetTopbarSelectMode />
</div>
<div class="flex items-center justify-end gap-3 flex-1">
<div class="flex items-center justify-end gap-2 flex-1">
<GeneralApiLoader v-if="!isMobileMode" />
<NcButton
@ -73,7 +73,7 @@ const topbarBreadcrumbItemWidth = computed(() => {
class="overflow-hidden trasition-all duration-200"
:class="{ 'w-[0px] invisible': isPanelExpanded, 'ml-1 w-[74px]': !isPanelExpanded }"
>
Extensions
{{ $t('general.extensions') }}
</span>
</div>
</NcButton>

22
packages/nc-gui/components/tabs/Smartsheet.vue

@ -161,8 +161,26 @@ watch([activeViewTitleOrId, activeTableId], () => {
handleSidebarOpenOnMobileForNonViews()
})
const { leftSidebarWidth, windowSize } = storeToRefs(useSidebarStore())
const { isPanelExpanded, extensionPanelSize } = useExtensions()
const contentSize = computed(() => {
if (isPanelExpanded.value && extensionPanelSize.value) {
return 100 - extensionPanelSize.value
} else {
return 100
}
})
const contentMaxSize = computed(() => {
if (!isPanelExpanded.value) {
return 100
} else {
return ((windowSize.value - leftSidebarWidth.value - 300) / (windowSize.value - leftSidebarWidth.value)) * 100
}
})
const onResize = (sizes: { min: number; max: number; size: number }[]) => {
if (sizes.length === 2) {
if (!sizes[1].size) return
@ -187,10 +205,10 @@ const onReady = () => {
<Splitpanes
v-if="openedViewsTab === 'view'"
class="nc-extensions-content-resizable-wrapper"
@ready="onReady"
@ready="() => onReady()"
@resized="onResize"
>
<Pane class="flex flex-col h-full min-w-0" :size="isPanelExpanded && extensionPanelSize ? 100 - extensionPanelSize : 100">
<Pane class="flex flex-col h-full min-w-0" :max-size="contentMaxSize" :size="contentSize">
<LazySmartsheetToolbar v-if="!isForm" />
<div
:style="{ height: isForm || isMobileMode ? '100%' : 'calc(100% - var(--toolbar-height))' }"

4
packages/nc-gui/components/workspace/integrations/IntegrationsTab.vue

@ -248,7 +248,7 @@ const handleAddIntegration = (category: IntegrationCategoryType, integration: In
<div
:tabindex="0"
class="source-card focus-visible:outline-none h-full"
class="source-card focus-visible:outline-none outline-none h-full"
:class="{
'is-available': integration?.isAvailable,
}"
@ -408,7 +408,7 @@ const handleAddIntegration = (category: IntegrationCategoryType, integration: In
@apply flex gap-4 flex-wrap;
.source-card {
@apply flex items-center gap-4 border-1 border-gray-200 rounded-xl p-3 w-[280px] cursor-pointer;
@apply flex items-center gap-4 border-1 border-gray-200 rounded-xl p-3 w-[280px] cursor-pointer transition-all duration-300;
.integration-icon-wrapper {
@apply flex-none h-[44px] w-[44px] rounded-lg flex items-center justify-center;

356
packages/nc-gui/composables/useExtensionHelper.ts

@ -1,218 +1,238 @@
import type { ColumnType, ViewType } from 'nocodb-sdk'
import type { ExtensionType } from '#imports'
import type { ExtensionManifest, ExtensionType } from '#imports'
const [useProvideExtensionHelper, useExtensionHelper] = useInjectionState((extension: Ref<ExtensionType>) => {
const { $api } = useNuxtApp()
const [useProvideExtensionHelper, useExtensionHelper] = useInjectionState(
(extension: Ref<ExtensionType>, extensionManifest: ComputedRef<ExtensionManifest | undefined>, activeError: Ref<any>) => {
const { $api } = useNuxtApp()
const basesStore = useBases()
const basesStore = useBases()
const { activeProjectId: baseId } = storeToRefs(basesStore)
const { activeProjectId: baseId } = storeToRefs(basesStore)
const tableStore = useTablesStore()
const tableStore = useTablesStore()
const { activeTables: tables } = storeToRefs(tableStore)
const { activeTables: tables } = storeToRefs(tableStore)
const viewStore = useViewsStore()
const viewStore = useViewsStore()
const { viewsByTable } = storeToRefs(viewStore)
const { viewsByTable } = storeToRefs(viewStore)
const { getMeta } = useMetas()
const { getMeta } = useMetas()
const { eventBus } = useSmartsheetStoreOrThrow()
const { eventBus } = useSmartsheetStoreOrThrow()
const fullscreen = ref(false)
const fullscreen = ref(false)
const collapsed = computed({
get: () => extension.value?.meta?.collapsed ?? false,
set: (value) => {
extension.value?.setMeta('collapsed', value)
},
})
const showExpandBtn = ref(true)
const getViewsForTable = async (tableId: string) => {
if (viewsByTable.value.has(tableId)) {
const fullscreenModalSize = ref<keyof typeof modalSizes>(extensionManifest.value?.config?.modalSize || 'lg')
const collapsed = computed({
get: () => extension.value?.meta?.collapsed ?? false,
set: (value) => {
extension.value?.setMeta('collapsed', value)
},
})
const getViewsForTable = async (tableId: string) => {
if (viewsByTable.value.has(tableId)) {
return viewsByTable.value.get(tableId) as ViewType[]
}
await viewStore.loadViews({ tableId, ignoreLoading: true })
return viewsByTable.value.get(tableId) as ViewType[]
}
await viewStore.loadViews({ tableId, ignoreLoading: true })
return viewsByTable.value.get(tableId) as ViewType[]
}
const getData = async (params: {
tableId: string
viewId?: string
eachPage: (records: Record<string, any>[], nextPage: () => void) => Promise<void> | void
done: () => Promise<void> | void
}) => {
const { tableId, viewId, eachPage, done } = params
let page = 1
const nextPage = async () => {
const { list: records, pageInfo } = await $api.dbViewRow.list(
'noco',
baseId.value!,
tableId,
viewId as string,
{
offset: (page - 1) * 100,
limit: 100,
} as any,
)
if (pageInfo?.isLastPage) {
await eachPage(records, () => {})
await done()
} else {
page++
await eachPage(records, nextPage)
const getData = async (params: {
tableId: string
viewId?: string
eachPage: (records: Record<string, any>[], nextPage: () => void) => Promise<void> | void
done: () => Promise<void> | void
}) => {
const { tableId, viewId, eachPage, done } = params
let page = 1
const nextPage = async () => {
const { list: records, pageInfo } = await $api.dbViewRow.list(
'noco',
baseId.value!,
tableId,
viewId as string,
{
offset: (page - 1) * 100,
limit: 100,
} as any,
)
if (pageInfo?.isLastPage) {
await eachPage(records, () => {})
await done()
} else {
page++
await eachPage(records, nextPage)
}
}
}
await nextPage()
}
await nextPage()
}
const getTableMeta = async (tableId: string) => {
return getMeta(tableId)
}
const getTableMeta = async (tableId: string) => {
return getMeta(tableId)
}
const insertData = async (params: { tableId: string; data: Record<string, any> }) => {
const { tableId, data } = params
const insertData = async (params: { tableId: string; data: Record<string, any> }) => {
const { tableId, data } = params
const chunks = []
const chunks = []
let inserted = 0
let inserted = 0
// chunk data into 100 records
for (let i = 0; i < data.length; i += 100) {
chunks.push(data.slice(i, i + 100))
}
// chunk data into 100 records
for (let i = 0; i < data.length; i += 100) {
chunks.push(data.slice(i, i + 100))
}
for (const chunk of chunks) {
inserted += chunk.length
await $api.dbDataTableRow.create(tableId, chunk)
}
for (const chunk of chunks) {
inserted += chunk.length
await $api.dbDataTableRow.create(tableId, chunk)
}
return {
inserted,
return {
inserted,
}
}
}
const updateData = async (params: { tableId: string; data: Record<string, any> }) => {
const { tableId, data } = params
const updateData = async (params: { tableId: string; data: Record<string, any> }) => {
const { tableId, data } = params
const chunks = []
const chunks = []
let updated = 0
let updated = 0
// chunk data into 100 records
for (let i = 0; i < data.length; i += 100) {
chunks.push(data.slice(i, i + 100))
}
// chunk data into 100 records
for (let i = 0; i < data.length; i += 100) {
chunks.push(data.slice(i, i + 100))
}
for (const chunk of chunks) {
updated += chunk.length
await $api.dbDataTableRow.update(tableId, chunk)
}
for (const chunk of chunks) {
updated += chunk.length
await $api.dbDataTableRow.update(tableId, chunk)
}
return {
updated,
return {
updated,
}
}
}
const upsertData = async (params: { tableId: string; data: Record<string, any>; upsertField: ColumnType }) => {
const { tableId, data, upsertField } = params
const upsertData = async (params: {
tableId: string
data: Record<string, any>
upsertField: ColumnType
importType: 'insert' | 'update' | 'insertAndUpdate'
}) => {
const { tableId, data, upsertField } = params
const chunkSize = 100
const chunkSize = 100
const tableMeta = await getMeta(tableId)
const tableMeta = await getMeta(tableId)
if (!tableMeta?.columns) throw new Error('Table not found')
if (!tableMeta?.columns) throw new Error('Table not found')
const chunks = []
const chunks = []
for (let i = 0; i < data.length; i += chunkSize) {
chunks.push(data.slice(i, i + chunkSize))
}
for (let i = 0; i < data.length; i += chunkSize) {
chunks.push(data.slice(i, i + chunkSize))
}
const insert = []
const update = []
let insertCounter = 0
let updateCounter = 0
for (const chunk of chunks) {
// select chunk of data to determine if it's an insert or update
const { list } = await $api.dbDataTableRow.list(tableId, {
where: `(${upsertField.title},in,${chunk.map((record: Record<string, any>) => record[upsertField.title!]).join(',')})`,
limit: chunkSize,
})
insert.push(
...chunk.filter(
(record: Record<string, any>) =>
!list.some((r: Record<string, any>) => `${r[upsertField.title!]}` === `${record[upsertField.title!]}`),
),
)
update.push(
...chunk
.filter((record: Record<string, any>) =>
list.some((r: Record<string, any>) => `${r[upsertField.title!]}` === `${record[upsertField.title!]}`),
const insert = []
const update = []
let insertCounter = 0
let updateCounter = 0
for (const chunk of chunks) {
// select chunk of data to determine if it's an insert or update
const { list } = await $api.dbDataTableRow.list(tableId, {
where: `(${upsertField.title},in,${chunk.map((record: Record<string, any>) => record[upsertField.title!]).join(',')})`,
limit: chunkSize,
})
if (params.importType !== 'update') {
insert.push(
...chunk.filter(
(record: Record<string, any>) =>
!list.some((r: Record<string, any>) => `${r[upsertField.title!]}` === `${record[upsertField.title!]}`),
),
)
.map((record: Record<string, any>) => {
const existingRecord = list.find(
(r: Record<string, any>) => `${r[upsertField.title!]}` === `${record[upsertField.title!]}`,
)
return {
...rowPkData(existingRecord!, tableMeta.columns!),
...record,
}
}),
)
}
}
if (params.importType !== 'insert') {
update.push(
...chunk
.filter((record: Record<string, any>) =>
list.some((r: Record<string, any>) => `${r[upsertField.title!]}` === `${record[upsertField.title!]}`),
)
.map((record: Record<string, any>) => {
const existingRecord = list.find(
(r: Record<string, any>) => `${r[upsertField.title!]}` === `${record[upsertField.title!]}`,
)
return {
...rowPkData(existingRecord!, tableMeta.columns!),
...record,
}
}),
)
}
}
if (insert.length) {
insertCounter += insert.length
while (insert.length) {
await $api.dbDataTableRow.create(tableId, insert.splice(0, chunkSize))
if (insert.length) {
insertCounter += insert.length
while (insert.length) {
await $api.dbDataTableRow.create(tableId, insert.splice(0, chunkSize))
}
}
}
if (update.length) {
updateCounter += update.length
while (update.length) {
await $api.dbDataTableRow.update(tableId, update.splice(0, chunkSize))
if (update.length) {
updateCounter += update.length
while (update.length) {
await $api.dbDataTableRow.update(tableId, update.splice(0, chunkSize))
}
}
return { inserted: insertCounter, updated: updateCounter }
}
const reloadData = () => {
eventBus.emit(SmartsheetStoreEvents.DATA_RELOAD)
}
return { inserted: insertCounter, updated: updateCounter }
}
const reloadData = () => {
eventBus.emit(SmartsheetStoreEvents.DATA_RELOAD)
}
const reloadMeta = () => {
eventBus.emit(SmartsheetStoreEvents.FIELD_RELOAD)
}
return {
$api,
fullscreen,
collapsed,
extension,
tables,
getViewsForTable,
getData,
getTableMeta,
insertData,
updateData,
upsertData,
reloadData,
reloadMeta,
}
}, 'extension-helper')
const reloadMeta = () => {
eventBus.emit(SmartsheetStoreEvents.FIELD_RELOAD)
}
return {
$api,
fullscreen,
collapsed,
extension,
extensionManifest,
activeError,
tables,
showExpandBtn,
fullscreenModalSize,
getViewsForTable,
getData,
getTableMeta,
insertData,
updateData,
upsertData,
reloadData,
reloadMeta,
}
},
'extension-helper',
)
export { useProvideExtensionHelper }

32
packages/nc-gui/composables/useExtensions.ts

@ -19,12 +19,23 @@ export interface ExtensionManifest {
entry: string
version: string
iconUrl: string
publisherName: string
publisherEmail: string
publisherUrl: string
publisher: {
name: string
email: string
url: string
icon?: {
src: string
width?: number
height?: number
}
}
disabled?: boolean
config?: {
modalMaxWith?: 'xs' | 'sm' | 'md' | 'lg'
links: {
title: string
href: string
}[]
config: {
modalSize?: 'xs' | 'sm' | 'md' | 'lg'
contentMinHeight?: string
}
}
@ -375,6 +386,17 @@ export const useExtensions = createSharedComposable(() => {
const mod = (await modules[path]()) as any
const manifest = mod.default as ExtensionManifest
if (!Array.isArray(manifest.links)) {
manifest.links = []
}
if (!manifest?.config || !manifest?.config?.modalSize) {
manifest.config = {
...(manifest.config || {}),
modalSize: 'lg',
}
}
if (manifest?.disabled !== true) {
availableExtensions.value.push(manifest)

BIN
packages/nc-gui/extensions/data-exporter/assets/fullscreen-modal-screenshot.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 298 KiB

BIN
packages/nc-gui/extensions/data-exporter/assets/icon.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

14
packages/nc-gui/extensions/data-exporter/assets/publisher-icon.svg

@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M6 10.8676L8.75329 13.6226V17.9842H6V10.8676ZM17.5645 5.01046V17.535C17.5645 17.7921 17.3548 18 17.0977 18C16.9744 18 16.8563 17.9525 16.7683 17.8644L6 8.15303V5.40504C6 5.14785 6.20787 4.94 6.46505 4.94H6.48972C6.61303 4.94 6.7328 4.98933 6.81911 5.07564L14.8094 12.009V5.01046H17.5645Z" fill="url(#paint0_linear_948_404152)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M3 0C1.34315 0 0 1.34315 0 3V21C0 22.6569 1.34315 24 3 24H21C22.6569 24 24 22.6569 24 21V3C24 1.34315 22.6569 0 21 0H3ZM3.63333 2.13333C2.8049 2.13333 2.13333 2.8049 2.13333 3.63333V20.3667C2.13333 21.1951 2.8049 21.8667 3.63333 21.8667H20.3667C21.1951 21.8667 21.8667 21.1951 21.8667 20.3667V3.63333C21.8667 2.8049 21.1951 2.13333 20.3667 2.13333H3.63333Z" fill="url(#paint1_linear_948_404152)"/>
<defs>
<linearGradient id="paint0_linear_948_404152" x1="11.7814" y1="0.543214" x2="11.7814" y2="21.6612" gradientUnits="userSpaceOnUse">
<stop stop-color="#4351E8"/>
<stop offset="1" stop-color="#2A1EA5"/>
</linearGradient>
<linearGradient id="paint1_linear_948_404152" x1="4.82267" y1="19.1431" x2="26.7035" y2="-2.6331" gradientUnits="userSpaceOnUse">
<stop stop-color="#4351E8"/>
<stop offset="1" stop-color="#2A1EA5"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
packages/nc-gui/extensions/data-exporter/assets/recent-exports-modal.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 KiB

BIN
packages/nc-gui/extensions/data-exporter/assets/recent-exports.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

24
packages/nc-gui/extensions/data-exporter/description.md

@ -5,9 +5,31 @@ The download process is handled asynchronously in the background, ensuring that
</br> </br>
**Key Features**
- Easy CSV downloads for specific tables and views
- Asynchronous processing for seamless operation
- Instant notifications when the file is ready for download
</br> </br>
</br> </br>
Elevate your data handling capabilities with the NocoDB Data Exporter extension!
</br></br>
<!-- Todo: Add docs link -->
<a href="" target="_blank" rel="noopener noreferrer" class="!no-underline !hover:underline inline-flex items-center gap-2 ">
Learn more
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M12 8.66667V12.6667C12 13.0203 11.8595 13.3594 11.6095 13.6095C11.3594 13.8595 11.0203 14 10.6667 14H3.33333C2.97971 14 2.64057 13.8595 2.39052 13.6095C2.14048 13.3594 2 13.0203 2 12.6667V5.33333C2 4.97971 2.14048 4.64057 2.39052 4.39052C2.64057 4.14048 2.97971 4 3.33333 4H7.33333" stroke="#374151" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10 2H14V6" stroke="#374151" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.66669 9.33333L14 2" stroke="#374151" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</a>
</br></br>
<div class="flex">
<img src="data-exporter/assets/recent-exports.png" width="50%" class="object-contain rounded-xl"/>
</div>
</br>
<div>
<img src="data-exporter/assets/recent-exports-modal.png" width="100%" class="object-contain rounded-xl"/>
</div>

358
packages/nc-gui/extensions/data-exporter/index.vue

@ -158,6 +158,25 @@ const urlHelper = (url: string) => {
}
}
const handleDownload = async (url: string) => {
const isExpired = await isLinkExpired(url)
if (isExpired) {
navigateTo(url, {
open: navigateToBlankTargetOpenOption,
})
return
}
const link = document.createElement('a')
link.href = url
link.style.display = 'none' // Hide the link
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
function titleHelper() {
const table = tables.value.find((t) => t.id === exportPayload.value.tableId)
const view = views.value.find((v) => v.id === exportPayload.value.viewId)
@ -184,154 +203,164 @@ onMounted(() => {
</script>
<template>
<div ref="dataExporterRef" class="data-exporter">
<div class="pb-3 flex items-center justify-between gap-2.5 flex-wrap">
<div
class="flex-1 flex items-center"
:class="{
'max-w-[min(350px,calc(100%-124px))]': isExporting && !fullscreen && width > 325,
'max-w-[min(350px,calc(100%_-_84px))]': !isExporting && !fullscreen && width > 325,
'max-w-full': width <= 325,
'max-w-[900px]': fullscreen,
}"
>
<NcSelect
v-model:value="exportPayload.tableId"
placeholder="-select table-"
:disabled="isExporting"
class="nc-data-exporter-table-select"
<ExtensionsExtensionWrapper>
<div
ref="dataExporterRef"
class="data-exporter"
:class="{
'p-4': fullscreen,
'p-3': !fullscreen,
}"
>
<div class="pb-3 flex items-center justify-between gap-2.5 flex-wrap">
<div
class="flex-1 flex items-center"
:class="{
'flex-1 max-w-[240px]': fullscreen,
'min-w-1/2 max-w-[175px]': !fullscreen,
'max-w-[min(350px,calc(100%-124px))]': isExporting && !fullscreen && width > 325,
'max-w-[min(350px,calc(100%_-_84px))]': !isExporting && !fullscreen && width > 325,
'max-w-full': width <= 325,
'max-w-[480px]': fullscreen,
}"
:filter-option="filterOption"
dropdown-class-name="w-[250px]"
show-search
@change="onTableSelect"
>
<a-select-option v-for="table of tableList" :key="table.label" :value="table.value">
<div class="w-full flex items-center gap-2">
<div class="min-w-5 flex items-center justify-center">
<GeneralTableIcon :meta="{ meta: table.meta }" class="text-gray-500" />
</div>
<NcTooltip class="flex-1 truncate" show-on-truncate-only>
<template #title>{{ table.label }}</template>
<span>{{ table.label }}</span>
</NcTooltip>
<component
:is="iconMap.check"
v-if="exportPayload.tableId === table.value"
id="nc-selected-item-icon"
class="flex-none text-primary w-4 h-4"
/>
</div>
</a-select-option>
</NcSelect>
<NcSelect
v-model:value="exportPayload.viewId"
placeholder="-select view-"
:disabled="isExporting"
class="nc-data-exporter-view-select"
:class="{
'flex-1 max-w-[240px]': fullscreen,
'min-w-1/2 max-w-[175px]': !fullscreen,
}"
dropdown-class-name="w-[250px]"
:filter-option="filterOption"
show-search
@change="onViewSelect"
>
<a-select-option v-for="view of viewList" :key="view.label" :value="view.value">
<div class="w-full flex items-center gap-2">
<div class="min-w-5 flex items-center justify-center">
<GeneralViewIcon :meta="{ meta: view.meta, type: view.type }" class="flex-none text-gray-500" />
</div>
<NcTooltip class="flex-1 truncate" show-on-truncate-only>
<template #title>{{ view.label }}</template>
<span>{{ view.label }}</span>
</NcTooltip>
<component
:is="iconMap.check"
v-if="exportPayload.viewId === view.value"
id="nc-selected-item-icon"
class="flex-none text-primary w-4 h-4"
/>
</div> </a-select-option
></NcSelect>
</div>
<div class="flex-none flex justify-end">
<NcTooltip class="flex" placement="topRight" :disabled="!isExporting">
<template #title> The CSV file is being prepared in the background. You'll be notified once it's ready. </template>
<NcButton :disabled="!exportPayload?.viewId" :loading="isExporting" size="xs" @click="exportDataAsync">{{
isExporting ? 'Generating' : 'Export'
}}</NcButton>
</NcTooltip>
</div>
</div>
<div class="data-exporter-body flex-1 flex flex-col">
<div class="data-exporter-header">Recent Exports</div>
<div v-if="exportedFiles.length" class="flex-1 flex flex-col nc-scrollbar-thin max-h-[calc(100%_-_25px)]">
<template v-for="exp of exportedFiles">
<div
v-if="exp.status === JobStatus.COMPLETED ? exp.result : true"
:key="exp.id"
class="px-3 py-2 flex gap-2 justify-between border-b-1 hover:bg-gray-50"
:class="{
'px-4 py-3': fullscreen,
'px-3 py-2': !fullscreen,
}"
class="flex-1 flex items-center border-1 border-gray-200 rounded-lg focus-within:(border-brand-500 shadow-selected) transition-colors transition-shadow"
>
<div
class="flex-1 flex items-center gap-3"
<NcSelect
v-model:value="exportPayload.tableId"
placeholder="-select table-"
:disabled="isExporting"
class="nc-data-exporter-table-select"
:class="{
'max-w-[calc(100%_-_74px)]': exp.status === JobStatus.COMPLETED,
'max-w-[calc(100%_-_38px)]': exp.status !== JobStatus.COMPLETED,
'flex-1 max-w-[240px]': fullscreen,
'min-w-1/2 max-w-[175px]': !fullscreen,
}"
:bordered="false"
:filter-option="filterOption"
dropdown-class-name="w-[250px]"
show-search
@change="onTableSelect"
>
<NcTooltip v-if="[JobStatus.COMPLETED, JobStatus.FAILED].includes(exp.status)" class="flex">
<template #title>
{{ jobStatusTooltip[exp.status] }}
</template>
<GeneralIcon
:icon="exp.status === JobStatus.COMPLETED ? 'circleCheck2' : 'alertTriangle'"
class="flex-none h-4 w-4"
:class="{
'!text-green-500': exp.status === JobStatus.COMPLETED,
'!text-red-500': exp.status === JobStatus.FAILED,
}"
/>
</NcTooltip>
<div v-else class="h-5 flex items-center">
<GeneralLoader size="regular" class="flex-none" />
</div>
<div class="flex-1 max-w-[calc(100%_-_28px)] flex flex-col gap-1">
<div class="inline-flex gap-1 text-sm text-gray-800 -ml-[1px]">
<span class="inline-flex items-center h-5">
<GeneralIcon icon="file" class="flex-none text-gray-600/80 h-3.5 w-3.5" />
</span>
<NcTooltip class="truncate max-w-[calc(100%_-_20px)]" show-on-truncate-only>
<template #title>
{{ exp.result.title || titleHelper() }}
</template>
{{ exp.result.title || titleHelper() }}
<a-select-option v-for="table of tableList" :key="table.label" :value="table.value">
<div class="w-full flex items-center gap-2">
<div class="min-w-5 flex items-center justify-center">
<GeneralTableIcon :meta="{ meta: table.meta }" class="text-gray-500" />
</div>
<NcTooltip class="flex-1 truncate" show-on-truncate-only>
<template #title>{{ table.label }}</template>
<span>{{ table.label }}</span>
</NcTooltip>
<component
:is="iconMap.check"
v-if="exportPayload.tableId === table.value"
id="nc-selected-item-icon"
class="flex-none text-primary w-4 h-4"
/>
</div>
<div v-if="exp.result.timestamp" class="text-[10px] leading-4 text-gray-600">
<NcTooltip class="truncate" show-on-truncate-only>
<template #title>
{{ dayjs(exp.result.timestamp).format('MM/DD/YYYY [at] hh:mm A') }}
</template>
{{ dayjs(exp.result.timestamp).format('MM/DD/YYYY [at] hh:mm A') }}
</a-select-option>
</NcSelect>
<div class="flex-none h-8 border-l-1 border-gray-200"></div>
<NcSelect
v-model:value="exportPayload.viewId"
placeholder="-select view-"
:disabled="isExporting"
class="nc-data-exporter-view-select"
:class="{
'flex-1 max-w-[240px]': fullscreen,
'min-w-1/2 max-w-[175px]': !fullscreen,
}"
:bordered="false"
dropdown-class-name="w-[250px]"
:filter-option="filterOption"
show-search
@change="onViewSelect"
>
<a-select-option v-for="view of viewList" :key="view.label" :value="view.value">
<div class="w-full flex items-center gap-2">
<div class="min-w-5 flex items-center justify-center">
<GeneralViewIcon :meta="{ meta: view.meta, type: view.type }" class="flex-none text-gray-500" />
</div>
<NcTooltip class="flex-1 truncate" show-on-truncate-only>
<template #title>{{ view.label }}</template>
<span>{{ view.label }}</span>
</NcTooltip>
<component
:is="iconMap.check"
v-if="exportPayload.viewId === view.value"
id="nc-selected-item-icon"
class="flex-none text-primary w-4 h-4"
/>
</div> </a-select-option
></NcSelect>
</div>
</div>
<div class="flex-none flex justify-end">
<NcTooltip class="flex" placement="topRight" :disabled="!isExporting">
<template #title> The CSV file is being prepared in the background. You'll be notified once it's ready. </template>
<NcButton :disabled="!exportPayload?.viewId" :loading="isExporting" size="xs" @click="exportDataAsync">{{
isExporting ? 'Generating' : 'Export'
}}</NcButton>
</NcTooltip>
</div>
</div>
<div class="data-exporter-body flex-1 flex flex-col">
<div class="data-exporter-header">Recent Exports</div>
<div v-if="exportedFiles.length" class="flex-1 flex flex-col nc-scrollbar-thin max-h-[calc(100%_-_25px)]">
<template v-for="exp of exportedFiles">
<div
v-if="exp.status === JobStatus.COMPLETED ? exp.result : true"
:key="exp.id"
class="p-3 flex gap-2 justify-between border-b-1 hover:bg-gray-50"
:class="{
'px-4 py-3': fullscreen,
'px-3 py-2': !fullscreen,
}"
>
<div
class="flex-1 flex items-start gap-3"
:class="{
'max-w-[calc(100%_-_74px)]': exp.status === JobStatus.COMPLETED,
'max-w-[calc(100%_-_38px)]': exp.status !== JobStatus.COMPLETED,
}"
>
<NcTooltip v-if="[JobStatus.COMPLETED, JobStatus.FAILED].includes(exp.status)" class="flex">
<template #title>
{{ jobStatusTooltip[exp.status] }}
</template>
<GeneralIcon
:icon="exp.status === JobStatus.COMPLETED ? 'circleCheckSolid' : 'alertTriangleSolid'"
class="flex-none h-5 w-5"
:class="{
'!text-green-700': exp.status === JobStatus.COMPLETED,
'!text-red-700': exp.status === JobStatus.FAILED,
}"
/>
</NcTooltip>
<div v-else class="h-5 flex items-center">
<GeneralLoader size="regular" class="flex-none" />
</div>
<div class="flex-1 max-w-[calc(100%_-_28px)] flex flex-col gap-1">
<div class="inline-flex gap-1 text-sm text-gray-800 -ml-[1px]">
<span class="inline-flex items-center h-5">
<GeneralIcon icon="file" class="flex-none text-gray-600/80 h-3.5 w-3.5" />
</span>
<NcTooltip class="truncate max-w-[calc(100%_-_20px)]" show-on-truncate-only>
<template #title>
{{ exp.result.title || titleHelper() }}
</template>
{{ exp.result.title || titleHelper() }}
</NcTooltip>
</div>
<div v-if="exp.result.timestamp" name="error" class="text-small leading-[18px] text-nc-content-gray-muted">
{{ timeAgo(dayjs(exp.result.timestamp).toString()) }}
</div>
</div>
</div>
</div>
<div v-if="exp.status === JobStatus.COMPLETED" class="flex items-center">
<a :href="urlHelper(exp.result.url)" target="_blank">
<NcTooltip class="flex items-center">
<div v-if="exp.status === JobStatus.COMPLETED" class="flex" @click="handleDownload(urlHelper(exp.result.url))">
<NcTooltip class="flex">
<template #title>
{{ $t('general.download') }}
</template>
@ -342,45 +371,48 @@ onMounted(() => {
</div>
</NcButton>
</NcTooltip>
</a>
</div>
</div>
<div class="flex items-center">
<NcTooltip class="flex">
<template #title>
{{ $t('general.remove') }}
</template>
<div class="flex">
<NcTooltip class="flex">
<template #title>
{{ $t('general.remove') }}
</template>
<NcButton type="text" size="xs" class="!px-[5px]" @click="onRemoveExportedFile(exp.id)">
<GeneralIcon icon="close" />
</NcButton>
</NcTooltip>
<NcButton type="text" size="xs" class="!px-[5px]" @click="onRemoveExportedFile(exp.id)">
<GeneralIcon icon="close" />
</NcButton>
</NcTooltip>
</div>
</div>
</div>
</template>
</template>
</div>
<div v-else class="px-3 py-2 flex-1 flex items-center justify-center text-gray-600">No exports</div>
</div>
<div v-else class="px-3 py-2 flex-1 flex items-center justify-center text-gray-600">No exports</div>
</div>
</div>
</ExtensionsExtensionWrapper>
</template>
<style lang="scss" scoped>
:deep(.extension-content-container) {
@apply !p-0;
}
.data-exporter {
@apply flex flex-col overflow-hidden h-full;
@apply flex flex-col overflow-hidden h-full;
.data-exporter-header {
@apply px-3 py-1 bg-gray-100 text-[11px] leading-4 text-gray-600 border-b-1;
}
.nc-data-exporter-table-select {
:deep(.ant-select-selector) {
@apply !border-r-[0.5px] rounded-lg !rounded-r-none shadow-none;
}
}
.nc-data-exporter-view-select {
:deep(.ant-select-selector) {
@apply !border-l-[0.5px] rounded-lg !rounded-l-none shadow-none;
}
}
// .nc-data-exporter-table-select {
// :deep(.ant-select-selector) {
// @apply !border-r-1 rounded-lg !rounded-r-none shadow-none;
// }
// }
// .nc-data-exporter-view-select {
// :deep(.ant-select-selector) {
// @apply !border-l-0 rounded-lg !rounded-l-none shadow-none;
// }
// }
.data-exporter-body {
@apply flex-1 rounded-lg border-1 overflow-hidden;
@ -391,3 +423,9 @@ onMounted(() => {
}
}
</style>
<style>
.nc-nc-data-exporter .extension-content {
@apply !p-0;
}
</style>

16
packages/nc-gui/extensions/data-exporter/manifest.json

@ -6,11 +6,19 @@
"entry": "data-exporter",
"version": "0.1",
"iconUrl": "data-exporter/assets/icon.svg",
"publisherName": "NocoDB",
"publisherEmail": "contact@nocodb.com",
"publisherUrl": "https://www.nocodb.com",
"publisher": {
"name": "NocoDB",
"email": "contact@nocodb.com",
"url": "https://www.nocodb.com",
"icon": {
"src": "csv-import-ee/assets/publisher-icon.svg",
"width": 24,
"height": 24
}
},
"links": [],
"config": {
"modalMaxWith": "sm",
"modalSize": "sm",
"contentMinHeight": "310px"
}
}

14
packages/nc-gui/extensions/json-exporter/assets/publisher-icon.svg

@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M6 10.8676L8.75329 13.6226V17.9842H6V10.8676ZM17.5645 5.01046V17.535C17.5645 17.7921 17.3548 18 17.0977 18C16.9744 18 16.8563 17.9525 16.7683 17.8644L6 8.15303V5.40504C6 5.14785 6.20787 4.94 6.46505 4.94H6.48972C6.61303 4.94 6.7328 4.98933 6.81911 5.07564L14.8094 12.009V5.01046H17.5645Z" fill="url(#paint0_linear_948_404152)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M3 0C1.34315 0 0 1.34315 0 3V21C0 22.6569 1.34315 24 3 24H21C22.6569 24 24 22.6569 24 21V3C24 1.34315 22.6569 0 21 0H3ZM3.63333 2.13333C2.8049 2.13333 2.13333 2.8049 2.13333 3.63333V20.3667C2.13333 21.1951 2.8049 21.8667 3.63333 21.8667H20.3667C21.1951 21.8667 21.8667 21.1951 21.8667 20.3667V3.63333C21.8667 2.8049 21.1951 2.13333 20.3667 2.13333H3.63333Z" fill="url(#paint1_linear_948_404152)"/>
<defs>
<linearGradient id="paint0_linear_948_404152" x1="11.7814" y1="0.543214" x2="11.7814" y2="21.6612" gradientUnits="userSpaceOnUse">
<stop stop-color="#4351E8"/>
<stop offset="1" stop-color="#2A1EA5"/>
</linearGradient>
<linearGradient id="paint1_linear_948_404152" x1="4.82267" y1="19.1431" x2="26.7035" y2="-2.6331" gradientUnits="userSpaceOnUse">
<stop stop-color="#4351E8"/>
<stop offset="1" stop-color="#2A1EA5"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

15
packages/nc-gui/extensions/json-exporter/description.md

@ -1,5 +1,16 @@
This is a sample NocoDB extension that exports data in JSON format.
This is a sample NocoDB extension that exports data in JSON format.
It is used to demonstrate how to create a NocoDB extension.
</br> </br>
This extension is disabled by default. To access it you need to first change the `disabled` property in the manifest file to `false`.
This extension is disabled by default. To access it you need to first change the `disabled` property in the manifest file to `false`.
</br></br>
<!-- Todo: Add docs link -->
<a href="" target="_blank" rel="noopener noreferrer" class="!no-underline !hover:underline inline-flex items-center gap-2 ">
Learn more
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M12 8.66667V12.6667C12 13.0203 11.8595 13.3594 11.6095 13.6095C11.3594 13.8595 11.0203 14 10.6667 14H3.33333C2.97971 14 2.64057 13.8595 2.39052 13.6095C2.14048 13.3594 2 13.0203 2 12.6667V5.33333C2 4.97971 2.14048 4.64057 2.39052 4.39052C2.64057 4.14048 2.97971 4 3.33333 4H7.33333" stroke="#374151" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10 2H14V6" stroke="#374151" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.66669 9.33333L14 2" stroke="#374151" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</a>

12
packages/nc-gui/extensions/json-exporter/index.vue

@ -92,11 +92,13 @@ onMounted(() => {
</script>
<template>
<div class="flex flex-col gap-2 p-2 border-1 rounded-lg">
<NcSelect v-model:value="exportPayload.tableId" :options="tableList" placeholder="-select table-" @change="onTableSelect" />
<NcSelect v-model:value="exportPayload.viewId" :options="viewList" placeholder="-select view-" @change="onViewSelect" />
<NcButton @click="exportJson">Export</NcButton>
</div>
<ExtensionsExtensionWrapper>
<div class="flex flex-col gap-2 p-2 border-1 rounded-lg">
<NcSelect v-model:value="exportPayload.tableId" :options="tableList" placeholder="-select table-" @change="onTableSelect" />
<NcSelect v-model:value="exportPayload.viewId" :options="viewList" placeholder="-select view-" @change="onViewSelect" />
<NcButton @click="exportJson">Export</NcButton>
</div>
</ExtensionsExtensionWrapper>
</template>
<style lang="scss"></style>

14
packages/nc-gui/extensions/json-exporter/manifest.json

@ -6,9 +6,17 @@
"entry": "json-exporter",
"version": "0.1",
"iconUrl": "json-exporter/assets/icon.png",
"publisherName": "NocoDB",
"publisherEmail": "contact@nocodb.com",
"publisherUrl": "https://www.nocodb.com",
"publisher": {
"name": "NocoDB",
"email": "contact@nocodb.com",
"url": "https://www.nocodb.com",
"icon": {
"src": "json-exporter/assets/publisher-icon.svg",
"width": 24,
"height": 24
}
},
"links": [],
"disabled": true,
"config": {
"contentMinHeight": "190px"

4
packages/nc-gui/lang/en.json

@ -287,7 +287,9 @@
"connections": "Connections",
"private": "Private",
"request": "Request",
"languages": "Languages"
"languages": "Languages",
"extension": "Extension",
"extensions": "Extensions"
},
"objects": {
"files": "files",

2
packages/nc-gui/store/bases.ts

@ -59,7 +59,7 @@ export const useBases = defineStore('basesStore', () => {
const isProjectsLoading = ref(false)
async function getBaseUsers({ baseId, searchText, force = false }: { baseId: string; searchText?: string; force?: boolean }) {
if(!baseId) return { users: [], totalRows: 0 }
if (!baseId) return { users: [], totalRows: 0 }
if (!force && basesUser.value.has(baseId)) {
const users = basesUser.value.get(baseId)

18
packages/nc-gui/utils/commonUtils.ts

@ -0,0 +1,18 @@
export const modalSizes = {
xs: {
width: 'min(calc(100vw - 32px), 448px)',
height: 'min(90vh, 448px)',
},
sm: {
width: 'min(calc(100vw - 32px), 640px)',
height: 'min(90vh, 424px)',
},
md: {
width: 'min(80vw, 900px)',
height: 'min(90vh, 540px)',
},
lg: {
width: 'min(80vw, 1280px)',
height: 'min(90vh, 864px)',
},
}

11
packages/nc-gui/utils/iconUtils.ts

@ -196,6 +196,7 @@ import NcReddit from '~icons/nc-icons/reddit'
import NcTwitter from '~icons/nc-icons/twitter'
import NcFile from '~icons/nc-icons/file'
import NcFileBig from '~icons/nc-icons/file-big'
import NcSettings from '~icons/nc-icons/settings'
import NcHelp from '~icons/nc-icons/help'
import NcAlertTriangle from '~icons/nc-icons/alert-triangle'
@ -556,6 +557,11 @@ import NcCalendarViewIcon from '~icons/nc-icons/calendar'
import NcPuzzleSolid from '~icons/nc-icons/puzzle-solid'
import NcPuzzleOutline from '~icons/nc-icons/puzzle-outline'
import NcInfoSolid from '~icons/nc-icons/info-solid'
import NcPlaceholderIcon from '~icons/nc-icons/placeholder-icon'
import NcSpanner from '~icons/nc-icons/spanner'
import NcScript from '~icons/nc-icons/script'
// keep it for reference
// todo: remove it after all icons are migrated
/* export const iconMapOld = {
@ -983,6 +989,7 @@ export const iconMap = {
ncReddit: NcReddit,
ncTwitter: NcTwitter,
file: NcFile,
fileBig: NcFileBig,
ncSettings: NcSettings,
ncHelp: NcHelp,
puzzle: MdiPuzzle,
@ -1327,6 +1334,10 @@ export const iconMap = {
langC: NcLangC,
ncPuzzleSolid: NcPuzzleSolid,
ncPuzzleOutline: NcPuzzleOutline,
ncInfoSolid: NcInfoSolid,
ncPlaceholderIcon: NcPlaceholderIcon,
ncSpanner: NcSpanner,
ncScript: NcScript,
}
export const getMdiIcon = (type: string): any => {

5
packages/nc-gui/windi.config.ts

@ -17,7 +17,10 @@ const isEE = process.env.EE
export default defineConfig({
extract: {
include: [isEE ? '../**/*.{vue,html,jsx,tsx,css,scss}' : '**/*.{vue,html,jsx,tsx,css,scss}'],
include: [
isEE ? '../**/*.{vue,html,jsx,tsx,css,scss}' : '**/*.{vue,html,jsx,tsx,css,scss}',
isEE ? '../extensions/**/*.md' : 'extensions/**/*.md',
],
exclude: ['node_modules', '.git'],
},

Loading…
Cancel
Save