Browse Source

Merge pull request #2860 from nocodb/feat/gui-v2-toolbar

refactor(gui-v2): Smartsheet toolbar
pull/2945/head
Raju Udava 2 years ago committed by GitHub
parent
commit
99e2cce4a6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 12
      packages/nc-gui-v2/assets/style-v2.scss
  2. 63
      packages/nc-gui-v2/components/dashboard/TreeView.vue
  3. 4
      packages/nc-gui-v2/components/dlg/TableCreate.vue
  4. 2
      packages/nc-gui-v2/components/dlg/TableRename.vue
  5. 12
      packages/nc-gui-v2/components/smartsheet-header/CellIcon.vue
  6. 8
      packages/nc-gui-v2/components/smartsheet-header/VirtualCellIcon.vue
  7. 167
      packages/nc-gui-v2/components/smartsheet-toolbar/ColumnFilter.vue
  8. 38
      packages/nc-gui-v2/components/smartsheet-toolbar/ColumnFilterMenu.vue
  9. 8
      packages/nc-gui-v2/components/smartsheet-toolbar/DeleteTable.vue
  10. 48
      packages/nc-gui-v2/components/smartsheet-toolbar/FieldListAutoCompleteDropdown.vue
  11. 72
      packages/nc-gui-v2/components/smartsheet-toolbar/FieldsMenu.vue
  12. 53
      packages/nc-gui-v2/components/smartsheet-toolbar/LockMenu.vue
  13. 12
      packages/nc-gui-v2/components/smartsheet-toolbar/MoreActions.vue
  14. 44
      packages/nc-gui-v2/components/smartsheet-toolbar/SearchData.vue
  15. 162
      packages/nc-gui-v2/components/smartsheet-toolbar/ShareView.vue
  16. 156
      packages/nc-gui-v2/components/smartsheet-toolbar/SharedViewList.vue
  17. 46
      packages/nc-gui-v2/components/smartsheet-toolbar/SortListMenu.vue
  18. 4
      packages/nc-gui-v2/components/smartsheet/Grid.vue
  19. 10
      packages/nc-gui-v2/components/tabs/Smartsheet.vue
  20. 4
      packages/nc-gui-v2/composables/index.ts
  21. 10
      packages/nc-gui-v2/composables/useDashboard.ts
  22. 53
      packages/nc-gui-v2/composables/useSmartsheetStore.ts
  23. 112
      packages/nc-gui-v2/composables/useTable.ts
  24. 52
      packages/nc-gui-v2/composables/useTableCreate.ts
  25. 3
      packages/nc-gui-v2/composables/useUIPermission/index.ts
  26. 9
      packages/nc-gui-v2/composables/useViewColumns.ts
  27. 15
      packages/nc-gui-v2/composables/useViewData.ts
  28. 75
      packages/nc-gui-v2/composables/useViewFilters.ts
  29. 33
      packages/nc-gui-v2/package-lock.json
  30. 2
      packages/nc-gui-v2/package.json
  31. 35
      packages/nc-gui-v2/utils/sortUtils.ts
  32. 4
      packages/nocodb-sdk/src/lib/Api.ts
  33. 2
      packages/nocodb/src/lib/models/View.ts
  34. 4
      packages/nocodb/src/lib/version-upgrader/ncProjectUpgraderV2_0090000.ts
  35. 32
      scripts/sdk/swagger.json

12
packages/nc-gui-v2/assets/style-v2.scss

@ -72,6 +72,8 @@ html {
overflow-y: auto !important; overflow-y: auto !important;
} }
// menu item styling
.nc-menu-item { .nc-menu-item {
@apply cursor-pointer text-xs flex align-center gap-2 px-4 py-3 relative after:(content-[''] absolute top-0 left-0 w-full h-full right 0 bg-current opacity-0 transition transition-opactity duration-100) hover:(after:(opacity-5)); @apply cursor-pointer text-xs flex align-center gap-2 px-4 py-3 relative after:(content-[''] absolute top-0 left-0 w-full h-full right 0 bg-current opacity-0 transition transition-opactity duration-100) hover:(after:(opacity-5));
} }
@ -92,3 +94,13 @@ html {
@apply z-1 text-xl p-1 text-gray-500; @apply z-1 text-xl p-1 text-gray-500;
} }
} }
// show a dot badge if some change present
.nc-badge {
@apply relative after:(absolute top-[-2px] right-[-2px] w-[8px] h-[8px] rounded-full bg-primary content-[''] !z-20);
}
// for highlighting toolbar menu item
.nc-active-btn > .ant-btn{
@apply bg-primary/20;
}

63
packages/nc-gui-v2/components/dashboard/TreeView.vue

