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" v-model:visible="vModel"
:class="{ active: vModel }" :class="{ active: vModel }"
:footer="null" :footer="null"
:width="1154" size="lg"
size="medium"
wrap-class-name="nc-modal-extension-details" wrap-class-name="nc-modal-extension-details"
> >
<div v-if="activeExtension" class="flex flex-col w-full h-full"> <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"> <div class="self-start flex items-center gap-2.5">
<NcButton size="small" class="w-full" @click="onAddExtension(activeExtension)"> <NcButton size="small" class="w-full" @click="onAddExtension(activeExtension)">
<div class="flex items-center justify-center gap-1 -ml-3px"> <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> </div>
</NcButton> </NcButton>
<NcButton size="small" type="text" @click="vModel = false"> <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-title">Version</div>
<div class="extension-details-right-subtitle">{{ activeExtension.version }}</div> <div class="extension-details-right-subtitle">{{ activeExtension.version }}</div>
</div> </div>
<NcDivider /> <NcDivider />
<div class="extension-details-right-section"> <div v-if="activeExtension.publisher" class="extension-details-right-section">
<div v-if="activeExtension.publisherName" class="extension-details-right-title">Publisher</div> <div class="extension-details-right-title">Publisher</div>
<div class="extension-details-right-subtitle">{{ activeExtension.publisherName }}</div> <div class="flex items-center gap-2">
</div> <img
<template v-if="activeExtension.publisherEmail"> v-if="activeExtension.publisher?.icon?.src"
<NcDivider /> :src="getExtensionAssetsUrl(activeExtension.publisher.icon.src)"
<div class="extension-details-right-section"> alt="Publisher icon"
<div class="extension-details-right-title">Publisher Email</div> class="object-contain flex-none"
<div class="extension-details-right-subtitle"> :style="{
<a :href="`mailto:${activeExtension.publisherEmail}`" target="_blank" rel="noopener noreferrer"> width: activeExtension.publisher?.icon?.width ? `${activeExtension.publisher?.icon?.width}px` : '24px',
{{ activeExtension.publisherEmail }} 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> </a>
</div> </template>
</div> </div>
</template> </div>
<template v-if="activeExtension.publisherUrl"> <template v-if="activeExtension.links && activeExtension.links.length">
<NcDivider /> <NcDivider />
<div class="extension-details-right-section"> <div class="extension-details-right-section">
<div class="extension-details-right-title">Publisher Website</div> <div class="extension-details-right-title">Links</div>
<div class="extension-details-right-subtitle"> <div>
<a :href="activeExtension.publisherUrl" target="_blank" rel="noopener noreferrer"> <div v-for="(doc, idx) of activeExtension.links" :key="idx" class="flex items-center gap-1">
{{ activeExtension.publisherUrl }} <div class="h-7 w-7 flex items-center justify-center">
</a> <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>
</div> </div>
</template> </template>
@ -138,7 +171,7 @@ const detailsBody = computed(() => {
<style lang="scss" scoped> <style lang="scss" scoped>
.extension-details { .extension-details {
@apply flex w-full h-[calc(100%_-_65px)]; @apply flex w-full h-[calc(100%_-_83px)];
.extension-details-left { .extension-details-left {
@apply p-6 flex-1 flex flex-col gap-6 nc-scrollbar-thin; @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; @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 { .extension-details-right-section {
@apply flex flex-col gap-2; @apply flex flex-col gap-3;
} }
.extension-details-right-title { .extension-details-right-title {
@ -168,18 +201,11 @@ const detailsBody = computed(() => {
} }
.nc-modal { .nc-modal {
@apply !p-0; @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 { .nc-extension-details-body {
@apply max-w-[768px] mx-auto;
p { p {
@apply !m-0 !leading-5; @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 { extensionId, error } = defineProps<Prop>()
const { const { extensionList, extensionsLoaded, availableExtensions, eventBus } = useExtensions()
extensionList,
extensionsLoaded, const isLoadedExtension = ref<boolean>(true)
availableExtensions,
eventBus,
getExtensionAssetsUrl,
duplicateExtension,
showExtensionDetails,
} = useExtensions()
const activeError = ref(error) const activeError = ref(error)
@ -32,63 +26,50 @@ const extension = computed(() => {
return ext return ext
}) })
const titleInput = ref<HTMLInputElement | null>(null) const extensionManifest = computed<ExtensionManifest | undefined>(() => {
return availableExtensions.value.find((ext) => ext.id === extension.value?.extensionId)
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 updateExtensionTitle = async () => { const {
await extension.value.setTitle(tempTitle.value) fullscreen,
titleEditMode.value = false fullscreenModalSize: currentExtensionModalSize,
} collapsed,
} = useProvideExtensionHelper(extension, extensionManifest, activeError)
const { fullscreen, collapsed } = useProvideExtensionHelper(extension) const { height } = useElementSize(extensionRef)
const component = ref<any>(null) const component = ref<any>(null)
const extensionManifest = ref<ExtensionManifest | undefined>() const extensionHeight = computed(() => {
const heigthInInt = parseInt(extensionManifest.value?.config?.contentMinHeight || '') || undefined
const fullscreenModalMaxWidth = computed(() => { if (!heigthInInt || height.value > heigthInInt) return `${height.value}px`
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)',
}
return extensionManifest.value?.config?.modalMaxWith return extensionManifest.value?.config?.contentMinHeight
? modalMaxWidth[extensionManifest.value?.config?.modalMaxWith] || modalMaxWidth.lg
: modalMaxWidth.lg
}) })
const expandExtension = () => { const fullscreenModalSize = computed(() => {
if (!collapsed.value) return 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(() => { onMounted(() => {
until(extensionsLoaded) until(extensionsLoaded)
.toMatch((v) => v) .toMatch((v) => v)
.then(() => { .then(() => {
extensionManifest.value = availableExtensions.value.find((ext) => ext.id === extension.value.extensionId)
if (!extensionManifest.value) { if (!extensionManifest.value) {
return return
} }
import(`../../extensions/${extensionManifest.value.entry}/index.vue`).then((mod) => { import(`../../extensions/${extensionManifest.value.entry}/index.vue`).then((mod) => {
component.value = markRaw(mod.default) component.value = markRaw(mod.default)
isLoadedExtension.value = false
}) })
}) })
.catch((err) => { .catch((err) => {
@ -97,9 +78,12 @@ onMounted(() => {
return return
} }
activeError.value = err activeError.value = err
isLoadedExtension.value = false
}) })
}) })
// #Listeners
// close fullscreen on escape key press // close fullscreen on escape key press
useEventListener('keydown', (e) => { useEventListener('keydown', (e) => {
// Check if the event target or its closest parent is an input, select, or textarea // 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) => { eventBus.on((event, payload) => {
if (event === ExtensionsEvents.DUPLICATE && extension.value.id === payload) { if (event === ExtensionsEvents.DUPLICATE && extension.value.id === payload) {
setTimeout(() => { setTimeout(() => {
@ -140,7 +107,7 @@ eventBus.on((event, payload) => {
</script> </script>
<template> <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 <div
class="extension-wrapper" class="extension-wrapper"
:class="[ :class="[
@ -153,7 +120,7 @@ eventBus.on((event, payload) => {
:style=" :style="
!collapsed !collapsed
? { ? {
height: extensionManifest?.config?.contentMinHeight, height: extensionHeight,
minHeight: extensionManifest?.config?.contentMinHeight, minHeight: extensionManifest?.config?.contentMinHeight,
} }
: {} : {}
@ -161,67 +128,7 @@ eventBus.on((event, payload) => {
@mousedown="isMouseDown = true" @mousedown="isMouseDown = true"
@mouseup="isMouseDown = false" @mouseup="isMouseDown = false"
> >
<div <ExtensionsExtensionHeader :is-fullscreen="false" />
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>
<template v-if="activeError"> <template v-if="activeError">
<div <div
@ -262,60 +169,16 @@ eventBus.on((event, payload) => {
:style=" :style="
fullscreen 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 <div
v-show="fullscreen || !collapsed" v-show="fullscreen || !collapsed"
class="extension-content" class="extension-content h-full"
:class="{ 'fullscreen h-[calc(100%-40px)]': fullscreen, 'h-full': !fullscreen }" :class="{ 'fullscreen': fullscreen, 'h-full nc-scrollbar-thin': !fullscreen }"
> >
<component :is="component" :key="extension.uiKey" class="h-full" /> <component :is="component" :key="extension.uiKey" class="h-full" />
</div> </div>
@ -323,6 +186,12 @@ eventBus.on((event, payload) => {
</div> </div>
</Teleport> </Teleport>
</template> </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>
</div> </div>
</template> </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 { .extension-content {
@apply rounded-lg; @apply rounded-lg;
@ -373,10 +219,10 @@ eventBus.on((event, payload) => {
} }
.extension-modal { .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 { .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"> <script setup lang="ts">
interface Props { interface Props {
fullscreen?: boolean isFullscreen?: boolean
activeError?: boolean
} }
const { fullscreen, activeError } = defineProps<Props>() defineProps<Props>()
const emits = defineEmits(['rename', 'duplicate', 'showDetails', 'clearData', 'delete']) const emits = defineEmits(['rename', 'duplicate', 'showDetails', 'clearData', 'delete'])
const { activeError } = useExtensionHelperOrThrow()
</script> </script>
<template> <template>
<div class="flex items-center"> <div class="flex items-center" @click.stop>
<NcDropdown :trigger="['click']" placement="bottomRight"> <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" /> <GeneralIcon icon="threeDotVertical" />
</NcButton> </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> <script lang="ts" setup>
import type { VNodeRef } from '@vue/runtime-core'
interface Prop { interface Prop {
modelValue?: boolean 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 props = defineProps<Prop>()
const emit = defineEmits(['update:modelValue']) 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 vModel = useVModel(props, 'modelValue', emit)
const { availableExtensions, addExtension, getExtensionAssetsUrl, showExtensionDetails } = useExtensions() const { availableExtensions, addExtension, getExtensionAssetsUrl, showExtensionDetails } = useExtensions()
const searchRef: VNodeRef = (el) => {
return el && (el as HTMLInputElement)?.focus()
}
const searchQuery = ref<string>('') const searchQuery = ref<string>('')
const activeTab = ref<TabKeysType>('extensions')
const filteredAvailableExtensions = computed(() => const filteredAvailableExtensions = computed(() =>
(availableExtensions.value || []).filter( (availableExtensions.value || []).filter(
(ext) => (ext) =>
ext.title.toLowerCase().includes(searchQuery.value.toLowerCase()) || ext.title.toLowerCase().includes(searchQuery.value.toLowerCase()?.trim()) ||
ext.subTitle.toLowerCase().includes(searchQuery.value.toLowerCase()), ext.subTitle.toLowerCase().includes(searchQuery.value.toLowerCase()?.trim()),
), ),
) )
@ -30,6 +67,13 @@ const onAddExtension = (ext: any) => {
addExtension(ext) addExtension(ext)
vModel.value = false vModel.value = false
} }
const handleSetActiveTab = (tab: TabItem) => {
if (tab.isDisabled) return
searchQuery.value = ''
activeTab.value = tab.tabKey
}
</script> </script>
<template> <template>
@ -37,27 +81,56 @@ const onAddExtension = (ext: any) => {
v-model:visible="vModel" v-model:visible="vModel"
:class="{ active: vModel }" :class="{ active: vModel }"
:footer="null" :footer="null"
:width="1154" size="lg"
size="medium"
wrap-class-name="nc-modal-extension-market" wrap-class-name="nc-modal-extension-market"
> >
<div class="h-full"> <div class="h-full">
<div class="flex items-center gap-3 p-4 border-b-1 border-gray-200"> <div class="nc-extension-market-header 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
<div class="flex-1 font-semibold text-xl">Extensions Marketplace</div> class="flex items-center gap-3 flex-none"
<NcButton size="small" type="text" @click="vModel = false"> :style="{
<GeneralIcon icon="close" class="text-gray-600" /> width: 'calc(\(100% - 358px - 24px\) / 2)',
</NcButton> }"
</div> >
<GeneralIcon icon="ncPuzzleSolid" class="h-6 w-6 flex-none text-gray-700" />
<div class="flex flex-col h-[calc(100%_-_65px)] px-6 py-4"> <div class="flex-1 font-semibold text-xl">Marketplace</div>
<div class="h-full flex flex-col gap-6 flex-1 pt-2"> </div>
<div class="flex flex max-w-[470px]"> <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 <a-input
:ref="searchRef"
v-model:value="searchQuery" v-model:value="searchQuery"
type="text" type="text"
class="nc-input-border-on-value !h-8 !px-3 !py-1 !rounded-lg" 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 allow-clear
> >
<template #prefix> <template #prefix>
@ -65,50 +138,69 @@ const onAddExtension = (ext: any) => {
</template> </template>
</a-input> </a-input>
</div> </div>
<div <NcButton size="small" type="text" @click="vModel = false">
class="max-h-[calc(100%_-_40px)] flex flex-wrap gap-3 nc-scrollbar-thin pb-2" <GeneralIcon icon="close" class="text-gray-600" />
:class="{ </NcButton>
'h-full': searchQuery && !filteredAvailableExtensions.length && availableExtensions.length, </div>
}" </div>
>
<template v-for="ext of filteredAvailableExtensions" :key="ext.id"> <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 <div
class="nc-market-extension-item flex border-1 rounded-xl p-3 w-[360px] cursor-pointer hover:bg-gray-50 transition-all" v-if="searchQuery && !filteredAvailableExtensions.length && availableExtensions.length"
@click="onExtensionClick(ext.id)" class="w-full h-full flex items-center justify-center"
> >
<div class="h-[60px] w-[60px] overflow-hidden m-auto"> <div class="pb-6 text-gray-500 flex flex-col items-center gap-6 text-center">
<img :src="getExtensionAssetsUrl(ext.iconUrl)" alt="icon" class="w-full h-full object-contain" /> <img
</div> src="~assets/img/placeholder/no-search-result-found.png"
<div class="flex flex-grow flex-col gap-1 ml-3"> class="!w-[164px] flex-none"
<div class="flex justify-between gap-1"> alt="No search results found"
<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"> {{ $t('title.noResultsMatchedYourSearch') }}
<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> </div>
</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> </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>
</div> </div>
</NcModal> </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); 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>
<style lang="scss"> <style lang="scss">
.nc-modal-extension-market { .nc-modal-extension-market {
.nc-modal { .nc-modal {
@apply !p-0; @apply !p-0;
height: min(calc(100vh - 100px), 1024px); }
max-height: min(calc(100vh - 100px), 1024px) !important;
.nc-edit-or-add-integration-left-panel { .nc-extension-market-header {
@apply w-full p-6 flex-1 flex justify-center; .nc-extension-market-header-tab-item {
} @apply relative;
.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; // 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 showSearchBox = ref(false)
const panelSize = computed(() => {
if (isPanelExpanded.value) {
return extensionPanelSize.value
}
return 0
})
const { width } = useElementSize(extensionHeaderRef) const { width } = useElementSize(extensionHeaderRef)
const isOpenSearchBox = computed(() => { const isOpenSearchBox = computed(() => {
@ -54,14 +61,6 @@ const toggleMarket = () => {
isMarketVisible.value = !isMarketVisible.value 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 } }) => { const onMove = async (_event: { moved: { newIndex: number; oldIndex: number; element: ExtensionType } }) => {
let { let {
moved: { newIndex = 0, oldIndex = 0, element }, moved: { newIndex = 0, oldIndex = 0, element },
@ -131,124 +130,130 @@ onMounted(() => {
<template> <template>
<Pane <Pane
v-if="isPanelExpanded" v-show="isPanelExpanded || isReady"
:size="extensionPanelSize" :size="panelSize"
min-size="10%"
max-size="60%" max-size="60%"
class="nc-extension-pane" class="nc-extension-pane"
:style="{ :style="
minWidth: isReady ? '300px' : `${normalizePaneMaxWidth}%`, !isReady
maxWidth: `${normalizePaneMaxWidth}%`, ? {
}" maxWidth: `${extensionPanelSize}%`,
}
: {}
"
> >
<div <Transition name="layout" :duration="150">
ref="extensionHeaderRef" <div v-if="isPanelExpanded" class="flex flex-col h-full">
class="h-[var(--toolbar-height)] flex items-center gap-3 px-4 py-2 border-b-1 border-gray-200 bg-white" <div
> ref="extensionHeaderRef"
<div class="h-[var(--toolbar-height)] flex items-center gap-3 px-4 py-2 border-b-1 border-gray-200 bg-white"
class="flex items-center gap-3 font-weight-700 text-gray-700 text-base" >
:class="{ <div
'flex-1': !isOpenSearchBox, 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"
> >
<template #prefix> <GeneralIcon icon="ncPuzzleSolid" class="h-5 w-5 text-gray-700 opacity-85" />
<GeneralIcon icon="search" class="mr-2 h-4 w-4 text-gray-500 group-hover:text-black" /> <span v-if="!isOpenSearchBox || width >= 507">{{ $t('general.extensions') }}</span>
</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') }}
</div> </div>
</NcButton> <div
</div> class="flex justify-end"
</template> :class="{
<template v-else> 'flex-1': isOpenSearchBox,
<Draggable }"
:model-value="filteredExtensionList" >
draggable=".nc-extension-item" <NcButton v-if="!isOpenSearchBox" size="xs" type="text" class="!px-1" @click="handleShowSearchInput">
item-key="id" <GeneralIcon icon="search" class="flex-none !text-gray-500" />
handle=".nc-extension-drag-handler" </NcButton>
ghost-class="ghost" <div v-else class="flex flex-grow items-center justify-end !max-w-[300px]">
class="nc-extension-list-wrapper flex items-center flex-col gap-3 w-full nc-scrollbar-md py-4" <a-input
:class="{ ref="searchExtensionRef"
'h-full': searchQuery && !filteredExtensionList.length && extensionList.length, v-model:value="searchQuery"
}" type="text"
@start="(e) => e.target.classList.add('grabbing')" class="nc-input-border-on-value !h-7 !px-3 !py-1 !rounded-lg"
@end="(e) => e.target.classList.remove('grabbing')" placeholder="Search Extension"
@change="onMove($event)" allow-clear
> @keydown.esc="handleCloseSearchbox"
<template #item="{ element: ext }"> >
<div class="nc-extension-item w-full"> <template #prefix>
<ExtensionsWrapper :extension-id="ext.id" /> <GeneralIcon icon="search" class="mr-2 h-4 w-4 text-gray-500 group-hover:text-black" />
</template>
</a-input>
</div>
</div> </div>
</template> <NcButton type="secondary" size="xs" @click="toggleMarket">
<template v-if="searchQuery && !filteredExtensionList.length && extensionList.length" #header> <div class="flex items-center gap-1 text-xs max-w-full -ml-3px">
<div class="w-full h-full flex-1 flex items-center justify-center"> <GeneralIcon icon="plus" />
<div class="pb-6 text-gray-500 flex flex-col items-center gap-6 text-center"> {{ $t('general.add') }}
<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>
</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> </div>
</template> </template>
</Draggable> <template v-else>
</template> <Draggable
<ExtensionsMarket v-if="isMarketVisible" v-model="isMarketVisible" /> :model-value="filteredExtensionList"
<ExtensionsDetails draggable=".nc-extension-item"
v-if="isDetailsVisible && detailsExtensionId" item-key="id"
v-model="isDetailsVisible" handle=".nc-extension-drag-handler"
:extension-id="detailsExtensionId" ghost-class="ghost"
:from="detailsFrom" 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> </Pane>
</template> </template>

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

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

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

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

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

@ -161,8 +161,26 @@ watch([activeViewTitleOrId, activeTableId], () => {
handleSidebarOpenOnMobileForNonViews() handleSidebarOpenOnMobileForNonViews()
}) })
const { leftSidebarWidth, windowSize } = storeToRefs(useSidebarStore())
const { isPanelExpanded, extensionPanelSize } = useExtensions() 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 }[]) => { const onResize = (sizes: { min: number; max: number; size: number }[]) => {
if (sizes.length === 2) { if (sizes.length === 2) {
if (!sizes[1].size) return if (!sizes[1].size) return
@ -187,10 +205,10 @@ const onReady = () => {
<Splitpanes <Splitpanes
v-if="openedViewsTab === 'view'" v-if="openedViewsTab === 'view'"
class="nc-extensions-content-resizable-wrapper" class="nc-extensions-content-resizable-wrapper"
@ready="onReady" @ready="() => onReady()"
@resized="onResize" @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" /> <LazySmartsheetToolbar v-if="!isForm" />
<div <div
:style="{ height: isForm || isMobileMode ? '100%' : 'calc(100% - var(--toolbar-height))' }" :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 <div
:tabindex="0" :tabindex="0"
class="source-card focus-visible:outline-none h-full" class="source-card focus-visible:outline-none outline-none h-full"
:class="{ :class="{
'is-available': integration?.isAvailable, 'is-available': integration?.isAvailable,
}" }"
@ -408,7 +408,7 @@ const handleAddIntegration = (category: IntegrationCategoryType, integration: In
@apply flex gap-4 flex-wrap; @apply flex gap-4 flex-wrap;
.source-card { .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 { .integration-icon-wrapper {
@apply flex-none h-[44px] w-[44px] rounded-lg flex items-center justify-center; @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 { ColumnType, ViewType } from 'nocodb-sdk'
import type { ExtensionType } from '#imports' import type { ExtensionManifest, ExtensionType } from '#imports'
const [useProvideExtensionHelper, useExtensionHelper] = useInjectionState((extension: Ref<ExtensionType>) => { const [useProvideExtensionHelper, useExtensionHelper] = useInjectionState(
const { $api } = useNuxtApp() (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({ const showExpandBtn = ref(true)
get: () => extension.value?.meta?.collapsed ?? false,
set: (value) => {
extension.value?.setMeta('collapsed', value)
},
})
const getViewsForTable = async (tableId: string) => { const fullscreenModalSize = ref<keyof typeof modalSizes>(extensionManifest.value?.config?.modalSize || 'lg')
if (viewsByTable.value.has(tableId)) {
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[] return viewsByTable.value.get(tableId) as ViewType[]
} }
await viewStore.loadViews({ tableId, ignoreLoading: true }) const getData = async (params: {
return viewsByTable.value.get(tableId) as ViewType[] tableId: string
} viewId?: string
eachPage: (records: Record<string, any>[], nextPage: () => void) => Promise<void> | void
const getData = async (params: { done: () => Promise<void> | void
tableId: string }) => {
viewId?: string const { tableId, viewId, eachPage, done } = params
eachPage: (records: Record<string, any>[], nextPage: () => void) => Promise<void> | void
done: () => Promise<void> | void let page = 1
}) => {
const { tableId, viewId, eachPage, done } = params const nextPage = async () => {
const { list: records, pageInfo } = await $api.dbViewRow.list(
let page = 1 'noco',
baseId.value!,
const nextPage = async () => { tableId,
const { list: records, pageInfo } = await $api.dbViewRow.list( viewId as string,
'noco', {
baseId.value!, offset: (page - 1) * 100,
tableId, limit: 100,
viewId as string, } as any,
{ )
offset: (page - 1) * 100,
limit: 100, if (pageInfo?.isLastPage) {
} as any, await eachPage(records, () => {})
) await done()
} else {
if (pageInfo?.isLastPage) { page++
await eachPage(records, () => {}) await eachPage(records, nextPage)
await done() }
} else {
page++
await eachPage(records, nextPage)
} }
}
await nextPage() await nextPage()
} }
const getTableMeta = async (tableId: string) => { const getTableMeta = async (tableId: string) => {
return getMeta(tableId) return getMeta(tableId)
} }
const insertData = async (params: { tableId: string; data: Record<string, any> }) => { const insertData = async (params: { tableId: string; data: Record<string, any> }) => {
const { tableId, data } = params const { tableId, data } = params
const chunks = [] const chunks = []
let inserted = 0 let inserted = 0
// chunk data into 100 records // chunk data into 100 records
for (let i = 0; i < data.length; i += 100) { for (let i = 0; i < data.length; i += 100) {
chunks.push(data.slice(i, i + 100)) chunks.push(data.slice(i, i + 100))
} }
for (const chunk of chunks) { for (const chunk of chunks) {
inserted += chunk.length inserted += chunk.length
await $api.dbDataTableRow.create(tableId, chunk) await $api.dbDataTableRow.create(tableId, chunk)
} }
return { return {
inserted, inserted,
}
} }
}
const updateData = async (params: { tableId: string; data: Record<string, any> }) => { const updateData = async (params: { tableId: string; data: Record<string, any> }) => {
const { tableId, data } = params const { tableId, data } = params
const chunks = [] const chunks = []
let updated = 0 let updated = 0
// chunk data into 100 records // chunk data into 100 records
for (let i = 0; i < data.length; i += 100) { for (let i = 0; i < data.length; i += 100) {
chunks.push(data.slice(i, i + 100)) chunks.push(data.slice(i, i + 100))
} }
for (const chunk of chunks) { for (const chunk of chunks) {
updated += chunk.length updated += chunk.length
await $api.dbDataTableRow.update(tableId, chunk) await $api.dbDataTableRow.update(tableId, chunk)
} }
return { return {
updated, updated,
}
} }
}
const upsertData = async (params: { tableId: string; data: Record<string, any>; upsertField: ColumnType }) => { const upsertData = async (params: {
const { tableId, data, upsertField } = 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) { for (let i = 0; i < data.length; i += chunkSize) {
chunks.push(data.slice(i, i + chunkSize)) chunks.push(data.slice(i, i + chunkSize))
} }
const insert = [] const insert = []
const update = [] const update = []
let insertCounter = 0 let insertCounter = 0
let updateCounter = 0 let updateCounter = 0
for (const chunk of chunks) { for (const chunk of chunks) {
// select chunk of data to determine if it's an insert or update // select chunk of data to determine if it's an insert or update
const { list } = await $api.dbDataTableRow.list(tableId, { const { list } = await $api.dbDataTableRow.list(tableId, {
where: `(${upsertField.title},in,${chunk.map((record: Record<string, any>) => record[upsertField.title!]).join(',')})`, where: `(${upsertField.title},in,${chunk.map((record: Record<string, any>) => record[upsertField.title!]).join(',')})`,
limit: chunkSize, limit: chunkSize,
}) })
insert.push( if (params.importType !== 'update') {
...chunk.filter( insert.push(
(record: Record<string, any>) => ...chunk.filter(
!list.some((r: Record<string, any>) => `${r[upsertField.title!]}` === `${record[upsertField.title!]}`), (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!]}`),
) )
.map((record: Record<string, any>) => { }
const existingRecord = list.find(
(r: Record<string, any>) => `${r[upsertField.title!]}` === `${record[upsertField.title!]}`, if (params.importType !== 'insert') {
) update.push(
return { ...chunk
...rowPkData(existingRecord!, tableMeta.columns!), .filter((record: Record<string, any>) =>
...record, 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) { if (insert.length) {
insertCounter += insert.length insertCounter += insert.length
while (insert.length) { while (insert.length) {
await $api.dbDataTableRow.create(tableId, insert.splice(0, chunkSize)) await $api.dbDataTableRow.create(tableId, insert.splice(0, chunkSize))
}
} }
}
if (update.length) { if (update.length) {
updateCounter += update.length updateCounter += update.length
while (update.length) { while (update.length) {
await $api.dbDataTableRow.update(tableId, update.splice(0, chunkSize)) 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 reloadMeta = () => {
} eventBus.emit(SmartsheetStoreEvents.FIELD_RELOAD)
}
const reloadData = () => {
eventBus.emit(SmartsheetStoreEvents.DATA_RELOAD) return {
} $api,
fullscreen,
const reloadMeta = () => { collapsed,
eventBus.emit(SmartsheetStoreEvents.FIELD_RELOAD) extension,
} extensionManifest,
activeError,
return { tables,
$api, showExpandBtn,
fullscreen, fullscreenModalSize,
collapsed, getViewsForTable,
extension, getData,
tables, getTableMeta,
getViewsForTable, insertData,
getData, updateData,
getTableMeta, upsertData,
insertData, reloadData,
updateData, reloadMeta,
upsertData, }
reloadData, },
reloadMeta, 'extension-helper',
} )
}, 'extension-helper')
export { useProvideExtensionHelper } export { useProvideExtensionHelper }

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

@ -19,12 +19,23 @@ export interface ExtensionManifest {
entry: string entry: string
version: string version: string
iconUrl: string iconUrl: string
publisherName: string publisher: {
publisherEmail: string name: string
publisherUrl: string email: string
url: string
icon?: {
src: string
width?: number
height?: number
}
}
disabled?: boolean disabled?: boolean
config?: { links: {
modalMaxWith?: 'xs' | 'sm' | 'md' | 'lg' title: string
href: string
}[]
config: {
modalSize?: 'xs' | 'sm' | 'md' | 'lg'
contentMinHeight?: string contentMinHeight?: string
} }
} }
@ -375,6 +386,17 @@ export const useExtensions = createSharedComposable(() => {
const mod = (await modules[path]()) as any const mod = (await modules[path]()) as any
const manifest = mod.default as ExtensionManifest 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) { if (manifest?.disabled !== true) {
availableExtensions.value.push(manifest) 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> </br> </br>
**Key Features** **Key Features**
- Easy CSV downloads for specific tables and views - Easy CSV downloads for specific tables and views
- Asynchronous processing for seamless operation - Asynchronous processing for seamless operation
- Instant notifications when the file is ready for download - Instant notifications when the file is ready for download
</br> </br> </br> </br>
Elevate your data handling capabilities with the NocoDB Data Exporter extension! 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() { function titleHelper() {
const table = tables.value.find((t) => t.id === exportPayload.value.tableId) const table = tables.value.find((t) => t.id === exportPayload.value.tableId)
const view = views.value.find((v) => v.id === exportPayload.value.viewId) const view = views.value.find((v) => v.id === exportPayload.value.viewId)
@ -184,154 +203,164 @@ onMounted(() => {
</script> </script>
<template> <template>
<div ref="dataExporterRef" class="data-exporter"> <ExtensionsExtensionWrapper>
<div class="pb-3 flex items-center justify-between gap-2.5 flex-wrap"> <div
<div ref="dataExporterRef"
class="flex-1 flex items-center" class="data-exporter"
:class="{ :class="{
'max-w-[min(350px,calc(100%-124px))]': isExporting && !fullscreen && width > 325, 'p-4': fullscreen,
'max-w-[min(350px,calc(100%_-_84px))]': !isExporting && !fullscreen && width > 325, 'p-3': !fullscreen,
'max-w-full': width <= 325, }"
'max-w-[900px]': fullscreen, >
}" <div class="pb-3 flex items-center justify-between gap-2.5 flex-wrap">
> <div
<NcSelect class="flex-1 flex items-center"
v-model:value="exportPayload.tableId"
placeholder="-select table-"
:disabled="isExporting"
class="nc-data-exporter-table-select"
:class="{ :class="{
'flex-1 max-w-[240px]': fullscreen, 'max-w-[min(350px,calc(100%-124px))]': isExporting && !fullscreen && width > 325,
'min-w-1/2 max-w-[175px]': !fullscreen, '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 <div
v-if="exp.status === JobStatus.COMPLETED ? exp.result : true" class="flex-1 flex items-center border-1 border-gray-200 rounded-lg focus-within:(border-brand-500 shadow-selected) transition-colors transition-shadow"
: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,
}"
> >
<div <NcSelect
class="flex-1 flex items-center gap-3" v-model:value="exportPayload.tableId"
placeholder="-select table-"
:disabled="isExporting"
class="nc-data-exporter-table-select"
:class="{ :class="{
'max-w-[calc(100%_-_74px)]': exp.status === JobStatus.COMPLETED, 'flex-1 max-w-[240px]': fullscreen,
'max-w-[calc(100%_-_38px)]': exp.status !== JobStatus.COMPLETED, '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"> <a-select-option v-for="table of tableList" :key="table.label" :value="table.value">
<template #title> <div class="w-full flex items-center gap-2">
{{ jobStatusTooltip[exp.status] }} <div class="min-w-5 flex items-center justify-center">
</template> <GeneralTableIcon :meta="{ meta: table.meta }" class="text-gray-500" />
<GeneralIcon </div>
:icon="exp.status === JobStatus.COMPLETED ? 'circleCheck2' : 'alertTriangle'" <NcTooltip class="flex-1 truncate" show-on-truncate-only>
class="flex-none h-4 w-4" <template #title>{{ table.label }}</template>
:class="{ <span>{{ table.label }}</span>
'!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() }}
</NcTooltip> </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>
<div v-if="exp.result.timestamp" class="text-[10px] leading-4 text-gray-600"> </a-select-option>
<NcTooltip class="truncate" show-on-truncate-only> </NcSelect>
<template #title> <div class="flex-none h-8 border-l-1 border-gray-200"></div>
{{ dayjs(exp.result.timestamp).format('MM/DD/YYYY [at] hh:mm A') }}
</template> <NcSelect
{{ dayjs(exp.result.timestamp).format('MM/DD/YYYY [at] hh:mm A') }} 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> </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>
</div>
<div v-if="exp.status === JobStatus.COMPLETED" class="flex items-center"> <div v-if="exp.status === JobStatus.COMPLETED" class="flex" @click="handleDownload(urlHelper(exp.result.url))">
<a :href="urlHelper(exp.result.url)" target="_blank"> <NcTooltip class="flex">
<NcTooltip class="flex items-center">
<template #title> <template #title>
{{ $t('general.download') }} {{ $t('general.download') }}
</template> </template>
@ -342,45 +371,48 @@ onMounted(() => {
</div> </div>
</NcButton> </NcButton>
</NcTooltip> </NcTooltip>
</a> </div>
</div>
<div class="flex items-center"> <div class="flex">
<NcTooltip class="flex"> <NcTooltip class="flex">
<template #title> <template #title>
{{ $t('general.remove') }} {{ $t('general.remove') }}
</template> </template>
<NcButton type="text" size="xs" class="!px-[5px]" @click="onRemoveExportedFile(exp.id)"> <NcButton type="text" size="xs" class="!px-[5px]" @click="onRemoveExportedFile(exp.id)">
<GeneralIcon icon="close" /> <GeneralIcon icon="close" />
</NcButton> </NcButton>
</NcTooltip> </NcTooltip>
</div>
</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>
<div v-else class="px-3 py-2 flex-1 flex items-center justify-center text-gray-600">No exports</div>
</div> </div>
</div> </ExtensionsExtensionWrapper>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
:deep(.extension-content-container) {
@apply !p-0;
}
.data-exporter { .data-exporter {
@apply flex flex-col overflow-hidden h-full; @apply flex flex-col overflow-hidden h-full;
.data-exporter-header { .data-exporter-header {
@apply px-3 py-1 bg-gray-100 text-[11px] leading-4 text-gray-600 border-b-1; @apply px-3 py-1 bg-gray-100 text-[11px] leading-4 text-gray-600 border-b-1;
} }
.nc-data-exporter-table-select { // .nc-data-exporter-table-select {
:deep(.ant-select-selector) { // :deep(.ant-select-selector) {
@apply !border-r-[0.5px] rounded-lg !rounded-r-none shadow-none; // @apply !border-r-1 rounded-lg !rounded-r-none shadow-none;
} // }
} // }
.nc-data-exporter-view-select { // .nc-data-exporter-view-select {
:deep(.ant-select-selector) { // :deep(.ant-select-selector) {
@apply !border-l-[0.5px] rounded-lg !rounded-l-none shadow-none; // @apply !border-l-0 rounded-lg !rounded-l-none shadow-none;
} // }
} // }
.data-exporter-body { .data-exporter-body {
@apply flex-1 rounded-lg border-1 overflow-hidden; @apply flex-1 rounded-lg border-1 overflow-hidden;
@ -391,3 +423,9 @@ onMounted(() => {
} }
} }
</style> </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", "entry": "data-exporter",
"version": "0.1", "version": "0.1",
"iconUrl": "data-exporter/assets/icon.svg", "iconUrl": "data-exporter/assets/icon.svg",
"publisherName": "NocoDB", "publisher": {
"publisherEmail": "contact@nocodb.com", "name": "NocoDB",
"publisherUrl": "https://www.nocodb.com", "email": "contact@nocodb.com",
"url": "https://www.nocodb.com",
"icon": {
"src": "csv-import-ee/assets/publisher-icon.svg",
"width": 24,
"height": 24
}
},
"links": [],
"config": { "config": {
"modalMaxWith": "sm", "modalSize": "sm",
"contentMinHeight": "310px" "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. It is used to demonstrate how to create a NocoDB extension.
</br> </br> </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> </script>
<template> <template>
<div class="flex flex-col gap-2 p-2 border-1 rounded-lg"> <ExtensionsExtensionWrapper>
<NcSelect v-model:value="exportPayload.tableId" :options="tableList" placeholder="-select table-" @change="onTableSelect" /> <div class="flex flex-col gap-2 p-2 border-1 rounded-lg">
<NcSelect v-model:value="exportPayload.viewId" :options="viewList" placeholder="-select view-" @change="onViewSelect" /> <NcSelect v-model:value="exportPayload.tableId" :options="tableList" placeholder="-select table-" @change="onTableSelect" />
<NcButton @click="exportJson">Export</NcButton> <NcSelect v-model:value="exportPayload.viewId" :options="viewList" placeholder="-select view-" @change="onViewSelect" />
</div> <NcButton @click="exportJson">Export</NcButton>
</div>
</ExtensionsExtensionWrapper>
</template> </template>
<style lang="scss"></style> <style lang="scss"></style>

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

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

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

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

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

@ -59,7 +59,7 @@ export const useBases = defineStore('basesStore', () => {
const isProjectsLoading = ref(false) const isProjectsLoading = ref(false)
async function getBaseUsers({ baseId, searchText, force = false }: { baseId: string; searchText?: string; force?: boolean }) { 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)) { if (!force && basesUser.value.has(baseId)) {
const users = basesUser.value.get(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 NcTwitter from '~icons/nc-icons/twitter'
import NcFile from '~icons/nc-icons/file' import NcFile from '~icons/nc-icons/file'
import NcFileBig from '~icons/nc-icons/file-big'
import NcSettings from '~icons/nc-icons/settings' import NcSettings from '~icons/nc-icons/settings'
import NcHelp from '~icons/nc-icons/help' import NcHelp from '~icons/nc-icons/help'
import NcAlertTriangle from '~icons/nc-icons/alert-triangle' 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 NcPuzzleSolid from '~icons/nc-icons/puzzle-solid'
import NcPuzzleOutline from '~icons/nc-icons/puzzle-outline' 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 // keep it for reference
// todo: remove it after all icons are migrated // todo: remove it after all icons are migrated
/* export const iconMapOld = { /* export const iconMapOld = {
@ -983,6 +989,7 @@ export const iconMap = {
ncReddit: NcReddit, ncReddit: NcReddit,
ncTwitter: NcTwitter, ncTwitter: NcTwitter,
file: NcFile, file: NcFile,
fileBig: NcFileBig,
ncSettings: NcSettings, ncSettings: NcSettings,
ncHelp: NcHelp, ncHelp: NcHelp,
puzzle: MdiPuzzle, puzzle: MdiPuzzle,
@ -1327,6 +1334,10 @@ export const iconMap = {
langC: NcLangC, langC: NcLangC,
ncPuzzleSolid: NcPuzzleSolid, ncPuzzleSolid: NcPuzzleSolid,
ncPuzzleOutline: NcPuzzleOutline, ncPuzzleOutline: NcPuzzleOutline,
ncInfoSolid: NcInfoSolid,
ncPlaceholderIcon: NcPlaceholderIcon,
ncSpanner: NcSpanner,
ncScript: NcScript,
} }
export const getMdiIcon = (type: string): any => { 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({ export default defineConfig({
extract: { 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'], exclude: ['node_modules', '.git'],
}, },

Loading…
Cancel
Save