Browse Source

Merge branch 'develop' into enhancement/filters

pull/5106/head
Wing-Kam Wong 2 years ago
parent
commit
ca5f4d7c3f
  1. 34
      .github/workflows/cleanup-caches-by-branch.yml
  2. 2
      package.json
  3. 2
      packages/nc-gui/assets/style.scss
  4. 1
      packages/nc-gui/components.d.ts
  5. 51
      packages/nc-gui/components/smartsheet/Grid.vue
  6. 11
      packages/nc-gui/components/smartsheet/header/Menu.vue
  7. 33
      packages/nc-gui/components/smartsheet/toolbar/FieldsMenu.vue
  8. 10
      packages/nc-gui/composables/useViewColumns.ts
  9. 2
      packages/nocodb/src/lib/Noco.ts
  10. 49
      packages/nocodb/src/lib/db/sql-client/lib/KnexClient.ts
  11. 263
      packages/nocodb/src/lib/meta/api/baseApis.ts
  12. 57
      packages/nocodb/src/lib/meta/api/columnApis.ts
  13. 3
      packages/nocodb/src/lib/meta/api/helpers/index.ts
  14. 278
      packages/nocodb/src/lib/meta/api/helpers/populateMeta.ts
  15. 5
      packages/nocodb/src/lib/meta/api/metaDiffApis.ts
  16. 265
      packages/nocodb/src/lib/meta/api/projectApis.ts
  17. 2
      packages/nocodb/src/lib/models/GridViewColumn.ts
  18. 17
      packages/nocodb/src/lib/models/Model.ts
  19. 159
      packages/nocodb/src/lib/models/View.ts
  20. 2
      packages/nocodb/src/lib/version-upgrader/NcUpgrader.ts
  21. 90
      packages/nocodb/src/lib/version-upgrader/ncStickyColumnUpgrader.ts
  22. 18
      tests/playwright/pages/Dashboard/Grid/Column/index.ts
  23. 4
      tests/playwright/pages/Dashboard/common/Toolbar/Fields.ts
  24. 2
      tests/playwright/quickTests/commonTest.ts
  25. 2
      tests/playwright/tests/cellSelection.spec.ts
  26. 2
      tests/playwright/tests/columnMenuOperations.spec.ts
  27. 2
      tests/playwright/tests/metaSync.spec.ts

34
.github/workflows/cleanup-caches-by-branch.yml

@ -0,0 +1,34 @@
name: cleanup caches by branch
on:
pull_request:
types:
- closed
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v3
- name: Cleanup
run: |
gh extension install actions/gh-actions-cache
REPO=${{ github.repository }}
# get the branch
BRANCH="refs/pull/${{ github.event.pull_request.number }}/merge"
# fetch list of cache key
cacheKeysForPR=$(gh actions-cache list -R $REPO -B $BRANCH | cut -f 1 )
# set this to not fail the workflow while deleting cache keys
set +e
# delete cache key
for cacheKey in $cacheKeysForPR
do
gh actions-cache delete $cacheKey -R $REPO -B $BRANCH --confirm
done
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

2
package.json

@ -43,6 +43,8 @@
"install:local:dep": "cd packages/nc-lib-gui;npm uninstall -S xc-lib;rm package-lock.json; npm i ../../../xc-lib-private; cd ../xc-instant;npm uninstall -S xc-lib xc-lib-gui;npm i ../../../xc-lib-private;npm i ../xc-lib-gui",
"install:npm:dep": "cd packages/nc-lib-gui;npm uninstall -S xc-lib; npm i -S xc-lib@latest; cd ../xc-instant;npm uninstall -S xc-lib xc-lib-gui;npm i -S xc-lib@latest xc-lib-gui@latest;npm i ../xc-lib-gui",
"prepare": "husky install",
"start:mysql": "docker-compose -f ./tests/playwright/scripts/docker-compose-mysql-playwright.yml up -d",
"stop:mysql": "docker-compose -f ./tests/playwright/scripts/docker-compose-mysql-playwright.yml down",
"start:pg": "docker-compose -f ./tests/playwright/scripts/docker-compose-pg.yml up -d",
"stop:pg": "docker-compose -f ./tests/playwright/scripts/docker-compose-pg.yml down"
},

2
packages/nc-gui/assets/style.scss

