@ -0,0 +1,181 @@
|
||||
<script setup lang="ts"> |
||||
import { useToast } from 'vue-toastification' |
||||
import AppInstall from './AppStore/AppInstall.vue' |
||||
import MdiEditIcon from '~icons/ic/round-edit' |
||||
import MdiCloseCircleIcon from '~icons/mdi/close-circle-outline' |
||||
import MdiPlusIcon from '~icons/mdi/plus' |
||||
const { $api, $e } = useNuxtApp() |
||||
const toast = useToast() |
||||
|
||||
let apps = $ref<null | Array<any>>(null) |
||||
let showPluginUninstallModal = $ref(false) |
||||
let showPluginInstallModal = $ref(false) |
||||
let pluginApp = $ref<any>(null) |
||||
|
||||
const fetchPluginApps = async () => { |
||||
try { |
||||
const plugins = (await $api.plugin.list()).list ?? [] |
||||
|
||||
apps = plugins.map((p) => ({ |
||||
...p, |
||||
tags: p.tags ? p.tags.split(',') : [], |
||||
parsedInput: p.input && JSON.parse(p.input), |
||||
})) |
||||
} catch (e) { |
||||
console.error(e) |
||||
toast.error('Something went wrong') |
||||
} |
||||
} |
||||
|
||||
const resetPlugin = async () => { |
||||
try { |
||||
await $api.plugin.update(pluginApp.id, { |
||||
input: undefined, |
||||
active: false, |
||||
}) |
||||
toast.success('Plugin uninstalled successfully') |
||||
showPluginUninstallModal = false |
||||
await fetchPluginApps() |
||||
} catch (e: any) { |
||||
console.log(e) |
||||
toast.error(e.message) |
||||
} |
||||
|
||||
$e('a:appstore:reset', { app: pluginApp.title }) |
||||
} |
||||
|
||||
const saved = async () => { |
||||
showPluginInstallModal = false |
||||
await fetchPluginApps() |
||||
$e('a:appstore:install', { app: pluginApp.title }) |
||||
} |
||||
|
||||
const showInstallPluginModal = async (app: any) => { |
||||
showPluginInstallModal = true |
||||
pluginApp = app |
||||
|
||||
$e('c:appstore:install', { app: app.title }) |
||||
} |
||||
|
||||
const showResetPluginModal = async (app: any) => { |
||||
showPluginUninstallModal = true |
||||
pluginApp = app |
||||
} |
||||
|
||||
onMounted(async () => { |
||||
if (apps === null) { |
||||
fetchPluginApps() |
||||
} |
||||
}) |
||||
</script> |
||||
|
||||
<template> |
||||
<a-modal v-model:visible="showPluginInstallModal" :closable="false" centered min-height="300" :footer="null"> |
||||
<AppInstall |
||||
v-if="pluginApp && showPluginInstallModal" |
||||
:id="pluginApp.id" |
||||
@close="showPluginInstallModal = false" |
||||
@saved="saved()" |
||||
/> |
||||
</a-modal> |
||||
|
||||
<a-modal v-model:visible="showPluginUninstallModal" :closable="false" width="24rem" centered :footer="null"> |
||||
<div class="flex flex-col h-full"> |
||||
<div class="flex flex-row justify-center mt-2 text-center w-full text-base"> |
||||
{{ `Click on confirm to reset ${pluginApp && pluginApp.title}` }} |
||||
</div> |
||||
<div class="flex mt-6 justify-center space-x-2"> |
||||
<a-button @click="showPluginUninstallModal = false"> Cancel </a-button> |
||||
<a-button type="primary" danger @click="resetPlugin"> Confirm </a-button> |
||||
</div> |
||||
</div> |
||||
</a-modal> |
||||
|
||||
<div class="grid grid-cols-2 gap-x-2 gap-y-4 mt-4"> |
||||
<a-card |
||||
v-for="(app, i) in apps" |
||||
:key="i" |
||||
class="relative flex overflow-x-hidden app-item-card !shadow-sm rounded-md w-full" |
||||
:body-style="{ width: '100%' }" |
||||
> |
||||
<div class="install-btn flex flex-row justify-end space-x-1"> |
||||
<a-button v-if="app.parsedInput" size="small" outlined @click="showInstallPluginModal(app)"> |
||||
<div class="flex flex-row justify-center items-center caption capitalize"> |
||||
<MdiEditIcon class="pr-0.5" :height="12" /> |
||||
Edit |
||||
</div> |
||||
</a-button> |
||||
<a-button v-if="app.parsedInput" size="small" outlined @click="showResetPluginModal(app)"> |
||||
<div class="flex flex-row justify-center items-center caption capitalize"> |
||||
<MdiCloseCircleIcon /> |
||||
<div class="flex ml-0.5">Reset</div> |
||||
</div> |
||||
</a-button> |
||||
<a-button v-else size="small" outlined @click="showInstallPluginModal(app)"> |
||||
<div class="flex flex-row justify-center items-center caption capitalize"> |
||||
<MdiPlusIcon /> |
||||
Install |
||||
</div> |
||||
</a-button> |
||||
</div> |
||||
|
||||
<div class="flex flex-row space-x-2 items-center justify-start w-full"> |
||||
<div class="flex w-20 pl-3"> |
||||
<img |
||||
v-if="app.title !== 'SMTP'" |
||||
class="avatar" |
||||
:style="{ |
||||
backgroundColor: app.title === 'SES' ? '#242f3e' : '', |
||||
}" |
||||
:src="`/${app.logo}`" |
||||
/> |
||||
<div v-else /> |
||||
</div> |
||||
<div class="flex flex-col flex-grow-1 w-3/5 pl-3"> |
||||
<a-typography-title :level="5">{{ app.title }}</a-typography-title> |
||||
{{ app.description }} |
||||
</div> |
||||
</div> |
||||
</a-card> |
||||
</div> |
||||
</template> |
||||
|
||||
<style scoped lang="scss"> |
||||
.app-item-card { |
||||
position: relative; |
||||
transition: 0.4s background-color; |
||||
|
||||
.install-btn { |
||||
position: absolute; |
||||
opacity: 1; |
||||
right: -100%; |
||||
top: 10px; |
||||
transition: 0.4s opacity, 0.4s right; |
||||
} |
||||
|
||||
&:hover .install-btn { |
||||
right: 10px; |
||||
opacity: 1; |
||||
} |
||||
} |
||||
|
||||
.app-item-card { |
||||
transition: 0.4s background-color, 0.4s transform; |
||||
|
||||
&:hover { |
||||
background: rgba(123, 126, 136, 0.1) !important; |
||||
} |
||||
} |
||||
|
||||
.caption { |
||||
font-size: 0.7rem; |
||||
color: #242f3e; |
||||
} |
||||
|
||||
.avatar { |
||||
width: 5rem; |
||||
height: 5rem; |
||||
padding: 0.25rem; |
||||
object-fit: contain; |
||||
} |
||||
</style> |
@ -0,0 +1,137 @@
|
||||
<script setup lang="ts"> |
||||
import type { FunctionalComponent, SVGAttributes } from 'vue' |
||||
import AuditTab from './AuditTab.vue' |
||||
import AppStore from './AppStore.vue' |
||||
import StoreFrontOutline from '~icons/mdi/storefront-outline' |
||||
import TeamFillIcon from '~icons/ri/team-fill' |
||||
import MultipleTableIcon from '~icons/mdi/table-multiple' |
||||
import NootbookOutline from '~icons/mdi/notebook-outline' |
||||
|
||||
interface Props { |
||||
show: boolean |
||||
} |
||||
|
||||
interface SubTabGroup { |
||||
[key: string]: { |
||||
title: string |
||||
body: any |
||||
} |
||||
} |
||||
|
||||
interface TabGroup { |
||||
[key: string]: { |
||||
title: string |
||||
icon: FunctionalComponent<SVGAttributes, {}> |
||||
subTabs: SubTabGroup |
||||
} |
||||
} |
||||
|
||||
const { show } = defineProps<Props>() |
||||
|
||||
const emits = defineEmits(['closed']) |
||||
|
||||
const tabsInfo: TabGroup = { |
||||
teamAndAuth: { |
||||
title: 'Team and Auth', |
||||
icon: TeamFillIcon, |
||||
subTabs: { |
||||
usersManagement: { |
||||
title: 'Users Management', |
||||
body: () => AuditTab, |
||||
}, |
||||
apiTokenManagement: { |
||||
title: 'API Token Management', |
||||
body: () => AuditTab, |
||||
}, |
||||
}, |
||||
}, |
||||
appStore: { |
||||
title: 'App Store', |
||||
icon: StoreFrontOutline, |
||||
subTabs: { |
||||
new: { |
||||
title: 'Apps', |
||||
body: () => AppStore, |
||||
}, |
||||
}, |
||||
}, |
||||
metaData: { |
||||
title: 'Project Metadata', |
||||
icon: MultipleTableIcon, |
||||
subTabs: { |
||||
metaData: { |
||||
title: 'Metadata', |
||||
body: () => AuditTab, |
||||
}, |
||||
acl: { |
||||
title: 'UI Access Control', |
||||
body: () => AuditTab, |
||||
}, |
||||
}, |
||||
}, |
||||
audit: { |
||||
title: 'Audit', |
||||
icon: NootbookOutline, |
||||
subTabs: { |
||||
audit: { |
||||
title: 'Audit', |
||||
body: () => AuditTab, |
||||
}, |
||||
}, |
||||
}, |
||||
} |
||||
|
||||
const firstKeyOfObject = (obj: object) => Object.keys(obj)[0] |
||||
|
||||
// Array of keys of tabs which are selected. In our case will be only one. |
||||
const selectedTabKeys = $ref<string[]>([firstKeyOfObject(tabsInfo)]) |
||||
const selectedTab = $computed(() => tabsInfo[selectedTabKeys[0]]) |
||||
|
||||
let selectedSubTabKeys = $ref<string[]>([firstKeyOfObject(selectedTab.subTabs)]) |
||||
const selectedSubTab = $computed(() => selectedTab.subTabs[selectedSubTabKeys[0]]) |
||||
|
||||
watch( |
||||
() => selectedTabKeys[0], |
||||
(newTabKey) => { |
||||
selectedSubTabKeys = [firstKeyOfObject(tabsInfo[newTabKey].subTabs)] |
||||
}, |
||||
) |
||||
</script> |
||||
|
||||
<template> |
||||
<a-modal :footer="null" :visible="show" width="max(90vw, 600px)" @cancel="emits('closed')"> |
||||
<a-typography-title class="ml-4 mb-2 select-none" type="secondary" :level="5">SETTINGS</a-typography-title> |
||||
<a-layout class="mt-3 modal-body"> |
||||
<!-- Side tabs --> |
||||
<a-layout-sider theme="light"> |
||||
<a-menu v-model:selectedKeys="selectedTabKeys" class="h-full" mode="inline" :open-keys="[]"> |
||||
<a-menu-item v-for="(tab, key) of tabsInfo" :key="key"> |
||||
<div class="flex flex-row items-center space-x-2"> |
||||
<component :is="tab.icon" class="flex" /> |
||||
<div class="flex select-none"> |
||||
{{ tab.title }} |
||||
</div> |
||||
</div> |
||||
</a-menu-item> |
||||
</a-menu> |
||||
</a-layout-sider> |
||||
|
||||
<!-- Sub Tabs --> |
||||
<a-layout-content class="h-full px-4 scrollbar-thumb-gray-500"> |
||||
<a-menu v-model:selectedKeys="selectedSubTabKeys" :open-keys="[]" mode="horizontal"> |
||||
<a-menu-item v-for="(tab, key) of selectedTab.subTabs" :key="key" class="select-none"> |
||||
{{ tab.title }} |
||||
</a-menu-item> |
||||
</a-menu> |
||||
|
||||
<component :is="selectedSubTab.body()" class="px-2 py-6" /> |
||||
</a-layout-content> |
||||
</a-layout> |
||||
</a-modal> |
||||
</template> |
||||
|
||||
<style scoped> |
||||
.modal-body { |
||||
@apply h-[70vh]; |
||||
} |
||||
</style> |
@ -0,0 +1,264 @@
|
||||
<script setup lang="ts"> |
||||
import { ref } from 'vue' |
||||
import { useToast } from 'vue-toastification' |
||||
import type { PluginType } from 'nocodb-sdk' |
||||
import MdiDeleteOutlineIcon from '~icons/mdi/delete-outline' |
||||
import CloseIcon from '~icons/material-symbols/close-rounded' |
||||
import MdiPlusIcon from '~icons/mdi/plus' |
||||
import { extractSdkResponseErrorMsg } from '~/utils/errorUtils' |
||||
|
||||
interface Props { |
||||
id: string |
||||
} |
||||
|
||||
type Plugin = PluginType & { |
||||
formDetails: Record<string, any> |
||||
parsedInput: Record<string, any> |
||||
} |
||||
|
||||
const { id } = defineProps<Props>() |
||||
|
||||
const emits = defineEmits(['saved', 'close']) |
||||
|
||||
enum Action { |
||||
Save = 'save', |
||||
Test = 'test', |
||||
} |
||||
|
||||
const toast = useToast() |
||||
|
||||
const { $api } = useNuxtApp() |
||||
|
||||
const formRef = ref() |
||||
|
||||
let plugin = $ref<Plugin | null>(null) |
||||
let pluginFormData = $ref<Record<string, any>>({}) |
||||
let isLoading = $ref(true) |
||||
let loadingAction = $ref<null | Action>(null) |
||||
|
||||
const layout = { |
||||
labelCol: { span: 14, pull: 4 }, |
||||
wrapperCol: { span: 20, pull: 4 }, |
||||
} |
||||
|
||||
const addSetting = () => pluginFormData.push({}) |
||||
|
||||
const saveSettings = async () => { |
||||
loadingAction = Action.Save |
||||
|
||||
try { |
||||
await formRef.value?.validateFields() |
||||
|
||||
await $api.plugin.update(id, { |
||||
input: JSON.stringify(pluginFormData), |
||||
active: true, |
||||
}) |
||||
|
||||
emits('saved') |
||||
toast.success(plugin?.formDetails.msgOnInstall || 'Plugin settings saved successfully') |
||||
} catch (_e: any) { |
||||
const e = await extractSdkResponseErrorMsg(_e) |
||||
toast.error(e.message) |
||||
} finally { |
||||
loadingAction = null |
||||
} |
||||
} |
||||
|
||||
const testSettings = async () => { |
||||
loadingAction = Action.Test |
||||
|
||||
try { |
||||
const res = await $api.plugin.test({ |
||||
input: pluginFormData, |
||||
id: plugin?.id, |
||||
category: plugin?.category, |
||||
title: plugin?.title, |
||||
}) |
||||
|
||||
if (res) { |
||||
toast.success('Successfully tested plugin settings') |
||||
} else { |
||||
toast.info('Invalid credentials') |
||||
} |
||||
} catch (_e: any) { |
||||
const e = await extractSdkResponseErrorMsg(_e) |
||||
toast.error(e.message) |
||||
} finally { |
||||
loadingAction = null |
||||
} |
||||
} |
||||
|
||||
const doAction = async (action: Action) => { |
||||
switch (action) { |
||||
case Action.Save: |
||||
await saveSettings() |
||||
break |
||||
case Action.Test: |
||||
await testSettings() |
||||
break |
||||
default: |
||||
// noop |
||||
break |
||||
} |
||||
} |
||||
|
||||
const readPluginDetails = async () => { |
||||
try { |
||||
isLoading = true |
||||
|
||||
const res = await $api.plugin.read(id) |
||||
const formDetails = JSON.parse(res.input_schema ?? '{}') |
||||
const emptyParsedInput = formDetails.array ? [{}] : {} |
||||
const parsedInput = res.input ? JSON.parse(res.input) : emptyParsedInput |
||||
|
||||
plugin = { ...res, formDetails, parsedInput } |
||||
pluginFormData = plugin.parsedInput |
||||
} catch (e) { |
||||
console.log(e) |
||||
} finally { |
||||
isLoading = false |
||||
} |
||||
} |
||||
|
||||
const deleteFormRow = (index: number) => pluginFormData.splice(index, 1) |
||||
|
||||
onMounted(async () => { |
||||
if (!plugin) { |
||||
await readPluginDetails() |
||||
} |
||||
}) |
||||
</script> |
||||
|
||||
<template> |
||||
<div v-if="isLoading" class="flex flex-row w-full justify-center items-center h-52"> |
||||
<a-spin size="large" /> |
||||
</div> |
||||
|
||||
<template v-else> |
||||
<div class="flex flex-col"> |
||||
<div class="w-full relative"> |
||||
<div class="flex flex-row justify-center pb-4 mb-2 border-b-1 w-full gap-x-1"> |
||||
<div |
||||
v-if="plugin.logo" |
||||
class="mr-1 flex items-center justify-center" |
||||
:class="[plugin.title === 'SES' ? 'p-2 bg-[#242f3e]' : '']" |
||||
> |
||||
<img :src="`/${plugin.logo}`" class="h-6" /> |
||||
</div> |
||||
|
||||
<span class="font-semibold text-lg">{{ plugin.formDetails.title }}</span> |
||||
</div> |
||||
<div class="absolute -right-2 -top-0.5"> |
||||
<a-button type="text" class="!rounded-md mr-1" @click="emits('close')"> |
||||
<template #icon> |
||||
<CloseIcon class="flex mx-auto" /> |
||||
</template> |
||||
</a-button> |
||||
</div> |
||||
</div> |
||||
|
||||
<a-form ref="formRef" v-bind="plugin?.formDetails.array ? {} : layout" :model="pluginFormData" class="mx-4 mt-3"> |
||||
<!-- Form with multiple entry --> |
||||
<div v-if="plugin.formDetails.array" class="flex flex-row justify-center"> |
||||
<table> |
||||
<thead> |
||||
<tr> |
||||
<th v-for="(columnData, columnIndex) in plugin.formDetails.items" :key="columnIndex"> |
||||
<div class="text-center font-normal mb-2"> |
||||
{{ columnData.label }} <span v-if="columnData.required" class="text-red-600">*</span> |
||||
</div> |
||||
</th> |
||||
</tr> |
||||
</thead> |
||||
<tbody> |
||||
<tr v-for="(itemRow, itemIndex) in plugin.parsedInput" :key="itemIndex"> |
||||
<td v-for="(columnData, columnIndex) in plugin.formDetails.items" :key="columnIndex" class="px-2"> |
||||
<a-form-item |
||||
class="relative mb-3" |
||||
:name="[`${itemIndex}`, columnData.key]" |
||||
:rules="[{ required: columnData.required, message: `${columnData.label} is required` }]" |
||||
> |
||||
<a-input-password |
||||
v-if="columnData.type === 'Password'" |
||||
v-model:value="itemRow[columnData.key]" |
||||
:placeholder="columnData.placeholder" |
||||
/> |
||||
<a-textarea |
||||
v-else-if="columnData.type === 'LongText'" |
||||
v-model:value="itemRow[columnData.key]" |
||||
:placeholder="columnData.placeholder" |
||||
/> |
||||
<a-switch |
||||
v-else-if="columnData.type === 'Checkbox'" |
||||
v-model:value="itemRow[columnData.key]" |
||||
:placeholder="columnData.placeholder" |
||||
/> |
||||
<a-input v-else v-model:value="itemRow[columnData.key]" :placeholder="columnData.placeholder" /> |
||||
<div |
||||
v-if="itemIndex !== 0 && columnIndex === plugin.formDetails.items.length - 1" |
||||
class="absolute flex flex-col justify-start mt-2 -right-6 top-0" |
||||
> |
||||
<MdiDeleteOutlineIcon class="hover:text-red-400 cursor-pointer" @click="deleteFormRow(itemIndex)" /> |
||||
</div> |
||||
</a-form-item> |
||||
</td> |
||||
</tr> |
||||
</tbody> |
||||
|
||||
<tr> |
||||
<td :colspan="plugin.formDetails.items.length" class="text-center"> |
||||
<a-button type="default" class="!bg-gray-100 rounded-md border-none mr-1" @click="addSetting"> |
||||
<template #icon> |
||||
<MdiPlusIcon class="flex mx-auto" /> |
||||
</template> |
||||
</a-button> |
||||
</td> |
||||
</tr> |
||||
</table> |
||||
</div> |
||||
|
||||
<!-- Form with only one entry --> |
||||
<template v-else> |
||||
<a-form-item |
||||
v-for="(columnData, i) in plugin.formDetails.items" |
||||
:key="i" |
||||
:label="columnData.label" |
||||
:name="columnData.key" |
||||
:rules="[{ required: columnData.required, message: `${columnData.label} is required` }]" |
||||
> |
||||
<a-input-password |
||||
v-if="columnData.type === 'Password'" |
||||
v-model:value="pluginFormData[columnData.key]" |
||||
:placeholder="columnData.placeholder" |
||||
/> |
||||
<a-textarea |
||||
v-else-if="columnData.type === 'LongText'" |
||||
v-model:value="pluginFormData[columnData.key]" |
||||
:placeholder="columnData.placeholder" |
||||
/> |
||||
<a-switch |
||||
v-else-if="columnData.type === 'Checkbox'" |
||||
v-model:checked="pluginFormData[columnData.key]" |
||||
:placeholder="columnData.placeholder" |
||||
/> |
||||
<a-input v-else v-model:value="pluginFormData[columnData.key]" :placeholder="columnData.placeholder" /> |
||||
</a-form-item> |
||||
</template> |
||||
<div class="flex flex-row space-x-4 justify-center mt-4"> |
||||
<a-button |
||||
v-for="(action, i) in plugin.formDetails.actions" |
||||
:key="i" |
||||
:loading="loadingAction === action.key" |
||||
:type="action.key === Action.Save ? 'primary' : 'default'" |
||||
:disabled="!!loadingAction" |
||||
@click="doAction(action.key)" |
||||
> |
||||
{{ action.label }} |
||||
</a-button> |
||||
</div> |
||||
</a-form> |
||||
</div> |
||||
</template> |
||||
</template> |
||||
|
||||
<style scoped lang="scss"></style> |
After Width: | Height: | Size: 2.1 KiB |
After Width: | Height: | Size: 10 KiB |
After Width: | Height: | Size: 9.0 KiB |
After Width: | Height: | Size: 2.1 KiB |
After Width: | Height: | Size: 666 B |
After Width: | Height: | Size: 12 KiB |
After Width: | Height: | Size: 4.1 KiB |
After Width: | Height: | Size: 2.4 KiB |
After Width: | Height: | Size: 1.6 KiB |
After Width: | Height: | Size: 1.5 KiB |
After Width: | Height: | Size: 2.9 KiB |
After Width: | Height: | Size: 5.3 KiB |
After Width: | Height: | Size: 2.1 KiB |
After Width: | Height: | Size: 89 KiB |
After Width: | Height: | Size: 880 B |
After Width: | Height: | Size: 564 B |
After Width: | Height: | Size: 4.9 KiB |
After Width: | Height: | Size: 5.7 KiB |