Browse Source

Merge branch 'develop' of https://github.com/nocodb/nocodb into geodata-prototyping-restart

pull/4749/head
flisowna 2 years ago
parent
commit
d4d8d2973b
  1. 14
      .github/workflows/release-nightly-dev.yml
  2. 1
      packages/nc-gui/components.d.ts
  3. 155
      packages/nc-gui/components/smartsheet/column/SelectOptions.vue
  4. 41
      packages/nc-gui/components/smartsheet/toolbar/FieldsMenu.vue
  5. 11
      packages/nc-gui/components/smartsheet/toolbar/ShareView.vue
  6. 15
      packages/nc-gui/composables/useKanbanViewStore.ts
  7. 30
      packages/nc-gui/composables/useSharedView.ts
  8. 10
      packages/nc-gui/composables/useViewData.ts
  9. 7
      packages/nc-gui/layouts/shared-view.vue
  10. 19
      packages/noco-docs/content/en/setup-and-usages/account-settings.md
  11. 18
      packages/nocodb-sdk/src/lib/Api.ts
  12. 4
      packages/nocodb-sdk/src/lib/sqlUi/MssqlUi.ts
  13. 14
      packages/nocodb/src/lib/meta/api/columnApis.ts
  14. 12
      packages/nocodb/src/lib/meta/api/publicApis/publicDataExportApis.ts
  15. 2
      packages/nocodb/src/lib/meta/api/publicApis/publicMetaApis.ts
  16. 36
      scripts/sdk/swagger.json
  17. 17
      tests/playwright/pages/Dashboard/Grid/Column/SelectOptionColumn.ts
  18. 7
      tests/playwright/tests/columnSingleSelect.spec.ts
  19. 26
      tests/playwright/tests/viewKanban.spec.ts

14
.github/workflows/release-nightly-dev.yml

@ -49,13 +49,13 @@ jobs:
NPM_TOKEN: "${{ secrets.NPM_TOKEN }}" NPM_TOKEN: "${{ secrets.NPM_TOKEN }}"
# Build executables and publish to GitHub # Build executables and publish to GitHub
release-executables: # release-executables:
needs: [set-tag, release-npm] # needs: [set-tag, release-npm]
uses: ./.github/workflows/release-timely-executables.yml # uses: ./.github/workflows/release-timely-executables.yml
with: # with:
tag: ${{ needs.set-tag.outputs.current_version }}-${{ needs.set-tag.outputs.nightly_build_tag }} # tag: ${{ needs.set-tag.outputs.current_version }}-${{ needs.set-tag.outputs.nightly_build_tag }}
secrets: # secrets:
NC_GITHUB_TOKEN: "${{ secrets.NC_GITHUB_TOKEN }}" # NC_GITHUB_TOKEN: "${{ secrets.NC_GITHUB_TOKEN }}"
# Build docker image and push to docker hub # Build docker image and push to docker hub
release-docker: release-docker:

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