@ -1,14 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from '@vue/reactivity' import type { TableType } from 'nocodb-sdk'
import { Modal } from 'ant-design-vue'
import type { LinkToAnotherRecordType, TableType } from 'nocodb-sdk'
import { UITypes } from 'nocodb-sdk'
import Sortable from 'sortablejs' import Sortable from 'sortablejs'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import SettingsModal from './settings/SettingsModal.vue' import SettingsModal from './settings/SettingsModal.vue'
import { useProject, useTabs, useUIPermission, watchEffect } from '#imports' import { computed, useProject, useTable, useTabs, useUIPermission, watchEffect } from '#imports'
import { useNuxtApp, useRoute } from '#app' import { useNuxtApp, useRoute } from '#app'
import { extractSdkResponseErrorMsg } from '~/utils'
import MdiSettingIcon from '~icons/mdi/cog' import MdiSettingIcon from '~icons/mdi/cog'
import MdiTable from '~icons/mdi/table' import MdiTable from '~icons/mdi/table'
import MdiView from '~icons/mdi/eye-circle-outline' import MdiView from '~icons/mdi/eye-circle-outline'
@ -18,7 +14,6 @@ import MdiPlus from '~icons/mdi/plus-circle-outline'
import MdiDrag from '~icons/mdi/drag-vertical' import MdiDrag from '~icons/mdi/drag-vertical'
import MdiMenuIcon from '~icons/mdi/dots-vertical' import MdiMenuIcon from '~icons/mdi/dots-vertical'
import MdiAPIDocIcon from '~icons/mdi/open-in-new' import MdiAPIDocIcon from '~icons/mdi/open-in-new'
import { TabType } from '~/composables'
const { addTab } = useTabs() const { addTab } = useTabs()
const toast = useToast() const toast = useToast()
@ -27,6 +22,7 @@ const { isUIAllowed } = useUIPermission()
const route = useRoute() const route = useRoute()
const { tables, loadTables } = useProject(route.params.projectId as string) const { tables, loadTables } = useProject(route.params.projectId as string)
const { closeTab } = useTabs() const { closeTab } = useTabs()
const { deleteTable } = useTable()
const tablesById = $computed<Record<string, TableType>>(() => const tablesById = $computed<Record<string, TableType>>(() =>
tables?.value?.reduce((acc: Record<string, TableType>, table: TableType) => { tables?.value?.reduce((acc: Record<string, TableType>, table: TableType) => {
@ -128,59 +124,6 @@ const setMenuContext = (type: 'table' | 'main', value?: any) => {
$e('c:table:create:navdraw:right-click') $e('c:table:create:navdraw:right-click')
} }
const deleteTable = (table: TableType) => {
$e('c:table:delete')
// 'Click Submit to Delete The table'
Modal.confirm({
title: `Click Yes to Delete The table : ${table.title}`,
okText: 'Yes',
okType: 'danger',
cancelText: 'No',
async onOk() {
const { getMeta, removeMeta } = useMetas()
try {
const meta = (await getMeta(table.id as string)) as TableType
const relationColumns = meta?.columns?.filter((c) => c.uidt === UITypes.LinkToAnotherRecord)
if (relationColumns?.length) {
const refColMsgs = await Promise.all(
relationColumns.map(async (c, i) => {
const refMeta = (await getMeta(
(c?.colOptions as LinkToAnotherRecordType)?.fk_related_model_id as string,
)) as TableType
return `${i + 1}. ${c.title} is a LinkToAnotherRecord of ${(refMeta && refMeta.title) || c.title}`
}),
)
toast.info(
h('div', {
innerHTML: `<div style="padding:10px 4px">Unable to delete tables because of the following.
<br><br>${refColMsgs.join('<br>')}<br><br>
Delete them & try again</div>`,
}),
)
return
}
await $api.dbTable.delete(table?.id as string)
closeTab({
type: TabType.TABLE,
id: table.id,
title: table.title,
})
await loadTables()
removeMeta(table.id as string)
toast.info(`Deleted table ${table.title} successfully`)
$e('a:table:delete')
} catch (e: any) {
toast.error(await extractSdkResponseErrorMsg(e))
}
},
})
}
const renameTableDlg = ref(false) const renameTableDlg = ref(false)
const renameTableMeta = ref() const renameTableMeta = ref()
const showRenameTableDlg = (table: TableType, rightClick = false) => { const showRenameTableDlg = (table: TableType, rightClick = false) => {

4
packages/nc-gui-v2/components/dlg/TableCreate.vue

@ -2,7 +2,7 @@
import type { ComponentPublicInstance } from '@vue/runtime-core' import type { ComponentPublicInstance } from '@vue/runtime-core'
import { Form } from 'ant-design-vue' import { Form } from 'ant-design-vue'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import { onMounted, useProject, useTableCreate, useTabs } from '#imports' import { onMounted, useProject, useTable, useTabs } from '#imports'
import { validateTableName } from '~/utils/validation' import { validateTableName } from '~/utils/validation'
import { TabType } from '~/composables' import { TabType } from '~/composables'
@ -29,7 +29,7 @@ const { addTab } = useTabs()
const { loadTables } = useProject() const { loadTables } = useProject()
const { table, createTable, generateUniqueTitle, tables, project } = useTableCreate(async (table) => { const { table, createTable, generateUniqueTitle, tables, project } = useTable(async (table) => {
await loadTables() await loadTables()
addTab({ addTab({

2
packages/nc-gui-v2/components/dlg/TableRename.vue

@ -3,7 +3,7 @@ import { watchEffect } from '@vue/runtime-core'
import { Form } from 'ant-design-vue' import { Form } from 'ant-design-vue'
import type { TableType } from 'nocodb-sdk' import type { TableType } from 'nocodb-sdk'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import { useProject, useTableCreate, useTabs } from '#imports' import { useProject, useTabs } from '#imports'
import { extractSdkResponseErrorMsg } from '~/utils/errorUtils' import { extractSdkResponseErrorMsg } from '~/utils/errorUtils'
import { validateTableName } from '~/utils/validation' import { validateTableName } from '~/utils/validation'
import { useNuxtApp } from '#app' import { useNuxtApp } from '#app'

12
packages/nc-gui-v2/components/smartsheet-header/CellIcon.vue

@ -6,7 +6,7 @@ import KeyIcon from '~icons/mdi/key-variant'
import JSONIcon from '~icons/mdi/code-json' import JSONIcon from '~icons/mdi/code-json'
// import FKIcon from '~icons/mdi/link-variant' // import FKIcon from '~icons/mdi/link-variant'
import TextAreaIcon from '~icons/mdi/card-text-outline' import TextAreaIcon from '~icons/mdi/card-text-outline'
import StringIcon from '~icons/mdi/alpha-a' import StringIcon from '~icons/mdi/alpha-a-box-outline'
import BooleanIcon from '~icons/mdi/check-box-outline' import BooleanIcon from '~icons/mdi/check-box-outline'
import SingleSelectIcon from '~icons/mdi/radiobox-marked' import SingleSelectIcon from '~icons/mdi/radiobox-marked'
import MultiSelectIcon from '~icons/mdi/checkbox-multiple-marked' import MultiSelectIcon from '~icons/mdi/checkbox-multiple-marked'
@ -19,7 +19,9 @@ import URLIcon from '~icons/mdi/link'
import EmailIcon from '~icons/mdi/email' import EmailIcon from '~icons/mdi/email'
import CurrencyIcon from '~icons/mdi/currency-usd-circle-outline' import CurrencyIcon from '~icons/mdi/currency-usd-circle-outline'
const column = inject(ColumnInj) const { columnMeta } = defineProps<{ columnMeta?: ColumnType }>()
const column = inject(ColumnInj, columnMeta)
const additionalColMeta = useColumn(column as ColumnType) const additionalColMeta = useColumn(column as ColumnType)
@ -55,9 +57,7 @@ const icon = computed(() => {
} else if (additionalColMeta.isCurrency) { } else if (additionalColMeta.isCurrency) {
return CurrencyIcon return CurrencyIcon
} else if (additionalColMeta.isString) { } else if (additionalColMeta.isString) {
return h(StringIcon, { return StringIcon
class: 'text-[1.5rem]',
})
} else { } else {
return GenericIcon return GenericIcon
} }
@ -65,5 +65,5 @@ const icon = computed(() => {
</script> </script>
<template> <template>
<component :is="icon" class="text-grey mx-1" /> <component :is="icon" class="text-grey mx-1 !text-sm" />
</template> </template>

8
packages/nc-gui-v2/components/smartsheet-header/VirtualCellIcon.vue

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { LinkToAnotherRecordType } from 'nocodb-sdk' import type { ColumnType, LinkToAnotherRecordType } from 'nocodb-sdk'
import { RelationTypes, UITypes } from 'nocodb-sdk' import { RelationTypes, UITypes } from 'nocodb-sdk'
import { ColumnInj } from '~/context' import { ColumnInj } from '~/context'
import GenericIcon from '~icons/mdi/square-rounded' import GenericIcon from '~icons/mdi/square-rounded'
@ -9,7 +9,9 @@ import MMIcon from '~icons/mdi/table-network'
import FormulaIcon from '~icons/mdi/math-integral' import FormulaIcon from '~icons/mdi/math-integral'
import RollupIcon from '~icons/mdi/movie-roll' import RollupIcon from '~icons/mdi/movie-roll'
const column = inject(ColumnInj) const { columnMeta } = defineProps<{ columnMeta?: ColumnType }>()
const column = inject(ColumnInj, columnMeta)
const icon = computed(() => { const icon = computed(() => {
switch (column?.uidt) { switch (column?.uidt) {
@ -35,5 +37,5 @@ const icon = computed(() => {
</script> </script>
<template> <template>
<component :is="icon" class="text-grey mx-1" /> <component :is="icon" class="text-grey mx-1 !text-sm" />
</template> </template>

167
packages/nc-gui-v2/components/smartsheet-toolbar/ColumnFilter.vue

@ -5,24 +5,35 @@ import FieldListAutoCompleteDropdown from './FieldListAutoCompleteDropdown.vue'
import { useNuxtApp } from '#app' import { useNuxtApp } from '#app'
import { inject, useViewFilters } from '#imports' import { inject, useViewFilters } from '#imports'
import { comparisonOpList } from '~/utils/filterUtils' import { comparisonOpList } from '~/utils/filterUtils'
import { ActiveViewInj, MetaInj, ReloadViewDataHookInj } from '~/context' import { ActiveViewInj, IsLockedInj, MetaInj, ReloadViewDataHookInj } from '~/context'
import MdiDeleteIcon from '~icons/mdi/close-box' import MdiDeleteIcon from '~icons/mdi/close-box'
import MdiAddIcon from '~icons/mdi/plus' import MdiAddIcon from '~icons/mdi/plus'
const { nested = false, parentId } = defineProps<{ nested?: boolean; parentId?: string }>()
const { nested = false, parentId, autoSave = true } = defineProps<{ nested?: boolean; parentId?: string; autoSave: boolean }>()
const emit = defineEmits(['update:filtersLength'])
const meta = inject(MetaInj) const meta = inject(MetaInj)
const activeView = inject(ActiveViewInj) const activeView = inject(ActiveViewInj)
const reloadDataHook = inject(ReloadViewDataHookInj) const reloadDataHook = inject(ReloadViewDataHookInj)
const isLocked = inject(IsLockedInj)
// todo: replace with inject or get from state
const shared = ref(false)
const { $e } = useNuxtApp() const { $e } = useNuxtApp()
const { filters, deleteFilter, saveOrUpdate, loadFilters, addFilter } = useViewFilters(activeView, parentId, () => { const { filters, deleteFilter, saveOrUpdate, loadFilters, addFilter, addFilterGroup, sync } = useViewFilters(
activeView,
parentId,
computed(() => autoSave),
() => {
reloadDataHook?.trigger() reloadDataHook?.trigger()
}) },
)
const filterUpdateCondition = (filter: FilterType, i: number) => { const filterUpdateCondition = (filter: FilterType, i: number) => {
saveOrUpdate(filter, i) saveOrUpdate(filter, i)
$e('a:filter:update', { $e('a:filter:update', {
logical: filter.logical_op, logical: filter.logical_op,
comparison: filter.comparison_op, comparison: filter.comparison_op,
@ -70,64 +81,81 @@ watch(
}, },
{ immediate: true }, { immediate: true },
) )
const nestedFilters = ref()
const logicalOps = [
{ value: 'and', text: 'AND' },
{ value: 'or', text: 'OR' },
]
watch(
() => filters?.value?.length,
(length) => {
emit('update:filtersLength', length ?? 0)
},
)
const applyChanges = async () => {
await sync()
for (const nestedFilter of nestedFilters?.value || []) {
if (nestedFilter.parentId) {
await nestedFilter.applyChanges(true)
}
}
}
defineExpose({
applyChanges,
parentId,
})
</script> </script>
<template> <template>
<div class="bg-white shadow pa-2 menu-filter-dropdown" :style="{ width: nested ? '100%' : '630px' }"> <div
<div v-if="filters && filters.length" class="grid" @click.stop> class="pa-2 menu-filter-dropdown bg-gray-50"
:class="{ 'shadow-xl min-w-[430px] max-w-[630px] max-h-[max(80vh,500px)] overflow-auto': !nested, 'border-1 w-full': nested }"
>
<div v-if="filters && filters.length" class="nc-filter-grid mb-2" @click.stop>
<template v-for="(filter, i) in filters" :key="filter.id || i"> <template v-for="(filter, i) in filters" :key="filter.id || i">
<template v-if="filter.status !== 'delete'"> <template v-if="filter.status !== 'delete'">
<div v-if="filter.is_group" :key="i" style="grid-column: span 4; padding: 6px" class="elevation-4"> <template v-if="filter.is_group">
<div class="d-flex" style="gap: 6px; padding: 0 6px">
<!-- <v-icon
v-if="!filter.readOnly"
:key="`${i}_3`"
small
class="nc-filter-item-remove-btn"
@click.stop="deleteFilter(filter, i)"
>
mdi-close-box
</v-icon> -->
<MdiDeleteIcon <MdiDeleteIcon
v-if="!filter.readOnly" v-if="!filter.readOnly"
:key="i"
small small
class="nc-filter-item-remove-btn" class="nc-filter-item-remove-btn text-grey"
@click.stop="deleteFilter(filter, i)" @click.stop="deleteFilter(filter, i)"
/> />
<span v-else :key="`${i}dummy`" />
<span v-else :key="`${i}_1`" /> <div :key="`${i}nested`" class="d-flex">
<a-select <a-select
v-model:value="filter.logical_op" v-model:value="filter.logical_op"
class="flex-shrink-1 flex-grow-0 elevation-0 caption" :dropdown-match-select-width="false"
:items="['and', 'or']" size="small"
density="compact" class="flex-shrink-1 flex-grow-0 elevation-0 caption !text-xs"
variant="solo"
hide-details
placeholder="Group op" placeholder="Group op"
@click.stop @click.stop
@change="saveOrUpdate(filter, i)" @change="saveOrUpdate(filter, i)"
> >
<!-- <template #item="{ item }"> --> <a-select-option v-for="op in logicalOps" :key="op.value" :value="op.value" class="!text-xs">
<!-- <span class="caption font-weight-regular">{{ item }}</span> --> {{ op.text }}
<!-- </template> --> </a-select-option>
</a-select> </a-select>
</div> </div>
<!-- <column-filter <span class="col-span-3" />
<div class="col-span-5">
<SmartsheetToolbarColumnFilter
v-if="filter.id || shared" v-if="filter.id || shared"
ref="nestedFilter" ref="nestedFilters"
v-model="filter.children" v-model="filter.children"
:parent-id="filter.id" :parent-id="filter.id"
:view-id="viewId"
nested nested
:meta="meta" :auto-save="autoSave"
:shared="shared" />
:web-hook="webHook"
:hook-id="hookId"
@updated="$emit('updated')"
@input="$emit('input', filters)"
/> -->
</div> </div>
</template>
<template v-else> <template v-else>
<!-- <v-icon <!-- <v-icon
v-if="!filter.readOnly" v-if="!filter.readOnly"
@ -151,21 +179,23 @@ watch(
<a-select <a-select
v-else v-else
v-model:value="filter.logical_op" v-model:value="filter.logical_op"
class="h-full" :dropdown-match-select-width="false"
:options="[ size="small"
{ value: 'and', text: 'AND' }, class="h-full !text-xs"
{ value: 'or', text: 'OR' },
]"
hide-details hide-details
:disabled="filter.readOnly" :disabled="filter.readOnly"
@click.stop @click.stop
@change="filterUpdateCondition(filter, i)" @change="filterUpdateCondition(filter, i)"
/> >
<a-select-option v-for="op in logicalOps" :key="op.value" :value="op.value" class="!text-xs">
{{ op.text }}
</a-select-option>
</a-select>
<FieldListAutoCompleteDropdown <FieldListAutoCompleteDropdown
:key="`${i}_6`" :key="`${i}_6`"
v-model="filter.fk_column_id" v-model="filter.fk_column_id"
class="caption text-sm nc-filter-field-select" class="caption nc-filter-field-select"
:columns="columns" :columns="columns"
:disabled="filter.readOnly" :disabled="filter.readOnly"
@click.stop @click.stop
@ -174,15 +204,21 @@ watch(
<a-select <a-select
v-model:value="filter.comparison_op" v-model:value="filter.comparison_op"
class="caption nc-filter-operation-select text-sm" :dropdown-match-select-width="false"
:options="comparisonOpList" size="small"
class="caption nc-filter-operation-select !text-xs"
:placeholder="$t('labels.operation')" :placeholder="$t('labels.operation')"
density="compact" density="compact"
variant="solo" variant="solo"
:disabled="filter.readOnly" :disabled="filter.readOnly"
hide-details hide-details
@change="filterUpdateCondition(filter, i)" @change="filterUpdateCondition(filter, i)"
/><!-- >
<a-select-option v-for="compOp in comparisonOpList" :key="compOp.value" :value="compOp.value" class="!text-xs">
{{ compOp.text }}
</a-select-option>
</a-select>
<!--
todo: filter based on column type todo: filter based on column type
item-value="value" item-value="value"
@ -196,7 +232,8 @@ watch(
<span v-if="['null', 'notnull', 'empty', 'notempty'].includes(filter.comparison_op)" :key="`span${i}`" /> <span v-if="['null', 'notnull', 'empty', 'notempty'].includes(filter.comparison_op)" :key="`span${i}`" />
<a-checkbox <a-checkbox
v-else-if="types[filter.field] === 'boolean'" v-else-if="types[filter.field] === 'boolean'"
v-model:value="filter.value" v-model:checked="filter.value"
size="small"
dense dense
:disabled="filter.readOnly" :disabled="filter.readOnly"
@change="saveOrUpdate(filter, i)" @change="saveOrUpdate(filter, i)"
@ -204,8 +241,9 @@ watch(
<a-input <a-input
v-else v-else
:key="`${i}_7`" :key="`${i}_7`"
v-model="filter.value" v-model:value="filter.value"
class="caption text-sm nc-filter-value-select" size="small"
class="caption nc-filter-value-select"
:disabled="filter.readOnly" :disabled="filter.readOnly"
@click.stop @click.stop
@input="saveOrUpdate(filter, i)" @input="saveOrUpdate(filter, i)"
@ -215,7 +253,8 @@ watch(
</template> </template>
</div> </div>
<a-button small class="elevation-0 text-sm text-capitalize text-grey my-3" @click.stop="addFilter"> <div class="flex gap-2 my-2">
<a-button size="small" class="elevation-0 text-capitalize text-grey" @click.stop="addFilter">
<div class="flex align-center gap-1"> <div class="flex align-center gap-1">
<!-- <v-icon small color="grey"> mdi-plus </v-icon> --> <!-- <v-icon small color="grey"> mdi-plus </v-icon> -->
<MdiAddIcon /> <MdiAddIcon />
@ -223,15 +262,33 @@ watch(
{{ $t('activity.addFilter') }} {{ $t('activity.addFilter') }}
</div> </div>
</a-button> </a-button>
<a-button size="small" class="elevation-0 text-capitalize text-grey" @click.stop="addFilterGroup">
<div class="flex align-center gap-1">
<!-- <v-icon small color="grey"> mdi-plus </v-icon> -->
<MdiAddIcon />
Add Filter Group
<!-- todo: add i18n {{ $t('activity.addFilterGroup') }} -->
</div>
</a-button>
</div>
<slot /> <slot />
</div> </div>
</template> </template>
<style scoped> <style scoped>
.grid { .nc-filter-grid {
display: grid; display: grid;
grid-template-columns: 30px 130px auto auto auto; grid-template-columns: 18px 70px auto auto auto;
column-gap: 6px; column-gap: 6px;
row-gap: 6px; row-gap: 6px;
align-items: center;
}
:deep(.ant-btn, .ant-select, .ant-input) {
@apply "!text-xs";
}
:deep(.ant-select-item-option) {
@apply "!min-w-min";
} }
</style> </style>

38
packages/nc-gui-v2/components/smartsheet-toolbar/ColumnFilterMenu.vue

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
// todo: move to persisted state // todo: move to persisted state
import type ColumnFilter from './ColumnFilter.vue'
import { useState } from '#app' import { useState } from '#app'
import { IsLockedInj } from '~/context' import { IsLockedInj } from '~/context'
import MdiFilterIcon from '~icons/mdi/filter-outline' import MdiFilterIcon from '~icons/mdi/filter-outline'
@ -9,26 +10,49 @@ const autoApplyFilter = useState('autoApplyFilter', () => false)
const isLocked = inject(IsLockedInj) const isLocked = inject(IsLockedInj)
// todo: emit from child // todo: emit from child
const filters = [] const filtersLength = ref(0)
// todo: sync with store
const autosave = ref(true)
const filterComp = ref<typeof ColumnFilter>()
// todo: implement // todo: implement
const applyChanges = () => {} const applyChanges = async () => {
await filterComp?.value?.applyChanges()
}
</script> </script>
<template> <template>
<a-dropdown :trigger="['click']"> <a-dropdown :trigger="['click']">
<v-badge :value="filters.length" color="primary" dot overlap> <div :class="{ 'nc-badge nc-active-btn': filtersLength }">
<a-button v-t="['c:filter']" class="nc-filter-menu-btn nc-toolbar-btn" :disabled="isLocked" size="small"> <a-button v-t="['c:filter']" class="text-xs nc-filter-menu-btn nc-toolbar-btn" :disabled="isLocked" size="small">
<div class="flex align-center gap-1"> <div class="flex align-center gap-1">
<MdiFilterIcon class="text-grey" /> <MdiFilterIcon class="text-grey" />
<!-- Filter --> <!-- Filter -->
<span class="text-capitalize nc-filter-menu-btn">{{ $t('activity.filter') }}</span> <span class="text-capitalize">{{ $t('activity.filter') }}</span>
<MdiMenuDownIcon class="text-grey" /> <MdiMenuDownIcon class="text-grey" />
</div> </div>
</a-button> </a-button>
</v-badge> </div>
<template #overlay> <template #overlay>
<SmartsheetToolbarColumnFilter /> <SmartsheetToolbarColumnFilter
ref="filterComp"
class="nc-table-toolbar-menu"
:auto-save="autosave"
@update:filters-length="filtersLength = $event"
>
<div class="d-flex align-end mt-2 min-h-[30px]" @click.stop>
<a-checkbox id="col-filter-checkbox" v-model:checked="autosave" class="col-filter-checkbox" hide-details dense>
<span class="text-grey text-xs">
{{ $t('msg.info.filterAutoApply') }}
<!-- Auto apply -->
</span>
</a-checkbox>
<div class="flex-1" />
<a-button v-show="!autosave" size="small" class="text-xs ml-2" @click="applyChanges"> Apply changes </a-button>
</div>
</SmartsheetToolbarColumnFilter>
</template> </template>
</a-dropdown> </a-dropdown>
</template> </template>

8
packages/nc-gui-v2/components/smartsheet-toolbar/DeleteTable.vue

@ -1,5 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { inject, useTable } from '#imports'
import { MetaInj } from '~/context'
import MdiDeleteIcon from '~icons/mdi/delete-outline' import MdiDeleteIcon from '~icons/mdi/delete-outline'
const meta = inject(MetaInj)
const { deleteTable } = useTable()
</script> </script>
<template> <template>
@ -7,7 +13,7 @@ import MdiDeleteIcon from '~icons/mdi/delete-outline'
<template #title> {{ $t('activity.deleteTable') }} </template> <template #title> {{ $t('activity.deleteTable') }} </template>
<div class="nc-sidebar-right-item hover:after:bg-red-500 group"> <div class="nc-sidebar-right-item hover:after:bg-red-500 group">
<MdiDeleteIcon class="group-hover:(!text-white)" /> <MdiDeleteIcon class="group-hover:(!text-white)" @click="deleteTable(meta)" />
</div> </div>
</a-tooltip> </a-tooltip>
</template> </template>

48
packages/nc-gui-v2/components/smartsheet-toolbar/FieldListAutoCompleteDropdown.vue

@ -1,7 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SelectProps } from 'ant-design-vue' import type { SelectProps } from 'ant-design-vue'
import { isVirtualCol } from 'nocodb-sdk'
import { computed } from 'vue' import { computed } from 'vue'
import { MetaInj } from '~/context' import { MetaInj } from '~/context'
import VirtualCellIcon from '~/components/smartsheet-header/VirtualCellIcon.vue'
import CellIcon from '~/components/smartsheet-header/CellIcon.vue'
interface Props { interface Props {
modelValue?: string modelValue?: string
@ -48,6 +51,10 @@ const options = computed<SelectProps['options']>(() =>
meta?.value?.columns?.map((c) => ({ meta?.value?.columns?.map((c) => ({
value: c.id, value: c.id,
label: c.title, label: c.title,
icon: h(isVirtualCol(c) ? VirtualCellIcon : CellIcon, {
columnMeta: c,
}),
c,
})), })),
) )
@ -59,40 +66,17 @@ const filterOption = (input: string, option: any) => {
<template> <template>
<a-select <a-select
v-model:value="localValue" v-model:value="localValue"
:dropdown-match-select-width="false"
size="small"
show-search show-search
class="!text-xs"
placeholder="Select a field" placeholder="Select a field"
:options="options"
:filter-option="filterOption" :filter-option="filterOption"
></a-select>
<!-- <v-autocomplete
ref="field"
v-model="localValue"
class="caption"
:items="meta.columns"
item-value="id"
item-text="title"
:label="$t('objects.field')"
variant="solo"
hide-details
@click.stop
> >
&lt;!&ndash; &lt;!&ndash; @change="$emit('change')" &ndash;&gt; &ndash;&gt; <a-select-option v-for="option in options" :key="option.value" :value="option.value">
&lt;!&ndash; <template #selection="{ item }"> &ndash;&gt; <div class="flex gap-2 text-xs items-center align-center h-full">
&lt;!&ndash; <v-icon small class="mr-1"> &ndash;&gt; <component :is="option.icon" class="min-w-5 !mx-0" /> <span class="min-w-0"> {{ option.label }}</span>
&lt;!&ndash; {{ item.icon }} &ndash;&gt; </div>
&lt;!&ndash; </v-icon> &ndash;&gt; </a-select-option>
&lt;!&ndash; {{ item.title }} &ndash;&gt; </a-select>
&lt;!&ndash; </template> &ndash;&gt;
&lt;!&ndash; <template #item="{ item }"> &ndash;&gt;
&lt;!&ndash; <span :class="`caption font-weight-regular nc-fld-${item.title}`"> &ndash;&gt;
&lt;!&ndash; <v-icon color="grey" small class="mr-1"> &ndash;&gt;
&lt;!&ndash; {{ item.icon }} &ndash;&gt;
&lt;!&ndash; </v-icon> &ndash;&gt;
&lt;!&ndash; {{ item.title }} &ndash;&gt;
&lt;!&ndash; </span> &ndash;&gt;
&lt;!&ndash; </template> &ndash;&gt;
</v-autocomplete> -->
</template> </template>
<style scoped></style>

72
packages/nc-gui-v2/components/smartsheet-toolbar/FieldsMenu.vue

@ -1,6 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import type { TableType } from 'nocodb-sdk'
import type { ComputedRef } from 'vue'
import { computed, inject } from 'vue' import { computed, inject } from 'vue'
import Draggable from 'vuedraggable' import Draggable from 'vuedraggable'
import { ActiveViewInj, FieldsInj, IsLockedInj, MetaInj, ReloadViewDataHookInj } from '~/context' import { ActiveViewInj, FieldsInj, IsLockedInj, MetaInj, ReloadViewDataHookInj } from '~/context'
@ -18,14 +16,8 @@ const { fieldsOrder, coverImageField, modelValue } = defineProps<{
const meta = inject(MetaInj) const meta = inject(MetaInj)
const activeView = inject(ActiveViewInj) const activeView = inject(ActiveViewInj)
const reloadDataHook = inject(ReloadViewDataHookInj) const reloadDataHook = inject(ReloadViewDataHookInj)
const isLocked = inject(IsLockedInj)
const rootFields = inject(FieldsInj) const rootFields = inject(FieldsInj)
const isLocked = inject(IsLockedInj)
const isAnyFieldHidden = computed(() => {
return false
// todo: implement
// return meta?.fields?.some(field => field.hidden)
})
const { $e } = useNuxtApp() const { $e } = useNuxtApp()
@ -39,8 +31,8 @@ const {
showAll, showAll,
hideAll, hideAll,
saveOrUpdate, saveOrUpdate,
sortedFields, // sortedFields,
} = useViewColumns(activeView, meta as ComputedRef<TableType>, false, () => reloadDataHook?.trigger()) } = useViewColumns(activeView, meta, false, () => reloadDataHook?.trigger())
watch( watch(
() => (activeView?.value as any)?.id, () => (activeView?.value as any)?.id,
@ -59,43 +51,52 @@ watch(
{ immediate: true }, { immediate: true },
) )
const onMove = (event: unknown) => { const isAnyFieldHidden = computed(() => {
return fields?.value?.some((f) => !(!showSystemFields && f.system) && !f.show)
})
const onMove = (event: { moved: { newIndex: number } }) => {
// todo : sync with server // todo : sync with server
// if (!sortedFields?.value) return if (!fields?.value) return
// if (sortedFields?.value.length - 1 === event.moved.newIndex) {
// sortedFields.value[event.moved.newIndex].order = sortedFields.value[event.moved.newIndex - 1].order + 1 if (fields.value.length < 2) return
// } else if (event.moved.newIndex === 0) {
// sortedFields.value[event.moved.newIndex].order = sortedFields.value[1].order / 2 if (fields?.value.length - 1 === event.moved.newIndex) {
// } else { fields.value[event.moved.newIndex].order = (fields.value[event.moved.newIndex - 1].order || 1) + 1
// sortedFields.value[event.moved.newIndex].order = } else if (event.moved.newIndex === 0) {
// (sortedFields?.value[event.moved.newIndex - 1].order + sortedFields?.value[event.moved.newIndex + 1].order) / 2 fields.value[event.moved.newIndex].order = (fields?.value[1].order || 1) / 2
// // ); } else {
// } fields.value[event.moved.newIndex].order =
// saveOrUpdate(sortedFields[event.moved.newIndex], event.moved.newIndex); ((fields?.value[event.moved.newIndex - 1].order || 1) + (fields?.value[event.moved.newIndex + 1].order || 1)) / 2
// );
}
saveOrUpdate(fields.value[event.moved.newIndex], event.moved.newIndex)
$e('a:fields:reorder') $e('a:fields:reorder')
} }
</script> </script>
<template> <template>
<a-dropdown :trigger="['click']"> <a-dropdown :trigger="['click']">
<v-badge :value="isAnyFieldHidden" color="primary" dot overlap> <div :class="{ 'nc-badge nc-active-btn': isAnyFieldHidden }">
<a-button v-t="['c:fields']" class="nc-fields-menu-btn nc-toolbar-btn" :disabled="isLocked" size="small"> <a-button v-t="['c:fields']" class="nc-fields-menu-btn nc-toolbar-btn" :disabled="isLocked" size="small">
<div class="flex align-center gap-1"> <div class="flex align-center gap-1">
<!-- <v-icon small class="mr-1" color="#777"> mdi-eye-off-outline </v-icon> --> <!-- <v-icon small class="mr-1" color="#777"> mdi-eye-off-outline </v-icon> -->
<MdiEyeIcon class="text-grey"></MdiEyeIcon> <MdiEyeIcon class="text-grey"></MdiEyeIcon>
<!-- Fields --> <!-- Fields -->
<span class="text-sm text-capitalize nc-fields-menu-btn">{{ $t('objects.fields') }}</span> <span class="text-xs text-capitalize">{{ $t('objects.fields') }}</span>
<MdiMenuDownIcon class="text-grey"></MdiMenuDownIcon> <MdiMenuDownIcon class="text-grey"></MdiMenuDownIcon>
</div> </div>
</a-button> </a-button>
</v-badge> </div>
<template #overlay> <template #overlay>
<div class="pt-0 min-w-[280px] bg-white shadow" @click.stop> <div class="pt-0 min-w-[280px] bg-gray-50 shadow nc-table-toolbar-menu max-h-[max(80vh,500px)] overflow-auto" @click.stop>
<div class="p-1" @click.stop>
<a-input v-model:value="filterQuery" size="small" :placeholder="$t('placeholder.searchFields')" />
</div>
<div class="nc-fields-list py-1"> <div class="nc-fields-list py-1">
<Draggable :list="sortedFields" @change="onMove($event)"> <Draggable :list="fields" item-key="id" @change="onMove($event)">
<template #item="{ element: field }"> <template #item="{ element: field }">
<div :key="field.id" class="px-2 py-1 flex" @click.stop> <div v-show="filteredFieldList.includes(field)" :key="field.id" class="px-2 py-1 flex" @click.stop>
<a-checkbox v-model:checked="field.show" class="flex-shrink" @change="saveOrUpdate(field, i)"> <a-checkbox v-model:checked="field.show" class="flex-shrink" @change="saveOrUpdate(field, i)">
<span class="text-xs">{{ field.title }}</span> <span class="text-xs">{{ field.title }}</span>
</a-checkbox> </a-checkbox>
@ -113,11 +114,11 @@ const onMove = (event: unknown) => {
</a-checkbox> </a-checkbox>
</div> </div>
<div class="p-2 flex gap-2" @click.stop> <div class="p-2 flex gap-2" @click.stop>
<a-button size="small" class="text-gray-500 text-sm text-capitalize" @click.stop="showAll"> <a-button size="small" class="!text-xs text-gray-500 text-capitalize" @click.stop="showAll">
<!-- Show All --> <!-- Show All -->
{{ $t('general.showAll') }} {{ $t('general.showAll') }}
</a-button> </a-button>
<a-button size="small" class="text-gray-500 text-sm text-capitalize" @click.stop="hideAll"> <a-button size="small" class="!text-xs text-gray-500 text-capitalize" @click.stop="hideAll">
<!-- Hide All --> <!-- Hide All -->
{{ $t('general.hideAll') }} {{ $t('general.hideAll') }}
</a-button> </a-button>
@ -128,7 +129,10 @@ const onMove = (event: unknown) => {
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
:deep(.ant-checkbox-input) { :deep(.ant-checkbox-inner) {
transform: scale(0.7); @apply transform scale-60;
}
:deep(::placeholder) {
@apply !text-xs;
} }
</style> </style>

53
packages/nc-gui-v2/components/smartsheet-toolbar/LockMenu.vue

@ -1,29 +1,21 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from '@vue/reactivity' import { computed } from '@vue/reactivity'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import { useSmartsheetStoreOrThrow } from '~/composables/useSmartsheetStore'
import { extractSdkResponseErrorMsg } from '~/utils/errorUtils'
import MdiLockOutlineIcon from '~icons/mdi/lock-outline' import MdiLockOutlineIcon from '~icons/mdi/lock-outline'
import MdiAccountIcon from '~icons/mdi/account' import MdiAccountIcon from '~icons/mdi/account'
import MdiAccountGroupIcon from '~icons/mdi/account-group' import MdiAccountGroupIcon from '~icons/mdi/account-group'
import MdiCheckIcon from '~icons/mdi/check-bold' import MdiCheckIcon from '~icons/mdi/check-bold'
interface Props {
modelValue?: LockType
}
const props = defineProps<Props>()
const emits = defineEmits(['update:modelValue'])
enum LockType { enum LockType {
Personal = 'personal', Personal = 'personal',
Locked = 'locked', Locked = 'locked',
Collaborative = 'collaborative', Collaborative = 'collaborative',
} }
const vModel = useVModel(props, 'modelValue', emits) const { view, $api } = useSmartsheetStoreOrThrow()
const { $e } = useNuxtApp() const { $e } = useNuxtApp()
const toast = useToast() const toast = useToast()
function changeLockType(type: LockType) { function changeLockType(type: LockType) {
@ -32,14 +24,20 @@ function changeLockType(type: LockType) {
if (type === 'personal') { if (type === 'personal') {
return toast.info('Coming soon', { timeout: 3000 }) return toast.info('Coming soon', { timeout: 3000 })
} }
try {
vModel.value = type view.value.lock_type = type
$api.dbView.update(view.value.id as string, {
lock_type: type,
})
toast.success(`Successfully Switched to ${type} view`, { timeout: 3000 }) toast.success(`Successfully Switched to ${type} view`, { timeout: 3000 })
} catch (e) {
toast.error(extractSdkResponseErrorMsg(e))
}
} }
const Icon = computed(() => { const Icon = computed(() => {
switch (vModel.value) { switch (view?.value?.lock_type) {
case LockType.Personal: case LockType.Personal:
return MdiAccountIcon return MdiAccountIcon
case LockType.Locked: case LockType.Locked:
@ -59,18 +57,20 @@ const Icon = computed(() => {
<template #overlay> <template #overlay>
<div class="min-w-[350px] max-w-[500px] shadow bg-white"> <div class="min-w-[350px] max-w-[500px] shadow bg-white">
<div> <div>
<div class="nc-menu-item"> <div class="nc-menu-item" @click="changeLockType(LockType.Collaborative)">
<MdiCheckIcon v-if="!vModel || vModel === LockType.Collaborative" /> <div>
<MdiCheckIcon v-if="!view?.lock_type || view?.lock_type === LockType.Collaborative" />
<span v-else /> <span v-else />
<div> <div>
<MdiAccountGroupIcon /> <MdiAccountGroupIcon />
Collaborative view Collaborative view
<div class="nc-subtitle">Collaborators with edit permissions or higher can change the view configuration.</div> <div class="nc-subtitle">Collaborators with edit permissions or higher can change the view configuration.</div>
</div> </div>
</div> </div>
<div class="nc-menu-item"> </div>
<MdiCheckIcon v-if="vModel === LockType.Locked" /> <div class="nc-menu-item" @click="changeLockType(LockType.Locked)">
<div>
<MdiCheckIcon v-if="view.lock_type === LockType.Locked" />
<span v-else /> <span v-else />
<div> <div>
<MdiLockOutlineIcon /> <MdiLockOutlineIcon />
@ -78,8 +78,10 @@ const Icon = computed(() => {
<div class="nc-subtitle">No one can edit the view configuration until it is unlocked.</div> <div class="nc-subtitle">No one can edit the view configuration until it is unlocked.</div>
</div> </div>
</div> </div>
<div class="nc-menu-item"> </div>
<MdiCheckIcon v-if="vModel === LockType.Personal" /> <div class="nc-menu-item" @click="changeLockType(LockType.Personal)">
<div>
<MdiCheckIcon v-if="view.lock_type === LockType.Personal" />
<span v-else /> <span v-else />
<div> <div>
<MdiAccountIcon /> <MdiAccountIcon />
@ -91,13 +93,18 @@ const Icon = computed(() => {
</div> </div>
</div> </div>
</div> </div>
</div>
</template> </template>
</a-dropdown> </a-dropdown>
</template> </template>
<style scoped> <style scoped>
.nc-menu-item { .nc-menu-item > div {
@apply grid grid-cols-[30px,auto] gap-2 p-4; @apply grid grid-cols-[30px,auto] gap-2 p-2 align-center;
}
.nc-menu-item > div > svg {
align-self: center;
} }
.nc-menu-option > :first-child { .nc-menu-option > :first-child {

12
packages/nc-gui-v2/components/smartsheet-toolbar/MoreActions.vue

@ -13,6 +13,8 @@ import MdiUploadIcon from '~icons/mdi/upload-outline'
import MdiHookIcon from '~icons/mdi/hook' import MdiHookIcon from '~icons/mdi/hook'
import MdiViewListIcon from '~icons/mdi/view-list-outline' import MdiViewListIcon from '~icons/mdi/view-list-outline'
const sharedViewListDlg = ref(false)
// todo : replace with inject // todo : replace with inject
const publicViewId = null const publicViewId = null
const { project } = useProject() const { project } = useProject()
@ -87,6 +89,7 @@ const exportCsv = async () => {
</script> </script>
<template> <template>
<div>
<a-dropdown> <a-dropdown>
<a-button v-t="['c:actions']" class="nc-actions-menu-btn nc-toolbar-btn"> <a-button v-t="['c:actions']" class="nc-actions-menu-btn nc-toolbar-btn">
<div class="flex gap-1 align-center"> <div class="flex gap-1 align-center">
@ -99,7 +102,7 @@ const exportCsv = async () => {
<template #overlay> <template #overlay>
<div class="bg-white shadow"> <div class="bg-white shadow">
<div> <div>
<div class="nc-menu-item" @click.stop="exportCsv"> <div class="nc-menu-item" @click="exportCsv">
<MdiDownloadIcon /> <MdiDownloadIcon />
<!-- Download as CSV --> <!-- Download as CSV -->
{{ $t('activity.downloadCSV') }} {{ $t('activity.downloadCSV') }}
@ -109,7 +112,7 @@ const exportCsv = async () => {
<!-- Upload CSV --> <!-- Upload CSV -->
{{ $t('activity.uploadCSV') }} {{ $t('activity.uploadCSV') }}
</div> </div>
<div class="nc-menu-item" @click.stop> <div class="nc-menu-item" @click="sharedViewListDlg = true">
<MdiViewListIcon /> <MdiViewListIcon />
<!-- Shared View List --> <!-- Shared View List -->
{{ $t('activity.listSharedView') }} {{ $t('activity.listSharedView') }}
@ -123,4 +126,9 @@ const exportCsv = async () => {
</div> </div>
</template> </template>
</a-dropdown> </a-dropdown>
<a-modal v-model:visible="sharedViewListDlg" title="Shared view list" width="max(900px,60vw)" :footer="null">
<SmartsheetToolbarSharedViewList v-if="sharedViewListDlg" />
</a-modal>
</div>
</template> </template>

44
packages/nc-gui-v2/components/smartsheet-toolbar/SearchData.vue

@ -1,23 +1,12 @@
<script lang="ts" setup> <script lang="ts" setup>
import { MetaInj } from '~/context' import { useProvideSmartsheetStore, useSmartsheetStoreOrThrow } from '~/composables/useSmartsheetStore'
import { MetaInj, ReloadViewDataHookInj } from '~/context'
import MdiSearchIcon from '~icons/mdi/magnify'
import MdiMenuDownIcon from '~icons/mdi/menu-down'
const { modelValue, field } = defineProps<{ const reloadData = inject(ReloadViewDataHookInj)
modelValue?: string const { search, meta } = useSmartsheetStoreOrThrow()
field?: any
}>()
const emit = defineEmits(['update:modelValue', 'update:field'])
const localValue = computed({
get: () => modelValue,
set: (val) => emit('update:modelValue', val),
})
const localField = computed({
get: () => field,
set: (val) => emit('update:field', val),
})
const meta = inject(MetaInj)
const columns = computed(() => const columns = computed(() =>
meta?.value?.columns?.map((c) => ({ meta?.value?.columns?.map((c) => ({
value: c.id, value: c.id,
@ -27,9 +16,26 @@ const columns = computed(() =>
</script> </script>
<template> <template>
<a-input v-model:value="localValue" size="small" class="max-w-[250px]" placeholder="Filter query"> <a-input
v-model:value="search.query"
size="small"
class="max-w-[200px]"
placeholder="Filter query"
@press-enter="reloadData.trigger()"
>
<template #addonBefore> <template #addonBefore>
<a-select v-model:value="localField" :options="columns" style="width: 80px" class="!text-xs" size="small" /> <div class="flex align-center relative" @click="isDropdownOpen = true">
<MdiSearchIcon class="text-grey" />
<MdiMenuDownIcon class="text-grey" />
<a-select
v-model:value="search.field"
size="small"
:dropdown-match-select-width="false"
:options="columns"
class="!absolute top-0 left-0 w-full h-full z-10 !text-xs opacity-0"
>
</a-select>
</div>
</template> </template>
</a-input> </a-input>
</template> </template>

162
packages/nc-gui-v2/components/smartsheet-toolbar/ShareView.vue

@ -1,13 +1,167 @@
<script lang="ts" setup> <script lang="ts" setup>
import MdiOpenInNew from '~icons/mdi/open-in-new' import { useClipboard } from '@vueuse/core'
import { useUIPermission } from '#imports' import { ViewTypes } from 'nocodb-sdk'
import { computed } from 'vue'
import { message } from 'ant-design-vue'
import { useToast } from 'vue-toastification'
import { useNuxtApp } from '#app'
import { useSmartsheetStoreOrThrow } from '#imports'
import { extractSdkResponseErrorMsg } from '~/utils/errorUtils'
import MdiOpenInNewIcon from '~icons/mdi/open-in-new'
import MdiCopyIcon from '~icons/mdi/content-copy'
const { isUIAllowed } = useUIPermission() const { isUIAllowed } = useUIPermission()
const { view, $api } = useSmartsheetStoreOrThrow()
const { copy } = useClipboard()
const { $e } = useNuxtApp()
const toast = useToast()
const { dashboardUrl } = useDashboard()
let showShareModel = $ref(false)
const passwordProtected = $ref(false)
const shared = ref()
const allowCSVDownload = computed({
get() {
return !!(shared.value?.meta && typeof shared.value.meta === 'string' ? JSON.parse(shared.value.meta) : shared.value.meta)
?.allowCSVDownload
},
set(allow) {
shared.value.meta = { allowCSVDownload: allow }
saveAllowCSVDownload()
},
})
const genShareLink = async () => {
shared.value = await $api.dbViewShare.create(view.value.id as string)
// shared.meta = shared.meta && typeof shared.meta === 'string' ? JSON.parse(shared.meta) : shared.meta;
// // todo: url
// shareLink = shared;
// passwordProtect = shared.password !== null;
// allowCSVDownload = shared.meta.allowCSVDownload;
showShareModel = true
}
const sharedViewUrl = computed(() => {
if (!shared.value) return
let viewType
switch (shared.value.type) {
case ViewTypes.FORM:
viewType = 'form'
break
case ViewTypes.KANBAN:
viewType = 'kanban'
break
default:
viewType = 'view'
}
// todo: get dashboard url
return `${dashboardUrl?.value}/nc/${viewType}/${shared.value.uuid}`
})
async function saveAllowCSVDownload() {
try {
const meta = shared.value.meta && typeof shared.value.meta === 'string' ? JSON.parse(shared.value.meta) : shared.value.meta
// todo: update swagger
await $api.dbViewShare.update(shared.value.id, {
meta,
} as any)
toast.success('Successfully updated')
} catch (e) {
toast.error(await extractSdkResponseErrorMsg(e))
}
if (allowCSVDownload?.value) {
$e('a:view:share:enable-csv-download')
} else {
$e('a:view:share:disable-csv-download')
}
}
const saveShareLinkPassword = async () => {
try {
await $api.dbViewShare.update(shared.value.id, {
password: shared.value.password,
})
toast.success('Successfully updated')
} catch (e) {
toast.error(await extractSdkResponseErrorMsg(e))
}
$e('a:view:share:enable-pwd')
}
const copyLink = () => {
copy(sharedViewUrl?.value as string)
message.success('Copied to clipboard')
}
</script> </script>
<template> <template>
<div v-t="['c:view:share']" class="nc-sidebar-right-item hover:after:bg-secondary/75 group"> <div>
<MdiOpenInNew class="group-hover:(!text-white)" /> <a-button v-t="['c:view:share']" outlined class="nc-btn-share-view nc-toolbar-btn" size="small">
<div class="flex align-center gap-1" @click="genShareLink">
<MdiOpenInNewIcon class="text-grey" />
<!-- Share View -->
{{ $t('activity.shareView') }} {{ $t('activity.shareView') }}
</div> </div>
</a-button>
<!-- This view is shared via a private link -->
<a-modal
v-model:visible="showShareModel"
size="small"
:title="$t('msg.info.privateLink')"
:footer="null"
width="min(100vw,640px)"
>
<div class="share-link-box nc-share-link-box bg-primary-50">
<div class="flex-1 h-min text-xs">{{ sharedViewUrl }}</div>
<!-- <v-spacer /> -->
<a v-t="['c:view:share:open-url']" :href="sharedViewUrl" target="_blank">
<MdiOpenInNewIcon class="text-sm text-gray-500 mt-2" />
</a>
<MdiCopyIcon class="text-gray-500 text-sm cursor-pointer" @click="copyLink" />
</div>
<a-collapse ghost>
<a-collapse-panel key="1" header="More Options">
<div class="mb-2">
<a-checkbox v-model:checked="passwordProtected" class="!text-xs">{{ $t('msg.info.beforeEnablePwd') }} </a-checkbox>
<!-- todo: add password toggle -->
<div v-if="passwordProtected" class="flex gap-2 mt-2 mb-4">
<a-input
v-model:value="shared.password"
size="small"
class="!text-xs max-w-[250px]"
type="password"
:placeholder="$t('placeholder.password.enter')"
/>
<a-button size="small" class="!text-xs" @click="saveShareLinkPassword"
>{{ $t('placeholder.password.save') }}
</a-button>
</div>
</div>
<div>
<a-checkbox v-if="shared && shared.type === ViewTypes.GRID" v-model:checked="allowCSVDownload" class="!text-xs"
>Allow Download
</a-checkbox>
</div>
</a-collapse-panel>
</a-collapse>
</a-modal>
</div>
</template> </template>
<style scoped>
.share-link-box {
@apply flex p-2 w-full items-center align-center gap-1 bg-gray-100 rounded;
}
:deep(.ant-collapse-header) {
@apply !text-xs;
}
</style>

156
packages/nc-gui-v2/components/smartsheet-toolbar/SharedViewList.vue

@ -0,0 +1,156 @@
<script lang="ts" setup>
import { useClipboard } from '@vueuse/core'
import { ViewTypes } from 'nocodb-sdk'
import { useToast } from 'vue-toastification'
import { message } from 'ant-design-vue'
import { useRoute } from '#app'
import { onMounted, useSmartsheetStoreOrThrow } from '#imports'
import { extractSdkResponseErrorMsg } from '~/utils/errorUtils'
import MdiVisibilityOnIcon from '~icons/mdi/visibility'
import MdiVisibilityOffIcon from '~icons/mdi/visibility-off'
import MdiCopyIcon from '~icons/mdi/content-copy'
import MdiDeleteIcon from '~icons/mdi/delete-outline'
interface SharedViewType {
password: string
title: string
uuid: string
type: ViewTypes
meta: string | Record<string, any>
showPassword?: boolean
}
const { view, $api, meta } = useSmartsheetStoreOrThrow()
const { copy } = useClipboard()
const toast = useToast()
const route = useRoute()
const { dashboardUrl } = useDashboard()
let isLoading = $ref(false)
// let activeSharedView = $ref(null)
const sharedViewList = ref<SharedViewType[]>()
const loadSharedViewsList = async () => {
isLoading = true
const list = await $api.dbViewShare.list(meta.value?.id as string)
console.log(unref(sharedViewList))
console.log(list)
sharedViewList.value = list
// todo: show active view in list separately
// const index = sharedViewList.value.findIndex((v) => {
// return view?.value?.id === v.id
// })
//
// if (index > -1) {
// activeSharedView = sharedViewList.value.splice(index, 1)[0]
// } else {
// activeSharedView = null
// }
isLoading = false
}
onMounted(loadSharedViewsList)
const sharedViewUrl = (view: SharedViewType) => {
let viewType
switch (view.type) {
case ViewTypes.FORM:
viewType = 'form'
break
case ViewTypes.KANBAN:
viewType = 'kanban'
break
default:
viewType = 'view'
}
return `/nc/${viewType}/${view.uuid}`
}
const renderAllowCSVDownload = (view: SharedViewType) => {
if (view.type === ViewTypes.GRID) {
view.meta = (view.meta && typeof view.meta === 'string' ? JSON.parse(view.meta) : view.meta) as Record<string, any>
return view.meta.allowCSVDownload ? '✔' : '❌'
} else {
return 'N/A'
}
}
const copyLink = (view: SharedViewType) => {
copy(`${dashboardUrl?.value as string}/${sharedViewUrl(view)}`)
message.success('Copied to clipboard')
}
const deleteLink = async (id: string) => {
try {
await $api.dbViewShare.delete(id)
toast.success('Deleted shared view successfully')
await loadSharedViewsList()
} catch (e) {
toast.error(await extractSdkResponseErrorMsg(e))
}
}
</script>
<template>
<div class="w-full">
<a-table class="" size="small" :data-source="sharedViewList" :pagination="{ position: ['bottomCenter'] }">
<!-- View name -->
<a-table-column key="title" :title="$t('labels.viewName')" data-index="title">
<template #default="{ text }">
<div class="text-xs" :title="text">
{{ text }}
</div>
</template>
</a-table-column>
<!-- View Link -->
<a-table-column key="title" :title="$t('labels.viewLink')" data-index="title">
<template #default="{ record }">
<nuxt-link :to="sharedViewUrl(record)" class="text-xs">
{{ `${dashboardUrl}/${sharedViewUrl(record)}` }}
</nuxt-link>
</template>
</a-table-column>
<!-- Password -->
<a-table-column key="password" :title="$t('labels.password')" data-index="title">
<template #default="{ record }">
<div class="flex align-center items-center gap-1">
<template v-if="record.password">
<span class="h-min">{{ record.showPassword ? record.password : '***************************' }}</span>
<component
:is="record.showPassword ? MdiVisibilityOffIcon : MdiVisibilityOnIcon"
@click="record.showPassword = !record.showPassword"
/>
</template>
</div>
</template>
</a-table-column>
<!-- Todo: i18n -->
<a-table-column key="meta" title="Download allowed" data-index="title">
<template #default="{ record }">
<template v-if="'meta' in record">
<div class="text-center">{{ renderAllowCSVDownload(record) }}</div>
</template>
</template>
</a-table-column>
<!-- Actions -->
<a-table-column key="id" :title="$t('labels.actions')" data-index="title">
<template #default="{ record }">
<div class="text-sm flex gap-2" :title="text">
<MdiCopyIcon class="cursor-pointer" @click="copyLink(record)" />
<MdiDeleteIcon class="cursor-pointer" @click="deleteLink(record.id)" />
</div>
</template>
</a-table-column>
</a-table>
</div>
</template>
<style scoped>
:deep(.ant-pagination-item > a) {
@apply leading-normal;
}
</style>

46
packages/nc-gui-v2/components/smartsheet-toolbar/SortListMenu.vue

@ -1,5 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ColumnType } from 'nocodb-sdk'
import FieldListAutoCompleteDropdown from './FieldListAutoCompleteDropdown.vue' import FieldListAutoCompleteDropdown from './FieldListAutoCompleteDropdown.vue'
import { getSortDirectionOptions } from '~/utils/sortUtils'
import { computed, inject, useViewSorts } from '#imports' import { computed, inject, useViewSorts } from '#imports'
import { ActiveViewInj, IsLockedInj, MetaInj, ReloadViewDataHookInj } from '~/context' import { ActiveViewInj, IsLockedInj, MetaInj, ReloadViewDataHookInj } from '~/context'
import MdiMenuDownIcon from '~icons/mdi/menu-down' import MdiMenuDownIcon from '~icons/mdi/menu-down'
@ -15,6 +17,12 @@ const reloadDataHook = inject(ReloadViewDataHookInj)
const { sorts, saveOrUpdate, loadSorts, addSort, deleteSort } = useViewSorts(view, () => reloadDataHook?.trigger()) const { sorts, saveOrUpdate, loadSorts, addSort, deleteSort } = useViewSorts(view, () => reloadDataHook?.trigger())
const columns = computed(() => meta?.value?.columns || []) const columns = computed(() => meta?.value?.columns || [])
const columnByID = computed<Record<string, ColumnType>>(() =>
columns?.value?.reduce((obj: any, col: any) => {
obj[col.id] = col
return obj
}, {}),
)
watch( watch(
() => (view?.value as any)?.id, () => (view?.value as any)?.id,
@ -27,19 +35,19 @@ watch(
<template> <template>
<a-dropdown offset-y class="" :trigger="['click']"> <a-dropdown offset-y class="" :trigger="['click']">
<v-badge :value="sorts && sorts.length" color="primary" dot overlap> <div :class="{ 'nc-badge nc-active-btn': sorts?.length }">
<a-button v-t="['c:sort']" size="small" class="nc-sort-menu-btn nc-toolbar-btn" :disabled="isLocked" <a-button v-t="['c:sort']" size="small" class="nc-sort-menu-btn nc-toolbar-btn" :disabled="isLocked"
><div class="flex align-center gap-1"> ><div class="flex align-center gap-1">
<MdiSortIcon class="text-grey" /> <MdiSortIcon class="text-grey" />
<!-- Sort --> <!-- Sort -->
<span class="text-capitalize nc-sort-menu-btn">{{ $t('activity.sort') }}</span> <span class="text-capitalize">{{ $t('activity.sort') }}</span>
<MdiMenuDownIcon class="text-grey" /> <MdiMenuDownIcon class="text-grey" />
</div> </div>
</a-button> </a-button>
</v-badge> </div>
<template #overlay> <template #overlay>
<div class="bg-white shadow p-2 menu-filter-dropdown min-w-[400px]"> <div class="bg-gray-50 shadow p-2 menu-filter-dropdown min-w-[400px] max-h-[max(80vh,500px)] overflow-auto">
<div class="sort-grid" @click.stop> <div v-if="sorts?.length" class="sort-grid mb-2" @click.stop>
<template v-for="(sort, i) in sorts || []" :key="i"> <template v-for="(sort, i) in sorts || []" :key="i">
<!-- <v-icon :key="`${i}icon`" class="nc-sort-item-remove-btn" small @click.stop="deleteSort(sort)"> mdi-close-box </v-icon> --> <!-- <v-icon :key="`${i}icon`" class="nc-sort-item-remove-btn" small @click.stop="deleteSort(sort)"> mdi-close-box </v-icon> -->
<MdiDeleteIcon <MdiDeleteIcon
@ -56,25 +64,27 @@ watch(
/> />
<a-select <a-select
v-model:value="sort.direction" v-model:value="sort.direction"
class="flex-shrink-1 flex-grow-0 caption nc-sort-dir-select" size="small"
:items="[ class="flex-shrink-1 flex-grow-0 caption nc-sort-dir-select !text-xs"
{ text: 'asc', value: 'asc' },
{ text: 'desc', value: 'desc' },
]"
:label="$t('labels.operation')" :label="$t('labels.operation')"
density="compact"
variant="solo"
hide-details
@click.stop @click.stop
@update:model-value="saveOrUpdate(sort, i)" @update:value="saveOrUpdate(sort, i)"
/> >
<a-select-option
v-for="(option, j) in getSortDirectionOptions(columnByID[sort.fk_column_id]?.uidt)"
:key="j"
:value="option.value"
>
<span class="text-xs">{{ option.text }}</span>
</a-select-option>
</a-select>
<!-- <template #item="{ item }"> --> <!-- <template #item="{ item }"> -->
<!-- <span class="caption font-weight-regular">{{ item.text }}</span> --> <!-- <span class="caption font-weight-regular">{{ item.text }}</span> -->
<!-- </template> --> <!-- </template> -->
<!-- </v-select> --> <!-- </v-select> -->
</template> </template>
</div> </div>
<a-button size="small" class="text-grey text-capitalize text-sm my-3" @click.stop="addSort"> <a-button size="small" class="text-xs text-grey text-capitalize my-2" @click.stop="addSort">
<div class="flex gap-1 align-center"> <div class="flex gap-1 align-center">
<MdiAddIcon /> <MdiAddIcon />
<!-- Add Sort Option --> <!-- Add Sort Option -->
@ -93,4 +103,8 @@ watch(
column-gap: 6px; column-gap: 6px;
row-gap: 6px; row-gap: 6px;
} }
:deep(.ant-btn, .ant-select, .ant-input, ::placeholder) {
@apply "!text-xs";
}
</style> </style>

4
packages/nc-gui-v2/components/smartsheet/Grid.vue

@ -32,9 +32,11 @@ const isPublicView = false
const selected = reactive<{ row?: number | null; col?: number | null }>({}) const selected = reactive<{ row?: number | null; col?: number | null }>({})
const editEnabled = ref(false) const editEnabled = ref(false)
const { sqlUi } = useProject()
const { xWhere } = useSmartsheetStoreOrThrow()
const addColumnDropdown = ref(false) const addColumnDropdown = ref(false)
const { loadData, paginationData, formattedData: data, updateRowProperty, changePage } = useViewData(meta, view as any) const { loadData, paginationData, formattedData: data, updateRowProperty, changePage } = useViewData(meta, view as any, xWhere)
const { loadGridViewColumns, updateWidth, resizingColWidth, resizingCol } = useGridViewColumnWidth(view) const { loadGridViewColumns, updateWidth, resizingColWidth, resizingCol } = useGridViewColumnWidth(view)
onMounted(loadGridViewColumns) onMounted(loadGridViewColumns)

10
packages/nc-gui-v2/components/tabs/Smartsheet.vue

@ -1,8 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ColumnType } from 'nocodb-sdk' import type { ColumnType, TableType, ViewType } from 'nocodb-sdk'
import { ViewTypes } from 'nocodb-sdk' import { ViewTypes } from 'nocodb-sdk'
import type { Ref } from 'vue'
import SmartsheetGrid from '../smartsheet/Grid.vue' import SmartsheetGrid from '../smartsheet/Grid.vue'
import { computed, inject, provide, useMetas, watch, watchEffect } from '#imports' import { computed, inject, provide, useMetas, useProvideSmartsheetStore, watch, watchEffect } from '#imports'
import { ActiveViewInj, FieldsInj, IsLockedInj, MetaInj, ReloadViewDataHookInj, RightSidebarInj, TabMetaInj } from '~/context' import { ActiveViewInj, FieldsInj, IsLockedInj, MetaInj, ReloadViewDataHookInj, RightSidebarInj, TabMetaInj } from '~/context'
import type { TabItem } from '~/composables' import type { TabItem } from '~/composables'
@ -19,7 +20,7 @@ const tabMeta = inject(
computed(() => ({} as TabItem)), computed(() => ({} as TabItem)),
) )
const meta = computed(() => metas.value?.[tabMeta?.value?.id as string]) const meta = computed<TableType>(() => metas.value?.[tabMeta?.value?.id as string])
watchEffect(async () => { watchEffect(async () => {
await getMeta(tabMeta?.value?.id as string) await getMeta(tabMeta?.value?.id as string)
@ -27,6 +28,7 @@ watchEffect(async () => {
const reloadEventHook = createEventHook<void>() const reloadEventHook = createEventHook<void>()
// todo: move to store
provide(MetaInj, meta) provide(MetaInj, meta)
provide(TabMetaInj, tabMeta) provide(TabMetaInj, tabMeta)
provide(ActiveViewInj, activeView) provide(ActiveViewInj, activeView)
@ -35,6 +37,8 @@ provide(ReloadViewDataHookInj, reloadEventHook)
provide(FieldsInj, fields) provide(FieldsInj, fields)
provide(RightSidebarInj, ref(true)) provide(RightSidebarInj, ref(true))
useProvideSmartsheetStore(activeView as Ref<TableType>, meta)
watch(tabMeta, async (newTabMeta, oldTabMeta) => { watch(tabMeta, async (newTabMeta, oldTabMeta) => {
if (newTabMeta !== oldTabMeta && newTabMeta.id) await getMeta(newTabMeta.id) if (newTabMeta !== oldTabMeta && newTabMeta.id) await getMeta(newTabMeta.id)
}) })

4
packages/nc-gui-v2/composables/index.ts

@ -11,7 +11,7 @@ export * from './useHasMany'
export * from './useManyToMany' export * from './useManyToMany'
export * from './useMetas' export * from './useMetas'
export * from './useProject' export * from './useProject'
export * from './useTableCreate' export * from './useTable'
export * from './useTabs' export * from './useTabs'
export * from './useViewColumns' export * from './useViewColumns'
export * from './useViewData' export * from './useViewData'
@ -19,3 +19,5 @@ export * from './useViewFilters'
export * from './useViews' export * from './useViews'
export * from './useViewSorts' export * from './useViewSorts'
export * from './useVirtualCell' export * from './useVirtualCell'
export * from './useColumnCreateStore'
export * from './useSmartsheetStore'

10
packages/nc-gui-v2/composables/useDashboard.ts

@ -0,0 +1,10 @@
export function useDashboard() {
const route = useRoute()
const dashboardUrl = computed(() => {
// todo: test in different scenarios
// get base path of app
return `${location.origin}${(location.pathname || '').replace(route.path, '')}`
})
return { dashboardUrl }
}

53
packages/nc-gui-v2/composables/useSmartsheetStore.ts

@ -0,0 +1,53 @@
import { computed } from '@vue/reactivity'
import { createInjectionState } from '@vueuse/core'
import type { TableType, ViewType } from 'nocodb-sdk'
import type { Ref } from 'vue'
import { useNuxtApp } from '#app'
import { useProject } from '#imports'
const [useProvideSmartsheetStore, useSmartsheetStore] = createInjectionState((view: Ref<ViewType>, meta: Ref<TableType>) => {
const { $api } = useNuxtApp()
const { sqlUi } = useProject()
// state
// todo: move to grid view store
const search = reactive({
field: '',
query: '',
})
// getters
const isLocked = computed(() => (view?.value as any)?.lock_type === 'locked')
const xWhere = computed(() => {
let where
const col = meta?.value?.columns?.find(({ id }) => id === search.field) || meta?.value?.columns?.find((v) => v.pv)
if (!col) return
if (!search.query.trim()) return
if (['text', 'string'].includes(sqlUi.value.getAbstractType(col)) && col.dt !== 'bigint') {
where = `(${col.title},like,%${search.query.trim()}%)`
} else {
where = `(${col.title},eq,${search.query.trim()})`
}
return where
})
// actions
return {
view,
meta,
isLocked,
$api,
search,
xWhere,
}
})
export { useProvideSmartsheetStore }
export function useSmartsheetStoreOrThrow() {
const smartsheetStore = useSmartsheetStore()
if (smartsheetStore == null) throw new Error('Please call `useSmartsheetStore` on the appropriate parent component')
return smartsheetStore
}

112
packages/nc-gui-v2/composables/useTable.ts

@ -0,0 +1,112 @@
import { Modal } from 'ant-design-vue'
import type { LinkToAnotherRecordType, TableType } from 'nocodb-sdk'
import { UITypes } from 'nocodb-sdk'
import { useToast } from 'vue-toastification'
import { useProject } from './useProject'
import { TabType } from '~/composables/useTabs'
import { extractSdkResponseErrorMsg } from '~/utils'
import { useNuxtApp } from '#app'
export function useTable(onTableCreate?: (tableMeta: TableType) => void) {
const table = reactive<{ title: string; table_name: string; columns: string[] }>({
title: '',
table_name: '',
columns: ['id', 'title', 'created_at', 'updated_at'],
})
const { $e, $api } = useNuxtApp()
const toast = useToast()
const { getMeta, removeMeta } = useMetas()
const { loadTables } = useProject()
const { closeTab } = useTabs()
const { sqlUi, project, tables } = useProject()
const createTable = async () => {
if (!sqlUi?.value) return
const columns = sqlUi?.value?.getNewTableColumns().filter((col) => {
if (col.column_name === 'id' && table.columns.includes('id_ag')) {
Object.assign(col, sqlUi?.value?.getDataTypeForUiType({ uidt: UITypes.ID }, 'AG'))
col.dtxp = sqlUi?.value?.getDefaultLengthForDatatype(col.dt)
col.dtxs = sqlUi?.value?.getDefaultScaleForDatatype(col.dt)
return true
}
return table.columns.includes(col.column_name)
})
const tableMeta = await $api.dbTable.create(project?.value?.id as string, {
...table,
columns,
})
onTableCreate?.(tableMeta)
}
watch(
() => table.title,
(title) => {
table.table_name = `${project?.value?.prefix || ''}${title}`
},
)
const generateUniqueTitle = () => {
let c = 1
while (tables?.value?.some((t) => t.title === `Sheet${c}`)) {
c++
}
table.title = `Sheet${c}`
}
const deleteTable = (table: TableType) => {
$e('c:table:delete')
// 'Click Submit to Delete The table'
Modal.confirm({
title: `Click Yes to Delete The table : ${table.title}`,
okText: 'Yes',
okType: 'danger',
cancelText: 'No',
async onOk() {
try {
const meta = (await getMeta(table.id as string)) as TableType
const relationColumns = meta?.columns?.filter((c) => c.uidt === UITypes.LinkToAnotherRecord)
if (relationColumns?.length) {
const refColMsgs = await Promise.all(
relationColumns.map(async (c, i) => {
const refMeta = (await getMeta(
(c?.colOptions as LinkToAnotherRecordType)?.fk_related_model_id as string,
)) as TableType
return `${i + 1}. ${c.title} is a LinkToAnotherRecord of ${(refMeta && refMeta.title) || c.title}`
}),
)
toast.info(
h('div', {
innerHTML: `<div style="padding:10px 4px">Unable to delete tables because of the following.
<br><br>${refColMsgs.join('<br>')}<br><br>
Delete them & try again</div>`,
}),
)
return
}
await $api.dbTable.delete(table?.id as string)
closeTab({
type: TabType.TABLE,
id: table.id,
title: table.title,
})
await loadTables()
removeMeta(table.id as string)
toast.info(`Deleted table ${table.title} successfully`)
$e('a:table:delete')
} catch (e: any) {
toast.error(await extractSdkResponseErrorMsg(e))
}
},
})
}
return { table, createTable, generateUniqueTitle, tables, project, deleteTable }
}

52
packages/nc-gui-v2/composables/useTableCreate.ts

@ -1,52 +0,0 @@
import type { TableType } from 'nocodb-sdk'
import { UITypes } from 'nocodb-sdk'
import { useProject } from './useProject'
import { useNuxtApp } from '#app'
export function useTableCreate(onTableCreate?: (tableMeta: TableType) => void) {
const table = reactive<{ title: string; table_name: string; columns: string[] }>({
title: '',
table_name: '',
columns: ['id', 'title', 'created_at', 'updated_at'],
})
const { sqlUi, project, tables } = useProject()
const { $api } = useNuxtApp()
const createTable = async () => {
if (!sqlUi?.value) return
const columns = sqlUi?.value?.getNewTableColumns().filter((col) => {
if (col.column_name === 'id' && table.columns.includes('id_ag')) {
Object.assign(col, sqlUi?.value?.getDataTypeForUiType({ uidt: UITypes.ID }, 'AG'))
col.dtxp = sqlUi?.value?.getDefaultLengthForDatatype(col.dt)
col.dtxs = sqlUi?.value?.getDefaultScaleForDatatype(col.dt)
return true
}
return table.columns.includes(col.column_name)
})
const tableMeta = await $api.dbTable.create(project?.value?.id as string, {
...table,
columns,
})
onTableCreate?.(tableMeta)
}
watch(
() => table.title,
(title) => {
table.table_name = `${project?.value?.prefix || ''}${title}`
},
)
const generateUniqueTitle = () => {
let c = 1
while (tables?.value?.some((t) => t.title === `Sheet${c}`)) {
c++
}
table.title = `Sheet${c}`
}
return { table, createTable, generateUniqueTitle, tables, project }
}

3
packages/nc-gui-v2/composables/useUIPermission/index.ts

@ -10,7 +10,6 @@ export function useUIPermission() {
const isUIAllowed = (permission: Permission, _skipPreviewAs = false) => { const isUIAllowed = (permission: Permission, _skipPreviewAs = false) => {
const user = $state.user const user = $state.user
let userRoles = user?.value?.roles || {} let userRoles = user?.value?.roles || {}
// if string populate key-value paired object // if string populate key-value paired object
if (typeof userRoles === 'string') { if (typeof userRoles === 'string') {
userRoles = userRoles.split(',').reduce<Record<string, boolean>>((acc, role) => { userRoles = userRoles.split(',').reduce<Record<string, boolean>>((acc, role) => {
@ -35,7 +34,7 @@ export function useUIPermission() {
return Object.entries<boolean>(roles).some(([role, hasRole]) => { return Object.entries<boolean>(roles).some(([role, hasRole]) => {
const rolePermission = rolePermissions[role as keyof typeof rolePermissions] as '*' | Record<Permission, true> const rolePermission = rolePermissions[role as keyof typeof rolePermissions] as '*' | Record<Permission, true>
return hasRole && (rolePermission === '*' || rolePermission[permission]) return hasRole && (rolePermission === '*' || rolePermission?.[permission])
}) })
} }

9
packages/nc-gui-v2/composables/useViewColumns.ts

@ -16,6 +16,7 @@ export function useViewColumns(
show: number | boolean show: number | boolean
title: string title: string
fk_column_id?: string fk_column_id?: string
system?: boolean
}[] }[]
>() >()
@ -42,6 +43,7 @@ export function useViewColumns(
fk_column_id: c.id, fk_column_id: c.id,
...(fieldById[c.id as string] ? fieldById[c.id as string] : {}), ...(fieldById[c.id as string] ? fieldById[c.id as string] : {}),
order: (fieldById[c.id as string] && fieldById[c.id as string].order) || order++, order: (fieldById[c.id as string] && fieldById[c.id as string].order) || order++,
system: isSystemColumn(fieldById[c.fk_model_id as string]?.type as ColumnType),
})) }))
.sort((a, b) => a.order - b.order) .sort((a, b) => a.order - b.order)
} else if (isPublic) { } else if (isPublic) {
@ -104,7 +106,7 @@ export function useViewColumns(
return false return false
} }
return !filterQuery?.value || field.title.toLowerCase().includes(filterQuery.value) return !filterQuery?.value || field.title.toLowerCase().includes(filterQuery.value.toLowerCase())
}) })
}) })
@ -125,10 +127,6 @@ export function useViewColumns(
?.sort((c1, c2) => c1.order - c2.order) ?.sort((c1, c2) => c1.order - c2.order)
?.map((c) => metaColumnById?.value?.[c.fk_column_id as string]) || []) as ColumnType[] ?.map((c) => metaColumnById?.value?.[c.fk_column_id as string]) || []) as ColumnType[]
}) })
const sortedFields = computed<ColumnType[]>(() => {
return (fields?.value?.sort((c1, c2) => c1.order - c2.order)?.map((c) => metaColumnById?.value?.[c.fk_column_id as string]) ||
[]) as ColumnType[]
})
// reload view columns when table meta changes // reload view columns when table meta changes
watch(meta, () => loadViewColumns()) watch(meta, () => loadViewColumns())
@ -143,6 +141,5 @@ export function useViewColumns(
saveOrUpdate, saveOrUpdate,
sortedAndFilteredFields, sortedAndFilteredFields,
showSystemFields, showSystemFields,
sortedFields,
} }
} }

15
packages/nc-gui-v2/composables/useViewData.ts

@ -1,4 +1,4 @@
import type { Api, FormType, GalleryType, GridType, PaginatedType, TableType } from 'nocodb-sdk' import type { Api, PaginatedType, TableType, ViewType } from 'nocodb-sdk'
import type { ComputedRef, Ref } from 'vue' import type { ComputedRef, Ref } from 'vue'
import { useNuxtApp } from '#app' import { useNuxtApp } from '#app'
import { useProject } from '#imports' import { useProject } from '#imports'
@ -13,10 +13,8 @@ const formatData = (list: Record<string, any>[]) =>
export function useViewData( export function useViewData(
meta: Ref<TableType> | ComputedRef<TableType> | undefined, meta: Ref<TableType> | ComputedRef<TableType> | undefined,
viewMeta: viewMeta: Ref<ViewType & { id: string }> | ComputedRef<ViewType & { id: string }> | undefined,
| Ref<(GridType | GalleryType | FormType) & { id: string }> where?: ComputedRef<string | undefined>,
| ComputedRef<(GridType | GalleryType | FormType) & { id: string }>
| undefined,
) { ) {
const data = ref<Record<string, any>[]>() const data = ref<Record<string, any>[]>()
const formattedData = ref<{ row: Record<string, any>; oldRow: Record<string, any>; rowMeta?: any }[]>() const formattedData = ref<{ row: Record<string, any>; oldRow: Record<string, any>; rowMeta?: any }[]>()
@ -27,7 +25,10 @@ export function useViewData(
const loadData = async (params: Parameters<Api<any>['dbViewRow']['list']>[4] = {}) => { const loadData = async (params: Parameters<Api<any>['dbViewRow']['list']>[4] = {}) => {
if (!project?.value?.id || !meta?.value?.id || !viewMeta?.value?.id) return if (!project?.value?.id || !meta?.value?.id || !viewMeta?.value?.id) return
const response = await $api.dbViewRow.list('noco', project.value.id, meta.value.id, viewMeta.value.id, params) const response = await $api.dbViewRow.list('noco', project.value.id, meta.value.id, viewMeta.value.id, {
...params,
where: where?.value,
})
data.value = response.list data.value = response.list
formattedData.value = formatData(response.list) formattedData.value = formatData(response.list)
paginationData.value = response.pageInfo paginationData.value = response.pageInfo
@ -97,7 +98,7 @@ export function useViewData(
const changePage = async (page: number) => { const changePage = async (page: number) => {
paginationData.value.page = page paginationData.value.page = page
await loadData({ offset: (page - 1) * (paginationData.value.pageSize || 25) } as any) await loadData({ offset: (page - 1) * (paginationData.value.pageSize || 25), where: where?.value } as any)
} }
return { data, loadData, paginationData, formattedData, insertRow, updateRowProperty, changePage } return { data, loadData, paginationData, formattedData, insertRow, updateRowProperty, changePage }

75
packages/nc-gui-v2/composables/useViewFilters.ts

@ -1,15 +1,19 @@
import type { FilterType, GalleryType, GridType, KanbanType } from 'nocodb-sdk' import type { FilterType, GalleryType, GridType, KanbanType } from 'nocodb-sdk'
import type { Ref } from 'vue' import type { ComputedRef, Ref } from 'vue'
import { useNuxtApp } from '#imports' import { useNuxtApp, useUIPermission } from '#imports'
export function useViewFilters( export function useViewFilters(
view: Ref<(GridType | KanbanType | GalleryType) & { id?: string }> | undefined, view: Ref<(GridType | KanbanType | GalleryType) & { id?: string }> | undefined,
parentId?: string, parentId?: string,
autoApply?: ComputedRef<boolean>,
reloadData?: () => void, reloadData?: () => void,
shared = false,
) { ) {
const filters = ref<(FilterType & { status?: 'update' | 'delete' })[]>([]) // todo: update swagger
const filters = ref<(FilterType & { status?: 'update' | 'delete' | 'create'; parentId?: string })[]>([])
const { $api } = useNuxtApp() const { $api } = useNuxtApp()
const { isUIAllowed } = useUIPermission()
const loadFilters = async () => { const loadFilters = async () => {
if (parentId) { if (parentId) {
@ -28,7 +32,7 @@ export function useViewFilters(
...filter, ...filter,
fk_parent_id: parentId, fk_parent_id: parentId,
}) })
} else { } else if (filter.status === 'create') {
filters.value[+i] = (await $api.dbTableFilter.create(view?.value?.id as string, { filters.value[+i] = (await $api.dbTableFilter.create(view?.value?.id as string, {
...filter, ...filter,
fk_parent_id: parentId, fk_parent_id: parentId,
@ -38,55 +42,41 @@ export function useViewFilters(
reloadData?.() reloadData?.()
} }
const deleteFilter = async (filter: FilterType, i: number) => { const deleteFilter = async (filter: FilterType & { status: string }, i: number) => {
// if (this.shared || !this._isUIAllowed('filterSync')) { if (shared || !isUIAllowed('filterSync')) {
// this.filters.splice(i, 1) const _filters = unref(filters.value)
// this.$emit('updated') _filters.splice(i, 1)
// } else filters.value = _filters
} else if (filter.id) {
if (filter.id) { if (!autoApply?.value) {
// if (!this.autoApply) { filter.status = 'delete'
// this.$set(filter, 'status', 'delete')
// } else {
await $api.dbTableFilter.delete(filter.id) /**/
// await this.loadFilter()
// this.$emit('updated')
// }
} else { } else {
// this.$emit('updated') await $api.dbTableFilter.delete(filter.id)
}
const _filters = unref(filters.value) const _filters = unref(filters.value)
_filters.splice(i, 1) _filters.splice(i, 1)
filters.value = _filters filters.value = _filters
// this.$e('a:filter:delete')
// // },
reloadData?.() reloadData?.()
} }
}
}
const saveOrUpdate = async (filter: FilterType, i: number) => { const saveOrUpdate = async (filter: FilterType & { status?: string }, i: number, force = false) => {
if (!view?.value) return if (!view?.value) return
if (shared || !isUIAllowed('filterSync')) {
// if (this.shared || !this._isUIAllowed('filterSync')) { // skip
// this.$emit('input', this.filters.filter(f => f.fk_column_id && f.comparison_op)) } else if (!autoApply?.value && !force) {
// this.$emit('updated') filter.status = filter.id ? 'update' : 'create'
// } else if (!this.autoApply) { } else if (filter.id) {
// filter.status = 'update'
// } else
if (filter.id) {
await $api.dbTableFilter.update(filter.id, { await $api.dbTableFilter.update(filter.id, {
...filter, ...filter,
fk_parent_id: parentId, fk_parent_id: parentId,
}) })
// this.$emit('updated')
} else { } else {
// todo: return type correction // todo: return type correction
filters.value[i] = (await $api.dbTableFilter.create(view?.value?.id as string, { filters.value[i] = (await $api.dbTableFilter.create(view?.value?.id as string, {
...filter, ...filter,
fk_parent_id: parentId, fk_parent_id: parentId,
})) as any })) as any
// this.$emit('updated')
} }
reloadData?.() reloadData?.()
} }
@ -95,10 +85,21 @@ export function useViewFilters(
filters.value.push({ filters.value.push({
comparison_op: 'eq', comparison_op: 'eq',
value: '', value: '',
status: 'update', status: 'create',
logical_op: 'and',
})
}
const addFilterGroup = async (parentId?: string) => {
filters.value.push({
parentId,
is_group: true,
status: 'create',
logical_op: 'and', logical_op: 'and',
}) })
const index = filters.value.length - 1
await saveOrUpdate(filters.value[index], index, true)
} }
return { filters, loadFilters, sync, deleteFilter, saveOrUpdate, addFilter } return { filters, loadFilters, sync, deleteFilter, saveOrUpdate, addFilter, addFilterGroup }
} }

33
packages/nc-gui-v2/package-lock.json generated

@ -19,7 +19,7 @@
"unique-names-generator": "^4.7.1", "unique-names-generator": "^4.7.1",
"vue-i18n": "^9.1.10", "vue-i18n": "^9.1.10",
"vue-toastification": "^2.0.0-rc.5", "vue-toastification": "^2.0.0-rc.5",
"vuedraggable": "^2.24.3", "vuedraggable": "^4.1.0",
"vuetify": "^3.0.0-alpha.13", "vuetify": "^3.0.0-alpha.13",
"xlsx": "^0.17.3" "xlsx": "^0.17.3"
}, },
@ -13869,17 +13869,20 @@
} }
}, },
"node_modules/vuedraggable": { "node_modules/vuedraggable": {
"version": "2.24.3", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-2.24.3.tgz", "resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-4.1.0.tgz",
"integrity": "sha512-6/HDXi92GzB+Hcs9fC6PAAozK1RLt1ewPTLjK0anTYguXLAeySDmcnqE8IC0xa7shvSzRjQXq3/+dsZ7ETGF3g==", "integrity": "sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==",
"dependencies": { "dependencies": {
"sortablejs": "1.10.2" "sortablejs": "1.14.0"
},
"peerDependencies": {
"vue": "^3.0.1"
} }
}, },
"node_modules/vuedraggable/node_modules/sortablejs": { "node_modules/vuedraggable/node_modules/sortablejs": {
"version": "1.10.2", "version": "1.14.0",
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.10.2.tgz", "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.14.0.tgz",
"integrity": "sha512-YkPGufevysvfwn5rfdlGyrGjt7/CRHwvRPogD/lC+TnvcN29jDpCifKP+rBqf+LRldfXSTh+0CGLcSg0VIxq3A==" "integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w=="
}, },
"node_modules/vuetify": { "node_modules/vuetify": {
"version": "3.0.0-beta.5", "version": "3.0.0-beta.5",
@ -24681,17 +24684,17 @@
} }
}, },
"vuedraggable": { "vuedraggable": {
"version": "2.24.3", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-2.24.3.tgz", "resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-4.1.0.tgz",
"integrity": "sha512-6/HDXi92GzB+Hcs9fC6PAAozK1RLt1ewPTLjK0anTYguXLAeySDmcnqE8IC0xa7shvSzRjQXq3/+dsZ7ETGF3g==", "integrity": "sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==",
"requires": { "requires": {
"sortablejs": "1.10.2" "sortablejs": "1.14.0"
}, },
"dependencies": { "dependencies": {
"sortablejs": { "sortablejs": {
"version": "1.10.2", "version": "1.14.0",
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.10.2.tgz", "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.14.0.tgz",
"integrity": "sha512-YkPGufevysvfwn5rfdlGyrGjt7/CRHwvRPogD/lC+TnvcN29jDpCifKP+rBqf+LRldfXSTh+0CGLcSg0VIxq3A==" "integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w=="
} }
} }
}, },

2
packages/nc-gui-v2/package.json

@ -25,7 +25,7 @@
"unique-names-generator": "^4.7.1", "unique-names-generator": "^4.7.1",
"vue-i18n": "^9.1.10", "vue-i18n": "^9.1.10",
"vue-toastification": "^2.0.0-rc.5", "vue-toastification": "^2.0.0-rc.5",
"vuedraggable": "^2.24.3", "vuedraggable": "^4.1.0",
"vuetify": "^3.0.0-alpha.13", "vuetify": "^3.0.0-alpha.13",
"xlsx": "^0.17.3" "xlsx": "^0.17.3"
}, },

35
packages/nc-gui-v2/utils/sortUtils.ts

@ -0,0 +1,35 @@
import { UITypes } from 'nocodb-sdk'
export const getSortDirectionOptions = (uidt: UITypes) => {
switch (uidt) {
case UITypes.Year:
case UITypes.Number:
case UITypes.Decimal:
case UITypes.Rating:
case UITypes.Count:
case UITypes.AutoNumber:
case UITypes.Time:
case UITypes.Currency:
case UITypes.Percent:
case UITypes.Duration:
case UITypes.PhoneNumber:
case UITypes.Date:
case UITypes.DateTime:
case UITypes.CreateTime:
case UITypes.LastModifiedTime:
return [
{ text: '1 → 9', value: 'asc' },
{ text: '9 → 1', value: 'desc' },
]
case UITypes.Checkbox:
return [
{ text: '▢ → ✓', value: 'asc' },
{ text: '✓ → ▢', value: 'desc' },
]
default:
return [
{ text: 'A → Z', value: 'asc' },
{ text: 'Z → A', value: 'desc' },
]
}
}

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

@ -127,6 +127,7 @@ export interface ViewType {
order?: number; order?: number;
fk_model_id?: string; fk_model_id?: string;
slug?: string; slug?: string;
lock_type?: 'collaborative' | 'locked' | 'personal';
} }
export interface TableInfoType { export interface TableInfoType {
@ -313,6 +314,7 @@ export interface GridType {
alias?: string; alias?: string;
deleted?: boolean; deleted?: boolean;
order?: number; order?: number;
lock_type?: 'collaborative' | 'locked' | 'personal';
} }
export interface GalleryType { export interface GalleryType {
@ -331,6 +333,7 @@ export interface GalleryType {
columns?: GalleryColumnType[]; columns?: GalleryColumnType[];
fk_model_id?: string; fk_model_id?: string;
fk_cover_image_col_id?: string; fk_cover_image_col_id?: string;
lock_type?: 'collaborative' | 'locked' | 'personal';
} }
export interface GalleryColumnType { export interface GalleryColumnType {
@ -382,6 +385,7 @@ export interface FormType {
submit_another_form?: boolean; submit_another_form?: boolean;
columns?: FormColumnType[]; columns?: FormColumnType[];
fk_model_id?: string; fk_model_id?: string;
lock_type?: 'collaborative' | 'locked' | 'personal';
} }
export interface FormColumnType { export interface FormColumnType {

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

@ -30,7 +30,7 @@ export default class View implements ViewType {
is_default: boolean; is_default: boolean;
order: number; order: number;
type: ViewTypes; type: ViewTypes;
lock_type?: string; lock_type?: ViewType['lock_type'];
fk_model_id: string; fk_model_id: string;
model?: Model; model?: Model;

4
packages/nocodb/src/lib/version-upgrader/ncProjectUpgraderV2_0090000.ts

@ -8,6 +8,7 @@ import {
ModelTypes, ModelTypes,
substituteColumnAliasWithIdInFormula, substituteColumnAliasWithIdInFormula,
UITypes, UITypes,
ViewType,
ViewTypes, ViewTypes,
} from 'nocodb-sdk'; } from 'nocodb-sdk';
import Column from '../models/Column'; import Column from '../models/Column';
@ -1029,7 +1030,8 @@ async function migrateViewsParams(
}, },
ncMeta ncMeta
); );
view.lock_type = queryParams?.viewStatus?.type; view.lock_type = queryParams?.viewStatus
?.type as ViewType['lock_type'];
} }
// migrate view sort list // migrate view sort list
for (const sort of queryParams.sortList || []) { for (const sort of queryParams.sortList || []) {

32
scripts/sdk/swagger.json

@ -6024,6 +6024,14 @@
}, },
"slug": { "slug": {
"type": "string" "type": "string"
},
"lock_type": {
"type": "string",
"enum": [
"collaborative",
"locked",
"personal"
]
} }
} }
}, },
@ -6852,6 +6860,14 @@
}, },
"order": { "order": {
"type": "number" "type": "number"
},
"lock_type": {
"type": "string",
"enum": [
"collaborative",
"locked",
"personal"
]
} }
}, },
"description": "" "description": ""
@ -6908,6 +6924,14 @@
}, },
"fk_cover_image_col_id": { "fk_cover_image_col_id": {
"type": "string" "type": "string"
},
"lock_type": {
"type": "string",
"enum": [
"collaborative",
"locked",
"personal"
]
} }
} }
}, },
@ -7057,6 +7081,14 @@
}, },
"fk_model_id": { "fk_model_id": {
"type": "string" "type": "string"
},
"lock_type": {
"type": "string",
"enum": [
"collaborative",
"locked",
"personal"
]
} }
} }
}, },

Loading…
Cancel
Save