Browse Source

Nc fix/duplicate name update (#9573)

* fix(nc-gui): duplicated view name issue

* fix(nc-gui): duplicated webhook title issue

* fix(nc-gui): duplicated field name issue from MFE

* fix(nocodb): duplicate integration unique name issue

* fix(nc-gui): duplicate default view is throwing error in views are not loaded

* test(nc-gui): update copyview test case

* fix(nocodb): ai review changes

* test(nc-gui): update MFE duplicate field test case

* test(nc-gui): fix kanban view test fail issue
pull/9531/merge
Ramesh Mane 2 months ago committed by GitHub
parent
commit
46e6cff0a3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 15
      packages/nc-gui/components/dashboard/TreeView/TableNode.vue
  2. 15
      packages/nc-gui/components/dlg/ViewCreate.vue
  3. 2
      packages/nc-gui/components/smartsheet/details/Fields.vue
  4. 6
      packages/nc-gui/composables/useTableNew.ts
  5. 2
      packages/nc-gui/store/webhooks.ts
  6. 32
      packages/nc-gui/utils/generateName.ts
  7. 25
      packages/nocodb/src/services/integrations.service.ts
  8. 8
      tests/playwright/tests/db/features/multiFieldEditor.spec.ts
  9. 6
      tests/playwright/tests/db/views/viewKanban.spec.ts

15
packages/nc-gui/components/dashboard/TreeView/TableNode.vue

@ -218,8 +218,18 @@ const deleteTable = () => {
isOptionsOpen.value = false isOptionsOpen.value = false
isTableDeleteDialogVisible.value = true isTableDeleteDialogVisible.value = true
} }
const isOnDuplicateLoading = ref<boolean>(false)
async function onDuplicate() {
// Load views if not loaded
if (!viewsByTable.value.get(table.value.id as string)) {
isOnDuplicateLoading.value = true
await _openTable(table.value, undefined, false)
isOnDuplicateLoading.value = false
}
function onDuplicate() {
isOptionsOpen.value = false isOptionsOpen.value = false
const views = viewsByTable.value.get(table.value.id as string) const views = viewsByTable.value.get(table.value.id as string)
@ -469,7 +479,8 @@ const source = computed(() => {
<NcDivider /> <NcDivider />
<NcMenuItem class="!text-gray-700" @click="onDuplicate"> <NcMenuItem class="!text-gray-700" @click="onDuplicate">
<GeneralIcon class="nc-view-copy-icon" icon="duplicate" /> <GeneralLoader v-if="isOnDuplicateLoading" size="regular" />
<GeneralIcon v-else class="nc-view-copy-icon" icon="duplicate" />
{{ {{
$t('general.duplicateEntity', { $t('general.duplicateEntity', {
entity: $t('title.defaultView').toLowerCase(), entity: $t('title.defaultView').toLowerCase(),

15
packages/nc-gui/components/dlg/ViewCreate.vue

@ -77,6 +77,8 @@ const meta = ref<TableType | undefined>()
const inputEl = ref<ComponentPublicInstance>() const inputEl = ref<ComponentPublicInstance>()
const descriptionInputEl = ref<ComponentPublicInstance>()
const formValidator = ref<typeof AntForm>() const formValidator = ref<typeof AntForm>()
const vModel = useVModel(props, 'modelValue', emits) const vModel = useVModel(props, 'modelValue', emits)
@ -151,12 +153,17 @@ watch(
function init() { function init() {
form.title = `${capitalize(typeAlias.value)}` form.title = `${capitalize(typeAlias.value)}`
if (selectedViewId.value) {
form.copy_from_id = selectedViewId?.value
const selectedViewName = views.value.find((v) => v.id === selectedViewId.value)?.title || form.title
form.title = generateUniqueTitle(`${selectedViewName} copy`, views.value, 'title', '_', true)
} else {
const repeatCount = views.value.filter((v) => v.title.startsWith(form.title)).length const repeatCount = views.value.filter((v) => v.title.startsWith(form.title)).length
if (repeatCount) { if (repeatCount) {
form.title = `${form.title}-${repeatCount}` form.title = `${form.title}-${repeatCount}`
} }
if (selectedViewId.value) {
form.copy_from_id = selectedViewId?.value
} }
nextTick(() => { nextTick(() => {
@ -259,7 +266,7 @@ const toggleDescription = () => {
} else { } else {
enableDescription.value = true enableDescription.value = true
setTimeout(() => { setTimeout(() => {
inputEl.value?.focus() descriptionInputEl.value?.focus()
}, 100) }, 100)
} }
} }
@ -766,7 +773,7 @@ const isCalendarReadonly = (calendarRange?: Array<{ fk_from_column_id: string; f
</div> </div>
<a-textarea <a-textarea
ref="inputEl" ref="descriptionInputEl"
v-model:value="form.description" v-model:value="form.description"
class="nc-input-sm nc-input-text-area nc-input-shadow px-3 !text-gray-800 max-h-[150px] min-h-[100px]" class="nc-input-sm nc-input-text-area nc-input-shadow px-3 !text-gray-800 max-h-[150px] min-h-[100px]"
hide-details hide-details

2
packages/nc-gui/components/smartsheet/details/Fields.vue

@ -253,7 +253,7 @@ const duplicateField = async (field: TableExplorerColumn) => {
if (!localMetaColumns.value) return if (!localMetaColumns.value) return
// generate duplicate column name // generate duplicate column name
const duplicateColumnName = getUniqueColumnName(`${field.title}_copy`, localMetaColumns.value) const duplicateColumnName = getUniqueColumnName(`${field.title} copy`, [...localMetaColumns.value, ...newFields.value])
let fieldPayload = {} let fieldPayload = {}

6
packages/nc-gui/composables/useTableNew.ts

@ -44,7 +44,7 @@ export function useTableNew(param: { onTableCreate?: (tableMeta: TableType) => v
const tables = computed(() => baseTables.value.get(param.baseId) || []) const tables = computed(() => baseTables.value.get(param.baseId) || [])
const base = computed(() => bases.value.get(param.baseId)) const base = computed(() => bases.value.get(param.baseId))
const openTable = async (table: SidebarTableNode, cmdOrCtrl: boolean = false) => { const openTable = async (table: SidebarTableNode, cmdOrCtrl: boolean = false, navigate: boolean = true) => {
if (!table.base_id) return if (!table.base_id) return
let base = bases.value.get(table.base_id) let base = bases.value.get(table.base_id)
@ -69,7 +69,7 @@ export function useTableNew(param: { onTableCreate?: (tableMeta: TableType) => v
} }
const navigateToTable = async () => { const navigateToTable = async () => {
if (openedViewsTab.value === 'view') { if (navigate && openedViewsTab.value === 'view') {
await navigateTo( await navigateTo(
`${cmdOrCtrl ? '#' : ''}/${workspaceIdOrType}/${baseIdOrBaseId}/${table?.id}`, `${cmdOrCtrl ? '#' : ''}/${workspaceIdOrType}/${baseIdOrBaseId}/${table?.id}`,
cmdOrCtrl cmdOrCtrl
@ -86,7 +86,7 @@ export function useTableNew(param: { onTableCreate?: (tableMeta: TableType) => v
await loadViews({ tableId: table.id as string }) await loadViews({ tableId: table.id as string })
const views = viewsByTable.value.get(table.id as string) ?? [] const views = viewsByTable.value.get(table.id as string) ?? []
if (openedViewsTab.value !== 'view' && views.length && views[0].id) { if (navigate && openedViewsTab.value !== 'view' && views.length && views[0].id) {
// find the default view and navigate to it, if not found navigate to the first one // find the default view and navigate to it, if not found navigate to the first one
const defaultView = views.find((v) => v.is_default) || views[0] const defaultView = views.find((v) => v.is_default) || views[0]

2
packages/nc-gui/store/webhooks.ts

@ -68,7 +68,7 @@ export const useWebhooksStore = defineStore('webhooksStore', () => {
try { try {
const newHook = await $api.dbTableWebhook.create(hook.fk_model_id!, { const newHook = await $api.dbTableWebhook.create(hook.fk_model_id!, {
...hook, ...hook,
title: `${hook.title} - Copy`, title: generateUniqueTitle(`${hook.title} copy`, hooks.value, 'title', '_', true),
active: hook.event === 'manual', active: hook.event === 'manual',
} as HookReqType) } as HookReqType)

32
packages/nc-gui/utils/generateName.ts

@ -8,17 +8,45 @@ export const generateUniqueName = async () => {
.replace(/[ -]/g, '_') .replace(/[ -]/g, '_')
} }
/**
* Generates a unique title by appending an incremented number if a title with the same name already exists in the array.
* This can be useful when you need to ensure unique names (e.g., for file names, database entries, etc.) in a list.
*
* @template T - The type of items in the array, defaults to a generic record.
* @param title - The initial title to check for uniqueness.
* @param arr - The array of objects where the title should be unique.
* @param predicate - The key of the object to check uniqueness against (e.g., the property of the object that stores the title).
* @param splitOperator - The character or string used to split the title and the appended number (defaults to `'-'`).
* @param startFromZero - If `true`, it checks if the title can exist without any appended number and starts counting from 0 (if `false`, it starts from 1).
* @returns A unique title string, with an appended number if necessary.
*
* @example
* const items = [{ name: 'Project' }, { name: 'Project-1' }];
* const uniqueTitle = generateUniqueTitle('Project', items, 'name');
* console.log(uniqueTitle); // 'Project-2'
*/
export const generateUniqueTitle = <T extends Record<string, any> = Record<string, any>>( export const generateUniqueTitle = <T extends Record<string, any> = Record<string, any>>(
title: string, title: string,
arr: T[], arr: T[],
predicate: keyof T, predicate: keyof T,
splitOperator: string = '-',
startFromZero: boolean = false,
) => { ) => {
// If we start from zero and the title is not already in the array, return the title as is.
if (startFromZero && !arr.map((item) => item[predicate]).includes(title as T[keyof T])) {
return title
}
// Counter to append to the title if necessary.
let c = 1 let c = 1
while (arr.some((item) => item[predicate].includes(`${title}-${c}` as keyof T))) {
// Keep incrementing the counter until a unique title is found.
while (arr.some((item) => item[predicate].includes(`${title}${splitOperator}${c}` as keyof T))) {
c++ c++
} }
return `${title}-${c}` // Return the unique title with the incremented number appended.
return `${title}${splitOperator}${c}`
} }
export const generateRandomNumber = () => { export const generateRandomNumber = () => {

25
packages/nocodb/src/services/integrations.service.ts

@ -15,6 +15,7 @@ import NcConnectionMgrv2 from '~/utils/common/NcConnectionMgrv2';
import { JobsRedis } from '~/modules/jobs/redis/jobs-redis'; import { JobsRedis } from '~/modules/jobs/redis/jobs-redis';
import { InstanceCommands } from '~/interface/Jobs'; import { InstanceCommands } from '~/interface/Jobs';
import { SourcesService } from '~/services/sources.service'; import { SourcesService } from '~/services/sources.service';
import { generateUniqueName } from '~/helpers/exportImportHelpers';
@Injectable() @Injectable()
export class IntegrationsService { export class IntegrationsService {
@ -257,11 +258,29 @@ export class IntegrationsService {
} }
} }
let uniqueTitle = '';
if (param.integration.copy_from_id) {
const integrations =
(
await Integration.list({
userId: param.req.user?.id,
limit: 1000,
offset: 0,
includeSourceCount: false,
query: '',
})
).list || [];
uniqueTitle = generateUniqueName(
`${integrationBody.title} copy`,
integrations.map((p) => p.title),
);
}
const integration = await Integration.createIntegration({ const integration = await Integration.createIntegration({
...integrationBody, ...integrationBody,
...(param.integration.copy_from_id ...(param.integration.copy_from_id ? { title: uniqueTitle } : {}),
? { title: `${integrationBody.title}_copy` }
: {}),
created_by: param.req.user.id, created_by: param.req.user.id,
}); });

8
tests/playwright/tests/db/features/multiFieldEditor.spec.ts

@ -250,20 +250,20 @@ test.describe('Multi Field Editor', () => {
await fields.saveChanges(); await fields.saveChanges();
let fieldsText = await fields.getAllFieldText(); let fieldsText = await fields.getAllFieldText();
expect(fieldsText[fieldsText.findIndex(field => field === defaultFieldName) + 1]).toBe(`${defaultFieldName}_copy`); expect(fieldsText[fieldsText.findIndex(field => field === defaultFieldName) + 1]).toBe(`${defaultFieldName} copy`);
// insert and verify // insert and verify
await fields.createOrUpdate({ title: 'Above Inserted Field', insertAboveColumnTitle: defaultFieldName }); await fields.createOrUpdate({ title: 'Above Inserted Field', insertAboveColumnTitle: defaultFieldName });
await fields.createOrUpdate({ title: 'Below Inserted Field', insertBelowColumnTitle: defaultFieldName }); await fields.createOrUpdate({ title: 'Below Inserted Field', insertBelowColumnTitle: defaultFieldName });
// delete and verify // delete and verify
await fields.selectFieldAction({ title: `${defaultFieldName}_copy`, action: 'delete' }); await fields.selectFieldAction({ title: `${defaultFieldName} copy`, action: 'delete' });
await expect(fields.getField({ title: `${defaultFieldName}_copy` })).toContainText('Deleted field'); await expect(fields.getField({ title: `${defaultFieldName} copy` })).toContainText('Deleted field');
await fields.saveChanges(); await fields.saveChanges();
fieldsText = await fields.getAllFieldText(); fieldsText = await fields.getAllFieldText();
expect(!fieldsText.includes(`${defaultFieldName}_copy`)).toBeTruthy(); expect(!fieldsText.includes(`${defaultFieldName} copy`)).toBeTruthy();
// verify grid column header // verify grid column header
await verifyGridColumnHeaders({ fields: fieldsText }); await verifyGridColumnHeaders({ fields: fieldsText });

6
tests/playwright/tests/db/views/viewKanban.spec.ts

@ -229,7 +229,7 @@ test.describe('View', () => {
await dashboard.viewSidebar.copyView({ title: 'Film Kanban' }); await dashboard.viewSidebar.copyView({ title: 'Film Kanban' });
await dashboard.viewSidebar.verifyView({ await dashboard.viewSidebar.verifyView({
title: 'Kanban', title: 'Film Kanban copy',
index: 1, index: 1,
}); });
const kanban = dashboard.kanban; const kanban = dashboard.kanban;
@ -255,12 +255,12 @@ test.describe('View', () => {
}); });
await dashboard.viewSidebar.changeViewIcon({ await dashboard.viewSidebar.changeViewIcon({
title: 'Kanban', title: 'Film Kanban copy',
icon: 'american-football', icon: 'american-football',
iconDisplay: '🏈', iconDisplay: '🏈',
}); });
await dashboard.viewSidebar.deleteView({ title: 'Kanban' }); await dashboard.viewSidebar.deleteView({ title: 'Film Kanban copy' });
/////////////////////////////////////////////// ///////////////////////////////////////////////
await dashboard.viewSidebar.openView({ title: 'Film Kanban' }); await dashboard.viewSidebar.openView({ title: 'Film Kanban' });

Loading…
Cancel
Save