Browse Source

Merge branch 'refactor/gui-v2' into feat/gui-v2-templates

pull/2828/head
Wing-Kam Wong 2 years ago
parent
commit
13020cbf12
  1. 8
      packages/nc-gui-v2/app.vue
  2. 15
      packages/nc-gui-v2/components.d.ts
  3. 3
      packages/nc-gui-v2/components/dashboard/TabView.vue
  4. 105
      packages/nc-gui-v2/components/dashboard/settings/AuditTab.vue
  5. 52
      packages/nc-gui-v2/components/monaco/Editor.vue
  6. 5
      packages/nc-gui-v2/components/tabs/Auth.vue
  7. 8
      packages/nc-gui-v2/composables/useGlobalState.ts
  8. 16
      packages/nc-gui-v2/composables/useProject.ts
  9. 4
      packages/nc-gui-v2/composables/useTabs.ts
  10. 20
      packages/nc-gui-v2/composables/useUIPermission/index.ts
  11. 1
      packages/nc-gui-v2/lang/en.json
  12. 1
      packages/nc-gui-v2/lib/constants.ts
  13. 38
      packages/nc-gui-v2/lib/types.ts
  14. 4
      packages/nc-gui-v2/nuxt.config.ts
  15. 45
      packages/nc-gui-v2/package-lock.json
  16. 2
      packages/nc-gui-v2/package.json
  17. 233
      packages/nc-gui-v2/pages/index/index.vue
  18. 47
      packages/nc-gui-v2/pages/index/index/list.vue
  19. 3
      packages/nc-gui-v2/pages/nc/[projectId].vue
  20. 13
      packages/nc-gui-v2/pages/project/index.vue
  21. 86
      packages/nc-gui-v2/pages/project/index/[id].vue
  22. 399
      packages/nc-gui-v2/pages/project/index/create-external.vue
  23. 82
      packages/nc-gui-v2/pages/project/index/create.vue
  24. 164
      packages/nc-gui-v2/pages/projects/create-external.vue
  25. 74
      packages/nc-gui-v2/pages/projects/create.vue
  26. 22
      packages/nc-gui-v2/pages/projects/index.vue
  27. 15
      packages/nc-gui-v2/pages/projects/index/index.vue
  28. 49
      packages/nc-gui-v2/pages/projects/index/list.vue
  29. 4
      packages/nc-gui-v2/plugins/api.ts
  30. 9
      packages/nc-gui-v2/utils/dateTimeUtils.ts
  31. 13
      packages/nc-gui-v2/utils/deepCompare.ts
  32. 42
      packages/nc-gui-v2/utils/fileUtils.ts
  33. 123
      packages/nc-gui-v2/utils/projectCreateUtils.ts
  34. 17
      packages/nc-gui-v2/utils/validation.ts

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

