mirror of https://github.com/nocodb/nocodb
Wing-Kam Wong
2 years ago
34 changed files with 1241 additions and 481 deletions
@ -0,0 +1,105 @@
|
||||
<script setup lang="ts"> |
||||
import { Tooltip as ATooltip } from 'ant-design-vue' |
||||
import type { AuditType } from 'nocodb-sdk' |
||||
import { timeAgo } from '~/utils/dateTimeUtils' |
||||
import { h, useNuxtApp, useProject } from '#imports' |
||||
import MdiReload from '~icons/mdi/reload' |
||||
|
||||
interface Props { |
||||
projectId: string |
||||
} |
||||
|
||||
const { projectId } = defineProps<Props>() |
||||
|
||||
const { $api } = useNuxtApp() |
||||
const { project, loadProject } = useProject() |
||||
|
||||
let isLoading = $ref(false) |
||||
|
||||
let audits = $ref<null | Array<AuditType>>(null) |
||||
|
||||
let totalRows = $ref(0) |
||||
|
||||
const currentPage = $ref(1) |
||||
const currentLimit = $ref(25) |
||||
|
||||
async function loadAudits(page = currentPage, limit = currentLimit) { |
||||
try { |
||||
if (!project.value?.id) return |
||||
|
||||
isLoading = true |
||||
|
||||
const { list, pageInfo } = await $api.project.auditList(project.value?.id, { |
||||
offset: (limit * (page - 1)).toString(), |
||||
limit: limit.toString(), |
||||
}) |
||||
|
||||
audits = list |
||||
totalRows = pageInfo.totalRows ?? 0 |
||||
} catch (e) { |
||||
console.error(e) |
||||
} finally { |
||||
isLoading = false |
||||
} |
||||
} |
||||
|
||||
onMounted(async () => { |
||||
if (audits === null) { |
||||
await loadProject(projectId) |
||||
await loadAudits(currentPage, currentLimit) |
||||
} |
||||
}) |
||||
|
||||
const columns = [ |
||||
{ |
||||
title: 'Operation Type', |
||||
dataIndex: 'op_type', |
||||
key: 'op_type', |
||||
}, |
||||
{ |
||||
title: 'Operation sub-type', |
||||
dataIndex: 'op_sub_type', |
||||
key: 'op_sub_type', |
||||
}, |
||||
{ |
||||
title: 'Description', |
||||
dataIndex: 'description', |
||||
key: 'description', |
||||
}, |
||||
{ |
||||
title: 'User', |
||||
dataIndex: 'user', |
||||
key: 'user', |
||||
customRender: (value: { text: string }) => h('div', () => value.text || 'Shared base'), |
||||
}, |
||||
{ |
||||
title: 'Created', |
||||
dataIndex: 'created_at', |
||||
key: 'created_at', |
||||
sort: 'desc', |
||||
customRender: (value: { text: string }) => |
||||
h(ATooltip, { placement: 'bottom', title: h('span', {}, value.text) }, () => timeAgo(value.text)), |
||||
}, |
||||
] |
||||
</script> |
||||
|
||||
<template> |
||||
<div class="flex flex-col gap-4 w-full"> |
||||
<a-button class="self-start" @click="loadAudits"> |
||||
<div class="flex items-center gap-2"> |
||||
<MdiReload :class="{ 'animate-infinite animate-spin !text-success': isLoading }" /> |
||||
Reload |
||||
</div> |
||||
</a-button> |
||||
|
||||
<a-table class="w-full" :data-source="audits ?? []" :columns="columns" :pagination="false" :loading="isLoading" /> |
||||
|
||||
<a-pagination |
||||
v-model:current="currentPage" |
||||
:page-size="currentLimit" |
||||
:total="totalRows" |
||||
show-less-items |
||||
@change="loadAudits" |
||||
/> |
||||
</div> |
||||
</template> |
@ -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> |
@ -0,0 +1,5 @@
|
||||
<template> |
||||
<div> |
||||
<h2 class="text-3xl mt-3">Team & Auth</h2> |
||||
</div> |
||||
</template> |
@ -1 +1,2 @@
|
||||
export const NOCO = 'noco' |
||||
export const USER_PROJECT_ROLES = 'user_project_roles' |
||||
|
@ -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> |
@ -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> |
@ -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> |
@ -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> |
@ -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> |
@ -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"> |
||||
<!– Test Database Connection –> |
||||
{{ $t('activity.testDbConn') }} |
||||
</v-btn> --> |
||||
</div> |
||||
</v-container> |
||||
</v-card> |
||||
</v-container> |
||||
</v-form> |
||||
</NuxtLayout> |
||||
</template> |
||||
|
||||
<style scoped></style> |
@ -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> |
@ -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> |
@ -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 |
||||
} |
Loading…
Reference in new issue