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'],
plugins: ['prettier'],
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 MdiDotsVertical from '~icons/mdi/dots-vertical'
import MaterialSymbolsMenu from '~icons/material-symbols/menu'
import MdiReload from '~icons/mdi/reload'
import { navigateTo } from '#app'
import { useGlobal } from '#imports'
const { $state } = useNuxtApp()
const state = useGlobal()
const sidebar = ref<HTMLDivElement>()
const email = computed(() => $state.user?.value?.email ?? '---')
const email = computed(() => state.user.value?.email ?? '---')
const signOut = () => {
$state.signOut()
state.signOut()
navigateTo('/signin')
}
const toggleSidebar = useToggle($state.sidebarOpen)
const sidebarOpen = computed({
get: () => !$state.sidebarOpen.value,
set: (val) => toggleSidebar(val),
const sidebarCollapsed = computed({
get: () => !state.sidebarOpen.value,
set: (val) => (state.sidebarOpen.value = !val),
})
const toggleSidebar = () => {
sidebarCollapsed.value = !sidebarCollapsed.value
}
</script>
<template>
<a-layout>
<a-layout class="min-h-[100vh]">
<a-layout-header class="flex !bg-primary items-center text-white px-4 shadow-md">
<MaterialSymbolsMenu
v-if="$state.signedIn.value"
class="text-xl cursor-pointer"
@click="toggleSidebar(!$state.sidebarOpen.value)"
/>
<MaterialSymbolsMenu v-if="state.signedIn.value" class="text-xl cursor-pointer" @click="toggleSidebar" />
<div class="flex-1" />
@ -41,15 +41,10 @@ const sidebarOpen = computed({
<span class="prose-xl">NocoDB</span>
</div>
<!-- todo: loading is not yet supported by nuxt 3 - see https://v3.nuxtjs.org/migration/component-options#loading
<span v-show="$nuxt.$loading.show" class="caption grey--text ml-3">
{{ $t('general.loading') }} <v-icon small color="grey">mdi-spin mdi-loading</v-icon>
</span>
todo: replace shortkey?
<span v-shortkey="['ctrl', 'shift', 'd']" @shortkey="openDiscord" />
-->
<div v-show="state.isLoading.value" class="text-gray-400 ml-3">
{{ $t('general.loading') }}
<MdiReload :class="{ 'animate-infinite animate-spin !text-success': state.isLoading.value }" />
</div>
</div>
<div class="flex-1" />
@ -57,7 +52,7 @@ const sidebarOpen = computed({
<div class="flex justify-end gap-4">
<general-language class="mr-3" />
<template v-if="$state.signedIn.value">
<template v-if="state.signedIn.value">
<a-dropdown :trigger="['click']">
<MdiDotsVertical class="md:text-xl cursor-pointer nc-user-menu" @click.prevent />
@ -89,7 +84,7 @@ const sidebarOpen = computed({
<a-layout>
<a-layout-sider
v-model:collapsed="sidebarOpen"
v-model:collapsed="sidebarCollapsed"
width="300"
collapsed-width="0"
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,
body,
#__nuxt,
@ -57,15 +68,39 @@ h1, h2, h3, h4, h5, h6, p, label, button, textarea, select {
@apply color-transition;
}
:root {
--header-height: 64px;
}
html {
overflow-y: auto !important;
}
// menu item styling
.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']
ACollapse: typeof import('ant-design-vue/es')['Collapse']
ACollapsePanel: typeof import('ant-design-vue/es')['CollapsePanel']
ADatePicker: typeof import('ant-design-vue/es')['DatePicker']
ADivider: typeof import('ant-design-vue/es')['Divider']
ADropdown: typeof import('ant-design-vue/es')['Dropdown']
ADropdownButton: typeof import('ant-design-vue/es')['DropdownButton']
AForm: typeof import('ant-design-vue/es')['Form']
AFormItem: typeof import('ant-design-vue/es')['FormItem']
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']
AModal: typeof import('ant-design-vue/es')['Modal']
APagination: typeof import('ant-design-vue/es')['Pagination']
APopconfirm: typeof import('ant-design-vue/es')['Popconfirm']
ARate: typeof import('ant-design-vue/es')['Rate']
ARow: typeof import('ant-design-vue/es')['Row']
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']
ATag: typeof import('ant-design-vue/es')['Tag']
ATextarea: typeof import('ant-design-vue/es')['Textarea']
ATimePicker: typeof import('ant-design-vue/es')['TimePicker']
ATooltip: typeof import('ant-design-vue/es')['Tooltip']
ATypographyText: typeof import('ant-design-vue/es')['TypographyText']
ATypographyTitle: typeof import('ant-design-vue/es')['TypographyTitle']
AUploadDragger: typeof import('ant-design-vue/es')['UploadDragger']
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 { useNuxtApp } from '#app'
import { ColumnInj, MetaInj } from '~/context'
import { NOCO } from '~/lib/constants'
import { isImage } from '~/utils/fileUtils'
import { NOCO } from '~/lib'
import { isImage } from '~/utils'
import MaterialPlusIcon from '~icons/mdi/plus'
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
}
@ -49,7 +49,7 @@ const addFile = () => {
fileInput.value?.click()
}
const onFileSelection = async (e) => {
const onFileSelection = async (e: unknown) => {
// if (this.isPublicGrid) {
// return
// }

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

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

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

@ -1,146 +1,62 @@
<script setup lang="ts">
import dayjs from 'dayjs'
import { computed, ref, useProject } from '#imports'
interface Props {
modelValue?: string
}
import { ReadonlyInj } from '~/context'
const { modelValue } = defineProps<Props>()
const emit = defineEmits(['update:modelValue'])
const { isMysql } = useProject()
const showMessage = ref(false)
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'))
interface Props {
modelValue: string
}
},
})
/* import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
const { isMysql } = useProject()
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 {
name: 'DateTimePickerCell',
props: {
value: [String, Date, Number],
ignoreFocus: Boolean,
},
data: () => ({
showMessage: false,
}),
computed: {
isMysql() {
return ['mysql', 'mysql2'].indexOf(this.$store.getters['project/GtrClientType'])
},
localState: {
const localState = $computed({
get() {
if (!this.value) {
return this.value
}
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'))
if (!modelValue) {
return undefined
}
},
},
parentListeners() {
const $listeners = {}
if (this.$listeners.blur) {
// $listeners.blur = this.$listeners.blur
}
if (this.$listeners.focus) {
$listeners.focus = this.$listeners.focus
if (!dayjs(modelValue).isValid()) {
isDateInvalid = true
return undefined
}
return $listeners
},
return /^\d+$/.test(modelValue) ? dayjs(+modelValue) : dayjs(modelValue)
},
mounted() {
// listen dialog click:outside event and save on close
if (this.$refs.picker && this.$refs.picker.$children && this.$refs.picker.$children[0]) {
this.$refs.picker.$children[0].$on('click:outside', () => {
this.$refs.picker.okHandler()
})
set(val?: dayjs.Dayjs) {
if (!val) {
emit('update:modelValue', null)
return
}
if (!this.ignoreFocus) {
this.$refs.picker.display = true
if (val.isValid()) {
emit('update:modelValue', val?.format(dateFormat))
}
},
} */
})
</script>
<template>
<input v-model="localState" type="datetime-local" />
<!-- <div> -->
<!-- <div v-show="!showMessage"> -->
<!-- <v-datetime-picker -->
<!-- ref="picker" -->
<!-- v-model="localState" -->
<!-- class="caption xc-date-time-picker" -->
<!-- :text-field-props="{ -->
<!-- class: 'caption mt-0 pt-0', -->
<!-- flat: true, -->
<!-- solo: true, -->
<!-- dense: true, -->
<!-- hideDetails: true, -->
<!-- }" -->
<!-- :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> -->
<a-date-picker
v-model:value="localState"
:show-time="true"
:bordered="false"
class="!w-full px-1"
format="YYYY-MM-DD HH:mm"
:placeholder="isDateInvalid ? 'Invalid date' : !readOnlyMode ? 'Select date and time' : ''"
:allow-clear="!readOnlyMode"
:input-read-only="true"
:open="readOnlyMode ? false : undefined"
>
<template #suffixIcon></template>
</a-date-picker>
</template>
<style scoped>
/*: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>
<style scoped></style>

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

@ -1,41 +1,32 @@
<script lang="ts" setup>
import { computed } from '#imports'
import { isEmail } from '~/utils/validation'
const { modelValue: value } = defineProps<Props>()
const emit = defineEmits(['update:modelValue'])
const editEnabled = inject<boolean>('editEnabled')
import { isEmail } from '~/utils'
interface Props {
modelValue: string
}
interface Emits {
(event: 'update:modelValue', model: string): void
}
const props = defineProps<Props>()
const emits = defineEmits<Emits>()
const root = ref<HTMLInputElement>()
const localState = computed({
get: () => value,
set: (val) => emit('update:modelValue', val),
})
const validEmail = computed(() => isEmail(value))
</script>
const editEnabled = inject<boolean>('editEnabled')
<script lang="ts">
export default {
name: 'EmailCell',
}
const vModel = useVModel(props, 'modelValue', emits)
const validEmail = computed(() => isEmail(vModel.value))
</script>
<template>
<input v-if="editEnabled" ref="root" v-model="localState" />
<a
v-else-if="validEmail"
class="caption py-2 text-primary underline hover:opacity-75"
:href="`mailto:${value}`"
target="_blank"
>
{{ value }}
<input v-if="editEnabled" ref="root" v-model="vModel" class="outline-none prose-sm" />
<a v-else-if="validEmail" class="prose-sm underline hover:opacity-75" :href="`mailto:${vModel}`" target="_blank">
{{ vModel }}
</a>
<span v-else>{{ value }}</span>
<span v-else>{{ vModel }}</span>
</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'
interface Props {
modelValue?: string | number
modelValue?: number
readOnly?: boolean
}
@ -44,5 +44,3 @@ const vModel = useVModel(props, 'modelValue', emits)
</template>
</a-rate>
</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 emit = defineEmits(['update:modelValue'])
const column = inject(ColumnInj)
const editEnabled = inject<boolean>('editEnabled')
const localState = computed({
const vModel = computed({
get: () => value,
set: (val) => {
if (!(column && column.meta && column.meta.validate) || isValidURL(val)) {
@ -24,19 +27,16 @@ const localState = computed({
const isValid = computed(() => value && isValidURL(value))
const root = ref<HTMLInputElement>()
onMounted(() => {
root.value?.focus()
})
</script>
<template>
<span v-if="editEnabled">
<input ref="root" v-model="localState" />
</span>
<span v-else>
<a v-if="isValid" class="caption py-2 text-primary underline hover:opacity-75" :href="value" target="_blank">{{ value }}</a>
<input v-if="editEnabled" ref="root" v-model="vModel" class="outline-none" />
<nuxt-link v-else-if="isValid" class="py-2 underline hover:opacity-75" :to="value" target="_blank">{{ value }}</nuxt-link>
<span v-else>{{ value }}</span>
</span>
</template>
<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">
import { computed } from '@vue/reactivity'
import { Modal } from 'ant-design-vue'
import { UITypes } from 'nocodb-sdk'
import type { LinkToAnotherRecordType, TableType } from 'nocodb-sdk'
import type { TableType } from 'nocodb-sdk'
import Sortable from 'sortablejs'
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 useProject from '~/composables/useProject'
import useTabs from '~/composables/useTabs'
import { extractSdkResponseErrorMsg } from '~/utils/errorUtils'
import MdiSettingIcon from '~icons/mdi/cog'
import MdiTable from '~icons/mdi/table'
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 MdiMenuIcon from '~icons/mdi/dots-vertical'
import MdiAPIDocIcon from '~icons/mdi/open-in-new'
import SettingsModal from '~/components/dashboard/settings/SettingsModal.vue'
const { addTab } = useTabs()
const toast = useToast()
@ -28,6 +22,7 @@ const { isUIAllowed } = useUIPermission()
const route = useRoute()
const { tables, loadTables } = useProject(route.params.projectId as string)
const { closeTab } = useTabs()
const { deleteTable } = useTable()
const tablesById = $computed<Record<string, 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')
}
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 renameTableMeta = ref()
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 MdiCloseCircleIcon from '~icons/mdi/close-circle-outline'
import MdiPlusIcon from '~icons/mdi/plus'
const { $api, $e } = useNuxtApp()
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 Metadata from './Metadata.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 TeamFillIcon from '~icons/ri/team-fill'
import MultipleTableIcon from '~icons/mdi/table-multiple'
@ -39,11 +41,11 @@ const tabsInfo: TabGroup = {
subTabs: {
usersManagement: {
title: 'Users Management',
body: () => AuditTab,
body: () => UserManagement,
},
apiTokenManagement: {
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',
headers: { 'xc-auth': $state.token.value as string },
params: {
id: socket.id,
id: socket?.id,
},
})
} catch (e: any) {

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

@ -2,8 +2,9 @@
import type { ComponentPublicInstance } from '@vue/runtime-core'
import { Form } from 'ant-design-vue'
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 { TabType } from '~/composables'
interface Props {
modelValue?: boolean
@ -28,13 +29,13 @@ const { addTab } = useTabs()
const { loadTables } = useProject()
const { table, createTable, generateUniqueTitle, tables, project } = useTableCreate(async (table) => {
const { table, createTable, generateUniqueTitle, tables, project } = useTable(async (table) => {
await loadTables()
addTab({
id: table.id as string,
title: table.title,
type: 'table',
type: TabType.TABLE,
})
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 type { TableType } from 'nocodb-sdk'
import { useToast } from 'vue-toastification'
import { useProject, useTableCreate, useTabs } from '#imports'
import { useProject, useTabs } from '#imports'
import { extractSdkResponseErrorMsg } from '~/utils/errorUtils'
import { validateTableName } from '~/utils/validation'
import { useNuxtApp } from '#app'

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

@ -1,32 +1,70 @@
<script setup lang="ts">
import { inject } from '@vue/runtime-core'
import type { TableType } from 'nocodb-sdk'
import type { ComponentPublicInstance } from '@vue/runtime-core'
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 type { Ref } from '#imports'
import { ActiveViewInj, MetaInj, ViewListInj } from '~/context'
import useViewCreate from '~/composables/useViewCreate'
import { useI18n } from 'vue-i18n'
import { MetaInj, ViewListInj } from '~/context'
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 activeView = inject(ActiveViewInj)
const dialogShow = computed({
get() {
return modelValue
},
set(v) {
emit('update:modelValue', v)
},
const form = reactive<Form>({
title: props.title || '',
type: props.type,
copy_from_id: null,
})
const { view, createView, generateUniqueTitle, loading } = useViewCreate(inject(MetaInj) as Ref<TableType>, (view) =>
emit('created', view),
)
const formRules = [
// 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(
() =>
@ -35,113 +73,85 @@ const typeAlias = computed(
[ViewTypes.GALLERY]: 'gallery',
[ViewTypes.FORM]: 'form',
[ViewTypes.KANBAN]: 'kanban',
}[type]),
}[props.type]),
)
const inputEl = ref<any>()
const form = ref<any>()
watch(vModel, (value) => value && init())
watch(
() => modelValue,
(v) => {
if (v) {
generateUniqueTitle(viewList?.value || [])
() => props.type,
(newType) => (form.type = newType),
)
function init() {
form.title = generateUniqueTitle(capitalize(ViewTypes[props.type].toLowerCase()), viewList?.value || [], 'title')
nextTick(() => {
const el = inputEl?.value?.$el
el?.querySelector('input')?.focus()
el?.querySelector('input')?.select()
form?.value?.validate()
const el = inputEl?.$el as HTMLInputElement
if (el) {
el.focus()
el.select()
}
})
}
},
)
/* name: 'CreateViewDialog',
props: [
'value',
'nodes',
'table',
'alias',
'show_as',
'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() {
async function onSubmit() {
const isValid = await formValidator?.validateFields()
if (isValid && form.type) {
const _meta = unref(meta)
if (!_meta || !_meta.id) return
try {
if (this.copyView && this.copyView.query_params) {
this.queryParams = { ...JSON.parse(this.copyView.query_params) };
let data: GridType | KanbanType | GalleryType | FormType | null = null
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>
<template>
<v-dialog v-model="dialogShow" max-width="600" min-width="400">
<v-card class="elevation-20">
<v-card-title class="grey darken-2 subheading" style="height: 30px" />
<v-card-text class="pt-4 pl-4">
<p class="headline">
<a-modal v-model:visible="vModel" class="!top-[35%]" :confirm-loading="loading">
<template #title>
{{ $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>
<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
socialMedias: string[]
title?: string
summary: string
summary?: string
hashTags?: string
css?: string
iconClass?: string

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

@ -15,8 +15,6 @@ const isZhLang = $computed(() => locale.value.startsWith('zh'))
</script>
<template>
<!-- todo: add missing google analytics directive events -->
<v-list>
<general-share
v-if="isZhLang"
class="flex justify-center"
@ -41,7 +39,6 @@ const isZhLang = $computed(() => locale.value.startsWith('zh'))
@click="open('https://calendly.com/nocodb-meeting')"
/>
</div>
</v-list>
</template>
<style scoped>
@ -52,7 +49,7 @@ const isZhLang = $computed(() => locale.value.startsWith('zh'))
.discourse {
height: 22px;
width: 22px;
background-image: url('~/assets/img/discourse-icon.png');
background-image: url('assets/img/discourse-icon.png');
background-size: contain;
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 Sponsors from './Sponsors.vue'
import { createVuetifyPlugin } from '~/plugins/vuetify'
import { createI18nPlugin } from '~/plugins/i18n'
import { createI18nPlugin } from '~/plugins/a.i18n'
const mountComponent = async (nav: boolean) => {
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 {
nav?: boolean
img?: boolean
}
const { nav = false } = defineProps<Props>()
const { nav = false, img = true } = defineProps<Props>()
</script>
<template>
<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">
{{ $t('msg.info.sponsor.header') }}
@ -21,7 +22,7 @@ const { nav = false } = defineProps<Props>()
</v-card-text>
<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" />
{{ $t('activity.sponsorUs') }}
</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 EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
import { onMounted } from '#imports'
import { deepCompare } from '~/utils/deepCompare'
import { deepCompare } from '~/utils'
const { modelValue } = defineProps<{ modelValue: any }>()
@ -83,5 +83,3 @@ watch(
<template>
<div ref="root"></div>
</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">
import type { ColumnType } from 'nocodb-sdk'
import { ColumnInj } from '../../context'
import type { ColumnType, TableType } from 'nocodb-sdk'
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 } }>()
provide(ColumnInj, column)
const meta = inject(MetaInj)
// instantiate column update store
useProvideColumnCreateStore(meta as Ref<TableType>, column)
/*
import { UITypes } from 'nocodb-sdk'
@ -70,96 +77,12 @@ export default {
</script>
<template>
<div class="d-flex align-center d-100">
<div class="flex align-center w-full">
<SmartsheetHeaderCellIcon v-if="column" />
<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> -->
<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 class="flex-1" />
<SmartsheetHeaderMenu />
</div>
</template>

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

@ -1,12 +1,12 @@
<script setup lang="ts">
import type { ColumnType } from 'nocodb-sdk'
import { ColumnInj } from '~/context'
import useColumn from '~/composables/useColumn'
import { useColumn } from '#imports'
import KeyIcon from '~icons/mdi/key-variant'
import JSONIcon from '~icons/mdi/code-json'
// import FKIcon from '~icons/mdi/link-variant'
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 SingleSelectIcon from '~icons/mdi/radiobox-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 EmailIcon from '~icons/mdi/email'
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)
@ -54,10 +57,10 @@ const icon = computed(() => {
return URLIcon
} else if (additionalColMeta.isCurrency) {
return CurrencyIcon
} else if (additionalColMeta.isPercent) {
return PercentIcon
} else if (additionalColMeta.isString) {
return h(StringIcon, {
class: 'text-[1.5rem]',
})
return StringIcon
} else {
return GenericIcon
}
@ -65,5 +68,5 @@ const icon = computed(() => {
</script>
<template>
<component :is="icon" class="text-grey mx-1" />
<component :is="icon" class="text-grey mx-1 !text-sm" />
</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">
import type { ColumnType } from 'nocodb-sdk'
import { ColumnInj } from '../../context'
import { ColumnInj } from '~/context'
import { provide } from '#imports'
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">
import type { LinkToAnotherRecordType } from 'nocodb-sdk'
import type { ColumnType, LinkToAnotherRecordType } from 'nocodb-sdk'
import { RelationTypes, UITypes } from 'nocodb-sdk'
import { ColumnInj } from '~/context'
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 RollupIcon from '~icons/mdi/movie-roll'
const column = inject(ColumnInj)
const { columnMeta } = defineProps<{ columnMeta?: ColumnType }>()
const column = inject(ColumnInj, columnMeta)
const icon = computed(() => {
switch (column?.uidt) {
@ -35,5 +37,5 @@ const icon = computed(() => {
</script>
<template>
<component :is="icon" class="text-grey mx-1" />
<component :is="icon" class="text-grey mx-1 !text-sm" />
</template>

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

@ -1,10 +1,15 @@
<script setup lang="ts">
import MdiAddIcon from '~icons/mdi/plus-outline'
const emit = defineEmits(['add-row'])
const emits = defineEmits(['addRow'])
</script>
<template>
<MdiAddIcon class="text-grey" @click="emit('add-row')" />
</template>
<a-tooltip placement="left">
<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">
import type { FilterType } from 'nocodb-sdk'
import { UITypes } from 'nocodb-sdk'
import FieldListAutoCompleteDropdown from './FieldListAutoCompleteDropdown.vue'
import { useNuxtApp } from '#app'
import { inject } from '#imports'
import { inject, useViewFilters } from '#imports'
import { comparisonOpList } from '~/utils/filterUtils'
import { ActiveViewInj, MetaInj, ReloadViewDataHookInj } from '~/context'
import useViewFilters from '~/composables/useViewFilters'
import { ActiveViewInj, IsLockedInj, MetaInj, ReloadViewDataHookInj } from '~/context'
import MdiDeleteIcon from '~icons/mdi/close-box'
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 activeView = inject(ActiveViewInj)
const reloadDataHook = inject(ReloadViewDataHookInj)
const isLocked = inject(IsLockedInj)
// todo: replace with inject or get from state
const shared = ref(false)
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()
})
},
)
const filterUpdateCondition = (filter, i) => {
const filterUpdateCondition = (filter: FilterType, i: number) => {
saveOrUpdate(filter, i)
$e('a:filter:update', {
logical: filter.logical_op,
@ -63,70 +75,87 @@ const types = computed(() => {
})
watch(
() => activeView?.value?.id,
() => (activeView?.value as any)?.id,
(n, o) => {
if (n !== o) loadFilters()
},
{ 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>
<template>
<div class="bg-white shadow pa-2 menu-filter-dropdown" :style="{ width: nested ? '100%' : '630px' }">
<div v-if="filters && filters.length" class="grid" @click.stop>
<div
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-if="filter.status !== 'delete'">
<div v-if="filter.is_group" :key="i" style="grid-column: span 4; padding: 6px" class="elevation-4">
<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> -->
<template v-if="filter.is_group">
<MdiDeleteIcon
v-if="!filter.readOnly"
:key="i"
small
class="nc-filter-item-remove-btn"
class="nc-filter-item-remove-btn text-grey"
@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
v-model:value="filter.logical_op"
class="flex-shrink-1 flex-grow-0 elevation-0 caption"
:items="['and', 'or']"
density="compact"
variant="solo"
hide-details
:dropdown-match-select-width="false"
size="small"
class="flex-shrink-1 flex-grow-0 elevation-0 caption !text-xs"
placeholder="Group op"
@click.stop
@change="saveOrUpdate(filter, i)"
>
<!-- <template #item="{ item }"> -->
<!-- <span class="caption font-weight-regular">{{ item }}</span> -->
<!-- </template> -->
<a-select-option v-for="op in logicalOps" :key="op.value" :value="op.value" class="!text-xs">
{{ op.text }}
</a-select-option>
</a-select>
</div>
<!-- <column-filter
<span class="col-span-3" />
<div class="col-span-5">
<SmartsheetToolbarColumnFilter
v-if="filter.id || shared"
ref="nestedFilter"
ref="nestedFilters"
v-model="filter.children"
:parent-id="filter.id"
:view-id="viewId"
nested
:meta="meta"
:shared="shared"
:web-hook="webHook"
:hook-id="hookId"
@updated="$emit('updated')"
@input="$emit('input', filters)"
/> -->
:auto-save="autoSave"
/>
</div>
</template>
<template v-else>
<!-- <v-icon
v-if="!filter.readOnly"
@ -150,21 +179,23 @@ watch(
<a-select
v-else
v-model:value="filter.logical_op"
class="h-full"
:options="[
{ value: 'and', text: 'AND' },
{ value: 'or', text: 'OR' },
]"
:dropdown-match-select-width="false"
size="small"
class="h-full !text-xs"
hide-details
:disabled="filter.readOnly"
@click.stop
@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
:key="`${i}_6`"
v-model="filter.fk_column_id"
class="caption text-sm nc-filter-field-select"
class="caption nc-filter-field-select"
:columns="columns"
:disabled="filter.readOnly"
@click.stop
@ -173,15 +204,21 @@ watch(
<a-select
v-model:value="filter.comparison_op"
class="caption nc-filter-operation-select text-sm"
:options="comparisonOpList"
:dropdown-match-select-width="false"
size="small"
class="caption nc-filter-operation-select !text-xs"
:placeholder="$t('labels.operation')"
density="compact"
variant="solo"
:disabled="filter.readOnly"
hide-details
@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
item-value="value"
@ -195,7 +232,8 @@ watch(
<span v-if="['null', 'notnull', 'empty', 'notempty'].includes(filter.comparison_op)" :key="`span${i}`" />
<a-checkbox
v-else-if="types[filter.field] === 'boolean'"
v-model:value="filter.value"
v-model:checked="filter.value"
size="small"
dense
:disabled="filter.readOnly"
@change="saveOrUpdate(filter, i)"
@ -203,8 +241,9 @@ watch(
<a-input
v-else
:key="`${i}_7`"
v-model="filter.value"
class="caption text-sm nc-filter-value-select"
v-model:value="filter.value"
size="small"
class="caption nc-filter-value-select"
:disabled="filter.readOnly"
@click.stop
@input="saveOrUpdate(filter, i)"
@ -214,7 +253,8 @@ watch(
</template>
</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">
<!-- <v-icon small color="grey"> mdi-plus </v-icon> -->
<MdiAddIcon />
@ -222,15 +262,33 @@ watch(
{{ $t('activity.addFilter') }}
</div>
</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 />
</div>
</template>
<style scoped>
.grid {
.nc-filter-grid {
display: grid;
grid-template-columns: 30px 130px auto auto auto;
grid-template-columns: 18px 70px auto auto auto;
column-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>

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

@ -1,5 +1,6 @@
<script setup lang="ts">
// todo: move to persisted state
import type ColumnFilter from './ColumnFilter.vue'
import { useState } from '#app'
import { IsLockedInj } from '~/context'
import MdiFilterIcon from '~icons/mdi/filter-outline'
@ -9,26 +10,49 @@ const autoApplyFilter = useState('autoApplyFilter', () => false)
const isLocked = inject(IsLockedInj)
// 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
const applyChanges = () => {}
const applyChanges = async () => {
await filterComp?.value?.applyChanges()
}
</script>
<template>
<a-dropdown :trigger="['click']">
<v-badge :value="filters.length" color="primary" dot overlap>
<a-button v-t="['c:filter']" class="nc-filter-menu-btn nc-toolbar-btn" :disabled="isLocked" size="small">
<div :class="{ 'nc-badge nc-active-btn': filtersLength }">
<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">
<MdiFilterIcon class="text-grey" />
<!-- 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" />
</div>
</a-button>
</v-badge>
</div>
<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>
</a-dropdown>
</template>

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

@ -1,9 +1,19 @@
<script setup lang="ts">
import { inject, useTable } from '#imports'
import { MetaInj } from '~/context'
import MdiDeleteIcon from '~icons/mdi/delete-outline'
const meta = inject(MetaInj)
const { deleteTable } = useTable()
</script>
<template>
<MdiDeleteIcon class="text-grey" />
</template>
<a-tooltip placement="left">
<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">
import type { SelectProps } from 'ant-design-vue'
import { isVirtualCol } from 'nocodb-sdk'
import { computed } from 'vue'
import { MetaInj } from '~/context'
import VirtualCellIcon from '~/components/smartsheet-header/VirtualCellIcon.vue'
import CellIcon from '~/components/smartsheet-header/CellIcon.vue'
interface Props {
modelValue?: string
@ -48,6 +51,10 @@ const options = computed<SelectProps['options']>(() =>
meta?.value?.columns?.map((c) => ({
value: c.id,
label: c.title,
icon: h(isVirtualCol(c) ? VirtualCellIcon : CellIcon, {
columnMeta: c,
}),
c,
})),
)
@ -59,40 +66,17 @@ const filterOption = (input: string, option: any) => {
<template>
<a-select
v-model:value="localValue"
:dropdown-match-select-width="false"
size="small"
show-search
class="!text-xs"
placeholder="Select a field"
:options="options"
: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;
&lt;!&ndash; <template #selection="{ item }"> &ndash;&gt;
&lt;!&ndash; <v-icon 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; </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> -->
<a-select-option v-for="option in options" :key="option.value" :value="option.value">
<div class="flex gap-2 text-xs items-center align-center h-full">
<component :is="option.icon" class="min-w-5 !mx-0" /> <span class="min-w-0"> {{ option.label }}</span>
</div>
</a-select-option>
</a-select>
</template>
<style scoped></style>

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

@ -2,7 +2,7 @@
import { computed, inject } from 'vue'
import Draggable from 'vuedraggable'
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 MdiEyeIcon from '~icons/mdi/eye-off-outline'
import MdiDragIcon from '~icons/mdi/drag'
@ -16,14 +16,8 @@ const { fieldsOrder, coverImageField, modelValue } = defineProps<{
const meta = inject(MetaInj)
const activeView = inject(ActiveViewInj)
const reloadDataHook = inject(ReloadViewDataHookInj)
const isLocked = inject(IsLockedInj)
const rootFields = inject(FieldsInj)
const isAnyFieldHidden = computed(() => {
return false
// todo: implement
// return meta?.fields?.some(field => field.hidden)
})
const isLocked = inject(IsLockedInj)
const { $e } = useNuxtApp()
@ -37,11 +31,11 @@ const {
showAll,
hideAll,
saveOrUpdate,
sortedFields,
// sortedFields,
} = useViewColumns(activeView, meta, false, () => reloadDataHook?.trigger())
watch(
() => activeView?.value?.id,
() => (activeView?.value as any)?.id,
async (newVal, oldVal) => {
if (newVal !== oldVal && meta?.value) {
await loadViewColumns()
@ -57,43 +51,52 @@ watch(
{ 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
// if (!sortedFields?.value) return
// if (sortedFields?.value.length - 1 === event.moved.newIndex) {
// sortedFields.value[event.moved.newIndex].order = sortedFields.value[event.moved.newIndex - 1].order + 1
// } else if (event.moved.newIndex === 0) {
// sortedFields.value[event.moved.newIndex].order = sortedFields.value[1].order / 2
// } else {
// sortedFields.value[event.moved.newIndex].order =
// (sortedFields?.value[event.moved.newIndex - 1].order + sortedFields?.value[event.moved.newIndex + 1].order) / 2
// // );
// }
// saveOrUpdate(sortedFields[event.moved.newIndex], event.moved.newIndex);
if (!fields?.value) return
if (fields.value.length < 2) return
if (fields?.value.length - 1 === event.moved.newIndex) {
fields.value[event.moved.newIndex].order = (fields.value[event.moved.newIndex - 1].order || 1) + 1
} else if (event.moved.newIndex === 0) {
fields.value[event.moved.newIndex].order = (fields?.value[1].order || 1) / 2
} else {
fields.value[event.moved.newIndex].order =
((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')
}
</script>
<template>
<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">
<div class="flex align-center gap-1">
<!-- <v-icon small class="mr-1" color="#777"> mdi-eye-off-outline </v-icon> -->
<MdiEyeIcon class="text-grey"></MdiEyeIcon>
<!-- 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>
</div>
</a-button>
</v-badge>
</div>
<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">
<Draggable :list="sortedFields" @change="onMove($event)">
<Draggable :list="fields" item-key="id" @change="onMove($event)">
<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)">
<span class="text-xs">{{ field.title }}</span>
</a-checkbox>
@ -111,11 +114,11 @@ const onMove = (event) => {
</a-checkbox>
</div>
<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 -->
{{ $t('general.showAll') }}
</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 -->
{{ $t('general.hideAll') }}
</a-button>
@ -126,7 +129,10 @@ const onMove = (event) => {
</template>
<style scoped lang="scss">
:deep(.ant-checkbox-input) {
transform: scale(0.7);
:deep(.ant-checkbox-inner) {
@apply transform scale-60;
}
:deep(::placeholder) {
@apply !text-xs;
}
</style>

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

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

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 FileSaver from 'file-saver'
import { useNuxtApp } from '#app'
import useProject from '~/composables/useProject'
import { useProject } from '#imports'
import { ActiveViewInj, MetaInj } from '~/context'
import { extractSdkResponseErrorMsg } from '~/utils/errorUtils'
import { extractSdkResponseErrorMsg } from '~/utils'
import MdiFlashIcon from '~icons/mdi/flash-outline'
import MdiMenuDownIcon from '~icons/mdi/menu-down'
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 MdiViewListIcon from '~icons/mdi/view-list-outline'
const sharedViewListDlg = ref(false)
// todo : replace with inject
const publicViewId = null
const { project } = useProject()
@ -80,13 +82,14 @@ const exportCsv = async () => {
toast.success('Successfully exported all table data')
}
}
} catch (e) {
} catch (e: any) {
toast.error(extractSdkResponseErrorMsg(e))
}
}
</script>
<template>
<div>
<a-dropdown>
<a-button v-t="['c:actions']" class="nc-actions-menu-btn nc-toolbar-btn">
<div class="flex gap-1 align-center">
@ -99,7 +102,7 @@ const exportCsv = async () => {
<template #overlay>
<div class="bg-white shadow">
<div>
<div class="nc-menu-item" @click.stop="exportCsv">
<div class="nc-menu-item" @click="exportCsv">
<MdiDownloadIcon />
<!-- Download as CSV -->
{{ $t('activity.downloadCSV') }}
@ -109,7 +112,7 @@ const exportCsv = async () => {
<!-- Upload CSV -->
{{ $t('activity.uploadCSV') }}
</div>
<div class="nc-menu-item" @click.stop>
<div class="nc-menu-item" @click="sharedViewListDlg = true">
<MdiViewListIcon />
<!-- Shared View List -->
{{ $t('activity.listSharedView') }}
@ -123,4 +126,9 @@ const exportCsv = async () => {
</div>
</template>
</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>

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

@ -1,11 +1,18 @@
<script setup lang="ts">
import { ReloadViewDataHookInj } from '~/context'
import MdiReloadIcon from '~icons/mdi/reload'
const reloadTri = inject(ReloadViewDataHookInj)
</script>
<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>
<style scoped></style>

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

@ -1,23 +1,12 @@
<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<{
modelValue?: string
field?: any
}>()
const reloadData = inject(ReloadViewDataHookInj)
const { search, meta } = useSmartsheetStoreOrThrow()
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(() =>
meta?.value?.columns?.map((c) => ({
value: c.id,
@ -27,9 +16,26 @@ const columns = computed(() =>
</script>
<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>
<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>
</a-input>
</template>

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

@ -1,18 +1,167 @@
<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 { 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>
<template>
<div>
<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">
<MdiOpenInNew class="text-grey" />
<div class="flex align-center gap-1" @click="genShareLink">
<MdiOpenInNewIcon class="text-grey" />
<!-- Share View -->
{{ $t('activity.shareView') }}
</div>
</a-button>
<!-- This view is shared via a private link -->
<a-modal
v-model:visible="showShareModel"
size="small"
:title="$t('msg.info.privateLink')"
:footer="null"
width="min(100vw,640px)"
>
<div class="share-link-box nc-share-link-box bg-primary-50">
<div class="flex-1 h-min text-xs">{{ sharedViewUrl }}</div>
<!-- <v-spacer /> -->
<a v-t="['c:view:share:open-url']" :href="sharedViewUrl" target="_blank">
<MdiOpenInNewIcon class="text-sm text-gray-500 mt-2" />
</a>
<MdiCopyIcon class="text-gray-500 text-sm cursor-pointer" @click="copyLink" />
</div>
<a-collapse ghost>
<a-collapse-panel key="1" header="More Options">
<div class="mb-2">
<a-checkbox v-model:checked="passwordProtected" class="!text-xs">{{ $t('msg.info.beforeEnablePwd') }} </a-checkbox>
<!-- todo: add password toggle -->
<div v-if="passwordProtected" class="flex gap-2 mt-2 mb-4">
<a-input
v-model:value="shared.password"
size="small"
class="!text-xs max-w-[250px]"
type="password"
:placeholder="$t('placeholder.password.enter')"
/>
<a-button size="small" class="!text-xs" @click="saveShareLinkPassword"
>{{ $t('placeholder.password.save') }}
</a-button>
</div>
</div>
<div>
<a-checkbox v-if="shared && shared.type === ViewTypes.GRID" v-model:checked="allowCSVDownload" class="!text-xs"
>Allow Download
</a-checkbox>
</div>
</a-collapse-panel>
</a-collapse>
</a-modal>
</div>
</template>
<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">
import type { ColumnType } from 'nocodb-sdk'
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 useViewSorts from '~/composables/useViewSorts'
import MdiMenuDownIcon from '~icons/mdi/menu-down'
import MdiSortIcon from '~icons/mdi/sort'
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 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(
() => view?.value?.id,
() => (view?.value as any)?.id,
() => {
loadSorts()
},
@ -28,19 +35,19 @@ watch(
<template>
<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"
><div class="flex align-center gap-1">
<MdiSortIcon class="text-grey" />
<!-- 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" />
</div>
</a-button>
</v-badge>
</div>
<template #overlay>
<div class="bg-white shadow p-2 menu-filter-dropdown min-w-[400px]">
<div class="sort-grid" @click.stop>
<div class="bg-gray-50 shadow p-2 menu-filter-dropdown min-w-[400px] max-h-[max(80vh,500px)] overflow-auto">
<div v-if="sorts?.length" class="sort-grid mb-2" @click.stop>
<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> -->
<MdiDeleteIcon
@ -57,25 +64,27 @@ watch(
/>
<a-select
v-model:value="sort.direction"
class="flex-shrink-1 flex-grow-0 caption nc-sort-dir-select"
:items="[
{ text: 'asc', value: 'asc' },
{ text: 'desc', value: 'desc' },
]"
size="small"
class="flex-shrink-1 flex-grow-0 caption nc-sort-dir-select !text-xs"
:label="$t('labels.operation')"
density="compact"
variant="solo"
hide-details
@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 }"> -->
<!-- <span class="caption font-weight-regular">{{ item.text }}</span> -->
<!-- </template> -->
<!-- </v-select> -->
</template>
</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">
<MdiAddIcon />
<!-- Add Sort Option -->
@ -94,4 +103,8 @@ watch(
column-gap: 6px;
row-gap: 6px;
}
:deep(.ant-btn, .ant-select, .ant-input, ::placeholder) {
@apply "!text-xs";
}
</style>

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

@ -1,14 +1,19 @@
<script setup lang="ts">
import { ReloadViewDataHookInj } from '~/context'
import MdiDoorOpenIcon from '~icons/mdi/door-open'
import MdiDoorClosedIcon from '~icons/mdi/door-closed'
const navDrawerOpened = ref(false)
import MdiUnfoldMoreVertical from '~icons/mdi/unfold-more-vertical'
import MdiUnfoldLessVertical from '~icons/mdi/unfold-less-vertical'
import { inject, ref } from '#imports'
import { RightSidebarInj } from '~/context'
const Icon = computed(() => (navDrawerOpened.value ? MdiDoorOpenIcon : MdiDoorClosedIcon))
const sidebarOpen = inject(RightSidebarInj, ref(false))
</script>
<template>
<Icon class="text-grey" @click="navDrawerOpened = !navDrawerOpened" />
</template>
<a-tooltip placement="left">
<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">
import type { ColumnType } from 'nocodb-sdk'
import { provide } from 'vue'
import { computed } from '#imports'
import { computed, useColumn } from '#imports'
import { ColumnInj } from '~/context'
import useColumn from '~/composables/useColumn'
interface Props {
column: ColumnType
@ -32,6 +31,7 @@ const {
isEmail,
isJSON,
isDate,
isYear,
isDateTime,
isTime,
isBoolean,
@ -43,6 +43,7 @@ const {
isString,
isSingleSelect,
isMultiSelect,
isPercent,
} = useColumn(column)
</script>
@ -175,8 +176,9 @@ todo :
<CellSingleSelect v-else-if="isSingleSelect" v-model="localState" />
<CellMultiSelect v-else-if="isMultiSelect" 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="isTime" v-model="localState" />
<CellTimePicker v-else-if="isTime" v-model="localState" />
<CellRating v-else-if="isRating" v-model="localState" />
<!-- v-model="localState"
:active="active"
@ -201,6 +203,7 @@ todo :
<!-- v-on="parentListeners"
/>
-->
<CellPercent v-else-if="isPercent" v-model="localState" />
<CellText v-else v-model="localState" />
<!-- 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>
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 useViewData from '~/composables/useViewData'
const meta = inject(MetaInj)
const view = inject(ActiveViewInj)
@ -13,7 +12,7 @@ const isPublicView = false
const selected = reactive<{ row?: number | null; col?: number | null }>({})
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(IsGridInj, false)

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

@ -1,7 +1,14 @@
<script lang="ts" setup>
import { computed } from '@vue/reactivity'
import { ColumnType, isVirtualCol } from 'nocodb-sdk'
import { inject, onKeyStroke, onMounted, provide } from '#imports'
import { isVirtualCol } from 'nocodb-sdk'
import {
inject,
onKeyStroke,
onMounted,
provide,
useGridViewColumnWidth,
useProvideColumnCreateStore,
useViewData,
} from '#imports'
import {
ActiveViewInj,
ChangePageInj,
@ -12,7 +19,7 @@ import {
PaginationDataInj,
ReloadViewDataHookInj,
} from '~/context'
import useViewData from '~/composables/useViewData'
import MdiPlusIcon from '~icons/mdi/plus'
const meta = inject(MetaInj)
const view = inject(ActiveViewInj)
@ -25,8 +32,13 @@ const isPublicView = false
const selected = reactive<{ row?: number | null; col?: number | null }>({})
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(IsGridInj, true)
@ -50,7 +62,7 @@ onKeyStroke(['Enter'], (e) => {
})
watch(
() => view?.value?.id,
() => (view?.value as any)?.id,
async (n?: string, o?: string) => {
if (n && n !== o) {
await loadData()
@ -59,9 +71,22 @@ watch(
{ 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({
loadData,
})
// instantiate column create store
// watchEffect(() => {
if (meta) useProvideColumnCreateStore(meta)
// })
</script>
<template>
@ -71,10 +96,29 @@ defineExpose({
<thead>
<tr>
<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" />
<SmartsheetHeaderCell v-else :column="col" />
</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>
</thead>
<tbody>
@ -94,7 +138,7 @@ defineExpose({
// 'text-center': isCentrallyAligned(columnObj),
// 'required': isRequired(columnObj, rowObj),
}"
:data-col="columnObj.title"
:data-col="columnObj.id"
@click="selectCell(rowIndex, colIndex)"
@dblclick="editEnabled = true"
>
@ -201,10 +245,15 @@ defineExpose({
td,
th {
min-height: 31px !important;
min-height: 41px !important;
height: 41px !important;
position: relative;
padding: 0 5px !important;
min-width: 200px;
padding: 0 5px;
& > * {
@apply flex align-center h-auto;
}
overflow: hidden;
}
table,
@ -246,4 +295,14 @@ defineExpose({
opacity: 0.1;
}
}
:deep {
.resizer:hover,
.resizer:active,
.resizer:focus {
// todo: replace with primary color
@apply bg-blue-500/50;
cursor: col-resize;
}
}
</style>

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

@ -45,18 +45,10 @@ export default {
</script>
<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>
<v-spacer />
<!-- <v-pagination
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"
/> -->
<div class="flex-1" />
<a-pagination
v-if="count !== Infinity"
@ -81,13 +73,12 @@ export default {
@keydown.enter="changePage(page)"
>
<template #append>
<MdiKeyboardIcon small icon.class="mt-1" @click="changePage(page)" />
<MdiKeyboardIcon class="mt-1" @click="changePage(page)" />
</template>
</v-text-field>
</div>
<v-spacer />
<v-spacer />
<div class="flex-1" />
</div>
</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>
<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" />
<SmartsheetToolbarFieldsMenu :show-system-fields="false" />
<SmartsheetToolbarColumnFilterMenu />
<SmartsheetToolbarSortListMenu />
<SmartsheetToolbarShareView />
<SmartsheetToolbarMoreActions />
<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>
</template>
@ -25,7 +20,4 @@
:deep(.nc-toolbar-btn) {
@apply border-0 !text-xs font-semibold px-2;
}
.dot {
@apply w-[3px] h-[3px] bg-gray-300 mx-1 rounded-full;
}
</style>

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

@ -1,8 +1,7 @@
<script setup lang="ts">
import type { ColumnType } from 'nocodb-sdk'
import { provide } from '#imports'
import { provide, useVirtualCell } from '#imports'
import { ColumnInj } from '~/context'
import useVirtualCell from '~/composables/useVirtualCell'
interface Props {
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>
<div>
<h2 class="text-3xl mt-3">Team & Auth</h2>
<div class="mt-2">
<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>
</template>

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

@ -1,19 +1,26 @@
<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 { computed, inject, onMounted, provide, watch, watchEffect } from '#imports'
import { ActiveViewInj, FieldsInj, IsLockedInj, MetaInj, ReloadViewDataHookInj, TabMetaInj } from '~/context'
import useMetas from '~/composables/useMetas'
import type { Ref } from 'vue'
import SmartsheetGrid from '../smartsheet/Grid.vue'
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 activeView = ref<ViewType>()
const el = ref<any>()
const activeView = ref()
const el = ref<typeof SmartsheetGrid>()
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 () => {
await getMeta(tabMeta?.value?.id as string)
@ -21,34 +28,41 @@ watchEffect(async () => {
const reloadEventHook = createEventHook<void>()
// todo: move to store
provide(MetaInj, meta)
provide(TabMetaInj, tabMeta)
provide(ActiveViewInj, activeView)
provide(IsLockedInj, false)
provide(ReloadViewDataHookInj, reloadEventHook)
provide(FieldsInj, fields)
provide(RightSidebarInj, ref(true))
watch(
() => tabMeta && tabMeta?.id,
async (newVal, oldVal) => {
if (newVal !== oldVal) await getMeta(newVal)
},
)
useProvideSmartsheetStore(activeView as Ref<TableType>, meta)
watch(tabMeta, async (newTabMeta, oldTabMeta) => {
if (newTabMeta !== oldTabMeta && newTabMeta.id) await getMeta(newTabMeta.id)
})
</script>
<template>
<div class="nc-container flex h-full">
<div class="flex flex-col h-full flex-1 min-w-0">
<SmartsheetToolbar />
<template v-if="meta">
<div class="flex flex-1 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" />
<SmartsheetGallery v-else-if="activeView.type === ViewTypes.GALLERY" />
<SmartsheetForm v-else-if="activeView.type === ViewTypes.FORM" />
</div>
<SmartsheetSidebar />
</div>
<teleport to="#sidebar-right">
<SmartsheetSidebar />
</teleport>
</template>
</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 ItemChip from './components/ItemChip.vue'
import { ColumnInj } from '~/context'
import useBelongsTo from '~/composables/useBelongsTo'
import { useBelongsTo } from '#imports'
const column = inject(ColumnInj)
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'
const column = inject(ColumnInj)
const value = inject('value')
const { isPg } = useProject()
@ -18,32 +19,23 @@ const showEditFormulaWarningMessage = () => {
}, 3000)
}
const result = computed(() => {
if (isPg) {
return handleTZ(value)
}
return value
})
const result = computed(() => (isPg ? handleTZ(value) : value))
const urls = computed(() => {
return replaceUrlsWithLink(result.value)
})
const urls = computed(() => replaceUrlsWithLink(result.value))
</script>
<template>
<div>
<v-tooltip v-if="column && column.colOptions && column.colOptions.error" bottom color="error">
<template #activator="{ on }">
<span class="caption" v-on="on">ERR<span class="error--text">!</span></span>
<a-tooltip v-if="column && column.colOptions && column.colOptions.error" placement="bottom" class="text-orange-700">
<template #title>
<span class="font-bold">{{ column.colOptions.error }}</span>
</template>
<span class="font-weight-bold">{{ column.colOptions.error }}</span>
</v-tooltip>
<div class="formula-cell-wrapper" @dblclick="showEditFormulaWarningMessage">
<span>ERR!</span>
</a-tooltip>
<div class="pa-2" @dblclick="showEditFormulaWarningMessage">
<div v-if="urls" v-html="urls" />
<div v-else>
{{ result }}
</div>
<div v-if="showEditFormulaWarning" class="edit-warning">
<div v-else>{{ result }}</div>
<div v-if="showEditFormulaWarning" class="text-left text-wrap mt-2 text-[#e65100]">
<!-- TODO: i18n -->
Warning: Formula fields should be configured in the field menu dropdown.
</div>
@ -51,14 +43,4 @@ const urls = computed(() => {
</div>
</template>
<style scoped>
.formula-cell-wrapper {
padding: 10px;
}
.edit-warning {
text-align: left;
margin-top: 10px;
color: #e65100;
}
</style>
<style scoped></style>

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

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

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

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

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

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

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

@ -14,16 +14,10 @@ const readonly = inject(ReadonlyInj, false)
<template>
<v-chip class="chip" :class="{ active }" small text-color="textColor">
<!--
:color="isDark ? '' : 'primary lighten-5'"
@click="!readonly && active && $emit('edit', item)" -->
<span class="name" :title="value">{{ value }}</span>
<span class="name">{{ value }}</span>
<!-- && _isUIAllowed('xcDatatableEditable') -->
<div v-show="active" v-if="!readonly" class="mr-n1 ml-2">
<MdiCloseThickIcon class="unlink-icon">
<!-- @click.stop="$emit('unlink', item)" -->
</MdiCloseThickIcon>
<MdiCloseThickIcon class="unlink-icon" />
</div>
</v-chip>
</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}'`"
:color="['error', 'grey']"
small
icon.class="mr-1 mt-n1"
class="mr-1 mt-n1"
@click.stop="$emit('unlink', ch, i)"
>
mdi-link-variant-remove

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

@ -122,7 +122,7 @@ export default {
@keydown.enter="loadData"
>
<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>
</v-text-field>
<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:
export default () => {
// todo: implement useAttachment
export function useAttachment() {
const localFilesState = reactive([])
const attachments = ref([])

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

@ -1,15 +1,14 @@
import type { ColumnType, TableType } from 'nocodb-sdk'
import type LinkToAnotherRecordColumn from '../../nocodb/src/lib/models/LinkToAnotherRecordColumn'
import useMetas from '~/composables/useMetas'
import { useMetas } from './useMetas'
export default function (column: ColumnType) {
export function useBelongsTo(column: ColumnType) {
const { metas, getMeta } = useMetas()
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 () => {
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(() => {

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

@ -1,9 +1,9 @@
import type { MaybeRef } from '@vueuse/core'
import { computed, effectScope, tryOnScopeDispose, unref, watch, watchEffect } from '#build/imports'
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()
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 { useProject } from '#imports'
export default (column: ColumnType) => {
export function useColumn(column: ColumnType) {
const { project } = useProject()
const uiDatatype: UITypes = (column && column.uidt) as UITypes
@ -17,6 +17,7 @@ export default (column: ColumnType) => {
const isInt = abstractType === 'integer'
const isFloat = abstractType === 'float'
const isDate = abstractType === 'date' || uiDatatype === 'Date'
const isYear = abstractType === 'year' || uiDatatype === 'Year'
const isTime = abstractType === 'time' || uiDatatype === 'Time'
const isDateTime = abstractType === 'datetime' || uiDatatype === 'DateTime'
const isJSON = uiDatatype === 'JSON'
@ -30,6 +31,7 @@ export default (column: ColumnType) => {
const isRating = uiDatatype === UITypes.Rating
const isCurrency = uiDatatype === 'Currency'
const isDuration = uiDatatype === UITypes.Duration
const isPercent = uiDatatype === UITypes.Percent
const isAutoSaved = [
UITypes.SingleLineText,
UITypes.LongText,
@ -55,6 +57,7 @@ export default (column: ColumnType) => {
isInt,
isFloat,
isDate,
isYear,
isTime,
isDateTime,
isJSON,
@ -70,5 +73,6 @@ export default (column: ColumnType) => {
isManualSaved,
isSingleSelect,
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 LinkToAnotherRecordColumn from '../../nocodb/src/lib/models/LinkToAnotherRecordColumn'
import useMetas from '~/composables/useMetas'
import { useMetas } from './useMetas'
export default function (column: ColumnType) {
export function useHasMany(column: ColumnType) {
const { metas, getMeta } = useMetas()
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 () => {
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(() => {

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 LinkToAnotherRecordColumn from '../../nocodb/src/lib/models/LinkToAnotherRecordColumn'
import useMetas from '~/composables/useMetas'
import { useMetas } from './useMetas'
export default function (column: ColumnType) {
export function useManyToMany(column: ColumnType) {
const { metas, getMeta } = useMetas()
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 () => {
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(() => {

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

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

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

@ -1,10 +1,10 @@
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 { 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 { $api } = useNuxtApp()
@ -21,6 +21,7 @@ export default (projectId?: MaybeRef<string>) => {
projectRoles.value = user.roles
}
}
async function loadTables() {
if (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 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 }
}

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

Loading…
Cancel
Save