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 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