Browse Source

Nc Fix: Update extension UI as per new design (#9306)

* fix(nc-gui): show extension as floating

* chore(nc-gui): add puzzle icon

* fix(nc-gui): toolbar searchbox auto close issue on clearing input value

* fix(nc-gui): update extension header

* fix(nc-gui): update extension panel as per new design

* fix(nc-gui): update extension icons

* fix(nc-gui): mionr extension panel corrections

* feat(nc-gui): extension reorder support

* fix(nc-gui): extension reorder issue

* fix(nc-gui): extension min height issue

* fix(nc-gui): extension error screen

* fix(nc-gui): extension fullscreen padding

* fix(nc-gui): update extension marketplace modal

* fix(nc-gui): update extension details modal

* fix(nc-gui): some changes

* fix(nc-gui): some review changes

* fix(nc-gui): some review changes

* fix(nc-gui): update extension header

* fix(nc-gui): update extension title input box

* fix(nc-gui): enable extension is cloud

* fix(nc-gui): add extension subtitle support

* feat(nc-gui): allow user to add extension description in markdown file

* fix(nc-gui): add cmdk topbar btn in all screens

* fix(nc-gui): meta.glob() as option deprecated warning

* fix(nc-gui): minor changes

* fix(nc-gui): make cmdk icon smaller

* fix(nc-gui): monor review changes

* misc: alignment & text for desc

* fix(nc-gui): extension review changes

* fix(nc-gui): extension topbar btn ui updates

* fix(nc-gui): update extension header icon size

* fix(nc-gui): use description md file instead of plain text in manifest

* fix(nc-gui): update extension modal size

* fix(nc-gui): cmdk test update

* chore(nc-gui): lint

* fix(nc-gui): minor changes

* fix(nc-gui): update extension details panel gap

* fix: minor alignments

---------

Co-authored-by: Raju Udava <86527202+dstala@users.noreply.github.com>
pull/9324/head
Ramesh Mane 3 months ago committed by GitHub
parent
commit
e808ef4a15
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 10
      packages/nc-gui/assets/nc-icons/puzzle-outline.svg
  2. 4
      packages/nc-gui/assets/nc-icons/puzzle-solid.svg
  3. 2
      packages/nc-gui/components/dashboard/Sidebar/TopSection.vue
  4. 212
      packages/nc-gui/components/extensions/Details.vue
  5. 176
      packages/nc-gui/components/extensions/Extension.vue
  6. 12
      packages/nc-gui/components/extensions/ExtensionMenu.vue
  7. 72
      packages/nc-gui/components/extensions/Market.vue
  8. 211
      packages/nc-gui/components/extensions/Pane.vue
  9. 7
      packages/nc-gui/components/project/View.vue
  10. 4
      packages/nc-gui/components/smartsheet/Kanban.vue
  11. 42
      packages/nc-gui/components/smartsheet/Topbar.vue
  12. 6
      packages/nc-gui/components/smartsheet/toolbar/SearchData.vue
  13. 19
      packages/nc-gui/components/smartsheet/topbar/CmdK.vue
  14. 4
      packages/nc-gui/components/tabs/Smartsheet.vue
  15. 8
      packages/nc-gui/components/workspace/View.vue
  16. 2
      packages/nc-gui/components/workspace/integrations/ConnectionsTab.vue
  17. 4
      packages/nc-gui/components/workspace/integrations/view.vue
  18. 81
      packages/nc-gui/composables/useExtensions.ts
  19. BIN
      packages/nc-gui/extensions/data-exporter/assets/fullscreen-modal-screenshot.png
  20. 0
      packages/nc-gui/extensions/data-exporter/assets/icon.png
  21. 7
      packages/nc-gui/extensions/data-exporter/assets/icon.svg
  22. 13
      packages/nc-gui/extensions/data-exporter/description.md
  23. 4
      packages/nc-gui/extensions/data-exporter/index.vue
  24. 11
      packages/nc-gui/extensions/data-exporter/manifest.json
  25. 0
      packages/nc-gui/extensions/json-exporter/assets/icon.png
  26. 5
      packages/nc-gui/extensions/json-exporter/description.md
  27. 10
      packages/nc-gui/extensions/json-exporter/manifest.json
  28. 4
      packages/nc-gui/utils/iconUtils.ts
  29. 8
      tests/playwright/pages/Dashboard/Sidebar/index.ts
  30. 4
      tests/playwright/pages/Dashboard/common/LeftSidebar/index.ts
  31. 9
      tests/playwright/pages/Dashboard/common/Topbar/index.ts

10
packages/nc-gui/assets/nc-icons/puzzle-outline.svg

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.3 KiB

4
packages/nc-gui/assets/nc-icons/puzzle-solid.svg

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.6 KiB

2
packages/nc-gui/components/dashboard/Sidebar/TopSection.vue

@ -54,7 +54,7 @@ const navigateToIntegrations = () => {
</template>
<template v-else-if="!isSharedBase">
<div class="xs:hidden flex flex-col p-1 mt-0.25 mb-0.5 truncate">
<DashboardSidebarTopSectionHeader />
<!-- <DashboardSidebarTopSectionHeader /> -->
<NcButton
v-if="isUIAllowed('workspaceSettings') || isUIAllowed('workspaceCollaborators')"

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

@ -13,7 +13,7 @@ const emit = defineEmits(['update:modelValue'])
const vModel = useVModel(props, 'modelValue', emit)
const { availableExtensions, addExtension, getExtensionIcon, isMarketVisible } = useExtensions()
const { availableExtensions, descriptionContent, addExtension, getExtensionAssetsUrl, isMarketVisible } = useExtensions()
const onBack = () => {
vModel.value = false
@ -29,57 +29,88 @@ const activeExtension = computed(() => {
return availableExtensions.value.find((ext) => ext.id === props.extensionId)
})
const detailsBody = activeExtension.value?.description ? marked.parse(activeExtension.value.description) : '<p></p>'
// Create a custom renderer
const renderer = new marked.Renderer()
// Override the image function to modify the URL
renderer.image = function (href: string, title: string | null, text: string) {
// Modify the URL here
const newUrl = getExtensionAssetsUrl(href)
return `<img src="${newUrl}" alt="${text}" title="${title || ''}">`
}
// Apply the custom renderer to marked
marked.use({ renderer })
const getModifiedContent = (content = '') => {
// Modify raw <img> tags, supporting both single and double quotes
return content.replace(/<img\s+src=(["'])(.*?)\1(.*?)>/g, (match, quote, src, rest) => {
const newSrc = getExtensionAssetsUrl(src)
return `<img src=${quote}${newSrc}${quote}${rest}>`
})
}
const detailsBody = computed(() => {
if (descriptionContent.value[props.extensionId]) {
return marked.parse(getModifiedContent(descriptionContent.value[props.extensionId]))
} else if (activeExtension.value?.description) {
return marked.parse(getModifiedContent(activeExtension.value.description))
}
return '<p></p>'
})
</script>
<template>
<NcModal
v-model:visible="vModel"
:body-style="{ 'max-height': '864px', 'height': '85vh' }"
:class="{ active: vModel }"
:closable="from === 'extension'"
:footer="null"
:width="1154"
size="medium"
wrap-class-name="nc-modal-extension-market"
wrap-class-name="nc-modal-extension-details"
>
<div v-if="activeExtension" class="flex flex-col w-full h-full">
<div v-if="from === 'market'" class="flex-none h-8 flex items-center mb-4">
<NcButton size="xsmall" type="text" class="!bg-gray-200/75 !hover:bg-gray-200 !rounded-full" @click="onBack">
<div class="flex items-center gap-2 px-2">
<GeneralIcon icon="ncArrowLeft" />
<span>Back</span>
</div>
<div class="flex items-center gap-3 p-4 border-b-1 border-gray-200">
<NcButton v-if="from === 'market'" size="small" type="text" @click="onBack">
<GeneralIcon icon="arrowLeft" />
</NcButton>
</div>
<div v-else class="h-8"></div>
<div class="extension-details">
<div class="extension-details-left nc-scrollbar-thin">
<div class="flex gap-6">
<img :src="getExtensionIcon(activeExtension.iconUrl)" alt="icon" class="h-[80px] w-[80px] object-contain" />
<div class="flex flex-col gap-3">
<div class="font-weight-700 text-2xl">{{ activeExtension.title }}</div>
<img :src="getExtensionAssetsUrl(activeExtension.iconUrl)" alt="icon" class="h-[50px] w-[50px] object-contain" />
<div class="flex-1 flex flex-col">
<div class="font-semibold text-xl truncate">{{ activeExtension.title }}</div>
<div class="text-small leading-[18px] text-gray-500 truncate">{{ activeExtension.subTitle }}</div>
</div>
<div class="self-start flex items-center gap-2.5">
<NcButton size="small" class="w-full" @click="onAddExtension(activeExtension)">
<div class="flex items-center justify-center gap-1 -ml-3px">
<GeneralIcon icon="plus" /> {{ $t('general.install') }}
</div>
</NcButton>
<NcButton size="small" type="text" @click="vModel = false">
<GeneralIcon icon="close" class="text-gray-600" />
</NcButton>
</div>
</div>
<div class="text-base text-gray-600" v-html="detailsBody"></div>
<div class="extension-details">
<div class="extension-details-left">
<div class="nc-extension-details-body" v-html="detailsBody"></div>
</div>
<div class="extension-details-right">
<NcButton class="w-full" @click="onAddExtension(activeExtension)">
<div class="flex items-center justify-center">Add Extension</div>
</NcButton>
<div class="flex flex-col gap-4 nc-scrollbar-thin">
<div class="flex flex-col gap-1">
<div class="extension-details-right-section">
<div class="extension-details-right-title">Version</div>
<div class="extension-details-right-subtitle">{{ activeExtension.version }}</div>
</div>
<div class="flex flex-col gap-1">
<NcDivider />
<div class="extension-details-right-section">
<div v-if="activeExtension.publisherName" class="extension-details-right-title">Publisher</div>
<div class="extension-details-right-subtitle">{{ activeExtension.publisherName }}</div>
</div>
<div v-if="activeExtension.publisherEmail" class="flex flex-col gap-1">
<template v-if="activeExtension.publisherEmail">
<NcDivider />
<div class="extension-details-right-section">
<div class="extension-details-right-title">Publisher Email</div>
<div class="extension-details-right-subtitle">
<a :href="`mailto:${activeExtension.publisherEmail}`" target="_blank" rel="noopener noreferrer">
@ -87,7 +118,10 @@ const detailsBody = activeExtension.value?.description ? marked.parse(activeExte
</a>
</div>
</div>
<div v-if="activeExtension.publisherUrl" class="flex flex-col gap-1">
</template>
<template v-if="activeExtension.publisherUrl">
<NcDivider />
<div class="extension-details-right-section">
<div class="extension-details-right-title">Publisher Website</div>
<div class="extension-details-right-subtitle">
<a :href="activeExtension.publisherUrl" target="_blank" rel="noopener noreferrer">
@ -95,7 +129,7 @@ const detailsBody = activeExtension.value?.description ? marked.parse(activeExte
</a>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
@ -104,17 +138,21 @@ const detailsBody = activeExtension.value?.description ? marked.parse(activeExte
<style lang="scss" scoped>
.extension-details {
@apply flex w-full h-full gap-8 px-3;
@apply flex w-full h-[calc(100%_-_65px)];
.extension-details-left {
@apply flex flex-col gap-6 w-3/4;
@apply p-6 flex-1 flex flex-col gap-6 nc-scrollbar-thin;
}
.extension-details-right {
@apply w-1/4 flex flex-col gap-4;
@apply p-5 w-[320px] flex flex-col space-y-4 border-l-1 border-gray-200 bg-gray-50 nc-scrollbar-thin;
.extension-details-right-section {
@apply flex flex-col gap-2;
}
.extension-details-right-title {
@apply text-base font-weight-700 text-gray-800;
@apply text-sm font-semibold text-gray-800;
}
.extension-details-right-subtitle {
@apply text-sm font-weight-500 text-gray-600;
@ -122,3 +160,109 @@ const detailsBody = activeExtension.value?.description ? marked.parse(activeExte
}
}
</style>
<style lang="scss">
.nc-modal-extension-details {
.ant-modal-content {
@apply overflow-hidden;
}
.nc-modal {
@apply !p-0;
height: min(calc(100vh - 100px), 1024px);
max-height: min(calc(100vh - 100px), 1024px) !important;
.nc-edit-or-add-integration-left-panel {
@apply w-full p-6 flex-1 flex justify-center;
}
.nc-edit-or-add-integration-right-panel {
@apply p-5 w-[320px] border-l-1 border-gray-200 flex flex-col gap-4 bg-gray-50 rounded-br-2xl;
}
}
.nc-extension-details-body {
p {
@apply !m-0 !leading-5;
}
ul {
li {
@apply ml-4;
list-style-type: disc;
}
}
ol {
@apply !pl-4;
li {
list-style-type: decimal;
}
}
ul,
ol {
@apply !my-0;
}
// Pre tag is the parent wrapper for Code block
pre {
@apply overflow-auto mt-3 bg-gray-100;
border-color: #d0d5dd;
border: 1px;
color: black;
font-family: 'JetBrainsMono', monospace;
padding: 1rem;
border-radius: 0.5rem;
height: fit-content;
code {
@apply !px-0;
}
}
code {
@apply rounded-md px-2 py-1 bg-gray-100;
color: inherit;
font-size: 0.8rem;
}
blockquote {
border-left: 3px solid #d0d5dd;
padding: 0 1em;
color: #666;
margin: 1em 0;
font-style: italic;
}
hr {
@apply !border-gray-300;
border: 0;
border-top: 1px solid #ccc;
margin: 1.5em 0;
}
h1 {
font-weight: 700;
font-size: 1.85rem;
margin-bottom: 0.1rem;
line-height: 36px;
}
h2 {
font-weight: 600;
font-size: 1.55rem;
margin-bottom: 0.1em;
line-height: 30px;
}
h3 {
font-weight: 600;
font-size: 1.15rem;
margin-bottom: 0.1em;
line-height: 24px;
}
}
}
</style>

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

@ -11,7 +11,7 @@ const {
extensionsLoaded,
availableExtensions,
eventBus,
getExtensionIcon,
getExtensionAssetsUrl,
duplicateExtension,
showExtensionDetails,
} = useExtensions()
@ -56,26 +56,34 @@ const { fullscreen, collapsed } = useProvideExtensionHelper(extension)
const component = ref<any>(null)
const extensionManifest = ref<any>(null)
const extensionManifest = ref<ExtensionManifest | undefined>()
const extensionMinHeight = computed(() => {
switch (extension.value.extensionId) {
case 'nc-data-exporter':
return 'min-h-[300px] h-[300px]'
case 'nc-json-exporter':
return 'min-h-[194px] h-[194px]'
case 'nc-csv-import':
return 'min-h-[180px] h-[180px]'
const fullscreenModalMaxWidth = computed(() => {
const modalMaxWidth = {
xs: 'min(calc(100vw - 32px), 448px)',
sm: 'min(calc(100vw - 32px), 640px)',
md: 'min(calc(100vw - 48px), 900px)',
lg: 'min(calc(100vw - 48px), 1280px)',
}
return extensionManifest.value?.config?.modalMaxWith
? modalMaxWidth[extensionManifest.value?.config?.modalMaxWith] || modalMaxWidth.lg
: modalMaxWidth.lg
})
const expandExtension = () => {
if (!collapsed.value) return
collapsed.value = false
}
onMounted(() => {
until(extensionsLoaded)
.toMatch((v) => v)
.then(() => {
extensionManifest.value = availableExtensions.value.find((ext) => ext.id === extension.value.extensionId)
if (!extensionManifest) {
if (!extensionManifest.value) {
return
}
@ -94,7 +102,11 @@ onMounted(() => {
// close fullscreen on escape key press
useEventListener('keydown', (e) => {
if (e.key === 'Escape') {
// Check if the event target or its closest parent is an input, select, or textarea
const isFormElement = (e?.target as HTMLElement)?.closest('input, select, textarea')
// If the target is not a form element and the key is 'Escape', close fullscreen
if (e.key === 'Escape' && !isFormElement) {
fullscreen.value = false
}
})
@ -132,78 +144,103 @@ eventBus.on((event, payload) => {
<div
class="extension-wrapper"
:class="[
`${!collapsed ? extensionMinHeight : ''}`,
{
'!h-auto': collapsed,
'isOpen': !collapsed,
'mousedown': isMouseDown,
},
]"
:style="
!collapsed
? {
height: extensionManifest?.config?.contentMinHeight,
minHeight: extensionManifest?.config?.contentMinHeight,
}
: {}
"
@mousedown="isMouseDown = true"
@mouseup="isMouseDown = false"
>
<div class="extension-header" :class="{ 'mb-2': !collapsed }">
<div
class="extension-header px-3 py-2"
:class="{
'border-b-1 border-gray-200 h-[49px]': !collapsed,
'collapsed border-transparent h-[48px]': collapsed,
}"
@click="expandExtension"
>
<div class="extension-header-left max-w-[calc(100%_-_100px)]">
<!-- Todo: enable later when we support extension reordering -->
<!-- eslint-disable vue/no-constant-condition -->
<NcButton v-if="false" size="xxsmall" type="text">
<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="getExtensionIcon(extensionManifest.iconUrl)"
:src="getExtensionAssetsUrl(extensionManifest.iconUrl)"
alt="icon"
class="h-6 w-6 object-contain"
class="h-8 w-8 object-contain"
/>
<input
<a-input
v-if="titleEditMode && !fullscreen"
ref="titleInput"
v-model="tempTitle"
class="flex-grow leading-1 outline-0 ring-none !text-inherit !bg-transparent w-4/5 extension-title"
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" @dblclick="enableEditMode">
<span class="extension-title cursor-pointer" @dblclick.stop="enableEditMode" @click.stop>
{{ extension.title }}
</span>
</NcTooltip>
</div>
<div class="extension-header-right">
<NcButton v-if="!activeError" type="text" size="xxsmall" @click="fullscreen = true">
<GeneralIcon icon="expand" />
</NcButton>
<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 size="xxsmall" type="text" @click="collapsed = !collapsed">
<GeneralIcon :icon="collapsed ? 'arrowUp' : 'arrowDown'" class="flex-none" />
<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">
<div v-show="!collapsed" class="extension-content">
<a-result status="error" title="Extension Error">
<div
v-show="!collapsed"
class="extension-content nc-scrollbar-thin h-[calc(100%_-_50px)] flex items-center justify-center"
:class="{
fullscreen,
}"
>
<a-result status="error" title="Extension Error" class="nc-extension-error">
<template #subTitle>{{ activeError }}</template>
<template #extra>
<NcButton @click="extension.clear()">
<NcButton size="small" @click="extension.clear()">
<div class="flex items-center gap-2">
<GeneralIcon icon="reload" />
Clear Data
</div>
</NcButton>
<NcButton type="danger" @click="extension.delete()">
<NcButton size="small" type="danger" @click="extension.delete()">
<div class="flex items-center gap-2">
<GeneralIcon icon="delete" />
Delete
@ -217,33 +254,45 @@ eventBus.on((event, payload) => {
<Teleport to="body" :disabled="!fullscreen">
<div
ref="extensionModalRef"
:class="{ 'extension-modal': fullscreen, 'h-[calc(100%_-_32px)]': !fullscreen }"
:class="{ 'extension-modal': fullscreen, 'h-[calc(100%_-_50px)]': !fullscreen }"
@click="closeFullscreen"
>
<div :class="{ 'extension-modal-content': fullscreen, 'h-full': !fullscreen }">
<div
:class="{ 'extension-modal-content': fullscreen, 'h-full': !fullscreen }"
:style="
fullscreen
? {
maxWidth: fullscreenModalMaxWidth,
}
: {}
"
>
<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-weight-600">
<div class="flex-1 max-w-[calc(100%_-_96px)] flex items-center gap-2 text-gray-800 font-semibold">
<img
v-if="extensionManifest"
:src="getExtensionIcon(extensionManifest.iconUrl)"
:src="getExtensionAssetsUrl(extensionManifest.iconUrl)"
alt="icon"
class="flex-none w-6 h-6"
class="flex-none w-8 h-8"
/>
<input
<a-input
v-if="titleEditMode"
ref="titleInput"
v-model="tempTitle"
class="flex-grow leading-1 outline-0 ring-none !text-xl !bg-transparent !font-weight-600"
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="updateExtensionTitle"
@keyup.esc="updateExtensionTitle"
@keyup.enter.stop="updateExtensionTitle"
@keyup.esc.stop="updateExtensionTitle"
@blur="updateExtensionTitle"
/>
<NcTooltip v-else show-on-truncate-only class="extension-title truncate text-xl">
>
</a-input>
<NcTooltip v-else show-on-truncate-only class="extension-title truncate text-lg">
<template #title>
{{ extension.title }}
</template>
<span @dblclick="enableEditMode">
<span class="cursor-pointer" @dblclick="enableEditMode">
{{ extension.title }}
</span>
</NcTooltip>
@ -266,7 +315,7 @@ eventBus.on((event, payload) => {
<div
v-show="fullscreen || !collapsed"
class="extension-content"
:class="{ 'h-[calc(100%-40px)]': fullscreen, 'h-full': !fullscreen }"
:class="{ 'fullscreen h-[calc(100%-40px)]': fullscreen, 'h-full': !fullscreen }"
>
<component :is="component" :key="extension.uiKey" class="h-full" />
</div>
@ -280,7 +329,7 @@ eventBus.on((event, payload) => {
<style scoped lang="scss">
.extension-wrapper {
@apply bg-white rounded-xl px-3 py-[11px] w-full border-1 relative;
@apply bg-white rounded-xl w-full border-1 relative;
&.isOpen {
resize: vertical;
@ -295,12 +344,19 @@ 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-2;
@apply flex items-center gap-1;
}
.extension-title {
@ -310,13 +366,35 @@ eventBus.on((event, payload) => {
.extension-content {
@apply rounded-lg;
&:not(.fullscreen) {
@apply p-3;
}
}
.extension-modal {
@apply absolute top-0 left-0 z-1000 w-full h-full bg-black bg-opacity-50;
.extension-modal-content {
@apply bg-white rounded-2xl w-[90%] max-w-[1154px] h-[90vh] mt-[5vh] mx-auto p-6 flex flex-col gap-3;
@apply bg-white rounded-2xl w-[90%] h-[90vh] mt-[5vh] mx-auto p-6 flex flex-col gap-3;
}
}
:deep(.nc-extension-error.ant-result) {
@apply p-0;
.ant-result-icon {
@apply mb-3;
& > span {
@apply text-[32px];
}
}
.ant-result-title {
@apply text-base text-gray-800 font-semibold;
}
.ant-result-extra {
@apply mt-3;
}
}
</style>

12
packages/nc-gui/components/extensions/ExtensionMenu.vue

@ -10,23 +10,23 @@ const emits = defineEmits(['rename', 'duplicate', 'showDetails', 'clearData', 'd
<template>
<div class="flex items-center">
<NcDropdown :trigger="['click']">
<NcButton type="text" :size="fullscreen ? 'small' : 'xxsmall'">
<NcDropdown :trigger="['click']" placement="bottomRight">
<NcButton type="text" :size="fullscreen ? 'small' : 'xs'" class="!px-1">
<GeneralIcon icon="threeDotVertical" />
</NcButton>
<template #overlay>
<NcMenu>
<template v-if="!activeError">
<NcMenuItem data-rec="true" class="!hover:text-primary" @click="emits('rename')">
<NcMenuItem data-rec="true" @click="emits('rename')">
<GeneralIcon icon="edit" />
Rename
</NcMenuItem>
<NcMenuItem data-rec="true" class="!hover:text-primary" @click="emits('duplicate')">
<NcMenuItem data-rec="true" @click="emits('duplicate')">
<GeneralIcon icon="duplicate" />
Duplicate
</NcMenuItem>
<NcMenuItem data-rec="true" class="!hover:text-primary" @click="emits('showDetails')">
<NcMenuItem data-rec="true" @click="emits('showDetails')">
<GeneralIcon icon="info" />
Details
</NcMenuItem>
@ -34,7 +34,7 @@ const emits = defineEmits(['rename', 'duplicate', 'showDetails', 'clearData', 'd
</template>
<NcMenuItem data-rec="true" class="!text-red-500 !hover:bg-red-50" @click="emits('clearData')">
<GeneralIcon icon="reload" />
Clear Data
Clear data
</NcMenuItem>
<NcMenuItem data-rec="true" class="!text-red-500 !hover:bg-red-50" @click="emits('delete')">
<GeneralIcon icon="delete" />

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

@ -9,7 +9,7 @@ const emit = defineEmits(['update:modelValue'])
const vModel = useVModel(props, 'modelValue', emit)
const { availableExtensions, addExtension, getExtensionIcon, showExtensionDetails } = useExtensions()
const { availableExtensions, addExtension, getExtensionAssetsUrl, showExtensionDetails } = useExtensions()
const searchQuery = ref<string>('')
@ -17,7 +17,7 @@ const filteredAvailableExtensions = computed(() =>
(availableExtensions.value || []).filter(
(ext) =>
ext.title.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
ext.description.toLowerCase().includes(searchQuery.value.toLowerCase()),
ext.subTitle.toLowerCase().includes(searchQuery.value.toLowerCase()),
),
)
@ -35,27 +35,28 @@ const onAddExtension = (ext: any) => {
<template>
<NcModal
v-model:visible="vModel"
:body-style="{ 'max-height': '864px', 'height': '85vh' }"
:class="{ active: vModel }"
:closable="true"
:footer="null"
:width="1154"
size="medium"
wrap-class-name="nc-modal-extension-market"
>
<template #header>
<div class="flex items-center gap-2 pb-2">
<GeneralIcon icon="puzzle" class="h-5 w-5 flex-none" />
<div class="font-weight-700 text-base">Extensions Marketplace</div>
<div class="h-full">
<div class="flex items-center gap-3 p-4 border-b-1 border-gray-200">
<GeneralIcon icon="ncPuzzleSolid" class="h-6 w-6 flex-none text-gray-700" />
<div class="flex-1 font-semibold text-xl">Extensions Marketplace</div>
<NcButton size="small" type="text" @click="vModel = false">
<GeneralIcon icon="close" class="text-gray-600" />
</NcButton>
</div>
</template>
<div class="flex flex-col h-[calc(100%_-_41px)]">
<div class="h-full flex flex-col gap-4 flex-1 pt-2">
<div class="flex flex-col h-[calc(100%_-_65px)] px-6 py-4">
<div class="h-full flex flex-col gap-6 flex-1 pt-2">
<div class="flex flex max-w-[470px]">
<a-input
v-model:value="searchQuery"
type="text"
class="!h-10 !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..."
allow-clear
>
@ -65,27 +66,30 @@ const onAddExtension = (ext: any) => {
</a-input>
</div>
<div
class="max-h-[calc(100%_-_40px)] flex flex-wrap gap-3 nc-scrollbar-thin"
class="max-h-[calc(100%_-_40px)] flex flex-wrap gap-3 nc-scrollbar-thin pb-2"
:class="{
'h-full': searchQuery && !filteredAvailableExtensions.length && availableExtensions.length,
}"
>
<template v-for="ext of filteredAvailableExtensions" :key="ext.id">
<div class="flex border-1 rounded-xl p-3 w-[360px] cursor-pointer" @click="onExtensionClick(ext.id)">
<div
class="nc-market-extension-item flex border-1 rounded-xl p-3 w-[360px] cursor-pointer hover:bg-gray-50 transition-all"
@click="onExtensionClick(ext.id)"
>
<div class="h-[60px] w-[60px] overflow-hidden m-auto">
<img :src="getExtensionIcon(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 class="flex flex-grow flex-col gap-2 ml-3">
<div class="flex flex-grow flex-col gap-1 ml-3">
<div class="flex justify-between gap-1">
<div class="font-weight-600">{{ ext.title }}</div>
<NcButton size="xsmall" type="secondary" @click.stop="onAddExtension(ext)">
<div class="flex items-center gap-2 mx-1">
<div class="font-weight-600 text-base">{{ ext.title }}</div>
<NcButton size="xsmall" type="secondary" class="!px-7px" @click.stop="onAddExtension(ext)">
<div class="flex items-center gap-1 -ml-3px text-small">
<GeneralIcon icon="plus" />
Add
{{ $t('general.install') }}
</div>
</NcButton>
</div>
<div class="w-[250px] h-[32px] text-xs text-gray-500 line-clamp-2">{{ ext.description }}</div>
<div class="w-[250px] h-[32px] text-xs text-gray-500 line-clamp-2">{{ ext.subTitle }}</div>
</div>
</div>
</template>
@ -106,7 +110,31 @@ const onAddExtension = (ext: any) => {
</div>
</div>
</div>
</div>
</NcModal>
</template>
<style lang="scss" scoped></style>
<style lang="scss" scoped>
.nc-market-extension-item {
&:hover {
box-shadow: 0px 4px 8px -2px rgba(0, 0, 0, 0.08), 0px 2px 4px -2px rgba(0, 0, 0, 0.04);
}
}
</style>
<style lang="scss">
.nc-modal-extension-market {
.nc-modal {
@apply !p-0;
height: min(calc(100vh - 100px), 1024px);
max-height: min(calc(100vh - 100px), 1024px) !important;
.nc-edit-or-add-integration-left-panel {
@apply w-full p-6 flex-1 flex justify-center;
}
.nc-edit-or-add-integration-right-panel {
@apply p-5 w-[320px] border-l-1 border-gray-200 flex flex-col gap-4 bg-gray-50 rounded-br-2xl;
}
}
}
</style>

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

@ -1,6 +1,8 @@
<script setup lang="ts">
import { Pane } from 'splitpanes'
import 'splitpanes/dist/splitpanes.css'
import Draggable from 'vuedraggable'
import type { ExtensionType } from '#imports'
const {
extensionList,
@ -10,13 +12,40 @@ const {
detailsFrom,
isMarketVisible,
extensionPanelSize,
toggleExtensionPanel,
updateExtension,
} = useExtensions()
const { $e } = useNuxtApp()
const isReady = ref(false)
const searchExtensionRef = ref<HTMLInputElement>()
const extensionHeaderRef = ref<HTMLDivElement>()
const searchQuery = ref<string>('')
const showSearchBox = ref(false)
const { width } = useElementSize(extensionHeaderRef)
const isOpenSearchBox = computed(() => {
return !!(searchQuery.value || showSearchBox.value)
})
const handleShowSearchInput = () => {
showSearchBox.value = true
nextTick(() => {
searchExtensionRef.value?.focus()
})
}
const handleCloseSearchbox = () => {
showSearchBox.value = false
searchQuery.value = ''
}
const filteredExtensionList = computed(() =>
(extensionList.value || []).filter((ext) => ext.title.toLowerCase().includes(searchQuery.value.toLowerCase())),
)
@ -33,6 +62,44 @@ const normalizePaneMaxWidth = computed(() => {
}
})
const onMove = async (_event: { moved: { newIndex: number; oldIndex: number; element: ExtensionType } }) => {
let {
moved: { newIndex = 0, oldIndex = 0, element },
} = _event
element = extensionList.value?.find((ext) => ext.id === element.id) || element
if (!element?.id) return
newIndex = extensionList.value.findIndex((ext) => ext.id === filteredExtensionList.value[newIndex].id)
oldIndex = extensionList.value.findIndex((ext) => ext.id === filteredExtensionList.value[oldIndex].id)
let nextOrder: number
// set new order value based on the new order of the items
if (extensionList.value.length - 1 === newIndex) {
// If moving to the end, set nextOrder greater than the maximum order in the list
nextOrder = Math.max(...extensionList.value.map((item) => item?.order ?? 0)) + 1
} else if (newIndex === 0) {
// If moving to the beginning, set nextOrder smaller than the minimum order in the list
nextOrder = Math.min(...extensionList.value.map((item) => item?.order ?? 0)) / 2
} else {
nextOrder =
(parseFloat(String(extensionList.value[newIndex - 1]?.order ?? 0)) +
parseFloat(String(extensionList.value[newIndex + 1]?.order ?? 0))) /
2
}
const _nextOrder = !isNaN(Number(nextOrder)) ? nextOrder : oldIndex
await updateExtension(element.id, {
order: _nextOrder,
})
$e('a:extension:reorder')
}
defineExpose({
onReady: () => {
isReady.value = true
@ -46,6 +113,20 @@ watch(isPanelExpanded, (newValue) => {
}, 300)
}
})
onClickOutside(searchExtensionRef, () => {
if (searchQuery.value) {
return
}
showSearchBox.value = false
})
onMounted(() => {
if (searchQuery.value && !showSearchBox.value) {
showSearchBox.value = true
}
})
</script>
<template>
@ -54,85 +135,100 @@ watch(isPanelExpanded, (newValue) => {
:size="extensionPanelSize"
min-size="10%"
max-size="60%"
class="flex flex-col gap-3 bg-[#F0F3FF]"
class="nc-extension-pane"
:style="{
minWidth: isReady ? '300px' : `${normalizePaneMaxWidth}%`,
maxWidth: `${normalizePaneMaxWidth}%`,
}"
>
<div class="flex justify-between items-center px-4 pt-3">
<div class="flex items-center gap-3 font-weight-700 text-brand-500 text-base">
<GeneralIcon icon="puzzle" class="h-5 w-5" /> Extensions
</div>
<NcTooltip class="flex" hide-on-click placement="topRight">
<template #title> Hide extensions </template>
<NcButton
size="xxsmall"
type="text"
class="!text-gray-700 !hover:text-gray-800 !hover:bg-gray-200"
@click="toggleExtensionPanel"
<div
ref="extensionHeaderRef"
class="h-[var(--toolbar-height)] flex items-center gap-3 px-4 py-2 border-b-1 border-gray-200 bg-white"
>
<div class="flex items-center justify-center">
<GeneralIcon icon="doubleRightArrow" class="flex-none !text-gray-500/75" />
</div>
</NcButton>
</NcTooltip>
</div>
<template v-if="extensionList.length === 0">
<div class="flex items-center flex-col gap-4 w-full nc-scrollbar-md text-center px-4">
<div class="w-[180px] h-[180px] bg-[#d9d9d9] rounded-3xl mt-[100px]"></div>
<div class="font-weight-700 text-base">No extensions added</div>
<div>Add Extensions from the community extensions marketplace</div>
<NcButton size="small" @click="toggleMarket">
<div class="flex items-center gap-2 font-weight-600">
<GeneralIcon icon="plus" />
Add Extension
<div
class="flex items-center gap-3 font-weight-700 text-gray-700 text-base"
:class="{
'flex-1': !isOpenSearchBox,
}"
>
<GeneralIcon icon="ncPuzzleSolid" class="h-5 w-5 text-gray-700 opacity-85" />
<span v-if="!isOpenSearchBox || width >= 507">Extensions</span>
</div>
<div
class="flex justify-end"
:class="{
'flex-1': isOpenSearchBox,
}"
>
<NcButton v-if="!isOpenSearchBox" size="xs" type="text" class="!px-1" @click="handleShowSearchInput">
<GeneralIcon icon="search" class="flex-none !text-gray-500" />
</NcButton>
</div>
</template>
<template v-else>
<div class="flex w-full items-center justify-between px-4">
<div class="flex flex-grow items-center mr-2">
<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="!h-8 !px-3 !py-1 !rounded-lg"
class="nc-input-border-on-value !h-7 !px-3 !py-1 !rounded-lg"
placeholder="Search Extension"
allow-clear
@keydown.esc="handleCloseSearchbox"
>
<template #prefix>
<GeneralIcon icon="search" class="mr-2 h-4 w-4 text-gray-500 group-hover:text-black" />
</template>
</a-input>
</div>
<NcButton type="ghost" size="small" class="!text-primary !bg-white children:children:max-w-full" @click="toggleMarket">
<div class="flex items-center gap-1 text-xs max-w-full">
</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" />
<NcTooltip
class="max-w-[calc(100%_-_16px)] truncate"
show-on-truncate-only
overlay-class-name="children:-ml-2"
modifier-key=""
>
<template #title> Add Extension </template>
Add Extension
</NcTooltip>
{{ $t('general.install') }}
</div>
</NcButton>
</div>
<div
class="nc-extension-list-wrapper flex items-center flex-col gap-3 w-full nc-scrollbar-md"
<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>
</NcButton>
</div>
</template>
<template v-else>
<Draggable
:model-value="filteredExtensionList"
draggable=".nc-extension-item"
item-key="id"
handle=".nc-extension-drag-handler"
ghost-class="ghost"
class="nc-extension-list-wrapper flex items-center flex-col gap-3 w-full nc-scrollbar-md py-4"
:class="{
'h-full': searchQuery && !filteredExtensionList.length && extensionList.length,
}"
@start="(e) => e.target.classList.add('grabbing')"
@end="(e) => e.target.classList.remove('grabbing')"
@change="onMove($event)"
>
<ExtensionsWrapper v-for="ext in filteredExtensionList" :key="ext.id" :extension-id="ext.id" />
<div
v-if="searchQuery && !filteredExtensionList.length && extensionList.length"
class="w-full h-full flex-1 flex items-center justify-center"
>
<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"
@ -143,7 +239,8 @@ watch(isPanelExpanded, (newValue) => {
{{ $t('title.noResultsMatchedYourSearch') }}
</div>
</div>
</div>
</template>
</Draggable>
</template>
<ExtensionsMarket v-if="isMarketVisible" v-model="isMarketVisible" />
<ExtensionsDetails
@ -161,4 +258,10 @@ watch(isPanelExpanded, (newValue) => {
@apply pb-3;
}
}
.nc-extension-pane {
@apply flex flex-col bg-gray-50 rounded-l-xl border-1 border-gray-200 z-30 -mt-1px;
box-shadow: 0px 0px 16px 0px rgba(0, 0, 0, 0.16), 0px 8px 8px -4px rgba(0, 0, 0, 0.04);
}
</style>

7
packages/nc-gui/components/project/View.vue

@ -112,10 +112,10 @@ watch(
<div class="h-full nc-base-view">
<div
v-if="!isAdminPanel"
class="flex flex-row px-2 py-2 gap-1 justify-between w-full border-b-1 border-gray-200"
class="flex flex-row px-2 py-2 gap-3 justify-between w-full border-b-1 border-gray-200"
:class="{ 'nc-table-toolbar-mobile': isMobileMode, 'h-[var(--topbar-height)]': !isMobileMode }"
>
<div class="flex flex-row items-center gap-x-3">
<div class="flex-1 flex flex-row items-center gap-x-3">
<GeneralOpenLeftSidebarBtn />
<div class="flex flex-row items-center h-full gap-x-2 px-2">
<GeneralProjectIcon :color="parseProp(currentBase?.meta).iconColor" :type="currentBase?.type" />
@ -127,6 +127,9 @@ watch(
</NcTooltip>
</div>
</div>
<SmartsheetTopbarCmdK v-if="!isSharedBase" />
<LazyGeneralShareProject />
</div>
<div

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

@ -1016,9 +1016,9 @@ const handleSubmitRenameOrNewStack = async (loadMeta: boolean, stack?: any, stac
</div>
</div>
<div class="flex items-center gap-3">
<div class="flex items-center gap-2 truncate">
<div
class="nc-kanban-data-count px-1 rounded bg-gray-200 text-gray-800 text-sm font-weight-500"
class="nc-kanban-data-count px-1 rounded bg-gray-200 text-gray-800 text-sm font-weight-500 truncate"
:style="{ 'word-break': 'keep-all', 'white-space': 'nowrap' }"
>
<!-- Record Count -->

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

@ -53,31 +53,41 @@ const topbarBreadcrumbItemWidth = computed(() => {
<div class="flex items-center justify-end gap-3 flex-1">
<GeneralApiLoader v-if="!isMobileMode" />
<div
v-if="extensionsEgg"
class="flex items-center px-2 py-1 border-1 rounded-lg h-8 xs:(h-10 ml-0) ml-1 border-gray-200 cursor-pointer font-weight-600 text-sm select-none"
:class="{ 'bg-brand-50 text-brand-500': isPanelExpanded }"
<NcButton
v-if="!isSharedBase && extensionsEgg"
v-e="['c:extension-toggle']"
type="secondary"
size="small"
class="nc-topbar-extension-btn"
:class="{ '!bg-brand-50 !hover:bg-brand-100/70 !text-brand-500': isPanelExpanded }"
data-testid="nc-topbar-extension-btn"
@click="toggleExtensionPanel"
>
<GeneralIcon icon="puzzle" class="w-4 h-4" :class="{ 'border-l-1 border-transparent': isPanelExpanded }" />
<div class="flex items-center justify-center min-w-[28.69px]">
<GeneralIcon
icon="ncPuzzleOutline"
class="w-4 h-4 !stroke-transparent"
:class="{ 'border-l-1 border-transparent': isPanelExpanded }"
/>
<span
class="overflow-hidden trasition-all duration-200"
:class="{ 'w-[0px] invisible': isPanelExpanded, 'ml-2 w-[74px]': !isPanelExpanded }"
:class="{ 'w-[0px] invisible': isPanelExpanded, 'ml-1 w-[74px]': !isPanelExpanded }"
>
Extensions
</span>
</div>
<div v-else-if="!extensionsEgg" class="w-[15px] h-[15px] cursor-pointer" @dblclick="onEggClick" />
<LazyGeneralShareProject
v-if="(isForm || isGrid || isKanban || isGallery || isMap || isCalendar) && !isPublic && !isMobileMode"
is-view-toolbar
/>
</NcButton>
<div v-else-if="!isSharedBase && !extensionsEgg" class="w-[15px] h-[15px] cursor-pointer" @dblclick="onEggClick" />
<div v-if="!isSharedBase">
<LazySmartsheetTopbarCmdK />
</div>
<div v-if="(isForm || isGrid || isKanban || isGallery || isMap || isCalendar) && !isPublic && !isMobileMode">
<LazyGeneralShareProject is-view-toolbar />
</div>
<LazyGeneralLanguage
v-if="isSharedBase && !appInfo.ee"
class="cursor-pointer text-lg hover:(text-black bg-gray-200) mr-0 p-1.5 rounded-md"
/>
<div v-if="isSharedBase && !appInfo.ee">
<LazyGeneralLanguage class="cursor-pointer text-lg hover:(text-black bg-gray-200) mr-0 p-1.5 rounded-md" />
</div>
</div>
</template>
</div>

6
packages/nc-gui/components/smartsheet/toolbar/SearchData.vue

@ -85,6 +85,12 @@ onClickOutside(globalSearchWrapperRef, (e) => {
showSearchBox.value = false
})
onMounted(() => {
if (search.value.query && !showSearchBox.value) {
showSearchBox.value = true
}
})
</script>
<template>

19
packages/nc-gui/components/smartsheet/topbar/CmdK.vue

@ -0,0 +1,19 @@
<script setup lang="ts">
const { commandPalette } = useCommandPalette()
</script>
<template>
<NcButton
v-e="['c:quick-actions']"
type="secondary"
size="small"
class="nc-topbar-cmd-k-btn"
data-testid="nc-topbar-cmd-k-btn"
@click="commandPalette?.open()"
>
<div class="flex items-center gap-1 text-sm">
<GeneralIcon icon="ncCommand" class="h-3.5" />
K
</div>
</NcButton>
</template>

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

@ -232,10 +232,10 @@ const onReady = () => {
.nc-extensions-content-resizable-wrapper > {
.splitpanes__splitter {
@apply !w-0 relative overflow-visible;
@apply !w-0 relative overflow-visible z-40 -ml-1px;
}
.splitpanes__splitter:before {
@apply bg-gray-200 w-0.25 absolute left-0 top-0 h-full z-40;
@apply bg-gray-200 absolute left-0 top-[12px] h-[calc(100%_-_24px)] rounded-full z-40;
content: '';
}

8
packages/nc-gui/components/workspace/View.vue

@ -69,8 +69,11 @@ onMounted(() => {
<template>
<div v-if="currentWorkspace" class="flex w-full flex-col nc-workspace-settings">
<div v-if="!props.workspaceId" class="min-w-0 p-2 h-[var(--topbar-height)] border-b-1 border-gray-200">
<div class="nc-breadcrumb nc-no-negative-margin pl-1 nc-workspace-title">
<div
v-if="!props.workspaceId"
class="min-w-0 p-2 h-[var(--topbar-height)] border-b-1 border-gray-200 flex items-center gap-3"
>
<div class="flex-1 nc-breadcrumb nc-no-negative-margin pl-1 nc-workspace-title">
<div class="nc-breadcrumb-item capitalize">
{{ currentWorkspace?.title }}
</div>
@ -80,6 +83,7 @@ onMounted(() => {
{{ $t('title.teamAndSettings') }}
</h1>
</div>
<SmartsheetTopbarCmdK />
</div>
<template v-else>
<div class="nc-breadcrumb px-2">

2
packages/nc-gui/components/workspace/integrations/ConnectionsTab.vue

@ -533,8 +533,8 @@ onKeyStroke('ArrowDown', onDown)
</template>
<NcMenuItem
@click="duplicateIntegration(integration)"
:disabled="integration?.sub_type === ClientType.SQLITE"
@click="duplicateIntegration(integration)"
>
<GeneralIcon
:class="{

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

@ -48,7 +48,7 @@ onBeforeMount(() => {
<template>
<div v-if="currentWorkspace" class="flex w-full flex-col nc-workspace-integrations">
<div class="flex gap-2 items-center min-w-0 p-2 h-[var(--topbar-height)] border-b-1 border-gray-200">
<div class="nc-breadcrumb nc-no-negative-margin pl-1">
<div class="flex-1 nc-breadcrumb nc-no-negative-margin pl-1">
<div class="nc-breadcrumb-item capitalize">
{{ currentWorkspace?.title }}
</div>
@ -57,6 +57,8 @@ onBeforeMount(() => {
{{ $t('general.integrations') }}
</h1>
</div>
<SmartsheetTopbarCmdK />
</div>
<NcTabs v-model:activeKey="activeViewTab">
<template #leftExtra>

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

@ -11,9 +11,10 @@ const extensionsState = createGlobalState(() => {
return { baseExtensions, extensionsEgg, extensionsEggCounter }
})
interface ExtensionManifest {
export interface ExtensionManifest {
id: string
title: string
subTitle: string
description: string
entry: string
version: string
@ -22,6 +23,10 @@ interface ExtensionManifest {
publisherEmail: string
publisherUrl: string
disabled?: boolean
config?: {
modalMaxWith?: 'xs' | 'sm' | 'md' | 'lg'
contentMinHeight?: string
}
}
abstract class ExtensionType {
@ -33,6 +38,7 @@ abstract class ExtensionType {
abstract title: string
abstract kvStore: any
abstract meta: any
abstract order: number
abstract setTitle(title: string): Promise<any>
abstract setMeta(key: string, value: any): Promise<any>
abstract clear(): Promise<any>
@ -56,6 +62,9 @@ export const useExtensions = createSharedComposable(() => {
const availableExtensions = ref<ExtensionManifest[]>([])
// Object to store description content for each extension
const descriptionContent = ref<Record<string, string>>({})
const extensionPanelSize = ref(40)
const activeBaseExtensions = computed(() => {
@ -70,7 +79,11 @@ export const useExtensions = createSharedComposable(() => {
})
const extensionList = computed<ExtensionType[]>(() => {
return activeBaseExtensions.value ? activeBaseExtensions.value.extensions : []
return (activeBaseExtensions.value ? activeBaseExtensions.value.extensions : []).sort(
(a: ExtensionType, b: ExtensionType) => {
return (a?.order ?? Infinity) - (b?.order ?? Infinity)
},
)
})
const toggleExtensionPanel = () => {
@ -158,7 +171,7 @@ export const useExtensions = createSharedComposable(() => {
return
}
const { id: _id, ...extensionData } = extension.serialize()
const { id: _id, order: _order, ...extensionData } = extension.serialize()
const newExtension = await $api.extensions.create(base.value.id, {
...extensionData,
@ -207,7 +220,7 @@ export const useExtensions = createSharedComposable(() => {
}
}
const getExtensionIcon = (pathOrUrl: string) => {
const getExtensionAssetsUrl = (pathOrUrl: string) => {
if (pathOrUrl.startsWith('http')) {
return pathOrUrl
} else {
@ -251,6 +264,7 @@ export const useExtensions = createSharedComposable(() => {
private _title: string
private _kvStore: KvStore
private _meta: any
private _order: number
public uiKey = 0
@ -262,6 +276,7 @@ export const useExtensions = createSharedComposable(() => {
this._title = data.title
this._kvStore = new KvStore(this._id, data.kv_store)
this._meta = data.meta
this._order = data.order
}
get id() {
@ -292,6 +307,10 @@ export const useExtensions = createSharedComposable(() => {
return this._meta
}
get order() {
return this._order
}
serialize() {
return {
id: this._id,
@ -301,6 +320,7 @@ export const useExtensions = createSharedComposable(() => {
title: this._title,
kv_store: this._kvStore.serialize(),
meta: this._meta,
order: this._order,
}
}
@ -312,6 +332,7 @@ export const useExtensions = createSharedComposable(() => {
this._title = data.title
this._kvStore = new KvStore(this._id, data.kv_store)
this._meta = data.meta
this._order = data.order
}
setTitle(title: string): Promise<any> {
@ -333,30 +354,67 @@ export const useExtensions = createSharedComposable(() => {
}
}
onMounted(() => {
// Function to load extensions
onMounted(async () => {
try {
// Load all JSON modules from the specified glob pattern
const modules = import.meta.glob('../extensions/*/*.json')
const extensionCount = modules ? Object.keys(modules).length : 0
const markdownModules = import.meta.glob('../extensions/*/*.md', {
query: '?raw',
import: 'default',
})
const extensionCount = Object.keys(modules).length
let disabledCount = 0
for (const path in modules) {
modules[path]().then((mod: any) => {
// Array to hold the promises
const promises = Object.keys(modules).map(async (path) => {
try {
// Load the module
const mod = (await modules[path]()) as any
const manifest = mod.default as ExtensionManifest
if (manifest?.disabled !== true) {
availableExtensions.value.push(manifest)
// Load the descriptionMarkdown if available
if (manifest.description) {
const markdownPath = `../extensions/${manifest.description}`
if (markdownModules[markdownPath] && manifest?.id) {
try {
const markdownContent = await markdownModules[markdownPath]()
descriptionContent.value[manifest.id] = `${markdownContent}`
} catch (markdownError) {
console.error(`Failed to load Markdown file at ${markdownPath}:`, markdownError)
}
}
}
} else {
disabledCount++
}
} catch (error) {
console.error(`Failed to load module at ${path}:`, error)
}
})
// Wait for all modules to be processed
await Promise.all(promises)
if (availableExtensions.value.length + disabledCount === extensionCount) {
// Sort extensions
availableExtensions.value.sort((a, b) => a.title.localeCompare(b.title))
extensionsLoaded.value = true
}
})
} catch (error) {
console.error('Error loading extensions:', error)
}
// if (isEeUI) {
// extensionsEgg.value = true
// }
})
watch(
@ -397,6 +455,7 @@ export const useExtensions = createSharedComposable(() => {
return {
extensionsLoaded,
availableExtensions,
descriptionContent,
extensionList,
isPanelExpanded,
toggleExtensionPanel,
@ -406,7 +465,7 @@ export const useExtensions = createSharedComposable(() => {
updateExtensionMeta,
clearKvStore,
deleteExtension,
getExtensionIcon,
getExtensionAssetsUrl,
isDetailsVisible,
detailsExtensionId,
detailsFrom,

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

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

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

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

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="61" height="60" viewBox="0 0 61 60" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.0404 23.7458H13.8854C11.6172 23.7458 9.44198 24.777 7.83818 26.6126C6.23438 28.4483 5.33337 30.9379 5.33337 33.5339C5.33337 36.1299 6.23438 38.6195 7.83818 40.4552C9.44198 42.2908 11.6172 43.322 13.8854 43.322H29.2789C29.3431 43.3221 29.4072 43.3216 29.4713 43.3206C29.5355 43.3216 29.5996 43.3221 29.6638 43.322H45.0573C47.3254 43.322 49.5007 42.2908 51.1045 40.4552C52.7083 38.6195 53.6093 36.1299 53.6093 33.5339C53.6093 30.9379 52.7083 28.4483 51.1045 26.6126C49.5007 24.777 47.3254 23.7458 45.0573 23.7458H42.9022C42.2623 20.9092 40.9401 18.3349 39.0857 16.3154C37.2314 14.2959 34.9193 12.9122 32.4123 12.3216C31.4404 12.0926 30.4544 11.9864 29.4713 12.0014C28.4883 11.9864 27.5023 12.0926 26.5304 12.3216C24.0233 12.9122 21.7113 14.2959 19.8569 16.3154C18.0026 18.3349 16.6804 20.9092 16.0404 23.7458Z" fill="#5ECCFF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.7646 25.7458H15.6095C13.3414 25.7458 11.1661 26.777 9.56232 28.6126C7.95851 30.4483 7.05751 32.9379 7.05751 35.5339C7.05751 38.1299 7.95851 40.6195 9.56232 42.4552C11.1661 44.2908 13.3414 45.322 15.6095 45.322H31.003C31.0672 45.3221 31.1313 45.3216 31.1955 45.3206C31.2596 45.3216 31.3237 45.3221 31.3879 45.322H46.7814C49.0496 45.322 51.2248 44.2908 52.8286 42.4552C54.4324 40.6195 55.3334 38.1299 55.3334 35.5339C55.3334 32.9379 54.4324 30.4483 52.8286 28.6126C51.2248 26.777 49.0496 25.7458 46.7814 25.7458H44.6264C43.9864 22.9092 42.6642 20.3349 40.8099 18.3154C38.9555 16.2959 36.6435 14.9122 34.1364 14.3216C33.1645 14.0926 32.1785 13.9864 31.1955 14.0014C30.2124 13.9864 29.2264 14.0926 28.2545 14.3216C25.7475 14.9122 23.4354 16.2959 21.5811 18.3154C19.7267 20.3349 18.4045 22.9092 17.7646 25.7458Z" fill="#AFE5FF"/>
<path d="M36.3334 32V34.6667C36.3334 35.0203 36.1929 35.3594 35.9429 35.6095C35.6928 35.8595 35.3537 36 35.0001 36H25.6667C25.3131 36 24.974 35.8595 24.7239 35.6095C24.4739 35.3594 24.3334 35.0203 24.3334 34.6667V32" stroke="#207399" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M27.0001 28.6665L30.3334 31.9999L33.6667 28.6665" stroke="#207399" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M30.3334 32V24" stroke="#207399" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

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

@ -0,0 +1,13 @@
Data Exporter extension is designed to simplify the process of exporting data from your NocoDB tables. With just a few clicks, you can effortlessly download CSV files for any specific table and view within your base.
</br> </br>
The download process is handled asynchronously in the background, ensuring that your workflow remains uninterrupted. Once your file is ready, you’ll receive a notification, allowing you to download the CSV at your convenience.
</br> </br>
**Key Features**
- Easy CSV downloads for specific tables and views
- Asynchronous processing for seamless operation
- Instant notifications when the file is ready for download
</br> </br>
Elevate your data handling capabilities with the NocoDB Data Exporter extension!

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

@ -185,7 +185,7 @@ onMounted(() => {
<template>
<div ref="dataExporterRef" class="data-exporter">
<div class="pb-3 pt-1 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
class="flex-1 flex items-center"
:class="{
@ -307,7 +307,7 @@ onMounted(() => {
</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">
<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>

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

@ -1,11 +1,16 @@
{
"id": "nc-data-exporter",
"title": "Data Exporter",
"description": "Export any view in various formats",
"subTitle": "Asynchronous CSV downloads with real-time notifications.",
"description": "data-exporter/description.md",
"entry": "data-exporter",
"version": "0.1",
"iconUrl": "data-exporter/icon.png",
"iconUrl": "data-exporter/assets/icon.svg",
"publisherName": "NocoDB",
"publisherEmail": "contact@nocodb.com",
"publisherUrl": "https://www.nocodb.com"
"publisherUrl": "https://www.nocodb.com",
"config": {
"modalMaxWith": "sm",
"contentMinHeight": "310px"
}
}

0
packages/nc-gui/extensions/json-exporter/icon.png → packages/nc-gui/extensions/json-exporter/assets/icon.png

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 43 KiB

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

@ -0,0 +1,5 @@
This is a sample NocoDB extension that exports data in JSON format.
It is used to demonstrate how to create a NocoDB extension.
</br> </br>
This extension is disabled by default. To access it you need to first change the `disabled` property in the manifest file to `false`.

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

@ -1,12 +1,16 @@
{
"id": "nc-json-exporter",
"title": "JSON Exporter",
"description": "This is a sample NocoDB extension that exports data in JSON format. \nIt is used to demonstrate how to create a NocoDB extension.\n\nThis extension is disabled by default. To access it you need to first change the `disabled` property in the manifest file to `false`.",
"subTitle": "JSON Exporter",
"description": "json-exporter/description.md",
"entry": "json-exporter",
"version": "0.1",
"iconUrl": "json-exporter/icon.png",
"iconUrl": "json-exporter/assets/icon.png",
"publisherName": "NocoDB",
"publisherEmail": "contact@nocodb.com",
"publisherUrl": "https://www.nocodb.com",
"disabled": true
"disabled": true,
"config": {
"contentMinHeight": "190px"
}
}

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

@ -551,6 +551,8 @@ import NcFormViewIcon from '~icons/nc-icons/form'
import NcGalleryViewIcon from '~icons/nc-icons/gallery'
import NcKanbanViewIcon from '~icons/nc-icons/kanban'
import NcCalendarViewIcon from '~icons/nc-icons/calendar'
import NcPuzzleSolid from '~icons/nc-icons/puzzle-solid'
import NcPuzzleOutline from '~icons/nc-icons/puzzle-outline'
// keep it for reference
// todo: remove it after all icons are migrated
@ -1319,6 +1321,8 @@ export const iconMap = {
langRuby: NcLangRuby,
langJava: NcLangJava,
langC: NcLangC,
ncPuzzleSolid: NcPuzzleSolid,
ncPuzzleOutline: NcPuzzleOutline,
}
export const getMdiIcon = (type: string): any => {

8
tests/playwright/pages/Dashboard/Sidebar/index.ts

@ -41,10 +41,10 @@ export class SidebarPage extends BasePage {
}
}
async verifyQuickActions({ isVisible }: { isVisible: boolean }) {
if (isVisible) await expect(this.get().getByTestId('nc-sidebar-search-btn')).toBeVisible();
else await expect(this.get().getByTestId('nc-sidebar-search-btn')).toHaveCount(0);
}
// async verifyQuickActions({ isVisible }: { isVisible: boolean }) {
// if (isVisible) await expect(this.get().getByTestId('nc-sidebar-search-btn')).toBeVisible();
// else await expect(this.get().getByTestId('nc-sidebar-search-btn')).toHaveCount(0);
// }
async verifyTeamAndSettings({ isVisible }: { isVisible: boolean }) {
if (isVisible) await expect(this.get().getByTestId('nc-sidebar-team-settings-btn')).toBeVisible();

4
tests/playwright/pages/Dashboard/common/LeftSidebar/index.ts

@ -11,7 +11,7 @@ export class LeftSidebarPage extends BasePage {
readonly btn_workspace: Locator;
readonly btn_newProject: Locator;
readonly btn_cmdK: Locator;
// readonly btn_cmdK: Locator;
readonly btn_teamAndSettings: Locator;
readonly modal_workspace: Locator;
@ -22,7 +22,7 @@ export class LeftSidebarPage extends BasePage {
this.btn_workspace = this.get().locator('.nc-workspace-menu');
this.btn_newProject = this.get().locator('[data-testid="nc-sidebar-create-base-btn"]');
this.btn_cmdK = this.get().locator('[data-testid="nc-sidebar-search-btn"]');
// this.btn_cmdK = this.get().locator('[data-testid="nc-sidebar-search-btn"]');
this.btn_teamAndSettings = this.get().locator('[data-testid="nc-sidebar-team-settings-btn"]');
this.modal_workspace = this.rootPage.locator('.nc-dropdown-workspace-menu');

9
tests/playwright/pages/Dashboard/common/Topbar/index.ts

@ -17,6 +17,8 @@ export class TopbarPage extends BasePage {
readonly btn_share: Locator;
readonly btn_data: Locator;
readonly btn_details: Locator;
readonly btn_cmdK: Locator;
readonly btn_extension: Locator;
constructor(parent: GridPage | GalleryPage | FormPage | KanbanPage | MapPage | CalendarPage) {
super(parent.rootPage);
@ -26,6 +28,8 @@ export class TopbarPage extends BasePage {
this.btn_share = this.get().locator(`[data-testid="share-base-button"]`);
this.btn_data = this.get().locator(`.nc-tab:has-text("Data")`);
this.btn_details = this.get().locator(`.nc-tab:has-text("Details")`);
this.btn_cmdK = this.rootPage.locator('[data-testid="nc-topbar-cmd-k-btn"]');
this.btn_extension = this.get().locator('[data-testid="nc-topbar-extension-btn"]');
}
get() {
@ -122,4 +126,9 @@ export class TopbarPage extends BasePage {
const file = fs.readFileSync('./output/at.txt', 'utf8').replace(/\r/g, '').split('\n');
expect(file).toEqual(expectedData);
}
async verifyQuickActions({ isVisible }: { isVisible: boolean }) {
if (isVisible) await expect(this.btn_cmdK).toBeVisible();
else await expect(this.btn_cmdK).toHaveCount(0);
}
}

Loading…
Cancel
Save