After Width: | Height: | Size: 554 B |
After Width: | Height: | Size: 1.4 KiB |
Before Width: | Height: | Size: 949 B After Width: | Height: | Size: 928 B |
Before Width: | Height: | Size: 301 B After Width: | Height: | Size: 320 B |
After Width: | Height: | Size: 774 B |
After Width: | Height: | Size: 1.4 KiB |
After Width: | Height: | Size: 1.5 KiB |
After Width: | Height: | Size: 828 B |
After Width: | Height: | Size: 716 B |
After Width: | Height: | Size: 1.2 KiB |
After Width: | Height: | Size: 2.4 KiB |
After Width: | Height: | Size: 1.4 KiB |
After Width: | Height: | Size: 4.1 KiB |
After Width: | Height: | Size: 2.1 KiB |
After Width: | Height: | Size: 9.4 KiB |
After Width: | Height: | Size: 5.1 KiB |
After Width: | Height: | Size: 3.5 KiB |
After Width: | Height: | Size: 4.6 KiB |
After Width: | Height: | Size: 5.3 KiB |
After Width: | Height: | Size: 6.6 KiB |
@ -0,0 +1,129 @@
|
||||
<script setup lang="ts"> |
||||
const { t } = useI18n() |
||||
|
||||
const { loadSetupApps, emailConfigured, storageConfigured, listModalDlg } = useAccountSetupStoreOrThrow() |
||||
|
||||
// const { appInfo } = useGlobal() |
||||
|
||||
const openedCategory = ref<string | null>(null) |
||||
|
||||
const configs = computed(() => [ |
||||
{ |
||||
title: t('labels.configLabel', { label: t('labels.email') }), |
||||
key: 'email', |
||||
description: |
||||
'Configure your preferred email service to manage how your application sends alerts, notifications and other essential emails.', |
||||
docsLink: 'https://docs.nocodb.com/account-settings/oss-specific-details#configure-email', |
||||
buttonClick: () => { |
||||
navigateTo(`/account/setup/email${emailConfigured.value ? `/${emailConfigured.value.title}` : ''}`) |
||||
}, |
||||
itemClick: () => { |
||||
navigateTo(`/account/setup/email`) |
||||
}, |
||||
configured: emailConfigured.value, |
||||
}, |
||||
{ |
||||
title: t('labels.configLabel', { label: t('labels.storage') }), |
||||
key: 'storage', |
||||
description: 'Set up and manage your preferred storage solution for securely handling and storing your application’s data.', |
||||
docsLink: 'https://docs.nocodb.com/account-settings/oss-specific-details#configure-storage', |
||||
buttonClick: () => { |
||||
navigateTo(`/account/setup/storage${storageConfigured.value ? `/${storageConfigured.value.title}` : ''}`) |
||||
}, |
||||
itemClick: () => { |
||||
navigateTo(`/account/setup/storage`) |
||||
}, |
||||
configured: storageConfigured.value, |
||||
}, |
||||
// { |
||||
// title: t('labels.switchToProd'), |
||||
// key: 'switchToProd', |
||||
// description: 'Switch to production-ready app database from existing application database.', |
||||
// docsLink: 'https://docs.nocodb.com', |
||||
// buttonClick: () => { |
||||
// // TODO: Implement the logic to switch to production |
||||
// }, |
||||
// isPending: !(appInfo.value as any)?.prodReady, |
||||
// }, |
||||
]) |
||||
|
||||
onMounted(async () => { |
||||
await loadSetupApps() |
||||
}) |
||||
</script> |
||||
|
||||
<template> |
||||
<div class="flex flex-col" data-test-id="nc-setup-main"> |
||||
<NcPageHeader> |
||||
<template #icon> |
||||
<div class="flex justify-center items-center h-5 w-5"> |
||||
<GeneralIcon icon="ncSliders" class="flex-none text-[20px]" /> |
||||
</div> |
||||
</template> |
||||
<template #title> |
||||
<span data-rec="true"> |
||||
{{ $t('labels.setup') }} |
||||
</span> |
||||
</template> |
||||
</NcPageHeader> |
||||
|
||||
<div |
||||
class="nc-content-max-w flex-1 max-h-[calc(100vh_-_100px)] overflow-y-auto nc-scrollbar-thin flex flex-col items-center gap-6 p-6" |
||||
> |
||||
<div class="flex flex-col gap-6 w-150"> |
||||
<div |
||||
v-for="config of configs" |
||||
class="flex flex-col border-1 rounded-2xl border-gray-200 p-6 gap-2 hover:(shadow bg-gray-10)" |
||||
:class="{ |
||||
'cursor-pointer': config.itemClick, |
||||
}" |
||||
:data-testid="`nc-setup-${config.key}`" |
||||
@click="config.itemClick" |
||||
> |
||||
<div class="flex gap-3 items-center" data-rec="true"> |
||||
<NcTooltip v-if="!config.configured || config.isPending"> |
||||
<template #title> |
||||
<span> |
||||
{{ $t('activity.pending') }} |
||||
</span> |
||||
</template> |
||||
<GeneralIcon icon="ncAlertCircle" class="text-orange-500 -mt-1 w-6 h-6 nc-pending" /> |
||||
</NcTooltip> |
||||
<GeneralIcon v-else icon="circleCheckSolid" class="text-success w-6 h-6 bg-white-500 nc-configured" /> |
||||
|
||||
<span class="font-bold text-base"> {{ config.title }}</span> |
||||
</div> |
||||
<div class="text-gray-600 text-sm">{{ config.description }}</div> |
||||
|
||||
<div class="flex justify-between mt-4"> |
||||
<NcButton |
||||
size="small" |
||||
type="text" |
||||
:href="config.docsLink" |
||||
target="_blank" |
||||
class="!flex items-center !no-underline" |
||||
rel="noopener noreferer" |
||||
@click.stop |
||||
> |
||||
<div class="flex gap-2 items-center"> |
||||
Go to docs |
||||
<GeneralIcon icon="ncExternalLink" /> |
||||
</div> |
||||
</NcButton> |
||||
<NcButton v-if="config.configured" size="small" type="text" @click.stop="config.buttonClick"> |
||||
<div class="flex gap-2 items-center"> |
||||
<GeneralIcon icon="ncEdit3" /> |
||||
{{ $t('general.edit') }} |
||||
</div> |
||||
</NcButton> |
||||
<NcButton v-else size="small" @click.stop="config.buttonClick">{{ $t('general.configure') }}</NcButton> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
|
||||
<LazyAccountSetupListModal v-if="openedCategory" v-model="listModalDlg" :category="openedCategory" /> |
||||
</div> |
||||
</template> |
||||
|
||||
<style scoped lang="scss"></style> |
@ -0,0 +1,13 @@
|
||||
<script setup lang="ts"> |
||||
defineProps<{ |
||||
app: { |
||||
title: string |
||||
logo: string |
||||
} |
||||
}>() |
||||
</script> |
||||
|
||||
<template> |
||||
<img v-if="app.title !== 'SMTP'" class="object-contain" :alt="app.title" :src="app.logo" /> |
||||
<GeneralIcon v-else class="text-gray-500" icon="mail" /> |
||||
</template> |
@ -0,0 +1,175 @@
|
||||
<script setup lang="ts"> |
||||
import dayjs from 'dayjs' |
||||
|
||||
const props = defineProps<{ |
||||
id: string |
||||
modelValue?: boolean |
||||
}>() |
||||
const emit = defineEmits(['saved', 'close', 'update:modelValue']) |
||||
|
||||
const vOpen = useVModel(props, 'modelValue', emit) |
||||
|
||||
const { |
||||
readPluginDetails, |
||||
activePluginFormData: pluginFormData, |
||||
activePlugin: plugin, |
||||
isLoading, |
||||
loadingAction, |
||||
testSettings, |
||||
saveSettings, |
||||
} = useAccountSetupStoreOrThrow() |
||||
|
||||
await readPluginDetails(props.id) |
||||
|
||||
const pluginTypeMap = { |
||||
Input: FormBuilderInputType.Input, |
||||
Select: FormBuilderInputType.Select, |
||||
Checkbox: FormBuilderInputType.Switch, |
||||
LongText: FormBuilderInputType.Input, |
||||
Password: FormBuilderInputType.Password, |
||||
} |
||||
|
||||
const { formState, validate, validateInfos } = useProvideFormBuilderHelper({ |
||||
formSchema: [ |
||||
...plugin.value.formDetails.items.flatMap((item, i) => [ |
||||
{ |
||||
type: pluginTypeMap[item.type] || FormBuilderInputType.Input, |
||||
label: item.label, |
||||
placeholder: item.placeholder, |
||||
model: item.key, |
||||
required: item.required, |
||||
helpText: item.help_text, |
||||
width: '48', |
||||
border: false, |
||||
showHintAsTooltip: true, |
||||
}, |
||||
...(i % 2 |
||||
? [] |
||||
: [ |
||||
{ |
||||
type: FormBuilderInputType.Space, |
||||
width: '4', |
||||
}, |
||||
]), |
||||
]), |
||||
], |
||||
initialState: pluginFormData, |
||||
}) |
||||
|
||||
const doAction = async (action: Action) => { |
||||
try { |
||||
switch (action) { |
||||
case Action.Save: |
||||
await validate() |
||||
pluginFormData.value = formState.value |
||||
await saveSettings() |
||||
vOpen.value = false |
||||
break |
||||
case Action.Test: |
||||
await validate() |
||||
pluginFormData.value = formState.value |
||||
await testSettings() |
||||
break |
||||
} |
||||
} catch (e: any) { |
||||
console.log(e) |
||||
} finally { |
||||
loadingAction.value = null |
||||
} |
||||
} |
||||
|
||||
const isValid = computed(() => { |
||||
return Object.values(validateInfos || {}).every((info) => info.validateStatus !== 'error') |
||||
}) |
||||
|
||||
const docLinks = computed(() => { |
||||
return [ |
||||
{ |
||||
title: 'Application Setup', |
||||
url: `https://docs.nocodb.com/account-settings/oss-specific-details#configure-${plugin.value?.category?.toLowerCase()}`, |
||||
}, |
||||
...(plugin.value?.formDetails?.docs || []), |
||||
] |
||||
}) |
||||
</script> |
||||
|
||||
<template> |
||||
<div class="flex flex-col h-full h-[calc(100vh_-_40px)]" data-testid="nc-setup-config"> |
||||
<NcPageHeader> |
||||
<template #title> |
||||
<div class="flex gap-3 items-center"> |
||||
<AccountSetupAppIcon :app="plugin" class="h-8 w-8" /> |
||||
|
||||
<span data-rec="true"> |
||||
{{ plugin.title }} |
||||
</span> |
||||
</div> |
||||
</template> |
||||
</NcPageHeader> |
||||
<div class="h-full flex h-[calc(100%_-_48px)]"> |
||||
<div class="nc-config-left-panel nc-scrollbar-thin relative h-full flex flex-col"> |
||||
<div class="w-full flex items-center gap-3 border-gray-200 py-6 px-6"> |
||||
<span class="font-semibold text-base">{{ $t('labels.configuration') }}</span> |
||||
<div class="flex-grow" /> |
||||
|
||||
<div class="flex gap-2"> |
||||
<NcButton |
||||
v-for="(action, i) in plugin.formDetails.actions" |
||||
:key="i" |
||||
:loading="loadingAction === action.key" |
||||
:type="action.key === Action.Save ? 'primary' : 'default'" |
||||
size="small" |
||||
:disabled="!!loadingAction || !isValid" |
||||
:data-testid="`nc-setup-config-action-${action.key?.toLowerCase()}`" |
||||
@click="doAction(action.key)" |
||||
> |
||||
{{ action.label }} |
||||
</NcButton> |
||||
</div> |
||||
</div> |
||||
<div class="h-[calc(100%_-_48px)] flex py-4 flex-col p-6 overflow-auto"> |
||||
<div v-if="isLoading || !plugin" class="flex flex-row w-full justify-center items-center h-52"> |
||||
<a-spin size="large" /> |
||||
</div> |
||||
|
||||
<div v-else class="flex"> |
||||
<NcFormBuilder class="w-229 px-2 mx-auto" /> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
<div class="nc-config-right-panel"> |
||||
<div class="flex-grow flex flex-col gap-3"> |
||||
<div class="text-gray-500 text-capitalize">{{ $t('labels.documentation') }}</div> |
||||
<a |
||||
v-for="doc of docLinks" |
||||
:key="doc.title" |
||||
:href="doc.url" |
||||
target="_blank" |
||||
rel="noopener noreferrer" |
||||
class="!no-underline !text-current flex gap-2 items-center" |
||||
> |
||||
<GeneralIcon icon="bookOpen" class="text-gray-500" /> |
||||
{{ doc.title }} |
||||
</a> |
||||
|
||||
<NcDivider /> |
||||
|
||||
<div class="text-gray-500 text-capitalize">{{ $t('labels.modifiedOn') }}</div> |
||||
<div class=""> |
||||
{{ dayjs(plugin.created_at).format('DD MMM YYYY HH:mm') }} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</template> |
||||
|
||||
<style lang="scss" scoped> |
||||
.nc-config-left-panel { |
||||
@apply w-full flex-1 flex justify-stretch; |
||||
} |
||||
|
||||
.nc-config-right-panel { |
||||
@apply p-5 w-[320px] border-l-1 border-gray-200 flex flex-col gap-4 bg-gray-50 rounded-br-2xl; |
||||
} |
||||
</style> |
@ -0,0 +1,149 @@
|
||||
<script setup lang="ts"> |
||||
const props = defineProps<{ |
||||
category: string |
||||
}>() |
||||
|
||||
const { categorizeApps, resetPlugin: _resetPlugin, showPluginUninstallModal, activePlugin } = useAccountSetupStoreOrThrow() |
||||
|
||||
const apps = computed(() => categorizeApps.value?.[props.category?.toLowerCase()] || []) |
||||
const configuredApp = computed(() => apps.value.find((app: any) => app.active)) |
||||
|
||||
const showResetActiveAppMsg = ref(false) |
||||
const switchingTo = ref(null) |
||||
|
||||
const showResetPluginModal = async (app: any, resetActiveAppMsg = false) => { |
||||
showResetActiveAppMsg.value = resetActiveAppMsg |
||||
showPluginUninstallModal.value = true |
||||
activePlugin.value = app |
||||
} |
||||
|
||||
const selectApp = (app: any) => { |
||||
const activeApp = app !== configuredApp.value && configuredApp.value |
||||
if (activeApp) { |
||||
switchingTo.value = app |
||||
return showResetPluginModal(activeApp, true) |
||||
} |
||||
|
||||
navigateTo(`/account/setup/${props.category}/${app.title}`) |
||||
} |
||||
|
||||
const resetPlugin = async () => { |
||||
await _resetPlugin(activePlugin.value) |
||||
if (showResetActiveAppMsg.value) { |
||||
await selectApp(switchingTo.value) |
||||
switchingTo.value = null |
||||
showResetActiveAppMsg.value = false |
||||
} |
||||
} |
||||
|
||||
const closeResetModal = () => { |
||||
activePlugin.value = null |
||||
switchingTo.value = null |
||||
showResetActiveAppMsg.value = false |
||||
showPluginUninstallModal.value = false |
||||
} |
||||
</script> |
||||
|
||||
<template> |
||||
<div class="flex flex-col" data-testid="nc-setup-list"> |
||||
<NcPageHeader> |
||||
<template #title> |
||||
<span data-rec="true"> |
||||
{{ category }} |
||||
</span> |
||||
</template> |
||||
</NcPageHeader> |
||||
<div class="h-[calc(100%_-_58px)] flex"> |
||||
<div class="w-full"> |
||||
<div class="w-950px px-4 mt-3 mx-auto text-lg font-weight-bold">{{ category }} Services</div> |
||||
<div class="container"> |
||||
<div |
||||
v-for="app in apps" |
||||
:key="app.title" |
||||
class="item group" |
||||
:data-testid="`nc-setup-list-item-${app.title}`" |
||||
@click="selectApp(app)" |
||||
> |
||||
<AccountSetupAppIcon :app="app" class="icon" /> |
||||
<span class="title">{{ app.title }}</span> |
||||
<div class="flex-grow" /> |
||||
|
||||
<GeneralIcon |
||||
v-if="app.active" |
||||
icon="delete" |
||||
class="text-error min-w-6 h-6 bg-white-500 !hidden !group-hover:!inline cursor-pointer" |
||||
/> |
||||
<GeneralIcon |
||||
v-if="app === configuredApp" |
||||
icon="circleCheckSolid" |
||||
class="text-success min-w-5 h-5 bg-white-500 nc-configured" |
||||
/> |
||||
|
||||
<NcDropdown :trigger="['click']" overlay-class-name="!rounded-md" @click.stop> |
||||
<GeneralIcon |
||||
v-if="app.active" |
||||
icon="threeDotVertical" |
||||
class="min-w-5 h-5 bg-white-500 text-gray-500 hover:text-current nc-setup-plugin-menu" |
||||
/> |
||||
|
||||
<template #overlay> |
||||
<NcMenu class="min-w-20"> |
||||
<NcMenuItem data-testid="nc-config-reset" @click.stop="showResetPluginModal(app)"> |
||||
<span> {{ $t('general.reset') }} </span> |
||||
</NcMenuItem> |
||||
</NcMenu> |
||||
</template> |
||||
</NcDropdown> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
|
||||
<a-modal |
||||
v-model:visible="showPluginUninstallModal" |
||||
:closable="false" |
||||
width="448px" |
||||
centered |
||||
:footer="null" |
||||
wrap-class-name="nc-modal-plugin-reset-conform" |
||||
> |
||||
<div class="flex flex-col h-full"> |
||||
<div v-if="showResetActiveAppMsg" class="text-base font-weight-bold"> |
||||
Switch to {{ switchingTo && switchingTo.title }} |
||||
</div> |
||||
<div v-else class="text-base font-weight-bold">Reset {{ activePlugin && activePlugin.title }} Configuration</div> |
||||
<div class="flex flex-row mt-2 w-full"> |
||||
<template v-if="showResetActiveAppMsg"> |
||||
Switching to {{ switchingTo && switchingTo.title }} will reset your {{ activePlugin && activePlugin.title }} |
||||
settings. Continue? |
||||
</template> |
||||
<template v-else>Resetting will erase your current configuration.</template> |
||||
</div> |
||||
<div class="flex mt-6 justify-end space-x-2"> |
||||
<NcButton size="small" type="secondary" @click="closeResetModal"> {{ $t('general.cancel') }}</NcButton> |
||||
<NcButton size="small" type="danger" data-testid="nc-reset-confirm-btn" @click="resetPlugin"> |
||||
{{ showResetActiveAppMsg ? `${$t('general.reset')} & ${$t('general.switch')}` : $t('general.reset') }} |
||||
</NcButton> |
||||
</div> |
||||
</div> |
||||
</a-modal> |
||||
</div> |
||||
</template> |
||||
|
||||
<style scoped lang="scss"> |
||||
.container { |
||||
@apply p-4 w-950px gap-5 mx-auto my-2 grid grid-cols-3; |
||||
|
||||
.item { |
||||
@apply text-base w-296px max-w-296px flex gap-3 border-1 border-gray-200 py-4 px-5 rounded-xl items-center cursor-pointer hover:(shadow bg-gray-50); |
||||
|
||||
.icon { |
||||
@apply !w-8 !h-8 object-contain; |
||||
} |
||||
|
||||
.title { |
||||
@apply font-weight-bold; |
||||
} |
||||
} |
||||
} |
||||
</style> |
@ -0,0 +1,182 @@
|
||||
<script setup lang="ts"> |
||||
import type { TableType } from 'nocodb-sdk' |
||||
import type { ComponentPublicInstance } from '@vue/runtime-core' |
||||
|
||||
interface Props { |
||||
modelValue?: boolean |
||||
tableMeta: TableType |
||||
sourceId: string |
||||
} |
||||
|
||||
const { tableMeta, ...props } = defineProps<Props>() |
||||
|
||||
const emit = defineEmits(['update:modelValue', 'updated']) |
||||
|
||||
const { $e, $api } = useNuxtApp() |
||||
|
||||
const { setMeta } = useMetas() |
||||
|
||||
const dialogShow = useVModel(props, 'modelValue', emit) |
||||
|
||||
const { loadProjectTables } = useTablesStore() |
||||
|
||||
const baseStore = useBase() |
||||
|
||||
const { loadTables } = baseStore |
||||
|
||||
const { addUndo, defineProjectScope } = useUndoRedo() |
||||
|
||||
const inputEl = ref<HTMLTextAreaElement>() |
||||
|
||||
const loading = ref(false) |
||||
|
||||
const useForm = Form.useForm |
||||
|
||||
const formState = reactive({ |
||||
description: '', |
||||
}) |
||||
|
||||
const validators = computed(() => { |
||||
return { |
||||
description: [ |
||||
{ |
||||
validator: (_: any, _value: any) => { |
||||
return new Promise<void>((resolve, _reject) => { |
||||
resolve() |
||||
}) |
||||
}, |
||||
}, |
||||
], |
||||
} |
||||
}) |
||||
|
||||
const { validateInfos } = useForm(formState, validators) |
||||
|
||||
watchEffect( |
||||
() => { |
||||
if (tableMeta?.description) formState.description = `${tableMeta.description}` |
||||
|
||||
nextTick(() => { |
||||
const input = inputEl.value?.$el as HTMLInputElement |
||||
|
||||
if (input) { |
||||
input.setSelectionRange(0, formState.description.length) |
||||
input.focus() |
||||
} |
||||
}) |
||||
}, |
||||
{ flush: 'post' }, |
||||
) |
||||
|
||||
const updateDescription = async (undo = false) => { |
||||
if (!tableMeta) return |
||||
|
||||
if (formState.description) { |
||||
formState.description = formState.description.trim() |
||||
} |
||||
|
||||
loading.value = true |
||||
try { |
||||
await $api.dbTable.update(tableMeta.id as string, { |
||||
base_id: tableMeta.base_id, |
||||
description: formState.description, |
||||
}) |
||||
|
||||
dialogShow.value = false |
||||
|
||||
await loadProjectTables(tableMeta.base_id!, true) |
||||
|
||||
if (!undo) { |
||||
addUndo({ |
||||
redo: { |
||||
fn: (t: string) => { |
||||
formState.description = t |
||||
updateDescription(true, true) |
||||
}, |
||||
args: [formState.description], |
||||
}, |
||||
undo: { |
||||
fn: (t: string) => { |
||||
formState.description = t |
||||
updateDescription(true, true) |
||||
}, |
||||
args: [tableMeta.description], |
||||
}, |
||||
scope: defineProjectScope({ model: tableMeta }), |
||||
}) |
||||
} |
||||
|
||||
await loadTables() |
||||
|
||||
// update metas |
||||
const newMeta = await $api.dbTable.read(tableMeta.id as string) |
||||
await setMeta(newMeta) |
||||
|
||||
$e('a:table:description:update') |
||||
|
||||
dialogShow.value = false |
||||
} catch (e: any) { |
||||
message.error(await extractSdkResponseErrorMsg(e)) |
||||
} |
||||
|
||||
loading.value = false |
||||
} |
||||
</script> |
||||
|
||||
<template> |
||||
<NcModal v-model:visible="dialogShow" size="small" :show-separator="false"> |
||||
<template #header> |
||||
<div class="flex flex-row items-center gap-x-2"> |
||||
<GeneralIcon icon="table" class="w-6 h-6 text-gray-700" /> |
||||
<span class="text-gray-900 font-bold"> |
||||
{{ tableMeta?.title ?? tableMeta?.table_name }} |
||||
</span> |
||||
</div> |
||||
</template> |
||||
<div class="mt-1"> |
||||
<a-form layout="vertical" :model="formState" name="create-new-table-form"> |
||||
<a-form-item :label="$t('labels.description')" v-bind="validateInfos.description"> |
||||
<a-textarea |
||||
ref="inputEl" |
||||
v-model:value="formState.description" |
||||
class="nc-input-sm !py-2 nc-text-area nc-input-shadow" |
||||
hide-details |
||||
size="small" |
||||
:placeholder="$t('msg.info.enterTableDescription')" |
||||
@keydown.enter.exact="() => updateDescription()" |
||||
/> |
||||
</a-form-item> |
||||
</a-form> |
||||
<div class="flex flex-row justify-end gap-x-2 mt-5"> |
||||
<NcButton type="secondary" size="small" @click="dialogShow = false">{{ $t('general.cancel') }}</NcButton> |
||||
|
||||
<NcButton |
||||
key="submit" |
||||
type="primary" |
||||
size="small" |
||||
:disabled=" |
||||
validateInfos?.description?.validateStatus === 'error' || formState.description?.trim() === tableMeta?.description |
||||
" |
||||
:loading="loading" |
||||
@click="() => updateDescription()" |
||||
> |
||||
{{ $t('general.save') }} |
||||
</NcButton> |
||||
</div> |
||||
</div> |
||||
</NcModal> |
||||
</template> |
||||
|
||||
<style scoped lang="scss"> |
||||
.nc-text-area { |
||||
@apply !py-2 min-h-[120px] max-h-[200px]; |
||||
} |
||||
|
||||
:deep(.ant-form-item-label > label) { |
||||
@apply !text-md font-base !leading-[20px] text-gray-800 flex; |
||||
|
||||
&.ant-form-item-required:not(.ant-form-item-required-mark-optional)::before { |
||||
@apply content-[''] m-0; |
||||
} |
||||
} |
||||
</style> |
@ -0,0 +1,170 @@
|
||||
<script setup lang="ts"> |
||||
import type { ViewType } from 'nocodb-sdk' |
||||
import type { ComponentPublicInstance } from '@vue/runtime-core' |
||||
|
||||
interface Props { |
||||
modelValue?: boolean |
||||
view: ViewType |
||||
sourceId?: string |
||||
} |
||||
|
||||
const { view, ...props } = defineProps<Props>() |
||||
|
||||
const emit = defineEmits(['update:modelValue', 'updated']) |
||||
|
||||
const { $e, $api } = useNuxtApp() |
||||
|
||||
const dialogShow = useVModel(props, 'modelValue', emit) |
||||
|
||||
const { loadViews } = useViewsStore() |
||||
|
||||
const { addUndo, defineProjectScope } = useUndoRedo() |
||||
|
||||
const inputEl = ref<ComponentPublicInstance>() |
||||
|
||||
const loading = ref(false) |
||||
|
||||
const useForm = Form.useForm |
||||
|
||||
const formState = reactive({ |
||||
description: '', |
||||
}) |
||||
|
||||
const validators = computed(() => { |
||||
return { |
||||
description: [ |
||||
{ |
||||
validator: (_: any, _value: any) => { |
||||
return new Promise<void>((resolve, _reject) => { |
||||
resolve() |
||||
}) |
||||
}, |
||||
}, |
||||
], |
||||
} |
||||
}) |
||||
|
||||
const { validateInfos } = useForm(formState, validators) |
||||
|
||||
watchEffect( |
||||
() => { |
||||
if (view?.description) formState.description = `${view.description}` |
||||
|
||||
nextTick(() => { |
||||
const input = inputEl.value?.$el as HTMLInputElement |
||||
|
||||
if (input) { |
||||
input.setSelectionRange(0, formState.description.length) |
||||
input.focus() |
||||
} |
||||
}) |
||||
}, |
||||
{ flush: 'post' }, |
||||
) |
||||
|
||||
const updateDescription = async (undo = false) => { |
||||
if (!view) return |
||||
|
||||
if (formState.description) { |
||||
formState.description = formState.description.trim() |
||||
} |
||||
|
||||
loading.value = true |
||||
try { |
||||
await $api.dbView.update(view.id as string, { |
||||
description: formState.description, |
||||
}) |
||||
|
||||
dialogShow.value = false |
||||
|
||||
if (!undo) { |
||||
addUndo({ |
||||
redo: { |
||||
fn: (t: string) => { |
||||
formState.description = t |
||||
updateDescription(true, true) |
||||
}, |
||||
args: [formState.description], |
||||
}, |
||||
undo: { |
||||
fn: (t: string) => { |
||||
formState.description = t |
||||
updateDescription(true, true) |
||||
}, |
||||
args: [view.description], |
||||
}, |
||||
scope: defineProjectScope({ view }), |
||||
}) |
||||
} |
||||
|
||||
await loadViews({ tableId: view.fk_model_id, ignoreLoading: true, force: true }) |
||||
|
||||
$e('a:view:description:update') |
||||
|
||||
dialogShow.value = false |
||||
} catch (e: any) { |
||||
message.error(await extractSdkResponseErrorMsg(e)) |
||||
} |
||||
|
||||
loading.value = false |
||||
} |
||||
</script> |
||||
|
||||
<template> |
||||
<NcModal v-model:visible="dialogShow" size="small" :show-separator="false"> |
||||
<template #header> |
||||
<div class="flex flex-row items-center gap-x-2"> |
||||
<GeneralViewIcon :meta="view" class="mt-0.5 !text-2xl" /> |
||||
|
||||
<span class="text-gray-900 font-semibold"> |
||||
{{ view?.title }} |
||||
</span> |
||||
</div> |
||||
</template> |
||||
<div class="mt-1"> |
||||
<a-form layout="vertical" :model="formState" name="create-new-table-form"> |
||||
<a-form-item :label="$t('labels.description')" v-bind="validateInfos.description"> |
||||
<a-textarea |
||||
ref="inputEl" |
||||
v-model:value="formState.description" |
||||
class="nc-input-sm !py-2 nc-text-area !text-gray-800 nc-input-shadow" |
||||
hide-details |
||||
size="small" |
||||
:placeholder="$t('msg.info.enterTableDescription')" |
||||
@keydown.enter.exact="() => updateDescription()" |
||||
/> |
||||
</a-form-item> |
||||
</a-form> |
||||
<div class="flex flex-row justify-end gap-x-2 mt-5"> |
||||
<NcButton type="secondary" size="small" @click="dialogShow = false">{{ $t('general.cancel') }}</NcButton> |
||||
|
||||
<NcButton |
||||
key="submit" |
||||
type="primary" |
||||
size="small" |
||||
:disabled=" |
||||
validateInfos?.description?.validateStatus === 'error' || formState.description?.trim() === view?.description |
||||
" |
||||
:loading="loading" |
||||
@click="() => updateDescription()" |
||||
> |
||||
{{ $t('general.save') }} |
||||
</NcButton> |
||||
</div> |
||||
</div> |
||||
</NcModal> |
||||
</template> |
||||
|
||||
<style scoped lang="scss"> |
||||
.nc-text-area { |
||||
@apply !py-2 min-h-[120px] max-h-[200px]; |
||||
} |
||||
|
||||
:deep(.ant-form-item-label > label) { |
||||
@apply !leading-[20px] font-base !text-md text-gray-800 flex; |
||||
|
||||
&.ant-form-item-required:not(.ant-form-item-required-mark-optional)::before { |
||||
@apply content-[''] m-0; |
||||
} |
||||
} |
||||
</style> |
@ -0,0 +1,27 @@
|
||||
<script lang="ts" setup> |
||||
const props = withDefaults( |
||||
defineProps<{ |
||||
type: keyof typeof allIntegrationsMapByValue |
||||
size: 'sx' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl' |
||||
}>(), |
||||
{ |
||||
size: 'sm', |
||||
}, |
||||
) |
||||
</script> |
||||
|
||||
<template> |
||||
<component |
||||
:is="allIntegrationsMapByValue[props.type]?.icon" |
||||
v-if="allIntegrationsMapByValue[props.type]?.icon" |
||||
class="stroke-transparent flex-none" |
||||
:class="{ |
||||
'w-3.5 h-3.5': size === 'sx', |
||||
'w-4 h-4': size === 'sm', |
||||
'w-5 h-5': size === 'md', |
||||
'w-6 h-6': size === 'lg', |
||||
'w-7 h-7': size === 'xl', |
||||
'w-8 h-8': size === 'xxl', |
||||
}" |
||||
/> |
||||
</template> |
@ -0,0 +1,81 @@
|
||||
<script lang="ts" setup> |
||||
const initState = ref({ |
||||
someDefaultProp: 'value', |
||||
}) |
||||
|
||||
const { formState, isLoading, submit } = useProvideFormBuilderHelper({ |
||||
formSchema: [ |
||||
{ |
||||
type: FormBuilderInputType.Input, |
||||
label: 'Sample Input', |
||||
width: 100, |
||||
model: 'title', |
||||
placeholder: 'Some placeholder', |
||||
category: 'General', |
||||
required: true, |
||||
}, |
||||
{ |
||||
type: FormBuilderInputType.Input, |
||||
label: 'Input To Nested Path', |
||||
width: 50, |
||||
model: 'config.sample', |
||||
placeholder: 'This is added to config.sample', |
||||
category: 'Sample Category', |
||||
helpText: 'This is a sample help text', |
||||
required: true, |
||||
}, |
||||
{ |
||||
type: FormBuilderInputType.Space, |
||||
width: 50, |
||||
category: 'Sample Category', |
||||
}, |
||||
{ |
||||
type: FormBuilderInputType.Input, |
||||
label: 'Multiple Elements in Category', |
||||
width: 50, |
||||
model: 'config.sample2', |
||||
placeholder: 'This is added to config.sample2', |
||||
category: 'Sample Category', |
||||
required: false, |
||||
}, |
||||
{ |
||||
type: FormBuilderInputType.Select, |
||||
label: 'Sample Select', |
||||
width: 100, |
||||
model: 'config.select', |
||||
category: 'Settings', |
||||
options: [ |
||||
{ label: 'Option 1', value: 'option1' }, |
||||
{ label: 'Option 2', value: 'option2' }, |
||||
{ label: 'Option 3', value: 'option3' }, |
||||
], |
||||
defaultValue: 'option2', |
||||
required: true, |
||||
}, |
||||
{ |
||||
type: FormBuilderInputType.Switch, |
||||
label: 'Sample Switch', |
||||
width: 100, |
||||
model: 'config.switch', |
||||
category: 'Misc', |
||||
helpText: 'This is a sample switch', |
||||
required: false, |
||||
border: true, |
||||
}, |
||||
], |
||||
onSubmit: async () => { |
||||
console.log('submit', formState) |
||||
}, |
||||
initialState: initState, |
||||
}) |
||||
</script> |
||||
|
||||
<template> |
||||
<div class="h-full"> |
||||
<NcFormBuilder /> |
||||
<div class="mt-10"></div> |
||||
<NcButton :loading="isLoading" type="primary" @click="submit">Submit</NcButton> |
||||
</div> |
||||
</template> |
||||
|
||||
<style lang="scss" scoped></style> |
@ -0,0 +1,265 @@
|
||||
<script lang="ts" setup> |
||||
const { form, formState, formElementsCategorized, isLoading, validateInfos } = useFormBuilderHelperOrThrow() |
||||
|
||||
const deepReference = (path: string): any => { |
||||
return path.split('.').reduce((acc, key) => acc[key], formState.value) |
||||
} |
||||
|
||||
const setFormState = (path: string, value: any) => { |
||||
// update nested prop in formState |
||||
const keys = path.split('.') |
||||
const lastKey = keys.pop() |
||||
|
||||
if (!lastKey) return |
||||
|
||||
const target = keys.reduce((acc, key) => { |
||||
if (!acc[key]) { |
||||
acc[key] = {} |
||||
} |
||||
return acc[key] |
||||
}, formState.value) |
||||
target[lastKey] = value |
||||
} |
||||
</script> |
||||
|
||||
<template> |
||||
<div class="nc-form-builder nc-scrollbar-thin relative"> |
||||
<a-form ref="form" :model="formState" hide-required-mark layout="vertical" class="flex flex-col gap-4"> |
||||
<template v-for="category in Object.keys(formElementsCategorized)" :key="category"> |
||||
<div class="nc-form-section"> |
||||
<div v-if="category !== FORM_BUILDER_NON_CATEGORIZED" class="nc-form-section-title">{{ category }}</div> |
||||
<div class="nc-form-section-body"> |
||||
<div class="flex flex-wrap"> |
||||
<template v-for="field in formElementsCategorized[category]" :key="field.model"> |
||||
<div |
||||
v-if="field.type === FormBuilderInputType.Space" |
||||
:style="`width:${+field.width || 100}%`" |
||||
class="w-full" |
||||
></div> |
||||
<a-form-item |
||||
v-else |
||||
v-bind="validateInfos[field.model]" |
||||
class="nc-form-item" |
||||
:style="`width:${+field.width || 100}%`" |
||||
:required="false" |
||||
:data-testid="`nc-form-input-${field.model}`" |
||||
> |
||||
<template v-if="![FormBuilderInputType.Switch].includes(field.type)" #label> |
||||
<div class="flex items-center gap-1"> |
||||
<span>{{ field.label }}</span> |
||||
<span v-if="field.required" class="text-red-500">*</span> |
||||
<NcTooltip v-if="field.helpText && field.showHintAsTooltip"> |
||||
<template #title> |
||||
<div class="text-xs"> |
||||
{{ field.helpText }} |
||||
</div> |
||||
</template> |
||||
<GeneralIcon icon="info" class="text-gray-500 h-4" /> |
||||
</NcTooltip> |
||||
</div> |
||||
</template> |
||||
<template v-if="field.type === FormBuilderInputType.Input"> |
||||
<a-input |
||||
autocomplete="off" |
||||
class="!w-full" |
||||
:value="deepReference(field.model)" |
||||
@update:value="setFormState(field.model, $event)" |
||||
/> |
||||
</template> |
||||
<template v-else-if="field.type === FormBuilderInputType.Password"> |
||||
<a-input-password |
||||
readonly |
||||
onfocus="this.removeAttribute('readonly');" |
||||
onblur="this.setAttribute('readonly', true);" |
||||
autocomplete="off" |
||||
:value="deepReference(field.model)" |
||||
@update:value="setFormState(field.model, $event)" |
||||
/> |
||||
</template> |
||||
<template v-else-if="field.type === FormBuilderInputType.Select"> |
||||
<NcSelect |
||||
:value="deepReference(field.model)" |
||||
:options="field.options" |
||||
@update:value="setFormState(field.model, $event)" |
||||
/> |
||||
</template> |
||||
<template v-else-if="field.type === FormBuilderInputType.Switch"> |
||||
<div class="flex flex-col p-2" :class="field.border ? 'border-1 rounded-lg shadow' : ''"> |
||||
<div class="flex items-center"> |
||||
<NcSwitch :checked="!!deepReference(field.model)" @update:checked="setFormState(field.model, $event)" /> |
||||
<span class="ml-[6px] font-bold">{{ field.label }}</span> |
||||
<NcTooltip v-if="field.helpText"> |
||||
<template #title> |
||||
<div class="text-xs"> |
||||
{{ field.helpText }} |
||||
</div> |
||||
</template> |
||||
<GeneralIcon icon="info" class="text-gray-500 h-4 ml-1" /> |
||||
</NcTooltip> |
||||
</div> |
||||
<div v-if="field.helpText && !field.showHintAsTooltip" class="w-full mt-1 pl-[35px]"> |
||||
<div class="text-xs text-gray-500">{{ field.helpText }}</div> |
||||
</div> |
||||
</div> |
||||
</template> |
||||
<div |
||||
v-if="field.helpText && field.type !== FormBuilderInputType.Switch && !field.showHintAsTooltip" |
||||
class="w-full mt-1" |
||||
> |
||||
<div class="text-xs text-gray-500">{{ field.helpText }}</div> |
||||
</div> |
||||
</a-form-item> |
||||
</template> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</template> |
||||
</a-form> |
||||
<general-overlay :model-value="isLoading" inline transition class="!bg-opacity-15"> |
||||
<div class="flex items-center justify-center h-full w-full !bg-white !bg-opacity-85 z-1000"> |
||||
<a-spin size="large" /> |
||||
</div> |
||||
</general-overlay> |
||||
</div> |
||||
</template> |
||||
|
||||
<style lang="scss" scoped> |
||||
.nc-form-item { |
||||
margin-bottom: 12px; |
||||
} |
||||
|
||||
:deep(.ant-collapse-header) { |
||||
@apply !-mt-4 !p-0 flex items-center !cursor-default children:first:flex; |
||||
} |
||||
|
||||
:deep(.ant-collapse-icon-position-right > .ant-collapse-item > .ant-collapse-header .ant-collapse-arrow) { |
||||
@apply !right-0; |
||||
} |
||||
|
||||
:deep(.ant-collapse-content-box) { |
||||
@apply !px-0 !pb-0 !pt-3; |
||||
} |
||||
|
||||
:deep(.ant-form-item-explain-error) { |
||||
@apply !text-xs; |
||||
} |
||||
|
||||
:deep(.ant-divider) { |
||||
@apply m-0; |
||||
} |
||||
|
||||
:deep(.ant-form-item-with-help .ant-form-item-explain) { |
||||
@apply !min-h-0; |
||||
} |
||||
|
||||
:deep(.ant-select .ant-select-selector .ant-select-selection-item) { |
||||
@apply font-weight-400; |
||||
} |
||||
|
||||
.nc-form-builder { |
||||
:deep(.ant-input-affix-wrapper), |
||||
:deep(.ant-input), |
||||
:deep(.ant-select) { |
||||
@apply !appearance-none border-solid rounded-md; |
||||
} |
||||
|
||||
:deep(.ant-input-password) { |
||||
input { |
||||
@apply !border-none my-0; |
||||
} |
||||
} |
||||
|
||||
.nc-form-section { |
||||
@apply flex flex-col gap-3; |
||||
} |
||||
|
||||
.nc-form-section-title { |
||||
@apply text-sm font-bold text-gray-800; |
||||
} |
||||
|
||||
.nc-form-section-body { |
||||
@apply flex flex-col gap-3; |
||||
} |
||||
|
||||
:deep(.ant-form-item-label > label.ant-form-item-required:after) { |
||||
@apply content-['*'] inline-block text-inherit text-red-500 ml-1; |
||||
} |
||||
|
||||
:deep(.ant-form-item) { |
||||
&.ant-form-item-has-error { |
||||
&:not(:has(.ant-input-password)) .ant-input { |
||||
&:not(:hover):not(:focus):not(:disabled) { |
||||
@apply shadow-default; |
||||
} |
||||
|
||||
&:hover:not(:focus):not(:disabled) { |
||||
@apply shadow-hover; |
||||
} |
||||
|
||||
&:focus { |
||||
@apply shadow-error ring-0; |
||||
} |
||||
} |
||||
|
||||
.ant-input-number, |
||||
.ant-input-affix-wrapper.ant-input-password { |
||||
&:not(:hover):not(:focus-within):not(:disabled) { |
||||
@apply shadow-default; |
||||
} |
||||
|
||||
&:hover:not(:focus-within):not(:disabled) { |
||||
@apply shadow-hover; |
||||
} |
||||
|
||||
&:focus-within { |
||||
@apply shadow-error ring-0; |
||||
} |
||||
} |
||||
} |
||||
|
||||
&:not(.ant-form-item-has-error) { |
||||
&:not(:has(.ant-input-password)) .ant-input { |
||||
&:not(:hover):not(:focus):not(:disabled) { |
||||
@apply shadow-default border-gray-200; |
||||
} |
||||
|
||||
&:hover:not(:focus):not(:disabled) { |
||||
@apply border-gray-200 shadow-hover; |
||||
} |
||||
|
||||
&:focus { |
||||
@apply shadow-selected ring-0; |
||||
} |
||||
} |
||||
|
||||
.ant-input-number, |
||||
.ant-input-affix-wrapper.ant-input-password { |
||||
&:not(:hover):not(:focus-within):not(:disabled) { |
||||
@apply shadow-default border-gray-200; |
||||
} |
||||
|
||||
&:hover:not(:focus-within):not(:disabled) { |
||||
@apply border-gray-200 shadow-hover; |
||||
} |
||||
|
||||
&:focus-within { |
||||
@apply shadow-selected ring-0; |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
:deep(.ant-row:not(.ant-form-item)) { |
||||
@apply !-mx-1.5; |
||||
& > .ant-col { |
||||
@apply !px-1.5; |
||||
} |
||||
} |
||||
|
||||
:deep(.ant-form-item) { |
||||
@apply !mb-6; |
||||
} |
||||
} |
||||
</style> |
||||
|
||||
<style lang="scss"></style> |