Browse Source

chore(gui-v2): rebase

pull/2907/head
Wing-Kam Wong 2 years ago
parent
commit
b3b0f48459
  1. 1
      packages/nc-gui-v2/.eslintrc.js
  2. 45
      packages/nc-gui-v2/app.vue
  3. BIN
      packages/nc-gui-v2/assets/img/discourse-icon.png
  4. 45
      packages/nc-gui-v2/assets/style-v2.scss
  5. 5
      packages/nc-gui-v2/components.d.ts
  6. 8
      packages/nc-gui-v2/components/cell/Attachment.vue
  7. 99
      packages/nc-gui-v2/components/cell/DatePicker.vue
  8. 156
      packages/nc-gui-v2/components/cell/DateTimePicker.vue
  9. 43
      packages/nc-gui-v2/components/cell/Email.vue
  10. 67
      packages/nc-gui-v2/components/cell/Percent.vue
  11. 4
      packages/nc-gui-v2/components/cell/Rating.vue
  12. 61
      packages/nc-gui-v2/components/cell/Time.vue
  13. 66
      packages/nc-gui-v2/components/cell/TimePicker.vue
  14. 14
      packages/nc-gui-v2/components/cell/Url.vue
  15. 59
      packages/nc-gui-v2/components/cell/YearPicker.vue
  16. 149
      packages/nc-gui-v2/components/dashboard/TabView.vue
  17. 66
      packages/nc-gui-v2/components/dashboard/TreeView.vue
  18. 1
      packages/nc-gui-v2/components/dashboard/settings/AppStore.vue
  19. 6
      packages/nc-gui-v2/components/dashboard/settings/SettingsModal.vue
  20. 2
      packages/nc-gui-v2/components/dlg/AirtableImport.vue
  21. 7
      packages/nc-gui-v2/components/dlg/TableCreate.vue
  22. 2
      packages/nc-gui-v2/components/dlg/TableRename.vue
  23. 238
      packages/nc-gui-v2/components/dlg/ViewCreate.vue
  24. 68
      packages/nc-gui-v2/components/dlg/ViewDelete.vue
  25. 119
      packages/nc-gui-v2/components/general/FlippingCard.vue
  26. 2
      packages/nc-gui-v2/components/general/Share.vue
  27. 5
      packages/nc-gui-v2/components/general/Social.vue
  28. 2
      packages/nc-gui-v2/components/general/Sponsors.test.ts
  29. 7
      packages/nc-gui-v2/components/general/Sponsors.vue
  30. 4
      packages/nc-gui-v2/components/monaco/Editor.vue
  31. 94
      packages/nc-gui-v2/components/smartsheet-column/AdvancedOptions.vue
  32. 143
      packages/nc-gui-v2/components/smartsheet-column/EditOrAdd.vue
  33. 101
      packages/nc-gui-v2/components/smartsheet-header/Cell.vue
  34. 17
      packages/nc-gui-v2/components/smartsheet-header/CellIcon.vue
  35. 42
      packages/nc-gui-v2/components/smartsheet-header/Menu.vue
  36. 2
      packages/nc-gui-v2/components/smartsheet-header/VirtualCell.vue
  37. 8
      packages/nc-gui-v2/components/smartsheet-header/VirtualCellIcon.vue
  38. 13
      packages/nc-gui-v2/components/smartsheet-toolbar/AddRow.vue
  39. 174
      packages/nc-gui-v2/components/smartsheet-toolbar/ColumnFilter.vue
  40. 38
      packages/nc-gui-v2/components/smartsheet-toolbar/ColumnFilterMenu.vue
  41. 16
      packages/nc-gui-v2/components/smartsheet-toolbar/DeleteTable.vue
  42. 48
      packages/nc-gui-v2/components/smartsheet-toolbar/FieldListAutoCompleteDropdown.vue
  43. 72
      packages/nc-gui-v2/components/smartsheet-toolbar/FieldsMenu.vue
  44. 63
      packages/nc-gui-v2/components/smartsheet-toolbar/LockMenu.vue
  45. 18
      packages/nc-gui-v2/components/smartsheet-toolbar/MoreActions.vue
  46. 9
      packages/nc-gui-v2/components/smartsheet-toolbar/Reload.vue
  47. 44
      packages/nc-gui-v2/components/smartsheet-toolbar/SearchData.vue
  48. 157
      packages/nc-gui-v2/components/smartsheet-toolbar/ShareView.vue
  49. 156
      packages/nc-gui-v2/components/smartsheet-toolbar/SharedViewList.vue
  50. 51
      packages/nc-gui-v2/components/smartsheet-toolbar/SortListMenu.vue
  51. 21
      packages/nc-gui-v2/components/smartsheet-toolbar/ToggleDrawer.vue
  52. 9
      packages/nc-gui-v2/components/smartsheet/Cell.vue
  53. 5
      packages/nc-gui-v2/components/smartsheet/Gallery.vue
  54. 81
      packages/nc-gui-v2/components/smartsheet/Grid.vue
  55. 19
      packages/nc-gui-v2/components/smartsheet/Pagination.vue
  56. 281
      packages/nc-gui-v2/components/smartsheet/Sidebar.vue
  57. 22
      packages/nc-gui-v2/components/smartsheet/Toolbar.vue
  58. 3
      packages/nc-gui-v2/components/smartsheet/VirtualCell.vue
  59. 146
      packages/nc-gui-v2/components/smartsheet/sidebar/MenuBottom.vue
  60. 238
      packages/nc-gui-v2/components/smartsheet/sidebar/MenuTop.vue
  61. 179
      packages/nc-gui-v2/components/smartsheet/sidebar/RenameableMenuItem.vue
  62. 35
      packages/nc-gui-v2/components/smartsheet/sidebar/Toolbar.vue
  63. 130
      packages/nc-gui-v2/components/smartsheet/sidebar/index.vue
  64. 42
      packages/nc-gui-v2/components/tabs/Auth.vue
  65. 44
      packages/nc-gui-v2/components/tabs/Smartsheet.vue
  66. 200
      packages/nc-gui-v2/components/tabs/auth/ApiTokenManagement.vue
  67. 297
      packages/nc-gui-v2/components/tabs/auth/UserManagement.vue
  68. 31
      packages/nc-gui-v2/components/tabs/auth/user-management/FeedbackForm.vue
  69. 229
      packages/nc-gui-v2/components/tabs/auth/user-management/ShareBase.vue
  70. 238
      packages/nc-gui-v2/components/tabs/auth/user-management/UsersModal.vue
  71. 2
      packages/nc-gui-v2/components/virtual-cell/BelongsTo.vue
  72. 42
      packages/nc-gui-v2/components/virtual-cell/Formula.vue
  73. 3
      packages/nc-gui-v2/components/virtual-cell/HasMany.vue
  74. 3
      packages/nc-gui-v2/components/virtual-cell/ManyToMany.vue
  75. 2
      packages/nc-gui-v2/components/virtual-cell/Rollup.vue
  76. 10
      packages/nc-gui-v2/components/virtual-cell/components/ItemChip.vue
  77. 2
      packages/nc-gui-v2/components/virtual-cell/components/ListChildItems.vue
  78. 2
      packages/nc-gui-v2/components/virtual-cell/components/ListItems.vue
  79. 23
      packages/nc-gui-v2/composables/index.ts
  80. 148
      packages/nc-gui-v2/composables/useApi/index.ts
  81. 80
      packages/nc-gui-v2/composables/useApi/interceptors.ts
  82. 26
      packages/nc-gui-v2/composables/useApi/types.ts
  83. 4
      packages/nc-gui-v2/composables/useAttachment.ts
  84. 9
      packages/nc-gui-v2/composables/useBelongsTo.ts
  85. 4
      packages/nc-gui-v2/composables/useColors.ts
  86. 6
      packages/nc-gui-v2/composables/useColumn.ts
  87. 223
      packages/nc-gui-v2/composables/useColumnCreateStore.ts
  88. 10
      packages/nc-gui-v2/composables/useDashboard.ts
  89. 54
      packages/nc-gui-v2/composables/useGlobal/actions.ts
  90. 25
      packages/nc-gui-v2/composables/useGlobal/getters.ts
  91. 71
      packages/nc-gui-v2/composables/useGlobal/index.ts
  92. 95
      packages/nc-gui-v2/composables/useGlobal/state.ts
  93. 45
      packages/nc-gui-v2/composables/useGlobal/types.ts
  94. 148
      packages/nc-gui-v2/composables/useGlobalState.ts
  95. 66
      packages/nc-gui-v2/composables/useGridViewColumnWidth.ts
  96. 9
      packages/nc-gui-v2/composables/useHasMany.ts
  97. 20
      packages/nc-gui-v2/composables/useInjectionState/index.ts
  98. 9
      packages/nc-gui-v2/composables/useManyToMany.ts
  99. 4
      packages/nc-gui-v2/composables/useMetas.ts
  100. 11
      packages/nc-gui-v2/composables/useProject.ts
  101. Some files were not shown because too many files have changed in this diff Show More

1
packages/nc-gui-v2/.eslintrc.js

@ -9,4 +9,5 @@ module.exports = {
extends: ['@antfu', 'plugin:prettier/recommended'], extends: ['@antfu', 'plugin:prettier/recommended'],
plugins: ['prettier'], plugins: ['prettier'],
rules: baseRules, rules: baseRules,
ignorePatterns: ['!*.d.ts'],
} }

45
packages/nc-gui-v2/app.vue

@ -3,35 +3,35 @@ import MdiAt from '~icons/mdi/at'
import MdiLogout from '~icons/mdi/logout' import MdiLogout from '~icons/mdi/logout'
import MdiDotsVertical from '~icons/mdi/dots-vertical' import MdiDotsVertical from '~icons/mdi/dots-vertical'
import MaterialSymbolsMenu from '~icons/material-symbols/menu' import MaterialSymbolsMenu from '~icons/material-symbols/menu'
import MdiReload from '~icons/mdi/reload'
import { navigateTo } from '#app' import { navigateTo } from '#app'
import { useGlobal } from '#imports'
const { $state } = useNuxtApp() const state = useGlobal()
const sidebar = ref<HTMLDivElement>() const sidebar = ref<HTMLDivElement>()
const email = computed(() => $state.user?.value?.email ?? '---') const email = computed(() => state.user.value?.email ?? '---')
const signOut = () => { const signOut = () => {
$state.signOut() state.signOut()
navigateTo('/signin') navigateTo('/signin')
} }
const toggleSidebar = useToggle($state.sidebarOpen) const sidebarCollapsed = computed({
get: () => !state.sidebarOpen.value,
const sidebarOpen = computed({ set: (val) => (state.sidebarOpen.value = !val),
get: () => !$state.sidebarOpen.value,
set: (val) => toggleSidebar(val),
}) })
const toggleSidebar = () => {
sidebarCollapsed.value = !sidebarCollapsed.value
}
</script> </script>
<template> <template>
<a-layout> <a-layout class="min-h-[100vh]">
<a-layout-header class="flex !bg-primary items-center text-white px-4 shadow-md"> <a-layout-header class="flex !bg-primary items-center text-white px-4 shadow-md">
<MaterialSymbolsMenu <MaterialSymbolsMenu v-if="state.signedIn.value" class="text-xl cursor-pointer" @click="toggleSidebar" />
v-if="$state.signedIn.value"
class="text-xl cursor-pointer"
@click="toggleSidebar(!$state.sidebarOpen.value)"
/>
<div class="flex-1" /> <div class="flex-1" />
@ -41,15 +41,10 @@ const sidebarOpen = computed({
<span class="prose-xl">NocoDB</span> <span class="prose-xl">NocoDB</span>
</div> </div>
<!-- todo: loading is not yet supported by nuxt 3 - see https://v3.nuxtjs.org/migration/component-options#loading <div v-show="state.isLoading.value" class="text-gray-400 ml-3">
<span v-show="$nuxt.$loading.show" class="caption grey--text ml-3"> {{ $t('general.loading') }}
{{ $t('general.loading') }} <v-icon small color="grey">mdi-spin mdi-loading</v-icon> <MdiReload :class="{ 'animate-infinite animate-spin !text-success': state.isLoading.value }" />
</span> </div>
todo: replace shortkey?
<span v-shortkey="['ctrl', 'shift', 'd']" @shortkey="openDiscord" />
-->
</div> </div>
<div class="flex-1" /> <div class="flex-1" />
@ -57,7 +52,7 @@ const sidebarOpen = computed({
<div class="flex justify-end gap-4"> <div class="flex justify-end gap-4">
<general-language class="mr-3" /> <general-language class="mr-3" />
<template v-if="$state.signedIn.value"> <template v-if="state.signedIn.value">
<a-dropdown :trigger="['click']"> <a-dropdown :trigger="['click']">
<MdiDotsVertical class="md:text-xl cursor-pointer nc-user-menu" @click.prevent /> <MdiDotsVertical class="md:text-xl cursor-pointer nc-user-menu" @click.prevent />
@ -89,7 +84,7 @@ const sidebarOpen = computed({
<a-layout> <a-layout>
<a-layout-sider <a-layout-sider
v-model:collapsed="sidebarOpen" v-model:collapsed="sidebarCollapsed"
width="300" width="300"
collapsed-width="0" collapsed-width="0"
class="bg-white dark:!bg-gray-800 border-r-1 border-gray-200 dark:!border-gray-600 h-full" class="bg-white dark:!bg-gray-800 border-r-1 border-gray-200 dark:!border-gray-600 h-full"

BIN
packages/nc-gui-v2/assets/img/discourse-icon.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

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

@ -1,3 +1,14 @@
@import 'ant-design-vue/dist/antd.variable.min.css';
@import 'ant-design-vue/dist/antd.min.css';
:root {
--header-height: 56px;
}
.ant-layout-header {
height: var(--header-height) !important;
}
html, html,
body, body,
#__nuxt, #__nuxt,
@ -57,15 +68,39 @@ h1, h2, h3, h4, h5, h6, p, label, button, textarea, select {
@apply color-transition; @apply color-transition;
} }
:root {
--header-height: 64px;
}
html { 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 p-4 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));
}
.nc-sidebar-right-item {
@apply relative flex items-center;
&::after {
@apply rounded-md absolute top-0 left-0 right-0 bottom-0 transition-all duration-150 ease-in-out;
content: '';
}
&:hover::after {
@apply ring shadow-2xl transform scale-110;
}
svg {
@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;
} }

5
packages/nc-gui-v2/components.d.ts vendored

@ -16,8 +16,10 @@ declare module '@vue/runtime-core' {
ACol: typeof import('ant-design-vue/es')['Col'] ACol: typeof import('ant-design-vue/es')['Col']
ACollapse: typeof import('ant-design-vue/es')['Collapse'] ACollapse: typeof import('ant-design-vue/es')['Collapse']
ACollapsePanel: typeof import('ant-design-vue/es')['CollapsePanel'] ACollapsePanel: typeof import('ant-design-vue/es')['CollapsePanel']
ADatePicker: typeof import('ant-design-vue/es')['DatePicker']
ADivider: typeof import('ant-design-vue/es')['Divider'] ADivider: typeof import('ant-design-vue/es')['Divider']
ADropdown: typeof import('ant-design-vue/es')['Dropdown'] ADropdown: typeof import('ant-design-vue/es')['Dropdown']
ADropdownButton: typeof import('ant-design-vue/es')['DropdownButton']
AForm: typeof import('ant-design-vue/es')['Form'] AForm: typeof import('ant-design-vue/es')['Form']
AFormItem: typeof import('ant-design-vue/es')['FormItem'] AFormItem: typeof import('ant-design-vue/es')['FormItem']
AInput: typeof import('ant-design-vue/es')['Input'] AInput: typeof import('ant-design-vue/es')['Input']
@ -36,6 +38,7 @@ declare module '@vue/runtime-core' {
AMenuItemGroup: typeof import('ant-design-vue/es')['MenuItemGroup'] AMenuItemGroup: typeof import('ant-design-vue/es')['MenuItemGroup']
AModal: typeof import('ant-design-vue/es')['Modal'] AModal: typeof import('ant-design-vue/es')['Modal']
APagination: typeof import('ant-design-vue/es')['Pagination'] APagination: typeof import('ant-design-vue/es')['Pagination']
APopconfirm: typeof import('ant-design-vue/es')['Popconfirm']
ARate: typeof import('ant-design-vue/es')['Rate'] ARate: typeof import('ant-design-vue/es')['Rate']
ARow: typeof import('ant-design-vue/es')['Row'] ARow: typeof import('ant-design-vue/es')['Row']
ASelect: typeof import('ant-design-vue/es')['Select'] ASelect: typeof import('ant-design-vue/es')['Select']
@ -52,7 +55,9 @@ declare module '@vue/runtime-core' {
ATabs: typeof import('ant-design-vue/es')['Tabs'] ATabs: typeof import('ant-design-vue/es')['Tabs']
ATag: typeof import('ant-design-vue/es')['Tag'] ATag: typeof import('ant-design-vue/es')['Tag']
ATextarea: typeof import('ant-design-vue/es')['Textarea'] ATextarea: typeof import('ant-design-vue/es')['Textarea']
ATimePicker: typeof import('ant-design-vue/es')['TimePicker']
ATooltip: typeof import('ant-design-vue/es')['Tooltip'] ATooltip: typeof import('ant-design-vue/es')['Tooltip']
ATypographyText: typeof import('ant-design-vue/es')['TypographyText']
ATypographyTitle: typeof import('ant-design-vue/es')['TypographyTitle'] ATypographyTitle: typeof import('ant-design-vue/es')['TypographyTitle']
AUploadDragger: typeof import('ant-design-vue/es')['UploadDragger'] AUploadDragger: typeof import('ant-design-vue/es')['UploadDragger']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']

8
packages/nc-gui-v2/components/cell/Attachment.vue

@ -3,8 +3,8 @@ import { useToast } from 'vue-toastification'
import { inject, ref, useProject, watchEffect } from '#imports' import { inject, ref, useProject, watchEffect } from '#imports'
import { useNuxtApp } from '#app' import { useNuxtApp } from '#app'
import { ColumnInj, MetaInj } from '~/context' import { ColumnInj, MetaInj } from '~/context'
import { NOCO } from '~/lib/constants' import { NOCO } from '~/lib'
import { isImage } from '~/utils/fileUtils' import { isImage } from '~/utils'
import MaterialPlusIcon from '~icons/mdi/plus' import MaterialPlusIcon from '~icons/mdi/plus'
import MaterialArrowExpandIcon from '~icons/mdi/arrow-expand' import MaterialArrowExpandIcon from '~icons/mdi/arrow-expand'
@ -37,7 +37,7 @@ watchEffect(() => {
} }
}) })
const selectImage = (file: any, i) => { const selectImage = (file: any, i: unknown) => {
// todo: implement // todo: implement
} }
@ -49,7 +49,7 @@ const addFile = () => {
fileInput.value?.click() fileInput.value?.click()
} }
const onFileSelection = async (e) => { const onFileSelection = async (e: unknown) => {
// if (this.isPublicGrid) { // if (this.isPublicGrid) {
// return // return
// } // }

99
packages/nc-gui-v2/components/cell/DatePicker.vue

@ -1,91 +1,60 @@
<script setup lang="ts"> <script setup lang="ts">
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { computed } from '#imports' import { ColumnInj, ReadonlyInj } from '~/context'
interface Props {
modelValue: string
}
const { modelValue } = defineProps<Props>() const { modelValue } = defineProps<Props>()
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
const localState = computed({ interface Props {
get() { modelValue: string
if (!modelValue || !dayjs(modelValue).isValid()) {
return undefined
} }
return (/^\d+$/.test(modelValue) ? dayjs(+modelValue) : dayjs(modelValue)).format('YYYY-MM-DD') const columnMeta = inject(ColumnInj, null)
}, const readOnlyMode = inject(ReadonlyInj, false)
set(val?: string) {
if (dayjs(val).isValid()) {
emit('update:modelValue', val && dayjs(val).format('YYYY-MM-DD'))
}
},
})
/* let isDateInvalid = $ref(false)
const dateFormat = columnMeta?.meta?.date_format ?? 'YYYY-MM-DD'
export default { const localState = $computed({
name: 'DatePickerCell',
props: {
value: [String, Date],
},
computed: {
localState: {
get() { get() {
if (!this.value || !dayjs(this.value).isValid()) { if (!modelValue) {
return undefined return undefined
} }
return (/^\d+$/.test(this.value) ? dayjs(+this.value) : dayjs(this.value)).format('YYYY-MM-DD') if (!dayjs(modelValue).isValid()) {
}, isDateInvalid = true
set(val) { return undefined
if (dayjs(val).isValid()) {
this.$emit('input', val && dayjs(val).format('YYYY-MM-DD'))
}
},
},
date() {
if (!this.value || this.localState) {
return this.localState
} }
return 'Invalid Date'
},
parentListeners() {
const $listeners = {}
if (this.$listeners.blur) { return /^\d+$/.test(modelValue) ? dayjs(+modelValue) : dayjs(modelValue)
$listeners.blur = this.$listeners.blur },
} set(val?: dayjs.Dayjs) {
if (this.$listeners.focus) { if (!val) {
$listeners.focus = this.$listeners.focus emit('update:modelValue', null)
return
} }
return $listeners if (val.isValid()) {
}, emit('update:modelValue', val?.format('YYYY-MM-DD'))
},
mounted() {
if (this.$el && this.$el.$el) {
this.$el.$el.focus()
} }
}, },
} */ })
</script> </script>
<template> <template>
<!-- <v-menu> --> <a-date-picker
<!-- <template #activator="{ on }"> --> v-model:value="localState"
<input v-model="localState" type="date" class="value" /> :bordered="false"
<!-- </template> --> class="!w-full px-1"
<!-- <v-date-picker v-model="localState" flat @click.native.stop v-on="parentListeners" /> --> :format="dateFormat"
<!-- </v-menu> --> :placeholder="isDateInvalid ? 'Invalid date' : !readOnlyMode ? 'Select date' : ''"
:allow-clear="!readOnlyMode"
:input-read-only="true"
:open="readOnlyMode ? false : undefined"
>
<template #suffixIcon></template>
</a-date-picker>
</template> </template>
<style scoped> <style scoped></style>
.value {
width: 100%;
min-height: 20px;
}
</style>

156
packages/nc-gui-v2/components/cell/DateTimePicker.vue

@ -1,146 +1,62 @@
<script setup lang="ts"> <script setup lang="ts">
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { computed, ref, useProject } from '#imports' import { ReadonlyInj } from '~/context'
interface Props {
modelValue?: string
}
const { modelValue } = defineProps<Props>() const { modelValue } = defineProps<Props>()
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
const { isMysql } = useProject() interface Props {
const showMessage = ref(false) modelValue: string
const localState = computed({
get() {
if (!modelValue) {
return modelValue
}
const d = /^\d+$/.test(modelValue) ? dayjs(+modelValue) : dayjs(modelValue)
if (d.isValid()) {
showMessage.value = false
return d.format('YYYY-MM-DD HH:mm')
} else {
showMessage.value = true
}
},
set(value?: string) {
if (isMysql) {
emit('update:modelValue', value && dayjs(value).format('YYYY-MM-DD HH:mm:ss'))
} else {
emit('update:modelValue', value && dayjs(value).format('YYYY-MM-DD HH:mm:ssZ'))
} }
},
})
/* import dayjs from 'dayjs' const { isMysql } = useProject()
import utc from 'dayjs/plugin/utc'
const readOnlyMode = inject(ReadonlyInj, false)
dayjs.extend(utc) let isDateInvalid = $ref(false)
const dateFormat = isMysql ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD HH:mm:ssZ'
export default { const localState = $computed({
name: 'DateTimePickerCell',
props: {
value: [String, Date, Number],
ignoreFocus: Boolean,
},
data: () => ({
showMessage: false,
}),
computed: {
isMysql() {
return ['mysql', 'mysql2'].indexOf(this.$store.getters['project/GtrClientType'])
},
localState: {
get() { get() {
if (!this.value) { if (!modelValue) {
return this.value return undefined
}
const d = /^\d+$/.test(this.value) ? dayjs(+this.value) : dayjs(this.value)
if (d.isValid()) {
this.showMessage = false
return d.format('YYYY-MM-DD HH:mm')
} else {
this.showMessage = true
}
},
set(value) {
if (this.isMysql) {
this.$emit('input', value && dayjs(value).format('YYYY-MM-DD HH:mm:ss'))
} else {
this.$emit('input', value && dayjs(value).format('YYYY-MM-DD HH:mm:ssZ'))
} }
},
},
parentListeners() {
const $listeners = {}
if (this.$listeners.blur) { if (!dayjs(modelValue).isValid()) {
// $listeners.blur = this.$listeners.blur isDateInvalid = true
} return undefined
if (this.$listeners.focus) {
$listeners.focus = this.$listeners.focus
} }
return $listeners return /^\d+$/.test(modelValue) ? dayjs(+modelValue) : dayjs(modelValue)
},
}, },
mounted() { set(val?: dayjs.Dayjs) {
// listen dialog click:outside event and save on close if (!val) {
if (this.$refs.picker && this.$refs.picker.$children && this.$refs.picker.$children[0]) { emit('update:modelValue', null)
this.$refs.picker.$children[0].$on('click:outside', () => { return
this.$refs.picker.okHandler()
})
} }
if (!this.ignoreFocus) { if (val.isValid()) {
this.$refs.picker.display = true emit('update:modelValue', val?.format(dateFormat))
} }
}, },
} */ })
</script> </script>
<template> <template>
<input v-model="localState" type="datetime-local" /> <a-date-picker
<!-- <div> --> v-model:value="localState"
<!-- <div v-show="!showMessage"> --> :show-time="true"
<!-- <v-datetime-picker --> :bordered="false"
<!-- ref="picker" --> class="!w-full px-1"
<!-- v-model="localState" --> format="YYYY-MM-DD HH:mm"
<!-- class="caption xc-date-time-picker" --> :placeholder="isDateInvalid ? 'Invalid date' : !readOnlyMode ? 'Select date and time' : ''"
<!-- :text-field-props="{ --> :allow-clear="!readOnlyMode"
<!-- class: 'caption mt-0 pt-0', --> :input-read-only="true"
<!-- flat: true, --> :open="readOnlyMode ? false : undefined"
<!-- solo: true, --> >
<!-- dense: true, --> <template #suffixIcon></template>
<!-- hideDetails: true, --> </a-date-picker>
<!-- }" -->
<!-- :time-picker-props="{ -->
<!-- format: '24hr', -->
<!-- }" -->
<!-- v-on="parentListeners" -->
<!-- /> -->
<!-- </div> -->
<!-- <div v-show="showMessage" class="edit-warning" @dblclick="$refs.picker.display = true"> -->
<!-- &lt;!&ndash; TODO: i18n &ndash;&gt; -->
<!-- ERR: Couldn't parse {{ value }} -->
<!-- </div> -->
<!-- </div> -->
</template> </template>
<style scoped> <style scoped></style>
/*:deep(.v-input),*/
/*:deep(.v-text-field) {*/
/* margin-top: 0 !important;*/
/* padding-top: 0 !important;*/
/* font-size: inherit !important;*/
/*}*/
/*.edit-warning {*/
/* padding: 10px;*/
/* text-align: left;*/
/* color: #e65100;*/
/*}*/
</style>

