Browse Source

Merge pull request #5060 from nocodb/feat/sticky-column

feat: sticky primary column
pull/5094/head
navi 2 years ago committed by GitHub
parent
commit
930dfd5901
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      packages/nc-gui/components.d.ts
  2. 51
      packages/nc-gui/components/smartsheet/Grid.vue
  3. 11
      packages/nc-gui/components/smartsheet/header/Menu.vue
  4. 33
      packages/nc-gui/components/smartsheet/toolbar/FieldsMenu.vue
  5. 10
      packages/nc-gui/composables/useViewColumns.ts
  6. 2
      packages/nocodb/src/lib/Noco.ts
  7. 263
      packages/nocodb/src/lib/meta/api/baseApis.ts
  8. 3
      packages/nocodb/src/lib/meta/api/helpers/index.ts
  9. 276
      packages/nocodb/src/lib/meta/api/helpers/populateMeta.ts
  10. 263
      packages/nocodb/src/lib/meta/api/projectApis.ts
  11. 2
      packages/nocodb/src/lib/models/GridViewColumn.ts
  12. 12
      packages/nocodb/src/lib/models/Model.ts
  13. 122
      packages/nocodb/src/lib/models/View.ts
  14. 2
      packages/nocodb/src/lib/version-upgrader/NcUpgrader.ts
  15. 72
      packages/nocodb/src/lib/version-upgrader/ncStickyColumnUpgrader.ts
  16. 18
      tests/playwright/pages/Dashboard/Grid/Column/index.ts
  17. 4
      tests/playwright/pages/Dashboard/common/Toolbar/Fields.ts
  18. 2
      tests/playwright/quickTests/commonTest.ts
  19. 2
      tests/playwright/tests/cellSelection.spec.ts
  20. 2
      tests/playwright/tests/columnMenuOperations.spec.ts
  21. 6
      tests/playwright/tests/metaSync.spec.ts

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) {

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',

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

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

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

@ -0,0 +1,276 @@
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.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);
// 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;
}

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

@ -1,33 +1,24 @@
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 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';
const nanoid = customAlphabet('1234567890abcdefghijklmnopqrstuvwxyz_', 4);
@ -202,256 +193,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);
}

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

@ -606,6 +606,18 @@ 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;
}

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

@ -630,6 +630,25 @@ 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 +898,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 +1106,19 @@ 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 +1176,83 @@ 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';
const log = debug('nc:version-upgrader');
import boxen from 'boxen';
@ -41,6 +42,7 @@ export default class NcUpgrader {
{ name: '0100002', handler: ncFilterUpgrader },
{ name: '0101002', handler: ncAttachmentUpgrader },
{ name: '0104002', handler: ncAttachmentUpgrader_0104002 },
{ name: '0104003', handler: ncStickyColumnUpgrader },
];
if (!(await ctx.ncMeta.knexConnection?.schema?.hasTable?.('nc_store'))) {
return;

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

@ -0,0 +1,72 @@
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({

6
tests/playwright/tests/metaSync.spec.ts

@ -252,18 +252,18 @@ 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({
columnTitle: 'Col1',
columnTitle: 'Col2',
isAscending: false,
isLocallySaved: false,
});
await dashboard.grid.toolbar.clickFilter();
await dashboard.grid.toolbar.filter.add({
columnTitle: 'Col1',
columnTitle: 'Col2',
opType: '>=',
value: '5',
isLocallySaved: false,

Loading…
Cancel
Save