@ -35,10 +35,10 @@ const sidebarOpen = computed({
<div class="flex-1" />
<div class="ml-4 flex items-center flex-1">
<div class="flex items-center gap-2">
<div class="ml-4 flex justify-center flex-1">
<div class="flex items-center gap-2 cursor-pointer" @click="navigateTo('/')">
<img width="35" src="~/assets/img/icons/512x512-trans.png" />
<span class="prose-xl" @click="navigateTo('/')">NocoDB</span>
<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
@ -55,8 +55,6 @@ const sidebarOpen = computed({
<div class="flex-1" />
<div class="flex justify-end gap-4">
<general-color-mode-switcher v-model="$state.darkMode.value" />
<general-language class="mr-3" />
<template v-if="$state.signedIn.value">

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

@ -10,6 +10,8 @@ declare module '@vue/runtime-core' {
AAutoComplete: typeof import('ant-design-vue/es')['AutoComplete']
AButton: typeof import('ant-design-vue/es')['Button']
ACard: typeof import('ant-design-vue/es')['Card']
ACheckbox: typeof import('ant-design-vue/es')['Checkbox']
ACol: typeof import('ant-design-vue/es')['Col']
ACollapse: typeof import('ant-design-vue/es')['Collapse']
ACollapsePanel: typeof import('ant-design-vue/es')['CollapsePanel']
ADivider: typeof import('ant-design-vue/es')['Divider']
@ -17,7 +19,9 @@ declare module '@vue/runtime-core' {
AForm: typeof import('ant-design-vue/es')['Form']
AFormItem: typeof import('ant-design-vue/es')['FormItem']
AInput: typeof import('ant-design-vue/es')['Input']
AInputNumber: typeof import('ant-design-vue/es')['InputNumber']
AInputPassword: typeof import('ant-design-vue/es')['InputPassword']
AInputSearch: typeof import('ant-design-vue/es')['InputSearch']
ALayout: typeof import('ant-design-vue/es')['Layout']
ALayoutContent: typeof import('ant-design-vue/es')['LayoutContent']
ALayoutHeader: typeof import('ant-design-vue/es')['LayoutHeader']
@ -25,13 +29,24 @@ declare module '@vue/runtime-core' {
AMenu: typeof import('ant-design-vue/es')['Menu']
AMenuDivider: typeof import('ant-design-vue/es')['MenuDivider']
AMenuItem: typeof import('ant-design-vue/es')['MenuItem']
AMenuItemGroup: typeof import('ant-design-vue/es')['MenuItemGroup']
AModal: typeof import('ant-design-vue/es')['Modal']
APagination: typeof import('ant-design-vue/es')['Pagination']
ARow: typeof import('ant-design-vue/es')['Row']
ASelect: typeof import('ant-design-vue/es')['Select']
ASelectOption: typeof import('ant-design-vue/es')['SelectOption']
ASkeleton: typeof import('ant-design-vue/es')['Skeleton']
ASpace: typeof import('ant-design-vue/es')['Space']
ASpin: typeof import('ant-design-vue/es')['Spin']
ASubMenu: typeof import('ant-design-vue/es')['SubMenu']
ATable: typeof import('ant-design-vue/es')['Table']
ATableColumn: typeof import('ant-design-vue/es')['TableColumn']
ATableColumnGroup: typeof import('ant-design-vue/es')['TableColumnGroup']
ATabPane: typeof import('ant-design-vue/es')['TabPane']
ATabs: typeof import('ant-design-vue/es')['Tabs']
ATag: typeof import('ant-design-vue/es')['Tag']
ATooltip: typeof import('ant-design-vue/es')['Tooltip']
AUploadDragger: typeof import('ant-design-vue/es')['UploadDragger']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}

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

@ -20,7 +20,8 @@ const tableCreateDialog = ref(false)
<v-window v-model="activeTab">
<v-window-item v-for="(tab, i) in tabs" :key="i" :value="i">
<TabsSmartsheet :tab-meta="tab" />
<TabsAuth v-if="tab.type === 'auth'" :tab-meta="tab" />
<TabsSmartsheet v-else :tab-meta="tab" />
</v-window-item>
</v-window>
</div>

105
packages/nc-gui-v2/components/dashboard/settings/AuditTab.vue

@ -0,0 +1,105 @@
<script setup lang="ts">
import { Tooltip as ATooltip } from 'ant-design-vue'
import type { AuditType } from 'nocodb-sdk'
import { timeAgo } from '~/utils/dateTimeUtils'
import { h, useNuxtApp, useProject } from '#imports'
import MdiReload from '~icons/mdi/reload'
interface Props {
projectId: string
}
const { projectId } = defineProps<Props>()
const { $api } = useNuxtApp()
const { project, loadProject } = useProject()
let isLoading = $ref(false)
let audits = $ref<null | Array<AuditType>>(null)
let totalRows = $ref(0)
const currentPage = $ref(1)
const currentLimit = $ref(25)
async function loadAudits(page = currentPage, limit = currentLimit) {
try {
if (!project.value?.id) return
isLoading = true
const { list, pageInfo } = await $api.project.auditList(project.value?.id, {
offset: (limit * (page - 1)).toString(),
limit: limit.toString(),
})
audits = list
totalRows = pageInfo.totalRows ?? 0
} catch (e) {
console.error(e)
} finally {
isLoading = false
}
}
onMounted(async () => {
if (audits === null) {
await loadProject(projectId)
await loadAudits(currentPage, currentLimit)
}
})
const columns = [
{
title: 'Operation Type',
dataIndex: 'op_type',
key: 'op_type',
},
{
title: 'Operation sub-type',
dataIndex: 'op_sub_type',
key: 'op_sub_type',
},
{
title: 'Description',
dataIndex: 'description',
key: 'description',
},
{
title: 'User',
dataIndex: 'user',
key: 'user',
customRender: (value: { text: string }) => h('div', () => value.text || 'Shared base'),
},
{
title: 'Created',
dataIndex: 'created_at',
key: 'created_at',
sort: 'desc',
customRender: (value: { text: string }) =>
h(ATooltip, { placement: 'bottom', title: h('span', {}, value.text) }, () => timeAgo(value.text)),
},
]
</script>
<template>
<div class="flex flex-col gap-4 w-full">
<a-button class="self-start" @click="loadAudits">
<div class="flex items-center gap-2">
<MdiReload :class="{ 'animate-infinite animate-spin !text-success': isLoading }" />
Reload
</div>
</a-button>
<a-table class="w-full" :data-source="audits ?? []" :columns="columns" :pagination="false" :loading="isLoading" />
<a-pagination
v-model:current="currentPage"
:page-size="currentLimit"
:total="totalRows"
show-less-items
@change="loadAudits"
/>
</div>
</template>

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

@ -0,0 +1,52 @@
<script setup lang="ts">
import * as monaco from 'monaco-editor'
import { onMounted } from '#imports'
import { deepCompare } from '~/utils/deepCompare'
const { modelValue } = defineProps<{ modelValue: any }>()
const emit = defineEmits(['update:modelValue'])
const root = ref<HTMLDivElement>()
let editor: monaco.editor.IStandaloneCodeEditor
onMounted(() => {
if (root.value) {
const model = monaco.editor.createModel(JSON.stringify(modelValue, null, 2), 'json')
// configure the JSON language support with schemas and schema associations
monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
validate: true,
})
editor = monaco.editor.create(root.value, {
model,
theme: 'dark',
})
editor.onDidChangeModelContent(async (e) => {
try {
// console.log(e)
const obj = JSON.parse(editor.getValue())
if (!deepCompare(modelValue, obj)) emit('update:modelValue', obj)
} catch (e) {
console.log(e)
}
})
}
})
watch(
() => modelValue,
(v) => {
if (editor && v && !deepCompare(v, JSON.parse(editor?.getValue() as string))) {
editor.setValue(JSON.stringify(v, null, 2))
}
},
)
</script>
<template>
<div ref="root"></div>
</template>
<style scoped></style>

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

@ -0,0 +1,5 @@
<template>
<div>
<h2 class="text-3xl mt-3">Team & Auth</h2>
</div>
</template>

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

@ -1,4 +1,4 @@
import { breakpointsTailwind, usePreferredDark, usePreferredLanguages, useStorage } from '@vueuse/core'
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'
@ -25,8 +25,10 @@ const storageKey = 'nocodb-gui-v2'
export const useGlobalState = (): GlobalState => {
/** get the preferred languages of a user, according to browser settings */
const preferredLanguages = $(usePreferredLanguages())
/** get the preferred dark mode setting, according to browser settings */
const prefersDarkMode = $(usePreferredDark())
/** 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)

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

@ -1,28 +1,38 @@
import { SqlUiFactory } from 'nocodb-sdk'
import type { ProjectType, TableType } from 'nocodb-sdk'
import { useNuxtApp } from '#app'
import { useNuxtApp, useState } from '#app'
import { USER_PROJECT_ROLES } from '~/lib/constants'
export default () => {
const projectRoles = useState<Record<string, boolean>>(USER_PROJECT_ROLES, () => ({}))
const { $api } = useNuxtApp()
const project = useState<ProjectType>('project')
const tables = useState<TableType[]>('tables')
const loadProjectRoles = async () => {
projectRoles.value = {}
if (project.value.id) {
const user = await $api.auth.me({ project_id: project.value.id })
projectRoles.value = user.roles
}
}
const loadTables = async () => {
if (project.value.id) {
const tablesResponse = await $api.dbTable.list(project.value.id)
if (tablesResponse.list) tables.value = tablesResponse.list
}
}
const loadProject = async (projectId: string) => {
project.value = await $api.project.read(projectId)
await loadProjectRoles()
}
const isMysql = computed(() => ['mysql', 'mysql2'].includes(project.value?.bases?.[0]?.type || ''))
const isPg = computed(() => project.value?.bases?.[0]?.type === 'pg')
const sqlUi = computed(() => SqlUiFactory.create({ client: project.value?.bases?.[0]?.type || '' }))
return { project, tables, loadProject, loadTables, isMysql, isPg, sqlUi }

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

@ -1,9 +1,9 @@
import { useState } from '#app'
export interface TabItem {
type: 'table' | 'view'
type: 'table' | 'view' | 'auth'
title: string
id: string
id?: string
}
export default () => {

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

@ -1,14 +1,27 @@
import rolePermissions from './rolePermissions'
import { useState } from '#app'
import { USER_PROJECT_ROLES } from '~/lib/constants'
export default () => {
const { $state } = useNuxtApp()
const projectRoles = useState<Record<string, boolean>>(USER_PROJECT_ROLES, () => ({}))
const isUIAllowed = (permission: keyof typeof rolePermissions[keyof typeof rolePermissions], _skipPreviewAs = false) => {
const user = $state.user
let userRoles = user?.value?.roles || {}
// if string populate key-value paired object
if (typeof userRoles === 'string') {
userRoles = userRoles.split(',').reduce<Record<string, boolean>>((acc, role) => {
acc[role] = true
return acc
}, {})
}
// merge user role and project specific user roles
const roles = {
...(user?.value?.roles || {}),
// todo: load project specific roles
// ...(state.projectRole || {}),
...userRoles,
...(projectRoles?.value || {}),
}
// todo: handle preview as
@ -17,6 +30,7 @@ export default () => {
// [state.previewAs]: true
// };
// }
return Object.entries(roles).some(([role, hasRole]) => {
return (
hasRole &&

1
packages/nc-gui-v2/lang/en.json

@ -213,6 +213,7 @@
"sqlOutput": "SQL Output",
"addOption": "Add option",
"aggregateFunction": "Aggregate function",
"database": "Database",
"dbCreateIfNotExists": "Database : create if not exists",
"clientKey": "Client Key",
"clientCert": "Client Cert",

1
packages/nc-gui-v2/lib/constants.ts

@ -1 +1,2 @@
export const NOCO = 'noco'
export const USER_PROJECT_ROLES = 'user_project_roles'

38
packages/nc-gui-v2/lib/types.ts

@ -32,3 +32,41 @@ export interface Actions {
export type ReadonlyState = Readonly<Pick<State, 'token' | 'user'>> & Omit<State, 'token' | 'user'>
export type GlobalState = Getters & Actions & ToRefs<ReadonlyState>
export enum ClientType {
MYSQL = 'mysql2',
MSSQL = 'mssql',
PG = 'pg',
SQLITE = 'sqlite3',
VITESS = 'vitess',
}
export interface ProjectCreateForm {
title: string
dataSource: {
client: ClientType
connection:
| {
host: string
database: string
user: string
password: string
port: number | string
ssl?: Record<string, string>
searchPath?: string[]
}
| {
client?: 'sqlite3'
database: string
connection?: {
filename?: string
}
useNullAsDefault?: boolean
}
}
inflection: {
inflectionColumn?: string
inflectionTable?: string
}
sslUse?: any
}

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

@ -4,6 +4,7 @@ import vueI18n from '@intlify/vite-plugin-vue-i18n'
import Icons from 'unplugin-icons/vite'
import Components from 'unplugin-vue-components/vite'
import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers'
import monacoEditorPlugin from 'vite-plugin-monaco-editor'
// https://v3.nuxtjs.org/api/configuration/nuxt.config
export default defineNuxtConfig({
@ -70,6 +71,9 @@ export default defineNuxtConfig({
}),
],
}),
monacoEditorPlugin({
languageWorkers: ['json'],
}),
],
define: {
'process.env.DEBUG': 'false',

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

@ -12,6 +12,7 @@
"jwt-decode": "^3.1.2",
"nocodb-sdk": "file:../nocodb-sdk",
"socket.io-client": "^4.5.1",
"unique-names-generator": "^4.7.1",
"vue-i18n": "^9.1.10",
"vue-toastification": "^2.0.0-rc.5",
"vuetify": "^3.0.0-alpha.13"
@ -40,6 +41,7 @@
"sass": "^1.53.0",
"unplugin-icons": "^0.14.7",
"unplugin-vue-components": "^0.21.1",
"vite-plugin-monaco-editor": "^1.1.0",
"vitest": "^0.18.0",
"windicss": "^3.5.6"
}
@ -9315,6 +9317,13 @@
"integrity": "sha512-sTitTPYnn23esFR3RlqYBWn4c45WGeLcsKzQiUpXJAyfcWkolvlYpV8FLo7JishK946oQwMFUCHXQ9AjGPKExw==",
"dev": true
},
"node_modules/monaco-editor": {
"version": "0.33.0",
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.33.0.tgz",
"integrity": "sha512-VcRWPSLIUEgQJQIE0pVT8FcGBIgFoxz7jtqctE+IiCxWugD0DwgyQBcZBhdSrdMC84eumoqMZsGl2GTreOzwqw==",
"dev": true,
"peer": true
},
"node_modules/mri": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
@ -12978,6 +12987,14 @@
"unplugin": "^0.6.3"
}
},
"node_modules/unique-names-generator": {
"version": "4.7.1",
"resolved": "https://registry.npmjs.org/unique-names-generator/-/unique-names-generator-4.7.1.tgz",
"integrity": "sha512-lMx9dX+KRmG8sq6gulYYpKWZc9RlGsgBR6aoO8Qsm3qvkSJ+3rAymr+TnV8EDMrIrwuFJ4kruzMWM/OpYzPoow==",
"engines": {
"node": ">=8"
}
},
"node_modules/unist-util-stringify-position": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-2.0.3.tgz",
@ -13397,6 +13414,15 @@
"node": ">= 12"
}
},
"node_modules/vite-plugin-monaco-editor": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/vite-plugin-monaco-editor/-/vite-plugin-monaco-editor-1.1.0.tgz",
"integrity": "sha512-IvtUqZotrRoVqwT0PBBDIZPNraya3BxN/bfcNfnxZ5rkJiGcNtO5eAOWWSgT7zullIAEqQwxMU83yL9J5k7gww==",
"dev": true,
"peerDependencies": {
"monaco-editor": ">=0.33.0"
}
},
"node_modules/vite-plugin-vuetify": {
"version": "1.0.0-alpha.12",
"resolved": "https://registry.npmjs.org/vite-plugin-vuetify/-/vite-plugin-vuetify-1.0.0-alpha.12.tgz",
@ -21072,6 +21098,13 @@
}
}
},
"monaco-editor": {
"version": "0.33.0",
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.33.0.tgz",
"integrity": "sha512-VcRWPSLIUEgQJQIE0pVT8FcGBIgFoxz7jtqctE+IiCxWugD0DwgyQBcZBhdSrdMC84eumoqMZsGl2GTreOzwqw==",
"dev": true,
"peer": true
},
"mri": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
@ -23835,6 +23868,11 @@
"unplugin": "^0.6.3"
}
},
"unique-names-generator": {
"version": "4.7.1",
"resolved": "https://registry.npmjs.org/unique-names-generator/-/unique-names-generator-4.7.1.tgz",
"integrity": "sha512-lMx9dX+KRmG8sq6gulYYpKWZc9RlGsgBR6aoO8Qsm3qvkSJ+3rAymr+TnV8EDMrIrwuFJ4kruzMWM/OpYzPoow=="
},
"unist-util-stringify-position": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-2.0.3.tgz",
@ -24096,6 +24134,13 @@
}
}
},
"vite-plugin-monaco-editor": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/vite-plugin-monaco-editor/-/vite-plugin-monaco-editor-1.1.0.tgz",
"integrity": "sha512-IvtUqZotrRoVqwT0PBBDIZPNraya3BxN/bfcNfnxZ5rkJiGcNtO5eAOWWSgT7zullIAEqQwxMU83yL9J5k7gww==",
"dev": true,
"requires": {}
},
"vite-plugin-vuetify": {
"version": "1.0.0-alpha.12",
"resolved": "https://registry.npmjs.org/vite-plugin-vuetify/-/vite-plugin-vuetify-1.0.0-alpha.12.tgz",

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

@ -18,6 +18,7 @@
"jwt-decode": "^3.1.2",
"nocodb-sdk": "file:../nocodb-sdk",
"socket.io-client": "^4.5.1",
"unique-names-generator": "^4.7.1",
"vue-i18n": "^9.1.10",
"vue-toastification": "^2.0.0-rc.5",
"vuetify": "^3.0.0-alpha.13"
@ -46,6 +47,7 @@
"sass": "^1.53.0",
"unplugin-icons": "^0.14.7",
"unplugin-vue-components": "^0.21.1",
"vite-plugin-monaco-editor": "^1.1.0",
"vitest": "^0.18.0",
"windicss": "^3.5.6"
}

233
packages/nc-gui-v2/pages/index/index.vue

@ -3,55 +3,48 @@ import { Modal } from 'ant-design-vue'
import type { ProjectType } from 'nocodb-sdk'
import { useToast } from 'vue-toastification'
import { navigateTo } from '#app'
import { computed, onMounted } from '#imports'
import { extractSdkResponseErrorMsg } from '~/utils/errorUtils'
import MaterialSymbolsFormatListBulletedRounded from '~icons/material-symbols/format-list-bulleted-rounded'
import MaterialSymbolsGridView from '~icons/material-symbols/grid-view'
import MdiDeleteOutline from '~icons/mdi/delete-outline'
import MdiEditOutline from '~icons/mdi/edit-outline'
import MdiRefresh from '~icons/mdi/refresh'
import MdiMenuDown from '~icons/mdi/menu-down'
import MdiPlus from '~icons/mdi/plus'
import MdiDatabaseOutline from '~icons/mdi/database-outline'
import MdiFolderOutline from '~icons/mdi/folder-outline'
const navDrawerOptions = [
{
title: 'My NocoDB',
icon: MdiFolderOutline,
},
/* todo: implement the api and bring back the options below
{
title: "Shared With Me",
icon: MdiAccountGroup
},
{
title: "Recent",
icon: MdiClockOutline
},
{
title: "Starred",
icon: MdiStar
} */
]
const route = useRoute()
const { $api, $state } = useNuxtApp()
const { $api, $state, $e } = useNuxtApp()
const toast = useToast()
$state.sidebarOpen.value = false
const filterQuery = ref('')
const loading = ref(true)
const projects = ref()
const loadProjects = async () => {
loading.value = true
const response = await $api.project.list({})
projects.value = response.list
loading.value = false
}
const filteredProjects = computed(() => {
return projects.value.filter(
(project) => !filterQuery.value || project.title?.toLowerCase?.().includes(filterQuery.value.toLowerCase()),
)
})
const response = await $api.project.list({})
const projects = $ref(response.list)
const activePage = $ref(navDrawerOptions[0].title)
const deleteProject = (project: ProjectType) => {
$e('c:project:delete')
Modal.confirm({
title: 'Do you want to delete the project?',
// icon: createVNode(ExclamationCircleOutlined),
content: 'Some descriptions',
title: `Do you want to delete '${project.title}' project?`,
okText: 'Yes',
okType: 'danger',
cancelText: 'No',
async onOk() {
try {
$e('c:project:delete')
await $api.project.delete(project.id as string)
projects.splice(projects.indexOf(project), 1)
projects.value.splice(projects.value.indexOf(project), 1)
} catch (e) {
toast.error(await extractSdkResponseErrorMsg(e))
}
@ -59,92 +52,124 @@ const deleteProject = (project: ProjectType) => {
})
}
const visible = ref(true)
onMounted(() => {
loadProjects()
})
// hide sidebar
$state.sidebarOpen.value = false
</script>
<template>
<NuxtLayout>
<template #sidebar>
<div class="flex flex-col h-full">
<div class="flex p-4">
<v-menu class="select-none">
<template #activator="{ props }">
<div
class="color-transition hover:(bg-gray-100 dark:bg-secondary/25) dark:(bg-secondary/50 !text-white shadow-gray-600) mr-auto select-none flex items-center gap-2 leading-8 cursor-pointer rounded-full border-1 border-gray-300 px-5 py-2 shadow prose-lg font-semibold"
@click="props.onClick"
>
<MdiPlus class="text-primary dark:(!text-white) text-2xl" />
{{ $t('title.newProj') }}
</div>
</template>
<a-card class="mx-auto mt-5 !max-w-[600px] shadow-lg">
<h1 class="text-center text-4xl pa-2 nc-project-page-title flex align-center justify-center gap-2">
<!-- My Projects -->
<b>{{ $t('title.myProject') }}</b>
<MdiRefresh
v-t="['a:project:refresh']"
class="text-sm text-gray-500 hover:text-primary mt-1 cursor-pointer"
@click="loadProjects"
></MdiRefresh>
</h1>
<div class="flex mb-6">
<a-input-search
v-model:value="filterQuery"
class="max-w-[200px] nc-project-page-search"
:placeholder="$t('activity.searchProject')"
></a-input-search>
<div class="flex-grow"></div>
<a-dropdown @click.stop>
<a-button class="nc-new-project-menu">
<div class="flex align-center">
{{ $t('title.newProj') }}
<MdiMenuDown class="menu-icon" />
</div>
</a-button>
<v-list class="!py-0 flex flex-col bg-white rounded-lg shadow-md border-1 border-gray-300 mt-2 ml-2">
<template #overlay>
<a-menu>
<div
class="grid grid-cols-12 cursor-pointer hover:bg-gray-200 flex items-center p-2"
@click="navigateTo('/create')"
v-t="['c:project:create:xcdb']"
class="grid grid-cols-12 cursor-pointer hover:bg-gray-200 flex items-center p-2 nc-create-xc-db-project"
@click="navigateTo('/project/create')"
>
<MdiPlus class="col-span-2 mr-1 mt-[1px] text-primary text-lg" />
<div class="col-span-10 text-sm xl:text-md">{{ $t('activity.createProject') }}</div>
</div>
<div
class="grid grid-cols-12 cursor-pointer hover:bg-gray-200 flex items-center p-2"
@click="navigateTo('/create-external')"
v-t="['c:project:create:extdb']"
class="grid grid-cols-12 cursor-pointer hover:bg-gray-200 flex items-center p-2 nc-create-external-db-project"
@click="navigateTo('/project/create-external')"
>
<MdiDatabaseOutline class="col-span-2 mr-1 mt-[1px] text-green-500 text-lg" />
<div class="col-span-10 text-sm xl:text-md" v-html="$t('activity.createProjectExtended.extDB')" />
</div>
</v-list>
</v-menu>
</div>
<a-menu class="pr-4 dark:bg-gray-800 dark:text-white flex-1 border-0">
<a-menu-item
v-for="(option, index) in navDrawerOptions"
:key="index"
class="!rounded-r-lg"
@click="activePage = option.title"
>
<div class="flex items-center gap-4">
<component :is="option.icon" />
<span class="font-semibold">
{{ option.title }}
</span>
</div>
</a-menu-item>
</a-menu>
<general-social />
<general-sponsors :nav="true" />
</div>
</template>
<v-container class="flex-1 mb-12">
<div class="flex">
<div class="flex-1 text-2xl md:text-4xl font-bold text-gray-500 dark:text-white p-4">
{{ activePage }}
</div>
<div class="self-end flex text-4xl mb-1">
<MaterialSymbolsGridView
:class="route.name === 'index-index' ? '!text-primary dark:(!text-secondary/75)' : ''"
class="cursor-pointer p-2 hover:bg-gray-300/50 rounded-full"
@click="navigateTo('/')"
/>
<MaterialSymbolsFormatListBulletedRounded
:class="route.name === 'index-index-list' ? '!text-primary dark:(!text-secondary/75)' : ''"
class="cursor-pointer p-2 hover:bg-gray-300/50 rounded-full"
@click="navigateTo('/list')"
/>
</div>
</a-menu>
</template>
</a-dropdown>
</div>
<a-divider class="!mb-4 lg:(!mb-8)" />
<NuxtPage :projects="projects" @delete-project="deleteProject" />
</v-container>
<div v-if="loading">
<a-skeleton />
</div>
<a-modal></a-modal>
<a-table
v-else
:custom-row="
(record) => ({
onClick: () => {
$e('a:project:open')
navigateTo(`/nc/${record.id}`)
},
})
"
:data-source="filteredProjects"
:pagination="{ position: ['bottomCenter'] }"
>
<!-- Title -->
<a-table-column key="title" :title="$t('general.title')" data-index="title">
<template #default="{ text }">
<div class="capitalize !w-[400px] overflow-hidden overflow-ellipsis whitespace-nowrap nc-project-row" :title="text">
{{ text }}
</div>
</template>
</a-table-column>
<!-- Actions -->
<a-table-column key="id" :title="$t('labels.actions')" data-index="id">
<template #default="{ text, record }">
<div class="flex align-center">
<MdiEditOutline
v-t="['c:project:edit:rename']"
class="nc-action-btn"
@click.stop="navigateTo(`/project/${text}`)"
/>
<MdiDeleteOutline class="nc-action-btn" @click.stop="deleteProject(record)" />
</div>
</template>
</a-table-column>
</a-table>
</a-card>
</NuxtLayout>
</template>
<style scoped>
.nc-action-btn {
@apply text-gray-500 hover:text-primary mr-2 cursor-pointer p-2 w-[30px] h-[30px] hover:bg-gray-300/50 rounded-full;
}
:deep(.ant-table-cell) {
@apply py-1;
}
:deep(.ant-table-row) {
@apply cursor-pointer;
}
:deep(.ant-table) {
@apply min-h-[428px];
}
</style>

47
packages/nc-gui-v2/pages/index/index/list.vue

@ -1,47 +0,0 @@
<script lang="ts" setup>
import type { ProjectType } from 'nocodb-sdk'
import { navigateTo } from '#app'
import MdiDeleteOutline from '~icons/mdi/delete-outline'
import MdiEditOutline from '~icons/mdi/edit-outline'
interface Props {
projects?: ProjectType[]
}
const { projects = [] } = defineProps<Props>()
const emit = defineEmits(['delete-project'])
const { $e } = useNuxtApp()
const openProject = async (project: ProjectType) => {
await navigateTo(`/nc/${project.id}`)
$e('a:project:open', { count: projects.length })
}
</script>
<template>
<div class="grid grid-cols-4 gap-2 prose-md p-2 font-semibold">
<div>{{ $t('general.title') }}</div>
<div>Updated At</div>
<div></div>
</div>
<v-divider class="col-span-3" />
<template v-for="project of projects" :key="project.id">
<div
class="cursor-pointer grid grid-cols-4 gap-2 prose-md hover:(bg-gray-100 shadow-sm dark:text-black) p-2 transition-color ease-in duration-100"
@click="openProject(project)"
>
<div class="font-semibold">{{ project.title || 'Untitled' }}</div>
<div>{{ project.updated_at }}</div>
<div>
<MdiDeleteOutline class="text-gray-500 hover:text-red-500 mr-2" @click.stop @click="emit('delete-project', project)" />
<MdiEditOutline class="text-gray-500 hover:text-primary mr-2" @click.stop />
</div>
</div>
<v-divider class="col-span-3" />
</template>
</template>

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

@ -5,11 +5,12 @@ import useTabs from '~/composables/useTabs'
const route = useRoute()
const { loadProject, loadTables } = useProject()
const { clearTabs } = useTabs()
const { clearTabs, addTab } = useTabs()
onMounted(async () => {
await loadProject(route.params.projectId as string)
await loadTables()
addTab({ type: 'auth', title: 'Team & Auth' })
})
watch(

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

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

86
packages/nc-gui-v2/pages/project/index/[id].vue

@ -0,0 +1,86 @@
<script lang="ts" setup>
import { onMounted } from '@vue/runtime-core'
import type { Form } from 'ant-design-vue'
import type { ProjectType } from 'nocodb-sdk'
import { ref } from 'vue'
import { useToast } from 'vue-toastification'
import { navigateTo, useNuxtApp, useRoute } from '#app'
import { extractSdkResponseErrorMsg } from '~/utils/errorUtils'
import { projectTitleValidator } from '~/utils/validation'
import MaterialSymbolsRocketLaunchOutline from '~icons/material-symbols/rocket-launch-outline'
const loading = ref(false)
const { $api, $state } = useNuxtApp()
const toast = useToast()
const route = useRoute()
const nameValidationRules = [
{
required: true,
message: 'Project name is required',
},
projectTitleValidator,
]
const formState = reactive({
title: '',
})
const getProject = async () => {
try {
const result: ProjectType = await $api.project.read(route.params.id as string)
formState.title = result.title as string
} catch (e: any) {
toast.error(await extractSdkResponseErrorMsg(e))
}
}
const renameProject = async () => {
loading.value = true
try {
await $api.project.update(route.params.id as string, formState)
navigateTo(`/nc/${route.params.id}`)
} catch (e) {
toast.error(await extractSdkResponseErrorMsg(e))
}
loading.value = false
}
const form = ref<typeof Form>()
// hide sidebar
$state.sidebarOpen.value = false
// select and focus title field on load
onMounted(async () => {
await getProject()
nextTick(() => {
// todo: replace setTimeout and follow better approach
setTimeout(() => {
const input = form.value?.$el?.querySelector('input[type=text]')
input.setSelectionRange(0, formState.title.length)
input.focus()
}, 500)
})
})
</script>
<template>
<a-card class="w-[500px] mx-auto !mt-100px shadow-md">
<h3 class="text-3xl text-center font-semibold mb-2">{{ $t('activity.editProject') }}</h3>
<a-form ref="form" :model="formState" name="basic" layout="vertical" autocomplete="off" @finish="renameProject">
<a-form-item :label="$t('labels.projName')" name="title" :rules="nameValidationRules" class="my-10 mx-10">
<a-input v-model:value="formState.title" name="title" class="nc-metadb-project-name" />
</a-form-item>
<a-form-item style="text-align: center" class="mt-2">
<a-button type="primary" html-type="submit" class="mx-auto flex justify-self-center">
<MaterialSymbolsRocketLaunchOutline class="mr-1" />
<span> {{ $t('general.edit') }} </span></a-button
>
</a-form-item>
</a-form>
</a-card>
</template>

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

@ -0,0 +1,399 @@
<script lang="ts" setup>
import { onMounted } from '@vue/runtime-core'
import { Form, Modal } from 'ant-design-vue'
import { useI18n } from 'vue-i18n'
import { useToast } from 'vue-toastification'
import { ref } from '#imports'
import { navigateTo, useNuxtApp } from '#app'
import type { ProjectCreateForm } from '~/lib/types'
import { ClientType } from '~/lib/types'
import { extractSdkResponseErrorMsg } from '~/utils/errorUtils'
import { readFile } from '~/utils/fileUtils'
import {
clientTypes,
generateUniqueName,
getDefaultConnectionConfig,
getTestDatabaseName,
sslUsage,
} from '~/utils/projectCreateUtils'
import { fieldRequiredValidator, projectTitleValidator } from '~/utils/validation'
const useForm = Form.useForm
const loading = ref(false)
const testSuccess = ref(false)
const { $api, $e, $state } = useNuxtApp()
const toast = useToast()
const { t } = useI18n()
const formState = $ref<ProjectCreateForm>({
title: '',
dataSource: { ...getDefaultConnectionConfig(ClientType.MYSQL) },
inflection: {
inflectionColumn: 'camelize',
inflectionTable: 'camelize',
},
sslUse: 'No',
})
const validators = computed(() => {
return {
'title': [
{
required: true,
message: 'Project name is required',
},
projectTitleValidator,
],
'dataSource.client': [fieldRequiredValidator],
...(formState.dataSource.client === ClientType.SQLITE
? {
'dataSource.connection.connection.filename': [fieldRequiredValidator],
}
: {
'dataSource.connection.host': [fieldRequiredValidator],
'dataSource.connection.port': [fieldRequiredValidator],
'dataSource.connection.user': [fieldRequiredValidator],
'dataSource.connection.password': [fieldRequiredValidator],
'dataSource.connection.database': [fieldRequiredValidator],
...([ClientType.PG, ClientType.MSSQL].includes(formState.dataSource.client)
? {
'dataSource.connection.searchPath.0': [fieldRequiredValidator],
}
: {}),
}),
}
})
const { resetFields, validate, validateInfos } = useForm(formState, validators)
const onClientChange = () => {
formState.dataSource = { ...getDefaultConnectionConfig(formState.dataSource.client) }
}
const inflectionTypes = ['camelize', 'none']
const configEditDlg = ref(false)
// populate database name based on title
watch(
() => formState.title,
(v) => (formState.dataSource.connection.database = `${v?.trim()}_noco`),
)
// generate a random project title
formState.title = generateUniqueName()
const caFileInput = ref<HTMLInputElement>()
const keyFileInput = ref<HTMLInputElement>()
const certFileInput = ref<HTMLInputElement>()
const onFileSelect = (key: 'ca' | 'cert' | 'key', el: HTMLInputElement) => {
readFile(el, (content) => {
if ('ssl' in formState.dataSource.connection && formState.dataSource.connection.ssl)
formState.dataSource.connection.ssl[key] = content ?? ''
})
}
const sslFilesRequired = computed<boolean>(() => {
return formState?.sslUse && formState.sslUse !== 'No'
})
function getConnectionConfig() {
const connection = {
...formState.dataSource.connection,
}
if ('ssl' in connection && connection.ssl && (!sslFilesRequired || Object.values(connection.ssl).every((v) => !v))) {
delete connection.ssl
}
return connection
}
const form = ref<any>()
const focusInvalidInput = () => {
form?.value?.$el.querySelector('.ant-form-item-explain-error')?.parentNode?.parentNode?.querySelector('input')?.focus()
}
const createProject = async () => {
try {
await validate()
} catch (e) {
focusInvalidInput()
return
}
loading.value = true
try {
const connection = getConnectionConfig()
const config = { ...formState.dataSource, connection }
const result = await $api.project.create({
title: formState.title,
bases: [
{
type: formState.dataSource.client,
config,
inflection_column: formState.inflection.inflectionColumn,
inflection_table: formState.inflection.inflectionTable,
},
],
external: true,
})
$e('a:project:create:extdb')
await navigateTo(`/nc/${result.id}`)
} catch (e: any) {
// todo: toast
toast.error(await extractSdkResponseErrorMsg(e))
}
loading.value = false
}
const testConnection = async () => {
try {
await validate()
} catch (e) {
focusInvalidInput()
return
}
$e('a:project:create:extdb:test-connection', [])
try {
if (formState.dataSource.client === ClientType.SQLITE) {
testSuccess.value = true
} else {
const connection: any = getConnectionConfig()
connection.database = getTestDatabaseName(formState.dataSource)
const testConnectionConfig = {
...formState.dataSource,
connection,
}
const result = await $api.utils.testConnection(testConnectionConfig)
if (result.code === 0) {
testSuccess.value = true
Modal.confirm({
title: t('msg.info.dbConnected'),
icon: null,
type: 'success',
okText: t('activity.OkSaveProject'),
okType: 'primary',
cancelText: 'Cancel',
onOk: createProject,
})
} else {
testSuccess.value = false
toast.error(`${t('msg.error.dbConnectionFailed')} ${result.message}`)
}
}
} catch (e: any) {
testSuccess.value = false
toast.error(await extractSdkResponseErrorMsg(e))
}
}
// hide sidebar
$state.sidebarOpen.value = false
// reset test status on config change
watch(
() => formState.dataSource,
() => (testSuccess.value = false),
{ deep: true },
)
// select and focus title field on load
onMounted(() => {
nextTick(() => {
// todo: replace setTimeout and follow better approach
setTimeout(() => {
const input = form.value?.$el?.querySelector('input[type=text]')
input.setSelectionRange(0, formState.title.length)
input.focus()
}, 500)
})
})
</script>
<template>
<a-card
class="max-w-[600px] mx-auto !mt-5 !mb-5"
:title="$t('activity.createProject')"
:head-style="{ textAlign: 'center', fontWeight: '700' }"
>
<a-form
ref="form"
:model="formState"
name="external-project-create-form"
layout="horizontal"
:label-col="{ span: 8 }"
:wrapper-col="{ span: 18 }"
>
<a-form-item :label="$t('placeholder.projName')" v-bind="validateInfos.title">
<a-input v-model:value="formState.title" size="small" class="nc-extdb-proj-name" />
</a-form-item>
<a-form-item :label="$t('labels.dbType')" v-bind="validateInfos['dataSource.client']">
<a-select v-model:value="formState.dataSource.client" size="small" class="nc-extdb-db-type" @change="onClientChange">
<a-select-option v-for="client in clientTypes" :key="client.value" :value="client.value"
>{{ client.text }}
</a-select-option>
</a-select>
</a-form-item>
<!-- SQLite File -->
<a-form-item
v-if="formState.dataSource.client === ClientType.SQLITE"
:label="$t('labels.sqliteFile')"
v-bind="validateInfos['dataSource.connection.connection.filename']"
>
<a-input v-model:value="formState.dataSource.connection.connection.filename" size="small" />
</a-form-item>
<template v-else>
<!-- Host Address -->
<a-form-item :label="$t('labels.hostAddress')" v-bind="validateInfos['dataSource.connection.host']">
<a-input v-model:value="formState.dataSource.connection.host" size="small" class="nc-extdb-host-address" />
</a-form-item>
<!-- Port Number -->
<a-form-item :label="$t('labels.port')" v-bind="validateInfos['dataSource.connection.port']">
<a-input-number v-model:value="formState.dataSource.connection.port" class="!w-full nc-extdb-host-port" size="small" />
</a-form-item>
<!-- Username -->
<a-form-item :label="$t('labels.username')" v-bind="validateInfos['dataSource.connection.user']">
<a-input v-model:value="formState.dataSource.connection.user" size="small" class="nc-extdb-host-user" />
</a-form-item>
<!-- Password -->
<a-form-item :label="$t('labels.password')">
<a-input-password
v-model:value="formState.dataSource.connection.password"
size="small"
class="nc-extdb-host-password"
/>
</a-form-item>
<!-- Database -->
<a-form-item :label="$t('labels.database')" v-bind="validateInfos['dataSource.connection.database']">
<!-- Database : create if not exists -->
<a-input
v-model:value="formState.dataSource.connection.database"
:placeholder="$t('labels.dbCreateIfNotExists')"
size="small"
class="nc-extdb-host-database"
/>
</a-form-item>
<!-- Schema name -->
<a-form-item
v-if="[ClientType.MSSQL, ClientType.PG].includes(formState.dataSource.client)"
:label="$t('labels.schemaName')"
v-bind="validateInfos['dataSource.connection.searchPath.0']"
>
<a-input v-model:value="formState.dataSource.connection.searchPath[0]" size="small" />
</a-form-item>
<a-collapse ghost expand-icon-position="right" class="mt-6">
<a-collapse-panel key="1" :header="$t('title.advancedParameters')">
<!-- todo: add in i18n -->
<a-form-item label="SSL mode">
<a-select v-model:value="formState.sslUse" size="small" @change="onClientChange">
<a-select-option v-for="opt in sslUsage" :key="opt" :value="opt">{{ opt }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="SSL keys">
<div class="flex gap-2">
<a-tooltip placement="top">
<!-- Select .cert file -->
<template #title>
<span>{{ $t('tooltip.clientCert') }}</span>
</template>
<a-button :disabled="!sslFilesRequired" size="small" @click="certFileInput.click()">
{{ $t('labels.clientCert') }}
</a-button>
</a-tooltip>
<a-tooltip placement="top">
<!-- Select .key file -->
<template #title>
<span>{{ $t('tooltip.clientKey') }}</span>
</template>
<a-button :disabled="!sslFilesRequired" size="small" @click="keyFileInput.click()">
{{ $t('labels.clientKey') }}
</a-button>
</a-tooltip>
<a-tooltip placement="top">
<!-- Select CA file -->
<template #title>
<span>{{ $t('tooltip.clientCA') }}</span>
</template>
<a-button :disabled="!sslFilesRequired" size="small" @click="caFileInput.click()">
{{ $t('labels.serverCA') }}
</a-button>
</a-tooltip>
</div>
</a-form-item>
<input ref="caFileInput" type="file" class="!hidden" @change="onFileSelect('ca', caFileInput)" />
<input ref="certFileInput" type="file" class="!hidden" @change="onFileSelect('cert', certFileInput)" />
<input ref="keyFileInput" type="file" class="!hidden" @change="onFileSelect('key', keyFileInput)" />
<a-form-item :label="$t('labels.inflection.tableName')">
<a-select v-model:value="formState.inflection.inflectionTable" size="small" @change="onClientChange">
<a-select-option v-for="type in inflectionTypes" :key="type" :value="type">{{ type }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item :label="$t('labels.inflection.columnName')">
<a-select v-model:value="formState.inflection.inflectionColumn" size="small" @change="onClientChange">
<a-select-option v-for="type in inflectionTypes" :key="type" :value="type">{{ type }}</a-select-option>
</a-select>
</a-form-item>
<div class="flex justify-end">
<a-button size="small" class="!shadow-md" @click="configEditDlg = true">
<!-- Edit connection JSON -->
{{ $t('activity.editConnJson') }}
</a-button>
</div>
</a-collapse-panel>
</a-collapse>
</template>
<a-form-item class="flex justify-center mt-5">
<div class="flex justify-center gap-2">
<a-button type="primary" class="nc-extdb-btn-test-connection" @click="testConnection">
{{ $t('activity.testDbConn') }}
</a-button>
<a-button type="primary" :disabled="!testSuccess" class="nc-extdb-btn-submit" @click="createProject"> Submit </a-button>
</div>
</a-form-item>
</a-form>
<v-dialog v-model="configEditDlg">
<a-card>
<MonacoEditor v-if="configEditDlg" v-model="formState" class="h-[400px] w-[600px]" />
</a-card>
</v-dialog>
</a-card>
</template>
<style scoped>
:deep(.ant-collapse-header) {
@apply !pr-10 !-mt-4 text-right justify-end;
}
:deep(.ant-collapse-content-box) {
@apply !px-0;
}
:deep(.ant-form-item-explain-error) {
@apply !text-xs;
}
:deep(.ant-form-item) {
@apply mb-2;
}
:deep(.ant-form-item-with-help .ant-form-item-explain) {
@apply !min-h-0;
}
</style>

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

@ -0,0 +1,82 @@
<script lang="ts" setup>
import { onMounted, onUpdated } from '@vue/runtime-core'
import type { Form } from 'ant-design-vue'
import { useToast } from 'vue-toastification'
import { nextTick, ref } from '#imports'
import { navigateTo, useNuxtApp } from '#app'
import { extractSdkResponseErrorMsg } from '~/utils/errorUtils'
import { projectTitleValidator } from '~/utils/validation'
import MaterialSymbolsRocketLaunchOutline from '~icons/material-symbols/rocket-launch-outline'
const name = ref('')
const loading = ref(false)
const valid = ref(false)
const { $api, $state, $e } = useNuxtApp()
const toast = useToast()
const nameValidationRules = [
{
required: true,
message: 'Project name is required',
},
projectTitleValidator,
]
const formState = reactive({
title: '',
})
const createProject = async () => {
$e('a:project:create:xcdb')
loading.value = true
try {
const result = await $api.project.create({
title: formState.title,
})
await navigateTo(`/nc/${result.id}`)
} catch (e: any) {
toast.error(await extractSdkResponseErrorMsg(e))
}
loading.value = false
}
const form = ref<typeof Form>()
// hide sidebar
$state.sidebarOpen.value = false
// select and focus title field on load
onMounted(async () => {
nextTick(() => {
// todo: replace setTimeout and follow better approach
setTimeout(() => {
const input = form.value?.$el?.querySelector('input[type=text]')
input.setSelectionRange(0, formState.title.length)
input.focus()
}, 500)
})
})
</script>
<template>
<a-card class="w-[500px] mx-auto !mt-100px shadow-md">
<h3 class="text-3xl text-center font-semibold mb-2">{{ $t('activity.createProject') }}</h3>
<a-form ref="form" :model="formState" name="basic" layout="vertical" autocomplete="off" @finish="createProject">
<a-form-item :label="$t('labels.projName')" name="title" :rules="nameValidationRules" class="my-10 mx-10">
<a-input v-model:value="formState.title" name="title" class="nc-metadb-project-name" />
</a-form-item>
<a-form-item style="text-align: center" class="mt-2">
<a-button type="primary" html-type="submit">
<div class="flex items-center">
<MaterialSymbolsRocketLaunchOutline class="mr-1" />
{{ $t('general.create') }}
</div>
</a-button>
</a-form-item>
</a-form>
</a-card>
</template>

164
packages/nc-gui-v2/pages/projects/create-external.vue

@ -1,164 +0,0 @@
<script lang="ts" setup>
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useToast } from 'vue-toastification'
import { navigateTo, useNuxtApp } from '#app'
import { extractSdkResponseErrorMsg } from '~/utils/errorUtils'
import { clientTypes, getDefaultConnectionConfig, getTestDatabaseName } from '~/utils/projectCreateUtils'
import MaterialSymbolsRocketLaunchOutline from '~icons/material-symbols/rocket-launch-outline'
const name = ref('')
const loading = ref(false)
const valid = ref(false)
const testSuccess = ref(true)
const projectDatasource = ref(getDefaultConnectionConfig('mysql2'))
const inflection = reactive({
tableName: 'camelize',
columnName: 'camelize',
})
const { $api, $e } = useNuxtApp()
const toast = useToast()
const { t } = useI18n()
const titleValidationRule = [
(v: string) => !!v || 'Title is required',
(v: string) => v.length <= 50 || 'Project name exceeds 50 characters',
]
const createProject = async () => {
loading.value = true
try {
const result = await $api.project.create({
title: name.value,
bases: [
{
type: projectDatasource.value.client,
config: projectDatasource.value,
inflection_column: inflection.columnName,
inflection_table: inflection.tableName,
},
],
external: true,
})
await navigateTo(`/nc/${result.id}`)
} catch (e: any) {
// todo: toast
toast.error(await extractSdkResponseErrorMsg(e))
}
loading.value = false
}
const testConnection = async () => {
$e('a:project:create:extdb:test-connection', [])
try {
// this.handleSSL(projectDatasource)
if (projectDatasource.value.client === 'sqlite3') {
testSuccess.value = true
} else {
const testConnectionConfig = {
...projectDatasource,
connection: {
...projectDatasource.value.connection,
database: getTestDatabaseName(projectDatasource.value),
},
}
const result = await $api.utils.testConnection(testConnectionConfig)
if (result.code === 0) {
testSuccess.value = true
} else {
testSuccess.value = false
toast.error(`${t('msg.error.dbConnectionFailed')} ${result.message}`)
}
}
} catch (e: any) {
testSuccess.value = false
toast.error(await extractSdkResponseErrorMsg(e))
}
}
</script>
<template>
<NuxtLayout>
<v-form ref="formValidator" v-model="valid" class="h-full" @submit.prevent="createProject">
<v-container fluid class="flex justify-center items-center h-5/6">
<v-card max-width="600">
<!-- Create Project -->
<v-container class="pb-10 px-12">
<h1 class="my-4 prose-lg text-center">
{{ $t('activity.createProject') }}
</h1>
<v-row>
<v-col offset="2" cols="8">
<v-text-field
v-model="name"
:rules="titleValidationRule"
class="nc-metadb-project-name"
:label="$t('labels.projName')"
/>
</v-col>
<v-col cols="6">
<v-select
v-model="projectDatasource.client"
density="compact"
:items="clientTypes"
item-title="text"
item-value="value"
class="nc-metadb-project-name"
label="Database client"
/>
</v-col>
<v-col cols="6">
<v-text-field v-model="projectDatasource.connection.host" density="compact" :label="$t('labels.hostAddress')" />
</v-col>
<v-col cols="6">
<v-text-field
v-model="projectDatasource.connection.port"
density="compact"
:label="$t('labels.port')"
type="number"
/>
</v-col>
<v-col cols="6">
<v-text-field v-model="projectDatasource.connection.user" density="compact" :label="$t('labels.username')" />
</v-col>
<v-col cols="6">
<v-text-field
v-model="projectDatasource.connection.password"
density="compact"
type="password"
:label="$t('labels.password')"
/>
</v-col>
<v-col cols="6">
<v-text-field v-model="projectDatasource.connection.database" density="compact" label="Database name" />
</v-col>
</v-row>
<div class="d-flex justify-center" style="gap: 4px">
<v-btn :disabled="!testSuccess" large :loading="loading" color="primary" @click="createProject">
<MaterialSymbolsRocketLaunchOutline class="mr-1" />
<span> {{ $t('general.create') }} </span>
</v-btn>
<!-- <v-btn small class="px-2"> -->
<!-- todo:implement test connection -->
<!-- <v-btn size="sm" class="text-sm text-capitalize">
&lt;!&ndash; Test Database Connection &ndash;&gt;
{{ $t('activity.testDbConn') }}
</v-btn> -->
</div>
</v-container>
</v-card>
</v-container>
</v-form>
</NuxtLayout>
</template>
<style scoped></style>

74
packages/nc-gui-v2/pages/projects/create.vue

@ -1,74 +0,0 @@
<script lang="ts" setup>
import { ref } from 'vue'
import { navigateTo, useNuxtApp } from '#app'
import { extractSdkResponseErrorMsg } from '~/utils/errorUtils'
import MaterialSymbolsRocketLaunchOutline from '~icons/material-symbols/rocket-launch-outline'
const name = ref('')
const loading = ref(false)
const valid = ref(false)
const { $api, $toast } = useNuxtApp()
const nameValidationRules = [
(v: string) => !!v || 'Title is required',
(v: string) => v.length <= 50 || 'Project name exceeds 50 characters',
]
const createProject = async () => {
loading.value = true
try {
const result = await $api.project.create({
title: name.value,
})
await navigateTo(`/nc/${result.id}`)
} catch (e: any) {
$toast.error(await extractSdkResponseErrorMsg(e)).goAway(3000)
}
loading.value = false
}
</script>
<template>
<NuxtLayout>
<v-form ref="formValidator" v-model="valid" class="h-full" @submit.prevent="createProject">
<v-container fluid class="flex justify-center items-center h-3/4">
<v-card max-width="500">
<v-container class="pb-10 px-12">
<h1 class="my-4 prose-lg text-center">
{{ $t('activity.createProject') }}
</h1>
<div class="mx-auto" style="width: 350px">
<v-text-field
v-model="name"
class="nc-metadb-project-name"
:rules="nameValidationRules"
:label="$t('labels.projName')"
/>
</div>
<v-btn class="mx-auto" large :loading="loading" color="primary" @click="createProject">
<MaterialSymbolsRocketLaunchOutline class="mr-1" />
<span> {{ $t('general.create') }} </span>
</v-btn>
</v-container>
</v-card>
</v-container>
</v-form>
</NuxtLayout>
</template>
<style scoped>
:deep(label) {
font-size: 0.75rem;
}
.wrapper {
border: 2px solid var(--v-backgroundColor-base);
border-radius: 4px;
}
.main {
height: calc(100vh - 48px);
}
</style>

22
packages/nc-gui-v2/pages/projects/index.vue

@ -34,7 +34,7 @@ const navDrawerOptions = [
const route = useRoute()
const { $api, $state } = useNuxtApp()
const { $api } = useNuxtApp()
const toast = useToast()
const response = await $api.project.list({})
@ -81,14 +81,14 @@ const visible = ref(true)
<v-list class="!py-0 flex flex-col bg-white rounded-lg shadow-md border-1 border-gray-300 mt-2 ml-2">
<div
class="grid grid-cols-12 cursor-pointer hover:bg-gray-200 flex items-center p-2"
@click="navigateTo('/create')"
@click="navigateTo('/project/create')"
>
<MdiPlus class="col-span-2 mr-1 mt-[1px] text-primary text-lg" />
<div class="col-span-10 text-sm xl:text-md">{{ $t('activity.createProject') }}</div>
</div>
<div
class="grid grid-cols-12 cursor-pointer hover:bg-gray-200 flex items-center p-2"
@click="navigateTo('/create-external')"
@click="navigateTo('/project/create-external')"
>
<MdiDatabaseOutline class="col-span-2 mr-1 mt-[1px] text-green-500 text-lg" />
<div class="col-span-10 text-sm xl:text-md" v-html="$t('activity.createProjectExtended.extDB')" />
@ -97,11 +97,11 @@ const visible = ref(true)
</v-menu>
</div>
<a-menu class="mx-4 dark:bg-gray-800 dark:text-white flex-1 border-0">
<a-menu class="pr-4 dark:bg-gray-800 dark:text-white flex-1 border-0">
<a-menu-item
v-for="(option, index) in navDrawerOptions"
:key="index"
class="f!rounded-r-lg"
class="!rounded-r-lg"
@click="activePage = option.title"
>
<div class="flex items-center gap-4">
@ -128,14 +128,14 @@ const visible = ref(true)
<div class="self-end flex text-4xl mb-1">
<MaterialSymbolsGridView
:class="route.name === 'projects-index' ? 'text-primary dark:(!text-secondary/75)' : ''"
class="color-transition cursor-pointer p-2 hover:bg-gray-300/50 rounded-full"
@click="navigateTo('/projects')"
:class="route.name === 'index-index' ? '!text-primary dark:(!text-secondary/75)' : ''"
class="cursor-pointer p-2 hover:bg-gray-300/50 rounded-full"
@click="navigateTo('/')"
/>
<MaterialSymbolsFormatListBulletedRounded
:class="route.name === 'projects-index-list' ? 'text-primary dark:(!text-secondary/75)' : ''"
class="color-transition cursor-pointer p-2 hover:bg-gray-300/50 rounded-full"
@click="navigateTo('/projects/list')"
:class="route.name === 'index-index-list' ? '!text-primary dark:(!text-secondary/75)' : ''"
class="cursor-pointer p-2 hover:bg-gray-300/50 rounded-full"
@click="navigateTo('/list')"
/>
</div>
</div>

15
packages/nc-gui-v2/pages/index/index/index.vue → packages/nc-gui-v2/pages/projects/index/index.vue

@ -34,7 +34,7 @@ const formatTitle = (title: string) =>
</script>
<template>
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 3xl:grid-cols-6 gap-6 md:(gap-y-16)">
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 3xl:grid-cols-8 gap-6 md:(gap-y-16)">
<div class="group flex flex-col items-center gap-2">
<v-menu>
<template #activator="{ props }">
@ -50,13 +50,16 @@ const formatTitle = (title: string) =>
</div>
</template>
<v-list class="!py-0 flex flex-col bg-white rounded-lg shadow-md border-1 border-gray-300 mt-2 ml-2">
<div class="grid grid-cols-12 cursor-pointer hover:bg-gray-200 flex items-center p-2" @click="navigateTo('/create')">
<div
class="grid grid-cols-12 cursor-pointer hover:bg-gray-200 flex items-center p-2"
@click="navigateTo('/project/create')"
>
<MdiPlus class="col-span-2 mr-1 mt-[1px] text-primary text-lg" />
<div class="col-span-10 text-sm xl:text-md">{{ $t('activity.createProject') }}</div>
</div>
<div
class="grid grid-cols-12 cursor-pointer hover:bg-gray-200 flex items-center p-2"
@click="navigateTo('/create-external')"
@click="navigateTo('/project/create-external')"
>
<MdiDatabaseOutline class="col-span-2 mr-1 mt-[1px] text-green-500 text-lg" />
<div class="col-span-10 text-sm xl:text-md" v-html="$t('activity.createProjectExtended.extDB')" />
@ -78,7 +81,7 @@ const formatTitle = (title: string) =>
<div class="col-span-4 text-sm xl:text-md">{{ $t('general.delete') }}</div>
</div>
</a-menu-item>
<a-menu-item>
<a-menu-item @click.stop="navigateTo(`/project/${project.id}`)">
<div class="grid grid-cols-6 cursor-pointer flex items-center p-2">
<MdiEditOutline class="col-span-2 mr-1 mt-[1px] text-primary text-lg" />
<div class="col-span-4 text-sm xl:text-md">{{ $t('general.edit') }}</div>
@ -89,7 +92,7 @@ const formatTitle = (title: string) =>
</a-dropdown>
</div>
<div class="prose-lg font-semibold">
<div class="prose-lg font-semibold overflow-ellipsis w-full overflow-hidden text-center capitalize">
{{ project.title || 'Untitled' }}
</div>
</div>
@ -98,7 +101,7 @@ const formatTitle = (title: string) =>
<style scoped>
.thumbnail {
@apply relative rounded-md opacity-75 font-bold text-white text-[75px] h-[150px] w-full max-w-[150px] shadow-md cursor-pointer uppercase flex items-center justify-center color-transition hover:(after:opacity-100 shadow-none);
@apply relative rounded-md opacity-75 font-bold text-white text-[75px] h-[100px] w-full w-[100px] shadow-md cursor-pointer uppercase flex items-center justify-center color-transition hover:(after:opacity-100 shadow-none);
}
.thumbnail::after {

49
packages/nc-gui-v2/pages/projects/index/list.vue

@ -0,0 +1,49 @@
<script lang="ts" setup>
import type { ProjectType } from 'nocodb-sdk'
import { navigateTo } from '#app'
import MdiDeleteOutline from '~icons/mdi/delete-outline'
import MdiEditOutline from '~icons/mdi/edit-outline'
interface Props {
projects?: ProjectType[]
}
const { projects = [] } = defineProps<Props>()
const emit = defineEmits(['delete-project'])
const { $e } = useNuxtApp()
const openProject = async (project: ProjectType) => {
await navigateTo(`/nc/${project.id}`)
$e('a:project:open', { count: projects.length })
}
</script>
<template>
<div>
<div class="grid grid-cols-3 gap-2 prose-md p-2 font-semibold">
<div>{{ $t('general.title') }}</div>
<div>Updated At</div>
<div></div>
</div>
<div class="col-span-3 w-full h-[1px] bg-gray-500/50" />
<template v-for="project of projects" :key="project.id">
<div
class="cursor-pointer grid grid-cols-3 gap-2 prose-md hover:(bg-gray-300/30 dark:bg-gray-500/30 shadow-sm) p-2 transition-color ease-in duration-100"
@click="openProject(project)"
>
<div class="font-semibold capitalize">{{ project.title || 'Untitled' }}</div>
<div>{{ project.updated_at }}</div>
<div class="flex justify-center">
<MdiDeleteOutline class="text-gray-500 hover:text-red-500 mr-2" @click.stop="emit('delete-project', project)" />
<MdiEditOutline class="text-gray-500 hover:text-primary mr-2" @click.stop="navigateTo(`/project/${project.id}`)" />
</div>
</div>
<div class="col-span-3 w-full h-[1px] bg-gray-500/30" />
</template>
</div>
</template>

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

@ -43,14 +43,14 @@ function addAxiosInterceptors(api: Api<any>, app: { $state: GlobalState }) {
// Return any error which is not due to authentication back to the calling service
if (!error.response || error.response.status !== 401) {
return error
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 error
return Promise.reject(error)
}
// Try request again with new token

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

@ -1,5 +1,14 @@
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import utc from 'dayjs/plugin/utc'
dayjs.extend(utc)
dayjs.extend(relativeTime)
export const timeAgo = (date: any) => {
return dayjs.utc(date).fromNow()
}
export const handleTZ = (val: any) => {
if (!val) {
return

13
packages/nc-gui-v2/utils/deepCompare.ts

@ -0,0 +1,13 @@
export const deepCompare = (a: any, b: any) => {
if (a === b) return true
if (a == null || b === null) return false
if (typeof a !== typeof b) return false
if (typeof a !== 'object') return a === b
if (Object.keys(a).length !== Object.keys(b).length) return false
for (const k in a) {
if (!(k in b)) return false
if (!deepCompare(a[k], b[k])) return false
}
return true
}

42
packages/nc-gui-v2/utils/fileUtils.ts

@ -5,3 +5,45 @@ const isImage = (name: string, mimetype?: string) => {
}
export { isImage, imageExt }
// Ref : https://stackoverflow.com/a/12002275
// Tested in Mozilla Firefox browser, Chrome
export function readFile(FileElement: HTMLInputElement, CallBackFunction: (content?: any) => void) {
try {
if (!FileElement.files || !FileElement.files.length) {
return CallBackFunction()
}
const file = FileElement.files[0]
if (file) {
const reader = new FileReader()
reader.readAsText(file, 'UTF-8')
reader.onload = function (evt) {
CallBackFunction(evt.target?.result)
}
reader.onerror = function () {
CallBackFunction()
}
}
} catch (Exception) {
const fallBack = ieReadFile(FileElement.value)
// eslint-disable-next-line eqeqeq
if (fallBack != false) {
CallBackFunction(fallBack)
}
}
}
/// Reading files with Internet Explorer
function ieReadFile(filename: string) {
try {
const fso = new ActiveXObject('Scripting.FileSystemObject')
const fh = fso.OpenTextFile(filename, 1)
const contents = fh.ReadAll()
fh.Close()
return contents
} catch (Exception) {
return false
}
}

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

@ -1,3 +1,6 @@
import { adjectives, animals, starWars, uniqueNamesGenerator } from 'unique-names-generator'
import type { ClientType, ProjectCreateForm } from '~/lib/types'
const testDataBaseNames = {
mysql2: null,
mysql: null,
@ -7,8 +10,6 @@ const testDataBaseNames = {
sqlite3: 'a.sqlite',
}
export type ClientType = 'mysql2' | 'mssql' | 'pg' | 'sqlite3' | 'vitess'
export const getTestDatabaseName = (db: { client: ClientType; connection?: { database?: string } }) => {
if (db.client === 'pg') return db.connection?.database
return testDataBaseNames[db.client as keyof typeof testDataBaseNames]
@ -29,23 +30,24 @@ export const clientTypes = [
},
{
text: 'SQLite',
value: 'sqlite',
value: 'sqlite3',
},
]
const homeDir = ''
const sampleConnectionData = {
const sampleConnectionData: Record<ClientType | string, ProjectCreateForm['dataSource']['connection']> = {
pg: {
host: 'localhost',
port: '5432',
user: 'postgres',
password: 'password',
database: '_test',
// ssl: {
// ca: '',
// key: '',
// cert: '',
// },
searchPath: ['public'],
ssl: {
ca: '',
key: '',
cert: '',
},
},
mysql2: {
host: 'localhost',
@ -53,11 +55,11 @@ const sampleConnectionData = {
user: 'root',
password: 'password',
database: '_test',
// ssl: {
// ca: '',
// key: '',
// cert: '',
// },
ssl: {
ca: '',
key: '',
cert: '',
},
},
vitess: {
host: 'localhost',
@ -65,11 +67,11 @@ const sampleConnectionData = {
user: 'root',
password: 'password',
database: '_test',
// ssl: {
// ca: '',
// key: '',
// cert: '',
// },
ssl: {
ca: '',
key: '',
cert: '',
},
},
tidb: {
host: 'localhost',
@ -77,11 +79,11 @@ const sampleConnectionData = {
user: 'root',
password: '',
database: '_test',
// ssl: {
// ca: '',
// key: '',
// cert: '',
// },
ssl: {
ca: '',
key: '',
cert: '',
},
},
yugabyte: {
host: 'localhost',
@ -89,11 +91,11 @@ const sampleConnectionData = {
user: 'postgres',
password: '',
database: '_test',
// ssl: {
// ca: '',
// key: '',
// cert: '',
// },
ssl: {
ca: '',
key: '',
cert: '',
},
},
citusdb: {
host: 'localhost',
@ -101,11 +103,11 @@ const sampleConnectionData = {
user: 'postgres',
password: '',
database: '_test',
// ssl: {
// ca: '',
// key: '',
// cert: '',
// },
ssl: {
ca: '',
key: '',
cert: '',
},
},
cockroachdb: {
host: 'localhost',
@ -113,11 +115,11 @@ const sampleConnectionData = {
user: 'postgres',
password: '',
database: '_test',
// ssl: {
// ca: '',
// key: '',
// cert: '',
// },
ssl: {
ca: '',
key: '',
cert: '',
},
},
greenplum: {
host: 'localhost',
@ -125,11 +127,11 @@ const sampleConnectionData = {
user: 'postgres',
password: '',
database: '_test',
// ssl: {
// ca: '',
// key: '',
// cert: '',
// },
ssl: {
ca: '',
key: '',
cert: '',
},
},
mssql: {
host: 'localhost',
@ -137,11 +139,12 @@ const sampleConnectionData = {
user: 'sa',
password: 'Password123.',
database: '_test',
// ssl: {
// ca: '',
// key: '',
// cert: '',
// },
searchPath: ['dbo'],
ssl: {
ca: '',
key: '',
cert: '',
},
},
oracledb: {
host: 'localhost',
@ -149,11 +152,11 @@ const sampleConnectionData = {
user: 'system',
password: 'Oracle18',
database: '_test',
// ssl: {
// ca: '',
// key: '',
// cert: '',
// },
ssl: {
ca: '',
key: '',
cert: '',
},
},
sqlite3: {
client: 'sqlite3',
@ -165,9 +168,19 @@ const sampleConnectionData = {
},
}
export const getDefaultConnectionConfig = (client: ClientType): { client: ClientType; connection: any } => {
export const getDefaultConnectionConfig = (client: ClientType): ProjectCreateForm['dataSource'] => {
return {
client,
connection: sampleConnectionData[client],
}
}
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, '_')
}

17
packages/nc-gui-v2/utils/validation.ts

@ -65,3 +65,20 @@ export function validateColumnName(v: string, isGQL = false) {
return true
}
}
export const projectTitleValidator = {
validator: (rule: any, value: any, callback: (errMsg?: string) => void) => {
if (value?.length > 50) {
callback('Project name exceeds 50 characters')
}
if (value[0] === ' ') {
callback('Project name cannot start with space')
}
callback()
},
}
export const fieldRequiredValidator = {
required: true,
message: 'Field is required',
}

Loading…
Cancel
Save