43
packages/nc-gui-v2/components/cell/Email.vue

@ -1,41 +1,32 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from '#imports' import { computed } from '#imports'
import { isEmail } from '~/utils/validation' import { isEmail } from '~/utils'
const { modelValue: value } = defineProps<Props>()
const emit = defineEmits(['update:modelValue'])
const editEnabled = inject<boolean>('editEnabled')
interface Props { interface Props {
modelValue: string modelValue: string
} }
interface Emits {
(event: 'update:modelValue', model: string): void
}
const props = defineProps<Props>()
const emits = defineEmits<Emits>()
const root = ref<HTMLInputElement>() const root = ref<HTMLInputElement>()
const localState = computed({
get: () => value,
set: (val) => emit('update:modelValue', val),
})
const validEmail = computed(() => isEmail(value)) const editEnabled = inject<boolean>('editEnabled')
</script>
<script lang="ts"> const vModel = useVModel(props, 'modelValue', emits)
export default {
name: 'EmailCell', const validEmail = computed(() => isEmail(vModel.value))
}
</script> </script>
<template> <template>
<input v-if="editEnabled" ref="root" v-model="localState" /> <input v-if="editEnabled" ref="root" v-model="vModel" class="outline-none prose-sm" />
<a <a v-else-if="validEmail" class="prose-sm underline hover:opacity-75" :href="`mailto:${vModel}`" target="_blank">
v-else-if="validEmail" {{ vModel }}
class="caption py-2 text-primary underline hover:opacity-75"
:href="`mailto:${value}`"
target="_blank"
>
{{ value }}
</a> </a>
<span v-else>{{ value }}</span> <span v-else>{{ vModel }}</span>
</template> </template>

67
packages/nc-gui-v2/components/cell/Percent.vue

@ -0,0 +1,67 @@
<script setup lang="ts">
import { computed, inject } from '#imports'
import { ColumnInj } from '~/context'
import { getPercentStep, isValidPercent, renderPercent } from '@/utils/percentUtils'
interface Props {
modelValue: number | string
}
const { modelValue } = defineProps<Props>()
const emit = defineEmits(['update:modelValue'])
const column = inject(ColumnInj)
const percent = ref()
const isEdited = ref(false)
const percentType = computed(() => column?.meta?.precision || 0)
const percentStep = computed(() => getPercentStep(percentType.value))
const localState = computed({
get: () => {
return renderPercent(modelValue, percentType.value, !isEdited.value)
},
set: (val) => {
if (val === null) val = 0
if (isValidPercent(val, column?.meta?.negative)) {
percent.value = val / 100
}
},
})
function onKeyDown(evt: KeyboardEvent) {
isEdited.value = true
return ['e', 'E', '+', '-'].includes(evt.key) && evt.preventDefault()
}
function onBlur() {
if (isEdited.value) {
emit('update:modelValue', percent.value)
isEdited.value = false
}
}
function onKeyDownEnter() {
if (isEdited.value) {
emit('update:modelValue', percent.value)
isEdited.value = false
}
}
</script>
<template>
<input
v-if="isEdited"
v-model="localState"
type="number"
:step="percentStep"
@keydown="onKeyDown"
@blur="onBlur"
@keydown.enter="onKeyDownEnter"
/>
<input v-else v-model="localState" type="text" @focus="isEdited = true" />
</template>

4
packages/nc-gui-v2/components/cell/Rating.vue

@ -8,7 +8,7 @@ import MdiThumbUpIcon from '~icons/mdi/thumb-up'
import MdiFlagIcon from '~icons/mdi/flag' import MdiFlagIcon from '~icons/mdi/flag'
interface Props { interface Props {
modelValue?: string | number modelValue?: number
readOnly?: boolean readOnly?: boolean
} }
@ -44,5 +44,3 @@ const vModel = useVModel(props, 'modelValue', emits)
</template> </template>
</a-rate> </a-rate>
</template> </template>
<style scoped></style>

61
packages/nc-gui-v2/components/cell/Time.vue

