Browse Source

Merge pull request #4630 from nocodb/feat/custom-table-icon

Feat: Custom table icon
pull/4697/merge
Pranav C 2 years ago committed by GitHub
parent
commit
55d20c1eb6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 77
      packages/nc-gui/components/dashboard/TreeView.vue
  2. 14
      packages/nc-gui/components/dashboard/settings/Metadata.vue
  3. 21
      packages/nc-gui/components/dashboard/settings/UIAcl.vue
  4. 6
      packages/nc-gui/components/erd/TableNode.vue
  5. 58
      packages/nc-gui/components/general/EmojiIcons.vue
  6. 22
      packages/nc-gui/components/general/TableIcon.vue
  7. 4
      packages/nc-gui/components/general/TruncateText.vue
  8. 26
      packages/nc-gui/components/general/ViewIcon.vue
  9. 10
      packages/nc-gui/components/smartsheet/column/CurrencyOptions.vue
  10. 8
      packages/nc-gui/components/smartsheet/column/LinkedToAnotherRecordOptions.vue
  11. 2
      packages/nc-gui/components/smartsheet/expanded-form/Header.vue
  12. 21
      packages/nc-gui/components/smartsheet/sidebar/MenuTop.vue
  13. 44
      packages/nc-gui/components/smartsheet/sidebar/RenameableMenuItem.vue
  14. 5
      packages/nc-gui/components/smartsheet/toolbar/SharedViewList.vue
  15. 7
      packages/nc-gui/components/smartsheet/toolbar/ViewActions.vue
  16. 9
      packages/nc-gui/components/smartsheet/toolbar/ViewInfo.vue
  17. 4
      packages/nc-gui/components/virtual-cell/components/ListChildItems.vue
  18. 7
      packages/nc-gui/composables/useSharedView.ts
  19. 4
      packages/nc-gui/composables/useTabs.ts
  20. 3
      packages/nc-gui/layouts/shared-view.vue
  21. 1
      packages/nc-gui/lib/types.ts
  22. 6
      packages/nc-gui/nuxt.config.ts
  23. 247
      packages/nc-gui/package-lock.json
  24. 5
      packages/nc-gui/package.json
  25. 11
      packages/nc-gui/pages/[projectType]/[projectId]/index/index.vue
  26. 1475
      packages/nc-gui/utils/iconUtils.ts
  27. 5
      packages/nocodb-sdk/src/lib/Api.ts
  28. 57
      packages/nocodb/src/lib/meta/api/metaDiffApis.ts
  29. 3
      packages/nocodb/src/lib/meta/api/modelVisibilityApis.ts
  30. 16
      packages/nocodb/src/lib/meta/api/tableApis.ts
  31. 71
      packages/nocodb/src/lib/models/Model.ts
  32. 28
      packages/nocodb/src/lib/models/View.ts
  33. 23
      packages/nocodb/src/lib/utils/modelUtils.ts
  34. 10
      scripts/sdk/swagger.json
  35. 4
      tests/playwright/pages/Dashboard/Grid/Column/LTAR/ChildList.ts
  36. 21
      tests/playwright/pages/Dashboard/TreeView.ts
  37. 21
      tests/playwright/pages/Dashboard/ViewSidebar/index.ts
  38. 7
      tests/playwright/tests/tableOperations.spec.ts
  39. 7
      tests/playwright/tests/viewKanban.spec.ts
  40. 6
      tests/playwright/tests/views.spec.ts

77
packages/nc-gui/components/dashboard/TreeView.vue