@ -124,6 +124,7 @@ declare module '@vue/runtime-core' {
MdiArrowDownDropCircleOutline: typeof import('~icons/mdi/arrow-down-drop-circle-outline')['default'] MdiArrowDownDropCircleOutline: typeof import('~icons/mdi/arrow-down-drop-circle-outline')['default']
MdiArrowExpand: typeof import('~icons/mdi/arrow-expand')['default'] MdiArrowExpand: typeof import('~icons/mdi/arrow-expand')['default']
MdiArrowLeftBold: typeof import('~icons/mdi/arrow-left-bold')['default'] MdiArrowLeftBold: typeof import('~icons/mdi/arrow-left-bold')['default']
MdiArrowULeftBottom: typeof import('~icons/mdi/arrow-u-left-bottom')['default']
MdiAt: typeof import('~icons/mdi/at')['default'] MdiAt: typeof import('~icons/mdi/at')['default']
MdiBackburger: typeof import('~icons/mdi/backburger')['default'] MdiBackburger: typeof import('~icons/mdi/backburger')['default']
MdiBookOpenOutline: typeof import('~icons/mdi/book-open-outline')['default'] MdiBookOpenOutline: typeof import('~icons/mdi/book-open-outline')['default']

155
packages/nc-gui/components/smartsheet/column/SelectOptions.vue

@ -3,6 +3,14 @@ import Draggable from 'vuedraggable'
import { UITypes } from 'nocodb-sdk' import { UITypes } from 'nocodb-sdk'
import { IsKanbanInj, enumColor, onMounted, useColumnCreateStoreOrThrow, useVModel, watch } from '#imports' import { IsKanbanInj, enumColor, onMounted, useColumnCreateStoreOrThrow, useVModel, watch } from '#imports'
interface Option {
color: string
title: string
id?: string
fk_colum_id?: string
order?: number
}
const props = defineProps<{ const props = defineProps<{
value: any value: any
}>() }>()
@ -13,7 +21,10 @@ const vModel = useVModel(props, 'value', emit)
const { setAdditionalValidations, validateInfos, isPg, isMysql } = useColumnCreateStoreOrThrow() const { setAdditionalValidations, validateInfos, isPg, isMysql } = useColumnCreateStoreOrThrow()
let options = $ref<any[]>([]) let options = $ref<Option[]>([])
let renderedOptions = $ref<(Option & { status?: 'remove' })[]>([])
let savedDefaultOption = $ref<Option | null>(null)
let savedCdf = $ref<string | null>(null)
const colorMenus = $ref<any>({}) const colorMenus = $ref<any>({})
@ -58,6 +69,9 @@ onMounted(() => {
} }
} }
options = vModel.value.colOptions.options options = vModel.value.colOptions.options
renderedOptions = [...options]
// Support for older options // Support for older options
for (const op of options.filter((el) => el.order === null)) { for (const op of options.filter((el) => el.order === null)) {
op.title = op.title.replace(/^'/, '').replace(/'$/, '') op.title = op.title.replace(/^'/, '').replace(/'$/, '')
@ -87,13 +101,6 @@ const optionChanged = (changedId: string) => {
} }
} }
const optionDropped = (changedId: string) => {
if (changedId && changedId === defaultOption.value?.id) {
vModel.value.cdf = null
defaultOption.value = null
}
}
const getNextColor = () => { const getNextColor = () => {
let tempColor = colors[0] let tempColor = colors[0]
if (options.length && options[options.length - 1].color) { if (options.length && options[options.length - 1].color) {
@ -108,13 +115,40 @@ const addNewOption = () => {
title: '', title: '',
color: getNextColor(), color: getNextColor(),
} }
renderedOptions.push(tempOption)
options.push(tempOption) options.push(tempOption)
} }
const removeOption = (index: number) => { const syncOptions = () => {
const optionId = options[index]?.id vModel.value.colOptions.options = renderedOptions.filter((op) => op.status !== 'remove')
options.splice(index, 1) }
optionDropped(optionId)
const removeRenderedOption = (index: number) => {
renderedOptions[index].status = 'remove'
syncOptions()
const optionId = renderedOptions[index]?.id
if (optionId === defaultOption.value?.id) {
savedDefaultOption = { ...defaultOption.value }
savedCdf = vModel.value.cdf
defaultOption.value = null
vModel.value.cdf = null
}
}
const undoRemoveRenderedOption = (index: number) => {
renderedOptions[index].status = undefined
syncOptions()
const optionId = renderedOptions[index]?.id
if (optionId === savedDefaultOption?.id) {
defaultOption.value = { ...savedDefaultOption }
vModel.value.cdf = savedCdf
savedDefaultOption = null
savedCdf = null
}
} }
// focus last created input // focus last created input
@ -128,47 +162,62 @@ watch(inputs, () => {
<template> <template>
<div class="w-full"> <div class="w-full">
<div class="max-h-[250px] overflow-x-auto scrollbar-thin-dull pr-3"> <div class="max-h-[250px] overflow-x-auto scrollbar-thin-dull pr-3">
<Draggable :list="options" item-key="id" handle=".nc-child-draggable-icon"> <Draggable :list="renderedOptions" item-key="id" handle=".nc-child-draggable-icon" @change="syncOptions">
<template #item="{ element, index }"> <template #item="{ element, index }">
<div class="flex py-1 items-center nc-select-option"> <div class="flex p-1 items-center nc-select-option">
<MdiDragVertical <div
v-if="!isKanban" class="flex items-center w-full"
small :data-testid="`select-column-option-${index}`"
class="nc-child-draggable-icon handle" :class="{ removed: element.status === 'remove' }"
:data-testid="`select-option-column-handle-icon-${element.title}`"
/>
<a-dropdown
v-model:visible="colorMenus[index]"
:trigger="['click']"
overlay-class-name="nc-dropdown-select-color-options"
> >
<template #overlay> <MdiDragVertical
<LazyGeneralColorPicker v-if="!isKanban"
v-model="element.color" small
:pick-button="true" class="nc-child-draggable-icon handle"
@update:model-value="colorMenus[index] = false" :data-testid="`select-option-column-handle-icon-${element.title}`"
/>
<a-dropdown
v-model:visible="colorMenus[index]"
:trigger="['click']"
overlay-class-name="nc-dropdown-select-color-options"
>
<template #overlay>
<LazyGeneralColorPicker
v-model="element.color"
:pick-button="true"
@update:model-value="colorMenus[index] = false"
/>
</template>
<MdiArrowDownDropCircle
class="mr-2 text-[1.5em] outline-0 hover:!text-[1.75em]"
:class="{ 'text-[1.75em]': colorMenus[index] }"
:style="{ color: element.color }"
/> />
</template> </a-dropdown>
<MdiArrowDownDropCircle
class="mr-2 text-[1.5em] outline-0 hover:!text-[1.75em]" <a-input
:class="{ 'text-[1.75em]': colorMenus[index] }" ref="inputs"
:style="{ color: element.color }" v-model:value="element.title"
class="caption"
:data-testid="`select-column-option-input-${index}`"
:disabled="element.status === 'remove'"
@keydown.enter.prevent="element.title?.trim() && addNewOption()"
@change="optionChanged(element.id)"
/> />
</a-dropdown> </div>
<a-input
ref="inputs"
v-model:value="element.title"
class="caption"
:data-testid="`select-column-option-input-${index}`"
@keydown.enter.prevent="element.title?.trim() && addNewOption()"
@change="optionChanged(element.id)"
/>
<MdiClose <MdiClose
v-if="element.status !== 'remove'"
class="ml-2 hover:!text-black-500 text-gray-500 cursor-pointer" class="ml-2 hover:!text-black-500 text-gray-500 cursor-pointer"
:data-testid="`select-column-option-remove-${index}`" :data-testid="`select-column-option-remove-${index}`"
@click="removeOption(index)" @click="removeRenderedOption(index)"
/>
<MdiArrowULeftBottom
v-else
class="ml-2 hover:!text-black-500 text-gray-500 cursor-pointer"
:data-testid="`select-column-option-remove-undo-${index}`"
@click="undoRemoveRenderedOption(index)"
/> />
</div> </div>
</template> </template>
@ -186,3 +235,19 @@ watch(inputs, () => {
</a-button> </a-button>
</div> </div>
</template> </template>
<style scoped>
.removed {
position: relative;
}
.removed:after {
position: absolute;
left: 0;
top: 50%;
height: 1px;
background: #ccc;
content: '';
width: calc(100% + 5px);
display: block;
}
</style>

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

@ -84,11 +84,31 @@ const onMove = (_event: { moved: { newIndex: number } }) => {
$e('a:fields:reorder') $e('a:fields:reorder')
} }
const coverOptions = computed<SelectProps['options']>(() => {
const filterFields =
fields.value
?.filter((el) => el.fk_column_id && metaColumnById.value[el.fk_column_id].uidt === UITypes.Attachment)
.map((field) => {
return {
value: field.fk_column_id,
label: field.title,
}
}) ?? []
return [{ value: null, label: 'No Image' }, ...filterFields]
})
const coverImageColumnId = computed({ const coverImageColumnId = computed({
get: () => get: () => {
(activeView.value?.type === ViewTypes.GALLERY || activeView.value?.type === ViewTypes.KANBAN) && activeView.value?.view const fk_cover_image_col_id =
? (activeView.value?.view as GalleryType).fk_cover_image_col_id (activeView.value?.type === ViewTypes.GALLERY || activeView.value?.type === ViewTypes.KANBAN) && activeView.value?.view
: undefined, ? (activeView.value?.view as GalleryType).fk_cover_image_col_id
: undefined
// check if `fk_cover_image_col_id` is in `coverOptions`
// e.g. in share view, users may not share the cover image column
if (coverOptions.value?.find((o) => o.value === fk_cover_image_col_id)) return fk_cover_image_col_id
// set to `No Image`
return null
},
set: async (val) => { set: async (val) => {
if ( if (
(activeView.value?.type === ViewTypes.GALLERY || activeView.value?.type === ViewTypes.KANBAN) && (activeView.value?.type === ViewTypes.GALLERY || activeView.value?.type === ViewTypes.KANBAN) &&
@ -113,19 +133,6 @@ const coverImageColumnId = computed({
}, },
}) })
const coverOptions = computed<SelectProps['options']>(() => {
const filterFields =
fields.value
?.filter((el) => el.fk_column_id && metaColumnById.value[el.fk_column_id].uidt === UITypes.Attachment)
.map((field) => {
return {
value: field.fk_column_id,
label: field.title,
}
}) ?? []
return [{ value: null, label: 'No Image' }, ...filterFields]
})
const getIcon = (c: ColumnType) => const getIcon = (c: ColumnType) =>
h(isVirtualCol(c) ? resolveComponent('SmartsheetHeaderVirtualCellIcon') : resolveComponent('SmartsheetHeaderCellIcon'), { h(isVirtualCol(c) ? resolveComponent('SmartsheetHeaderVirtualCellIcon') : resolveComponent('SmartsheetHeaderCellIcon'), {
columnMeta: c, columnMeta: c,

11
packages/nc-gui/components/smartsheet/toolbar/ShareView.vue

@ -86,10 +86,17 @@ const genShareLink = async () => {
if (!view.value?.id) return if (!view.value?.id) return
const response = (await $api.dbViewShare.create(view.value.id)) as SharedView const response = (await $api.dbViewShare.create(view.value.id)) as SharedView
const meta = isString(response.meta) ? JSON.parse(response.meta) : response.meta const meta = isString(response.meta) ? JSON.parse(response.meta) : response.meta
shared.value = { ...response, meta } shared.value = { ...response, meta }
if (shared.value.type === ViewTypes.KANBAN) {
const { groupingFieldColumn } = useKanbanViewStoreOrThrow()
shared.value.meta = { ...shared.value.meta, groupingFieldColumn: groupingFieldColumn.value }
await updateSharedViewMeta(true)
}
passwordProtected.value = !!shared.value.password && shared.value.password !== '' passwordProtected.value = !!shared.value.password && shared.value.password !== ''
showShareModel = true showShareModel = true
@ -136,7 +143,7 @@ async function saveTheme() {
// const saveTransitionDuration = useDebounceFn(updateSharedViewMeta, 1000, { maxWait: 2000 }) // const saveTransitionDuration = useDebounceFn(updateSharedViewMeta, 1000, { maxWait: 2000 })
async function updateSharedViewMeta() { async function updateSharedViewMeta(silentMessage = false) {
try { try {
const meta = shared.value.meta && isString(shared.value.meta) ? JSON.parse(shared.value.meta) : shared.value.meta const meta = shared.value.meta && isString(shared.value.meta) ? JSON.parse(shared.value.meta) : shared.value.meta
@ -144,7 +151,7 @@ async function updateSharedViewMeta() {
meta, meta,
}) })
message.success(t('msg.success.updated')) if (!silentMessage) message.success(t('msg.success.updated'))
} catch (e: any) { } catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e)) message.error(await extractSdkResponseErrorMsg(e))
} }

15
packages/nc-gui/composables/useKanbanViewStore.ts

@ -112,7 +112,10 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
let res let res
if (isPublic.value) { if (isPublic.value) {
res = await fetchSharedViewGroupedData(groupingFieldColumn!.value!.id!) res = await fetchSharedViewGroupedData(groupingFieldColumn!.value!.id!, {
sortsArr: sorts.value,
filtersArr: nestedFilters.value,
})
} else { } else {
res = await api.dbViewRow.groupedDataList( res = await api.dbViewRow.groupedDataList(
'noco', 'noco',
@ -138,6 +141,7 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
if (stackTitle === null) { if (stackTitle === null) {
where = `(${groupingField.value},is,null)` where = `(${groupingField.value},is,null)`
} }
const response = !isPublic.value const response = !isPublic.value
? await api.dbViewRow.list('noco', project.value.id!, meta.value!.id!, viewMeta.value!.id!, { ? await api.dbViewRow.list('noco', project.value.id!, meta.value!.id!, viewMeta.value!.id!, {
...params, ...params,
@ -145,7 +149,7 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
...(isUIAllowed('filterSync') ? {} : { filterArrJson: JSON.stringify(nestedFilters.value) }), ...(isUIAllowed('filterSync') ? {} : { filterArrJson: JSON.stringify(nestedFilters.value) }),
where, where,
}) })
: await fetchSharedViewData({ sortsArr: sorts.value, filtersArr: nestedFilters.value }) : await fetchSharedViewData({ sortsArr: sorts.value, filtersArr: nestedFilters.value, offset: params.offset })
formattedData.value.set(stackTitle, [...formattedData.value.get(stackTitle)!, ...formatData(response.list)]) formattedData.value.set(stackTitle, [...formattedData.value.get(stackTitle)!, ...formatData(response.list)])
} }
@ -155,9 +159,12 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
kanbanMetaData.value = isPublic.value kanbanMetaData.value = isPublic.value
? (sharedView.value?.view as KanbanType) ? (sharedView.value?.view as KanbanType)
: await $api.dbView.kanbanRead(viewMeta.value.id) : await $api.dbView.kanbanRead(viewMeta.value.id)
// set groupingField // set groupingField
groupingFieldColumn.value = groupingFieldColumn.value = !isPublic.value
(meta.value.columns as ColumnType[]).filter((f) => f.id === kanbanMetaData.value.fk_grp_col_id)[0] || {} ? (meta.value.columns as ColumnType[]).filter((f) => f.id === kanbanMetaData.value.fk_grp_col_id)[0] || {}
: ((typeof sharedView.value?.meta === 'string' ? JSON.parse(sharedView.value?.meta) : sharedView.value?.meta)
.groupingFieldColumn! as ColumnType)
groupingField.value = groupingFieldColumn.value.title! groupingField.value = groupingFieldColumn.value.title!

30
packages/nc-gui/composables/useSharedView.ts

@ -70,6 +70,7 @@ export function useSharedView() {
meta.value = { ...viewMeta.model } meta.value = { ...viewMeta.model }
let order = 1 let order = 1
meta.value!.columns = [...viewMeta.model.columns] meta.value!.columns = [...viewMeta.model.columns]
.filter((c) => c.show) .filter((c) => c.show)
.map((c) => ({ ...c, order: order++ })) .map((c) => ({ ...c, order: order++ }))
@ -92,16 +93,27 @@ export function useSharedView() {
Object.keys(relatedMetas).forEach((key) => setMeta(relatedMetas[key])) Object.keys(relatedMetas).forEach((key) => setMeta(relatedMetas[key]))
} }
const fetchSharedViewData = async ({ sortsArr, filtersArr }: { sortsArr: SortType[]; filtersArr: FilterType[] }) => { const fetchSharedViewData = async ({
sortsArr,
filtersArr,
offset,
}: {
sortsArr: SortType[]
filtersArr: FilterType[]
offset?: number
}) => {
if (!sharedView.value) return if (!sharedView.value) return
const page = paginationData.value.page || 1 if (!offset) {
const pageSize = paginationData.value.pageSize || appInfoDefaultLimit const page = paginationData.value.page || 1
const pageSize = paginationData.value.pageSize || appInfoDefaultLimit
offset = (page - 1) * pageSize
}
const { data } = await $api.public.dataList( const { data } = await $api.public.dataList(
sharedView.value.uuid!, sharedView.value.uuid!,
{ {
offset: (page - 1) * pageSize, offset,
filterArrJson: JSON.stringify(filtersArr ?? nestedFilters.value), filterArrJson: JSON.stringify(filtersArr ?? nestedFilters.value),
sortArrJson: JSON.stringify(sortsArr ?? sorts.value), sortArrJson: JSON.stringify(sortsArr ?? sorts.value),
} as any, } as any,
@ -114,7 +126,10 @@ export function useSharedView() {
return data return data
} }
const fetchSharedViewGroupedData = async (columnId: string, params: Parameters<Api<any>['dbViewRow']['list']>[4] = {}) => { const fetchSharedViewGroupedData = async (
columnId: string,
{ sortsArr, filtersArr }: { sortsArr: SortType[]; filtersArr: FilterType[] },
) => {
if (!sharedView.value) return if (!sharedView.value) return
const page = paginationData.value.page || 1 const page = paginationData.value.page || 1
@ -125,9 +140,8 @@ export function useSharedView() {
columnId, columnId,
{ {
offset: (page - 1) * pageSize, offset: (page - 1) * pageSize,
filterArrJson: JSON.stringify(nestedFilters.value), filterArrJson: JSON.stringify(filtersArr ?? nestedFilters.value),
sortArrJson: JSON.stringify(sorts.value), sortArrJson: JSON.stringify(sortsArr ?? sorts.value),
...params,
} as any, } as any,
{ {
headers: { headers: {

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

@ -49,7 +49,9 @@ export function useViewData(
const { getMeta } = useMetas() const { getMeta } = useMetas()
const appInfoDefaultLimit = appInfo.defaultLimit || 25 const appInfoDefaultLimit = appInfo.defaultLimit || 25
const _paginationData = ref<PaginatedType>({ page: 1, pageSize: appInfoDefaultLimit }) const _paginationData = ref<PaginatedType>({ page: 1, pageSize: appInfoDefaultLimit })
const aggCommentCount = ref<{ row_id: string; count: number }[]>([]) const aggCommentCount = ref<{ row_id: string; count: number }[]>([])
const galleryData = ref<GalleryType>() const galleryData = ref<GalleryType>()
@ -64,7 +66,7 @@ export function useViewData(
const { project, isSharedBase } = useProject() const { project, isSharedBase } = useProject()
const { fetchSharedViewData, paginationData: sharedPaginationData } = useSharedView() const { sharedView, fetchSharedViewData, paginationData: sharedPaginationData } = useSharedView()
const { $api, $e } = useNuxtApp() const { $api, $e } = useNuxtApp()
@ -203,8 +205,10 @@ export function useViewData(
} }
async function loadGalleryData() { async function loadGalleryData() {
if (!viewMeta?.value?.id || isPublic.value) return if (!viewMeta?.value?.id) return
galleryData.value = await $api.dbView.galleryRead(viewMeta.value.id) galleryData.value = isPublic.value
? (sharedView.value?.view as GalleryType)
: await $api.dbView.galleryRead(viewMeta.value.id)
} }
async function insertRow( async function insertRow(

7
packages/nc-gui/layouts/shared-view.vue

@ -1,7 +1,10 @@
<script lang="ts" setup> <script lang="ts" setup>
import { navigateTo, useEventListener, useRouter } from '#imports' import { navigateTo, useEventListener, useRouter } from '#imports'
const { isLoading, currentVersion } = useGlobal()
const { isLoading, appInfo } = useGlobal()
const { sharedView } = useSharedView() const { sharedView } = useSharedView()
const router = useRouter() const router = useRouter()
onMounted(() => { onMounted(() => {
@ -43,7 +46,7 @@ export default {
<div class="transition-all duration-200 p-2 cursor-pointer transform hover:scale-105" @click="navigateTo('/')"> <div class="transition-all duration-200 p-2 cursor-pointer transform hover:scale-105" @click="navigateTo('/')">
<a-tooltip placement="bottom"> <a-tooltip placement="bottom">
<template #title> <template #title>
{{ currentVersion }} {{ appInfo.version }}
</template> </template>
<img width="35" alt="NocoDB" src="~/assets/img/icons/512x512-trans.png" /> <img width="35" alt="NocoDB" src="~/assets/img/icons/512x512-trans.png" />
</a-tooltip> </a-tooltip>

19
packages/noco-docs/content/en/setup-and-usages/account-settings.md

@ -27,14 +27,25 @@ If you are a super admin, you can also manage all user roles in organization lev
## User Management ## User Management
Super-admin has new privelege to do user management at root-level. Permissions within NocoDB are divided into two levels: Organisation level and Project level.
- `org-level-creator` - this user can create a new project and access any invited project. ### Organisation Level Permissions:
- `Org Level Creator`: Allows users to create new projects and access invited projects.
- `org-level-viewer` - this user can't create a new project but they can access any invited project. - `Org Level Viewer`: Allows users to access invited projects but does not permit the creation of new projects.
![image](https://user-images.githubusercontent.com/35857179/203261168-5ba75f9c-476e-4fe7-ace4-f81051f42773.png) ![image](https://user-images.githubusercontent.com/35857179/203261168-5ba75f9c-476e-4fe7-ace4-f81051f42773.png)
### Project Level Permissions:
- `Owner`: The user who created the project. A project can have only one owner. The owner persists until the project exists and the role is non-transferable. The owner has access to carry out any operations within the project, including deleting it.
- `Creator`: Has access to carry out any operations within the project except deleting the project and removing the "Owner."
- `Editor`: Can modify data but cannot modify the schema (add/remove columns, tables, users, and such).
- `Commenter`: Can neither modify data nor schema, can only see data and can mark row-level comments.
- `Viewer`: Can only see data.
Additional access details for project level permissions can be found [here](https://docs.nocodb.com/setup-and-usages/team-and-auth#advanced-options--configurations).
Please note that the above-mentioned Project Level Permissions are additional to the already defined Organisation Level Permissions.
In addition to the previously defined permissions, NocoDB also includes the role of "Super Admin." The "Super Admin" is the first user to sign up on this NocoDB installation. An organisation can have only one "Super Admin" and this role is non-transferable. The "Super Admin" will have the equivalent permissions of an "Org Level Creator" and "Owner" for all projects within the organisation.
## Enable / Disable Signup ## Enable / Disable Signup
Signup without an invitation is disabled by default and can be managed from UI by a super admin. Signup without an invitation is disabled by default and can be managed from UI by a super admin.

18
packages/nocodb-sdk/src/lib/Api.ts

@ -2292,6 +2292,7 @@ export class Api<
* *
* @tags DB table column * @tags DB table column
* @name Delete * @name Delete
* @summary Column Delete
* @request DELETE:/api/v1/db/meta/columns/{columnId} * @request DELETE:/api/v1/db/meta/columns/{columnId}
* @response `200` `void` OK * @response `200` `void` OK
*/ */
@ -2302,6 +2303,22 @@ export class Api<
...params, ...params,
}), }),
/**
* No description
*
* @tags DB Table Column
* @name Get
* @summary Column Get
* @request GET:/api/v1/db/meta/columns/{columnId}
* @response `200` `void` OK
*/
get: (columnId: string, params: RequestParams = {}) =>
this.request<void, any>({
path: `/api/v1/db/meta/columns/${columnId}`,
method: 'GET',
...params,
}),
/** /**
* No description * No description
* *
@ -3655,6 +3672,7 @@ export class Api<
where?: string; where?: string;
/** Query params for nested data */ /** Query params for nested data */
nested?: any; nested?: any;
offset?: number;
}, },
params: RequestParams = {} params: RequestParams = {}
) => ) =>

4
packages/nocodb-sdk/src/lib/sqlUi/MssqlUi.ts

@ -384,7 +384,7 @@ export class MssqlUi {
return ''; return '';
case 'varchar': case 'varchar':
return ''; return 255;
default: default:
return ''; return '';
@ -1098,7 +1098,7 @@ export class MssqlUi {
colProp.dt = 'varchar'; colProp.dt = 'varchar';
break; break;
case 'Date': case 'Date':
colProp.dt = 'varchar'; colProp.dt = 'date';
break; break;
case 'Year': case 'Year':

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

@ -101,6 +101,10 @@ async function createHmAndBtColumn(
} }
} }
export async function columnGet(req: Request, res: Response) {
res.json(await Column.get({ colId: req.params.columnId }));
}
export async function columnAdd( export async function columnAdd(
req: Request<any, any, ColumnReqType & { uidt: UITypes }>, req: Request<any, any, ColumnReqType & { uidt: UITypes }>,
res: Response<TableType> res: Response<TableType>
@ -1811,21 +1815,31 @@ async function createColumnIndex({
} }
const router = Router({ mergeParams: true }); const router = Router({ mergeParams: true });
router.post( router.post(
'/api/v1/db/meta/tables/:tableId/columns/', '/api/v1/db/meta/tables/:tableId/columns/',
metaApiMetrics, metaApiMetrics,
ncMetaAclMw(columnAdd, 'columnAdd') ncMetaAclMw(columnAdd, 'columnAdd')
); );
router.patch( router.patch(
'/api/v1/db/meta/columns/:columnId', '/api/v1/db/meta/columns/:columnId',
metaApiMetrics, metaApiMetrics,
ncMetaAclMw(columnUpdate, 'columnUpdate') ncMetaAclMw(columnUpdate, 'columnUpdate')
); );
router.delete( router.delete(
'/api/v1/db/meta/columns/:columnId', '/api/v1/db/meta/columns/:columnId',
metaApiMetrics, metaApiMetrics,
ncMetaAclMw(columnDelete, 'columnDelete') ncMetaAclMw(columnDelete, 'columnDelete')
); );
router.get(
'/api/v1/db/meta/columns/:columnId',
metaApiMetrics,
ncMetaAclMw(columnGet, 'columnGet')
);
router.post( router.post(
'/api/v1/db/meta/columns/:columnId/primary', '/api/v1/db/meta/columns/:columnId/primary',
metaApiMetrics, metaApiMetrics,

12
packages/nocodb/src/lib/meta/api/publicApis/publicDataExportApis.ts

@ -28,16 +28,26 @@ async function exportExcel(req: Request, res: Response) {
} }
const model = await view.getModelWithInfo(); const model = await view.getModelWithInfo();
await view.getColumns(); await view.getColumns();
const { offset, dbRows, elapsed } = await getDbRows(model, view, req); const { offset, dbRows, elapsed } = await getDbRows(model, view, req);
const fields = req.query.fields as string[]; const fields = req.query.fields as string[];
const data = XLSX.utils.json_to_sheet(dbRows, { header: fields }); const data = XLSX.utils.json_to_sheet(
dbRows.map((o: Record<string, any>) =>
Object.fromEntries(fields.map((f) => [f, o[f]]))
),
{ header: fields }
);
const wb = XLSX.utils.book_new(); const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, data, view.title); XLSX.utils.book_append_sheet(wb, data, view.title);
const buf = XLSX.write(wb, { type: 'base64', bookType: 'xlsx' }); const buf = XLSX.write(wb, { type: 'base64', bookType: 'xlsx' });
res.set({ res.set({
'Access-Control-Expose-Headers': 'nc-export-offset', 'Access-Control-Expose-Headers': 'nc-export-offset',
'nc-export-offset': offset, 'nc-export-offset': offset,

2
packages/nocodb/src/lib/meta/api/publicApis/publicMetaApis.ts

@ -39,8 +39,6 @@ export async function viewMetaGet(req: Request, res: Response) {
// todo: return only required props // todo: return only required props
delete view['password']; delete view['password'];
// const columnsById = c;
view.model.columns = view.columns view.model.columns = view.columns
.filter((c) => { .filter((c) => {
const column = view.model.columnsById[c.fk_column_id]; const column = view.model.columnsById[c.fk_column_id];

36
scripts/sdk/swagger.json

@ -1972,8 +1972,7 @@
"project_id": { "project_id": {
"type": "string" "type": "string"
}, },
"meta": { "meta": {}
}
} }
} }
} }
@ -2105,7 +2104,7 @@
] ]
}, },
"delete": { "delete": {
"summary": "", "summary": "Column Delete",
"operationId": "db-table-column-delete", "operationId": "db-table-column-delete",
"responses": { "responses": {
"200": { "200": {
@ -2115,6 +2114,18 @@
"tags": [ "tags": [
"DB table column" "DB table column"
] ]
},
"get": {
"summary": "Column Get",
"operationId": "db-table-column-get",
"responses": {
"200": {
"description": "OK"
}
},
"tags": [
"DB Table Column"
]
} }
}, },
"/api/v1/db/meta/columns/{columnId}/primary": { "/api/v1/db/meta/columns/{columnId}/primary": {
@ -2194,8 +2205,7 @@
"order": { "order": {
"type": "number" "type": "number"
}, },
"meta": { "meta": {},
},
"title": { "title": {
"type": "string" "type": "string"
}, },
@ -3970,6 +3980,13 @@
"in": "query", "in": "query",
"name": "nested", "name": "nested",
"description": "Query params for nested data" "description": "Query params for nested data"
},
{
"schema": {
"type": "number"
},
"in": "query",
"name": "offset"
} }
], ],
"responses": { "responses": {
@ -7585,8 +7602,7 @@
"number" "number"
] ]
}, },
"meta": { "meta": {}
}
}, },
"required": [ "required": [
"table_name", "table_name",
@ -7684,8 +7700,7 @@
"uuid": { "uuid": {
"type": "string" "type": "string"
}, },
"meta": { "meta": {},
},
"show_system_fields": { "show_system_fields": {
"type": "boolean" "type": "boolean"
}, },
@ -7931,8 +7946,7 @@
"$ref": "#/components/schemas/Column" "$ref": "#/components/schemas/Column"
} }
}, },
"meta": { "meta": {}
}
}, },
"required": [ "required": [
"table_name", "table_name",

17
tests/playwright/pages/Dashboard/Grid/Column/SelectOptionColumn.ts

@ -1,5 +1,6 @@
import { ColumnPageObject } from '.'; import { ColumnPageObject } from '.';
import BasePage from '../../../Base'; import BasePage from '../../../Base';
import { expect } from '@playwright/test';
export class SelectOptionColumnPageObject extends BasePage { export class SelectOptionColumnPageObject extends BasePage {
readonly column: ColumnPageObject; readonly column: ColumnPageObject;
@ -49,6 +50,22 @@ export class SelectOptionColumnPageObject extends BasePage {
await this.column.get().locator(`svg[data-testid="select-column-option-remove-${index}"]`).click(); await this.column.get().locator(`svg[data-testid="select-column-option-remove-${index}"]`).click();
await expect(this.column.get().getByTestId(`select-column-option-${index}`)).toHaveClass(/removed/);
await this.column.save({ isUpdated: true });
}
async deleteOptionWithUndo({ columnTitle, index }: { index: number; columnTitle: string }) {
await this.column.openEdit({ title: columnTitle });
await this.column.get().locator(`svg[data-testid="select-column-option-remove-${index}"]`).click();
await expect(this.column.get().getByTestId(`select-column-option-${index}`)).toHaveClass(/removed/);
await this.column.get().locator(`svg[data-testid="select-column-option-remove-undo-${index}"]`).click();
await expect(this.column.get().getByTestId(`select-column-option-${index}`)).not.toHaveClass(/removed/);
await this.column.save({ isUpdated: true }); await this.column.save({ isUpdated: true });
} }

7
tests/playwright/tests/columnSingleSelect.spec.ts

@ -53,6 +53,13 @@ test.describe('Single select', () => {
await grid.cell.selectOption.select({ index: 0, columnHeader: 'SingleSelect', option: 'Option 3' }); await grid.cell.selectOption.select({ index: 0, columnHeader: 'SingleSelect', option: 'Option 3' });
await grid.cell.selectOption.verify({ index: 0, columnHeader: 'SingleSelect', option: 'Option 3' }); await grid.cell.selectOption.verify({ index: 0, columnHeader: 'SingleSelect', option: 'Option 3' });
await grid.column.selectOption.deleteOptionWithUndo({ index: 0, columnTitle: 'SingleSelect' });
await grid.cell.selectOption.verifyOptions({
index: 0,
columnHeader: 'SingleSelect',
options: ['Option 1', 'Option 2', 'Option 3'],
});
await grid.column.selectOption.deleteOption({ index: 2, columnTitle: 'SingleSelect' }); await grid.column.selectOption.deleteOption({ index: 2, columnTitle: 'SingleSelect' });
await grid.cell.selectOption.verifyNoOptionsSelected({ index: 0, columnHeader: 'SingleSelect' }); await grid.cell.selectOption.verifyNoOptionsSelected({ index: 0, columnHeader: 'SingleSelect' });

26
tests/playwright/tests/viewKanban.spec.ts

@ -301,4 +301,30 @@ test.describe('View', () => {
count: [1, 25, 25, 25, 25, 25], count: [1, 25, 25, 25, 25, 25],
}); });
}); });
test('Kanban shared view operations', async ({ page }) => {
test.slow();
await dashboard.viewSidebar.createKanbanView({
title: 'Film Kanban',
});
await dashboard.viewSidebar.verifyView({
title: 'Film Kanban',
index: 1,
});
// Share view
await toolbar.fields.toggle({ title: 'Rating' });
await toolbar.clickShareView();
const sharedLink = await toolbar.shareView.getShareLink();
await toolbar.shareView.close();
// sign-out
await dashboard.signOut();
// Open shared view & verify stack count
await page.goto(sharedLink);
const kanban = dashboard.kanban;
await kanban.verifyStackCount({ count: 6 });
});
}); });

Loading…
Cancel
Save