Browse Source

Merge branch 'refactor/gui-v2' into feat/gui-v2-quick-import

pull/2795/head
Wing-Kam Wong 2 years ago
parent
commit
40cbb14129
  1. 6
      packages/nc-gui-v2/app.vue
  2. 16
      packages/nc-gui-v2/components.d.ts
  3. 52
      packages/nc-gui-v2/components/monaco/Editor.vue
  4. 1
      packages/nc-gui-v2/lang/en.json
  5. 38
      packages/nc-gui-v2/lib/types.ts
  6. 4
      packages/nc-gui-v2/nuxt.config.ts
  7. 45
      packages/nc-gui-v2/package-lock.json
  8. 2
      packages/nc-gui-v2/package.json
  9. 213
      packages/nc-gui-v2/pages/index/index.vue
  10. 47
      packages/nc-gui-v2/pages/index/index/list.vue
  11. 13
      packages/nc-gui-v2/pages/project/index.vue
  12. 86
      packages/nc-gui-v2/pages/project/index/[id].vue
  13. 399
      packages/nc-gui-v2/pages/project/index/create-external.vue
  14. 82
      packages/nc-gui-v2/pages/project/index/create.vue
  15. 164
      packages/nc-gui-v2/pages/projects/create-external.vue
  16. 74
      packages/nc-gui-v2/pages/projects/create.vue
  17. 22
      packages/nc-gui-v2/pages/projects/index.vue
  18. 15
      packages/nc-gui-v2/pages/projects/index/index.vue
  19. 49
      packages/nc-gui-v2/pages/projects/index/list.vue
  20. 4
      packages/nc-gui-v2/plugins/api.ts
  21. 13
      packages/nc-gui-v2/utils/deepCompare.ts
  22. 42
      packages/nc-gui-v2/utils/fileUtils.ts
  23. 123
      packages/nc-gui-v2/utils/projectCreateUtils.ts
  24. 17
      packages/nc-gui-v2/utils/validation.ts

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

