diff --git a/packages/nc-gui/components/smartsheet/expanded-form/index.vue b/packages/nc-gui/components/smartsheet/expanded-form/index.vue index bab06b1ff8..e801b549fb 100644 --- a/packages/nc-gui/components/smartsheet/expanded-form/index.vue +++ b/packages/nc-gui/components/smartsheet/expanded-form/index.vue @@ -355,10 +355,15 @@ const save = async () => { if (props.blueprintMode) { isUnsavedFormExist.value = false isExpanded.value = false - emits('createdRecord', { + const blueprintData: Record = { ..._row.value.row, _isBlueprint: true, - }) + } + // Include nested ltarState so sub-blueprints (e.g., Tasks → Sub-tasks) are preserved + if (rowState.value && Object.keys(rowState.value).length) { + blueprintData._ltarState = rowState.value + } + emits('createdRecord', blueprintData) isSaving.value = false return } diff --git a/packages/nc-gui/components/smartsheet/grid/canvas/components/AddNewRowMenu.vue b/packages/nc-gui/components/smartsheet/grid/canvas/components/AddNewRowMenu.vue index 6bf72cc35c..017dfe296d 100644 --- a/packages/nc-gui/components/smartsheet/grid/canvas/components/AddNewRowMenu.vue +++ b/packages/nc-gui/components/smartsheet/grid/canvas/components/AddNewRowMenu.vue @@ -25,10 +25,13 @@ const { t } = useI18n() const { templates: allTemplates, selectedTemplate, setSelectedTemplate } = useRecordTemplate() -// Filter to only enabled templates for this menu -const templates = computed(() => allTemplates.value.filter((t: any) => t.enabled !== false)) +// Filter to only enabled templates for the current table +const templates = computed(() => + allTemplates.value.filter((t: any) => t.enabled !== false && t.source_id === meta.value?.id), +) const { $api } = useNuxtApp() +const { getMeta } = useMetas() const reloadViewDataHook = inject(ReloadViewDataHookInj, createEventHook()) @@ -43,6 +46,7 @@ const handleUseTemplate = async (tmpl: any) => { (meta.value.columns || []) as ColumnType[], $api, base.value.id, + getMeta, ) // Create record via standard row creation API (handles LTAR/Links natively) diff --git a/packages/nc-gui/components/smartsheet/grid/canvas/index.vue b/packages/nc-gui/components/smartsheet/grid/canvas/index.vue index 11ea4bdb74..4d7810f4ba 100644 --- a/packages/nc-gui/components/smartsheet/grid/canvas/index.vue +++ b/packages/nc-gui/components/smartsheet/grid/canvas/index.vue @@ -765,6 +765,7 @@ async function onSelectedTemplateClick() { (meta.value.columns || []) as ColumnType[], $api, base.value.id, + getMeta, ) await $api.dbTableRow.create('noco', base.value.id, meta.value.id, { diff --git a/packages/nc-gui/components/smartsheet/toolbar/RecordTemplatesButton.vue b/packages/nc-gui/components/smartsheet/toolbar/RecordTemplatesButton.vue index f371ccb1bc..8fd91acfa4 100644 --- a/packages/nc-gui/components/smartsheet/toolbar/RecordTemplatesButton.vue +++ b/packages/nc-gui/components/smartsheet/toolbar/RecordTemplatesButton.vue @@ -9,6 +9,7 @@ interface TemplateType { title: string description?: string template_data: Record | string + source_id?: string usage_count?: number enabled?: boolean created_by?: string @@ -19,21 +20,37 @@ const isLocked = inject(IsLockedInj, ref(false)) const { meta } = useSmartsheetStoreOrThrow() const { base } = storeToRefs(useBase()) +const { baseTables } = storeToRefs(useTablesStore()) +const { getMeta } = useMetas() const { $api } = useNuxtApp() const { t } = useI18n() const reloadViewDataHook = inject(ReloadViewDataHookInj, createEventHook()) const { open: openExpandedForm } = useExpandedFormDetached() +const getTableName = (sourceId?: string) => { + if (!sourceId || !base.value?.id) return '' + const tables = baseTables.value.get(base.value.id) || [] + return tables.find((t) => t.id === sourceId)?.title || '' +} + // --- State --- const { showRecordTemplateManager: showManager, templates } = useRecordTemplate() const showDeleteConfirm = ref(false) const isLoading = ref(false) const templateToDelete = ref(null) const searchQuery = ref('') +const selectedTableFilter = ref('') const orderBy = ref>({ title: 'asc' }) const currentPage = ref(1) const PAGE_SIZE = 5 +// Tables that have at least one template +const tablesWithTemplates = computed(() => { + const tableIds = new Set(templates.value.map((t) => t.source_id).filter(Boolean)) + const tables = baseTables.value.get(base.value?.id || '') || [] + return tables.filter((t) => tableIds.has(t.id)) +}) + // --- Table Columns --- const columns = computed(() => [ { @@ -49,6 +66,13 @@ const columns = computed(() => [ dataIndex: 'title', showOrderBy: true, }, + { + key: 'table', + title: t('objects.table'), + minWidth: 140, + dataIndex: 'source_id', + showOrderBy: true, + }, { key: 'created_at', title: 'Added On', @@ -78,10 +102,17 @@ const columns = computed(() => [ const filteredTemplates = computed(() => { let result = [...templates.value] + // Apply table filter + if (selectedTableFilter.value) { + result = result.filter((t) => t.source_id === selectedTableFilter.value) + } + // Apply search filter if (searchQuery.value.trim()) { const query = searchQuery.value.trim().toLowerCase() - result = result.filter((t) => t.title?.toLowerCase().includes(query)) + result = result.filter( + (t) => t.title?.toLowerCase().includes(query) || getTableName(t.source_id).toLowerCase().includes(query), + ) } // Apply sort from NcTable orderBy @@ -90,8 +121,8 @@ const filteredTemplates = computed(() => { const sortKey = sortKeys[0] as keyof TemplateType const sortDir = orderBy.value[sortKeys[0]] result.sort((a, b) => { - let aVal: any = a[sortKey] ?? '' - let bVal: any = b[sortKey] ?? '' + let aVal: any = sortKey === 'source_id' ? getTableName(a.source_id) : (a[sortKey] ?? '') + let bVal: any = sortKey === 'source_id' ? getTableName(b.source_id) : (b[sortKey] ?? '') if (typeof aVal === 'number' && typeof bVal === 'number') { return sortDir === 'asc' ? aVal - bVal : bVal - aVal @@ -118,8 +149,8 @@ const paginatedTemplates = computed(() => { return filteredTemplates.value.slice(start, start + PAGE_SIZE) }) -// Reset to page 1 when search or sort changes -watch([searchQuery, orderBy], () => { +// Reset to page 1 when search, sort, or table filter changes +watch([searchQuery, orderBy, selectedTableFilter], () => { currentPage.value = 1 }) @@ -137,10 +168,14 @@ const nextTemplateNumber = computed(() => { // --- API --- const loadTemplates = async () => { - if (!base.value?.id || !meta.value?.id) return + if (!base.value?.id) return isLoading.value = true try { - const response = await $api.recordTemplates.recordTemplateList(base.value.id, meta.value.id) + const response = await ($api as any).request({ + path: `/api/v2/meta/bases/${base.value.id}/record-templates/all`, + method: 'GET', + format: 'json', + }) templates.value = (response as any)?.list || [] } catch (e: any) { console.error(e) @@ -174,9 +209,10 @@ const saveTemplate = async (rowData: Record, editingTmpl: TemplateT // Extract template name from the special _templateName field const title = rowData._templateName?.trim() || `Record Template #${nextTemplateNumber.value}` - // Enforce unique template name (client-side check) + // Enforce unique template name per table (client-side check) const duplicate = templates.value.find( - (t) => t.title?.trim().toLowerCase() === title.toLowerCase() && t.id !== editingTmpl?.id, + (t) => + t.title?.trim().toLowerCase() === title.toLowerCase() && t.id !== editingTmpl?.id && t.source_id === tableId, ) if (duplicate) { message.toast(`A template with the name "${title}" already exists`) @@ -233,15 +269,28 @@ const openManager = () => { showManager.value = true } -const openTemplateForm = (editingTmpl: TemplateType | null = null) => { +const openTemplateForm = async (editingTmpl: TemplateType | null = null) => { const { fields: existingFields, ltarState } = editingTmpl ? parseRecordTemplateData(editingTmpl) : { fields: {}, ltarState: {} } const templateName = editingTmpl?.title || `Record Template #${nextTemplateNumber.value}` - // Collect existing template names for duplicate validation (exclude current template when editing) + // Resolve the table meta for this template (may be a different table than the current one) + let tableMeta: TableType | undefined + if (editingTmpl?.source_id && editingTmpl.source_id !== meta.value?.id) { + try { + tableMeta = (await getMeta(base.value!.id!, editingTmpl.source_id)) as TableType + } catch { + message.toast('Failed to load table metadata for this template') + return + } + } + tableMeta = tableMeta || (meta.value as TableType) + + // Collect existing template names for duplicate validation per table (exclude current template when editing) + const templateTableId = editingTmpl?.source_id || tableMeta.id const existingTemplateNames = templates.value - .filter((t) => t.id !== editingTmpl?.id) + .filter((t) => t.id !== editingTmpl?.id && t.source_id === templateTableId) .map((t) => t.title || '') openExpandedForm({ @@ -251,7 +300,7 @@ const openTemplateForm = (editingTmpl: TemplateType | null = null) => { oldRow: {}, rowMeta: { new: true, ltarState: Object.keys(ltarState).length ? ltarState : undefined }, }, - meta: meta.value as TableType, + meta: tableMeta, state: Object.keys(ltarState).length ? ltarState : undefined, useMetaFields: true, skipReload: true, @@ -286,20 +335,30 @@ const onDeleteConfirm = async () => { } const handleUseTemplate = async (tmpl: TemplateType) => { - if (!tmpl.id || !base.value?.id || !meta.value?.id) return + if (!tmpl.id || !base.value?.id) return + const tableId = tmpl.source_id || meta.value?.id + if (!tableId) return try { + // Resolve the table meta for this template + let tableMeta: TableType | undefined + if (tmpl.source_id && tmpl.source_id !== meta.value?.id) { + tableMeta = (await getMeta(base.value.id!, tmpl.source_id)) as TableType + } + tableMeta = tableMeta || (meta.value as TableType) + const { fields, ltarState } = parseRecordTemplateData(tmpl) // Resolve any blueprint records — create real records in linked tables first const resolvedLtarState = await resolveBlueprintsInLtarState( ltarState, - (meta.value.columns || []) as ColumnType[], + (tableMeta.columns || []) as ColumnType[], $api, base.value.id, + getMeta, ) // Create record via standard row creation API (handles LTAR/Links natively) - await $api.dbTableRow.create('noco', base.value.id, meta.value.id, { + await $api.dbTableRow.create('noco', base.value.id, tableId, { ...fields, ...resolvedLtarState, }) @@ -378,7 +437,7 @@ const customRow = (record: Record) => ({ - +
) => ({ + + +
+ + {{ selectedTableFilter ? getTableName(selectedTableFilter) : 'All Tables' }} + +
+
+ +
@@ -417,6 +512,12 @@ const customRow = (record: Record) => ({ + + + + {{ getTableName(tmpl.source_id) }} + + diff --git a/packages/nc-gui/components/virtual-cell/components/ItemChip.vue b/packages/nc-gui/components/virtual-cell/components/ItemChip.vue index 6096496ac8..01e7a85248 100644 --- a/packages/nc-gui/components/virtual-cell/components/ItemChip.vue +++ b/packages/nc-gui/components/virtual-cell/components/ItemChip.vue @@ -59,11 +59,13 @@ function openBlueprintEditor() { if (isClickDisabled.value) return // Clone the blueprint data (exclude internal flags for the form) - const { _isBlueprint, ...blueprintData } = item.value + const { _isBlueprint, _ltarState, ...blueprintData } = item.value + const nestedLtarState = _ltarState && Object.keys(_ltarState).length ? _ltarState : undefined open({ isOpen: true, - row: { row: { ...blueprintData }, oldRow: {}, rowMeta: { new: true } }, + row: { row: { ...blueprintData }, oldRow: {}, rowMeta: { new: true, ltarState: nestedLtarState } }, + state: nestedLtarState, meta: relatedTableMeta.value, loadRow: false, useMetaFields: true, diff --git a/packages/nc-gui/composables/useRecordTemplate.ts b/packages/nc-gui/composables/useRecordTemplate.ts index f0b4acee3b..98e0490ab8 100644 --- a/packages/nc-gui/composables/useRecordTemplate.ts +++ b/packages/nc-gui/composables/useRecordTemplate.ts @@ -57,7 +57,12 @@ export async function resolveBlueprintsInLtarState( columns: ColumnType[], api: any, baseId: string, + getMeta?: (baseId: string, tableId: string) => Promise, + depth: number = 0, ): Promise> { + // Guard against infinite recursion (max 3 levels deep) + if (depth > 3) return {} + const resolvedState: Record = {} for (const [colTitle, linkedData] of Object.entries(ltarState)) { @@ -81,9 +86,8 @@ export async function resolveBlueprintsInLtarState( const resolvedItems = [] for (const item of linkedData) { if (item?._isBlueprint) { - const { _isBlueprint, ...recordData } = item try { - const created = await api.dbTableRow.create('noco', baseId, relatedTableId, recordData) + const created = await resolveSingleBlueprint(item, relatedTableId, api, baseId, getMeta, depth) resolvedItems.push(created) } catch (e: any) { console.error(`Failed to create blueprint record in table ${relatedTableId}:`, e) @@ -95,9 +99,8 @@ export async function resolveBlueprintsInLtarState( resolvedState[colTitle] = resolvedItems } else if (linkedData?._isBlueprint) { // BT or OO — single linked record - const { _isBlueprint, ...recordData } = linkedData try { - const created = await api.dbTableRow.create('noco', baseId, relatedTableId, recordData) + const created = await resolveSingleBlueprint(linkedData, relatedTableId, api, baseId, getMeta, depth) resolvedState[colTitle] = created } catch (e: any) { console.error(`Failed to create blueprint record in table ${relatedTableId}:`, e) @@ -109,3 +112,36 @@ export async function resolveBlueprintsInLtarState( return resolvedState } + +/** + * Resolve a single blueprint record: if it has nested _ltarState, recursively resolve those first, + * then create the record with resolved nested links. + */ +async function resolveSingleBlueprint( + blueprint: Record, + relatedTableId: string, + api: any, + baseId: string, + getMeta?: (baseId: string, tableId: string) => Promise, + depth: number = 0, +): Promise { + const { _isBlueprint, _ltarState, ...recordData } = blueprint + + // If this blueprint has nested blueprints, resolve them first + if (_ltarState && Object.keys(_ltarState).length && getMeta) { + const relatedMeta = await getMeta(baseId, relatedTableId) + const relatedColumns = relatedMeta?.columns || [] + const resolvedNestedState = await resolveBlueprintsInLtarState( + _ltarState, + relatedColumns, + api, + baseId, + getMeta, + depth + 1, + ) + // Merge resolved nested links into the record data + Object.assign(recordData, resolvedNestedState) + } + + return await api.dbTableRow.create('noco', baseId, relatedTableId, recordData) +} diff --git a/packages/nocodb/src/controllers/record-templates.controller.ts b/packages/nocodb/src/controllers/record-templates.controller.ts index 683953534b..c5c31521e7 100644 --- a/packages/nocodb/src/controllers/record-templates.controller.ts +++ b/packages/nocodb/src/controllers/record-templates.controller.ts @@ -27,6 +27,25 @@ export class RecordTemplatesController { private readonly recordTemplatesService: RecordTemplatesService, ) {} + @Get([ + '/api/v1/db/meta/bases/:baseId/record-templates', + '/api/v2/meta/bases/:baseId/record-templates/all', + ]) + @Acl('recordTemplateList') + async listAll( + @TenantContext() context: NcContext, + @Req() req: NcRequest, + @Param('baseId') baseId: string, + ) { + return new PagedResponseImpl( + await this.recordTemplatesService.list({ + context, + baseId, + req, + }), + ); + } + @Get([ '/api/v1/db/meta/bases/:baseId/tables/:sourceId/record-templates', '/api/v2/meta/bases/:baseId/tables/:sourceId/record-templates',