@ -1,61 +0,0 @@
<script setup lang="ts">
import { inject } from 'vue'
interface Props {
modelValue: any
}
const props = defineProps<Props>()
const emits = defineEmits(['update:modelValue', 'save'])
const vModel = useVModel(props, 'modelValue', emits)
const editEnabled = inject<boolean>('editEnabled')
</script>
<template>
<v-menu>
<template #activator="{ props: menuProps }">
<input v-model="vModel" class="value" v-bind="menuProps.onClick" />
</template>
<div class="d-flex flex-column justify-center" @click.stop>
<v-time-picker v-model="vModel" />
<v-btn small color="primary" @click="emits('save')">
<!-- Save -->
{{ $t('general.save') }}
</v-btn>
</div>
</v-menu>
</template>
<style scoped>
.value {
width: 100%;
min-height: 20px;
}
</style>
<!--
/**
* @copyright Copyright (c) 2021, Xgene Cloud Ltd
*
* @author Naveen MR <oof1lab@gmail.com>
* @author Pranav C Balan <pranavxc@gmail.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
-->

66
packages/nc-gui-v2/components/cell/TimePicker.vue

@ -0,0 +1,66 @@
<script setup lang="ts">
import dayjs from 'dayjs'
import { ReadonlyInj } from '~/context'
const { modelValue } = defineProps<Props>()
const emit = defineEmits(['update:modelValue'])
interface Props {
modelValue: string
}
const { isMysql } = useProject()
const readOnlyMode = inject(ReadonlyInj, false)
let isTimeInvalid = $ref(false)
const dateFormat = isMysql ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD HH:mm:ssZ'
const localState = $computed({
get() {
if (!modelValue) {
return undefined
}
if (!dayjs(modelValue).isValid()) {
isTimeInvalid = true
return undefined
}
return dayjs(modelValue)
},
set(val?: dayjs.Dayjs) {
if (!val) {
emit('update:modelValue', null)
return
}
if (val.isValid()) {
const time = val.format('HH:mm')
const date = dayjs(`1999-01-01 ${time}:00`)
emit('update:modelValue', date.format(dateFormat))
}
},
})
</script>
<template>
<a-time-picker
v-model:value="localState"
autofocus
:show-time="true"
:bordered="false"
use12-hours
format="HH:mm"
class="!w-full px-1"
:placeholder="isTimeInvalid ? 'Invalid time' : !readOnlyMode ? 'Select time' : ''"
:allow-clear="!readOnlyMode"
:input-read-only="true"
:open="readOnlyMode ? false : undefined"
>
<template #suffixIcon></template>
</a-time-picker>
</template>
<style scoped></style>

14
packages/nc-gui-v2/components/cell/Url.vue

@ -8,11 +8,14 @@ interface Props {
} }
const { modelValue: value } = defineProps<Props>() const { modelValue: value } = defineProps<Props>()
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
const column = inject(ColumnInj) const column = inject(ColumnInj)
const editEnabled = inject<boolean>('editEnabled') const editEnabled = inject<boolean>('editEnabled')
const localState = computed({ const vModel = computed({
get: () => value, get: () => value,
set: (val) => { set: (val) => {
if (!(column && column.meta && column.meta.validate) || isValidURL(val)) { if (!(column && column.meta && column.meta.validate) || isValidURL(val)) {
@ -24,19 +27,16 @@ const localState = computed({
const isValid = computed(() => value && isValidURL(value)) const isValid = computed(() => value && isValidURL(value))
const root = ref<HTMLInputElement>() const root = ref<HTMLInputElement>()
onMounted(() => { onMounted(() => {
root.value?.focus() root.value?.focus()
}) })
</script> </script>
<template> <template>
<span v-if="editEnabled"> <input v-if="editEnabled" ref="root" v-model="vModel" class="outline-none" />
<input ref="root" v-model="localState" /> <nuxt-link v-else-if="isValid" class="py-2 underline hover:opacity-75" :to="value" target="_blank">{{ value }}</nuxt-link>
</span>
<span v-else>
<a v-if="isValid" class="caption py-2 text-primary underline hover:opacity-75" :href="value" target="_blank">{{ value }}</a>
<span v-else>{{ value }}</span> <span v-else>{{ value }}</span>
</span>
</template> </template>
<style scoped></style> <style scoped></style>

59
packages/nc-gui-v2/components/cell/YearPicker.vue

@ -0,0 +1,59 @@
<script setup lang="ts">
import dayjs from 'dayjs'
import { ReadonlyInj } from '~/context'
const { modelValue } = defineProps<Props>()
const emit = defineEmits(['update:modelValue'])
interface Props {
modelValue: number
}
const readOnlyMode = inject(ReadonlyInj, false)
let isYearInvalid = $ref(false)
const localState = $computed({
get() {
if (!modelValue) {
return undefined
}
const yearDate = dayjs(modelValue.toString(), 'YYYY')
if (!yearDate.isValid()) {
isYearInvalid = true
return undefined
}
return yearDate
},
set(val?: dayjs.Dayjs) {
if (!val) {
emit('update:modelValue', null)
return
}
if (val?.isValid()) {
emit('update:modelValue', Number(val.format('YYYY')))
}
},
})
</script>
<template>
<a-date-picker
v-model:value="localState"
picker="year"
:bordered="false"
class="!w-full px-1"
:placeholder="isYearInvalid ? 'Invalid year' : !readOnlyMode ? 'Select year' : ''"
:allow-clear="!readOnlyMode"
:input-read-only="true"
:open="readOnlyMode ? false : undefined"
>
<template #suffixIcon></template>
</a-date-picker>
</template>
<style scoped></style>

149
packages/nc-gui-v2/components/dashboard/TabView.vue

@ -1,149 +0,0 @@
<script setup lang="ts">
import useTabs from '~/composables/useTabs'
import MdiPlusIcon from '~icons/mdi/plus'
import MdiTableIcon from '~icons/mdi/table'
import MdiCsvIcon from '~icons/mdi/file-document-outline'
import MdiExcelIcon from '~icons/mdi/file-excel'
import MdiJSONIcon from '~icons/mdi/code-json'
import MdiAirTableIcon from '~icons/mdi/table-large'
import MdiRequestDataSourceIcon from '~icons/mdi/open-in-new'
import MdiAccountGroupIcon from '~icons/mdi/account-group'
const { tabs, activeTab, closeTab } = useTabs()
const { isUIAllowed } = useUIPermission()
const tableCreateDialog = ref(false)
const airtableImportDialog = ref(false)
const quickImportDialog = ref(false)
const importType = ref('')
const currentMenu = ref<string[]>(['addORImport'])
function onEdit(targetKey: number, action: string) {
if (action !== 'add') {
closeTab(targetKey)
}
}
function openQuickImportDialog(type: string) {
quickImportDialog.value = true
importType.value = type
}
</script>
<template>
<div>
<a-tabs v-model:activeKey="activeTab" hide-add type="editable-card" :tab-position="top" @edit="onEdit">
<a-tab-pane v-for="(tab, i) in tabs" :key="i" :value="i" class="text-capitalize" :closable="true">
<template #tab>
<span class="flex items-center gap-2">
<MdiAccountGroupIcon v-if="tab.type === 'auth'" class="text-primary" />
<MdiTableIcon v-else class="text-primary" />
{{ tab.title }}
</span>
</template>
</a-tab-pane>
<template #leftExtra>
<a-menu v-model:selectedKeys="currentMenu" mode="horizontal">
<a-sub-menu key="addORImport">
<template #title>
<span class="flex items-center gap-2">
<MdiPlusIcon />
Add / Import
</span>
</template>
<a-menu-item-group v-if="isUIAllowed('addTable')">
<a-menu-item key="add-new-table" v-t="['a:actions:create-table']" @click="tableCreateDialog = true">
<span class="flex items-center gap-2">
<MdiTableIcon class="text-primary" />
<!-- Add new table -->
{{ $t('tooltip.addTable') }}
</span>
</a-menu-item>
</a-menu-item-group>
<a-menu-item-group title="QUICK IMPORT FROM">
<a-menu-item
v-if="isUIAllowed('airtableImport')"
key="quick-import-airtable"
v-t="['a:actions:import-airtable']"
@click="airtableImportDialog = true"
>
<span class="flex items-center gap-2">
<MdiAirTableIcon class="text-primary" />
<!-- TODO: i18n -->
Airtable
</span>
</a-menu-item>
<a-menu-item
v-if="isUIAllowed('csvImport')"
key="quick-import-csv"
v-t="['a:actions:import-csv']"
@click="openQuickImportDialog('csv')"
>
<span class="flex items-center gap-2">
<MdiCsvIcon class="text-primary" />
<!-- TODO: i18n -->
CSV file
</span>
</a-menu-item>
<a-menu-item
v-if="isUIAllowed('jsonImport')"
key="quick-import-json"
v-t="['a:actions:import-json']"
@click="openQuickImportDialog('json')"
>
<span class="flex items-center gap-2">
<MdiJSONIcon class="text-primary" />
<!-- TODO: i18n -->
JSON file
</span>
</a-menu-item>
<a-menu-item
v-if="isUIAllowed('excelImport')"
key="quick-import-excel"
v-t="['a:actions:import-excel']"
@click="openQuickImportDialog('excel')"
>
<span class="flex items-center gap-2">
<MdiExcelIcon class="text-primary" />
<!-- TODO: i18n -->
Microsoft Excel
</span>
</a-menu-item>
</a-menu-item-group>
<a-divider class="ma-0 mb-2" />
<a-menu-item
v-if="isUIAllowed('importRequest')"
key="add-new-table"
v-t="['e:datasource:import-request']"
class="ma-0 mt-3"
>
<a href="https://github.com/nocodb/nocodb/issues/2052" target="_blank" class="prose-sm pa-0">
<span class="flex items-center gap-2">
<MdiRequestDataSourceIcon class="text-primary" />
<!-- TODO: i18n -->
Request a data source you need?
</span>
</a>
</a-menu-item>
</a-sub-menu>
</a-menu>
</template>
</a-tabs>
<DlgTableCreate v-if="tableCreateDialog" v-model="tableCreateDialog" />
<DlgQuickImport v-if="quickImportDialog" v-model="quickImportDialog" :import-type="importType" />
<DlgAirtableImport v-if="airtableImportDialog" v-model="airtableImportDialog" />
<v-window v-model="activeTab">
<v-window-item v-for="(tab, i) in tabs" :key="i" :value="i">
<TabsAuth v-if="tab.type === 'auth'" :tab-meta="tab" />
<TabsSmartsheet v-else :tab-meta="tab" />
</v-window-item>
</v-window>
</div>
</template>
<style scoped lang="scss">
:deep(.ant-menu-item-group-list) .ant-menu-item {
@apply m-0 pa-0 pl-4 pr-16;
}
</style>

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

@ -1,15 +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 { UITypes } from 'nocodb-sdk'
import type { LinkToAnotherRecordType, TableType } from 'nocodb-sdk'
import Sortable from 'sortablejs' import Sortable from 'sortablejs'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import { watchEffect } from '#imports' import SettingsModal from './settings/SettingsModal.vue'
import { computed, useProject, useTable, useTabs, useUIPermission, watchEffect } from '#imports'
import { useNuxtApp, useRoute } from '#app' import { useNuxtApp, useRoute } from '#app'
import useProject from '~/composables/useProject'
import useTabs from '~/composables/useTabs'
import { extractSdkResponseErrorMsg } from '~/utils/errorUtils'
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'
@ -19,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 SettingsModal from '~/components/dashboard/settings/SettingsModal.vue'
const { addTab } = useTabs() const { addTab } = useTabs()
const toast = useToast() const toast = useToast()
@ -28,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) => {
@ -129,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: '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) => {

1
packages/nc-gui-v2/components/dashboard/settings/AppStore.vue

@ -4,6 +4,7 @@ import AppInstall from './app-store/AppInstall.vue'
import MdiEditIcon from '~icons/ic/round-edit' import MdiEditIcon from '~icons/ic/round-edit'
import MdiCloseCircleIcon from '~icons/mdi/close-circle-outline' import MdiCloseCircleIcon from '~icons/mdi/close-circle-outline'
import MdiPlusIcon from '~icons/mdi/plus' import MdiPlusIcon from '~icons/mdi/plus'
const { $api, $e } = useNuxtApp() const { $api, $e } = useNuxtApp()
const toast = useToast() const toast = useToast()

6
packages/nc-gui-v2/components/dashboard/settings/SettingsModal.vue

@ -4,6 +4,8 @@ import AuditTab from './AuditTab.vue'
import AppStore from './AppStore.vue' import AppStore from './AppStore.vue'
import Metadata from './Metadata.vue' import Metadata from './Metadata.vue'
import UIAcl from './UIAcl.vue' import UIAcl from './UIAcl.vue'
import ApiTokenManagement from '~/components/tabs/auth/ApiTokenManagement.vue'
import UserManagement from '~/components/tabs/auth/UserManagement.vue'
import StoreFrontOutline from '~icons/mdi/storefront-outline' import StoreFrontOutline from '~icons/mdi/storefront-outline'
import TeamFillIcon from '~icons/ri/team-fill' import TeamFillIcon from '~icons/ri/team-fill'
import MultipleTableIcon from '~icons/mdi/table-multiple' import MultipleTableIcon from '~icons/mdi/table-multiple'
@ -39,11 +41,11 @@ const tabsInfo: TabGroup = {
subTabs: { subTabs: {
usersManagement: { usersManagement: {
title: 'Users Management', title: 'Users Management',
body: () => AuditTab, body: () => UserManagement,
}, },
apiTokenManagement: { apiTokenManagement: {
title: 'API Token Management', title: 'API Token Management',
body: () => AuditTab, body: () => ApiTokenManagement,
}, },
}, },
}, },

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

@ -142,7 +142,7 @@ async function sync() {
method: 'POST', method: 'POST',
headers: { 'xc-auth': $state.token.value as string }, headers: { 'xc-auth': $state.token.value as string },
params: { params: {
id: socket.id, id: socket?.id,
}, },
}) })
} catch (e: any) { } catch (e: any) {

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

@ -2,8 +2,9 @@
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'
interface Props { interface Props {
modelValue?: boolean modelValue?: boolean
@ -28,13 +29,13 @@ 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({
id: table.id as string, id: table.id as string,
title: table.title, title: table.title,
type: 'table', type: TabType.TABLE,
}) })
dialogShow.value = false dialogShow.value = false
}) })

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'

238
packages/nc-gui-v2/components/dlg/ViewCreate.vue

@ -1,32 +1,70 @@
<script setup lang="ts"> <script setup lang="ts">
import { inject } from '@vue/runtime-core' import type { ComponentPublicInstance } from '@vue/runtime-core'
import type { TableType } from 'nocodb-sdk' import { notification } from 'ant-design-vue'
import type { Form as AntForm } from 'ant-design-vue'
import { capitalize, inject } from '@vue/runtime-core'
import type { FormType, GalleryType, GridType, KanbanType } from 'nocodb-sdk'
import { ViewTypes } from 'nocodb-sdk' import { ViewTypes } from 'nocodb-sdk'
import type { Ref } from '#imports' import { useI18n } from 'vue-i18n'
import { ActiveViewInj, MetaInj, ViewListInj } from '~/context' import { MetaInj, ViewListInj } from '~/context'
import useViewCreate from '~/composables/useViewCreate' import { generateUniqueTitle } from '~/utils'
import { computed, nextTick, reactive, unref, useApi, useVModel, watch } from '#imports'
const { modelValue, type } = defineProps<{ type: ViewTypes; modelValue: boolean }>() interface Props {
modelValue: boolean
type: ViewTypes
title?: string
}
interface Emits {
(event: 'update:modelValue', value: boolean): void
(event: 'created', value: GridType | KanbanType | GalleryType | FormType): void
}
interface Form {
title: string
type: ViewTypes
copy_from_id: string | null
}
const props = defineProps<Props>()
const emits = defineEmits<Emits>()
const emit = defineEmits(['update:modelValue', 'created']) const inputEl = $ref<ComponentPublicInstance>()
const valid = ref(false) const formValidator = $ref<typeof AntForm>()
const vModel = useVModel(props, 'modelValue', emits)
const { t } = useI18n()
const { isLoading: loading, api } = useApi()
const meta = inject(MetaInj)
const viewList = inject(ViewListInj) const viewList = inject(ViewListInj)
const activeView = inject(ActiveViewInj)
const dialogShow = computed({ const form = reactive<Form>({
get() { title: props.title || '',
return modelValue type: props.type,
}, copy_from_id: null,
set(v) {
emit('update:modelValue', v)
},
}) })
const { view, createView, generateUniqueTitle, loading } = useViewCreate(inject(MetaInj) as Ref<TableType>, (view) => const formRules = [
emit('created', view), // name is required
) { required: true, message: `${t('labels.viewName')} ${t('general.required')}` },
// name is unique
{
validator: (_: unknown, v: string) =>
new Promise((resolve, reject) => {
;(unref(viewList) || []).every((v1) => ((v1 as GridType | KanbanType | GalleryType).alias || v1.title) !== v)
? resolve(true)
: reject(new Error(`View name should be unique`))
}),
message: 'View name should be unique',
},
]
const typeAlias = computed( const typeAlias = computed(
() => () =>
@ -35,113 +73,85 @@ const typeAlias = computed(
[ViewTypes.GALLERY]: 'gallery', [ViewTypes.GALLERY]: 'gallery',
[ViewTypes.FORM]: 'form', [ViewTypes.FORM]: 'form',
[ViewTypes.KANBAN]: 'kanban', [ViewTypes.KANBAN]: 'kanban',
}[type]), }[props.type]),
) )
const inputEl = ref<any>() watch(vModel, (value) => value && init())
const form = ref<any>()
watch( watch(
() => modelValue, () => props.type,
(v) => { (newType) => (form.type = newType),
if (v) { )
generateUniqueTitle(viewList?.value || [])
function init() {
form.title = generateUniqueTitle(capitalize(ViewTypes[props.type].toLowerCase()), viewList?.value || [], 'title')
nextTick(() => { nextTick(() => {
const el = inputEl?.value?.$el const el = inputEl?.$el as HTMLInputElement
el?.querySelector('input')?.focus()
el?.querySelector('input')?.select() if (el) {
form?.value?.validate() el.focus()
el.select()
}
}) })
} }
},
)
/* name: 'CreateViewDialog', async function onSubmit() {
props: [ const isValid = await formValidator?.validateFields()
'value',
'nodes', if (isValid && form.type) {
'table', const _meta = unref(meta)
'alias',
'show_as', if (!_meta || !_meta.id) return
'viewsCount',
'primaryValueColumn',
'meta',
'copyView',
'viewsList',
'selectedViewId',
],
data: () => ({
valid: false,
view_name: '',
loading: false,
queryParams: {},
}),
computed: {
localState: {
get() {
return this.value;
},
set(v) {
this.$emit('input', v);
},
},
typeAlias() {
return {
[ViewTypes.GRID]: 'grid',
[ViewTypes.GALLERY]: 'gallery',
[ViewTypes.FORM]: 'form',
[ViewTypes.KANBAN]: 'kanban',
}[this.show_as];
},
},
mounted() {
try { try {
if (this.copyView && this.copyView.query_params) { let data: GridType | KanbanType | GalleryType | FormType | null = null
this.queryParams = { ...JSON.parse(this.copyView.query_params) };
switch (form.type) {
case ViewTypes.GRID:
data = await api.dbView.gridCreate(_meta.id, form)
break
case ViewTypes.GALLERY:
data = await api.dbView.galleryCreate(_meta.id, form)
break
case ViewTypes.FORM:
data = await api.dbView.formCreate(_meta.id, form)
break
}
if (data) {
notification.success({
message: 'View created successfully',
})
emits('created', data)
}
} catch (e: any) {
notification.error({
message: e.message,
})
}
vModel.value = false
}
} }
} catch (e) {}
this.view_name = `${this.alias || this.table}${this.viewsCount}`;
this.$nextTick(() => {
const input = this.$refs.name.$el.querySelector('input');
input.setSelectionRange(0, this.view_name.length);
input.focus();
});
}, */
</script> </script>
<template> <template>
<v-dialog v-model="dialogShow" max-width="600" min-width="400"> <a-modal v-model:visible="vModel" class="!top-[35%]" :confirm-loading="loading">
<v-card class="elevation-20"> <template #title>
<v-card-title class="grey darken-2 subheading" style="height: 30px" />
<v-card-text class="pt-4 pl-4">
<p class="headline">
{{ $t('general.create') }} <span class="text-capitalize">{{ typeAlias }}</span> {{ $t('objects.view') }} {{ $t('general.create') }} <span class="text-capitalize">{{ typeAlias }}</span> {{ $t('objects.view') }}
</p>
<v-form ref="form" v-model="valid" @submit.prevent="createView">
<!-- label="View Name" -->
<v-text-field
ref="inputEl"
v-model="view.title"
:label="$t('labels.viewName')"
:rules="[
(v) => !!v || 'View name required',
(v) => (viewList || []).every((v1) => (v1.alias || v1.title) !== v) || 'View name should be unique',
]"
autofocus
/>
</v-form>
</v-card-text>
<v-card-actions class="pa-4">
<v-spacer />
<v-btn class="" small @click="emit('update:modelValue', false)">
{{ $t('general.cancel') }}
</v-btn>
<v-btn small :loading="loading" class="primary" :disabled="!valid" @click="createView(type, activeView.id)">
{{ $t('general.submit') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template> </template>
<style scoped></style> <a-form ref="formValidator" layout="vertical" :model="form">
<a-form-item :label="$t('labels.viewName')" name="title" :rules="formRules">
<a-input ref="inputEl" v-model:value="form.title" autofocus @keydown.enter="onSubmit" />
</a-form-item>
</a-form>
<template #footer>
<a-button key="back" @click="vModel = false">{{ $t('general.cancel') }}</a-button>
<a-button key="submit" type="primary" :loading="loading" @click="onSubmit">{{ $t('general.submit') }}</a-button>
</template>
</a-modal>
</template>

68
packages/nc-gui-v2/components/dlg/ViewDelete.vue

@ -0,0 +1,68 @@
<script lang="ts" setup>
import { notification } from 'ant-design-vue'
import { extractSdkResponseErrorMsg } from '~/utils'
import { onKeyStroke, useApi, useNuxtApp, useVModel } from '#imports'
interface Props {
modelValue: boolean
view?: Record<string, any>
}
interface Emits {
(event: 'update:modelValue', data: boolean): void
(event: 'deleted'): void
}
const props = defineProps<Props>()
const emits = defineEmits<Emits>()
const vModel = useVModel(props, 'modelValue', emits)
const { api, isLoading } = useApi()
const { $e } = useNuxtApp()
onKeyStroke('Escape', () => (vModel.value = false))
onKeyStroke('Enter', () => onDelete())
/** Delete a view */
async function onDelete() {
if (!props.view) return
try {
await api.dbView.delete(props.view.id)
notification.success({
message: 'View deleted successfully',
duration: 3,
})
} catch (e: any) {
notification.error({
message: await extractSdkResponseErrorMsg(e),
duration: 3,
})
}
emits('deleted')
// telemetry event
$e('a:view:delete', { view: props.view.type })
}
</script>
<template>
<a-modal v-model:visible="vModel" class="!top-[35%]" :confirm-loading="isLoading">
<template #title> {{ $t('general.delete') }} {{ $t('objects.view') }} </template>
Are you sure you want to delete this view?
<template #footer>
<a-button key="back" @click="vModel = false">{{ $t('general.cancel') }}</a-button>
<a-button key="submit" danger html-type="submit" :loading="isLoading" @click="onDelete">{{
$t('general.submit')
}}</a-button>
</template>
</a-modal>
</template>

119
packages/nc-gui-v2/components/general/FlippingCard.vue

@ -0,0 +1,119 @@
<script lang="ts" setup>
type FlipTrigger = 'hover' | 'click' | { duration: number }
interface Props {
triggers?: FlipTrigger[]
duration?: number
}
const props = withDefaults(defineProps<Props>(), {
triggers: () => ['click'] as FlipTrigger[],
duration: 800,
})
let flipped = $ref(false)
let hovered = $ref(false)
let flipTimer = $ref<NodeJS.Timer | null>(null)
onMounted(() => {
const duration = props.triggers.reduce((dur, trigger) => {
if (typeof trigger !== 'string') {
dur = trigger.duration
}
return dur
}, 0)
if (duration > 0) {
flipTimer = setInterval(() => {
if (!hovered) {
flipped = !flipped
}
}, duration)
}
})
onBeforeUnmount(() => {
if (flipTimer) {
clearInterval(flipTimer)
}
})
function onHover(isHovering: boolean) {
hovered = isHovering
if (props.triggers.find((trigger) => trigger === 'hover')) {
flipped = isHovering
}
}
function onClick() {
if (props.triggers.find((trigger) => trigger === 'click')) {
flipped = !flipped
}
}
let isFlipping = $ref(false)
watch($$(flipped), () => {
isFlipping = true
setTimeout(() => {
isFlipping = false
}, props.duration / 2)
})
</script>
<template>
<div class="flip-card" @click="onClick" @mouseover="onHover(true)" @mouseleave="onHover(false)">
<div
class="flipper"
:style="{ '--flip-duration': `${props.duration || 800}ms`, 'transform': flipped ? 'rotateY(180deg)' : '' }"
>
<div
class="front"
:style="{ 'pointer-events': flipped ? 'none' : 'auto', 'opacity': !isFlipping ? (flipped ? 0 : 100) : flipped ? 100 : 0 }"
>
<slot name="front" />
</div>
<div
class="back"
:style="{ 'pointer-events': flipped ? 'auto' : 'none', 'opacity': !isFlipping ? (flipped ? 100 : 0) : flipped ? 0 : 100 }"
>
<slot name="back" />
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.flip-card {
background-color: transparent;
perspective: 1000px;
}
.flipper {
--flip-duration: 800ms;
position: relative;
width: 100%;
height: 100%;
text-align: center;
transition: all ease-in-out;
transition-duration: var(--flip-duration);
transform-style: preserve-3d;
}
.front,
.back {
position: absolute;
width: 100%;
height: 100%;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
}
.back {
transform: rotateY(180deg);
}
</style>

2
packages/nc-gui-v2/components/general/Share.vue

@ -3,7 +3,7 @@ interface Props {
url: string url: string
socialMedias: string[] socialMedias: string[]
title?: string title?: string
summary: string summary?: string
hashTags?: string hashTags?: string
css?: string css?: string
iconClass?: string iconClass?: string

5
packages/nc-gui-v2/components/general/Social.vue

@ -15,8 +15,6 @@ const isZhLang = $computed(() => locale.value.startsWith('zh'))
</script> </script>
<template> <template>
<!-- todo: add missing google analytics directive events -->
<v-list>
<general-share <general-share
v-if="isZhLang" v-if="isZhLang"
class="flex justify-center" class="flex justify-center"
@ -41,7 +39,6 @@ const isZhLang = $computed(() => locale.value.startsWith('zh'))
@click="open('https://calendly.com/nocodb-meeting')" @click="open('https://calendly.com/nocodb-meeting')"
/> />
</div> </div>
</v-list>
</template> </template>
<style scoped> <style scoped>
@ -52,7 +49,7 @@ const isZhLang = $computed(() => locale.value.startsWith('zh'))
.discourse { .discourse {
height: 22px; height: 22px;
width: 22px; width: 22px;
background-image: url('~/assets/img/discourse-icon.png'); background-image: url('assets/img/discourse-icon.png');
background-size: contain; background-size: contain;
background-repeat: no-repeat; background-repeat: no-repeat;
} }

2
packages/nc-gui-v2/components/general/Sponsors.test.ts

@ -2,7 +2,7 @@ import { mount } from '@vue/test-utils'
import { expect, test } from 'vitest' import { expect, test } from 'vitest'
import Sponsors from './Sponsors.vue' import Sponsors from './Sponsors.vue'
import { createVuetifyPlugin } from '~/plugins/vuetify' import { createVuetifyPlugin } from '~/plugins/vuetify'
import { createI18nPlugin } from '~/plugins/i18n' import { createI18nPlugin } from '~/plugins/a.i18n'
const mountComponent = async (nav: boolean) => { const mountComponent = async (nav: boolean) => {
const vuetify = createVuetifyPlugin() const vuetify = createVuetifyPlugin()

7
packages/nc-gui-v2/components/general/Sponsors.vue

@ -3,14 +3,15 @@ import MdiHeartsCard from '~icons/mdi/cards-heart'
interface Props { interface Props {
nav?: boolean nav?: boolean
img?: boolean
} }
const { nav = false } = defineProps<Props>() const { nav = false, img = true } = defineProps<Props>()
</script> </script>
<template> <template>
<v-card :rounded="0" class="dark:bg-gray-900" href="https://github.com/sponsors/nocodb" target="_blank"> <v-card :rounded="0" class="dark:bg-gray-900" href="https://github.com/sponsors/nocodb" target="_blank">
<v-img src="/ants-leaf-cutter.jpeg" :cover="true" :aspect-ratio="1" :height="nav ? 80 : ''" /> <v-img v-if="img" src="/ants-leaf-cutter.jpeg" :cover="true" :aspect-ratio="1" :height="nav ? 80 : ''" />
<v-card-title v-if="!nav" class="pb-2"> <v-card-title v-if="!nav" class="pb-2">
{{ $t('msg.info.sponsor.header') }} {{ $t('msg.info.sponsor.header') }}
@ -21,7 +22,7 @@ const { nav = false } = defineProps<Props>()
</v-card-text> </v-card-text>
<v-card-actions class="justify-center"> <v-card-actions class="justify-center">
<v-btn class="dark:(!text-white) text-primary"> <v-btn color="primary" class="dark:(!text-white)">
<MdiHeartsCard class="text-red-500 mr-2" /> <MdiHeartsCard class="text-red-500 mr-2" />
{{ $t('activity.sponsorUs') }} {{ $t('activity.sponsorUs') }}
</v-btn> </v-btn>

4
packages/nc-gui-v2/components/monaco/Editor.vue

@ -3,7 +3,7 @@ import * as monaco from 'monaco-editor'
import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker' import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker'
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker' import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
import { onMounted } from '#imports' import { onMounted } from '#imports'
import { deepCompare } from '~/utils/deepCompare' import { deepCompare } from '~/utils'
const { modelValue } = defineProps<{ modelValue: any }>() const { modelValue } = defineProps<{ modelValue: any }>()
@ -83,5 +83,3 @@ watch(
<template> <template>
<div ref="root"></div> <div ref="root"></div>
</template> </template>
<style scoped></style>

94
packages/nc-gui-v2/components/smartsheet-column/AdvancedOptions.vue

@ -0,0 +1,94 @@
<script setup lang="ts">
import { useColumnCreateStoreOrThrow } from '#imports'
const { formState, validateInfos, setAdditionalValidations, sqlUi, onDataTypeChange, onAlter } = useColumnCreateStoreOrThrow()
const dataTypes = computed(() => sqlUi?.value?.getDataTypeListForUiType(formState))
// set additional validations
setAdditionalValidations({})
// to avoid type error with checkbox
formState.value.rqd = !!formState.value.rqd
formState.value.pk = !!formState.value.pk
formState.value.un = !!formState.value.un
formState.value.ai = !!formState.value.ai
formState.value.au = !!formState.value.au
</script>
<template>
<div class="p-4 border-[2px] radius-1 border-grey w-full">
<div class="flex justify-space-between">
<a-form-item label="NN">
<a-checkbox
v-model:checked="formState.rqd"
:disabled="formState.pk || !sqlUi.columnEditable(formState)"
size="small"
class="nc-column-name-input"
@change="onAlter"
/>
</a-form-item>
<a-form-item label="PK">
<a-checkbox
v-model:checked="formState.pk"
:disabled="!sqlUi.columnEditable(formState)"
size="small"
class="nc-column-name-input"
@change="onAlter"
/>
</a-form-item>
<a-form-item label="AI">
<a-checkbox
v-model:checked="formState.ai"
:disabled="sqlUi.colPropUNDisabled(formState) || !sqlUi.columnEditable(formState)"
size="small"
class="nc-column-name-input"
@change="onAlter"
/>
</a-form-item>
<a-form-item
label="UN"
:disabled="sqlUi.colPropUNDisabled(formState) || !sqlUi.columnEditable(formState)"
@change="onAlter"
>
<a-checkbox v-model:checked="formState.un" size="small" class="nc-column-name-input" />
</a-form-item>
<a-form-item
label="AU"
:disabled="sqlUi.colPropAuDisabled(formState) || !sqlUi.columnEditable(formState)"
@change="onAlter"
>
<a-checkbox v-model:checked="formState.au" size="small" class="nc-column-name-input" />
</a-form-item>
</div>
<a-form-item :label="$t('labels.databaseType')" v-bind="validateInfos.dt">
<a-select v-model:value="formState.dt" size="small" @change="onDataTypeChange">
<a-select-option v-for="type in dataTypes" :key="type" :value="type">
{{ type }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item :label="$t('labels.lengthValue')">
<a-input
v-model:value="formState.dtxp"
:disabled="sqlUi.getDefaultLengthIsDisabled(formState.dt) || !sqlUi.columnEditable(formState)"
size="small"
@input="onAlter"
/>
</a-form-item>
<a-form-item v-if="sqlUi.showScale(formState)" label="Scale">
<a-input v-model="formState.dtxs" :disabled="!sqlUi.columnEditable(formState)" size="small" @input="onAlter" />
</a-form-item>
<a-form-item :label="$t('placeholder.defaultValue')">
<a-textarea
v-model="formState.cdf"
:help="sqlUi.getDefaultValueForDatatype(formState.dt)"
size="small"
auto-size
@input="onAlter(2, true)"
/>
</a-form-item>
</div>
</template>
<style scoped></style>

143
packages/nc-gui-v2/components/smartsheet-column/EditOrAdd.vue

@ -0,0 +1,143 @@
<script lang="ts" setup>
import { computed, inject, useColumnCreateStoreOrThrow, useMetas, watchEffect } from '#imports'
import { MetaInj } from '~/context'
import { uiTypes } from '~/utils/columnUtils'
import MdiPlusIcon from '~icons/mdi/plus-circle-outline'
import MdiMinusIcon from '~icons/mdi/minus-circle-outline'
const emit = defineEmits(['cancel'])
const meta = inject(MetaInj)
const advancedOptions = ref(false)
const { getMeta } = useMetas()
const {
formState,
resetFields,
validate,
validateInfos,
onUidtOrIdTypeChange,
onAlter,
addOrUpdate,
generateNewColumnMeta,
isEdit,
} = useColumnCreateStoreOrThrow()
const uiTypesOptions = computed<typeof uiTypes>(() => {
return [
...uiTypes.filter((t) => !isEdit || !t.virtual),
...(!isEdit && meta?.value?.columns?.every((c) => !c.pk)
? [
{
name: 'ID',
icon: 'mdi-identifier',
},
]
: []),
]
})
const reloadMeta = () => {
emit('cancel')
getMeta(meta?.value.id as string, true)
}
// create column meta if it's a new column
watchEffect(() => {
if (!isEdit) {
generateNewColumnMeta()
}
})
// focus and select the column name field
const antInput = ref()
watchEffect(() => {
if (antInput.value && formState.value) {
// todo: replace setTimeout
setTimeout(() => {
antInput.value.focus()
antInput.value.select()
}, 300)
}
})
</script>
<template>
<div class="max-w-[450px] min-w-[350px] w-max max-h-[95vh] bg-white shadow p-4 overflow-auto" @click.stop>
<a-form v-model="formState" name="column-create-or-edit" layout="vertical">
<a-form-item :label="$t('labels.columnName')" v-bind="validateInfos.column_name">
<a-input
ref="antInput"
v-model:value="formState.column_name"
size="small"
class="nc-column-name-input"
@input="onAlter(8)"
/>
</a-form-item>
<a-form-item :label="$t('labels.columnType')">
<a-select v-model:value="formState.uidt" size="small" class="nc-column-name-input" @change="onUidtOrIdTypeChange">
<a-select-option v-for="opt in uiTypesOptions" :key="opt.name" :value="opt.name" v-bind="validateInfos.uidt">
<div class="flex gap-1 align-center text-xs">
<component :is="opt.icon" class="text-grey" />
{{ opt.name }}
</div>
</a-select-option>
</a-select>
</a-form-item>
<div>
<div
class="text-xs cursor-pointer text-grey nc-more-options my-2 flex align-center gap-1 justify-end"
@click="advancedOptions = !advancedOptions"
>
{{ advancedOptions ? $t('general.hideAll') : $t('general.showMore') }}
<component :is="advancedOptions ? MdiMinusIcon : MdiPlusIcon" />
</div>
</div>
<div class="overflow-hidden" :class="advancedOptions ? 'h-min' : 'h-0'">
<SmartsheetColumnAdvancedOptions />
</div>
<a-form-item>
<div class="flex justify-end gap-1 mt-4">
<a-button html-type="button" size="small" @click="emit('cancel')">
<!-- Cancel -->
{{ $t('general.cancel') }}
</a-button>
<a-button html-type="submit" type="primary" size="small" @click="addOrUpdate(reloadMeta)">
<!-- Save -->
{{ $t('general.save') }}
</a-button>
</div>
</a-form-item>
</a-form>
</div>
</template>
<style scoped>
:deep(.ant-form-item-label > label) {
@apply !text-xs;
}
:deep(.ant-form-item-label) {
@apply !pb-0;
}
:deep(.ant-form-item-control-input) {
@apply !min-h-min;
}
:deep(.ant-form-item) {
@apply !mb-1;
}
:deep(.ant-select-selection-item) {
@apply flex align-center;
}
:deep(.ant-form-item-explain-error) {
@apply !text-[10px];
}
:deep(.ant-form-item-explain) {
@apply !min-h-[15px];
}
</style>

101
packages/nc-gui-v2/components/smartsheet-header/Cell.vue

@ -1,9 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ColumnType } from 'nocodb-sdk' import type { ColumnType, TableType } from 'nocodb-sdk'
import { ColumnInj } from '../../context' import type { Ref } from 'vue'
import { inject } from 'vue'
import { ColumnInj, MetaInj } from '~/context'
import { useProvideColumnCreateStore } from '#imports'
const { column } = defineProps<{ column: ColumnType & { meta: any } }>() const { column } = defineProps<{ column: ColumnType & { meta: any } }>()
provide(ColumnInj, column) provide(ColumnInj, column)
const meta = inject(MetaInj)
// instantiate column update store
useProvideColumnCreateStore(meta as Ref<TableType>, column)
/* /*
import { UITypes } from 'nocodb-sdk' import { UITypes } from 'nocodb-sdk'
@ -70,96 +77,12 @@ export default {
</script> </script>
<template> <template>
<div class="d-flex align-center d-100"> <div class="flex align-center w-full">
<SmartsheetHeaderCellIcon v-if="column" /> <SmartsheetHeaderCellIcon v-if="column" />
<span v-if="column" class="name" style="white-space: nowrap" :title="column.title">{{ column.title }}</span> <span v-if="column" class="name" style="white-space: nowrap" :title="column.title">{{ column.title }}</span>
<!-- <span v-if="(column.rqd && !column.cdf) || required" class="error&#45;&#45;text text&#45;&#45;lighten-1">&nbsp;*</span> --> <div class="flex-1" />
<SmartsheetHeaderMenu />
<v-spacer />
<!-- todo: implement delete or edit column
<v-menu
v-if="!isLocked && !isPublicView && _isUIAllowed('edit-column') && !isForm"
open-on-hover
left
z-index="999"
transition="slide-y-transition"
>
<template #activator="{ on }">
<v-icon v-if="!isLocked && !isVirtual" small v-on="on"> mdi-menu-down</v-icon>
</template>
<v-list dense>
<v-list-item class="nc-column-edit" dense @click="showColumnEdit">
<x-icon small class="mr-1" color="primary"> mdi-pencil</x-icon>
<span class="caption">
&lt;!&ndash; Edit &ndash;&gt;
{{ $t('general.edit') }}
</span>
</v-list-item>
<v-list-item v-t="['a:column:set-primary']" dense @click="setAsPrimaryValue">
<x-icon small class="mr-1" color="primary"> mdi-key-star</x-icon>
<v-tooltip bottom>
<template #activator="{ on }">
<span class="caption" v-on="on">
&lt;!&ndash; Set as Primary value &ndash;&gt;
{{ $t('activity.setPrimary') }}
</span>
</template>
<span class="caption font-weight-bold">Primary value will be shown in place of primary key</span>
</v-tooltip>
</v-list-item>
<v-list-item class="nc-column-delete" @click="columnDeleteDialog = true">
<x-icon small class="mr-1" color="error"> mdi-delete-outline</x-icon>
<span class="caption">
&lt;!&ndash; Delete &ndash;&gt;
{{ $t('general.delete') }}
</span>
</v-list-item>
</v-list>
</v-menu>
<v-menu v-model="editColumnMenu" z-index="999" offset-y content-class="" left transition="slide-y-transition">
<template #activator="{ on }">
<span v-on="on" />
</template>
<EditColumn
v-if="editColumnMenu"
:meta="meta"
:sql-ui="sqlUi"
:nodes="nodes"
:edit-column="true"
:column="column"
:column-index="columnIndex"
@onRelationDelete="$emit('onRelationDelete')"
@saved="(_cn, _cno) => $emit('saved', _cn, _cno)"
@close="editColumnMenu = false"
/>
</v-menu>
<v-dialog
v-model="columnDeleteDialog"
max-width="500"
persistent
@keydown.esc="columnDeleteDialog = false"
@keydown.enter="deleteColumn"
>
<v-card class="nc-delete-dialog-card">
<v-card-title class="grey darken-2 subheading white&#45;&#45;text"> Confirm</v-card-title>
<v-divider />
<v-card-text class="mt-4 title">
Do you want to delete <span class="font-weight-bold">'{{ column.title }}'</span> column ?
</v-card-text>
<v-divider />
<v-card-actions class="d-flex pa-4">
<v-spacer />
<v-btn small @click="columnDeleteDialog = false">
&lt;!&ndash; Cancel &ndash;&gt;
{{ $t('general.cancel') }}
</v-btn>
<v-btn v-t="['a:column:delete']" small color="error" @click="deleteColumn"> Confirm</v-btn>
</v-card-actions>
</v-card>
</v-dialog> -->
</div> </div>
</template> </template>

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

@ -1,12 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ColumnType } from 'nocodb-sdk' import type { ColumnType } from 'nocodb-sdk'
import { ColumnInj } from '~/context' import { ColumnInj } from '~/context'
import useColumn from '~/composables/useColumn' import { useColumn } from '#imports'
import KeyIcon from '~icons/mdi/key-variant' 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'
@ -18,8 +18,11 @@ import AttachmentIcon from '~icons/mdi/image-multiple-outline'
import URLIcon from '~icons/mdi/link' 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'
import PercentIcon from '~icons/mdi/percent-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)
@ -54,10 +57,10 @@ const icon = computed(() => {
return URLIcon return URLIcon
} else if (additionalColMeta.isCurrency) { } else if (additionalColMeta.isCurrency) {
return CurrencyIcon return CurrencyIcon
} else if (additionalColMeta.isPercent) {
return PercentIcon
} 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 +68,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>

42
packages/nc-gui-v2/components/smartsheet-header/Menu.vue

@ -0,0 +1,42 @@
<script lang="ts" setup>
import MdiEditIcon from '~icons/mdi/pencil'
import MdiStarIcon from '~icons/mdi/star'
import MdiDeleteIcon from '~icons/mdi/delete-outline'
import MdiMenuDownIcon from '~icons/mdi/menu-down'
const editColumnDropdown = $ref(false)
</script>
<template>
<a-dropdown v-model:visible="editColumnDropdown" :trigger="['click']">
<span />
<template #overlay>
<SmartsheetColumnEditOrAdd @click.stop @cancel="editColumnDropdown = false" />
</template>
</a-dropdown>
<a-dropdown :trigger="['hover']">
<MdiMenuDownIcon class="text-grey" />
<template #overlay>
<div class="shadow bg-white">
<div class="nc-column-edit nc-menu-item" @click="editColumnDropdown = true">
<MdiEditIcon class="text-primary" />
<!-- Edit -->
{{ $t('general.edit') }}
</div>
<div v-t="['a:column:set-primary']" class="nc-menu-item">
<MdiStarIcon class="text-primary" />
<!-- todo : tooltip -->
<!-- Set as Primary value -->
{{ $t('activity.setPrimary') }}
<!-- <span class="caption font-weight-bold">Primary value will be shown in place of primary key</span> -->
</div>
<div class="nc-column-delete nc-menu-item">
<MdiDeleteIcon class="text-error" />
<!-- Delete -->
{{ $t('general.delete') }}
</div>
</div>
</template>
</a-dropdown>
</template>

2
packages/nc-gui-v2/components/smartsheet-header/VirtualCell.vue

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ColumnType } from 'nocodb-sdk' import type { ColumnType } from 'nocodb-sdk'
import { ColumnInj } from '../../context' import { ColumnInj } from '~/context'
import { provide } from '#imports' import { provide } from '#imports'
const { column } = defineProps<{ column: ColumnType & { meta: any } }>() const { column } = defineProps<{ column: ColumnType & { meta: any } }>()

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>

13
packages/nc-gui-v2/components/smartsheet-toolbar/AddRow.vue

@ -1,10 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import MdiAddIcon from '~icons/mdi/plus-outline' import MdiAddIcon from '~icons/mdi/plus-outline'
const emit = defineEmits(['add-row'])
const emits = defineEmits(['addRow'])
</script> </script>
<template> <template>
<MdiAddIcon class="text-grey" @click="emit('add-row')" /> <a-tooltip placement="left">
</template> <template #title> {{ $t('activity.addRow') }} </template>
<style scoped></style> <div class="nc-sidebar-right-item hover:after:bg-primary/75 group">
<MdiAddIcon class="group-hover:(!text-white)" @click="emits('addRow')" />
</div>
</a-tooltip>
</template>

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

@ -1,26 +1,38 @@
<script setup lang="ts"> <script setup lang="ts">
import type { FilterType } from 'nocodb-sdk'
import { UITypes } from 'nocodb-sdk' import { UITypes } from 'nocodb-sdk'
import FieldListAutoCompleteDropdown from './FieldListAutoCompleteDropdown.vue' import FieldListAutoCompleteDropdown from './FieldListAutoCompleteDropdown.vue'
import { useNuxtApp } from '#app' import { useNuxtApp } from '#app'
import { inject } 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 useViewFilters from '~/composables/useViewFilters'
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, i) => { 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,
@ -63,70 +75,87 @@ const types = computed(() => {
}) })
watch( watch(
() => activeView?.value?.id, () => (activeView?.value as any)?.id,
(n, o) => { (n, o) => {
if (n !== o) loadFilters() if (n !== o) loadFilters()
}, },
{ 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"
@ -150,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
@ -173,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"
@ -195,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)"
@ -203,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)"
@ -214,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 />
@ -222,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>

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

@ -1,9 +1,19 @@
<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>
<MdiDeleteIcon class="text-grey" /> <a-tooltip placement="left">
</template> <template #title> {{ $t('activity.deleteTable') }} </template>
<style scoped></style> <div class="nc-sidebar-right-item hover:after:bg-red-500 group">
<MdiDeleteIcon class="group-hover:(!text-white)" @click="deleteTable(meta)" />
</div>
</a-tooltip>
</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

@ -2,7 +2,7 @@
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'
import useViewColumns from '~/composables/useViewColumns' import { useViewColumns } from '#imports'
import MdiMenuDownIcon from '~icons/mdi/menu-down' import MdiMenuDownIcon from '~icons/mdi/menu-down'
import MdiEyeIcon from '~icons/mdi/eye-off-outline' import MdiEyeIcon from '~icons/mdi/eye-off-outline'
import MdiDragIcon from '~icons/mdi/drag' import MdiDragIcon from '~icons/mdi/drag'
@ -16,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()
@ -37,11 +31,11 @@ const {
showAll, showAll,
hideAll, hideAll,
saveOrUpdate, saveOrUpdate,
sortedFields, // sortedFields,
} = useViewColumns(activeView, meta, false, () => reloadDataHook?.trigger()) } = useViewColumns(activeView, meta, false, () => reloadDataHook?.trigger())
watch( watch(
() => activeView?.value?.id, () => (activeView?.value as any)?.id,
async (newVal, oldVal) => { async (newVal, oldVal) => {
if (newVal !== oldVal && meta?.value) { if (newVal !== oldVal && meta?.value) {
await loadViewColumns() await loadViewColumns()
@ -57,43 +51,52 @@ watch(
{ immediate: true }, { immediate: true },
) )
const onMove = (event) => { 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>
@ -111,11 +114,11 @@ const onMove = (event) => {
</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>
@ -126,7 +129,10 @@ const onMove = (event) => {
</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>

63
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:
@ -51,30 +49,28 @@ const Icon = computed(() => {
}) })
</script> </script>
<script lang="ts">
export default {
name: 'LockMenu',
}
</script>
<template> <template>
<a-dropdown max-width="350" :trigger="['click']"> <a-dropdown max-width="350" :trigger="['click']">
<Icon class="mx-1 nc-view-lock-menu text-grey"> mdi-lock-outline </Icon> <div class="nc-sidebar-right-item hover:after:bg-indigo-500 group">
<Icon class="cursor-pointer group-hover:(!text-white)" />
</div>
<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 />
@ -82,8 +78,10 @@ export default {
<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 />
@ -95,13 +93,18 @@ export default {
</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 {

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

@ -3,9 +3,9 @@ import { ExportTypes } from 'nocodb-sdk'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import FileSaver from 'file-saver' import FileSaver from 'file-saver'
import { useNuxtApp } from '#app' import { useNuxtApp } from '#app'
import useProject from '~/composables/useProject' import { useProject } from '#imports'
import { ActiveViewInj, MetaInj } from '~/context' import { ActiveViewInj, MetaInj } from '~/context'
import { extractSdkResponseErrorMsg } from '~/utils/errorUtils' import { extractSdkResponseErrorMsg } from '~/utils'
import MdiFlashIcon from '~icons/mdi/flash-outline' import MdiFlashIcon from '~icons/mdi/flash-outline'
import MdiMenuDownIcon from '~icons/mdi/menu-down' import MdiMenuDownIcon from '~icons/mdi/menu-down'
import MdiDownloadIcon from '~icons/mdi/download-outline' import MdiDownloadIcon from '~icons/mdi/download-outline'
@ -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()
@ -80,13 +82,14 @@ const exportCsv = async () => {
toast.success('Successfully exported all table data') toast.success('Successfully exported all table data')
} }
} }
} catch (e) { } catch (e: any) {
toast.error(extractSdkResponseErrorMsg(e)) toast.error(extractSdkResponseErrorMsg(e))
} }
} }
</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>

9
packages/nc-gui-v2/components/smartsheet-toolbar/Reload.vue

@ -1,11 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { ReloadViewDataHookInj } from '~/context' import { ReloadViewDataHookInj } from '~/context'
import MdiReloadIcon from '~icons/mdi/reload' import MdiReloadIcon from '~icons/mdi/reload'
const reloadTri = inject(ReloadViewDataHookInj) const reloadTri = inject(ReloadViewDataHookInj)
</script> </script>
<template> <template>
<MdiReloadIcon class="text-grey" @click="reloadTri.trigger()" /> <a-tooltip placement="left">
<template #title> {{ $t('general.reload') }} </template>
<div class="nc-sidebar-right-item hover:after:bg-green-500 group">
<MdiReloadIcon class="group-hover:(!text-white)" @click="reloadTri.trigger()" />
</div>
</a-tooltip>
</template> </template>
<style scoped></style> <style scoped></style>

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>

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

@ -1,18 +1,167 @@
<script lang="ts" setup> <script lang="ts" setup>
import MdiOpenInNew from '~icons/mdi/open-in-new' import { useClipboard } from '@vueuse/core'
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> <div>
<a-button v-t="['c:view:share']" outlined class="nc-btn-share-view nc-toolbar-btn" size="small"> <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"> <div class="flex align-center gap-1" @click="genShareLink">
<MdiOpenInNew class="text-grey" /> <MdiOpenInNewIcon class="text-grey" />
<!-- Share View --> <!-- Share View -->
{{ $t('activity.shareView') }} {{ $t('activity.shareView') }}
</div> </div>
</a-button> </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> </div>
</template> </template>
<style scoped /> <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>

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

@ -1,8 +1,9 @@
<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 { computed, inject } from '#imports' import { getSortDirectionOptions } from '~/utils/sortUtils'
import { computed, inject, useViewSorts } from '#imports'
import { ActiveViewInj, IsLockedInj, MetaInj, ReloadViewDataHookInj } from '~/context' import { ActiveViewInj, IsLockedInj, MetaInj, ReloadViewDataHookInj } from '~/context'
import useViewSorts from '~/composables/useViewSorts'
import MdiMenuDownIcon from '~icons/mdi/menu-down' import MdiMenuDownIcon from '~icons/mdi/menu-down'
import MdiSortIcon from '~icons/mdi/sort' import MdiSortIcon from '~icons/mdi/sort'
import MdiDeleteIcon from '~icons/mdi/close-box' import MdiDeleteIcon from '~icons/mdi/close-box'
@ -16,9 +17,15 @@ 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?.id, () => (view?.value as any)?.id,
() => { () => {
loadSorts() loadSorts()
}, },
@ -28,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
@ -57,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 -->
@ -94,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>

21
packages/nc-gui-v2/components/smartsheet-toolbar/ToggleDrawer.vue

@ -1,14 +1,19 @@
<script setup lang="ts"> <script setup lang="ts">
import { ReloadViewDataHookInj } from '~/context' import MdiUnfoldMoreVertical from '~icons/mdi/unfold-more-vertical'
import MdiDoorOpenIcon from '~icons/mdi/door-open' import MdiUnfoldLessVertical from '~icons/mdi/unfold-less-vertical'
import MdiDoorClosedIcon from '~icons/mdi/door-closed' import { inject, ref } from '#imports'
const navDrawerOpened = ref(false) import { RightSidebarInj } from '~/context'
const Icon = computed(() => (navDrawerOpened.value ? MdiDoorOpenIcon : MdiDoorClosedIcon)) const sidebarOpen = inject(RightSidebarInj, ref(false))
</script> </script>
<template> <template>
<Icon class="text-grey" @click="navDrawerOpened = !navDrawerOpened" /> <a-tooltip placement="left">
</template> <template #title> {{ $t('tooltip.toggleNavDraw') }} </template>
<style scoped></style> <div class="nc-sidebar-right-item hover:after:bg-pink-500 group">
<MdiUnfoldLessVertical v-if="sidebarOpen" class="group-hover:(!text-white)" @click="sidebarOpen = false" />
<MdiUnfoldMoreVertical v-else class="group-hover:(!text-white)" @click="sidebarOpen = true" />
</div>
</a-tooltip>
</template>

9
packages/nc-gui-v2/components/smartsheet/Cell.vue

@ -1,9 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ColumnType } from 'nocodb-sdk' import type { ColumnType } from 'nocodb-sdk'
import { provide } from 'vue' import { provide } from 'vue'
import { computed } from '#imports' import { computed, useColumn } from '#imports'
import { ColumnInj } from '~/context' import { ColumnInj } from '~/context'
import useColumn from '~/composables/useColumn'
interface Props { interface Props {
column: ColumnType column: ColumnType
@ -32,6 +31,7 @@ const {
isEmail, isEmail,
isJSON, isJSON,
isDate, isDate,
isYear,
isDateTime, isDateTime,
isTime, isTime,
isBoolean, isBoolean,
@ -43,6 +43,7 @@ const {
isString, isString,
isSingleSelect, isSingleSelect,
isMultiSelect, isMultiSelect,
isPercent,
} = useColumn(column) } = useColumn(column)
</script> </script>
@ -175,8 +176,9 @@ todo :
<CellSingleSelect v-else-if="isSingleSelect" v-model="localState" /> <CellSingleSelect v-else-if="isSingleSelect" v-model="localState" />
<CellMultiSelect v-else-if="isMultiSelect" v-model="localState" /> <CellMultiSelect v-else-if="isMultiSelect" v-model="localState" />
<CellDatePicker v-else-if="isDate" v-model="localState" /> <CellDatePicker v-else-if="isDate" v-model="localState" />
<CellYearPicker v-else-if="isYear" v-model="localState" />
<CellDateTimePicker v-else-if="isDateTime" v-model="localState" /> <CellDateTimePicker v-else-if="isDateTime" v-model="localState" />
<CellDateTimePicker v-else-if="isTime" v-model="localState" /> <CellTimePicker v-else-if="isTime" v-model="localState" />
<CellRating v-else-if="isRating" v-model="localState" /> <CellRating v-else-if="isRating" v-model="localState" />
<!-- v-model="localState" <!-- v-model="localState"
:active="active" :active="active"
@ -201,6 +203,7 @@ todo :
<!-- v-on="parentListeners" <!-- v-on="parentListeners"
/> />
--> -->
<CellPercent v-else-if="isPercent" v-model="localState" />
<CellText v-else v-model="localState" /> <CellText v-else v-model="localState" />
<!-- v-on="$listeners" <span v-if="hint" class="nc-hint">{{ hint }}</span> --> <!-- v-on="$listeners" <span v-if="hint" class="nc-hint">{{ hint }}</span> -->

5
packages/nc-gui-v2/components/smartsheet/Gallery.vue

@ -1,8 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { isVirtualCol } from 'nocodb-sdk' import { isVirtualCol } from 'nocodb-sdk'
import { inject, onKeyStroke, onMounted, provide } from '#imports' import { inject, provide, useViewData } from '#imports'
import { ActiveViewInj, ChangePageInj, IsFormInj, IsGridInj, MetaInj, PaginationDataInj, ReadonlyInj } from '~/context' import { ActiveViewInj, ChangePageInj, IsFormInj, IsGridInj, MetaInj, PaginationDataInj, ReadonlyInj } from '~/context'
import useViewData from '~/composables/useViewData'
const meta = inject(MetaInj) const meta = inject(MetaInj)
const view = inject(ActiveViewInj) const view = inject(ActiveViewInj)
@ -13,7 +12,7 @@ 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 { loadData, paginationData, formattedData: data, updateRowProperty, changePage } = useViewData(meta, view) const { loadData, paginationData, formattedData: data, updateRowProperty, changePage } = useViewData(meta, view as any)
provide(IsFormInj, false) provide(IsFormInj, false)
provide(IsGridInj, false) provide(IsGridInj, false)

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

@ -1,7 +1,14 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from '@vue/reactivity' import { isVirtualCol } from 'nocodb-sdk'
import { ColumnType, isVirtualCol } from 'nocodb-sdk' import {
import { inject, onKeyStroke, onMounted, provide } from '#imports' inject,
onKeyStroke,
onMounted,
provide,
useGridViewColumnWidth,
useProvideColumnCreateStore,
useViewData,
} from '#imports'
import { import {
ActiveViewInj, ActiveViewInj,
ChangePageInj, ChangePageInj,
@ -12,7 +19,7 @@ import {
PaginationDataInj, PaginationDataInj,
ReloadViewDataHookInj, ReloadViewDataHookInj,
} from '~/context' } from '~/context'
import useViewData from '~/composables/useViewData' import MdiPlusIcon from '~icons/mdi/plus'
const meta = inject(MetaInj) const meta = inject(MetaInj)
const view = inject(ActiveViewInj) const view = inject(ActiveViewInj)
@ -25,8 +32,13 @@ 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 { loadData, paginationData, formattedData: data, updateRowProperty, changePage } = useViewData(meta, view) const { loadData, paginationData, formattedData: data, updateRowProperty, changePage } = useViewData(meta, view as any, xWhere)
const { loadGridViewColumns, updateWidth, resizingColWidth, resizingCol } = useGridViewColumnWidth(view)
onMounted(loadGridViewColumns)
provide(IsFormInj, false) provide(IsFormInj, false)
provide(IsGridInj, true) provide(IsGridInj, true)
@ -50,7 +62,7 @@ onKeyStroke(['Enter'], (e) => {
}) })
watch( watch(
() => view?.value?.id, () => (view?.value as any)?.id,
async (n?: string, o?: string) => { async (n?: string, o?: string) => {
if (n && n !== o) { if (n && n !== o) {
await loadData() await loadData()
@ -59,9 +71,22 @@ watch(
{ immediate: true }, { immediate: true },
) )
const onresize = (colID: string, event: any) => {
updateWidth(colID, event.detail)
}
const onXcResizing = (cn: string, event: any) => {
resizingCol.value = cn
resizingColWidth.value = event.detail
}
defineExpose({ defineExpose({
loadData, loadData,
}) })
// instantiate column create store
// watchEffect(() => {
if (meta) useProvideColumnCreateStore(meta)
// })
</script> </script>
<template> <template>
@ -71,10 +96,29 @@ defineExpose({
<thead> <thead>
<tr> <tr>
<th>#</th> <th>#</th>
<th v-for="col in fields" :key="col.title"> <th
v-for="col in fields"
:key="col.title"
v-xc-ver-resize
:data-col="col.id"
@xcresize="onresize(col.id, $event)"
@xcresizing="onXcResizing(col.title, $event)"
@xcresized="resizingCol = null"
>
<SmartsheetHeaderVirtualCell v-if="isVirtualCol(col)" :column="col" /> <SmartsheetHeaderVirtualCell v-if="isVirtualCol(col)" :column="col" />
<SmartsheetHeaderCell v-else :column="col" /> <SmartsheetHeaderCell v-else :column="col" />
</th> </th>
<!-- v-if="!isLocked && !isVirtual && !isPublicView && _isUIAllowed('add-column')" -->
<th v-t="['c:column:add']" @click="addColumnDropdown = true">
<a-dropdown v-model:visible="addColumnDropdown" :trigger="['click']">
<div class="h-full w-full flex align-center justify-center">
<MdiPlusIcon class="text-sm" />
</div>
<template #overlay>
<SmartsheetColumnEditOrAdd @click.stop @cancel="addColumnDropdown = false" />
</template>
</a-dropdown>
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -94,7 +138,7 @@ defineExpose({
// 'text-center': isCentrallyAligned(columnObj), // 'text-center': isCentrallyAligned(columnObj),
// 'required': isRequired(columnObj, rowObj), // 'required': isRequired(columnObj, rowObj),
}" }"
:data-col="columnObj.title" :data-col="columnObj.id"
@click="selectCell(rowIndex, colIndex)" @click="selectCell(rowIndex, colIndex)"
@dblclick="editEnabled = true" @dblclick="editEnabled = true"
> >
@ -201,10 +245,15 @@ defineExpose({
td, td,
th { th {
min-height: 31px !important; min-height: 41px !important;
height: 41px !important;
position: relative; position: relative;
padding: 0 5px !important; padding: 0 5px;
min-width: 200px;
& > * {
@apply flex align-center h-auto;
}
overflow: hidden;
} }
table, table,
@ -246,4 +295,14 @@ defineExpose({
opacity: 0.1; opacity: 0.1;
} }
} }
:deep {
.resizer:hover,
.resizer:active,
.resizer:focus {
// todo: replace with primary color
@apply bg-blue-500/50;
cursor: col-resize;
}
}
</style> </style>

19
packages/nc-gui-v2/components/smartsheet/Pagination.vue

@ -45,18 +45,10 @@ export default {
</script> </script>
<template> <template>
<div class="d-flex align-center"> <div class="flex items-center mb-2">
<span v-if="count !== null && count !== Infinity" class="caption ml-2"> {{ count }} record{{ count !== 1 ? 's' : '' }} </span> <span v-if="count !== null && count !== Infinity" class="caption ml-2"> {{ count }} record{{ count !== 1 ? 's' : '' }} </span>
<v-spacer />
<!-- <v-pagination <div class="flex-1" />
v-if="count !== Infinity"
v-model="page"
style="max-width: 100%"
:length="Math.ceil(count / size)"
:total-visible="8"
color="primary lighten-2"
class="nc-pagination"
/> -->
<a-pagination <a-pagination
v-if="count !== Infinity" v-if="count !== Infinity"
@ -81,13 +73,12 @@ export default {
@keydown.enter="changePage(page)" @keydown.enter="changePage(page)"
> >
<template #append> <template #append>
<MdiKeyboardIcon small icon.class="mt-1" @click="changePage(page)" /> <MdiKeyboardIcon class="mt-1" @click="changePage(page)" />
</template> </template>
</v-text-field> </v-text-field>
</div> </div>
<v-spacer /> <div class="flex-1" />
<v-spacer />
</div> </div>
</template> </template>

281
packages/nc-gui-v2/components/smartsheet/Sidebar.vue

@ -1,281 +0,0 @@
<script setup lang="ts">
import { ViewTypes } from 'nocodb-sdk'
import type { TableType } from 'nocodb-sdk'
import type { Ref } from 'vue'
import { inject, ref } from '#imports'
import { ActiveViewInj, MetaInj, ViewListInj } from '~/context'
import useViews from '~/composables/useViews'
import { viewIcons } from '~/utils/viewUtils'
import MdiPlusIcon from '~icons/mdi/plus'
const meta = inject(MetaInj)
const activeView = inject(ActiveViewInj)
const { views, loadViews } = useViews(meta as Ref<TableType>)
provide(ViewListInj, views)
const _isUIAllowed = (view: string) => {}
// todo decide based on route param
loadViews().then(() => {
if (activeView) activeView.value = views.value?.[0]
})
const toggleDrawer = ref(false)
// todo: identify based on meta
const isView = ref(false)
const viewCreateType = ref<ViewTypes>()
const viewCreateDlg = ref<boolean>(false)
const openCreateViewDlg = (type: ViewTypes) => {
viewCreateDlg.value = true
viewCreateType.value = type
}
const onViewCreate = (view) => {
views.value?.push(view)
activeView.value = view
viewCreateDlg.value = false
}
</script>
<template>
<div
class="views-navigation-drawer flex-item-stretch pa-4 elevation-1"
:style="{
maxWidth: toggleDrawer ? '0' : '220px',
minWidth: toggleDrawer ? '0' : '220px',
}"
>
<div class="d-flex flex-column h-100">
<div class="flex-grow-1">
<v-list v-if="views && views.length" dense>
<v-list-item dense>
<!-- Views -->
<span class="body-2 font-weight-medium">{{ $t('objects.views') }}</span>
</v-list-item>
<!-- <v-list-group v-model="selectedViewIdLocal" mandatory color="primary"> -->
<!--
todo: add sortable
<draggable
:is="_isUIAllowed('viewlist-drag-n-drop') ? 'draggable' : 'div'"
v-model="viewsList"
draggable="div"
v-bind="dragOptions"
@change="onMove($event)"
> -->
<!-- <transition-group
type="transition"
:name="!drag ? 'flip-list' : null"
> -->
<v-list-item
v-for="view in views"
:key="view.id"
v-t="['a:view:open', { view: view.type }]"
dense
:value="view.id"
active-class="x-active--text"
@click="activeView = view"
>
<!-- :class="`body-2 view nc-view-item nc-draggable-child nc-${
viewTypeAlias[view.type]
}-view-item`"
@click="$emit('rerender')" -->
<!-- <v-icon
v-if="_isUIAllowed('viewlist-drag-n-drop')"
small
:class="`nc-child-draggable-icon nc-child-draggable-icon-${view.title}`"
@click.stop
>
mdi-drag-vertical
</v-icon> -->
<!-- <v-list-item-icon class="mr-n1">
<v-icon v-if="viewIcons[view.type]" x-small :color="viewIcons[view.type].color">
{{ viewIcons[view.type].icon }}
</v-icon>
<v-icon v-else color="primary" small> mdi-table </v-icon>
</v-list-item-icon> -->
<component :is="viewIcons[view.type].icon" :class="`text-${viewIcons[view.type].color} mr-1`" />
<span>{{ view.alias || view.title }}</span>
<!-- <v-list-item-title>
<v-tooltip bottom>
<template #activator="{ on }">
<div class="font-weight-regular" style="overflow: hidden; text-overflow: ellipsis">
<input v-if="view.edit" :ref="`input${i}`" v-model="view.title_temp" />
&lt;!&ndash; @click.stop
@keydown.enter.stop="updateViewName(view, i)"
@blur="updateViewName(view, i)" &ndash;&gt;
<template v-else>
<span v-on="on">{{ view.alias || view.title }}</span>
</template>
</div>
</template>
{{ view.alias || view.title }}
</v-tooltip>
</v-list-item-title> -->
<v-spacer />
<!-- <template v-if="_isUIAllowed('virtualViewsCreateOrEdit')">
&lt;!&ndash; Copy view &ndash;&gt;
<x-icon
v-if="!view.edit"
:tooltip="$t('activity.copyView')"
x-small
color="primary"
icon-class="view-icon nc-view-copy-icon"
@click.stop="copyView(view, i)"
>
mdi-content-copy
</x-icon>
&lt;!&ndash; Rename view &ndash;&gt;
<x-icon
v-if="!view.edit"
:tooltip="$t('activity.renameView')"
x-small
color="primary"
icon-class="view-icon nc-view-edit-icon"
@click.stop="showRenameTextBox(view, i)"
>
mdi-pencil
</x-icon>
&lt;!&ndash; Delete view" &ndash;&gt;
<x-icon
v-if="!view.is_default"
:tooltip="$t('activity.deleteView')"
small
color="error"
icon-class="view-icon nc-view-delete-icon"
@click.stop="deleteView(view)"
>
mdi-delete-outline
</x-icon>
</template>
<v-icon
v-if="view.id === selectedViewId"
small
class="check-icon"
>
mdi-check-bold
</v-icon> -->
</v-list-item>
<!-- </transition-group> -->
<!-- </draggable> -->
<!-- </v-list-group> -->
</v-list>
<v-divider class="advance-menu-divider" />
<v-list dense>
<v-list-item dense>
<!-- Create a View -->
<span class="body-2 font-weight-medium" @dblclick="enableDummyFeat = true">
{{ $t('activity.createView') }}
</span>
<!-- <v-tooltip top>
<template #activator="{ props }">
&lt;!&ndash; <x-icon &ndash;&gt;
&lt;!&ndash; color="pink textColor" &ndash;&gt;
&lt;!&ndash; icon-class="ml-2" &ndash;&gt;
&lt;!&ndash; small &ndash;&gt;
&lt;!&ndash; v-on="on" &ndash;&gt;
&lt;!&ndash; @mouseenter="overShieldIcon = true" &ndash;&gt;
&lt;!&ndash; @mouseleave="overShieldIcon = false" &ndash;&gt;
&lt;!&ndash; > &ndash;&gt;
&lt;!&ndash; mdi-shield-lock-outline &ndash;&gt;
&lt;!&ndash; </x-icon> &ndash;&gt;
</template>
&lt;!&ndash; Only visible to Creator &ndash;&gt;
<span class="caption">
{{ $t('msg.info.onlyCreator') }}
</span>
</v-tooltip> -->
</v-list-item>
<v-tooltip bottom>
<template #activator="{ props }">
<v-list-item dense class="body-2 nc-create-grid-view" v-bind="props" @click="openCreateViewDlg(ViewTypes.GRID)">
<!-- <v-list-item-icon class="mr-n1"> -->
<component :is="viewIcons[ViewTypes.GRID].icon" :class="`text-${viewIcons[ViewTypes.GRID].color} mr-1`" />
<!-- </v-list-item-icon> -->
<v-list-item-title>
<span class="font-weight-regular">
<!-- Grid -->
{{ $t('objects.viewType.grid') }}
</span>
</v-list-item-title>
<v-spacer />
<MdiPlusIcon class="mr-1" />
<!-- <v-icon class="mr-1" small> mdi-plus</v-icon> -->
</v-list-item>
</template>
<!-- Add Grid View -->
{{ $t('msg.info.addView.grid') }}
</v-tooltip>
<v-tooltip bottom>
<template #activator="{ props }">
<v-list-item
dense
class="body-2 nc-create-gallery-view"
v-bind="props"
@click="openCreateViewDlg(ViewTypes.GALLERY)"
>
<!-- <v-list-item-icon class="mr-n1"> -->
<component :is="viewIcons[ViewTypes.GALLERY].icon" :class="`text-${viewIcons[ViewTypes.GALLERY].color} mr-1`" />
<!-- </v-list-item-icon> -->
<v-list-item-title>
<span class="font-weight-regular">
<!-- Gallery -->
{{ $t('objects.viewType.gallery') }}
</span>
</v-list-item-title>
<v-spacer />
<MdiPlusIcon class="mr-1" />
<!-- <v-icon class="mr-1" small> mdi-plus</v-icon> -->
</v-list-item>
</template>
<!-- Add Gallery View -->
{{ $t('msg.info.addView.gallery') }}
</v-tooltip>
<v-tooltip bottom>
<template #activator="{ props }">
<v-list-item
v-if="!isView"
dense
class="body-2 nc-create-form-view"
v-bind="props"
@click="openCreateViewDlg(ViewTypes.FORM)"
>
<!-- <v-list-item-icon class="mr-n1"> -->
<component :is="viewIcons[ViewTypes.FORM].icon" :class="`text-${viewIcons[ViewTypes.FORM].color} mr-1`" />
<!-- </v-list-item-icon> -->
<v-list-item-title>
<span class="font-weight-regular">
<!-- Form -->
{{ $t('objects.viewType.form') }}
</span>
</v-list-item-title>
<v-spacer />
<MdiPlusIcon class="mr-1" />
<!-- <v-icon class="mr-1" small> mdi-plus</v-icon> -->
</v-list-item>
</template>
<!-- Add Form View -->
{{ $t('msg.info.addView.form') }}
</v-tooltip>
</v-list>
</div>
</div>
<DlgViewCreate v-if="views" v-model="viewCreateDlg" :type="viewCreateType" @created="onViewCreate" />
</div>
</template>
<style scoped></style>

22
packages/nc-gui-v2/components/smartsheet/Toolbar.vue

@ -1,23 +1,18 @@
<script setup lang="ts"></script>
<template> <template>
<div dense class="nc-table-toolbar w-100 p-1 flex gap-1 align-center" style="z-index: 7"> <div class="nc-table-toolbar w-full py-1 flex gap-1 items-center" style="z-index: 7">
<SmartsheetToolbarSearchData class="flex-shrink" /> <SmartsheetToolbarSearchData class="flex-shrink" />
<SmartsheetToolbarFieldsMenu :show-system-fields="false" /> <SmartsheetToolbarFieldsMenu :show-system-fields="false" />
<SmartsheetToolbarColumnFilterMenu /> <SmartsheetToolbarColumnFilterMenu />
<SmartsheetToolbarSortListMenu /> <SmartsheetToolbarSortListMenu />
<SmartsheetToolbarShareView /> <SmartsheetToolbarShareView />
<SmartsheetToolbarMoreActions /> <SmartsheetToolbarMoreActions />
<div class="flex-1" /> <div class="flex-1" />
<SmartsheetToolbarLockMenu />
<div class="dot" />
<SmartsheetToolbarReload />
<div class="dot" />
<SmartsheetToolbarAddRow />
<div class="dot" />
<SmartsheetToolbarDeleteTable />
<div class="dot" />
<SmartsheetToolbarToggleDrawer class="mr-2" />
</div> </div>
</template> </template>
@ -25,7 +20,4 @@
:deep(.nc-toolbar-btn) { :deep(.nc-toolbar-btn) {
@apply border-0 !text-xs font-semibold px-2; @apply border-0 !text-xs font-semibold px-2;
} }
.dot {
@apply w-[3px] h-[3px] bg-gray-300 mx-1 rounded-full;
}
</style> </style>

3
packages/nc-gui-v2/components/smartsheet/VirtualCell.vue

@ -1,8 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ColumnType } from 'nocodb-sdk' import type { ColumnType } from 'nocodb-sdk'
import { provide } from '#imports' import { provide, useVirtualCell } from '#imports'
import { ColumnInj } from '~/context' import { ColumnInj } from '~/context'
import useVirtualCell from '~/composables/useVirtualCell'
interface Props { interface Props {
column: ColumnType column: ColumnType

146
packages/nc-gui-v2/components/smartsheet/sidebar/MenuBottom.vue

@ -0,0 +1,146 @@
<script lang="ts" setup>
import { ViewTypes } from 'nocodb-sdk'
import { ref, useNuxtApp } from '#imports'
import { viewIcons } from '~/utils'
import MdiPlusIcon from '~icons/mdi/plus'
import MdiXml from '~icons/mdi/xml'
import MdiHook from '~icons/mdi/hook'
import MdiHeartsCard from '~icons/mdi/cards-heart'
import MdiShieldLockOutline from '~icons/mdi/shield-lock-outline'
interface Emits {
(event: 'openModal', data: { type: ViewTypes; title?: string }): void
}
const emits = defineEmits<Emits>()
const { $e } = useNuxtApp()
const isView = ref(false)
function onApiSnippet() {
// get API snippet
$e('a:view:api-snippet')
}
function onOpenModal(type: ViewTypes, title = '') {
emits('openModal', { type, title })
}
</script>
<template>
<a-menu :selected-keys="[]" class="flex-1 flex flex-col">
<h3 class="px-3 py-1 text-xs font-semibold flex items-center gap-4">
{{ $t('activity.createView') }}
<a-tooltip>
<template #title>
{{ $t('msg.info.onlyCreator') }}
</template>
<MdiShieldLockOutline class="text-pink-500" />
</a-tooltip>
</h3>
<a-menu-item key="grid" class="group !flex !items-center !my-0 !h-[30px]" @click="onOpenModal(ViewTypes.GRID)">
<a-tooltip placement="left">
<template #title>
{{ $t('msg.info.addView.grid') }}
</template>
<div class="text-xs flex items-center h-full w-full gap-2">
<component :is="viewIcons[ViewTypes.GRID].icon" :class="`text-${viewIcons[ViewTypes.GRID].color}`" />
<div>{{ $t('objects.viewType.grid') }}</div>
<div class="flex-1" />
<MdiPlusIcon class="group-hover:text-primary" />
</div>
</a-tooltip>
</a-menu-item>
<a-menu-item key="gallery" class="group !flex !items-center !-my0 !h-[30px]" @click="onOpenModal(ViewTypes.GALLERY)">
<a-tooltip placement="left">
<template #title>
{{ $t('msg.info.addView.gallery') }}
</template>
<div class="text-xs flex items-center h-full w-full gap-2">
<component :is="viewIcons[ViewTypes.GALLERY].icon" :class="`text-${viewIcons[ViewTypes.GALLERY].color}`" />
<div>{{ $t('objects.viewType.gallery') }}</div>
<div class="flex-1" />
<MdiPlusIcon class="group-hover:text-primary" />
</div>
</a-tooltip>
</a-menu-item>
<a-menu-item v-if="!isView" key="form" class="group !flex !items-center !my-0 !h-[30px]" @click="onOpenModal(ViewTypes.FORM)">
<a-tooltip placement="left">
<template #title>
{{ $t('msg.info.addView.form') }}
</template>
<div class="text-xs flex items-center h-full w-full gap-2">
<component :is="viewIcons[ViewTypes.FORM].icon" :class="`text-${viewIcons[ViewTypes.FORM].color}`" />
<div>{{ $t('objects.viewType.form') }}</div>
<div class="flex-1" />
<MdiPlusIcon class="group-hover:text-primary" />
</div>
</a-tooltip>
</a-menu-item>
<div class="flex-auto justify-end flex flex-col gap-4 mt-4">
<button
class="flex items-center gap-2 w-full mx-3 px-4 py-3 rounded !bg-primary text-white transform translate-x-4 hover:(translate-x-0 shadow-lg) transition duration-150 ease"
@click="onApiSnippet"
>
<MdiXml />Get API Snippet
</button>
<button
class="flex items-center gap-2 w-full mx-3 px-4 py-3 rounded border transform translate-x-4 hover:(translate-x-0 shadow-lg) transition duration-150 ease"
@click="onApiSnippet"
>
<MdiHook />{{ $t('objects.webhooks') }}
</button>
</div>
<general-flipping-card class="my-4 lg:my-6 min-h-[100px]" :triggers="['click', { duration: 15000 }]">
<template #front>
<div class="flex h-full w-full gap-6 flex-col">
<general-social />
<div>
<a
v-t="['e:hiring']"
class="px-4 py-3 !bg-primary rounded shadow text-white"
href="https://angel.co/company/nocodb"
target="_blank"
@click.stop
>
🚀 We are Hiring! 🚀
</a>
</div>
</div>
</template>
<template #back>
<!-- todo: add project cost -->
<a
href="https://github.com/sponsors/nocodb"
target="_blank"
class="group flex items-center gap-2 w-full mx-3 px-4 py-2 rounded-l !bg-primary text-white transform translate-x-4 hover:(translate-x-0 shadow-lg !opacity-100) transition duration-150 ease"
@click.stop
>
<MdiHeartsCard class="text-red-500" />
{{ $t('activity.sponsorUs') }}
</a>
</template>
</general-flipping-card>
</a-menu>
</template>

238
packages/nc-gui-v2/components/smartsheet/sidebar/MenuTop.vue

@ -0,0 +1,238 @@
<script lang="ts" setup>
import type { FormType, GalleryType, GridType, KanbanType, ViewTypes } from 'nocodb-sdk'
import type { SortableEvent } from 'sortablejs'
import type { Menu as AntMenu } from 'ant-design-vue'
import { notification } from 'ant-design-vue'
import type { Ref } from 'vue'
import Sortable from 'sortablejs'
import RenameableMenuItem from './RenameableMenuItem.vue'
import { inject, onMounted, ref, useApi, useTabs, watch } from '#imports'
import { extractSdkResponseErrorMsg } from '~/utils'
import { ActiveViewInj, MetaInj, ViewListInj } from '~/context'
interface Emits {
(event: 'openModal', data: { type: ViewTypes; title?: string }): void
(event: 'deleted'): void
(event: 'sorted'): void
}
const emits = defineEmits<Emits>()
const activeView = inject(ActiveViewInj, ref())
const views = inject<Ref<any[]>>(ViewListInj, ref([]))
const meta = inject(MetaInj)
const { addTab } = useTabs()
const { api } = useApi()
const router = useRouter()
/** Selected view(s) for menu */
const selected = ref<string[]>([])
/** dragging renamable view items */
let dragging = $ref(false)
let deleteModalVisible = $ref(false)
/** view to delete for modal */
let toDelete = $ref<Record<string, any> | undefined>()
const menuRef = $ref<typeof AntMenu>()
let isMarked = $ref<string | false>(false)
/** Watch currently active view, so we can mark it in the menu */
watch(activeView, (nextActiveView) => {
const _nextActiveView = nextActiveView as GridType | FormType | KanbanType
if (_nextActiveView && _nextActiveView.id) {
selected.value = [_nextActiveView.id]
}
})
/** shortly mark an item after sorting */
function markItem(id: string) {
isMarked = id
setTimeout(() => {
isMarked = false
}, 300)
}
/** validate view title */
function validate(value?: string) {
if (!value || value.trim().length < 0) {
return 'View name is required'
}
if (views.value.every((v1) => ((v1 as GridType | KanbanType | GalleryType).alias || v1.title) !== value)) {
return 'View name should be unique'
}
return true
}
function onSortStart(evt: SortableEvent) {
evt.stopImmediatePropagation()
evt.preventDefault()
dragging = true
}
async function onSortEnd(evt: SortableEvent) {
evt.stopImmediatePropagation()
evt.preventDefault()
dragging = false
if (views.value.length < 2) return
const { newIndex = 0, oldIndex = 0 } = evt
if (newIndex === oldIndex) return
const children = evt.to.children as unknown as HTMLLIElement[]
const previousEl = children[newIndex - 1]
const nextEl = children[newIndex + 1]
const currentItem: Record<string, any> = views.value.find((v) => v.id === evt.item.id)
const previousItem: Record<string, any> = previousEl ? views.value.find((v) => v.id === previousEl.id) : {}
const nextItem: Record<string, any> = nextEl ? views.value.find((v) => v.id === nextEl.id) : {}
let nextOrder: number
// set new order value based on the new order of the items
if (views.value.length - 1 === newIndex) {
nextOrder = parseFloat(previousItem.order) + 1
} else if (newIndex === 0) {
nextOrder = parseFloat(nextItem.order) / 2
} else {
nextOrder = (parseFloat(previousItem.order) + parseFloat(nextItem.order)) / 2
}
const _nextOrder = !isNaN(Number(nextOrder)) ? nextOrder.toString() : oldIndex.toString()
currentItem.order = _nextOrder
await api.dbView.update(currentItem.id, { order: _nextOrder })
markItem(currentItem.id)
}
let sortable: Sortable
// todo: replace with vuedraggable
const initSortable = (el: HTMLElement) => {
if (sortable) sortable.destroy()
sortable = new Sortable(el, {
handle: '.nc-drag-icon',
ghostClass: 'ghost',
onStart: onSortStart,
onEnd: onSortEnd,
})
}
onMounted(() => menuRef && initSortable(menuRef.$el))
// todo: fix view type, alias is missing for some reason?
/** Navigate to view by changing url param */
function changeView(view: { id: string; alias?: string; title?: string; type: ViewTypes }) {
router.push({ params: { viewTitle: (view.alias ?? view.title) || '' } })
}
/** Rename a view */
async function onRename(view: Record<string, any>) {
const valid = validate(view.title)
if (valid !== true) {
notification.error({
message: valid,
duration: 2,
})
}
try {
// todo typing issues, order and id do not exist on all members of ViewTypes (Kanban, Gallery, Form, Grid)
await api.dbView.update(view.id, {
title: view.title,
order: view.order,
})
notification.success({
message: 'View renamed successfully',
duration: 3,
})
} catch (e: any) {
notification.error({
message: await extractSdkResponseErrorMsg(e),
duration: 3,
})
}
}
/** Open delete modal */
async function onDelete(view: Record<string, any>) {
toDelete = view
deleteModalVisible = true
}
/** View was deleted, trigger reload */
function onDeleted() {
emits('deleted')
toDelete = undefined
deleteModalVisible = false
}
</script>
<template>
<h3 class="pt-3 px-3 text-xs font-semibold">{{ $t('objects.views') }}</h3>
<a-menu ref="menuRef" :class="{ dragging }" class="nc-views-menu" :selected-keys="selected">
<RenameableMenuItem
v-for="view of views"
:id="view.id"
:key="view.id"
:view="view"
class="transition-all ease-in duration-300"
:class="[isMarked === view.id ? 'bg-gray-200' : '']"
@change-view="changeView"
@open-modal="$emit('openModal', $event)"
@delete="onDelete"
@rename="onRename"
/>
</a-menu>
<dlg-view-delete v-model="deleteModalVisible" :view="toDelete" @deleted="onDeleted" />
</template>
<style lang="scss">
.nc-views-menu {
@apply flex-1 max-h-[20vh] overflow-y-scroll scrollbar-thin-primary;
.ghost,
.ghost > * {
@apply !pointer-events-none;
}
&.dragging {
.nc-icon {
@apply !hidden;
}
.nc-view-icon {
@apply !block;
}
}
.ant-menu-item:not(.sortable-chosen) {
@apply color-transition hover:!bg-transparent;
}
.sortable-chosen {
@apply !bg-primary/25 text-primary;
}
}
</style>

179
packages/nc-gui-v2/components/smartsheet/sidebar/RenameableMenuItem.vue

@ -0,0 +1,179 @@
<script lang="ts" setup>
import type { ViewTypes } from 'nocodb-sdk'
import { viewIcons } from '~/utils'
import { useDebounceFn, useNuxtApp, useVModel } from '#imports'
import MdiTrashCan from '~icons/mdi/trash-can'
import MdiContentCopy from '~icons/mdi/content-copy'
import MdiDrag from '~icons/mdi/drag-vertical'
interface Props {
view: Record<string, any>
}
interface Emits {
(event: 'openModal', data: { type: ViewTypes; title?: string }): void
(event: 'update:view', data: Record<string, any>): void
(event: 'changeView', view: Record<string, any>): void
(event: 'rename', view: Record<string, any>): void
(event: 'delete', view: Record<string, any>): void
}
const props = defineProps<Props>()
const emits = defineEmits<Emits>()
const vModel = useVModel(props, 'view', emits)
const { $e } = useNuxtApp()
/** Is editing the view name enabled */
let isEditing = $ref<boolean>(false)
/** Helper to check if editing was disabled before the view navigation timeout triggers */
let isStopped = $ref(false)
/** Original view title when editing the view name */
let originalTitle = $ref<string | undefined>()
/** Debounce click handler, so we can potentially enable editing view name {@see onDblClick} */
const onClick = useDebounceFn(() => {
if (isEditing || isStopped) return
emits('changeView', vModel.value)
}, 250)
/** Enable editing view name on dbl click */
function onDblClick() {
if (!isEditing) {
isEditing = true
originalTitle = vModel.value.title
}
}
/** Handle keydown on input field */
function onKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
onKeyEsc(event)
} else if (event.key === 'Enter') {
onKeyEnter(event)
}
}
/** Rename view when enter is pressed */
function onKeyEnter(event: KeyboardEvent) {
event.stopImmediatePropagation()
event.preventDefault()
onRename()
}
/** Disable renaming view when escape is pressed */
function onKeyEsc(event: KeyboardEvent) {
event.stopImmediatePropagation()
event.preventDefault()
onCancel()
}
onKeyStroke('Enter', (event) => {
if (isEditing) {
onKeyEnter(event)
}
})
function focusInput(el: HTMLInputElement) {
if (el) el.focus()
}
/** Duplicate a view */
// todo: This is not really a duplication, maybe we need to implement a true duplication?
function onDuplicate() {
emits('openModal', { type: vModel.value.type, title: vModel.value.title })
$e('c:view:copy', { view: vModel.value.type })
}
/** Delete a view */
async function onDelete() {
emits('delete', vModel.value)
}
/** Rename a view */
async function onRename() {
if (!isEditing) return
if (vModel.value.title === '' || vModel.value.title === originalTitle) {
onCancel()
return
}
emits('rename', vModel.value)
onStopEdit()
}
/** Cancel renaming view */
function onCancel() {
if (!isEditing) return
vModel.value.title = originalTitle
onStopEdit()
}
/** Stop editing view name, timeout makes sure that view navigation (click trigger) does not pick up before stop is done */
function onStopEdit() {
isStopped = true
isEditing = false
originalTitle = ''
setTimeout(() => {
isStopped = false
}, 250)
}
</script>
<template>
<a-menu-item class="select-none group !flex !items-center !my-0" @dblclick.stop="onDblClick" @click.stop="onClick">
<div v-t="['a:view:open', { view: vModel.type }]" class="text-xs flex items-center w-full gap-2">
<div class="flex w-auto">
<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[vModel.type].icon"
class="nc-view-icon group-hover:hidden"
:class="`text-${viewIcons[vModel.type].color}`"
/>
</div>
<a-input v-if="isEditing" :ref="focusInput" v-model:value="vModel.title" @blur="onCancel" @keydown="onKeyDown($event)" />
<div v-else>{{ vModel.alias || vModel.title }}</div>
<div class="flex-1" />
<template v-if="!isEditing">
<div class="flex items-center gap-1">
<a-tooltip placement="left">
<template #title>
{{ $t('activity.copyView') }}
</template>
<MdiContentCopy class="hidden group-hover:block text-gray-500" @click.stop="onDuplicate" />
</a-tooltip>
<template v-if="!vModel.is_default">
<a-tooltip placement="left">
<template #title>
{{ $t('activity.deleteView') }}
</template>
<MdiTrashCan class="hidden group-hover:block text-red-500" @click.stop="onDelete" />
</a-tooltip>
</template>
</div>
</template>
</div>
</a-menu-item>
</template>

35
packages/nc-gui-v2/components/smartsheet/sidebar/Toolbar.vue

@ -0,0 +1,35 @@
<template>
<div class="flex gap-2">
<slot name="start" />
<SmartsheetToolbarLockMenu />
<div class="dot" />
<SmartsheetToolbarReload />
<div class="dot" />
<SmartsheetToolbarAddRow />
<div class="dot" />
<SmartsheetToolbarDeleteTable />
<div class="dot" />
<SmartsheetToolbarToggleDrawer />
<slot name="end" />
</div>
</template>
<style scoped>
:deep(.nc-toolbar-btn) {
@apply border-0 !text-xs font-semibold px-2;
}
.dot {
@apply w-[3px] h-[3px] bg-gray-300 rounded-full;
}
</style>

130
packages/nc-gui-v2/components/smartsheet/sidebar/index.vue

@ -0,0 +1,130 @@
<script setup lang="ts">
import type { FormType, GalleryType, GridType, KanbanType, ViewTypes } from 'nocodb-sdk'
import MenuTop from './MenuTop.vue'
import MenuBottom from './MenuBottom.vue'
import Toolbar from './Toolbar.vue'
import { computed, inject, provide, ref, useApi, useViews, watch } from '#imports'
import { ActiveViewInj, MetaInj, RightSidebarInj, ViewListInj } from '~/context'
import MdiXml from '~icons/mdi/xml'
import MdiHook from '~icons/mdi/hook'
const meta = inject(MetaInj, ref())
const activeView = inject(ActiveViewInj, ref())
const { views, loadViews } = useViews(meta)
const { api } = useApi()
const route = useRoute()
provide(ViewListInj, views)
/** Sidebar visible */
const sidebarOpen = inject(RightSidebarInj, ref(true))
const sidebarCollapsed = computed(() => !sidebarOpen.value)
/** View type to create from modal */
let viewCreateType = $ref<ViewTypes>()
/** View title to create from modal (when duplicating) */
let viewCreateTitle = $ref('')
/** is view creation modal open */
let modalOpen = $ref(false)
/** Watch route param and change active view based on `viewTitle` */
watch(
[views, () => route.params.viewTitle],
([nextViews, viewTitle]) => {
if (viewTitle) {
const view = nextViews.find((v) => v.title === viewTitle)
if (view) {
activeView.value = view
}
}
/** if active view is not found, set it to first view */
if (!activeView.value && nextViews.length) {
activeView.value = nextViews[0]
}
},
{ immediate: true },
)
/** Open view creation modal */
function openModal({ type, title = '' }: { type: ViewTypes; title: string }) {
modalOpen = true
viewCreateType = type
viewCreateTitle = title
}
/** Handle view creation */
function onCreate(view: GridType | FormType | KanbanType | GalleryType) {
views.value.push(view)
activeView.value = view
modalOpen = false
}
</script>
<template>
<a-layout-sider
:collapsed="sidebarCollapsed"
collapsiple
collapsed-width="50"
width="250"
class="shadow !mt-[-9px]"
style="height: calc(100% + 9px)"
theme="light"
>
<Toolbar v-if="sidebarOpen" class="flex items-center py-3 px-3 justify-between border-b-1" />
<Toolbar v-else class="py-3 px-2 max-w-[50px] flex !flex-col-reverse gap-4 items-center mt-[-1px]">
<template #start>
<a-tooltip placement="left">
<template #title> {{ $t('objects.webhooks') }}</template>
<div class="nc-sidebar-right-item hover:after:bg-gray-300">
<MdiHook />
</div>
</a-tooltip>
<div class="dot" />
<a-tooltip placement="left">
<template #title> Get API Snippet</template>
<div class="nc-sidebar-right-item group hover:after:bg-yellow-500">
<MdiXml class="group-hover:(!text-white)" />
</div>
</a-tooltip>
<div class="dot" />
</template>
</Toolbar>
<div v-if="sidebarOpen" class="flex-1 flex flex-col">
<MenuTop @open-modal="openModal" @deleted="loadViews" @sorted="loadViews" />
<a-divider class="my-2" />
<MenuBottom @open-modal="openModal" />
</div>
<dlg-view-create v-if="views" v-model="modalOpen" :title="viewCreateTitle" :type="viewCreateType" @created="onCreate" />
</a-layout-sider>
</template>
<style scoped>
:deep(.ant-menu-title-content) {
@apply w-full;
}
:deep(.ant-layout-sider-children) {
@apply flex flex-col;
}
.dot {
@apply w-[3px] h-[3px] bg-gray-300 rounded-full;
}
</style>

42
packages/nc-gui-v2/components/tabs/Auth.vue

@ -1,5 +1,43 @@
<script setup lang="ts">
import UserManagement from './auth/UserManagement.vue'
import ApiTokenManagement from './auth/ApiTokenManagement.vue'
interface TabGroup {
[key: string]: {
title: string
body: any
}
}
const tabsInfo: TabGroup = {
usersManagement: {
title: 'Users Management',
body: () => UserManagement,
},
apiTokenManagement: {
title: 'API Token Management',
body: () => ApiTokenManagement,
},
}
const firstKeyOfObject = (obj: object) => Object.keys(obj)[0]
const selectedTabKeys = $ref<string[]>([firstKeyOfObject(tabsInfo)])
const selectedTab = $computed(() => tabsInfo[selectedTabKeys[0]])
</script>
<template> <template>
<div> <div class="mt-2">
<h2 class="text-3xl mt-3">Team & Auth</h2> <a-menu v-model:selectedKeys="selectedTabKeys" :open-keys="[]" mode="horizontal">
<a-menu-item v-for="(tab, key) of tabsInfo" :key="key" class="select-none">
<div class="text-xs pb-2.5">
{{ tab.title }}
</div>
</a-menu-item>
</a-menu>
<div class="mx-4 py-6 mt-2">
<component :is="selectedTab.body()" />
</div>
</div> </div>
</template> </template>

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

@ -1,19 +1,26 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ColumnType, ViewType } from 'nocodb-sdk' import type { ColumnType, TableType, ViewType } from 'nocodb-sdk'
import { ViewTypes } from 'nocodb-sdk' import { ViewTypes } from 'nocodb-sdk'
import { computed, inject, onMounted, provide, watch, watchEffect } from '#imports' import type { Ref } from 'vue'
import { ActiveViewInj, FieldsInj, IsLockedInj, MetaInj, ReloadViewDataHookInj, TabMetaInj } from '~/context' import SmartsheetGrid from '../smartsheet/Grid.vue'
import useMetas from '~/composables/useMetas' import { computed, inject, provide, useMetas, useProvideSmartsheetStore, watch, watchEffect } from '#imports'
import { ActiveViewInj, FieldsInj, IsLockedInj, MetaInj, ReloadViewDataHookInj, RightSidebarInj, TabMetaInj } from '~/context'
import type { TabItem } from '~/composables'
const { getMeta, metas } = useMetas() const { getMeta, metas } = useMetas()
const activeView = ref<ViewType>() const activeView = ref()
const el = ref<any>()
const el = ref<typeof SmartsheetGrid>()
const fields = ref<ColumnType[]>([]) const fields = ref<ColumnType[]>([])
const tabMeta = inject(TabMetaInj) const tabMeta = inject(
TabMetaInj,
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)
@ -21,34 +28,41 @@ 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)
provide(IsLockedInj, false) provide(IsLockedInj, false)
provide(ReloadViewDataHookInj, reloadEventHook) provide(ReloadViewDataHookInj, reloadEventHook)
provide(FieldsInj, fields) provide(FieldsInj, fields)
provide(RightSidebarInj, ref(true))
watch( useProvideSmartsheetStore(activeView as Ref<TableType>, meta)
() => tabMeta && tabMeta?.id,
async (newVal, oldVal) => { watch(tabMeta, async (newTabMeta, oldTabMeta) => {
if (newVal !== oldVal) await getMeta(newVal) if (newTabMeta !== oldTabMeta && newTabMeta.id) await getMeta(newTabMeta.id)
}, })
)
</script> </script>
<template> <template>
<div class="nc-container flex h-full"> <div class="nc-container flex h-full">
<div class="flex flex-col h-full flex-1 min-w-0"> <div class="flex flex-col h-full flex-1 min-w-0">
<SmartsheetToolbar /> <SmartsheetToolbar />
<template v-if="meta"> <template v-if="meta">
<div class="flex flex-1 min-h-0"> <div class="flex flex-1 min-h-0">
<div v-if="activeView" class="h-full flex-grow min-w-0 min-h-0"> <div v-if="activeView" class="h-full flex-grow min-w-0 min-h-0">
<SmartsheetGrid v-if="activeView.type === ViewTypes.GRID" :ref="el" /> <SmartsheetGrid v-if="activeView.type === ViewTypes.GRID" :ref="el" />
<SmartsheetGallery v-else-if="activeView.type === ViewTypes.GALLERY" /> <SmartsheetGallery v-else-if="activeView.type === ViewTypes.GALLERY" />
<SmartsheetForm v-else-if="activeView.type === ViewTypes.FORM" /> <SmartsheetForm v-else-if="activeView.type === ViewTypes.FORM" />
</div> </div>
<SmartsheetSidebar />
</div> </div>
<teleport to="#sidebar-right">
<SmartsheetSidebar />
</teleport>
</template> </template>
</div> </div>
</div> </div>

200
packages/nc-gui-v2/components/tabs/auth/ApiTokenManagement.vue

@ -0,0 +1,200 @@
<script setup lang="ts">
import type { ApiTokenType } from 'nocodb-sdk'
import { useToast } from 'vue-toastification'
import { useClipboard } from '@vueuse/core'
import KebabIcon from '~icons/ic/baseline-more-vert'
import MdiPlusIcon from '~icons/mdi/plus'
import CloseIcon from '~icons/material-symbols/close-rounded'
import ReloadIcon from '~icons/mdi/reload'
import VisibilityOpenIcon from '~icons/material-symbols/visibility'
import VisibilityCloseIcon from '~icons/material-symbols/visibility-off'
import MdiDeleteOutlineIcon from '~icons/mdi/delete-outline'
import MdiContentCopyIcon from '~icons/mdi/content-copy'
import { extractSdkResponseErrorMsg } from '~/utils/errorUtils'
const toast = useToast()
interface ApiToken extends ApiTokenType {
show?: boolean
}
const { $api, $e } = useNuxtApp()
const { project } = $(useProject())
const { copy } = useClipboard()
let tokensInfo = $ref<ApiToken[] | undefined>([])
let showNewTokenModal = $ref(false)
let showDeleteTokenModal = $ref(false)
let selectedTokenData = $ref<ApiToken>({})
const loadApiTokens = async () => {
if (!project?.id) return
tokensInfo = await $api.apiToken.list(project.id)
}
const openNewTokenModal = () => {
showNewTokenModal = true
$e('c:api-token:generate')
}
const copyToken = (token: string | undefined) => {
if (!token) return
copy(token)
toast.info('Copied to clipboard')
$e('c:api-token:copy')
}
const generateToken = async () => {
try {
if (!project?.id) return
await $api.apiToken.create(project.id, selectedTokenData)
showNewTokenModal = false
toast.success('Token generated successfully')
selectedTokenData = {}
await loadApiTokens()
} catch (e: any) {
console.error(e)
toast.error(await extractSdkResponseErrorMsg(e))
}
$e('a:api-token:generate')
}
const deleteToken = async () => {
try {
if (!project?.id || !selectedTokenData.token) return
await $api.apiToken.delete(project.id, selectedTokenData.token)
toast.success('Token deleted successfully')
await loadApiTokens()
showDeleteTokenModal = false
} catch (e: any) {
console.error(e)
toast.error(await extractSdkResponseErrorMsg(e))
}
$e('a:api-token:delete')
}
const openDeleteModal = (item: ApiToken) => {
selectedTokenData = item
showDeleteTokenModal = true
}
onMounted(() => {
loadApiTokens()
})
</script>
<template>
<a-modal v-model:visible="showNewTokenModal" :closable="false" width="28rem" centered :footer="null">
<div class="relative flex flex-col h-full">
<a-button type="text" class="!absolute top-0 right-0 rounded-md -mt-2 -mr-3" @click="showNewTokenModal = false">
<template #icon>
<CloseIcon class="flex mx-auto" />
</template>
</a-button>
<div class="flex flex-row justify-center w-full -mt-1">
<a-typography-title :level="5">Generate Token</a-typography-title>
</div>
<div class="flex flex-col mt-3 justify-center space-y-6">
<a-input v-model:value="selectedTokenData.description" placeholder="Description" />
<div class="flex flex-row justify-center">
<a-button type="primary" @click="generateToken"> Generate </a-button>
</div>
</div>
</div>
</a-modal>
<a-modal v-model:visible="showDeleteTokenModal" :closable="false" width="28rem" centered :footer="null">
<div class="flex flex-col h-full">
<div class="flex flex-row justify-center mt-2 text-center w-full text-base">This action will remove this API Token</div>
<div class="flex mt-6 justify-center space-x-2">
<a-button @click="showDeleteTokenModal = false"> Cancel </a-button>
<a-button type="primary" danger @click="deleteToken()"> Confirm </a-button>
</div>
</div>
</a-modal>
<div class="flex flex-col px-10 mt-6">
<div class="flex flex-row justify-end">
<div class="flex flex-row space-x-1">
<a-button size="middle" type="text" @click="loadApiTokens()">
<div class="flex flex-row justify-center items-center caption capitalize space-x-1">
<ReloadIcon class="text-gray-500" />
<div class="text-gray-500">Reload</div>
</div>
</a-button>
<a-button size="middle" @click="openNewTokenModal">
<div class="flex flex-row justify-center items-center caption capitalize space-x-1">
<MdiPlusIcon />
<div>Add New Token</div>
</div>
</a-button>
</div>
</div>
<div v-if="tokensInfo" class="w-full flex flex-col mt-2 px-1">
<div class="flex flex-row border-b-1 text-gray-600 text-xs pb-2 pt-2">
<div class="flex w-4/10 pl-2">Description</div>
<div class="flex w-4/10 justify-center">Token</div>
<div class="flex w-2/10 justify-end pr-2">Actions</div>
</div>
<div v-for="(item, index) in tokensInfo" :key="index" class="flex flex-col">
<div class="flex flex-row border-b-1 items-center px-2 py-2">
<div class="flex flex-row w-4/10 flex-wrap overflow-ellipsis">
{{ item.description }}
</div>
<div class="flex w-4/10 justify-center flex-wrap overflow-ellipsis">
<span v-if="item.show">{{ item.token }}</span>
<span v-else>****************************************</span>
</div>
<div class="flex flex-row w-2/10 justify-end">
<a-tooltip placement="bottom">
<template #title>
<span v-if="item.show">Hide API token </span>
<span v-else>Show API token </span>
</template>
<a-button type="text" class="!rounded-md" @click="item.show = !item.show">
<template #icon>
<VisibilityCloseIcon v-if="item.show" class="flex mx-auto h-[1.1rem]" />
<VisibilityOpenIcon v-else class="flex mx-auto h-[1rem]" />
</template>
</a-button>
</a-tooltip>
<a-tooltip placement="bottom">
<template #title> Copy token to clipboard </template>
<a-button type="text" class="!rounded-md" @click="copyToken(item.token)">
<template #icon>
<MdiContentCopyIcon class="flex mx-auto h-[1rem]" />
</template>
</a-button>
</a-tooltip>
<a-dropdown :trigger="['click']" class="flex" placement="bottomRight">
<div class="flex flex-row items-center">
<a-button type="text" class="!px-0">
<div class="flex flex-row items-center h-[1.2rem]">
<KebabIcon />
</div>
</a-button>
</div>
<template #overlay>
<a-menu>
<a-menu-item>
<div class="flex flex-row items-center py-1 h-[1rem]" @click="openDeleteModal(item)">
<MdiDeleteOutlineIcon class="flex" />
<div class="text-xs pl-2">Remove API Token</div>
</div>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</div>
</div>
</div>
</div>
</template>

297
packages/nc-gui-v2/components/tabs/auth/UserManagement.vue

@ -0,0 +1,297 @@
<script setup lang="ts">
import { useClipboard, watchDebounced } from '@vueuse/core'
import { useToast } from 'vue-toastification'
import UsersModal from './user-management/UsersModal.vue'
import FeedbackForm from './user-management/FeedbackForm.vue'
import KebabIcon from '~icons/ic/baseline-more-vert'
import { extractSdkResponseErrorMsg } from '~/utils/errorUtils'
import { projectRoleTagColors } from '~/utils/userUtils'
import MidAccountIcon from '~icons/mdi/account-outline'
import ReloadIcon from '~icons/mdi/reload'
import MdiEditIcon from '~icons/ic/round-edit'
import SearchIcon from '~icons/ic/round-search'
import MdiDeleteOutlineIcon from '~icons/mdi/delete-outline'
import EmailIcon from '~icons/eva/email-outline'
import MdiPlusIcon from '~icons/mdi/plus'
import MdiContentCopyIcon from '~icons/mdi/content-copy'
import MdiEmailSendIcon from '~icons/mdi/email-arrow-right-outline'
import RolesIcon from '~icons/mdi/drama-masks'
import type { User } from '~/lib/types'
const toast = useToast()
const { $api, $e } = useNuxtApp()
const { project } = useProject()
const { copy } = useClipboard()
let users = $ref<null | User[]>(null)
let selectedUser = $ref<null | User>(null)
let showUserModal = $ref(false)
let showUserDeleteModal = $ref(false)
let isLoading = $ref(false)
let totalRows = $ref(0)
const currentPage = $ref(1)
const currentLimit = $ref(10)
const searchText = ref<string>('')
const loadUsers = async (page = currentPage, limit = currentLimit) => {
try {
if (!project.value?.id) return
// TODO: Types of api is not correct
const response = await $api.auth.projectUserList(project.value?.id, {
query: {
limit,
offset: searchText.value.length === 0 ? (page - 1) * limit : 0,
query: searchText.value,
},
})
if (!response.users) return
totalRows = response.users.pageInfo.totalRows ?? 0
users = response.users.list as User[]
} catch (e: any) {
console.error(e)
toast.error(await extractSdkResponseErrorMsg(e))
}
}
const inviteUser = async (user: User) => {
try {
if (!project.value?.id) return
await $api.auth.projectUserAdd(project.value.id, user)
toast.success('Successfully added user to project')
await loadUsers()
} catch (e: any) {
console.error(e)
toast.error(await extractSdkResponseErrorMsg(e))
}
$e('a:user:add')
}
const deleteUser = async () => {
try {
if (!project.value?.id || !selectedUser?.id) return
await $api.auth.projectUserRemove(project.value.id, selectedUser.id)
toast.success('Successfully deleted user from project')
await loadUsers()
showUserDeleteModal = false
} catch (e: any) {
console.error(e)
toast.error(await extractSdkResponseErrorMsg(e))
}
$e('a:user:delete')
}
const onEdit = (user: User) => {
selectedUser = user
showUserModal = true
}
const onInvite = () => {
selectedUser = null
showUserModal = true
}
const onDelete = (user: User) => {
selectedUser = user
showUserDeleteModal = true
}
const resendInvite = async (user: User) => {
if (!project.value?.id) return
try {
await $api.auth.projectUserResendInvite(project.value.id, user.id)
toast.success('Invite email sent successfully')
await loadUsers()
} catch (e: any) {
console.error(e)
toast.error(await extractSdkResponseErrorMsg(e))
}
$e('a:user:resend-invite')
}
const copyInviteUrl = (user: User) => {
if (!user.invite_token) return
const getInviteUrl = (token: string) => `${location.origin}${location.pathname}#/user/authentication/signup/${token}`
copy(getInviteUrl(user.invite_token))
toast.success('Invite url copied to clipboard')
}
onMounted(async () => {
if (!users) {
isLoading = true
try {
await loadUsers()
} finally {
isLoading = false
}
}
})
watchDebounced(searchText, () => loadUsers(), { debounce: 300, maxWait: 600 })
</script>
<template>
<div v-if="isLoading" class="h-full w-full flex flex-row justify-center mt-42">
<a-spin size="large" />
</div>
<div v-else class="flex flex-col w-full px-6">
<UsersModal
:key="showUserModal"
:show="showUserModal"
:selected-user="selectedUser"
@closed="showUserModal = false"
@reload="loadUsers()"
/>
<a-modal v-model:visible="showUserDeleteModal" :closable="false" width="28rem" centered :footer="null">
<div class="flex flex-col h-full">
<div class="flex flex-row justify-center mt-2 text-center w-full text-base">
This action will remove this user from this project
</div>
<div class="flex mt-6 justify-center space-x-2">
<a-button @click="showUserDeleteModal = false"> Cancel </a-button>
<a-button type="primary" danger @click="deleteUser"> Confirm </a-button>
</div>
</div>
</a-modal>
<div class="flex flex-row mb-4 mx-4 justify-between">
<div class="flex w-1/3">
<a-input v-model:value="searchText" placeholder="Filter by email">
<template #prefix>
<SearchIcon class="text-gray-400" />
</template>
</a-input>
</div>
<div class="flex flex-row space-x-1">
<a-button size="middle" type="text" @click="loadUsers()">
<div class="flex flex-row justify-center items-center caption capitalize space-x-1">
<ReloadIcon class="text-gray-500" />
<div class="text-gray-500">Reload</div>
</div>
</a-button>
<a-button size="middle" @click="onInvite">
<div class="flex flex-row justify-center items-center caption capitalize space-x-1">
<MidAccountIcon />
<div>Invite Team</div>
</div>
</a-button>
</div>
</div>
<div class="px-5">
<div class="flex flex-row border-b-1 pb-2 px-2">
<div class="flex flex-row w-4/6 space-x-1 items-center pl-1">
<EmailIcon class="flex text-gray-500 -mt-0.5" />
<div class="text-gray-600 text-xs space-x-1">E-mail</div>
</div>
<div class="flex flex-row justify-center w-1/6 space-x-1 items-center pl-1">
<RolesIcon class="flex text-gray-500 -mt-0.5" />
<div class="text-gray-600 text-xs">Role</div>
</div>
<div class="flex flex-row w-1/6 justify-end items-center pl-1">
<div class="text-gray-600 text-xs">Actions</div>
</div>
</div>
<div v-for="(user, index) in users" :key="index" class="flex flex-row items-center border-b-1 py-2 px-2">
<div class="flex w-4/6 flex-wrap">
{{ user.email }}
</div>
<div class="flex w-1/6 justify-center flex-wrap ml-4">
<div :class="`rounded-full px-2 py-1 bg-[${projectRoleTagColors[user.roles]}]`">
{{ user.roles }}
</div>
</div>
<div class="flex w-1/6 flex-wrap justify-end">
<a-tooltip v-if="user.project_id" placement="bottom">
<template #title>
<span>Edit user</span>
</template>
<a-button type="text" class="!rounded-md" @click="onEdit(user)">
<template #icon>
<MdiEditIcon class="flex mx-auto h-[1rem]" />
</template>
</a-button>
</a-tooltip>
<a-tooltip v-if="!user.project_id" placement="bottom">
<template #title>
<span>Add user to the project</span>
</template>
<a-button type="text" class="!rounded-md" @click="inviteUser(user)">
<template #icon>
<MdiPlusIcon class="flex mx-auto h-[1.1rem]" />
</template>
</a-button>
</a-tooltip>
<a-tooltip v-else placement="bottom">
<template #title>
<span>Remove user from the project</span>
</template>
<a-button type="text" class="!rounded-md" @click="onDelete(user)">
<template #icon>
<MdiDeleteOutlineIcon class="flex mx-auto h-[1.1rem]" />
</template>
</a-button>
</a-tooltip>
<a-dropdown :trigger="['click']" class="flex" placement="bottomRight">
<div class="flex flex-row items-center">
<a-button type="text" class="!px-0">
<div class="flex flex-row items-center h-[1.2rem]">
<KebabIcon />
</div>
</a-button>
</div>
<template #overlay>
<a-menu>
<a-menu-item>
<div class="flex flex-row items-center py-1" @click="resendInvite(user)">
<MdiEmailSendIcon class="flex h-[1rem]" />
<div class="text-xs pl-2">Resend invite email</div>
</div>
</a-menu-item>
<a-menu-item>
<div class="flex flex-row items-center py-1" @click="copyInviteUrl(user)">
<MdiContentCopyIcon class="flex h-[1rem]" />
<div class="text-xs pl-2">Copy invite URL</div>
</div>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</div>
<a-pagination
v-model:current="currentPage"
hide-on-single-page
class="mt-4"
:page-size="currentLimit"
:total="totalRows"
show-less-items
@change="loadUsers"
/>
<FeedbackForm />
</div>
</div>
</template>
<style scoped>
.users-table {
/* equally spaced columns in table */
table-layout: fixed;
width: 100%;
}
</style>

31
packages/nc-gui-v2/components/tabs/auth/user-management/FeedbackForm.vue

@ -0,0 +1,31 @@
<script setup lang="ts">
import CloseIcon from '~icons/material-symbols/close-rounded'
const { feedbackForm } = useGlobal()
</script>
<template>
<div v-if="feedbackForm && !feedbackForm.isHidden" class="nc-feedback-form-wrapper mt-6">
<CloseIcon class="nc-close-icon" @click="feedbackForm.isHidden = true" />
<iframe :src="feedbackForm.url" width="100%" height="500" frameborder="0" marginheight="0" marginwidth="0">Loading </iframe>
</div>
<div v-else />
</template>
<style scoped lang="scss">
.nc-feedback-form-wrapper {
width: 100%;
position: relative;
iframe {
margin: 0 auto;
}
.nc-close-icon {
position: absolute;
top: 5px;
right: 10px;
}
}
</style>

229
packages/nc-gui-v2/components/tabs/auth/user-management/ShareBase.vue

@ -0,0 +1,229 @@
<script setup lang="ts">
import { useToast } from 'vue-toastification'
import { useClipboard } from '@vueuse/core'
import OpenInNewIcon from '~icons/mdi/open-in-new'
import { dashboardUrl } from '~/utils/urlUtils'
import { extractSdkResponseErrorMsg } from '~/utils/errorUtils'
import MdiReload from '~icons/mdi/reload'
import DownIcon from '~icons/ic/round-keyboard-arrow-down'
import ContentCopyIcon from '~icons/mdi/content-copy'
import MdiXmlIcon from '~icons/mdi/xml'
const toast = useToast()
interface ShareBase {
uuid?: string
url?: string
role?: string
}
enum ShareBaseRole {
Editor = 'editor',
Viewer = 'viewer',
}
const { $api, $e } = useNuxtApp()
let base = $ref<null | ShareBase>(null)
const showEditBaseDropdown = $ref(false)
const { project } = useProject()
const { copy } = useClipboard()
const url = $computed(() => (base && base.uuid ? `${dashboardUrl()}#/nc/base/${base.uuid}` : null))
const loadBase = async () => {
try {
if (!project.value.id) return
const res = await $api.project.sharedBaseGet(project.value.id)
base = {
uuid: res.uuid,
url: res.url,
role: res.roles,
}
} catch (e: any) {
console.error(e)
toast.error(await extractSdkResponseErrorMsg(e))
}
}
const createShareBase = async (role = ShareBaseRole.Viewer) => {
try {
if (!project.value.id) return
const res = await $api.project.sharedBaseUpdate(project.value.id, {
roles: role,
})
base = res || {}
base.role = role
} catch (e: any) {
console.error(e)
toast.error(await extractSdkResponseErrorMsg(e))
}
$e('a:shared-base:enable', { role })
}
const disableSharedBase = async () => {
try {
if (!project.value.id) return
await $api.project.sharedBaseDisable(project.value.id)
base = null
} catch (e: any) {
console.error(e)
toast.error(await extractSdkResponseErrorMsg(e))
}
$e('a:shared-base:disable')
}
const recreate = async () => {
try {
if (!project.value.id) return
const sharedBase = await $api.project.sharedBaseCreate(project.value.id, {
roles: base?.role || ShareBaseRole.Viewer,
})
const newBase = sharedBase || {}
base = { ...newBase, role: base?.role }
} catch (e: any) {
console.error(e)
toast.error(await extractSdkResponseErrorMsg(e))
}
$e('a:shared-base:recreate')
}
const copyUrl = async () => {
if (!url) return
copy(url)
toast.success('Copied shareable base url to clipboard!')
$e('c:shared-base:copy-url')
}
const navigateToSharedBase = () => {
if (!url) return
window.open(url, '_blank')
$e('c:shared-base:open-url')
}
const generateEmbeddableIframe = () => {
if (!url) return
copy(`<iframe
class="nc-embed"
src="${url}?embed"
frameborder="0"
width="100%"
height="700"
style="background: transparent; border: 1px solid #ddd"></iframe>`)
toast.success('Copied embeddable html code!')
$e('c:shared-base:copy-embed-frame')
}
onMounted(() => {
if (!base) {
loadBase()
}
})
</script>
<template>
<div class="flex flex-col w-full">
<div class="flex flex-row items-center space-x-0.5 pl-2 h-[0.8rem]">
<OpenInNewIcon />
<div class="text-xs">Shared Base Link</div>
</div>
<div v-if="base?.uuid" class="flex flex-row mt-2 bg-red-50 py-4 mx-1 px-2 items-center rounded-sm w-full justify-between">
<span class="flex text-xs overflow-x-hidden overflow-ellipsis text-gray-700 pl-2">{{ url }}</span>
<div class="flex border-l-1 pt-1 pl-1">
<a-tooltip placement="bottom">
<template #title>
<span>Reload</span>
</template>
<a-button type="text" class="!rounded-md mr-1 -mt-1.5 h-[1rem]" @click="recreate">
<template #icon>
<MdiReload class="flex mx-auto text-gray-600" />
</template>
</a-button>
</a-tooltip>
<a-tooltip placement="bottom">
<template #title>
<span>Copy URL</span>
</template>
<a-button type="text" class="!rounded-md mr-1 -mt-1.5 h-[1rem]" @click="copyUrl">
<template #icon>
<ContentCopyIcon class="flex mx-auto text-gray-600" />
</template>
</a-button>
</a-tooltip>
<a-tooltip placement="bottom">
<template #title>
<span>Open new tab</span>
</template>
<a-button type="text" class="!rounded-md mr-1 -mt-1.5 h-[1rem]" @click="navigateToSharedBase">
<template #icon>
<OpenInNewIcon class="flex mx-auto text-gray-600" />
</template>
</a-button>
</a-tooltip>
<a-tooltip placement="bottom">
<template #title>
<span>Copy embeddable HTML code</span>
</template>
<a-button type="text" class="!rounded-md mr-1 -mt-1.5 h-[1rem]" @click="generateEmbeddableIframe">
<template #icon>
<MdiXmlIcon class="flex mx-auto text-gray-600" />
</template>
</a-button>
</a-tooltip>
</div>
</div>
<div class="flex text-xs text-gray-500 mt-2 justify-start ml-2">Generate publicly shareable readonly base</div>
<div class="mt-4 flex flex-row justify-between mx-1">
<a-dropdown v-model="showEditBaseDropdown" class="flex">
<a-button>
<div class="flex flex-row items-center space-x-2">
<div v-if="base?.uuid">Anyone with the link</div>
<div v-else>Disable shared base</div>
<DownIcon class="h-[1rem]" />
</div>
</a-button>
<template #overlay>
<a-menu>
<a-menu-item>
<div v-if="base?.uuid" @click="disableSharedBase">Disable shared base</div>
<div v-else @click="createShareBase(ShareBaseRole.Viewer)">Anyone with the link</div>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<a-select v-if="base?.uuid" v-model:value="base.role" class="flex">
<template #suffixIcon>
<div class="flex flex-row">
<DownIcon class="text-black -mt-0.5 h-[1rem]" />
</div>
</template>
<a-select-option
v-for="(role, index) in [ShareBaseRole.Editor, ShareBaseRole.Viewer]"
:key="index"
:value="role"
dropdown-class-name="capitalize"
@click="createShareBase(role)"
>
<div class="w-full px-2 capitalize">
{{ role }}
</div>
</a-select-option>
</a-select>
</div>
</div>
</template>
<style scoped></style>

238
packages/nc-gui-v2/components/tabs/auth/user-management/UsersModal.vue

@ -0,0 +1,238 @@
<script setup lang="ts">
import { useToast } from 'vue-toastification'
import { Form } from 'ant-design-vue'
import { useClipboard } from '@vueuse/core'
import ShareBase from './ShareBase.vue'
import SendIcon from '~icons/material-symbols/send-outline'
import CloseIcon from '~icons/material-symbols/close-rounded'
import MidAccountIcon from '~icons/mdi/account-outline'
import ContentCopyIcon from '~icons/mdi/content-copy'
import type { User } from '~/lib/types'
import { ProjectRole } from '~/lib/enums'
import { projectRoleTagColors } from '~/utils/userUtils'
import { extractSdkResponseErrorMsg } from '~/utils/errorUtils'
import { isEmail } from '~/utils/validation'
interface Props {
show: boolean
selectedUser?: User
}
interface Users {
emails?: string
role: ProjectRole
invitationToken?: string
}
const { show, selectedUser } = defineProps<Props>()
const emit = defineEmits(['closed', 'reload'])
const toast = useToast()
const { project } = useProject()
const { $api, $e } = useNuxtApp()
const { copy } = useClipboard()
const usersData = $ref<Users>({ emails: undefined, role: ProjectRole.Guest, invitationToken: undefined })
const formRef = ref()
const useForm = Form.useForm
const validators = computed(() => {
return {
emails: [
{
validator: (rule: any, value: string, callback: (errMsg?: string) => void) => {
if (value.length === 0) {
callback('Email is required')
return
}
const invalidEmails = (value || '').split(/\s*,\s*/).filter((e: string) => !isEmail(e))
if (invalidEmails.length > 0) {
callback(`${invalidEmails.length > 1 ? ' Invalid emails:' : 'Invalid email:'} ${invalidEmails.join(', ')} `)
} else {
callback()
}
},
},
],
}
})
const { validateInfos } = useForm(usersData, validators)
onMounted(() => {
if (!usersData.emails && selectedUser?.email) {
usersData.emails = selectedUser.email
usersData.role = selectedUser.roles
}
})
const saveUser = async () => {
$e('a:user:invite', { role: usersData.role })
if (!project.value.id) return
await formRef.value?.validateFields()
try {
if (selectedUser?.id) {
await $api.auth.projectUserUpdate(project.value.id, selectedUser.id, {
roles: usersData.role,
email: selectedUser.email,
project_id: project.value.id,
projectName: project.value.title,
})
emit('reload')
emit('closed')
} else {
const res = await $api.auth.projectUserAdd(project.value.id, {
roles: usersData.role,
email: usersData.emails,
project_id: project.value.id,
projectName: project.value.title,
})
usersData.invitationToken = res.invite_token
}
toast.success('Successfully updated the user details')
} catch (e: any) {
console.error(e)
toast.error(await extractSdkResponseErrorMsg(e))
}
}
const inviteUrl = $computed(() =>
usersData.invitationToken
? `${location.origin}${location.pathname}#/user/authentication/signup/${usersData.invitationToken}`
: null,
)
const copyUrl = async () => {
if (!inviteUrl) return
copy(inviteUrl)
toast.success('Copied shareable base url to clipboard!')
$e('c:shared-base:copy-url')
}
const clickInviteMore = () => {
$e('c:user:invite-more')
usersData.invitationToken = undefined
usersData.role = ProjectRole.Guest
usersData.emails = undefined
}
</script>
<template>
<a-modal :footer="null" centered :visible="show" :closable="false" width="max(50vw, 44rem)" @cancel="emit('closed')">
<div class="flex flex-col">
<div class="flex flex-row justify-between items-center pb-1.5 mb-2 border-b-1 w-full">
<a-typography-title class="select-none" :level="4"> Share: {{ project.title }} </a-typography-title>
<a-button type="text" class="!rounded-md mr-1 -mt-1.5" @click="emit('closed')">
<template #icon>
<CloseIcon class="flex mx-auto" />
</template>
</a-button>
</div>
<div class="px-2 mt-1.5">
<template v-if="usersData.invitationToken">
<div class="flex flex-col mt-1 border-b-1 pb-5">
<div class="flex flex-row items-center pl-1.5 pb-1 h-[1.1rem]">
<MidAccountIcon />
<div class="text-xs ml-0.5 mt-0.5">Copy Invite Token</div>
</div>
<a-alert class="mt-1" type="success" show-icon>
<template #message>
<div class="flex flex-row w-full justify-between items-center">
<div class="flex pl-2 text-green-700">
{{ inviteUrl }}
</div>
<a-button type="text" class="!rounded-md mr-1" @click="copyUrl">
<template #icon>
<ContentCopyIcon class="flex mx-auto text-green-700 h-[1rem]" />
</template>
</a-button>
</div>
</template>
</a-alert>
<div class="flex text-xs text-gray-500 mt-2 justify-start ml-2">
Looks like you have not configured mailer yet! Please copy above invite link and send it to
{{ usersData.invitationToken && usersData.emails }}
</div>
<div class="flex flex-row justify-start mt-4 ml-2">
<a-button size="small" outlined @click="clickInviteMore">
<div class="flex flex-row justify-center items-center space-x-0.5">
<SendIcon class="flex mx-auto text-gray-600 h-[0.8rem]" />
<div class="text-xs text-gray-600">Invite more</div>
</div>
</a-button>
</div>
</div>
</template>
<div v-else class="flex flex-col pb-4">
<div class="flex flex-row items-center pl-2 pb-1 h-[1rem]">
<MidAccountIcon />
<div class="text-xs ml-0.5 mt-0.5">{{ selectedUser ? 'Edit User' : 'Invite Team' }}</div>
</div>
<div class="border-1 py-3 px-4 rounded-md mt-1">
<a-form
ref="formRef"
:validate-on-rule-change="false"
:model="usersData"
validate-trigger="onBlur"
@finish="saveUser"
>
<div class="flex flex-row space-x-4">
<div class="flex flex-col w-3/4">
<a-form-item
v-bind="validateInfos.emails"
validate-trigger="onBlur"
name="emails"
:rules="[{ required: true, message: 'Please input email' }]"
>
<div class="ml-1 mb-1 text-xs text-gray-500">Email:</div>
<a-input
v-model:value="usersData.emails"
validate-trigger="onBlur"
placeholder="Email"
:disabled="!!selectedUser"
/>
</a-form-item>
</div>
<div class="flex flex-col w-1/4">
<a-form-item name="role" :rules="[{ required: true, message: 'Role required' }]">
<div class="ml-1 mb-1 text-xs text-gray-500">Select User Role:</div>
<a-select v-model:value="usersData.role">
<a-select-option v-for="(role, index) in Object.keys(projectRoleTagColors)" :key="index" :value="role">
<div class="flex flex-row h-full justify-start items-center">
<div :class="`px-2 py-1 flex rounded-full text-xs bg-[${projectRoleTagColors[role]}]`">
{{ role }}
</div>
</div>
</a-select-option>
</a-select>
</a-form-item>
</div>
</div>
<div class="flex flex-row justify-center">
<a-button type="primary" html-type="submit">
<div v-if="selectedUser">Save</div>
<div v-else class="flex flex-row justify-center items-center space-x-1.5">
<SendIcon class="flex h-[0.8rem]" />
<div>Invite</div>
</div>
</a-button>
</div>
</a-form>
</div>
</div>
<div class="flex mt-4">
<ShareBase />
</div>
</div>
</div>
</a-modal>
</template>
<style scoped></style>

2
packages/nc-gui-v2/components/virtual-cell/BelongsTo.vue

@ -2,7 +2,7 @@
import type { ColumnType } from 'nocodb-sdk' import type { ColumnType } from 'nocodb-sdk'
import ItemChip from './components/ItemChip.vue' import ItemChip from './components/ItemChip.vue'
import { ColumnInj } from '~/context' import { ColumnInj } from '~/context'
import useBelongsTo from '~/composables/useBelongsTo' import { useBelongsTo } from '#imports'
const column = inject(ColumnInj) const column = inject(ColumnInj)
const value = inject('value') const value = inject('value')

42
packages/nc-gui-v2/components/virtual-cell/Formula.vue

@ -5,6 +5,7 @@ import { handleTZ } from '~/utils/dateTimeUtils'
import { replaceUrlsWithLink } from '~/utils/urlUtils' import { replaceUrlsWithLink } from '~/utils/urlUtils'
const column = inject(ColumnInj) const column = inject(ColumnInj)
const value = inject('value') const value = inject('value')
const { isPg } = useProject() const { isPg } = useProject()
@ -18,32 +19,23 @@ const showEditFormulaWarningMessage = () => {
}, 3000) }, 3000)
} }
const result = computed(() => { const result = computed(() => (isPg ? handleTZ(value) : value))
if (isPg) {
return handleTZ(value)
}
return value
})
const urls = computed(() => { const urls = computed(() => replaceUrlsWithLink(result.value))
return replaceUrlsWithLink(result.value)
})
</script> </script>
<template> <template>
<div> <div>
<v-tooltip v-if="column && column.colOptions && column.colOptions.error" bottom color="error"> <a-tooltip v-if="column && column.colOptions && column.colOptions.error" placement="bottom" class="text-orange-700">
<template #activator="{ on }"> <template #title>
<span class="caption" v-on="on">ERR<span class="error--text">!</span></span> <span class="font-bold">{{ column.colOptions.error }}</span>
</template> </template>
<span class="font-weight-bold">{{ column.colOptions.error }}</span> <span>ERR!</span>
</v-tooltip> </a-tooltip>
<div class="formula-cell-wrapper" @dblclick="showEditFormulaWarningMessage"> <div class="pa-2" @dblclick="showEditFormulaWarningMessage">
<div v-if="urls" v-html="urls" /> <div v-if="urls" v-html="urls" />
<div v-else> <div v-else>{{ result }}</div>
{{ result }} <div v-if="showEditFormulaWarning" class="text-left text-wrap mt-2 text-[#e65100]">
</div>
<div v-if="showEditFormulaWarning" class="edit-warning">
<!-- TODO: i18n --> <!-- TODO: i18n -->
Warning: Formula fields should be configured in the field menu dropdown. Warning: Formula fields should be configured in the field menu dropdown.
</div> </div>
@ -51,14 +43,4 @@ const urls = computed(() => {
</div> </div>
</template> </template>
<style scoped> <style scoped></style>
.formula-cell-wrapper {
padding: 10px;
}
.edit-warning {
text-align: left;
margin-top: 10px;
color: #e65100;
}
</style>

3
packages/nc-gui-v2/components/virtual-cell/HasMany.vue

@ -2,7 +2,8 @@
import type { ColumnType } from 'nocodb-sdk' import type { ColumnType } from 'nocodb-sdk'
import ItemChip from './components/ItemChip.vue' import ItemChip from './components/ItemChip.vue'
import { ColumnInj } from '~/context' import { ColumnInj } from '~/context'
import useHasMany from '~/composables/useHasMany' import { useHasMany } from '#imports'
const column = inject(ColumnInj) const column = inject(ColumnInj)
const value = inject('value') const value = inject('value')
const active = false const active = false

3
packages/nc-gui-v2/components/virtual-cell/ManyToMany.vue

@ -2,7 +2,8 @@
import type { ColumnType } from 'nocodb-sdk' import type { ColumnType } from 'nocodb-sdk'
import ItemChip from './components/ItemChip.vue' import ItemChip from './components/ItemChip.vue'
import { ColumnInj } from '~/context' import { ColumnInj } from '~/context'
import useManyToMany from '~/composables/useManyToMany' import { useManyToMany } from '#imports'
const column = inject(ColumnInj) const column = inject(ColumnInj)
const value = inject('value') const value = inject('value')
const active = false const active = false

2
packages/nc-gui-v2/components/virtual-cell/Rollup.vue

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
const { value } = defineProps<{ value: any }>() const { value } = defineProps<{ value?: any }>()
</script> </script>
<template> <template>

10
packages/nc-gui-v2/components/virtual-cell/components/ItemChip.vue

@ -14,16 +14,10 @@ const readonly = inject(ReadonlyInj, false)
<template> <template>
<v-chip class="chip" :class="{ active }" small text-color="textColor"> <v-chip class="chip" :class="{ active }" small text-color="textColor">
<!-- <span class="name">{{ value }}</span>
:color="isDark ? '' : 'primary lighten-5'"
@click="!readonly && active && $emit('edit', item)" -->
<span class="name" :title="value">{{ value }}</span>
<!-- && _isUIAllowed('xcDatatableEditable') -->
<div v-show="active" v-if="!readonly" class="mr-n1 ml-2"> <div v-show="active" v-if="!readonly" class="mr-n1 ml-2">
<MdiCloseThickIcon class="unlink-icon"> <MdiCloseThickIcon class="unlink-icon" />
<!-- @click.stop="$emit('unlink', item)" -->
</MdiCloseThickIcon>
</div> </div>
</v-chip> </v-chip>
</template> </template>

2
packages/nc-gui-v2/components/virtual-cell/components/ListChildItems.vue

@ -148,7 +148,7 @@ export default {
:tooltip="`Unlink this '${meta.title}' from '${parentMeta.title}'`" :tooltip="`Unlink this '${meta.title}' from '${parentMeta.title}'`"
:color="['error', 'grey']" :color="['error', 'grey']"
small small
icon.class="mr-1 mt-n1" class="mr-1 mt-n1"
@click.stop="$emit('unlink', ch, i)" @click.stop="$emit('unlink', ch, i)"
> >
mdi-link-variant-remove mdi-link-variant-remove

2
packages/nc-gui-v2/components/virtual-cell/components/ListItems.vue

@ -122,7 +122,7 @@ export default {
@keydown.enter="loadData" @keydown.enter="loadData"
> >
<template #append> <template #append>
<x-icon tooltip="Apply filter" small icon.class="mt-1" @click="loadData"> mdi-keyboard-return </x-icon> <x-icon tooltip="Apply filter" small icon class="mt-1" @click="loadData"> mdi-keyboard-return </x-icon>
</template> </template>
</v-text-field> </v-text-field>
<v-spacer /> <v-spacer />

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

@ -0,0 +1,23 @@
export * from './useApi'
export * from './useGlobal'
export * from './useInjectionState'
export * from './useUIPermission'
export * from './useAttachment'
export * from './useBelongsTo'
export * from './useColors'
export * from './useColumn'
export * from './useGridViewColumnWidth'
export * from './useHasMany'
export * from './useManyToMany'
export * from './useMetas'
export * from './useProject'
export * from './useTable'
export * from './useTabs'
export * from './useViewColumns'
export * from './useViewData'
export * from './useViewFilters'
export * from './useViews'
export * from './useViewSorts'
export * from './useVirtualCell'
export * from './useColumnCreateStore'
export * from './useSmartsheetStore'

148
packages/nc-gui-v2/composables/useApi/index.ts

@ -0,0 +1,148 @@
import type { AxiosError, AxiosResponse } from 'axios'
import { Api } from 'nocodb-sdk'
import type { Ref } from 'vue'
import { addAxiosInterceptors } from './interceptors'
import type { CreateApiOptions, UseApiProps, UseApiReturn } from './types'
import { createEventHook, ref, unref, useCounter, useGlobal, useNuxtApp } from '#imports'
export function createApiInstance<SecurityDataType = any>(options: CreateApiOptions = {}): Api<SecurityDataType> {
return addAxiosInterceptors(
new Api<SecurityDataType>({
baseURL: options.baseURL ?? 'http://localhost:8080',
}),
)
}
/**
* Api composable that provides loading, error and response refs, as well as event hooks for error and response.
*
* You can use this composable to generate a fresh api instance with its own loading and error refs.
*
* Any request called by useApi will be pushed into the global requests counter which toggles the global loading state.
*
* @example
* ```js
* const { api, isLoading, error, response, onError, onResponse } = useApi()
*
* const onSignIn = async () => {
* const { token } = await api.auth.signIn(form)
* }
*/
export function useApi<Data = any, RequestConfig = any>({
useGlobalInstance = false,
apiOptions,
axiosConfig,
}: UseApiProps<Data> = {}): UseApiReturn<Data, RequestConfig> {
const state = useGlobal()
/**
* Local state of running requests, do not confuse with global state of running requests
* This state is only counting requests made by this instance of `useApi` and not by other instances.
*/
const { count, inc, dec } = useCounter(0)
/** is request loading */
const isLoading = ref(false)
/** latest request error */
const error = ref(null)
/** latest request response */
const response = ref<unknown | null>(null)
const errorHook = createEventHook<AxiosError<Data, RequestConfig>>()
const responseHook = createEventHook<AxiosResponse<Data, RequestConfig>>()
/** global api instance */
const $api = useNuxtApp().$api
/** api instance - with interceptors for token refresh already bound */
const api = useGlobalInstance && !!$api ? $api : createApiInstance(apiOptions)
/** set loading to true and increment local and global request counter */
function onRequestStart() {
isLoading.value = true
/** local count */
inc()
/** global count */
state.runningRequests.inc()
}
/** decrement local and global request counter and check if we can stop loading */
function onRequestFinish() {
/** local count */
dec()
/** global count */
state.runningRequests.dec()
/** try to stop loading */
stopLoading()
}
/** set loading state to false *only* if no request is still running */
function stopLoading() {
if (count.value === 0) {
isLoading.value = false
}
}
/** reset response and error refs */
function reset() {
error.value = null
response.value = null
}
api.instance.interceptors.request.use(
(config) => {
reset()
onRequestStart()
return {
...config,
...unref(axiosConfig),
}
},
(requestError) => {
errorHook.trigger(requestError)
error.value = requestError
response.value = null
onRequestFinish()
return Promise.reject(requestError)
},
)
api.instance.interceptors.response.use(
(apiResponse) => {
responseHook.trigger(apiResponse as AxiosResponse<Data, RequestConfig>)
response.value = apiResponse
onRequestFinish()
return Promise.resolve(apiResponse)
},
(apiError) => {
errorHook.trigger(apiError)
error.value = apiError
onRequestFinish()
return Promise.reject(apiError)
},
)
return {
api,
isLoading,
response: response as Ref<AxiosResponse<Data, RequestConfig>>,
error,
onError: errorHook.on,
onResponse: responseHook.on,
}
}

80
packages/nc-gui-v2/composables/useApi/interceptors.ts

@ -0,0 +1,80 @@
import type { Api } from 'nocodb-sdk'
import { navigateTo, useGlobal, useRoute, useRouter } from '#imports'
const DbNotFoundMsg = 'Database config not found'
export function addAxiosInterceptors(api: Api<any>) {
const state = useGlobal()
const router = useRouter()
const route = useRoute()
api.instance.interceptors.request.use((config) => {
config.headers['xc-gui'] = 'true'
if (state.token.value) config.headers['xc-auth'] = state.token.value
if (!config.url?.endsWith('/user/me') && !config.url?.endsWith('/admin/roles')) {
// config.headers['xc-preview'] = store.state.users.previewAs
}
if (!config.url?.endsWith('/user/me') && !config.url?.endsWith('/admin/roles')) {
if (route && route.params && route.params.shared_base_id) config.headers['xc-shared-base-id'] = route.params.shared_base_id
}
return config
})
// Return a successful response back to the calling service
api.instance.interceptors.response.use(
(response) => response,
// Handle Error
(error) => {
if (error.response && error.response.data && error.response.data.msg === DbNotFoundMsg) return router.replace('/project/0')
// Return any error which is not due to authentication back to the calling service
if (!error.response || error.response.status !== 401) {
return Promise.reject(error)
}
// Logout user if token refresh didn't work or user is disabled
if (error.config.url === '/auth/refresh-token') {
state.signOut()
return Promise.reject(error)
}
// Try request again with new token
return api.instance
.post('/auth/refresh-token', null, {
withCredentials: true,
})
.then((token) => {
// New request with new token
const config = error.config
config.headers['xc-auth'] = token.data.token
state.signIn(token.data.token)
return new Promise((resolve, reject) => {
api.instance
.request(config)
.then((response) => {
resolve(response)
})
.catch((error) => {
reject(error)
})
})
})
.catch(async (error) => {
state.signOut()
// todo: handle new user
navigateTo('/signIn')
return Promise.reject(error)
})
},
)
return api
}

26
packages/nc-gui-v2/composables/useApi/types.ts

@ -0,0 +1,26 @@
import type { Api } from 'nocodb-sdk'
import type { Ref } from 'vue'
import type { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios'
import type { EventHook, MaybeRef } from '@vueuse/core'
export interface UseApiReturn<D = any, R = any> {
api: Api<any>
isLoading: Ref<boolean>
error: Ref<AxiosError<D, R> | null>
response: Ref<AxiosResponse<D, R> | null>
onError: EventHook<AxiosError<D, R>>['on']
onResponse: EventHook<AxiosResponse<D, R>>['on']
}
/** {@link Api} options */
export interface CreateApiOptions {
baseURL?: string
}
export interface UseApiProps<D = any> {
/** additional axios config for requests */
axiosConfig?: MaybeRef<AxiosRequestConfig<D>>
/** {@link Api} options */
apiOptions?: CreateApiOptions
useGlobalInstance?: boolean
}

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

@ -1,5 +1,5 @@
// todo: // todo: implement useAttachment
export default () => { export function useAttachment() {
const localFilesState = reactive([]) const localFilesState = reactive([])
const attachments = ref([]) const attachments = ref([])

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

@ -1,15 +1,14 @@
import type { ColumnType, TableType } from 'nocodb-sdk' import type { ColumnType, TableType } from 'nocodb-sdk'
import type LinkToAnotherRecordColumn from '../../nocodb/src/lib/models/LinkToAnotherRecordColumn' import { useMetas } from './useMetas'
import useMetas from '~/composables/useMetas'
export default function (column: ColumnType) { export function useBelongsTo(column: ColumnType) {
const { metas, getMeta } = useMetas() const { metas, getMeta } = useMetas()
const parentMeta = computed<TableType>(() => { const parentMeta = computed<TableType>(() => {
return metas.value?.[(column.colOptions as LinkToAnotherRecordColumn)?.fk_related_model_id as string] return metas.value?.[(column.colOptions as any)?.fk_related_model_id as string]
}) })
const loadParentMeta = async () => { const loadParentMeta = async () => {
await getMeta((column.colOptions as LinkToAnotherRecordColumn)?.fk_related_model_id as string) await getMeta((column.colOptions as any)?.fk_related_model_id as string)
} }
const primaryValueProp = computed(() => { const primaryValueProp = computed(() => {

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

@ -1,9 +1,9 @@
import type { MaybeRef } from '@vueuse/core' import type { MaybeRef } from '@vueuse/core'
import { computed, effectScope, tryOnScopeDispose, unref, watch, watchEffect } from '#build/imports' import { computed, effectScope, tryOnScopeDispose, unref, watch, watchEffect } from '#build/imports'
import { useNuxtApp } from '#app' import { useNuxtApp } from '#app'
import theme from '~/utils/colorsUtils' import { theme } from '~/utils'
export default function useColors(darkMode?: MaybeRef<boolean>) { export function useColors(darkMode?: MaybeRef<boolean>) {
const scope = effectScope() const scope = effectScope()
let mode = $ref(unref(darkMode)) let mode = $ref(unref(darkMode))

6
packages/nc-gui-v2/composables/useColumn.ts

@ -2,7 +2,7 @@ import type { ColumnType } from 'nocodb-sdk'
import { SqlUiFactory, UITypes, isVirtualCol } from 'nocodb-sdk' import { SqlUiFactory, UITypes, isVirtualCol } from 'nocodb-sdk'
import { useProject } from '#imports' import { useProject } from '#imports'
export default (column: ColumnType) => { export function useColumn(column: ColumnType) {
const { project } = useProject() const { project } = useProject()
const uiDatatype: UITypes = (column && column.uidt) as UITypes const uiDatatype: UITypes = (column && column.uidt) as UITypes
@ -17,6 +17,7 @@ export default (column: ColumnType) => {
const isInt = abstractType === 'integer' const isInt = abstractType === 'integer'
const isFloat = abstractType === 'float' const isFloat = abstractType === 'float'
const isDate = abstractType === 'date' || uiDatatype === 'Date' const isDate = abstractType === 'date' || uiDatatype === 'Date'
const isYear = abstractType === 'year' || uiDatatype === 'Year'
const isTime = abstractType === 'time' || uiDatatype === 'Time' const isTime = abstractType === 'time' || uiDatatype === 'Time'
const isDateTime = abstractType === 'datetime' || uiDatatype === 'DateTime' const isDateTime = abstractType === 'datetime' || uiDatatype === 'DateTime'
const isJSON = uiDatatype === 'JSON' const isJSON = uiDatatype === 'JSON'
@ -30,6 +31,7 @@ export default (column: ColumnType) => {
const isRating = uiDatatype === UITypes.Rating const isRating = uiDatatype === UITypes.Rating
const isCurrency = uiDatatype === 'Currency' const isCurrency = uiDatatype === 'Currency'
const isDuration = uiDatatype === UITypes.Duration const isDuration = uiDatatype === UITypes.Duration
const isPercent = uiDatatype === UITypes.Percent
const isAutoSaved = [ const isAutoSaved = [
UITypes.SingleLineText, UITypes.SingleLineText,
UITypes.LongText, UITypes.LongText,
@ -55,6 +57,7 @@ export default (column: ColumnType) => {
isInt, isInt,
isFloat, isFloat,
isDate, isDate,
isYear,
isTime, isTime,
isDateTime, isDateTime,
isJSON, isJSON,
@ -70,5 +73,6 @@ export default (column: ColumnType) => {
isManualSaved, isManualSaved,
isSingleSelect, isSingleSelect,
isMultiSelect, isMultiSelect,
isPercent,
} }
} }

223
packages/nc-gui-v2/composables/useColumnCreateStore.ts

@ -0,0 +1,223 @@
import { createInjectionState } from '@vueuse/core'
import { Form } from 'ant-design-vue'
import type { ColumnType, TableType } from 'nocodb-sdk'
import { UITypes } from 'nocodb-sdk'
import type { Ref } from 'vue'
import { useToast } from 'vue-toastification'
import { useColumn } from './useColumn'
import { computed } from '#imports'
import { useNuxtApp } from '#app'
import { extractSdkResponseErrorMsg } from '~/utils/errorUtils'
const useForm = Form.useForm
// enum ColumnAlterType {
// NEW=4,
// EDIT=2,
// RENAME=8,
// DELETE=0,
// }
const columnToValidate = [UITypes.Email, UITypes.URL, UITypes.PhoneNumber]
const [useProvideColumnCreateStore, useColumnCreateStore] = createInjectionState((meta: Ref<TableType>, column?: ColumnType) => {
const { sqlUi } = useProject()
const { $api } = useNuxtApp()
const toast = useToast()
const idType = null
// state
// todo: give proper type - ColumnType
const formState = ref<Partial<Record<string, any>>>({
title: 'title',
uidt: UITypes.SingleLineText,
...(column || {}),
})
const additionalValidations = ref<Record<string, any>>({})
const validators = computed(() => {
return {
column_name: [
{
required: true,
message: 'Column name is required',
},
// validation for unique column name
{
validator: (rule: any, value: any) => {
return new Promise<void>((resolve, reject) => {
if (
meta.value?.columns?.some(
(c) =>
c.id !== formState.value.id && // ignore current column
// compare against column_name and title
((value || '').toLowerCase() === (c.column_name || '').toLowerCase() ||
(value || '').toLowerCase() === (c.title || '').toLowerCase()),
)
) {
return reject(new Error('Duplicate column name'))
}
resolve()
})
},
},
],
uidt: [
{
required: true,
message: 'UI Datatype is required',
},
],
...(additionalValidations?.value || {}),
}
})
const { resetFields, validate, validateInfos } = useForm(formState, validators)
// actions
const generateNewColumnMeta = () => {
formState.value = sqlUi.value.getNewColumn((meta.value.columns?.length || 0) + 1)
}
const setAdditionalValidations = (validations: Record<string, any>) => {
additionalValidations.value = validations
}
const onUidtOrIdTypeChange = () => {
const { isCurrency } = useColumn(formState.value as ColumnType)
const colProp = sqlUi?.value.getDataTypeForUiType(formState?.value as any, idType as any)
formState.value = {
...formState.value,
meta: null,
rqd: false,
pk: false,
ai: false,
cdf: null,
un: false,
dtx: 'specificType',
...colProp,
}
formState.value.dtxp = sqlUi.value.getDefaultLengthForDatatype(formState.value.dt)
formState.value.dtxs = sqlUi.value.getDefaultScaleForDatatype(formState.value.dt)
const selectTypes = [UITypes.MultiSelect, UITypes.SingleSelect]
if (column && selectTypes.includes(formState.value.uidt) && selectTypes.includes(column.uidt as UITypes)) {
formState.value.dtxp = column.dtxp
}
if (columnToValidate.includes(formState.value.uidt)) {
formState.value.meta = {
validate: formState.value.meta && formState.value.meta.validate,
}
}
if (isCurrency) {
if (column?.uidt === UITypes.Currency) {
formState.value.dtxp = column.dtxp
formState.value.dtxs = column.dtxs
} else {
formState.value.dtxp = 19
formState.value.dtxs = 2
}
}
formState.value.altered = formState.value.altered || 2
}
const onDataTypeChange = () => {
const { isCurrency } = useColumn(formState.value as ColumnType)
formState.value.rqd = false
if (formState.value.uidt !== UITypes.ID) {
formState.value.primaryKey = false
}
formState.value.ai = false
formState.value.cdf = null
formState.value.un = false
formState.value.dtxp = sqlUi.value.getDefaultLengthForDatatype(formState.value.dt)
formState.value.dtxs = sqlUi.value.getDefaultScaleForDatatype(formState.value.dt)
formState.value.dtx = 'specificType'
const selectTypes = [UITypes.MultiSelect, UITypes.SingleSelect]
if (column && selectTypes.includes(formState.value.uidt) && selectTypes.includes(column.uidt as UITypes)) {
formState.value.dtxp = column.dtxp
}
if (isCurrency) {
if (column?.uidt === UITypes.Currency) {
formState.value.dtxp = column.dtxp
formState.value.dtxs = column.dtxs
} else {
formState.value.dtxp = 19
formState.value.dtxs = 2
}
}
// this.$set(formState.value, 'uidt', sqlUi.value.getUIType(formState.value));
formState.value.altered = formState.value.altered || 2
}
const onAlter = (val = 2, cdf = false) => {
formState.value.altered = formState.value.altered || val
if (cdf) formState.value.cdf = formState.value.cdf || null
}
const addOrUpdate = async (onSuccess: () => {}) => {
if (!(await validate())) return
formState.value.table_name = meta.value.table_name
formState.value.title = formState.value.column_name
try {
if (column) {
await $api.dbTableColumn.update(column.id as string, formState.value)
toast.success('Column updated')
} else {
// todo : set additional meta for auto generated string id
if (formState.value.uidt === UITypes.ID) {
// based on id column type set autogenerated meta prop
// if (isAutoGenId) {
// this.newColumn.meta = {
// ag: 'nc',
// };
// }
}
await $api.dbTableColumn.create(meta.value.id as string, formState.value)
toast.success('Column created')
}
onSuccess()
} catch (e: any) {
toast.error(await extractSdkResponseErrorMsg(e))
}
}
return {
formState,
resetFields,
validate,
validateInfos,
setAdditionalValidations,
onUidtOrIdTypeChange,
sqlUi,
onDataTypeChange,
onAlter,
addOrUpdate,
generateNewColumnMeta,
isEdit: !!column?.id,
}
})
export { useProvideColumnCreateStore }
export function useColumnCreateStoreOrThrow() {
const columnCreateStore = useColumnCreateStore()
if (columnCreateStore == null) throw new Error('Please call `useColumnCreateStore` on the appropriate parent component')
return columnCreateStore
}

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 }
}

54
packages/nc-gui-v2/composables/useGlobal/actions.ts

@ -0,0 +1,54 @@
import { notification } from 'ant-design-vue'
import type { Actions, State } from './types'
import { useNuxtApp } from '#imports'
export function useGlobalActions(state: State): Actions {
// todo replace with just `new Api()`? Would solve recursion issues
/** we have to use the globally injected api instance, otherwise we run into recursion as `useApi` calls `useGlobal` */
const { $api } = useNuxtApp()
/** Sign out by deleting the token from localStorage */
const signOut: Actions['signOut'] = () => {
state.token.value = null
state.user.value = null
}
/** Sign in by setting the token in localStorage */
const signIn: Actions['signIn'] = async (newToken) => {
state.token.value = newToken
if (state.jwtPayload.value) {
state.user.value = {
id: state.jwtPayload.value.id,
email: state.jwtPayload.value.email,
firstname: state.jwtPayload.value.firstname,
lastname: state.jwtPayload.value.lastname,
roles: state.jwtPayload.value.roles,
}
}
}
/** manually try to refresh token */
const refreshToken = async () => {
$api.instance
.post('/auth/refresh-token', null, {
withCredentials: true,
})
.then((response) => {
if (response.data?.token) {
signIn(response.data.token)
}
})
.catch((err) => {
notification.error({
// todo: add translation
message: err.message || 'You have been signed out.',
})
console.error(err)
signOut()
})
}
return { signIn, signOut, refreshToken }
}

25
packages/nc-gui-v2/composables/useGlobal/getters.ts

@ -0,0 +1,25 @@
import type { Getters, State } from './types'
import { computed } from '#imports'
export function useGlobalGetters(state: State): Getters {
/** Verify that a user is signed in by checking if token exists and is not expired */
const signedIn: Getters['signedIn'] = computed(
() =>
!!(
!!state.token &&
state.token.value !== '' &&
state.jwtPayload.value &&
state.jwtPayload.value.exp &&
state.jwtPayload.value.exp > state.timestamp.value / 1000
),
)
/** global loading state */
let loading = $ref(false)
const isLoading = computed({
get: () => state.runningRequests.count.value > 0 || loading,
set: (_loading) => (loading = _loading),
})
return { signedIn, isLoading }
}

71
packages/nc-gui-v2/composables/useGlobal/index.ts

@ -0,0 +1,71 @@
import { useGlobalState } from './state'
import { useGlobalActions } from './actions'
import type { UseGlobalReturn } from './types'
import { useGlobalGetters } from './getters'
import { useNuxtApp, watch } from '#imports'
/**
* Global state is injected by {@link import('~/plugins/state') state} plugin into our nuxt app (available as `$state`).
* You can still call `useGlobal` to receive the `$state` object and access the global state.
* If it's not available yet, a new global state object is created and injected into the nuxt app.
*
* Part of the state is stored in {@link WindowLocalStorage localStorage}, so it will be available even if the user closes the browser tab.
* Check the {@link StoredState StoredState} type for more information.
*
* @example
* ```js
* import { useNuxtApp } from '#app'
*
* const { $state } = useNuxtApp()
*
* const token = $state.token.value
* const user = $state.user.value
* ```
*
* @example
* ```js
* import { useGlobal } from '#imports'
*
* const globalState = useGlobal()
*
* cont token = globalState.token.value
* const user = globalState.user.value
*
* console.log(state.isLoading.value) // isLoading = true if any api request is still running
* ```
*/
export const useGlobal = (): UseGlobalReturn => {
const { $state, provide } = useNuxtApp()
/** If state already exists, return it */
if (typeof $state !== 'undefined') return $state
const state = useGlobalState()
const getters = useGlobalGetters(state)
const actions = useGlobalActions(state)
/** try to refresh token before expiry (5 min before expiry) */
watch(
() =>
!!(
state.jwtPayload.value &&
state.jwtPayload.value.exp &&
state.jwtPayload.value.exp - 5 * 60 < state.timestamp.value / 1000
),
async (expiring) => {
if (getters.signedIn.value && state.jwtPayload.value && expiring) {
await actions.refreshToken()
}
},
{ immediate: true },
)
const globalState = { ...state, ...getters, ...actions } as UseGlobalReturn
/** provide a fresh state instance into nuxt app */
provide('state', globalState)
return globalState
}

95
packages/nc-gui-v2/composables/useGlobal/state.ts

@ -0,0 +1,95 @@
import { usePreferredLanguages, useStorage } from '@vueuse/core'
import { useJwt } from '@vueuse/integrations/useJwt'
import type { JwtPayload } from 'jwt-decode'
import type { State, StoredState } from './types'
import { computed, ref, toRefs, useCounter, useNuxtApp, useTimestamp } from '#imports'
import type { User } from '~/lib'
export function useGlobalState(storageKey = 'nocodb-gui-v2'): State {
/** get the preferred languages of a user, according to browser settings */
const preferredLanguages = $(usePreferredLanguages())
/** todo: reimplement; get the preferred dark mode setting, according to browser settings */
// const prefersDarkMode = $(usePreferredDark())
const prefersDarkMode = false
/** reactive timestamp to check token expiry against */
const timestamp = useTimestamp({ immediate: true, interval: 100 })
const {
vueApp: { i18n },
} = useNuxtApp()
/**
* Set initial language based on browser settings.
* If the user has not set a preferred language, we fallback to 'en'.
* If the user has set a preferred language, we try to find a matching locale in the available locales.
*/
const preferredLanguage = preferredLanguages.reduce((locale, language) => {
/** split language to language and code, e.g. en-GB -> [en, GB] */
const [lang, code] = language.split(/[_-]/)
/** find all locales that match the language */
let availableLocales = i18n.availableLocales.filter((locale) => locale.startsWith(lang))
/** If we can match more than one locale, we check if the code of the language matches as well */
if (availableLocales.length > 1) {
availableLocales = availableLocales.filter((locale) => locale.endsWith(code))
}
/** if there are still multiple locales, pick the first one */
const availableLocale = availableLocales[0]
/** if we found a matching locale, return it */
if (availableLocale) locale = availableLocale
return locale
}, 'en' /** fallback locale */)
/** State */
const initialState: StoredState = {
token: null,
user: null,
lang: preferredLanguage,
darkMode: prefersDarkMode,
feedbackForm: {
url: 'https://docs.google.com/forms/d/e/1FAIpQLSeTlAfZjszgr53lArz3NvUEnJGOT9JtG9NAU5d0oQwunDS2Pw/viewform?embedded=true',
createdAt: new Date('2020-01-01T00:00:00.000Z').toISOString(),
isHidden: false,
},
}
/** saves a reactive state, any change to these values will write/delete to localStorage */
const storage = useStorage<StoredState>(storageKey, initialState)
/** force turn off of dark mode, regardless of previously stored settings */
storage.value.darkMode = false
/** current token ref, used by `useJwt` to reactively parse our token payload */
const token = computed({
get: () => storage.value.token || '',
set: (val) => (storage.value.token = val),
})
/** reactive token payload */
const { payload } = useJwt<JwtPayload & User>(token)
/** is sidebar open */
const sidebarOpen = ref(false)
/** currently running requests */
const runningRequests = useCounter()
/** global error */
const error = ref()
return {
...toRefs(storage.value),
storage,
token,
jwtPayload: payload,
sidebarOpen,
timestamp,
runningRequests,
error,
}
}

45
packages/nc-gui-v2/composables/useGlobal/types.ts

@ -0,0 +1,45 @@
import type { ComputedRef, Ref, ToRefs } from 'vue'
import type { WritableComputedRef } from '@vue/reactivity'
import type { JwtPayload } from 'jwt-decode'
import type { User } from '~/lib'
import type { useCounter } from '#imports'
export interface FeedbackForm {
url: string
createdAt: string
isHidden: boolean
lastFormPollDate?: string
}
export interface StoredState {
token: string | null
user: User | null
lang: string
darkMode: boolean
feedbackForm: FeedbackForm
}
export type State = ToRefs<Omit<StoredState, 'token'>> & {
storage: Ref<StoredState>
token: WritableComputedRef<StoredState['token']>
jwtPayload: ComputedRef<(JwtPayload & User) | null>
sidebarOpen: Ref<boolean>
timestamp: Ref<number>
runningRequests: ReturnType<typeof useCounter>
error: Ref<any>
}
export interface Getters {
signedIn: ComputedRef<boolean>
isLoading: WritableComputedRef<boolean>
}
export interface Actions {
signOut: () => void
signIn: (token: string) => void
refreshToken: () => void
}
export type ReadonlyState = Readonly<Pick<State, 'token' | 'user'>> & Omit<State, 'token' | 'user'>
export type UseGlobalReturn = Getters & Actions & ReadonlyState

148
packages/nc-gui-v2/composables/useGlobalState.ts

@ -1,148 +0,0 @@
import { breakpointsTailwind, usePreferredLanguages, useStorage } from '@vueuse/core'
import { useJwt } from '@vueuse/integrations/useJwt'
import type { JwtPayload } from 'jwt-decode'
import { computed, ref, toRefs, useBreakpoints, useNuxtApp, useTimestamp, watch } from '#imports'
import type { Actions, Getters, GlobalState, StoredState, User } from '~/lib/types'
const storageKey = 'nocodb-gui-v2'
/**
* Global state is injected by {@link import('~/plugins/state') state} plugin into our nuxt app (available as `$state`).
* Manual initialization is unnecessary and should be avoided.
*
* The state is stored in {@link WindowLocalStorage localStorage}, so it will be available even if the user closes the browser tab.
*
* @example
* ```js
* import { useNuxtApp } from '#app'
*
* const { $state } = useNuxtApp()
*
* const token = $state.token.value
* const user = $state.user.value
* ```
*/
export const useGlobalState = (): GlobalState => {
/** get the preferred languages of a user, according to browser settings */
const preferredLanguages = $(usePreferredLanguages())
/** todo: reimplement; get the preferred dark mode setting, according to browser settings */
// const prefersDarkMode = $(usePreferredDark())
const prefersDarkMode = false
/** get current breakpoints (for enabling sidebar) */
const breakpoints = useBreakpoints(breakpointsTailwind)
/** reactive timestamp to check token expiry against */
const timestamp = $(useTimestamp({ immediate: true, interval: 100 }))
const {
$api,
vueApp: { i18n },
} = useNuxtApp()
/**
* Set initial language based on browser settings.
* If the user has not set a preferred language, we fallback to 'en'.
* If the user has set a preferred language, we try to find a matching locale in the available locales.
*/
const preferredLanguage = preferredLanguages.reduce<string>((locale, language) => {
/** split language to language and code, e.g. en-GB -> [en, GB] */
const [lang, code] = language.split(/[_-]/)
/** find all locales that match the language */
let availableLocales = i18n.availableLocales.filter((locale) => locale.startsWith(lang))
/** If we can match more than one locale, we check if the code of the language matches as well */
if (availableLocales.length > 1) {
availableLocales = availableLocales.filter((locale) => locale.endsWith(code))
}
/** if there are still multiple locales, pick the first one */
const availableLocale = availableLocales[0]
/** if we found a matching locale, return it */
if (availableLocale) locale = availableLocale
return locale
}, 'en' /** fallback locale */)
/** State */
const initialState: StoredState = { token: null, user: null, lang: preferredLanguage, darkMode: prefersDarkMode }
/** saves a reactive state, any change to these values will write/delete to localStorage */
const storage = $(useStorage<StoredState>(storageKey, initialState))
/** force turn off of dark mode, regardless of previously stored settings */
storage.darkMode = false
/** current token ref, used by `useJwt` to reactively parse our token payload */
let token = $computed({
get: () => storage.token || '',
set: (val) => (storage.token = val),
})
/** reactive token payload */
const { payload } = $(useJwt<JwtPayload & User>($$(token!)))
/** Getters */
/** Verify that a user is signed in by checking if token exists and is not expired */
const signedIn: Getters['signedIn'] = computed(
() => !!(!!token && token !== '' && payload && payload.exp && payload.exp > timestamp / 1000),
)
/** is sidebar open */
const sidebarOpen = ref(signedIn.value && breakpoints.greater('md').value)
/** Actions */
/** Sign out by deleting the token from localStorage */
const signOut: Actions['signOut'] = () => {
storage.token = null
storage.user = null
}
/** Sign in by setting the token in localStorage */
const signIn: Actions['signIn'] = async (newToken) => {
token = newToken
if (payload) {
storage.user = {
id: payload.id,
email: payload.email,
firstname: payload.firstname,
lastname: payload.lastname,
roles: payload.roles,
}
}
}
/** manually try to refresh token */
const refreshToken = async () => {
$api.instance
.post('/auth/refresh-token', null, {
withCredentials: true,
})
.then((response) => {
if (response.data?.token) {
signIn(response.data.token)
}
})
.catch((err) => {
console.error(err)
signOut()
})
}
/** try to refresh token before expiry (5 min before expiry) */
watch(
() => !!(payload && payload.exp && payload.exp - 5 * 60 < timestamp / 1000),
async (expiring) => {
if (signedIn.value && payload && expiring) {
await refreshToken()
}
},
{ immediate: true },
)
return { ...toRefs(storage), signedIn, signOut, signIn, sidebarOpen }
}

66
packages/nc-gui-v2/composables/useGridViewColumnWidth.ts

@ -0,0 +1,66 @@
import { useStyleTag } from '@vueuse/core'
import type { ColumnType, GridColumnType, GridType } from 'nocodb-sdk'
import type { Ref } from 'vue'
import { useMetas } from './useMetas'
import { useUIPermission } from './useUIPermission'
// todo: update swagger
export function useGridViewColumnWidth(view: Ref<(GridType & { id?: string }) | undefined>) {
const { css, load: loadCss, unload: unloadCss } = useStyleTag('')
const { isUIAllowed } = useUIPermission()
const { $api } = useNuxtApp()
const { metas } = useMetas()
const gridViewCols = ref<Record<string, GridColumnType>>({})
const resizingCol = ref('')
const resizingColWidth = ref('200px')
const columns = computed<ColumnType[]>(() => metas?.value?.[(view?.value as any)?.fk_model_id as string]?.columns)
watch(
// todo : update type in swagger
() => [gridViewCols, resizingCol, resizingColWidth],
() => {
let style = ''
for (const c of columns?.value || []) {
const val = gridViewCols?.value?.[c?.id as string]?.width || '200px'
if (val && c.title !== resizingCol?.value) {
style += `[data-col="${c.id}"]{min-width:${val};max-width:${val};width: ${val};}`
} else {
style += `[data-col="${c.id}"]{min-width:${resizingColWidth?.value};max-width:${resizingColWidth?.value};width: ${resizingColWidth?.value};}`
}
}
css.value = style
},
{ deep: true, immediate: true },
)
const loadGridViewColumns = async () => {
if (!view.value?.id) return
const colsData: GridColumnType[] = await $api.dbView.gridColumnsList(view.value.id)
gridViewCols.value = colsData.reduce<Record<string, GridColumnType>>(
(o, col) => ({
...o,
[col.fk_column_id as string]: col,
}),
{},
)
loadCss()
}
const updateWidth = (id: string, width: string) => {
if (gridViewCols?.value?.[id]) {
gridViewCols.value[id].width = width
}
// sync with server if allowed
if (isUIAllowed('gridColUpdate') && gridViewCols.value[id]?.id) {
$api.dbView.gridColumnUpdate(gridViewCols.value[id].id as string, {
width,
})
}
}
return { loadGridViewColumns, updateWidth, resizingCol, resizingColWidth, loadCss, unloadCss }
}

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

@ -1,15 +1,14 @@
import type { ColumnType, TableType } from 'nocodb-sdk' import type { ColumnType, TableType } from 'nocodb-sdk'
import type LinkToAnotherRecordColumn from '../../nocodb/src/lib/models/LinkToAnotherRecordColumn' import { useMetas } from './useMetas'
import useMetas from '~/composables/useMetas'
export default function (column: ColumnType) { export function useHasMany(column: ColumnType) {
const { metas, getMeta } = useMetas() const { metas, getMeta } = useMetas()
const childMeta = computed<TableType>(() => { const childMeta = computed<TableType>(() => {
return metas.value?.[(column.colOptions as LinkToAnotherRecordColumn)?.fk_related_model_id as string] return metas.value?.[(column.colOptions as any)?.fk_related_model_id as string]
}) })
const loadChildMeta = async () => { const loadChildMeta = async () => {
await getMeta((column.colOptions as LinkToAnotherRecordColumn)?.fk_related_model_id as string) await getMeta((column.colOptions as any)?.fk_related_model_id as string)
} }
const primaryValueProp = computed(() => { const primaryValueProp = computed(() => {

20
packages/nc-gui-v2/composables/useInjectionState/index.ts

@ -0,0 +1,20 @@
import type { InjectionKey } from 'vue'
export function useInjectionState<Arguments extends any[], Return>(
composable: (...args: Arguments) => Return,
keyName = 'InjectionState',
): readonly [useProvidingState: (...args: Arguments) => void, useInjectedState: () => Return | undefined] {
const key: string | InjectionKey<Return> = Symbol(keyName)
const useProvidingState = (...args: Arguments) => {
const providedState = composable(...args)
provide(key, providedState)
return providedState
}
const useInjectedState = () => inject(key)
return [useProvidingState, useInjectedState]
}

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

@ -1,15 +1,14 @@
import type { ColumnType, TableType } from 'nocodb-sdk' import type { ColumnType, TableType } from 'nocodb-sdk'
import type LinkToAnotherRecordColumn from '../../nocodb/src/lib/models/LinkToAnotherRecordColumn' import { useMetas } from './useMetas'
import useMetas from '~/composables/useMetas'
export default function (column: ColumnType) { export function useManyToMany(column: ColumnType) {
const { metas, getMeta } = useMetas() const { metas, getMeta } = useMetas()
const childMeta = computed<TableType>(() => { const childMeta = computed<TableType>(() => {
return metas.value?.[(column.colOptions as LinkToAnotherRecordColumn)?.fk_related_model_id as string] return metas.value?.[(column.colOptions as any)?.fk_related_model_id as string]
}) })
const loadChildMeta = async () => { const loadChildMeta = async () => {
await getMeta((column.colOptions as LinkToAnotherRecordColumn)?.fk_related_model_id as string) await getMeta((column.colOptions as any)?.fk_related_model_id as string)
} }
const primaryValueProp = computed(() => { const primaryValueProp = computed(() => {

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

@ -1,8 +1,8 @@
import type { TableInfoType, TableType } from 'nocodb-sdk' import type { TableInfoType, TableType } from 'nocodb-sdk'
import { useProject } from './useProject'
import { useNuxtApp, useState } from '#app' import { useNuxtApp, useState } from '#app'
import { useProject } from '#imports'
export default () => { export function useMetas() {
const { $api } = useNuxtApp() const { $api } = useNuxtApp()
const { tables } = useProject() const { tables } = useProject()

11
packages/nc-gui-v2/composables/useProject.ts

@ -1,10 +1,10 @@
import { SqlUiFactory } from 'nocodb-sdk' import { SqlUiFactory } from 'nocodb-sdk'
import type { ProjectType, TableType } from 'nocodb-sdk' import type { OracleUi, ProjectType, TableType } from 'nocodb-sdk'
import type { MaybeRef } from '@vueuse/core' import type { MaybeRef } from '@vueuse/core'
import { useNuxtApp, useState } from '#app' import { useNuxtApp, useState } from '#app'
import { USER_PROJECT_ROLES } from '~/lib/constants' import { USER_PROJECT_ROLES } from '~/lib'
export default (projectId?: MaybeRef<string>) => { export function useProject(projectId?: MaybeRef<string>) {
const projectRoles = useState<Record<string, boolean>>(USER_PROJECT_ROLES, () => ({})) const projectRoles = useState<Record<string, boolean>>(USER_PROJECT_ROLES, () => ({}))
const { $api } = useNuxtApp() const { $api } = useNuxtApp()
@ -21,6 +21,7 @@ export default (projectId?: MaybeRef<string>) => {
projectRoles.value = user.roles projectRoles.value = user.roles
} }
} }
async function loadTables() { async function loadTables() {
if (project.value.id) { if (project.value.id) {
const tablesResponse = await $api.dbTable.list(project.value.id) const tablesResponse = await $api.dbTable.list(project.value.id)
@ -44,7 +45,9 @@ export default (projectId?: MaybeRef<string>) => {
const isMysql = computed(() => ['mysql', 'mysql2'].includes(projectBaseType)) const isMysql = computed(() => ['mysql', 'mysql2'].includes(projectBaseType))
const isPg = computed(() => projectBaseType === 'pg') const isPg = computed(() => projectBaseType === 'pg')
const sqlUi = computed(() => SqlUiFactory.create({ client: projectBaseType })) const sqlUi = computed(
() => SqlUiFactory.create({ client: projectBaseType }) as Exclude<ReturnType<typeof SqlUiFactory['create']>, typeof OracleUi>,
)
return { project, tables, loadProjectRoles, loadProject, loadTables, isMysql, isPg, sqlUi } return { project, tables, loadProjectRoles, loadProject, loadTables, isMysql, isPg, sqlUi }
} }

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save