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. 86
      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. 198
      packages/nc-gui/components/extensions/Market.vue
  12. 41
      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. 28
      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. 98
      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. 11
      packages/nc-gui/extensions/json-exporter/description.md
  29. 2
      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

86
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,32 +102,66 @@ 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">
<img
v-if="activeExtension.publisher?.icon?.src"
:src="getExtensionAssetsUrl(activeExtension.publisher.icon.src)"
alt="Publisher icon"
class="object-contain flex-none"
:style="{
width: activeExtension.publisher?.icon?.width ? `${activeExtension.publisher?.icon?.width}px` : '24px',
height: activeExtension.publisher?.icon?.height ? `${activeExtension.publisher?.icon?.height}px` : '24px',
}"
/>
<div class="extension-details-right-subtitle">{{ activeExtension.publisher.name }}</div>
</div> </div>
<template v-if="activeExtension.publisherEmail"> <div class="flex items-center gap-3 text-sm font-semibold text-nc-content-brand">
<NcDivider /> <a
<div class="extension-details-right-section"> v-if="activeExtension.publisher?.url"
<div class="extension-details-right-title">Publisher Email</div> :href="activeExtension.publisher.url"
<div class="extension-details-right-subtitle"> target="_blank"
<a :href="`mailto:${activeExtension.publisherEmail}`" target="_blank" rel="noopener noreferrer"> rel="noopener noreferrer"
{{ activeExtension.publisherEmail }} 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>
</template>
</div> </div>
</div> </div>
</template> <template v-if="activeExtension.links && activeExtension.links.length">
<template v-if="activeExtension.publisherUrl">
<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">
<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> </a>
</div> </div>
</div> </div>
</div>
</template> </template>
</div> </div>
</div> </div>
@ -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>

198
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">
<div
class="flex items-center gap-3 flex-none"
:style="{
width: 'calc(\(100% - 358px - 24px\) / 2)',
}"
>
<GeneralIcon icon="ncPuzzleSolid" class="h-6 w-6 flex-none text-gray-700" /> <GeneralIcon icon="ncPuzzleSolid" class="h-6 w-6 flex-none text-gray-700" />
<div class="flex-1 font-semibold text-xl">Extensions Marketplace</div> <div class="flex-1 font-semibold text-xl">Marketplace</div>
<NcButton size="small" type="text" @click="vModel = false">
<GeneralIcon icon="close" class="text-gray-600" />
</NcButton>
</div> </div>
<div class="flex bg-nc-bg-gray-medium rounded-lg p-1">
<div class="flex flex-col h-[calc(100%_-_65px)] px-6 py-4"> <div class="flex items-center">
<div class="h-full flex flex-col gap-6 flex-1 pt-2"> <NcTooltip
<div class="flex flex max-w-[470px]"> 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,33 +138,44 @@ const onAddExtension = (ext: any) => {
</template> </template>
</a-input> </a-input>
</div> </div>
<NcButton size="small" type="text" @click="vModel = false">
<GeneralIcon icon="close" class="text-gray-600" />
</NcButton>
</div>
</div>
<div class="flex flex-col h-[calc(100%_-_65px)]">
<div v-if="activeTab === 'extensions'" class="h-full py-4">
<div class="h-full flex flex-col gap-6 flex-1 pt-2 px-6 max-w-[900px] w-full mx-auto">
<div v-if="searchQuery" class="text-base text-nc-content-gray-subtle">Search result for {{ searchQuery }}</div>
<div <div
class="max-h-[calc(100%_-_40px)] flex flex-wrap gap-3 nc-scrollbar-thin pb-2" class="flex flex-wrap gap-6 overflow-auto nc-scrollbar-thin pb-2"
:class="{ :class="{
'h-full': searchQuery && !filteredAvailableExtensions.length && availableExtensions.length, 'h-full': searchQuery && !filteredAvailableExtensions.length && availableExtensions.length,
}" }"
> >
<template v-for="ext of filteredAvailableExtensions" :key="ext.id"> <template v-for="ext of filteredAvailableExtensions" :key="ext.id">
<div <div
class="nc-market-extension-item flex border-1 rounded-xl p-3 w-[360px] cursor-pointer hover:bg-gray-50 transition-all" 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)" @click="onExtensionClick(ext.id)"
> >
<div class="h-[60px] w-[60px] overflow-hidden m-auto"> <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" /> <img :src="getExtensionAssetsUrl(ext.iconUrl)" alt="icon" class="w-full h-full object-contain" />
</div> </div>
<div class="flex flex-grow flex-col gap-1 ml-3"> <div class="flex-1 flex flex-grow flex-col gap-1">
<div class="flex justify-between gap-1"> <div class="font-weight-600 text-base line-clamp-1">
<div class="font-weight-600 text-base">{{ ext.title }}</div> {{ ext.title }}
<NcButton size="xsmall" type="secondary" class="!px-7px" @click.stop="onAddExtension(ext)"> </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"> <div class="flex items-center gap-1 -ml-3px text-small">
<GeneralIcon icon="plus" /> <GeneralIcon icon="plus" />
{{ $t('general.install') }} {{ $t('general.add') }}
</div> </div>
</NcButton> </NcButton>
</div> </div>
<div class="w-[250px] h-[32px] text-xs text-gray-500 line-clamp-2">{{ ext.subTitle }}</div>
</div>
</div>
</template> </template>
<div <div
v-if="searchQuery && !filteredAvailableExtensions.length && availableExtensions.length" v-if="searchQuery && !filteredAvailableExtensions.length && availableExtensions.length"
@ -110,6 +194,14 @@ const onAddExtension = (ext: any) => {
</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>
</NcModal> </NcModal>
</template> </template>
@ -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-extension-market-header {
.nc-extension-market-header-tab-item {
@apply relative;
// Add vertical line to all items except the last one
&:not(:last-child)::after {
@apply absolute right-0 top-[4px] h-[16px] w-[1px] bg-nc-bg-gray-dark; // Use WindiCSS utilities for line
content: '';
transform: scaleY(0); // Hide by default
transition: transform 0.18s;
}
// Handle lines visibility based on selection
&.selected {
box-shadow: 0px 3px 1px -2px rgba(0, 0, 0, 0.06), 0px 5px 3px -2px rgba(0, 0, 0, 0.02);
}
// Ensure lines are shown between non-selected items
&:not(.selected)::after {
transform: scaleY(1);
}
.nc-edit-or-add-integration-left-panel { // If supported, this will hide the line to the right of the selected item
@apply w-full p-6 flex-1 flex justify-center; &:has(+ .selected)::after {
transform: scaleY(0); // Hide the line on the right of the selected item
} }
.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;
} }
} }
} }

