Browse Source

Merge pull request #2837 from nocodb/feat/views-sidebar

pull/2849/head
Braks 2 years ago committed by GitHub
parent
commit
6e18133d4c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      packages/nc-gui-v2/.eslintrc.js
  2. 33
      packages/nc-gui-v2/app.vue
  3. BIN
      packages/nc-gui-v2/assets/img/discourse-icon.png
  4. 15
      packages/nc-gui-v2/assets/style-v2.scss
  5. 1
      packages/nc-gui-v2/components.d.ts
  6. 248
      packages/nc-gui-v2/components/dlg/ViewCreate.vue
  7. 66
      packages/nc-gui-v2/components/dlg/ViewDelete.vue
  8. 119
      packages/nc-gui-v2/components/general/FlippingCard.vue
  9. 51
      packages/nc-gui-v2/components/general/Social.vue
  10. 7
      packages/nc-gui-v2/components/general/Sponsors.vue
  11. 12
      packages/nc-gui-v2/components/smartsheet-toolbar/ToggleDrawer.vue
  12. 17
      packages/nc-gui-v2/components/smartsheet/Pagination.vue
  13. 281
      packages/nc-gui-v2/components/smartsheet/Sidebar.vue
  14. 19
      packages/nc-gui-v2/components/smartsheet/Toolbar.vue
  15. 148
      packages/nc-gui-v2/components/smartsheet/sidebar/MenuBottom.vue
  16. 244
      packages/nc-gui-v2/components/smartsheet/sidebar/MenuTop.vue
  17. 179
      packages/nc-gui-v2/components/smartsheet/sidebar/RenameableMenuItem.vue
  18. 71
      packages/nc-gui-v2/components/smartsheet/sidebar/index.vue
  19. 1
      packages/nc-gui-v2/components/tabs/Smartsheet.vue
  20. 87
      packages/nc-gui-v2/composables/useApi/index.ts
  21. 78
      packages/nc-gui-v2/composables/useApi/interceptors.ts
  22. 5
      packages/nc-gui-v2/composables/useGlobalState/index.ts
  23. 47
      packages/nc-gui-v2/composables/useTabs.ts
  24. 7
      packages/nc-gui-v2/composables/useViewColumns.ts
  25. 73
      packages/nc-gui-v2/composables/useViewCreate.ts
  26. 24
      packages/nc-gui-v2/composables/useViews.ts
  27. 6
      packages/nc-gui-v2/context/index.ts
  28. 2
      packages/nc-gui-v2/layouts/default.vue
  29. 3
      packages/nc-gui-v2/lib/index.ts
  30. 16
      packages/nc-gui-v2/nuxt-shim.d.ts
  31. 10
      packages/nc-gui-v2/nuxt.config.ts
  32. 45629
      packages/nc-gui-v2/package-lock.json
  33. 5
      packages/nc-gui-v2/package.json
  34. 4
      packages/nc-gui-v2/pages/forgot-password.vue
  35. 2
      packages/nc-gui-v2/pages/index/user/index/index.vue
  36. 1
      packages/nc-gui-v2/pages/nc/[projectId]/index.vue
  37. 8
      packages/nc-gui-v2/pages/nc/[projectId]/index/index.vue
  38. 10
      packages/nc-gui-v2/pages/project/index.vue
  39. 13
      packages/nc-gui-v2/pages/project/index/create-external.vue
  40. 4
      packages/nc-gui-v2/pages/signin.vue
  41. 4
      packages/nc-gui-v2/pages/signup.vue
  42. 6
      packages/nc-gui-v2/plugins/ant.ts
  43. 89
      packages/nc-gui-v2/plugins/api.ts
  44. 8
      packages/nc-gui-v2/tsconfig.json
  45. 22
      packages/nc-gui-v2/utils/generateName.ts
  46. 11
      packages/nc-gui-v2/utils/index.ts
  47. 9
      packages/nc-gui-v2/utils/projectCreateUtils.ts

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'],
}

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