@ -308,7 +308,7 @@ a {
.ant-btn-loading-icon{
& > span {
@apply block bg-red-500
@apply block;
}
}

1
packages/nc-gui/components.d.ts vendored

@ -232,6 +232,7 @@ declare module '@vue/runtime-core' {
MdiTable: typeof import('~icons/mdi/table')['default']
MdiTableColumnPlusAfter: typeof import('~icons/mdi/table-column-plus-after')['default']
MdiTableColumnPlusBefore: typeof import('~icons/mdi/table-column-plus-before')['default']
MdiTableKey: typeof import('~icons/mdi/table-key')['default']
MdiTableLarge: typeof import('~icons/mdi/table-large')['default']
MdiText: typeof import('~icons/mdi/text')['default']
MdiThumbUp: typeof import('~icons/mdi/thumb-up')['default']

51
packages/nc-gui/components/smartsheet/Grid.vue

@ -714,9 +714,9 @@ const closeAddColumnDropdown = () => {
@contextmenu="showContextMenu"
>
<thead ref="tableHead">
<tr class="nc-grid-header border-1 bg-gray-100 sticky top[-1px] !z-4">
<th data-testid="grid-id-column">
<div class="w-full h-full bg-gray-100 flex min-w-[70px] pl-5 pr-1 items-center" data-testid="nc-check-all">
<tr class="nc-grid-header">
<th class="w-[80px] min-w-[80px]" data-testid="grid-id-column">
<div class="w-full h-full bg-gray-100 flex pl-5 pr-1 items-center" data-testid="nc-check-all">
<template v-if="!readOnly">
<div class="nc-no-label text-gray-500" :class="{ hidden: selectedAllRecords }">#</div>
<div
@ -993,21 +993,24 @@ const closeAddColumnDropdown = () => {
td,
th {
@apply border-gray-200 border-solid border-b border-r;
min-height: 41px !important;
height: 41px !important;
position: relative;
}
th {
@apply bg-gray-100;
}
td:not(:first-child) > div {
overflow: hidden;
@apply flex px-1 h-auto;
}
table,
td,
th {
@apply !border-1;
border-collapse: collapse;
table {
border-collapse: separate;
border-spacing: 0;
}
td {
@ -1027,7 +1030,7 @@ const closeAddColumnDropdown = () => {
// todo: replace with css variable
td.active::after {
@apply border-2 border-solid text-primary border-current bg-primary bg-opacity-5;
@apply border-1 border-solid text-primary border-current bg-primary bg-opacity-5;
}
//td.active::before {
@ -1035,6 +1038,34 @@ const closeAddColumnDropdown = () => {
// z-index:4;
// @apply absolute !w-[10px] !h-[10px] !right-[-5px] !bottom-[-5px] bg-primary;
//}
thead th:nth-child(1) {
position: sticky !important;
left: 0;
z-index: 5;
}
tbody td:nth-child(1) {
position: sticky !important;
left: 0;
z-index: 4;
background: white;
}
thead th:nth-child(2) {
position: sticky !important;
left: 80px;
z-index: 5;
@apply border-r-2 border-r-gray-300;
}
tbody td:nth-child(2) {
position: sticky !important;
left: 80px;
z-index: 4;
background: white;
@apply shadow-lg border-r-2 border-r-gray-300;
}
}
:deep {
@ -1081,7 +1112,7 @@ const closeAddColumnDropdown = () => {
position: sticky;
top: -1px;
@apply z-1;
@apply z-10 bg-gray-100;
&:hover {
.nc-no-label {

11
packages/nc-gui/components/smartsheet/header/Menu.vue

@ -74,6 +74,8 @@ const setAsPrimaryValue = async () => {
await getMeta(meta?.value?.id as string, true)
eventBus.emit(SmartsheetStoreEvents.FIELD_RELOAD)
// Successfully updated as primary column
message.success(t('msg.success.primaryColumnUpdated'))
@ -154,6 +156,7 @@ const duplicateColumn = async () => {
await $api.dbTableColumn.create(meta!.value!.id!, {
...columnCreatePayload,
pv: false,
column_order: {
order: newColumnOrder,
view_id: view.value?.id as string,
@ -241,7 +244,7 @@ const hideField = async () => {
</a-menu-item>
</template>
<a-divider class="!my-0" />
<a-menu-item @click="hideField">
<a-menu-item v-if="!column?.pv" @click="hideField">
<div v-e="['a:field:hide']" class="nc-column-insert-before nc-header-menu-item">
<MdiEyeOffOutline class="text-primary" />
<!-- Hide Field -->
@ -268,7 +271,7 @@ const hideField = async () => {
{{ t('general.insertAfter') }}
</div>
</a-menu-item>
<a-menu-item @click="addColumn(true)">
<a-menu-item v-if="!column?.pv" @click="addColumn(true)">
<div v-e="['a:field:insert:before']" class="nc-column-insert-before nc-header-menu-item">
<MdiTableColumnPlusBefore class="text-primary" />
<!-- Insert Before -->
@ -277,7 +280,7 @@ const hideField = async () => {
</a-menu-item>
<a-divider class="!my-0" />
<a-menu-item v-if="!virtual" @click="setAsPrimaryValue">
<a-menu-item v-if="!virtual && !column?.pv" @click="setAsPrimaryValue">
<div class="nc-column-set-primary nc-header-menu-item">
<MdiStar class="text-primary" />
@ -287,7 +290,7 @@ const hideField = async () => {
</div>
</a-menu-item>
<a-menu-item @click="deleteColumn">
<a-menu-item v-if="!column?.pv" @click="deleteColumn">
<div class="nc-column-delete nc-header-menu-item">
<MdiDeleteOutline class="text-error" />
<!-- Delete -->

33
packages/nc-gui/components/smartsheet/toolbar/FieldsMenu.vue

@ -68,6 +68,12 @@ watch(
const numberOfHiddenFields = computed(() => filteredFieldList.value?.filter((field) => !field.show)?.length)
const gridPrimaryValueField = computed(() => {
if (activeView.value?.type !== ViewTypes.GRID) return null
const pvCol = Object.values(metaColumnById.value)?.find((col) => col?.pv)
return filteredFieldList.value?.find((field) => field.fk_column_id === pvCol?.id)
})
const onMove = (_event: { moved: { newIndex: number } }) => {
// todo : sync with server
if (!fields.value) return
@ -188,7 +194,7 @@ useMenuCloseOnEsc(open)
<Draggable v-model="fields" item-key="id" @change="onMove($event)">
<template #item="{ element: field, index: index }">
<div
v-show="filteredFieldList.includes(field)"
v-if="filteredFieldList.filter((el) => el !== gridPrimaryValueField).includes(field)"
:key="field.id"
class="px-2 py-1 flex items-center"
:data-testid="`nc-fields-menu-${field.title}`"
@ -212,6 +218,31 @@ useMenuCloseOnEsc(open)
<MdiDrag class="cursor-move" />
</div>
</template>
<template v-if="activeView?.type === ViewTypes.GRID" #header>
<div
v-if="gridPrimaryValueField"
:key="`pv-${gridPrimaryValueField.id}`"
class="px-2 py-1 flex items-center"
:data-testid="`nc-fields-menu-${gridPrimaryValueField.title}`"
@click.stop
>
<a-tooltip placement="bottom">
<template #title>
<span class="text-sm">Primary Value</span>
</template>
<MdiTableKey class="text-xs" />
</a-tooltip>
<div class="flex items-center px-[8px]">
<component :is="getIcon(metaColumnById[filteredFieldList[0].fk_column_id as string])" />
<span>{{ filteredFieldList[0].title }}</span>
</div>
<div class="flex-1" />
</div>
</template>
</Draggable>
</div>

10
packages/nc-gui/composables/useViewColumns.ts

@ -162,7 +162,10 @@ export function useViewColumns(
.update(view.value.id, {
show_system_fields: v,
})
.finally(() => reloadData?.())
.finally(() => {
loadViewColumns()
reloadData?.()
})
}
view.value.show_system_fields = v
}
@ -173,6 +176,8 @@ export function useViewColumns(
const filteredFieldList = computed(() => {
return (
fields.value?.filter((field: Field) => {
if (metaColumnById?.value?.[field.fk_column_id!]?.pv) return true
// hide system columns if not enabled
if (!showSystemFields.value && isSystemColumn(metaColumnById?.value?.[field.fk_column_id!])) {
return false
@ -195,7 +200,8 @@ export function useViewColumns(
!showSystemFields.value &&
metaColumnById.value &&
metaColumnById?.value?.[field.fk_column_id!] &&
isSystemColumn(metaColumnById.value?.[field.fk_column_id!])
isSystemColumn(metaColumnById.value?.[field.fk_column_id!]) &&
!metaColumnById.value?.[field.fk_column_id!]?.pv
) {
return false
}

2
packages/nocodb/src/lib/Noco.ts

@ -105,7 +105,7 @@ export default class Noco {
constructor() {
process.env.PORT = process.env.PORT || '8080';
// todo: move
process.env.NC_VERSION = '0104002';
process.env.NC_VERSION = '0104003';
// if env variable NC_MINIMAL_DBS is set, then disable project creation with external sources
if (process.env.NC_MINIMAL_DBS) {

49
packages/nocodb/src/lib/db/sql-client/lib/KnexClient.ts

@ -2367,39 +2367,26 @@ class KnexClient extends SqlClient {
const foreignKeyName = args.foreignKeyName || null;
try {
// s = await this.sqlClient.schema.index(Object.keys(args.columns));
await this.sqlClient.schema.table(args.childTable, function (table) {
table = table
.foreign(args.childColumn, foreignKeyName)
.references(args.parentColumn)
.on(args.parentTable);
if (args.onUpdate) {
table = table.onUpdate(args.onUpdate);
}
if (args.onDelete) {
table = table.onDelete(args.onDelete);
const upQb = this.sqlClient.schema.table(
args.childTable,
function (table) {
table = table
.foreign(args.childColumn, foreignKeyName)
.references(args.parentColumn)
.on(args.parentTable);
if (args.onUpdate) {
table = table.onUpdate(args.onUpdate);
}
if (args.onDelete) {
table.onDelete(args.onDelete);
}
}
});
);
const upStatement =
this.querySeparator() +
(await this.sqlClient.schema
.table(args.childTable, function (table) {
table = table
.foreign(args.childColumn, foreignKeyName)
.references(args.parentColumn)
.on(args.parentTable);
await upQb;
if (args.onUpdate) {
table = table.onUpdate(args.onUpdate);
}
if (args.onDelete) {
table = table.onDelete(args.onDelete);
}
})
.toQuery());
const upStatement = this.querySeparator() + upQb.toQuery();
this.emit(`Success : ${upStatement}`);
@ -2407,7 +2394,7 @@ class KnexClient extends SqlClient {
this.querySeparator() +
this.sqlClient.schema
.table(args.childTable, function (table) {
table = table.dropForeign(args.childColumn, foreignKeyName);
table.dropForeign(args.childColumn, foreignKeyName);
})
.toQuery();

263
packages/nocodb/src/lib/meta/api/baseApis.ts

@ -1,22 +1,13 @@
import { Request, Response } from 'express';
import Project from '../../models/Project';
import { BaseListType, ModelTypes, UITypes } from 'nocodb-sdk';
import { BaseListType } from 'nocodb-sdk';
import { PagedResponseImpl } from '../helpers/PagedResponse';
import { syncBaseMigration } from '../helpers/syncMigration';
import { IGNORE_TABLES } from '../../utils/common/BaseApiBuilder';
import Column from '../../models/Column';
import Model from '../../models/Model';
import NcHelp from '../../utils/NcHelp';
import Base from '../../models/Base';
import NcConnectionMgrv2 from '../../utils/common/NcConnectionMgrv2';
import getTableNameAlias, { getColumnNameAlias } from '../helpers/getTableName';
import LinkToAnotherRecordColumn from '../../models/LinkToAnotherRecordColumn';
import ncMetaAclMw from '../helpers/ncMetaAclMw';
import { Tele } from 'nc-help';
import getColumnUiType from '../helpers/getColumnUiType';
import mapDefaultPrimaryValue from '../helpers/mapDefaultPrimaryValue';
import { extractAndGenerateManyToManyRelations } from './metaDiffApis';
import { metaApiMetrics } from '../helpers/apiMetrics';
import { populateMeta } from './helpers';
export async function baseGet(
req: Request<any, any, any>,
@ -107,256 +98,6 @@ async function baseCreate(req: Request<any, any>, res) {
res.json(base);
}
async function populateMeta(base: Base, project: Project): Promise<any> {
const info = {
type: 'rest',
apiCount: 0,
tablesCount: 0,
relationsCount: 0,
viewsCount: 0,
client: base?.getConnectionConfig()?.client,
timeTaken: 0,
};
const t = process.hrtime();
const sqlClient = await NcConnectionMgrv2.getSqlClient(base);
let order = 1;
const models2: { [tableName: string]: Model } = {};
const virtualColumnsInsert = [];
/* Get all relations */
const relations = (await sqlClient.relationListAll())?.data?.list;
info.relationsCount = relations.length;
let tables = (await sqlClient.tableList())?.data?.list
?.filter(({ tn }) => !IGNORE_TABLES.includes(tn))
?.map((t) => {
t.order = ++order;
t.title = getTableNameAlias(t.tn, project.prefix, base);
t.table_name = t.tn;
return t;
});
/* filter based on prefix */
if (base.is_meta && project?.prefix) {
tables = tables.filter((t) => {
return t?.tn?.startsWith(project?.prefix);
});
}
info.tablesCount = tables.length;
tables.forEach((t) => {
t.title = getTableNameAlias(t.tn, project.prefix, base);
});
relations.forEach((r) => {
r.title = getTableNameAlias(r.tn, project.prefix, base);
r.rtitle = getTableNameAlias(r.rtn, project.prefix, base);
});
// await this.syncRelations();
const tableMetasInsert = tables.map((table) => {
return async () => {
/* filter relation where this table is present */
const tableRelations = relations.filter(
(r) => r.tn === table.tn || r.rtn === table.tn
);
const columns: Array<
Omit<Column, 'column_name' | 'title'> & {
cn: string;
system?: boolean;
}
> = (await sqlClient.columnList({ tn: table.tn }))?.data?.list;
const hasMany =
table.type === 'view'
? []
: tableRelations.filter((r) => r.rtn === table.tn);
const belongsTo =
table.type === 'view'
? []
: tableRelations.filter((r) => r.tn === table.tn);
mapDefaultPrimaryValue(columns);
// add vitual columns
const virtualColumns = [
...hasMany.map((hm) => {
return {
uidt: UITypes.LinkToAnotherRecord,
type: 'hm',
hm,
title: `${hm.title} List`,
};
}),
...belongsTo.map((bt) => {
// find and mark foreign key column
const fkColumn = columns.find((c) => c.cn === bt.cn);
if (fkColumn) {
fkColumn.uidt = UITypes.ForeignKey;
fkColumn.system = true;
}
return {
uidt: UITypes.LinkToAnotherRecord,
type: 'bt',
bt,
title: `${bt.rtitle}`,
};
}),
];
// await Model.insert(project.id, base.id, meta);
/* create nc_models and its rows if it doesn't exists */
models2[table.table_name] = await Model.insert(project.id, base.id, {
table_name: table.tn || table.table_name,
title: table.title,
type: table.type || 'table',
order: table.order,
});
// table crud apis
info.apiCount += 5;
let colOrder = 1;
for (const column of columns) {
await Column.insert({
uidt: column.uidt || getColumnUiType(base, column),
fk_model_id: models2[table.tn].id,
...column,
title: getColumnNameAlias(column.cn, base),
column_name: column.cn,
order: colOrder++,
});
}
virtualColumnsInsert.push(async () => {
const columnNames = {};
for (const column of virtualColumns) {
// generate unique name if there is any duplicate column name
let c = 0;
while (`${column.title}${c || ''}` in columnNames) {
c++;
}
column.title = `${column.title}${c || ''}`;
columnNames[column.title] = true;
const rel = column.hm || column.bt;
const rel_column_id = (await models2?.[rel.tn]?.getColumns())?.find(
(c) => c.column_name === rel.cn
)?.id;
const tnId = models2?.[rel.tn]?.id;
const ref_rel_column_id = (
await models2?.[rel.rtn]?.getColumns()
)?.find((c) => c.column_name === rel.rcn)?.id;
const rtnId = models2?.[rel.rtn]?.id;
try {
await Column.insert<LinkToAnotherRecordColumn>({
project_id: project.id,
db_alias: base.id,
fk_model_id: models2[table.tn].id,
cn: column.cn,
title: column.title,
uidt: column.uidt,
type: column.hm ? 'hm' : column.mm ? 'mm' : 'bt',
// column_id,
fk_child_column_id: rel_column_id,
fk_parent_column_id: ref_rel_column_id,
fk_index_name: rel.fkn,
ur: rel.ur,
dr: rel.dr,
order: colOrder++,
fk_related_model_id: column.hm ? tnId : rtnId,
system: column.system,
});
// nested relations data apis
info.apiCount += 5;
} catch (e) {
console.log(e);
}
}
});
};
});
/* handle xc_tables update in parallel */
await NcHelp.executeOperations(tableMetasInsert, base.type);
await NcHelp.executeOperations(virtualColumnsInsert, base.type);
await extractAndGenerateManyToManyRelations(Object.values(models2));
let views: Array<{ order: number; table_name: string; title: string }> = (
await sqlClient.viewList()
)?.data?.list
// ?.filter(({ tn }) => !IGNORE_TABLES.includes(tn))
?.map((v) => {
v.order = ++order;
v.table_name = v.view_name;
v.title = getTableNameAlias(v.view_name, project.prefix, base);
return v;
});
/* filter based on prefix */
if (base.is_meta && project?.prefix) {
views = tables.filter((t) => {
return t?.tn?.startsWith(project?.prefix);
});
}
info.viewsCount = views.length;
const viewMetasInsert = views.map((table) => {
return async () => {
const columns = (await sqlClient.columnList({ tn: table.table_name }))
?.data?.list;
/* create nc_models and its rows if it doesn't exists */
models2[table.table_name] = await Model.insert(project.id, base.id, {
table_name: table.table_name,
title: getTableNameAlias(table.table_name, project.prefix, base),
// todo: sanitize
type: ModelTypes.VIEW,
order: table.order,
});
let colOrder = 1;
// view apis
info.apiCount += 2;
for (const column of columns) {
await Column.insert({
fk_model_id: models2[table.table_name].id,
...column,
title: getColumnNameAlias(column.cn, base),
order: colOrder++,
uidt: getColumnUiType(base, column),
});
}
};
});
await NcHelp.executeOperations(viewMetasInsert, base.type);
const t1 = process.hrtime(t);
const t2 = t1[0] + t1[1] / 1000000000;
(info as any).timeTaken = t2.toFixed(1);
return info;
}
export default (router) => {
router.get(
'/api/v1/db/meta/projects/:projectId/bases/:baseId',

57
packages/nocodb/src/lib/meta/api/columnApis.ts

@ -50,12 +50,26 @@ export enum Altered {
UPDATE_COLUMN = 8,
}
// generate unique foreign key constraint name for foreign key
const generateFkName = (parent: TableType, child: TableType) => {
// generate a unique constraint name by taking first 10 chars of parent and child table name (by replacing all non word chars with _)
// and appending a random string of 15 chars maximum length.
// In database constraint name can be upto 64 chars and here we are generating a name of maximum 40 chars
const constraintName = `fk_${parent.table_name
.replace(/\W+/g, '_')
.slice(0, 10)}_${child.table_name
.replace(/\W+/g, '_')
.slice(0, 10)}_${randomID(15)}`;
return constraintName;
};
async function createHmAndBtColumn(
child: Model,
parent: Model,
childColumn: Column,
type?: RelationTypes,
alias?: string,
fkColName?: string,
virtual = false,
isSystemCol = false
) {
@ -79,6 +93,7 @@ async function createHmAndBtColumn(
fk_related_model_id: parent.id,
virtual,
system: isSystemCol,
fk_index_name: fkColName,
});
}
// save hm column
@ -97,6 +112,7 @@ async function createHmAndBtColumn(
fk_related_model_id: child.id,
virtual,
system: isSystemCol,
fk_index_name: fkColName,
});
}
}
@ -262,6 +278,7 @@ export async function columnAdd(
`${parent.table_name}_id`
);
let foreignKeyName;
{
// create foreign key
const newColumn = {
@ -307,6 +324,7 @@ export async function columnAdd(
// ignore relation creation if virtual
if (!(req.body as LinkToAnotherColumnReqType).virtual) {
foreignKeyName = generateFkName(parent, child);
// create relation
await sqlMgr.sqlOpPlus(base, 'relationCreate', {
childColumn: fkColName,
@ -316,6 +334,7 @@ export async function columnAdd(
onUpdate: 'NO ACTION',
type: 'real',
parentColumn: parent.primaryKey.column_name,
foreignKeyName,
});
}
@ -338,6 +357,7 @@ export async function columnAdd(
childColumn,
(req.body as LinkToAnotherColumnReqType).type as RelationTypes,
(req.body as LinkToAnotherColumnReqType).title,
foreignKeyName,
(req.body as LinkToAnotherColumnReqType).virtual
);
} else if ((req.body as LinkToAnotherColumnReqType).type === 'mm') {
@ -399,7 +419,13 @@ export async function columnAdd(
columns: associateTableCols,
});
let foreignKeyName1;
let foreignKeyName2;
if (!(req.body as LinkToAnotherColumnReqType).virtual) {
foreignKeyName1 = generateFkName(parent, child);
foreignKeyName2 = generateFkName(parent, child);
const rel1Args = {
...req.body,
childTable: aTn,
@ -407,6 +433,7 @@ export async function columnAdd(
parentTable: parent.table_name,
parentColumn: parentPK.column_name,
type: 'real',
foreignKeyName: foreignKeyName1,
};
const rel2Args = {
...req.body,
@ -415,6 +442,7 @@ export async function columnAdd(
parentTable: child.table_name,
parentColumn: childPK.column_name,
type: 'real',
foreignKeyName: foreignKeyName2,
};
await sqlMgr.sqlOpPlus(base, 'relationCreate', rel1Args);
@ -433,6 +461,7 @@ export async function columnAdd(
childCol,
null,
null,
foreignKeyName1,
(req.body as LinkToAnotherColumnReqType).virtual,
true
);
@ -442,6 +471,7 @@ export async function columnAdd(
parentCol,
null,
null,
foreignKeyName2,
(req.body as LinkToAnotherColumnReqType).virtual,
true
);
@ -1724,6 +1754,31 @@ const deleteHmOrBtRelation = async (
},
ignoreFkDelete = false
) => {
let foreignKeyName;
// if relationColOpt is not provided, extract it from child table
// and get the foreign key name for dropping the foreign key
if (!relationColOpt) {
foreignKeyName = (
(
await childTable.getColumns().then((cols) => {
return cols?.find((c) => {
return (
c.uidt === UITypes.LinkToAnotherRecord &&
c.colOptions.fk_related_model_id === parentTable.id &&
(c.colOptions as LinkToAnotherRecordType).fk_child_column_id ===
childColumn.id &&
(c.colOptions as LinkToAnotherRecordType).fk_parent_column_id ===
parentColumn.id
);
});
})
).colOptions as LinkToAnotherRecordType
).fk_index_name;
} else {
foreignKeyName = relationColOpt.fk_index_name;
}
// todo: handle relation delete exception
try {
await sqlMgr.sqlOpPlus(base, 'relationDelete', {
@ -1731,7 +1786,7 @@ const deleteHmOrBtRelation = async (
childTable: childTable.table_name,
parentTable: parentTable.table_name,
parentColumn: parentColumn.column_name,
// foreignKeyName: relation.fkn
foreignKeyName,
});
} catch (e) {
console.log(e);

3
packages/nocodb/src/lib/meta/api/helpers/index.ts

@ -0,0 +1,3 @@
import { populateMeta } from './populateMeta';
export { populateMeta };

278
packages/nocodb/src/lib/meta/api/helpers/populateMeta.ts

@ -0,0 +1,278 @@
import Project from '../../../models/Project';
import Column from '../../../models/Column';
import Model from '../../../models/Model';
import NcHelp from '../../../utils/NcHelp';
import Base from '../../../models/Base';
import View from '../../../models/View';
import NcConnectionMgrv2 from '../../../utils/common/NcConnectionMgrv2';
import getTableNameAlias, {
getColumnNameAlias,
} from '../../helpers/getTableName';
import LinkToAnotherRecordColumn from '../../../models/LinkToAnotherRecordColumn';
import getColumnUiType from '../../helpers/getColumnUiType';
import mapDefaultPrimaryValue from '../../helpers/mapDefaultPrimaryValue';
import { extractAndGenerateManyToManyRelations } from '../metaDiffApis';
import { ModelTypes, UITypes, ViewTypes } from 'nocodb-sdk';
import { IGNORE_TABLES } from '../../../utils/common/BaseApiBuilder';
export async function populateMeta(base: Base, project: Project): Promise<any> {
const info = {
type: 'rest',
apiCount: 0,
tablesCount: 0,
relationsCount: 0,
viewsCount: 0,
client: base?.getConnectionConfig()?.client,
timeTaken: 0,
};
const t = process.hrtime();
const sqlClient = await NcConnectionMgrv2.getSqlClient(base);
let order = 1;
const models2: { [tableName: string]: Model } = {};
const virtualColumnsInsert = [];
/* Get all relations */
const relations = (await sqlClient.relationListAll())?.data?.list;
info.relationsCount = relations.length;
let tables = (await sqlClient.tableList())?.data?.list
?.filter(({ tn }) => !IGNORE_TABLES.includes(tn))
?.map((t) => {
t.order = ++order;
t.title = getTableNameAlias(t.tn, project.prefix, base);
t.table_name = t.tn;
return t;
});
/* filter based on prefix */
if (base.is_meta && project?.prefix) {
tables = tables.filter((t) => {
return t?.tn?.startsWith(project?.prefix);
});
}
info.tablesCount = tables.length;
tables.forEach((t) => {
t.title = getTableNameAlias(t.tn, project.prefix, base);
});
relations.forEach((r) => {
r.title = getTableNameAlias(r.tn, project.prefix, base);
r.rtitle = getTableNameAlias(r.rtn, project.prefix, base);
});
// await this.syncRelations();
const tableMetasInsert = tables.map((table) => {
return async () => {
/* filter relation where this table is present */
const tableRelations = relations.filter(
(r) => r.tn === table.tn || r.rtn === table.tn
);
const columns: Array<
Omit<Column, 'column_name' | 'title'> & {
cn: string;
system?: boolean;
}
> = (await sqlClient.columnList({ tn: table.tn }))?.data?.list;
const hasMany =
table.type === 'view'
? []
: tableRelations.filter((r) => r.rtn === table.tn);
const belongsTo =
table.type === 'view'
? []
: tableRelations.filter((r) => r.tn === table.tn);
mapDefaultPrimaryValue(columns);
// add vitual columns
const virtualColumns = [
...hasMany.map((hm) => {
return {
uidt: UITypes.LinkToAnotherRecord,
type: 'hm',
hm,
title: `${hm.title} List`,
};
}),
...belongsTo.map((bt) => {
// find and mark foreign key column
const fkColumn = columns.find((c) => c.cn === bt.cn);
if (fkColumn) {
fkColumn.uidt = UITypes.ForeignKey;
fkColumn.system = true;
}
return {
uidt: UITypes.LinkToAnotherRecord,
type: 'bt',
bt,
title: `${bt.rtitle}`,
};
}),
];
// await Model.insert(project.id, base.id, meta);
/* create nc_models and its rows if it doesn't exists */
models2[table.table_name] = await Model.insert(project.id, base.id, {
table_name: table.tn || table.table_name,
title: table.title,
type: table.type || 'table',
order: table.order,
});
// table crud apis
info.apiCount += 5;
let colOrder = 1;
for (const column of columns) {
await Column.insert({
uidt: column.uidt || getColumnUiType(base, column),
fk_model_id: models2[table.tn].id,
...column,
title: getColumnNameAlias(column.cn, base),
column_name: column.cn,
order: colOrder++,
});
}
virtualColumnsInsert.push(async () => {
const columnNames = {};
for (const column of virtualColumns) {
// generate unique name if there is any duplicate column name
let c = 0;
while (`${column.title}${c || ''}` in columnNames) {
c++;
}
column.title = `${column.title}${c || ''}`;
columnNames[column.title] = true;
const rel = column.hm || column.bt;
const rel_column_id = (await models2?.[rel.tn]?.getColumns())?.find(
(c) => c.column_name === rel.cn
)?.id;
const tnId = models2?.[rel.tn]?.id;
const ref_rel_column_id = (
await models2?.[rel.rtn]?.getColumns()
)?.find((c) => c.column_name === rel.rcn)?.id;
const rtnId = models2?.[rel.rtn]?.id;
try {
await Column.insert<LinkToAnotherRecordColumn>({
project_id: project.id,
db_alias: base.id,
fk_model_id: models2[table.tn].id,
cn: column.cn,
title: column.title,
uidt: column.uidt,
type: column.hm ? 'hm' : column.mm ? 'mm' : 'bt',
// column_id,
fk_child_column_id: rel_column_id,
fk_parent_column_id: ref_rel_column_id,
fk_index_name: rel.cstn,
ur: rel.ur,
dr: rel.dr,
order: colOrder++,
fk_related_model_id: column.hm ? tnId : rtnId,
system: column.system,
});
// nested relations data apis
info.apiCount += 5;
} catch (e) {
console.log(e);
}
}
});
};
});
/* handle xc_tables update in parallel */
await NcHelp.executeOperations(tableMetasInsert, base.type);
await NcHelp.executeOperations(virtualColumnsInsert, base.type);
await extractAndGenerateManyToManyRelations(Object.values(models2));
let views: Array<{ order: number; table_name: string; title: string }> = (
await sqlClient.viewList()
)?.data?.list
// ?.filter(({ tn }) => !IGNORE_TABLES.includes(tn))
?.map((v) => {
v.order = ++order;
v.table_name = v.view_name;
v.title = getTableNameAlias(v.view_name, project.prefix, base);
return v;
});
/* filter based on prefix */
if (base.is_meta && project?.prefix) {
views = tables.filter((t) => {
return t?.tn?.startsWith(project?.prefix);
});
}
info.viewsCount = views.length;
const viewMetasInsert = views.map((table) => {
return async () => {
const columns = (await sqlClient.columnList({ tn: table.table_name }))
?.data?.list;
/* create nc_models and its rows if it doesn't exists */
models2[table.table_name] = await Model.insert(project.id, base.id, {
table_name: table.table_name,
title: getTableNameAlias(table.table_name, project.prefix, base),
// todo: sanitize
type: ModelTypes.VIEW,
order: table.order,
});
let colOrder = 1;
// view apis
info.apiCount += 2;
for (const column of columns) {
await Column.insert({
fk_model_id: models2[table.table_name].id,
...column,
title: getColumnNameAlias(column.cn, base),
order: colOrder++,
uidt: getColumnUiType(base, column),
});
}
};
});
await NcHelp.executeOperations(viewMetasInsert, base.type);
// fix pv column for created grid views
const models = await Model.list({ project_id: project.id, base_id: base.id });
for (const model of models) {
const views = await model.getViews();
for (const view of views) {
if (view.type === ViewTypes.GRID) {
await View.fixPVColumnForView(view.id);
}
}
}
const t1 = process.hrtime(t);
const t2 = t1[0] + t1[1] / 1000000000;
(info as any).timeTaken = t2.toFixed(1);
return info;
}

5
packages/nocodb/src/lib/meta/api/metaDiffApis.ts

@ -107,6 +107,7 @@ type MetaDiffChange = {
cn?: string;
rcn?: string;
relationType: RelationTypes;
cstn?: string;
}
);
@ -146,6 +147,7 @@ async function getMetaDiff(
cn: string;
rcn: string;
found?: any;
cstn?: string;
}> = (await sqlClient.relationListAll())?.data?.list;
for (const table of tableList) {
@ -394,6 +396,7 @@ async function getMetaDiff(
rcn: relation.rcn,
msg: `New relation added`,
relationType: RelationTypes.BELONGS_TO,
cstn: relation.cstn,
});
}
if (!relation?.found?.[RelationTypes.HAS_MANY]) {
@ -736,6 +739,7 @@ export async function metaDiffSync(req, res) {
fk_parent_column_id: parentCol.id,
fk_child_column_id: childCol.id,
virtual: false,
fk_index_name: change.cstn,
});
} else if (change.relationType === RelationTypes.HAS_MANY) {
const title = getUniqueColumnAliasName(
@ -751,6 +755,7 @@ export async function metaDiffSync(req, res) {
fk_parent_column_id: parentCol.id,
fk_child_column_id: childCol.id,
virtual: false,
fk_index_name: change.cstn,
});
}
});

265
packages/nocodb/src/lib/meta/api/projectApis.ts

@ -1,34 +1,25 @@
import { Request, Response } from 'express';
import { OrgUserRoles, ProjectType } from 'nocodb-sdk';
import Project from '../../models/Project';
import { ModelTypes, ProjectListType, UITypes } from 'nocodb-sdk';
import { ProjectListType } from 'nocodb-sdk';
import DOMPurify from 'isomorphic-dompurify';
import { packageVersion } from '../../utils/packageVersion';
import { Tele } from 'nc-help';
import { PagedResponseImpl } from '../helpers/PagedResponse';
import syncMigration from '../helpers/syncMigration';
import { IGNORE_TABLES } from '../../utils/common/BaseApiBuilder';
import Column from '../../models/Column';
import Model from '../../models/Model';
import Filter from '../../models/Filter';
import NcHelp from '../../utils/NcHelp';
import Base from '../../models/Base';
import NcConnectionMgrv2 from '../../utils/common/NcConnectionMgrv2';
import getTableNameAlias, { getColumnNameAlias } from '../helpers/getTableName';
import LinkToAnotherRecordColumn from '../../models/LinkToAnotherRecordColumn';
import ncMetaAclMw from '../helpers/ncMetaAclMw';
import ProjectUser from '../../models/ProjectUser';
import { customAlphabet } from 'nanoid';
import Noco from '../../Noco';
import isDocker from 'is-docker';
import { NcError } from '../helpers/catchError';
import getColumnUiType from '../helpers/getColumnUiType';
import mapDefaultPrimaryValue from '../helpers/mapDefaultPrimaryValue';
import { extractAndGenerateManyToManyRelations } from './metaDiffApis';
import { metaApiMetrics } from '../helpers/apiMetrics';
import { extractPropsAndSanitize } from '../helpers/extractProps';
import NcConfigFactory from '../../utils/NcConfigFactory';
import { promisify } from 'util';
import { populateMeta } from './helpers';
import Filter from '../../models/Filter';
const nanoid = customAlphabet('1234567890abcdefghijklmnopqrstuvwxyz_', 4);
@ -203,256 +194,6 @@ async function projectCreate(req: Request<any, any>, res) {
res.json(project);
}
async function populateMeta(base: Base, project: Project): Promise<any> {
const info = {
type: 'rest',
apiCount: 0,
tablesCount: 0,
relationsCount: 0,
viewsCount: 0,
client: base?.getConnectionConfig()?.client,
timeTaken: 0,
};
const t = process.hrtime();
const sqlClient = await NcConnectionMgrv2.getSqlClient(base);
let order = 1;
const models2: { [tableName: string]: Model } = {};
const virtualColumnsInsert = [];
/* Get all relations */
const relations = (await sqlClient.relationListAll())?.data?.list;
info.relationsCount = relations.length;
let tables = (await sqlClient.tableList())?.data?.list
?.filter(({ tn }) => !IGNORE_TABLES.includes(tn))
?.map((t) => {
t.order = ++order;
t.title = getTableNameAlias(t.tn, project.prefix, base);
t.table_name = t.tn;
return t;
});
/* filter based on prefix */
if (project?.prefix) {
tables = tables.filter((t) => {
return t?.tn?.startsWith(project?.prefix);
});
}
info.tablesCount = tables.length;
tables.forEach((t) => {
t.title = getTableNameAlias(t.tn, project.prefix, base);
});
relations.forEach((r) => {
r.title = getTableNameAlias(r.tn, project.prefix, base);
r.rtitle = getTableNameAlias(r.rtn, project.prefix, base);
});
// await this.syncRelations();
const tableMetasInsert = tables.map((table) => {
return async () => {
/* filter relation where this table is present */
const tableRelations = relations.filter(
(r) => r.tn === table.tn || r.rtn === table.tn
);
const columns: Array<
Omit<Column, 'column_name' | 'title'> & {
cn: string;
system?: boolean;
}
> = (await sqlClient.columnList({ tn: table.tn }))?.data?.list;
const hasMany =
table.type === 'view'
? []
: tableRelations.filter((r) => r.rtn === table.tn);
const belongsTo =
table.type === 'view'
? []
: tableRelations.filter((r) => r.tn === table.tn);
mapDefaultPrimaryValue(columns);
// add vitual columns
const virtualColumns = [
...hasMany.map((hm) => {
return {
uidt: UITypes.LinkToAnotherRecord,
type: 'hm',
hm,
title: `${hm.title} List`,
};
}),
...belongsTo.map((bt) => {
// find and mark foreign key column
const fkColumn = columns.find((c) => c.cn === bt.cn);
if (fkColumn) {
fkColumn.uidt = UITypes.ForeignKey;
fkColumn.system = true;
}
return {
uidt: UITypes.LinkToAnotherRecord,
type: 'bt',
bt,
title: `${bt.rtitle}`,
};
}),
];
// await Model.insert(project.id, base.id, meta);
/* create nc_models and its rows if it doesn't exists */
models2[table.table_name] = await Model.insert(project.id, base.id, {
table_name: table.tn || table.table_name,
title: table.title,
type: table.type || 'table',
order: table.order,
});
// table crud apis
info.apiCount += 5;
let colOrder = 1;
for (const column of columns) {
await Column.insert({
uidt: column.uidt || getColumnUiType(base, column),
fk_model_id: models2[table.tn].id,
...column,
title: getColumnNameAlias(column.cn, base),
column_name: column.cn,
order: colOrder++,
});
}
virtualColumnsInsert.push(async () => {
const columnNames = {};
for (const column of virtualColumns) {
// generate unique name if there is any duplicate column name
let c = 0;
while (`${column.title}${c || ''}` in columnNames) {
c++;
}
column.title = `${column.title}${c || ''}`;
columnNames[column.title] = true;
const rel = column.hm || column.bt;
const rel_column_id = (await models2?.[rel.tn]?.getColumns())?.find(
(c) => c.column_name === rel.cn
)?.id;
const tnId = models2?.[rel.tn]?.id;
const ref_rel_column_id = (
await models2?.[rel.rtn]?.getColumns()
)?.find((c) => c.column_name === rel.rcn)?.id;
const rtnId = models2?.[rel.rtn]?.id;
try {
await Column.insert<LinkToAnotherRecordColumn>({
project_id: project.id,
db_alias: base.id,
fk_model_id: models2[table.tn].id,
cn: column.cn,
title: column.title,
uidt: column.uidt,
type: column.hm ? 'hm' : column.mm ? 'mm' : 'bt',
// column_id,
fk_child_column_id: rel_column_id,
fk_parent_column_id: ref_rel_column_id,
fk_index_name: rel.fkn,
ur: rel.ur,
dr: rel.dr,
order: colOrder++,
fk_related_model_id: column.hm ? tnId : rtnId,
system: column.system,
});
// nested relations data apis
info.apiCount += 5;
} catch (e) {
console.log(e);
}
}
});
};
});
/* handle xc_tables update in parallel */
await NcHelp.executeOperations(tableMetasInsert, base.type);
await NcHelp.executeOperations(virtualColumnsInsert, base.type);
await extractAndGenerateManyToManyRelations(Object.values(models2));
let views: Array<{ order: number; table_name: string; title: string }> = (
await sqlClient.viewList()
)?.data?.list
// ?.filter(({ tn }) => !IGNORE_TABLES.includes(tn))
?.map((v) => {
v.order = ++order;
v.table_name = v.view_name;
v.title = getTableNameAlias(v.view_name, project.prefix, base);
return v;
});
/* filter based on prefix */
if (project?.prefix) {
views = tables.filter((t) => {
return t?.tn?.startsWith(project?.prefix);
});
}
info.viewsCount = views.length;
const viewMetasInsert = views.map((table) => {
return async () => {
const columns = (await sqlClient.columnList({ tn: table.table_name }))
?.data?.list;
/* create nc_models and its rows if it doesn't exists */
models2[table.table_name] = await Model.insert(project.id, base.id, {
table_name: table.table_name,
title: getTableNameAlias(table.table_name, project.prefix, base),
// todo: sanitize
type: ModelTypes.VIEW,
order: table.order,
});
let colOrder = 1;
// view apis
info.apiCount += 2;
for (const column of columns) {
await Column.insert({
fk_model_id: models2[table.table_name].id,
...column,
title: getColumnNameAlias(column.cn, base),
order: colOrder++,
uidt: getColumnUiType(base, column),
});
}
};
});
await NcHelp.executeOperations(viewMetasInsert, base.type);
const t1 = process.hrtime(t);
const t2 = t1[0] + t1[1] / 1000000000;
(info as any).timeTaken = t2.toFixed(1);
return info;
}
export async function projectInfoGet(_req, res) {
res.json({
Node: process.version,

2
packages/nocodb/src/lib/models/GridViewColumn.ts

@ -109,6 +109,8 @@ export default class GridViewColumn implements GridColumnType {
`${CacheScope.GRID_VIEW_COLUMN}:${id}`
);
await View.fixPVColumnForView(column.fk_view_id, ncMeta);
return this.get(id, ncMeta);
}

17
packages/nocodb/src/lib/models/Model.ts

@ -606,6 +606,23 @@ export default class Model implements TableType {
newPvCol.id
);
const grid_views_with_column = await ncMeta.metaList2(
null,
null,
MetaTable.GRID_VIEW_COLUMNS,
{
condition: {
fk_column_id: newPvCol.id,
},
}
);
if (grid_views_with_column.length) {
for (const gv of grid_views_with_column) {
await View.fixPVColumnForView(gv.fk_view_id, ncMeta);
}
}
return true;
}

159
packages/nocodb/src/lib/models/View.ts

@ -630,6 +630,35 @@ export default class View implements ViewType {
break;
}
const updateObj = extractProps(colData, ['order', 'show']);
// keep primary_value_column always visible and first in grid view
if (view.type === ViewTypes.GRID) {
const primary_value_column_meta = await ncMeta.metaGet2(
null,
null,
MetaTable.COLUMNS,
{
fk_model_id: view.fk_model_id,
pv: true,
}
);
const primary_value_column = await ncMeta.metaGet2(
null,
null,
MetaTable.GRID_VIEW_COLUMNS,
{
fk_view_id: view.id,
fk_column_id: primary_value_column_meta.id,
}
);
if (primary_value_column && primary_value_column.id === colId) {
updateObj.order = 1;
updateObj.show = true;
}
}
// get existing cache
const key = `${cacheScope}:${colId}`;
let o = await NocoCache.get(key, CacheGetType.TYPE_OBJECT);
@ -879,7 +908,16 @@ export default class View implements ViewType {
// set meta
await ncMeta.metaUpdate(null, null, MetaTable.VIEWS, updateObj, viewId);
return this.get(viewId);
const view = await this.get(viewId);
if (view.type === ViewTypes.GRID) {
if ('show_system_fields' in updateObj) {
await View.fixPVColumnForView(viewId, ncMeta);
}
}
return view;
}
// @ts-ignore
@ -1078,6 +1116,24 @@ export default class View implements ViewType {
const view = await this.get(viewId);
const table = this.extractViewColumnsTableName(view);
const scope = this.extractViewColumnsTableNameScope(view);
if (view.type === ViewTypes.GRID) {
const primary_value_column = await ncMeta.metaGet2(
null,
null,
MetaTable.COLUMNS,
{
fk_model_id: view.fk_model_id,
pv: true,
}
);
// keep primary_value_column always visible
if (primary_value_column) {
ignoreColdIds.push(primary_value_column.id);
}
}
// get existing cache
const dataList = await NocoCache.getList(scope, [viewId]);
if (dataList?.length) {
@ -1135,4 +1191,105 @@ export default class View implements ViewType {
sharedViews = sharedViews.filter((v) => v.uuid !== null);
return sharedViews?.map((v) => new View(v));
}
static async fixPVColumnForView(viewId, ncMeta = Noco.ncMeta) {
// get a list of view columns sorted by order
const view_columns = await ncMeta.metaList2(
null,
null,
MetaTable.GRID_VIEW_COLUMNS,
{
condition: {
fk_view_id: viewId,
},
orderBy: {
order: 'asc',
},
}
);
const view_columns_meta = [];
// get column meta for each view column
for (const col of view_columns) {
const col_meta = await ncMeta.metaGet2(
null,
null,
MetaTable.COLUMNS,
col.fk_column_id
);
view_columns_meta.push(col_meta);
}
const primary_value_column_meta = view_columns_meta.find((col) => col.pv);
if (primary_value_column_meta) {
const primary_value_column = view_columns.find(
(col) => col.fk_column_id === primary_value_column_meta.id
);
const primary_value_column_index = view_columns.findIndex(
(col) => col.fk_column_id === primary_value_column_meta.id
);
const view_orders = view_columns.map((col) => col.order);
const view_min_order = Math.min(...view_orders);
// if primary_value_column is not visible, make it visible
if (!primary_value_column.show) {
await ncMeta.metaUpdate(
null,
null,
MetaTable.GRID_VIEW_COLUMNS,
{ show: true },
primary_value_column.id
);
await NocoCache.set(
`${CacheScope.GRID_VIEW_COLUMN}:${primary_value_column.id}`,
primary_value_column
);
}
if (
primary_value_column.order === view_min_order &&
view_orders.filter((o) => o === view_min_order).length === 1
) {
// if primary_value_column is in first order do nothing
return;
} else {
// if primary_value_column not in first order, move it to the start of array
if (primary_value_column_index !== 0) {
const temp_pv = view_columns.splice(primary_value_column_index, 1);
view_columns.unshift(...temp_pv);
}
// update order of all columns in view to match the order in array
for (let i = 0; i < view_columns.length; i++) {
await ncMeta.metaUpdate(
null,
null,
MetaTable.GRID_VIEW_COLUMNS,
{ order: i + 1 },
view_columns[i].id
);
await NocoCache.set(
`${CacheScope.GRID_VIEW_COLUMN}:${view_columns[i].id}`,
view_columns[i]
);
}
}
}
const views = await ncMeta.metaList2(
null,
null,
MetaTable.GRID_VIEW_COLUMNS,
{
condition: {
fk_view_id: viewId,
},
orderBy: {
order: 'asc',
},
}
);
await NocoCache.setList(CacheScope.GRID_VIEW_COLUMN, [viewId], views);
}
}

2
packages/nocodb/src/lib/version-upgrader/NcUpgrader.ts

@ -11,6 +11,7 @@ import ncProjectRolesUpgrader from './ncProjectRolesUpgrader';
import ncFilterUpgrader from './ncFilterUpgrader';
import ncAttachmentUpgrader from './ncAttachmentUpgrader';
import ncAttachmentUpgrader_0104002 from './ncAttachmentUpgrader_0104002';
import ncStickyColumnUpgrader from './ncStickyColumnUpgrader';
import ncFilterUpgrader_0104003 from './ncFilterUpgrader_0104003';
const log = debug('nc:version-upgrader');
@ -42,6 +43,7 @@ export default class NcUpgrader {
{ name: '0100002', handler: ncFilterUpgrader },
{ name: '0101002', handler: ncAttachmentUpgrader },
{ name: '0104002', handler: ncAttachmentUpgrader_0104002 },
{ name: '0104003', handler: ncStickyColumnUpgrader },
{ name: '0104003', handler: ncFilterUpgrader_0104003 },
];
if (!(await ctx.ncMeta.knexConnection?.schema?.hasTable?.('nc_store'))) {

90
packages/nocodb/src/lib/version-upgrader/ncStickyColumnUpgrader.ts

@ -0,0 +1,90 @@
import { NcUpgraderCtx } from './NcUpgrader';
import { MetaTable } from '../utils/globals';
// before 0.104.3, primary value column can be in any position in table
// with this upgrade we introduced sticky primary column feature
// this upgrader will make primary value column first column in grid views
export default async function ({ ncMeta }: NcUpgraderCtx) {
const grid_columns = await ncMeta.metaList2(
null,
null,
MetaTable.GRID_VIEW_COLUMNS
);
const grid_views = [...new Set(grid_columns.map((col) => col.fk_view_id))];
for (const view_id of grid_views) {
// get a list of view columns sorted by order
const view_columns = await ncMeta.metaList2(
null,
null,
MetaTable.GRID_VIEW_COLUMNS,
{
condition: {
fk_view_id: view_id,
},
orderBy: {
order: 'asc',
},
}
);
const view_columns_meta = [];
// get column meta for each view column
for (const col of view_columns) {
const col_meta = await ncMeta.metaGet(null, null, MetaTable.COLUMNS, {
id: col.fk_column_id,
});
view_columns_meta.push(col_meta);
}
const primary_value_column_meta = view_columns_meta.find((col) => col.pv);
if (primary_value_column_meta) {
const primary_value_column = view_columns.find(
(col) => col.fk_column_id === primary_value_column_meta.id
);
const primary_value_column_index = view_columns.findIndex(
(col) => col.fk_column_id === primary_value_column_meta.id
);
const view_orders = view_columns.map((col) => col.order);
const view_min_order = Math.min(...view_orders);
// if primary_value_column is not visible, make it visible
if (!primary_value_column.show) {
await ncMeta.metaUpdate(
null,
null,
MetaTable.GRID_VIEW_COLUMNS,
{ show: true },
primary_value_column.id
);
}
if (
primary_value_column.order === view_min_order &&
view_orders.filter((o) => o === view_min_order).length === 1
) {
// if primary_value_column is in first order do nothing
continue;
} else {
// if primary_value_column not in first order, move it to the start of array
if (primary_value_column_index !== 0) {
const temp_pv = view_columns.splice(primary_value_column_index, 1);
view_columns.unshift(...temp_pv);
}
// update order of all columns in view to match the order in array
for (let i = 0; i < view_columns.length; i++) {
await ncMeta.metaUpdate(
null,
null,
MetaTable.GRID_VIEW_COLUMNS,
{ order: i + 1 },
view_columns[i].id
);
}
}
}
}
}

18
tests/playwright/pages/Dashboard/Grid/Column/index.ts

@ -44,6 +44,7 @@ export class ColumnPageObject extends BasePage {
timeFormat = '',
insertAfterColumnTitle,
insertBeforeColumnTitle,
isPrimaryValue = false,
}: {
title: string;
type?: string;
@ -60,9 +61,16 @@ export class ColumnPageObject extends BasePage {
timeFormat?: string;
insertBeforeColumnTitle?: string;
insertAfterColumnTitle?: string;
isPrimaryValue?: boolean;
}) {
if (insertBeforeColumnTitle) {
await this.grid.get().locator(`th[data-title="${insertBeforeColumnTitle}"] .nc-ui-dt-dropdown`).click();
if (isPrimaryValue) {
await expect(this.rootPage.locator('li[role="menuitem"]:has-text("Insert Before")')).toHaveCount(0);
return;
}
await this.rootPage.locator('li[role="menuitem"]:has-text("Insert Before"):visible').click();
} else if (insertAfterColumnTitle) {
await this.grid.get().locator(`th[data-title="${insertAfterColumnTitle}"] .nc-ui-dt-dropdown`).click();
@ -313,9 +321,14 @@ export class ColumnPageObject extends BasePage {
await this.grid.get().locator(`th[data-title="${expectedTitle}"]`).isVisible();
}
async hideColumn({ title }: { title: string }) {
async hideColumn({ title, isPrimaryValue = false }: { title: string; isPrimaryValue?: boolean }) {
await this.grid.get().locator(`th[data-title="${title}"] .nc-ui-dt-dropdown`).click();
if (isPrimaryValue) {
await expect(this.rootPage.locator('li[role="menuitem"]:has-text("Hide Field")')).toHaveCount(0);
return;
}
await this.waitForResponse({
uiAction: this.rootPage.locator('li[role="menuitem"]:has-text("Hide Field"):visible').click(),
requestUrlPathToMatch: 'api/v1/db/meta/views',
@ -386,5 +399,8 @@ export class ColumnPageObject extends BasePage {
)
.first()
.isVisible();
// close sort menu
await this.grid.toolbar.clickSort();
}
}

4
tests/playwright/pages/Dashboard/common/Toolbar/Fields.ts

@ -31,12 +31,12 @@ export class ToolbarFieldsPage extends BasePage {
await this.toolbar.clickFields();
}
async verify({ title, checked }: { title: string; checked: boolean }) {
async verify({ title, checked }: { title: string; checked?: boolean }) {
const checkbox = this.get().locator(`[data-testid="nc-fields-menu-${title}"]`).locator('input[type="checkbox"]');
if (checked) {
await expect(checkbox).toBeChecked();
} else {
} else if (checked === false) {
await expect(checkbox).not.toBeChecked();
}
}

2
tests/playwright/quickTests/commonTest.ts

@ -208,7 +208,7 @@ const quickVerify = async ({
// Verify Fields
await dashboard.grid.toolbar.clickFields();
await dashboard.grid.toolbar.fields.verify({ title: 'Name', checked: true });
await dashboard.grid.toolbar.fields.verify({ title: 'Name' });
await dashboard.grid.toolbar.fields.verify({ title: 'Notes', checked: true });
await dashboard.grid.toolbar.fields.verify({ title: 'Attachments', checked: false });
await dashboard.grid.toolbar.fields.verify({ title: 'Status', checked: true });

2
tests/playwright/tests/cellSelection.spec.ts

@ -83,7 +83,7 @@ test.describe('Verify cell selection', () => {
await dashboard.grid.toolbar.fields.toggleShowSystemFields();
await grid.selectRange({
start: { index: 2, columnHeader: 'City List' },
end: { index: 0, columnHeader: 'CountryId' },
end: { index: 0, columnHeader: 'Country' },
});
expect(await grid.selectedCount()).toBe(12);

2
tests/playwright/tests/columnMenuOperations.spec.ts

@ -91,6 +91,7 @@ test.describe('Column menu operations', () => {
title: 'InsertBeforeColumn',
type: 'SingleLineText',
insertBeforeColumnTitle: 'Title',
isPrimaryValue: true,
});
await dashboard.grid.column.create({
@ -107,6 +108,7 @@ test.describe('Column menu operations', () => {
await dashboard.grid.column.hideColumn({
title: 'Title',
isPrimaryValue: true,
});
await dashboard.grid.column.hideColumn({

2
tests/playwright/tests/metaSync.spec.ts

@ -252,7 +252,7 @@ test.describe('Meta sync', () => {
await dashboard.treeView.openTable({ title: 'Table1' });
await dashboard.grid.toolbar.clickFields();
await dashboard.grid.toolbar.fields.click({ title: 'Col1' });
await dashboard.grid.toolbar.fields.click({ title: 'Col2' });
await dashboard.grid.toolbar.clickFields();
await dashboard.grid.toolbar.sort.add({

Loading…
Cancel
Save