Browse Source

refactor(gui-v2): make create and update project pages subpages of dashboard

pull/3280/head
braks 2 years ago
parent
commit
0bdbdd5bd7
  1. 235
      packages/nc-gui-v2/pages/index/index.vue
  2. 53
      packages/nc-gui-v2/pages/index/index/[id].vue
  3. 451
      packages/nc-gui-v2/pages/index/index/create-external.vue
  4. 51
      packages/nc-gui-v2/pages/index/index/create.vue
  5. 211
      packages/nc-gui-v2/pages/index/index/index.vue
  6. 5
      packages/nc-gui-v2/pages/project/index.vue
  7. 450
      packages/nc-gui-v2/pages/project/index/create-external.vue

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

@ -1,201 +1,14 @@
<script lang="ts" setup>
import { Modal, message } from 'ant-design-vue'
import type { ProjectType } from 'nocodb-sdk'
import {
computed,
definePageMeta,
extractSdkResponseErrorMsg,
navigateTo,
onMounted,
ref,
useApi,
useNuxtApp,
useSidebar,
useUIPermission,
} from '#imports'
definePageMeta({
title: 'title.myProject',
})
const { $e } = useNuxtApp()
const { api, isLoading } = useApi()
const { isUIAllowed } = useUIPermission()
useSidebar({ hasSidebar: true, isOpen: true })
const filterQuery = ref('')
const projects = ref<ProjectType[]>()
const loadProjects = async () => {
const response = await api.project.list({})
projects.value = response.list
}
const filteredProjects = computed(
() =>
projects.value?.filter(
(project) => !filterQuery.value || project.title?.toLowerCase?.().includes(filterQuery.value.toLowerCase()),
) ?? [],
)
const deleteProject = (project: ProjectType) => {
$e('c:project:delete')
Modal.confirm({
title: `Do you want to delete '${project.title}' project?`,
okText: 'Yes',
okType: 'danger',
cancelText: 'No',
async onOk() {
try {
await api.project.delete(project.id as string)
$e('a:project:delete')
return projects.value?.splice(projects.value.indexOf(project), 1)
} catch (e: any) {
return message.error(await extractSdkResponseErrorMsg(e))
}
},
})
}
onMounted(() => {
loadProjects()
})
</script>
<template>
<NuxtLayout>
<div class="bg-primary bg-opacity-5 flex flex-col lg:flex-row flex-wrap gap-6 py-6 px-12 pt-65px">
<NuxtLayout class="">
<div
class="min-h-[calc(100vh_-_var(--header-height))] h-auto bg-primary bg-opacity-5 flex flex-col lg:flex-row flex-wrap gap-6 py-6 px-12 pt-65px"
>
<div class="hidden xl:(block)">
<GeneralSponsors />
</div>
<div class="min-w-2/4 flex-auto">
<div
class="bg-white relative flex flex-col justify-center gap-2 w-full p-8 md:(rounded-lg border-1 border-gray-200 shadow-xl)"
>
<general-noco-icon
class="color-transition hover:(ring ring-accent)"
:class="[isLoading ? 'animated-bg-gradient' : '']"
/>
<h1 class="flex items-center justify-center gap-2 leading-8 mb-8 mt-4">
<!-- My Projects -->
<div class="text-4xl">{{ $t('title.myProject') }}</div>
<a-tooltip title="Reload projects">
<span
class="transition-all duration-200 h-full flex items-center group hover:ring active:(ring ring-accent) rounded-full mt-1"
:class="isLoading ? 'animate-spin ring ring-gray-200' : ''"
>
<MdiRefresh
v-t="['a:project:refresh']"
class="text-xl text-gray-500 group-hover:text-accent cursor-pointer"
:class="isLoading ? '!text-primary' : ''"
@click="loadProjects"
/>
</span>
</a-tooltip>
</h1>
<div class="flex mb-6">
<a-input-search
v-model:value="filterQuery"
class="max-w-[250px] nc-project-page-search rounded"
:placeholder="$t('activity.searchProject')"
/>
<div class="flex-1" />
<a-dropdown v-if="isUIAllowed('projectCreate', true)" :trigger="['click']">
<button class="nc-new-project-menu">
<div class="flex items-center w-full">
{{ $t('title.newProj') }}
<MdiMenuDown class="menu-icon" />
</div>
</button>
<template #overlay>
<a-menu class="!py-0 rounded">
<a-menu-item>
<div
v-t="['c:project:create:xcdb']"
class="nc-project-menu-item group"
@click="navigateTo('/project/create')"
>
<MdiPlusOutline class="group-hover:text-accent" />
<div>{{ $t('activity.createProject') }}</div>
</div>
</a-menu-item>
<a-menu-item>
<div
v-t="['c:project:create:extdb']"
class="nc-project-menu-item group"
@click="navigateTo('/project/create-external')"
>
<MdiDatabaseOutline class="group-hover:text-accent" />
<div v-html="$t('activity.createProjectExtended.extDB')" />
</div>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
<TransitionGroup name="layout" mode="out-in">
<div v-if="isLoading">
<a-skeleton />
</div>
<a-table
v-else
:custom-row="
(record) => ({
onClick: () => {
$e('a:project:open')
navigateTo(`/nc/${record.id}`)
},
class: ['group'],
})
"
: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 color-transition group-hover:text-primary !w-[400px] overflow-hidden overflow-ellipsis whitespace-nowrap"
>
{{ 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 items-center gap-2">
<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>
</TransitionGroup>
</div>
<div class="min-w-2/4 xl:max-w-2/4 w-full flex-auto">
<NuxtPage />
</div>
<div class="flex flex-1 justify-between gap-6 lg:block">
@ -210,39 +23,3 @@ onMounted(() => {
</div>
</NuxtLayout>
</template>
<style scoped>
.nc-action-btn {
@apply text-gray-500 group-hover:text-accent active:(ring ring-accent) cursor-pointer p-2 w-[30px] h-[30px] hover:bg-gray-300/50 rounded-full;
}
.nc-new-project-menu {
@apply cursor-pointer z-1 relative color-transition rounded-md px-3 py-2 text-white;
&::after {
@apply rounded-md absolute top-0 left-0 right-0 bottom-0 transition-all duration-150 ease-in-out bg-primary bg-opacity-100;
content: '';
z-index: -1;
}
&:hover::after {
@apply transform scale-110 ring ring-accent;
}
&:active::after {
@apply ring ring-accent;
}
}
:deep(.ant-table-cell) {
@apply py-1;
}
:deep(.ant-table-row) {
@apply cursor-pointer;
}
:deep(.ant-table) {
@apply min-h-[428px];
}
</style>

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

@ -56,8 +56,6 @@ const renameProject = async () => {
// select and focus title field on load
onMounted(async () => {
await getProject()
await nextTick(() => {
// todo: replace setTimeout and follow better approach
setTimeout(() => {
@ -69,34 +67,39 @@ onMounted(async () => {
}, 500)
})
})
await getProject()
</script>
<template>
<div
class="overflow-auto md:bg-primary bg-opacity-5 pb-4 update-project h-full min-h-[600px] flex flex-col justify-center items-end"
class="update-project bg-white relative flex-auto flex flex-col justify-center gap-2 p-8 md:(rounded-lg border-1 border-gray-200 shadow-xl)"
>
<general-noco-icon class="color-transition hover:(ring ring-accent)" :class="[isLoading ? 'animated-bg-gradient' : '']" />
<div
class="bg-white mt-65px relative flex flex-col justify-center gap-2 w-full max-w-[500px] !mx-auto p-8 md:(rounded-lg border-1 border-gray-200 shadow-xl)"
class="color-transition transform group absolute top-5 left-5 text-4xl rounded-full bg-white cursor-pointer"
@click="navigateTo('/')"
>
<general-noco-icon class="color-transition hover:(ring ring-accent)" :class="[isLoading ? 'animated-bg-gradient' : '']" />
<h1 class="prose-2xl font-bold self-center my-4">{{ $t('activity.editProject') }}</h1>
<a-form ref="form" :model="formState" name="basic" layout="vertical" no-style autocomplete="off" @finish="renameProject">
<a-form-item :label="$t('labels.projName')" name="title" :rules="nameValidationRules">
<a-input v-model:value="formState.title" name="title" class="nc-metadb-project-name" />
</a-form-item>
<div class="text-center">
<button type="submit" class="submit">
<span class="flex items-center gap-2">
<MaterialSymbolsRocketLaunchOutline />
{{ $t('general.edit') }}
</span>
</button>
</div>
</a-form>
<MdiChevronLeft class="text-black group-hover:(text-accent scale-110)" />
</div>
<h1 class="prose-2xl font-bold self-center my-4">{{ $t('activity.editProject') }}</h1>
<a-form ref="form" :model="formState" name="basic" layout="vertical" no-style autocomplete="off" @finish="renameProject">
<a-form-item :label="$t('labels.projName')" name="title" :rules="nameValidationRules">
<a-input v-model:value="formState.title" name="title" class="nc-metadb-project-name" />
</a-form-item>
<div class="text-center">
<button type="submit" class="submit">
<span class="flex items-center gap-2">
<MaterialSymbolsRocketLaunchOutline />
{{ $t('general.edit') }}
</span>
</button>
</div>
</a-form>
</div>
</template>
@ -107,12 +110,6 @@ onMounted(async () => {
@apply !appearance-none my-1 border-1 border-solid border-primary/50 rounded;
}
.password {
input {
@apply !border-none;
}
}
.submit {
@apply z-1 relative color-transition rounded p-3 text-white shadow-sm;

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

@ -0,0 +1,451 @@
<script lang="ts" setup>
import { Form, Modal, message } from 'ant-design-vue'
import {
clientTypes,
computed,
extractSdkResponseErrorMsg,
fieldRequiredValidator,
generateUniqueName,
getDefaultConnectionConfig,
getTestDatabaseName,
navigateTo,
nextTick,
onMounted,
projectTitleValidator,
readFile,
ref,
sslUsage,
useApi,
useI18n,
useNuxtApp,
useSidebar,
watch,
} from '#imports'
import { ClientType } from '~/lib'
import type { ProjectCreateForm } from '~/utils'
const useForm = Form.useForm
const testSuccess = ref(false)
const { api, isLoading } = useApi()
const { $e } = useNuxtApp()
useSidebar({ hasSidebar: false })
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.searchPath.0': [fieldRequiredValidator],
}
: {}),
}),
}
})
const { 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
}
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) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
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 = 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
message.error(`${t('msg.error.dbConnectionFailed')} ${result.message}`)
}
}
} catch (e: any) {
testSuccess.value = false
message.error(await extractSdkResponseErrorMsg(e))
}
}
// 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>
<div
class="create-external bg-white relative flex flex-col justify-center gap-2 w-full p-8 md:(rounded-lg border-1 border-gray-200 shadow-xl)"
>
<general-noco-icon class="color-transition hover:(ring ring-accent)" :class="[isLoading ? 'animated-bg-gradient' : '']" />
<div
class="color-transition transform group absolute top-5 left-5 text-4xl rounded-full bg-white cursor-pointer"
@click="navigateTo('/')"
>
<MdiChevronLeft class="text-black group-hover:(text-accent scale-110)" />
</div>
<h1 class="prose-2xl font-bold self-center my-4">{{ $t('activity.createProject') }}</h1>
<a-form
ref="form"
:model="formState"
name="external-project-create-form"
layout="horizontal"
no-style
:label-col="{ span: 10 }"
>
<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) && formState.dataSource.searchPath"
:label="$t('labels.schemaName')"
v-bind="validateInfos['dataSource.searchPath.0']"
>
<a-input v-model:value="formState.dataSource.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" class="shadow" @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" class="shadow" @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" class="shadow" @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 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" ghost class="nc-extdb-btn-test-connection" @click="testConnection">
{{ $t('activity.testDbConn') }}
</a-button>
<a-button type="primary" :disabled="!testSuccess" class="nc-extdb-btn-submit !shadow" @click="createProject">
Submit
</a-button>
</div>
</a-form-item>
</a-form>
<!-- todo: needs replacement
<v-dialog v-model="configEditDlg">
<a-card>
<MonacoEditor v-if="configEditDlg" v-model="formState" class="h-[400px] w-[600px]" />
</a-card>
</v-dialog>
-->
</div>
</template>
<style lang="scss" 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;
}
.create-external {
:deep(.ant-input-affix-wrapper),
:deep(.ant-input),
:deep(.ant-select) {
@apply !appearance-none my-1 border-1 border-solid border-primary/50 rounded;
}
.nc-extdb-host-password {
input {
@apply !border-none;
}
}
}
</style>

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