@ -35,10 +35,10 @@ const sidebarOpen = computed({
<div class="flex-1" /> <div class="flex-1" />
<div class="ml-4 flex items-center flex-1"> <div class="ml-4 flex justify-center flex-1">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2 cursor-pointer" @click="navigateTo('/')">
<img width="35" src="~/assets/img/icons/512x512-trans.png" /> <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> </div>
<!-- todo: loading is not yet supported by nuxt 3 - see https://v3.nuxtjs.org/migration/component-options#loading <!-- todo: loading is not yet supported by nuxt 3 - see https://v3.nuxtjs.org/migration/component-options#loading

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

@ -7,12 +7,20 @@ export {}
declare module '@vue/runtime-core' { declare module '@vue/runtime-core' {
export interface GlobalComponents { export interface GlobalComponents {
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'] ADivider: typeof import('ant-design-vue/es')['Divider']
ADropdown: typeof import('ant-design-vue/es')['Dropdown'] ADropdown: typeof import('ant-design-vue/es')['Dropdown']
AForm: typeof import('ant-design-vue/es')['Form'] AForm: typeof import('ant-design-vue/es')['Form']
AFormItem: typeof import('ant-design-vue/es')['FormItem'] AFormItem: typeof import('ant-design-vue/es')['FormItem']
AInput: typeof import('ant-design-vue/es')['Input'] AInput: typeof import('ant-design-vue/es')['Input']
AInputNumber: typeof import('ant-design-vue/es')['InputNumber']
AInputPassword: typeof import('ant-design-vue/es')['InputPassword'] AInputPassword: typeof import('ant-design-vue/es')['InputPassword']
AInputSearch: typeof import('ant-design-vue/es')['InputSearch']
ALayout: typeof import('ant-design-vue/es')['Layout'] ALayout: typeof import('ant-design-vue/es')['Layout']
ALayoutContent: typeof import('ant-design-vue/es')['LayoutContent'] ALayoutContent: typeof import('ant-design-vue/es')['LayoutContent']
ALayoutHeader: typeof import('ant-design-vue/es')['LayoutHeader'] ALayoutHeader: typeof import('ant-design-vue/es')['LayoutHeader']
@ -21,8 +29,16 @@ declare module '@vue/runtime-core' {
AMenuDivider: typeof import('ant-design-vue/es')['MenuDivider'] AMenuDivider: typeof import('ant-design-vue/es')['MenuDivider']
AMenuItem: typeof import('ant-design-vue/es')['MenuItem'] AMenuItem: typeof import('ant-design-vue/es')['MenuItem']
AModal: typeof import('ant-design-vue/es')['Modal'] AModal: typeof import('ant-design-vue/es')['Modal']
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']
ASubMenu: typeof import('ant-design-vue/es')['SubMenu'] ASubMenu: typeof import('ant-design-vue/es')['SubMenu']
ATable: typeof import('ant-design-vue/es')['Table'] ATable: typeof import('ant-design-vue/es')['Table']
ATableColumn: typeof import('ant-design-vue/es')['TableColumn']
ATableColumnGroup: typeof import('ant-design-vue/es')['TableColumnGroup']
ATag: typeof import('ant-design-vue/es')['Tag']
ATooltip: typeof import('ant-design-vue/es')['Tooltip']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
} }

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>

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

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

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 ReadonlyState = Readonly<Pick<State, 'token' | 'user'>> & Omit<State, 'token' | 'user'>
export type GlobalState = Getters & Actions & ToRefs<ReadonlyState> 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 Icons from 'unplugin-icons/vite'
import Components from 'unplugin-vue-components/vite' import Components from 'unplugin-vue-components/vite'
import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers' import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers'
import monacoEditorPlugin from 'vite-plugin-monaco-editor'
// https://v3.nuxtjs.org/api/configuration/nuxt.config // https://v3.nuxtjs.org/api/configuration/nuxt.config
export default defineNuxtConfig({ export default defineNuxtConfig({
@ -70,6 +71,9 @@ export default defineNuxtConfig({
}), }),
], ],
}), }),
monacoEditorPlugin({
languageWorkers: ['json'],
}),
], ],
define: { define: {
'process.env.DEBUG': 'false', 'process.env.DEBUG': 'false',

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

@ -12,6 +12,7 @@
"jwt-decode": "^3.1.2", "jwt-decode": "^3.1.2",
"nocodb-sdk": "file:../nocodb-sdk", "nocodb-sdk": "file:../nocodb-sdk",
"socket.io-client": "^4.5.1", "socket.io-client": "^4.5.1",
"unique-names-generator": "^4.7.1",
"vue-i18n": "^9.1.10", "vue-i18n": "^9.1.10",
"vue-toastification": "^2.0.0-rc.5", "vue-toastification": "^2.0.0-rc.5",
"vuetify": "^3.0.0-alpha.13" "vuetify": "^3.0.0-alpha.13"
@ -40,6 +41,7 @@
"sass": "^1.53.0", "sass": "^1.53.0",
"unplugin-icons": "^0.14.7", "unplugin-icons": "^0.14.7",
"unplugin-vue-components": "^0.21.1", "unplugin-vue-components": "^0.21.1",
"vite-plugin-monaco-editor": "^1.1.0",
"vitest": "^0.18.0", "vitest": "^0.18.0",
"windicss": "^3.5.6" "windicss": "^3.5.6"
} }
@ -9315,6 +9317,13 @@
"integrity": "sha512-sTitTPYnn23esFR3RlqYBWn4c45WGeLcsKzQiUpXJAyfcWkolvlYpV8FLo7JishK946oQwMFUCHXQ9AjGPKExw==", "integrity": "sha512-sTitTPYnn23esFR3RlqYBWn4c45WGeLcsKzQiUpXJAyfcWkolvlYpV8FLo7JishK946oQwMFUCHXQ9AjGPKExw==",
"dev": true "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": { "node_modules/mri": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
@ -12978,6 +12987,14 @@
"unplugin": "^0.6.3" "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": { "node_modules/unist-util-stringify-position": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-2.0.3.tgz", "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-2.0.3.tgz",
@ -13397,6 +13414,15 @@
"node": ">= 12" "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": { "node_modules/vite-plugin-vuetify": {
"version": "1.0.0-alpha.12", "version": "1.0.0-alpha.12",
"resolved": "https://registry.npmjs.org/vite-plugin-vuetify/-/vite-plugin-vuetify-1.0.0-alpha.12.tgz", "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": { "mri": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
@ -23835,6 +23868,11 @@
"unplugin": "^0.6.3" "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": { "unist-util-stringify-position": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-2.0.3.tgz", "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": { "vite-plugin-vuetify": {
"version": "1.0.0-alpha.12", "version": "1.0.0-alpha.12",
"resolved": "https://registry.npmjs.org/vite-plugin-vuetify/-/vite-plugin-vuetify-1.0.0-alpha.12.tgz", "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", "jwt-decode": "^3.1.2",
"nocodb-sdk": "file:../nocodb-sdk", "nocodb-sdk": "file:../nocodb-sdk",
"socket.io-client": "^4.5.1", "socket.io-client": "^4.5.1",
"unique-names-generator": "^4.7.1",
"vue-i18n": "^9.1.10", "vue-i18n": "^9.1.10",
"vue-toastification": "^2.0.0-rc.5", "vue-toastification": "^2.0.0-rc.5",
"vuetify": "^3.0.0-alpha.13" "vuetify": "^3.0.0-alpha.13"
@ -46,6 +47,7 @@
"sass": "^1.53.0", "sass": "^1.53.0",
"unplugin-icons": "^0.14.7", "unplugin-icons": "^0.14.7",
"unplugin-vue-components": "^0.21.1", "unplugin-vue-components": "^0.21.1",
"vite-plugin-monaco-editor": "^1.1.0",
"vitest": "^0.18.0", "vitest": "^0.18.0",
"windicss": "^3.5.6" "windicss": "^3.5.6"
} }

213
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 type { ProjectType } from 'nocodb-sdk'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import { navigateTo } from '#app' import { navigateTo } from '#app'
import { computed, onMounted } from '#imports'
import { extractSdkResponseErrorMsg } from '~/utils/errorUtils' 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 MdiPlus from '~icons/mdi/plus'
import MdiDatabaseOutline from '~icons/mdi/database-outline' import MdiDatabaseOutline from '~icons/mdi/database-outline'
import MdiFolderOutline from '~icons/mdi/folder-outline'
const navDrawerOptions = [ const { $api, $state, $e } = useNuxtApp()
{ const toast = useToast()
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 filterQuery = ref('')
const loading = ref(true)
const projects = ref()
const { $api, $state } = useNuxtApp() const loadProjects = async () => {
const toast = useToast() loading.value = true
const response = await $api.project.list({})
projects.value = response.list
loading.value = false
}
$state.sidebarOpen.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) => { const deleteProject = (project: ProjectType) => {
$e('c:project:delete')
Modal.confirm({ Modal.confirm({
title: 'Do you want to delete the project?', title: `Do you want to delete '${project.title}' project?`,
// icon: createVNode(ExclamationCircleOutlined),
content: 'Some descriptions',
okText: 'Yes', okText: 'Yes',
okType: 'danger', okType: 'danger',
cancelText: 'No', cancelText: 'No',
async onOk() { async onOk() {
try { try {
$e('c:project:delete')
await $api.project.delete(project.id as string) await $api.project.delete(project.id as string)
projects.splice(projects.indexOf(project), 1) projects.value.splice(projects.value.indexOf(project), 1)
} catch (e) { } catch (e) {
toast.error(await extractSdkResponseErrorMsg(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> </script>
<template> <template>
<NuxtLayout> <NuxtLayout>
<template #sidebar> <a-card class="mx-auto mt-5 !max-w-[600px] shadow-lg">
<div class="flex flex-col h-full"> <h1 class="text-center text-4xl pa-2 nc-project-page-title flex align-center justify-center gap-2">
<div class="flex p-4"> <!-- My Projects -->
<v-menu class="select-none"> <b>{{ $t('title.myProject') }}</b>
<template #activator="{ props }">
<div <MdiRefresh
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" v-t="['a:project:refresh']"
@click="props.onClick" class="text-sm text-gray-500 hover:text-primary mt-1 cursor-pointer"
> @click="loadProjects"
<MdiPlus class="text-primary dark:(!text-white) text-2xl" /> ></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') }} {{ $t('title.newProj') }}
<MdiMenuDown class="menu-icon" />
</div> </div>
</template> </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 <div
class="grid grid-cols-12 cursor-pointer hover:bg-gray-200 flex items-center p-2" v-t="['c:project:create:xcdb']"
@click="navigateTo('/create')" 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" /> <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 class="col-span-10 text-sm xl:text-md">{{ $t('activity.createProject') }}</div>
</div> </div>
<div <div
class="grid grid-cols-12 cursor-pointer hover:bg-gray-200 flex items-center p-2" v-t="['c:project:create:extdb']"
@click="navigateTo('/create-external')" 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" /> <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 class="col-span-10 text-sm xl:text-md" v-html="$t('activity.createProjectExtended.extDB')" />
</div> </div>
</v-list> </a-menu>
</v-menu> </template>
</a-dropdown>
</div> </div>
<a-menu class="pr-4 dark:bg-gray-800 dark:text-white flex-1 border-0"> <div v-if="loading">
<a-menu-item <a-skeleton />
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> </div>
</a-menu-item>
</a-menu>
<general-social />
<general-sponsors :nav="true" /> <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> </div>
</template> </template>
</a-table-column>
<v-container class="flex-1 mb-12"> <!-- Actions -->
<div class="flex"> <a-table-column key="id" :title="$t('labels.actions')" data-index="id">
<div class="flex-1 text-2xl md:text-4xl font-bold text-gray-500 dark:text-white p-4"> <template #default="{ text, record }">
{{ activePage }} <div class="flex align-center">
</div> <MdiEditOutline
v-t="['c:project:edit:rename']"
<div class="self-end flex text-4xl mb-1"> class="nc-action-btn"
<MaterialSymbolsGridView @click.stop="navigateTo(`/project/${text}`)"
: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')"
/> />
<MdiDeleteOutline class="nc-action-btn" @click.stop="deleteProject(record)" />
</div> </div>
</div> </template>
</a-table-column>
</a-table>
</a-card>
</NuxtLayout>
</template>
<a-divider class="!mb-4 lg:(!mb-8)" /> <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;
}
<NuxtPage :projects="projects" @delete-project="deleteProject" /> :deep(.ant-table-cell) {
</v-container> @apply py-1;
}
<a-modal></a-modal> :deep(.ant-table-row) {
</NuxtLayout> @apply cursor-pointer;
</template> }
: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>

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 route = useRoute()
const { $api, $state } = useNuxtApp() const { $api } = useNuxtApp()
const toast = useToast() const toast = useToast()
const response = await $api.project.list({}) 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"> <v-list class="!py-0 flex flex-col bg-white rounded-lg shadow-md border-1 border-gray-300 mt-2 ml-2">
<div <div
class="grid grid-cols-12 cursor-pointer hover:bg-gray-200 flex items-center p-2" 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" /> <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 class="col-span-10 text-sm xl:text-md">{{ $t('activity.createProject') }}</div>
</div> </div>
<div <div
class="grid grid-cols-12 cursor-pointer hover:bg-gray-200 flex items-center p-2" 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" /> <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 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> </v-menu>
</div> </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 <a-menu-item
v-for="(option, index) in navDrawerOptions" v-for="(option, index) in navDrawerOptions"
:key="index" :key="index"
class="f!rounded-r-lg" class="!rounded-r-lg"
@click="activePage = option.title" @click="activePage = option.title"
> >
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
@ -128,14 +128,14 @@ const visible = ref(true)
<div class="self-end flex text-4xl mb-1"> <div class="self-end flex text-4xl mb-1">
<MaterialSymbolsGridView <MaterialSymbolsGridView
:class="route.name === 'projects-index' ? 'text-primary dark:(!text-secondary/75)' : ''" :class="route.name === 'index-index' ? '!text-primary dark:(!text-secondary/75)' : ''"
class="color-transition cursor-pointer p-2 hover:bg-gray-300/50 rounded-full" class="cursor-pointer p-2 hover:bg-gray-300/50 rounded-full"
@click="navigateTo('/projects')" @click="navigateTo('/')"
/> />
<MaterialSymbolsFormatListBulletedRounded <MaterialSymbolsFormatListBulletedRounded
:class="route.name === 'projects-index-list' ? 'text-primary dark:(!text-secondary/75)' : ''" :class="route.name === 'index-index-list' ? '!text-primary dark:(!text-secondary/75)' : ''"
class="color-transition cursor-pointer p-2 hover:bg-gray-300/50 rounded-full" class="cursor-pointer p-2 hover:bg-gray-300/50 rounded-full"
@click="navigateTo('/projects/list')" @click="navigateTo('/list')"
/> />
</div> </div>
</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> </script>
<template> <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"> <div class="group flex flex-col items-center gap-2">
<v-menu> <v-menu>
<template #activator="{ props }"> <template #activator="{ props }">
@ -50,13 +50,16 @@ const formatTitle = (title: string) =>
</div> </div>
</template> </template>
<v-list class="!py-0 flex flex-col bg-white rounded-lg shadow-md border-1 border-gray-300 mt-2 ml-2"> <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" /> <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 class="col-span-10 text-sm xl:text-md">{{ $t('activity.createProject') }}</div>
</div> </div>
<div <div
class="grid grid-cols-12 cursor-pointer hover:bg-gray-200 flex items-center p-2" 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" /> <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 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 class="col-span-4 text-sm xl:text-md">{{ $t('general.delete') }}</div>
</div> </div>
</a-menu-item> </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"> <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" /> <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> <div class="col-span-4 text-sm xl:text-md">{{ $t('general.edit') }}</div>
@ -89,7 +92,7 @@ const formatTitle = (title: string) =>
</a-dropdown> </a-dropdown>
</div> </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' }} {{ project.title || 'Untitled' }}
</div> </div>
</div> </div>
@ -98,7 +101,7 @@ const formatTitle = (title: string) =>
<style scoped> <style scoped>
.thumbnail { .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 { .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 // Return any error which is not due to authentication back to the calling service
if (!error.response || error.response.status !== 401) { 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 // Logout user if token refresh didn't work or user is disabled
if (error.config.url === '/auth/refresh-token') { if (error.config.url === '/auth/refresh-token') {
app.$state.signOut() app.$state.signOut()
return error return Promise.reject(error)
} }
// Try request again with new token // Try request again with new token

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