Merge pull request #12951 from nocodb/nc-feat/managed-app-ui-changes

Nc feat/managed app UI changes
This commit is contained in:
Ramesh Mane
2026-01-28 12:07:33 +05:30
committed by GitHub
18 changed files with 68 additions and 1681 deletions

3
.gitignore vendored
View File

@@ -104,4 +104,5 @@ result
# Temp
migrate-colors.js
antd.variable.css
antd.variable.css
CLAUDE.md

View File

@@ -1,7 +1,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_715_5581)">
<path d="M8.00065 14.6663C11.6825 14.6663 14.6673 11.6816 14.6673 7.99967C14.6673 4.31778 11.6825 1.33301 8.00065 1.33301C4.31875 1.33301 1.33398 4.31778 1.33398 7.99967C1.33398 11.6816 4.31875 14.6663 8.00065 14.6663Z" stroke="#17803D" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.5 5.5L6.6875 10.5L4.5 8.22727" stroke="#17803D" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.00065 14.6663C11.6825 14.6663 14.6673 11.6816 14.6673 7.99967C14.6673 4.31778 11.6825 1.33301 8.00065 1.33301C4.31875 1.33301 1.33398 4.31778 1.33398 7.99967C1.33398 11.6816 4.31875 14.6663 8.00065 14.6663Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.5 5.5L6.6875 10.5L4.5 8.22727" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_715_5581">

Before

Width:  |  Height:  |  Size: 689 B

After

Width:  |  Height:  |  Size: 699 B

View File