@ -63,29 +63,34 @@ onMounted(async () => {
</script>
<template>
<div class="overflow-auto md:bg-primary bg-opacity-5 pb-4 create h-full min-h-[600px] flex flex-col justify-center items-end">
<div
class="create bg-white relative flex flex-col justify-center gap-2 w-full p-8 md:(rounded-lg border-1 border-gray-200 shadow-xl)"
>
<general-noco-icon class="color-transition hover:(ring ring-accent)" :class="[isLoading ? 'animated-bg-gradient' : '']" />
<div
class="bg-white mt-65px relative flex flex-col justify-center gap-2 w-full max-w-[500px] !mx-auto p-8 md:(rounded-lg border-1 border-gray-200 shadow-xl)"
class="color-transition transform group absolute top-5 left-5 text-4xl rounded-full bg-white cursor-pointer"
@click="navigateTo('/')"
>
<general-noco-icon class="color-transition hover:(ring ring-accent)" :class="[isLoading ? 'animated-bg-gradient' : '']" />
<h1 class="prose-2xl font-bold self-center my-4">{{ $t('activity.createProject') }}</h1>
<a-form ref="form" :model="formState" name="basic" layout="vertical" no-style 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>
<div class="text-center">
<button class="submit" type="submit">
<span class="flex items-center gap-2">
<MaterialSymbolsRocketLaunchOutline />
{{ $t('general.create') }}
</span>
</button>
</div>
</a-form>
<MdiChevronLeft class="text-black group-hover:(text-accent scale-110)" />
</div>
<h1 class="prose-2xl font-bold self-center my-4">{{ $t('activity.createProject') }}</h1>
<a-form ref="form" :model="formState" name="basic" layout="vertical" no-style 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>
<div class="text-center">
<button class="submit" type="submit">
<span class="flex items-center gap-2">
<MaterialSymbolsRocketLaunchOutline />
{{ $t('general.create') }}
</span>
</button>
</div>
</a-form>
</div>
</template>
@ -96,12 +101,6 @@ onMounted(async () => {
@apply !appearance-none my-1 border-1 border-solid border-primary/50 rounded;
}
.password {
input {
@apply !border-none;
}
}
.submit {
@apply z-1 relative color-transition rounded p-3 text-white shadow-sm;

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

@ -0,0 +1,211 @@
<script lang="ts" setup>
import { Modal, message } from 'ant-design-vue'
import type { ProjectType } from 'nocodb-sdk'
import {
computed,
definePageMeta,
extractSdkResponseErrorMsg,
navigateTo,
ref,
useApi,
useNuxtApp,
useSidebar,
useUIPermission,
} from '#imports'
definePageMeta({
title: 'title.myProject',
})
const { $e } = useNuxtApp()
const { api, isLoading } = useApi()
const { isUIAllowed } = useUIPermission()
useSidebar({ hasSidebar: true, isOpen: true })
const filterQuery = ref('')
const projects = ref<ProjectType[]>()
const loadProjects = async () => {
const response = await api.project.list({})
projects.value = response.list
}
const filteredProjects = computed(
() =>
projects.value?.filter(
(project) => !filterQuery.value || project.title?.toLowerCase?.().includes(filterQuery.value.toLowerCase()),
) ?? [],
)
const deleteProject = (project: ProjectType) => {
$e('c:project:delete')
Modal.confirm({
title: `Do you want to delete '${project.title}' project?`,
okText: 'Yes',
okType: 'danger',
cancelText: 'No',
async onOk() {
try {
await api.project.delete(project.id as string)
$e('a:project:delete')
projects.value?.splice(projects.value?.indexOf(project), 1)
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
},
})
}
await loadProjects()
</script>
<template>
<div class="bg-white relative flex flex-col justify-center gap-2 w-full p-8 md:(rounded-lg border-1 border-gray-200 shadow-xl)">
<general-noco-icon class="color-transition hover:(ring ring-accent)" :class="[isLoading ? 'animated-bg-gradient' : '']" />
<h1 class="flex items-center justify-center gap-2 leading-8 mb-8 mt-4">
<!-- My Projects -->
<span class="text-4xl">{{ $t('title.myProject') }}</span>
<a-tooltip title="Reload projects">
<span
class="transition-all duration-200 h-full flex items-center group hover:ring active:(ring ring-accent) rounded-full mt-1"
:class="isLoading ? 'animate-spin ring ring-gray-200' : ''"
>
<MdiRefresh
v-t="['a:project:refresh']"
class="text-xl text-gray-500 group-hover:text-accent cursor-pointer"
:class="isLoading ? '!text-primary' : ''"
@click="loadProjects"
/>
</span>
</a-tooltip>
</h1>
<div class="flex mb-6">
<a-input-search
v-model:value="filterQuery"
class="max-w-[250px] nc-project-page-search rounded"
:placeholder="$t('activity.searchProject')"
/>
<div class="flex-1" />
<a-dropdown v-if="isUIAllowed('projectCreate', true)" :trigger="['click']">
<button class="nc-new-project-menu">
<span class="flex items-center w-full">
{{ $t('title.newProj') }}
<MdiMenuDown class="menu-icon" />
</span>
</button>
<template #overlay>
<a-menu class="!py-0 rounded">
<a-menu-item>
<div v-t="['c:project:create:xcdb']" class="nc-project-menu-item group" @click="navigateTo('/create')">
<MdiPlusOutline class="group-hover:text-accent" />
<div>{{ $t('activity.createProject') }}</div>
</div>
</a-menu-item>
<a-menu-item>
<div v-t="['c:project:create:extdb']" class="nc-project-menu-item group" @click="navigateTo('/create-external')">
<MdiDatabaseOutline class="group-hover:text-accent" />
<div v-html="$t('activity.createProjectExtended.extDB')" />
</div>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
<TransitionGroup name="layout" mode="out-in">
<div v-if="isLoading">
<a-skeleton />
</div>
<a-table
v-else
:custom-row="
(record) => ({
onClick: () => {
$e('a:project:open')
navigateTo(`/nc/${record.id}`)
},
class: ['group'],
})
"
: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 color-transition group-hover:text-primary !w-[400px] overflow-hidden overflow-ellipsis whitespace-nowrap"
>
{{ 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 items-center gap-2">
<MdiEditOutline v-t="['c:project:edit:rename']" class="nc-action-btn" @click.stop="navigateTo(`/${text}`)" />
<MdiDeleteOutline class="nc-action-btn" @click.stop="deleteProject(record)" />
</div>
</template>
</a-table-column>
</a-table>
</TransitionGroup>
</div>
</template>
<style scoped>
.nc-action-btn {
@apply text-gray-500 group-hover:text-accent active:(ring ring-accent) cursor-pointer p-2 w-[30px] h-[30px] hover:bg-gray-300/50 rounded-full;
}
.nc-new-project-menu {
@apply cursor-pointer z-1 relative color-transition rounded-md px-3 py-2 text-white;
&::after {
@apply rounded-md absolute top-0 left-0 right-0 bottom-0 transition-all duration-150 ease-in-out bg-primary bg-opacity-100;
content: '';
z-index: -1;
}
&:hover::after {
@apply transform scale-110 ring ring-accent;
}
&:active::after {
@apply ring ring-accent;
}
}
:deep(.ant-table-cell) {
@apply py-1;
}
:deep(.ant-table-row) {
@apply cursor-pointer;
}
:deep(.ant-table) {
@apply min-h-[428px];
}
</style>

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

@ -1,5 +0,0 @@
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>

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

@ -1,450 +0,0 @@
<script lang="ts" setup>
import { Form, Modal, message } from 'ant-design-vue'
import {
clientTypes,
computed,
extractSdkResponseErrorMsg,
fieldRequiredValidator,
generateUniqueName,
getDefaultConnectionConfig,
getTestDatabaseName,
navigateTo,
nextTick,
onMounted,
projectTitleValidator,
readFile,
ref,
sslUsage,
useApi,
useI18n,
useNuxtApp,
useSidebar,
watch,
} from '#imports'
import { ClientType } from '~/lib'
import type { ProjectCreateForm } from '~/utils'
const useForm = Form.useForm
const testSuccess = ref(false)
const { api, isLoading } = useApi()
const { $e } = useNuxtApp()
useSidebar({ hasSidebar: false })
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.searchPath.0': [fieldRequiredValidator],
}
: {}),
}),
}
})
const { 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
}
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) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
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 = 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
message.error(`${t('msg.error.dbConnectionFailed')} ${result.message}`)
}
}
} catch (e: any) {
testSuccess.value = false
message.error(await extractSdkResponseErrorMsg(e))
}
}
// 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>
<div
class="overflow-auto md:bg-primary bg-opacity-5 pb-4 create-external h-full min-h-[600px] flex flex-col justify-center items-end"
>
<div
class="bg-white mt-275px relative flex flex-col justify-center gap-2 w-full max-w-[500px] !mx-auto p-8 md:(rounded-lg border-1 border-gray-200 shadow-xl)"
>
<general-noco-icon class="color-transition hover:(ring ring-accent)" :class="[isLoading ? 'animated-bg-gradient' : '']" />
<h1 class="prose-2xl font-bold self-center my-4">{{ $t('activity.createProject') }}</h1>
<a-form
ref="form"
:model="formState"
name="external-project-create-form"
layout="horizontal"
no-style
:label-col="{ span: 10 }"
>
<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) && formState.dataSource.searchPath"
:label="$t('labels.schemaName')"
v-bind="validateInfos['dataSource.searchPath.0']"
>
<a-input v-model:value="formState.dataSource.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" class="shadow" @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" class="shadow" @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" class="shadow" @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 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" ghost class="nc-extdb-btn-test-connection" @click="testConnection">
{{ $t('activity.testDbConn') }}
</a-button>
<a-button type="primary" :disabled="!testSuccess" class="nc-extdb-btn-submit !shadow" @click="createProject">
Submit
</a-button>
</div>
</a-form-item>
</a-form>
<!-- todo: needs replacement
<v-dialog v-model="configEditDlg">
<a-card>
<MonacoEditor v-if="configEditDlg" v-model="formState" class="h-[400px] w-[600px]" />
</a-card>
</v-dialog>
-->
</div>
</div>
</template>
<style lang="scss" 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;
}
:deep(.ant-card-head-title) {
@apply !text-3xl;
}
.create-external {
:deep(.ant-input-affix-wrapper),
:deep(.ant-input),
:deep(.ant-select) {
@apply !appearance-none my-1 border-1 border-solid border-primary/50 rounded;
}
}
</style>
Loading…
Cancel
Save