@ -1,14 +1,17 @@
<script setup lang="ts">
import type { TableType } from 'nocodb-sdk'
import type { Input } from 'ant-design-vue'
import { Dropdown, Tooltip, message } from 'ant-design-vue'
import Sortable from 'sortablejs'
import GithubButton from 'vue-github-button'
import { Icon } from '@iconify/vue'
import type { VNodeRef } from '#imports'
import {
ClientType,
Empty,
TabType,
computed,
extractSdkResponseErrorMsg,
isDrawerOrModalExist,
isMac,
reactive,
@ -27,7 +30,7 @@ import {
import MdiView from '~icons/mdi/eye-circle-outline'
import MdiTableLarge from '~icons/mdi/table-large'
const { addTab } = useTabs()
const { addTab, updateTab } = useTabs()
const { $api, $e } = useNuxtApp()
@ -77,7 +80,6 @@ const initSortable = (el: Element) => {
if (!base_id) return
if (sortables[base_id]) sortables[base_id].destroy()
Sortable.create(el as HTMLLIElement, {
handle: '.nc-drag-icon',
onEnd: async (evt) => {
const offset = tables.value.findIndex((table) => table.base_id === base_id)
@ -299,6 +301,26 @@ watch(
},
{ immediate: true },
)
const setIcon = async (icon: string, table: TableType) => {
try {
table.meta = {
...(table.meta || {}),
icon,
}
tables.value.splice(tables.value.indexOf(table), 1, { ...table })
updateTab({ id: table.id }, { meta: table.meta })
$api.dbTable.update(table.id as string, {
meta: table.meta,
})
$e('a:table:icon:navdraw', { icon })
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
</script>
<template>
@ -422,10 +444,10 @@ watch(
<div v-if="bases[0] && bases[0].enabled && !bases.slice(1).filter((el) => el.enabled)?.length" class="flex-1">
<div
v-if="isUIAllowed('table-create')"
class="group flex items-center gap-2 pl-8 pr-3 py-2 text-primary/70 hover:(text-primary/100) cursor-pointer select-none"
class="group flex items-center gap-2 pl-2 pr-3 py-2 text-primary/70 hover:(text-primary/100) cursor-pointer select-none"
@click="openTableCreateDialog(bases[0].id)"
>
<MdiPlus />
<MdiPlus class="w-5" />
<span class="text-gray-500 group-hover:(text-primary/100) flex-1 nc-add-new-table">{{ $t('tooltip.addTable') }}</span>
@ -558,26 +580,47 @@ watch(
:data-testid="`tree-view-table-${table.title}`"
@click="addTableTab(table)"
>
<GeneralTooltip class="pl-8 pr-3 py-2" modifier-key="Alt">
<GeneralTooltip class="pl-2 pr-3 py-2" modifier-key="Alt">
<template #title>{{ table.table_name }}</template>
<div class="flex items-center gap-2 h-full" @contextmenu="setMenuContext('table', table)">
<div class="flex w-auto" :data-testid="`tree-view-table-draggable-handle-${table.title}`">
<MdiDragVertical
v-if="isUIAllowed('treeview-drag-n-drop')"
:class="`nc-child-draggable-icon-${table.title}`"
class="nc-drag-icon text-xs hidden group-hover:block transition-opacity opacity-0 group-hover:opacity-100 text-gray-500 cursor-move"
@click.stop.prevent
/>
<component
:is="icon(table)"
class="nc-view-icon text-xs"
:class="{ 'group-hover:hidden group-hover:text-gray-500': isUIAllowed('treeview-drag-n-drop') }"
/>
:is="isUIAllowed('tableIconCustomisation') ? Dropdown : 'div'"
trigger="click"
destroy-popup-on-hide
class="flex items-center"
@click.stop
>
<div class="flex items-center" @click.stop>
<component :is="isUIAllowed('tableIconCustomisation') ? Tooltip : 'div'">
<span v-if="table.meta?.icon" :key="table.meta?.icon" class="nc-table-icon flex items-center">
<Icon
:key="table.meta?.icon"
:data-testid="`nc-icon-${table.meta?.icon}`"
class="text-xl"
:icon="table.meta?.icon"
></Icon>
</span>
<component
:is="icon(table)"
v-else
class="nc-table-icon nc-view-icon w-5"
:class="{ 'group-hover:text-gray-500': isUIAllowed('treeview-drag-n-drop') }"
/>
<template v-if="isUIAllowed('tableIconCustomisation')" #title>Change icon</template>
</component>
</div>
<template v-if="isUIAllowed('tableIconCustomisation')" #overlay>
<GeneralEmojiIcons class="shadow bg-white p-2" @select-icon="setIcon($event, table)" />
</template>
</component>
</div>
<div class="nc-tbl-title flex-1">
<GeneralTruncateText>{{ table.title }}</GeneralTruncateText>
<GeneralTruncateText :key="table.title" :length="activeTable === table.id ? 18 : 20">{{
table.title
}}</GeneralTruncateText>
</div>
<a-dropdown

14
packages/nc-gui/components/dashboard/settings/Metadata.vue

@ -70,8 +70,6 @@ const columns = [
// Models
title: tableHeaderRenderer(t('labels.models')),
key: 'table_name',
customRender: ({ record }: { record: { table_name: string; title?: string } }) =>
h('div', {}, record.title || record.table_name),
},
{
// Sync state
@ -97,7 +95,6 @@ const columns = [
</div>
</a-button>
</div>
<div class="max-h-600px overflow-y-auto">
<a-table
class="w-full"
@ -116,6 +113,17 @@ const columns = [
<template #emptyText>
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" :description="$t('labels.noData')" />
</template>
<template #bodyCell="{ record, column }">
<div v-if="column.key === 'table_name'">
<div class="flex items-center gap-1">
<div class="min-w-5 flex items-center justify-center">
<GeneralTableIcon :meta="record" class="text-gray-500"></GeneralTableIcon>
</div>
<span class="overflow-ellipsis min-w-0 shrink-1">{{ record.title || record.table_name }}</span>
</div>
</div>
</template>
</a-table>
</div>
</div>

21
packages/nc-gui/components/dashboard/settings/UIAcl.vue

@ -10,7 +10,6 @@ import {
useI18n,
useNuxtApp,
useProject,
viewIcons,
} from '#imports'
const props = defineProps<{
@ -159,12 +158,24 @@ const columns = [
</template>
<template #bodyCell="{ record, column }">
<div v-if="column.name === 'table_name'">{{ record._ptn }}</div>
<div v-if="column.name === 'table_name'">
<div class="flex items-center gap-1">
<div class="min-w-5 flex items-center justify-center">
<GeneralTableIcon
:meta="{ meta: record.table_meta, type: record.ptype }"
class="text-gray-500"
></GeneralTableIcon>
</div>
<span class="overflow-ellipsis min-w-0 shrink-1">{{ record._ptn }}</span>
</div>
</div>
<div v-if="column.name === 'view_name'">
<div class="flex items-center">
<component :is="viewIcons[record.type].icon" :class="`text-${viewIcons[record.type].color} mr-1`" />
{{ record.title }}
<div class="flex items-center gap-1">
<div class="min-w-5 flex items-center justify-center">
<GeneralViewIcon :meta="record" class="text-gray-500"></GeneralViewIcon>
</div>
<span class="overflow-ellipsis min-w-0 shrink-1">{{ record.title }}</span>
</div>
</div>

6
packages/nc-gui/components/erd/TableNode.vue

@ -59,10 +59,8 @@ watch(
:class="[showSkeleton ? '' : 'bg-primary bg-opacity-10', hasColumns ? 'border-b-1' : '']"
class="text-slate-600 text-md py-2 border-slate-500 rounded-t-lg w-full h-full px-3 font-semibold flex items-center"
>
<MdiTableLarge v-if="table.type === 'table'" class="text-primary" :class="showSkeleton ? 'text-6xl !px-2' : ''" />
<MdiEyeCircleOutline v-else class="text-primary" :class="showSkeleton ? 'text-6xl !px-2' : ''" />
<div :class="showSkeleton ? 'text-6xl' : ''" class="flex px-2">
<GeneralTableIcon class="text-primary" :class="{ '!text-6xl !w-auto mr-2': showSkeleton }" :meta="table" />
<div :class="showSkeleton ? 'text-6xl' : ''" class="flex pr-2 pl-1">
{{ table.title }}
</div>
</div>

58
packages/nc-gui/components/general/EmojiIcons.vue

@ -0,0 +1,58 @@
<script lang="ts" setup>
import { Icon } from '@iconify/vue'
import InfiniteLoading from 'v3-infinite-loading'
import { emojiIcons } from '#imports'
const emit = defineEmits(['selectIcon'])
let search = $ref('')
// keep a variable to load icons with infinite scroll
// set initial value to 60 to load first 60 icons (index - `0 - 59`)
// and next value will be 120 and shows first 120 icons ( index - `0 - 129`)
let toIndex = $ref(60)
const filteredIcons = computed(() => {
return emojiIcons.filter((icon) => !search || icon.toLowerCase().includes(search.toLowerCase())).slice(0, toIndex)
})
const load = () => {
// increment `toIndex` to include next set of icons
toIndex += Math.min(filteredIcons.value.length, toIndex + 60)
if (toIndex > filteredIcons.value.length) {
toIndex = filteredIcons.value.length
}
}
const selectIcon = (icon: string) => {
search = ''
emit('selectIcon', `emojione:${icon}`)
}
</script>
<template>
<div class="p-1 w-[280px] h-[280px] flex flex-col gap-1 justify-start nc-emoji" data-testid="nc-emoji-container">
<div @click.stop>
<input
v-model="search"
data-testid="nc-emoji-filter"
class="p-1 text-xs border-1 w-full overflow-y-auto"
placeholder="Search"
@input="toIndex = 60"
/>
</div>
<div class="flex gap-1 flex-wrap w-full flex-shrink overflow-y-auto scrollbar-thin-dull">
<div v-for="icon of filteredIcons" :key="icon" @click="selectIcon(icon)">
<span class="cursor-pointer nc-emoji-item">
<Icon class="text-xl iconify" :icon="`emojione:${icon}`"></Icon>
</span>
</div>
<InfiniteLoading @infinite="load"><span /></InfiniteLoading>
</div>
</div>
</template>
<style scoped>
.nc-emoji-item {
@apply hover:(bg-primary bg-opacity-10) active:(bg-primary !bg-opacity-20) rounded-md w-[38px] h-[38px] block flex items-center justify-center;
}
</style>

22
packages/nc-gui/components/general/TableIcon.vue

@ -0,0 +1,22 @@
<script lang="ts" setup>
import { Icon as IcIcon } from '@iconify/vue'
import type { TableType } from 'nocodb-sdk'
const { meta: tableMeta } = defineProps<{
meta: TableType
}>()
</script>
<template>
<IcIcon
v-if="tableMeta.meta?.icon"
:data-testid="`nc-icon-${tableMeta.meta?.icon}`"
class="text-lg"
:icon="tableMeta.meta?.icon"
/>
<MdiEyeCircleOutline v-else-if="tableMeta?.type === 'view'" class="w-5" />
<MdiTableLarge v-else class="w-5" />
</template>
<style scoped></style>

4
packages/nc-gui/components/general/TruncateText.vue

@ -41,7 +41,5 @@ const shortName = computed(() =>
<div v-else class="w-full" data-testid="truncate-label">
<slot />
</div>
<div ref="text" class="hidden">
<slot />
</div>
<div ref="text" class="hidden"><slot /></div>
</template>

26
packages/nc-gui/components/general/ViewIcon.vue

@ -0,0 +1,26 @@
<script lang="ts" setup>
import { Icon as IcIcon } from '@iconify/vue'
import type { TableType } from 'nocodb-sdk'
import { viewIcons } from '#imports'
const { meta: viewMeta } = defineProps<{
meta: TableType
}>()
</script>
<template>
<IcIcon
v-if="viewMeta?.meta?.icon"
:data-testid="`nc-icon-${viewMeta?.meta?.icon}`"
class="text-[16px]"
:icon="viewMeta?.meta?.icon"
/>
<component
:is="viewIcons[viewMeta.type]?.icon"
v-else
class="nc-view-icon group-hover"
:style="{ color: viewIcons[viewMeta.type]?.color }"
/>
</template>
<style scoped></style>

10
packages/nc-gui/components/smartsheet/column/CurrencyOptions.vue

@ -1,13 +1,5 @@
<script setup lang="ts">
import {
computed,
currencyCodes,
currencyLocales,
useProject,
useVModel,
validateCurrencyCode,
validateCurrencyLocale,
} from '#imports'
import { computed, currencyCodes, currencyLocales, useVModel, validateCurrencyCode, validateCurrencyLocale } from '#imports'
interface Option {
label: string

8
packages/nc-gui/components/smartsheet/column/LinkedToAnotherRecordOptions.vue

@ -73,7 +73,13 @@ const filterOption = (value: string, option: { key: string }) => option.key.toLo
@change="onDataTypeChange"
>
<a-select-option v-for="table of refTables" :key="table.title" :value="table.id">
{{ table.title }}
<div class="flex items-center gap-2">
<div class="min-w-5 flex items-center justify-center">
<GeneralTableIcon :meta="table" class="text-gray-500"></GeneralTableIcon>
</div>
<span class="overflow-ellipsis min-w-0 shrink-1">{{ table.title }}</span>
</div>
</a-select-option>
</a-select>
</a-form-item>

2
packages/nc-gui/components/smartsheet/expanded-form/Header.vue

@ -77,7 +77,7 @@ useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
<template>
<div class="flex p-2 items-center gap-2 p-4 nc-expanded-form-header">
<h5 class="text-lg font-weight-medium flex items-center gap-1 mb-0 min-w-0 overflow-x-hidden truncate">
<mdi-table-arrow-right :style="{ color: iconColor }" />
<GeneralTableIcon :style="{ color: iconColor }" :meta="meta" class="mx-2" />
<template v-if="meta">
{{ meta.title }}

21
packages/nc-gui/components/smartsheet/sidebar/MenuTop.vue

@ -140,7 +140,7 @@ const initSortable = (el: HTMLElement) => {
if (sortable) sortable.destroy()
sortable = new Sortable(el, {
handle: '.nc-drag-icon',
// handle: '.nc-drag-icon',
ghostClass: 'ghost',
onStart: onSortStart,
onEnd: onSortEnd,
@ -213,6 +213,24 @@ function openDeleteDialog(view: ViewType) {
close(1000)
}
}
const setIcon = async (icon: string, view: ViewType) => {
try {
// modify the icon property in meta
view.meta = {
...(view.meta || {}),
icon,
}
api.dbView.update(view.id as string, {
meta: view.meta,
})
$e('a:view:icon:sidebar', { icon })
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
</script>
<template>
@ -234,6 +252,7 @@ function openDeleteDialog(view: ViewType) {
@open-modal="$emit('openModal', $event)"
@delete="openDeleteDialog"
@rename="onRename"
@select-icon="setIcon($event, view)"
/>
</a-menu>
</template>

44
packages/nc-gui/components/smartsheet/sidebar/RenameableMenuItem.vue

@ -1,18 +1,8 @@
<script lang="ts" setup>
import type { KanbanType, ViewType, ViewTypes } from 'nocodb-sdk'
import type { WritableComputedRef } from '@vue/reactivity'
import {
IsLockedInj,
computed,
inject,
message,
onKeyStroke,
useDebounceFn,
useNuxtApp,
useUIPermission,
useVModel,
viewIcons,
} from '#imports'
import { Tooltip } from 'ant-design-vue'
import { IsLockedInj, inject, message, onKeyStroke, useDebounceFn, useNuxtApp, useUIPermission, useVModel } from '#imports'
interface Props {
view: ViewType
@ -21,9 +11,15 @@ interface Props {
interface Emits {
(event: 'update:view', data: Record<string, any>): void
(event: 'selectIcon', icon: string): void
(event: 'changeView', view: Record<string, any>): void
(event: 'rename', view: ViewType): void
(event: 'delete', view: ViewType): void
(event: 'openModal', data: { type: ViewTypes; title?: string; copyViewId?: string; groupingFieldColumnId?: string }): void
}
@ -48,8 +44,6 @@ let isStopped = $ref(false)
/** Original view title when editing the view name */
let originalTitle = $ref<string | undefined>()
const viewType = computed(() => vModel.value.type as number)
/** Debounce click handler, so we can potentially enable editing view name {@see onDblClick} */
const onClick = useDebounceFn(() => {
if (isEditing || isStopped) return
@ -172,17 +166,17 @@ function onStopEdit() {
@click.stop="onClick"
>
<div v-e="['a:view:open', { view: vModel.type }]" class="text-xs flex items-center w-full gap-2" data-testid="view-item">
<div class="flex w-auto" :data-testid="`view-sidebar-drag-handle-${vModel.alias || vModel.title}`">
<MdiDrag
class="nc-drag-icon hidden group-hover:block transition-opacity opacity-0 group-hover:opacity-100 text-gray-500 !cursor-move"
@click.stop.prevent
/>
<component
:is="viewIcons[viewType].icon"
class="nc-view-icon group-hover:hidden"
:style="{ color: viewIcons[viewType].color }"
/>
<div class="flex w-auto min-w-5" :data-testid="`view-sidebar-drag-handle-${vModel.alias || vModel.title}`">
<a-dropdown :trigger="['click']" @click.stop>
<component :is="isUIAllowed('tableIconCustomisation') ? Tooltip : 'div'">
<GeneralViewIcon :meta="props.view" class="nc-view-icon"></GeneralViewIcon>
<template v-if="isUIAllowed('tableIconCustomisation')" #title>Change icon</template>
</component>
<template v-if="isUIAllowed('viewIconCustomisation')" #overlay>
<GeneralEmojiIcons class="shadow bg-white p-2" @select-icon="emits('selectIcon', $event)" />
</template>
</a-dropdown>
</div>
<a-input v-if="isEditing" :ref="focusInput" v-model:value="vModel.title" @blur="onCancel" @keydown="onKeyDown($event)" />

5
packages/nc-gui/components/smartsheet/toolbar/SharedViewList.vue

@ -108,8 +108,9 @@ const deleteLink = async (id: string) => {
<!-- View name -->
<a-table-column key="title" :title="$t('labels.viewName')" data-index="title">
<template #default="{ text }">
<div class="text-xs" :title="text">
<template #default="{ text, record }">
<div class="text-xs flex items-center gap-1" :title="text">
<GeneralViewIcon class="w-5" :meta="record" />
{{ text }}
</div>
</template>

7
packages/nc-gui/components/smartsheet/toolbar/ViewActions.vue

@ -4,7 +4,6 @@ import {
IsLockedInj,
IsPublicInj,
extractSdkResponseErrorMsg,
getViewIcon,
inject,
message,
ref,
@ -93,11 +92,7 @@ useMenuCloseOnEsc(open)
<a-dropdown v-model:visible="open" :trigger="['click']" overlay-class-name="nc-dropdown-actions-menu">
<a-button v-e="['c:actions']" class="nc-actions-menu-btn nc-toolbar-btn">
<div class="flex gap-2 items-center">
<component
:is="getViewIcon(selectedView?.type)?.icon"
class="nc-view-icon group-hover:hidden"
:style="{ color: getViewIcon(selectedView?.type)?.color }"
/>
<GeneralViewIcon :meta="selectedView"></GeneralViewIcon>
<span class="!text-sm font-weight-normal">
<GeneralTruncateText>{{ selectedView?.title }}</GeneralTruncateText>

9
packages/nc-gui/components/smartsheet/toolbar/ViewInfo.vue

@ -1,17 +1,12 @@
<script setup lang="ts">
import { ActiveViewInj, inject, viewIcons } from '#imports'
import { ActiveViewInj, inject } from '#imports'
const selectedView = inject(ActiveViewInj)
</script>
<template>
<div class="flex gap-2 items-center ml-2 mr-2 pr-4 pb-1 py-0.5 border-r-1 border-gray-100">
<component
:is="viewIcons[selectedView?.type].icon"
v-if="selectedView?.type"
class="nc-view-icon group-hover:hidden"
:style="{ color: viewIcons[selectedView?.type].color }"
/>
<GeneralViewIcon class="nc-view-icon" :meta="selectedView" />
<span class="!text-sm font-medium max-w-36 overflow-ellipsis overflow-hidden whitespace-nowrap">
{{ selectedView?.title }}

4
packages/nc-gui/components/virtual-cell/components/ListChildItems.vue

@ -129,7 +129,9 @@ const onClick = (row: Row) => {
>
<div class="flex items-center gap-1">
<MdiLinkVariant class="text-xs" type="primary" />
Link to '{{ relatedTableMeta.title }}'
Link to '
<GeneralTableIcon :meta="relatedTableMeta" class="-mx-1 w-5" />
{{ relatedTableMeta.title }}'
</div>
</a-button>
</div>

7
packages/nc-gui/composables/useSharedView.ts

@ -58,8 +58,11 @@ export function useSharedView() {
'xc-password': localPassword ?? password.value,
},
})
allowCSVDownload.value = JSON.parse(viewMeta.meta)?.allowCSVDownload
try {
allowCSVDownload.value = (typeof viewMeta.meta === 'string' ? JSON.parse(viewMeta.meta) : viewMeta.meta)?.allowCSVDownload
} catch {
allowCSVDownload.value = false
}
if (localPassword) password.value = localPassword
sharedView.value = { title: '', ...viewMeta }

4
packages/nc-gui/composables/useTabs.ts

@ -41,6 +41,8 @@ const [setup, use] = useInjectionState(() => {
tab.title = currentTable.title
tab.meta = currentTable.meta
// append base alias to tab title if duplicate titles exist on other bases
if (tables.value.find((t) => t.title === currentTable?.title && t.base_id !== currentTable?.base_id))
tab.title = `${tab.title}${currentBase?.alias ? ` (${currentBase.alias})` : ``}`
@ -92,6 +94,8 @@ const [setup, use] = useInjectionState(() => {
const currentTable = tables.value.find((t) => t.id === tabMeta.id || t.title === tabMeta.id)
const currentBase = bases.value.find((b) => b.id === currentTable?.base_id)
tabMeta.meta = currentTable?.meta
// append base alias to tab title if duplicate titles exist on other bases
if (tables.value.find((t) => t.title === currentTable?.title && t.base_id !== currentTable?.base_id))
tabMeta.title = `${tabMeta.title}${currentBase?.alias ? ` (${currentBase.alias})` : ``}`

3
packages/nc-gui/layouts/shared-view.vue

@ -58,7 +58,8 @@ export default {
<MdiReload :class="{ 'animate-infinite animate-spin ': isLoading }" />
</template>
<div v-else class="text-xl font-semibold truncate text-white nc-shared-view-title">
<div v-else class="text-xl font-semibold truncate text-white nc-shared-view-title flex gap-2 items-center">
<GeneralViewIcon class="!text-xl" :meta="sharedView" />
{{ sharedView?.title }}
</div>
</div>

1
packages/nc-gui/lib/types.ts

@ -78,6 +78,7 @@ export interface TabItem {
viewId?: string
sortsState?: Map<string, any>
filterState?: Map<string, any>
meta?: Record<string, any>
}
export interface SharedViewMeta extends Record<string, any> {

6
packages/nc-gui/nuxt.config.ts

@ -7,6 +7,8 @@ import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers'
import monacoEditorPlugin from 'vite-plugin-monaco-editor'
import { NodeModulesPolyfillPlugin } from '@esbuild-plugins/node-modules-polyfill'
import PurgeIcons from 'vite-plugin-purge-icons'
// https://v3.nuxtjs.org/api/configuration/nuxt.config
export default defineNuxtConfig({
modules: ['@vueuse/nuxt', 'nuxt-windicss', '@nuxt/image-edge'],
@ -138,6 +140,10 @@ export default defineNuxtConfig({
monacoEditorPlugin({
languageWorkers: ['json'],
}),
PurgeIcons({
/* PurgeIcons Options */
includedCollections: ['emojione'],
}),
],
define: {
'process.env.DEBUG': 'false',

247
packages/nc-gui/package-lock.json generated

@ -9,6 +9,7 @@
"license": "AGPL-3.0-or-later",
"dependencies": {
"@ckpack/vue-color": "^1.2.0",
"@iconify/vue": "^4.0.1",
"@types/file-saver": "^2.0.5",
"@vue-flow/additional-components": "^1.2.0",
"@vue-flow/core": "^1.3.0",
@ -34,6 +35,7 @@
"sortablejs": "^1.15.0",
"tinycolor2": "^1.4.2",
"unique-names-generator": "^4.7.1",
"v3-infinite-loading": "^1.2.2",
"validator": "^13.7.0",
"vue-dompurify-html": "^3.0.0",
"vue-github-button": "^3.0.3",
@ -86,6 +88,7 @@
"unplugin-icons": "^0.14.7",
"unplugin-vue-components": "^0.22.4",
"vite-plugin-monaco-editor": "^1.1.0",
"vite-plugin-purge-icons": "^0.9.0",
"vitest": "^0.18.0",
"windicss": "^3.5.6"
}
@ -1204,6 +1207,15 @@
"@iconify/types": "*"
}
},
"node_modules/@iconify/iconify": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/@iconify/iconify/-/iconify-2.2.1.tgz",
"integrity": "sha512-WJzw+3iicrF/tbjbxxRinSgy5FHdJoz/egTqwi3xCDkNRJPq482RX1iyaWrjNuY2vMNSPkQMuqHvZDXgA+WnwQ==",
"dev": true,
"funding": {
"url": "http://github.com/sponsors/cyberalien"
}
},
"node_modules/@iconify/types": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-1.1.0.tgz",
@ -1224,6 +1236,25 @@
"local-pkg": "^0.4.1"
}
},
"node_modules/@iconify/vue": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@iconify/vue/-/vue-4.0.1.tgz",
"integrity": "sha512-k4VwcSQpGqJpoyqENRRviFuXlVcquLvQ6BKLNJ6o2amZo7u+3HyALSO79Xyz7Sg68szQGstOk6weaKUF0DJbog==",
"dependencies": {
"@iconify/types": "^2.0.0"
},
"funding": {
"url": "https://github.com/sponsors/cyberalien"
},
"peerDependencies": {
"vue": ">=3"
}
},
"node_modules/@iconify/vue/node_modules/@iconify/types": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
"integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="
},
"node_modules/@intlify/bundle-utils": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@intlify/bundle-utils/-/bundle-utils-2.2.2.tgz",
@ -2568,6 +2599,49 @@
"integrity": "sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==",
"dev": true
},
"node_modules/@purge-icons/core": {
"version": "0.9.1",
"resolved": "https://registry.npmjs.org/@purge-icons/core/-/core-0.9.1.tgz",
"integrity": "sha512-sx8/a30MbbqQVEqhuMPE1wJpdVRRbEmwEPZpFzVkcDixzX4p+R2A0WVxqkb0xfHUBAVQwrSE2SeAyniIQLqbLw==",
"dev": true,
"dependencies": {
"@iconify/iconify": "2.1.2",
"axios": "^0.26.0",
"debug": "^4.3.3",
"fast-glob": "^3.2.11",
"fs-extra": "^10.0.1"
}
},
"node_modules/@purge-icons/core/node_modules/@iconify/iconify": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@iconify/iconify/-/iconify-2.1.2.tgz",
"integrity": "sha512-QcUzFeEWkE/mW+BVtEGmcWATClcCOIJFiYUD/PiCWuTcdEA297o8D4oN6Ra44WrNOHu1wqNW4J0ioaDIiqaFOQ==",
"dev": true,
"dependencies": {
"cross-fetch": "^3.1.5"
},
"funding": {
"url": "http://github.com/sponsors/cyberalien"
}
},
"node_modules/@purge-icons/core/node_modules/axios": {
"version": "0.26.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.26.1.tgz",
"integrity": "sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==",
"dev": true,
"dependencies": {
"follow-redirects": "^1.14.8"
}
},
"node_modules/@purge-icons/generated": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@purge-icons/generated/-/generated-0.9.0.tgz",
"integrity": "sha512-s2t+1oVtGDV6KtqfCXtUOhxfeYvOdDF90IVm+nMs/6bUP0HeGZLslguuL/AibpwtfL4FA/oCsIu/RhwapgAdJw==",
"dev": true,
"dependencies": {
"@iconify/iconify": ">=2.0.0-rc.6"
}
},
"node_modules/@rollup/plugin-alias": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@rollup/plugin-alias/-/plugin-alias-4.0.2.tgz",
@ -5755,6 +5829,35 @@
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"dev": true
},
"node_modules/cross-fetch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz",
"integrity": "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==",
"dev": true,
"dependencies": {
"node-fetch": "2.6.7"
}
},
"node_modules/cross-fetch/node_modules/node-fetch": {
"version": "2.6.7",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz",
"integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==",
"dev": true,
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
@ -14167,6 +14270,19 @@
"rollup-plugin-inject": "^3.0.0"
}
},
"node_modules/rollup-plugin-purge-icons": {
"version": "0.9.1",
"resolved": "https://registry.npmjs.org/rollup-plugin-purge-icons/-/rollup-plugin-purge-icons-0.9.1.tgz",
"integrity": "sha512-hRDKBsPUz47UMdBufki2feTmBF2ClEJlYqL7N6vpVAHSLd7V2BJUaNKOF7YYbLMofVVF+9hm44YSkYO6k9hUgg==",
"dev": true,
"dependencies": {
"@purge-icons/core": "^0.9.1",
"@purge-icons/generated": "^0.9.0"
},
"engines": {
"node": ">= 12"
}
},
"node_modules/rollup-plugin-terser": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/rollup-plugin-terser/-/rollup-plugin-terser-7.0.2.tgz",
@ -16075,6 +16191,11 @@
"node": ">= 0.4.0"
}
},
"node_modules/v3-infinite-loading": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/v3-infinite-loading/-/v3-infinite-loading-1.2.2.tgz",
"integrity": "sha512-MWJc6yChnqeUasBFJ3Enu8IGPcQgRMSTrAEtT1MsHBEx+QjwvNTaY8o+8V9DgVt1MVhQSl3MC55hsaWLJmpRMw=="
},
"node_modules/v8-compile-cache": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz",
@ -16673,6 +16794,23 @@
"monaco-editor": ">=0.33.0"
}
},
"node_modules/vite-plugin-purge-icons": {
"version": "0.9.1",
"resolved": "https://registry.npmjs.org/vite-plugin-purge-icons/-/vite-plugin-purge-icons-0.9.1.tgz",
"integrity": "sha512-oS0Y9Iq6vGnTDVRzB8xJNhA/gGlyR0lfCICU6+9FRKdrO5PnT34fRjvd8YWEsegCrk91+w3GVZc0HJDj/dPp5Q==",
"dev": true,
"dependencies": {
"@purge-icons/core": "^0.9.1",
"@purge-icons/generated": "^0.9.0",
"rollup-plugin-purge-icons": "^0.9.1"
},
"engines": {
"node": ">= 12"
},
"peerDependencies": {
"vite": "^2.0.0-beta.3 || ^3.0.0"
}
},
"node_modules/vite-plugin-windicss": {
"version": "1.8.7",
"resolved": "https://registry.npmjs.org/vite-plugin-windicss/-/vite-plugin-windicss-1.8.7.tgz",
@ -18461,6 +18599,12 @@
"@iconify/types": "*"
}
},
"@iconify/iconify": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/@iconify/iconify/-/iconify-2.2.1.tgz",
"integrity": "sha512-WJzw+3iicrF/tbjbxxRinSgy5FHdJoz/egTqwi3xCDkNRJPq482RX1iyaWrjNuY2vMNSPkQMuqHvZDXgA+WnwQ==",
"dev": true
},
"@iconify/types": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-1.1.0.tgz",
@ -18481,6 +18625,21 @@
"local-pkg": "^0.4.1"
}
},
"@iconify/vue": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@iconify/vue/-/vue-4.0.1.tgz",
"integrity": "sha512-k4VwcSQpGqJpoyqENRRviFuXlVcquLvQ6BKLNJ6o2amZo7u+3HyALSO79Xyz7Sg68szQGstOk6weaKUF0DJbog==",
"requires": {
"@iconify/types": "^2.0.0"
},
"dependencies": {
"@iconify/types": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
"integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="
}
}
},
"@intlify/bundle-utils": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@intlify/bundle-utils/-/bundle-utils-2.2.2.tgz",
@ -19424,6 +19583,48 @@
"integrity": "sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==",
"dev": true
},
"@purge-icons/core": {
"version": "0.9.1",
"resolved": "https://registry.npmjs.org/@purge-icons/core/-/core-0.9.1.tgz",
"integrity": "sha512-sx8/a30MbbqQVEqhuMPE1wJpdVRRbEmwEPZpFzVkcDixzX4p+R2A0WVxqkb0xfHUBAVQwrSE2SeAyniIQLqbLw==",
"dev": true,
"requires": {
"@iconify/iconify": "2.1.2",
"axios": "^0.26.0",
"debug": "^4.3.3",
"fast-glob": "^3.2.11",
"fs-extra": "^10.0.1"
},
"dependencies": {
"@iconify/iconify": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@iconify/iconify/-/iconify-2.1.2.tgz",
"integrity": "sha512-QcUzFeEWkE/mW+BVtEGmcWATClcCOIJFiYUD/PiCWuTcdEA297o8D4oN6Ra44WrNOHu1wqNW4J0ioaDIiqaFOQ==",
"dev": true,
"requires": {
"cross-fetch": "^3.1.5"
}
},
"axios": {
"version": "0.26.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.26.1.tgz",
"integrity": "sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==",
"dev": true,
"requires": {
"follow-redirects": "^1.14.8"
}
}
}
},
"@purge-icons/generated": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@purge-icons/generated/-/generated-0.9.0.tgz",
"integrity": "sha512-s2t+1oVtGDV6KtqfCXtUOhxfeYvOdDF90IVm+nMs/6bUP0HeGZLslguuL/AibpwtfL4FA/oCsIu/RhwapgAdJw==",
"dev": true,
"requires": {
"@iconify/iconify": ">=2.0.0-rc.6"
}
},
"@rollup/plugin-alias": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@rollup/plugin-alias/-/plugin-alias-4.0.2.tgz",
@ -21733,6 +21934,26 @@
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"dev": true
},
"cross-fetch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz",
"integrity": "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==",
"dev": true,
"requires": {
"node-fetch": "2.6.7"
},
"dependencies": {
"node-fetch": {
"version": "2.6.7",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz",
"integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==",
"dev": true,
"requires": {
"whatwg-url": "^5.0.0"
}
}
}
},
"cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
@ -27878,6 +28099,16 @@
"rollup-plugin-inject": "^3.0.0"
}
},
"rollup-plugin-purge-icons": {
"version": "0.9.1",
"resolved": "https://registry.npmjs.org/rollup-plugin-purge-icons/-/rollup-plugin-purge-icons-0.9.1.tgz",
"integrity": "sha512-hRDKBsPUz47UMdBufki2feTmBF2ClEJlYqL7N6vpVAHSLd7V2BJUaNKOF7YYbLMofVVF+9hm44YSkYO6k9hUgg==",
"dev": true,
"requires": {
"@purge-icons/core": "^0.9.1",
"@purge-icons/generated": "^0.9.0"
}
},
"rollup-plugin-terser": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/rollup-plugin-terser/-/rollup-plugin-terser-7.0.2.tgz",
@ -29313,6 +29544,11 @@
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"dev": true
},
"v3-infinite-loading": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/v3-infinite-loading/-/v3-infinite-loading-1.2.2.tgz",
"integrity": "sha512-MWJc6yChnqeUasBFJ3Enu8IGPcQgRMSTrAEtT1MsHBEx+QjwvNTaY8o+8V9DgVt1MVhQSl3MC55hsaWLJmpRMw=="
},
"v8-compile-cache": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz",
@ -29624,6 +29860,17 @@
"dev": true,
"requires": {}
},
"vite-plugin-purge-icons": {
"version": "0.9.1",
"resolved": "https://registry.npmjs.org/vite-plugin-purge-icons/-/vite-plugin-purge-icons-0.9.1.tgz",
"integrity": "sha512-oS0Y9Iq6vGnTDVRzB8xJNhA/gGlyR0lfCICU6+9FRKdrO5PnT34fRjvd8YWEsegCrk91+w3GVZc0HJDj/dPp5Q==",
"dev": true,
"requires": {
"@purge-icons/core": "^0.9.1",
"@purge-icons/generated": "^0.9.0",
"rollup-plugin-purge-icons": "^0.9.1"
}
},
"vite-plugin-windicss": {
"version": "1.8.7",
"resolved": "https://registry.npmjs.org/vite-plugin-windicss/-/vite-plugin-windicss-1.8.7.tgz",

5
packages/nc-gui/package.json

@ -32,6 +32,8 @@
},
"dependencies": {
"@ckpack/vue-color": "^1.2.0",
"@iconify/vue": "^4.0.1",
"@types/file-saver": "^2.0.5",
"@vue-flow/additional-components": "^1.2.0",
"@vue-flow/core": "^1.3.0",
"@vuelidate/core": "^2.0.0-alpha.44",
@ -41,7 +43,6 @@
"ant-design-vue": "^3.2.11",
"d3-scale": "^4.0.2",
"dagre": "^0.8.5",
"@types/file-saver": "^2.0.5",
"dayjs": "^1.11.3",
"file-saver": "^2.0.5",
"httpsnippet": "^2.0.0",
@ -57,6 +58,7 @@
"sortablejs": "^1.15.0",
"tinycolor2": "^1.4.2",
"unique-names-generator": "^4.7.1",
"v3-infinite-loading": "^1.2.2",
"validator": "^13.7.0",
"vue-dompurify-html": "^3.0.0",
"vue-github-button": "^3.0.3",
@ -109,6 +111,7 @@
"unplugin-icons": "^0.14.7",
"unplugin-vue-components": "^0.22.4",
"vite-plugin-monaco-editor": "^1.1.0",
"vite-plugin-purge-icons": "^0.9.0",
"vitest": "^0.18.0",
"windicss": "^3.5.6"
}

11
packages/nc-gui/pages/[projectType]/[projectId]/index/index.vue

@ -1,4 +1,5 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import type { TabItem } from '~/lib'
import { TabType } from '~/lib'
import { TabMetaInj, iconMap, provide, useGlobal, useSidebar, useTabs } from '#imports'
@ -46,9 +47,15 @@ function onEdit(targetKey: number, action: 'add' | 'remove' | string) {
<a-tabs v-model:activeKey="activeTabIndex" class="nc-root-tabs" type="editable-card" @edit="onEdit">
<a-tab-pane v-for="(tab, i) of tabs" :key="i">
<template #tab>
<div class="flex items-center gap-2 max-w-[110px]">
<div class="flex items-center gap-2 max-w-[110px]" data-testid="nc-tab-title">
<div class="flex items-center">
<component :is="icon(tab)" class="text-sm" />
<Icon
v-if="tab.meta?.icon"
:icon="tab.meta?.icon"
class="text-xl"
:data-testid="`nc-tab-icon-${tab.meta?.icon}`"
/>
<component :is="icon(tab)" v-else class="text-sm" />
</div>
<a-tooltip v-if="tab.title?.length > 12" placement="bottom">

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

File diff suppressed because it is too large Load Diff

5
packages/nocodb-sdk/src/lib/Api.ts

@ -124,6 +124,7 @@ export interface TableType {
columnsById?: object;
slug?: string;
mm?: boolean | number;
meta?: any;
}
export interface ViewType {
@ -134,6 +135,7 @@ export interface ViewType {
fk_model_id?: string;
slug?: string;
uuid?: string;
meta?: any;
show_system_fields?: boolean;
lock_type?: 'collaborative' | 'locked' | 'personal';
type?: number;
@ -175,6 +177,7 @@ export interface TableReqType {
order?: number;
mm?: boolean;
columns: ColumnType[];
meta?: any;
}
export interface TableListType {
@ -2159,6 +2162,7 @@ export class Api<
table_name?: string;
title?: string;
project_id?: string;
meta?: any;
},
params: RequestParams = {}
) =>
@ -2313,6 +2317,7 @@ export class Api<
viewId: string,
data: {
order?: number;
meta?: any;
title?: string;
show_system_fields?: boolean;
lock_type?: 'collaborative' | 'locked' | 'personal';

57
packages/nocodb/src/lib/meta/api/metaDiffApis.ts

@ -44,6 +44,7 @@ type MetaDiff = {
table_name: string;
base_id: string;
type: ModelTypes;
meta?: any;
detectedChanges: Array<MetaDiffChange>;
};
@ -176,6 +177,7 @@ async function getMetaDiff(
const tableProp: MetaDiff = {
title: oldMeta.title,
meta: oldMeta.meta,
table_name: table.tn,
base_id: base.id,
type: ModelTypes.TABLE,
@ -248,6 +250,7 @@ async function getMetaDiff(
for (const model of oldTableMetas) {
changes.push({
table_name: model.table_name,
meta: model.meta,
base_id: base.id,
type: ModelTypes.TABLE,
detectedChanges: [
@ -452,6 +455,7 @@ async function getMetaDiff(
const tableProp: MetaDiff = {
title: oldMeta.title,
meta: oldMeta.meta,
table_name: view.tn,
base_id: base.id,
type: ModelTypes.VIEW,
@ -520,6 +524,7 @@ async function getMetaDiff(
for (const model of oldViewMetas) {
changes.push({
table_name: model.table_name,
meta: model.meta,
base_id: base.id,
type: ModelTypes.TABLE,
detectedChanges: [
@ -539,7 +544,7 @@ async function getMetaDiff(
export async function metaDiff(req, res) {
const project = await Project.getWithInfo(req.params.projectId);
let changes = []
let changes = [];
for (const base of project.bases) {
try {
// @ts-ignore
@ -556,7 +561,7 @@ export async function metaDiff(req, res) {
export async function baseMetaDiff(req, res) {
const project = await Project.getWithInfo(req.params.projectId);
const base = await Base.get(req.params.baseId);
let changes = []
let changes = [];
const sqlClient = NcConnectionMgrv2.getSqlClient(base);
changes = await getMetaDiff(sqlClient, project, base);
@ -572,10 +577,10 @@ export async function metaDiffSync(req, res) {
// @ts-ignore
const sqlClient = NcConnectionMgrv2.getSqlClient(base);
const changes = await getMetaDiff(sqlClient, project, base);
/* Get all relations */
// const relations = (await sqlClient.relationListAll())?.data?.list;
for (const { table_name, detectedChanges } of changes) {
// reorder changes to apply relation remove changes
// before column remove to avoid foreign key constraint error
@ -585,7 +590,7 @@ export async function metaDiffSync(req, res) {
applyChangesPriorityOrder.indexOf(a.type)
);
});
for (const change of detectedChanges) {
switch (change.type) {
case MetaDiffType.TABLE_NEW:
@ -593,15 +598,19 @@ export async function metaDiffSync(req, res) {
const columns = (
await sqlClient.columnList({ tn: table_name })
)?.data?.list?.map((c) => ({ ...c, column_name: c.cn }));
mapDefaultPrimaryValue(columns);
const model = await Model.insert(project.id, base.id, {
table_name: table_name,
title: getTableNameAlias(table_name, base.is_meta ? project.prefix : '', base),
title: getTableNameAlias(
table_name,
base.is_meta ? project.prefix : '',
base
),
type: ModelTypes.TABLE,
});
for (const column of columns) {
await Column.insert({
uidt: getColumnUiType(base, column),
@ -617,15 +626,15 @@ export async function metaDiffSync(req, res) {
const columns = (
await sqlClient.columnList({ tn: table_name })
)?.data?.list?.map((c) => ({ ...c, column_name: c.cn }));
mapDefaultPrimaryValue(columns);
const model = await Model.insert(project.id, base.id, {
table_name: table_name,
title: getTableNameAlias(table_name, project.prefix, base),
type: ModelTypes.VIEW,
});
for (const column of columns) {
await Column.insert({
uidt: getColumnUiType(base, column),
@ -657,7 +666,7 @@ export async function metaDiffSync(req, res) {
// update old
// populateParams.tableNames.push({ tn });
// populateParams.oldMetas[tn] = oldMetas.find(m => m.tn === tn);
break;
case MetaDiffType.TABLE_COLUMN_TYPE_CHANGE:
case MetaDiffType.VIEW_COLUMN_TYPE_CHANGE:
@ -698,17 +707,21 @@ export async function metaDiffSync(req, res) {
});
const parentCol = await parentModel
.getColumns()
.then((cols) => cols.find((c) => c.column_name === change.rcn));
.then((cols) =>
cols.find((c) => c.column_name === change.rcn)
);
const childCol = await childModel
.getColumns()
.then((cols) => cols.find((c) => c.column_name === change.cn));
.then((cols) =>
cols.find((c) => c.column_name === change.cn)
);
await Column.update(childCol.id, {
...childCol,
uidt: UITypes.ForeignKey,
system: true,
});
if (change.relationType === RelationTypes.BELONGS_TO) {
const title = getUniqueColumnAliasName(
childModel.columns,
@ -746,9 +759,9 @@ export async function metaDiffSync(req, res) {
}
}
}
await NcHelp.executeOperations(virtualColumnInsert, base.type);
// populate m2m relations
await extractAndGenerateManyToManyRelations(await base.getModels());
}
@ -784,7 +797,11 @@ export async function baseMetaDiffSync(req, res) {
const model = await Model.insert(project.id, base.id, {
table_name: table_name,
title: getTableNameAlias(table_name, base.is_meta ? project.prefix : '', base),
title: getTableNameAlias(
table_name,
base.is_meta ? project.prefix : '',
base
),
type: ModelTypes.TABLE,
});

3
packages/nocodb/src/lib/meta/api/modelVisibilityApis.ts

@ -48,7 +48,7 @@ export async function xcVisibilityMetaGet(
const roles = ['owner', 'creator', 'viewer', 'editor', 'commenter', 'guest'];
const defaultDisabled = roles.reduce((o, r) => ({ ...o, [r]: false }), {});
let models =
_models ||
(await Model.list({
@ -78,6 +78,7 @@ export async function xcVisibilityMetaGet(
ptype: model.type,
tn: view.title,
_tn: view.title,
table_meta: model.meta,
...view,
disabled: { ...defaultDisabled },
};

16
packages/nocodb/src/lib/meta/api/tableApis.ts

@ -228,9 +228,23 @@ export async function tableCreate(req: Request<any, any, TableReqType>, res) {
export async function tableUpdate(req: Request<any, any>, res) {
const model = await Model.get(req.params.tableId);
const project = await Project.getWithInfo(req.body.project_id);
const project = await Project.getWithInfo(
req.body.project_id || (req as any).ncProjectId
);
const base = project.bases.find((b) => b.id === model.base_id);
if (model.project_id !== project.id) {
NcError.badRequest('Model does not belong to project');
}
// if meta present update meta and return
// todo: allow user to update meta and other prop in single api call
if ('meta' in req.body) {
await Model.updateMeta(req.params.tableId, req.body.meta);
return res.json({ msg: 'success' });
}
if (!req.body.table_name) {
NcError.badRequest(
'Missing table name `table_name` property in request body'

71
packages/nocodb/src/lib/models/Model.ts

@ -1,4 +1,5 @@
import Noco from '../Noco';
import { parseMetaProp } from '../utils/modelUtils';
import Column from './Column';
import NocoCache from '../cache/NocoCache';
import { XKnex } from '../db/sql-data-mapper';
@ -51,6 +52,7 @@ export default class Model implements TableType {
columns?: Column[];
columnsById?: { [id: string]: Column };
views?: View[];
meta?: Record<string, any> | string;
constructor(data: Partial<TableType | Model>) {
Object.assign(this, data);
@ -175,8 +177,17 @@ export default class Model implements TableType {
}
);
// parse meta of each model
for (const model of modelList) {
model.meta = parseMetaProp(model);
}
if (base_id) {
await NocoCache.setList(CacheScope.MODEL, [project_id, base_id], modelList);
await NocoCache.setList(
CacheScope.MODEL,
[project_id, base_id],
modelList
);
} else {
await NocoCache.setList(CacheScope.MODEL, [project_id], modelList);
}
@ -210,6 +221,11 @@ export default class Model implements TableType {
MetaTable.MODELS
);
// parse meta of each model
for (const model of modelList) {
model.meta = parseMetaProp(model);
}
await NocoCache.setList(CacheScope.MODEL, [project_id], modelList);
}
@ -230,8 +246,11 @@ export default class Model implements TableType {
));
if (!modelData) {
modelData = await ncMeta.metaGet2(null, null, MetaTable.MODELS, id);
if (modelData)
if (modelData) {
modelData.meta = parseMetaProp(modelData);
await NocoCache.set(`${CacheScope.MODEL}:${modelData.id}`, modelData);
}
}
return modelData && new Model(modelData);
}
@ -257,24 +276,7 @@ export default class Model implements TableType {
));
if (!modelData) {
modelData = await ncMeta.metaGet2(null, null, MetaTable.MODELS, k);
// if (
// this.baseModels?.[modelData.base_id]?.[modelData.db_alias]?.[
// modelData.title
// ]
// ) {
// delete this.baseModels[modelData.base_id][modelData.db_alias][
// modelData.title
// ];
// }
// if (
// this.baseModels?.[modelData.base_id]?.[modelData.db_alias]?.[
// modelData.id
// ]
// ) {
// delete this.baseModels[modelData.base_id][modelData.db_alias][
// modelData.id
// ];
// }
modelData.meta = parseMetaProp(modelData);
}
if (modelData) {
await NocoCache.set(`${CacheScope.MODEL}:${modelData.id}`, modelData);
@ -308,6 +310,7 @@ export default class Model implements TableType {
table_name,
}
);
modelData.meta = parseMetaProp(modelData);
await NocoCache.set(`${CacheScope.MODEL}:${modelData.id}`, modelData);
// modelData.filters = await Filter.getFilterObject({
// viewId: modelData.id
@ -722,4 +725,32 @@ export default class Model implements TableType {
{}
);
}
// For updating table meta
static async updateMeta(
tableId: string,
meta: string | Record<string, any>,
ncMeta = Noco.ncMeta
) {
// get existing cache
const key = `${CacheScope.MODEL}:${tableId}`;
const existingCache = await NocoCache.get(key, CacheGetType.TYPE_OBJECT);
if (existingCache) {
try {
existingCache.meta = typeof meta === 'string' ? JSON.parse(meta) : meta;
// set cache
await NocoCache.set(key, existingCache);
} catch {}
}
// set meta
return await ncMeta.metaUpdate(
null,
null,
MetaTable.MODELS,
{
meta: typeof meta === 'object' ? JSON.stringify(meta) : meta,
},
tableId
);
}
}

28
packages/nocodb/src/lib/models/View.ts

@ -5,6 +5,7 @@ import {
CacheScope,
MetaTable,
} from '../utils/globals';
import { parseMetaProp, stringifyMetaProp } from '../utils/modelUtils';
import Model from './Model';
import FormView from './FormView';
import GridView from './GridView';
@ -118,6 +119,7 @@ export default class View implements ViewType {
));
if (!view) {
view = await ncMeta.metaGet2(null, null, MetaTable.VIEWS, viewId);
view.meta = parseMetaProp(view);
await NocoCache.set(`${CacheScope.VIEW}:${view.id}`, view);
}
@ -156,6 +158,7 @@ export default class View implements ViewType {
],
}
);
view.meta = parseMetaProp(view);
// todo: cache - titleOrId can be viewId so we need a different scope here
await NocoCache.set(
`${CacheScope.VIEW}:${fk_model_id}:${titleOrId}`,
@ -188,6 +191,7 @@ export default class View implements ViewType {
},
null
);
view.meta = parseMetaProp(view);
await NocoCache.set(`${CacheScope.VIEW}:${fk_model_id}:default`, view);
}
return view && new View(view);
@ -204,6 +208,9 @@ export default class View implements ViewType {
order: 'asc',
},
});
for (const view of viewsList) {
view.meta = parseMetaProp(view);
}
await NocoCache.setList(CacheScope.VIEW, [modelId], viewsList);
}
viewsList.sort(
@ -254,8 +261,11 @@ export default class View implements ViewType {
base_id: view.base_id,
created_at: view.created_at,
updated_at: view.updated_at,
meta: view.meta ?? {},
};
insertObj.meta = stringifyMetaProp(insertObj);
// get project and base id if missing
if (!(view.project_id && view.base_id)) {
const model = await Model.getByIdOrName({ id: view.fk_model_id }, ncMeta);
@ -707,11 +717,16 @@ export default class View implements ViewType {
}
}
// todo: cache
static async getByUUID(uuid: string, ncMeta = Noco.ncMeta) {
const view = await ncMeta.metaGet2(null, null, MetaTable.VIEWS, {
uuid,
});
if (view) {
view.meta = parseMetaProp(view);
}
return view && new View(view);
}
@ -740,8 +755,9 @@ export default class View implements ViewType {
viewId
);
}
if (!view.meta) {
if (!view.meta || !('allowCSVDownload' in view.meta)) {
const defaultMeta = {
...(view.meta ?? {}),
allowCSVDownload: true,
};
// get existing cache
@ -749,7 +765,7 @@ export default class View implements ViewType {
const o = await NocoCache.get(key, CacheGetType.TYPE_OBJECT);
if (o) {
// update data
o.meta = JSON.stringify(defaultMeta);
o.meta = defaultMeta;
// set cache
await NocoCache.set(key, o);
}
@ -838,7 +854,7 @@ export default class View implements ViewType {
'meta',
'uuid',
]);
updateObj.meta = JSON.stringify(updateObj.meta);
// get existing cache
const key = `${CacheScope.VIEW}:${viewId}`;
let o = await NocoCache.get(key, CacheGetType.TYPE_OBJECT);
@ -854,6 +870,12 @@ export default class View implements ViewType {
// set cache
await NocoCache.set(key, o);
}
// if meta data defined then stringify it
if ('meta' in updateObj) {
updateObj.meta = stringifyMetaProp(updateObj);
}
// set meta
await ncMeta.metaUpdate(null, null, MetaTable.VIEWS, updateObj, viewId);
return this.get(viewId);

23
packages/nocodb/src/lib/utils/modelUtils.ts

@ -0,0 +1,23 @@
export function parseMetaProp(model: { meta: any }): any {
if (!model) return;
// parse meta property
try {
return typeof model.meta === 'string' ? JSON.parse(model.meta) : model.meta;
} catch {
return {};
}
}
export function stringifyMetaProp(model: { meta?: any }): string | void {
if (!model) return;
// stringify meta property
try {
return typeof model.meta === 'string'
? model.meta
: JSON.stringify(model.meta);
} catch (e) {
return '{}';
}
}

10
scripts/sdk/swagger.json

@ -1971,6 +1971,8 @@
},
"project_id": {
"type": "string"
},
"meta": {
}
}
}
@ -2192,6 +2194,8 @@
"order": {
"type": "number"
},
"meta": {
},
"title": {
"type": "string"
},
@ -7484,6 +7488,8 @@
"boolean",
"number"
]
},
"meta": {
}
},
"required": [
@ -7582,6 +7588,8 @@
"uuid": {
"type": "string"
},
"meta": {
},
"show_system_fields": {
"type": "boolean"
},
@ -7823,6 +7831,8 @@
"items": {
"$ref": "#/components/schemas/Column"
}
},
"meta": {
}
},
"required": [

4
tests/playwright/pages/Dashboard/Grid/Column/LTAR/ChildList.ts

@ -20,7 +20,7 @@ export class ChildList extends BasePage {
// button: Link to 'City'
// icon: reload
await expect(this.get().locator(`.ant-modal-title`)).toHaveText(`Child list`);
await expect(await this.get().locator(`button:has-text("Link to '${linkField}'")`).isVisible()).toBeTruthy();
await expect(await this.get().locator(`text=/Link to '.*${linkField}'/i`).isVisible()).toBeTruthy();
await expect(await this.get().locator(`[data-testid="nc-child-list-reload"]`).isVisible()).toBeTruthy();
// child list body validation (card count, card title)
@ -50,7 +50,7 @@ export class ChildList extends BasePage {
}
async openLinkRecord({ linkTableTitle }: { linkTableTitle: string }) {
const openActions = this.get().locator(`button:has-text("Link to '${linkTableTitle}'")`).click();
const openActions = this.get().locator(`text=/Link to '.*${linkTableTitle}'/i`).click();
await this.waitForResponse({
requestUrlPathToMatch: '/exclude',
httpMethodsToMatch: ['GET'],

21
tests/playwright/pages/Dashboard/TreeView.ts

@ -129,6 +129,27 @@ export class TreeViewPage extends BasePage {
await importMenu.locator(`.ant-dropdown-menu-title-content:has-text("${title}")`).click();
}
async changeTableIcon({ title, icon }: { title: string; icon: string }) {
await this.get().locator(`.nc-project-tree-tbl-${title} .nc-table-icon`).click();
await this.rootPage.getByTestId('nc-emoji-filter').type(icon);
await this.rootPage.getByTestId('nc-emoji-container').locator(`.nc-emoji-item >> svg`).first().click();
await this.rootPage.getByTestId('nc-emoji-container').isHidden();
await expect(
this.get().locator(`.nc-project-tree-tbl-${title} [data-testid="nc-icon-emojione:${icon}"]`)
).toHaveCount(1);
}
async verifyTabIcon({ title, icon }: { title: string; icon: string }) {
await new Promise(resolve => setTimeout(resolve, 1000));
await expect(
this.rootPage.locator(
`[data-testid="nc-tab-title"]:has-text("${title}") [data-testid="nc-tab-icon-emojione:${icon}"]`
)
).toBeVisible();
}
// todo: Break this into smaller methods
async validateRoleAccess(param: { role: string }) {
// Add new table button

21
tests/playwright/pages/Dashboard/ViewSidebar/index.ts

@ -140,6 +140,27 @@ export class ViewSidebarPage extends BasePage {
await this.verifyToast({ message: 'View created successfully' });
}
async changeViewIcon({ title, icon }: { title: string; icon: string }) {
await this.get().locator(`[data-testid="view-sidebar-view-${title}"] .nc-view-icon`).click();
await this.rootPage.getByTestId('nc-emoji-filter').type(icon);
await this.rootPage.getByTestId('nc-emoji-container').locator(`.nc-emoji-item >> svg`).first().click();
await this.rootPage.getByTestId('nc-emoji-container').isHidden();
await expect(
this.get().locator(`[data-testid="view-sidebar-view-${title}"] [data-testid="nc-icon-emojione:${icon}"]`)
).toHaveCount(1);
}
async verifyTabIcon({ title, icon }: { title: string; icon: string }) {
await new Promise(resolve => setTimeout(resolve, 1000));
await expect(
this.rootPage.locator(
`[data-testid="nc-tab-title"]:has-text("${title}") [data-testid="nc-tab-icon-emojione:${icon}"]`
)
).toBeVisible();
}
async validateRoleAccess(param: { role: string }) {
const count = param.role === 'creator' ? 1 : 0;
await expect(this.createGridButton).toHaveCount(count);

7
tests/playwright/tests/tableOperations.spec.ts

@ -13,7 +13,7 @@ test.describe('Table Operations', () => {
settings = dashboard.settings;
});
test('Create, and delete table, verify in audit tab, rename City table and reorder tables', async () => {
test('Create, and delete table, verify in audit tab, rename City table, update icon and reorder tables', async () => {
await dashboard.treeView.createTable({ title: 'tablex' });
await dashboard.treeView.verifyTable({ title: 'tablex' });
@ -46,5 +46,10 @@ test.describe('Table Operations', () => {
destinationTable: 'Address',
});
await dashboard.treeView.verifyTable({ title: 'Address', index: 0 });
// verify table icon customization
await dashboard.treeView.openTable({ title: 'Address' });
await dashboard.treeView.changeTableIcon({ title: 'Address', icon: 'american-football' });
await dashboard.treeView.verifyTabIcon({ title: 'Address', icon: 'american-football' });
});
});

7
tests/playwright/tests/viewKanban.spec.ts

@ -19,7 +19,7 @@ test.describe('View', () => {
// close 'Team & Auth' tab
await dashboard.closeTab({ title: 'Team & Auth' });
await dashboard.treeView.openTable({ title: 'Film' });
if (isSqlite(context)) {
await dashboard.treeView.deleteTable({ title: 'FilmList' });
}
@ -237,6 +237,11 @@ test.describe('View', () => {
order: order2[i - 1],
});
await dashboard.viewSidebar.changeViewIcon({
title: 'Kanban-1',
icon: 'american-football',
});
await dashboard.viewSidebar.deleteView({ title: 'Kanban-1' });
///////////////////////////////////////////////

6
tests/playwright/tests/views.spec.ts

@ -44,11 +44,17 @@ test.describe('Views CRUD Operations', () => {
title: 'CityGallery',
newTitle: 'CityGallery2',
});
await dashboard.viewSidebar.verifyView({
title: 'CityGallery2',
index: 3,
});
await dashboard.viewSidebar.changeViewIcon({
title: 'CityGallery2',
icon: 'american-football',
});
// todo: Enable when view bug is fixed
// await dashboard.viewSidebar.reorderViews({
// sourceView: "CityGrid",

Loading…
Cancel
Save