@@ -369,12 +369,6 @@ const duplicateProject = (base: BaseType) => {
isDuplicateDlgOpen.value = true
}
const isConvertToManagedAppDlgOpen = ref(false)
const convertToManagedApp = () => {
isConvertToManagedAppDlgOpen.value = true
}
const tableDelete = () => {
isTableDeleteDialogVisible.value = true
$e('c:table:delete')
@@ -619,7 +613,6 @@ defineExpose({
@click-menu="onClickMenu"
@rename="enableEditMode()"
@duplicate-project="duplicateProject($event)"
@convert-to-managed-app="convertToManagedApp"
@copy-project-info="copyProjectInfo()"
@open-erd-view="openErdView($event)"
@open-base-settings="openBaseSettings($event)"
@@ -680,7 +673,6 @@ defineExpose({
@click-menu="onClickMenu"
@rename="enableEditMode(true)"
@duplicate-project="duplicateProject($event)"
@convert-to-managed-app="convertToManagedApp"
@copy-project-info="copyProjectInfo()"
@open-erd-view="openErdView($event)"
@open-base-settings="openBaseSettings($event)"
@@ -765,7 +757,6 @@ defineExpose({
/>
<DlgBaseDelete v-model:visible="isBaseDeleteDialogVisible" :base-id="base?.id" />
<DlgBaseDuplicate v-if="selectedProjectToDuplicate" v-model="isDuplicateDlgOpen" :base="selectedProjectToDuplicate" />
<DlgConvertToManagedApp v-if="base?.id" v-model:visible="isConvertToManagedAppDlgOpen" :base-id="base.id" />
<GeneralModal v-model:visible="isErdModalOpen" size="large">
<div class="h-[80vh]">
<LazyDashboardSettingsErd :base-id="base?.id" :source-id="activeBaseId" />

View File

@@ -407,8 +407,8 @@ const collapseKey = ref('')
<div class="my-5">
<NcListSourceSelector
ref="sourceSelectorRef"
:base-id="baseId"
v-model:source-id="sourceIdRef"
:base-id="baseId"
:show-source-selector="showSourceSelector"
force-layout="vertical"
/>

View File

@@ -1,185 +0,0 @@
<script setup lang="ts">
import { FormBuilderValidatorType } from 'nocodb-sdk'
import { FORM_BUILDER_NON_CATEGORIZED, FormBuilderInputType } from '#imports'
const props = defineProps<{
baseId: string
}>()
const visible = defineModel<boolean>('visible', { required: true })
const { $api } = useNuxtApp()
const { t } = useI18n()
const initialManagedAppFormState = ref<Record<string, any>>({
title: '',
description: '',
category: '',
visibility: 'private',
})
const { base } = storeToRefs(useBase())
const basesStore = useBases()
const convertToManagedApp = async (formState: Record<string, any>) => {
try {
const response = await $api.internal.postOperation(
base.value!.fk_workspace_id as string,
props.baseId,
{
operation: 'managedAppCreate',
} as any,
{
title: formState.title,
description: formState.description,
category: formState.category,
visibility: formState.visibility,
},
)
message.success(t('msg.success.managedAppCreated'))
visible.value = false
// Update the base with the managed_app_id from response
if (response && response.managed_app_id) {
const currentBase = basesStore.bases.get(props.baseId)
if (currentBase) {
;(currentBase as any).managed_app_id = response.managed_app_id
}
}
// Reload base to ensure all managed app data is loaded
await basesStore.loadProject(props.baseId, true)
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
const { formState, isLoading, submit } = useProvideFormBuilderHelper({
formSchema: [
{
type: FormBuilderInputType.Input,
label: t('labels.managedAppTitle'),
span: 24,
model: 'title',
placeholder: 'Enter a descriptive title',
category: FORM_BUILDER_NON_CATEGORIZED,
validators: [
{
type: FormBuilderValidatorType.Required,
message: t('labels.titleRequired'),
},
],
required: true,
},
{
type: FormBuilderInputType.Textarea,
label: t('labels.managedAppDescription'),
span: 24,
model: 'description',
placeholder: "Describe your application's capabilities",
category: FORM_BUILDER_NON_CATEGORIZED,
},
{
type: FormBuilderInputType.Input,
label: t('labels.managedAppCategory'),
span: 12,
model: 'category',
placeholder: 'e.g., CRM, HR',
category: FORM_BUILDER_NON_CATEGORIZED,
},
{
type: FormBuilderInputType.Select,
label: t('labels.managedAppVisibility'),
span: 12,
model: 'visibility',
category: FORM_BUILDER_NON_CATEGORIZED,
options: [
{ label: 'Public', value: 'public', icon: 'eye' },
{ label: 'Private', value: 'private', icon: 'lock' },
{ label: 'Unlisted', value: 'unlisted', icon: 'ncEyeOff' },
],
defaultValue: 'private',
},
],
onSubmit: async () => {
return await convertToManagedApp(formState.value)
},
initialState: initialManagedAppFormState,
})
watch(visible, (isVisible) => {
if (isVisible && base.value) {
formState.value = {
title: base.value.title || '',
description: '',
category: '',
visibility: 'private',
}
}
})
</script>
<template>
<NcModal
v-model:visible="visible"
size="sm"
height="auto"
centered
wrap-class-name="nc-modal-convert-to-managed-app "
nc-modal-class-name="!p-0"
>
<div class="p-4 w-full flex items-center gap-3 border-b border-nc-border-gray-medium">
<div class="w-10 h-10 rounded-lg bg-gradient-to-br from-brand-500 to-brand-600 flex items-center justify-center">
<GeneralIcon icon="ncBox" class="w-5 h-5 text-white" />
</div>
<div class="flex-1">
<div class="font-semibold text-lg text-nc-content-gray-emphasis">Convert to Managed App</div>
<div class="text-xs text-nc-content-gray-subtle2">{{ $t('labels.publishToAppStore') }}</div>
</div>
<NcButton size="small" type="text" class="self-start" @click="visible = false">
<GeneralIcon icon="close" class="text-nc-content-gray-subtle2" />
</NcButton>
</div>
<div class="flex-1 p-6 nc-scrollbar-thin">
<NcFormBuilder>
<template #header>
<NcAlert
type="info"
align="top"
description="Convert this base into a living application that can be published to the App Store. You'll be able to manage versions and push updates to all installations."
class="!p-3 !items-start bg-nc-bg-blue-light border-1 !border-nc-blue-200 rounded-lg p-3 mb-4"
>
<template #icon>
<GeneralIcon icon="info" class="w-4 h-4 mt-0.5 text-nc-content-blue-dark flex-none" />
</template>
</NcAlert>
</template>
</NcFormBuilder>
</div>
<div class="flex justify-end gap-2 px-4 py-3 border-t border-nc-border-gray-medium">
<NcButton size="small" type="secondary" :disabled="isLoading" @click="visible = false">
{{ $t('general.cancel') }}
</NcButton>
<NcButton size="small" type="primary" :loading="isLoading" @click="submit">
<template #icon>
<GeneralIcon icon="ncBox" />
</template>
Convert to Managed App
</NcButton>
</div>
</NcModal>
</template>
<style lang="scss">
.nc-modal-convert-to-managed-app {
.nc-modal {
max-height: min(90vh, 540px) !important;
height: min(90vh, 540px) !important;
}
}
</style>

View File

@@ -1036,8 +1036,8 @@ watch(
<div class="mb-4">
<NcListSourceSelector
ref="sourceSelectorRef"
:base-id="baseId"
v-model:source-id="sourceIdRef"
:base-id="baseId"
:show-source-selector="showSourceSelector"
force-layout="vertical"
/>

View File

@@ -488,8 +488,8 @@ watch(_baseId, () => {
<NcListSourceSelector
ref="sourceSelectorRef"
:base-id="baseId"
v-model:source-id="sourceIdRef"
:base-id="baseId"
:show-source-selector="showSourceSelector"
/>
</template>

View File

@@ -149,7 +149,7 @@ if (stopEventPropogation.value) {
<div
ref="ncModalRef"
class="flex flex-col nc-modal p-6 h-full"
:class="[`${ncModalClassName}`]"
:class="[`nc-modal-size-${size} ${ncModalClassName}`]"
:style="{
maxHeight: height,
...(modalSizes[size] ? { height } : {}),

View File

@@ -1,717 +0,0 @@
<script setup lang="ts">
const props = defineProps<{
visible: boolean
managedApp: any
currentVersion: any
initialTab?: 'publish' | 'fork' | 'deployments'
}>()
const emit = defineEmits(['update:visible', 'published', 'forked'])
const { $api } = useNuxtApp()
const baseStore = useBase()
const { base } = storeToRefs(baseStore)
const activeTab = ref('publish')
const isLoading = ref(false)
const isLoadingDeployments = ref(false)
const versions = ref<any[]>([])
const deploymentStats = ref<any>(null)
// Version deployments modal
const showVersionDeploymentsModal = ref(false)
const selectedVersion = ref<any>(null)
// Publish form (for draft versions)
const publishForm = reactive({
releaseNotes: '',
})
// Fork form (for creating new draft from published)
const forkForm = reactive({
version: '',
releaseNotes: '',
})
const isPublished = computed(() => props.currentVersion?.status === 'published')
const isDraft = computed(() => props.currentVersion?.status === 'draft')
// Load versions
const loadVersions = async () => {
if (!props.managedApp?.id || !base.value?.fk_workspace_id) return
try {
const response = await $api.internal.getOperation(base.value.fk_workspace_id, base.value.id!, {
operation: 'managedAppVersionsList',
managedAppId: props.managedApp.id,
} as any)
if (response?.list) {
versions.value = response.list
}
} catch (e: any) {
console.error('Failed to load versions:', e)
}
}
// Load real deployment statistics
const loadDeployments = async () => {
if (!props.managedApp?.id || !base.value?.fk_workspace_id) return
isLoadingDeployments.value = true
try {
const response = await $api.internal.getOperation(base.value.fk_workspace_id, base.value.id!, {
operation: 'managedAppDeployments',
managedAppId: props.managedApp.id,
} as any)
if (response) {
deploymentStats.value = response
}
} catch (e: any) {
console.error('Failed to load deployments:', e)
} finally {
isLoadingDeployments.value = false
}
}
const publishCurrentDraft = async () => {
if (!base.value?.fk_workspace_id || !base.value?.id || !props.currentVersion?.id) return
isLoading.value = true
try {
await $api.internal.postOperation(
base.value.fk_workspace_id,
base.value.id,
{
operation: 'managedAppPublish',
},
{
managedAppVersionId: props.currentVersion.id,
},
)
// Reload base to get updated managed app version info
if (base.value?.id) {
await baseStore.loadProject()
}
message.success(`Version ${props.currentVersion.version} published successfully!`)
emit('published')
emit('update:visible', false)
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
} finally {
isLoading.value = false
}
}
const createNewDraft = async () => {
if (!base.value?.fk_workspace_id || !base.value?.id || !props.managedApp?.id) return
if (!forkForm.version) {
message.error('Please provide a version')
return
}
isLoading.value = true
try {
await $api.internal.postOperation(
base.value.fk_workspace_id,
base.value.id,
{
operation: 'managedAppCreateDraft',
},
{
managedAppId: props.managedApp.id,
version: forkForm.version,
},
)
// Reload base to get updated managed app version info
if (base.value?.id) {
await baseStore.loadProject()
}
message.success(`New draft version ${forkForm.version} created successfully!`)
emit('forked')
emit('update:visible', false)
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
} finally {
isLoading.value = false
}
}
const formatDate = (dateString: string) => {
if (!dateString) return 'N/A'
const date = new Date(dateString)
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(date)
}
const suggestNextVersion = () => {
if (!props.currentVersion?.version) {
forkForm.version = '1.0.0'
return
}
const currentVersion = props.currentVersion.version
const versionParts = currentVersion.split('.')
if (versionParts.length === 3) {
// Increment minor version for new draft
versionParts[1] = String(Number(versionParts[1]) + 1)
versionParts[2] = '0'
forkForm.version = versionParts.join('.')
} else {
forkForm.version = ''
}
}
const openVersionDeploymentsModal = (version: any) => {
selectedVersion.value = version
showVersionDeploymentsModal.value = true
}
watch(
() => props.visible,
async (val) => {
if (val) {
// Use initialTab if provided, otherwise default based on version status
if (props.initialTab) {
activeTab.value = props.initialTab
} else if (isDraft.value) {
activeTab.value = 'publish'
} else if (isPublished.value) {
activeTab.value = 'fork'
} else {
// Fallback to deployments if version status is unclear
activeTab.value = 'deployments'
}
await loadVersions()
await loadDeployments()
if (!isDraft.value && !props.initialTab) {
suggestNextVersion()
}
}
},
)
</script>
<template>
<NcModal
:visible="visible"
size="lg"
nc-modal-class-name="nc-modal-managed-app-management"
centered
@update:visible="emit('update:visible', $event)"
>
<div class="flex flex-col h-full">
<!-- Header with Tabs -->
<div class="nc-managed-app-header">
<div class="flex items-center gap-3 flex-1">
<div class="nc-managed-app-icon">
<GeneralIcon icon="ncBox" class="h-5 w-5 text-white" />
</div>
<div class="flex-1">
<div class="text-lg font-semibold text-nc-content-gray-emphasis">Managed App Management</div>
<div class="text-xs text-nc-content-gray-subtle2">Manage versions and track deployments</div>
</div>
</div>
<!-- Tabs (Segmented Control) -->
<div class="nc-managed-app-tabs">
<div class="flex items-center">
<div
v-if="isDraft"
class="nc-managed-app-tab"
:class="{ selected: activeTab === 'publish' }"
@click="activeTab = 'publish'"
>
<GeneralIcon icon="upload" class="h-4 w-4 flex-none opacity-75" />
<span>Publish</span>
</div>
<div
v-if="isPublished"
class="nc-managed-app-tab"
:class="{ selected: activeTab === 'fork' }"
@click="activeTab = 'fork'"
>
<GeneralIcon icon="ncGitBranch" class="h-4 w-4 flex-none opacity-75" />
<span>Fork</span>
</div>
<div class="nc-managed-app-tab" :class="{ selected: activeTab === 'deployments' }" @click="activeTab = 'deployments'">
<GeneralIcon icon="ncServer" class="h-4 w-4 flex-none opacity-75" />
<span>Deployments</span>
</div>
</div>
</div>
<NcButton size="small" type="text" @click="emit('update:visible', false)">
<GeneralIcon icon="close" class="text-nc-content-gray-muted h-4 w-4" />
</NcButton>
</div>
<!-- Content -->
<div class="flex-1 overflow-y-auto">
<!-- Publish Tab -->
<div v-if="activeTab === 'publish'" class="p-6">
<NcAlert
type="info"
align="top"
class="!p-3 !items-start bg-nc-bg-blue-light border-1 !border-nc-blue-200 rounded-lg p-3 mb-4"
>
<template #icon>
<GeneralIcon icon="info" class="w-4 h-4 mt-0.5 text-nc-content-blue-dark flex-none" />
</template>
<template #description>
Publishing version <strong>{{ currentVersion?.version }}</strong> will make it available in the App Store and
automatically update all installations.
</template>
</NcAlert>
<div class="space-y-4">
<div>
<label class="text-nc-content-gray text-sm font-medium mb-2 block">Version</label>
<a-input :value="currentVersion?.version" disabled size="large" class="rounded-lg nc-input-sm">
<template #prefix>
<span class="text-nc-content-gray-subtle2">v</span>
</template>
</a-input>
</div>
<div>
<label class="text-nc-content-gray text-sm font-medium mb-2 block">Release Notes (Optional)</label>
<a-textarea
v-model:value="publishForm.releaseNotes"
placeholder="Describe what's new in this version"
:rows="6"
size="large"
class="rounded-lg nc-input-sm"
/>
</div>
</div>
</div>
<!-- Fork Tab -->
<div v-if="activeTab === 'fork'" class="p-6">
<NcAlert
type="info"
align="top"
class="!p-3 !items-start bg-nc-bg-blue-light border-1 !border-nc-blue-200 rounded-lg p-3 mb-4"
>
<template #icon>
<GeneralIcon icon="info" class="w-4 h-4 mt-0.5 text-nc-content-blue-dark flex-none" />
</template>
<template #description>
Create a new draft version to work on updates. Current published version
<strong>{{ currentVersion?.version }}</strong> will remain unchanged.
</template>
</NcAlert>
<div class="space-y-4">
<div>
<label class="text-nc-content-gray text-sm font-medium mb-2 block">
New Version <span class="text-nc-content-red-dark">*</span>
</label>
<a-input
v-model:value="forkForm.version"
placeholder="e.g., 2.0.0"
size="large"
class="rounded-lg nc-input-sm nc-input-shadow"
>
<template #prefix>
<span class="text-nc-content-gray-subtle2">v</span>
</template>
</a-input>
<div class="text-xs text-nc-content-gray-subtle2 mt-1.5">Use semantic versioning (e.g., 2.0.0, 2.1.0)</div>
</div>
</div>
</div>
<!-- Deployments Tab -->
<div v-if="activeTab === 'deployments'" class="nc-deployments-content">
<div v-if="isLoadingDeployments" class="nc-deployments-loading">
<a-spin size="large" />
<div class="text-sm text-nc-content-gray-muted mt-3">Loading deployment statistics...</div>
</div>
<template v-else-if="deploymentStats">
<!-- Stats Cards -->
<div class="nc-deployment-stats">
<!-- Total Deployments -->
<div class="nc-stat-card">
<div class="nc-stat-icon-wrapper bg-nc-bg-blue-light">
<GeneralIcon icon="ncServer" class="w-5 h-5 text-nc-content-blue-dark" />
</div>
<div class="nc-stat-content">
<div class="nc-stat-value">{{ deploymentStats.statistics?.totalDeployments || 0 }}</div>
<div class="nc-stat-label">Total Installs</div>
</div>
</div>
<!-- Active -->
<div class="nc-stat-card">
<div class="nc-stat-icon-wrapper bg-nc-bg-green-light">
<GeneralIcon icon="check" class="w-5 h-5 text-nc-content-green-dark" />
</div>
<div class="nc-stat-content">
<div class="nc-stat-value">{{ deploymentStats.statistics?.activeDeployments || 0 }}</div>
<div class="nc-stat-label">Active</div>
</div>
</div>
<!-- Failed -->
<div class="nc-stat-card">
<div class="nc-stat-icon-wrapper bg-nc-bg-red-light">
<GeneralIcon icon="alertTriangle" class="w-5 h-5 text-nc-content-red-dark" />
</div>
<div class="nc-stat-content">
<div class="nc-stat-value">{{ deploymentStats.statistics?.failedDeployments || 0 }}</div>
<div class="nc-stat-label">Failed</div>
</div>
</div>
<!-- Versions -->
<div class="nc-stat-card">
<div class="nc-stat-icon-wrapper bg-nc-bg-purple-light">
<GeneralIcon icon="ncGitBranch" class="w-5 h-5 text-nc-content-purple-dark" />
</div>
<div class="nc-stat-content">
<div class="nc-stat-value">{{ deploymentStats.statistics?.totalVersions || 0 }}</div>
<div class="nc-stat-label">Versions</div>
</div>
</div>
</div>
<!-- Version List -->
<div v-if="deploymentStats.versionStats && deploymentStats.versionStats.length > 0" class="nc-version-list-wrapper">
<div class="nc-version-list-header">
<div>
<h3 class="text-sm font-semibold text-nc-content-gray-emphasis">Version History</h3>
<p class="text-xs text-nc-content-gray-subtle2 mt-0.5">
{{ deploymentStats.versionStats.length }}
{{ deploymentStats.versionStats.length === 1 ? 'version' : 'versions' }}
published
</p>
</div>
</div>
<div class="nc-version-list">
<div
v-for="(versionStat, index) in deploymentStats.versionStats"
:key="versionStat.versionId"
class="nc-version-item"
:class="{ 'nc-version-item-clickable': versionStat.deploymentCount > 0 }"
@click="versionStat.deploymentCount > 0 && openVersionDeploymentsModal(versionStat)"
>
<div class="nc-version-info">
<div class="nc-version-icon">
<GeneralIcon icon="ncGitBranch" class="w-4 h-4" />
</div>
<div class="nc-version-details">
<div class="nc-version-title">
<span class="nc-version-number">v{{ versionStat.version }}</span>
<div v-if="index === 0 && versionStat.status === 'published'" class="nc-version-badge">
<GeneralIcon icon="check" class="w-3 h-3" />
<span>Current</span>
</div>
</div>
<div class="nc-version-date">Published {{ formatDate(versionStat.publishedAt) }}</div>
</div>
</div>
<div class="nc-version-installs">
<div class="nc-installs-count">
<GeneralIcon icon="download" class="w-4 h-4 text-nc-content-gray-subtle2" />
<span class="font-bold">{{ versionStat.deploymentCount }}</span>
<span class="text-nc-content-gray-muted">{{
versionStat.deploymentCount === 1 ? 'install' : 'installs'
}}</span>
</div>
<GeneralIcon
v-if="versionStat.deploymentCount > 0"
icon="chevronRight"
class="w-4 h-4 text-nc-content-gray-subtle2 nc-chevron-icon"
/>
</div>
</div>
</div>
</div>
<!-- Empty State -->
<div v-else class="nc-deployments-empty">
<div class="nc-empty-icon">
<GeneralIcon icon="ncServer" class="w-10 h-10 text-nc-content-gray-muted" />
</div>
<div class="text-base font-semibold text-nc-content-gray mb-1">No installations yet</div>
<div class="text-sm text-nc-content-gray-subtle max-w-md text-center">
Once users install your application from the App Store, their deployments will appear here.
</div>
</div>
</template>
<div v-else class="nc-deployments-error">
<div class="nc-error-icon">
<GeneralIcon icon="alertTriangle" class="w-10 h-10 text-nc-content-red-dark" />
</div>
<div class="text-base font-semibold text-nc-content-gray mb-1">Failed to load statistics</div>
<div class="text-sm text-nc-content-gray-subtle mb-4">There was an error loading deployment data</div>
<NcButton size="small" type="secondary" @click="loadDeployments">
<template #icon>
<GeneralIcon icon="reload" />
</template>
Retry
</NcButton>
</div>
</div>
</div>
<!-- Footer -->
<div v-if="activeTab === 'publish' || activeTab === 'fork'" class="nc-managed-app-footer">
<div class="flex justify-end gap-2">
<NcButton type="secondary" size="small" @click="emit('update:visible', false)"> Cancel </NcButton>
<NcButton v-if="activeTab === 'publish'" type="primary" size="small" :loading="isLoading" @click="publishCurrentDraft">
<template #icon>
<GeneralIcon icon="upload" />
</template>
Publish
</NcButton>
<NcButton
v-if="activeTab === 'fork'"
type="primary"
size="small"
:loading="isLoading"
:disabled="!forkForm.version"
@click="createNewDraft"
>
<template #icon>
<GeneralIcon icon="plus" />
</template>
Create Draft
</NcButton>
</div>
</div>
</div>
<!-- Version Deployments Modal -->
<SmartsheetTopbarManagedAppVersionDeploymentsModal
v-model:visible="showVersionDeploymentsModal"
:managed-app="managedApp"
:version="selectedVersion"
/>
</NcModal>
</template>
<style lang="scss" scoped>
.nc-managed-app-header {
@apply flex items-center gap-4 px-4 py-3 border-b-1 border-nc-border-gray-medium;
}
.nc-managed-app-icon {
@apply w-10 h-10 rounded-xl flex items-center justify-center;
background: linear-gradient(135deg, var(--nc-content-brand) 0%, var(--nc-content-blue-medium) 100%);
box-shadow: 0 2px 4px rgba(51, 102, 255, 0.15);
}
.nc-managed-app-tabs {
@apply flex bg-nc-bg-gray-medium rounded-lg p-1;
}
.nc-managed-app-tab {
@apply px-3 py-1.5 flex items-center gap-2 text-xs rounded-md select-none cursor-pointer;
@apply text-nc-content-gray-subtle2 transition-all duration-200;
&.selected {
@apply bg-nc-bg-default text-nc-content-gray-emphasis;
box-shadow: 0px 3px 1px -2px rgba(0, 0, 0, 0.06), 0px 5px 3px -2px rgba(0, 0, 0, 0.02);
}
&:hover:not(.selected) {
@apply text-nc-content-gray;
}
}
.nc-managed-app-footer {
@apply px-6 py-3 border-t-1 border-nc-border-gray-medium;
}
// Deployments Tab Styles
.nc-deployments-content {
@apply p-6;
}
.nc-deployments-loading {
@apply flex flex-col items-center justify-center py-16;
}
.nc-deployment-stats {
@apply grid grid-cols-4 gap-4 mb-6;
}
.nc-stat-card {
@apply bg-nc-bg-gray-extralight border-1 border-nc-border-gray-light rounded-xl p-4 transition-all duration-200 hover:(border-nc-border-gray-medium shadow-hover);
}
.nc-stat-icon-wrapper {
@apply w-10 h-10 rounded-lg flex items-center justify-center mb-3;
}
.nc-stat-content {
@apply space-y-1;
}
.nc-stat-value {
@apply text-3xl font-bold text-nc-content-gray-emphasis leading-none;
}
.nc-stat-label {
@apply text-xs font-medium text-nc-content-gray-subtle2 uppercase tracking-wide;
}
.nc-version-list-wrapper {
@apply mt-6;
}
.nc-version-list-header {
@apply flex items-center justify-between mb-4;
}
.nc-version-list {
@apply space-y-2;
}
.nc-version-item {
@apply bg-nc-bg-default border-1 border-nc-border-gray-medium rounded-xl p-4 flex items-center justify-between gap-4 relative overflow-hidden;
@apply transition-all duration-200 ease-in-out;
&::before {
@apply absolute left-0 top-0 bottom-0 w-1 bg-nc-content-brand opacity-0;
@apply transition-opacity duration-200 ease-in-out;
content: '';
}
&.nc-version-item-clickable {
@apply cursor-pointer;
&:hover {
@apply border-nc-border-brand transform translate-x-0.5;
box-shadow: 0 4px 12px rgba(51, 102, 255, 0.08);
&::before {
@apply opacity-100;
}
.nc-version-icon {
@apply transform scale-105;
box-shadow: 0 4px 8px rgba(51, 102, 255, 0.15);
}
.nc-chevron-icon {
@apply opacity-100;
}
}
}
&:last-child {
@apply mb-0;
}
}
.nc-chevron-icon {
@apply opacity-0 transition-opacity duration-200 ease-in-out;
}
.nc-version-info {
@apply flex items-center gap-3 flex-1 min-w-0;
}
.nc-version-icon {
@apply w-9 h-9 rounded-lg bg-nc-bg-gray-light border-1 border-nc-border-gray-light;
@apply flex items-center justify-center text-nc-content-gray flex-shrink-0;
@apply transition-all duration-200 ease-in-out;
}
.nc-version-details {
@apply flex-1 min-w-0;
}
.nc-version-title {
@apply flex items-center gap-2 mb-1;
}
.nc-version-number {
@apply font-mono font-bold text-base text-nc-content-gray-emphasis;
}
.nc-version-badge {
@apply inline-flex items-center gap-1 px-2 py-0.5 rounded-full;
@apply bg-nc-bg-green-light text-nc-content-green-dark;
@apply text-xs font-semibold;
}
.nc-version-date {
@apply text-xs text-nc-content-gray-subtle2;
}
.nc-version-installs {
@apply flex items-center gap-3;
}
.nc-installs-count {
@apply flex items-center gap-2 text-sm;
@apply px-3 py-1.5 rounded-lg bg-nc-bg-gray-light;
}
.nc-deployments-empty {
@apply flex flex-col items-center justify-center py-16;
}
.nc-empty-icon {
@apply w-16 h-16 rounded-full bg-nc-bg-gray-light;
@apply flex items-center justify-center mb-4;
}
.nc-deployments-error {
@apply flex flex-col items-center justify-center py-16;
}
.nc-error-icon {
@apply w-16 h-16 rounded-full bg-nc-bg-red-light;
@apply flex items-center justify-center mb-4;
}
// Responsive
@media (max-width: 1024px) {
.nc-deployment-stats {
@apply grid-cols-2 gap-3;
}
}
@media (max-width: 640px) {
.nc-deployment-stats {
@apply grid-cols-1;
}
.nc-version-item {
@apply flex-col items-start;
}
.nc-version-installs {
@apply w-full justify-between;
}
}
</style>
<style lang="scss">
.nc-modal-managed-app-management {
@apply !p-0;
}
</style>

View File

@@ -0,0 +1,3 @@
<template>
<NcSpanHidden />
</template>

View File

@@ -1,513 +0,0 @@
<script setup lang="ts">
import { DeploymentStatus } from 'nocodb-sdk'
const props = defineProps<{
visible: boolean
managedApp: any
version: any
}>()
const emit = defineEmits(['update:visible'])
const { $api } = useNuxtApp()
const { base } = storeToRefs(useBase())
const isLoading = ref(false)
const deployments = ref<any[]>([])
const pageInfo = ref<any>({})
const currentPage = ref(1)
const pageSize = 10
// Expanded deployment logs state
const expandedBaseId = ref<string | null>(null)
const deploymentLogs = ref<any[]>([])
const logsPageInfo = ref<any>({})
const logsCurrentPage = ref(1)
const logsPageSize = 10
const isLoadingLogs = ref(false)
const loadDeployments = async (page = 1) => {
if (!props.managedApp?.id || !props.version?.versionId || !base.value?.fk_workspace_id) return
isLoading.value = true
try {
const offset = (page - 1) * pageSize
const response = await $api.internal.getOperation(base.value.fk_workspace_id, base.value.id!, {
operation: 'managedAppVersionDeployments',
managedAppId: props.managedApp.id,
versionId: props.version.versionId,
limit: pageSize,
offset,
} as any)
if (response) {
deployments.value = response.list || []
pageInfo.value = response.pageInfo || {}
currentPage.value = page
}
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
} finally {
isLoading.value = false
}
}
const loadDeploymentLogs = async (baseId: string, page = 1) => {
if (!base.value?.fk_workspace_id) return
isLoadingLogs.value = true
try {
const offset = (page - 1) * logsPageSize
const response = await $api.internal.getOperation(base.value.fk_workspace_id, base.value.id!, {
operation: 'managedAppDeploymentLogs',
baseId,
limit: logsPageSize,
offset,
} as any)
if (response) {
deploymentLogs.value = response.logs || []
logsPageInfo.value = response.pageInfo || {}
logsCurrentPage.value = page
}
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
} finally {
isLoadingLogs.value = false
}
}
const toggleExpand = async (deployment: any) => {
if (expandedBaseId.value === deployment.baseId) {
// Collapse
expandedBaseId.value = null
deploymentLogs.value = []
logsCurrentPage.value = 1
} else {
// Expand and load logs
expandedBaseId.value = deployment.baseId
await loadDeploymentLogs(deployment.baseId, 1)
}
}
const formatDate = (dateString: string) => {
if (!dateString) return 'N/A'
const date = new Date(dateString)
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(date)
}
const getDeploymentTypeLabel = (type: string) => {
const labels = {
install: 'Initial Install',
update: 'Update',
}
return labels[type as keyof typeof labels] || type
}
const getStatusColor = (status: string) => {
const colors = {
[DeploymentStatus.SUCCESS]: 'text-nc-content-green-dark bg-nc-bg-green-light',
[DeploymentStatus.FAILED]: 'text-nc-content-red-dark bg-nc-bg-red-light',
[DeploymentStatus.PENDING]: 'text-nc-content-orange-dark bg-nc-bg-orange-light',
[DeploymentStatus.IN_PROGRESS]: 'text-nc-content-blue-dark bg-nc-bg-blue-light',
}
return colors[status as keyof typeof colors] || 'text-nc-content-gray bg-nc-bg-gray-light'
}
const handlePageChange = (page: number) => {
loadDeployments(page)
}
const handleLogsPageChange = (page: number) => {
if (expandedBaseId.value) {
loadDeploymentLogs(expandedBaseId.value, page)
}
}
watch(
() => props.visible,
(val) => {
if (val) {
loadDeployments(1)
expandedBaseId.value = null
deploymentLogs.value = []
}
},
)
</script>
<template>
<GeneralModal :visible="visible" size="large" centered @update:visible="emit('update:visible', $event)">
<div class="nc-deployments-modal">
<!-- Header -->
<div class="nc-deployments-header">
<div class="nc-deployments-header-icon">
<GeneralIcon icon="ncServer" class="w-5 h-5 text-white" />
</div>
<div class="flex-1 min-w-0">
<h2 class="text-base font-semibold text-nc-content-gray-emphasis m-0">Version Deployments</h2>
<p class="text-xs text-nc-content-gray-subtle2 m-0 mt-0.5">
Tracking installations for
<span class="font-mono font-semibold text-nc-content-brand">v{{ version?.version }}</span>
</p>
</div>
<NcButton type="text" size="small" class="!px-1" @click="emit('update:visible', false)">
<GeneralIcon icon="close" class="w-4 h-4" />
</NcButton>
</div>
<!-- Content -->
<div class="nc-deployments-content">
<div v-if="isLoading" class="nc-deployments-loading">
<a-spin size="large" />
<div class="text-sm text-nc-content-gray-muted mt-3">Loading deployments...</div>
</div>
<template v-else-if="deployments.length > 0">
<div class="nc-deployments-list">
<div v-for="deployment in deployments" :key="deployment.baseId" class="nc-deployment-item">
<!-- Deployment Row -->
<div class="nc-deployment-row" @click="toggleExpand(deployment)">
<div class="nc-deployment-info">
<!-- Expand Icon -->
<div class="nc-expand-icon">
<GeneralIcon
:icon="expandedBaseId === deployment.baseId ? 'ncChevronDown' : 'ncChevronRight'"
class="w-4 h-4"
/>
</div>
<!-- Deployment Details -->
<div class="nc-deployment-icon">
<GeneralIcon icon="ncServer" class="w-4 h-4" />
</div>
<div class="nc-deployment-details">
<div class="nc-deployment-title">{{ deployment.baseTitle }}</div>
<div class="nc-deployment-date">
<GeneralIcon icon="calendar" class="w-3.5 h-3.5 opacity-60" />
<span>Installed {{ formatDate(deployment.installedAt) }}</span>
</div>
</div>
</div>
<!-- Status Badge -->
<div class="nc-deployment-status">
<div class="nc-status-badge" :class="getStatusColor(deployment.status)">
<span>{{ deployment.status }}</span>
</div>
</div>
</div>
<!-- Expanded Logs Section -->
<div v-if="expandedBaseId === deployment.baseId" class="nc-deployment-logs-wrapper">
<div class="nc-deployment-logs">
<div class="nc-logs-header">
<GeneralIcon icon="ncFileText" class="w-4 h-4 text-nc-content-gray-subtle2" />
<span>Deployment History</span>
</div>
<div v-if="isLoadingLogs" class="nc-logs-loading">
<a-spin size="small" />
<span class="text-xs text-nc-content-gray-muted ml-2">Loading history...</span>
</div>
<template v-else-if="deploymentLogs.length > 0">
<div class="nc-logs-list">
<div v-for="log in deploymentLogs" :key="log.id" class="nc-log-item">
<div class="nc-log-content">
<div class="nc-log-header">
<div class="nc-log-badges">
<div class="nc-log-status" :class="getStatusColor(log.status)">
{{ log.status }}
</div>
<div class="nc-log-type">
<GeneralIcon
:icon="log.deploymentType === 'install' ? 'download' : 'reload'"
class="w-3 h-3 opacity-60"
/>
<span>{{ getDeploymentTypeLabel(log.deploymentType) }}</span>
</div>
</div>
</div>
<div class="nc-log-meta">
<div class="nc-log-version">
<div v-if="log.fromVersion" class="flex items-center gap-1.5">
<span class="font-mono text-nc-content-gray-subtle2">v{{ log.fromVersion.version }}</span>
<GeneralIcon icon="arrowRight" class="w-3 h-3 text-nc-content-gray-subtle2" />
</div>
<span class="font-mono font-semibold text-nc-content-brand">v{{ log.toVersion?.version }}</span>
</div>
<span class="nc-log-divider"></span>
<div class="nc-log-time">
<GeneralIcon icon="clock" class="w-3.5 h-3.5 opacity-60" />
<span>{{ formatDate(log.createdAt) }}</span>
</div>
</div>
<div v-if="log.errorMessage" class="nc-log-error">
<GeneralIcon icon="alertTriangle" class="w-3.5 h-3.5 flex-shrink-0" />
<span>{{ log.errorMessage }}</span>
</div>
</div>
</div>
</div>
<!-- Logs Pagination -->
<div v-if="logsPageInfo.totalRows > logsPageSize" class="nc-logs-pagination">
<a-pagination
v-model:current="logsCurrentPage"
:total="logsPageInfo.totalRows"
:page-size="logsPageSize"
:show-size-changer="false"
size="small"
@change="handleLogsPageChange"
/>
</div>
</template>
<div v-else class="nc-logs-empty">
<GeneralIcon icon="inbox" class="w-8 h-8 text-nc-content-gray-subtle2 mb-2" />
<div class="text-sm text-nc-content-gray-subtle2">No deployment history available</div>
</div>
</div>
</div>
</div>
</div>
<!-- Deployments Pagination -->
<div v-if="pageInfo.totalRows > pageSize" class="nc-deployments-pagination">
<a-pagination
v-model:current="currentPage"
:total="pageInfo.totalRows"
:page-size="pageSize"
:show-size-changer="false"
@change="handlePageChange"
/>
</div>
</template>
<!-- Empty State -->
<div v-else class="nc-deployments-empty">
<div class="nc-empty-icon">
<GeneralIcon icon="ncServer" class="w-10 h-10 text-nc-content-gray-muted" />
</div>
<div class="text-base font-semibold text-nc-content-gray mb-1">No installations found</div>
<div class="text-sm text-nc-content-gray-subtle max-w-md text-center">
Version <span class="font-mono font-semibold">v{{ version?.version }}</span> hasn't been installed by any users yet
</div>
</div>
</div>
</div>
</GeneralModal>
</template>
<style lang="scss" scoped>
.nc-deployments-modal {
@apply flex flex-col h-full max-h-[80vh];
}
.nc-deployments-header {
@apply flex items-center gap-3 px-4 py-3 border-b-1 border-nc-border-gray-medium;
}
.nc-deployments-header-icon {
@apply w-9 h-9 rounded-lg flex items-center justify-center flex-shrink-0;
background: linear-gradient(135deg, var(--nc-content-brand) 0%, var(--nc-content-blue-medium) 100%);
box-shadow: 0 2px 4px rgba(51, 102, 255, 0.15);
}
.nc-deployments-content {
@apply flex-1 overflow-y-auto p-6;
}
.nc-deployments-loading {
@apply flex flex-col items-center justify-center py-16;
}
.nc-deployments-list {
@apply space-y-3;
}
.nc-deployment-item {
@apply bg-nc-bg-default border-1 border-nc-border-gray-medium rounded-xl overflow-hidden relative;
@apply transition-all duration-200 ease-in-out;
&::before {
@apply absolute left-0 top-0 bottom-0 w-1 bg-nc-content-brand opacity-0;
@apply transition-opacity duration-200 ease-in-out;
content: '';
}
&:hover {
@apply border-nc-border-brand transform translate-x-0.5;
box-shadow: 0 4px 12px rgba(51, 102, 255, 0.08);
&::before {
@apply opacity-100;
}
.nc-deployment-icon {
@apply transform scale-105;
box-shadow: 0 4px 8px rgba(51, 102, 255, 0.15);
}
}
}
.nc-deployment-row {
@apply flex items-center justify-between gap-4 p-4 cursor-pointer;
}
.nc-deployment-info {
@apply flex items-center gap-3 flex-1 min-w-0;
}
.nc-expand-icon {
@apply w-6 h-6 flex items-center justify-center text-nc-content-gray-subtle2 flex-shrink-0;
@apply transition-transform duration-200;
}
.nc-deployment-icon {
@apply w-9 h-9 rounded-lg bg-nc-bg-gray-light border-1 border-nc-border-gray-light;
@apply flex items-center justify-center text-nc-content-gray flex-shrink-0;
@apply transition-all duration-200 ease-in-out;
}
.nc-deployment-details {
@apply flex-1 min-w-0;
}
.nc-deployment-title {
@apply font-semibold text-sm text-nc-content-gray-emphasis truncate mb-1;
}
.nc-deployment-date {
@apply flex items-center gap-1.5 text-xs text-nc-content-gray-subtle2;
}
.nc-deployment-status {
@apply flex-shrink-0;
}
.nc-status-badge {
@apply inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold;
}
.nc-deployment-logs-wrapper {
@apply border-t-1 border-nc-border-gray-medium bg-nc-bg-gray-extralight;
}
.nc-deployment-logs {
@apply p-4;
}
.nc-logs-header {
@apply flex items-center gap-2 text-xs font-semibold text-nc-content-gray-emphasis mb-3;
@apply uppercase tracking-wide;
}
.nc-logs-loading {
@apply flex items-center justify-center py-8;
}
.nc-logs-list {
@apply space-y-2;
}
.nc-log-item {
@apply bg-nc-bg-default border-1 border-nc-border-gray-light rounded-lg p-3;
@apply transition-all duration-150;
&:hover {
@apply border-nc-border-gray-medium shadow-sm;
}
}
.nc-log-content {
@apply space-y-2;
}
.nc-log-header {
@apply flex items-center justify-between gap-2;
}
.nc-log-badges {
@apply flex items-center gap-2 flex-wrap;
}
.nc-log-status {
@apply inline-flex px-2 py-0.5 rounded text-xs font-semibold;
}
.nc-log-type {
@apply inline-flex items-center gap-1 px-2 py-0.5 rounded;
@apply bg-nc-bg-gray-light text-nc-content-gray text-xs font-medium;
}
.nc-log-meta {
@apply flex items-center gap-2 text-xs text-nc-content-gray-subtle2 flex-wrap;
}
.nc-log-version {
@apply flex items-center gap-1.5;
}
.nc-log-divider {
@apply text-nc-content-gray-subtle2;
}
.nc-log-time {
@apply flex items-center gap-1;
}
.nc-log-error {
@apply flex items-start gap-2 p-2 rounded-lg;
@apply bg-nc-bg-red-light text-nc-content-red-dark text-xs;
}
.nc-logs-pagination {
@apply flex justify-center mt-4 pt-4 border-t-1 border-nc-border-gray-light;
}
.nc-logs-empty {
@apply flex flex-col items-center justify-center py-12 text-center;
}
.nc-deployments-pagination {
@apply flex justify-center mt-6;
}
.nc-deployments-empty {
@apply flex flex-col items-center justify-center py-16;
}
.nc-empty-icon {
@apply w-16 h-16 rounded-full bg-nc-bg-gray-light;
@apply flex items-center justify-center mb-4;
}
// Responsive
@media (max-width: 640px) {
.nc-deployment-row {
@apply flex-col items-start;
}
.nc-deployment-info {
@apply w-full;
}
.nc-deployment-status {
@apply w-full;
}
.nc-log-meta {
@apply flex-col items-start gap-1;
}
}
</style>

View File

@@ -1,251 +1,16 @@
<script setup lang="ts">
import { FormBuilderValidatorType } from 'nocodb-sdk'
import { FORM_BUILDER_NON_CATEGORIZED, FormBuilderInputType } from '#imports'
const props = defineProps<{
defineProps<{
visible: boolean
baseId?: string
title?: string
subTitle?: string
alertDescription?: string
submitButtonText?: string
}>()
const emit = defineEmits(['update:visible'])
const visible = useVModel(props, 'visible', emit)
const { $api } = useNuxtApp()
const { t } = useI18n()
const { navigateToProject } = useGlobal()
const initialSanboxFormState = ref<Record<string, any>>({
title: '',
description: '',
category: '',
visibility: 'private',
startFrom: 'new',
baseId: undefined,
})
const workspaceStore = useWorkspace()
const { activeWorkspaceId } = storeToRefs(workspaceStore)
const basesStore = useBases()
const createManagedApp = async (formState: Record<string, any>) => {
try {
const response = await $api.internal.postOperation(
activeWorkspaceId.value as string,
formState.baseId || NO_SCOPE,
{
operation: 'managedAppCreate',
} as any,
{
title: formState.title,
description: formState.description,
category: formState.category,
visibility: formState.visibility,
...(!formState.baseId
? {
basePayload: {
title: formState.title,
default_role: '' as NcProject['default_role'],
meta: JSON.stringify({
iconColor: baseIconColors[Math.floor(Math.random() * 1000) % baseIconColors.length],
}),
},
}
: {}),
},
)
message.success(t('msg.success.managedAppCreated'))
visible.value = false
// Update the base with the managed_app_id from response
if (response && response.managed_app_id && formState.baseId) {
const currentBase = basesStore.bases.get(formState.baseId as string)
if (currentBase) {
;(currentBase as any).managed_app_id = response.managed_app_id
}
}
// Reload base to ensure all managed app data is loaded
if (formState.baseId) {
await basesStore.loadProject(formState.baseId, true)
} else {
await basesStore.loadProjects()
}
if (response?.base_id || formState.baseId) {
navigateToProject({
baseId: response?.base_id || formState.baseId,
workspaceId: activeWorkspaceId.value as string,
})
}
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
const { formState, isLoading, submit } = useProvideFormBuilderHelper({
formSchema: [
{
type: FormBuilderInputType.Input,
label: t('labels.managedAppTitle'),
span: 24,
model: 'title',
placeholder: 'Enter a descriptive title',
category: FORM_BUILDER_NON_CATEGORIZED,
validators: [
{
type: FormBuilderValidatorType.Required,
message: t('labels.titleRequired'),
},
{
type: FormBuilderValidatorType.Custom,
validator: baseTitleValidator('App').validator,
},
],
required: true,
},
{
type: FormBuilderInputType.Textarea,
label: t('labels.managedAppDescription'),
span: 24,
model: 'description',
placeholder: "Describe your application's capabilities",
category: FORM_BUILDER_NON_CATEGORIZED,
},
{
type: FormBuilderInputType.Select,
label: 'Start from',
span: 12,
model: 'startFrom',
category: FORM_BUILDER_NON_CATEGORIZED,
options: [
{ label: 'New', value: 'new', icon: 'plus' },
{ label: 'Existing Base', value: 'existing', icon: 'copy' },
],
defaultValue: 'new',
},
{
type: FormBuilderInputType.Space,
span: 12,
category: FORM_BUILDER_NON_CATEGORIZED,
condition: {
model: 'startFrom',
equal: 'new',
},
},
{
type: FormBuilderInputType.SelectBase,
label: 'Select base',
span: 12,
model: 'baseId',
category: FORM_BUILDER_NON_CATEGORIZED,
condition: {
model: 'startFrom',
equal: 'existing',
},
defaultValue: undefined,
filterOption: (base) => base && !base?.managed_app_id,
},
{
type: FormBuilderInputType.Input,
label: t('labels.managedAppCategory'),
span: 12,
model: 'category',
placeholder: 'e.g., CRM, HR',
category: FORM_BUILDER_NON_CATEGORIZED,
},
{
type: FormBuilderInputType.Select,
label: t('labels.managedAppVisibility'),
span: 12,
model: 'visibility',
category: FORM_BUILDER_NON_CATEGORIZED,
options: [
{ label: 'Public', value: 'public', icon: 'eye' },
{ label: 'Private', value: 'private', icon: 'lock' },
{ label: 'Unlisted', value: 'unlisted', icon: 'ncEyeOff' },
],
defaultValue: 'private',
},
],
onSubmit: async () => {
if (formState.value.startFrom === 'new' && formState.value.baseId) {
formState.value.baseId = ''
}
formState.value.title = formState.value.title.trim()
return await createManagedApp(formState.value)
},
initialState: initialSanboxFormState,
})
defineEmits(['update:visible'])
</script>
<template>
<div class="flex flex-col h-full">
<div class="p-4 w-full flex items-center gap-3 border-b border-nc-border-gray-medium">
<div class="nc-managed-app-icon">
<GeneralIcon icon="ncBox" class="h-5 w-5" />
</div>
<div class="flex-1">
<div class="font-semibold text-lg text-nc-content-gray-emphasis">Create Managed App</div>
<div class="text-xs text-nc-content-gray-subtle2">{{ $t('labels.publishToAppStore') }}</div>
</div>
<NcButton size="small" type="text" class="self-start" @click="visible = false">
<GeneralIcon icon="close" class="text-nc-content-gray-subtle2" />
</NcButton>
</div>
<div class="flex-1 p-6 nc-scrollbar-thin">
<NcFormBuilder>
<template #header>
<NcAlert
type="info"
align="top"
description="Create managed application that can be published to the App Store. You'll be able to manage versions and push updates to all installations."
class="!p-3 !items-start bg-nc-bg-blue-light border-1 !border-nc-blue-200 rounded-lg p-3 mb-4"
>
<template #icon>
<GeneralIcon icon="info" class="w-4 h-4 mt-0.5 text-nc-content-blue-dark flex-none" />
</template>
</NcAlert>
</template>
</NcFormBuilder>
</div>
<div class="flex justify-end gap-2 px-6 py-3 border-t border-nc-border-gray-medium">
<NcButton size="small" type="secondary" :disabled="isLoading" @click="visible = false">
{{ $t('general.cancel') }}
</NcButton>
<NcButton size="small" type="primary" :loading="isLoading" @click="submit">
<template #icon>
<GeneralIcon icon="ncBox" />
</template>
Create managed app
</NcButton>
</div>
</div>
<NcSpanHidden />
</template>
<style lang="scss">
.nc-modal-convert-to-managed-app {
.nc-modal {
max-height: min(90vh, 540px) !important;
height: min(90vh, 540px) !important;
}
}
</style>
<style lang="scss" scoped>
.nc-managed-app-icon {
@apply w-10 h-10 rounded-xl flex items-center justify-center text-white shadow-sm;
background: linear-gradient(135deg, var(--nc-content-brand) 0%, var(--nc-content-blue-medium) 100%);
box-shadow: 0 2px 4px rgba(51, 102, 255, 0.15);
}
</style>

View File

@@ -302,6 +302,7 @@
"refresh": "Refresh",
"reset": "Reset",
"install": "Install",
"installed": "Installed",
"show": "Show",
"access": "Access",
"visibility": "Visibility",
@@ -1185,6 +1186,9 @@
"updateDetails": "Update Details",
"published": "Published",
"draft": "Draft",
"live": "Live",
"upToDate": "Up to date",
"updateAvailable": "Update available",
"managedAppTitle": "Title",
"managedAppDescription": "Description",
"managedAppCategory": "Category",

View File

@@ -229,6 +229,8 @@ type NcProject = BaseType & {
managed_app_master?: boolean
managed_app_id?: string
managed_app_version_id?: string
managed_app_version?: string
managed_app_published_at?: string
auto_update?: boolean
managed_app_schema_locked?: boolean
}

View File

@@ -18,8 +18,16 @@ export const useBase = defineStore('baseStore', () => {
const basesStore = useBases()
const managedApp = ref<any>(null)
const managedAppVersions = ref<any[]>([])
const managedAppVersionsInfo = computed(() => {})
const isManagedAppMaster = ref(false)
const isManagedAppInstaller = ref(false)
const baseId = computed(() => {
// In shared base mode, use activeProjectId from basesStore which has the correct base ID
if (route.value.params.typeOrId === 'base') {
@@ -268,6 +276,10 @@ export const useBase = defineStore('baseStore', () => {
return `${basUrl}${projectPage ? `?page=${projectPage}` : ''}`
}
const loadManagedApp = async () => {}
const loadCurrentVersion = async () => {}
watch(
() => route.value.params.baseType,
(n) => {
@@ -343,6 +355,12 @@ export const useBase = defineStore('baseStore', () => {
isPrivateBase,
showBaseAccessRequestOverlay,
isManagedAppMaster,
isManagedAppInstaller,
managedApp,
loadManagedApp,
loadCurrentVersion,
managedAppVersions,
managedAppVersionsInfo,
}
})

View File

@@ -104,3 +104,20 @@ export const extractAiBaseCreateQueryParams = (query: any) => {
return searchQuery
}
export const suggestManagedAppNextVersion = (currentVersion?: string) => {
if (!currentVersion) {
return '1.0.0'
}
const versionParts = currentVersion.split('.')
if (versionParts.length === 3) {
// Increment minor version for new draft
versionParts[1] = String(Number(versionParts[1]) + 1)
versionParts[2] = '0'
return versionParts.join('.')
} else {
return ''
}
}

View File

@@ -127,8 +127,9 @@ export const OPERATION_SCOPES = {
// Managed App Operations
managedAppStoreList: 'org',
managedAppList: 'workspace',
managedAppGet: 'org',
managedAppVersionsList: 'org',
managedAppList: 'workspace',
managedAppCreate: 'workspace',
managedAppUpdate: 'base',
managedAppDelete: 'base',
@@ -137,7 +138,6 @@ export const OPERATION_SCOPES = {
managedAppUnpublish: 'base',
managedAppInstall: 'workspace',
managedAppGetUpdates: 'base',
managedAppVersionsList: 'base',
managedAppInstallationsList: 'base',
managedAppDeployments: 'base',
managedAppVersionDeployments: 'base',

View File

@@ -59,6 +59,9 @@ export default class Base implements BaseType {
managed_app_id?: string; // Points to MANAGED_APPS (for both master and installed instances)
managed_app_version_id?: string; // Current version ID from MANAGED_APP_VERSIONS
auto_update?: boolean; // For installed instances: auto-update to new published versions
// managed app info (populated fields)
managed_app_version?: string; // Current version string
managed_app_published_at?: string; // When this version was published
managed_app_schema_locked?: boolean; // Computed: whether schema modifications are allowed
constructor(base: Partial<Base>) {
@@ -69,8 +72,8 @@ export default class Base implements BaseType {
return base && new Base(base);
}
public static async computeSchemaLocked(_base: Base): Promise<boolean> {
return false;
public static async populateManagedAppInfo(_base: Base): Promise<void> {
return;
}
public static async createProject(
@@ -289,9 +292,8 @@ export default class Base implements BaseType {
}
const base = this.castType(baseData);
// Compute managed_app_schema_locked
if (base && base.managed_app_id) {
base.managed_app_schema_locked = await this.computeSchemaLocked(base);
await this.populateManagedAppInfo(base);
}
return base;
@@ -370,9 +372,8 @@ export default class Base implements BaseType {
if (baseData) {
const base = this.castType(baseData);
// Compute managed_app_schema_locked
if (base.managed_app_id) {
base.managed_app_schema_locked = await this.computeSchemaLocked(base);
await this.populateManagedAppInfo(base);
}
await base.getSources(includeConfig, ncMeta);