41
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,16 +130,20 @@ 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}%`,
}
: {}
"
> >
<Transition name="layout" :duration="150">
<div v-if="isPanelExpanded" class="flex flex-col h-full">
<div <div
ref="extensionHeaderRef" ref="extensionHeaderRef"
class="h-[var(--toolbar-height)] flex items-center gap-3 px-4 py-2 border-b-1 border-gray-200 bg-white" class="h-[var(--toolbar-height)] flex items-center gap-3 px-4 py-2 border-b-1 border-gray-200 bg-white"
@ -152,7 +155,7 @@ onMounted(() => {
}" }"
> >
<GeneralIcon icon="ncPuzzleSolid" class="h-5 w-5 text-gray-700 opacity-85" /> <GeneralIcon icon="ncPuzzleSolid" class="h-5 w-5 text-gray-700 opacity-85" />
<span v-if="!isOpenSearchBox || width >= 507">Extensions</span> <span v-if="!isOpenSearchBox || width >= 507">{{ $t('general.extensions') }}</span>
</div> </div>
<div <div
class="flex justify-end" class="flex justify-end"
@ -182,7 +185,7 @@ onMounted(() => {
<NcButton type="secondary" size="xs" @click="toggleMarket"> <NcButton type="secondary" size="xs" @click="toggleMarket">
<div class="flex items-center gap-1 text-xs max-w-full -ml-3px"> <div class="flex items-center gap-1 text-xs max-w-full -ml-3px">
<GeneralIcon icon="plus" /> <GeneralIcon icon="plus" />
{{ $t('general.install') }} {{ $t('general.add') }}
</div> </div>
</NcButton> </NcButton>
</div> </div>
@ -195,7 +198,7 @@ onMounted(() => {
<NcButton size="small" @click="toggleMarket"> <NcButton size="small" @click="toggleMarket">
<div class="flex items-center gap-1 -ml-3px"> <div class="flex items-center gap-1 -ml-3px">
<GeneralIcon icon="plus" /> <GeneralIcon icon="plus" />
{{ $t('general.install') }} {{ $t('general.add') }} {{ $t('general.extension') }}
</div> </div>
</NcButton> </NcButton>
<!-- Todo: add docs link --> <!-- Todo: add docs link -->
@ -249,6 +252,8 @@ onMounted(() => {
:extension-id="detailsExtensionId" :extension-id="detailsExtensionId"
:from="detailsFrom" :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;

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

@ -1,7 +1,8 @@
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(
(extension: Ref<ExtensionType>, extensionManifest: ComputedRef<ExtensionManifest | undefined>, activeError: Ref<any>) => {
const { $api } = useNuxtApp() const { $api } = useNuxtApp()
const basesStore = useBases() const basesStore = useBases()
@ -22,6 +23,10 @@ const [useProvideExtensionHelper, useExtensionHelper] = useInjectionState((exten
const fullscreen = ref(false) const fullscreen = ref(false)
const showExpandBtn = ref(true)
const fullscreenModalSize = ref<keyof typeof modalSizes>(extensionManifest.value?.config?.modalSize || 'lg')
const collapsed = computed({ const collapsed = computed({
get: () => extension.value?.meta?.collapsed ?? false, get: () => extension.value?.meta?.collapsed ?? false,
set: (value) => { set: (value) => {
@ -120,7 +125,12 @@ const [useProvideExtensionHelper, useExtensionHelper] = useInjectionState((exten
} }
} }
const upsertData = async (params: { tableId: string; data: Record<string, any>; upsertField: ColumnType }) => { const upsertData = async (params: {
tableId: string
data: Record<string, any>
upsertField: ColumnType
importType: 'insert' | 'update' | 'insertAndUpdate'
}) => {
const { tableId, data, upsertField } = params const { tableId, data, upsertField } = params
const chunkSize = 100 const chunkSize = 100
@ -148,13 +158,16 @@ const [useProvideExtensionHelper, useExtensionHelper] = useInjectionState((exten
limit: chunkSize, limit: chunkSize,
}) })
if (params.importType !== 'update') {
insert.push( insert.push(
...chunk.filter( ...chunk.filter(
(record: Record<string, any>) => (record: Record<string, any>) =>
!list.some((r: Record<string, any>) => `${r[upsertField.title!]}` === `${record[upsertField.title!]}`), !list.some((r: Record<string, any>) => `${r[upsertField.title!]}` === `${record[upsertField.title!]}`),
), ),
) )
}
if (params.importType !== 'insert') {
update.push( update.push(
...chunk ...chunk
.filter((record: Record<string, any>) => .filter((record: Record<string, any>) =>
@ -171,6 +184,7 @@ const [useProvideExtensionHelper, useExtensionHelper] = useInjectionState((exten
}), }),
) )
} }
}
if (insert.length) { if (insert.length) {
insertCounter += insert.length insertCounter += insert.length
@ -202,7 +216,11 @@ const [useProvideExtensionHelper, useExtensionHelper] = useInjectionState((exten
fullscreen, fullscreen,
collapsed, collapsed,
extension, extension,
extensionManifest,
activeError,
tables, tables,
showExpandBtn,
fullscreenModalSize,
getViewsForTable, getViewsForTable,
getData, getData,
getTableMeta, getTableMeta,
@ -212,7 +230,9 @@ const [useProvideExtensionHelper, useExtensionHelper] = useInjectionState((exten
reloadData, reloadData,
reloadMeta, 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>

98
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,7 +203,15 @@ onMounted(() => {
</script> </script>
<template> <template>
<div ref="dataExporterRef" class="data-exporter"> <ExtensionsExtensionWrapper>
<div
ref="dataExporterRef"
class="data-exporter"
:class="{
'p-4': fullscreen,
'p-3': !fullscreen,
}"
>
<div class="pb-3 flex items-center justify-between gap-2.5 flex-wrap"> <div class="pb-3 flex items-center justify-between gap-2.5 flex-wrap">
<div <div
class="flex-1 flex items-center" class="flex-1 flex items-center"
@ -192,8 +219,11 @@ onMounted(() => {
'max-w-[min(350px,calc(100%-124px))]': isExporting && !fullscreen && width > 325, 'max-w-[min(350px,calc(100%-124px))]': isExporting && !fullscreen && width > 325,
'max-w-[min(350px,calc(100%_-_84px))]': !isExporting && !fullscreen && width > 325, 'max-w-[min(350px,calc(100%_-_84px))]': !isExporting && !fullscreen && width > 325,
'max-w-full': width <= 325, 'max-w-full': width <= 325,
'max-w-[900px]': fullscreen, 'max-w-[480px]': fullscreen,
}" }"
>
<div
class="flex-1 flex items-center border-1 border-gray-200 rounded-lg focus-within:(border-brand-500 shadow-selected) transition-colors transition-shadow"
> >
<NcSelect <NcSelect
v-model:value="exportPayload.tableId" v-model:value="exportPayload.tableId"
@ -204,6 +234,7 @@ onMounted(() => {
'flex-1 max-w-[240px]': fullscreen, 'flex-1 max-w-[240px]': fullscreen,
'min-w-1/2 max-w-[175px]': !fullscreen, 'min-w-1/2 max-w-[175px]': !fullscreen,
}" }"
:bordered="false"
:filter-option="filterOption" :filter-option="filterOption"
dropdown-class-name="w-[250px]" dropdown-class-name="w-[250px]"
show-search show-search
@ -227,6 +258,7 @@ onMounted(() => {
</div> </div>
</a-select-option> </a-select-option>
</NcSelect> </NcSelect>
<div class="flex-none h-8 border-l-1 border-gray-200"></div>
<NcSelect <NcSelect
v-model:value="exportPayload.viewId" v-model:value="exportPayload.viewId"
@ -237,6 +269,7 @@ onMounted(() => {
'flex-1 max-w-[240px]': fullscreen, 'flex-1 max-w-[240px]': fullscreen,
'min-w-1/2 max-w-[175px]': !fullscreen, 'min-w-1/2 max-w-[175px]': !fullscreen,
}" }"
:bordered="false"
dropdown-class-name="w-[250px]" dropdown-class-name="w-[250px]"
:filter-option="filterOption" :filter-option="filterOption"
show-search show-search
@ -260,6 +293,7 @@ onMounted(() => {
</div> </a-select-option </div> </a-select-option
></NcSelect> ></NcSelect>
</div> </div>
</div>
<div class="flex-none flex justify-end"> <div class="flex-none flex justify-end">
<NcTooltip class="flex" placement="topRight" :disabled="!isExporting"> <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> <template #title> The CSV file is being prepared in the background. You'll be notified once it's ready. </template>
@ -276,14 +310,14 @@ onMounted(() => {
<div <div
v-if="exp.status === JobStatus.COMPLETED ? exp.result : true" v-if="exp.status === JobStatus.COMPLETED ? exp.result : true"
:key="exp.id" :key="exp.id"
class="px-3 py-2 flex gap-2 justify-between border-b-1 hover:bg-gray-50" class="p-3 flex gap-2 justify-between border-b-1 hover:bg-gray-50"
:class="{ :class="{
'px-4 py-3': fullscreen, 'px-4 py-3': fullscreen,
'px-3 py-2': !fullscreen, 'px-3 py-2': !fullscreen,
}" }"
> >
<div <div
class="flex-1 flex items-center gap-3" class="flex-1 flex items-start gap-3"
:class="{ :class="{
'max-w-[calc(100%_-_74px)]': exp.status === JobStatus.COMPLETED, 'max-w-[calc(100%_-_74px)]': exp.status === JobStatus.COMPLETED,
'max-w-[calc(100%_-_38px)]': exp.status !== JobStatus.COMPLETED, 'max-w-[calc(100%_-_38px)]': exp.status !== JobStatus.COMPLETED,
@ -294,11 +328,11 @@ onMounted(() => {
{{ jobStatusTooltip[exp.status] }} {{ jobStatusTooltip[exp.status] }}
</template> </template>
<GeneralIcon <GeneralIcon
:icon="exp.status === JobStatus.COMPLETED ? 'circleCheck2' : 'alertTriangle'" :icon="exp.status === JobStatus.COMPLETED ? 'circleCheckSolid' : 'alertTriangleSolid'"
class="flex-none h-4 w-4" class="flex-none h-5 w-5"
:class="{ :class="{
'!text-green-500': exp.status === JobStatus.COMPLETED, '!text-green-700': exp.status === JobStatus.COMPLETED,
'!text-red-500': exp.status === JobStatus.FAILED, '!text-red-700': exp.status === JobStatus.FAILED,
}" }"
/> />
</NcTooltip> </NcTooltip>
@ -318,20 +352,15 @@ onMounted(() => {
{{ exp.result.title || titleHelper() }} {{ exp.result.title || titleHelper() }}
</NcTooltip> </NcTooltip>
</div> </div>
<div v-if="exp.result.timestamp" class="text-[10px] leading-4 text-gray-600">
<NcTooltip class="truncate" show-on-truncate-only> <div v-if="exp.result.timestamp" name="error" class="text-small leading-[18px] text-nc-content-gray-muted">
<template #title> {{ timeAgo(dayjs(exp.result.timestamp).toString()) }}
{{ dayjs(exp.result.timestamp).format('MM/DD/YYYY [at] hh:mm A') }}
</template>
{{ dayjs(exp.result.timestamp).format('MM/DD/YYYY [at] hh:mm A') }}
</NcTooltip>
</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,10 +371,9 @@ 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') }}
@ -362,25 +390,29 @@ onMounted(() => {
<div v-else class="px-3 py-2 flex-1 flex items-center justify-center text-gray-600">No exports</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> </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

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

@ -3,3 +3,14 @@ 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>

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

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