@ -3,10 +3,13 @@ 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'
const { $state } = useNuxtApp()
const { isLoading } = useApi({ useGlobalInstance: true })
const sidebar = ref<HTMLDivElement>()
const email = computed(() => $state.user?.value?.email ?? '---')
@ -16,22 +19,20 @@ const signOut = () => {
navigateTo('/signin')
}
const toggleSidebar = useToggle($state.sidebarOpen)
const sidebarOpen = computed({
const sidebarCollapsed = computed({
get: () => !$state.sidebarOpen.value,
set: (val) => toggleSidebar(val),
set: (val) => ($state.sidebarOpen.value = !val),
})
const toggleSidebar = () => {
sidebarCollapsed.value = !sidebarCollapsed.value
}
</script>
<template>
<a-layout>
<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 +42,9 @@ 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="isLoading" class="text-gray-400 ml-3">
{{ $t('general.loading') }} <MdiReload :class="{ 'animate-infinite animate-spin !text-success': isLoading }" />
</div>
</div>
<div class="flex-1" />
@ -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

15
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,10 +68,6 @@ h1, h2, h3, h4, h5, h6, p, label, button, textarea, select {
@apply color-transition;
}
:root {
--header-height: 64px;
}
html {
overflow-y: auto !important;
}

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

@ -38,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']
ARow: typeof import('ant-design-vue/es')['Row']
ASelect: typeof import('ant-design-vue/es')['Select']
ASelectOption: typeof import('ant-design-vue/es')['SelectOption']

248
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
}
const emit = defineEmits(['update:modelValue', 'created'])
interface Emits {
(event: 'update:modelValue', value: boolean): void
(event: 'created', value: GridType | KanbanType | GalleryType | FormType): void
}
const valid = ref(false)
interface Form {
title: string
type: ViewTypes
copy_from_id: string | null
}
const props = defineProps<Props>()
const emits = defineEmits<Emits>()
const inputEl = $ref<ComponentPublicInstance>()
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,87 @@ 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 || [])
nextTick(() => {
const el = inputEl?.value?.$el
el?.querySelector('input')?.focus()
el?.querySelector('input')?.select()
form?.value?.validate()
})
}
},
() => props.type,
(newType) => (form.type = newType),
)
/* 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() {
function init() {
form.title = generateUniqueTitle(capitalize(ViewTypes[props.type].toLowerCase()), viewList?.value || [], 'title')
nextTick(() => {
const el = inputEl?.$el as HTMLInputElement
if (el) {
el.focus()
el.select()
}
})
}
async function onSubmit() {
const isValid = await formValidator?.validateFields()
if (!isValid) return
if (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
}
} 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();
});
}, */
if (data) {
notification.success({
message: 'View created successfully',
})
emits('created', data)
}
} catch (e: any) {
notification.error({
message: e.message,
})
}
vModel.value = false
}
}
</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">
{{ $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>
<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') }}
</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>

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

@ -0,0 +1,66 @@
<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" type="danger" :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>

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

@ -15,33 +15,30 @@ 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"
url="https://github.com/nocodb/nocodb"
:social-medias="['renren', 'douban', 'weibo', 'wechat']"
/>
<div v-else class="flex justify-between gap-1 w-full px-2">
<MdiDiscord v-t="['e:community:discord']" class="icon text-[#7289DA]" @click="open('https://discord.gg/5RgZmkW')" />
<div
v-t="['e:community:discourse']"
class="icon flex items-center justify-center min-w-[43px]"
@click="open('https://community.nocodb.com/')"
>
<div class="discourse" />
</div>
<MdiReddit v-t="['e:community:reddit']" class="icon text-[#FF4600]" @click="open('https://www.reddit.com/r/NocoDB/')" />
<MdiTwitter v-t="['e:community:twitter']" class="icon text-[#1DA1F2]" @click="open('https://twitter.com/NocoDB')" />
<MdiCalendarMonth
v-t="['e:community:book-demo']"
class="icon text-green-500"
@click="open('https://calendly.com/nocodb-meeting')"
/>
<general-share
v-if="isZhLang"
class="flex justify-center"
url="https://github.com/nocodb/nocodb"
:social-medias="['renren', 'douban', 'weibo', 'wechat']"
/>
<div v-else class="flex justify-between gap-1 w-full px-2">
<MdiDiscord v-t="['e:community:discord']" class="icon text-[#7289DA]" @click="open('https://discord.gg/5RgZmkW')" />
<div
v-t="['e:community:discourse']"
class="icon flex items-center justify-center min-w-[43px]"
@click="open('https://community.nocodb.com/')"
>
<div class="discourse" />
</div>
</v-list>
<MdiReddit v-t="['e:community:reddit']" class="icon text-[#FF4600]" @click="open('https://www.reddit.com/r/NocoDB/')" />
<MdiTwitter v-t="['e:community:twitter']" class="icon text-[#1DA1F2]" @click="open('https://twitter.com/NocoDB')" />
<MdiCalendarMonth
v-t="['e:community:book-demo']"
class="icon text-green-500"
@click="open('https://calendly.com/nocodb-meeting')"
/>
</div>
</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;
}

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>

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

@ -1,14 +1,14 @@
<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)
const Icon = computed(() => (navDrawerOpened.value ? MdiDoorOpenIcon : MdiDoorClosedIcon))
const drawerOpen = inject('navDrawerOpen', ref(false))
const Icon = computed(() => (drawerOpen.value ? MdiDoorOpenIcon : MdiDoorClosedIcon))
</script>
<template>
<Icon class="text-grey" @click="navDrawerOpened = !navDrawerOpened" />
<a-tooltip placement="left">
<template #title> {{ $t('tooltip.toggleNavDraw') }} </template>
<Icon class="rounded text-xl p-1 text-gray-500 hover:(text-white bg-pink-500 shadow)" @click="drawerOpen = !drawerOpen" />
</a-tooltip>
</template>
<style scoped></style>

