mirror of
https://github.com/nocodb/nocodb.git
synced 2026-05-01 05:16:54 +00:00
575 lines
15 KiB
TypeScript
575 lines
15 KiB
TypeScript
import { useStorage } from '@vueuse/core'
|
|
import type { ProjectRoles } from 'nocodb-sdk'
|
|
import { PlanLimitTypes, getProjectRole, hasMinimumRoleAccess } from 'nocodb-sdk'
|
|
import { usePlugin } from './usePlugin'
|
|
import { ExtensionsEvents } from '#imports'
|
|
import { extensionUserPrefsManager } from '~/helpers/extensionUserPrefsManager'
|
|
|
|
const extensionsState = createGlobalState(() => {
|
|
const baseExtensions = ref<Record<string, any>>({})
|
|
|
|
return { baseExtensions }
|
|
})
|
|
|
|
interface ExtensionPanelState {
|
|
width: number
|
|
isOpen: boolean
|
|
}
|
|
const extensionsPanelState = createGlobalState(() =>
|
|
useStorage<Record<string, ExtensionPanelState>>('nc-extensions-global-state', {}),
|
|
)
|
|
|
|
export interface IKvStore<T extends Record<string, any>> {
|
|
get<K extends keyof T>(key: K): T[K] | null
|
|
set<K extends keyof T>(key: K, value: T[K]): Promise<void>
|
|
delete<K extends keyof T>(key: K): Promise<void>
|
|
serialize(): Record<string, T[keyof T]>
|
|
}
|
|
|
|
abstract class ExtensionType {
|
|
abstract id: string
|
|
abstract uiKey: number
|
|
abstract baseId: string
|
|
abstract fkUserId: string
|
|
abstract extensionId: string
|
|
abstract title: string
|
|
abstract kvStore: IKvStore<any>
|
|
abstract meta: any
|
|
abstract order: number
|
|
abstract setTitle(title: string): Promise<any>
|
|
abstract setMeta(key: string, value: any): Promise<any>
|
|
abstract clear(): Promise<any>
|
|
abstract delete(): Promise<any>
|
|
abstract serialize(): any
|
|
abstract deserialize(data: any): void
|
|
}
|
|
|
|
export { ExtensionType }
|
|
|
|
export const useExtensions = createSharedComposable(() => {
|
|
const {
|
|
pluginsLoaded,
|
|
getPluginAssetUrl,
|
|
availableExtensions,
|
|
availableExtensionIds,
|
|
availableExtensionMapById,
|
|
pluginTypes,
|
|
pluginDescriptionContent,
|
|
isPluginsEnabled,
|
|
} = usePlugin()
|
|
|
|
const { baseExtensions } = extensionsState()
|
|
|
|
const { $api, $e } = useNuxtApp()
|
|
|
|
const { user } = useGlobal()
|
|
|
|
const { isUIAllowed } = useRoles()
|
|
|
|
const { base } = storeToRefs(useBase())
|
|
|
|
const { updateStatLimit, blockExtensions, showUpgradeToUseExtensions } = useEeConfig()
|
|
|
|
const { isSharedBase } = storeToRefs(useWorkspace())
|
|
|
|
const eventBus = useEventBus<ExtensionsEvents>(Symbol('useExtensions'))
|
|
|
|
const extensionAccess = computed(() => {
|
|
return {
|
|
list: isUIAllowed('extensionList') && !isSharedBase.value,
|
|
create: isUIAllowed('extensionCreate'),
|
|
delete: isUIAllowed('extensionDelete'),
|
|
update: isUIAllowed('extensionUpdate'),
|
|
}
|
|
})
|
|
|
|
const activeBaseExtensions = computed(() => {
|
|
if (!base.value || !base.value.id) {
|
|
return null
|
|
}
|
|
return baseExtensions.value[base.value.id]
|
|
})
|
|
|
|
const panelState = extensionsPanelState()
|
|
|
|
const extensionPanelSize = ref(40)
|
|
const isPanelExpanded = ref(false)
|
|
|
|
const savePanelState = () => {
|
|
panelState.value = {
|
|
...panelState.value,
|
|
[base.value.id!]: {
|
|
width: extensionPanelSize.value,
|
|
isOpen: isPanelExpanded.value,
|
|
},
|
|
}
|
|
}
|
|
|
|
watch(
|
|
base,
|
|
() => {
|
|
extensionPanelSize.value = +(panelState.value[base.value.id!]?.width || 40)
|
|
isPanelExpanded.value = panelState.value[base.value.id!]?.isOpen || false
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
|
|
// Debounce since width is updated continuously when user drags.
|
|
watchDebounced([extensionPanelSize, isPanelExpanded], savePanelState, { debounce: 500, maxWait: 1000 })
|
|
|
|
const toggleExtensionPanel = () => {
|
|
isPanelExpanded.value = !isPanelExpanded.value
|
|
}
|
|
|
|
/**
|
|
* @param extensionId - The id of the extension which is defined in manifest.json to get the minimum access role for
|
|
* @returns The minimum access role for the extension
|
|
*/
|
|
const getExtensionMinAccessRole = (extensionId: string): ExtensionManifest['minAccessRole'] => {
|
|
const extension = availableExtensionMapById.value[extensionId]
|
|
return extension?.minAccessRole || 'creator'
|
|
}
|
|
|
|
const extensionList = computed<ExtensionType[]>(() => {
|
|
return (activeBaseExtensions.value ? activeBaseExtensions.value.extensions : [])
|
|
.filter((e: ExtensionType) => availableExtensionIds.value.includes(e.extensionId))
|
|
.sort((a: ExtensionType, b: ExtensionType) => {
|
|
return (a?.order ?? Infinity) - (b?.order ?? Infinity)
|
|
})
|
|
})
|
|
|
|
const userCurrentBaseRole = computed(() => {
|
|
return getProjectRole(user.value, true)
|
|
})
|
|
|
|
/**
|
|
* @param extensionId - The id of the extension which is defined in manifest.json to check if the user has access to
|
|
* @returns True if the user has access to the extension, false otherwise
|
|
*/
|
|
const userHasAccessToExtension = (extensionId: string) => {
|
|
return hasMinimumRoleAccess(user.value, getExtensionMinAccessRole(extensionId) as ProjectRoles)
|
|
}
|
|
|
|
const addExtension = async (extension: any) => {
|
|
if (blockExtensions.value) {
|
|
showUpgradeToUseExtensions()
|
|
return
|
|
}
|
|
|
|
if (!base.value || !base.value.id || !baseExtensions.value[base.value.id]) {
|
|
return
|
|
}
|
|
|
|
const extensionReq = {
|
|
base_id: base.value.id,
|
|
title: extension.title,
|
|
extension_id: extension.id,
|
|
meta: {
|
|
collapsed: false,
|
|
},
|
|
}
|
|
|
|
try {
|
|
const newExtension = await $api.internal.postOperation(
|
|
base.value!.fk_workspace_id!,
|
|
base.value.id,
|
|
{
|
|
operation: 'extensionCreate',
|
|
},
|
|
extensionReq,
|
|
)
|
|
|
|
if (newExtension) {
|
|
updateStatLimit(PlanLimitTypes.LIMIT_EXTENSION_PER_WORKSPACE, 1)
|
|
|
|
baseExtensions.value[base.value.id].extensions.push(new Extension(newExtension))
|
|
|
|
nextTick(() => {
|
|
eventBus.emit(ExtensionsEvents.ADD, newExtension?.id)
|
|
$e('a:extension:add', { extensionId: extensionReq.extension_id })
|
|
})
|
|
}
|
|
return newExtension
|
|
} catch (e: any) {
|
|
message.error(await extractSdkResponseErrorMsg(e))
|
|
}
|
|
}
|
|
|
|
const updateExtension = async (extensionId: string, extension: any) => {
|
|
if (!extensionList.value.length || !extensionAccess.value.update) {
|
|
return
|
|
}
|
|
|
|
const extensionToUpdate = extensionList.value.find((ext: any) => ext.id === extensionId)
|
|
|
|
if (!extensionToUpdate) return
|
|
|
|
try {
|
|
const updatedExtension = await $api.internal.postOperation(
|
|
base.value!.fk_workspace_id!,
|
|
base.value!.id!,
|
|
{
|
|
operation: 'extensionUpdate',
|
|
extensionId,
|
|
},
|
|
extension,
|
|
)
|
|
|
|
if (updatedExtension) {
|
|
extensionToUpdate.deserialize(updatedExtension)
|
|
}
|
|
|
|
return updatedExtension
|
|
} catch (e: any) {
|
|
message.error(await extractSdkResponseErrorMsg(e))
|
|
}
|
|
}
|
|
|
|
const updateExtensionMeta = async (extensionId: string, key: string, value: any) => {
|
|
const extension = extensionList.value.find((ext: any) => ext.id === extensionId)
|
|
|
|
if (!extension) {
|
|
return
|
|
}
|
|
|
|
return updateExtension(extensionId, {
|
|
meta: {
|
|
...extension.meta,
|
|
[key]: value,
|
|
},
|
|
})
|
|
}
|
|
|
|
const deleteExtension = async (extensionId: string) => {
|
|
if (!base.value || !base.value.id || !baseExtensions.value[base.value.id] || !extensionAccess.value.delete) {
|
|
return
|
|
}
|
|
|
|
const extensionToDelete = baseExtensions.value[base.value.id].extensions.find((e: any) => e.id === extensionId)
|
|
|
|
if (!extensionToDelete) return
|
|
|
|
try {
|
|
await $api.internal.postOperation(
|
|
base.value!.fk_workspace_id!,
|
|
base.value.id,
|
|
{
|
|
operation: 'extensionDelete',
|
|
extensionId,
|
|
},
|
|
{},
|
|
)
|
|
|
|
updateStatLimit(PlanLimitTypes.LIMIT_EXTENSION_PER_WORKSPACE, -1)
|
|
|
|
baseExtensions.value[base.value.id].extensions = baseExtensions.value[base.value.id].extensions.filter(
|
|
(ext: any) => ext.id !== extensionId,
|
|
)
|
|
|
|
extensionUserPrefsManager.deleteExtension(extensionId)
|
|
$e('a:extension:delete', { extensionId: extensionToDelete.extensionId })
|
|
} catch (e: any) {
|
|
message.error(await extractSdkResponseErrorMsg(e))
|
|
}
|
|
}
|
|
|
|
const duplicateExtension = async (extensionId: string) => {
|
|
if (!base.value || !base.value.id || !baseExtensions.value[base.value.id] || !extensionAccess.value.create) {
|
|
return
|
|
}
|
|
|
|
const extension = extensionList.value.find((ext: any) => ext.id === extensionId)
|
|
|
|
if (!extension) {
|
|
return
|
|
}
|
|
|
|
const { id: _id, order: _order, ...extensionData } = extension.serialize()
|
|
|
|
const newExtension = await $api.internal.postOperation(
|
|
base.value!.fk_workspace_id!,
|
|
base.value.id,
|
|
{
|
|
operation: 'extensionCreate',
|
|
},
|
|
{
|
|
...extensionData,
|
|
title: `${extension.title} (Copy)`,
|
|
},
|
|
)
|
|
|
|
if (newExtension) {
|
|
const duplicatedExtension = new Extension(newExtension)
|
|
baseExtensions.value[base.value.id].extensions.push(duplicatedExtension)
|
|
eventBus.emit(ExtensionsEvents.DUPLICATE, duplicatedExtension.id)
|
|
|
|
$e('a:extension:duplicate', { extensionId: extension.extensionId })
|
|
}
|
|
|
|
return newExtension
|
|
}
|
|
|
|
const clearKvStore = async (extensionId: string) => {
|
|
const extension = extensionList.value.find((ext: any) => ext.id === extensionId)
|
|
|
|
if (!extension) {
|
|
return
|
|
}
|
|
|
|
let defaultKvStore = {}
|
|
|
|
switch (extension.extensionId) {
|
|
case 'nc-data-exporter': {
|
|
defaultKvStore = {
|
|
...defaultKvStore,
|
|
deletedExports: extension.kvStore.get('deletedExports') || [],
|
|
}
|
|
}
|
|
}
|
|
|
|
return updateExtension(extensionId, {
|
|
kv_store: {
|
|
...defaultKvStore,
|
|
},
|
|
})
|
|
}
|
|
|
|
const loadExtensionsForBase = async (baseId: string) => {
|
|
if (!baseId || !extensionAccess.value.list) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
const { list } = await $api.internal.getOperation(base.value!.fk_workspace_id!, baseId, {
|
|
operation: 'extensionList',
|
|
})
|
|
|
|
const extensions = list?.map((ext: any) => new Extension(ext))
|
|
|
|
if (baseExtensions.value[baseId]) {
|
|
baseExtensions.value[baseId].extensions = extensions || baseExtensions.value[baseId].extensions
|
|
} else {
|
|
baseExtensions.value[baseId] = {
|
|
extensions: extensions || [],
|
|
expanded: false,
|
|
}
|
|
}
|
|
|
|
if (user.value?.id && extensions) {
|
|
const validExtensionIds = extensions.map((ext: any) => ext.id)
|
|
extensionUserPrefsManager.verifyAndCleanup(user.value.id, validExtensionIds)
|
|
}
|
|
} catch (e) {
|
|
baseExtensions.value[baseId] = {
|
|
extensions: [],
|
|
expanded: false,
|
|
}
|
|
console.log(e)
|
|
}
|
|
}
|
|
class KvStore<T extends Record<string, any> = any> implements IKvStore<T> {
|
|
private _id: string
|
|
private data: T
|
|
private _extension: Extension | null = null
|
|
|
|
constructor(id: string, data: T, extension?: Extension) {
|
|
this._id = id
|
|
this.data = data || {}
|
|
this._extension = extension || null
|
|
}
|
|
|
|
get<K extends keyof T = any>(key: K) {
|
|
return this.data[key] || null
|
|
}
|
|
|
|
set<K extends keyof T = any>(key: K, value: any) {
|
|
this.data[key] = value
|
|
// Skip update if last change was from realtime
|
|
if (this._extension?.is_last_update_from_realtime) {
|
|
this._extension.is_last_update_from_realtime = false
|
|
return Promise.resolve()
|
|
}
|
|
return updateExtension(this._id, { kv_store: this.data })
|
|
}
|
|
|
|
async delete<K extends keyof T = any>(key: K) {
|
|
delete this.data[key]
|
|
await updateExtension(this._id, { kv_store: this.data })
|
|
}
|
|
|
|
serialize() {
|
|
return this.data
|
|
}
|
|
}
|
|
|
|
class Extension implements ExtensionType {
|
|
private _id: string
|
|
private _baseId: string
|
|
private _fkUserId: string
|
|
private _extensionId: string
|
|
private _title: string
|
|
private _kvStore: KvStore
|
|
private _meta: any
|
|
private _order: number
|
|
public uiKey = 0
|
|
public is_last_update_from_realtime = false
|
|
|
|
constructor(data: any) {
|
|
this._id = data.id
|
|
this._baseId = data.base_id
|
|
this._fkUserId = data.fk_user_id
|
|
this._extensionId = data.extension_id
|
|
this._title = data.title
|
|
this._kvStore = new KvStore(this._id, data.kv_store, this)
|
|
this._meta = data.meta
|
|
this._order = data.order
|
|
}
|
|
|
|
get id() {
|
|
return this._id
|
|
}
|
|
|
|
get baseId() {
|
|
return this._baseId
|
|
}
|
|
|
|
get fkUserId() {
|
|
return this._fkUserId
|
|
}
|
|
|
|
get extensionId() {
|
|
return this._extensionId
|
|
}
|
|
|
|
get title() {
|
|
return this._title
|
|
}
|
|
|
|
get kvStore() {
|
|
return this._kvStore
|
|
}
|
|
|
|
get meta() {
|
|
return this._meta
|
|
}
|
|
|
|
get order() {
|
|
return this._order
|
|
}
|
|
|
|
serialize() {
|
|
return {
|
|
id: this._id,
|
|
base_id: this._baseId,
|
|
fk_user_id: this._fkUserId,
|
|
extension_id: this._extensionId,
|
|
title: this._title,
|
|
kv_store: this._kvStore.serialize(),
|
|
meta: this._meta,
|
|
order: this._order,
|
|
}
|
|
}
|
|
|
|
deserialize(data: any) {
|
|
this._id = data.id
|
|
this._baseId = data.base_id
|
|
this._fkUserId = data.fk_user_id
|
|
this._extensionId = data.extension_id
|
|
this._title = data.title
|
|
this._kvStore = new KvStore(this._id, data.kv_store, this)
|
|
this._meta = data.meta
|
|
this._order = data.order
|
|
}
|
|
|
|
setTitle(title: string): Promise<any> {
|
|
return updateExtension(this.id, { title })
|
|
}
|
|
|
|
setMeta(key: string, value: any): Promise<any> {
|
|
if (!this._meta) {
|
|
this._meta = {}
|
|
}
|
|
|
|
this._meta[key] = value
|
|
|
|
return updateExtensionMeta(this.id, key, value)
|
|
}
|
|
|
|
async clear(): Promise<any> {
|
|
return clearKvStore(this.id).then(() => {
|
|
this.uiKey++
|
|
|
|
nextTick(() => {
|
|
eventBus.emit(ExtensionsEvents.CLEARDATA, this.id)
|
|
$e('c:extension:clear-data', { extensionId: this._extensionId })
|
|
})
|
|
})
|
|
}
|
|
|
|
delete(): Promise<any> {
|
|
return deleteExtension(this.id)
|
|
}
|
|
}
|
|
|
|
watch(
|
|
[() => base.value?.id, isPluginsEnabled, () => extensionAccess.value.list, () => pluginsLoaded.value],
|
|
([baseId, newPluginsEnabled, isAllowed, isPluginsLoaded]) => {
|
|
if (!newPluginsEnabled || !baseId || !isAllowed || !isPluginsLoaded) {
|
|
return
|
|
}
|
|
|
|
loadExtensionsForBase(baseId).catch((e) => {
|
|
console.error(e)
|
|
})
|
|
},
|
|
{
|
|
immediate: true,
|
|
},
|
|
)
|
|
|
|
// Extension details modal
|
|
const isDetailsVisible = ref(false)
|
|
const detailsExtensionId = ref<string>()
|
|
const detailsFrom = ref<'market' | 'extension'>('market')
|
|
|
|
const showExtensionDetails = (extensionId: string, from?: 'market' | 'extension') => {
|
|
detailsExtensionId.value = extensionId
|
|
isDetailsVisible.value = true
|
|
detailsFrom.value = from || 'market'
|
|
|
|
$e('c:extension:details', { source: from, extensionId })
|
|
}
|
|
// Extension market modal
|
|
const isMarketVisible = ref(false)
|
|
|
|
return {
|
|
extensionsLoaded: pluginsLoaded,
|
|
availableExtensions,
|
|
descriptionContent: pluginDescriptionContent,
|
|
extensionList,
|
|
isPanelExpanded,
|
|
toggleExtensionPanel,
|
|
addExtension,
|
|
duplicateExtension,
|
|
updateExtension,
|
|
updateExtensionMeta,
|
|
clearKvStore,
|
|
deleteExtension,
|
|
loadExtensionsForBase,
|
|
getExtensionAssetsUrl: (pathOrUrl: string) => getPluginAssetUrl(pathOrUrl, pluginTypes.extension),
|
|
isDetailsVisible,
|
|
detailsExtensionId,
|
|
detailsFrom,
|
|
showExtensionDetails,
|
|
isMarketVisible,
|
|
extensionPanelSize,
|
|
eventBus,
|
|
getExtensionMinAccessRole,
|
|
userHasAccessToExtension,
|
|
userCurrentBaseRole,
|
|
extensionAccess,
|
|
baseExtensions,
|
|
Extension,
|
|
}
|
|
})
|