17
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"
@ -86,8 +78,7 @@ export default {
</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>

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

@ -1,23 +1,40 @@
<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 class="dot" />
</div>
</template>

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

@ -0,0 +1,148 @@
<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">
<a-divider class="my-2" />
<h3 class="px-3 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 md:gap-4 lg:gap-8 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] w-[250px]" :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>

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

@ -0,0 +1,244 @@
<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 type { TabItem } from '~/composables/useTabs'
import { TabType } from '~/composables/useTabs'
import { ActiveViewInj, 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 { addTab } = useTabs()
const { api } = useApi()
/** 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 and add new tab if necessary */
function changeView(view: { id: string; alias?: string; title?: string; type: ViewTypes }) {
activeView.value = view
const tabProps: TabItem = {
id: view.id,
title: (view.alias ?? view.title) || '',
type: TabType.VIEW,
}
addTab(tabProps)
}
/** 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="nc-headline 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-[50vh] md:max-h-[200px] lg:max-h-[400px] xl:max-h-[600px] 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>

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

@ -0,0 +1,71 @@
<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 { inject, provide, ref, useApi, useViews, watch } from '#imports'
import { ActiveViewInj, MetaInj, ViewListInj } from '~/context'
const meta = inject(MetaInj, ref())
const activeView = inject(ActiveViewInj, ref())
const { views, loadViews } = useViews(meta)
const { api } = useApi()
provide(ViewListInj, views)
/** Sidebar visible */
const drawerOpen = inject('navDrawerOpen', ref(false))
/** 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 current views and on change set the next active view */
watch(
views,
(nextViews) => {
if (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 theme="light" class="shadow" :width="drawerOpen ? 0 : 250">
<div class="flex flex-col h-full">
<MenuTop @open-modal="openModal" @deleted="loadViews" @sorted="loadViews" />
<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;
}
</style>

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

@ -27,6 +27,7 @@ provide(ActiveViewInj, activeView)
provide(IsLockedInj, false)
provide(ReloadViewDataHookInj, reloadEventHook)
provide(FieldsInj, fields)
provide('navDrawerOpen', ref(false))
watch(
() => tabMeta && tabMeta?.id,

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

@ -0,0 +1,87 @@
import type { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios'
import { Api } from 'nocodb-sdk'
import type { Ref } from 'vue'
import type { EventHook, MaybeRef } from '@vueuse/core'
import { addAxiosInterceptors } from './interceptors'
import { createEventHook, ref, unref, useNuxtApp } from '#imports'
import type { NuxtApp } from '#app'
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']
}
export function createApiInstance(app: NuxtApp, baseURL = 'http://localhost:8080') {
const api = new Api({
baseURL,
})
addAxiosInterceptors(api, app)
return api
}
/** todo: add props? */
interface UseApiProps<D = any> {
axiosConfig?: MaybeRef<AxiosRequestConfig<D>>
useGlobalInstance?: MaybeRef<boolean>
}
export function useApi<Data = any, RequestConfig = any>(props: UseApiProps<Data> = {}): UseApiReturn<Data, RequestConfig> {
const isLoading = ref(false)
const error = ref(null)
const response = ref<any>(null)
const errorHook = createEventHook<AxiosError<Data, RequestConfig>>()
const responseHook = createEventHook<AxiosResponse<Data, RequestConfig>>()
const api = unref(props.useGlobalInstance) ? useNuxtApp().$api : createApiInstance(useNuxtApp())
api.instance.interceptors.request.use(
(config) => {
error.value = null
response.value = null
isLoading.value = true
return {
...config,
...unref(props),
}
},
(requestError) => {
errorHook.trigger(requestError)
error.value = requestError
response.value = null
isLoading.value = false
return requestError
},
)
api.instance.interceptors.response.use(
(apiResponse) => {
responseHook.trigger(apiResponse as AxiosResponse<Data, RequestConfig>)
// can't properly typecast
response.value = apiResponse
isLoading.value = false
return apiResponse
},
(apiError) => {
errorHook.trigger(apiError)
error.value = apiError
isLoading.value = false
return apiError
},
)
return { api, isLoading, response, error, onError: errorHook.on, onResponse: responseHook.on }
}

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

@ -0,0 +1,78 @@
import type { Api } from 'nocodb-sdk'
import { navigateTo, useRoute, useRouter } from '#imports'
import type { NuxtApp } from '#app'
const DbNotFoundMsg = 'Database config not found'
export function addAxiosInterceptors(api: Api<any>, app: NuxtApp) {
const router = useRouter()
const route = useRoute()
api.instance.interceptors.request.use((config) => {
config.headers['xc-gui'] = 'true'
if (app.$state.token.value) config.headers['xc-auth'] = app.$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') {
app.$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
app.$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) => {
app.$state.signOut()
// todo: handle new user
navigateTo('/signIn')
return Promise.reject(error)
})
},
)
}

5
packages/nc-gui-v2/composables/useGlobalState/index.ts

@ -2,6 +2,7 @@ import { breakpointsTailwind, usePreferredLanguages, useStorage } from '@vueuse/
import { useJwt } from '@vueuse/integrations/useJwt'
import type { JwtPayload } from 'jwt-decode'
import initialFeedBackForm from './initialFeedbackForm'
import { notification } from 'ant-design-vue'
import { computed, ref, toRefs, useBreakpoints, useNuxtApp, useTimestamp, watch } from '#imports'
import type { Actions, Getters, GlobalState, StoredState, User } from '~/lib/types'
@ -143,6 +144,10 @@ export const useGlobalState = (): GlobalState => {
}
})
.catch((err) => {
notification.error({
// todo: add translation
message: err.message || 'You have been signed out.',
})
console.error(err)
signOut()

47
packages/nc-gui-v2/composables/useTabs.ts

@ -1,8 +1,7 @@
import type { WritableComputedRef } from '@vue/reactivity'
import { useState } from '#app'
import useProject from '~/composables/useProject'
import { navigateTo, useProject, useRoute, useState } from '#imports'
enum TabType {
export enum TabType {
TABLE = 'table',
VIEW = 'view',
AUTH = 'auth',
@ -23,45 +22,49 @@ function getPredicate(key: Partial<TabItem>) {
export default () => {
const tabs = useState<TabItem[]>('tabs', () => [])
// const activeTab = useState<number>('activeTab', () => 0)
const route = useRoute()
const router = useRouter()
const { tables } = useProject()
const activeTabIndex: WritableComputedRef<number> = computed({
get() {
console.log(route?.name)
if ((route?.name as string)?.startsWith('nc-projectId-index-index-type-title-viewTitle') && tables?.value?.length) {
if ((route.name as string)?.startsWith('nc-projectId-index-index-type-title-viewTitle') && tables.value?.length) {
const tab: Partial<TabItem> = { type: route.params.type as TabType, title: route.params.title as string }
const id = tables?.value?.find((t) => t.title === tab.title)?.id
const id = tables.value?.find((t) => t.title === tab.title)?.id
tab.id = id as string
let index = tabs.value.findIndex((t) => t.id === tab.id)
if (index === -1) {
tabs.value.push(tab as TabItem)
index = tabs.value.length - 1
}
return index
} else if ((route?.name as string)?.startsWith('nc-projectId-index-index-auth')) {
} else if ((route.name as string)?.startsWith('nc-projectId-index-index-auth')) {
return tabs.value.findIndex((t) => t.type === 'auth')
}
return -1
},
set(index: number) {
if (index === -1) {
router.push(`/nc/${route.params.projectId}`)
navigateTo(`/nc/${route.params.projectId}`)
} else {
const tab = tabs.value[index]
if (!tab) {
return
}
if (tab.type === TabType.TABLE) {
router.push(`/nc/${route.params.projectId}/table/${tab?.title}`)
} else if (tab.type === TabType.VIEW) {
router.push(`/nc/${route.params.projectId}/view/${tab?.title}`)
} else if (tab.type === TabType.AUTH) {
router.push(`/nc/${route.params.projectId}/auth`)
if (!tab) return
switch (tab.type) {
case TabType.TABLE:
return navigateTo(`/nc/${route.params.projectId}/table/${tab?.title}`)
case TabType.VIEW:
return navigateTo(`/nc/${route.params.projectId}/view/${tab?.title}`)
case TabType.AUTH:
return navigateTo(`/nc/${route.params.projectId}/auth`)
}
}
},
@ -81,18 +84,20 @@ export default () => {
activeTabIndex.value = tabs.value.length - 1
}
}
const clearTabs = () => {
tabs.value = []
}
const closeTab = async (key: number | Partial<TabItem>) => {
const index = typeof key === 'number' ? key : tabs.value.findIndex(getPredicate(key))
if (activeTabIndex.value === index) {
let newTabIndex = index - 1
if (newTabIndex < 0 && tabs.value?.length > 1) newTabIndex = index + 1
if (newTabIndex === -1) {
await router.push(`/nc/${route.params.projectId}`)
await navigateTo(`/nc/${route.params.projectId}`)
} else {
await router.push(`/nc/${route.params.projectId}/table/${tabs.value?.[newTabIndex]?.title}`)
await navigateTo(`/nc/${route.params.projectId}/table/${tabs.value?.[newTabIndex]?.title}`)
}
}
tabs.value.splice(index, 1)

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

@ -111,7 +111,12 @@ export default function (
return (fields?.value
?.filter((c) => {
// hide system columns if not enabled
if (!showSystemFields.value && isSystemColumn(metaColumnById?.value?.[c.fk_column_id as string])) {
if (
!showSystemFields.value &&
metaColumnById.value &&
metaColumnById?.value?.[c.fk_column_id as string] &&
isSystemColumn(metaColumnById?.value?.[c.fk_column_id as string])
) {
return false
}
return c.show

73
packages/nc-gui-v2/composables/useViewCreate.ts

@ -1,73 +0,0 @@
import type { TableType, ViewType } from 'nocodb-sdk'
import { ViewTypes } from 'nocodb-sdk'
import type { Ref } from 'vue'
import { useToast } from 'vue-toastification'
import { useNuxtApp } from '#app'
// import useMetas from '~/composables/useMetas'
export default (meta: Ref<TableType>, onViewCreate?: (viewMeta: any) => void) => {
const view = reactive<{ title: string; type?: ViewTypes }>({
title: '',
})
const loading = ref(false)
const { $api } = useNuxtApp()
const toast = useToast()
// unused
// const { metas } = useMetas()
const createView = async (viewType: ViewTypes, selectedViewId = null) => {
loading.value = true
try {
let data
switch (viewType) {
case ViewTypes.GRID:
// todo: update swagger
data = await $api.dbView.gridCreate(
meta?.value?.id as string,
{
title: view?.title,
copy_from_id: selectedViewId,
} as any,
)
break
case ViewTypes.GALLERY:
data = await $api.dbView.galleryCreate(
meta?.value?.id as string,
{
title: view?.title,
copy_from_id: selectedViewId,
} as any,
)
break
case ViewTypes.FORM:
data = await $api.dbView.formCreate(
meta?.value?.id as string,
{
title: view?.title,
copy_from_id: selectedViewId,
} as any,
)
break
}
toast.success('View created successfully')
onViewCreate?.(data)
} catch (e: any) {
toast.error(e.message)
}
loading.value = false
}
const generateUniqueTitle = (views: ViewType[]) => {
let c = 1
while (views?.some((t) => t.title === `${meta?.value?.title}${c}`)) {
c++
}
view.title = `${meta?.value?.title}${c}`
}
return { view, createView, generateUniqueTitle, loading }
}

24
packages/nc-gui-v2/composables/useViews.ts

@ -1,15 +1,27 @@
import type { FormType, GalleryType, GridType, KanbanType, TableType } from 'nocodb-sdk'
import type { Ref } from 'vue'
import type { MaybeRef } from '@vueuse/core'
import type { WatchOptions } from '@vue/runtime-core'
import { useNuxtApp } from '#app'
export default function (meta: Ref<TableType>) {
const views = ref<(GridType | FormType | KanbanType | GalleryType)[]>()
export default function (meta: MaybeRef<TableType | undefined>, watchOptions: WatchOptions = {}) {
let views = $ref<(GridType | FormType | KanbanType | GalleryType)[]>([])
const { $api } = useNuxtApp()
const loadViews = async () => {
if (meta.value?.id)
views.value = (await $api.dbView.list(meta.value?.id)).list as (GridType | FormType | KanbanType | GalleryType)[]
const _meta = unref(meta)
if (_meta && _meta.id) {
const response = (await $api.dbView.list(_meta.id)).list as any[]
if (response) {
views = response.sort((a, b) => a.order - b.order)
}
}
}
return { views, loadViews }
watch(() => meta, loadViews, {
immediate: true,
...watchOptions,
})
return { views: $$(views), loadViews }
}

6
packages/nc-gui-v2/context/index.ts

@ -1,4 +1,4 @@
import type { ColumnType, TableType, ViewType } from 'nocodb-sdk'
import type { ColumnType, FormType, GalleryType, GridType, KanbanType, TableType } from 'nocodb-sdk'
import type { ComputedRef, InjectionKey, Ref } from 'vue'
import type { EventHook } from '@vueuse/core'
import type { useViewData } from '#imports'
@ -14,8 +14,8 @@ export const IsFormInj: InjectionKey<boolean> = Symbol('is-form-injection')
export const IsGridInj: InjectionKey<boolean> = Symbol('is-grid-injection')
export const IsLockedInj: InjectionKey<boolean> = Symbol('is-locked-injection')
export const ValueInj: InjectionKey<any> = Symbol('value-injection')
export const ActiveViewInj: InjectionKey<Ref<ViewType>> = Symbol('active-view-injection')
export const ActiveViewInj: InjectionKey<Ref<GridType | FormType | KanbanType | GalleryType>> = Symbol('active-view-injection')
export const ReadonlyInj: InjectionKey<any> = Symbol('readonly-injection')
export const ReloadViewDataHookInj: InjectionKey<EventHook<void>> = Symbol('reload-view-data-injection')
export const FieldsInj: InjectionKey<Ref<any[]>> = Symbol('fields-injection')
export const ViewListInj: InjectionKey<Ref<ViewType[]>> = Symbol('view-list-injection')
export const ViewListInj: InjectionKey<Ref<(GridType | FormType | KanbanType | GalleryType)[]>> = Symbol('view-list-injection')

2
packages/nc-gui-v2/layouts/default.vue

@ -18,7 +18,7 @@ export default {
</script>
<template>
<a-layout-content>
<a-layout-content class="pl-2 pt-2">
<teleport v-if="$slots.sidebar" to="#sidebar">
<slot name="sidebar" />
</teleport>

3
packages/nc-gui-v2/lib/index.ts

@ -0,0 +1,3 @@
export * from './constants'
export * from './enums'
export * from './types'

16
packages/nc-gui-v2/nuxt-shim.d.ts vendored

@ -1,17 +1,19 @@
import type { RemovableRef } from '@vueuse/core'
import type { Api } from 'nocodb-sdk'
import type { Api as BaseAPI } from 'nocodb-sdk'
import type { I18n } from 'vue-i18n'
import type { GlobalState } from '~/lib/types'
import type { GlobalState } from './src/lib/types'
import type messages from '@intlify/vite-plugin-vue-i18n/messages'
import type en from './lang/en.json'
type MessageSchema = typeof en
declare module '#app/nuxt' {
interface NuxtApp {
$api: Api<any>;
$api: BaseAPI<any>
/** {@link import('./plugins/tele') Telemetry} */
$tele: {
emit: (event: string, data: any) => void
}
// tele.emit
/** {@link import('./plugins/tele') Telemetry} Emit telemetry event */
$e: (event: string, data?: any) => void
$state: GlobalState
}
@ -19,6 +21,6 @@ declare module '#app/nuxt' {
declare module '@vue/runtime-core' {
interface App {
i18n: I18n<messages, unknown, unknown, false>['global']
i18n: I18n<MessageSchema, unknown, unknown, false>['global']
}
}

10
packages/nc-gui-v2/nuxt.config.ts

@ -87,4 +87,14 @@ export default defineNuxtConfig({
reactivityTransform: true,
viteNode: false,
},
typescript: {
typeCheck: true,
strict: true,
tsConfig: {
compilerOptions: {
types: ['@intlify/vite-plugin-vue-i18n/client', 'vue-i18n', 'unplugin-icons/types/vue', 'nuxt-windicss'],
},
},
},
})

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

File diff suppressed because it is too large Load Diff

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

@ -25,9 +25,9 @@
"unique-names-generator": "^4.7.1",
"vue-i18n": "^9.1.10",
"vue-toastification": "^2.0.0-rc.5",
"xlsx": "^0.17.3",
"vuedraggable": "^4.1.0",
"vuetify": "^3.0.0-alpha.13"
"vuetify": "^3.0.0-alpha.13",
"xlsx": "^0.17.3"
},
"devDependencies": {
"@antfu/eslint-config": "^0.25.2",
@ -39,6 +39,7 @@
"@iconify-json/mdi": "^1.1.25",
"@iconify-json/ri": "^1.1.3",
"@intlify/vite-plugin-vue-i18n": "^4.0.0",
"@types/axios": "^0.14.0",
"@types/papaparse": "^5.3.2",
"@types/sortablejs": "^1.13.0",
"@vitejs/plugin-vue": "^2.3.3",

4
packages/nc-gui-v2/pages/forgot-password.vue

@ -75,7 +75,7 @@ const resetError = () => {
>
<div class="h-full w-full flex flex-col flex-wrap justify-center items-center">
<div
class="color-transition bg-white dark:(!bg-gray-900 !text-white) md:relative flex flex-col justify-center gap-2 w-full max-w-[500px] mx-auto p-8 md:(rounded-lg border-1 border-gray-200 shadow-xl)"
class="color-transition bg-white dark:(!bg-gray-900 !text-white) relative flex flex-col justify-center gap-2 w-full max-w-[500px] mx-auto p-8 md:(rounded-lg border-1 border-gray-200 shadow-xl)"
>
<general-noco-icon />
@ -128,7 +128,7 @@ const resetError = () => {
}
.submit {
@apply ml-1 bordered border-gray-300 rounded-lg p-4 bg-gray-100/50 text-white bg-primary hover:bg-primary/75 dark:(!bg-secondary/75 hover:!bg-secondary/50);
@apply ml-1 border border-gray-300 rounded-lg p-4 bg-gray-100/50 text-white bg-primary hover:bg-primary/75 dark:(!bg-secondary/75 hover:!bg-secondary/50);
}
}
</style>

2
packages/nc-gui-v2/pages/index/user/index/index.vue

@ -140,7 +140,7 @@ const resetError = () => {
}
.submit {
@apply ml-1 bordered border-gray-300 rounded-lg p-4 bg-gray-100/50 text-white bg-primary hover:bg-primary/75 dark:(!bg-secondary/75 hover:!bg-secondary/50);
@apply ml-1 border border-gray-300 rounded-lg p-4 bg-gray-100/50 text-white bg-primary hover:bg-primary/75 dark:(!bg-secondary/75 hover:!bg-secondary/50);
}
}
</style>

1
packages/nc-gui-v2/pages/nc/[projectId]/index.vue

@ -21,6 +21,7 @@ $state.sidebarOpen.value = true
<template #sidebar>
<DashboardTreeView />
</template>
<NuxtPage />
</NuxtLayout>
</template>

8
packages/nc-gui-v2/pages/nc/[projectId]/index/index.vue

@ -36,13 +36,13 @@ function openQuickImportDialog(type: string) {
</script>
<template>
<div class="nc-container d-flex flex-column">
<div class="nc-container flex flex-col">
<div>
<a-tabs v-model:activeKey="activeTabIndex" size="small" type="editable-card" @edit="closeTab">
<a-tabs v-model:activeKey="activeTabIndex" type="editable-card" @edit="closeTab">
<a-tab-pane v-for="(tab, i) in tabs" :key="i" :tab="tab.title" />
<template #leftExtra>
<a-menu v-model:selectedKeys="currentMenu" mode="horizontal">
<a-menu v-model:selectedKeys="currentMenu" class="border-0" mode="horizontal">
<a-sub-menu key="addORImport">
<template #title>
<div class="text-sm flex items-center gap-2">
@ -142,7 +142,7 @@ function openQuickImportDialog(type: string) {
<style scoped>
.nc-container {
height: calc(calc(100vh - var(--header-height)));
height: calc(100vh - var(--header-height) - 8px);
@apply overflow-hidden;
}

10
packages/nc-gui-v2/pages/project/index.vue

@ -1,13 +1,5 @@
<template>
<NuxtLayout>
<div class="w-full nc-container">
<NuxtPage />
</div>
<NuxtPage />
</NuxtLayout>
</template>
<style scoped>
.nc-container {
height: calc(100vh - var(--header-height));
}
</style>

13
packages/nc-gui-v2/pages/project/index/create-external.vue

@ -5,18 +5,19 @@ import { useI18n } from 'vue-i18n'
import { useToast } from 'vue-toastification'
import { ref } from '#imports'
import { navigateTo, useNuxtApp } from '#app'
import { ClientType } from '~/lib/enums'
import { extractSdkResponseErrorMsg } from '~/utils/errorUtils'
import { readFile } from '~/utils/fileUtils'
import type { ProjectCreateForm } from '~/utils/projectCreateUtils'
import { ClientType } from '~/lib'
import type { ProjectCreateForm } from '~/utils'
import {
clientTypes,
extractSdkResponseErrorMsg,
fieldRequiredValidator,
generateUniqueName,
getDefaultConnectionConfig,
getTestDatabaseName,
projectTitleValidator,
readFile,
sslUsage,
} from '~/utils/projectCreateUtils'
import { fieldRequiredValidator, projectTitleValidator } from '~/utils/validation'
} from '~/utils'
const useForm = Form.useForm
const loading = ref(false)

4
packages/nc-gui-v2/pages/signin.vue

@ -79,7 +79,7 @@ const resetError = () => {
>
<div class="h-full w-full flex flex-col flex-wrap items-center pt-[100px]">
<div
class="bg-white dark:(!bg-gray-900 !text-white) md:relative flex flex-col justify-center gap-2 w-full max-w-[500px] mx-auto p-8 md:(rounded-lg border-1 border-gray-200 shadow-xl)"
class="bg-white dark:(!bg-gray-900 !text-white) relative flex flex-col justify-center gap-2 w-full max-w-[500px] mx-auto p-8 md:(rounded-lg border-1 border-gray-200 shadow-xl)"
>
<general-noco-icon />
@ -152,7 +152,7 @@ const resetError = () => {
}
.submit {
@apply ml-1 bordered border-gray-300 rounded-lg p-4 bg-gray-100/50 text-white bg-primary hover:bg-primary/75 dark:(!bg-secondary/75 hover:!bg-secondary/50);
@apply ml-1 border border-gray-300 rounded-lg p-4 bg-gray-100/50 text-white bg-primary hover:bg-primary/75 dark:(!bg-secondary/75 hover:!bg-secondary/50);
}
}
</style>

4
packages/nc-gui-v2/pages/signup.vue

@ -76,7 +76,7 @@ const resetError = () => {
>
<div class="h-full w-full flex flex-col flex-wrap pt-[100px]">
<div
class="bg-white dark:(!bg-gray-900 !text-white) md:relative flex flex-col justify-center gap-2 w-full max-w-[500px] mx-auto p-8 md:(rounded-lg border-1 border-gray-200 shadow-xl)"
class="bg-white dark:(!bg-gray-900 !text-white) relative flex flex-col justify-center gap-2 w-full max-w-[500px] mx-auto p-8 md:(rounded-lg border-1 border-gray-200 shadow-xl)"
>
<general-noco-icon />
@ -137,7 +137,7 @@ const resetError = () => {
}
.submit {
@apply ml-1 bordered border-gray-300 rounded-lg p-4 bg-gray-100/50 text-white bg-primary hover:bg-primary/75 dark:(!bg-secondary/75 hover:!bg-secondary/50);
@apply ml-1 border border-gray-300 rounded-lg p-4 bg-gray-100/50 text-white bg-primary hover:bg-primary/75 dark:(!bg-secondary/75 hover:!bg-secondary/50);
}
}
</style>

6
packages/nc-gui-v2/plugins/ant.ts

@ -0,0 +1,6 @@
import { Menu as AntMenu } from 'ant-design-vue'
import { defineNuxtPlugin } from '#imports'
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.component(AntMenu.name, AntMenu)
})

89
packages/nc-gui-v2/plugins/api.ts

@ -1,88 +1,7 @@
import { Api } from 'nocodb-sdk'
import { defineNuxtPlugin, navigateTo } from '#app'
import type { GlobalState } from '~/lib/types'
import { defineNuxtPlugin } from '#imports'
import { createApiInstance } from '~/composables/useApi'
export default defineNuxtPlugin((nuxtApp) => {
const api = new Api({
baseURL: 'http://localhost:8080',
})
addAxiosInterceptors(api, nuxtApp as any)
nuxtApp.provide('api', api)
/** injects a global api instance */
nuxtApp.provide('api', createApiInstance(nuxtApp))
})
const DbNotFoundMsg = 'Database config not found'
function addAxiosInterceptors(api: Api<any>, app: { $state: GlobalState }) {
const router = useRouter()
const route = useRoute()
api.instance.interceptors.request.use((config) => {
config.headers['xc-gui'] = 'true'
if (app.$state?.token.value) config.headers['xc-auth'] = app.$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') {
app.$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
app.$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) => {
app.$state.signOut()
// todo: handle new user
navigateTo('/signIn')
return Promise.reject(error)
})
},
)
}

8
packages/nc-gui-v2/tsconfig.json

@ -10,9 +10,13 @@
"noUnusedLocals": false,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"types": ["@intlify/vite-plugin-vue-i18n/client", "vue-i18n", "unplugin-icons/types/vue", "nuxt-windicss"]
"types": [
"@intlify/vite-plugin-vue-i18n/client",
"vue-i18n",
"unplugin-icons/types/vue",
"nuxt-windicss"
]
},
"files": ["nuxt-shim.d.ts", "windi.config.ts"],
"exclude": [
"node_modules",
"dist",

22
packages/nc-gui-v2/utils/generateName.ts

@ -0,0 +1,22 @@
import { adjectives, animals, starWars, uniqueNamesGenerator } from 'unique-names-generator'
export const generateUniqueName = () => {
return uniqueNamesGenerator({
dictionaries: [[starWars], [adjectives, animals]][Math.floor(Math.random() * 2)],
})
.toLowerCase()
.replace(/[ -]/g, '_')
}
export const generateUniqueTitle = <T extends Record<string, any> = Record<string, any>>(
title: string,
arr: T[],
predicate: keyof T,
) => {
let c = 1
while (arr.some((item) => item[predicate] === (`${title}-${c}` as keyof T))) {
c++
}
return `${title}-${c}`
}

11
packages/nc-gui-v2/utils/index.ts

@ -0,0 +1,11 @@
export * from './colorsUtils'
export * from './dateTimeUtils'
export * from './durationHelper'
export * from './errorUtils'
export * from './fileUtils'
export * from './filterUtils'
export * from './generateName'
export * from './projectCreateUtils'
export * from './urlUtils'
export * from './validation'
export * from './viewUtils'

9
packages/nc-gui-v2/utils/projectCreateUtils.ts

@ -1,4 +1,3 @@
import { adjectives, animals, starWars, uniqueNamesGenerator } from 'unique-names-generator'
import { ClientType } from '~/lib/enums'
export interface ProjectCreateForm {
@ -208,11 +207,3 @@ export const getDefaultConnectionConfig = (client: ClientType): ProjectCreateFor
}
export const sslUsage = ['No', 'Preferred', 'Required', 'Required-CA', 'Required-IDENTITY']
export const generateUniqueName = () => {
return uniqueNamesGenerator({
dictionaries: [[starWars], [adjectives, animals]][Math.floor(Math.random() * 2)],
})
.toLowerCase()
.replace(/[ -]/g, '_')
}

Loading…
Cancel
Save